eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

๐Ÿค– 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.