Skip to content

feat(settings): read-only Key backup row in Settings → Identity - #1821

Closed
matt2e wants to merge 13 commits into
mainfrom
onboarding/08-settings-key-backup-row
Closed

matt2e wants to merge 13 commits into
mainfrom
onboarding/08-settings-key-backup-row

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 (merged)
  2. refactor(communities): extract the join owner and add the pre-join relay lookup #1815 communities: join owner and pre-join relay lookup (merged)
  3. feat(identity): observable nsec backup status with pass tickets, explicit lock release, and refused-recovery copy #1816 identity: native nsec backup status (merged)
  4. feat(identity): first-run Builderlab account client and dev seed — bundled-grant sign-in, account reads, key link/unlink, backup, saved communities #1818 identity: first-run Builderlab account client and dev seed
  5. 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
  6. feat(identity): the first-run Welcome flow replaces the key-setup gate (FOUNDATION: App.tsx) #1820 identity: Welcome flow replaces the key-setup gate
  7. feat(settings): read-only Key backup row in Settings → Identity #1821 settings: read-only Key backup row ← this PR
  8. [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: the merged #1816 (backup.ts types, backup-failure.ts, identity.backup) and #1818 (backupOfferedFor) only. It is based on #1820 to keep the stack linear; it does not use #1819 or #1820 and can be reviewed first.

Diff: production +72/−14; tests +141/−3; docs +10/−0

What this layer is

BackupRow reads identity.backup via useSyncExternalStore, one sentence per status (first run's BACKUP_FAILURES copy, with a "Buzz tries again when you next sign in to Builderlab." tail for causes a new sign-in clears), hidden unless backupOfferedFor(backup) holds. No consent or retry control.

Review focus

  • One copy table: the failure sentences come from backup-failure.ts, the same entries first run shows; only the Settings tail differs. This layer adds the table's cause field (the sentence without first run's instruction) that BackupRow reads, since it is the first caller.
  • Visibility rule shared with first run (backupOfferedFor): the row shows only when the build has a Builderlab URL and native backup is on; any error hides the row.
  • Known UI points, documented and not fixed here: the row appears after the first status read ("Sign out of Buzz" shifts down once), retryable messages end in "Try again." though the row has no button, and the dev seed makes first run offer backup while the row follows the real rule.

Checks run on this branch's tree

Native evidence

None on this tree. Native first-run runs use the full tree in the DO-NOT-MERGE #1822 (it adds the isolated debug Keychain slot), and none has been made since the rebase. Treat every PR before #1822 as unit/integration-tested only.

🤖 Generated with Claude Code

@matt2e
matt2e marked this pull request as ready for review October 28, 2026 13:40
@matt2e
matt2e requested a review from a team as a code owner October 28, 2026 13:40
@matt2e
matt2e force-pushed the onboarding/08-settings-key-backup-row branch from d5a5ef2 to c1e4548 Compare October 28, 2026 14:18
@matt2e
matt2e force-pushed the onboarding/07-welcome-first-run branch from 4823851 to 787e2b9 Compare October 28, 2026 14:18
@matt2e
matt2e force-pushed the onboarding/08-settings-key-backup-row branch from c1e4548 to 954be58 Compare October 28, 2026 14:21
@matt2e
matt2e force-pushed the onboarding/07-welcome-first-run branch from 787e2b9 to 141b42b Compare October 28, 2026 14:21

@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, integrating Rocket, Mantis and Groot: no production defects found; one low-priority documentation correction is inline. Optional: remove the PR description’s stale claim that retryable Settings messages end in “Try again”; they now describe retrying on the next Builderlab sign-in.

Head b4e66e5944b1b16ba0e1b28baa12eb996a65fa02, base e2f928f8b8d89f9506ba1b1e703d22fd0eba9da2. Source-only: tests, CI, native backup behavior and rendered accessibility were not validated.

Comment thread docs/identity.md
that a failure first run's Try again could clear, and a lapsed sign-in, say
instead that Buzz tries again the next time the user signs in to Builderlab,
since a new sign-in hands the uploader a session and starts another pass. Idle is what a fresh launch or
a window reload reports, not evidence that no backup exists. The row has no

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.

🤖 [P3] Qualify when a window reload reports idle

NsecBackupHost::document_load in src-tauri/src/nsec_backup.rs:302–312 changes the status to Idle only when it interrupts a Running pass. Settled statuses such as Complete or Failed are retained and returned by the next status read, so reloading Settings does not generally produce the idle sentence described here. Please say that a fresh launch reports idle and a reload does so when it interrupts an active pass, while preserving the explanation that idle does not prove absence of a backup.

@matt2e
matt2e force-pushed the onboarding/07-welcome-first-run branch from e2f928f to 64a5f0b Compare October 29, 2026 23:48
@matt2e
matt2e force-pushed the onboarding/08-settings-key-backup-row branch from b4e66e5 to 4ffb5a0 Compare October 29, 2026 23:48
@matt2e
matt2e force-pushed the onboarding/07-welcome-first-run branch from 64a5f0b to 75127a4 Compare October 30, 2026 00:30
@matt2e
matt2e force-pushed the onboarding/08-settings-key-backup-row branch from 4ffb5a0 to 1b6313a Compare October 30, 2026 00:30

@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, integrating Rocket, Mantis and Groot: no new production findings in this follow-up and its affected base integration. The existing P3 reload documentation correction remains: document_load resets a running pass to idle, while settled results survive reload. The earlier optional PR-description wording cleanup also remains optional; no duplicate inline comments added.

Reviewed head 4ffb5a0cd449affa8be0bce685920ed926972212, base 64a5f0bad98c51935229f39458a4489247ce0ce7, compared with prior reviewed b4e66e5944b1b16ba0e1b28baa12eb996a65fa02. The Settings feature patch is unchanged in behavior; this pass checked the revised backup offer gate and native status integration. Public description and commit material inspected; no attached images or new disclosure finding.

Source-only: no tests or app execution, CI assessment, native backup acceptance, rendered accessibility/focus or performance validation. This is a non-blocking comment, not approval.

@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, integrating Rocket, Mantis and Groot: no new production findings in this follow-up or its affected integration. The existing P3 reload-documentation correction remains: document_load resets an interrupted running pass to idle, while settled results survive reload. The earlier optional cleanup of the PR description’s “Try again” wording also remains. No duplicate inline comments added.

Reviewed head 1b6313acdc99d71a2dd587d57917ad9461b18061, base 75127a4d427c6831fa4723ffe0e92677d2344719, compared with prior reviewed 4ffb5a0cd449affa8be0bce685920ed926972212. The Settings feature implementation is unchanged; this pass checked the inherited hosted-community sign-in routing and spare-process change alongside the backup-status integration. Existing findings on the earlier stack layers remain tracked on their own PRs. Public description and commit material inspected; no attached images or new disclosure finding.

Source-only: no tests, app execution, CI assessment, native backup acceptance, rendered accessibility/focus or performance validation. The PR body’s test claims were not independently verified. This is a non-blocking comment, not approval.

matt2e and others added 4 commits November 2, 2026 11:46
…nted natively and accepted by the binding signer

Pure configuration plumbing; no first-run page uses it yet. A build-time
table `BUZZ_BUILDERLAB_TENANTS` — rows `{ relayHost, self?, loginOrigin }`
— says which relays each Builderlab deployment hosts and where its people
sign in, so a later first-launch screen can send a typed community URL's
sign-in to the right portal instead of one hard-coded Builderlab.

Frontend (`src/features/identity/builderlab-tenants.ts`, new):
`parseBuilderlabTenants` validates the table at Vite configuration time
(hostname shape, ASCII-only folding, 253-byte cap, unique hosts, unique
64-hex `self`, `loginOrigin` equal to its own canonical https origin, no
unknown fields) and fails without echoing the value;
`discoverAuthentication(found, tenants)` answers `{ kind: "builderlab",
origin }` or `{ kind: "none" }`: hostname match (exact, or a suffix at a
label boundary — `block.builderlab.xyz` covers `buzz.block.builderlab.xyz`
but not `notblock.builderlab.xyz` — default port only, longest host wins),
then NIP-11 `self` equality (a known deployment survives a DNS move; it can
only select an origin already in the table), else `none`.
`vite.config.ts` parses the table and defines
`VITE_BUZZ_BUILDERLAB_TENANTS`. `browserCredential(host, signal, bridge,
origin?)` (`oauth/browser.ts`) runs the whole OAuth round-trip —
`/v1/auth/login`, `/login/exchange`, `/auth/me` — at `oauthTarget(origin)`,
the tenant origin going through the same https/no-credential/no-query
check as the build URL; `nativeBridge` is exported for the next PR. The
Settings card is unchanged and always uses the build URL.

Native (`crates/plugin-manager/build.rs`, `src/lib.rs`): `build.rs` loads
`BUZZ_BUILDERLAB_TENANTS` beside `BUZZ_BUILDERLAB_URL` through
`build_env::load`, validates every row with the same rules as the frontend
parser (at most 16 origins), and emits `BUZZ_BUILDERLAB_ORIGINS` (build URL
origin first, then each distinct tenant origin); `builderlab_origins()`
feeds the `block.builderlab`/`bundled` grant's `network_origins`, and
`BUILDERLAB_ORIGIN` stays for `identity.rs`. Malformed input fails the
build with a fixed message. `serde_json` joins that crate's
`[build-dependencies]` (already in the lockfile; no Cargo.lock change).
`src-tauri/src/identity.rs`: the kind-24243 binding signer accepts a
challenge for production Builderlab or any login origin the build grants
(`builderlab_origins()`) instead of only production or the build URL;
unknown, case-changed, trailing-slash, port-changed and empty origins are
still refused.

Config and docs: `.env.example` and `docs/configuration.md` document the
input, the matching rules and the grant, with the three verified rows
(`communities.buzz.xyz` → `https://app.builderlab.xyz`,
`block.builderlab.xyz` → `https://block.builderlab.xyz`,
`test.blockstaging.build` → `https://test.blockstaging.build`, each with its
relay key). The repository ships no deployment default. The Builderlab
README's two grant sentences name the tenant origins.

Tests: `builderlab-tenants.test.ts` (the three seed rows, subdomain,
case/final-dot, lookalikes, non-default port, `self` fallback and its
limits, nested-row precedence, parser rejections without echo incl. an
over-long host and a Kelvin sign); `oauth/browser.test.ts` (an explicit
origin for the whole round-trip and the same refusal of `http://`);
the tenant lines of `browser-host/vite-config.test.mjs` (the define and the
non-echoing rejection); Rust `build.rs` tests (origin union, file-sourced
table, malformed rows), `host_request.rs` (every granted origin),
`identity/tests.rs` (granted, production, ungranted and malformed origins;
the IPC test signs and verifies for every granted origin and refuses a
foreign one).

Not in this PR: `src-tauri/src/identity.rs` hosted-communities binding for
other tenants stays as on main apart from the origin rule;
`src/bundled/hosted-communities/native.ts` still hard-codes the production
API.

Stack: PR 04 of the onboarding stack, on 02 (`builderlab-tenants.ts`
imports `type FoundCommunity` from `communities/find-community.ts`);
independent of 03. 05–07 build on it.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
… 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 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
…ion follows in the next PR)

