diff --git a/.env.example b/.env.example index 24a5beb5d5..4de22a8e68 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,20 @@ BUZZ_DEV_VIEWER= # Production builds ignore it. # BUZZ_DEV_OPEN_RELAY=1 +# Optional: answer the first-run Builderlab account reads (linked key, owned +# communities, backed-up keys) and fake the key link, unlink, backup and restore +# without kgoose or the native uploader, so the returning-user screens can be +# walked against a real account whose key binding must not change. `unlinked`, +# or a JSON object with optional fields {"npub":"npub1…", +# "communities":["host",…],"known":["host",…],"backups":["npub1…",…], +# "recovery":"nsec1…","backup":""|"fail-once", +# "unlink":}. `known` lists the destinations saved to the account. `recovery` is +# the key every restore recovers: use a throwaway key; without it, restore +# answers that recovery is unavailable. +# Dev server only; production builds ignore it. Restart the dev server after +# changing this. +# BUZZ_DEV_FIRST_RUN_ACCOUNT=unlinked + # Optional compatibility aliases for previously saved community IDs. # Keep the same alias mapped to the same origin to retain existing memberships. # Values are public configuration included in the frontend bundle, never secrets. diff --git a/browser-host/vite-config.test.mjs b/browser-host/vite-config.test.mjs index 5f5f1e45ba..28c41a69c9 100644 --- a/browser-host/vite-config.test.mjs +++ b/browser-host/vite-config.test.mjs @@ -169,6 +169,46 @@ it("loads the broker's Vite config without native-compatibility warnings", () => ); assert.equal(result.config.define['import.meta.env.VITE_BUZZ_BUILDERLAB_URL'], JSON.stringify(expected)); } + // The first-run account seed reaches only a dev server, and a + // malformed value fails at configuration time without being echoed. + for (const command of ['serve', 'build']) { + process.env.BUZZ_DEV_FIRST_RUN_ACCOUNT = 'unlinked'; + const seeded = await loadConfigFromFile( + { command, mode: command === 'serve' ? 'development' : 'production' }, configFile, + ); + assert.equal( + seeded.config.define['import.meta.env.VITE_BUZZ_FIRST_RUN_ACCOUNT'], + JSON.stringify(command === 'serve' ? 'unlinked' : ''), + ); + } + // A backup kind resolves against the failure table, and backups + // and a recovery key decode, through the native loader. + const failingSeed = JSON.stringify({ + backup: 'unreachable', + known: ['design.example.com'], + backups: ['npub1424242424242424242424242424242424242424242424242424qamrcaj'], + recovery: 'nsec1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqstywftw', + }); + process.env.BUZZ_DEV_FIRST_RUN_ACCOUNT = failingSeed; + const failing = await loadConfigFromFile({ command: 'serve', mode: 'development' }, configFile); + assert.equal( + failing.config.define['import.meta.env.VITE_BUZZ_FIRST_RUN_ACCOUNT'], + JSON.stringify(failingSeed), + ); + for (const seed of [ + '{"npub":"s3cret"}', + '{"backup":"s3cret"}', + '{"backups":["s3cret"]}', + '{"recovery":"nsec1s3cret"}', + '{"known":["http://s3cret.example"]}', + ]) { + process.env.BUZZ_DEV_FIRST_RUN_ACCOUNT = seed; + await assert.rejects( + loadConfigFromFile({ command: 'serve', mode: 'development' }, configFile), + (error) => /BUZZ_DEV_FIRST_RUN_ACCOUNT must be/.test(error.message) && !error.message.includes('s3cret'), + ); + } + delete process.env.BUZZ_DEV_FIRST_RUN_ACCOUNT; // Without a viewer pin nothing consumes the seed, so it is neither exposed nor required. process.env.BUZZ_DEV_OPEN_RELAY = '1'; process.env.BUZZ_RELAY_URL = ''; 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/configuration.md b/docs/configuration.md index 89850d7a1f..8a04c9f89d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,6 +15,7 @@ Build defaults are readable binary data, **never secret storage**. | `BUZZ_RELAY_URL` | Live development broker's default community, not an agent relay override or a packaged default. | | `BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY` | Presence-only alias for fresh-viewer community selection in live development only. Saved viewer choice wins. | | `BUZZ_DEV_OPEN_RELAY` | Development-specific override of that alias: only `1` enables; `0` explicitly opts out. Requires a relay URL and live viewer pin to have an effect. | +| `BUZZ_DEV_FIRST_RUN_ACCOUNT` | Dev server only: answers the first-run Builderlab account reads (linked key, owned communities, saved communities, backed-up keys) in place of kgoose, and fakes backup recovery. Builds define it empty. See [below](#first-run-account-seed-development). | | `BUZZ_DEV_VIEWER`, `BUZZ_COMMUNITY_ALIASES`, `BUZZ_DEV_NOTIFICATIONS` | Existing public viewer pin, public routing aliases and dev notification override; unchanged. See the [development setup](contributing.md). | | `BUZZ_UPDATER_PUBLIC_KEY`, `BUZZ_UPDATER_ENDPOINT` | Native release build, process environment only: two non-empty values register the updater plugin. The same public key and endpoint must reach `tauri build --config` as `plugins.updater`, and update archives must be signed by the matching private key. Desktop preview release runs supply both for every platform (see [releases](releases.md)); candidate-only and local builds report automatic updates as unavailable. | @@ -117,6 +118,61 @@ web and desktop frontend builds substitute the value into compiled code: changin or the launched app's environment afterward cannot override it. Rebuild and redistribute to change a packaged value. +## First-run account seed (development) + +After Builderlab sign-in, the first-launch flow reads four things about the +account from kgoose: the Nostr key bound to it, the communities it owns, the +destinations saved to it ([known communities](communities.md#known-communities)) +and the keys it holds a backup of ([identity](identity.md)), binds a key it creates or +imports to an unlinked account, and, on Start over, unlinks the lost key +before binding the new one. A developer's real account is bound to their real key and that binding +must not change, so `BUZZ_DEV_FIRST_RUN_ACCOUNT` replaces those reads, the +binding, the unlink, the key backup and backup recovery — and nothing else — +on a dev server: + +```sh +# A brand-new account: the key-setup choice follows sign-in. +BUZZ_DEV_FIRST_RUN_ACCOUNT=unlinked +# A returning user: Welcome back for this npub, with these owned communities. +BUZZ_DEV_FIRST_RUN_ACCOUNT='{"npub":"npub1…","communities":["design.example.com"]}' +# An account with these destinations saved to it (read with the account check; nothing restores them yet). +BUZZ_DEV_FIRST_RUN_ACCOUNT='{"known":["chat.example.com","wss://buzz.example.org:8443"]}' +# A brand-new account whose every backup fails as if Builderlab were unreachable. +BUZZ_DEV_FIRST_RUN_ACCOUNT='{"backup":"unreachable"}' +# A brand-new account whose first backup fails, so Try again then succeeds. +BUZZ_DEV_FIRST_RUN_ACCOUNT='{"backup":"fail-once"}' +# A returning user whose Start over unlink fails as kgoose answering 503 would. +BUZZ_DEV_FIRST_RUN_ACCOUNT='{"npub":"npub1…","unlink":503}' +# A returning user with Restore from backup, which answers that recovery is unavailable. +BUZZ_DEV_FIRST_RUN_ACCOUNT='{"npub":"npub1…","backups":["npub1…"]}' +# The same, where every restore recovers this throwaway key. +BUZZ_DEV_FIRST_RUN_ACCOUNT='{"npub":"npub1…","backups":["npub1…"],"recovery":"nsec1…"}' +``` + +Every field is optional; without `npub` the account is unlinked. `communities` +lists hostnames (or `wss://`/`https://` origins) the way the Community URL field +accepts them; `known` lists the destinations saved to the account the same way, +answered as kgoose spells them (`wss://host[:port]`). `backup` names a failure kind from +`src/features/identity/backup-failure.ts` (such as `unreachable`, `timeout` or +`refused`), and every backup then fails with that kind's page; `fail-once` +fails only the first backup after each sign-in, as Builderlab not confirming +it, so Try again succeeds. `unlink` is an +HTTP error status (400–599), and every unlink then fails as kgoose answering it +would: a 429 or 5xx offers Try again, and a 401 reads as a lapsed sign-in. `backups` lists the npubs backup status answers +(none when absent), so Welcome back offers Restore from backup when it holds +`npub`. `recovery` is the `nsec1…` every restore recovers, which installing +imports as if it were typed; without it every restore answers that recovery is +unavailable, as every tenant does today. The value is plain text in your +environment, so use a throwaway key, never a real one. Vite exposes the value +as `VITE_BUZZ_FIRST_RUN_ACCOUNT` for `serve` only; `pnpm build` and +`pnpm tauri build` define it empty, so packaged apps always ask kgoose. A +malformed value fails the Vite configuration without being echoed. Restart the +dev server after changing it. With a seed, binding, unlinking and backup +succeed (unless `unlink` or `backup` is set), and a joined community's save to +the account succeeds, without a request, a native signature or a native backup +command, in every build. +Restore opens no browser and runs no native recovery command. + ## Whole-video limit The desktop app reads relay video from its origin one bounded `Range` at 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 f8916b23bb..b543b72e57 100644 --- a/docs/identity.md +++ b/docs/identity.md @@ -1,13 +1,409 @@ # 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 Builderlab-hosted +community (a host under `communities.buzz.xyz`, `block.builderlab.xyz` or +`test.blockstaging.build` on the default port, or a relay publishing one of +those deployments' NIP-11 `self` keys; `builderlab-hosted.ts`) requires +Builderlab sign-in, so it starts at once at `BUZZ_BUILDERLAB_URL`, exactly as +the welcome screen's own button does, and setup then lands on **Join +Community** for the address; it is never offered a key of the user's own +instead. Any other found community pauses on **Connect to {host}**: nothing a +relay publishes names its sign-in portal, so the user chooses. 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 +507,20 @@ 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. +Otherwise, as in 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 +561,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 +777,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 +919,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 008c16734c..8cdde066dc 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/bundled/builderlab/README.md b/src/bundled/builderlab/README.md index 11cebfb4a3..763102471c 100644 --- a/src/bundled/builderlab/README.md +++ b/src/bundled/builderlab/README.md @@ -135,6 +135,25 @@ agents are not migrated into this type. plugin-lifetime session. `browserCredential()` returns a verified `Credential`; `session.credential()` provides access to it. The session handles cancellation, sign-out, and disposal. + `oauth/first-run.ts` lets the app's first-launch welcome screen run the same + sign-in before any plugin session exists, using this plugin's bundled native + grant, at the build URL like the Settings card; that credential is separate + from the Settings session. + A successful first-run sign-in continues into the app-owned setup steps + described in [identity](../../../docs/identity.md): the app reads the key + bound to the account, the destinations saved to it (this plugin's + known-communities list route, never through the Settings session's client) + and the communities it owns through this plugin's bundled grant + (`src/features/identity/builderlab-account.ts`, reusing the + `communities/` routes and parsers); a linked account is asked for that + key, otherwise the user creates a key or brings one they already have, and + that key is then bound to the account through the same native signer and + backed up (an imported key only once the user agrees); + starting over creates a new key and unlinks the lost one before binding it. + Key backup hands the sign-in to the native uploader, and the same portal is + asked which keys the account holds a backup of; Welcome back + restores the linked key from one through native recovery's own browser + sign-in. - `login/` owns the login experience: pending state, cancellation, retry, email display, and sign-out. - `agents/` lists the signed-in account's remote agents beneath login, with diff --git a/src/bundled/builderlab/known-communities/client.ts b/src/bundled/builderlab/known-communities/client.ts index 8de482e5f4..bc04e8f855 100644 --- a/src/bundled/builderlab/known-communities/client.ts +++ b/src/bundled/builderlab/known-communities/client.ts @@ -57,7 +57,7 @@ function unexpected(status: number): Refusal { /** A listed row's destination. `null` is a row whose address this device could * never save (dropped on its own), `undefined` a row of a shape this client * does not understand, which makes the whole response invalid. */ -function address(row: unknown): string | null | undefined { +export function listedAddress(row: unknown): string | null | undefined { if (!row || typeof row !== "object") return undefined; const { community_url } = row as Record; if (typeof community_url !== "string") return undefined; @@ -161,7 +161,7 @@ export function createKnownCommunitiesClient( // Protobuf JSON can omit an empty repeated field. const rows = value.communities ?? []; if (!Array.isArray(rows)) return { kind: "invalid_response" }; - const addresses = rows.map(address); + const addresses = rows.map(listedAddress); if (addresses.includes(undefined)) return { kind: "invalid_response" }; const communities = addresses.filter((a): a is string => a !== null); return { diff --git a/src/bundled/builderlab/oauth/first-run.test.ts b/src/bundled/builderlab/oauth/first-run.test.ts new file mode 100644 index 0000000000..ef2f0cc30b --- /dev/null +++ b/src/bundled/builderlab/oauth/first-run.test.ts @@ -0,0 +1,46 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; +import { bundledHost, firstRunCredential } from "./first-run"; +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), + isTauri: vi.fn(() => true), +})); +afterEach(() => { + vi.resetAllMocks(); + vi.unstubAllEnvs(); +}); + +it("requests under the bundled Builderlab grant without a plugin owner", async () => { + vi.mocked(invoke).mockResolvedValueOnce({ + status: 200, + headers: {}, + body: "{}", + }); + const request = { url: "https://app.builderlab.xyz/api/goose/v1/auth/me" }; + await bundledHost.request(request); + expect(invoke).toHaveBeenCalledWith("plugin_host_request", { + id: "block.builderlab", + revision: "bundled", + request, + }); + expect(await bundledHost.runCommand("any")).toBeNull(); +}); + +it("starts the native browser handoff at the configured build URL", async () => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); + // A refused listener ends the attempt before any exchange request. + vi.mocked(invoke).mockRejectedValueOnce("listener unavailable"); + await expect( + firstRunCredential(new AbortController().signal), + ).rejects.toThrow("Could not complete"); + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledWith( + "oauth_callback_begin", + expect.objectContaining({ + authorizationUrl: expect.stringMatching( + /^https:\/\/builderlab\.example\/api\/goose\/v1\/auth\/login\?/, + ), + useState: true, + }), + ); +}); diff --git a/src/bundled/builderlab/oauth/first-run.ts b/src/bundled/builderlab/oauth/first-run.ts new file mode 100644 index 0000000000..9872deca17 --- /dev/null +++ b/src/bundled/builderlab/oauth/first-run.ts @@ -0,0 +1,23 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { Host } from "../../../features/host/service"; +import manifest from "../manifest.json"; +import { browserCredential, type Credential } from "./browser"; + +/** + * First launch runs before this plugin can share its Settings session, so the + * welcome screen signs in through the same OAuth client under the bundled + * plugin's own native network grant. Native code grants that origin at build + * time; it does not depend on the plugin's JavaScript having started. + */ +export const bundledHost: Host = { + runCommand: () => Promise.resolve(null), + request: (request) => + invoke("plugin_host_request", { + id: manifest.id, + revision: "bundled", + request, + }), +}; + +export const firstRunCredential = (signal: AbortSignal): Promise => + browserCredential(bundledHost, signal); diff --git a/src/features/identity/BackupOffer.tsx b/src/features/identity/BackupOffer.tsx new file mode 100644 index 0000000000..4d84adb583 --- /dev/null +++ b/src/features/identity/BackupOffer.tsx @@ -0,0 +1,41 @@ +import { Button } from "../../shared/design-system/ui/Button"; + +/** + * Pause after an imported key, on either lane. Importing must never upload + * silently: the backup is offered, and the key continues either way. The key + * may be used elsewhere, so the copy discloses that Block can restore it there + * too; choosing Back it up is the backup consent. + */ +export function BackupOffer({ + onSkip, + onBackUp, +}: { + onSkip: () => void; + onBackUp: () => void; +}) { + return ( + <> +
+

+ Back up this key? +

+

+ Buzz can keep an encrypted copy in your account so you can skip this + step on your next device. Block can use that copy to restore this key, + including anywhere else you use it. +

