From 7b657d3aac270f28404a8972ed742b982234e3ce Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 00:31:52 -0500 Subject: [PATCH 1/9] BUZZ-518: Add FI signup and auth recovery Co-authored-by: Honor Signed-off-by: Brad Seiler --- docs/buzz-help.md | 6 + docs/community-auth-recovery.md | 74 ++++++++++ src/features/communities/FindCommunity.tsx | 11 ++ .../communities/find-community.test.ts | 36 +++++ src/features/communities/find-community.ts | 26 +++- .../CommunitySignIn.test.tsx | 70 +++++++++- .../federated-identity/CommunitySignIn.tsx | 70 ++++++++-- src/features/identity/ConnectChoice.tsx | 14 +- src/features/identity/Welcome.tsx | 50 ++++++- src/features/identity/backup.test.ts | 35 +++++ .../identity/builderlab-account.test.ts | 11 ++ src/features/identity/builderlab-account.ts | 21 ++- .../identity/federated-first-run.test.ts | 129 ++++++++++++++++++ src/features/identity/federated-first-run.ts | 35 +++++ src/features/identity/identity.test.tsx | 120 ++++++++++++++++ 15 files changed, 690 insertions(+), 18 deletions(-) create mode 100644 docs/community-auth-recovery.md create mode 100644 src/features/identity/federated-first-run.test.ts create mode 100644 src/features/identity/federated-first-run.ts diff --git a/docs/buzz-help.md b/docs/buzz-help.md index b0a51e62aa..b85fd23568 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -57,6 +57,12 @@ the interface unless an available tool actually supports that action. unavailable until you sign in. Sign-in, sign-out and when the current sign-in ends also live under **Settings → Buzz → Identity → Organization sign-in**. +- A community may require account sign-in even when you already have a key. + Sign-in keeps that key. If the account is linked to another identity, review + the link or choose **Sign in with another account**; Buzz does not replace it + automatically. A build without authority configured for that exact community + needs the administrator's supported build. Sign-in alone does not grant + community membership or require joining a sponsor's billing workspace. - A community invite or address connects someone to a community; creating an identity alone does not grant membership. The same identity can be used in more than one community. diff --git a/docs/community-auth-recovery.md b/docs/community-auth-recovery.md new file mode 100644 index 0000000000..2410009eee --- /dev/null +++ b/docs/community-auth-recovery.md @@ -0,0 +1,74 @@ +# Required-community client support and acceptance + +BUZZ-518 adds guidance from the host-specific `requires_nip_fi` discovery field. +It does not reinterpret `limitation.federated_identity`: that remains capability +advertisement, and the native client retains its existing attachment and denial +behavior when the requirement field is absent. Malformed requirement discovery +offers retry and administrator/build guidance rather than implying free access. + +The first-run Welcome component accepts the existing native FI service and uses +the bundled Builderlab authority grant for account checks and explicit linking. +It reuses the existing linked-account/import/create choices. Account tokens stay +native; requests carry only the public session revision. Native validation still +checks the enabled artifact, authority, operation and current session. A conflicting +binding stops before a challenge is requested; no automatic unlink is added. + +**Draft integration gate:** `App.tsx` must pass +`federatedIdentity={services.federatedIdentity}` to `Welcome`. That FOUNDATION edit +needs explicit human guidance under `AGENTS.md`; until supplied, the app's first-run +FI path is not wired. Component tests alone do not establish packaged support. + +## Configuration and artifacts + +| Artifact or integration | Supported configuration and limits | +| --- | --- | +| Candidate V1 desktop | Compile the same validated `BUZZ_BUILD_FEDERATED_IDENTITY_CONFIG` into Vite and Cargo. `communityHosts` must match the exact target host/port, or its configured single-label wildcard. `identityOrigin` is a bare HTTPS origin; `adapterBase` is its adapter prefix. Builderlab first run requires its `/api/goose/` API shape. | +| Existing V1 desktop releases | Inspect the actual build descriptor. A Block-only descriptor does not cover bb-public. Preview 233.1 had no FI descriptor; code support does not add configuration to an old artifact. | +| Runtime-descriptor proposal | PR #2152 is separate and is not assumed here. Its presence does not establish first-run or bundled-plugin configuration. | +| Local agents and bundled Builderlab agents | Existing native FI grants/assertions and NIP-OA attestation remain authoritative. Explicit linking uses the configured FI origin, derived by `crates/plugin-manager/build.rs`, plus the signer's existing production-origin allowance. A relay-advertised origin cannot expand that trust. | +| Agent Git evidence | PR #2240 is separate; this change does not claim its behavior. | +| CLI | This repository does not add an interactive CLI account flow. A CLI/agent needs its own supported assertion path and exact authority/host configuration. | +| Mobile | This repository does not produce the separate mobile client. Dogfood rc.17 (`b5b69d3d`) supports issuer-selected lifetimes but its inspected configuration covered the Block tenant, not bb-public. Mobile needs its own exact-host candidate and acceptance evidence. | + +For bb-public staging, the planned origin is `https://app.builderlabstaging.xyz`, +the adapter prefix is `/api/goose`, and the API base is `/api/goose/`. Match only +the intended community hosts; do not infer support from a build's name. Candidate +packaging and service activation belong to BUZZ-514–516. No shipped candidate or +successful live flow is asserted by this document. + +## Required live acceptance + +Use disposable accounts and keys in an isolated OS account. Do not overwrite an +operator's saved identity. Record build hash, descriptor, exact relay host, issuer, +and assertion lifetime without recording credentials or private keys. + +1. Fresh signup: sign in from Welcome, confirm the account is unlinked, create + and link a key, then join the originally entered required community. Exercise + backup only where the build/service explicitly supports it. +2. Returning account: confirm its bound npub is shown before import/restore. + A different key must be refused. Cancel and change account without creating a key. +3. Existing device key: record its npub, sign in and bind it, reconnect the same + community, and confirm the npub is unchanged after restart. +4. Conflict: retain the conflicting mapping and device key. Review the mapping + or explicitly select another account. Never silently unbind or regenerate. +5. Keep a required-community connection active across at least two actual + assertion lifetimes. Disconnect/reconnect and rediscover policy. Revoke the + account, key, and agent separately; protected access must stop and recover only + after permitted fresh authentication. +6. Check a free community, an old relay without the new field, an unsupported + host/authority, malformed discovery, storage refusal, browser cancellation, + expired session and network recovery. No relay may choose a new sign-in origin. + +Socket renewal already belongs to the native FI/socket owners (including #2142). +Audio acceptance remains BUZZ-457; this change adds no audio renewal mechanism. +Server membership checks remain independent of sponsor billing membership. + +Local component/service tests and the public-state recovery fixture do not prove +native credential custody, packaged SSO, mobile behavior or the live two-lifetime +contract. Keep those gaps explicit on the draft PR. + +Sources: [BUZZ-518 and its artifact correction](https://linear.app/squareup/issue/BUZZ-518/v1-clients-complete-account-signuplinking-and-auth-recovery-for), +[native session foundation](https://github.com/block/buzz-app/pull/1424), +[Builderlab FI forwarding](https://github.com/block/buzz-app/pull/1461), +[quiet socket renewal](https://github.com/block/buzz-app/pull/2142), +and the local [identity contract](identity.md) and [build configuration](configuration.md). diff --git a/src/features/communities/FindCommunity.tsx b/src/features/communities/FindCommunity.tsx index aeb7e700bd..63dc84600d 100644 --- a/src/features/communities/FindCommunity.tsx +++ b/src/features/communities/FindCommunity.tsx @@ -25,6 +25,11 @@ export type JoinConsent = Readonly<{ adult: boolean; agreed: boolean }>; /** Classified copy; raw transport errors are never shown. */ const FAILURES: Record = { + "unsupported-auth": { + message: + "This community requires sign-in that this Buzz build cannot confirm. Ask its administrator for a supported build and try again.", + retry: true, + }, unreachable: { message: "Could not reach the relay. Check your connection and try again.", retry: true, @@ -177,6 +182,12 @@ export function FindCommunity({ return ( <> {heading("Join Community")} + {found.requiresNipFi && ( +

+ This community requires account sign-in and a linked Buzz identity. + Your existing key will be kept. +