The stateless building blocks of the first-run flow, reviewable as
components against DESIGN.md and Kenny's onboarding layouts. Nothing
imports them until PR 07 (`Welcome.tsx`); the repo has no unused-export
check, so lint and typecheck pass with them standing alone.

Setup store and steps (`setup.ts`): `createSetup()` with `start(steps,
then)`, `retry()` (re-runs only the failed step, then the rest) and
`reset()`, over snapshots `idle` / `running{label}` / `paused{at}` /
`failed{error, retryable, skipsLink}` / `done`; a step's `run` may resolve
to a resting state that overrides the caller's `then`. Step factories:
`checkAccount(account, onLinked, onKnown)` (the linked-key and
known-communities reads in parallel under one label; the list read never
fails setup), `checkBackup(account, onListed)` (never fails setup),
`createKey`, `importKey`, `linkKey`, `unlinkKey(account, old, new)`,
`restoreCommunities(known, communities, decide)` and `finish()`.
`restore-communities.ts`: `restoreKnown(known, communities, { concurrency:
3, startBy: 45_000 })` — the same signed profile read the Join Community
page uses, at most three at once, nothing started after 45 s, each admitted
destination saved through `communities.joined`; outcomes `admitted` /
`denied` / `unavailable` in list order; never throws. `key-import.ts`:
`classifyKeyImport` (empty / `ncryptsec` refused with a clear message /
`nsec` with its npub preview / invalid) and `readKeyFile` (1 KiB cap, first
non-blank line).