+
+ {/* Footer order: secondary first, primary last. */} +
+ + +
+ + ); +} diff --git a/src/features/identity/CommunityUrlField.test.ts b/src/features/identity/CommunityUrlField.test.ts new file mode 100644 index 0000000000..74632d4673 --- /dev/null +++ b/src/features/identity/CommunityUrlField.test.ts @@ -0,0 +1,47 @@ +import { expect, it } from "vitest"; +import { enteredCommunity } from "./CommunityUrlField"; + +it.each([ + ["relay.example.com", "https://relay.example.com", ""], + [" Relay.Example.com:8443 ", "https://relay.example.com:8443", ""], + ["wss://relay.example.com/", "https://relay.example.com", ""], + ["https://relay.example.com", "https://relay.example.com", ""], + [ + "https://Relay.Example.com:8443/invite/v2.Invite-code_1", + "https://relay.example.com:8443", + "v2.Invite-code_1", + ], + [ + "https://relay.example.com/invite/v2.code/", + "https://relay.example.com", + "v2.code", + ], + [ + "buzz://join?relay=wss%3A%2F%2Frelay.example.com%3A8443&code=v2.code", + "https://relay.example.com:8443", + "v2.code", + ], + // Windows hands the app link over with a root path. + [ + "buzz://join/?relay=wss%3A%2F%2Frelay.example.com&code=v2.code&policy_receipt=r", + "https://relay.example.com", + "v2.code", + ], +])("reads %j as %s with code %j", (input, origin, code) => { + expect(enteredCommunity(input)).toEqual({ origin, code }); +}); + +it.each([ + "", + "not a url", + "ws://relay.example.com", + "https://relay.example.com/other", + "relay.example.com/invite/v2.code", + "http://relay.example.com/invite/v2.code", + "https://user@relay.example.com/invite/v2.code", + "https://relay.example.com/invite/v2.code?next=evil", + "buzz://join?relay=wss%3A%2F%2Frelay.example.com", + "buzz://join?relay=ws%3A%2F%2Frelay.example.com&code=v2.code", +])("refuses %j", (input) => { + expect(enteredCommunity(input)).toBeUndefined(); +}); diff --git a/src/features/identity/CommunityUrlField.tsx b/src/features/identity/CommunityUrlField.tsx new file mode 100644 index 0000000000..5b0199160c --- /dev/null +++ b/src/features/identity/CommunityUrlField.tsx @@ -0,0 +1,88 @@ +import { useId, useState } from "react"; +import { enteredRelayOrigin } from "../communities/destination"; +import { parsePastedInvite } from "../communities/invite-link"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Field } from "../../shared/design-system/ui/Field"; +import { Input } from "../../shared/design-system/ui/Input"; + +/** + * A typed address (a bare host is a secure relay) with no code, else an invite + * link — the relay's `https:///invite/` page or the `buzz://join` + * link it opens — with its code. A bare address never resolves an alias. + */ +export function enteredCommunity(url: string) { + try { + return { origin: enteredRelayOrigin(url), code: "" }; + } catch { + const invite = parsePastedInvite(url); + return invite?.code + ? { origin: invite.community, code: invite.code } + : undefined; + } +} + +/** + * The community URL field with its Continue button, shared by the first-run + * landing and Join a community. Enter is handled by the input, not a native + * form submit, so it reports an empty or invalid address whatever the button's + * state; Continue therefore stays enabled (only `disabled` turns it off) and a + * click shows the same error under the field. Continue only starts the lookup; + * the join itself happens on Join Community. + */ +export function CommunityUrlField({ + url, + onUrlChange, + onSubmit, + disabled = false, +}: { + url: string; + onUrlChange: (url: string) => void; + /** Receives the canonical secure origin, and an invite link's code ("" for an address). */ + onSubmit: (origin: string, code: string) => void; + disabled?: boolean; +}) { + const [invalid, setInvalid] = useState(false); + const errorId = useId(); + const entered = enteredCommunity(url); + const submit = () => { + if (entered) onSubmit(entered.origin, entered.code); + else setInvalid(true); + }; + return ( +
+
+
+ + { + onUrlChange(event.target.value); + setInvalid(false); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing) + return; + event.preventDefault(); + submit(); + }} + /> + +
+ +
+ {invalid && ( + + )} +
+ ); +} diff --git a/src/features/identity/ConnectChoice.tsx b/src/features/identity/ConnectChoice.tsx new file mode 100644 index 0000000000..6bcedbf097 --- /dev/null +++ b/src/features/identity/ConnectChoice.tsx @@ -0,0 +1,60 @@ +import { Button } from "../../shared/design-system/ui/Button"; + +/** + * Pause on a found community that is not Builderlab-hosted (a hosted one signs + * in at once), before any sign-in: the relay cannot sign the user in, but Buzz + * still needs a key. Connect runs + * the build portal's sign-in exactly as the landing's button does (the key it + * creates is linked to that account and backed up there); the import brings a + * key with no account to link it to. Either way setup ends on Join Community + * for the found address. + */ +export function ConnectChoice({ + host, + onConnect, + onImport, + onBack, +}: { + /** The found community's host, as the find page shows it. */ + host: string; + /** Sign in at the build portal and continue as the landing’s Sign in with email does. */ + onConnect: () => void; + /** The user already has a key: open the import page. */ + onImport: () => void; + /** Return to the landing with the address kept. */ + onBack: () => void; +}) { + return ( + <> +
+

+ Connect to your community +

+ {/* The host stands apart from the title so a long one wraps on its own + line and the title reads as one phrase to assistive technology. */} +

+ {host} +

+

+ Buzz relies on a key for your identity. +

+
+
+ + {/* The secondary choice is deliberately smaller than the primary. */} + +
+
+ +
+ + ); +} 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/InviteCodeField.tsx b/src/features/identity/InviteCodeField.tsx new file mode 100644 index 0000000000..57bcce1529 --- /dev/null +++ b/src/features/identity/InviteCodeField.tsx @@ -0,0 +1,73 @@ +import { useId, useState } from "react"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Field } from "../../shared/design-system/ui/Field"; +import { Input } from "../../shared/design-system/ui/Input"; + +/** + * The invite page's code field with its Join button. A refusal shows under + * the field until the code is edited; the typed code is kept either way. + */ +export function InviteCodeField({ + error, + busy, + pending, + onSubmit, +}: { + /** Why the relay refused the last code. */ + error?: string | undefined; + /** Something on the page is in flight: nothing can be submitted. */ + busy: boolean; + /** This field's own claim is the one in flight. */ + pending: boolean; + onSubmit: (code: string) => void; +}) { + const [code, setCode] = useState(""); + const [edited, setEdited] = useState(false); + const errorId = useId(); + const shown = edited ? undefined : error; + const value = code.trim(); + const submit = () => { + if (!value || busy) return; + setEdited(false); + onSubmit(value); + }; + // `CommunityUrlField`'s recipe: the error sits below the row, so Join stays + // aligned with the input. + return ( +
+
+
+ + { + setCode(event.target.value); + setEdited(true); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing) + return; + event.preventDefault(); + submit(); + }} + /> + +
+ +
+ {shown && ( + + )} +
+ ); +} diff --git a/src/features/identity/JoinCommunity.tsx b/src/features/identity/JoinCommunity.tsx new file mode 100644 index 0000000000..7a0ce6194d --- /dev/null +++ b/src/features/identity/JoinCommunity.tsx @@ -0,0 +1,82 @@ +import { Avatar } from "../../shared/design-system/ui/Avatar"; +import { Button } from "../../shared/design-system/ui/Button"; +import type { OwnedCommunity } from "./builderlab-account"; +import { CommunityUrlField } from "./CommunityUrlField"; +import { OnboardingScreen } from "./OnboardingScreen"; + +/** + * First-run landing after setup. The community URL is the live action; a + * linked account's owned communities are offered as rows that take the same + * path with their address. + */ +export function JoinCommunity({ + url, + onUrlChange, + onFind, + owned = [], +}: { + url: string; + onUrlChange: (url: string) => void; + /** Receives the canonical secure origin, and an invite link's code when one was pasted. */ + onFind: (origin: string, code?: string) => void; + owned?: readonly OwnedCommunity[] | undefined; +}) { + // The in-app Join a community landing's layout (CommunityLanding). + return ( + +

+ Join a community +

+
+ {owned.length > 0 && ( + // min-w-0 keeps a long hostname truncating rather than widening the grid. +
+

+ Communities you own +

+
    + {owned.map(({ host, url: origin }) => ( +
  • + +

    + {host} +

    + +
  • + ))} +
+
+ )} + +
+ + } + > + + +
+ ); +} diff --git a/src/features/identity/KeyChoice.tsx b/src/features/identity/KeyChoice.tsx new file mode 100644 index 0000000000..ea4496a962 --- /dev/null +++ b/src/features/identity/KeyChoice.tsx @@ -0,0 +1,75 @@ +import { Button } from "../../shared/design-system/ui/Button"; +import styles from "./IdentitySetup.module.css"; +import { OnboardingScreen } from "./OnboardingScreen"; + +/** + * Pause on an unlinked account: create a key, which is then linked and backed + * up, or bring an existing one. The copy discloses that Block can restore the + * key, since choosing to create it is the backup consent. + */ +export function KeyChoice({ + email, + backups, + onCreate, + onImport, + onSwitchAccount, +}: { + /** The signed-in Builderlab account the lookup ran for. */ + email: string | undefined; + /** + * The keys the backed-up-key lookup listed; `undefined` when it was + * unavailable or failed. Backups outlive an unlink, so an unlinked account + * may still list some, and only an empty list says none was found. + */ + backups: readonly string[] | undefined; + /** Create the key, link it to the account, then back it up. */ + onCreate: () => void; + /** The user already has a key: open the import page. */ + onImport: () => void; + /** Wrong account: sign the session out and return to the landing. */ + onSwitchAccount: () => void; +}) { + // The welcome screen's create-or-import stack, after sign-in. + return ( + +

+ Set up your key +

+

+ Buzz signs everything you do with a private key kept on this device. + {backups?.length === 0 && + " We didn’t find a backed-up key for this account, so let’s make one."}{" "} + An encrypted copy is backed up to your account, and Block can use it + to restore the key on your other devices. +

+ {/* Who the key would belong to, so a wrong account is visible before any key exists. */} +

+ {email ? `Signed in as ${email}.` : "Signed in."} +

+ + } + > + + + +
+ ); +} diff --git a/src/features/identity/KeyImport.tsx b/src/features/identity/KeyImport.tsx new file mode 100644 index 0000000000..e982545f08 --- /dev/null +++ b/src/features/identity/KeyImport.tsx @@ -0,0 +1,213 @@ +import { useId, useRef, useState } from "react"; +import { npubEncode } from "nostr-tools/nip19"; +import { useFileDrop } from "../messages/use-file-drop"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Field } from "../../shared/design-system/ui/Field"; +import { Input } from "../../shared/design-system/ui/Input"; +import styles from "./IdentitySetup.module.css"; +import { classifyKeyImport, readKeyFile } from "./key-import"; +import { OnboardingScreen } from "./OnboardingScreen"; +import { SetupProgress } from "./SetupProgress"; + +/** + * Pause for a key the user already has: paste or type an `nsec1…` key, or + * drop or choose a small text file holding one. Valid input previews the + * npub; Continue hands the nsec to native import. The key exists only in this + * component's state and is never logged or stored, so leaving the page + * discards it. While import runs the form gives way to the progress page on + * the same screen but stays mounted, so a native refusal returns to it with + * the typed key kept. + * With `expectedPubkey`, only that key continues: any other valid nsec is + * refused under the field. + */ +export function KeyImport({ + expectedPubkey, + onBack, + onImport, +}: { + /** The hex key linked to the signed-in account, when the lane has one. */ + expectedPubkey?: string | undefined; + onBack: () => void; + /** Rejects with the native refusal, shown under the field. */ + onImport: (nsec: string) => Promise; +}) { + const [text, setText] = useState(""); + const [revealed, setRevealed] = useState(false); + const [busy, setBusy] = useState(false); + const [failure, setFailure] = useState(); + const form = useRef(null); + const formId = useId(); + const picker = useRef(null); + const input = classifyKeyImport(text); + const expected = + expectedPubkey === undefined ? undefined : npubEncode(expectedPubkey); + const mismatch = + input.kind === "nsec" && expected !== undefined && input.npub !== expected; + const ready = input.kind === "nsec" && !mismatch; + const change = (next: string) => { + setText(next); + setFailure(undefined); + // Masked by default must re-assert for the next key pasted. + if (!next.trim()) setRevealed(false); + }; + const fromFile = async (file: File | undefined) => { + if (!file) return; + try { + change(await readKeyFile(file)); + } catch (error) { + setFailure( + error instanceof Error ? error.message : "Couldn’t read that file.", + ); + } + }; + // The whole form is the drop target; the hook rebinds when the form returns. + const dragging = useFileDrop(form, !busy, (files) => void fromFile(files[0])); + const submit = async () => { + if (!ready || busy) return; + setBusy(true); + try { + await onImport(input.nsec); + } catch (error) { + setFailure( + error instanceof Error ? error.message : "Couldn’t import this key.", + ); + } finally { + setBusy(false); + } + }; + const message = + failure ?? + (mismatch ? ( + <> + That isn’t the key linked to this account. It expects{" "} + + {expected} + {" "} + (the key on your other device). + + ) : input.kind === "encrypted" ? ( + "Password-protected backups aren’t supported here yet. Paste the nsec1 key itself." + ) : input.kind === "invalid" ? ( + "That doesn’t look like a valid nsec. Paste an nsec1 key." + ) : undefined); + return ( + + + + ) : ( + <> + {/* The onboarding import layout: the actions below submit through `form`. */} +
{ + event.preventDefault(); + void submit(); + }} + > +

+ Enter your private key +

+

+ Paste your private key to sign in to Buzz. You can also drop a + text file holding it here, or choose one. +

+
+ + Public identity:{" "} + + {input.npub} + + + ) + } + > + + +
+
+ + +
+

+ {dragging + ? "Drop to use this file." + : "A key file is a small text file whose first line is the nsec1 key."} +

