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

feat(identity): the first-run Welcome flow replaces the key-setup gate (FOUNDATION: App.tsx) - #1820

Merged
matt2e merged 13 commits into
mainfrom
onboarding/07-welcome-first-run
Nov 2, 2026
Merged

matt2e merged 13 commits into
mainfrom
onboarding/07-welcome-first-run

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 (merged)
  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 (merged)
  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 ← this PR
  7. feat(settings): read-only Key backup row in Settings → Identity #1821 settings: read-only Key backup row
  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)

#1817 (the Builderlab tenant table) was closed on 2026-10-29 and is not in the stack. #1821 and #1822 are still based on this PR's pre-rebase head 86634692, which is the same change as 480bf96b. They do not have becb0bd7 yet and need rebasing onto this head.

Builds on (all merged): #1814–#1816, #1818 and #1819. Welcome.tsx imports #1815's FindCommunity/find-community/join/join-journal/destination, #1816's service, #1818's builderlab-account/first-run-seed/oauth/first-run, and all of #1819. first-run-navigation.test.tsx needs #1814.

Diff (18 files, +7568/−207): production +1064/−16; tests +5993/−160; docs +511/−31. No Rust, build configuration or FOUNDATION file other than App.tsx changes.

CI status: the latest run fails only Rust and tool integration, and so CI required. The cause is a new zip advisory that main has already fixed in #2137. This branch needs main merged in before CI can pass. Details are under Checks below.

What this layer is

The composition and the product flow. Welcome.tsx (936 lines) covers:

  • the landing (Sign in with email and Community URL) and discovery via FindCommunity;
  • for a community hosted by this build's Builderlab, sign-in at once. A build has one Builderlab, and the only listed deployment is the public one. builderlab-hosted.ts (48 lines) matches hosts under communities.buzz.xyz on the default port, or a relay publishing that deployment's NIP-11 self key. hostedByThisBuilderlab holds only when that deployment's portal, https://app.builderlab.xyz, is the origin of this build's BUZZ_BUILDERLAB_URL. An empty, invalid or other-origin build URL (an unconfigured or federated-identity build) never signs in at once. The table keeps room for a second row, but none is added;
  • for any other community, including one run by another Builderlab deployment, the connect choice: sign in at BUZZ_BUILDERLAB_URL, or bring a key;
  • the setup lanes (Create my key / I already have a key / Welcome back / Restore / Start over);
  • Join Community through admitCommunity/completeJoin, the profile step, the known-communities restore and the invite-intent handoff;
  • the failure page (Try again / Continue without backup / a blocking link failure);
  • the backToLanding/enterApp invariants and the heading focus handoff.

LegacyIdentityOffer.tsx (64 lines) offers old Buzz's identity as a dialog over the landing (see the review follow-ups below).

