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 b0a51e62aa..ac282cefe7 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -57,6 +57,14 @@ 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 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 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..7577b19b90 --- /dev/null +++ b/docs/community-auth-recovery.md @@ -0,0 +1,153 @@ +# 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 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. + +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. +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 + +[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 `82c64ce008171855606511c1340cddaa949196fe`: + +- [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 +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. 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. + +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 + 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. +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. + +**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 + +| 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. + 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 + 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/bundled/builderlab/oauth/first-run.test.ts b/src/bundled/builderlab/oauth/first-run.test.ts index 4e75fff89e..0b89a16171 100644 --- a/src/bundled/builderlab/oauth/first-run.test.ts +++ b/src/bundled/builderlab/oauth/first-run.test.ts @@ -222,6 +222,23 @@ describe("an FI build", () => { }), "Could not load your Builderlab account. Try again.", ], + [ + "the session changes before the sign-in answer is consumed", + (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.", + ], [ "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 06d60c69ba..a6cc399f05 100644 --- a/src/features/communities/FindCommunity.test.tsx +++ b/src/features/communities/FindCommunity.test.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { requiredFiDiscovery } from "./discovery-fixture"; import "@testing-library/jest-dom/vitest"; import { act, @@ -30,7 +31,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, + ...requiredFiDiscovery, + requires_nip_fi: required, + federated_identity: { + ...requiredFiDiscovery.federated_identity, + core: compatible ? "client-attached" : "unsupported", + 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/communities/FindCommunity.tsx b/src/features/communities/FindCommunity.tsx index aeb7e700bd..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, @@ -25,6 +26,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: false, + }, unreachable: { message: "Could not reach the relay. Check your connection and try again.", retry: true, @@ -158,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( @@ -177,6 +185,20 @@ export function FindCommunity({ return ( <> {heading("Join Community")} + {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/communities/discovery-fixture.ts b/src/features/communities/discovery-fixture.ts new file mode 100644 index 0000000000..5457bf4166 --- /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: 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, + * 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 263a644548..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, @@ -46,6 +47,72 @@ const failure = (answers: Record) => 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, + ...requiredFiDiscovery, + requires_nip_fi: required, + }), + "/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) => { + expect( + await failure({ + "/": Response.json({ ...buzz, ...info }), + "/api/join-policy": html(404), + }), + ).toBe("unsupported-auth"); + }, +); + +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 f192c67433..af41dcd112 100644 --- a/src/features/communities/find-community.ts +++ b/src/features/communities/find-community.ts @@ -12,9 +12,17 @@ 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; + /** Whether this client's native FI contract understands a required descriptor. */ + nipFiCompatible?: 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 +69,9 @@ 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, + nipFiCompatible: + info.requires_nip_fi === true ? compatibleFi(info) : undefined, policy: await policyAnswer(await policy), }; } @@ -85,6 +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 && + 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" && @@ -103,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 + ); +} diff --git a/src/features/federated-identity/CommunitySignIn.test.tsx b/src/features/federated-identity/CommunitySignIn.test.tsx index 5507e355d8..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"; @@ -55,8 +55,122 @@ 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 again" }), + ).toBeEnabled(); + else expect(screen.queryByRole("button")).not.toBeInTheDocument(); expect(h.service.signIn).not.toHaveBeenCalled(); expect(h.service.signOut).not.toHaveBeenCalled(); }, ); + +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(); + render( + , + ); + expect(h.service.signOut).not.toHaveBeenCalled(); + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Sign in again" })); + expect(h.service.signOut).not.toHaveBeenCalled(); + 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(); + } +}); + +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 c4f732440f..53bf567dab 100644 --- a/src/features/federated-identity/CommunitySignIn.tsx +++ b/src/features/federated-identity/CommunitySignIn.tsx @@ -53,15 +53,79 @@ function Action({ identity.snapshot, ); const [failure, setFailure] = useState(""); + const [signingIn, setSigningIn] = useState(false); const refusal = error || state.failure || ""; - if (/binding[-_]mismatch/.test(refusal)) + const pending = signingIn || state.status === "signing-in"; + const signIn = async () => { + const before = identity.snapshot(); + setFailure(""); + setSigningIn(true); + try { + const next = await identity.signIn(); + 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 { + setSigningIn(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. +

+

