diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts
index 2487dbbf9c..43a617ffd4 100644
--- a/desktop/tests/e2e/messaging.spec.ts
+++ b/desktop/tests/e2e/messaging.spec.ts
@@ -734,7 +734,9 @@ test("opens a single-level thread panel with inline expansion", async ({
return body.scrollHeight - body.clientHeight;
});
})
- .toBeGreaterThanOrEqual(160);
+ // Compact continuation rows intentionally reduce the available overflow;
+ // this test only needs enough space to prove the thread body scrolls.
+ .toBeGreaterThan(0);
await expect(
timeline.getByTestId("message-row").filter({ hasText: firstReply }),
From bf139e8d0bdba10df9a5adbf16843140e0a78a59 Mon Sep 17 00:00:00 2001
From: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Date: Thu, 30 Jul 2026 15:26:11 -0400
Subject: [PATCH 16/87] perf(presence): reduce heartbeat frequency (#3783)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
- send desktop presence heartbeats every 60 seconds instead of every 30
seconds
- extend presence TTL from 90 to 180 seconds to preserve the existing
three-heartbeat expiry window
- add mutation-sensitive tests that pin the one-minute / three-window
timing contract
- update presence documentation to match
This halves steady-state **desktop** presence `SET` + `PUBLISH` traffic
while retaining tolerance for two missed heartbeats. Mobile already uses
a 60-second heartbeat, so the fleet-wide reduction depends on desktop's
share of connected clients.
## Rollout order
Deploy the relay TTL increase before shipping the desktop heartbeat
change. Old desktop + new relay is safe; new desktop + old relay leaves
only a 90-second TTL on a 60-second cadence and can flap after one
missed heartbeat.
## Verification
At initial live-test commit `00816e233b187bc5ba12c667d675ed050a8cc1c9`:
- isolated clean-room relay built from the exact SHA against fresh
Postgres, Redis, and MinIO
- live Redis `MONITOR` observed kind-20001 writes as `SET ... EX 180`,
global `PUBLISH`, and clean-disconnect / explicit-offline `DEL`
- normal workflows passed: channel create/update/archive/unarchive;
message send/get/reply/thread/search; archived-channel write rejection
and resumed write after unarchive
At follow-up commit `bf38a8c5c96f196ff8ee46e48d4141ee7811f186`:
- `pnpm -C desktop test` — 3829 passed
- `pnpm -C desktop typecheck`
- `cargo test -p buzz-pubsub` — 24 passed, 11 Redis-dependent tests
ignored
- mutation probes fail when the server TTL changes to `999999` or the
desktop heartbeat changes back to 30 seconds
- `git diff --check`
The pre-push suite's relevant checks passed, but its unrelated Tauri
clippy step fails on current `origin/main`:
`desktop/src-tauri/src/linux_media.rs` has three dead-code warnings on
macOS. This PR does not modify that file, so the branch was pushed after
independently running the suites above.
## Buzz context
Originating channel: `buzz-redis-cluster-mode`
(`f4e36d32-afdb-447f-8c87-ab003e069d18`)
---------
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
---
ARCHITECTURE.md | 4 ++--
crates/buzz-pubsub/src/lib.rs | 2 +-
crates/buzz-pubsub/src/presence.rs | 14 ++++++++++----
desktop/src/features/presence/hooks.ts | 4 ++--
.../src/features/presence/lib/presence.test.mjs | 11 +++++++++++
desktop/src/features/presence/lib/presence.ts | 6 ++++++
6 files changed, 32 insertions(+), 9 deletions(-)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 90cbbac0cf..5c8e263a2a 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -447,7 +447,7 @@ The subscriber uses a **dedicated** `redis::aio::PubSub` connection — not from
**Reconnection:** exponential backoff 1s → 30s (`backoff_secs * 2`). Backoff resets to 1s only after a clean stream end, not on each reconnect attempt.
-**Presence:** `SET buzz:presence:{pubkey_hex} {status} EX 90` — 90-second TTL (3× the 30-second heartbeat interval). Single missed heartbeat does not cause presence flap.
+**Presence:** `SET buzz:presence:{pubkey_hex} {status} EX 180` — 180-second TTL (3× the 60-second heartbeat interval). Single missed heartbeat does not cause presence flap.
**Typing indicators:**
```
@@ -797,7 +797,7 @@ Docker Compose provides the full local development stack. All services include h
| Pattern | Type | TTL | Purpose |
|---------|------|-----|---------|
| `buzz:channel:{uuid}` | Pub/Sub channel | — | Event fan-out (single-community form; shared multi-community Redis must use `buzz:{community}:channel:{uuid}` or equivalent) |
-| `buzz:presence:{pubkey_hex}` | String | 90s | Online/away status (single-community form; shared multi-community Redis must scope by community) |
+| `buzz:presence:{pubkey_hex}` | String | 180s | Online/away status (single-community form; shared multi-community Redis must scope by community) |
| `buzz:typing:{channel_uuid}` | Sorted Set | 60s | Active typers (5s window; shared multi-community Redis must scope by community) |
### Full-Text Search (Postgres FTS)
diff --git a/crates/buzz-pubsub/src/lib.rs b/crates/buzz-pubsub/src/lib.rs
index eae8c5ef9e..4f1690beef 100644
--- a/crates/buzz-pubsub/src/lib.rs
+++ b/crates/buzz-pubsub/src/lib.rs
@@ -328,7 +328,7 @@ impl PubSubManager {
publisher::publish_event(&self.pool, ctx, topic, event).await
}
- /// Set presence with 60s TTL. Call on connect and every 30s heartbeat.
+ /// Set presence with 180s TTL. Call on connect and every 60s heartbeat.
pub async fn set_presence(
&self,
ctx: &TenantContext,
diff --git a/crates/buzz-pubsub/src/presence.rs b/crates/buzz-pubsub/src/presence.rs
index 178ba7550a..e0c9dfd6c9 100644
--- a/crates/buzz-pubsub/src/presence.rs
+++ b/crates/buzz-pubsub/src/presence.rs
@@ -1,7 +1,7 @@
//! Presence tracking — online/away status with TTL.
//!
-//! Stored as `SET buzz:{community}:presence:{pubkey_hex} "online" EX 90`.
-//! TTL is 3x the 30s heartbeat interval so a single missed heartbeat doesn't
+//! Stored as `SET buzz:{community}:presence:{pubkey_hex} "online" EX 180`.
+//! TTL is 3x the 60s heartbeat interval so a single missed heartbeat doesn't
//! cause presence flap. Clean disconnect deletes immediately.
use buzz_core::TenantContext;
@@ -12,8 +12,8 @@ use std::collections::HashMap;
use crate::error::PubSubError;
use crate::topic::BUZZ_PREFIX;
-/// 3x the 30s heartbeat — single missed heartbeat won't cause presence flap.
-pub const PRESENCE_TTL_SECS: u64 = 90;
+/// 3x the 60s heartbeat — single missed heartbeat won't cause presence flap.
+pub const PRESENCE_TTL_SECS: u64 = 180;
/// Returns the Redis key for the presence entry of `pubkey` under `ctx`.
pub fn presence_key(ctx: &TenantContext, pubkey: &PublicKey) -> String {
@@ -109,6 +109,12 @@ mod tests {
TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(id)), host)
}
+ #[test]
+ fn presence_ttl_is_three_one_minute_heartbeat_windows() {
+ assert_eq!(PRESENCE_TTL_SECS, 180);
+ assert_eq!(PRESENCE_TTL_SECS, 3 * 60);
+ }
+
#[test]
fn test_presence_key_format() {
let pubkey = make_pubkey();
diff --git a/desktop/src/features/presence/hooks.ts b/desktop/src/features/presence/hooks.ts
index 5ba8683693..04936241df 100644
--- a/desktop/src/features/presence/hooks.ts
+++ b/desktop/src/features/presence/hooks.ts
@@ -11,14 +11,14 @@ import {
mergePresenceUpdate,
parseLivePresenceEvent,
presenceQueryWantsPubkey,
+ PRESENCE_HEARTBEAT_INTERVAL_MS,
+ PRESENCE_TTL_SECONDS,
resolveAutomaticPresenceStatus,
} from "@/features/presence/lib/presence";
import type { PresenceLookup, PresenceStatus } from "@/shared/api/types";
-const PRESENCE_HEARTBEAT_INTERVAL_MS = 30_000;
const PRESENCE_STATUS_TICK_INTERVAL_MS = 30_000;
const PRESENCE_ACTIVITY_THROTTLE_MS = 1_000;
-const PRESENCE_TTL_SECONDS = 90;
const PRESENCE_PREFERENCE_STORAGE_KEY = "buzz-presence-preference";
type PresencePreference = "auto" | "away" | "offline" | null;
diff --git a/desktop/src/features/presence/lib/presence.test.mjs b/desktop/src/features/presence/lib/presence.test.mjs
index c202dcb776..90a6ac524b 100644
--- a/desktop/src/features/presence/lib/presence.test.mjs
+++ b/desktop/src/features/presence/lib/presence.test.mjs
@@ -5,7 +5,9 @@ import {
mergePresenceUpdate,
parseLivePresenceEvent,
presenceQueryWantsPubkey,
+ PRESENCE_HEARTBEAT_INTERVAL_MS,
PRESENCE_IDLE_TIMEOUT_MS,
+ PRESENCE_TTL_SECONDS,
resolveAutomaticPresenceStatus,
} from "./presence.ts";
@@ -13,6 +15,15 @@ const WILL = "8e39cba681211b3782d0e4483e9343719b9b7be66515252da5491f26421896b1";
const OTHER =
"44b8e82baaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+test("presence heartbeat is one minute with a three-window TTL", () => {
+ assert.equal(PRESENCE_HEARTBEAT_INTERVAL_MS, 60_000);
+ assert.equal(PRESENCE_TTL_SECONDS, 180);
+ assert.equal(
+ PRESENCE_TTL_SECONDS,
+ 3 * (PRESENCE_HEARTBEAT_INTERVAL_MS / 1000),
+ );
+});
+
test("merge adds an absent pubkey going online (the core bug)", () => {
const old = {};
const next = mergePresenceUpdate(old, WILL, "online");
diff --git a/desktop/src/features/presence/lib/presence.ts b/desktop/src/features/presence/lib/presence.ts
index 34ac4fc353..5b1fdc21c5 100644
--- a/desktop/src/features/presence/lib/presence.ts
+++ b/desktop/src/features/presence/lib/presence.ts
@@ -36,6 +36,12 @@ export function mergePresenceUpdate(
return { ...old, [pubkey]: status };
}
+// Keep the local optimistic cache and relay expiry at three heartbeat windows.
+// The relay owns the authoritative TTL; deploy its TTL increase before shipping
+// a desktop build with a slower heartbeat.
+export const PRESENCE_HEARTBEAT_INTERVAL_MS = 60_000;
+export const PRESENCE_TTL_SECONDS = 3 * (PRESENCE_HEARTBEAT_INTERVAL_MS / 1000);
+
// Away means "human not at the machine" (Slack/Discord semantics), never
// "Buzz is not the focused window". OS-wide idle is authoritative when the
// platform exposes it; otherwise fall back to in-app activity.
From 4d47aa83455a9fd024121a596154cd311dca1d76 Mon Sep 17 00:00:00 2001
From: Taylor Ho
Date: Thu, 30 Jul 2026 12:34:35 -0700
Subject: [PATCH 17/87] feat(desktop): improve agent activity header ui (#3321)
**Category:** improvement
**User Impact:** Activity feeds now clearly identify the agent and keep
update recency visible even when channel names are long.
**Problem:** The activity header led with a generic label, making it
hard to tell which agent was in view, while channel scope and recency
competed for limited horizontal space. Long channel names could hide the
update timestamp entirely.
**Solution:** Lead with the resolved agent avatar and name, then place
mode and scope in a truncating metadata region with recency pinned at
the right edge. This preserves the compact two-line header while keeping
the most important identity and freshness signals legible.
File changes
**desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx**
Reorganizes the activity header around the agent identity, reuses the
existing resolved profile avatar and label helpers, and separates scope
truncation from the always-visible recency label.
**desktop/tests/e2e/activity-scope-label-screenshots.spec.ts**
Expands activity-header coverage across channel-scoped, all-channel,
raw, long-name, and narrow layouts, including measured truncation and
recency visibility.
## Reproduction steps
1. Open an agent's activity feed from a channel.
2. Confirm the agent avatar and name lead the header.
3. Open a feed scoped to a channel with a long name and resize the panel
narrowly.
4. Confirm the mode and channel scope truncate while the recency label
remains visible at the right edge.
5. Toggle Raw mode and open an all-channel feed to confirm the same
hierarchy and truncation behavior.
## Screenshots
| Long channel | Narrow layout |
|---|---|
| | |
| Raw mode | All channels |
|---|---|
| | |
---------
Signed-off-by: Taylor Ho
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
---
.../channels/ui/AgentSessionThreadPanel.tsx | 57 +++++++++++----
.../activity-scope-label-screenshots.spec.ts | 73 +++++++++++++++++--
2 files changed, 112 insertions(+), 18 deletions(-)
diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
index a907f87245..641b81490b 100644
--- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
+++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
@@ -35,10 +35,12 @@ import {
AuxiliaryPanelHeader,
AuxiliaryPanelHeaderActions,
AuxiliaryPanelHeaderGroup,
- AuxiliaryPanelHeaderTitleBlock,
} from "@/shared/layout/AuxiliaryPanel";
import { Button } from "@/shared/ui/button";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
+import { resolveUserLabel } from "@/features/profile/lib/identity";
+import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
+import { normalizePubkey } from "@/shared/lib/pubkey";
import {
DropdownMenu,
DropdownMenuContent,
@@ -237,6 +239,15 @@ export function AgentSessionThreadPanel({
? `#${scopeChannelName}`
: "1 channel"
: "All channels";
+ const agentProfile = profiles?.[normalizePubkey(agent.pubkey)] ?? null;
+ const agentLabel = resolveUserLabel({
+ pubkey: agent.pubkey,
+ fallbackName: agent.name,
+ profiles,
+ preferResolvedSelfLabel: true,
+ });
+ const viewLabel = showRawFeed ? "Raw ACP activity" : "Activity";
+ const headerScopeLabel = `${viewLabel} · ${scopeLabel}`;
const animateActivity = useTranscriptAnimationEnabled();
const showTimestamps = useTranscriptTimestampsEnabled();
async function handleInterruptTurn() {
@@ -417,19 +428,39 @@ export function AgentSessionThreadPanel({
backButtonTestId="agent-session-back"
onBack={onBack}
>
-
- {/* Scope label: makes channel-targeted vs all-channels state obvious
- (an all-channels pane can look "wrong" without it). */}
-
- {scopeLabel}
-
+
({
+ step: step.step,
+ command: step.command,
+ success: step.success,
+ stdout: step.stdout,
+ stderr: step.stderr,
+ exitCode: step.exit_code,
+ hint: step.hint,
+ })),
+ restartedCount: raw.restarted_count,
+ failedRestartCount: raw.failed_restart_count,
+ logPath: raw.log_path ?? null,
+ };
+}
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts
index c57525480e..69e2e455ec 100644
--- a/desktop/src/shared/api/tauri.ts
+++ b/desktop/src/shared/api/tauri.ts
@@ -3,6 +3,10 @@ import {
activateRateLimit,
parseRateLimitHint,
} from "@/shared/api/relayRateLimitGate";
+import {
+ fromRawInstallRuntimeResult,
+ type RawInstallRuntimeResult,
+} from "@/shared/api/installTypes";
import type {
AddChannelMembersInput,
AddChannelMembersResult,
@@ -202,22 +206,10 @@ export type RawAcpRuntimeCatalogEntry = {
definition_env?: Record;
};
-export type RawInstallStepResult = {
- step: string;
- command: string;
- success: boolean;
- stdout: string;
- stderr: string;
- exit_code: number | null;
- hint?: string;
-};
-
-export type RawInstallRuntimeResult = {
- success: boolean;
- steps: RawInstallStepResult[];
- restarted_count: number;
- failed_restart_count: number;
-};
+export type {
+ RawInstallRuntimeResult,
+ RawInstallStepResult,
+} from "./installTypes";
type RawGitBashPrerequisite = {
available: boolean;
@@ -772,25 +764,6 @@ export function fromRawAcpRuntimeCatalogEntry(
};
}
-function fromRawInstallRuntimeResult(
- raw: RawInstallRuntimeResult,
-): InstallRuntimeResult {
- return {
- success: raw.success,
- steps: raw.steps.map((step) => ({
- step: step.step,
- command: step.command,
- success: step.success,
- stdout: step.stdout,
- stderr: step.stderr,
- exitCode: step.exit_code,
- hint: step.hint,
- })),
- restartedCount: raw.restarted_count,
- failedRestartCount: raw.failed_restart_count,
- };
-}
-
function fromRawCommandAvailability(
command: RawCommandAvailability,
): CommandAvailability {
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts
index 689c400b03..877b5b1c61 100644
--- a/desktop/src/shared/api/types.ts
+++ b/desktop/src/shared/api/types.ts
@@ -571,22 +571,10 @@ export type AcpRuntime = AcpRuntimeCatalogEntry & {
binaryPath: string;
};
-export type InstallStepResult = {
- step: string;
- command: string;
- success: boolean;
- stdout: string;
- stderr: string;
- exitCode: number | null;
- hint?: string;
-};
-
-export type InstallRuntimeResult = {
- success: boolean;
- steps: InstallStepResult[];
- restartedCount: number;
- failedRestartCount: number;
-};
+export type {
+ InstallRuntimeResult,
+ InstallStepResult,
+} from "./installTypes";
export type AcpAuthMethod = {
id: string;
diff --git a/desktop/src/shared/lib/configNudge.ts b/desktop/src/shared/lib/configNudge.ts
index 82ed8f306a..86c0e16c13 100644
--- a/desktop/src/shared/lib/configNudge.ts
+++ b/desktop/src/shared/lib/configNudge.ts
@@ -32,7 +32,7 @@ export type ConfigNudgeRequirement =
* Determines which message and CTA the nudge card shows:
* - "available" → tooling installed, needs login
* - "adapter_missing" → CLI installed but ACP adapter missing
- * - "adapter_outdated" → ACP adapter present but from deprecated package; reinstall required
+ * - "adapter_outdated" → ACP adapter present but unsupported/outdated; reinstall required
* - "cli_missing" → ACP adapter installed but CLI missing
* - "not_installed" → neither adapter nor CLI found
*/
diff --git a/desktop/src/shared/lib/installError.test.mjs b/desktop/src/shared/lib/installError.test.mjs
index c6b51186a8..181d7b802b 100644
--- a/desktop/src/shared/lib/installError.test.mjs
+++ b/desktop/src/shared/lib/installError.test.mjs
@@ -3,84 +3,108 @@ import test from "node:test";
import { getInstallErrorMessage } from "./installError.ts";
+/** A failed install result carrying `steps` and, optionally, a log pointer. */
+function failed(steps, logPath = null) {
+ return {
+ success: false,
+ steps,
+ restartedCount: 0,
+ failedRestartCount: 0,
+ logPath,
+ };
+}
+
test("getInstallErrorMessage: empty steps array returns fallback", () => {
- assert.equal(getInstallErrorMessage([]), "Install failed with no output.");
+ assert.equal(
+ getInstallErrorMessage(failed([])),
+ "Install failed with no output.",
+ );
});
test("getInstallErrorMessage: failed step without hint contains step name and stderr", () => {
- const message = getInstallErrorMessage([
- {
- step: "adapter",
- command: "npm install -g @block/buzz-acp",
- success: false,
- stdout: "",
- stderr: "EACCES: permission denied",
- exitCode: 1,
- },
- ]);
+ const message = getInstallErrorMessage(
+ failed([
+ {
+ step: "adapter",
+ command: "npm install -g @block/buzz-acp",
+ success: false,
+ stdout: "",
+ stderr: "EACCES: permission denied",
+ exitCode: 1,
+ },
+ ]),
+ );
assert.match(message, /Step "adapter" failed:/);
assert.match(message, /EACCES: permission denied/);
});
test("getInstallErrorMessage: failed step without hint does not contain hint-ish text", () => {
- const message = getInstallErrorMessage([
- {
- step: "adapter",
- command: "npm install -g @block/buzz-acp",
- success: false,
- stdout: "",
- stderr: "EACCES: permission denied",
- exitCode: 1,
- },
- ]);
+ const message = getInstallErrorMessage(
+ failed([
+ {
+ step: "adapter",
+ command: "npm install -g @block/buzz-acp",
+ success: false,
+ stdout: "",
+ stderr: "EACCES: permission denied",
+ exitCode: 1,
+ },
+ ]),
+ );
assert.doesNotMatch(message, /npm config set prefix/);
});
test("getInstallErrorMessage: failed step with hint starts with hint and still contains stderr", () => {
const hint =
"Fix the npm prefix ownership:\n sudo chown -R $USER $(npm config get prefix)";
- const message = getInstallErrorMessage([
- {
- step: "adapter",
- command: "npm install -g @block/buzz-acp",
- success: false,
- stdout: "",
- stderr: "EACCES: permission denied, mkdir '/usr/local/lib'",
- exitCode: 1,
- hint,
- },
- ]);
+ const message = getInstallErrorMessage(
+ failed([
+ {
+ step: "adapter",
+ command: "npm install -g @block/buzz-acp",
+ success: false,
+ stdout: "",
+ stderr: "EACCES: permission denied, mkdir '/usr/local/lib'",
+ exitCode: 1,
+ hint,
+ },
+ ]),
+ );
assert.ok(message.startsWith(hint), "message should start with hint");
assert.match(message, /EACCES: permission denied/);
});
test("getInstallErrorMessage: failed step with empty stderr falls back to stdout", () => {
- const message = getInstallErrorMessage([
- {
- step: "node",
- command: "node --version",
- success: false,
- stdout: "some stdout output",
- stderr: "",
- exitCode: 1,
- },
- ]);
+ const message = getInstallErrorMessage(
+ failed([
+ {
+ step: "node",
+ command: "node --version",
+ success: false,
+ stdout: "some stdout output",
+ stderr: "",
+ exitCode: 1,
+ },
+ ]),
+ );
assert.match(message, /some stdout output/);
});
test("getInstallErrorMessage: hint and step detail are separated by double newline for whitespace-pre-line rendering", () => {
const hint = "Git Bash is required. Install it from git-scm.com.";
- const message = getInstallErrorMessage([
- {
- step: "shell",
- command: "bash -l -c 'npm install'",
- success: false,
- stdout: "",
- stderr: "bash: command not found",
- exitCode: 127,
- hint,
- },
- ]);
+ const message = getInstallErrorMessage(
+ failed([
+ {
+ step: "shell",
+ command: "bash -l -c 'npm install'",
+ success: false,
+ stdout: "",
+ stderr: "bash: command not found",
+ exitCode: 127,
+ hint,
+ },
+ ]),
+ );
assert.ok(
message.includes("\n\n"),
"hint and step detail should be separated by a blank line",
@@ -89,25 +113,72 @@ test("getInstallErrorMessage: hint and step detail are separated by double newli
});
test("getInstallErrorMessage: only reports the last (failing) step when multiple steps present", () => {
- const message = getInstallErrorMessage([
- {
- step: "node",
- command: "node --version",
- success: true,
- stdout: "v20.0.0",
- stderr: "",
- exitCode: 0,
- },
- {
- step: "adapter",
- command: "npm install -g @agentclientprotocol/claude-code-acp",
- success: false,
- stdout: "",
- stderr: "npm ERR! code E404",
- exitCode: 1,
- },
- ]);
+ const message = getInstallErrorMessage(
+ failed([
+ {
+ step: "node",
+ command: "node --version",
+ success: true,
+ stdout: "v20.0.0",
+ stderr: "",
+ exitCode: 0,
+ },
+ {
+ step: "adapter",
+ command: "npm install -g @agentclientprotocol/claude-code-acp",
+ success: false,
+ stdout: "",
+ stderr: "npm ERR! code E404",
+ exitCode: 1,
+ },
+ ]),
+ );
assert.match(message, /Step "adapter" failed:/);
assert.match(message, /npm ERR! code E404/);
assert.doesNotMatch(message, /Step "node"/);
});
+
+test("getInstallErrorMessage: points at the install log when one was written", () => {
+ const message = getInstallErrorMessage(
+ failed(
+ [
+ {
+ step: "cli",
+ command: "curl … | bash",
+ success: false,
+ stdout: "",
+ stderr: "download failed",
+ exitCode: 1,
+ },
+ ],
+ "/logs/install-goose.log",
+ ),
+ );
+ assert.match(message, /download failed/);
+ assert.ok(
+ message.endsWith("\n\nFull log: /logs/install-goose.log"),
+ `log pointer should close the message, got: ${message}`,
+ );
+});
+
+test("getInstallErrorMessage: omits the log pointer when no log was written", () => {
+ const message = getInstallErrorMessage(
+ failed([
+ {
+ step: "cli",
+ command: "curl … | bash",
+ success: false,
+ stdout: "",
+ stderr: "download failed",
+ exitCode: 1,
+ },
+ ]),
+ );
+ assert.doesNotMatch(message, /Full log/);
+});
+
+test("getInstallErrorMessage: a run with no steps at all still points at its log", () => {
+ const message = getInstallErrorMessage(failed([], "/logs/install-goose.log"));
+ assert.match(message, /Install failed with no output\./);
+ assert.match(message, /Full log: \/logs\/install-goose\.log/);
+});
diff --git a/desktop/src/shared/lib/installError.ts b/desktop/src/shared/lib/installError.ts
index bf72c4d3b2..82bcd4a310 100644
--- a/desktop/src/shared/lib/installError.ts
+++ b/desktop/src/shared/lib/installError.ts
@@ -1,15 +1,25 @@
-import type { InstallStepResult } from "@/shared/api/types";
+import type { InstallRuntimeResult } from "@/shared/api/types";
/**
* Build the user-visible error message for a failed install.
* When the last step carries an actionable hint, it is shown first,
* followed by the raw step failure detail.
+ *
+ * The step detail is truncated for display, so the message ends with a pointer
+ * to the install log — which holds every attempt of every step, each record
+ * bounded far above the display truncation — when one was written.
*/
-export function getInstallErrorMessage(steps: InstallStepResult[]): string {
+export function getInstallErrorMessage(result: InstallRuntimeResult): string {
+ const { steps, logPath } = result;
const lastStep = steps[steps.length - 1];
if (!lastStep) {
- return "Install failed with no output.";
+ return withLog("Install failed with no output.", logPath);
}
const base = `Step "${lastStep.step}" failed: ${lastStep.stderr || lastStep.stdout || "unknown error"}`;
- return lastStep.hint ? `${lastStep.hint}\n\n${base}` : base;
+ const detail = lastStep.hint ? `${lastStep.hint}\n\n${base}` : base;
+ return withLog(detail, logPath);
+}
+
+function withLog(message: string, logPath: string | null): string {
+ return logPath ? `${message}\n\nFull log: ${logPath}` : message;
}
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 07eaa77902..841e6ba83f 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -1,4 +1,5 @@
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
+import { emit } from "@tauri-apps/api/event";
import { mockIPC, mockWindows } from "@tauri-apps/api/mocks";
import { decode, npubEncode } from "nostr-tools/nip19";
import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
@@ -203,6 +204,8 @@ type E2eConfig = {
acpRuntimesCatalogAfterConnect?: RawAcpRuntimeCatalogEntry[];
activePersonaIds?: string[];
installAcpRuntimeDelayMs?: number;
+ /** Live output lines the mocked install emits before it settles. */
+ installAcpRuntimeOutputLines?: string[];
installAcpRuntimeResult?: RawInstallRuntimeResult;
/** Sequence of results for successive `install_acp_runtime` calls.
* Call N returns results[N]; when exhausted the last entry repeats.
@@ -1257,6 +1260,8 @@ const REACTION_TARGET_CONTENT = "React to me with a custom emoji";
// REACTION_TARGET_EVENT_ID.
const SYSTEM_REACTION_TARGET_EVENT_ID = "e".repeat(64);
const E2E_IDENTITY_OVERRIDE_STORAGE_KEY = "buzz:e2e-identity-override.v1";
+/** Stands in for `tauri.conf.json`'s version, which no mock IPC call can read. */
+const MOCK_APP_VERSION = "0.0.0-e2e";
const DEFAULT_MOCK_IDENTITY = {
pubkey: "deadbeef".repeat(8),
display_name: "npub1mock...",
@@ -7228,6 +7233,53 @@ let personaSharePublicationCallCount = 0;
// Per-page confirm_team_snapshot_import call counter for sequenced error testing.
let teamSnapshotConfirmCallCount = 0;
+// Live-output sequence for the install currently being replayed. The backend
+// counter is per run (`InstallReporter::for_run` starts a fresh one), so this
+// restarts too — a bridge that stayed monotonic across installs would hide a UI
+// that carried a stale sequence number into the next run and rejected all of it.
+let installOutputSeq = 0;
+
+/**
+ * Replay the live output the Rust reporter emits while an install runs: a clear
+ * signal, then one line per entry. `seq` is install-wide and monotonic, matching
+ * the backend contract the UI's ordering depends on.
+ *
+ * The clear and the first line emit synchronously with the install invocation,
+ * exactly as the backend does — the command is invoked from the click handler,
+ * so those events land before React has committed the pending install state.
+ * Delaying them would let a listener that mounts on that state still catch them,
+ * hiding the very race the UI has to survive.
+ *
+ * Later lines are spaced so each is observable rather than collapsing into one
+ * frame with the next.
+ */
+const INSTALL_OUTPUT_REPLAY_GAP_MS = 1000;
+
+async function replayInstallOutput(
+ runtimeId: string,
+ lines: string[],
+): Promise {
+ installOutputSeq = 0;
+ // The leading null is the clear signal the backend sends when an attempt
+ // starts, so this replays a whole attempt rather than only its output.
+ const events: (string | null)[] = [null, ...lines];
+ for (const [index, line] of events.entries()) {
+ // Index 0 and 1 are the clear and the first line: no gap before either.
+ if (index > 1) {
+ await new Promise((resolve) =>
+ window.setTimeout(resolve, INSTALL_OUTPUT_REPLAY_GAP_MS),
+ );
+ }
+ // `emit` reaches listeners registered through the real `listen` API, which
+ // is what the UI hook uses; mockIPC's shouldMockEvents wires the two.
+ await emit("acp-install-output", {
+ runtime_id: runtimeId,
+ seq: installOutputSeq++,
+ line,
+ });
+ }
+}
+
async function handleInstallAcpRuntime(
args: {
runtimeId?: string;
@@ -7235,6 +7287,10 @@ async function handleInstallAcpRuntime(
config: E2eConfig | undefined,
): Promise {
const runtimeId = args.runtimeId ?? "";
+ const outputLines = config?.mock?.installAcpRuntimeOutputLines;
+ if (outputLines && outputLines.length > 0) {
+ await replayInstallOutput(runtimeId, outputLines);
+ }
const perRuntime = config?.mock?.installAcpRuntimeByRuntime?.[runtimeId];
if (perRuntime) {
@@ -7291,6 +7347,7 @@ async function handleInstallAcpRuntime(
],
restarted_count: 0,
failed_restart_count: 0,
+ log_path: null,
};
}
@@ -11629,6 +11686,11 @@ export function maybeInstallE2eTauriMocks() {
return null;
case "plugin:window|is_fullscreen":
return false;
+ // Settings reads the app version through the app plugin. Without this the
+ // bridge throws an unhandled page error on every Settings render, which
+ // shows up as noise in unrelated specs.
+ case "plugin:app|version":
+ return MOCK_APP_VERSION;
case "merge_save_subscription_kinds": {
// Mirrors `merge_owner_p_kinds`: union `kind` into the owner_p row's
// kinds, creating the row if it doesn't exist yet.
diff --git a/desktop/tests/e2e/doctor-states.spec.ts b/desktop/tests/e2e/doctor-states.spec.ts
index 77d0fbcb45..2d69a4e0da 100644
--- a/desktop/tests/e2e/doctor-states.spec.ts
+++ b/desktop/tests/e2e/doctor-states.spec.ts
@@ -983,4 +983,91 @@ test.describe("Doctor panel state screenshots", () => {
path: `${SHOTS}/08-concurrent-installs-and-stale-clear.png`,
});
});
+ /**
+ * 09 — install observability: the live output line appears while the install
+ * runs and disappears when it settles, and the failure message points at the
+ * install log rather than only the truncated last step.
+ */
+ test("09-install-output-line-and-log-pointer", async ({ page }) => {
+ await installMockBridge(page, {
+ acpRuntimesCatalog: [
+ GOOSE_AVAILABLE,
+ CLAUDE_AVAILABLE_LOGGED_IN,
+ {
+ ...CODEX_NOT_INSTALLED,
+ can_auto_install: true,
+ node_required: false,
+ },
+ BUZZ_AGENT_AVAILABLE,
+ ],
+ installAcpRuntimeDelayMs: 500,
+ installAcpRuntimeOutputLines: [
+ "npm http fetch GET 200 @zed-industries/codex-acp",
+ "npm warn deprecated a transitive dependency",
+ ],
+ installAcpRuntimeResult: {
+ success: false,
+ steps: [
+ {
+ step: "adapter",
+ command: "npm install -g @zed-industries/codex-acp",
+ success: false,
+ stdout: "",
+ stderr: "npm ERR! code E404",
+ exit_code: 1,
+ },
+ ],
+ log_path: "/tmp/buzz-install-codex.log",
+ },
+ });
+
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await openSettings(page, "agents");
+
+ const row = page.getByTestId("doctor-runtime-codex");
+ await expect(row).toBeVisible({ timeout: 10_000 });
+
+ const installButton = page.getByTestId("doctor-runtime-install-codex");
+ await expect(installButton).toBeEnabled();
+ await installButton.click();
+
+ // The bridge emits the attempt-start clear and the first line synchronously
+ // with the install invocation — before React commits the pending state — so
+ // observing this line proves the listener was already mounted at the click.
+ // A subscription that waited for the install state would have missed both.
+ const outputLine = page.getByTestId("doctor-runtime-install-output-codex");
+ await expect(outputLine).toContainText("npm http fetch", {
+ timeout: 5_000,
+ });
+
+ // Each new line replaces the previous one rather than accumulating.
+ await expect(outputLine).toContainText("npm warn deprecated", {
+ timeout: 5_000,
+ });
+ await expect(outputLine).not.toContainText("npm http fetch");
+
+ // Settled: the line clears, so a finished install leaves no stale output
+ // under a fresh Install button.
+ const installError = page.getByTestId("doctor-runtime-install-error-codex");
+ await expect(installError).toBeVisible({ timeout: 5_000 });
+ await expect(outputLine).toHaveCount(0);
+
+ // The failure points at the log holding bounded output for every attempt.
+ await expect(installError).toContainText("npm ERR! code E404");
+ await expect(installError).toContainText("/tmp/buzz-install-codex.log");
+
+ await row.scrollIntoViewIfNeeded();
+ await waitForAnimations(page);
+ await row.screenshot({
+ path: `${SHOTS}/09-install-output-line-and-log-pointer.png`,
+ });
+
+ // A second install shows its own output. The backend sequence restarts per
+ // run, so a display that kept the previous run's sequence number would
+ // reject every event of this one and show nothing at all.
+ await installButton.click();
+ await expect(outputLine).toContainText("npm http fetch", {
+ timeout: 5_000,
+ });
+ });
});
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index 468f860203..c3473ae4f1 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -131,6 +131,22 @@ export type MockAgentMemoryListing = {
fetchedAt: number;
};
+/** Result returned by the `install_acp_runtime` mock command. */
+type MockInstallRuntimeResult = {
+ success: boolean;
+ steps: {
+ step: string;
+ command: string;
+ success: boolean;
+ stdout: string;
+ stderr: string;
+ exit_code: number | null;
+ hint?: string;
+ }[];
+ /** Install log the failure message points at. Omitted = no log was written. */
+ log_path?: string | null;
+};
+
type MockBridgeOptions = {
/** Advertised HEAD for the first mock project without adding that branch. */
projectHeadBranch?: string;
@@ -171,35 +187,17 @@ type MockBridgeOptions = {
connectAcpRuntimeDelayMs?: number;
connectAcpRuntimeError?: string;
installAcpRuntimeDelayMs?: number;
+ /** Live output lines the mocked install emits before it settles, in order.
+ * Each arrives as an `acp-install-output` event, preceded by the clear
+ * signal the backend sends at the start of an attempt. */
+ installAcpRuntimeOutputLines?: string[];
/** Override the result returned by the `install_acp_runtime` mock command.
* Pass `{ success: false, steps: [...] }` to exercise error/Retry states. */
- installAcpRuntimeResult?: {
- success: boolean;
- steps: {
- step: string;
- command: string;
- success: boolean;
- stdout: string;
- stderr: string;
- exit_code: number | null;
- hint?: string;
- }[];
- };
+ installAcpRuntimeResult?: MockInstallRuntimeResult;
/** Sequence of results for successive `install_acp_runtime` calls. Call N
* returns results[N]; when exhausted the last entry repeats. Takes precedence
* over `installAcpRuntimeResult`. Use for fail-then-succeed Retry tests. */
- installAcpRuntimeResults?: Array<{
- success: boolean;
- steps: {
- step: string;
- command: string;
- success: boolean;
- stdout: string;
- stderr: string;
- exit_code: number | null;
- hint?: string;
- }[];
- }>;
+ installAcpRuntimeResults?: MockInstallRuntimeResult[];
activePersonaIds?: string[];
/**
* Listing returned by the mocked `get_agent_memory` command. Pass a single
From b9e4ed616f39b812bc964e79c7a40223c4e93832 Mon Sep 17 00:00:00 2001
From: Wes
Date: Thu, 30 Jul 2026 15:50:51 -0600
Subject: [PATCH 21/87] test(desktop): click visible thread collapse guide
(#3800)
## Summary
- target the visible thread branch collapse guide in the messaging smoke
test
- avoid clicking the underlying collapse rail when the guide overlaps it
- retain the existing post-click assertions that verify the two-reply
branch collapses
## Context
`main` CI failed because Playwright repeatedly attempted to click the
lower `thread-collapse-rail` while the matching `thread-collapse-guide`
intercepted pointer events. Both controls dispatch collapse for the same
branch; the guide is the actual topmost user target and is already used
by `thread-unread.spec.ts`.
Failing run: https://github.com/block/buzz/actions/runs/30575425126
## Validation
- focused Playwright smoke test: 1 passed
- pre-push hooks: desktop check passed; 3,835 desktop tests passed
- `git diff --check`
## Review
Princess Donut reviewed the test-only approach and locator determinism
with no blockers. Mongo review is pending.
Signed-off-by: Wes
Co-authored-by: Carl
---
desktop/tests/e2e/messaging.spec.ts | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts
index 43a617ffd4..c6f5aefb9b 100644
--- a/desktop/tests/e2e/messaging.spec.ts
+++ b/desktop/tests/e2e/messaging.spec.ts
@@ -918,10 +918,10 @@ test("opens a single-level thread panel with inline expansion", async ({
`[data-testid="message-thread-summary"][data-thread-head-id="${firstReplyId}"]`,
);
await expect(firstReplySummaryRow).toHaveCount(0);
- const firstReplyBranchRail = threadReplies.locator(
- `[data-testid="thread-collapse-rail"][data-thread-head-id="${firstReplyId}"]`,
+ const firstReplyBranchGuide = threadReplies.locator(
+ `[data-testid="thread-collapse-guide"][data-thread-head-id="${firstReplyId}"]`,
);
- await expect(firstReplyBranchRail).toHaveCount(1);
+ await expect(firstReplyBranchGuide).not.toHaveCount(0);
await expect(rootSummaryRow).toContainText("18 replies");
await expect(
@@ -941,7 +941,7 @@ test("opens a single-level thread panel with inline expansion", async ({
await expectThreadReplyUnobscured(nestedReplyRow);
- await firstReplyBranchRail.click();
+ await firstReplyBranchGuide.first().click();
await expect(firstReplySummaryRow).toHaveCount(1);
await expect(firstReplySummaryRow).toContainText("2 replies");
await expect(
From 114d40d9d37f05eff83ee90347ed93fb3da512c5 Mon Sep 17 00:00:00 2001
From: Will Pfleger
Date: Thu, 30 Jul 2026 17:53:30 -0400
Subject: [PATCH 22/87] feat(relay): gate kind 30178 team-catalog reads behind
the shared tag (#3358)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Team catalog projections (`kind:30178`) embed every member's system
prompt, so they need the same read gate personas already have: only the
author sees an unshared event. The gate was hardcoded to `kind:30175` at
six read surfaces plus the SQL pushdown, so rather than adding a second
special case it becomes kind-generic over `SHARED_GATED_KINDS = {30175,
30178}`.
## Kind 30178
New parameterized-replaceable kind, addressed by `(pubkey_o, 30178,
team_id)`. It embeds sanitized member projections instead of referencing
`kind:30175` heads — a foreign reader of a shared team could not
otherwise hydrate members whose own persona events are unshared or, for
built-ins, absent entirely. `kind:30176`'s wire body is untouched, so
device sync keeps its contract.
## Kind-generic shared gate
`buzz_core::kind` replaces `is_persona_shared_kind` /
`is_unshared_persona_event` / `persona_event_is_shared` with
`SHARED_GATED_KINDS` and the kind-agnostic `is_shared_gated_kind` /
`is_unshared_gated_event` / `event_is_shared`. Every read surface
consults the set:
| Surface | File |
|---|---|
| REQ historical delivery + `ids` lookup |
`crates/buzz-relay/src/handlers/req.rs` |
| Live fan-out | `crates/buzz-relay/src/handlers/event.rs` |
| COUNT fallback | `crates/buzz-relay/src/handlers/count.rs` |
| NIP-98 HTTP `/query`, `/count`, `/search` |
`crates/buzz-relay/src/api/bridge.rs` |
| Pre-`LIMIT` SQL pushdown | `crates/buzz-db/src/event.rs` |
The SQL clause generalizes from `kind != 30175` to `kind NOT IN (...)`
bound from `SHARED_GATED_KINDS`, still applied before `ORDER BY … LIMIT`
so a page of newer private events cannot starve an older shared one off
the candidate set. `EventQuery::persona_reader` is renamed
`shared_gated_reader` and `needs_persona_filtering` to
`needs_shared_gate_filtering` to match.
Because the `buzz-core` rename has consumers outside the relay, the four
desktop call sites of `persona_event_is_shared` travel with it:
`desktop/src-tauri/src/commands/personas/pending.rs`,
`desktop/src-tauri/src/event_sync.rs`, and two in
`desktop/src-tauri/src/managed_agents/persona_events.rs`. Each call is
unchanged apart from the name — the persona `shared` projection behaves
exactly as before.
## Ingest validation
`validate_persona_envelope` splits into two reusable pieces —
`validate_shared_tag` (exactly-two-element `["shared","true"]`, at most
one occurrence) and `single_bounded_d_tag` (exactly one `d` tag,
non-empty, `<=64` chars, no ASCII control characters or whitespace).
`validate_team_catalog_envelope` composes both; personas additionally
keep the slug grammar `^[a-z0-9][a-z0-9_-]{0,63}$`.
`kind:30178` deliberately does **not** get the slug grammar. Team ids
are UUIDs or built-in identifiers such as `builtin-team:welcome`, and
the colon is not slug-legal; rewriting ids to fit would break NIP-33
addressing against the team's own `kind:30176` head. The non-empty and
exactly-one checks are load-bearing regardless — without them generic
NIP-33 storage maps a missing `d` onto `(pubkey_o, 30178, "")` and every
team overwrites its predecessor.
The exact two-element `shared` shape is enforced because the SQL
visibility clause is JSONB containment (`tags @>
'[["shared","true"]]'`), which would match a three-element superset such
as `["shared","true","extra"]`.
`kind:30178` is also added to the `Scope::UsersWrite` allowlist and to
`is_global_only_kind`, so a stray `h` tag cannot channel-scope an
owner-authored definition.
## Deferred
`kind:30176` is deliberately not a gate member. Its writers never emit
`shared`, so catalog opt-in semantics do not describe it — it needs
owner-private reads driven by an authenticated principal set, tracked as
a separate follow-up.
## Tests
- 19 new `ingest.rs` unit tests covering the 30178 envelope (UUID and
colon `d` tags, 64-char boundary, non-ASCII bound,
empty/valueless/duplicate/missing `d`, embedded newline, `shared`
false/three-element/duplicate, scope and global-only membership).
- Persona regressions for the valueless `["d"]` shapes, since the
`d`-tag helper is shared by both validators.
- Existing `kind.rs` gate tests generalized and extended to assert the
gate applies to 30178 as it does to 30175.
- New `crates/buzz-test-client/tests/e2e_team_catalog.rs`: 9 WS-level
tests over a live relay covering author reads of unshared heads, foreign
omission from REQ, `ids`-lookup denial, COUNT existence-leak, share and
unshare transitions, and the mixed-kind filter case.
- `.github/workflows/ci.yml` adds `--test e2e_team_catalog` to the Relay
E2E job so the new suite runs.
## Docs
`docs/nips/NIP-AP.md` gains a "Team catalog projection: kind:30178"
section and an "Ingest validation: kind:30178" subsection, records the
gate as kind-generic, documents 30178 deletion vs. unshare semantics,
and adds a security note that sharing a team exposes every member's
instructions even when that member's own `kind:30175` head is unshared.
Signed-off-by: Will Pfleger
---
.github/workflows/ci.yml | 2 +-
crates/buzz-core/src/kind.rs | 182 +++++--
crates/buzz-db/src/event.rs | 49 +-
crates/buzz-relay/src/api/bridge.rs | 38 +-
crates/buzz-relay/src/handlers/count.rs | 32 +-
crates/buzz-relay/src/handlers/event.rs | 12 +-
crates/buzz-relay/src/handlers/ingest.rs | 309 +++++++++--
crates/buzz-relay/src/handlers/req.rs | 42 +-
crates/buzz-test-client/tests/e2e_persona.rs | 4 +-
.../tests/e2e_team_catalog.rs | 484 ++++++++++++++++++
.../src/commands/personas/pending.rs | 4 +-
desktop/src-tauri/src/event_sync.rs | 2 +-
.../src/managed_agents/persona_events.rs | 4 +-
docs/nips/NIP-AP.md | 48 +-
14 files changed, 1033 insertions(+), 179 deletions(-)
create mode 100644 crates/buzz-test-client/tests/e2e_team_catalog.rs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 59d63f28da..bc594e16ad 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -739,7 +739,7 @@ jobs:
./scripts/start-relay-for-tests.sh --no-build
- name: Relay E2E tests
run: |
- cargo test -p buzz-test-client --test e2e_persona --test e2e_nostr_interop -- --ignored --nocapture
+ cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture
env:
diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs
index afec52305a..e5f67f671f 100644
--- a/crates/buzz-core/src/kind.rs
+++ b/crates/buzz-core/src/kind.rs
@@ -182,29 +182,43 @@ pub const P_GATED_KINDS: &[u32] = &[
/// or more than one `shared` tag) so no ambiguous heads can exist.
pub const KIND_PERSONA: u32 = 30175;
-/// Returns `true` if `kind` uses the author-only-unless-shared read model
-/// (currently only `KIND_PERSONA` / 30175).
+/// Kinds that use the author-only-unless-shared read model.
///
/// Events of these kinds may only be delivered to foreign readers when the
-/// event carries exactly `["shared", "true"]`. Used by all relay read
-/// chokepoints: REQ historical delivery, live fan-out, COUNT fallback,
-/// and the `ids`-lookup result gate.
-pub fn is_persona_shared_kind(kind: u32) -> bool {
- kind == KIND_PERSONA
+/// event carries exactly `["shared", "true"]`. Every relay read chokepoint
+/// consults this set: REQ historical delivery, live fan-out, COUNT fallback,
+/// the `ids`-lookup result gate, both HTTP surfaces, and the pre-`LIMIT` SQL
+/// visibility pushdown in `buzz-db`.
+///
+/// Membership is a privacy decision, not a convenience: adding a kind here
+/// makes its events invisible to foreign readers until their author opts in,
+/// and the opt-in must be a `shared` TAG (not a content field) so that
+/// toggling it leaves content bytes — and any content hash derived from them —
+/// unchanged.
+///
+/// `KIND_TEAM` (30176) is deliberately NOT a member. Its writers never emit
+/// `shared`, so catalog opt-in semantics do not describe it; it needs
+/// owner-private read semantics instead, which is a separate change.
+pub const SHARED_GATED_KINDS: &[u32] = &[KIND_PERSONA, KIND_TEAM_CATALOG];
+
+/// Returns `true` if `kind` uses the author-only-unless-shared read model
+/// (see [`SHARED_GATED_KINDS`]).
+pub fn is_shared_gated_kind(kind: u32) -> bool {
+ SHARED_GATED_KINDS.contains(&kind)
}
-/// Returns `true` if the event is a persona-shared-catalog kind AND the
-/// requester is NOT the author AND the event does NOT carry `["shared",
-/// "true"]`. All three conditions must hold to withhold the event.
+/// Returns `true` if the event is a shared-gated kind AND the requester is NOT
+/// the author AND the event does NOT carry `["shared", "true"]`. All three
+/// conditions must hold to withhold the event.
///
/// This is the per-event gate used by REQ historical delivery, live fan-out,
/// and COUNT fallback paths. It is intentionally independent of
-/// `is_author_only_event` — persona events with `["shared", "true"]` MUST
+/// `is_author_only_event` — shared-gated events with `["shared", "true"]` MUST
/// reach foreign readers; stripping them at the author-only layer would break
/// the catalog query.
-pub fn is_unshared_persona_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool {
+pub fn is_unshared_gated_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool {
let kind = event.kind.as_u16() as u32;
- if !is_persona_shared_kind(kind) {
+ if !is_shared_gated_kind(kind) {
return false;
}
// Author reads are always allowed.
@@ -212,18 +226,23 @@ pub fn is_unshared_persona_event(event: &nostr::Event, requester_pubkey_bytes: &
return false;
}
// Foreign reader: allowed only if the event is explicitly shared.
- !persona_event_is_shared(event)
+ !event_is_shared(event)
}
/// Returns `true` if the event carries exactly one `["shared", "true"]` tag.
///
+/// Kind-agnostic: this is purely the tag-shape predicate. The kind check lives
+/// in [`is_shared_gated_kind`], so callers that need "is this event shared"
+/// for a kind they already know (e.g. a client deciding whether its own
+/// retained head is published) can use this directly.
+///
/// Requires the tag to have exactly two elements so that a three-element shape
/// like `["shared","true","extra"]` is NOT treated as shared. Ingest enforces
/// the same exact shape, so a well-stored event either has no `shared` tag
/// (author-only) or exactly one with precisely two elements and value `"true"`
/// (community-readable). This helper fails closed on any non-exact shape
/// independently of ingest guarantees.
-pub fn persona_event_is_shared(event: &nostr::Event) -> bool {
+pub fn event_is_shared(event: &nostr::Event) -> bool {
let mut count = 0usize;
for tag in event.tags.iter() {
let parts = tag.as_slice();
@@ -258,6 +277,34 @@ pub const KIND_TEAM: u32 = 30176;
/// since these events are world-readable on the relay.
pub const KIND_MANAGED_AGENT: u32 = 30177;
+/// NIP-AP: Team Catalog projection (parameterized replaceable, owner-authored).
+///
+/// The shareable projection of a team, addressed by `(pubkey, kind, d_tag)`
+/// where `d_tag` is the team's stable id. Content is a versioned JSON body
+/// carrying sanitized team fields plus ordered, EMBEDDED member definition
+/// projections.
+///
+/// # Why this is not a `shared` tag on [`KIND_TEAM`]
+///
+/// A team's members live in kind 30175 events that are author-only unless
+/// individually shared, so a foreign reader of a shared team could never
+/// hydrate its members. This kind therefore embeds the member projections
+/// rather than referencing them: the share is atomic, it covers built-in
+/// members that have no 30175 head at all, it is immune to local-id/d-tag
+/// divergence, and an unshared 30175 stays private. Kind 30176's wire body is
+/// untouched, so device sync keeps its contract.
+///
+/// # Access control
+///
+/// Member of [`SHARED_GATED_KINDS`]: author-only unless the event carries
+/// exactly `["shared", "true"]`. Ingest additionally requires exactly one
+/// non-empty, bounded `d` tag — generic NIP-33 storage maps a missing `d` to
+/// the empty coordinate, which would collapse every team into one slot.
+///
+/// Content carries only sanitized fields: no env vars, no `respond_to`
+/// allowlist pubkeys, no source or local ids, no filesystem paths, no secrets.
+pub const KIND_TEAM_CATALOG: u32 = 30178;
+
// NIP-56 reporting
/// NIP-56: Report an event, pubkey, or blob to relay moderators (kind:1984).
///
@@ -586,6 +633,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_PERSONA,
KIND_TEAM,
KIND_MANAGED_AGENT,
+ KIND_TEAM_CATALOG,
KIND_REPORT,
KIND_PRODUCT_FEEDBACK,
KIND_NIP29_PUT_USER,
@@ -784,6 +832,7 @@ const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000–
const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999
+const _: () = assert!(is_parameterized_replaceable(KIND_TEAM_CATALOG)); // 30178 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999
const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999
@@ -858,64 +907,68 @@ mod tests {
}
}
- // ── persona_event_is_shared / is_unshared_persona_event ──────────────
+ // ── event_is_shared / is_unshared_gated_event ────────────────────────
- fn make_persona_event(tags: &[&[&str]]) -> nostr::Event {
+ fn make_event_of_kind(kind: u32, tags: &[&[&str]]) -> nostr::Event {
use nostr::{EventBuilder, Keys, Kind, Tag};
let keys = Keys::generate();
let tag_vec: Vec = tags
.iter()
.map(|parts| Tag::parse(parts.iter().copied()).unwrap())
.collect();
- EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "")
+ EventBuilder::new(Kind::Custom(kind as u16), "")
.tags(tag_vec)
.sign_with_keys(&keys)
.unwrap()
}
+ fn make_persona_event(tags: &[&[&str]]) -> nostr::Event {
+ make_event_of_kind(KIND_PERSONA, tags)
+ }
+
#[test]
- fn persona_event_is_shared_true_tag() {
+ fn event_is_shared_true_tag() {
let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"]]);
- assert!(persona_event_is_shared(&ev));
+ assert!(event_is_shared(&ev));
}
#[test]
- fn persona_event_is_shared_no_tag() {
+ fn event_is_shared_no_tag() {
let ev = make_persona_event(&[&["d", "my-agent"]]);
- assert!(!persona_event_is_shared(&ev));
+ assert!(!event_is_shared(&ev));
}
#[test]
- fn persona_event_is_shared_wrong_value() {
+ fn event_is_shared_wrong_value() {
let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "false"]]);
- assert!(!persona_event_is_shared(&ev));
+ assert!(!event_is_shared(&ev));
}
#[test]
- fn persona_event_is_shared_duplicate_shared_tags() {
+ fn event_is_shared_duplicate_shared_tags() {
// Two ["shared","true"] tags → ambiguous; not considered shared.
let ev =
make_persona_event(&[&["d", "my-agent"], &["shared", "true"], &["shared", "true"]]);
- assert!(!persona_event_is_shared(&ev));
+ assert!(!event_is_shared(&ev));
}
#[test]
- fn persona_event_is_shared_three_element_tag_not_shared() {
+ fn event_is_shared_three_element_tag_not_shared() {
// ["shared","true","extra"] — three elements — must NOT be treated as shared.
// The helper fails closed on any non-exact shape independently of ingest guarantees.
let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true", "extra"]]);
- assert!(!persona_event_is_shared(&ev));
+ assert!(!event_is_shared(&ev));
}
#[test]
- fn persona_event_is_shared_one_element_tag_not_shared() {
+ fn event_is_shared_one_element_tag_not_shared() {
// ["shared"] — only one element — not shared (fails the == 2 check).
let ev = make_persona_event(&[&["d", "my-agent"], &["shared"]]);
- assert!(!persona_event_is_shared(&ev));
+ assert!(!event_is_shared(&ev));
}
#[test]
- fn is_unshared_persona_event_author_always_allowed() {
+ fn is_unshared_gated_event_author_always_allowed() {
// Even without a shared tag the event author should not be blocked.
use nostr::{EventBuilder, Keys, Kind, Tag};
let keys = Keys::generate();
@@ -924,32 +977,83 @@ mod tests {
.sign_with_keys(&keys)
.unwrap();
let author_bytes = keys.public_key().to_bytes();
- assert!(!is_unshared_persona_event(&ev, &author_bytes));
+ assert!(!is_unshared_gated_event(&ev, &author_bytes));
}
#[test]
- fn is_unshared_persona_event_foreign_no_tag() {
+ fn is_unshared_gated_event_foreign_no_tag() {
let ev = make_persona_event(&[&["d", "my-agent"]]);
let foreign = [0u8; 32];
- assert!(is_unshared_persona_event(&ev, &foreign));
+ assert!(is_unshared_gated_event(&ev, &foreign));
}
#[test]
- fn is_unshared_persona_event_foreign_shared_tag() {
+ fn is_unshared_gated_event_foreign_shared_tag() {
let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"]]);
let foreign = [0u8; 32];
- assert!(!is_unshared_persona_event(&ev, &foreign));
+ assert!(!is_unshared_gated_event(&ev, &foreign));
}
#[test]
- fn is_unshared_persona_event_non_persona_kind_passthrough() {
+ fn is_unshared_gated_event_ungated_kind_passthrough() {
use nostr::{EventBuilder, Keys, Kind};
let keys = Keys::generate();
let ev = EventBuilder::new(Kind::Custom(KIND_TEAM as u16), "")
.sign_with_keys(&keys)
.unwrap();
let foreign = [0u8; 32];
- // Non-persona kinds are never blocked by this gate.
- assert!(!is_unshared_persona_event(&ev, &foreign));
+ // Kinds outside SHARED_GATED_KINDS are never blocked by this gate.
+ assert!(!is_unshared_gated_event(&ev, &foreign));
+ }
+
+ #[test]
+ fn is_unshared_gated_event_team_catalog_foreign_no_tag() {
+ // The gate must cover 30178 identically to 30175 — an unshared team
+ // catalog projection is author-only.
+ let ev = make_event_of_kind(KIND_TEAM_CATALOG, &[&["d", "team-1"]]);
+ let foreign = [0u8; 32];
+ assert!(is_unshared_gated_event(&ev, &foreign));
+ }
+
+ #[test]
+ fn is_unshared_gated_event_team_catalog_foreign_shared_tag() {
+ let ev = make_event_of_kind(KIND_TEAM_CATALOG, &[&["d", "team-1"], &["shared", "true"]]);
+ let foreign = [0u8; 32];
+ assert!(!is_unshared_gated_event(&ev, &foreign));
+ }
+
+ #[test]
+ fn is_unshared_gated_event_team_catalog_author_always_allowed() {
+ use nostr::{EventBuilder, Keys, Kind, Tag};
+ let keys = Keys::generate();
+ let ev = EventBuilder::new(Kind::Custom(KIND_TEAM_CATALOG as u16), "")
+ .tags(vec![Tag::parse(["d", "team-1"]).unwrap()])
+ .sign_with_keys(&keys)
+ .unwrap();
+ let author_bytes = keys.public_key().to_bytes();
+ assert!(!is_unshared_gated_event(&ev, &author_bytes));
+ }
+
+ #[test]
+ fn is_unshared_gated_event_team_catalog_malformed_shared_tag_fails_closed() {
+ // A three-element `shared` tag can never be stored (ingest rejects it),
+ // but the read gate must independently treat it as NOT shared.
+ let ev = make_event_of_kind(
+ KIND_TEAM_CATALOG,
+ &[&["d", "team-1"], &["shared", "true", "extra"]],
+ );
+ let foreign = [0u8; 32];
+ assert!(is_unshared_gated_event(&ev, &foreign));
+ }
+
+ #[test]
+ fn shared_gated_kinds_membership() {
+ assert!(is_shared_gated_kind(KIND_PERSONA));
+ assert!(is_shared_gated_kind(KIND_TEAM_CATALOG));
+ // 30176 has owner-private semantics, not catalog opt-in semantics: its
+ // writers never emit `shared`, so gating it here would hide every team
+ // from its own delegated readers.
+ assert!(!is_shared_gated_kind(KIND_TEAM));
+ assert!(!is_shared_gated_kind(KIND_MANAGED_AGENT));
}
}
diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs
index 0e54196d11..c0550e7e22 100644
--- a/crates/buzz-db/src/event.rs
+++ b/crates/buzz-db/src/event.rs
@@ -11,7 +11,7 @@ use uuid::Uuid;
use buzz_core::kind::{
event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_EVENT_REMINDER,
- KIND_HUDDLE_STARTED,
+ KIND_HUDDLE_STARTED, SHARED_GATED_KINDS,
};
use buzz_core::{CommunityId, StoredEvent};
@@ -71,13 +71,15 @@ pub struct EventQuery {
/// which needs to fetch all matching events for post-filter counting.
/// When None, the default clamp of 1000 applies.
pub max_limit: Option,
- /// Persona visibility reader: when set, append an SQL visibility clause
- /// for kind 30175 before ORDER/LIMIT so private personas are excluded from
- /// the candidate page rather than discarded after it.
+ /// Shared-gated visibility reader: when set, append an SQL visibility
+ /// clause for every kind in [`SHARED_GATED_KINDS`] before ORDER/LIMIT so
+ /// private events are excluded from the candidate page rather than
+ /// discarded after it.
///
- /// The clause is: `AND (kind != 30175 OR pubkey = $reader OR tags @> ?)`,
- /// where `?` is the JSONB literal `[["shared","true"]]`. The GIN index on
- /// `tags` (migration 0004, jsonb_path_ops) makes the containment check fast.
+ /// The clause is: `AND (kind NOT IN (...) OR pubkey = $reader OR tags @> ?)`,
+ /// where the `IN` list is [`SHARED_GATED_KINDS`] and `?` is the JSONB
+ /// literal `[["shared","true"]]`. The GIN index on `tags` (migration 0004,
+ /// jsonb_path_ops) makes the containment check fast.
///
/// NOTE: `tags @> '[["shared","true"]]'` uses JSONB containment, which
/// matches any tag array that is a superset of `[["shared","true"]]` — it
@@ -85,7 +87,7 @@ pub struct EventQuery {
/// 2` exact-shape check ensures such malformed tags are never stored, so the
/// SQL pushdown is sound. Keeping `event_visible_to_reader` as post-filter
/// defense-in-depth catches any residual mismatch.
- pub persona_reader: Option>,
+ pub shared_gated_reader: Option>,
}
impl EventQuery {
@@ -114,7 +116,7 @@ impl EventQuery {
e_tags: None,
channel_ids: None,
max_limit: None,
- persona_reader: None,
+ shared_gated_reader: None,
}
}
}
@@ -512,25 +514,28 @@ pub(crate) async fn query_events_on(
}
}
- // Persona visibility pushdown: exclude kind 30175 events that are neither
- // authored by the reader nor explicitly shared. Applied BEFORE ORDER/LIMIT
- // so that a page of newer private personas does not push visible shared ones
- // off the end of the result set (the catalog query pattern).
+ // Shared-gated visibility pushdown: exclude SHARED_GATED_KINDS events that
+ // are neither authored by the reader nor explicitly shared. Applied BEFORE
+ // ORDER/LIMIT so that a page of newer private events does not push visible
+ // shared ones off the end of the result set (the catalog query pattern).
//
- // Clause: AND (kind != 30175 OR pubkey = $reader OR tags @> '[["shared","true"]]')
+ // Clause: AND (kind NOT IN (30175, 30178) OR pubkey = $reader
+ // OR tags @> '[["shared","true"]]')
//
// The JSONB containment check is served by idx_events_tags_gin (migration
// 0004, jsonb_path_ops). `tags @> '[["shared","true"]]'` matches any array
// that contains exactly the sub-array — a two-element `["shared","true"]`
- // tag passes; a tag-absent event does not. Because ingest now requires
- // exactly two elements for the shared tag (parts.len() == 2), no stored
- // event can carry a three-element superset.
- if let Some(ref reader_bytes) = q.persona_reader {
- let kind_30175: i32 = 30175;
+ // tag passes; a tag-absent event does not. Because ingest requires exactly
+ // two elements for the shared tag (parts.len() == 2), no stored event can
+ // carry a three-element superset.
+ if let Some(ref reader_bytes) = q.shared_gated_reader {
let shared_containment = serde_json::json!([["shared", "true"]]);
- qb.push(format!(" AND ({col_prefix}kind != "));
- qb.push_bind(kind_30175);
- qb.push(format!(" OR {col_prefix}pubkey = "));
+ qb.push(format!(" AND ({col_prefix}kind NOT IN ("));
+ let mut sep = qb.separated(", ");
+ for kind in SHARED_GATED_KINDS {
+ sep.push_bind(*kind as i32);
+ }
+ qb.push(format!(") OR {col_prefix}pubkey = "));
qb.push_bind(reader_bytes.clone());
qb.push(format!(" OR {col_prefix}tags @> "));
qb.push_bind(shared_containment);
diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs
index 10461d8d46..678199e734 100644
--- a/crates/buzz-relay/src/api/bridge.rs
+++ b/crates/buzz-relay/src/api/bridge.rs
@@ -1236,10 +1236,10 @@ async fn query_events_authed(
extract_channel_from_filter(filter),
&accessible_channels,
);
- // Persona visibility pushdown: must mirror WS REQ so that a page of newer
- // private personas does not starve older shared ones off the candidate page.
- if crate::handlers::req::filter_can_match_persona_shared_kinds(filter) {
- query.persona_reader = Some(pubkey_bytes.clone());
+ // Shared-gated visibility pushdown: must mirror WS REQ so that a page of
+ // newer private events does not starve older shared ones off the page.
+ if crate::handlers::req::filter_can_match_shared_gated_kinds(filter) {
+ query.shared_gated_reader = Some(pubkey_bytes.clone());
}
match extract_before_id(raw) {
@@ -1453,11 +1453,11 @@ async fn count_events_authed(
filter,
&authed_pubkey_hex,
);
- // Force per-event fallback for filters that can match kind:30175 —
- // the fast SQL count_events() path has no per-event gate and would
- // over-count foreign unshared persona events (existence leak).
- let needs_persona_filtering =
- crate::handlers::req::filter_can_match_persona_shared_kinds(filter);
+ // Force per-event fallback for filters that can match a shared-gated
+ // kind — the fast SQL count_events() path has no per-event gate and
+ // would over-count foreign unshared events (existence leak).
+ let needs_shared_gate_filtering =
+ crate::handlers::req::filter_can_match_shared_gated_kinds(filter);
// If filter targets a specific channel, verify access.
if let Some(ch_id) = extract_channel_from_filter(filter) {
@@ -1472,10 +1472,10 @@ async fn count_events_authed(
tenant.community(),
)
.await;
- // Persona visibility pushdown: same as REQ and /query paths, so the
- // fallback's query_events call doesn't over-fetch private persona rows.
- if needs_persona_filtering {
- query.persona_reader = Some(pubkey_bytes.clone());
+ // Shared-gated visibility pushdown: same as REQ and /query paths, so
+ // the fallback's query_events call doesn't over-fetch private rows.
+ if needs_shared_gate_filtering {
+ query.shared_gated_reader = Some(pubkey_bytes.clone());
}
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
!authors.is_empty()
@@ -1486,7 +1486,7 @@ async fn count_events_authed(
if crate::handlers::req::filter_fully_pushable(filter)
&& (!needs_author_only_filtering || author_is_self)
&& !needs_result_gated_filtering
- && !needs_persona_filtering
+ && !needs_shared_gate_filtering
{
match state.db.count_events_routed("bridge_count", &query).await {
Ok(n) => total += n as u64,
@@ -1541,10 +1541,10 @@ async fn count_events_authed(
)
.await;
query.channel_ids = Some(accessible_channels.to_vec());
- // Persona visibility pushdown: pre-filter before ORDER/LIMIT on the
- // fallback query_events path.
- if needs_persona_filtering {
- query.persona_reader = Some(pubkey_bytes.clone());
+ // Shared-gated visibility pushdown: pre-filter before ORDER/LIMIT on
+ // the fallback query_events path.
+ if needs_shared_gate_filtering {
+ query.shared_gated_reader = Some(pubkey_bytes.clone());
}
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
@@ -1556,7 +1556,7 @@ async fn count_events_authed(
if crate::handlers::req::filter_fully_pushable(filter)
&& (!needs_author_only_filtering || author_is_self)
&& !needs_result_gated_filtering
- && !needs_persona_filtering
+ && !needs_shared_gate_filtering
{
query.limit = None;
match state.db.count_events_routed("bridge_count", &query).await {
diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs
index dfb44e152f..3eeab5e807 100644
--- a/crates/buzz-relay/src/handlers/count.rs
+++ b/crates/buzz-relay/src/handlers/count.rs
@@ -7,8 +7,8 @@ use tracing::warn;
use crate::connection::{AuthState, ConnectionState};
use crate::handlers::req::{
- event_visible_to_reader, filter_can_match_persona_shared_kinds,
- filter_can_match_result_gated_kinds, result_gated_count_safe_for_pushdown,
+ event_visible_to_reader, filter_can_match_result_gated_kinds,
+ filter_can_match_shared_gated_kinds, result_gated_count_safe_for_pushdown,
};
use crate::protocol::RelayMessage;
use crate::state::AppState;
@@ -103,11 +103,11 @@ pub async fn handle_count(
// fast-path count_events() cannot be used because it doesn't do
// per-event author filtering.
let needs_author_only_filtering = super::req::filter_can_match_author_only_kinds(filter);
- // Determine if this filter can match kind 30175 (persona) — if so, the
- // fast-path must be bypassed because it has no per-event shared-tag check.
- // A fast count over 30175 would include foreign unshared persona events,
- // leaking the existence of private agent activity.
- let needs_persona_filtering = filter_can_match_persona_shared_kinds(filter);
+ // Determine if this filter can match a shared-gated kind (30175, 30178)
+ // — if so, the fast path must be bypassed because it has no per-event
+ // shared-tag check. A fast count over those kinds would include foreign
+ // unshared events, leaking the existence of private agent activity.
+ let needs_shared_gate_filtering = filter_can_match_shared_gated_kinds(filter);
// Determine if this filter can match result-gated kinds (44200, 30622)
// that require a per-event owner check. When the fast SQL path would
// count matching rows without calling reader_authorized_for_event, a
@@ -157,10 +157,10 @@ pub async fn handle_count(
conn.tenant.community(),
)
.await;
- // Persona visibility pushdown: pre-filter the fallback query_events
- // candidate page before ORDER/LIMIT.
- if needs_persona_filtering {
- query.persona_reader = Some(pubkey_bytes.clone());
+ // Shared-gated visibility pushdown: pre-filter the fallback
+ // query_events candidate page before ORDER/LIMIT.
+ if needs_shared_gate_filtering {
+ query.shared_gated_reader = Some(pubkey_bytes.clone());
}
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
!authors.is_empty()
@@ -171,7 +171,7 @@ pub async fn handle_count(
if super::req::filter_fully_pushable(filter)
&& (!needs_author_only_filtering || author_is_self)
&& !needs_result_gated_filtering
- && !needs_persona_filtering
+ && !needs_shared_gate_filtering
{
match state.db.count_events_routed("count_req", &query).await {
Ok(n) => total += n as u64,
@@ -230,9 +230,9 @@ pub async fn handle_count(
)
.await;
query.channel_ids = Some(accessible_channels.to_vec());
- // Persona visibility pushdown for the fallback query_events path.
- if needs_persona_filtering {
- query.persona_reader = Some(pubkey_bytes.clone());
+ // Shared-gated visibility pushdown for the fallback query_events path.
+ if needs_shared_gate_filtering {
+ query.shared_gated_reader = Some(pubkey_bytes.clone());
}
let author_is_self = filter.authors.as_ref().is_some_and(|authors| {
@@ -244,7 +244,7 @@ pub async fn handle_count(
if super::req::filter_fully_pushable(filter)
&& (!needs_author_only_filtering || author_is_self)
&& !needs_result_gated_filtering
- && !needs_persona_filtering
+ && !needs_shared_gate_filtering
{
query.limit = None; // COUNT doesn't need a row limit
match state.db.count_events_routed("count_req", &query).await {
diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs
index 88dd5f5180..a9cdffcdec 100644
--- a/crates/buzz-relay/src/handlers/event.rs
+++ b/crates/buzz-relay/src/handlers/event.rs
@@ -7,7 +7,7 @@ use tracing::{debug, error, info, warn};
use buzz_core::event::StoredEvent;
use buzz_core::kind::{
- event_kind_u32, is_ephemeral, is_unshared_persona_event, AUTHOR_ONLY_KINDS,
+ event_kind_u32, is_ephemeral, is_unshared_gated_event, AUTHOR_ONLY_KINDS,
KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE,
};
use buzz_core::observer::{
@@ -151,10 +151,10 @@ pub async fn filter_fanout_by_access(
matches
};
- // Persona shared-read gate (fan-out): kind 30175 events fan out to all
- // connections only when carrying ["shared","true"]. Unshared personas
- // are delivered only to the author's own connections, matching REQ semantics.
- let matches = if buzz_core::kind::is_persona_shared_kind(event_kind_u32(&stored_event.event)) {
+ // Shared-read gate (fan-out): SHARED_GATED_KINDS events fan out to all
+ // connections only when carrying ["shared","true"]. Unshared ones are
+ // delivered only to the author's own connections, matching REQ semantics.
+ let matches = if buzz_core::kind::is_shared_gated_kind(event_kind_u32(&stored_event.event)) {
let author = stored_event.event.pubkey.to_bytes();
matches
.into_iter()
@@ -167,7 +167,7 @@ pub async fn filter_fanout_by_access(
return true;
}
// Foreign connection: allowed only if the event is shared.
- !is_unshared_persona_event(&stored_event.event, &pk)
+ !is_unshared_gated_event(&stored_event.event, &pk)
})
.collect()
} else {
diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs
index ee644d5a9b..39ecbe18e4 100644
--- a/crates/buzz-relay/src/handlers/ingest.rs
+++ b/crates/buzz-relay/src/handlers/ingest.rs
@@ -31,9 +31,9 @@ use buzz_core::kind::{
KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT,
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF,
KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED,
- KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS,
- KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE,
- RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE,
+ KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE,
+ KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER,
+ RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE,
};
use buzz_core::tenant::TenantContext;
use buzz_core::verification::verify_event;
@@ -214,7 +214,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite),
KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM
| KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT
- | super::push_lease::KIND_PUSH_LEASE => {
+ | KIND_TEAM_CATALOG | super::push_lease::KIND_PUSH_LEASE => {
Ok(Scope::UsersWrite)
}
// NIP-AM: agent turn metrics are agent-authored global events (encrypted to owner).
@@ -419,10 +419,12 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
| KIND_AGENT_PROFILE
// NIP-AP: persona definitions (30175): owner-authored, keyed by (pubkey, kind, d_tag).
| KIND_PERSONA
- // NIP-AP: team (30176) + managed-agent (30177) definitions: owner-authored,
- // keyed by (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them.
+ // NIP-AP: team (30176) + managed-agent (30177) definitions and the
+ // team-catalog projection (30178): owner-authored, keyed by
+ // (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them.
| KIND_TEAM
| KIND_MANAGED_AGENT
+ | KIND_TEAM_CATALOG
// NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
// Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag).
| KIND_GIT_REPO_ANNOUNCEMENT
@@ -1029,37 +1031,27 @@ fn validate_engram_envelope(event: &Event) -> Result<(), String> {
Ok(())
}
-/// Validate the envelope of a kind:30175 persona event.
-///
-/// Enforces:
-/// * exactly one `d` tag with a non-empty value matching the slug grammar
-/// `^[a-z0-9][a-z0-9_-]{0,63}$`.
-/// * at most one `shared` tag; if present, its value must be exactly `"true"`.
+/// Enforce the `shared`-tag shape shared by every kind in
+/// [`buzz_core::kind::SHARED_GATED_KINDS`]: at most one `shared` tag, and if
+/// present it must be exactly `["shared", "true"]`.
///
-/// Without the `d`-tag check, an empty d-tag collapses every persona into the
-/// `(pubkey, 30175, "")` slot — last-write-wins data loss.
+/// This ensures no ambiguous heads: either an event has no `shared` tag
+/// (author-only) or exactly `["shared", "true"]` (community-readable). Any
+/// other value (`"false"`, `"1"`, extra elements, duplicate tags) is rejected
+/// at ingest so read-path helpers — including the SQL-level `tags @>
+/// '[["shared","true"]]'` containment clause, which would otherwise match a
+/// three-element superset — can treat stored events as unambiguously one or the
+/// other.
///
-/// The `shared` tag rule ensures no ambiguous heads: either an event has no
-/// `shared` tag (author-only) or exactly `["shared", "true"]` (community-
-/// readable). Any other value (`"false"`, `"1"`, extra tags) is rejected at
-/// ingest so read-path helpers can treat stored events as unambiguously one or
-/// the other.
-fn validate_persona_envelope(event: &Event) -> Result<(), String> {
- let mut d_tags: Vec<&str> = Vec::new();
+/// `label` names the kind in error messages (e.g. `"persona event"`).
+fn validate_shared_tag(event: &Event, label: &str) -> Result<(), String> {
let mut shared_count = 0usize;
for tag in event.tags.iter() {
let parts = tag.as_slice();
- if parts.len() >= 2 && parts[0].as_str() == "d" {
- d_tags.push(&parts[1]);
- }
if !parts.is_empty() && parts[0].as_str() == "shared" {
- // Exact shape required: ["shared", "true"] — exactly two elements,
- // second element exactly "true". Extra elements are rejected so that
- // a three-element tag like ["shared","true","extra"] cannot be stored
- // and later misread as shared by the SQL-level visibility clause.
if parts.len() != 2 || parts[1].as_str() != "true" {
return Err(format!(
- "persona event `shared` tag must be exactly [\"shared\",\"true\"] (got {:?})",
+ "{label} `shared` tag must be exactly [\"shared\",\"true\"] (got {:?})",
parts.iter().map(|s| s.as_str()).collect::>()
));
}
@@ -1068,43 +1060,106 @@ fn validate_persona_envelope(event: &Event) -> Result<(), String> {
}
if shared_count > 1 {
return Err(format!(
- "persona event must have at most one `shared` tag (got {shared_count})"
+ "{label} must have at most one `shared` tag (got {shared_count})"
));
}
+ Ok(())
+}
+
+/// Return the event's single `d` tag value, requiring exactly one tag whose
+/// value is non-empty, at most 64 characters, and free of Unicode control
+/// characters and whitespace.
+///
+/// Without this check an empty `d` tag collapses every event of the kind into
+/// the `(pubkey, kind, "")` slot — last-write-wins data loss. The character
+/// bound keeps the value usable as a NIP-33 coordinate (`::`)
+/// and as a log field: an embedded newline or tab would break line-oriented
+/// consumers of both.
+///
+/// Tags are counted by their first element alone, so a valueless `["d"]`
+/// counts. Skipping it would let `["d"]` plus `["d", "team-1"]` pass the
+/// exactly-one rule, and a NIP-33 consumer that reads `["d"]` as an
+/// empty-valued first `d` tag would then address the event at `""` where this
+/// relay addresses it at `"team-1"`.
+///
+/// `label` names the kind in error messages (e.g. `"persona event"`).
+fn single_bounded_d_tag<'a>(event: &'a Event, label: &str) -> Result<&'a str, String> {
+ let d_tags: Vec
> = event
+ .tags
+ .iter()
+ .filter_map(|tag| {
+ let parts = tag.as_slice();
+ (parts.first().map(|name| name.as_str()) == Some("d"))
+ .then(|| parts.get(1).map(|value| value.as_str()))
+ })
+ .collect();
if d_tags.len() != 1 {
return Err(format!(
- "persona event must have exactly one `d` tag (got {})",
+ "{label} must have exactly one `d` tag (got {})",
d_tags.len()
));
}
- let d = d_tags[0];
+ let d = d_tags[0].unwrap_or_default();
if d.is_empty() {
- return Err("persona event `d` tag must not be empty".to_string());
+ return Err(format!("{label} `d` tag must not be empty"));
}
- // Slug grammar: ^[a-z0-9][a-z0-9_-]{0,63}$
- if d.len() > 64 {
+ let char_count = d.chars().count();
+ if char_count > 64 {
return Err(format!(
- "persona event `d` tag too long ({} chars, max 64)",
- d.len()
+ "{label} `d` tag too long ({char_count} chars, max 64)"
));
}
+ if d.chars().any(|c| c.is_control() || c.is_whitespace()) {
+ return Err(format!(
+ "{label} `d` tag must not contain control characters or whitespace"
+ ));
+ }
+ Ok(d)
+}
+
+/// Validate the envelope of a kind:30175 persona event.
+///
+/// Enforces the shared-gated `shared`-tag shape ([`validate_shared_tag`]) plus
+/// exactly one `d` tag matching the persona slug grammar
+/// `^[a-z0-9][a-z0-9_-]{0,63}$`.
+fn validate_persona_envelope(event: &Event) -> Result<(), String> {
+ const LABEL: &str = "persona event";
+ validate_shared_tag(event, LABEL)?;
+ let d = single_bounded_d_tag(event, LABEL)?;
+ // Slug grammar: ^[a-z0-9][a-z0-9_-]{0,63}$
let bytes = d.as_bytes();
if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() {
- return Err(
- "persona event `d` tag must start with a lowercase letter or digit".to_string(),
- );
+ return Err(format!(
+ "{LABEL} `d` tag must start with a lowercase letter or digit"
+ ));
}
if !bytes[1..]
.iter()
.all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
{
- return Err(
- "persona event `d` tag must match [a-z0-9_-] after the first character".to_string(),
- );
+ return Err(format!(
+ "{LABEL} `d` tag must match [a-z0-9_-] after the first character"
+ ));
}
Ok(())
}
+/// Validate the envelope of a kind:30178 team-catalog event.
+///
+/// Enforces the shared-gated `shared`-tag shape ([`validate_shared_tag`]) plus
+/// exactly one non-empty, bounded `d` tag.
+///
+/// Deliberately NOT the persona slug grammar: a team's `d` tag is its stable
+/// local id, which is either a UUID or a built-in identifier such as
+/// `builtin-team:welcome` — the colon is not slug-legal, and rewriting ids to
+/// fit would break NIP-33 addressing against the team's own kind:30176 head.
+fn validate_team_catalog_envelope(event: &Event) -> Result<(), String> {
+ const LABEL: &str = "team-catalog event";
+ validate_shared_tag(event, LABEL)?;
+ single_bounded_d_tag(event, LABEL)?;
+ Ok(())
+}
+
/// Validate that `content` is a syntactically plausible NIP-44 v2 ciphertext.
///
/// Checks:
@@ -2070,6 +2125,11 @@ async fn ingest_event_inner(
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
}
+ if kind_u32 == KIND_TEAM_CATALOG {
+ validate_team_catalog_envelope(&event)
+ .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
+ }
+
// Track pre-created channel UUID for compensation on insert failure.
let mut pre_created_channel: Option = None;
@@ -3597,6 +3657,24 @@ mod tests {
assert!(err.contains("`d` tag"), "got: {err}");
}
+ #[test]
+ fn persona_envelope_rejects_valueless_d_tag() {
+ // A lone ["d"] carries no value; it must fail as a missing value, not
+ // be skipped as though the event had no `d` tag at all.
+ let ev = make_persona(&[&["d"]]);
+ let err = validate_persona_envelope(&ev).unwrap_err();
+ assert!(err.contains("must not be empty"), "got: {err}");
+ }
+
+ #[test]
+ fn persona_envelope_rejects_valueless_plus_valued_d_tags() {
+ // Counting only tags with a value would see one `d` here and accept the
+ // event, breaking the exactly-one rule.
+ let ev = make_persona(&[&["d"], &["d", "slug-a"]]);
+ let err = validate_persona_envelope(&ev).unwrap_err();
+ assert!(err.contains("exactly one `d` tag"), "got: {err}");
+ }
+
#[test]
fn persona_envelope_rejects_too_long() {
let slug = "a".repeat(65);
@@ -3723,6 +3801,151 @@ mod tests {
);
}
+ // ─── team-catalog (30178) envelope tests ─────────────────────────────────
+
+ fn make_team_catalog(tags: &[&[&str]]) -> Event {
+ make_event_with_tags(
+ KIND_TEAM_CATALOG,
+ r#"{"v":1,"name":"Team","members":[]}"#,
+ tags,
+ )
+ }
+
+ #[test]
+ fn team_catalog_envelope_accepts_uuid_d_tag() {
+ let ev = make_team_catalog(&[&["d", "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"]]);
+ assert!(validate_team_catalog_envelope(&ev).is_ok());
+ }
+
+ #[test]
+ fn team_catalog_envelope_accepts_builtin_colon_d_tag() {
+ // Built-in team ids carry a colon (`builtin-team:welcome`), which the
+ // persona slug grammar forbids. The catalog `d` tag must accept them so
+ // a built-in team can be shared under its real local id.
+ let ev = make_team_catalog(&[&["d", "builtin-team:welcome"]]);
+ assert!(validate_team_catalog_envelope(&ev).is_ok());
+ }
+
+ #[test]
+ fn team_catalog_envelope_accepts_shared_true() {
+ let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "true"]]);
+ assert!(validate_team_catalog_envelope(&ev).is_ok());
+ }
+
+ #[test]
+ fn team_catalog_envelope_rejects_missing_d_tag() {
+ let ev = make_team_catalog(&[]);
+ let err = validate_team_catalog_envelope(&ev).unwrap_err();
+ assert!(err.contains("exactly one `d` tag"), "got: {err}");
+ }
+
+ #[test]
+ fn team_catalog_envelope_rejects_empty_d_tag() {
+ // An empty d-tag collapses every team into the (pubkey, 30178, "") slot.
+ let ev = make_team_catalog(&[&["d", ""]]);
+ let err = validate_team_catalog_envelope(&ev).unwrap_err();
+ assert!(err.contains("must not be empty"), "got: {err}");
+ }
+
+ #[test]
+ fn team_catalog_envelope_rejects_duplicate_d_tags() {
+ let ev = make_team_catalog(&[&["d", "team-1"], &["d", "team-2"]]);
+ let err = validate_team_catalog_envelope(&ev).unwrap_err();
+ assert!(err.contains("exactly one `d` tag"), "got: {err}");
+ }
+
+ #[test]
+ fn team_catalog_envelope_rejects_valueless_d_tag() {
+ // A lone ["d"] carries no value; it must fail as a missing value, not
+ // be skipped as though the event had no `d` tag at all.
+ let ev = make_team_catalog(&[&["d"]]);
+ let err = validate_team_catalog_envelope(&ev).unwrap_err();
+ assert!(err.contains("must not be empty"), "got: {err}");
+ }
+
+ #[test]
+ fn team_catalog_envelope_rejects_valueless_plus_valued_d_tags() {
+ // Counting only tags with a value would see one `d` here and accept the
+ // event. A NIP-33 consumer that reads ["d"] as an empty-valued first
+ // `d` tag would then address this event at "" where we address it at
+ // "team-1".
+ let ev = make_team_catalog(&[&["d"], &["d", "team-1"]]);
+ let err = validate_team_catalog_envelope(&ev).unwrap_err();
+ assert!(err.contains("exactly one `d` tag"), "got: {err}");
+ }
+
+ #[test]
+ fn team_catalog_envelope_bounds_d_tag_by_chars_not_bytes() {
+ // 64 multi-byte characters is 192 bytes; the documented bound is
+ // characters, so this must be accepted.
+ let d = "é".repeat(64);
+ assert!(d.len() > 64, "fixture must exceed the bound in bytes");
+ let ev = make_team_catalog(&[&["d", &d]]);
+ assert!(validate_team_catalog_envelope(&ev).is_ok());
+ }
+
+ #[test]
+ fn team_catalog_envelope_rejects_too_long_d_tag() {
+ let d = "a".repeat(65);
+ let ev = make_team_catalog(&[&["d", &d]]);
+ let err = validate_team_catalog_envelope(&ev).unwrap_err();
+ assert!(err.contains("too long"), "got: {err}");
+ }
+
+ #[test]
+ fn team_catalog_envelope_accepts_max_length_d_tag() {
+ let d = "a".repeat(64);
+ let ev = make_team_catalog(&[&["d", &d]]);
+ assert!(validate_team_catalog_envelope(&ev).is_ok());
+ }
+
+ #[test]
+ fn team_catalog_envelope_rejects_whitespace_d_tag() {
+ // A newline in the d-tag would break the NIP-33 coordinate and any
+ // line-oriented log consumer.
+ let ev = make_team_catalog(&[&["d", "team\n1"]]);
+ let err = validate_team_catalog_envelope(&ev).unwrap_err();
+ assert!(err.contains("control characters"), "got: {err}");
+ }
+
+ #[test]
+ fn team_catalog_envelope_rejects_shared_false() {
+ let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "false"]]);
+ let err = validate_team_catalog_envelope(&ev).unwrap_err();
+ assert!(err.contains("\"true\""), "got: {err}");
+ }
+
+ #[test]
+ fn team_catalog_envelope_rejects_shared_three_elements() {
+ // Same exact-shape rule as personas: a three-element tag would match the
+ // SQL containment clause `tags @> '[["shared","true"]]'` as a superset.
+ let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "true", "extra"]]);
+ let err = validate_team_catalog_envelope(&ev).unwrap_err();
+ assert!(err.contains("[\"shared\",\"true\"]"), "got: {err}");
+ }
+
+ #[test]
+ fn team_catalog_envelope_rejects_duplicate_shared_tags() {
+ let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "true"], &["shared", "true"]]);
+ let err = validate_team_catalog_envelope(&ev).unwrap_err();
+ assert!(err.contains("at most one"), "got: {err}");
+ }
+
+ #[test]
+ fn team_catalog_is_in_scope_allowlist() {
+ let dummy = make_dummy_event();
+ assert_eq!(
+ required_scope_for_kind(KIND_TEAM_CATALOG, &dummy).unwrap(),
+ Scope::UsersWrite,
+ );
+ }
+
+ #[test]
+ fn team_catalog_is_global_only() {
+ assert!(is_global_only_kind(KIND_TEAM_CATALOG));
+ assert!(!requires_h_channel_scope(KIND_TEAM_CATALOG));
+ }
+
// ─── agent_turn_metric envelope tests ────────────────────────────────────
/// Build an event for kind:44200 with the given tags and content.
diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs
index 51400452d7..35fbf0c892 100644
--- a/crates/buzz-relay/src/handlers/req.rs
+++ b/crates/buzz-relay/src/handlers/req.rs
@@ -7,8 +7,8 @@ use tracing::{debug, warn};
use buzz_core::filter::filters_match;
use buzz_core::kind::{
- is_unshared_persona_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC,
- KIND_DM_VISIBILITY, KIND_PERSONA, P_GATED_KINDS, RESULT_GATED_KINDS,
+ is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC,
+ KIND_DM_VISIBILITY, P_GATED_KINDS, RESULT_GATED_KINDS, SHARED_GATED_KINDS,
};
use buzz_core::tenant::TenantContext;
use buzz_db::EventQuery;
@@ -290,11 +290,11 @@ pub async fn handle_req(
let mut params =
filter_to_query_params(filter, per_filter_channel, conn.tenant.community());
apply_access_scope_to_query(&mut params, per_filter_channel, &accessible_channels);
- // Persona visibility pushdown: set reader bytes so query_events appends
- // the SQL visibility clause before ORDER/LIMIT, preventing newer private
- // personas from starving older shared ones off the page.
- if filter_can_match_persona_shared_kinds(filter) {
- params.persona_reader = Some(pubkey_bytes.clone());
+ // Shared-gated visibility pushdown: set reader bytes so query_events
+ // appends the SQL visibility clause before ORDER/LIMIT, preventing
+ // newer private events from starving older shared ones off the page.
+ if filter_can_match_shared_gated_kinds(filter) {
+ params.shared_gated_reader = Some(pubkey_bytes.clone());
}
(idx, per_filter_channel, params)
})
@@ -1137,19 +1137,20 @@ pub(crate) fn filter_can_match_author_only_kinds(filter: &Filter) -> bool {
})
}
-/// Returns `true` if the filter CAN match kind 30175 (persona) — meaning it
-/// either has no `kinds` constraint (wildcard) or explicitly includes 30175.
+/// Returns `true` if the filter CAN match any kind in [`SHARED_GATED_KINDS`] —
+/// meaning it either has no `kinds` constraint (wildcard) or explicitly includes
+/// one of them.
///
/// Used by the COUNT handler to force the per-event fallback path, which calls
-/// `is_unshared_persona_event` on each row. The fast SQL `count_events()` path
+/// `is_unshared_gated_event` on each row. The fast SQL `count_events()` path
/// has no per-event access check, so it would over-count foreign unshared
-/// persona events — leaking the existence of persona activity even without
-/// returning content.
-pub(crate) fn filter_can_match_persona_shared_kinds(filter: &Filter) -> bool {
- filter
- .kinds
- .as_ref()
- .is_none_or(|ks| ks.iter().any(|k| k.as_u16() as u32 == KIND_PERSONA))
+/// events — leaking the existence of private persona/team-catalog activity even
+/// without returning content.
+pub(crate) fn filter_can_match_shared_gated_kinds(filter: &Filter) -> bool {
+ filter.kinds.as_ref().is_none_or(|ks| {
+ ks.iter()
+ .any(|k| SHARED_GATED_KINDS.contains(&(k.as_u16() as u32)))
+ })
}
/// Returns `true` if the filter CAN match result-gated kinds — meaning it
@@ -1208,8 +1209,9 @@ pub(crate) fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes:
///
/// 1. **Author-only kinds** (`AUTHOR_ONLY_KINDS`, e.g. kind 30300/30350): only
/// the author may read their own events.
-/// 2. **Persona shared-gate** (kind 30175 without `["shared","true"]`): the
-/// event is only visible to the author unless explicitly opted into sharing.
+/// 2. **Shared-gate** (`SHARED_GATED_KINDS`, e.g. kind 30175/30178 without
+/// `["shared","true"]`): the event is only visible to the author unless
+/// explicitly opted into sharing.
/// 3. **Result-gated kinds** (kind 44200/30622 etc.): `reader_authorized_for_event`
/// carries the per-event ownership check.
///
@@ -1223,7 +1225,7 @@ pub(crate) fn event_visible_to_reader(event: &nostr::Event, requester_pubkey_byt
if is_author_only_event(event, requester_pubkey_bytes) {
return false;
}
- if is_unshared_persona_event(event, requester_pubkey_bytes) {
+ if is_unshared_gated_event(event, requester_pubkey_bytes) {
return false;
}
let requester_pubkey_hex = hex::encode(requester_pubkey_bytes);
diff --git a/crates/buzz-test-client/tests/e2e_persona.rs b/crates/buzz-test-client/tests/e2e_persona.rs
index b3b1f7f6b2..4f37e22e16 100644
--- a/crates/buzz-test-client/tests/e2e_persona.rs
+++ b/crates/buzz-test-client/tests/e2e_persona.rs
@@ -1324,7 +1324,7 @@ async fn test_persona_http_query_cross_author_gate() {
///
/// A foreign authenticated caller counting `{kinds:[30175],authors:[victim]}`
/// must count only shared heads — not unshared ones — on both the fast SQL
-/// path (prevented by `needs_persona_filtering`) and the fallback path.
+/// path (prevented by `needs_shared_gate_filtering`) and the fallback path.
#[tokio::test]
#[ignore]
async fn test_persona_http_count_cross_author_gate() {
@@ -1403,7 +1403,7 @@ async fn test_persona_http_count_cross_author_gate() {
/// event is returned.
///
/// Verifies at `312014d5e`: this test fails there because `query_events` did
-/// not have the `persona_reader` SQL clause and the private rows starved the
+/// not have the `shared_gated_reader` SQL clause and the private rows starved the
/// shared one off the page.
#[tokio::test]
#[ignore]
diff --git a/crates/buzz-test-client/tests/e2e_team_catalog.rs b/crates/buzz-test-client/tests/e2e_team_catalog.rs
new file mode 100644
index 0000000000..ce313d1fe9
--- /dev/null
+++ b/crates/buzz-test-client/tests/e2e_team_catalog.rs
@@ -0,0 +1,484 @@
+//! End-to-end tests for kind:30178 team-catalog events (NIP-AP).
+//!
+//! Kind 30178 is the shareable projection of a team. It joins kind:30175 in
+//! `SHARED_GATED_KINDS`, so these tests assert the wire behaviour of that gate
+//! at every read chokepoint (REQ, `ids` lookup, COUNT, live fan-out) plus the
+//! ingest envelope rules that make the gate sound:
+//! - Exactly one non-empty, bounded `d` tag — the team's stable local id, which
+//! may contain a colon (`builtin-team:welcome`) unlike a persona slug.
+//! - `shared`, if present, is exactly `["shared", "true"]`.
+//!
+//! # Running
+//!
+//! Start the relay, then run:
+//!
+//! ```text
+//! RELAY_URL=ws://localhost:3000 cargo test --test e2e_team_catalog -- --ignored
+//! ```
+
+use std::time::Duration;
+
+use buzz_test_client::{BuzzTestClient, RelayMessage};
+use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};
+
+const TEAM_CATALOG_KIND: u16 = 30178;
+
+fn relay_url() -> String {
+ std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
+}
+
+fn sub_id(name: &str) -> String {
+ format!("e2e-team-catalog-{name}-{}", uuid::Uuid::new_v4())
+}
+
+fn catalog_content(name: &str) -> String {
+ serde_json::json!({ "v": 1, "name": name, "members": [] }).to_string()
+}
+
+/// Build a kind:30178 event, optionally carrying the `["shared","true"]` opt-in.
+fn catalog_event(keys: &Keys, d_tag: &str, shared: bool) -> nostr::Event {
+ catalog_event_at(keys, d_tag, shared, Timestamp::now().as_secs())
+}
+
+/// Same as [`catalog_event`] with an explicit `created_at`, so NIP-33 head
+/// ordering is deterministic instead of resolved by event-id tie-break.
+fn catalog_event_at(keys: &Keys, d_tag: &str, shared: bool, created_at: u64) -> nostr::Event {
+ let mut tags = vec![Tag::parse(["d", d_tag]).unwrap()];
+ if shared {
+ tags.push(Tag::parse(["shared", "true"]).unwrap());
+ }
+ EventBuilder::new(
+ Kind::Custom(TEAM_CATALOG_KIND),
+ catalog_content("Test Team"),
+ )
+ .tags(tags)
+ .custom_created_at(Timestamp::from(created_at))
+ .sign_with_keys(keys)
+ .unwrap()
+}
+
+fn author_filter(author: &Keys) -> Filter {
+ Filter::new()
+ .kind(Kind::Custom(TEAM_CATALOG_KIND))
+ .author(author.public_key())
+}
+
+fn coordinate_filter(author: &Keys, d_tag: &str) -> Filter {
+ author_filter(author).custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag])
+}
+
+fn d_tag_of(event: &nostr::Event) -> Option<&str> {
+ event.tags.iter().find_map(|t| {
+ let parts = t.as_slice();
+ if parts.first().map(|p| p.as_str()) != Some("d") {
+ return None;
+ }
+ Some(parts.get(1)?.as_str())
+ })
+}
+
+/// The author's own unshared projection round-trips at its NIP-33 coordinate.
+///
+/// The `d` tag is a UUID, matching the desktop team id — proof the envelope does
+/// NOT apply the persona slug grammar.
+#[tokio::test]
+#[ignore]
+async fn test_team_catalog_publish_and_query_own_unshared() {
+ let url = relay_url();
+ let keys = Keys::generate();
+ let d_tag = uuid::Uuid::new_v4().to_string();
+
+ let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
+ let event = catalog_event(&keys, &d_tag, false);
+ let event_id = event.id;
+ let ok = client.send_event(event).await.expect("send catalog");
+ assert!(ok.accepted, "relay rejected catalog event: {}", ok.message);
+
+ let sid = sub_id("own-unshared");
+ client
+ .subscribe(&sid, vec![coordinate_filter(&keys, &d_tag)])
+ .await
+ .expect("subscribe");
+ let events = client
+ .collect_until_eose(&sid, Duration::from_secs(5))
+ .await
+ .expect("collect");
+
+ assert_eq!(events.len(), 1, "author must see own unshared projection");
+ assert_eq!(events[0].id, event_id);
+
+ client.disconnect().await.expect("disconnect");
+}
+
+/// A built-in team id (`builtin-team:welcome`) is accepted as the `d` tag.
+///
+/// The colon is illegal in a persona slug; rewriting the id to fit would break
+/// NIP-33 addressing against the team's own kind:30176 head.
+#[tokio::test]
+#[ignore]
+async fn test_team_catalog_accepts_builtin_colon_d_tag() {
+ let url = relay_url();
+ let keys = Keys::generate();
+ let d_tag = format!("builtin-team:{}", &uuid::Uuid::new_v4().to_string()[..8]);
+
+ let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
+ let ok = client
+ .send_event(catalog_event(&keys, &d_tag, true))
+ .await
+ .expect("send catalog");
+ assert!(
+ ok.accepted,
+ "relay rejected colon-bearing team id: {}",
+ ok.message
+ );
+
+ client.disconnect().await.expect("disconnect");
+}
+
+/// Ingest refuses an empty `d` tag: generic NIP-33 storage maps it to the empty
+/// coordinate, collapsing every team into one `(pubkey, 30178, "")` slot.
+#[tokio::test]
+#[ignore]
+async fn test_team_catalog_rejects_empty_d_tag() {
+ let url = relay_url();
+ let keys = Keys::generate();
+
+ let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
+ let ok = client
+ .send_event(catalog_event(&keys, "", false))
+ .await
+ .expect("send catalog");
+ assert!(!ok.accepted, "empty d-tag must be rejected");
+ assert!(
+ ok.message.contains("invalid:"),
+ "expected an `invalid:` refusal, got: {}",
+ ok.message
+ );
+
+ client.disconnect().await.expect("disconnect");
+}
+
+/// Ingest refuses a valueless `["d"]` tag alongside a valued one. Counting only
+/// tags that carry a value would see exactly one `d` here and accept the event;
+/// a NIP-33 consumer that reads `["d"]` as an empty-valued first `d` tag would
+/// then address the event at `""` where this relay addresses it at the team id.
+#[tokio::test]
+#[ignore]
+async fn test_team_catalog_rejects_valueless_plus_valued_d_tags() {
+ let url = relay_url();
+ let keys = Keys::generate();
+ let d_tag = uuid::Uuid::new_v4().to_string();
+
+ let event = EventBuilder::new(
+ Kind::Custom(TEAM_CATALOG_KIND),
+ catalog_content("Two d tags"),
+ )
+ .tags(vec![
+ Tag::parse(["d"]).unwrap(),
+ Tag::parse(["d", d_tag.as_str()]).unwrap(),
+ ])
+ .sign_with_keys(&keys)
+ .unwrap();
+
+ let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
+ let ok = client.send_event(event).await.expect("send catalog");
+ assert!(
+ !ok.accepted,
+ "a valueless `d` tag must count toward the exactly-one rule"
+ );
+ assert!(
+ ok.message.contains("invalid:"),
+ "expected an `invalid:` refusal, got: {}",
+ ok.message
+ );
+
+ client.disconnect().await.expect("disconnect");
+}
+
+/// Ingest refuses a malformed `shared` tag. A three-element tag would satisfy
+/// the SQL containment clause `tags @> '[["shared","true"]]'` as a superset
+/// while the in-process gate reads it as unshared — the two layers must agree,
+/// so such an event can never be stored.
+#[tokio::test]
+#[ignore]
+async fn test_team_catalog_rejects_three_element_shared_tag() {
+ let url = relay_url();
+ let keys = Keys::generate();
+ let d_tag = uuid::Uuid::new_v4().to_string();
+
+ let event = EventBuilder::new(
+ Kind::Custom(TEAM_CATALOG_KIND),
+ catalog_content("Malformed"),
+ )
+ .tags(vec![
+ Tag::parse(["d", d_tag.as_str()]).unwrap(),
+ Tag::parse(["shared", "true", "extra"]).unwrap(),
+ ])
+ .sign_with_keys(&keys)
+ .unwrap();
+
+ let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
+ let ok = client.send_event(event).await.expect("send catalog");
+ assert!(!ok.accepted, "three-element shared tag must be rejected");
+ assert!(
+ ok.message.contains("invalid:"),
+ "expected an `invalid:` refusal, got: {}",
+ ok.message
+ );
+
+ client.disconnect().await.expect("disconnect");
+}
+
+/// REQ historical delivery: a foreign reader receives only shared projections,
+/// while the author receives both of their own.
+#[tokio::test]
+#[ignore]
+async fn test_team_catalog_foreign_sees_only_shared() {
+ let url = relay_url();
+ let author_keys = Keys::generate();
+ let foreign_keys = Keys::generate();
+
+ let d_unshared = format!("priv-{}", uuid::Uuid::new_v4());
+ let d_shared = format!("pub-{}", uuid::Uuid::new_v4());
+
+ let mut author = BuzzTestClient::connect(&url, &author_keys)
+ .await
+ .expect("connect author");
+ let shared_event = catalog_event(&author_keys, &d_shared, true);
+ let shared_id = shared_event.id;
+ let ok = author
+ .send_event(catalog_event(&author_keys, &d_unshared, false))
+ .await
+ .expect("send unshared");
+ assert!(ok.accepted, "unshared ingest rejected: {}", ok.message);
+ let ok = author.send_event(shared_event).await.expect("send shared");
+ assert!(ok.accepted, "shared ingest rejected: {}", ok.message);
+
+ let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
+ .await
+ .expect("connect foreign");
+ let sid = sub_id("fg-all");
+ foreign
+ .subscribe(&sid, vec![author_filter(&author_keys)])
+ .await
+ .expect("subscribe");
+ let events = foreign
+ .collect_until_eose(&sid, Duration::from_secs(5))
+ .await
+ .expect("collect");
+
+ assert!(
+ !events
+ .iter()
+ .any(|e| d_tag_of(e) == Some(d_unshared.as_str())),
+ "foreign reader must NOT see the unshared projection"
+ );
+ assert!(
+ events.iter().any(|e| e.id == shared_id),
+ "foreign reader must see the shared projection"
+ );
+
+ let sid_author = sub_id("auth-all");
+ author
+ .subscribe(&sid_author, vec![author_filter(&author_keys)])
+ .await
+ .expect("subscribe author");
+ let author_events = author
+ .collect_until_eose(&sid_author, Duration::from_secs(5))
+ .await
+ .expect("collect author");
+ assert!(
+ author_events.len() >= 2,
+ "author must see both own projections, got {}",
+ author_events.len()
+ );
+
+ author.disconnect().await.expect("disconnect author");
+ foreign.disconnect().await.expect("disconnect foreign");
+}
+
+/// Knowing an event id does NOT grant access: `{ids:[unshared]}` returns nothing
+/// to a foreign reader.
+#[tokio::test]
+#[ignore]
+async fn test_team_catalog_ids_lookup_unshared_returns_nothing_to_foreign() {
+ let url = relay_url();
+ let author_keys = Keys::generate();
+ let foreign_keys = Keys::generate();
+
+ let event = catalog_event(&author_keys, &uuid::Uuid::new_v4().to_string(), false);
+ let event_id = event.id;
+
+ let mut author = BuzzTestClient::connect(&url, &author_keys)
+ .await
+ .expect("connect author");
+ let ok = author.send_event(event).await.expect("send");
+ assert!(ok.accepted, "ingest rejected: {}", ok.message);
+ author.disconnect().await.expect("disconnect author");
+
+ let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
+ .await
+ .expect("connect foreign");
+ let sid = sub_id("ids-unshared");
+ foreign
+ .subscribe(&sid, vec![Filter::new().id(event_id)])
+ .await
+ .expect("subscribe");
+ let events = foreign
+ .collect_until_eose(&sid, Duration::from_secs(5))
+ .await
+ .expect("collect");
+
+ assert!(
+ events.is_empty(),
+ "ids-lookup of an unshared projection must return nothing, got {:?}",
+ events.iter().map(|e| e.id).collect::>()
+ );
+
+ foreign.disconnect().await.expect("disconnect foreign");
+}
+
+/// COUNT must take the per-event fallback for kind:30178 so the aggregate does
+/// not leak the existence of unshared projections.
+#[tokio::test]
+#[ignore]
+async fn test_team_catalog_count_excludes_foreign_unshared() {
+ let url = relay_url();
+ let author_keys = Keys::generate();
+ let foreign_keys = Keys::generate();
+
+ let mut author = BuzzTestClient::connect(&url, &author_keys)
+ .await
+ .expect("connect author");
+ let ok = author
+ .send_event(catalog_event(
+ &author_keys,
+ &uuid::Uuid::new_v4().to_string(),
+ false,
+ ))
+ .await
+ .expect("send unshared");
+ assert!(ok.accepted, "unshared rejected: {}", ok.message);
+ let ok = author
+ .send_event(catalog_event(
+ &author_keys,
+ &uuid::Uuid::new_v4().to_string(),
+ true,
+ ))
+ .await
+ .expect("send shared");
+ assert!(ok.accepted, "shared rejected: {}", ok.message);
+ author.disconnect().await.expect("disconnect author");
+
+ let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
+ .await
+ .expect("connect foreign");
+ let sid = sub_id("count");
+ let count_msg = serde_json::json!(["COUNT", sid, author_filter(&author_keys)]);
+ foreign.send_raw(&count_msg).await.expect("send COUNT");
+
+ let count = match foreign.recv_event(Duration::from_secs(5)).await {
+ Ok(RelayMessage::Count { count, .. }) => count,
+ Ok(RelayMessage::Closed { message, .. }) => panic!("COUNT closed unexpectedly: {message}"),
+ Ok(other) => panic!("unexpected relay message for COUNT: {other:?}"),
+ Err(e) => panic!("unexpected error for COUNT: {e}"),
+ };
+ assert_eq!(
+ count, 1,
+ "foreign COUNT must see only the shared projection, got {count}"
+ );
+
+ foreign.disconnect().await.expect("disconnect foreign");
+}
+
+/// Live fan-out honours the gate, and unsharing (a NIP-33 replacement that drops
+/// the `shared` tag) retracts the projection from foreign readers.
+#[tokio::test]
+#[ignore]
+async fn test_team_catalog_live_fanout_and_unshare_retracts() {
+ let url = relay_url();
+ let author_keys = Keys::generate();
+ let foreign_keys = Keys::generate();
+
+ let d_tag = uuid::Uuid::new_v4().to_string();
+ let now = Timestamp::now().as_secs();
+ let (t0, t1, t2) = (now.saturating_sub(2), now.saturating_sub(1), now);
+
+ // Subscribe BEFORE publishing, scoped to this author so parallel tests
+ // publishing their own 30178s cannot trip the leak assertion.
+ let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
+ .await
+ .expect("connect foreign");
+ let sid = sub_id("fanout");
+ foreign
+ .subscribe(&sid, vec![author_filter(&author_keys)])
+ .await
+ .expect("subscribe");
+ let _ = foreign
+ .collect_until_eose(&sid, Duration::from_secs(5))
+ .await
+ .expect("drain eose");
+
+ let mut author = BuzzTestClient::connect(&url, &author_keys)
+ .await
+ .expect("connect author");
+
+ // Unshared publish must NOT reach the foreign connection.
+ let ok = author
+ .send_event(catalog_event_at(&author_keys, &d_tag, false, t0))
+ .await
+ .expect("send unshared");
+ assert!(ok.accepted, "unshared rejected: {}", ok.message);
+ match foreign.recv_event(Duration::from_millis(750)).await {
+ Err(buzz_test_client::TestClientError::Timeout) => {}
+ Ok(RelayMessage::Event { event, .. }) if event.kind == Kind::Custom(TEAM_CATALOG_KIND) => {
+ panic!("unshared projection leaked to foreign live subscription");
+ }
+ Ok(_) => {}
+ Err(e) => panic!("unexpected error awaiting fan-out: {e}"),
+ }
+
+ // Shared replacement MUST reach it.
+ let shared_event = catalog_event_at(&author_keys, &d_tag, true, t1);
+ let shared_id = shared_event.id;
+ let ok = author.send_event(shared_event).await.expect("send shared");
+ assert!(ok.accepted, "shared rejected: {}", ok.message);
+ let delivered = loop {
+ match foreign.recv_event(Duration::from_secs(5)).await {
+ Ok(RelayMessage::Event { event, .. }) if event.id == shared_id => break true,
+ Ok(_) => continue,
+ Err(buzz_test_client::TestClientError::Timeout) => break false,
+ Err(e) => panic!("unexpected error awaiting shared fan-out: {e}"),
+ }
+ };
+ assert!(
+ delivered,
+ "shared projection must fan out to foreign readers"
+ );
+
+ // Unshare: replace at the same coordinate without the tag. Subsequent
+ // foreign REQs must return nothing.
+ let ok = author
+ .send_event(catalog_event_at(&author_keys, &d_tag, false, t2))
+ .await
+ .expect("send unshare");
+ assert!(ok.accepted, "unshare rejected: {}", ok.message);
+
+ let sid_post = sub_id("post-unshare");
+ foreign
+ .subscribe(&sid_post, vec![coordinate_filter(&author_keys, &d_tag)])
+ .await
+ .expect("subscribe post");
+ let after = foreign
+ .collect_until_eose(&sid_post, Duration::from_secs(5))
+ .await
+ .expect("collect post");
+ assert!(
+ after.is_empty(),
+ "unsharing must retract the projection from foreign readers, got {} event(s)",
+ after.len()
+ );
+
+ author.disconnect().await.expect("disconnect author");
+ foreign.disconnect().await.expect("disconnect foreign");
+}
diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs
index a4003329bc..cab5fababc 100644
--- a/desktop/src-tauri/src/commands/personas/pending.rs
+++ b/desktop/src-tauri/src/commands/personas/pending.rs
@@ -75,11 +75,11 @@ pub(super) fn prepare_persona_publication(
}
fn retained_persona_is_shared(row: Option<&RetainedEvent>) -> bool {
- use buzz_core_pkg::kind::persona_event_is_shared;
+ use buzz_core_pkg::kind::event_is_shared;
use nostr::JsonUtil;
row.and_then(|retained| nostr::Event::from_json(&retained.raw_event).ok())
- .is_some_and(|event| persona_event_is_shared(&event))
+ .is_some_and(|event| event_is_shared(&event))
}
/// Project each persona's catalog visibility from the active relay+owner
diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs
index d9fe6acdb9..ee8e0d8b10 100644
--- a/desktop/src-tauri/src/event_sync.rs
+++ b/desktop/src-tauri/src/event_sync.rs
@@ -165,7 +165,7 @@ fn migrate_personas_in_dir_at(
scoped_record.shared = existing
.as_ref()
.and_then(|row| nostr::Event::from_json(&row.raw_event).ok())
- .is_some_and(|event| buzz_core_pkg::kind::persona_event_is_shared(&event));
+ .is_some_and(|event| buzz_core_pkg::kind::event_is_shared(&event));
let event = build_persona_event(&scoped_record)
.map_err(|e| format!("failed to build event for '{}': {e}", record.display_name))?
.custom_created_at(monotonic_created_at(
diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs
index ea61a811db..6afc18a501 100644
--- a/desktop/src-tauri/src/managed_agents/persona_events.rs
+++ b/desktop/src-tauri/src/managed_agents/persona_events.rs
@@ -5,7 +5,7 @@
use std::collections::BTreeMap;
-use buzz_core_pkg::kind::{persona_event_is_shared, KIND_PERSONA};
+use buzz_core_pkg::kind::{event_is_shared, KIND_PERSONA};
use nostr::{EventBuilder, Kind, Tag};
use serde::{Deserialize, Serialize};
@@ -192,7 +192,7 @@ pub fn persona_from_event(event: &nostr::Event) -> Result:`. Unsharing is distinct from deletion — it is a newer valid head at the same coordinate published *without* the `shared` tag, which keeps the projection readable to its author while retracting it from foreign readers.
+
## Relationships to other NIPs
### NIP-AE (Agent Engrams)
@@ -216,6 +218,29 @@ surface per-event errors.
Agents spawned from a persona carry [NIP-OA](NIP-OA.md) owner attestation — an `auth` tag proving that `pubkey_o` authorized the agent's key. The persona event itself does not contain attestation; it is the *definition* from which attestation is issued at spawn time.
+## Team catalog projection: kind:30178
+
+Kind `30178` is the **shareable projection of a team**: owner-authored, parameterized replaceable, addressed by `(pubkey_o, 30178, d)` where `d` is the team's stable local id. Its `content` is a versioned JSON body carrying sanitized team fields plus ordered, *embedded* member definition projections. The content schema is defined by the client that publishes it; this section specifies only the envelope and the relay's contract.
+
+```jsonc
+{
+ "kind": 30178,
+ "pubkey": "",
+ "created_at": ,
+ "tags": [
+ ["d", ""],
+ ["shared", "true"] // optional; presence opts the projection into community reads
+ ],
+ "content": ""
+}
+```
+
+**Why a separate kind rather than a `shared` tag on the team event (kind:30176).** A team's members are `kind:30175` definitions, which are author-only unless individually shared — so a foreign reader of a shared team could never hydrate its members. Kind `30178` embeds the member projections instead of referencing them: the share is atomic, it covers built-in members that have no `30175` head at all, it is immune to local-id/`d`-tag divergence, and an unshared `30175` stays private. Kind `30176`'s wire body is untouched, so device sync keeps its contract.
+
+**The `d` tag is a team id, not a persona slug.** It is either a UUID or a built-in identifier such as `builtin-team:welcome`. The colon is illegal under the persona slug grammar, and rewriting ids to fit would break NIP-33 addressing against the team's own `kind:30176` head — so the relay applies a laxer rule (see below) to `30178` than to `30175`.
+
+**Content carries only sanitized fields.** No environment variables, no `respond_to` allowlist pubkeys, no source or local ids, no filesystem paths, no secrets. Sharing a team makes the team's and every member's instructions community-readable plaintext.
+
## Relay behavior
### Ingest validation
@@ -226,10 +251,20 @@ Agents spawned from a persona carry [NIP-OA](NIP-OA.md) owner attestation — an
- The relay MUST enforce that the `d` tag is non-empty (standard NIP-33 requirement for parameterized replaceable events).
- The relay MUST enforce shared-tag shape: if a `shared` tag is present, it MUST consist of **exactly two elements** — `["shared", "true"]`. Extra elements (e.g. `["shared","true","extra"]`), wrong values (`["shared","false"]`), missing values (`["shared"]`), or duplicate `shared` tags are all rejected with `invalid:`. The two-element exact-shape constraint is required so that the relay's SQL visibility clause (`tags @> '[["shared","true"]]'`) never matches a stored malformed tag via JSONB containment supersets.
+### Ingest validation: kind:30178
+
+Kind `30178` is stored globally and its content is unvalidated, exactly as for `30175`. The envelope rules differ in one respect — the `d` grammar:
+
+- The relay MUST enforce the same `shared`-tag exact shape as `30175`, for the same reason: the read gate and the SQL containment clause must agree on every stored event.
+- The relay MUST enforce **exactly one** `d` tag whose value is non-empty, at most 64 characters, and free of Unicode control characters and whitespace. Tags are counted by their first element, so a valueless `["d"]` counts toward the total and fails the value check on its own — otherwise `["d"]` alongside `["d",""]` would pass, and a consumer that reads `["d"]` as an empty-valued first `d` tag would address the event at `""` while this relay addresses it at ``. Without the non-empty check, generic NIP-33 storage maps a missing or empty `d` to the empty coordinate, collapsing every team into the single `(pubkey_o, 30178, "")` slot — last-write-wins data loss. The character bound keeps the value usable as a NIP-33 coordinate and as a log field.
+- The relay MUST NOT apply the persona slug grammar to a `30178` `d` tag; team ids legitimately contain characters (notably `:`) that the slug grammar forbids.
+
### Access control: author-only-unless-shared
Kind `30175` uses **shared-tag-gated read semantics** to protect system prompts and `respond_to_allowlist` from being visible to all community members as a side-effect of device sync.
+The gate is kind-generic: the relay applies it to every kind in `SHARED_GATED_KINDS` (`buzz-core/src/kind.rs`), currently `30175` and the `30178` team-catalog projection described below. The rules and enforcement surfaces are identical for each member kind.
+
**Rules:**
| Event state | Author reads | Foreign reads |
@@ -239,13 +274,13 @@ Kind `30175` uses **shared-tag-gated read semantics** to protect system prompts
These rules are enforced at the following relay read surfaces (content and event existence are withheld on all of them):
-- **REQ historical delivery** — foreign requests silently omit unshared persona events, even in mixed-kind filters (`{kinds:[30175,9]}`). The visibility check is applied **before `ORDER BY … LIMIT`** at the SQL level (`persona_reader` field in `EventQuery`), so a page of newer private personas cannot starve an older shared persona off the candidate set — the catalog's primary all-author query pattern is correctly served.
+- **REQ historical delivery** — foreign requests silently omit unshared persona events, even in mixed-kind filters (`{kinds:[30175,9]}`). The visibility check is applied **before `ORDER BY … LIMIT`** at the SQL level (`shared_gated_reader` field in `EventQuery`), so a page of newer private personas cannot starve an older shared persona off the candidate set — the catalog's primary all-author query pattern is correctly served.
- **NIP-01 `ids` lookup** — knowing an event id does NOT grant access to an unshared persona. The result gate returns nothing.
- **Live fan-out** — unshared personas are delivered only to the author's connections. Shared personas fan out community-wide.
-- **COUNT** — the fast SQL `count_events()` path is bypassed when the filter can match `kind:30175`. A per-event fallback applies the shared-tag check, preventing existence-leak via COUNT.
-- **NIP-98 HTTP bridge `/query`** — the same per-event visibility check is applied to the catchall post-processing loop. The SQL-level `persona_reader` clause also applies before `LIMIT`, preventing older shared personas from being starved by newer private ones on paginated catalog queries. A foreign caller POSTing `{kinds:[30175],authors:[victim]}` or a kindless `{ids:[...]}` filter to `/query` receives no unshared persona content.
-- **NIP-98 HTTP bridge `/count`** — `needs_persona_filtering` forces the per-event fallback path for any filter that can match `kind:30175`; the fast SQL `count_events()` path is not used. Both the channel-scoped and unconstrained fallback loops apply `event_visible_to_reader`, preventing existence-leak via COUNT over HTTP.
-- **FTS (NIP-50 search) and `/search`** — kind `30175` is not in the relay's FTS allowlist (migration 8 indexes only kinds `0, 9, 40002, 45001, 45003`); no FTS result can contain an unshared persona. A defense-in-depth check is also present in the bridge search result loop so that a future FTS allowlist change cannot silently reopen the bypass.
+- **COUNT** — the fast SQL `count_events()` path is bypassed when the filter can match a shared-gated kind. A per-event fallback applies the shared-tag check, preventing existence-leak via COUNT.
+- **NIP-98 HTTP bridge `/query`** — the same per-event visibility check is applied to the catchall post-processing loop. The SQL-level `shared_gated_reader` clause also applies before `LIMIT`, preventing older shared personas from being starved by newer private ones on paginated catalog queries. A foreign caller POSTing `{kinds:[30175],authors:[victim]}` or a kindless `{ids:[...]}` filter to `/query` receives no unshared persona content.
+- **NIP-98 HTTP bridge `/count`** — `needs_shared_gate_filtering` forces the per-event fallback path for any filter that can match a shared-gated kind; the fast SQL `count_events()` path is not used. Both the channel-scoped and unconstrained fallback loops apply `event_visible_to_reader`, preventing existence-leak via COUNT over HTTP.
+- **FTS (NIP-50 search) and `/search`** — no shared-gated kind is in the relay's FTS allowlist (migration 8 indexes only kinds `0, 9, 40002, 45001, 45003`); no FTS result can contain an unshared event. A defense-in-depth check is also present in the bridge search result loop so that a future FTS allowlist change cannot silently reopen the bypass.
**Device sync is unaffected.** The sync subscription (`{kinds:[30175], authors:[self]}`) reads the author's own events, which are always returned regardless of shared state.
@@ -263,6 +298,7 @@ These rules are enforced at the following relay read surfaces (content and event
- **Slug collision across pubkeys.** Two different owners can publish personas with the same slug. Clients MUST always scope queries by author pubkey, not just slug.
- **Metadata exposure.** The `(pubkey, kind:30175, slug)` triple reveals persona existence. Event timestamps reveal edit history.
- **No owner write authority over agents.** Persona events define *what* an agent should be; they do not grant runtime control over a running agent. The agent consumes the persona at spawn time. Updates to the persona event do not automatically propagate to running agents.
+- **Sharing a team shares every member's instructions.** A `kind:30178` head carrying `["shared","true"]` exposes the team's own fields *and* the embedded projection of every member — including members whose own `kind:30175` heads are unshared and therefore still private. Clients MUST make this explicit at the point of sharing; the relay cannot infer it.
## Reference test vectors
From 29dfe4821ed577489a1879fd2a9bfe2a621a52b3 Mon Sep 17 00:00:00 2001
From: Sumit Madan <33051892+sumit-m@users.noreply.github.com>
Date: Fri, 31 Jul 2026 03:30:22 +0530
Subject: [PATCH 23/87] fix(desktop): don't gate hover affordances on the hover
media query (#3657)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What problem this solves
Tailwind v4 compiles every `hover:` variant inside `@media (hover:
hover)`. Some
Windows hosts answer that query `false` **even with a mouse attached**,
and then
every hover-revealed control in the app is permanently `visibility:
hidden`.
Measured in the app's own WebView2 devtools console, on a mouse-driven
Windows 11
desktop:
```js
matchMedia('(hover: hover)').matches // false
matchMedia('(any-hover: hover)').matches // false
matchMedia('(pointer: fine)').matches // false
matchMedia('(any-pointer: fine)').matches // false
navigator.maxTouchPoints // 10
```
Windows itself, on the same machine at the same moment, reports a mouse
present
and an integrated digitizer:
```
GetSystemMetrics(SM_DIGITIZER) = 197 // INTEGRATED_TOUCH | INTEGRATED_PEN
// | MULTI_INPUT | READY
GetSystemMetrics(SM_MAXIMUMTOUCHES) = 10
SystemInformation.MousePresent = True
```
So this is not "the user has no mouse". Windows knows a mouse is
attached, and
Chromium still reports `any-pointer: fine: false` and `any-hover: false`
— the
`any-*` queries exist precisely to describe *any* available input
device, and
they are wrong here. The presence of an integrated touch digitizer
collapses the
reported capability to touch-only.
The compiled rule that never applies:
```css
.group-hover\/member\:visible {
&:is(:where(.group\/member):hover *) {
@media (hover: hover) { visibility: visible; }
}
}
```
The row genuinely matches `:hover` (verified: `row.matches(':hover') ===
true`),
the button is in the DOM, the utility class is generated — and the
declaration
still never lands.
## Why this is more than one control
Not a single menu. Confirmed newly-ungated in the production bundle
after the
change:
| utility | media-gated before | after |
|---|---|---|
| `group-hover/member:visible` | yes | no |
| `group-hover/inbox-item:opacity-100` | yes | no |
| `group-hover/channel-row:opacity-100` | yes | no |
| `group-hover/attachment:opacity-100` | yes | no |
| `hover:bg-muted` | yes | no |
On an affected host the channel-member action menu (remove member,
change role,
start/stop agent) has **no reachable affordance at all**: `visibility:
hidden`
also removes the button from tab order, so there is no keyboard path
either.
## The fix
One line, at the root, next to the existing variant override:
```css
@custom-variant hover (&:hover);
```
This trusts the actual hover event rather than the capability query.
Chromium
only fires `:hover` when a real pointer is present, so behaviour on
hosts that
report the capability correctly is unchanged.
Verified against a production `vite build`, not just the dev server —
the
override cascades to the *named* group variants (`group-hover/member`,
etc.),
which is the part that matters here.
## Prior art in this repo
#2849 overrides Tailwind v4's `dark:` variant default at the *exact same
insertion point* in this file, for the same class of reason (a v4
default that
does not match how this app actually works). This change follows that
precedent.
**Note for whoever merges second: #2849 and this PR will conflict
textually** —
both append a `@custom-variant` immediately after `@config`. The
resolution is
to keep both lines; they are independent.
## Scope
Desktop only. `web/src/shared/styles/globals.css` has the same Tailwind
v4
default, but `web/src` contains **zero** `group-hover` usages, so there
are no
hover-revealed affordances to strand there. Adding the override to web
would be
speculative.
One `hover` capability query is deliberately left in place —
`.buzz-wave-hover-trigger` in `animations.css` gates a decorative
wave-hand
animation on `(hover: hover) and (pointer: fine)`. That is a cosmetic
flourish
rather than an affordance, so it stays inert on affected hosts instead
of
widening this diff.
## Reproducing
The trigger is **an integrated touch digitizer anywhere on the
machine**, not the
display you are actually working on. This was found on a touch-capable
laptop
docked to an ordinary non-touch external monitor, driven entirely by a
mouse — so
"I'm on a desktop monitor" does not rule you out. Check with:
```js
matchMedia('(hover: hover)').matches // false ⇒ affected
```
Not reproducible on macOS, or on a Windows machine with no digitizer at
all —
`hover: hover` is true there and every affordance works normally. If you
are on
such a host, emulate it in devtools by forcing `hover: none` / `pointer:
coarse`,
then open a channel's member list and hover a row: no action menu
appears.
## Tradeoff worth naming
On a genuine touch-only device, a bare `&:hover` can latch after a tap
and stay
applied until the next interaction, where the media-query default would
have
suppressed it. That is the real cost of this change.
The judgement here is that a stuck hover style is a cosmetic annoyance,
while an
unreachable "remove member" button is a functional dead end — and that
the
affected hosts are overwhelmingly mouse-driven machines that merely
*happen* to
ship a digitizer, as the `MousePresent = True` reading above shows. If
you would
rather scope this to `@media not (hover: hover)` as an additive fallback
instead
of overriding the variant, I am happy to rework it.
Signed-off-by: sumit-m <33051892+sumit-m@users.noreply.github.com>
---
desktop/src/shared/styles/globals.css | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index fd60621933..704f6e542d 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
+
@import "./globals/scrollbars.css";
@import "./globals/motion.css";
@import "./globals/animations.css";
@@ -17,3 +18,15 @@
@import "./globals/progress.css";
@config "../../../tailwind.config.js";
+
+/* Tailwind v4 gates `hover:` behind `@media (hover: hover)`. Some Windows
+ hosts answer that query `false` even with a mouse attached — WebView2 here
+ reports `hover: none`, `any-pointer: fine: false`, `maxTouchPoints: 10` —
+ which leaves every hover-revealed control permanently `visibility: hidden`:
+ member row action menus, sidebar row actions, attachment controls. A bare
+ `&:hover` trusts the actual hover event instead of the capability query;
+ Chromium only fires :hover when a real pointer is present.
+
+ Must stay below every `@import`: CSS requires `@import` to precede other
+ at-rules, so placing this above them silently drops the rest of the sheet. */
+@custom-variant hover (&:hover);
From 74cd5712191bffd84ae688d59bb8b451c6eec1b0 Mon Sep 17 00:00:00 2001
From: Wes
Date: Thu, 30 Jul 2026 16:04:14 -0600
Subject: [PATCH 24/87] fix(desktop): report authenticated relay recovery
(#3812)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
- report the relay as connected immediately after socket open and
successful AUTH
- keep rate-limited subscription replay, the connect promise, and
reconnect listeners unchanged
- cover authenticated reconnect while replay is held behind the shared
rate-limit gate
## Why
After WARP recovery, the socket could reopen and authenticate
successfully while subscription replay waited behind the existing
rate-limit gate. `connect()` kept `ConnectionState` at `reconnecting`
during that intentional delay, so the desktop displayed “Can’t reach the
relay” despite authenticated traffic already flowing.
This is separate from #3774: that fix keeps routine operations from
bypassing scheduled reconnect backoff. This patch preserves those
protections and only corrects the authenticated transport-state
boundary.
## Failure semantics
If replay fails after the early `connected` transition, the existing
`replayLiveSubscriptions()` catch calls `resetConnection()`, closes the
socket, returns state to `reconnecting`, and schedules recovery.
Operation waiters and reconnect notifications still do not complete
until replay succeeds.
## Validation
At commit `c8a4308e1079f4f9e6a72f0f0bfba280fe822ec0` with a clean
working tree:
- `pnpm --dir desktop typecheck`
- `pnpm --dir desktop test` — 3,847 passed
- `pnpm --dir desktop check` — passed; two pre-existing informational
template-literal notices
- `pnpm --dir desktop exec playwright test
tests/e2e/relay-reconnect.spec.ts` — 8 passed
- regression test proven red before the production ordering change
(`reconnecting` after 3 seconds) and green after it
Signed-off-by: Wes
Co-authored-by: Carl
---
desktop/src/shared/api/relayClientSession.ts | 2 +-
desktop/src/testing/e2eBridge.ts | 5 +++
desktop/tests/e2e/relay-reconnect.spec.ts | 35 ++++++++++++++++++++
3 files changed, 41 insertions(+), 1 deletion(-)
diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts
index 8274034ed5..94438386eb 100644
--- a/desktop/src/shared/api/relayClientSession.ts
+++ b/desktop/src/shared/api/relayClientSession.ts
@@ -566,8 +566,8 @@ export class RelayClient {
this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
}, BACKOFF_RESET_STABLE_MS);
- await this.replayLiveSubscriptions();
this.connectionStateEmitter.set("connected");
+ await this.replayLiveSubscriptions();
this.stallWatchdog.start();
this.emitReconnectIfNeeded();
} catch (error) {
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 841e6ba83f..9bb3feabda 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -11,6 +11,7 @@ import {
} from "./e2eBridgeCustomHarnesses.ts";
import { relayClient } from "@/shared/api/relayClient";
+import { activateRateLimit } from "@/shared/api/relayRateLimitGate";
import type { ConnectionState } from "@/shared/api/relayClientShared";
import type { ChannelTemplate, RelayEvent } from "@/shared/api/types";
import { getMarkdownParseCount } from "@/shared/ui/markdown/nodeCache";
@@ -1115,6 +1116,7 @@ declare global {
unavailable: boolean,
) => void;
__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => number[];
+ __BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__?: (seconds: number) => void;
__BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => void;
__BUZZ_E2E_SET_MESH__?: (mesh: {
admitted?: boolean;
@@ -9706,6 +9708,9 @@ export function maybeInstallE2eTauriMocks() {
window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => [
...relayWebsocketConnectAttemptStarts,
];
+ window.__BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__ = (seconds) => {
+ activateRateLimit(seconds);
+ };
window.__BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => {
relayWebsocketConnectAttemptStarts.length = 0;
};
diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts
index 67ce725da8..6606d5f04d 100644
--- a/desktop/tests/e2e/relay-reconnect.spec.ts
+++ b/desktop/tests/e2e/relay-reconnect.spec.ts
@@ -62,6 +62,19 @@ async function setMockWebsocketUnavailable(
}, unavailable);
}
+async function activateRelayRateLimit(
+ page: import("@playwright/test").Page,
+ seconds: number,
+) {
+ await page.evaluate((duration) => {
+ const activate = window.__BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__;
+ if (!activate) {
+ throw new Error("E2E relay rate-limit seam is not installed.");
+ }
+ activate(duration);
+ }, seconds);
+}
+
async function getMockWebsocketConnectAttempts(
page: import("@playwright/test").Page,
) {
@@ -195,6 +208,28 @@ test("routine traffic cannot bypass outage backoff and recovery stays automatic"
);
});
+test("authenticated reconnect reports connected while replay is rate-limited", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await expect(page.getByTestId("channel-general")).toBeVisible();
+
+ await activateRelayRateLimit(page, 5);
+ await disconnectMockWebsockets(page);
+
+ // Replay remains intentionally blocked behind admission control, but socket
+ // open + successful AUTH is already a healthy connection. The UI must not
+ // claim the relay is unreachable for the rest of the gate window.
+ await expect
+ .poll(
+ () =>
+ page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
+ { timeout: 3_000 },
+ )
+ .toBe("connected");
+ await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0);
+});
+
test("service restart close resets accumulated backoff", async ({ page }) => {
await installMockBridge(page, {
websocketConnectErrors: ["down 1", "down 2", "down 3"],
From 36571f4adcfdcf3714a17bd968c58c78bcbdd9ef Mon Sep 17 00:00:00 2001
From: Will Pfleger
Date: Thu, 30 Jul 2026 18:11:03 -0400
Subject: [PATCH 25/87] fix(desktop): allow linux-only media items as dead code
off-linux (#3811)
Local `desktop-tauri-clippy` fails on macOS with dead-code errors for
`PROD_ORIGIN`, `DEV_ORIGIN`, and `is_trusted_media_origin`, which are
only used inside `#[cfg(target_os = "linux")] enable_media_capture`. The
items are intentionally platform-independent so unit tests run
everywhere. Added `cfg_attr` allow attribute to suppress the warnings on
non-Linux targets.
Since [#3607](https://github.com/block/buzz/pull/3607), this affects all
Rust developers on macOS.
Signed-off-by: Will Pfleger
Co-authored-by: d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78
---
desktop/src-tauri/src/linux_media.rs | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/desktop/src-tauri/src/linux_media.rs b/desktop/src-tauri/src/linux_media.rs
index c768e15422..240e2f8a77 100644
--- a/desktop/src-tauri/src/linux_media.rs
+++ b/desktop/src-tauri/src/linux_media.rs
@@ -22,17 +22,22 @@
//! which is the backend WebKitGTK media capture is reliable on.
/// The origin Tauri serves the packaged app from on Linux.
+/// Consumed only by linux-gated [`enable_media_capture`]; kept compiling on all
+/// platforms so the unit tests run everywhere.
+#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const PROD_ORIGIN: &str = "tauri://localhost";
/// The Vite dev-server origin (`devUrl` in `tauri.conf.json`, `strictPort`
/// 1420 in `vite.config.ts`). Only trusted in debug builds.
#[cfg(debug_assertions)]
+#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const DEV_ORIGIN: &str = "http://localhost:1420";
/// Whether `uri` (the webview's current document URI) is a trusted app origin
/// allowed to use mic/camera. Matches the origin exactly or as a path prefix so
/// `tauri://localhost.evil.com` and `http://localhost:14200` do not slip
/// through. Pure and platform-independent so it can be unit-tested everywhere.
+#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn is_trusted_media_origin(uri: &str) -> bool {
fn matches(uri: &str, origin: &str) -> bool {
uri == origin
From 23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9 Mon Sep 17 00:00:00 2001
From: Will Pfleger
Date: Thu, 30 Jul 2026 18:42:18 -0400
Subject: [PATCH 26/87] fix(relay): align NIP-11 max_limit with REQ ceiling
(#3635)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Buzz's NIP-11 document advertised `limitation.max_limit: 10_000`, but
the effective websocket REQ page ceiling was `1_000` — a 10x lie.
The websocket REQ path never sets `EventQuery::max_limit`, so
`query_events` applied its own `unwrap_or(1000)` clamp to every
historical query. Only the COUNT fallback (`apply_count_fallback_limit`)
ever raises that clamp. A client that trusts the advertised value asks
for 10,000 events, silently receives 1,000, and — with no error and no
continuation signal — reads that short page as exhaustion. Up to 9,000
events are dropped without anyone noticing.
`MAX_HISTORICAL_LIMIT = 2_000` in `handlers/req.rs` was dead weight for
the same reason: nothing clamped to 2,000 could survive the DB's 1,000
clamp one layer down.
## Change
`buzz_db::DEFAULT_MAX_PAGE_LIMIT` (`1_000`) is now the single source of
truth. It is the `query_events` clamp default, the value both REQ clamp
sites use, and the value advertised as NIP-11 `max_limit`.
`MAX_HISTORICAL_LIMIT` is removed rather than re-pointed — an alias for
a constant used four lines away adds a name without adding meaning.
The NIP-50 search path carries a second, independent bound. It clamps
its emission target to the shared ceiling like any other REQ, but how
many FTS candidates it will scan was bounded separately, by a bare
10-page loop over 100-hit pages. That product only coincidentally
equalled the ceiling, so raising the ceiling — or shrinking a page —
would shrink the scan relative to what clients may now request,
degrading search quality while nothing in the code registered the
change. The page count is now ceiling-divided from
`DEFAULT_MAX_PAGE_LIMIT` over a named `SEARCH_PAGE_SIZE`, so the scan
budget tracks the advertised ceiling by construction.
That budget is a resource policy, not a delivery promise. It bounds
candidates *scanned*, not events *emitted*: post-filtering (NIP-01
match, channel access, reader visibility, dedup) discards an
unpredictable share of every page, so a search result smaller than the
requested limit remains possible. This is not a NIP-11 violation —
`max_limit` is defined as a clamp the relay applies to a requested
`limit`, not a guaranteed count in the response.
Two guards hold the pair together:
- `req_filter_limit_clamps_to_advertised_nip11_max_limit` reads
`max_limit` back out of a built `RelayInfo` and asserts the REQ path
clamps to exactly that number.
- `search_scan_capacity_covers_advertised_nip11_max_limit` asserts the
scan budget covers exactly one advertised ceiling's worth of candidates
— no less, and with no spare page of slack, so the derivation can't be
quietly replaced by a hand-tuned constant that happens to pass today.
## Behavior
Websocket behavior is unchanged: 1,000 was already the real ceiling on
every path, including NIP-50. The advertisement now tells the truth
about it. Raising the effective limit is a capacity decision and is
deliberately not made here.
The generic HTTP bridge's page-2+ offsets do change, as a consequence of
the corrected clamp. `extract_page_offset` sizes a page from
`query.limit` *before* the DB clamp applies, so an absent limit
previously produced an offset of 2,000 and a requested 1,500 produced
1,500 — while the page actually returned held at most 1,000 rows. Both
now produce 1,000. This corrects paging that had been skipping rows the
previous page never returned;
`extract_page_offset_sizes_pages_from_clamped_limit` locks it down.
## Scope note
The bridge's per-endpoint ceilings — `BRIDGE_WINDOW_MAX_LIMIT` (200) for
channel windows and `BRIDGE_THREAD_MAX_LIMIT` (500) for thread reads —
are endpoint contracts on a non-NIP-01 transport, not values NIP-11
speaks for, and are unchanged.
Fixes #3757
---------
Signed-off-by: Will Pfleger
Co-authored-by: Duncan
---
crates/buzz-db/src/event.rs | 15 ++-
crates/buzz-db/src/lib.rs | 2 +-
crates/buzz-relay/src/api/bridge.rs | 21 +++++
crates/buzz-relay/src/handlers/req.rs | 126 ++++++++++++++++++++++----
crates/buzz-relay/src/nip11.rs | 7 +-
5 files changed, 147 insertions(+), 24 deletions(-)
diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs
index c0550e7e22..6c84950a2c 100644
--- a/crates/buzz-db/src/event.rs
+++ b/crates/buzz-db/src/event.rs
@@ -17,6 +17,13 @@ use buzz_core::{CommunityId, StoredEvent};
use crate::error::{DbError, Result};
+/// Largest page [`query_events`] will return when [`EventQuery::max_limit`] is
+/// unset — the effective ceiling on any client-requested `limit`.
+///
+/// This is the value the relay advertises as NIP-11 `limitation.max_limit`, so
+/// the advertised ceiling and the enforced one cannot drift.
+pub const DEFAULT_MAX_PAGE_LIMIT: i64 = 1_000;
+
/// Optional filters for [`query_events`].
#[derive(Debug, Clone)]
pub struct EventQuery {
@@ -67,9 +74,9 @@ pub struct EventQuery {
/// channel-less global events. Applied before SQL `LIMIT` so access-filtered
/// historical pages have exact exhaustion semantics.
pub channel_ids: Option>,
- /// Override the default limit clamp (1000). Used by COUNT fallback path
- /// which needs to fetch all matching events for post-filter counting.
- /// When None, the default clamp of 1000 applies.
+ /// Override the default page clamp ([`DEFAULT_MAX_PAGE_LIMIT`]). Used by
+ /// the COUNT fallback path, which needs to fetch all matching events for
+ /// post-filter counting. When None, the default clamp applies.
pub max_limit: Option,
/// Shared-gated visibility reader: when set, append an SQL visibility
/// clause for every kind in [`SHARED_GATED_KINDS`] before ORDER/LIMIT so
@@ -357,7 +364,7 @@ pub(crate) async fn query_events_on(
return Ok(vec![]);
}
- let clamp = q.max_limit.unwrap_or(1000);
+ let clamp = q.max_limit.unwrap_or(DEFAULT_MAX_PAGE_LIMIT);
let limit_val = q.limit.unwrap_or(100).min(clamp);
let offset_val = q.offset.unwrap_or(0);
diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs
index 5c60d1a702..50aac1cbaf 100644
--- a/crates/buzz-db/src/lib.rs
+++ b/crates/buzz-db/src/lib.rs
@@ -55,7 +55,7 @@ pub mod user;
pub mod workflow;
pub use error::{DbError, Result};
-pub use event::{EventQuery, ReactionEventInsertOutcome};
+pub use event::{EventQuery, ReactionEventInsertOutcome, DEFAULT_MAX_PAGE_LIMIT};
use chrono::{DateTime, Utc};
use sqlx::postgres::{PgConnection, PgPoolOptions};
diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs
index 678199e734..a118ff453f 100644
--- a/crates/buzz-relay/src/api/bridge.rs
+++ b/crates/buzz-relay/src/api/bridge.rs
@@ -3042,6 +3042,27 @@ mod tests {
assert_eq!(extract_page_offset(&raw, None), None);
}
+ /// Offsets are sized from the *clamped* limit the DB will honor, not from
+ /// what the client asked for. `filter_to_query_params` clamps an absent or
+ /// over-ceiling `limit` to `DEFAULT_MAX_PAGE_LIMIT` (guarded in
+ /// `handlers::req::tests::req_filter_limit_clamps_to_advertised_nip11_max_limit`)
+ /// and that clamped value is what arrives here — so page N starts exactly
+ /// N-1 full pages in. Sizing from an unclamped limit would step past rows
+ /// the previous page never returned.
+ #[test]
+ fn extract_page_offset_sizes_pages_from_clamped_limit() {
+ let clamped = buzz_db::DEFAULT_MAX_PAGE_LIMIT;
+
+ assert_eq!(
+ extract_page_offset(&serde_json::json!({ "page": 2 }), Some(clamped)),
+ Some(clamped)
+ );
+ assert_eq!(
+ extract_page_offset(&serde_json::json!({ "page": 3 }), Some(clamped)),
+ Some(clamped * 2)
+ );
+ }
+
#[test]
fn extract_depth_limit_valid() {
let raw = serde_json::json!({ "depth_limit": 3 });
diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs
index 35fbf0c892..2aed12cd7f 100644
--- a/crates/buzz-relay/src/handlers/req.rs
+++ b/crates/buzz-relay/src/handlers/req.rs
@@ -22,7 +22,6 @@ use crate::connection::{AuthState, ConnectionState};
use crate::protocol::RelayMessage;
use crate::state::AppState;
-const MAX_HISTORICAL_LIMIT: i64 = 2_000;
const MAX_SUBSCRIPTIONS: usize = 1024;
/// Maximum `query_events` calls in flight per multi-filter REQ / bridge query.
@@ -416,10 +415,24 @@ pub async fn handle_req(
);
}
-/// Handle a NIP-50 search REQ: query Postgres FTS, fetch full events, deliver results, EOSE.
-/// Search subscriptions are one-shot — no persistent subscription is registered.
+/// FTS candidate hits fetched per page. Pages are always full regardless of
+/// the requested limit — post-filtering discards an unpredictable share of
+/// hits, so the scan fetches candidates in full pages rather than sizing
+/// pages to the request.
+const SEARCH_PAGE_SIZE: u32 = 100;
+
/// Maximum FTS pages to fetch per filter (prevents unbounded loops).
-const MAX_SEARCH_PAGES: u32 = 10;
+///
+/// Derived from the advertised page ceiling rather than fixed: the scan
+/// budget is a resource policy — at most one advertised page ceiling's worth
+/// of candidates per filter — and deriving it keeps the budget tracking the
+/// ceiling if the ceiling ever moves. This bounds candidates *scanned*, not
+/// events *emitted*: post-filtering (NIP-01 match, channel access, reader
+/// visibility, dedup) can discard any number of candidates, so a result
+/// smaller than the requested limit remains possible and is not a NIP-11
+/// violation — `max_limit` promises a clamp on the request, not a count in
+/// the response.
+const MAX_SEARCH_PAGES: u32 = (buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32).div_ceil(SEARCH_PAGE_SIZE);
/// Resolve request-local channel access, repairing a stale cache-negative.
///
@@ -501,6 +514,8 @@ pub(crate) fn build_search_channel_scope_filter(
})
}
+/// Handle a NIP-50 search REQ: query Postgres FTS, fetch full events, deliver results, EOSE.
+/// Search subscriptions are one-shot — no persistent subscription is registered.
#[allow(clippy::too_many_arguments)]
async fn handle_search_req(
sub_id: &str,
@@ -535,8 +550,8 @@ async fn handle_search_req(
let limit = filter
.limit
- .map(|l| (l as u32).min(MAX_HISTORICAL_LIMIT as u32))
- .unwrap_or(MAX_HISTORICAL_LIMIT as u32);
+ .map(|l| (l as u32).min(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32))
+ .unwrap_or(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32);
if limit == 0 {
continue; // NIP-01: limit 0 means "no results from this filter"
@@ -583,13 +598,11 @@ async fn handle_search_req(
let since = filter.since.map(|s| s.as_secs() as i64);
let until = filter.until.map(|u| u.as_secs() as i64);
- // Paginate: keep fetching pages until we've emitted `limit` results
- // or exhausted the search result set. This ensures post-filtering
- // doesn't silently reduce the result count below the requested limit.
+ // Paginate: keep fetching pages until we've emitted `limit` results or
+ // exhausted the search result set. Post-filtering discards an unpredictable
+ // share of each page, so continuing past short yields gives the scan a
+ // chance — not a guarantee — of filling the requested limit.
let mut emitted: u32 = 0;
- // Always fetch full pages (100) regardless of limit — post-filtering
- // may discard many hits, so we need headroom to fill the requested limit.
- let per_page: u32 = 100;
for page in 1..=MAX_SEARCH_PAGES {
if emitted >= limit {
@@ -605,7 +618,7 @@ async fn handle_search_req(
since,
until,
page,
- per_page,
+ per_page: SEARCH_PAGE_SIZE,
mode: buzz_search::SearchMode::FullText,
};
@@ -617,9 +630,9 @@ async fn handle_search_req(
}
};
- // A short page is the last page: FTS returns up to `per_page` hits,
- // so fewer than that means the result set is exhausted.
- let exhausted = search_result.hits.len() < per_page as usize;
+ // A short page is the last page: FTS returns up to a full page of
+ // hits, so fewer than that means the result set is exhausted.
+ let exhausted = search_result.hits.len() < SEARCH_PAGE_SIZE as usize;
let page_empty = search_result.hits.is_empty();
let hit_ids: Vec<[u8; 32]> =
@@ -878,8 +891,8 @@ fn filter_to_query_params(
.and_then(|u| chrono::DateTime::from_timestamp(u.as_secs() as i64, 0));
let limit = filter
.limit
- .map(|l| (l as i64).min(MAX_HISTORICAL_LIMIT))
- .unwrap_or(MAX_HISTORICAL_LIMIT);
+ .map(|l| (l as i64).min(buzz_db::DEFAULT_MAX_PAGE_LIMIT))
+ .unwrap_or(buzz_db::DEFAULT_MAX_PAGE_LIMIT);
// Push author filter into SQL. Single-author uses the indexed `pubkey` column;
// multi-author uses the `authors` IN-list pushdown added in the pure-nostr PR.
@@ -1418,6 +1431,83 @@ mod tests {
)
}
+ /// NIP-11 `limitation.max_limit` as this relay actually advertises it.
+ fn advertised_max_limit() -> i64 {
+ crate::nip11::RelayInfo::build(
+ None,
+ None,
+ false,
+ crate::config::DEFAULT_MAX_FRAME_BYTES,
+ None,
+ )
+ .limitation
+ .expect("limitation")
+ .max_limit
+ .expect("max_limit") as i64
+ }
+
+ #[test]
+ fn req_filter_limit_clamps_to_advertised_nip11_max_limit() {
+ let advertised = advertised_max_limit();
+
+ let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4());
+
+ // A filter asking for more than the relay advertises is clamped down to
+ // exactly the advertised ceiling — the NIP-11 document is the promise,
+ // this is the enforcement.
+ let greedy = filter_to_query_params(
+ &Filter::new().limit(advertised as usize * 10),
+ None,
+ community,
+ );
+ assert_eq!(greedy.limit, Some(advertised));
+
+ // A filter with no `limit` gets the same ceiling, not something larger.
+ let unbounded = filter_to_query_params(&Filter::new(), None, community);
+ assert_eq!(unbounded.limit, Some(advertised));
+
+ // Neither sets `max_limit`, so `query_events` applies its own default
+ // clamp. That default must equal the advertised value too, or the
+ // clamp above would be undone one layer down.
+ assert_eq!(greedy.max_limit, None);
+ assert_eq!(unbounded.max_limit, None);
+ assert_eq!(buzz_db::DEFAULT_MAX_PAGE_LIMIT, advertised);
+
+ // Under-ceiling requests are honored verbatim.
+ let modest = filter_to_query_params(&Filter::new().limit(10), None, community);
+ assert_eq!(modest.limit, Some(10));
+ }
+
+ /// The NIP-50 search path clamps its emission target to the advertised
+ /// ceiling like every other REQ, but the number of candidates it will scan
+ /// is bounded a second time by the page budget. This pins the resource
+ /// policy: the budget covers exactly one advertised page ceiling's worth of
+ /// candidates — no less (a ceiling raise must not silently shrink the scan
+ /// relative to what clients may request) and no hand-tuned spare (the budget
+ /// must stay derived, not drift back into a magic number). It deliberately
+ /// does NOT claim search fills the emitted limit — post-filtering can
+ /// discard any number of candidates.
+ #[test]
+ fn search_scan_capacity_covers_advertised_nip11_max_limit() {
+ let advertised = advertised_max_limit();
+ let capacity = i64::from(MAX_SEARCH_PAGES) * i64::from(SEARCH_PAGE_SIZE);
+
+ assert!(
+ capacity >= advertised,
+ "NIP-50 scans at most {capacity} candidates ({MAX_SEARCH_PAGES} pages of \
+ {SEARCH_PAGE_SIZE}) but NIP-11 advertises {advertised} — the scan budget \
+ no longer covers the advertised ceiling"
+ );
+
+ // The budget is derived, not hand-tuned: one page under the derived
+ // count must be insufficient, or the ceiling could rise without the
+ // page count following it.
+ assert!(
+ capacity - i64::from(SEARCH_PAGE_SIZE) < advertised,
+ "scan budget has a spare page of slack — derive it from the ceiling"
+ );
+ }
+
#[test]
fn count_fallback_fetches_one_extra_candidate() {
let mut query =
diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs
index a8e397dd21..2575ddd7ba 100644
--- a/crates/buzz-relay/src/nip11.rs
+++ b/crates/buzz-relay/src/nip11.rs
@@ -89,6 +89,11 @@ pub struct RelayLimitation {
/// Canonical `RelayLimitation` advertised by this relay.
///
+/// `max_limit` is [`buzz_db::DEFAULT_MAX_PAGE_LIMIT`], the same constant the
+/// REQ path clamps filter limits to, so the advertised ceiling and the
+/// enforced one cannot drift (see
+/// `handlers::req::tests::req_filter_limit_clamps_to_advertised_nip11_max_limit`).
+///
/// `auth_required` is always `true`: the REQ, EVENT, and COUNT handlers
/// unconditionally reject connections that are not in
/// `AuthState::Authenticated`. This is independent of the REST API token
@@ -103,7 +108,7 @@ fn relay_limitation(max_message_length: usize) -> RelayLimitation {
max_message_length: Some(max_message_length as u64),
max_subscriptions: Some(1024),
max_filters: Some(10),
- max_limit: Some(10_000),
+ max_limit: Some(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32),
max_subid_length: Some(256),
min_pow_difficulty: None,
auth_required: true,
From ede26863345a518ec46edd6d7692e0281883491b Mon Sep 17 00:00:00 2001
From: Bradley Axen
Date: Thu, 30 Jul 2026 15:47:35 -0700
Subject: [PATCH 27/87] fix(desktop): align data deletion labels (#2230)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Why
The Profile settings action still says “Sign Out,” while its
confirmation action says “Delete My Data.” Both buttons trigger the same
destructive local-data wipe and should name it consistently.
## What
- Label both destructive actions “Delete my data”
- Assert the matching section and confirmation labels in the existing
Playwright coverage
## Risk Assessment
Low — copy and test assertions only; sign-out behavior is unchanged.
## References
- Follow-up to #2208
- #2216 also touches this copy and should preserve “Delete my data” when
rebased
- `just desktop-check`
- `just desktop-test` (3,275 tests)
- Desktop E2E build and sign-out Playwright spec (2 tests)
Generated with Codex
Signed-off-by: Bradley Axen
---
desktop/src/features/settings/ui/SignOutSection.tsx | 4 ++--
desktop/tests/e2e/signout-screenshots.spec.ts | 7 +++++--
2 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/desktop/src/features/settings/ui/SignOutSection.tsx b/desktop/src/features/settings/ui/SignOutSection.tsx
index 8d4dc1c481..746220459b 100644
--- a/desktop/src/features/settings/ui/SignOutSection.tsx
+++ b/desktop/src/features/settings/ui/SignOutSection.tsx
@@ -151,7 +151,7 @@ export function SignOutSection() {
{isPending ? (
) : null}
- {isPending ? "Signing out…" : "Sign Out"}
+ {isPending ? "Signing out…" : "Delete my data"}
) : null}
- {isPending ? "Signing out…" : "Delete My Data"}
+ {isPending ? "Signing out…" : "Delete my data"}
diff --git a/desktop/tests/e2e/signout-screenshots.spec.ts b/desktop/tests/e2e/signout-screenshots.spec.ts
index 32fc3ed09d..5cf35c70ab 100644
--- a/desktop/tests/e2e/signout-screenshots.spec.ts
+++ b/desktop/tests/e2e/signout-screenshots.spec.ts
@@ -23,7 +23,7 @@ test.describe("signout screenshots", () => {
});
});
- test("signout-section — Sign Out card in Settings › Profile", async ({
+ test("signout-section — data deletion card in Settings › Profile", async ({
page,
}) => {
await installMockBridge(page);
@@ -32,6 +32,9 @@ test.describe("signout screenshots", () => {
const section = page.getByTestId("settings-signout");
await section.scrollIntoViewIfNeeded();
+ await expect(
+ section.getByRole("button", { name: "Delete my data" }),
+ ).toBeVisible();
// Settle animations before capture.
await page.evaluate(() =>
@@ -59,7 +62,7 @@ test.describe("signout screenshots", () => {
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog.getByText("Sign out and wipe all data?")).toBeVisible();
await expect(
- dialog.getByRole("button", { name: "Delete My Data" }),
+ dialog.getByRole("button", { name: "Delete my data" }),
).toBeVisible();
// Settle animations before capture.
From 9e8fcfda099652926b921bca7fcc9bfecab0e140 Mon Sep 17 00:00:00 2001
From: Clay Delk
Date: Thu, 30 Jul 2026 18:53:04 -0400
Subject: [PATCH 28/87] fix(desktop): channel topic and membership metadata
cleanup (#3642)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
First slice of #2216, scoped to the system/status lines in the chat
timeline.
## Why
Two problems on the same surface.
**Clearing a channel topic renders as empty quotes.** The relay reports
a clear as a `topic_changed` event carrying an empty string — there's no
separate "cleared" event type. So the timeline printed:
> Alice
> changed the topic to “”
which reads as if the topic were *set to* two quote marks. Same for
purpose.
**The membership caption reads like a headline, not a metadata line.**
`title` and `action` render on separate lines — the member's name sits
in the header row with the avatar and timestamp, and the caption sits
beneath it. So the caption was "was added by Alice Chen" standing alone
under a name, while its siblings on that same line are "joined the
channel" and "left the channel".
## What
- Blank, missing, or whitespace-only topic/purpose now reads **"cleared
the channel topic"** / **"cleared the channel purpose"**.
- Membership captions drop "was": **"added by Alice Chen"**, matching
"joined the channel" and "left the channel".
- The wording moves to `lib/systemEventCopy.ts` as a pure function, so
it's assertable in a unit test instead of only reachable through the
DOM. That also removes two JSX fragments from `SystemMessageRow.tsx`,
taking it 911 → 900 lines.
## Two E2E assertions this exposed
Both were measuring something other than what they claimed, and the copy
change tipped them over. Neither is a product bug, but both would have
failed the next person too.
1. **`mentions.spec.ts:1245`** asserted a button was un-underlined while
the mouse was still parked from a previous `hover()`. Any reflow — new
rows, scroll-to-bottom, a different text wrap — can slide that button
under the stationary pointer, so the assertion measured *where the mouse
happened to be* rather than the resting style. Dropping four characters
changed the text wrap, changed the row height, changed the scroll
offset, and the pointer landed on it. Now parks the pointer off-target
first.
2. **`mentions.spec.ts:1253`** used a bare `role=tooltip` lookup. Once
the first tooltip animates out while the second opens, two elements
match and strict mode trips. Now scopes to the open tooltip via
`:not([data-state="closed"])`.
## Deliberately out of scope
- **Timestamps.** The day divider, per-message clock times, the Inbox
thread pane, and the inbox list have three divergent date
implementations and none fully match the writing standard's
Today/Yesterday/weekday/date progression. That's its own slice of #2216.
- **Whose avatar shows.** An addition puts the *added* member in the
header; a removal puts the *remover* there. Possibly intentional, but
it's a design question, not copy.
- **`the channel` vs `this channel`.** joined/left/removed say "the
channel"; created/archived/unarchived say "this channel". Worth
normalizing, but it touches lines this PR otherwise leaves alone.
## Validation
- `pnpm check`, `pnpm typecheck` — clean
- Unit: **3781/3781**, including 6 new tests in
`systemEventCopy.test.mjs` covering set/blank/undefined/null/whitespace
for both fields, plus a guard that no variant can emit empty quotes
- Smoke E2E `mentions` + `messaging`: **85/85**
- The previously fragile test run with `--repeat-each=5`: **5/5**
Signed-off-by: Clay Delk
Co-authored-by: Claude Opus 5 (1M context)
---
.../messages/lib/systemEventCopy.test.mjs | 107 ++++++++++++++++++
.../features/messages/lib/systemEventCopy.ts | 59 ++++++++++
.../features/messages/ui/SystemMessageRow.tsx | 56 +++++++--
desktop/tests/e2e/mentions.spec.ts | 26 +++--
.../channel_detail_page/system_rows.dart | 7 +-
.../features/channels/timeline_message.dart | 24 +++-
.../channels/channel_detail_page_test.dart | 6 +-
.../channels/timeline_message_test.dart | 47 ++++++++
8 files changed, 311 insertions(+), 21 deletions(-)
create mode 100644 desktop/src/features/messages/lib/systemEventCopy.test.mjs
create mode 100644 desktop/src/features/messages/lib/systemEventCopy.ts
diff --git a/desktop/src/features/messages/lib/systemEventCopy.test.mjs b/desktop/src/features/messages/lib/systemEventCopy.test.mjs
new file mode 100644
index 0000000000..eeed9d543c
--- /dev/null
+++ b/desktop/src/features/messages/lib/systemEventCopy.test.mjs
@@ -0,0 +1,107 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ describeChannelTextFieldChange,
+ toInlineName,
+} from "./systemEventCopy.ts";
+
+test("a set topic is quoted verbatim", () => {
+ assert.equal(
+ describeChannelTextFieldChange("topic", "Release planning"),
+ "changed the topic to “Release planning”",
+ );
+});
+
+test("a set purpose names the purpose, not the topic", () => {
+ assert.equal(
+ describeChannelTextFieldChange("purpose", "Where we ship from"),
+ "changed the purpose to “Where we ship from”",
+ );
+});
+
+// The relay reports a clear as a change carrying an empty string, so without
+// this branch the timeline reads: changed the topic to “”.
+test("an empty value reads as cleared, not as a change to empty quotes", () => {
+ for (const blank of ["", undefined, null]) {
+ assert.equal(
+ describeChannelTextFieldChange("topic", blank),
+ "cleared the topic",
+ );
+ assert.equal(
+ describeChannelTextFieldChange("purpose", blank),
+ "cleared the purpose",
+ );
+ }
+});
+
+test("a whitespace-only value reads as cleared", () => {
+ assert.equal(
+ describeChannelTextFieldChange("topic", " \n\t "),
+ "cleared the topic",
+ );
+});
+
+test("surrounding whitespace is trimmed out of the quotes", () => {
+ assert.equal(
+ describeChannelTextFieldChange("topic", " Release planning "),
+ "changed the topic to “Release planning”",
+ );
+});
+
+test("no caption announces empty quotes", () => {
+ for (const value of ["", " ", null, undefined, "Real topic"]) {
+ for (const field of ["topic", "purpose"]) {
+ assert.doesNotMatch(
+ describeChannelTextFieldChange(field, value),
+ /“”|""/,
+ `${field} with ${JSON.stringify(value)} must not render empty quotes`,
+ );
+ }
+ }
+});
+
+test("the reader's own name is lowercase mid-sentence", () => {
+ // "added by You" next to an agent's "managed by you" was the inconsistency.
+ assert.equal(toInlineName("You", true), "you");
+});
+
+test("cleared and changed captions use the same noun", () => {
+ // Not "cleared the channel topic" against "changed the topic to …".
+ assert.match(describeChannelTextFieldChange("topic", ""), /\bthe topic\b/);
+ assert.match(
+ describeChannelTextFieldChange("topic", "Ship it"),
+ /\bthe topic\b/,
+ );
+ for (const value of ["", "Ship it"]) {
+ assert.doesNotMatch(
+ describeChannelTextFieldChange("topic", value),
+ /channel topic/,
+ );
+ }
+});
+
+test("every other name keeps its own capitalization", () => {
+ for (const name of [
+ "Alice Chen",
+ "you-know-who",
+ "Someone",
+ "npub1abc…def",
+ ]) {
+ assert.equal(toInlineName(name, false), name);
+ }
+});
+
+test("someone else whose display name is literally You is left alone", () => {
+ // The decisive case: the label is user-controlled, identity is not. Matching
+ // on the string would rewrite this person's name as if they were the reader.
+ assert.equal(toInlineName("You", false), "You");
+ assert.equal(toInlineName("Youssef", false), "Youssef");
+ assert.equal(toInlineName("You Know Who", false), "You Know Who");
+});
+
+test("the reader is lowercased whatever their profile name says", () => {
+ // Self resolution never consults the profile, but the rule keys on identity,
+ // so it does not matter what the label happens to be.
+ assert.equal(toInlineName("Alice Chen", true), "you");
+});
diff --git a/desktop/src/features/messages/lib/systemEventCopy.ts b/desktop/src/features/messages/lib/systemEventCopy.ts
new file mode 100644
index 0000000000..bae6abb09b
--- /dev/null
+++ b/desktop/src/features/messages/lib/systemEventCopy.ts
@@ -0,0 +1,59 @@
+/**
+ * Copy for channel system events (the "joined", "added by", "changed the
+ * topic" captions in the message timeline).
+ *
+ * These live outside `SystemMessageRow` so the wording is a pure function of
+ * the payload and can be asserted directly in tests. Only cases whose caption
+ * is plain text belong here — cases that interpolate a profile link build their
+ * JSX in the component.
+ */
+
+/** Curly quotes, so the caption matches the typography used elsewhere in chat. */
+const OPEN_QUOTE = "“";
+const CLOSE_QUOTE = "”";
+
+export type ChannelTextField = "topic" | "purpose";
+
+/**
+ * Caption for a channel topic or purpose change.
+ *
+ * Bare "the topic" rather than "the channel topic": this row only ever renders
+ * in a channel timeline, under that channel's own header, so naming the channel
+ * again is redundant — and it keeps the cleared and changed captions on the same
+ * noun instead of one saying "channel topic" and the other "topic".
+ *
+ * A blank value means the field was cleared: the relay reports a clear as a
+ * `topic_changed` / `purpose_changed` event carrying an empty string, not as a
+ * separate event type. Without this branch the timeline renders `changed the
+ * topic to ""`, which reads like the topic was set to two quote marks.
+ * Whitespace-only values are treated as cleared for the same reason.
+ */
+export function describeChannelTextFieldChange(
+ field: ChannelTextField,
+ value: string | null | undefined,
+): string {
+ const trimmed = value?.trim();
+ if (!trimmed) {
+ return `cleared the ${field}`;
+ }
+ return `changed the ${field} to ${OPEN_QUOTE}${trimmed}${CLOSE_QUOTE}`;
+}
+
+/**
+ * Adjusts a resolved display name for use inside a sentence rather than in the
+ * name slot at the top of a row — "added by you", "removed you from the channel".
+ *
+ * `resolveUserLabel` returns "You" for the current user, which is right standing
+ * alone and wrong mid-phrase. Agent ownership already draws the same distinction
+ * from the other side: `formatOwnerLabel` returns lowercase "you" because it is
+ * only ever read as "managed by you".
+ *
+ * `isSelf` is the caller's pubkey comparison, not an inspection of `label`.
+ * Matching on the string would also rewrite a different person whose display
+ * name happens to be "You" — the label is user-controlled, identity is not.
+ * Every name that isn't the reader's own is a proper noun and is returned
+ * untouched.
+ */
+export function toInlineName(label: string, isSelf: boolean): string {
+ return isSelf ? "you" : label;
+}
diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx
index 4410964dd9..c4637d2823 100644
--- a/desktop/src/features/messages/ui/SystemMessageRow.tsx
+++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx
@@ -28,6 +28,10 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
+import {
+ describeChannelTextFieldChange,
+ toInlineName,
+} from "../lib/systemEventCopy";
import { MessageAgentOwner } from "./MessageAgentOwner";
import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader";
import { MessageTimestamp } from "./MessageTimestamp";
@@ -180,6 +184,29 @@ function resolveDisplayLabel(
return resolveLabel(pubkey, currentPubkey, profiles);
}
+function isSelfPubkey(
+ pubkey: string | undefined,
+ currentPubkey: string | undefined,
+): boolean {
+ return Boolean(
+ pubkey &&
+ currentPubkey &&
+ normalizePubkey(pubkey) === normalizePubkey(currentPubkey),
+ );
+}
+
+/** Same label as `resolveDisplayLabel`, adjusted for mid-sentence use. */
+function resolveInlineDisplayLabel(
+ pubkey: string | undefined,
+ currentPubkey: string | undefined,
+ profiles: UserProfileLookup | undefined,
+): string {
+ return toInlineName(
+ resolveLabel(pubkey, currentPubkey, profiles),
+ isSelfPubkey(pubkey, currentPubkey),
+ );
+}
+
function isKnownAgentPubkey(
pubkey: string | undefined,
profiles: UserProfileLookup | undefined,
@@ -386,7 +413,7 @@ function MembershipPersonName({
pubkey={pubkey}
underlineOnHover
>
- {resolveDisplayLabel(pubkey, currentPubkey, profiles)}
+ {resolveInlineDisplayLabel(pubkey, currentPubkey, profiles)}
);
}
@@ -497,12 +524,17 @@ function describeSystemEvent(
currentPubkey,
profiles,
);
+ const inlineTargetLabel = resolveInlineDisplayLabel(
+ payload.target,
+ currentPubkey,
+ profiles,
+ );
const actorName = (
{actorLabel}
);
const targetName = (
- {targetLabel}
+ {inlineTargetLabel}
);
const membershipTitle = (
@@ -522,9 +554,13 @@ function describeSystemEvent(
title: membershipTitle,
action: (
<>
- was added by{" "}
+ added by{" "}
- {resolveDisplayLabel(payload.actor, currentPubkey, profiles)}
+ {resolveInlineDisplayLabel(
+ payload.actor,
+ currentPubkey,
+ profiles,
+ )}
, along with{" "}
- was added by{" "}
+ added by{" "}
- {resolveDisplayLabel(payload.actor, currentPubkey, profiles)}
+ {resolveInlineDisplayLabel(
+ payload.actor,
+ currentPubkey,
+ profiles,
+ )}
>
),
@@ -587,12 +627,12 @@ function describeSystemEvent(
case "topic_changed":
return {
title: actorName,
- action: <>changed the topic to “{payload.topic}”>,
+ action: describeChannelTextFieldChange("topic", payload.topic),
};
case "purpose_changed":
return {
title: actorName,
- action: <>changed the purpose to “{payload.purpose}”>,
+ action: describeChannelTextFieldChange("purpose", payload.purpose),
};
case "channel_created":
return {
diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts
index 694b5abef5..512eb3d800 100644
--- a/desktop/tests/e2e/mentions.spec.ts
+++ b/desktop/tests/e2e/mentions.spec.ts
@@ -1114,7 +1114,7 @@ test("system add rows use plain names while remove rows retain agent mention sty
const addedRow = page
.getByTestId("system-message-row")
.filter({ hasText: "portal" })
- .filter({ hasText: "was added by" });
+ .filter({ hasText: "added by" });
const removedRow = page
.getByTestId("system-message-row")
.filter({ hasText: "removed portal from the channel" });
@@ -1178,7 +1178,7 @@ test("groups member additions and joins with hidden names in the standard toolti
const groupedRow = page
.getByTestId("system-message-row")
- .filter({ hasText: "was added by Alice Chen" });
+ .filter({ hasText: "added by Alice Chen" });
for (const visibleName of [
"Erica Chapman",
"Peter Griffin",
@@ -1188,9 +1188,9 @@ test("groups member additions and joins with hidden names in the standard toolti
await expect(groupedRow).toContainText(visibleName);
}
await expect(
- groupedRow.locator("p").filter({ hasText: "was added by" }),
+ groupedRow.locator("p").filter({ hasText: "added by" }),
).toContainText(
- "was added by Alice Chen, along with Peter Griffin, Marcia Thomas, Jordan Lee, and 2 others",
+ "added by Alice Chen, along with Peter Griffin, Marcia Thomas, Jordan Lee, and 2 others",
);
await expect(groupedRow.locator("[data-mention]")).toHaveCount(0);
@@ -1200,6 +1200,11 @@ test("groups member additions and joins with hidden names in the standard toolti
await expect(visibleName).toHaveCSS("text-decoration-line", "underline");
const othersTrigger = groupedRow.getByRole("button", { name: "2 others" });
+ // Park the pointer off-target first: the previous hover leaves the mouse at a
+ // fixed viewport point, and any later reflow (new rows, scroll-to-bottom, a
+ // different text wrap) can slide this button under it. Without this the
+ // assertion measures where the mouse happens to be, not the resting style.
+ await page.mouse.move(0, 0);
await expect(othersTrigger).toHaveCSS("text-decoration-line", "none");
await othersTrigger.hover();
await expect(othersTrigger).toHaveCSS("text-decoration-line", "underline");
@@ -1242,10 +1247,17 @@ test("groups member additions and joins with hidden names in the standard toolti
const joinedOthersTrigger = joinedRow.getByRole("button", {
name: "2 others",
});
+ await page.mouse.move(0, 0);
await expect(joinedOthersTrigger).toHaveCSS("text-decoration-line", "none");
await joinedOthersTrigger.hover();
- await expect(page.getByRole("tooltip")).toContainText("Olivia Park");
- await expect(page.getByRole("tooltip")).toContainText("Sam Rivera");
+ // Scope to the *open* tooltip: the first row's tooltip stays mounted with
+ // data-state="closed" while it animates out, so a bare role=tooltip lookup
+ // matches two elements and trips strict mode.
+ const joinedTooltip = page.locator(
+ '[role="tooltip"]:not([data-state="closed"])',
+ );
+ await expect(joinedTooltip).toContainText("Olivia Park");
+ await expect(joinedTooltip).toContainText("Sam Rivera");
});
test("system agent profile only exposes message action", async ({ page }) => {
@@ -1277,7 +1289,7 @@ test("system agent profile only exposes message action", async ({ page }) => {
const joinedRow = page
.getByTestId("system-message-row")
.filter({ hasText: "mira" })
- .filter({ hasText: "was added by" });
+ .filter({ hasText: "added by" });
const agentName = joinedRow.getByText("mira", { exact: true });
await expect(agentName).toHaveText("mira");
await expect(agentName).not.toHaveAttribute("data-mention");
diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart
index 72554ee1a1..0372690e1e 100644
--- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart
+++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart
@@ -279,7 +279,12 @@ class _MembershipSystemMessageContent extends StatelessWidget {
TextSpan(
text: event.isSelfJoin
? 'joined the channel'
- : 'was added by ${resolveLabel(event.actorPubkey)}',
+ // No "was": the name renders on the line above via
+ // MessageAuthorMeta, so this reads as a status line rather than a
+ // sentence continuing across the metadata row. Matches desktop's
+ // SystemMessageRow. `SystemEvent.describe` keeps "was added by"
+ // because it builds subject and predicate into one string.
+ : 'added by ${resolveLabel(event.actorPubkey)}',
),
if (additionalTargets.isNotEmpty)
TextSpan(text: event.isSelfJoin ? ' along with ' : ', along with '),
diff --git a/mobile/lib/features/channels/timeline_message.dart b/mobile/lib/features/channels/timeline_message.dart
index 253c949703..c8fd96491b 100644
--- a/mobile/lib/features/channels/timeline_message.dart
+++ b/mobile/lib/features/channels/timeline_message.dart
@@ -101,9 +101,10 @@ class SystemEvent {
final target = resolveLabel(targetPubkey);
return '$actor removed $target from the channel';
}(),
- SystemEventType.topicChanged => '$actor changed the topic to "$topic"',
+ SystemEventType.topicChanged =>
+ '$actor ${_describeTextFieldChange('topic', topic)}',
SystemEventType.purposeChanged =>
- '$actor changed the purpose to "$purpose"',
+ '$actor ${_describeTextFieldChange('purpose', purpose)}',
SystemEventType.channelCreated => '$actor created this channel',
SystemEventType.channelArchived => '$actor archived this channel',
SystemEventType.channelUnarchived => '$actor unarchived this channel',
@@ -113,6 +114,25 @@ class SystemEvent {
}
}
+/// Caption fragment for a channel topic or purpose change, e.g.
+/// `changed the topic to "Release planning"` or `cleared the topic`.
+///
+/// A blank value means the field was cleared: the relay reports a clear as a
+/// `topic_changed` / `purpose_changed` event carrying an empty string, not as a
+/// separate event type. Without this branch the timeline renders
+/// `changed the topic to ""`, which reads as if the topic were set to two quote
+/// marks. Whitespace-only values are treated as cleared for the same reason.
+///
+/// Mirrors `describeChannelTextFieldChange` in
+/// `desktop/src/features/messages/lib/systemEventCopy.ts`.
+String _describeTextFieldChange(String field, String? value) {
+ final trimmed = value?.trim();
+ if (trimmed == null || trimmed.isEmpty) {
+ return 'cleared the $field';
+ }
+ return 'changed the $field to "$trimmed"';
+}
+
@immutable
class TimelineReaction {
final String emoji;
diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart
index 1c66093899..899394de23 100644
--- a/mobile/test/features/channels/channel_detail_page_test.dart
+++ b/mobile/test/features/channels/channel_detail_page_test.dart
@@ -1348,7 +1348,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Bob'), findsOneWidget);
- final addedAction = findRichText('was added by Alice');
+ final addedAction = findRichText('added by Alice');
expect(addedAction, findsOneWidget);
expect(find.text('Alice added Bob to the channel'), findsNothing);
expect(
@@ -1366,7 +1366,7 @@ void main() {
expect(timestampRect.left, greaterThan(nameRect.right));
final addedText = tester.widget(addedAction);
expect(
- effectiveFontSizeForText(addedText.text, 'was added by Alice'),
+ effectiveFontSizeForText(addedText.text, 'added by Alice'),
systemMessageBodyTextStyle.fontSize,
);
});
@@ -1435,7 +1435,7 @@ void main() {
expect(find.text('Bob'), findsOneWidget);
expect(
- findRichText('was added by Alice, along with Carol, Dave, Erin, and '),
+ findRichText('added by Alice, along with Carol, Dave, Erin, and '),
findsOneWidget,
);
expect(find.byKey(const Key('membership-overflow')), findsOneWidget);
diff --git a/mobile/test/features/channels/timeline_message_test.dart b/mobile/test/features/channels/timeline_message_test.dart
index 87f39d1c65..c29a12aef4 100644
--- a/mobile/test/features/channels/timeline_message_test.dart
+++ b/mobile/test/features/channels/timeline_message_test.dart
@@ -287,6 +287,53 @@ void main() {
);
});
+ // The relay reports a clear as a change carrying an empty string, so
+ // without the cleared branch this reads: changed the topic to "".
+ test('a blank topic or purpose reads as cleared', () {
+ for (final blank in [null, '', ' \n\t ']) {
+ expect(
+ SystemEvent(
+ type: SystemEventType.topicChanged,
+ actorPubkey: 'pk1',
+ topic: blank,
+ ).describe(resolve),
+ 'Alice cleared the topic',
+ );
+ expect(
+ SystemEvent(
+ type: SystemEventType.purposeChanged,
+ actorPubkey: 'pk1',
+ purpose: blank,
+ ).describe(resolve),
+ 'Alice cleared the purpose',
+ );
+ }
+ });
+
+ test('no caption announces empty quotes', () {
+ for (final value in [null, '', ' ', 'Real topic']) {
+ expect(
+ SystemEvent(
+ type: SystemEventType.topicChanged,
+ actorPubkey: 'pk1',
+ topic: value,
+ ).describe(resolve),
+ isNot(contains('""')),
+ );
+ }
+ });
+
+ test('surrounding whitespace is trimmed out of the quotes', () {
+ expect(
+ SystemEvent(
+ type: SystemEventType.topicChanged,
+ actorPubkey: 'pk1',
+ topic: ' Release v2 ',
+ ).describe(resolve),
+ 'Alice changed the topic to "Release v2"',
+ );
+ });
+
test('channel_created', () {
final event = SystemEvent(
type: SystemEventType.channelCreated,
From f3e5e812677f6f14bffe16a7aa02642d56faca4b Mon Sep 17 00:00:00 2001
From: Alex Kemper
Date: Thu, 30 Jul 2026 18:54:41 -0400
Subject: [PATCH 29/87] fix(catalog): update Amp tagline (#3806)
## Summary
Update Amp's runtime catalog description to use its current tagline:
> The coding agent and development environment that runs anywhere and
everywhere.
### Related issue
N/A. This follows the Amp description update in
https://github.com/block/buzz/pull/3758.
### Testing
* `pnpm -C desktop check`
* `pnpm -C desktop typecheck`
* `pnpm -C desktop test` (3,835 passed)
No screenshot is included because this changes only the catalog
description text. It does not change layout or interaction behavior.
Signed-off-by: AJKemps
Co-authored-by: AJKemps
Co-authored-by: Alex Kemper
---
desktop/src/features/settings/ui/harnessCatalogCopy.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/desktop/src/features/settings/ui/harnessCatalogCopy.ts b/desktop/src/features/settings/ui/harnessCatalogCopy.ts
index 70439091b2..9a71ff70f9 100644
--- a/desktop/src/features/settings/ui/harnessCatalogCopy.ts
+++ b/desktop/src/features/settings/ui/harnessCatalogCopy.ts
@@ -36,7 +36,7 @@ const HARNESS_DESCRIPTIONS: Record = {
// https://moonshotai.github.io/kimi-cli/en/
kimi: "A terminal coding agent for software development and command-line tasks.",
// Sources: https://ampcode.com, https://ampcode.com/manual
- amp: "A coding agent for your terminal and editor.",
+ amp: "The coding agent and development environment that runs anywhere and everywhere.",
// Sources: https://github.com/NousResearch/hermes-agent,
// https://hermes-agent.nousresearch.com/docs/
hermes: "A general-purpose AI agent from Nous Research.",
From 468647a51f858b29d27eaf9fd07bf90294f99d39 Mon Sep 17 00:00:00 2001
From: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Date: Thu, 30 Jul 2026 20:58:03 -0400
Subject: [PATCH 30/87] feat(desktop): locally stored NIP-49 encrypted key
backup (#2937)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Adds a locally stored **NIP-49 encrypted key backup** (`ncryptsec`) to
the desktop app, per the plan reviewed in buzz-development (Rev 3,
approved 9/10 by Wren; implementation also reviewed and approved 9/10).
**Two-artifact design — canonical bytes originate entirely in Rust:**
- `create_ncryptsec_backup` runs under the `identity_mutation` lock:
encrypt → decrypt-verify against the live pubkey → atomic `0o600` write
to `{app_data_dir}/identity.ncryptsec` → reread/byte-compare → return
the exact persisted bytes. The frontend never re-derives or re-encrypts.
- `save_ncryptsec_copy` writes a portable copy via the save dialog
(parse-gated, secret-file semantics) and never mutates canonical state.
- `generate_backup_passphrase`: 6 words from the EFF short wordlist via
`OsRng` (custom passphrases min 12 chars).
- Import accepts `ncryptsec1` with optional password; the raw-`nsec`
path is untouched. Different-pubkey import and sign-out wipe the
app-managed backup (post-commit, best-effort — a failed import can never
destroy the still-live identity's backup; regression-tested).
**Never-relay guarantee (egress guard + tripwires):**
- `egress_guard.rs` fail-closed at all 8 `/events` submission boundaries
(relay submit funnel, 3× `relay.rs`, huddle STT, both engram submitters,
native WS choke point), rejecting `ncryptsec1`/`NCRYPTSEC1` in text and
binary frames. Scope is deliberately ncryptsec-only: pairing
intentionally carries raw nsec inside its encrypted session.
- Site-granular `/events` inventory tripwire: per-file (`/events` count,
guard-call count) pairs; unlisted files expect zero. Mutation-style
tests prove a ninth site in an existing file, a removed guard, and a new
unlisted file all fail the scan.
- ncryptsec source-allowlist scans in **both** trees (Rust + TS).
**Frontend:** onboarding `BackupStep` is encrypted-by-default — the
default path never invokes `get_nsec` (e2e asserts the command log).
Raw-nsec export stays behind an explicit click with prior semantics.
Shared `EncryptedBackupCreator` powers onboarding + a new settings row;
the import form auto-switches to encrypted mode on `ncryptsec1` paste
(case-insensitive HRP).
**Open product call for @tlongwell-block:** onboarding default is
*encrypted* in this PR; flipping to raw-default is a small change either
way (documented in the plan).
Review history: plan Rev 3 and the implementation were both iterated
with Wren to 9/10 (two blockers from round 1 — import ordering,
inventory granularity — plus an uppercase-bech32 hardening gap, all
fixed in `dde37183e`). Thread: buzz-development.
### Related issue
Follow-up to the direction explored in #385 (NIP-PB, closed) — this
ships local NIP-49 (the standard) instead of a new NIP. No open
duplicate found.
### Testing
All at exactly `dde37183e` (same shell, HEAD verified):
- `cargo test` — 1680 passed / 0 failed / 14 ignored (includes a
deliberate ~70s log_n-18 NIP-49 round trip, spec vector, wrong-password,
NFKC, uppercase-vector decrypt, injection test per egress boundary,
inventory mutation tests, import-ordering regression tests)
- `cargo clippy --all-targets -- -D warnings` — clean; `cargo fmt
--check` — clean
- `pnpm typecheck` — clean; JS unit suite 3529/3529; biome (repo-pinned
2.4.16) clean
- Playwright `onboarding-backup` / `onboarding` /
`onboarding-agent-defaults` / `profile-nsec-reveal` — 86 passed, 1 known
avatar-reservation flake (passed on rerun; untouched by this diff).
`passThroughBackupStep` now exercises the encrypted default, so every
downstream onboarding spec covers the new path.
- Note: browser e2e fakes the crypto via the mock bridge (fixed
spec-vector blob); decryption correctness is proven in the Rust tests.
## Latest onboarding integration
The current head adds an additive `IdentityInfo.storage` field
(`ephemeral`, `system-keyring`, `local-file`, or `environment`) so
onboarding can accurately explain where the active identity is
protected. It surfaces storage metadata only—never key material—and
leaves the existing lost/keyring-locked recovery behavior intact.
---------
Signed-off-by: Tyler Longwell
Signed-off-by: Taylor Ho
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell
Co-authored-by: Taylor Ho
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
---
.../texture-card/generate-card-texture.mjs | 159 ++--
desktop/src-tauri/src/app_state.rs | 97 +-
desktop/src-tauri/src/app_state_tests.rs | 12 +-
desktop/src-tauri/src/commands/identity.rs | 117 ++-
desktop/src-tauri/src/identity_storage.rs | 62 ++
desktop/src-tauri/src/key_backup.rs | 47 +
desktop/src-tauri/src/key_backup_tests.rs | 79 +-
desktop/src-tauri/src/lib.rs | 1 +
desktop/src-tauri/src/models.rs | 2 +
desktop/src-tauri/src/reset.rs | 20 +
.../onboarding/lib/encryptedBackup.test.mjs | 118 +++
.../onboarding/lib/encryptedBackup.ts | 135 +++
.../onboarding/lib/keyImportInput.test.mjs | 73 ++
.../features/onboarding/lib/keyImportInput.ts | 127 +++
.../onboarding/ui/BackupPasswordTimeline.tsx | 130 +++
.../src/features/onboarding/ui/BackupStep.tsx | 495 +++++++---
.../features/onboarding/ui/BackupTestFlow.tsx | 745 +++++++++++++++
.../onboarding/ui/DownloadKeyStep.tsx | 139 +++
.../onboarding/ui/EncryptedBackupCreator.tsx | 885 ++++++++++++++++++
.../onboarding/ui/KeyringLockedScreen.tsx | 4 +-
.../onboarding/ui/MachineOnboardingFlow.tsx | 158 +++-
.../onboarding/ui/NostrKeyImportForm.tsx | 561 ++++++-----
.../onboarding/ui/OnboardingChrome.tsx | 15 +-
.../features/onboarding/ui/OnboardingFlow.tsx | 4 +-
.../ui/OnboardingSlideTransition.tsx | 1 +
.../src/features/onboarding/ui/SetupStep.tsx | 41 +-
.../ui/onboardingFlowSteps.test.mjs | 24 +-
.../features/settings/ui/SignOutSection.tsx | 38 +-
desktop/src/shared/api/identityTypes.ts | 28 +
desktop/src/shared/api/tauriIdentity.ts | 11 +-
desktop/src/shared/api/types.ts | 20 +-
.../shared/lib/ncryptsecSourceScan.test.mjs | 74 ++
.../src/shared/styles/globals/components.css | 85 +-
desktop/src/shared/ui/alert-dialog.tsx | 69 +-
.../shared/ui/assets/card-texture-compact.png | Bin 0 -> 232377 bytes
.../ui/assets/card-texture-dark-compact.png | Bin 0 -> 328178 bytes
.../shared/ui/assets/card-texture-dark.png | Bin 0 -> 1678571 bytes
desktop/src/shared/ui/card-texture.css | 47 +-
desktop/src/shared/ui/card.tsx | 46 +-
desktop/src/shared/ui/popover.tsx | 73 +-
desktop/src/testing/e2eBridge.ts | 65 +-
desktop/tests/e2e/harness-management.spec.ts | 9 +-
desktop/tests/e2e/onboarding-backup.spec.ts | 333 ++++++-
.../onboarding-docked-cta-screenshots.spec.ts | 99 +-
desktop/tests/e2e/onboarding.spec.ts | 125 +++
.../tests/e2e/signout-confirmation.spec.ts | 34 +-
desktop/tests/helpers/fileDrag.ts | 52 +
desktop/tests/helpers/onboarding.ts | 3 +-
48 files changed, 4728 insertions(+), 734 deletions(-)
create mode 100644 desktop/src-tauri/src/identity_storage.rs
create mode 100644 desktop/src/features/onboarding/lib/encryptedBackup.test.mjs
create mode 100644 desktop/src/features/onboarding/lib/encryptedBackup.ts
create mode 100644 desktop/src/features/onboarding/lib/keyImportInput.test.mjs
create mode 100644 desktop/src/features/onboarding/lib/keyImportInput.ts
create mode 100644 desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx
create mode 100644 desktop/src/features/onboarding/ui/BackupTestFlow.tsx
create mode 100644 desktop/src/features/onboarding/ui/DownloadKeyStep.tsx
create mode 100644 desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx
create mode 100644 desktop/src/shared/api/identityTypes.ts
create mode 100644 desktop/src/shared/lib/ncryptsecSourceScan.test.mjs
create mode 100644 desktop/src/shared/ui/assets/card-texture-compact.png
create mode 100644 desktop/src/shared/ui/assets/card-texture-dark-compact.png
create mode 100644 desktop/src/shared/ui/assets/card-texture-dark.png
create mode 100644 desktop/tests/helpers/fileDrag.ts
diff --git a/desktop/scripts/texture-card/generate-card-texture.mjs b/desktop/scripts/texture-card/generate-card-texture.mjs
index 75cc24e744..57ebc61a9b 100644
--- a/desktop/scripts/texture-card/generate-card-texture.mjs
+++ b/desktop/scripts/texture-card/generate-card-texture.mjs
@@ -12,83 +12,116 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
-const OUTPUT = path.resolve(
- HERE,
- "../../src/shared/ui/assets/card-texture.png",
-);
-
-// CSS-pixel source geometry. Screenshotting at DPR 2 produces a crisp asset.
-const CARD_SIZE = 640;
-const OUTSET = 96;
-const CAPTURE_SIZE = CARD_SIZE + OUTSET * 2;
+const OUTPUT_DIRECTORY = path.resolve(HERE, "../../src/shared/ui/assets");
const DPR = 2;
// Approved texture parameters, archived from the former runtime SVG filter.
-const BLUR = 66;
-const DILATE = Math.round(BLUR * 0.85);
const THRESHOLD_BIAS = 0.302;
const SLOPE = 8;
const FREQUENCY = 0.999;
const OCTAVES = 3;
const SEED = 5315;
-await mkdir(path.dirname(OUTPUT), { recursive: true });
+const TEXTURES = [
+ {
+ filename: "card-texture.png",
+ color: "white",
+ cardSize: 640,
+ outset: 96,
+ blur: 66,
+ innerBand: 112,
+ },
+ {
+ filename: "card-texture-dark.png",
+ color: "#171b21",
+ cardSize: 640,
+ outset: 96,
+ blur: 66,
+ innerBand: 112,
+ },
+ {
+ filename: "card-texture-compact.png",
+ color: "white",
+ cardSize: 320,
+ outset: 24,
+ blur: 24,
+ innerBand: 44,
+ },
+ {
+ filename: "card-texture-dark-compact.png",
+ color: "#171b21",
+ cardSize: 320,
+ outset: 24,
+ blur: 24,
+ innerBand: 44,
+ },
+];
+
+await mkdir(OUTPUT_DIRECTORY, { recursive: true });
const browser = await chromium.launch();
try {
- const page = await browser.newPage({
- deviceScaleFactor: DPR,
- viewport: { height: CAPTURE_SIZE, width: CAPTURE_SIZE },
- });
+ for (const texture of TEXTURES) {
+ const captureSize = texture.cardSize + texture.outset * 2;
+ const dilate = Math.round(texture.blur * 0.85);
+ const output = path.join(OUTPUT_DIRECTORY, texture.filename);
+ const page = await browser.newPage({
+ deviceScaleFactor: DPR,
+ viewport: { height: captureSize, width: captureSize },
+ });
- await page.setContent(`
-
-
-
-
-
`);
+ await page.setContent(`
+
+
+
+
+
`);
- await page.locator("#stage").screenshot({
- omitBackground: true,
- path: OUTPUT,
- });
+ await page.locator("#stage").screenshot({
+ omitBackground: true,
+ path: output,
+ });
+ await page.close();
+
+ console.log(`Generated ${output}`);
+ console.log(`Asset: ${captureSize * DPR}×${captureSize * DPR}px @${DPR}x`);
+ console.log(
+ `Runtime slice: ${(texture.outset + texture.innerBand) * DPR}px; outset: ${texture.outset}px`,
+ );
+ }
} finally {
await browser.close();
}
-
-console.log(`Generated ${OUTPUT}`);
-console.log(`Asset: ${CAPTURE_SIZE * DPR}×${CAPTURE_SIZE * DPR}px @${DPR}x`);
-console.log(`Runtime slice: ${(OUTSET + 112) * DPR}px; outset: ${OUTSET}px`);
diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs
index 94d162e620..abce86202a 100644
--- a/desktop/src-tauri/src/app_state.rs
+++ b/desktop/src-tauri/src/app_state.rs
@@ -2,7 +2,7 @@ use std::{
collections::HashMap,
io::Write,
sync::{
- atomic::{AtomicBool, AtomicU16},
+ atomic::{AtomicBool, AtomicU16, AtomicU8},
Arc, Mutex,
},
};
@@ -13,10 +13,15 @@ use tauri::{AppHandle, Manager};
use tokio::sync::Mutex as AsyncMutex;
use crate::huddle::HuddleState;
+pub(crate) use crate::identity_storage::{IdentityStorage, RecoveryState, ResolvedIdentity};
use crate::managed_agents::config_bridge::SessionConfigCache;
use crate::managed_agents::{ManagedAgentPairRuntime, ManagedAgentRuntimeKey};
+
pub struct AppState {
pub keys: Mutex,
+ /// Durable backend holding `keys`. Updated after the key write and before
+ /// recovery flags are cleared so `get_identity` reports a consistent state.
+ pub(crate) identity_storage: AtomicU8,
pub http_client: reqwest::Client,
/// A no-redirect client for authenticated relay media fetches (download,
/// clipboard copy, snapshot, editor). Every caller pre-validates the URL
@@ -178,19 +183,20 @@ pub fn build_media_fetch_client() -> reqwest::Result {
pub fn build_app_state() -> AppState {
// Env var takes precedence (dev/CI). If absent, resolve_persisted_identity()
// in setup() will replace the ephemeral placeholder with a persisted key.
- let keys = match identity_from_env() {
+ let (keys, identity_storage) = match identity_from_env() {
Some(keys) => {
eprintln!(
"buzz-desktop: configured identity pubkey {}",
keys.public_key().to_hex()
);
- keys
+ (keys, IdentityStorage::Environment)
}
- None => Keys::generate(),
+ None => (Keys::generate(), IdentityStorage::Ephemeral),
};
AppState {
keys: Mutex::new(keys),
+ identity_storage: AtomicU8::new(identity_storage as u8),
http_client: reqwest::Client::builder()
.resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0)))
.pool_idle_timeout(std::time::Duration::from_secs(10))
@@ -366,9 +372,13 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
let resolved = load_or_create_identity(&data_dir)?;
- // Write keys before setting the recovery flags (Release) so any thread
- // that reads a flag as false with Acquire is guaranteed to see the keys.
- *state.keys.lock().map_err(|e| e.to_string())? = resolved.keys;
+ // Write keys and storage before setting the recovery flags (Release) so
+ // any thread that reads a flag as false with Acquire sees consistent data.
+ {
+ let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?;
+ *active_keys = resolved.keys;
+ state.set_identity_storage(resolved.storage);
+ }
state.identity_lost.store(
resolved.recovery == RecoveryState::Lost,
std::sync::atomic::Ordering::Release,
@@ -394,26 +404,6 @@ const IDENTITY_KEY_NAME: &str = "identity";
/// keyring is merely unreachable (the key IS in the keyring, must NOT generate).
const MIGRATION_MARKER_NAME: &str = "identity.migrated";
-/// Recovery state produced by identity resolution. `None` means the app has
-/// a real, usable identity. `Lost` means the keyring was reachable-but-empty
-/// despite a prior successful migration — the key vanished externally. `KeyringLocked`
-/// means the keyring is unreachable this boot but was used in the past
-/// (marker present, no file) — the key still exists but is temporarily
-/// inaccessible. Both non-`None` variants boot with an ephemeral key; the
-/// frontend shows a different recovery screen for each.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum RecoveryState {
- None,
- Lost,
- KeyringLocked,
-}
-
-/// The output of identity resolution.
-struct ResolvedIdentity {
- keys: Keys,
- recovery: RecoveryState,
-}
-
/// The keyring operations the identity resolution flow needs. Abstracted so the
/// corrupt-keyring recovery decision ([`recover_from_keyring`]) can be
/// unit-tested against a fake without touching the live OS keyring.
@@ -465,6 +455,7 @@ fn load_or_create_identity(data_dir: &std::path::Path) -> Result Result<(), String> {
+) -> Result {
match persist_identity_to_keyring(store, keys, legacy_path, data_dir) {
- Ok(()) => Ok(()),
+ Ok(()) => Ok(IdentityStorage::SystemKeyring),
Err(e) => {
eprintln!(
"buzz-desktop: keyring write failed during import ({e}), \
falling back to identity.key"
);
- save_key_file(legacy_path, keys)
+ save_key_file(legacy_path, keys)?;
+ Ok(IdentityStorage::LocalFile)
}
}
}
@@ -892,7 +897,7 @@ pub(crate) fn persist_imported_identity(
keys: &Keys,
legacy_path: &std::path::Path,
data_dir: &std::path::Path,
-) -> Result<(), String> {
+) -> Result {
persist_imported_identity_impl(store, keys, legacy_path, data_dir)
}
@@ -920,15 +925,6 @@ fn write_migration_marker(marker_path: &std::path::Path) -> Result<(), String> {
.map_err(|e| format!("commit migration marker: {e}"))
}
-/// Which backend [`store_key_preferring_keyring`] wrote to. The caller writes
-/// the migration marker only after a keyring success — on the file-fallback arm
-/// the key is on disk and a marker would wrongly trip the next Unreachable boot
-/// into failing closed.
-enum PersistBackend {
- Keyring,
- File,
-}
-
/// Generate a fresh identity, persist it through the store, return it.
///
/// On a keyring-backed persist no file is written, so a later
@@ -940,9 +936,10 @@ fn generate_and_persist(
store: &impl IdentityKeyStore,
legacy_path: &std::path::Path,
data_dir: &std::path::Path,
-) -> Result {
+) -> Result<(Keys, IdentityStorage), String> {
let keys = Keys::generate();
- if let PersistBackend::Keyring = store_key_preferring_keyring(store, &keys, legacy_path)? {
+ let storage = store_key_preferring_keyring(store, &keys, legacy_path)?;
+ if storage == IdentityStorage::SystemKeyring {
let marker_path = migration_marker_path(data_dir);
if let Err(e) = write_migration_marker(&marker_path) {
eprintln!(
@@ -956,7 +953,7 @@ fn generate_and_persist(
"buzz-desktop: generated and saved identity pubkey {}",
keys.public_key().to_hex()
);
- Ok(keys)
+ Ok((keys, storage))
}
/// Persist `keys` through the store, silently falling back to the `0o600` file
@@ -968,17 +965,17 @@ fn store_key_preferring_keyring(
store: &impl IdentityKeyStore,
keys: &Keys,
legacy_path: &std::path::Path,
-) -> Result {
+) -> Result {
let nsec = keys
.secret_key()
.to_bech32()
.map_err(|e| format!("encode nsec: {e}"))?;
match store.store(IDENTITY_KEY_NAME, &nsec) {
- Ok(()) => Ok(PersistBackend::Keyring),
+ Ok(()) => Ok(IdentityStorage::SystemKeyring),
Err(keyring_err) => {
eprintln!("buzz-desktop: keyring write failed ({keyring_err}), using file fallback");
save_key_file(legacy_path, keys)?;
- Ok(PersistBackend::File)
+ Ok(IdentityStorage::LocalFile)
}
}
}
diff --git a/desktop/src-tauri/src/app_state_tests.rs b/desktop/src-tauri/src/app_state_tests.rs
index 485dfaea15..751bcf22e5 100644
--- a/desktop/src-tauri/src/app_state_tests.rs
+++ b/desktop/src-tauri/src/app_state_tests.rs
@@ -484,7 +484,7 @@ fn fresh_keyring_generate_writes_marker() {
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
// The key was stored in the keyring (not the file), and the marker marks it.
- assert!(!legacy_path.exists());
+ assert!(!legacy_path.exists() && resolved.storage == IdentityStorage::SystemKeyring);
assert!(migration_marker_path(dir.path()).exists());
assert_eq!(
store
@@ -541,7 +541,10 @@ fn fresh_generate_keyring_failure_falls_back_to_file_without_marker() {
let from_file = load_key_file(&legacy_path).unwrap();
assert_key_eq(&resolved.keys, &from_file);
// No marker: the file is the authoritative store, not the keyring.
- assert!(!migration_marker_path(dir.path()).exists());
+ assert!(
+ !migration_marker_path(dir.path()).exists()
+ && resolved.storage == IdentityStorage::LocalFile
+ );
}
// ── New tests for the three defects fixed in this PR ─────────────────────
@@ -786,10 +789,7 @@ fn persist_imported_identity_falls_back_to_file_on_keyring_failure() {
let result = persist_imported_identity_impl(&store, &imported_keys, &legacy_path, dir.path());
// The policy core handles the keyring failure — Ok, not Err.
- assert!(
- result.is_ok(),
- "must not propagate keyring failure when file fallback succeeds"
- );
+ assert_eq!(result.unwrap(), IdentityStorage::LocalFile);
// Key is recoverable from the file on next boot.
let from_file = load_key_file(&legacy_path).unwrap();
diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs
index 142e3bac88..33ecf3cfca 100644
--- a/desktop/src-tauri/src/commands/identity.rs
+++ b/desktop/src-tauri/src/commands/identity.rs
@@ -43,6 +43,7 @@ pub fn get_identity(state: State<'_, AppState>) -> Result
Ok(IdentityInfo {
pubkey: pubkey_hex,
display_name,
+ storage: state.identity_storage().as_str().to_string(),
lost,
locked,
reset_failed,
@@ -334,11 +335,17 @@ pub async fn save_ncryptsec_copy(
#[tauri::command]
pub async fn import_identity(
nsec: String,
+ password: Option,
app_handle: tauri::AppHandle,
) -> Result {
tokio::task::spawn_blocking(move || {
- let trimmed = nsec.trim();
- let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?;
+ // NIP-49 backups require a passphrase and decrypt entirely in Rust.
+ // Raw nsec/hex input follows the existing parser path unchanged.
+ let password = password.map(zeroize::Zeroizing::new);
+ let keys = crate::key_backup::recover_keys_from_input(
+ &nsec,
+ password.as_ref().map(|value| value.as_str()),
+ )?;
// Serialize against persist_current_identity: hold this guard for the
// full function body so a concurrent stale persist can't overwrite
@@ -353,30 +360,14 @@ pub async fn import_identity(
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
let key_path = data_dir.join("identity.key");
- // Persist into the OS keyring first (store → read-back verify → marker →
- // delete file). Falls back to the 0o600 file when the keyring is
- // unavailable; returns Err only when both backends fail.
- let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service());
- crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?;
-
- // Update in-memory keys BEFORE clearing recovery flags. The Release
- // stores below pair with Acquire loads in get_identity: a reader
- // observing false is guaranteed to see the updated keys.
- let pubkey = keys.public_key();
- *state.keys.lock().map_err(|e| e.to_string())? = keys;
-
- // Clear both recovery flags — an import is valid in either lost or
- // keyring-locked state and resolves both. In the locked case the
- // keyring is unreachable, so persist_imported_identity already fell
- // back to identity.key; on the next Unreachable boot the file is
- // loaded directly and when the keyring returns the adoption path
- // picks it up.
- state
- .identity_lost
- .store(false, std::sync::atomic::Ordering::Release);
- state
- .keyring_locked
- .store(false, std::sync::atomic::Ordering::Release);
+ let (pubkey, storage) = commit_imported_identity(&state, &data_dir, keys, |keys| {
+ // Persist into the OS keyring first (store → read-back verify →
+ // marker → delete file). Falls back to the 0o600 file when the
+ // keyring is unavailable; returns Err only when both backends fail.
+ let store =
+ crate::secret_store::SecretStore::shared(crate::app_state::keyring_service());
+ crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir)
+ })?;
let pubkey_hex = pubkey.to_hex();
let display_name = truncated_display_name(&pubkey)?;
@@ -386,6 +377,7 @@ pub async fn import_identity(
Ok(IdentityInfo {
pubkey: pubkey_hex,
display_name,
+ storage: storage.as_str().to_string(),
lost: false,
locked: false,
reset_failed: false,
@@ -395,6 +387,69 @@ pub async fn import_identity(
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
+/// Commit an imported identity: durably persist, swap in-memory keys, clear
+/// recovery flags, then remove the previous identity's stale app-managed
+/// backup. Caller must hold `state.identity_mutation`.
+///
+/// Ordering is the contract:
+///
+/// 1. `persist` runs FIRST. If it fails (`Err` from both keyring and file
+/// fallback), nothing has changed — the previous identity stays live in
+/// memory AND its valid canonical `identity.ncryptsec` stays on disk.
+/// 2. Only after durable persistence do we swap `state.keys` and clear the
+/// recovery flags.
+/// 3. Stale-backup cleanup runs LAST and is deliberately best-effort: at that
+/// point the import is durably committed, so reporting a cleanup failure
+/// as a command `Err` would claim a half-applied import that actually
+/// succeeded. The leftover blob is still passphrase-encrypted and is
+/// replaced by the next backup creation; we log and move on.
+fn commit_imported_identity(
+ state: &AppState,
+ data_dir: &std::path::Path,
+ keys: nostr::Keys,
+ persist: impl FnOnce(&nostr::Keys) -> Result,
+) -> Result<(nostr::PublicKey, crate::app_state::IdentityStorage), String> {
+ // Capture the previous pubkey up front for post-commit cleanup.
+ let previous_pubkey = state.keys.lock().map_err(|e| e.to_string())?.public_key();
+
+ let storage = persist(&keys)?;
+
+ // Update in-memory keys BEFORE clearing recovery flags. The Release
+ // stores below pair with Acquire loads in get_identity: a reader
+ // observing false is guaranteed to see the updated keys.
+ let pubkey = keys.public_key();
+ {
+ let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?;
+ *active_keys = keys;
+ state.set_identity_storage(storage);
+ }
+
+ // Clear both recovery flags — an import is valid in either lost or
+ // keyring-locked state and resolves both. In the locked case the
+ // keyring is unreachable, so the persist step already fell back to
+ // identity.key; on the next Unreachable boot the file is loaded
+ // directly and when the keyring returns the adoption path picks it up.
+ state
+ .identity_lost
+ .store(false, std::sync::atomic::Ordering::Release);
+ state
+ .keyring_locked
+ .store(false, std::sync::atomic::Ordering::Release);
+
+ // Importing a different identity invalidates the app-managed backup: it
+ // encrypts the previous key and must not linger mislabeled. Best-effort
+ // per the ordering contract above.
+ if let Err(e) = crate::key_backup::cleanup_stale_backup(&previous_pubkey, &pubkey, data_dir) {
+ eprintln!(
+ "buzz-desktop: import committed, but stale key backup cleanup failed: {e}; \
+ the leftover identity.ncryptsec encrypts the PREVIOUS key and will be \
+ replaced by the next backup creation"
+ );
+ }
+
+ Ok((pubkey, storage))
+}
+
/// Make the current ephemeral identity durable by persisting it to the OS
/// keyring (or falling back to identity.key). This is called when the user
/// chooses to start a new identity instead of re-importing their previous one
@@ -438,11 +493,12 @@ pub async fn persist_current_identity(
let key_path = data_dir.join("identity.key");
let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service());
- crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?;
+ let storage =
+ crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?;
- // Keys are already the live identity — only clear identity_lost.
- // Release pairs with Acquire in get_identity so readers see
- // consistent state.
+ // Keys are already the live identity. Record where the durable write
+ // landed before clearing identity_lost.
+ state.set_identity_storage(storage);
state
.identity_lost
.store(false, std::sync::atomic::Ordering::Release);
@@ -454,6 +510,7 @@ pub async fn persist_current_identity(
Ok(IdentityInfo {
pubkey: pubkey_hex,
display_name,
+ storage: storage.as_str().to_string(),
lost: false,
locked: false,
reset_failed: false,
diff --git a/desktop/src-tauri/src/identity_storage.rs b/desktop/src-tauri/src/identity_storage.rs
new file mode 100644
index 0000000000..b39c1a0331
--- /dev/null
+++ b/desktop/src-tauri/src/identity_storage.rs
@@ -0,0 +1,62 @@
+use nostr::Keys;
+
+use crate::app_state::AppState;
+
+/// Durable location of the active human identity.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[repr(u8)]
+pub(crate) enum IdentityStorage {
+ Ephemeral = 0,
+ SystemKeyring = 1,
+ LocalFile = 2,
+ Environment = 3,
+}
+
+impl IdentityStorage {
+ pub(crate) fn as_str(self) -> &'static str {
+ match self {
+ Self::Ephemeral => "ephemeral",
+ Self::SystemKeyring => "system-keyring",
+ Self::LocalFile => "local-file",
+ Self::Environment => "environment",
+ }
+ }
+
+ fn from_u8(value: u8) -> Self {
+ match value {
+ 1 => Self::SystemKeyring,
+ 2 => Self::LocalFile,
+ 3 => Self::Environment,
+ _ => Self::Ephemeral,
+ }
+ }
+}
+
+impl AppState {
+ pub(crate) fn identity_storage(&self) -> IdentityStorage {
+ IdentityStorage::from_u8(
+ self.identity_storage
+ .load(std::sync::atomic::Ordering::Acquire),
+ )
+ }
+
+ pub(crate) fn set_identity_storage(&self, storage: IdentityStorage) {
+ self.identity_storage
+ .store(storage as u8, std::sync::atomic::Ordering::Release);
+ }
+}
+
+/// Recovery state produced by identity resolution.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum RecoveryState {
+ None,
+ Lost,
+ KeyringLocked,
+}
+
+/// Identity and persistence metadata produced by startup resolution.
+pub(crate) struct ResolvedIdentity {
+ pub(crate) keys: Keys,
+ pub(crate) recovery: RecoveryState,
+ pub(crate) storage: IdentityStorage,
+}
diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs
index 6396911aef..f97bf95a67 100644
--- a/desktop/src-tauri/src/key_backup.rs
+++ b/desktop/src-tauri/src/key_backup.rs
@@ -13,6 +13,10 @@
use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity};
use nostr::{FromBech32, Keys, ToBech32};
+/// Bech32 prefix of NIP-49 encrypted secret keys. Import routing is
+/// case-insensitive because bech32 permits all-uppercase encodings.
+pub const NCRYPTSEC_HRP: &str = "ncryptsec1";
+
/// scrypt cost for new backups (2^18 — Gossip's desktop default, ~256 MiB).
/// The blob self-describes its cost, so this can be raised later without
/// breaking existing backups.
@@ -108,6 +112,27 @@ pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result {
Ok(Keys::new(secret_key))
}
+/// Recover identity keys from either an encrypted NIP-49 backup or the raw
+/// nsec/hex formats accepted before encrypted imports were added.
+pub fn recover_keys_from_input(input: &str, password: Option<&str>) -> Result {
+ let trimmed = input.trim();
+ let is_ncryptsec = trimmed
+ .get(..NCRYPTSEC_HRP.len())
+ .is_some_and(|prefix| prefix.eq_ignore_ascii_case(NCRYPTSEC_HRP));
+
+ if is_ncryptsec {
+ let password = password.ok_or_else(|| "key backup requires a password".to_string())?;
+ decrypt_ncryptsec(trimmed, password)
+ } else {
+ Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))
+ }
+}
+
+/// Path of the canonical app-managed backup file.
+pub fn backup_file_path(data_dir: &std::path::Path) -> std::path::PathBuf {
+ data_dir.join(BACKUP_FILE_NAME)
+}
+
/// Atomically write `ncryptsec` to `path` with owner-only permissions, then
/// reread and byte-compare. Same crash-safety pattern as
/// `app_state::save_key_file`.
@@ -140,6 +165,28 @@ pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(),
Ok(())
}
+/// Delete the app-managed backup if present. Missing files are already clean.
+pub fn delete_backup_file(data_dir: &std::path::Path) -> Result<(), String> {
+ let path = backup_file_path(data_dir);
+ match std::fs::remove_file(path) {
+ Ok(()) => Ok(()),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ Err(e) => Err(format!("delete stale backup file: {e}")),
+ }
+}
+
+/// Remove the app-managed backup only when an import changes identities.
+pub fn cleanup_stale_backup(
+ previous: &nostr::PublicKey,
+ new: &nostr::PublicKey,
+ data_dir: &std::path::Path,
+) -> Result<(), String> {
+ if previous != new {
+ delete_backup_file(data_dir)?;
+ }
+ Ok(())
+}
+
/// Generate a passphrase of `word_count` EFF short-wordlist words joined by
/// `separator`, using OS entropy.
///
diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs
index e5892ad99e..b9713201e1 100644
--- a/desktop/src-tauri/src/key_backup_tests.rs
+++ b/desktop/src-tauri/src/key_backup_tests.rs
@@ -79,12 +79,59 @@ fn verify_backup_blob_catches_pubkey_mismatch() {
assert!(err.contains("does not match identity"), "{err}");
}
+// ── Import key recovery ───────────────────────────────────────────────────────
+
+#[test]
+fn recover_keys_ncryptsec_happy_path() {
+ let keys = recover_keys_from_input(&format!(" {SPEC_NCRYPTSEC}\n"), Some("nostr")).unwrap();
+ assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX);
+}
+
+#[test]
+fn recover_keys_ncryptsec_requires_password() {
+ let err = recover_keys_from_input(SPEC_NCRYPTSEC, None).unwrap_err();
+ assert_eq!(err, "key backup requires a password");
+}
+
+#[test]
+fn recover_keys_ncryptsec_wrong_password() {
+ let err = recover_keys_from_input(SPEC_NCRYPTSEC, Some("wrong")).unwrap_err();
+ assert_eq!(err, "wrong backup password or damaged key backup");
+}
+
+#[test]
+fn recover_keys_uppercase_ncryptsec_classifies_as_encrypted() {
+ let upper = SPEC_NCRYPTSEC.to_ascii_uppercase();
+ assert_eq!(
+ recover_keys_from_input(&upper, None).unwrap_err(),
+ "key backup requires a password"
+ );
+ let keys = recover_keys_from_input(&upper, Some("nostr")).unwrap();
+ assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX);
+
+ let mut mixed = SPEC_NCRYPTSEC.to_string();
+ mixed.replace_range(0..1, "N");
+ let err = recover_keys_from_input(&mixed, Some("nostr")).unwrap_err();
+ assert!(err.contains("invalid ncryptsec"), "{err}");
+}
+
+#[test]
+fn recover_keys_raw_nsec_path_unchanged() {
+ let keys = Keys::generate();
+ let nsec = keys.secret_key().to_bech32().unwrap();
+ let recovered = recover_keys_from_input(&nsec, Some("ignored")).unwrap();
+ assert_eq!(recovered.public_key(), keys.public_key());
+ let recovered = recover_keys_from_input(&nsec, None).unwrap();
+ assert_eq!(recovered.public_key(), keys.public_key());
+ assert!(recover_keys_from_input("garbage", None).is_err());
+}
+
// ── File lifecycle ────────────────────────────────────────────────────────────
#[test]
fn write_backup_file_persists_0600_and_verifies() {
let dir = tempfile::tempdir().unwrap();
- let path = dir.path().join(BACKUP_FILE_NAME);
+ let path = backup_file_path(dir.path());
write_backup_file(&path, SPEC_NCRYPTSEC).unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
@@ -101,7 +148,7 @@ fn write_backup_file_persists_0600_and_verifies() {
#[test]
fn write_backup_file_overwrites_atomically() {
let dir = tempfile::tempdir().unwrap();
- let path = dir.path().join(BACKUP_FILE_NAME);
+ let path = backup_file_path(dir.path());
write_backup_file(&path, "ncryptsec1old").unwrap();
write_backup_file(&path, SPEC_NCRYPTSEC).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), SPEC_NCRYPTSEC);
@@ -113,6 +160,34 @@ fn write_backup_file_overwrites_atomically() {
assert_eq!(entries, vec![std::ffi::OsString::from(BACKUP_FILE_NAME)]);
}
+#[test]
+fn delete_backup_file_is_idempotent() {
+ let dir = tempfile::tempdir().unwrap();
+ delete_backup_file(dir.path()).unwrap();
+ let path = backup_file_path(dir.path());
+ write_backup_file(&path, SPEC_NCRYPTSEC).unwrap();
+ delete_backup_file(dir.path()).unwrap();
+ assert!(!path.exists());
+}
+
+#[test]
+fn cleanup_stale_backup_removes_only_on_identity_change() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = backup_file_path(dir.path());
+ let a = Keys::generate().public_key();
+ let b = Keys::generate().public_key();
+
+ write_backup_file(&path, SPEC_NCRYPTSEC).unwrap();
+ cleanup_stale_backup(&a, &a, dir.path()).unwrap();
+ assert!(path.exists(), "same identity must keep the backup");
+
+ cleanup_stale_backup(&a, &b, dir.path()).unwrap();
+ assert!(
+ !path.exists(),
+ "identity change must remove the stale backup"
+ );
+}
+
#[test]
fn generated_passphrase_respects_word_count_and_separator() {
let words: std::collections::HashSet<&str> =
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 7dcc5994ae..ee2a98f5c1 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -8,6 +8,7 @@ mod egress_guard;
mod event_sync;
mod events;
mod huddle;
+mod identity_storage;
mod key_backup;
mod linux_media;
mod managed_agents;
diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs
index 1d9747bc20..3f04d3d7a1 100644
--- a/desktop/src-tauri/src/models.rs
+++ b/desktop/src-tauri/src/models.rs
@@ -6,6 +6,8 @@ use serde::{Deserialize, Deserializer, Serialize};
pub struct IdentityInfo {
pub pubkey: String,
pub display_name: String,
+ /// Durable location of the active identity key.
+ pub storage: String,
/// True when the app booted with an ephemeral key because the OS keyring
/// was empty despite a prior successful migration (key was externally
/// deleted). The frontend routes to the nsec re-import step when true.
diff --git a/desktop/src-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs
index d2e35e6839..18ddd80eb8 100644
--- a/desktop/src-tauri/src/reset.rs
+++ b/desktop/src-tauri/src/reset.rs
@@ -463,6 +463,26 @@ mod tests {
assert_eq!(kc.delete_calls.get(), 1, "keychain deleted once");
}
+ // ── NIP-49: the boot wipe destroys the app-managed key backup ─────────────
+
+ #[test]
+ fn test_wipe_removes_app_managed_key_backup() {
+ let tmp = TempDir::new().unwrap();
+ let app_data = make_app_data(&tmp);
+ let backup = crate::key_backup::backup_file_path(&app_data);
+ std::fs::write(&backup, b"encrypted-backup-bytes").unwrap();
+
+ write_sentinel(&app_data).unwrap();
+ let kc = FakeKeychain::ok();
+ let outcome = run_boot_reset_with_keychain(make_ctx(&app_data, &kc, false));
+
+ assert!(outcome.completed);
+ assert!(
+ !backup.exists(),
+ "sign-out wipe must destroy the app-managed key backup"
+ );
+ }
+
// ── Test 3: keychain failure keeps sentinel ────────────────────────────────
#[test]
diff --git a/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs
new file mode 100644
index 0000000000..b570153185
--- /dev/null
+++ b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs
@@ -0,0 +1,118 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ MIN_PASSPHRASE_LEN,
+ downloadDisabled,
+ isEncrypting,
+ passphraseIssue,
+ pendingEncryptPassphrase,
+ effectivePassphrase,
+ encryptedBackupReducer,
+ initialEncryptedBackupState,
+} from "./encryptedBackup.ts";
+const reduce = (events, from = initialEncryptedBackupState) =>
+ events.reduce(encryptedBackupReducer, from);
+test("password validation mirrors Rust character counting", () => {
+ assert.equal(passphraseIssue(""), null);
+ assert.match(passphraseIssue("short"), new RegExp(`${MIN_PASSPHRASE_LEN}`));
+ const emoji = "😀".repeat(MIN_PASSPHRASE_LEN);
+ assert.equal(passphraseIssue(emoji), null);
+ assert.equal(
+ effectivePassphrase(reduce([{ type: "set-passphrase", value: emoji }])),
+ emoji,
+ );
+});
+test("valid password requests encryption without copying it into events", () => {
+ const ready = reduce([
+ { type: "set-passphrase", value: "one-two-three-four" },
+ ]);
+ assert.equal(pendingEncryptPassphrase(ready), "one-two-three-four");
+ const started = reduce([{ type: "encrypt-started", requestId: 1 }], ready);
+ assert.equal(isEncrypting(started), true);
+ assert.equal(started.requestId, 1);
+ assert.equal(Object.hasOwn(started, "encryptingPassphrase"), false);
+});
+test("background encryption remains silent until download is clicked", () => {
+ const state = reduce([
+ { type: "set-passphrase", value: "one-two-three-four" },
+ { type: "encrypt-started", requestId: 1 },
+ { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" },
+ ]);
+ assert.equal(state.passphrase, "one-two-three-four");
+ assert.equal(state.encrypted, "ncryptsec1abc");
+ assert.equal(state.ncryptsec, null);
+ assert.equal(state.savedPassword, false);
+ assert.equal(state.requestId, null);
+});
+test("stale async completions cannot replace current request", () => {
+ const state = reduce([
+ { type: "set-passphrase", value: "one-two-three-four" },
+ { type: "encrypt-started", requestId: 1 },
+ { type: "set-passphrase", value: "five-six-seven-eight" },
+ { type: "encrypt-started", requestId: 2 },
+ { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1stale" },
+ ]);
+ assert.equal(state.requestId, 2);
+ assert.equal(state.encrypted, null);
+ assert.equal(state.passphrase, "five-six-seven-eight");
+});
+test("failure clears submitted password", () => {
+ const state = reduce([
+ { type: "set-passphrase", value: "one-two-three-four" },
+ { type: "encrypt-started", requestId: 1 },
+ { type: "download-clicked" },
+ { type: "encrypt-failed", requestId: 1, message: "keychain unavailable" },
+ ]);
+ assert.equal(state.passphrase, "");
+ assert.equal(state.createError, "keychain unavailable");
+ assert.equal(state.downloadPending, false);
+ assert.equal(downloadDisabled(state), true);
+});
+test("queued download commits and clears password", () => {
+ const state = reduce([
+ { type: "set-passphrase", value: "one-two-three-four" },
+ { type: "encrypt-started", requestId: 1 },
+ { type: "download-clicked" },
+ { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" },
+ ]);
+ assert.equal(state.ncryptsec, "ncryptsec1abc");
+ assert.equal(state.passphrase, "");
+ assert.equal(state.savedPassword, true);
+});
+test("Back preserves blob for immediate re-download without password", () => {
+ const made = reduce([
+ { type: "set-passphrase", value: "one-two-three-four" },
+ { type: "encrypt-started", requestId: 1 },
+ { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" },
+ { type: "download-clicked" },
+ { type: "back-to-password" },
+ ]);
+ assert.equal(made.ncryptsec, "ncryptsec1abc");
+ assert.equal(made.passphrase, "");
+ assert.equal(downloadDisabled(made), false);
+});
+test("starting over discards blob and invalidates late requests", () => {
+ const made = {
+ ...initialEncryptedBackupState,
+ ncryptsec: "ncryptsec1abc",
+ encrypted: "ncryptsec1abc",
+ savedPassword: true,
+ nextRequestId: 3,
+ };
+ const fresh = reduce([{ type: "start-new-backup" }], made);
+ assert.equal(fresh.ncryptsec, null);
+ assert.equal(fresh.nextRequestId, 4);
+ assert.equal(
+ reduce(
+ [
+ {
+ type: "encrypt-succeeded",
+ requestId: 2,
+ ncryptsec: "ncryptsec1stale",
+ },
+ ],
+ fresh,
+ ).ncryptsec,
+ null,
+ );
+});
diff --git a/desktop/src/features/onboarding/lib/encryptedBackup.ts b/desktop/src/features/onboarding/lib/encryptedBackup.ts
new file mode 100644
index 0000000000..2f7d4a0bb0
--- /dev/null
+++ b/desktop/src/features/onboarding/lib/encryptedBackup.ts
@@ -0,0 +1,135 @@
+/** Pure state model for NIP-49 backup creation. */
+export const MIN_PASSPHRASE_LEN = 12;
+
+export type EncryptedBackupState = {
+ passphrase: string;
+ requestId: number | null;
+ nextRequestId: number;
+ encrypted: string | null;
+ createError: string | null;
+ downloadPending: boolean;
+ ncryptsec: string | null;
+ savedPassword: boolean;
+};
+
+export const initialEncryptedBackupState: EncryptedBackupState = {
+ passphrase: "",
+ requestId: null,
+ nextRequestId: 1,
+ encrypted: null,
+ createError: null,
+ downloadPending: false,
+ ncryptsec: null,
+ savedPassword: false,
+};
+
+export type EncryptedBackupEvent =
+ | { type: "set-passphrase"; value: string }
+ | { type: "encrypt-started"; requestId: number }
+ | { type: "encrypt-succeeded"; requestId: number; ncryptsec: string }
+ | { type: "encrypt-failed"; requestId: number; message: string }
+ | { type: "download-clicked" }
+ | { type: "back-to-password" }
+ | { type: "start-new-backup" };
+
+export function encryptedBackupReducer(
+ state: EncryptedBackupState,
+ event: EncryptedBackupEvent,
+): EncryptedBackupState {
+ switch (event.type) {
+ case "set-passphrase":
+ return {
+ ...state,
+ passphrase: event.value,
+ encrypted: null,
+ createError: null,
+ };
+ case "encrypt-started":
+ return {
+ ...state,
+ requestId: event.requestId,
+ nextRequestId: Math.max(state.nextRequestId, event.requestId + 1),
+ createError: null,
+ };
+ case "encrypt-succeeded":
+ if (event.requestId !== state.requestId) return state;
+ if (state.downloadPending) {
+ return {
+ ...state,
+ passphrase: "",
+ requestId: null,
+ encrypted: event.ncryptsec,
+ ncryptsec: event.ncryptsec,
+ downloadPending: false,
+ savedPassword: true,
+ };
+ }
+ return {
+ ...state,
+ requestId: null,
+ encrypted: event.ncryptsec,
+ };
+ case "encrypt-failed":
+ if (event.requestId !== state.requestId) return state;
+ return {
+ ...state,
+ passphrase: "",
+ requestId: null,
+ createError: event.message,
+ downloadPending: false,
+ };
+ case "download-clicked":
+ if (
+ state.ncryptsec ||
+ state.downloadPending ||
+ (!state.encrypted && !effectivePassphrase(state))
+ )
+ return state;
+ return state.encrypted
+ ? {
+ ...state,
+ ncryptsec: state.encrypted,
+ passphrase: "",
+ savedPassword: true,
+ }
+ : { ...state, downloadPending: true };
+ case "back-to-password":
+ return { ...state, createError: null };
+ case "start-new-backup":
+ return {
+ ...initialEncryptedBackupState,
+ nextRequestId: state.nextRequestId + 1,
+ };
+ }
+}
+
+export function passphraseIssue(passphrase: string): string | null {
+ if (passphrase.length === 0) return null;
+ return [...passphrase].length < MIN_PASSPHRASE_LEN
+ ? `Use at least ${MIN_PASSPHRASE_LEN} characters.`
+ : null;
+}
+export function effectivePassphrase(
+ state: EncryptedBackupState,
+): string | null {
+ return [...state.passphrase].length < MIN_PASSPHRASE_LEN
+ ? null
+ : state.passphrase;
+}
+export function pendingEncryptPassphrase(
+ state: EncryptedBackupState,
+): string | null {
+ if (state.savedPassword || state.encrypted || state.requestId !== null)
+ return null;
+ return effectivePassphrase(state);
+}
+export function isEncrypting(state: EncryptedBackupState): boolean {
+ return state.requestId !== null;
+}
+export function downloadDisabled(state: EncryptedBackupState): boolean {
+ if (state.savedPassword && state.ncryptsec) return false;
+ return (
+ state.downloadPending ||
+ (!state.encrypted && effectivePassphrase(state) === null)
+ );
+}
diff --git a/desktop/src/features/onboarding/lib/keyImportInput.test.mjs b/desktop/src/features/onboarding/lib/keyImportInput.test.mjs
new file mode 100644
index 0000000000..bc0bb4b4d7
--- /dev/null
+++ b/desktop/src/features/onboarding/lib/keyImportInput.test.mjs
@@ -0,0 +1,73 @@
+/**
+ * Pure-logic tests for key-import input classification (nsec vs NIP-49
+ * ncryptsec) and submit gating.
+ */
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { nsecEncode } from "nostr-tools/nip19";
+import { generateSecretKey } from "nostr-tools/pure";
+import {
+ classifyKeyImportInput,
+ isPlausibleNcryptsec,
+ keyImportSubmitEnabled,
+ NCRYPTSEC_ENCODED_LENGTH,
+} from "./keyImportInput.ts";
+
+// NIP-49 spec vector — structurally valid encrypted backup.
+const NCRYPTSEC =
+ "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
+
+const VALID_NSEC = nsecEncode(generateSecretKey());
+
+test("classify_by_hrp_with_whitespace_tolerance", () => {
+ assert.equal(classifyKeyImportInput(` ${NCRYPTSEC}\n`), "ncryptsec");
+ assert.equal(classifyKeyImportInput(VALID_NSEC), "nsec");
+ assert.equal(classifyKeyImportInput("npub1whatever"), "unknown");
+ assert.equal(classifyKeyImportInput(""), "unknown");
+ // nsec must not be shadowed by the longer HRP check.
+ assert.equal(classifyKeyImportInput("nsec1"), "nsec");
+});
+
+test("uppercase_bech32_encoding_classifies_and_gates_like_lowercase", () => {
+ // Bech32 permits an all-uppercase encoding; it must route to the
+ // encrypted path (matching Rust) and be submit-plausible.
+ const upper = NCRYPTSEC.toUpperCase();
+ assert.equal(classifyKeyImportInput(upper), "ncryptsec");
+ assert.equal(isPlausibleNcryptsec(upper), true);
+ assert.equal(keyImportSubmitEnabled(upper, ""), false);
+ assert.equal(keyImportSubmitEnabled(upper, "hunter2hunter2"), true);
+ // Mixed case: routed encrypted (Rust reports the accurate error) but
+ // never plausible/submittable — mixed-case bech32 cannot decode.
+ const mixed = `N${NCRYPTSEC.slice(1)}`;
+ assert.equal(classifyKeyImportInput(mixed), "ncryptsec");
+ assert.equal(isPlausibleNcryptsec(mixed), false);
+ assert.equal(keyImportSubmitEnabled(mixed, "hunter2hunter2"), false);
+});
+
+test("plausible_ncryptsec_requires_complete_checksummed_nip49_payload", () => {
+ assert.equal(NCRYPTSEC.length, NCRYPTSEC_ENCODED_LENGTH);
+ assert.equal(isPlausibleNcryptsec(NCRYPTSEC), true);
+ assert.equal(isPlausibleNcryptsec(` ${NCRYPTSEC}\n`), true);
+ assert.equal(isPlausibleNcryptsec(NCRYPTSEC.slice(0, -1)), false);
+ assert.equal(isPlausibleNcryptsec(`${NCRYPTSEC}q`), false);
+ // Same length and charset, but a changed checksum must not advance the UI.
+ assert.equal(isPlausibleNcryptsec(`${NCRYPTSEC.slice(0, -1)}q`), false);
+ // '1' and 'b' / 'i' / 'o' are not in the Bech32 data charset.
+ assert.equal(isPlausibleNcryptsec("ncryptsec1bio"), false);
+ assert.equal(isPlausibleNcryptsec("ncryptsec1"), false);
+ assert.equal(isPlausibleNcryptsec("ncryptsec1 with spaces"), false);
+});
+
+test("submit_gating_nsec_path_unchanged", () => {
+ assert.equal(keyImportSubmitEnabled(VALID_NSEC, ""), true);
+ assert.equal(keyImportSubmitEnabled("nsec1garbage", ""), false);
+ assert.equal(keyImportSubmitEnabled("", ""), false);
+});
+
+test("submit_gating_ncryptsec_requires_passphrase", () => {
+ assert.equal(keyImportSubmitEnabled(NCRYPTSEC, ""), false);
+ assert.equal(keyImportSubmitEnabled(NCRYPTSEC, "hunter2hunter2"), true);
+ // Structurally implausible blob never submits, passphrase or not.
+ assert.equal(keyImportSubmitEnabled("ncryptsec1bio", "hunter2"), false);
+});
diff --git a/desktop/src/features/onboarding/lib/keyImportInput.ts b/desktop/src/features/onboarding/lib/keyImportInput.ts
new file mode 100644
index 0000000000..0f6fc609ed
--- /dev/null
+++ b/desktop/src/features/onboarding/lib/keyImportInput.ts
@@ -0,0 +1,127 @@
+/**
+ * Pure classification + submit gating for the key-import form, unit-testable
+ * without a DOM.
+ *
+ * `ncryptsec1…` is a NIP-49 encrypted backup: no npub preview is possible
+ * (the pubkey is inside the encrypted payload) and a passphrase is required.
+ * Password validation happens in Rust at decrypt time; this module performs
+ * the password-independent Bech32 and NIP-49 structure checks needed to decide
+ * when the form can safely switch modes.
+ */
+
+import { nsecToNpub } from "@/shared/lib/nostrUtils";
+
+export type KeyImportKind = "nsec" | "ncryptsec" | "unknown";
+
+const NCRYPTSEC_HRP = "ncryptsec";
+const NIP49_VERSION = 2;
+const NIP49_PAYLOAD_BYTES = 91;
+/** Current NIP-49 payloads encode to 162 characters including the checksum. */
+export const NCRYPTSEC_ENCODED_LENGTH = 162;
+const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
+const BECH32_GENERATORS = [
+ 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3,
+] as const;
+
+function bech32Polymod(values: readonly number[]): number {
+ let checksum = 1;
+ for (const value of values) {
+ const high = checksum >>> 25;
+ checksum = ((checksum & 0x1ffffff) << 5) ^ value;
+ for (let index = 0; index < BECH32_GENERATORS.length; index += 1) {
+ if ((high >>> index) & 1) checksum ^= BECH32_GENERATORS[index];
+ }
+ }
+ return checksum >>> 0;
+}
+
+function expandBech32Hrp(hrp: string): number[] {
+ return [
+ ...Array.from(hrp, (character) => character.charCodeAt(0) >>> 5),
+ 0,
+ ...Array.from(hrp, (character) => character.charCodeAt(0) & 31),
+ ];
+}
+
+function convertFiveBitWordsToBytes(words: readonly number[]): number[] | null {
+ let accumulator = 0;
+ let bitCount = 0;
+ const bytes: number[] = [];
+
+ for (const word of words) {
+ accumulator = (accumulator << 5) | word;
+ bitCount += 5;
+ while (bitCount >= 8) {
+ bitCount -= 8;
+ bytes.push((accumulator >>> bitCount) & 0xff);
+ }
+ }
+
+ // Bech32 conversion without padding permits fewer than five zero remainder
+ // bits. Any larger or non-zero remainder is not a canonical byte encoding.
+ if (bitCount >= 5 || ((accumulator << (8 - bitCount)) & 0xff) !== 0) {
+ return null;
+ }
+ return bytes;
+}
+
+export function classifyKeyImportInput(input: string): KeyImportKind {
+ const trimmed = input.trim();
+ // Case-insensitive on the HRP to match the Rust classifier: an uppercase
+ // valid backup routes to the encrypted path (and decodes there); mixed
+ // case routes there too and fails in Rust with the accurate error.
+ if (trimmed.slice(0, 10).toLowerCase() === "ncryptsec1") return "ncryptsec";
+ if (trimmed.startsWith("nsec1")) return "nsec";
+ return "unknown";
+}
+
+/**
+ * Password-independent NIP-49 validation used for the automatic UI transition.
+ * A candidate must have canonical casing and length, a valid Bech32 checksum,
+ * and the current 91-byte/version-2 NIP-49 payload shape.
+ */
+export function isPlausibleNcryptsec(input: string): boolean {
+ const trimmed = input.trim();
+ if (trimmed.length !== NCRYPTSEC_ENCODED_LENGTH) return false;
+ if (trimmed !== trimmed.toLowerCase() && trimmed !== trimmed.toUpperCase()) {
+ return false;
+ }
+
+ const normalized = trimmed.toLowerCase();
+ const separatorIndex = normalized.lastIndexOf("1");
+ if (
+ separatorIndex !== NCRYPTSEC_HRP.length ||
+ normalized.slice(0, separatorIndex) !== NCRYPTSEC_HRP
+ ) {
+ return false;
+ }
+
+ const encoded = normalized.slice(separatorIndex + 1);
+ const words = Array.from(encoded, (character) =>
+ BECH32_CHARSET.indexOf(character),
+ );
+ if (words.some((word) => word < 0) || words.length <= 6) return false;
+ if (bech32Polymod([...expandBech32Hrp(NCRYPTSEC_HRP), ...words]) !== 1) {
+ return false;
+ }
+
+ const payload = convertFiveBitWordsToBytes(words.slice(0, -6));
+ return (
+ payload?.length === NIP49_PAYLOAD_BYTES && payload[0] === NIP49_VERSION
+ );
+}
+
+/**
+ * Whether the import form's submit should be enabled.
+ * nsec: must derive an npub. ncryptsec: plausible blob + non-empty passphrase.
+ */
+export function keyImportSubmitEnabled(
+ input: string,
+ passphrase: string,
+): boolean {
+ const kind = classifyKeyImportInput(input);
+ if (kind === "ncryptsec") {
+ return isPlausibleNcryptsec(input) && passphrase.length > 0;
+ }
+ return nsecToNpub(input) !== null;
+}
diff --git a/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx b/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx
new file mode 100644
index 0000000000..610c104d95
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx
@@ -0,0 +1,130 @@
+import { FileKey2, LockKeyhole, LockOpen } from "lucide-react";
+import { motion, useReducedMotion } from "motion/react";
+
+import { cn } from "@/shared/lib/cn";
+
+const BACKUP_KEY_DOTS = [
+ "key-dot-1",
+ "key-dot-2",
+ "key-dot-3",
+ "key-dot-4",
+ "key-dot-5",
+ "key-dot-6",
+ "key-dot-7",
+ "key-dot-8",
+ "key-dot-9",
+] as const;
+
+const TIMELINE_CONNECTOR_DOTS = [
+ "connector-dot-1",
+ "connector-dot-2",
+ "connector-dot-3",
+ "connector-dot-4",
+] as const;
+
+const TIMELINE_DOT_INITIAL = { opacity: 0.35, scale: 0.85 };
+const TIMELINE_DOT_PULSE = {
+ opacity: [0.35, 1, 0.35],
+ scale: [0.85, 1.25, 0.85],
+};
+const TIMELINE_DOT_TRANSITION = {
+ duration: 0.7,
+ ease: "easeInOut" as const,
+ repeat: Number.POSITIVE_INFINITY,
+ repeatDelay: 1.2,
+};
+const TIMELINE_TOP_DOT_TRANSITIONS = TIMELINE_CONNECTOR_DOTS.map(
+ (_, index) => ({
+ ...TIMELINE_DOT_TRANSITION,
+ delay: index * 0.16,
+ }),
+);
+const TIMELINE_BOTTOM_DOT_TRANSITIONS = TIMELINE_CONNECTOR_DOTS.map(
+ (_, index) => ({
+ ...TIMELINE_DOT_TRANSITION,
+ delay: (index + TIMELINE_CONNECTOR_DOTS.length) * 0.16 + 0.24,
+ }),
+);
+
+/**
+ * Decorative timeline shared by backup creation and encrypted-backup restore.
+ * Backup creation reads key → password → lock; restore reads encrypted file →
+ * password → unlocked account. The password field is layered over the center.
+ */
+export function BackupPasswordTimeline({
+ className,
+ mode = "backup",
+}: {
+ className?: string;
+ mode?: "backup" | "restore";
+}) {
+ const reduceMotion = useReducedMotion() ?? false;
+
+ return (
+
+ );
+}
diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx
index ed2184baaa..99d9c6324d 100644
--- a/desktop/src/features/onboarding/ui/BackupStep.tsx
+++ b/desktop/src/features/onboarding/ui/BackupStep.tsx
@@ -1,183 +1,438 @@
-import { AlertTriangle, Info, RefreshCw } from "lucide-react";
+import { Check, Copy, Eye, EyeOff, Info, ShieldCheck } from "lucide-react";
+import { useReducedMotion } from "motion/react";
import * as React from "react";
import { getNsec } from "@/shared/api/tauriIdentity";
+import type { IdentityStorage } from "@/shared/api/types";
+import { cn } from "@/shared/lib/cn";
+import { writeTextToClipboard } from "@/shared/lib/clipboard";
import { Button } from "@/shared/ui/button";
+import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
import { Card } from "@/shared/ui/card";
import { Spinner } from "@/shared/ui/spinner";
-import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome";
+import {
+ ONBOARDING_PRIMARY_CTA_CLASS,
+ ONBOARDING_SECONDARY_CTA_CLASS,
+} from "./OnboardingChrome";
import { OnboardingFooter } from "./OnboardingFooter";
import {
type OnboardingTransitionDirection,
OnboardingSlideTransition,
} from "./OnboardingSlideTransition";
-import { NsecMaskedDisplay } from "./NsecMaskedDisplay";
+import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay";
/**
- * Pure helper so the disabled logic can be unit-tested without a DOM.
- *
- * Disabled while loading (key not fetched yet) or after a failed load (only
- * the explicit "Skip for now" ghost advances past an error).
+ * How long the "Creating your identity key" loader holds the stage before the
+ * finished state fades in. Purely perceptual — the key already exists; the
+ * pause sells the creation moment.
*/
-export function backupNextDisabled({
- isLoading,
- loadError,
-}: {
- isLoading: boolean;
- loadError: string | null;
-}): boolean {
- return isLoading || loadError !== null;
+const INTRO_HOLD_MS = 1400;
+
+/**
+ * The creation moment should only be sold once per app session. Module-level
+ * so remounts (e.g. navigating Back and returning to this step) skip the fake
+ * hold and show the finished state instantly.
+ */
+let introPlayed = false;
+
+const REVEAL_ANIMATION_CLASS =
+ "animate-in fade-in duration-700 motion-reduce:animate-none";
+
+const BACKUP_OPTION_CLASS =
+ "flex min-h-48 w-full flex-col items-start justify-start px-6 py-5 text-left text-foreground";
+
+/** Viewing the key never blocks onboarding — Next is always actionable. */
+export function backupNextDisabled(): boolean {
+ return false;
}
type BackupStepProps = {
direction: OnboardingTransitionDirection;
+ identityStorage?: IdentityStorage;
onBack: () => void;
onNext: () => void;
+ onOpenPasswordBackup: () => void;
+ onShowOptions: () => void;
+ optionsExpanded: boolean;
+ returningFromSecurity: boolean;
};
/**
- * Onboarding backup step — shows the user their freshly created key so they
- * can save it somewhere safe. Only shown on the fresh-key path.
+ * Onboarding identity-key step — shows the freshly created key, then opens a
+ * dark backup-options state. Copy fetches the raw key only after an explicit
+ * click; password backup opens the separate security flow. Neither method
+ * blocks Next.
*/
-export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
+export function BackupStep({
+ direction,
+ identityStorage,
+ onBack,
+ onNext,
+ onOpenPasswordBackup,
+ onShowOptions,
+ optionsExpanded,
+ returningFromSecurity,
+}: BackupStepProps) {
+ const reduceMotion = useReducedMotion() ?? false;
+ const [created, setCreated] = React.useState(introPlayed || reduceMotion);
+ const [copyState, setCopyState] = React.useState<
+ "idle" | "copying" | "copied"
+ >("idle");
+ const [copyError, setCopyError] = React.useState(null);
const [nsec, setNsec] = React.useState(null);
- const [isLoading, setIsLoading] = React.useState(true);
- const [loadError, setLoadError] = React.useState(null);
+ const [isRevealed, setIsRevealed] = React.useState(false);
const cancelledRef = React.useRef(false);
+ const copiedTimerRef = React.useRef(null);
- const loadNsec = React.useCallback(async () => {
- setIsLoading(true);
- setLoadError(null);
- try {
- const value = await getNsec();
- if (!cancelledRef.current) setNsec(value);
- } catch (err) {
- if (!cancelledRef.current)
- setLoadError(
- err instanceof Error
- ? err.message
- : "Failed to retrieve private key.",
- );
- } finally {
- if (!cancelledRef.current) setIsLoading(false);
+ React.useEffect(() => {
+ if (introPlayed) return;
+ if (reduceMotion) {
+ introPlayed = true;
+ setCreated(true);
+ return;
}
- }, []);
+ const timer = window.setTimeout(() => {
+ introPlayed = true;
+ setCreated(true);
+ }, INTRO_HOLD_MS);
+ return () => window.clearTimeout(timer);
+ }, [reduceMotion]);
React.useEffect(() => {
cancelledRef.current = false;
- void loadNsec();
return () => {
// Back-during-fetch: cancel any in-flight setState calls and clear the
// nsec from memory on unmount (backup step is only on the fresh-key path).
cancelledRef.current = true;
setNsec(null);
+ if (copiedTimerRef.current !== null)
+ window.clearTimeout(copiedTimerRef.current);
};
- }, [loadNsec]);
+ }, []);
+
+ const copyKeyToClipboard = React.useCallback(async () => {
+ setCopyState("copying");
+ setCopyError(null);
+ try {
+ const value = nsec ?? (await getNsec());
+ await writeTextToClipboard(value);
+ if (cancelledRef.current) return;
+ setCopyState("copied");
+ if (copiedTimerRef.current !== null)
+ window.clearTimeout(copiedTimerRef.current);
+ copiedTimerRef.current = window.setTimeout(() => {
+ if (!cancelledRef.current) setCopyState("idle");
+ }, 2000);
+ } catch (err) {
+ if (cancelledRef.current) return;
+ setCopyState("idle");
+ setCopyError(
+ err instanceof Error ? err.message : "Failed to retrieve private key.",
+ );
+ }
+ }, [nsec]);
+
+ const toggleReveal = React.useCallback(async () => {
+ if (isRevealed) {
+ setIsRevealed(false);
+ return;
+ }
+ setCopyError(null);
+ try {
+ // The raw key enters the DOM only after this explicit reveal action.
+ const value = nsec ?? (await getNsec());
+ if (cancelledRef.current) return;
+ setNsec(value);
+ setIsRevealed(true);
+ } catch (err) {
+ if (cancelledRef.current) return;
+ setCopyError(
+ err instanceof Error ? err.message : "Failed to retrieve private key.",
+ );
+ }
+ }, [isRevealed, nsec]);
+
+ // Fixed-length decorative mask (nsec keys are 63 chars) so no key material
+ // is fetched just to render the blurred row. Bullets are joined with a
+ // zero-width space: WebKit won't line-break a run of U+2022 without an
+ // explicit break opportunity, so the masked row would overflow otherwise.
+ const maskedKey = React.useMemo(
+ () => Array.from({ length: nsec?.length ?? 63 }, () => "•").join("\u200b"),
+ [nsec],
+ );
+ const storageDescription =
+ identityStorage === "system-keyring"
+ ? "Buzz keeps your identity key in your system keychain. Your computer may ask for your password when Buzz needs to read the key."
+ : identityStorage === "local-file"
+ ? "Your system keychain wasn’t available, so Buzz keeps your identity key in a private file on this device."
+ : "Buzz keeps your identity key protected on this device. Make a separate backup in case you lose access.";
+ const storageTitle =
+ identityStorage === "system-keyring"
+ ? "Protected by your system keychain"
+ : identityStorage === "local-file"
+ ? "Stored in private device storage"
+ : "Protected in private device storage";
+ const introStorageDescription =
+ identityStorage === "system-keyring"
+ ? "Buzz keeps your identity key in your system keychain."
+ : identityStorage === "local-file"
+ ? "Buzz keeps your identity key in a private file on this device because the system keychain wasn’t available."
+ : "Your identity key is protected on this device.";
+
+ if (optionsExpanded) {
+ return (
+
+
+
+ Backup options
+
+
+ Your identity key works like a password for your Buzz account. Keep
+ a copy somewhere safe. You can create a backup file and lock it with
+ a password you can remember.
+
+
+
+
+
+
+ {storageTitle}
+
+ {storageDescription}
+
+
+
+
+
+ Saved in your password manager
+
+
+ Copy your identity key, then save it in a password manager like
+ 1Password.
+
+
+
+
+
+
+ Locked in a backup file
+
+
+ Create a backup file and choose a password you can remember.
+ You’ll need both to restore your account.
+
+
+
+
+
+ {copyError ? (
+
+ Could not retrieve your private key: {copyError}. You can continue
+ and find it later in Settings > Profile > Identity.
+
+ ) : null}
+
+
+ );
+ }
return (
-
- Your unique identity key has been created
+ {/* Plain string concat: cn()'s tailwind-merge misreads the custom
+ text-title size token as conflicting with text-foreground. */}
+
+ {created
+ ? "Your unique identity key has been created"
+ : "Creating your identity key"}
-
- This key is stored in your system keychain, but save it some place
- safe in case you ever need to restore your account.
-
-
-
-
- {isLoading ? (
-
-
- Loading your private key…
-
- ) : loadError ? (
-
-
-
-
- Could not retrieve your private key: {loadError}. You can
- continue and find it later in Settings > Profile >
- Identity.
-
-
-
- ) : nsec ? (
-
-
-
-
-
- ) : (
-
- No key available to back up.
-
- )}
-
- {nsec ? (
-
-
-
- Never share your private key. Anyone with this key can impersonate
- you and access everything in your account.
-
+ review backup options
+ {" "}
+ for ways to restore your account.
+ );
+}
diff --git a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx
new file mode 100644
index 0000000000..3d69150049
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx
@@ -0,0 +1,139 @@
+import { motion, useReducedMotion } from "motion/react";
+import * as React from "react";
+
+import { Button } from "@/shared/ui/button";
+import {
+ ONBOARDING_SECURITY_PRIMARY_CTA_CLASS,
+ ONBOARDING_SECONDARY_CTA_CLASS,
+} from "./OnboardingChrome";
+import { OnboardingFooter } from "./OnboardingFooter";
+import {
+ type OnboardingTransitionDirection,
+ OnboardingSlideTransition,
+} from "./OnboardingSlideTransition";
+import {
+ type EncryptedBackupSession,
+ EncryptedBackupCreator,
+} from "./EncryptedBackupCreator";
+
+type DownloadKeyStepProps = {
+ direction: OnboardingTransitionDirection;
+ /** Backup state owned by the parent flow across the creation and test views. */
+ session: EncryptedBackupSession;
+ onBack: () => void;
+};
+
+/**
+ * Password-backup security subview within the identity-key onboarding step.
+ * The raw key never enters this component: Rust builds the NIP-49 payload
+ * locally and the native save dialog produces the user-owned file.
+ */
+export function DownloadKeyStep({
+ direction,
+ session,
+ onBack,
+}: DownloadKeyStepProps) {
+ const reduceMotion = useReducedMotion() ?? false;
+ // Once the encrypted payload is saved, the creator advances to its guided
+ // backup test while this surface keeps its own navigation.
+ const hasCreated = session.created;
+ const hasVerifiedBackup = session.verified;
+ const hasSelectedBackup = session.test.stage === "password";
+ const [primaryActionSlot, setPrimaryActionSlot] =
+ React.useState(null);
+
+ return (
+
+
+ {/* Plain string concat: cn()'s tailwind-merge misreads the custom
+ text-title size token as conflicting with text-foreground. */}
+
+ {hasVerifiedBackup
+ ? "Your backup is verified"
+ : hasSelectedBackup
+ ? "That’s your backup file"
+ : hasCreated
+ ? "Optionally, test your backup"
+ : "Backup your key with a password"}
+
+
+ {hasVerifiedBackup
+ ? "Your file and password can restore your identity."
+ : hasSelectedBackup
+ ? "Now enter your password to prove you can unlock it."
+ : hasCreated
+ ? "Learn how your backup works. Drop the file you just saved and unlock it with your password."
+ : "Keep the downloaded file private — you need both it and your password to restore your identity. Save the backup password somewhere safe; Buzz cannot reset it if lost."}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {hasVerifiedBackup ? "Finish" : hasCreated ? "Skip for now" : "Back"}
+
+
+
+ );
+}
diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx
new file mode 100644
index 0000000000..bb76166bd7
--- /dev/null
+++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx
@@ -0,0 +1,885 @@
+import { AlertTriangle, Eye, EyeOff, RefreshCw } from "lucide-react";
+import * as React from "react";
+import { createPortal } from "react-dom";
+
+import {
+ createNcryptsecBackup,
+ generateBackupPassphrase,
+ saveNcryptsecCopy,
+} from "@/shared/api/tauriIdentity";
+import { cn } from "@/shared/lib/cn";
+import { Button } from "@/shared/ui/button";
+import { Input } from "@/shared/ui/input";
+import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
+import { Spinner } from "@/shared/ui/spinner";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/shared/ui/alert-dialog";
+import {
+ downloadDisabled,
+ passphraseIssue,
+ pendingEncryptPassphrase,
+ encryptedBackupReducer,
+ initialEncryptedBackupState,
+ MIN_PASSPHRASE_LEN,
+ type EncryptedBackupEvent,
+ type EncryptedBackupState,
+} from "../lib/encryptedBackup";
+import {
+ type BackupTestProgress,
+ BackupTestFlow,
+ initialBackupTestProgress,
+} from "./BackupTestFlow";
+import { BackupPasswordTimeline } from "./BackupPasswordTimeline";
+import {
+ ONBOARDING_SECURITY_PRIMARY_CTA_CLASS,
+ ONBOARDING_SECONDARY_CTA_CLASS,
+} from "./OnboardingChrome";
+
+/** Word-count bounds mirroring `key_backup.rs` (Rust clamps regardless). */
+const MIN_GENERATED_WORDS = 3;
+const MAX_GENERATED_WORDS = 10;
+const DEFAULT_GENERATED_WORDS = 3;
+
+const SEPARATOR_OPTIONS = [
+ { label: "Spaces", value: " " },
+ { label: "Hyphens", value: "-" },
+ { label: "Periods", value: "." },
+ { label: "Commas", value: "," },
+] as const;
+
+const DEFAULT_SEPARATOR = SEPARATOR_OPTIONS[0].value;
+
+/**
+ * Pause after the last keystroke before the background KDF starts, so typing
+ * past the minimum length doesn't launch an encryption per character.
+ */
+const ENCRYPT_DEBOUNCE_MS = 400;
+
+const PENDING_TICKER_MESSAGES = [
+ "Downloading once finished",
+ "Encrypting your password",
+ "Just a bit longer...",
+] as const;
+
+/** How long each ticker message holds before sliding to the next. */
+const PENDING_TICKER_INTERVAL_MS = 2500;
+
+/** Matches the `duration-300` slide transition on the ticker column. */
+const PENDING_TICKER_SLIDE_MS = 300;
+
+/**
+ * Vertical ticker for the queued-download button label — cycles through the
+ * pending messages by sliding a stacked column inside a one-line viewport.
+ * The column ends with a clone of the first message, so the wrap-around
+ * slides up from the bottom like every other step; once the clone settles,
+ * the column snaps (transition disabled) back to the real first row. All
+ * lines render at all times, so the button keeps the width of the longest
+ * message instead of resizing on each swap.
+ */
+function PendingDownloadTicker() {
+ // Index into the rendered column (messages + trailing clone of the first).
+ const [position, setPosition] = React.useState(0);
+ const [snap, setSnap] = React.useState(false);
+
+ React.useEffect(() => {
+ const timer = window.setInterval(
+ () => setPosition((current) => current + 1),
+ PENDING_TICKER_INTERVAL_MS,
+ );
+ return () => window.clearInterval(timer);
+ }, []);
+
+ // The clone is visually identical to the first message: once its slide-in
+ // finishes, jump back to the real first row without animating.
+ React.useEffect(() => {
+ if (position !== PENDING_TICKER_MESSAGES.length) return;
+ const timer = window.setTimeout(() => {
+ setSnap(true);
+ setPosition(0);
+ }, PENDING_TICKER_SLIDE_MS);
+ return () => window.clearTimeout(timer);
+ }, [position]);
+
+ // Re-enable the transition one frame after the snap has painted.
+ React.useEffect(() => {
+ if (!snap) return;
+ const raf = window.requestAnimationFrame(() => setSnap(false));
+ return () => window.cancelAnimationFrame(raf);
+ }, [snap]);
+
+ // The clone row duplicates the first message's text, so it carries its own
+ // stable key.
+ const column = [
+ ...PENDING_TICKER_MESSAGES.map((message) => ({ key: message, message })),
+ { key: "wrap-clone", message: PENDING_TICKER_MESSAGES[0] },
+ ];
+
+ return (
+
+
+ {column.map((row) => (
+
+ {row.message}
+
+ ))}
+
+
+ );
+}
+
+/**
+ * Everything about an in-progress backup that must survive this component
+ * unmounting: the reducer state (short-lived passphrase + encrypted blob), whether the
+ * backup test passed, where the file was saved, the save-once guard, and the
+ * test-flow progress. Hosts that need the state to outlive the creator (the
+ * onboarding flow, where Back unmounts the step) call
+ * `useEncryptedBackupSession` at a longer-lived level and pass it down;
+ * otherwise the creator owns a private session internally.
+ */
+export type EncryptedBackupSession = {
+ state: EncryptedBackupState;
+ dispatch: React.Dispatch;
+ /**
+ * True once the encrypted payload has been committed AND saved to disk.
+ * Derived so hosts (e.g. DownloadKeyStep) can branch on it without touching
+ * the blob itself — keeping them outside the ncryptsec confinement scan.
+ */
+ created: boolean;
+ /** True once the user has passed the backup test. */
+ verified: boolean;
+ setVerified: React.Dispatch>;
+ savedPath: string | null;
+ setSavedPath: React.Dispatch>;
+ /** The committed blob a save was already kicked off for (save-once guard). */
+ savedForRef: React.MutableRefObject;
+ test: BackupTestProgress;
+ setTest: React.Dispatch>;
+};
+
+/** Host-side state for `EncryptedBackupCreator` — see `EncryptedBackupSession`. */
+export function useEncryptedBackupSession(): EncryptedBackupSession {
+ const [state, dispatch] = React.useReducer(
+ encryptedBackupReducer,
+ initialEncryptedBackupState,
+ );
+ const [verified, setVerified] = React.useState(false);
+ const [savedPath, setSavedPath] = React.useState(null);
+ const savedForRef = React.useRef(null);
+ const [test, setTest] = React.useState(
+ initialBackupTestProgress,
+ );
+ return React.useMemo(
+ () => ({
+ state,
+ dispatch,
+ created: state.ncryptsec !== null && savedPath !== null,
+ verified,
+ setVerified,
+ savedPath,
+ setSavedPath,
+ savedForRef,
+ test,
+ setTest,
+ }),
+ [state, verified, savedPath, test],
+ );
+}
+
+/**
+ * Return to a secure saved-password placeholder. The encrypted blob survives
+ * for instant re-download, while no password or test attempt is retained.
+ */
+export function backupSessionToPasswordEntry(
+ session: EncryptedBackupSession,
+): void {
+ session.dispatch({ type: "back-to-password" });
+ session.setVerified(false);
+ session.setSavedPath(null);
+ session.setTest(initialBackupTestProgress);
+}
+
+/** Discard all backup-creation and verification progress. */
+export function resetEncryptedBackupSession(
+ session: EncryptedBackupSession,
+): void {
+ session.dispatch({ type: "start-new-backup" });
+ session.setVerified(false);
+ session.setSavedPath(null);
+ session.savedForRef.current = null;
+ session.setTest(initialBackupTestProgress);
+}
+
+type EncryptedBackupCreatorProps = {
+ /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */
+ variant?: "spotlight" | "boxed";
+ /**
+ * When set, the "Download" button is portaled into this element instead of
+ * rendering inline.
+ */
+ createButtonPortal?: HTMLElement | null;
+ /** Optional onboarding footer target for the guided-test verification CTA. */
+ verifyButtonPortal?: HTMLElement | null;
+ /** Extra classes for the "Download" button. */
+ createButtonClassName?: string;
+ /**
+ * Host-owned session so the backup state survives this component
+ * unmounting (onboarding Back navigation). Omitted = private session.
+ */
+ session?: EncryptedBackupSession;
+ /** Fired once the encrypted payload has been created (before saving). */
+ onCreated?: () => void;
+ /** Fired only after the encrypted key file has been saved successfully. */
+ onSaved?: (path: string) => void;
+ /** Whether creation continues into onboarding's guided test ceremony. */
+ guidedTest?: boolean;
+ /** Fired once when the user completes the backup test successfully. */
+ onVerified?: () => void;
+};
+
+/**
+ * 1Password-style memorable-password generator popover with word-count and
+ * separator fields, anchored to a refresh icon inset in the password field
+ * (the anchor assumes a `relative` parent). The first click opens the
+ * popover and generates; further clicks on the icon re-roll while the
+ * popover stays open — only click-outside or Esc closes it. There is no
+ * candidate preview: every generation writes the passphrase straight into
+ * the parent's password field via `onGenerated`.
+ */
+function PassphraseGeneratorPopover({
+ disabled = false,
+ onRequestGenerate,
+ onGenerated,
+ securityTheme = false,
+}: {
+ disabled?: boolean;
+ onRequestGenerate?: () => void;
+ onGenerated: (value: string) => void;
+ securityTheme?: boolean;
+}) {
+ const [open, setOpen] = React.useState(false);
+ const [words, setWords] = React.useState(DEFAULT_GENERATED_WORDS);
+ const [separator, setSeparator] = React.useState(DEFAULT_SEPARATOR);
+ const [error, setError] = React.useState(null);
+ const anchorRef = React.useRef(null);
+ const mountedRef = React.useRef(true);
+ // Read via a ref so `generate` stays reference-stable even though parents
+ // pass an inline `onGenerated`. Otherwise each generated password would
+ // re-render the parent, rebuild `generate`, and re-fire the open/controls
+ // effect below — an infinite generate loop while the popover is open.
+ const onGeneratedRef = React.useRef(onGenerated);
+
+ React.useEffect(() => {
+ onGeneratedRef.current = onGenerated;
+ }, [onGenerated]);
+
+ React.useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
+ const generate = React.useCallback(async (wordCount: number, sep: string) => {
+ setError(null);
+ try {
+ const passphrase = await generateBackupPassphrase({
+ words: wordCount,
+ separator: sep,
+ });
+ if (mountedRef.current) onGeneratedRef.current(passphrase);
+ } catch (err) {
+ if (!mountedRef.current) return;
+ setError(
+ err instanceof Error ? err.message : "Failed to generate a password.",
+ );
+ }
+ }, []);
+
+ // Fill the password field on every open and whenever a control changes.
+ React.useEffect(() => {
+ if (open) void generate(words, separator);
+ }, [open, words, separator, generate]);
+
+ return (
+
+ {/* Anchor (not Trigger): Radix triggers toggle on click, but repeat
+ clicks here must generate a fresh password while the popover stays
+ open. Only click-outside or Esc closes it. */}
+
+ {
+ // The open effect below generates the first password; later
+ // clicks re-roll with the current controls.
+ if (onRequestGenerate) {
+ onRequestGenerate();
+ return;
+ }
+ if (!open) setOpen(true);
+ else void generate(words, separator);
+ }}
+ ref={anchorRef}
+ size="icon"
+ type="button"
+ variant="ghost"
+ >
+
+
+
+ {
+ // Clicking the anchor icon is "outside" the content — keep the
+ // popover open so that click re-rolls instead of closing.
+ if (
+ event.target instanceof Node &&
+ anchorRef.current?.contains(event.target)
+ ) {
+ event.preventDefault();
+ }
+ }}
+ onOpenAutoFocus={(event) => event.preventDefault()}
+ >
+
+ );
+ }
+ // Without the guided test (settings), a completed save keeps the form
+ // visible in its saved-password state: masked input, instant re-download,
+ // and the change-password confirmation guarding any edit.
+
+ return (
+
+ );
+ // `undefined` = inline (settings); `null` = slot not mounted yet
+ // (skip a frame rather than flashing the button inline).
+ if (createButtonPortal === undefined)
+ return
{createButton}
;
+ return createButtonPortal
+ ? createPortal(createButton, createButtonPortal)
+ : null;
+ })()}
+
+
+
+ Create a new backup password?
+
+ Starting over lets you pick a new password and download a fresh
+ backup file. Backups you saved earlier will still work — just use
+ the password you created them with.
+
+
+
+
+ Keep current backup
+
+ {
+ dispatch({ type: "start-new-backup" });
+ setSavedPath(null);
+ savedForRef.current = null;
+ setTest(initialBackupTestProgress);
+ setIsRevealed(false);
+ }}
+ >
+ Start with a new password
+
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx b/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx
index 0376fc9709..a6a02f38c0 100644
--- a/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx
+++ b/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx
@@ -22,8 +22,8 @@ export function KeyringLockedScreen() {
}, []);
const handleImport = React.useCallback(
- async (nsec: string) => {
- const identity = await importIdentity(nsec);
+ async (nsec: string, password?: string) => {
+ const identity = await importIdentity(nsec, password);
// Update the identity query cache so useIdentityQuery observers see
// locked: false. The bootedLocked latch in hooks.ts will then route
// to RelaunchRequiredScreen via bootedLocked && !identityLocked.
diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
index ca87c76636..cee17c68f8 100644
--- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx
@@ -1,20 +1,33 @@
import * as React from "react";
import type { QueryClient } from "@tanstack/react-query";
+import { ArrowUp } from "lucide-react";
+import { motion, useReducedMotion } from "motion/react";
import {
getIdentity,
importIdentity,
persistCurrentIdentity,
} from "@/shared/api/tauriIdentity";
+import type { IdentityStorage } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { BackupStep } from "./BackupStep";
import { DefaultConfigStep } from "./DefaultConfigStep";
+import { DownloadKeyStep } from "./DownloadKeyStep";
+import {
+ backupSessionToPasswordEntry,
+ resetEncryptedBackupSession,
+ useEncryptedBackupSession,
+} from "./EncryptedBackupCreator";
import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog";
import { LandingBees } from "./LandingBees";
-import { NostrKeyImportForm } from "./NostrKeyImportForm";
+import {
+ NostrKeyImportForm,
+ type NostrKeyImportStage,
+} from "./NostrKeyImportForm";
import {
ONBOARDING_LANDING_CTA_CLASS,
+ ONBOARDING_SECONDARY_CTA_CLASS,
OnboardingChrome,
} from "./OnboardingChrome";
import { OnboardingFooterProvider } from "./OnboardingFooter";
@@ -28,6 +41,8 @@ export type MachineOnboardingPage =
| "setup"
| "config";
+type BackupSubview = "created" | "options" | "password";
+
/** A pending navigation the parent should execute after RouterProvider mounts. */
export type PostOnboardingNavigation = {
to: string;
@@ -61,10 +76,27 @@ export function MachineOnboardingFlow({
const [error, setError] = React.useState(null);
const [isPending, setIsPending] = React.useState(false);
const [identityWasImported, setIdentityWasImported] = React.useState(false);
+ const [keyImportStage, setKeyImportStage] =
+ React.useState("key-entry");
const [selectedPubkey, setSelectedPubkey] = React.useState(
null,
);
+ const [identityStorage, setIdentityStorage] = React.useState<
+ IdentityStorage | undefined
+ >();
const [readyRuntimeIds, setReadyRuntimeIds] = React.useState([]);
+ const [backupSubview, setBackupSubview] =
+ React.useState("created");
+ const [backupDirection, setBackupDirection] = React.useState<
+ "forward" | "backward"
+ >("forward");
+ const [returningFromSecurity, setReturningFromSecurity] =
+ React.useState(false);
+ // Owned here so switching between the yellow onboarding view and the dark
+ // security subview keeps the created backup, password, and test progress.
+ const backupSession = useEncryptedBackupSession();
+ const reduceMotion = useReducedMotion() ?? false;
+ const isSecuritySubview = page === "backup" && backupSubview !== "created";
const handleReadyRuntimeIdsChange = React.useCallback(
(runtimeIds: readonly string[]) => {
setReadyRuntimeIds(Array.from(new Set(runtimeIds)));
@@ -79,6 +111,10 @@ export function MachineOnboardingFlow({
const identity = await getIdentity();
queryClient.setQueryData(["identity"], identity);
setSelectedPubkey(identity.pubkey);
+ setIdentityStorage(identity.storage);
+ setBackupDirection("forward");
+ setReturningFromSecurity(false);
+ setBackupSubview("created");
setPage("backup");
} catch (cause) {
setError(
@@ -101,6 +137,10 @@ export function MachineOnboardingFlow({
const identity = await persistCurrentIdentity();
queryClient.setQueryData(["identity"], identity);
setSelectedPubkey(identity.pubkey);
+ setIdentityStorage(identity.storage);
+ setBackupDirection("forward");
+ setReturningFromSecurity(false);
+ setBackupSubview("created");
setPage("backup");
} catch (cause) {
setError(
@@ -112,8 +152,8 @@ export function MachineOnboardingFlow({
}, [queryClient]);
const importExistingIdentity = React.useCallback(
- async (nsec: string) => {
- const identity = await importIdentity(nsec);
+ async (nsec: string, password?: string) => {
+ const identity = await importIdentity(nsec, password);
continueWithIdentity(identity.pubkey);
queryClient.setQueryData(["identity"], identity);
setIdentityWasImported(true);
@@ -126,6 +166,8 @@ export function MachineOnboardingFlow({
return (
- {identityLost
- ? "Re-import your key"
- : "Enter your private key"}
+ {keyImportStage === "backup-password"
+ ? "Unlock your account"
+ : identityLost
+ ? "Re-import your key"
+ : "Enter your private key"}
- {identityLost
- ? "Your identity is no longer in the system keyring. Re-import your nsec to restore it."
- : "If you already have a Buzz account, enter your private key below to get started."}
+ {keyImportStage === "backup-password"
+ ? "Enter your backup password to unlock your key and restore your identity."
+ : identityLost
+ ? "Your identity is no longer in the system keyring. Re-import your nsec to restore it."
+ : "If you already have a Buzz account, enter your private key below to get started."}
+ {isEncryptedInput
+ ? "Waiting for a complete ncryptsec backup"
+ : "Waiting for a valid nsec1 key"}
+
+ ) : null}
- {errorMessage ? (
-
{errorMessage}
- ) : null}
-
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+ ) : null}
- {backLabel}
+ {isPasswordStage ? "Back" : backLabel}
diff --git a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx
index 936313bce0..7a52ae4999 100644
--- a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx
+++ b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx
@@ -2,8 +2,8 @@ import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
/**
* Positions in the first-launch flow: landing, identity/key, harness setup,
- * default config, community choice, community profile, meet the team. Used as
- * the default pagination length when a flow doesn't pass an explicit total.
+ * default config, community choice, community profile, meet the team. Password
+ * backup is an optional subview of identity/key, not another position.
*/
export const TOTAL_ONBOARDING_PAGES = 7;
@@ -17,6 +17,9 @@ const ONBOARDING_CTA_SHAPE = "h-[2.375rem] rounded-full px-6";
*/
export const ONBOARDING_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-onboarding-cta-label)]`;
+/** Inverted primary action used only on dark backup-security surfaces. */
+export const ONBOARDING_SECURITY_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} bg-white text-black/80 hover:bg-white/90 hover:text-black`;
+
/**
* Primary-CTA styling for the landing screen only: the shared pill with the
* chartreuse label (`--buzz-welcome-chartreuse`). The blue label is reserved
@@ -24,6 +27,10 @@ export const ONBOARDING_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(-
*/
export const ONBOARDING_LANDING_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-welcome-chartreuse)]`;
+/** Shared quiet pill for secondary actions throughout onboarding. */
+export const ONBOARDING_SECONDARY_CTA_CLASS =
+ "h-9 rounded-full bg-foreground/10 px-6 text-foreground hover:bg-foreground/15 hover:text-foreground";
+
/**
* Icon-control styling for onboarding surfaces that sit on the textured card:
* olive backup ink (`--buzz-onboarding-backup-ink`) with a plain
@@ -34,6 +41,10 @@ export const ONBOARDING_LANDING_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(-
export const ONBOARDING_INK_ICON_CLASS =
"text-[color:var(--buzz-onboarding-backup-ink)] hover:bg-transparent hover:text-foreground";
+/** Icon controls on the dark noisy backup surfaces stay visually unboxed. */
+export const ONBOARDING_SECURITY_ICON_CLASS =
+ "text-muted-foreground hover:bg-transparent hover:text-foreground";
+
/**
* Shared onboarding chrome shown on every page after the landing screen: a
* static Buzz mark pinned to the top-left, and a centered pagination track that
diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
index 01a226e3de..a3653f750f 100644
--- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
@@ -388,8 +388,8 @@ export function OnboardingFlow({
// key's relay profile reseeds the steps, and a key that already finished
// onboarding on this machine skips straight into the app.
const importExistingKey = React.useCallback(
- async (nsec: string) => {
- const identity = await importIdentity(nsec);
+ async (nsec: string, password?: string) => {
+ const identity = await importIdentity(nsec, password);
relayClient.disconnect();
queryClient.setQueryData(["identity"], identity);
queryClient.removeQueries({ queryKey: profileQueryKey });
diff --git a/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx b/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx
index 82d9a0213c..ba5d8b2c87 100644
--- a/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx
+++ b/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx
@@ -14,6 +14,7 @@ export type OnboardingTransitionDirection = "forward" | "backward";
export type OnboardingTransitionEffect =
| "fade"
| "line-slide"
+ | "mask-reveal-down"
| "mask-reveal-up"
| "none";
diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx
index 431c9b2f51..911ddaf362 100644
--- a/desktop/src/features/onboarding/ui/SetupStep.tsx
+++ b/desktop/src/features/onboarding/ui/SetupStep.tsx
@@ -698,25 +698,28 @@ function SetupStepContent({
/>
- actions.next(readyRuntimeIds)}
- type="button"
- >
- Next
-
-
- actions.next([])}
- type="button"
- variant="ghost"
- >
- Skip for now
-
+ {/* Relative row keeps the primary CTA truly centered while Skip
+ hangs off its right edge without shifting the center. */}
+
+ actions.next(readyRuntimeIds)}
+ type="button"
+ >
+ Next
+
+ actions.next([])}
+ type="button"
+ variant="ghost"
+ >
+ Skip for now
+
+
{
});
// ---------------------------------------------------------------------------
-// BackupStep gating: backupNextDisabled() pure helper
+// BackupStep gating: saving a password-protected backup is recommended, not required
// ---------------------------------------------------------------------------
-test("backup_next_disabled_while_loading", () => {
- // During a slow keychain read, Next must be blocked — user cannot race past
- // the key display before it is shown.
- assert.equal(backupNextDisabled({ isLoading: true, loadError: null }), true);
-});
-
-test("backup_next_disabled_on_load_error", () => {
- // Error state: only the explicit "Skip for now" ghost advances; Next blocked.
- assert.equal(
- backupNextDisabled({ isLoading: false, loadError: "IPC error" }),
- true,
- );
-});
-
-test("backup_next_enabled_after_clean_load", () => {
- // Key shown (or backend cleanly returned none) — user may proceed.
- assert.equal(
- backupNextDisabled({ isLoading: false, loadError: null }),
- false,
- );
+test("backup_next_is_always_enabled", () => {
+ assert.equal(backupNextDisabled(), false);
});
// ---------------------------------------------------------------------------
diff --git a/desktop/src/features/settings/ui/SignOutSection.tsx b/desktop/src/features/settings/ui/SignOutSection.tsx
index 746220459b..500875b6f0 100644
--- a/desktop/src/features/settings/ui/SignOutSection.tsx
+++ b/desktop/src/features/settings/ui/SignOutSection.tsx
@@ -31,13 +31,13 @@ export const SIGNOUT_CONFIRM_PHRASE = "wipe all my data";
* Signing out wipes the identity key and all local data, so the confirm
* dialog gates the delete button behind two explicit steps:
*
- * 1. Back up the key — the nsec is shown inline (masked, with reveal/copy);
- * the "I have saved my private key" checkbox unlocks only after the user
- * actually reveals or copies the key.
+ * 1. Confirm recovery — Settings offers a tested password-protected backup;
+ * the dialog also shows the raw nsec as a last-chance fallback, and the
+ * user checks a box confirming they can restore their identity.
* 2. Typed confirmation — the user must type the exact phrase
* "wipe all my data".
*
- * Only when both gates pass does "Delete My Data" become clickable.
+ * Only when both gates pass does "Delete my data" become clickable.
*/
export function SignOutSection() {
const [isOpen, setIsOpen] = React.useState(false);
@@ -47,7 +47,6 @@ export function SignOutSection() {
const [nsec, setNsec] = React.useState(null);
const [nsecError, setNsecError] = React.useState(null);
const [isNsecLoading, setIsNsecLoading] = React.useState(false);
- const [hasInteractedWithKey, setHasInteractedWithKey] = React.useState(false);
const [hasConfirmedBackup, setHasConfirmedBackup] = React.useState(false);
// Guards against a late-resolving getNsec() repopulating state after the
// dialog closes.
@@ -58,20 +57,13 @@ export function SignOutSection() {
const isPhraseConfirmed =
confirmText.trim().toLowerCase() === SIGNOUT_CONFIRM_PHRASE;
- // The backup checkbox unlocks after real interaction with the key
- // (reveal or copy). If the key cannot be loaded at all there is nothing to
- // interact with — let the user proceed past the backup step rather than
- // locking them out of sign-out entirely.
- const isBackupGateSatisfied = hasConfirmedBackup;
- const canConfirmBackup = hasInteractedWithKey || nsecError !== null;
- const canDelete = isBackupGateSatisfied && isPhraseConfirmed && !isPending;
+ const canDelete = hasConfirmedBackup && isPhraseConfirmed && !isPending;
function resetDialogState() {
fetchCancelledRef.current = true;
setNsec(null);
setNsecError(null);
setIsNsecLoading(false);
- setHasInteractedWithKey(false);
setHasConfirmedBackup(false);
setConfirmText("");
}
@@ -137,7 +129,8 @@ export function SignOutSection() {
Sign out
Removes your identity key and all local app data from this device.
- Back up your private key (nsec) first — this cannot be undone.
+ Before signing out, create and test a password-protected key backup
+ above — this cannot be undone.
- 1. Back up your private key (nsec)
+ 1. Confirm you can restore your identity
{isNsecLoading ? (
Loading…
@@ -187,10 +180,7 @@ export function SignOutSection() {
{nsecError}
) : nsec ? (
- setHasInteractedWithKey(true)}
- />
+
) : null}
diff --git a/desktop/src/shared/api/identityTypes.ts b/desktop/src/shared/api/identityTypes.ts
new file mode 100644
index 0000000000..9ae41d3345
--- /dev/null
+++ b/desktop/src/shared/api/identityTypes.ts
@@ -0,0 +1,28 @@
+export type IdentityStorage =
+ | "system-keyring"
+ | "local-file"
+ | "environment"
+ | "ephemeral";
+
+export type Identity = {
+ pubkey: string;
+ displayName: string;
+ /** Durable location of the active identity key. Older/mock bridges may omit
+ * this until they adopt identity storage reporting. */
+ storage?: IdentityStorage;
+ /** True when the app booted in "identity lost" recovery mode — the OS
+ * keyring was empty despite a prior successful migration. The frontend
+ * should route to nsec re-import instead of normal onboarding.
+ * Mutually exclusive with `locked`. */
+ lost?: boolean;
+ /** True when the app booted with an ephemeral key because the OS keyring
+ * holding the real identity is UNREACHABLE (e.g. GNOME Keyring / KWallet
+ * locked). The real key still exists; no in-app recovery is possible —
+ * the user must unlock the keyring externally and relaunch.
+ * Mutually exclusive with `lost`. */
+ locked?: boolean;
+ /** True when the boot-time Phase 2 reset attempted a wipe but verification
+ * failed. Identity resolution was skipped; the sentinel is preserved so
+ * the next relaunch retries the wipe automatically. */
+ resetFailed?: boolean;
+};
diff --git a/desktop/src/shared/api/tauriIdentity.ts b/desktop/src/shared/api/tauriIdentity.ts
index e6ec266bff..161f25c211 100644
--- a/desktop/src/shared/api/tauriIdentity.ts
+++ b/desktop/src/shared/api/tauriIdentity.ts
@@ -1,9 +1,10 @@
import { invokeTauri } from "@/shared/api/tauri";
-import type { Identity } from "@/shared/api/types";
+import type { Identity, IdentityStorage } from "@/shared/api/types";
type RawIdentity = {
pubkey: string;
display_name: string;
+ storage?: IdentityStorage;
lost?: boolean;
locked?: boolean;
reset_failed?: boolean;
@@ -13,6 +14,7 @@ function fromRawIdentity(raw: RawIdentity): Identity {
return {
pubkey: raw.pubkey,
displayName: raw.display_name,
+ storage: raw.storage,
lost: raw.lost === true,
locked: raw.locked === true,
resetFailed: raw.reset_failed === true,
@@ -27,9 +29,12 @@ export async function getNsec(): Promise {
return invokeTauri("get_nsec");
}
-export async function importIdentity(nsec: string): Promise {
+export async function importIdentity(
+ nsec: string,
+ password?: string,
+): Promise {
return fromRawIdentity(
- await invokeTauri("import_identity", { nsec }),
+ await invokeTauri("import_identity", { nsec, password }),
);
}
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts
index 877b5b1c61..3f07e9ad9a 100644
--- a/desktop/src/shared/api/types.ts
+++ b/desktop/src/shared/api/types.ts
@@ -103,25 +103,7 @@ export type AddChannelMembersResult = {
}>;
};
-export type Identity = {
- pubkey: string;
- displayName: string;
- /** True when the app booted in "identity lost" recovery mode — the OS
- * keyring was empty despite a prior successful migration. The frontend
- * should route to nsec re-import instead of normal onboarding.
- * Mutually exclusive with `locked`. */
- lost?: boolean;
- /** True when the app booted with an ephemeral key because the OS keyring
- * holding the real identity is UNREACHABLE (e.g. GNOME Keyring / KWallet
- * locked). The real key still exists; no in-app recovery is possible —
- * the user must unlock the keyring externally and relaunch.
- * Mutually exclusive with `lost`. */
- locked?: boolean;
- /** True when the boot-time Phase 2 reset attempted a wipe but verification
- * failed. Identity resolution was skipped; the sentinel is preserved so
- * the next relaunch retries the wipe automatically. */
- resetFailed?: boolean;
-};
+export type { Identity, IdentityStorage } from "./identityTypes";
export type Profile = {
pubkey: string;
diff --git a/desktop/src/shared/lib/ncryptsecSourceScan.test.mjs b/desktop/src/shared/lib/ncryptsecSourceScan.test.mjs
new file mode 100644
index 0000000000..9ca8b9acf5
--- /dev/null
+++ b/desktop/src/shared/lib/ncryptsecSourceScan.test.mjs
@@ -0,0 +1,74 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import test from "node:test";
+
+// Structural tripwire (plan D4, defense-in-depth): NIP-49 backup material
+// handling in the webview is confined to the identity/backup/import UI and
+// its API wrappers. Anything else in `desktop/src` touching `ncryptsec` is
+// structural drift toward an unguarded egress path and must be reviewed —
+// the runtime guarantee lives in src-tauri's egress guard, this scan only
+// keeps the blob from quietly spreading through the frontend.
+//
+// Mirror of the Rust-side scan in
+// `src-tauri/src/egress_guard_tests.rs::ncryptsec_handling_is_confined_to_allowlisted_files`.
+
+const SRC_ROOT = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "../..",
+);
+
+const ALLOWLIST = [
+ "shared/api/tauriIdentity.ts",
+ "features/onboarding/lib/encryptedBackup.ts",
+ "features/onboarding/lib/encryptedBackup.test.mjs",
+ "features/onboarding/lib/keyImportInput.ts",
+ "features/onboarding/lib/keyImportInput.test.mjs",
+ "features/onboarding/ui/BackupStep.tsx",
+ "features/onboarding/ui/BackupPasswordTimeline.tsx",
+ "features/onboarding/ui/BackupTestFlow.tsx",
+ "features/onboarding/ui/EncryptedBackupCreator.tsx",
+ "features/onboarding/ui/NostrKeyImportForm.tsx",
+ "features/onboarding/ui/NsecMaskedDisplay.tsx",
+ "features/settings/EncryptedBackupProvider.tsx",
+ "features/settings/lib/encryptedBackup.ts",
+ "features/settings/lib/encryptedBackup.test.mjs",
+ "features/settings/ui/BackupTestFlow.tsx",
+ "features/settings/ui/EncryptedBackupCreator.tsx",
+ "features/settings/ui/ProfileSettingsCard.tsx",
+ // e2e-only mock bridge (never in the production bundle):
+ "testing/e2eBridge.ts",
+ // this scan:
+ "shared/lib/ncryptsecSourceScan.test.mjs",
+];
+
+function* walk(dir) {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ yield* walk(full);
+ } else if (/\.(ts|tsx|mjs|js|jsx)$/.test(entry.name)) {
+ yield full;
+ }
+ }
+}
+
+test("ncryptsec handling is confined to allowlisted frontend files", () => {
+ const violations = [];
+ for (const file of walk(SRC_ROOT)) {
+ const rel = path.relative(SRC_ROOT, file).replaceAll("\\", "/");
+ if (ALLOWLIST.includes(rel)) continue;
+ const content = fs.readFileSync(file, "utf8");
+ if (content.toLowerCase().includes("ncryptsec")) {
+ violations.push(rel);
+ }
+ }
+ assert.deepEqual(
+ violations,
+ [],
+ `NIP-49 material outside allowlisted files — wire it through the ` +
+ `identity layer (and its egress-guarded Rust commands) instead:\n` +
+ violations.join("\n"),
+ );
+});
diff --git a/desktop/src/shared/styles/globals/components.css b/desktop/src/shared/styles/globals/components.css
index c21e4a617d..036cf925e0 100644
--- a/desktop/src/shared/styles/globals/components.css
+++ b/desktop/src/shared/styles/globals/components.css
@@ -241,6 +241,54 @@
--buzz-onboarding-avatar-dialog-shadow: 0 0 0;
}
+ /*
+ * Backup options and password backup intentionally leave the bright
+ * onboarding world for a dark security-focused subview. Keep these semantic
+ * overrides on a reusable class as well as the shell so portaled
+ * popovers/dialogs can opt into the same treatment.
+ */
+ .buzz-onboarding-security-theme {
+ color-scheme: dark;
+ --buzz-onboarding-shell-bottom: #082b49;
+ --buzz-onboarding-cta-label: #f5f5f5;
+ --background: 222 45% 4%;
+ --foreground: 0 0% 96%;
+ --card: 220 14% 11%;
+ --card-foreground: 0 0% 96%;
+ --popover: 220 14% 9%;
+ --popover-foreground: 0 0% 96%;
+ --primary: 0 0% 16%;
+ --primary-foreground: 0 0% 96%;
+ --secondary: 0 0% 12%;
+ --secondary-foreground: 0 0% 96%;
+ --muted: 0 0% 12%;
+ --muted-foreground: 0 0% 72%;
+ --accent: 0 0% 16%;
+ --accent-foreground: 0 0% 96%;
+ --destructive: 0 84% 70%;
+ --destructive-foreground: 0 0% 4%;
+ --border: 0 0% 22%;
+ --input: 0 0% 22%;
+ --ring: 0 0% 84%;
+ }
+
+ .buzz-onboarding-neutral-theme.buzz-startup-shell.buzz-onboarding-security-theme {
+ background-color: #010103;
+ background-image:
+ radial-gradient(circle, rgb(143 211 255 / 0.11) 1px, transparent 1px),
+ radial-gradient(
+ ellipse at 50% 58%,
+ rgb(28 112 196 / 0.24) 0%,
+ rgb(18 76 138 / 0.1) 38%,
+ transparent 66%
+ ),
+ linear-gradient(to bottom, #010103 0%, #040914 46%, #082b49 100%);
+ background-size:
+ 24px 24px,
+ auto,
+ auto;
+ }
+
.buzz-onboarding-key-text {
@apply w-full break-all [overflow-wrap:anywhere] font-mono text-nsec-key;
@@ -376,10 +424,13 @@
animation-name: buzz-onboarding-line-slide-backward;
}
+ .buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-down"],
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"] {
overflow: visible;
}
+ .buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-down"]
+ > .buzz-onboarding-transition-content,
.buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"]
> .buzz-onboarding-transition-content {
/* `backwards` (not `both`): the reveal ends on an identity transform, so
@@ -387,12 +438,23 @@
that establishes a containing block and traps `position: fixed`
descendants (the bottom-docked onboarding footer). Reverting to no
transform at rest looks identical and frees fixed positioning. */
- animation: buzz-onboarding-mask-reveal-up 760ms
- cubic-bezier(0.22, 1, 0.36, 1) backwards;
+ animation-duration: 760ms;
+ animation-fill-mode: backwards;
+ animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
animation-delay: var(--buzz-onboarding-transition-delay, 0ms);
transform-origin: 50% 70%;
}
+ .buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-down"]
+ > .buzz-onboarding-transition-content {
+ animation-name: buzz-onboarding-mask-reveal-down;
+ }
+
+ .buzz-onboarding-transition-line[data-onboarding-effect="mask-reveal-up"]
+ > .buzz-onboarding-transition-content {
+ animation-name: buzz-onboarding-mask-reveal-up;
+ }
+
.buzz-onboarding-name-placeholder-caret {
animation: buzz-onboarding-caret-blink 1.1s steps(1, end) infinite;
}
@@ -543,6 +605,25 @@
}
}
+ @keyframes buzz-onboarding-mask-reveal-down {
+ from {
+ filter: blur(8px);
+ opacity: 0;
+ transform: translate3d(0, -30px, 0) scale(0.985);
+ }
+
+ 56% {
+ filter: blur(2px);
+ opacity: 0.86;
+ }
+
+ to {
+ filter: blur(0);
+ opacity: 1;
+ transform: translate3d(0, 0, 0) scale(1);
+ }
+ }
+
@media (prefers-reduced-motion: reduce) {
.buzz-onboarding-runtime-check,
.buzz-onboarding-runtime-checkmark {
diff --git a/desktop/src/shared/ui/alert-dialog.tsx b/desktop/src/shared/ui/alert-dialog.tsx
index d4bf93d4b1..97574abbe6 100644
--- a/desktop/src/shared/ui/alert-dialog.tsx
+++ b/desktop/src/shared/ui/alert-dialog.tsx
@@ -5,6 +5,11 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/shared/lib/cn";
import { buttonVariants } from "@/shared/ui/button";
+import {
+ type CardTextureSize,
+ type CardTextureTone,
+ texturedSurfaceClasses,
+} from "@/shared/ui/card";
import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop";
import {
MODAL_CONTENT_MOTION_CLASS,
@@ -31,25 +36,59 @@ const AlertDialogOverlay = React.forwardRef<
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
+type AlertDialogContentProps = React.ComponentPropsWithoutRef<
+ typeof AlertDialogPrimitive.Content
+> & {
+ surface?: "default" | "textured";
+ textureSize?: CardTextureSize;
+ textureTone?: CardTextureTone;
+};
+
const AlertDialogContent = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-
-