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 is_record_type(current_name(collection)) || namespace(collection).is_some_and(|ns| private_namespaces().contains(&ns))
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use serde_json::json;
283
284 const RENAMES_JSON: &str = crate::renames::JSON;
286
287 const AT: &str = "2026-10-19T12:00:00.000Z";
288 const NOW: u64 = 1_792_411_200;
291
292 fn one(collection: &str, space: Option<&str>) -> Write {
293 let record = json!({"$type": collection, "text": "Honest walk", "createdAt": AT});
294 plan_at("did:plc:alice", vec![Record { record, rkey: None, space: space.map(str::to_owned) }], NOW).unwrap().remove(0)
295 }
296
297 fn private_and_public_types() -> (&'static str, &'static str) {
299 let private = record_types().into_iter().find(|c| !has_public_home(c)).expect("a private type");
300 let ns = namespace(private);
301 let public = record_types().into_iter().find(|c| has_public_home(c) && namespace(c) == ns).expect("a public type");
302 (private, public)
303 }
304
305 #[test]
306 fn a_record_without_a_type_or_anything_to_key_it_by_is_refused() {
307 let repo = "did:plc:alice";
308 assert!(plan(repo, vec![Record { record: json!({"name": "x"}), rkey: None, space: None }]).is_err());
309 let bare = json!({"$type": "x.example.thing", "name": "x"});
310 let err = plan(repo, vec![Record { record: bare.clone(), rkey: None, space: None }]).unwrap_err();
311 assert!(err.contains("no createdAt"), "{err}");
312 assert_eq!(plan(repo, vec![Record { record: bare, rkey: Some("k".into()), space: None }]).unwrap()[0].rkey, "k");
313 }
314
315 fn del(uri: Option<&str>, collection: Option<&str>, rkey: Option<&str>, space: Option<&str>) -> DeleteRecord {
316 let s = |v: Option<&str>| v.map(str::to_owned);
317 DeleteRecord { uri: s(uri), collection: s(collection), rkey: s(rkey), space: s(space) }
318 }
319
320 #[test]
321 fn deletes_are_placed_like_writes_in_the_order_they_go_out() {
322 let repo = "did:plc:alice";
323 let event = "community.lexicon.calendar.event";
324 let space = "at://did:plc:alice/pub.techne.draft.space.event.detail/k1";
325 let p = plan_with_deletes(
326 repo,
327 vec![],
328 vec![
329 del(Some(&format!("at://{repo}/{event}/k1")), None, None, None),
330 del(None, Some("pub.techne.draft.event.detail"), Some("k1"), Some(space)),
331 del(None, Some("com.example.thirdparty.note"), Some("k2"), None),
332 ],
333 )
334 .unwrap();
335 assert!(p.writes.is_empty());
336 assert_eq!(
337 p.deletes.iter().map(|d| (d.collection.as_str(), d.private)).collect::<Vec<_>>(),
338 [(event, false), ("com.example.thirdparty.note", false), ("pub.techne.draft.event.detail", true)]
339 );
340 assert_eq!(p.deletes[0].uri, format!("at://{repo}/{event}/k1"));
341 assert_eq!(p.deletes[2].space.as_deref(), Some(space));
342 }
343
344 #[test]
345 fn a_delete_outside_the_repo_or_of_a_key_also_written_or_without_a_key_is_refused() {
346 let repo = "did:plc:alice";
347 let event = json!({"$type": "community.lexicon.calendar.event", "name": "x", "startsAt": "2026-10-25T18:00:00.000Z", "createdAt": "2026-10-19T12:00:00.000Z"});
348 let err = plan_with_deletes(repo, vec![], vec![del(Some("at://did:plc:bob/community.lexicon.calendar.event/k"), None, None, None)]).unwrap_err();
349 assert!(err.contains("not in did:plc:alice's repo"), "{err}");
350 let both = plan_with_deletes(
351 repo,
352 vec![Record { record: event, rkey: Some("k".into()), space: None }],
353 vec![del(None, Some("community.lexicon.calendar.event"), Some("k"), None)],
354 )
355 .unwrap_err();
356 assert!(both.contains("both written and deleted"), "{both}");
357 assert!(plan_with_deletes(repo, vec![], vec![del(None, Some("x.y.z"), None, None)]).is_err());
358 assert!(plan_with_deletes(repo, vec![], vec![del(Some("at://did:plc:alice/x.y.z"), None, None, None)]).is_err());
359 let ev = "community.lexicon.calendar.event";
360 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();
361 assert!(twice.contains("deleted twice"), "{twice}");
362 for (uri, c, k, space) in [
363 (Some("at://did:plc:alice/x.y.z/k?q"), None, None, None),
364 (Some("at://did:plc:alice/x.y.z/k#f"), None, None, None),
365 (None, Some("x.y/z"), Some("k"), None),
366 (None, Some("x.y.z"), Some(".."), None),
367 (None, Some("x.y.z"), Some("k"), Some("")),
368 (Some("at://alice.test/x.y.z/k"), None, None, None),
369 (Some("at://did:plc:alice/x.y.z/other"), Some("x.y.z"), Some("k"), None),
370 ] {
371 assert!(plan_with_deletes(repo, vec![], vec![del(uri, c, k, space)]).is_err(), "{uri:?} {c:?} {k:?} {space:?}");
372 }
373 let back = plan_with_deletes(repo, vec![], vec![del(Some(&format!("at://{repo}/{ev}/k")), Some(ev), Some("k"), None)]).unwrap();
375 assert_eq!(back.deletes[0].uri, format!("at://{repo}/{ev}/k"));
376 }
377
378 #[test]
381 fn a_type_with_no_lexicon_goes_to_the_users_pds() {
382 assert!(!one("com.example.thirdparty.note", None).private, "no lexicon: the user's PDS");
383 let space = "at://did:plc:alice/com.example.space/self";
384 assert!(one("com.example.thirdparty.note", Some(space)).private, "a record in a space is private whatever its type");
385 let (private, public) = private_and_public_types();
386 assert!(one(private, None).private, "{private}: a lexicon type with no public home stays private");
387 assert!(!one(public, None).private, "{public}: a lexicon type with a public home is public");
388 }
389
390 #[test]
394 fn the_plan_says_whether_the_platform_has_a_lexicon_for_the_type() {
395 let (private, public) = private_and_public_types();
396 assert!(one(private, None).lexicon && one(public, None).lexicon);
397 assert!(!one("com.example.thirdparty.note", None).lexicon, "another app's own type");
398 assert!(!one(&format!("{private}x"), None).lexicon, "a typo in the platform's namespace");
399 }
400
401 #[test]
405 fn an_unknown_type_in_the_platforms_namespace_stays_private() {
406 let (private, public) = private_and_public_types();
407 let ns = namespace(private).unwrap();
408 let old_name = |c: &str| c.replacen(".draft.", ".", 1);
409 let unknown = [
410 format!("{private}x"),
411 old_name(private),
412 old_name(public),
413 private.to_ascii_uppercase(),
414 public.to_ascii_uppercase(),
415 ];
416 for collection in &unknown {
417 assert!(!is_record_type(collection), "{collection} has no lexicon");
418 assert!(one(collection, None).private, "{collection}: an unknown name in {ns} stays private");
419 }
420 let neighbour = format!("{ns}x.note");
421 assert!(!one(&neighbour, None).private, "{neighbour}: only the namespace itself");
422 }
423
424
425 fn renames() -> Vec<(String, String)> {
427 crate::renames::pairs().iter().map(|p| (p.old.clone(), p.new.clone())).collect()
428 }
429
430 #[test]
436 fn renames_copy_is_the_platforms() {
437 let input = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../inputs/platform-api/nsid_renames.json");
438 if let Ok(bytes) = std::fs::read_to_string(&input) {
439 assert_eq!(bytes, RENAMES_JSON, "crates/techne-sdk/src/nsid_renames.json differs from {}: run scripts/inputs-sync.mjs", input.display());
440 }
441 let pairs = renames();
442 assert!(!pairs.is_empty());
443 for (old, new) in &pairs {
444 assert_eq!(current_name(old), new, "the check renames {old} otherwise");
445 }
446 let map: std::collections::BTreeMap<&str, &str> = pairs.iter().map(|(o, n)| (o.as_str(), n.as_str())).collect();
447 let currents: std::collections::BTreeSet<String> =
448 record_types().into_iter().map(str::to_owned).chain(pairs.iter().map(|(_, n)| n.clone())).collect();
449 let mut asked = 0;
450 for current in ¤ts {
451 let tail = current.strip_prefix("pub.techne.draft.").or_else(|| current.strip_prefix("social.scenius."));
452 let candidates = tail
453 .into_iter()
454 .flat_map(|t| [format!("coop.lexicon.draft.{t}"), format!("coop.lexicon.{t}"), format!("social.scenius.{t}")]);
455 for name in candidates.chain(std::iter::once(current.clone())) {
456 asked += 1;
457 let answered = current_name(&name);
458 if answered != name {
459 assert_eq!(map.get(name.as_str()).copied(), Some(answered), "the check renames {name} to {answered}; this copy does not");
460 }
461 }
462 }
463 assert!(asked > pairs.len(), "the reverse direction asked about {asked} names");
464 }
465
466 #[test]
469 fn an_old_name_has_a_lexicon_only_while_its_rename_is_honoured() {
470 let doc: Value = serde_json::from_str(RENAMES_JSON).unwrap();
471 let day = |d: &str| chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").unwrap();
472 let at = |d: chrono::NaiveDate, h, m, s| u64::try_from(d.and_hms_opt(h, m, s).unwrap().and_utc().timestamp()).unwrap();
473 let early: Vec<(chrono::NaiveDate, String)> = doc["closeEarlier"]
474 .as_object()
475 .unwrap()
476 .iter()
477 .flat_map(|(d, olds)| olds.as_array().unwrap().iter().map(move |o| (day(d), o.as_str().unwrap().to_owned())))
478 .collect();
479 let last_of = |old: &str| early.iter().find(|(_, o)| o == old).map_or_else(|| day(doc["deleteAfter"].as_str().unwrap()), |(d, _)| *d);
480 let (mut early_seen, mut late_seen) = (false, false);
481 for (old, new) in renames() {
482 if !is_record_type(&new) || is_record_type(&old) {
483 continue;
484 }
485 let last = last_of(&old);
486 early_seen |= last != day(doc["deleteAfter"].as_str().unwrap());
487 late_seen |= last == day(doc["deleteAfter"].as_str().unwrap());
488 let lexicon = |now| {
489 let record = json!({"$type": old, "text": "Honest walk", "createdAt": AT});
490 plan_at("did:plc:alice", vec![Record { record, rkey: Some("k".into()), space: None }], now).unwrap()[0].lexicon
491 };
492 assert!(lexicon(at(last, 23, 59, 59)), "{old}: a lexicon through {last}");
493 assert!(!lexicon(at(last + chrono::Days::new(1), 0, 0, 0)), "{old}: no lexicon after {last}");
494 let record = json!({"$type": new, "text": "Honest walk", "createdAt": AT});
495 assert!(plan_at("did:plc:alice", vec![Record { record, rkey: Some("k".into()), space: None }], u64::MAX / 2).unwrap()[0].lexicon);
496 }
497 assert!(early_seen && late_seen, "both kinds of window are checked");
498 }
499
500 #[test]
505 fn an_old_name_is_placed_as_the_type_it_was_renamed_to() {
506 let (mut public, mut private, mut literal) = (0, 0, 0);
507 for (old, _) in renames() {
508 let current = current_name(&old);
509 if old == current || !is_record_type(current) {
510 continue;
511 }
512 let w = one(&old, None);
513 assert_eq!(w.private, !has_public_home(current), "{old} -> {current}");
514 assert!(w.lexicon, "{old}: the platform has {current}");
515 assert_eq!(w.collection, old, "written under the name given");
516 if let Some(key) = record_key(current).and_then(|k| k.strip_prefix("literal:")) {
517 assert_eq!(w.rkey, key, "{old}: {current}'s key");
518 literal += 1;
519 }
520 if w.private { private += 1 } else { public += 1 }
521 }
522 assert!(public > 0 && private > 0 && literal > 0, "public {public}, private {private}, literal {literal}");
523 }
524
525 #[test]
529 fn a_delete_under_an_old_name_is_placed_as_the_type_it_was_renamed_to() {
530 let repo = "did:plc:alice";
531 let (mut public, mut private) = (0, 0);
532 for (old, _) in renames() {
533 let current = current_name(&old);
534 if old == current || !is_record_type(current) {
535 continue;
536 }
537 let p = plan_with_deletes(repo, vec![], vec![del(None, Some(&old), Some("k1"), None)]).unwrap();
538 let d = &p.deletes[0];
539 assert_eq!(d.private, !has_public_home(current), "{old} -> {current}");
540 assert_eq!((d.collection.as_str(), d.uri.clone()), (old.as_str(), format!("at://{repo}/{old}/k1")), "deleted under the name given");
541 if d.private { private += 1 } else { public += 1 }
542 }
543 assert!(public > 0 && private > 0, "public {public}, private {private}");
544 }
545
546 #[test]
549 fn an_unknown_name_in_the_old_namespace_stays_private() {
550 let renames = renames();
551 let old = |public: bool| {
552 renames
553 .iter()
554 .find(|(o, n)| o != n && is_record_type(n) && has_public_home(n) == public && namespace(o) != namespace(n))
555 .map(|(o, _)| o.clone())
556 .expect("a renamed type")
557 };
558 for old in [old(true), old(false)] {
559 for collection in [format!("{old}x"), old.to_ascii_uppercase()] {
560 assert!(one(&collection, None).private, "{collection}: an unknown name in {:?}", namespace(&old));
561 let p = plan_with_deletes("did:plc:alice", vec![], vec![del(None, Some(&collection), Some("k1"), None)]).unwrap();
562 assert!(p.deletes[0].private, "{collection}: its delete is private too");
563 }
564 }
565 }
566}