Pages (all `src/features/identity/`, stateless, props-driven):
`CommunityUrlField` (bare host, `wss://`/`https://`, or an invite link —
`https://<host>/invite/<code>`, `buzz://join?relay=…&code=…` — via
`enteredCommunity`; Continue stays enabled while empty/invalid and shows one
inline error), `InviteCodeField`, `JoinCommunity` (owned rows named "Join
{host}" for assistive technology, the URL field, the disabled "Scan a QR
code" / "Create a community — Coming soon" buttons, on the onboarding
layout), `KeyChoice` (Create my key / I already have a key / Use a
different account, the signed-in address, the backed-up-key sentence only
when the lookup listed none), `KeyImport` (masked field, Reveal/Hide, file
drop/picker, npub preview, `expectedPubkey` mismatch refused in the page,
"Importing your key…" on the same screen), `KeyLost`, `KeyRestore` (the K3
column; "Your key is stored securely on this device." only while the
restore may still install the key; the unavailable/refused page),
`BackupOffer`, `ConnectChoice` ("Connect to your community" with the host
on its own mono line), `WelcomeBack` (the linked npub with Copy, Restore
from backup when listed, Enter your private key, I lost this key, Use a
different account), `PublicKeyPanel`, `SetupProgress`, `Wireframe`
(`WireframeScreen` on Kenny's frame, `WireframePanel` as the one stacking
group), `ProfileStep` (a pure form over `ProfileFields`: Continue hands the
trimmed draft out, Skip for now leaves). Every `h1` carries `tabIndex={-1}`
and the Back triggers `data-return-focus`, for the heading-focus handoff
`Welcome` performs. Shared owners only: `Button`, `Field`, `Input`,
`Checkbox`, `Avatar`, `LoadingRegion`, `OnboardingScreen` and Kenny's
`IdentitySetup.module.css` classes (unchanged); role colours and type roles
only, no new tokens or variants.

Tests: `setup.test.ts` (retry/rest/idle semantics, `checkAccount` and
`checkBackup` never failing setup, `unlinkKey` sending nothing while the
old key is on the device), `restore-communities.test.ts` (three reads at
once, outcomes in list order, dedupe and insecure skip, throwing `joined`,
the 45 s cutoff under fake timers), `key-import.test.ts`,
`CommunityUrlField.test.ts` (8 accepted and 10 refused forms),
`ProfileStep.test.tsx`. The pages' rendered checks (Chromium/WebKit, light/
dark, 480/1100 px, 150 % text) were done on the branch in scratch fixtures
and are recorded in the branch's commit history; mount a page with stub
props to repeat them.

Stack: PR 06 of the onboarding stack, on 05; imports 02 (`destination`,
`find-community` types, `api`, `ProfileFields autoFocus`), 03 (`service`,
`RestoreIdentity` `expectedPubkey`/`onImport`, `BackupError`) and 05
(`builderlab-account` types). 07 composes these.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
…e (FOUNDATION: App.tsx)

The composition and the product flow. On native desktop with no human key
in the credential store, the app now opens on `Welcome` instead of the
"Your Buzz identity" key import/create screen, and stays there until a key
exists and setup has nothing left to do.

