{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[00be3a29] invite system\n\nSide A — unified diff (full patch):\ndiff --git a/bb.edn b/bb.edn\nindex 50be8232847e672b3f273a2fb25ddd1d12adb7e2..818f850765370d12d58f239e287764fc3649d78b 100644\n--- a/bb.edn\n+++ b/bb.edn\n@@ -47,14 +47,16 @@\n \"RUST_LOG\" \"info\"})})))}\n \n test\n- {:doc \"Full test suite: integration + auth + grants\"\n+ {:doc \"Full test suite: integration + auth + grants + invites\"\n :requires ([test.integration :as integration]\n [test.auth :as auth]\n- [test.grants :as grants])\n+ [test.grants :as grants]\n+ [test.invites :as invites])\n :task (do\n (integration/integration)\n (auth/auth-test)\n- (grants/grants-test))}\n+ (grants/grants-test)\n+ (invites/invites-test))}\n \n perf\n {:doc \"Performance test: concurrent HTTP requests to detect blocking I/O\"\ndiff --git a/cli/src/main.rs b/cli/src/main.rs\nindex e5833b0b93d8e667b94c574ba2b0f8cb758ff3df..8eda9f485bd1f7392f1e34be27176c21e20354eb 100644\n--- a/cli/src/main.rs\n+++ b/cli/src/main.rs\n@@ -105,6 +105,23 @@ enum ScopedCmd {\n #[arg(long)]\n json: bool,\n },\n+\n+ /// Mint a shareable invite link (24h TTL, in-memory until redeemed). Requires Manage on the room.\n+ InviteLink {\n+ /// Comma-separated: view, post, vote, add_item, manage\n+ #[arg(long = \"caps\", value_delimiter = ',')]\n+ caps: Vec,\n+ #[arg(long, default_value_t = 1)]\n+ uses: usize,\n+ #[arg(long)]\n+ json: bool,\n+ },\n+\n+ /// List principals granted access in this room (requires View or Manage)\n+ Audit {\n+ #[arg(long)]\n+ json: bool,\n+ },\n }\n \n #[derive(Subcommand, Debug)]\n@@ -514,17 +531,35 @@ fn print_thread(resp: &ThreadDetailResponse) {\n .duration_since(std::time::UNIX_EPOCH)\n .unwrap_or_default()\n .as_millis() as i64;\n- if resp.total > resp.posts.len() {\n- let end = resp.offset + resp.posts.len();\n- eprintln!(\"# showing {}-{} of {} posts (--offset N --limit N to paginate)\", resp.offset, end.saturating_sub(1), resp.total);\n+ if resp.total > resp.items.len() {\n+ let end = resp.offset + resp.items.len();\n+ eprintln!(\n+ \"# showing {}-{} of {} rows (--offset N --limit N to paginate)\",\n+ resp.offset,\n+ end.saturating_sub(1),\n+ resp.total\n+ );\n }\n- for (i, post) in resp.posts.iter().enumerate() {\n- let timeago = slug_types::timeago::timeago_compact(now_ms, post.ts);\n- let body = &post.body.trim();\n- println!(\"\", post.index, timeago);\n- println!(\"{}\", body);\n- println!(\"\");\n- if i + 1 < resp.posts.len() {\n+ for (i, item) in resp.items.iter().enumerate() {\n+ match item {\n+ ThreadItem::Post {\n+ index,\n+ ts,\n+ body,\n+ ..\n+ } => {\n+ let timeago = slug_types::timeago::timeago_compact(now_ms, *ts);\n+ let body = body.trim();\n+ println!(\"\", index, timeago);\n+ println!(\"{}\", body);\n+ println!(\"\");\n+ }\n+ ThreadItem::System { ts, text } => {\n+ let timeago = slug_types::timeago::timeago_compact(now_ms, *ts);\n+ println!(\"{}\", timeago, text.trim());\n+ }\n+ }\n+ if i + 1 < resp.items.len() {\n println!();\n println!();\n }\n@@ -1036,6 +1071,95 @@ async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> {\n }\n }\n },\n+ ScopedCmd::InviteLink { caps, uses, json } => {\n+ let caps: Vec = caps\n+ .into_iter()\n+ .flat_map(|s| {\n+ s.split(',')\n+ .map(|p| p.trim().to_lowercase())\n+ .filter(|p| !p.is_empty())\n+ .collect::>()\n+ })\n+ .collect();\n+ if caps.is_empty() {\n+ return Err(anyhow!(\"--caps is required (e.g. --caps view,post,vote)\"));\n+ }\n+ let bearer = effective_bearer().ok_or_else(|| {\n+ anyhow!(\n+ \"no bearer token: run `slugsocial identity start --rig --model ` \\\n+ then `slugsocial identity poll `, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token\"\n+ )\n+ })?;\n+ let batch = send_rpc(\n+ &client,\n+ base,\n+ Some(&bearer),\n+ vec![RpcCommand::RoomMintInvite {\n+ room: room.to_string(),\n+ capabilities: caps,\n+ max_uses: uses,\n+ }],\n+ )\n+ .await?;\n+ match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::RoomInviteMinted {\n+ invite_url,\n+ expires_at_ms,\n+ max_uses,\n+ } => {\n+ if json {\n+ println!(\n+ \"{}\",\n+ serde_json::to_string_pretty(&serde_json::json!({\n+ \"invite_url\": invite_url,\n+ \"expires_at_ms\": expires_at_ms,\n+ \"max_uses\": max_uses,\n+ }))?\n+ );\n+ } else {\n+ println!(\"{invite_url}\");\n+ println!(\"(Expires in 24 hours. Max uses: {max_uses})\");\n+ }\n+ }\n+ _ => return Err(anyhow!(\"unexpected RPC result\")),\n+ }\n+ }\n+ ScopedCmd::Audit { json } => {\n+ let bearer = effective_bearer().ok_or_else(|| {\n+ anyhow!(\n+ \"no bearer token: run `slugsocial identity start --rig --model ` \\\n+ then `slugsocial identity poll `, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token\"\n+ )\n+ })?;\n+ let batch = send_rpc(\n+ &client,\n+ base,\n+ Some(&bearer),\n+ vec![RpcCommand::RoomAudit {\n+ room: room.to_string(),\n+ }],\n+ )\n+ .await?;\n+ match rpc_line_ok(&batch.results[0])? {\n+ RpcResult::RoomAudit(resp) => {\n+ if json {\n+ println!(\"{}\", serde_json::to_string_pretty(&resp)?);\n+ } else {\n+ println!(\"room {}\", resp.room);\n+ if resp.grants.is_empty() {\n+ println!(\"(no grants recorded)\");\n+ } else {\n+ let w_user = resp.grants.iter().map(|g| g.username.len()).max().unwrap_or(0);\n+ for g in &resp.grants {\n+ let caps = g.capabilities.join(\", \");\n+ println!(\"{: return Err(anyhow!(\"unexpected RPC result\")),\n+ }\n+ }\n ScopedCmd::Check { file, json } => {\n let mut text = String::new();\n match file {\ndiff --git a/server/src/api/auth.rs b/server/src/api/auth.rs\nindex 995ce4a61d29b024c399c656134f541ecfd880cf..b45ba39419c84af8bf2333fc9b7d47e98525c45f 100644\n--- a/server/src/api/auth.rs\n+++ b/server/src/api/auth.rs\n@@ -12,12 +12,61 @@ use tokio::sync::RwLock;\n \n use crate::{\n api::helpers::{api_error, now_ms, sha256_hex},\n- events::{Event, TokenIssued, UserRegistered},\n+ events::{Event, GrantAdded, TokenIssued, UserRegistered},\n identity::{parse_agent, parse_username},\n html::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page},\n state::{AppState, PendingSession},\n };\n \n+/// Delegate id for browser users who land via `/join/inv_…` (no CLI agent).\n+const INVITE_BROWSER_AGENT: &str = \"00000000-0000-0000-0000-000000000000:invite:web/join\";\n+\n+async fn apply_invite_redemption(state: &AppState, invite_token: &str, grantee_username: &str) -> Result<(), String> {\n+ let now = now_ms();\n+ let ga = {\n+ let mut invites = state.invites.write().await;\n+ let Some(inv) = invites.get_mut(invite_token) else {\n+ return Err(\"invite not found\".into());\n+ };\n+ if now > inv.expires_at_ms {\n+ invites.remove(invite_token);\n+ return Err(\"invite expired\".into());\n+ }\n+ if inv.current_uses >= inv.max_uses {\n+ return Err(\"invite exhausted\".into());\n+ }\n+ inv.current_uses += 1;\n+ Event::GrantAdded(GrantAdded {\n+ ts: now,\n+ room_id: inv.room_id.clone(),\n+ username: grantee_username.to_string(),\n+ capabilities: inv.capabilities.clone(),\n+ granted_by: inv.inviter.clone(),\n+ })\n+ };\n+\n+ match state.event_log.append(&ga).await {\n+ Ok(()) => {\n+ let mut reduced = state.reduced.write().await;\n+ reduced.apply_event(ga);\n+ let mut invites = state.invites.write().await;\n+ if let Some(inv) = invites.get(invite_token) {\n+ if inv.current_uses >= inv.max_uses {\n+ invites.remove(invite_token);\n+ }\n+ }\n+ Ok(())\n+ }\n+ Err(e) => {\n+ let mut invites = state.invites.write().await;\n+ if let Some(inv) = invites.get_mut(invite_token) {\n+ inv.current_uses = inv.current_uses.saturating_sub(1);\n+ }\n+ Err(format!(\"{e}\"))\n+ }\n+ }\n+}\n+\n fn pending_sessions(state: &AppState) -> Arc>> {\n state.pending_sessions.clone()\n }\n@@ -115,6 +164,42 @@ pub struct AuthLoginQuery {\n pub session: String,\n }\n \n+pub async fn get_join_invite(Path(token): Path, State(state): State) -> impl IntoResponse {\n+ let token = token.trim().to_string();\n+ if token.is_empty() {\n+ return api_error(StatusCode::NOT_FOUND, \"invite invalid or expired\", None).into_response();\n+ }\n+ let now = now_ms();\n+ let valid = {\n+ let invites = state.invites.read().await;\n+ match invites.get(&token) {\n+ None => false,\n+ Some(inv) => now <= inv.expires_at_ms && inv.current_uses < inv.max_uses,\n+ }\n+ };\n+ if !valid {\n+ return api_error(StatusCode::NOT_FOUND, \"invite invalid or expired\", None).into_response();\n+ }\n+\n+ let session = format!(\"p_{}\", uuid::Uuid::new_v4().simple());\n+ let s = PendingSession {\n+ agent: INVITE_BROWSER_AGENT.to_string(),\n+ created_ts: now_ms(),\n+ provider: None,\n+ provider_id: None,\n+ redeem_invite: Some(token),\n+ complete: None,\n+ };\n+ state.pending_sessions.write().await.insert(session.clone(), s);\n+\n+ let public_url = std::env::var(\"SLUG_PUBLIC_URL\").unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n+ Redirect::temporary(&format!(\n+ \"{public_url}/auth/login?session={}\",\n+ urlencoding::encode(&session)\n+ ))\n+ .into_response()\n+}\n+\n pub async fn get_auth_login(Query(q): Query, State(state): State) -> impl IntoResponse {\n // Redirect to Google auth endpoint.\n let sessions = pending_sessions(&state);\n@@ -205,6 +290,7 @@ pub async fn get_auth_callback(Query(q): Query, State(state):\n s.provider = Some(\"google\".to_string());\n s.provider_id = Some(sub.clone());\n if let Some(username) = existing {\n+ let invite_tok = s.redeem_invite.clone();\n let (bearer, token_event) = issue_token_for_user(&username);\n // append token event\n let ev = Event::TokenIssued(token_event);\n@@ -215,6 +301,11 @@ pub async fn get_auth_callback(Query(q): Query, State(state):\n let mut reduced = reduced_arc.write().await;\n reduced.apply_event(ev);\n }\n+ if let Some(tok) = invite_tok {\n+ if let Err(e) = apply_invite_redemption(&state, &tok, &username).await {\n+ tracing::warn!(error = %e, \"invite redemption skipped after oauth\");\n+ }\n+ }\n s.complete = Some((username, bearer));\n return Redirect::temporary(&format!(\"{public_url}/auth/complete\")).into_response();\n }\n@@ -310,6 +401,16 @@ pub async fn post_choose_username(\n reduced.apply_event(ti_ev.clone());\n }\n \n+ // Redeem invite (if any) before marking the session complete.\n+ if let Some(tok) = {\n+ let sessions_read = sessions.read().await;\n+ sessions_read.get(&form.session).and_then(|s| s.redeem_invite.clone())\n+ } {\n+ if let Err(e) = apply_invite_redemption(&state, &tok, &canon_user).await {\n+ tracing::warn!(error = %e, \"invite redemption skipped after registration\");\n+ }\n+ }\n+\n // Mark complete for polling.\n {\n let mut sessions_write = sessions.write().await;\n@@ -339,6 +440,7 @@ pub async fn post_pending_session(\n created_ts: now_ms(),\n provider: None,\n provider_id: None,\n+ redeem_invite: None,\n complete: None,\n };\n let sessions = pending_sessions(&state);\ndiff --git a/server/src/api/mod.rs b/server/src/api/mod.rs\nindex a1ea432932cdca0e42dc7377f0a25075fd5644c0..cb031aecebad1107dfa2898a98fb6084b28ba6e9 100644\n--- a/server/src/api/mod.rs\n+++ b/server/src/api/mod.rs\n@@ -4,6 +4,7 @@ mod rpc;\n mod validate;\n \n pub use auth::{\n+ get_join_invite,\n get_pending_session,\n get_whoami,\n post_pending_session,\ndiff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs\nindex 11a76ed7a02aae588edadb7a87a43660f5669d9e..f6bbc3df71909a2da7403cd46fe4ea6ca130c692 100644\n--- a/server/src/api/rpc.rs\n+++ b/server/src/api/rpc.rs\n@@ -13,12 +13,14 @@ use slug_types::*;\n use crate::{\n canonical_path::{canonicalize_item, canonicalize_tag},\n dsl,\n- events::{AgentBound, Event, GrantAdded, Ingest, RoomCreated, ThreadCapability, ThreadVisibility},\n+ events::{\n+ AgentBound, Event, GrantAdded, Ingest, RoomCreated, ThreadCapability, ThreadVisibility,\n+ },\n identity::{parse_agent, parse_username},\n path_types::CanonicalItemUrl,\n ranking::{connected_components_from_voted_pairs, ranked_items_subset},\n reducer::{scope_from_room_wire, ReducerState, ScopeId},\n- state::AppState,\n+ state::{AppState, InviteState},\n };\n \n use super::auth::verify_bearer_principal;\n@@ -142,6 +144,27 @@ fn gen_short_id() -> String {\n (0..7).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect()\n }\n \n+fn gen_invite_token() -> String {\n+ use rand::Rng;\n+ const ALPHABET: &[u8] = b\"0123456789abcdefghijklmnopqrstuvwxyz\";\n+ let mut rng = rand::thread_rng();\n+ let tail: String = (0..16).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect();\n+ format!(\"inv_{tail}\")\n+}\n+\n+const INVITE_TTL_MS: i64 = 86_400_000;\n+\n+fn capability_wire(c: ThreadCapability) -> String {\n+ match c {\n+ ThreadCapability::View => \"view\",\n+ ThreadCapability::Post => \"post\",\n+ ThreadCapability::Vote => \"vote\",\n+ ThreadCapability::AddItem => \"add_item\",\n+ ThreadCapability::Manage => \"manage\",\n+ }\n+ .to_string()\n+}\n+\n fn build_rank_response_for_content(\n content: &crate::reducer::ContentState,\n parent: Option<&str>,\n@@ -512,7 +535,7 @@ fn rpc_forum_thread_detail(\n None => Err((\"post not found\".into(), None)),\n Some((idx, ing)) => Ok(ThreadDetailResponse {\n thread: format!(\"#{}\", tag),\n- posts: vec![PostRow {\n+ items: vec![ThreadItem::Post {\n id: ing.id.clone(),\n index: idx,\n ts: ing.ts,\n@@ -543,7 +566,7 @@ fn rpc_forum_thread_detail(\n \n let total = filtered.len();\n const MAX_BODY: usize = 2000;\n- let posts: Vec = filtered\n+ let items: Vec = filtered\n .into_iter()\n .skip(offset)\n .take(limit)\n@@ -553,7 +576,7 @@ fn rpc_forum_thread_detail(\n } else {\n (ing.raw.clone(), false)\n };\n- PostRow {\n+ ThreadItem::Post {\n id: ing.id.clone(),\n index: idx,\n ts: ing.ts,\n@@ -566,7 +589,7 @@ fn rpc_forum_thread_detail(\n \n Ok(ThreadDetailResponse {\n thread: format!(\"#{}\", tag),\n- posts,\n+ items,\n total,\n offset,\n })\n@@ -1007,7 +1030,7 @@ pub async fn handle_rpc_batch(\n RpcCommand::RoomGrant {\n room,\n username,\n- capability,\n+ capabilities,\n } => {\n let principal = {\n let reduced = state.reduced.read().await;\n@@ -1022,6 +1045,8 @@ pub async fn handle_rpc_batch(\n };\n if !can_manage {\n line_err(\"requires Manage capability\", None)\n+ } else if capabilities.is_empty() {\n+ line_err(\"capabilities must not be empty\", None)\n } else {\n match parse_username(&username) {\n Err(msg) => line_err(\"invalid username\", Some(msg)),\n@@ -1033,14 +1058,18 @@ pub async fn handle_rpc_batch(\n if !user_exists {\n line_err(format!(\"user @{target} not found\"), None)\n } else {\n- match parse_capability(&capability) {\n+ let caps: Result, String> = capabilities\n+ .iter()\n+ .map(|c| parse_capability(c.trim()))\n+ .collect();\n+ match caps {\n Err(msg) => line_err(msg, None),\n- Ok(cap) => {\n+ Ok(caps) => {\n let ga_ev = Event::GrantAdded(GrantAdded {\n ts: now_ms(),\n room_id: room,\n username: target,\n- capabilities: vec![cap],\n+ capabilities: caps,\n granted_by: principal,\n });\n if let Err(e) = state.event_log.append(&ga_ev).await {\n@@ -1059,6 +1088,114 @@ pub async fn handle_rpc_batch(\n }\n }\n }\n+ RpcCommand::RoomMintInvite {\n+ room,\n+ capabilities,\n+ max_uses,\n+ } => {\n+ let principal = {\n+ let reduced = state.reduced.read().await;\n+ verify_bearer_principal(&headers, &*reduced)\n+ };\n+ match principal {\n+ Err((_, m)) => line_err(m, None),\n+ Ok(principal) => {\n+ let can_manage = {\n+ let reduced = state.reduced.read().await;\n+ reduced.user_has_cap(&room, &principal, ThreadCapability::Manage)\n+ };\n+ if !can_manage {\n+ line_err(\"requires Manage capability\", None)\n+ } else if capabilities.is_empty() {\n+ line_err(\"capabilities must not be empty\", None)\n+ } else {\n+ match capabilities\n+ .iter()\n+ .map(|c| parse_capability(c.trim()))\n+ .collect::, String>>()\n+ {\n+ Err(msg) => line_err(msg, None),\n+ Ok(caps) => {\n+ let max_uses = max_uses.max(1).min(100_000);\n+ let now = now_ms();\n+ let expires_at_ms = now + INVITE_TTL_MS;\n+ let token = loop {\n+ let t = gen_invite_token();\n+ let taken = {\n+ let invites = state.invites.read().await;\n+ invites.contains_key(&t)\n+ };\n+ if !taken {\n+ break t;\n+ }\n+ };\n+ let inv = InviteState {\n+ room_id: room.clone(),\n+ capabilities: caps,\n+ expires_at_ms,\n+ max_uses,\n+ current_uses: 0,\n+ inviter: principal,\n+ };\n+ state.invites.write().await.insert(token.clone(), inv);\n+ let public_url = std::env::var(\"SLUG_PUBLIC_URL\")\n+ .unwrap_or_else(|_| \"http://127.0.0.1:8080\".to_string());\n+ let invite_url = format!(\"{public_url}/join/{token}\");\n+ line_ok(RpcResult::RoomInviteMinted {\n+ invite_url,\n+ expires_at_ms: Some(expires_at_ms),\n+ max_uses,\n+ })\n+ }\n+ }\n+ }\n+ }\n+ }\n+ }\n+ RpcCommand::RoomAudit { room } => {\n+ let principal = {\n+ let reduced = state.reduced.read().await;\n+ verify_bearer_principal(&headers, &*reduced)\n+ };\n+ match principal {\n+ Err((_, m)) => line_err(m, None),\n+ Ok(principal) => {\n+ let reduced = state.reduced.read().await;\n+ if !reduced.rooms.contains_key(&room) {\n+ line_err(\"unknown room\", None)\n+ } else {\n+ let can_audit = reduced.user_has_cap(&room, &principal, ThreadCapability::View)\n+ || reduced.user_has_cap(&room, &principal, ThreadCapability::Manage);\n+ if !can_audit {\n+ line_err(\"requires View or Manage capability\", None)\n+ } else {\n+ let grants: Vec = reduced\n+ .grants\n+ .get(&room)\n+ .map(|m| {\n+ let mut v: Vec = m\n+ .iter()\n+ .map(|(username, caps)| {\n+ let mut c: Vec =\n+ caps.iter().copied().map(capability_wire).collect();\n+ c.sort();\n+ RoomAuditEntry {\n+ username: username.clone(),\n+ capabilities: c,\n+ }\n+ })\n+ .collect();\n+ v.sort_by(|a, b| a.username.cmp(&b.username));\n+ v\n+ })\n+ .unwrap_or_default();\n+ line_ok(RpcResult::RoomAudit(RoomAuditResponse { room, grants }))\n+ }\n+ }\n+ }\n+ }\n+ }\n+ RpcCommand::RoomRevoke { .. } => line_err(\"RoomRevoke is not implemented yet\", None),\n RpcCommand::GetGlobalRank {\n room,\n limit,\ndiff --git a/server/src/events.rs b/server/src/events.rs\nindex 125005d89ade7b22c9b6d997f0ed81781df5e7ec..9e60f2c218f5c871a93e855e415dd984e3a017c5 100644\n--- a/server/src/events.rs\n+++ b/server/src/events.rs\n@@ -26,6 +26,8 @@ pub enum Event {\n RoomCreated(RoomCreated),\n GrantAdded(GrantAdded),\n GrantRevoked(GrantRevoked),\n+ InviteMinted(InviteMinted),\n+ InviteRedeemed(InviteRedeemed),\n /// Ingest of a DSL+prose body. Identity and routing live in event metadata.\n Ingest(Ingest),\n }\n@@ -82,6 +84,25 @@ pub struct GrantRevoked {\n pub revoked_by: String,\n }\n \n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n+pub struct InviteMinted {\n+ pub ts: i64,\n+ pub token: String,\n+ pub room_id: String,\n+ pub capabilities: Vec,\n+ pub inviter: String,\n+ pub max_uses: u32,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub expires_ts_ms: Option,\n+}\n+\n+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n+pub struct InviteRedeemed {\n+ pub ts: i64,\n+ pub token: String,\n+ pub username: String,\n+}\n+\n #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]\n pub struct Ingest {\n /// Unix timestamp in milliseconds.\ndiff --git a/server/src/lib.rs b/server/src/lib.rs\nindex d00b56d909e1fabd1d122d054dd02785e13e0bd8..bf2f73d4d0f26a357961e88c52c3b4f72626af81 100644\n--- a/server/src/lib.rs\n+++ b/server/src/lib.rs\n@@ -27,6 +27,7 @@ pub fn create_app(state: AppState) -> Router {\n Router::new()\n .route(\"/healthz\", axum::routing::get(|| async { \"ok\" }))\n .route(\"/static/:filename\", axum::routing::get(crate::html::serve_theme_css))\n+ .route(\"/join/:token\", axum::routing::get(api::get_join_invite))\n .route(\"/auth/login\", axum::routing::get(api::get_auth_login))\n .route(\"/auth/callback\", axum::routing::get(api::get_auth_callback))\n .route(\"/auth/complete\", axum::routing::get(api::get_auth_complete))\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex 118cb19f4df4a083df2878ca7a1424f3d63de851..5190d0a7ee3d545a786a68fef483322aafdce7f9 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet, VecDeque};\n use serde::{Deserialize, Serialize};\n \n use crate::canonical_path::canonicalize_tag;\n-use crate::events::{Event, Ingest, ThreadCapability};\n+use crate::events::{Event, Ingest, ThreadCapability, ThreadVisibility};\n use crate::path_types::CanonicalItemUrl;\n \n #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]\n@@ -156,6 +156,41 @@ pub struct RoomState {\n pub visibility: crate::events::ThreadVisibility,\n }\n \n+/// Durable invite link state (from [`crate::events::InviteMinted`] / [`crate::events::InviteRedeemed`]).\n+#[derive(Debug, Clone)]\n+pub struct ActiveInviteState {\n+ pub room_id: String,\n+ pub capabilities: HashSet,\n+ pub inviter: String,\n+ pub uses_remaining: u32,\n+ pub expires_ts_ms: Option,\n+}\n+\n+#[derive(Clone, Debug)]\n+pub enum RoomTimelineKind {\n+ RoomCreated {\n+ owner: String,\n+ slug: String,\n+ visibility: ThreadVisibility,\n+ },\n+ GrantAdded {\n+ username: String,\n+ granted_by: String,\n+ capabilities: Vec,\n+ },\n+ GrantRevoked {\n+ username: String,\n+ revoked_by: String,\n+ capabilities: Vec,\n+ },\n+}\n+\n+#[derive(Clone, Debug)]\n+pub struct RoomTimelineEntry {\n+ pub ts: i64,\n+ pub kind: RoomTimelineKind,\n+}\n+\n #[derive(Debug, Clone)]\n pub struct ForumThreadState {\n pub last_activity_ts: i64,\n@@ -210,6 +245,10 @@ pub struct ReducerState {\n pub ingests_ordered: Vec,\n /// room_id → username → capabilities\n pub grants: HashMap>>,\n+ /// room_id → chronological room admin lines (for thread UI).\n+ pub room_timeline: HashMap>,\n+ /// Invite token → active invite (absent when fully consumed or never minted).\n+ pub invites: HashMap,\n }\n \n impl ReducerState {\n@@ -225,6 +264,20 @@ impl ReducerState {\n .unwrap_or(false)\n }\n \n+ /// Invite link is present, not expired, and has uses left.\n+ pub fn invite_token_active(&self, token: &str, now_ms: i64) -> Option<&ActiveInviteState> {\n+ let inv = self.invites.get(token)?;\n+ if inv.uses_remaining == 0 {\n+ return None;\n+ }\n+ if let Some(exp) = inv.expires_ts_ms {\n+ if now_ms > exp {\n+ return None;\n+ }\n+ }\n+ Some(inv)\n+ }\n+\n pub fn content_for_scope_mut(&mut self, scope: ScopeId) -> &mut ContentState {\n self.content.entry(scope).or_default()\n }\n@@ -356,6 +409,17 @@ impl ReducerState {\n visibility: rc.visibility,\n },\n );\n+ self.room_timeline\n+ .entry(rc.room_id.clone())\n+ .or_default()\n+ .push(RoomTimelineEntry {\n+ ts: rc.ts,\n+ kind: RoomTimelineKind::RoomCreated {\n+ owner: rc.owner.clone(),\n+ slug: rc.slug.clone(),\n+ visibility: rc.visibility,\n+ },\n+ });\n }\n Event::Ingest(mut ing) => {\n ing.thread_tag = canonicalize_tag(&ing.thread_tag);\n@@ -525,18 +589,31 @@ impl ReducerState {\n nav!(self.actor_last_post_ts, keypath(ing.principal.clone()), setval(ing.ts));\n }\n Event::GrantAdded(ga) => {\n+ let room_id = ga.room_id.clone();\n let caps = self.grants\n .entry(ga.room_id)\n .or_default()\n- .entry(ga.username)\n+ .entry(ga.username.clone())\n .or_default();\n- for cap in ga.capabilities {\n+ for cap in ga.capabilities.iter().copied() {\n caps.insert(cap);\n }\n+ self.room_timeline\n+ .entry(room_id)\n+ .or_default()\n+ .push(RoomTimelineEntry {\n+ ts: ga.ts,\n+ kind: RoomTimelineKind::GrantAdded {\n+ username: ga.username.clone(),\n+ granted_by: ga.granted_by.clone(),\n+ capabilities: ga.capabilities.clone(),\n+ },\n+ });\n }\n Event::GrantRevoked(gr) => {\n+ let room_id = gr.room_id.clone();\n if let Some(room_grants) = self.grants.get_mut(&gr.room_id) {\n- let username = gr.username;\n+ let username = gr.username.clone();\n if let Some(caps) = room_grants.get_mut(&username) {\n for cap in &gr.capabilities {\n caps.remove(cap);\n@@ -549,6 +626,37 @@ impl ReducerState {\n self.grants.remove(&gr.room_id);\n }\n }\n+ self.room_timeline\n+ .entry(room_id)\n+ .or_default()\n+ .push(RoomTimelineEntry {\n+ ts: gr.ts,\n+ kind: RoomTimelineKind::GrantRevoked {\n+ username: gr.username.clone(),\n+ revoked_by: gr.revoked_by.clone(),\n+ capabilities: gr.capabilities.clone(),\n+ },\n+ });\n+ }\n+ Event::InviteMinted(im) => {\n+ self.invites.insert(\n+ im.token.clone(),\n+ ActiveInviteState {\n+ room_id: im.room_id.clone(),\n+ capabilities: im.capabilities.iter().copied().collect(),\n+ inviter: im.inviter.clone(),\n+ uses_remaining: im.max_uses,\n+ expires_ts_ms: im.expires_ts_ms,\n+ },\n+ );\n+ }\n+ Event::InviteRedeemed(ir) => {\n+ if let Some(inv) = self.invites.get_mut(&ir.token) {\n+ inv.uses_remaining = inv.uses_remaining.saturating_sub(1);\n+ if inv.uses_remaining == 0 {\n+ self.invites.remove(&ir.token);\n+ }\n+ }\n }\n }\n }\n@@ -570,6 +678,8 @@ impl Default for ReducerState {\n actor_last_post_ts: HashMap::new(),\n ingests_ordered: Vec::new(),\n grants: HashMap::new(),\n+ room_timeline: HashMap::new(),\n+ invites: HashMap::new(),\n }\n }\n }\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex b1ff2330903780cbe0cdf35964cb166eb2423d7d..628fd5921b34ba53ac0ca6b6fd33957ca09129b9 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -1,8 +1,20 @@\n+use std::collections::HashMap;\n use std::sync::Arc;\n \n use tokio::sync::{broadcast, RwLock};\n \n-use crate::{event_log::EventLog, reducer::ReducerState};\n+use crate::{event_log::EventLog, events::ThreadCapability, reducer::ReducerState};\n+\n+/// Ephemeral invite link (24h TTL, in-memory only; not written to the event log).\n+#[derive(Debug, Clone)]\n+pub struct InviteState {\n+ pub room_id: String,\n+ pub capabilities: Vec,\n+ pub expires_at_ms: i64,\n+ pub max_uses: usize,\n+ pub current_uses: usize,\n+ pub inviter: String,\n+}\n \n #[derive(Debug, Clone)]\n pub struct PendingSession {\n@@ -10,6 +22,8 @@ pub struct PendingSession {\n pub created_ts: i64,\n pub provider: Option,\n pub provider_id: Option,\n+ /// When set, successful OAuth completion redeems this invite token and appends [`crate::events::GrantAdded`].\n+ pub redeem_invite: Option,\n pub complete: Option<(String /*username*/, String /*bearer*/ )>,\n }\n \n@@ -42,7 +56,9 @@ pub struct AppState {\n pub cfg: Arc,\n pub event_log: Arc,\n pub reduced: Arc>,\n- pub pending_sessions: Arc>>,\n+ pub pending_sessions: Arc>>,\n+ /// Ephemeral invite tokens (`inv_…`) until expiry or exhaustion.\n+ pub invites: Arc>>,\n /// Broadcast channel for SSE live-streaming. Capacity = 64 events.\n pub stream_tx: broadcast::Sender,\n /// Broadcast channel for web SSE HTML fragments (poem pattern). Capacity = 64.\n@@ -58,7 +74,8 @@ impl AppState {\n cfg: Arc::new(cfg),\n event_log: Arc::new(event_log),\n reduced: Arc::new(RwLock::new(ReducerState::default())),\n- pending_sessions: Arc::new(RwLock::new(std::collections::HashMap::new())),\n+ pending_sessions: Arc::new(RwLock::new(HashMap::new())),\n+ invites: Arc::new(RwLock::new(HashMap::new())),\n stream_tx,\n html_tx,\n }\ndiff --git a/server/src/timeline.rs b/server/src/timeline.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..251158943ab36d3015268e35f6bdd01c3f42ab3b\n--- /dev/null\n+++ b/server/src/timeline.rs\n@@ -0,0 +1,153 @@\n+//! Room admin lines merged into forum thread views.\n+\n+use crate::{\n+ canonical_path::canonicalize_tag,\n+ reducer::{ReducerState, RoomTimelineEntry, RoomTimelineKind},\n+};\n+\n+fn cap_label(c: crate::events::ThreadCapability) -> &'static str {\n+ use crate::events::ThreadCapability::*;\n+ match c {\n+ View => \"view\",\n+ Post => \"post\",\n+ Vote => \"vote\",\n+ AddItem => \"add_item\",\n+ Manage => \"manage\",\n+ }\n+}\n+\n+fn caps_list(caps: &[crate::events::ThreadCapability]) -> String {\n+ let mut v: Vec<_> = caps.iter().map(|c| cap_label(*c)).collect();\n+ v.sort();\n+ v.join(\", \")\n+}\n+\n+/// Human-readable system line for the thread feed.\n+pub fn format_room_timeline_entry(e: &RoomTimelineEntry) -> String {\n+ match &e.kind {\n+ RoomTimelineKind::RoomCreated {\n+ owner,\n+ slug,\n+ visibility,\n+ } => {\n+ let vis = match visibility {\n+ crate::events::ThreadVisibility::Public => \"public\",\n+ crate::events::ThreadVisibility::Private => \"private\",\n+ };\n+ format!(\"@{owner} created room #{slug} ({vis})\")\n+ }\n+ RoomTimelineKind::GrantAdded {\n+ username,\n+ granted_by,\n+ capabilities,\n+ } => {\n+ format!(\n+ \"@{granted_by} granted @{} {}\",\n+ username,\n+ caps_list(capabilities)\n+ )\n+ }\n+ RoomTimelineKind::GrantRevoked {\n+ username,\n+ revoked_by,\n+ capabilities,\n+ } => {\n+ format!(\n+ \"@{revoked_by} revoked @{} {}\",\n+ username,\n+ caps_list(capabilities)\n+ )\n+ }\n+ }\n+}\n+\n+#[derive(Clone, Debug)]\n+pub enum MergedThreadRow {\n+ System { ts: i64, text: String },\n+ Post {\n+ index: usize,\n+ id: String,\n+ ts: i64,\n+ principal: String,\n+ raw: String,\n+ },\n+}\n+\n+/// Merge room admin lines with thread ingests for one room + tag. Oldest first.\n+/// `actor_prefix` filters posts only (system lines always included).\n+pub fn merge_thread_rows(\n+ reduced: &ReducerState,\n+ room_wire: &str,\n+ thread_tag: &str,\n+ since: Option,\n+ before: Option,\n+ actor_prefix: &str,\n+) -> Vec {\n+ let scope = crate::reducer::scope_from_room_wire(room_wire);\n+ let tag = canonicalize_tag(thread_tag);\n+ let key = (scope.clone(), tag.clone());\n+\n+ let mut rows: Vec = Vec::new();\n+\n+ if let Some(entries) = reduced.room_timeline.get(room_wire.trim()) {\n+ for e in entries {\n+ if since.map_or(true, |s| e.ts >= s) && before.map_or(true, |b| e.ts < b) {\n+ rows.push(MergedThreadRow::System {\n+ ts: e.ts,\n+ text: format_room_timeline_entry(e),\n+ });\n+ }\n+ }\n+ }\n+\n+ let all_ids: Vec = reduced\n+ .ingests_by_scope_thread\n+ .get(&key)\n+ .map(|q| q.iter().rev().cloned().collect())\n+ .unwrap_or_default();\n+\n+ for (idx, id) in all_ids.into_iter().enumerate() {\n+ let Some(ing) = reduced.ingests_by_id.get(&id) else {\n+ continue;\n+ };\n+ if since.map_or(true, |s| ing.ts >= s) && before.map_or(true, |b| ing.ts < b) {\n+ if !actor_prefix.is_empty()\n+ && !ing\n+ .principal\n+ .to_lowercase()\n+ .starts_with(actor_prefix)\n+ {\n+ continue;\n+ }\n+ rows.push(MergedThreadRow::Post {\n+ index: idx,\n+ id: ing.id.clone(),\n+ ts: ing.ts,\n+ principal: ing.principal.clone(),\n+ raw: ing.raw.clone(),\n+ });\n+ }\n+ }\n+\n+ rows.sort_by(|a, b| {\n+ let ta = match a {\n+ MergedThreadRow::System { ts, .. } | MergedThreadRow::Post { ts, .. } => *ts,\n+ };\n+ let tb = match b {\n+ MergedThreadRow::System { ts, .. } | MergedThreadRow::Post { ts, .. } => *ts,\n+ };\n+ ta.cmp(&tb)\n+ });\n+ rows\n+}\n+\n+/// Public forum thread (`room_wire == \"public\"`): same merge (timeline usually empty).\n+pub fn merge_public_thread_rows(\n+ reduced: &ReducerState,\n+ thread_tag: &str,\n+ since: Option,\n+ before: Option,\n+ actor_prefix: &str,\n+) -> Vec {\n+ merge_thread_rows(reduced, \"public\", thread_tag, since, before, actor_prefix)\n+}\ndiff --git a/test/grants.bb b/test/grants.bb\nindex 10ef0f08ce1c3945e5f1634314e6aebcf6810cd2..4ded5ce92576cdea653cce0ba8a751be4374731a 100644\n--- a/test/grants.bb\n+++ b/test/grants.bb\n@@ -105,7 +105,7 @@\n ;; Alice grants bob View only.\n (println \"\\nalice grants bob View only…\")\n (assert! (rpc-line-ok? (:parsed (rpc-batch! base-url alice-token\n- [{\"RoomGrant\" {\"room\" room-id \"username\" \"bob\" \"capability\" \"view\"}}])))\n+ [{\"RoomGrant\" {\"room\" room-id \"username\" \"bob\" \"capabilities\" [\"view\"]}}])))\n \"grant View RPC ok\")\n \n (println \"\\nbob (View only) tries to post prose…\")\n@@ -117,7 +117,7 @@\n ;; Alice grants bob Post.\n (println \"\\nalice grants bob Post…\")\n (assert! (rpc-line-ok? (:parsed (rpc-batch! base-url alice-token\n- [{\"RoomGrant\" {\"room\" room-id \"username\" \"bob\" \"capability\" \"post\"}}])))\n+ [{\"RoomGrant\" {\"room\" room-id \"username\" \"bob\" \"capabilities\" [\"post\"]}}])))\n \"grant Post RPC ok\")\n \n (println \"\\nbob (View + Post) posts prose…\")\n@@ -143,7 +143,7 @@\n ;; Alice grants bob Vote.\n (println \"\\nalice grants bob Vote…\")\n (assert! (rpc-line-ok? (:parsed (rpc-batch! base-url alice-token\n- [{\"RoomGrant\" {\"room\" room-id \"username\" \"bob\" \"capability\" \"vote\"}}])))\n+ [{\"RoomGrant\" {\"room\" room-id \"username\" \"bob\" \"capabilities\" [\"vote\"]}}])))\n \"grant Vote RPC ok\")\n \n (println \"\\nbob (View + Post + Vote) votes…\")\ndiff --git a/test/invites.bb b/test/invites.bb\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..f72b1e630a6856174cdef11eb977e2f81b16e551\n--- /dev/null\n+++ b/test/invites.bb\n@@ -0,0 +1,150 @@\n+(ns test.invites\n+ \"Ephemeral invite links: mint via RPC, GET /join → pending session + OAuth, redemption → GrantAdded,\n+ RoomAudit, post succeeds, second GET /join returns 404 when max_uses exhausted.\"\n+ (:require [babashka.fs :as fs]\n+ [cheshire.core :as json]\n+ [clojure.string :as str]\n+ [test.common :as common]\n+ [test.oauth :as oauth]))\n+\n+(def ^:private counts (atom {:pass 0 :fail 0}))\n+\n+(defn- assert! [pred msg]\n+ (common/test-assert! counts pred msg))\n+\n+(defn- bearer [token] {\"Authorization\" (str \"Bearer \" token)})\n+\n+(defn- rpc-batch! [base-url token cmds]\n+ (let [resp (oauth/http-post-json (str base-url \"/api/v0/rpc\") cmds :headers (bearer token))]\n+ {:status (:status resp)\n+ :parsed (json/parse-string (:body resp) false)}))\n+\n+(defn- rpc-line-ok? [parsed]\n+ (true? (get-in parsed [\"results\" 0 \"ok\"])))\n+\n+(defn- session-from-login-location [loc]\n+ (when loc\n+ (let [qpart (if (str/includes? loc \"?\")\n+ (-> loc (str/split #\"\\?\" 2) second)\n+ \"\")\n+ qpart (if (str/includes? qpart \"#\")\n+ (-> qpart (str/split #\"#\" 2) first)\n+ qpart)\n+ m (oauth/parse-query qpart)]\n+ (some-> (get m :session) str))))\n+\n+(defn- invite-token-from-url [invite-url]\n+ (second (re-find #\"/join/(inv_[^/?#]+)\" (str invite-url))))\n+\n+(defn- register-user! [base-url session-agent username]\n+ (oauth/complete-registration! base-url\n+ :agent session-agent\n+ :username username\n+ :assert! (fn [pred msg] (assert! pred msg))))\n+\n+(defn- ingest! [base-url token room thread delegate text]\n+ (rpc-batch! base-url token\n+ [{\"Post\" {\"room\" room\n+ \"thread_tag\" thread\n+ \"delegate\" delegate\n+ \"text\" text\n+ \"return_rank_diff\" false}}]))\n+\n+(defn invites-test [& _args]\n+ (println \"\\n━━━ ephemeral invite + audit integration check ━━━\\n\")\n+ (reset! counts {:pass 0 :fail 0})\n+\n+ (println \"building server binary…\")\n+ (common/letlocals\n+ (bind build (common/run-cargo-build-release! [\"slugsocial-server\"]))\n+ (assert! (zero? (:exit build)) \"cargo build succeeds\")\n+ (bind server-bin \"target/release/slugsocial-server\")\n+\n+ (bind tmp-dir (str (fs/create-temp-dir {:prefix \"slug-invites-\"})))\n+ (bind slug-port (common/pick-port))\n+ (bind google-port (common/pick-port))\n+ (bind base-url (str \"http://127.0.0.1:\" slug-port))\n+ (bind google-url (str \"http://127.0.0.1:\" google-port))\n+\n+ (bind !server (atom nil))\n+ (bind !google (atom nil))\n+\n+ (bind server-env (common/slug-server-env tmp-dir base-url google-url slug-port))\n+ (try\n+ (println (str \"starting mock google on :\" google-port))\n+ (reset! !google (oauth/start-mock-google google-port\n+ :google-users [\"google-user-alice\" \"google-user-bob\"]))\n+\n+ (println (str \"starting server on :\" slug-port))\n+ (reset! !server (common/start-server server-bin server-env))\n+ (assert! (common/wait-for-server base-url 10000) \"server responds to /healthz\")\n+\n+ (println \"\\nregistering alice…\")\n+ (let [alice-token (register-user! base-url\n+ \"00000000-0000-0000-0000-000000000001:test:local/dev\"\n+ \"alice\")\n+\n+ _ (println \"\\nalice creates private room…\")\n+ create (rpc-batch! base-url alice-token\n+ [{\"RoomCreate\" {\"slug\" \"invite-demo\" \"visibility\" \"private\"}}])\n+ _ (assert! (= 200 (:status create)) \"room create HTTP 200\")\n+ _ (assert! (rpc-line-ok? (:parsed create)) \"room create RPC ok\")\n+ room-id (get-in (:parsed create) [\"results\" 0 \"result\" \"RoomCreated\" \"room_id\"])\n+ _ (assert! (some? room-id) \"room_id present\")\n+\n+ _ (println \"\\nalice mints invite (view,post,vote uses=1)…\")\n+ mint (rpc-batch! base-url alice-token\n+ [{\"RoomMintInvite\" {\"room\" room-id\n+ \"capabilities\" [\"view\" \"post\" \"vote\"]\n+ \"max_uses\" 1}}])\n+ _ (assert! (= 200 (:status mint)) \"mint HTTP 200\")\n+ _ (assert! (rpc-line-ok? (:parsed mint)) \"mint RPC ok\")\n+ invite-url (get-in (:parsed mint) [\"results\" 0 \"result\" \"RoomInviteMinted\" \"invite_url\"])\n+ inv-tok (invite-token-from-url invite-url)\n+ _ (assert! (some? inv-tok) \"invite token parsed from URL\")\n+\n+ _ (println \"\\nGET /join/:token (expect redirect + session)…\")\n+ join-resp (oauth/http-get-no-redirect (str base-url \"/join/\" inv-tok))\n+ _ (assert! (contains? #{302 307} (:status join-resp))\n+ (str \"join returns redirect (got status \" (:status join-resp) \")\"))\n+ sess (session-from-login-location (:location join-resp))\n+ _ (assert! (and (some? sess) (str/starts-with? sess \"p_\")) \"Location carries session=p_…\")\n+\n+ _ (println \"\\nbob completes OAuth via invite session…\")\n+ bob-token (oauth/complete-pending-session! base-url sess \"bob\"\n+ :assert! (fn [pred msg] (assert! pred msg)))\n+\n+ _ (println \"\\nalice runs RoomAudit…\")\n+ audit (rpc-batch! base-url alice-token [{\"RoomAudit\" {\"room\" room-id}}])\n+ _ (assert! (rpc-line-ok? (:parsed audit)) \"audit RPC ok\")\n+ grants (get-in (:parsed audit) [\"results\" 0 \"result\" \"RoomAudit\" \"grants\"])\n+ bob-entry (first (filter #(= \"bob\" (get % \"username\")) grants))\n+ _ (assert! (some? bob-entry) \"audit lists bob\")\n+ bob-caps (set (get bob-entry \"capabilities\"))\n+ _ (assert! (= bob-caps #{\"view\" \"post\" \"vote\"}) \"bob has view, post, vote\")\n+\n+ _ (println \"\\nbob posts prose to private room…\")\n+ _ (assert! (rpc-line-ok? (:parsed (ingest! base-url bob-token room-id \"main\"\n+ \"00000000-0000-0000-0000-000000000002:test:local/dev\"\n+ \"Hello via invite link.\")))\n+ \"bob post succeeds\")\n+\n+ _ (println \"\\nsecond GET /join (invite exhausted → 404)…\")\n+ join2 (oauth/http-get-no-redirect (str base-url \"/join/\" inv-tok))\n+ _ (assert! (= 404 (:status join2)) \"exhausted invite returns 404\")]\n+\n+ (println \"\\ninvite lifecycle OK.\"))\n+\n+ (finally\n+ (when-some [s @!server] (common/kill-server s))\n+ (when-some [g @!google] ((:stop-fn g)))\n+ (fs/delete-tree tmp-dir)))\n+\n+ (bind {pass :pass fail :fail} @counts)\n+ (if (zero? fail)\n+ (println (str \"\\n\" common/ansi-green \"━━━ \" pass \" invite checks passed ━━━\" common/ansi-reset \"\\n\"))\n+ (do (println (str \"\\n\" common/ansi-red \"━━━ \" fail \" invite checks FAILED ━━━\" common/ansi-reset \"\\n\"))\n+ (System/exit 1)))))\n+\n+(when (= *file* (System/getProperty \"babashka.file\"))\n+ (invites-test))\ndiff --git a/test/oauth.bb b/test/oauth.bb\nindex b69459acd17c23c023170d8dd93fe45bb49c8d88..efb3f3e66346ffde81bf9622d75c3a6ec89f8db2 100644\n--- a/test/oauth.bb\n+++ b/test/oauth.bb\n@@ -21,6 +21,21 @@\n resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]\n {:status (.statusCode resp) :body (.body resp) :headers (.map (.headers resp))})))\n \n+(defn http-get-no-redirect\n+ \"GET without following redirects; returns `:location` from the first `Location` header when present.\"\n+ [url & {:keys [headers]}]\n+ (let [client (-> (java.net.http.HttpClient/newBuilder)\n+ (.followRedirects java.net.http.HttpClient$Redirect/NEVER)\n+ (.connectTimeout connect-timeout)\n+ (.build))\n+ b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]\n+ (doseq [[k v] (or headers {})]\n+ (.header b k v))\n+ (let [req (-> b (.timeout request-timeout) (.GET) (.build))\n+ resp (.send client req (java.net.http.HttpResponse$BodyHandlers/ofString))\n+ loc (first (get (.map (.headers resp)) \"location\"))]\n+ {:status (.statusCode resp) :body (.body resp) :location loc})))\n+\n (defn http-post-json [url data & {:keys [headers]}]\n (let [body (json/generate-string data)\n b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]\n@@ -50,6 +65,35 @@\n resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]\n {:status (.statusCode resp) :body (.body resp) :headers (.map (.headers resp))})))\n \n+(defn complete-pending-session!\n+ \"Finish OAuth for an existing pending session id (e.g. created by `GET /join/inv_…`). Returns bearer token.\"\n+ [base-url session-id username & {:keys [assert!]}]\n+ (let [check! (fn [pred msg resp]\n+ (if assert!\n+ (assert! pred msg)\n+ (when-not pred\n+ (throw (ex-info msg {:resp resp})))))]\n+ (let [enc (java.net.URLEncoder/encode session-id \"UTF-8\")\n+ login-url (str base-url \"/auth/login?session=\" enc)\n+ login-get (http-get login-url)]\n+ (check! (= 200 (:status login-get))\n+ (str \"oauth redirect chain for session \" session-id)\n+ login-get)\n+ (let [choose (http-post-form (str base-url \"/auth/choose-username\")\n+ {:session session-id :username username})]\n+ (check! (= 200 (:status choose))\n+ (str \"choose-username for \" username \" returns 200\")\n+ choose)\n+ (let [poll (http-get (str base-url \"/api/v0/pending-session/\" session-id))]\n+ (check! (= 200 (:status poll))\n+ (str \"pending-session poll returns 200\")\n+ poll)\n+ (let [poll-json (json/parse-string (:body poll) true)]\n+ (check! (:complete poll-json)\n+ (str \"pending session complete for \" username)\n+ poll)\n+ (:token poll-json)))))))\n+\n (defn parse-query [s]\n (into {}\n (for [part (str/split (or s \"\") #\"&\")\ndiff --git a/types/src/lib.rs b/types/src/lib.rs\nindex a715fe5639f4a3a7bfedcc706b5fb312b98c9ac5..98c66a00fd27803ef6f75b5ac478ff2eb762d771 100644\n--- a/types/src/lib.rs\n+++ b/types/src/lib.rs\n@@ -123,13 +123,32 @@ pub struct PathDetailResponse {\n #[derive(Debug, Serialize, Deserialize)]\n pub struct ThreadDetailResponse {\n pub thread: String,\n- pub posts: Vec,\n- /// Total posts in this thread.\n+ /// Chronological page: prose posts and room system lines, oldest first within the window.\n+ pub items: Vec,\n+ /// Total rows (posts + system lines) in this thread after filters.\n pub total: usize,\n- /// Chronological offset of the first post in this page.\n+ /// Offset into the merged chronological list.\n pub offset: usize,\n }\n \n+/// One row in a thread timeline: a normal post or a room system line.\n+#[derive(Debug, Serialize, Deserialize)]\n+#[serde(tag = \"kind\", rename_all = \"snake_case\")]\n+pub enum ThreadItem {\n+ Post {\n+ id: String,\n+ index: usize,\n+ ts: i64,\n+ actor: String,\n+ body: String,\n+ truncated: bool,\n+ },\n+ System {\n+ ts: i64,\n+ text: String,\n+ },\n+}\n+\n /// One post in a thread. Full body, no snippet.\n #[derive(Debug, Serialize, Deserialize)]\n pub struct PostRow {\n@@ -224,6 +243,23 @@ pub struct FeedPost {\n // RPC batch API (`POST /api/v0/rpc`)\n // ---------------------------------------------------------------------------\n \n+fn default_invite_max_uses() -> usize {\n+ 1\n+}\n+\n+/// One principal's capabilities in a private room (from [`RpcCommand::RoomAudit`]).\n+#[derive(Debug, Serialize, Deserialize)]\n+pub struct RoomAuditEntry {\n+ pub username: String,\n+ pub capabilities: Vec,\n+}\n+\n+#[derive(Debug, Serialize, Deserialize)]\n+pub struct RoomAuditResponse {\n+ pub room: String,\n+ pub grants: Vec,\n+}\n+\n #[derive(Debug, Serialize, Deserialize)]\n #[serde(transparent)]\n pub struct RpcBatch(pub Vec);\n@@ -287,10 +323,27 @@ pub enum RpcCommand {\n visibility: Option,\n },\n RoomGrant {\n+ room: String,\n+ username: String,\n+ /// Capability names: `view`, `post`, `vote`, `add_item`, `manage`.\n+ capabilities: Vec,\n+ },\n+ RoomRevoke {\n room: String,\n username: String,\n capability: String,\n },\n+ /// Mint a shareable invite link (24h TTL, stored in memory only until redeemed or expiry).\n+ RoomMintInvite {\n+ room: String,\n+ capabilities: Vec,\n+ #[serde(default = \"default_invite_max_uses\")]\n+ max_uses: usize,\n+ },\n+ /// List principals granted access in a room (requires View or Manage).\n+ RoomAudit {\n+ room: String,\n+ },\n GetGlobalRank {\n room: String,\n #[serde(default)]\n@@ -361,6 +414,13 @@ pub enum RpcResult {\n RoomCreated {\n room_id: String,\n },\n+ RoomInviteMinted {\n+ invite_url: String,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ expires_at_ms: Option,\n+ max_uses: usize,\n+ },\n+ RoomAudit(RoomAuditResponse),\n GrantOk {},\n GlobalRank(GlobalRankResponse),\n Pair(PairResponse),\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[595b3850] Fix star-topology ranking by using degree-based d_max (#146).\n\nA pure forward star at the default `>` ratio (2:1) produced uniform 1/3\nscores, and the alphabetical-fallback sort placed the unambiguous winner\nlast. Root cause: `compute_scores_from_edges` divided by the max sum of\npairwise-normalized weights, so every node ended up with P_ii = 0 — a\nbipartite Markov chain whose power iteration oscillated and, after the\nconfigured even iteration count, returned to the uniform initial state.\n\nSwitch the divisor to the unweighted max neighbor degree, matching the\ncanonical Rank Centrality definition in Negahban–Oh–Shah 2012 §3.1\n(arXiv:1209.1688, eq. defP and the d_max definition in §6). This gives\nevery non-saturated node a positive self-loop, makes the chain aperiodic,\nand converges the star to π_zebra = 1/2, π_alpha = π_beta = 1/4.\n\nAdd Rust regression test and a Clojure test that drives the sorterc\nbinary against four .sorter fixtures (star, inverse star, chain, cycle).\n\nCo-Authored-By: Claude Opus 4.7 (1M context) \n\nSide B — unified diff (full patch):\ndiff --git a/server/src/ranking.rs b/server/src/ranking.rs\nindex 3710c9f64437f5bef3b2121905b6f3bcb7611047..38e6d09b4370e5f8cbae09c0e5760b4e7f1ef7db 100644\n--- a/server/src/ranking.rs\n+++ b/server/src/ranking.rs\n@@ -1,4 +1,4 @@\n-use std::collections::HashMap;\n+use std::collections::{HashMap, HashSet};\n \n use crate::path_types::ItemId;\n use crate::reducer::GroupState;\n@@ -143,23 +143,35 @@ fn compute_scores_from_edges(n: usize, edges: impl Iterator 0 for every non-maximum-degree node, and for max-degree\n+ // nodes whenever any neighbor weight is below 1 (i.e. not a unanimous\n+ // loss). Without this, regular comparison graphs (e.g. a pure star at\n+ // ratio 2:1) produce a bipartite chain that oscillates instead of\n+ // converging — see issue #146.\n let mut out_edges: Vec> = vec![Vec::new(); n];\n- let mut out_deg: Vec = vec![0.0; n];\n+ let mut neighbors: Vec> = vec![HashSet::new(); n];\n \n for ((src, dst), w) in &normalized {\n out_edges[*src].push((*dst, *w));\n- out_deg[*src] += w;\n+ neighbors[*src].insert(*dst);\n+ neighbors[*dst].insert(*src);\n }\n \n- let mut max_out = 0.0f64;\n- for &d in &out_deg {\n- if d > max_out {\n- max_out = d;\n- }\n- }\n- if max_out <= 1e-12 {\n+ let weight_sum: Vec = out_edges\n+ .iter()\n+ .map(|es| es.iter().map(|(_, w)| *w).sum())\n+ .collect();\n+ let d_max = neighbors.iter().map(|s| s.len()).max().unwrap_or(0);\n+ if d_max == 0 {\n return vec![1.0 / n as f64; n];\n }\n+ let d_max_f = d_max as f64;\n \n let mut scores = vec![1.0 / n as f64; n];\n let mut next = vec![0.0f64; n];\n@@ -167,14 +179,14 @@ fn compute_scores_from_edges(n: usize, edges: impl Iterator` ratio (2:1).\n+ /// Under the old (sum-of-weights) divisor every node had P_ii = 0 and the\n+ /// chain was bipartite; power iteration oscillated and returned the\n+ /// uniform initial distribution after an even number of steps. Using the\n+ /// paper's degree-based d_max gives every node a positive self-loop and\n+ /// the chain converges to the correct stationary distribution.\n+ #[test]\n+ fn star_topology_winner_at_top_via_subset() {\n+ let mut g = mk_group();\n+ g.apply_vote(vote(1, \"zebra\", \"alpha\", 2, 1));\n+ g.apply_vote(vote(2, \"zebra\", \"beta\", 2, 1));\n+\n+ let mut items: Vec<(usize, String)> = g\n+ .idx_to_item\n+ .iter()\n+ .enumerate()\n+ .map(|(i, it)| (i, it.as_str().to_string()))\n+ .collect();\n+ items.sort_by(|a, b| a.1.cmp(&b.1));\n+ let idxs: Vec = items.iter().map(|(i, _)| *i).collect();\n+\n+ let ranked = ranked_items_subset(&g, &idxs, 10000, 1e-8);\n+ for r in &ranked {\n+ eprintln!(\"{}: {}\", r.item.as_str(), r.score);\n+ }\n+ assert_eq!(\n+ ranked[0].item.as_str(),\n+ \"https://slug.social/zebra\",\n+ \"zebra won both votes and should rank #1\"\n+ );\n+ }\n+\n #[test]\n fn group_ranking_cache_dirty_flow() {\n let mut g = mk_group();\ndiff --git a/test/fixtures/ranking/chain.sorter b/test/fixtures/ranking/chain.sorter\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..912a96bdc08f631be27f3c9afc7e05004a2457c0\n--- /dev/null\n+++ b/test/fixtures/ranking/chain.sorter\n@@ -0,0 +1,10 @@\n+#t3\n+\n+~/t3/a { head of chain }\n+~/t3/b { middle }\n+~/t3/c { tail }\n+\n+{ a > b }\n+~/t3/a > ~/t3/b\n+{ b > c }\n+~/t3/b > ~/t3/c\ndiff --git a/test/fixtures/ranking/cycle.sorter b/test/fixtures/ranking/cycle.sorter\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..771731ae176e5d77e00ab89767ff2d7465bb7c6b\n--- /dev/null\n+++ b/test/fixtures/ranking/cycle.sorter\n@@ -0,0 +1,12 @@\n+#t4\n+\n+~/t4/a { node a }\n+~/t4/b { node b }\n+~/t4/c { node c }\n+\n+{ a > b }\n+~/t4/a > ~/t4/b\n+{ b > c }\n+~/t4/b > ~/t4/c\n+{ c > a }\n+~/t4/c > ~/t4/a\ndiff --git a/test/fixtures/ranking/star.sorter b/test/fixtures/ranking/star.sorter\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..135c9f9097d57b73c7ab18fac737ed3598d76fbe\n--- /dev/null\n+++ b/test/fixtures/ranking/star.sorter\n@@ -0,0 +1,11 @@\n+#repro\n+\n+~/repro/zebra { winner — beats both others }\n+~/repro/alpha { loser — alphabetically first }\n+~/repro/beta { loser — alphabetically middle }\n+\n+{ zebra beats alpha }\n+~/repro/zebra > ~/repro/alpha\n+\n+{ zebra beats beta }\n+~/repro/zebra > ~/repro/beta\ndiff --git a/test/fixtures/ranking/star_inverse.sorter b/test/fixtures/ranking/star_inverse.sorter\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..dab842fa924e5124865ee216b9565f6c7471c812\n--- /dev/null\n+++ b/test/fixtures/ranking/star_inverse.sorter\n@@ -0,0 +1,11 @@\n+#t2\n+\n+~/t2/win { source of incoming edges (loses both) }\n+~/t2/loss-a { winner }\n+~/t2/loss-b { winner }\n+\n+{ loss-a beats win }\n+~/t2/loss-a > ~/t2/win\n+\n+{ loss-b beats win }\n+~/t2/loss-b > ~/t2/win\ndiff --git a/test/ranking.clj b/test/ranking.clj\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..e1358783a84a264ee633bd79151ba29750b717cf\n--- /dev/null\n+++ b/test/ranking.clj\n@@ -0,0 +1,74 @@\n+(ns test.ranking\n+ \"Drives sorterc on .sorter fixtures and asserts ranking properties.\n+\n+ Regression coverage for issue #146 — pure forward star at default ratio\n+ (2:1 for `>`) used to produce tied uniform scores because the random walk\n+ on the normalized edge weights was bipartite. Fixed by switching to the\n+ degree-based d_max from Negahban–Oh–Shah rank centrality (§3.1).\"\n+ (:require [clojure.test :refer [deftest is testing]]\n+ [babashka.process :as p]\n+ [cheshire.core :as json]\n+ [clojure.java.io :as io]))\n+\n+(def sorterc-bin\n+ \"Path to the locally-built sorterc binary. Builds on demand if missing.\"\n+ (let [dbg \"target/debug/sorterc\"\n+ release \"target/release/sorterc\"]\n+ (cond\n+ (.exists (io/file release)) release\n+ (.exists (io/file dbg)) dbg\n+ :else\n+ (do (println \"building sorterc…\")\n+ (let [r (p/shell {:out :string :err :string :continue true}\n+ \"cargo build -p sorterc\")]\n+ (when-not (zero? (:exit r))\n+ (throw (ex-info \"cargo build -p sorterc failed\"\n+ {:stderr (:err r)}))))\n+ dbg))))\n+\n+(defn compile-sorter [fixture-path]\n+ (let [{:keys [out exit]} (p/shell {:out :string :err :string :continue true}\n+ sorterc-bin \"compile\" fixture-path)]\n+ (when-not (zero? exit)\n+ (throw (ex-info \"sorterc exit nonzero\" {:fixture fixture-path :out out})))\n+ (json/parse-string out true)))\n+\n+(defn first-component-ranking [result]\n+ (-> result :rankings first :components first :ranking))\n+\n+(defn item-leaf [item]\n+ (last (clojure.string/split item #\"/\")))\n+\n+(deftest chain-ranks-head-first\n+ (let [ranking (first-component-ranking (compile-sorter \"test/fixtures/ranking/chain.sorter\"))\n+ names (mapv (comp item-leaf :item) ranking)]\n+ (is (= [\"a\" \"b\" \"c\"] names)\n+ \"chain a>b>c should rank a, b, c in order\")\n+ (is (apply > (map :score ranking))\n+ \"scores should attenuate strictly down the chain\")))\n+\n+(deftest inverse-star-puts-winners-on-top\n+ (let [ranking (first-component-ranking (compile-sorter \"test/fixtures/ranking/star_inverse.sorter\"))\n+ names (mapv (comp item-leaf :item) ranking)]\n+ (is (= \"win\" (last names))\n+ \"the item that lost to both others should be ranked last\")))\n+\n+(deftest cycle-produces-uniform-scores\n+ (let [ranking (first-component-ranking (compile-sorter \"test/fixtures/ranking/cycle.sorter\"))\n+ scores (map :score ranking)]\n+ (is (every? #(< (Math/abs (- % 1/3)) 1e-3) scores)\n+ \"a perfectly symmetric 3-cycle should give every node ~1/3\")))\n+\n+(deftest star-topology-winner-at-top\n+ ;; Issue #146 regression: source-only star at default `>` ratio (2:1).\n+ ;; Pre-fix produced uniform 1/3 scores; alphabetical fallback put the\n+ ;; unambiguous winner at the bottom. Post-fix the chain is aperiodic and\n+ ;; converges to π_zebra = 1/2, π_alpha = π_beta = 1/4.\n+ (let [ranking (first-component-ranking (compile-sorter \"test/fixtures/ranking/star.sorter\"))\n+ names (mapv (comp item-leaf :item) ranking)\n+ by-name (into {} (map (juxt (comp item-leaf :item) :score) ranking))]\n+ (is (= \"zebra\" (first names))\n+ \"zebra won both votes and should rank #1\")\n+ (is (< (Math/abs (- (by-name \"zebra\") 0.5)) 1e-3))\n+ (is (< (Math/abs (- (by-name \"alpha\") 0.25)) 1e-3))\n+ (is (< (Math/abs (- (by-name \"beta\") 0.25)) 1e-3))))\n","role":"user"}],"model":"~anthropic/claude-sonnet-latest"}