# 🤖 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 —
   **done** (PR #1383).
2. Entitlements: the Mac gains its first `applinks` entries (same domains) —
   **done** (`LChatMac.entitlements`, with #1383's server half; the Mac App
   ID needs the Associated Domains capability before a release build's
   profile authorizes it).
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).**~~ **Superseded — the
  interpreter shipped.** The Swift flow interpreter is real now
  (`FlowEngine.swift` running the bundled `Flows/welcome.json`, a byte-derived
  copy of `welcome.flow.js` drift-gated by `test/flow.test.mjs`, hosted by
  `ElevenConversationModel`), and a native member/DM-invite claim creates the
  welcome app-thread and lands the joiner in the full conversation — which
  asks their name — on both platforms (`SpaceClaim.claim` →
  `WelcomeLanding.markPending`/`threadToLand`, #1376). The passkey ceremony
  step stays web-only by design (`env.native` skips it — native access *is*
  the sign-in).
- **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.
