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