You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit message: [239c074b] url schema stuff Side A — unified diff (full patch): diff --git a/AGENTS.md b/AGENTS.md index 426a88e7c1da54fe0a28c5c76fa4e1f1bc117fcf..e60b9ba6012593361ef10e8fdd9439cd9932e09b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,3 +58,4 @@ Use **tmux** for `cargo run --package sorter2-server` (dev server). Rebuild afte - First `cargo test` / `cargo build --release` is slow; Clojure smoke test always does a release build. - `legacy/` and `ideas/` are not part of the workspace build. +- **ItemId** for web URLs is a canonical full URL (`https://reddit.com/r/rust`). Rules live in [`server/src/url_rules/`](server/src/url_rules/) (composable Rust, not a config DSL). After changing canonicalization rules, rebuild the projection: `cargo run --package sorter2-server -- replay-index`. diff --git a/Cargo.lock b/Cargo.lock index 0dd4fce5fb6400ae153cca4e3dbf5a5158e6d8b4..49a908ef935c430dbe63c6a28d8a24e38b489486 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1951,6 +1951,7 @@ dependencies = [ "tower-http 0.5.2", "tracing", "tracing-subscriber", + "url", "urlencoding", ] diff --git a/REPLAY.sh b/REPLAY.sh new file mode 100755 index 0000000000000000000000000000000000000000..f2dbd8aea60c02d2feef74805f7ef5c2b7022537 --- /dev/null +++ b/REPLAY.sh @@ -0,0 +1,2 @@ +cargo run --package sorter2-server -- replay-index + diff --git a/server/Cargo.toml b/server/Cargo.toml index 27f552c20b97ef28cdde4cb6b1a4980375135111..ad4912791aff59fb1d3293f66ad381ae618cd60b 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -24,6 +24,7 @@ async-stream = "0.3" futures-util = { version = "0.3", default-features = false, features = ["std"] } rand = "0.8" urlencoding = "2" +url = "2" durable = { path = "../durable" } [dev-dependencies] diff --git a/server/src/entity_store.rs b/server/src/entity_store.rs index d5d17c3676e4a8ddec998e9f5a9dbafe9c2d9d0e..d29f39aecca6f12cdcf263cf77c3654eb4ee6cfa 100644 --- a/server/src/entity_store.rs +++ b/server/src/entity_store.rs @@ -124,7 +124,7 @@ mod tests { fn round_trip_payload() { let tmp = tempfile::tempdir().unwrap(); let store = EntityStore::open(tmp.path()).unwrap(); - let id = ItemId::parse("reddit.com/r/rust").unwrap(); + let id = ItemId::from_url("https://reddit.com/r/rust").unwrap(); let payload = json!({"kind": "t5", "data": {"display_name": "rust"}}); store.put(&id, &payload).unwrap(); diff --git a/server/src/event_log.rs b/server/src/event_log.rs index 36f5b406084065b608735987cdb483c236e03081..2c9290b6fdbf2c2ad1c0f1ffd7374b2d9cc97f36 100644 --- a/server/src/event_log.rs +++ b/server/src/event_log.rs @@ -199,7 +199,7 @@ mod tests { log.append(&sample_record( 1, Event::NodeEnsured { - id: "reddit.com/r/rust".into(), + id: "https://reddit.com/r/rust".into(), }, )) .await @@ -237,7 +237,7 @@ mod tests { let path = tmp.path().join("events.jsonl"); let log = EventLog::new(&path); let event = Event::NodeEnsured { - id: "reddit.com/r/rust".into(), + id: "https://reddit.com/r/rust".into(), }; log.append(&sample_record(1, event)).await.unwrap(); @@ -255,7 +255,7 @@ mod tests { let path = tmp.path().join("events.jsonl"); std::fs::write( &path, - r#"{"type":"node_ensured","id":"reddit.com/r/rust"} + r#"{"type":"node_ensured","id":"https://reddit.com/r/rust"} {"schema":1,"seq":1,"ts":1,"event":{"type":"vote_recorded","ts":1,"a":"a","b":"b","ratio_left":2,"ratio_right":1,"scope":""}} "#, ) @@ -295,7 +295,7 @@ mod tests { log.append(&sample_record( 1, Event::NodeEnsured { - id: "reddit.com/r/rust".into(), + id: "https://reddit.com/r/rust".into(), }, )) .await @@ -303,7 +303,7 @@ mod tests { log.append(&sample_record( 3, Event::NodeEnsured { - id: "reddit.com/r/python".into(), + id: "https://reddit.com/r/python".into(), }, )) .await diff --git a/server/src/journal.rs b/server/src/journal.rs index 521a108019de1ea870d14c4fafbfe572c20ce0de..50bc89f976edb82b7b0e49e954a8eccbbe82bf87 100644 --- a/server/src/journal.rs +++ b/server/src/journal.rs @@ -141,10 +141,10 @@ mod tests { let j2 = journal.clone(); let (r1, r2) = tokio::join!( j1.append(Event::NodeEnsured { - id: "reddit.com/r/rust".into(), + id: "https://reddit.com/r/rust".into(), }), j2.append(Event::NodeEnsured { - id: "reddit.com/r/python".into(), + id: "https://reddit.com/r/python".into(), }), ); r1.unwrap(); @@ -153,10 +153,10 @@ mod tests { assert_eq!(projection_store.last_applied_event_count().unwrap(), 2); let tree = projection_store.load_tree().unwrap(); assert!(tree - .get(&ItemId::parse("reddit.com/r/rust").unwrap()) + .get(&ItemId::parse("https://reddit.com/r/rust").unwrap()) .is_some()); assert!(tree - .get(&ItemId::parse("reddit.com/r/python").unwrap()) + .get(&ItemId::parse("https://reddit.com/r/python").unwrap()) .is_some()); } @@ -170,7 +170,7 @@ mod tests { 1, 1, Event::NodeEnsured { - id: "reddit.com/r/rust".into(), + id: "https://reddit.com/r/rust".into(), }, )) .await @@ -186,7 +186,7 @@ mod tests { 1, 1, Event::NodeEnsured { - id: "reddit.com/r/rust".into(), + id: "https://reddit.com/r/rust".into(), }, )], ) @@ -202,7 +202,7 @@ mod tests { ); journal .append(Event::NodeEnsured { - id: "reddit.com/r/python".into(), + id: "https://reddit.com/r/python".into(), }) .await .unwrap(); @@ -227,13 +227,13 @@ mod tests { journal .append_many(vec![ Event::NodeEnsured { - id: "reddit.com/r/rust".into(), + id: "https://reddit.com/r/rust".into(), }, Event::NodeEnsured { - id: "reddit.com/r/python".into(), + id: "https://reddit.com/r/python".into(), }, Event::NodeEnsured { - id: "reddit.com/r/clojure".into(), + id: "https://reddit.com/r/clojure".into(), }, ]) .await @@ -245,7 +245,7 @@ mod tests { assert_eq!(projection_store.last_applied_event_count().unwrap(), 3); let tree = projection_store.load_tree().unwrap(); assert!(tree - .get(&ItemId::parse("reddit.com/r/clojure").unwrap()) + .get(&ItemId::parse("https://reddit.com/r/clojure").unwrap()) .is_some()); } } diff --git a/server/src/lib.rs b/server/src/lib.rs index 9bd5f76fd1406b9b1be4c272f4ba8647edde2678..5c02c8e704e4664453bad75d819df8a067668176 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -9,6 +9,7 @@ pub mod journal; pub mod pair; pub mod parser; pub mod path_types; +pub mod url_rules; pub mod projection_apply; pub mod projection_store; pub mod ranking; diff --git a/server/src/pair.rs b/server/src/pair.rs index 43f780ba6ea6ce1cdc2e1f4cbb252ba8a10684b9..815a97b80e3e9f348e0937a4f147f2862018edb0 100644 --- a/server/src/pair.rs +++ b/server/src/pair.rs @@ -381,42 +381,42 @@ mod tests { #[test] fn suggest_prefers_unvoted_pair() { - let parent = ItemId::parse("reddit.com/r/rust").unwrap(); + let parent = ItemId::parse("https://reddit.com/r/rust").unwrap(); let mut tree = seed_children( &parent, &[ - "reddit.com/r/rust/a", - "reddit.com/r/rust/b", - "reddit.com/r/rust/c", + "https://reddit.com/r/rust/a", + "https://reddit.com/r/rust/b", + "https://reddit.com/r/rust/c", ], ); let vote = - VoteData::from_recorded(1, "reddit.com/r/rust/a", "reddit.com/r/rust/b", 2, 1).unwrap(); + VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap(); tree.apply_vote(&parent, vote); let group = tree.get(&parent).unwrap().local_ranking.clone(); let pool = children_of(&tree, &parent); let (l, r) = suggest_next_pair_in_pool(&group, &pool, None).unwrap(); - let voted_ab = (l.as_str() == "reddit.com/r/rust/a" && r.as_str() == "reddit.com/r/rust/b") - || (l.as_str() == "reddit.com/r/rust/b" && r.as_str() == "reddit.com/r/rust/a"); + let voted_ab = (l.as_str() == "https://reddit.com/r/rust/a" && r.as_str() == "https://reddit.com/r/rust/b") + || (l.as_str() == "https://reddit.com/r/rust/b" && r.as_str() == "https://reddit.com/r/rust/a"); assert!(!voted_ab); } #[test] fn suggest_bridges_separate_components() { - let parent = ItemId::parse("reddit.com/r/rust").unwrap(); + let parent = ItemId::parse("https://reddit.com/r/rust").unwrap(); let mut tree = seed_children( &parent, &[ - "reddit.com/r/rust/a", - "reddit.com/r/rust/b", - "reddit.com/r/rust/c", - "reddit.com/r/rust/d", + "https://reddit.com/r/rust/a", + "https://reddit.com/r/rust/b", + "https://reddit.com/r/rust/c", + "https://reddit.com/r/rust/d", ], ); let ab = - VoteData::from_recorded(1, "reddit.com/r/rust/a", "reddit.com/r/rust/b", 2, 1).unwrap(); + VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap(); let cd = - VoteData::from_recorded(2, "reddit.com/r/rust/c", "reddit.com/r/rust/d", 2, 1).unwrap(); + VoteData::from_recorded(2, "https://reddit.com/r/rust/c", "https://reddit.com/r/rust/d", 2, 1).unwrap(); tree.apply_vote(&parent, ab); tree.apply_vote(&parent, cd); let group = tree.get(&parent).unwrap().local_ranking.clone(); @@ -424,37 +424,37 @@ mod tests { let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap(); let chosen = pair_set(&pair); let from_ab = - chosen.contains("reddit.com/r/rust/a") || chosen.contains("reddit.com/r/rust/b"); + chosen.contains("https://reddit.com/r/rust/a") || chosen.contains("https://reddit.com/r/rust/b"); let from_cd = - chosen.contains("reddit.com/r/rust/c") || chosen.contains("reddit.com/r/rust/d"); + chosen.contains("https://reddit.com/r/rust/c") || chosen.contains("https://reddit.com/r/rust/d"); assert!(from_ab && from_cd, "expected bridge pair, got {:?}", chosen); } #[test] fn suggest_prefers_attach_over_isolate_pair_among_many_unranked() { - let parent = ItemId::parse("reddit.com/r/rust").unwrap(); + let parent = ItemId::parse("https://reddit.com/r/rust").unwrap(); let mut tree = seed_children( &parent, &[ - "reddit.com/r/rust/a", - "reddit.com/r/rust/b", - "reddit.com/r/rust/c", - "reddit.com/r/rust/d", - "reddit.com/r/rust/e", + "https://reddit.com/r/rust/a", + "https://reddit.com/r/rust/b", + "https://reddit.com/r/rust/c", + "https://reddit.com/r/rust/d", + "https://reddit.com/r/rust/e", ], ); let ab = - VoteData::from_recorded(1, "reddit.com/r/rust/a", "reddit.com/r/rust/b", 2, 1).unwrap(); + VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap(); tree.apply_vote(&parent, ab); let group = tree.get(&parent).unwrap().local_ranking.clone(); let pool = children_of(&tree, &parent); let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap(); let chosen = pair_set(&pair); let from_ab = - chosen.contains("reddit.com/r/rust/a") || chosen.contains("reddit.com/r/rust/b"); - let from_cde = chosen.contains("reddit.com/r/rust/c") - || chosen.contains("reddit.com/r/rust/d") - || chosen.contains("reddit.com/r/rust/e"); + chosen.contains("https://reddit.com/r/rust/a") || chosen.contains("https://reddit.com/r/rust/b"); + let from_cde = chosen.contains("https://reddit.com/r/rust/c") + || chosen.contains("https://reddit.com/r/rust/d") + || chosen.contains("https://reddit.com/r/rust/e"); assert!( from_ab && from_cde, "expected ranked+unranked attach, got {:?}", @@ -464,40 +464,40 @@ mod tests { #[test] fn suggest_connects_isolate_to_existing_component() { - let parent = ItemId::parse("reddit.com/r/rust").unwrap(); + let parent = ItemId::parse("https://reddit.com/r/rust").unwrap(); let mut tree = seed_children( &parent, &[ - "reddit.com/r/rust/a", - "reddit.com/r/rust/b", - "reddit.com/r/rust/c", + "https://reddit.com/r/rust/a", + "https://reddit.com/r/rust/b", + "https://reddit.com/r/rust/c", ], ); let ab = - VoteData::from_recorded(1, "reddit.com/r/rust/a", "reddit.com/r/rust/b", 2, 1).unwrap(); + VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap(); tree.apply_vote(&parent, ab); let group = tree.get(&parent).unwrap().local_ranking.clone(); let pool = children_of(&tree, &parent); let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap(); let chosen = pair_set(&pair); - assert!(chosen.contains("reddit.com/r/rust/c")); - assert!(chosen.contains("reddit.com/r/rust/a") || chosen.contains("reddit.com/r/rust/b")); + assert!(chosen.contains("https://reddit.com/r/rust/c")); + assert!(chosen.contains("https://reddit.com/r/rust/a") || chosen.contains("https://reddit.com/r/rust/b")); } #[test] fn suggest_zips_adjacent_ranks_when_tree_complete() { - let parent = ItemId::parse("reddit.com/r/rust").unwrap(); + let parent = ItemId::parse("https://reddit.com/r/rust").unwrap(); let mut tree = seed_children( &parent, &[ - "reddit.com/r/rust/a", - "reddit.com/r/rust/b", - "reddit.com/r/rust/c", + "https://reddit.com/r/rust/a", + "https://reddit.com/r/rust/b", + "https://reddit.com/r/rust/c", ], ); for (a, b, l, r) in [ - ("reddit.com/r/rust/a", "reddit.com/r/rust/b", 3, 1), - ("reddit.com/r/rust/a", "reddit.com/r/rust/c", 2, 1), + ("https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 3, 1), + ("https://reddit.com/r/rust/a", "https://reddit.com/r/rust/c", 2, 1), ] { let v = VoteData::from_recorded(1, a, b, l, r).unwrap(); tree.apply_vote(&parent, v); @@ -506,26 +506,26 @@ mod tests { let pool = children_of(&tree, &parent); let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap(); let chosen = pair_set(&pair); - assert!(chosen.contains("reddit.com/r/rust/b")); - assert!(chosen.contains("reddit.com/r/rust/c")); + assert!(chosen.contains("https://reddit.com/r/rust/b")); + assert!(chosen.contains("https://reddit.com/r/rust/c")); } #[test] fn suggest_zip_prefers_1v2_before_2v3_when_both_unvoted() { - let parent = ItemId::parse("reddit.com/r/rust").unwrap(); + let parent = ItemId::parse("https://reddit.com/r/rust").unwrap(); let mut tree = seed_children( &parent, &[ - "reddit.com/r/rust/a", - "reddit.com/r/rust/b", - "reddit.com/r/rust/c", - "reddit.com/r/rust/d", + "https://reddit.com/r/rust/a", + "https://reddit.com/r/rust/b", + "https://reddit.com/r/rust/c", + "https://reddit.com/r/rust/d", ], ); for (a, b, l, r) in [ - ("reddit.com/r/rust/c", "reddit.com/r/rust/d", 3, 1), - ("reddit.com/r/rust/b", "reddit.com/r/rust/c", 2, 1), - ("reddit.com/r/rust/a", "reddit.com/r/rust/c", 2, 1), + ("https://reddit.com/r/rust/c", "https://reddit.com/r/rust/d", 3, 1), + ("https://reddit.com/r/rust/b", "https://reddit.com/r/rust/c", 2, 1), + ("https://reddit.com/r/rust/a", "https://reddit.com/r/rust/c", 2, 1), ] { let v = VoteData::from_recorded(1, a, b, l, r).unwrap(); tree.apply_vote(&parent, v); @@ -534,16 +534,16 @@ mod tests { let pool = children_of(&tree, &parent); let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap(); let chosen = pair_set(&pair); - assert!(chosen.contains("reddit.com/r/rust/a")); - assert!(chosen.contains("reddit.com/r/rust/b")); + assert!(chosen.contains("https://reddit.com/r/rust/a")); + assert!(chosen.contains("https://reddit.com/r/rust/b")); } #[test] fn resolve_pair_picks_from_pool() { - let parent = ItemId::parse("reddit.com/r/rust").unwrap(); - let tree = seed_children(&parent, &["reddit.com/r/rust/a", "reddit.com/r/rust/b"]); + let parent = ItemId::parse("https://reddit.com/r/rust").unwrap(); + let tree = seed_children(&parent, &["https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b"]); let pair = resolve_pair(&tree, &parent, None, None).unwrap(); - let pool: HashSet<_> = ["reddit.com/r/rust/a", "reddit.com/r/rust/b"] + let pool: HashSet<_> = ["https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b"] .into_iter() .collect(); assert!(pool.contains(pair.0.as_str())); diff --git a/server/src/parser.rs b/server/src/parser.rs index 9df2dcc9313f7fe250ce3b6aa167f6ec5d57951f..b2a963dd6cab415576c8d3a9a241966758565bb8 100644 --- a/server/src/parser.rs +++ b/server/src/parser.rs @@ -23,7 +23,7 @@ mod tests { fn parses_short_path() { assert_eq!( parse_reddit_url("r/rust").unwrap().as_str(), - "reddit.com/r/rust" + "https://reddit.com/r/rust" ); } @@ -33,7 +33,7 @@ mod tests { parse_reddit_url("https://www.reddit.com/r/programming/hot") .unwrap() .as_str(), - "reddit.com/r/programming" + "https://reddit.com/r/programming" ); } @@ -43,7 +43,10 @@ mod tests { "https://old.reddit.com/r/AmItheAsshole/comments/1trnvdl/aita_for_cancelling/", ) .unwrap(); - assert_eq!(id.as_str(), "reddit.com/r/amitheasshole/comments/1trnvdl"); + assert_eq!( + id.as_str(), + "https://reddit.com/r/amitheasshole/comments/1trnvdl" + ); } #[test] diff --git a/server/src/path_types.rs b/server/src/path_types.rs index fafd924452f6fd85e7a5b27ed2653a19581e6e56..71ffc01f35c5589686ff05dae3b5610fa01f30ce 100644 --- a/server/src/path_types.rs +++ b/server/src/path_types.rs @@ -1,13 +1,14 @@ use serde::{Deserialize, Serialize}; use std::fmt; -/// Canonical hierarchical identity for any URL/path in the fractal tree. +use crate::url_rules::{looks_like_url, navigable_breadcrumbs, parent_url, resolve_canonical}; + +/// Canonical identity: a real URL (with scheme) or an opaque non-URL key. #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)] pub struct ItemId(String); impl ItemId { - /// Parse an already-canonical path (no URL normalization). Empty string is invalid here; - /// use [`Self::root`] for the tree root. + /// Parse an already-canonical id (no normalization). Empty string is invalid; use [`Self::root`]. pub fn parse(s: &str) -> Option { let t = s.trim(); if t.is_empty() { @@ -16,12 +17,12 @@ impl ItemId { Some(Self(t.to_string())) } - /// Build an opaque item key (legacy demo votes, non-URL items). + /// Build an opaque item key (demo votes, non-URL items). pub fn opaque(s: impl Into) -> Self { Self(s.into()) } - /// Root of the internet tree (empty path). + /// Root of the internet tree. pub fn root() -> Self { Self(String::new()) } @@ -34,23 +35,18 @@ impl ItemId { &self.0 } - /// Creates a canonical ID from a raw URL or path. Normalizes domains and - /// trims tracking query params. + /// Canonical URL from a raw pasted or fetched URL. pub fn from_url(raw_url: &str) -> Option { - Self::canonicalize(raw_url).map(Self) + resolve_canonical(raw_url).map(Self) } - /// Normalize strings from forms, events, and Reddit imports into the same - /// stored id shape (e.g. drop post title slug after comment id). + /// Normalize strings from forms, events, and imports into canonical identity. pub fn from_storage(s: &str) -> Option { let t = s.trim(); if t.is_empty() { return None; } - if t.contains("://") || t.starts_with("r/") { - return Self::from_url(t).or_else(|| Self::parse(t)); - } - if t.starts_with("reddit.com/") && t.contains("/comments/") { + if looks_like_url(t) { return Self::from_url(t).or_else(|| Self::parse(t)); } Self::parse(t).or_else(|| Self::from_url(t)) @@ -62,35 +58,52 @@ impl ItemId { if s.is_empty() { return Self::root(); } - Self(format!("reddit.com/r/{s}")) + if looks_like_url(s) || s.contains('/') { + Self::from_storage(s).unwrap_or_else(|| Self::opaque(s)) + } else { + Self(format!("https://reddit.com/r/{s}")) + } } - /// Extract the parent, e.g. `reddit.com/r/aww/comments/1trnvdl` → - /// `reddit.com/r/aww`. + /// Immediate parent scope in the tree. pub fn parent(&self) -> Option { - if self.0.is_empty() { + if self.is_root() { return None; } - + if looks_like_url(self.0.as_str()) { + return parent_url(self.0.as_str()).map(Self); + } let parts: Vec<&str> = self.0.trim_end_matches('/').split('/').collect(); if parts.len() <= 1 { return None; } - - if self.0.contains("/comments/") { - return Some(Self(parts[..parts.len().saturating_sub(2)].join("/"))); - } - Some(Self(parts[..parts.len() - 1].join("/"))) } pub fn segments(&self) -> Vec<&str> { + if self.is_root() { + return vec![]; + } + if let Some(rest) = self.0.strip_prefix("https://") { + return rest.split('/').filter(|s| !s.is_empty()).collect(); + } + if let Some(rest) = self.0.strip_prefix("http://") { + return rest.split('/').filter(|s| !s.is_empty()).collect(); + } self.0.split('/').filter(|s| !s.is_empty()).collect() } - /// Cumulative paths for breadcrumb rendering, e.g. - /// `reddit.com/r/movies` → `["reddit.com", "reddit.com/r", "reddit.com/r/movies"]`. + /// Cumulative navigable paths for breadcrumbs and tree wiring (includes self). pub fn breadcrumb_paths(&self) -> Vec { + if self.is_root() { + return vec![]; + } + if looks_like_url(self.0.as_str()) { + return navigable_breadcrumbs(self.0.as_str()) + .into_iter() + .map(ItemId) + .collect(); + } let segs = self.segments(); let mut paths = Vec::with_capacity(segs.len()); let mut current = String::new(); @@ -111,13 +124,13 @@ impl ItemId { if self.is_root() { return String::new(); } - if self.as_str().contains("://") { - return self.as_str().to_string(); + if self.0.contains("://") { + return self.0.clone(); } if self.segments().first().is_some_and(|s| s.contains('.')) { - format!("https://{}", self.as_str()) + format!("https://{}", self.0) } else { - self.as_str().to_string() + self.0.clone() } } @@ -144,70 +157,6 @@ impl ItemId { pub fn from_browse_uri(path: &str) -> Option { path.strip_prefix("/~/").map(ItemId::from_browse_tail) } - - fn canonicalize(raw: &str) -> Option { - let s = raw.trim(); - if s.is_empty() { - return None; - } - - let owned = if let Some(rest) = s.strip_prefix("r/") { - format!("reddit.com/r/{rest}") - } else if let Some(rest) = s.strip_prefix("/r/") { - format!("reddit.com/r/{rest}") - } else { - s.to_string() - }; - - let (host_path, _query) = split_query(&owned); - let host_path = host_path.trim_end_matches('/'); - - let path = if host_path.contains("://") { - parse_url_host_path(host_path)? - } else if host_path.starts_with("reddit.com") || host_path.starts_with("www.reddit.com") { - normalize_reddit_host_path(host_path) - } else if host_path.contains('/') { - host_path.to_string() - } else { - return None; - }; - - Some(normalize_reddit_path(&path)) - } -} - -fn split_query(s: &str) -> (&str, Option<&str>) { - if let Some((path, q)) = s.split_once('?') { - (path, Some(q)) - } else { - (s, None) - } -} - -fn parse_url_host_path(url: &str) -> Option { - let rest = url - .strip_prefix("https://") - .or_else(|| url.strip_prefix("http://")) - .unwrap_or(url); - let (host, path) = rest.split_once('/').unwrap_or((rest, "")); - let host = normalize_host(host); - if path.is_empty() { - Some(host) - } else { - Some(format!("{host}/{path}")) - } -} - -fn normalize_host(host: &str) -> String { - let h = host - .strip_prefix("www.") - .unwrap_or(host) - .to_ascii_lowercase(); - if h == "old.reddit.com" || h == "new.reddit.com" || h == "reddit.com" { - "reddit.com".to_string() - } else { - h - } } fn normalize_browse_tail(tail: &str) -> String { @@ -215,7 +164,6 @@ fn normalize_browse_tail(tail: &str) -> String { if t.is_empty() { return String::new(); } - // Some HTTP stacks collapse `https://` → `https:/` inside a path segment. if t.starts_with("https:/") && !t.starts_with("https://") { return format!("https://{}", &t[7..]); } @@ -225,33 +173,6 @@ fn normalize_browse_tail(tail: &str) -> String { t.to_string() } -fn normalize_reddit_host_path(s: &str) -> String { - let (host, path) = s.split_once('/').unwrap_or((s, "")); - let host = normalize_host(host); - if path.is_empty() { - host - } else { - format!("{host}/{path}") - } -} - -/// Lowercase subreddit segment, drop listing suffixes, drop title slug after post id. -fn normalize_reddit_path(path: &str) -> String { - let mut parts: Vec = path.split('/').map(str::to_string).collect(); - if parts.len() >= 3 && parts[1] == "r" { - parts[2] = parts[2].to_ascii_lowercase(); - } - if let Some(i) = parts.iter().position(|p| p == "comments") { - if parts.len() > i + 2 { - parts.truncate(i + 2); - } - } else if parts.len() > 3 && parts.get(1).map(|s| s.as_str()) == Some("r") { - // reddit.com/r/{sub}/hot → reddit.com/r/{sub} - parts.truncate(3); - } - parts.join("/") -} - impl fmt::Display for ItemId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) @@ -268,43 +189,59 @@ mod tests { "https://old.reddit.com/r/AmItheAsshole/comments/1trnvdl/aita_for_cancelling/", ) .unwrap(); - assert_eq!(id.as_str(), "reddit.com/r/amitheasshole/comments/1trnvdl"); + assert_eq!( + id.as_str(), + "https://reddit.com/r/amitheasshole/comments/1trnvdl" + ); } #[test] fn from_url_strips_query() { let id = ItemId::from_url("https://www.reddit.com/r/rust/?sort=top").unwrap(); - assert_eq!(id.as_str(), "reddit.com/r/rust"); + assert_eq!(id.as_str(), "https://reddit.com/r/rust"); } #[test] fn from_url_short_path() { assert_eq!( ItemId::from_url("r/rust").unwrap().as_str(), - "reddit.com/r/rust" + "https://reddit.com/r/rust" ); } #[test] fn parent_of_post_is_subreddit() { - let id = ItemId::parse("reddit.com/r/aww/comments/1trnvdl").unwrap(); - assert_eq!(id.parent().unwrap().as_str(), "reddit.com/r/aww"); + let id = ItemId::from_url("https://reddit.com/r/aww/comments/1trnvdl").unwrap(); + assert_eq!(id.parent().unwrap().as_str(), "https://reddit.com/r/aww"); } #[test] fn parent_of_subreddit_is_r_segment() { - let id = ItemId::parse("reddit.com/r/movies").unwrap(); - assert_eq!(id.parent().unwrap().as_str(), "reddit.com/r"); + let id = ItemId::from_url("https://reddit.com/r/movies").unwrap(); + assert_eq!(id.parent().unwrap().as_str(), "https://reddit.com/r"); + } + + #[test] + fn breadcrumb_paths_skip_phantom_comments() { + let id = ItemId::from_url("https://reddit.com/r/aww/comments/1trnvdl").unwrap(); + let crumbs = id.breadcrumb_paths(); + let paths: Vec<_> = crumbs.iter().map(|p| p.as_str()).collect(); + assert!(!paths.iter().any(|p| p.ends_with("/comments"))); + assert!(paths.contains(&"https://reddit.com/r/aww")); } #[test] - fn breadcrumb_paths() { - let id = ItemId::parse("reddit.com/r/movies").unwrap(); + fn breadcrumb_paths_subreddit() { + let id = ItemId::from_url("https://reddit.com/r/movies").unwrap(); let crumbs = id.breadcrumb_paths(); let paths: Vec<_> = crumbs.iter().map(|p| p.as_str()).collect(); assert_eq!( paths, - vec!["reddit.com", "reddit.com/r", "reddit.com/r/movies"] + vec![ + "https://reddit.com", + "https://reddit.com/r", + "https://reddit.com/r/movies" + ] ); } @@ -312,33 +249,33 @@ mod tests { fn legacy_scope_maps_to_reddit_sub() { assert_eq!( ItemId::from_legacy_scope("rust").as_str(), - "reddit.com/r/rust" + "https://reddit.com/r/rust" ); assert!(ItemId::from_legacy_scope("").is_root()); } #[test] - fn browse_href_wraps_canonical_path() { - let id = ItemId::parse("reddit.com/r/rust").unwrap(); + fn browse_href_wraps_canonical_url() { + let id = ItemId::from_url("https://reddit.com/r/rust").unwrap(); assert_eq!(id.browse_href(), "/~/https://reddit.com/r/rust"); } #[test] fn from_browse_tail_parses_full_url() { let id = ItemId::from_browse_tail("https://reddit.com/r/AmITheAsshole"); - assert_eq!(id.as_str(), "reddit.com/r/amitheasshole"); + assert_eq!(id.as_str(), "https://reddit.com/r/amitheasshole"); } #[test] fn from_storage_strips_post_title_slug() { let id = ItemId::from_storage("reddit.com/r/rust/comments/aaa/announcing_rust_199").unwrap(); - assert_eq!(id.as_str(), "reddit.com/r/rust/comments/aaa"); + assert_eq!(id.as_str(), "https://reddit.com/r/rust/comments/aaa"); } #[test] fn from_browse_uri_strips_prefix() { let id = ItemId::from_browse_uri("/~/https://reddit.com/r/rust").unwrap(); - assert_eq!(id.as_str(), "reddit.com/r/rust"); + assert_eq!(id.as_str(), "https://reddit.com/r/rust"); } } diff --git a/server/src/projection_apply.rs b/server/src/projection_apply.rs index ebe122e417bda1d9369a53443de93d28213c5a0d..5644557a41b3e9497c7421b444155ae629fa79f1 100644 --- a/server/src/projection_apply.rs +++ b/server/src/projection_apply.rs @@ -19,13 +19,21 @@ use crate::{ storage_schema::{ensure_path_writes, entity_view_writes, vote_writes}, }; -/// Legacy-compatible scope parsing for persisted vote events. +fn parse_event_id(id: &str) -> Result { + ItemId::from_storage(id) + .or_else(|| ItemId::parse(id)) + .ok_or_else(|| EventLogError::Apply(format!("invalid id: {id}"))) +} + +/// Scope key from a vote event (canonicalized at apply time). fn parent_from_event_scope(scope: &str) -> ItemId { - if scope.contains('/') { - ItemId::parse(scope).unwrap_or_else(|| ItemId::from_legacy_scope(scope)) - } else { - ItemId::from_legacy_scope(scope) + let s = scope.trim(); + if s.is_empty() { + return ItemId::root(); } + ItemId::from_storage(s) + .or_else(|| ItemId::parse(s)) + .unwrap_or_else(|| ItemId::from_legacy_scope(s)) } pub fn apply_records( @@ -68,15 +76,11 @@ pub fn apply_records( vote_parents.insert(parent); } Event::NodeEnsured { id } => { - let parsed = ItemId::parse(id) - .or_else(|| ItemId::from_url(id)) - .ok_or_else(|| EventLogError::Apply(format!("invalid node id: {id}")))?; + let parsed = parse_event_id(id)?; ensure_path_writes(&mut batch, &parsed); } Event::EntityImported { id, payload, .. } => { - let parsed = ItemId::parse(id) - .or_else(|| ItemId::from_url(id)) - .ok_or_else(|| EventLogError::Apply(format!("invalid entity id: {id}")))?; + let parsed = parse_event_id(id)?; let view = entity_view_from_payload(&parsed, payload); entity_view_writes(&mut batch, &parsed, view.as_ref()); entity_store @@ -92,7 +96,6 @@ pub fn apply_records( .commit_with(durable::Durability::DisableWal) .map_err(|e| EventLogError::Apply(e.to_string()))?; - // Cap recent-vote windows (idempotent, blind; not part of the cursor batch). for parent in vote_parents { projection_store .trim_recent_votes(&parent) diff --git a/server/src/reddit.rs b/server/src/reddit.rs index 72caf7c33b9dd44ea15b62b59e91227cf96a3431..20b7f9e3f8be39268a1767d09f5cf81eaa6ae0df 100644 --- a/server/src/reddit.rs +++ b/server/src/reddit.rs @@ -183,7 +183,7 @@ pub fn entity_view_from_payload( id: &ItemId, payload: &Value, ) -> Option { - if id.as_str().starts_with("reddit.com") { + if id.as_str().contains("reddit.com") { return parse_reddit_view(id, payload); } None @@ -495,41 +495,59 @@ fn rate_limit_reset_secs(resp: &reqwest::Response) -> u64 { .unwrap_or(5) } +fn reddit_path_segments(id: &ItemId) -> Option> { + let s = id.as_str(); + let rest = s + .strip_prefix("https://reddit.com/") + .or_else(|| s.strip_prefix("http://reddit.com/")) + .or_else(|| s.strip_prefix("reddit.com/"))?; + let segments: Vec = rest + .split('/') + .filter(|p| !p.is_empty()) + .map(str::to_string) + .collect(); + Some(segments) +} + pub fn map_item_to_reddit_api(id: &ItemId, api_base: &str) -> String { - let path = id.as_str(); - if !path.starts_with("reddit.com/") && path != "reddit.com" { - return String::new(); - } + let segments = match reddit_path_segments(id) { + Some(s) => s, + None if matches!( + id.as_str(), + "https://reddit.com" | "http://reddit.com" | "reddit.com" + ) => + { + return String::new(); + } + None => return String::new(), + }; let base = api_base.trim_end_matches('/'); - let segments: Vec<&str> = path.split('/').collect(); - - if let Some(i) = segments.iter().position(|&p| p == "comments") { + if let Some(i) = segments.iter().position(|p| p == "comments") { if segments.len() > i + 1 { - let api_path = segments[1..=i + 1].join("/"); + let api_path = segments[..=i + 1].join("/"); return format!("{base}/{api_path}.json?raw_json=1"); } } - if segments.len() == 3 && segments[1] == "r" { - return format!("{base}/r/{}/about.json?raw_json=1", segments[2]); + if segments.len() == 2 && segments[0] == "r" { + return format!("{base}/r/{}/about.json?raw_json=1", segments[1]); } String::new() } /// Listing URL for a node's children. Currently only subreddits -/// (`reddit.com/r/` → `/r/.json`) expose a child listing. +/// (`https://reddit.com/r/` → `/r/.json`) expose a child listing. pub fn map_children_url(id: &ItemId, api_base: &str) -> String { - let path = id.as_str(); - if !path.starts_with("reddit.com/") { - return String::new(); - } + let segments = match reddit_path_segments(id) { + Some(s) => s, + None => return String::new(), + }; let base = api_base.trim_end_matches('/'); - let segments: Vec<&str> = path.split('/').collect(); - if segments.len() == 3 && segments[1] == "r" { - return format!("{base}/r/{}.json?raw_json=1&limit=25", segments[2]); + if segments.len() == 2 && segments[0] == "r" { + return format!("{base}/r/{}.json?raw_json=1&limit=25", segments[1]); } String::new() } @@ -548,8 +566,8 @@ fn parse_children(_parent: &ItemId, payload: &Value) -> Vec<(ItemId, Value)> { Some(p) if !p.is_empty() => p, _ => continue, }; - let path = format!("reddit.com{}", permalink.trim_end_matches('/')); - if let Some(id) = ItemId::from_storage(&path) { + let raw = format!("https://reddit.com{}", permalink.trim_end_matches('/')); + if let Some(id) = ItemId::from_url(&raw) { out.push((id, child.clone())); } } @@ -683,7 +701,7 @@ mod tests { #[test] fn map_subreddit_about_url() { - let id = ItemId::parse("reddit.com/r/rust").unwrap(); + let id = ItemId::from_url("https://reddit.com/r/rust").unwrap(); assert_eq!( map_item_to_reddit_api(&id, "https://www.reddit.com"), "https://www.reddit.com/r/rust/about.json?raw_json=1" @@ -699,7 +717,8 @@ mod tests { let json = include_str!("../../test/fixtures/reddit/r_rust_about.json"); let v: Value = serde_json::from_str(json).unwrap(); let entity = - entity_view_from_payload(&ItemId::parse("reddit.com/r/rust").unwrap(), &v).unwrap(); + entity_view_from_payload(&ItemId::from_url("https://reddit.com/r/rust").unwrap(), &v) + .unwrap(); assert_eq!(entity.title, "The Rust Programming Language"); } @@ -707,7 +726,8 @@ mod tests { fn parse_post_listing_extracts_thumb_and_full_preview() { let json = include_str!("../../test/fixtures/reddit/post_preview.json"); let v: Value = serde_json::from_str(json).unwrap(); - let id = ItemId::parse("reddit.com/r/nsfw/comments/1tpy6a1/angel_eyes").unwrap(); + let id = + ItemId::from_url("https://reddit.com/r/nsfw/comments/1tpy6a1/angel_eyes").unwrap(); let entity = entity_view_from_payload(&id, &v).unwrap(); assert_eq!(entity.title, "Angel Eyes"); assert!(entity.thumb_url.as_ref().unwrap().contains("width=140")); diff --git a/server/src/reducer.rs b/server/src/reducer.rs index 6578f64a41726845517cdbf59a359c69e0aa56db..5179ddeca7a7cb0fb92dfc4aa9d5a80bd9125611 100644 --- a/server/src/reducer.rs +++ b/server/src/reducer.rs @@ -248,16 +248,18 @@ mod from_recorded_tests { #[test] fn ensure_path_wires_children() { let mut tree = GlobalTree::new(); - let id = ItemId::parse("reddit.com/r/rust").unwrap(); + let id = ItemId::from_url("https://reddit.com/r/rust").unwrap(); tree.ensure_path(&id); let root = tree.get(&ItemId::root()).unwrap(); assert!(root .children - .contains(&ItemId::parse("reddit.com").unwrap())); - let reddit = tree.get(&ItemId::parse("reddit.com").unwrap()).unwrap(); + .contains(&ItemId::from_url("https://reddit.com").unwrap())); + let reddit = tree + .get(&ItemId::from_url("https://reddit.com").unwrap()) + .unwrap(); assert!(reddit .children - .contains(&ItemId::parse("reddit.com/r").unwrap())); + .contains(&ItemId::from_url("https://reddit.com/r").unwrap())); let sub = tree.get(&id).unwrap(); assert_eq!(sub.id, id); } diff --git a/server/src/render/reddit.rs b/server/src/render/reddit.rs index 7f840aa33b734a31d8cf3341a0581c8bcb9bbcf3..595e202436040b0bfc419e68f083f94757ba5d0c 100644 --- a/server/src/render/reddit.rs +++ b/server/src/render/reddit.rs @@ -9,7 +9,7 @@ use crate::{ }; pub fn is_reddit_post(id: &ItemId) -> bool { - id.as_str().starts_with("reddit.com/") && id.as_str().contains("/comments/") + id.as_str().contains("reddit.com/") && id.as_str().contains("/comments/") } /// Post detail card (inside [`crate::fetch::html::entity_panel`]). diff --git a/server/src/state.rs b/server/src/state.rs index 78126f08d90f8069a586279d79258c27a9f9f7a4..513b329ef45fc332e63b3f8ed8498a1f59feb07c 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -217,15 +217,21 @@ impl AppState { ratio_right: i32, ) -> Result<(), String> { let ts = crate::html::now_ms(); - let vote = VoteData::from_recorded(ts, a, b, ratio_left, ratio_right) - .ok_or_else(|| "invalid vote: need two distinct non-empty items".to_string())?; + let a_raw = a.trim(); + let b_raw = b.trim(); + if a_raw.is_empty() || b_raw.is_empty() || a_raw == b_raw { + return Err("invalid vote: need two distinct non-empty items".to_string()); + } + // Validate items canonicalize (or are opaque keys) before append. + let _ = VoteData::from_recorded(ts, a_raw, b_raw, ratio_left, ratio_right) + .ok_or_else(|| "invalid vote: need two distinct parseable items".to_string())?; let event = Event::VoteRecorded { ts, - a: vote.a.as_str().to_string(), - b: vote.b.as_str().to_string(), - ratio_left: vote.ratio_left, - ratio_right: vote.ratio_right, + a: a_raw.to_string(), + b: b_raw.to_string(), + ratio_left, + ratio_right, scope: parent.as_str().to_string(), }; @@ -253,7 +259,7 @@ mod tests { let log = EventLog::new(log_path.to_string_lossy().into_owned()); let payload = json!({"kind":"t5","data":{"title":"Rust","display_name":"rust"}}); let event = Event::EntityImported { - id: "reddit.com/r/rust".into(), + id: "https://reddit.com/r/rust".into(), ts: 1, payload: payload.clone(), }; @@ -266,14 +272,14 @@ mod tests { .await .unwrap(); let tree = projection_store - .scope_tree(&ItemId::parse("reddit.com/r/rust").unwrap()) + .scope_tree(&ItemId::parse("https://reddit.com/r/rust").unwrap()) .unwrap(); let node = tree - .get(&ItemId::parse("reddit.com/r/rust").unwrap()) + .get(&ItemId::parse("https://reddit.com/r/rust").unwrap()) .unwrap(); assert_eq!(node.data.as_ref().unwrap().title, "Rust"); let stored = entity_store - .get(&ItemId::parse("reddit.com/r/rust").unwrap()) + .get(&ItemId::parse("https://reddit.com/r/rust").unwrap()) .unwrap() .unwrap(); assert_eq!(stored["data"]["display_name"], "rust"); @@ -289,13 +295,13 @@ mod tests { event_record( 1, Event::NodeEnsured { - id: "reddit.com/r/rust".into(), + id: "https://reddit.com/r/rust".into(), }, ), event_record( 2, Event::EntityImported { - id: "reddit.com/r/rust".into(), + id: "https://reddit.com/r/rust".into(), ts: 2, payload: payload.clone(), }, @@ -325,7 +331,7 @@ mod tests { &[event_record( 1, Event::NodeEnsured { - id: "reddit.com/r/stale".into(), + id: "https://reddit.com/r/stale".into(), }, )], ) @@ -352,11 +358,11 @@ mod tests { let root = tree.get(&ItemId::root()).unwrap(); assert!(root.children.contains(&ItemId::parse("alpha").unwrap())); assert!(projection_store - .load_node(&ItemId::parse("reddit.com/r/stale").unwrap()) + .load_node(&ItemId::parse("https://reddit.com/r/stale").unwrap()) .unwrap() .is_none()); let stored = entity_store - .get(&ItemId::parse("reddit.com/r/rust").unwrap()) + .get(&ItemId::parse("https://reddit.com/r/rust").unwrap()) .unwrap() .unwrap(); assert_eq!(stored["data"]["display_name"], "rust"); @@ -370,7 +376,7 @@ mod tests { log.append(&event_record( 1, Event::NodeEnsured { - id: "reddit.com/r/rust".into(), + id: "https://reddit.com/r/rust".into(), }, )) .await @@ -387,7 +393,7 @@ mod tests { &[event_record( 2, Event::NodeEnsured { - id: "reddit.com/r/rust".into(), + id: "https://reddit.com/r/rust".into(), }, )], ) @@ -454,7 +460,7 @@ mod tests { port: 0, }) .await; - let id = ItemId::parse("reddit.com/r/rust").unwrap(); + let id = ItemId::parse("https://reddit.com/r/rust").unwrap(); state.ensure_node(&id).await.unwrap(); @@ -465,11 +471,11 @@ mod tests { let projected = state.projection_store.load_tree().unwrap(); assert!(projected.get(&id).is_some()); let reddit = projected - .get(&ItemId::parse("reddit.com").unwrap()) + .get(&ItemId::from_url("https://reddit.com").unwrap()) .unwrap(); assert!(reddit .children - .contains(&ItemId::parse("reddit.com/r").unwrap())); + .contains(&ItemId::from_url("https://reddit.com/r").unwrap())); } #[tokio::test] @@ -510,7 +516,7 @@ mod tests { log.append(&event_record( 1, Event::NodeEnsured { - id: "reddit.com/r/rust".into(), + id: "https://reddit.com/r/rust".into(), }, )) .await @@ -518,7 +524,7 @@ mod tests { log.append(&event_record( 2, Event::NodeEnsured { - id: "reddit.com/r/python".into(), + id: "https://reddit.com/r/python".into(), }, )) .await @@ -544,13 +550,13 @@ mod tests { 2 ); let tree = second - .scope_tree(&ItemId::parse("reddit.com/r/rust").unwrap()) + .scope_tree(&ItemId::parse("https://reddit.com/r/rust").unwrap()) .unwrap(); assert!(tree - .get(&ItemId::parse("reddit.com/r/rust").unwrap()) + .get(&ItemId::parse("https://reddit.com/r/rust").unwrap()) .is_some()); assert!(tree - .get(&ItemId::parse("reddit.com/r/python").unwrap()) + .get(&ItemId::parse("https://reddit.com/r/python").unwrap()) .is_none()); } @@ -611,7 +617,7 @@ mod tests { #[test] fn parse_item_param_from_url() { let id = parse_item_param("https://reddit.com/r/rust"); - assert_eq!(id.as_str(), "reddit.com/r/rust"); + assert_eq!(id.as_str(), "https://reddit.com/r/rust"); } #[test] diff --git a/server/src/url_rules/engine.rs b/server/src/url_rules/engine.rs new file mode 100644 index 0000000000000000000000000000000000000000..e29b6b48c08deb7bffe031b1e542b1e25a7bef15 --- /dev/null +++ b/server/src/url_rules/engine.rs @@ -0,0 +1,187 @@ +//! Composable URL normalization primitives. + +use std::collections::HashMap; + +use url::Url; + +/// Mutable URL view used by rule combinators before serializing to a canonical string. +#[derive(Debug, Clone)] +pub struct ParsedUrl { + pub scheme: String, + pub host: String, + pub path_segments: Vec, + pub query: HashMap, + pub fragment: Option, +} + +impl ParsedUrl { + pub fn parse(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + let with_scheme = if trimmed.contains("://") { + trimmed.to_string() + } else if trimmed.starts_with("r/") || trimmed.starts_with("/r/") { + let rest = trimmed.trim_start_matches('/').trim_start_matches("r/"); + format!("https://reddit.com/r/{rest}") + } else if trimmed.contains('.') && !trimmed.starts_with('/') { + format!("https://{trimmed}") + } else { + trimmed.to_string() + }; + + let url = Url::parse(&with_scheme).ok()?; + let host = url.host_str()?.to_string(); + let path_segments: Vec = url + .path_segments() + .map(|segs| segs.filter(|s| !s.is_empty()).map(str::to_string).collect()) + .unwrap_or_default(); + + let mut query = HashMap::new(); + for (k, v) in url.query_pairs() { + query.insert(k.into_owned(), v.into_owned()); + } + + Some(Self { + scheme: url.scheme().to_string(), + path_segments, + query, + fragment: url.fragment().map(str::to_string), + host, + }) + } + + pub fn with_path_segments(&self, segments: &[String]) -> Self { + let mut u = self.clone(); + u.path_segments = segments.to_vec(); + u + } + + pub fn to_url(&self) -> Option { + let mut url = if self.path_segments.is_empty() { + Url::parse(&format!("{}://{}", self.scheme, self.host)).ok()? + } else { + let path = format!("/{}", self.path_segments.join("/")); + Url::parse(&format!("{}://{}{}", self.scheme, self.host, path)).ok()? + }; + if !self.query.is_empty() { + let mut pairs: Vec<_> = self.query.iter().collect(); + pairs.sort_by(|a, b| a.0.cmp(b.0)); + url.query_pairs_mut().clear(); + for (k, v) in pairs { + url.query_pairs_mut().append_pair(k, v); + } + } + if let Some(ref frag) = self.fragment { + url.set_fragment(Some(frag)); + } + Some(url) + } + + pub fn canonical_string(&self) -> Option { + let url = self.to_url()?; + let mut s = url.to_string(); + if self.path_segments.is_empty() { + s = s.trim_end_matches('/').to_string(); + } + Some(s) + } +} + +pub fn force_https(u: &mut ParsedUrl) { + if u.scheme == "http" { + u.scheme = "https".to_string(); + } +} + +pub fn drop_fragment(u: &mut ParsedUrl) { + u.fragment = None; +} + +pub fn strip_www(u: &mut ParsedUrl) { + if u.host.starts_with("www.") { + u.host = u.host[4..].to_string(); + } +} + +pub fn lowercase_host(u: &mut ParsedUrl) { + u.host = u.host.to_ascii_lowercase(); +} + +pub fn lowercase_path(u: &mut ParsedUrl) { + for seg in &mut u.path_segments { + *seg = seg.to_ascii_lowercase(); + } +} + +pub fn clear_query(u: &mut ParsedUrl) { + u.query.clear(); +} + +pub fn keep_only_query(u: &mut ParsedUrl, keys: &[&str]) { + u.query + .retain(|k, _| keys.iter().any(|want| want == &k.as_str())); +} + +pub fn strip_tracking_params(u: &mut ParsedUrl) { + u.query.retain(|k, _| { + let lower = k.to_ascii_lowercase(); + !(lower.starts_with("utm_") + || matches!( + lower.as_str(), + "fbclid" | "gclid" | "ref" | "ref_src" | "ref_source" | "mc_cid" | "mc_eid" + )) + }); +} + +pub fn truncate_after_segment(u: &mut ParsedUrl, name: &str, keep: usize) { + if let Some(i) = u.path_segments.iter().position(|s| s == name) { + let end = (i + 1 + keep).min(u.path_segments.len()); + u.path_segments.truncate(end); + } +} + +pub fn drop_listing_suffix(u: &mut ParsedUrl, suffixes: &[&str]) { + if u.path_segments.len() >= 3 && u.path_segments.first().map(String::as_str) == Some("r") { + if let Some(last) = u.path_segments.last() { + if suffixes.iter().any(|s| *s == last.as_str()) { + u.path_segments.pop(); + } + } + } +} + +pub fn normalize_reddit_host(u: &mut ParsedUrl) { + if matches!( + u.host.as_str(), + "old.reddit.com" | "new.reddit.com" | "www.reddit.com" + ) { + u.host = "reddit.com".to_string(); + } +} + +pub fn rewrite_youtu_be(u: &mut ParsedUrl) { + if u.host == "youtu.be" && u.path_segments.len() == 1 { + let id = u.path_segments[0].clone(); + u.host = "youtube.com".to_string(); + u.path_segments = vec!["watch".to_string()]; + u.query.insert("v".to_string(), id); + } +} + +pub fn rewrite_youtube_shorts(u: &mut ParsedUrl) { + if u.host == "youtube.com" && u.path_segments.first().map(String::as_str) == Some("shorts") { + if let Some(id) = u.path_segments.get(1).cloned() { + u.path_segments = vec!["watch".to_string()]; + u.query.insert("v".to_string(), id); + } + } +} + +pub fn normalize_youtube_host(u: &mut ParsedUrl) { + if matches!(u.host.as_str(), "m.youtube.com" | "www.youtube.com") { + u.host = "youtube.com".to_string(); + } +} diff --git a/server/src/url_rules/mod.rs b/server/src/url_rules/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..03d53bd3e82d704a01ba3fd8dd02b7d31422c0de --- /dev/null +++ b/server/src/url_rules/mod.rs @@ -0,0 +1,13 @@ +//! URL canonicalization and hierarchy rules for [`crate::path_types::ItemId`]. + +mod engine; +mod registry; + +pub use registry::{ + canonicalize_raw, looks_like_url, navigable_breadcrumbs, parent_url, resolve_id, CanonicalResult, +}; + +/// Resolve raw input to canonical URL. +pub fn resolve_canonical(raw: &str) -> Option { + canonicalize_raw(raw.trim()).map(|r| r.canonical) +} diff --git a/server/src/url_rules/registry.rs b/server/src/url_rules/registry.rs new file mode 100644 index 0000000000000000000000000000000000000000..14514e9af8385fb2b9b2f35eb9ee14d453d4b97c --- /dev/null +++ b/server/src/url_rules/registry.rs @@ -0,0 +1,235 @@ +//! Per-domain canonicalization and hierarchy rules. + +use std::collections::HashSet; + +use super::engine::{ + clear_query, drop_fragment, drop_listing_suffix, force_https, keep_only_query, lowercase_host, + lowercase_path, normalize_reddit_host, normalize_youtube_host, rewrite_youtu_be, + rewrite_youtube_shorts, strip_tracking_params, strip_www, truncate_after_segment, ParsedUrl, +}; + +/// Result of canonicalizing a raw URL string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanonicalResult { + pub canonical: String, + /// When the input normalizes to a different string, the original is an alias. + pub alias_of: Option, +} + +fn apply_global(u: &mut ParsedUrl) { + force_https(u); + drop_fragment(u); + strip_www(u); + lowercase_host(u); + strip_tracking_params(u); +} + +fn normalize_reddit(u: &mut ParsedUrl) { + normalize_reddit_host(u); + lowercase_path(u); + truncate_after_segment(u, "comments", 1); + drop_listing_suffix(u, &["hot", "top", "new", "rising", "controversial"]); + clear_query(u); +} + +fn normalize_youtube(u: &mut ParsedUrl) { + rewrite_youtu_be(u); + normalize_youtube_host(u); + rewrite_youtube_shorts(u); + keep_only_query(u, &["v", "list"]); +} + +fn normalize_default(_u: &mut ParsedUrl) { + // Global rules only. +} + +fn domain_key(host: &str) -> &'static str { + if host == "reddit.com" || host.ends_with(".reddit.com") { + "reddit.com" + } else if host == "youtube.com" || host == "youtu.be" { + "youtube.com" + } else { + "default" + } +} + +fn normalize_for_host(u: &mut ParsedUrl) { + apply_global(u); + match domain_key(&u.host) { + "reddit.com" => normalize_reddit(u), + "youtube.com" => normalize_youtube(u), + _ => normalize_default(u), + } +} + +/// Structural path segments that must not become standalone tree nodes when more path follows. +fn structural_trailing(host: &str) -> &'static [&'static str] { + match domain_key(host) { + "reddit.com" => &["comments"], + _ => &[], + } +} + +/// Canonicalize a raw URL. Returns `None` if the input is not URL-like. +pub fn canonicalize_raw(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + let mut u = ParsedUrl::parse(trimmed)?; + let input_snapshot = u.canonical_string()?; + normalize_for_host(&mut u); + let canonical = u.canonical_string()?; + let alias_of = if input_snapshot != canonical { + Some(trimmed.to_string()) + } else { + None + }; + Some(CanonicalResult { + canonical, + alias_of, + }) +} + +/// Resolve a stored or event id string to its canonical URL identity. +pub fn resolve_id(raw: &str) -> Option { + canonicalize_raw(raw).map(|r| r.canonical) +} + +/// Navigable ancestor URLs from domain root up to and including `canonical` (full URLs). +pub fn navigable_breadcrumbs(canonical: &str) -> Vec { + let Some(u) = ParsedUrl::parse(canonical) else { + return vec![canonical.to_string()]; + }; + let structural: HashSet<&str> = structural_trailing(&u.host).iter().copied().collect(); + let n = u.path_segments.len(); + let mut out = Vec::new(); + + // Domain root (no path segments). + if let Some(base) = u.with_path_segments(&[]).canonical_string() { + out.push(base); + } + + for i in 0..n { + let segs: Vec = u.path_segments[..=i].to_vec(); + let is_last = i == n - 1; + let seg = u.path_segments[i].as_str(); + if structural.contains(seg) && !is_last { + continue; + } + if let Some(url) = u.with_path_segments(&segs).canonical_string() { + if out.last() != Some(&url) { + out.push(url); + } + } + } + out +} + +/// Immediate parent scope URL, or `None` for tree root / opaque single-segment ids. +pub fn parent_url(canonical: &str) -> Option { + let crumbs = navigable_breadcrumbs(canonical); + if crumbs.len() <= 1 { + None + } else { + crumbs.get(crumbs.len() - 2).cloned() + } +} + +/// True when `raw` looks like a URL (has scheme or host-like shape). +pub fn looks_like_url(raw: &str) -> bool { + let t = raw.trim(); + t.contains("://") + || t.starts_with("r/") + || t.starts_with("/r/") + || (t.contains('.') && t.contains('/')) + || t.starts_with("reddit.com") + || t.starts_with("www.") + || t.starts_with("youtu.be/") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reddit_post_drops_slug_and_normalizes_host() { + let r = canonicalize_raw( + "https://old.reddit.com/r/AmItheAsshole/comments/1trnvdl/aita_for_cancelling/", + ) + .unwrap(); + assert_eq!( + r.canonical, + "https://reddit.com/r/amitheasshole/comments/1trnvdl" + ); + } + + #[test] + fn reddit_strips_query_and_listing() { + assert_eq!( + canonicalize_raw("https://www.reddit.com/r/rust/?sort=top") + .unwrap() + .canonical, + "https://reddit.com/r/rust" + ); + assert_eq!( + canonicalize_raw("https://www.reddit.com/r/programming/hot") + .unwrap() + .canonical, + "https://reddit.com/r/programming" + ); + } + + #[test] + fn reddit_short_path() { + assert_eq!( + canonicalize_raw("r/rust").unwrap().canonical, + "https://reddit.com/r/rust" + ); + } + + #[test] + fn reddit_breadcrumbs_skip_phantom_comments() { + let post = "https://reddit.com/r/aww/comments/1trnvdl"; + let crumbs = navigable_breadcrumbs(post); + assert!(!crumbs.iter().any(|c| c.ends_with("/comments"))); + assert_eq!( + crumbs.last().map(String::as_str), + Some(post) + ); + assert!(crumbs.contains(&"https://reddit.com/r/aww".to_string())); + } + + #[test] + fn reddit_parent_of_post_is_subreddit() { + assert_eq!( + parent_url("https://reddit.com/r/aww/comments/1trnvdl").as_deref(), + Some("https://reddit.com/r/aww") + ); + } + + #[test] + fn youtube_youtu_be_and_watch_same_canonical() { + let a = canonicalize_raw("https://youtu.be/dQw4w9WgXcQ").unwrap().canonical; + let b = canonicalize_raw("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=10").unwrap(); + assert_eq!(a, b.canonical); + assert_eq!(a, "https://youtube.com/watch?v=dQw4w9WgXcQ"); + } + + #[test] + fn legacy_schemeless_upgrades() { + assert_eq!( + canonicalize_raw("reddit.com/r/rust/comments/aaa/announcing_rust_199") + .unwrap() + .canonical, + "https://reddit.com/r/rust/comments/aaa" + ); + } + + #[test] + fn alias_recorded_when_input_differs() { + let r = canonicalize_raw("https://youtu.be/abc123").unwrap(); + assert_eq!(r.canonical, "https://youtube.com/watch?v=abc123"); + assert!(r.alias_of.is_some()); + } +} Side B — contributor: tommy-mor Side B — commit message: [80ad7753] refactor: split canonical_path and identity; strict wire identity without @ - Add canonical_path.rs (tag + item URL normalization) and identity.rs (parse_username/parse_agent; reject @ in API input). - Slim events.rs to event types only; reducer applies no identity rewriting. - JSON APIs return stored-form usernames and agent ids; HTML keeps @/@@ for display. - Optional delegate on ingest; CLI and tests use naked uuid:rig:model. Made-with: Cursor Side B — unified diff (full patch): diff --git a/cli/src/main.rs b/cli/src/main.rs index 5ac8e289f2b7a366b5959d9338d02f9b546f408a..630c5dea1f78c0ec9bc53e6b96234a0dc75bb705 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -61,8 +61,8 @@ enum Command { /// Example: --before 2026-06-01 #[arg(long, value_name = "DATE_OR_MS")] before: Option, - /// Filter to posts from this actor (UUID prefix match). - /// Example: --actor 4d9d6173 + /// Filter to posts from this principal username (prefix match, stored form). + /// Example: --actor alice #[arg(long, value_name = "PREFIX")] actor: Option, /// Fetch a single post by its ingest ID (from --json output). @@ -75,9 +75,8 @@ enum Command { /// /// SYNTAX: /// - /// Actor (required, once per document): - /// @:: - /// Example: @7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet + /// Identity: human comes from the bearer token; optional AI delegate from `--delegate` + /// (`uuid:rig:provider/model`). The document body is DSL only (items, votes, prose) — no `@` lines. /// /// Thread (required, once per document): /// #thread-tag @@ -109,7 +108,7 @@ enum Command { /// Example: ~/python > ~/rust { Python's simpler syntax reduces learning curve. } /// /// Prose (optional, anywhere): - /// Any line that doesn't start with @, #, or ~ is prose. + /// Any line that doesn't start with # or ~ (or `http`) is prose. /// Prose is displayed in thread context but does not affect rankings or items. /// Use prose to write blog posts, reasoning, or notes within your ingest. /// @@ -125,8 +124,7 @@ enum Command { /// EXAMPLES: /// /// # From heredoc (recommended for agents) - /// npx slugsocial ingest << 'EOF' - /// @7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet + /// npx slugsocial ingest --delegate '7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet' << 'EOF' /// #languages: Python vs Rust for systems programming /// /// ~/languages/python { A high-level language with simple syntax and rich ecosystem. } @@ -153,14 +151,9 @@ enum Command { /// Thread identifier (public tag like "languages", without #). #[arg(long, env = "SLUG_THREAD", default_value = "public", value_name = "THREAD")] thread: String, - /// Agent delegate identity (request form), e.g. @@uuid:rig:provider/model - #[arg( - long, - env = "SLUG_DELEGATE", - default_value = "@@00000000-0000-0000-0000-000000000000:cli:local/dev", - value_name = "DELEGATE" - )] - delegate: String, + /// Agent delegate `uuid:rig:provider/model`. Omit for human-only ingests. + #[arg(long, env = "SLUG_DELEGATE", value_name = "DELEGATE")] + delegate: Option, /// Output as JSON for agent parsing #[arg(long)] json: bool, @@ -174,14 +167,9 @@ enum Command { /// Thread identifier (public tag like "languages", without #). #[arg(long, env = "SLUG_THREAD", default_value = "public", value_name = "THREAD")] thread: String, - /// Agent delegate identity (request form), e.g. @@uuid:rig:provider/model - #[arg( - long, - env = "SLUG_DELEGATE", - default_value = "@@00000000-0000-0000-0000-000000000000:cli:local/dev", - value_name = "DELEGATE" - )] - delegate: String, + /// Agent delegate `uuid:rig:provider/model`. Omit for human-only ingests. + #[arg(long, env = "SLUG_DELEGATE", value_name = "DELEGATE")] + delegate: Option, /// Output as JSON for agent parsing #[arg(long)] json: bool, @@ -193,10 +181,10 @@ enum Command { /// Useful for agents to catch up on activity after a context reset. /// /// Examples: - /// npx slugsocial feed @:: - /// npx slugsocial feed @:: --since 2026-01-01 + /// npx slugsocial feed tommy + /// npx slugsocial feed tommy --since 2026-01-01 Feed { - /// Actor identifier (@uuid:rig:model) + /// Principal username (stored form) #[arg(value_name = "ACTOR")] actor: String, /// Override the lower bound. Accepts Unix ms or YYYY-MM-DD. @@ -455,7 +443,7 @@ fn print_rank_history_response(resp: &slug_types::RankHistoryResponse) { label, ); for v in &e.caused_by { - println!(" {} {} {} {}", v.a, v.ratio, v.b, v.actor.as_deref().map(|a| format!(" (@{})", a)).unwrap_or_default()); + println!(" {} {} {} {}", v.a, v.ratio, v.b, v.actor.as_deref().map(|a| format!(" ({})", a)).unwrap_or_default()); if !v.body.is_empty() { println!(" {}", v.body.lines().next().unwrap_or(&v.body).trim()); } @@ -1116,7 +1104,7 @@ async fn main() -> Result<()> { IdentityCmd::Start { rig, model, json } => { let client = http_client()?; let uuid = uuid::Uuid::new_v4().to_string(); - let delegate = format!("@@{}:{}:{}", uuid, rig, model); + let delegate = format!("{uuid}:{rig}:{model}"); let start: PendingSessionStartResponse = expect_json( client diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs index cb0faa29b834931e2c2b2f5c174c875e2e2e9346..995ce4a61d29b024c399c656134f541ecfd880cf 100644 --- a/server/src/api/auth.rs +++ b/server/src/api/auth.rs @@ -12,10 +12,8 @@ use tokio::sync::RwLock; use crate::{ api::helpers::{api_error, now_ms, sha256_hex}, - events::{ - canonicalize_username, validate_agent_format, validate_username, - Event, TokenIssued, UserRegistered, - }, + events::{Event, TokenIssued, UserRegistered}, + identity::{parse_agent, parse_username}, html::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page}, state::{AppState, PendingSession}, }; @@ -77,9 +75,9 @@ fn verify_token(reduced: &crate::reducer::ReducerState, bearer: &str) -> Result< Ok(username) } -fn issue_token_for_user(username: &str) -> (String, TokenIssued, String) { - // Returns: (bearer, event, canonical_username) - let canonical_user = canonicalize_username(username); +/// `stored_username` must already be in persisted shape (lowercase slug, no `@`). +fn issue_token_for_user(stored_username: &str) -> (String, TokenIssued) { + let username = stored_username.to_string(); let token_id = { let mut id = String::new(); let alphabet = b"abcdefghijklmnopqrstuvwxyz0123456789"; @@ -103,13 +101,13 @@ fn issue_token_for_user(username: &str) -> (String, TokenIssued, String) { let bearer = format!("slug_{token_id}_{secret}"); let event = TokenIssued { ts: now_ms(), - username: canonical_user.clone(), + username: username.clone(), token_id, token_hash, salt, issued_via: "oauth".to_string(), }; - (bearer, event, canonical_user) + (bearer, event) } #[derive(Debug, Deserialize)] @@ -207,7 +205,7 @@ pub async fn get_auth_callback(Query(q): Query, State(state): s.provider = Some("google".to_string()); s.provider_id = Some(sub.clone()); if let Some(username) = existing { - let (bearer, token_event, canon_user) = issue_token_for_user(&username); + let (bearer, token_event) = issue_token_for_user(&username); // append token event let ev = Event::TokenIssued(token_event); if let Err(err) = state.event_log.append(&ev).await { @@ -217,7 +215,7 @@ pub async fn get_auth_callback(Query(q): Query, State(state): let mut reduced = reduced_arc.write().await; reduced.apply_event(ev); } - s.complete = Some((canon_user, bearer)); + s.complete = Some((username, bearer)); return Redirect::temporary(&format!("{public_url}/auth/complete")).into_response(); } } @@ -251,9 +249,10 @@ pub async fn post_choose_username( State(state): State, Form(form): Form, ) -> impl IntoResponse { - if let Err(msg) = validate_username(&form.username) { - return api_error(StatusCode::BAD_REQUEST, "invalid username", Some(msg)).into_response(); - } + let canon_user = match parse_username(&form.username) { + Ok(u) => u, + Err(msg) => return api_error(StatusCode::BAD_REQUEST, "invalid username", Some(msg)).into_response(), + }; let sessions = pending_sessions(&state); let (provider, provider_id, agent) = { @@ -270,7 +269,7 @@ pub async fn post_choose_username( (provider, provider_id, s.agent.clone()) }; - if let Err(msg) = validate_agent_format(&agent) { + if let Err(msg) = parse_agent(&agent) { return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)).into_response(); } @@ -280,7 +279,7 @@ pub async fn post_choose_username( if reduced.users_by_provider.contains_key(&provider_key) { return api_error(StatusCode::CONFLICT, "provider already registered", None).into_response(); } - if reduced.users_by_provider.values().any(|u| u == &canonicalize_username(&form.username)) { + if reduced.users_by_provider.values().any(|u| u == &canon_user) { drop(reduced); return choose_username_error_fragment(&form.session, "that username is taken — try another").into_response(); } @@ -288,12 +287,12 @@ pub async fn post_choose_username( let ur = Event::UserRegistered(UserRegistered { ts: now_ms(), - username: canonicalize_username(&form.username), + username: canon_user.clone(), provider: provider.to_lowercase(), provider_id: provider_id.clone(), }); - let (bearer, ti, canon_user) = issue_token_for_user(&form.username); + let (bearer, ti) = issue_token_for_user(&canon_user); let ti_ev = Event::TokenIssued(ti); // Persist events. @@ -325,15 +324,18 @@ pub async fn post_pending_session( State(state): State, Json(req): Json, ) -> impl IntoResponse { - if let Err(msg) = validate_agent_format(&req.agent) { - return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)).into_response(); - } + let agent_naked = match parse_agent(&req.agent) { + Ok(a) => a, + Err(msg) => { + return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)).into_response(); + } + }; let session = format!("p_{}", uuid::Uuid::new_v4().simple()); let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); let login_url = format!("{public_url}/auth/login?session={}", urlencoding::encode(&session)); let poll_url = format!("/api/v0/pending-session/{}", session); let s = PendingSession { - agent: req.agent.clone(), + agent: agent_naked, created_ts: now_ms(), provider: None, provider_id: None, @@ -359,7 +361,7 @@ pub async fn get_pending_session( return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response(); }; let (complete, user, token) = match &s.complete { - Some((u, t)) => (true, Some(format!("@{}", u)), Some(t.clone())), + Some((u, t)) => (true, Some(u.clone()), Some(t.clone())), None => (false, None, None), }; Json(PendingSessionPollResponse { @@ -388,7 +390,7 @@ pub async fn get_whoami(State(state): State, headers: HeaderMap) -> im }; let agents_bound = reduced.agent_bindings.values().filter(|u| *u == &username).count(); Json(WhoamiResponse { - user: format!("@{}", username), + user: username, agents_bound, }) .into_response() diff --git a/server/src/api/feed.rs b/server/src/api/feed.rs index f665d34aef12f2bd6e71d1fb4a3d0b4d509aa775..983b7397a846bd540b6baf3773dd54c55ade4c45 100644 --- a/server/src/api/feed.rs +++ b/server/src/api/feed.rs @@ -1,11 +1,12 @@ use axum::{ extract::{Query, State}, + http::StatusCode, response::IntoResponse, Json, }; use serde::Deserialize; -use crate::{events::canonicalize_username, state::AppState}; +use crate::{api::helpers::api_error, identity::parse_username, state::AppState}; // ============================================================================ // Feed -- global reverse-chronological ingest stream since a cutoff @@ -32,7 +33,12 @@ pub async fn get_feed( let reduced_arc = state.reduced.clone(); let reduced = reduced_arc.read().await; - let actor = canonicalize_username(&q.actor); + let actor = match parse_username(&q.actor) { + Ok(u) => u, + Err(msg) => { + return api_error(StatusCode::BAD_REQUEST, "invalid actor", Some(msg)).into_response(); + } + }; let since = q.since.or_else(|| reduced.actor_last_post_ts.get(&actor).copied()); let cutoff = since.unwrap_or(0); let limit = q.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); @@ -65,7 +71,7 @@ pub async fn get_feed( .collect(); Json(slug_types::FeedResponse { - actor: format!("@{}", actor), + actor, since, posts, total, diff --git a/server/src/api/forum.rs b/server/src/api/forum.rs index cf134e97a3aa9f2cea5efcf5bfa2564b9fc2ea46..ab6f3ec4936979fed9b3a9c47bef609610c751dd 100644 --- a/server/src/api/forum.rs +++ b/server/src/api/forum.rs @@ -8,7 +8,8 @@ use serde::Deserialize; use slug_types::*; use crate::{ - events::canonicalize_tag, + canonical_path::canonicalize_tag, + identity::parse_username, state::AppState, }; @@ -46,7 +47,7 @@ pub struct ThreadDetailQuery { pub since: Option, /// Only posts strictly before this Unix ms timestamp. pub before: Option, - /// Filter to posts whose actor starts with this prefix (UUID prefix or full actor string). + /// Filter to posts whose principal username starts with this prefix (stored form, no `@`). pub actor: Option, /// Return the single post with this ingest ID. pub post_id: Option, @@ -56,6 +57,13 @@ pub struct ThreadDetailQuery { pub async fn get_thread(State(state): State, Query(q): Query) -> impl IntoResponse { let reduced_arc = state.reduced.clone(); let tag = canonicalize_tag(&q.tag); + let actor_prefix = match q.actor.as_deref().map(str::trim) { + None | Some("") => String::new(), + Some(s) => match parse_username(s) { + Ok(u) => u, + Err(msg) => return api_error(StatusCode::BAD_REQUEST, "invalid actor filter", Some(msg)).into_response(), + }, + }; let reduced = reduced_arc.read().await; // Single post lookup by ingest ID -- return full body untruncated. @@ -72,7 +80,7 @@ pub async fn get_thread(State(state): State, Query(q): Query, Query(q): Query = all_ids .into_iter() .enumerate() @@ -119,7 +126,7 @@ pub async fn get_thread(State(state): State, Query(q): Query = match &req.delegate { + None => None, + Some(s) if s.trim().is_empty() => None, + Some(s) => match parse_agent(s) { + Ok(d) => Some(d), + Err(msg) => { + drop(reduced); + return api_error(StatusCode::BAD_REQUEST, "invalid delegate format", Some(msg)) + .into_response(); + } + }, + }; let thread_id = canonicalize_tag(&req.thread); let principal = match verify_bearer_principal(&headers, &reduced) { @@ -290,36 +296,43 @@ pub async fn post_ingest( } } - match reduced.agent_bindings.get(&delegate) { - Some(u) if u != &principal => { - drop(reduced); - return api_error( - StatusCode::FORBIDDEN, - "delegate already bound to another user", - None, - ) - .into_response(); + if let Some(ref d) = delegate { + match reduced.agent_bindings.get(d) { + Some(u) if u != &principal => { + drop(reduced); + return api_error( + StatusCode::FORBIDDEN, + "delegate already bound to another user", + None, + ) + .into_response(); + } + _ => {} } - _ => {} } - let need_agent_bind = reduced.agent_bindings.get(&delegate).is_none(); + let need_agent_bind = delegate + .as_ref() + .map(|d| reduced.agent_bindings.get(d).is_none()) + .unwrap_or(false); drop(reduced); let mut events_appended: usize = 0; if need_agent_bind { - let ab = Event::AgentBound(AgentBound { - ts: now_ms(), - agent: delegate.clone(), - username: principal.clone(), - }); - if let Err(err) = event_log.append(&ab).await { - return api_error(StatusCode::INTERNAL_SERVER_ERROR, format!("{err}"), None); - } - events_appended += 1; - { - let mut reduced = reduced_arc.write().await; - reduced.apply_event(ab); + if let Some(agent_id) = delegate.clone() { + let ab = Event::AgentBound(AgentBound { + ts: now_ms(), + agent: agent_id, + username: principal.clone(), + }); + if let Err(err) = event_log.append(&ab).await { + return api_error(StatusCode::INTERNAL_SERVER_ERROR, format!("{err}"), None); + } + events_appended += 1; + { + let mut reduced = reduced_arc.write().await; + reduced.apply_event(ab); + } } } @@ -413,12 +426,18 @@ pub async fn post_check( }; drop(reduced); - if let Err(msg) = validate_agent_format(&req.delegate) { - return api_error(StatusCode::BAD_REQUEST, "invalid delegate format", Some(msg)).into_response(); - } - let delegate = canonicalize_agent(&req.delegate); + let delegate: Option = match &req.delegate { + None => None, + Some(s) if s.trim().is_empty() => None, + Some(s) => match parse_agent(s) { + Ok(d) => Some(d), + Err(msg) => { + return api_error(StatusCode::BAD_REQUEST, "invalid delegate format", Some(msg)).into_response(); + } + }, + }; let thread_id = canonicalize_tag(&req.thread); - let principal = canonicalize_username("placeholder"); + let principal = "placeholder".to_string(); let event = Event::Ingest(Ingest { ts: v.ts, diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index f1d5c139b875c268cc46c3085c332d8de31b092e..f42e907775645166c60aeae2d98c5855223a71be 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -65,7 +65,7 @@ mod tests { id: format!("test-{ts}"), raw: raw.to_string(), principal: "test".to_string(), - delegate: "@00000000-0000-0000-0000-000000000000:test:local/test".to_string(), + delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()), thread_id: "t".to_string(), })); } diff --git a/server/src/api/rank.rs b/server/src/api/rank.rs index 059095bb512096da5e9699ff9c4b92cb7de7fa1c..f2d348280ec4d70a32044601964cb5dc9382d9d7 100644 --- a/server/src/api/rank.rs +++ b/server/src/api/rank.rs @@ -226,7 +226,7 @@ pub async fn get_rank_history( let reduced = reduced_arc.read().await; let content = reduced.public(); - let item_str = crate::events::canonicalize_item(&q.item); + let item_str = crate::canonical_path::canonicalize_item(&q.item); let item = CanonicalItemUrl(item_str.clone()); let entries = content.rank_history.get(&item).cloned().unwrap_or_default(); @@ -238,15 +238,15 @@ pub async fn get_rank_history( .map(|doc| { doc.statements.into_iter().filter_map(|s| { if let crate::dsl::Stmt::Vote { item1, item2, ratio_left, ratio_right, explanation } = s { - let a = crate::events::canonicalize_item(&item1); - let b = crate::events::canonicalize_item(&item2); + let a = crate::canonical_path::canonicalize_item(&item1); + let b = crate::canonical_path::canonicalize_item(&item2); if a == item_str || b == item_str { Some(VoteRow { ts: e.ts, a: item_path_for_api(&a), b: item_path_for_api(&b), ratio: format!("{}:{}", ratio_left, ratio_right), - actor: reduced.ingests_by_id.get(&e.post_id).map(|ing| format!("@{}", ing.principal)), + actor: reduced.ingests_by_id.get(&e.post_id).map(|ing| ing.principal.clone()), body: explanation, thread: Some(format!("#{}", e.thread)), }) diff --git a/server/src/api/search.rs b/server/src/api/search.rs index 8028c3117b9b22460fb9677ca969741f6849d2d0..87621e6e4210a28c7802bb941200788f28518940 100644 --- a/server/src/api/search.rs +++ b/server/src/api/search.rs @@ -119,7 +119,7 @@ pub async fn get_search( .unwrap_or_else(|| "#unknown".to_string()); scored_posts.push((score, ingest.ts, slug_types::SearchPostHit { thread, - actor: format!("@{}", ingest.principal), + actor: ingest.principal.clone(), snippet: snippet_around(&ingest.raw, &words, 160), ts: ingest.ts, })); diff --git a/server/src/api/thread.rs b/server/src/api/thread.rs index 576b0b5be2767b0c6cba5e0701e4ffc9f215029d..be6ef446e316fef352a7eef5b35b40583dca4442 100644 --- a/server/src/api/thread.rs +++ b/server/src/api/thread.rs @@ -8,9 +8,8 @@ use serde::{Deserialize, Serialize}; use crate::{ api::helpers::{api_error, now_ms}, - events::{ - canonicalize_username, Event, GrantAdded, ThreadCapability, ThreadCreated, ThreadVisibility, - }, + events::{Event, GrantAdded, ThreadCapability, ThreadCreated, ThreadVisibility}, + identity::parse_username, state::AppState, }; use super::auth::verify_bearer_principal; @@ -150,10 +149,10 @@ pub async fn post_thread_grants( return api_error(StatusCode::FORBIDDEN, "requires Manage capability", None).into_response(); } - let target = canonicalize_username(&req.username); - if target.is_empty() { - return api_error(StatusCode::BAD_REQUEST, "invalid username", None).into_response(); - } + let target = match parse_username(&req.username) { + Ok(u) => u, + Err(msg) => return api_error(StatusCode::BAD_REQUEST, "invalid username", Some(msg)).into_response(), + }; if !reduced.users_by_provider.values().any(|u| u == &target) { return api_error(StatusCode::NOT_FOUND, format!("user @{target} not found"), None).into_response(); } diff --git a/server/src/canonical_path.rs b/server/src/canonical_path.rs new file mode 100644 index 0000000000000000000000000000000000000000..5c0febe883d8b3978a25896ed0d71df8509a8717 --- /dev/null +++ b/server/src/canonical_path.rs @@ -0,0 +1,92 @@ +//! Normalization for thread tags and ontology item URLs (DSL ↔ stored canonical form). +//! Not event types — see `events` and `path_types`. + +/// Thread / public tag: stored without leading `#`, lowercase. +pub fn canonicalize_tag(input: &str) -> String { + input.trim().trim_start_matches('#').to_lowercase() +} + +/// Ontology item reference → canonical absolute URL on the slug host. +pub fn canonicalize_item(input: &str) -> String { + let s = input.trim(); + if s.is_empty() { + return String::new(); + } + + if let Some(rest) = s.strip_prefix("https://") { + let (host, tail) = rest.split_once('/').map_or((rest, ""), |(h, t)| (h, t)); + let host = host.trim().to_lowercase(); + if tail.is_empty() { + return format!("https://{}", host); + } else { + return format!("https://{}/{}", host, tail); + } + } + if let Some(rest) = s.strip_prefix("http://") { + let (host, tail) = rest.split_once('/').map_or((rest, ""), |(h, t)| (h, t)); + let host = host.trim().to_lowercase(); + if tail.is_empty() { + return format!("http://{}", host); + } else { + return format!("http://{}/{}", host, tail); + } + } + + let is_tilde = s.starts_with("~/"); + let rest = s.strip_prefix("~/").or_else(|| s.strip_prefix("/")).unwrap_or(s); + + let tail = rest + .split('/') + .filter_map(|seg| { + let t = seg.trim(); + if t.is_empty() { + None + } else { + Some(t.to_lowercase()) + } + }) + .collect::>() + .join("/"); + + if is_tilde { + format!("https://slug.social/~/{}", tail) + } else if tail.is_empty() { + "https://slug.social".to_string() + } else { + format!("https://slug.social/{}", tail) + } +} + +pub fn item_path_segments(input: &str) -> Vec { + let canonical = canonicalize_item(input); + if canonical.is_empty() { + return vec![]; + } + + if let Some(rest) = canonical.strip_prefix("https://") { + let (host, tail) = rest.split_once('/').map_or((rest, ""), |(h, t)| (h, t)); + let mut out = vec![format!("https://{}", host)]; + out.extend(tail.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string())); + return out; + } + if let Some(rest) = canonical.strip_prefix("http://") { + let (host, tail) = rest.split_once('/').map_or((rest, ""), |(h, t)| (h, t)); + let mut out = vec![format!("http://{}", host)]; + out.extend(tail.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string())); + return out; + } + + canonical + .split('/') + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect() +} + +pub fn item_parent_path(input: &str) -> Option { + let segs = item_path_segments(input); + if segs.len() <= 1 { + return None; + } + Some(segs[..segs.len() - 1].join("/")) +} diff --git a/server/src/events.rs b/server/src/events.rs index 7775e59974a521f5fea9361a600802898d05c6ab..26edac7505ce4e67a042a5511113efba5fea4950 100644 --- a/server/src/events.rs +++ b/server/src/events.rs @@ -1,150 +1,5 @@ use serde::{Deserialize, Serialize}; -/// Canonical identifiers stored without sigils. -/// - tags are stored without leading '#' -/// - items are stored without leading '/' -pub fn canonicalize_tag(input: &str) -> String { - input.trim().trim_start_matches('#').to_lowercase() -} - -pub fn canonicalize_item(input: &str) -> String { - let s = input.trim(); - if s.is_empty() { - return String::new(); - } - - if let Some(rest) = s.strip_prefix("https://") { - let (host, tail) = rest.split_once('/').map_or((rest, ""), |(h, t)| (h, t)); - let host = host.trim().to_lowercase(); - if tail.is_empty() { - return format!("https://{}", host); - } else { - return format!("https://{}/{}", host, tail); - } - } - if let Some(rest) = s.strip_prefix("http://") { - let (host, tail) = rest.split_once('/').map_or((rest, ""), |(h, t)| (h, t)); - let host = host.trim().to_lowercase(); - if tail.is_empty() { - return format!("http://{}", host); - } else { - return format!("http://{}/{}", host, tail); - } - } - - let is_tilde = s.starts_with("~/"); - let rest = s.strip_prefix("~/").or_else(|| s.strip_prefix("/")).unwrap_or(s); - - let tail = rest - .split('/') - .filter_map(|seg| { - let t = seg.trim(); - if t.is_empty() { - None - } else { - Some(t.to_lowercase()) - } - }) - .collect::>() - .join("/"); - - if is_tilde { - format!("https://slug.social/~/{}", tail) - } else if tail.is_empty() { - "https://slug.social".to_string() - } else { - format!("https://slug.social/{}", tail) - } -} - -pub fn item_path_segments(input: &str) -> Vec { - let canonical = canonicalize_item(input); - if canonical.is_empty() { - return vec![]; - } - - if let Some(rest) = canonical.strip_prefix("https://") { - let (host, tail) = rest.split_once('/').map_or((rest, ""), |(h, t)| (h, t)); - let mut out = vec![format!("https://{}", host)]; - out.extend(tail.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string())); - return out; - } - if let Some(rest) = canonical.strip_prefix("http://") { - let (host, tail) = rest.split_once('/').map_or((rest, ""), |(h, t)| (h, t)); - let mut out = vec![format!("http://{}", host)]; - out.extend(tail.split('/').filter(|s| !s.is_empty()).map(|s| s.to_string())); - return out; - } - - // Should be unreachable since all canonical items are now URLs - canonical - .split('/') - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect() -} - -pub fn item_parent_path(input: &str) -> Option { - let segs = item_path_segments(input); - if segs.len() <= 1 { - return None; - } - Some(segs[..segs.len() - 1].join("/")) -} - -/// Canonical username stored without leading '@'. -pub fn canonicalize_username(input: &str) -> String { - input.trim().trim_start_matches('@').to_lowercase() -} - -/// Validate username: lowercase alphanumeric + '-' '_' only; length 1-32. -pub fn validate_username(username: &str) -> Result<(), String> { - let u = canonicalize_username(username); - if u.is_empty() || u.len() > 32 { - return Err("username must be 1-32 characters".to_string()); - } - if !u - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') - { - return Err("username must be lowercase alphanumeric with '-' or '_' only".to_string()); - } - Ok(()) -} - -/// Canonical agent identity stored with single leading '@'. -/// -/// Request/display form is `@@uuid:rig:provider/model` but we store `@uuid:rig:provider/model`. -pub fn canonicalize_agent(input: &str) -> String { - let s = input.trim(); - let s = s.strip_prefix("@@").or_else(|| s.strip_prefix('@')).unwrap_or(s); - format!("@{}", s.to_lowercase()) -} - -/// Validate agent format: @@:: -pub fn validate_agent_format(agent: &str) -> Result<(), String> { - let a = agent.trim(); - if !a.starts_with("@@") && !a.starts_with('@') { - return Err("agent must start with @@".to_string()); - } - let a = a.strip_prefix("@@").or_else(|| a.strip_prefix('@')).unwrap_or(a); - let parts: Vec<&str> = a.split(':').collect(); - if parts.len() != 3 { - return Err("agent must be @@::".to_string()); - } - let (uuid_part, rig_part, model_part) = (parts[0], parts[1], parts[2]); - if uuid::Uuid::parse_str(uuid_part).is_err() { - return Err("agent uuid must be a valid UUID v4".to_string()); - } - if rig_part.trim().is_empty() { - return Err("agent rig must be non-empty".to_string()); - } - if !model_part.contains('/') { - return Err("agent model must be ".to_string()); - } - Ok(()) -} - #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ThreadVisibility { @@ -236,10 +91,11 @@ pub struct Ingest { pub id: String, /// Raw DSL+prose body only (no identity/routing metadata). pub raw: String, - /// Human principal username (no leading '@'). + /// Human principal username (wire and storage: no `@`). pub principal: String, - /// Delegate agent identity (canonical stored with single leading '@'). - pub delegate: String, + /// AI delegate id `uuid:rig:model` (wire and storage: no `@`). Omitted when absent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delegate: Option, /// Thread identifier: public tag (e.g. "languages") or private id/slug (e.g. "a7f2k9x/project-review"). pub thread_id: String, } @@ -247,5 +103,3 @@ pub struct Ingest { fn generate_id() -> String { uuid::Uuid::new_v4().to_string() } - - diff --git a/server/src/html/editor.rs b/server/src/html/editor.rs index 1d2c4be7eef4cc437cacddd0949307895f3ef018..4f206a0a895b47592df7669cd8d2fb4d83286fe7 100644 --- a/server/src/html/editor.rs +++ b/server/src/html/editor.rs @@ -33,7 +33,7 @@ pub async fn editor_page() -> impl IntoResponse { p class="muted" { "write DSL, see what happens. nothing is saved." } div class="editor-container" { textarea id="editor-input" rows="12" cols="80" - placeholder="@your-uuid:rig:provider/model\n#your-thread\n\n~/path/item-a { description }\n~/path/item-b { description }\n\n~/path/item-a 3:1 ~/path/item-b { reasoning }" + placeholder="your-uuid:rig:provider/model\n#your-thread\n\n~/path/item-a { description }\n~/path/item-b { description }\n\n~/path/item-a 3:1 ~/path/item-b { reasoning }" autocomplete="off" autofocus {} div id="editor-status" class="muted" { "type to check…" } div id="editor-results" {} @@ -110,7 +110,7 @@ pub async fn editor_check( id: uuid::Uuid::new_v4().to_string(), raw: form.text.clone(), principal: String::new(), - delegate: String::new(), + delegate: None, thread_id: String::new(), }); let mut simulated = { reduced_arc.read().await.clone() }; diff --git a/server/src/html/forum.rs b/server/src/html/forum.rs index d40cc6398aaf3b2348d91498b5b3a80ed593e55c..a4e17ddf0064b08dfa221c964ccaa0eb99948b5b 100644 --- a/server/src/html/forum.rs +++ b/server/src/html/forum.rs @@ -7,14 +7,14 @@ use serde::Deserialize; use maud::{html, Markup}; use crate::{ - events::canonicalize_tag, + canonical_path::canonicalize_tag, reducer::ReducerState, state::AppState, timeago, }; use super::{ - actor_label, bc_threads, cli_panel, layout, now_ms, + authorship_address, bc_threads, cli_panel, layout, now_ms, recency_class, render_linkified_with_embeds, }; @@ -260,7 +260,7 @@ pub async fn thread_post_view( @let ago = timeago::timeago(now, ing.ts); div class="ingest-entry" data-ingest-id=(ing.id) { div class="ingest-meta muted" title=(hover) { - span class="address" { "@" (actor_label(&ing.delegate)) } + span class="address" { (authorship_address(&ing.principal, &ing.delegate)) } " · " (ago) } diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs index bddd90aa6eb288c0c97bb08f472e2934fd2fe67a..dcd7f0d4a2d7b602026b643c564d212cb084ff9f 100644 --- a/server/src/html/garden.rs +++ b/server/src/html/garden.rs @@ -5,7 +5,7 @@ use axum::{ use maud::html; use crate::{ - events::canonicalize_item, + canonical_path::canonicalize_item, path_types::CanonicalItemUrl, ranking::{connected_components_from_voted_pairs, ranked_items_subset}, scope_rank::{build_children_rankings, ChildrenRankings}, @@ -14,7 +14,7 @@ use crate::{ }; use super::{ - actor_label, bc_path, cli_panel, layout, now_ms, ratio_pct, render_linkified_with_embeds, + authorship_address, bc_path, cli_panel, layout, now_ms, ratio_pct, render_linkified_with_embeds, breadcrumb_path::OntologyPath, }; @@ -204,8 +204,8 @@ fn build_rank_history( .map(|doc| { doc.statements.into_iter().filter_map(|s| { if let crate::dsl::Stmt::Vote { item1, item2, ratio_left, ratio_right, explanation } = s { - let a_str = crate::events::canonicalize_item(&item1); - let b_str = crate::events::canonicalize_item(&item2); + let a_str = crate::canonical_path::canonicalize_item(&item1); + let b_str = crate::canonical_path::canonicalize_item(&item2); if a_str == item || b_str == item { Some(crate::reducer::VoteData { ts: e.ts, @@ -216,9 +216,7 @@ fn build_rank_history( principal: reduced.ingests_by_id.get(&e.post_id) .map(|ing| ing.principal.clone()) .unwrap_or_default(), - delegate: reduced.ingests_by_id.get(&e.post_id) - .map(|ing| ing.delegate.clone()) - .unwrap_or_default(), + delegate: reduced.ingests_by_id.get(&e.post_id).and_then(|ing| ing.delegate.clone()), thread_id: e.thread.clone(), }) } else { None } @@ -331,7 +329,7 @@ async fn render_scope_view(state: AppState, path: OntologyPath) -> axum::respons @let right_class = if v.b.as_str() == model.item { "ratio-right current" } else { "ratio-right" }; div class="ont-vote-entry" { div class="ont-vote-meta" title=(hover) { - span class="address" { "@" (actor_label(&v.delegate)) } + span class="address" { (authorship_address(&v.principal, &v.delegate)) } " · " (ago) } @@ -482,7 +480,7 @@ mod tests { id: format!("ing-{ts}"), raw: raw.to_string(), principal: "testuser".to_string(), - delegate: "@00000000-0000-0000-0000-000000000000:test:local/test".to_string(), + delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()), thread_id: String::new(), })); } diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index 8b48a25cce79f0eefc7e29849e1a667cae4c7986..82c5993fabfeaf2ea8b9034bccaef37924100264 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -238,9 +238,9 @@ pub(super) fn bc_threads(thread_tag: Option<&str>) -> Markup { } } -/// Input is canonicalized without leading '@' (usually uuid:rig:provider/model). -pub(super) fn actor_label(actor: &str) -> String { - let a = actor.trim_start_matches('@').trim(); +/// Short display label for a stored agent id (`uuid:rig:model`, no `@`). +pub(super) fn actor_label(agent_naked: &str) -> String { + let a = agent_naked.trim(); let parts: Vec<&str> = a.split(':').collect(); if parts.len() >= 3 { let rig = parts[1].trim(); @@ -258,6 +258,13 @@ pub(super) fn actor_label(actor: &str) -> String { a.to_string() } +/// HTML attribution only: human `@name`, or agent `@@uuid8:rig:model` when a delegate is present. +pub(super) fn authorship_address(principal: &str, delegate: &Option) -> String { + match delegate { + Some(d) => format!("@@{}", actor_label(d)), + None => format!("@{}", principal), + } +} /// Escape HTML special chars for safe injection. fn escape_html(s: &str) -> String { diff --git a/server/src/html/search.rs b/server/src/html/search.rs index f56f31546b4870d41e594d0e3ac2a4b50ba831b8..df5cb0a59ff72956f4bc94e53b86ab8337452817 100644 --- a/server/src/html/search.rs +++ b/server/src/html/search.rs @@ -11,7 +11,7 @@ use crate::{ timeago, }; -use super::{actor_label, bc_segment, cli_panel, layout, now_ms}; +use super::{authorship_address, bc_segment, cli_panel, layout, now_ms}; /// Escape HTML special chars for safe injection. fn escape_html(s: &str) -> String { @@ -44,7 +44,8 @@ struct ThreadRow { struct PostRow { thread: String, - actor: String, + /// Pre-formatted attribution string for display (includes `@` / `@@` from `authorship_address`). + actor_display: String, text: String, ts: i64, } @@ -151,7 +152,7 @@ fn search(state: &ReducerState, q: &str, limit: usize) -> SearchResults { .unwrap_or_else(|| "unknown".to_string()); scored_posts.push((score, PostRow { thread, - actor: ingest.principal.clone(), + actor_display: authorship_address(&ingest.principal, &ingest.delegate), text: ingest.raw.clone(), ts: ingest.ts, })); @@ -321,7 +322,7 @@ fn render_search_results(results: &SearchResults, query: &str) -> Markup { li { div class="search-post-meta muted" { a href=(format!("/t/{}", r.thread)) { "#" (r.thread) } - " · " (actor_label(&r.actor)) + " · " (r.actor_display) " · " (timeago::timeago(now, r.ts)) } div class="search-snippet" { diff --git a/server/src/html/tree.rs b/server/src/html/tree.rs index 568a08edbb548291ab6f03b46c79cdcb4b432468..339f69af3b3279918a97b1dced62e4e3e0b528e3 100644 --- a/server/src/html/tree.rs +++ b/server/src/html/tree.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; use crate::{ - events::canonicalize_item, + canonical_path::canonicalize_item, path_types::{CanonicalItemUrl, RelativePath}, scope_rank::ChildrenRankings, state::AppState, @@ -702,7 +702,8 @@ pub async fn tree_select( mod tests { use super::*; - use crate::events::{canonicalize_item, Event, Ingest}; + use crate::canonical_path::canonicalize_item; + use crate::events::{Event, Ingest}; use crate::reducer::ReducerState; fn ingest(raw: &str) -> Event { @@ -711,7 +712,7 @@ mod tests { id: "test-ingest".to_string(), raw: raw.to_string(), principal: "tester".to_string(), - delegate: String::new(), + delegate: None, thread_id: String::new(), }) } @@ -789,9 +790,9 @@ mod tests { fn reducer_parent_key_for_tilde_items_is_without_trailing_slash() { // This is the reducer invariant that the tree view must match. let item = canonicalize_item("~/alphabet/a"); - assert_eq!(crate::events::item_parent_path(&item).unwrap(), "https://slug.social/~/alphabet"); + assert_eq!(crate::canonical_path::item_parent_path(&item).unwrap(), "https://slug.social/~/alphabet"); let item2 = canonicalize_item("~/a"); - assert_eq!(crate::events::item_parent_path(&item2).unwrap(), "https://slug.social/~"); + assert_eq!(crate::canonical_path::item_parent_path(&item2).unwrap(), "https://slug.social/~"); } #[test] diff --git a/server/src/identity.rs b/server/src/identity.rs new file mode 100644 index 0000000000000000000000000000000000000000..ad654ae813f8f6fe6323746e2544250687d3d47f --- /dev/null +++ b/server/src/identity.rs @@ -0,0 +1,66 @@ +//! Usernames and agent delegate ids. Wire JSON and query params use **stored form only** (no `@` / `@@`). +//! The HTTP layer validates here; the reducer does not rewrite identity. For humans, `@name` / `@@agent` +//! appear only in HTML (see `html::authorship_address`). + +/// Parse username from query/body: trim, lowercase. `@` is not allowed (use `tommy`, not `@tommy`). +pub fn parse_username(input: &str) -> Result { + let s = input.trim(); + if s.is_empty() { + return Err("username must not be empty".to_string()); + } + if s.contains('@') { + return Err( + "username must not contain '@' — use stored form (e.g. `tommy`)".to_string(), + ); + } + let u = s.to_lowercase(); + validate_username_naked(&u)?; + Ok(u) +} + +fn validate_username_naked(u: &str) -> Result<(), String> { + if u.len() > 32 { + return Err("username must be 1-32 characters".to_string()); + } + if !u + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + { + return Err("username must be lowercase alphanumeric with '-' or '_' only".to_string()); + } + Ok(()) +} + +/// Parse agent id: trim, lowercase. Must be `uuid:rig:provider/model` with no `@`. +pub fn parse_agent(input: &str) -> Result { + let s = input.trim(); + if s.is_empty() { + return Err("agent id must not be empty".to_string()); + } + if s.contains('@') { + return Err( + "agent id must not contain '@' — use `uuid:rig:provider/model`".to_string(), + ); + } + let a = s.to_lowercase(); + validate_agent_naked(&a)?; + Ok(a) +} + +fn validate_agent_naked(a: &str) -> Result<(), String> { + let parts: Vec<&str> = a.split(':').collect(); + if parts.len() != 3 { + return Err("agent must be ::".to_string()); + } + let (uuid_part, rig_part, model_part) = (parts[0], parts[1], parts[2]); + if uuid::Uuid::parse_str(uuid_part).is_err() { + return Err("agent uuid must be a valid UUID v4".to_string()); + } + if rig_part.trim().is_empty() { + return Err("agent rig must be non-empty".to_string()); + } + if !model_part.contains('/') { + return Err("agent model must be ".to_string()); + } + Ok(()) +} diff --git a/server/src/lib.rs b/server/src/lib.rs index 1820b0dd9cd2d0b6ce05789e3225e0d86a3d357a..173fbdd6b3f23c062a200b268d04491d5e030b3f 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1,10 +1,12 @@ #[macro_use] pub mod paths; pub mod api; +pub mod canonical_path; pub mod dsl; pub mod html; pub mod event_log; pub mod events; +pub mod identity; pub mod middleware; pub mod path_types; pub mod ranking; diff --git a/server/src/path_types.rs b/server/src/path_types.rs index 5a903de4f8309b6c3874e2cd70f2eb5650fd50a8..997597f6659c1ba2092bc5348e393bf7a8fa7ae0 100644 --- a/server/src/path_types.rs +++ b/server/src/path_types.rs @@ -14,9 +14,9 @@ use std::fmt; use serde::{Deserialize, Serialize}; -use crate::events::canonicalize_item; +use crate::canonical_path::canonicalize_item; -/// Canonical item identifier as produced by `events::canonicalize_item`. +/// Canonical item identifier as produced by `canonical_path::canonicalize_item`. /// /// In practice this is usually: /// - `https://slug.social/~/...` for ontology items, or diff --git a/server/src/ranking.rs b/server/src/ranking.rs index 70119611473a430113429eafb6d92b2bf96a3eb5..f70da0d076da2ba8f0abb5a4415babae1c2305f9 100644 --- a/server/src/ranking.rs +++ b/server/src/ranking.rs @@ -265,7 +265,7 @@ mod tests { ratio_right: r, body: "because".to_string(), principal: "test".to_string(), - delegate: "@00000000-0000-0000-0000-000000000000:test:local/test".to_string(), + delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()), thread_id: "untagged".to_string(), } } diff --git a/server/src/reducer.rs b/server/src/reducer.rs index 9ab063a340e228edaa8967ec35762809fccd5d3a..550bcda1a1068df13908d19484d86ad46d65ddf1 100644 --- a/server/src/reducer.rs +++ b/server/src/reducer.rs @@ -2,7 +2,8 @@ use std::collections::{HashMap, HashSet, VecDeque}; use serde::{Deserialize, Serialize}; -use crate::events::{canonicalize_agent, canonicalize_tag, canonicalize_username, Event, Ingest, ThreadCapability}; +use crate::canonical_path::canonicalize_tag; +use crate::events::{Event, Ingest, ThreadCapability}; use crate::path_types::CanonicalItemUrl; #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] @@ -21,10 +22,10 @@ pub struct VoteData { pub ratio_left: i32, pub ratio_right: i32, pub body: String, - /// Human principal username (no leading '@'). + /// Human principal username (no `@` in stored events). pub principal: String, - /// Agent delegate identity (stored with single leading '@'). - pub delegate: String, + /// AI delegate id, if any (no `@` in stored events). + pub delegate: Option, /// Thread id where this vote was cast (public tag or private id/slug). pub thread_id: String, } @@ -99,8 +100,6 @@ impl GroupState { pub fn apply_vote(&mut self, mut vote: VoteData) { vote.a = CanonicalItemUrl::parse(vote.a.as_str()).unwrap_or(vote.a); vote.b = CanonicalItemUrl::parse(vote.b.as_str()).unwrap_or(vote.b); - vote.principal = canonicalize_username(&vote.principal); - vote.delegate = canonicalize_agent(&vote.delegate); vote.thread_id = canonicalize_tag(&vote.thread_id); if vote.ratio_left < 0 { vote.ratio_left = 0; @@ -189,7 +188,7 @@ pub struct ReducerState { pub users_by_provider: HashMap<(String, String), String>, /// token_id -> (username, salt, token_hash) pub tokens_by_id: HashMap, - /// agent delegate (canonical '@...') -> username + /// agent id (naked `uuid:rig:model`) -> username pub agent_bindings: HashMap, pub ingests_by_id: HashMap, @@ -330,26 +329,27 @@ impl ReducerState { pub fn apply_event(&mut self, event: Event) { match event { Event::UserRegistered(ur) => { - let username = canonicalize_username(&ur.username); - self.users_by_provider - .insert((ur.provider.to_lowercase(), ur.provider_id.clone()), username); + self.users_by_provider.insert( + (ur.provider.to_lowercase(), ur.provider_id.clone()), + ur.username, + ); } Event::TokenIssued(ti) => { - let username = canonicalize_username(&ti.username); - self.tokens_by_id - .insert(ti.token_id.clone(), (username, ti.salt.clone(), ti.token_hash.clone())); + self.tokens_by_id.insert( + ti.token_id.clone(), + (ti.username, ti.salt.clone(), ti.token_hash.clone()), + ); } Event::AgentBound(ab) => { - let username = canonicalize_username(&ab.username); - let agent = canonicalize_agent(&ab.agent); - self.agent_bindings.insert(agent, username); + if ab.agent.is_empty() { + return; + } + self.agent_bindings.insert(ab.agent, ab.username); } Event::ThreadCreated(tc) => { self.threads.entry(tc.thread_id.clone()).or_default().visibility = tc.visibility; } Event::Ingest(mut ing) => { - ing.principal = canonicalize_username(&ing.principal); - ing.delegate = canonicalize_agent(&ing.delegate); ing.thread_id = canonicalize_tag(&ing.thread_id); self.ingests_by_id.insert(ing.id.clone(), ing.clone()); @@ -528,7 +528,7 @@ impl ReducerState { let caps = self.grants .entry(ga.thread_id) .or_default() - .entry(canonicalize_username(&ga.username)) + .entry(ga.username) .or_default(); for cap in ga.capabilities { caps.insert(cap); @@ -536,7 +536,7 @@ impl ReducerState { } Event::GrantRevoked(gr) => { if let Some(thread_grants) = self.grants.get_mut(&gr.thread_id) { - let username = canonicalize_username(&gr.username); + let username = gr.username; if let Some(caps) = thread_grants.get_mut(&username) { for cap in &gr.capabilities { caps.remove(cap); diff --git a/server/tests/basic.rs b/server/tests/basic.rs index 8d7dd87d327c969ad953ecaa6b021a03e4d1842e..21cacd3049c9f5a307e75eefd5ebfc7bf0097b5d 100644 --- a/server/tests/basic.rs +++ b/server/tests/basic.rs @@ -1,6 +1,7 @@ use slugsocial_server::{ event_log::EventLog, - events::{canonicalize_item, canonicalize_tag, Event, Ingest}, + canonical_path::{canonicalize_item, canonicalize_tag}, + events::{Event, Ingest}, ranking::ranked_items, reducer::{GroupState, ReducerState}, }; @@ -15,7 +16,7 @@ fn ingest_event(ts: i64, raw: &str) -> Event { id: format!("test-{ts}"), raw: raw.to_string(), principal: "test".to_string(), - delegate: "@@00000000-0000-0000-0000-000000000000:test:local/test".to_string(), + delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()), thread_id: "t".to_string(), }) } @@ -491,7 +492,7 @@ fn reducer_negative_ratio_clamped_to_zero() { ratio_right: -3, body: "negative".to_string(), principal: "test".to_string(), - delegate: "@@00000000-0000-0000-0000-000000000000:test:local/test".to_string(), + delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()), thread_id: "t".to_string(), }); assert_eq!(group.idx_to_item.len(), 2); diff --git a/server/tests/integration.rs b/server/tests/integration.rs index 24ef464631cd269889b8bb751ef0e90289443e97..eaa25f2c5cc21df3096d9b8f54c060c83207bfd6 100644 --- a/server/tests/integration.rs +++ b/server/tests/integration.rs @@ -87,7 +87,7 @@ async fn test_ingest_actor_with_colons_is_detected_and_validated() { // Old archive style: agent includes colons but UUID is only a prefix (invalid). // We should detect the agent line, then fail with "invalid agent format". let ingest_payload = serde_json::json!({ - "delegate": "@@aec1e31c:claudecode:anthropic/claude-sonnet-4.5", + "delegate": "aec1e31c:claudecode:anthropic/claude-sonnet-4.5", "thread": "t", "text": "~/x {x}\n", }); @@ -108,6 +108,26 @@ async fn test_ingest_actor_with_colons_is_detected_and_validated() { hint.to_lowercase().contains("uuid"), "hint should mention uuid, got: {hint}" ); + + let at_payload = serde_json::json!({ + "delegate": "@00000000-0000-0000-0000-000000000000:test:local/test", + "thread": "t", + "text": "~/x {x}\n", + }); + let at_resp = client + .post(&format!("http://{}/api/v0/ingest", addr)) + .header("Authorization", format!("Bearer {}", test_bearer())) + .json(&at_payload) + .send() + .await + .unwrap(); + assert_eq!(at_resp.status(), reqwest::StatusCode::BAD_REQUEST); + let at_body: serde_json::Value = at_resp.json().await.unwrap(); + let at_hint = at_body["hint"].as_str().unwrap_or_default(); + assert!( + at_hint.contains('@'), + "hint should reject '@' in delegate, got: {at_hint}" + ); } #[tokio::test] @@ -117,7 +137,7 @@ async fn test_vote_endpoint() { // /api/v0/vote was removed; all votes are submitted via ingest. let ingest_payload = serde_json::json!({ - "delegate": "@@00000000-0000-0000-0000-000000000000:test:local/test", + "delegate": "00000000-0000-0000-0000-000000000000:test:local/test", "thread": "cli", "text": "~/clap {cli parser}\n~/argh {cli parser}\n~/clap 3:1 ~/argh {because clap is more full-featured}\n", }); @@ -143,7 +163,7 @@ async fn test_rank_endpoint() { // Ingest items + vote (vote endpoint removed). let ingest_payload = serde_json::json!({ - "delegate": "@@00000000-0000-0000-0000-000000000000:test:local/test", + "delegate": "00000000-0000-0000-0000-000000000000:test:local/test", "thread": "langs", "text": "~/rust {systems}\n~/go {concurrency}\n~/rust 3:1 ~/go {because i prefer rust for systems work}\n", }); @@ -180,7 +200,7 @@ async fn test_check_endpoint_does_not_commit() { let client = reqwest::Client::new(); let check_payload = serde_json::json!({ - "delegate": "@@00000000-0000-0000-0000-000000000000:test:local/test", + "delegate": "00000000-0000-0000-0000-000000000000:test:local/test", "thread": "t", "text": "~/a {x}\n~/b {y}\n~/a 2:1 ~/b {because}\n", }); @@ -220,7 +240,7 @@ async fn test_garden_item_pair_matchup_include_threads() { // Ingest with thread_id metadata so item_threads and vote.thread_id are populated. let ingest_payload = serde_json::json!({ - "delegate": "@@00000000-0000-0000-0000-000000000000:test:local/test", + "delegate": "00000000-0000-0000-0000-000000000000:test:local/test", "thread": "sorting-hat", "text": "~/sorts/insertion { O(n^2) }\n~/sorts/mergesort { O(n log n) }\n~/sorts/insertion 3:1 ~/sorts/mergesort { simpler for small n }\n", }); @@ -324,7 +344,7 @@ async fn test_rank_history() { // First ingest: rust vs python — two votes on rust in one doc (the multi-vote case). ingest( serde_json::json!({ - "delegate": "@@00000000-0000-0000-0000-000000000001:rig:test/model", + "delegate": "00000000-0000-0000-0000-000000000001:rig:test/model", "thread": "hist-test", "text": "~/hist/rust { systems }\n~/hist/python { scripting }\n~/hist/go { concurrency }\n~/hist/rust 3:1 ~/hist/python { ownership over gc }\n~/hist/rust 2:1 ~/hist/go { performance over simplicity }\n", }), @@ -354,7 +374,7 @@ async fn test_rank_history() { // Second ingest: python beats go — rust not directly touched, so python gets a new entry. ingest( serde_json::json!({ - "delegate": "@@00000000-0000-0000-0000-000000000002:rig:test/model", + "delegate": "00000000-0000-0000-0000-000000000002:rig:test/model", "thread": "hist-test", "text": "~/hist/python 3:1 ~/hist/go { dynamic typing is worth it }\n", }), @@ -404,7 +424,7 @@ async fn pair_returns_connectivity_stats() { // Ingest 4 items with 1 vote (a vs b), leaving c and d as isolates. let doc = serde_json::json!({ - "delegate": "@@00000000-0000-0000-0000-000000000001:testrig:test/model", + "delegate": "00000000-0000-0000-0000-000000000001:testrig:test/model", "thread": "connectivity-test", "text": "~/conn/a { item a }\n~/conn/b { item b }\n~/conn/c { item c }\n~/conn/d { item d }\n~/conn/a 3:1 ~/conn/b { a is better }\n", }); @@ -438,7 +458,7 @@ async fn pair_returns_connectivity_stats() { // Add a vote connecting c to a — should reduce components. let doc2 = serde_json::json!({ - "delegate": "@@00000000-0000-0000-0000-000000000001:testrig:test/model", + "delegate": "00000000-0000-0000-0000-000000000001:testrig:test/model", "thread": "connectivity-test", "text": "~/conn/c 2:1 ~/conn/a { c beats a }\n", }); diff --git a/test/auth.bb b/test/auth.bb index b54ea509aafd2d5ee85877665982d7fc00cd8b20..611e04f1806ef81d678a91f499597fe55f691dd7 100644 --- a/test/auth.bb +++ b/test/auth.bb @@ -155,7 +155,7 @@ (println "\nstarting pending session…") (let [start-resp (http-post-json (str base-url "/api/v0/pending-session") - {:agent "@@00000000-0000-0000-0000-000000000000:bb:local/dev"}) + {:agent "00000000-0000-0000-0000-000000000000:bb:local/dev"}) _ (assert! (= 200 (:status start-resp)) "pending-session start returns 200") start-json (json/parse-string (:body start-resp) true)] (assert! (clojure.string/starts-with? (:session start-json) "p_") "session id has p_ prefix") diff --git a/test/grants.bb b/test/grants.bb index 962ff01cdc25d6d6977cc9a810c404918c20856d..f72799ae6fe099f48bdf316deee07f15882e9d45 100644 --- a/test/grants.bb +++ b/test/grants.bb @@ -187,11 +187,11 @@ ;; Register two users. The mock google cycles through google-user-alice then google-user-bob. (println "\nregistering alice…") (let [alice-token (register-user base-url - "@@00000000-0000-0000-0000-000000000001:test:local/dev" + "00000000-0000-0000-0000-000000000001:test:local/dev" "alice") _ (println "registering bob…") bob-token (register-user base-url - "@@00000000-0000-0000-0000-000000000002:test:local/dev" + "00000000-0000-0000-0000-000000000002:test:local/dev" "bob") ;; Alice creates a private thread. @@ -206,14 +206,14 @@ ;; Alice (owner) can post prose to her own private thread. (println "\nalice posts prose to her private thread…") (assert! (= 200 (:status (ingest! base-url alice-token thread-id - "@@00000000-0000-0000-0000-000000000001:test:local/dev" + "00000000-0000-0000-0000-000000000001:test:local/dev" "Hello from alice."))) "alice prose post succeeds") ;; Bob has no grants at all — should get 403. (println "\nbob (no grants) tries to post prose…") (assert! (= 403 (:status (ingest! base-url bob-token thread-id - "@@00000000-0000-0000-0000-000000000002:test:local/dev" + "00000000-0000-0000-0000-000000000002:test:local/dev" "Hello from bob, unauthorized."))) "bob without grants gets 403") @@ -226,7 +226,7 @@ (println "\nbob (View only) tries to post prose…") (assert! (= 403 (:status (ingest! base-url bob-token thread-id - "@@00000000-0000-0000-0000-000000000002:test:local/dev" + "00000000-0000-0000-0000-000000000002:test:local/dev" "Hello from bob, view only."))) "bob with View but no Post gets 403") @@ -239,21 +239,21 @@ (println "\nbob (View + Post) posts prose…") (assert! (= 200 (:status (ingest! base-url bob-token thread-id - "@@00000000-0000-0000-0000-000000000002:test:local/dev" + "00000000-0000-0000-0000-000000000002:test:local/dev" "Hello from bob, now authorised."))) "bob with View + Post succeeds for prose") ;; Alice defines two items and votes on them in the private thread. (println "\nalice posts items + vote to private thread…") (assert! (= 200 (:status (ingest! base-url alice-token thread-id - "@@00000000-0000-0000-0000-000000000001:test:local/dev" + "00000000-0000-0000-0000-000000000001:test:local/dev" "~/fruits/apple { A crisp red apple. }\n~/fruits/banana { A yellow banana. }\n~/fruits/apple > ~/fruits/banana { apples are better }"))) "alice vote in private thread succeeds") ;; Bob (View + Post, no Vote) tries to vote — should be 403. (println "\nbob (no Vote) tries to vote…") (assert! (= 403 (:status (ingest! base-url bob-token thread-id - "@@00000000-0000-0000-0000-000000000002:test:local/dev" + "00000000-0000-0000-0000-000000000002:test:local/dev" "~/fruits/apple > ~/fruits/banana { bob's take }"))) "bob without Vote gets 403") @@ -266,7 +266,7 @@ (println "\nbob (View + Post + Vote) votes…") (assert! (= 200 (:status (ingest! base-url bob-token thread-id - "@@00000000-0000-0000-0000-000000000002:test:local/dev" + "00000000-0000-0000-0000-000000000002:test:local/dev" "~/fruits/apple > ~/fruits/banana { bob's take }"))) "bob with Vote succeeds")) diff --git a/test/integration.bb b/test/integration.bb index 93db8f3102b1b3a05f3c4bd2cc9f348072fe54cc..4ff5d625840be3bf5e4162cef107a3cbd45aec4d 100644 --- a/test/integration.bb +++ b/test/integration.bb @@ -166,7 +166,7 @@ ;; 3. ingest via CLI (bearer required) (println "\ningesting .sorter document via CLI…") - (bind ingest1-result (common/run-cli cli-bin base-url ["ingest" "--json" "--thread" "integration-test"] :input sorter-doc :extra-env token-env)) + (bind ingest1-result (common/run-cli cli-bin base-url ["ingest" "--json" "--thread" "integration-test" "--delegate" "00000000-0000-0000-0000-000000000000:cli:local/dev"] :input sorter-doc :extra-env token-env)) (assert! (zero? (:exit ingest1-result)) "cli ingest exits 0") (bind ingest1-resp (json/parse-string (:out ingest1-result) true)) (assert! (:ok ingest1-resp) "ingest response ok=true") @@ -237,7 +237,7 @@ "#integration-test" "~/languages/rust 4:1 ~/languages/python { type safety }" "~/languages/rust 3:1 ~/languages/go { zero-cost abstractions }"])) - (bind hist-ingest (common/run-cli cli-bin base-url ["ingest" "--json" "--thread" "integration-test"] :input two-vote-doc :extra-env token-env)) + (bind hist-ingest (common/run-cli cli-bin base-url ["ingest" "--json" "--thread" "integration-test" "--delegate" "00000000-0000-0000-0000-000000000000:cli:local/dev"] :input two-vote-doc :extra-env token-env)) (assert! (zero? (:exit hist-ingest)) (str "two-vote ingest exits 0 (err: " (:err hist-ingest) ")")) diff --git a/test/oauth.bb b/test/oauth.bb index f94583ee0f6fedfc023564ed7ee3a2986adcf660..84679e1a4adbe100dfe7e7d64a2c6270b023d275 100644 --- a/test/oauth.bb +++ b/test/oauth.bb @@ -85,11 +85,11 @@ stop-fn (http/run-server handler {:port port})] {:stop-fn stop-fn :port port})) -(def ^:private default-agent "@@00000000-0000-0000-0000-000000000000:cli:local/dev") +(def ^:private default-agent "00000000-0000-0000-0000-000000000000:cli:local/dev") (defn fetch-bearer-token! "Simulate browser OAuth + username choice; returns `slug_…` bearer token. - Agent must match CLI default `SLUG_DELEGATE` for ingest binding." + Ingest `--delegate` must match this agent string for `AgentBound` on first write." [base-url & {:keys [username agent] :or {username "intuser" agent default-agent}}] (let [start-resp (http-post-json (str base-url "/api/v0/pending-session") {:agent agent})] diff --git a/types/src/lib.rs b/types/src/lib.rs index a62822a36463c68fde6c5fc1aa9e4273c82ca0c8..92e6d122d573ea225f8fb86c548a81b3454425ad 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -138,7 +138,7 @@ pub struct PostRow { /// Chronological index within the thread (0 = oldest). pub index: usize, pub ts: i64, - /// Self-declared actor (`@uuid:rig:model`). + /// Principal username (stored form, no `@`). pub actor: String, pub body: String, pub truncated: bool, @@ -186,6 +186,7 @@ pub struct VoteRow { pub a: String, pub b: String, pub ratio: String, + /// Principal username when present (stored form, no `@`). pub actor: Option, pub body: String, /// Thread where this vote was cast (e.g. "#sorting-hat"). @@ -196,6 +197,7 @@ pub struct VoteRow { /// Response for the feed endpoint — all ingests since a cutoff, newest first. #[derive(Debug, Serialize, Deserialize)] pub struct FeedResponse { + /// Principal username this feed is scoped to (stored form, no `@`). pub actor: String, /// The lower-bound timestamp used (actor's last ingest, ms). None if actor has never posted. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -222,8 +224,9 @@ pub struct FeedPost { pub struct IngestRequest { /// Thread identifier: public tag (e.g. "languages") or private id/slug (e.g. "a7f2k9x/project-review"). pub thread: String, - /// Agent delegate identity in request form (e.g. "@@uuid:rig:provider/model"). - pub delegate: String, + /// Delegate id: `uuid:rig:provider/model` (no `@`). Omit for human-only ingests. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delegate: Option, /// DSL+prose body only. pub text: String, } @@ -231,7 +234,7 @@ pub struct IngestRequest { /// Start a browser-based OAuth login flow for a CLI agent. #[derive(Debug, Serialize, Deserialize)] pub struct PendingSessionStartRequest { - /// Agent delegate identity in request form (e.g. "@@uuid:rig:provider/model"). + /// Delegate id: `uuid:rig:provider/model` (no `@`). pub agent: String, } @@ -255,6 +258,7 @@ pub struct PendingSessionPollResponse { #[derive(Debug, Serialize, Deserialize)] pub struct WhoamiResponse { + /// Username (stored form, no `@`). pub user: String, pub agents_bound: usize, }