eleven / messenger Public

docs: onboarding & home-server design (welcome page, passkeys, sync) (#110)

Captures the 2026-07-03 design discussion: deferred-claim welcome page,
optional passkey-based home server (WebAuthn PRF, envelope-encrypted blob,
additive-only invariant), device sync/auto-add, fragility banner (replacing
the backup toast), elevenmessenger:// scheme + universal links, invite-link
fingerprint, and group-direct invites. Adds the no-floating-toasts convention
to CLAUDE.md.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e684b547e70eddbbc68ce2c8997f7db166d1170c parent 27bb96d
2 files changed, +263 −0
  • CLAUDE.md +4 −0
  • docs/onboarding-and-home-server.md +259 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index 2477a30..858782f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -67,6 +67,10 @@ Frontend module layout:
- **Never `innerHTML` untrusted content.** Build DOM nodes / use `textContent`.
`innerHTML` is only acceptable with static, in-code strings (e.g. `ICON_X`).
+- **Never show a toast that floats over UI.** Nudges, warnings, and status
+ render inline, occupying layout space — nothing overlays content someone may
+ be reading or about to tap. Existing floating-`#toast` uses migrate to
+ inline feedback as they're touched (see `docs/onboarding-and-home-server.md`).
- **Token-gate every data endpoint** with `s.tokenOK(token)` (or the equivalent
invite check) — messages, uploads, blob downloads, unfurl, push subscribe.
- **Pull pure logic into a testable module** rather than burying it in DOM code,
diff --git a/docs/onboarding-and-home-server.md b/docs/onboarding-and-home-server.md
new file mode 100644
index 0000000..76782e0
--- /dev/null
+++ b/docs/onboarding-and-home-server.md
@@ -0,0 +1,259 @@
+# Onboarding & the home server — design
+
+Status: **proposed** — captured from design discussion 2026-07-03, nothing built.
+This doc is the single place for where we landed. Builds on
+`invite-redesign.md` (invite secret `T` in the URL fragment, server-blind claim).
+
+## Goals
+
+It should be nearly seamless to:
+
+a) **join a chat** from an invite link,
+b) **get the app**, and
+c) **get the chat into the app** —
+
+and it should be hard to *lose* access: the server list (and optionally the
+credentials behind it) should be restorable on a fresh device and kept in sync
+across devices. All without usernames, passwords, or weakening the one rule:
+**a compromised server yields nothing**.
+
+## Invariants (non-negotiable)
+
+- **No accounts.** Identity stays per-instance keypairs; membership only ever
+ enters via invites. No federation.
+- **The home server is strictly additive.** A client with no enrolment record
+ makes **no requests to any home server, ever** — no passive pings, no
+ "check for a backup" lookups. Enrolment is created only by an explicit user
+ action. A web-only user who declines never appears in any home-server log.
+- **This is not a Matrix homeserver.** It holds no messages, no roster, no
+ identity any instance recognises. It disappearing inconveniences you exactly
+ once (you lose the backup, not the chats).
+- Invite fragments are never sent to any server, including the home server.
+
+## Who sees what (the whole model in one table)
+
+| Party | Sees |
+|---|---|
+| An instance | your per-instance public key + ciphertext (unchanged from today) |
+| The home server (if enrolled) | one WebAuthn credential public key per passkey, one opaque **padded** blob, device link tokens, IPs/timing |
+| A stolen device link token | ciphertext reads + overwrites (server keeps version history; destructive ops need a fresh ceremony) |
+| A compromised enrolled device | the blob — but it already holds live sessions to every instance in it, so nothing new |
+| Nobody | the relationship between your identities on different instances, except your own devices |
+
+---
+
+## 1. The welcome page (deferred claim)
+
+Today `/join/<label>#<T>` claims immediately on load. The one-time secret gets
+burned by whichever context loads it first — the source of the
+"This link doesn't work" bug class (hidden Mac webviews, browser-then-app).
+
+New behaviour: `/join/...` renders a **welcome card** (group name + icon via the
+public `/api/group`) and does nothing else. The fragment sits untouched in the
+URL bar. Claim happens only on an explicit choice.
+
+**Golden path** (sign-in is explicit, never implicit):
+
+1. Click the invite link → welcome page: *"You've been invited to Campbell
+ Family."* Primary nudges: **Get the app** and **Sign in at
+ home.elevenmessenger.com**. Secondary: **Continue on the web** — with a
+ caveat shown ("your access will live only in this browser").
+2. After home sign-in (popup on desktop; full redirect on mobile, with the
+ fragment parked in the instance origin's `sessionStorage` across the trip),
+ return to a **second variant** of the welcome page:
+ - **Open in Eleven** (app), or
+ - **Continue on the web** — no caveat this time, because the browser now
+ holds the DEK + link token, so the join that follows lands in the blob in
+ the same breath. The reassurance is true by construction.
+3. Joiners who skipped sign-in get a post-join nudge instead ("Don't lose
+ access to this chat — save a passkey"), Apple-style, after they've arrived.
+
+**Signed-out server root** (e.g. visiting `chat.paulca.com` with no session) is
+currently a dead end. It becomes the same card: **Restore with passkey** /
+**Open in the app** / *"I have an invite link"*. During restore the home server
+just returns the whole blob — the instance page picks out its own entry
+client-side, so home never learns which instance prompted the sign-in.
+
+**App Store round trip:** invites stay valid until claimed, so "install, come
+back, tap the link again" works today; the QR flow (shipped on iOS) covers
+cross-device. Deferred deep-linking via pasteboard is a possible later polish,
+not phase-one (it triggers a paste-permission prompt).
+
+## 2. The home server
+
+A tiny, deployable-like-an-instance service (single Go binary, SQLite, one
+table that matters). Default `home.elevenmessenger.com`; the welcome page's
+cog lets a joiner point at a different one. Every instance gets it for free
+because instances aren't involved at all — the only relationship is
+**client ↔ home**.
+
+### What it stores
+
+Per user: WebAuthn credential public key(s), one **opaque padded blob**
+(fixed size, e.g. 16 KB — length leaks nothing), a version counter, and
+device link tokens. No email, no username, no display name. The credential ID
+*is* the identity.
+
+### Crypto
+
+- Passkey at RP `home.elevenmessenger.com`, created with the **PRF extension**.
+ The PRF output (only ever visible client-side) derives a KEK.
+- **Envelope encryption:** a random long-lived DEK encrypts the blob (AES-GCM);
+ the DEK is wrapped once per enrolled passkey's KEK. Adding a second passkey
+ (e.g. 1Password alongside iCloud) = wrapping the DEK again = recovery
+ redundancy. The DEK is stable across blob updates — that's what makes silent
+ sync possible.
+- **Blob contents — the one open decision.** Recommendation: the server list
+ **plus per-instance credentials** (session token + private key per
+ instance), so restore = fully signed in everywhere with one Face ID.
+ The list-only alternative shrinks the blast radius of a compromised passkey
+ fabric but reduces "restore" to "here's a map, now do an add-device dance
+ per server" — and makes the signed-out-root flow above nearly useless.
+ Either way the server sees ciphertext; this decision is about what a stolen
+ passkey unlocks, and enrolment is opt-in for exactly that reason.
+- No PRF support (old browser/manager)? Then no enrolment on that device —
+ add-device links remain the universal fallback. Losing **all** passkeys means
+ the blob is cryptographically gone; that's the design working. Recovery
+ paths are your other enrolled devices, add-device links, or fresh invites.
+
+### API sketch
+
+```
+POST /api/register WebAuthn create (attestation) → credential stored
+POST /api/challenge issue challenge for an assertion (with PRF eval)
+POST /api/devices after a verified ceremony → device link token
+GET /api/blob bearer: link token (If-None-Match: version)
+PUT /api/blob bearer: link token (If-Match: version → 409 on race;
+ clients merge the small list client-side and retry)
+DELETE /api/devices/<id> requires a FRESH ceremony, not a token
+DELETE /api/blob requires a fresh ceremony
+```
+
+The asymmetry is deliberate: a link token can read and update, but anything
+that *reduces* access (revoking devices, removing passkeys, deleting the blob)
+requires a live passkey ceremony — a stolen token can't lock you out.
+
+### Devices & silent sync
+
+After one enrolment ceremony a device caches the **DEK** (Keychain / secure
+storage) and holds a **link token**. From then on it syncs in the background
+with no Face ID: poll on launch/foreground (the Mac app also periodically or
+via a watch connection), see the version bump, decrypt with the cached DEK,
+adopt changes. On the **web** the default is session-only DEK caching — a
+"Trust this browser" checkbox opts into persistence, so sign-in on a shared
+computer doesn't durably enrol it.
+
+**Auto-add flow** (the payoff): join a new instance in any browser → the
+welcome page hands the new entry `{instance URL, credentials}` to the home
+origin via `postMessage`/`#fragment` (never through a server in the clear) →
+one Face ID (or a cached web session) → blob updated → your Mac and iPhone
+poll, decrypt, and the server **appears in the rail on its own** — the Mac app
+seeds the instance webview's session, iOS adopts the credentials natively.
+
+Revocation: lost phone → any enrolled device (fresh ceremony) revokes its link
+token; per-instance measures (admin roster) unchanged.
+
+### Abuse controls
+
+Open blob store on the internet: hard size cap, per-credential quota (one
+blob), rate limits on register/challenge, optionally proof-of-work on register.
+
+## 3. The fragility banner
+
+Members without a backup should *know* their access is fragile. The
+**client** decides — the instance is never told (it couldn't verify a
+self-report anyway, it leaks cross-instance metadata, and it goes stale):
+chat.js shows the banner when a session exists and there's **no local
+enrolment record**. "If this browser session is lost, your access to this chat
+is lost."
+
+Dismissal is a **double dismiss**: first dismiss = "remind me later"
+(returns after ~7 days); second requires a confirm-state click — the control
+changes to *"I understand — I can lose access to this chat"* and you click
+that. Persisted locally (it's a per-device warning). Enrolling clears it
+everywhere, genuinely.
+
+**UI rule (product-wide): never show a toast that floats over the UI.**
+Nudges, warnings, and status belong *inline*, occupying layout space (like
+this banner, above the thread list) — not overlaying content someone may be
+reading or about to tap. The floating `#toast` pill is retired as a pattern:
+existing uses (e.g. "Copied") move to inline/adjacent feedback as they're
+touched. This rule is also recorded in CLAUDE.md conventions.
+
+**This replaces the existing backup toast.** chat.js currently shows a
+one-shot toast to single-device members (`maybeNudgeBackup()`, keyed on
+`lchat_backup_nudged`) pointing at ⋮ → "Add a device / back up". Once the
+onboarding flow owns back-up messaging, that toast is **removed** — one voice,
+not two competing nudges. The banner is strictly better: it's driven by real
+state (enrolment) rather than a fire-once flag, it can come back, and its
+call-to-action is the actual fix (save a passkey) rather than a menu pointer.
+The ⋮ add-device link stays where it is as the manual/no-passkey path.
+
+## 4. Opening in the app
+
+Two mechanisms, deliberately ranked:
+
+- **Universal links (primary).** Every instance is the same binary, so each
+ serves `/.well-known/apple-app-site-association`. Invite links on associated
+ domains open straight into the app — full URL, fragment included — and fall
+ back to the web welcome page when the app's absent. Limitation: the
+ associated-domains entitlement is a static list (`*.luan.chat` + named
+ custom domains); arbitrary custom domains can't join it. (WebAuthn Related
+ Origin Requests could stretch things later; parked.)
+- **Custom scheme `elevenmessenger://` (explicit fallback).** One-line
+ registration on iOS *and* the Mac app (`CFBundleURLTypes`); fired by the
+ welcome page's "Open in Eleven" button, works from any domain. Deliberately
+ non-generic (not `eleven://`) — schemes are a global namespace, so the less
+ collision-prone the better. Which is also the caveat that keeps it in second
+ place: schemes aren't exclusive — a squatting app could receive the URL and
+ its invite secret. Acceptable for a user-initiated tap handing over a
+ one-time secret that's about to be claimed; never used for long-lived
+ secrets, never the default path.
+- QR scanning (shipped on iOS) remains the cross-device path.
+
+The web still can't detect "is the app installed" — by design the welcome page
+*is* the fallback, so the button just tries and offers "Get the app" alongside.
+Distribution: TestFlight link now (paid team is live), App Store later; Mac via
+notarized direct download.
+
+## 5. Invite links shared inside the app
+
+An invite pasted into a chat should open the native add-chat flow, not a
+browser. Our renderers control link handling; the missing piece is
+*recognising* an invite deterministically (any site can have `/join/`). Give
+links a versioned **fragment fingerprint** — `#e1.<T>` instead of `#<T>` — so
+clients recognise invites offline and instantly; we mint the links, so this
+costs nothing. Old-format links keep working via an `/api/group` probe.
+OS-level interception (tap in iMessage) is the universal-links story above.
+
+## 6. Direct invites to a group chat
+
+An invite can carry a **target thread id**; the claim flow ships that thread's
+key exactly the way the instance key ships today (the inviter is a member and
+can wrap it). The joiner lands in the group, not an empty home screen.
+Deliberate semantics: joining a group still means joining the **instance**
+(roster is instance-wide, so all members can see the newcomer exists) —
+"invite to group" = "invite to instance, pre-added to this group".
+
+---
+
+## Phasing
+
+1. **Welcome page + deferred claim** — kills the burned-invite bug class; adds
+ the fragment fingerprint, `eleven://`, and AASA universal links. No crypto
+ changes. (Group-direct invites are independent and can ride along.)
+2. **Home server MVP** — register/challenge/blob, enrol + restore flows, the
+ post-join nudge, the fragility banner (and the old backup toast retires
+ with it).
+3. **Silent sync + auto-add** — device link tokens, DEK caching, app polling,
+ the signed-out-root restore page, welcome-page sign-in variant.
+4. **Distribution polish** — TestFlight/App Store buttons, Mac notarized
+ download, deferred deep-linking if it earns its keep.
+
+## Open decisions
+
+- **Blob contents:** list-only vs list + credentials (recommendation:
+ credentials in, documented blast radius, opt-in enrolment).
+- Web DEK cache default (recommendation: session-only + "trust this browser").
+- Banner "remind me later" interval (~7 days?).
+- Custom-domain universal links via Related Origin Requests: later, if ever.