# 🤖 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):

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.

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

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