eleven / messenger Public

server+web: the passkey return handshake β€” completion oracle, poller, recorded skips (#711)

The onboarding conversations lost track of a finished sign-in ceremony in
every context where the success redirect lands somewhere other than the page
that launched it: iOS home-screen apps run cross-origin links in an in-app
overlay with separate storage, and the native wrapper hands off to the system
browser. The page running the flow polls a localStorage flag that nothing
ever flips there β€” so Eleven re-pitched the step forever.

Worse, on EVERY resume (including the working desktop redirect) the
email/photo 'Skip for now' chips recorded nothing, so the flow replayed the
already-skipped steps β€” the 'it keeps saying Skip for now' report.

- home: /enroll reports a finished ceremony to a completion oracle keyed by
  the instance's client-random nonce (in-memory, TTL); GET /api/enroll-status
  answers the instance's poll (CORS-open β€” the nonce is the capability).
- web: backup.js polls the oracle while an enrolment is pending (launch +
  boot + visibilitychange); standalone PWAs switch to stay-mode like the
  native wrapper so the overlay ends on a done screen instead of re-booting
  the app; instance CSP admits the home origin for exactly this poll.
- flows: skip chips record their step (a skip is an answer); the waiting
  steps settle on bridge.enrolled so a late confirmation advances with the
  proper ack, no Continue tap; setup mirrors welcome's resumeUnless so the
  return leg skips the generic re-greeting. Native flow twins regenerated.

Verified end-to-end (Playwright + virtual authenticator, real WebAuthn PRF
ceremony against local home+instance): desktop redirect leg and stay-mode
poller leg both land on the backed-up ack with zero manual taps.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b6b07c3ca46032280141f036177c355e2002140f parent dbb115e
12 files changed, +433 −14
  • changelog/2026-07-11-sign-in-pickup.md +1 −0
  • home/enrolstatus.go +103 −0
  • home/enrolstatus_test.go +106 −0
  • home/main.go +13 −0
  • home/web/static/enroll.js +15 −0
  • ios/LChatCore/Sources/LChatCore/Flows/setup.json +8 −0
  • ios/LChatCore/Sources/LChatCore/Flows/welcome.json +4 −0
  • main.go +4 −2
  • test/flow.test.mjs +60 −0
  • web/static/apps/setup.flow.js +19 −2
  • web/static/apps/welcome.flow.js +6 −0
  • web/static/backup.js +94 −10
diff --git a/changelog/2026-07-11-sign-in-pickup.md b/changelog/2026-07-11-sign-in-pickup.md
new file mode 100644
index 0000000..b2a5aae
--- /dev/null
+++ b/changelog/2026-07-11-sign-in-pickup.md
@@ -0,0 +1 @@
+πŸ€– Saving your access no longer gets stuck. When you finish signing in, Eleven now notices right away and carries the conversation on β€” including on iPhone home-screen apps and in the Mac app, where the sign-in finishes in a separate browser window. No more tapping "Continue" or being asked to skip something you already did.
diff --git a/home/enrolstatus.go b/home/enrolstatus.go
new file mode 100644
index 0000000..709a8c8
--- /dev/null
+++ b/home/enrolstatus.go
@@ -0,0 +1,103 @@
+// The enrolment completion oracle. An instance page that launches a passkey
+// enrolment can't always receive the success redirect: iOS home-screen apps
+// open cross-origin links in an in-app overlay with its own storage, and the
+// native wrapper hands off to the system browser β€” in both, the redirect (and
+// its nonce marker) lands in the wrong browsing context and the page that
+// started the enrolment never learns it finished. So /enroll also reports
+// completion here, keyed by the same client-random nonce the instance minted,
+// and the instance polls GET /api/enroll-status until it confirms.
+//
+// Privacy: the nonce is random client entropy that never carries identity β€”
+// this server learns nothing it didn't already know from storing the backup,
+// and a forged "done" could only quiet an instance's own backup nudge. Marks
+// live in memory only: enrolments are short-lived (the poll spans minutes),
+// and losing them on a restart just falls back to the redirect path.
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ enrolMarkTTL = 30 * time.Minute
+ enrolMarkCap = 10000 // memory guard; enrolments are low-volume
+ nonceMinLen = 8
+ nonceMaxLen = 128
+)
+
+type enrolMarks struct {
+ mu sync.Mutex
+ done map[string]time.Time
+ now func() time.Time // injectable clock for tests
+}
+
+func newEnrolMarks() *enrolMarks {
+ return &enrolMarks{done: map[string]time.Time{}, now: time.Now}
+}
+
+// purgeLocked drops expired marks; callers hold mu.
+func (m *enrolMarks) purgeLocked() {
+ cutoff := m.now().Add(-enrolMarkTTL)
+ for n, t := range m.done {
+ if t.Before(cutoff) {
+ delete(m.done, n)
+ }
+ }
+}
+
+func (m *enrolMarks) mark(nonce string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.purgeLocked()
+ if len(m.done) >= enrolMarkCap {
+ return // refuse rather than evict β€” a flood mustn't unmark real enrolments
+ }
+ m.done[nonce] = m.now()
+}
+
+func (m *enrolMarks) check(nonce string) bool {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ t, ok := m.done[nonce]
+ return ok && t.After(m.now().Add(-enrolMarkTTL))
+}
+
+func validNonce(n string) bool {
+ return len(n) >= nonceMinLen && len(n) <= nonceMaxLen
+}
+
+// enrollDoneHandler (POST /api/enroll-done, rate-limited): the enrol page
+// reports a finished ceremony. Same-origin only in practice (called by
+// enroll.js), but nothing here needs a stronger gate β€” see the file header.
+func (s *Server) enrollDoneHandler(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Nonce string `json:"nonce"`
+ }
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&body); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+ nonce := strings.TrimSpace(body.Nonce)
+ if !validNonce(nonce) {
+ http.Error(w, "bad nonce", http.StatusBadRequest)
+ return
+ }
+ s.enrol.mark(nonce)
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// enrollStatusHandler (GET /api/enroll-status?nonce=...) answers "has an
+// enrolment with this nonce completed?". CORS-open on purpose: the poller is
+// an instance page on an arbitrary origin, and the nonce itself is the
+// capability β€” without it the endpoint reveals nothing.
+func (s *Server) enrollStatusHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Content-Type", "application/json")
+ nonce := r.URL.Query().Get("nonce")
+ done := validNonce(nonce) && s.enrol.check(nonce)
+ json.NewEncoder(w).Encode(map[string]bool{"done": done})
+}
diff --git a/home/enrolstatus_test.go b/home/enrolstatus_test.go
new file mode 100644
index 0000000..8dd2fbc
--- /dev/null
+++ b/home/enrolstatus_test.go
@@ -0,0 +1,106 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+)
+
+func enrolStatus(t *testing.T, ts string, nonce string) bool {
+ t.Helper()
+ resp, err := http.Get(ts + "/api/enroll-status?nonce=" + nonce)
+ if err != nil {
+ t.Fatalf("status GET: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status code = %d", resp.StatusCode)
+ }
+ if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" {
+ t.Fatalf("ACAO = %q, want *", got)
+ }
+ var body struct{ Done bool }
+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ return body.Done
+}
+
+func TestEnrolStatusRoundTrip(t *testing.T) {
+ ts, _ := newTestServer(t)
+ defer ts.Close()
+
+ const nonce = "abcdefgh1234"
+ if enrolStatus(t, ts.URL, nonce) {
+ t.Fatal("unknown nonce reported done")
+ }
+
+ resp, err := http.Post(ts.URL+"/api/enroll-done", "application/json",
+ strings.NewReader(`{"nonce":"`+nonce+`"}`))
+ if err != nil {
+ t.Fatalf("done POST: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusNoContent {
+ t.Fatalf("done POST code = %d", resp.StatusCode)
+ }
+
+ if !enrolStatus(t, ts.URL, nonce) {
+ t.Fatal("marked nonce not reported done")
+ }
+ if enrolStatus(t, ts.URL, "differentnonce") {
+ t.Fatal("other nonce reported done")
+ }
+}
+
+func TestEnrolDoneRejectsBadNonces(t *testing.T) {
+ ts, _ := newTestServer(t)
+ defer ts.Close()
+
+ for _, body := range []string{`{}`, `{"nonce":"short"}`, `{"nonce":"` + strings.Repeat("x", 200) + `"}`, `not json`} {
+ resp, err := http.Post(ts.URL+"/api/enroll-done", "application/json", strings.NewReader(body))
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("body %q: code = %d, want 400", body, resp.StatusCode)
+ }
+ }
+}
+
+func TestEnrolMarksExpiry(t *testing.T) {
+ m := newEnrolMarks()
+ now := time.Now()
+ m.now = func() time.Time { return now }
+ m.mark("nonce-that-expires")
+ if !m.check("nonce-that-expires") {
+ t.Fatal("fresh mark not found")
+ }
+ now = now.Add(enrolMarkTTL + time.Minute)
+ if m.check("nonce-that-expires") {
+ t.Fatal("expired mark still reported done")
+ }
+ // And the next mark purges it from the map entirely.
+ m.mark("another-nonce")
+ m.mu.Lock()
+ _, still := m.done["nonce-that-expires"]
+ m.mu.Unlock()
+ if still {
+ t.Fatal("expired mark not purged")
+ }
+}
+
+func TestEnrolMarksCap(t *testing.T) {
+ m := newEnrolMarks()
+ for i := 0; i < enrolMarkCap; i++ {
+ m.mark("padpadpad" + strconv.Itoa(i))
+ }
+ m.mark("overflow-nonce")
+ if m.check("overflow-nonce") {
+ t.Fatal("mark accepted past the cap")
+ }
+}
diff --git a/home/main.go b/home/main.go
index 386b6c3..312fee7 100644
--- a/home/main.go
+++ b/home/main.go
@@ -61,6 +61,10 @@ type Server struct {
page []byte
enrollPage []byte
restorePage []byte
+ // enrol remembers recently-completed enrolments by nonce (enrolstatus.go)
+ // so the page that launched one can poll for the finish. In-memory: if this
+ // server ever runs replicated, move the marks into the store.
+ enrol *enrolMarks
}
func main() {
@@ -193,6 +197,7 @@ func newServer(store vaultStore, origin string, stateSecret []byte) (*Server, er
limiter: newRateLimiter(10, 10.0/60), // burst of 10, refills 10/minute
page: page,
origin: strings.TrimRight(origin, "/"),
+ enrol: newEnrolMarks(),
}, nil
}
@@ -227,6 +232,14 @@ func (s *Server) handler() http.Handler {
mux.HandleFunc("POST /api/login/begin", s.rateLimited(s.loginBegin))
mux.HandleFunc("POST /api/login/finish", s.loginFinish)
+ // Enrolment completion oracle (enrolstatus.go): /enroll reports a finished
+ // ceremony; the instance page that launched it polls for the answer. The
+ // status read is CORS-open and unlimited on purpose β€” it's a map lookup
+ // gated by an unguessable client nonce, and the poller may not share an IP
+ // budget with anyone (the write side is rate-limited like the begins).
+ mux.HandleFunc("POST /api/enroll-done", s.rateLimited(s.enrollDoneHandler))
+ mux.HandleFunc("GET /api/enroll-status", s.enrollStatusHandler)
+
// The blob: read/write with a link token; delete only with a fresh ceremony.
mux.HandleFunc("GET /api/blob", s.getBlobHandler)
mux.HandleFunc("PUT /api/blob", s.putBlobHandler)
diff --git a/home/web/static/enroll.js b/home/web/static/enroll.js
index 9f47a9e..bedd2ff 100644
--- a/home/web/static/enroll.js
+++ b/home/web/static/enroll.js
@@ -304,6 +304,21 @@ function succeed() {
// Seal the tile's key badge (β†’ green check) and hold a short beat before
// leaving, so success is legible instead of a flash before the redirect.
$("badge").classList.add("sealed");
+ // Report completion to the oracle (enrolstatus.go): the page that launched
+ // this enrolment may never receive the redirect (iOS home-screen apps open
+ // cross-origin links in an in-app overlay; the native wrapper hands off to
+ // the system browser) β€” it polls /api/enroll-status with this same nonce
+ // instead. Fire-and-forget, keepalive so it survives the redirect below.
+ if (payload.nonce) {
+ try {
+ fetch("/api/enroll-done", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ nonce: String(payload.nonce) }),
+ keepalive: true,
+ }).catch(() => {});
+ } catch {}
+ }
setStatus("Saved. Taking you back to your chat…", "ok");
setTimeout(() => goBack(true), 700); // returns with the one-time nonce so the instance trusts it
}
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/setup.json b/ios/LChatCore/Sources/LChatCore/Flows/setup.json
index 43a98f6..17aad8f 100644
--- a/ios/LChatCore/Sources/LChatCore/Flows/setup.json
+++ b/ios/LChatCore/Sources/LChatCore/Flows/setup.json
@@ -400,6 +400,10 @@
},
{
"id": "keys-waiting",
+ "settle": {
+ "when": "bridge.enrolled",
+ "goto": "keys"
+ },
"bubbles": [
{
"text": "I'll wait here while you finish signing in. When you're done, tap Continue."
@@ -439,6 +443,10 @@
},
{
"id": "keys-confirm",
+ "settle": {
+ "when": "bridge.enrolled",
+ "goto": "keys"
+ },
"bubbles": [
{
"text": "I have to be straight with you: without a backup, one lost or cleared browser means this space and everything in it is gone β€” permanently, for everyone, and no one can help. I'll remind you again in a day or so."
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
index aa33c55..e8bc505 100644
--- a/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
+++ b/ios/LChatCore/Sources/LChatCore/Flows/welcome.json
@@ -114,6 +114,10 @@
},
{
"id": "access-waiting",
+ "settle": {
+ "when": "bridge.enrolled",
+ "goto": "access"
+ },
"bubbles": [
{
"text": "I'll wait here while you finish saving the passkey. When you're done, tap Continue."
diff --git a/main.go b/main.go
index d95fc1f..f0132a6 100644
--- a/main.go
+++ b/main.go
@@ -574,8 +574,10 @@ func (s *Server) servePage(name string) http.HandlerFunc {
// *.giphy.com is the /gif app's CLIENT-side doorway (hostBridge.gif in
// shell.js): search + GIF download happen in the browser, so the server
// never sees the query or the pick β€” the deliberate, disclosed sibling
- // of the unfurl trade-off. Keep connect-src pinned to named hosts.
- "connect-src 'self' https://*.giphy.com; "+
+ // of the unfurl trade-off. The home origin admits exactly one call: the
+ // enrolment completion poll (backup.js startEnrolPoll), which sends only
+ // the client-random nonce. Keep connect-src pinned to named hosts.
+ "connect-src 'self' https://*.giphy.com "+s.homeOrigin+"; "+
// 'self' admits exactly one same-origin frame: /app-sandbox, the
// opaque-origin cage member apps run in (memberapps.go).
"frame-src 'self' https://www.youtube-nocookie.com; "+
diff --git a/test/flow.test.mjs b/test/flow.test.mjs
index ace9e0f..7071c11 100644
--- a/test/flow.test.mjs
+++ b/test/flow.test.mjs
@@ -498,6 +498,66 @@ await test("welcome: passkey-return leg β€” richer ack, no generic re-greet, que
assert.ok(h2.transcript.find((t) => t.extra.key === "q-photo"), "resumes at the first undone step");
});
+// The ceremony can finish in ANOTHER browsing context (iOS home-screen apps run
+// it in an in-app overlay; the native wrapper in the system browser) β€” the
+// completion poll flips bridge.enrolled while the flow sits on the WAITING
+// step. The waiting step's settle must then carry the flow on, with the access
+// step's proper record + ack, and never require the manual "Continue" tap.
+await test("welcome: enrolment confirmed while on access-waiting settles through", async () => {
+ const h = harness({ envExtra: welcomeEnv() });
+ runFlow(WELCOME_FLOW, h.env, h.io);
+ await tick();
+
+ await h.tap("Save my access (recommended)"); // fire-and-forget verb; page stays (stay mode)
+ const probe = h.pendingDelays.find((d) => d.ms === 1500);
+ assert.ok(probe, "the save chip arms its fall-to-waiting probe");
+ probe.fn();
+ await tick();
+ assert.ok(h.transcript.find((t) => /I'll wait here while you finish saving/.test(t.text)), "fell to access-waiting");
+
+ // The poll confirms; access-waiting's settle re-enters access, whose entry
+ // records the backup and acks β€” no Continue tap.
+ h.enrolled = true;
+ const settle = h.pendingDelays.filter((d) => d.ms === 700).at(-1);
+ assert.ok(settle, "access-waiting arms a settle poll");
+ settle.fn();
+ await tick();
+ await tick();
+ assert.deepEqual(h.records.find((r) => r.step === "access"),
+ { step: "access", label: "Saved your access securely with a passkey", value: "passkey" });
+ assert.ok(h.transcript.find((t) => /^Great β€” welcome back\. πŸ”’/.test(t.text)));
+ assert.ok(h.transcript.find((t) => t.extra.key === "q-name"), "carried on to the name step");
+});
+
+await test("setup: enrolment confirmed while on keys-waiting settles through", async () => {
+ const h = harness({
+ myName: "Paul",
+ done: { name: true, group: true, email: true, photo: true, invites: true },
+ });
+ runFlow(SETUP_FLOW, h.env, h.io);
+ await tick();
+ assert.ok(h.transcript.find((t) => /Now the most important thing of all/.test(t.text)), "keys pitch shown");
+
+ await h.tap("Sign in");
+ const probe = h.pendingDelays.find((d) => d.ms === 1500);
+ probe.fn();
+ await tick();
+ assert.ok(h.transcript.find((t) => /I'll wait here while you finish signing in/.test(t.text)), "fell to keys-waiting");
+
+ h.enrolled = true;
+ const settle = h.pendingDelays.filter((d) => d.ms === 700).at(-1);
+ assert.ok(settle, "keys-waiting arms a settle poll");
+ settle.fn();
+ await tick();
+ await tick();
+ assert.deepEqual(h.records.find((r) => r.step === "keys"),
+ { step: "keys", label: "Saved your credentials securely with a passkey", value: "passkey" });
+ assert.ok(h.transcript.find((t) => /safely backed up behind your passkey. βœ…/.test(t.text)));
+ assert.ok(h.verbLog.find((v) => v[0] === "markSetupComplete"), "reached finish without a tap");
+ // Backed up β†’ the finish bubble must NOT nag about coming back to it.
+ assert.equal(h.transcript.some((t) => /come back to that key backup/.test(t.text)), false);
+});
+
// ---- native bundle drift gate ------------------------------------------------
// The Swift interpreter (ios/.../FlowEngine.swift) runs byte-derived copies of
// these flow documents (Flows/*.json). Regenerate after editing a flow:
diff --git a/web/static/apps/setup.flow.js b/web/static/apps/setup.flow.js
index b1858d3..4aeff2d 100644
--- a/web/static/apps/setup.flow.js
+++ b/web/static/apps/setup.flow.js
@@ -11,6 +11,9 @@ export default {
// alone doesn't count: the native creator arrives with one pre-set from the
// create screen, and that first run must still get the greeting.)
resumedIf: { any: ["done.name", "done.group", "done.email", "done.photo", "done.invites", "done.keys"] },
+ // The passkey RETURN leg gets its own richer ack in the keys step's entry,
+ // so it skips the generic re-greeting (mirrors welcome.flow.js).
+ resumeUnless: { all: ["bridge.enrolled", "!done.keys"] },
resumeSay: "Welcome back β€” let's pick up where we left off.",
steps: [
{
@@ -86,7 +89,12 @@ export default {
hint: "Type a contact email",
},
],
- chips: [{ label: "Skip for now", spent: "Skipped", goto: "photo" }],
+ // The skip RECORDS the step: an unrecorded skip replays on every resume β€”
+ // notably the passkey return leg, which re-asked the email (and photo)
+ // instead of carrying on. A skip is an answer.
+ chips: [
+ { label: "Skip for now", spent: "Skipped", record: { label: "skipped the contact email", value: "skipped" }, goto: "photo" },
+ ],
input: { kind: "text" },
effect: { verb: "setContactEmail", args: ["$answer"] },
on: {
@@ -110,7 +118,10 @@ export default {
attachHint: "Optional: type alt text for the photo",
},
],
- chips: [{ label: "Skip for now", spent: "Skipped", cancelInput: true, goto: "invites" }],
+ // Recorded for the same resume reason as the email skip.
+ chips: [
+ { label: "Skip for now", spent: "Skipped", cancelInput: true, record: { label: "skipped the space photo", value: "skipped" }, goto: "invites" },
+ ],
input: {
kind: "photo",
reject: { text: "Send it as a picture β€” tap the + next to the message box or drag one in β€” or tap Skip." },
@@ -264,6 +275,9 @@ export default {
},
{
id: "keys-waiting",
+ // The ceremony may confirm while we sit here (the completion poll flips
+ // the flag) β€” re-enter keys so its entry records + acks, no tap needed.
+ settle: { when: "bridge.enrolled", goto: "keys" },
bubbles: [{ text: "I'll wait here while you finish signing in. When you're done, tap Continue." }],
chips: [
{
@@ -291,6 +305,9 @@ export default {
},
{
id: "keys-confirm",
+ // Same late-confirmation catch as keys-waiting: a ceremony finished in
+ // another context can land while the warning is up β€” advance over it.
+ settle: { when: "bridge.enrolled", goto: "keys" },
bubbles: [
{
text: "I have to be straight with you: without a backup, one lost or cleared browser means this space and everything in it is gone β€” permanently, for everyone, and no one can help. I'll remind you again in a day or so.",
diff --git a/web/static/apps/welcome.flow.js b/web/static/apps/welcome.flow.js
index 0dafbf3..18774d0 100644
--- a/web/static/apps/welcome.flow.js
+++ b/web/static/apps/welcome.flow.js
@@ -87,6 +87,9 @@ export default {
},
{
id: "access-waiting",
+ // The ceremony may confirm while we sit here (the completion poll flips
+ // the flag) β€” re-enter access so its entry records + acks, no tap needed.
+ settle: { when: "bridge.enrolled", goto: "access" },
bubbles: [{ text: "I'll wait here while you finish saving the passkey. When you're done, tap Continue." }],
chips: [
{
@@ -166,6 +169,9 @@ export default {
spent: "Skipped",
cancelInput: true,
set: { "vars.skippedPhoto": true },
+ // Recorded so a resume (notably the passkey return leg) doesn't
+ // re-ask a step the person already waved away. A skip is an answer.
+ record: { label: "skipped the profile photo", value: "skipped" },
goto: "finish",
},
],
diff --git a/web/static/backup.js b/web/static/backup.js
index d076bd1..4035e5c 100644
--- a/web/static/backup.js
+++ b/web/static/backup.js
@@ -150,6 +150,11 @@ export function initBackupBanner(session, opts = {}) {
// If we've just returned from the home server's /enroll page, record the
// backup (and scrub the marker) before deciding whether to nudge.
checkEnrolReturn();
+ // An enrolment that's still pending (the redirect never reached us β€” see
+ // startEnrolPoll) may have finished in another browsing context: ask the
+ // home server. Runs before the suppress bail so a mid-conversation reload
+ // still picks the completion up.
+ startEnrolPoll();
// While the setup conversation is running, backup messaging is Eleven's job β€”
// one voice, not a sidebar banner competing with the in-chat key ceremony.
if (opts.suppress) return;
@@ -237,13 +242,20 @@ export function launchEnrol(session) {
const home = window.__homeOrigin;
if (!home) return;
const native = isNativeWrapper();
+ // "Stay" contexts keep THIS page alive and run the ceremony elsewhere: the
+ // native wrapper opens the system browser, and an iOS home-screen app opens
+ // cross-origin links in an in-app overlay whose storage we don't share β€” a
+ // redirect back would land in the wrong context (and, in the overlay, boot
+ // the whole app inside it). In stay mode /enroll ends on a "done" screen
+ // instead of redirecting, and completion reaches us via startEnrolPoll.
+ const stay = native || isStandalonePWA();
const nonce = randomNonce();
localStorage.setItem(PENDING_KEY, nonce);
const payload = {
v: 1,
nonce, // echoed back in the return fragment to prove the redirect is genuine
ret: location.origin, // where /enroll sends the user back to (web only)
- native, // native wrapper: /enroll ends on a "done" screen, no redirect back
+ native: stay, // stay mode: /enroll ends on a "done" screen, no redirect back
entry: {
url: location.origin, // the instance being enrolled
name: groupName(), // best-effort, for the confirmation UI
@@ -261,16 +273,17 @@ export function launchEnrol(session) {
};
const encoded = bytesToB64url(new TextEncoder().encode(JSON.stringify(payload)));
const url = `${home}/enroll#${encoded}`; // payload rides in the #fragment β€” never sent to a server
- if (native) {
- // In the native wrapper (Mac app), the passkey ceremony can't run in the
- // WKWebView, and navigating the instance's own webview cross-origin trips the
- // app's join fallback. Open the system browser instead (the app routes
- // window.open there), where passkeys work. Snooze the nudge so it doesn't nag
- // while the user finishes in the browser. (Native auto-clear on return is a
- // follow-up β€” needs a deep-link handshake back into the app.)
- localStorage.setItem(SNOOZE_KEY, String(Date.now() + SNOOZE_MS));
- removeBanner();
+ if (stay) {
+ // Native wrapper: the ceremony can't run in the WKWebView, and navigating
+ // the instance's own webview cross-origin trips the app's join fallback β€”
+ // the app routes window.open to the system browser, where passkeys work.
+ // Standalone PWA: window.open keeps the app page alive under the overlay.
+ // Either way this page survives, so the poller below picks up completion
+ // and flips the flag the conversation flows are settling on. The native
+ // snooze stays as a belt-and-braces fallback should the poll miss.
+ if (native) localStorage.setItem(SNOOZE_KEY, String(Date.now() + SNOOZE_MS));
window.open(url, "_blank");
+ startEnrolPoll();
} else {
location.assign(url); // same-tab redirect: Safari blocks WebAuthn in a popup
}
@@ -280,6 +293,77 @@ function isNativeWrapper() {
return document.documentElement.classList.contains("native");
}
+// Standalone (installed / home-screen) PWA display context β€” where cross-origin
+// navigations open an in-app browser overlay instead of leaving the page.
+function isStandalonePWA() {
+ try {
+ return (window.matchMedia && window.matchMedia("(display-mode: standalone)").matches) ||
+ window.navigator.standalone === true;
+ } catch {
+ return false;
+ }
+}
+
+// ---- enrolment completion poll ------------------------------------------------
+// The success redirect can land in a DIFFERENT browsing context than the page
+// that launched the enrolment: iOS home-screen apps open cross-origin links in
+// an in-app overlay with its own storage, and the native wrapper hands off to
+// the system browser. So the redirect's nonce marker (checkEnrolReturn) can't be
+// the only completion signal. While an enrolment is pending, ask the home
+// server's completion oracle β€” keyed by the same client-random nonce β€” and flip
+// the local flag the moment it confirms; the conversation flows' settle polls
+// (and the banner rules) advance from there on their own. An old home server
+// without the oracle 404s β€” we stop quietly and behave exactly as before.
+let enrolPollActive = false;
+
+export function startEnrolPoll() {
+ const home = window.__homeOrigin;
+ if (enrolPollActive || !home || isEnrolled() || !localStorage.getItem(PENDING_KEY)) return;
+ enrolPollActive = true;
+ const deadline = Date.now() + 10 * 60 * 1000; // long enough for a slow ceremony, not forever
+ let timer = null;
+ let misses = 0; // consecutive 404s β†’ the home server has no oracle
+ const stop = () => {
+ enrolPollActive = false;
+ clearTimeout(timer);
+ document.removeEventListener("visibilitychange", kick);
+ };
+ const schedule = () => {
+ timer = setTimeout(tick, 3000);
+ };
+ // A hidden tab pauses the loop; coming back (tabbing back from the ceremony)
+ // checks immediately β€” that's the moment completion is most likely.
+ const kick = () => {
+ if (!document.hidden) {
+ clearTimeout(timer);
+ tick();
+ }
+ };
+ const tick = async () => {
+ if (!enrolPollActive) return;
+ const nonce = localStorage.getItem(PENDING_KEY);
+ if (!nonce || isEnrolled() || Date.now() > deadline) return stop();
+ if (document.hidden) return; // resumes via the visibilitychange kick
+ try {
+ const r = await fetch(`${home}/api/enroll-status?nonce=${encodeURIComponent(nonce)}`);
+ if (r.status === 404) {
+ if (++misses >= 3) return stop();
+ } else if (r.ok) {
+ misses = 0;
+ if ((await r.json()).done) {
+ localStorage.setItem(ENROLLED_KEY, "1");
+ localStorage.removeItem(PENDING_KEY);
+ removeBanner();
+ return stop();
+ }
+ }
+ } catch {}
+ schedule();
+ };
+ document.addEventListener("visibilitychange", kick);
+ schedule();
+}
+
function randomNonce() {
const b = new Uint8Array(16);
crypto.getRandomValues(b);