FOUNDATION edit (`src/app/App.tsx`), the three approved lines: import
`Welcome` from `../features/identity/Welcome` instead of `IdentitySetup`,
and wrap `ConnectedApp` in `<Welcome identity={services.identity}
communities={services.communities} invites={services.invites}>`. No other
FOUNDATION file (`services.ts`, `main.tsx`, `communities/service.ts`,
`plugins/api.ts`) is touched. `IdentitySetup` stays for the credential-free
browser fixture and gets a one-line comment.

`Welcome.tsx` (new, 940 lines) owns the flow; its state (`finding`,
`inviteCode`, `entered`, `reads`, `known`, `linked`, `owned`, `backups` and
the `setup` store) is shared by every lane, which is why it lands whole:
- Landing: "Buzz" on Kenny's onboarding screen — Sign in with email,
  Community URL (bare host, origin or invite link; Continue starts the
  lookup) and Scan a QR code — Coming soon.
- A typed address is looked up before any sign-in (`FindCommunity`, two
  unauthenticated GETs) and fails fast; a found community decides the
  portal through `discoverAuthentication`: a tenant's relay signs in at that
  tenant's portal, an unmatched one pauses on "Connect to your community"
  (Sign in with email at the build portal, or I already have a key, which
  imports without any account and lands on Join Community).
- After sign-in: Checking your account… (linked key + saved destinations in
  parallel) → Checking for a backed-up key… → Set up your key (Create my
  key: Creating → Linking → Backing up; I already have a key: Importing →
  Linking → Back up this key?; Use a different account) or, for a linked
  account, Welcome back (Restore from backup when listed, Enter your private
  key accepting only the linked key, I lost this key → Start over with a new
  key: Creating → Unlinking the old key → Linking → Backing up, Use a
  different account). Both post-sign-in pages show the signed-in address.
- Couldn’t finish setup: Try again for retryable failures; once a key
  exists a failed backup offers Continue without backup, a failed link or
  unlink blocks with Try again only (nothing links the key later); before a
  key exists, Back to the landing. The key choice and reveal pages never
  show hex; nothing is signed to a relay before the user chooses to join.
- Restoring your communities…: the account's saved destinations are read
  with the signed profile read and admitted ones saved; setup rests on Join
  Community (typed URL) or Join a community when nothing was admitted,
  enters the app on the first admitted one otherwise, and hands a typed
  address or invite code that was not among them to the rail's
  Add-a-community dialog through `invites.open`, the intent OS deep links
  already use.
- Join Community joins through `admitCommunity`/`completeJoin` (journal
  before any effect, read before reclaim, `accept-policy` → `claim` with an
  invite code, the signed read); a denied read opens "This community needs
  an invite" with the npub, Copy, an invite-code field, Back and Try again;
  a user with no profile anywhere gets the Set up your profile step before
  the membership is saved; a join after a Builderlab sign-in is also saved
  to the account (`saveCommunity`, best effort).
- Invariants: every return to the landing goes through `backToLanding`
  (clears discovery, the account reads and the first-run session; keeps the
  typed address); `enterApp()` fires a pending handoff, clears what `Welcome`
  kept and resets setup. Each page change moves focus to the new page's
  heading, or back to the choice that opened it on Back.

Tests: `identity.test.tsx` (whole file, +4,837/−109 vs main: the landing,
discovery, Connect, Join/profile/invite, key import, Welcome back/restore,
Start over/owned, failure and seed journeys over a `nativeRelay` fixture
with a real keypair, `relay_sign`, per-origin `relay_http` and a real
`createCommunities`; the exact `invoke`/`relay_http` sequences are asserted
throughout). This PR's `Welcome.tsx` is the `19df106d5` version, which
turns these six journeys green — they are red against the earlier
`572d69398` Welcome: "a link that fails after the key is created blocks
setup with only Try again…", "a link that keeps failing after a key is
imported…", "a transient unlink failure after Start over…", "an unlink
refused after Start over offers only Try again…", "a transient link failure
after a successful unlink…", "a development seed's unlink status fails Start
over's unlink…". `src/app/first-run-join-handoff.test.tsx` (new): the real
`App` + `createServices()` under mocked Tauri commands, the gate lifting
with the Add-a-community dialog open on a typed address or invite link, the
dialog's claim with the code, and Cancel. `src/app/first-run-navigation.test.tsx`
(new): proves PR 01's deadline rule through the real first-run gate.

Docs: `docs/identity.md` first-run block (every page, what is signed and
what is not, where Back lands, the failed page), `docs/communities.md`
(`Welcome` as the second caller of the join owner and the invite intent),
`docs/deep-links.md`.

Stack: PR 07 of the onboarding stack, on 06; depends on 01–06. 08 is based
on it only to keep the stack linear.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
@matt2e
matt2e force-pushed the onboarding/07-welcome-first-run branch from 75127a4 to a8185e9 Compare November 2, 2026 01:04
@matt2e
matt2e requested a review from a team as a code owner November 2, 2026 01:04
matt2e and others added 5 commits November 2, 2026 13:11
…r one

