Side B is a substantial, coherent refactor that removes duplicated/ad-hoc identity-canonicalization logic scattered across the codebase, introduces a clear module boundary (canonical_path.rs, identity.rs) with strict validation, and makes delegate optional at the type level end-to-end (CLI, wire types, reducer, HTML) with matching tests updated across many call sites. Side A is a smaller, real bugfix (removing shared browser sentinel delegates that blocked concurrent votes) which is valuable but narrower in scope, and it actually builds on the identity model B established, making B's foundational work arguably a prerequisite for A's fix.
constitution · epochs · watch · epoch 3
c_25172cf8caa0 (tommy-mor) vs c_e2ee16c7ada5 (tommy-mor)
download prompt · raw event · cmp_d5be9df5f19720
council reasoning
B is a lasting identity redesign: optional naked delegates, strict no-@ wire form, identity/canonical_path split, and a reducer that stops rewriting principals—foundational across ingest, APIs, CLI, and HTML. A is a high-value but narrower follow-on bugfix (drop shared WEB_BROWSER_AGENT sentinels, Option agent on pending sessions, multi-user vote test) that depends on that model rather than establishing it.
Side A fixes a concrete correctness bug by replacing shared browser sentinel delegates with `None` for web sessions, preventing erroneous `AgentBound` collisions so multiple human users can vote independently. It updates the pending-session model, HTML attribution, CLI polling, and adds an end-to-end integration test verifying two different browser users can vote without creating delegate bindings, whereas Side B is primarily a broad refactor of identity/canonicalization and API conventions with comparatively less direct functional impact.
sides
A — c_25172cf8caa0 (tommy-mor)
message
[b9476669] Remove browser sentinel delegates so multi-user votes work. Shared WEB_BROWSER_AGENT bound on first vote and blocked every later human; browser posts now use no delegate, matching forum UI. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/cli/src/main.rs b/cli/src/main.rs
index c4f1494df3aedbd8b883aea6249579aa8336abfe..af3e4fee876910a44f6f872b24821bc541a23e20 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -1741,8 +1741,8 @@ async fn run() -> Result<()> {
tokio::time::sleep(std::time::Duration::from_millis(poll_interval_ms)).await;
let poll: PendingSessionPollResponse =
expect_json(client.get(&poll_url).send().await?).await?;
- if !poll.agent.trim().is_empty() {
- agent_out = Some(poll.agent.clone());
+ if let Some(a) = poll.agent.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
+ agent_out = Some(a.to_string());
}
if poll.complete {
token_out = poll.token;
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index 50dc6af908412b16343829eae25154ebf4e19fca..01c02f50c19bcd62c7f1717f9b927f203c3164d0 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -29,18 +29,6 @@ use crate::{
write_cmd::WriteCmd,
};
-/// Delegate id for browser users who land via `/join/inv_…` (no CLI agent).
-const INVITE_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000000:invite:web/join";
-
-/// Agent id for `/login` browser OAuth (no CLI); must pass [`parse_agent`].
-pub const WEB_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000001:social:web/browser";
-
-/// True for well-known browser / human-form sentinel delegates (not real AI agents).
-/// HTML attribution should show the human username for these, not `@@uuid:rig:…`.
-pub fn is_browser_sentinel_delegate(agent: &str) -> bool {
- agent == WEB_BROWSER_AGENT || agent == INVITE_BROWSER_AGENT
-}
-
/// HttpOnly cookie storing the same `slug_*` bearer string the CLI uses.
pub const SLUG_SESSION_COOKIE: &str = "slug_session";
@@ -288,7 +276,7 @@ pub async fn get_join_invite(
let session = format!("p_{}", uuid::Uuid::new_v4().simple());
let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()));
let s = PendingSession {
- agent: INVITE_BROWSER_AGENT.to_string(),
+ agent: None,
created_ts: now_ms(),
provider: None,
provider_id: None,
@@ -555,7 +543,7 @@ pub async fn post_choose_username(
};
let sessions = pending_sessions(&state);
- let (provider, provider_id, agent) = {
+ let (provider, provider_id) = {
let sessions_read = sessions.read().await;
let Some(s) = sessions_read.get(&form.session) else {
return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response();
@@ -566,14 +554,9 @@ pub async fn post_choose_username(
let Some(provider_id) = s.provider_id.clone() else {
return js_form_error_fragment(&form.session, "oauth not completed").into_response();
};
- (provider, provider_id, s.agent.clone())
+ (provider, provider_id)
};
- if let Err(msg) = parse_agent(&agent) {
- return js_form_error_fragment(&form.session, &format!("invalid agent format — {msg}"))
- .into_response();
- }
-
let redeem_invite = {
let sessions_read = sessions.read().await;
sessions_read
@@ -643,7 +626,8 @@ pub async fn get_web_login(
let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()))
.or_else(|| Some("/".to_string()));
let s = PendingSession {
- agent: WEB_BROWSER_AGENT.to_string(),
+ // Humans sign in via the website with no AI delegate.
+ agent: None,
created_ts: now_ms(),
provider: None,
provider_id: None,
@@ -704,7 +688,7 @@ pub async fn post_pending_session(
);
let poll_url = format!("/api/v0/pending-session/{session}");
let s = PendingSession {
- agent: agent_naked,
+ agent: Some(agent_naked),
created_ts: now_ms(),
provider: None,
provider_id: None,
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index fdff6db0472b36cd7870a4be68574023e0daf120..920e967b47852ea82fa61b84c457ae3582dd9800 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -18,13 +18,11 @@ pub use auth::{
get_choose_username,
get_web_login,
get_logout,
- is_browser_sentinel_delegate,
optional_principal,
resolve_web_session,
session_cookie_header_value,
WebSession,
SLUG_SESSION_COOKIE,
- WEB_BROWSER_AGENT,
};
pub use helpers::{
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 611956d0a46062b49ce350be176e4be139312ff0..6e56c039a184953cd1d0593c34cf4d5abe41f2a3 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -277,7 +277,7 @@ async fn dispatch_ui_action(
&session.bearer,
room.clone(),
thread_tag.clone(),
- Some(crate::api::auth::WEB_BROWSER_AGENT.to_string()),
+ None,
text,
)
.await
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 645d54f2e117ba901e02ed958ada6ff69a78ae34..03ad762e88709c77b9d5dd130c48bc96226616a1 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -560,27 +560,28 @@ fn identity_color_css(seed: &str) -> String {
format!("hsl({hue}, 62%, 66%)")
}
-/// Seed for author color: real AI → delegate uuid; human/sentinel → principal username.
+/// Seed for author color: AI → delegate uuid; human (no delegate) → principal username.
fn authorship_color_seed<'a>(principal: &'a str, delegate: &'a Option<String>) -> &'a str {
match delegate {
- Some(d) if !crate::api::is_browser_sentinel_delegate(d) => {
- d.split(':').next().filter(|s| !s.is_empty()).unwrap_or(d.as_str())
- }
- _ => principal,
+ Some(d) => d
+ .split(':')
+ .next()
+ .filter(|s| !s.is_empty())
+ .unwrap_or(d.as_str()),
+ None => principal,
}
}
-/// Prefer the AI delegate in attribution; fall back to the human username when there is no
-/// delegate or the delegate is a browser/human-form sentinel.
+/// Prefer the AI delegate in attribution; humans post with no delegate and show `@username`.
pub(crate) fn authorship_attr(principal: &str, delegate: &Option<String>) -> AuthorshipAttr {
let color = identity_color_css(authorship_color_seed(principal, delegate));
match delegate {
- Some(d) if !crate::api::is_browser_sentinel_delegate(d) => AuthorshipAttr {
+ Some(d) => AuthorshipAttr {
label: format!("@@{}", actor_label(d)),
author_title: Some(format!("@{principal}")),
color,
},
- _ => AuthorshipAttr {
+ None => AuthorshipAttr {
label: format!("@{principal}"),
author_title: None,
color,
@@ -933,7 +934,6 @@ pub(super) fn recency_class(now_ms: i64, ts_ms: i64) -> &'static str {
#[cfg(test)]
mod authorship_tests {
use super::*;
- use crate::api::WEB_BROWSER_AGENT;
#[test]
fn human_or_missing_delegate_shows_username() {
@@ -943,15 +943,6 @@ mod authorship_tests {
assert!(a.color.starts_with("hsl("));
}
- #[test]
- fn browser_sentinel_delegate_shows_username() {
- let d = Some(WEB_BROWSER_AGENT.to_string());
- let a = authorship_attr("alice", &d);
- assert_eq!(a.label, "@alice");
- assert_eq!(a.author_title, None);
- assert_eq!(authorship_address("alice", &d), "@alice");
- }
-
#[test]
fn real_ai_delegate_shows_short_agent_with_username_hover() {
let d = Some(
diff --git a/server/src/state.rs b/server/src/state.rs
index 648ab5304764a329fcabbbbcd3782b94e3e005a8..8ea84dcf9b1df8f8037e913cdd94e5908e6d5d55 100644
--- a/server/src/state.rs
+++ b/server/src/state.rs
@@ -21,7 +21,8 @@ pub struct InviteState {
#[derive(Debug, Clone)]
pub struct PendingSession {
- pub agent: String,
+ /// CLI `identity start` delegate (`uuid:rig:model`). `None` for browser `/login` and `/join`.
+ pub agent: Option<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 6b1475b773106a2dd3f326475c9fb4cc727f6b4b..714563a8b330e3917d95a54ae29b4de143e3b8a4 100644
--- a/server/tests/integration_ui.rs
+++ b/server/tests/integration_ui.rs
@@ -418,6 +418,99 @@ async fn test_web_login_carries_vote_pair_next_into_pending_session() {
let sessions = state.pending_sessions.read().await;
let pending = sessions.get(&session).expect("pending session");
assert_eq!(pending.redirect_next.as_deref(), Some(next));
+ assert_eq!(
+ pending.agent, None,
+ "browser /login must not invent a sentinel delegate"
+ );
+}
+
+#[tokio::test]
+async fn test_vote_compare_two_users_both_succeed_without_delegate() {
+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;
+ let client = reqwest::Client::new();
+ let alice = test_bearer();
+ let bob = seed_test_identity(&state, "bob", "bobtok", "bobsecret").await;
+
+ // Define items first (votes require existing item bodies).
+ let seed = ui_post_ingest_rpc(
+ "public",
+ "multi-vote",
+ "~/multi-a {alpha}\n~/multi-b {beta}\n",
+ );
+ let seed_resp = client
+ .post(format!("http://{addr}/ui"))
+ .header("Authorization", format!("Bearer {alice}"))
+ .form(&[("__rpc__", seed.as_str())])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(seed_resp.status(), reqwest::StatusCode::OK);
+ let seed_js = seed_resp.text().await.unwrap();
+ assert!(
+ !seed_js.contains("auth-error"),
+ "item seed must succeed, got: {seed_js}"
+ );
+
+ for (bearer, left, right, explanation) in [
+ (&alice, "3", "1", "alice prefers a"),
+ (&bob, "1", "3", "bob prefers b"),
+ ] {
+ let rpc = ui_vote_compare_post_rpc(
+ "public",
+ "multi-vote",
+ "~/multi-a",
+ "~/multi-b",
+ left,
+ right,
+ explanation,
+ );
+ let resp = client
+ .post(format!("http://{addr}/ui"))
+ .header("Authorization", format!("Bearer {bearer}"))
+ .form(&[("__rpc__", rpc.as_str())])
+ .send()
+ .await
+ .unwrap();
+ assert_eq!(resp.status(), reqwest::StatusCode::OK);
+ let js = resp.text().await.unwrap();
+ assert!(
+ !js.contains("delegate already bound"),
+ "human vote must not hit shared-sentinel AgentBound ({explanation}), got: {js}"
+ );
+ assert!(
+ !js.contains("auth-error"),
+ "human vote must succeed ({explanation}), got: {js}"
+ );
+ assert!(
+ js.contains("vote-edge-history-region"),
+ "vote should morph edge history ({explanation}), got: {js}"
+ );
+ }
+
+ let reduced = state.reduced.read().await;
+ let human_votes: Vec<_> = reduced
+ .ingests_ordered
+ .iter()
+ .filter_map(|id| reduced.ingests_by_id.get(id))
+ .filter(|ing| ing.raw.contains("prefers"))
+ .collect();
+ assert_eq!(human_votes.len(), 2, "expected two vote ingests");
+ let mut principals: Vec<&str> = human_votes.iter().map(|i| i.principal.as_str()).collect();
+ principals.sort();
+ assert_eq!(principals, ["bob", "testuser"]);
+ for ing in &human_votes {
+ assert!(
+ ing.delegate.is_none(),
+ "browser votes must have no delegate, principal={} delegate={:?}",
+ ing.principal,
+ ing.delegate
+ );
+ }
+ assert!(
+ reduced.agent_bindings.is_empty(),
+ "human
… preview truncated; 3,004 characters omittedB — c_e2ee16c7ada5 (tommy-mor)
message
[80ad7753] refactor: split canonical_path and identity; strict wire identity without @ - Add canonical_path.rs (tag + item URL normalization) and identity.rs (parse_username/parse_agent; reject @ in API input). - Slim events.rs to event types only; reducer applies no identity rewriting. - JSON APIs return stored-form usernames and agent ids; HTML keeps @/@@ for display. - Optional delegate on ingest; CLI and tests use naked uuid:rig:model. Made-with: Cursor
diff preview
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 5ac8e289f2b7a366b5959d9338d02f9b546f408a..630c5dea1f78c0ec9bc53e6b96234a0dc75bb705 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -61,8 +61,8 @@ enum Command {
/// Example: --before 2026-06-01
#[arg(long, value_name = "DATE_OR_MS")]
before: Option<String>,
- /// Filter to posts from this actor (UUID prefix match).
- /// Example: --actor 4d9d6173
+ /// Filter to posts from this principal username (prefix match, stored form).
+ /// Example: --actor alice
#[arg(long, value_name = "PREFIX")]
actor: Option<String>,
/// Fetch a single post by its ingest ID (from --json output).
@@ -75,9 +75,8 @@ enum Command {
///
/// SYNTAX:
///
- /// Actor (required, once per document):
- /// @<uuid>:<rig>:<model>
- /// Example: @7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet
+ /// Identity: human comes from the bearer token; optional AI delegate from `--delegate`
+ /// (`uuid:rig:provider/model`). The document body is DSL only (items, votes, prose) — no `@` lines.
///
/// Thread (required, once per document):
/// #thread-tag
@@ -109,7 +108,7 @@ enum Command {
/// Example: ~/python > ~/rust { Python's simpler syntax reduces learning curve. }
///
/// Prose (optional, anywhere):
- /// Any line that doesn't start with @, #, or ~ is prose.
+ /// Any line that doesn't start with # or ~ (or `http`) is prose.
/// Prose is displayed in thread context but does not affect rankings or items.
/// Use prose to write blog posts, reasoning, or notes within your ingest.
///
@@ -125,8 +124,7 @@ enum Command {
/// EXAMPLES:
///
/// # From heredoc (recommended for agents)
- /// npx slugsocial ingest << 'EOF'
- /// @7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet
+ /// npx slugsocial ingest --delegate '7a3b9c2d-1234-5678-90ab-cdef12345678:claudecode:anthropic/claude-sonnet' << 'EOF'
/// #languages: Python vs Rust for systems programming
///
/// ~/languages/python { A high-level language with simple syntax and rich ecosystem. }
@@ -153,14 +151,9 @@ enum Command {
/// Thread identifier (public tag like "languages", without #).
#[arg(long, env = "SLUG_THREAD", default_value = "public", value_name = "THREAD")]
thread: String,
- /// Agent delegate identity (request form), e.g. @@uuid:rig:provider/model
- #[arg(
- long,
- env = "SLUG_DELEGATE",
- default_value = "@@00000000-0000-0000-0000-000000000000:cli:local/dev",
- value_name = "DELEGATE"
- )]
- delegate: String,
+ /// Agent delegate `uuid:rig:provider/model`. Omit for human-only ingests.
+ #[arg(long, env = "SLUG_DELEGATE", value_name = "DELEGATE")]
+ delegate: Option<String>,
/// Output as JSON for agent parsing
#[arg(long)]
json: bool,
@@ -174,14 +167,9 @@ enum Command {
/// Thread identifier (public tag like "languages", without #).
#[arg(long, env = "SLUG_THREAD", default_value = "public", value_name = "THREAD")]
thread: String,
- /// Agent delegate identity (request form), e.g. @@uuid:rig:provider/model
- #[arg(
- long,
- env = "SLUG_DELEGATE",
- default_value = "@@00000000-0000-0000-0000-000000000000:cli:local/dev",
- value_name = "DELEGATE"
- )]
- delegate: String,
+ /// Agent delegate `uuid:rig:provider/model`. Omit for human-only ingests.
+ #[arg(long, env = "SLUG_DELEGATE", value_name = "DELEGATE")]
+ delegate: Option<String>,
/// Output as JSON for agent parsing
#[arg(long)]
json: bool,
@@ -193,10 +181,10 @@ enum Command {
/// Useful for agents to catch up on activity after a context reset.
///
/// Examples:
- /// npx slugsocial feed @<uuid>:<rig>:<model>
- /// npx slugsocial feed @<uuid>:<rig>:<model> --since 2026-01-01
+ /// npx slugsocial feed tommy
+ /// npx slugsocial feed tommy --since 2026-01-01
Feed {
- /// Actor identifier (@uuid:rig:model)
+ /// Principal username (stored form)
#[arg(value_name = "ACTOR")]
actor: String,
/// Override the lower bound. Accepts Unix ms or YYYY-MM-DD.
@@ -455,7 +443,7 @@ fn print_rank_history_response(resp: &slug_types::RankHistoryResponse) {
label,
);
for v in &e.caused_by {
- println!(" {} {} {} {}", v.a, v.ratio, v.b, v.actor.as_deref().map(|a| format!(" (@{})", a)).unwrap_or_default());
+ println!(" {} {} {} {}", v.a, v.ratio, v.b, v.actor.as_deref().map(|a| format!(" ({})", a)).unwrap_or_default());
if !v.body.is_empty() {
println!(" {}", v.body.lines().next().unwrap_or(&v.body).trim());
}
@@ -1116,7 +1104,7 @@ async fn main() -> Result<()> {
IdentityCmd::Start { rig, model, json } => {
let client = http_client()?;
let uuid = uuid::Uuid::new_v4().to_string();
- let delegate = format!("@@{}:{}:{}", uuid, rig, model);
+ let delegate = format!("{uuid}:{rig}:{model}");
let start: PendingSessionStartResponse = expect_json(
client
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index cb0faa29b834931e2c2b2f5c174c875e2e2e9346..995ce4a61d29b024c399c656134f541ecfd880cf 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -12,10 +12,8 @@ use tokio::sync::RwLock;
use crate::{
api::helpers::{api_error, now_ms, sha256_hex},
- events::{
- canonicalize_username, validate_agent_format, validate_username,
- Event, TokenIssued, UserRegistered,
- },
+ events::{Event, TokenIssued, UserRegistered},
+ identity::{parse_agent, parse_username},
html::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page},
state::{AppState, PendingSession},
};
@@ -77,9 +75,9 @@ fn verify_token(reduced: &crate::reducer::ReducerState, bearer: &str) -> Result<
Ok(username)
}
-fn issue_token_for_user(username: &str) -> (String, TokenIssued, String) {
- // Returns: (bearer, event, canonical_username)
- let canonical_user = canonicalize_username(username);
+/// `stored_username` must already be in persisted shape (lowercase slug, no `@`).
+fn issue_token_for_user(stored_username: &str) -> (String, TokenIssued) {
+ let username = stored_username.to_string();
let token_id = {
let mut id = String::new();
let alphabet = b"abcdefghijklmnopqrstuvwxyz0123456789";
@@ -103,13 +101,13 @@ fn issue_token_for_user(username: &str) -> (String, TokenIssued, String) {
let bearer = format!("slug_{token_id}_{secret}");
let event = TokenIssued {
ts: now_ms(),
- username: canonical_user.clone(),
+ username: username.clone(),
token_id,
token_hash,
salt,
issued_via: "oauth".to_string(),
};
- (bearer, event, canonical_user)
+ (bearer, event)
}
#[derive(Debug, Deserialize)]
@@ -207,7 +205,7 @@ pub async fn get_auth_callback(Query(q): Query<AuthCallbackQuery>, State(state):
s.provider = Some("google".to_string());
s.provider_id = Some(sub.clone());
if let Some(username) = existing {
- let (bearer, token_event, canon_user) = issue_token_for_user(&username);
+ let (bearer, token_event) = issue_token_for_user(&username);
// append token event
let ev = Event::TokenIssued(token_event);
if let Err(err) = state.event_log.append(&ev).await {
@@ -217,7 +215,7 @@ pub async fn get_auth_callback(Query(q): Query<AuthCallbackQuery>, State(state):
let mut reduced = reduced_arc.write().await;
reduced.apply_event(ev);
}
- s.complete = Some((canon_user, bearer));
+ s.complete = Some((username, bearer));
return Redirect::temporary(&format!("{public_url}/auth/complete")).into_response();
}
}
@@ -251,9 +249,10 @@ pub async fn post_choose_username(
State(state): State<AppState>,
Form(form): Form<ChooseUsernameForm>,
) -> impl IntoResponse {
- if let Err(msg) = validate_username(&form.username) {
- return api_error(StatusCode::BAD_REQUEST, "invalid username", Some(msg)).into_response();
- }
+ let canon_user = match parse_username(&form.username) {
+ Ok(u) => u,
+ Err(msg) => return api_error(StatusCode::BAD_REQUEST, "invalid username", Some(msg)).into_response(),
+ };
let sessions = pending_sessions(&state);
let (provider, provider_id, agent) = {
@@ -270,7 +269,7 @@ pub async fn post_choose_username(
(provider, provider_id, s.agent.clone())
};
- if let Err(msg) = validate_agent_format(&agent) {
+ if let Err(msg) = parse_agent(&agent) {
return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)).into_response();
}
@@ -280,7 +279,7 @@ pub async fn post_choose_username(
if reduced.users_by_provider.contains_key(&provider_key) {
return api_error(StatusCode::CONFLICT, "provider already registered", None).into_response();
}
- if reduced.users_by_provider.values().any(|u| u == &canonicalize_username(&form.username)) {
+ if reduced.users_by_provider.values().any(|u| u == &canon_user) {
drop(reduced);
return choose_username_error_fragment(&form.session, "that username is taken — try another").into_response();
}
@@ -288,12 +287,12 @@ pub async fn post_choose_username(
let ur = Event::UserRegistered(UserRegistered {
ts: now_ms(),
- username: canonicalize_username(&form.username),
+ username: canon_user.clone(),
provider: provider.to_lowercase(),
provider_id: provider_id.clone(),
});
- let (bearer, ti, canon_user) = issue_token_for_user(&form.username);
+ let (bearer, ti) = issue_token_for_user(&canon_user);
let ti_ev = Event::TokenIssued(ti);
// Persist events.
@@ -325,15 +324,18 @@ pub async fn post_pending_session(
State(state): State<AppState>,
Json(req): Json<PendingSessionStartRequest>,
) -> impl IntoResponse {
- if let Err(msg) = validate_agent_format(&req.agent) {
- return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)).into_response();
- }
+ let agent_naked = match parse_agent(&req.agent) {
+ Ok(a) => a,
+ Err(msg) => {
+ return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)).into_response();
+ }
+ };
let session = format!("p_{}", uuid::Uuid::new_v4().simple());
let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string());
let login_url = format!("{public_url}/auth/login?session={}", urlencoding::encode(&session));
let poll_url = format!("/api/v0/pending-session/{}", session);
let s = PendingSession {
- agent: req.agent.clone(),
+ agent: agent_naked,
created_ts: now_ms(),
provider: None,
provider_id: None,
@@ -359,7 +361,7 @@ pub async fn get_pending_session(
return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response();
};
let (complete, user, token) = match &s.complete {
- Some((u, t)) => (true, Some(format!("@{}", u)), Some(t.clone())),
+ Some((u, t)) => (true, Some(u.clone()), Some(t.clone())),
None => (false, None, None),
};
Json(PendingSessionPollResponse {
@@ -388,7 +390,7 @@ pub async fn get_whoami(State(state): State<AppState>, headers: HeaderMap)
… preview truncated; 62,772 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.