Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,16 @@ 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.

**Settings → Identity** shows the uploader's latest status as a read-only **Key
backup** row, by the same rule: the row appears only when this build's uploader
can back up to Builderlab and a Builderlab sign-in would be at the build's own
portal, as the Settings one is. It uses first run's failure wording, except
that a failure first run's Try again could clear, and a lapsed sign-in, say
instead that Buzz tries again the next time the user signs in to Builderlab,
since a new sign-in hands the uploader a session and starts another pass. Idle is what a fresh launch or
a window reload reports, not evidence that no backup exists. The row has no

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [P3] Qualify when a window reload reports idle

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

consent or retry control.

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
Expand Down
134 changes: 132 additions & 2 deletions src/app/IdentitySettings.test.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,72 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { act, cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { npubEncode } from "nostr-tools/nip19";
import { ToastProvider } from "../shared/design-system/ui/Toast";
import {
type BackupStatus,
createBackupStatus,
} from "../features/identity/backup";
import { BACKUP_FAILURES } from "../features/identity/backup-failure";
import { backupOfferedFor } from "../features/identity/builderlab-account";
import type { Identity } from "../features/identity/service";
import { IdentitySettings } from "./IdentitySettings";

vi.mock("@tauri-apps/api/core", async (importOriginal) => ({
...(await importOriginal<typeof import("@tauri-apps/api/core")>()),
Channel: class {
constructor(public onmessage: (status: unknown) => void) {}
},
}));

const viewer = "ab".repeat(32);
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllEnvs();
});

type Send = NonNullable<Parameters<typeof createBackupStatus>[0]>;
type Progress<S = BackupStatus> = S extends unknown
? Omit<S, "seq" | "pass" | "builderlab">
: never;
const at = (seq: number, progress: Progress, builderlab = true) =>
({ seq, pass: seq, builderlab, ...progress }) as BackupStatus;

/** The real status store over a native side that answers `initial` and records its watcher. */
function backupHost(initial: BackupStatus) {
const watchers: { onmessage: (status: BackupStatus) => void }[] = [];
const send = vi.fn(
async (
command: string,
args?: Record<string, unknown>,
): Promise<unknown> => {
if (command === "nsec_backup_watch") {
watchers.push(args?.onEvent as (typeof watchers)[number]);
return undefined;
}
if (command === "nsec_backup_state") return initial;
throw new Error(`Unexpected command: ${command}`);
},
);
const store = createBackupStatus(send as Send);
return {
identity: { exportKey: vi.fn(), backup: store } as unknown as Identity,
push: (status: BackupStatus) =>
act(() => watchers.at(-1)?.onmessage(status)),
/** Settles once the row's own offer check has: it started first on the same store. */
settled: () => act(() => backupOfferedFor(store)),
};
}

