eleven / messenger Public
๐ค Onboarding & the home server โ design
Status: shipping, not proposed โ for the machinery. The home server, enrolment, and the signed-out-root restore flow are built (see the inline Implemented markers); silent multi-device sync (phase 3c) is the main piece still landing. This doc is the single place for how that machinery works. Builds on invite-redesign.md (invite secret T in the URL fragment, server-blind claim).
> The identity invariants were revised 2026-07-16. "Passkey-only sign-in, > no email, no social login, ever" was field-falsified by real lockouts within > a week of the native apps leaning on it. The successor design โ anchored > ownership, anonymous membership, and the identity-vs-keys split โ is > accounts-and-recovery.md, which supersedes the Invariants section > below. The invariants that survive unchanged are restated there.
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 (revised โ see accounts-and-recovery.md)
> Superseded 2026-07-16. This section originally declared "No social or > third-party login, ever" and "no email" as permanent decisions. The > replacement invariants โ membership stays anonymous, ownership is anchored, > providers attest but are never the identity, the server never holds a > readable key โ live in accounts-and-recovery.md together with the > rationale (including the updated App Store guideline 4.8 analysis). The > invariants below survive unchanged and remain non-negotiable:
- Membership enters via invites. Identity stays per-instance keypairs; no federation. (Joining a space still requires no identifier, ever.)
- 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):
- 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").
- After home sign-in (popup on desktop; full redirect on mobile, with the fragment parked in the instance origin's
sessionStorageacross 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. - 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.
๐ค Shipped 2026-07-31 (signup-first join): the sign-in-first ordering above now exists as the join page's lead card โ fresh joiners see "Sign in with Eleven" before the app/web choice, with "Continue without signing in" as small print (a quiet link, not a competing button โ one path, plus an escape hatch; Paul, 2026-08-06). The round trip differs from the sketch in two details: it's always a same-tab navigation (never a popup โ Safari blocks WebAuthn in cross-origin popups), and the fragment parks in the instance origin's localStorage (nonce-stamped, expiring), not sessionStorage, so a mobile OAuth app-switch can't orphan it. On return the welcome card renders caveat-free, and after the claim the session auto-saves to the account via a hands-free /enroll bounce (payload.auto โ the live-session fast path only). Decision logic: web/static/prejoin.js; the home-side return guard: home/web/static/joinreturn.js. Design: docs/superpowers/specs/2026-07-31-signup-first-join-design.md.
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 instance passes its origin and an ephemeral public key; after the passkey unlocks the blob, the home server returns only the entry matching that origin (never the whole list), ECIES-wrapped to the ephemeral key so the credentials are never plaintext in the return URL. A one-time nonce ties the return to a restore the instance actually started. (Implemented: phase 3b.)
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.
Blob schema (the decrypted plaintext โ the contract every client reads). { v: 1, servers: [{ url, name?, starred? }] }. The servers array order is the display order; each entry may carry starred: true. Rendering (web and native) partitions it into a Starred list and the rest, each keeping its array-order โ so the two lists sort independently: reordering one never disturbs the other. Native apps map this straight through โ starred spaces populate the rail, the rest live behind a "more" affordance. The pure helpers (web/static/serverlist.js, unit-tested) are the canonical partition/star/move logic; keep the native implementations behaviourally identical. Merges on a version race are last-writer-wins per url (the list is tiny), which can drop a concurrent reorder โ acceptable until per-entry ordering earns a CRDT.
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. (The full recovery ladder โ including the non-passkey key anchors that soften this โ is
accounts-and-recovery.md ยง What recovery restores.)
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 ceremonyThe 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.
Enrolment / auto-add flow (the payoff): from a chat (the fragility banner's "Back up with a passkey", or later the welcome page) the instance opens the home server's /enroll popup with the new entry {instance URL, name, credentials} in the popup's URL fragment โ client-to-client, never through a server in the clear (same model as invite secrets). The handoff is deliberately one-way: the home server ships no inbound postMessage receiver (that was an injection surface โ any site can window.open the popup), so there is nothing cross-origin to spoof. The popup shows exactly what's being added (name + host) as the confirmation gate, runs one Face ID (create a backup passkey, or sign in to an existing one), merges the entry into the blob, and posts only a boolean result back to the opener (never blob contents), which the opener accepts only when event.origin === HOME_ORIGIN. Later, 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. (Implemented phase 3a: enrolment + fragility banner. Silent multi-device sync is phase 3c.)
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" (renamed from "Add a device / back up" in the 2026-07-22 sign-in language sweep). 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 (*.example.com+ 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 (noteleven://) โ 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
- 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.) - Home server MVP โ register/challenge/blob, enrol + restore flows, the post-join nudge, the fragility banner (and the old backup toast retires with it).
- Silent sync + auto-add โ device link tokens, DEK caching, app polling, the signed-out-root restore page, welcome-page sign-in variant.
- 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.