Review follow-up on PR 1820, thread 1 (canceled sign-in continuation
resumes). `Welcome`'s `signIn` awaited `login.signIn()` and then read the
session's global state. The session store resolves a canceled attempt's
promise normally, and native HTTP does not observe the abort, so a tenant
sign-in canceled mid-exchange could settle after a later sign-in at the
build portal succeeded. Its continuation then saw `signed-in`, built the
account from the later credential with the canceled tenant's origin
(sending that credential to the tenant's portal) and restarted setup over
whatever page the user was on.

`signIn` now records the cancel count when it starts and continues only
if no cancel happened since. `backToLanding`, the only cancel path while
mounted, bumps the count. The count is not bumped on entry, so a repeated
call while one sign-in is pending cannot strand it. Unmount needs no
fence: it cancels a pending sign-in, which then settles `signed-out`.

Test: a new `identity.test.tsx` journey. A tenant sign-in is canceled
with its exchange still pending, then a build-portal sign-in reaches
Enter your private key with a draft typed. The canceled exchange then
resolves; the test waits on that attempt's account digest and drains
microtasks. The page and draft stay, `account` is built once (later
credential, no origin), and no relay call follows the lookup. It fails
on the previous `Welcome.tsx` (the page is replaced by a restarted setup)
and passes now.

Docs: `docs/identity.md` states that a canceled sign-in finishing later
is ignored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
…t today's rollout

Review follow-up on PR 1820, thread 3 (deployment rollout details in
public docs). Three sentences in `docs/identity.md` said which deployments
enable a feature today. Each now states what the build and service must
provide, and keeps the fallback:

- The backed-up-key lookup: kgoose answers `404` wherever backups are not
  configured. Dropped "which today is everywhere but staging's public
  Builderlab".
- Welcome back's restore: the unavailable page applies where recovery is
  unavailable for the deployment or refused for the account (public
  organizations are refused by design). Dropped "no tenant enables it
  today".
- Automatic backup: anywhere else (a tenant sign-in, or a build without
  backup configured) the step fails at once. Dropped "which today is".

`grep today docs/identity.md` now returns nothing. The tenant example rows
in `docs/configuration.md` stay; the reviewer excluded them. Docs only, no
behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
…eated denial

Review follow-up on PR 1820, thread 4 (Try again loses focus on a
repeated denial). `Failure` disabled Try again while its read ran. Both
Chromium and WebKit move focus off a focused button when it becomes
natively disabled. If the relay refused the key again, the page key
(`done:join-error:<title>`) did not change, so `Welcome`'s focus effect
did not run and keyboard users were left on `body`.

Try again now uses the shared Button's loading state while its own read
runs (`loading={busy === "read"}`). Base UI keeps it focusable with
`aria-disabled` and `aria-busy`, and the Button still blocks activation.
While an invite code is being claimed it stays plainly disabled. The
label stays "Try again": DESIGN.md's Button rule keeps the label's
geometry and accessible name while loading, so "Trying again…" is gone.
Back and the invite code field are unchanged.

Test: the invite-page Try again journey now holds a retry that the relay
refuses again. While the read is pending, Try again is focused,
`aria-busy` and `aria-disabled`, has no native `disabled`, and Enter
starts nothing. After the refusal, focus is still on Try again. Enter
then retries and is admitted, and the relay calls show each attempt is
the same signed read. jsdom keeps focus on a disabled button, so on the
previous `Welcome.tsx` the journey fails at the `aria-busy` check, not
at the focus check. Browser focus loss was confirmed with a standalone
Playwright probe (plain buttons, not the app): native `disabled` sent
focus to `body` in both engines, `aria-disabled` kept it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
… landing

Review follow-up on PR 1820, thread 2 (preserve the explicit legacy-key
migration entry point). Replacing the key-setup gate with `Welcome` left
`identity.legacyAvailable()` / `identity.importLegacy()` reachable only
from `IdentitySetup`, which only the browser fixture still mounts. Someone
upgrading from old Buzz could no longer bring its identity over in one
click.

The offer comes back as an alert dialog over the Welcome landing. The
landing, its actions, the pages, the steps and the setup store are
unchanged; no FOUNDATION file is touched.

`LegacyIdentityOffer.tsx` (new) exports `useLegacyIdentityOffer(identity,
open)`. `Welcome` calls it with its other hooks and renders the result
only in the landing JSX, so the folder check and a dismissal outlive a
visit to another page (+6 lines in `Welcome.tsx`):
- `identity.legacyAvailable()` runs once per entry into `missing`, with
  `.catch(() => false)` and an `active` guard, as in `IdentitySetup`. The
  landing's buttons never wait for it.
- The dialog opens only when the check answered true, the landing is the
  current page (so setup is idle), no sign-in is pending, and it was not
  dismissed during this entry. A check that answers off the landing or
  during a sign-in waits until the landing is back.
- It uses the shared `AlertDialog` and `Button`, with no new tokens or
  variants. Title **Continue with your Buzz identity?**, then **Not now**
  and the prominent **Continue with your Buzz identity**, which calls
  `identity.importLegacy()`. While the import runs, Continue shows the
  Button's loading state, Not now is disabled, and `pending` blocks Escape
  and Close.
- A failed import shows its error inside the dialog (role alert), and
  Continue tries again. Only errors after the click are shown, so a setup
  error from another page never appears in the dialog.
- Success lifts the gate into the app with no sign-in, link or backup,
  where `LegacyCommunityImport` takes over as on main.
- Not now and Escape close it for this entry. `finalFocus` returns focus
  to the landing heading, since launch focuses nothing. Base UI's deferred
  initial focus still wins over `Welcome`'s heading-focus effect when the
  dialog opens as the landing returns.
- After Sign out, the next launch is a new entry into `missing`, so the
  offer returns while old Buzz's folder exists.

Tests (`identity.test.tsx`): the `IdentitySetup` migration journey moves
onto the live `Welcome` gate:
- no dialog when the check answers false or fails;
- no import before the click; Continue imports, stays pending, and
  enters the app with `connect` and `account` never called;
- Not now and Escape keep the landing, focus its heading, import nothing,
  and the offer stays closed after a failed sign-in and Back;
- a failed import shows its alert in the dialog, and Continue retries;
- no dialog while a sign-in is pending or setup runs; after a failed
  Create my key and Back it opens with focus and without the create error;
- the offer returns on the launch after Sign out.

Each guard was checked fail-before by mutation. Removing the render,
the pending-sign-in condition, `finalFocus`, the error scoping, `pending`,
or the dismissal each fails its targeted test. Mechanical updates: 36
exact command sequences gain `identity_legacy_available`; 29 `invoke`
mocks (including the shared `nativeRelay` and `landingRelay`) and five
`mockResolvedValueOnce` chains answer it `false`. So do the
`first-run-join-handoff`, `first-run-navigation` and `window-chrome`
fixture mocks.

Docs: `docs/identity.md` describes the dialog over the welcome screen:
when it opens, the click-only read, the error and retry, entering the
app with no Builderlab sign-in, link or backup, dismissal, and its return
after Sign out. The first-launch bullet in `docs/buzz-help.md` now
describes the Welcome flow and the offer.

Validation: lint, typecheck and `design:check` pass; the full Vitest
suite passes (854 files, 14,737 tests); `window-chrome.spec.mjs` passes
in Chromium. Rendered in a scratch fixture (not committed) in Chromium
and WebKit, light and dark, at 1100 and 480 px and with 150 % text. At
480 px with 150 % text the primary label is wider than the compact
dialog, and the shared footer scrolls horizontally. Not run: the native
app and a real keychain prompt.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
The dialog's title already names the identity, and the long button label
wrapped the footer onto two rows at 1100 px and clipped at 480 px with
enlarged text. Not now and Continue now share one row at both sizes.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
@matt2e
matt2e force-pushed the onboarding/08-settings-key-backup-row branch from 1b6313a to 23e9e52 Compare November 2, 2026 04:21
…n is always at BUZZ_BUILDERLAB_URL

PR 1817 (the Builderlab tenant table, stack PR 04) was closed on
2026-10-29 and PRs 05 and 06 merged into main without it; this PR was
rebased onto main without it on 10-30 (head `75127a4d4`). The 11-02
force-push rebuilt the branch from the pre-closure 10-28 commits, so
`5b78e9de8`'s tenant table and the stale 05/06 variants built on it came
back: `builderlab-tenants.ts`, the `BUZZ_BUILDERLAB_TENANTS` input in
`vite.config.ts`, `build.rs` and `.env.example`/`docs/configuration.md`,
the `builderlab_origins()` grant union in `host_request.rs`, the
binding-signer origin rule in `identity.rs`, the per-origin
`browserCredential`/`firstRunCredential`/`builderlabAccount` signatures,
and `Welcome`'s per-tenant routing. The review thread on
`vite.config.ts:70` (the renderer reading the table through Vite's
mode-aware `loadEnv` while the native grant reads only the process and
`.env.local`) was against that reintroduced code.

This commit puts main's (`af6e8993d`) version of those 20 files back and
restores the tenant-free PR 07 pieces from `75127a4d4`:
`builderlab-hosted.ts` (the fixed host/`self` list; a hosted community
signs in at once, always at `BUZZ_BUILDERLAB_URL`) and its test, the
`Welcome.tsx` hunks (`connect(signal)`, `account(credential, identity)`,
no `tenants` prop or `loginOrigin`), and the ConnectChoice/setup
comments. Every later fix stays: the canceled-sign-in cancel-count
fence (`0dc22c0d4`, hand-merged into the tenant-free `signIn`), the
capability-contract docs (`3854b5a18`, minus its tenant clause), Try
again's loading state (`86a4b4c30`) and the old-Buzz offer dialog
(`b3ccd00e3`, `f2a94e076`). No FOUNDATION file changes; Rust is back to
main.

Tests: the canceled-sign-in journey is now two build-portal sign-ins
(Sign in with email, Cancel sign-in, Sign in with email, I already have
a key, a typed draft, then the stale exchange resolves); it still fails
at the Enter your private key heading without the fence. The 14
`tenants={tenants}` journeys follow `75127a4d4`'s connect-choice forms
again, and the hosted-URL journey returns. The tenant lines of
`browser-host/vite-config.test.mjs`, `builderlab-account.test.ts`,
`oauth/*.test.ts` and the Rust tests go with their code.

Docs: `docs/identity.md` describes the hosted list and the connect
choice without a tenant table.

Validation: `tsc --noEmit`; `identity.test.tsx` (132),
`first-run-join-handoff.test.tsx`, `first-run-navigation.test.tsx`,
`builderlab-hosted.test.ts` and `vite-config.test.mjs` (23) pass. The
full suite, lint, design checks and native builds were not run.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
@matt2e
matt2e force-pushed the onboarding/08-settings-key-backup-row branch from 23e9e52 to f98b7e9 Compare November 2, 2026 05:55
…Builderlab hosts it

Correction to `50bad0cdb`, whose message called sign-in "always at
BUZZ_BUILDERLAB_URL" for a hosted URL as intended: for another
deployment's relay that auto-sign-in was wrong. A public release
(`app.builderlab.xyz`, bb-public) given a `block.builderlab.xyz`
community URL signed in at once at bb-public, then linked the user's
new or imported key to that bb-public account and offered to back it
up there. `main` cannot do this (its key-setup gate never calls
Builderlab); the auto-sign-in came in with `builderlab-hosted.ts` in
`75127a4d4`, after #1817 closed saying a typed address always gets the
connect choice.

Rule: a known Builderlab relay signs in at once only when its owning
portal is the origin of `VITE_BUZZ_BUILDERLAB_URL`. Host x build:

  typed host \ build           app.builderlab  block.builderlab  test.blockstaging
  *.communities.buzz.xyz        sign in         key only          key only
  *.block.builderlab.xyz        key only        sign in           key only
  *.test.blockstaging.build     key only        key only          sign in
  any other host                Connect         Connect           Connect

(was: every hosted cell signed in at the build portal). A relay
publishing a listed `self` key follows its row; an FI or unconfigured
build matches no portal, so every known host is key only.

- `builderlab-hosted.ts`: each row names its portal;
  `builderlabHosting(found, build)` answers `{ portal, here }` or
  `undefined` (hostname first, then `self`; default port only).
- `Welcome.tsx`: `onFound` signs in only when `here`; otherwise it keeps
  the other portal's host in `elsewhere` and pauses on the connect
  choice. A join admitted after a landing sign-in is not saved to this
  account when another Builderlab hosts it (`ProfileStepState.found`
  keeps `self` so the guard can use the relay-key fallback).
- `ConnectChoice.tsx`: with `elsewhere`, the page names that portal and
  offers only a prominent "I already have a key" (and Back); title,
  host line, page key and `data-return-focus="import"` are unchanged.
  Shared `Button` only, no new tokens or variants.

Tests: `builderlab-hosted.test.ts` asserts each accepted host's portal,
the 3x3 host x build `here` matrix, empty/invalid/path-bearing build
URLs and the `self` fallback. `identity.test.tsx`: the hosted journey is
now a 3-case `it.each` with the build URL stubbed to each portal; a new
2-case journey (bb-block URL in a public build, bb-public URL in a Block
build) shows the key-only page, returns focus to "I already have a key"
from the import page's Back, imports, reaches Join Community with
`connect` and `account` never called and exactly
`identity_restore, identity_legacy_available, relay_http x2,
identity_import, relay_http x2`, all to the typed origin. It fails on
`50bad0cdb` (no "Connect to your community" heading: sign-in started)
and passes now. A third journey joins a bb-block community after
signing in at a public build and asserts nothing is saved to the
account; it fails with the guard removed.

Docs: `docs/identity.md` (portal per deployment, sign in only for this
build's, key only for another's, no save) and one sentence in
`docs/buzz-help.md`.

Validation: `tsc --noEmit`; Biome on the changed files; `vitest run`
of `identity.test.tsx`, `builderlab-hosted.test.ts`,
`first-run-join-handoff.test.tsx` and `first-run-navigation.test.tsx`
(4 files, 171 tests) pass. Not run: the full suite, hooks,
`design:check`, and a rendered check of the key-only page (themes,
480/1100 px, 150 % text, keyboard and pointer).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
@matt2e
matt2e force-pushed the onboarding/08-settings-key-backup-row branch from f98b7e9 to 417b71d Compare November 2, 2026 06:36
matt2e and others added 2 commits November 2, 2026 19:31
The owner's decision: block/buzz-app supports one Builderlab per build
for now, the public deployment (`app.builderlab.xyz`, hosting
`communities.buzz.xyz`). This supersedes `d2e2b7ee9`'s multi-deployment
routing.

- `builderlab-hosted.ts`: keeps only the `communities.buzz.xyz` row
  (host, relay `self` key, portal) and drops the `block.builderlab.xyz`
  and `test.blockstaging.build` rows. `builderlabHosting(found, build)`
  (`{ portal, here } | undefined`) becomes
  `hostedByThisBuilderlab(found, build): boolean`. It is true only when
  the listed deployment hosts the relay (hostname exact or label-boundary
  suffix on the default port, else its `self` key) and its portal is the
  origin of `VITE_BUZZ_BUILDERLAB_URL`. An empty, invalid or other-origin
  build URL, as in an unconfigured or federated-identity build, answers
  false, so such builds never sign in at once. The table stays, so a
  second deployment could be added later.
- `Welcome.tsx`: `onFound` signs in only when `hostedByThisBuilderlab`
  holds and otherwise pauses on the connect choice. The `elsewhere` state
  and prop are gone. `finishJoin` saves an admitted join to the account
  as it did before `d2e2b7ee9`, without the other-Builderlab guard.
- `ConnectChoice.tsx`: back to its single layout. "Buzz relies on a key
  for your identity.", prominent Sign in with email, small I already
  have a key, and Back. The key-only layout and its "different
  Builderlab" copy are gone.

So a community another Builderlab deployment runs, such as a
`block.builderlab.xyz` relay typed into a public build, now gets the
ordinary Connect to {host} choice.

Tests: `builderlab-hosted.test.ts` is rewritten for the boolean. It
covers the `communities.buzz.xyz` apex, subdomain, case, final-dot and
`:443` forms, and every lookalike and non-default-port refusal (now
including a bb-block host). It also covers build URLs of "", "not a
url", the API-path form and `https://block.builderlab.xyz`, and the
`self` fallback with bb-public's key, on the default port only. A new
assertion makes sure an unknown relay with no build URL never matches.
`identity.test.tsx`: the hosted journey runs once, with a public build
URL stubbed. The two key-only "another Builderlab" journeys and the
not-saved join journey are removed. A new 2-case journey covers a bb-block
URL in a public build and a bb-public URL in a build with no Builderlab
URL: each pauses on the ordinary choice (Sign in with email, I already
have a key, Back) with no sign-in started.

Docs: `docs/identity.md` describes one Builderlab per build and the
ordinary choice for every other community. It adds a note that
federated-identity builds do not yet support the first-run flow: Sign in
with email needs the bundled Builderlab plugin's own network grant,
which an FI build does not have (#1461), so sign-in fails after the
browser step. `docs/configuration.md` has the same note under the FI
build input. `docs/buzz-help.md` drops the "different Builderlab"
sentence.

Validation: `vitest run` of `builderlab-hosted.test.ts`,
`identity.test.tsx`, `first-run-join-handoff.test.tsx` and
`first-run-navigation.test.tsx` (4 files, 158 tests) pass.
`tsc --noEmit` and Biome on the changed source files pass. Not run: the
full suite, hooks, lint, `design:check` and native builds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
First run leaves the automatic backup choice on after a failed backup, so a
later Builderlab sign-in may finish the backup in the background; this row
makes that visible. `BackupRow` (`src/app/IdentitySettings.tsx`) reads
`identity.backup` through `useSyncExternalStore`, as Settings reads its
other stores, and 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"`.

One sentence per status — idle: "Buzz hasn’t checked your key’s backup
since it opened." (what a launch or reload reports, not "no backup");
running: "Backing up your key…"; 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 entry from `BACKUP_FAILURES`
(`backup-failure.ts`), so it is the same copy as first run's failed page,
with the Settings tail "Buzz tries again when you next sign in to
Builderlab." for the causes a new sign-in clears (busy, unreachable,
unconfirmed, storage, signed_out). There is no second table. Read-only: no
consent toggle, no retry.

Hidden unless backup is offered, by the same rule first run uses:
`backupOfferedFor(backup)` (`builderlab-account.ts`) answers false unless
the build has a Builderlab URL, and then returns the status's `builderlab`;
any error answers false. Known differences, documented: the
row appears after the first status read, so "Sign out of Buzz" moves down
one row once the IPC answers; retryable messages end in "Try again." though
the row has no button; under the `BUZZ_DEV_FIRST_RUN_ACCOUNT` seed first run
offers backup while the row follows the real rule.

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},
failed{unreachable} and the signed_out tail; hidden when the status says
`builderlab: false` and when the build has no Builderlab portal; following
pushes at seq 2, 3 and 4 with a late seq-2 push changing nothing; no button
or switch in the row. `Settings.test.tsx`'s identity case gains an inert
`backup` store. docs/identity.md describes the row.