The one FOUNDATION edit: App.tsx imports Welcome instead of IdentitySetup and passes identity, communities and invites (+7/−3). IdentitySetup.tsx gets a one-line comment. JoinCommunity.tsx (from #1819, which nothing on main mounts) loses its disabled Scan a QR code and Create a community buttons. Docs describe the whole flow.

Not supported: federated-identity builds. Welcome's Sign in with email uses the bundled Builderlab plugin's own network grant. Builds with BUZZ_BUILD_FEDERATED_IDENTITY_CONFIG do not have that grant (#1461), so sign-in fails after the browser step. FI builds need their own first-run path before they adopt this flow. This is documented in docs/identity.md (first-run section) and docs/configuration.md (federated identity build input).

Review follow-ups

SHAs here are the current ones. Threads and comments from before the 2026-11-02 rebase use the old SHAs; the mapping is in the next section.

  • e65782e9: a canceled sign-in that settles after a later one is ignored. signIn continues only if no cancel happened since it started.
  • ad85304f: docs/identity.md states the backup and recovery capability contract instead of which deployments enable them.
  • a80d2f1c: Try again on the invite page uses the shared Button's loading state, so focus stays on it through a repeated denial.
  • d647876f + f8a1f9db: the old-Buzz key bring-over is back, as an alert dialog over the landing. Title Continue with your Buzz identity?, actions Not now and Continue.
    • identity.legacyAvailable() runs once per entry into missing, and identity.importLegacy() runs only on Continue.
    • The dialog waits while a sign-in is pending or another first-run page shows.
    • A failed import shows its error in the dialog, and Continue retries.
    • Success enters the app with no sign-in, link or backup, and LegacyCommunityImport takes over as on main.
    • Not now and Escape dismiss it until the next launch with no key. It returns after Sign out.
  • e1841cf4: removes the tenant table that re-entered this PR (below). Its message, and an earlier version of this description, said hosted sign-in was "always at BUZZ_BUILDERLAB_URL" as intended. That was wrong for a community another Builderlab hosts: a public release (app.builderlab.xyz) given a block.builderlab.xyz community URL signed in at the public Builderlab and linked the new or imported key to that account. main never did this, because its key-setup gate never calls Builderlab.
  • 7bec8638: the first fix. It gave each listed deployment (public, Block, staging) its portal, and another deployment's community got a connect page offering only a key. Superseded by 480bf96b.
  • 480bf96b: one Builderlab per build for now, the public deployment (owner decision).
    • Removed: the block.builderlab.xyz and test.blockstaging.build rows, the key-only connect page (elsewhere) and the join save guard.
    • builderlabHosting → { portal, here } becomes hostedByThisBuilderlab → boolean. It is true only for a communities.buzz.xyz relay (or its self key) in a build whose BUZZ_BUILDERLAB_URL origin is https://app.builderlab.xyz.
    • Every other community gets the ordinary connect choice, including another deployment's. An admitted join after a landing sign-in is saved to the account again.
    • Docs add the federated-identity note above.
  • becb0bd7: the disabled "Coming soon" placeholders are removed (owner request; they come back once the features exist).
    • The landing loses "Scan a QR code — Coming soon".
    • Join a community loses "Scan a QR code" and "Create a community". Its action column is now empty, as on other onboarding screens.
    • Welcome back's "Recover from another device — Coming soon" stays.
    • The two tests that asserted the buttons and the docs/identity.md mentions are updated.

Rebase onto main (2026-11-02)

At 10:37 UTC this branch was rebased from af6e8993d onto main at 67e404917. The PR's diff is the same before and after apart from hunk offsets (17 files, +7580/−199 at the time; becb0bd7 came after). Every commit SHA changed:

Before Now Commit
5b78e9de aea3fe70 tenant table (stale, see below)
0dc22c0d e65782e9 canceled sign-in
3854b5a1 ad85304f capability-contract docs
86a4b4c3 a80d2f1c Try again focus
b3ccd00e / f2a94e07 d647876f / f8a1f9db old-Buzz offer
50bad0cd e1841cf4 tenant table removed
d2e2b7ee 7bec8638 per-deployment portals
86634692 480bf96b one Builderlab per build

The tenant table re-entered and is removed again

The 2026-11-02 01:04 UTC force-push rebuilt this branch from the pre-closure 10-28 commits. That brought back #1817's tenant table (aea3fe70) and the 05/06 variants built on it (171c8051, 5cf1b65d):

  • builderlab-tenants.ts;
  • the BUZZ_BUILDERLAB_TENANTS input in vite.config.ts and build.rs;
  • the grant union in host_request.rs and the binding-signer origin rule in identity.rs;
  • the per-origin sign-in signatures and Welcome's per-tenant routing.

e1841cf4 puts back main's (af6e8993d) version of those 20 files. It restores the tenant-free pieces from 75127a4d4: builderlab-hosted.ts and its test, and Welcome's connect(signal) and account(credential, identity). Every review fix above stays. 7bec8638 later gave each builderlab-hosted.ts row its portal, with no tenant table and no build input, and 480bf96b keeps only the public row. The three stale commits still appear in this PR's commit list. After e1841cf4 their net diff against main is zero, and the repository squash-merges.

Review focus

  • FOUNDATION: src/app/App.tsx. The import swap and the <Welcome identity communities invites> wrapper are the only three approved lines. After the rebase onto main, the LegacyCommunityImport that main placed inside IdentitySetup stays inside <Welcome>, so it still renders only once setup is complete (resolution approved by the author). No other FOUNDATION file (services.ts, main.tsx, communities/service.ts, plugins/api.ts) is in the stack.
  • The gate: children render only when identity is ready and setup is idle. enterApp() is the only way setup becomes idle with a key. backToLanding is the only path back to the landing: it clears discovery, reads and the first-run session, and keeps the typed address.
  • Nothing is signed to a relay before the user chooses to join. A failed link or unlink blocks with Try again only; a failed backup may be skipped.
  • The old-Buzz dialog renders only in the landing JSX, while its hook runs with Welcome's other hooks, so the folder check and a dismissal outlive visits to other pages.
  • identity.test.tsx journeys are grouped by lane in file order: landing/discovery and the old-Buzz offer, Connect, Join/profile/invite, key import, Welcome back/restore, Start over/owned, then failures and seed. They assert exact invoke/relay_http sequences. first-run-join-handoff.test.tsx runs the real App + createServices(). first-run-navigation.test.tsx proves fix(navigation): run the visit deadline only while a presenter is attached #1814's rule through the real gate.
  • Browser journeys: none added or removed. tests/fixtures/window-chrome.tsx gains one line that answers identity_legacy_available with false.

Checks run on this branch's tree

  • Hosted CI at becb0bd7 (this head): run 41430912324 has 37 checks passing, 2 skipped and 2 failing.
    • Passed:
      • JavaScript 1–4/4. Each shard runs pnpm check (Biome, typecheck, design:check), the frontend build and a quarter of the Vitest suite.
      • Browser journeys in Chromium (9 shards) and WebKit (14 shards).
      • The Vitest flake check (2 shards) and its selection.
      • Browser measurements, Native browser fixture, Protected agents (macOS), WebKit recording (macOS), DCO, zizmor and Semgrep OSS.
    • Skipped: the browser flake-check matrix and Windows native validation, as on every earlier run of this PR.
    • Failed: Rust and tool integration, at its Dependency policy step (cargo-deny --locked check), and so CI required.
      • The one error is RUSTSEC-2026-0429 against zip 4.6.1, which the app reaches only through tauri-plugin-updater 2.13.3.
      • The advisory is new: this job passed on this PR at 08:40 UTC, and other branches started failing the same step at 10:39 UTC.
      • This PR changes neither Cargo.lock nor deny.toml.
      • deps: tauri-plugin-updater 2.14.1 moves zip to 9, clearing RUSTSEC-2026-0429 #2137, merged to main at 11:33 UTC, moves the updater to 2.14.1 and zip to 9. This branch's base (67e404917) predates it, so a re-run fails again. It needs main merged in, or a rebase.
  • Locally at becb0bd7:
    • Passed: tsc --noEmit, Biome on the changed source files, git diff --check, and three Vitest files with 141 tests (identity.test.tsx, first-run-join-handoff.test.tsx and first-run-navigation.test.tsx).
    • Deferred: a rendered check of the landing and Join a community without the buttons (both themes, 480 and 1100 px, 150 % text).
  • Locally at 480bf96b:
    • Passed: tsc --noEmit, plus four Vitest files with 158 tests, run with BUZZ_TEST_WORKERS=2: builderlab-hosted.test.ts, identity.test.tsx, first-run-join-handoff.test.tsx and first-run-navigation.test.tsx. Biome passed on the five changed source and test files.
    • Fail-before confirmed:
      • The new 2-case journey fails with 7bec8638's Welcome.tsx, ConnectChoice.tsx and builderlab-hosted.ts in place (2 failed). A bb-block URL in a public build, and a bb-public URL with no build URL, each got the key-only page.
      • With the row !== undefined guard removed from hostedByThisBuilderlab, its self-fallback test fails: an unknown relay matched when there was no build URL.
    • Not run: the hooks (the commit and pushes used --no-verify, as the owner asked), any native build and a rendered check. ConnectChoice is back to its e1841cf4 layout.
  • At 7bec8638 and e1841cf4:
    • Local tsc --noEmit and the first-run Vitest files passed: 171 tests in four files at 7bec8638. At e1841cf4, 132 tests in identity.test.tsx, the other first-run files and vite-config.test.mjs (23 tests).
    • Fail-before was confirmed for each commit's new journeys. 480bf96b later removed 7bec8638's key-only journeys along with that behaviour. The canceled-sign-in journey, rewritten with two build-portal sign-ins, still failed without the cancel-count check.
    • Hosted CI passed on the pre-rebase commits (runs 41400983722 and 41396764126).
  • Earlier, on the tenant tree (before e1841cf4):
    • At d647876f: lint, typecheck and design:check passed; the full Vitest suite passed (854 files, 14,737 tests); window-chrome.spec.mjs passed in Chromium.
    • The old-Buzz dialog was rendered in a scratch fixture in Chromium and WebKit, light and dark, at 1100 and 480 px and with 150 % text. After f8a1f9db shortened the primary label, Not now and Continue share one row at both widths.
    • Hosted CI run 41386883487 on pre-rebase f2a94e07 passed.
    • No later commit changes LegacyIdentityOffer.tsx or where Welcome renders it.

Native evidence

None on this tree, and the native-validation gap is still open. Native first-run runs use the full tree in the DO-NOT-MERGE #1822, which adds the isolated debug Keychain slot. #1822 is still on the pre-rebase head, and no native run has been made since the 10-30 rebase, so the old-Buzz dialog's real keychain prompt has not been seen either. Treat every PR before #1822 as unit/integration-tested only.

Why this branch is green where 572d69398 was red. This PR's Welcome.tsx is the 19df106d5 version: a failed link blocks first run with Try again only, instead of offering Continue without linking. Six identity.test.tsx journeys depend on it and fail against the earlier 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…"

#1791's quiet read retries. After the rebase onto main with #1791, the identity.test.tsx relay fixture's unreachable saved community answers a web page instead of a 503. inspectProfile would retry a 503 for seconds, and those retries leaked into later tests. With the web page, the restore test keeps asserting exactly one discovery read without waiting out a retry schedule.

🤖 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/06-first-run-pages-and-setup-store branch from f5752d2 to d35338d Compare October 28, 2026 14:18
@matt2e
matt2e force-pushed the onboarding/07-welcome-first-run branch 2 times, most recently from 787e2b9 to 141b42b Compare October 28, 2026 14:21
@matt2e
matt2e force-pushed the onboarding/06-first-run-pages-and-setup-store branch from d35338d to 4972a16 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.

Three changes requested inline: fence canceled sign-in continuations, preserve the legacy-key migration entry point, and remove deployment rollout details from public documentation. One optional retry-focus improvement is also inline.

Rocket, Mantis and Groot supplied independent review lanes. Reviewed head e2f928f8b8d89f9506ba1b1e703d22fd0eba9da2, base 5c9a65404d7d0dd929650f6f7098e7aa5936841d. Source-only: tests, app/native flows, rendered accessibility and CI were not executed or verified; the author’s deferred validation remains open.

Comment thread src/features/identity/Welcome.tsx Resolved
Comment thread src/app/App.tsx Resolved
Comment thread docs/identity.md Resolved
Comment thread src/features/identity/Welcome.tsx Resolved
@matt2e
matt2e force-pushed the onboarding/06-first-run-pages-and-setup-store branch from 5c9a654 to 32731cd Compare October 29, 2026 23:48
@matt2e
matt2e force-pushed the onboarding/07-welcome-first-run branch from e2f928f to 64a5f0b Compare October 29, 2026 23:48

@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.

No new findings in the tenant-removal/rebase changes. Three earlier items still need changes:

  • Sign-in continuation: the previous P1 cross-origin consequence is resolved by removing tenant routing. A P2 remains at Welcome.tsx:247–260: a canceled sign-in can settle after a newer sign-in reaches key import, restart account checks, replace that page and discard its typed draft. Fence the continuation to its attempt before reading the shared session; cover cancellation followed by a newer sign-in and a late old completion.
  • Legacy-key migration (P2) remains absent from Welcome. Keeping LegacyCommunityImport inside the gate preserves community import, but does not restore the explicit old-key import action promised by docs/identity.md:427. Restore that action or explicitly resolve the product contract and update its documentation/tests.
  • Public rollout details (P2) remain in docs/identity.md:66–67,151; replace environment-specific rollout statements with the portable capability contract.

The retry-focus suggestion remains optional. Existing comments are linked rather than duplicated.

Rocket, Mantis and Groot completed independent lanes. Reviewed head 64a5f0bad98c51935229f39458a4489247ce0ce7, base 32731cd75ccc4cacfea3249f9016fc894435747f. Source-only follow-up: no tests, app/native flows, rendered accessibility, performance or CI verification. The PR’s stated native-validation gap remains open.

@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/06-first-run-pages-and-setup-store branch from 32731cd to 235e33e 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 follow-up via Wes’s account.

No new findings in the hosted-community recognition and automatic-sign-in changes. The following earlier P2 items still need changes:

  • Canceled sign-in continuation: Welcome.tsx:248–260 still lets a canceled attempt resume after a newer sign-in reaches key import, replacing that page and losing its draft. Fence the continuation to its attempt on new sign-in, cancellation and unmount. The earlier cross-origin consequence remains resolved.
  • Legacy-key migration: the active Welcome gate still omits the opt-in legacy-key import promised by docs/identity.md:432–438. Restore the action or explicitly resolve the product contract and update its documentation/tests.
  • Public rollout details: deployment rollout statements remain at docs/identity.md:72–73,157. Replace them with the portable capability contract. The new public-host/key classifier is not an additional disclosure finding.

The retry-focus suggestion remains optional. Existing comments are linked rather than duplicated.

Rocket, Mantis and Groot completed independent lanes. Reviewed head 75127a4d427c6831fa4723ffe0e92677d2344719, base 235e33ead40427dcf9d905783e33105c9894a2b5. Source-only follow-up: no tests, app/native flows, rendered accessibility, performance or CI verification. The PR’s stated native-validation gap remains open.

@matt2e
matt2e force-pushed the onboarding/06-first-run-pages-and-setup-store branch from 235e33e to 17c1081 Compare October 30, 2026 11:24
@matt2e
matt2e requested a review from a team as a code owner October 30, 2026 11:24
@matt2e
matt2e force-pushed the onboarding/06-first-run-pages-and-setup-store branch from 17c1081 to 71ef1e1 Compare November 1, 2026 23:53
Base automatically changed from onboarding/06-first-run-pages-and-setup-store to main November 2, 2026 00:34
@matt2e
matt2e force-pushed the onboarding/07-welcome-first-run branch from 75127a4 to a8185e9 Compare November 2, 2026 01:04

@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.

One new P2 configuration-parity finding is inline. The earlier findings still need changes:

  • Canceled sign-in continuation is P1 again: tenant routing restores its cross-origin consequence. At Welcome.tsx:267–274, canceled attempt A can finish after B has signed in and paused at key setup. The session owner discards A’s result but resolves A’s caller, which combines B’s shared credential with A’s captured origin. The account-check requests then send B’s credential to A’s configured portal. Fence this continuation on new attempts, cancellation and unmount before reading the credential or replacing account state; cover A settling after B succeeds. The abort checks inside the OAuth client do not fence Welcome’s continuation.
  • Legacy-key migration (P2) remains missing from the active Welcome gate. Restore the opt-in migration action through the existing identity service, or explicitly resolve the product contract and update the retained documentation/tests.
  • Public rollout details (P2) remain in docs/identity.md:76–77,160–162. Replace deployment-specific availability statements with the portable capability contract.

The existing retry-focus suggestion remains optional. Earlier comments are linked rather than duplicated.

Rocket, Mantis and Groot completed independent lanes; Star Lord integrated the source evidence. Reviewed head a8185e95a3f62739ba7dcf90dfcc84bd000be124, base af6e8993d4f090b4fd4e7526a0dec1e42e51090f. Public PR description inspected; no description attachments. Source-only: no tests, builds, app/native sign-in, rendered accessibility or performance checks were run. One hosted-CI snapshot showed 21 successful, 17 running and 2 skipped checks; it does not establish acceptance. The PR’s stated native-validation gap remains open.

Comment thread vite.config.ts Resolved
matt2e and others added 13 commits November 2, 2026 22:40
…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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
… a plugin session, kgoose reads, link/unlink, backup, saved communities — and its dev seed

All of first run's network logic, no UI. Nothing on main calls it yet; the
Welcome flow (PR 07) is the caller.

`src/bundled/builderlab/oauth/first-run.ts` (new): binds a `Host` to the
Builderlab plugin's own bundled native grant (`block.builderlab`/`bundled`,
granted at build time from `BUZZ_BUILDERLAB_URL` and the tenant table) and
exposes `firstRunCredential(signal, origin?)`, the plugin's
`browserCredential` run before any plugin session exists. That credential
is separate from the Settings session and is never persisted.

`src/features/identity/builderlab-account.ts` (new): `FirstRunAccount`, the
kgoose reads and writes first run needs, every request a POST with `{}` or
a body, `X-BB-Session-Credential` from the first-run session and `Origin`
set to the login origin that signed the user in, through `bundledHost.request`:
- `linkedKey` (`/v1/buzz/nostr-identities/current`): a 2xx with `identity`
  null or absent is *not linked* (kgoose never sends `MISSING_MAPPING` on
  this route); a present identity whose `pubkey_hex` is not 64 lowercase
  hex fails — a linked account must never be given a second key.
- `ownedCommunities` (`/v1/buzz/communities/list`, archived and insecure
  hosts excluded) and `knownCommunities`
  (`/v1/buzz/known-communities/list`, read with the Builderlab plugin's own
  `listedAddress`, exported from `known-communities/client.ts` for this; a
  row this client cannot read rejects the whole list).
- `bind(pubkey)`: reads `current` first and returns when the account already
  holds this key, else challenge → native `identity_sign_builderlab_binding`
  → verify, which must confirm this key. 409 `identity_already_bound` /
  `pubkey_already_bound` take the hosted-communities card's copy.
- `unbind(old, new)` (`/nostr-identities/delete`): the route takes no key
  and deletes whatever is bound, so it sends the delete only while `current`
  still answers the old key, counts as done when the delete echoes the old
  key or a re-read shows none or the new key, and fails without Try again
  if the old key is still bound. The real fix is an `expected_pubkey_hex`
  on the delete, noted in a comment.
- `backUp(pubkey)`: refuses with `BackupError("unavailable")` unless
  `backupOfferedFor(backup, origin)` holds — this build's uploader backs up
  to Builderlab and the sign-in is at the build's own portal (a tenant's
  credential is never handed to the native uploader) — then `backUpWith`.
