eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1# ๐Ÿค– Onboarding & the home server โ€” design
2
3Status: **shipping, not proposed** โ€” for the machinery. The home server,
4enrolment, 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
6still landing. This doc is the single place for how that machinery works.
7Builds on `invite-redesign.md` (invite secret `T` in the URL fragment,
8server-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
19It should be nearly seamless to:
20
21a) **join a chat** from an invite link,
22b) **get the app**, and
23c) **get the chat into the app** โ€”
24
25and it should be hard to *lose* access: the server list (and optionally the
26credentials behind it) should be restorable on a fresh device and kept in sync
27across 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
65Today `/join/<label>#<T>` claims immediately on load. The one-time secret gets
66burned 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
69New behaviour: `/join/...` renders a **welcome card** (group name + icon via the
70public `/api/group`) and does nothing else. The fragment sits untouched in the
71URL bar. Claim happens only on an explicit choice.
72
73**Golden path** (sign-in is explicit, never implicit):
74
751. 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").
792. 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.
863. 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
90now exists as the join page's **lead card** โ€” fresh joiners see "Sign in with
91Eleven" before the app/web choice, with "Continue without signing in" as small
92print (a quiet link, not a competing button โ€” one path, plus an escape hatch;
93Paul, 2026-08-06). The round trip differs from the sketch in two details:
94it's always a same-tab navigation (never a popup โ€” Safari blocks WebAuthn in
95cross-origin popups), and the fragment parks in the instance origin's
96`localStorage` (nonce-stamped, expiring), not `sessionStorage`, so a mobile
97OAuth app-switch can't orphan it. On return the welcome card renders
98caveat-free, and after the claim the session auto-saves to the account via a
99hands-free `/enroll` bounce (`payload.auto` โ€” the live-session fast path
100only). 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
105currently 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
107passes its origin and an ephemeral public key; after the passkey unlocks the
108blob, the home server returns **only the entry matching that origin** (never the
109whole list), ECIES-wrapped to the ephemeral key so the credentials are never
110plaintext in the return URL. A one-time nonce ties the return to a restore the
111instance actually started. *(Implemented: phase 3b.)*
112
113**App Store round trip:** invites stay valid until claimed, so "install, come
114back, tap the link again" works today; the QR flow (shipped on iOS) covers
115cross-device. Deferred deep-linking via pasteboard is a possible later polish,
116not phase-one (it triggers a paste-permission prompt).
117
118## 2. The home server
119
120A tiny, deployable-like-an-instance service (single Go binary, SQLite, one
121table that matters). Default `home.elevenmessenger.com`; the welcome page's
122cog lets a joiner point at a different one. Every instance gets it for free
123because instances aren't involved at all โ€” the only relationship is
124**client โ†” home**.
125
126### What it stores
127
128Per user: WebAuthn credential public key(s), one **opaque padded blob**
129(fixed size, e.g. 16 KB โ€” length leaks nothing), a version counter, and
130device 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
135the display order**; each entry may carry `starred: true`. Rendering (web and
136native) partitions it into a **Starred** list and the rest, *each keeping its
137array-order* โ€” so the two lists sort independently: reordering one never
138disturbs the other. Native apps map this straight through โ€” starred spaces
139populate 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
141logic; keep the native implementations behaviourally identical. Merges on a
142version race are last-writer-wins per `url` (the list is tiny), which can drop a
143concurrent 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```
172POST /api/register WebAuthn create (attestation) โ†’ credential stored
173POST /api/challenge issue challenge for an assertion (with PRF eval)
174POST /api/devices after a verified ceremony โ†’ device link token
175GET /api/blob bearer: link token (If-None-Match: version)
176PUT /api/blob bearer: link token (If-Match: version โ†’ 409 on race;
177 clients merge the small list client-side and retry)
178DELETE /api/devices/<id> requires a FRESH ceremony, not a token
179DELETE /api/blob requires a fresh ceremony
180```
181
182The asymmetry is deliberate: a link token can read and update, but anything
183that *reduces* access (revoking devices, removing passkeys, deleting the blob)
184requires a live passkey ceremony โ€” a stolen token can't lock you out.
185
186### Devices & silent sync
187
188After one enrolment ceremony a device caches the **DEK** (Keychain / secure
189storage) and holds a **link token**. From then on it syncs in the background
190with no Face ID: poll on launch/foreground (the Mac app also periodically or
191via a watch connection), see the version bump, decrypt with the cached DEK,
192adopt 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
194computer 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
198server's `/enroll` popup with the new entry `{instance URL, name, credentials}`
199in the **popup's URL fragment** โ€” client-to-client, never through a server in
200the clear (same model as invite secrets). The handoff is deliberately one-way:
201the home server ships **no inbound `postMessage` receiver** (that was an
202injection surface โ€” any site can `window.open` the popup), so there is nothing
203cross-origin to spoof. The popup shows exactly what's being added (name + host)
204as the confirmation gate, runs one Face ID (create a backup passkey, or sign in
205to an existing one), merges the entry into the blob, and posts **only a boolean
206result** back to the opener (never blob contents), which the opener accepts only
207when `event.origin === HOME_ORIGIN`. Later, your Mac and iPhone poll, decrypt,
208and the server **appears in the rail on its own** โ€” the Mac app seeds the
209instance webview's session, iOS adopts the credentials natively. *(Implemented
210phase 3a: enrolment + fragility banner. Silent multi-device sync is phase 3c.)*
211
212Revocation: lost phone โ†’ any enrolled device (fresh ceremony) revokes its link
213token; per-instance measures (admin roster) unchanged.
214
215### Abuse controls
216
217Open blob store on the internet: hard size cap, per-credential quota (one
218blob), rate limits on register/challenge, optionally proof-of-work on register.
219
220## 3. The fragility banner
221
222Members without a backup should *know* their access is fragile. The
223**client** decides โ€” the instance is never told (it couldn't verify a
224self-report anyway, it leaks cross-instance metadata, and it goes stale):
225chat.js shows the banner when a session exists and there's **no local
226enrolment record**. "If this browser session is lost, your access to this chat
227is lost."
228
229Dismissal is a **double dismiss**: first dismiss = "remind me later"
230(returns after ~7 days); second requires a confirm-state click โ€” the control
231changes to *"I understand โ€” I can lose access to this chat"* and you click
232that. Persisted locally (it's a per-device warning). Enrolling clears it
233everywhere, genuinely.
234
235**UI rule (product-wide): never show a toast that floats over the UI.**
236Nudges, warnings, and status belong *inline*, occupying layout space (like
237this banner, above the thread list) โ€” not overlaying content someone may be
238reading or about to tap. The floating `#toast` pill is retired as a pattern:
239existing uses (e.g. "Copied") move to inline/adjacent feedback as they're
240touched. This rule is also recorded in CLAUDE.md conventions.
241
242**This replaces the existing backup toast.** chat.js currently shows a
243one-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
245onboarding flow owns back-up messaging, that toast is **removed** โ€” one voice,
246not two competing nudges. The banner is strictly better: it's driven by real
247state (enrolment) rather than a fire-once flag, it can come back, and its
248call-to-action is the actual fix (save a passkey) rather than a menu pointer.
249The โ‹ฎ add-device link stays where it is as the manual/no-passkey path.
250
251## 4. Opening in the app
252
253Two 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
273The 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.
275Distribution: TestFlight link now (paid team is live), App Store later; Mac via
276notarized direct download.
277
278## 5. Invite links shared inside the app
279
280An invite pasted into a chat should open the native add-chat flow, not a
281browser. Our renderers control link handling; the missing piece is
282*recognising* an invite deterministically (any site can have `/join/`). Give
283links a versioned **fragment fingerprint** โ€” `#e1.<T>` instead of `#<T>` โ€” so
284clients recognise invites offline and instantly; we mint the links, so this
285costs nothing. Old-format links keep working via an `/api/group` probe.
286OS-level interception (tap in iMessage) is the universal-links story above.
287
288## 6. Direct invites to a group chat
289
290An invite can carry a **target thread id**; the claim flow ships that thread's
291key exactly the way the instance key ships today (the inviter is a member and
292can wrap it). The joiner lands in the group, not an empty home screen.
293Deliberate 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
3011. **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.)
3042. **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).
3073. **Silent sync + auto-add** โ€” device link tokens, DEK caching, app polling,
308 the signed-out-root restore page, welcome-page sign-in variant.
3094. **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