eleven / messenger Public
π€ 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
Tlives 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}fromT,GET /api/key-invite/{id}, claim for a device token, generate the identity keypair, register the pubkey, resolvemyUserId, 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.goserveAASA) matches/join/*for the iOS app id on every instance, tested inhandlers_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)
- Entitlements: add
applinks:*.11ch.atbeside the existingapplinks:*.elevenmessenger.com. (Apple's rule: one wildcard prefix per entry, every domain named in the shipped binary β so arbitrary custom domains likechat.paulca.comcan'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.) - Handlers:
onOpenURL+onContinueUserActivity(NSUserActivityTypeBrowsingWeb)on the iOS root βinviteLink(fromOpenedURL:)βparseJoinLinkβ present the welcome card (never claim here β deferred claim). - 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 withwrapKey, generate the keypair, wrap the thread key to our own new pubkey, send that aswrappedin 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'saccessstep, 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.addalready 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):
- Server:
serveAASAadds the Mac app id (J5NDF5PCD8.com.elevenmessenger.messenger.mac) beside the iOS one β done (PR #1383). - Entitlements: the Mac gains its first
applinksentries (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). LChatMacApp.onOpenURLgrows 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.swiftrunning the bundledFlows/welcome.json, a byte-derived copy ofwelcome.flow.jsdrift-gated bytest/flow.test.mjs, hosted byElevenConversationModel), 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.nativeskips it β native access is* the sign-in). - No QR scanning revival, no paste field. Links open the app; that's the door. (The
NSCameraUsageDescriptionstring 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
- iOS: entitlement + URL handlers + welcome card +
SpaceClaimmember/DM + add-device shapes (with tests against join.js's shapes) + the post-join passkey nudge. Ships alone; Mac unaffected. - Server: AASA gains the Mac app id (one-line change + test update).
- Mac: entitlements + handler + the same card.
- 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=developerneeded 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.athosts 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.