eleven / messenger Public

docs: native onboarding design — a link opens the app, signed in (#477)

The ideal-flow design: universal links (server AASA already ships on every
instance; the iOS entitlement gains *.11ch.at), a native welcome card with
deferred claim, SpaceClaim growing the member/DM + add-device shapes, the
explicit save-your-spaces moment after landing, and the second device's
passkey ceremony as the sync. Mac follows one beat behind (AASA needs its
app id added server-side). Build order + what deliberately stays out.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 54d95be36243b7aca13c183ab7c26ec95a728ba2 parent 4f0b51d
1 file changed, +151 −0
  • docs/native-onboarding.md +151 −0
diff --git a/docs/native-onboarding.md b/docs/native-onboarding.md
new file mode 100644
index 0000000..ca84dd8
--- /dev/null
+++ b/docs/native-onboarding.md
@@ -0,0 +1,151 @@
+# 🤖 Native onboarding: a link → the app → signed in
+
+**Status: design (2026-07-09), sequenced for building.** The goal, stated as
+the experience: someone taps an invite link and ends up **in the native app**,
+**in the space**, and **signed in with a passkey** — and when they later set up
+their other device (Mac first or iPhone first, either order), the passkey
+ceremony on the second device *is* the sync: everything appears.
+
+This replaces the retired paste-a-link joins (deleted 2026-07-08 with the
+sign-in-only door) with something better, not with their return. The design
+leans on two invariants from `docs/onboarding-and-home-server.md` that are
+already load-bearing:
+
+- **Deferred claim.** Opening a link never consumes the invite — claiming is
+ an explicit tap. This is what makes "the app AND the browser both saw the
+ link" safe: whoever *joins* claims; everyone else just looked.
+- **Fragments never reach a server.** The invite secret `T` lives in the
+ `#fragment`; native parsing (`InviteLink.swift`) and the claim derivation
+ (`InviteCrypto.derive`) keep it that way.
+
+## What already exists (more than it looks)
+
+The 2026-07-09 create-a-space work built the missing spine without meaning to
+advertise it:
+
+- **`SpaceClaim` (LChatCore)** claims a key-invite **natively**: derive
+ `{id, wrapKey, claimSecret}` from `T`, `GET /api/key-invite/{id}`, claim for
+ a device token, generate the identity keypair, register the pubkey, resolve
+ `myUserId`, even bootstrap the instance key when first-in. Today it handles
+ the setup-invite shape; the member/DM and add-device shapes are small
+ extensions (below).
+- **Every instance already serves the AASA.** `GET
+ /.well-known/apple-app-site-association` (main.go `serveAASA`) matches
+ `/join/*` for the iOS app id on **every** instance, tested in
+ `handlers_test.go`. The server side of universal links shipped long ago.
+- **`inviteLink(fromOpenedURL:)` + `parseJoinLink`** (LChatCore) still parse
+ both the custom scheme (`elevenmessenger://add?url=…` — the join page's
+ "Open in Eleven" button) and raw https join links.
+- **Passkey sign-in and the vault sync** are shipped on both platforms; the
+ cross-device story ("the second device's passkey dance does the sync") is
+ already true for everything in the vault.
+
+What's missing is the *middle*: neither app currently handles a join URL at
+all (iOS has no `onOpenURL`; the Mac accepts only the sign-in callback), the
+iOS `applinks` entitlement covers `*.elevenmessenger.com` but not `*.11ch.at`
+where the fleet actually lives, and there's no native welcome screen.
+
+## The flow, end to end
+
+### A. The link opens the app (iOS first)
+
+1. **Entitlements**: add `applinks:*.11ch.at` beside the existing
+ `applinks:*.elevenmessenger.com`. (Apple's rule: one wildcard prefix per
+ entry, every domain named in the shipped binary — so arbitrary custom
+ domains like `chat.paulca.com` can't be covered generically. They keep the
+ web join page, which still works everywhere, plus its "Open in Eleven"
+ custom-scheme button that reaches the same handler.)
+2. **Handlers**: `onOpenURL` + `onContinueUserActivity(NSUserActivityTypeBrowsingWeb)`
+ on the iOS root → `inviteLink(fromOpenedURL:)` → `parseJoinLink` → present
+ the **welcome card** (never claim here — deferred claim).
+3. App not installed: the link lands on the web join page exactly as today.
+ Distribution polish (smart App Store banner on join.html) is phase 4 of
+ the onboarding doc and stays there.
+
+### B. The native welcome card
+
+A sheet with the space's **public branding** (`/api/group` name +
+`/api/server-avatar` — public by design so this exact card can exist), the
+suggested name from the fragment's `~name` tail prefilled into a name field,
+and one button: **Join**. Tapping Join is the explicit claim.
+
+`SpaceClaim` grows the two remaining invite shapes (both already documented in
+join.js):
+
+- **Member/DM invite** (`{threadKey}` payload): decrypt the thread key with
+ `wrapKey`, generate the keypair, wrap the thread key to our own new pubkey,
+ send that as `wrapped` in the claim. Lands in the pending DM.
+- **Add-device link** (`{priv, pub}` payload): adopt the existing identity —
+ no new keypair, no label publish.
+
+Either way the result is a `SavedInstance` through the same `app.add` path,
+which persists it, selects it, and pushes it to the vault when signed in.
+
+### C. The passkey moment
+
+Right after landing in the space — not before, the chat is the payoff — the
+app makes the save-your-spaces ask:
+
+- **No home session yet**: one inline card at the top of the thread list
+ (never a toast): "Keep this space safe — save it to Eleven" → the existing
+ explicit mint (`HomeSignInModel.backUp`). This is the native twin of the
+ welcome conversation's `access` step, which deliberately runs *first* on
+ the web too. Declining defers, matching the web's reminder cadence.
+- **Session exists** (e.g. an invited member who already had Eleven): nothing
+ to ask — `app.add` already pushed the new space into the vault.
+
+### D. The second device
+
+Nothing new to build: install the app → **Sign in** → the passkey ceremony
+restores the vault → every space appears with credentials, order, stars. This
+is shipped behavior; the design just makes it the *stated* contract: **the
+sync IS the sign-in.**
+
+### E. The Mac
+
+Same shape, one platform beat behind (iOS-first rule):
+
+1. Server: `serveAASA` adds the Mac app id
+ (`J5NDF5PCD8.com.elevenmessenger.messenger.mac`) beside the iOS one —
+ today's payload names only iOS.
+2. Entitlements: the Mac gains its first `applinks` entries (same domains).
+3. `LChatMacApp.onOpenURL` grows a second accepted URL family: join links →
+ the same welcome card in a sheet.
+
+macOS routes universal links less aggressively than iOS (browser-first unless
+the user opts in), so the join page's "Open in Eleven" button matters more
+here — it fires the custom scheme, which macOS routes unconditionally.
+
+## What deliberately stays out
+
+- **No welcome *conversation* natively (yet).** The web's scripted joiner
+ conversation (name → photo → passkey, `welcome.flow.js`) is JS-only; the
+ conversation-scripts doc's Swift interpreter is real future work. The native
+ welcome card + the passkey nudge covers the essentials without it. When the
+ interpreter lands, the card can hand off to the full conversation.
+- **No QR scanning revival, no paste field.** Links open the app; that's the
+ door. (The `NSCameraUsageDescription` string left from the retired QR flow
+ should be dropped when the welcome card lands.)
+- **No change to web joins.** join.html remains the universal fallback and
+ the only path on uncovered custom domains.
+
+## Build order
+
+1. **iOS**: entitlement + URL handlers + welcome card + `SpaceClaim` member/DM
+ + add-device shapes (with tests against join.js's shapes) + the
+ post-join passkey nudge. Ships alone; Mac unaffected.
+2. **Server**: AASA gains the Mac app id (one-line change + test update).
+3. **Mac**: entitlements + handler + the same card.
+4. **Polish**: smart banner on join.html; drop the stale camera-permission
+ string; revisit the welcome conversation once scripts run natively.
+
+## Risks / open questions
+
+- **TestFlight + associated domains**: applinks work on TestFlight builds via
+ the AASA's app id — no `?mode=developer` needed for the new wildcard (that
+ mode never worked on macOS anyway; see LChatMac-Dev.entitlements).
+- **Wildcard AASA fetch**: Apple's CDN fetches the AASA per-host on install;
+ `*.11ch.at` hosts each serve it (single binary — already true).
+- **A claimed-but-never-saved device**: someone joins natively, never saves a
+ passkey, loses the phone → the space is orphaned like any keyless loss.
+ Unchanged from web behavior; the reminder cadence is the mitigation.