diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 97febc6efd..6fcd5cedee 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -95,6 +95,9 @@ the interface unless an available tool actually supports that action. - Plugins can add features. **Settings → Plugins → Manage plugins** controls them. Do not assume a particular optional plugin is on. +- A plugin can manage some Solo chats, such as task chats. For those chats, + **Archive**, **Unarchive**, and **Undo** open the plugin's own flow. If that + plugin is off or still loading, Buzz refuses the action and changes nothing. - **Attention**, when available, lets agents use Interests, watches, and timers to follow work. Its tab in an agent's profile shows what that agent is watching and whether Attention is on for this device. A mention can still wake an agent diff --git a/docs/solo-regions.md b/docs/solo-regions.md index 57c46435f1..6782d4f5bc 100644 --- a/docs/solo-regions.md +++ b/docs/solo-regions.md @@ -43,6 +43,14 @@ tasks and restore focus after a move. Plugins must scope queries to their own region. Changes to the marker or its meaning require coordinated updates to the host and consuming plugins. +A source may own Archive, Unarchive, and Undo for its chats through the optional +`lifecycle(binding, { channelId, action })` hook. The host routes these actions +for every Solo chat with the source's effective region label, archived chats +included. It then opens no confirmation and publishes nothing; a rejection shows +as the action's failure. If the source is off, loading, unavailable, or failed, +or has no hook, the host refuses the action and publishes nothing. Unlabelled +Solo chats and other actions keep the host confirmation. + The host retains one JSON state value per scope, viewer, and source key across unmount, disposal, and registration. It accepts at most 16 KiB. A failed or stale write returns false and does not update the memory value. Sources must keep their diff --git a/src-tauri/src/relay.rs b/src-tauri/src/relay.rs index e4924ffc85..ad6c0cce7c 100644 --- a/src-tauri/src/relay.rs +++ b/src-tauri/src/relay.rs @@ -88,6 +88,7 @@ fn request_url(community: &str, path: &str, method: &str) -> Result { | "/api/invites/accept-policy" | "/gifs/search" | "/profiles/search" + | "/buzz/v1/lifecycle/state" ), _ => false, }; diff --git a/src-tauri/src/relay/channel_writes.rs b/src-tauri/src/relay/channel_writes.rs index 5a50f46ef1..d6aed80abc 100644 --- a/src-tauri/src/relay/channel_writes.rs +++ b/src-tauri/src/relay/channel_writes.rs @@ -86,12 +86,27 @@ fn details_labels(labels: &[Vec]) -> bool { fn lifecycle(event: &EventTemplate) -> bool { if !matches!(event.kind, 9002 | 9008 | 9021 | 9022 | 41012) || !common(event) - || event.tags.len() != if event.kind == 9002 { 2 } else { 1 } + || !tag(event, 0, "h").is_some_and(uuid) { return false; } - tag(event, 0, "h").is_some_and(uuid) - && (event.kind != 9002 || matches!(tag(event, 1, "archived"), Some("true" | "false"))) + match (event.kind, event.tags.len()) { + (9002, 2) => matches!(tag(event, 1, "archived"), Some("true" | "false")), + // Guarded state request: archive is always subtree-scoped. + (9002, 4) => { + matches!( + (tag(event, 1, "archived"), tag(event, 2, "scope")), + (Some("true"), Some("subtree")) | (Some("false"), Some("target" | "subtree")) + ) && tag(event, 3, "expect").is_some_and(super::hex_key) + } + // Guarded fence: replaces only the target's token. + (9002, 3) => { + tag(event, 1, "lifecycle") == Some("fence") + && tag(event, 2, "expect-target").is_some_and(|t| t == "-" || uuid(t)) + } + (9002, _) => false, + (_, len) => len == 1, + } } fn archive(event: &EventTemplate) -> bool { if !matches!(event.kind, 9035 | 9036) @@ -878,12 +893,10 @@ mod member_administration_tests { use super::*; use tauri::test::{get_ipc_response, mock_builder, INVOKE_KEY}; - #[test] - fn member_commands_match_js_and_sign_through_production_ipc_and_acl() { - let cases: Value = serde_json::from_str(include_str!( - "../../../src/features/channel-members/administration-contract.json" - )) - .unwrap(); + /// A production command handler for one purpose-bound route. + fn ipc( + route: &'static str, + ) -> impl Fn(&str, Value) -> std::result::Result { let app = mock_builder() .manage(IdentityHost::fixture()) .manage(FederatedIdentity::new().unwrap()) @@ -893,7 +906,8 @@ mod member_administration_tests { let view = tauri::WebviewWindowBuilder::new(&app, "main", tauri::WebviewUrl::default()) .build() .unwrap(); - let invoke = |command: &str, event: Value| { + move |command: &str, event: Value| { + let _app = &app; get_ipc_response( &view, tauri::webview::InvokeRequest { @@ -902,13 +916,58 @@ mod member_administration_tests { error: tauri::ipc::CallbackFn(1), url: view.url().unwrap(), body: tauri::ipc::InvokeBody::Json(serde_json::json!({ - "community": "https://relay.test", "route": "member-administration", "event": event + "community": "https://relay.test", "route": route, "event": event })), headers: Default::default(), invoke_key: INVOKE_KEY.into(), }, ) - }; + } + } + + #[test] + fn guarded_lifecycle_commands_match_js_and_sign_through_production_ipc() { + let cases: Value = serde_json::from_str(include_str!( + "../../../src/features/relay/channel-lifecycle-contract.json" + )) + .unwrap(); + let invoke = ipc("channel-lifecycle"); + for case in cases["accepted"].as_array().unwrap() { + let template = case["event"].clone(); + let event: Value = invoke("relay_channel_sign", template.clone()) + .unwrap() + .deserialize() + .unwrap(); + super::super::verify_signature(&event).unwrap(); + assert_eq!(event["tags"], template["tags"], "{}", case["name"]); + let viewer = event["pubkey"].as_str().unwrap(); + assert!(validate("channel-lifecycle", &event, Some(viewer)).is_ok()); + for route in [ + "channel-details", + "identity-archive", + "member-administration", + ] { + assert!(validate(route, &event, Some(viewer)).is_err()); + } + } + for case in cases["rejected"].as_array().unwrap() { + let event = case["event"].clone(); + assert!( + validate("channel-lifecycle", &event, None).is_err(), + "{}", + case["name"] + ); + assert!(invoke("relay_channel_sign", event).is_err()); + } + } + + #[test] + fn member_commands_match_js_and_sign_through_production_ipc_and_acl() { + let cases: Value = serde_json::from_str(include_str!( + "../../../src/features/channel-members/administration-contract.json" + )) + .unwrap(); + let invoke = ipc("member-administration"); for case in cases["accepted"].as_array().unwrap() { let template = case["event"].clone(); let event: Value = invoke("relay_channel_sign", template.clone()) diff --git a/src-tauri/src/relay/tests.rs b/src-tauri/src/relay/tests.rs index 440f3b0bdd..f9493a9acd 100644 --- a/src-tauri/src/relay/tests.rs +++ b/src-tauri/src/relay/tests.rs @@ -22,7 +22,12 @@ fn routes_cannot_retarget_credentials_or_expand_http_access() { ] { assert!(request_url("https://relay.test", path, "POST").is_err()); } - for path in ["/api/invites", "/gifs/search", "/profiles/search"] { + for path in [ + "/api/invites", + "/gifs/search", + "/profiles/search", + "/buzz/v1/lifecycle/state", + ] { assert!(request_url("https://relay.test", path, "POST").is_ok()); assert!(request_url("https://relay.test", path, "GET").is_err()); } diff --git a/src/features/channel-navigation/ChannelNavigationState.test.tsx b/src/features/channel-navigation/ChannelNavigationState.test.tsx index 6991fc8f72..e12a3e3bb0 100644 --- a/src/features/channel-navigation/ChannelNavigationState.test.tsx +++ b/src/features/channel-navigation/ChannelNavigationState.test.tsx @@ -339,3 +339,51 @@ it("runs a chat archive's Undo through the lifecycle gate, after any action hold expect(view.result.current.lifecycleDialog).toBeUndefined(); expect(run).toHaveBeenCalledTimes(3); }); + +it("refuses a region chat's Archive, Unarchive and Undo without a live source and publishes nothing", async () => { + const run = vi.fn(async () => {}); + const view = mount(fixture(run).relay, true); + const task = { + id: "task", + name: "Task", + channelType: "solo" as const, + labels: ["buzz.solo", "buzz.region:nexus:tasks"], + }; + for (const [action, choose] of [ + ["Archive", () => view.result.current.openLifecycle(task, "archive")], + [ + "Unarchive", + () => + view.result.current.openLifecycle( + { ...task, archived: true }, + "unarchive", + ), + ], + [ + "Unarchive", + () => view.result.current.undoArchive({ ...task, archived: true }), + ], + ] as const) { + act(choose); + expect(view.result.current.lifecycleDialog).toBeUndefined(); + expect( + await screen.findByText( + "The plugin that manages this chat is not available. Try again when it is running.", + ), + ).toBeDefined(); + expect(screen.getByText(`${action} chat failed`)).toBeDefined(); + fireEvent.click( + screen.getByRole("button", { name: "Dismiss notification" }), + ); + await waitFor(() => + expect(screen.queryByText(`${action} chat failed`)).toBeNull(), + ); + } + expect(run).not.toHaveBeenCalled(); + // Leave and Delete are not region-owned and keep the host confirmation. + act(() => view.result.current.openLifecycle(task, "leave")); + expect(view.result.current.lifecycleDialog).toMatchObject({ + channel: task, + action: "leave", + }); +}); diff --git a/src/features/channel-navigation/ChannelNavigationState.tsx b/src/features/channel-navigation/ChannelNavigationState.tsx index 86e6029d69..f5255dde41 100644 --- a/src/features/channel-navigation/ChannelNavigationState.tsx +++ b/src/features/channel-navigation/ChannelNavigationState.tsx @@ -22,6 +22,7 @@ import type { RelayData } from "../relay/service"; import type { RelaySession } from "../relay/session"; import type { ChannelSummary } from "../relay/contracts"; import type { ChannelLifecycleAction } from "../relay/channel-lifecycle-protocol"; +import { routeSoloLifecycle } from "./solo-navigation"; /** The session owns writes/recovery; each menu owns its focus and read status. */ export type ChannelMenuSurface = { @@ -212,6 +213,24 @@ export function ChannelNavigationProvider({ else lifecycleOrigin.addEventListener("abort", retire, { once: true }); return () => lifecycleOrigin.removeEventListener("abort", retire); }, [lifecycleOrigin, update]); + // A region source owns its chats' Archive, Unarchive and Undo: no dialog here. + const routeRegion = useCallback( + (channel: ChannelSummary, action: ChannelLifecycleAction) => { + if (action !== "archive" && action !== "unarchive") return false; + const session = connection.session; + const routed = routeSoloLifecycle(session, channel, action); + routed?.catch((error: unknown) => { + if (relay.snapshot().session === session) + setFailure({ + session, + title: `${action === "archive" ? "Archive" : "Unarchive"} chat failed`, + message: error instanceof Error ? error.message : String(error), + }); + }); + return !!routed; + }, + [relay, connection.session], + ); const value = useMemo( () => ({ ...state, @@ -238,6 +257,7 @@ export function ChannelNavigationProvider({ })); }, openLifecycle(channel, action, trigger, origin, callbacks) { + if (origin?.aborted || routeRegion(channel, action)) return; update((previous) => origin?.aborted || previous.lifecycleDialog ? previous @@ -283,6 +303,7 @@ export function ChannelNavigationProvider({ update((previous) => ({ ...previous, lifecycleDialog: undefined })); }, undoArchive(channel) { + if (routeRegion(channel, "unarchive")) return; update((previous) => previous.lifecycleDialog ? { @@ -311,6 +332,7 @@ export function ChannelNavigationProvider({ connection.session, connection.viewer, update, + routeRegion, clearPreparingDm, ], ); diff --git a/src/features/channel-navigation/SoloRegions.test.tsx b/src/features/channel-navigation/SoloRegions.test.tsx index ddbc011faa..bcfd9069f6 100644 --- a/src/features/channel-navigation/SoloRegions.test.tsx +++ b/src/features/channel-navigation/SoloRegions.test.tsx @@ -12,6 +12,7 @@ import { afterEach, assert, expect, it, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { type ReactNode, useCallback, useRef, useState } from "react"; import { SoloRegions } from "./SoloRegions"; +import { ChannelNavigationProvider } from "./ChannelNavigationState"; import { ToastProvider } from "../../shared/design-system/ui/Toast"; import { MenuItem } from "../../shared/design-system/ui/Menu"; import { SoloHeading } from "../../bundled/channels/SoloHeading"; @@ -44,6 +45,7 @@ function fixture() { function mount( h: ReturnType, target: OpenTarget = { version: 1, kind: "home" }, + handoff = false, ) { const open = vi.fn(async () => ({ status: "opened" as const })); const retryReveal = vi.fn(); @@ -81,11 +83,18 @@ function mount( /> ); } - const mounted = render( + const Tree = () => ( - - , + {handoff ? ( + + + + ) : ( + + )} + ); + const mounted = render(); return { ...mounted, open, @@ -94,11 +103,7 @@ function mount( navigate(next: OpenTarget) { target = next; void h.navigate(next); - mounted.rerender( - - - , - ); + mounted.rerender(); }, }; } @@ -1461,3 +1466,49 @@ it("hands a header menu action the durable trigger as its dialog origin", async ); expect(selected).toHaveBeenCalledOnce(); }); + +it("sends the chat menu's Archive for a region chat to the source hook instead of the host dialog", async () => { + const h = fixture(); + const run = vi.fn(async () => {}); + Object.assign(h.session, { + channelLifecycle: { + ...h.session.channelLifecycle, + available: true, + run, + load: async (channelId: string) => ({ + channelId, + channelType: "stream", + canArchive: true, + canUnarchive: false, + canDelete: false, + canLeave: true, + canHide: false, + }), + }, + }); + const lifecycle = vi.fn(async () => { + throw new Error("Finish preparing this task first."); + }); + const source = await h.register({ component: Body, lifecycle }); + mount(h, undefined, true); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Actions for Owned" })); + await user.click( + await screen.findByRole("menuitem", { name: "Archive chat" }), + ); + await waitFor(() => + expect(lifecycle).toHaveBeenCalledExactlyOnceWith(source.binding(), { + channelId: "owned", + action: "archive", + }), + ); + // The source owns confirmation and results; its refusal is the action's failure. + expect( + await screen.findByText("Finish preparing this task first."), + ).toBeInTheDocument(); + expect(screen.getByText("Archive chat failed")).toBeInTheDocument(); + expect( + screen.queryByRole("dialog", { name: "Archive chat" }), + ).not.toBeInTheDocument(); + expect(run).not.toHaveBeenCalled(); +}); diff --git a/src/features/channel-navigation/solo-navigation-contract.ts b/src/features/channel-navigation/solo-navigation-contract.ts index f1bd5375af..e1f2a2fb47 100644 --- a/src/features/channel-navigation/solo-navigation-contract.ts +++ b/src/features/channel-navigation/solo-navigation-contract.ts @@ -37,6 +37,13 @@ export type SoloRegionSource = Readonly<{ read(binding: SoloRegionBinding): SoloRegionSnapshot; component: ComponentType; actions?: (binding: SoloRegionBinding) => readonly SoloRegionAction[]; + /** Owns Archive, Unarchive and Undo for this region's chats, archived ones + * included. The host then shows no confirmation, result or Undo and publishes + * nothing; a rejection shows as the action's failure. */ + lifecycle?: ( + binding: SoloRegionBinding, + request: Readonly<{ channelId: string; action: "archive" | "unarchive" }>, + ) => void | Promise; }>; export type SoloChatInput = Readonly<{ channelId: string; diff --git a/src/features/channel-navigation/solo-navigation.test.ts b/src/features/channel-navigation/solo-navigation.test.ts index 2f9797ec5c..f47c82ba44 100644 --- a/src/features/channel-navigation/solo-navigation.test.ts +++ b/src/features/channel-navigation/solo-navigation.test.ts @@ -3,6 +3,7 @@ import { keypair, signed } from "../relay/testing"; import type { RelayEvent } from "../relay/events"; import { createRelaySession } from "../relay/session"; import { createSoloRegionFixture } from "./solo-navigation-testing"; +import { routeSoloLifecycle } from "./solo-navigation"; import { afterEach, expect, it, vi } from "vitest"; function deferred() { @@ -432,3 +433,63 @@ it("allows explicit recovery reveal with cached connection coordinates while ref expect(reveal).toHaveBeenCalledOnce(); stop(); }); + +it("routes a region chat's Archive and Unarchive only to its live source hook and refuses otherwise", async () => { + const h = fixture(); + const channel = (id: string) => { + const found = h.session.channels.list().channels.find((c) => c.id === id); + if (!found) throw new Error(`Missing ${id}`); + return found; + }; + // Ordinary Solo chats and unlabelled channels stay on the host path. + expect(routeSoloLifecycle(h.session, channel("chat"), "archive")).toBe( + undefined, + ); + expect(routeSoloLifecycle(h.session, channel("unknown"), "archive")).toBe( + undefined, + ); + // A labelled chat without a live hook is refused, never handed back. + await expect( + routeSoloLifecycle(h.session, channel("owned"), "archive"), + ).rejects.toThrow("The plugin that manages this chat is not available"); + const plain = await h.register(); + await expect( + routeSoloLifecycle(h.session, channel("owned"), "archive"), + ).rejects.toThrow("not available"); + await plain.fiber.dispose(); + + const gate = deferred(); + const lifecycle = vi.fn(async () => gate.promise); + const source = await h.register({ lifecycle }); + // Archived chats are outside the projection but still belong to the region. + const routed = routeSoloLifecycle( + h.session, + channel("archived"), + "unarchive", + ); + expect(lifecycle).toHaveBeenCalledExactlyOnceWith(source.binding(), { + channelId: "archived", + action: "unarchive", + }); + let settled = false; + void routed?.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + gate.resolve(); + await expect(routed).resolves.toBeUndefined(); + + lifecycle.mockImplementationOnce(() => { + throw new Error("Task is still preparing"); + }); + await expect( + routeSoloLifecycle(h.session, channel("owned"), "archive"), + ).rejects.toThrow("Task is still preparing"); + + source.update({ status: "loading", binding: source.binding(), revision: 2 }); + await expect( + routeSoloLifecycle(h.session, channel("owned"), "archive"), + ).rejects.toThrow("not available"); + expect(lifecycle).toHaveBeenCalledTimes(2); +}); diff --git a/src/features/channel-navigation/solo-navigation.ts b/src/features/channel-navigation/solo-navigation.ts index 4d0f186511..c46e974d7a 100644 --- a/src/features/channel-navigation/solo-navigation.ts +++ b/src/features/channel-navigation/solo-navigation.ts @@ -2,12 +2,18 @@ import { Service, type Context } from "@deepseek-ai/cordis"; import { createContributions } from "../../plugins/contributions"; import { readView, viewRevision, writeView } from "../../shared/view-state"; import type { RelayData, RelaySnapshot } from "../relay/service"; +import type { ChannelSummary } from "../relay/contracts"; import type { RelaySession } from "../relay/session"; import type { JsonValue } from "../navigation/targets"; import type { PluginManager } from "../../plugins/manager"; import { relayPartition } from "../relay/partition"; import { ownSolo, soloLabels } from "../solo/labels"; -import { regionLabels, soloRegionLabel } from "../solo/region-labels"; +import { + effectiveRegionLabel, + REGION_PREFIX, + regionLabels, + soloRegionLabel, +} from "../solo/region-labels"; import { projectSoloRegions, type RegisteredSoloSource, @@ -368,6 +374,30 @@ export class SoloNavigationService extends Service implements SoloNavigation { retry = (key: string) => { if (this.sources().some((source) => source.key === key)) this.bind(true); }; + /** Runs the live source's lifecycle hook for one of its chats, archived ones included. */ + lifecycle( + label: string, + channelId: string, + action: "archive" | "unarchive", + ): Promise { + const binding = this.binding; + const runtime = [...this.runtimes.values()].find( + ({ source }) => + `${REGION_PREFIX}${source.pluginId}:${source.id}` === label, + ); + const hook = runtime?.source.lifecycle; + if ( + !binding || + !runtime || + runtime.failed || + runtime.snapshot?.status !== "ready" || + !hook + ) + return Promise.reject(new Error(REGION_LIFECYCLE_UNAVAILABLE)); + return (async () => { + await hook(binding, Object.freeze({ channelId, action })); + })(); + } registerSource(source: SoloRegionSource): SoloRegionHandle { const owner = this.ctx.pluginOwner; if (!owner) @@ -380,7 +410,8 @@ export class SoloNavigationService extends Service implements SoloNavigation { typeof source.read !== "function" || typeof source.subscribe !== "function" || !source.component || - (source.order !== undefined && !Number.isFinite(source.order)) + (source.order !== undefined && !Number.isFinite(source.order)) || + (source.lifecycle !== undefined && typeof source.lifecycle !== "function") ) throw new Error("Invalid Solo region source"); const identity = {}; @@ -452,6 +483,30 @@ export class SoloNavigationService extends Service implements SoloNavigation { } } +const REGION_LIFECYCLE_UNAVAILABLE = + "The plugin that manages this chat is not available. Try again when it is running."; + +/** + * Archive and Unarchive of a region-labelled Solo chat belong to its live + * region source; without one they are refused, never published here. + * Undefined means the host handles the chat itself. + */ +export function routeSoloLifecycle( + session: RelaySession, + channel: ChannelSummary, + action: "archive" | "unarchive", +): Promise | undefined { + const label = + channel.channelType === "solo" + ? effectiveRegionLabel(channel.labels) + : undefined; + if (!label) return undefined; + return ( + soloNavigationFor(session)?.lifecycle(label, channel.id, action) ?? + Promise.reject(new Error(REGION_LIFECYCLE_UNAVAILABLE)) + ); +} + /** Recheck the shared projection immediately before a personal filing write. */ export function prepareSoloMove(session: RelaySession, channelId: string) { const region = soloNavigationFor(session) diff --git a/src/features/relay/channel-lifecycle-contract.json b/src/features/relay/channel-lifecycle-contract.json new file mode 100644 index 0000000000..305731b699 --- /dev/null +++ b/src/features/relay/channel-lifecycle-contract.json @@ -0,0 +1,301 @@ +{ + "accepted": [ + { + "name": "archive-subtree", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "02020202-0202-0202-0202-020202020202"], + ["archived", "true"], + ["scope", "subtree"], + [ + "expect", + "e9fa8e584e36a45a6773703a48536e276fee69015545bbc3b40169a56cbaeef7" + ] + ] + } + }, + { + "name": "restore-target", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "04040404-0404-0404-0404-040404040404"], + ["archived", "false"], + ["scope", "target"], + [ + "expect", + "32a700a437403b0695e028542a50e74705b4416095dee22366a8584fd7ce4fdd" + ] + ] + } + }, + { + "name": "restore-subtree", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "02020202-0202-0202-0202-020202020202"], + ["archived", "false"], + ["scope", "subtree"], + [ + "expect", + "3821bc3142cdbc0a678052580d5e103553669eb11ebd956d1678411d65938fd7" + ] + ] + } + }, + { + "name": "fence-token", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "02020202-0202-0202-0202-020202020202"], + ["lifecycle", "fence"], + ["expect-target", "a2a2a2a2-a2a2-a2a2-a2a2-a2a2a2a2a2a2"] + ] + } + }, + { + "name": "fence-null-token", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "03030303-0303-0303-0303-030303030303"], + ["lifecycle", "fence"], + ["expect-target", "-"] + ] + } + } + ], + "rejected": [ + { + "name": "archive-target-scope", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "02020202-0202-0202-0202-020202020202"], + ["archived", "true"], + ["scope", "target"], + [ + "expect", + "e9fa8e584e36a45a6773703a48536e276fee69015545bbc3b40169a56cbaeef7" + ] + ] + } + }, + { + "name": "unknown-scope", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "02020202-0202-0202-0202-020202020202"], + ["archived", "false"], + ["scope", "all"], + [ + "expect", + "3821bc3142cdbc0a678052580d5e103553669eb11ebd956d1678411d65938fd7" + ] + ] + } + }, + { + "name": "uppercase-expect", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "02020202-0202-0202-0202-020202020202"], + ["archived", "true"], + ["scope", "subtree"], + [ + "expect", + "E9FA8E584E36A45A6773703A48536E276FEE69015545BBC3B40169A56CBAEEF7" + ] + ] + } + }, + { + "name": "short-expect", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "02020202-0202-0202-0202-020202020202"], + ["archived", "true"], + ["scope", "subtree"], + [ + "expect", + "e9fa8e584e36a45a6773703a48536e276fee69015545bbc3b40169a56cbaeef" + ] + ] + } + }, + { + "name": "settings-tag", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "02020202-0202-0202-0202-020202020202"], + ["archived", "true"], + ["scope", "subtree"], + [ + "expect", + "e9fa8e584e36a45a6773703a48536e276fee69015545bbc3b40169a56cbaeef7" + ], + ["name", "renamed"] + ] + } + }, + { + "name": "reordered", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "02020202-0202-0202-0202-020202020202"], + ["scope", "subtree"], + ["archived", "true"], + [ + "expect", + "e9fa8e584e36a45a6773703a48536e276fee69015545bbc3b40169a56cbaeef7" + ] + ] + } + }, + { + "name": "repeated-expect", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "02020202-0202-0202-0202-020202020202"], + ["archived", "true"], + [ + "expect", + "e9fa8e584e36a45a6773703a48536e276fee69015545bbc3b40169a56cbaeef7" + ], + [ + "expect", + "e9fa8e584e36a45a6773703a48536e276fee69015545bbc3b40169a56cbaeef7" + ] + ] + } + }, + { + "name": "state-with-fence", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "02020202-0202-0202-0202-020202020202"], + ["archived", "true"], + ["lifecycle", "fence"], + [ + "expect", + "e9fa8e584e36a45a6773703a48536e276fee69015545bbc3b40169a56cbaeef7" + ] + ] + } + }, + { + "name": "fence-other", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "02020202-0202-0202-0202-020202020202"], + ["lifecycle", "other"], + ["expect-target", "-"] + ] + } + }, + { + "name": "fence-uppercase-token", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "02020202-0202-0202-0202-020202020202"], + ["lifecycle", "fence"], + ["expect-target", "A2A2A2A2-A2A2-A2A2-A2A2-A2A2A2A2A2A2"] + ] + } + }, + { + "name": "fence-empty-token", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "02020202-0202-0202-0202-020202020202"], + ["lifecycle", "fence"], + ["expect-target", ""] + ] + } + }, + { + "name": "fence-with-archived", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "02020202-0202-0202-0202-020202020202"], + ["lifecycle", "fence"], + ["archived", "true"] + ] + } + }, + { + "name": "target-without-fence", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "02020202-0202-0202-0202-020202020202"], + ["expect-target", "-"], + ["lifecycle", "fence"] + ] + } + }, + { + "name": "non-uuid-channel", + "event": { + "kind": 9002, + "created_at": 1700000010, + "content": "", + "tags": [ + ["h", "not-a-channel"], + ["lifecycle", "fence"], + ["expect-target", "-"] + ] + } + } + ] +} diff --git a/src/features/relay/channel-lifecycle-protocol.ts b/src/features/relay/channel-lifecycle-protocol.ts index a1f648b4ba..64cf1e8c0f 100644 --- a/src/features/relay/channel-lifecycle-protocol.ts +++ b/src/features/relay/channel-lifecycle-protocol.ts @@ -13,6 +13,7 @@ export const CHANNEL_LIFECYCLE_KINDS = [9002, 9008, 9021, 9022, 41012] as const; export const DM_VISIBILITY_KIND = 30622; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PUBKEY = /^[0-9a-f]{64}$/; +const DIGEST = PUBKEY; export function lifecycleChannelId(value: string): string { if (typeof value !== "string" || !UUID.test(value)) @@ -47,6 +48,53 @@ export function lifecycleTemplate( return event; } +/** + * A guarded task lifecycle request (relay `lifecycle_guard` v1). The host + * builds every tag, so callers cannot add metadata. `state` archives a subtree + * (`archived: true`) or restores (`archived: false`) when `expect` still + * matches the relay's digest. `fence` only replaces the target's token when it + * is still `expectTarget` (null: never set). + */ +export type GuardedLifecycleRequest = + | Readonly<{ + kind: "state"; + channelId: string; + archived: boolean; + scope: "target" | "subtree"; + expect: string; + }> + | Readonly<{ kind: "fence"; channelId: string; expectTarget: string | null }>; + +export function guardedLifecycleTemplate( + request: GuardedLifecycleRequest, +): EventTemplate { + const id = lifecycleChannelId(request.channelId); + const event = { + kind: 9002, + created_at: Math.floor(Date.now() / 1000), + content: "", + tags: + request.kind === "state" + ? [ + ["h", id], + ["archived", String(request.archived)], + ["scope", request.scope], + ["expect", request.expect], + ] + : [ + ["h", id], + ["lifecycle", "fence"], + ["expect-target", request.expectTarget ?? "-"], + ], + }; + validateLifecycleTemplate(event); + return event; +} + +/** Whether a valid lifecycle template is a guarded request rather than a plain archive. */ +export const guardedLifecycleEvent = (event: EventTemplate) => + event.kind === 9002 && event.tags.length > 2; + /** The host never accepts generic metadata edits or other-member removal. */ export function validateLifecycleTemplate(event: EventTemplate): void { if ( @@ -56,22 +104,109 @@ export function validateLifecycleTemplate(event: EventTemplate): void { !Number.isSafeInteger(event.created_at) || event.created_at < 0 || !Array.isArray(event.tags) || - event.tags.length !== (event.kind === 9002 ? 2 : 1) || + !(event.kind === 9002 + ? [2, 3, 4].includes(event.tags.length) + : event.tags.length === 1) || event.tags[0]?.length !== 2 || event.tags[0]?.[0] !== "h" ) throw new Error("Invalid channel lifecycle command"); lifecycleChannelId(event.tags[0]?.[1] ?? ""); + if (event.kind !== 9002) return; + const value = (index: number, name: string) => { + const tag = event.tags[index]; + return tag?.length === 2 && tag[0] === name ? tag[1] : undefined; + }; + const archived = value(1, "archived"); + const scope = value(2, "scope"); + const target = value(2, "expect-target"); if ( - event.kind === 9002 && - !["true", "false"].some( - (value) => - JSON.stringify(event.tags[1]) === JSON.stringify(["archived", value]), - ) + !(event.tags.length === 2 + ? archived === "true" || archived === "false" + : event.tags.length === 4 + ? (archived === "true" + ? scope === "subtree" + : archived === "false" && + (scope === "target" || scope === "subtree")) && + DIGEST.test(value(3, "expect") ?? "") + : value(1, "lifecycle") === "fence" && + (target === "-" || UUID.test(target ?? ""))) ) throw new Error("Only archive metadata may be changed here"); } +/** The relay's guarded lifecycle capability. */ +export type LifecycleGuard = Readonly<{ maxSubtreeChannels: number }>; +export const LIFECYCLE_STATE_PATH = "/buzz/v1/lifecycle/state"; +/** The state route's fixed batch limit; the route works without the capability. */ +const MAX_STATE_CHANNELS = 1000; + +/** NIP-11 `lifecycle_guard` v1; anything else means new guarded requests are unsupported. */ +export function lifecycleGuard(info: unknown): LifecycleGuard | undefined { + const guard = (info as { lifecycle_guard?: unknown } | null) + ?.lifecycle_guard as Record | null | undefined; + const subtree = guard?.max_subtree_channels; + return guard?.version === 1 && + guard.state_path === LIFECYCLE_STATE_PATH && + Number.isSafeInteger(subtree) && + (subtree as number) > 0 + ? Object.freeze({ maxSubtreeChannels: subtree as number }) + : undefined; +} + +/** One channel's current lifecycle state from the relay's state route. */ +export type ChannelLifecycleState = Readonly<{ + id: string; + /** Unix seconds; null while active. */ + archivedAt: number | null; + deleted: boolean; + /** Null until the first guarded change. */ + token: string | null; +}>; + +/** Request batches for the state route: unique canonical ids, at most 1000 each. */ +export function lifecycleStateBatches(ids: readonly string[]) { + const unique = [...new Set(ids.map(lifecycleChannelId))]; + if (!unique.length) throw new Error("Invalid channel ID"); + const batches: string[][] = []; + for (let start = 0; start < unique.length; start += MAX_STATE_CHANNELS) + batches.push(unique.slice(start, start + MAX_STATE_CHANNELS)); + return batches; +} + +/** Validates one state response. An omitted id is unknown, never gone. */ +export function lifecycleStates( + body: unknown, + requested: readonly string[], +): ChannelLifecycleState[] { + const channels = (body as { channels?: unknown } | null)?.channels; + if (!Array.isArray(channels)) + throw new Error("Invalid channel lifecycle state"); + const pending = new Set(requested); + return channels.map((entry: unknown) => { + const row = (entry ?? {}) as Record; + const { id, archived_at: archivedAt, deleted } = row; + const token = row.lifecycle_token; + if ( + typeof id !== "string" || + !pending.delete(id) || + !( + archivedAt === null || + (Number.isSafeInteger(archivedAt) && (archivedAt as number) >= 0) + ) || + typeof deleted !== "boolean" || + !(token === null || (typeof token === "string" && UUID.test(token))) + ) + throw new Error("Invalid channel lifecycle state"); + return Object.freeze({ + id, + archivedAt: archivedAt as number | null, + deleted, + token: token as string | null, + }); + }); +} + export function exactLifecycleTag(event: RelayEvent, name: string) { const matches = event.tags.filter(([key]) => key === name); if (matches.length > 1 || matches.some((entry) => entry.length !== 2)) diff --git a/src/features/relay/channel-lifecycle.test.ts b/src/features/relay/channel-lifecycle.test.ts index 320baff6db..df28daf6e5 100644 --- a/src/features/relay/channel-lifecycle.test.ts +++ b/src/features/relay/channel-lifecycle.test.ts @@ -14,15 +14,18 @@ import { } from "vitest"; import { ReadError } from "./errors"; import { finalizeEvent, getPublicKey, type EventTemplate } from "nostr-tools"; -import { PublishRejected } from "./outbox"; +import { PublishNotSent, PublishRejected } from "./outbox"; +import contract from "./channel-lifecycle-contract.json"; import { createChannelLifecycle, ChannelLifecycleUnconfirmed, } from "./channel-lifecycle"; import { + guardedLifecycleTemplate, lifecycleSettings, lifecycleTemplate, validateLifecycleTemplate, + type GuardedLifecycleRequest, } from "./channel-lifecycle-protocol"; import type { ReadFilter, RelayEvent } from "./events"; import type { ReadOptions } from "./reader"; @@ -1868,3 +1871,309 @@ it("retires queued entries and waits only after confirmed removal, including pen h.owner.dispose(); } }); + +describe("guarded task lifecycle", () => { + const golden = (n: number) => { + const hex = n.toString(16).padStart(2, "0").repeat(16); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + }; + function guarded() { + const sign = vi.fn(async (event: EventTemplate) => + finalizeEvent(structuredClone(event), key), + ); + const publish = vi.fn(async (_event: RelayEvent): Promise => ""); + const info = vi.fn( + async (_signal: AbortSignal): Promise => ({ + lifecycle_guard: { + version: 1, + state_path: "/buzz/v1/lifecycle/state", + max_state_channels: 1000, + max_subtree_channels: 200, + }, + }), + ); + const state = vi.fn( + async ( + body: Readonly<{ channel_ids: readonly string[] }>, + _signal: AbortSignal, + ) => + Response.json({ + channels: body.channel_ids.map((channel) => ({ + id: channel, + archived_at: null, + deleted: false, + lifecycle_token: null, + })), + }), + ); + let access = true; + const owner = createChannelLifecycle({ + writer: { sign, publish, info, state }, + viewer, + relayAuthor, + canAccess: () => access, + acceptDiscovery: () => {}, + removed: () => {}, + }); + const api = owner.capability.guarded; + assert.exists(api); + return { + api, + owner, + sign, + publish, + info, + state, + deny() { + access = false; + }, + }; + } + const archive = { + kind: "state", + channelId: golden(2), + archived: true, + scope: "subtree", + expect: contract.accepted[0]?.event.tags[3]?.[1] ?? "", + } as const; + + it("matches the shared contract and builds every tag itself", () => { + for (const { event } of contract.accepted) + expect(() => validateLifecycleTemplate(event)).not.toThrow(); + for (const { name, event } of contract.rejected) + expect(() => validateLifecycleTemplate(event), name).toThrow(); + const tags = (request: GuardedLifecycleRequest) => + guardedLifecycleTemplate(request).tags; + expect(tags(archive)).toEqual(contract.accepted[0]?.event.tags); + expect( + tags({ + kind: "fence", + channelId: golden(2), + expectTarget: golden(0xa2), + }), + ).toEqual(contract.accepted[3]?.event.tags); + expect( + tags({ kind: "fence", channelId: golden(3), expectTarget: null }), + ).toEqual(contract.accepted[4]?.event.tags); + expect(() => tags({ ...archive, scope: "target" })).toThrow(); + expect(() => tags({ ...archive, archived: "yes" as never })).toThrow(); + }); + + it("reads the advertised capability fresh and treats anything else as unsupported", async () => { + const h = guarded(); + await expect(h.api.capability()).resolves.toEqual({ + maxSubtreeChannels: 200, + }); + for (const lifecycle_guard of [ + undefined, + { version: 2, state_path: "/buzz/v1/lifecycle/state" }, + { version: 1, state_path: "/other", max_subtree_channels: 5 }, + { version: 1, state_path: "/buzz/v1/lifecycle/state" }, + { + version: 1, + state_path: "/buzz/v1/lifecycle/state", + max_subtree_channels: 0, + }, + ]) { + h.info.mockResolvedValueOnce({ lifecycle_guard }); + await expect(h.api.capability()).resolves.toBeUndefined(); + } + expect(h.info).toHaveBeenCalledTimes(6); + // Without the reads, a connection does not offer guarded requests at all. + expect(harness().owner.capability.guarded).toBeUndefined(); + }); + + it("batches state reads and keeps an omitted channel unknown", async () => { + const h = guarded(); + const ids = Array.from( + { length: 1001 }, + (_, index) => + `00000000-0000-4000-8000-${index.toString(16).padStart(12, "0")}`, + ); + const rows = await h.api.state([...ids, ids[0] ?? ""]); + expect(h.state.mock.calls.map(([body]) => body.channel_ids.length)).toEqual( + [1000, 1], + ); + expect(rows).toHaveLength(1001); + h.state.mockResolvedValueOnce( + Response.json({ + channels: [ + { + id: golden(2), + archived_at: 1_700_000_000, + deleted: false, + lifecycle_token: golden(0xa2), + }, + { + id: golden(4), + archived_at: null, + deleted: true, + lifecycle_token: null, + }, + ], + }), + ); + await expect( + h.api.state([golden(2), golden(3), golden(4)]), + ).resolves.toEqual([ + { + id: golden(2), + archivedAt: 1_700_000_000, + deleted: false, + token: golden(0xa2), + }, + { id: golden(4), archivedAt: null, deleted: true, token: null }, + ]); + for (const channels of [ + [ + { + id: golden(5), + archived_at: null, + deleted: false, + lifecycle_token: null, + }, + ], + [ + { + id: golden(2), + archived_at: null, + deleted: false, + lifecycle_token: null, + }, + { + id: golden(2), + archived_at: null, + deleted: false, + lifecycle_token: null, + }, + ], + [ + { + id: golden(2), + archived_at: null, + deleted: "no", + lifecycle_token: null, + }, + ], + [ + { + id: golden(2), + archived_at: -1, + deleted: false, + lifecycle_token: null, + }, + ], + [ + { + id: golden(2), + archived_at: null, + deleted: false, + lifecycle_token: "x", + }, + ], + ]) { + h.state.mockResolvedValueOnce(Response.json({ channels })); + await expect(h.api.state([golden(2)])).rejects.toThrow( + "Invalid channel lifecycle state", + ); + } + h.state.mockResolvedValueOnce( + Response.json( + { error: { code: "forbidden", request_id: "r" } }, + { status: 403 }, + ), + ); + await expect(h.api.state([golden(2)])).rejects.toThrow(); + await expect(h.api.state(["NOT-A-UUID"])).rejects.toThrow( + "Invalid channel ID", + ); + await expect(h.api.state([])).rejects.toThrow("Invalid channel ID"); + }); + + it("classifies one send without retrying: applied, refused, not sent or unknown", async () => { + const h = guarded(); + const event = await h.api.sign(archive); + expect(event.tags).toEqual(contract.accepted[0]?.event.tags); + await expect(h.api.send(event)).resolves.toEqual({ kind: "applied" }); + h.publish.mockResolvedValueOnce("duplicate: already have this event"); + await expect(h.api.send(event)).resolves.toEqual({ kind: "applied" }); + h.publish.mockRejectedValueOnce( + new PublishRejected( + "Relay rejected the message (400)", + "invalid: channel is archived", + ), + ); + await expect(h.api.send(event)).resolves.toEqual({ + kind: "refused", + reason: "invalid: channel is archived", + }); + // A conflict says nothing more than any other refusal. + h.publish.mockRejectedValueOnce( + new PublishRejected( + "conflict: the relay state changed; reload before writing again", + ), + ); + await expect(h.api.send(event)).resolves.toMatchObject({ + kind: "refused", + }); + h.publish.mockRejectedValueOnce(new PublishNotSent("rate-limited: wait")); + await expect(h.api.send(event)).resolves.toEqual({ + kind: "not-sent", + reason: "rate-limited: wait", + }); + h.publish.mockRejectedValueOnce(new Error("connection lost")); + await expect(h.api.send(event)).rejects.toBeInstanceOf( + ChannelLifecycleUnconfirmed, + ); + // Cancelling after publication started leaves the outcome unknown. + const gate = deferred(); + h.publish.mockImplementationOnce(() => gate.promise); + const controller = new AbortController(); + const pending = h.api.send(event, controller.signal); + await vi.waitFor(() => expect(h.publish).toHaveBeenCalledTimes(7)); + controller.abort(); + gate.reject(new DOMException("aborted", "AbortError")); + await expect(pending).rejects.toBeInstanceOf(ChannelLifecycleUnconfirmed); + expect(h.publish).toHaveBeenCalledTimes(7); + }); + + it("publishes nothing when the caller retires, access ends, or the event is not a guarded request", async () => { + const h = guarded(); + const event = await h.api.sign({ + kind: "fence", + channelId: golden(2), + expectTarget: null, + }); + // The caller's publication guard decides at the moment of publication. + const retired = vi.fn(() => false); + await expect(h.api.send(event, undefined, retired)).resolves.toEqual({ + kind: "not-sent", + reason: "Channel action cancelled", + }); + expect(retired).toHaveBeenCalledOnce(); + const plain = finalizeEvent(lifecycleTemplate("archive", golden(2)), key); + const forged = { + ...event, + tags: [...event.tags.slice(0, 2), ["expect-target", golden(0xa2)]], + }; + const foreign = finalizeEvent( + guardedLifecycleTemplate({ + kind: "fence", + channelId: golden(2), + expectTarget: null, + }), + agentKey, + ); + for (const candidate of [plain, forged, foreign]) + await expect(h.api.send(candidate)).resolves.toMatchObject({ + kind: "not-sent", + }); + h.deny(); + await expect(h.api.send(event)).resolves.toMatchObject({ + kind: "not-sent", + }); + await expect(h.api.sign(archive)).rejects.toThrow("Channel access"); + expect(h.publish).not.toHaveBeenCalled(); + expect(h.sign).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/relay/channel-lifecycle.ts b/src/features/relay/channel-lifecycle.ts index d1611a4add..3f4da9e45a 100644 --- a/src/features/relay/channel-lifecycle.ts +++ b/src/features/relay/channel-lifecycle.ts @@ -1,8 +1,9 @@ -import { getEventHash } from "nostr-tools"; +import { getEventHash, type EventTemplate } from "nostr-tools"; import { attestedOwner } from "../agents/owner-attestation"; import type { RelayReader } from "./reader"; -import type { RelayWriter } from "./transport"; -import { PublishRejected } from "./outbox"; +import type { ChannelLifecycleWriter } from "./transport"; +import { PublishNotSent, PublishRejected } from "./outbox"; +import { readApiFailure } from "./http-admission"; import { isTransientFailure, readErrorKind } from "./errors"; import { retryRead } from "./read-retry"; import { hasTag, newer, type RelayEvent } from "./events"; @@ -10,12 +11,21 @@ import { openMetadata } from "./discovery"; import { DM_VISIBILITY_KIND, exactLifecycleTag, + guardedLifecycleEvent, + guardedLifecycleTemplate, lifecycleChannelId, + lifecycleGuard, lifecycleRecord, lifecycleSettings, + lifecycleStateBatches, + lifecycleStates, lifecycleTemplate, + validateLifecycleTemplate, type ChannelLifecycleCommand, type ChannelLifecycleSettings, + type ChannelLifecycleState, + type GuardedLifecycleRequest, + type LifecycleGuard, } from "./channel-lifecycle-protocol"; /** No automatic or same-dialog retry after a request may have reached the relay. */ @@ -28,6 +38,40 @@ export class ChannelLifecycleUnconfirmed extends Error { } } +/** + * How one send of a signed guarded request ended. Only `applied` proves an + * effect: a state request committed, or a fence replaced the token. A refusal + * proves only that this delivery did not commit; `reason` is display text. + * `send` rejects instead when the outcome is unknown. + */ +export type GuardedSendOutcome = + | Readonly<{ kind: "applied" }> + | Readonly<{ kind: "refused"; reason: string }> + | Readonly<{ kind: "not-sent"; reason: string }>; + +/** Guarded task lifecycle (relay `lifecycle_guard` v1). The host never reads back or retries. */ +export interface GuardedChannelLifecycle { + /** The relay's current capability; undefined means new guarded requests are unsupported. */ + capability(signal?: AbortSignal): Promise; + /** Current state of each id, in any order. An omitted id is unknown; only + * `deleted: true` means gone. It works without the capability. */ + state( + ids: readonly string[], + signal?: AbortSignal, + ): Promise; + /** Signs one request. Store the event before `send`; resending it is safe. */ + sign( + request: GuardedLifecycleRequest, + signal?: AbortSignal, + ): Promise; + send( + event: RelayEvent, + signal?: AbortSignal, + /** Checked synchronously right before publishing; false publishes nothing. */ + active?: () => boolean, + ): Promise; +} + export type DmVisibility = Readonly<{ status: "idle" | "loading" | "ready" | "error"; hidden: readonly string[]; @@ -50,6 +94,8 @@ export interface ChannelLifecycleCapability { subscribe(listener: () => void): () => void; refreshVisibility(signal?: AbortSignal): Promise; observeRemoval(listener: (channelId: string) => void): () => void; + /** Present only on connections that implement guarded requests. */ + readonly guarded?: GuardedChannelLifecycle; } export function createChannelLifecycle({ @@ -62,7 +108,7 @@ export function createChannelLifecycle({ removed, }: { reader?: RelayReader | undefined; - writer?: RelayWriter | undefined; + writer?: ChannelLifecycleWriter | undefined; viewer: string; relayAuthor: string; canAccess(id: string): boolean; @@ -272,8 +318,109 @@ export function createChannelLifecycle({ emit({ status: "ready", hidden: snapshot.hidden }); return snapshot.hidden; } + async function sign(template: EventTemplate, signal: AbortSignal) { + if (!writer) + throw new Error("Channel actions are unavailable on this connection"); + const signed = await writer.sign(structuredClone(template), signal); + signal.throwIfAborted(); + if ( + signed.pubkey !== viewer || + signed.kind !== template.kind || + signed.created_at !== template.created_at || + signed.content !== template.content || + JSON.stringify(signed.tags) !== JSON.stringify(template.tags) || + getEventHash(signed) !== signed.id + ) + throw new Error("Signer changed the channel lifecycle command"); + return signed; + } + const info = writer?.info; + const stateRoute = writer?.state; + const guarded: GuardedChannelLifecycle | undefined = + writer && info && stateRoute + ? Object.freeze({ + capability: (caller?: AbortSignal) => + owned({ caller }, async (signal) => { + const document = await info(signal); + signal.throwIfAborted(); + return lifecycleGuard(document); + }), + async state(ids: readonly string[], caller?: AbortSignal) { + const batches = lifecycleStateBatches(ids); + return owned({ caller }, async (signal) => { + const states: ChannelLifecycleState[] = []; + for (const batch of batches) { + const response = await stateRoute( + { channel_ids: batch }, + signal, + ); + signal.throwIfAborted(); + if (!response.ok) + throw new Error((await readApiFailure(response)).error); + states.push(...lifecycleStates(await response.json(), batch)); + } + return Object.freeze(states); + }); + }, + async sign(request: GuardedLifecycleRequest, caller?: AbortSignal) { + const template = guardedLifecycleTemplate(request); + const id = lifecycleChannelId(request.channelId); + return owned({ id, caller }, async (signal) => { + assertAccess(id); + return sign(template, signal); + }); + }, + async send( + event: RelayEvent, + caller?: AbortSignal, + active?: () => boolean, + ): Promise { + let publicationStarted = false; + try { + validateLifecycleTemplate(event); + if ( + !guardedLifecycleEvent(event) || + event.pubkey !== viewer || + getEventHash(event) !== event.id + ) + throw new Error("Invalid channel lifecycle command"); + const id = lifecycleChannelId(event.tags[0]?.[1] ?? ""); + return await owned({ id, caller }, async (signal) => { + assertAccess(id); + if (active?.() === false) + throw new DOMException( + "Channel action cancelled", + "AbortError", + ); + publicationStarted = true; + await writer.publish(event, signal); + return Object.freeze({ kind: "applied" as const }); + }); + } catch (error) { + if (error instanceof PublishNotSent) + return Object.freeze({ + kind: "not-sent", + reason: error.message, + }); + if (error instanceof PublishRejected) + return Object.freeze({ + kind: "refused", + reason: error.reason ?? error.message, + }); + if (!publicationStarted) + return Object.freeze({ + kind: "not-sent", + reason: + error instanceof Error ? error.message : String(error), + }); + throw new ChannelLifecycleUnconfirmed(error); + } + }, + }) + : undefined; const capability: ChannelLifecycleCapability = Object.freeze({ available, + ...(guarded ? { guarded } : {}), observeRemoval(listener: (channelId: string) => void) { removalListeners.add(listener); return () => { @@ -418,18 +565,7 @@ export function createChannelLifecycle({ ); }; if (await authorize()) return; - const template = lifecycleTemplate(action, id); - const signed = await writer.sign(structuredClone(template), signal); - signal.throwIfAborted(); - if ( - signed.pubkey !== viewer || - signed.kind !== template.kind || - signed.created_at !== template.created_at || - signed.content !== template.content || - JSON.stringify(signed.tags) !== JSON.stringify(template.tags) || - getEventHash(signed) !== signed.id - ) - throw new Error("Signer changed the channel lifecycle command"); + const signed = await sign(lifecycleTemplate(action, id), signal); if (await authorize()) return; signal.throwIfAborted(); if (active?.() === false) diff --git a/src/features/relay/native.test.ts b/src/features/relay/native.test.ts index 3130ee6ee4..ac46a3f847 100644 --- a/src/features/relay/native.test.ts +++ b/src/features/relay/native.test.ts @@ -3,6 +3,7 @@ import { READ_STATE_TIMESTAMP_REFUSAL, } from "./read-state-host"; import memberContract from "../channel-members/administration-contract.json"; +import lifecycleContract from "./channel-lifecycle-contract.json"; import { afterEach, assert, beforeEach, expect, it, vi } from "vitest"; import { invoke } from "@tauri-apps/api/core"; import { @@ -1088,6 +1089,50 @@ it("routes lifecycle sign/publish separately from the message writer and preserv ).rejects.toThrow("rate-limited: unrecognized reason"); }); +it("reads guarded lifecycle capability and state over the signed native routes", async () => { + const transport = await connectNativeTransport(community); + const writer = transport.channelLifecycle; + assert.exists(writer?.info); + assert.exists(writer.state); + const signal = new AbortController().signal; + const guard = { + version: 1, + state_path: "/buzz/v1/lifecycle/state", + max_state_channels: 1000, + max_subtree_channels: 200, + }; + vi.mocked(invoke).mockResolvedValueOnce({ + status: 200, + headers: {}, + body: JSON.stringify({ self: relay.pubkey, lifecycle_guard: guard }), + }); + expect(await writer.info(signal)).toMatchObject({ lifecycle_guard: guard }); + expect(vi.mocked(invoke).mock.lastCall).toEqual([ + "relay_http", + { community, path: "/", method: "GET", body: null }, + ]); + const body = { channel_ids: ["11111111-1111-4111-8111-111111111111"] }; + vi.mocked(invoke).mockResolvedValueOnce({ + status: 200, + headers: {}, + body: JSON.stringify({ channels: [] }), + }); + const response = await writer.state(body, signal); + expect(await response.json()).toEqual({ channels: [] }); + // The host signs NIP-98 over this exact body; no other path is reachable. + expect(vi.mocked(invoke).mock.lastCall).toEqual([ + "relay_http", + { + community, + path: "/buzz/v1/lifecycle/state", + method: "POST", + body: JSON.stringify(body), + }, + ]); + for (const { event } of lifecycleContract.rejected) + await expect(writer.sign(event, signal)).rejects.toThrow(); +}); + it("prepares and decodes only verified community-scoped recipe records", async () => { const transport = await connectNativeTransport(community); assert.exists(transport.channelKit); diff --git a/src/features/relay/native.ts b/src/features/relay/native.ts index e509ae7a4e..984a3d752f 100644 --- a/src/features/relay/native.ts +++ b/src/features/relay/native.ts @@ -30,7 +30,10 @@ import { } from "../channel-templates/model"; import type { RelayWriter } from "./transport"; import { communityGitRepository } from "../repos/git"; -import { validateLifecycleTemplate } from "./channel-lifecycle-protocol"; +import { + LIFECYCLE_STATE_PATH, + validateLifecycleTemplate, +} from "./channel-lifecycle-protocol"; import { validateMemberAdministrationTemplate } from "../channel-members/administration-protocol"; import { validateDetailsTemplate } from "./channel-details-protocol"; import { validateArchiveRequestTemplate } from "./identity-archive-protocol"; @@ -471,10 +474,17 @@ export async function connectNativeTransport( return response; }), archiveAuthority: author, - channelLifecycle: commandWriter( - "channel-lifecycle", - validateLifecycleTemplate, - ), + channelLifecycle: { + ...commandWriter("channel-lifecycle", validateLifecycleTemplate), + info: (signal) => nativeRelayInfo(origin, signal), + state: (body, signal) => + admitSignedRequest( + origin, + transport.viewer, + () => nativeRelayRequest(origin, LIFECYCLE_STATE_PATH, body, signal), + signal, + ), + }, channelDetails: commandWriter("channel-details", validateDetailsTemplate), memberAdministration: commandWriter("member-administration", (event) => validateMemberAdministrationTemplate(event, transport.viewer ?? ""), diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index b9c231369b..414a29e974 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -85,6 +85,16 @@ export interface RelayWriter { signal: AbortSignal, ): Promise | Promise; } +/** Guarded task lifecycle needs both reads; a writer without them cannot offer it. */ +export interface ChannelLifecycleWriter extends RelayWriter { + /** The relay's current NIP-11 document. */ + readonly info?: (signal: AbortSignal) => Promise; + /** One NIP-98 signed POST of the lifecycle state route with this exact body. */ + readonly state?: ( + body: Readonly<{ channel_ids: readonly string[] }>, + signal: AbortSignal, + ) => Promise; +} export interface ReadTransport { readonly projectGit?: RepoGit; readonly readAgentMemories?: MemoryReader; @@ -106,7 +116,7 @@ export interface ReadTransport { ) => Promise; readonly workflows?: WorkflowHost; /** Narrow lifecycle signer/publisher; never supplied to the message outbox. */ - readonly channelLifecycle?: RelayWriter; + readonly channelLifecycle?: ChannelLifecycleWriter; /** Name/about/private-only metadata writer, separate from lifecycle and outbox. */ readonly channelDetails?: RelayWriter; readonly memberAdministration?: RelayWriter;