From 328d2eb3a84f0e7737e9e00cc3c5c244be18176b Mon Sep 17 00:00:00 2001 From: Paul Miller Date: Wed, 4 Nov 2026 13:36:30 -0600 Subject: [PATCH] A person's approved key opens a fragment's pages: POST /api/f/{name}/session A native app has no browser session. Its approved NIP-98 key now asks for the same one-use, one-fragment __signin redemption /auth/fragment mints for a browser, with the same consent: silent on the person's own and shared fragments, asked first on anyone else's, the yes remembered. The registry mints it from a parent session of the key's own (one a key, sessions.device_key, schema step 9). Its token is never kept, so it is no bearer session. Each site session redeemed from it, and each unspent redemption, lives only while the key is still its person's: revoking the key ends them and nothing else. An agent's key is refused (403). The new `native` e2e section covers refusals, one use, wrong fragment, consent, and revocation isolation; restart checks a key's site session outlives a restart. Co-Authored-By: Claude Opus 5.5 --- SPECIAL-CASE-INVENTORY.md | 2 +- cell/src/auth.rs | 81 ++++++++++--- cell/src/lib.rs | 3 + cell/src/registry/calls.rs | 10 +- cell/src/registry/signin.rs | 68 ++++++++++- .../migrations/registry/0009_key_sessions.sql | 7 ++ crates/core/migrations/schema.lock | 4 +- crates/core/src/migrate.rs | 1 + crates/e2e/src/lanes/mod.rs | 4 +- crates/e2e/src/lanes/native.rs | 114 ++++++++++++++++++ crates/e2e/src/lanes/restart.rs | 4 + docs/MODEL.md | 2 +- docs/api.md | 1 + 13 files changed, 276 insertions(+), 25 deletions(-) create mode 100644 crates/core/migrations/registry/0009_key_sessions.sql create mode 100644 crates/e2e/src/lanes/native.rs diff --git a/SPECIAL-CASE-INVENTORY.md b/SPECIAL-CASE-INVENTORY.md index 8882d6ce3..d3cbe05a2 100644 --- a/SPECIAL-CASE-INVENTORY.md +++ b/SPECIAL-CASE-INVENTORY.md @@ -14,7 +14,7 @@ a vendor's product beyond the credential it holds. | Surface | Why it is platform | Built on | |---|---|---| -| Sign-in, sessions, CLI approval (`/auth/*`, `/cli`; the shell's tabs sign in through `/auth/frame`; a stranger's fragment asks first, at `/auth/fragment`) | Security: it mints the credentials every other surface trusts. Which of a browser's cookies count on a fragment's origin is the router's (Fetch Metadata), so another fragment's page reaches it as a stranger (docs/api.md, Sign-in). | WorkOS AuthKit; the registry | +| Sign-in, sessions, CLI approval (`/auth/*`, `/cli`; the shell's tabs sign in through `/auth/frame`; a stranger's fragment asks first, at `/auth/fragment`; a person's approved key, for an app with no browser, at `POST /api/f/{name}/session`) | Security: it mints the credentials every other surface trusts. Which of a browser's cookies count on a fragment's origin is the router's (Fetch Metadata), so another fragment's page reaches it as a stranger (docs/api.md, Sign-in). | WorkOS AuthKit; the registry | | The shell (`/`, `/settings`, `/apps`, `/history`, `/welcome`): sidebar, desktop (its dock and windows), settings, History (every chat you have had, archived and imported ones too, and its search), the Apps page (each of your fragments but chats and agents: your dock, its card, its icon, who has access and its share sheet; one page, as it acts as you over all of them, which no fragment may), the launcher (search), first run (the welcome); your Session Archive (your one fragment of kind `sessions`: its chats' index listed in History, each opened read-only in the middle column, its own page framed as a chat is; its Continue, asked by that page and done by the shell as you, makes a chat with the agent of yours you pick (one a conversation and agent) whose first record is the conversation's history, and does the same for an archived chat of yours: templates/sessions/README.md, docs/chat-records.md); to a page of a fragment you own that asks, your agents, and adding one of them to that fragment; to your own chat's page, a way to ask for an app put in its composer (`compose`; docs/api.md, The shell); to a chat's page of yours, its alerts, which no frame may ask the browser for (the shell's alerts worker, one subscription a chat, made with the chat's own key); what your agent is doing, which it reads from your chats' rows' listed records by the chat's turn kinds (docs/chat-records.md): the shell is that contract's third reader, beside the bridge and the chat's page, so a change to it changes the shell too | Security: it holds your session and frames your fragments and computers. The thin page users can't break. A page asks it, never grants: the shell checks the page is your own fragment's, at its own origin, offers only your own agents, and adds one only once you confirm it in the shell's own dialog, every time. A chat's page takes `compose` only from the platform's origin (its hello's `platform`) and sends nothing: you do. | The public fragment, computer, identity and ledger APIs | | Identity and delegation | Security: who an agent acts for, and at what role. | The registry | | Optional public addresses (`GET`/`PUT /api/f/{name}/address`) | Security: first-come reservations coordinate across fragments and permanently bind an origin to one app identity, so a delete never gives another app its cookies or storage. | The registry and each fragment's own identity and owner | diff --git a/cell/src/auth.rs b/cell/src/auth.rs index 335e1a396..97c909f99 100644 --- a/cell/src/auth.rs +++ b/cell/src/auth.rs @@ -433,7 +433,7 @@ pub async fn platform(mut req: Request, env: &Env, ctx: &Context, cfg: &Config, let (name, back) = fragment_asked(url)?; let signed_out = || to_login(&platform, &format!("/auth/fragment?name={name}&return={}{}", enc(&back), address_query(cfg, url))); let Some(token) = cookie_of(&req, SESSION_COOKIE, secure(url), "/")? else { return signed_out() }; - match mint_for(env, url, &token, &name, &back, None).await? { + match mint_for(env, url, Minter::Session(&token), &name, &back, None, false).await? { Minting::Redeem(redeem) => to_signin(env, cfg, url, &name, &redeem).await, Minting::Ask(who, email) => consent_page(&token, &who, email.as_deref(), &name, &back, &address_query(cfg, url)), Minting::SignedOut => signed_out(), @@ -445,7 +445,7 @@ pub async fn platform(mut req: Request, env: &Env, ctx: &Context, cfg: &Config, Ok(posted) => posted, Err(page) => return Ok(page), }; - let given = calls::Mint { token: session, fragment: name.clone(), return_to: back, consent: Consent::Given, embedder: None }; + let given = calls::Mint { token: Some(session), key: None, fragment: name.clone(), return_to: back, consent: Consent::Given, embedder: None }; let minted = ask_registry(env, &given).await?; Ok(to_signin(env, cfg, url, &name, minted.redeem.as_deref().ok_or_else(|| CellError::host("a given mint answered no redemption"))?).await?.with_status(303)) } @@ -633,25 +633,78 @@ async fn to_signin(env: &Env, cfg: &Config, url: &Url, name: &str, redeem: &str) redirect(&format!("{base}__signin?token={redeem}"), &[]) } -/// What a platform session may sign a browser in to a fragment with. +/// `POST /api/f/{name}/session`'s body: `{returnTo?, consent?}`. +const KEY_SESSION_MAX_BYTES: usize = 4096; + +/// `POST /api/f/{name}/session`: a person's approved key (a native app's, +/// which holds no browser session) opens a fragment's pages in a web view +/// of its own, as `/auth/fragment` decides for a browser (`mint_for`): a +/// fragment of theirs, shared with them, or one they said yes to answers a +/// one-use URL of its `__signin`; any other answers that they are asked +/// first (`consentRequired`), until they ask again with `consent: true`, the +/// yes the page's form gives. Only that fragment's cookie ever enters the +/// web view: never the key, nor a platform session. +pub(crate) async fn key_session(mut req: Request, env: &Env, cfg: &Config, url: &Url, name: &str) -> CellResult { + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct Asked { + #[serde(default)] + consent: bool, + return_to: Option, + } + if !fragment_proto::valid_fragment_name(name) { + return Err(CellError::invalid("name a fragment: