| 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. |