Side A fixes a genuine multi-user correctness bug (shared sentinel delegate silently locking out other human voters) with a minimal, well-scoped change plus a targeted regression test proving the fix. Side B also fixes a real bug (inconsistent canonical root path causing lookup mismatches) but does so with a much larger, more invasive refactor across path types touching many call sites, which carries more risk despite good test coverage; both are solid but A's fix is more surgical and addresses a more severe functional defect (broken voting) with lower blast radius.
constitution · epochs · watch · epoch 3
c_25ece8f92dc8 (tommy-mor) vs c_b0194743d156 (tommy-mor)
download prompt · raw event · cmp_85c9ae3c9f0d26
council reasoning
A removes false shared browser agent sentinels and models PendingSession.agent as Option, fixing multi-user votes blocked by AgentBound and aligning browser posts with real no-delegate humans; B mainly unifies tilde-root URL spellings and lookup normalization. A’s identity/delegate design change is the sharper, more product-critical correctness fix, while B is important path hygiene with more surface/tests but narrower lasting model impact.
Side B consolidates canonical path handling by introducing shared helpers (`tilde_http_path_to_canonical`, normalized ontology-root storage) and updates ranking, breadcrumb, and garden code to use normalized keys, fixing root-path inconsistencies and adding broad regression coverage. Side A correctly removes browser sentinel delegates by making pending-session agents optional and prevents shared delegate binding for browser users, but its scope is narrower than B's cross-cutting path normalization and routing improvements.
sides
A — c_25ece8f92dc8 (tommy-mor)
message
[b7626603] 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 <cursoragent@cursor.com>
diff preview
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 70435b412188a151c5e89e842a5de57f7480ddf2..a4a22724fc9e27879951f520a9241d5832982a70 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -1645,8 +1645,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 bfddcce4dc1a77571d297648dd840e6c5bc194f8..01c02f50c19bcd62c7f1717f9b927f203c3164d0 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -29,12 +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";
-
/// HttpOnly cookie storing the same `slug_*` bearer string the CLI uses.
pub const SLUG_SESSION_COOKIE: &str = "slug_session";
@@ -282,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,
@@ -549,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();
@@ -560,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
@@ -637,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,
@@ -698,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/ui_html.rs b/server/src/api/ui_html.rs
index 578e5844e86f7c2666c8e37ef9fa90c01d20d134..57a0139928b09034122398f8f3362cbdbcc5862e 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -276,7 +276,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/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<String>,
pub created_ts: i64,
pub provider: Option<String>,
pub provider_id: Option<String>,
diff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs
index d242abff5769cc704b3c8070d7456e5788c31a45..22b16c87d5ecdb5272a017af70b5353d8c39c6e8 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_user_token(&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 4a620eaa875e7a1145f2a4e82cc4ff2be21d5345..a5306fa2c4c9fda47e43fc0de42407b66e23eced 100644
--- a/server/tests/support/mod.rs
+++ b/server/tests/support/mod.rs
@@ -70,20 +70,25 @@ pub async fn rpc_batch(
response.json().await.unwrap()
}
-pub async fn seed_test_token(state: &AppState) {
+/// Seed a user + bearer into reducer state (not appended to the event log).
+/// Returns the `slug_<token_id>_<secret>` bearer string.
+pub async fn seed_user_token(
+ state: &AppState,
+ username: &str,
+ token_id: &str,
+ secret: &str,
+) -> String {
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(),
@@ -92,6 +97,11 @@ pub async fn seed_test_token(state: &AppState) {
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_user_token(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 211493935d19582607f5c87fb492faf47bdc6f53..0c4295b15908ea7a2303dac4c42015ded51e4d73 100644
--- a/types/src/lib.rs
+++ b/types/src/lib.rs
@@ -535,7 +535,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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
B — c_b0194743d156 (tommy-mor)
message
[c59951f5] fixed canonical item paths business
diff preview
diff --git a/server/src/html/breadcrumb_path.rs b/server/src/html/breadcrumb_path.rs
index 12ad2c84faf2fbb693015d4552e45b5c54d587b9..c8a3937923161a6ff248bd77e87eff0dc6fe9ab0 100644
--- a/server/src/html/breadcrumb_path.rs
+++ b/server/src/html/breadcrumb_path.rs
@@ -1,4 +1,4 @@
-use crate::path_types::CanonicalItemUrl;
+use crate::path_types::{tilde_http_path_to_canonical, CanonicalItemUrl};
/// Semantic view of an ontology path for rendering and routing decisions.
pub(super) struct OntologyPath {
@@ -11,16 +11,7 @@ impl OntologyPath {
/// Path is the `*path` segment from `/~/*path` (e.g. `topic/a`). Always treat it as under `~/`
/// so it canonicalizes to `https://slug.social/~/…`, not the non-tilde site path.
pub(super) fn from_input(path: &str) -> Self {
- let p = path.trim_start_matches('/');
- let raw = if p.starts_with("http://") || p.starts_with("https://") {
- p.to_string()
- } else if p.is_empty() {
- "~/".to_string()
- } else {
- format!("~/{}", p)
- };
- let canonical = CanonicalItemUrl::parse(&raw)
- .unwrap_or_else(|| CanonicalItemUrl::parse("~/").unwrap());
+ let canonical = tilde_http_path_to_canonical(path);
Self::from_canonical(canonical)
}
@@ -37,7 +28,7 @@ impl OntologyPath {
}
pub(super) fn root() -> Self {
- Self::from_canonical(CanonicalItemUrl::parse("~/").unwrap())
+ Self::from_canonical(CanonicalItemUrl::ontology_root())
}
pub(super) fn is_root(&self) -> bool {
diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs
index d58d9b46f575eb6b7e4971086b07dda75ca2f645..319feb15a6b68d2b5df98b4289fedbc9bdd048d3 100644
--- a/server/src/html/garden.rs
+++ b/server/src/html/garden.rs
@@ -459,6 +459,8 @@ struct ItemPageViewModel {
item: String,
body: Option<String>,
sibling_rank: Option<SiblingRank>,
+ /// False at the tilde ontology root (`~/`): sibling-rank footnote does not apply.
+ item_has_parent: bool,
child_rankings: ChildrenRankings,
rank_history: Vec<RankHistoryEntryView>,
/// Forum threads that mention or vote on this item.
@@ -470,11 +472,12 @@ fn build_sibling_rank(
scope: &ScopeId,
item: &CanonicalItemUrl,
) -> Option<SiblingRank> {
+ let item = item.clone().normalized_storage();
let content = reduced
.content_for_scope(scope)
.unwrap_or_else(|| reduced.public());
let group = &content.ranking_group;
- let parent = item.parent()?;
+ let parent = item.parent()?.normalized_storage();
let siblings: Vec<CanonicalItemUrl> = content
.item_children
.get(&parent)
@@ -492,7 +495,7 @@ fn build_sibling_rank(
if scoped_idxs.is_empty() {
return None;
}
- let current_idx = *group.item_to_idx.get(item)?;
+ let current_idx = *group.item_to_idx.get(&item)?;
if !scoped_idxs.contains(¤t_idx) {
return None;
}
@@ -520,7 +523,7 @@ fn build_sibling_rank(
.filter_map(|li| local_to_global.get(*li).copied())
.collect();
let ranked = ranked_items_subset(group, &comp_global, 10000, 1e-8);
- let position = ranked.iter().position(|r| &r.item == item)? + 1;
+ let position = ranked.iter().position(|r| r.item == item)? + 1;
Some(SiblingRank {
position,
component_size: ranked.len(),
@@ -597,7 +600,9 @@ fn build_item_page_view_model(
.content_for_scope(scope)
.unwrap_or_else(|| reduced.public());
let item_key = CanonicalItemUrl::parse(item)
- .unwrap_or_else(|| CanonicalItemUrl::parse("~/").unwrap());
+ .unwrap_or_else(|| CanonicalItemUrl::parse("~/").unwrap())
+ .normalized_storage();
+ let item_has_parent = item_key.parent().is_some();
let child_rankings = build_children_rankings(content, &item_key);
let rank_history = build_rank_history(reduced, scope, item_key.as_str());
@@ -617,6 +622,7 @@ fn build_item_page_view_model(
.cloned()
.or_else(|| reduced.public().item_bodies.get(&item_key).cloned()),
sibling_rank: build_sibling_rank(reduced, scope, &item_key),
+ item_has_parent,
child_rankings,
rank_history,
threads,
@@ -652,7 +658,7 @@ async fn render_scope_view(
(format!("#{} of {}", rank.position, rank.component_size))
}
span class="muted" { (format!("({} siblings)", rank.sibling_total)) }
- } @else {
+ } @else if model.item_has_parent {
span class="muted" { "unranked among siblings" }
}
}
@@ -815,6 +821,18 @@ mod tests {
}));
}
+ fn apply_ingest_room(state: &mut ReducerState, ts: i64, room_id: &str, raw: &str) {
+ state.apply_event(Event::Ingest(Ingest {
+ ts,
+ id: format!("ing-{ts}"),
+ raw: raw.to_string(),
+ principal: "testuser".to_string(),
+ delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()),
+ room_id: room_id.to_string(),
+ thread_tag: String::new(),
+ }));
+ }
+
#[test]
fn item_page_model_includes_body_and_unranked_without_votes() {
let mut reduced = ReducerState::default();
@@ -885,4 +903,85 @@ mod tests {
|| model.child_rankings.unranked_items.contains(&CanonicalItemUrl("https://slug.social/~/topic/kid2".to_string()))
);
}
+
+ #[test]
+ fn item_page_room_scope_root_lists_top_level_children() {
+ let mut reduced = ReducerState::default();
+ apply_ingest_room(
+ &mut reduced,
+ 1,
+ "9ab12cd/my-room",
+ "@00000000-0000-0000-0000-000000000000:test:local/test\n~/t1 {a}\n~/t2 {b}\n",
+ );
+ use crate::path_types::CanonicalItemUrl;
+ let root = CanonicalItemUrl::ontology_root();
+ let model = build_item_page_view_model(
+ &reduced,
+ &ScopeId::Room("9ab12cd/my-room".to_string()),
+ root.as_str(),
+ );
+ assert!(!model.item_has_parent);
+ assert_eq!(model.child_rankings.unranked_items.len(), 2);
+ let set: std::collections::HashSet<&str> = model
+ .child_rankings
+ .unranked_items
+ .iter()
+ .map(|u| u.as_str())
+ .collect();
+ assert!(set.contains("https://slug.social/~/t1"));
+ assert!(set.contains("https://slug.social/~/t2"));
+ }
+
+ /// Top-level `~/a` vs `~/b` votes form one ranked component under the ontology root.
+ #[test]
+ fn item_page_room_scope_root_shows_ranked_child_group() {
+ let mut reduced = ReducerState::default();
+ apply_ingest_room(
+ &mut reduced,
+ 1,
+ "9ab12cd/my-room",
+ "@00000000-0000-0000-0000-000000000000:test:local/test\n\
+ ~/a {a}\n~/b {b}\n~/a 2:1 ~/b {because}\n",
+ );
+ use crate::path_types::CanonicalItemUrl;
+ let root = CanonicalItemUrl::ontology_root();
+ let model = build_item_page_view_model(
+ &reduced,
+ &ScopeId::Room("9ab12cd/my-room".to_string()),
+ root.as_str(),
+ );
+ assert_eq!(model.child_rankings.component_rankings.len(), 1);
+ assert_eq!(model.child_rankings.component_rankings[0].pairs, 1);
+ let names: Vec<&str> = model.child_rankings.component_rankings[0]
+ .ranked
+ .iter()
+ .map(|r| r.item.as_str())
+ .collect();
+ assert_eq!(
+ names,
+ vec!["https://slug.social/~/a", "https://slug.social/~/b"]
+ );
+ assert!(model.child_rankings.unranked_items.is_empty());
+ }
+
+ /// Legacy `https://slug.social/~/` spelling still resolves children under the real root key.
+ #[test]
+ fn item_page_model_normalizes_legacy_tilde_root_storage_url() {
+ let mut reduced = ReducerState::default();
+ apply_ingest(
+ &mut reduced,
+ 1,
+ "@00000000-0000-0000-0000-000000000000:test:local/test\n~/x {x}\n",
+ );
+ let model = build_item_page_view_model(
+ &reduced,
+ &ScopeId::Public,
+ "https://slug.social/~/",
+ );
+ assert_eq!(model.child_rankings.unranked_items.len(), 1);
+ assert_eq!(
+ model.child_rankings.unranked_items[0].as_str(),
+ "https://slug.social/~/x"
+ );
+ }
}
diff --git a/server/src/path_types.rs b/server/src/path_types.rs
index 4c8075bbd488f1b9a5eda9e03ced83f6238c6d5e..361a9d446c9043cbdea5f060db2e8633c2fd9bf9 100644
--- a/server/src/path_types.rs
+++ b/server/src/path_types.rs
@@ -1,3 +1,5 @@
//! Re-exports — implementations live in `slug-types` (`paths` module).
-pub use slug_types::paths::{CanonicalItemUrl, RelativePath, TildePath};
+pub use slug_types::paths::{
+ tilde_http_path_to_canonical, CanonicalItemUrl, RelativePath, TildeHttpPathTail, TildePath,
+};
diff --git a/server/src/scope_rank.rs b/server/src/scope_rank.rs
index dc640848232b7e79142bfeeea3003c9023f84854..d656b2f0a0a3623ca6b234b746beaaca8ae6017d 100644
--- a/server/src/scope_rank.rs
+++ b/server/src/scope_rank.rs
@@ -149,9 +149,10 @@ pub fn build_rankings_for_item_set(content: &ContentState, items_in_scope: &[Can
/// Build connected-component rankings for direct children of parent_scope.
/// Matches the HTML garden view: multiple components, isolates, no-vote items.
pub fn build_children_rankings(content: &ContentState, parent: &CanonicalItemUrl) -> ChildrenRankings {
+ let parent = parent.clone().normalized_storage();
let items: Vec<CanonicalItemUrl> = content
.item_children
- .get(parent)
+ .get(&parent)
.map(|s| s.iter().cloned().collect())
.unwrap_or_default();
build_rankings_for_item_set(content, &items)
diff --git a/server/tests/integration.rs b/server/tests/integration.rs
index f9c218378f6a173e56ae1cec797b3c492986eac0..d4c5bfe9c6f1c71dc61878bd8c5e729b1b7c69ef 100644
--- a/server/tests/integration.rs
+++ b/server/tests/integration.rs
@@ -704,6 +704,62 @@ async fn test_private_room_post_links_use_private_garden_routes() {
assert!(garden_body.contains(&format!("/r/{room_short}/{room_slug}/t/garden-thread")));
}
+#[tokio::test]
+async fn test_private_room_garden_root_lists_top_level_tilde_children() {
+ let (addr, _tmp, _log, _handle) = create_test_server().await;
+ let client = reqwest::Client::builder()
+ .redirect(reqwest::redirect::Policy::none())
+ .build()
+ .unwrap();
+ let bearer = test_bearer();
+
+ let create = rpc_batch(
+ &client,
+ addr,
+ Some(&bearer),
+ serde_json::json!([{
+ "RoomCreate": { "slug": "garden-root-list" }
+ }]),
+ )
+ .await;
+ let room_id = create["results"][0]["result"]["RoomCreated"]["room_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let (room_short, room_slug) = room_id.split_once('/').unwrap();
+
+ let rpc = ui_post_ingest_rpc(
+ &room_id,
+ "ing",
+ "~/test1 {wow}\n~/test2 {wow2}\n~/test1 2:1 ~/test2 {because}\n",
+ );
+ let post = client
+ .post(format!("http://{addr}/ui"))
+ .header("Authorization", format!("Bearer {bearer}"))
+ .form(&[("__rpc__", rpc.as_str())])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(post.status(), reqwest::StatusCode::OK);
+
+ let root_page = client
+ .get(format!("http://{addr}/r/{room_short}/{room_slug}/~"))
+ .header("Authorization", format!("Bearer {bearer}"))
+ .send()
+ .await
+ .unwrap();
+ assert!(root_page.status().is_success());
+ let body = root_page.text().await.unwrap();
+
… preview truncated; 14,119 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.