+ )}
) => error instanceof FindCommunityError ? error.failure : error, ); +it.each([true, false, undefined])( + "keeps host requirement %s separate from FI capability", + async (required) => { + const found = await find({ + "/": Response.json({ + ...buzz, + requires_nip_fi: required, + limitation: { federated_identity: true }, + federated_identity: { + core: "client-attached", + assertion_freshness: { class: "offline-jwt" }, + }, + }), + "/api/join-policy": html(404), + }); + expect(found.requiresNipFi).toBe(required); + }, +); + +it.each([ + { requires_nip_fi: "true" }, + { requires_nip_fi: null }, + { requires_nip_fi: true }, + { requires_nip_fi: true, limitation: { federated_identity: false } }, +])( + "gives actionable discovery failure for an invalid requirement %j", + async (info) => { + expect( + await failure({ + "/": Response.json({ ...buzz, ...info }), + "/api/join-policy": html(404), + }), + ).toBe("unsupported-auth"); + }, +); + it("reports only what the relay publishes, with the icon sanitized, from two unauthenticated reads", async () => { const request = fetcher({ "/": Response.json(buzz), diff --git a/src/features/communities/find-community.ts b/src/features/communities/find-community.ts index f192c67433..5a737e923f 100644 --- a/src/features/communities/find-community.ts +++ b/src/features/communities/find-community.ts @@ -12,9 +12,15 @@ export type FoundCommunity = { /** The relay deployment's key (NIP-11 `self`), when it is well-formed. */ self?: string | undefined; policy: JoinPolicy | null; + /** Host-specific requirement, distinct from legacy FI capability. */ + requiresNipFi?: boolean | undefined; }; -export type FindFailure = "unreachable" | "not-buzz" | "no-community"; +export type FindFailure = + | "unreachable" + | "not-buzz" + | "no-community" + | "unsupported-auth"; export class FindCommunityError extends Error { constructor(readonly failure: FindFailure) { @@ -61,6 +67,7 @@ export async function findCommunity( name: typeof info.name === "string" ? info.name : undefined, icon: communityIcon(info.icon), self: relaySelf(info), + requiresNipFi: info.requires_nip_fi as boolean | undefined, policy: await policyAnswer(await policy), }; } @@ -85,6 +92,23 @@ async function relayInfo(response: Response) { if (!body || typeof body !== "object" || Array.isArray(body)) throw new FindCommunityError("not-buzz"); const info = body as Record; + if (info.requires_nip_fi !== undefined) { + const descriptor = info.federated_identity as + | Record + | undefined; + const limitation = info.limitation as Record | undefined; + const freshness = descriptor?.assertion_freshness as + | Record + | undefined; + if ( + typeof info.requires_nip_fi !== "boolean" || + (info.requires_nip_fi && + (limitation?.federated_identity !== true || + descriptor?.core !== "client-attached" || + freshness?.class !== "offline-jwt")) + ) + throw new FindCommunityError("unsupported-auth"); + } // buzz-relay nip11.rs hard-codes both; either identifies the software. if ( info.name !== "Buzz Relay" && diff --git a/src/features/federated-identity/CommunitySignIn.test.tsx b/src/features/federated-identity/CommunitySignIn.test.tsx index 5507e355d8..b78f59e2d3 100644 --- a/src/features/federated-identity/CommunitySignIn.test.tsx +++ b/src/features/federated-identity/CommunitySignIn.test.tsx @@ -55,8 +55,76 @@ it.each(["binding-mismatch", "authorization-denied"])( ? "Settings → Builderlab hosting" : "administrator", ); - expect(screen.queryByRole("button")).not.toBeInTheDocument(); + if (failure === "binding-mismatch") + expect( + screen.getByRole("button", { name: "Sign in with another account" }), + ).toBeEnabled(); + else expect(screen.queryByRole("button")).not.toBeInTheDocument(); expect(h.service.signIn).not.toHaveBeenCalled(); expect(h.service.signOut).not.toHaveBeenCalled(); }, ); + +it("changes account only on explicit recovery, then resumes the refused connection", async () => { + const h = federatedFixture(); + h.publish({ status: "signed-in" }); + const retry = vi.fn(); + render( + , + ); + expect(h.service.signOut).not.toHaveBeenCalled(); + await userEvent + .setup() + .click( + screen.getByRole("button", { name: "Sign in with another account" }), + ); + expect(h.service.signOut).toHaveBeenCalledOnce(); + expect(h.service.signIn).toHaveBeenCalledOnce(); + expect(retry).toHaveBeenCalledOnce(); +}); + +it.each([ + ["Federated identity is not configured for this community", "exact host"], + [ + "Unsupported federated community discovery", + "Check your connection and retry", + ], +])("explains %s without opening a browser", (error, message) => { + const h = federatedFixture(); + render( + , + ); + expect(screen.getByRole("alert")).toHaveTextContent(message); + expect(h.service.signIn).not.toHaveBeenCalled(); +}); + +it("keeps sign-in cancellable while the browser handoff is pending", async () => { + const h = federatedFixture(); + let finish!: () => void; + vi.mocked(h.service.signIn).mockImplementation(async () => { + h.publish({ status: "signing-in" }); + await new Promise((resolve) => { + finish = resolve; + }); + return h.service.snapshot(); + }); + render( + , + ); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Sign in" })); + try { + await user.click(screen.getByRole("button", { name: "Cancel sign-in" })); + expect(h.service.cancelSignIn).toHaveBeenCalledOnce(); + } finally { + finish(); + } +}); diff --git a/src/features/federated-identity/CommunitySignIn.tsx b/src/features/federated-identity/CommunitySignIn.tsx index c4f732440f..7167279adb 100644 --- a/src/features/federated-identity/CommunitySignIn.tsx +++ b/src/features/federated-identity/CommunitySignIn.tsx @@ -53,15 +53,70 @@ function Action({ identity.snapshot, ); const [failure, setFailure] = useState(""); + const [switching, setSwitching] = useState(false); const refusal = error || state.failure || ""; - if (/binding[-_]mismatch/.test(refusal)) + const signIn = async (differentAccount = false) => { + setFailure(""); + setSwitching(differentAccount); + try { + if (differentAccount) await identity.signOut(); + const next = await identity.signIn(); + if (next.status === "signed-in") retry(); + } catch (error) { + setFailure( + error instanceof Error ? error.message : "Sign-in unavailable", + ); + } finally { + setSwitching(false); + } + }; + if (/not configured for this community/.test(refusal)) + return ( +

+ This Buzz build has no sign-in authority configured for this community. + Ask its administrator for a build configured for this exact host. Your + key is kept. +

+ ); + if (/federated community discovery/i.test(refusal)) return (

- This account is linked to a different Buzz identity. Open Settings → - Builderlab hosting to review the existing identity link. Keep this key - until you have reviewed the link. + Buzz couldn’t confirm this community’s sign-in requirements. Check your + connection and retry. If it keeps happening, ask its administrator for a + supported build.

); + if (/binding[-_]mismatch/.test(refusal)) + return ( + <> +

+ This account is linked to a different Buzz identity. Open Settings → + Builderlab hosting to review the existing identity link. Keep this key + until you have reviewed the link. +

+ {state.authority && ( + + )} + {failure && ( +

+ {recoveryMessage(failure)} +

+ )} + + ); if (/authorization[-_]denied/.test(refusal)) return (

@@ -89,14 +144,11 @@ function Action({ return ( <>

createOAuthSession(connect)); + const [legacyLogin] = useState(() => createOAuthSession(connect)); + const [nativeLogin, setNativeLogin] = + useState>(); + const login = nativeLogin ?? legacyLogin; // Counts canceled sign-ins: a sign-in continues only if none was canceled // since it started, since the session may by then hold a later sign-in. const cancels = useRef(0); const auth = useSyncExternalStore(login.subscribe, login.snapshot); const [setup] = useState(createSetup); const progress = useSyncExternalStore(setup.subscribe, setup.snapshot); + const firstRun = state.status === "missing" || progress.status !== "idle"; + useEffect(() => { + if (!firstRun || !federatedIdentity || !nativeBuilderlab()) return; + const session = createFederatedFirstRun(federatedIdentity); + setNativeLogin(session); + return () => { + if (session.snapshot().status === "pending") session.cancel(); + session.dispose(); + }; + }, [federatedIdentity, firstRun]); // The signed-in account's reads, from sign-in until the landing is shown again. const reads = useRef(undefined); // The hex key the account check found bound to the account: the returning- @@ -164,6 +181,7 @@ export function Welcome({ // discovery lookup; after setup, the Join Community page. Cleared on every // return to the landing or choices page. const [finding, setFinding] = useState(); + const [requiresSignIn, setRequiresSignIn] = useState(false); // The code of the invite link `finding` came from, claimed on Join; "" for // a bare address. Every submit sets it with `finding`. const [inviteCode, setInviteCode] = useState(""); @@ -293,10 +311,23 @@ export function Welcome({ const attempt = cancels.current; // A canceled attempt still settles here, possibly after a later sign-in // succeeded; it must not read that one's credential. - await login.signIn(); + if (nativeLogin) { + // Native sign-in settles before its account read. Wait for the account, + // including restore already in flight, before choosing a key lane. + await nativeLogin + .ensureSignedIn(new AbortController().signal) + .catch(() => {}); + } else await login.signIn(); if (attempt !== cancels.current || login.snapshot().status !== "signed-in") return; - const current = account(login.credential(), identity); + const current = + nativeLogin && federatedIdentity + ? builderlabSessionAccount( + nativeLogin, + identity, + federatedIdentity.snapshot().revision, + ) + : account(legacyLogin.credential(), identity); reads.current = current; let checkedKey: string | undefined; let checkedBackups: readonly string[] | undefined; @@ -436,6 +467,7 @@ export function Welcome({ setFinding(undefined); setInviteCode(""); setSwitchError(undefined); + setRequiresSignIn(false); setLinked(undefined); setOwned(undefined); setBackups(undefined); @@ -600,6 +632,12 @@ export function Welcome({ return finding ? ( { setup.reset(); void signIn(); @@ -668,7 +706,10 @@ export function Welcome({ ]); }} onImport={() => pause("key-import")} - onSwitchAccount={backToLanding} + onSwitchAccount={() => { + nativeLogin?.signOut(); + backToLanding(); + }} /> ); case "welcome-back": @@ -971,6 +1012,7 @@ export function Welcome({ url={finding} onCancel={backToLanding} onFound={(found) => { + setRequiresSignIn(found.requiresNipFi === true); if (hostedByThisBuilderlab(found)) void signIn(); else pause("connect-choice"); }} diff --git a/src/features/identity/backup.test.ts b/src/features/identity/backup.test.ts index bb01556fda..75aaaeadb3 100644 --- a/src/features/identity/backup.test.ts +++ b/src/features/identity/backup.test.ts @@ -260,6 +260,41 @@ function backUp( const settledAt = (seq: number, pass: number, settled: object) => ({ seq, pass, builderlab: true, ...settled }) as BackupStatus; +it("waits for a native first-run backup using a public revision rather than a renderer credential", async () => { + const host = native(); + host.session.mockResolvedValueOnce(1); + const identity: Waiter = { + backup: host.store(), + setBackupChoice: vi.fn(async (pubkey, automatic) => ({ + available: true, + pubkey, + automatic, + })), + }; + const done = backUpWith( + identity, + pubkey, + { federated: "session-revision" }, + { send: host.send as Send }, + ); + await vi.waitFor(() => + expect(host.send).toHaveBeenCalledWith( + "nsec_backup_session", + expect.objectContaining({ + credential: null, + federated: "session-revision", + }), + ), + ); + host.watchers[0]?.onmessage(at(2, 1, "complete")); + await expect(done).resolves.toBeUndefined(); + expect( + host.send.mock.calls.filter( + ([command]) => command === "nsec_backup_session", + ), + ).toHaveLength(1); +}); + it("turns consent on, hands the credential over and waits for the pass its ticket names", async () => { // A stale failure from before the handoff must not answer. const host = native( diff --git a/src/features/identity/builderlab-account.test.ts b/src/features/identity/builderlab-account.test.ts index 4c1cbe228d..c4d7fd42e3 100644 --- a/src/features/identity/builderlab-account.test.ts +++ b/src/features/identity/builderlab-account.test.ts @@ -530,6 +530,17 @@ describe("bind", () => { expect(invoke).not.toHaveBeenCalled(); }); + it("keeps another key's binding and refuses before requesting or signing a challenge", async () => { + const { request, sent } = kgoose(bound("cd".repeat(32))); + await expect( + builderlabAccount(credential, request, untouched).bind(pubkey), + ).rejects.toThrow(); + expect(sent.map(({ url }) => url)).toEqual([ + "https://t.example/api/goose/v1/buzz/nostr-identities/current", + ]); + expect(invoke).not.toHaveBeenCalled(); + }); + it.each([ [ "a verify for an account bound to another key", diff --git a/src/features/identity/builderlab-account.ts b/src/features/identity/builderlab-account.ts index 85607fca3d..c80e79dabe 100644 --- a/src/features/identity/builderlab-account.ts +++ b/src/features/identity/builderlab-account.ts @@ -14,6 +14,7 @@ import { import { enteredRelayOrigin, relayAddress } from "../communities/destination"; import type { FederatedIdentity } from "../federated-identity/service"; import type { Host, HostResponse } from "../host/service"; +import type { BuilderlabSession } from "../../bundled/builderlab/session"; import { BackupError, backUpWith } from "./backup"; import type { BackupFailureKind } from "./backup-failure"; import type { FirstRunAccountSeed } from "./first-run-seed"; @@ -371,6 +372,22 @@ export function federatedBuilderlabAccount( ); } +/** Same account contract through the shared native session. */ +export function builderlabSessionAccount( + session: BuilderlabSession, + identity: BackupIdentity, + federated: string, +): FirstRunAccount { + const credential = session.credential(); + return kgooseAccount( + (path, headers, body) => session.request(credential, { + path, method: "POST", headers, body, + }), + { federated }, + identity, + ); +} + /** One JSON POST under the Builderlab API root; `path` starts with `/`. */ type Post = ( path: string, @@ -449,7 +466,9 @@ function kgooseAccount( linkedKey, async bind(pubkey) { // Re-running after a failure must not meet 409 `pubkey_already_bound`. - if ((await linkedKey()) === pubkey) return; + const bound = await linkedKey(); + if (bound === pubkey) return; + if (bound) throw new Error(OTHER_KEY); const step = "account link"; const fallback = "Could not link your key to this Builderlab account."; // The challenge refuses only with 403 `unauthorized` or `invalid_origin`, diff --git a/src/features/identity/federated-first-run.test.ts b/src/features/identity/federated-first-run.test.ts new file mode 100644 index 0000000000..7ac46e929e --- /dev/null +++ b/src/features/identity/federated-first-run.test.ts @@ -0,0 +1,129 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; +import { federatedFixture } from "../federated-identity/testing"; +import { createFederatedFirstRun } from "./federated-first-run"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), + isTauri: () => true, + Channel: class {}, +})); +afterEach(() => { + vi.resetAllMocks(); + vi.unstubAllEnvs(); +}); +const response = (body: unknown) => ({ + status: 200, + headers: {}, + body: JSON.stringify(body), +}); + +it.each([null, "ab".repeat(32)])( + "reads first-run account binding %s through the native grant without a token", + async (key) => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); + const h = federatedFixture(); + vi.mocked(invoke).mockImplementation(async (command, args) => { + if (command === "federated_identity_request") { + const path = + (args as { request?: { path?: string } } | undefined)?.request + ?.path ?? ""; + if (path.endsWith("auth/me")) + return response({ subject: "account", email: "a@example.com" }); + if (path.endsWith("nostr-identities/current")) + return response({ identity: key ? { pubkey_hex: key } : null }); + throw new Error(`Unexpected route ${path}`); + } + if ( + command === "federated_identity_register" || + command === "federated_identity_unregister" + ) + return; + throw new Error(`Unexpected native operation ${command}`); + }); + const session = createFederatedFirstRun(h.service); + try { + await session.ensureSignedIn(new AbortController().signal); + const reply = await session.request(session.credential(), { + path: "/v1/buzz/nostr-identities/current", + method: "POST", + headers: { Origin: "https://builderlab.example" }, + }); + expect(JSON.parse(reply.body).identity?.pubkey_hex ?? null).toBe(key); + const requests = vi + .mocked(invoke) + .mock.calls.filter( + ([command]) => command === "federated_identity_request", + ); + expect(requests).toHaveLength(2); + expect(requests[1]?.[1]).toMatchObject({ + id: "block.builderlab", + revision: "bundled", + session: "user:1", + request: { + path: "/api/goose/v1/buzz/nostr-identities/current", + headers: { Origin: "https://builderlab.example" }, + }, + }); + expect(JSON.stringify(requests)).not.toMatch( + /Authorization|X-BB-Session-Credential/, + ); + expect( + vi + .mocked(invoke) + .mock.calls.filter( + ([command]) => command === "federated_identity_unregister", + ), + ).toHaveLength(2); + expect(h.service.request).not.toHaveBeenCalled(); + } finally { + session.dispose(); + } + }, +); + +it("refuses first-run account setup without a bundled authority grant and releases the registration", async () => { + const h = federatedFixture(); + vi.mocked(invoke) + .mockResolvedValue(undefined) + .mockRejectedValueOnce( + new Error("Plugin has no authenticated authority grant"), + ); + const session = createFederatedFirstRun(h.service); + try { + await expect( + session.ensureSignedIn(new AbortController().signal), + ).rejects.toThrow("Could not load your Builderlab account"); + expect(h.service.signIn).toHaveBeenCalledOnce(); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "federated_identity_register", + "federated_identity_unregister", + ]); + } finally { + session.dispose(); + } +}); + +it("rejects account requests from a retired session before dispatch", async () => { + const h = federatedFixture(); + vi.mocked(invoke).mockImplementation(async (command) => + command === "federated_identity_request" + ? response({ subject: "account" }) + : undefined, + ); + const session = createFederatedFirstRun(h.service); + try { + await session.ensureSignedIn(new AbortController().signal); + const credential = session.credential(); + h.publish({ status: "signed-out", revision: "retired" }); + const before = vi.mocked(invoke).mock.calls.length; + await expect( + session.request(credential, { + path: "/v1/buzz/nostr-identities/challenge", + }), + ).rejects.toThrow(); + expect(invoke).toHaveBeenCalledTimes(before); + } finally { + session.dispose(); + } +}); diff --git a/src/features/identity/federated-first-run.ts b/src/features/identity/federated-first-run.ts new file mode 100644 index 0000000000..f9575fb129 --- /dev/null +++ b/src/features/identity/federated-first-run.ts @@ -0,0 +1,35 @@ +import { invoke } from "@tauri-apps/api/core"; +import manifest from "../../bundled/builderlab/manifest.json"; +import { createNativeSession } from "../../bundled/builderlab/session"; +import type { FederatedIdentity } from "../federated-identity/service"; + +/** Welcome uses the bundled plugin's existing grant before its UI is mounted. + * Native rechecks enabled artifact, authority, operation and session revision. + * Each bounded native request releases its registration when it settles. */ +export function createFederatedFirstRun(identity: FederatedIdentity) { + return createNativeSession({ + apiAvailable: identity.apiAvailable, + snapshot: identity.snapshot, + subscribe: identity.subscribe, + signIn: () => identity.signIn(), + cancelSignIn: () => identity.cancelSignIn(), + signOut: () => identity.signOut(), + async request(request) { + const session = identity.snapshot().revision; + const registration = crypto.randomUUID(); + const owner = { id: manifest.id, revision: "bundled", registration }; + try { + await invoke("federated_identity_register", owner); + return await invoke("federated_identity_request", { + ...owner, + session, + request, + }); + } finally { + await invoke("federated_identity_unregister", { registration }).catch( + () => {}, + ); + } + }, + }); +} diff --git a/src/features/identity/identity.test.tsx b/src/features/identity/identity.test.tsx index ebd2783710..e1fc8b6d82 100644 --- a/src/features/identity/identity.test.tsx +++ b/src/features/identity/identity.test.tsx @@ -78,6 +78,126 @@ const credential: Credential = { }, }; const welcomeHeading = () => screen.getByRole("heading", { name: "Buzz" }); + +it.each([null, owned.pubkey])( + "FI first launch checks account binding %s before creating or importing a key", + async (linked) => { + vi.stubEnv("VITE_BUZZ_FEDERATED_IDENTITY", "1"); + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); + const h = federatedFixture(); + vi.mocked(invoke).mockImplementation(async (command, args) => { + if (command === "identity_restore") return null; + if (command === "identity_legacy_available") return false; + if (command === "nsec_backup_state") + return { seq: 1, pass: 0, builderlab: false, status: "idle" }; + if ( + command === "nsec_backup_watch" || + command === "federated_identity_register" || + command === "federated_identity_unregister" + ) + return; + if (command === "federated_identity_request") { + const path = (args as { request?: { path?: string } } | undefined) + ?.request?.path; + const body = path?.endsWith("auth/me") + ? { subject: "account", email: "person@example.com" } + : path?.endsWith("nostr-identities/current") + ? { identity: linked ? { pubkey_hex: linked } : null } + : path?.endsWith("known-communities/list") + ? { communities: [] } + : undefined; + if (body) + return { status: 200, headers: {}, body: JSON.stringify(body) }; + } + throw new Error(`Unexpected native command ${command}`); + }); + const identity = createIdentity(); + const view = render( + +

App open

+
, + ); + try { + await userEvent + .setup() + .click(await screen.findByRole("button", { name: SIGN_IN })); + expect( + await screen.findByRole("heading", { + name: linked ? "Welcome back" : "Set up your key", + }), + ).toBeVisible(); + expect( + screen.getByText("Signed in as person@example.com."), + ).toBeVisible(); + if (linked) { + expect(screen.getByText(npubEncode(linked))).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Create my key" }), + ).toBeNull(); + } + expect( + vi + .mocked(invoke) + .mock.calls.some( + ([command]) => + command === "identity_create" || + command === "identity_import" || + command === "plugin_host_request", + ), + ).toBe(false); + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Use a different account" })); + expect(h.service.signOut).toHaveBeenCalledOnce(); + expect( + await screen.findByRole("button", { name: SIGN_IN }), + ).toBeVisible(); + } finally { + view.unmount(); + identity.dispose(); + } + }, +); + +it("FI Welcome leaves a saved Nostr identity in place and performs no first-run account requests", async () => { + vi.stubEnv("VITE_BUZZ_FEDERATED_IDENTITY", "1"); + vi.mocked(invoke).mockImplementation(async (command) => { + if (command === "identity_restore") return owned.pubkey; + throw new Error(`Unexpected native command ${command}`); + }); + const identity = createIdentity(); + const h = federatedFixture(); + h.publish({ status: "signed-in" }); + const view = render( + +

App open

+
, + ); + try { + expect(await screen.findByText("App open")).toBeVisible(); + expect(identity.snapshot()).toMatchObject({ + status: "ready", + viewer: owned.pubkey, + }); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + ]); + expect(h.service.signIn).not.toHaveBeenCalled(); + } finally { + view.unmount(); + identity.dispose(); + } +}); const connectButton = () => screen.getByRole("button", { name: SIGN_IN }); function titleBar() { const header = document.querySelector("header"); From cc9948d8f983c5d506dca59f3996c522eb36b254 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 01:16:33 -0500 Subject: [PATCH 2/9] BUZZ-518: Separate FI policy and compatibility Document the BUZZ-511 producer prerequisite and retain required policy when its descriptor is incompatible. Co-authored-by: Honor Signed-off-by: Brad Seiler --- docs/community-auth-recovery.md | 47 ++++++++++++++++++- .../communities/find-community.test.ts | 36 +++++++++++--- src/features/communities/find-community.ts | 43 ++++++++++------- 3 files changed, 102 insertions(+), 24 deletions(-) diff --git a/docs/community-auth-recovery.md b/docs/community-auth-recovery.md index 2410009eee..8796592436 100644 --- a/docs/community-auth-recovery.md +++ b/docs/community-auth-recovery.md @@ -4,7 +4,52 @@ BUZZ-518 adds guidance from the host-specific `requires_nip_fi` discovery field. It does not reinterpret `limitation.federated_identity`: that remains capability advertisement, and the native client retains its existing attachment and denial behavior when the requirement field is absent. Malformed requirement discovery -offers retry and administrator/build guidance rather than implying free access. +offers administrator/build guidance rather than implying free access. +An incompatible descriptor preserves `required=true` with compatibility reported +separately. A non-FI build or incompatible descriptor shows supported-build guidance +and does not offer a sign-in or join action it cannot complete. + +## Producer prerequisite and rollout + +[BUZZ-511](https://linear.app/squareup/issue/BUZZ-511) owns the discovery producer +and specification. Its local, unvalidated checkpoint is +`c7661f64302cad3208676deb6ed20fb43ea8208a`, pending the revised BUZZ-510 parent; +it is not a published prerequisite release. The proposed contract text is +`block/buzz/docs/community-auth-upgrades.md`, section **Request enforcement and +discovery (BUZZ-511)**. It specifies the same host-specific boolean on root NIP-11 +and `/info`, `no-store`, generic 404 for a non-serving host, and 503 on policy lookup +failure. `required=true` implies FI capability and a compatible descriptor, while +optional hosts may still advertise FI capability. This is a Buzz extension to +discovery, not a field already specified by NIP-FI itself. + +Pin the client fixtures to the final committed producer output after its handoff. +Until that happens, the fixtures express the proposed contract and do not prove +producer interoperability. This prerequisite and the live checks below block +readiness; BUZZ-510's operator receipt alone is not a discovery producer. + +1. Finalize and publish the BUZZ-511 producer/specification on the revised parent, + then pin and validate fixtures from that exact head. +2. Produce exact-host clients and verify discovery, signup/linking and recovery. + With the field absent, both shipped and new clients retain legacy discovery + and denial behavior. Shipped FI clients still use the existing capability + fields; new non-FI clients show upgrade/configuration guidance for required hosts. +3. Verify free controls and supported-client access before enabling a durable + requirement. Old non-FI clients still need the separate BUZZ-517 upgrade path. + Publishing the new field does not grant membership or change issuer trust. + +Observe success using the exact build/descriptor and host, the unchanged device +npub, successful admission to the original community, and renewal/reconnection +through two actual assertion lifetimes. Record timestamps and results, never +tokens or authentication headers. The procedure below is required live evidence; +CI and component tests are not substitutes. + +Client rollback is a revert or replacement with a verified compatible build. +It does not clear the relay's durable requirement, reverse an identity binding, +erase the device key, or reset saved backup consent or the native FI session. +Those remain in their existing stores and formats. Do not use a data wipe or +automatic unlink as rollback. Restoring access after a bad client rollout still +requires a client that can satisfy the active policy; reverting guidance alone +does not make a required community free. The first-run Welcome component accepts the existing native FI service and uses the bundled Builderlab authority grant for account checks and explicit linking. diff --git a/src/features/communities/find-community.test.ts b/src/features/communities/find-community.test.ts index 12633f5820..3fa36f19ca 100644 --- a/src/features/communities/find-community.test.ts +++ b/src/features/communities/find-community.test.ts @@ -65,12 +65,7 @@ it.each([true, false, undefined])( }, ); -it.each([ - { requires_nip_fi: "true" }, - { requires_nip_fi: null }, - { requires_nip_fi: true }, - { requires_nip_fi: true, limitation: { federated_identity: false } }, -])( +it.each([{ requires_nip_fi: "true" }, { requires_nip_fi: null }])( "gives actionable discovery failure for an invalid requirement %j", async (info) => { expect( @@ -82,6 +77,35 @@ it.each([ }, ); +it.each([ + {}, + { limitation: { federated_identity: false } }, + { + limitation: { federated_identity: true }, + federated_identity: { + core: "client-attached", + assertion_freshness: { + class: "offline-jwt", + maximum_residual_upstream_revocation_seconds: 60, + }, + }, + }, + { + limitation: { federated_identity: true }, + federated_identity: { core: "unknown" }, + }, +])( + "preserves required policy separately from incompatible descriptor %j", + async (descriptor) => { + const found = await find({ + "/": Response.json({ ...buzz, ...descriptor, requires_nip_fi: true }), + "/api/join-policy": html(404), + }); + expect(found.requiresNipFi).toBe(true); + expect(found.nipFiCompatible).toBe(false); + }, +); + it("reports only what the relay publishes, with the icon sanitized, from two unauthenticated reads", async () => { const request = fetcher({ "/": Response.json(buzz), diff --git a/src/features/communities/find-community.ts b/src/features/communities/find-community.ts index 5a737e923f..af41dcd112 100644 --- a/src/features/communities/find-community.ts +++ b/src/features/communities/find-community.ts @@ -14,6 +14,8 @@ export type FoundCommunity = { policy: JoinPolicy | null; /** Host-specific requirement, distinct from legacy FI capability. */ requiresNipFi?: boolean | undefined; + /** Whether this client's native FI contract understands a required descriptor. */ + nipFiCompatible?: boolean | undefined; }; export type FindFailure = @@ -68,6 +70,8 @@ export async function findCommunity( icon: communityIcon(info.icon), self: relaySelf(info), requiresNipFi: info.requires_nip_fi as boolean | undefined, + nipFiCompatible: + info.requires_nip_fi === true ? compatibleFi(info) : undefined, policy: await policyAnswer(await policy), }; } @@ -92,23 +96,11 @@ async function relayInfo(response: Response) { if (!body || typeof body !== "object" || Array.isArray(body)) throw new FindCommunityError("not-buzz"); const info = body as Record; - if (info.requires_nip_fi !== undefined) { - const descriptor = info.federated_identity as - | Record - | undefined; - const limitation = info.limitation as Record | undefined; - const freshness = descriptor?.assertion_freshness as - | Record - | undefined; - if ( - typeof info.requires_nip_fi !== "boolean" || - (info.requires_nip_fi && - (limitation?.federated_identity !== true || - descriptor?.core !== "client-attached" || - freshness?.class !== "offline-jwt")) - ) - throw new FindCommunityError("unsupported-auth"); - } + if ( + info.requires_nip_fi !== undefined && + typeof info.requires_nip_fi !== "boolean" + ) + throw new FindCommunityError("unsupported-auth"); // buzz-relay nip11.rs hard-codes both; either identifies the software. if ( info.name !== "Buzz Relay" && @@ -127,3 +119,20 @@ async function relayInfo(response: Response) { throw new FindCommunityError("no-community"); return info; } + +/** Compatibility never waives the host's explicit requirement or chooses an issuer. */ +function compatibleFi(info: Record) { + const descriptor = info.federated_identity as + | Record + | undefined; + const limitation = info.limitation as Record | undefined; + const freshness = descriptor?.assertion_freshness as + | Record + | undefined; + return ( + limitation?.federated_identity === true && + descriptor?.core === "client-attached" && + freshness?.class === "offline-jwt" && + freshness.maximum_residual_upstream_revocation_seconds == null + ); +} From 18a751fc3411ad2da51ce2f9f06fdb579aa6eb05 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 01:16:46 -0500 Subject: [PATCH 3/9] BUZZ-518: Gate guidance on FI build support Keep required hosts actionable without offering unsupported sign-in or join paths. Co-authored-by: Honor Signed-off-by: Brad Seiler --- src/features/communities/FindCommunity.tsx | 23 ++++++++++++++++------ src/features/identity/ConnectChoice.tsx | 18 +++++++++++++---- src/features/identity/Welcome.tsx | 12 ++++++++++- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/src/features/communities/FindCommunity.tsx b/src/features/communities/FindCommunity.tsx index 63dc84600d..df8630d6de 100644 --- a/src/features/communities/FindCommunity.tsx +++ b/src/features/communities/FindCommunity.tsx @@ -4,6 +4,7 @@ import { Avatar } from "../../shared/design-system/ui/Avatar"; import { Button } from "../../shared/design-system/ui/Button"; import { Checkbox } from "../../shared/design-system/ui/Checkbox"; import { LoadingRegion } from "../../shared/design-system/ui/LoadingRegion"; +import { nativeBuilderlab } from "../../bundled/builderlab/session"; import { FindCommunityError, findCommunity, @@ -28,7 +29,7 @@ const FAILURES: Record = { "unsupported-auth": { message: "This community requires sign-in that this Buzz build cannot confirm. Ask its administrator for a supported build and try again.", - retry: true, + retry: false, }, unreachable: { message: "Could not reach the relay. Check your connection and try again.", @@ -163,6 +164,8 @@ export function FindCommunity({ } const { found } = state; const name = communityName(found.name, found.host); + const unsupportedSignIn = + found.requiresNipFi && (!nativeBuilderlab() || !found.nipFiCompatible); const policy = found.policy; const needsAge = policy?.age_attestation_required === true; const needsAgreement = Boolean( @@ -182,11 +185,19 @@ export function FindCommunity({ return ( <> {heading("Join Community")} - {found.requiresNipFi && ( -

- This community requires account sign-in and a linked Buzz identity. - Your existing key will be kept. + {unsupportedSignIn ? ( +

+ This community requires account sign-in. Ask its administrator for a + supported Buzz build configured for this host and compatible community + settings. Your key is kept.

+ ) : ( + found.requiresNipFi && ( +

+ This community requires account sign-in and a linked Buzz identity. + Your existing key will be kept. +

+ ) )}
void join()} > Join diff --git a/src/features/identity/ConnectChoice.tsx b/src/features/identity/ConnectChoice.tsx index c0fe7ada2e..28076a3b30 100644 --- a/src/features/identity/ConnectChoice.tsx +++ b/src/features/identity/ConnectChoice.tsx @@ -1,6 +1,7 @@ import { Avatar } from "../../shared/design-system/ui/Avatar"; import { OnboardingBack } from "./OnboardingBack"; import { Button } from "../../shared/design-system/ui/Button"; +import { nativeBuilderlab } from "../../bundled/builderlab/session"; /** * Pause on a found community that this build's Builderlab does not host (one @@ -17,6 +18,7 @@ export function ConnectChoice({ onBack, requiresSignIn = false, signInOrigin, + nipFiCompatible = true, }: { /** The found community's host, as the find page shows it. */ host: string; @@ -28,7 +30,11 @@ export function ConnectChoice({ onBack: () => void; requiresSignIn?: boolean; signInOrigin?: string | undefined; + nipFiCompatible?: boolean; }) { + const unsupportedSignIn = + requiresSignIn && + (!nativeBuilderlab() || !signInOrigin || !nipFiCompatible); return ( <>
@@ -40,11 +46,13 @@ export function ConnectChoice({ Connect to your community

- {requiresSignIn - ? "This community requires account sign-in, including when you use an existing key. Buzz keeps that key." - : "Buzz relies on a key for your identity."} + {unsupportedSignIn + ? "This community requires account sign-in. Ask its administrator for a supported Buzz build configured for this host and compatible community settings. Your key is kept." + : requiresSignIn + ? "This community requires account sign-in, including when you use an existing key. Buzz keeps that key." + : "Buzz relies on a key for your identity."}

- {signInOrigin && ( + {signInOrigin && !unsupportedSignIn && (

Sign in at {signInOrigin}.

@@ -62,6 +70,7 @@ export function ConnectChoice({ {host}
+ {!unsupportedSignIn && (
+ )}
diff --git a/src/features/identity/Welcome.tsx b/src/features/identity/Welcome.tsx index 9e9d157840..dd2a08db45 100644 --- a/src/features/identity/Welcome.tsx +++ b/src/features/identity/Welcome.tsx @@ -182,6 +182,7 @@ export function Welcome({ // return to the landing or choices page. const [finding, setFinding] = useState(); const [requiresSignIn, setRequiresSignIn] = useState(false); + const [nipFiCompatible, setNipFiCompatible] = useState(true); // The code of the invite link `finding` came from, claimed on Join; "" for // a bare address. Every submit sets it with `finding`. const [inviteCode, setInviteCode] = useState(""); @@ -633,6 +634,7 @@ export function Welcome({ { setRequiresSignIn(found.requiresNipFi === true); - if (hostedByThisBuilderlab(found)) void signIn(); + setNipFiCompatible(found.nipFiCompatible !== false); + if ( + hostedByThisBuilderlab(found) && + (!found.requiresNipFi || + (nativeLogin && + federatedIdentity?.snapshot().authority && + found.nipFiCompatible)) + ) + void signIn(); else pause("connect-choice"); }} titleId="welcome-title" From 7acfd9f9c6addf33a14ab055939858c50139198f Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 01:16:57 -0500 Subject: [PATCH 4/9] BUZZ-518: Cover required-host onboarding Exercise required policy through mounted Welcome and FindCommunity, including unsupported builds and trusted origin display. Co-authored-by: Honor Signed-off-by: Brad Seiler --- .../communities/FindCommunity.test.tsx | 57 ++++++++- src/features/identity/identity.test.tsx | 116 ++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/src/features/communities/FindCommunity.test.tsx b/src/features/communities/FindCommunity.test.tsx index 06d60c69ba..08de356bb0 100644 --- a/src/features/communities/FindCommunity.test.tsx +++ b/src/features/communities/FindCommunity.test.tsx @@ -30,7 +30,62 @@ const policy = { privacy_markdown: "# Privacy", age_attestation_required: true, }; -afterEach(cleanup); +afterEach(() => { + cleanup(); + vi.unstubAllEnvs(); +}); + +it.each([ + [true, true, true], + [false, true, true], + [true, true, false], + [false, false, true], +])( + "renders required-host guidance for FI build=%s, required=%s, compatible=%s", + async (fi, required, compatible) => { + vi.stubEnv("VITE_BUZZ_FEDERATED_IDENTITY", fi ? "1" : "0"); + const request = relay(() => ({ + "/": Response.json({ + ...buzz, + requires_nip_fi: required, + limitation: { federated_identity: true }, + federated_identity: { + core: compatible ? "client-attached" : "unsupported", + assertion_freshness: { class: "offline-jwt" }, + identityOrigin: "https://untrusted.invalid", + }, + }), + "/api/join-policy": new Response(null, { status: 404 }), + })); + render( + , + ); + await screen.findByRole("heading", { name: "Join Community" }); + if (required && (!fi || !compatible)) { + expect(screen.getByRole("alert")).toHaveTextContent( + "supported Buzz build configured for this host", + ); + expect(screen.getByRole("button", { name: "Join" })).toBeDisabled(); + } else { + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.getByRole("button", { name: "Join" })).toBeEnabled(); + if (required) + expect( + screen.getByText(/account sign-in and a linked Buzz identity/), + ).toBeVisible(); + else + expect( + screen.queryByText(/account sign-in and a linked Buzz identity/), + ).toBeNull(); + } + expect(document.body).not.toHaveTextContent("untrusted.invalid"); + }, +); function deferred() { let resolve!: (value: T) => void; diff --git a/src/features/identity/identity.test.tsx b/src/features/identity/identity.test.tsx index e1fc8b6d82..08ea247c54 100644 --- a/src/features/identity/identity.test.tsx +++ b/src/features/identity/identity.test.tsx @@ -1209,6 +1209,7 @@ it("after Sign out, the next launch offers old Buzz's identity again while its f function landingRelay( origin = "https://relay.example.com:8443", created: Promise = Promise.resolve(viewer), + infoFields: Record = {}, ) { const relayCalls: unknown[] = []; const info = deferred(); @@ -1238,6 +1239,7 @@ function landingRelay( JSON.stringify({ name: "Buzz Relay", icon: "https://cdn.example.com/icon.png", + ...infoFields, }), ); }); @@ -1385,6 +1387,120 @@ async function connectChoicePage( expect(connectChoiceHost()).toBeVisible(); } +it.each([ + [true, true], + [false, true], + [true, false], +])( + "required Welcome discovery reaches ConnectChoice with FI build=%s and compatible=%s", + async (fi, compatible) => { + vi.stubEnv("VITE_BUZZ_FEDERATED_IDENTITY", fi ? "1" : "0"); + const relay = landingRelay(undefined, undefined, { + requires_nip_fi: true, + limitation: { federated_identity: true }, + federated_identity: { + core: compatible ? "client-attached" : "unsupported", + assertion_freshness: { class: "offline-jwt" }, + identityOrigin: "https://untrusted.invalid", + }, + }); + const h = federatedFixture(); + const identity = createIdentity(); + const connect = vi.fn(); + render( + +

App open

+
, + ); + try { + await connectChoicePage(userEvent.setup(), relay); + if (fi && compatible) { + expect( + screen.getByText( + /This community requires account sign-in, including when you use an existing key/, + ), + ).toBeVisible(); + expect( + screen.getByText("Sign in at https://builderlab.example."), + ).toBeVisible(); + expect(screen.getByRole("button", { name: SIGN_IN })).toBeEnabled(); + } else { + expect( + screen.getByText(/supported Buzz build configured for this host/), + ).toBeVisible(); + expect(screen.queryByRole("button", { name: SIGN_IN })).toBeNull(); + expect( + screen.queryByRole("button", { name: "I already have a key" }), + ).toBeNull(); + } + expect(document.body).not.toHaveTextContent("untrusted.invalid"); + expect(h.service.signIn).not.toHaveBeenCalled(); + expect(connect).not.toHaveBeenCalled(); + expect(identity.snapshot().status).toBe("missing"); + if (fi && compatible) { + vi.mocked(h.service.signIn).mockImplementation(async () => { + h.publish({ status: "signing-in" }); + return h.service.snapshot(); + }); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: SIGN_IN })); + expect(h.service.signIn).toHaveBeenCalledOnce(); + expect(connect).not.toHaveBeenCalled(); + await user.click( + screen.getByRole("button", { name: "Cancel sign-in" }), + ); + expect(h.service.cancelSignIn).toHaveBeenCalled(); + } + } finally { + identity.dispose(); + } + }, +); + +it("a required hosted community does not automatically start legacy OAuth in a non-FI build", async () => { + vi.stubEnv("VITE_BUZZ_FEDERATED_IDENTITY", "0"); + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://app.builderlab.xyz"); + const relay = landingRelay("https://team.communities.buzz.xyz", undefined, { + requires_nip_fi: true, + limitation: { federated_identity: true }, + federated_identity: { + core: "client-attached", + assertion_freshness: { class: "offline-jwt" }, + }, + }); + const identity = createIdentity(); + const connect = vi.fn(); + render( + +

App open

+
, + ); + try { + await screen.findByRole("button", { name: SIGN_IN }); + await userEvent + .setup() + .type(urlField(), "team.communities.buzz.xyz{Enter}"); + await act(async () => relay.info.resolve()); + expect( + await screen.findByText(/supported Buzz build configured for this host/), + ).toBeVisible(); + expect(connect).not.toHaveBeenCalled(); + } finally { + identity.dispose(); + } +}); + it("a found landing URL pauses on Connect to {host} with no sign-in and no key, and Back keeps the address", async () => { const relay = landingRelay(); const connect = vi.fn(); From ad8de9063882bc2a58a975d4d9eb78e3b63d1f4a Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 01:45:21 -0500 Subject: [PATCH 5/9] Preserve FI sessions during account recovery Join canceled replacements before exposing account setup, and finish account checks before permitting another attempt. Refresh same-account credentials at dispatch and retry retired registration once. Co-authored-by: Honor Signed-off-by: Brad Seiler --- crates/plugin-manager/src/lib.rs | 1 + docs/buzz-help.md | 6 +- docs/community-auth-recovery.md | 22 + .../CommunitySignIn.test.tsx | 60 ++- .../federated-identity/CommunitySignIn.tsx | 39 +- src/features/identity/Welcome.tsx | 230 ++++++++-- src/features/identity/backup.ts | 18 +- .../identity/builderlab-account.test.ts | 155 +++++++ src/features/identity/builderlab-account.ts | 1 - .../identity/federated-first-run.test.ts | 86 ++++ src/features/identity/federated-first-run.ts | 54 ++- src/features/identity/identity.test.tsx | 410 ++++++++++++++++-- 12 files changed, 979 insertions(+), 103 deletions(-) diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index 44dedfe74b..1569e45857 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -2302,6 +2302,7 @@ mod tests { .map(|op| format!("{} {}", op.method, op.path)) .collect(); assert!(operations.contains(&format!("GET {base}v1/auth/me"))); + assert!(operations.contains(&format!("POST {base}v1/buzz/nsec-backups/status"))); assert!(operations.contains(&format!("POST {base}v3/beekeeper/register-agent"))); assert!(operations.contains(&format!("POST {base}v1/buzz/nsec-backups/status"))); assert_eq!(operations.len(), super::BUILDERLAB_FI_ROUTES.len()); diff --git a/docs/buzz-help.md b/docs/buzz-help.md index b85fd23568..ac282cefe7 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -59,8 +59,10 @@ the interface unless an available tool actually supports that action. **Settings → Buzz → Identity → Organization sign-in**. - A community may require account sign-in even when you already have a key. Sign-in keeps that key. If the account is linked to another identity, review - the link or choose **Sign in with another account**; Buzz does not replace it - automatically. A build without authority configured for that exact community + the link or change accounts with your sign-in provider in your browser, then + choose **Sign in again**. Buzz cannot force a browser account chooser. Your + current sign-in remains active until replacement succeeds; a successful account + change applies to all your communities and agents. A build without authority configured for that exact community needs the administrator's supported build. Sign-in alone does not grant community membership or require joining a sponsor's billing workspace. - A community invite or address connects someone to a community; creating an diff --git a/docs/community-auth-recovery.md b/docs/community-auth-recovery.md index 8796592436..6439177a08 100644 --- a/docs/community-auth-recovery.md +++ b/docs/community-auth-recovery.md @@ -9,6 +9,25 @@ An incompatible descriptor preserves `required=true` with compatibility reported separately. A non-FI build or incompatible descriptor shows supported-build guidance and does not offer a sign-in or join action it cannot complete. +Account recovery uses native sign-in renewal, which keeps an existing valid +shared FI session until replacement succeeds. Cancel or browser failure does not +first sign the user out globally. A successful account change applies to all +communities and derived agent access. Welcome detects a browser returning the +same account instead of pretending that it switched accounts. + +The traced adapter contract has no account-selection parameter: +`EnterpriseAdapterLoginStartAction.start` accepts `return_to` and the handoff +challenge fields; `BbidentityAuthService.startLogin` builds the Auth0 URL without +an explicit chooser hint (cash-server main `51533fb7bd3d071f06f06484675f5d0d722609d2`). Changing the browser's provider +account may therefore be required before **Sign in again**. No unsupported hint +is added, and a real chooser/cancel run is still a readiness gate. + +First-run account requests obtain the latest credential for the same account +subject on each request and reject a different subject, including a response +arriving after an account change. Backup uses the latest public native revision. +Registration retirement permits one re-registration before dispatch; it does not +replay arbitrary failures or change the request's original session revision. + ## Producer prerequisite and rollout [BUZZ-511](https://linear.app/squareup/issue/BUZZ-511) owns the discovery producer @@ -96,6 +115,9 @@ and assertion lifetime without recording credentials or private keys. community, and confirm the npub is unchanged after restart. 4. Conflict: retain the conflicting mapping and device key. Review the mapping or explicitly select another account. Never silently unbind or regenerate. + Verify provider account selection and cancellation/failure with an existing + shared session. Cancel/failure must leave that session usable; a provider + returning the same account must not be reported as an account switch. 5. Keep a required-community connection active across at least two actual assertion lifetimes. Disconnect/reconnect and rediscover policy. Revoke the account, key, and agent separately; protected access must stop and recover only diff --git a/src/features/federated-identity/CommunitySignIn.test.tsx b/src/features/federated-identity/CommunitySignIn.test.tsx index b78f59e2d3..a78d387747 100644 --- a/src/features/federated-identity/CommunitySignIn.test.tsx +++ b/src/features/federated-identity/CommunitySignIn.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import { afterEach, expect, it } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { vi } from "vitest"; import { CommunitySignIn } from "./CommunitySignIn"; @@ -57,7 +57,7 @@ it.each(["binding-mismatch", "authorization-denied"])( ); if (failure === "binding-mismatch") expect( - screen.getByRole("button", { name: "Sign in with another account" }), + screen.getByRole("button", { name: "Sign in again" }), ).toBeEnabled(); else expect(screen.queryByRole("button")).not.toBeInTheDocument(); expect(h.service.signIn).not.toHaveBeenCalled(); @@ -65,7 +65,7 @@ it.each(["binding-mismatch", "authorization-denied"])( }, ); -it("changes account only on explicit recovery, then resumes the refused connection", async () => { +it("renews explicitly without global sign-out, then resumes the refused connection", async () => { const h = federatedFixture(); h.publish({ status: "signed-in" }); const retry = vi.fn(); @@ -79,10 +79,8 @@ it("changes account only on explicit recovery, then resumes the refused connecti expect(h.service.signOut).not.toHaveBeenCalled(); await userEvent .setup() - .click( - screen.getByRole("button", { name: "Sign in with another account" }), - ); - expect(h.service.signOut).toHaveBeenCalledOnce(); + .click(screen.getByRole("button", { name: "Sign in again" })); + expect(h.service.signOut).not.toHaveBeenCalled(); expect(h.service.signIn).toHaveBeenCalledOnce(); expect(retry).toHaveBeenCalledOnce(); }); @@ -128,3 +126,51 @@ it("keeps sign-in cancellable while the browser handoff is pending", async () => finish(); } }); + +it.each(["cancel", "failure"])( + "binding recovery retains the existing shared session on browser %s", + async (outcome) => { + const h = federatedFixture(); + h.publish({ status: "signed-in" }); + const before = h.service.snapshot(); + let finish!: () => void; + vi.mocked(h.service.signIn).mockImplementation(async () => { + await new Promise((resolve) => { + finish = resolve; + }); + if (outcome === "failure") throw new Error("storage-unavailable"); + return h.service.snapshot(); + }); + vi.mocked(h.service.cancelSignIn).mockResolvedValue(); + const retry = vi.fn(); + render( + , + ); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Sign in again" })); + try { + expect( + screen.getByText(/existing valid Buzz sign-in stays active/), + ).toBeVisible(); + expect(h.service.snapshot()).toBe(before); + if (outcome === "cancel") + await user.click( + screen.getByRole("button", { name: "Cancel sign-in" }), + ); + } finally { + await act(async () => finish()); + } + await waitFor(() => + expect( + screen.getByRole("button", { name: "Sign in again" }), + ).toBeEnabled(), + ); + expect(h.service.snapshot()).toBe(before); + expect(h.service.signOut).not.toHaveBeenCalled(); + expect(retry).not.toHaveBeenCalled(); + }, +); diff --git a/src/features/federated-identity/CommunitySignIn.tsx b/src/features/federated-identity/CommunitySignIn.tsx index 7167279adb..53bf567dab 100644 --- a/src/features/federated-identity/CommunitySignIn.tsx +++ b/src/features/federated-identity/CommunitySignIn.tsx @@ -53,21 +53,26 @@ function Action({ identity.snapshot, ); const [failure, setFailure] = useState(""); - const [switching, setSwitching] = useState(false); + const [signingIn, setSigningIn] = useState(false); const refusal = error || state.failure || ""; - const signIn = async (differentAccount = false) => { + const pending = signingIn || state.status === "signing-in"; + const signIn = async () => { + const before = identity.snapshot(); setFailure(""); - setSwitching(differentAccount); + setSigningIn(true); try { - if (differentAccount) await identity.signOut(); const next = await identity.signIn(); - if (next.status === "signed-in") retry(); + if ( + next.status === "signed-in" && + (before.status !== "signed-in" || next.revision !== before.revision) + ) + retry(); } catch (error) { setFailure( error instanceof Error ? error.message : "Sign-in unavailable", ); } finally { - setSwitching(false); + setSigningIn(false); } }; if (/not configured for this community/.test(refusal)) @@ -94,20 +99,24 @@ function Action({ Builderlab hosting to review the existing identity link. Keep this key until you have reviewed the link.

+

+ To use another account, change accounts with your sign-in provider in + your browser, then sign in again. Buzz cannot force an account + chooser. An existing valid Buzz sign-in stays active until a + replacement succeeds. A successful account change applies to all your + communities and agents. +

{state.authority && ( )} {failure && ( @@ -144,11 +153,9 @@ function Action({ return ( <> {(failure || state.failure) && (

diff --git a/src/features/identity/Welcome.tsx b/src/features/identity/Welcome.tsx index dd2a08db45..228e4aadda 100644 --- a/src/features/identity/Welcome.tsx +++ b/src/features/identity/Welcome.tsx @@ -140,10 +140,31 @@ export function Welcome({ const [legacyLogin] = useState(() => createOAuthSession(connect)); const [nativeLogin, setNativeLogin] = useState>(); + const [accountChange, setAccountChange] = useState< + | { status: "pending" | "canceling" } + | { status: "failed"; error: string; reload: boolean } + | { status: "cancel-failed" } + >(); + const accountRequest = useRef< + | { + revision: string; + canceling: boolean; + settled: Promise; + } + | undefined + >(undefined); const login = nativeLogin ?? legacyLogin; - // Counts canceled sign-ins: a sign-in continues only if none was canceled - // since it started, since the session may by then hold a later sign-in. + // Fences canceled or replaced sign-ins and their account-loading continuations. const cancels = useRef(0); + useEffect( + () => () => { + const pending = accountRequest.current; + accountRequest.current = undefined; + cancels.current++; + if (pending) void federatedIdentity?.cancelSignIn().catch(() => {}); + }, + [federatedIdentity], + ); const auth = useSyncExternalStore(login.subscribe, login.snapshot); const [setup] = useState(createSetup); const progress = useSyncExternalStore(setup.subscribe, setup.snapshot); @@ -234,8 +255,9 @@ export function Welcome({ * the user's is shown — the gate lifted, or the key still being restored — * so launch never moves focus. */ - const page = - state.status === "loading" + const page = accountChange + ? "account-change" + : state.status === "loading" ? undefined : state.status === "error" ? "error" @@ -308,31 +330,74 @@ export function Welcome({ * pauses on Welcome back, otherwise the key choice. The backed-up-key * lookup runs on both. Each choice below resumes setup with its own steps. */ - const signIn = async () => { - const attempt = cancels.current; - // A canceled attempt still settles here, possibly after a later sign-in - // succeeded; it must not read that one's credential. - if (nativeLogin) { - // Native sign-in settles before its account read. Wait for the account, - // including restore already in flight, before choosing a key lane. - await nativeLogin - .ensureSignedIn(new AbortController().signal) - .catch(() => {}); - } else await login.signIn(); - if (attempt !== cancels.current || login.snapshot().status !== "signed-in") - return; - const current = - nativeLogin && federatedIdentity - ? builderlabSessionAccount( - nativeLogin, - identity, - federatedIdentity.snapshot().revision, - ) + /** Once native replaces a session, no previous account's setup may be exposed. */ + const discardAccountSetup = () => { + reads.current = undefined; + known.current = undefined; + setLinked(undefined); + setBackups(undefined); + setOwned(undefined); + setup.reset(); + }; + const signIn = async (changeAccount = false) => { + let settle = () => {}; + const settled = new Promise((resolve) => { + settle = resolve; + }); + const request = + nativeLogin && federatedIdentity && (changeAccount || accountChange) + ? { + revision: federatedIdentity.snapshot().revision, + canceling: false, + settled, + } + : undefined; + const attempt = request ? ++cancels.current : cancels.current; + const reload = accountChange?.status === "failed" && accountChange.reload; + if (request) { + accountRequest.current = request; + setAccountChange({ status: "pending" }); + } + const currentAttempt = () => + mounted.current && + attempt === cancels.current && + (!request || accountRequest.current === request); + let previousSubject: string | undefined; + try { + if (changeAccount && nativeLogin && federatedIdentity && request) { + try { + previousSubject = nativeLogin.credential().account.subject; + } catch { + /* Expired session. */ + } + const next = await federatedIdentity.signIn(); + if (!currentAttempt()) return; + if (next.revision !== request.revision) discardAccountSetup(); + if (next.status !== "signed-in") + throw new Error("Sign-in did not finish"); + if (next.revision === request.revision) { + accountRequest.current = undefined; + setAccountChange(undefined); + return; + } + } + // Native exchange completes before its account read. Join that read, + // including a same-session retry; no second browser step is needed. + if (nativeLogin) + await nativeLogin.ensureSignedIn(new AbortController().signal); + else await login.signIn(); + if (!currentAttempt() || login.snapshot().status !== "signed-in") return; + const current = nativeLogin + ? builderlabSessionAccount(nativeLogin, identity) : account(legacyLogin.credential(), identity); + const sameAccount = + changeAccount && + nativeLogin?.credential().account.subject === previousSubject; + discardAccountSetup(); reads.current = current; let checkedKey: string | undefined; let checkedBackups: readonly string[] | undefined; - void setup.start( + await setup.start( [ checkAccount( current, @@ -359,6 +424,67 @@ export function Welcome({ ], { status: "paused", at: "key-choice" }, ); + + if (!currentAttempt()) return; + if (request) { + accountRequest.current = undefined; + setAccountChange( + sameAccount + ? { + status: "failed", + reload: false, + error: + "The browser returned the same account. Change accounts with your sign-in provider in the browser, then try again.", + } + : undefined, + ); + } + } catch { + if (!currentAttempt()) return; + if (request && federatedIdentity) { + const replaced = + federatedIdentity.snapshot().revision !== request.revision; + if (replaced) discardAccountSetup(); + accountRequest.current = undefined; + setAccountChange({ + status: "failed", + reload: !!reload || replaced, + error: + "Couldn’t finish account sign-in. Your key is kept. Try again.", + }); + } + // The ordinary login's own snapshot supplies its classified failure. + } finally { + settle(); + } + }; + const cancelAccountChange = async () => { + const request = accountRequest.current; + if (!request || request.canceling || !federatedIdentity) return; + request.canceling = true; + cancels.current++; + setAccountChange({ status: "canceling" }); + try { + await federatedIdentity.cancelSignIn(); + // Native can cross its irreversible commit point before publishing the + // replacement. Join this attempt before deciding the old setup is safe. + await request.settled; + if (!mounted.current || accountRequest.current !== request) return; + accountRequest.current = undefined; + if (federatedIdentity.snapshot().revision !== request.revision) { + discardAccountSetup(); + setAccountChange({ + status: "failed", + reload: true, + error: + "Sign-in completed or changed before cancellation. Load the current account to continue. Your key is kept.", + }); + } else setAccountChange(undefined); + } catch { + if (!mounted.current || accountRequest.current !== request) return; + request.canceling = false; + setAccountChange({ status: "cancel-failed" }); + } }; /** Between pauses nothing runs: no key exists until a choice resumes setup. */ const pause = (at: SetupPause) => @@ -708,10 +834,9 @@ export function Welcome({ ]); }} onImport={() => pause("key-import")} - onSwitchAccount={() => { - nativeLogin?.signOut(); - backToLanding(); - }} + onSwitchAccount={ + nativeLogin ? () => void signIn(true) : backToLanding + } /> ); case "welcome-back": @@ -822,6 +947,52 @@ export function Welcome({ {null} ); + if (accountChange) + return ( + + {accountChange.status === "failed" ? ( + setAccountChange(undefined) + } + onRetry={() => void signIn(!accountChange.reload)} + /> + ) : accountChange.status === "cancel-failed" ? ( + void cancelAccountChange()} + /> + ) : ( + <> +

+ Finish sign-in in your browser +

+

+ Buzz cannot force an account chooser. Your browser may reuse its + signed-in account; change accounts with your sign-in provider + there. An existing valid Buzz sign-in stays active until + replacement succeeds. A successful change applies to all your + communities and agents. Your key is kept. +

+ + + )} + + ); // Discovery: the same unauthenticated lookup Join Community runs after // setup, so a bad address fails here, before sign-in and before a key // exists. A community hosted by this build's Builderlab signs in at once; @@ -1007,6 +1178,7 @@ export function Welcome({ title="Couldn’t sign in" error={auth.error} onBack={backToLanding} + onRetry={nativeLogin ? () => void signIn() : undefined} /> ) : finding ? ( (command: string, args?: Record) => Promise; +type BackupCredential = string | Readonly<{ credential: string } | { federated: string }>; // Shared by both login owners so re-enabling a plugin cannot reuse an old revision. let generation = 0; @@ -165,7 +166,7 @@ function failure(status: BackupStatus): BackupFailureKind | undefined { export async function backUpWith( identity: Pick, pubkey: string, - session: Readonly<{ credential: string } | { federated: string }>, + credential: BackupCredential | (() => BackupCredential), { timeout = 60_000, after = timer, @@ -187,10 +188,15 @@ export async function backUpWith( const settled = new AbortController(); let unsubscribe = () => {}; let completed = false; + let supplied: BackupCredential | undefined; try { - const ticket = await ("federated" in session - ? handoffFederatedBackupSession(session.federated, send) - : handoffBackupSession("builderlab", session.credential, send)); + // Native revisions can change while readiness and consent are awaited. + supplied = typeof credential === "function" ? credential() : credential; + const ticket = await (typeof supplied === "string" + ? handoffBackupSession("builderlab", supplied, send) + : "federated" in supplied + ? handoffFederatedBackupSession(supplied.federated, send) + : handoffBackupSession("builderlab", supplied.credential, send)); if (ticket === null) throw new BackupError("unavailable"); const outcome = new Promise((resolve) => { const check = () => { @@ -213,8 +219,8 @@ export async function backUpWith( settled.abort(); // After a timeout this also retires the pass still running. Native // refuses an FI revision that is no longer current. - await (completed && "federated" in session - ? handoffFederatedBackupSession(session.federated, send) + await (completed && supplied && typeof supplied !== "string" && "federated" in supplied + ? handoffFederatedBackupSession(supplied.federated, send) : handoffBackupSession("builderlab", null, send)); } } diff --git a/src/features/identity/builderlab-account.test.ts b/src/features/identity/builderlab-account.test.ts index c4d7fd42e3..c839a79400 100644 --- a/src/features/identity/builderlab-account.test.ts +++ b/src/features/identity/builderlab-account.test.ts @@ -1,6 +1,8 @@ import { invoke } from "@tauri-apps/api/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Credential } from "../../bundled/builderlab/oauth/browser"; +import { createNativeSession } from "../../bundled/builderlab/session"; +import { federatedFixture } from "../federated-identity/testing"; import type { HostRequest } from "../host/service"; import { BackupError, type BackupStatus, createBackupStatus } from "./backup"; import { BACKUP_FAILURES } from "./backup-failure"; @@ -9,6 +11,7 @@ import { backupOfferedFor, builderlabAccount, federatedBuilderlabAccount, + builderlabSessionAccount, seededAccount, } from "./builderlab-account"; @@ -95,6 +98,71 @@ afterEach(() => { vi.mocked(invoke).mockReset(); }); +async function nativeAccountSession() { + const h = federatedFixture(); + let subject = "original"; + h.request.mockImplementation(async (request) => + answer( + 200, + request.path.endsWith("auth/me") + ? { subject, email: `${subject}@example.com` } + : { identity: null }, + ), + ); + const session = createNativeSession(h.service); + await session.ensureSignedIn(new AbortController().signal); + return { + h, + session, + async replace(nextSubject: string, revision: string) { + subject = nextSubject; + h.publish({ status: "signed-in", revision }); + await vi.waitFor(() => + expect(session.snapshot().status).toBe("signed-in"), + ); + }, + }; +} + +it("first-run requests follow same-account renewal but refuse a different account", async () => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); + const native = await nativeAccountSession(); + try { + const account = builderlabSessionAccount(native.session, untouched); + expect(await account.linkedKey()).toBeNull(); + await native.replace("original", "renewed-session"); + expect(await account.linkedKey()).toBeNull(); + await native.replace("different", "other-session"); + const before = native.h.request.mock.calls.length; + await expect(account.linkedKey()).rejects.toMatchObject({ + name: "AbortError", + }); + expect(native.h.request).toHaveBeenCalledTimes(before); + } finally { + native.session.dispose(); + } +}); + +it("refuses a first-run response after the account has been retired", async () => { + const native = await nativeAccountSession(); + try { + const account = builderlabSessionAccount(native.session, untouched); + let finish!: (response: ReturnType) => void; + native.h.request.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const result = account.linkedKey(); + native.h.publish({ status: "signed-out", revision: "retired" }); + finish(answer(200, { identity: { pubkey_hex: pubkey } })); + await expect(result).rejects.toThrow(); + } finally { + native.session.dispose(); + } +}); + describe("linkedKey", () => { it("POSTs the card's identity route at the build URL with the session credential and reads the bound key", async () => { const { request, sent } = kgoose( @@ -1072,6 +1140,93 @@ describe("backup", () => { return { identity, send, setBackupChoice, watchers }; } + it("uses the renewed native revision for first-run backup without clearing the shared slot", async () => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); + const nativeSession = await nativeAccountSession(); + const native = host(status(1, 0)); + vi.mocked(invoke).mockResolvedValue(1); + try { + const account = builderlabSessionAccount( + nativeSession.session, + native.identity, + ); + await nativeSession.replace("original", "renewed-native-revision"); + const done = account.backUp(pubkey); + await vi.waitFor(() => + expect(invoke).toHaveBeenCalledWith( + "nsec_backup_session", + expect.objectContaining({ + credential: null, + federated: "renewed-native-revision", + }), + ), + ); + native.watchers[0]?.onmessage(status(2, 1, true, "complete")); + await done; + expect(invoke).toHaveBeenCalledTimes(1); + } finally { + nativeSession.session.dispose(); + } + }); + + it.each(["status", "consent"])( + "resolves backup credentials after the pending %s preparation", + async (phase) => { + const owner = await nativeAccountSession(); + const native = host(status(1, 0)); + let started!: () => void, release!: () => void; + const ready = new Promise((resolve) => { + started = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + const original = native.send.getMockImplementation(); + if (!original) throw new Error("Missing backup host"); + if (phase === "status") + native.send.mockImplementation(async (command, args) => { + if (command === "nsec_backup_state") { + started(); + await gate; + } + return original(command, args); + }); + else + native.setBackupChoice.mockImplementation(async () => { + started(); + await gate; + return { available: true, pubkey, automatic: true } as never; + }); + vi.mocked(invoke).mockResolvedValue(1); + try { + const account = builderlabSessionAccount( + owner.session, + native.identity, + ); + const done = account.backUp(pubkey); + void done.catch(() => {}); + await ready; + await owner.replace("original", "renewed-during-preparation"); + release(); + await vi.waitFor(() => + expect(invoke).toHaveBeenCalledWith( + "nsec_backup_session", + expect.objectContaining({ + federated: "renewed-during-preparation", + credential: null, + }), + ), + ); + native.watchers[0]?.onmessage(status(2, 1, true, "complete")); + await done; + } finally { + release(); + native.watchers[0]?.onmessage(status(3, 1, true, "complete")); + owner.session.dispose(); + } + }, + ); + it("hands the session credential to the native uploader and waits for its pass", async () => { vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example/"); const native = host(status(1, 0)); diff --git a/src/features/identity/builderlab-account.ts b/src/features/identity/builderlab-account.ts index c80e79dabe..4882a3bca5 100644 --- a/src/features/identity/builderlab-account.ts +++ b/src/features/identity/builderlab-account.ts @@ -376,7 +376,6 @@ export function federatedBuilderlabAccount( export function builderlabSessionAccount( session: BuilderlabSession, identity: BackupIdentity, - federated: string, ): FirstRunAccount { const credential = session.credential(); return kgooseAccount( diff --git a/src/features/identity/federated-first-run.test.ts b/src/features/identity/federated-first-run.test.ts index 7ac46e929e..9613ba8fb6 100644 --- a/src/features/identity/federated-first-run.test.ts +++ b/src/features/identity/federated-first-run.test.ts @@ -127,3 +127,89 @@ it("rejects account requests from a retired session before dispatch", async () = session.dispose(); } }); + +it.each(["once", "always", "network"])( + "handles registration failure %s without unbounded or arbitrary replay", + async (mode) => { + const h = federatedFixture(); + let attempts = 0; + const revisions: unknown[] = []; + const registrations: unknown[] = []; + vi.mocked(invoke).mockImplementation(async (command, args) => { + if (command === "federated_identity_register") + registrations.push( + (args as Record | undefined)?.registration, + ); + if (command !== "federated_identity_request") return; + const input = args as { session: string; request: { path: string } }; + if (input.request.path.endsWith("auth/me")) + return response({ subject: "account" }); + revisions.push(input.session); + attempts++; + if (mode === "network") return Promise.reject("Network unavailable"); + if (mode === "always" || attempts === 1) + return Promise.reject("Plugin registration retired"); + return response({ identity: null }); + }); + const session = createFederatedFirstRun(h.service); + try { + await session.ensureSignedIn(new AbortController().signal); + const request = session.request(session.credential(), { + path: "/v1/buzz/nostr-identities/current", + }); + if (mode === "once") + await expect(request).resolves.toMatchObject({ status: 200 }); + else await expect(request).rejects.toThrow(); + expect(attempts).toBe(mode === "network" ? 1 : 2); + expect(new Set(registrations).size).toBe(registrations.length); + expect(revisions).toEqual(Array(attempts).fill("user:1")); + expect( + vi + .mocked(invoke) + .mock.calls.filter( + ([command]) => command === "federated_identity_unregister", + ), + ).toHaveLength(registrations.length); + } finally { + session.dispose(); + } + }, +); + +it("does not dispatch after disposal while native registration is pending", async () => { + const h = federatedFixture(); + let hold = false; + let registered!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + registered = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + vi.mocked(invoke).mockImplementation(async (command) => { + if (command === "federated_identity_register" && hold) { + registered(); + await gate; + } + if (command === "federated_identity_request") + return response({ subject: "account" }); + }); + const session = createFederatedFirstRun(h.service); + await session.ensureSignedIn(new AbortController().signal); + hold = true; + const request = session.request(session.credential(), { + path: "/v1/buzz/nostr-identities/challenge", + }); + await started; + session.dispose(); + release(); + await expect(request).rejects.toMatchObject({ name: "AbortError" }); + expect( + vi + .mocked(invoke) + .mock.calls.filter( + ([command]) => command === "federated_identity_request", + ), + ).toHaveLength(1); +}); diff --git a/src/features/identity/federated-first-run.ts b/src/features/identity/federated-first-run.ts index f9575fb129..33b84acd6b 100644 --- a/src/features/identity/federated-first-run.ts +++ b/src/features/identity/federated-first-run.ts @@ -2,12 +2,14 @@ import { invoke } from "@tauri-apps/api/core"; import manifest from "../../bundled/builderlab/manifest.json"; import { createNativeSession } from "../../bundled/builderlab/session"; import type { FederatedIdentity } from "../federated-identity/service"; +import type { HostResponse } from "../host/service"; /** Welcome uses the bundled plugin's existing grant before its UI is mounted. * Native rechecks enabled artifact, authority, operation and session revision. * Each bounded native request releases its registration when it settles. */ export function createFederatedFirstRun(identity: FederatedIdentity) { - return createNativeSession({ + let active = true; + const session = createNativeSession({ apiAvailable: identity.apiAvailable, snapshot: identity.snapshot, subscribe: identity.subscribe, @@ -16,20 +18,44 @@ export function createFederatedFirstRun(identity: FederatedIdentity) { signOut: () => identity.signOut(), async request(request) { const session = identity.snapshot().revision; - const registration = crypto.randomUUID(); - const owner = { id: manifest.id, revision: "bundled", registration }; - try { - await invoke("federated_identity_register", owner); - return await invoke("federated_identity_request", { - ...owner, - session, - request, - }); - } finally { - await invoke("federated_identity_unregister", { registration }).catch( - () => {}, - ); + for (let attempt = 0; ; attempt++) { + if (!active) throw new DOMException("First run closed", "AbortError"); + const registration = crypto.randomUUID(); + const owner = { id: manifest.id, revision: "bundled", registration }; + let response: HostResponse; + try { + await invoke("federated_identity_register", owner); + if (!active) throw new DOMException("First run closed", "AbortError"); + response = await invoke("federated_identity_request", { + ...owner, + session, + request, + }); + } catch (error) { + // Native emits this only before forwarding a request. Re-register once, + // retaining the original session revision; never replay other failures. + if ( + active && + error === "Plugin registration retired" && + attempt === 0 + ) + continue; + throw typeof error === "string" ? new Error(error) : error; + } finally { + await invoke("federated_identity_unregister", { registration }).catch( + () => {}, + ); + } + if (!active) throw new DOMException("First run closed", "AbortError"); + return response; } }, }); + return { + ...session, + dispose() { + active = false; + session.dispose(); + }, + }; } diff --git a/src/features/identity/identity.test.tsx b/src/features/identity/identity.test.tsx index 08ea247c54..6ee30b5536 100644 --- a/src/features/identity/identity.test.tsx +++ b/src/features/identity/identity.test.tsx @@ -79,38 +79,48 @@ const credential: Credential = { }; const welcomeHeading = () => screen.getByRole("heading", { name: "Buzz" }); +function nativeFirstRunReplies( + account: () => { subject: string; email: string }, + linked: () => string | null, +) { + vi.mocked(invoke).mockImplementation(async (command, args) => { + if (command === "identity_restore") return null; + if (command === "identity_legacy_available") return false; + if (command === "nsec_backup_state") + return { seq: 1, pass: 0, builderlab: false, status: "idle" }; + if ( + command === "nsec_backup_watch" || + command === "federated_identity_register" || + command === "federated_identity_unregister" + ) + return; + if (command === "federated_identity_request") { + const path = (args as { request?: { path?: string } } | undefined) + ?.request?.path; + const key = linked(); + const body = path?.endsWith("auth/me") + ? account() + : path?.endsWith("nostr-identities/current") + ? { identity: key ? { pubkey_hex: key } : null } + : path?.endsWith("known-communities/list") + ? { communities: [] } + : undefined; + if (body) return { status: 200, headers: {}, body: JSON.stringify(body) }; + } + throw new Error(`Unexpected native command ${command}`); + }); +} + it.each([null, owned.pubkey])( "FI first launch checks account binding %s before creating or importing a key", async (linked) => { vi.stubEnv("VITE_BUZZ_FEDERATED_IDENTITY", "1"); vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); const h = federatedFixture(); - vi.mocked(invoke).mockImplementation(async (command, args) => { - if (command === "identity_restore") return null; - if (command === "identity_legacy_available") return false; - if (command === "nsec_backup_state") - return { seq: 1, pass: 0, builderlab: false, status: "idle" }; - if ( - command === "nsec_backup_watch" || - command === "federated_identity_register" || - command === "federated_identity_unregister" - ) - return; - if (command === "federated_identity_request") { - const path = (args as { request?: { path?: string } } | undefined) - ?.request?.path; - const body = path?.endsWith("auth/me") - ? { subject: "account", email: "person@example.com" } - : path?.endsWith("nostr-identities/current") - ? { identity: linked ? { pubkey_hex: linked } : null } - : path?.endsWith("known-communities/list") - ? { communities: [] } - : undefined; - if (body) - return { status: 200, headers: {}, body: JSON.stringify(body) }; - } - throw new Error(`Unexpected native command ${command}`); - }); + nativeFirstRunReplies( + () => ({ subject: "account", email: "person@example.com" }), + () => linked, + ); const identity = createIdentity(); const view = render( { + vi.stubEnv("VITE_BUZZ_FEDERATED_IDENTITY", "1"); + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); + const h = federatedFixture(); + let subject = "original"; + nativeFirstRunReplies( + () => ({ subject, email: `${subject}@example.com` }), + () => (subject === "original" ? owned.pubkey : null), + ); + const identity = createIdentity(); + const view = render( + +

App open

+
, + ); + const user = userEvent.setup(); + try { + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + await screen.findByRole("heading", { name: "Welcome back" }); + const before = h.service.snapshot(); + const gate = deferred(); + vi.mocked(h.service.cancelSignIn).mockResolvedValue(); + vi.mocked(h.service.signIn).mockImplementation(async () => { + await gate.promise; + if (outcome === "failure") throw new Error("Browser unavailable"); + if (outcome !== "cancel") { + if (outcome === "different-account") subject = "replacement"; + h.publish({ status: "signed-in", revision: "replacement-session" }); + } + return h.service.snapshot(); + }); + await user.click( + screen.getByRole("button", { name: "Use a different account" }), + ); + expect( + screen.getByRole("heading", { name: "Finish sign-in in your browser" }), + ).toBeVisible(); + expect(h.service.snapshot()).toBe(before); + expect(h.service.signOut).not.toHaveBeenCalled(); + if (outcome === "cancel") + await user.click( + screen.getByRole("button", { name: "Cancel sign-in" }), + ); + await act(async () => gate.resolve()); + if (outcome === "different-account") { + expect( + await screen.findByRole("heading", { name: "Set up your key" }), + ).toBeVisible(); + expect( + screen.getByText("Signed in as replacement@example.com."), + ).toBeVisible(); + } else if (outcome === "cancel") { + expect( + await screen.findByRole("heading", { name: "Welcome back" }), + ).toBeVisible(); + expect(h.service.snapshot()).toBe(before); + } else { + expect( + await screen.findByRole("heading", { + name: "Couldn’t change account", + }), + ).toBeVisible(); + if (outcome === "same-account") + expect( + screen.getByText(/browser returned the same account/), + ).toBeVisible(); + else expect(h.service.snapshot()).toBe(before); + await user.click(screen.getByRole("button", { name: "Back" })); + expect( + screen.getByRole("heading", { name: "Welcome back" }), + ).toBeVisible(); + } + expect(h.service.signOut).not.toHaveBeenCalled(); + expect(identity.snapshot().status).toBe("missing"); + expect( + vi + .mocked(invoke) + .mock.calls.some( + ([command]) => + command === "identity_create" || command === "identity_import", + ), + ).toBe(false); + } finally { + view.unmount(); + identity.dispose(); + } + }, +); + +it.each(["account-read-failure", "cancel-after-commit", "commit-after-cancel"])( + "FI replacement %s cannot expose the old account's key lane", + async (outcome) => { + vi.stubEnv("VITE_BUZZ_FEDERATED_IDENTITY", "1"); + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); + const h = federatedFixture(); + let subject = "original"; + let refuse = false; + nativeFirstRunReplies( + () => { + if (refuse) throw new Error("Account read unavailable"); + return { subject, email: `${subject}@example.com` }; + }, + () => (subject === "original" ? owned.pubkey : null), + ); + const identity = createIdentity(); + const view = render( + +

App open

+
, + ); + const user = userEvent.setup(); + const exchange = deferred(); + try { + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + await screen.findByRole("heading", { name: "Welcome back" }); + vi.mocked(h.service.cancelSignIn).mockResolvedValue(); + vi.mocked(h.service.signIn).mockImplementation(async () => { + if (outcome === "commit-after-cancel") await exchange.promise; + subject = "replacement"; + refuse = true; + h.publish({ status: "signed-in", revision: "committed-replacement" }); + if (outcome === "cancel-after-commit") await exchange.promise; + return h.service.snapshot(); + }); + await user.click( + screen.getByRole("button", { name: "Use a different account" }), + ); + if (outcome !== "account-read-failure") { + await user.click( + screen.getByRole("button", { name: "Cancel sign-in" }), + ); + if (outcome === "commit-after-cancel") { + await waitFor(() => + expect(h.service.cancelSignIn).toHaveBeenCalledOnce(), + ); + expect( + screen.getByRole("button", { name: "Cancel sign-in" }), + ).toBeDisabled(); + expect( + screen.queryByRole("heading", { name: "Welcome back" }), + ).toBeNull(); + } + await act(async () => exchange.resolve()); + } + expect( + await screen.findByRole("heading", { name: "Couldn’t change account" }), + ).toBeVisible(); + expect(screen.queryByRole("button", { name: "Back" })).toBeNull(); + expect( + screen.queryByRole("button", { name: "Enter your private key" }), + ).toBeNull(); + expect(screen.queryByText(npubEncode(owned.pubkey))).toBeNull(); + refuse = false; + await user.click(screen.getByRole("button", { name: "Try again" })); + expect( + await screen.findByRole("heading", { name: "Set up your key" }), + ).toBeVisible(); + expect( + screen.getByText("Signed in as replacement@example.com."), + ).toBeVisible(); + expect(h.service.signIn).toHaveBeenCalledTimes(2); + expect(h.service.signOut).not.toHaveBeenCalled(); + expect(identity.snapshot().status).toBe("missing"); + } finally { + exchange.resolve(); + view.unmount(); + identity.dispose(); + } + }, +); + +it("allows one cancellation per attempt and joins it before allowing another attempt", async () => { + vi.stubEnv("VITE_BUZZ_FEDERATED_IDENTITY", "1"); + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); + const h = federatedFixture(); + nativeFirstRunReplies( + () => ({ subject: "original", email: "original@example.com" }), + () => owned.pubkey, + ); + const identity = createIdentity(); + const view = render( + +

App open

+
, + ); + const user = userEvent.setup(); + const first = deferred(), + second = deferred(), + cancelFirst = deferred(), + cancelSecond = deferred(); + try { + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + await screen.findByRole("heading", { name: "Welcome back" }); + vi.mocked(h.service.signIn) + .mockImplementationOnce(async () => { + await first.promise; + return h.service.snapshot(); + }) + .mockImplementationOnce(async () => { + await second.promise; + return h.service.snapshot(); + }); + vi.mocked(h.service.cancelSignIn) + .mockImplementationOnce(() => cancelFirst.promise) + .mockImplementationOnce(() => cancelSecond.promise); + await user.click( + screen.getByRole("button", { name: "Use a different account" }), + ); + const cancel = screen.getByRole("button", { name: "Cancel sign-in" }); + await user.click(cancel); + expect(cancel).toBeDisabled(); + await user.click(cancel); + expect(h.service.cancelSignIn).toHaveBeenCalledOnce(); + await act(async () => cancelFirst.resolve()); + expect( + screen.getByRole("button", { name: "Cancel sign-in" }), + ).toBeDisabled(); + await act(async () => first.resolve()); + await screen.findByRole("heading", { name: "Welcome back" }); + await user.click( + screen.getByRole("button", { name: "Use a different account" }), + ); + expect( + screen.getByRole("heading", { name: "Finish sign-in in your browser" }), + ).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Cancel sign-in" })); + expect(h.service.cancelSignIn).toHaveBeenCalledTimes(2); + await act(async () => cancelSecond.resolve()); + await act(async () => second.resolve()); + expect(screen.getByRole("heading", { name: "Welcome back" })).toBeVisible(); + expect(h.service.signOut).not.toHaveBeenCalled(); + } finally { + first.resolve(); + second.resolve(); + cancelFirst.resolve(); + cancelSecond.resolve(); + view.unmount(); + identity.dispose(); + } +}); + +it("finishes same-account checks before offering another replacement attempt", async () => { + vi.stubEnv("VITE_BUZZ_FEDERATED_IDENTITY", "1"); + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); + const h = federatedFixture(); + let subject = "original"; + nativeFirstRunReplies( + () => ({ subject, email: `${subject}@example.com` }), + () => (subject === "original" ? owned.pubkey : null), + ); + const reply = vi.mocked(invoke).getMockImplementation(); + if (!reply) throw new Error("Missing native reply fixture"); + const read = deferred(); + let gated = false; + vi.mocked(invoke).mockImplementation(async (command, args) => { + const path = (args as { request?: { path?: string } } | undefined)?.request + ?.path; + if (gated && path?.endsWith("nostr-identities/current")) await read.promise; + return reply(command, args); + }); + const identity = createIdentity(); + const view = render( + +

App open

+
, + ); + const user = userEvent.setup(); + try { + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + await screen.findByRole("heading", { name: "Welcome back" }); + vi.mocked(h.service.signIn).mockImplementation(async () => { + h.publish({ status: "signed-in", revision: `${subject}-renewed` }); + return h.service.snapshot(); + }); + gated = true; + await user.click( + screen.getByRole("button", { name: "Use a different account" }), + ); + await waitFor(() => + expect( + vi + .mocked(invoke) + .mock.calls.filter( + ([command, args]) => + command === "federated_identity_request" && + (args as { request: { path: string } }).request.path.endsWith( + "nostr-identities/current", + ), + ), + ).toHaveLength(2), + ); + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); + expect( + screen.getByRole("heading", { name: "Finish sign-in in your browser" }), + ).toBeVisible(); + await act(async () => read.resolve()); + await screen.findByText(/browser returned the same account/); + subject = "replacement"; + gated = false; + await user.click(screen.getByRole("button", { name: "Try again" })); + expect( + await screen.findByRole("heading", { name: "Set up your key" }), + ).toBeVisible(); + expect( + screen.getByText("Signed in as replacement@example.com."), + ).toBeVisible(); + expect(h.service.signOut).not.toHaveBeenCalled(); + } finally { + read.resolve(); + view.unmount(); + identity.dispose(); + } +}); From 360abc7fa334a4ad05b7e542f42a17abbc04c2f3 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 02:32:28 -0500 Subject: [PATCH 6/9] BUZZ-518: Pin discovery to BUZZ-511 output Share the exact required-host field projection from producer d3ae4935b across parser and mounted onboarding fixtures. Link the published draft and normative spec while preserving rollout and live acceptance gates. Co-authored-by: Honor Signed-off-by: Brad Seiler --- docs/community-auth-recovery.md | 41 +++++++++++-------- .../communities/FindCommunity.test.tsx | 5 ++- src/features/communities/discovery-fixture.ts | 20 +++++++++ .../communities/find-community.test.ts | 17 +++++--- src/features/identity/identity.test.tsx | 13 ++---- 5 files changed, 63 insertions(+), 33 deletions(-) create mode 100644 src/features/communities/discovery-fixture.ts diff --git a/docs/community-auth-recovery.md b/docs/community-auth-recovery.md index 6439177a08..2224542ed9 100644 --- a/docs/community-auth-recovery.md +++ b/docs/community-auth-recovery.md @@ -31,23 +31,30 @@ replay arbitrary failures or change the request's original session revision. ## Producer prerequisite and rollout [BUZZ-511](https://linear.app/squareup/issue/BUZZ-511) owns the discovery producer -and specification. Its local, unvalidated checkpoint is -`c7661f64302cad3208676deb6ed20fb43ea8208a`, pending the revised BUZZ-510 parent; -it is not a published prerequisite release. The proposed contract text is -`block/buzz/docs/community-auth-upgrades.md`, section **Request enforcement and -discovery (BUZZ-511)**. It specifies the same host-specific boolean on root NIP-11 -and `/info`, `no-store`, generic 404 for a non-serving host, and 503 on policy lookup -failure. `required=true` implies FI capability and a compatible descriptor, while -optional hosts may still advertise FI capability. This is a Buzz extension to -discovery, not a field already specified by NIP-FI itself. - -Pin the client fixtures to the final committed producer output after its handoff. -Until that happens, the fixtures express the proposed contract and do not prove -producer interoperability. This prerequisite and the live checks below block -readiness; BUZZ-510's operator receipt alone is not a discovery producer. - -1. Finalize and publish the BUZZ-511 producer/specification on the revised parent, - then pin and validate fixtures from that exact head. +and specification, published as draft [block/buzz#8988](https://github.com/block/buzz/pull/8988). +The consumer fixture `src/features/communities/discovery-fixture.ts` pins selected +emitted fields to producer head `d3ae4935b11e207f0736833cd5220f207be359fd`: + +- [Normative Discovery contract](https://github.com/block/buzz/blob/d3ae4935b11e207f0736833cd5220f207be359fd/docs/nips/NIP-FI.md#L891). +- [Executable host/mode matrix](https://github.com/block/buzz/blob/d3ae4935b11e207f0736833cd5220f207be359fd/crates/buzz-relay/src/nip_fi_policy/tests.rs#L247). +- [Producer response](https://github.com/block/buzz/blob/d3ae4935b11e207f0736833cd5220f207be359fd/crates/buzz-relay/src/nip11.rs#L400). + +The top-level boolean is stored requirement OR global Enforce. Capability remains +mode.evaluates OR effective requirement: a free Shadow host advertises capability +without requiring FI; a free Off host omits FI capability. Required output includes +`core: "client-attached"`, freshness class `offline-jwt`, and a null residual upstream +revocation bound. Neither capability nor policy grants issuer/account-origin trust. +Root NIP-11 and `/info` agree and return `no-store`; unknown/non-serving hosts return +404 and unavailable policy returns 503. Client policy remains separate from descriptor +compatibility, including unsupported descriptors in negative fixtures. + +This pins a published draft, not a deployed producer. Producer review remains +pending; update the pin if its contract changes. Production baseline verification +and activation/live-fleet gates remain unresolved. +Static fixtures do not establish live interoperability or completed activation. + +1. Finalize and release the BUZZ-511 producer/specification on the revised BUZZ-510 + parent; revalidate the pinned fixture after any producer revision. 2. Produce exact-host clients and verify discovery, signup/linking and recovery. With the field absent, both shipped and new clients retain legacy discovery and denial behavior. Shipped FI clients still use the existing capability diff --git a/src/features/communities/FindCommunity.test.tsx b/src/features/communities/FindCommunity.test.tsx index 08de356bb0..d3c84a3626 100644 --- a/src/features/communities/FindCommunity.test.tsx +++ b/src/features/communities/FindCommunity.test.tsx @@ -1,3 +1,4 @@ +import { requiredFiDiscovery } from "./discovery-fixture"; // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import { @@ -47,11 +48,11 @@ it.each([ const request = relay(() => ({ "/": Response.json({ ...buzz, + ...requiredFiDiscovery, requires_nip_fi: required, - limitation: { federated_identity: true }, federated_identity: { + ...requiredFiDiscovery.federated_identity, core: compatible ? "client-attached" : "unsupported", - assertion_freshness: { class: "offline-jwt" }, identityOrigin: "https://untrusted.invalid", }, }), diff --git a/src/features/communities/discovery-fixture.ts b/src/features/communities/discovery-fixture.ts new file mode 100644 index 0000000000..e1dd083493 --- /dev/null +++ b/src/features/communities/discovery-fixture.ts @@ -0,0 +1,20 @@ +/** Selected discovery fields emitted for a required host by BUZZ-511. + * Producer: https://github.com/block/buzz/pull/8988 + * Pinned head: d3ae4935b11e207f0736833cd5220f207be359fd + * Spec: docs/nips/NIP-FI.md, Discovery (line 891). + * Source: crates/buzz-relay/src/nip11.rs, nip11_document/RelayInfo::build. + * Producer test: nip_fi_policy/tests.rs, + * discovery_is_host_specific_compatible_uncached_and_consistent. + * This projection omits unrelated NIP-11 fields and conveys no issuer trust. + */ +export const requiredFiDiscovery = { + requires_nip_fi: true, + limitation: { federated_identity: true }, + federated_identity: { + core: "client-attached", + assertion_freshness: { + class: "offline-jwt", + maximum_residual_upstream_revocation_seconds: null, + }, + }, +} as const; diff --git a/src/features/communities/find-community.test.ts b/src/features/communities/find-community.test.ts index 3fa36f19ca..b60e534a0a 100644 --- a/src/features/communities/find-community.test.ts +++ b/src/features/communities/find-community.test.ts @@ -1,5 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { expect, it, vi } from "vitest"; +import { requiredFiDiscovery } from "./discovery-fixture"; import { FindCommunityError, findCommunity, @@ -52,19 +53,25 @@ it.each([true, false, undefined])( const found = await find({ "/": Response.json({ ...buzz, + ...requiredFiDiscovery, requires_nip_fi: required, - limitation: { federated_identity: true }, - federated_identity: { - core: "client-attached", - assertion_freshness: { class: "offline-jwt" }, - }, }), "/api/join-policy": html(404), }); expect(found.requiresNipFi).toBe(required); + expect(found.nipFiCompatible).toBe(required ? true : undefined); }, ); +it("accepts a free Off host without capability or a descriptor", async () => { + const found = await find({ + "/": Response.json({ ...buzz, requires_nip_fi: false }), + "/api/join-policy": html(404), + }); + expect(found.requiresNipFi).toBe(false); + expect(found.nipFiCompatible).toBeUndefined(); +}); + it.each([{ requires_nip_fi: "true" }, { requires_nip_fi: null }])( "gives actionable discovery failure for an invalid requirement %j", async (info) => { diff --git a/src/features/identity/identity.test.tsx b/src/features/identity/identity.test.tsx index 6ee30b5536..fd8476db9a 100644 --- a/src/features/identity/identity.test.tsx +++ b/src/features/identity/identity.test.tsx @@ -1,3 +1,4 @@ +import { requiredFiDiscovery } from "../communities/discovery-fixture"; // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import { @@ -1414,11 +1415,10 @@ it.each([ async (fi, compatible) => { vi.stubEnv("VITE_BUZZ_FEDERATED_IDENTITY", fi ? "1" : "0"); const relay = landingRelay(undefined, undefined, { - requires_nip_fi: true, - limitation: { federated_identity: true }, + ...requiredFiDiscovery, federated_identity: { + ...requiredFiDiscovery.federated_identity, core: compatible ? "client-attached" : "unsupported", - assertion_freshness: { class: "offline-jwt" }, identityOrigin: "https://untrusted.invalid", }, }); @@ -1485,12 +1485,7 @@ it("a required hosted community does not automatically start legacy OAuth in a n vi.stubEnv("VITE_BUZZ_FEDERATED_IDENTITY", "0"); vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://app.builderlab.xyz"); const relay = landingRelay("https://team.communities.buzz.xyz", undefined, { - requires_nip_fi: true, - limitation: { federated_identity: true }, - federated_identity: { - core: "client-attached", - assertion_freshness: { class: "offline-jwt" }, - }, + ...requiredFiDiscovery, }); const identity = createIdentity(); const connect = vi.fn(); From a34f582ac28cfd860853609ed58fbf63b4beb909 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 3 Nov 2026 03:30:51 -0500 Subject: [PATCH 7/9] BUZZ-518: Refresh producer provenance Pin unchanged discovery fields to the reviewed readiness repair and document recoverable required-host dependency failures. Co-authored-by: Honor Signed-off-by: Brad Seiler --- docs/community-auth-recovery.md | 22 +++++++++++-------- src/features/communities/discovery-fixture.ts | 2 +- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/community-auth-recovery.md b/docs/community-auth-recovery.md index 2224542ed9..9eff1b00c6 100644 --- a/docs/community-auth-recovery.md +++ b/docs/community-auth-recovery.md @@ -33,11 +33,11 @@ replay arbitrary failures or change the request's original session revision. [BUZZ-511](https://linear.app/squareup/issue/BUZZ-511) owns the discovery producer and specification, published as draft [block/buzz#8988](https://github.com/block/buzz/pull/8988). The consumer fixture `src/features/communities/discovery-fixture.ts` pins selected -emitted fields to producer head `d3ae4935b11e207f0736833cd5220f207be359fd`: +emitted fields to producer head `82c64ce008171855606511c1340cddaa949196fe`: -- [Normative Discovery contract](https://github.com/block/buzz/blob/d3ae4935b11e207f0736833cd5220f207be359fd/docs/nips/NIP-FI.md#L891). -- [Executable host/mode matrix](https://github.com/block/buzz/blob/d3ae4935b11e207f0736833cd5220f207be359fd/crates/buzz-relay/src/nip_fi_policy/tests.rs#L247). -- [Producer response](https://github.com/block/buzz/blob/d3ae4935b11e207f0736833cd5220f207be359fd/crates/buzz-relay/src/nip11.rs#L400). +- [Normative Discovery contract](https://github.com/block/buzz/blob/82c64ce008171855606511c1340cddaa949196fe/docs/nips/NIP-FI.md#L891). +- [Executable host/mode matrix](https://github.com/block/buzz/blob/82c64ce008171855606511c1340cddaa949196fe/crates/buzz-relay/src/nip_fi_policy/tests.rs#L247). +- [Producer response](https://github.com/block/buzz/blob/82c64ce008171855606511c1340cddaa949196fe/crates/buzz-relay/src/nip11.rs#L400). The top-level boolean is stored requirement OR global Enforce. Capability remains mode.evaluates OR effective requirement: a free Shadow host advertises capability @@ -45,11 +45,15 @@ without requiring FI; a free Off host omits FI capability. Required output inclu `core: "client-attached"`, freshness class `offline-jwt`, and a null residual upstream revocation bound. Neither capability nor policy grants issuer/account-origin trust. Root NIP-11 and `/info` agree and return `no-store`; unknown/non-serving hosts return -404 and unavailable policy returns 503. Client policy remains separate from descriptor -compatibility, including unsupported descriptors in negative fixtures. - -This pins a published draft, not a deployed producer. Producer review remains -pending; update the pin if its contract changes. Production baseline verification +404. For a required host, unavailable policy or missing verifier, deny map or +finite lifetime returns 503. +Buzz treats 503 as a recoverable discovery failure, never as optional/free access. +Client policy remains separate from descriptor compatibility, including +unsupported descriptors in negative fixtures. + +This pins a published draft, not a deployed producer. Its exact-head source recheck +resolved the prior findings with no new blockers; hosted CI and human acceptance +remain gates. Update the pin if its contract changes. Production baseline verification and activation/live-fleet gates remain unresolved. Static fixtures do not establish live interoperability or completed activation. diff --git a/src/features/communities/discovery-fixture.ts b/src/features/communities/discovery-fixture.ts index e1dd083493..5457bf4166 100644 --- a/src/features/communities/discovery-fixture.ts +++ b/src/features/communities/discovery-fixture.ts @@ -1,6 +1,6 @@ /** Selected discovery fields emitted for a required host by BUZZ-511. * Producer: https://github.com/block/buzz/pull/8988 - * Pinned head: d3ae4935b11e207f0736833cd5220f207be359fd + * Pinned head: 82c64ce008171855606511c1340cddaa949196fe * Spec: docs/nips/NIP-FI.md, Discovery (line 891). * Source: crates/buzz-relay/src/nip11.rs, nip11_document/RelayInfo::build. * Producer test: nip_fi_policy/tests.rs, From bbd3534352f80c35dd8f19f5474f1d56004a3f6d Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 4 Nov 2026 12:19:26 -0500 Subject: [PATCH 8/9] BUZZ-518: Reconcile first run with current main Reuse the bundled FI owner while retaining renewal and account-change fences. Preserve main signup, installed-key handling and backup failure cleanup. Co-authored-by: Honor Signed-off-by: Brad Seiler --- docs/community-auth-recovery.md | 11 +- .../builderlab/oauth/first-run.test.ts | 11 + src/bundled/builderlab/oauth/first-run.ts | 30 ++- .../communities/FindCommunity.test.tsx | 2 +- src/features/identity/ConnectChoice.tsx | 18 +- src/features/identity/Welcome.tsx | 166 +++++++------- src/features/identity/backup.test.ts | 2 +- src/features/identity/backup.ts | 16 +- .../identity/builderlab-account.test.ts | 47 ++-- src/features/identity/builderlab-account.ts | 45 ++-- .../identity/federated-first-run.test.ts | 215 ------------------ src/features/identity/federated-first-run.ts | 61 ----- src/features/identity/identity.test.tsx | 168 +++++++++++--- 13 files changed, 333 insertions(+), 459 deletions(-) delete mode 100644 src/features/identity/federated-first-run.test.ts delete mode 100644 src/features/identity/federated-first-run.ts diff --git a/docs/community-auth-recovery.md b/docs/community-auth-recovery.md index 9eff1b00c6..7577b19b90 100644 --- a/docs/community-auth-recovery.md +++ b/docs/community-auth-recovery.md @@ -25,7 +25,7 @@ is added, and a real chooser/cancel run is still a readiness gate. First-run account requests obtain the latest credential for the same account subject on each request and reject a different subject, including a response arriving after an account change. Backup uses the latest public native revision. -Registration retirement permits one re-registration before dispatch; it does not +The existing FI service owns registration; retirement permits one re-registration before dispatch; it does not replay arbitrary failures or change the request's original session revision. ## Producer prerequisite and rollout @@ -88,10 +88,11 @@ native; requests carry only the public session revision. Native validation still checks the enabled artifact, authority, operation and current session. A conflicting binding stops before a challenge is requested; no automatic unlink is added. -**Draft integration gate:** `App.tsx` must pass -`federatedIdentity={services.federatedIdentity}` to `Welcome`. That FOUNDATION edit -needs explicit human guidance under `AGENTS.md`; until supplied, the app's first-run -FI path is not wired. Component tests alone do not establish packaged support. +**FOUNDATION boundary:** current main already supplies Welcome with the bundled +`services.firstRunFederatedIdentity` owner. This PR preserves that composition +and makes no `App.tsx` changes. Any further FOUNDATION wiring change still needs +explicit human guidance under `AGENTS.md`. Component tests alone do not establish +packaged support. ## Configuration and artifacts diff --git a/src/bundled/builderlab/oauth/first-run.test.ts b/src/bundled/builderlab/oauth/first-run.test.ts index 4e75fff89e..afac1875db 100644 --- a/src/bundled/builderlab/oauth/first-run.test.ts +++ b/src/bundled/builderlab/oauth/first-run.test.ts @@ -222,6 +222,17 @@ describe("an FI build", () => { }), "Could not load your Builderlab account. Try again.", ], + [ + "the session changes before the sign-in answer is consumed", + (fi: ReturnType) => + vi.mocked(fi.service.signIn).mockImplementationOnce(async () => { + fi.publish({ status: "signed-in", revision: "returned" }); + const returned = fi.service.snapshot(); + fi.publish({ revision: "concurrent-replacement" }); + return returned; + }), + "Could not load your Builderlab account. Try again.", + ], [ "the session changes during the account read", (fi: ReturnType) => diff --git a/src/bundled/builderlab/oauth/first-run.ts b/src/bundled/builderlab/oauth/first-run.ts index ec37e2c2dd..ae8c0e5b0e 100644 --- a/src/bundled/builderlab/oauth/first-run.ts +++ b/src/bundled/builderlab/oauth/first-run.ts @@ -140,11 +140,7 @@ async function federatedCredential( throw new Error(recoveryMessage("sign-in-canceled")); if (session.status !== "signed-in") throw new Error(recoveryMessage(session.failure ?? "")); - const account = await federatedAccount(identity).catch(() => undefined); - signal.throwIfAborted(); - if (!account || identity.snapshot().revision !== session.revision) - throw new Error("Could not load your Builderlab account. Try again."); - return Object.freeze({ value: session.revision, account }); + return await readFederatedCredential(identity, signal, session.revision); } catch (error) { if (signal.aborted) throw new Error("Sign-in canceled."); throw error instanceof Error @@ -155,3 +151,27 @@ async function federatedCredential( signal.removeEventListener("abort", cancel); } } + +/** Read the current native account without starting another browser sign-in. */ +export async function readFederatedCredential( + identity: Pick, + signal?: AbortSignal, + expectedRevision?: string, +): Promise { + signal?.throwIfAborted(); + const session = identity.snapshot(); + if (session.status !== "signed-in") + throw new Error("Sign in to Builderlab first."); + if (expectedRevision !== undefined && session.revision !== expectedRevision) + throw new Error("Could not load your Builderlab account. Try again."); + const account = await federatedAccount(identity).catch(() => undefined); + signal?.throwIfAborted(); + const after = identity.snapshot(); + if ( + !account || + after.status !== "signed-in" || + after.revision !== session.revision + ) + throw new Error("Could not load your Builderlab account. Try again."); + return Object.freeze({ value: session.revision, account }); +} diff --git a/src/features/communities/FindCommunity.test.tsx b/src/features/communities/FindCommunity.test.tsx index d3c84a3626..a6cc399f05 100644 --- a/src/features/communities/FindCommunity.test.tsx +++ b/src/features/communities/FindCommunity.test.tsx @@ -1,5 +1,5 @@ -import { requiredFiDiscovery } from "./discovery-fixture"; // @vitest-environment jsdom +import { requiredFiDiscovery } from "./discovery-fixture"; import "@testing-library/jest-dom/vitest"; import { act, diff --git a/src/features/identity/ConnectChoice.tsx b/src/features/identity/ConnectChoice.tsx index 28076a3b30..9d4a132eb8 100644 --- a/src/features/identity/ConnectChoice.tsx +++ b/src/features/identity/ConnectChoice.tsx @@ -50,7 +50,7 @@ export function ConnectChoice({ ? "This community requires account sign-in. Ask its administrator for a supported Buzz build configured for this host and compatible community settings. Your key is kept." : requiresSignIn ? "This community requires account sign-in, including when you use an existing key. Buzz keeps that key." - : "Buzz relies on a key for your identity."} + : "Sign in to your Builderlab account with email, or use an existing private key to continue."}

{signInOrigin && !unsupportedSignIn && (

@@ -71,14 +71,14 @@ export function ConnectChoice({

{!unsupportedSignIn && ( -
- - -
+
+ + +
)}
diff --git a/src/features/identity/Welcome.tsx b/src/features/identity/Welcome.tsx index 228e4aadda..b0996dc1ac 100644 --- a/src/features/identity/Welcome.tsx +++ b/src/features/identity/Welcome.tsx @@ -14,12 +14,11 @@ import type { Credential } from "../../bundled/builderlab/oauth/browser"; import { bundledHost, firstRunCredential, + readFederatedCredential, type FirstRunIdentity, } from "../../bundled/builderlab/oauth/first-run"; import { createOAuthSession } from "../../bundled/builderlab/oauth/session"; import { nativeBuilderlab } from "../../bundled/builderlab/session"; -import type { FederatedIdentity } from "../federated-identity/service"; -import { createFederatedFirstRun } from "./federated-first-run"; import { communityDestination } from "../communities/destination"; import { FindCommunity, type JoinConsent } from "../communities/FindCommunity"; import type { FoundCommunity } from "../communities/find-community"; @@ -40,7 +39,6 @@ import { hostedByThisBuilderlab } from "./builderlab-hosted"; import { builderlabAccount, federatedBuilderlabAccount, - builderlabSessionAccount, type FirstRunAccount, type OwnedCommunity, seededAccount, @@ -137,9 +135,8 @@ export function Welcome({ | undefined; }) { const state = useSyncExternalStore(identity.subscribe, identity.snapshot); - const [legacyLogin] = useState(() => createOAuthSession(connect)); - const [nativeLogin, setNativeLogin] = - useState>(); + const [login] = useState(() => createOAuthSession(connect)); + const [setupCredential, setSetupCredential] = useState(); const [accountChange, setAccountChange] = useState< | { status: "pending" | "canceling" } | { status: "failed"; error: string; reload: boolean } @@ -153,7 +150,6 @@ export function Welcome({ } | undefined >(undefined); - const login = nativeLogin ?? legacyLogin; // Fences canceled or replaced sign-ins and their account-loading continuations. const cancels = useRef(0); useEffect( @@ -168,16 +164,6 @@ export function Welcome({ const auth = useSyncExternalStore(login.subscribe, login.snapshot); const [setup] = useState(createSetup); const progress = useSyncExternalStore(setup.subscribe, setup.snapshot); - const firstRun = state.status === "missing" || progress.status !== "idle"; - useEffect(() => { - if (!firstRun || !federatedIdentity || !nativeBuilderlab()) return; - const session = createFederatedFirstRun(federatedIdentity); - setNativeLogin(session); - return () => { - if (session.snapshot().status === "pending") session.cancel(); - session.dispose(); - }; - }, [federatedIdentity, firstRun]); // The signed-in account's reads, from sign-in until the landing is shown again. const reads = useRef(undefined); // The hex key the account check found bound to the account: the returning- @@ -333,6 +319,7 @@ export function Welcome({ /** Once native replaces a session, no previous account's setup may be exposed. */ const discardAccountSetup = () => { reads.current = undefined; + setSetupCredential(undefined); known.current = undefined; setLinked(undefined); setBackups(undefined); @@ -345,7 +332,7 @@ export function Welcome({ settle = resolve; }); const request = - nativeLogin && federatedIdentity && (changeAccount || accountChange) + federatedIdentity && (changeAccount || accountChange) ? { revision: federatedIdentity.snapshot().revision, canceling: false, @@ -362,68 +349,72 @@ export function Welcome({ mounted.current && attempt === cancels.current && (!request || accountRequest.current === request); - let previousSubject: string | undefined; + const previousSubject = setupCredential?.account.subject; try { - if (changeAccount && nativeLogin && federatedIdentity && request) { - try { - previousSubject = nativeLogin.credential().account.subject; - } catch { - /* Expired session. */ + let credential: Credential; + if (request && federatedIdentity) { + let replacementRevision: string | undefined; + if (changeAccount) { + const next = await federatedIdentity.signIn(); + replacementRevision = next.revision; + if (!currentAttempt()) return; + if (next.status !== "signed-in") + throw new Error("Sign-in did not finish"); + if (next.revision === request.revision) { + accountRequest.current = undefined; + setAccountChange(undefined); + return; + } } - const next = await federatedIdentity.signIn(); - if (!currentAttempt()) return; - if (next.revision !== request.revision) discardAccountSetup(); - if (next.status !== "signed-in") - throw new Error("Sign-in did not finish"); - if (next.revision === request.revision) { - accountRequest.current = undefined; - setAccountChange(undefined); + // A committed replacement whose account read failed is reloaded without + // another browser step. The old setup stays hidden until this settles. + credential = await readFederatedCredential( + federatedIdentity, + undefined, + replacementRevision, + ); + } else { + await login.signIn(); + if (!currentAttempt() || login.snapshot().status !== "signed-in") return; - } + credential = login.credential(); } - // Native exchange completes before its account read. Join that read, - // including a same-session retry; no second browser step is needed. - if (nativeLogin) - await nativeLogin.ensureSignedIn(new AbortController().signal); - else await login.signIn(); - if (!currentAttempt() || login.snapshot().status !== "signed-in") return; - const current = nativeLogin - ? builderlabSessionAccount(nativeLogin, identity) - : account(legacyLogin.credential(), identity); + if (!currentAttempt()) return; + const current = account(credential, identity); const sameAccount = - changeAccount && - nativeLogin?.credential().account.subject === previousSubject; + changeAccount && credential.account.subject === previousSubject; discardAccountSetup(); - reads.current = current; - let checkedKey: string | undefined; - let checkedBackups: readonly string[] | undefined; - await setup.start( - [ - checkAccount( - current, - (key) => { - checkedKey = key; - setLinked(key); - }, - (list) => { - known.current = list; - }, - ), - checkBackup(current, (keys) => { - checkedBackups = keys; - setBackups(keys); - }), - { - label: "Preparing your identity…", - run: async () => { - if (!checkedKey || !checkedBackups?.includes(checkedKey)) return; - void current.recovery.beginRecovery(); - return { status: "paused", at: "key-restore" }; + reads.current = current; + setSetupCredential(credential); + let checkedKey: string | undefined; + let checkedBackups: readonly string[] | undefined; + await setup.start( + [ + checkAccount( + current, + (key) => { + checkedKey = key; + setLinked(key); + }, + (list) => { + known.current = list; + }, + ), + checkBackup(current, (keys) => { + checkedBackups = keys; + setBackups(keys); + }), + { + label: "Preparing your identity…", + run: async () => { + if (!checkedKey || !checkedBackups?.includes(checkedKey)) return; + void current.recovery.beginRecovery(); + return { status: "paused", at: "key-restore" }; + }, }, - }, - ], - { status: "paused", at: "key-choice" }, - ); + ], + { status: "paused", at: "key-choice" }, + ); if (!currentAttempt()) return; if (request) { @@ -552,7 +543,7 @@ export function Welcome({ ]; /** A retry after expiration reacquires the same account before uploading. */ const backupKey = (initial: FirstRunAccount) => { - const original = login.credential().account; + const original = (setupCredential ?? login.credential()).account; let currentAccount = initial; let expired = false; return backUp(identity, { @@ -569,6 +560,7 @@ export function Welcome({ `Sign in as ${original.email} to back up this key.`, ); } + setSetupCredential(credential); currentAccount = account(credential, identity); reads.current = currentAccount; expired = false; @@ -594,11 +586,13 @@ export function Welcome({ setFinding(undefined); setInviteCode(""); setSwitchError(undefined); + setSetupCredential(undefined); setRequiresSignIn(false); setLinked(undefined); setOwned(undefined); setBackups(undefined); reads.current = undefined; + setSetupCredential(undefined); known.current = undefined; setup.reset(); cancels.current++; @@ -762,7 +756,7 @@ export function Welcome({ requiresSignIn={requiresSignIn} nipFiCompatible={nipFiCompatible} signInOrigin={ - nativeLogin + federatedIdentity ? federatedIdentity?.snapshot().authority?.identityOrigin : undefined } @@ -824,7 +818,7 @@ export function Welcome({ case "key-choice": return ( { void setup.start([ createKey(identity), @@ -835,7 +829,7 @@ export function Welcome({ }} onImport={() => pause("key-import")} onSwitchAccount={ - nativeLogin ? () => void signIn(true) : backToLanding + federatedIdentity ? () => void signIn(true) : backToLanding } /> ); @@ -843,7 +837,7 @@ export function Welcome({ return linked ? ( <> pause("key-import")} onSwitchAccount={() => { - if (viewer !== linked) return backToLanding(); + if (viewer !== linked) + return federatedIdentity + ? void signIn(true) + : backToLanding(); void identity .signOut({ wipe: false, removeAgents: false }) .catch(() => @@ -873,7 +870,7 @@ export function Welcome({ return linked ? ( back("welcome-back", "restore")} onImport={() => pause("key-import")} @@ -1116,7 +1113,10 @@ export function Welcome({ finishJoin(profileStep, next)} /> ) : joinError ? ( @@ -1178,7 +1178,7 @@ export function Welcome({ title="Couldn’t sign in" error={auth.error} onBack={backToLanding} - onRetry={nativeLogin ? () => void signIn() : undefined} + onRetry={federatedIdentity ? () => void signIn() : undefined} /> ) : finding ? ( command === "nsec_backup_session", ), - ).toHaveLength(1); + ).toHaveLength(2); }); it("turns consent on, hands the credential over and waits for the pass its ticket names", async () => { diff --git a/src/features/identity/backup.ts b/src/features/identity/backup.ts index 59e328d0c9..62430185c6 100644 --- a/src/features/identity/backup.ts +++ b/src/features/identity/backup.ts @@ -3,7 +3,9 @@ import { BACKUP_FAILURES, type BackupFailureKind } from "./backup-failure"; import type { Identity } from "./service"; type Send = (command: string, args?: Record) => Promise; -type BackupCredential = string | Readonly<{ credential: string } | { federated: string }>; +type BackupCredential = + | string + | Readonly<{ credential: string } | { federated: string }>; // Shared by both login owners so re-enabling a plugin cannot reuse an old revision. let generation = 0; @@ -166,7 +168,9 @@ function failure(status: BackupStatus): BackupFailureKind | undefined { export async function backUpWith( identity: Pick, pubkey: string, - credential: BackupCredential | (() => BackupCredential), + credential: + | BackupCredential + | (() => BackupCredential | Promise), { timeout = 60_000, after = timer, @@ -191,7 +195,8 @@ export async function backUpWith( let supplied: BackupCredential | undefined; try { // Native revisions can change while readiness and consent are awaited. - supplied = typeof credential === "function" ? credential() : credential; + supplied = + typeof credential === "function" ? await credential() : credential; const ticket = await (typeof supplied === "string" ? handoffBackupSession("builderlab", supplied, send) : "federated" in supplied @@ -219,7 +224,10 @@ export async function backUpWith( settled.abort(); // After a timeout this also retires the pass still running. Native // refuses an FI revision that is no longer current. - await (completed && supplied && typeof supplied !== "string" && "federated" in supplied + await (completed && + supplied && + typeof supplied !== "string" && + "federated" in supplied ? handoffFederatedBackupSession(supplied.federated, send) : handoffBackupSession("builderlab", null, send)); } diff --git a/src/features/identity/builderlab-account.test.ts b/src/features/identity/builderlab-account.test.ts index c839a79400..89f93f4016 100644 --- a/src/features/identity/builderlab-account.test.ts +++ b/src/features/identity/builderlab-account.test.ts @@ -1,8 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Credential } from "../../bundled/builderlab/oauth/browser"; -import { createNativeSession } from "../../bundled/builderlab/session"; -import { federatedFixture } from "../federated-identity/testing"; +import { readFederatedCredential } from "../../bundled/builderlab/oauth/first-run"; import type { HostRequest } from "../host/service"; import { BackupError, type BackupStatus, createBackupStatus } from "./backup"; import { BACKUP_FAILURES } from "./backup-failure"; @@ -11,7 +10,6 @@ import { backupOfferedFor, builderlabAccount, federatedBuilderlabAccount, - builderlabSessionAccount, seededAccount, } from "./builderlab-account"; @@ -109,17 +107,16 @@ async function nativeAccountSession() { : { identity: null }, ), ); - const session = createNativeSession(h.service); - await session.ensureSignedIn(new AbortController().signal); + await h.service.signIn(); + const initial = await readFederatedCredential(h.service); return { h, - session, + account: (identity: BackupIdentity) => + federatedBuilderlabAccount(initial, h.service, identity), + session: { dispose() {} }, async replace(nextSubject: string, revision: string) { subject = nextSubject; h.publish({ status: "signed-in", revision }); - await vi.waitFor(() => - expect(session.snapshot().status).toBe("signed-in"), - ); }, }; } @@ -128,7 +125,7 @@ it("first-run requests follow same-account renewal but refuse a different accoun vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); const native = await nativeAccountSession(); try { - const account = builderlabSessionAccount(native.session, untouched); + const account = native.account(untouched); expect(await account.linkedKey()).toBeNull(); await native.replace("original", "renewed-session"); expect(await account.linkedKey()).toBeNull(); @@ -137,7 +134,8 @@ it("first-run requests follow same-account renewal but refuse a different accoun await expect(account.linkedKey()).rejects.toMatchObject({ name: "AbortError", }); - expect(native.h.request).toHaveBeenCalledTimes(before); + expect(native.h.request).toHaveBeenCalledTimes(before + 1); + expect(native.h.request.mock.calls.at(-1)?.[0].path).toMatch(/auth\/me$/); } finally { native.session.dispose(); } @@ -146,7 +144,7 @@ it("first-run requests follow same-account renewal but refuse a different accoun it("refuses a first-run response after the account has been retired", async () => { const native = await nativeAccountSession(); try { - const account = builderlabSessionAccount(native.session, untouched); + const account = native.account(untouched); let finish!: (response: ReturnType) => void; native.h.request.mockImplementationOnce( () => @@ -155,6 +153,7 @@ it("refuses a first-run response after the account has been retired", async () = }), ); const result = account.linkedKey(); + await vi.waitFor(() => expect(finish).toBeTypeOf("function")); native.h.publish({ status: "signed-out", revision: "retired" }); finish(answer(200, { identity: { pubkey_hex: pubkey } })); await expect(result).rejects.toThrow(); @@ -1146,10 +1145,7 @@ describe("backup", () => { const native = host(status(1, 0)); vi.mocked(invoke).mockResolvedValue(1); try { - const account = builderlabSessionAccount( - nativeSession.session, - native.identity, - ); + const account = nativeSession.account(native.identity); await nativeSession.replace("original", "renewed-native-revision"); const done = account.backUp(pubkey); await vi.waitFor(() => @@ -1163,7 +1159,7 @@ describe("backup", () => { ); native.watchers[0]?.onmessage(status(2, 1, true, "complete")); await done; - expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledTimes(2); } finally { nativeSession.session.dispose(); } @@ -1199,10 +1195,7 @@ describe("backup", () => { }); vi.mocked(invoke).mockResolvedValue(1); try { - const account = builderlabSessionAccount( - owner.session, - native.identity, - ); + const account = owner.account(native.identity); const done = account.backUp(pubkey); void done.catch(() => {}); await ready; @@ -1473,18 +1466,24 @@ describe("federatedBuilderlabAccount", () => { ]); }); - it("refuses without a request once the session it signed in with has changed", async () => { + it("refuses an account change before sending account operations", async () => { const fi = await signedIn(); const account = federatedBuilderlabAccount( fi.credential, fi.service, untouched, ); + fi.request.mockResolvedValueOnce( + answer(200, { subject: "different", email: "different@example.com" }), + ); await fi.service.signIn(); - await expect(account.linkedKey()).rejects.toThrow(signedOut); + await expect(account.linkedKey()).rejects.toThrow( + "Builderlab account changed", + ); fi.publish({ status: "signed-out" }); await expect(account.ownedCommunities()).rejects.toThrow(signedOut); - expect(fi.request).not.toHaveBeenCalled(); + expect(fi.request).toHaveBeenCalledOnce(); + expect(fi.request.mock.calls[0]?.[0].path).toMatch(/auth\/me$/); }); it("refuses without a request while the session it signed in with is unavailable", async () => { diff --git a/src/features/identity/builderlab-account.ts b/src/features/identity/builderlab-account.ts index 4882a3bca5..6de6d329e7 100644 --- a/src/features/identity/builderlab-account.ts +++ b/src/features/identity/builderlab-account.ts @@ -14,7 +14,7 @@ import { import { enteredRelayOrigin, relayAddress } from "../communities/destination"; import type { FederatedIdentity } from "../federated-identity/service"; import type { Host, HostResponse } from "../host/service"; -import type { BuilderlabSession } from "../../bundled/builderlab/session"; +import { readFederatedCredential } from "../../bundled/builderlab/oauth/first-run"; import { BackupError, backUpWith } from "./backup"; import type { BackupFailureKind } from "./backup-failure"; import type { FirstRunAccountSeed } from "./first-run-seed"; @@ -351,38 +351,39 @@ export function federatedBuilderlabAccount( federated: Pick, identity: BackupIdentity, ): FirstRunAccount { + let currentCredential = credential; + const current = async () => { + const session = federated.snapshot(); + if (session.status !== "signed-in" || !session.authority) + throw new Error(SIGNED_OUT); + if (session.revision !== currentCredential.value) { + const next = await readFederatedCredential(federated); + if (next.account.subject !== credential.account.subject) + throw new DOMException("Builderlab account changed.", "AbortError"); + currentCredential = next; + } + return currentCredential; + }; return kgooseAccount( - (path, headers, body) => { + async (path, headers, body) => { + const credential = await current(); const session = federated.snapshot(); if ( session.status !== "signed-in" || - session.revision !== credential.value || - !session.authority + !session.authority || + session.revision !== credential.value ) - return Promise.reject(new Error(SIGNED_OUT)); - return federated.request({ + throw new Error(SIGNED_OUT); + const response = await federated.request({ path: `${session.authority.apiBasePath}${path.slice(1)}`, method: "POST", headers, body, }); + await current(); + return response; }, - { federated: credential.value }, - identity, - ); -} - -/** Same account contract through the shared native session. */ -export function builderlabSessionAccount( - session: BuilderlabSession, - identity: BackupIdentity, -): FirstRunAccount { - const credential = session.credential(); - return kgooseAccount( - (path, headers, body) => session.request(credential, { - path, method: "POST", headers, body, - }), - { federated }, + async () => ({ federated: (await current()).value }), identity, ); } diff --git a/src/features/identity/federated-first-run.test.ts b/src/features/identity/federated-first-run.test.ts deleted file mode 100644 index 9613ba8fb6..0000000000 --- a/src/features/identity/federated-first-run.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { afterEach, expect, it, vi } from "vitest"; -import { invoke } from "@tauri-apps/api/core"; -import { federatedFixture } from "../federated-identity/testing"; -import { createFederatedFirstRun } from "./federated-first-run"; - -vi.mock("@tauri-apps/api/core", () => ({ - invoke: vi.fn(), - isTauri: () => true, - Channel: class {}, -})); -afterEach(() => { - vi.resetAllMocks(); - vi.unstubAllEnvs(); -}); -const response = (body: unknown) => ({ - status: 200, - headers: {}, - body: JSON.stringify(body), -}); - -it.each([null, "ab".repeat(32)])( - "reads first-run account binding %s through the native grant without a token", - async (key) => { - vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); - const h = federatedFixture(); - vi.mocked(invoke).mockImplementation(async (command, args) => { - if (command === "federated_identity_request") { - const path = - (args as { request?: { path?: string } } | undefined)?.request - ?.path ?? ""; - if (path.endsWith("auth/me")) - return response({ subject: "account", email: "a@example.com" }); - if (path.endsWith("nostr-identities/current")) - return response({ identity: key ? { pubkey_hex: key } : null }); - throw new Error(`Unexpected route ${path}`); - } - if ( - command === "federated_identity_register" || - command === "federated_identity_unregister" - ) - return; - throw new Error(`Unexpected native operation ${command}`); - }); - const session = createFederatedFirstRun(h.service); - try { - await session.ensureSignedIn(new AbortController().signal); - const reply = await session.request(session.credential(), { - path: "/v1/buzz/nostr-identities/current", - method: "POST", - headers: { Origin: "https://builderlab.example" }, - }); - expect(JSON.parse(reply.body).identity?.pubkey_hex ?? null).toBe(key); - const requests = vi - .mocked(invoke) - .mock.calls.filter( - ([command]) => command === "federated_identity_request", - ); - expect(requests).toHaveLength(2); - expect(requests[1]?.[1]).toMatchObject({ - id: "block.builderlab", - revision: "bundled", - session: "user:1", - request: { - path: "/api/goose/v1/buzz/nostr-identities/current", - headers: { Origin: "https://builderlab.example" }, - }, - }); - expect(JSON.stringify(requests)).not.toMatch( - /Authorization|X-BB-Session-Credential/, - ); - expect( - vi - .mocked(invoke) - .mock.calls.filter( - ([command]) => command === "federated_identity_unregister", - ), - ).toHaveLength(2); - expect(h.service.request).not.toHaveBeenCalled(); - } finally { - session.dispose(); - } - }, -); - -it("refuses first-run account setup without a bundled authority grant and releases the registration", async () => { - const h = federatedFixture(); - vi.mocked(invoke) - .mockResolvedValue(undefined) - .mockRejectedValueOnce( - new Error("Plugin has no authenticated authority grant"), - ); - const session = createFederatedFirstRun(h.service); - try { - await expect( - session.ensureSignedIn(new AbortController().signal), - ).rejects.toThrow("Could not load your Builderlab account"); - expect(h.service.signIn).toHaveBeenCalledOnce(); - expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ - "federated_identity_register", - "federated_identity_unregister", - ]); - } finally { - session.dispose(); - } -}); - -it("rejects account requests from a retired session before dispatch", async () => { - const h = federatedFixture(); - vi.mocked(invoke).mockImplementation(async (command) => - command === "federated_identity_request" - ? response({ subject: "account" }) - : undefined, - ); - const session = createFederatedFirstRun(h.service); - try { - await session.ensureSignedIn(new AbortController().signal); - const credential = session.credential(); - h.publish({ status: "signed-out", revision: "retired" }); - const before = vi.mocked(invoke).mock.calls.length; - await expect( - session.request(credential, { - path: "/v1/buzz/nostr-identities/challenge", - }), - ).rejects.toThrow(); - expect(invoke).toHaveBeenCalledTimes(before); - } finally { - session.dispose(); - } -}); - -it.each(["once", "always", "network"])( - "handles registration failure %s without unbounded or arbitrary replay", - async (mode) => { - const h = federatedFixture(); - let attempts = 0; - const revisions: unknown[] = []; - const registrations: unknown[] = []; - vi.mocked(invoke).mockImplementation(async (command, args) => { - if (command === "federated_identity_register") - registrations.push( - (args as Record | undefined)?.registration, - ); - if (command !== "federated_identity_request") return; - const input = args as { session: string; request: { path: string } }; - if (input.request.path.endsWith("auth/me")) - return response({ subject: "account" }); - revisions.push(input.session); - attempts++; - if (mode === "network") return Promise.reject("Network unavailable"); - if (mode === "always" || attempts === 1) - return Promise.reject("Plugin registration retired"); - return response({ identity: null }); - }); - const session = createFederatedFirstRun(h.service); - try { - await session.ensureSignedIn(new AbortController().signal); - const request = session.request(session.credential(), { - path: "/v1/buzz/nostr-identities/current", - }); - if (mode === "once") - await expect(request).resolves.toMatchObject({ status: 200 }); - else await expect(request).rejects.toThrow(); - expect(attempts).toBe(mode === "network" ? 1 : 2); - expect(new Set(registrations).size).toBe(registrations.length); - expect(revisions).toEqual(Array(attempts).fill("user:1")); - expect( - vi - .mocked(invoke) - .mock.calls.filter( - ([command]) => command === "federated_identity_unregister", - ), - ).toHaveLength(registrations.length); - } finally { - session.dispose(); - } - }, -); - -it("does not dispatch after disposal while native registration is pending", async () => { - const h = federatedFixture(); - let hold = false; - let registered!: () => void; - let release!: () => void; - const started = new Promise((resolve) => { - registered = resolve; - }); - const gate = new Promise((resolve) => { - release = resolve; - }); - vi.mocked(invoke).mockImplementation(async (command) => { - if (command === "federated_identity_register" && hold) { - registered(); - await gate; - } - if (command === "federated_identity_request") - return response({ subject: "account" }); - }); - const session = createFederatedFirstRun(h.service); - await session.ensureSignedIn(new AbortController().signal); - hold = true; - const request = session.request(session.credential(), { - path: "/v1/buzz/nostr-identities/challenge", - }); - await started; - session.dispose(); - release(); - await expect(request).rejects.toMatchObject({ name: "AbortError" }); - expect( - vi - .mocked(invoke) - .mock.calls.filter( - ([command]) => command === "federated_identity_request", - ), - ).toHaveLength(1); -}); diff --git a/src/features/identity/federated-first-run.ts b/src/features/identity/federated-first-run.ts deleted file mode 100644 index 33b84acd6b..0000000000 --- a/src/features/identity/federated-first-run.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; -import manifest from "../../bundled/builderlab/manifest.json"; -import { createNativeSession } from "../../bundled/builderlab/session"; -import type { FederatedIdentity } from "../federated-identity/service"; -import type { HostResponse } from "../host/service"; - -/** Welcome uses the bundled plugin's existing grant before its UI is mounted. - * Native rechecks enabled artifact, authority, operation and session revision. - * Each bounded native request releases its registration when it settles. */ -export function createFederatedFirstRun(identity: FederatedIdentity) { - let active = true; - const session = createNativeSession({ - apiAvailable: identity.apiAvailable, - snapshot: identity.snapshot, - subscribe: identity.subscribe, - signIn: () => identity.signIn(), - cancelSignIn: () => identity.cancelSignIn(), - signOut: () => identity.signOut(), - async request(request) { - const session = identity.snapshot().revision; - for (let attempt = 0; ; attempt++) { - if (!active) throw new DOMException("First run closed", "AbortError"); - const registration = crypto.randomUUID(); - const owner = { id: manifest.id, revision: "bundled", registration }; - let response: HostResponse; - try { - await invoke("federated_identity_register", owner); - if (!active) throw new DOMException("First run closed", "AbortError"); - response = await invoke("federated_identity_request", { - ...owner, - session, - request, - }); - } catch (error) { - // Native emits this only before forwarding a request. Re-register once, - // retaining the original session revision; never replay other failures. - if ( - active && - error === "Plugin registration retired" && - attempt === 0 - ) - continue; - throw typeof error === "string" ? new Error(error) : error; - } finally { - await invoke("federated_identity_unregister", { registration }).catch( - () => {}, - ); - } - if (!active) throw new DOMException("First run closed", "AbortError"); - return response; - } - }, - }); - return { - ...session, - dispose() { - active = false; - session.dispose(); - }, - }; -} diff --git a/src/features/identity/identity.test.tsx b/src/features/identity/identity.test.tsx index fd8476db9a..e735e7f764 100644 --- a/src/features/identity/identity.test.tsx +++ b/src/features/identity/identity.test.tsx @@ -1,5 +1,5 @@ -import { requiredFiDiscovery } from "../communities/discovery-fixture"; // @vitest-environment jsdom +import { requiredFiDiscovery } from "../communities/discovery-fixture"; import "@testing-library/jest-dom/vitest"; import { act, @@ -37,6 +37,7 @@ import { federatedFixture } from "../federated-identity/testing"; import { ProfileStep } from "./ProfileStep"; import { OnboardingScreen } from "./OnboardingScreen"; import type { Credential } from "../../bundled/builderlab/oauth/browser"; +import { firstRunCredential } from "../../bundled/builderlab/oauth/first-run"; import { BackupError, type BackupStatus } from "./backup"; import { builderlabAccount, @@ -81,10 +82,24 @@ const credential: Credential = { const welcomeHeading = () => screen.getByRole("heading", { name: "Buzz" }); function nativeFirstRunReplies( + h: ReturnType, account: () => { subject: string; email: string }, linked: () => string | null, ) { vi.mocked(invoke).mockImplementation(async (command, args) => { + if (command === "plugin_catalog") + return { + status: "ready", + catalog: { + plugins: [ + { + source: "bundled", + manifest: { id: "block.builderlab" }, + enabled: true, + }, + ], + }, + }; if (command === "identity_restore") return null; if (command === "identity_legacy_available") return false; if (command === "nsec_backup_state") @@ -110,6 +125,9 @@ function nativeFirstRunReplies( } throw new Error(`Unexpected native command ${command}`); }); + h.request.mockImplementation((request) => + invoke("federated_identity_request", { request }), + ); } it.each([null, owned.pubkey])( @@ -119,6 +137,7 @@ it.each([null, owned.pubkey])( vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); const h = federatedFixture(); nativeFirstRunReplies( + h, () => ({ subject: "account", email: "person@example.com" }), () => linked, ); @@ -139,14 +158,12 @@ it.each([null, owned.pubkey])( .click(await screen.findByRole("button", { name: SIGN_IN })); expect( await screen.findByRole("heading", { - name: linked ? "Welcome back" : "Set up your key", + name: linked ? "Continue with this key" : "Get started with Buzz", }), ).toBeVisible(); - expect( - screen.getByText("Signed in as person@example.com."), - ).toBeVisible(); + expect(screen.getByText("Signed in as person@example.com")).toBeVisible(); if (linked) { - expect(screen.getByText(npubEncode(linked))).toBeVisible(); + expect(screen.getByRole("button", { name: /Saved key/ })).toBeVisible(); expect( screen.queryByRole("button", { name: "Create my key" }), ).toBeNull(); @@ -173,7 +190,7 @@ it.each([null, owned.pubkey])( .click(screen.getByRole("button", { name: "Back" })); expect( screen.getByRole("heading", { - name: linked ? "Welcome back" : "Set up your key", + name: linked ? "Continue with this key" : "Get started with Buzz", }), ).toBeVisible(); } finally { @@ -1424,7 +1441,27 @@ it.each([ }); const h = federatedFixture(); const identity = createIdentity(); - const connect = vi.fn(); + const relayAnswers = vi.mocked(invoke).getMockImplementation(); + if (!relayAnswers) throw new Error("Missing relay fixture"); + vi.mocked(invoke).mockImplementation((command, args) => + command === "plugin_catalog" + ? Promise.resolve({ + status: "ready", + catalog: { + plugins: [ + { + source: "bundled", + manifest: { id: "block.builderlab" }, + enabled: true, + }, + ], + }, + }) + : relayAnswers(command, args), + ); + const connect = vi.fn((signal: AbortSignal) => + firstRunCredential(signal, h.service), + ); render( (); vi.mocked(h.service.signIn).mockImplementation(async () => { h.publish({ status: "signing-in" }); + await browser.promise; return h.service.snapshot(); }); + vi.mocked(h.service.cancelSignIn).mockImplementation(async () => { + h.publish({ status: "signed-out" }); + browser.resolve(); + }); const user = userEvent.setup(); await user.click(screen.getByRole("button", { name: SIGN_IN })); - expect(h.service.signIn).toHaveBeenCalledOnce(); - expect(connect).not.toHaveBeenCalled(); - await user.click( - screen.getByRole("button", { name: "Cancel sign-in" }), - ); + await waitFor(() => expect(h.service.signIn).toHaveBeenCalledOnce()); + expect(connect).toHaveBeenCalledOnce(); + await user.click(screen.getByRole("button", { name: "Other options" })); expect(h.service.cancelSignIn).toHaveBeenCalled(); } } finally { @@ -7055,6 +7096,7 @@ it.each(["cancel", "failure", "same-account", "different-account"])( const h = federatedFixture(); let subject = "original"; nativeFirstRunReplies( + h, () => ({ subject, email: `${subject}@example.com` }), () => (subject === "original" ? owned.pubkey : null), ); @@ -7072,7 +7114,7 @@ it.each(["cancel", "failure", "same-account", "different-account"])( const user = userEvent.setup(); try { await user.click(await screen.findByRole("button", { name: SIGN_IN })); - await screen.findByRole("heading", { name: "Welcome back" }); + await screen.findByRole("heading", { name: "Continue with this key" }); const before = h.service.snapshot(); const gate = deferred(); vi.mocked(h.service.cancelSignIn).mockResolvedValue(); @@ -7100,14 +7142,16 @@ it.each(["cancel", "failure", "same-account", "different-account"])( await act(async () => gate.resolve()); if (outcome === "different-account") { expect( - await screen.findByRole("heading", { name: "Set up your key" }), + await screen.findByRole("heading", { name: "Get started with Buzz" }), ).toBeVisible(); expect( - screen.getByText("Signed in as replacement@example.com."), + screen.getByText("Signed in as replacement@example.com"), ).toBeVisible(); } else if (outcome === "cancel") { expect( - await screen.findByRole("heading", { name: "Welcome back" }), + await screen.findByRole("heading", { + name: "Continue with this key", + }), ).toBeVisible(); expect(h.service.snapshot()).toBe(before); } else { @@ -7123,7 +7167,7 @@ it.each(["cancel", "failure", "same-account", "different-account"])( else expect(h.service.snapshot()).toBe(before); await user.click(screen.getByRole("button", { name: "Back" })); expect( - screen.getByRole("heading", { name: "Welcome back" }), + screen.getByRole("heading", { name: "Continue with this key" }), ).toBeVisible(); } expect(h.service.signOut).not.toHaveBeenCalled(); @@ -7152,6 +7196,7 @@ it.each(["account-read-failure", "cancel-after-commit", "commit-after-cancel"])( let subject = "original"; let refuse = false; nativeFirstRunReplies( + h, () => { if (refuse) throw new Error("Account read unavailable"); return { subject, email: `${subject}@example.com` }; @@ -7173,7 +7218,7 @@ it.each(["account-read-failure", "cancel-after-commit", "commit-after-cancel"])( const exchange = deferred(); try { await user.click(await screen.findByRole("button", { name: SIGN_IN })); - await screen.findByRole("heading", { name: "Welcome back" }); + await screen.findByRole("heading", { name: "Continue with this key" }); vi.mocked(h.service.cancelSignIn).mockResolvedValue(); vi.mocked(h.service.signIn).mockImplementation(async () => { if (outcome === "commit-after-cancel") await exchange.promise; @@ -7198,7 +7243,7 @@ it.each(["account-read-failure", "cancel-after-commit", "commit-after-cancel"])( screen.getByRole("button", { name: "Cancel sign-in" }), ).toBeDisabled(); expect( - screen.queryByRole("heading", { name: "Welcome back" }), + screen.queryByRole("heading", { name: "Continue with this key" }), ).toBeNull(); } await act(async () => exchange.resolve()); @@ -7214,10 +7259,10 @@ it.each(["account-read-failure", "cancel-after-commit", "commit-after-cancel"])( refuse = false; await user.click(screen.getByRole("button", { name: "Try again" })); expect( - await screen.findByRole("heading", { name: "Set up your key" }), + await screen.findByRole("heading", { name: "Get started with Buzz" }), ).toBeVisible(); expect( - screen.getByText("Signed in as replacement@example.com."), + screen.getByText("Signed in as replacement@example.com"), ).toBeVisible(); expect(h.service.signIn).toHaveBeenCalledTimes(2); expect(h.service.signOut).not.toHaveBeenCalled(); @@ -7235,6 +7280,7 @@ it("allows one cancellation per attempt and joins it before allowing another att vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); const h = federatedFixture(); nativeFirstRunReplies( + h, () => ({ subject: "original", email: "original@example.com" }), () => owned.pubkey, ); @@ -7256,7 +7302,7 @@ it("allows one cancellation per attempt and joins it before allowing another att cancelSecond = deferred(); try { await user.click(await screen.findByRole("button", { name: SIGN_IN })); - await screen.findByRole("heading", { name: "Welcome back" }); + await screen.findByRole("heading", { name: "Continue with this key" }); vi.mocked(h.service.signIn) .mockImplementationOnce(async () => { await first.promise; @@ -7282,7 +7328,7 @@ it("allows one cancellation per attempt and joins it before allowing another att screen.getByRole("button", { name: "Cancel sign-in" }), ).toBeDisabled(); await act(async () => first.resolve()); - await screen.findByRole("heading", { name: "Welcome back" }); + await screen.findByRole("heading", { name: "Continue with this key" }); await user.click( screen.getByRole("button", { name: "Use a different account" }), ); @@ -7293,7 +7339,9 @@ it("allows one cancellation per attempt and joins it before allowing another att expect(h.service.cancelSignIn).toHaveBeenCalledTimes(2); await act(async () => cancelSecond.resolve()); await act(async () => second.resolve()); - expect(screen.getByRole("heading", { name: "Welcome back" })).toBeVisible(); + expect( + screen.getByRole("heading", { name: "Continue with this key" }), + ).toBeVisible(); expect(h.service.signOut).not.toHaveBeenCalled(); } finally { first.resolve(); @@ -7311,6 +7359,7 @@ it("finishes same-account checks before offering another replacement attempt", a const h = federatedFixture(); let subject = "original"; nativeFirstRunReplies( + h, () => ({ subject, email: `${subject}@example.com` }), () => (subject === "original" ? owned.pubkey : null), ); @@ -7338,7 +7387,7 @@ it("finishes same-account checks before offering another replacement attempt", a const user = userEvent.setup(); try { await user.click(await screen.findByRole("button", { name: SIGN_IN })); - await screen.findByRole("heading", { name: "Welcome back" }); + await screen.findByRole("heading", { name: "Continue with this key" }); vi.mocked(h.service.signIn).mockImplementation(async () => { h.publish({ status: "signed-in", revision: `${subject}-renewed` }); return h.service.snapshot(); @@ -7370,10 +7419,10 @@ it("finishes same-account checks before offering another replacement attempt", a gated = false; await user.click(screen.getByRole("button", { name: "Try again" })); expect( - await screen.findByRole("heading", { name: "Set up your key" }), + await screen.findByRole("heading", { name: "Get started with Buzz" }), ).toBeVisible(); expect( - screen.getByText("Signed in as replacement@example.com."), + screen.getByText("Signed in as replacement@example.com"), ).toBeVisible(); expect(h.service.signOut).not.toHaveBeenCalled(); } finally { @@ -7382,3 +7431,64 @@ it("finishes same-account checks before offering another replacement attempt", a identity.dispose(); } }); + +it("FI account choice with the linked key already installed preserves main's device sign-out boundary", async () => { + const h = federatedFixture(); + const checked = deferred(); + const linkedKey = vi.fn(() => checked.promise); + const signOut = vi.fn(async () => {}); + vi.mocked(invoke).mockImplementation(async (command) => { + if (command === "identity_restore") return null; + if (command === "identity_legacy_available") return false; + if (command === "identity_create") return owned.pubkey; + throw new Error(`Unexpected command ${command}`); + }); + const identity = createIdentity(); + const deviceSignOut = vi + .spyOn(identity, "signOut") + .mockImplementation(signOut); + const user = userEvent.setup(); + const view = render( + credential} + account={(value, id) => ({ + ...linkedTo(owned.pubkey)(value, id), + linkedKey, + })} + > +

App open

+
, + ); + try { + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + await waitFor(() => expect(linkedKey).toHaveBeenCalledOnce()); + // A native key installation finishes while the account check is pending. + await act(async () => { + await identity.create(); + checked.resolve(owned.pubkey); + }); + await screen.findByRole("heading", { name: "Continue with this key" }); + await user.click( + screen.getByRole("button", { name: "Use a different account" }), + ); + expect(deviceSignOut).toHaveBeenCalledExactlyOnceWith({ + wipe: false, + removeAgents: false, + }); + expect(h.service.signIn).not.toHaveBeenCalled(); + expect(h.service.signOut).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Set me up" })).toBeNull(); + expect(identity.snapshot()).toMatchObject({ + status: "ready", + viewer: owned.pubkey, + }); + } finally { + checked.resolve(owned.pubkey); + view.unmount(); + identity.dispose(); + } +}); From da8fa6ca29ee2ccd8c4997f82891636db62af889 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Wed, 4 Nov 2026 12:42:07 -0500 Subject: [PATCH 9/9] BUZZ-518: Prove sign-in revision fences Supply a successful account reply in the concurrent replacement regression and cover Welcome preserving the selected sign-in revision before explicit reload. Co-authored-by: Honor Signed-off-by: Brad Seiler --- .../builderlab/oauth/first-run.test.ts | 10 +++- src/features/identity/identity.test.tsx | 57 +++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/bundled/builderlab/oauth/first-run.test.ts b/src/bundled/builderlab/oauth/first-run.test.ts index afac1875db..0b89a16171 100644 --- a/src/bundled/builderlab/oauth/first-run.test.ts +++ b/src/bundled/builderlab/oauth/first-run.test.ts @@ -224,13 +224,19 @@ describe("an FI build", () => { ], [ "the session changes before the sign-in answer is consumed", - (fi: ReturnType) => + (fi: ReturnType) => { + fi.request.mockResolvedValueOnce({ + status: 200, + headers: {}, + body: JSON.stringify(me), + }); vi.mocked(fi.service.signIn).mockImplementationOnce(async () => { fi.publish({ status: "signed-in", revision: "returned" }); const returned = fi.service.snapshot(); fi.publish({ revision: "concurrent-replacement" }); return returned; - }), + }); + }, "Could not load your Builderlab account. Try again.", ], [ diff --git a/src/features/identity/identity.test.tsx b/src/features/identity/identity.test.tsx index e735e7f764..6d45ea6070 100644 --- a/src/features/identity/identity.test.tsx +++ b/src/features/identity/identity.test.tsx @@ -7492,3 +7492,60 @@ it("FI account choice with the linked key already installed preserves main's dev identity.dispose(); } }); + +it("FI replacement rejects a concurrent session before consuming the selected sign-in result", async () => { + vi.stubEnv("VITE_BUZZ_FEDERATED_IDENTITY", "1"); + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); + const h = federatedFixture(); + let subject = "original"; + nativeFirstRunReplies( + h, + () => ({ subject, email: `${subject}@example.com` }), + () => (subject === "original" ? owned.pubkey : null), + ); + const identity = createIdentity(); + const view = render( + +

App open

+
, + ); + const user = userEvent.setup(); + try { + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + await screen.findByRole("heading", { name: "Continue with this key" }); + const requests = h.request.mock.calls.length; + vi.mocked(h.service.signIn).mockImplementationOnce(async () => { + h.publish({ status: "signed-in", revision: "selected-session" }); + const selected = h.service.snapshot(); + subject = "concurrent"; + h.publish({ revision: "concurrent-session" }); + return selected; + }); + await user.click( + screen.getByRole("button", { name: "Use a different account" }), + ); + await screen.findByRole("heading", { name: "Couldn’t change account" }); + // Successful account replies are available, but this sign-in may not adopt + // the concurrent session. The failure view is the completion barrier. + expect(h.request).toHaveBeenCalledTimes(requests); + expect(screen.queryByRole("button", { name: "Back" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Set me up" })).toBeNull(); + expect(identity.snapshot().status).toBe("missing"); + // An explicit retry may load the current account without another browser. + await user.click(screen.getByRole("button", { name: "Try again" })); + await screen.findByRole("heading", { name: "Get started with Buzz" }); + expect( + screen.getByText("Signed in as concurrent@example.com"), + ).toBeVisible(); + expect(h.service.signIn).toHaveBeenCalledTimes(2); + expect(h.service.signOut).not.toHaveBeenCalled(); + } finally { + view.unmount(); + identity.dispose(); + } +});