| 1 | # ๐ค Onboarding & the home server โ design |
| 2 | |
| 3 | Status: **shipping, not proposed** โ for the machinery. The home server, |
| 4 | enrolment, and the signed-out-root restore flow are built (see the inline |
| 5 | *Implemented* markers); silent multi-device sync (phase 3c) is the main piece |
| 6 | still landing. This doc is the single place for how that machinery works. |
| 7 | Builds on `invite-redesign.md` (invite secret `T` in the URL fragment, |
| 8 | server-blind claim). |
| 9 | |
| 10 | > **The identity invariants were revised 2026-07-16.** "Passkey-only sign-in, |
| 11 | > no email, no social login, ever" was field-falsified by real lockouts within |
| 12 | > a week of the native apps leaning on it. The successor design โ anchored |
| 13 | > ownership, anonymous membership, and the identity-vs-keys split โ is |
| 14 | > **`accounts-and-recovery.md`**, which supersedes the Invariants section |
| 15 | > below. The invariants that survive unchanged are restated there. |
| 16 | |
| 17 | ## Goals |
| 18 | |
| 19 | It should be nearly seamless to: |
| 20 | |
| 21 | a) **join a chat** from an invite link, |
| 22 | b) **get the app**, and |
| 23 | c) **get the chat into the app** โ |
| 24 | |
| 25 | and it should be hard to *lose* access: the server list (and optionally the |
| 26 | credentials behind it) should be restorable on a fresh device and kept in sync |
| 27 | across devices. All without usernames, passwords, or weakening the one rule: |
| 28 | **a compromised server yields nothing**. |
| 29 | |
| 30 | ## Invariants (revised โ see `accounts-and-recovery.md`) |
| 31 | |
| 32 | > **Superseded 2026-07-16.** This section originally declared "No social or |
| 33 | > third-party login, ever" and "no email" as permanent decisions. The |
| 34 | > replacement invariants โ membership stays anonymous, ownership is anchored, |
| 35 | > providers attest but are never the identity, the server never holds a |
| 36 | > readable key โ live in **`accounts-and-recovery.md`** together with the |
| 37 | > rationale (including the updated App Store guideline 4.8 analysis). The |
| 38 | > invariants below survive unchanged and remain non-negotiable: |
| 39 | |
| 40 | - **Membership enters via invites.** Identity stays per-instance keypairs; |
| 41 | no federation. (Joining a space still requires no identifier, ever.) |
| 42 | - **The home server is strictly additive.** A client with no enrolment record |
| 43 | makes **no requests to any home server, ever** โ no passive pings, no |
| 44 | "check for a backup" lookups. Enrolment is created only by an explicit user |
| 45 | action. A web-only user who declines never appears in any home-server log. |
| 46 | - **This is not a Matrix homeserver.** It holds no messages, no roster, no |
| 47 | identity any instance recognises. It disappearing inconveniences you exactly |
| 48 | once (you lose the backup, not the chats). |
| 49 | - Invite fragments are never sent to any server, including the home server. |
| 50 | |
| 51 | ## Who sees what (the whole model in one table) |
| 52 | |
| 53 | | Party | Sees | |
| 54 | |---|---| |
| 55 | | An instance | your per-instance public key + ciphertext (unchanged from today) | |
| 56 | | The home server (if enrolled) | one WebAuthn credential public key per passkey, one opaque **padded** blob, device link tokens, IPs/timing | |
| 57 | | A stolen device link token | ciphertext reads + overwrites (server keeps version history; destructive ops need a fresh ceremony) | |
| 58 | | A compromised enrolled device | the blob โ but it already holds live sessions to every instance in it, so nothing new | |
| 59 | | Nobody | the relationship between your identities on different instances, except your own devices | |
| 60 | |
| 61 | --- |
| 62 | |
| 63 | ## 1. The welcome page (deferred claim) |
| 64 | |
| 65 | Today `/join/<label>#<T>` claims immediately on load. The one-time secret gets |
| 66 | burned by whichever context loads it first โ the source of the |
| 67 | "This link doesn't work" bug class (hidden Mac webviews, browser-then-app). |
| 68 | |
| 69 | New behaviour: `/join/...` renders a **welcome card** (group name + icon via the |
| 70 | public `/api/group`) and does nothing else. The fragment sits untouched in the |
| 71 | URL bar. Claim happens only on an explicit choice. |
| 72 | |
| 73 | **Golden path** (sign-in is explicit, never implicit): |
| 74 | |
| 75 | 1. Click the invite link โ welcome page: *"You've been invited to Campbell |
| 76 | Family."* Primary nudges: **Get the app** and **Sign in at |
| 77 | home.elevenmessenger.com**. Secondary: **Continue on the web** โ with a |
| 78 | caveat shown ("your access will live only in this browser"). |
| 79 | 2. After home sign-in (popup on desktop; full redirect on mobile, with the |
| 80 | fragment parked in the instance origin's `sessionStorage` across the trip), |
| 81 | return to a **second variant** of the welcome page: |
| 82 | - **Open in Eleven** (app), or |
| 83 | - **Continue on the web** โ no caveat this time, because the browser now |
| 84 | holds the DEK + link token, so the join that follows lands in the blob in |
| 85 | the same breath. The reassurance is true by construction. |
| 86 | 3. Joiners who skipped sign-in get a post-join nudge instead ("Don't lose |
| 87 | access to this chat โ save a passkey"), Apple-style, after they've arrived. |
| 88 | |
| 89 | ๐ค *Shipped 2026-07-31 (signup-first join):* the sign-in-first ordering above |
| 90 | now exists as the join page's **lead card** โ fresh joiners see "Sign in with |
| 91 | Eleven" before the app/web choice, with "Continue without signing in" as small |
| 92 | print (a quiet link, not a competing button โ one path, plus an escape hatch; |
| 93 | Paul, 2026-08-06). The round trip differs from the sketch in two details: |
| 94 | it's always a same-tab navigation (never a popup โ Safari blocks WebAuthn in |
| 95 | cross-origin popups), and the fragment parks in the instance origin's |
| 96 | `localStorage` (nonce-stamped, expiring), not `sessionStorage`, so a mobile |
| 97 | OAuth app-switch can't orphan it. On return the welcome card renders |
| 98 | caveat-free, and after the claim the session auto-saves to the account via a |
| 99 | hands-free `/enroll` bounce (`payload.auto` โ the live-session fast path |
| 100 | only). Decision logic: `web/static/prejoin.js`; the home-side return guard: |
| 101 | `home/web/static/joinreturn.js`. Design: |
| 102 | `docs/superpowers/specs/2026-07-31-signup-first-join-design.md`. |
| 103 | |
| 104 | **Signed-out server root** (e.g. visiting `chat.paulca.com` with no session) is |
| 105 | currently a dead end. It becomes the same card: **Restore with passkey** / |
| 106 | **Open in the app** / *"I have an invite link"*. During restore the instance |
| 107 | passes its origin and an ephemeral public key; after the passkey unlocks the |
| 108 | blob, the home server returns **only the entry matching that origin** (never the |
| 109 | whole list), ECIES-wrapped to the ephemeral key so the credentials are never |
| 110 | plaintext in the return URL. A one-time nonce ties the return to a restore the |
| 111 | instance actually started. *(Implemented: phase 3b.)* |
| 112 | |
| 113 | **App Store round trip:** invites stay valid until claimed, so "install, come |
| 114 | back, tap the link again" works today; the QR flow (shipped on iOS) covers |
| 115 | cross-device. Deferred deep-linking via pasteboard is a possible later polish, |
| 116 | not phase-one (it triggers a paste-permission prompt). |
| 117 | |
| 118 | ## 2. The home server |
| 119 | |
| 120 | A tiny, deployable-like-an-instance service (single Go binary, SQLite, one |
| 121 | table that matters). Default `home.elevenmessenger.com`; the welcome page's |
| 122 | cog lets a joiner point at a different one. Every instance gets it for free |
| 123 | because instances aren't involved at all โ the only relationship is |
| 124 | **client โ home**. |
| 125 | |
| 126 | ### What it stores |
| 127 | |
| 128 | Per user: WebAuthn credential public key(s), one **opaque padded blob** |
| 129 | (fixed size, e.g. 16 KB โ length leaks nothing), a version counter, and |
| 130 | device link tokens. No email, no username, no display name. The credential ID |
| 131 | *is* the identity. |
| 132 | |
| 133 | **Blob schema (the decrypted plaintext โ the contract every client reads).** |
| 134 | `{ v: 1, servers: [{ url, name?, starred? }] }`. The `servers` **array order is |
| 135 | the display order**; each entry may carry `starred: true`. Rendering (web and |
| 136 | native) partitions it into a **Starred** list and the rest, *each keeping its |
| 137 | array-order* โ so the two lists sort independently: reordering one never |
| 138 | disturbs the other. Native apps map this straight through โ starred spaces |
| 139 | populate the rail, the rest live behind a "more" affordance. The pure helpers |
| 140 | (`web/static/serverlist.js`, unit-tested) are the canonical partition/star/move |
| 141 | logic; keep the native implementations behaviourally identical. Merges on a |
| 142 | version race are last-writer-wins per `url` (the list is tiny), which can drop a |
| 143 | concurrent reorder โ acceptable until per-entry ordering earns a CRDT. |
| 144 | |
| 145 | ### Crypto |
| 146 | |
| 147 | - Passkey at RP `home.elevenmessenger.com`, created with the **PRF extension**. |
| 148 | The PRF output (only ever visible client-side) derives a KEK. |
| 149 | - **Envelope encryption:** a random long-lived DEK encrypts the blob (AES-GCM); |
| 150 | the DEK is wrapped once per enrolled passkey's KEK. Adding a second passkey |
| 151 | (e.g. 1Password alongside iCloud) = wrapping the DEK again = recovery |
| 152 | redundancy. The DEK is stable across blob updates โ that's what makes silent |
| 153 | sync possible. |
| 154 | - **Blob contents โ the one open decision.** Recommendation: the server list |
| 155 | **plus per-instance credentials** (session token + private key per |
| 156 | instance), so restore = fully signed in everywhere with one Face ID. |
| 157 | The list-only alternative shrinks the blast radius of a compromised passkey |
| 158 | fabric but reduces "restore" to "here's a map, now do an add-device dance |
| 159 | per server" โ and makes the signed-out-root flow above nearly useless. |
| 160 | Either way the server sees ciphertext; this decision is about what a stolen |
| 161 | passkey unlocks, and enrolment is opt-in for exactly that reason. |
| 162 | - No PRF support (old browser/manager)? Then no enrolment on that device โ |
| 163 | add-device links remain the universal fallback. Losing **all** passkeys means |
| 164 | the blob is cryptographically gone; that's the design working. Recovery |
| 165 | paths are your other enrolled devices, add-device links, or fresh invites. |
| 166 | (The full recovery ladder โ including the non-passkey key anchors that |
| 167 | soften this โ is `accounts-and-recovery.md ยง What recovery restores`.) |
| 168 | |
| 169 | ### API sketch |
| 170 | |
| 171 | ``` |
| 172 | POST /api/register WebAuthn create (attestation) โ credential stored |
| 173 | POST /api/challenge issue challenge for an assertion (with PRF eval) |
| 174 | POST /api/devices after a verified ceremony โ device link token |
| 175 | GET /api/blob bearer: link token (If-None-Match: version) |
| 176 | PUT /api/blob bearer: link token (If-Match: version โ 409 on race; |
| 177 | clients merge the small list client-side and retry) |
| 178 | DELETE /api/devices/<id> requires a FRESH ceremony, not a token |
| 179 | DELETE /api/blob requires a fresh ceremony |
| 180 | ``` |
| 181 | |
| 182 | The asymmetry is deliberate: a link token can read and update, but anything |
| 183 | that *reduces* access (revoking devices, removing passkeys, deleting the blob) |
| 184 | requires a live passkey ceremony โ a stolen token can't lock you out. |
| 185 | |
| 186 | ### Devices & silent sync |
| 187 | |
| 188 | After one enrolment ceremony a device caches the **DEK** (Keychain / secure |
| 189 | storage) and holds a **link token**. From then on it syncs in the background |
| 190 | with no Face ID: poll on launch/foreground (the Mac app also periodically or |
| 191 | via a watch connection), see the version bump, decrypt with the cached DEK, |
| 192 | adopt changes. On the **web** the default is session-only DEK caching โ a |
| 193 | "Trust this browser" checkbox opts into persistence, so sign-in on a shared |
| 194 | computer doesn't durably enrol it. |
| 195 | |
| 196 | **Enrolment / auto-add flow** (the payoff): from a chat (the fragility banner's |
| 197 | "Back up with a passkey", or later the welcome page) the instance opens the home |
| 198 | server's `/enroll` popup with the new entry `{instance URL, name, credentials}` |
| 199 | in the **popup's URL fragment** โ client-to-client, never through a server in |
| 200 | the clear (same model as invite secrets). The handoff is deliberately one-way: |
| 201 | the home server ships **no inbound `postMessage` receiver** (that was an |
| 202 | injection surface โ any site can `window.open` the popup), so there is nothing |
| 203 | cross-origin to spoof. The popup shows exactly what's being added (name + host) |
| 204 | as the confirmation gate, runs one Face ID (create a backup passkey, or sign in |
| 205 | to an existing one), merges the entry into the blob, and posts **only a boolean |
| 206 | result** back to the opener (never blob contents), which the opener accepts only |
| 207 | when `event.origin === HOME_ORIGIN`. Later, your Mac and iPhone poll, decrypt, |
| 208 | and the server **appears in the rail on its own** โ the Mac app seeds the |
| 209 | instance webview's session, iOS adopts the credentials natively. *(Implemented |
| 210 | phase 3a: enrolment + fragility banner. Silent multi-device sync is phase 3c.)* |
| 211 | |
| 212 | Revocation: lost phone โ any enrolled device (fresh ceremony) revokes its link |
| 213 | token; per-instance measures (admin roster) unchanged. |
| 214 | |
| 215 | ### Abuse controls |
| 216 | |
| 217 | Open blob store on the internet: hard size cap, per-credential quota (one |
| 218 | blob), rate limits on register/challenge, optionally proof-of-work on register. |
| 219 | |
| 220 | ## 3. The fragility banner |
| 221 | |
| 222 | Members without a backup should *know* their access is fragile. The |
| 223 | **client** decides โ the instance is never told (it couldn't verify a |
| 224 | self-report anyway, it leaks cross-instance metadata, and it goes stale): |
| 225 | chat.js shows the banner when a session exists and there's **no local |
| 226 | enrolment record**. "If this browser session is lost, your access to this chat |
| 227 | is lost." |
| 228 | |
| 229 | Dismissal is a **double dismiss**: first dismiss = "remind me later" |
| 230 | (returns after ~7 days); second requires a confirm-state click โ the control |
| 231 | changes to *"I understand โ I can lose access to this chat"* and you click |
| 232 | that. Persisted locally (it's a per-device warning). Enrolling clears it |
| 233 | everywhere, genuinely. |
| 234 | |
| 235 | **UI rule (product-wide): never show a toast that floats over the UI.** |
| 236 | Nudges, warnings, and status belong *inline*, occupying layout space (like |
| 237 | this banner, above the thread list) โ not overlaying content someone may be |
| 238 | reading or about to tap. The floating `#toast` pill is retired as a pattern: |
| 239 | existing uses (e.g. "Copied") move to inline/adjacent feedback as they're |
| 240 | touched. This rule is also recorded in CLAUDE.md conventions. |
| 241 | |
| 242 | **This replaces the existing backup toast.** chat.js currently shows a |
| 243 | one-shot toast to single-device members (`maybeNudgeBackup()`, keyed on |
| 244 | `lchat_backup_nudged`) pointing at โฎ โ "Add a device" (renamed from "Add a device / back up" in the 2026-07-22 sign-in language sweep). Once the |
| 245 | onboarding flow owns back-up messaging, that toast is **removed** โ one voice, |
| 246 | not two competing nudges. The banner is strictly better: it's driven by real |
| 247 | state (enrolment) rather than a fire-once flag, it can come back, and its |
| 248 | call-to-action is the actual fix (save a passkey) rather than a menu pointer. |
| 249 | The โฎ add-device link stays where it is as the manual/no-passkey path. |
| 250 | |
| 251 | ## 4. Opening in the app |
| 252 | |
| 253 | Two mechanisms, deliberately ranked: |
| 254 | |
| 255 | - **Universal links (primary).** Every instance is the same binary, so each |
| 256 | serves `/.well-known/apple-app-site-association`. Invite links on associated |
| 257 | domains open straight into the app โ full URL, fragment included โ and fall |
| 258 | back to the web welcome page when the app's absent. Limitation: the |
| 259 | associated-domains entitlement is a static list (`*.example.com` + named |
| 260 | custom domains); arbitrary custom domains can't join it. (WebAuthn Related |
| 261 | Origin Requests could stretch things later; parked.) |
| 262 | - **Custom scheme `elevenmessenger://` (explicit fallback).** One-line |
| 263 | registration on iOS *and* the Mac app (`CFBundleURLTypes`); fired by the |
| 264 | welcome page's "Open in Eleven" button, works from any domain. Deliberately |
| 265 | non-generic (not `eleven://`) โ schemes are a global namespace, so the less |
| 266 | collision-prone the better. Which is also the caveat that keeps it in second |
| 267 | place: schemes aren't exclusive โ a squatting app could receive the URL and |
| 268 | its invite secret. Acceptable for a user-initiated tap handing over a |
| 269 | one-time secret that's about to be claimed; never used for long-lived |
| 270 | secrets, never the default path. |
| 271 | - QR scanning (shipped on iOS) remains the cross-device path. |
| 272 | |
| 273 | The web still can't detect "is the app installed" โ by design the welcome page |
| 274 | *is* the fallback, so the button just tries and offers "Get the app" alongside. |
| 275 | Distribution: TestFlight link now (paid team is live), App Store later; Mac via |
| 276 | notarized direct download. |
| 277 | |
| 278 | ## 5. Invite links shared inside the app |
| 279 | |
| 280 | An invite pasted into a chat should open the native add-chat flow, not a |
| 281 | browser. Our renderers control link handling; the missing piece is |
| 282 | *recognising* an invite deterministically (any site can have `/join/`). Give |
| 283 | links a versioned **fragment fingerprint** โ `#e1.<T>` instead of `#<T>` โ so |
| 284 | clients recognise invites offline and instantly; we mint the links, so this |
| 285 | costs nothing. Old-format links keep working via an `/api/group` probe. |
| 286 | OS-level interception (tap in iMessage) is the universal-links story above. |
| 287 | |
| 288 | ## 6. Direct invites to a group chat |
| 289 | |
| 290 | An invite can carry a **target thread id**; the claim flow ships that thread's |
| 291 | key exactly the way the instance key ships today (the inviter is a member and |
| 292 | can wrap it). The joiner lands in the group, not an empty home screen. |
| 293 | Deliberate semantics: joining a group still means joining the **instance** |
| 294 | (roster is instance-wide, so all members can see the newcomer exists) โ |
| 295 | "invite to group" = "invite to instance, pre-added to this group". |
| 296 | |
| 297 | --- |
| 298 | |
| 299 | ## Phasing |
| 300 | |
| 301 | 1. **Welcome page + deferred claim** โ kills the burned-invite bug class; adds |
| 302 | the fragment fingerprint, `eleven://`, and AASA universal links. No crypto |
| 303 | changes. (Group-direct invites are independent and can ride along.) |
| 304 | 2. **Home server MVP** โ register/challenge/blob, enrol + restore flows, the |
| 305 | post-join nudge, the fragility banner (and the old backup toast retires |
| 306 | with it). |
| 307 | 3. **Silent sync + auto-add** โ device link tokens, DEK caching, app polling, |
| 308 | the signed-out-root restore page, welcome-page sign-in variant. |
| 309 | 4. **Distribution polish** โ TestFlight/App Store buttons, Mac notarized |
| 310 | download, deferred deep-linking if it earns its keep. |
| 311 | |
| 312 | ## Open decisions |
| 313 | |
| 314 | - **Blob contents:** list-only vs list + credentials (recommendation: |
| 315 | credentials in, documented blast radius, opt-in enrolment). |
| 316 | - Web DEK cache default (recommendation: session-only + "trust this browser"). |
| 317 | - Banner "remind me later" interval (~7 days?). |
| 318 | - Custom-domain universal links via Related Origin Requests: later, if ever. |
| 319 | |