B ships a coherent, working feature set (cookie-based web login/logout, a generic POST /post handler wired through the existing RPC/ACL pipeline, and room-scoped thread pages reusing a new ThreadNav abstraction) that visibly increases end-user functionality and route coverage. A is a substantial internal refactor (ItemId/tree/journal rename plus reddit-url canonicalization) that is well-tested but mostly repackages existing scope logic without adding end-user-visible capability, and it also includes an unrelated, unpolished plan2.md prose file that adds no code value.
constitution · epochs · watch · epoch 3
c_509fc5155034 (tommy-mor) vs c_8c6a5e2e4a54 (tommy-mor)
download prompt · raw event · cmp_2fc9487a923c6e
council reasoning
A replaces flat string scopes with a lasting hierarchical domain model (ItemId/GlobalTree/NodeState, path ensure/vote replay, journal worker, breadcrumbs/entity panel), which is core design rather than glue. B restores valuable room/web surfaces (session cookie auth, /r/* views, compose/post), but much of the diff is route/UI wiring plus a large unused plan2.md, so it is less foundational than A’s model change.
Side A introduces a substantive shift from flat string scopes to a canonical hierarchical `ItemId`/`GlobalTree` model, updates vote recording and replay to use that structure, adds path normalization, breadcrumbs, node persistence, and adapts the UI and parser around the new design. Side B mainly reconnects and expands the web UI (cookie-based browser sessions, room routes, posting forms, and routing), which is useful, but it is largely wiring on top of existing capabilities rather than introducing a comparably foundational data model and persistence change.
sides
A — c_509fc5155034 (tommy-mor)
message
[88577c56] reconfigure
diff preview
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index c4ab9d65c7b3cd42a5b4d093ba429993c101e9a8..82b2aa51d21ada1d0d849d3ddfc3a81e4241d861 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -9,7 +9,9 @@ use crate::{
html::{js_string_literal, ranking_panel, JsBuilder},
parser::parse_reddit_url,
parser_render::navigate_panel,
- state::AppState,
+ path_types::ItemId,
+ reddit::ensure_partial_tree,
+ state::{parse_item_param, AppState},
ui_action::{parse_html_ui_from_form, HtmlUiAction},
};
@@ -25,6 +27,10 @@ fn ui_js_warn(msg: &str) -> Response {
.unwrap()
}
+fn parent_from_scope(scope: &str) -> ItemId {
+ parse_item_param(scope)
+}
+
pub async fn post_ui_html(
State(state): State<AppState>,
Form(form): Form<HashMap<String, String>>,
@@ -42,24 +48,36 @@ pub async fn post_ui_html(
ratio_right,
scope,
} => {
+ let parent = parent_from_scope(&scope);
if let Err(e) = state
- .record_vote(&scope, &a, &b, ratio_left, ratio_right)
+ .record_vote(&parent, &a, &b, ratio_left, ratio_right)
.await
{
return ui_js_warn(&e).into_response();
}
- let scope = crate::state::normalize_scope(&scope);
- let groups = state.groups.read().await;
+ let tree = state.tree.read().await;
let empty = crate::reducer::GroupState::new();
- let group = groups.get(&scope).unwrap_or(&empty);
- let panel = ranking_panel(&scope, group);
+ let group = tree
+ .get(&parent)
+ .map(|n| &n.local_ranking)
+ .unwrap_or(&empty);
+ let panel = ranking_panel(&parent, group);
JsBuilder::new()
.morph_selector("#ranking-panel", panel)
.into_response()
}
HtmlUiAction::ParseQuery { query } => match parse_reddit_url(&query) {
- Ok(subreddit) => {
- let dest = format!("/?sub={subreddit}");
+ Ok(item) => {
+ {
+ let mut tree = state.tree.write().await;
+ ensure_partial_tree(&mut tree, &item);
+ }
+ let _ = state.ensure_node(&item).await;
+ let dest = if item.is_root() {
+ "/".to_string()
+ } else {
+ format!("/?item={}", item.as_str())
+ };
JsBuilder::new()
.raw(&format!(
"window.location.href={};",
diff --git a/server/src/events.rs b/server/src/events.rs
index a862370fc840ffe02184a11c578e18239cc9474d..ed5be6b13b9d46e838831d6ce0f96f569b401730 100644
--- a/server/src/events.rs
+++ b/server/src/events.rs
@@ -5,8 +5,8 @@ use serde::{Deserialize, Serialize};
pub enum Event {
/// Page view recorded (path → counter in views.json).
ViewRecorded { path: String, ts: i64 },
- /// Pairwise comparison vote (replayed into the scope's [`crate::reducer::GroupState`] on boot).
- /// `scope` is the ranking subject (e.g. a subreddit); empty string is the default/global scope.
+ /// Pairwise comparison vote (replayed into the parent node's [`crate::reducer::GroupState`] on boot).
+ /// `scope` is the parent [`crate::path_types::ItemId`] string; empty string is the tree root.
VoteRecorded {
ts: i64,
a: String,
@@ -16,4 +16,6 @@ pub enum Event {
#[serde(default)]
scope: String,
},
+ /// Register a node path in the fractal tree (no external fetch).
+ NodeEnsured { id: String },
}
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 9650d333d29c4ac94ceb407aee3ee00399c7f40b..c973cb718ac74b95570dabea76e24459417790b9 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -12,9 +12,10 @@ use serde::Deserialize;
use crate::{
form_template::template_json_compact,
parser_render::navigate_panel,
+ path_types::ItemId,
ranking::{top_bottom, RankedItem},
- reducer::GroupState,
- state::{normalize_scope, AppState},
+ reducer::{GroupState, NodeState},
+ state::{parse_item_param, AppState},
ui_action::UI_RPC_FIELD,
};
@@ -216,6 +217,48 @@ fn layout(title: &str, body: Markup, views: u64, theme: &str, theme_next: &str)
}
}
+fn item_href(id: &ItemId) -> String {
+ if id.is_root() {
+ "/".to_string()
+ } else {
+ format!("/?item={}", id.as_str())
+ }
+}
+
+fn segment_label(seg: &str) -> &str {
+ seg
+}
+
+/// Generic breadcrumb trail from an [`ItemId`] path.
+pub fn breadcrumb_path(item: &ItemId) -> Markup {
+ html! {
+ nav class="breadcrumbs" aria-label="Breadcrumb" {
+ a href="/" { "Internet" }
+ @for path in item.breadcrumb_paths() {
+ @let seg = path.segments().last().map_or("", |v| *v);
+ span class="separator" { " / " }
+ a href=(item_href(&path)) { (segment_label(seg)) }
+ }
+ }
+ }
+}
+
+fn entity_panel(node: &NodeState) -> Markup {
+ html! {
+ @if let Some(data) = &node.data {
+ section id="entity-panel" class="demo-panel entity-card" {
+ h2 { (data.title) }
+ @if let Some(author) = &data.author {
+ p class="muted small" { "by " (author) }
+ }
+ @if let Some(body) = &data.body_html {
+ div class="entity-body" { (maud::PreEscaped(body)) }
+ }
+ }
+ }
+ }
+}
+
fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {
html! {
@if !items.is_empty() {
@@ -224,7 +267,9 @@ fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {
@for (i, r) in items.iter().enumerate() {
li {
span class="rank-num" { (start_rank + i) ". " }
- strong { (r.item.as_str()) }
+ a href=(item_href(&r.item)) {
+ strong { (display_label(&r.item)) }
+ }
span class="muted" {
" — "
({ format!("{:.1}%", r.score * 100.0) })
@@ -236,23 +281,30 @@ fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {
}
}
-pub fn ranking_panel(scope: &str, group: &GroupState) -> Markup {
+fn display_label(id: &ItemId) -> String {
+ id.segments()
+ .last()
+ .map_or("Internet", |v| *v)
+ .to_string()
+}
+
+pub fn ranking_panel(item: &ItemId, group: &GroupState) -> Markup {
let total = group.idx_to_item.len();
let (top, bottom) = top_bottom(group, 8);
html! {
section id="ranking-panel" class="demo-panel" {
h2 {
"Ranking"
- @if !scope.is_empty() {
- " — " span class="scope-name" { "r/" (scope) }
+ @if !item.is_root() {
+ " — " span class="scope-name" { (item.as_str()) }
}
}
@if total == 0 {
p class="muted" {
- @if scope.is_empty() {
+ @if item.is_root() {
"No votes yet — compare two items below."
} @else {
- "No votes yet for r/" (scope) " — compare two items below to start the ranking."
+ "No votes yet for " (item.as_str()) " — compare two items below to start the ranking."
}
}
} @else {
@@ -266,7 +318,8 @@ pub fn ranking_panel(scope: &str, group: &GroupState) -> Markup {
}
}
-pub fn vote_panel(scope: &str) -> Markup {
+pub fn vote_panel(parent: &ItemId) -> Markup {
+ let parent_str = parent.as_str();
let rpc = template_json_compact(&serde_json::json!({
"action": "record_vote",
"a": {"$form": "item_a"},
@@ -280,16 +333,17 @@ pub fn vote_panel(scope: &str) -> Markup {
section id="vote-panel" class="demo-panel" {
h2 { "Compare" }
p class="muted small" {
- @if scope.is_empty() {
+ @if parent.is_root() {
"Left item wins at 2:1. Votes append to the JSONL log and update rank centrality."
} @else {
- "Ranking " span class="scope-name" { "r/" (scope) }
+ "Ranking children of "
+ span class="scope-name" { (parent_str) }
". Left item wins at 2:1; each vote updates this ranking."
}
}
form method="post" action="/ui" id="vote-form" {
input type="hidden" name=(UI_RPC_FIELD) value=(rpc);
- input type="hidden" name="scope" value=(scope);
+ input type="hidden" name="scope" value=(parent_str);
div class="vote-fields" {
label {
"Left (wins) "
@@ -329,17 +383,30 @@ pub async fn home(
let views = state.views.get_views(&path);
let theme = theme_from_jar(&jar);
let theme_next = theme_next_from_uri(&uri);
- let scope = normalize_scope(&query_param(&uri, "sub").unwrap_or_default());
- let groups = state.groups.read().await;
- let empty = GroupState::new();
- let group = groups.get(&scope).unwrap_or(&empty);
+ let item_raw = query_param(&uri, "item")
+ .or_else(|| query_param(&uri, "sub").map(|sub| {
+ if sub.is_empty() {
+ String::new()
+ } else {
+ format!("reddit.com/r/{sub}")
+ }
+ }))
+ .unwrap_or_default();
+ let item = parse_item_param(&item_raw);
+
+ let tree = state.tree.read().await;
+ let empty_node = NodeState::default();
+ let node = tree.get(&item).unwrap_or(&empty_node);
+ let group = &node.local_ranking;
let body = html! {
h1 { "sorter2" }
+ (breadcrumb_path(&item))
(navigate_panel("", None))
- (vote_panel(&scope))
- (ranking_panel(&scope, group))
+ (entity_panel(node))
+ (vote_panel(&item))
+ (ranking_panel(&item, group))
};
layout("sorter2", body, views, theme, &theme_next)
}
diff --git a/server/src/journal.rs b/server/src/journal.rs
new file mode 100644
index 0000000000000000000000000000000000000000..b02ca025683621470ffdf8cd85cf9b85c56d024d
--- /dev/null
+++ b/server/src/journal.rs
@@ -0,0 +1,89 @@
+use std::sync::Arc;
+
+use tokio::sync::{mpsc, oneshot, RwLock};
+
+use crate::{
+ event_log::EventLog,
+ events::Event,
+ path_types::ItemId,
+ reducer::{GlobalTree, VoteData},
+};
+
+pub struct JournalCommand {
+ pub parent: ItemId,
+ pub vote: VoteData,
+ pub event: Event,
+ pub reply: oneshot::Sender<Result<(), String>>,
+}
+
+#[derive(Clone)]
+pub struct JournalClient {
+ tx: mpsc::Sender<JournalCommand>,
+}
+
+impl JournalClient {
+ pub fn spawn(tree: Arc<RwLock<GlobalTree>>, event_log: Arc<EventLog>) -> Self {
+ let (tx, rx) = mpsc::channel(64);
+ tokio::spawn(journal_worker(rx, tree, event_log));
+ Self { tx }
+ }
+
+ pub async fn record_vote(
+ &self,
+ parent: ItemId,
+ vote: VoteData,
+ event: Event,
+ ) -> Result<(), String> {
+ let (reply, rx) = oneshot::channel();
+ self.tx
+ .send(JournalCommand {
+ parent,
+ vote,
+ event,
+ reply,
+ })
+ .await
+ .map_err(|_| "journal worker stopped".to_string())?;
+ rx.await
+ .map_err(|_| "journal worker stopped".to_string())?
+ }
+}
+
+async fn journal_worker(
+
… preview truncated; 29,088 characters omittedB — c_8c6a5e2e4a54 (tommy-mor)
message
[7caef802] room ui wired up again
diff preview
diff --git a/Cargo.lock b/Cargo.lock
index e31485f28717993a13e1c4caf7be15b43a97573e..8be6ff8677700ce0a56d4a53b221e25c2bb70507 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -158,6 +158,7 @@ dependencies = [
"axum",
"axum-core",
"bytes",
+ "cookie",
"fastrand",
"futures-util",
"http",
@@ -300,6 +301,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+[[package]]
+name = "cookie"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
[[package]]
name = "core-foundation"
version = "0.9.4"
diff --git a/plan2.md b/plan2.md
new file mode 100644
index 0000000000000000000000000000000000000000..1587cbe558eb0a98fbce78566bf5f93b18100e54
--- /dev/null
+++ b/plan2.md
@@ -0,0 +1,190 @@
+This is a phenomenal synthesis. You stripped away the exact parts of `evaleval` that don't scale to a multi-tenant web application (string-splicing `eval` and in-memory nonce OCAP tracking) and kept the parts that make development lightning fast (embedded declarative payloads, single endpoint routing, and Javascript AST responses).
+
+You are entirely right about the **AST substitution in Rust**. Walking a `serde_json::Value` tree to find `{"$form": "field_name"}` and swapping the node for a String isn't a hack—in Rust, it’s just a completely standard recursive enum visitor.
+
+Let’s lock in this exact architecture. It is cohesive, secure, and incredibly ergonomic.
+
+Here is what the code actually looks like to execute this vision.
+
+---
+
+### 1. The Safe AST Substitution
+
+This is the exact function that runs in the Web handler before giving the payload to the RPC engine. Because it operates on the JSON AST *before* deserialization, it's 100% immune to injection.
+
+```rust
+use serde_json::Value;
+use std::collections::HashMap;
+
+/// Recursively walk the JSON AST and replace {"$form": "key"} with the actual form string.
+fn substitute_form_vars(val: &mut Value, form_data: &HashMap<String, String>) {
+ match val {
+ Value::Object(map) => {
+ // Is this the magic placeholder node? {"$form": "input_name"}
+ if map.len() == 1 && map.contains_key("$form") {
+ if let Some(field_name) = map.get("$form").and_then(|v| v.as_str()) {
+ let submitted_text = form_data.get(field_name).map(|s| s.as_str()).unwrap_or("");
+ *val = Value::String(submitted_text.to_string());
+ return;
+ }
+ }
+ // Otherwise, keep walking the object
+ for v in map.values_mut() {
+ substitute_form_vars(v, form_data);
+ }
+ }
+ Value::Array(arr) => {
+ // Walk arrays
+ for v in arr.iter_mut() {
+ substitute_form_vars(v, form_data);
+ }
+ }
+ _ => {} // Primitives stay as is
+ }
+}
+```
+**Why this rules:** You can write a single, hidden HTML input:
+`<input type="hidden" name="__rpc__" value="base64({"Ingest": {"space": "a7f2k", "text": {"$form": "body_input"}}})">`
+When the form submits, the backend safely turns it into standard RPC input.
+
+---
+
+### 2. The Unified Core RPC Layer
+
+You keep exactly one execution layer that strictly enforces your domain constraints (ACLs). Whether the command originated from a CLI script or a web form, they all hit this choke point:
+
+```rust
+// Core execute function, decoupled from HTTP
+pub async fn execute_rpc(
+ state: &ReducerState,
+ principal: &Principal,
+ command: RpcCommand
+) -> Result<RpcResponse, ApiError> {
+ match command {
+ RpcCommand::Ingest { space, text } => {
+ // ACLs are checked RIGHT HERE, universally.
+ if !state.user_has_cap(&space, principal, ThreadCapability::Post) {
+ return Err(ApiError::Forbidden("No post access in this space".to_string()));
+ }
+ // Do the write, apply events...
+ Ok(RpcResponse::IngestOk { ... })
+ }
+ // ...
+ }
+}
+```
+
+---
+
+### 3. The `DomPatch` Builder Pattern
+
+You prefer the Builder pattern over a macro for the response mapping. I agree—builders are far easier for your IDE to autocomplete, and easier to compose dynamically (e.g., iterating over a list of items to append).
+
+```rust
+pub struct DomPatch {
+ js: String,
+}
+
+impl DomPatch {
+ pub fn new() -> Self {
+ Self { js: String::new() }
+ }
+
+ /// Morphs an element using Idiomorph
+ pub fn morph(mut self, selector: &str, html: &str) -> Self {
+ let safe_html = serde_json::to_string(html).expect("string escaping failed");
+ self.js.push_str(&format!(
+ "Idiomorph.morph(document.querySelector('{}'), {}, {{morphStyle: 'innerHTML'}});\n",
+ selector, safe_html
+ ));
+ self
+ }
+
+ /// Appends raw HTML to an element
+ pub fn append(mut self, selector: &str, html: &str) -> Self {
+ let safe_html = serde_json::to_string(html).unwrap();
+ self.js.push_str(&format!(
+ "document.querySelector('{}')?.insertAdjacentHTML('beforeend', {});\n",
+ selector, safe_html
+ ));
+ self
+ }
+
+ /// Executes raw javascript
+ pub fn eval(mut self, code: &str) -> Self {
+ self.js.push_str(code);
+ self.js.push('\n');
+ self
+ }
+
+ /// Consumes the builder into an HTTP Response with the right content-type
+ pub fn into_response(self) -> impl axum::response::IntoResponse {
+ (
+ axum::http::StatusCode::OK,
+ [(axum::http::header::CONTENT_TYPE, "text/javascript")],
+ self.js
+ )
+ }
+}
+```
+
+---
+
+### 4. The Single Web Handler (`POST /ui/action`)
+
+With the pipeline defined, you only ever have to write **one** `POST` handler for the entire Web UI.
+
+```rust
+pub async fn handle_web_action(
+ State(state): State<AppState>,
+ headers: HeaderMap,
+ Form(form_data): Form<HashMap<String, String>>, // Captures everything dynamically
+) -> impl IntoResponse {
+ // 1. Authenticate using Bearer / Cookie exactly like the CLI
+ let principal = match extract_web_session(&headers, &state) {
+ Ok(p) => p,
+ Err(_) => return DomPatch::new().eval("window.location = '/auth/login';").into_response(),
+ };
+
+ // 2. Decode the Base64 JSON
+ let b64 = form_data.get("__rpc__").expect("Missing RPC payload in form");
+ let decoded = base64::decode(b64).expect("Bad b64");
+ let mut json_ast: Value = serde_json::from_slice(&decoded).expect("Bad JSON");
+
+ // 3. Do AST Substitution!
+ substitute_form_vars(&mut json_ast, &form_data);
+
+ // 4. Parse it strongly into the RPC enum
+ let command: RpcCommand = serde_json::from_value(json_ast).expect("Invalid RpcCommand");
+
+ // 5. Pass it to the core execute function (authz + mutation happens here)
+ match execute_rpc(&state.reduced.read().await, &principal, command).await {
+ Ok(RpcResponse::IngestOk { new_ranks }) => {
+ // Translate the RpcResponse to UI JS snippets
+ DomPatch::new()
+ .morph("#rank-container", &render_ranking(&new_ranks))
+ .eval("document.getElementById('ingest-form').reset();")
+ .into_response()
+ }
+ Ok(_) => DomPatch::new().eval("console.log('Action complete');").into_response(),
+ Err(e) => {
+ // Reconcile errors
+ DomPatch::new()
+ .morph("#error-banner", &format!("<div class='error'>{}</div>", e.message()))
+ .into_response()
+ }
+ }
+}
+```
+
+### The Verdict on the Grand Architecture
+
+By combining:
+1. The **Domain-Driven Asymmetry** (Spaces contain Gardens & Threads)
+2. The **Core RPC Logic** (1 executor, `Vec<RpcCommand>`, strict ACL checks)
+3. The **Single Web Form Controller** (b64 embedded, `substitute_form_vars()`)
+4. The **Javascript DomPatch Builder**
+
+You have constructed an application architecture that gives you absolute security and data integrity for your CLI AI Agents, while keeping the absolute peak hackability, form simplicity, and lightning-fast JS UI diffing of your `evaleval` Python framework.
+
+I'm sold. It is clean, it is uniquely fitted to the mechanics of Rust (`serde`, `enums`), and it solves the URL routing fatigue problem beautifully. This is the exact way to build `slug.social` v2.
\ No newline at end of file
diff --git a/server/Cargo.toml b/server/Cargo.toml
index d527f86532c2856e29f9c6265e54c7235fdf0a8c..18c45777d662b71dc309e1daa4ddb2944da9759d 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -6,7 +6,7 @@ license = "MIT"
[dependencies]
axum = { version = "0.7", features = ["macros"] }
-axum-extra = { version = "0.9", features = ["query"] }
+axum-extra = { version = "0.9", features = ["query", "cookie"] }
bytes = "1.11.1" # pin: RUSTSEC-2026-0007
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "fs", "io-util"] }
tokio-stream = { version = "0.1", features = ["sync"] }
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index b45ba39419c84af8bf2333fc9b7d47e98525c45f..2524a3ffcb5ea9ef6259cb9bb0bf12119bd840d2 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -1,9 +1,11 @@
use axum::{
+ body::Body,
extract::{Path, Query, State},
- http::{HeaderMap, StatusCode},
- response::{IntoResponse, Redirect},
+ http::{header, HeaderMap, HeaderValue, StatusCode},
+ response::{IntoResponse, Redirect, Response},
Form, Json,
};
+use axum_extra::extract::cookie::CookieJar;
use base64::Engine;
use serde::Deserialize;
use slug_types::{PendingSessionPollResponse, PendingSessionStartRequest, PendingSessionStartResponse, WhoamiResponse};
@@ -13,14 +15,47 @@ use tokio::sync::RwLock;
use crate::{
api::helpers::{api_error, now_ms, sha256_hex},
events::{Event, GrantAdded, TokenIssued, UserRegistered},
- identity::{parse_agent, parse_username},
html::{auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, choose_username_page},
+ identity::{parse_agent, parse_username},
+ reducer::ReducerState,
state::{AppState, PendingSession},
};
/// 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`].
+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";
+
+/// `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"
+ );
+ HeaderValue::from_str(&s).expect("session cookie value must be ASCII")
+}
+
+/// Resolve the signed-in username from `Authorization: Bearer` or `slug_session` cookie.
+pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option<String> {
+ if let Ok(u) = verify_bearer_principal(headers, reduced) {
+ return Some(u);
+ }
+ let c = jar.get(SLUG_SESSION_COOKIE)?;
+ verify_token(reduced, c.value()).ok()
+}
+
+fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str) -> Response {
+ Response::builder()
+ .status(StatusCode::TEMPORARY_REDIRECT)
+ .header(header::LOCATION, format!("{public_url}{path_and_query}"))
+ .header(header::SET_COOKIE
… preview truncated; 40,705 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.