1use techne_lexicon_check::{current_name, dag_cbor, has_public_home, is_record_type, record_key, record_types};
36use serde::{Deserialize, Serialize};
37use serde_json::Value;
38
39#[derive(Debug, Deserialize)]
41pub struct Record {
42 pub record: Value,
43 #[serde(default)]
44 pub rkey: Option<String>,
45 #[serde(default)]
46 pub space: Option<String>,
47}
48
49#[derive(Debug, Serialize, PartialEq)]
51pub struct Write {
52 pub uri: String,
53 pub cid: String,
54 pub collection: String,
55 pub rkey: String,
56 pub record: Value,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub space: Option<String>,
59 pub private: bool,
61 pub lexicon: bool,
65}
66
67#[derive(Debug, Deserialize)]
70pub struct DeleteRecord {
71 #[serde(default)]
72 pub uri: Option<String>,
73 #[serde(default)]
74 pub collection: Option<String>,
75 #[serde(default)]
76 pub rkey: Option<String>,
77 #[serde(default)]
78 pub space: Option<String>,
79}
80
81#[derive(Debug, Serialize, PartialEq)]
83pub struct Delete {
84 pub uri: String,
85 pub collection: String,
86 pub rkey: String,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub space: Option<String>,
89 pub private: bool,
91}
92
93#[derive(Debug, Serialize, PartialEq)]
97pub struct Plan {
98 pub writes: Vec<Write>,
99 pub deletes: Vec<Delete>,
100}
101
102#[cfg(not(target_arch = "wasm32"))]
105pub fn plan_with_deletes(repo: &str, records: Vec<Record>, deletes: Vec<DeleteRecord>) -> Result<Plan, String> {
106 plan_with_deletes_at(repo, records, deletes, unix_now())
107}
108
109pub fn plan_with_deletes_at(repo: &str, records: Vec<Record>, deletes: Vec<DeleteRecord>, now_unix: u64) -> Result<Plan, String> {
111 let writes = plan_at(repo, records, now_unix)?;
112 let mut deletes = deletes.into_iter().map(|d| delete(repo, d)).collect::<Result<Vec<_>, _>>()?;
113 if let Some(both) = deletes.iter().find(|d| writes.iter().any(|w| w.uri == d.uri)) {
114 return Err(format!("{}: both written and deleted in one put", both.uri));
115 }
116 if let Some((_, twice)) = deletes.iter().enumerate().find(|(i, d)| deletes[..*i].iter().any(|e| e.uri == d.uri)) {
117 return Err(format!("{}: deleted twice in one put", twice.uri));
118 }
119 deletes.sort_by_key(|d| d.private);
120 Ok(Plan { writes, deletes })
121}
122
123fn is_nsid(s: &str) -> bool {
125 let segments: Vec<&str> = s.split('.').collect();
126 segments.len() >= 3 && segments.iter().all(|g| !g.is_empty() && g.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
127}
128
129fn is_rkey(s: &str) -> bool {
131 (1..=512).contains(&s.len())
132 && s != "."
133 && s != ".."
134 && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '~' | '-'))
135}
136
137fn delete(repo: &str, DeleteRecord { uri, collection, rkey, space }: DeleteRecord) -> Result<Delete, String> {
138 let from_uri = |uri: &str| -> Result<(String, String), String> {
139 let rest = uri.strip_prefix("at://").ok_or_else(|| format!("{uri}: not an at:// URI"))?;
140 let mut parts = rest.split('/');
141 match (parts.next(), parts.next(), parts.next(), parts.next()) {
142 (Some(authority), Some(c), Some(k), None) => {
143 if authority != repo {
144 return Err(format!("{uri}: not in {repo}'s repo; put deletes only the writer's own records (by DID)"));
145 }
146 Ok((c.to_owned(), k.to_owned()))
147 }
148 _ => Err(format!("{uri}: expected at://{{repo}}/{{collection}}/{{rkey}}")),
149 }
150 };
151 let (collection, rkey) = match (uri, collection, rkey) {
152 (Some(uri), None, None) => from_uri(&uri)?,
153 (None, Some(c), Some(k)) => (c, k),
154 (Some(uri), Some(c), Some(k)) => {
156 if from_uri(&uri)? != (c.clone(), k.clone()) {
157 return Err(format!("{uri}: its uri names another key than {c}/{k}"));
158 }
159 (c, k)
160 }
161 _ => return Err("a delete names its uri, or its collection and rkey".to_owned()),
162 };
163 if !is_nsid(&collection) {
164 return Err(format!("{collection}: not a collection NSID"));
165 }
166 if !is_rkey(&rkey) {
167 return Err(format!("{rkey}: not a record key"));
168 }
169 if let Some(space) = &space {
170 if !space.starts_with("at://") {
171 return Err(format!("{space}: a space is an at:// URI"));
172 }
173 }
174 Ok(Delete {
175 uri: format!("at://{repo}/{collection}/{rkey}"),
176 private: space.is_some() || (is_ours(&collection) && !has_public_home(current_name(&collection))),
178 collection,
179 rkey,
180 space,
181 })
182}
183
184#[cfg(not(target_arch = "wasm32"))]
187pub fn plan(repo: &str, records: Vec<Record>) -> Result<Vec<Write>, String> {
188 plan_at(repo, records, unix_now())
189}
190
191pub fn plan_at(repo: &str, records: Vec<Record>, now_unix: u64) -> Result<Vec<Write>, String> {
194 let mut writes = records.into_iter().map(|r| write(repo, r, now_unix)).collect::<Result<Vec<_>, _>>()?;
195 writes.sort_by_key(|w| !w.private);
196 Ok(writes)
197}
198
199#[cfg(not(target_arch = "wasm32"))]
203fn unix_now() -> u64 {
204 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
205}
206
207fn write(repo: &str, Record { record, rkey, space }: Record, now_unix: u64) -> Result<Write, String> {
208 let collection = record.get("$type").and_then(Value::as_str).ok_or("a record without a $type")?.to_owned();
209 let cid = dag_cbor::cid_for_json(&record).map_err(|e| format!("{collection}: {}", e.0))?;
210 let current = current_name(&collection);
213 let rkey = match (rkey, record_key(current).and_then(|k| k.strip_prefix("literal:"))) {
214 (Some(rkey), _) => rkey,
215 (None, Some(literal)) => literal.to_owned(),
216 (None, None) => {
217 let created = record.get("createdAt").and_then(Value::as_str).ok_or_else(|| {
218 format!("{collection}: no rkey given, and no createdAt to derive one from")
219 })?;
220 let at = chrono::DateTime::parse_from_rfc3339(created).map_err(|e| format!("{collection}: createdAt: {e}"))?;
221 let digest = cid.hash().digest();
222 techne_lexicon_check::tid(at.timestamp_micros(), u16::from_be_bytes([digest[0], digest[1]]))
223 }
224 };
225 Ok(Write {
226 uri: format!("at://{repo}/{collection}/{rkey}"),
227 cid: cid.to_string(),
228 private: space.is_some() || (is_ours(&collection) && !has_public_home(current)),
229 lexicon: is_record_type(&collection)
232 || crate::renames::current_name_at(&collection, now_unix).is_some_and(is_record_type),
233 collection,
234 rkey,
235 record,
236 space,
237 })
238}
239
240fn namespace(nsid: &str) -> Option<String> {
243 let mut segments = nsid.splitn(3, '.');
244 let (a, b) = (segments.next()?, segments.next()?);
245 segments.next()?;
246 Some(format!("{a}.{b}").to_ascii_lowercase())
247}
248
249fn private_namespaces() -> &'static [String] {
253 static NAMESPACES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
254 NAMESPACES.get_or_init(|| {
255 let mut out: Vec<String> =
256 record_types().into_iter().filter(|c| !has_public_home(c)).filter_map(namespace).collect();
257 out.sort();
258 out.dedup();
259 let mut old: Vec<String> = crate::renames::pairs()
260 .iter()
261 .filter(|p| namespace(&p.new).is_some_and(|ns| out.contains(&ns)))
262 .filter_map(|p| namespace(&p.old))
263 .collect();
264 out.append(&mut old);
265 out.sort();
266 out.dedup();
267 out
268 })
269}
270
271fn is_ours(collection: &str) -> bool {
276 if techne_lexicon_check::runtime::community::is_community(collection) {
281 return false;
282 }
283 is_record_type(current_name(collection)) || namespace(collection).is_some_and(|ns| private_namespaces().contains(&ns))
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use serde_json::json;
290
291 const RENAMES_JSON: &str = crate::renames::JSON;
293
294 const AT: &str = "2026-10-19T12:00:00.000Z";
295 const NOW: u64 = 1_792_411_200;
298
299 fn one(collection: &str, space: Option<&str>) -> Write {
300 let record = json!({"$type": collection, "text": "Honest walk", "createdAt": AT});
301 plan_at("did:plc:alice", vec![Record { record, rkey: None, space: space.map(str::to_owned) }], NOW).unwrap().remove(0)
302 }
303
304 fn private_and_public_types() -> (&'static str, &'static str) {
306 let private = record_types().into_iter().find(|c| !has_public_home(c)).expect("a private type");
307 let ns = namespace(private);
308 let public = record_types().into_iter().find(|c| has_public_home(c) && namespace(c) == ns).expect("a public type");
309 (private, public)
310 }
311
312 #[test]
313 fn a_community_draft_is_public_unless_a_space_is_named() {
314 let c = "pub.techne.draft.c.abcdefghi7.toolListing";
315 assert!(!one(c, None).private, "{c} goes to the user's repo");
316 assert!(one(c, Some("at://did:plc:alice/pub.techne.draft.space.private/k")).private, "{c} in a space");
317 assert!(one("pub.techne.draft.notAType", None).private);
319 assert!(one("pub.techne.draft.cx.thing", None).private);
320 }
321
322 #[test]
323 fn a_name_that_only_looks_like_a_community_draft_stays_private() {
324 for c in [
327 "pub.techne.draft.c.r8fd.toolListing", "pub.techne.draft.c.7bcdefghi.toolListing", "Pub.Techne.Draft.C.abcdefghi7.toolListing", "pub.techne.draft.c.ABCDEFGHI7.toolListing", "pub.techne.draft.c.abcdefghi7", ] {
333 assert!(!techne_lexicon_check::runtime::community::is_community(c), "{c}");
334 assert!(one(c, None).private, "{c} stays private");
335 }
336 }
337
338 #[test]
339 fn a_record_without_a_type_or_anything_to_key_it_by_is_refused() {
340 let repo = "did:plc:alice";
341 assert!(plan(repo, vec![Record { record: json!({"name": "x"}), rkey: None, space: None }]).is_err());
342 let bare = json!({"$type": "x.example.thing", "name": "x"});
343 let err = plan(repo, vec![Record { record: bare.clone(), rkey: None, space: None }]).unwrap_err();
344 assert!(err.contains("no createdAt"), "{err}");
345 assert_eq!(plan(repo, vec![Record { record: bare, rkey: Some("k".into()), space: None }]).unwrap()[0].rkey, "k");
346 }
347
348 fn del(uri: Option<&str>, collection: Option<&str>, rkey: Option<&str>, space: Option<&str>) -> DeleteRecord {
349 let s = |v: Option<&str>| v.map(str::to_owned);
350 DeleteRecord { uri: s(uri), collection: s(collection), rkey: s(rkey), space: s(space) }
351 }
352
353 #[test]
354 fn deletes_are_placed_like_writes_in_the_order_they_go_out() {
355 let repo = "did:plc:alice";
356 let event = "community.lexicon.calendar.event";
357 let space = "at://did:plc:alice/pub.techne.draft.space.event.detail/k1";
358 let p = plan_with_deletes(
359 repo,
360 vec![],
361 vec![
362 del(Some(&format!("at://{repo}/{event}/k1")), None, None, None),
363 del(None, Some("pub.techne.draft.event.detail"), Some("k1"), Some(space)),
364 del(None, Some("com.example.thirdparty.note"), Some("k2"), None),
365 ],
366 )
367 .unwrap();
368 assert!(p.writes.is_empty());
369 assert_eq!(
370 p.deletes.iter().map(|d| (d.collection.as_str(), d.private)).collect::<Vec<_>>(),
371 [(event, false), ("com.example.thirdparty.note", false), ("pub.techne.draft.event.detail", true)]
372 );
373 assert_eq!(p.deletes[0].uri, format!("at://{repo}/{event}/k1"));
374 assert_eq!(p.deletes[2].space.as_deref(), Some(space));
375 }
376
377 #[test]
378 fn a_delete_outside_the_repo_or_of_a_key_also_written_or_without_a_key_is_refused() {
379 let repo = "did:plc:alice";
380 let event = json!({"$type": "community.lexicon.calendar.event", "name": "x", "startsAt": "2026-10-25T18:00:00.000Z", "createdAt": "2026-10-19T12:00:00.000Z"});
381 let err = plan_with_deletes(repo, vec![], vec![del(Some("at://did:plc:bob/community.lexicon.calendar.event/k"), None, None, None)]).unwrap_err();
382 assert!(err.contains("not in did:plc:alice's repo"), "{err}");
383 let both = plan_with_deletes(
384 repo,
385 vec![Record { record: event, rkey: Some("k".into()), space: None }],
386 vec![del(None, Some("community.lexicon.calendar.event"), Some("k"), None)],
387 )
388 .unwrap_err();
389 assert!(both.contains("both written and deleted"), "{both}");
390 assert!(plan_with_deletes(repo, vec![], vec![del(None, Some("x.y.z"), None, None)]).is_err());
391 assert!(plan_with_deletes(repo, vec![], vec![del(Some("at://did:plc:alice/x.y.z"), None, None, None)]).is_err());
392 let ev = "community.lexicon.calendar.event";
393 let twice = plan_with_deletes(repo, vec![], vec![del(None, Some(ev), Some("k"), None), del(Some(&format!("at://{repo}/{ev}/k")), None, None, Some("at://did:plc:alice/x.y.space/k"))]).unwrap_err();
394 assert!(twice.contains("deleted twice"), "{twice}");
395 for (uri, c, k, space) in [
396 (Some("at://did:plc:alice/x.y.z/k?q"), None, None, None),
397 (Some("at://did:plc:alice/x.y.z/k#f"), None, None, None),
398 (None, Some("x.y/z"), Some("k"), None),
399 (None, Some("x.y.z"), Some(".."), None),
400 (None, Some("x.y.z"), Some("k"), Some("")),
401 (Some("at://alice.test/x.y.z/k"), None, None, None),
402 (Some("at://did:plc:alice/x.y.z/other"), Some("x.y.z"), Some("k"), None),
403 ] {
404 assert!(plan_with_deletes(repo, vec![], vec![del(uri, c, k, space)]).is_err(), "{uri:?} {c:?} {k:?} {space:?}");
405 }
406 let back = plan_with_deletes(repo, vec![], vec![del(Some(&format!("at://{repo}/{ev}/k")), Some(ev), Some("k"), None)]).unwrap();
408 assert_eq!(back.deletes[0].uri, format!("at://{repo}/{ev}/k"));
409 }
410
411 #[test]
414 fn a_type_with_no_lexicon_goes_to_the_users_pds() {
415 assert!(!one("com.example.thirdparty.note", None).private, "no lexicon: the user's PDS");
416 let space = "at://did:plc:alice/com.example.space/self";
417 assert!(one("com.example.thirdparty.note", Some(space)).private, "a record in a space is private whatever its type");
418 let (private, public) = private_and_public_types();
419 assert!(one(private, None).private, "{private}: a lexicon type with no public home stays private");
420 assert!(!one(public, None).private, "{public}: a lexicon type with a public home is public");
421 }
422
423 #[test]
427 fn the_plan_says_whether_the_platform_has_a_lexicon_for_the_type() {
428 let (private, public) = private_and_public_types();
429 assert!(one(private, None).lexicon && one(public, None).lexicon);
430 assert!(!one("com.example.thirdparty.note", None).lexicon, "another app's own type");
431 assert!(!one(&format!("{private}x"), None).lexicon, "a typo in the platform's namespace");
432 }
433
434 #[test]
438 fn an_unknown_type_in_the_platforms_namespace_stays_private() {
439 let (private, public) = private_and_public_types();
440 let ns = namespace(private).unwrap();
441 let old_name = |c: &str| c.replacen(".draft.", ".", 1);
442 let unknown = [
443 format!("{private}x"),
444 old_name(private),
445 old_name(public),
446 private.to_ascii_uppercase(),
447 public.to_ascii_uppercase(),
448 ];
449 for collection in &unknown {
450 assert!(!is_record_type(collection), "{collection} has no lexicon");
451 assert!(one(collection, None).private, "{collection}: an unknown name in {ns} stays private");
452 }
453 let neighbour = format!("{ns}x.note");
454 assert!(!one(&neighbour, None).private, "{neighbour}: only the namespace itself");
455 }
456
457
458 fn renames() -> Vec<(String, String)> {
460 crate::renames::pairs().iter().map(|p| (p.old.clone(), p.new.clone())).collect()
461 }
462
463 #[test]
469 fn renames_copy_is_the_platforms() {
470 let input = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../inputs/platform-api/nsid_renames.json");
471 if let Ok(bytes) = std::fs::read_to_string(&input) {
472 assert_eq!(bytes, RENAMES_JSON, "crates/techne-sdk/src/nsid_renames.json differs from {}: run scripts/inputs-sync.mjs", input.display());
473 }
474 let pairs = renames();
475 assert!(!pairs.is_empty());
476 for (old, new) in &pairs {
477 assert_eq!(current_name(old), new, "the check renames {old} otherwise");
478 }
479 let map: std::collections::BTreeMap<&str, &str> = pairs.iter().map(|(o, n)| (o.as_str(), n.as_str())).collect();
480 let currents: std::collections::BTreeSet<String> =
481 record_types().into_iter().map(str::to_owned).chain(pairs.iter().map(|(_, n)| n.clone())).collect();
482 let mut asked = 0;
483 for current in ¤ts {
484 let tail = current.strip_prefix("pub.techne.draft.").or_else(|| current.strip_prefix("social.scenius."));
485 let candidates = tail
486 .into_iter()
487 .flat_map(|t| [format!("coop.lexicon.draft.{t}"), format!("coop.lexicon.{t}"), format!("social.scenius.{t}")]);
488 for name in candidates.chain(std::iter::once(current.clone())) {
489 asked += 1;
490 let answered = current_name(&name);
491 if answered != name {
492 assert_eq!(map.get(name.as_str()).copied(), Some(answered), "the check renames {name} to {answered}; this copy does not");
493 }
494 }
495 }
496 assert!(asked > pairs.len(), "the reverse direction asked about {asked} names");
497 }
498
499 #[test]
502 fn an_old_name_has_a_lexicon_only_while_its_rename_is_honoured() {
503 let doc: Value = serde_json::from_str(RENAMES_JSON).unwrap();
504 let day = |d: &str| chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").unwrap();
505 let at = |d: chrono::NaiveDate, h, m, s| u64::try_from(d.and_hms_opt(h, m, s).unwrap().and_utc().timestamp()).unwrap();
506 let early: Vec<(chrono::NaiveDate, String)> = doc["closeEarlier"]
507 .as_object()
508 .unwrap()
509 .iter()
510 .flat_map(|(d, olds)| olds.as_array().unwrap().iter().map(move |o| (day(d), o.as_str().unwrap().to_owned())))
511 .collect();
512 let last_of = |old: &str| early.iter().find(|(_, o)| o == old).map_or_else(|| day(doc["deleteAfter"].as_str().unwrap()), |(d, _)| *d);
513 let (mut early_seen, mut late_seen) = (false, false);
514 for (old, new) in renames() {
515 if !is_record_type(&new) || is_record_type(&old) {
516 continue;
517 }
518 let last = last_of(&old);
519 early_seen |= last != day(doc["deleteAfter"].as_str().unwrap());
520 late_seen |= last == day(doc["deleteAfter"].as_str().unwrap());
521 let lexicon = |now| {
522 let record = json!({"$type": old, "text": "Honest walk", "createdAt": AT});
523 plan_at("did:plc:alice", vec![Record { record, rkey: Some("k".into()), space: None }], now).unwrap()[0].lexicon
524 };
525 assert!(lexicon(at(last, 23, 59, 59)), "{old}: a lexicon through {last}");
526 assert!(!lexicon(at(last + chrono::Days::new(1), 0, 0, 0)), "{old}: no lexicon after {last}");
527 let record = json!({"$type": new, "text": "Honest walk", "createdAt": AT});
528 assert!(plan_at("did:plc:alice", vec![Record { record, rkey: Some("k".into()), space: None }], u64::MAX / 2).unwrap()[0].lexicon);
529 }
530 assert!(early_seen && late_seen, "both kinds of window are checked");
531 }
532
533 #[test]
538 fn an_old_name_is_placed_as_the_type_it_was_renamed_to() {
539 let (mut public, mut private, mut literal) = (0, 0, 0);
540 for (old, _) in renames() {
541 let current = current_name(&old);
542 if old == current || !is_record_type(current) {
543 continue;
544 }
545 let w = one(&old, None);
546 assert_eq!(w.private, !has_public_home(current), "{old} -> {current}");
547 assert!(w.lexicon, "{old}: the platform has {current}");
548 assert_eq!(w.collection, old, "written under the name given");
549 if let Some(key) = record_key(current).and_then(|k| k.strip_prefix("literal:")) {
550 assert_eq!(w.rkey, key, "{old}: {current}'s key");
551 literal += 1;
552 }
553 if w.private { private += 1 } else { public += 1 }
554 }
555 assert!(public > 0 && private > 0 && literal > 0, "public {public}, private {private}, literal {literal}");
556 }
557
558 #[test]
562 fn a_delete_under_an_old_name_is_placed_as_the_type_it_was_renamed_to() {
563 let repo = "did:plc:alice";
564 let (mut public, mut private) = (0, 0);
565 for (old, _) in renames() {
566 let current = current_name(&old);
567 if old == current || !is_record_type(current) {
568 continue;
569 }
570 let p = plan_with_deletes(repo, vec![], vec![del(None, Some(&old), Some("k1"), None)]).unwrap();
571 let d = &p.deletes[0];
572 assert_eq!(d.private, !has_public_home(current), "{old} -> {current}");
573 assert_eq!((d.collection.as_str(), d.uri.clone()), (old.as_str(), format!("at://{repo}/{old}/k1")), "deleted under the name given");
574 if d.private { private += 1 } else { public += 1 }
575 }
576 assert!(public > 0 && private > 0, "public {public}, private {private}");
577 }
578
579 #[test]
582 fn an_unknown_name_in_the_old_namespace_stays_private() {
583 let renames = renames();
584 let old = |public: bool| {
585 renames
586 .iter()
587 .find(|(o, n)| o != n && is_record_type(n) && has_public_home(n) == public && namespace(o) != namespace(n))
588 .map(|(o, _)| o.clone())
589 .expect("a renamed type")
590 };
591 for old in [old(true), old(false)] {
592 for collection in [format!("{old}x"), old.to_ascii_uppercase()] {
593 assert!(one(&collection, None).private, "{collection}: an unknown name in {:?}", namespace(&old));
594 let p = plan_with_deletes("did:plc:alice", vec![], vec![del(None, Some(&collection), Some("k1"), None)]).unwrap();
595 assert!(p.deletes[0].private, "{collection}: its delete is private too");
596 }
597 }
598 }
599}