Consent flow doc: steward rulings and the built design; pin repeat-revoke

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-08 13:24:48 +00:00
commit 48207268d0
2 changed files with 128 additions and 60 deletions

View file

@ -1,8 +1,10 @@
# Joystick sign-in and consent: what exists, what is missing, a draft flow
# Joystick sign-in and consent: what was missing, and the flow that fills it
Status: DRAFT design, no code. Source-grounded at `fefd2dd` (main). Follow-up
`heimdall-body:follow_up:chat-s3124-3129`. Not for merge until a Heimdall
steward rules on the open questions below.
Status: built (sections 3-6 describe the code on this branch); sections 1-2 are
the state of `fefd2dd` (main) that motivated it. Follow-up
`heimdall-body:follow_up:chat-s3124-3129`, built under
`heimdall-body:follow_up:chat-mind-20261108T131253147Z-t1`. The steward's rulings
on the five open questions are in section 4.
## 1. What a viewer sees today
@ -59,65 +61,129 @@ person in the browser:
| Receipt | Operator audit rows only | The viewer cannot audit what was agreed, when, or revoke it. |
| Viewer vs creator | The `connect` binding proves a binding exists, not that the browser's owner is the creator (`joystick:follow_up:connect-start-needs-app-auth`) | Consent from the wrong person is not consent. |
## 3. Draft consent flow
## 3. The consent flow
Applies to any Joystick completion (creator connect now; viewer sign-in when
the public bot lands) and, if ruled, to every provider whose state carries
`managed_credential`.
Applies to every completion `callbackMint` calls `custody_only`: a managed
creator connection (Joystick today; Twitch and YouTube on StreamPixels when
they are connected rather than signed in). Viewer sign-in through an identity
provider is unchanged.
### 3.1 The ask (visible, before the redirect)
### 3.1 The ask
`GET /v1/oauth/:provider/launch` stops answering `302`. It renders a Heimdall
page, **after** the state is verified but **before** the use is claimed, with:
`GET /v1/oauth/:provider/launch` renders a Heimdall page (`src/consent.ts`)
instead of answering `302`, after the state verifies and before any use is
claimed. The page names the app and the origin the person returns to, the
provider and the exact scopes Heimdall will send (`authorizationScopes`, the
same function that builds the authorization URL), what Heimdall keeps (an
encrypted refresh token and access token, until the person takes it back or the
provider ends it), what the app receives, and how to take it back. It carries
two POST forms to `/v1/oauth/:provider/consent`, **Continue to Joystick** and
**No, return to the app**, and runs no script. It sets the binding cookie, so
either answer can only come from the browser that was shown the ask; an answer
without the cookie is `state_not_bound` and spends nothing. Viewing the ask
claims nothing. Continue claims the launch use and answers `302`.
- the app by display name and return origin;
- the provider and the exact scope list that will be requested;
- what Heimdall keeps (a refresh token, encrypted, rotating) versus what the
app receives (no token; a claim) and for how long;
- two forms, both POST, CSRF-bound by the existing binding cookie:
**Continue to Joystick** and **No, return to the app**;
- a link to the receipt of any earlier consent for this account and app.
Only Continue claims the launch use and answers `302`. The page is plain
server-rendered HTML with no script, matching the handoff page's escape rule.
What the app receives, stated as the code does it: not a claim. The app
backend can call `managed-credentials/resolve` and receive the current access
token; it never receives the refresh token. The earlier draft said the app
receives "no token"; that was wrong and the page does not say it.
### 3.2 Typed refusal
**No** (and a provider `access_denied` on return) produces a distinct result
`consent_refused` with `{provider, app, mode, by: "viewer" | "provider"}`:
delivered to the app by the same handoff channel as any result, written as an
audit event `consent_refused`, no credential stored, state consumed. A provider
failure stays `provider_error`. The app is told not to ask again in the same
session; whether it may ask later is the app's rule, not Heimdall's.
A decline, and a provider `error=access_denied` on any provider, produce the
result `consent_refused` with `by: "viewer" | "provider"`: an audit event
`consent_refused` (provider, mode, by; no account, no person), the browser
attempt denied with `denialCode consent_refused`, both uses of the state spent,
a `backend_callback` app told with the same payload, a "Not Connected" page in
the browser, and `400 {error: "consent_refused", by}` as JSON. No credential is
stored and no receipt is written. Every other provider error stays
`provider_error`. This changes the `access_denied` answer for every provider,
including Bifrost's and Repixelizer's sign-in; see the PR for the contract note.
### 3.3 Receipt
On a granted completion Heimdall writes one `consent_receipt` row: receipt id
(random, non-guessable), account (or creator target), app, provider, scopes
asked, scopes granted, what Heimdall retains, `grantedAt`, `consentPageDigest`
(hash of the exact text shown, so a later wording change is visible), and a
`revokedAt` slot. The handoff page shows the receipt id and a "View receipt" link.
On a granted `custody_only` completion Heimdall writes one `consent_receipts`
row of kind `grant`: id (random), account, app, provider, scopes asked, scopes
granted (as the provider returned them), `consentPageDigest` (sha-256 of the
lines the page showed, so a wording change is visible), `createdAt`. The id
reaches the app (`consentReceiptId` in the completion and backend payloads) and
the person (the handoff page).
`GET /v1/receipts/:id` (viewer-bound by the same cookie or by the account's
access token) shows the row and offers **Revoke**, which calls the existing
managed-credential revocation and stamps `revokedAt`. Revoke is itself receipted.
Receipts are append-only; a revocation is a second row, not an edit.
Receipts are append-only: the store role has `SELECT` and `INSERT` on the table
and nothing else. A revocation is a second row of kind `revocation` naming the
grant it takes back; a grant is live exactly when no revocation names it. The
draft's `revokedAt` slot is gone for that reason.
## 4. Open questions for the steward
### 3.4 My grants
1. Who gets the ask: every provider, or only `managed_credential` ones? (Recommend: only managed custody, where Heimdall holds something beyond a login.)
2. Where does the receipt live: Heimdall store as a new document, or the existing audit table with a new `eventType`? (Recommend: new typed document; the audit table is operator-shaped and has no viewer read path.)
3. Viewer identity for the receipt page when the viewer has no Heimdall session yet (a first-time creator connect). Binding cookie only, or require sign-in first?
4. Does `consent_refused` need to reach a backend callback app, or only the browser handoff?
5. Wording ownership: the page text is legal-adjacent (CultCoin legal research is a sibling thread); who signs the copy?
Signed in means a live Heimdall access token for any app (`Authorization:
Bearer`), or a page link minted from one.
## 5. Out of scope here
- `GET /v1/me/grants`: every grant and receipt of the account. `Accept:
text/html` gets the page, one **Take this back** form per live grant; anything
else gets `{grants, receipts}`.
- `POST /v1/me/grants/link` (bearer): `{url}` to the page, valid ten minutes.
The token is signed, purpose-bound (`heimdall_grants_page`), stateless, and
opens only this page and its revoke forms. This is how a browser that holds no
session gets in; Heimdall sets no new cookie and stores nothing for it.
- `POST /v1/me/grants/:id/revoke` (bearer, or the page token in the form):
strips the stored tokens from the credential, then writes a revocation row
for every live grant on that credential. The credential is one row per account
and provider, whichever app asked, so taking it back ends every grant resting
on it. The next `managed-credentials/resolve` answers `410
managed_credential_revoked`; nothing else needs to learn of it. Revoking a
revoked grant changes nothing. Another account's grant id is `404`.
Enabling Joystick in `identityProviders`; fixing `connect-start-needs-app-auth`;
Postgres schema/migration for the receipt; any deploy. Each needs its own cut.
Heimdall can delete its copy; it cannot withdraw the permission at the
provider, and the page says so.
## 6. Test sketch
## 4. The steward's rulings
`tests/joystick-consent.test.ts` holds the behaviours above as skipped tests
(they cannot pass until the cut is built). Unrun: the author's workspace could
not install dependencies, so treat the file as a sketch to be typechecked.
Each ruling picks the answer that gives the person the most agency and leaves
the least stored.
1. **Who gets the ask: managed custody only.** Where Heimdall holds a credential
the person cannot see and an app can use, the person is asked and given a
receipt. A login is already asked by the provider's own screen, and a receipt
per login would store a row per sign-in for no agency the provider's screen
does not give. The line is `callbackMint === "custody_only"`, so it needs no
second list to keep in step with the profiles.
2. **The receipt is a new typed table, `consent_receipts`.** The audit table is
operator-shaped and has no read path for the person; a viewer-readable
record in it would make every audit row a potential disclosure. The table
holds ids, scopes and a digest: no IP address, no user agent, no page text.
Refusals leave no receipt, only the anonymous audit row.
3. **Viewer identity: sign-in, not the cookie.** The binding cookie dies with
the state and proves only that a browser launched a flow; it is not an
account. The receipt page and revoke require a live access token or a
ten-minute link minted from one. The cost is that a first-time creator must
hold the session the completion returned to the app; the app mints the link.
The alternative, a new long-lived Heimdall cookie, would store a session for
every person who merely looked.
4. **`consent_refused` reaches a backend callback app.** An app that started the
attempt and is waiting must be told, or it polls to expiry and cannot tell
a person's no from a fault. The person's no is also not held against them:
Heimdall stores nothing and sets no cooldown; whether and when to ask again
is the app's rule.
5. **Wording: derived, not authored.** The text is generated from facts the code
holds and states nothing the code does not do; it is a constant in
`src/consent.ts` and its digest rides every receipt. Legal review of the
wording (the CultCoin legal thread) may only narrow it, and any change moves
the digest. Until that review lands the copy is Heimdall's, signed by the
steward.
## 5. Out of scope
Enabling Joystick in `identityProviders`; fixing
`joystick:follow_up:connect-start-needs-app-auth`; revoking at the provider
(Joystick offers no revocation call Heimdall uses); retention or deletion of
consent receipts and of the audit rows; consent for the creator's audience.
## 6. Tests
`tests/joystick-consent.test.ts` holds one behaviour per rule above. Rules were
checked by mutation, each mutant run on its own with a 300 s limit under an 8 GB
address-space cap (the PR lists them). The Postgres statements for
`consent_receipts` are exercised by `tests/store.test.ts` against the fake pool
and, when `HEIMDALL_TEST_PG_ADMIN_URL` is set, against a live database
(`scripts/test-live-pg.sh`); the live run was not possible where this was built.

View file

@ -382,6 +382,8 @@ describe("my grants", () => {
expect(second.statusCode).toBe(200);
expect(second.json().revoked).toEqual([]);
expect(await store.listConsentReceipts(a.account.id)).toHaveLength(2);
const events = [...(store as unknown as { auditEvents: Map<string, { eventType: string }> }).auditEvents.values()];
expect(events.filter((event) => event.eventType === "consent_revoked")).toHaveLength(1);
});
it("a fresh connection after a revoke is a new grant, live again, and the old one stays revoked", async () => {