Joystick consent: ask page, consent_refused, receipts, signed-in grants page with revoke #5

Merged
heimdall merged 10 commits from heimdall/joystick-consent-flow-design into main 2026-11-08 13:45:59 +00:00
Member

Builds the consent idea in docs/joystick-consent-flow.md (rulings in §4). Updated after the review of head 48207268.

What it does

  • Managed custody (callbackMint === custody_only) stops at a Heimdall ask page before the provider redirect. The ask sets its own cookie; only Continue/No accept it. The launch binding is set only when Continue claims the launch, and carries the exact text shown to the callback.
  • consent_refused (by: viewer|provider) for a decline or a provider access_denied.
  • consent_receipts (append-only, SELECT+INSERT): grant rows hold scopes asked/granted, the wording text shown and its sha-256; a revocation is a second row, unique per grant.
  • GET /v1/me/grants, POST /v1/me/grants/link, POST /v1/me/grants/:id/revoke: list every grant and receipt, one-click revoke strips the credential so the next managed-credentials/resolve answers 410. Page links keep their original 10-minute expiry.

Review fixes in this push

  1. High: the ask no longer sets the launch binding (a stranger holding the launch URL could become the state's owner). Attacker probe is a test.
  2. Grants-link form tokens keep the link's exp.
  3. Contract change, see below.
  4. Wording is built at the ask, carried in the binding, stored with the receipt; button labels are in the text. A custody callback with no shown text exchanges no code and stores nothing.
  5. Unique partial index on revokes_receipt_id; revoke reports only what it wrote and audits once.

Contract change (decide before deploy): error=access_denied is now consent_refused for every provider. Visible to apps three ways: the callback JSON/HTML result, the backend_callback payload (error: "consent_refused", by), and the auth attempt's denialCode, which completeAuth in the private command plane returns as the denied error to Bifrost, Repixelizer and Ghostlight. Pinned by tests/oauth-state-binding.test.ts for Bifrost and Repixelizer (Ghostlight uses the same attempt path; it has no test of its own). Other provider errors stay provider_error.

Verification (8 GB ulimit -v, one vitest worker): consent, drift, store and state-binding files green (66+ tests); whole suite 520 pass / 19 fail. The 19 are entrypoint-secrets (fails identically on origin/main); 12 further files cannot load because the vendored cultnet/cultcache/cultmesh packages are not built here.
Mutants, each alone with timeout 300: first round 18/18 killed; review-fix round F1a ask sets binding, F1b no ask cookie needed, F1c shown text dropped, F2 fresh link expiry, F3 denial code reverts, F4a text rebuilt at callback, F4b labels omitted, F4c custody without text, F5a second revocation allowed, F5b revoke over-reports, F5c no unique index: 11/11 killed (F2 and F4c first survived; fixed the tests and the code, then killed).

Fix run on head f7a38a7 (rulings from Eureka's review of 5ce8f1c)

  1. inflateRawSync in cookieConsentText is capped at 16 KB (maxOutputLength); a bomb cookie is refused fast.
  2. A custody callback whose binding has no ct answers 400 consent_text_missing before the state is claimed.
  3. requestedScopes capped at 32 items of 512 chars; over-cap is requested_scopes_too_large at the ask. An ask the cookies cannot carry (wording > 16 KB or cookie > 3800 chars) is refused consent_ask_too_large.
  4. consent_receipts has UNIQUE(id, account_id) and a composite FK (revokes_receipt_id, account_id) to it, so a revocation names a receipt of its own account; checkSchema counts 17 keys; the memory store refuses the same.
    Addendum: the per-index predicate test now checks both partial indexes; exerciseStore revokes a grant twice; a live case boots with consent_receipts dropped; scripts/test-live-pg.sh does its prerequisites (submodule, vendor install and dist builds, pnpm install).
    Mutants, each alone under the 8 GB cap and 300 s: inflate uncapped / cap enlarged, text-missing check off, scope count and char caps (off-by-one, >=, check off), memory-store account check dropped, FK dropped, FK without account, UNIQUE(id, account_id) dropped: all killed. Not run: mutants of the two live cases (no Postgres here). Full suite 524 pass / 19 fail, the same environmental 19 as before.

Gaps

  • Postgres: initdb is not installed in this body, so scripts/test-live-pg.sh was not run. The consent_receipts SQL (including ON CONFLICT DO NOTHING against the partial unique index and the read-back SELECT) has only run against the fake pool. Run it before deploy. The schema is derived into checkSchema (112 columns, 17 keys); with applySchemaOnStartup off, the table must exist before boot or the start refuses.
  • systemd-run scopes unavailable to this uid; mutants ran under ulimit and timeout, not MemoryMax.
  • The FK SQL (FOREIGN KEY (revokes_receipt_id, account_id) to UNIQUE(id, account_id)) and the new live cases (second revocation takes postgres.ts read-back; boot with consent_receipts missing refuses SCHEMA_MISSING) have not run on Postgres. Eureka runs scripts/test-live-pg.sh on the new head.
  • Wording unreviewed legal copy; Heimdall cannot withdraw the grant at the provider.

Not merged, not deployed.

🤖 Generated with Claude Code

Builds the consent idea in `docs/joystick-consent-flow.md` (rulings in §4). Updated after the review of head 48207268. **What it does** - Managed custody (`callbackMint === custody_only`) stops at a Heimdall ask page before the provider redirect. The ask sets its own cookie; only Continue/No accept it. The launch binding is set only when Continue claims the launch, and carries the exact text shown to the callback. - `consent_refused` (`by: viewer|provider`) for a decline or a provider `access_denied`. - `consent_receipts` (append-only, SELECT+INSERT): grant rows hold scopes asked/granted, the wording text shown and its sha-256; a revocation is a second row, unique per grant. - `GET /v1/me/grants`, `POST /v1/me/grants/link`, `POST /v1/me/grants/:id/revoke`: list every grant and receipt, one-click revoke strips the credential so the next `managed-credentials/resolve` answers 410. Page links keep their original 10-minute expiry. **Review fixes in this push** 1. High: the ask no longer sets the launch binding (a stranger holding the launch URL could become the state's owner). Attacker probe is a test. 2. Grants-link form tokens keep the link's exp. 3. Contract change, see below. 4. Wording is built at the ask, carried in the binding, stored with the receipt; button labels are in the text. A custody callback with no shown text exchanges no code and stores nothing. 5. Unique partial index on `revokes_receipt_id`; revoke reports only what it wrote and audits once. **Contract change (decide before deploy):** `error=access_denied` is now `consent_refused` for every provider. Visible to apps three ways: the callback JSON/HTML result, the `backend_callback` payload (`error: "consent_refused"`, `by`), and the auth attempt's `denialCode`, which `completeAuth` in the private command plane returns as the `denied` error to Bifrost, Repixelizer and Ghostlight. Pinned by `tests/oauth-state-binding.test.ts` for Bifrost and Repixelizer (Ghostlight uses the same attempt path; it has no test of its own). Other provider errors stay `provider_error`. **Verification** (8 GB `ulimit -v`, one vitest worker): consent, drift, store and state-binding files green (66+ tests); whole suite 520 pass / 19 fail. The 19 are `entrypoint-secrets` (fails identically on `origin/main`); 12 further files cannot load because the vendored cultnet/cultcache/cultmesh packages are not built here. Mutants, each alone with `timeout 300`: first round 18/18 killed; review-fix round F1a ask sets binding, F1b no ask cookie needed, F1c shown text dropped, F2 fresh link expiry, F3 denial code reverts, F4a text rebuilt at callback, F4b labels omitted, F4c custody without text, F5a second revocation allowed, F5b revoke over-reports, F5c no unique index: 11/11 killed (F2 and F4c first survived; fixed the tests and the code, then killed). **Fix run on head f7a38a7** (rulings from Eureka's review of 5ce8f1c) 1. `inflateRawSync` in `cookieConsentText` is capped at 16 KB (`maxOutputLength`); a bomb cookie is refused fast. 2. A custody callback whose binding has no `ct` answers 400 `consent_text_missing` before the state is claimed. 3. `requestedScopes` capped at 32 items of 512 chars; over-cap is `requested_scopes_too_large` at the ask. An ask the cookies cannot carry (wording > 16 KB or cookie > 3800 chars) is refused `consent_ask_too_large`. 4. `consent_receipts` has `UNIQUE(id, account_id)` and a composite FK `(revokes_receipt_id, account_id)` to it, so a revocation names a receipt of its own account; `checkSchema` counts 17 keys; the memory store refuses the same. Addendum: the per-index predicate test now checks both partial indexes; `exerciseStore` revokes a grant twice; a live case boots with `consent_receipts` dropped; `scripts/test-live-pg.sh` does its prerequisites (submodule, vendor install and dist builds, pnpm install). Mutants, each alone under the 8 GB cap and 300 s: inflate uncapped / cap enlarged, text-missing check off, scope count and char caps (off-by-one, `>=`, check off), memory-store account check dropped, FK dropped, FK without account, UNIQUE(id, account_id) dropped: all killed. Not run: mutants of the two live cases (no Postgres here). Full suite 524 pass / 19 fail, the same environmental 19 as before. **Gaps** - Postgres: `initdb` is not installed in this body, so `scripts/test-live-pg.sh` was not run. The `consent_receipts` SQL (including `ON CONFLICT DO NOTHING` against the partial unique index and the read-back `SELECT`) has only run against the fake pool. Run it before deploy. The schema is derived into `checkSchema` (112 columns, 17 keys); with `applySchemaOnStartup` off, the table must exist before boot or the start refuses. - `systemd-run` scopes unavailable to this uid; mutants ran under `ulimit` and `timeout`, not `MemoryMax`. - The FK SQL (`FOREIGN KEY (revokes_receipt_id, account_id)` to `UNIQUE(id, account_id)`) and the new live cases (second revocation takes `postgres.ts` read-back; boot with `consent_receipts` missing refuses `SCHEMA_MISSING`) have not run on Postgres. Eureka runs `scripts/test-live-pg.sh` on the new head. - Wording unreviewed legal copy; Heimdall cannot withdraw the grant at the provider. Not merged, not deployed. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Documents what a viewer sees today on the Joystick path and what consent
is missing, then drafts a visible ask, typed refusal and auditable receipt.
Design only; the test file is a skipped sketch and was not run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Managed custody (callbackMint custody_only) now stops at a Heimdall page before
the provider redirect. A decline or a provider access_denied is consent_refused
(by viewer|provider), distinct from provider_error. A granted completion writes
an append-only consent receipt with the asked and granted scopes and the digest
of the wording shown. GET /v1/me/grants lists every grant and receipt of the
signed-in account; revoking one strips the credential, so the app's next token
request is refused, and writes a revocation receipt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
heimdall changed title from WIP: Draft Joystick consent flow (design + skipped test sketch) to Joystick consent: ask page, consent_refused, receipts, signed-in grants page with revoke 2026-11-08 13:25:00 +00:00
The ask sets its own cookie and no launch binding; Continue sets the binding
and carries the exact text shown into it; the receipt stores that text and its
hash. Grants-page form tokens keep the link's expiry. Revocations are unique
per grant and revoke reports only what it wrote. access_denied's attempt
denial code is pinned for Bifrost and Repixelizer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Tests green locally; mutants not yet run; not pushed; PR body not updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
GameCult/Heimdall!5
No description provided.