Account-link consent
The configured browser flow implements the accepted account-operation contract with the proof adapters and atomic directory. That decision supersedes the earlier proposed linking design. The browser host registers the identity handler and the replacement app exposes it under Settings → Identities. Configuration, current human Access proof, the directory and the named Slack secret govern availability. The separate local fixture uses fictional identities and enables no live account operation.
- Code:
src/index.ts,src/channels/browserHomeWiring.ts,web/green/pages/Identities.vue,src/core/commands/identity.ts,src/core/commands/all.ts,src/channels/identityRuntime.ts,src/channels/identitySeed.ts,src/channels/webSeed.ts,src/channels/webShell.ts,src/channels/linkBrowser.ts,src/core/identity/linkCeremony.ts,web/src/modules/account-linking/LinkConsent.vue,web/src/modules/account-linking/fixtures.ts,web/src/modules/account-linking/preview.ts,web/link-consent-preview.html. - Tests:
web/green/pages/identities.test.ts,src/core/commandConformance.test.ts,src/channels/identityRuntime.test.ts,src/channels/linkBrowser.test.ts,web/src/modules/account-linking/LinkConsent.test.ts.
Behavior
- Missing proof or configuration cannot enable linking. The low-level linking adapter remains disabled by default and returns 404 before reading credentials or touching a provider or directory. Its explicit local-fixture mode requires an exact HTTPS loopback origin. The production browser host constructs the identity runtime and routes authenticated identity requests to it. Binding operations require the configured HTTPS origin, current human Access proof, directory and named Slack secret; missing prerequisites remain unavailable. Signed-in account reads and protected notice acknowledgment can remain available when Slack setup is removed, as specified below. The separate Vite fixture uses made-up identities only, not real credentials or requests to Slack.
- The browser is bound, not a person selector. A Secure, HttpOnly, SameSite=Lax, host-only cookie carries the opaque operation and independent browser secret. Lax permits the top-level OAuth return. The URL, form and JSON view never carry that secret, a person target or a credential. Same-origin POSTs also require a form token bound to the cookie and exact action; closed forms reject extra or duplicate fields. The callback instead uses the stored one-time state and authenticated browser.
- Consent is local and explicit. After callback, a clean URL displays the verified Access issuer/subject/audience and signed Slack user/workspace identifiers, not email/name matches or a requested team hint. Only an explicit checkbox and protected final POST can call the atomic transaction. The server re-verifies the current signed Access session at callback, display, consent, cancellation, interruption and receipt read. The session may predate the challenge: freshness means a new operation-bound target proof and local consent, not a new identity-provider login or a token-issuance timestamp. The store enforces the original tuple/audience/browser, state/revision, unchanged or shortened deadline and current directory revisions. Refresh never extends the intent.
- Retries are not new links. Callback replays never exchange a second code. An uncertain exchange stays claimed across reconstruction; an explicit protected interrupt makes it terminal without retrying credentials. Cancellation and expiry cannot link. Duplicate final POSTs and response-loss retries return the existing terminal outcome within the original result window, not another person, binding receipt or operation audit. A same-origin, cookie-bound restart starts a fresh verification after a terminal operation, including a committed link. It rotates to a new browser seed; the old cookie can still read its result during the original window. An expired result does not restore old proof or mutate the saved binding. Active operations cannot restart.
- No credential-bearing rendering. The callback always returns a bodyless 303 to the clean consent address, including failures. Every adapter response carries no-store and a restrictive CSP. JSON, callbacks, redirects and refusals use no-referrer. Only the clean hosted HTML form with status 200 uses same-origin so local POSTs retain their actual Origin; cross-origin provider navigation still sends no referrer. Only allowlisted verified metadata enters views or the directory; provider errors and raw callback parameters are never reflected. There is no logger, run-event sink, credential cache or audit hook on the browser adapter. Edge/proxy query redaction is a separate deployment obligation, not a claim established by this handler or its isolated tests.
- Production forms and fixture isolation. Settings → Identities uses the shared consent module, a typed identity seed and protected native forms served by the registered identity runtime. Its explicit props and form CSP do not grant account authority. The separate fixture entry supplies start, consent, exchanging, committed and refusal states in either theme, using fictional identities without granting cross-surface access. Fixture and read-only labels use info colors.
Validation criteria
| Criterion | Evidence |
|---|---|
| Account and notice pages reverify the same signed receiver after addressed notice retries. Expiry withholds account identities and writable controls while preserving the original committed link and notice ownership. | [unit] src/channels/identityRuntime.test.ts::browser identity activation::rechecks the signed receiver after addressed notice retries before exposing linked accounts:… |
| Only successful clean hosted HTML forms use same-origin; refused HTML, JSON, callbacks and redirects retain no-referrer, no-store and CSP | [unit] src/channels/identityRuntime.test.ts::browser identity activation::protects every identity response while keeping local form submissions same-origin; ::keeps refused hosted HTML private while a successful consent form preserves its local Origin; ::keeps unavailable identity notices and negotiated JSON private |
| Default-off adapters and non-loopback fixtures cannot authenticate, exchange or mutate | [unit] src/channels/linkBrowser.test.ts::staged browser linking::keeps the entry disabled by default and refuses nonlocal fixture origins |
| Initiation, final consent, cancel and interrupt require same origin and cookie-bound CSRF; extra fields cannot select a person | [unit] src/channels/linkBrowser.test.ts::staged browser linking::*::protects every POST and rejects client-selected targets |
| Verified accounts are displayed before explicit consent; both stores receive one atomic metadata-only commit | [unit] src/channels/linkBrowser.test.ts::staged browser linking::*::shows verified accounts and requires explicit local consent |
| Wrong browser, Access subject, issuer and audience at callback or consent cannot exchange or commit | [unit] src/channels/linkBrowser.test.ts::staged browser linking::*::revalidates the browser and exact Access account at callback and consent |
| Cancelled and expired intents never link, including refreshed Access credentials | [unit] src/channels/linkBrowser.test.ts::staged browser linking::*::refuses cancelled and expired intents without extending the deadline |
| A protected terminal restart rotates the browser cookie for fresh verification while the old cookie can still read its bounded result; active sessions cannot restart; a committed result can start a fresh operation without changing its prior binding | [unit] src/channels/linkBrowser.test.ts::staged browser linking::*::lets a protected terminal browser start fresh without losing the old result window, src/channels/linkBrowser.test.ts::staged browser linking::*::offers a fresh operation after a terminal result but never replaces an active session, src/channels/linkBrowser.test.ts::staged browser linking::*::replays duplicate final POSTs and response loss without another mutation |
| Callback replay, crash and restart cannot repeat exchange or bypass consent | [unit] src/channels/linkBrowser.test.ts::staged browser linking::*::fences callback replay and interrupted exchange across reconstruction |
| Concurrent final POSTs and lost responses replay one outcome without a second transaction mutation | [unit] src/channels/linkBrowser.test.ts::staged browser linking::*::replays duplicate final POSTs and response loss without another mutation |
| Credentials, query and provider errors stay out of rendering, persistence and side-effect sinks; callback transport never renders content | [unit] src/channels/linkBrowser.test.ts::staged browser linking::*::contains callback secrets and suppresses credential-bearing failures, src/channels/linkBrowser.test.ts::staged browser linking::contains asynchronous rendering failures instead of leaking them to a host logger |
| UI requires an unchecked-by-default consent checkbox and carries only the protected form fields | [unit] web/src/modules/account-linking/LinkConsent.test.ts::link consent fixture::shows the verified pair and requires explicit consent |
| Terminal UI renders a restart only when the server supplies its protected token; active UI does not offer it | [unit] web/src/modules/account-linking/LinkConsent.test.ts::link consent fixture::offers a protected restart only for a terminal verification |
| Every fixture state renders in both themes using verification language, not internal attempt terminology, without enabling production routes | [unit] web/src/modules/account-linking/LinkConsent.test.ts::link consent fixture::renders every fixture state in both themes without a live route |
| Real browser theme/accessibility/layout proof | [agent] Run npm run dev -w web -- --host 127.0.0.1, open /link-consent-preview.html?theme=light and ?theme=dark, inspect keyboard consent/cancellation and narrow layout, and attach both screenshots to the review. This Vite-only entry is not in the production build inputs. |
| Production query-log redaction, deployed rate/outstanding-intent limits, durable exchange timeout ownership, cookie policy, revocation/authorization fences, email retirement, physical notice delivery and live linking | [gap] Source tests do not establish these deployment and live outcomes. Configured route registration does not prove live linking. |
Configured host handler
createIdentityRuntime composes the existing two-account ceremony. It requires the current human Access account and a host authorization callback for writes. Service credentials, view-as and cross-site posts cannot bind accounts. Missing configuration, directory, Access application or named secret remains unavailable. The browser host mounts the handler for identity requests. The handler carries protected forms and pins the Slack provider in its form CSP; it persists no OAuth credentials.
| Criterion | Evidence |
|---|---|
| The optional identity document carries protected forms with only the local origin and pinned Slack redirect allowed | [unit] src/channels/identityRuntime.test.ts::browser identity activation::serves the selected green identity document with protected native forms and the pinned Slack redirect allowed |
| Missing configuration and named secret remain unavailable | [unit] src/channels/identityRuntime.test.ts::browser identity activation::keeps absent configuration and a named missing secret truthfully unavailable |
| Both current account proofs precede explicit consent; identical consent retries return the same durable link | [unit] src/channels/identityRuntime.test.ts::browser identity activation::verifies both accounts before explicit consent and returns the same durable link on an identical retry |
| Service, view-as and cross-site mutation cannot create a binding | [unit] src/channels/identityRuntime.test.ts::browser identity activation::refuses service, view-as and cross-site mutation without creating an account binding |
| Identity operations use the shared registry and actual signed-in actor. Caller input cannot select another identity or person; viewing another user and missing fresh adapter proof cannot execute. | [unit] src/core/commandConformance.test.ts::command conformance — catalogue fences::routes identity operations through the full registry only with current adapter proof and rejects caller-selected identity fields | | Signed-in account reads and notice acknowledgment remain available after Slack setup is removed; all binding mutations stay unavailable. | [unit] src/channels/identityRuntime.test.ts::browser identity activation::keeps exact signed-in identity reads and notice acknowledgment available when Slack setup is removed, while binding changes stay unavailable | | A stable Slack notice retries after server replacement without borrowing a browser proof or acknowledging its browser notice. | [unit] src/channels/identityRuntime.test.ts::browser identity activation::retries the stable Slack notice after server replacement without borrowing an Access proof or acknowledging the browser notice | | Unlink and recovery require fresh two-sided proof and current administrator permission; browser notices are acknowledged only through bound anti-forgery tokens. | [unit] src/channels/identityRuntime.test.ts::browser identity activation::verifies each unlink and recovery afresh, checks current administrator permission and acknowledges only delivered browser notices |
|---|---|
| Native identity settings renders closed-field consent, fresh operation choices and protected acknowledgment, while readonly and missing flow remain truthful | [unit] web/green/pages/identities.test.ts::live identity settings::* |
| The shared module retains fixture-only protected defaults without a live route | [unit] web/src/modules/account-linking/LinkConsent.test.ts::link consent fixture::* |