From 1d5b1b1e8f8278405bda3f883ea0483a7fb94526 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 29 Oct 2026 00:18:59 +1100 Subject: [PATCH] =?UTF-8?q?feat(identity):=20first-run=20Builderlab=20acco?= =?UTF-8?q?unt=20client=20=E2=80=94=20sign-in=20without=20a=20plugin=20ses?= =?UTF-8?q?sion,=20kgoose=20reads,=20link/unlink,=20backup,=20saved=20comm?= =?UTF-8?q?unities=20=E2=80=94=20and=20its=20dev=20seed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All of first run's network logic, no UI. Nothing on main calls it yet; the Welcome flow (PR 07) is the caller. `src/bundled/builderlab/oauth/first-run.ts` (new): binds a `Host` to the Builderlab plugin's own bundled native grant (`block.builderlab`/`bundled`, granted at build time from `BUZZ_BUILDERLAB_URL` and the tenant table) and exposes `firstRunCredential(signal, origin?)`, the plugin's `browserCredential` run before any plugin session exists. That credential is separate from the Settings session and is never persisted. `src/features/identity/builderlab-account.ts` (new): `FirstRunAccount`, the kgoose reads and writes first run needs, every request a POST with `{}` or a body, `X-BB-Session-Credential` from the first-run session and `Origin` set to the login origin that signed the user in, through `bundledHost.request`: - `linkedKey` (`/v1/buzz/nostr-identities/current`): a 2xx with `identity` null or absent is *not linked* (kgoose never sends `MISSING_MAPPING` on this route); a present identity whose `pubkey_hex` is not 64 lowercase hex fails — a linked account must never be given a second key. - `ownedCommunities` (`/v1/buzz/communities/list`, archived and insecure hosts excluded) and `knownCommunities` (`/v1/buzz/known-communities/list`, read with the Builderlab plugin's own `listedAddress`, exported from `known-communities/client.ts` for this; a row this client cannot read rejects the whole list). - `bind(pubkey)`: reads `current` first and returns when the account already holds this key, else challenge → native `identity_sign_builderlab_binding` → verify, which must confirm this key. 409 `identity_already_bound` / `pubkey_already_bound` take the hosted-communities card's copy. - `unbind(old, new)` (`/nostr-identities/delete`): the route takes no key and deletes whatever is bound, so it sends the delete only while `current` still answers the old key, counts as done when the delete echoes the old key or a re-read shows none or the new key, and fails without Try again if the old key is still bound. The real fix is an `expected_pubkey_hex` on the delete, noted in a comment. - `backUp(pubkey)`: refuses with `BackupError("unavailable")` unless `backupOfferedFor(backup, origin)` holds — this build's uploader backs up to Builderlab and the sign-in is at the build's own portal (a tenant's credential is never handed to the native uploader) — then `backUpWith`. - `backupStatus()` (`/v1/buzz/nsec-backups/status`): the listed hex keys; "unavailable" without a request unless offered, and on a 404; other answers map to the backup failure kinds. - `recovery`: the native recovery driver, so the dev seed can replace it. - `saveCommunity(url)` (`/v1/buzz/known-communities/add`, `wss://host[:port]`). - Shared `call`/`post`: transient failures (no status, 429, 5xx) are retryable, 4xx refusals and rejected shapes are not; any HTTP 401 is "Your Builderlab sign-in is no longer valid."; failure copy names the step and status, never a response body; kgoose codes the card's table lacks get first-run copy (`invalid_origin`, `invalid_challenge`, `challenge_*`, `too_many_attempts`; `challenge_expired/consumed/not_found` retryable since Try again fetches a fresh challenge); the correlation ID stays off the page and goes to a development-only `console.warn`. Dev seed (`first-run-seed.ts`, new; `seededAccount`): `BUZZ_DEV_FIRST_RUN_ACCOUNT` — `unlinked`, or `{"npub","communities", "known","backups","recovery","backup":""|"fail-once","unlink":}` — replaces the kgoose reads, the binding, the unlink, the backup and backup recovery, and nothing else, on a dev server. `vite.config.ts` validates it (explicit `.ts` imports so Node's native config loader stays warning-free) and defines `VITE_BUZZ_FIRST_RUN_ACCOUNT` for `serve` only; builds define it empty. Bad values are rejected without being echoed. A developer's real account binding never changes under a seed. Docs: `docs/configuration.md` (the seed row and "First-run account seed (development)" section), `.env.example`, the Builderlab README's first-run paragraph. Tests: `builderlab-account.test.ts` (1,337: every route's exact request at a tenant and at the build URL; linked/not-linked readings; the bind sequences incl. the already-bound no-op and 409 from verify; unbind's current-then-delete with echo and re-read outcomes; backUp's tenant guard and handoff; backupStatus mappings; saveCommunity; the retryability, 401 and correlation-ID rules; the seeded account), `first-run-seed.test.ts` (268: every field, rejections without echo, the seeded backup/unlink/ recovery behaviours), `oauth/first-run.test.ts` (the bundled grant request shape with and without an origin), and the seed lines of `browser-host/vite-config.test.mjs` (serve vs build define, rejections). Stack: PR 05 of the onboarding stack, on 04; imports 02 (`destination`), 03 (`backup`, `backup-failure`, `service`) and 04 (`oauth/browser`). 06–08 build on it. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- .env.example | 14 + browser-host/vite-config.test.mjs | 40 + docs/configuration.md | 56 + src/bundled/builderlab/README.md | 25 + .../builderlab/known-communities/client.ts | 4 +- .../builderlab/oauth/first-run.test.ts | 52 + src/bundled/builderlab/oauth/first-run.ts | 27 + src/features/identity/backup-failure.ts | 7 + .../identity/builderlab-account.test.ts | 1337 +++++++++++++++++ src/features/identity/builderlab-account.ts | 629 ++++++++ src/features/identity/first-run-seed.test.ts | 268 ++++ src/features/identity/first-run-seed.ts | 121 ++ vite.config.ts | 10 + 13 files changed, 2588 insertions(+), 2 deletions(-) create mode 100644 src/bundled/builderlab/oauth/first-run.test.ts create mode 100644 src/bundled/builderlab/oauth/first-run.ts create mode 100644 src/features/identity/builderlab-account.test.ts create mode 100644 src/features/identity/builderlab-account.ts create mode 100644 src/features/identity/first-run-seed.test.ts create mode 100644 src/features/identity/first-run-seed.ts diff --git a/.env.example b/.env.example index dbbe1960cf..98c5d8a91b 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,20 @@ BUZZ_DEV_VIEWER= # Production builds ignore it. # BUZZ_DEV_OPEN_RELAY=1 +# Optional: answer the first-run Builderlab account reads (linked key, owned +# communities, backed-up keys) and fake the key link, unlink, backup and restore +# without kgoose or the native uploader, so the returning-user screens can be +# walked against a real account whose key binding must not change. `unlinked`, +# or a JSON object with optional fields {"npub":"npub1…", +# "communities":["host",…],"known":["host",…],"backups":["npub1…",…], +# "recovery":"nsec1…","backup":""|"fail-once", +# "unlink":}. `known` lists the destinations saved to the account. `recovery` is +# the key every restore recovers: use a throwaway key; without it, restore +# answers that recovery is unavailable. +# Dev server only; production builds ignore it. Restart the dev server after +# changing this. +# BUZZ_DEV_FIRST_RUN_ACCOUNT=unlinked + # Optional compatibility aliases for previously saved community IDs. # Keep the same alias mapped to the same origin to retain existing memberships. # Values are public configuration included in the frontend bundle, never secrets. diff --git a/browser-host/vite-config.test.mjs b/browser-host/vite-config.test.mjs index ab0d68c8d6..34b7d1c36a 100644 --- a/browser-host/vite-config.test.mjs +++ b/browser-host/vite-config.test.mjs @@ -169,6 +169,46 @@ it("loads the broker's Vite config without native-compatibility warnings", () => (error) => /BUZZ_BUILDERLAB_TENANTS must be/.test(error.message) && !error.message.includes('secret'), ); process.env.BUZZ_BUILDERLAB_TENANTS = ''; + // The first-run account seed reaches only a dev server, and a + // malformed value fails at configuration time without being echoed. + for (const command of ['serve', 'build']) { + process.env.BUZZ_DEV_FIRST_RUN_ACCOUNT = 'unlinked'; + const seeded = await loadConfigFromFile( + { command, mode: command === 'serve' ? 'development' : 'production' }, configFile, + ); + assert.equal( + seeded.config.define['import.meta.env.VITE_BUZZ_FIRST_RUN_ACCOUNT'], + JSON.stringify(command === 'serve' ? 'unlinked' : ''), + ); + } + // A backup kind resolves against the failure table, and backups + // and a recovery key decode, through the native loader. + const failingSeed = JSON.stringify({ + backup: 'unreachable', + known: ['design.example.com'], + backups: ['npub1424242424242424242424242424242424242424242424242424qamrcaj'], + recovery: 'nsec1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqstywftw', + }); + process.env.BUZZ_DEV_FIRST_RUN_ACCOUNT = failingSeed; + const failing = await loadConfigFromFile({ command: 'serve', mode: 'development' }, configFile); + assert.equal( + failing.config.define['import.meta.env.VITE_BUZZ_FIRST_RUN_ACCOUNT'], + JSON.stringify(failingSeed), + ); + for (const seed of [ + '{"npub":"s3cret"}', + '{"backup":"s3cret"}', + '{"backups":["s3cret"]}', + '{"recovery":"nsec1s3cret"}', + '{"known":["http://s3cret.example"]}', + ]) { + process.env.BUZZ_DEV_FIRST_RUN_ACCOUNT = seed; + await assert.rejects( + loadConfigFromFile({ command: 'serve', mode: 'development' }, configFile), + (error) => /BUZZ_DEV_FIRST_RUN_ACCOUNT must be/.test(error.message) && !error.message.includes('s3cret'), + ); + } + delete process.env.BUZZ_DEV_FIRST_RUN_ACCOUNT; // Without a viewer pin nothing consumes the seed, so it is neither exposed nor required. process.env.BUZZ_DEV_OPEN_RELAY = '1'; process.env.BUZZ_RELAY_URL = ''; diff --git a/docs/configuration.md b/docs/configuration.md index edd9052459..6c8faed65f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,6 +15,7 @@ not in source. Build defaults are readable binary data, **never secret storage** | `BUZZ_RELAY_URL` | Live development broker's default community, not an agent relay override or a packaged default. | | `BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY` | Presence-only alias for fresh-viewer community selection in live development only. Saved viewer choice wins. | | `BUZZ_DEV_OPEN_RELAY` | Development-specific override of that alias: only `1` enables; `0` explicitly opts out. Requires a relay URL and live viewer pin to have an effect. | +| `BUZZ_DEV_FIRST_RUN_ACCOUNT` | Dev server only: answers the first-run Builderlab account reads (linked key, owned communities, saved communities, backed-up keys) in place of kgoose, and fakes backup recovery. Builds define it empty. See [below](#first-run-account-seed-development). | | `BUZZ_DEV_VIEWER`, `BUZZ_COMMUNITY_ALIASES`, `BUZZ_DEV_NOTIFICATIONS` | Existing public viewer pin, public routing aliases and dev notification override; unchanged. See the [development setup](contributing.md). | | `BUZZ_UPDATER_PUBLIC_KEY`, `BUZZ_UPDATER_ENDPOINT` | Native release build, process environment only: two non-empty values register the updater plugin. The same public key and endpoint must reach `tauri build --config` as `plugins.updater`, and update archives must be signed by the matching private key. Desktop preview release runs supply both for every platform (see [releases](releases.md)); candidate-only and local builds report automatic updates as unavailable. | @@ -136,6 +137,61 @@ whether to connect with Builderlab at `BUZZ_BUILDERLAB_URL` or bring an existing Malformed rows fail the Vite configuration and the native build without echoing the value. The same restart and rebuild rules as `BUZZ_BUILDERLAB_URL` apply. +## First-run account seed (development) + +After Builderlab sign-in, the first-launch flow reads four things about the +account from kgoose: the Nostr key bound to it, the communities it owns, the +destinations saved to it ([known communities](communities.md#known-communities)) +and the keys it holds a backup of ([identity](identity.md)), binds a key it creates or +imports to an unlinked account, and, on Start over, unlinks the lost key +before binding the new one. A developer's real account is bound to their real key and that binding +must not change, so `BUZZ_DEV_FIRST_RUN_ACCOUNT` replaces those reads, the +binding, the unlink, the key backup and backup recovery — and nothing else — +on a dev server: + +```sh +# A brand-new account: the key-setup choice follows sign-in. +BUZZ_DEV_FIRST_RUN_ACCOUNT=unlinked +# A returning user: Welcome back for this npub, with these owned communities. +BUZZ_DEV_FIRST_RUN_ACCOUNT='{"npub":"npub1…","communities":["design.example.com"]}' +# An account with these destinations saved to it (read with the account check; nothing restores them yet). +BUZZ_DEV_FIRST_RUN_ACCOUNT='{"known":["chat.example.com","wss://buzz.example.org:8443"]}' +# A brand-new account whose every backup fails as if Builderlab were unreachable. +BUZZ_DEV_FIRST_RUN_ACCOUNT='{"backup":"unreachable"}' +# A brand-new account whose first backup fails, so Try again then succeeds. +BUZZ_DEV_FIRST_RUN_ACCOUNT='{"backup":"fail-once"}' +# A returning user whose Start over unlink fails as kgoose answering 503 would. +BUZZ_DEV_FIRST_RUN_ACCOUNT='{"npub":"npub1…","unlink":503}' +# A returning user with Restore from backup, which answers that recovery is unavailable. +BUZZ_DEV_FIRST_RUN_ACCOUNT='{"npub":"npub1…","backups":["npub1…"]}' +# The same, where every restore recovers this throwaway key. +BUZZ_DEV_FIRST_RUN_ACCOUNT='{"npub":"npub1…","backups":["npub1…"],"recovery":"nsec1…"}' +``` + +Every field is optional; without `npub` the account is unlinked. `communities` +lists hostnames (or `wss://`/`https://` origins) the way the Community URL field +accepts them; `known` lists the destinations saved to the account the same way, +answered as kgoose spells them (`wss://host[:port]`). `backup` names a failure kind from +`src/features/identity/backup-failure.ts` (such as `unreachable`, `timeout` or +`refused`), and every backup then fails with that kind's page; `fail-once` +fails only the first backup after each sign-in, as Builderlab not confirming +it, so Try again succeeds. `unlink` is an +HTTP error status (400–599), and every unlink then fails as kgoose answering it +would: a 429 or 5xx offers Try again, and a 401 reads as a lapsed sign-in. `backups` lists the npubs backup status answers +(none when absent), so Welcome back offers Restore from backup when it holds +`npub`. `recovery` is the `nsec1…` every restore recovers, which installing +imports as if it were typed; without it every restore answers that recovery is +unavailable, as every tenant does today. The value is plain text in your +environment, so use a throwaway key, never a real one. Vite exposes the value +as `VITE_BUZZ_FIRST_RUN_ACCOUNT` for `serve` only; `pnpm build` and +`pnpm tauri build` define it empty, so packaged apps always ask kgoose. A +malformed value fails the Vite configuration without being echoed. Restart the +dev server after changing it. With a seed, binding, unlinking and backup +succeed (unless `unlink` or `backup` is set), and a joined community's save to +the account succeeds, without a request, a native signature or a native backup +command, in every build. +Restore opens no browser and runs no native recovery command. + ## Whole-video limit The desktop app reads relay video from its origin one bounded `Range` at a diff --git a/src/bundled/builderlab/README.md b/src/bundled/builderlab/README.md index 89ef009835..806ee812d3 100644 --- a/src/bundled/builderlab/README.md +++ b/src/bundled/builderlab/README.md @@ -107,6 +107,31 @@ agents are not migrated into this type. plugin-lifetime session. `browserCredential()` returns a verified `Credential`; `session.credential()` provides access to it. The session handles cancellation, sign-out, and disposal. + `oauth/first-run.ts` lets the app's first-launch welcome screen run the same + sign-in before any plugin session exists, using this plugin's bundled native + grant; that credential is separate from the Settings session. When a community + URL on that screen names another Builderlab deployment (the + [`BUZZ_BUILDERLAB_TENANTS` table](../../../docs/configuration.md#builderlab-tenant-table-build-input)), + the welcome screen passes that deployment's origin and `browserCredential` + runs the whole round-trip there; the Settings card always uses the build URL, + and so does the sign-in that screen offers for a community no tenant hosts. + A successful first-run sign-in continues into the app-owned setup steps + described in [identity](../../../docs/identity.md): the app reads the key + bound to the account, the destinations saved to it (this plugin's + known-communities list route, at the deployment that signed the user in, + never through the Settings session's client) and the communities it owns + through this plugin's bundled grant + (`src/features/identity/builderlab-account.ts`, reusing the + hosted-communities routes and parsers); a linked account is asked for that + key, otherwise the user creates a key or brings one they already have, and + that key is then bound to the account through the same native signer at that + deployment and backed up (an imported key only once the user agrees); + starting over creates a new key and unlinks the lost one before binding it. + Key backup hands the sign-in to the native uploader only for the build's own + portal (anywhere else the step reports backup unavailable), which is also + the only portal asked which keys the account holds a backup of; Welcome back + restores the linked key from one through native recovery's own browser + sign-in. - `login/` owns the login experience: pending state, cancellation, retry, email display, and sign-out. - `agents/` lists the signed-in account's remote agents beneath login, with diff --git a/src/bundled/builderlab/known-communities/client.ts b/src/bundled/builderlab/known-communities/client.ts index 8de482e5f4..bc04e8f855 100644 --- a/src/bundled/builderlab/known-communities/client.ts +++ b/src/bundled/builderlab/known-communities/client.ts @@ -57,7 +57,7 @@ function unexpected(status: number): Refusal { /** A listed row's destination. `null` is a row whose address this device could * never save (dropped on its own), `undefined` a row of a shape this client * does not understand, which makes the whole response invalid. */ -function address(row: unknown): string | null | undefined { +export function listedAddress(row: unknown): string | null | undefined { if (!row || typeof row !== "object") return undefined; const { community_url } = row as Record; if (typeof community_url !== "string") return undefined; @@ -161,7 +161,7 @@ export function createKnownCommunitiesClient( // Protobuf JSON can omit an empty repeated field. const rows = value.communities ?? []; if (!Array.isArray(rows)) return { kind: "invalid_response" }; - const addresses = rows.map(address); + const addresses = rows.map(listedAddress); if (addresses.includes(undefined)) return { kind: "invalid_response" }; const communities = addresses.filter((a): a is string => a !== null); return { diff --git a/src/bundled/builderlab/oauth/first-run.test.ts b/src/bundled/builderlab/oauth/first-run.test.ts new file mode 100644 index 0000000000..38b2395c23 --- /dev/null +++ b/src/bundled/builderlab/oauth/first-run.test.ts @@ -0,0 +1,52 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; +import { bundledHost, firstRunCredential } from "./first-run"; +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), + isTauri: vi.fn(() => true), +})); +afterEach(() => { + vi.resetAllMocks(); + vi.unstubAllEnvs(); +}); + +it("requests under the bundled Builderlab grant without a plugin owner", async () => { + vi.mocked(invoke).mockResolvedValueOnce({ + status: 200, + headers: {}, + body: "{}", + }); + const request = { url: "https://app.builderlab.xyz/api/goose/v1/auth/me" }; + await bundledHost.request(request); + expect(invoke).toHaveBeenCalledWith("plugin_host_request", { + id: "block.builderlab", + revision: "bundled", + request, + }); + expect(await bundledHost.runCommand("any")).toBeNull(); +}); + +it.each([ + ["the configured build URL", undefined, "https://builderlab.example"], + ["a tenant origin", "https://tenant.example", "https://tenant.example"], +])( + "starts the native browser handoff at %s", + async (_label, origin, expected) => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); + // A refused listener ends the attempt before any exchange request. + vi.mocked(invoke).mockRejectedValueOnce("listener unavailable"); + await expect( + firstRunCredential(new AbortController().signal, origin), + ).rejects.toThrow("Could not complete"); + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledWith( + "oauth_callback_begin", + expect.objectContaining({ + authorizationUrl: expect.stringMatching( + new RegExp(`^${expected}/api/goose/v1/auth/login\\?`), + ), + useState: true, + }), + ); + }, +); diff --git a/src/bundled/builderlab/oauth/first-run.ts b/src/bundled/builderlab/oauth/first-run.ts new file mode 100644 index 0000000000..eec5fedbf5 --- /dev/null +++ b/src/bundled/builderlab/oauth/first-run.ts @@ -0,0 +1,27 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { Host } from "../../../features/host/service"; +import manifest from "../manifest.json"; +import { browserCredential, type Credential, nativeBridge } from "./browser"; + +/** + * First launch runs before this plugin can share its Settings session, so the + * welcome screen signs in through the same OAuth client under the bundled + * plugin's own native network grant. Native code grants that origin at build + * time; it does not depend on the plugin's JavaScript having started. + */ +export const bundledHost: Host = { + runCommand: () => Promise.resolve(null), + request: (request) => + invoke("plugin_host_request", { + id: manifest.id, + revision: "bundled", + request, + }), +}; + +/** `origin`: a tenant's portal chosen from the community URL; default otherwise. */ +export const firstRunCredential = ( + signal: AbortSignal, + origin?: string, +): Promise => + browserCredential(bundledHost, signal, nativeBridge, origin); diff --git a/src/features/identity/backup-failure.ts b/src/features/identity/backup-failure.ts index adfe9c98f9..995119d7cf 100644 --- a/src/features/identity/backup-failure.ts +++ b/src/features/identity/backup-failure.ts @@ -7,6 +7,8 @@ const retry = (cause: string, then = "Try again.") => * and whether Try again can help. Native code names its own kinds and retries * them all on its next wake; `unavailable`, `interrupted`, `timeout` and * `unknown` are the renderer's. No message carries a service or IPC answer. + * A leaf module: vite.config.ts validates the development seed against it + * through Node's native loader. */ export const BACKUP_FAILURES = { unavailable: { @@ -59,3 +61,8 @@ export const BACKUP_FAILURES = { >; export type BackupFailureKind = keyof typeof BACKUP_FAILURES; + +export const isBackupFailureKind = ( + value: unknown, +): value is BackupFailureKind => + typeof value === "string" && Object.hasOwn(BACKUP_FAILURES, value); diff --git a/src/features/identity/builderlab-account.test.ts b/src/features/identity/builderlab-account.test.ts new file mode 100644 index 0000000000..68f3d52c6d --- /dev/null +++ b/src/features/identity/builderlab-account.test.ts @@ -0,0 +1,1337 @@ +import { invoke } from "@tauri-apps/api/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Credential } from "../../bundled/builderlab/oauth/browser"; +import type { HostRequest } from "../host/service"; +import { BackupError, type BackupStatus, createBackupStatus } from "./backup"; +import { BACKUP_FAILURES } from "./backup-failure"; +import { + backupOfferedFor, + builderlabAccount, + seededAccount, +} from "./builderlab-account"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), + Channel: class { + constructor(public onmessage: (response: unknown) => void) {} + }, +})); + +const credential: Credential = { + value: "private-token", + account: { + subject: "user", + email: "a@example.com", + canUseRemoteAgents: false, + }, +}; +const pubkey = "ab".repeat(32); +const answer = (status: number, body: unknown) => ({ + status, + headers: {}, + body: typeof body === "string" ? body : JSON.stringify(body), +}); +/** kgoose puts one on every identity and community route reply, error or not. */ +const correlationId = "3f2a9c1e-5b7d-4e8f-9a0b-1c2d3e4f5a6b"; +/** kgoose's error reply: its code, its own message and the correlation ID. */ +const refusal = (status: number, code: string) => + answer(status, { + error: { code, message: "kgoose's own wording" }, + correlation_id: correlationId, + }); +/** misk's answer to a lapsed session on any `@Authenticated` route. */ +const lapsed = answer(401, "Unauthenticated"); +const signedOut = "Your Builderlab sign-in is no longer valid."; +/** One request stub per call, in order; remembers what was sent. */ +function kgoose(...replies: Array | Error | string>) { + const sent: HostRequest[] = []; + const request = vi.fn(async (input: HostRequest) => { + sent.push(input); + const reply = replies.shift(); + if (reply === undefined) throw new Error("No reply scripted"); + if (reply instanceof Error || typeof reply === "string") throw reply; + return reply; + }); + return { request, sent }; +} +type BackupIdentity = Parameters[3]; +/** Only Restore from backup drives native recovery. */ +const noRecovery = { + subscribe: () => { + throw new Error("Watched the identity"); + }, + snapshot: () => { + throw new Error("Read the identity"); + }, + beginRecovery: () => { + throw new Error("Began recovery"); + }, + installRecovery: () => { + throw new Error("Installed a recovered key"); + }, + cancelRecovery: () => { + throw new Error("Canceled recovery"); + }, +} satisfies Omit; +/** The reads and binding never touch the backup status or consent. */ +const untouched: BackupIdentity = { + ...noRecovery, + backup: createBackupStatus(() => { + throw new Error("Read the backup status"); + }), + setBackupChoice: () => { + throw new Error("Wrote backup consent"); + }, +}; +afterEach(() => { + vi.unstubAllEnvs(); + vi.mocked(invoke).mockReset(); +}); + +describe("linkedKey", () => { + it("POSTs the card's identity route at the tenant with the session credential and reads the bound key", async () => { + const { request, sent } = kgoose( + answer(200, { + identity: { npub: "npub1x", pubkey_hex: pubkey }, + correlation_id: correlationId, + }), + ); + const account = builderlabAccount( + credential, + "https://tenant.example", + request, + untouched, + ); + expect(await account.linkedKey()).toBe(pubkey); + expect(sent).toEqual([ + { + url: "https://tenant.example/api/goose/v1/buzz/nostr-identities/current", + method: "POST", + headers: { + "Content-Type": "application/json", + "X-BB-Session-Credential": "private-token", + Origin: "https://tenant.example", + }, + body: "{}", + }, + ]); + }); + + it("uses the build URL when no tenant was chosen", async () => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example/"); + const { request, sent } = kgoose( + answer(200, { identity: { pubkey_hex: pubkey } }), + ); + await builderlabAccount( + credential, + undefined, + request, + untouched, + ).linkedKey(); + expect(sent[0]?.url).toBe( + "https://builderlab.example/api/goose/v1/buzz/nostr-identities/current", + ); + expect(sent[0]?.headers?.Origin).toBe("https://builderlab.example"); + }); + + // kgoose's `current` answers 200 with `identity` null or absent for an + // account with no mapping; MISSING_MAPPING is the list route's shape, kept + // for symmetry. + it.each([ + [ + "a 200 with identity null", + answer(200, { identity: null, correlation_id: correlationId }), + ], + ["a 200 with no identity", answer(200, { correlation_id: correlationId })], + [ + "a 400 MISSING_MAPPING", + answer(400, { error: { code: "MISSING_MAPPING" } }), + ], + [ + "a 400 setup_needed", + answer(400, { error: { code: "other", setup_needed: true } }), + ], + ])("reads %s as not linked", async (_label, reply) => { + const { request } = kgoose(reply); + const account = builderlabAccount( + credential, + "https://t.example", + request, + untouched, + ); + expect(await account.linkedKey()).toBeNull(); + }); + + it.each([ + [ + "an account without the USER role", + refusal(403, "unauthorized"), + "This Builderlab account can't manage Buzz identities right now. Try signing in again.", + ], + ["a lapsed session", lapsed, signedOut], + [ + "a bound identity without a usable key", + answer(200, { identity: { npub: "npub1x" } }), + "Builderlab account check failed (HTTP 200: unusable linked key).", + ], + [ + "a bound identity with a malformed key", + answer(200, { identity: { pubkey_hex: "not-hex" } }), + "Builderlab account check failed (HTTP 200: unusable linked key).", + ], + [ + "a non-JSON failure", + answer(403, "Forbidden"), + "Builderlab account check failed (HTTP 403: not JSON).", + ], + [ + "an unstructured failure", + answer(500, { message: "boom" }), + "Builderlab account check failed (HTTP 500).", + ], + [ + "a malformed error code", + answer(400, { error: { code: 42 } }), + "Could not check this Builderlab account.", + ], + [ + "an oversize body", + answer(200, `{"identity":{"pubkey_hex":"${"a".repeat(70_000)}"}}`), + "Builderlab account check failed (HTTP 200: response too large).", + ], + ["a native refusal string", "host request denied", /host request denied/], + ["a transport error", new Error("offline"), /offline/], + ])("fails on %s instead of answering", async (_label, reply, message) => { + const { request } = kgoose(reply); + const account = builderlabAccount( + credential, + "https://t.example", + request, + untouched, + ); + await expect(account.linkedKey()).rejects.toThrow(message); + }); + + it("warns in development with the route and status, never the body", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { request } = kgoose(answer(403, "secret body")); + const account = builderlabAccount( + credential, + "https://t.example", + request, + untouched, + ); + await expect(account.linkedKey()).rejects.toThrow(/HTTP 403/); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.join(" ")).toBe( + "Builderlab account check: POST /v1/buzz/nostr-identities/current answered HTTP 403 Builderlab account check failed (HTTP 403: not JSON).", + ); + expect(warn.mock.calls[0]?.join(" ")).not.toContain("secret body"); + }); +}); + +describe("ownedCommunities", () => { + it("lists joinable hosts from the card's list route, skipping archived and unusable rows", async () => { + const { request, sent } = kgoose( + answer(200, { + communities: [ + { id: "1", normalized_host: "wss://Design.Example.com" }, + { id: "2", normalized_host: "buzz.example.org:8443" }, + { + id: "3", + normalized_host: "old.example.org", + archived_at: "2026-01-01T00:00:00Z", + }, + { id: "4", normalized_host: "" }, + { id: "5", normalized_host: "bad host/with/path" }, + { id: "6" }, + ], + }), + ); + const account = builderlabAccount( + credential, + "https://t.example", + request, + untouched, + ); + expect(await account.ownedCommunities()).toEqual([ + { host: "design.example.com", url: "https://design.example.com" }, + { host: "buzz.example.org:8443", url: "https://buzz.example.org:8443" }, + ]); + expect(sent[0]?.url).toBe( + "https://t.example/api/goose/v1/buzz/communities/list", + ); + }); + + it("answers an unlinked account with no communities and fails on other errors", async () => { + const unlinked = builderlabAccount( + credential, + "https://t.example", + kgoose(answer(400, { error: { code: "missing_mapping" } })).request, + untouched, + ); + expect(await unlinked.ownedCommunities()).toEqual([]); + const failing = builderlabAccount( + credential, + "https://t.example", + kgoose(answer(503, { error: { code: "relay_unavailable" } })).request, + untouched, + ); + await expect(failing.ownedCommunities()).rejects.toThrow( + /temporarily unavailable/, + ); + const unparseable = builderlabAccount( + credential, + "https://t.example", + kgoose(answer(502, "bad gateway")).request, + untouched, + ); + await expect(unparseable.ownedCommunities()).rejects.toThrow( + "Builderlab community list failed (HTTP 502: not JSON).", + ); + }); +}); + +describe("knownCommunities", () => { + it("POSTs the plugin's list route at the tenant with the session credential and reads the saved destinations in the device's spelling", async () => { + const { request, sent } = kgoose( + answer(200, { + communities: [ + { community_url: "wss://Design.Example.com/" }, + { community_url: "wss://buzz.example.org:8443" }, + // Rows this device could never save are dropped, as the sync drops them. + { community_url: "ws://plain.example" }, + { community_url: "wss://bad host" }, + { community_url: "wss://with.example/path" }, + ], + }), + ); + const account = builderlabAccount( + credential, + "https://tenant.example", + request, + untouched, + ); + expect(await account.knownCommunities()).toEqual([ + "wss://design.example.com", + "wss://buzz.example.org:8443", + ]); + expect(sent).toEqual([ + { + url: "https://tenant.example/api/goose/v1/buzz/known-communities/list", + method: "POST", + headers: { + "Content-Type": "application/json", + "X-BB-Session-Credential": "private-token", + Origin: "https://tenant.example", + }, + body: "{}", + }, + ]); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("uses the build URL when no tenant was chosen, and reads an omitted list as empty", async () => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example/"); + const { request, sent } = kgoose(answer(200, {})); + expect( + await builderlabAccount( + credential, + undefined, + request, + untouched, + ).knownCommunities(), + ).toEqual([]); + expect(sent[0]?.url).toBe( + "https://builderlab.example/api/goose/v1/buzz/known-communities/list", + ); + expect(sent[0]?.headers?.Origin).toBe("https://builderlab.example"); + }); + + // The route's refusals are plain strings, not the card's error objects, and + // a portal without the route answers a plain-text 404. + it.each([ + [ + "a 403 forbidden", + answer(403, { error: "forbidden" }), + "Could not load the communities saved to this account.", + false, + ], + [ + "a plain-text 404", + answer(404, "Not Found"), + "Builderlab known communities failed (HTTP 404: not JSON).", + false, + ], + [ + "a 200 that is not JSON", + answer(200, "block page"), + "Builderlab known communities failed (HTTP 200: not JSON).", + false, + ], + [ + "a 200 with a row this client cannot read", + answer(200, { communities: [{ community_url: 1 }] }), + "Builderlab known communities failed (HTTP 200: unreadable row).", + false, + ], + [ + "a 200 whose list is not a list", + answer(200, { communities: "wss://a.example" }), + "Builderlab known communities failed (HTTP 200: unreadable row).", + false, + ], + [ + "a transport failure", + "Host request failed: connection reset", + "Host request failed: connection reset", + true, + ], + ])("rejects %s", async (_label, reply, message, retryable) => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const { request, sent } = kgoose(reply); + const failed: unknown = await builderlabAccount( + credential, + "https://t.example", + request, + untouched, + ) + .knownCommunities() + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe(message); + expect((failed as { retryable?: unknown }).retryable === true).toBe( + retryable, + ); + expect(sent).toHaveLength(1); + expect(invoke).not.toHaveBeenCalled(); + }); +}); + +describe("saveCommunity", () => { + const add = (origin: string) => ({ + url: `${origin}/api/goose/v1/buzz/known-communities/add`, + method: "POST", + headers: { + "Content-Type": "application/json", + "X-BB-Session-Credential": "private-token", + Origin: origin, + }, + body: JSON.stringify({ community_url: "wss://relay.example.com:8443" }), + }); + + it("POSTs the plugin's add route at the tenant with the session credential and the address in the device's spelling", async () => { + const { request, sent } = kgoose(answer(200, {})); + await builderlabAccount( + credential, + "https://tenant.example", + request, + untouched, + ).saveCommunity("https://relay.example.com:8443"); + expect(sent).toEqual([add("https://tenant.example")]); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("uses the build URL when no tenant was chosen", async () => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example/"); + const { request, sent } = kgoose(answer(200, {})); + await builderlabAccount( + credential, + undefined, + request, + untouched, + ).saveCommunity("https://relay.example.com:8443"); + expect(sent).toEqual([add("https://builderlab.example")]); + }); + + it.each([ + [ + "a 422 limit_reached", + answer(422, { error: "limit_reached" }), + "Could not save this community to your Builderlab account.", + false, + ], + [ + "a plain-text 422", + answer(422, "Unprocessable Entity"), + "Builderlab community save failed (HTTP 422: not JSON).", + false, + ], + [ + "a transport failure", + "Host request failed: connection reset", + "Host request failed: connection reset", + true, + ], + ])("rejects %s", async (_label, reply, message, retryable) => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const { request, sent } = kgoose(reply); + const failed: unknown = await builderlabAccount( + credential, + "https://t.example", + request, + untouched, + ) + .saveCommunity("https://relay.example.com:8443") + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe(message); + expect((failed as { retryable?: unknown }).retryable === true).toBe( + retryable, + ); + expect(sent).toHaveLength(1); + }); + + it("resolves without a request for the development seed", async () => { + await expect( + seededAccount( + { linked: null, communities: [] }, + { + importKey: () => { + throw new Error("Imported a key"); + }, + snapshot: () => { + throw new Error("Read the identity"); + }, + }, + ).saveCommunity("https://relay.example.com:8443"), + ).resolves.toBeUndefined(); + expect(invoke).not.toHaveBeenCalled(); + }); +}); + +describe("bind", () => { + const challenge = { + challenge_id: "550e8400-e29b-41d4-a716-446655440000", + nonce: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234_-7", + verification_code: "123456", + origin: "https://tenant.example", + expires_at: "2999-01-01T00:00:00Z", + }; + const event = { id: "e".repeat(64), kind: 24243, sig: "s".repeat(128) }; + const unbound = answer(200, { + identity: null, + correlation_id: correlationId, + }); + const bound = (key: string) => + answer(200, { + identity: { pubkey_hex: key }, + correlation_id: correlationId, + }); + const at = (origin: string) => ({ + method: "POST", + headers: { + "Content-Type": "application/json", + "X-BB-Session-Credential": "private-token", + Origin: origin, + }, + }); + + it("reads the bound key, then challenges at the tenant, signs natively and verifies with the signed event", async () => { + vi.mocked(invoke).mockResolvedValue(event); + const { request, sent } = kgoose( + unbound, + // Only the five challenge fields reach the native signer. + answer(200, { ...challenge, created_at: "2026-01-01T00:00:00Z" }), + bound(pubkey), + ); + const account = builderlabAccount( + credential, + "https://tenant.example", + request, + untouched, + ); + await account.bind(pubkey); + const api = "https://tenant.example/api/goose/v1/buzz/nostr-identities"; + expect(sent).toEqual([ + { url: `${api}/current`, ...at("https://tenant.example"), body: "{}" }, + { + url: `${api}/challenge`, + ...at("https://tenant.example"), + body: JSON.stringify({ origin: "https://tenant.example" }), + }, + { + url: `${api}/verify`, + ...at("https://tenant.example"), + body: JSON.stringify({ + challenge_id: challenge.challenge_id, + nonce: challenge.nonce, + signed_payload: JSON.stringify(event), + }), + }, + ]); + expect(vi.mocked(invoke).mock.calls).toEqual([ + ["identity_sign_builderlab_binding", { challenge }], + ]); + }); + + it("challenges for the build URL's origin when no tenant was chosen", async () => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example/"); + vi.mocked(invoke).mockResolvedValue(event); + const { request, sent } = kgoose( + unbound, + answer(200, { ...challenge, origin: "https://builderlab.example" }), + bound(pubkey), + ); + await builderlabAccount(credential, undefined, request, untouched).bind( + pubkey, + ); + expect(sent.map(({ url }) => url)).toEqual( + ["current", "challenge", "verify"].map( + (path) => + `https://builderlab.example/api/goose/v1/buzz/nostr-identities/${path}`, + ), + ); + expect(sent.map(({ headers }) => headers?.Origin)).toEqual( + Array(3).fill("https://builderlab.example"), + ); + expect(sent[1]?.body).toBe( + JSON.stringify({ origin: "https://builderlab.example" }), + ); + }); + + it("sends nothing more when the account already holds this key", async () => { + const { request, sent } = kgoose(bound(pubkey)); + await builderlabAccount( + credential, + "https://t.example", + request, + untouched, + ).bind(pubkey); + 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", + [answer(200, challenge), refusal(409, "identity_already_bound")], + "This Builderlab account is connected to another Buzz identity.", + ], + [ + "a verify for a key bound to another account", + [answer(200, challenge), refusal(409, "pubkey_already_bound")], + "This Buzz identity is connected to another Builderlab account.", + ], + // kgoose's codes the card lacks. + [ + "a challenge refused for its origin", + [refusal(403, "invalid_origin")], + "Builderlab didn’t accept this request from Buzz.", + ], + [ + "a challenge refused for too many open challenges", + [refusal(429, "too_many_attempts")], + "Builderlab has had too many attempts to link a key. Wait a few minutes, then try again.", + ], + [ + "a non-JSON challenge answer", + [answer(502, "bad gateway")], + "Builderlab account link failed (HTTP 502: not JSON).", + ], + [ + "a non-JSON verify answer", + [answer(200, challenge), answer(403, "Forbidden")], + "Builderlab account link failed (HTTP 403: not JSON).", + ], + [ + "a verify that confirms another key", + [answer(200, challenge), bound("cd".repeat(32))], + "Builderlab account link failed (HTTP 200: link not confirmed).", + ], + [ + "a verify that confirms no key", + [answer(200, challenge), answer(200, {})], + "Builderlab account link failed (HTTP 200: link not confirmed).", + ], + ])( + "fails on %s with the card's or the step's copy", + async (_label, replies, message) => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.mocked(invoke).mockResolvedValue(event); + const { request, sent } = kgoose(unbound, ...replies); + const account = builderlabAccount( + credential, + "https://t.example", + request, + untouched, + ); + const failed = await account.bind(pubkey).catch((error: Error) => error); + // Never kgoose's own message or its correlation ID. + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe(message); + expect(sent).toHaveLength(1 + replies.length); + // A refused challenge is never signed; an answered one is, before verify. + expect(vi.mocked(invoke).mock.calls).toEqual( + Array(replies.length - 1).fill([ + "identity_sign_builderlab_binding", + { challenge }, + ]), + ); + }, + ); + + // kgoose's statuses (`NostrIdentityService.verify`). A code about one + // challenge may clear, since Try again asks for a fresh one; a bad signature + // or a skewed `created_at` (invalid_challenge) does not. + it.each([ + [ + "invalid_challenge", + 400, + "Builderlab couldn’t verify your key’s signature, so the key wasn’t linked.", + false, + ], + [ + "challenge_not_found", + 404, + "Builderlab couldn’t find the request to link your key, so the key wasn’t linked.", + true, + ], + [ + "challenge_expired", + 400, + "The request to link your key expired before Builderlab confirmed it.", + true, + ], + [ + "challenge_consumed", + 409, + "The request to link your key was already used.", + true, + ], + [ + "too_many_attempts", + 429, + "Builderlab has had too many attempts to link a key. Wait a few minutes, then try again.", + true, + ], + [ + "identity_already_bound", + 409, + "This Builderlab account is connected to another Buzz identity.", + false, + ], + [ + "pubkey_already_bound", + 409, + "This Buzz identity is connected to another Builderlab account.", + false, + ], + // Defensive, not a kgoose reply: its identity routes never send `unknown`, + // which the card reads as its deletion service's. + [ + "unknown", + 400, + "Could not link your key to this Builderlab account.", + false, + ], + [ + "a_code_nobody_knows", + 400, + "Could not link your key to this Builderlab account.", + false, + ], + ] as const)( + "fails a verify answering %s (HTTP %i) with first run's copy, never kgoose's, and retryable only if it may clear", + async (code, status, message, retryable) => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.mocked(invoke).mockResolvedValue(event); + const { request } = kgoose( + unbound, + answer(200, challenge), + refusal(status, code), + ); + const failed: unknown = await builderlabAccount( + credential, + "https://t.example", + request, + untouched, + ) + .bind(pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe(message); + expect((failed as { retryable?: unknown }).retryable === true).toBe( + retryable, + ); + }, + ); + + it("keeps kgoose's correlation ID off the message, on the error and in the development log", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.mocked(invoke).mockResolvedValue(event); + for (const [code, status, message] of [ + [ + "challenge_expired", + 400, + "The request to link your key expired before Builderlab confirmed it.", + ], + // The card's own copy, whose `ApiFailure` would append the ID. + [ + "pubkey_already_bound", + 409, + "This Buzz identity is connected to another Builderlab account.", + ], + ] as const) { + warn.mockClear(); + const failed: unknown = await builderlabAccount( + credential, + "https://t.example", + kgoose(unbound, answer(200, challenge), refusal(status, code)).request, + untouched, + ) + .bind(pubkey) + .catch((error: unknown) => error); + expect((failed as Error).message).toBe(message); + expect((failed as Error).message).not.toContain(correlationId); + expect(failed).toMatchObject({ correlationId }); + expect(warn.mock.calls.map((call) => call.join(" "))).toEqual([ + `Builderlab account link: POST /v1/buzz/nostr-identities/verify answered HTTP ${status} (correlation ID ${correlationId}) ${message}`, + ]); + } + }); + + it.each([ + ["the bound-key read", [lapsed]], + ["the challenge", [unbound, lapsed]], + ["the verify", [unbound, answer(200, challenge), lapsed]], + ])( + "reads a lapsed session's plain-text 401 on %s as an ended sign-in, without Try again", + async (_label, replies) => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.mocked(invoke).mockResolvedValue(event); + const failed: unknown = await builderlabAccount( + credential, + "https://t.example", + kgoose(...replies).request, + untouched, + ) + .bind(pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe(signedOut); + expect((failed as { retryable?: unknown }).retryable).toBeUndefined(); + }, + ); + + it("surfaces the native signer's refusal and never verifies", async () => { + vi.mocked(invoke).mockRejectedValue("Invalid Nostr identity challenge"); + const { request, sent } = kgoose(unbound, answer(200, challenge)); + const account = builderlabAccount( + credential, + "https://t.example", + request, + untouched, + ); + await expect(account.bind(pubkey)).rejects.toThrow( + "Invalid Nostr identity challenge", + ); + expect(sent).toHaveLength(2); + }); + + it.each([ + ["a gateway failure", [answer(502, "bad gateway")], true], + ["a transport error", [new Error("offline")], true], + ["a rate limit", [refusal(429, "too_many_attempts")], true], + [ + "a refused link", + [answer(200, challenge), refusal(409, "identity_already_bound")], + false, + ], + ["a refused origin", [refusal(403, "invalid_origin")], false], + ])( + "lets Try again repeat %s only if it may clear", + async (_label, replies, retryable) => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.mocked(invoke).mockResolvedValue(event); + const failed: unknown = await builderlabAccount( + credential, + "https://t.example", + kgoose(unbound, ...replies).request, + untouched, + ) + .bind(pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as { retryable?: unknown }).retryable === true).toBe( + retryable, + ); + expect(vi.mocked(invoke).mock.calls).toEqual( + Array(replies.length - 1).fill([ + "identity_sign_builderlab_binding", + { challenge }, + ]), + ); + }, + ); +}); + +describe("unbind", () => { + const old = "cd".repeat(32); + const bound = (key: string) => + answer(200, { + identity: { pubkey_hex: key }, + correlation_id: correlationId, + }); + const unbound = answer(200, { + identity: null, + correlation_id: correlationId, + }); + const at = (origin: string) => ({ + method: "POST", + headers: { + "Content-Type": "application/json", + "X-BB-Session-Credential": "private-token", + Origin: origin, + }, + body: "{}", + }); + + it("reads the bound key, then deletes the old key's binding at the tenant with the session credential and Origin", async () => { + const { request, sent } = kgoose(bound(old), bound(old)); + await builderlabAccount( + credential, + "https://tenant.example", + request, + untouched, + ).unbind(old, pubkey); + const api = "https://tenant.example/api/goose/v1/buzz/nostr-identities"; + expect(sent).toEqual([ + { url: `${api}/current`, ...at("https://tenant.example") }, + { url: `${api}/delete`, ...at("https://tenant.example") }, + ]); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("deletes at the build URL's origin when no tenant was chosen", async () => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example/"); + const { request, sent } = kgoose(bound(old), bound(old)); + await builderlabAccount(credential, undefined, request, untouched).unbind( + old, + pubkey, + ); + expect(sent[1]).toEqual({ + url: "https://builderlab.example/api/goose/v1/buzz/nostr-identities/delete", + ...at("https://builderlab.example"), + }); + }); + + it.each([ + ["no key", unbound], + ["the new key, after a re-run", bound(pubkey)], + ])("sends nothing more when the account holds %s", async (_label, reply) => { + const { request, sent } = kgoose(reply); + await builderlabAccount( + credential, + "https://t.example", + request, + untouched, + ).unbind(old, pubkey); + expect(sent.map(({ url }) => url)).toEqual([ + "https://t.example/api/goose/v1/buzz/nostr-identities/current", + ]); + }); + + it("fails without a delete when the account holds a third key", async () => { + const { request, sent } = kgoose(bound("12".repeat(32))); + const failed: unknown = await builderlabAccount( + credential, + "https://t.example", + request, + untouched, + ) + .unbind(old, pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe( + "Your Builderlab account is linked to a different key, so your new key can’t be linked.", + ); + expect((failed as { retryable?: unknown }).retryable).toBeUndefined(); + expect(sent.map(({ url }) => url)).toEqual([ + "https://t.example/api/goose/v1/buzz/nostr-identities/current", + ]); + }); + + // kgoose answers 200 with no `identity` when nothing was bound, and also + // when the session or its authorization lapsed, removing nothing. + const deletes = [ + ["no identity", answer(200, { correlation_id: correlationId })], + ["identity null", unbound], + ["another key", bound("ef".repeat(32))], + ] as const; + + it.each( + deletes.flatMap(([deleted, reply]) => [ + [deleted, "no key", reply, unbound], + [deleted, "the new key", reply, bound(pubkey)], + ]), + )( + "succeeds when the delete answers %s and the account then holds %s", + async (_deleted, _now, reply, now) => { + const { request, sent } = kgoose(bound(old), reply, now); + await expect( + builderlabAccount( + credential, + "https://t.example", + request, + untouched, + ).unbind(old, pubkey), + ).resolves.toBeUndefined(); + expect(sent.map(({ url }) => url.split("/").at(-1))).toEqual([ + "current", + "delete", + "current", + ]); + }, + ); + + it.each( + deletes.flatMap(([deleted, reply]) => [ + [ + deleted, + "the old key", + reply, + bound(old), + "Builderlab didn’t unlink your old key, so your new key can’t be linked. Your Builderlab sign-in may have ended.", + ], + [ + deleted, + "a third key", + reply, + bound("12".repeat(32)), + "Your Builderlab account is linked to a different key, so your new key can’t be linked.", + ], + ]), + )( + "fails for good when the delete answers %s and the account still holds %s", + async (_deleted, _now, reply, now, message) => { + const failed: unknown = await builderlabAccount( + credential, + "https://t.example", + kgoose(bound(old), reply, now).request, + untouched, + ) + .unbind(old, pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe(message); + expect((failed as { retryable?: unknown }).retryable).toBeUndefined(); + }, + ); + + it("fails like the account check when the read after a delete fails", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const failed: unknown = await builderlabAccount( + credential, + "https://t.example", + kgoose(bound(old), unbound, new Error("offline")).request, + untouched, + ) + .unbind(old, pubkey) + .catch((error: unknown) => error); + expect((failed as Error).message).toBe("offline"); + expect((failed as { retryable?: unknown }).retryable).toBe(true); + }); + + // kgoose's delete answers 200 or a 403 `invalid_origin`; its + // `@Authenticated` gate answers a lapsed session with misk's 401. + it.each([ + ["a lapsed session", lapsed, signedOut, false], + [ + "a refused origin", + refusal(403, "invalid_origin"), + "Builderlab didn’t accept this request from Buzz.", + false, + ], + // Defensive, not a kgoose reply: its identity routes never send `unknown`, + // and its real 500 is misk's plain text (the non-JSON rows). + [ + "a defensive `unknown` 500", + refusal(500, "unknown"), + "Could not unlink your old key from this Builderlab account.", + true, + ], + [ + "a non-JSON refusal", + answer(403, "Forbidden"), + "Builderlab account unlink failed (HTTP 403: not JSON).", + false, + ], + [ + "a non-JSON gateway failure", + answer(502, "bad gateway"), + "Builderlab account unlink failed (HTTP 502: not JSON).", + true, + ], + [ + "an unstructured server failure", + answer(500, { message: "boom" }), + "Builderlab account unlink failed (HTTP 500).", + true, + ], + ["a transport error", new Error("offline"), "offline", true], + [ + "a native refusal string", + "host request denied", + "host request denied", + true, + ], + ])( + "fails on %s with the card's or the step's copy, retryable only if it may clear", + async (_label, reply, message, retryable) => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const { request, sent } = kgoose(bound(old), reply); + const failed: unknown = await builderlabAccount( + credential, + "https://t.example", + request, + untouched, + ) + .unbind(old, pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe(message); + expect((failed as { retryable?: unknown }).retryable === true).toBe( + retryable, + ); + expect(sent).toHaveLength(2); + }, + ); +}); + +describe("backup", () => { + const status = ( + seq: number, + pass: number, + builderlab = true, + progress: "idle" | "running" | "complete" = "idle", + ): BackupStatus => ({ seq, pass, builderlab, status: progress }); + /** The identity's real status store over a scripted native host. */ + function host(snapshot: BackupStatus) { + const watchers: { onmessage: (status: BackupStatus) => void }[] = []; + const send = vi.fn(async (command: string, args?: unknown) => { + if (command === "nsec_backup_watch") + watchers.push((args as { onEvent: (typeof watchers)[number] }).onEvent); + else if (command === "nsec_backup_state") return snapshot; + else throw new Error(`Unexpected command ${command}`); + }); + const setBackupChoice = vi.fn( + (_pubkey: string, automatic: boolean): Promise => + Promise.resolve({ available: true, pubkey, automatic } as never), + ); + const identity: BackupIdentity = { + ...noRecovery, + backup: createBackupStatus(send as never), + setBackupChoice, + }; + return { identity, send, setBackupChoice, watchers }; + } + + 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)); + vi.mocked(invoke).mockImplementation(async (_command, args) => + (args as { credential: string | null }).credential ? 1 : null, + ); + const account = builderlabAccount( + credential, + undefined, + kgoose().request, + native.identity, + ); + const done = account.backUp(pubkey); + await vi.waitFor(() => expect(invoke).toHaveBeenCalledTimes(1)); + native.watchers[0]?.onmessage(status(2, 1, true, "complete")); + await done; + expect(native.setBackupChoice.mock.calls).toEqual([[pubkey, true]]); + expect(vi.mocked(invoke).mock.calls).toEqual([ + [ + "nsec_backup_session", + { + source: "builderlab", + generation: expect.any(Number), + credential: "private-token", + }, + ], + [ + "nsec_backup_session", + { + source: "builderlab", + generation: expect.any(Number), + credential: null, + }, + ], + ]); + }); + + it("refuses with unavailable where backup is not offered, handing the uploader nothing", async () => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example/"); + for (const [origin, builderlab] of [ + ["https://tenant.example", true], + [undefined, false], + ] as const) { + const native = host(status(1, 0, builderlab)); + const failed = await builderlabAccount( + credential, + origin, + kgoose().request, + native.identity, + ) + .backUp(pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(BackupError); + expect(failed).toMatchObject({ kind: "unavailable" }); + expect(native.setBackupChoice).not.toHaveBeenCalled(); + } + expect(invoke).not.toHaveBeenCalled(); + }); + + it("is offered only for the build's own portal, and only where native backup runs", async () => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example/"); + const offered = (origin: string | undefined, builderlab: boolean) => { + const native = host(status(1, 0, builderlab)); + return { + native, + offered: backupOfferedFor(native.identity.backup, origin), + }; + }; + expect(await offered(undefined, true).offered).toBe(true); + expect(await offered("https://builderlab.example", true).offered).toBe( + true, + ); + expect(await offered(undefined, false).offered).toBe(false); + // A tenant's credential is never handed to the uploader, so the status is not read. + const tenant = offered("https://tenant.example", true); + expect(await tenant.offered).toBe(false); + expect(tenant.native.send).not.toHaveBeenCalled(); + // A status that cannot be read is not an offer. + const refused = host(status(1, 0)); + refused.send.mockRejectedValueOnce( + "Backup status belongs to the main window", + ); + expect(await backupOfferedFor(refused.identity.backup)).toBe(false); + // A build without its own portal hands no first-run credential over. + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", ""); + expect(await offered("https://builderlab.example", true).offered).toBe( + false, + ); + }); + + describe("backupStatus", () => { + const other = "cd".repeat(32); + /** An account where backup is offered: the build's own portal, with native backup running. */ + const offeredWith = (...replies: Parameters) => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example/"); + const native = host(status(1, 0)); + const { request, sent } = kgoose(...replies); + return { + account: builderlabAccount( + credential, + undefined, + request, + native.identity, + ), + sent, + }; + }; + + it("POSTs status with the session credential and lists the backed-up keys", async () => { + const { account, sent } = offeredWith( + answer(200, { + current_nostr_pubkey: pubkey, + backup_nostr_pubkeys: [pubkey, other.toUpperCase()], + }), + ); + expect(await account.backupStatus()).toEqual([pubkey, other]); + expect(sent).toEqual([ + { + url: "https://builderlab.example/api/goose/v1/buzz/nsec-backups/status", + method: "POST", + headers: { + "Content-Type": "application/json", + "X-BB-Session-Credential": "private-token", + Origin: "https://builderlab.example", + }, + body: "{}", + }, + ]); + }); + + it("lists nothing for an account with no backups", async () => { + const { account } = offeredWith( + answer(200, { current_nostr_pubkey: null, backup_nostr_pubkeys: [] }), + ); + expect(await account.backupStatus()).toEqual([]); + }); + + it("is unavailable, not empty, where kgoose has no backups configured", async () => { + const { account } = offeredWith(answer(404, "Not Found")); + expect(await account.backupStatus()).toBe("unavailable"); + }); + + it("is unavailable without a request where backup is not offered", async () => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example/"); + for (const [origin, builderlab] of [ + ["https://tenant.example", true], + [undefined, false], + ] as const) { + const { request } = kgoose(); + const account = builderlabAccount( + credential, + origin, + request, + host(status(1, 0, builderlab)).identity, + ); + expect(await account.backupStatus()).toBe("unavailable"); + expect(request).not.toHaveBeenCalled(); + } + }); + + it.each([ + ["a transport failure", new Error("offline"), "unreachable"], + ["401", answer(401, "Unauthorized"), "signed_out"], + ["403", answer(403, "Forbidden"), "not_offered"], + ["429", answer(429, "Too Many Requests"), "unreachable"], + [ + "a 500 for an invalid stored backup", + answer(500, "oops"), + "unreachable", + ], + ["400", answer(400, "Bad Request"), "refused"], + ["a 200 that is not JSON", answer(200, ""), "invalid_service"], + [ + "a 200 with no list", + answer(200, { current_nostr_pubkey: null }), + "invalid_service", + ], + [ + "a 200 listing a non-key", + answer(200, { backup_nostr_pubkeys: [pubkey, "npub1x"] }), + "invalid_service", + ], + [ + "an oversized 200", + answer(200, { + backup_nostr_pubkeys: Array(1100).fill(pubkey), + }), + "invalid_service", + ], + ] as const)( + "rejects %s with the %s backup failure", + async (_label, reply, kind) => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const { account } = offeredWith(reply); + const failed = await account + .backupStatus() + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(BackupError); + expect(failed).toMatchObject({ + kind, + retryable: BACKUP_FAILURES[kind].retryable, + }); + }, + ); + }); +}); diff --git a/src/features/identity/builderlab-account.ts b/src/features/identity/builderlab-account.ts new file mode 100644 index 0000000000..9ce9084868 --- /dev/null +++ b/src/features/identity/builderlab-account.ts @@ -0,0 +1,629 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listedAddress } from "../../bundled/builderlab/known-communities/client"; +import { + type Credential, + oauthTarget, +} from "../../bundled/builderlab/oauth/browser"; +import { + ApiFailure, + boundKey, + check, + relayUrl, + type Reply, +} from "../../bundled/hosted-communities/api"; +import { enteredRelayOrigin, relayAddress } from "../communities/destination"; +import type { Host, HostResponse } from "../host/service"; +import { BackupError, backUpWith } from "./backup"; +import type { BackupFailureKind } from "./backup-failure"; +import type { FirstRunAccountSeed } from "./first-run-seed"; +import { + type Identity, + RECOVERY_UNAVAILABLE, + type RecoveryState, +} from "./service"; + +/** A community the signed-in account owns: its hostname and the canonical origin a join uses. */ +export type OwnedCommunity = Readonly<{ host: string; url: string }>; + +/** + * The identity's native recovery, with the state it publishes while a restore + * is under way (`undefined` otherwise). + */ +export type Recovery = Pick< + Identity, + "subscribe" | "beginRecovery" | "installRecovery" | "cancelRecovery" +> & + Readonly<{ snapshot(): RecoveryState | undefined }>; + +/** + * What first run reads about the signed-in Builderlab account, the binding + * that makes this device's key the account's (and the unlink that releases a + * lost one), and that key's backup. + */ +export type FirstRunAccount = Readonly<{ + /** The hex key bound 1:1 to the account, or null when none is (a reply with no `identity`). */ + linkedKey(): Promise; + /** The communities the account owns. Archived ones have no relay to join and are left out. */ + ownedCommunities(): Promise; + /** + * The destinations saved to the account, `wss://host[:port]` in kgoose's + * order: the list the Builderlab plugin's known-communities sync keeps. + * Rejects on any failure, and on a row this client cannot read, so a list it + * half understood is never acted on. + */ + knownCommunities(): Promise; + /** + * Saves `url` to that list (`wss://host[:port]`), as the sync's outbox add + * would at the next Settings sign-in; the add is idempotent, so that later + * send changes nothing. Rejects on any failure. + */ + saveCommunity(url: string): Promise; + /** + * Binds `pubkey`, the key on this device, to the account: kgoose's challenge, + * the native kind-24243 signer, then verify. Sends nothing more once the + * account already holds that key, so running it again is safe. + */ + bind(pubkey: string): Promise; + /** + * Unbinds `old`, the key the account held before starting over, so + * `replacement`, the new key on this device, can be linked: kgoose's delete. + * Sends nothing more once the account holds no key or `replacement` (after a + * re-run), so running it again never removes that, and fails without a + * delete when it holds a third key. Resolves only once the account holds no + * key or `replacement`. + */ + unbind(old: string, replacement: string): Promise; + /** + * Backs `pubkey` up and waits for the native uploader's result; rejects with + * a `BackupError`, "unavailable" where `backupOfferedFor` says this build + * cannot back up for this sign-in (nothing is handed to the uploader). + */ + backUp(pubkey: string): Promise; + /** + * The hex keys the account holds a backup of, or "unavailable" where backup + * is not offered (nothing is sent) or kgoose has no backups configured (404). + * Rejects with a `BackupError`. + */ + backupStatus(): Promise; + /** + * Restores a backed-up key. Native recovery opens its own browser sign-in + * (it takes no first-run credential), and an installed key leaves the + * identity ready. + */ + recovery: Recovery; +}>; + +/** The identity's backup status, consent and recovery, which the backup steps use. */ +type BackupIdentity = Pick< + Identity, + | "backup" + | "setBackupChoice" + | "subscribe" + | "snapshot" + | "beginRecovery" + | "installRecovery" + | "cancelRecovery" +>; + +const MAX_RESPONSE_BYTES = 64 * 1024; + +/** Native commands and host HTTP reject with strings; surface them as errors. */ +const asError = (error: unknown) => + typeof error === "string" ? new Error(error) : error; + +/** + * A setup-needed mapping is "not linked", as the hosted-communities card reads + * it. Only the list route answers this way; the identity route answers 200 + * with no `identity` instead (see `linkedKey`). + */ +const notLinked = (reply: Reply) => + reply.error?.setup_needed === true || + (typeof reply.error?.code === "string" && + reply.error.code.toLowerCase() === "missing_mapping"); + +/** + * Names the step and the HTTP status so a failed page tells the account check + * from the list, and a 403 text/plain from a JSON shape problem. Never the + * response body: provider bodies are not forwarded to the user. + */ +const failure = ( + step: string, + response: Pick, + detail?: string, +) => + new Error( + `Builderlab ${step} failed (HTTP ${response.status}${detail ? `: ${detail}` : ""}).`, + ); + +/** + * kgoose answers a lapsed session credential on every `@Authenticated` route + * with misk's plain-text 401. Try again would send the same credential. + */ +const SIGNED_OUT = "Your Builderlab sign-in is no longer valid."; + +const OTHER_KEY = + "Your Builderlab account is linked to a different key, so your new key can’t be linked."; + +/** + * A transport failure (no status), a rate limit or a 5xx may clear on its own, + * as the native uploader reads them, so the failed page offers Try again; a + * refusal or an answer we rejected will not. kgoose's challenge route answers + * 429 `too_many_attempts` once five challenges are open. A host refusal (an + * undeclared origin) also arrives with no status: native host requests reject + * with untyped strings, so it cannot be told from a transport failure here. + */ +const transient = (error: unknown, status?: number) => + error instanceof Error && + (status === undefined || status === 429 || status >= 500) + ? Object.assign(error, { retryable: true }) + : error; + +/** + * kgoose identity codes (`BuzzNostrIdentityErrorCode`) the hosted card's table + * lacks, in first run's voice. The card reads `unknown` as its deletion + * service's, so it takes the step's fallback (no `message`). A code about one + * challenge is `retryable` whatever its status: Try again re-runs `bind()`, + * which reads `current` again and asks for a fresh challenge. + * `invalid_challenge` is not: kgoose sends it for a bad signature or a + * `created_at` outside its skew window, and a fresh challenge fixes neither. + */ +const ACCOUNT_ERRORS: Readonly< + Record> +> = { + invalid_origin: { + message: "Builderlab didn’t accept this request from Buzz.", + }, + invalid_challenge: { + message: + "Builderlab couldn’t verify your key’s signature, so the key wasn’t linked.", + }, + challenge_not_found: { + message: + "Builderlab couldn’t find the request to link your key, so the key wasn’t linked.", + retryable: true, + }, + challenge_expired: { + message: + "The request to link your key expired before Builderlab confirmed it.", + retryable: true, + }, + challenge_consumed: { + message: "The request to link your key was already used.", + retryable: true, + }, + too_many_attempts: { + message: + "Builderlab has had too many attempts to link a key. Wait a few minutes, then try again.", + }, + unknown: {}, +}; + +/** + * The card's `check` with `ACCOUNT_ERRORS` first. kgoose's own message is + * dropped, so a code neither table knows takes the step's fallback and + * kgoose's wording never reaches the failed page. So is its correlation ID, + * which the card's `ApiFailure` would append to the copy; `call` keeps it. + */ +function checked(reply: Reply, fallback: string) { + if (!reply.error) return reply; + const code = reply.error.code ?? ""; + const own = Object.hasOwn(ACCOUNT_ERRORS, code) + ? ACCOUNT_ERRORS[code] + : undefined; + // Throws the card's copy: the reply has an error. + if (!own) return check({ error: { code } }, fallback); + throw Object.assign( + new ApiFailure(code, own.message ?? fallback), + own.retryable ? { retryable: true } : {}, + ); +} + +/** The hosted-communities native backend's reply rules, for a response that came through the bundled grant. */ +function parse(step: string, response: HostResponse): Reply { + if (new TextEncoder().encode(response.body).byteLength > MAX_RESPONSE_BYTES) + throw failure(step, response, "response too large"); + let value: unknown; + try { + value = JSON.parse(response.body); + } catch { + throw failure(step, response, "not JSON"); + } + const ok = response.status >= 200 && response.status < 300; + if ( + value && + typeof value === "object" && + !Array.isArray(value) && + (ok || (value as Reply).error) + ) + return value as Reply; + throw failure(step, response); +} + +/** A backup route's status as the native uploader reads it (`nsec_backup.rs` `failure`). */ +const backupFailure = (status: number): BackupFailureKind => + status === 401 + ? "signed_out" + : status === 403 + ? "not_offered" + : status >= 400 && status < 500 && status !== 429 + ? "refused" + : "unreachable"; + +/** `nsec-backups/status`'s `backup_nostr_pubkeys`, or undefined for any other reply. */ +function backedUpKeys(response: HostResponse) { + if (new TextEncoder().encode(response.body).byteLength > MAX_RESPONSE_BYTES) + return undefined; + let keys: unknown; + try { + keys = JSON.parse(response.body)?.backup_nostr_pubkeys; + } catch { + return undefined; + } + if (!Array.isArray(keys)) return undefined; + const listed = keys.map((key) => + typeof key === "string" ? boundKey({ pubkey_hex: key }) : null, + ); + return listed.every((key) => key !== null) ? listed : undefined; +} + +function owned(community: NonNullable[number]) { + const relay = relayUrl(community); + if (!relay || community.archived_at) return []; + try { + const url = enteredRelayOrigin(relay); + return [{ host: new URL(url).host, url }]; + } catch { + return []; + } +} + +/** + * Whether this build backs a key up for a Builderlab sign-in at `origin` (the + * build's own portal when omitted, where Settings signs in). The uploader + * accepts credentials only from that portal, so a tenant's status is never + * read, and runs only when the status says Builderlab backup is allowed. First + * run's backup and status reads and Settings → Identity share this rule. Any + * error answers false. + */ +export async function backupOfferedFor( + backup: Pick, + origin?: string, +) { + try { + if (new URL(oauthTarget(origin)).origin !== new URL(oauthTarget()).origin) + return false; + return (await backup.ready()).builderlab; + } catch { + return false; + } +} + +/** + * The real reads, binding and unlink, against the kgoose sign-in used + * (`/api/goose`; the build URL when no tenant was chosen), + * authenticated with the first-run session credential and sent through the + * bundled plugin's native grant like the sign-in itself. Routes and reply + * shapes are the hosted-communities card's (`native.ts` ROUTES `identity`, + * `list` and `unbind`, and its `bind`; `api.ts` parsers) and, for the saved + * destinations, the Builderlab plugin's known-communities list and add; + * neither owner's own client is touched. The backup goes through the native + * uploader instead (`backup.ts`). + */ +export function builderlabAccount( + credential: Credential, + origin: string | undefined, + request: Host["request"], + identity: BackupIdentity, +): FirstRunAccount { + const target = oauthTarget(origin); + const portal = new URL(target).origin; + const post = (path: string, body: object = {}) => + request({ + url: `${target}${path}`, + method: "POST", + headers: { + "Content-Type": "application/json", + "X-BB-Session-Credential": credential.value, + // Binding requires the challenge origin to match this header. + Origin: portal, + }, + body: JSON.stringify(body), + }); + /** + * One POST; `read` turns the parsed reply (null when not linked) into the + * answer. Any refusal of the answer is logged in development with the + * route, status and kgoose's correlation ID — again never the body — then + * thrown with that ID as `correlationId`, off the failed page. + */ + const call = async ( + step: string, + path: string, + fallback: string, + read: (reply: Reply | null, response: HostResponse) => T, + body: object = {}, + ) => { + const response = await post(path, body).catch((error: unknown) => { + throw transient(asError(error)); + }); + let reply: Reply | undefined; + try { + if (response.status === 401) throw new Error(SIGNED_OUT); + reply = parse(step, response); + return read(notLinked(reply) ? null : checked(reply, fallback), response); + } catch (error) { + const id = reply?.correlation_id; + if (id && error instanceof Error) + Object.assign(error, { correlationId: id }); + if (import.meta.env.DEV) + console.warn( + `Builderlab ${step}: POST ${path} answered HTTP ${response.status}${id ? ` (correlation ID ${id})` : ""}`, + error instanceof Error ? error.message : error, + ); + throw transient(error, response.status); + } + }; + const linkedKey = () => + call( + "account check", + "/v1/buzz/nostr-identities/current", + "Could not check this Builderlab account.", + (reply, response) => { + // kgoose answers 200 with `identity` null or absent when no key is + // bound; it never sends MISSING_MAPPING here. A linked account must + // never be given a second key, so a bound identity without a usable + // key is a failure, not "not linked". + if (!reply || reply.identity == null) return null; + const key = boundKey(reply.identity); + if (!key) + throw failure("account check", response, "unusable linked key"); + return key; + }, + ); + return { + linkedKey, + async bind(pubkey) { + // Re-running after a failure must not meet 409 `pubkey_already_bound`. + if ((await linkedKey()) === pubkey) return; + 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`, + // or 429 `too_many_attempts`. + const challenge = await call( + step, + "/v1/buzz/nostr-identities/challenge", + fallback, + (reply, response) => { + if (!reply) throw failure(step, response); + const { challenge_id, nonce, verification_code, origin, expires_at } = + reply as Record; + return { challenge_id, nonce, verification_code, origin, expires_at }; + }, + { origin: portal }, + ); + // Native code signs only a well-formed challenge for a granted origin. + const event = await invoke("identity_sign_builderlab_binding", { + challenge, + }).catch((error: unknown) => { + throw asError(error); + }); + // An account bound to another key is refused here, with 409 + // `identity_already_bound`. + await call( + step, + "/v1/buzz/nostr-identities/verify", + fallback, + (reply, response) => { + if (boundKey(reply?.identity) !== pubkey) + throw failure(step, response, "link not confirmed"); + }, + { + challenge_id: challenge.challenge_id, + nonce: challenge.nonce, + signed_payload: JSON.stringify(event), + }, + ); + }, + async unbind(old, replacement) { + // kgoose deletes whatever is bound, so check it is still the old key. + // A key bound between this read and the delete is still removed: the + // real fix is an `expected_pubkey_hex` on the delete request, checked + // under kgoose's user-row lock. + const before = await linkedKey(); + if (before === null || before === replacement) return; + if (before !== old) throw new Error(OTHER_KEY); + const removed = await call( + "account unlink", + "/v1/buzz/nostr-identities/delete", + "Could not unlink your old key from this Builderlab account.", + (reply) => boundKey(reply?.identity), + ); + if (removed === old) return; + // kgoose also answers 200 with no `identity`, removing nothing, when + // the session or its authorization has lapsed, so read what is bound. + const now = await linkedKey(); + if (now === null || now === replacement) return; + throw new Error( + now === old + ? "Builderlab didn’t unlink your old key, so your new key can’t be linked. Your Builderlab sign-in may have ended." + : OTHER_KEY, + ); + }, + ownedCommunities: () => + call( + "community list", + "/v1/buzz/communities/list", + "Could not load the communities this account owns.", + (reply) => reply?.communities?.flatMap(owned) ?? [], + ), + // The route consults no Nostr identity, so its refusals are plain strings + // and `reply` is never null here; its rows are not the card's. + knownCommunities: () => + call( + "known communities", + "/v1/buzz/known-communities/list", + "Could not load the communities saved to this account.", + (reply, response) => { + // Protobuf JSON can omit an empty repeated field. + const rows: unknown = reply?.communities ?? []; + const listed = Array.isArray(rows) + ? rows.map(listedAddress) + : [undefined]; + if (listed.includes(undefined)) + throw failure("known communities", response, "unreadable row"); + return listed.filter((address): address is string => !!address); + }, + ), + async saveCommunity(url) { + await call( + "community save", + "/v1/buzz/known-communities/add", + "Could not save this community to your Builderlab account.", + () => undefined, + { community_url: relayAddress(url) }, + ); + }, + // The uploader never checks where a credential came from, so a tenant's + // must not reach it. + async backUp(pubkey) { + if (!(await backupOfferedFor(identity.backup, origin))) + throw new BackupError("unavailable"); + await backUpWith(identity, pubkey, credential.value); + }, + // Backup routes answer plain text, not the card's reply shape, so only + // the status and a 2xx body are read. + async backupStatus() { + if (!(await backupOfferedFor(identity.backup, origin))) + return "unavailable"; + const path = "/v1/buzz/nsec-backups/status"; + const response = await post(path).catch(() => { + throw new BackupError("unreachable"); + }); + if (response.status === 404) return "unavailable"; + const ok = response.status >= 200 && response.status < 300; + const keys = ok ? backedUpKeys(response) : undefined; + if (keys) return keys; + if (import.meta.env.DEV) + console.warn( + `Builderlab backup status: POST ${path} answered HTTP ${response.status}`, + ); + throw new BackupError( + ok ? "invalid_service" : backupFailure(response.status), + ); + }, + recovery: { + subscribe: identity.subscribe, + snapshot() { + const state = identity.snapshot(); + return state.status === "missing" ? state.recovery : undefined; + }, + beginRecovery: identity.beginRecovery, + installRecovery: identity.installRecovery, + cancelRecovery: identity.cancelRecovery, + }, + }; +} + +/** + * Recovery answered from the seed, with no browser sign-in or native recovery + * command: a seeded `recovery` key is the one backup recovered, and + * installing it imports it as Enter your private key would. With none, every + * restore answers that recovery is unavailable, as every tenant does today. + */ +function seededRecovery( + seed: FirstRunAccountSeed, + identity: Pick, +): Recovery { + let state: RecoveryState | undefined; + const listeners = new Set<() => void>(); + const publish = (next: RecoveryState | undefined) => { + state = next; + for (const listener of listeners) listener(); + }; + return { + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + snapshot: () => state, + async beginRecovery() { + publish( + seed.recovery + ? { + status: "choosing", + candidates: [{ handle: "seed", pubkey: seed.recovery.pubkey }], + currentPubkey: seed.linked, + failedCount: 0, + } + : { status: "error", error: RECOVERY_UNAVAILABLE }, + ); + }, + async installRecovery() { + if (!seed.recovery) return; + publish({ status: "saving" }); + await identity.importKey(seed.recovery.nsec); + const saved = identity.snapshot(); + publish( + saved.status === "ready" + ? undefined + : { + status: "error", + error: + (saved.status === "missing" && saved.error) || + "Couldn’t restore your identity. Try again.", + }, + ); + }, + async cancelRecovery() { + publish(undefined); + return true; + }, + }; +} + +/** + * The reads answered from the `BUZZ_DEV_FIRST_RUN_ACCOUNT` development seed. + * Binding, unlinking and backup succeed without a request, a native signature + * or a native backup command, in every build; a seeded `backup` kind fails + * every backup with that kind's page instead (`fail-once`: only this sign-in's + * first, as an unconfirmed upload, so Try again succeeds), and a seeded + * `unlink` status fails every unlink as kgoose answering it would. Backup + * status lists the seeded `backups`, the saved destinations are the seeded + * `known` (a save sends nothing), and recovery is `seededRecovery`. + */ +export function seededAccount( + seed: FirstRunAccountSeed, + identity: Pick, +): FirstRunAccount { + let failOnce = seed.backup === "fail-once"; + return { + linkedKey: () => Promise.resolve(seed.linked), + ownedCommunities: () => Promise.resolve(seed.communities), + knownCommunities: () => Promise.resolve(seed.known ?? []), + saveCommunity: () => Promise.resolve(), + bind: () => Promise.resolve(), + unbind: () => + seed.unlink + ? Promise.reject( + transient( + seed.unlink === 401 + ? new Error(SIGNED_OUT) + : failure("account unlink", { status: seed.unlink }), + seed.unlink, + ), + ) + : Promise.resolve(), + backUp() { + if (failOnce) { + failOnce = false; + return Promise.reject(new BackupError("unconfirmed")); + } + return seed.backup && seed.backup !== "fail-once" + ? Promise.reject(new BackupError(seed.backup)) + : Promise.resolve(); + }, + backupStatus: () => Promise.resolve(seed.backups ?? []), + recovery: seededRecovery(seed, identity), + }; +} diff --git a/src/features/identity/first-run-seed.test.ts b/src/features/identity/first-run-seed.test.ts new file mode 100644 index 0000000000..022630f82f --- /dev/null +++ b/src/features/identity/first-run-seed.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it, vi } from "vitest"; +import { npubEncode, nsecEncode } from "nostr-tools/nip19"; +import { generateSecretKey, getPublicKey } from "nostr-tools/pure"; +import { BackupError } from "./backup"; +import { seededAccount } from "./builderlab-account"; +import { parseFirstRunAccountSeed } from "./first-run-seed"; +import { type IdentitySnapshot, recoveryOff } from "./service"; + +const pubkey = "ab".repeat(32); +/** Only a seeded restore imports a key. */ +const noImport = { + importKey: () => Promise.reject(new Error("Imported a key")), + snapshot: (): IdentitySnapshot => ({ status: "missing", busy: false }), +}; + +describe("parseFirstRunAccountSeed", () => { + it("is absent when unset, and `unlinked` answers no key and no communities", async () => { + expect(parseFirstRunAccountSeed()).toBeUndefined(); + expect(parseFirstRunAccountSeed(" ")).toBeUndefined(); + const seed = parseFirstRunAccountSeed("unlinked"); + expect(seed).toEqual({ linked: null, communities: [] }); + if (!seed) throw new Error("unreachable"); + const account = seededAccount(seed, noImport); + expect(await account.linkedKey()).toBeNull(); + expect(await account.ownedCommunities()).toEqual([]); + expect(await account.knownCommunities()).toEqual([]); + await expect(account.backUp(pubkey)).resolves.toBeUndefined(); + await expect(account.bind(pubkey)).resolves.toBeUndefined(); + await expect(account.unbind(pubkey, pubkey)).resolves.toBeUndefined(); + expect(await account.backupStatus()).toEqual([]); + }); + + it.each([ + [503, "Builderlab account unlink failed (HTTP 503).", true], + [403, "Builderlab account unlink failed (HTTP 403).", false], + [401, "Your Builderlab sign-in is no longer valid.", false], + ])( + "fails every unlink as kgoose answering %i would", + async (status, message, retryable) => { + const seed = parseFirstRunAccountSeed( + JSON.stringify({ npub: npubEncode(pubkey), unlink: status }), + ); + expect(seed).toEqual({ linked: pubkey, communities: [], unlink: status }); + if (!seed) throw new Error("unreachable"); + const failed: unknown = await seededAccount(seed, noImport) + .unbind(pubkey, pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe(message); + expect((failed as { retryable?: unknown }).retryable === true).toBe( + retryable, + ); + }, + ); + + it("fails every backup with a seeded kind, for a linked or an unlinked account", async () => { + const seed = parseFirstRunAccountSeed('{"backup":"unreachable"}'); + expect(seed).toEqual({ + linked: null, + communities: [], + backup: "unreachable", + }); + if (!seed) throw new Error("unreachable"); + const failed = await seededAccount(seed, noImport) + .backUp(pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(BackupError); + expect(failed).toMatchObject({ kind: "unreachable", retryable: true }); + expect( + parseFirstRunAccountSeed( + JSON.stringify({ npub: npubEncode(pubkey), backup: "timeout" }), + ), + ).toEqual({ linked: pubkey, communities: [], backup: "timeout" }); + expect(parseFirstRunAccountSeed("{}")).toEqual({ + linked: null, + communities: [], + }); + }); + + it("fails only the first backup with fail-once, as unconfirmed, for a linked or an unlinked account", async () => { + for (const value of [ + { backup: "fail-once" }, + { npub: npubEncode(pubkey), backup: "fail-once" }, + ]) { + const seed = parseFirstRunAccountSeed(JSON.stringify(value)); + expect(seed).toMatchObject({ backup: "fail-once" }); + if (!seed) throw new Error("unreachable"); + const account = seededAccount(seed, noImport); + const failed = await account + .backUp(pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(BackupError); + expect(failed).toMatchObject({ kind: "unconfirmed", retryable: true }); + await expect(account.backUp(pubkey)).resolves.toBeUndefined(); + // Each sign-in's account fails once again. + await expect( + seededAccount(seed, noImport).backUp(pubkey), + ).rejects.toThrow(BackupError); + } + }); + + it("decodes the npub to hex and canonicalizes the owned and saved hosts", async () => { + const seed = parseFirstRunAccountSeed( + JSON.stringify({ + npub: npubEncode(pubkey), + communities: ["Design.Example.com", "wss://buzz.example.org:8443"], + // Saved destinations take the field's spellings and come back as kgoose lists them. + known: ["Chat.Example.com.", "https://buzz.example.org:8443/"], + }), + ); + expect(seed).toEqual({ + linked: pubkey, + communities: [ + { host: "design.example.com", url: "https://design.example.com" }, + { host: "buzz.example.org:8443", url: "https://buzz.example.org:8443" }, + ], + known: ["wss://chat.example.com", "wss://buzz.example.org:8443"], + }); + if (!seed) throw new Error("unreachable"); + const account = seededAccount(seed, noImport); + expect(await account.linkedKey()).toBe(pubkey); + expect(await account.knownCommunities()).toEqual([ + "wss://chat.example.com", + "wss://buzz.example.org:8443", + ]); + expect( + parseFirstRunAccountSeed(JSON.stringify({ npub: npubEncode(pubkey) })), + ).toEqual({ linked: pubkey, communities: [] }); + }); + + it("lists the seeded backups, decoded to hex, in backup status", async () => { + const other = "cd".repeat(32); + const seed = parseFirstRunAccountSeed( + JSON.stringify({ + npub: npubEncode(pubkey), + backups: [npubEncode(pubkey), npubEncode(other)], + }), + ); + expect(seed).toEqual({ + linked: pubkey, + communities: [], + backups: [pubkey, other], + }); + if (!seed) throw new Error("unreachable"); + expect(await seededAccount(seed, noImport).backupStatus()).toEqual([ + pubkey, + other, + ]); + }); + + it("answers every restore that recovery is unavailable when no key is seeded, with no native command", async () => { + const seed = parseFirstRunAccountSeed( + JSON.stringify({ npub: npubEncode(pubkey) }), + ); + if (!seed) throw new Error("unreachable"); + const { recovery } = seededAccount(seed, noImport); + const changed = vi.fn(); + recovery.subscribe(changed); + expect(recovery.snapshot()).toBeUndefined(); + await recovery.beginRecovery(); + expect(recoveryOff(recovery.snapshot())).toBe(true); + expect(await recovery.cancelRecovery()).toBe(true); + expect(recovery.snapshot()).toBeUndefined(); + expect(changed).toHaveBeenCalledTimes(2); + }); + + it("recovers the seeded key and installs it through the native import", async () => { + const secret = generateSecretKey(); + const nsec = nsecEncode(secret); + const restored = getPublicKey(secret); + const seed = parseFirstRunAccountSeed( + JSON.stringify({ npub: npubEncode(restored), recovery: nsec }), + ); + expect(seed).toEqual({ + linked: restored, + communities: [], + recovery: { nsec, pubkey: restored }, + }); + if (!seed) throw new Error("unreachable"); + let state: IdentitySnapshot = { status: "missing", busy: false }; + const importKey = vi.fn(async (_nsec: string) => { + state = { status: "ready", viewer: restored }; + }); + const { recovery } = seededAccount(seed, { + importKey, + snapshot: () => state, + }); + await recovery.beginRecovery(); + expect(recovery.snapshot()).toEqual({ + status: "choosing", + candidates: [{ handle: "seed", pubkey: restored }], + currentPubkey: restored, + failedCount: 0, + }); + await recovery.installRecovery("seed"); + expect(importKey.mock.calls).toEqual([[nsec]]); + expect(recovery.snapshot()).toBeUndefined(); + + // A refused import is the restore's error, as a native one would be. + state = { status: "missing", busy: false, error: "Couldn’t save the key." }; + importKey.mockResolvedValueOnce(undefined); + await recovery.beginRecovery(); + await recovery.installRecovery("seed"); + expect(recovery.snapshot()).toEqual({ + status: "error", + error: "Couldn’t save the key.", + }); + }); + + it.each([ + ["garbage", "linked"], + ["an array", "[]"], + ["a private key", JSON.stringify({ npub: nsecEncode(new Uint8Array(32)) })], + ["hex instead of an npub", JSON.stringify({ npub: pubkey })], + [ + "an unknown field", + JSON.stringify({ npub: npubEncode(pubkey), secret: "s3cret" }), + ], + ["an unknown backup kind", JSON.stringify({ backup: "s3cret" })], + [ + "an inherited name as a backup kind", + JSON.stringify({ backup: "toString" }), + ], + ["a non-string backup kind", JSON.stringify({ backup: true })], + ["a misspelled fail-once", JSON.stringify({ backup: "fail_once" })], + ["a backup list that is not a list", JSON.stringify({ backups: "s3cret" })], + ["a hex backup", JSON.stringify({ backups: [pubkey] })], + [ + "a private key as a backup", + JSON.stringify({ backups: [nsecEncode(new Uint8Array(32).fill(1))] }), + ], + ["a non-string recovery key", JSON.stringify({ recovery: 1 })], + [ + "an npub as the recovery key", + JSON.stringify({ recovery: npubEncode(pubkey) }), + ], + ["a malformed recovery key", JSON.stringify({ recovery: "nsec1s3cret" })], + ["a non-numeric unlink status", JSON.stringify({ unlink: "s3cret" })], + ["a successful unlink status", JSON.stringify({ unlink: 200 })], + ["a fractional unlink status", JSON.stringify({ unlink: 503.5 })], + [ + "a non-string host", + JSON.stringify({ npub: npubEncode(pubkey), communities: [1] }), + ], + [ + "an insecure host", + JSON.stringify({ + npub: npubEncode(pubkey), + communities: ["http://s3cret.example"], + }), + ], + ["a non-array known list", JSON.stringify({ known: "s3cret.example" })], + ["a non-string saved host", JSON.stringify({ known: [1] })], + [ + "an insecure saved host", + JSON.stringify({ known: ["http://s3cret.example"] }), + ], + [ + "a saved host with a path", + JSON.stringify({ known: ["wss://s3cret.example/relay"] }), + ], + ])("rejects %s without echoing it", (_label, raw) => { + expect(() => parseFirstRunAccountSeed(raw)).toThrow( + /^BUZZ_DEV_FIRST_RUN_ACCOUNT must be/, + ); + expect(() => parseFirstRunAccountSeed(raw)).not.toThrow(/s3cret/); + }); +}); diff --git a/src/features/identity/first-run-seed.ts b/src/features/identity/first-run-seed.ts new file mode 100644 index 0000000000..d92f4856ea --- /dev/null +++ b/src/features/identity/first-run-seed.ts @@ -0,0 +1,121 @@ +import { decode } from "nostr-tools/nip19"; +import { getPublicKey } from "nostr-tools/pure"; +import { + enteredRelayOrigin, + relayAddress, +} from "../communities/destination.ts"; +import { + type BackupFailureKind, + isBackupFailureKind, +} from "./backup-failure.ts"; +import type { OwnedCommunity } from "./builderlab-account.ts"; + +/** + * Development stand-in for the first-run Builderlab account, from + * `BUZZ_DEV_FIRST_RUN_ACCOUNT`: `unlinked`, or + * `{"npub"?: "npub1…", "communities"?: ["host", …], "known"?: ["host", …], + * "backups"?: ["npub1…", …], "recovery"?: "nsec1…", + * "backup"?: "" | "fail-once", "unlink"?: }`, + * where no npub is an unlinked account, `known` the destinations saved to the + * account, `backups` the keys backup status + * lists, `recovery` the key every restore recovers (none: recovery is + * unavailable, as on every tenant today), `backup` names the failure every + * backup reports (`fail-once`: only the first, which Try again then clears), + * and `unlink` the 4xx or 5xx every unlink answers. Builds + * leave the Vite define empty, so the seed can only ever answer a dev server. + * A leaf module with explicit `.ts` imports: vite.config.ts validates the + * value through Node's native loader. + */ +export type FirstRunAccountSeed = Readonly<{ + linked: string | null; + communities: readonly OwnedCommunity[]; + /** Saved destinations as kgoose lists them: `wss://host[:port]`. */ + known?: readonly string[]; + /** Hex keys. */ + backups?: readonly string[]; + /** The nsec as typed, which native import takes, and its hex key. */ + recovery?: Readonly<{ nsec: string; pubkey: string }>; + backup?: BackupFailureKind | "fail-once"; + unlink?: number; +}>; + +/** An `npub1…` as hex; throws for anything else. */ +function hex(npub: unknown) { + const decoded = typeof npub === "string" ? decode(npub) : undefined; + if (decoded?.type !== "npub") throw new Error(); + return decoded.data; +} + +/** An `nsec1…` as typed, with its hex key; throws for anything else. */ +function secretKey(nsec: string) { + const decoded = decode(nsec); + if (decoded.type !== "nsec") throw new Error(); + return { nsec, pubkey: getPublicKey(decoded.data) }; +} + +export function parseFirstRunAccountSeed( + raw = "", +): FirstRunAccountSeed | undefined { + const value = raw.trim(); + if (!value) return undefined; + if (value === "unlinked") return { linked: null, communities: [] }; + try { + const seed: unknown = JSON.parse(value); + if (!seed || typeof seed !== "object" || Array.isArray(seed)) + throw new Error(); + const { + npub, + communities = [], + known, + backups, + recovery, + backup, + unlink, + ...rest + } = seed as Record; + if ( + Object.keys(rest).length > 0 || + !Array.isArray(communities) || + (known !== undefined && !Array.isArray(known)) || + (backups !== undefined && !Array.isArray(backups)) || + (recovery !== undefined && typeof recovery !== "string") || + (backup !== undefined && + backup !== "fail-once" && + !isBackupFailureKind(backup)) || + (unlink !== undefined && + !( + typeof unlink === "number" && + Number.isInteger(unlink) && + unlink >= 400 && + unlink <= 599 + )) + ) + throw new Error(); + return { + linked: npub === undefined ? null : hex(npub), + communities: communities.map((host: unknown) => { + if (typeof host !== "string") throw new Error(); + const url = enteredRelayOrigin(host); + return { host: new URL(url).host, url }; + }), + // The same spellings as the Community URL field, stored as kgoose lists them. + ...(known === undefined + ? {} + : { + known: known.map((value: unknown) => { + if (typeof value !== "string") throw new Error(); + return relayAddress(enteredRelayOrigin(value)); + }), + }), + ...(backups === undefined ? {} : { backups: backups.map(hex) }), + ...(recovery === undefined ? {} : { recovery: secretKey(recovery) }), + ...(backup === undefined ? {} : { backup }), + ...(unlink === undefined ? {} : { unlink: unlink as number }), + }; + } catch { + // Do not echo malformed input: an environment value might contain credentials. + throw new Error( + "BUZZ_DEV_FIRST_RUN_ACCOUNT must be `unlinked` or a JSON object {npub?, communities?: [hostname, …], known?: [hostname, …], backups?: [npub, …], recovery?: nsec, backup?: failure kind or fail-once, unlink?: HTTP status}.", + ); + } +} diff --git a/vite.config.ts b/vite.config.ts index 8b4aa6f4e8..f7d86e3232 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,7 @@ import { parseCommunityAliases, relayOrigin, } from "./src/features/communities/destination.ts"; +import { parseFirstRunAccountSeed } from "./src/features/identity/first-run-seed.ts"; import { parseBuilderlabTenants } from "./src/features/identity/builderlab-tenants.ts"; export default defineConfig(async ({ command, mode }) => { @@ -52,6 +53,13 @@ export default defineConfig(async ({ command, mode }) => { "BUZZ_DATABRICKS_HOST must be an https:// URL without credentials.", ); } + // Opt-in seed: answers the first-run Builderlab account reads (linked key, + // owned communities) without kgoose, so the returning-user screens can be + // walked against a real account whose binding must not change. Builds never + // see it. + const firstRunAccount = + command === "serve" ? (env.BUZZ_DEV_FIRST_RUN_ACCOUNT ?? "") : ""; + parseFirstRunAccountSeed(firstRunAccount); const plugins: PluginOption[] = [react()]; if (command === "serve") plugins.push( @@ -96,6 +104,8 @@ export default defineConfig(async ({ command, mode }) => { "import.meta.env.VITE_BUZZ_OPEN_RELAY": JSON.stringify(openOrigin), "import.meta.env.VITE_BUZZ_DATABRICKS_HOST": JSON.stringify(databricksHost), + "import.meta.env.VITE_BUZZ_FIRST_RUN_ACCOUNT": + JSON.stringify(firstRunAccount), }, build: { rollupOptions: {