B fixes a real security/design flaw (feed cutoff keyed by unauthenticated actor string) with an auth-bound delegate model, adds thorough integration tests covering rejection paths, and updates CLI/docs consistently. A is a substantial refactor adding children-fetch support and SSE-as-JS-eval, which is useful but more speculative/UI-plumbing with less clear-cut correctness payoff and more churn (rewriting SSE protocol, JS handling) than B's targeted security fix.
constitution · epochs · watch · epoch 3
c_f7f1b6183703 (tommy-mor) vs c_8af10ca56dff (tommy-mor)
download prompt · raw event · cmp_baefc4de99f364
council reasoning
Commit A delivers substantial product capability: separate self/children Reddit fetch kinds, children listing import with tree linking, OAuth token vs API base split, SSE unified to eval JS morphs, and ranking_panel by connected components—backed by fixtures and tests. Commit B is a focused, correct feed redesign (delegate scoping, auth/binding checks, principal-wide catch-up) with solid tests, but it is a narrower API/CLI behavioral fix versus A’s multi-layer feature and protocol work.
Side A delivers a substantial functional expansion by introducing separate fetch modes for entities versus children, wiring the new fetch kind through the UI, SSE pipeline, Reddit worker, state management, and adding child-import parsing/linking plus updated ranking behavior for imported children. Side B makes an important API/security improvement by scoping feed retrieval to authenticated delegates and adding authorization checks and tests, but it is narrower in scope than A's end-to-end feature and architectural changes.
sides
A — c_f7f1b6183703 (tommy-mor)
message
[902a4c5c] refactor
diff preview
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 7af6527d03c483f33f3469ce6766c01a554c5fe3..d1defd28242fd2ca3b886adc91a7070bef75e653 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -56,12 +56,9 @@ pub async fn post_ui_html(
return ui_js_warn(&e).into_response();
}
let tree = state.tree.read().await;
- let empty = crate::reducer::GroupState::new();
- let group = tree
- .get(&parent)
- .map(|n| &n.local_ranking)
- .unwrap_or(&empty);
- let panel = ranking_panel(&parent, group);
+ let empty = crate::reducer::NodeState::default();
+ let node = tree.get(&parent).unwrap_or(&empty);
+ let panel = ranking_panel(&parent, node);
JsBuilder::new()
.morph_selector("#ranking-panel", panel)
.into_response()
@@ -88,9 +85,9 @@ pub async fn post_ui_html(
.into_response()
}
},
- HtmlUiAction::FetchEntity { item } => {
+ HtmlUiAction::FetchEntity { item, kind } => {
let id = parse_item_param(&item);
- fetch::fetch_entity_stream(state, id).into_response()
+ fetch::fetch_entity_stream(state, id, kind).into_response()
}
}
}
diff --git a/server/src/fetch/html.rs b/server/src/fetch/html.rs
index 63634508496e224c38b9ec0308b7a6086462f925..9b6c2d0964640a157bca2a6a64cb0820238d8e4f 100644
--- a/server/src/fetch/html.rs
+++ b/server/src/fetch/html.rs
@@ -5,7 +5,7 @@ use maud::{html, Markup};
use crate::{
form_template::template_json_compact,
path_types::ItemId,
- reddit::is_fetchable,
+ reddit::{is_children_fetchable, is_fetchable},
reducer::NodeState,
ui_action::UI_RPC_FIELD,
};
@@ -26,25 +26,16 @@ fn entity_panel(node: &NodeState) -> Markup {
}
}
-/// Reddit/API import — `POST /ui` with `fetch_entity` returns an SSE stream.
-pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {
- if !is_fetchable(item) {
- return html! {};
- }
- let label = if fetching {
- "Fetching…"
- } else if has_data {
- "Fetch more"
- } else {
- "Fetch from Reddit"
- };
+/// One `fetch_entity` form/button targeting `kind` ("self" or "children").
+fn fetch_button(item: &ItemId, kind: &str, label: &str, fetching: bool) -> Markup {
let rpc = template_json_compact(&serde_json::json!({
"action": "fetch_entity",
"item": item.as_str(),
+ "kind": kind,
}))
.expect("fetch_entity rpc template");
html! {
- form method="post" action="/ui" id="fetch-entity-form" class="fetch-entity-form" {
+ form method="post" action="/ui" class="fetch-entity-form" {
input type="hidden" name=(UI_RPC_FIELD) value=(rpc);
@if fetching {
button type="submit" class="btn-secondary" disabled { (label) }
@@ -55,6 +46,33 @@ pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Mark
}
}
+/// Reddit/API import controls — `POST /ui` with `fetch_entity` returns an SSE
+/// stream whose events are JS snippets to `eval`.
+pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {
+ let self_ok = is_fetchable(item);
+ let children_ok = is_children_fetchable(item);
+ if !self_ok && !children_ok {
+ return html! {};
+ }
+ let self_label = if fetching {
+ "Fetching…"
+ } else if has_data {
+ "Refresh this"
+ } else {
+ "Fetch from Reddit"
+ };
+ html! {
+ div id="fetch-controls" class="fetch-controls" {
+ @if self_ok {
+ (fetch_button(item, "self", self_label, fetching))
+ }
+ @if children_ok {
+ (fetch_button(item, "children", if fetching { "Fetching…" } else { "Fetch posts" }, fetching))
+ }
+ }
+ }
+}
+
/// Entity card + fetch control (target `#entity-section` for Idiomorph / SSE).
pub fn entity_section(item: &ItemId, node: &NodeState, fetching: bool) -> Markup {
let has_data = node.data.is_some();
diff --git a/server/src/fetch/mod.rs b/server/src/fetch/mod.rs
index 2290f9d3a0f1cbf1806c6339f82a4515c11cc3d3..0177bb161cea1b72a100b52efdfc5e710271c9eb 100644
--- a/server/src/fetch/mod.rs
+++ b/server/src/fetch/mod.rs
@@ -1,4 +1,8 @@
//! Entity import over `POST /ui` as SSE (Reddit worker in [`crate::reddit`]).
+//!
+//! Each SSE event's `data` is a JS snippet that the browser `eval`s — the same
+//! Idiomorph-morph snippets the non-streaming `/ui` responses use. There is no
+//! bespoke JSON envelope; the client just evals whatever each event carries.
pub mod html;
@@ -8,14 +12,15 @@ use std::time::Duration;
use async_stream::stream;
use axum::response::sse::{Event, KeepAlive, Sse};
use futures_util::Stream;
-use serde::Serialize;
use tokio::sync::oneshot;
use crate::{
+ html::{ranking_panel, JsBuilder},
path_types::ItemId,
- reddit::FetchJobResult,
+ reddit::{FetchJobResult, FetchKind},
reducer::NodeState,
state::AppState,
+ ui_action::FetchTarget,
};
pub fn now_ms() -> i64 {
@@ -25,55 +30,62 @@ pub fn now_ms() -> i64 {
t.as_millis() as i64
}
-#[derive(Serialize)]
-struct SseMorphPayload {
- selector: &'static str,
- html: String,
+fn js_event(js: String) -> Event {
+ Event::default().data(js)
}
-fn morph_complete_event(html: maud::Markup) -> Event {
- let payload = SseMorphPayload {
- selector: "#entity-section",
- html: html.into_string(),
- };
- let data = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".into());
- Event::default().event("complete").data(data)
+/// JS that surfaces a transient message in the page's `#errors` region.
+fn error_js(message: &str) -> String {
+ JsBuilder::new()
+ .morph_selector(
+ "#errors",
+ maud::html! { div id="errors" { p class="muted" { (message) } } },
+ )
+ .build()
}
-/// Stream `fetching` → `complete` / `error` for [`crate::ui_action::HtmlUiAction::FetchEntity`].
+/// Stream Idiomorph-morph JS snippets for [`crate::ui_action::HtmlUiAction::FetchEntity`].
pub fn fetch_entity_stream(
state: AppState,
id: ItemId,
+ target: FetchTarget,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
- tracing::debug!(item = %id, "fetch entity stream opened");
+ let kind = match target {
+ FetchTarget::SelfEntity => FetchKind::SelfEntity,
+ FetchTarget::Children => FetchKind::Children,
+ };
+ tracing::debug!(item = %id, ?kind, "fetch entity stream opened");
let stream = stream! {
if id.is_root() {
- yield Ok(Event::default().event("error").data("{\"message\":\"nothing to fetch for the root\"}"));
+ yield Ok(js_event(error_js("Nothing to fetch for the root.")));
return;
}
- if !crate::reddit::is_fetchable(&id) {
- tracing::debug!(item = %id, "fetch stream: not fetchable");
- yield Ok(Event::default().event("error").data("{\"message\":\"this page cannot be fetched from Reddit\"}"));
+ let fetchable = match kind {
+ FetchKind::SelfEntity => crate::reddit::is_fetchable(&id),
+ FetchKind::Children => crate::reddit::is_children_fetchable(&id),
+ };
+ if !fetchable {
+ tracing::debug!(item = %id, ?kind, "fetch stream: not fetchable");
+ yield Ok(js_event(error_js("This page cannot be fetched from Reddit.")));
return;
}
- let fetching_html = {
+ // Optimistic "Fetching…" morph of the entity section.
+ let fetching_js = {
let tree = state.tree.read().await;
let empty = NodeState::default();
let node = tree.get(&id).unwrap_or(&empty);
- html::entity_section(&id, node, true).into_string()
+ JsBuilder::new()
+ .morph_selector("#entity-section", html::entity_section(&id, node, true))
+ .build()
};
- let fetching_payload = serde_json::json!({
- "selector": "#entity-section",
- "html": fetching_html,
- });
- yield Ok(Event::default().event("fetching").data(fetching_payload.to_string()));
+ yield Ok(js_event(fetching_js));
let (tx, rx) = oneshot::channel();
- state.reddit.request_fetch(id.clone(), true, Some(tx));
- tracing::debug!(item = %id, "fetch stream: queued reddit job");
+ state.queue_entity_fetch(id.clone(), kind, Some(tx));
+ tracing::debug!(item = %id, ?kind, "fetch stream: queued reddit job");
let result = match rx.await {
Ok(r) => r,
@@ -82,31 +94,42 @@ pub fn fetch_entity_stream(
FetchJobResult::Failed("reddit worker stopped".into())
}
};
-
tracing::debug!(item = %id, ?result, "fetch stream: job finished");
match result {
- FetchJobResult::Imported | FetchJobResult::NotFound => {
+ FetchJobResult::Imported(_)
+ | FetchJobResult::NotFound
+ | FetchJobResult::SkippedCached
+ | FetchJobResult::SkippedDuplicate => {
let tree = state.tree.read().await;
let empty = NodeState::default();
let node = tree.get(&id).unwrap_or(&empty);
- yield Ok(morph_complete_event(html::entity_section(&id, node, false)));
+ let mut b = JsBuilder::new()
+ .morph_selector("#entity-section", html::entity_section(&id, node, false));
+ if kind == FetchKind::Children {
+ b = b.morph_selector("#ranking-panel", ranking_panel(&id, node));
+ }
+ yield Ok(js_event(b.build()));
}
- FetchJobResult::SkippedCached | FetchJobResult::SkippedDuplicate => {
+ FetchJobResult::RateLimited { reset_secs } => {
let tree = state.tree.read().await;
let empty = NodeState::default();
let node = tree.get(&id).unwrap_or(&empty);
- yield Ok(morph_complete_event(html::entity_section(&id, node, false)));
- }
- FetchJobResult::RateLimited { reset_secs } => {
- yield Ok(Event::default().event("error").data(
- serde_json::json!({"message": format!("Reddit rate limit — retry in {reset_secs}s")}).to_string(),
- ));
+ let js = JsBuilder::new()
+ .morph_selector("#entity-section", html::entity_section(&id, node, false))
+ .raw(&error_js(&format!("Reddit rate limit — retry in {reset_secs}s.")))
+ .build();
+ yield Ok(js_event(js));
}
FetchJobResult::Failed(msg) => {
- yield Ok(Event::default().event("error").data(
- serde_json::json!({"message": msg}).to_string(),
- ));
+ let tree = state.tree.read().await;
+ let empty = NodeState::default();
+ let node = tree.get(&id).unwrap_or(&empty);
+ let js = JsBuilder::new()
+ .morph_selector("#entity-section", html::entity_section(&id, node, false))
+ .raw(&error_js(&format!("Fetch failed: {msg}")))
+ .build();
+ yield Ok(js_event(js));
}
}
};
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 9314a7556306ddab969b896dbf4126b542a46722..27ce9118c73ec5643e04363ab1a36cf5da6101bf 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -6,12 +6,16 @@ use axum::{
};
use maud::{html, Markup, DOCTYPE};
+use std::collections::HashSet;
… preview truncated; 29,086 characters omittedB — c_8af10ca56dff (tommy-mor)
message
[951dac0e] Feed RPC/CLI: scope by agent delegate instead of principal (#108) * feat(feed): key GetFeed by agent delegate, not principal - RpcCommand::GetFeed and FeedResponse use delegate (uuid:rig:model) - Accept legacy JSON field name "actor" via serde alias - Default cutoff finds last ingest with matching delegate - CLI feed subcommand takes DELEGATE; docs updated - Integration: ACL test uses post delegate; add delegate scoping test Co-authored-by: tommy <thmorriss@gmail.com> * fix(feed): require auth and bound delegate; drop actor JSON alias - GetFeed rejects missing Authorization and delegates not bound to bearer principal - CLI feed sends saved bearer token like other authenticated RPCs - Integration: assert cross-user delegate rejected; no-auth rejected Co-authored-by: tommy <thmorriss@gmail.com> * fix(feed): principal-wide catch-up includes delegate posts; optional CLI delegate - GetFeed without delegate: since = last ingest by bearer principal (any delegate) - CLI: optional positional delegate, defaults from SLUG_DELEGATE like forum post - Integration test for delegate-only posting + argless feed bounded since Co-authored-by: tommy <thmorriss@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
diff preview
diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter
index 9828cba4d9c17b7cce3de597d8724609b2b2adbe..59782bd1140dc9f31d44965bace833ea562e113e 100644
--- a/cli/GUIDE.sorter
+++ b/cli/GUIDE.sorter
@@ -171,8 +171,9 @@ EOF
# See current ranking
npx slugsocial public garden children languages --json
-# After a context reset: catch up (feed is keyed by principal username, stored form)
-npx slugsocial feed yourusername
+# Catch up: no delegate arg → since your last post as this user (any delegate). Or pass the chat's uuid:rig:model (or SLUG_DELEGATE).
+npx slugsocial feed
+npx slugsocial feed 550e8400-e29b-41d4-a716-446655440000:cursor:anthropic/claude-sonnet-4.5
}
~/commands {
@@ -207,8 +208,9 @@ identity poll <session> Complete OAuth; saves bearer t
whoami [--json] Resolve saved bearer token to principal
-feed <username> Activity since your last post (stored username, no @)
-feed <username> --since 2026-01-01 Override lower bound (Unix ms or YYYY-MM-DD)
+feed Activity since you last posted (principal-wide)
+feed <uuid:rig:model> Activity since this delegate last posted (or SLUG_DELEGATE)
+feed … --since 2026-01-01 Override lower bound (Unix ms or YYYY-MM-DD)
search <query> Search items, threads, posts (public index)
diff --git a/cli/src/main.rs b/cli/src/main.rs
index b008cf377bb360aaae666d2dfd4b5e13dedb204a..122ee42189b2a6e47137be594556f8dddb7989a9 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -146,20 +146,27 @@ enum Command {
sub: RoomCmd,
},
- /// Show all activity since you last posted (global feed)
+ /// Show activity since your last post, or since this delegate last posted (global feed)
///
- /// Returns all ingests since this actor's last ingest, newest first.
- /// Useful for agents to catch up on activity after a context reset.
+ /// With **no delegate argument**: uses the timestamp of your **last ingest as this principal** (any
+ /// `uuid:rig:model` or none), so an old chat that only has your token still gets a sane catch-up
+ /// after you have been posting with `--delegate`.
+ ///
+ /// With a **delegate** (or `SLUG_DELEGATE`): cutoff is that delegate's last ingest only — use the same
+ /// string as in that chat for per-session continuity.
+ ///
+ /// Requires your saved bearer token; an explicit delegate must be bound to your account.
///
/// Examples:
- /// npx slugsocial feed tommy
- /// npx slugsocial feed tommy --since 2026-01-01
+ /// npx slugsocial feed
+ /// npx slugsocial feed 550e8400-e29b-41d4-a716-446655440000:cursor:anthropic/claude-sonnet-4.5
+ /// npx slugsocial feed --since 2026-01-01
Feed {
- /// Principal username (stored form)
- #[arg(value_name = "ACTOR")]
- actor: String,
+ /// Agent delegate (`uuid:rig:provider/model`); omit for principal-wide catch-up. Same env as forum post.
+ #[arg(value_name = "DELEGATE", env = "SLUG_DELEGATE")]
+ delegate: Option<String>,
/// Override the lower bound. Accepts Unix ms or YYYY-MM-DD.
- /// Defaults to the actor's last ingest timestamp on the server.
+ /// Defaults to the delegate's last ingest timestamp on the server.
#[arg(long, value_name = "DATE_OR_MS")]
since: Option<String>,
/// Max items to return (default: 10)
@@ -1434,14 +1441,25 @@ async fn main() -> Result<()> {
}
}
- Command::Feed { actor, since, limit, json } => {
+ Command::Feed { delegate, since, limit, json } => {
let client = http_client()?;
+ let bearer = effective_bearer().ok_or_else(|| {
+ anyhow!(
+ "no bearer token: run `slugsocial identity start --rig <rig> --model <model>` \
+ then `slugsocial identity poll <session>`, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token"
+ )
+ })?;
+ let delegate = delegate
+ .as_deref()
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string());
let batch = send_rpc(
&client,
base,
- None,
+ Some(&bearer),
vec![RpcCommand::GetFeed {
- actor,
+ delegate,
since: match since {
Some(s) => Some(parse_ts(&s)?),
None => None,
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index 422b520f527f64571f86f0ee3cb57edcbc21c0c3..936f55bac6abb69e672f6594385934688dc215e3 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -1785,30 +1785,105 @@ pub async fn handle_rpc_batch(
}
},
RpcCommand::GetFeed {
- actor,
+ delegate,
since,
limit,
} => {
const DEFAULT_LIMIT: usize = 50;
const MAX_LIMIT: usize = 200;
- match parse_username(&actor) {
- Err(msg) => line_err("invalid actor", Some(msg)),
- Ok(actor_stored) => {
- let reduced = state.reduced.read().await;
- match principal_from_optional_bearer(&headers, &reduced) {
- Err((e, h)) => line_err(e, h),
- Ok(viewer) => {
+ let reduced = state.reduced.read().await;
+ match verify_bearer_principal(&headers, &reduced) {
+ Err((_, m)) => line_err(m, None),
+ Ok(viewer) => {
+ let delegate_parsed: Option<Result<String, String>> = delegate
+ .as_deref()
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .map(parse_agent);
+ match delegate_parsed {
+ Some(Err(msg)) => {
+ drop(reduced);
+ line_err("invalid delegate", Some(msg))
+ }
+ Some(Ok(delegate_stored)) => {
+ let line = if reduced.agent_bindings.get(&delegate_stored) != Some(&viewer) {
+ line_err(
+ "not your delegate",
+ Some("this delegate is not bound to your signed-in account".into()),
+ )
+ } else {
+ let since_default = reduced
+ .ingests_ordered
+ .iter()
+ .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).map_or(false, |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()))
+ })
+ })
+ .collect();
+ let total = matching.len();
+ let posts: Vec<FeedPost> = matching.into_iter()
+ .take(limit)
+ .filter_map(|id| reduced.ingests_by_id.get(id))
+ .filter(|ing| !reduced.redacted_posts.contains(&ing.id))
+ .map(|ing| {
+ let scope = scope_from_room_wire(&ing.room_id);
+ let thread_post_index = reduced
+ .ingests_by_scope_thread
+ .get(&(scope, ing.thread_tag.clone()))
+ .and_then(|q| {
+ q.iter().rev().position(|pid| pid == &ing.id).map(|i| i + 1)
+ });
+ 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),
+ since,
+ posts,
+ total,
+ }))
+ };
+ drop(reduced);
+ line
+ }
+ 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
.ingests_ordered
.iter()
.rev()
.filter_map(|id| reduced.ingests_by_id.get(id))
.find(|ing| {
- if ing.principal != actor_stored {
+ if ing.principal != viewer {
return false;
}
let scope = scope_from_room_wire(&ing.room_id);
- can_view_scope(&reduced, &scope, viewer.as_deref())
+ can_view_scope(&reduced, &scope, Some(viewer.as_st
… preview truncated; 9,880 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.