Skip to content

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

Open
matt2e wants to merge 1 commit into
onboarding/06-first-run-pages-and-setup-storefrom
onboarding/07-welcome-first-run
Open

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

Depends on: PRs 01–06 (Welcome.tsx imports 24 changed modules: 02's FindCommunity/find-community/join/join-journal/destination, 03's service, 04's oauth/browser + builderlab-tenants, 05's builderlab-account/first-run-seed/oauth/first-run, all of 06; first-run-navigation.test.tsx needs 01).

Diff: production +948/−3; tests +5468/−96; docs +473/−17

What this layer is

The composition and the product flow. Welcome.tsx (940 lines, the 19df106d5 version): landing (Sign in with email, Community URL, QR placeholder), discovery via FindCommunity, tenant/none decision, sign-in at the chosen portal, 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, 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. Plus the one FOUNDATION edit: App.tsx imports Welcome instead of IdentitySetup and passes identity, communities, invites (+7/−3). IdentitySetup.tsx gets a one-line comment. Docs describe the whole flow.

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, and backToLanding is the only path back to the landing (clears discovery, reads and the first-run session; keeps the typed address).
  • Nothing is signed to a relay before the user chooses to join; a tenant's credential never reaches the native uploader; a failed link/unlink blocks with Try again only, a failed backup may be skipped.
  • identity.test.tsx journeys are grouped by lane in file order (landing/discovery, Connect, Join/profile/invite, key import, Welcome back/restore, Start over/owned, failures & seed) and assert exact invoke/relay_http sequences. first-run-join-handoff.test.tsx runs the real App + createServices(); first-run-navigation.test.tsx proves PR 01's rule through the real gate.

Checks run on this branch's tree

  • Deferred to CI at the author's request (the build machine was under a deliberate stress load from another session, load avg 58–110, so local vitest/cargo runs were timing out in untouched files). Pushed with --no-verify; the hosted checks on this PR are the verification. The plan's checks for this layer were: vitest run src/features/identity src/features/communities src/app, tsc --noEmit, biome check, pnpm design:check, test:browser --project=chromium --project=webkit --no-deps tests/browser/window-chrome.spec.mjs.

Native evidence

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

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…".

  • Rebased onto main with fix: retry transient finite reads before recovery actions #1791's quiet read retries: the identity.test.tsx relay fixture's unreachable saved community now answers a web page instead of a 503 (which inspectProfile would retry for seconds, with the retries leaking into later tests), so 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
…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>
@matt2e
matt2e force-pushed the onboarding/06-first-run-pages-and-setup-store branch from 4972a16 to 5c9a654 Compare October 29, 2026 03:36
@matt2e
matt2e force-pushed the onboarding/07-welcome-first-run branch from 141b42b to e2f928f Compare October 29, 2026 03:36

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Three 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 on lines +269 to +272
await login.signIn();
if (login.snapshot().status !== "signed-in") return;
const current = account(login.credential(), origin, identity);
reads.current = current;

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.

🤖 P1 — Fence the sign-in continuation to its own attempt. A canceled login can finish its pending HTTP exchange after a newer login succeeds. createOAuthSession discards the old result but resolves its promise, so this global status check accepts the newer session and constructs account(newCredential, oldOrigin). Once the newer setup is paused, setup.start also accepts the stale account checks; builderlabAccount sends that credential to the earlier configured portal. Native HTTP does not receive the abort signal (oauth/browser.ts:122–132). Invalidate a Welcome attempt generation on new sign-in, cancellation and unmount, then check it immediately after this await before reading credentials or replacing reads.current. Cover canceled A settling after B succeeds, with no account requests to A’s origin.

Comment thread src/app/App.tsx
Comment on lines +72 to +76
<Welcome
identity={services.identity}
communities={services.communities}
invites={services.invites}
>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 P2 — Preserve the explicit legacy-key migration entry point. Replacing the gate removes the only reachable UI calling identity.legacyAvailable() / identity.importLegacy(): those calls remain solely in IdentitySetup, while Welcome never offers them. An existing old-Buzz user now loses “Continue with your Buzz identity” and must manually export/paste the key. docs/identity.md still promises that offer, including after sign-out, and the retained migration test mounts the obsolete gate. Reuse the existing opt-in migration action in Welcome and test it through the live gate. If removal is an intended product change, explicitly resolve that contract and update the documentation/tests.

Comment thread docs/identity.md
Comment on lines +75 to +77
[automatic backup](#automatic-backup-and-optional-recovery)); kgoose answers
`404` wherever backups are not configured, which today is everywhere but
staging's public Builderlab. Not sent, a `404` or any failure leaves the lookup

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 P2 — Keep deployment rollout details out of public documentation. This new paragraph identifies which deployed environment currently enables backups; later recovery/backup paragraphs likewise describe current tenant rollout coverage. Those are operational deployment details in a public repository. Replace them with the portable contract: backups/recovery require the corresponding build and service capabilities, and unavailable capabilities produce the documented fallback. Keep the environment-specific rollout evidence in internal tracking.

</Button>
)}
{onRetry && (
<Button variant="prominent" onClick={onRetry} disabled={!!busy}>

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.

🤖 Optional P3 — Preserve focus across a repeated retry failure. disabled={!!busy} disables the focused Try again button. If the same denial returns, the page key is unchanged and the page-change focus effect does not run. Use the shared Button’s loading/focusable-disabled behavior for this pending action, while keeping it disabled during an invite-code submission, and cover focus after repeated denial. This is source-inferred; browser focus was not exercised.

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