+ 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 && ( +

+ {recoveryMessage(failure)} +

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

@@ -91,12 +155,7 @@ function Action({ {(failure || state.failure) && (

diff --git a/src/features/identity/ConnectChoice.tsx b/src/features/identity/ConnectChoice.tsx index 0ec0a4c9f2..9d4a132eb8 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 @@ -15,6 +16,9 @@ export function ConnectChoice({ onConnect, onImport, onBack, + requiresSignIn = false, + signInOrigin, + nipFiCompatible = true, }: { /** The found community's host, as the find page shows it. */ host: string; @@ -24,7 +28,13 @@ export function ConnectChoice({ onImport: () => void; /** Return to the landing with the address kept. */ onBack: () => void; + requiresSignIn?: boolean; + signInOrigin?: string | undefined; + nipFiCompatible?: boolean; }) { + const unsupportedSignIn = + requiresSignIn && + (!nativeBuilderlab() || !signInOrigin || !nipFiCompatible); return ( <>

@@ -36,9 +46,17 @@ export function ConnectChoice({ Connect to your community

- Sign in to your Builderlab account with email, or use an existing - private key to continue. + {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." + : "Sign in to your Builderlab account with email, or use an existing private key to continue."}

+ {signInOrigin && !unsupportedSignIn && ( +

+ Sign in at {signInOrigin}. +

+ )}
-
- - -
+ {!unsupportedSignIn && ( +
+ + +
+ )}
diff --git a/src/features/identity/Welcome.tsx b/src/features/identity/Welcome.tsx index 72e63146bb..b0996dc1ac 100644 --- a/src/features/identity/Welcome.tsx +++ b/src/features/identity/Welcome.tsx @@ -14,9 +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 { communityDestination } from "../communities/destination"; import { FindCommunity, type JoinConsent } from "../communities/FindCommunity"; import type { FoundCommunity } from "../communities/find-community"; @@ -134,9 +136,31 @@ export function Welcome({ }) { const state = useSyncExternalStore(identity.subscribe, identity.snapshot); const [login] = useState(() => createOAuthSession(connect)); - // 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 [setupCredential, setSetupCredential] = 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); + // 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); @@ -164,6 +188,8 @@ 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); + 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(""); @@ -215,8 +241,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" @@ -289,44 +316,166 @@ 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. - await login.signIn(); - if (attempt !== cancels.current || login.snapshot().status !== "signed-in") - return; - const current = account(login.credential(), identity); - reads.current = current; - let checkedKey: string | undefined; - let checkedBackups: readonly string[] | undefined; - void 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" }; + /** 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); + setOwned(undefined); + setup.reset(); + }; + const signIn = async (changeAccount = false) => { + let settle = () => {}; + const settled = new Promise((resolve) => { + settle = resolve; + }); + const request = + 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); + const previousSubject = setupCredential?.account.subject; + try { + 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; + } + } + // 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(); + } + if (!currentAttempt()) return; + const current = account(credential, identity); + const sameAccount = + changeAccount && credential.account.subject === previousSubject; + discardAccountSetup(); + 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) { + 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) => @@ -394,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, { @@ -411,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; @@ -436,10 +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++; @@ -600,6 +753,13 @@ export function Welcome({ return finding ? ( { setup.reset(); void signIn(); @@ -658,7 +818,7 @@ export function Welcome({ case "key-choice": return ( { void setup.start([ createKey(identity), @@ -668,14 +828,16 @@ export function Welcome({ ]); }} onImport={() => pause("key-import")} - onSwitchAccount={backToLanding} + onSwitchAccount={ + federatedIdentity ? () => void signIn(true) : backToLanding + } /> ); case "welcome-back": 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(() => @@ -705,7 +870,7 @@ export function Welcome({ return linked ? ( back("welcome-back", "restore")} onImport={() => pause("key-import")} @@ -779,6 +944,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; @@ -902,7 +1113,10 @@ export function Welcome({ finishJoin(profileStep, next)} /> ) : joinError ? ( @@ -964,6 +1178,7 @@ export function Welcome({ title="Couldn’t sign in" error={auth.error} onBack={backToLanding} + onRetry={federatedIdentity ? () => void signIn() : undefined} /> ) : finding ? ( { - if (hostedByThisBuilderlab(found)) void signIn(); + setRequiresSignIn(found.requiresNipFi === true); + setNipFiCompatible(found.nipFiCompatible !== false); + if ( + hostedByThisBuilderlab(found) && + (!found.requiresNipFi || + (nativeBuilderlab() && + federatedIdentity?.snapshot().authority && + found.nipFiCompatible)) + ) + void signIn(); else pause("connect-choice"); }} titleId="welcome-title" diff --git a/src/features/identity/backup.test.ts b/src/features/identity/backup.test.ts index bb01556fda..07618986ec 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(2); +}); + 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/backup.ts b/src/features/identity/backup.ts index 09b82ca872..62430185c6 100644 --- a/src/features/identity/backup.ts +++ b/src/features/identity/backup.ts @@ -3,6 +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 }>; // Shared by both login owners so re-enabling a plugin cannot reuse an old revision. let generation = 0; @@ -165,7 +168,9 @@ function failure(status: BackupStatus): BackupFailureKind | undefined { export async function backUpWith( identity: Pick, pubkey: string, - session: Readonly<{ credential: string } | { federated: string }>, + credential: + | BackupCredential + | (() => BackupCredential | Promise), { timeout = 60_000, after = timer, @@ -187,10 +192,16 @@ 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" ? await 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 +224,11 @@ 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 4c1cbe228d..89f93f4016 100644 --- a/src/features/identity/builderlab-account.test.ts +++ b/src/features/identity/builderlab-account.test.ts @@ -1,6 +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 { 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"; @@ -95,6 +96,72 @@ 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 }, + ), + ); + await h.service.signIn(); + const initial = await readFederatedCredential(h.service); + return { + h, + account: (identity: BackupIdentity) => + federatedBuilderlabAccount(initial, h.service, identity), + session: { dispose() {} }, + async replace(nextSubject: string, revision: string) { + subject = nextSubject; + h.publish({ status: "signed-in", revision }); + }, + }; +} + +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 = native.account(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 + 1); + expect(native.h.request.mock.calls.at(-1)?.[0].path).toMatch(/auth\/me$/); + } finally { + native.session.dispose(); + } +}); + +it("refuses a first-run response after the account has been retired", async () => { + const native = await nativeAccountSession(); + try { + const account = native.account(untouched); + let finish!: (response: ReturnType) => void; + native.h.request.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + 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(); + } 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( @@ -530,6 +597,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", @@ -1061,6 +1139,87 @@ 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 = nativeSession.account(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(2); + } 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 = owner.account(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)); @@ -1307,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 85607fca3d..6de6d329e7 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 { 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"; @@ -350,23 +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 }, + async () => ({ federated: (await current()).value }), identity, ); } @@ -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/identity.test.tsx b/src/features/identity/identity.test.tsx index ebd2783710..6d45ea6070 100644 --- a/src/features/identity/identity.test.tsx +++ b/src/features/identity/identity.test.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { requiredFiDiscovery } from "../communities/discovery-fixture"; import "@testing-library/jest-dom/vitest"; import { act, @@ -36,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, @@ -78,6 +80,160 @@ 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") + 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}`); + }); + h.request.mockImplementation((request) => + invoke("federated_identity_request", { request }), + ); +} + +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(); + nativeFirstRunReplies( + h, + () => ({ subject: "account", email: "person@example.com" }), + () => linked, + ); + 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 ? "Continue with this key" : "Get started with Buzz", + }), + ).toBeVisible(); + expect(screen.getByText("Signed in as person@example.com")).toBeVisible(); + if (linked) { + expect(screen.getByRole("button", { name: /Saved key/ })).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( + await screen.findByText(/The browser returned the same account/), + ).toBeVisible(); + expect(h.service.signOut).not.toHaveBeenCalled(); + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Back" })); + expect( + screen.getByRole("heading", { + name: linked ? "Continue with this key" : "Get started with Buzz", + }), + ).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"); @@ -1089,6 +1245,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(); @@ -1118,6 +1275,7 @@ function landingRelay( JSON.stringify({ name: "Buzz Relay", icon: "https://cdn.example.com/icon.png", + ...infoFields, }), ); }); @@ -1265,6 +1423,138 @@ 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, { + ...requiredFiDiscovery, + federated_identity: { + ...requiredFiDiscovery.federated_identity, + core: compatible ? "client-attached" : "unsupported", + identityOrigin: "https://untrusted.invalid", + }, + }); + const h = federatedFixture(); + const identity = createIdentity(); + 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( + +

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: "Use an existing 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) { + const browser = deferred(); + 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 })); + 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 { + 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, { + ...requiredFiDiscovery, + }); + 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(); @@ -6797,3 +7087,465 @@ it.each(["unavailable", "not_offered"] as const)( identity.dispose(); }, ); + +it.each(["cancel", "failure", "same-account", "different-account"])( + "FI first-run account replacement handles %s without global sign-out or key creation", + async (outcome) => { + 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 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: "Get started with Buzz" }), + ).toBeVisible(); + expect( + screen.getByText("Signed in as replacement@example.com"), + ).toBeVisible(); + } else if (outcome === "cancel") { + expect( + await screen.findByRole("heading", { + name: "Continue with this key", + }), + ).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: "Continue with this key" }), + ).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( + h, + () => { + 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: "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; + 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: "Continue with this key" }), + ).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: "Get started with Buzz" }), + ).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( + h, + () => ({ 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: "Continue with this key" }); + 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: "Continue with this key" }); + 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: "Continue with this key" }), + ).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( + h, + () => ({ 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: "Continue with this key" }); + 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: "Get started with Buzz" }), + ).toBeVisible(); + expect( + screen.getByText("Signed in as replacement@example.com"), + ).toBeVisible(); + expect(h.service.signOut).not.toHaveBeenCalled(); + } finally { + read.resolve(); + view.unmount(); + 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(); + } +}); + +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(); + } +});