镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

feat(identity): Set up your key — managed, own-key reveal, import — and Welcome back for a linked account - #1805

Closed
matt2e wants to merge 3 commits into
onboarding/03-builderlab-tenantsfrom
onboarding/04-key-choice-import-welcome-back
Closed

matt2e wants to merge 3 commits into
onboarding/03-builderlab-tenantsfrom
onboarding/04-key-choice-import-welcome-back

Conversation

@matt2e

@matt2e matt2e commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Part 4 of 12 of the onboarding-flow stack (the first-run onboarding work reviewed on #1061, split into stacked PRs per the plan). Base: onboarding/03-builderlab-tenants. Each PR is a contiguous slice of the original branch's history, cherry-picked onto main; the combined tree of the whole stack equals onboarding-flow at 572d69398 rebased onto main (verified by diff, see the stack notes in the last PR). Built on main at df5180b4a (2026-10-28, the origin/main of the build); main has moved on since and the stack is not rebased onto the newer tip.

Stack

  1. feat(identity): first-run welcome screen with community lookup, Join Community and the invite page #1802 onboarding/01-welcome-find-join
  2. feat(identity): Set up your profile after the first join, and look up a community URL from the landing page before sign-in #1803 onboarding/02-profile-step-landing-url
  3. feat(identity): decide the Builderlab portal from the community URL with a build-time tenant table #1804 onboarding/03-builderlab-tenants
    4. feat(identity): Set up your key — managed, own-key reveal, import — and Welcome back for a linked account #1805 onboarding/04-key-choice-import-welcome-back ← this PR
  4. feat(identity): link the first-run key to the account, and back it up through the observable native uploader #1806 onboarding/05-link-and-native-backup
  5. feat(identity): real unlink on Start over, Restore from backup, and the Create my key / I already have a key setup with Try again / Continue without backup #1807 onboarding/06-unlink-restore-create-my-key
  6. feat(identity): ask "Connect to your community" — sign in or bring a key — for a community no tenant hosts #1808 onboarding/07-connect-choice
  7. feat(identity): invite links on first run, restore the account's saved communities, and hand a typed address to the join dialog #1809 onboarding/08-invite-links-known-communities
  8. feat(identity): render first run on Kenny's onboarding layouts, and say "Sign in with email" #1810 onboarding/09-onboarding-layouts-and-copy
  9. fix(navigation): run the visit deadline only while a presenter is attached #1811 onboarding/10-navigation-deadline
  10. fix(identity): address Carl's review of #1061 — signed-in account exit, focus handoff, consent copy, shared join owner, a11y names, design pass #1812 onboarding/11-review-fixes
  11. [DO NOT MERGE] onboarding: full stack + isolated debug Keychain slot for native testing #1813 onboarding/12-do-not-merge-keychain-slot

Scope

createSetup pauses (key-choice, key-reveal, key-import, welcome-back, …), KeyChoice, KeyReveal + PrivateKey initiallyRevealed, KeyImport + key-import.ts + SetupProgress, WelcomeBack, PublicKeyPanel, BackupOffer, KeyLost, owned rows on Join a community, the real kgoose reads (builderlab-account.ts: nostr-identities/current, communities/list) and the BUZZ_DEV_FIRST_RUN_ACCOUNT seed (first-run-seed.ts).

Transitional code, stated plainly: KeyReveal.tsx and the PrivateKey change land here and are removed again in PR 06 of the stack (plan P, aa8c19da4). Rewriting that out of history was judged not worth the conflict risk.

Squash vs the original branch: 6adbb9a6a (reads kgoose's 200-with-null-identity as "not linked", names the failing read, keeps Back off the app after a saved key) folded into 99e508806.

Checks at this level (worktree /tmp/onb-stack, head of onboarding/04-key-choice-import-welcome-back)

  • bin/pnpm exec tsc --noEmit — 0 errors
  • bin/pnpm lint — 0 errors (21 pre-existing infos)
  • bin/pnpm design:check — all checks pass
  • git diff --check origin/main...HEAD — clean
  • bin/pnpm vitest run src/features/identity browser-host/vite-config.test.mjs — 12 files, 203 tests passed, 0 failed

Native evidence — not claimed here

No per-level native run was performed for this PR. Native first-run testing happens on the tree of the final DO-NOT-MERGE PR of the stack (#1813), which carries the isolated debug Keychain slot; the human in-app check for this PR's behaviour is owed there, so this PR stays a draft without buzz-review-completed until Matt confirms it.

🤖 Generated with Claude Code

matt2e and others added 3 commits October 28, 2026 22:36
…th a managed backup path and an nsec reveal page

After Builderlab sign-in on first run (Connect with Builderlab, or a tenant's
community URL on the landing page), setup no longer runs a fixed Downloading →
Creating → Uploading list. It now runs its prelude, pauses for the user to say
who looks after the key, and resumes with that choice's steps:

    sign-in ─► Finding communities… [TODO] (Connect only)
            ─► Checking for a backed-up key… [TODO] ─► Set up your key
      Manage my keys for me ─► Creating keys… ─► Backing up your key… [TODO] ─► Join …
      I want to manage my own key ─► Creating keys… ─► Your private identity key
            Please back this up for me ─► Backing up your key… [TODO] ─► Join …
            Continue without backing up (gated by the checkbox) ─────► Join …
      I already have a key — disabled, "Coming soon"
    any step fails ─► Couldn’t finish setup ─► Back ─► landing (unchanged)

"Join …" is Join Community for a landing URL or Join a community otherwise,
exactly as before. The key-only path (a URL no tenant hosts) is unchanged:
Creating keys… alone, then Join Community, with no choice or reveal page.

- `setup.ts`: `SetupSnapshot` gains `{ status: "paused"; at: "key-choice" |
  "key-reveal" }`. `start(steps, then = { status: "done" })` rests at `then`
  and is legal from `idle` or `paused`, so a choice resumes the same store; a
  double click is absorbed by the `running` guard, and `start([])` is the
  "nothing more to do" exit. `reset()` is unchanged. `setupSteps()` is
  replaced by `firstRunSteps(identity, delay)`, a named table
  (`findCommunities`, `checkBackup`, `createKey`, `backUp`) `Welcome` composes
  into phases, plus the two fakes with their `TODO(kgoose)` seams:
  `lookupBackedUpKey()` always answers `{ found: false }` and `backUpKey()`
  only waits. The `[TODO]` label marker stays on every placeholder;
  "Downloading keys…" and "Uploading keys…" are gone.
- `Welcome.tsx`: sign-in starts `[findCommunities?, checkBackup]` paused at
  `key-choice`; a `paused` branch renders `KeyChoice` / `KeyReveal`, whose
  handlers call `setup.start` with the rows above. The existing gate (children
  only when identity is ready and setup idle) needs no change: during the
  reveal the key exists but setup is paused, so the app stays hidden. Every
  back-to-landing path still runs `backToLanding`, and `reset()` is fine from
  a pause.
- `KeyChoice.tsx` (new, stateless): "Set up your key" — says no backed-up key
  was found for the signed-in email and that Builderlab can keep a copy for
  other devices; primary `size="lg"` **Manage my keys for me**, then two
  visibly smaller `size="sm"` buttons, **I want to manage my own key** and
  **I already have a key — Coming soon** (disabled, like Scan a QR code).
- `KeyReveal.tsx` (new): "Your private identity key" with the agreed body
  ("This key is how you sign in to Buzz. Don’t share it … nobody — not Buzz,
  not Builderlab — can reset or recover it if it’s lost. Copy it into a
  password manager like 1Password now."), the branch's wireframe box holding
  `<PrivateKey initiallyRevealed>`, a `Checkbox` "I’ve saved my private key
  somewhere safe", and the footer **Continue without backing up** (secondary,
  enabled only once the box is ticked; no other interaction tracking) /
  **Please back this up for me** (primary). No save-to-file; the code comment
  records why (no save dialog or file plugin in the webview — a key file would
  have to be written natively, deferred).
- `PrivateKey.tsx`: optional `initiallyRevealed`. On mount it runs the same
  `access(false)` Reveal runs (via `useEffectEvent`, once), renders the secret
  in a large monospace well (`<code>`, `font-mono text-body-lg`: no mono type
  role is larger than 12px yet, noted in a comment) instead of the `Input`, and
  makes **Copy private key** the primary button. The hygiene is untouched:
  window blur, document hiding, Hide and unmount still clear it through the
  same generation guard, and **Reveal private key** is offered again once
  hidden. The nsec comes from the existing `identity.exportKey()` →
  `identity_export`, which serves the key `identity_create` just stored; it is
  never shown as hex. Settings → Profile and Sign out render the default
  (masked) mode exactly as before.

No FOUNDATION file (App.tsx, services.ts, main.tsx, communities/service.ts),
no Rust, no new dependencies. Production diff ≈ +115/−45 lines across
setup.ts, Welcome.tsx, PrivateKey.tsx and the two new pages.

Tests (identity.test.tsx): `landingRelay` and `nativeRelay` learn
`identity_export` → the fixture nsec; a `manageKeysForMe()` helper carries the
join/profile journeys (`joinCommunityPage` and the three inline Join a
community cases) through the new pause unchanged. The Connect journey now
asserts Finding → Checking → Set up your key (with the email in the body, no
`identity_create` yet, the own-key button enabled and the import button
disabled) → Manage → Creating keys… → Backing up your key… → Join a community,
`delay` still `[[2000],[2000],[2000]]`; the tenant-URL journey asserts
Checking → choice → Manage → Backing up → Join Community with two delays; the
creation-failure journey releases two placeholders and clicks Manage before
the rejected `identity_create`, with no backup delay after it. New journeys:
own key → Creating keys… → reveal page with the nsec in a `font-mono` well on
arrival, `identity_export` once, Copy writing it to the clipboard without a
second export, window blur hiding it and offering Reveal, Continue disabled
until the box is ticked, then Join a community with exactly two delays and
`invoke` = restore, create, export; own key on the tenant path → Please back
this up for me → Backing up your key… → Join Community; own-path creation
failure → Couldn’t finish setup → Back → landing with identity missing and
`identity_export` never invoked. All six changed/new journeys fail against the
previous Welcome/setup/PrivateKey and pass now. One jsdom quirk is worked
around in the own-key test and commented: after the clicked choice button
unmounts, jsdom aims the next focus move's `blur` at `window`, which
`PrivateKey` rightly treats as "hide"; the test parks focus on `body` first.
docs/identity.md rewrites the first-run paragraph for the new order and pages
and adds the first-run exception to "UI and sensitive data"; the Builderlab
README's first-launch sentence matches.

Validation: `bin/pnpm vitest run src/features/identity src/features/communities
src/app` (59 files, 740 tests), `tsc --noEmit`, `pnpm lint`, `biome check` on
the changed files, `pnpm design:check`, `git diff --check`, and
tests/browser/window-chrome.spec.mjs in Chromium (passes; the "Buzz" heading
after the launch handoff is unchanged). Not exercised in the native app: no
real first run was performed, so the reveal page against a key `identity_create`
just stored in the Keychain (one `identity_export` on arrival, no second ACL
prompt expected since the key is in memory), the clipboard write from
WKWebView on a first-run page, and the choice/reveal pages' appearance in the
packaged window in both themes remain to be tried by hand. Reset this
branch's isolated dev slot before such a run:
`security delete-generic-password -s dev.local.buzz.foundation.identity.debug.onboarding -a human`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
… key choice

**I already have a key** on the first-run **Set up your key** page (5d0c3818)
is live: it drops its "— Coming soon" suffix and opens a new **Enter your
private key** page, modelled on the old desktop app's key-import form, while
setup stays paused. An imported key skips the backup and reveal pages and
continues straight to **Join Community** (landing URL) or **Join a
community**, exactly where the created-key paths end:

    Set up your key ─► I already have a key ─► Enter your private key
        Back ─────────────────────────────────► Set up your key (all three live)
        Continue with this key ─► Importing your key… ─► Join …
        native refusal ─► same page, key kept, message under the field

The key-only path (a URL no tenant hosts) is unchanged and never shows the
choice page.

- `key-import.ts` (new, pure): `classifyKeyImport(text)` trims and returns
  `empty`, `encrypted` (`ncryptsec1…` in any case — a NIP-49 backup; the app
  has no NIP-49 decryption and the native parser rejects the prefix, so it is
  refused with a clear message rather than adding Rust), `nsec` with the text
  as typed plus the npub preview (`nostr-tools/nip19` `decode` → `getPublicKey`
  → `npubEncode`; derivation also rejects an out-of-range secret, as native
  import does), or `invalid`. `readKeyFile(file)` refuses anything over
  `KEY_FILE_MAX_BYTES` (1024) before reading and returns the first non-blank
  line, trimmed.
- `KeyImport.tsx` (new): "Enter your private key" / "Paste your private key to
  sign in to Buzz. You can also drop a text file holding it here, or choose
  one." A `Field` "Private key (nsec)" with a masked `Input`
  (`type="password"`, `autoComplete="off"`, `autoCorrect`/`autoCapitalize`
  off, `spellCheck={false}`, focused on arrival), **Reveal private key** /
  **Hide private key** (disabled while empty; masking re-asserts when the
  field empties), **Choose a key file** opening a hidden `type="file"` input,
  and the whole form as a drop target through the existing `useFileDrop`
  (`src/features/messages`, already imported across features), with a hint
  line that flips while dragging. The field's description is the npub preview
  ("Public identity: npub1…", never hex); its error is the classification —
  "Password-protected backups aren’t supported here yet. Paste the nsec1 key
  itself." / "That doesn’t look like a valid nsec. Paste an nsec1 key." — or
  a file/native failure. Footer in branch order: **Back** (secondary) then
  **Continue with this key** (primary, enabled only for a valid nsec; Enter in
  the field submits). While `onImport` is pending the component renders the
  "Setting up Buzz" progress page with "Importing your key…" but stays
  mounted, so a native refusal (an invalid key, a key already in the slot)
  returns to the form with the typed key kept and the message under the
  field — never the fatal "Couldn’t finish setup" page. The key lives only in
  this component's state: leaving the page discards it; nothing logs or
  persists it. Wireframe box like the sibling pages.
- `SetupProgress.tsx` (new, 23 lines): the "Setting up Buzz" heading and
  `CircleNotchIcon` spinner recipe extracted verbatim from `Welcome`'s running
  branch so both `Welcome` and `KeyImport` render the same page.
- `setup.ts`: `SetupPause` gains `"key-import"`. `createKey`'s snapshot check
  becomes `saved(identity)`, shared with new `importKey(identity, nsec)`,
  which calls the existing `identity.importKey` (native `identity_import`,
  the same command `IdentitySetup` uses) and re-raises the snapshot's error.
  It is deliberately not a step: a refusal belongs under the field. No new
  store method: moving between pauses is `start([], { status: "paused", at })`,
  which rests there synchronously and runs nothing, since no key exists yet.
- `KeyChoice.tsx`: `onImport` prop; the button is enabled and reads
  "I already have a key".
- `Welcome.tsx`: `pause(at)` helper; the paused branch renders `KeyImport` for
  `key-import` with `onBack={() => pause("key-choice")}` and
  `onImport={async nsec => { await importKey(identity, nsec); void
  setup.start([]); }}`; the running branch uses `SetupProgress`. The gate
  (children only when identity is ready and setup idle) is unchanged.

Native import takes the bech32 `nsec1…` string as typed (`Key::parse` in
`src-tauri/src/identity.rs`: 63 chars, `nsec1`/`NSEC1`, bech32-checked), so no
TS hex decoding and no Rust change. The profile step already prefers an
existing relay kind 0 (`read.exists ? read.profile : client.profile`) and the
join reads `memberships.length` from the live service, so nothing assumes the
key was created. No FOUNDATION file (App.tsx, services.ts, main.tsx,
communities/service.ts), no Rust, no new dependencies (`nostr-tools/pure` is
the already-installed package's existing entry point). Production diff ≈
+50/−30 in existing files plus 241 new lines across the three new files.

Tests: `key-import.test.ts` (node) covers a valid nsec as typed, trimmed of
surrounding whitespace/newlines, and all-uppercase; `ncryptsec` in lower and
mixed case; garbage, a bare `nsec1`, a bad checksum, an npub and raw hex as
invalid; empty/whitespace; and `readKeyFile` returning the first non-blank
CRLF line, "" for a blank file, and refusing an oversize file without reading
it. `identity.test.tsx` adds a real keypair (`owned`/`ownedNsec`) and five
journeys: choice → I already have a key → masked/focused field with
autocomplete and spellcheck off, invalid → error + Continue disabled,
ncryptsec → unsupported message, valid → npub preview as the accessible
description with no hex, Reveal/Hide, Back → choice with all three buttons
enabled, re-open → field empty, `invoke` = restore only; Continue → exact
`invoke("identity_import", { nsec })`, "Setting up Buzz" / "Importing your
key…" with the form and children hidden, then Join a community with identity
ready as the imported pubkey, no reveal heading, `delay` = `[[2000],[2000]]`,
`invoke` = restore, import; a rejected `identity_import` → message under the
field, form kept with the key, Continue enabled, identity still missing,
editing replaces the message, Back → choice; a dropped file fills the field
from its first line (masked, preview shown), an oversize chosen file is
refused with the field unchanged, a chosen file with other text classifies
as invalid; and the tenant-URL path → import → Join Community with one delay
and `invoke` = restore, 2× relay_http, import, 2× relay_http. The Connect
journey now asserts the import button enabled. All five new journeys fail
against the previous Welcome/KeyChoice/setup (checked by swapping HEAD's
files in) and pass now. `landingRelay` learns `identity_import`.
docs/identity.md describes the page in the first-run section and adds it to
"UI and sensitive data"; the Builderlab README sentence mentions bringing an
existing key.

Validation: `bin/pnpm vitest run src/features/identity src/features/communities
src/app` (60 files, 748 tests), `tsc --noEmit`, `pnpm lint`, `biome check` on
the eight changed files, `pnpm design:check`, `git diff --check`, and
tests/browser/window-chrome.spec.mjs in Chromium (passes). Not exercised in
the native app: no real `identity_import` was run from this page against the
Keychain, so the native refusal copy for a key already in the slot, the
WKWebView file-drop and file-picker paths, `autoFocus` and the masked field's
behaviour with the system password manager, and the page's appearance in the
packaged window in both themes remain to be tried by hand. Reset this
branch's isolated dev slot before such a run:
`security delete-generic-password -s dev.local.buzz.foundation.identity.debug.onboarding -a human`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
… instead of offering a new one

After Builderlab sign-in on first run (Connect with Builderlab, or a tenant's
community URL on the landing page), setup no longer assumes a new user. It now
asks kgoose which Nostr key is bound to the signed-in account and forks:

    sign-in ─► Checking your account… (REAL)
      not linked ─► Checking for a backed-up key… [TODO] ─► Set up your key
                      (Manage / own key / I already have a key, unchanged)
      linked ─────► Checking for a backed-up key… [TODO] (silent) ─► Welcome back
          Enter your private key ─► Importing your key… ─► Back up this key?
              Not now ───────────────────────────► Finding communities… (REAL) ─► Join …
              Back it up ─► Backing up your key… [TODO] ─► Finding communities… ─► Join …
          Recover from another device — disabled, "Coming soon"
          I lost this key ─► Finding communities… (REAL) ─► Start over with a new key
              Back ─► Welcome back
              Start over ─► Unlinking your old key… [TODO] ─► Set up your key (as above)

"Join …" is Join Community for a landing URL or Join a community otherwise, as
before. Binding is 1:1 and enforced on the server, so the linked lane never
offers Manage my keys for me or I want to manage my own key, and the import page
accepts only the linked key. The key-only path (a URL no tenant hosts) is
unchanged.

What is real and what is faked:
- REAL: `CurrentNostrIdentity` (`POST <loginOrigin>/api/goose/v1/buzz/nostr-identities/current`)
  and `ListBuzzCommunities` (`POST …/v1/buzz/communities/list`), in new
  `src/features/identity/builderlab-account.ts`. Both are POSTs with `{}`,
  `X-BB-Session-Credential` from the first-run OAuth session and `Origin` set
  to the login origin `discoverAuthentication` chose (the build URL otherwise,
  through the same `oauthTarget` check sign-in uses), sent through
  `bundledHost.request` — the bundled plugin's native grant `first-run.ts`
  already uses for sign-in. Reply shapes and parsers are the hosted-communities
  card's (`boundKey`, `relayUrl`, `check`, `Reply` from `api.ts`; the route
  paths from `native.ts` ROUTES `identity`/`list`); the card's own backend and
  behaviour are untouched. A 400 `MISSING_MAPPING` / `setup_needed` reply is
  "not linked" (and an empty owned list); a bound identity without a usable key
  is a failure, never "not linked"; any other failure of the account check is
  the existing "Couldn’t finish setup" page. Owned communities exclude archived
  ones (no relay to join) and hosts `enteredRelayOrigin` refuses.
- FAKED, still `[TODO]` and only waiting: the backed-up-key lookup (a linked
  key does not imply a backup — the nsec-backup record is a separate opt-in
  table no shipped client writes — and no copy ever implies the user lost
  one); "Backing up your key…" (linked lane) and the renamed "Backing up and
  linking your key…" (created-key lanes), whose seam comment now names the
  HPKE envelope upload and the kind-24243 challenge/verify binding the
  hosted-communities card drives in `native.ts` `bind`; and "Unlinking your
  old key…", a seam for kgoose `DeleteNostrIdentity` (`/nostr-identities/delete`,
  the card's `unbind` route) that is deliberately not called.

Pages and behaviour:
- `WelcomeBack.tsx` (new): "This Builderlab account is already linked to a Buzz
  identity — the key you use on your other device. Enter that key to continue."
  with the full linked npub under *Linked public key (npub)* and **Copy npub**
  plus inline `role="status"`; **Enter your private key** (primary),
  **Recover from another device — Coming soon** (disabled; comment names
  NIP-AB pairing as the future entry), **I lost this key** (ghost, sm).
- `PublicKeyPanel.tsx` (new): the invite page's npub block extracted from
  `Welcome`'s `Failure` so both pages render the same recipe; `Failure` shrinks.
- `KeyImport`: optional `expectedPubkey` (hex). A valid nsec whose npub differs
  shows "That isn’t the key linked to this account. It expects npub1… (the key
  on your other device)." under the field, Continue stays disabled and Enter
  does nothing; the comparison happens in the page, so a mismatched key never
  reaches `identity_import`. Back returns to Welcome back on this lane.
- `BackupOffer.tsx` (new): "Back up this key?" — Not now / Back it up.
  Importing never uploads silently.
- `KeyLost.tsx` (new): "Start over with a new key" with the loss explanation,
  a *Communities you own* wireframe box (hostnames, "None", or a could-not-load
  line when the read failed) and the note that memberships in other people's
  communities cannot be listed and are lost too; Back / destructive Start over.
- `JoinCommunity`: optional `owned` rows above the URL field — `Avatar`
  placeholder, hostname, **Join** — where Join calls `onFind(origin)`, exactly
  the typed-URL path, so Find/Join Community do the work. Empty or failed list
  renders the page as before. The "Finding communities… [TODO]" placeholder is
  gone from the Connect prelude; the real read runs at the end of the linked
  lane, after a key is on the device, and before the start-over page. The old
  key's list is cleared on Start over, so it is never offered to the new key.
- `setup.ts`: `SetupStep.run` may resolve to a resting state, which overrides
  the caller's `then` (the seam the backed-up-key TODO had reserved); new
  pauses `welcome-back` / `backup-offer` / `key-lost`; step factories
  `checkAccount(account, onLinked)` and `findCommunities(account, onFound)`;
  table rows `backUp`, `backUpAndLink`, `unlink`. `createSetup` is otherwise
  unchanged.
- `Welcome.tsx`: new injectable `account` prop (default: the dev seed when set,
  else `builderlabAccount` over `bundledHost.request`); `linked` / `owned`
  state and a `reads` ref, all cleared on every return to the landing; the
  paused branch becomes a `switch` over the six pauses.

Dev seed for demos: `BUZZ_DEV_FIRST_RUN_ACCOUNT` — `unlinked`, or a JSON object
`{"npub":"npub1…","communities":["host",…]}` — replaces the two kgoose reads and
nothing else. `vite.config.ts` validates it (`parseFirstRunAccountSeed` in new
leaf module `first-run-seed.ts`, with 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, so packaged apps always ask kgoose.
Documented in docs/configuration.md and .env.example. Matt's staging account is
bound to his real key, which this never touches.

No FOUNDATION file (App.tsx, services.ts, main.tsx, communities/service.ts), no
Rust, no new dependencies. Production diff ≈ +330/−100 across Welcome.tsx,
setup.ts, KeyImport.tsx, JoinCommunity.tsx and vite.config.ts, plus ~360 lines
in the six new source files.

Tests: `builderlab-account.test.ts` (request shape at a tenant and at the build
URL, bound key, MISSING_MAPPING/setup_needed ⇒ null, unauthorized / keyless /
non-JSON / unstructured / oversize / string-refusal / transport failures,
owned-list filtering) and `first-run-seed.test.ts` (unlinked, npub + hosts,
rejections without echo). `identity.test.tsx`: every existing render gets an
`unlinked` account stub, the Connect journey gates the real check and asserts
"Checking your account…" first, placeholder counts drop by the removed Finding
step, and the created-key label is "Backing up and linking…". New journeys:
Welcome back with the full npub and Copy → import Back → mismatch refused with
the expected npub and Continue disabled → linked key → Importing → Back up this
key? → Not now → Join a community as the linked key; Back it up running the
backup step; I lost this key → owned hosts listed → Back → Start over →
Unlinking → Set up your key → Manage → Join a community with no rows (fails
without the owned-list clear); owned rows whose Join opens Finding community for
that host with the two GETs; tenant URL + linked → Welcome back → import → Join
Community with the reads at the tenant origin; account-check failure → Couldn’t
finish setup → Back → landing; the env seed answering in place of kgoose.
`vite-config.test.mjs` checks the define for serve vs build and the non-echoing
rejection. docs/identity.md rewrites the first-run section for the fork and
adds the lane to "UI and sensitive data"; the Builderlab README sentence names
the reads.

Validation: `bin/pnpm vitest run src/features/identity src/features/communities
src/app src/bundled/builderlab browser-host/vite-config.test.mjs` (75 files,
922 tests), `tsc --noEmit`, `pnpm lint`, `biome check` on the changed files,
`pnpm design:check`, `git diff --check`, and
tests/browser/window-chrome.spec.mjs in Chromium (passes). Nothing was exercised
natively: no real `nostr-identities/current` or `communities/list` call was made
from the bundled grant against kgoose, so the staging portal accepting the
first-run credential on those routes, the native `plugin_host_request` path for
POSTs with a body, and the new pages' appearance in the packaged window remain
to be tried by hand (with `BUZZ_DEV_FIRST_RUN_ACCOUNT` for the linked lane).

Squashed for the stacked PR — 6adbb9a (fix(identity): read kgoose's unlinked answer, name the failing account read, and keep Back off the app after a saved key):

fix(identity): read kgoose's unlinked answer, name the failing account read, and keep Back off the app after a saved key

Three fixes in src/features/identity from the native walkthrough of c67ecfc49
("Take 0" stopped on "Couldn’t finish setup — Builderlab returned an invalid
response") and that commit's review.

1. `builderlab-account.ts` `linkedKey`: kgoose's
   `POST …/v1/buzz/nostr-identities/current` (`NostrIdentityService.current`)
   answers **200 with `identity` null or absent** for an account with no Nostr
   mapping; it never sends `MISSING_MAPPING` — only `communities/list` does.
   The check read that 200 as "a bound identity without a usable key" and
   failed setup for every genuinely new user. A 2xx reply with no `identity`
   is now *not linked* and continues to Set up your key. A present identity
   whose `pubkey_hex` is not 64 lowercase hex still fails (a linked account
   must never be given a second key), and every non-2xx / non-JSON answer
   still fails closed to the setup-failed page. `notLinked` keeps the
   `MISSING_MAPPING` / `setup_needed` reading for the list route and now
   checks `typeof code === "string"` first, so a malformed body yields the
   fixed fallback message instead of a TypeError's text (review nit).

2. Diagnosability: the failure copy names the step and HTTP status — never
   the response body, per the never-forward-provider-bodies rule —
   "Builderlab account check failed (HTTP 403: not JSON)." /
   "… (HTTP 500)." / "… (HTTP 200: response too large)." /
   "… (HTTP 200: unusable linked key)." and "Builderlab community list failed
   (…)" for the list, so a 403 text/plain is told from a JSON shape problem
   and the account check from the list. `call` takes the step name and a
   `read` callback so every refusal of an answer passes one `catch`, which in
   development builds (`import.meta.env.DEV`) `console.warn`s
   "Builderlab <step>: POST <route> answered HTTP <status>" plus our own
   message, again without the body. The card's structured-error messages
   (`check`) are unchanged.

3. `Welcome.tsx` (review warning): when a step failed *after* a key was saved
   — backup after an import, backup-and-link after creation, the unlink seam
   once real — the failed page's Back ran `backToLanding` → `setup.reset()` →
   `idle`, and with identity `ready` the gate returned `children`: the app
   opened with no community, skipping Join. Back now rests on the Join page
   (`setup.start([])` → `done`: Join Community for a landing URL, Join a
   community otherwise) when `state.status === "ready"`, and still goes to
   the landing when no key exists. `createSetup().start` admits `failed` as
   well as `idle`/`paused` (the guard is now "not running or done"), so a
   failed setup can be resumed rather than reset first.

No FOUNDATION file (App.tsx, services.ts, main.tsx, communities/service.ts),
no Rust, no new dependencies. Production diff ≈ +75/−25 across
builderlab-account.ts, Welcome.tsx and setup.ts.

Tests: builderlab-account.test.ts reads `{ identity: null }`, `{}`, a 400
`MISSING_MAPPING` and `setup_needed` as not linked; pins the new copy for an
unusable and a malformed linked key, a 403 text/plain, an unstructured 500, a
non-string error `code` (fallback message, no TypeError) and an oversize
body; checks the dev warning carries the route and status and not the body;
and gives the list route its own not-JSON message. identity.test.tsx gains a
`reject` on `deferred` and a `fail(index, error)` on `gatedDelay`, and two
journeys: a rejected backup after the linked key is imported → Couldn’t
finish setup with the key ready and children hidden → Back → Join a
community, children still hidden, no landing; and a rejected unlink before
any key → Back → the first-launch screen with identity missing. The
account-check failure journey's injected message takes the new shape. Against
HEAD's three source files 11 of the changed/new cases fail (the no-key unlink
case passes on both, as a guard for the unchanged branch) and all pass now.
docs/identity.md describes the 200-no-identity reading, the named failure
copy, and where Back lands with and without a key.

Validation: `bin/pnpm vitest run src/features/identity src/features/communities
src/app` (64 files, 806 tests), `tsc --noEmit`, `pnpm lint`, `biome check` on
the changed files, `git diff --check`. The real `nostr-identities/current`
read is still unverified natively: no call was made from the bundled grant
against kgoose, so the 200-with-null-identity reading rests on the walkthrough
and the cash-server source, and the next native first run should confirm it
(the dev warning now names the route and status if it does not).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e marked this pull request as ready for review October 28, 2026 12:26
@matt2e
matt2e requested review from a team as code owners October 28, 2026 12:26
@matt2e

matt2e commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Closing: this stack was cut as chronological slices of the branch history rather than logical, independently reviewable layers. It will be replaced by a stack built from the squashed diff. Nothing from this PR was merged.

@matt2e matt2e closed this Oct 28, 2026
@matt2e
matt2e deleted the onboarding/04-key-choice-import-welcome-back branch October 28, 2026 12:30
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.

1 participant