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

feat(identity): link the first-run key to the account, and back it up through the observable native uploader - #1806

Closed
matt2e wants to merge 5 commits into
onboarding/04-key-choice-import-welcome-backfrom
onboarding/05-link-and-native-backup
Closed

matt2e wants to merge 5 commits into
onboarding/04-key-choice-import-welcome-backfrom
onboarding/05-link-and-native-backup

Conversation

@matt2e

@matt2e matt2e commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Part 5 of 12 of the onboarding-flow stack (the first-run onboarding work reviewed on #1061, split into stacked PRs per the plan). Base: onboarding/04-key-choice-import-welcome-back. 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
    5. 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 ← this PR
  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

bind() + the native binding signer's origin allow-list (src-tauri/src/identity.rs), the native BackupStatus store with tickets (nsec_backup.rs; nsec_backup_watch / nsec_backup_state registered in lib.rs, build.rs and capabilities), the explicit flock(LOCK_UN) lease fix, the first-run waiter backUpWith + backup-failure.ts + setup retry / fallBack, and the read-only Settings → Identity "Key backup" row.

Commits kept as on the original branch: each carries its own fail-before evidence, and the lock fix (2154b2b7a) is a standalone correctness change a Rust reviewer will want to see alone. The Settings-row commit also makes "I want to manage my own key" primary when Manage is hidden (a PR 04 page) — accepted overlap.

Checks at this level (worktree /tmp/onb-stack, head of onboarding/05-link-and-native-backup)

  • 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 src/app browser-host/vite-config.test.mjs — 58 files, 813 tests passed, 0 failed
  • bin/cargo fmt --check — clean
  • bin/cargo test --manifest-path src-tauri/Cargo.toml --locked identity — 36 passed, 0 failed
  • bin/cargo test --manifest-path src-tauri/Cargo.toml --locked nsec_backup — 49 passed, 0 failed
  • bin/cargo clippy --workspace --locked --all-targets -- -D warnings — clean

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 5 commits October 28, 2026 22:37
…ilderlab account ("Linking your key…")

Implements C1 from the onboarding plan (§7): first run now binds the key
it creates or imports to the signed-in Builderlab account instead of
leaving binding inside the "Backing up and linking your key" placeholder.

FirstRunAccount gains bind(pubkey). It reads kgoose `current` first and
returns without another request when the account already holds that
key (the plan's amendment), so running it again after a failure never
meets 409 `pubkey_already_bound`. Otherwise it requests a challenge for
the portal origin, has the native identity_sign_builderlab_binding
command sign it as a kind-24243 event, and posts it to verify, which
must confirm this key. Every request carries the portal `Origin`.
Refused links (409 `identity_already_bound` / `pubkey_already_bound`)
show the hosted-communities card's copy; other failures name the step
and HTTP status, as the account check does, and a native signer refusal
surfaces as-is.

setup.ts replaces backUpAndLink with linkKey ("Linking your key…"),
which binds the saved viewer key; backUp is now only "Backing up your
key… [TODO]". Welcome runs it:
- Manage: Creating keys → Linking your key → Backing up your key.
- Own key: Creating keys → Linking your key, then the key reveal.
- Import on the unlinked (new-account) lane: Importing your key… →
  Linking your key → Join a community.
- Import on the Welcome back (linked) lane: no link step; the imported
  key is the linked one.
The development seed (seededAccount) fakes bind() with no request and
no native signature, so a developer's real binding never changes.

The native signer (src-tauri/src/identity.rs) now accepts a challenge
for production Builderlab or any login origin the build grants
(buzzodz_plugins::builderlab_origins(): the BUZZ_BUILDERLAB_URL origin
and each tenant's) instead of only production or the build URL origin.
Unknown, case-changed, trailing-slash, port-changed and empty origins
are still refused.

Tests:
- Rust: the template allow-list test covers granted, production,
  ungranted and malformed origins; the production-IPC test signs and
  verifies for every granted origin and still refuses a foreign one
  (fails on the old rule with "https://block.builderlab.xyz was
  refused").
- builderlab-account.test.ts: bind's full request shapes at a tenant
  and at the build URL, the exact signer invoke, the already-bound
  no-op, the card's 409 copy, non-JSON and unconfirmed verify answers,
  and a signer refusal.
- identity.test.tsx: the Manage, own-key and unlinked-import journeys
  hold bind on a gate and assert "Linking your key…" and its key; the
  linked lane never binds; a failed link stops setup before the backup
  and Back rests on Join a community without entering the app; the
  seed links with only identity_restore/identity_create invokes.
17 TypeScript cases fail against the previous builderlab-account.ts,
setup.ts and Welcome.tsx; the seed journey was checked as a guard by
removing the fake bind.

Docs: identity.md, configuration.md and the builderlab README describe
the link step, the seed's fake binding and the signer's origin rule.

No FOUNDATION file changed. The "Create my key" / Try again / Continue
anyway product rule has not landed on this branch and is not
implemented here.

Validation: bin/pnpm vitest run src/features/identity
src/features/communities src/app src/bundled (4090 passed, 8 skipped),
tsc --noEmit, pnpm lint, biome check on changed files, cargo test
--manifest-path src-tauri/Cargo.toml --locked identity (33 passed),
cargo fmt --check, git diff --check.

Known interim gaps:
- A link failure on the Manage, own-key or unlinked-import lane ends at
  Couldn't finish setup; Back lands on Join a community with the key
  saved but unlinked, and on the own-key lane never revealed. C2's
  fallback closes this.
- Start over → Set up your key links a new key while kgoose still holds
  the old one, so a real kgoose refuses it (409
  `identity_already_bound`) until C4's real unlink lands.

Not exercised in the native app: the Builderlab/Okta sign-in is
human-only, so plugin_host_request forwarding `Origin`, the staging
portal accepting challenge/verify from the first-run credential, and a
real bind remain unverified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…ket for each handoff

First half of C2 (note 4f94a6a2 §7), on top of C1 (e850e92): the native
uploader becomes observable, so the first-run waiter and a Settings row can
follow up on a specific backup pass. Neither of those is in this commit.

State model (C2.1, src-tauri/src/nsec_backup.rs)
- `BackupStatus { seq, pass, builderlab, status… }`, where `status` is one of:
  - `idle`, `running`, `complete`;
  - `waiting { reason: no_consent | no_session | not_linked }`;
  - `failed { kind: busy | signed_out | not_offered | other_key_linked |
    invalid_service | refused | unreachable | unconfirmed | storage }`.
- `builderlab` is true when a storage root exists and the Builderlab source is
  allowed for this build.
- `backup()` now returns `Result<Outcome, Stop>`. Failures follow C2.4's
  native rows:
  - 401 → signed_out.
  - 403 → other_key_linked on upload, not_offered on enrollment; 404 →
    not_offered.
  - 429, 5xx and transport errors → unreachable; other 4xx → refused.
  - Enrollment 409 → waiting{not_linked}.
  - An invalid URL, credential or enrollment → invalid_service.
  - An upload response that isn't a stored, matching receipt → unconfirmed.
  - `Api` reads fold every status into one error, so they all report
    unreachable.
  - Another copy holding the consent lock → busy (`nsec_backup_choice::BUSY`).
  - Other local consent, record or key I/O → storage. No response body
    crosses IPC.
- Publish points, all under the state lock so pushes leave in seq order:
  - A spawned pass publishes `running`, then its outcome.
  - A queued rerun publishes `running` under the latest ticket.
  - A null handoff that retires a running pass publishes waiting{no_session}.
  - `document_load` turns a running pass into `idle`.
  - A retired or closed pass publishes nothing; the next pass's `running`
    replaces it.

Tickets
- `trigger`, `session` and `retry` return `Option<u64>`: the number of a pass
  that starts after the call, or None when no pass will.
- A wake during a pass gets a ticket covered by the single queued rerun.
- `nsec_backup_session` and `nsec_backup_retry` return `number | null`.

Exposure (C2.2)
- `nsec_backup_state` returns the snapshot.
- `nsec_backup_watch(Channel)` replaces the single watcher slot and refuses
  any window but main, as `deep_link_watch` does. A stale channel's failed
  send is ignored.
- Both commands are registered in lib.rs, build.rs, capabilities/default.json
  and browser_permissions_tests.rs.

TypeScript
- `createBackupStatus` (src/features/identity/backup.ts) registers nothing
  until the first `subscribe` or `ready()`. Then it watches before reading
  the snapshot and keeps the higher `seq`.
- After a failed registration, the next reader registers again. `dispose`
  silences the channel.
- `identity.backup` exposes the store. It stays out of `IdentitySnapshot`, so
  the identity journeys' exact invoke lists are unchanged.
- `handoffBackupSession` resolves to the ticket, or null on a refusal or
  rejection, and never rejects. Existing callers (Settings plugin, hosted
  card, agents client) still ignore the result; the worker behaves as before
  for them.
- docs/identity.md describes the observable status.

Tests
- Rust cases 1–12 from C2.9:
  - Running, then the outcome, under the returned ticket.
  - The full waiting/failure table, including busy from a held consent lock.
  - Rerun tickets; supersede by key install; null handoff; document_load
    with a stale and a new watcher; no consent; not linked, then retried.
  - Ticket returns; retry after a timeout reusing the same bytes; flat
    serialization and `builderlab`; permissions.
- Four existing backup tests now assert typed `Stop` kinds instead of
  `is_err()`.
- New tests wait at most 5 s for an upload to start, then fail with the
  statuses the pass reported, so they don't hang.
- backup.test.ts covers:
  - Lazy registration and watch-then-state order.
  - Keeping the higher seq from the channel or the snapshot.
  - Registering again after a failed watch, and dispose.
  - Handoff tickets and null.
- backup-choice.test.ts checks that creating an identity never invokes the
  watch or state commands.
- Mutation checks:
  - Breaking four Rust publish points failed four targeted tests.
  - Six TS store mutations each failed a backup.test.ts case: equal seq
    accepted, failed watch cached, dispose leaving the channel live, state
    read before watch, rejection propagating, ready() returning the first
    status.

Validation run on this snapshot (macOS):
- `cargo test --manifest-path src-tauri/Cargo.toml --locked`:
  - 47/47 nsec_backup tests in 15 isolated runs, and again after the last
    test edit; the permissions test passes.
  - Full-suite runs pass apart from the flakiness below. The last one failed
    once: case 6 reported failed{storage}.
- cargo fmt --check, and `cargo clippy --workspace --locked --all-targets
  -- -D warnings`.
- `vitest run src/features/identity src/bundled src/app` (3807 passed).
- tsc --noEmit, pnpm lint, biome check on changed files, git diff --check.

Known flakiness (pre-existing, not fixed here)
- Under the full suite on macOS, nsec_backup tests sometimes see a spurious
  busy or storage result. Some of these are new tests.
- Cause: `fs2` flock locks are inherited by children that other tests in the
  same process spawn, until each child execs. A scratch probe, run 3000
  times, found the shared lock still busy right after the exclusive lock was
  released:
  - 0 times with no spawner;
  - 31 times with a `posix_spawn` spawner;
  - 97 times with a `fork` + `pre_exec` spawner.
- The base commit shows the same: `absent_or_invalid_consent…` failed in 2
  of 8 full runs.
- Fixing it means changing how the consent and record locks treat
  contention, which this commit leaves alone. It needs a decision, because
  in the app a concurrent agent or terminal spawn could surface as a
  spurious failed{busy}. Linux CI exposure was not measured.

Diff budget: about +338/−57 production lines (+240/−46 in nsec_backup.rs)
against the plan's ≈+150/−30 for Rust. The overrun is rustfmt expansion, doc
comments on the specified types, and the `From`/`classify` helpers the
classification table requires. No machinery beyond the plan.

No FOUNDATION files. No rebase. The native app was not run: no
installed-app, WebView-channel or live-service check was performed.

Next: the first-run waiter (`backUpWith`), backup-failure.ts, setup.ts /
Welcome.tsx wiring, and the Settings row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…d cannot hold them

Fixes the pre-existing nsec_backup lock flake noted in 27ee14d. The
consent lock (`nsec_backup_choice::lock`) and the per-identity record lock
(`persistence::LockedRecord`) relied on closing their file to release the
`flock`. Rust opens files close-on-exec, but a child spawned through fork
or posix_spawn shares the parent's open file description until it calls
exec. Closing our descriptor does not release a `flock` while another
descriptor still refers to that description, so the next `try_lock` saw
contention. The pass then reported a spurious `failed{busy}` (consent) or
`failed{storage}` (record).

Root-cause fix instead of the agreed bounded retry: an explicit
`flock(LOCK_UN)` releases the lock for every descriptor that shares the
description, including the child's inherited copy. New
`nsec_backup_choice::Lease` holds the locked file and unlocks it in `Drop`,
and `lease(file, exclusive)` is the only way to build one. Both sites use
it. `BackupChoice._lease`, `set`'s exclusive lease and
`LockedRecord._lock` now hold a `Lease`. This is the pattern
`crates/credential-store` (`Lock`) and agent-controller's `bundle_lock.rs`
already use, with the same comment.

Why not the retry: once the lock is released at drop, there is no
transient contention left to wait out. A retry would add a timer and delay
for no remaining cause. Contention from a lease that is genuinely held
(another app copy, or a choice change during an upload) still reports
`busy` / the record failure at once, exactly as before. Error messages and
every caller are unchanged. Windows handles are not inheritable and already
released on close; the explicit unlock is harmless there.

Scratch probe (outside the repo, 3000 trials each, four spawner threads,
"lock exclusive, release, then try_lock_shared on a new descriptor"):

| spawner                        | close only | unlock, then close |
|--------------------------------|------------|--------------------|
| none                           | 0          | 0                  |
| posix_spawn (`std::process`)   | 29         | 0                  |
| fork + `pre_exec`              | 57         | 0                  |

Tests: one per lock site. A `dup` of the lease's descriptor (`try_clone`,
via the test-only `Lease::inherited()`) stands in for a spawned child's
inherited copy, since it is the same kernel relationship and needs no
spawn. With that copy still open, a held lease makes another acquisition
fail at once (`BUSY` / the record's `FAILED`). Once the lease drops, the
lock is acquired.
- nsec_backup_choice/tests.rs:
  a_released_lease_is_free_even_while_a_spawned_child_still_shares_it
- nsec_backup/persistence.rs:
  a_released_record_lock_is_free_even_while_a_spawned_child_still_shares_it
With the `Drop` unlock removed, both fail at the post-release assertion.

Full-suite before/after (macOS, this machine, same snapshot otherwise):
copies of the base (27ee14d) and fixed lib test binaries, run in
alternation, 25 runs each, with a 120 s watchdog per run so a hang counts
as a failure:
- base: 14/25 runs failed (13 failed runs, 1 hung run). Every failure but
  one was an nsec_backup test reporting a spurious `Busy` or "Backup retry
  state is unavailable". The hang was
  completion_persistence_failure_retains_the_same_pending_bytes_for_restart
  on its unbounded `upload_started.notified()` wait.
- fixed: 1/25 runs failed, 0 nsec_backup failures, 0 hangs.
The remaining failure in each arm (1/25 base, 1/25 fixed) is
terminal::tests::foreground_job_receives_ctrl_c_and_close_stops_job_control_group
("Ctrl+C did not reach foreground job" at its 3 s deadline). It is
unrelated to these locks and not touched here.

The bounded `upload_started()` helper from 27ee14d is kept. It hides
nothing: it turns a pass that never reaches upload into a failure with the
pass's statuses instead of a hang.

Not in scope, same latent pattern: `app_agents.rs` (`lock()`, "closing the
file lets go") and `sign_out.rs`'s agent-ownership check rely on close to
release std `File` locks.

No FOUNDATION file, no TypeScript, no docs (behaviour is unchanged), no new
dependencies. Production diff: +31/−12 in nsec_backup_choice.rs and
persistence.rs (7 of the added lines are the test-only accessor).

Validation on this snapshot: `cargo test --manifest-path
src-tauri/Cargo.toml --locked` 5 times in a row (540 passed, 0 failed each),
`cargo clippy --workspace --locked --all-targets -- -D warnings`,
`cargo fmt --check`, `git diff --check`. Not run natively.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…g up your key…", with Try again / Show my key / Not now

The second half of C2 (plan note 4f94a6a2 §7), on top of 27ee14d (native
status store and tickets) and bd25b74 (lock fix). The first-run "Backing
up your key…" step is no longer a two-second `[TODO]` wait. It waits on the
pass the native uploader runs for its own handoff, and fails the step when
that pass fails.

C2.3, the waiter and the account seam
- `backUpWith` (backup.ts) does five things in order:
  - reads `identity.backup.ready()`;
  - turns consent on with `setBackupChoice(pubkey, true)`;
  - hands the first-run credential to the shared `builderlab` slot;
  - waits for the first status with `pass >= ticket` and not `running`;
  - hands the slot back (`null`) on every exit after the handoff.
  An earlier pass never answers, so a stale `failed` from before sign-in
  is ignored.
- Status mapping:
  - `complete` → resolves;
  - `failed{kind}` → that kind;
  - `waiting{not_linked}` → `not_linked`;
  - other `waiting`, or `idle` → `interrupted`. A pass that would only be
    retried later counts as a failure, since nothing wakes it during first
    run.
  - A 60 s deadline → `timeout`. The null handoff also retires the pass
    still running.
  - Renderer-side failures:
    - status read fails → `unknown`;
    - `builderlab: false` or no ticket → `unavailable`;
    - consent write fails → `interrupted`.
    No native text reaches the page.
- `FirstRunAccount` gains three members:
  - `backupOffered()`: true only for the build's own portal (a tenant's
    credential is never handed over) and only when the status says
    Builderlab backup runs; any error answers false.
  - `backUp(pubkey)`.
  - `declineBackup(pubkey)`: `setBackupChoice(pubkey, false)`, best effort,
    never rejects.
  `builderlabAccount` takes the identity's backup status and consent as a
  fourth argument.

C2.4, the failure table
- New leaf module backup-failure.ts holds `BACKUP_FAILURES`: the plan's
  kinds, retryability and copy.
- `BackupError(kind)` carries the kind, the table's message and
  `retryable`.

C2.5 / C2.6, setup and Welcome
- setup.ts gains:
  - per-step `fallback`s;
  - a failed snapshot carrying `retryable` (read structurally from the
    error) and the fallback label;
  - `retry()`, which re-runs only the failed step, then the rest;
  - `fallBack()`, which runs the fallback, then rests at its page or
    continues after the failed step.
  Both consume the failed tail, so a second click does nothing.
- Fallbacks by lane:
  - Created key: `linkKey` and `backUp` get "Show my key" (rest on the
    reveal), so a key neither backed up nor seen never leaves first run.
    This applies to Manage and to Please back this up.
  - Imported key, new-account lane: a failed link continues to Join with
    "Not now". I chose "Not now" to match the table and the backup offer's
    own skip.
  - Imported key, linked lane: Back it up's backup uses "Not now".
  - A backup fallback also runs `declineBackup`. Consent otherwise stays on
    after a failure, as decided.
- `Failure` gains a `backLabel`, and "Your key is saved on this device."
  once a key exists. Try again appears only for retryable errors.
- When `backupOffered()` is false:
  - KeyChoice drops Manage my keys for me and Builderlab's copy sentence;
  - KeyReveal drops Please back this up for me;
  - the linked lane's import skips the Back up this key? offer.
- `firstRunSteps().backUp` and `backUpKey` are removed.

§8 dev seed
- `BUZZ_DEV_FIRST_RUN_ACCOUNT` accepts `"backup": "<kind>"`, validated
  against the table without echoing the value. `npub` is now optional, so
  `{"backup":"unreachable"}` seeds an unlinked account whose backups fail.
- The seeded account offers backup and never invokes a native backup
  command.

Docs: identity.md covers:
- the real backup step;
- the failed page's options;
- consent until declined;
- the portal-only offer.
configuration.md and .env.example describe the seed field. The Builderlab
README no longer lists key backup as a placeholder.

Not in this commit: the Settings → Identity status row, unlink (C4) and
lookup/restore (C3). No FOUNDATION files were touched.

Tests (C2.9)
- backup.test.ts: the waiter against the real store over a scripted host:
  - invoke order watch → state → set_choice → session → session(null);
  - a stale lower-pass failure and `running` do not answer;
  - each status → kind;
  - a snapshot that settled before the subscription;
  - deadline → timeout;
  - `builderlab: false` (no consent write) and a null ticket
    (`unavailable`);
  - unread status / unwritten consent, with no native text and no
    credential handoff.
- builderlab-account.test.ts:
  - `backUp` hands over the session credential;
  - `backupOffered` on the build portal / tenant / `builderlab: false` /
    unreadable status / unconfigured build;
  - `declineBackup` writes `false` and never rejects;
  - the reads and binding never touch backup.
- setup.test.ts (new): `retry` re-runs from the failed step; `fallBack`
  rests or continues; structural `retryable`; no-ops unless failed, and on
  a second click.
- first-run-seed.test.ts and vite-config.test.mjs: the `backup` field
  through the native loader, and an unknown or inherited kind rejected
  without echo.
- identity.test.tsx journeys, updated:
  - Connect waits on `backUp(viewer)`;
  - the reveal backup times out, Try again re-runs only the backup, then
    Join Community;
  - Back it up is gated;
  - a link failure after Manage → Show my key → reveal → Join;
  - a refused backup after import → no Try again → Not now declines and
    still lists owned communities.
- identity.test.tsx journeys, new:
  - Manage + `not_offered` → Show my key waits for the decline, then shows
    the reveal;
  - a link failure on the import lane → Not now → Join;
  - not offered: no Manage, no reveal backup, no backup offer on the
    linked lane;
  - the real `builderlabAccount` and identity store over mocked native
    commands, past a stale failure, with the credential then the null
    handoff;
  - a seeded `unreachable` backup fails with Try again and Show my key,
    without any `nsec_backup_*` invoke.

Validation (this snapshot)
- `bin/pnpm vitest run src/features/identity src/features/communities
  src/app src/bundled browser-host/vite-config.test.mjs`: 273 files /
  4125 tests passed (2 files / 8 tests skipped).
- `tsc --noEmit`, `pnpm lint`, `biome check` on the changed files,
  `pnpm design:check` and `git diff --check` are clean.
- `tests/browser/window-chrome.spec.mjs` passes in Chromium. The
  first-launch heading is unchanged.
- Against the previous sources (HEAD versions restored, new tests kept),
  24 cases fail: every new and changed journey, plus every new
  backup/setup/seed case. backup.test.ts fails at import, since
  backup-failure.ts does not exist there.

Not exercised in the native app: no Tauri build or run, no live Builderlab
sign-in, and no real uploader pass. The waiter is checked only against
mocked native commands.

Design note for review: with backup not offered, Set up your key has no
primary-variant button (only the two small secondary choices). The plan
only says to hide Manage. Shared owners reused: `Button` and the existing
`Failure`, `KeyChoice` and `KeyReveal` layouts. There are no new tokens or
components.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
… Identity, and make own key primary when Manage is hidden

C2c from the onboarding plan (note 4f94a6a2, §C2.7 / P's decision 5), on
top of 27ee14d (native status store), bd25b74 (lock fix) and
93b8ad8 (first-run waiter). First run now leaves consent on after a
failed backup, so a later Builderlab sign-in may finish it in the
background; this row makes that visible.

Settings → Identity "Key backup" row (src/app/IdentitySettings.tsx)
- `BackupRow` reads `identity.backup` through `useSyncExternalStore`, the
  way Settings reads its other stores, so it follows every higher `seq` the
  store accepts. It sits in the existing rows `SettingsGroup` above
  "Sign out of Buzz", as a `PreferenceRow` with no trailing control. The
  sentence is wrapped in `role="status"` so a change is announced.
- One sentence per status:
  - idle: "Buzz hasn’t checked your key’s backup since it opened." (idle is
    what a launch or a reload reports, not "no backup")
  - running: "Backing up your key…" (the first-run step label)
  - complete: "Your key is backed up to Builderlab."
  - waiting{no_consent}: "Automatic backup is off for this key."
  - waiting{no_session}: "Sign in to Builderlab to back up your key."
  - waiting{not_linked} and every failed kind: the message from
    `BACKUP_FAILURES` in backup-failure.ts, so it is the same copy as
    first run's failed page. There is no second table.
- Read-only: no consent toggle, no retry, no other action. The consent
  wording still needs the backup owner's sign-off.
- Hidden unless backup is offered, by the same rule first run uses. That
  rule moves out of `builderlabAccount().backupOffered` into the exported
  `backupOfferedFor(backup, origin?)` in builderlab-account.ts. It answers
  false unless the sign-in origin is the build's own Builderlab portal,
  which it checks before reading the status so a tenant's status is never
  read, and then returns the status's `builderlab`. Any error answers
  false. First run passes its login origin. Settings passes none, because
  the Settings sign-in is always at the build portal.
  `builderlabAccount().backupOffered` now just delegates to it, with no
  change in behaviour.
- Known difference: under the `BUZZ_DEV_FIRST_RUN_ACCOUNT` dev seed,
  first run always offers backup while the row still follows the real
  rule. The seed fakes the whole account and is serve-only.

Key choice (src/features/identity/KeyChoice.tsx)
- When Manage my keys for me is hidden because backup isn't offered,
  "I want to manage my own key" becomes the primary, at the same `lg` size
  the Manage button uses, so the page keeps a primary action. The order is
  unchanged and "I already have a key" stays small and secondary. Before
  this, the page had no primary-variant button in that case.

No FOUNDATION file (App.tsx, services.ts, main.tsx, communities/service.ts,
src/plugins/api.ts), no Rust, no new dependencies, no new styling
primitives. Shared owners reused: `SettingsGroup`, `PreferenceRow`,
`Button`. Production diff: +46/−2 in IdentitySettings.tsx, +21/−9 in
builderlab-account.ts, +6/−2 in KeyChoice.tsx.

Tests
- IdentitySettings.test.tsx drives the real `createBackupStatus` store over
  a scripted native side, with a mocked Tauri `Channel`:
  - the sentence for complete, running, waiting{no_session},
    waiting{not_linked} and failed{unreachable};
  - hidden when the status says `builderlab: false`, and when the build has
    no Builderlab portal;
  - following pushes at seq 2, 3 and 4, a late seq-2 push changing nothing,
    and the row holding no button or switch.
  - The barrier for the negative cases is awaiting `backupOfferedFor` on
    the same store. The row's own check started first, and the positive
    cases use the same barrier with synchronous queries.
- Mutation checks, each failing its intended test(s) and restored:
  ignoring `offered`; replacing the shared rule with `status.builderlab`
  alone; reading the snapshot once instead of subscribing; writing own
  failure copy.
- The existing reveal test, and Settings.test.tsx's identity case, gain a
  `backup` store, since the row now reads it (an inert one in Settings).
- identity.test.tsx's not-offered journey asserts the two remaining
  choices in order, own key `prominent` and import `subtle`. It fails
  against HEAD's KeyChoice.tsx.
- docs/identity.md describes the row and the primary fallback.

Validation (this snapshot, macOS)
- `bin/pnpm vitest run src/features/identity src/app`: 55 files, 741 tests
  passed.
- `tsc --noEmit`, `pnpm lint` (exit 0; 22 pre-existing infos),
  `biome check` on the six changed source/test files, `pnpm design:check`
  and `git diff --check` are clean.

Nothing was run in the native app: no Tauri build, no Settings → Identity
visit against the real uploader, and no rendered check in either theme, at
narrow or wide widths, or with enlarged text. Open design and copy points
for review:
- Retryable failure messages end in "Try again." even though the row has
  no button.
- The row appears after the first status read, so "Sign out of Buzz" moves
  down by one row once the IPC answers.

Co-Authored-By: Claude Opus 5.5 <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/05-link-and-native-backup 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