- `backupStatus()` (`/v1/buzz/nsec-backups/status`): the listed hex keys;
  "unavailable" without a request unless offered, and on a 404; other
  answers map to the backup failure kinds.
- `recovery`: the native recovery driver, so the dev seed can replace it.
- `saveCommunity(url)` (`/v1/buzz/known-communities/add`,
  `wss://host[:port]`).
- Shared `call`/`post`: transient failures (no status, 429, 5xx) are
  retryable, 4xx refusals and rejected shapes are not; any HTTP 401 is
  "Your Builderlab sign-in is no longer valid."; failure copy names the
  step and status, never a response body; kgoose codes the card's table
  lacks get first-run copy (`invalid_origin`, `invalid_challenge`,
  `challenge_*`, `too_many_attempts`; `challenge_expired/consumed/not_found`
  retryable since Try again fetches a fresh challenge); the correlation ID
  stays off the page and goes to a development-only `console.warn`.

Dev seed (`first-run-seed.ts`, new; `seededAccount`):
`BUZZ_DEV_FIRST_RUN_ACCOUNT` — `unlinked`, or `{"npub","communities",
"known","backups","recovery","backup":"<kind>"|"fail-once","unlink":<status>}`
— replaces the kgoose reads, the binding, the unlink, the backup and backup
recovery, and nothing else, on a dev server. `vite.config.ts` validates it
(explicit `.ts` imports so Node's native config loader stays warning-free)
and defines `VITE_BUZZ_FIRST_RUN_ACCOUNT` for `serve` only; builds define it
empty. Bad values are rejected without being echoed. A developer's real
account binding never changes under a seed.

Docs: `docs/configuration.md` (the seed row and "First-run account seed
(development)" section), `.env.example`, the Builderlab README's first-run
paragraph.

Tests: `builderlab-account.test.ts` (1,337: every route's exact request at
a tenant and at the build URL; linked/not-linked readings; the bind
sequences incl. the already-bound no-op and 409 from verify; unbind's
current-then-delete with echo and re-read outcomes; backUp's tenant guard
and handoff; backupStatus mappings; saveCommunity; the retryability, 401
and correlation-ID rules; the seeded account), `first-run-seed.test.ts`
(268: every field, rejections without echo, the seeded backup/unlink/
recovery behaviours), `oauth/first-run.test.ts` (the bundled grant request
shape with and without an origin), and the seed lines of
`browser-host/vite-config.test.mjs` (serve vs build define, rejections).

Stack: PR 05 of the onboarding stack, on 04; imports 02 (`destination`),
03 (`backup`, `backup-failure`, `service`) and 04 (`oauth/browser`). 06–08
build on it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
… 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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
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 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…rom first run

The owner asked to remove these placeholders for now; they will come
back once the features exist.

- Welcome landing: the disabled "Scan a QR code — Coming soon" button
  is gone. Sign in with email, the Community URL field and the old-Buzz
  offer are unchanged.
- Join a community (`JoinCommunity.tsx`): the disabled "Scan a QR code"
  and "Create a community" buttons are gone. The action column is empty
  (`{null}`, as other onboarding screens already do). The owned rows and
  the URL field are unchanged.

Welcome back's "Recover from another device — Coming soon" stays; it
was not part of the request.

Tests: `identity.test.tsx` drops the two assertions that the disabled
buttons were present, and the landing test's title no longer mentions
QR scanning.

Docs: `docs/identity.md` no longer lists either button on the welcome
screen or on Join a community.

Validation: `vitest run` of `identity.test.tsx`,
`first-run-join-handoff.test.tsx` and `first-run-navigation.test.tsx`
(3 files, 141 tests) pass. `tsc --noEmit`, Biome on the changed source
files and `git diff --check` pass. Not run: a rendered check of the
landing and Join a community without the buttons (themes, 480/1100 px,
150 % text).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e force-pushed the onboarding/07-welcome-first-run branch from becb0bd to b15fa60 Compare November 2, 2026 11:41
@matt2e
matt2e merged commit 8b5b4e6 into main Nov 2, 2026
41 checks passed
@matt2e
matt2e deleted the onboarding/07-welcome-first-run branch November 2, 2026 12:05
wesbillman pushed a commit that referenced this pull request Nov 3, 2026
…ion (FOUNDATION: App.tsx, services.ts) (#2249)

## Summary

New users of FI builds (the internal Block build, 0.7.7) couldn't finish
first run. **Sign in with email** opened the browser, and then the app
showed *Couldn't sign in. Could not complete Builderlab sign-in.*
Welcome exchanged the code through the bundled Builderlab plugin's
network grant. FI builds don't have that grant: Builderlab calls go
through the native NIP-FI session instead
([#1461](#1461)).
[#1820](#1820) documented this as
a known gap.

With this change, Welcome in FI builds signs in through the native
NIP-FI session and sends setup's account calls through its forwarder.
Non-FI builds are unchanged.

- **Sign-in:** `firstRunCredential` calls `federatedIdentity.signIn()`.
The browser step, code exchange and token all stay native. It then reads
`GET v1/auth/me` through the forwarder. The returned credential's value
is the session's public revision. If a session already exists, it signs
in again so the browser can choose the account. A renewal that doesn't
finish (canceled or timed out in the browser) gets back the old session
at the same revision, so first run refuses rather than continuing as the
old account. Native's bare failure codes map to the same copy as the
community sign-in.
- **Account calls:** identity check, link and unlink, owned communities,
and known communities use the same routes through the forwarder via
`federatedBuilderlabAccount`. Each call is checked against the session
revision that sign-in returned, so a session that changed refuses
without sending anything.
- **Acting as the bundled installation:** native FI admission checks
only the grant for the (id, revision) pair, not whether the plugin is
running. So Welcome calls as `block.builderlab`/`bundled` through
`ctx.extend({ pluginOwner })`, the same installation `bundledHost`
already acts as. No native or grant changes.
- **Backup:** `backUpWith` hands the uploader the FI session revision
(`handoffFederatedBackupSession`). Backup status returns "unavailable".
The forwarder has no route for it, and the FI plugin doesn't read one
either, so Welcome back offers no restore and an imported key is offered
a backup. The Block build has no backup configured today.
- **After Welcome:** the native session persists, so `LaunchSignIn`
finds it already signed in and doesn't prompt again.

**Known gap:** after a first-run backup, `backUpWith` clears the
uploader's `builderlab` slot as it does today. The plugin shares the FI
session, so it won't hand that slot off again until the session changes.
This only matters where backup is configured, and the Block build
doesn't configure it.

**FOUNDATION:** `services.ts` gets one line to create the bundled-owner
FI view, only in FI builds. `App.tsx` gets one prop to pass that view to
Welcome.

Out of scope: `browser.ts` still hides native error strings. This is
deliberate, so provider bodies are never forwarded. The "Sign in with
email" label also stays as is in FI builds.

## Validation

- Full `vitest run`: 873 files and 15,427 tests passed (7 files and 21
tests skipped). `tsc --noEmit` and `biome check` are clean. These ran on
the tree committed as `41388fb64`.
- New tests:
- `first-run.test.ts`: the bundled-owner view registers and requests as
`block.builderlab`/`bundled` with a real cordis `Context`. The FI
sign-in reads the account and never opens the plugin's browser handoff.
Failures map to the right copy, including a renewal that doesn't finish,
a native failure code, and an account read that isn't JSON. Cancel calls
`cancelSignIn`.
- `builderlab-account.test.ts`: forwarder paths and headers, revision
fencing, backup status "unavailable", and the federated backup handoff.
- `identity.test.tsx`: Welcome's defaults route through the FI session.
- I mutation-checked the owner and Welcome-wiring tests: each fails when
the change is reverted.
- **Real FI build, step 2 only:** I ran a debug desktop build with the
production bb-block descriptor, on a fresh profile with its own HOME and
keychain. Sign-in now reaches **Welcome back** (the test account already
has a linked key). On `main`, the same binary shows *Couldn't sign in*.
I didn't run steps 3–4 below (Create my key, Cancel sign-in) in a real
FI build.

## Before and after

Both runs use the same FI debug build (bb-block descriptor) on a fresh
profile. Only the frontend differs. I cut the browser sign-in step, and
blacked out the account's email and npub.

### Before: `main` (`90ce5e37a`) shows *Couldn't sign in*


https://github.com/user-attachments/assets/3c04ff79-f020-4123-b226-09aeafa15c24

### After: this PR (`373d7484d`) lands on Welcome back


https://github.com/user-attachments/assets/b109f807-7a4a-4dd3-8fef-f199ec76523c

## Human test (FI build, bb-block staging)

1. Build and run with the staging descriptor from
`squareup/buzz-releases` `scripts/build-macos-app-staging.sh`, and use a
fresh profile with no key:
`BUZZ_BUILD_FEDERATED_IDENTITY_CONFIG='<staging descriptor>'
BUZZ_BUILDERLAB_URL= just desktop`
2. Click **Sign in with email** and finish the browser step. You should
land on **Create my key** (or **Welcome back** for an account that
already has a linked key), not *Couldn't sign in*.
3. Click **Create my key**. Setup should link the key and continue. The
app should open without a second organization sign-in prompt.
4. Run it again on a fresh profile and on one already signed in to the
organization. On each, click **Cancel sign-in** during the browser step.
You should return to the landing page with no key created.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
arividar pushed a commit to minniai/minni-buzz-app that referenced this pull request Nov 4, 2026
…ion (FOUNDATION: App.tsx, services.ts) (block#2249)

## Summary

New users of FI builds (the internal Block build, 0.7.7) couldn't finish
first run. **Sign in with email** opened the browser, and then the app
showed *Couldn't sign in. Could not complete Builderlab sign-in.*
Welcome exchanged the code through the bundled Builderlab plugin's
network grant. FI builds don't have that grant: Builderlab calls go
through the native NIP-FI session instead
([block#1461](block#1461)).
[block#1820](block#1820) documented this as
a known gap.

With this change, Welcome in FI builds signs in through the native
NIP-FI session and sends setup's account calls through its forwarder.
Non-FI builds are unchanged.

- **Sign-in:** `firstRunCredential` calls `federatedIdentity.signIn()`.
The browser step, code exchange and token all stay native. It then reads
`GET v1/auth/me` through the forwarder. The returned credential's value
is the session's public revision. If a session already exists, it signs
in again so the browser can choose the account. A renewal that doesn't
finish (canceled or timed out in the browser) gets back the old session
at the same revision, so first run refuses rather than continuing as the
old account. Native's bare failure codes map to the same copy as the
community sign-in.
- **Account calls:** identity check, link and unlink, owned communities,
and known communities use the same routes through the forwarder via
`federatedBuilderlabAccount`. Each call is checked against the session
revision that sign-in returned, so a session that changed refuses
without sending anything.
- **Acting as the bundled installation:** native FI admission checks
only the grant for the (id, revision) pair, not whether the plugin is
running. So Welcome calls as `block.builderlab`/`bundled` through
`ctx.extend({ pluginOwner })`, the same installation `bundledHost`
already acts as. No native or grant changes.
- **Backup:** `backUpWith` hands the uploader the FI session revision
(`handoffFederatedBackupSession`). Backup status returns "unavailable".
The forwarder has no route for it, and the FI plugin doesn't read one
either, so Welcome back offers no restore and an imported key is offered
a backup. The Block build has no backup configured today.
- **After Welcome:** the native session persists, so `LaunchSignIn`
finds it already signed in and doesn't prompt again.

**Known gap:** after a first-run backup, `backUpWith` clears the
uploader's `builderlab` slot as it does today. The plugin shares the FI
session, so it won't hand that slot off again until the session changes.
This only matters where backup is configured, and the Block build
doesn't configure it.

**FOUNDATION:** `services.ts` gets one line to create the bundled-owner
FI view, only in FI builds. `App.tsx` gets one prop to pass that view to
Welcome.

Out of scope: `browser.ts` still hides native error strings. This is
deliberate, so provider bodies are never forwarded. The "Sign in with
email" label also stays as is in FI builds.

## Validation

- Full `vitest run`: 873 files and 15,427 tests passed (7 files and 21
tests skipped). `tsc --noEmit` and `biome check` are clean. These ran on
the tree committed as `41388fb64`.
- New tests:
- `first-run.test.ts`: the bundled-owner view registers and requests as
`block.builderlab`/`bundled` with a real cordis `Context`. The FI
sign-in reads the account and never opens the plugin's browser handoff.
Failures map to the right copy, including a renewal that doesn't finish,
a native failure code, and an account read that isn't JSON. Cancel calls
`cancelSignIn`.
- `builderlab-account.test.ts`: forwarder paths and headers, revision
fencing, backup status "unavailable", and the federated backup handoff.
- `identity.test.tsx`: Welcome's defaults route through the FI session.
- I mutation-checked the owner and Welcome-wiring tests: each fails when
the change is reverted.
- **Real FI build, step 2 only:** I ran a debug desktop build with the
production bb-block descriptor, on a fresh profile with its own HOME and
keychain. Sign-in now reaches **Welcome back** (the test account already
has a linked key). On `main`, the same binary shows *Couldn't sign in*.
I didn't run steps 3–4 below (Create my key, Cancel sign-in) in a real
FI build.

## Before and after

Both runs use the same FI debug build (bb-block descriptor) on a fresh
profile. Only the frontend differs. I cut the browser sign-in step, and
blacked out the account's email and npub.

### Before: `main` (`90ce5e37a`) shows *Couldn't sign in*


https://github.com/user-attachments/assets/3c04ff79-f020-4123-b226-09aeafa15c24

### After: this PR (`373d7484d`) lands on Welcome back


https://github.com/user-attachments/assets/b109f807-7a4a-4dd3-8fef-f199ec76523c

## Human test (FI build, bb-block staging)

1. Build and run with the staging descriptor from
`squareup/buzz-releases` `scripts/build-macos-app-staging.sh`, and use a
fresh profile with no key:
`BUZZ_BUILD_FEDERATED_IDENTITY_CONFIG='<staging descriptor>'
BUZZ_BUILDERLAB_URL= just desktop`
2. Click **Sign in with email** and finish the browser step. You should
land on **Create my key** (or **Welcome back** for an account that
already has a linked key), not *Couldn't sign in*.
3. Click **Create my key**. Setup should link the key and continue. The
app should open without a second organization sign-in prompt.
4. Run it again on a fresh profile and on one already signed in to the
organization. On each, click **Cancel sign-in** during the browser step.
You should return to the landing page with no key created.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Jitter <2b2e6415e748c35180847a37aadae06a11e30dddc76b784e7fd9354c4eb42e7a@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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