eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

πŸ€– 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.