+
+
+
+ {/* Outside the form, so the key is its only field and Enter submits. */} + { + const file = event.target.files?.[0]; + event.target.value = ""; + void fromFile(file); + }} + /> + + ) + } + > + {!busy && ( + <> + + + + )} +
+ ); +} diff --git a/src/features/identity/KeyLost.tsx b/src/features/identity/KeyLost.tsx new file mode 100644 index 0000000000..363e8eec3c --- /dev/null +++ b/src/features/identity/KeyLost.tsx @@ -0,0 +1,73 @@ +import { Button } from "../../shared/design-system/ui/Button"; +import type { OwnedCommunity } from "./builderlab-account"; +import { WireframePanel } from "./Wireframe"; + +/** + * Pause for a linked key the user no longer has. Starting over creates a new + * key, unlinks this one, links the new one in its place and backs it up, with + * no key choice between; the page spells out what the old key keeps and, as + * Set up your key does, that Block can restore the new one. + */ +export function KeyLost({ + owned, + onBack, + onStartOver, +}: { + /** The communities the old key owns; `undefined` when the list could not be loaded. */ + owned: readonly OwnedCommunity[] | undefined; + onBack: () => void; + onStartOver: () => void; +}) { + return ( + <> +
+

+ Start over with a new key +

+

+ A new key is a new identity. Everything the old key could reach stays + with it: its profile, its messages and the communities it belongs to. + Starting over unlinks the old key, then creates a new key, links it to + this account and keeps an encrypted copy that Block can use to restore + it. +

+
+ +

Communities you own

+ {owned === undefined ? ( +

+ Couldn’t load the communities this account owns. +

+ ) : owned.length === 0 ? ( +

None

+ ) : ( +
    + {owned.map(({ host, url }) => ( +
  • + {host} +
  • + ))} +
+ )} +

+ Memberships in other people’s communities can’t be listed here and + will also be lost. +

+
+ {/* Footer order: secondary first, primary last. */} +
+ + +
+ + ); +} diff --git a/src/features/identity/KeyRestore.tsx b/src/features/identity/KeyRestore.tsx new file mode 100644 index 0000000000..0e09a49040 --- /dev/null +++ b/src/features/identity/KeyRestore.tsx @@ -0,0 +1,98 @@ +import { useSyncExternalStore } from "react"; +import { Button } from "../../shared/design-system/ui/Button"; +import type { Recovery } from "./builderlab-account"; +import styles from "./IdentitySetup.module.css"; +import { OnboardingScreen } from "./OnboardingScreen"; +import { RestoreIdentity } from "./RestoreIdentity"; +import { recoveryOff } from "./service"; + +/** + * Pause after Welcome back's Restore from backup: native recovery's browser + * sign-in, then the linked key's backup to install. Where recovery is + * unavailable or refused, which no retry changes, or no backup holds the + * linked key, the page offers Enter your private key instead of Try again. + */ +export function KeyRestore({ + recovery, + expectedPubkey, + onBack, + onImport, + onInstalled, +}: { + recovery: Recovery; + /** The hex key linked to the account: the only one restored. */ + expectedPubkey: string; + /** Recovery was canceled: return to Welcome back. */ + onBack: () => void; + /** Recovery was canceled: open the import page. */ + onImport: () => void; + /** Installing settled, restored or not. */ + onInstalled: () => void; +}) { + const state = useSyncExternalStore(recovery.subscribe, recovery.snapshot); + // A recovery holds the identity busy, so leaving cancels it first. + const leave = (then: () => void) => () => + recovery.cancelRecovery().then((canceled) => { + if (canceled) then(); + return canceled; + }); + const off = recoveryOff(state); + // "Stored securely" holds only while a restore may still install the key: + // an outcome that restored nothing explains itself below. + const restoring = + state?.status === "waiting" || + (state?.status === "choosing" && + state.candidates.some( + (candidate) => candidate.pubkey === expectedPubkey, + )); + return ( + +

+ Restore from backup +

+ {(off || restoring) && ( +

+ {off + ? "Backup restore isn’t available for this account. Enter your private key to continue." + : "Your key is stored securely on this device."} +

+ )} + {off ? ( + // Footer order: secondary first, primary last. +
+ + +
+ ) : state ? ( + + recovery.installRecovery(handle).then(onInstalled), + }} + recovery={state} + expectedPubkey={expectedPubkey} + onImport={() => void leave(onImport)()} + /> + ) : null} + + } + > + {null} +
+ ); +} diff --git a/src/features/identity/ProfileStep.test.tsx b/src/features/identity/ProfileStep.test.tsx new file mode 100644 index 0000000000..1192af7ff9 --- /dev/null +++ b/src/features/identity/ProfileStep.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ComponentProps } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { ProfileStep } from "./ProfileStep"; + +const found = { + url: "https://relay.example.com", + host: "relay.example.com", +}; +const none = { + existing: {}, + profile: { name: "", picture: "", about: "" }, + exists: false, +}; +function deferred() { + let resolve!: () => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} +function step(props: Partial> = {}) { + const onContinue = vi.fn["onContinue"]>( + () => Promise.resolve(), + ); + const onSkip = vi.fn["onSkip"]>(() => + Promise.resolve(), + ); + render( + , + ); + return { onContinue, onSkip, user: userEvent.setup() }; +} +afterEach(() => { + cleanup(); + vi.resetAllMocks(); +}); + +it("suggests and focuses the default name, needs a name to continue, and hands the trimmed profile to Continue", async () => { + const { onContinue, onSkip, user } = step(); + const name = screen.getByRole("textbox", { name: "Display name" }); + expect(name).toHaveValue("ada"); + expect(name).toHaveFocus(); + await user.clear(name); + expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled(); + await user.type(name, " Ada "); + await user.type( + screen.getByRole("textbox", { name: "Profile description (optional)" }), + "Hi ", + ); + await user.click(screen.getByRole("button", { name: "Continue" })); + expect(onContinue).toHaveBeenCalledExactlyOnceWith({ + name: "Ada", + picture: "", + about: "Hi", + }); + expect(onSkip).not.toHaveBeenCalled(); +}); + +it("prefills a relay profile and hands it to Continue as it is", async () => { + const profile = { name: "Ada", picture: "", about: "Hi" }; + const { onContinue, user } = step({ + read: { existing: { name: "Ada", about: "Hi" }, profile, exists: true }, + defaultName: "ignored", + }); + expect(screen.getByRole("textbox", { name: "Display name" })).toHaveValue( + "Ada", + ); + await user.click(screen.getByRole("button", { name: "Continue" })); + expect(onContinue).toHaveBeenCalledExactlyOnceWith(profile); +}); + +it("holds both buttons while Continue runs, and a rejection stays on the step with the error inline and the draft kept", async () => { + const gate = deferred(); + const { onContinue, onSkip, user } = step(); + onContinue.mockReturnValueOnce(gate.promise); + const name = screen.getByRole("textbox", { name: "Display name" }); + await user.clear(name); + await user.type(name, "Ada{Enter}"); + expect(onContinue).toHaveBeenCalledOnce(); + expect(screen.getByRole("button", { name: "Continue" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Skip for now" })).toBeDisabled(); + expect(name).toBeDisabled(); + gate.reject( + new Error( + "Your profile change is not current. Your edits are retained; try again.", + ), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Your profile change is not current. Your edits are retained; try again.", + ); + expect(name).toHaveValue("Ada"); + expect(name).toBeEnabled(); + expect(screen.getByRole("button", { name: "Continue" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Skip for now" })).toBeEnabled(); + expect(onSkip).not.toHaveBeenCalled(); +}); + +it("classifies a denied read, and Skip for now hands off without the draft and keeps the step when it fails", async () => { + const { onContinue, onSkip, user } = step(); + onContinue.mockRejectedValueOnce( + Object.assign(new Error("Relay read failed (403)"), {}), + ); + await user.click(screen.getByRole("button", { name: "Continue" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "relay.example.com no longer admits this key. Skip for now and ask a community administrator.", + ); + onSkip.mockRejectedValueOnce( + new Error("Could not save this community on this device. Try again."), + ); + await user.click(screen.getByRole("button", { name: "Skip for now" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not save this community on this device. Try again.", + ); + expect(onSkip).toHaveBeenCalledExactlyOnceWith(); + expect(screen.getByRole("button", { name: "Skip for now" })).toBeEnabled(); + await user.click(screen.getByRole("button", { name: "Skip for now" })); + expect(onSkip).toHaveBeenCalledTimes(2); + expect(screen.queryByRole("alert")).toBeNull(); +}); diff --git a/src/features/identity/ProfileStep.tsx b/src/features/identity/ProfileStep.tsx new file mode 100644 index 0000000000..ac6338e9ff --- /dev/null +++ b/src/features/identity/ProfileStep.tsx @@ -0,0 +1,123 @@ +import { useState } from "react"; +import type { inspectProfile } from "../communities/api"; +import type { FoundCommunity } from "../communities/find-community"; +import { canSaveProfile, ProfileFields } from "../communities/ProfileFields"; +import type { PersonalProfile } from "../communities/service"; +import { readErrorKind } from "../relay/errors"; +import { Button } from "../../shared/design-system/ui/Button"; +import { WireframePanel } from "./Wireframe"; + +/** + * First-run profile, shown once the relay has admitted the key and before the + * gate lifts. A pure form: the caller owns what Continue and Skip do with the + * draft (the join's completion, which publishes the kind 0 through the shared + * owner and saves the membership after it), and a rejection stays here with + * the error inline and the draft intact. + */ +export function ProfileStep({ + found, + read, + defaultName, + onContinue, + onSkip, +}: { + found: Pick; + /** The admission read that joined the community: the prefill. */ + read: Awaited>; + /** Suggested display name when the relay holds no profile for this key. */ + defaultName: string; + /** Publishes the trimmed draft and finishes the join; a rejection keeps the step. */ + onContinue: (profile: PersonalProfile) => Promise; + /** Finishes the join without publishing; a rejection keeps the step. */ + onSkip: () => Promise; +}) { + const [draft, setDraft] = useState(() => + read.exists ? read.profile : { name: defaultName, picture: "", about: "" }, + ); + const [busy, setBusy] = useState(false); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(""); + const canContinue = canSaveProfile(draft) && !busy && !uploading; + const run = async (action: () => Promise) => { + setBusy(true); + setError(""); + try { + await action(); + } catch (reason) { + setError(publishFailure(found.host, reason)); + setBusy(false); + } + }; + return ( +
{ + event.preventDefault(); + if (canContinue) + void run(() => + onContinue({ + ...draft, + name: draft.name.trim(), + about: draft.about?.trim() ?? "", + }), + ); + }} + > +
+

+ Set up your profile +

+

+ Add a name and avatar. They’ll show up on your messages, reactions, + and agent handoffs in {found.host}. You can change them any time in + Settings → Profile. +

+
+ + + + {error && ( +

+ {error} +

+ )} + {/* Footer order: secondary first, primary last. */} +
+ + +
+
+ ); +} + +/** Classified like the join's read failure; the owner's own messages (the + * native route's "Community request failed (500)", an unconfirmed receipt, a + * readback that is not current, a device record that will not save) are + * shown as is. */ +function publishFailure(host: string, error: unknown) { + if (readErrorKind(error) === "denied") + return `${host} no longer admits this key. Skip for now and ask a community administrator.`; + return error instanceof Error + ? error.message + : "Could not reach the relay. Check your connection and try again."; +} diff --git a/src/features/identity/PublicKeyPanel.tsx b/src/features/identity/PublicKeyPanel.tsx new file mode 100644 index 0000000000..c31f60c0be --- /dev/null +++ b/src/features/identity/PublicKeyPanel.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import { CopyIcon } from "../../shared/design-system/icons/index"; +import { Button } from "../../shared/design-system/ui/Button"; +import { WireframePanel } from "./Wireframe"; + +/** + * A full npub — never hex or an abbreviation — with Copy and inline copy + * feedback, for first-run pages that render outside ToastProvider. The + * Profile panel's public-key recipe. + */ +export function PublicKeyPanel({ + publicKey, + heading = "Your public key (npub)", +}: { + publicKey: string; + heading?: string; +}) { + const [copyStatus, setCopyStatus] = useState(""); + return ( + +
+

{heading}

+ +
+ + {publicKey} + +

+ This is your public identity — it’s safe to share. +

+ + {copyStatus} + +
+ ); +} diff --git a/src/features/identity/RestoreIdentity.tsx b/src/features/identity/RestoreIdentity.tsx index b4b86f6c64..07281d9475 100644 --- a/src/features/identity/RestoreIdentity.tsx +++ b/src/features/identity/RestoreIdentity.tsx @@ -6,18 +6,37 @@ import { Field } from "../../shared/design-system/ui/Field"; import { Radio, RadioGroup } from "../../shared/design-system/ui/RadioGroup"; import { type Identity, type RecoveryState, recoveryOff } from "./service"; +/** + * Recovered backups to restore one from. With `expectedPubkey`, only that key + * is offered: when no backup holds it, nothing can be restored, and `onImport` + * offers Enter your private key instead. Try recovery again is offered only + * where a retry may help. + */ export function RestoreIdentity({ identity, recovery, + expectedPubkey, + onImport, }: { - identity: Identity; + identity: Pick< + Identity, + "beginRecovery" | "installRecovery" | "cancelRecovery" + >; recovery: RecoveryState; + /** The hex key linked to the signed-in account, when only it may be restored. */ + expectedPubkey?: string | undefined; + /** No backup holds `expectedPubkey`: leave recovery for the import page. */ + onImport?: (() => void) | undefined; }) { const [selected, setSelected] = useState<{ recovery: RecoveryState; handle: string; }>(); - const candidates = recovery.status === "choosing" ? recovery.candidates : []; + const recovered = recovery.status === "choosing" ? recovery.candidates : []; + const candidates = + expectedPubkey === undefined + ? recovered + : recovered.filter((candidate) => candidate.pubkey === expectedPubkey); const labels = publicKeyLabels( candidates.map((candidate) => candidate.pubkey), ); @@ -29,6 +48,7 @@ export function RestoreIdentity({ : undefined; const handle = selected?.recovery === recovery ? selected.handle : current?.handle; + const mismatched = recovered.length > 0 && candidates.length === 0; return (
{recovery.status === "waiting" ? ( @@ -39,6 +59,11 @@ export function RestoreIdentity({

Canceling recovery…

) : recovery.status === "error" ? (

{recovery.error}

+ ) : mismatched ? ( +

+ None of your backups holds the key linked to this account, so nothing + was restored. +

) : candidates.length === 0 ? (

{recovery.failedCount @@ -104,8 +129,12 @@ export function RestoreIdentity({ > Restore this identity + ) : mismatched && onImport ? ( + ) : (recovery.status === "error" && !recoveryOff(recovery)) || - (recovery.status === "choosing" && candidates.length === 0) ? ( + (recovery.status === "choosing" && recovered.length === 0) ? ( + + )} + + } + > + {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 then 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 ? ( + { + if (isBuilderlabHosted(found)) void signIn(); + 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/WelcomeBack.tsx b/src/features/identity/WelcomeBack.tsx new file mode 100644 index 0000000000..02aea25274 --- /dev/null +++ b/src/features/identity/WelcomeBack.tsx @@ -0,0 +1,94 @@ +import { Button } from "../../shared/design-system/ui/Button"; +import { PublicKeyPanel } from "./PublicKeyPanel"; + +/** + * Pause after the account check found a key already linked to the signed-in + * Builderlab account. Binding is 1:1, so this lane never creates a key: the + * user brings the linked one, restores it from a backup, or starts over + * without it. + */ +export function WelcomeBack({ + npub, + email, + onRestore, + onImport, + onLost, + onSwitchAccount, +}: { + /** The linked key, as the account's npub. */ + npub: string; + /** The signed-in Builderlab account the key is linked to. */ + email: string | undefined; + /** Restore the linked key; set only when backup status lists it. */ + onRestore?: (() => void) | undefined; + /** Open the import page, which accepts only this key. */ + onImport: () => void; + /** Open the start-over page. */ + onLost: () => void; + /** Wrong account: sign the session out and return to the landing. */ + onSwitchAccount: () => void; +}) { + return ( + <> +
+

+ Welcome back +

+

+ This account is already linked to a Buzz identity — the key you use on + your other device. + {onRestore + ? " Restore it from your backup, or enter that key to continue." + : " Enter that key to continue."} +

+ {/* Who the linked key belongs to, so a wrong account is visible before anything is imported. */} +

+ {email ? `Signed in as ${email}.` : "Signed in."} +

+
+ +
+ {onRestore && ( + + )} + {/* Secondary choices are deliberately smaller than the primary. */} + + {/* Future entry for NIP-AB device pairing: the signed-in device hands the key over. */} + + {/* Quieter than the other choices: starting over loses the old identity. */} + + {/* A lane exit, not a key choice, so it comes last. */} + +
+ + ); +} diff --git a/src/features/identity/Wireframe.tsx b/src/features/identity/Wireframe.tsx new file mode 100644 index 0000000000..2cac9014e8 --- /dev/null +++ b/src/features/identity/Wireframe.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; +import { OnboardingScreen } from "./OnboardingScreen"; + +// Unstyled wireframe: first-run pages with no onboarding design yet stack the +// shared controls inside the shared onboarding screen. + +/** A stacked group within a first-run page. */ +export function WireframePanel({ children }: { children: ReactNode }) { + return
{children}
; +} + +/** A first-run page titled by its `welcome-title` heading. */ +export function WireframeScreen({ children }: { children: ReactNode }) { + return ( + + {children} + + } + > + {null} + + ); +} diff --git a/src/features/identity/backup-failure.ts b/src/features/identity/backup-failure.ts index adfe9c98f9..995119d7cf 100644 --- a/src/features/identity/backup-failure.ts +++ b/src/features/identity/backup-failure.ts @@ -7,6 +7,8 @@ const retry = (cause: string, then = "Try again.") => * and whether Try again can help. Native code names its own kinds and retries * them all on its next wake; `unavailable`, `interrupted`, `timeout` and * `unknown` are the renderer's. No message carries a service or IPC answer. + * A leaf module: vite.config.ts validates the development seed against it + * through Node's native loader. */ export const BACKUP_FAILURES = { unavailable: { @@ -59,3 +61,8 @@ export const BACKUP_FAILURES = { >; export type BackupFailureKind = keyof typeof BACKUP_FAILURES; + +export const isBackupFailureKind = ( + value: unknown, +): value is BackupFailureKind => + typeof value === "string" && Object.hasOwn(BACKUP_FAILURES, value); diff --git a/src/features/identity/builderlab-account.test.ts b/src/features/identity/builderlab-account.test.ts new file mode 100644 index 0000000000..ddf3cdc246 --- /dev/null +++ b/src/features/identity/builderlab-account.test.ts @@ -0,0 +1,1152 @@ +import { invoke } from "@tauri-apps/api/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Credential } from "../../bundled/builderlab/oauth/browser"; +import type { HostRequest } from "../host/service"; +import { BackupError, type BackupStatus, createBackupStatus } from "./backup"; +import { BACKUP_FAILURES } from "./backup-failure"; +import { + backupOfferedFor, + builderlabAccount, + seededAccount, +} from "./builderlab-account"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), + Channel: class { + constructor(public onmessage: (response: unknown) => void) {} + }, +})); + +const credential: Credential = { + value: "private-token", + account: { + subject: "user", + email: "a@example.com", + canUseRemoteAgents: false, + }, +}; +const pubkey = "ab".repeat(32); +const answer = (status: number, body: unknown) => ({ + status, + headers: {}, + body: typeof body === "string" ? body : JSON.stringify(body), +}); +/** kgoose puts one on every identity and community route reply, error or not. */ +const correlationId = "3f2a9c1e-5b7d-4e8f-9a0b-1c2d3e4f5a6b"; +/** kgoose's error reply: its code, its own message and the correlation ID. */ +const refusal = (status: number, code: string) => + answer(status, { + error: { code, message: "kgoose's own wording" }, + correlation_id: correlationId, + }); +/** misk's answer to a lapsed session on any `@Authenticated` route. */ +const lapsed = answer(401, "Unauthenticated"); +const signedOut = "Your Builderlab sign-in is no longer valid."; +/** One request stub per call, in order; remembers what was sent. */ +function kgoose(...replies: Array | Error | string>) { + const sent: HostRequest[] = []; + const request = vi.fn(async (input: HostRequest) => { + sent.push(input); + const reply = replies.shift(); + if (reply === undefined) throw new Error("No reply scripted"); + if (reply instanceof Error || typeof reply === "string") throw reply; + return reply; + }); + return { request, sent }; +} +type BackupIdentity = Parameters[2]; +/** Only Restore from backup drives native recovery. */ +const noRecovery = { + subscribe: () => { + throw new Error("Watched the identity"); + }, + snapshot: () => { + throw new Error("Read the identity"); + }, + beginRecovery: () => { + throw new Error("Began recovery"); + }, + installRecovery: () => { + throw new Error("Installed a recovered key"); + }, + cancelRecovery: () => { + throw new Error("Canceled recovery"); + }, +} satisfies Omit; +/** The reads and binding never touch the backup status or consent. */ +const untouched: BackupIdentity = { + ...noRecovery, + backup: createBackupStatus(() => { + throw new Error("Read the backup status"); + }), + setBackupChoice: () => { + throw new Error("Wrote backup consent"); + }, +}; +// The trailing slash checks that routes and Origin use the canonical origin. +beforeEach(() => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://t.example/"); +}); +afterEach(() => { + vi.unstubAllEnvs(); + vi.mocked(invoke).mockReset(); +}); + +describe("linkedKey", () => { + it("POSTs the card's identity route at the build URL with the session credential and reads the bound key", async () => { + const { request, sent } = kgoose( + answer(200, { + identity: { npub: "npub1x", pubkey_hex: pubkey }, + correlation_id: correlationId, + }), + ); + const account = builderlabAccount(credential, request, untouched); + expect(await account.linkedKey()).toBe(pubkey); + expect(sent).toEqual([ + { + url: "https://t.example/api/goose/v1/buzz/nostr-identities/current", + method: "POST", + headers: { + "Content-Type": "application/json", + "X-BB-Session-Credential": "private-token", + Origin: "https://t.example", + }, + body: "{}", + }, + ]); + }); + + // kgoose's `current` answers 200 with `identity` null or absent for an + // account with no mapping; MISSING_MAPPING is the list route's shape, kept + // for symmetry. + it.each([ + [ + "a 200 with identity null", + answer(200, { identity: null, correlation_id: correlationId }), + ], + ["a 200 with no identity", answer(200, { correlation_id: correlationId })], + [ + "a 400 MISSING_MAPPING", + answer(400, { error: { code: "MISSING_MAPPING" } }), + ], + [ + "a 400 setup_needed", + answer(400, { error: { code: "other", setup_needed: true } }), + ], + ])("reads %s as not linked", async (_label, reply) => { + const { request } = kgoose(reply); + const account = builderlabAccount(credential, request, untouched); + expect(await account.linkedKey()).toBeNull(); + }); + + it.each([ + [ + "an account without the USER role", + refusal(403, "unauthorized"), + "This Builderlab account can't manage Buzz identities right now. Try signing in again.", + ], + ["a lapsed session", lapsed, signedOut], + [ + "a bound identity without a usable key", + answer(200, { identity: { npub: "npub1x" } }), + "Builderlab account check failed (HTTP 200: unusable linked key).", + ], + [ + "a bound identity with a malformed key", + answer(200, { identity: { pubkey_hex: "not-hex" } }), + "Builderlab account check failed (HTTP 200: unusable linked key).", + ], + [ + "a non-JSON failure", + answer(403, "Forbidden"), + "Builderlab account check failed (HTTP 403: not JSON).", + ], + [ + "an unstructured failure", + answer(500, { message: "boom" }), + "Builderlab account check failed (HTTP 500).", + ], + [ + "a malformed error code", + answer(400, { error: { code: 42 } }), + "Could not check this Builderlab account.", + ], + [ + "an oversize body", + answer(200, `{"identity":{"pubkey_hex":"${"a".repeat(70_000)}"}}`), + "Builderlab account check failed (HTTP 200: response too large).", + ], + ["a native refusal string", "host request denied", /host request denied/], + ["a transport error", new Error("offline"), /offline/], + ])("fails on %s instead of answering", async (_label, reply, message) => { + const { request } = kgoose(reply); + const account = builderlabAccount(credential, request, untouched); + await expect(account.linkedKey()).rejects.toThrow(message); + }); + + it("warns in development with the route and status, never the body", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { request } = kgoose(answer(403, "secret body")); + const account = builderlabAccount(credential, request, untouched); + await expect(account.linkedKey()).rejects.toThrow(/HTTP 403/); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.join(" ")).toBe( + "Builderlab account check: POST /v1/buzz/nostr-identities/current answered HTTP 403 Builderlab account check failed (HTTP 403: not JSON).", + ); + expect(warn.mock.calls[0]?.join(" ")).not.toContain("secret body"); + }); +}); + +describe("ownedCommunities", () => { + it("lists joinable hosts from the card's list route, skipping archived and unusable rows", async () => { + const { request, sent } = kgoose( + answer(200, { + communities: [ + { id: "1", normalized_host: "wss://Design.Example.com" }, + { id: "2", normalized_host: "buzz.example.org:8443" }, + { + id: "3", + normalized_host: "old.example.org", + archived_at: "2026-01-01T00:00:00Z", + }, + { id: "4", normalized_host: "" }, + { id: "5", normalized_host: "bad host/with/path" }, + { id: "6" }, + ], + }), + ); + const account = builderlabAccount(credential, request, untouched); + expect(await account.ownedCommunities()).toEqual([ + { host: "design.example.com", url: "https://design.example.com" }, + { host: "buzz.example.org:8443", url: "https://buzz.example.org:8443" }, + ]); + expect(sent[0]?.url).toBe( + "https://t.example/api/goose/v1/buzz/communities/list", + ); + }); + + it("answers an unlinked account with no communities and fails on other errors", async () => { + const unlinked = builderlabAccount( + credential, + kgoose(answer(400, { error: { code: "missing_mapping" } })).request, + untouched, + ); + expect(await unlinked.ownedCommunities()).toEqual([]); + const failing = builderlabAccount( + credential, + kgoose(answer(503, { error: { code: "relay_unavailable" } })).request, + untouched, + ); + await expect(failing.ownedCommunities()).rejects.toThrow( + /temporarily unavailable/, + ); + const unparseable = builderlabAccount( + credential, + kgoose(answer(502, "bad gateway")).request, + untouched, + ); + await expect(unparseable.ownedCommunities()).rejects.toThrow( + "Builderlab community list failed (HTTP 502: not JSON).", + ); + }); +}); + +describe("knownCommunities", () => { + it("POSTs the plugin's list route at the build URL with the session credential and reads the saved destinations in the device's spelling", async () => { + const { request, sent } = kgoose( + answer(200, { + communities: [ + { community_url: "wss://Design.Example.com/" }, + { community_url: "wss://buzz.example.org:8443" }, + // Rows this device could never save are dropped, as the sync drops them. + { community_url: "ws://plain.example" }, + { community_url: "wss://bad host" }, + { community_url: "wss://with.example/path" }, + ], + }), + ); + const account = builderlabAccount(credential, request, untouched); + expect(await account.knownCommunities()).toEqual([ + "wss://design.example.com", + "wss://buzz.example.org:8443", + ]); + expect(sent).toEqual([ + { + url: "https://t.example/api/goose/v1/buzz/known-communities/list", + method: "POST", + headers: { + "Content-Type": "application/json", + "X-BB-Session-Credential": "private-token", + Origin: "https://t.example", + }, + body: "{}", + }, + ]); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("reads an omitted list as empty", async () => { + const { request } = kgoose(answer(200, {})); + expect( + await builderlabAccount( + credential, + request, + untouched, + ).knownCommunities(), + ).toEqual([]); + }); + + // The route's refusals are plain strings, not the card's error objects, and + // a portal without the route answers a plain-text 404. + it.each([ + [ + "a 403 forbidden", + answer(403, { error: "forbidden" }), + "Could not load the communities saved to this account.", + false, + ], + [ + "a plain-text 404", + answer(404, "Not Found"), + "Builderlab known communities failed (HTTP 404: not JSON).", + false, + ], + [ + "a 200 that is not JSON", + answer(200, "block page"), + "Builderlab known communities failed (HTTP 200: not JSON).", + false, + ], + [ + "a 200 with a row this client cannot read", + answer(200, { communities: [{ community_url: 1 }] }), + "Builderlab known communities failed (HTTP 200: unreadable row).", + false, + ], + [ + "a 200 whose list is not a list", + answer(200, { communities: "wss://a.example" }), + "Builderlab known communities failed (HTTP 200: unreadable row).", + false, + ], + [ + "a transport failure", + "Host request failed: connection reset", + "Host request failed: connection reset", + true, + ], + ])("rejects %s", async (_label, reply, message, retryable) => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const { request, sent } = kgoose(reply); + const failed: unknown = await builderlabAccount( + credential, + request, + untouched, + ) + .knownCommunities() + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe(message); + expect((failed as { retryable?: unknown }).retryable === true).toBe( + retryable, + ); + expect(sent).toHaveLength(1); + expect(invoke).not.toHaveBeenCalled(); + }); +}); + +describe("saveCommunity", () => { + const add = (origin: string) => ({ + url: `${origin}/api/goose/v1/buzz/known-communities/add`, + method: "POST", + headers: { + "Content-Type": "application/json", + "X-BB-Session-Credential": "private-token", + Origin: origin, + }, + body: JSON.stringify({ community_url: "wss://relay.example.com:8443" }), + }); + + it("POSTs the plugin's add route at the build URL with the session credential and the address in the device's spelling", async () => { + const { request, sent } = kgoose(answer(200, {})); + await builderlabAccount(credential, request, untouched).saveCommunity( + "https://relay.example.com:8443", + ); + expect(sent).toEqual([add("https://t.example")]); + expect(invoke).not.toHaveBeenCalled(); + }); + + it.each([ + [ + "a 422 limit_reached", + answer(422, { error: "limit_reached" }), + "Could not save this community to your Builderlab account.", + false, + ], + [ + "a plain-text 422", + answer(422, "Unprocessable Entity"), + "Builderlab community save failed (HTTP 422: not JSON).", + false, + ], + [ + "a transport failure", + "Host request failed: connection reset", + "Host request failed: connection reset", + true, + ], + ])("rejects %s", async (_label, reply, message, retryable) => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const { request, sent } = kgoose(reply); + const failed: unknown = await builderlabAccount( + credential, + request, + untouched, + ) + .saveCommunity("https://relay.example.com:8443") + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe(message); + expect((failed as { retryable?: unknown }).retryable === true).toBe( + retryable, + ); + expect(sent).toHaveLength(1); + }); + + it("resolves without a request for the development seed", async () => { + await expect( + seededAccount( + { linked: null, communities: [] }, + { + importKey: () => { + throw new Error("Imported a key"); + }, + snapshot: () => { + throw new Error("Read the identity"); + }, + }, + ).saveCommunity("https://relay.example.com:8443"), + ).resolves.toBeUndefined(); + expect(invoke).not.toHaveBeenCalled(); + }); +}); + +describe("bind", () => { + const challenge = { + challenge_id: "550e8400-e29b-41d4-a716-446655440000", + nonce: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234_-7", + verification_code: "123456", + origin: "https://t.example", + expires_at: "2999-01-01T00:00:00Z", + }; + const event = { id: "e".repeat(64), kind: 24243, sig: "s".repeat(128) }; + const unbound = answer(200, { + identity: null, + correlation_id: correlationId, + }); + const bound = (key: string) => + answer(200, { + identity: { pubkey_hex: key }, + correlation_id: correlationId, + }); + const at = (origin: string) => ({ + method: "POST", + headers: { + "Content-Type": "application/json", + "X-BB-Session-Credential": "private-token", + Origin: origin, + }, + }); + + it("reads the bound key, then challenges at the build URL, signs natively and verifies with the signed event", async () => { + vi.mocked(invoke).mockResolvedValue(event); + const { request, sent } = kgoose( + unbound, + // Only the five challenge fields reach the native signer. + answer(200, { ...challenge, created_at: "2026-01-01T00:00:00Z" }), + bound(pubkey), + ); + const account = builderlabAccount(credential, request, untouched); + await account.bind(pubkey); + const api = "https://t.example/api/goose/v1/buzz/nostr-identities"; + expect(sent).toEqual([ + { url: `${api}/current`, ...at("https://t.example"), body: "{}" }, + { + url: `${api}/challenge`, + ...at("https://t.example"), + body: JSON.stringify({ origin: "https://t.example" }), + }, + { + url: `${api}/verify`, + ...at("https://t.example"), + body: JSON.stringify({ + challenge_id: challenge.challenge_id, + nonce: challenge.nonce, + signed_payload: JSON.stringify(event), + }), + }, + ]); + expect(vi.mocked(invoke).mock.calls).toEqual([ + ["identity_sign_builderlab_binding", { challenge }], + ]); + }); + + it("sends nothing more when the account already holds this key", async () => { + const { request, sent } = kgoose(bound(pubkey)); + await builderlabAccount(credential, request, untouched).bind(pubkey); + expect(sent.map(({ url }) => url)).toEqual([ + "https://t.example/api/goose/v1/buzz/nostr-identities/current", + ]); + expect(invoke).not.toHaveBeenCalled(); + }); + + it.each([ + [ + "a verify for an account bound to another key", + [answer(200, challenge), refusal(409, "identity_already_bound")], + "This Builderlab account is connected to another Buzz identity.", + ], + [ + "a verify for a key bound to another account", + [answer(200, challenge), refusal(409, "pubkey_already_bound")], + "This Buzz identity is connected to another Builderlab account.", + ], + // kgoose's codes the card lacks. + [ + "a challenge refused for its origin", + [refusal(403, "invalid_origin")], + "Builderlab didn’t accept this request from Buzz.", + ], + [ + "a challenge refused for too many open challenges", + [refusal(429, "too_many_attempts")], + "Builderlab has had too many attempts to link a key. Wait a few minutes, then try again.", + ], + [ + "a non-JSON challenge answer", + [answer(502, "bad gateway")], + "Builderlab account link failed (HTTP 502: not JSON).", + ], + [ + "a non-JSON verify answer", + [answer(200, challenge), answer(403, "Forbidden")], + "Builderlab account link failed (HTTP 403: not JSON).", + ], + [ + "a verify that confirms another key", + [answer(200, challenge), bound("cd".repeat(32))], + "Builderlab account link failed (HTTP 200: link not confirmed).", + ], + [ + "a verify that confirms no key", + [answer(200, challenge), answer(200, {})], + "Builderlab account link failed (HTTP 200: link not confirmed).", + ], + ])( + "fails on %s with the card's or the step's copy", + async (_label, replies, message) => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.mocked(invoke).mockResolvedValue(event); + const { request, sent } = kgoose(unbound, ...replies); + const account = builderlabAccount(credential, request, untouched); + const failed = await account.bind(pubkey).catch((error: Error) => error); + // Never kgoose's own message or its correlation ID. + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe(message); + expect(sent).toHaveLength(1 + replies.length); + // A refused challenge is never signed; an answered one is, before verify. + expect(vi.mocked(invoke).mock.calls).toEqual( + Array(replies.length - 1).fill([ + "identity_sign_builderlab_binding", + { challenge }, + ]), + ); + }, + ); + + // kgoose's statuses (`NostrIdentityService.verify`). A code about one + // challenge may clear, since Try again asks for a fresh one; a bad signature + // or a skewed `created_at` (invalid_challenge) does not. + it.each([ + [ + "invalid_challenge", + 400, + "Builderlab couldn’t verify your key’s signature, so the key wasn’t linked.", + false, + ], + [ + "challenge_not_found", + 404, + "Builderlab couldn’t find the request to link your key, so the key wasn’t linked.", + true, + ], + [ + "challenge_expired", + 400, + "The request to link your key expired before Builderlab confirmed it.", + true, + ], + [ + "challenge_consumed", + 409, + "The request to link your key was already used.", + true, + ], + [ + "too_many_attempts", + 429, + "Builderlab has had too many attempts to link a key. Wait a few minutes, then try again.", + true, + ], + [ + "identity_already_bound", + 409, + "This Builderlab account is connected to another Buzz identity.", + false, + ], + [ + "pubkey_already_bound", + 409, + "This Buzz identity is connected to another Builderlab account.", + false, + ], + // Defensive, not a kgoose reply: its identity routes never send `unknown`, + // which the card reads as its deletion service's. + [ + "unknown", + 400, + "Could not link your key to this Builderlab account.", + false, + ], + [ + "a_code_nobody_knows", + 400, + "Could not link your key to this Builderlab account.", + false, + ], + ] as const)( + "fails a verify answering %s (HTTP %i) with first run's copy, never kgoose's, and retryable only if it may clear", + async (code, status, message, retryable) => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.mocked(invoke).mockResolvedValue(event); + const { request } = kgoose( + unbound, + answer(200, challenge), + refusal(status, code), + ); + const failed: unknown = await builderlabAccount( + credential, + request, + untouched, + ) + .bind(pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe(message); + expect((failed as { retryable?: unknown }).retryable === true).toBe( + retryable, + ); + }, + ); + + it("keeps kgoose's correlation ID off the message, on the error and in the development log", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.mocked(invoke).mockResolvedValue(event); + for (const [code, status, message] of [ + [ + "challenge_expired", + 400, + "The request to link your key expired before Builderlab confirmed it.", + ], + // The card's own copy, whose `ApiFailure` would append the ID. + [ + "pubkey_already_bound", + 409, + "This Buzz identity is connected to another Builderlab account.", + ], + ] as const) { + warn.mockClear(); + const failed: unknown = await builderlabAccount( + credential, + kgoose(unbound, answer(200, challenge), refusal(status, code)).request, + untouched, + ) + .bind(pubkey) + .catch((error: unknown) => error); + expect((failed as Error).message).toBe(message); + expect((failed as Error).message).not.toContain(correlationId); + expect(failed).toMatchObject({ correlationId }); + expect(warn.mock.calls.map((call) => call.join(" "))).toEqual([ + `Builderlab account link: POST /v1/buzz/nostr-identities/verify answered HTTP ${status} (correlation ID ${correlationId}) ${message}`, + ]); + } + }); + + it.each([ + ["the bound-key read", [lapsed]], + ["the challenge", [unbound, lapsed]], + ["the verify", [unbound, answer(200, challenge), lapsed]], + ])( + "reads a lapsed session's plain-text 401 on %s as an ended sign-in, without Try again", + async (_label, replies) => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.mocked(invoke).mockResolvedValue(event); + const failed: unknown = await builderlabAccount( + credential, + kgoose(...replies).request, + untouched, + ) + .bind(pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe(signedOut); + expect((failed as { retryable?: unknown }).retryable).toBeUndefined(); + }, + ); + + it("surfaces the native signer's refusal and never verifies", async () => { + vi.mocked(invoke).mockRejectedValue("Invalid Nostr identity challenge"); + const { request, sent } = kgoose(unbound, answer(200, challenge)); + const account = builderlabAccount(credential, request, untouched); + await expect(account.bind(pubkey)).rejects.toThrow( + "Invalid Nostr identity challenge", + ); + expect(sent).toHaveLength(2); + }); + + it.each([ + ["a gateway failure", [answer(502, "bad gateway")], true], + ["a transport error", [new Error("offline")], true], + ["a rate limit", [refusal(429, "too_many_attempts")], true], + [ + "a refused link", + [answer(200, challenge), refusal(409, "identity_already_bound")], + false, + ], + ["a refused origin", [refusal(403, "invalid_origin")], false], + ])( + "lets Try again repeat %s only if it may clear", + async (_label, replies, retryable) => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.mocked(invoke).mockResolvedValue(event); + const failed: unknown = await builderlabAccount( + credential, + kgoose(unbound, ...replies).request, + untouched, + ) + .bind(pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as { retryable?: unknown }).retryable === true).toBe( + retryable, + ); + expect(vi.mocked(invoke).mock.calls).toEqual( + Array(replies.length - 1).fill([ + "identity_sign_builderlab_binding", + { challenge }, + ]), + ); + }, + ); +}); + +describe("unbind", () => { + const old = "cd".repeat(32); + const bound = (key: string) => + answer(200, { + identity: { pubkey_hex: key }, + correlation_id: correlationId, + }); + const unbound = answer(200, { + identity: null, + correlation_id: correlationId, + }); + const at = (origin: string) => ({ + method: "POST", + headers: { + "Content-Type": "application/json", + "X-BB-Session-Credential": "private-token", + Origin: origin, + }, + body: "{}", + }); + + it("reads the bound key, then deletes the old key's binding at the build URL with the session credential and Origin", async () => { + const { request, sent } = kgoose(bound(old), bound(old)); + await builderlabAccount(credential, request, untouched).unbind(old, pubkey); + const api = "https://t.example/api/goose/v1/buzz/nostr-identities"; + expect(sent).toEqual([ + { url: `${api}/current`, ...at("https://t.example") }, + { url: `${api}/delete`, ...at("https://t.example") }, + ]); + expect(invoke).not.toHaveBeenCalled(); + }); + + it.each([ + ["no key", unbound], + ["the new key, after a re-run", bound(pubkey)], + ])("sends nothing more when the account holds %s", async (_label, reply) => { + const { request, sent } = kgoose(reply); + await builderlabAccount(credential, request, untouched).unbind(old, pubkey); + expect(sent.map(({ url }) => url)).toEqual([ + "https://t.example/api/goose/v1/buzz/nostr-identities/current", + ]); + }); + + it("fails without a delete when the account holds a third key", async () => { + const { request, sent } = kgoose(bound("12".repeat(32))); + const failed: unknown = await builderlabAccount( + credential, + request, + untouched, + ) + .unbind(old, pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe( + "Your Builderlab account is linked to a different key, so your new key can’t be linked.", + ); + expect((failed as { retryable?: unknown }).retryable).toBeUndefined(); + expect(sent.map(({ url }) => url)).toEqual([ + "https://t.example/api/goose/v1/buzz/nostr-identities/current", + ]); + }); + + // kgoose answers 200 with no `identity` when nothing was bound, and also + // when the session or its authorization lapsed, removing nothing. + const deletes = [ + ["no identity", answer(200, { correlation_id: correlationId })], + ["identity null", unbound], + ["another key", bound("ef".repeat(32))], + ] as const; + + it.each( + deletes.flatMap(([deleted, reply]) => [ + [deleted, "no key", reply, unbound], + [deleted, "the new key", reply, bound(pubkey)], + ]), + )( + "succeeds when the delete answers %s and the account then holds %s", + async (_deleted, _now, reply, now) => { + const { request, sent } = kgoose(bound(old), reply, now); + await expect( + builderlabAccount(credential, request, untouched).unbind(old, pubkey), + ).resolves.toBeUndefined(); + expect(sent.map(({ url }) => url.split("/").at(-1))).toEqual([ + "current", + "delete", + "current", + ]); + }, + ); + + it.each( + deletes.flatMap(([deleted, reply]) => [ + [ + deleted, + "the old key", + reply, + bound(old), + "Builderlab didn’t unlink your old key, so your new key can’t be linked. Your Builderlab sign-in may have ended.", + ], + [ + deleted, + "a third key", + reply, + bound("12".repeat(32)), + "Your Builderlab account is linked to a different key, so your new key can’t be linked.", + ], + ]), + )( + "fails for good when the delete answers %s and the account still holds %s", + async (_deleted, _now, reply, now, message) => { + const failed: unknown = await builderlabAccount( + credential, + kgoose(bound(old), reply, now).request, + untouched, + ) + .unbind(old, pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe(message); + expect((failed as { retryable?: unknown }).retryable).toBeUndefined(); + }, + ); + + it("fails like the account check when the read after a delete fails", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const failed: unknown = await builderlabAccount( + credential, + kgoose(bound(old), unbound, new Error("offline")).request, + untouched, + ) + .unbind(old, pubkey) + .catch((error: unknown) => error); + expect((failed as Error).message).toBe("offline"); + expect((failed as { retryable?: unknown }).retryable).toBe(true); + }); + + // kgoose's delete answers 200 or a 403 `invalid_origin`; its + // `@Authenticated` gate answers a lapsed session with misk's 401. + it.each([ + ["a lapsed session", lapsed, signedOut, false], + [ + "a refused origin", + refusal(403, "invalid_origin"), + "Builderlab didn’t accept this request from Buzz.", + false, + ], + // Defensive, not a kgoose reply: its identity routes never send `unknown`, + // and its real 500 is misk's plain text (the non-JSON rows). + [ + "a defensive `unknown` 500", + refusal(500, "unknown"), + "Could not unlink your old key from this Builderlab account.", + true, + ], + [ + "a non-JSON refusal", + answer(403, "Forbidden"), + "Builderlab account unlink failed (HTTP 403: not JSON).", + false, + ], + [ + "a non-JSON gateway failure", + answer(502, "bad gateway"), + "Builderlab account unlink failed (HTTP 502: not JSON).", + true, + ], + [ + "an unstructured server failure", + answer(500, { message: "boom" }), + "Builderlab account unlink failed (HTTP 500).", + true, + ], + ["a transport error", new Error("offline"), "offline", true], + [ + "a native refusal string", + "host request denied", + "host request denied", + true, + ], + ])( + "fails on %s with the card's or the step's copy, retryable only if it may clear", + async (_label, reply, message, retryable) => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const { request, sent } = kgoose(bound(old), reply); + const failed: unknown = await builderlabAccount( + credential, + request, + untouched, + ) + .unbind(old, pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe(message); + expect((failed as { retryable?: unknown }).retryable === true).toBe( + retryable, + ); + expect(sent).toHaveLength(2); + }, + ); +}); + +describe("backup", () => { + const status = ( + seq: number, + pass: number, + builderlab = true, + progress: "idle" | "running" | "complete" = "idle", + ): BackupStatus => ({ seq, pass, builderlab, status: progress }); + /** The identity's real status store over a scripted native host. */ + function host(snapshot: BackupStatus) { + const watchers: { onmessage: (status: BackupStatus) => void }[] = []; + const send = vi.fn(async (command: string, args?: unknown) => { + if (command === "nsec_backup_watch") + watchers.push((args as { onEvent: (typeof watchers)[number] }).onEvent); + else if (command === "nsec_backup_state") return snapshot; + else throw new Error(`Unexpected command ${command}`); + }); + const setBackupChoice = vi.fn( + (_pubkey: string, automatic: boolean): Promise => + Promise.resolve({ available: true, pubkey, automatic } as never), + ); + const identity: BackupIdentity = { + ...noRecovery, + backup: createBackupStatus(send as never), + setBackupChoice, + }; + return { identity, send, setBackupChoice, watchers }; + } + + it("hands the session credential to the native uploader and waits for its pass", async () => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example/"); + const native = host(status(1, 0)); + vi.mocked(invoke).mockImplementation(async (_command, args) => + (args as { credential: string | null }).credential ? 1 : null, + ); + const account = builderlabAccount( + credential, + kgoose().request, + native.identity, + ); + const done = account.backUp(pubkey); + await vi.waitFor(() => expect(invoke).toHaveBeenCalledTimes(1)); + native.watchers[0]?.onmessage(status(2, 1, true, "complete")); + await done; + expect(native.setBackupChoice.mock.calls).toEqual([[pubkey, true]]); + expect(vi.mocked(invoke).mock.calls).toEqual([ + [ + "nsec_backup_session", + { + source: "builderlab", + generation: expect.any(Number), + credential: "private-token", + }, + ], + [ + "nsec_backup_session", + { + source: "builderlab", + generation: expect.any(Number), + credential: null, + }, + ], + ]); + }); + + it("refuses with unavailable where backup is not offered, handing the uploader nothing", async () => { + const native = host(status(1, 0, false)); + const failed = await builderlabAccount( + credential, + kgoose().request, + native.identity, + ) + .backUp(pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(BackupError); + expect(failed).toMatchObject({ kind: "unavailable" }); + expect(native.setBackupChoice).not.toHaveBeenCalled(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it("is offered only where native backup runs", async () => { + const offered = (builderlab: boolean) => + backupOfferedFor(host(status(1, 0, builderlab)).identity.backup); + expect(await offered(true)).toBe(true); + expect(await offered(false)).toBe(false); + // A status that cannot be read is not an offer. + const refused = host(status(1, 0)); + refused.send.mockRejectedValueOnce( + "Backup status belongs to the main window", + ); + expect(await backupOfferedFor(refused.identity.backup)).toBe(false); + // A build without a Builderlab URL offers no backup. + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", ""); + expect(await offered(true)).toBe(false); + }); + + describe("backupStatus", () => { + const other = "cd".repeat(32); + /** An account where backup is offered: native backup is running. */ + const offeredWith = (...replies: Parameters) => { + vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example/"); + const native = host(status(1, 0)); + const { request, sent } = kgoose(...replies); + return { + account: builderlabAccount(credential, request, native.identity), + sent, + }; + }; + + it("POSTs status with the session credential and lists the backed-up keys", async () => { + const { account, sent } = offeredWith( + answer(200, { + current_nostr_pubkey: pubkey, + backup_nostr_pubkeys: [pubkey, other.toUpperCase()], + }), + ); + expect(await account.backupStatus()).toEqual([pubkey, other]); + expect(sent).toEqual([ + { + url: "https://builderlab.example/api/goose/v1/buzz/nsec-backups/status", + method: "POST", + headers: { + "Content-Type": "application/json", + "X-BB-Session-Credential": "private-token", + Origin: "https://builderlab.example", + }, + body: "{}", + }, + ]); + }); + + it("lists nothing for an account with no backups", async () => { + const { account } = offeredWith( + answer(200, { current_nostr_pubkey: null, backup_nostr_pubkeys: [] }), + ); + expect(await account.backupStatus()).toEqual([]); + }); + + it("is unavailable, not empty, where kgoose has no backups configured", async () => { + const { account } = offeredWith(answer(404, "Not Found")); + expect(await account.backupStatus()).toBe("unavailable"); + }); + + it("is unavailable without a request where backup is not offered", async () => { + const { request } = kgoose(); + const account = builderlabAccount( + credential, + request, + host(status(1, 0, false)).identity, + ); + expect(await account.backupStatus()).toBe("unavailable"); + expect(request).not.toHaveBeenCalled(); + }); + + it.each([ + ["a transport failure", new Error("offline"), "unreachable"], + ["401", answer(401, "Unauthorized"), "signed_out"], + ["403", answer(403, "Forbidden"), "not_offered"], + ["429", answer(429, "Too Many Requests"), "unreachable"], + [ + "a 500 for an invalid stored backup", + answer(500, "oops"), + "unreachable", + ], + ["400", answer(400, "Bad Request"), "refused"], + ["a 200 that is not JSON", answer(200, ""), "invalid_service"], + [ + "a 200 with no list", + answer(200, { current_nostr_pubkey: null }), + "invalid_service", + ], + [ + "a 200 listing a non-key", + answer(200, { backup_nostr_pubkeys: [pubkey, "npub1x"] }), + "invalid_service", + ], + [ + "an oversized 200", + answer(200, { + backup_nostr_pubkeys: Array(1100).fill(pubkey), + }), + "invalid_service", + ], + ] as const)( + "rejects %s with the %s backup failure", + async (_label, reply, kind) => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const { account } = offeredWith(reply); + const failed = await account + .backupStatus() + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(BackupError); + expect(failed).toMatchObject({ + kind, + retryable: BACKUP_FAILURES[kind].retryable, + }); + }, + ); + }); +}); diff --git a/src/features/identity/builderlab-account.ts b/src/features/identity/builderlab-account.ts new file mode 100644 index 0000000000..618b39d8c7 --- /dev/null +++ b/src/features/identity/builderlab-account.ts @@ -0,0 +1,622 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listedAddress } from "../../bundled/builderlab/known-communities/client"; +import { + type Credential, + oauthTarget, +} from "../../bundled/builderlab/oauth/browser"; +import { + ApiFailure, + boundKey, + check, + relayUrl, + type Reply, +} from "../../bundled/builderlab/communities/api"; +import { enteredRelayOrigin, relayAddress } from "../communities/destination"; +import type { Host, HostResponse } from "../host/service"; +import { BackupError, backUpWith } from "./backup"; +import type { BackupFailureKind } from "./backup-failure"; +import type { FirstRunAccountSeed } from "./first-run-seed"; +import { + type Identity, + RECOVERY_UNAVAILABLE, + type RecoveryState, +} from "./service"; + +/** A community the signed-in account owns: its hostname and the canonical origin a join uses. */ +export type OwnedCommunity = Readonly<{ host: string; url: string }>; + +/** + * The identity's native recovery, with the state it publishes while a restore + * is under way (`undefined` otherwise). + */ +export type Recovery = Pick< + Identity, + "subscribe" | "beginRecovery" | "installRecovery" | "cancelRecovery" +> & + Readonly<{ snapshot(): RecoveryState | undefined }>; + +/** + * What first run reads about the signed-in Builderlab account, the binding + * that makes this device's key the account's (and the unlink that releases a + * lost one), and that key's backup. + */ +export type FirstRunAccount = Readonly<{ + /** The hex key bound 1:1 to the account, or null when none is (a reply with no `identity`). */ + linkedKey(): Promise; + /** The communities the account owns. Archived ones have no relay to join and are left out. */ + ownedCommunities(): Promise; + /** + * The destinations saved to the account, `wss://host[:port]` in kgoose's + * order: the list the Builderlab plugin's known-communities sync keeps. + * Rejects on any failure, and on a row this client cannot read, so a list it + * half understood is never acted on. + */ + knownCommunities(): Promise; + /** + * Saves `url` to that list (`wss://host[:port]`), as the sync's outbox add + * would at the next Settings sign-in; the add is idempotent, so that later + * send changes nothing. Rejects on any failure. + */ + saveCommunity(url: string): Promise; + /** + * Binds `pubkey`, the key on this device, to the account: kgoose's challenge, + * the native kind-24243 signer, then verify. Sends nothing more once the + * account already holds that key, so running it again is safe. + */ + bind(pubkey: string): Promise; + /** + * Unbinds `old`, the key the account held before starting over, so + * `replacement`, the new key on this device, can be linked: kgoose's delete. + * Sends nothing more once the account holds no key or `replacement` (after a + * re-run), so running it again never removes that, and fails without a + * delete when it holds a third key. Resolves only once the account holds no + * key or `replacement`. + */ + unbind(old: string, replacement: string): Promise; + /** + * Backs `pubkey` up and waits for the native uploader's result; rejects with + * a `BackupError`, "unavailable" where `backupOfferedFor` says this build + * cannot back up for this sign-in (nothing is handed to the uploader). + */ + backUp(pubkey: string): Promise; + /** + * The hex keys the account holds a backup of, or "unavailable" where backup + * is not offered (nothing is sent) or kgoose has no backups configured (404). + * Rejects with a `BackupError`. + */ + backupStatus(): Promise; + /** + * Restores a backed-up key. Native recovery opens its own browser sign-in + * (it takes no first-run credential), and an installed key leaves the + * identity ready. + */ + recovery: Recovery; +}>; + +/** The identity's backup status, consent and recovery, which the backup steps use. */ +type BackupIdentity = Pick< + Identity, + | "backup" + | "setBackupChoice" + | "subscribe" + | "snapshot" + | "beginRecovery" + | "installRecovery" + | "cancelRecovery" +>; + +const MAX_RESPONSE_BYTES = 64 * 1024; + +/** Native commands and host HTTP reject with strings; surface them as errors. */ +const asError = (error: unknown) => + typeof error === "string" ? new Error(error) : error; + +/** + * A setup-needed mapping is "not linked", as the Builderlab communities + * section reads it. Only the list route answers this way; the identity route answers 200 + * with no `identity` instead (see `linkedKey`). + */ +const notLinked = (reply: Reply) => + reply.error?.setup_needed === true || + (typeof reply.error?.code === "string" && + reply.error.code.toLowerCase() === "missing_mapping"); + +/** + * Names the step and the HTTP status so a failed page tells the account check + * from the list, and a 403 text/plain from a JSON shape problem. Never the + * response body: provider bodies are not forwarded to the user. + */ +const failure = ( + step: string, + response: Pick, + detail?: string, +) => + new Error( + `Builderlab ${step} failed (HTTP ${response.status}${detail ? `: ${detail}` : ""}).`, + ); + +/** + * kgoose answers a lapsed session credential on every `@Authenticated` route + * with misk's plain-text 401. Try again would send the same credential. + */ +const SIGNED_OUT = "Your Builderlab sign-in is no longer valid."; + +const OTHER_KEY = + "Your Builderlab account is linked to a different key, so your new key can’t be linked."; + +/** + * A transport failure (no status), a rate limit or a 5xx may clear on its own, + * as the native uploader reads them, so the failed page offers Try again; a + * refusal or an answer we rejected will not. kgoose's challenge route answers + * 429 `too_many_attempts` once five challenges are open. A host refusal (an + * undeclared origin) also arrives with no status: native host requests reject + * with untyped strings, so it cannot be told from a transport failure here. + */ +const transient = (error: unknown, status?: number) => + error instanceof Error && + (status === undefined || status === 429 || status >= 500) + ? Object.assign(error, { retryable: true }) + : error; + +/** + * kgoose identity codes (`BuzzNostrIdentityErrorCode`) the hosted card's table + * lacks, in first run's voice. The card reads `unknown` as its deletion + * service's, so it takes the step's fallback (no `message`). A code about one + * challenge is `retryable` whatever its status: Try again re-runs `bind()`, + * which reads `current` again and asks for a fresh challenge. + * `invalid_challenge` is not: kgoose sends it for a bad signature or a + * `created_at` outside its skew window, and a fresh challenge fixes neither. + */ +const ACCOUNT_ERRORS: Readonly< + Record> +> = { + invalid_origin: { + message: "Builderlab didn’t accept this request from Buzz.", + }, + invalid_challenge: { + message: + "Builderlab couldn’t verify your key’s signature, so the key wasn’t linked.", + }, + challenge_not_found: { + message: + "Builderlab couldn’t find the request to link your key, so the key wasn’t linked.", + retryable: true, + }, + challenge_expired: { + message: + "The request to link your key expired before Builderlab confirmed it.", + retryable: true, + }, + challenge_consumed: { + message: "The request to link your key was already used.", + retryable: true, + }, + too_many_attempts: { + message: + "Builderlab has had too many attempts to link a key. Wait a few minutes, then try again.", + }, + unknown: {}, +}; + +/** + * The card's `check` with `ACCOUNT_ERRORS` first. kgoose's own message is + * dropped, so a code neither table knows takes the step's fallback and + * kgoose's wording never reaches the failed page. So is its correlation ID, + * which the card's `ApiFailure` would append to the copy; `call` keeps it. + */ +function checked(reply: Reply, fallback: string) { + if (!reply.error) return reply; + const code = reply.error.code ?? ""; + const own = Object.hasOwn(ACCOUNT_ERRORS, code) + ? ACCOUNT_ERRORS[code] + : undefined; + // Throws the card's copy: the reply has an error. + if (!own) return check({ error: { code } }, fallback); + throw Object.assign( + new ApiFailure(code, own.message ?? fallback), + own.retryable ? { retryable: true } : {}, + ); +} + +/** The Builderlab communities native backend's reply rules, for a response that came through the bundled grant. */ +function parse(step: string, response: HostResponse): Reply { + if (new TextEncoder().encode(response.body).byteLength > MAX_RESPONSE_BYTES) + throw failure(step, response, "response too large"); + let value: unknown; + try { + value = JSON.parse(response.body); + } catch { + throw failure(step, response, "not JSON"); + } + const ok = response.status >= 200 && response.status < 300; + if ( + value && + typeof value === "object" && + !Array.isArray(value) && + (ok || (value as Reply).error) + ) + return value as Reply; + throw failure(step, response); +} + +/** A backup route's status as the native uploader reads it (`nsec_backup.rs` `failure`). */ +const backupFailure = (status: number): BackupFailureKind => + status === 401 + ? "signed_out" + : status === 403 + ? "not_offered" + : status >= 400 && status < 500 && status !== 429 + ? "refused" + : "unreachable"; + +/** `nsec-backups/status`'s `backup_nostr_pubkeys`, or undefined for any other reply. */ +function backedUpKeys(response: HostResponse) { + if (new TextEncoder().encode(response.body).byteLength > MAX_RESPONSE_BYTES) + return undefined; + let keys: unknown; + try { + keys = JSON.parse(response.body)?.backup_nostr_pubkeys; + } catch { + return undefined; + } + if (!Array.isArray(keys)) return undefined; + const listed = keys.map((key) => + typeof key === "string" ? boundKey({ pubkey_hex: key }) : null, + ); + return listed.every((key) => key !== null) ? listed : undefined; +} + +function owned(community: NonNullable[number]) { + const relay = relayUrl(community); + if (!relay || community.archived_at) return []; + try { + const url = enteredRelayOrigin(relay); + return [{ host: new URL(url).host, url }]; + } catch { + return []; + } +} + +/** + * Whether this build backs a key up for a Builderlab sign-in: only when it has + * a Builderlab URL (`oauthTarget` throws otherwise) and the native status says + * Builderlab backup is allowed. First run's backup and status reads and + * Settings → Identity share this rule. Any error answers false. + */ +export async function backupOfferedFor( + backup: Pick, +) { + try { + oauthTarget(); + return (await backup.ready()).builderlab; + } catch { + return false; + } +} + +/** + * The real reads, binding and unlink, against the build URL's kgoose + * (`/api/goose`), + * authenticated with the first-run session credential and sent through the + * bundled plugin's native grant like the sign-in itself. Routes and reply + * shapes are the communities section's (`communities/native.ts` ROUTES + * `identity`, `list` and `unbind`, and its `bind`; `api.ts` parsers) and, for the saved + * destinations, the Builderlab plugin's known-communities list and add; + * neither owner's own client is touched. The backup goes through the native + * uploader instead (`backup.ts`). + */ +export function builderlabAccount( + credential: Credential, + request: Host["request"], + identity: BackupIdentity, +): FirstRunAccount { + const target = oauthTarget(); + const portal = new URL(target).origin; + const post = (path: string, body: object = {}) => + request({ + url: `${target}${path}`, + method: "POST", + headers: { + "Content-Type": "application/json", + "X-BB-Session-Credential": credential.value, + // Binding requires the challenge origin to match this header. + Origin: portal, + }, + body: JSON.stringify(body), + }); + /** + * One POST; `read` turns the parsed reply (null when not linked) into the + * answer. Any refusal of the answer is logged in development with the + * route, status and kgoose's correlation ID — again never the body — then + * thrown with that ID as `correlationId`, off the failed page. + */ + const call = async ( + step: string, + path: string, + fallback: string, + read: (reply: Reply | null, response: HostResponse) => T, + body: object = {}, + ) => { + const response = await post(path, body).catch((error: unknown) => { + throw transient(asError(error)); + }); + let reply: Reply | undefined; + try { + if (response.status === 401) throw new Error(SIGNED_OUT); + reply = parse(step, response); + return read(notLinked(reply) ? null : checked(reply, fallback), response); + } catch (error) { + const id = reply?.correlation_id; + if (id && error instanceof Error) + Object.assign(error, { correlationId: id }); + if (import.meta.env.DEV) + console.warn( + `Builderlab ${step}: POST ${path} answered HTTP ${response.status}${id ? ` (correlation ID ${id})` : ""}`, + error instanceof Error ? error.message : error, + ); + throw transient(error, response.status); + } + }; + const linkedKey = () => + call( + "account check", + "/v1/buzz/nostr-identities/current", + "Could not check this Builderlab account.", + (reply, response) => { + // kgoose answers 200 with `identity` null or absent when no key is + // bound; it never sends MISSING_MAPPING here. A linked account must + // never be given a second key, so a bound identity without a usable + // key is a failure, not "not linked". + if (!reply || reply.identity == null) return null; + const key = boundKey(reply.identity); + if (!key) + throw failure("account check", response, "unusable linked key"); + return key; + }, + ); + return { + linkedKey, + async bind(pubkey) { + // Re-running after a failure must not meet 409 `pubkey_already_bound`. + if ((await linkedKey()) === pubkey) return; + const step = "account link"; + const fallback = "Could not link your key to this Builderlab account."; + // The challenge refuses only with 403 `unauthorized` or `invalid_origin`, + // or 429 `too_many_attempts`. + const challenge = await call( + step, + "/v1/buzz/nostr-identities/challenge", + fallback, + (reply, response) => { + if (!reply) throw failure(step, response); + const { challenge_id, nonce, verification_code, origin, expires_at } = + reply as Record; + return { challenge_id, nonce, verification_code, origin, expires_at }; + }, + { origin: portal }, + ); + // Native code signs only a well-formed challenge for production + // Builderlab or the build URL's origin. + const event = await invoke("identity_sign_builderlab_binding", { + challenge, + }).catch((error: unknown) => { + throw asError(error); + }); + // An account bound to another key is refused here, with 409 + // `identity_already_bound`. + await call( + step, + "/v1/buzz/nostr-identities/verify", + fallback, + (reply, response) => { + if (boundKey(reply?.identity) !== pubkey) + throw failure(step, response, "link not confirmed"); + }, + { + challenge_id: challenge.challenge_id, + nonce: challenge.nonce, + signed_payload: JSON.stringify(event), + }, + ); + }, + async unbind(old, replacement) { + // kgoose deletes whatever is bound, so check it is still the old key. + // A key bound between this read and the delete is still removed: the + // real fix is an `expected_pubkey_hex` on the delete request, checked + // under kgoose's user-row lock. + const before = await linkedKey(); + if (before === null || before === replacement) return; + if (before !== old) throw new Error(OTHER_KEY); + const removed = await call( + "account unlink", + "/v1/buzz/nostr-identities/delete", + "Could not unlink your old key from this Builderlab account.", + (reply) => boundKey(reply?.identity), + ); + if (removed === old) return; + // kgoose also answers 200 with no `identity`, removing nothing, when + // the session or its authorization has lapsed, so read what is bound. + const now = await linkedKey(); + if (now === null || now === replacement) return; + throw new Error( + now === old + ? "Builderlab didn’t unlink your old key, so your new key can’t be linked. Your Builderlab sign-in may have ended." + : OTHER_KEY, + ); + }, + ownedCommunities: () => + call( + "community list", + "/v1/buzz/communities/list", + "Could not load the communities this account owns.", + (reply) => reply?.communities?.flatMap(owned) ?? [], + ), + // The route consults no Nostr identity, so its refusals are plain strings + // and `reply` is never null here; its rows are not the card's. + knownCommunities: () => + call( + "known communities", + "/v1/buzz/known-communities/list", + "Could not load the communities saved to this account.", + (reply, response) => { + // Protobuf JSON can omit an empty repeated field. + const rows: unknown = reply?.communities ?? []; + const listed = Array.isArray(rows) + ? rows.map(listedAddress) + : [undefined]; + if (listed.includes(undefined)) + throw failure("known communities", response, "unreadable row"); + return listed.filter((address): address is string => !!address); + }, + ), + async saveCommunity(url) { + await call( + "community save", + "/v1/buzz/known-communities/add", + "Could not save this community to your Builderlab account.", + () => undefined, + { community_url: relayAddress(url) }, + ); + }, + async backUp(pubkey) { + if (!(await backupOfferedFor(identity.backup))) + throw new BackupError("unavailable"); + await backUpWith(identity, pubkey, credential.value); + }, + // Backup routes answer plain text, not the card's reply shape, so only + // the status and a 2xx body are read. + async backupStatus() { + if (!(await backupOfferedFor(identity.backup))) return "unavailable"; + const path = "/v1/buzz/nsec-backups/status"; + const response = await post(path).catch(() => { + throw new BackupError("unreachable"); + }); + if (response.status === 404) return "unavailable"; + const ok = response.status >= 200 && response.status < 300; + const keys = ok ? backedUpKeys(response) : undefined; + if (keys) return keys; + if (import.meta.env.DEV) + console.warn( + `Builderlab backup status: POST ${path} answered HTTP ${response.status}`, + ); + throw new BackupError( + ok ? "invalid_service" : backupFailure(response.status), + ); + }, + recovery: { + subscribe: identity.subscribe, + snapshot() { + const state = identity.snapshot(); + return state.status === "missing" ? state.recovery : undefined; + }, + beginRecovery: identity.beginRecovery, + installRecovery: identity.installRecovery, + cancelRecovery: identity.cancelRecovery, + }, + }; +} + +/** + * Recovery answered from the seed, with no browser sign-in or native recovery + * command: a seeded `recovery` key is the one backup recovered, and + * installing it imports it as Enter your private key would. With none, every + * restore answers that recovery is unavailable, as every tenant does today. + */ +function seededRecovery( + seed: FirstRunAccountSeed, + identity: Pick, +): Recovery { + let state: RecoveryState | undefined; + const listeners = new Set<() => void>(); + const publish = (next: RecoveryState | undefined) => { + state = next; + for (const listener of listeners) listener(); + }; + return { + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + snapshot: () => state, + async beginRecovery() { + publish( + seed.recovery + ? { + status: "choosing", + candidates: [{ handle: "seed", pubkey: seed.recovery.pubkey }], + currentPubkey: seed.linked, + failedCount: 0, + } + : { status: "error", error: RECOVERY_UNAVAILABLE }, + ); + }, + async installRecovery() { + if (!seed.recovery) return; + publish({ status: "saving" }); + await identity.importKey(seed.recovery.nsec); + const saved = identity.snapshot(); + publish( + saved.status === "ready" + ? undefined + : { + status: "error", + error: + (saved.status === "missing" && saved.error) || + "Couldn’t restore your identity. Try again.", + }, + ); + }, + async cancelRecovery() { + publish(undefined); + return true; + }, + }; +} + +/** + * The reads answered from the `BUZZ_DEV_FIRST_RUN_ACCOUNT` development seed. + * Binding, unlinking and backup succeed without a request, a native signature + * or a native backup command, in every build; a seeded `backup` kind fails + * every backup with that kind's page instead (`fail-once`: only this sign-in's + * first, as an unconfirmed upload, so Try again succeeds), and a seeded + * `unlink` status fails every unlink as kgoose answering it would. Backup + * status lists the seeded `backups`, the saved destinations are the seeded + * `known` (a save sends nothing), and recovery is `seededRecovery`. + */ +export function seededAccount( + seed: FirstRunAccountSeed, + identity: Pick, +): FirstRunAccount { + let failOnce = seed.backup === "fail-once"; + return { + linkedKey: () => Promise.resolve(seed.linked), + ownedCommunities: () => Promise.resolve(seed.communities), + knownCommunities: () => Promise.resolve(seed.known ?? []), + saveCommunity: () => Promise.resolve(), + bind: () => Promise.resolve(), + unbind: () => + seed.unlink + ? Promise.reject( + transient( + seed.unlink === 401 + ? new Error(SIGNED_OUT) + : failure("account unlink", { status: seed.unlink }), + seed.unlink, + ), + ) + : Promise.resolve(), + backUp() { + if (failOnce) { + failOnce = false; + return Promise.reject(new BackupError("unconfirmed")); + } + return seed.backup && seed.backup !== "fail-once" + ? Promise.reject(new BackupError(seed.backup)) + : Promise.resolve(); + }, + backupStatus: () => Promise.resolve(seed.backups ?? []), + recovery: seededRecovery(seed, identity), + }; +} diff --git a/src/features/identity/builderlab-hosted.test.ts b/src/features/identity/builderlab-hosted.test.ts new file mode 100644 index 0000000000..0f9e25983f --- /dev/null +++ b/src/features/identity/builderlab-hosted.test.ts @@ -0,0 +1,51 @@ +import { expect, it } from "vitest"; +import { relayOrigin } from "../communities/destination"; +import type { FoundCommunity } from "../communities/find-community"; +import { isBuilderlabHosted } from "./builderlab-hosted"; + +const BLOCK_SELF = + "76aa9daa7edc9c2e1dc6a2a90dbce10c1bd52f060fa6506ba7bad4f50240fc01"; +/** As `findCommunity` reports it: the canonical origin, plus `self` when given. */ +const found = (input: string, self?: string): FoundCommunity => ({ + url: relayOrigin(input), + host: new URL(relayOrigin(input)).host, + self, + policy: null, +}); + +it.each([ + "wss://communities.buzz.xyz", + "https://design.communities.buzz.xyz", + "wss://buzz.block.builderlab.xyz", + "wss://acme.communities.block.builderlab.xyz", + "wss://buzz.test.blockstaging.build", + "WSS://Design.Communities.BUZZ.xyz.", + "https://communities.buzz.xyz:443", +])("%s is Builderlab-hosted", (input) => { + expect(isBuilderlabHosted(found(input))).toBe(true); +}); + +it.each([ + "wss://communities.buzz.xyz.evil.com", + "wss://notcommunities.buzz.xyz", + "wss://xcommunities.buzz.xyz", + "wss://buzz.xyz", + "wss://buzz.acme.builderlab.xyz", + "wss://relay.example.com", + "https://communities.buzz.xyz:8443", +])("%s is not Builderlab-hosted", (input) => { + expect(isBuilderlabHosted(found(input))).toBe(false); +}); + +it("falls back to the relay key when the hostname is unknown, on the default port only", () => { + // A deployment moved to a new domain keeps its relay key. + expect( + isBuilderlabHosted(found("wss://buzz.block.example", BLOCK_SELF)), + ).toBe(true); + expect( + isBuilderlabHosted(found("https://buzz.block.example:8443", BLOCK_SELF)), + ).toBe(false); + expect( + isBuilderlabHosted(found("wss://buzz.block.example", "ab".repeat(32))), + ).toBe(false); +}); diff --git a/src/features/identity/builderlab-hosted.ts b/src/features/identity/builderlab-hosted.ts new file mode 100644 index 0000000000..f890ba8232 --- /dev/null +++ b/src/features/identity/builderlab-hosted.ts @@ -0,0 +1,43 @@ +import type { FoundCommunity } from "../communities/find-community.ts"; + +/** + * The relays Builderlab deployments host: hostname, then relay key (NIP-11 + * `self`), so a deployment stays recognisable while its DNS is reshuffled. + */ +const HOSTED = [ + { + host: "communities.buzz.xyz", + self: "12f6870117eff1a6318bd38c82a65d51dd19879b7489f57247114d0ee8a96de3", + }, + { + host: "block.builderlab.xyz", + self: "76aa9daa7edc9c2e1dc6a2a90dbce10c1bd52f060fa6506ba7bad4f50240fc01", + }, + { + host: "test.blockstaging.build", + self: "f7f4df9401ecf46cc608b0889a61d957b306721cdefe42cb24519cb5a473dcf1", + }, +] as const; + +/** + * Whether a community URL given before setup is Builderlab-hosted, so it needs + * Builderlab sign-in rather than a key the person brings. + * + * Hostname first (exact or label-boundary suffix: `design.communities.buzz.xyz` + * and the apex match `communities.buzz.xyz`; `notcommunities.buzz.xyz` and + * `communities.buzz.xyz.evil.com` do not), then the relay's `self`. Block's + * deployments serve on 443, so a non-default port never matches. Sign-in is + * always at `BUZZ_BUILDERLAB_URL`, whichever deployment matched: nothing a + * relay publishes names its portal. + */ +export function isBuilderlabHosted(found: FoundCommunity): boolean { + // Canonical already: relayOrigin() lower-cased the host and dropped a final dot. + const url = new URL(found.url); + if (url.port !== "") return false; + return HOSTED.some( + ({ host, self }) => + url.hostname === host || + url.hostname.endsWith(`.${host}`) || + found.self === self, + ); +} diff --git a/src/features/identity/first-run-seed.test.ts b/src/features/identity/first-run-seed.test.ts new file mode 100644 index 0000000000..022630f82f --- /dev/null +++ b/src/features/identity/first-run-seed.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it, vi } from "vitest"; +import { npubEncode, nsecEncode } from "nostr-tools/nip19"; +import { generateSecretKey, getPublicKey } from "nostr-tools/pure"; +import { BackupError } from "./backup"; +import { seededAccount } from "./builderlab-account"; +import { parseFirstRunAccountSeed } from "./first-run-seed"; +import { type IdentitySnapshot, recoveryOff } from "./service"; + +const pubkey = "ab".repeat(32); +/** Only a seeded restore imports a key. */ +const noImport = { + importKey: () => Promise.reject(new Error("Imported a key")), + snapshot: (): IdentitySnapshot => ({ status: "missing", busy: false }), +}; + +describe("parseFirstRunAccountSeed", () => { + it("is absent when unset, and `unlinked` answers no key and no communities", async () => { + expect(parseFirstRunAccountSeed()).toBeUndefined(); + expect(parseFirstRunAccountSeed(" ")).toBeUndefined(); + const seed = parseFirstRunAccountSeed("unlinked"); + expect(seed).toEqual({ linked: null, communities: [] }); + if (!seed) throw new Error("unreachable"); + const account = seededAccount(seed, noImport); + expect(await account.linkedKey()).toBeNull(); + expect(await account.ownedCommunities()).toEqual([]); + expect(await account.knownCommunities()).toEqual([]); + await expect(account.backUp(pubkey)).resolves.toBeUndefined(); + await expect(account.bind(pubkey)).resolves.toBeUndefined(); + await expect(account.unbind(pubkey, pubkey)).resolves.toBeUndefined(); + expect(await account.backupStatus()).toEqual([]); + }); + + it.each([ + [503, "Builderlab account unlink failed (HTTP 503).", true], + [403, "Builderlab account unlink failed (HTTP 403).", false], + [401, "Your Builderlab sign-in is no longer valid.", false], + ])( + "fails every unlink as kgoose answering %i would", + async (status, message, retryable) => { + const seed = parseFirstRunAccountSeed( + JSON.stringify({ npub: npubEncode(pubkey), unlink: status }), + ); + expect(seed).toEqual({ linked: pubkey, communities: [], unlink: status }); + if (!seed) throw new Error("unreachable"); + const failed: unknown = await seededAccount(seed, noImport) + .unbind(pubkey, pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + expect((failed as Error).message).toBe(message); + expect((failed as { retryable?: unknown }).retryable === true).toBe( + retryable, + ); + }, + ); + + it("fails every backup with a seeded kind, for a linked or an unlinked account", async () => { + const seed = parseFirstRunAccountSeed('{"backup":"unreachable"}'); + expect(seed).toEqual({ + linked: null, + communities: [], + backup: "unreachable", + }); + if (!seed) throw new Error("unreachable"); + const failed = await seededAccount(seed, noImport) + .backUp(pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(BackupError); + expect(failed).toMatchObject({ kind: "unreachable", retryable: true }); + expect( + parseFirstRunAccountSeed( + JSON.stringify({ npub: npubEncode(pubkey), backup: "timeout" }), + ), + ).toEqual({ linked: pubkey, communities: [], backup: "timeout" }); + expect(parseFirstRunAccountSeed("{}")).toEqual({ + linked: null, + communities: [], + }); + }); + + it("fails only the first backup with fail-once, as unconfirmed, for a linked or an unlinked account", async () => { + for (const value of [ + { backup: "fail-once" }, + { npub: npubEncode(pubkey), backup: "fail-once" }, + ]) { + const seed = parseFirstRunAccountSeed(JSON.stringify(value)); + expect(seed).toMatchObject({ backup: "fail-once" }); + if (!seed) throw new Error("unreachable"); + const account = seededAccount(seed, noImport); + const failed = await account + .backUp(pubkey) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(BackupError); + expect(failed).toMatchObject({ kind: "unconfirmed", retryable: true }); + await expect(account.backUp(pubkey)).resolves.toBeUndefined(); + // Each sign-in's account fails once again. + await expect( + seededAccount(seed, noImport).backUp(pubkey), + ).rejects.toThrow(BackupError); + } + }); + + it("decodes the npub to hex and canonicalizes the owned and saved hosts", async () => { + const seed = parseFirstRunAccountSeed( + JSON.stringify({ + npub: npubEncode(pubkey), + communities: ["Design.Example.com", "wss://buzz.example.org:8443"], + // Saved destinations take the field's spellings and come back as kgoose lists them. + known: ["Chat.Example.com.", "https://buzz.example.org:8443/"], + }), + ); + expect(seed).toEqual({ + linked: pubkey, + communities: [ + { host: "design.example.com", url: "https://design.example.com" }, + { host: "buzz.example.org:8443", url: "https://buzz.example.org:8443" }, + ], + known: ["wss://chat.example.com", "wss://buzz.example.org:8443"], + }); + if (!seed) throw new Error("unreachable"); + const account = seededAccount(seed, noImport); + expect(await account.linkedKey()).toBe(pubkey); + expect(await account.knownCommunities()).toEqual([ + "wss://chat.example.com", + "wss://buzz.example.org:8443", + ]); + expect( + parseFirstRunAccountSeed(JSON.stringify({ npub: npubEncode(pubkey) })), + ).toEqual({ linked: pubkey, communities: [] }); + }); + + it("lists the seeded backups, decoded to hex, in backup status", async () => { + const other = "cd".repeat(32); + const seed = parseFirstRunAccountSeed( + JSON.stringify({ + npub: npubEncode(pubkey), + backups: [npubEncode(pubkey), npubEncode(other)], + }), + ); + expect(seed).toEqual({ + linked: pubkey, + communities: [], + backups: [pubkey, other], + }); + if (!seed) throw new Error("unreachable"); + expect(await seededAccount(seed, noImport).backupStatus()).toEqual([ + pubkey, + other, + ]); + }); + + it("answers every restore that recovery is unavailable when no key is seeded, with no native command", async () => { + const seed = parseFirstRunAccountSeed( + JSON.stringify({ npub: npubEncode(pubkey) }), + ); + if (!seed) throw new Error("unreachable"); + const { recovery } = seededAccount(seed, noImport); + const changed = vi.fn(); + recovery.subscribe(changed); + expect(recovery.snapshot()).toBeUndefined(); + await recovery.beginRecovery(); + expect(recoveryOff(recovery.snapshot())).toBe(true); + expect(await recovery.cancelRecovery()).toBe(true); + expect(recovery.snapshot()).toBeUndefined(); + expect(changed).toHaveBeenCalledTimes(2); + }); + + it("recovers the seeded key and installs it through the native import", async () => { + const secret = generateSecretKey(); + const nsec = nsecEncode(secret); + const restored = getPublicKey(secret); + const seed = parseFirstRunAccountSeed( + JSON.stringify({ npub: npubEncode(restored), recovery: nsec }), + ); + expect(seed).toEqual({ + linked: restored, + communities: [], + recovery: { nsec, pubkey: restored }, + }); + if (!seed) throw new Error("unreachable"); + let state: IdentitySnapshot = { status: "missing", busy: false }; + const importKey = vi.fn(async (_nsec: string) => { + state = { status: "ready", viewer: restored }; + }); + const { recovery } = seededAccount(seed, { + importKey, + snapshot: () => state, + }); + await recovery.beginRecovery(); + expect(recovery.snapshot()).toEqual({ + status: "choosing", + candidates: [{ handle: "seed", pubkey: restored }], + currentPubkey: restored, + failedCount: 0, + }); + await recovery.installRecovery("seed"); + expect(importKey.mock.calls).toEqual([[nsec]]); + expect(recovery.snapshot()).toBeUndefined(); + + // A refused import is the restore's error, as a native one would be. + state = { status: "missing", busy: false, error: "Couldn’t save the key." }; + importKey.mockResolvedValueOnce(undefined); + await recovery.beginRecovery(); + await recovery.installRecovery("seed"); + expect(recovery.snapshot()).toEqual({ + status: "error", + error: "Couldn’t save the key.", + }); + }); + + it.each([ + ["garbage", "linked"], + ["an array", "[]"], + ["a private key", JSON.stringify({ npub: nsecEncode(new Uint8Array(32)) })], + ["hex instead of an npub", JSON.stringify({ npub: pubkey })], + [ + "an unknown field", + JSON.stringify({ npub: npubEncode(pubkey), secret: "s3cret" }), + ], + ["an unknown backup kind", JSON.stringify({ backup: "s3cret" })], + [ + "an inherited name as a backup kind", + JSON.stringify({ backup: "toString" }), + ], + ["a non-string backup kind", JSON.stringify({ backup: true })], + ["a misspelled fail-once", JSON.stringify({ backup: "fail_once" })], + ["a backup list that is not a list", JSON.stringify({ backups: "s3cret" })], + ["a hex backup", JSON.stringify({ backups: [pubkey] })], + [ + "a private key as a backup", + JSON.stringify({ backups: [nsecEncode(new Uint8Array(32).fill(1))] }), + ], + ["a non-string recovery key", JSON.stringify({ recovery: 1 })], + [ + "an npub as the recovery key", + JSON.stringify({ recovery: npubEncode(pubkey) }), + ], + ["a malformed recovery key", JSON.stringify({ recovery: "nsec1s3cret" })], + ["a non-numeric unlink status", JSON.stringify({ unlink: "s3cret" })], + ["a successful unlink status", JSON.stringify({ unlink: 200 })], + ["a fractional unlink status", JSON.stringify({ unlink: 503.5 })], + [ + "a non-string host", + JSON.stringify({ npub: npubEncode(pubkey), communities: [1] }), + ], + [ + "an insecure host", + JSON.stringify({ + npub: npubEncode(pubkey), + communities: ["http://s3cret.example"], + }), + ], + ["a non-array known list", JSON.stringify({ known: "s3cret.example" })], + ["a non-string saved host", JSON.stringify({ known: [1] })], + [ + "an insecure saved host", + JSON.stringify({ known: ["http://s3cret.example"] }), + ], + [ + "a saved host with a path", + JSON.stringify({ known: ["wss://s3cret.example/relay"] }), + ], + ])("rejects %s without echoing it", (_label, raw) => { + expect(() => parseFirstRunAccountSeed(raw)).toThrow( + /^BUZZ_DEV_FIRST_RUN_ACCOUNT must be/, + ); + expect(() => parseFirstRunAccountSeed(raw)).not.toThrow(/s3cret/); + }); +}); diff --git a/src/features/identity/first-run-seed.ts b/src/features/identity/first-run-seed.ts new file mode 100644 index 0000000000..d92f4856ea --- /dev/null +++ b/src/features/identity/first-run-seed.ts @@ -0,0 +1,121 @@ +import { decode } from "nostr-tools/nip19"; +import { getPublicKey } from "nostr-tools/pure"; +import { + enteredRelayOrigin, + relayAddress, +} from "../communities/destination.ts"; +import { + type BackupFailureKind, + isBackupFailureKind, +} from "./backup-failure.ts"; +import type { OwnedCommunity } from "./builderlab-account.ts"; + +/** + * Development stand-in for the first-run Builderlab account, from + * `BUZZ_DEV_FIRST_RUN_ACCOUNT`: `unlinked`, or + * `{"npub"?: "npub1…", "communities"?: ["host", …], "known"?: ["host", …], + * "backups"?: ["npub1…", …], "recovery"?: "nsec1…", + * "backup"?: "" | "fail-once", "unlink"?: }`, + * where no npub is an unlinked account, `known` the destinations saved to the + * account, `backups` the keys backup status + * lists, `recovery` the key every restore recovers (none: recovery is + * unavailable, as on every tenant today), `backup` names the failure every + * backup reports (`fail-once`: only the first, which Try again then clears), + * and `unlink` the 4xx or 5xx every unlink answers. Builds + * leave the Vite define empty, so the seed can only ever answer a dev server. + * A leaf module with explicit `.ts` imports: vite.config.ts validates the + * value through Node's native loader. + */ +export type FirstRunAccountSeed = Readonly<{ + linked: string | null; + communities: readonly OwnedCommunity[]; + /** Saved destinations as kgoose lists them: `wss://host[:port]`. */ + known?: readonly string[]; + /** Hex keys. */ + backups?: readonly string[]; + /** The nsec as typed, which native import takes, and its hex key. */ + recovery?: Readonly<{ nsec: string; pubkey: string }>; + backup?: BackupFailureKind | "fail-once"; + unlink?: number; +}>; + +/** An `npub1…` as hex; throws for anything else. */ +function hex(npub: unknown) { + const decoded = typeof npub === "string" ? decode(npub) : undefined; + if (decoded?.type !== "npub") throw new Error(); + return decoded.data; +} + +/** An `nsec1…` as typed, with its hex key; throws for anything else. */ +function secretKey(nsec: string) { + const decoded = decode(nsec); + if (decoded.type !== "nsec") throw new Error(); + return { nsec, pubkey: getPublicKey(decoded.data) }; +} + +export function parseFirstRunAccountSeed( + raw = "", +): FirstRunAccountSeed | undefined { + const value = raw.trim(); + if (!value) return undefined; + if (value === "unlinked") return { linked: null, communities: [] }; + try { + const seed: unknown = JSON.parse(value); + if (!seed || typeof seed !== "object" || Array.isArray(seed)) + throw new Error(); + const { + npub, + communities = [], + known, + backups, + recovery, + backup, + unlink, + ...rest + } = seed as Record; + if ( + Object.keys(rest).length > 0 || + !Array.isArray(communities) || + (known !== undefined && !Array.isArray(known)) || + (backups !== undefined && !Array.isArray(backups)) || + (recovery !== undefined && typeof recovery !== "string") || + (backup !== undefined && + backup !== "fail-once" && + !isBackupFailureKind(backup)) || + (unlink !== undefined && + !( + typeof unlink === "number" && + Number.isInteger(unlink) && + unlink >= 400 && + unlink <= 599 + )) + ) + throw new Error(); + return { + linked: npub === undefined ? null : hex(npub), + communities: communities.map((host: unknown) => { + if (typeof host !== "string") throw new Error(); + const url = enteredRelayOrigin(host); + return { host: new URL(url).host, url }; + }), + // The same spellings as the Community URL field, stored as kgoose lists them. + ...(known === undefined + ? {} + : { + known: known.map((value: unknown) => { + if (typeof value !== "string") throw new Error(); + return relayAddress(enteredRelayOrigin(value)); + }), + }), + ...(backups === undefined ? {} : { backups: backups.map(hex) }), + ...(recovery === undefined ? {} : { recovery: secretKey(recovery) }), + ...(backup === undefined ? {} : { backup }), + ...(unlink === undefined ? {} : { unlink: unlink as number }), + }; + } catch { + // Do not echo malformed input: an environment value might contain credentials. + throw new Error( + "BUZZ_DEV_FIRST_RUN_ACCOUNT must be `unlinked` or a JSON object {npub?, communities?: [hostname, …], known?: [hostname, …], backups?: [npub, …], recovery?: nsec, backup?: failure kind or fail-once, unlink?: HTTP status}.", + ); + } +} diff --git a/src/features/identity/identity.test.tsx b/src/features/identity/identity.test.tsx index a78609552e..a4997cbc59 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 Builderlab deployments, 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,132 +783,4692 @@ 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."; +it("a landing URL is looked up before sign-in, Sign in with email from the connect choice leads to setup, 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) => released.promise); + const { backupStatus, release } = gatedStatus(); const identity = createIdentity(); const user = userEvent.setup(); render( - -

Restored

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

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 connect choice. Sign in with email starts the build portal's + // sign-in, shown pending on the landing with the typed address held in the + // disabled field. + expect(connect).not.toHaveBeenCalled(); + await user.click(connectButton()); + expect(connect).toHaveBeenCalledTimes(1); + expect( + screen.queryByRole("heading", { name: "Finding community" }), + ).toBeNull(); + 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…", + ), ); + await release(); expect( - screen.queryByRole("button", { name: "Create a new identity" }), + 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(); - fireEvent.mouseDown(titleBar(), { - button: 0, - detail: 1, - }); - expect(startDragging).toHaveBeenCalledOnce(); - await user.click( - screen.getByRole("button", { name: "Retry secure storage access" }), + 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 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 an address 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 found landing URL 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

+
, ); - expect(await screen.findByText("Restored")).toBeVisible(); + await connectChoicePage(user, relay); + // Found: 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", + "relay_http", + "relay_http", + ]); + identity.dispose(); +}); + +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) => 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(); + 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, shown pending on the + // landing with the address held. + expect(connect).toHaveBeenCalledTimes(1); + 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, 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", + "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("import failure keeps setup open without falling through to creation", async () => { - vi.mocked(invoke) - .mockResolvedValueOnce(null) - .mockRejectedValueOnce("Invalid nsec"); +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): Promise => { + signal = current; + return new Promise(() => {}); + }); const identity = createIdentity(); - await waitFor(() => expect(identity.snapshot().status).toBe("missing")); - await identity.importKey(key); + 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).toHaveBeenCalledTimes(1); + 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("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(); + const user = userEvent.setup(); + render( + +

Signed in

+
, + ); + await connectChoicePage(user, relay); + await user.click( + screen.getByRole("button", { name: "I already have a key" }), + ); + 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: "missing", - busy: false, - error: "Invalid nsec", + 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("coalesces pending creation and only creates on explicit request", async () => { - const created = deferred(); - vi.mocked(invoke) - .mockResolvedValueOnce(null) - .mockImplementationOnce(() => created.promise); +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 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( + 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("fetches a private key only on interaction, masks by absence, and cancels a late reveal", async () => { - vi.mocked(invoke).mockResolvedValueOnce(viewer); +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}`); + }); + const connect = vi.fn(); 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( - "••••••••••••••••", + render( + +

Signed in

+
, ); - 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( - "••••••••••••••••", + 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("copies without revealing and cancels a pending export on blur before clipboard access", async () => { - vi.mocked(invoke).mockResolvedValueOnce(viewer); +it("a Builderlab-hosted landing URL signs in at once, with no connect choice", async () => { + const relay = landingRelay("https://design.communities.buzz.xyz"); + relay.info.resolve(); + const connect = vi + .fn<(signal: AbortSignal) => Promise>() + .mockImplementation(() => new Promise(() => {})); 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); - }); - expect(write).toHaveBeenCalledTimes(1); + await screen.findByRole("button", { name: SIGN_IN }); + await user.type(urlField(), "design.communities.buzz.xyz{Enter}"); + await screen.findByRole("button", { name: "Cancel sign-in" }); + expect(connect).toHaveBeenCalledTimes(1); + expect( + screen.queryByRole("heading", { name: "Connect to your community" }), + ).toBeNull(); + expect(urlField()).toHaveValue("design.communities.buzz.xyz"); + expect(relay.relayCalls).toEqual(relay.lookup); + expect(identity.snapshot().status).toBe("missing"); + identity.dispose(); +}); + +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) => 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("heading", { name: "Connect to your community" }); + await user.click(connectButton()); + await screen.findByRole("button", { name: "Cancel sign-in" }); + 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); + // The landing's own Connect still signs in after the cancelled one. + await user.click(connectButton()); + await screen.findByRole("button", { name: "Cancel sign-in" }); + expect(connect).toHaveBeenCalledTimes(2); + 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()); + await screen.findByRole("heading", { name: "Connect to your community" }); + await user.click(connectButton()); + 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); + 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, 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, _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, 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}"); + await screen.findByRole("heading", { name: "Connect to your community" }); + await user.click(connectButton()); + 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) 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 after signing in from the connect choice 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 screen.findByRole("heading", { name: "Connect to your community" }); + await user.click(connectButton()); + 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, identity) => + builderlabAccount(credential, 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, identity) => + builderlabAccount(credential, 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 signed in from the connect choice 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}"); + await screen.findByRole("heading", { name: "Connect to your community" }); + await user.click(connectButton()); + expect( + await screen.findByRole("heading", { name: "Welcome back" }), + ).toBeVisible(); + expect(account).toHaveBeenCalledWith(credential, 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, 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(); }); @@ -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(); diff --git a/src/features/identity/key-import.test.ts b/src/features/identity/key-import.test.ts new file mode 100644 index 0000000000..e4010149d6 --- /dev/null +++ b/src/features/identity/key-import.test.ts @@ -0,0 +1,61 @@ +import { npubEncode, nsecEncode } from "nostr-tools/nip19"; +import { generateSecretKey, getPublicKey } from "nostr-tools/pure"; +import { expect, it, vi } from "vitest"; +import { + KEY_FILE_MAX_BYTES, + classifyKeyImport, + readKeyFile, +} from "./key-import"; + +const secret = generateSecretKey(); +const nsec = nsecEncode(secret); +const npub = npubEncode(getPublicKey(secret)); + +it("accepts an nsec as typed, trimmed of surrounding whitespace, and previews its npub", () => { + expect(classifyKeyImport(nsec)).toEqual({ kind: "nsec", nsec, npub }); + expect(classifyKeyImport(`\n ${nsec} \r\n`)).toEqual({ + kind: "nsec", + nsec, + npub, + }); + // Bech32 allows an all-uppercase string, and so does native import. + expect(classifyKeyImport(nsec.toUpperCase())).toEqual({ + kind: "nsec", + nsec: nsec.toUpperCase(), + npub, + }); +}); + +it("tells a password-protected backup apart from an invalid key", () => { + expect( + classifyKeyImport( + "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p", + ), + ).toEqual({ kind: "encrypted" }); + expect(classifyKeyImport(" NcryptSec1abc ")).toEqual({ kind: "encrypted" }); + for (const text of [ + "hello", + "nsec1", + `${nsec.slice(0, -1)}${nsec.endsWith("q") ? "p" : "q"}`, + npub, + "ab".repeat(32), + ]) + expect(classifyKeyImport(text)).toEqual({ kind: "invalid" }); + expect(classifyKeyImport("")).toEqual({ kind: "empty" }); + expect(classifyKeyImport(" \n\t")).toEqual({ kind: "empty" }); +}); + +it("reads the first non-blank line of a small key file and refuses a larger one unread", async () => { + const text = vi.fn(async () => `\r\n ${nsec} \r\nsecond line\n`); + await expect(readKeyFile({ size: KEY_FILE_MAX_BYTES, text })).resolves.toBe( + nsec, + ); + await expect( + readKeyFile({ size: 3, text: async () => "\n\n" }), + ).resolves.toBe(""); + const big = vi.fn(async () => nsec); + await expect( + readKeyFile({ size: KEY_FILE_MAX_BYTES + 1, text: big }), + ).rejects.toThrow(/too large to be a private key/); + expect(big).not.toHaveBeenCalled(); +}); diff --git a/src/features/identity/key-import.ts b/src/features/identity/key-import.ts new file mode 100644 index 0000000000..199b750381 --- /dev/null +++ b/src/features/identity/key-import.ts @@ -0,0 +1,50 @@ +import { decode, npubEncode } from "nostr-tools/nip19"; +import { getPublicKey } from "nostr-tools/pure"; + +/** What pasted, typed or file-sourced private-key text turned out to be. */ +export type KeyImportInput = + | { kind: "empty" } + /** `nsec` is the trimmed text native import takes as is; `npub` its public identity. */ + | { kind: "nsec"; nsec: string; npub: string } + /** A NIP-49 password-protected backup: nothing here can decrypt it. */ + | { kind: "encrypted" } + | { kind: "invalid" }; + +export function classifyKeyImport(text: string): KeyImportInput { + const trimmed = text.trim(); + if (!trimmed) return { kind: "empty" }; + if (trimmed.slice(0, 10).toLowerCase() === "ncryptsec1") + return { kind: "encrypted" }; + try { + const decoded = decode(trimmed); + if (decoded.type !== "nsec") return { kind: "invalid" }; + // Derivation also rejects a secret outside the curve order, as native import does. + return { + kind: "nsec", + nsec: trimmed, + npub: npubEncode(getPublicKey(decoded.data)), + }; + } catch { + return { kind: "invalid" }; + } +} + +/** Anything larger is not a key file; it is refused before being read. */ +export const KEY_FILE_MAX_BYTES = 1024; + +/** The first non-blank line of a small text file, trimmed. */ +export async function readKeyFile( + file: Pick, +): Promise { + if (file.size > KEY_FILE_MAX_BYTES) + throw new Error( + "That file is too large to be a private key. Choose a small text file whose first line is an nsec1 key.", + ); + const text = await file.text(); + return ( + text + .split(/\r?\n/) + .find((line) => line.trim()) + ?.trim() ?? "" + ); +} diff --git a/src/features/identity/restore-communities.test.ts b/src/features/identity/restore-communities.test.ts new file mode 100644 index 0000000000..ef03103acd --- /dev/null +++ b/src/features/identity/restore-communities.test.ts @@ -0,0 +1,185 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { inspectProfile } from "../communities/api"; +import type { ClientSnapshot } from "../communities/service"; +import { ReadError } from "../relay/errors"; +import { restoreKnown } from "./restore-communities"; +import { restoreCommunities } from "./setup"; + +vi.mock("../communities/api", () => ({ inspectProfile: vi.fn() })); +afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); +}); + +type Read = Awaited>; +const empty = { name: "", picture: "", about: "" }; +const found = (name: string): Read => ({ + existing: { name }, + profile: { name, picture: "", about: "" }, + exists: true, +}); +const none: Read = { existing: {}, profile: empty, exists: false }; +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} +function service(status: ClientSnapshot["status"] = "ready") { + return { + snapshot: () => ({ status, profile: empty }) as ClientSnapshot, + joined: vi.fn(), + }; +} +const options = { concurrency: 3, startBy: 45_000 }; +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +it("reads at most three destinations at once and reports outcomes in list order", async () => { + const reads = new Map>>(); + vi.mocked(inspectProfile).mockImplementation((id) => { + const read = deferred(); + reads.set(id, read); + return read.promise; + }); + const communities = service(); + const known = ["a", "b", "c", "d", "e"].map( + (host) => `wss://${host}.example`, + ); + const restoring = restoreKnown(known, communities, options); + await flush(); + expect([...reads.keys()]).toEqual([ + "https://a.example", + "https://b.example", + "https://c.example", + ]); + // The second finishing first frees its slot for the fourth. + reads.get("https://b.example")?.resolve(none); + await flush(); + expect([...reads.keys()]).toHaveLength(4); + for (const read of reads.values()) read.resolve(none); + await flush(); + reads.get("https://e.example")?.resolve(none); + const outcomes = await restoring; + expect(outcomes.map((outcome) => outcome.host)).toEqual([ + "a.example", + "b.example", + "c.example", + "d.example", + "e.example", + ]); + expect(communities.joined).toHaveBeenCalledTimes(5); +}); + +it("joins only the destinations that admit the key, and records refusals and failures as outcomes", async () => { + const ada = found("Ada"); + vi.mocked(inspectProfile).mockImplementation(async (id) => { + if (id === "https://denied.example") + throw new ReadError("denied", "Relay read failed (403)", 403); + if (id === "https://down.example") throw "Community discovery failed"; + return id === "https://ada.example" ? ada : none; + }); + const communities = service(); + const outcomes = await restoreKnown( + [ + "wss://denied.example", + "wss://ada.example", + "wss://down.example", + "wss://new.example:8443", + ], + communities, + options, + ); + expect(outcomes).toEqual([ + { + url: "https://denied.example", + id: "https://denied.example", + host: "denied.example", + kind: "denied", + }, + { + url: "https://ada.example", + id: "https://ada.example", + host: "ada.example", + kind: "admitted", + read: ada, + }, + { + url: "https://down.example", + id: "https://down.example", + host: "down.example", + kind: "unavailable", + }, + { + url: "https://new.example:8443", + id: "https://new.example:8443", + host: "new.example:8443", + kind: "admitted", + read: none, + }, + ]); + // A relay profile is offered to the empty local default; otherwise the + // local default is. + expect(communities.joined.mock.calls).toEqual([ + [{ id: "https://ada.example", name: "ada.example" }, ada.profile], + [{ id: "https://new.example:8443", name: "new.example:8443" }, empty], + ]); +}); + +it("reads a destination listed twice once and skips one this build cannot open", async () => { + vi.mocked(inspectProfile).mockResolvedValue(none); + const outcomes = await restoreKnown( + ["wss://a.example", "https://A.example/", "ws://insecure.example"], + service(), + options, + ); + expect(vi.mocked(inspectProfile).mock.calls).toEqual([["https://a.example"]]); + expect(outcomes.map((outcome) => outcome.kind)).toEqual(["admitted"]); +}); + +it("an admitted destination whose membership cannot be saved, or arrives before the device record is ready, is unavailable", async () => { + vi.mocked(inspectProfile).mockResolvedValue(none); + const failing = service(); + failing.joined.mockImplementation(() => { + throw new Error("Your saved communities could not be stored."); + }); + expect( + await restoreKnown(["wss://a.example"], failing, options), + ).toMatchObject([{ kind: "unavailable" }]); + const loading = service("loading"); + expect( + await restoreKnown(["wss://a.example"], loading, options), + ).toMatchObject([{ kind: "unavailable" }]); + expect(loading.joined).not.toHaveBeenCalled(); +}); + +it("starts no read 45 s after the restore began, leaving the rest unavailable", async () => { + vi.useFakeTimers(); + const first = deferred(); + vi.mocked(inspectProfile).mockReturnValueOnce(first.promise); + const restoring = restoreKnown( + ["wss://a.example", "wss://b.example"], + service(), + { concurrency: 1, startBy: 45_000 }, + ); + await vi.advanceTimersByTimeAsync(45_000); + first.resolve(none); + expect((await restoring).map((outcome) => outcome.kind)).toEqual([ + "admitted", + "unavailable", + ]); + expect(inspectProfile).toHaveBeenCalledTimes(1); +}); + +it("the restore step hands the outcomes to `decide` and rests where it says", async () => { + vi.mocked(inspectProfile).mockResolvedValue(none); + const decide = vi.fn(() => ({ status: "idle" }) as const); + const step = restoreCommunities(["wss://a.example"], service(), decide); + expect(step.label).toBe("Restoring your communities…"); + expect(await step.run()).toEqual({ status: "idle" }); + expect(decide).toHaveBeenCalledWith([ + expect.objectContaining({ id: "https://a.example", kind: "admitted" }), + ]); +}); diff --git a/src/features/identity/restore-communities.ts b/src/features/identity/restore-communities.ts new file mode 100644 index 0000000000..8de2f97540 --- /dev/null +++ b/src/features/identity/restore-communities.ts @@ -0,0 +1,81 @@ +import { inspectProfile } from "../communities/api"; +import { communityDestination } from "../communities/destination"; +import type { Communities } from "../communities/service"; +import { readErrorKind } from "../relay/errors"; + +/** + * What restoring one saved destination found: the relay admitted the key and + * the membership is saved (with the read that proved it), refused the key, or + * could not be read or saved. + */ +export type Restored = Readonly<{ + url: string; + id: string; + host: string; + kind: "admitted" | "denied" | "unavailable"; + read?: Awaited>; +}>; + +/** + * Joins each destination saved to the account that admits this key, by the + * same signed profile read and `communities.joined` the first-run join uses. + * At most `concurrency` reads run at once, and none starts `startBy` ms after + * the restore began (each read is itself bounded); one not started is + * unavailable. Outcomes follow the list order, one per destination. Never + * throws: every failure is an outcome. + */ +export async function restoreKnown( + known: readonly string[], + communities: Pick, + { concurrency, startBy }: { concurrency: number; startBy: number }, +): Promise { + const destinations = new Map>(); + for (const address of known) { + try { + const { id, url, name } = communityDestination(address); + if (!destinations.has(id)) destinations.set(id, { url, id, host: name }); + } catch { + // Not a destination this build can open: nothing to restore. + } + } + const pending = [...destinations.values()]; + const deadline = Date.now() + startBy; + const outcomes: Restored[] = []; + // One iterator shared by every worker: each destination is taken once. + const queue = pending.entries(); + const restore = async (destination: (typeof pending)[number]) => { + let read: Awaited>; + try { + read = await inspectProfile(destination.id); + } catch (error) { + return { + ...destination, + kind: readErrorKind(error) === "denied" ? "denied" : "unavailable", + } as const; + } + // A membership saved before the device record hydrates would be lost to it. + const client = communities.snapshot(); + if (client.status !== "ready") + return { ...destination, kind: "unavailable" } as const; + try { + communities.joined( + { id: destination.id, name: destination.host }, + read.exists ? read.profile : client.profile, + ); + } catch { + return { ...destination, kind: "unavailable" } as const; + } + return { ...destination, kind: "admitted", read } as const; + }; + const worker = async () => { + for (const [index, destination] of queue) + outcomes[index] = + Date.now() >= deadline + ? { ...destination, kind: "unavailable" } + : await restore(destination); + }; + await Promise.all( + Array.from({ length: Math.min(concurrency, pending.length) }, worker), + ); + return outcomes; +} diff --git a/src/features/identity/setup.test.ts b/src/features/identity/setup.test.ts new file mode 100644 index 0000000000..f6c993eb2a --- /dev/null +++ b/src/features/identity/setup.test.ts @@ -0,0 +1,211 @@ +import { expect, it, vi } from "vitest"; +import type { FirstRunAccount } from "./builderlab-account"; +import { BackupError } from "./backup"; +import { + checkAccount, + checkBackup, + createSetup, + type SetupStep, +} from "./setup"; + +const ok = (label: string): SetupStep => ({ + label, + run: vi.fn(() => Promise.resolve()), +}); +/** Fails until `heal`, with a retryable error when asked. */ +function flaky(label: string, retryable: boolean) { + let broken = true; + const run = vi.fn(async () => { + if (broken) + throw Object.assign(new Error(`${label} failed`), { retryable }); + }); + const step: SetupStep = { label, run }; + return { step, run, heal: () => (broken = false) }; +} + +it("retries from the failed step and rests where the steps would have", async () => { + const setup = createSetup(); + const before = ok("Before"); + const failing = flaky("Linking", true); + const after = ok("After"); + await setup.start([before, failing.step, after], { + status: "paused", + at: "backup-offer", + }); + expect(setup.snapshot()).toEqual({ + status: "failed", + error: "Linking failed", + retryable: true, + skipsLink: false, + }); + + failing.heal(); + await setup.retry(); + expect(setup.snapshot()).toEqual({ status: "paused", at: "backup-offer" }); + expect(before.run).toHaveBeenCalledTimes(1); + expect(failing.run).toHaveBeenCalledTimes(2); + expect(after.run).toHaveBeenCalledTimes(1); +}); + +it("reports only an error that says so as retryable", async () => { + const setup = createSetup(); + await setup.start([flaky("Backing up", false).step]); + expect(setup.snapshot()).toEqual({ + status: "failed", + error: "Backing up failed", + retryable: false, + skipsLink: false, + }); + await setup.start([ + { label: "Odd", run: () => Promise.reject("text") }, + ]); + expect(setup.snapshot()).toMatchObject({ retryable: false }); +}); + +it.each([ + ["the unlink before it", 0, true], + ["the link itself", 1, true], + ["the backup after it", 2, false], +])( + "says Continue skips the link only while a linking step is left: %s failing", + async (_label, failing, skipsLink) => { + const run = (index: number) => () => + index === failing + ? Promise.reject(new Error("Failed")) + : Promise.resolve(); + const setup = createSetup(); + await setup.start([ + { label: "Unlinking", run: run(0) }, + { label: "Linking", run: run(1), links: true }, + { label: "Backing up", run: run(2) }, + ]); + expect(setup.snapshot()).toMatchObject({ status: "failed", skipsLink }); + }, +); + +it("ignores Try again unless failed, and a second click", async () => { + const setup = createSetup(); + await setup.retry(); + expect(setup.snapshot()).toEqual({ status: "idle" }); + + let release!: () => void; + const run = vi + .fn<() => Promise>() + .mockRejectedValueOnce( + Object.assign(new Error("Backing up failed"), { retryable: true }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const after = ok("After"); + await setup.start([{ label: "Backing up", run }, after]); + const first = setup.retry(); + expect(setup.snapshot()).toEqual({ status: "running", label: "Backing up" }); + await setup.retry(); + release(); + await first; + expect(run).toHaveBeenCalledTimes(2); + expect(after.run).toHaveBeenCalledTimes(1); + expect(setup.snapshot()).toEqual({ status: "done" }); +}); + +it("reads the linked key and the saved destinations together, and a failed list never fails setup", async () => { + const pubkey = "ab".repeat(32); + const check = async ( + linkedKey: FirstRunAccount["linkedKey"], + knownCommunities: FirstRunAccount["knownCommunities"], + ) => { + const onLinked = vi.fn(); + const onKnown = vi.fn(); + const setup = createSetup(); + await setup.start([ + checkAccount( + { linkedKey, knownCommunities } as FirstRunAccount, + onLinked, + onKnown, + ), + ]); + return { + rest: setup.snapshot(), + linked: onLinked.mock.calls, + known: onKnown.mock.calls, + }; + }; + // Both reads start before either answers. + let answerKey!: (pubkey: string | null) => void; + let answerList!: (known: readonly string[]) => void; + const linkedKey = vi.fn( + () => new Promise((resolve) => (answerKey = resolve)), + ); + const knownCommunities = vi.fn( + () => new Promise((resolve) => (answerList = resolve)), + ); + const pending = check(linkedKey, knownCommunities); + expect(linkedKey).toHaveBeenCalledTimes(1); + expect(knownCommunities).toHaveBeenCalledTimes(1); + answerList(["wss://a.example"]); + answerKey(pubkey); + expect(await pending).toEqual({ + rest: { status: "paused", at: "welcome-back" }, + linked: [[pubkey]], + known: [[["wss://a.example"]]], + }); + expect( + await check( + () => Promise.resolve(null), + () => Promise.reject(new Error("Builderlab known communities failed")), + ), + ).toEqual({ rest: { status: "done" }, linked: [], known: [[undefined]] }); + // The key read still decides setup. + expect( + await check( + () => Promise.reject(new Error("Builderlab account check failed")), + () => Promise.resolve([]), + ), + ).toMatchObject({ + rest: { status: "failed", error: "Builderlab account check failed" }, + linked: [], + known: [], + }); +}); + +it("reports the backed-up keys, and an unavailable or failed status as none listed without failing setup", async () => { + const listed = "ab".repeat(32); + const lookup = async (status: FirstRunAccount["backupStatus"]) => { + const onListed = vi.fn(); + const setup = createSetup(); + await setup.start([ + checkBackup({ backupStatus: status } as FirstRunAccount, onListed), + ]); + expect(setup.snapshot()).toEqual({ status: "done" }); + return onListed.mock.calls; + }; + expect(await lookup(() => Promise.resolve([listed]))).toEqual([[[listed]]]); + expect(await lookup(() => Promise.resolve([]))).toEqual([[[]]]); + expect(await lookup(() => Promise.resolve("unavailable"))).toEqual([ + [undefined], + ]); + expect( + await lookup(() => Promise.reject(new BackupError("unreachable"))), + ).toEqual([[undefined]]); +}); + +it("a step that rests idle ends setup past the caller's rest, also when a retry reaches it", async () => { + const setup = createSetup(); + const failing = flaky("Linking", true); + const enter: SetupStep = { + label: "Restoring", + run: async () => ({ status: "idle" }) as const, + }; + await setup.start([failing.step, enter], { status: "done" }); + expect(setup.snapshot()).toMatchObject({ status: "failed" }); + failing.heal(); + await setup.retry(); + expect(setup.snapshot()).toEqual({ status: "idle" }); + // Idle, unlike done, lets setup start again. + await setup.start([], { status: "paused", at: "key-choice" }); + expect(setup.snapshot()).toEqual({ status: "paused", at: "key-choice" }); +}); diff --git a/src/features/identity/setup.ts b/src/features/identity/setup.ts new file mode 100644 index 0000000000..0e12e3a580 --- /dev/null +++ b/src/features/identity/setup.ts @@ -0,0 +1,300 @@ +import type { Communities } from "../communities/service"; +import type { FirstRunAccount, OwnedCommunity } from "./builderlab-account"; +import { type Restored, restoreKnown } from "./restore-communities"; +import type { Identity } from "./service"; + +/** A step may pick where setup rests afterwards, overriding the caller's `then`. */ +export type SetupStep = Readonly<{ + label: string; + run: () => Promise | Promise; + /** Links the key on this device to the account: skipping it leaves the key unlinked. */ + links?: boolean; +}>; +/** + * Where setup rests for a decision: the connect choice for a found + * community that is not Builderlab-hosted, the key choice, an import, the backup offer after an import, + * or the returning-user pages (Welcome back, a restore from backup, the + * start-over page for a lost key). + */ +export type SetupPause = + | "connect-choice" + | "key-choice" + | "key-import" + | "welcome-back" + | "backup-offer" + | "key-restore" + | "key-lost"; +export type SetupSnapshot = Readonly< + | { status: "idle" } + | { status: "running"; label: string } + | { status: "paused"; at: SetupPause } + | { + status: "failed"; + error: string; + /** The error says Try again can help, as a retryable `BackupError` does. */ + retryable: boolean; + /** Continuing past the failure skips the link, so the key stays unlinked. */ + skipsLink: boolean; + } + | { status: "done" } +>; +/** Idle ends setup outright: with a key on the device, the gate lifts. */ +export type Resting = Extract< + SetupSnapshot, + { status: "idle" | "paused" | "done" } +>; + +/** The key now on this device, which the account steps act on. */ +function viewer(identity: Identity) { + const state = identity.snapshot(); + if (state.status !== "ready") + throw new Error("Your key could not be saved on this device."); + return state.viewer; +} + +/** `identity.create` and `importKey` report failure through the snapshot, not by throwing. */ +function saved(identity: Identity) { + const state = identity.snapshot(); + if (state.status === "ready") return; + throw new Error( + (state.status === "missing" && state.error) || + "Your key could not be saved on this device.", + ); +} + +/** Creates a key on this device, which the account steps then act on. */ +export function createKey(identity: Identity): SetupStep { + return { + label: "Creating keys…", + run: async () => { + await identity.create(); + saved(identity); + }, + }; +} + +/** + * Stores a key the user already has through the key setup screen's native + * import command, which takes the nsec as typed. Not a step: a refusal belongs + * under the import page's field, not on the failed page. + */ +export async function importKey(identity: Identity, nsec: string) { + await identity.importKey(nsec); + saved(identity); +} + +/** + * Real: the key bound to the signed-in account picks the lane. Binding is 1:1 + * and enforced, so a linked account must never be given a second key: it rests + * on Welcome back instead of the key choice, and later steps still run. The + * destinations saved to the account are read alongside, under the same label; + * that list never fails setup and is reported as `undefined` when it did. + */ +export function checkAccount( + account: FirstRunAccount, + onLinked: (pubkey: string) => void, + onKnown: (known: readonly string[] | undefined) => void, +): SetupStep { + return { + label: "Checking your account…", + run: async () => { + const [pubkey, known] = await Promise.all([ + account.linkedKey(), + account.knownCommunities().catch(() => undefined), + ]); + onKnown(known); + if (!pubkey) return undefined; + onLinked(pubkey); + return { status: "paused", at: "welcome-back" }; + }, + }; +} + +/** + * Real: binds the key now on this device to the signed-in account, so the + * account is this key's and a backup can be accepted for it. The account sends + * nothing more when it already holds the key, so a re-run is harmless. + */ +export function linkKey( + identity: Identity, + account: FirstRunAccount, +): SetupStep { + return { + label: "Linking your key…", + run: () => account.bind(viewer(identity)), + links: true, + }; +} + +/** + * Real: unbinds `old`, the key the account held before starting over, so the + * new key created on this device can be linked in its place: binding is 1:1 + * and never overwrites. Runs once that key exists, just before `linkKey`, as + * the hosted card's switch does. The account deletes only while it still holds + * `old`, so a re-run never removes the new key's binding, and the step fails + * unless the account is then left with no key or the new one. + */ +export function unlinkKey( + identity: Identity, + account: FirstRunAccount, + old: string, +): SetupStep { + return { + label: "Unlinking your old key…", + run: () => account.unbind(old, viewer(identity)), + }; +} + +/** + * Real: backs the key now on this device up to the signed-in account and waits + * for the native uploader's own result (`backUpWith`), which kgoose accepts + * only for the key bound to the account: the linked lane's own, or one + * `linkKey` bound first. + */ +export function backUp( + identity: Identity, + account: FirstRunAccount, +): SetupStep { + return { + label: "Backing up your key…", + run: () => account.backUp(viewer(identity)), + }; +} + +/** + * Real: the communities the linked account owns, for Join a community's rows + * and the start-over page. Runs only once a key is on the device and the + * account is linked (the list is meaningless otherwise). A failed list is + * reported as `undefined`, not a failed setup: the pages work without it. + */ +export function findCommunities( + account: FirstRunAccount, + onFound: (owned: readonly OwnedCommunity[] | undefined) => void, +): SetupStep { + return { + label: "Finding communities…", + run: () => + account.ownedCommunities().then(onFound, () => onFound(undefined)), + }; +} + +/** + * Real: joins the destinations saved to the account that admit the key now on + * this device (`restoreKnown`), then lets `decide` pick where setup rests from + * the outcomes. Never fails setup: a destination that was refused or could not + * be read is an outcome, not an error. + */ +export function restoreCommunities( + known: readonly string[], + communities: Pick, + decide: (outcomes: readonly Restored[]) => Resting | undefined, +): SetupStep { + return { + label: "Restoring your communities…", + run: async () => + decide( + await restoreKnown(known, communities, { + concurrency: 3, + startBy: 45_000, + }), + ), + }; +} + +/** + * Real: the keys the signed-in account holds a backup of, which decide whether + * Welcome back offers a restore and an import still needs the backup offer. + * Reported as `undefined` where backup status is unavailable (most Builderlab + * deployments today) or failed, never as a failed setup: the pages work without it, and + * no copy implies the user lost a backup. + */ +export function checkBackup( + account: FirstRunAccount, + onListed: (pubkeys: readonly string[] | undefined) => void, +): SetupStep { + return { + label: "Checking for a backed-up key…", + run: () => + account.backupStatus().then( + (listed) => onListed(listed === "unavailable" ? undefined : listed), + () => onListed(undefined), + ), + }; +} + +/** + * Runs steps in order, stopping at the first failure, then rests at `then` — + * done, or a pause the caller resumes with another `start` — unless a step + * picked another resting state. No steps means "nothing more to do" and + * rests at once. A failed setup may also be resumed: by `retry` from the + * failed step, or once a key exists, by Continue from the failed page, which + * skips the steps left and rests on the Join page rather than the landing. + */ +export function createSetup() { + let state: SetupSnapshot = { status: "idle" }; + // From the failed step on, with where setup would have rested. Taken once, + // so a second click on Try again does nothing. + let failed: + | Readonly<{ steps: readonly SetupStep[]; rest: Resting }> + | undefined; + const listeners = new Set<() => void>(); + const publish = (next: SetupSnapshot) => { + state = next; + for (const listener of listeners) listener(); + }; + async function run(steps: readonly SetupStep[], then: Resting) { + let rest = then; + for (const [index, step] of steps.entries()) { + publish({ status: "running", label: step.label }); + try { + const picked = await step.run(); + if (picked) rest = picked; + } catch (error) { + const left = steps.slice(index); + failed = { steps: left, rest }; + publish({ + status: "failed", + error: + error instanceof Error ? error.message : "Setup failed. Try again.", + retryable: + error instanceof Error && + "retryable" in error && + error.retryable === true, + skipsLink: left.some((step) => step.links === true), + }); + return; + } + } + publish(rest); + } + return { + snapshot: () => state, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + async start( + steps: readonly SetupStep[], + then: Resting = { status: "done" }, + ) { + if (state.status === "running" || state.status === "done") return; + failed = undefined; + await run(steps, then); + }, + /** Runs the failed step again, then the rest, resting where it would have. */ + async retry() { + const from = state.status === "failed" ? failed : undefined; + if (!from) return; + failed = undefined; + await run(from.steps, from.rest); + }, + reset() { + if (state.status === "running") return; + failed = undefined; + publish({ status: "idle" }); + }, + }; +} +export type Setup = ReturnType; diff --git a/vite.config.ts b/vite.config.ts index df74923da1..d3d3ea5da7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,7 @@ import { parseCommunityAliases, relayOrigin, } from "./src/features/communities/destination.ts"; +import { parseFirstRunAccountSeed } from "./src/features/identity/first-run-seed.ts"; export default defineConfig(async ({ command, mode }) => { const env = loadEnv(mode, ".", "BUZZ_"); @@ -48,6 +49,13 @@ export default defineConfig(async ({ command, mode }) => { "BUZZ_DATABRICKS_HOST must be an https:// URL without credentials.", ); } + // Opt-in seed: answers the first-run Builderlab account reads (linked key, + // owned communities) without kgoose, so the returning-user screens can be + // walked against a real account whose binding must not change. Builds never + // see it. + const firstRunAccount = + command === "serve" ? (env.BUZZ_DEV_FIRST_RUN_ACCOUNT ?? "") : ""; + parseFirstRunAccountSeed(firstRunAccount); const plugins: PluginOption[] = [react()]; if (command === "serve") plugins.push( @@ -93,6 +101,8 @@ export default defineConfig(async ({ command, mode }) => { "import.meta.env.VITE_BUZZ_OPEN_RELAY": JSON.stringify(openOrigin), "import.meta.env.VITE_BUZZ_DATABRICKS_HOST": JSON.stringify(databricksHost), + "import.meta.env.VITE_BUZZ_FIRST_RUN_ACCOUNT": + JSON.stringify(firstRunAccount), }, build: { rollupOptions: {