Side A is a coherent architectural fix: it replaces ad-hoc/vestigial vote fields with a proper pseudonym→actor-UUID identity model and uuid-keyed vote dedup, rewrites the storage schema and reducer accordingly, and adds targeted tests validating dedup/rollback semantics — a real correctness improvement to the ranking core. Side B is a useful but more diffuse grab-bag of UI/UX fixes (GitHub resolver pagination and readable bodies, vote-compare next-pair suggestion, login redirect handling) that improves surface features without touching the system's core data-integrity model.
constitution · epochs · watch · epoch 3
c_3e6a3370542e (tommy-mor) vs c_99071b4accf5 (tommy-mor)
download prompt · raw event · cmp_65819d8d8f0cf2
council reasoning
A redesigns core vote persistence: uuid-keyed dedup with rollback/replace, rankings derived from uuid_votes on load, and a seeded pseudonyms map—replacing blind edge merges and vestigial identity fields. B delivers real product value (safer auth next redirects, richer GitHub resolver + paging/bodies, vote-compare next-pair/nav UX), but it is incremental feature/fix work rather than a lasting storage/identity model change.
Side A introduces a fundamental change to vote storage and replay by replacing incremental edge merges with UUID-keyed deduplicated votes, adding pseudonym-to-UUID resolution, rebuilding rankings from stored votes, updating schemas, and changing reducer logic to replace prior votes from the same actor. Side B contains valuable fixes and UX improvements (GitHub resolver pagination/content, vote compare navigation, safe login redirects, and browser flow fixes), but these are incremental features and bug fixes rather than a lasting redesign of core persistence and ranking behavior.
sides
A — c_3e6a3370542e (tommy-mor)
message
[e52a5895] Replace vestigial vote identity with pseudonym-based uuid dedup. Store votes in uuid_votes (not blind edge merges), derive rankings on load, and resolve actors via a pseudonyms map seeded at projection open. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/server/src/bin/storage_bench.rs b/server/src/bin/storage_bench.rs
index 3d988416ad36b27a7d3dc84280cfdbdcafa43e69..cc5cd78ba1e02a1e6f92aa503677f2d329bb5993 100644
--- a/server/src/bin/storage_bench.rs
+++ b/server/src/bin/storage_bench.rs
@@ -29,14 +29,7 @@ async fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
for chunk_start in (0..opts.events).step_by(opts.batch_size) {
let chunk_end = (chunk_start + opts.batch_size).min(opts.events);
let events = (chunk_start..chunk_end)
- .map(|i| Event::VoteRecorded {
- ts: i as i64,
- a: format!("item-{i}"),
- b: format!("item-{}", i + 1),
- ratio_left: 2,
- ratio_right: 1,
- scope: String::new(),
- })
+ .map(|i| Event::vote_recorded(i as i64, format!("item-{i}"), format!("item-{}", i + 1), 2, 1, ""))
.collect();
journal.append_many(events).await?;
}
diff --git a/server/src/event_log.rs b/server/src/event_log.rs
index 2c9290b6fdbf2c2ad1c0f1ffd7374b2d9cc97f36..8e5684cb1378e80c45bcf743d2fdc48402dd4dc8 100644
--- a/server/src/event_log.rs
+++ b/server/src/event_log.rs
@@ -206,14 +206,7 @@ mod tests {
.unwrap();
log.append(&sample_record(
2,
- Event::VoteRecorded {
- ts: 1,
- a: "a".into(),
- b: "b".into(),
- ratio_left: 2,
- ratio_right: 1,
- scope: String::new(),
- },
+ Event::vote_recorded(1, "a", "b", 2, 1, ""),
))
.await
.unwrap();
diff --git a/server/src/events.rs b/server/src/events.rs
index d76c3bb4277216b0d39c9422ba7a50db10a95e05..8a166d49b4f26835fbc2b58cb1f4bdbf002763b8 100644
--- a/server/src/events.rs
+++ b/server/src/events.rs
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
/// Schema version for JSONL log records. Bump when event semantics change.
-pub const CURRENT_LOG_SCHEMA: u32 = 1;
+pub const CURRENT_LOG_SCHEMA: u32 = 2;
/// One JSONL line: schema envelope around a payload event.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -51,9 +51,33 @@ pub enum Event {
b: String,
ratio_left: i32,
ratio_right: i32,
- #[serde(default)]
scope: String,
+ pseudonym: String,
+ trust_weight: f64,
},
/// Register a node path in the fractal tree (no external fetch).
NodeEnsured { id: String },
}
+
+impl Event {
+ /// Construct a vote event with the default dev pseudonym (tests and benches).
+ pub fn vote_recorded(
+ ts: i64,
+ a: impl Into<String>,
+ b: impl Into<String>,
+ ratio_left: i32,
+ ratio_right: i32,
+ scope: impl Into<String>,
+ ) -> Self {
+ Self::VoteRecorded {
+ ts,
+ a: a.into(),
+ b: b.into(),
+ ratio_left,
+ ratio_right,
+ scope: scope.into(),
+ pseudonym: crate::identity::DEFAULT_PSEUDONYM.to_string(),
+ trust_weight: 1.0,
+ }
+ }
+}
diff --git a/server/src/html/vote.rs b/server/src/html/vote.rs
index 8cf2b0d4d8bd58cc51cc27c9046fa7f7728a7017..bf82aef3ad9c5f4e9e877c47dab27beb29a80b8f 100644
--- a/server/src/html/vote.rs
+++ b/server/src/html/vote.rs
@@ -336,6 +336,7 @@ pub async fn vote_page(
#[cfg(test)]
mod polarity_tests {
use super::*;
+ use crate::identity::{DEFAULT_PSEUDONYM, TEST_ACTOR_UUID};
use crate::ranking::ranked_items;
use crate::reducer::GlobalTree;
@@ -351,11 +352,11 @@ mod polarity_tests {
let right = id("right_item");
// Stored a == page left: keep order.
- let v1 = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();
+ let v1 = VoteData::from_event(1, left.as_str(), right.as_str(), 9, 1, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap();
assert_eq!(ratios_for_page(&v1, &left, &right), (9, 1));
// Stored a == page right: swap so left stays left.
- let v2 = VoteData::from_recorded(2, right.as_str(), left.as_str(), 9, 1).unwrap();
+ let v2 = VoteData::from_event(2, right.as_str(), left.as_str(), 9, 1, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap();
assert_eq!(ratios_for_page(&v2, &left, &right), (1, 9));
}
@@ -383,9 +384,9 @@ mod polarity_tests {
let right = id("right_item");
// Slider dragged left yields e.g. 9:1 with a = left item.
- let vote = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap();
+ let vote = VoteData::from_event(1, left.as_str(), right.as_str(), 9, 1, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap();
let mut tree = GlobalTree::new();
- tree.apply_vote(&parent, vote);
+ tree.apply_vote(&parent, vote, TEST_ACTOR_UUID);
let group = &tree.get(&parent).unwrap().local_ranking;
let ranked = ranked_items(group);
diff --git a/server/src/identity.rs b/server/src/identity.rs
new file mode 100644
index 0000000000000000000000000000000000000000..621d505436912246ec0830ff20c3473f0c4665ce
--- /dev/null
+++ b/server/src/identity.rs
@@ -0,0 +1,35 @@
+//! Actor identity: pseudonym display names mapped to stable UUIDs for vote dedup.
+
+use durable::Db;
+
+use crate::storage_schema::{Store, StoreFields};
+
+/// Default pseudonym until session/auth UI exists.
+pub const DEFAULT_PSEUDONYM: &str = "anon";
+
+/// UUID for the default single-user dev principal.
+pub const DEFAULT_ACTOR_UUID: &str = "00000000-0000-0000-0000-000000000001";
+
+/// UUID for in-memory unit tests.
+pub const TEST_ACTOR_UUID: &str = "00000000-0000-0000-0000-000000000099";
+
+/// Resolve the trust anchor for a pseudonym (must exist in the pseudonyms map).
+pub fn resolve_actor_uuid(db: &Db, pseudonym: &str) -> Result<String, String> {
+ Store::root()
+ .pseudonyms()
+ .key(&pseudonym.to_string())
+ .get(db)
+ .map_err(|e| e.to_string())?
+ .ok_or_else(|| format!("unknown pseudonym: {pseudonym}"))
+}
+
+/// Ensure the default pseudonym → UUID mapping exists (operational seed, not event-logged).
+pub fn seed_default_pseudonym(db: &Db) -> Result<(), durable::Error> {
+ let path = Store::root()
+ .pseudonyms()
+ .key(&DEFAULT_PSEUDONYM.to_string());
+ if path.get(db)?.is_none() {
+ db.run(path.set(&DEFAULT_ACTOR_UUID.to_string()), durable::Durability::SyncWal)?;
+ }
+ Ok(())
+}
diff --git a/server/src/lib.rs b/server/src/lib.rs
index 3dfc7c8acb8ed61bb73ade63e72768e402042cc5..da6e33e3dd6d79967bbee7a2708a7d982c93b88c 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -4,6 +4,7 @@ pub mod events;
pub mod fetch;
pub mod form_template;
pub mod html;
+pub mod identity;
pub mod journal;
pub mod pair;
pub mod parser;
diff --git a/server/src/pair.rs b/server/src/pair.rs
index 2277e32edf6e0024687d6fe2b9d1a9b84c0b34c8..42a1b1eb2adf16730d34d0fe23c13d5a75d7ba27 100644
--- a/server/src/pair.rs
+++ b/server/src/pair.rs
@@ -360,8 +360,17 @@ impl PairError {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::identity::{DEFAULT_PSEUDONYM, TEST_ACTOR_UUID};
use crate::reducer::{GlobalTree, VoteData};
+ fn test_vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData {
+ VoteData::from_event(ts, a, b, l, r, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap()
+ }
+
+ fn apply(tree: &mut GlobalTree, parent: &ItemId, vote: VoteData) {
+ tree.apply_vote(parent, vote, TEST_ACTOR_UUID);
+ }
+
fn seed_children(parent: &ItemId, ids: &[&str]) -> GlobalTree {
let mut tree = GlobalTree::new();
tree.ensure_path(parent);
@@ -382,22 +391,13 @@ mod tests {
#[test]
fn zero_weight_vote_leaves_pair_available_for_suggestion() {
let parent = ItemId::parse("https://reddit.com/r/rust").unwrap();
- let mut tree = seed_children(
+ let tree = seed_children(
&parent,
&[
"https://reddit.com/r/rust/a",
"https://reddit.com/r/rust/b",
],
);
- let noop = VoteData::from_recorded(
- 1,
- "https://reddit.com/r/rust/a",
- "https://reddit.com/r/rust/b",
- 0,
- 0,
- )
- .unwrap();
- tree.apply_vote(&parent, noop);
let group = tree.get(&parent).unwrap().local_ranking.clone();
let pool = children_of(&tree, &parent);
assert!(!pair_is_voted(&group, &pool[0], &pool[1]));
@@ -415,9 +415,8 @@ mod tests {
"https://reddit.com/r/rust/c",
],
);
- let vote =
- VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap();
- tree.apply_vote(&parent, vote);
+ let vote = test_vote(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1);
+ apply(&mut tree, &parent, vote);
let group = tree.get(&parent).unwrap().local_ranking.clone();
let pool = children_of(&tree, &parent);
let (l, r) = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
@@ -438,12 +437,10 @@ mod tests {
"https://reddit.com/r/rust/d",
],
);
- let ab =
- VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap();
- let cd =
- VoteData::from_recorded(2, "https://reddit.com/r/rust/c", "https://reddit.com/r/rust/d", 2, 1).unwrap();
- tree.apply_vote(&parent, ab);
- tree.apply_vote(&parent, cd);
+ let ab = test_vote(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1);
+ let cd = test_vote(2, "https://reddit.com/r/rust/c", "https://reddit.com/r/rust/d", 2, 1);
+ apply(&mut tree, &parent, ab);
+ apply(&mut tree, &parent, cd);
let group = tree.get(&parent).unwrap().local_ranking.clone();
let pool = children_of(&tree, &parent);
let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
@@ -468,9 +465,8 @@ mod tests {
"https://reddit.com/r/rust/e",
],
);
- let ab =
- VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap();
- tree.apply_vote(&parent, ab);
+ let ab = test_vote(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1);
+ apply(&mut tree, &parent, ab);
let group = tree.get(&parent).unwrap().local_ranking.clone();
let pool = children_of(&tree, &parent);
let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
@@ -498,9 +494,8 @@ mod tests {
"https://reddit.com/r/rust/c",
],
);
- let ab =
- VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap();
- tree.apply_vote(&parent, ab);
+ let ab = test_vote(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1);
+ apply(&mut tree, &parent, ab);
let group = tree.get(&parent).unwrap().local_ranking.clone();
let pool = children_of(&tree, &parent);
let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap();
@@ -524,8 +519,8 @@ mod tests {
("https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 3, 1),
("https://reddit.com/r/rust/a", "https://reddit.com/r/rust/c", 2, 1),
] {
- let v = VoteData::from_recorded(1, a, b, l, r).unwrap();
- tree.apply_vote(&parent, v);
+ let v = test_vote(1, a, b, l, r);
+ apply(&mut tree, &parent, v);
}
let group = tree.get(&parent).unwrap().local_ranking.clone();
let pool = children_of(&tree, &parent);
@@ -552,8 +547,8 @@ mod tests {
("https://reddi
… preview truncated; 38,467 characters omittedB — c_99071b4accf5 (tommy-mor)
message
[e8c85249] Fix GitHub resolver and vote compare flow (#149) * Fix GitHub resolver and vote compare flow Co-authored-by: tommy <thmorriss@gmail.com> * Fix vote compare next pair helper Co-authored-by: tommy <thmorriss@gmail.com> * Extend GitHub resolver and vote pair updates Co-authored-by: tommy <thmorriss@gmail.com> * Fix resolver browser refresh coverage Co-authored-by: tommy <thmorriss@gmail.com> * Stabilize GitHub resolver browser test Co-authored-by: tommy <thmorriss@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
diff preview
diff --git a/agents.md b/agents.md
index c66f33789441eea193b4354fce4c03b7fffdd639..7508234d9b04223d0e64cfe69fedbebd06a256b5 100644
--- a/agents.md
+++ b/agents.md
@@ -36,12 +36,18 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma
- **`HtmlUiAction` / `POST /ui`** (`server/src/html/ui_action.rs`, `server/src/api/ui_html.rs`): **Browser session** (cookie) UI commands. Payload is `__rpc__` + form fields. Most responses are **JS morphs**; some actions return **HTTP redirects** (see below).
+- **Do not add one-off POST routes** for browser mutations. New browser actions belong in **`HtmlUiAction`** behind **`POST /ui`**; new programmatic verbs belong in **`RpcCommand`** behind **`POST /api/v0/rpc`**. Ordinary shareable pages remain normal **`GET`** routes.
+
- **Non-morph `POST /ui` responses:** **`SetGardenPin`** returns **`303 See Other`** and **`Set-Cookie`** (same as **`POST /theme`**). Garden pin/unpin is a normal **`<form method="POST" action="/ui" data-navigate="full">`** — browser navigation applies cookies reliably (see **`test/browser_garden_pin.clj`**). Each **`__rpc__`** payload includes **`form_action: "/ui"`**; **`post_ui_html`** rejects mismatches to bind tokens to the UI endpoint.
-- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-compare-preview`** (new ingest card), **`#vote-edge-history-region`** (recomputed **`<ul>`** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer). The compare **`GET`** page uses **`layout_full_bleed_chromeless`** (no breadcrumbs, no **`#controls`**, no **`slug-pin-hud`**; **`view-vote-compare-fullscreen`** full-width **`body`**). **`__rpc__`** carries **`form_action: "/ui"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes.
+- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-edge-history-region`** (recomputed **`<ul>`** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer) and **`.vote-compare-nav`** (fresh next-pair link). The compare **`GET`** page uses **`layout_full_bleed_chromeless`** (no breadcrumbs, no **`#controls`**, no **`slug-pin-hud`**; **`view-vote-compare-fullscreen`** full-width **`body`**). **`__rpc__`** carries **`form_action: "/ui"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes.
+
+- **`ResolveExternal`:** GitHub resolver buttons are browser actions through **`POST /ui`**. Success responses morph **`#external-resolver-status`** then redirect to the sanitized shareable **`GET`** page so imported children render through the normal page path; errors morph the same status region. Resolver results are durable system ingests, while cooldown state is RAM-only.
- **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`** (fullscreen **`GET`** page: no HUD; other garden pages). HUD (**`#slug-pin-hud`**): only when **`layout`** passes garden metadata on **`body`**; the label is **`POST /ui`** **`set_garden_pin`** **`clear:true`** (**`slug_ui.js`**), not a permalink to the item.
+- **Browser auth redirects:** `/login`, `/join/:token`, `/auth/login`, and `/auth/choose-username` may carry **`next`** (or legacy **`redirect`**) as a **safe local path only**. The value is stored on the RAM-only pending session and applied after OAuth / username selection.
+
**Rule of thumb:** New **CLI or API** verbs → `RpcCommand`. New **in-page morph or form-driven** behavior that only makes sense in the browser → `HtmlUiAction`. If both need the same operation, implement the real work once (e.g. call shared RPC helpers from `post_ui_html`) and keep the wire shapes separate.
---
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index c9c4082f251838b5ac46de7ce48392c136883d55..88bb3335e4873643530351266d213f258da5893e 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -8,7 +8,10 @@ use axum::{
use axum_extra::extract::cookie::CookieJar;
use base64::Engine;
use serde::Deserialize;
-use slug_types::{PendingSessionPollResponse, PendingSessionStartRequest, PendingSessionStartResponse, WhoamiResponse};
+use slug_types::{
+ PendingSessionPollResponse, PendingSessionStartRequest, PendingSessionStartResponse,
+ WhoamiResponse,
+};
use std::{collections::HashMap, sync::Arc};
use tokio::sync::{oneshot, RwLock};
@@ -17,7 +20,8 @@ use crate::{
events::{Event, TokenIssued},
html::{
auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment,
- choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri, JsBuilder,
+ choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri,
+ JsBuilder,
},
identity::{parse_agent, parse_username},
reducer::ReducerState,
@@ -36,12 +40,26 @@ pub const SLUG_SESSION_COOKIE: &str = "slug_session";
/// `Set-Cookie` header value (full attribute string).
pub fn session_cookie_header_value(bearer: &str) -> HeaderValue {
- let s = format!(
- "{SLUG_SESSION_COOKIE}={bearer}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
- );
+ let s =
+ format!("{SLUG_SESSION_COOKIE}={bearer}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000");
HeaderValue::from_str(&s).expect("session cookie value must be ASCII")
}
+fn safe_local_redirect(raw: Option<&str>) -> Option<String> {
+ let s = raw?.trim();
+ if s.starts_with('/') && !s.starts_with("//") && s.len() < 8192 {
+ Some(s.to_string())
+ } else {
+ None
+ }
+}
+
+fn redirect_query(next: Option<&str>) -> String {
+ safe_local_redirect(next)
+ .map(|n| format!("&next={}", urlencoding::encode(&n)))
+ .unwrap_or_default()
+}
+
fn js_form_error_fragment(session: &str, error: &str) -> Response {
JsBuilder::new()
.id("choose-username-form")
@@ -49,11 +67,11 @@ fn js_form_error_fragment(session: &str, error: &str) -> Response {
.into_response()
}
-fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response {
+fn js_signed_in_fragment(bearer: &str, jar: &CookieJar, redirect_to: &str) -> Response {
let mut response = JsBuilder::new()
.id("choose-username-form")
.morph_inner(auth_signed_in_fragment())
- .redirect("/auth/complete")
+ .redirect(redirect_to)
.into_response();
let headers = response.headers_mut();
headers.append(header::SET_COOKIE, session_cookie_header_value(bearer));
@@ -64,7 +82,11 @@ fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response {
}
/// Resolve the signed-in username from `Authorization: Bearer` or `slug_session` cookie.
-pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option<String> {
+pub fn optional_principal(
+ headers: &HeaderMap,
+ jar: &CookieJar,
+ reduced: &ReducerState,
+) -> Option<String> {
if let Ok(u) = verify_bearer_principal(headers, reduced) {
return Some(u);
}
@@ -80,7 +102,11 @@ pub struct WebSession {
}
/// Resolve username and bearer together for `POST /ui` dispatch (one read of headers + jar).
-pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option<WebSession> {
+pub fn resolve_web_session(
+ headers: &HeaderMap,
+ jar: &CookieJar,
+ reduced: &ReducerState,
+) -> Option<WebSession> {
let username = optional_principal(headers, jar, reduced)?;
let bearer = headers
.get(header::AUTHORIZATION)
@@ -90,7 +116,12 @@ pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &Reduc
Some(WebSession { username, bearer })
}
-fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str, jar: &CookieJar) -> Response {
+fn redirect_with_session_cookie(
+ public_url: &str,
+ path_and_query: &str,
+ bearer: &str,
+ jar: &CookieJar,
+) -> Response {
let mut res = Response::builder()
.status(StatusCode::TEMPORARY_REDIRECT)
.header(header::LOCATION, format!("{public_url}{path_and_query}"))
@@ -112,21 +143,32 @@ fn pending_sessions(state: &AppState) -> Arc<RwLock<HashMap<String, PendingSessi
/// Safe here because the token was received directly from Google's token endpoint over TLS.
fn extract_jwt_sub(jwt: &str) -> Option<String> {
let payload_b64 = jwt.split('.').nth(1)?;
- let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
+ let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
+ .decode(payload_b64)
+ .ok()?;
let v: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
v.get("sub")?.as_str().map(|s| s.to_string())
}
pub(crate) fn parse_bearer(headers: &HeaderMap) -> Result<String, (StatusCode, String)> {
let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else {
- return Err((StatusCode::UNAUTHORIZED, "missing Authorization header".to_string()));
+ return Err((
+ StatusCode::UNAUTHORIZED,
+ "missing Authorization header".to_string(),
+ ));
};
let Ok(s) = value.to_str() else {
- return Err((StatusCode::UNAUTHORIZED, "invalid Authorization header".to_string()));
+ return Err((
+ StatusCode::UNAUTHORIZED,
+ "invalid Authorization header".to_string(),
+ ));
};
let s = s.trim();
let Some(rest) = s.strip_prefix("Bearer ") else {
- return Err((StatusCode::UNAUTHORIZED, "Authorization must be Bearer".to_string()));
+ return Err((
+ StatusCode::UNAUTHORIZED,
+ "Authorization must be Bearer".to_string(),
+ ));
};
Ok(rest.trim().to_string())
}
@@ -140,7 +182,10 @@ pub fn verify_bearer_principal(
verify_token(reduced, &bearer)
}
-pub(crate) fn verify_token(reduced: &crate::reducer::ReducerState, bearer: &str) -> Result<String, (StatusCode, String)> {
+pub(crate) fn verify_token(
+ reduced: &crate::reducer::ReducerState,
+ bearer: &str,
+) -> Result<String, (StatusCode, String)> {
// slug_<token_id>_<secret>
let Some(rest) = bearer.strip_prefix("slug_") else {
return Err((StatusCode::UNAUTHORIZED, "invalid token format".to_string()));
@@ -199,9 +244,25 @@ pub(crate) fn issue_token_for_user(stored_username: &str) -> (String, TokenIssue
#[derive(Debug, Deserialize)]
pub struct AuthLoginQuery {
pub session: String,
+ #[serde(default)]
+ pub next: Option<String>,
+ #[serde(default)]
+ pub redirect: Option<String>,
}
-pub async fn get_join_invite(Path(token): Path<String>, State(state): State<AppState>) -> impl IntoResponse {
+#[derive(Debug, Deserialize)]
+pub struct JoinInviteQuery {
+ #[serde(default)]
+ pub next: Option<String>,
+ #[serde(default)]
+ pub redirect: Option<String>,
+}
+
+pub async fn get_join_invite(
+ Path(token): Path<String>,
+ Query(q): Query<JoinInviteQuery>,
+ State(state): State<AppState>,
+) -> impl IntoResponse {
let token = token.trim().to_string();
if token.is_empty() {
return api_error(StatusCode::NOT_FOUND, "invite invalid or expired", None).into_response();
@@ -219,37 +280,57 @@ pub async fn get_join_invite(Path(token): Path<String>, State(state): State<AppS
}
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(),
created_ts: now_ms(),
provider: None,
provider_id: None,
redeem_invite: Some(token),
+ redirect_next: redirect_next.clone(),
complete: None,
};
- state.pending_sessi
… preview truncated; 86,121 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.