Stack: on the Welcome flow to keep the stack linear, but it depends only on
the merged `backup.ts`, `backup-failure.ts` and `identity.backup` and the
account client's `backupOfferedFor` — it can be reviewed before the pages
and the Welcome flow.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
@matt2e
matt2e force-pushed the onboarding/08-settings-key-backup-row branch from 417b71d to 3bee161 Compare November 2, 2026 08:32
@matt2e
matt2e force-pushed the onboarding/07-welcome-first-run branch 2 times, most recently from becb0bd to b15fa60 Compare November 2, 2026 11:41
Base automatically changed from onboarding/07-welcome-first-run to main November 2, 2026 12:05

@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 follow-up via Wes’s account, integrating independent Rocket, Mantis and Groot reviews.

No new production findings in the backup row or the affected onboarding integration. The existing P3 reload-documentation correction still applies at docs/identity.md:553: a window reload resets the status to idle only when it interrupts an active backup pass; settled results survive. No duplicate inline comment added.

Reviewed head 3bee161d053c78e5dee82507e090ad4fe8f7c501 against base 8b5b4e675bc358cefdff4419b969212d27e97a56, including the carried backup status subscription, legacy-identity offer, canceled-sign-in fencing, retry focus handling and build-specific hosted-community routing. The read-only row and existing failure-recovery product decisions are preserved.

Source analysis only. No tests, builds, app launches or live native workflows were run, and CI was not assessed. Native backup/reload behavior, real sign-in, rendered focus/accessibility and human acceptance remain unverified. The public PR description was inspected and has no attached images. This COMMENT is not an approval.

@matt2e
matt2e marked this pull request as draft November 3, 2026 11:45
@matt2e matt2e closed this Nov 4, 2026
@matt2e
matt2e deleted the onboarding/08-settings-key-backup-row branch November 5, 2026 03:22
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.

3 participants