Skip to content

feat(identity): first-run Builderlab account client — sign-in without a plugin session, kgoose reads, link/unlink, backup, saved communities — and its dev seed - #1818

Open
matt2e wants to merge 1 commit into
onboarding/04-builderlab-tenantsfrom
onboarding/05-first-run-account-client
Open

matt2e wants to merge 1 commit into
onboarding/04-builderlab-tenantsfrom
onboarding/05-first-run-account-client

Conversation

@matt2e

@matt2e matt2e commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Onboarding stack (merge in order, top to bottom):

  1. fix(navigation): run the visit deadline only while a presenter is attached #1814 navigation: visit deadline only while a presenter is attached
  2. refactor(communities): extract the join owner and add the pre-join relay lookup #1815 communities: join owner and pre-join relay lookup
  3. feat(identity): observable nsec backup status with pass tickets, explicit lock release, and refused-recovery copy #1816 identity: native nsec backup status
  4. feat(builderlab): tenant table — a login origin per hosted relay, granted natively and accepted by the binding signer #1817 builderlab: tenant table and granted native origins
  5. feat(identity): first-run Builderlab account client — sign-in without a plugin session, kgoose reads, link/unlink, backup, saved communities — and its dev seed #1818 identity: first-run Builderlab account client and dev seed ← this PR
  6. feat(identity): first-run setup store, step table and pages (composition follows in the next PR) #1819 identity: first-run setup store, step table and pages
  7. feat(identity): the first-run Welcome flow replaces the key-setup gate (FOUNDATION: App.tsx) #1820 identity: Welcome flow replaces the key-setup gate
  8. feat(settings): read-only Key backup row in Settings → Identity #1821 settings: read-only Key backup row
  9. [DO NOT MERGE] chore(identity): DO NOT MERGE — isolated debug Keychain slot (full stack for native testing) #1822 DO NOT MERGE: isolated debug Keychain slot (full stack for native testing)

