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>
12 files changed,
+433
−14
changelog/2026-07-11-sign-in-pickup.md+1 −0home/enrolstatus.go+103 −0home/enrolstatus_test.go+106 −0home/main.go+13 −0home/web/static/enroll.js+15 −0ios/LChatCore/Sources/LChatCore/Flows/setup.json+8 −0ios/LChatCore/Sources/LChatCore/Flows/welcome.json+4 −0main.go+4 −2test/flow.test.mjs+60 −0web/static/apps/setup.flow.js+19 −2web/static/apps/welcome.flow.js+6 −0web/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); |