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: [b9476669] Remove browser sentinel delegates so multi-user votes work. Shared WEB_BROWSER_AGENT bound on first vote and blocked every later human; browser posts now use no delegate, matching forum UI. Co-authored-by: Cursor Side A — unified diff (full patch): diff --git a/cli/src/main.rs b/cli/src/main.rs index c4f1494df3aedbd8b883aea6249579aa8336abfe..af3e4fee876910a44f6f872b24821bc541a23e20 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1741,8 +1741,8 @@ async fn run() -> Result<()> { tokio::time::sleep(std::time::Duration::from_millis(poll_interval_ms)).await; let poll: PendingSessionPollResponse = expect_json(client.get(&poll_url).send().await?).await?; - if !poll.agent.trim().is_empty() { - agent_out = Some(poll.agent.clone()); + if let Some(a) = poll.agent.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + agent_out = Some(a.to_string()); } if poll.complete { token_out = poll.token; diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs index 50dc6af908412b16343829eae25154ebf4e19fca..01c02f50c19bcd62c7f1717f9b927f203c3164d0 100644 --- a/server/src/api/auth.rs +++ b/server/src/api/auth.rs @@ -29,18 +29,6 @@ use crate::{ write_cmd::WriteCmd, }; -/// Delegate id for browser users who land via `/join/inv_…` (no CLI agent). -const INVITE_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000000:invite:web/join"; - -/// Agent id for `/login` browser OAuth (no CLI); must pass [`parse_agent`]. -pub const WEB_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000001:social:web/browser"; - -/// True for well-known browser / human-form sentinel delegates (not real AI agents). -/// HTML attribution should show the human username for these, not `@@uuid:rig:…`. -pub fn is_browser_sentinel_delegate(agent: &str) -> bool { - agent == WEB_BROWSER_AGENT || agent == INVITE_BROWSER_AGENT -} - /// HttpOnly cookie storing the same `slug_*` bearer string the CLI uses. pub const SLUG_SESSION_COOKIE: &str = "slug_session"; @@ -288,7 +276,7 @@ pub async fn get_join_invite( let session = format!("p_{}", uuid::Uuid::new_v4().simple()); let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref())); let s = PendingSession { - agent: INVITE_BROWSER_AGENT.to_string(), + agent: None, created_ts: now_ms(), provider: None, provider_id: None, @@ -555,7 +543,7 @@ pub async fn post_choose_username( }; let sessions = pending_sessions(&state); - let (provider, provider_id, agent) = { + let (provider, provider_id) = { let sessions_read = sessions.read().await; let Some(s) = sessions_read.get(&form.session) else { return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response(); @@ -566,14 +554,9 @@ pub async fn post_choose_username( let Some(provider_id) = s.provider_id.clone() else { return js_form_error_fragment(&form.session, "oauth not completed").into_response(); }; - (provider, provider_id, s.agent.clone()) + (provider, provider_id) }; - if let Err(msg) = parse_agent(&agent) { - return js_form_error_fragment(&form.session, &format!("invalid agent format — {msg}")) - .into_response(); - } - let redeem_invite = { let sessions_read = sessions.read().await; sessions_read @@ -643,7 +626,8 @@ pub async fn get_web_login( let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref())) .or_else(|| Some("/".to_string())); let s = PendingSession { - agent: WEB_BROWSER_AGENT.to_string(), + // Humans sign in via the website with no AI delegate. + agent: None, created_ts: now_ms(), provider: None, provider_id: None, @@ -704,7 +688,7 @@ pub async fn post_pending_session( ); let poll_url = format!("/api/v0/pending-session/{session}"); let s = PendingSession { - agent: agent_naked, + agent: Some(agent_naked), created_ts: now_ms(), provider: None, provider_id: None, diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index fdff6db0472b36cd7870a4be68574023e0daf120..920e967b47852ea82fa61b84c457ae3582dd9800 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -18,13 +18,11 @@ pub use auth::{ get_choose_username, get_web_login, get_logout, - is_browser_sentinel_delegate, optional_principal, resolve_web_session, session_cookie_header_value, WebSession, SLUG_SESSION_COOKIE, - WEB_BROWSER_AGENT, }; pub use helpers::{ diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs index 611956d0a46062b49ce350be176e4be139312ff0..6e56c039a184953cd1d0593c34cf4d5abe41f2a3 100644 --- a/server/src/api/ui_html.rs +++ b/server/src/api/ui_html.rs @@ -277,7 +277,7 @@ async fn dispatch_ui_action( &session.bearer, room.clone(), thread_tag.clone(), - Some(crate::api::auth::WEB_BROWSER_AGENT.to_string()), + None, text, ) .await diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index 645d54f2e117ba901e02ed958ada6ff69a78ae34..03ad762e88709c77b9d5dd130c48bc96226616a1 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -560,27 +560,28 @@ fn identity_color_css(seed: &str) -> String { format!("hsl({hue}, 62%, 66%)") } -/// Seed for author color: real AI → delegate uuid; human/sentinel → principal username. +/// Seed for author color: AI → delegate uuid; human (no delegate) → principal username. fn authorship_color_seed<'a>(principal: &'a str, delegate: &'a Option) -> &'a str { match delegate { - Some(d) if !crate::api::is_browser_sentinel_delegate(d) => { - d.split(':').next().filter(|s| !s.is_empty()).unwrap_or(d.as_str()) - } - _ => principal, + Some(d) => d + .split(':') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or(d.as_str()), + None => principal, } } -/// Prefer the AI delegate in attribution; fall back to the human username when there is no -/// delegate or the delegate is a browser/human-form sentinel. +/// Prefer the AI delegate in attribution; humans post with no delegate and show `@username`. pub(crate) fn authorship_attr(principal: &str, delegate: &Option) -> AuthorshipAttr { let color = identity_color_css(authorship_color_seed(principal, delegate)); match delegate { - Some(d) if !crate::api::is_browser_sentinel_delegate(d) => AuthorshipAttr { + Some(d) => AuthorshipAttr { label: format!("@@{}", actor_label(d)), author_title: Some(format!("@{principal}")), color, }, - _ => AuthorshipAttr { + None => AuthorshipAttr { label: format!("@{principal}"), author_title: None, color, @@ -933,7 +934,6 @@ pub(super) fn recency_class(now_ms: i64, ts_ms: i64) -> &'static str { #[cfg(test)] mod authorship_tests { use super::*; - use crate::api::WEB_BROWSER_AGENT; #[test] fn human_or_missing_delegate_shows_username() { @@ -943,15 +943,6 @@ mod authorship_tests { assert!(a.color.starts_with("hsl(")); } - #[test] - fn browser_sentinel_delegate_shows_username() { - let d = Some(WEB_BROWSER_AGENT.to_string()); - let a = authorship_attr("alice", &d); - assert_eq!(a.label, "@alice"); - assert_eq!(a.author_title, None); - assert_eq!(authorship_address("alice", &d), "@alice"); - } - #[test] fn real_ai_delegate_shows_short_agent_with_username_hover() { let d = Some( diff --git a/server/src/state.rs b/server/src/state.rs index 648ab5304764a329fcabbbbcd3782b94e3e005a8..8ea84dcf9b1df8f8037e913cdd94e5908e6d5d55 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -21,7 +21,8 @@ pub struct InviteState { #[derive(Debug, Clone)] pub struct PendingSession { - pub agent: String, + /// CLI `identity start` delegate (`uuid:rig:model`). `None` for browser `/login` and `/join`. + pub agent: Option, pub created_ts: i64, pub provider: Option, pub provider_id: Option, diff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs index 6b1475b773106a2dd3f326475c9fb4cc727f6b4b..714563a8b330e3917d95a54ae29b4de143e3b8a4 100644 --- a/server/tests/integration_ui.rs +++ b/server/tests/integration_ui.rs @@ -418,6 +418,99 @@ async fn test_web_login_carries_vote_pair_next_into_pending_session() { let sessions = state.pending_sessions.read().await; let pending = sessions.get(&session).expect("pending session"); assert_eq!(pending.redirect_next.as_deref(), Some(next)); + assert_eq!( + pending.agent, None, + "browser /login must not invent a sentinel delegate" + ); +} + +#[tokio::test] +async fn test_vote_compare_two_users_both_succeed_without_delegate() { + let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await; + let client = reqwest::Client::new(); + let alice = test_bearer(); + let bob = seed_test_identity(&state, "bob", "bobtok", "bobsecret").await; + + // Define items first (votes require existing item bodies). + let seed = ui_post_ingest_rpc( + "public", + "multi-vote", + "~/multi-a {alpha}\n~/multi-b {beta}\n", + ); + let seed_resp = client + .post(format!("http://{addr}/ui")) + .header("Authorization", format!("Bearer {alice}")) + .form(&[("__rpc__", seed.as_str())]) + .send() + .await + .unwrap(); + assert_eq!(seed_resp.status(), reqwest::StatusCode::OK); + let seed_js = seed_resp.text().await.unwrap(); + assert!( + !seed_js.contains("auth-error"), + "item seed must succeed, got: {seed_js}" + ); + + for (bearer, left, right, explanation) in [ + (&alice, "3", "1", "alice prefers a"), + (&bob, "1", "3", "bob prefers b"), + ] { + let rpc = ui_vote_compare_post_rpc( + "public", + "multi-vote", + "~/multi-a", + "~/multi-b", + left, + right, + explanation, + ); + let resp = client + .post(format!("http://{addr}/ui")) + .header("Authorization", format!("Bearer {bearer}")) + .form(&[("__rpc__", rpc.as_str())]) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::OK); + let js = resp.text().await.unwrap(); + assert!( + !js.contains("delegate already bound"), + "human vote must not hit shared-sentinel AgentBound ({explanation}), got: {js}" + ); + assert!( + !js.contains("auth-error"), + "human vote must succeed ({explanation}), got: {js}" + ); + assert!( + js.contains("vote-edge-history-region"), + "vote should morph edge history ({explanation}), got: {js}" + ); + } + + let reduced = state.reduced.read().await; + let human_votes: Vec<_> = reduced + .ingests_ordered + .iter() + .filter_map(|id| reduced.ingests_by_id.get(id)) + .filter(|ing| ing.raw.contains("prefers")) + .collect(); + assert_eq!(human_votes.len(), 2, "expected two vote ingests"); + let mut principals: Vec<&str> = human_votes.iter().map(|i| i.principal.as_str()).collect(); + principals.sort(); + assert_eq!(principals, ["bob", "testuser"]); + for ing in &human_votes { + assert!( + ing.delegate.is_none(), + "browser votes must have no delegate, principal={} delegate={:?}", + ing.principal, + ing.delegate + ); + } + assert!( + reduced.agent_bindings.is_empty(), + "human votes must not create AgentBound entries: {:?}", + reduced.agent_bindings + ); } #[tokio::test] diff --git a/server/tests/support/mod.rs b/server/tests/support/mod.rs index a4320e991763617c1a760efad4b621977e2b74d0..67650520ea8f9f144bfe904347c40329d5db37e8 100644 --- a/server/tests/support/mod.rs +++ b/server/tests/support/mod.rs @@ -72,17 +72,14 @@ pub async fn rpc_batch( response.json().await.unwrap() } -pub async fn seed_test_token(state: &AppState) { - seed_test_identity(state, "testuser", "testtok", "secret").await; -} - -/// Add a distinct principal and bearer to a running integration-test server. +/// Seed a user + bearer into reducer state (not appended to the event log). +/// Returns the `slug__` bearer string. pub async fn seed_test_identity( state: &AppState, username: &str, token_id: &str, secret: &str, -) { +) -> String { let registered = Event::UserRegistered(UserRegistered { ts: 0, username: username.to_string(), @@ -102,6 +99,11 @@ pub async fn seed_test_identity( let mut r = state.reduced.write().await; r.apply_event(registered); r.apply_event(ev); + format!("slug_{token_id}_{secret}") +} + +pub async fn seed_test_token(state: &AppState) { + let _ = seed_test_identity(state, "testuser", "testtok", "secret").await; } pub async fn create_test_server_with_state() -> ( diff --git a/test/oauth.clj b/test/oauth.clj index 3cbf496005e906ad6c579d44ac8c4055ee60d063..6911ac34a55707acda9f9ce091c079964649a252 100644 --- a/test/oauth.clj +++ b/test/oauth.clj @@ -186,8 +186,9 @@ (:token poll-json)))))))) (defn fetch-bearer-token! - "Simulate browser OAuth + username choice; returns `slug_…` bearer token. - Ingest `--delegate` must match this agent string for `AgentBound` on first write." + "Simulate CLI identity OAuth + username choice; returns `slug_…` bearer token. + Pass `:agent` (default local/dev) when the test will CLI-ingest with `--delegate` + so first write can `AgentBound`. Browser UI posts use no delegate." [base-url & {:keys [username agent] :or {username "intuser" agent default-agent}}] (let [token (complete-registration! base-url :username username :agent agent)] (when-not (str/starts-with? token "slug_") diff --git a/types/src/lib.rs b/types/src/lib.rs index cd6f94c60a4e1c3bfbce13bc0404b803f5f57c6d..4d05466c19a2f15f7d600e1d4a206b39984fabc4 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -538,7 +538,9 @@ pub struct PendingSessionStartResponse { pub struct PendingSessionPollResponse { pub ok: bool, pub complete: bool, - pub agent: String, + /// Present for CLI identity sessions; omitted for browser `/login` / `/join` (no delegate). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, #[serde(skip_serializing_if = "Option::is_none")] pub user: Option, #[serde(skip_serializing_if = "Option::is_none")] Side B — contributor: tommy-mor Side B — commit message: [c94456ff] Make feed catch-up stable and permission-aware Anchor implicit feeds to durable ingest order and cover multi-user private-room visibility so concurrent posts are not missed or leaked. Co-authored-by: Cursor Side B — unified diff (full patch): diff --git a/cli/src/main.rs b/cli/src/main.rs index abb5a55b49f60fe28fbfd4ec02715cb94ea0b4ec..c4f1494df3aedbd8b883aea6249579aa8336abfe 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -878,6 +878,30 @@ mod tests { "graph: 4 items, 3/6 pairs (50.0% density), 1 component, connected" ); } + + #[test] + fn feed_without_since_uses_logged_in_delegate_from_env() { + let key = "SLUG_DELEGATE"; + let previous = std::env::var_os(key); + let expected = "00000000-0000-0000-0000-0000000000ee:test:local/model"; + std::env::set_var(key, expected); + + let cli = Cli::try_parse_from(["slugsocial", "feed"]).expect("parse feed"); + + match previous { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + match cli.cmd { + Some(Command::Feed { + delegate, since, .. + }) => { + assert_eq!(delegate.as_deref(), Some(expected)); + assert!(since.is_none()); + } + _ => panic!("expected feed command"), + } + } } async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> { @@ -1626,7 +1650,15 @@ async fn run() -> Result<()> { } else { for p in &resp.posts { let ago = slug_types::timeago::timeago(now_ms, p.ts); - println!("", p.id, ago); + let thread_attr = p + .thread + .as_deref() + .map(|thread| format!(" thread=\"{thread}\"")) + .unwrap_or_default(); + println!( + "", + p.id, ago, p.room, thread_attr + ); print!("{}", p.body); if !p.body.ends_with('\n') { println!(); } println!(""); diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs index afc4f95bef160c1e38ecff2c096d6440cd94e2b3..46d748f918d9b225acc4ecedfe5a1793089407b5 100644 --- a/server/src/api/rpc.rs +++ b/server/src/api/rpc.rs @@ -72,6 +72,80 @@ fn can_view_scope(reduced: &ReducerState, scope: &ScopeId, principal: Option<&st } } +/// Build a feed in durable ingest order. +/// +/// An implicit feed boundary is an ingest position, not only its millisecond timestamp. Two users +/// can post in the same millisecond, and wall-clock timestamps can move backwards during replay. +/// Explicit `since` remains a timestamp query for API compatibility, but scans the whole ordered +/// ledger rather than assuming timestamps are monotonic. +fn rpc_feed( + reduced: &ReducerState, + viewer: &str, + delegate: Option, + requested_since: Option, + implicit_anchor: Option<(usize, i64)>, + limit: usize, +) -> FeedResponse { + let since = requested_since.or_else(|| implicit_anchor.map(|(_, ts)| ts)); + let implicit_anchor_index = requested_since + .is_none() + .then(|| implicit_anchor.map(|(index, _)| index)) + .flatten(); + + let matching: Vec<&str> = reduced + .ingests_ordered + .iter() + .enumerate() + .rev() + .filter(|(index, id)| { + reduced.ingests_by_id.get(id.as_str()).is_some_and(|ing| { + match requested_since { + Some(cutoff) => ing.ts > cutoff, + None => implicit_anchor_index.is_none_or(|anchor| *index > anchor), + } + }) + }) + .map(|(_, id)| id.as_str()) + .filter(|id| { + reduced.ingests_by_id.get(*id).is_some_and(|ing| { + let scope = scope_from_room_wire(&ing.room_id); + can_view_scope(reduced, &scope, Some(viewer)) + }) + }) + .filter(|id| !reduced.redacted_posts.contains(*id)) + .collect(); + + let total = matching.len(); + let posts = matching + .into_iter() + .take(limit) + .filter_map(|id| reduced.ingests_by_id.get(id)) + .map(|ing| { + let scope = scope_from_room_wire(&ing.room_id); + let thread_post_index = reduced.try_thread_post_index_chronological( + &scope, + &ing.thread_tag, + &ing.id, + ); + FeedPost { + ts: ing.ts, + id: ing.id.clone(), + room: ing.room_id.clone(), + thread: Some(ing.thread_tag.clone()), + thread_post_index, + body: ing.raw.clone(), + } + }) + .collect(); + + FeedResponse { + delegate, + since, + posts, + total, + } +} + fn principal_from_optional_bearer(headers: &HeaderMap, reduced: &ReducerState) -> Result, RpcErr> { if headers.contains_key(axum::http::header::AUTHORIZATION) { verify_bearer_principal(headers, reduced) @@ -1506,60 +1580,27 @@ pub async fn handle_rpc_batch( Some("this delegate is not bound to your signed-in account".into()), ) } else { - let since_default = reduced + let implicit_anchor = reduced .ingests_ordered .iter() + .enumerate() .rev() - .filter_map(|id| reduced.ingests_by_id.get(id)) - .find(|ing| { - if ing.delegate.as_deref() != Some(delegate_stored.as_str()) { - return false; - } - let scope = scope_from_room_wire(&ing.room_id); - can_view_scope(&reduced, &scope, Some(viewer.as_str())) - }) - .map(|ing| ing.ts); - let since = since.or(since_default); - let cutoff = since.unwrap_or(0); - let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); - let matching: Vec<&str> = reduced.ingests_ordered.iter().rev() - .map(|id| id.as_str()) - .take_while(|id| reduced.ingests_by_id.get(*id).is_some_and(|ing| ing.ts > cutoff)) - .filter(|id| { - reduced.ingests_by_id.get(*id).is_some_and(|ing| { - let scope = scope_from_room_wire(&ing.room_id); - can_view_scope(&reduced, &scope, Some(viewer.as_str())) + .find_map(|(index, id)| { + reduced.ingests_by_id.get(id).and_then(|ing| { + (ing.delegate.as_deref() + == Some(delegate_stored.as_str())) + .then_some((index, ing.ts)) }) - }) - .filter(|id| !reduced.redacted_posts.contains(*id)) - .collect(); - let total = matching.len(); - let posts: Vec = matching.into_iter() - .take(limit) - .filter_map(|id| reduced.ingests_by_id.get(id)) - .map(|ing| { - let scope = scope_from_room_wire(&ing.room_id); - let thread_post_index = reduced - .try_thread_post_index_chronological( - &scope, - &ing.thread_tag, - &ing.id, - ); - FeedPost { - ts: ing.ts, - id: ing.id.clone(), - thread: Some(ing.thread_tag.clone()), - thread_post_index, - body: ing.raw.clone(), - } - }) - .collect(); - line_ok(RpcResult::Feed(FeedResponse { - delegate: Some(delegate_stored), + }); + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + line_ok(RpcResult::Feed(rpc_feed( + &reduced, + &viewer, + Some(delegate_stored), since, - posts, - total, - })) + implicit_anchor, + limit, + ))) }; drop(reduced); line @@ -1567,60 +1608,25 @@ pub async fn handle_rpc_batch( None => { // Session catch-up: last time *you* posted anything (delegate or not), so revisiting // an old chat with only a token still gets a sane cutoff. - let since_default = reduced + let implicit_anchor = reduced .ingests_ordered .iter() + .enumerate() .rev() - .filter_map(|id| reduced.ingests_by_id.get(id)) - .find(|ing| { - if ing.principal != viewer { - return false; - } - let scope = scope_from_room_wire(&ing.room_id); - can_view_scope(&reduced, &scope, Some(viewer.as_str())) - }) - .map(|ing| ing.ts); - let since = since.or(since_default); - let cutoff = since.unwrap_or(0); - let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); - let matching: Vec<&str> = reduced.ingests_ordered.iter().rev() - .map(|id| id.as_str()) - .take_while(|id| reduced.ingests_by_id.get(*id).is_some_and(|ing| ing.ts > cutoff)) - .filter(|id| { - reduced.ingests_by_id.get(*id).is_some_and(|ing| { - let scope = scope_from_room_wire(&ing.room_id); - can_view_scope(&reduced, &scope, Some(viewer.as_str())) + .find_map(|(index, id)| { + reduced.ingests_by_id.get(id).and_then(|ing| { + (ing.principal == viewer).then_some((index, ing.ts)) }) - }) - .filter(|id| !reduced.redacted_posts.contains(*id)) - .collect(); - let total = matching.len(); - let posts: Vec = matching.into_iter() - .take(limit) - .filter_map(|id| reduced.ingests_by_id.get(id)) - .map(|ing| { - let scope = scope_from_room_wire(&ing.room_id); - let thread_post_index = reduced - .try_thread_post_index_chronological( - &scope, - &ing.thread_tag, - &ing.id, - ); - FeedPost { - ts: ing.ts, - id: ing.id.clone(), - thread: Some(ing.thread_tag.clone()), - thread_post_index, - body: ing.raw.clone(), - } - }) - .collect(); - let line = line_ok(RpcResult::Feed(FeedResponse { - delegate: None, + }); + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + let line = line_ok(RpcResult::Feed(rpc_feed( + &reduced, + &viewer, + None, since, - posts, - total, - })); + implicit_anchor, + limit, + ))); drop(reduced); line } diff --git a/server/tests/integration_rooms.rs b/server/tests/integration_rooms.rs index a3b378267e090a016a7e663f103a0dd9f482690b..e623523ba352ce456674a399542c689505760b78 100644 --- a/server/tests/integration_rooms.rs +++ b/server/tests/integration_rooms.rs @@ -1,6 +1,9 @@ mod support; use slug_types::room_route_segment; +use slugsocial_server::events::{ + AgentBound, Event, GrantAdded, GrantRevoked, Ingest, RoomCreated, ThreadCapability, +}; use support::*; #[tokio::test] @@ -473,6 +476,247 @@ async fn test_feed_without_delegate_uses_principal_last_post_including_delegate( ); } +#[tokio::test] +async fn test_feed_uses_delegate_ingest_position_when_multi_user_timestamps_collide() { + let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await; + seed_test_identity(&state, "bob", "bobtok", "bobsecret").await; + let client = reqwest::Client::new(); + let alice_delegate = + "00000000-0000-0000-0000-0000000000c1:feedtest:local/alice-model"; + let bob_delegate = + "00000000-0000-0000-0000-0000000000c2:feedtest:local/bob-model"; + + { + let mut reduced = state.reduced.write().await; + for (agent, username) in [ + (alice_delegate, "testuser"), + (bob_delegate, "bob"), + ] { + reduced.apply_event(Event::AgentBound(AgentBound { + ts: 1, + agent: agent.to_string(), + username: username.to_string(), + })); + } + reduced.apply_event(Event::Ingest(Ingest { + ts: 100, + id: "alice-anchor".into(), + raw: "alice anchor".into(), + principal: "testuser".into(), + delegate: Some(alice_delegate.into()), + room_id: "public".into(), + thread_tag: "feed-order".into(), + })); + reduced.apply_event(Event::Ingest(Ingest { + ts: 100, + id: "bob-same-millisecond".into(), + raw: "bob same-millisecond change".into(), + principal: "bob".into(), + delegate: Some(bob_delegate.into()), + room_id: "public".into(), + thread_tag: "feed-order".into(), + })); + // Event-log order remains authoritative even if the wall clock moves backwards. + reduced.apply_event(Event::Ingest(Ingest { + ts: 99, + id: "bob-clock-rollback".into(), + raw: "bob change after clock rollback".into(), + principal: "bob".into(), + delegate: Some(bob_delegate.into()), + room_id: "public".into(), + thread_tag: "feed-order".into(), + })); + } + + let feed = rpc_batch( + &client, + addr, + Some(&test_bearer()), + serde_json::json!([{ + "GetFeed": { "delegate": alice_delegate, "limit": 20 } + }]), + ) + .await; + let posts = feed["results"][0]["result"]["Feed"]["posts"] + .as_array() + .unwrap(); + let ids: Vec<&str> = posts.iter().filter_map(|p| p["id"].as_str()).collect(); + assert_eq!( + ids, + ["bob-clock-rollback", "bob-same-millisecond"], + "implicit feed cutoff must use append position, not timestamp" + ); +} + +#[tokio::test] +async fn test_feed_multi_user_private_room_visibility_and_revoked_anchor_access() { + let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await; + seed_test_identity(&state, "bob", "bobtok", "bobsecret").await; + seed_test_identity(&state, "carol", "caroltok", "carolsecret").await; + let client = reqwest::Client::new(); + let alice_delegate = + "00000000-0000-0000-0000-0000000000d1:feedtest:local/alice-model"; + let bob_delegate = + "00000000-0000-0000-0000-0000000000d2:feedtest:local/bob-model"; + let room_alice = "alice01/alice-room"; + let room_bob = "bob0001/bob-room"; + + { + let mut reduced = state.reduced.write().await; + for (agent, username) in [ + (alice_delegate, "testuser"), + (bob_delegate, "bob"), + ] { + reduced.apply_event(Event::AgentBound(AgentBound { + ts: 1, + agent: agent.to_string(), + username: username.to_string(), + })); + } + for (room_id, slug, owner) in [ + (room_alice, "alice-room", "testuser"), + (room_bob, "bob-room", "bob"), + ] { + reduced.apply_event(Event::RoomCreated(RoomCreated { + ts: 2, + room_id: room_id.to_string(), + slug: slug.to_string(), + owner: owner.to_string(), + })); + reduced.apply_event(Event::GrantAdded(GrantAdded { + ts: 2, + room_id: room_id.to_string(), + username: owner.to_string(), + capabilities: vec![ThreadCapability::View], + granted_by: owner.to_string(), + })); + } + + let mut add_ingest = + |ts, id: &str, raw: &str, principal: &str, delegate: Option<&str>, room: &str| { + reduced.apply_event(Event::Ingest(Ingest { + ts, + id: id.into(), + raw: raw.into(), + principal: principal.into(), + delegate: delegate.map(str::to_string), + room_id: room.into(), + thread_tag: "feed-permissions".into(), + })); + }; + add_ingest(10, "prehistory", "must remain before alice anchor", "carol", None, "public"); + add_ingest( + 11, + "alice-private-anchor", + "alice last posted here", + "testuser", + Some(alice_delegate), + room_alice, + ); + add_ingest( + 12, + "bob-public-anchor", + "bob last posted here", + "bob", + Some(bob_delegate), + "public", + ); + add_ingest(13, "alice-room-change", "visible only to alice", "carol", None, room_alice); + add_ingest(14, "bob-room-change", "visible only to bob", "carol", None, room_bob); + add_ingest(15, "public-change", "visible to everyone", "carol", None, "public"); + + reduced.apply_event(Event::GrantRevoked(GrantRevoked { + ts: 16, + room_id: room_alice.into(), + username: "testuser".into(), + capabilities: vec![ThreadCapability::View], + revoked_by: "testuser".into(), + })); + } + + let alice_feed = rpc_batch( + &client, + addr, + Some(&test_bearer()), + serde_json::json!([{ + "GetFeed": { "delegate": alice_delegate, "limit": 20 } + }]), + ) + .await; + let alice_posts = alice_feed["results"][0]["result"]["Feed"]["posts"] + .as_array() + .unwrap(); + let alice_ids: Vec<&str> = alice_posts + .iter() + .filter_map(|p| p["id"].as_str()) + .collect(); + assert_eq!( + alice_ids, + ["public-change", "bob-public-anchor"], + "revoked private content must be hidden without moving the delegate anchor backwards" + ); + assert!(!alice_ids.contains(&"prehistory")); + assert!( + alice_posts + .iter() + .all(|post| post["room"].as_str() == Some("public")), + "private posts must not leak and every feed post must identify its room" + ); + + let bob_bearer = test_bearer_for("bobtok", "bobsecret"); + let bob_feed = rpc_batch( + &client, + addr, + Some(&bob_bearer), + serde_json::json!([{ + "GetFeed": { "delegate": bob_delegate, "limit": 20 } + }]), + ) + .await; + let bob_ids: Vec<&str> = bob_feed["results"][0]["result"]["Feed"]["posts"] + .as_array() + .unwrap() + .iter() + .filter_map(|p| p["id"].as_str()) + .collect(); + assert_eq!(bob_ids, ["public-change", "bob-room-change"]); + assert_eq!( + bob_feed["results"][0]["result"]["Feed"]["posts"][1]["room"], + room_bob + ); + + // Restoring View exposes only changes after the same stable delegate anchor. + { + let mut reduced = state.reduced.write().await; + reduced.apply_event(Event::GrantAdded(GrantAdded { + ts: 17, + room_id: room_alice.into(), + username: "testuser".into(), + capabilities: vec![ThreadCapability::View], + granted_by: "testuser".into(), + })); + } + let restored = rpc_batch( + &client, + addr, + Some(&test_bearer()), + serde_json::json!([{ + "GetFeed": { "delegate": alice_delegate, "limit": 20 } + }]), + ) + .await; + let restored_ids: Vec<&str> = restored["results"][0]["result"]["Feed"]["posts"] + .as_array() + .unwrap() + .iter() + .filter_map(|p| p["id"].as_str()) + .collect(); + assert_eq!( + restored_ids, + ["public-change", "alice-room-change", "bob-public-anchor"] + ); +} + #[tokio::test] async fn test_private_room_thread_urls_use_t_segment() { let (addr, _tmp, _log, _handle) = create_test_server().await; diff --git a/server/tests/support/mod.rs b/server/tests/support/mod.rs index 4a620eaa875e7a1145f2a4e82cc4ff2be21d5345..a4320e991763617c1a760efad4b621977e2b74d0 100644 --- a/server/tests/support/mod.rs +++ b/server/tests/support/mod.rs @@ -20,8 +20,10 @@ pub fn sha256_hex(s: &str) -> String { /// Fixed bearer for integration tests (`TokenIssued` seeded into reducer in `create_test_server`). pub fn test_bearer() -> String { - let token_id = "testtok"; - let secret = "secret"; + test_bearer_for("testtok", "secret") +} + +pub fn test_bearer_for(token_id: &str, secret: &str) -> String { format!("slug_{token_id}_{secret}") } @@ -71,19 +73,27 @@ pub async fn rpc_batch( } pub async fn seed_test_token(state: &AppState) { + seed_test_identity(state, "testuser", "testtok", "secret").await; +} + +/// Add a distinct principal and bearer to a running integration-test server. +pub async fn seed_test_identity( + state: &AppState, + username: &str, + token_id: &str, + secret: &str, +) { let registered = Event::UserRegistered(UserRegistered { ts: 0, - username: "testuser".to_string(), + username: username.to_string(), provider: "test".to_string(), - provider_id: "testuser".to_string(), + provider_id: username.to_string(), }); - let token_id = "testtok"; - let secret = "secret"; let salt = "salt"; let token_hash = sha256_hex(&format!("{salt}:{secret}")); let ev = Event::TokenIssued(TokenIssued { ts: 0, - username: "testuser".to_string(), + username: username.to_string(), token_id: token_id.to_string(), token_hash, salt: salt.to_string(), diff --git a/types/src/lib.rs b/types/src/lib.rs index 211493935d19582607f5c87fb492faf47bdc6f53..cd6f94c60a4e1c3bfbce13bc0404b803f5f57c6d 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -264,6 +264,9 @@ pub struct FeedResponse { pub struct FeedPost { pub ts: i64, pub id: String, + /// Permission scope containing the post: `"public"` or a private room id. + #[serde(default)] + pub room: String, /// Primary thread tag (without #), if the ingest declared one. #[serde(skip_serializing_if = "Option::is_none")] pub thread: Option,