eleven / messenger Public

home: the kebab's "Sign-in options" step — list, remove, add sign-in methods (#1098) (#1128)

* home: compose the sign-in card — last-used first, else platform default, other ways (#1112)

The card mechanics half of #1112, built with the live doors (Apple, email,
passkey); providers slot in as they ship.

- composeCard: ONE primary door — the localStorage last-used marker when it
  names a live door, else the platform default (Apple platforms → Apple;
  Android/ChromeOS → Google and Windows → Microsoft, both dark for now so
  they fall through to email-first; unknown → email-first) — with the email
  door always beneath it (unless it IS the primary) and a quiet "Other ways
  to sign in" link. Demoted doors don't render on the main card.
- The all-ways step (#otherways): every door on one card, same screen-swap
  pattern as the email door, each row wired to the SAME handler as the main
  card's version; clearly-commented slots where Google/Microsoft/GitHub join.
- DOORS registry: a dark door ships by flipping one flag + adding its button
  to the commented slot — no restructuring.
- The Apple stage-clear now keys off .loading (not an id), so the pending
  choreography works from either surface; #newhere follows the visible
  sign-in surface, so the passkey-failure flow works from wherever the door
  was invoked; the email door's Back returns to whichever surface opened it.
- Greeter/cookie/reassure rules hold: the all-ways step hides #signed-out
  exactly like the email door does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* home: anchor management — list and remove sign-in methods (#1089)

GET /api/anchors (link token) lists every way into the account — passkeys,
SiwA anchors, email anchors — as {kind, id, created_at}; the id is the
removal handle (base64url credential id; the HMAC-peppered hash for
siwa/email, which reverses to nothing). DELETE /api/anchors/{kind}/{id}
(ceremony proof) removes one, refused with a 409 when it would leave ZERO
ways in: the guard evolved from last-passkey to last-METHOD, so an anchored
account may now shed its final passkey. The guard lives in the stores
(transaction/CAS-atomic), enforced identically by the legacy
/api/credentials/{id} door. Store additions (listing + deletion) land in
both SQLite and S3; the S3 side also sweeps stale anchor pointers so a
removed identifier can re-anchor a different account. #1098's settings UI
builds on this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* home: anchor management — list and remove sign-in methods (#1089)

GET /api/anchors (link token) lists every way into the account — passkeys,
SiwA anchors, email anchors — as {kind, id, created_at}; the id is the
removal handle (base64url credential id; the HMAC-peppered hash for
siwa/email, which reverses to nothing). DELETE /api/anchors/{kind}/{id}
(ceremony proof) removes one, refused with a 409 when it would leave ZERO
ways in: the guard evolved from last-passkey to last-METHOD, so an anchored
account may now shed its final passkey. The guard lives in the stores
(transaction/CAS-atomic), enforced identically by the legacy
/api/credentials/{id} door. Store additions (listing + deletion) land in
both SQLite and S3; the S3 side also sweeps stale anchor pointers so a
removed identifier can re-anchor a different account. #1098's settings UI
builds on this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* home: email magic link — tap the mail's code instead of typing it (#1091)

The sign-in mail gains a link under the code: the SAME address and code,
carried in the URL #fragment (https://<origin>/#email=<addr>&code=<6 digits>)
— which browsers never send to any server. Clicking runs the ordinary
/api/email/verify from the page, so the link shares the code's single-use/
expiry/attempt machinery and adds zero new server auth surface; the code
stays first in the mail as the spine (recovery-grade, works typed anywhere).

- email.go: emailSignInLink + emailCodeBody (origin from s.origin); encoding
  is QueryEscape with space-"+" as %20, exactly what decodeURIComponent
  inverts (plus-addressed local parts round-trip).
- index.html: the pre-paint gate also trips on #email=, raising the waiting
  face (booting + siwa-wait) before first paint; the CSP inline-script hash
  self-heals from the served bytes.
- home.js: #email= boot handler — clear the hash via replaceState, verify
  under the cover, land through the existing markSignedIn("email") +
  persistSession + enterAnchorSession path, then the shared anchorArrival
  (factored out of finishSiwa). Failure lifts to the doors with "That
  sign-in link has expired — request a fresh code."
- email_test.go: the fake SES now captures bodies; the new
  TestEmailMailCarriesMagicLink pins the link format server-side and
  redeems the parsed fragment values through the real verify.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* home: the OIDC provider table — dark Google and Microsoft doors (#1112)

Generalizes the Sign in with Apple verifier into a provider table
(home/oidc.go). What differs per door lives in one oidcProvider row —
name, display, issuer check, client ids, pepper, JWKS cache — and
everything else is shared: the jwksCache (moved verbatim from siwa.go),
the hand-rolled RS256 verification (verifyIDToken, same check order),
resolve-or-create, link mode, and the form_post trampoline. Apple keeps
its exact routes, env vars, and siwa_anchors rows; verifySiwaToken is
now a thin wrapper over the shared core, so there is still one verifier
and /api/siwa/* is unchanged on the wire (its whole suite passes
untouched).

New per-provider surface: POST /api/oidc/{provider}/verify (the same
request/response contract as /api/siwa/verify) and POST /oidc/return
(the same web-state trampoline, provider-blind, fragment key #oidc=).
Gating rides the SiwA pattern per door: LCHAT_HOME_GOOGLE_CLIENT_IDS +
LCHAT_HOME_GOOGLE_PEPPER and LCHAT_HOME_MICROSOFT_CLIENT_IDS +
LCHAT_HOME_MICROSOFT_PEPPER, both-or-neither each, off = 404. Peppers
are per provider and durable — account keys are HMAC(provider pepper,
sub), and the store carries the provider name beside the hash
(oidc_anchors rows; oidc/<provider>/<hash> pointers on S3), so hashes
can never collide across providers.

Issuer decisions: Google accepts both documented spellings
(https://accounts.google.com and the legacy bare form — same account
either way). Microsoft uses the v2.0 common endpoint, so the issuer is
the per-tenant template https://login.microsoftonline.com/{tenantid}/v2.0
(consumer + work accounts); we accept the pattern and conservatively
require the token's tid claim to equal the issuer's tenant segment.

The anchors surface (#1089) gains the new kinds: GET /api/anchors lists
google/microsoft anchors (from the fixed table, so a disabled door's
anchors still list), DELETE /api/anchors/{google|microsoft}/{id}
removes them, and the last-method guard counts them in both stores.

Both doors ship DARK: no UI, and each stays 404 until its console
registration exists and the env pair is set (Paul's #1112 checklist).
No changelog entry for the same reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* home: the kebab's "How you sign in" step — list, remove, add methods (#1098)

The account kebab gains its first real entry: a card step listing every way
into the account (GET /api/anchors — "Apple", "Email", "Passkey · added 12
Jul"), each with a quiet Remove behind an inline are-you-sure beat, plus Add
Apple / Add email reusing the existing door flows in link mode.

Removal and linking both need a fresh ceremony proof, so home.js grows a
proof pocket: every ceremony that mints one (passkey login/register, the
Apple and email verifies) drops it in, mirrored to the server's 5-minute
window. When the pocket is empty, a confirm beat offers whatever the account
can actually run — passkey ceremony first, else an email code re-verify
(sign-in mode, proof kept, token dropped), else a fresh Apple round trip
that lands back on the step.

Add Apple stashes the proof in sessionStorage beside the SiwA state so it
survives the round trip; the #siwa= boot path routes a marked return through
the link verify (link:true + X-Ceremony-Proof) instead of signing in, then
resumes this tab's own session. Markers are set-or-cleared per trip and
consumed exactly once, so a stale one can never misroute a later sign-in.

The last-method 409 and the already-linked 409 show the server's plain words
inline; the account-merge flow is a later slice and hooks in at those
refusals (marked LATER in the code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* home: the account-merge operation — POST /api/account/merge (#1098)

Merging two accounts is client-first by design: the client decrypts both
vaults locally, unions the saved spaces, re-encrypts under the survivor's
DEK, PUTs to the survivor, and deletes the absorbed blob — only then does
the server move anything. This adds the server slice: a both-sides-proofs-
gated endpoint (survivor link token + survivor fresh ceremony proof +
absorb_proof for the account being absorbed) that moves every anchor,
passkey credential, and device link token onto the survivor and retires
the emptied account. An absorbed account still holding a blob is a 409
("move the spaces over first") — both in the handler and as a store-side
belt (errAbsorbedHasBlob) — so merge itself never destroys anything but
an empty shell, and a retried merge is a zero-count success.

Store seam: vaultStore.mergeAccounts(survivor, absorbed) (mergeMoved, err).
SQLite does it in one transaction (user_id repoints can't collide — every
key column is globally unique). S3 sequences for crash safety like
createLinkToken documents: survivor doc gains entries first (deduped CAS),
pointers repoint second (unconditional overwrite — pointers never decide),
absorbed doc deleted last, so a crash anywhere resolves via the
pointer-verify contract and a retry completes the walk.

Tests: the contract suite gains a MergeAccounts subtest (runs against
SQLite and, env-gated, live S3); merge_test.go covers the HTTP contract —
happy path incl. the absorbed device's token now seeing the merged vault,
blob-present 409 then success after the ceremony-gated blob delete,
same-account 400, the auth-gating matrix (missing/mismatched/stale
proofs), and the last-method guard counting merged-in methods.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* home: Sign-in options rework — per-row kebabs, serial card steps (#1098)

Paul's review revisions on the methods step, plus the rename to
"Sign-in options" (menu item, step title, changelog, quoted comments).

Per-row kebabs: each method row gets a small kebab in the account menu's
vocabulary (aria-haspopup/expanded, focus onto the first item, Escape and
outside-click close, one open at a time) holding the kind-appropriate
"Add another Apple account / email address / passkey" and Remove (the
inline are-you-sure beat and verbatim 409 handling stay). The big add
buttons below the list now render only for kinds with ZERO linked entries,
driven by a generalized METHOD_KINDS table future doors slot into.

Serial card steps, never inline/stacked: the confirm beat, add-email, and
add-passkey are each their own screen-swap step with a back — confirm
always comes FIRST when the proof pocket is empty (list → confirm → the
add step or the removal → back to the list with the outcome status). The
confirm step's email re-verify keeps its own two-step form; add-email is
the door's address→code shape posting link:true + proof.

New since the review asked to generalize to all kinds: add-another-passkey
is a real step — register/begin is fetched WITH the ceremony proof (that's
what attaches the credential to THIS account), options prefetched at step
open so create() fires inside the tap, and when this tab holds the DEK and
a blob exists the new passkey also gets the wrapped DEK, so it opens the
list rather than only signing in.

Kept: the proof pocket, the Apple link round-trip markers, verbatim 409
messages with the LATER merge hooks, and the AX/focus discipline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* home: row-kebab add items are just "Add another" (#1098)

Paul's review pass: the row already names the kind, so "Add another Apple
account" / "… email address" / "… passkey" all become a plain "Add another"
(the accessible name keeps the kind — "Add another Passkey" — for menus
read out of visual context; label-in-name holds). The per-kind `another`
labels leave METHOD_KINDS.

Passkey rows verified, no code change needed: GET /api/anchors lists
passkeys (anchors.go's listCredentialMeta leg, pinned by anchors_test's
listing assertions), the client renders every kind through the same
methodRow, and renderMethodsAdd already hides a kind's big first-add
button once one entry exists — so no-passkey shows "Add a passkey" below
the list (Paul's screenshot: an Apple-born account) and has-passkey shows
the row + kebab with no big button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* home: resume the interrupted change after a confirm-with-Apple round trip (#1098)

Paul's flow bug: Add email → empty proof pocket → confirm with Apple →
the return landed on the bare list and the add had to be started again.

The continuation system is now ONE intent mechanism, as data instead of
closures: pendingIntent ({kind: add-email | add-passkey | add-apple |
verify-email | remove}) drives every confirm path through the same
runIntent dispatcher. In-page ceremonies (passkey, email re-verify)
resume it from memory with payloads intact; the Apple confirmation
serializes intentToken ("add-email" | "add-passkey" | "add-apple" |
"remove:<kind>:<id>") into sessionStorage's home_methods_intent beside
the return marker, and the #siwa= return consumes it exactly once and
resumes INTO the parked change — the add step opens, or the removal's
are-you-sure re-arms (never auto-deleting across a page reload; the
in-memory `armed` flag is deliberately dropped by serialization).
Payloads never persist: a typed address/code stays in memory, so the
verify-email retry degrades to reopening the add-email step, where the
same mailed code can be retyped.

Set-or-cleared per trip like the other markers (startAppleSignIn,
releaseApplePending), so a stale intent can't fire later. A FAILED
confirm return now lands back on the list with the error when the tab's
session survives, instead of the signed-out card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* home: the combine-accounts flow behind the link-mode 409 (#1098)

Builds the merge UI on top of #1130's POST /api/account/merge (that branch
is merged in here), replacing the LATER hooks: hitting "already signs in
somewhere else" while adding email or Apple now grows the error line into
an offer — "Combine the accounts" — that opens its own serial card step.

The step runs three stages, one visible at a time. Prove the other side:
for email a FRESH code (the 409 burnt the typed one — the redeem precedes
the conflict answer), auto-requested with plain words and a resend link,
verified in SIGN-IN mode; for Apple a round trip carrying the new
merge-apple intent, whose return leg (finishSiwaMergeProve) verifies
sign-in-mode WITHOUT adopting — the absorbed account's token+proof are
held in memory only, and the survivor session resumes as-is. Its spaces:
no blob skips ahead; a real blob offers unlocking with the ABSORBED
account's passkey (the ceremony must land there — the credential id has to
appear in its own anchors listing, else "that passkey opens a different
account", retry), PRF-decrypts, unions into the survivor under saveVault's
exact race rules (survivor wins ties, tombstones hold), wraps the survivor
DEK for the unlocking passkey too (it should open the combined list, not
hit the sealed prompt), CAS-PUTs, then ceremony-deletes the absorbed blob
— merge.go's client-first precondition. When unlocking can't happen (no
absorbed passkey, or the survivor side is sealed/keyless) only the honest
paths show: combine without its spaces behind an inline are-you-sure whose
copy is plain about the sealed list being destroyed, or cancel — nothing
destructive before the explicit beats. The merge call itself sends both
sides' fresh proofs; lapsed proofs re-run the right prove (survivor → the
confirm step via a memory-only combine-finish intent; absorbed → stage A).

Success lands back on the list: "Accounts combined — N sign-in methods
moved, M spaces brought over" (pluralised, localised), anchors reloaded so
the moved methods appear, provisioner ledger re-read when spaces moved.
Server refusals land on the combine step verbatim; cancel anywhere leaves
both accounts untouched. The 409 offers key off the HTTP status, never
message text (the add flows now use status-aware fetches).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* home: sign out earns a beat — the card holds ~700ms

Meaningful transitions read as events, not glitches, even when the work
is instant.

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b1b3e0135485c9084d1206a7fa20b6e18d3cc97b parent 9001e44
4 files changed, +1461 −11
  • changelog/2026-07-19-sign-in-options.md +4 −0
  • home/web/pages/index.html +188 −3
  • home/web/static/backup-core.js +4 −2
  • home/web/static/home.js +1265 −6
diff --git a/changelog/2026-07-19-sign-in-options.md b/changelog/2026-07-19-sign-in-options.md
new file mode 100644
index 0000000..b8a17b6
--- /dev/null
+++ b/changelog/2026-07-19-sign-in-options.md
@@ -0,0 +1,4 @@
+🤖 Your account menu on the home page now has "Sign-in options": see every
+way into your account — Apple, email, your passkeys — add another so you're
+never locked out, or remove one you no longer use. Changing them always asks
+you to confirm it's you first, and the last way in can never be removed.
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 5ffcd69..57c4956 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -58,6 +58,18 @@
}
@keyframes btn-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { button.loading::after { animation: none; } }
+ /* A brief working beat for meaningful transitions that are technically
+ instant (sign out): the card holds with a ring so the change reads as
+ an event, not a glitch. Case-by-case, per the project memory. */
+ main.card.waiting > * { visibility: hidden; }
+ main.card.waiting::after {
+ content: ""; position: absolute; inset: 0; margin: auto;
+ width: 22px; height: 22px; border-radius: 50%;
+ border: 3px solid color-mix(in srgb, var(--accent) 30%, transparent);
+ border-top-color: var(--accent);
+ animation: btn-spin .8s linear infinite;
+ }
+ @media (prefers-reduced-motion: reduce) { main.card.waiting::after { animation: none; } }
/* Tapping Apple clears the stage: everything but the spinning button fades
away, so the wait for the sheet is just the button at work. The pressed
button wears .loading, and the door can be on the main card OR the
@@ -65,8 +77,9 @@
on either surface. The smoke cover (html.siwa-wait, below with the
loading chrome) follows on a timer — we can't hear the overlay appear. */
#signed-out .actions > *, #otherways .actions > *,
+ #methods .actions > *, #methods-confirm .actions > *, #methods-combine .actions > *,
#otherways .icon-back, #otherways .create-title, #newhere { transition: opacity .3s ease, transform .3s ease; }
- main.card.apple-pending :is(#signed-out, #otherways) .actions > :not(.loading),
+ main.card.apple-pending :is(#signed-out, #otherways, #methods, #methods-confirm, #methods-combine) .actions > :not(.loading),
main.card.apple-pending #otherways .icon-back,
main.card.apple-pending #otherways .create-title,
main.card.apple-pending #newhere {
@@ -77,6 +90,7 @@
body:has(main.card.apple-pending) .demo-card { opacity: 0; pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
#signed-out .actions > *, #otherways .actions > *,
+ #methods .actions > *, #methods-confirm .actions > *, #methods-combine .actions > *,
#otherways .icon-back, #otherways .create-title, #newhere { transition: none; }
}
.apple-btn svg { display: block; flex: none; }
@@ -281,6 +295,36 @@
/* Pointer-born opens: focus still lands in the menu, but the ring is a
keyboard courtesy — the focused item shows the hover wash instead. */
.menu-pop.pointer-open [role="menuitem"]:focus-visible { outline: none; background: var(--quiet); }
+ /* Sign-in options (issue #1098): the method rows — friendly name + when
+ it was added, and a small per-row kebab whose menu holds Add another … /
+ Remove (removal arms to an inline are-you-sure, never a browser
+ confirm()). List left-aligned like the spaces list; the steps centre
+ their titles like the other card steps. */
+ #methods, #methods-confirm, #methods-email, #methods-passkey, #methods-combine { text-align: center; }
+ ul.methods { list-style: none; margin: 0 0 14px; padding: 0; text-align: left; }
+ ul.methods li { display: flex; align-items: center; gap: 8px; padding: 7px 6px; border-radius: 10px; }
+ ul.methods .method-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
+ ul.methods .method-name { font-weight: 600; line-height: 1.25; }
+ ul.methods .method-added { color: var(--muted); font-size: .72rem; line-height: 1.3; }
+ #methods-list > p.empty { color: var(--muted); font-size: .9rem; margin: 6px 0 14px; text-align: left; }
+ /* The per-row kebab anchors its own .menu-pop (the account menu's
+ vocabulary, one wrap per row). */
+ .row-kebab-wrap { position: relative; flex: none; }
+ /* The email mini-flow rows, shared by the confirm step's re-verify and the
+ add-email step. This inline sheet loads after home.css, so the display
+ rules beat the global .hidden at equal specificity — restate the hides
+ (the .icon-add trap above). */
+ .email-mini { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
+ .email-mini.hidden { display: none; }
+ .email-mini form { display: flex; gap: 8px; }
+ .email-mini form.hidden { display: none; }
+ .email-mini input { flex: 1; min-width: 0; }
+ .email-mini button { flex: none; }
+ .email-mini .passkey-link { align-self: flex-start; }
+ /* The combine offer rides under whichever status line showed the 409. */
+ #combine-offer { margin-top: 10px; }
+ #combine-prove .anchor-note, #combine-vault #combine-vault-note { text-align: left; }
+ #combine-vault .create-sub { margin-bottom: 14px; }
/* Low-emphasis, full-width: Done (confirmation) and Cancel (archive). */
.wide-quiet {
width: 100%; margin-top: 18px; font: inherit; font-weight: 600; cursor: pointer;
@@ -815,12 +859,13 @@
<h2>Your Eleven spaces</h2>
<button id="open-create" class="icon-add hidden" type="button" aria-label="Create a new space"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg></button>
<!-- The account menu (issue #1098's single control): everything about
- the account itself hangs off this one kebab — Sign out today;
- "How you sign in" and account-merge join it later. -->
+ the account itself hangs off this one kebab — "Sign-in options"
+ (the #methods step) and Sign out; account-merge joins it later. -->
<div class="kebab-wrap">
<button id="account-menu-btn" class="icon-kebab" type="button" aria-label="Account menu"
aria-haspopup="menu" aria-expanded="false"><svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor"><circle cx="8" cy="3" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="8" cy="13" r="1.5"/></svg></button>
<div id="account-menu" class="menu-pop hidden" role="menu" aria-label="Account menu">
+ <button id="how-signin" type="button" role="menuitem">Sign-in options</button>
<button id="signout" type="button" role="menuitem">Sign out</button>
</div>
</div>
@@ -837,6 +882,146 @@
<p class="status" id="vault-status"></p>
</div>
+ <!-- Sign-in options (issue #1098): every way into this account, reached
+ from the account kebab. Rows come from GET /api/anchors, each with its
+ own small kebab (Add another …, Remove — removal keeps an inline
+ are-you-sure beat; the server's last-method refusal shows verbatim).
+ A kind with ZERO linked entries gets a big first-add button below the
+ list instead (home.js builds both from METHOD_KINDS). Every flow is a
+ SERIAL card step — the confirm beat, add-email, and add-passkey each
+ swap the whole card, one screen at a time, each with a back. -->
+ <div id="methods" class="hidden screen">
+ <button id="methods-back" class="icon-back" type="button" aria-label="Back to your spaces"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
+ <h2 class="create-title" id="methods-title" tabindex="-1">Sign-in options</h2>
+ <p class="create-sub">Every way into your account. Add a second way so
+ you're never locked out, or remove one you no longer use.</p>
+ <div id="methods-list"></div>
+ <div id="methods-add" class="actions"></div>
+ <p class="status" id="methods-status"></p>
+ <!-- The combine doorway (#1098): a link-mode 409 ("already signs in
+ somewhere else") grows this offer under whichever status line showed
+ it — home.js moves the node beside the error (list or add-email
+ step) and reveals it. -->
+ <div id="combine-offer" class="actions hidden">
+ <button id="combine-offer-go" class="secondary" type="button">Combine the accounts</button>
+ </div>
+ </div>
+
+ <!-- Confirm it's you — its own serial step, FIRST whenever the proof
+ pocket is empty: list → here → the add step (or the removal) → list.
+ home.js offers whatever the account can actually run: the passkey
+ ceremony, an email code re-verify, else a fresh Apple sign-in that
+ lands straight back on the list. -->
+ <div id="methods-confirm" class="hidden screen">
+ <button id="methods-confirm-back" class="icon-back" type="button" aria-label="Back to sign-in options"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
+ <h2 class="create-title" id="methods-confirm-title" tabindex="-1">Confirm it&rsquo;s you</h2>
+ <p class="create-sub" id="methods-confirm-why"></p>
+ <div class="actions">
+ <button id="methods-confirm-passkey" class="secondary hidden" type="button">Confirm with your passkey</button>
+ <button id="methods-confirm-apple" class="apple-btn hidden" type="button"><svg viewBox="0 0 814 1000" width="14" height="17" fill="currentColor" aria-hidden="true"><path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/></svg>Confirm with Apple</button>
+ </div>
+ <div id="methods-cemail" class="email-mini hidden">
+ <form id="methods-cemail-form">
+ <input id="methods-cemail-addr" type="email" required autocomplete="email"
+ placeholder="you@example.com" aria-label="The email address you sign in with">
+ <button id="methods-cemail-send" type="submit">Send code</button>
+ </form>
+ <form id="methods-cemail-code-form" class="hidden">
+ <input id="methods-cemail-code" type="text" required inputmode="numeric" pattern="[0-9]{6}"
+ maxlength="6" autocomplete="one-time-code" placeholder="6-digit code"
+ aria-label="The 6-digit code from your email">
+ <button id="methods-cemail-verify" type="submit">Continue</button>
+ </form>
+ </div>
+ <p class="status" id="methods-confirm-status"></p>
+ </div>
+
+ <!-- Add an email address — its own serial step (the sign-in door's
+ two-step shape: address → code), posting verify with link:true. -->
+ <div id="methods-email" class="hidden screen">
+ <button id="methods-email-back" class="icon-back" type="button" aria-label="Back to sign-in options"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
+ <h2 class="create-title" id="methods-email-title" tabindex="-1">Add an email address</h2>
+ <p class="create-sub">A 6-digit code confirms it's yours. We never keep the
+ address — only a fingerprint that lets you sign in with it.</p>
+ <div class="email-mini">
+ <form id="methods-email-form">
+ <input id="methods-email-addr" type="email" required autocomplete="email"
+ placeholder="you@example.com" aria-label="Your email address">
+ <button id="methods-email-send" type="submit">Send code</button>
+ </form>
+ <form id="methods-email-code-form" class="hidden">
+ <input id="methods-email-code" type="text" required inputmode="numeric" pattern="[0-9]{6}"
+ maxlength="6" autocomplete="one-time-code" placeholder="6-digit code"
+ aria-label="The 6-digit code from your email">
+ <button id="methods-email-verify" type="submit">Add email</button>
+ </form>
+ </div>
+ <p class="status" id="methods-email-status"></p>
+ </div>
+
+ <!-- Add another passkey — its own serial step: one button, so the
+ ceremony fires inside a fresh tap (Safari activation) with the
+ proof-carrying options prefetched when the step opened. -->
+ <div id="methods-passkey" class="hidden screen">
+ <button id="methods-passkey-back" class="icon-back" type="button" aria-label="Back to sign-in options"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
+ <h2 class="create-title" id="methods-passkey-title" tabindex="-1">Add another passkey</h2>
+ <p class="create-sub">Create a passkey on this device or in your password
+ manager — one more one-tap way into this account.</p>
+ <div class="actions">
+ <button id="methods-passkey-create" class="secondary" type="button">Create the passkey</button>
+ </div>
+ <p class="status" id="methods-passkey-status"></p>
+ </div>
+
+ <!-- Combine your accounts (#1098) — the serial step behind the 409 offer.
+ Three stages, one visible at a time (home.js drives): prove the OTHER
+ account is yours (a fresh email code, or an Apple round trip via the
+ merge-apple intent), sort out its saved spaces (unlock with ITS
+ passkey and bring them over; or, behind an inline are-you-sure,
+ combine without them — destroying its sealed list), then the merge
+ call moves every sign-in method and device over. Cancel anywhere
+ leaves both accounts untouched — nothing destructive happens before
+ the explicit beats. -->
+ <div id="methods-combine" class="hidden screen">
+ <button id="combine-back" class="icon-back" type="button" aria-label="Back to sign-in options"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
+ <h2 class="create-title" id="combine-title" tabindex="-1">Combine your accounts</h2>
+ <p class="create-sub">Two accounts become one: the spaces from both end up
+ here, and the other account's sign-in methods move over too.</p>
+ <div id="combine-prove" class="hidden">
+ <p class="anchor-note" id="combine-prove-note"></p>
+ <div id="combine-code" class="email-mini hidden">
+ <form id="combine-code-form">
+ <input id="combine-code-input" type="text" required inputmode="numeric" pattern="[0-9]{6}"
+ maxlength="6" autocomplete="one-time-code" placeholder="6-digit code"
+ aria-label="The 6-digit code from your email">
+ <button id="combine-code-verify" type="submit">Continue</button>
+ </form>
+ <button id="combine-resend" class="passkey-link" type="button">Send a new code</button>
+ </div>
+ <div class="actions">
+ <button id="combine-apple-go" class="apple-btn hidden" type="button"><svg viewBox="0 0 814 1000" width="14" height="17" fill="currentColor" aria-hidden="true"><path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/></svg>Continue with Apple</button>
+ </div>
+ </div>
+ <div id="combine-vault" class="hidden">
+ <p class="create-sub" id="combine-vault-note"></p>
+ <div class="actions">
+ <button id="combine-unlock" class="secondary hidden" type="button">Unlock its spaces with its passkey</button>
+ <button id="combine-skip" class="passkey-link hidden" type="button">Combine without its spaces…</button>
+ </div>
+ <div id="combine-skip-confirm" class="hidden">
+ <p class="create-sub stern-copy danger-ink" id="combine-skip-note">Without unlocking them, the
+ other account's saved spaces list is <b>deleted forever</b> when the accounts combine —
+ the spaces themselves keep running, but that account's saved ways back into them are
+ lost. <b>This cannot be undone.</b></p>
+ <div class="actions">
+ <button id="combine-skip-yes" class="btn-danger" type="button">Delete its spaces list and combine</button>
+ <button id="combine-skip-no" class="wide-quiet" type="button">Keep it — go back</button>
+ </div>
+ </div>
+ </div>
+ <p class="status" id="combine-status"></p>
+ </div>
+
<!-- Create a space: a focused screen — one line, one button. On success the
pitch is replaced by a single-serve confirmation with a Done. -->
<div id="create" class="hidden screen">
diff --git a/home/web/static/backup-core.js b/home/web/static/backup-core.js
index d4ca81c..415e1f6 100644
--- a/home/web/static/backup-core.js
+++ b/home/web/static/backup-core.js
@@ -64,7 +64,7 @@ export function beginLogin() {
}
// runRegister creates a NEW passkey (a new anonymous account) with PRF, then
-// returns { token, credId, prf }. Pass pre-fetched beginRegister() options so
+// returns { token, credId, prf, proof }. Pass pre-fetched beginRegister() options so
// create() fires within the click (see above); omit to fetch inline (fallback).
// Some authenticators evaluate PRF at create time; the rest only during an
// assertion, which this handles so the caller always gets a usable PRF output.
@@ -103,7 +103,9 @@ export async function runRegister(prefetched) {
prf = prfOutputOf(assertion);
}
if (!prf) throw new Error("no PRF output from this passkey");
- return { token: fin.token, credId: fin.credential_id, prf };
+ // proof: register/finish answers exactly like login/finish (finishCeremony),
+ // so a fresh ceremony proof rides along for immediate gated ops.
+ return { token: fin.token, credId: fin.credential_id, prf, proof: fin.proof };
}
// runLogin runs a full assertion (discoverable credential) with PRF eval and
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 475dac0..d4b278c 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -305,9 +305,10 @@ function composeCard() {
async function signIn() {
setSignedOutBusy(true);
setStatus("Confirming it’s you…", "busy");
- const { token, credId, prf } = await runLogin(loginBegin);
+ const { token, credId, prf, proof } = await runLogin(loginBegin);
state.token = token;
state.credId = credId;
+ noteProof(proof); // fills the confirm-step pocket (Sign-in options)
markSignedIn("passkey");
persistSession(token, "passkey");
const got = await fetchBlob(token);
@@ -374,6 +375,7 @@ async function createPasskey() {
}
state.token = reg.token;
state.credId = reg.credId;
+ noteProof(reg.proof); // register/finish mints one too (finishCeremony)
markSignedIn("passkey"); // register/finish is a sign-in too (#1112)
persistSession(reg.token, "passkey");
@@ -430,9 +432,16 @@ function releaseApplePending() {
}
window.removeEventListener("pointerdown", releaseApplePending, true);
window.removeEventListener("keydown", releaseApplePending, true);
+ // A cancelled trip must not leave the methods step's round-trip markers
+ // behind — a stale one would misroute a LATER Apple sign-in into link mode.
+ try {
+ sessionStorage.removeItem("home_siwa_link");
+ sessionStorage.removeItem("home_methods_return");
+ sessionStorage.removeItem("home_methods_intent");
+ } catch { /* nothing stored */ }
}
-function startAppleSignIn(e) {
+function startAppleSignIn(e, opts = {}) {
// Tap: the button spins and the REST of the form fades away — the wait
// for Apple's sheet is just the button at work. By 1.6s the sheet is up,
// so the smoke cover takes over then; post-fingerprint there is nothing
@@ -450,6 +459,21 @@ function startAppleSignIn(e) {
window.addEventListener("keydown", releaseApplePending, true);
const rand = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join("");
const st = "web." + rand;
+ // The methods step's round-trip markers ride sessionStorage beside the
+ // state: linkProof turns the #siwa= return into "add Apple to MY account"
+ // (link:true + the stashed proof), methodsReturn routes a confirm-with-
+ // Apple sign-in straight back to the step, and intent is the serialized
+ // in-progress change (intentToken) so the return RESUMES it instead of
+ // landing on the bare list. Set fresh or CLEARED per trip, so a stale
+ // marker from an abandoned attempt can never misroute later.
+ try {
+ if (opts.linkProof) sessionStorage.setItem("home_siwa_link", JSON.stringify(opts.linkProof));
+ else sessionStorage.removeItem("home_siwa_link");
+ if (opts.methodsReturn) sessionStorage.setItem("home_methods_return", "1");
+ else sessionStorage.removeItem("home_methods_return");
+ if (opts.intent) sessionStorage.setItem("home_methods_intent", opts.intent);
+ else sessionStorage.removeItem("home_methods_intent");
+ } catch { /* no storage — the return leg lands as a plain sign-in */ }
sessionStorage.setItem("home_siwa_state", st);
location.href = "https://appleid.apple.com/auth/authorize" +
"?client_id=com.elevenmessenger.signin" +
@@ -465,7 +489,7 @@ function startAppleSignIn(e) {
// pre-paint waiting face (smoke + ring, html.siwa-wait) is up the whole time;
// verify success earns the arrival: ring gives way to the Eleven lockup,
// a beat to land, then the cover lifts as the spaces screen slides up.
-async function finishSiwa(idToken) {
+async function finishSiwa(idToken, backToMethods = false, intentStr = "") {
setStatus("Confirming it’s you…", "busy");
try {
const res = await apiJSON("/api/siwa/verify", {
@@ -473,16 +497,36 @@ async function finishSiwa(idToken) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identity_token: idToken }),
});
+ noteProof(res.proof); // fills the confirm-step pocket (Sign-in options)
markSignedIn("apple");
persistSession(res.token, "apple");
await enterAnchorSession(res); // renders the landing beneath the cover
} catch {
+ if (backToMethods && state.token) {
+ // A failed confirm-with-Apple: the tab's session is untouched — land
+ // back on the list with the refusal (the intent was already consumed).
+ await openMethods();
+ methodsStatus("That Apple sign-in couldn’t be confirmed — try again.", "error");
+ finishHomeLoader({ immediate: true });
+ return;
+ }
// No brand moment for a sign-in that didn't happen: lift straight to
// the signed-out card and say what went wrong.
setStatus("That Apple sign-in couldn’t be confirmed — try again.", "error");
finishHomeLoader({ immediate: true });
return;
}
+ if (backToMethods) {
+ // A confirm-with-Apple round trip (the methods step sent us): no arrival
+ // pageantry — straight back to Sign-in options, proof in the pocket. A
+ // persisted intent resumes the interrupted change — the add step opens
+ // (or the removal re-arms) instead of landing on the bare list.
+ await openMethods();
+ const intent = parseIntentToken(intentStr);
+ if (intent && freshProof()) runIntent(intent, freshProof());
+ finishHomeLoader({ immediate: true });
+ return;
+ }
anchorArrival();
}
@@ -504,6 +548,112 @@ function anchorArrival() {
}, reduced ? 0 : 850); // 400ms brand fade + 450ms dwell
}
+// finishSiwaLink completes an ADD-APPLE round trip (the #siwa= return with a
+// link marker): the ceremony proof stashed before leaving for Apple rode
+// sessionStorage — same tab — and must still be inside its 5-minute window on
+// return. The link verify never signs anyone in; this tab's own session
+// (which survived the navigation) is resumed instead, and the outcome lands
+// inline on the Sign-in options step.
+async function finishSiwaLink(idToken, markRaw) {
+ let mark = null;
+ try { mark = JSON.parse(markRaw); } catch { /* mangled marker — treat as lapsed */ }
+ let outcome, conflict = false;
+ if (!mark || !mark.proof || !(mark.expires > Date.now())) {
+ // The proof's 5-minute window lapsed during the trip — re-prompt.
+ outcome = { msg: "That took a little too long — confirm it's you again, then tap Add Apple.", kind: "error" };
+ } else {
+ try {
+ const res = await fetch("/api/siwa/verify", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Ceremony-Proof": mark.proof },
+ body: JSON.stringify({ identity_token: idToken, link: true }),
+ });
+ if (res.ok) {
+ outcome = { msg: "Apple is now a way into your account.", kind: "ok" };
+ } else {
+ // The server's refusals verbatim: a lapsed proof (401 — the line
+ // asks to confirm again), or the 409 "that Apple ID already signs in
+ // somewhere else" — the COMBINE doorway (#1098): the offer keys off
+ // the status, never off message text.
+ const msg = ((await res.text().catch(() => "")) || "").trim();
+ conflict = res.status === 409;
+ outcome = { msg: msg || "Couldn't add Apple — try again.", kind: "error" };
+ }
+ } catch {
+ outcome = { msg: "Couldn't add Apple — try again.", kind: "error" };
+ }
+ }
+ if (await resumeAfterLinkReturn()) {
+ await openMethods();
+ methodsStatus(outcome.msg, outcome.kind);
+ if (conflict) {
+ combine = { kind: "apple" };
+ showCombineOffer("methods-status");
+ }
+ } else {
+ // No session survived the round trip (storage cleared mid-flight) — the
+ // doors, with the outcome where the visitor will read it.
+ setStatus(outcome.msg, outcome.kind === "ok" ? "" : "error");
+ }
+ finishHomeLoader({ immediate: true });
+}
+
+// finishSiwaMergeProve is the merge-apple intent's return leg: the combine
+// step sent the person to Apple to prove the OTHER account is theirs. The
+// id_token verifies in SIGN-IN mode, but the answer is deliberately NOT
+// adopted — the absorbed account's token+proof are held in memory for the
+// merge only, and this tab's own (survivor) session resumes as-is.
+async function finishSiwaMergeProve(idToken) {
+ let creds = null;
+ try {
+ const res = await apiJSON("/api/siwa/verify", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ identity_token: idToken }),
+ });
+ creds = { token: res.token, proof: res.proof, expires: Date.now() + PROOF_TTL_MS };
+ } catch { /* falls through to the refusal below */ }
+ if (!(await resumeAfterLinkReturn())) {
+ setStatus("The combine couldn't resume — sign in and try again.", "error");
+ finishHomeLoader({ immediate: true });
+ return;
+ }
+ await openMethods();
+ if (!creds) {
+ methodsStatus("That Apple sign-in couldn’t be confirmed — try combining again.", "error");
+ finishHomeLoader({ immediate: true });
+ return;
+ }
+ combine = { kind: "apple", absorbedToken: creds.token, absorbedProof: creds.proof, absorbedExpires: creds.expires };
+ openCombineStep(); // proven — lands straight on the spaces stage
+ finishHomeLoader({ immediate: true });
+}
+
+// resumeAfterLinkReturn rebuilds the signed-in view after an add-Apple round
+// trip: the tab's own session survived the navigation (sessionStorage), or
+// the stay-signed-in token did (localStorage). True once a signed-in screen
+// is up again; false means every session evaporated mid-trip.
+async function resumeAfterLinkReturn() {
+ try {
+ if (state.token && state.dek) {
+ const got = await fetchBlob(state.token);
+ if (got) {
+ dekKey = await importDEK(state.dek);
+ const opened = await openBlob(got.bytes, dekKey);
+ vault = { obj: opened.obj, keys: opened.keys, version: got.version };
+ showVault();
+ return true;
+ }
+ }
+ const saved = state.token ? { token: state.token } : persistedSession();
+ if (saved) {
+ await enterAnchorSession({ token: saved.token, created: false });
+ return true;
+ }
+ } catch { /* fall through to the doors */ }
+ return false;
+}
+
// The email door (email.go): type your address, get a 6-digit code, type it —
// find-or-create like Apple. The address goes only into the two request
// bodies; nothing here stores it. It's a card step of its own — the doors
@@ -639,6 +789,7 @@ async function verifyEmailCode(e) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, code }),
}), spinFloor]);
+ noteProof(res.proof); // fills the confirm-step pocket (Sign-in options)
markSignedIn("email");
persistSession(res.token, "email");
await enterAnchorSession(res);
@@ -666,6 +817,7 @@ async function finishEmailLink(email, code) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, code }),
});
+ noteProof(res.proof); // fills the confirm-step pocket (Sign-in options)
markSignedIn("email");
persistSession(res.token, "email");
await enterAnchorSession(res); // renders the landing beneath the cover
@@ -737,6 +889,13 @@ function signOut() {
$("upgrade").classList.add("hidden");
$("claim").classList.add("hidden");
$("archive").classList.add("hidden");
+ for (const s of METHOD_SCREENS) $(s).classList.add("hidden");
+ resetMethodsSteps();
+ pendingIntent = null;
+ combine = null;
+ anchors = null;
+ armedRemove = "";
+ ceremonyProof = null; // proofs are per-person, never per-browser
archiving = null;
clearUndo();
$("spaces").textContent = "";
@@ -755,7 +914,8 @@ function signOut() {
// ---- the account menu (issue #1098's single control) ---------------------------
// One kebab in the vault header collects everything about the account itself:
-// Sign out today; "How you sign in" and account-merge join it later (#1098).
+// "Sign-in options" (the methods step family below) and Sign out;
+// account-merge joins it later (#1098's next slice).
// Menu semantics by hand: focus lands on the first item on open and returns
// to the kebab on close; Escape and click-outside both close.
function accountMenuOpen() {
@@ -796,6 +956,1052 @@ function accountMenuKey(e) {
if (e.key === "Escape") { e.preventDefault(); closeAccountMenu(true); }
}
+// ---- sign-in options (issue #1098) ----------------------------------------------
+
+// The ceremony-proof pocket. Removing or adding a sign-in method needs a
+// FRESH proof (the server holds them to a 5-minute window — stateless.go's
+// ceremonyWindow); every ceremony that mints one drops it here, and
+// freshProof answers only while a client-side mirror of that window still
+// has margin, so a call never leaves with a proof that will be refused on
+// arrival. In-memory only, like the passkey path's transient use.
+const PROOF_TTL_MS = 5 * 60 * 1000;
+const PROOF_MARGIN_MS = 10 * 1000;
+let ceremonyProof = null; // { proof, expires } | null
+function noteProof(proof) {
+ if (proof) ceremonyProof = { proof, expires: Date.now() + PROOF_TTL_MS };
+}
+function freshProof() {
+ return ceremonyProof && ceremonyProof.expires - PROOF_MARGIN_MS > Date.now()
+ ? ceremonyProof.proof : null;
+}
+
+// The kind vocabulary, generalized: every sign-in method the product knows,
+// in listing order. A linked kind's ADD lives in each of its rows' kebab (a
+// plain "Add another" — the row already names the kind); a kind with ZERO
+// linked entries gets a big first-add button below the list instead. Future
+// doors (google/microsoft) slot in here — same shape, gated by their DOORS
+// flag — with no restructuring.
+const METHOD_KINDS = [
+ { kind: "passkey", name: "Passkey", first: "Add a passkey",
+ enabled: () => !!window.PublicKeyCredential, start: (e) => startAddPasskey(e) },
+ { kind: "siwa", name: "Apple", first: "Add Apple",
+ enabled: () => DOORS.apple, start: (e) => startAddApple(e) },
+ { kind: "email", name: "Email", first: "Add email",
+ enabled: () => DOORS.email, start: (e) => startAddEmail(e) },
+];
+
+// The step family is SERIAL card screens — one at a time, each with a back:
+// the list, the confirm beat, add-email, add-passkey. (Add-Apple's "step" is
+// the Apple journey itself.) showMethodsScreen swaps within the family;
+// callers manage focus.
+const METHOD_SCREENS = ["methods", "methods-confirm", "methods-email", "methods-passkey", "methods-combine"];
+function showMethodsScreen(id) {
+ closeRowMenu(false);
+ for (const s of METHOD_SCREENS) $(s).classList.toggle("hidden", s !== id);
+}
+
+function methodsStatus(msg, kind) {
+ const el = $("methods-status");
+ el.textContent = msg || "";
+ el.className = "status" + (kind ? " " + kind : "");
+}
+function confirmStatus(msg, kind) {
+ const el = $("methods-confirm-status");
+ el.textContent = msg || "";
+ el.className = "status" + (kind ? " " + kind : "");
+}
+function emailStepStatus(msg, kind) {
+ const el = $("methods-email-status");
+ el.textContent = msg || "";
+ el.className = "status" + (kind ? " " + kind : "");
+}
+function passkeyStepStatus(msg, kind) {
+ const el = $("methods-passkey-status");
+ el.textContent = msg || "";
+ el.className = "status" + (kind ? " " + kind : "");
+}
+
+// anchors is the last-fetched listing (GET /api/anchors) — renderMethods'
+// input, and what the confirm step consults to pick a ceremony this account
+// can actually run. armedRemove holds the row currently in its inline
+// are-you-sure state, so re-renders keep it armed.
+let anchors = null;
+let armedRemove = "";
+// pendingIntent is the change the confirm step interrupted, AS DATA — the one
+// continuation system behind every confirm path. In-page ceremonies resume it
+// from memory (payloads intact); the confirm-with-Apple round trip carries
+// its intentToken through sessionStorage and the return leg resumes the same
+// way. Shapes: { kind: "add-email" | "add-passkey" | "add-apple" }, { kind:
+// "verify-email", email, code } (memory-only payload), { kind: "remove",
+// anchorKind, id, armed }.
+let pendingIntent = null;
+
+// intentToken / parseIntentToken serialize a pending intent for the Apple
+// confirmation's full-page round trip (sessionStorage, set-or-cleared per
+// trip like the other markers). Only the change's IDENTITY rides — never a
+// payload: a typed address/code stays in memory, so "verify-email" degrades
+// to reopening the add-email step (the same mailed code can be retyped
+// there), and a removal's passed are-you-sure is deliberately dropped (a
+// reloaded page re-arms rather than auto-deleting). Anchor ids are
+// base64url/hex, so ":" can't ambiguate the remove form.
+function intentToken(intent) {
+ if (!intent) return "";
+ if (intent.kind === "remove") return "remove:" + intent.anchorKind + ":" + intent.id;
+ if (intent.kind === "verify-email") return "add-email";
+ if (intent.kind === "combine-finish") return ""; // the combine context is memory-only
+ return intent.kind; // add-email | add-passkey | add-apple
+}
+function parseIntentToken(s) {
+ if (s === "add-email" || s === "add-passkey" || s === "add-apple") return { kind: s };
+ const m = /^remove:([^:]+):(.+)$/.exec(s || "");
+ return m ? { kind: "remove", anchorKind: m[1], id: m[2] } : null;
+}
+
+// runIntent performs a parked change once a fresh proof is in hand. Callers
+// guarantee the pocket is fresh; the intent decides its own navigation.
+function runIntent(intent, proof) {
+ switch (intent.kind) {
+ case "add-email": openMethodsEmailStep(); return;
+ case "add-passkey": openMethodsPasskeyStep(); return;
+ case "add-apple": startAddApple(null); return; // pocket is fresh — straight to Apple in link mode
+ case "verify-email":
+ // In-memory only (never serialized): retry the exact typed code — it
+ // survived, link mode gates on the proof BEFORE redeeming (email.go).
+ backToMethodsList("", "", { reload: false });
+ finishEmailAdd(intent.email, intent.code, proof);
+ return;
+ case "combine-finish":
+ // In-memory only: the survivor's proof lapsed mid-combine; finish now.
+ combineFinish(intent.movedSpaces || 0);
+ return;
+ case "remove": {
+ const a = (anchors || []).find((x) => x.kind === intent.anchorKind && x.id === intent.id);
+ backToMethodsList("", "", { reload: !a }); // row gone meanwhile → just the fresh list
+ if (!a) return;
+ if (intent.armed) {
+ // The are-you-sure was passed right before an in-page confirm —
+ // complete the removal.
+ doRemoveAnchor(a, proof);
+ } else {
+ // The Apple round trip reloaded the page: the destructive tap stays
+ // explicit — re-arm THAT row's are-you-sure instead of auto-deleting.
+ armedRemove = intent.anchorKind + "/" + intent.id;
+ renderMethods();
+ focusMethodRow(armedRemove, "button.danger-ink");
+ }
+ return;
+ }
+ }
+}
+
+async function openMethods() {
+ hideVault();
+ resetMethodsSteps();
+ armedRemove = "";
+ pendingIntent = null;
+ methodsStatus("");
+ showMethodsScreen("methods");
+ $("methods-title").focus(); // announce the step; rows load in beneath
+ await loadAnchors();
+}
+
+function closeMethods() {
+ resetMethodsSteps();
+ pendingIntent = null;
+ combine = null; // abandoning the flow touches neither account
+ for (const s of METHOD_SCREENS) $(s).classList.add("hidden");
+ // Back to whichever signed-in view this session actually has — anchor-born
+ // sessions may still be sealed (no vault object to render).
+ if (vault) showVault(); else showSealedVault();
+}
+
+// backToMethodsList returns from a sub-step (or finishes a flow) to the
+// list, with the outcome line where the person lands.
+function backToMethodsList(msg, kind, { reload = true } = {}) {
+ showMethodsScreen("methods");
+ $("methods-title").focus();
+ methodsStatus(msg || "", kind);
+ if (reload) loadAnchors();
+}
+
+// resetMethodsSteps parks every sub-step — shared by open/close/sign-out so
+// no half-finished round trip greets the next visit.
+function resetMethodsSteps() {
+ // The confirm step.
+ $("methods-confirm-passkey").classList.add("hidden");
+ $("methods-confirm-apple").classList.add("hidden");
+ $("methods-cemail").classList.add("hidden");
+ $("methods-cemail-form").classList.remove("hidden");
+ $("methods-cemail-code-form").classList.add("hidden");
+ $("methods-cemail-addr").value = "";
+ $("methods-cemail-code").value = "";
+ confirmStatus("");
+ // The add-email step.
+ $("methods-email-form").classList.remove("hidden");
+ $("methods-email-code-form").classList.add("hidden");
+ $("methods-email-addr").value = "";
+ $("methods-email-code").value = "";
+ emailStepStatus("");
+ // The add-passkey step.
+ passkeyAddBegin = null;
+ passkeyStepStatus("");
+ // The combine step's stages + the 409 offer (the combine CONTEXT itself
+ // survives — only closing the family or finishing clears it).
+ $("combine-offer").classList.add("hidden");
+ $("combine-prove").classList.add("hidden");
+ $("combine-code").classList.add("hidden");
+ $("combine-apple-go").classList.add("hidden");
+ $("combine-code-input").value = "";
+ $("combine-vault").classList.add("hidden");
+ $("combine-unlock").classList.add("hidden");
+ $("combine-skip").classList.add("hidden");
+ $("combine-skip-confirm").classList.add("hidden");
+ combineStatus("");
+}
+
+async function loadAnchors() {
+ try {
+ const r = await apiJSON("/api/anchors", { headers: { Authorization: "Bearer " + state.token } });
+ anchors = r.anchors || [];
+ renderMethods();
+ } catch (e) {
+ methodsStatus(e.message || "Couldn't load your sign-in options — try again.", "error");
+ }
+}
+
+// methodAdded is the row's quiet second line — "added 12 Jul", year only
+// when it isn't this year's.
+function methodAdded(ts) {
+ if (!ts) return "";
+ const d = new Date(ts * 1000);
+ const opts = { day: "numeric", month: "short" };
+ if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric";
+ return "added " + d.toLocaleDateString(undefined, opts);
+}
+
+function renderMethods() {
+ closeRowMenu(false);
+ const box = $("methods-list");
+ box.textContent = "";
+ if (anchors && anchors.length) {
+ const ul = document.createElement("ul");
+ ul.className = "methods";
+ for (const a of anchors) ul.append(methodRow(a));
+ box.append(ul);
+ } else if (anchors) {
+ // Can't normally happen (a signed-in account always has a way in) — a
+ // racing removal from another device can strand a beat, though.
+ box.append(emptyLine("No sign-in methods to show."));
+ }
+ renderMethodsAdd();
+}
+
+// renderMethodsAdd builds the big first-add buttons: one per LIVE kind with
+// zero linked entries (a linked kind's add lives in its rows' kebab).
+function renderMethodsAdd() {
+ const box = $("methods-add");
+ box.textContent = "";
+ for (const k of METHOD_KINDS) {
+ if (!k.enabled()) continue;
+ if ((anchors || []).some((a) => a.kind === k.kind)) continue;
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "secondary";
+ btn.textContent = k.first;
+ btn.addEventListener("click", (e) => k.start(e));
+ box.append(btn);
+ }
+}
+
+// The same three dots as the account kebab (static, in-code SVG).
+const KEBAB_SVG =
+ '<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor">' +
+ '<circle cx="8" cy="3" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="8" cy="13" r="1.5"/></svg>';
+
+function methodRow(a) {
+ const li = document.createElement("li");
+ const key = a.kind + "/" + a.id;
+ li.dataset.key = key;
+ const def = METHOD_KINDS.find((k) => k.kind === a.kind);
+ const label = def ? def.name : a.kind;
+ const added = methodAdded(a.created_at);
+ const longName = label + (added ? ", " + added : ""); // for the aria labels
+
+ const main = document.createElement("div");
+ main.className = "method-main";
+ const name = document.createElement("span");
+ name.className = "method-name";
+ name.textContent = label;
+ main.append(name);
+ if (added) {
+ const sub = document.createElement("span");
+ sub.className = "method-added";
+ sub.textContent = added;
+ main.append(sub);
+ }
+ li.append(main);
+
+ if (armedRemove === key) {
+ // The inline are-you-sure beat (never a browser confirm()): the row's
+ // own controls become the question.
+ const yes = document.createElement("button");
+ yes.type = "button";
+ yes.className = "pill danger-ink";
+ yes.textContent = "Remove";
+ yes.setAttribute("aria-label", "Yes, remove " + longName);
+ yes.addEventListener("click", () => {
+ armedRemove = "";
+ renderMethods(); // disarm before the (async) removal repaints
+ focusMethodRow(key, "button"); // keep focus off <body> mid-flight
+ performRemove(a);
+ });
+ const no = document.createElement("button");
+ no.type = "button";
+ no.className = "pill";
+ no.textContent = "Keep";
+ no.setAttribute("aria-label", "Keep " + longName);
+ no.addEventListener("click", () => {
+ armedRemove = "";
+ renderMethods();
+ focusMethodRow(key, "button");
+ });
+ li.append(yes, no);
+ } else {
+ li.append(rowKebab(key, def, longName, a));
+ }
+ return li;
+}
+
+// rowKebab is the row's own menu — the account kebab's vocabulary scaled
+// down: aria-haspopup/expanded, focus onto the first item, Escape and
+// outside-click both close (the shared manager keeps one open at a time).
+// It holds "Add another" (when the kind's door is live) and Remove.
+function rowKebab(key, def, longName, a) {
+ const wrap = document.createElement("div");
+ wrap.className = "row-kebab-wrap";
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "icon-kebab";
+ btn.setAttribute("aria-haspopup", "menu");
+ btn.setAttribute("aria-expanded", "false");
+ btn.setAttribute("aria-label", "Options for " + longName);
+ btn.innerHTML = KEBAB_SVG;
+ const menu = document.createElement("div");
+ menu.className = "menu-pop hidden";
+ menu.setAttribute("role", "menu");
+ menu.setAttribute("aria-label", "Options for " + longName);
+ if (def && def.enabled()) {
+ const add = document.createElement("button");
+ add.type = "button";
+ add.setAttribute("role", "menuitem");
+ // Visibly just "Add another" — the row names the kind. The accessible
+ // name carries it for menus read out of visual context ("Add another
+ // Passkey"); label-in-name holds, the visible text is its prefix.
+ add.textContent = "Add another";
+ add.setAttribute("aria-label", "Add another " + (def.name || a.kind));
+ add.addEventListener("click", (e) => { closeRowMenu(false); def.start(e); });
+ menu.append(add);
+ }
+ const rm = document.createElement("button");
+ rm.type = "button";
+ rm.setAttribute("role", "menuitem");
+ rm.className = "danger-ink";
+ rm.textContent = "Remove";
+ rm.setAttribute("aria-label", "Remove " + longName);
+ rm.addEventListener("click", () => {
+ closeRowMenu(false);
+ armedRemove = key;
+ renderMethods();
+ focusMethodRow(key, "button.danger-ink"); // the are-you-sure's Remove
+ });
+ menu.append(rm);
+ btn.addEventListener("pointerdown", () => { rowMenuPointerOpen = true; });
+ btn.addEventListener("click", () => toggleRowMenu(btn, menu));
+ wrap.append(btn, menu);
+ return wrap;
+}
+
+// One row menu open at a time, with the account menu's open/close manners
+// (including its pointer-open focus-ring courtesy).
+let openRowMenuState = null; // { btn, menu } | null
+let rowMenuPointerOpen = false;
+function toggleRowMenu(btn, menu) {
+ if (openRowMenuState && openRowMenuState.menu === menu) { closeRowMenu(true); return; }
+ closeRowMenu(false);
+ menu.classList.toggle("pointer-open", rowMenuPointerOpen);
+ rowMenuPointerOpen = false;
+ menu.classList.remove("hidden");
+ btn.setAttribute("aria-expanded", "true");
+ openRowMenuState = { btn, menu };
+ menu.querySelector('[role="menuitem"]').focus();
+ addEventListener("pointerdown", rowMenuOutside, true);
+ addEventListener("keydown", rowMenuKey, true);
+}
+function closeRowMenu(refocus) {
+ if (!openRowMenuState) return;
+ openRowMenuState.menu.classList.add("hidden");
+ openRowMenuState.btn.setAttribute("aria-expanded", "false");
+ if (refocus) openRowMenuState.btn.focus();
+ openRowMenuState = null;
+ removeEventListener("pointerdown", rowMenuOutside, true);
+ removeEventListener("keydown", rowMenuKey, true);
+}
+function rowMenuOutside(e) {
+ if (!e.target.closest || !e.target.closest(".row-kebab-wrap")) closeRowMenu(false);
+}
+function rowMenuKey(e) {
+ if (e.key === "Escape") { e.preventDefault(); closeRowMenu(true); }
+}
+
+// focusMethodRow puts focus back where a re-render dropped it.
+function focusMethodRow(key, selector) {
+ for (const li of $("methods-list").querySelectorAll("li")) {
+ if (li.dataset.key === key) { li.querySelector(selector)?.focus(); return; }
+ }
+}
+
+// performRemove: a fresh proof first — SERIALLY, via the confirm step when
+// the pocket is empty (list → confirm → back to the list → the DELETE) —
+// then doRemoveAnchor. The server's refusals — notably the last-method 409
+// ("that's the last way into this account…") — show inline, verbatim.
+function performRemove(a) {
+ const p = freshProof();
+ if (p) { doRemoveAnchor(a, p); return; }
+ // armed: the are-you-sure was just passed, so an in-page confirm completes
+ // the removal; the round-trip serialization drops the flag on purpose.
+ pendingIntent = { kind: "remove", anchorKind: a.kind, id: a.id, armed: true };
+ openMethodsConfirmStep();
+}
+
+async function doRemoveAnchor(a, proof) {
+ methodsStatus("Removing…", "busy");
+ try {
+ await apiJSON("/api/anchors/" + encodeURIComponent(a.kind) + "/" + encodeURIComponent(a.id), {
+ method: "DELETE",
+ headers: { "X-Ceremony-Proof": proof },
+ });
+ methodsStatus("Removed.", "ok");
+ } catch (e) {
+ methodsStatus((e && e.message) || "Couldn't remove it.", "error");
+ }
+ await loadAnchors(); // either way — a refusal means the list needs truth
+ // The focused row may just have vanished — never strand focus on <body>.
+ if (document.activeElement === document.body) $("methods-title").focus();
+}
+
+function resumeIntent() {
+ const intent = pendingIntent;
+ pendingIntent = null;
+ const p = freshProof();
+ if (intent && p) runIntent(intent, p);
+}
+
+// ---- the confirm step -----------------------------------------------------------
+
+// openMethodsConfirmStep is the serial "it's really you" screen, FIRST
+// whenever an action needs a proof the pocket doesn't hold. It offers
+// whatever the account can actually run, best first: the passkey ceremony,
+// an email code re-verify (sign-in mode — the answer's proof is all we
+// keep), else a fresh Apple sign-in that lands straight back on the list.
+function openMethodsConfirmStep(note) {
+ resetMethodsSteps(); // fresh forms; the parked change rides pendingIntent
+ showMethodsScreen("methods-confirm");
+ const havePasskey = !!window.PublicKeyCredential && (anchors || []).some((x) => x.kind === "passkey");
+ const haveEmail = (anchors || []).some((x) => x.kind === "email");
+ $("methods-confirm-passkey").classList.toggle("hidden", !havePasskey);
+ $("methods-confirm-apple").classList.toggle("hidden", havePasskey || haveEmail);
+ const why = $("methods-confirm-why");
+ if (note) confirmStatus(note, "error");
+ if (havePasskey) {
+ why.textContent = "Changing your sign-in options needs a quick check — confirm with your passkey and you'll carry straight on.";
+ $("methods-confirm-passkey").focus();
+ } else if (haveEmail) {
+ why.textContent = "Changing your sign-in options needs a quick check — enter the email address you sign in with and we'll send a code.";
+ $("methods-cemail").classList.remove("hidden");
+ $("methods-cemail-addr").focus();
+ } else {
+ why.textContent = "Changing your sign-in options needs a quick check — sign in with Apple again and you'll come straight back here.";
+ $("methods-confirm-apple").focus();
+ }
+}
+
+async function confirmMethodsPasskey() {
+ const btn = $("methods-confirm-passkey");
+ btn.disabled = true;
+ confirmStatus("Confirming it’s you…", "busy");
+ try {
+ // Only the proof is kept — this tab's session stays as it is (picking a
+ // different account's passkey just makes the follow-on call miss, said
+ // inline on the list).
+ const { proof } = await runLogin(loginBegin);
+ noteProof(proof);
+ resumeIntent();
+ } catch (e) {
+ confirmStatus(friendlyCeremonyError(e, "signin").message, "error");
+ } finally {
+ prefetchLogin(); // ceremony options are single-use — ready the next
+ btn.disabled = false;
+ }
+}
+
+function confirmMethodsApple(e) {
+ // A fresh Apple sign-in mints the proof (finishSiwa pockets it); the
+ // markers route the return leg straight back into the flow: methodsReturn
+ // lands on the step family, and the serialized intent RESUMES the parked
+ // change (the add step opens, a removal re-arms) instead of the bare list.
+ const intent = intentToken(pendingIntent);
+ pendingIntent = null; // payloads can't survive the navigation; the token rides
+ startAppleSignIn(e, { methodsReturn: true, intent });
+}
+
+// The confirm step's email re-verify: the same two-step form as the door,
+// but the answer's TOKEN is deliberately dropped — the proof is the point,
+// and this tab keeps the session it already has.
+async function cemailSend(e) {
+ e.preventDefault();
+ const email = $("methods-cemail-addr").value.trim();
+ const btn = $("methods-cemail-send");
+ btn.disabled = true;
+ btn.classList.add("loading");
+ confirmStatus("Sending your code…", "busy");
+ const spinFloor = new Promise((r) => setTimeout(r, 900)); // same floor as the door
+ try {
+ await Promise.all([apiJSON("/api/email/request", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email }),
+ }), spinFloor]);
+ confirmStatus("Check your email for a 6-digit code.", "");
+ $("methods-cemail-form").classList.add("hidden");
+ $("methods-cemail-code-form").classList.remove("hidden");
+ $("methods-cemail-code").focus();
+ } catch (err) {
+ confirmStatus(err.message || "Couldn’t send the code — try again.", "error");
+ } finally {
+ btn.disabled = false;
+ btn.classList.remove("loading");
+ }
+}
+
+async function cemailVerify(e) {
+ e.preventDefault();
+ const email = $("methods-cemail-addr").value.trim();
+ const code = $("methods-cemail-code").value.trim();
+ const btn = $("methods-cemail-verify");
+ btn.disabled = true;
+ btn.classList.add("loading");
+ confirmStatus("Checking your code…", "busy");
+ try {
+ const res = await apiJSON("/api/email/verify", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email, code }),
+ });
+ noteProof(res.proof);
+ resumeIntent();
+ } catch (err) {
+ confirmStatus(err.message || "That code didn’t work — try again.", "error");
+ } finally {
+ btn.disabled = false;
+ btn.classList.remove("loading");
+ }
+}
+
+// ---- the add steps --------------------------------------------------------------
+
+// startAddApple begins the ADD-APPLE round trip: a fresh proof first —
+// serially, via the confirm step when needed — then the ordinary Apple
+// hand-off with the link marker, so the #siwa= return posts link:true
+// instead of signing in (the proof rides sessionStorage beside the state;
+// the server holds it to the same 5-minute window).
+function startAddApple(e) {
+ const src = e && e.currentTarget;
+ const go = (proof) => startAppleSignIn(
+ // The pressed button spins when it's still on screen (the big first-add
+ // button); a menu-born add has no visible button left, so a detached one
+ // absorbs the spinner and the smoke cover carries the wait.
+ { currentTarget: src && src.isConnected ? src : document.createElement("button") },
+ { linkProof: { proof, expires: ceremonyProof ? ceremonyProof.expires : Date.now() + PROOF_TTL_MS } });
+ const p = freshProof();
+ if (p) { go(p); return; }
+ pendingIntent = { kind: "add-apple" };
+ openMethodsConfirmStep();
+}
+
+// startAddEmail / startAddPasskey open their serial card steps — confirm
+// first when the pocket is empty (list → confirm → the add step → list).
+function startAddEmail() {
+ if (freshProof()) { openMethodsEmailStep(); return; }
+ pendingIntent = { kind: "add-email" };
+ openMethodsConfirmStep();
+}
+function startAddPasskey() {
+ if (freshProof()) { openMethodsPasskeyStep(); return; }
+ pendingIntent = { kind: "add-passkey" };
+ openMethodsConfirmStep();
+}
+
+function openMethodsEmailStep() {
+ resetMethodsSteps();
+ showMethodsScreen("methods-email");
+ $("methods-email-addr").focus();
+}
+
+async function methodsEmailSend(e) {
+ e.preventDefault();
+ const email = $("methods-email-addr").value.trim();
+ const btn = $("methods-email-send");
+ btn.disabled = true;
+ btn.classList.add("loading");
+ emailStepStatus("Sending your code…", "busy");
+ const spinFloor = new Promise((r) => setTimeout(r, 900));
+ try {
+ await Promise.all([apiJSON("/api/email/request", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email }),
+ }), spinFloor]);
+ emailStepStatus("Check your email for a 6-digit code.", "");
+ $("methods-email-form").classList.add("hidden");
+ $("methods-email-code-form").classList.remove("hidden");
+ $("methods-email-code").focus();
+ } catch (err) {
+ emailStepStatus(err.message || "Couldn’t send the code — try again.", "error");
+ } finally {
+ btn.disabled = false;
+ btn.classList.remove("loading");
+ }
+}
+
+async function methodsEmailVerify(e) {
+ e.preventDefault();
+ const email = $("methods-email-addr").value.trim();
+ const code = $("methods-email-code").value.trim();
+ const proof = freshProof();
+ if (!proof) {
+ // The proof lapsed while the code round-tripped. The code survives —
+ // link mode gates on the proof BEFORE redeeming (email.go) — so run the
+ // confirm step again, then retry this exact verify.
+ pendingIntent = { kind: "verify-email", email, code };
+ openMethodsConfirmStep("That took a little while — confirm it’s you again.");
+ return;
+ }
+ const btn = $("methods-email-verify");
+ btn.disabled = true;
+ btn.classList.add("loading");
+ emailStepStatus("Checking your code…", "busy");
+ try {
+ await finishEmailAdd(email, code, proof);
+ } finally {
+ btn.disabled = false;
+ btn.classList.remove("loading");
+ }
+}
+
+// finishEmailAdd posts the link verify and lands the outcome: success goes
+// back to the list with the good news; a refusal shows wherever the person
+// is (the add step, or the list after a confirm-step retry), verbatim. The
+// 409 ("that address already signs in somewhere else") is the COMBINE
+// doorway (#1098): the error line grows the offer that opens the combine
+// step — keyed off the status, never off message text.
+async function finishEmailAdd(email, code, proof) {
+ const res = await fetch("/api/email/verify", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Ceremony-Proof": proof },
+ body: JSON.stringify({ email, code, link: true }),
+ });
+ if (res.ok) {
+ backToMethodsList("Email added — you can sign in with it now.", "ok");
+ return;
+ }
+ const msg = ((await res.text().catch(() => "")) || "").trim() || "That code didn’t work — try again.";
+ const onEmailStep = !$("methods-email").classList.contains("hidden");
+ if (onEmailStep) emailStepStatus(msg, "error"); else backToMethodsList(msg, "error");
+ if (res.status === 409) {
+ combine = { kind: "email", email };
+ showCombineOffer(onEmailStep ? "methods-email-status" : "methods-status");
+ }
+}
+
+// The add-passkey step: one button, so navigator.credentials.create fires
+// inside a fresh tap (Safari drops the activation across a network await).
+// The register options are fetched WITH the proof at step open — that's what
+// makes the new passkey attach to THIS account instead of minting a fresh
+// one (webauthn.go's register/begin).
+let passkeyAddBegin = null;
+
+function openMethodsPasskeyStep() {
+ resetMethodsSteps();
+ showMethodsScreen("methods-passkey");
+ $("methods-passkey-create").focus();
+ prefetchPasskeyAddBegin();
+}
+
+function prefetchPasskeyAddBegin() {
+ const proof = freshProof();
+ if (!proof) return;
+ apiJSON("/api/register/begin", { method: "POST", headers: { "X-Ceremony-Proof": proof } })
+ .then((v) => { passkeyAddBegin = v; })
+ .catch(() => { passkeyAddBegin = null; });
+}
+
+async function addPasskeyCeremony() {
+ const btn = $("methods-passkey-create");
+ btn.disabled = true;
+ passkeyStepStatus("Creating your passkey…", "busy");
+ let begin = passkeyAddBegin;
+ passkeyAddBegin = null; // ceremony options are single-use
+ try {
+ if (!begin) {
+ const proof = freshProof();
+ if (!proof) {
+ // Sat on this step past the window — the confirm step runs again,
+ // then reopens here with fresh proof-carrying options.
+ pendingIntent = { kind: "add-passkey" };
+ openMethodsConfirmStep("That took a little while — confirm it’s you again.");
+ return;
+ }
+ // The prefetch missed (slow network); fetching here may cost Safari's
+ // activation — the retry tap then uses the fresh prefetch below.
+ begin = await apiJSON("/api/register/begin", { method: "POST", headers: { "X-Ceremony-Proof": proof } });
+ }
+ const reg = await runRegister(begin);
+ noteProof(reg.proof);
+ // When this tab holds the DEK and a blob already exists, wrap it for the
+ // new credential too — the passkey then OPENS the list, not just signs
+ // in. Sealed/anchor tabs (and blob-less accounts — an empty vault save
+ // would trip the creation-eligibility gate, see createPasskey) skip it:
+ // identity-only is still a real way in under the anchor model.
+ if (dekKey && vault && vault.version !== "0" && reg.prf) {
+ try {
+ const kek = await kekFromPRF(reg.prf);
+ vault.keys[reg.credId] = await wrapDEK(dekKey, kek);
+ await saveVault();
+ } catch { /* the passkey works for sign-in; the wrap can land later */ }
+ }
+ backToMethodsList("Passkey added — one tap next time on this device.", "ok");
+ } catch (e) {
+ prefetchPasskeyAddBegin(); // options were spent — ready a retry
+ if (e && e.message === "no-prf") {
+ passkeyStepStatus("This device can’t protect your sign-in here (no PRF support) — try a different device or password manager.", "error");
+ } else {
+ passkeyStepStatus(formatCeremonyError(e, "create"), "error");
+ }
+ } finally {
+ btn.disabled = false;
+ }
+}
+
+// ---- combine your accounts (#1098's merge) --------------------------------------
+
+// The in-flight combine context, memory-only: which anchor kind collided
+// ("email" | "apple"), the typed address (email kind), and — once stage A
+// proves the other side — the ABSORBED account's credentials. Those are
+// deliberately never adopted as the session and never persisted: this tab
+// stays signed into the SURVIVOR; the absorbed token+proof exist only to
+// move its vault and feed absorb_proof to POST /api/account/merge
+// (merge.go). Cancel anywhere just drops this object — nothing destructive
+// happens before the explicit beats.
+let combine = null; // { kind, email?, absorbedToken?, absorbedProof?, absorbedExpires?, absorbedBlob?, absorbedPasskeyIds? }
+
+function combineStatus(msg, kind) {
+ const el = $("combine-status");
+ el.textContent = msg || "";
+ el.className = "status" + (kind ? " " + kind : "");
+}
+
+// showCombineOffer grows the offer under whichever status line carries the
+// 409 — the node moves beside it (like #newhere follows its surface) and
+// resets hide it again.
+function showCombineOffer(statusId) {
+ $(statusId).insertAdjacentElement("afterend", $("combine-offer"));
+ $("combine-offer").classList.remove("hidden");
+}
+
+// absorbedProofFresh mirrors freshProof for the OTHER side's proof — the
+// merge and the absorbed-blob delete both refuse a stale one server-side,
+// so a lapsed proof re-runs the prove stage instead of burning a call.
+function absorbedProofFresh() {
+ return !!(combine && combine.absorbedProof && combine.absorbedExpires - PROOF_MARGIN_MS > Date.now());
+}
+
+// openCombineStep is the serial combine screen. Stage A (prove the other
+// account) when the absorbed side is still unproven; a proven side goes
+// straight to stage B (its spaces). The Apple prove is a full round trip
+// (merge-apple intent), so its return re-enters here already proven.
+function openCombineStep() {
+ if (!combine) { backToMethodsList("", "", { reload: false }); return; }
+ resetMethodsSteps();
+ showMethodsScreen("methods-combine");
+ $("combine-title").focus();
+ if (absorbedProofFresh()) { combineVaultStage(); return; }
+ combine.absorbedProof = ""; // a stale proof re-proves from scratch
+ $("combine-prove").classList.remove("hidden");
+ if (combine.kind === "email") {
+ // The add-flow's code was spent by the 409 (the redeem happens before
+ // the conflict answer), so a FRESH code proves the other side — say so.
+ $("combine-prove-note").textContent =
+ "First, prove the other account is yours: we’re sending another code to " + combine.email + ".";
+ $("combine-code").classList.remove("hidden");
+ combineRequestCode();
+ $("combine-code-input").focus();
+ } else {
+ $("combine-prove-note").textContent =
+ "First, prove the other account is yours: sign in with that Apple ID — you’ll come straight back here.";
+ $("combine-apple-go").classList.remove("hidden");
+ $("combine-apple-go").focus();
+ }
+}
+
+async function combineRequestCode() {
+ combineStatus("Sending a code…", "busy");
+ try {
+ await apiJSON("/api/email/request", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: combine.email }),
+ });
+ combineStatus("We sent another code to prove that account is yours — check your email.", "");
+ } catch (e) {
+ combineStatus((e && e.message) || "Couldn’t send the code — try again.", "error");
+ }
+}
+
+// The prove-by-code verify runs in SIGN-IN mode (link:false): the answer is
+// the absorbed account's token + proof, held in memory only (see combine).
+async function combineCodeVerify(e) {
+ e.preventDefault();
+ const code = $("combine-code-input").value.trim();
+ const btn = $("combine-code-verify");
+ btn.disabled = true;
+ btn.classList.add("loading");
+ combineStatus("Checking your code…", "busy");
+ const spinFloor = new Promise((r) => setTimeout(r, 900)); // same floor as the door
+ try {
+ const [res] = await Promise.all([apiJSON("/api/email/verify", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: combine.email, code }),
+ }), spinFloor]);
+ combine.absorbedToken = res.token;
+ combine.absorbedProof = res.proof;
+ combine.absorbedExpires = Date.now() + PROOF_TTL_MS;
+ combineStatus("");
+ combineVaultStage();
+ } catch (err) {
+ combineStatus((err && err.message) || "That code didn’t work — try again.", "error");
+ } finally {
+ btn.disabled = false;
+ btn.classList.remove("loading");
+ }
+}
+
+// The prove-by-Apple leg: a full round trip carrying the merge-apple intent;
+// finishSiwaMergeProve picks the return up and re-enters the step proven.
+function combineAppleProve(e) {
+ startAppleSignIn(e, { intent: "merge-apple" });
+}
+
+// Stage B: the absorbed account's saved spaces. Nothing to move (no blob)
+// skips straight to the merge; a real blob offers the honest choices —
+// unlock with ITS passkey and bring them over, or (behind an inline
+// are-you-sure) combine without them, destroying its sealed list.
+async function combineVaultStage() {
+ $("combine-prove").classList.add("hidden");
+ $("combine-code").classList.add("hidden");
+ $("combine-apple-go").classList.add("hidden");
+ $("combine-skip-confirm").classList.add("hidden");
+ combineStatus("Checking the other account’s spaces…", "busy");
+ let got;
+ try {
+ got = await fetchBlob(combine.absorbedToken);
+ if (got) {
+ const r = await apiJSON("/api/anchors", { headers: { Authorization: "Bearer " + combine.absorbedToken } });
+ combine.absorbedPasskeyIds = (r.anchors || []).filter((x) => x.kind === "passkey").map((x) => x.id);
+ }
+ } catch (e) {
+ combineStatus((e && e.message) || "Couldn’t reach the other account — try again.", "error");
+ return;
+ }
+ if (!got) { combineFinish(0); return; } // (a) nothing to move
+ combine.absorbedBlob = got;
+ combineStatus("");
+ const canCeremony = !!window.PublicKeyCredential && combine.absorbedPasskeyIds.length > 0;
+ // The union needs the SURVIVOR side open too: its DEK in hand and at least
+ // one wrapped copy of it to seal under (a keys-less PUT would mint a blob
+ // nobody could ever open).
+ const canUnion = !!dekKey && !!vault && Object.keys(vault.keys || {}).length > 0;
+ $("combine-vault").classList.remove("hidden");
+ $("combine-unlock").classList.toggle("hidden", !(canCeremony && canUnion));
+ $("combine-skip").classList.remove("hidden");
+ const note = $("combine-vault-note");
+ if (canCeremony && canUnion) {
+ note.textContent = "The other account has saved spaces. Unlock them with that account’s passkey and they’ll move over with everything else.";
+ $("combine-unlock").focus();
+ prefetchLogin(); // the unlock ceremony wants gesture-fresh options
+ } else if (!canUnion) {
+ note.textContent = "The other account has saved spaces, but your own spaces are locked right now, so they can’t be brought over. Go back and unlock your spaces with your passkey first — or combine without its spaces.";
+ $("combine-skip").focus();
+ } else {
+ note.textContent = "The other account has saved spaces, but it has no passkey to unlock them, so they can’t be brought over.";
+ $("combine-skip").focus();
+ }
+}
+
+// combineUnlock runs the ABSORBED account's passkey ceremony, decrypts its
+// vault, unions it into the survivor's (survivor wins ties, tombstones from
+// either side hold, exactly saveVault's race rules), re-encrypts under the
+// survivor's DEK, deletes the absorbed blob, then merges.
+async function combineUnlock() {
+ const btn = $("combine-unlock");
+ btn.disabled = true;
+ combineStatus("Confirm with the OTHER account’s passkey…", "busy");
+ let fin;
+ try {
+ fin = await runLogin(loginBegin);
+ } catch (e) {
+ combineStatus(friendlyCeremonyError(e, "signin").message, "error");
+ prefetchLogin();
+ btn.disabled = false;
+ return;
+ }
+ prefetchLogin(); // options were spent either way
+ // The ceremony MUST land on the absorbed account — its credential ids are
+ // exactly the passkey ids its anchors listing gave us. A miss means the
+ // wrong passkey was picked (likely this account's own): explain and retry.
+ if (!combine.absorbedPasskeyIds.includes(fin.credId)) {
+ combineStatus("That passkey opens a different account — choose the OTHER account’s passkey and try again.", "error");
+ btn.disabled = false;
+ return;
+ }
+ // Freshest absorbed credentials win (the ceremony just minted a proof).
+ combine.absorbedToken = fin.token;
+ combine.absorbedProof = fin.proof;
+ combine.absorbedExpires = Date.now() + PROOF_TTL_MS;
+ try {
+ if (!fin.prf) throw new Error("That passkey didn’t return the key material needed to unlock (no PRF) — try another device, or combine without its spaces.");
+ const kek = await kekFromPRF(fin.prf);
+ const wrapped = unpackBlob(combine.absorbedBlob.bytes).keys[fin.credId];
+ if (!wrapped) throw new Error("That passkey can’t unlock the other account’s spaces.");
+ const absDek = await unwrapDEK(wrapped, kek);
+ const opened = await openBlob(combine.absorbedBlob.bytes, absDek);
+ combineStatus("Bringing its spaces over…", "busy");
+ // Union under saveVault's rules: absorbed first, survivor overlays
+ // (survivor wins ties by url); tombstones from either side hold unless
+ // the SURVIVOR's live list still carries the entry.
+ const before = (vault.obj.servers || []).length;
+ const merged = new Map((opened.obj.servers || []).map((sv) => [sv.url, sv]));
+ for (const sv of vault.obj.servers || []) merged.set(sv.url, sv);
+ const removed = mergeRemoved(opened.obj.removed, vault.obj.removed)
+ .filter((t) => !(vault.obj.servers || []).some((sv) => sv.url === t.url));
+ vault.obj.servers = [...merged.values()].filter((sv) => !removed.some((t) => t.url === sv.url));
+ if (removed.length) vault.obj.removed = removed; else delete vault.obj.removed;
+ const movedSpaces = vault.obj.servers.length - before;
+ // The absorbed passkey just proved AND unlocked — after the merge it
+ // signs into the survivor, so wrap the survivor's DEK for it too: it
+ // should OPEN the combined list, not hit the sealed prompt.
+ vault.keys[fin.credId] = await wrapDEK(dekKey, kek);
+ await saveVault(); // CAS PUT of the survivor's union, retrying once
+ await deleteAbsorbedBlob(); // client-first ordering: emptied before merge
+ combineFinish(movedSpaces);
+ } catch (e) {
+ combineStatus((e && e.message) || "Couldn’t bring its spaces over.", "error");
+ btn.disabled = false;
+ }
+}
+
+// The skip path's inline are-you-sure: destroying the absorbed account's
+// sealed spaces list must be an explicit, plainly-worded beat of its own.
+function combineSkip() {
+ $("combine-unlock").classList.add("hidden");
+ $("combine-skip").classList.add("hidden");
+ $("combine-skip-confirm").classList.remove("hidden");
+ $("combine-skip-yes").focus();
+}
+
+async function combineSkipYes() {
+ const btn = $("combine-skip-yes");
+ btn.disabled = true;
+ combineStatus("Clearing its spaces list…", "busy");
+ try {
+ await deleteAbsorbedBlob();
+ combineFinish(0);
+ } catch (e) {
+ combineStatus((e && e.message) || "Couldn’t clear the other account’s backup.", "error");
+ } finally {
+ btn.disabled = false;
+ }
+}
+
+// deleteAbsorbedBlob is the ceremony-gated DELETE /api/blob, authed by the
+// ABSORBED side's proof — merge.go's precondition (client-first ordering).
+async function deleteAbsorbedBlob() {
+ if (!absorbedProofFresh()) {
+ openCombineStep(); // stale other-side proof: prove again, then retry
+ combineStatus("That took a while — prove the other account again first.", "error");
+ throw new Error("prove the other account again first");
+ }
+ const res = await fetch("/api/blob", { method: "DELETE", headers: { "X-Ceremony-Proof": combine.absorbedProof } });
+ if (!res.ok && res.status !== 404) {
+ throw new Error(((await res.text().catch(() => "")) || "").trim() || "Couldn’t clear the other account’s backup.");
+ }
+}
+
+// Stage C: the merge itself — both sides' fresh proofs (merge.go's
+// both-sides-proofs auth), then the plain-words outcome back on the list.
+async function combineFinish(movedSpaces = 0) {
+ if (!combine) { backToMethodsList("", "", { reload: false }); return; }
+ const sp = freshProof();
+ if (!sp) {
+ // The SURVIVOR's proof lapsed mid-combine: the confirm step runs, then
+ // finishes right here (memory-only intent — a confirm-with-Apple trip
+ // would lose the combine context, and intentToken serializes this to
+ // nothing on purpose).
+ pendingIntent = { kind: "combine-finish", movedSpaces };
+ openMethodsConfirmStep("That took a while — confirm it’s you again to finish combining.");
+ return;
+ }
+ if (!absorbedProofFresh()) {
+ openCombineStep();
+ combineStatus("That took a while — prove the other account again first.", "error");
+ return;
+ }
+ showMethodsScreen("methods-combine");
+ combineStatus("Combining the accounts…", "busy");
+ let res;
+ try {
+ res = await fetch("/api/account/merge", {
+ method: "POST",
+ headers: {
+ Authorization: "Bearer " + state.token,
+ "X-Ceremony-Proof": sp,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ absorb_proof: combine.absorbedProof }),
+ });
+ } catch {
+ combineStatus("Couldn’t reach the server — try again.", "error");
+ return;
+ }
+ if (!res.ok) {
+ // The server's plain refusals verbatim (same-account, still-holds-a-
+ // backup, a stale proof), landed on this step for another go.
+ combineStatus(((await res.text().catch(() => "")) || "").trim() || "Couldn’t combine the accounts.", "error");
+ return;
+ }
+ const body = await res.json().catch(() => ({}));
+ const movedMethods = (body.moved && (body.moved.anchors || 0) + (body.moved.passkeys || 0)) || 0;
+ let msg = "Accounts combined — " + movedMethods.toLocaleString() + " sign-in method" +
+ (movedMethods === 1 ? "" : "s") + " moved";
+ if (movedSpaces > 0) {
+ msg += ", " + movedSpaces.toLocaleString() + " space" + (movedSpaces === 1 ? "" : "s") + " brought over";
+ }
+ msg += ".";
+ combine = null;
+ backToMethodsList(msg, "ok"); // reloads the anchors — the moved methods appear
+ if (movedSpaces > 0 && vault) provisioned = null; // the ledger may know the new spaces
+}
+
// ---- spaces list --------------------------------------------------------------
// hideVault parks the spaces screen for a sibling screen (or sign-out). The
@@ -2027,6 +3233,7 @@ async function destroyForEveryone() {
let proof;
try {
({ proof } = await runLogin(loginBegin));
+ noteProof(proof); // fills the confirm-step pocket (Sign-in options)
} catch (e) {
prefetchLogin();
archiveStatus(friendlyCeremonyError(e, "signin").message, "error");
@@ -2137,8 +3344,19 @@ let siwaBooting = false; // claims the boot — the cold-open path below stands
history.replaceState(null, "", location.pathname + location.search);
const minted = sessionStorage.getItem("home_siwa_state") || "";
sessionStorage.removeItem("home_siwa_state");
+ // The methods step's round-trip markers (add-Apple's stashed proof, the
+ // confirm-with-Apple return flag, the serialized in-progress intent) —
+ // consumed exactly once, here, so a reload can't replay any of them.
+ const linkMark = sessionStorage.getItem("home_siwa_link");
+ const methodsReturn = !!sessionStorage.getItem("home_methods_return");
+ const methodsIntent = sessionStorage.getItem("home_methods_intent") || "";
+ sessionStorage.removeItem("home_siwa_link");
+ sessionStorage.removeItem("home_methods_return");
+ sessionStorage.removeItem("home_methods_intent");
if (minted && decodeURIComponent(m[2]) === minted) {
- finishSiwa(decodeURIComponent(m[1]));
+ if (linkMark) finishSiwaLink(decodeURIComponent(m[1]), linkMark);
+ else if (methodsIntent === "merge-apple") finishSiwaMergeProve(decodeURIComponent(m[1]));
+ else finishSiwa(decodeURIComponent(m[1]), methodsReturn, methodsIntent);
} else {
// A state that didn't start here: straight to the signed-out card.
finishHomeLoader({ immediate: true });
@@ -2174,7 +3392,48 @@ $("account-menu-btn").addEventListener("pointerdown", () => { kebabPointerOpen =
$("account-menu-btn").addEventListener("click", () => {
if (accountMenuOpen()) closeAccountMenu(true); else openAccountMenu();
});
-$("signout").addEventListener("click", signOut);
+// Sign out is technically instant (local state clears) — the ~700ms beat
+// makes it read as the event it is (Paul's rule: meaningful transitions
+// earn a working state; case-by-case, not universal).
+$("signout").addEventListener("click", () => {
+ closeAccountMenu(false);
+ const card = document.querySelector("main.card");
+ card.classList.add("waiting");
+ setTimeout(() => {
+ card.classList.remove("waiting");
+ signOut();
+ }, 700);
+});
+// Sign-in options (issue #1098): the kebab's serial step family. The list's
+// own controls (row kebabs, first-add buttons) are wired per render.
+$("how-signin").addEventListener("click", () => { closeAccountMenu(false); openMethods(); });
+$("methods-back").addEventListener("click", closeMethods);
+$("methods-confirm-back").addEventListener("click", () => {
+ pendingIntent = null; // backing out cancels the parked change
+ backToMethodsList("", "", { reload: false });
+});
+$("methods-confirm-passkey").addEventListener("click", confirmMethodsPasskey);
+$("methods-confirm-apple").addEventListener("click", confirmMethodsApple);
+$("methods-cemail-form").addEventListener("submit", cemailSend);
+$("methods-cemail-code-form").addEventListener("submit", cemailVerify);
+$("methods-email-back").addEventListener("click", () => backToMethodsList("", "", { reload: false }));
+$("methods-email-form").addEventListener("submit", methodsEmailSend);
+$("methods-email-code-form").addEventListener("submit", methodsEmailVerify);
+$("methods-passkey-back").addEventListener("click", () => backToMethodsList("", "", { reload: false }));
+$("methods-passkey-create").addEventListener("click", addPasskeyCeremony);
+// The combine step (#1098's merge): offer → prove → its spaces → the merge.
+$("combine-offer-go").addEventListener("click", openCombineStep);
+$("combine-back").addEventListener("click", () => {
+ combine = null; // cancel leaves both accounts untouched
+ backToMethodsList("", "", { reload: false });
+});
+$("combine-code-form").addEventListener("submit", combineCodeVerify);
+$("combine-resend").addEventListener("click", combineRequestCode);
+$("combine-apple-go").addEventListener("click", combineAppleProve);
+$("combine-unlock").addEventListener("click", combineUnlock);
+$("combine-skip").addEventListener("click", combineSkip);
+$("combine-skip-yes").addEventListener("click", combineSkipYes);
+$("combine-skip-no").addEventListener("click", combineVaultStage);
$("open-create").addEventListener("click", openCreate);
$("create-back").addEventListener("click", closeCreate);
$("create-done").addEventListener("click", closeCreate);