const renderBackup = (initial: BackupStatus) => {
const host = backupHost(initial);
render(<IdentitySettings viewer={viewer} identity={host.identity} />);
return host;
};
const backupRow = () => screen.queryByText("Key backup");
it("shows exact public identity formats and copies either value", async () => {
const writeText = vi.fn(async () => {});
const user = userEvent.setup();
Expand Down Expand Up @@ -91,7 +146,10 @@ it("keeps the identity selectable and explains manual recovery when copy fails",

it("clears a revealed key when leaving identity settings and starts masked on return", async () => {
const exportKey = vi.fn().mockResolvedValue("nsec-public-test-fixture");
const identity = { exportKey } as unknown as Identity;
const identity = {
exportKey,
backup: backupHost(at(1, { status: "idle" })).identity.backup,
} as unknown as Identity;
const user = userEvent.setup();
const view = render(<IdentitySettings viewer={viewer} identity={identity} />);
expect(exportKey).not.toHaveBeenCalled();
Expand All @@ -107,3 +165,75 @@ it("clears a revealed key when leaving identity settings and starts masked on re
);
expect(exportKey).toHaveBeenCalledTimes(1);
});

it.each([
[{ status: "complete" }, "Your key is backed up to Builderlab."],
[{ status: "running" }, "Backing up your key…"],
[
{ status: "waiting", reason: "no_session" },
"Sign in to Builderlab to back up your key.",
],
[
{ status: "waiting", reason: "not_linked" },
BACKUP_FAILURES.not_linked.message,
],
// First run offers no Try again for it, but a new sign-in clears it.
[
{ status: "failed", kind: "signed_out" },
"Your Builderlab sign-in is no longer valid, so your key wasn’t backed up. Buzz tries again when you next sign in to Builderlab.",
],
// Settings has no Try again, so a retryable failure names what will retry.
...(["busy", "unreachable", "unconfirmed", "storage"] as const).map(
(kind) =>
[
{ status: "failed", kind },
`${BACKUP_FAILURES[kind].cause} Buzz tries again when you next sign in to Builderlab.`,
] as const,
),
] as const)(
"reads the uploader's %o status as one sentence",
async (progress, sentence) => {
vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example");
await renderBackup(at(1, progress)).settled();
expect(backupRow()).toBeVisible();
expect(screen.getByRole("status").textContent).toBe(sentence);
},
);

it("hides the backup row wherever first run would not offer backup", async () => {
vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example");
// This build's uploader has no Builderlab source.
await renderBackup(at(1, { status: "complete" }, false)).settled();
expect(backupRow()).toBeNull();
cleanup();
// A build with no portal of its own never hands a credential over.
vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "");
await renderBackup(at(1, { status: "complete" })).settled();
expect(backupRow()).toBeNull();
// The public identity is unaffected.
expect(screen.getByText(viewer, { exact: true })).toBeVisible();
});

it("follows the status store as it publishes a higher seq", async () => {
vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example");
const host = renderBackup(at(1, { status: "idle" }));
await host.settled();
const status = screen.getByRole("status");
expect(status).toHaveTextContent(
"Buzz hasn’t checked your key’s backup since it opened.",
);
host.push(at(2, { status: "running" }));
expect(status).toHaveTextContent("Backing up your key…");
host.push(at(3, { status: "failed", kind: "busy" }));
const busy = /^Another backup of this key is in progress\. Buzz tries/;
expect(status).toHaveTextContent(busy);
// A late, lower seq is not newer news.
host.push(at(2, { status: "complete" }));
expect(status).toHaveTextContent(busy);
host.push(at(4, { status: "complete" }));
expect(status).toHaveTextContent("Your key is backed up to Builderlab.");
// Read-only: no consent, retry or other control beside the status.
const row = status.closest(".buzz-preference-row") as HTMLElement;
expect(within(row).queryByRole("button")).toBeNull();
expect(within(row).queryByRole("switch")).toBeNull();
});
56 changes: 55 additions & 1 deletion src/app/IdentitySettings.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { useRef, useState } from "react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { npubEncode } from "nostr-tools/nip19";
import type { BackupStatus } from "../features/identity/backup";
import { BACKUP_FAILURES } from "../features/identity/backup-failure";
import { backupOfferedFor } from "../features/identity/builderlab-account";
import type { Identity } from "../features/identity/service";
import { PrivateKey } from "../features/identity/PrivateKey";
import { SignOutDialog } from "../features/identity/SignOut";
Expand All @@ -13,6 +16,56 @@ import { ToastNotice } from "../shared/design-system/ui/Toast";
import { CopyIcon } from "../shared/design-system/icons";
import styles from "./IdentitySettings.module.css";

const WAITING = {
no_consent: "Automatic backup is off for this key.",
no_session: "Sign in to Builderlab to back up your key.",
not_linked: BACKUP_FAILURES.not_linked.message,
} as const;

/** One sentence per uploader status; failures read as first run's copy. */
function backupSentence(status: BackupStatus) {
switch (status.status) {
case "idle":
// Nothing has run this launch; a backup may still exist from an earlier one.
return "Buzz hasn’t checked your key’s backup since it opened.";
case "running":
return "Backing up your key…";
case "complete":
return "Your key is backed up to Builderlab.";
case "waiting":
return WAITING[status.reason];
case "failed": {
// Settings has no Try again; a new Builderlab sign-in hands the
// uploader a session, which starts another pass, so a kind it clears
// (every retryable one, and a lapsed sign-in) says so.
const failure: { cause?: string; message: string } =
BACKUP_FAILURES[status.kind];
return failure.cause
? `${failure.cause} Buzz tries again when you next sign in to Builderlab.`
: failure.message;
}
}
}

/**
* Read-only until the consent wording is signed off. Hidden wherever first run
* would not offer backup, by the same rule.
*/
function BackupRow({ backup }: { backup: Identity["backup"] }) {
const status = useSyncExternalStore(backup.subscribe, backup.snapshot);
const [offered, setOffered] = useState(false);
useEffect(() => {
void backupOfferedFor(backup).then(setOffered);
}, [backup]);
if (!offered || !status) return null;
return (
<PreferenceRow
title="Key backup"
subtitle={<span role="status">{backupSentence(status)}</span>}
/>
);
}

export function IdentitySettings({
viewer,
identity,
Expand Down Expand Up @@ -93,6 +146,7 @@ export function IdentitySettings({
{!signingOut && <PrivateKey identity={identity} />}
</SettingsGroup>
<SettingsGroup>
<BackupRow backup={identity.backup} />
<PreferenceRow
title="Sign out of Buzz"
subtitle="Remove your private key from this device. Also ends Builderlab and hosted community logins."
Expand Down
10 changes: 9 additions & 1 deletion src/app/Settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1994,7 +1994,15 @@ it("keeps identity independent of community profile and clears revealed keys on
{...host}
cards={cards}
identity={
{ exportKey } as unknown as Parameters<typeof Settings>[0]["identity"]
{
exportKey,
// No backup status: the row stays hidden.
backup: {
subscribe: () => () => {},
snapshot: () => undefined,
ready: () => new Promise(() => {}),
},
} as unknown as Parameters<typeof Settings>[0]["identity"]
}
/>
</ToastProvider>,
Expand Down
30 changes: 17 additions & 13 deletions src/features/identity/backup-failure.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
/** A failure Try again may clear, and what first run's failed page says to do. */
const retry = (cause: string, then = "Try again.") =>
({ message: `${cause} ${then}`, retryable: true }) as const;
/**
* A failure Try again may clear: its `cause`, which Settings shows alone (it
* has no Try again), then what first run's failed page says to do.
*/
const retry = <Cause extends string>(cause: Cause, then = "Try again.") =>
({ cause, message: `${cause} ${then}`, retryable: true }) as const;

const SIGNED_OUT =
"Your Builderlab sign-in is no longer valid, so your key wasn’t backed up.";

/**
* Why "Backing up your key…" did not complete: the copy the failed page shows
* 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.
* `unknown` are the renderer's. A `cause` is what Settings shows before saying
* a later Builderlab sign-in tries again: every retryable kind's, and
* `signed_out`'s, which a new sign-in clears though first run's Try again,
* sending the same credential, cannot. 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: {
Expand All @@ -21,12 +30,7 @@ export const BACKUP_FAILURES = {
),
interrupted: retry("The backup was interrupted."),
timeout: retry("Builderlab is taking too long to confirm your backup."),
// A new sign-in clears this, though Try again, sending the same credential, cannot.
signed_out: {
message:
"Your Builderlab sign-in is no longer valid, so your key wasn’t backed up.",
retryable: false,
},
signed_out: { cause: SIGNED_OUT, message: SIGNED_OUT, retryable: false },
not_offered: {
message: "Builderlab doesn’t offer key backup for this account yet.",
retryable: false,
Expand Down Expand Up @@ -57,7 +61,7 @@ export const BACKUP_FAILURES = {
unknown: { message: "Your key wasn’t backed up.", retryable: false },
} as const satisfies Record<
string,
Readonly<{ message: string; retryable: boolean }>
Readonly<{ cause?: string; message: string; retryable: boolean }>
>;

export type BackupFailureKind = keyof typeof BACKUP_FAILURES;
Expand Down
Loading