Depends on: PR 02 (destination), PR 03 (backup, backup-failure, service; this layer adds isBackupFailureKind to backup-failure.ts for the seed guard, which vite.config.ts loads through Node's native loader), PR 04 (oauth/browser). PRs 06–08 build on it.

Diff: production +835/−2; tests +1697/−0; docs +56/−0

What this layer is

All first-run network logic, no UI. oauth/first-run.ts binds a Host to the plugin's bundled grant and exposes firstRunCredential(signal, origin?); builderlab-account.ts is FirstRunAccount: linkedKey (200-with-null ⇒ not linked), ownedCommunities, knownCommunities (via listedAddress), bind (current-first, challenge → native signer → verify), unbind (delete only while the old key is bound, verified by echo or re-read), backUp (tenant guard, then backUpWith), backupStatus, recovery, saveCommunity, backupOfferedFor; the shared call/post helpers with retryability (no status / 5xx / 429), kgoose code tables, 401 mapping, correlation-ID handling; seededAccount + first-run-seed.ts (BUZZ_DEV_FIRST_RUN_ACCOUNT parser, Node-loadable with .ts imports) wired through vite.config.ts for serve only.

Review focus

  • Every request carries the portal Origin and the first-run session credential and goes through the bundled grant — never the Settings session's client. The credential is never persisted.
  • linkedKey: a 2xx with identity null/absent is not linked; a present identity with a bad pubkey_hex fails closed (a linked account must never be given a second key).
  • unbind: kgoose's delete takes no key, so the client reads current first and verifies by echo or re-read; a third key or a still-bound old key fails rather than resolving quietly. The real fix (expected_pubkey_hex on the delete) is a kgoose change, noted in a comment.
  • backUp's tenant guard: a tenant's credential never reaches the native uploader, which cannot tell where a credential came from.
  • Retryability and copy: no status / 429 / 5xx retryable; 401 ⇒ lapsed sign-in, non-retryable; no response body or correlation ID reaches a page. The dev seed replaces only the reads, bind, unbind, backup and recovery; builds define it empty.

Checks run on this branch's tree

  • Deferred to CI at the author's request (the build machine was under a deliberate stress load from another session, load avg 58–110, so local vitest/cargo runs were timing out in untouched files). Pushed with --no-verify; the hosted checks on this PR are the verification. The plan's checks for this layer were: vitest run src/features/identity src/bundled/builderlab browser-host/vite-config.test.mjs, tsc --noEmit, biome check.

Native evidence

None on this tree. The native first-run recordings and the hands-on checks on the branch were made on the full tree, which only the DO-NOT-MERGE PR 09 reproduces (it adds the isolated debug Keychain slot). Treat every layer below 09 as unit/integration-tested only.

🤖 Generated with Claude Code

… a plugin session, kgoose reads, link/unlink, backup, saved communities — and its dev seed

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":"<kind>"|"fail-once","unlink":<status>}`
— 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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e force-pushed the onboarding/05-first-run-account-client branch from 6e41931 to 1d5b1b1 Compare October 29, 2026 03:36
@matt2e
matt2e force-pushed the onboarding/04-builderlab-tenants branch from 1ee6dc5 to 1d6ceda Compare October 29, 2026 03:36

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Star Lord’s automated source review via Wes’s account.

Three P2 issues are detailed inline: challenge-origin binding, first-run transport after disabling Builderlab, and concurrent unlink safety. Optional: clarify that the seed/Welcome documentation describes the later stack integration.

Rocket, Mantis and Groot completed independent lanes. Reviewed head 1d5b1b1e8f8278405bda3f883ea0483a7fb94526, base 1d6cedae14db488ec7ef86ccf16325823128226c. This is account-client groundwork; the UI caller arrives later. Source-only: no tests, app execution, live account operations or CI verification; cancellation/focus behavior in the eventual UI remains unverified.

if (!reply) throw failure(step, response);
const { challenge_id, nonce, verification_code, origin, expires_at } =
reply as Record<string, unknown>;
return { challenge_id, nonce, verification_code, origin, expires_at };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P2 — Bind the returned challenge to the selected portal before signing. The request supplies portal, but the response's origin is forwarded unchecked to the native signer. BuilderlabChallenge::template accepts any build-granted origin, so a response from one tenant can obtain a binding signature scoped to another granted portal; the subsequent verify response check happens after the signature has already been disclosed. Reject origin !== portal before invoking the signer, and cover a reply naming a different granted origin with a test asserting no signature or verify request.

runCommand: () => Promise.resolve(null),
request: (request) =>
invoke("plugin_host_request", {
id: manifest.id,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P2 — Handle a persisted disabled Builderlab plugin before first-run sign-in. This transport calls plugin_host_request, whose host_grants lookup requires the bundled plugin to be enabled (crates/plugin-manager/src/lib.rs:1112–1122). Disabling Builderlab is persisted, and ordinary sign-out retains that registry (src-tauri/src/sign_out.rs:665–669). Thus this first-run helper can finish browser authentication and then fail credential exchange locally on the next setup. Give the app-owned flow narrowly scoped access independent of Settings-plugin enablement, or explicitly handle the disabled state before opening login, retaining configured-origin enforcement. Cover disabled-plugin → plain sign-out → first-run login.

if (before !== old) throw new Error(OTHER_KEY);
const removed = await call(
"account unlink",
"/v1/buzz/nostr-identities/delete",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P2 — Make unlink conditional on the old binding before exposing Start over. As the comment above acknowledges, the preflight read and unconditional delete are separate operations. If another device replaces the old binding between them, this delete removes that replacement; the later read sees no key and returns success. Readback cannot recover the deleted binding, so the replacement/third-key protection in this API's contract is not enforced. Require a server-checked expected old key under the binding lock and send it here; until that contract is available, keep this destructive path unavailable. Add a regression for a binding change between the read and delete.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants