From e2f928f8b8d89f9506ba1b1e703d22fd0eba9da2 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 29 Oct 2026 00:19:03 +1100 Subject: [PATCH] feat(identity): the first-run Welcome flow replaces the key-setup gate (FOUNDATION: App.tsx) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ``. 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 Signed-off-by: Matt Toohey --- docs/communities.md | 30 +- docs/deep-links.md | 4 +- docs/identity.md | 456 ++- src/app/App.tsx | 10 +- src/app/first-run-join-handoff.test.tsx | 445 +++ src/app/first-run-navigation.test.tsx | 234 ++ src/features/identity/IdentitySetup.tsx | 1 + src/features/identity/Welcome.tsx | 940 +++++ src/features/identity/identity.test.tsx | 4885 ++++++++++++++++++++++- 9 files changed, 6889 insertions(+), 116 deletions(-) create mode 100644 src/app/first-run-join-handoff.test.tsx create mode 100644 src/app/first-run-navigation.test.tsx create mode 100644 src/features/identity/Welcome.tsx diff --git a/docs/communities.md b/docs/communities.md index 6bdec43da8..9d0978d51d 100644 --- a/docs/communities.md +++ b/docs/communities.md @@ -42,7 +42,23 @@ publication uses a signed kind:0 event and requires a matching accepted receipt and current profile readback before the client saves the completed membership. Rejected setup retains input and does not replace the currently selected community. An admitted invite is a remote side effect and is not undone if later profile -setup fails or is cancelled. +setup fails or is cancelled. The native first-run **Join Community** page +(`features/identity/Welcome.tsx`, [identity](identity.md)) is the only other +caller of `communities.joined`, through the same owner as the dialog +(`join.ts`): the join is journaled before any remote effect, a code from an +invite link or the invite page is claimed with the same policy receipt and +signed claim (a journaled join reads before it reclaims), and the signed profile +read proves admission; the join itself publishes no profile. The first-run +profile step that follows, when no profile exists yet, is the dialog's: a fresh +read, a kind 0 published when the draft differs, its readback, and only then +the saved and selected membership. First run's +restore of the account's [known communities](#known-communities) joins each +admitted destination through the same read and call. `Welcome` is also the +second caller of the invite intent (`invite-intent.ts`) beside OS deep links: +an address typed on the welcome screen that the restore did not admit opens +this dialog, prefilled, as the app opens. + + Profile editing preserves existing fields that this editor does not expose. After a confirmed Settings save, the profile also becomes the device-local @@ -176,7 +192,17 @@ devices of one account can disagree about a community, and an add queued while the service was unreachable can likewise land after another device's removal. Joining and leaving never wait for sync. The `knownCommunities` capability exposes the record, the queue and these writes to one owner, the bundled -Builderlab plugin. +Builderlab plugin. First run reads the same account list after the Builderlab +sign-in and restores it before the app opens ([identity](identity.md)): each +destination that admits the new device's key is joined through an admission +read, so a refused or unreachable one is not saved then; the next sign-in's +merge adds it without a read, as for any other device. Removals are ignored +there too. A join on first run's **Join Community** page is also posted to the +account's add at once with the first-run sign-in's credential, since the +Settings session that sends the outbox may not exist until a later manual +sign-in; `joined()` still queues the add, and the next sign-in sends it again, +which the service's idempotent add makes harmless. Restored destinations are not +posted. The list is account-scoped, but this state is still kept under the device record's viewer key (`buzz-client.v1:`), where it outlives a diff --git a/docs/deep-links.md b/docs/deep-links.md index a235a83aaf..5f14ee5931 100644 --- a/docs/deep-links.md +++ b/docs/deep-links.md @@ -46,7 +46,9 @@ selected or no identity is known. `buzz://join` is the separate invitation form. It holds through identity setup and opens the existing join dialog with the relay and code prefilled; it never claims or saves membership until the user completes discovery, policy acceptance, claim and profile -confirmation. The relay's HTTPS `/invite/` page supplies that `buzz://join` +confirmation. The first-run welcome screen hands an address (and invite code) +typed there to the same intent when it enters the app on a restored community +instead ([identity](identity.md)), so one dialog serves both. The relay's HTTPS `/invite/` page supplies that `buzz://join` link; HTTPS itself is not registered as an OS handler. `buzz://connect` (the relay website's **Open in Buzz**) and `buzz://add-community` (hosted community **Open in Buzz**; `name` is ignored) are the same form without a code: diff --git a/docs/identity.md b/docs/identity.md index 6a7308bbd1..978ab4ea8d 100644 --- a/docs/identity.md +++ b/docs/identity.md @@ -1,13 +1,413 @@ # Packaged human identity and relay access -Native macOS, Windows and Linux without the live development broker offer -**Create a new identity** or **Use an existing key**. These are alternatives. -Setup can also offer **Continue with your Buzz identity** when old Buzz is -installed and **Restore from backup** in builds with backup recovery configured; -both are described below. Import accepts an nsec, -validates its checksum and secp256k1 scalar, and persists that exact key before -adopting its public identity. It never generates a replacement after an import, -read or write failure. Denied/unavailable/corrupt storage is not first run. +Native macOS, Windows and Linux without the live development broker open on a +welcome screen when no human key exists on this device: **Buzz** — "Your people, +your agents, your projects — all in one place." — with **Sign in with email** +(the label the Buzz mobile client uses; first-run copy never names the sign-in +provider), a live **Community URL** field with **Continue** beside it (the +same field and normalisation as Join a community below), and **Scan a QR code**, +shown disabled as *Coming soon*. Sign in runs the bundled Builderlab plugin's +browser sign-in under that plugin's own native network grant; that credential +is not yet shared with Settings → Integrations → Builderlab. A failed or +canceled sign-in shows *Couldn’t sign in* with the error and **Back** to the +welcome screen. The welcome screen carries the +onboarding artwork and wordmark, and every later first-run page sits on the +same artwork; pages without a finished design are plain black-and-white +wireframes. When a page replaces another, focus moves to the new page's +heading unless the page focuses a field itself, and **Back** returns focus to +the choice that opened the page. + +A URL given on the welcome screen is looked up first, before sign-in and before +any key exists: **Continue** opens the same **Finding community** page described +below, which sends only the two unauthenticated GETs, so a typo, an unreachable +relay or a non-Buzz address fails there with the same classified copy; **Cancel** +(and **Retry**, when offered) behave as below, except that Cancel returns to the +welcome screen with the address kept in the field. A found community decides +which sign-in follows: `discoverAuthentication` in +`src/features/identity/builderlab-tenants.ts` matches the community's hostname, +then its NIP-11 `self` key, against the +[`BUZZ_BUILDERLAB_TENANTS` build table](configuration.md#builderlab-tenant-table-build-input). +A match starts Builderlab sign-in at once at that tenant's portal (the same +browser sign-in, exchange and account check, at the row's `loginOrigin` instead +of `BUZZ_BUILDERLAB_URL`), and the welcome screen shows it pending (**Cancel +sign-in**, with the field disabled). Cancel sign-in, and **Back** from a sign-in +or setup failure, return to the welcome screen with the address kept and do not +look it up again. A community no row hosts is not known to be Builderlab-hosted, +so it pauses on **Connect to {host}** instead of choosing a portal. The page +says only that Buzz relies on a key for the user's identity; its choices are +**Sign in with email**, which signs in at `BUZZ_BUILDERLAB_URL` and +continues exactly as the welcome screen's own button does (the landing shows +the sign-in pending with the address held; the key it creates is linked to that +account and backed up there), and setup then lands on **Join Community** for +the address; +**I already have a key** opens **Enter your private key** and, with no account +signed in, continues from there with no link step and no backup offer; **Back** +returns to the welcome screen with the address kept. + +Success continues to a **Setting up Buzz** page that shows the current step +beside a spinner. It first runs **Checking your account**, a real read of the +Nostr key bound to the signed-in Builderlab account +(`POST /api/goose/v1/buzz/nostr-identities/current` at the portal that signed +the user in, authenticated with the first-run session credential and sent +through the bundled plugin's native grant; `src/features/identity/builderlab-account.ts`, +sharing the hosted-communities card's routes and reply parsers). A `200` with +no `identity` means no key is linked (kgoose sends `MISSING_MAPPING` only from +the community list); any other failure is **Couldn’t finish setup**, naming the +step and HTTP status (never the response body), for example "Builderlab account +check failed (HTTP 403: not JSON)". A lapsed sign-in, which kgoose answers on +every account route with a plain-text HTTP 401, reads *Your Builderlab sign-in +is no longer valid.* here and on every later account step. Binding is 1:1 and +enforced, so the answer picks the lane: a linked account is never given a +second key. Under the same label, and in parallel, it reads the destinations +saved to the account (`POST /api/goose/v1/buzz/known-communities/list`, same +portal and credential: the list the Builderlab plugin's +[known-communities sync](communities.md#known-communities) keeps, which needs +no Nostr identity). That read never fails setup: a refusal, a portal without +the route (a plain-text `404`) or a row the app cannot read leaves the list +unknown and first run continues as if the account had saved none. The list is +kept from sign-in until the welcome screen is shown again, so Start over keeps +it, and every lane ends by restoring it (*Restoring your communities*, below). + +Either lane continues with *Checking for a backed-up key*, which reads the +keys the account holds a backup of +(`POST /api/goose/v1/buzz/nsec-backups/status`, same portal). It is sent only +where the backup step can run (see +[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 +unavailable rather than empty, and never fails setup: it only decides whether +Welcome back offers a restore and whether an import needs the backup offer. + +**Not linked** then pauses on **Set up your key**: when the lookup listed no +backups, it says no backed-up key was found for this account, so Buzz will +make one (an unavailable or failed lookup says nothing either way, nor does +one that lists backups, which outlive an unlink), and it says that an +encrypted copy is backed up to the account, which Block can use to restore the +key on the user's other devices. Under the body it says who is signed in +(*Signed in as {email}.*, or *Signed in.* when the portal sent no address), so +a wrong account is visible before any key exists. Like the welcome screen, it +stacks its choices at the bottom: **Create my key** (primary) above a quieter +**I already have a key** and **Use a different account**, which signs the +first-run session out and returns to the welcome screen with the typed address +kept and no key created. The browser may still hold the portal's own cookie +session, so the next **Sign in with email** can re-select the same account +unless the user signs out of the portal in the browser first; first run cannot +end that session. Choosing Create my key is the backup consent (see +[consent hooks](#automatic-backup-consent-hooks)). It runs **Creating keys**, +which generates a Nostr key through the same native create command as the key +setup screen, so the next launch opens as a returning user; then **Linking your key**, which +binds that key to the signed-in account at the same portal (kgoose's binding +challenge, signed natively as a kind-24243 event, then verified — the flow the +hosted-communities card drives), sending nothing more when the account already +holds the key; then **Backing up your key**, which waits for the native +uploader to confirm the backup +([automatic backup](#automatic-backup-and-optional-recovery)). First run never +shows the new key; Settings → Identity can reveal it later. + +**I already have a key** opens **Enter your private key** while setup stays +paused, laid out as the onboarding import screen (centred heading and body, +**Continue with this key** above **Back** at the bottom): a masked private-key +field — +paste or type an `nsec1…` key, +or drop or choose a text file of at most 1 KiB whose first line is one — with +**Reveal private key**, a live *Public identity* npub preview under a valid key +(never hex), and classified messages for anything else: a NIP-49 `ncryptsec1…` +backup is refused as *Password-protected backups aren’t supported here yet* +(the app has no NIP-49 decryption), other text as *That doesn’t look like a +valid nsec*. **Back** returns to Set up your key with the typed key discarded; +**Continue with this key**, enabled only for a valid nsec, stores it through the +same native import command as the key setup screen (`identity_import`, which +takes the nsec as typed) behind *Importing your key…*, shown on the same +screen. A native refusal — an +invalid key, or a key already saved in this slot — appears under the field with +the form kept. An imported key is then linked to the account like a created +one and pauses on **Back up this key?**, since importing never uploads +silently: Buzz can keep an encrypted copy in the account, which Block can use +to restore this key, including anywhere else it is used. **Not now** carries on without a +backup; **Back it up**, the backup consent for that key, runs **Backing up your +key**. An import from **Connect to {host}**, with no Builderlab sign-in, has +no account to link the key to or back it up in, so it skips the link and the +offer and continues at once. + +**Linked** pauses on **Welcome back**: the account is already linked to a Buzz +identity — the key on the user's other device — shown as the full npub under +*Linked public key (npub)* with **Copy**, with the same *Signed in as {email}.* +line under the body. A linked key does not mean a +backed-up one, so only when the lookup lists the linked key does the page also +offer **Restore from backup** (primary), with **Enter your private key** as a +smaller choice; otherwise **Enter your private key** is primary. It opens the +same import page, which here accepts only the linked key: any other valid nsec +is refused under the field with *That isn’t the key linked to this account. It +expects npub1…*, and **Back** returns to Welcome back. **Recover from another +device** is *Coming soon* (device pairing is its future entry). Last in the +stack, **Use a different account** is the same exit as on Set up your key: it +signs the session out and returns to the welcome screen without importing, +restoring or replacing the linked key. The imported +key is already the account's, so nothing is linked. Unless the lookup already +lists it, a successful import then pauses on **Back up this key?** as on a new +account; a listed key continues without the offer. + +**Restore from backup** runs native recovery (see +[optional recovery](#automatic-backup-and-optional-recovery)), which opens its +own browser sign-in: the first-run one is not reused. Above the recovery, the +page centres its heading and, only while it waits for that sign-in or offers +the linked key, *Your key is stored securely on this device.*: an outcome that +restores nothing drops the line, and the unavailable copy below replaces it. Of +the keys it recovers, only the linked one is offered; a recovery without it +says *None of your backups holds the key linked to this account, so nothing was +restored.*, installs nothing and offers **Enter your private key**, which opens +the import page. Installing the key continues as an import does, without the +backup offer: the key is already backed up. Where recovery is unavailable or +refused for the account — no tenant enables it today, and public organizations +are refused by design — the page says *Backup restore isn’t available for this +account* and offers **Enter your private key**, which opens the import +page, never a retry. Any other failure offers **Try recovery again**. **Back** +cancels the recovery and returns to Welcome back. + +On Welcome back, **I lost this key** (quieter) runs **Finding communities**, a real read of the +communities the account owns +(`POST /api/goose/v1/buzz/communities/list`), and shows **Start over with a new +key**: a new key loses everything the old one could reach; *Communities you +own* lists their hostnames (*None* when empty, or that the list could not be +loaded), and memberships in other people's communities cannot be listed and are +lost too. The page also says that starting over unlinks the old key, then +creates a new key, links it to the account and keeps an encrypted copy that +Block can use to restore it, so choosing it is the new key's backup consent. +**Back** returns to Welcome back; **Start over with a new key** (destructive) +runs **Creating keys**, then *Unlinking your old key*, which deletes the old +key's binding (`POST /api/goose/v1/buzz/nostr-identities/delete`), then +**Linking your key** and **Backing up your key** for the new key, with no key +choice between. The old key stays linked until the new one is on the device, +and binding never overwrites, so the unlink comes after creation and before +the link. The unlink checks the bound key first: it sends nothing more when +the account holds no key or the new one, fails when it holds a third key, and +deletes only while it is still the old one, so repeating it never removes the +new key's binding. It +counts as done only when kgoose confirms removing the old key, or the account +then holds no key or the new one. If the old key is still bound (kgoose removes +nothing when the sign-in is no longer active) or another key is, the step fails +without Try again. +Welcome back itself never offers Create my key. + +Every lane ends the same way. A linked account runs **Finding communities** +(the same real read) once its key is on the device; the old key's list is not +read after starting over. A failed step shows **Couldn’t finish setup** with the +error — for a refused link, the hosted-communities card's copy (*This +Builderlab account is connected to another Buzz identity.*, *This Buzz identity +is connected to another Builderlab account.*) or first run's own for kgoose's +other refusals, such as an expired link request or too many attempts; for a +backup, why it did not complete; otherwise the step and HTTP status, as for the +account check. kgoose's own wording is never shown, nor its correlation ID, +which stays on the error and is logged in development builds. Once a key is on +the device, the page also says what is undone: *Your key is saved on this +device, but it isn’t linked to your account or backed up yet.* when +the unlink or link failed (a key that isn't linked can't be backed up), +otherwise *Your key is saved on this device, but it isn’t backed up yet.* A +failure that may clear offers **Try again** (primary), which repeats the failed +step and the rest of the lane, never creating a second key: for the account +check, unlink or link, no answer, too many requests (HTTP 429) or a server +error (HTTP 5xx), and for the link also a link request that expired, was +already used or could not be found, since Try again asks for a new one; for a +backup, Builderlab being unreachable or slow to confirm. A lapsed sign-in is +never retried, since Try again would send the same sign-in. A failed unlink or +link with a key on the device blocks: nothing links the key later, so the page +offers only **Try again**, whatever the failure, and no way past it. A failed +backup also offers **Continue without backup**, which writes nothing and skips +the remaining steps for the end of the lane below (Finding communities on a +linked account, then the restore); a later sign-in runs the backup again. +Before a key exists, the page offers **Back** to the welcome screen instead. + +Within one first-run session the Join page exits only through a join or a +restored community; there is no skip. That holds only while the app stays +open. Quitting with a key on the device but no saved community ends first run: +the next launch restores the key and, with setup idle, lifts the gate straight +into the in-app **Join a community** landing (`CommunityLanding`), the page a +key without a community has always opened on. The key stays on the device, and +the **Key backup** row in Settings → Identity shows the backup state. A link or +backup step still running at quit ends with the process: the native uploader's +pass is retired, and whether the link committed on kgoose is unknown to the +client. Both are safe to run again — the link reads the account's current key +first and does nothing when it already holds this one, and the uploader runs +again when the user next signs in to Builderlab in Settings — but nothing +re-runs the link on its own, and Settings has no row for it; the Key backup row +reports only what the uploader's last pass found. + +Every lane ends with *Restoring your communities…* when the account check +listed destinations saved to the account (`src/features/identity/restore-communities.ts`): +each distinct destination this build can open (the list is deduplicated by +canonical origin, and an address the app cannot open is skipped) gets the +same signed profile read the Join Community page uses to prove admission, at +most three at once, and none started more than 45 s after the step began. A +destination that admits the key is saved to this device through +`communities.joined` under its hostname (its relay profile seeds the empty +local default); one that refuses the key, cannot be reached or will not save +is recorded and otherwise silent — nothing shows it in the app, and the +Builderlab plugin's sync adds it without an admission read after the next +Settings sign-in. The step never fails setup. Where setup rests then depends +on what was admitted: + +- Nothing (the list was empty, unreadable, or every destination was refused or + unreachable): the Join page below, as if the account had saved none. +- At least one, with no address given on the welcome screen: the first + admitted is selected and the app opens on it. +- At least one, and the welcome-screen address is among them: that one is + selected and the app opens on it; nothing else is asked. +- At least one, but the welcome-screen address is not among them (not saved, + refused or unreachable): the first admitted is selected, the app opens on it, + and the typed address — with its invite code, if it came from an invite link — + is handed to the rail's **Add a community** dialog through the same invite + intent a `buzz://join` link uses ([deep links](deep-links.md)), so the join + completes inside the app: discovery, policy, code, profile. A saved + destination that refused the key therefore never shows the first-run **This + community needs an invite** page; the dialog's access step says to ask an + administrator for a code, and the npub is in Settings → Identity. Closing + the dialog leaves the restored community selected. + +Entering the app this way (as after a first-run join) first runs the profile +step below when no profile exists anywhere, and clears the typed address from +the welcome screen's own state, so once the dialog owns it nothing on this +side can look it up again. The startup visit's 15-second deadline runs only +while the app is presenting it, so a first run of any length enters on the +channels page rather than a timed-out destination. Otherwise setup lands on +**Join Community** for the address given on the welcome screen, looked up again +(the policy may have changed meanwhile), or otherwise on **Join a community**, +laid out as the in-app Join a community landing: a centred heading, then, when +the account owns communities, rows under *Communities you own* — icon +placeholder, hostname and **Join** (named *Join {host}* for assistive +technology), which takes exactly the typed-URL path for that host — then a +**Community URL** field with **Continue** beside it. +**Scan a QR code** and **Create a community**, both *Coming soon*, are stacked +at the bottom. An empty or failed list shows the page without rows. No invite +list is shown; nothing serves pending invites yet. In development, +[`BUZZ_DEV_FIRST_RUN_ACCOUNT`](configuration.md#first-run-account-seed-development) +answers the two account reads, and fakes the link, the unlink and the backup, +in place of kgoose and the native uploader. + +The URL field is live. A typed address may omit its scheme (`relay.example.com` +is read as `wss://relay.example.com`); `wss://` and `https://` are accepted in +any case, while `ws://`/`http://`, credentials, paths, queries and fragments are +not (`enteredRelayOrigin` in `src/features/communities/destination.ts`). +**Continue** stays enabled while the field is empty or invalid; Continue or +Enter submits, and an empty or invalid address shows *Please enter a valid +community URL* under the field. Continue only starts the lookup; the join itself +is the Join Community page's **Join**. The field also takes an invite link: the +relay's `https:///invite/` page, or the `buzz://join?relay=…&code=…` +link that page opens (`parsePastedInvite` in +`src/features/communities/invite-link.ts`). The community is looked up at the +link's origin exactly as for a typed address, and nothing is sent with the code +until Join claims it; a link given on the welcome screen keeps its code through +sign-in and setup. + +Join opens **Finding community** (`src/features/communities/FindCommunity.tsx`, +reusable by later flows): the hostname and **Cancel** at once, with a "Looking +up {host}…" status only after one second (the shared `LoadingRegion`, as for +any initial read; Retry starts the second again). **Cancel** returns to Join a +community with the typed address intact — however the user arrived there — and +discards any late answer. The page reads the relay's NIP-11 +document and `/api/join-policy` in parallel through the identity-free native +`relay_http` GET path. Those two unauthenticated GETs are the only pre-join +traffic: nothing is signed with the device's key until the user chooses to join. +On success it shows **Join Community**: only what the relay publishes — the +icon if set and the hostname — plus, when the deployment has a join policy, +*I confirm that I am at least 18 years old.* (if age attestation is required) and +*I agree to this community’s Terms of Service and Privacy Notice.*, whose links +open the relay's `/api/join-policy/terms` and `/privacy` pages outside the app. No +name, description, member count, owner or invite badge is shown: none is knowable +before joining. **Join** sits to the right of **Cancel**, as in every dialog +footer, and is always labelled Join: shown consent boxes gate it, they do not +rename it. Lookup failures are classified, not echoed: an unreachable relay or an +unusable policy answer offers **Cancel** and **Retry** (a missing policy counts as +"none" only once NIP-11 confirmed a Buzz relay); a non-Buzz address or a Buzz +relay with no community bound to that host offers **Cancel** only. + +**Join** is the same join the rail's Add-a-community dialog performs, through +the same owner (`admitCommunity` and `completeJoin` in +`src/features/communities/join.ts`, which save through `communities.joined` in +`src/features/communities/service.ts`; [communities](communities.md)). Before +anything is sent, the join is recorded on this device for the key (the join +journal, `join-journal.ts`), so a reply lost between the relay and the app — +a claim the relay honoured but the app never heard — is not claimed again: a +later Join for a recorded community first sends the signed profile read, and +only when that still denies the key does it claim. A device that cannot keep +the record stops there with the journal's storage message and sends nothing. +When the address came with an invite code, Join +claims it as the dialog does: if the deployment has a join policy, the +policy is accepted for that code with its version and the age box +(`/api/invites/accept-policy`), then the claim (`/api/invites/claim`) carries the +returned receipt; both are NIP-98-signed by the key, and the relay must answer +that the key joined or already was a member. Then, with or without a code, a +signed profile read (`inspectProfile`: NIP-11 again, then a NIP-98-signed +`POST /query` for the key's own kind 0) proves the relay admits this key. The +membership — the canonical origin as id, the hostname as name, the NIP-11 +icon if set — is saved to this device and selected, and the gate lifts into the +app on that community; when **Set up your profile** follows (below), the +membership is saved after that step, as in the dialog, so nothing is joined +while it stands. After a Builderlab sign-in the destination is also saved +to the account straight away (`POST /api/goose/v1/buzz/known-communities/add` +with its `wss://host[:port]` address, same portal and credential as the account +reads), so the user's other devices restore it; the app waits for that answer, +but a failed save never stops it opening. A key imported from **Connect to +{host}** has no account, so nothing is saved there. Restored destinations are +not saved again, and joins in the in-app dialog reach the account through the +sync instead ([known communities](communities.md#known-communities)). While the +join is in flight the page shows *Joining…* with Join and Cancel disabled. The +join itself publishes nothing: no kind 0, and no policy receipt or invite claim +without a code. Without a code the ticked boxes are the client's record of +consent, as on the dialog's code-less path. + +A relay that requires an invite answers the read with 401/403, shown as **This +community needs an invite**; a claim the relay refuses (an expired, used-up or +invalid code, or a policy that changed) lands on the same page with the dialog's +refusal copy under the code field, and no read is sent. The page offers an +**Invite code** field with **Join**, which runs the same policy acceptance (under +the consent the first Join gave), claim and read for the typed code; a refusal +shows under the field and keeps the page and the typed code, and a code the +native route will not send (anything but letters, digits, `.`, `_` and `-`) +reads as not valid. Admins also admit people by npub, so the page shows the +user's public key as the full npub (never hex or an abbreviation) under *Your +public key (npub)* with **Copy** and an inline copied/could-not-copy status, +plus the note that it is their public identity and safe to share. **Try again** +repeats the join without a code, so an admin's add takes effect without +retyping the address; **Back** returns to the find page (the typed address +stays in the URL field behind it). A relay that cannot be reached — for the +claim or the read — shows the lookup's unreachable copy; a device that will +not keep the join's record shows the journal's storage message. Both offer +**Back**. An existing relay profile seeds the empty local default. + +When no profile exists anywhere — the relay held no kind 0 for the key and the +device's local default has no name (read before the join, since `joined` adopts +a relay profile into an empty default) — the gate stays up for **Set up your +profile** (`src/features/identity/ProfileStep.tsx`): the shared profile +fields — avatar, required display name, optional description — prefilled with +the local part of the Builderlab email as the name (blank when the key was +imported without a Builderlab sign-in). The same rule applies after +*Restoring your communities*: if none of the admitted destinations held a +profile and the local default has none, the step is shown for the selected +one, and a handed-off address opens in the dialog only after it, so the +dialog's own profile step starts from the name given here. A relay that +already holds the key's profile, or a local default with a name, skips the +step. **Continue** is the dialog's profile step (`completeJoin`): the draft is +recorded in the join journal, the profile is read afresh, the kind 0 is +published to that one relay through the same signed `relay_sign` + +`POST /events` route Settings → Profile uses when the draft differs from what +the relay holds, and it is read back; only a readback that is current saves the +membership (and the result as the device's local default for later joins) and +enters the app on the community. A rejected publish, or a readback the relay +has since superseded, stays on the step with the error inline and the draft +intact. **Skip for now** saves the membership alone, publishes nothing, and +enters the app; Settings → Profile remains the +editor. Denied/unavailable/corrupt storage is not first run: like the page +shown while the saved identity opens, it is titled **Your Buzz identity**, and +it keeps the **Retry secure storage access** path. + +The key setup screen (`IdentitySetup`) is no longer shown on first launch; the +fixture below still renders it. It offers **Create a new identity** or **Use an +existing key**. These are alternatives. It can also offer **Continue with your +Buzz identity** when old Buzz is installed and **Restore from backup** in builds +with backup recovery configured; both are described below. Import accepts an +nsec, validates its checksum and secp256k1 scalar, and persists that exact key +before adopting its public identity. It never generates a replacement after an +import, read or write failure. One native owner keeps the key in memory after successful restore. The new create-only secure-storage item is service `xyz.block.buzz.desktop.identity` in release @@ -111,6 +511,22 @@ handoff or retry resolves to the number of the pass it starts, or to none, so a caller can wait for that pass's own outcome. A pass superseded by a key install reports nothing; reloading the window mid-pass reports idle. +First run's **Backing up your key** is a pass of this uploader. It saves the +automatic choice for the key, hands the first-run Builderlab sign-in to the +uploader, waits up to 60 seconds for the pass that handoff starts, and then +hands the sign-in back, whatever the outcome; an earlier pass's result never +answers it. A pass that stops to wait for consent, a session or a key binding +fails the step, since nothing would wake it again before first run ends. The +failed page names the reason without any service response. The automatic choice +stays on after a failure, Continue without backup included, so a later +Builderlab sign-in may still finish the backup the user asked for. The step +hands a sign-in over only when this build's uploader can back up to Builderlab +and the user signed in at the build's own portal: a tenant's sign-in is never +handed to the uploader. Anywhere else, which today is every tenant sign-in and +every build without backup configured, the step fails at once as *Key backup +isn’t available in this version of Buzz.* without saving the choice, and the +user continues without backup. + When secure storage confirms absence, native recovery commands support explicit browser sign-in. The session and recovered keys remain native; the renderer sees only validated public identities and attempt-scoped handles. Recovery prefers the @@ -151,7 +567,16 @@ if the community profile cannot load. The full npub and hex public key can be copied. The nsec is absent from the rendered field until Reveal; Copy can fetch it without revealing it. Hide, leaving Identity, window blur and document hiding retire pending reveals and clear the displayed secret. Failed private-key actions report fixed errors. Copy deliberately leaves -the key on the OS clipboard; the UI warns about that. +the key on the OS clipboard; the UI warns about that. The one exception is the +first-run **Enter your private key** page: the typed, pasted or file-read nsec +lives only in that page's state, masked until **Reveal private key**, and +crosses IPC once, on **Continue with this key**, through `identity_import`; leaving the page +discards it, and nothing logs or persists it. On the returning-user lane the +page compares the typed key's derived npub with the account's linked npub in +the page, before IPC, so a mismatched nsec never reaches native import. The +**Welcome back** page shows the linked public key and the signed-in email; the first-run +Builderlab session credential is held by the OAuth session store and sent only +in the two account reads' `X-BB-Session-Credential` header, never rendered. The app UI passes a private string through IPC only during explicit import/export interaction. JavaScript/IPC string memory is not guaranteed zeroized. Native @@ -358,10 +783,12 @@ The native identity service exposes `getBackupChoice()` and `setBackupChoice(pubkey, automatic)`. These APIs expose public metadata and do not change identity readiness. Reading a choice does not contact a server; saving automatic backup wakes the existing native uploader if a session is connected. -No onboarding -or settings selection is added here. A future selection must disclose that Block -can recover the key, including an imported key used elsewhere, before saving -`automatic: true`. Account sign-in and key creation/import do not imply consent. +First run's selections are **Create my key**, **Start over with a new key** and, +for an imported key, **Back it up**: each page discloses that Block can restore +the key (an imported one including anywhere else it is used) before **Backing +up your key** saves `automatic: true`. Account sign-in, the key setup screen, +an import without Builderlab sign-in and **Not now** do not imply consent, and Settings has no +selection. The choice is bound to the current public key and trusted build-configured backup service and namespace. Missing or mismatched metadata returns no choice; @@ -498,7 +925,8 @@ Use the existing environment-free fixture Vite config: bin/pnpm exec vite --config tests/fixtures/agent-control.vite.mjs --port 1547 ``` -Open `/tests/fixtures/identity.html`. It uses mock IPC and an in-memory public +Open `/tests/fixtures/identity.html`. It renders the key import/create screen +(not the first-launch welcome) with mock IPC and an in-memory public fixture key, not an OS credential store or a relay. Import accepts only the displayed fixture key. Reset and simulated restart affect fixture state only. **Never enter a real nsec.** Browser exercises prove UI behavior, not secure native persistence. diff --git a/src/app/App.tsx b/src/app/App.tsx index af8d6b24f1..e4be0c8a83 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,6 +1,6 @@ // FOUNDATION: Startup, navigation, contributed pages, and built-in Settings. import { AgentAvatarArtworkProvider } from "../features/agents/AgentAvatarArtworkProvider"; -import { IdentitySetup } from "../features/identity/IdentitySetup"; +import { Welcome } from "../features/identity/Welcome"; import { NewMenu } from "./shell/NewMenu"; import { PrimarySidebar } from "./shell/PrimarySidebar"; import { ChannelNavigationProvider } from "../features/channel-navigation/ChannelNavigationState"; @@ -69,12 +69,16 @@ export function App({ services }: { services: AppServices }) { > {services.identity ? ( - + {!launching && ( )} - + ) : ( )} diff --git a/src/app/first-run-join-handoff.test.tsx b/src/app/first-run-join-handoff.test.tsx new file mode 100644 index 0000000000..64781a8151 --- /dev/null +++ b/src/app/first-run-join-handoff.test.tsx @@ -0,0 +1,445 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { + act, + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { invoke, isTauri } from "@tauri-apps/api/core"; +import type { EventTemplate } from "nostr-tools"; +import { App } from "./App"; +import { createServices, type AppServices } from "./services"; +import type { JoinPolicy } from "../features/communities/join-policy"; +import type { RelayEvent } from "../features/relay/events"; +import { keypair, profile, signed } from "../features/relay/testing"; + +// A first run whose Builderlab account saved one community and whose landing +// named another: the real gate lifts on the restored community and the rail's +// Add-a-community dialog takes the typed address over. Setup outlasts the +// startup visit's 15 s deadline, as a real sign-in does; that deadline runs only +// once the gate lifts and a presenter exists, so the app opens without a notice. +vi.mock("@tauri-apps/api/core", async (importOriginal) => ({ + ...(await importOriginal()), + isTauri: vi.fn(() => true), + invoke: vi.fn(), + Channel: class { + id = 1; + onmessage = () => {}; + }, +})); +vi.mock("../bundled", async () => ({ + bundledPlugins: [ + { + manifest: { id: "buzz.channels", name: "Channels", apiVersion: 1 }, + module: await import("../bundled/channels"), + }, + ], +})); +// The browser sign-in, which the seeded account below never sends anywhere. +vi.mock("../bundled/builderlab/oauth/first-run", () => ({ + bundledHost: { request: vi.fn() }, + firstRunCredential: vi.fn(async () => ({ + value: "private-token", + account: { + subject: "user", + email: "a@example.com", + canUseRemoteAgents: false, + }, + })), +})); +// jsdom has no IndexedDB. +vi.mock("../features/relay/outbox-storage", () => ({ + browserOutboxStorage: () => ({ load: () => [], save: () => {} }), +})); + +const member = keypair(); +const relayA = "https://relay-a.example"; +const relayB = "https://relay-b.example"; +type Relay = { + self: string; + profile?: RelayEvent; + /** Whether a signed read admits the key; an accepted claim admits it. */ + admitted: boolean; + policy?: JoinPolicy; + /** The relay's error code for every claim; unset, a claim admits the key. */ + refusal?: string; +}; +/** Both relays admit the key; only relay-a holds its profile. */ +let relays: Record; +/** Every `relay_http` request in order, its JSON body parsed (`null` for a GET). */ +let requests: Array<{ community: string; path: string; body: unknown }>; +let services: AppServices | undefined; +beforeEach(() => { + relays = { + [relayA]: { + self: keypair().pubkey, + profile: profile(member, { name: "Ada", picture: "" }), + admitted: true, + }, + [relayB]: { self: keypair().pubkey, admitted: true }, + }; + requests = []; + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.stubEnv("VITE_BUZZ_LIVE", "0"); + vi.stubEnv("VITE_BUZZ_OPEN_RELAY", ""); + vi.stubEnv( + "VITE_BUZZ_FIRST_RUN_ACCOUNT", + JSON.stringify({ known: ["relay-a.example"] }), + ); + vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + vi.stubGlobal("matchMedia", (media: string) => ({ + media, + matches: false, + addEventListener() {}, + removeEventListener() {}, + })); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.stubGlobal( + "WebSocket", + class { + close() {} + }, + ); + vi.mocked(isTauri).mockReturnValue(true); + let created = false; + vi.mocked(invoke).mockImplementation(async (command, args) => { + if (command === "identity_restore") return created ? member.pubkey : null; + if (command === "identity_create") { + created = true; + return member.pubkey; + } + if (command === "get_os_idle_seconds") return 0; + if (command === "deep_link_take") return []; + if (command === "deep_link_watch") return null; + if (command === "relay_sign") + return signed(member, (args as { event: EventTemplate }).event); + if (command === "relay_http") return relayAnswer(args); + if (command === "plugin_catalog") + return { + status: "ready", + externalPluginsPaused: false, + catalog: { + profile: "fixture", + location: "fixture", + plugins: [ + { + manifest: { + id: "buzz.channels", + name: "Channels", + apiVersion: 1, + }, + source: "bundled", + revision: "bundled", + enabled: true, + previous: null, + reloadable: false, + error: null, + }, + ], + }, + }; + // Other native owners may initialize, but no actual native operations run. + throw new Error(`Fixture native capability unavailable: ${command}`); + }); +}); +afterEach(async () => { + cleanup(); + await services?.dispose(); + services = undefined; + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + localStorage.clear(); + window.history.replaceState(null, "", "/"); +}); + +/** + * Each relay answers discovery, its join policy, invite acceptance and claims, + * its kind-0 reads and a kind-0 publish; nothing else is held. + */ +function relayAnswer(args: unknown) { + const request = args as { + community: string; + path: string; + body: string | null; + }; + const body: unknown = request.body === null ? null : JSON.parse(request.body); + requests.push({ community: request.community, path: request.path, body }); + const answer = (status: number, value?: unknown) => ({ + status, + headers: {}, + body: value === undefined ? "" : JSON.stringify(value), + }); + const relay = relays[request.community]; + if (!relay) return answer(404); + if (request.path === "/") + return answer(200, { + name: "Buzz Relay", + self: relay.self, + icon: "https://cdn.example.com/icon.png", + }); + if (request.path === "/api/join-policy") + return relay.policy ? answer(200, { policy: relay.policy }) : answer(404); + if (request.path === "/api/invites/accept-policy") + return answer(200, { receipt: "policy-receipt" }); + if (request.path === "/api/invites/claim") { + if (relay.refusal) return answer(403, { error: relay.refusal }); + relay.admitted = true; + return answer(200, { status: "joined" }); + } + if (request.path === "/query") { + if (!relay.admitted) return answer(403, { error: "membership required" }); + const filters = body as Array<{ kinds?: number[] }>; + const reads = filters.some((filter) => filter.kinds?.includes(0)); + return answer(200, reads && relay.profile ? [relay.profile] : []); + } + if (request.path === "/events") { + const event = body as RelayEvent; + if (event.kind === 0) relay.profile = event; + return answer(200, { accepted: true, event_id: event.id }); + } + return answer(404); +} + +const notice = () => screen.queryByText("This destination couldn’t open"); +const joinDialog = () => + screen.queryByRole("dialog", { name: "Join a community" }); +/** Landing address → Connect from the choice → Create my key → the restore lifts the gate. */ +async function firstRunWithAddress( + user: ReturnType, + address = "relay-b.example", + code = "", +) { + const current = createServices(); + services = current; + render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000); + }); + await user.type( + await screen.findByRole("textbox", { name: "Community URL" }), + `${address}{Enter}`, + ); + await user.click( + await screen.findByRole("button", { name: "Sign in with email" }), + ); + await user.click( + await screen.findByRole("button", { name: "Create my key" }), + ); + const dialog = await screen.findByRole("dialog", { + name: "Join a community", + }); + // Entered on the restored community with the typed address prefilled; the + // startup visit presents normally beneath the dialog, no notice. + await waitFor(() => + expect(current.navigation.snapshot().status).toBe("opened"), + ); + expect(notice()).toBeNull(); + expect(current.communities.snapshot()).toMatchObject({ + memberships: [{ id: relayA }], + selected: relayA, + }); + expect(within(dialog).getByLabelText("Community URL")).toHaveValue(relayB); + expect(current.invites.snapshot()).toMatchObject({ + community: relayB, + code, + viewer: member.pubkey, + }); + // First run itself sends nothing but unauthenticated lookups to relay-b. + expect( + requests.filter((r) => r.community === relayB).map((r) => r.path), + ).toEqual(["/", "/api/join-policy"]); + return { current, dialog }; +} + +it("hands a typed address the restore did not admit to the Add-a-community dialog, which joins and opens it", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const { current, dialog } = await firstRunWithAddress(user); + await user.click(within(dialog).getByRole("button", { name: "Continue" })); + await user.click( + await within(dialog).findByRole("button", { name: "Continue" }), + ); + // The profile starts from the local default the restore adopted. + expect( + await within(dialog).findByRole("textbox", { name: "Display name" }), + ).toHaveValue("Ada"); + await user.click( + within(dialog).getByRole("button", { name: "Publish profile & open" }), + ); + await waitFor(() => + expect(current.communities.snapshot()).toMatchObject({ + memberships: [{ id: relayA }, { id: relayB }], + selected: relayB, + }), + ); + expect(current.invites.snapshot()).toBeUndefined(); + expect(joinDialog()).toBeNull(); + // Opening the joined community is a visit of its own, presented at once. + await waitFor(() => + expect(current.navigation.snapshot().status).toBe("opened"), + ); + expect(notice()).toBeNull(); + expect(current.navigation.snapshot().entry.target).toMatchObject({ + kind: "page", + pluginId: "buzz.channels", + pageId: "channels", + }); +}); + +it("closing the handed-off dialog clears the intent and stays on the restored community", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const { current, dialog } = await firstRunWithAddress(user); + await user.click(within(dialog).getByRole("button", { name: "Back" })); + await waitFor(() => expect(joinDialog()).toBeNull()); + expect(current.invites.snapshot()).toBeUndefined(); + expect(current.communities.snapshot()).toMatchObject({ + memberships: [{ id: relayA }], + selected: relayA, + }); + // The startup visit stays presented: the channels page, no notice to retry. + expect(current.navigation.snapshot()).toMatchObject({ + status: "opened", + entry: { + target: { kind: "page", pluginId: "buzz.channels", pageId: "channels" }, + }, + }); + expect(notice()).toBeNull(); + expect(screen.queryByRole("button", { name: "Retry navigation" })).toBeNull(); +}); + +const code = "b-Invite_2.x"; +const policy: JoinPolicy = { + version: "2026-10", + age_attestation_required: true, + terms_markdown: "# Terms", +}; +const httpsInvite = `${relayB}/invite/${code}`; +const appInvite = `buzz://join?relay=${encodeURIComponent("wss://relay-b.example")}&code=${code}`; +const inviteRequests = () => + requests.filter( + (r) => r.community === relayB && r.path.startsWith("/api/invites/"), + ); +/** Continue past the prefilled address to the access step, which holds the code. */ +async function accessStep( + user: ReturnType, + dialog: HTMLElement, +) { + await user.click(within(dialog).getByRole("button", { name: "Continue" })); + const field = await within(dialog).findByLabelText( + "Invite code (if required)", + ); + expect(field).toHaveValue(code); + // Nothing is claimed until the user continues from this step. + expect(inviteRequests()).toEqual([]); +} + +it.each([ + { form: "relay invite page", address: httpsInvite, published: policy }, + { form: "buzz://join link", address: appInvite, published: undefined }, +])( + "hands a $form's code to the dialog, which claims it and joins", + async ({ address, published }) => { + relays[relayB] = { + self: keypair().pubkey, + admitted: false, + ...(published ? { policy: published } : {}), + }; + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const { current, dialog } = await firstRunWithAddress(user, address, code); + await accessStep(user, dialog); + if (published) { + await user.click( + within(dialog).getByRole("checkbox", { + name: "I agree to this community’s Terms of Service and Privacy Notice.", + }), + ); + await user.click( + within(dialog).getByRole("checkbox", { + name: "I confirm that I am at least 18 years old.", + }), + ); + } + await user.click(within(dialog).getByRole("button", { name: "Continue" })); + expect( + await within(dialog).findByRole("textbox", { name: "Display name" }), + ).toHaveValue("Ada"); + expect(inviteRequests()).toEqual([ + ...(published + ? [ + { + community: relayB, + path: "/api/invites/accept-policy", + body: { + code, + policy_version: published.version, + age_confirmed: true, + }, + }, + ] + : []), + { + community: relayB, + path: "/api/invites/claim", + body: published ? { code, policy_receipt: "policy-receipt" } : { code }, + }, + ]); + await user.click( + within(dialog).getByRole("button", { name: "Publish profile & open" }), + ); + await waitFor(() => + expect(current.communities.snapshot()).toMatchObject({ + memberships: [{ id: relayA }, { id: relayB }], + selected: relayB, + }), + ); + expect(current.invites.snapshot()).toBeUndefined(); + expect(joinDialog()).toBeNull(); + }, +); + +it("a refused handed-off code keeps the dialog open with the refusal and stays on the restored community", async () => { + relays[relayB] = { + self: keypair().pubkey, + admitted: false, + refusal: "invite_expired", + }; + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const { current, dialog } = await firstRunWithAddress( + user, + httpsInvite, + code, + ); + await accessStep(user, dialog); + await user.click(within(dialog).getByRole("button", { name: "Continue" })); + expect(await within(dialog).findByRole("alert")).toHaveTextContent( + "This invite code has expired — ask for a new one.", + ); + expect(inviteRequests()).toEqual([ + { community: relayB, path: "/api/invites/claim", body: { code } }, + ]); + // The refusal ends the attempt: no read follows the claim. + expect(requests.filter((r) => r.community === relayB).at(-1)?.path).toBe( + "/api/invites/claim", + ); + expect(joinDialog()).toBe(dialog); + expect( + within(dialog).getByLabelText("Invite code (if required)"), + ).toHaveValue(code); + expect(current.communities.snapshot()).toMatchObject({ + memberships: [{ id: relayA }], + selected: relayA, + }); +}); diff --git a/src/app/first-run-navigation.test.tsx b/src/app/first-run-navigation.test.tsx new file mode 100644 index 0000000000..91e73742d6 --- /dev/null +++ b/src/app/first-run-navigation.test.tsx @@ -0,0 +1,234 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { invoke, isTauri } from "@tauri-apps/api/core"; +import type { EventTemplate } from "nostr-tools"; +import { App } from "./App"; +import { createServices, type AppServices } from "./services"; +import { keypair, signed } from "../features/relay/testing"; +import { communityDestination } from "../features/communities/destination"; + +// The startup `home` visit is created inside createServices(), before React +// renders. On a native first run `Welcome` hides `ConnectedApp`, the only +// presenter, for far longer than the visit deadline; that deadline runs only +// while a presenter is attached, so the gate lifting presents the visit as-is. +// The key the gate waits for already holds one community on this device, as +// every setup lane leaves it (joined, restored or handed off), so the channels +// page is what presents. +vi.mock("@tauri-apps/api/core", async (importOriginal) => ({ + ...(await importOriginal()), + isTauri: vi.fn(() => true), + invoke: vi.fn(), + Channel: class { + id = 1; + onmessage = () => {}; + }, +})); +vi.mock("../bundled", async () => ({ + bundledPlugins: [ + { + manifest: { id: "buzz.channels", name: "Channels", apiVersion: 1 }, + module: await import("../bundled/channels"), + }, + ], +})); +const catalogPlugin = (id: string, name: string) => ({ + manifest: { id, name, apiVersion: 1 }, + source: "bundled", + revision: "bundled", + enabled: true, + previous: null, + reloadable: false, + error: null, +}); +const member = keypair(); +const relayA = "https://relay-a.example"; +// Its channel list never arrives: a presenter that waits on it never finishes. +const relayB = "https://relay-b.example"; +const relaySelf: Record = { + [relayA]: keypair().pubkey, + [relayB]: keypair().pubkey, +}; +let services: AppServices | undefined; +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.stubEnv("VITE_BUZZ_LIVE", "0"); + vi.stubEnv("VITE_BUZZ_OPEN_RELAY", ""); + vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + vi.stubGlobal("matchMedia", (media: string) => ({ + media, + matches: false, + addEventListener() {}, + removeEventListener() {}, + })); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.stubGlobal( + "WebSocket", + class { + close() {} + }, + ); + vi.mocked(isTauri).mockReturnValue(true); + localStorage.setItem( + `buzz-client.v1:${member.pubkey}`, + JSON.stringify({ + profile: { name: "Ada", picture: "", about: "" }, + memberships: [ + { id: relayA, name: "relay-a.example" }, + { id: relayB, name: "relay-b.example" }, + ], + selected: relayA, + }), + ); + let created = false; + vi.mocked(invoke).mockImplementation(async (command, args) => { + if (command === "identity_restore") return created ? member.pubkey : null; + if (command === "identity_create") { + created = true; + return member.pubkey; + } + if (command === "get_os_idle_seconds") return 0; + if (command === "relay_sign") + return signed(member, (args as { event: EventTemplate }).event); + if (command === "relay_http") return relayAnswer(args); + if (command === "deep_link_take") return []; + if (command === "deep_link_watch") return null; + if (command === "plugin_catalog") + return { + status: "ready", + externalPluginsPaused: false, + catalog: { + profile: "fixture", + location: "fixture", + plugins: [catalogPlugin("buzz.channels", "Channels")], + }, + }; + // Other native owners may initialize, but no actual native operations run. + throw new Error(`Fixture native capability unavailable: ${command}`); + }); +}); +afterEach(async () => { + cleanup(); + await services?.dispose(); + services = undefined; + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + localStorage.clear(); + window.history.replaceState(null, "", "/"); +}); + +/** Both relays answer discovery; only relay-a answers reads, with nothing in them. */ +function relayAnswer(args: unknown) { + const request = args as { community: string; path: string }; + const answer = (status: number, value?: unknown) => ({ + status, + headers: {}, + body: value === undefined ? "" : JSON.stringify(value), + }); + const self = relaySelf[request.community]; + if (!self) return answer(404); + if (request.path === "/") return answer(200, { name: "Buzz Relay", self }); + if (request.path === "/query") { + if (request.community === relayB) return new Promise(() => {}); + return answer(200, []); + } + return answer(404); +} + +const notice = () => screen.queryByText("This destination couldn’t open"); +const signIn = () => + screen.queryByRole("button", { name: "Sign in with email" }); +async function firstRun() { + const current = createServices(); + services = current; + render(); + await waitFor(() => expect(signIn()).toBeVisible()); + expect(current.navigation.snapshot()).toMatchObject({ + status: "opening", + entry: { target: { kind: "home" } }, + }); + return current; +} +/** A key lands in the credential store, as every setup lane ends; the gate lifts. */ +async function openGate(current: AppServices) { + await act(async () => { + await current.identity?.create(); + }); + await waitFor(() => + expect(current.navigation.snapshot().status).toBe("opened"), + ); + expect(notice()).not.toBeInTheDocument(); + expect(signIn()).not.toBeInTheDocument(); +} + +it("presents the startup visit as-is however long the first-run gate stays up", async () => { + const current = await firstRun(); + const { attempt } = current.navigation.snapshot(); + // Sign-in, setup, join and profile outlast the 15 s deadline many times over. + await act(async () => { + await vi.advanceTimersByTimeAsync(120_000); + }); + expect(current.navigation.snapshot()).toMatchObject({ + status: "opening", + attempt, + }); + expect(notice()).not.toBeInTheDocument(); + await openGate(current); + // The same attempt completed on the channels page; nothing was retried. + expect(current.navigation.snapshot().attempt.id).toBe(attempt.id); + expect(current.navigation.snapshot().entry.target).toMatchObject({ + kind: "page", + pluginId: "buzz.channels", + pageId: "channels", + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(20_000); + }); + expect(current.navigation.snapshot().status).toBe("opened"); + expect(notice()).not.toBeInTheDocument(); +}); + +it("still times out a visit the mounted presenter never finishes", async () => { + const current = await firstRun(); + await openGate(current); + const pending = current.navigation.open({ + version: 1, + kind: "page", + pluginId: "buzz.channels", + pageId: "channels", + scope: { + viewer: member.pubkey, + communityOrigin: communityDestination(relayB).url, + }, + }); + // Real time also advances the fake clock here, so leave the boundary a margin. + await act(async () => { + await vi.advanceTimersByTimeAsync(14_000); + }); + expect(current.navigation.snapshot().status).toBe("opening"); + expect(current.communities.snapshot().selected).toBe(relayB); + expect(notice()).not.toBeInTheDocument(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + await expect(pending).resolves.toEqual({ + status: "failed", + reason: "timeout", + }); + expect( + await screen.findByText("This destination couldn’t open"), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Retry navigation" }), + ).toBeVisible(); +}); diff --git a/src/features/identity/IdentitySetup.tsx b/src/features/identity/IdentitySetup.tsx index 9ee79d85e3..a3c790a2fb 100644 --- a/src/features/identity/IdentitySetup.tsx +++ b/src/features/identity/IdentitySetup.tsx @@ -20,6 +20,7 @@ import styles from "./IdentitySetup.module.css"; import { OnboardingWordmark } from "./OnboardingWordmark"; import { OnboardingScreen } from "./OnboardingScreen"; +/** Key import/create gate; the app's first launch now uses Welcome instead. */ export function IdentitySetup({ identity, children, diff --git a/src/features/identity/Welcome.tsx b/src/features/identity/Welcome.tsx new file mode 100644 index 0000000000..8ae2ab5cdc --- /dev/null +++ b/src/features/identity/Welcome.tsx @@ -0,0 +1,940 @@ +import { + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; +import { npubEncode } from "nostr-tools/nip19"; +import type { Credential } from "../../bundled/builderlab/oauth/browser"; +import { + bundledHost, + firstRunCredential, +} from "../../bundled/builderlab/oauth/first-run"; +import { createOAuthSession } from "../../bundled/builderlab/oauth/session"; +import { communityDestination } from "../communities/destination"; +import { FindCommunity, type JoinConsent } from "../communities/FindCommunity"; +import type { FoundCommunity } from "../communities/find-community"; +import type { createInviteIntent } from "../communities/invite-intent"; +import { + type Admission, + admitCommunity, + CLAIM_REFUSALS, + completeJoin, + type Current, +} from "../communities/join"; +import { createJoinJournal, JournalError } from "../communities/join-journal"; +import type { Communities, PersonalProfile } from "../communities/service"; +import { readErrorKind } from "../relay/errors"; +import { Button } from "../../shared/design-system/ui/Button"; +import { BackupOffer } from "./BackupOffer"; +import { + builderlabAccount, + type FirstRunAccount, + type OwnedCommunity, + seededAccount, +} from "./builderlab-account"; +import { + type BuilderlabTenant, + configuredBuilderlabTenants, + discoverAuthentication, +} from "./builderlab-tenants"; +import { CommunityUrlField } from "./CommunityUrlField"; +import { ConnectChoice } from "./ConnectChoice"; +import { InviteCodeField } from "./InviteCodeField"; +import styles from "./IdentitySetup.module.css"; +import { parseFirstRunAccountSeed } from "./first-run-seed"; +import { JoinCommunity } from "./JoinCommunity"; +import { KeyChoice } from "./KeyChoice"; +import { KeyImport } from "./KeyImport"; +import { KeyLost } from "./KeyLost"; +import { KeyRestore } from "./KeyRestore"; +import { OnboardingScreen } from "./OnboardingScreen"; +import { OnboardingWordmark } from "./OnboardingWordmark"; +import { ProfileStep } from "./ProfileStep"; +import { PublicKeyPanel } from "./PublicKeyPanel"; +import type { Restored } from "./restore-communities"; +import { type Identity, nativeIdentityEnabled } from "./service"; +import { + backUp, + checkAccount, + checkBackup, + createKey, + createSetup, + findCommunities, + importKey, + linkKey, + type Resting, + restoreCommunities, + type SetupPause, + unlinkKey, +} from "./setup"; +import { SetupProgress } from "./SetupProgress"; +import { WelcomeBack } from "./WelcomeBack"; +import { WireframeScreen } from "./Wireframe"; + +/** + * The signed-in account: the development seed when a dev server set one + * (production builds define it empty), else the tenant's kgoose through the + * same bundled grant the sign-in used, and the identity's native uploader. + */ +const firstRunAccount = ( + credential: Credential, + origin: string | undefined, + identity: Identity, +) => { + const seed = parseFirstRunAccountSeed( + import.meta.env.VITE_BUZZ_FIRST_RUN_ACCOUNT, + ); + return seed + ? seededAccount(seed, identity) + : builderlabAccount(credential, origin, bundledHost.request, identity); +}; + +/** + * First-launch gate: renders children once a human key exists on this device, + * except while first-run setup is in progress, which creates that key midway. + */ +export function Welcome({ + identity, + communities, + invites, + children, + connect = firstRunCredential, + account = firstRunAccount, + tenants = configuredBuilderlabTenants, +}: { + identity: Identity; + /** The live startup service: joining through it is what selects the community. */ + communities: Pick; + /** + * The app's invite intent: a typed address the restore did not admit is + * handed to the rail's Add-a-community dialog as the gate lifts. + */ + invites: Pick, "open">; + children: ReactNode; + /** `origin` is the tenant portal a community URL selected; the build default otherwise. */ + connect?: (signal: AbortSignal, origin?: string) => Promise; + /** The account for a credential `connect` returned, at the same origin. */ + account?: + | (( + credential: Credential, + origin: string | undefined, + identity: Identity, + ) => FirstRunAccount) + | undefined; + tenants?: readonly BuilderlabTenant[]; +}) { + const state = useSyncExternalStore(identity.subscribe, identity.snapshot); + // Set by discovery before each sign-in; the session store never sees a URL. + const loginOrigin = useRef(undefined); + const [login] = useState(() => + createOAuthSession((signal) => connect(signal, loginOrigin.current)), + ); + const auth = useSyncExternalStore(login.subscribe, login.snapshot); + const [setup] = useState(createSetup); + const progress = useSyncExternalStore(setup.subscribe, setup.snapshot); + // The signed-in account's reads, from sign-in until the landing is shown again. + const reads = useRef(undefined); + // The hex key the account check found bound to the account: the returning- + // user lane. Cleared by Start over and on the landing. + const [linked, setLinked] = useState(); + // The communities that key owns; `undefined` until found, or when the list failed. + const [owned, setOwned] = useState(); + // The destinations saved to the account, read with the account check and + // restored as every lane ends; `undefined` until read, or when the list + // failed. The account's, not a key's, so it lives as long as `reads`: Start + // over keeps it and the landing clears it. + const known = useRef(undefined); + // The keys the account holds a backup of; `undefined` until listed, or when + // backup status is unavailable or failed. Cleared on the landing. + const [backups, setBackups] = useState(); + // The typed address survives a round trip to the find page and back. + const [entered, setEntered] = useState(""); + // The canonical origin the user asked to join: before sign-in it drives the + // discovery lookup; after setup, the Join Community page. Cleared on every + // return to the landing or choices page. + const [finding, setFinding] = useState(); + // The code of the invite link `finding` came from, claimed on Join; "" for + // a bare address. Every submit sets it with `finding`. + const [inviteCode, setInviteCode] = useState(""); + const [joinError, setJoinError] = useState<{ + title: string; + error: string; + /** Set when the relay refused the key: the page takes a code and offers Try again. */ + denied?: Denied; + /** Why the relay refused the code the join claimed. */ + codeError?: string | undefined; + }>(); + const [retrying, setRetrying] = useState<"read" | "code">(); + // Set for a user with no profile yet once the relay admits the key; the + // gate lifts when it exits. + const [profileStep, setProfileStep] = useState(); + const viewer = state.status === "ready" ? state.viewer : undefined; + // The join owner's recovery record, as the Add-a-community dialog keeps it: + // a relaunch resumes an unfinished join from there, through that dialog. + const journal = useMemo( + () => + nativeIdentityEnabled() && viewer ? createJoinJournal(viewer) : undefined, + [viewer], + ); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + /** The owner's fence: still shown, and `entry` still the journal's record. */ + const current: Current = (entry) => + mounted.current && (!entry || !journal || journal.current(entry)); + // The typed address the restore entered the app without; opened in the + // join dialog, with its invite code, once the gate lifts. + const handoff = useRef(undefined); + useEffect( + () => () => { + if (login.snapshot().status === "pending") login.cancel(); + }, + [login], + ); + const discovering = finding && auth.status === "signed-out"; + /** + * The page rendered below, keyed in the order the JSX decides it, so a + * change of key is a page replacing another; `undefined` while nothing of + * the user's is shown — the gate lifted, or the key still being restored — + * so launch never moves focus. + */ + const page = + state.status === "loading" + ? undefined + : state.status === "error" + ? "error" + : progress.status === "idle" + ? state.status === "ready" + ? undefined + : auth.error + ? "auth-error" + : discovering + ? "discover" + : "landing" + : progress.status === "done" + ? profileStep + ? "done:profile" + : joinError + ? `done:join-error:${joinError.title}` + : finding + ? "done:find" + : "done:join" + : progress.status === "paused" + ? `paused:${progress.at}` + : progress.status; + // A page replacing another takes focus with it: Back returns it to the + // choice that opened the page it leaves (`data-return-focus`); otherwise + // the new heading has it — unless the page focused a field itself, which + // this passive effect runs after, as the app's own fill-in does. + const shown = useRef(undefined); + const returnFocus = useRef(undefined); + useEffect(() => { + if ( + shown.current !== undefined && + page !== undefined && + shown.current !== page + ) { + const trigger = returnFocus.current + ? document.querySelector( + `[data-return-focus="${returnFocus.current}"]`, + ) + : null; + if (trigger) trigger.focus(); + else if (document.activeElement === document.body) + document + .getElementById("welcome-title") + ?.focus({ preventScroll: true }); + } + returnFocus.current = undefined; + shown.current = page; + }, [page]); + if (state.status === "ready" && progress.status === "idle") return children; + // The linked key is backed up: Welcome back offers a restore, and an + // import of it skips the backup offer. + const backedUp = !!linked && !!backups?.includes(linked); + /** + * Sign-in leads to the account check, which picks the lane: a linked key + * pauses on Welcome back, otherwise the key choice. The backed-up-key + * lookup runs on both. Each choice below resumes setup with its own steps. + */ + const signIn = async (origin?: string) => { + loginOrigin.current = origin; + await login.signIn(); + if (login.snapshot().status !== "signed-in") return; + const current = account(login.credential(), origin, identity); + reads.current = current; + void setup.start( + [ + checkAccount(current, setLinked, (list) => { + known.current = list; + }), + checkBackup(current, setBackups), + ], + { status: "paused", at: "key-choice" }, + ); + }; + /** Between pauses nothing runs: no key exists until a choice resumes setup. */ + const pause = (at: SetupPause) => + void setup.start([], { status: "paused", at }); + /** A page's Back: the choice that opened it, marked `data-return-focus`, takes focus again. */ + const back = (to: SetupPause, trigger: "import" | "restore" | "lost") => { + returnFocus.current = trigger; + pause(to); + }; + /** + * The lifted gate takes over: a pending handoff opens the join dialog for + * the typed address, and nothing kept here can show discovery for it again. + * The reset is ignored while the restore runs; it rests idle instead. + */ + const enterApp = () => { + const current = identity.snapshot(); + if (handoff.current && current.status === "ready") + invites.open( + { community: handoff.current, code: inviteCode }, + current.viewer, + ); + handoff.current = undefined; + setFinding(undefined); + setInviteCode(""); + setEntered(""); + setup.reset(); + }; + /** + * Where the restore rests. With nothing admitted, setup ends on the Join + * page as before. Otherwise it selects the typed address when that was + * admitted, else the first admitted, and enters the app (after the profile + * step when no profile exists yet), handing a typed address it did not + * admit to the join dialog. + */ + const decide = (outcomes: readonly Restored[]): Resting | undefined => { + const admitted = outcomes.filter((outcome) => outcome.kind === "admitted"); + const typed = finding ? communityDestination(finding).id : undefined; + const chosen = + admitted.find((outcome) => outcome.id === typed) ?? admitted[0]; + if (!chosen?.read) return undefined; + communities.select(chosen.id); + handoff.current = chosen.id === typed ? undefined : finding; + // `joined` adopts a relay profile only into an empty local default, so + // with no relay profile read the local one is unchanged by the restore. + if ( + !admitted.some((outcome) => outcome.read?.exists) && + !communities.snapshot().profile.name + ) { + setProfileStep({ found: chosen, read: chosen.read }); + return undefined; + } + enterApp(); + return { status: "idle" }; + }; + /** Restores the account's saved destinations, when it has any. */ + const restore = () => + known.current?.length + ? [restoreCommunities(known.current, communities, decide)] + : []; + /** How every lane ends: a linked account's owned communities feed Join a community. */ + const finish = () => [ + ...(linked && reads.current + ? [findCommunities(reads.current, setOwned)] + : []), + ...restore(), + ]; + /** A submitted address or invite link: what to look up, and the code to claim. */ + const find = (origin: string, code = "") => { + setFinding(origin); + setInviteCode(code); + }; + /** Every way back to the landing, so a kept `finding` cannot re-enter discovery. */ + const backToLanding = () => { + setFinding(undefined); + setInviteCode(""); + setLinked(undefined); + setOwned(undefined); + setBackups(undefined); + reads.current = undefined; + known.current = undefined; + setup.reset(); + login.cancel(); + }; + /** + * The Add-a-community dialog's completion for the community the step + * holds (`src/features/communities/join.ts`): with `publish`, the draft is + * journaled, the profile read afresh, published when it differs and read + * back; then the membership is saved through the live Communities service — + * after the profile step, when there was one — which selects it, and the + * journal's record for the destination is cleared. A restored community is + * already saved: `joined` takes the same id again and adopts the profile + * into the empty local default. `false` means the fence stopped it. + */ + const complete = (step: ProfileStepState, publish?: PersonalProfile) => + completeJoin({ + community: step.found.url, + membership: { + id: step.found.url, + name: step.found.host, + ...(step.found.icon ? { icon: step.found.icon } : {}), + }, + found: step.read, + journal, + // The journal's current record, whichever step wrote it last: a failed + // publish replaced the admission's. + entry: journal?.get(step.found.url), + publish, + communities, + current, + }); + const leave = () => { + setProfileStep(undefined); + enterApp(); + }; + /** + * How a join ends: completed, then — for a join, not a restore — saved to + * the account, so another device restores it (`joined` queued the same + * idempotent add for the next Settings sign-in; best effort: `call` warns + * of a refused answer in development, and a failure never stops entry), and + * the gate lifts into the app on that community. + */ + const finishJoin = async ( + step: ProfileStepState, + publish?: PersonalProfile, + ) => { + if (!(await complete(step, publish))) return; + if (step.admission) + await reads.current?.saveCommunity(step.found.url).catch(() => {}); + leave(); + }; + /** + * The same join the Add-a-community dialog performs, through its owner: + * the join is journaled before any remote effect (so a relaunch resumes it + * through the dialog, and a lost claim reply is never claimed again), an + * invite code, when there is one, is claimed first, and a signed profile + * read proves admission. The membership is saved by `finishJoin`: at once, + * or after the profile step a user with no profile yet continues to. The + * join itself publishes nothing. + */ + const join = async ( + found: FoundCommunity, + consent: JoinConsent, + code: string, + ) => { + const fail = (error: string, title = "Couldn’t join community") => + setJoinError({ title, error }); + const unreachable = () => + fail("Could not reach the relay. Check your connection and try again."); + const client = communities.snapshot(); + if (client.status !== "ready") + return fail(client.error ?? "Your saved communities aren’t ready yet."); + const needsInvite = (codeError?: string) => + setJoinError({ + title: "This community needs an invite", + error: `${found.host} admits members by invite, and this key has none yet. Enter an invite code, or send your public key to a community administrator so they can add you, then try again.`, + denied: { found, consent }, + codeError, + }); + let admission: Admission | undefined; + try { + admission = await admitCommunity({ + community: found.url, + journal, + code, + policy: found.policy, + consent, + current, + }); + } catch (error) { + // A refused code (already its copy, from the owner; "Invalid invite + // code" is the native route's for one it will not send) is the user's + // to replace on the invite page; a relay that refuses the key needs one; + // the journal's own storage message is shown as is. Everything else is + // classified, not echoed. + const refusal = + error instanceof Error + ? (Object.values(CLAIM_REFUSALS).find( + (copy) => copy === error.message, + ) ?? + (error.message === "Invalid invite code" + ? CLAIM_REFUSALS.invite_invalid + : undefined)) + : undefined; + if (refusal) return needsInvite(refusal); + if (readErrorKind(error) === "denied") return needsInvite(); + return error instanceof JournalError + ? fail(error.message) + : unreachable(); + } + if (!admission) return; + const step = { found, read: admission.found, admission }; + // Neither the relay nor the local default (read before `joined` may adopt + // the relay's) holds a profile: the profile step asks for one, and the + // membership waits for it. + if (!admission.found.exists && !client.profile.name) + return setProfileStep(step); + try { + await finishJoin(step); + } catch (error) { + return fail(error instanceof Error ? error.message : String(error)); + } + }; + /** Try again repeats the read; a typed code is claimed first. */ + const retry = async ({ found, consent }: Denied, code = "") => { + setRetrying(code ? "code" : "read"); + try { + await join(found, consent, code); + } finally { + setRetrying(undefined); + } + }; + const denied = joinError?.denied; + // A failure once a key is saved. A failed link (or the unlink before it) + // blocks: nothing links the key later, so letting the user past it would + // leave the key on this device only, and the page offers the error and Try + // again until Builderlab answers. A failed backup may be skipped: a later + // sign-in runs the backup again. + const failedAfterKey = + progress.status === "failed" && state.status === "ready"; + const blocked = failedAfterKey && progress.skipsLink; + const skipped = failedAfterKey && !blocked ? SKIPPED_BACKUP : undefined; + /** + * The pages setup rests on. The first two may be reached before any sign-in, + * for a community no tenant hosts; the rest only after one. A pause this + * component did not reach has nothing to render. + */ + const paused = (at: SetupPause) => { + const current = reads.current; + switch (at) { + case "connect-choice": + // Connect is the landing's own sign-in: resetting first lets the + // landing show it pending, with Cancel sign-in, as for a tenant. + return finding ? ( + { + setup.reset(); + void signIn(); + }} + onImport={() => pause("key-import")} + onBack={backToLanding} + /> + ) : null; + case "key-import": + // With no account this run (the connect choice), the key is nobody's + // to link or back up, so the import is the whole setup. On the linked + // lane only the linked key is accepted, so there is nothing to link. + // Otherwise an imported key is linked like a created one. Either way + // the backup is offered, never assumed: the key may be used elsewhere. + // The linked lane skips the offer when status already lists the key. + return ( + + back( + !current + ? "connect-choice" + : linked + ? "welcome-back" + : "key-choice", + "import", + ) + } + onImport={async (nsec) => { + await importKey(identity, nsec); + if (!current) void setup.start([]); + else if (!linked) + void setup.start([linkKey(identity, current)], { + status: "paused", + at: "backup-offer", + }); + else if (!backedUp) pause("backup-offer"); + else void setup.start(finish()); + }} + /> + ); + } + if (!current) return null; + // A new key becomes the account's before it is backed up. + const link = linkKey(identity, current); + const backUpKey = backUp(identity, current); + switch (at) { + case "key-choice": + return ( + + void setup.start([ + createKey(identity), + link, + backUpKey, + ...finish(), + ]) + } + onImport={() => pause("key-import")} + onSwitchAccount={backToLanding} + /> + ); + case "welcome-back": + return linked ? ( + { + void current.recovery.beginRecovery(); + pause("key-restore"); + } + : undefined + } + onImport={() => pause("key-import")} + onLost={() => + void setup.start([findCommunities(current, setOwned)], { + status: "paused", + at: "key-lost", + }) + } + onSwitchAccount={backToLanding} + /> + ) : null; + case "key-restore": + // A restored key is already backed up, so no backup step follows. + return linked ? ( + back("welcome-back", "restore")} + onImport={() => pause("key-import")} + onInstalled={() => { + if (identity.snapshot().status === "ready") + void setup.start(finish()); + }} + /> + ) : null; + case "backup-offer": + return ( + void setup.start(finish())} + onBackUp={() => void setup.start([backUpKey, ...finish()])} + /> + ); + case "key-lost": + // Starting over creates a new key, as Create my key does, and the old + // key's communities are not offered to it (so the lane ends with the + // restore alone, not `finish`). The old key stays linked + // until the new one exists, so the account is never left without a + // key; binding never overwrites, so the old one is unlinked before the + // new one is linked. + return linked ? ( + back("welcome-back", "lost")} + onStartOver={() => { + setLinked(undefined); + setOwned(undefined); + void setup.start([ + createKey(identity), + unlinkKey(identity, current, linked), + link, + backUpKey, + ...restore(), + ]); + }} + /> + ) : null; + } + }; + if (state.status === "loading" || state.status === "error") + return ( + +

+ Your Buzz identity +

+ {state.status === "loading" ? ( +

Opening your saved identity…

+ ) : ( + <> +

{state.error}

+ + + )} + + } + > + {null} +
+ ); + // Discovery: the same unauthenticated lookup Join Community runs after + // setup, so a bad address fails here, before sign-in and before a key + // exists. A found community says which sign-in comes next: a Builderlab + // tenant's portal, or none, which asks the user whether to connect at the + // build portal or bring a key. + if (progress.status === "idle" && !auth.error && !discovering) + return ( + +

+ +

+

+ Your people, your agents, your projects —
+ all in one place. +

+ + } + > + + {auth.status === "pending" && ( +

+ Finish sign-in in your browser, then return to Buzz. +

+ )} + {/* The action column centres its text; the field keeps its label aligned. */} +
+ +
+ {/* A scanner's text takes the field's path: an address or invite link → find. */} + +
+ ); + // The key pages and Join a community bring their own onboarding layouts; + // the rest are wireframes. A pause with nothing to render keeps the frame, + // so the title bar and window controls stay. + if ( + progress.status === "paused" && + ["key-choice", "key-import", "key-restore"].includes(progress.at) + ) + return paused(progress.at) ?? {null}; + if (progress.status === "done" && !profileStep && !joinError && !finding) + return ( + + ); + return ( + + {progress.status === "done" ? ( + profileStep ? ( + finishJoin(profileStep, next)} + // A restored community is saved already: Skip only leaves. + onSkip={() => + profileStep.admission + ? finishJoin(profileStep) + : Promise.resolve(leave()) + } + /> + ) : joinError ? ( + // Back returns to the find page, which looks the community up again. + setJoinError(undefined)} + publicKey={denied ? npubOf(state) : undefined} + invite={ + denied && { + error: joinError.codeError, + onSubmit: (code) => void retry(denied, code), + } + } + onRetry={denied && (() => void retry(denied))} + busy={retrying} + /> + ) : finding ? ( + setFinding(undefined)} + onJoin={(found, consent) => join(found, consent, inviteCode)} + titleId="welcome-title" + /> + ) : null + ) : progress.status === "running" ? ( + + ) : progress.status === "paused" ? ( + paused(progress.at) + ) : progress.status === "failed" ? ( + // Once a key is saved, the way on is Join without the steps left: + // `backToLanding` would reset to idle, and with identity ready the + // gate would lift into the app with no community. + void setup.start(finish()) + : backToLanding + } + onRetry={ + blocked || progress.retryable ? () => void setup.retry() : undefined + } + /> + ) : auth.error ? ( + + ) : finding ? ( + { + const method = discoverAuthentication(found, tenants); + if (method.kind === "builderlab") void signIn(method.origin); + else pause("connect-choice"); + }} + titleId="welcome-title" + /> + ) : null} + + ); +} + +/** The refused key and the consent its Join carried, for a retry or a code. */ +type Denied = { found: FoundCommunity; consent: JoinConsent }; + +/** The community the profile step is for, and the read that admitted the key. */ +type ProfileStepState = { + found: Pick; + read: Admission["found"]; + /** The join's admission; absent for a restored community, whose membership is saved already. */ + admission?: Admission; +}; + +/** + * Admins admit people by npub, so the denied page shows the key to send them. + * Full npub only: the service validates the viewer as 64-hex, so encoding fails + * only on a snapshot that is not ready, and no key block is shown then. + */ +function npubOf(state: ReturnType) { + if (state.status !== "ready") return undefined; + try { + return npubEncode(state.viewer); + } catch { + return undefined; + } +} + +/** + * What a failed link or unlink leaves undone: the key is not the account's + * (and, after an unlink, the old key stays bound), and with it the backup, + * which needs a linked key. The page has no way past it but Try again. + */ +const UNLINKED_NOTE = + "Your key is saved on this device, but it isn’t linked to your account or backed up yet."; + +/** The failed page's way on when only the backup is left undone. */ +const SKIPPED_BACKUP = { + label: "Continue without backup", + note: "Your key is saved on this device, but it isn’t backed up yet.", +} as const; + +function Failure({ + title, + error, + note, + onBack, + backLabel = "Back", + publicKey, + invite, + onRetry, + busy, +}: { + title: string; + error: string; + /** Setup failed after the key was saved: say it is not lost, and what is undone. */ + note?: string | undefined; + /** The way back or on; absent where the failure blocks and only Try again is offered. */ + onBack?: (() => void) | undefined; + backLabel?: string | undefined; + /** Shown as the user's shareable identity, with Copy, when set. */ + publicKey?: string | undefined; + /** Offers an invite code field, with the last code's refusal under it. */ + invite?: + | { error?: string | undefined; onSubmit: (code: string) => void } + | undefined; + /** Offers Try again; every action is disabled while `busy`. */ + onRetry?: (() => void) | undefined; + /** Which action is in flight: Try again's read, or the code's claim. */ + busy?: "read" | "code" | undefined; +}) { + return ( + <> +

+ {title} +

+

+ {error} +

+ {note &&

{note}

} + {invite && ( + + )} + {publicKey && } + {/* Footer order: secondary first, primary last. */} +
+ {onBack && ( + + )} + {onRetry && ( + + )} +
+ + ); +} diff --git a/src/features/identity/identity.test.tsx b/src/features/identity/identity.test.tsx index a78609552e..12cf82ce44 100644 --- a/src/features/identity/identity.test.tsx +++ b/src/features/identity/identity.test.tsx @@ -3,15 +3,21 @@ import "@testing-library/jest-dom/vitest"; import { act, cleanup, + createEvent, fireEvent, render, screen, waitFor, + within, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type { ComponentProps } from "react"; import { afterEach, expect, it, vi } from "vitest"; +import { Context } from "@deepseek-ai/cordis"; import { invoke, isTauri } from "@tauri-apps/api/core"; import { getCurrentWindow } from "@tauri-apps/api/window"; +import type { EventTemplate } from "nostr-tools"; +import { npubEncode, nsecEncode } from "nostr-tools/nip19"; import { formatPublicKey } from "../../shared/identity/public-key"; import nsecBackup from "../../../src-tauri/src/nsec_backup.rs?raw"; import { @@ -19,20 +25,51 @@ import { nativeIdentityEnabled, RECOVERY_REFUSED, RECOVERY_UNAVAILABLE, + type Identity, type RecoveryResult, } from "./service"; import { IdentitySetup } from "./IdentitySetup"; import { PrivateKey } from "./PrivateKey"; import { SignOutDialog, WIPE_PHRASE } from "./SignOut"; +import { Welcome } from "./Welcome"; +import type { Credential } from "../../bundled/builderlab/oauth/browser"; +import { BackupError, type BackupStatus } from "./backup"; +import { + builderlabAccount, + type FirstRunAccount, + type OwnedCommunity, +} from "./builderlab-account"; +import type { JoinPolicy } from "../communities/join-policy"; +import { createCommunities } from "../communities/service"; +import { connectNativeTransport } from "../relay/native"; +import { keypair, profile, signed } from "../relay/testing"; vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), isTauri: vi.fn(() => true), + Channel: class { + constructor(public onmessage: (response: unknown) => void) {} + }, })); vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: vi.fn(), })); +/** The landing and Connect to {host} primary, as the Buzz mobile client labels it. */ +const SIGN_IN = "Sign in with email"; const viewer = "ab".repeat(32); const key = "nsec-fixture-only"; +/** A real key for the import journeys: native import takes the bech32 nsec as typed. */ +const owned = keypair(); +const ownedNsec = nsecEncode(owned.secret); +const credential: Credential = { + value: "private-token", + account: { + subject: "user", + email: "a@example.com", + canUseRemoteAgents: false, + }, +}; +const welcomeHeading = () => screen.getByRole("heading", { name: "Buzz" }); +const connectButton = () => screen.getByRole("button", { name: SIGN_IN }); function titleBar() { const header = document.querySelector("header"); if (!header) throw new Error("Missing native title bar"); @@ -40,16 +77,96 @@ function titleBar() { } function deferred() { let resolve!: (value: T) => void; - const promise = new Promise((done) => { + let reject!: (error: Error) => void; + const promise = new Promise((done, fail) => { resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} +/** Welcome reads the Communities service only to join; these screens never get there. */ +const noJoin: ComponentProps["communities"] = { + snapshot() { + throw new Error("Welcome consulted communities before a join"); + }, + joined() { + throw new Error("Welcome joined without a join"); + }, + select() { + throw new Error("Welcome selected a community without a restore"); + }, +}; +/** The app's invite intent: only a restore that skipped the typed address opens it. */ +const invites = { open: vi.fn() }; +/** The first-run Join Community join's save to the account; nothing else saves. */ +const saveCommunity = vi.fn(() => + Promise.resolve(), +); +type BackupAnswers = Partial< + Pick +>; +const unexpectedRecovery = () => { + throw new Error("Welcome recovered a key without Restore from backup"); +}; +/** + * Every backup succeeding, as on the build's own portal; backup status + * unavailable, as on most tenants, so nothing is restored. + */ +const backupAnswers = { + backUp: () => Promise.resolve(), + backupStatus: () => Promise.resolve("unavailable" as const), + recovery: { + subscribe: unexpectedRecovery, + snapshot: unexpectedRecovery, + beginRecovery: unexpectedRecovery, + installRecovery: unexpectedRecovery, + cancelRecovery: unexpectedRecovery, + }, +} satisfies BackupAnswers; +/** Only Start over unlinks; no other lane may reach kgoose's delete. */ +const noUnlink = () => + Promise.reject(new Error("Welcome unlinked a key it did not replace")); +/** The signed-in account with no key bound and nothing owned, binding through `bind`. */ +const unlinkedWith = + ( + bind: FirstRunAccount["bind"], + backup: BackupAnswers = {}, + ): ComponentProps["account"] => + () => ({ + linkedKey: () => Promise.resolve(null), + ownedCommunities: () => Promise.resolve([]), + knownCommunities: () => Promise.resolve([]), + saveCommunity, + bind, + unbind: noUnlink, + ...backupAnswers, + ...backup, }); - return { promise, resolve }; +/** Today's every user: nothing bound or owned, and binding the new key succeeds. */ +const unlinked = unlinkedWith(() => Promise.resolve()); +const roots: Context[] = []; +/** The production shape: the service exists at startup and hydrates once the key is ready. */ +function communitiesFor(identity: Identity, openRelay = "") { + const ctx = new Context(); + roots.push(ctx); + return createCommunities( + ctx, + false, + undefined, + openRelay, + undefined, + identity.ready, + connectNativeTransport, + ); } -afterEach(() => { +afterEach(async () => { cleanup(); + for (const root of roots.splice(0)) await root.fiber.dispose(); vi.resetAllMocks(); vi.unstubAllEnvs(); + vi.unstubAllGlobals(); vi.restoreAllMocks(); + localStorage.clear(); }); it("keeps native titlebar actions available throughout identity setup without dragging form controls", async () => { @@ -666,138 +783,4698 @@ it("retires a browser sign-in that starts after setup is unmounted", async () => identity.dispose(); }); -it("restore denial does not become first run or generate a replacement", async () => { - vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); - const startDragging = vi.fn().mockResolvedValue(undefined); - vi.mocked(getCurrentWindow).mockReturnValue({ - startDragging, - } as unknown as ReturnType); - vi.mocked(invoke) - .mockRejectedValueOnce("Keychain access was denied") - .mockResolvedValueOnce(viewer); +it("first launch offers email sign-in and a live community URL field, with QR scanning disabled", async () => { + vi.mocked(invoke).mockResolvedValueOnce(null); + const identity = createIdentity(); + render( + +

Signed in

+
, + ); + // The loading state is titled "Your Buzz identity"; wait for the missing state. + expect(await screen.findByRole("button", { name: SIGN_IN })).toBeEnabled(); + expect(welcomeHeading()).toBeVisible(); + // The onboarding tagline breaks its line after the dash. + expect( + screen.getByText( + /^Your people, your agents, your projects —\s*all in one place\.$/, + ), + ).toBeVisible(); + const url = screen.getByRole("textbox", { name: "Community URL" }); + expect(url).toBeEnabled(); + expect(url).toHaveValue(""); + expect(screen.getByRole("button", { name: "Continue" })).toBeEnabled(); + expect( + screen.getByRole("button", { name: "Scan a QR code — Coming soon" }), + ).toBeDisabled(); + expect( + screen.queryByRole("button", { name: /existing key|new identity/ }), + ).toBeNull(); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + ]); + identity.dispose(); +}); + +/** + * A relay that answers the two unauthenticated reads a landing URL triggers, + * before any key exists. `info` holds the NIP-11 answer so the page can be + * observed waiting on it; the policy is missing, as on most deployments. + * `created` holds key creation so "Creating keys…" can be observed. + */ +function landingRelay( + origin = "https://relay.example.com:8443", + created: Promise = Promise.resolve(viewer), +) { + const relayCalls: unknown[] = []; + const info = deferred(); + const answer = (status: number, body = "") => ({ status, headers: {}, body }); + vi.mocked(invoke).mockImplementation(async (command, args) => { + if (command === "identity_restore") return null; + if (command === "identity_create") return created; + if (command === "identity_import") return owned.pubkey; + if (command === "identity_export") return key; + if (command !== "relay_http") + throw new Error(`Unexpected command ${command}`); + relayCalls.push(args); + const { path } = args as { path: string }; + if (path !== "/") return answer(404); + await info.promise; + return answer( + 200, + JSON.stringify({ + name: "Buzz Relay", + icon: "https://cdn.example.com/icon.png", + }), + ); + }); + const get = (path: string) => ({ + community: origin, + path, + method: "GET", + body: null, + }); + return { relayCalls, info, lookup: [get("/"), get("/api/join-policy")] }; +} +const urlField = () => screen.getByRole("textbox", { name: "Community URL" }); +const urlContinue = () => screen.getByRole("button", { name: "Continue" }); +/** The key choice a setup with no backed-up key pauses on; Create links and backs up the key. */ +const createMyKey = async (user: ReturnType) => + user.click(await screen.findByRole("button", { name: "Create my key" })); +const keySavedNote = + "Your key is saved on this device, but it isn’t backed up yet."; +const keyUnlinkedNote = + "Your key is saved on this device, but it isn’t linked to your account or backed up yet."; +/** The test table: relay.example.com (any subdomain, default port) signs in at tenant.example. */ +const tenants = [ + { relayHost: "relay.example.com", loginOrigin: "https://tenant.example" }, +]; + +it("a landing URL is looked up before sign-in, a tenant's relay signs in at that tenant's portal, setup then lands on Join Community, and Cancel reaches Join a community with the address kept", async () => { + const relay = landingRelay("https://relay.example.com"); + const released = deferred(); + const connect = vi.fn( + (_signal: AbortSignal, _origin?: string) => released.promise, + ); + const { backupStatus, release } = gatedStatus(); const identity = createIdentity(); const user = userEvent.setup(); render( - -

Restored

-
, + Promise.resolve(), { backupStatus })} + connect={connect} + tenants={tenants} + > +

Signed in

+
, ); - expect(await screen.findByRole("alert")).toHaveTextContent( - "Keychain access was denied", + await screen.findByRole("button", { name: SIGN_IN }); + await user.type(urlField(), "Design.Relay.Example.com{Enter}"); + // Discovery: the same two unauthenticated GETs the find page sends, with + // no sign-in and no key yet. The field is gone: the heading takes focus. + expect( + screen.getByRole("heading", { name: "Finding community" }), + ).toBeVisible(); + expect( + screen.getByRole("heading", { name: "Finding community" }), + ).toHaveFocus(); + expect(screen.getByText("design.relay.example.com")).toBeVisible(); + const lookup = relay.lookup.map((call) => ({ + ...call, + community: "https://design.relay.example.com", + })); + expect(relay.relayCalls).toEqual(lookup); + expect(connect).not.toHaveBeenCalled(); + expect(invoke).not.toHaveBeenCalledWith("identity_create", {}); + await act(async () => relay.info.resolve()); + // Found: the host matches the tenant row as a subdomain, so sign-in starts + // at once at that tenant's portal; the landing shows it pending with the + // typed address held in the disabled field. + expect(connect).toHaveBeenCalledTimes(1); + expect(connect).toHaveBeenCalledWith( + expect.any(AbortSignal), + "https://tenant.example", ); expect( - screen.queryByRole("button", { name: "Create a new identity" }), + screen.queryByRole("heading", { name: "Finding community" }), ).toBeNull(); - fireEvent.mouseDown(titleBar(), { - button: 0, - detail: 1, - }); - expect(startDragging).toHaveBeenCalledOnce(); - await user.click( - screen.getByRole("button", { name: "Retry secure storage access" }), + expect(screen.getByRole("button", { name: "Cancel sign-in" })).toBeEnabled(); + expect(urlField()).toBeDisabled(); + expect(urlField()).toHaveValue("Design.Relay.Example.com"); + expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled(); + await act(async () => released.resolve(credential)); + // The account check found no key, so the backed-up-key lookup comes next. + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent( + "Checking for a backed-up key…", + ), ); - expect(await screen.findByText("Restored")).toBeVisible(); + await release(); + expect( + screen.getByRole("heading", { name: "Set up your key" }), + ).toBeVisible(); + expect(invoke).not.toHaveBeenCalledWith("identity_create", {}); + await createMyKey(user); + // Setup ran (the key exists) and landed on the find page for that URL, not + // on the choices page. + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + expect(backupStatus).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledWith("identity_create", {}); + expect(identity.snapshot()).toEqual({ status: "ready", viewer }); + expect( + screen.queryByRole("heading", { name: "Join a community" }), + ).toBeNull(); + expect(screen.queryByText("Signed in")).toBeNull(); + // The account saved no communities: nothing restored, nothing handed off. + expect(invites.open).not.toHaveBeenCalled(); + expect(relay.relayCalls).toEqual([...lookup, ...lookup]); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect( + screen.getByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect(urlField()).toHaveValue("Design.Relay.Example.com"); + // Continue from the choices page looks the same address up again. + await user.click(urlContinue()); + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + expect(relay.relayCalls).toHaveLength(6); + identity.dispose(); +}); + +/** The connect choice a found community no tenant hosts pauses on. */ +const connectChoiceHeading = () => + screen.getByRole("heading", { name: "Connect to your community" }); +/** The found host, shown under the title rather than in it. */ +const connectChoiceHost = () => screen.getByText("relay.example.com:8443"); +/** Type the non-hosted address (the tenant's host on a non-default port) through to the choice. */ +async function connectChoicePage( + user: ReturnType, + relay: ReturnType, +) { + await screen.findByRole("button", { name: SIGN_IN }); + await user.type(urlField(), "relay.example.com:8443{Enter}"); + expect( + screen.getByRole("heading", { name: "Finding community" }), + ).toBeVisible(); + expect(relay.relayCalls).toEqual(relay.lookup); + await act(async () => relay.info.resolve()); + expect(connectChoiceHeading()).toBeVisible(); + expect(connectChoiceHost()).toBeVisible(); +} + +it("a landing URL no tenant hosts pauses on Connect to {host} with no sign-in and no key, and Back keeps the address", async () => { + const relay = landingRelay(); + const connect = vi.fn(); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await connectChoicePage(user, relay); + // Found, but no tenant hosts it: nothing runs until the user says who + // provides the key. The body is one sentence: why a key is needed at all. + expect( + screen.getByText("Buzz relies on a key for your identity."), + ).toBeVisible(); + expect(connectButton()).toBeEnabled(); + expect( + screen.getByRole("button", { name: "I already have a key" }), + ).toBeEnabled(); + expect( + screen.queryByRole("heading", { name: "Finding community" }), + ).toBeNull(); + expect(screen.queryByRole("heading", { name: "Setting up Buzz" })).toBeNull(); + expect(connect).not.toHaveBeenCalled(); + expect(invoke).not.toHaveBeenCalledWith("identity_create", {}); + expect(relay.relayCalls).toEqual(relay.lookup); + // Back is the landing, with the address editable and not looked up again. + await user.click(screen.getByRole("button", { name: "Back" })); + expect(welcomeHeading()).toBeVisible(); + expect(screen.queryByRole("heading", { name: /^Connect to / })).toBeNull(); + expect( + screen.queryByRole("heading", { name: "Finding community" }), + ).toBeNull(); + expect(urlField()).toBeEnabled(); + expect(urlField()).toHaveValue("relay.example.com:8443"); + expect(relay.relayCalls).toEqual(relay.lookup); + expect(identity.snapshot().status).toBe("missing"); expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ "identity_restore", - "identity_restore", + "relay_http", + "relay_http", ]); identity.dispose(); }); -it("import failure keeps setup open without falling through to creation", async () => { - vi.mocked(invoke) - .mockResolvedValueOnce(null) - .mockRejectedValueOnce("Invalid nsec"); +it("Sign in with email from the choice signs in at the build portal, runs the Connect lane with its key backup, and lands on Join Community for the address", async () => { + const relay = landingRelay(); + const released = deferred(); + const connect = vi.fn( + (_signal: AbortSignal, _origin?: string) => released.promise, + ); + const { backupStatus, release } = gatedStatus(); + const backUp = vi.fn((_pubkey: string) => Promise.resolve()); + const account = vi.fn( + unlinkedWith(() => Promise.resolve(), { backupStatus, backUp }), + ); const identity = createIdentity(); - await waitFor(() => expect(identity.snapshot().status).toBe("missing")); - await identity.importKey(key); - expect(identity.snapshot()).toEqual({ - status: "missing", - busy: false, - error: "Invalid nsec", - }); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await connectChoicePage(user, relay); + await user.click(connectButton()); + // The landing's own sign-in, at the build portal (no tenant origin), shown + // pending on the landing with the address held, as for a tenant. + expect(connect).toHaveBeenCalledTimes(1); + expect(connect).toHaveBeenCalledWith(expect.any(AbortSignal), undefined); + expect(screen.queryByRole("heading", { name: /^Connect to / })).toBeNull(); + expect(screen.getByRole("button", { name: "Cancel sign-in" })).toBeEnabled(); + expect(urlField()).toBeDisabled(); + expect(urlField()).toHaveValue("relay.example.com:8443"); + expect(relay.relayCalls).toEqual(relay.lookup); + await act(async () => released.resolve(credential)); + // The reads go to the build portal too, and the lane is Connect's: account + // check, backed-up-key lookup, then the key choice. + expect(account).toHaveBeenCalledWith(credential, undefined, identity); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent( + "Checking for a backed-up key…", + ), + ); + await release(); + expect( + screen.getByRole("heading", { name: "Set up your key" }), + ).toBeVisible(); + expect(invoke).not.toHaveBeenCalledWith("identity_create", {}); + await createMyKey(user); + // Created, linked and backed up at the build portal, then the find page for + // the address rather than the choices page. + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + expect(backUp.mock.calls).toEqual([[viewer]]); + expect(identity.snapshot()).toEqual({ status: "ready", viewer }); + expect( + screen.queryByRole("heading", { name: "Join a community" }), + ).toBeNull(); + expect(screen.queryByText("Signed in")).toBeNull(); + // The account saved no communities: no restore read, no handoff. + expect(invites.open).not.toHaveBeenCalled(); + expect(relay.relayCalls).toEqual([...relay.lookup, ...relay.lookup]); expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ "identity_restore", - "identity_import", + "relay_http", + "relay_http", + "identity_create", + "relay_http", + "relay_http", ]); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect( + screen.getByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect(urlField()).toHaveValue("relay.example.com:8443"); identity.dispose(); }); -it("coalesces pending creation and only creates on explicit request", async () => { - const created = deferred(); - vi.mocked(invoke) - .mockResolvedValueOnce(null) - .mockImplementationOnce(() => created.promise); +it("Cancel sign-in after Connect from the choice returns to the landing with the address kept and no second lookup", async () => { + const relay = landingRelay(); + let signal: AbortSignal | undefined; + const connect = vi.fn( + (current: AbortSignal, _origin?: string): Promise => { + signal = current; + return new Promise(() => {}); + }, + ); const identity = createIdentity(); - await waitFor(() => expect(identity.snapshot().status).toBe("missing")); - expect(invoke).toHaveBeenCalledTimes(1); - const first = identity.create(); - await identity.create(); - expect(invoke).toHaveBeenCalledTimes(2); - created.resolve(viewer); - await first; - expect(identity.snapshot()).toEqual({ status: "ready", viewer }); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await connectChoicePage(user, relay); + await user.click(connectButton()); + await screen.findByRole("button", { name: "Cancel sign-in" }); + expect(connect).toHaveBeenCalledWith(expect.any(AbortSignal), undefined); + await user.click(screen.getByRole("button", { name: "Cancel sign-in" })); + expect(signal?.aborted).toBe(true); + // The landing, not the choice: the address is editable again and neither + // discovery nor the choice page came back on their own. + expect(welcomeHeading()).toBeVisible(); + expect(connectButton()).toBeEnabled(); + expect(urlField()).toBeEnabled(); + expect(urlField()).toHaveValue("relay.example.com:8443"); + expect( + screen.queryByRole("heading", { name: "Finding community" }), + ).toBeNull(); + expect(screen.queryByRole("heading", { name: /^Connect to / })).toBeNull(); + expect(relay.relayCalls).toEqual(relay.lookup); + expect(identity.snapshot().status).toBe("missing"); identity.dispose(); }); -it("fetches a private key only on interaction, masks by absence, and cancels a late reveal", async () => { - vi.mocked(invoke).mockResolvedValueOnce(viewer); +it("I already have a key from the choice imports the key with no sign-in, no link and no backup offer, and lands on Join Community for the address", async () => { + const relay = landingRelay(); + // Hold the import so the progress page can be seen. + const imported = deferred(); + const relayAnswers = vi.mocked(invoke).getMockImplementation(); + if (!relayAnswers) throw new Error("landingRelay installed no answers"); + vi.mocked(invoke).mockImplementation((command, args) => + command === "identity_import" + ? imported.promise + : relayAnswers(command, args), + ); + const connect = vi.fn(); + const account = vi.fn(unlinked); const identity = createIdentity(); - await identity.ready; - const exported = deferred(); - vi.mocked(invoke).mockImplementationOnce(() => exported.promise); const user = userEvent.setup(); - const view = render(); - expect(invoke).toHaveBeenCalledTimes(1); - expect(screen.getByLabelText("Private key (nsec)")).toHaveValue( - "••••••••••••••••", + render( + +

Signed in

+
, ); - await user.click(screen.getByRole("button", { name: "Reveal private key" })); - await user.click(screen.getByRole("button", { name: "Cancel" })); - await act(async () => { - exported.resolve(key); - }); - expect(screen.getByLabelText("Private key (nsec)")).toHaveValue( - "••••••••••••••••", + await connectChoicePage(user, relay); + await user.click( + screen.getByRole("button", { name: "I already have a key" }), ); - vi.mocked(invoke).mockResolvedValueOnce(key); - await user.click(screen.getByRole("button", { name: "Reveal private key" })); - expect(screen.getByLabelText("Private key (nsec)")).toHaveValue(key); - view.unmount(); - render(); - expect(screen.getByLabelText("Private key (nsec)")).toHaveValue( - "••••••••••••••••", + expect( + screen.getByRole("heading", { name: "Enter your private key" }), + ).toBeVisible(); + // No account, so no linked key to expect: any valid nsec is accepted. + await user.paste(ownedNsec); + expect(screen.queryByRole("alert")).toBeNull(); + expect(keyField()).toHaveAccessibleDescription( + `Public identity: ${npubEncode(owned.pubkey)}`, + ); + expect(continueWithKey()).toBeEnabled(); + // Back is the choice, not Set up your key, and reopening starts clean. + await user.click(screen.getByRole("button", { name: "Back" })); + expect(connectChoiceHeading()).toBeVisible(); + expect(screen.queryByRole("heading", { name: "Set up your key" })).toBeNull(); + await user.click( + screen.getByRole("button", { name: "I already have a key" }), ); + expect(keyField()).toHaveValue(""); + await user.paste(ownedNsec); + await user.click(continueWithKey()); + expect(invoke).toHaveBeenCalledWith("identity_import", { nsec: ownedNsec }); + expect( + screen.getByRole("heading", { name: "Setting up Buzz" }), + ).toBeVisible(); + expect(screen.getByRole("status")).toHaveTextContent("Importing your key…"); + expect(screen.queryByLabelText("Private key (nsec)")).toBeNull(); + expectNoImportActions(); + expect(screen.queryByText("Signed in")).toBeNull(); + await act(async () => imported.resolve(owned.pubkey)); + // The import is the whole setup: nobody's account to link to, nothing to + // offer a backup for, straight to the find page for the address. + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + expect(identity.snapshot()).toEqual({ + status: "ready", + viewer: owned.pubkey, + }); + expect( + screen.queryByRole("heading", { name: "Back up this key?" }), + ).toBeNull(); + expect( + screen.queryByRole("heading", { name: "Join a community" }), + ).toBeNull(); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(connect).not.toHaveBeenCalled(); + // No account, so no saved communities: nothing is restored or handed off. + expect(account).not.toHaveBeenCalled(); + expect(invites.open).not.toHaveBeenCalled(); + expect(relay.relayCalls).toEqual([...relay.lookup, ...relay.lookup]); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "relay_http", + "relay_http", + "identity_import", + "relay_http", + "relay_http", + ]); identity.dispose(); }); -it("copies without revealing and cancels a pending export on blur before clipboard access", async () => { - vi.mocked(invoke).mockResolvedValueOnce(viewer); +it("a native import refusal from the choice stays under the field, and Back returns to the choice", async () => { + const relay = landingRelay(); + const relayAnswers = vi.mocked(invoke).getMockImplementation(); + if (!relayAnswers) throw new Error("landingRelay installed no answers"); + vi.mocked(invoke).mockImplementation((command, args) => + command === "identity_import" + ? Promise.reject("An identity is already saved; nothing was overwritten") + : relayAnswers(command, args), + ); const identity = createIdentity(); - await identity.ready; const user = userEvent.setup(); - const write = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); - vi.mocked(invoke).mockResolvedValueOnce(key); - render(); - await user.click(screen.getByRole("button", { name: "Copy private key" })); - expect(write).toHaveBeenCalledWith(key); - expect(screen.getByLabelText("Private key (nsec)")).toHaveValue( - "••••••••••••••••", + render( + +

Signed in

+
, ); - const exported = deferred(); - vi.mocked(invoke).mockImplementationOnce(() => exported.promise); - await user.click(screen.getByRole("button", { name: "Copy private key" })); - fireEvent(window, new Event("blur")); - await act(async () => { - exported.resolve(key); + await connectChoicePage(user, relay); + await user.click( + screen.getByRole("button", { name: "I already have a key" }), + ); + await user.paste(ownedNsec); + await user.click(continueWithKey()); + expect(await screen.findByRole("alert")).toHaveTextContent( + "An identity is already saved; nothing was overwritten", + ); + expect( + screen.getByRole("heading", { name: "Enter your private key" }), + ).toBeVisible(); + expect( + screen.queryByRole("heading", { name: "Couldn’t finish setup" }), + ).toBeNull(); + expect(keyField()).toHaveValue(ownedNsec); + expect(identity.snapshot().status).toBe("missing"); + await user.click(screen.getByRole("button", { name: "Back" })); + expect(connectChoiceHeading()).toBeVisible(); + expect( + screen.getByRole("button", { name: "I already have a key" }), + ).toHaveFocus(); + expect(relay.relayCalls).toEqual(relay.lookup); + identity.dispose(); +}); + +it("a landing URL that cannot be reached fails before sign-in, and Cancel keeps the address", async () => { + const relayCalls: unknown[] = []; + vi.mocked(invoke).mockImplementation((command, args) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "relay_http") { + relayCalls.push(args); + return Promise.reject(new Error("connection refused")); + } + throw new Error(`Unexpected command ${command}`); }); - expect(write).toHaveBeenCalledTimes(1); + const connect = vi.fn(); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await screen.findByRole("button", { name: SIGN_IN }); + await user.type(urlField(), "relay.example.com{Enter}"); + expect( + await screen.findByRole("heading", { name: "Couldn’t find community" }), + ).toBeVisible(); + expect(screen.getByRole("alert")).toHaveTextContent( + "Could not reach the relay. Check your connection and try again.", + ); + expect(screen.getByRole("button", { name: "Retry" })).toBeEnabled(); + expect(connect).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(welcomeHeading()).toBeVisible(); + expect(welcomeHeading()).toHaveFocus(); + expect(screen.queryByRole("alert")).toBeNull(); + expect(connectButton()).toBeEnabled(); + expect(urlField()).toHaveValue("relay.example.com"); + expect(relayCalls).toHaveLength(2); + expect(identity.snapshot().status).toBe("missing"); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "relay_http", + "relay_http", + ]); identity.dispose(); }); -it("enables packaged identity on desktop platforms but never in the live broker or browser", () => { - vi.mocked(isTauri).mockReturnValue(true); - vi.stubEnv("VITE_BUZZ_LIVE", ""); +it("canceling or failing sign-in after a landing URL returns to the landing with the address kept and no second lookup", async () => { + const relay = landingRelay("https://relay.example.com"); + relay.info.resolve(); + let signal: AbortSignal | undefined; + const connect = vi + .fn<(signal: AbortSignal, origin?: string) => Promise>() + .mockImplementationOnce((current) => { + signal = current; + return new Promise(() => {}); + }) + .mockImplementationOnce(() => new Promise(() => {})) + .mockRejectedValueOnce(new Error("Browser could not open.")); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await screen.findByRole("button", { name: SIGN_IN }); + await user.type(urlField(), "relay.example.com{Enter}"); + await screen.findByRole("button", { name: "Cancel sign-in" }); + expect(connect).toHaveBeenCalledWith( + expect.any(AbortSignal), + "https://tenant.example", + ); + expect(connect).toHaveBeenCalledTimes(1); + expect(relay.relayCalls).toEqual(relay.lookup); + await user.click(screen.getByRole("button", { name: "Cancel sign-in" })); + expect(signal?.aborted).toBe(true); + // Back on the landing: the typed address is editable again and discovery + // did not restart on its own. + expect(welcomeHeading()).toBeVisible(); + expect(connectButton()).toBeEnabled(); + expect(urlField()).toBeEnabled(); + expect(urlField()).toHaveValue("relay.example.com"); + expect( + screen.queryByRole("heading", { name: "Finding community" }), + ).toBeNull(); + expect(relay.relayCalls).toEqual(relay.lookup); + // Connect without the URL: the tenant origin from the cancelled sign-in + // must not leak into this one. + await user.click(connectButton()); + await screen.findByRole("button", { name: "Cancel sign-in" }); + expect(connect).toHaveBeenCalledTimes(2); + expect(connect).toHaveBeenLastCalledWith(expect.any(AbortSignal), undefined); + await user.click(screen.getByRole("button", { name: "Cancel sign-in" })); + expect(welcomeHeading()).toBeVisible(); + expect(urlField()).toHaveValue("relay.example.com"); + expect(relay.relayCalls).toEqual(relay.lookup); + // Continue again: a fresh lookup, then a sign-in that fails; Back behaves the same. + await user.click(urlContinue()); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Browser could not open.", + ); + expect( + screen.getByRole("heading", { name: "Couldn’t sign in" }), + ).toBeVisible(); + expect(connect).toHaveBeenCalledTimes(3); + expect(relay.relayCalls).toEqual([...relay.lookup, ...relay.lookup]); + await user.click(screen.getByRole("button", { name: "Back" })); + expect(welcomeHeading()).toBeVisible(); + expect(urlField()).toHaveValue("relay.example.com"); + expect( + screen.queryByRole("heading", { name: "Finding community" }), + ).toBeNull(); + expect(relay.relayCalls).toHaveLength(4); + expect(identity.snapshot().status).toBe("missing"); + identity.dispose(); +}); + +/** Backup status that answers only once released, so the lookup step can be seen running. */ +function gatedStatus() { + const answer = deferred(); + const backupStatus = vi.fn(() => answer.promise); + const release = (listed: readonly string[] | "unavailable" = "unavailable") => + act(async () => { + if (!backupStatus.mock.calls.length) + throw new Error("The backed-up-key lookup never started"); + answer.resolve(listed); + }); + return { backupStatus, release }; +} + +it("Sign in with email runs sign-in, creates the key mid-setup, and lands on Join a community", async () => { + const created = deferred(); + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_create") return created.promise; + throw new Error("Unexpected command"); + }); + const released = deferred(); + const connect = vi.fn((_signal: AbortSignal) => released.promise); + const { backupStatus, release } = gatedStatus(); + const linked = deferred(); + const bound = deferred(); + const bind = vi.fn((_pubkey: string) => bound.promise); + const backedUp = deferred(); + const backUp = vi.fn((_pubkey: string) => backedUp.promise); + // The saved-destinations list is read beside the key, and a failed list + // changes nothing: a portal without the route answers a plain-text 404. + const knownCommunities = vi.fn(() => + Promise.reject( + new Error("Builderlab known communities failed (HTTP 404: not JSON)."), + ), + ); + const account = vi.fn(() => ({ + linkedKey: () => linked.promise, + ownedCommunities: () => Promise.resolve([]), + knownCommunities, + saveCommunity, + bind, + unbind: noUnlink, + ...backupAnswers, + backUp, + backupStatus, + })); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + expect(connect).toHaveBeenCalledTimes(1); + // No community URL chose a tenant, so sign-in uses the build's default origin. + expect(connect).toHaveBeenCalledWith(expect.any(AbortSignal), undefined); + expect(screen.getByRole("status")).toHaveTextContent( + "Finish sign-in in your browser", + ); + expect(screen.getByRole("button", { name: "Cancel sign-in" })).toBeEnabled(); + await act(async () => released.resolve(credential)); + expect( + screen.getByRole("heading", { name: "Setting up Buzz" }), + ).toBeVisible(); + // The landing's button is gone with the landing: the new heading has focus. + expect( + screen.getByRole("heading", { name: "Setting up Buzz" }), + ).toHaveFocus(); + // The account check is real and runs first: it decides the lane. + expect(account).toHaveBeenCalledWith(credential, undefined, identity); + expect(screen.getByRole("status")).toHaveTextContent( + "Checking your account…", + ); + // Both prelude reads are in flight under that one label. + expect(knownCommunities).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("button")).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith("identity_create", {}); + await act(async () => linked.resolve(null)); + expect(screen.getByRole("status")).toHaveTextContent( + "Checking for a backed-up key…", + ); + await release([]); + // The lookup found nothing, so setup pauses for the key choice before any + // key exists. + expect( + screen.getByRole("heading", { name: "Set up your key" }), + ).toBeVisible(); + expect( + screen.getByRole("heading", { name: "Set up your key" }), + ).toHaveFocus(); + // The lookup's sentence no longer carries the address: the signed-in line + // does, once, with a way out if it is the wrong account. + expect( + screen.getByText(/backed-up key for this account, so let’s make one/), + ).toBeVisible(); + expect(screen.getByText("Signed in as a@example.com.")).toBeVisible(); + expect(screen.queryByText(/backed-up key for a@example\.com/)).toBeNull(); + expect( + screen.getByRole("button", { name: "Use a different account" }), + ).toBeEnabled(); + expect(invoke).not.toHaveBeenCalledWith("identity_create", {}); + // Creating is the backup consent, so the page says who can restore the key. + expect(screen.getByText(/Block can use it to restore the key/)).toBeVisible(); + expect( + screen.getByRole("button", { name: "I already have a key" }), + ).toBeEnabled(); + expect(screen.getByRole("button", { name: "Create my key" })).toHaveAttribute( + "data-variant", + "prominent", + ); + await createMyKey(user); + expect(screen.getByRole("status")).toHaveTextContent("Creating keys…"); + expect(invoke).toHaveBeenCalledWith("identity_create", {}); + expect(bind).not.toHaveBeenCalled(); + await act(async () => created.resolve(viewer)); + // The key is now ready, but setup keeps the app behind the onboarding screens. + expect(identity.snapshot()).toEqual({ status: "ready", viewer }); + expect(screen.queryByText("Signed in")).toBeNull(); + // The created key is bound to the account before anything is backed up. + expect(screen.getByRole("status")).toHaveTextContent("Linking your key…"); + expect(bind.mock.calls).toEqual([[viewer]]); + expect(backUp).not.toHaveBeenCalled(); + await act(async () => bound.resolve()); + // Then backed up, waiting on the native uploader's own answer. + expect(screen.getByRole("status")).toHaveTextContent("Backing up your key…"); + expect(backUp.mock.calls).toEqual([[viewer]]); + expect(screen.queryByRole("button")).toBeNull(); + expect(screen.queryByText("Signed in")).toBeNull(); + await act(async () => backedUp.resolve()); + expect( + screen.getByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect( + screen.getByRole("heading", { name: "Join a community" }), + ).toHaveFocus(); + expect(screen.queryByText("Signed in")).toBeNull(); + // No invite list yet: nothing serves pending invites, so the URL field is the only entry. + expect(screen.queryByRole("list")).toBeNull(); + const url = screen.getByRole("textbox", { name: "Community URL" }); + expect(url).toBeEnabled(); + expect(url).toHaveValue(""); + expect(screen.getByRole("button", { name: "Continue" })).toBeEnabled(); + for (const name of [ + "Scan a QR code — Coming soon", + "Create a community — Coming soon", + ]) { + expect(screen.getByRole("button", { name })).toBeDisabled(); + } + expect(backupStatus).toHaveBeenCalledTimes(1); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "identity_create", + ]); + identity.dispose(); +}); + +it("Use a different account on Set up your key signs the session out, returns to the landing with no key, and the next sign-in rebuilds the account reads", async () => { + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + throw new Error(`Unexpected command ${command}`); + }); + const second: Credential = { + value: "second-token", + account: { ...credential.account, email: "b@example.com" }, + }; + const first = deferred(); + const again = deferred(); + const connect = vi + .fn((_signal: AbortSignal) => again.promise) + .mockImplementationOnce((_signal: AbortSignal) => first.promise); + // One stub per sign-in, each counting its own account read, so the second + // sign-in is shown to build new reads rather than reuse the first's. + const linkedKeys = [ + vi.fn(() => Promise.resolve(null)), + vi.fn(() => Promise.resolve(null)), + ]; + let signIns = 0; + const account = vi.fn( + (_credential: Credential, _origin: string | undefined, _id: Identity) => { + const linkedKey = linkedKeys[signIns++]; + if (!linkedKey) throw new Error("A third sign-in"); + return { + linkedKey, + ownedCommunities: () => Promise.resolve([]), + knownCommunities: () => Promise.resolve([]), + saveCommunity, + bind: () => + Promise.reject(new Error("Welcome bound a key it never created")), + unbind: noUnlink, + ...backupAnswers, + }; + }, + ); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + await act(async () => first.resolve(credential)); + expect( + await screen.findByRole("heading", { name: "Set up your key" }), + ).toBeVisible(); + expect(screen.getByText("Signed in as a@example.com.")).toBeVisible(); + expect(account).toHaveBeenCalledTimes(1); + expect(linkedKeys[0]).toHaveBeenCalledTimes(1); + await user.click( + screen.getByRole("button", { name: "Use a different account" }), + ); + // Back on the landing, signed out, with no key created or imported. + expect(welcomeHeading()).toBeVisible(); + expect(connectButton()).toBeEnabled(); + expect(screen.queryByRole("heading", { name: "Set up your key" })).toBeNull(); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(identity.snapshot().status).toBe("missing"); + expect(connect).toHaveBeenCalledTimes(1); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + ]); + // Signing in again runs a new sign-in and reads the new account, not the old. + await user.click(connectButton()); + expect(connect).toHaveBeenCalledTimes(2); + await act(async () => again.resolve(second)); + expect( + await screen.findByRole("heading", { name: "Set up your key" }), + ).toBeVisible(); + expect(account).toHaveBeenCalledTimes(2); + expect(account).toHaveBeenNthCalledWith(2, second, undefined, identity); + expect(linkedKeys[0]).toHaveBeenCalledTimes(1); + expect(linkedKeys[1]).toHaveBeenCalledTimes(1); + expect(screen.getByText("Signed in as b@example.com.")).toBeVisible(); + expect(screen.queryByText("Signed in as a@example.com.")).toBeNull(); + identity.dispose(); +}); + +it.each([ + // Backups outlive an unlink, so an unlinked account may still list some. + ["lists a backup", () => Promise.resolve(["ef".repeat(32)])], + ["is unavailable", () => Promise.resolve("unavailable" as const)], + ["fails", () => Promise.reject(new BackupError("unreachable"))], +])( + "the key choice says no backed-up key was found only when the lookup listed none, not when it %s", + async (_label, backupStatus) => { + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + throw new Error(`Unexpected command ${command}`); + }); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + Promise.resolve(), { backupStatus })} + connect={vi.fn().mockResolvedValue(credential)} + > +

Signed in

+
, + ); + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + expect( + await screen.findByRole("heading", { name: "Set up your key" }), + ).toBeVisible(); + expect(screen.queryByText(/backed-up key/)).toBeNull(); + // Creating is still the backup consent. + expect( + screen.getByText(/Block can use it to restore the key/), + ).toBeVisible(); + // No key is linked, so there is nothing to restore on this lane. + expect( + screen.queryByRole("button", { name: "Restore from backup" }), + ).toBeNull(); + identity.dispose(); + }, +); + +it("a link that fails after the key is created blocks setup with only Try again, which runs the link again and, once it succeeds, backs the key up and lands on Join a community", async () => { + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_create") return Promise.resolve(viewer); + throw new Error(`Unexpected command ${command}`); + }); + const bind = vi + .fn((_pubkey: string) => Promise.resolve()) + .mockRejectedValueOnce( + new Error( + "This Builderlab account is connected to another Buzz identity.", + ), + ); + const backUp = vi.fn((_pubkey: string) => Promise.resolve()); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + await createMyKey(user); + expect( + await screen.findByRole("heading", { name: "Couldn’t finish setup" }), + ).toBeVisible(); + expect( + screen.getByRole("heading", { name: "Couldn’t finish setup" }), + ).toHaveFocus(); + expect(screen.getByRole("alert")).toHaveTextContent( + "This Builderlab account is connected to another Buzz identity.", + ); + expect(screen.getByText(keyUnlinkedNote)).toBeVisible(); + // The link, not only the backup, is left undone. + expect(screen.queryByText(keySavedNote)).toBeNull(); + expect( + screen.queryByRole("button", { name: "Continue without backup" }), + ).toBeNull(); + expect(bind.mock.calls).toEqual([[viewer]]); + // The key is saved; the backup never ran, and the app stays hidden. + expect(identity.snapshot()).toEqual({ status: "ready", viewer }); + expect(backUp).not.toHaveBeenCalled(); + expect(screen.queryByText("Signed in")).toBeNull(); + // Nothing links the key later, so there is no way past a failed link: no + // Back (which would lift the gate with an unlinked key) and no Continue, + // only Try again, offered even for a refusal Builderlab may repeat. + expect(screen.queryByRole("button", { name: "Back" })).toBeNull(); + expect( + screen.queryByRole("button", { name: "Continue without linking" }), + ).toBeNull(); + expect(screen.getByRole("button", { name: "Try again" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Try again" })); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect( + screen.getByRole("heading", { name: "Join a community" }), + ).toHaveFocus(); + expect(screen.queryByText("Signed in")).toBeNull(); + // The link ran again and, once it held, the backup followed. + expect(bind.mock.calls).toEqual([[viewer], [viewer]]); + expect(backUp.mock.calls).toEqual([[viewer]]); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "identity_create", + ]); + identity.dispose(); +}); + +it("a backup that times out after Create my key offers Try again, which re-runs only the backup, and setup lands on Join Community for a landing URL", async () => { + const relay = landingRelay("https://relay.example.com"); + relay.info.resolve(); + const bind = vi.fn((_pubkey: string) => Promise.resolve()); + const timedOut = deferred(); + const confirmed = deferred(); + const backUp = vi + .fn() + .mockReturnValueOnce(timedOut.promise) + .mockReturnValueOnce(confirmed.promise); + const { backupStatus, release } = gatedStatus(); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await screen.findByRole("button", { name: SIGN_IN }); + await user.type(urlField(), "relay.example.com{Enter}"); + expect(await screen.findByRole("status")).toHaveTextContent( + "Checking for a backed-up key…", + ); + await release(); + await createMyKey(user); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent( + "Backing up your key…", + ), + ); + await act(async () => timedOut.reject(new BackupError("timeout"))); + expect(screen.getByRole("alert")).toHaveTextContent( + "Builderlab is taking too long to confirm your backup. Try again.", + ); + expect(screen.getByText(keySavedNote)).toBeVisible(); + // Try again is the primary action, Continue without backup the secondary. + expect(screen.getByRole("button", { name: "Try again" })).toHaveAttribute( + "data-variant", + "prominent", + ); + expect( + screen.getByRole("button", { name: "Continue without backup" }), + ).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Try again" })); + expect(screen.getByRole("status")).toHaveTextContent("Backing up your key…"); + expect(backUp.mock.calls).toEqual([[viewer], [viewer]]); + expect(bind.mock.calls).toEqual([[viewer]]); + await act(async () => confirmed.resolve()); + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(backupStatus).toHaveBeenCalledTimes(1); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "relay_http", + "relay_http", + "identity_create", + "relay_http", + "relay_http", + ]); + identity.dispose(); +}); + +it("Join a community checks the typed URL, opens Finding community, and Cancel keeps the URL", async () => { + const relayCalls: unknown[] = []; + vi.mocked(invoke).mockImplementation((command, args) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_create") return Promise.resolve(viewer); + if (command === "relay_http") { + relayCalls.push(args); + return new Promise(() => {}); + } + throw new Error("Unexpected command"); + }); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + await createMyKey(user); + await screen.findByRole("heading", { name: "Join a community" }); + const field = () => screen.getByRole("textbox", { name: "Community URL" }); + // The page remounts after Cancel, so look the button up each time. + const next = () => screen.getByRole("button", { name: "Continue" }); + // Join stays enabled while empty; clicking it answers with the same error. + expect(next()).toBeEnabled(); + await user.click(next()); + expect(screen.getByRole("alert")).toHaveTextContent( + "Please enter a valid community URL", + ); + expect(relayCalls).toEqual([]); + await user.type(field(), "not a url{Enter}"); + expect(next()).toBeEnabled(); + expect(screen.getByRole("alert")).toHaveTextContent( + "Please enter a valid community URL", + ); + expect(field()).toHaveAccessibleDescription( + "Please enter a valid community URL", + ); + expect(relayCalls).toEqual([]); + await user.clear(field()); + expect(screen.queryByRole("alert")).toBeNull(); + await user.type(field(), "Relay.Example.com:8443"); + expect(next()).toBeEnabled(); + await user.keyboard("{Enter}"); + expect( + screen.getByRole("heading", { name: "Finding community" }), + ).toBeVisible(); + expect(screen.getByText("relay.example.com:8443")).toBeVisible(); + // Both unauthenticated reads go to the canonical origin through relay_http. + expect(relayCalls).toEqual([ + { + community: "https://relay.example.com:8443", + path: "/", + method: "GET", + body: null, + }, + { + community: "https://relay.example.com:8443", + path: "/api/join-policy", + method: "GET", + body: null, + }, + ]); + expect(screen.queryByText("Signed in")).toBeNull(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect( + screen.getByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + // Cancel's button is gone with the lookup: the heading takes focus. + expect( + screen.getByRole("heading", { name: "Join a community" }), + ).toHaveFocus(); + expect(field()).toHaveValue("Relay.Example.com:8443"); + await user.click(next()); + expect( + screen.getByRole("heading", { name: "Finding community" }), + ).toBeVisible(); + expect(relayCalls).toHaveLength(4); + identity.dispose(); +}); + +it("a found community shows Join Community after exactly the two unauthenticated GETs", async () => { + const origin = "https://relay.example.com:8443"; + const relayCalls: unknown[] = []; + const answer = (status: number, body = "") => + Promise.resolve({ status, headers: {}, body }); + vi.mocked(invoke).mockImplementation((command, args) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_create") return Promise.resolve(viewer); + if (command === "relay_http") { + relayCalls.push(args); + const { path } = args as { path: string }; + if (path === "/") + return answer( + 200, + JSON.stringify({ name: "Buzz Relay", read_state_snapshot: {} }), + ); + if (path === "/api/join-policy") return answer(404); + } + throw new Error("Unexpected command"); + }); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + await createMyKey(user); + await screen.findByRole("heading", { name: "Join a community" }); + await user.type( + screen.getByRole("textbox", { name: "Community URL" }), + "relay.example.com:8443{Enter}", + ); + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + expect(screen.getByText("relay.example.com:8443")).toHaveClass("font-mono"); + expect(screen.queryByText("Signed in")).toBeNull(); + // Nothing is signed with the key setup just created: the two unauthenticated + // GETs are the only pre-join traffic, with no POST before the user opts in. + expect(relayCalls).toEqual([ + { community: origin, path: "/", method: "GET", body: null }, + { community: origin, path: "/api/join-policy", method: "GET", body: null }, + ]); + identity.dispose(); +}); + +/** + * A native first run against a relay that answers: the key setup creates is the + * one the host signs with, so `identity_restore` learns it only after + * `identity_create`. The kind-0 read waits on `query` so the pending join is + * observable; `admitted` decides whether that read is answered or refused, and + * `publish` whether a kind 0 posted to `/events` is accepted or fails with 500. + * `others` answers further origins, each with its own relay key: admitted + * (with the profile it holds, if any), refused, or `"gone"`, whose address + * answers a web page rather than a community (an outage's 503 is retried for + * seconds by `inspectProfile` first, which these tests do not wait out). + * `policy` is the join policy (none by default). An invite claim waits on + * `claim`, then answers `refusal` as the relay's error code, or admits the key. + */ +function nativeRelay( + member: ReturnType, + others: Record< + string, + { + admitted: boolean | "gone"; + profile?: ReturnType; + /** Holds the kind-0 read until resolved. */ + query?: Promise; + } + > = {}, +) { + const relayKey = keypair(); + const otherKeys = new Map( + Object.keys(others).map((origin) => [origin, keypair()]), + ); + const origin = "https://relay.example.com:8443"; + const calls: Array<{ + community: string; + path: string; + method: string; + body: unknown; + }> = []; + const relay = { + origin, + calls, + admitted: true, + publish: true, + profile: undefined as ReturnType | undefined, + query: Promise.resolve(), + policy: undefined as JoinPolicy | undefined, + claim: Promise.resolve(), + refusal: undefined as string | undefined, + /** The claim admits the key, but its reply is lost on the way back. */ + lostClaim: false, + /** Another device's newer kind 0, which the relay keeps over an accepted publish. */ + superseded: undefined as ReturnType | undefined, + }; + let created = false; + vi.stubEnv("VITE_BUZZ_LIVE", "0"); + vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + vi.stubGlobal( + "WebSocket", + class { + close() {} + }, + ); + vi.mocked(invoke).mockImplementation(async (command, args) => { + if (command === "get_os_idle_seconds") return 0; + if (command === "identity_restore") return created ? member.pubkey : null; + if (command === "identity_create") { + created = true; + return member.pubkey; + } + if (command === "identity_export") return key; + if (command === "relay_sign") + return signed(member, (args as { event: EventTemplate }).event); + if (command !== "relay_http") + throw new Error(`Unexpected command ${command}`); + const request = args as { + community: string; + path: string; + method: string; + body: string | null; + }; + const body: unknown = request.body ? JSON.parse(request.body) : null; + calls.push({ ...request, body }); + const answer = (status: number, value?: unknown) => ({ + status, + headers: {}, + body: value === undefined ? "" : JSON.stringify(value), + }); + const other = others[request.community]; + if (other) { + if (other.admitted === "gone") return answer(200, ""); + if (request.path === "/") + return answer(200, { self: otherKeys.get(request.community)?.pubkey }); + if (request.path === "/events") { + other.profile = body as ReturnType; + return answer(200, { accepted: true, event_id: other.profile.id }); + } + if (request.path !== "/query") return answer(404); + const filters = body as Array<{ kinds?: number[] }>; + if (!filters.some((filter) => filter.kinds?.includes(0))) + return answer(200, []); + await other.query; + if (!other.admitted) return answer(403, { error: "membership required" }); + return answer(200, other.profile ? [other.profile] : []); + } + if (request.path === "/") + return answer(200, { + name: "Buzz Relay", + self: relayKey.pubkey, + icon: "https://cdn.example.com/icon.png", + }); + if (request.path === "/api/join-policy") + return relay.policy ? answer(200, { policy: relay.policy }) : answer(404); + if (request.path === "/api/invites/accept-policy") + return answer(200, { receipt: "policy-receipt" }); + if (request.path === "/api/invites/claim") { + await relay.claim; + if (relay.refusal) return answer(403, { error: relay.refusal }); + relay.admitted = true; + // Native IPC rejects with a plain string. + if (relay.lostClaim) + return Promise.reject("Relay response was interrupted"); + return answer(200, { status: "joined" }); + } + if (request.path === "/query") { + const filters = body as Array<{ kinds?: number[] }>; + if (!filters.some((filter) => filter.kinds?.includes(0))) + return answer(200, []); + await relay.query; + if (!relay.admitted) return answer(403, { error: "membership required" }); + return answer(200, relay.profile ? [relay.profile] : []); + } + if (request.path === "/events") { + if (!relay.publish) return answer(500); + const event = body as ReturnType; + relay.profile = relay.superseded ?? event; + return answer(200, { accepted: true, event_id: event.id }); + } + return answer(404); + }); + return relay; +} +async function joinCommunityPage( + identity: Identity, + openRelay = "", + address = "relay.example.com:8443", + /** `"seed"` leaves `Welcome` its own default: the development seed when set. */ + account: ComponentProps["account"] | "seed" = unlinked, +) { + const communities = communitiesFor(identity, openRelay); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + await createMyKey(user); + await screen.findByRole("heading", { name: "Join a community" }); + await user.type( + screen.getByRole("textbox", { name: "Community URL" }), + `${address}{Enter}`, + ); + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + await waitFor(() => expect(communities.snapshot().status).toBe("ready")); + return { communities, user }; +} + +const eventsCalls = (relay: ReturnType) => + relay.calls.filter((call) => call.path === "/events"); +const claims = (relay: ReturnType) => + relay.calls.filter((call) => call.path === "/api/invites/claim"); +/** The join owner's recovery record for `member` (`buzz-community-joins.v1`). */ +const journalOf = (member: ReturnType): unknown => + JSON.parse( + localStorage.getItem(`buzz-community-joins.v1:${member.pubkey}`) ?? "null", + ); +/** The signed profile read of `member` at the relay: NIP-11, then the kind-0 query. */ +function readCalls( + relay: ReturnType, + member: ReturnType, +) { + return restoreRead(relay.origin, member); +} +/** The profile step's Continue through the owner: a fresh read, the kind 0, its readback. */ +function publishCalls( + relay: ReturnType, + member: ReturnType, +) { + return [ + ...readCalls(relay, member), + { + community: relay.origin, + path: "/events", + method: "POST", + body: expect.objectContaining({ kind: 0, pubkey: member.pubkey }), + }, + ...readCalls(relay, member), + ]; +} + +it("Join journals the join, proves admission with the signed profile read, and continues to the profile step, whose Continue publishes the kind 0 with a readback and then saves the membership", async () => { + const member = keypair(); + const relay = nativeRelay(member); + const gate = deferred(); + relay.query = gate.promise; + const identity = createIdentity(); + const { communities, user } = await joinCommunityPage(identity); + expect(relay.calls.map((call) => call.method)).toEqual(["GET", "GET"]); + expect(screen.getByRole("button", { name: "Join" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Join" })); + // The read is in flight: the page holds still and the app stays hidden. + expect(screen.getByRole("status")).toHaveTextContent("Joining…"); + expect(screen.getByRole("button", { name: "Join" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled(); + await waitFor(() => expect(relay.calls).toHaveLength(4)); + expect(relay.calls).toEqual([ + { community: relay.origin, path: "/", method: "GET", body: null }, + { + community: relay.origin, + path: "/api/join-policy", + method: "GET", + body: null, + }, + { community: relay.origin, path: "/", method: "GET", body: null }, + { + community: relay.origin, + path: "/query", + method: "POST", + body: [ + { + kinds: [0], + authors: [member.pubkey], + limit: 5, + consistency: "strong", + }, + ], + }, + ]); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(communities.snapshot().memberships).toEqual([]); + // The join was journaled before anything was sent to the relay. + expect(journalOf(member)).toEqual([ + { id: expect.any(String), community: relay.origin }, + ]); + await act(async () => gate.resolve()); + // Admitted, the first community continues to the profile step rather than + // into the app; as in the dialog, the membership waits for the step, and + // so does the save to the account. + expect( + await screen.findByRole("heading", { name: "Set up your profile" }), + ).toBeVisible(); + expect(saveCommunity).not.toHaveBeenCalled(); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(communities.snapshot()).toMatchObject({ + status: "ready", + viewer: member.pubkey, + memberships: [], + selected: null, + }); + expect(journalOf(member)).toEqual([ + { id: expect.any(String), community: relay.origin }, + ]); + const membership = { + id: relay.origin, + name: "relay.example.com:8443", + icon: "https://cdn.example.com/icon.png", + }; + expect( + screen.getByText(/agent handoffs in relay\.example\.com:8443\./), + ).toBeVisible(); + // The relay holds no profile for the new key: the Builderlab email's local + // part is suggested, focused, and Continue needs a name. + const name = screen.getByRole("textbox", { name: "Display name" }); + expect(name).toHaveValue("a"); + expect(name).toHaveFocus(); + await user.clear(name); + expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Skip for now" })).toBeEnabled(); + await user.type(name, "Ada{Enter}"); + expect(await screen.findByText("Signed in")).toBeVisible(); + // Continue is the dialog's profile step: the profile is read afresh, the + // kind 0 published, and read back before the membership is saved (after + // which the service's own session reads NIP-11 again). + expect(relay.calls.slice(4, 9)).toEqual(publishCalls(relay, member)); + expect(eventsCalls(relay)).toEqual([ + { + community: relay.origin, + path: "/events", + method: "POST", + body: expect.objectContaining({ + kind: 0, + pubkey: member.pubkey, + tags: [], + content: JSON.stringify({ + name: "Ada", + display_name: "Ada", + picture: "", + about: "", + }), + }), + }, + ]); + expect(saveCommunity.mock.calls).toEqual([[relay.origin]]); + expect(journalOf(member)).toEqual([]); + // The device record the next launch restores carries the published name as + // the local default for later joins. + expect( + JSON.parse(localStorage.getItem(`buzz-client.v1:${member.pubkey}`) ?? ""), + ).toEqual({ + profile: { name: "Ada", picture: "", about: "" }, + memberships: [membership], + selected: relay.origin, + // `joined()` also queues the add for the account's known-communities + // sync, which first run has already posted; the later send is idempotent. + sync: { + known: [], + outbox: [{ url: "wss://relay.example.com:8443", removed: false }], + }, + }); + expect(communities.snapshot().selected).toBe(relay.origin); + identity.dispose(); +}); + +it("a relay that already holds the key's profile skips the profile step, enters the app with it as the local default, and publishes nothing", async () => { + const member = keypair(); + const relay = nativeRelay(member); + relay.profile = profile(member, { name: "Ada", picture: "" }); + const saved = deferred(); + saveCommunity.mockReturnValueOnce(saved.promise); + const identity = createIdentity(); + const { communities, user } = await joinCommunityPage(identity); + await user.click(screen.getByRole("button", { name: "Join" })); + // The join is saved to the account once the signed read has admitted the + // key, and the app waits for that answer. + await waitFor(() => expect(saveCommunity).toHaveBeenCalledTimes(1)); + expect(saveCommunity).toHaveBeenCalledWith(relay.origin); + expect(relay.calls.at(-1)).toMatchObject({ path: "/query", method: "POST" }); + expect(screen.getByRole("status")).toHaveTextContent("Joining…"); + expect(screen.queryByText("Signed in")).toBeNull(); + await act(async () => saved.resolve()); + expect(await screen.findByText("Signed in")).toBeVisible(); + expect( + screen.queryByRole("heading", { name: "Set up your profile" }), + ).toBeNull(); + const membership = { + id: relay.origin, + name: "relay.example.com:8443", + icon: "https://cdn.example.com/icon.png", + }; + expect(communities.snapshot()).toMatchObject({ + memberships: [membership], + selected: relay.origin, + }); + // The relay's existing profile seeds the empty local default; nothing was + // published to get there. + expect( + JSON.parse(localStorage.getItem(`buzz-client.v1:${member.pubkey}`) ?? ""), + ).toEqual({ + profile: { name: "Ada", picture: "", about: "" }, + memberships: [membership], + selected: relay.origin, + // `joined()` also queues the add for the account's known-communities + // sync, which first run has already posted; the later send is idempotent. + sync: { + known: [], + outbox: [{ url: "wss://relay.example.com:8443", removed: false }], + }, + }); + expect(eventsCalls(relay)).toEqual([]); + expect(journalOf(member)).toEqual([]); + identity.dispose(); +}); + +it("an account that refuses to save the join still enters the app on the community", async () => { + const member = keypair(); + const relay = nativeRelay(member); + relay.profile = profile(member, { name: "Ada", picture: "" }); + saveCommunity.mockRejectedValueOnce( + new Error("Builderlab community save failed (HTTP 422: not JSON)."), + ); + const identity = createIdentity(); + const { communities, user } = await joinCommunityPage(identity); + await user.click(screen.getByRole("button", { name: "Join" })); + expect(await screen.findByText("Signed in")).toBeVisible(); + expect(saveCommunity.mock.calls).toEqual([[relay.origin]]); + expect(screen.queryByRole("alert")).toBeNull(); + expect(communities.snapshot()).toMatchObject({ + memberships: [expect.objectContaining({ id: relay.origin })], + selected: relay.origin, + }); + identity.dispose(); +}); + +it("a key imported from Connect to {host} joins with no account to save the community to", async () => { + const relay = nativeRelay(owned); + relay.profile = profile(owned, { name: "Ada", picture: "" }); + const relayAnswers = vi.mocked(invoke).getMockImplementation(); + if (!relayAnswers) throw new Error("nativeRelay installed no answers"); + let imported = false; + vi.mocked(invoke).mockImplementation(async (command, args) => { + if (command === "identity_import") { + imported = true; + return owned.pubkey; + } + if (command === "identity_restore" && imported) return owned.pubkey; + return relayAnswers(command, args); + }); + const account = vi.fn(unlinked); + const identity = createIdentity(); + const communities = communitiesFor(identity); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await screen.findByRole("button", { name: SIGN_IN }); + await user.type(urlField(), "relay.example.com:8443{Enter}"); + expect( + await screen.findByRole("heading", { + name: "Connect to your community", + }), + ).toBeVisible(); + expect(connectChoiceHost()).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "I already have a key" }), + ); + await user.paste(ownedNsec); + await user.click(continueWithKey()); + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + await waitFor(() => expect(communities.snapshot().status).toBe("ready")); + await user.click(screen.getByRole("button", { name: "Join" })); + expect(await screen.findByText("Signed in")).toBeVisible(); + expect(communities.snapshot().selected).toBe(relay.origin); + expect(account).not.toHaveBeenCalled(); + expect(saveCommunity).not.toHaveBeenCalled(); + identity.dispose(); +}); + +it("the development seed saves a joined community without a request", async () => { + vi.stubEnv("VITE_BUZZ_FIRST_RUN_ACCOUNT", "unlinked"); + const member = keypair(); + const relay = nativeRelay(member); + relay.profile = profile(member, { name: "Ada", picture: "" }); + const identity = createIdentity(); + const { communities, user } = await joinCommunityPage( + identity, + "", + undefined, + "seed", + ); + await user.click(screen.getByRole("button", { name: "Join" })); + expect(await screen.findByText("Signed in")).toBeVisible(); + expect(communities.snapshot().selected).toBe(relay.origin); + // The seed answered in place of kgoose (`plugin_host_request`) and of the + // file's account stubs. + expect(saveCommunity).not.toHaveBeenCalled(); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toContain( + "relay_http", + ); + expect( + vi.mocked(invoke).mock.calls.map(([command]) => command), + ).not.toContain("plugin_host_request"); + identity.dispose(); +}); + +it("a relay that rejects the profile publish keeps the step with the error inline and the membership unsaved, and Skip saves it and enters the app", async () => { + const member = keypair(); + const relay = nativeRelay(member); + relay.publish = false; + const identity = createIdentity(); + const { communities, user } = await joinCommunityPage(identity); + await user.click(screen.getByRole("button", { name: "Join" })); + await screen.findByRole("heading", { name: "Set up your profile" }); + const name = screen.getByRole("textbox", { name: "Display name" }); + await user.clear(name); + await user.type(name, "Ada"); + await user.click(screen.getByRole("button", { name: "Continue" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Community request failed (500)", + ); + expect( + screen.getByRole("heading", { name: "Set up your profile" }), + ).toBeVisible(); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(name).toHaveValue("Ada"); + expect(screen.getByRole("button", { name: "Continue" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Skip for now" })).toBeEnabled(); + expect(eventsCalls(relay)).toHaveLength(1); + // Nothing is saved while the step stands: the membership waits, the + // rejected name never became the local default, and the journal keeps the + // draft for a retry. + expect(communities.snapshot()).toMatchObject({ + memberships: [], + selected: null, + profile: { name: "", picture: "", about: "" }, + }); + expect(saveCommunity).not.toHaveBeenCalled(); + expect(journalOf(member)).toEqual([ + { + id: expect.any(String), + community: relay.origin, + profile: { name: "Ada", picture: "", about: "" }, + }, + ]); + await user.click(screen.getByRole("button", { name: "Skip for now" })); + expect(await screen.findByText("Signed in")).toBeVisible(); + // Skip saves the membership with the local default and publishes nothing more. + expect(eventsCalls(relay)).toHaveLength(1); + expect(communities.snapshot()).toMatchObject({ + memberships: [{ id: relay.origin }], + selected: relay.origin, + profile: { name: "", picture: "", about: "" }, + }); + expect(saveCommunity.mock.calls).toEqual([[relay.origin]]); + expect(journalOf(member)).toEqual([]); + identity.dispose(); +}); + +it("a key whose device record already holds a community but no profile still gets the profile step, then enters the app on the new one", async () => { + const member = keypair(); + const relay = nativeRelay(member); + const identity = createIdentity(); + // The development seed: one membership, and no local profile, before this join. + const { communities, user } = await joinCommunityPage( + identity, + "https://other.example.com", + ); + expect(communities.snapshot().memberships).toHaveLength(1); + await user.click(screen.getByRole("button", { name: "Join" })); + await screen.findByRole("heading", { name: "Set up your profile" }); + // Only the seed is saved until the step ends. + expect(communities.snapshot().memberships).toHaveLength(1); + await user.click(screen.getByRole("button", { name: "Skip for now" })); + expect(await screen.findByText("Signed in")).toBeVisible(); + expect(communities.snapshot()).toMatchObject({ + memberships: [{ id: "https://other.example.com" }, { id: relay.origin }], + selected: relay.origin, + }); + expect(eventsCalls(relay)).toEqual([]); + identity.dispose(); +}); + +it("a profile the relay supersedes before the readback keeps the step with the draft and no membership, and Continue succeeds once the relay is current", async () => { + const member = keypair(); + const relay = nativeRelay(member); + // Another device publishes a newer kind 0 as this one is accepted. + relay.superseded = profile(member, { name: "Grace", picture: "" }); + const identity = createIdentity(); + const { communities, user } = await joinCommunityPage(identity); + await user.click(screen.getByRole("button", { name: "Join" })); + await screen.findByRole("heading", { name: "Set up your profile" }); + const name = screen.getByRole("textbox", { name: "Display name" }); + await user.clear(name); + await user.type(name, "Ada{Enter}"); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Your profile change is not current. Your edits are retained; try again.", + ); + // The publish was accepted, read back and found superseded: nothing saved. + expect(relay.calls.slice(4, 9)).toEqual(publishCalls(relay, member)); + expect(communities.snapshot()).toMatchObject({ + memberships: [], + selected: null, + }); + expect(saveCommunity).not.toHaveBeenCalled(); + expect(journalOf(member)).toMatchObject([ + { + community: relay.origin, + profile: { name: "Ada", picture: "", about: "" }, + }, + ]); + expect(name).toHaveValue("Ada"); + expect(screen.getByRole("button", { name: "Continue" })).toBeEnabled(); + // The relay now keeps what it accepts: the fresh read finds the other + // device's profile, the draft differs, so it is published again and this + // time reads back. + relay.superseded = undefined; + await user.click(screen.getByRole("button", { name: "Continue" })); + expect(await screen.findByText("Signed in")).toBeVisible(); + expect(relay.calls.slice(9, 14)).toEqual(publishCalls(relay, member)); + expect(eventsCalls(relay)).toHaveLength(2); + expect(communities.snapshot()).toMatchObject({ + memberships: [{ id: relay.origin }], + selected: relay.origin, + profile: { name: "Ada", picture: "", about: "" }, + }); + expect(saveCommunity.mock.calls).toEqual([[relay.origin]]); + expect(journalOf(member)).toEqual([]); + identity.dispose(); +}); + +it("a claim whose reply is lost is never claimed again: Back and Join read the journaled destination first, and the admitted key continues", async () => { + const member = keypair(); + const relay = nativeRelay(member); + relay.admitted = false; + relay.lostClaim = true; + const identity = createIdentity(); + const { communities, user } = await joinCommunityPage( + identity, + "", + inviteLink, + ); + await user.click(screen.getByRole("button", { name: "Join" })); + expect( + await screen.findByRole("heading", { name: "Couldn’t join community" }), + ).toBeVisible(); + expect(screen.getByRole("alert")).toHaveTextContent( + "Could not reach the relay. Check your connection and try again.", + ); + // The relay admitted the key on that claim; the journal still holds the join. + expect(relay.calls.slice(2)).toEqual([ + { + community: relay.origin, + path: "/api/invites/claim", + method: "POST", + body: { code: inviteCode }, + }, + ]); + const [entry] = journalOf(member) as unknown[]; + expect(entry).toEqual({ id: expect.any(String), community: relay.origin }); + expect(communities.snapshot().memberships).toEqual([]); + relay.lostClaim = false; + await user.click(screen.getByRole("button", { name: "Back" })); + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Join" })); + // Read before reclaim: the signed read finds the key admitted, so the code + // (which the relay would now refuse as used) is not claimed again. + expect( + await screen.findByRole("heading", { name: "Set up your profile" }), + ).toBeVisible(); + expect(relay.calls.slice(3)).toEqual([ + ...lookupCalls(relay), + ...readCalls(relay, member), + ]); + expect(claims(relay)).toHaveLength(1); + expect(journalOf(member)).toEqual([entry]); + await user.click(screen.getByRole("button", { name: "Skip for now" })); + expect(await screen.findByText("Signed in")).toBeVisible(); + expect(claims(relay)).toHaveLength(1); + expect(communities.snapshot()).toMatchObject({ + memberships: [{ id: relay.origin }], + selected: relay.origin, + }); + expect(journalOf(member)).toEqual([]); + identity.dispose(); +}); + +/** The signed-in account with no key bound and `known` saved to it; binding succeeds. */ +const restoringAccount = + (known: readonly string[]): ComponentProps["account"] => + () => ({ + linkedKey: () => Promise.resolve(null), + ownedCommunities: () => Promise.resolve([]), + knownCommunities: () => Promise.resolve(known), + saveCommunity, + bind: () => Promise.resolve(), + unbind: noUnlink, + ...backupAnswers, + }); +const relayA = "https://relay-a.example"; +const relayB = "https://relay-b.example"; +/** The kind-0 read restoring `community` sends for `member`. */ +const restoreRead = (community: string, member: ReturnType) => [ + { community, path: "/", method: "GET", body: null }, + { + community, + path: "/query", + method: "POST", + body: [ + { + kinds: [0], + authors: [member.pubkey], + limit: 5, + consistency: "strong", + }, + ], + }, +]; +const callsTo = (relay: ReturnType, community: string) => + relay.calls.filter((call) => call.community === community); +/** + * Sign in with email (from the connect choice when `typed` is given, as + * no tenant hosts it) for an account that saved `known`, then Create my key. + */ +async function restoreLane( + identity: Identity, + known: readonly string[], + typed?: string, +) { + const communities = communitiesFor(identity); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await screen.findByRole("button", { name: SIGN_IN }); + if (typed) { + await user.type(urlField(), `${typed}{Enter}`); + await screen.findByRole("heading", { name: /^Connect to / }); + } + await user.click(connectButton()); + await createMyKey(user); + return { communities, user }; +} + +it("a typed address the account's saved communities lack enters the app on the restored one and hands the address to the join dialog", async () => { + const member = keypair(); + const gate = deferred(); + const relay = nativeRelay(member, { + [relayA]: { + admitted: true, + profile: profile(member, { name: "Ada" }), + query: gate.promise, + }, + }); + const identity = createIdentity(); + const { communities } = await restoreLane( + identity, + ["wss://relay-a.example"], + "relay.example.com:8443", + ); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent( + "Restoring your communities…", + ), + ); + expect(invites.open).not.toHaveBeenCalled(); + await act(async () => gate.resolve()); + expect(await screen.findByText("Signed in")).toBeVisible(); + // Only the saved destination is read: the typed one is the dialog's to join. + // The joined community's own session then reads on. + expect(callsTo(relay, relayA).slice(0, 2)).toEqual( + restoreRead(relayA, member), + ); + expect( + callsTo(relay, relay.origin).filter((call) => call.path === "/query"), + ).toEqual([]); + expect(communities.snapshot()).toMatchObject({ + memberships: [{ id: relayA, name: "relay-a.example" }], + selected: relayA, + profile: { name: "Ada" }, + }); + expect(invites.open.mock.calls).toEqual([ + [{ community: relay.origin, code: "" }, member.pubkey], + ]); + expect(screen.queryByRole("heading", { name: "Join Community" })).toBeNull(); + identity.dispose(); +}); + +it("an invite link's code is handed to the join dialog with its address, and nothing is claimed first", async () => { + const member = keypair(); + const relay = nativeRelay(member, { + [relayA]: { admitted: true, profile: profile(member, { name: "Ada" }) }, + }); + const identity = createIdentity(); + const { communities } = await restoreLane( + identity, + ["wss://relay-a.example"], + "https://relay.example.com:8443/invite/CODE-1", + ); + expect(await screen.findByText("Signed in")).toBeVisible(); + expect(communities.snapshot()).toMatchObject({ + memberships: [{ id: relayA }], + selected: relayA, + }); + // The dialog claims the code; first run sends nothing signed to that relay. + expect( + callsTo(relay, relay.origin).filter((call) => call.method === "POST"), + ).toEqual([]); + expect(invites.open.mock.calls).toEqual([ + [{ community: relay.origin, code: "CODE-1" }, member.pubkey], + ]); + identity.dispose(); +}); + +it("a typed address the restore admitted is selected and entered without the join dialog", async () => { + const member = keypair(); + const relay = nativeRelay(member, { + [relayA]: { admitted: true, profile: profile(member, { name: "Ada" }) }, + }); + relay.profile = profile(member, { name: "Ada" }); + const identity = createIdentity(); + const { communities } = await restoreLane( + identity, + ["wss://relay-a.example", "wss://relay.example.com:8443"], + "relay.example.com:8443", + ); + expect(await screen.findByText("Signed in")).toBeVisible(); + // The typed address wins over the first admitted. + expect(communities.snapshot()).toMatchObject({ + memberships: [{ id: relayA }, { id: relay.origin }], + selected: relay.origin, + }); + expect(invites.open).not.toHaveBeenCalled(); + expect(screen.queryByRole("heading", { name: "Join Community" })).toBeNull(); + identity.dispose(); +}); + +it("saved communities that all refuse the key or no longer host a community join nothing, and setup lands on Join Community for the typed address", async () => { + const member = keypair(); + const gate = deferred(); + const relay = nativeRelay(member, { + [relayA]: { admitted: false, query: gate.promise }, + [relayB]: { admitted: "gone" }, + }); + const identity = createIdentity(); + const { communities } = await restoreLane( + identity, + ["wss://relay-a.example", "wss://relay-b.example"], + "relay.example.com:8443", + ); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent( + "Restoring your communities…", + ), + ); + await act(async () => gate.resolve()); + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + expect(callsTo(relay, relayA)).toEqual(restoreRead(relayA, member)); + expect(callsTo(relay, relayB)).toEqual([ + { community: relayB, path: "/", method: "GET", body: null }, + ]); + expect(communities.snapshot().memberships).toEqual([]); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(invites.open).not.toHaveBeenCalled(); + identity.dispose(); +}); + +it("with no typed address, a saved community that admits the key and holds its profile is entered directly", async () => { + const member = keypair(); + const relay = nativeRelay(member, { + [relayA]: { admitted: true, profile: profile(member, { name: "Ada" }) }, + [relayB]: { admitted: false }, + }); + const identity = createIdentity(); + const { communities } = await restoreLane(identity, [ + "wss://relay-b.example", + "wss://relay-a.example", + ]); + expect(await screen.findByText("Signed in")).toBeVisible(); + expect( + screen.queryByRole("heading", { name: "Set up your profile" }), + ).toBeNull(); + expect(callsTo(relay, relayB)).toEqual(restoreRead(relayB, member)); + expect(callsTo(relay, relayA).slice(0, 2)).toEqual( + restoreRead(relayA, member), + ); + expect(callsTo(relay, relay.origin)).toEqual([]); + // The device record the next launch restores holds the one admitted. + expect( + JSON.parse(localStorage.getItem(`buzz-client.v1:${member.pubkey}`) ?? ""), + ).toMatchObject({ + profile: { name: "Ada", picture: "", about: "" }, + memberships: [{ id: relayA, name: "relay-a.example" }], + selected: relayA, + }); + expect(communities.snapshot().selected).toBe(relayA); + expect(invites.open).not.toHaveBeenCalled(); + identity.dispose(); +}); + +it("restored communities with no profile anywhere continue to the profile step for the first admitted, and the typed address is handed off only after it", async () => { + const member = keypair(); + const relay = nativeRelay(member, { + [relayA]: { admitted: true }, + [relayB]: { admitted: true }, + }); + const identity = createIdentity(); + const { communities, user } = await restoreLane( + identity, + ["wss://relay-a.example", "wss://relay-b.example"], + "relay.example.com:8443", + ); + expect( + await screen.findByRole("heading", { name: "Set up your profile" }), + ).toBeVisible(); + expect( + screen.getByText(/agent handoffs in relay-a\.example\./), + ).toBeVisible(); + expect(communities.snapshot()).toMatchObject({ + memberships: [{ id: relayA }, { id: relayB }], + selected: relayA, + }); + expect(invites.open).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Continue" })); + expect(await screen.findByText("Signed in")).toBeVisible(); + expect( + callsTo(relay, relayA).filter((call) => call.path === "/events"), + ).toEqual([ + expect.objectContaining({ + body: expect.objectContaining({ kind: 0, pubkey: member.pubkey }), + }), + ]); + expect( + callsTo(relay, relayB).filter((call) => call.path === "/events"), + ).toEqual([]); + expect(communities.snapshot().profile.name).toBe("a"); + expect(invites.open.mock.calls).toEqual([ + [{ community: relay.origin, code: "" }, member.pubkey], + ]); + identity.dispose(); +}); + +it("a relay that refuses the read says the community needs an invite, shows the npub to send, and keeps the typed URL", async () => { + const member = keypair(); + const relay = nativeRelay(member); + relay.admitted = false; + const identity = createIdentity(); + const { communities, user } = await joinCommunityPage(identity); + await user.click(screen.getByRole("button", { name: "Join" })); + expect( + await screen.findByRole("heading", { + name: "This community needs an invite", + }), + ).toBeVisible(); + expect(screen.getByRole("alert")).toHaveTextContent( + "relay.example.com:8443 admits members by invite, and this key has none yet. Enter an invite code, or send your public key to a community administrator so they can add you, then try again.", + ); + // No code was given, so the field starts empty with nothing refused. + expect(screen.getByRole("textbox", { name: "Invite code" })).toHaveValue(""); + expect(screen.getAllByRole("alert")).toHaveLength(1); + // The created key, as the full npub an admin pastes; never hex. + const npub = npubEncode(member.pubkey); + expect( + screen.getByRole("heading", { name: "Your public key (npub)" }), + ).toBeVisible(); + expect(screen.getByText(npub)).toBeVisible(); + expect(screen.queryByText(member.pubkey)).toBeNull(); + const write = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); + await user.click(screen.getByRole("button", { name: "Copy npub" })); + expect(write).toHaveBeenCalledWith(npub); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent("Public key copied."), + ); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(communities.snapshot().memberships).toEqual([]); + expect(localStorage.getItem(`buzz-client.v1:${member.pubkey}`)).toBeNull(); + // The journaled join outlives the denial: a later Join reads before it claims. + expect(journalOf(member)).toEqual([ + { id: expect.any(String), community: relay.origin }, + ]); + await user.click(screen.getByRole("button", { name: "Back" })); + // Back lands on the find page, which looks the community up again. + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect( + screen.getByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect(screen.getByRole("textbox", { name: "Community URL" })).toHaveValue( + "relay.example.com:8443", + ); + identity.dispose(); +}); + +it("Try again on the invite page repeats the signed read and enters the app once the relay admits the key", async () => { + const member = keypair(); + const relay = nativeRelay(member); + relay.admitted = false; + const identity = createIdentity(); + const { communities, user } = await joinCommunityPage(identity); + await user.click(screen.getByRole("button", { name: "Join" })); + await screen.findByRole("heading", { + name: "This community needs an invite", + }); + expect(relay.calls).toHaveLength(4); + expect(journalOf(member)).toEqual([ + { id: expect.any(String), community: relay.origin }, + ]); + // The admin has added the key in the meantime; the read is held to show the + // page waiting on it. With the join journaled, Try again reads first, and + // that read is the admission, so nothing else is sent. + relay.admitted = true; + const gate = deferred(); + relay.query = gate.promise; + await user.click(screen.getByRole("button", { name: "Try again" })); + expect(screen.getByRole("button", { name: "Trying again…" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Back" })).toBeDisabled(); + await waitFor(() => expect(relay.calls).toHaveLength(6)); + expect(relay.calls.slice(4)).toEqual([ + { community: relay.origin, path: "/", method: "GET", body: null }, + { + community: relay.origin, + path: "/query", + method: "POST", + body: [ + { + kinds: [0], + authors: [member.pubkey], + limit: 5, + consistency: "strong", + }, + ], + }, + ]); + expect(screen.queryByText("Signed in")).toBeNull(); + await act(async () => gate.resolve()); + // Admitted on retry: the first community continues to the profile step. + await user.click(await screen.findByRole("button", { name: "Skip for now" })); + expect(await screen.findByText("Signed in")).toBeVisible(); + expect(communities.snapshot()).toMatchObject({ + memberships: [{ id: relay.origin, name: "relay.example.com:8443" }], + selected: relay.origin, + }); + expect(journalOf(member)).toEqual([]); + identity.dispose(); +}); + +/** A code the relay accepts in an invite link, and the policy it gates. */ +const inviteCode = "v2.Invite-code_1"; +const invitePolicy: JoinPolicy = { + version: "2026-10", + age_attestation_required: true, + terms_markdown: "# Terms", +}; +const inviteLink = `https://relay.example.com:8443/invite/${inviteCode}`; +/** The requests a Join with a code sends: the claim (after the policy's acceptance), then the read. */ +function claimCalls( + relay: ReturnType, + member: ReturnType, + code = inviteCode, +) { + const post = (path: string, body: unknown) => ({ + community: relay.origin, + path, + method: "POST", + body, + }); + return [ + ...(relay.policy + ? [ + post("/api/invites/accept-policy", { + code, + policy_version: relay.policy.version, + age_confirmed: true, + }), + ] + : []), + // No policy, no receipt: the claim carries only the code. + post( + "/api/invites/claim", + relay.policy ? { code, policy_receipt: "policy-receipt" } : { code }, + ), + { community: relay.origin, path: "/", method: "GET", body: null }, + post("/query", [ + { kinds: [0], authors: [member.pubkey], limit: 5, consistency: "strong" }, + ]), + ]; +} +/** Tick Join Community's consent boxes for `invitePolicy`. */ +async function consent(user: ReturnType) { + await user.click( + screen.getByRole("checkbox", { + name: "I confirm that I am at least 18 years old.", + }), + ); + await user.click( + screen.getByRole("checkbox", { + name: "I agree to this community’s Terms of Service.", + }), + ); +} +const lookupCalls = (relay: ReturnType) => [ + { community: relay.origin, path: "/", method: "GET", body: null }, + { + community: relay.origin, + path: "/api/join-policy", + method: "GET", + body: null, + }, +]; + +it("an invite link pasted on the landing is looked up at its origin, kept through sign-in and setup, and Join accepts the policy and claims the code before the signed read", async () => { + const member = keypair(); + const relay = nativeRelay(member); + relay.admitted = false; + relay.policy = invitePolicy; + const identity = createIdentity(); + const communities = communitiesFor(identity); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await screen.findByRole("button", { name: SIGN_IN }); + await user.type(urlField(), inviteLink); + expect(urlContinue()).toBeEnabled(); + await user.keyboard("{Enter}"); + // The lookup goes to the community's origin, never to the invite path, and + // the code is not sent anywhere before Join. + expect( + await screen.findByRole("heading", { + name: "Connect to your community", + }), + ).toBeVisible(); + expect(connectChoiceHost()).toBeVisible(); + expect(relay.calls).toEqual(lookupCalls(relay)); + await user.click(connectButton()); + await createMyKey(user); + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + await waitFor(() => expect(communities.snapshot().status).toBe("ready")); + expect(relay.calls).toEqual([...lookupCalls(relay), ...lookupCalls(relay)]); + await consent(user); + await user.click(screen.getByRole("button", { name: "Join" })); + expect( + await screen.findByRole("heading", { name: "Set up your profile" }), + ).toBeVisible(); + expect(relay.calls.slice(0, 8)).toEqual([ + ...lookupCalls(relay), + ...lookupCalls(relay), + ...claimCalls(relay, member), + ]); + // The membership waits for the profile step. + expect(communities.snapshot().memberships).toEqual([]); + await user.click(screen.getByRole("button", { name: "Skip for now" })); + expect(await screen.findByText("Signed in")).toBeVisible(); + expect(communities.snapshot()).toMatchObject({ + memberships: [{ id: relay.origin }], + selected: relay.origin, + }); + identity.dispose(); +}); + +it("an invite link pasted on Join a community claims its code with no policy to accept, then joins as before", async () => { + const member = keypair(); + const relay = nativeRelay(member); + relay.admitted = false; + const identity = createIdentity(); + const { communities, user } = await joinCommunityPage( + identity, + "", + `${inviteLink}/`, + ); + expect(relay.calls).toEqual(lookupCalls(relay)); + await user.click(screen.getByRole("button", { name: "Join" })); + expect( + await screen.findByRole("heading", { name: "Set up your profile" }), + ).toBeVisible(); + expect(relay.calls.slice(0, 5)).toEqual([ + ...lookupCalls(relay), + ...claimCalls(relay, member), + ]); + expect(communities.snapshot().memberships).toEqual([]); + await user.click(screen.getByRole("button", { name: "Skip for now" })); + expect(await screen.findByText("Signed in")).toBeVisible(); + expect(communities.snapshot().memberships).toMatchObject([ + { id: relay.origin }, + ]); + identity.dispose(); +}); + +it("a buzz://join link pasted on Join a community claims its code at the link's relay", async () => { + const member = keypair(); + const relay = nativeRelay(member); + relay.admitted = false; + relay.policy = invitePolicy; + const identity = createIdentity(); + const { user } = await joinCommunityPage( + identity, + "", + `buzz://join?relay=wss%3A%2F%2Frelay.example.com%3A8443&code=${inviteCode}`, + ); + expect(relay.calls).toEqual(lookupCalls(relay)); + await consent(user); + await user.click(screen.getByRole("button", { name: "Join" })); + expect( + await screen.findByRole("heading", { name: "Set up your profile" }), + ).toBeVisible(); + expect(relay.calls.slice(0, 6)).toEqual([ + ...lookupCalls(relay), + ...claimCalls(relay, member), + ]); + identity.dispose(); +}); + +it("a refused invite link lands on the invite page with the refusal under the code field and no read, and a new code there joins", async () => { + const member = keypair(); + const relay = nativeRelay(member); + relay.admitted = false; + relay.refusal = "invite_expired"; + const identity = createIdentity(); + const { communities, user } = await joinCommunityPage( + identity, + "", + inviteLink, + ); + await user.click(screen.getByRole("button", { name: "Join" })); + expect( + await screen.findByRole("heading", { + name: "This community needs an invite", + }), + ).toBeVisible(); + const field = screen.getByRole("textbox", { name: "Invite code" }); + expect(field).toHaveAccessibleDescription( + "This invite code has expired — ask for a new one.", + ); + expect(field).toHaveValue(""); + // The npub, Back and Try again stay for an admin who adds by key. + expect(screen.getByText(npubEncode(member.pubkey))).toBeVisible(); + expect(screen.getByRole("button", { name: "Back" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Try again" })).toBeEnabled(); + // The refused claim is the last request: no read was signed after it. + expect(relay.calls.slice(2)).toEqual([ + { + community: relay.origin, + path: "/api/invites/claim", + method: "POST", + body: { code: inviteCode }, + }, + ]); + expect(communities.snapshot().memberships).toEqual([]); + // A new code from the admin: the claim is held to show the page waiting. + relay.refusal = undefined; + const gate = deferred(); + relay.claim = gate.promise; + await user.type(field, "v2.fresh{Enter}"); + expect(screen.getByRole("button", { name: "Joining…" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Try again" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Back" })).toBeDisabled(); + expect(field).toBeDisabled(); + await act(async () => gate.resolve()); + expect( + await screen.findByRole("heading", { name: "Set up your profile" }), + ).toBeVisible(); + // The refused attempt left its journal record, so the relay is read (and + // still refuses) before the new code is claimed. + expect(relay.calls.slice(3, 8)).toEqual([ + ...readCalls(relay, member), + ...claimCalls(relay, member, "v2.fresh"), + ]); + expect(communities.snapshot().memberships).toEqual([]); + await user.click(screen.getByRole("button", { name: "Skip for now" })); + expect(await screen.findByText("Signed in")).toBeVisible(); + expect(communities.snapshot().memberships).toMatchObject([ + { id: relay.origin }, + ]); + identity.dispose(); +}); + +it("a code typed on the invite page after a refused read accepts the policy Join consented to, and a refused or malformed code keeps the page with the copy under the field", async () => { + const member = keypair(); + const relay = nativeRelay(member); + relay.admitted = false; + relay.policy = invitePolicy; + const identity = createIdentity(); + const { communities, user } = await joinCommunityPage(identity); + await consent(user); + await user.click(screen.getByRole("button", { name: "Join" })); + await screen.findByRole("heading", { + name: "This community needs an invite", + }); + expect(relay.calls).toHaveLength(4); + const field = screen.getByRole("textbox", { name: "Invite code" }); + const codeJoin = () => screen.getByRole("button", { name: "Join" }); + expect(codeJoin()).toBeDisabled(); + relay.refusal = "invite_invalid"; + await user.type(field, " v2.wrong "); + await user.click(codeJoin()); + await waitFor(() => + expect(field).toHaveAccessibleDescription( + "This invite code is not valid for this community.", + ), + ); + // The refused attempt's journal record has the relay read first; then the + // code, trimmed, is accepted under the consent the first Join carried. + expect(relay.calls.slice(4)).toEqual([ + ...readCalls(relay, member), + { + community: relay.origin, + path: "/api/invites/accept-policy", + method: "POST", + body: { + code: "v2.wrong", + policy_version: "2026-10", + age_confirmed: true, + }, + }, + { + community: relay.origin, + path: "/api/invites/claim", + method: "POST", + body: { code: "v2.wrong", policy_receipt: "policy-receipt" }, + }, + ]); + expect( + screen.getByRole("heading", { name: "This community needs an invite" }), + ).toBeVisible(); + expect(field).toHaveValue(" v2.wrong "); + expect(field).toBeEnabled(); + // Editing the code clears the refusal. + await user.clear(field); + expect(field).not.toHaveAccessibleDescription(); + // A code the native route refuses to send gets the same copy, with no + // request for it (only the record's read before it). + await user.type(field, "not a code{Enter}"); + await waitFor(() => + expect(field).toHaveAccessibleDescription( + "This invite code is not valid for this community.", + ), + ); + expect(relay.calls).toHaveLength(10); + relay.refusal = undefined; + await user.clear(field); + await user.type(field, `${inviteCode}{Enter}`); + expect( + await screen.findByRole("heading", { name: "Set up your profile" }), + ).toBeVisible(); + expect(relay.calls.slice(10, 16)).toEqual([ + ...readCalls(relay, member), + ...claimCalls(relay, member), + ]); + expect(communities.snapshot().memberships).toEqual([]); + await user.click(screen.getByRole("button", { name: "Skip for now" })); + expect(await screen.findByText("Signed in")).toBeVisible(); + expect(communities.snapshot()).toMatchObject({ + memberships: [{ id: relay.origin }], + selected: relay.origin, + }); + identity.dispose(); +}); + +it("a device that will not keep the join's record names the storage failure before anything is sent, and leaves nothing joined", async () => { + const member = keypair(); + const relay = nativeRelay(member); + const identity = createIdentity(); + const { communities, user } = await joinCommunityPage(identity); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("QuotaExceededError"); + }); + await user.click(screen.getByRole("button", { name: "Join" })); + expect( + await screen.findByRole("heading", { name: "Couldn’t join community" }), + ).toBeVisible(); + // The journal's record comes before any remote effect, so a device that + // cannot keep it sends nothing: the lookup's two GETs are all the relay saw. + expect(screen.getByRole("alert")).toHaveTextContent( + "Could not save community setup on this device. Free storage and try again.", + ); + expect(relay.calls).toHaveLength(2); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(communities.snapshot()).toMatchObject({ + memberships: [], + selected: null, + }); + await user.click(screen.getByRole("button", { name: "Back" })); + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + identity.dispose(); +}); + +it("a key creation failure stops setup and Back returns to the first-launch screen", async () => { + vi.mocked(invoke) + .mockResolvedValueOnce(null) + .mockRejectedValueOnce("Keychain refused to save the key"); + const connect = vi.fn().mockResolvedValue(credential); + const { backupStatus, release } = gatedStatus(); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + Promise.resolve(), { backupStatus })} + connect={connect} + > +

Signed in

+
, + ); + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent( + "Checking for a backed-up key…", + ), + ); + await release(); + await createMyKey(user); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Keychain refused to save the key", + ); + expect( + screen.getByRole("heading", { name: "Couldn’t finish setup" }), + ).toBeVisible(); + // Nothing runs after the failure, and no key is saved to say so of. + expect(backupStatus).toHaveBeenCalledTimes(1); + expect(identity.snapshot().status).toBe("missing"); + expect(screen.queryByText(keySavedNote)).toBeNull(); + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Back" })); + expect(welcomeHeading()).toBeVisible(); + expect(screen.queryByRole("alert")).toBeNull(); + expect(connectButton()).toBeEnabled(); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "identity_create", + ]); + identity.dispose(); +}); + +const keyField = () => screen.getByLabelText("Private key (nsec)"); +const continueWithKey = () => + screen.getByRole("button", { name: "Continue with this key" }); +/** While an import runs, neither Back nor Continue may leave or resubmit it. */ +const expectNoImportActions = () => { + for (const name of ["Back", "Continue with this key"]) + expect(screen.queryByRole("button", { name })).toBeNull(); +}; +const invalidKeyCopy = + "That doesn’t look like a valid nsec. Paste an nsec1 key."; +/** Connect, then open the import page from the choice. */ +async function keyImportPage( + user: ReturnType, + identity: Identity, + account = unlinked, +) { + render( + +

Signed in

+
, + ); + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + await user.click( + await screen.findByRole("button", { name: "I already have a key" }), + ); + expect( + screen.getByRole("heading", { name: "Enter your private key" }), + ).toBeVisible(); +} + +it("I already have a key opens Enter your private key, classifies what is pasted, and Back returns to the choice with nothing created", async () => { + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + throw new Error(`Unexpected command ${command}`); + }); + const identity = createIdentity(); + const user = userEvent.setup(); + await keyImportPage(user, identity); + expect( + screen.getByText(/Paste your private key to sign in to Buzz\./), + ).toBeVisible(); + // Masked, sensitive and focused on arrival; nothing to continue with yet. + expect(keyField()).toHaveFocus(); + expect(keyField()).toHaveAttribute("type", "password"); + expect(keyField()).toHaveAttribute("autocomplete", "off"); + expect(keyField()).toHaveAttribute("spellcheck", "false"); + expect(continueWithKey()).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Reveal private key" }), + ).toBeDisabled(); + expect(screen.queryByRole("alert")).toBeNull(); + await user.paste("hello"); + expect(screen.getByRole("alert")).toHaveTextContent(invalidKeyCopy); + expect(keyField()).toHaveAccessibleDescription(invalidKeyCopy); + expect(continueWithKey()).toBeDisabled(); + await user.clear(keyField()); + expect(screen.queryByRole("alert")).toBeNull(); + await user.paste( + "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p", + ); + expect(screen.getByRole("alert")).toHaveTextContent( + "Password-protected backups aren’t supported here yet.", + ); + expect(continueWithKey()).toBeDisabled(); + await user.clear(keyField()); + await user.paste(ownedNsec); + // A valid key previews its public identity as an npub, never hex, and + // unlocks Continue; Reveal shows the typed key and Hide masks it again. + expect(screen.queryByRole("alert")).toBeNull(); + expect(keyField()).toHaveAccessibleDescription( + `Public identity: ${npubEncode(owned.pubkey)}`, + ); + expect(screen.queryByText(owned.pubkey)).toBeNull(); + expect(continueWithKey()).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Reveal private key" })); + expect(keyField()).toHaveAttribute("type", "text"); + expect(keyField()).toHaveValue(ownedNsec); + await user.click(screen.getByRole("button", { name: "Hide private key" })); + expect(keyField()).toHaveAttribute("type", "password"); + // Back discards the typed key and offers every choice again; no key was made. + await user.click(screen.getByRole("button", { name: "Back" })); + expect( + screen.getByRole("heading", { name: "Set up your key" }), + ).toBeVisible(); + for (const name of ["Create my key", "I already have a key"]) + expect(screen.getByRole("button", { name })).toBeEnabled(); + // Focus returns to the choice that opened the page, not to the heading. + expect( + screen.getByRole("button", { name: "I already have a key" }), + ).toHaveFocus(); + await user.click( + screen.getByRole("button", { name: "I already have a key" }), + ); + expect(keyField()).toHaveValue(""); + expect(continueWithKey()).toBeDisabled(); + expect(identity.snapshot().status).toBe("missing"); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + ]); + identity.dispose(); +}); + +it("Continue with this key imports the pasted nsec behind Importing your key, links it to the new account, then asks Back up this key?, and Not now lands on Join a community with nothing backed up", async () => { + const imported = deferred(); + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_import") return imported.promise; + throw new Error(`Unexpected command ${command}`); + }); + const bound = deferred(); + const bind = vi.fn((_pubkey: string) => bound.promise); + const backUp = vi.fn((_pubkey: string) => Promise.resolve()); + const identity = createIdentity(); + const user = userEvent.setup(); + await keyImportPage(user, identity, unlinkedWith(bind, { backUp })); + await user.paste(ownedNsec); + // Enter in the field submits, like the Continue button. + await user.keyboard("{Enter}"); + expect(invoke).toHaveBeenCalledWith("identity_import", { nsec: ownedNsec }); + expect( + screen.getByRole("heading", { name: "Setting up Buzz" }), + ).toBeVisible(); + expect(screen.getByRole("status")).toHaveTextContent("Importing your key…"); + expect(screen.queryByLabelText("Private key (nsec)")).toBeNull(); + expectNoImportActions(); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(bind).not.toHaveBeenCalled(); + await act(async () => imported.resolve(owned.pubkey)); + // The account has no key, so the imported one is bound to it like a created one. + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent("Linking your key…"), + ); + expect(bind.mock.calls).toEqual([[owned.pubkey]]); + expect(screen.queryByText("Signed in")).toBeNull(); + await act(async () => bound.resolve()); + // Importing never uploads silently: the backup is offered, disclosing that + // Block can restore the key wherever else it is used. + expect( + await screen.findByRole("heading", { name: "Back up this key?" }), + ).toBeVisible(); + expect( + screen.getByText(/restore this key, including anywhere else you use it/), + ).toBeVisible(); + expect(backUp).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Not now" })); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect(identity.snapshot()).toEqual({ + status: "ready", + viewer: owned.pubkey, + }); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(backUp).not.toHaveBeenCalled(); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "identity_import", + ]); + identity.dispose(); +}); + +it("a link that keeps failing after a key is imported for a new account stays on the failed page: Try again runs the link again, and nothing offers a way past it", async () => { + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_import") return Promise.resolve(owned.pubkey); + throw new Error(`Unexpected command ${command}`); + }); + const bind = vi.fn((_pubkey: string) => + Promise.reject( + new Error( + "This Builderlab account is connected to another Buzz identity.", + ), + ), + ); + const backUp = vi.fn((_pubkey: string) => Promise.resolve()); + const identity = createIdentity(); + const user = userEvent.setup(); + await keyImportPage(user, identity, unlinkedWith(bind, { backUp })); + await user.paste(ownedNsec); + await user.click(continueWithKey()); + expect(await screen.findByRole("alert")).toHaveTextContent( + "This Builderlab account is connected to another Buzz identity.", + ); + expect(screen.getByText(keyUnlinkedNote)).toBeVisible(); + expect(bind.mock.calls).toEqual([[owned.pubkey]]); + expect(screen.queryByRole("button", { name: "Back" })).toBeNull(); + expect( + screen.queryByRole("button", { name: "Continue without linking" }), + ).toBeNull(); + await user.click(screen.getByRole("button", { name: "Try again" })); + await waitFor(() => expect(bind).toHaveBeenCalledTimes(2)); + expect(await screen.findByRole("alert")).toHaveTextContent( + "This Builderlab account is connected to another Buzz identity.", + ); + expect( + screen.getByRole("heading", { name: "Couldn’t finish setup" }), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Try again" })).toBeEnabled(); + expect( + screen.queryByRole("button", { name: "Continue without linking" }), + ).toBeNull(); + expect( + screen.queryByRole("heading", { name: "Join a community" }), + ).toBeNull(); + expect( + screen.queryByRole("heading", { name: "Back up this key?" }), + ).toBeNull(); + expect(backUp).not.toHaveBeenCalled(); + expect(screen.queryByText("Signed in")).toBeNull(); + identity.dispose(); +}); + +it("a native import refusal stays on the import page with the key kept, and Back still returns to the choice", async () => { + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_import") + return Promise.reject( + "An identity is already saved; nothing was overwritten", + ); + throw new Error(`Unexpected command ${command}`); + }); + const identity = createIdentity(); + const user = userEvent.setup(); + await keyImportPage(user, identity); + await user.paste(ownedNsec); + await user.click(continueWithKey()); + expect(await screen.findByRole("alert")).toHaveTextContent( + "An identity is already saved; nothing was overwritten", + ); + expect( + screen.getByRole("heading", { name: "Enter your private key" }), + ).toBeVisible(); + expect( + screen.queryByRole("heading", { name: "Couldn’t finish setup" }), + ).toBeNull(); + expect(keyField()).toHaveValue(ownedNsec); + expect(continueWithKey()).toBeEnabled(); + expect(identity.snapshot().status).toBe("missing"); + // Editing the key replaces the refusal with the live classification. + await user.type(keyField(), "x"); + expect(screen.getByRole("alert")).toHaveTextContent(invalidKeyCopy); + await user.click(screen.getByRole("button", { name: "Back" })); + expect( + screen.getByRole("heading", { name: "Set up your key" }), + ).toBeVisible(); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "identity_import", + ]); + identity.dispose(); +}); + +it("a dropped or chosen key file fills the field from its first line, and an oversize file is refused", async () => { + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + throw new Error(`Unexpected command ${command}`); + }); + const identity = createIdentity(); + const user = userEvent.setup(); + await keyImportPage(user, identity); + const form = screen + .getByRole("heading", { name: "Enter your private key" }) + .closest("form"); + if (!form) throw new Error("The import page is not a form"); + const dropped = new File( + [`\n ${ownedNsec} \nnot the key\n`], + "buzz-key.txt", + { type: "text/plain" }, + ); + fireEvent( + form, + createEvent.drop(form, { + dataTransfer: { types: ["Files"], files: [dropped] }, + bubbles: true, + cancelable: true, + }), + ); + await waitFor(() => expect(keyField()).toHaveValue(ownedNsec)); + expect(keyField()).toHaveAttribute("type", "password"); + expect(keyField()).toHaveAccessibleDescription( + `Public identity: ${npubEncode(owned.pubkey)}`, + ); + expect(continueWithKey()).toBeEnabled(); + const picker = screen.getByLabelText("Key file"); + await user.upload( + picker, + new File([new Uint8Array(1025)], "big.txt", { type: "text/plain" }), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "That file is too large to be a private key.", + ); + expect(keyField()).toHaveValue(ownedNsec); + await user.upload( + picker, + new File(["hello\n"], "notes.txt", { type: "text/plain" }), + ); + await waitFor(() => expect(keyField()).toHaveValue("hello")); + expect(screen.getByRole("alert")).toHaveTextContent(invalidKeyCopy); + expect(continueWithKey()).toBeDisabled(); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + ]); + identity.dispose(); +}); + +it("an imported key on the tenant path is offered a backup, and Not now lands on Join Community for the landing URL", async () => { + const relay = landingRelay("https://relay.example.com"); + relay.info.resolve(); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await screen.findByRole("button", { name: SIGN_IN }); + await user.type(urlField(), "relay.example.com{Enter}"); + await user.click( + await screen.findByRole("button", { name: "I already have a key" }), + ); + await user.click(keyField()); + await user.paste(ownedNsec); + await user.click(continueWithKey()); + await user.click(await screen.findByRole("button", { name: "Not now" })); + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + expect(identity.snapshot()).toEqual({ + status: "ready", + viewer: owned.pubkey, + }); + expect( + screen.queryByRole("heading", { name: "Join a community" }), + ).toBeNull(); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "relay_http", + "relay_http", + "identity_import", + "relay_http", + "relay_http", + ]); + identity.dispose(); +}); + +/** A real second key that is not the linked one, for the mismatch refusal. */ +const stranger = keypair(); +/** The signed-in account's reads once a key is bound to it. */ +const linkedTo = + ( + pubkey: string, + communities: readonly OwnedCommunity[] = [], + backup: BackupAnswers = {}, + ): ComponentProps["account"] => + () => ({ + linkedKey: () => Promise.resolve(pubkey), + ownedCommunities: () => Promise.resolve(communities), + knownCommunities: () => Promise.resolve([]), + saveCommunity, + // The import accepts only the linked key: there is nothing to bind. + bind: () => Promise.reject(new Error("Welcome bound the linked key again")), + unbind: noUnlink, + ...backupAnswers, + ...backup, + }); +const linkedNpub = npubEncode(owned.pubkey); +/** + * Connect as a linked account: the backed-up-key lookup still runs, and setup + * pauses on Welcome back instead of the choice. + */ +async function welcomeBackPage( + user: ReturnType, + identity: Identity, + account: ComponentProps["account"], +) { + render( + +

Signed in

+
, + ); + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + expect( + await screen.findByRole("heading", { name: "Welcome back" }), + ).toBeVisible(); + expect(screen.getByRole("heading", { name: "Welcome back" })).toHaveFocus(); +} +/** Enter your private key with the linked key, through to the backup offer. */ +async function importLinkedKey(user: ReturnType) { + await user.click( + screen.getByRole("button", { name: "Enter your private key" }), + ); + await user.paste(ownedNsec); + await user.click(continueWithKey()); + expect( + await screen.findByRole("heading", { name: "Back up this key?" }), + ).toBeVisible(); +} + +it("a linked account is welcomed back with its npub, accepts only the linked key, offers a backup after the import, and Not now lands on Join a community", async () => { + const imported = deferred(); + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_import") return imported.promise; + throw new Error(`Unexpected command ${command}`); + }); + const write = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage(user, identity, linkedTo(owned.pubkey)); + expect(screen.getByText(/already linked to a Buzz identity/)).toBeVisible(); + // The linked key in full, as an npub, never hex; no lane here creates a key. + expect(screen.getByText(linkedNpub)).toHaveClass("font-mono"); + expect(screen.queryByText(owned.pubkey)).toBeNull(); + for (const name of ["Create my key", "I already have a key"]) + expect(screen.queryByRole("button", { name })).toBeNull(); + expect( + screen.getByRole("button", { + name: "Recover from another device — Coming soon", + }), + ).toBeDisabled(); + expect(screen.getByRole("button", { name: "I lost this key" })).toBeEnabled(); + // Who the linked key belongs to, with a way out if it is the wrong account. + expect(screen.getByText("Signed in as a@example.com.")).toBeVisible(); + expect( + screen.getByRole("button", { name: "Use a different account" }), + ).toBeEnabled(); + expect(invoke).not.toHaveBeenCalledWith("identity_create", {}); + await user.click(screen.getByRole("button", { name: "Copy npub" })); + expect(write).toHaveBeenCalledWith(linkedNpub); + expect(screen.getByRole("status")).toHaveTextContent("Public key copied."); + await user.click( + screen.getByRole("button", { name: "Enter your private key" }), + ); + expect( + screen.getByRole("heading", { name: "Enter your private key" }), + ).toBeVisible(); + // Back returns to Welcome back on this lane, not to the key choice, with + // focus on the choice that opened the import page. + await user.click(screen.getByRole("button", { name: "Back" })); + expect(screen.getByRole("heading", { name: "Welcome back" })).toBeVisible(); + expect( + screen.getByRole("button", { name: "Enter your private key" }), + ).toHaveFocus(); + await user.click( + screen.getByRole("button", { name: "Enter your private key" }), + ); + // A valid key that is not the linked one is refused, naming the expected npub. + await user.paste(nsecEncode(stranger.secret)); + expect(screen.getByRole("alert")).toHaveTextContent( + `That isn’t the key linked to this account. It expects ${linkedNpub} (the key on your other device).`, + ); + expect(continueWithKey()).toBeDisabled(); + await user.keyboard("{Enter}"); + expect(invoke).not.toHaveBeenCalledWith("identity_import", expect.anything()); + await user.clear(keyField()); + await user.paste(ownedNsec); + expect(screen.queryByRole("alert")).toBeNull(); + expect(continueWithKey()).toBeEnabled(); + await user.click(continueWithKey()); + expect(invoke).toHaveBeenCalledWith("identity_import", { nsec: ownedNsec }); + expect(screen.getByRole("status")).toHaveTextContent("Importing your key…"); + await act(async () => imported.resolve(owned.pubkey)); + // Importing never uploads on its own: the backup is offered, with the app + // still behind the pause. + expect( + await screen.findByRole("heading", { name: "Back up this key?" }), + ).toBeVisible(); + expect(identity.snapshot()).toEqual({ + status: "ready", + viewer: owned.pubkey, + }); + expect(screen.queryByText("Signed in")).toBeNull(); + await user.click(screen.getByRole("button", { name: "Not now" })); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(screen.queryByRole("list")).toBeNull(); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "identity_import", + ]); + identity.dispose(); +}); + +it("Use a different account on Welcome back returns to the landing without touching the key, and the linked key is read again only at the next sign-in", async () => { + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + throw new Error(`Unexpected command ${command}`); + }); + const linkedKey = vi.fn(() => Promise.resolve(owned.pubkey)); + const account: ComponentProps["account"] = () => ({ + linkedKey, + ownedCommunities: () => Promise.resolve([]), + knownCommunities: () => Promise.resolve([]), + saveCommunity, + bind: () => Promise.reject(new Error("Welcome bound the linked key again")), + unbind: noUnlink, + ...backupAnswers, + }); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage(user, identity, account); + expect(linkedKey).toHaveBeenCalledTimes(1); + expect(screen.getByText("Signed in as a@example.com.")).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "Use a different account" }), + ); + // Back on the landing, signed out; the linked key was never imported or + // replaced, and its npub is gone with the page. + expect(welcomeHeading()).toBeVisible(); + expect(screen.queryByRole("heading", { name: "Welcome back" })).toBeNull(); + expect(screen.queryByText(linkedNpub)).toBeNull(); + expect(identity.snapshot().status).toBe("missing"); + expect(linkedKey).toHaveBeenCalledTimes(1); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + ]); + // The next sign-in reads the account again rather than reusing the answer. + await user.click(connectButton()); + expect( + await screen.findByRole("heading", { name: "Welcome back" }), + ).toBeVisible(); + expect(linkedKey).toHaveBeenCalledTimes(2); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + ]); + identity.dispose(); +}); + +it("Back it up on the backup offer backs the linked key up before Join a community", async () => { + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_import") return Promise.resolve(owned.pubkey); + throw new Error(`Unexpected command ${command}`); + }); + const backedUp = deferred(); + const backUp = vi.fn((_pubkey: string) => backedUp.promise); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage(user, identity, linkedTo(owned.pubkey, [], { backUp })); + await importLinkedKey(user); + await user.click(screen.getByRole("button", { name: "Back it up" })); + // The linked key only needs the backup: no binding step. + expect(screen.getByRole("status")).toHaveTextContent("Backing up your key…"); + expect(backUp.mock.calls).toEqual([[owned.pubkey]]); + await act(async () => backedUp.resolve()); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + identity.dispose(); +}); + +it("a backup that fails twice after the key is imported offers Try again each time, and Continue without backup still lists the owned communities on Join a community", async () => { + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_import") return Promise.resolve(owned.pubkey); + throw new Error(`Unexpected command ${command}`); + }); + const backUp = vi.fn((_pubkey: string) => + Promise.reject(new BackupError("unreachable")), + ); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage( + user, + identity, + linkedTo( + owned.pubkey, + [{ host: "design.example.com", url: "https://design.example.com" }], + { backUp }, + ), + ); + await importLinkedKey(user); + await user.click(screen.getByRole("button", { name: "Back it up" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Couldn’t reach Builderlab to back up your key.", + ); + expect( + screen.getByRole("heading", { name: "Couldn’t finish setup" }), + ).toBeVisible(); + expect(screen.getByText(keySavedNote)).toBeVisible(); + // The key is on the device, so the gate must not lift into an app with no + // community: the page never offers the landing. + expect(identity.snapshot()).toEqual({ + status: "ready", + viewer: owned.pubkey, + }); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(screen.queryByRole("button", { name: "Back" })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Try again" })); + await waitFor(() => expect(backUp).toHaveBeenCalledTimes(2)); + expect( + await screen.findByRole("heading", { name: "Couldn’t finish setup" }), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Try again" })).toBeEnabled(); + await user.click( + screen.getByRole("button", { name: "Continue without backup" }), + ); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect(backUp.mock.calls).toEqual([[owned.pubkey], [owned.pubkey]]); + expect( + within(screen.getByRole("list", { name: "Communities you own" })).getByText( + "design.example.com", + ), + ).toBeVisible(); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(screen.queryByRole("heading", { name: "Buzz" })).toBeNull(); + identity.dispose(); +}); + +it("Back it up with the real account waits on the native uploader's pass for its own handoff, past a stale failure, then hands the credential back", async () => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); + const channels: { onmessage: (status: BackupStatus) => void }[] = []; + const at = (seq: number, pass: number, settled: object) => + ({ seq, pass, builderlab: true, ...settled }) as BackupStatus; + vi.mocked(invoke).mockImplementation(async (command, args) => { + if (command === "identity_restore") return null; + if (command === "identity_import") return owned.pubkey; + if (command === "nsec_backup_watch") { + channels.push((args as { onEvent: (typeof channels)[number] }).onEvent); + return undefined; + } + // The last pass, before this sign-in, could not reach Builderlab. + if (command === "nsec_backup_state") + return at(1, 3, { status: "failed", kind: "unreachable" }); + if (command === "nsec_backup_set_choice") + return { available: true, pubkey: owned.pubkey, automatic: true }; + if (command === "nsec_backup_session") + return (args as { credential: string | null }).credential ? 4 : null; + throw new Error(`Unexpected command ${command}`); + }); + const answer = (body: object) => ({ + status: 200, + headers: {}, + body: JSON.stringify(body), + }); + const request = vi.fn(async ({ url }: { url: string }) => + url.endsWith("/nostr-identities/current") + ? answer({ + identity: { npub: linkedNpub, pubkey_hex: owned.pubkey }, + correlation_id: "3f2a9c1e-5b7d-4e8f-9a0b-1c2d3e4f5a6b", + }) + : url.endsWith("/nsec-backups/status") + ? answer({ + current_nostr_pubkey: owned.pubkey, + backup_nostr_pubkeys: [], + }) + : answer({ communities: [] }), + ); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage(user, identity, (credential, origin, identity) => + builderlabAccount(credential, origin, request, identity), + ); + await importLinkedKey(user); + await user.click(screen.getByRole("button", { name: "Back it up" })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith( + "nsec_backup_session", + expect.objectContaining({ credential: "private-token" }), + ), + ); + expect(screen.getByRole("status")).toHaveTextContent("Backing up your key…"); + await act(async () => + channels[0]?.onmessage(at(2, 4, { status: "running" })), + ); + expect(screen.getByRole("status")).toHaveTextContent("Backing up your key…"); + await act(async () => + channels[0]?.onmessage(at(3, 4, { status: "complete" })), + ); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect( + vi + .mocked(invoke) + .mock.calls.filter(([command]) => command.startsWith("nsec_backup_")) + .map(([command, args]) => + args === undefined || command === "nsec_backup_watch" + ? [command] + : [command, args], + ), + ).toEqual([ + ["nsec_backup_watch"], + ["nsec_backup_state"], + ["nsec_backup_set_choice", { pubkey: owned.pubkey, automatic: true }], + [ + "nsec_backup_session", + { + source: "builderlab", + generation: expect.any(Number), + credential: "private-token", + }, + ], + [ + "nsec_backup_session", + { + source: "builderlab", + generation: expect.any(Number), + credential: null, + }, + ], + ]); + identity.dispose(); +}); + +it.each([ + ["lists the linked key", [owned.pubkey], true], + ["lists only another key", [stranger.pubkey], false], + ["lists nothing", [], false], + ["is unavailable", "unavailable", false], +] as const)( + "Welcome back offers Restore from backup only where backup status lists the linked key: status %s", + async (_label, listed, restore) => { + vi.mocked(invoke).mockResolvedValueOnce(null); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage( + user, + identity, + linkedTo(owned.pubkey, [], { + backupStatus: () => Promise.resolve(listed), + }), + ); + expect( + screen.queryByRole("button", { name: "Restore from backup" }) !== null, + ).toBe(restore); + expect( + screen.getByRole("button", { name: "Enter your private key" }), + ).toBeEnabled(); + identity.dispose(); + }, +); + +it("a backup status lookup that fails hides Restore from backup, and Enter your private key still reaches the backup offer", async () => { + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_import") return Promise.resolve(owned.pubkey); + throw new Error(`Unexpected command ${command}`); + }); + const backupStatus = vi.fn(() => + Promise.reject(new BackupError("unreachable")), + ); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage( + user, + identity, + linkedTo(owned.pubkey, [], { backupStatus }), + ); + expect(backupStatus).toHaveBeenCalledTimes(1); + expect( + screen.queryByRole("button", { name: "Restore from backup" }), + ).toBeNull(); + expect(screen.queryByRole("alert")).toBeNull(); + await importLinkedKey(user); + identity.dispose(); +}); + +it("importing a linked key backup status already lists skips the backup offer", async () => { + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_import") return Promise.resolve(owned.pubkey); + throw new Error(`Unexpected command ${command}`); + }); + const backUp = vi.fn((_pubkey: string) => Promise.resolve()); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage( + user, + identity, + linkedTo(owned.pubkey, [], { + backupStatus: () => Promise.resolve([owned.pubkey]), + backUp, + }), + ); + await user.click( + screen.getByRole("button", { name: "Enter your private key" }), + ); + await user.paste(ownedNsec); + await user.click(continueWithKey()); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect( + screen.queryByRole("heading", { name: "Back up this key?" }), + ).toBeNull(); + expect(backUp).not.toHaveBeenCalled(); + identity.dispose(); +}); + +/** + * The real account on the build's own portal, where backup runs, with kgoose + * listing the linked key as backed up; native recovery answers from `native`. + */ +function backedUpAccount( + native: Record unknown>, +): ComponentProps["account"] { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); + vi.mocked(invoke).mockImplementation(async (command, args) => { + if (command === "identity_restore") return null; + if (command === "nsec_backup_watch") return undefined; + if (command === "nsec_backup_state") + return { seq: 1, pass: 0, builderlab: true, status: "idle" }; + const answer = native[command]; + if (!answer) throw new Error(`Unexpected command ${command}`); + return answer(args); + }); + const request = vi.fn(async ({ url }: { url: string }) => ({ + status: 200, + headers: {}, + body: JSON.stringify( + url.endsWith("/nostr-identities/current") + ? { + identity: { npub: linkedNpub, pubkey_hex: owned.pubkey }, + correlation_id: "3f2a9c1e-5b7d-4e8f-9a0b-1c2d3e4f5a6b", + } + : url.endsWith("/nsec-backups/status") + ? { + current_nostr_pubkey: owned.pubkey, + backup_nostr_pubkeys: [owned.pubkey], + } + : { communities: [] }, + ), + })); + return (credential, origin, identity) => + builderlabAccount(credential, origin, request, identity); +} +/** Native recovery's answer, holding a backup of each key. */ +const backupsOf = (...pubkeys: string[]): RecoveryResult => ({ + candidates: pubkeys.map((pubkey, index) => ({ + handle: `backup-${index}`, + pubkey, + })), + currentPubkey: owned.pubkey, + failedCount: 0, +}); +/** The restore page's line, true only while a restore may still install the key. */ +const storedSecurely = () => + screen.queryByText("Your key is stored securely on this device."); +/** Native commands in order, without the backup status store's own reads. */ +const recoveryCalls = () => + vi + .mocked(invoke) + .mock.calls.map(([command]) => command) + .filter((command) => !/^nsec_backup_(watch|state)$/.test(command)); + +it("Restore from backup recovers through its own browser sign-in, offers only the linked key, and lands on Join a community without a backup offer", async () => { + const begun = deferred<{ attempt: string }>(); + const account = backedUpAccount({ + nsec_restore_begin: () => begun.promise, + nsec_restore_wait: () => backupsOf(stranger.pubkey, owned.pubkey), + nsec_restore_install: () => owned.pubkey, + }); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage(user, identity, account); + expect(screen.getByText(/Restore it from your backup/)).toBeVisible(); + expect( + screen.getByRole("button", { name: "Enter your private key" }), + ).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Restore from backup" })); + expect( + screen.getByRole("heading", { name: "Restore from backup" }), + ).toBeVisible(); + expect(screen.getByRole("status")).toHaveTextContent( + "Sign in in your browser to find your backups…", + ); + expect(storedSecurely()).toBeVisible(); + await act(async () => begun.resolve({ attempt: "attempt-1" })); + // The account holds another key's backup too; only the linked one is offered. + expect(await screen.findByRole("radio", { checked: true })).toBeVisible(); + expect(screen.getAllByRole("radio")).toHaveLength(1); + expect(storedSecurely()).toBeVisible(); + expect(screen.getByText(linkedNpub)).toBeVisible(); + expect(screen.queryByText(npubEncode(stranger.pubkey))).toBeNull(); + await user.click( + screen.getByRole("button", { name: "Restore this identity" }), + ); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect(identity.snapshot()).toEqual({ + status: "ready", + viewer: owned.pubkey, + }); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(invoke).toHaveBeenCalledWith("nsec_restore_install", { + attempt: "attempt-1", + handle: "backup-1", + }); + // A restored key is already backed up: no offer, no uploader handoff. + expect(recoveryCalls()).toEqual([ + "identity_restore", + "nsec_restore_begin", + "nsec_restore_wait", + "nsec_restore_install", + ]); + identity.dispose(); +}); + +it.each([ + ["unavailable", RECOVERY_UNAVAILABLE], + ["refused", RECOVERY_REFUSED], +])( + "a restore recovery answers as %s offers Enter your private key instead of Try again, and the import skips the backup offer", + async (_label, refusal) => { + const account = backedUpAccount({ + nsec_restore_begin: () => ({ attempt: "attempt-1" }), + nsec_restore_wait: () => Promise.reject(refusal), + nsec_restore_cancel: () => undefined, + identity_import: () => owned.pubkey, + }); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage(user, identity, account); + await user.click( + screen.getByRole("button", { name: "Restore from backup" }), + ); + expect( + await screen.findByText( + "Backup restore isn’t available for this account. Enter your private key to continue.", + ), + ).toBeVisible(); + // The unavailable copy replaces the line rather than following it. + expect(storedSecurely()).toBeNull(); + expect(screen.queryByText(refusal)).toBeNull(); + expect(screen.queryByRole("button", { name: /Try/ })).toBeNull(); + await user.click( + screen.getByRole("button", { name: "Enter your private key" }), + ); + // Recovery is canceled first: until then the identity refuses an import. + expect( + await screen.findByRole("heading", { name: "Enter your private key" }), + ).toBeVisible(); + expect(invoke).toHaveBeenCalledWith("nsec_restore_cancel", { + attempt: "attempt-1", + }); + await user.paste(ownedNsec); + await user.click(continueWithKey()); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect(identity.snapshot()).toEqual({ + status: "ready", + viewer: owned.pubkey, + }); + expect(recoveryCalls()).toEqual([ + "identity_restore", + "nsec_restore_begin", + "nsec_restore_wait", + "nsec_restore_cancel", + "identity_import", + ]); + identity.dispose(); + }, +); + +it("a restore that fails for a reason a retry may clear offers Try recovery again, and Back returns to Welcome back", async () => { + const wait = vi + .fn() + .mockRejectedValueOnce("Could not restore your identity. Try again.") + .mockResolvedValueOnce(backupsOf(owned.pubkey)); + const account = backedUpAccount({ + nsec_restore_begin: () => ({ attempt: "attempt-1" }), + nsec_restore_wait: wait, + nsec_restore_cancel: () => undefined, + }); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage(user, identity, account); + await user.click(screen.getByRole("button", { name: "Restore from backup" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not restore your identity. Try again.", + ); + expect(storedSecurely()).toBeNull(); + expect( + screen.queryByRole("button", { name: "Enter your private key" }), + ).toBeNull(); + await user.click(screen.getByRole("button", { name: "Try recovery again" })); + expect(await screen.findByRole("radio", { checked: true })).toBeVisible(); + expect(storedSecurely()).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Back" })); + expect( + await screen.findByRole("heading", { name: "Welcome back" }), + ).toBeVisible(); + // Back returns focus to the choice that opened the restore page. + expect( + screen.getByRole("button", { name: "Restore from backup" }), + ).toHaveFocus(); + expect(identity.snapshot().status).toBe("missing"); + expect(recoveryCalls()).toEqual([ + "identity_restore", + "nsec_restore_begin", + "nsec_restore_wait", + "nsec_restore_cancel", + "nsec_restore_begin", + "nsec_restore_wait", + "nsec_restore_cancel", + ]); + identity.dispose(); +}); + +it("a restore whose backups do not hold the linked key installs nothing, and Back returns to Welcome back", async () => { + const account = backedUpAccount({ + nsec_restore_begin: () => ({ attempt: "attempt-1" }), + nsec_restore_wait: () => backupsOf(stranger.pubkey), + nsec_restore_cancel: () => undefined, + }); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage(user, identity, account); + await user.click(screen.getByRole("button", { name: "Restore from backup" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "None of your backups holds the key linked to this account, so nothing was restored.", + ); + expect(storedSecurely()).toBeNull(); + expect(screen.queryByRole("radio")).toBeNull(); + for (const name of ["Restore this identity", "Try recovery again"]) + expect(screen.queryByRole("button", { name })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Back" })); + expect( + await screen.findByRole("heading", { name: "Welcome back" }), + ).toBeVisible(); + expect(identity.snapshot().status).toBe("missing"); + expect(recoveryCalls()).toEqual([ + "identity_restore", + "nsec_restore_begin", + "nsec_restore_wait", + "nsec_restore_cancel", + ]); + identity.dispose(); +}); + +it.each([ + [0, "No backups were found for this account."], + [2, "Your backups couldn’t be recovered. Try again."], +])( + "a restore that recovers no backup (%s failed) says so without claiming the key is stored on this device", + async (failedCount, message) => { + const account = backedUpAccount({ + nsec_restore_begin: () => ({ attempt: "attempt-1" }), + nsec_restore_wait: () => ({ + candidates: [], + currentPubkey: null, + failedCount, + }), + nsec_restore_cancel: () => undefined, + }); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage(user, identity, account); + await user.click( + screen.getByRole("button", { name: "Restore from backup" }), + ); + expect(await screen.findByText(message)).toBeVisible(); + expect(storedSecurely()).toBeNull(); + expect( + screen.getByRole("button", { name: "Try recovery again" }), + ).toBeEnabled(); + expect(identity.snapshot().status).toBe("missing"); + identity.dispose(); + }, +); + +it("a restore whose backups do not hold the linked key offers Enter your private key, which cancels recovery before the import page", async () => { + const account = backedUpAccount({ + nsec_restore_begin: () => ({ attempt: "attempt-1" }), + nsec_restore_wait: () => backupsOf(stranger.pubkey), + nsec_restore_cancel: () => undefined, + identity_import: () => owned.pubkey, + }); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage(user, identity, account); + await user.click(screen.getByRole("button", { name: "Restore from backup" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "None of your backups holds the key linked to this account", + ); + await user.click( + screen.getByRole("button", { name: "Enter your private key" }), + ); + expect( + await screen.findByRole("heading", { name: "Enter your private key" }), + ).toBeVisible(); + await user.paste(ownedNsec); + await user.click(continueWithKey()); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect(identity.snapshot()).toEqual({ + status: "ready", + viewer: owned.pubkey, + }); + expect(recoveryCalls()).toEqual([ + "identity_restore", + "nsec_restore_begin", + "nsec_restore_wait", + "nsec_restore_cancel", + "identity_import", + ]); + identity.dispose(); +}); + +/** + * The linked account a user starts over on. `unbind` and `bind` stand in for + * kgoose: they decide the outcome, whatever the account last reported. + */ +const replacedBy = + ( + unbind: FirstRunAccount["unbind"], + bind: FirstRunAccount["bind"], + communities: () => Promise = () => + Promise.resolve([]), + backup: BackupAnswers = {}, + ): ComponentProps["account"] => + () => ({ + linkedKey: () => Promise.resolve(owned.pubkey), + ownedCommunities: communities, + knownCommunities: () => Promise.resolve([]), + saveCommunity, + unbind, + bind, + ...backupAnswers, + ...backup, + }); +/** Welcome back → I lost this key → Start over, which creates the new key at once. */ +async function startOver(user: ReturnType) { + await user.click(screen.getByRole("button", { name: "I lost this key" })); + await user.click( + await screen.findByRole("button", { name: "Start over with a new key" }), + ); +} +/** A failure kgoose may clear, as a transport error or a 5xx is. */ +const transientError = (message: string) => + Object.assign(new Error(message), { retryable: true }); +/** + * Native commands and account calls in the order they ran. `created` holds key + * creation so "Creating keys…" can be observed. + */ +function callLog(created: Promise = Promise.resolve(viewer)) { + const calls: string[] = []; + vi.mocked(invoke).mockImplementation((command) => { + calls.push(command); + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_create") return created; + throw new Error(`Unexpected command ${command}`); + }); + /** Answers each call with the next scripted answer, then succeeds. */ + const step = + (name: string, answers: Array<() => Promise> = []) => + (pubkey: string) => { + calls.push(`${name} ${pubkey}`); + return (answers.shift() ?? (() => Promise.resolve()))(); + }; + return { calls, step }; +} + +it("I lost this key lists the communities the old key owns, Back returns to Welcome back, and Start over creates the new key before unlinking the old one, then links the new key and backs it up", async () => { + const created = deferred(); + const { calls, step } = callLog(created.promise); + const found = deferred(); + const unlinked = deferred(); + const bound = deferred(); + const backedUp = deferred(); + const unbind = vi.fn(step("unbind", [() => unlinked.promise])); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage( + user, + identity, + replacedBy( + unbind, + step("bind", [() => bound.promise]), + () => found.promise, + { backUp: step("backUp", [() => backedUp.promise]) }, + ), + ); + await user.click(screen.getByRole("button", { name: "I lost this key" })); + // The owned list is real and read for this page. + expect(screen.getByRole("status")).toHaveTextContent("Finding communities…"); + await act(async () => + found.resolve([ + { host: "design.example.com", url: "https://design.example.com" }, + { host: "buzz.example.org", url: "https://buzz.example.org" }, + ]), + ); + expect( + await screen.findByRole("heading", { name: "Start over with a new key" }), + ).toBeVisible(); + expect( + within(screen.getByRole("list", { name: "Communities you own" })) + .getAllByRole("listitem") + .map((item) => item.textContent), + ).toEqual(["design.example.com", "buzz.example.org"]); + expect( + screen.getByText(/Memberships in other people’s communities/), + ).toBeVisible(); + // Starting over is the new key's backup consent, so the page says so. + expect( + screen.getByText(/encrypted copy that Block can use to restore it/), + ).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Back" })); + expect(screen.getByRole("heading", { name: "Welcome back" })).toBeVisible(); + expect(screen.getByRole("button", { name: "I lost this key" })).toHaveFocus(); + await startOver(user); + // No key choice: the new key is created at once, and nothing is unlinked + // before it exists, so the account is never left without a key. + expect(screen.getByRole("status")).toHaveTextContent("Creating keys…"); + expect(screen.queryByRole("heading", { name: "Set up your key" })).toBeNull(); + expect(unbind).not.toHaveBeenCalled(); + await act(async () => created.resolve(viewer)); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent( + "Unlinking your old key…", + ), + ); + expect(identity.snapshot()).toEqual({ status: "ready", viewer }); + await act(async () => unlinked.resolve()); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent("Linking your key…"), + ); + await act(async () => bound.resolve()); + await waitFor(() => + expect(screen.getByRole("status")).toHaveTextContent( + "Backing up your key…", + ), + ); + await act(async () => backedUp.resolve()); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + // The new key replaces the old one: created, the old binding released + // (for the new key, which the account may then hold), the new key linked, + // then backed up. + expect(unbind.mock.calls).toEqual([[owned.pubkey, viewer]]); + expect(calls).toEqual([ + "identity_restore", + "identity_create", + `unbind ${owned.pubkey}`, + `bind ${viewer}`, + `backUp ${viewer}`, + ]); + // The old key's communities are not offered to the new one. + expect(screen.queryByRole("list")).toBeNull(); + expect(screen.queryByText("Signed in")).toBeNull(); + identity.dispose(); +}); + +it("a transient unlink failure after Start over keeps the new key, and Try again re-runs only the unlink before linking and backing up", async () => { + const { calls, step } = callLog(); + const failed = deferred(); + const retried = deferred(); + const unbind = vi.fn( + step("unbind", [() => failed.promise, () => retried.promise]), + ); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage( + user, + identity, + replacedBy(unbind, step("bind"), undefined, { backUp: step("backUp") }), + ); + await startOver(user); + await waitFor(() => expect(unbind).toHaveBeenCalledTimes(1)); + await act(async () => + failed.reject( + transientError("Builderlab account unlink failed (HTTP 503)."), + ), + ); + expect( + screen.getByRole("heading", { name: "Couldn’t finish setup" }), + ).toBeVisible(); + expect(screen.getByRole("alert")).toHaveTextContent( + "Builderlab account unlink failed (HTTP 503).", + ); + expect(screen.getByText(keyUnlinkedNote)).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Continue without linking" }), + ).toBeNull(); + expect(screen.queryByRole("button", { name: "Back" })).toBeNull(); + expect(screen.queryByText("Signed in")).toBeNull(); + await user.click(screen.getByRole("button", { name: "Try again" })); + expect(screen.getByRole("status")).toHaveTextContent( + "Unlinking your old key…", + ); + await act(async () => retried.resolve()); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect(calls).toEqual([ + "identity_restore", + "identity_create", + `unbind ${owned.pubkey}`, + `unbind ${owned.pubkey}`, + `bind ${viewer}`, + `backUp ${viewer}`, + ]); + identity.dispose(); +}); + +it("an unlink refused after Start over offers only Try again, which runs the unlink again and, once it succeeds, links and backs up the new key", async () => { + const { calls, step } = callLog(); + const unbind = vi.fn( + step("unbind", [ + () => + Promise.reject( + new Error( + "This Builderlab account can't manage Buzz identities right now. Try signing in again.", + ), + ), + ]), + ); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage( + user, + identity, + replacedBy(unbind, step("bind"), undefined, { backUp: step("backUp") }), + ); + await startOver(user); + expect(await screen.findByRole("alert")).toHaveTextContent( + "can't manage Buzz identities right now", + ); + expect(screen.getByText(keyUnlinkedNote)).toBeVisible(); + // A refusal is not one Try again is promised to clear, but with nothing to + // link the key later the page offers nothing else. + expect( + screen.queryByRole("button", { name: "Continue without linking" }), + ).toBeNull(); + expect(screen.queryByRole("button", { name: "Back" })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Try again" })); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + // The unlink ran again from the failed step; binding never overwrites, so + // the link waited for it, then the backup followed. + expect(calls).toEqual([ + "identity_restore", + "identity_create", + `unbind ${owned.pubkey}`, + `unbind ${owned.pubkey}`, + `bind ${viewer}`, + `backUp ${viewer}`, + ]); + expect(identity.snapshot()).toEqual({ status: "ready", viewer }); + identity.dispose(); +}); + +it("a transient link failure after a successful unlink retries only the link, never unlinking again", async () => { + const { calls, step } = callLog(); + const unbind = vi.fn(step("unbind")); + const bind = vi.fn( + step("bind", [ + () => + Promise.reject( + transientError( + "Builderlab account link failed (HTTP 502: not JSON).", + ), + ), + ]), + ); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage( + user, + identity, + replacedBy(unbind, bind, undefined, { backUp: step("backUp") }), + ); + await startOver(user); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Builderlab account link failed (HTTP 502: not JSON).", + ); + expect(screen.getByText(keyUnlinkedNote)).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Continue without linking" }), + ).toBeNull(); + await user.click(screen.getByRole("button", { name: "Try again" })); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect(unbind).toHaveBeenCalledTimes(1); + expect(calls).toEqual([ + "identity_restore", + "identity_create", + `unbind ${owned.pubkey}`, + `bind ${viewer}`, + `bind ${viewer}`, + `backUp ${viewer}`, + ]); + identity.dispose(); +}); + +it("a linked account's owned communities are offered on Join a community, and a row's Join looks that community up", async () => { + const relay = landingRelay("https://design.example.com"); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage( + user, + identity, + linkedTo(owned.pubkey, [ + { host: "design.example.com", url: "https://design.example.com" }, + ]), + ); + await importLinkedKey(user); + await user.click(screen.getByRole("button", { name: "Not now" })); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + const rows = screen.getByRole("list", { name: "Communities you own" }); + expect(within(rows).getAllByRole("listitem")).toHaveLength(1); + // Distinct accessible names: a screen reader hears "Join design.example.com" + // and "Continue", not two "Join" buttons. + expect( + within(rows).getByRole("button", { name: "Join design.example.com" }), + ).toHaveAccessibleName("Join design.example.com"); + expect(urlContinue()).toHaveAccessibleName("Continue"); + expect(within(rows).getByText("design.example.com")).toBeVisible(); + expect(urlField()).toHaveValue(""); + expect(relay.relayCalls).toEqual([]); + // The row's Join, named for its host, is the typed-URL path: the same lookup. + await user.click( + within(rows).getByRole("button", { name: "Join design.example.com" }), + ); + expect( + screen.getByRole("heading", { name: "Finding community" }), + ).toBeVisible(); + expect(screen.getByText("design.example.com")).toBeVisible(); + expect(relay.relayCalls).toEqual(relay.lookup); + await act(async () => relay.info.resolve()); + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect( + screen.getByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect( + screen.getByRole("list", { name: "Communities you own" }), + ).toBeVisible(); + expect(urlField()).toHaveValue(""); + identity.dispose(); +}); + +it("a linked account on the tenant path is welcomed back and its imported key lands on Join Community for the landing URL", async () => { + const relay = landingRelay("https://relay.example.com"); + relay.info.resolve(); + const account = vi.fn(linkedTo(owned.pubkey)); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await screen.findByRole("button", { name: SIGN_IN }); + await user.type(urlField(), "relay.example.com{Enter}"); + expect( + await screen.findByRole("heading", { name: "Welcome back" }), + ).toBeVisible(); + // The reads go to the tenant that signed the user in. + expect(account).toHaveBeenCalledWith( + credential, + "https://tenant.example", + identity, + ); + await importLinkedKey(user); + await user.click(screen.getByRole("button", { name: "Not now" })); + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + expect(identity.snapshot()).toEqual({ + status: "ready", + viewer: owned.pubkey, + }); + expect( + screen.queryByRole("heading", { name: "Join a community" }), + ).toBeNull(); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "relay_http", + "relay_http", + "identity_import", + "relay_http", + "relay_http", + ]); + identity.dispose(); +}); + +it("Connect from the choice as a linked account is welcomed back at the build portal, and its imported key lands on Join Community for the address", async () => { + const relay = landingRelay(); + const account = vi.fn(linkedTo(owned.pubkey)); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await connectChoicePage(user, relay); + await user.click(connectButton()); + expect( + await screen.findByRole("heading", { name: "Welcome back" }), + ).toBeVisible(); + // The reads go to the build portal, as Connect without a URL sends them. + expect(account).toHaveBeenCalledWith(credential, undefined, identity); + await importLinkedKey(user); + await user.click(screen.getByRole("button", { name: "Not now" })); + // The returning-user lane needs no branching of its own: it rests at done, + // and the kept address picks the find page. + expect( + await screen.findByRole("heading", { name: "Join Community" }), + ).toBeVisible(); + expect(identity.snapshot()).toEqual({ + status: "ready", + viewer: owned.pubkey, + }); + expect( + screen.queryByRole("heading", { name: "Join a community" }), + ).toBeNull(); + expect(screen.queryByText("Signed in")).toBeNull(); + expect(relay.relayCalls).toEqual([...relay.lookup, ...relay.lookup]); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "relay_http", + "relay_http", + "identity_import", + "relay_http", + "relay_http", + ]); + identity.dispose(); +}); + +it("an account check that fails stops setup, and Back returns to the first-launch screen", async () => { + vi.mocked(invoke).mockResolvedValueOnce(null); + const backupStatus = vi.fn(); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + ({ + linkedKey: () => + Promise.reject( + new Error("Builderlab account check failed (HTTP 503: not JSON)."), + ), + ownedCommunities: () => Promise.resolve([]), + knownCommunities: () => Promise.resolve([]), + saveCommunity, + bind: () => Promise.resolve(), + unbind: noUnlink, + ...backupAnswers, + backupStatus, + })} + > +

Signed in

+
, + ); + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Builderlab account check failed (HTTP 503: not JSON).", + ); + expect( + screen.getByRole("heading", { name: "Couldn’t finish setup" }), + ).toBeVisible(); + // Nothing after the check ran, and no key was made. + expect(backupStatus).not.toHaveBeenCalled(); + expect(identity.snapshot().status).toBe("missing"); + await user.click(screen.getByRole("button", { name: "Back" })); + expect(welcomeHeading()).toBeVisible(); + expect(connectButton()).toBeEnabled(); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + ]); + identity.dispose(); +}); + +it("the development seed answers the account reads in place of kgoose", async () => { + vi.stubEnv( + "VITE_BUZZ_FIRST_RUN_ACCOUNT", + JSON.stringify({ npub: linkedNpub, communities: ["design.example.com"] }), + ); + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + throw new Error(`Unexpected command ${command}`); + }); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + expect( + await screen.findByRole("heading", { name: "Welcome back" }), + ).toBeVisible(); + expect(screen.getByText(linkedNpub)).toBeVisible(); + // No host request went anywhere: the seed replaced both reads. + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + ]); + identity.dispose(); +}); + +it("the development seed links and backs up a created key without a request, a native signature or a native backup command", async () => { + vi.stubEnv("VITE_BUZZ_FIRST_RUN_ACCOUNT", "unlinked"); + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_create") return Promise.resolve(viewer); + throw new Error(`Unexpected command ${command}`); + }); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + await createMyKey(user); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect(identity.snapshot()).toEqual({ status: "ready", viewer }); + // Neither kgoose (`plugin_host_request`), the binding signer nor the native + // backup uploader (`nsec_backup_*`) was reached. + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "identity_create", + ]); + identity.dispose(); +}); + +it("a development seed's backup kind fails the backup with that kind's page, still without a native backup command", async () => { + vi.stubEnv("VITE_BUZZ_FIRST_RUN_ACCOUNT", '{"backup":"unreachable"}'); + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_create") return Promise.resolve(viewer); + throw new Error(`Unexpected command ${command}`); + }); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + await createMyKey(user); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Couldn’t reach Builderlab to back up your key. Check your connection and try again.", + ); + await user.click(screen.getByRole("button", { name: "Try again" })); + expect(await screen.findByRole("alert")).toBeVisible(); + expect( + screen.getByRole("button", { name: "Continue without backup" }), + ).toBeEnabled(); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "identity_create", + ]); + identity.dispose(); +}); + +it("a development seed's fail-once backup fails only the first backup, and Try again lands on Join a community, still without a native backup command", async () => { + vi.stubEnv("VITE_BUZZ_FIRST_RUN_ACCOUNT", '{"backup":"fail-once"}'); + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_create") return Promise.resolve(viewer); + throw new Error(`Unexpected command ${command}`); + }); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + await createMyKey(user); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Builderlab didn’t confirm your backup. Try again.", + ); + expect(screen.getByText(keySavedNote)).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Try again" })); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "identity_create", + ]); + identity.dispose(); +}); + +it("a development seed's unlink status fails Start over's unlink as kgoose would, still without a request", async () => { + vi.stubEnv( + "VITE_BUZZ_FIRST_RUN_ACCOUNT", + JSON.stringify({ npub: linkedNpub, unlink: 503 }), + ); + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_create") return Promise.resolve(viewer); + throw new Error(`Unexpected command ${command}`); + }); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage(user, identity, undefined); + await startOver(user); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Builderlab account unlink failed (HTTP 503).", + ); + // The failed unlink blocks: Try again is the only action, and the seed + // fails it again. + expect( + screen.queryByRole("button", { name: "Continue without linking" }), + ).toBeNull(); + expect(screen.queryByRole("button", { name: "Back" })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Try again" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Builderlab account unlink failed (HTTP 503).", + ); + expect(screen.getByRole("button", { name: "Try again" })).toBeEnabled(); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "identity_create", + ]); + identity.dispose(); +}); + +it("the development seed restores its seeded key without a browser sign-in or a native recovery command", async () => { + vi.stubEnv( + "VITE_BUZZ_FIRST_RUN_ACCOUNT", + JSON.stringify({ + npub: linkedNpub, + backups: [linkedNpub], + recovery: ownedNsec, + }), + ); + vi.mocked(invoke).mockImplementation((command) => { + if (command === "identity_restore") return Promise.resolve(null); + if (command === "identity_import") return Promise.resolve(owned.pubkey); + throw new Error(`Unexpected command ${command}`); + }); + const identity = createIdentity(); + const user = userEvent.setup(); + await welcomeBackPage(user, identity, undefined); + await user.click(screen.getByRole("button", { name: "Restore from backup" })); + expect(screen.getByRole("radio", { checked: true })).toBeVisible(); + await user.click( + screen.getByRole("button", { name: "Restore this identity" }), + ); + expect( + await screen.findByRole("heading", { name: "Join a community" }), + ).toBeVisible(); + expect(identity.snapshot()).toEqual({ + status: "ready", + viewer: owned.pubkey, + }); + expect(vi.mocked(invoke).mock.calls).toEqual([ + ["identity_restore", {}], + ["identity_import", { nsec: ownedNsec }], + ]); + identity.dispose(); +}); + +it("a failed or canceled sign-in returns to the first-launch screen", async () => { + vi.mocked(invoke).mockResolvedValueOnce(null); + const released = deferred(); + let signal: AbortSignal | undefined; + const connect = vi + .fn() + .mockRejectedValueOnce(new Error("Browser could not open.")) + .mockImplementationOnce((current: AbortSignal) => { + signal = current; + return released.promise; + }); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await user.click(await screen.findByRole("button", { name: SIGN_IN })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Browser could not open.", + ); + expect( + screen.getByRole("heading", { name: "Couldn’t sign in" }), + ).toBeVisible(); + expect(screen.queryByRole("heading", { name: "Buzz" })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Back" })); + expect(welcomeHeading()).toBeVisible(); + expect(screen.queryByRole("alert")).toBeNull(); + await user.click(connectButton()); + try { + await user.click(screen.getByRole("button", { name: "Cancel sign-in" })); + expect(signal?.aborted).toBe(true); + expect(connectButton()).toBeEnabled(); + expect(screen.queryByRole("alert")).toBeNull(); + } finally { + await act(async () => released.resolve(credential)); + } + // A canceled attempt's late credential never signs the user in. + expect(welcomeHeading()).toBeVisible(); + expect(connect).toHaveBeenCalledTimes(2); + identity.dispose(); +}); + +it("restore denial does not become first run or generate a replacement", async () => { + vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + const startDragging = vi.fn().mockResolvedValue(undefined); + vi.mocked(getCurrentWindow).mockReturnValue({ + startDragging, + } as unknown as ReturnType); + vi.mocked(invoke) + .mockRejectedValueOnce("Keychain access was denied") + .mockResolvedValueOnce(viewer); + const identity = createIdentity(); + const user = userEvent.setup(); + render( + +

Restored

+
, + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Keychain access was denied", + ); + expect( + screen.getByRole("heading", { name: "Your Buzz identity" }), + ).toBeVisible(); + expect(screen.queryByRole("button", { name: SIGN_IN })).toBeNull(); + fireEvent.mouseDown(titleBar(), { + button: 0, + detail: 1, + }); + expect(startDragging).toHaveBeenCalledOnce(); + await user.click( + screen.getByRole("button", { name: "Retry secure storage access" }), + ); + expect(await screen.findByText("Restored")).toBeVisible(); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "identity_restore", + ]); + identity.dispose(); +}); + +it("import failure keeps setup open without falling through to creation", async () => { + vi.mocked(invoke) + .mockResolvedValueOnce(null) + .mockRejectedValueOnce("Invalid nsec"); + const identity = createIdentity(); + await waitFor(() => expect(identity.snapshot().status).toBe("missing")); + await identity.importKey(key); + expect(identity.snapshot()).toEqual({ + status: "missing", + busy: false, + error: "Invalid nsec", + }); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).toEqual([ + "identity_restore", + "identity_import", + ]); + identity.dispose(); +}); + +it("coalesces pending creation and only creates on explicit request", async () => { + const created = deferred(); + vi.mocked(invoke) + .mockResolvedValueOnce(null) + .mockImplementationOnce(() => created.promise); + const identity = createIdentity(); + await waitFor(() => expect(identity.snapshot().status).toBe("missing")); + expect(invoke).toHaveBeenCalledTimes(1); + const first = identity.create(); + await identity.create(); + expect(invoke).toHaveBeenCalledTimes(2); + created.resolve(viewer); + await first; + expect(identity.snapshot()).toEqual({ status: "ready", viewer }); + identity.dispose(); +}); + +it("fetches a private key only on interaction, masks by absence, and cancels a late reveal", async () => { + vi.mocked(invoke).mockResolvedValueOnce(viewer); + const identity = createIdentity(); + await identity.ready; + const exported = deferred(); + vi.mocked(invoke).mockImplementationOnce(() => exported.promise); + const user = userEvent.setup(); + const view = render(); + expect(invoke).toHaveBeenCalledTimes(1); + expect(screen.getByLabelText("Private key (nsec)")).toHaveValue( + "••••••••••••••••", + ); + await user.click(screen.getByRole("button", { name: "Reveal private key" })); + await user.click(screen.getByRole("button", { name: "Cancel" })); + await act(async () => { + exported.resolve(key); + }); + expect(screen.getByLabelText("Private key (nsec)")).toHaveValue( + "••••••••••••••••", + ); + vi.mocked(invoke).mockResolvedValueOnce(key); + await user.click(screen.getByRole("button", { name: "Reveal private key" })); + expect(screen.getByLabelText("Private key (nsec)")).toHaveValue(key); + view.unmount(); + render(); + expect(screen.getByLabelText("Private key (nsec)")).toHaveValue( + "••••••••••••••••", + ); + identity.dispose(); +}); + +it("copies without revealing and cancels a pending export on blur before clipboard access", async () => { + vi.mocked(invoke).mockResolvedValueOnce(viewer); + const identity = createIdentity(); + await identity.ready; + const user = userEvent.setup(); + const write = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); + vi.mocked(invoke).mockResolvedValueOnce(key); + render(); + await user.click(screen.getByRole("button", { name: "Copy private key" })); + expect(write).toHaveBeenCalledWith(key); + expect(screen.getByLabelText("Private key (nsec)")).toHaveValue( + "••••••••••••••••", + ); + const exported = deferred(); + vi.mocked(invoke).mockImplementationOnce(() => exported.promise); + await user.click(screen.getByRole("button", { name: "Copy private key" })); + fireEvent(window, new Event("blur")); + await act(async () => { + exported.resolve(key); + }); + expect(write).toHaveBeenCalledTimes(1); + identity.dispose(); +}); + +it("enables packaged identity on desktop platforms but never in the live broker or browser", () => { + vi.mocked(isTauri).mockReturnValue(true); + vi.stubEnv("VITE_BUZZ_LIVE", ""); for (const platform of ["MacIntel", "Win32", "Linux x86_64"]) { vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); expect(nativeIdentityEnabled()).toBe(true); @@ -819,7 +5496,23 @@ it.each(["Linux x86_64", "Win32"])( vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); vi.mocked(invoke).mockReturnValue(new Promise(() => {})); const identity = createIdentity(); - render(Signed in); + render( + + Signed in + , + ); + expect( + screen.getByRole("heading", { name: "Your Buzz identity" }), + ).toBeVisible(); + expect(screen.getByRole("status")).toHaveTextContent( + "Opening your saved identity…", + ); expect(titleBar()).toHaveAttribute("data-tauri-drag-region", "true"); expect(screen.getByRole("button", { name: "Close window" })).toBeEnabled(); identity.dispose();