From 4d696dbbeab7896cf677908bc94bf2849b66da6a Mon Sep 17 00:00:00 2001 From: mtoohey Date: Thu, 5 Nov 2026 15:25:07 +0000 Subject: [PATCH 01/12] fix(relay): show the relay's reason when it refuses workflows or emoji MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the relay refused a workflow save, run or delete, or a custom emoji add or remove, Buzz replaced its reason with fixed copy ("Workflow command rejected; retain the draft…", "Failed to add emoji."). A member who adds a Call Webhook step was never told that the step needs owner or admin, and a timed-out member could not tell when to try again. The relay's text is shown only for a real refusal: a PublishRejected that is not a PublishNotSent. Timeouts, lost receipts, signer mismatches, client-side checks and not-sent errors keep today's copy. - transport: `forbidden:` joins the relay-shaped prefixes. The signed writer's keep-filter dropped a 400 `{error:"forbidden: …"}` body, so the reason arrived as "Relay rejected the message (400)". This also fixes Hide conversation's "forbidden: not a member of this DM". - outbox: onReceipt gains an optional `refusal` argument with the clipped display text. The journaled error is unchanged: relay text can quote event content, so it never reaches the journal. - workflows capability: receipt() keeps the refusal as an in-memory rejected result, so Recent activity shows the relay's words for this session. After a reload the journal's fixed copy is the fallback. - session.ts (FOUNDATION, approved for this one-line change): forward the refusal to workflows.receipt. - emoji directory: a refusal reads "Failed to add emoji. " (or the remove equivalent). Timeouts and failed own-set reads are unchanged. - docs: workflows.md and relay-queries.md describe the new behavior. Built on main @ b62173845. None of the touched lines changed since the design note's 9c941525c, and #2085, #2306 and #2281 have not landed, so nothing needed adapting. Not changed here: - Browser host: the broker answers a socket `forbidden:`/`blocked:`/ `restricted:` refusal with 503 `sent:false`, which the client treats as PublishNotSent, so those keep fixed copy there. `invalid:` refusals arrive as 400 and show their reason. - A stale save's 409 now shows the client's own "conflict: the relay state changed; reload before writing again" instead of the fixed workflow copy. - The 200 `{accepted:false}` path still shows the raw prefixed text. Tests: a `forbidden:` row in transport.test.ts; refusal and not-sent cases in capability.test.ts through the real outbox, with saved() checked for relay text; the refusal forward in outbox-receipts.test.ts; exact emoji add/remove refusal copy and a not-sent case in emoji.test.ts and CustomEmojiSettings.test.tsx; workflows.journey.mjs now expects the fixture's refusal text. Each new or tightened assertion fails without the production change. The journey fails without the session.ts forward. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mtoohey Signed-off-by: Matt Toohey --- docs/relay-queries.md | 7 ++-- docs/workflows.md | 4 ++- .../emoji/CustomEmojiSettings.test.tsx | 9 +++-- src/bundled/workflows/workflows.journey.mjs | 6 ++-- src/features/relay/emoji-directory.ts | 8 +++-- src/features/relay/emoji.test.ts | 29 +++++++++++---- src/features/relay/outbox-receipts.test.ts | 18 ++++++++-- src/features/relay/outbox.ts | 16 +++++++-- src/features/relay/session.ts | 3 +- src/features/relay/transport.test.ts | 4 +++ src/features/relay/transport.ts | 2 +- src/features/workflows/capability.test.ts | 35 ++++++++++++++++++- src/features/workflows/capability.ts | 10 ++++-- 13 files changed, 122 insertions(+), 29 deletions(-) diff --git a/docs/relay-queries.md b/docs/relay-queries.md index d28031d339..3d38468c79 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -159,9 +159,10 @@ shortcode, and republishes the whole set with a strictly newer `created_at`. Adds within a session run one at a time, so a stale read cannot drop an earlier add. The signed echo must match the template before publishing, the accepted set joins the palette immediately, and failures surface as the reference -`Failed to add emoji.` / `Timed out while adding emoji.` copy. The development -broker admits kind 30030 only for the canonical own-set template -(`validEmojiSetTemplate`). +`Failed to add emoji.` / `Timed out while adding emoji.` copy. A relay refusal +appends the relay's reason, such as `Failed to add emoji. You are banned from +this community`. The development broker admits kind 30030 only for the +canonical own-set template (`validEmojiSetTemplate`). Live kind-30030 updates share the existing profile route, not another socket or subscription slot. Global establishment repairs an already requested catalog; diff --git a/docs/workflows.md b/docs/workflows.md index 298b65616c..705cb77953 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -113,7 +113,9 @@ See the [capability contract](../src/features/workflows/types.ts). The outbox journals intent/signature before publication. A verified echo does not replace the result-bearing receipt. Restored commands never run automatically; -result text stays ephemeral, outside the journal. Workflow intents cannot be replayed +result text stays ephemeral, outside the journal. A refused command shows the +relay's reason in this session only; the journal keeps fixed copy, because relay +text can quote event content. Workflow intents cannot be replayed through generic Outbox Retry either; inspection and dismissal remain available. **Check saved configuration** resolves an unknown save only when a fresh verified diff --git a/src/bundled/emoji/CustomEmojiSettings.test.tsx b/src/bundled/emoji/CustomEmojiSettings.test.tsx index 50cd1a86ed..54390206ed 100644 --- a/src/bundled/emoji/CustomEmojiSettings.test.tsx +++ b/src/bundled/emoji/CustomEmojiSettings.test.tsx @@ -325,11 +325,16 @@ it("shows the failed save, keeps the draft and saves on retry", async () => { store.connect(vi.fn(async (file: File) => uploaded(file))); await user.upload(screen.getByLabelText("Upload image"), png("wave.png")); await screen.findByRole("img", { name: "Selected custom emoji preview" }); - store.rejectNext(new PublishRejected("blocked: no")); + store.rejectNext( + new PublishRejected( + "You are timed out until 12:00", + "restricted: you are timed out until 12:00", + ), + ); const save = screen.getByRole("button", { name: "Save emoji" }); await user.click(save); expect(await screen.findByRole("alert")).toHaveTextContent( - "Failed to add emoji.", + /^Failed to add emoji\. You are timed out until 12:00$/, ); expect(save).toBeEnabled(); expect(screen.getByRole("textbox")).toHaveValue("wave"); diff --git a/src/bundled/workflows/workflows.journey.mjs b/src/bundled/workflows/workflows.journey.mjs index 52e68fc3e9..8cd5b07120 100644 --- a/src/bundled/workflows/workflows.journey.mjs +++ b/src/bundled/workflows/workflows.journey.mjs @@ -1960,11 +1960,9 @@ test("generic Outbox offers message retry but no workflow replay", async ({ await expect( page.getByText("Failed to trigger workflow", { exact: true }), ).toBeVisible(); + // The relay's refusal text replaces the journal's fixed copy in this session. await expect( - page.getByText( - "Workflow command rejected; retain the draft and refresh the saved configuration.", - { exact: true }, - ), + page.getByText("Fixture non-delivery", { exact: true }), ).toBeVisible(); // The Outbox is page chrome beneath the modal editor. await button("Close editor").click(); diff --git a/src/features/relay/emoji-directory.ts b/src/features/relay/emoji-directory.ts index 723e8b22fc..073d4e303c 100644 --- a/src/features/relay/emoji-directory.ts +++ b/src/features/relay/emoji-directory.ts @@ -12,6 +12,7 @@ import { type CustomEmoji, } from "./emoji"; import { eventDto, newer, type RelayEvent } from "./events"; +import { PublishNotSent, PublishRejected } from "./outbox"; import type { Priority, RelayReader } from "./reader"; import type { RelayWriter } from "./transport"; @@ -239,13 +240,16 @@ export function createEmojiDirectory( signal.throwIfAborted(); accept([event]); return shortcode; - } catch { + } catch (error) { throw new Error( timeout.aborted ? url === undefined ? "Timed out while removing emoji." : "Timed out while adding emoji." - : failure, + : error instanceof PublishRejected && + !(error instanceof PublishNotSent) + ? `${failure} ${error.message}` + : failure, ); } } diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index 633002990e..2686cd5346 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -10,7 +10,7 @@ import { } from "./emoji"; import { createRelaySession } from "./session"; import { foldMessages } from "./fold"; -import { PublishRejected } from "./outbox"; +import { PublishNotSent, PublishRejected } from "./outbox"; import { flush, keypair, @@ -728,17 +728,28 @@ it("rejects invalid names before any read and maps publish failure to reference /Invalid emoji name/, ); expect(h.wire.pending).toHaveLength(0); - h.publish.mockRejectedValueOnce(new PublishRejected("blocked: no")); + // A relay refusal adds the relay's reason to the reference copy. + h.publish.mockRejectedValueOnce( + new PublishRejected("You are banned", "blocked: you are banned"), + ); const adding = h.emoji.add?.("party", url); await vi.waitFor(() => expect(h.wire.pending).toHaveLength(1)); h.ownRead().respond([]); - await expect(adding).rejects.toThrow("Failed to add emoji."); + await expect(adding).rejects.toThrow( + /^Failed to add emoji\. You are banned$/, + ); expect(h.emoji.snapshot().mine).toEqual([]); + // A write that never reached the relay has no reason to show. + h.publish.mockRejectedValueOnce(new PublishNotSent("Relay unreachable")); + const notSent = h.emoji.add?.("party", url); + await vi.waitFor(() => expect(h.wire.pending).toHaveLength(1)); + h.ownRead().respond([]); + await expect(notSent).rejects.toThrow(/^Failed to add emoji\.$/); const failedRead = h.emoji.add?.("party", url); await vi.waitFor(() => expect(h.wire.pending).toHaveLength(1)); h.ownRead().fail(new Error("offline")); - await expect(failedRead).rejects.toThrow("Failed to add emoji."); - expect(h.publish).toHaveBeenCalledTimes(1); + await expect(failedRead).rejects.toThrow(/^Failed to add emoji\.$/); + expect(h.publish).toHaveBeenCalledTimes(2); }); it("removes only from the fresh own set and preserves another member's colliding emoji", async () => { const h = authoring(); @@ -823,12 +834,16 @@ it("serializes additions and removals despite a stale own-set read", async () => }); it("keeps the own set after rejected removal and recovers on retry", async () => { const h = authoring(); - h.publish.mockRejectedValueOnce(new PublishRejected("blocked: no")); + h.publish.mockRejectedValueOnce( + new PublishRejected("You are banned", "blocked: you are banned"), + ); const own = set(viewer); const removing = h.emoji.remove?.("party"); await vi.waitFor(() => expect(h.wire.pending).toHaveLength(1)); h.ownRead().respond([own]); - await expect(removing).rejects.toThrow("Failed to remove emoji."); + await expect(removing).rejects.toThrow( + /^Failed to remove emoji\. You are banned$/, + ); expect(h.emoji.snapshot().mine).toEqual([{ shortcode: "party", url }]); const retry = h.emoji.remove?.("party"); await vi.waitFor(() => expect(h.wire.pending).toHaveLength(1)); diff --git a/src/features/relay/outbox-receipts.test.ts b/src/features/relay/outbox-receipts.test.ts index 64ac3c6374..d9060626b3 100644 --- a/src/features/relay/outbox-receipts.test.ts +++ b/src/features/relay/outbox-receipts.test.ts @@ -125,7 +125,11 @@ it("echo plus lost receipt remains seen but settles command result as unavailabl h.reject(new Error("connection lost")); await flush(); expect(h.local.snapshot()[0]?.delivery).toBe("seen"); - expect(h.onReceipt).toHaveBeenCalledExactlyOnceWith(h.published(), undefined); + expect(h.onReceipt).toHaveBeenCalledExactlyOnceWith( + h.published(), + undefined, + undefined, + ); }); it("receipt waits are bounded and late results after disposal never publish", async () => { vi.useFakeTimers(); @@ -135,7 +139,11 @@ it("receipt waits are bounded and late results after disposal never publish", as h.observe([h.published()]); await vi.advanceTimersByTimeAsync(101); expect(h.signal().aborted).toBe(true); - expect(h.onReceipt).toHaveBeenCalledExactlyOnceWith(h.published(), undefined); + expect(h.onReceipt).toHaveBeenCalledExactlyOnceWith( + h.published(), + undefined, + undefined, + ); const late = setup(); late.send(); await vi.advanceTimersByTimeAsync(0); @@ -211,6 +219,12 @@ it.each([30620, 46020, 5])( await flush(); expect(h.outbox.snapshot()[0]?.delivery).toBe("failed"); expect(JSON.stringify(h.saved())).not.toContain("PRIVATE"); + // The refusal text reaches the in-memory result owner only. + expect(h.onReceipt).toHaveBeenCalledWith( + expect.anything(), + undefined, + "PRIVATE", + ); h.outbox.retry(id); await flush(); expect(h.publish).toHaveBeenCalledTimes(1); diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts index cd3f63178a..c011adced2 100644 --- a/src/features/relay/outbox.ts +++ b/src/features/relay/outbox.ts @@ -172,7 +172,13 @@ export function createOutbox( timeoutMs?: number; /** Commands await their receipt even after a verified echo. Never persisted. */ needsReceipt?: (event: EventData) => boolean; - onReceipt?: (event: EventData, message: string | undefined) => void; + /** `refusal` is the relay's display text for a refused command. The journal + * keeps fixed copy, because relay text can quote event content. */ + onReceipt?: ( + event: EventData, + message: string | undefined, + refusal?: string, + ) => void; /** `acknowledged` is false when the publisher was entered but delivery is `unknown`. */ onAccepted?: (event: RelayEvent, acknowledged: boolean) => void; profiling?: RelayProfiler; @@ -647,7 +653,13 @@ export function createOutbox( total(!closed && !latest ? "ok" : "error"); if (closed) return; if (latest && awaitsReceipt(latest.event)) - onReceipt(latest.signed ?? latest.event, undefined); + onReceipt( + latest.signed ?? latest.event, + undefined, + error instanceof PublishRejected && !(error instanceof PublishNotSent) + ? clip(error.message) + : undefined, + ); const delivery: Delivery | undefined = latest && (latest.delivery === "seen" || attempt.previousDelivery === "seen" diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index d397798522..b5bf512793 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -347,7 +347,8 @@ export function createRelaySession( if (acknowledged) acceptOwnActivity(event); }, needsReceipt: isWorkflowOperation, - onReceipt: (event, message) => workflows.receipt(event, message), + onReceipt: (event, message, refusal) => + workflows.receipt(event, message, refusal), // A chat this device is starting takes its events once the relay // has its create; its local agents hear its owner's messages now. // The start's own create and member adds never wait. diff --git a/src/features/relay/transport.test.ts b/src/features/relay/transport.test.ts index 0f33adbc90..fb5d9e2acc 100644 --- a/src/features/relay/transport.test.ts +++ b/src/features/relay/transport.test.ts @@ -148,6 +148,10 @@ it.each([ ], ["blocked: you are banned", "You are banned"], ["restricted: not a channel member", "Not a channel member"], + [ + "forbidden: workflow is disabled or inactive", + "Workflow is disabled or inactive", + ], ["error: internal failure", "Internal failure"], ["pow: difficulty too low", "Difficulty too low"], ["duplicate: already accepted", "Already accepted"], diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index c658d52385..98fbb050c0 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -1615,7 +1615,7 @@ export async function acceptReadStatePublish(response: Response, id: string) { const REFUSAL_STATUSES = [400, 401, 403, 404, 413, 422]; /** NIP-01 machine-readable prefixes: the shape of a relay's own refusal. */ const NIP01_PREFIX = - /^(invalid|blocked|restricted|error|pow|duplicate|rate-limited|conflict|auth-required):\s*/; + /^(invalid|blocked|restricted|forbidden|error|pow|duplicate|rate-limited|conflict|auth-required):\s*/; /** Prefixes writers match on, so the text is shown as sent. */ const KEPT_PREFIXES = new Set(["rate-limited", "conflict", "auth-required"]); /** The relay's own `{error}` text for a refused write. Admission otherwise diff --git a/src/features/workflows/capability.test.ts b/src/features/workflows/capability.test.ts index 62abe668e1..e18eedb651 100644 --- a/src/features/workflows/capability.test.ts +++ b/src/features/workflows/capability.test.ts @@ -2,6 +2,7 @@ import { afterEach, assert, expect, it, vi } from "vitest"; import { createWorkflows } from "./capability"; import { createOutbox, + PublishNotSent, PublishRejected, type OutgoingEvent, } from "../relay/outbox"; @@ -56,7 +57,8 @@ function setup( }, { needsReceipt: (event) => [30620, 46020, 5].includes(event.kind), - onReceipt: (event, message) => workflows.receipt(event, message), + onReceipt: (event, message, refusal) => + workflows.receipt(event, message, refusal), }, ); const read = vi.fn(async () => [] as RelayEvent[]); @@ -389,6 +391,37 @@ it("explicit rejection is rejected, not unknown; revocation fences late receipts expect(h.saved().some((row) => row.event.id === id)).toBe(true); expect(JSON.stringify(h.saved())).not.toContain("PRIVATE"); }); +it.each([ + [ + "the relay's reason for a refusal", + new PublishRejected( + "Workflow is disabled or inactive", + "forbidden: workflow is disabled or inactive", + ), + "Workflow is disabled or inactive", + ], + [ + "fixed copy for a not-sent failure", + new PublishNotSent("Relay unreachable"), + "Workflow command rejected; retain the draft and refresh the saved configuration.", + ], +])( + "a rejected run shows %s; the journal never holds relay text", + async (_, error, shown) => { + const h = setup(); + const operation = h.capability.trigger(h.definition); + await flush(); + h.reject(error); + await flush(); + expect(h.capability.operations.snapshot()[0]).toMatchObject({ + eventId: operation, + outcome: "rejected", + error: shown, + }); + // The journal keeps fixed copy; the relay's text lives in memory only. + expect(JSON.stringify(h.saved())).not.toContain(error.message); + }, +); it("a secret-bearing save receipt succeeds; the secret is taken once and never journaled or surfaced", async () => { const h = setup("https://relay.test"); const operation = h.capability.save({ diff --git a/src/features/workflows/capability.ts b/src/features/workflows/capability.ts index a71c3456d1..bcf79a537f 100644 --- a/src/features/workflows/capability.ts +++ b/src/features/workflows/capability.ts @@ -647,12 +647,16 @@ export function createWorkflows({ "Only the author or their verified agent owner can delete this workflow", ); }, - receipt(event: EventData, message: string | undefined) { + receipt(event: EventData, message: string | undefined, refusal?: string) { if (closed || !receiptInterest.delete(event.id)) return; if (event.kind !== 46020) invalidateDefinitionCache(); if (message === undefined) { - if (results.get(event.id)?.outcome !== "succeeded") - results.delete(event.id); + // The relay's reason lives here for this session; the journal keeps fixed copy. + if (results.get(event.id)?.outcome !== "succeeded") { + if (refusal) + results.set(event.id, { outcome: "rejected", error: refusal }); + else results.delete(event.id); + } rebuild(); return; } From c1614745b34ac11614cfcd6ecf4147416497b134 Mon Sep 17 00:00:00 2001 From: mtoohey Date: Thu, 5 Nov 2026 15:47:37 +0000 Subject: [PATCH 02/12] fix(channels): offer Refresh channels on stale lifecycle failures Two channel-lifecycle failure toasts told people to refresh, but offered no control to do it. An unconfirmed archive said "Refresh channels before trying again." (twice, with the readback's own text), and that control lived only inside collapsed Diagnostics. After a demotion, Archive said "Refresh channel permissions.", which no control is called, and Settings kept offering Archive and Delete. Refresh is offered only for stale state: an unconfirmed result or a permission that changed since the action was offered. A refusal, an owner's leave reason or an unavailable check keeps Dismiss only. - channel-lifecycle: new ChannelLifecycleStale, which ChannelLifecycleUnconfirmed now extends. "No longer permitted" throws it with "Refresh channels to see what you can do now." The readback error drops its own "Refresh channels before trying again." so the wrapper says it once. "Archive/Delete check unavailable" now says "Try again." instead of naming "Retry channel permissions". - ChannelLifecycleDialog: failed() gains a `refresh` flag, set for a ChannelLifecycleStale. - ChannelNavigationState: the failure ToastNotice gets a Refresh channels Button. It runs refreshList({ consistency: "strong" }) and dismisses the toast. Same pattern as the mute and activity toasts. - ChannelsPage: the Settings ChannelLifecycleActions key includes viewerRole, so a refreshed (or live) role change re-reads permissions. refreshList re-reads the viewer's 39002 rosters, which carry the role (the relay re-signs them on every membership change); the remounted actions re-read 39001/39002 themselves. - docs/channels.md describes the recovery. Tests: a real-session ChannelsPage test demotes the viewer, archives, refreshes from the toast and sees Archive and Delete give way to Leave. ChannelNavigationState shows Refresh only for stale errors and calls a strong refreshList. ChannelLifecycleMenu checks the flag for stale and refused errors. channel-lifecycle asserts the stale class and both exact messages. Reverting each production piece separately fails at least one of these. ChannelSettingsPanel's fixture text follows the new readback copy. Checked in Chromium (throwaway spec, not committed): the unconfirmed archive toast reads "... Refresh channels before trying again. Relay returned an invalid delivery receipt", once. Light and dark, 1280 and 600 wide, 100% and 200% text all fit. Keyboard order is F6, toast, Dismiss notification, Refresh channels, following the shared ToastCard, and focus returns to the composer after Refresh. At 600 px and 200% text, Refresh sits below the bounded stack's fold until Tab scrolls it into view. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mtoohey Signed-off-by: Matt Toohey --- docs/channels.md | 11 +- .../channels/ChannelLifecycleDialog.tsx | 14 ++- .../channels/ChannelLifecycleMenu.test.tsx | 43 ++++++- .../channels/ChannelSettingsPanel.test.tsx | 2 +- .../channels/ChannelsPage.tabs.test.tsx | 106 +++++++++++++++++- src/bundled/channels/ChannelsPage.tsx | 3 +- .../ChannelNavigationState.test.tsx | 72 +++++++++++- .../ChannelNavigationState.tsx | 28 ++++- src/features/relay/channel-lifecycle.test.ts | 19 +++- src/features/relay/channel-lifecycle.ts | 18 +-- 10 files changed, 285 insertions(+), 31 deletions(-) diff --git a/docs/channels.md b/docs/channels.md index 1bb44799d3..7400c31ebc 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -607,10 +607,13 @@ confirmed `41010` open, including New message Send, unhides the DM, and its row returns when visibility next refreshes. A definitive rejection offers retry without optimistic removal. If publication or -confirmation has an uncertain outcome, the dialog warns that the command may have -taken effect, disables blind resubmission and asks the user to close and refresh -channels. Cancellation/cache clear/session replacement fence late results but cannot -retract a request already sent. Cancellation returns focus to the originating row; +confirmation has an uncertain outcome, the failure toast warns that the command may +have taken effect and disables blind resubmission. That toast, and the "no longer +permitted" toast shown when signed channel state no longer allows the action, offer +**Refresh channels**: a writer-consistent channel list read that closes the toast. +Settings rereads its actions when the refreshed role or archive state changes. +Other failures offer only Dismiss. Cancellation/cache clear/session replacement +fence late results but cannot retract a request already sent. Cancellation returns focus to the originating row; confirmed Delete, Leave or Hide moves an active conversation to another available destination (or the neutral Messages page) with a visible sidebar-row focus fallback. Last-row Delete/Leave/Hide completion uses the explicit version-1 Channels route `"empty"`, which bypasses diff --git a/src/bundled/channels/ChannelLifecycleDialog.tsx b/src/bundled/channels/ChannelLifecycleDialog.tsx index 8d4418e6a8..4bd6be0d3a 100644 --- a/src/bundled/channels/ChannelLifecycleDialog.tsx +++ b/src/bundled/channels/ChannelLifecycleDialog.tsx @@ -1,6 +1,9 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { Button } from "../../shared/design-system/ui/Button"; -import type { ChannelLifecycleCapability } from "../../features/relay/channel-lifecycle"; +import { + ChannelLifecycleStale, + type ChannelLifecycleCapability, +} from "../../features/relay/channel-lifecycle"; import type { ChannelLifecycleAction } from "../../features/relay/channel-lifecycle-protocol"; import { Dialog, type DialogProps } from "../../shared/design-system/ui/Dialog"; import { @@ -88,7 +91,8 @@ export function ChannelLifecycleDialog({ completed(): void; onSubmit?(): void; finalFocus?: DialogProps["finalFocus"]; - failed?(title: string, message: string): void; + /** `refresh`: refreshing channels is the recovery. */ + failed?(title: string, message: string, refresh: boolean): void; /** Already chosen without a dialog, as Undo is: run it on mount. */ confirmed?: boolean; /** Offered from an immediate archive's result toast. */ @@ -160,7 +164,11 @@ export function ChannelLifecycleDialog({ if (!controller.signal.aborted) { const message = error instanceof Error ? error.message : String(error); if (callbacks.current.failed) - callbacks.current.failed(`${copy.title} failed`, message); + callbacks.current.failed( + `${copy.title} failed`, + message, + error instanceof ChannelLifecycleStale, + ); else setError(message); } } finally { diff --git a/src/bundled/channels/ChannelLifecycleMenu.test.tsx b/src/bundled/channels/ChannelLifecycleMenu.test.tsx index 3b3043f8a6..d0aa7a4a4b 100644 --- a/src/bundled/channels/ChannelLifecycleMenu.test.tsx +++ b/src/bundled/channels/ChannelLifecycleMenu.test.tsx @@ -14,6 +14,7 @@ import { import userEvent from "@testing-library/user-event"; import { ContextMenuRoot, MenuPopup } from "../../shared/design-system/ui/Menu"; import { + ChannelLifecycleStale, ChannelLifecycleUnconfirmed, type ChannelLifecycleCapability, } from "../../features/relay/channel-lifecycle"; @@ -499,6 +500,42 @@ it("uncertain delivery reports refresh guidance in a toast without offering blin expect(lifecycle.run).toHaveBeenCalledOnce(); }); +it.each([ + [ + "a permission that changed", + new ChannelLifecycleStale( + "This action is no longer permitted. Refresh channels to see what you can do now.", + ), + ], + ["an unconfirmed result", new ChannelLifecycleUnconfirmed("connection lost")], +])("flags %s for a channel refresh", async (_, error) => { + const lifecycle = capability(); + lifecycle.run.mockRejectedValueOnce(error); + const failed = vi.fn(); + render( + + {}} + completed={() => {}} + failed={failed} + /> + , + ); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Archive channel" })); + await waitFor(() => + expect(failed).toHaveBeenCalledExactlyOnceWith( + "Archive channel failed", + error.message, + true, + ), + ); +}); + it.each( ([{ action: "delete", label: "Delete channel" }] as const).flatMap( ({ action, label }) => @@ -683,8 +720,8 @@ it("chat archive runs without confirmation, then Undo unarchives from its result completed(); setOpen(false); }} - failed={(title, message) => { - failed(title, message); + failed={(title, message, refresh) => { + failed(title, message, refresh); setOpen(false); }} undo={() => { @@ -735,6 +772,8 @@ it("chat archive runs without confirmation, then Undo unarchives from its result expect(failed).toHaveBeenCalledExactlyOnceWith( "Unarchive chat failed", "Relay rejected the request.", + // A refusal is not stale state; refreshing channels would not help. + false, ), ); await waitFor(() => diff --git a/src/bundled/channels/ChannelSettingsPanel.test.tsx b/src/bundled/channels/ChannelSettingsPanel.test.tsx index a413c8cae5..45194bebe6 100644 --- a/src/bundled/channels/ChannelSettingsPanel.test.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.test.tsx @@ -532,7 +532,7 @@ it.each([ // As the lifecycle owner throws it when readback never shows the flag. new ChannelLifecycleUnconfirmed( new Error( - "The relay accepted the request, but the change is not confirmed. Refresh channels before trying again.", + "The relay accepted the request, but the change is not confirmed.", ), ), "Saved, but not confirmed yet. Try again to check.", diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx index 7cc490691b..40b7a52c88 100644 --- a/src/bundled/channels/ChannelsPage.tabs.test.tsx +++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx @@ -22,6 +22,7 @@ import type { import type { PageNavigation } from "../../features/navigation/service"; import type { OpenTarget } from "../../features/navigation/targets"; import { soloDraftTarget } from "../../features/channel-navigation/routes"; +import { ChannelNavigationProvider } from "../../features/channel-navigation/ChannelNavigationState"; import { createRelaySession, type RelaySession, @@ -309,9 +310,12 @@ function workspace( { membership, lifecycle, + handoff = false, }: { membership?: CommunityMembership; lifecycle?: (event: RelayEvent) => Promise; + /** Mount the shell's channel navigation, which owns lifecycle actions. */ + handoff?: boolean; } = {}, ) { const owner = createRelaySession( @@ -363,8 +367,8 @@ function workspace( const navigator = createNavigationController( createMemoryHistory(), ).navigation; - const page = (navigation?: PageNavigation) => ( - + const page = (navigation?: PageNavigation) => { + const content = ( - - ); + ); + return ( + + {handoff ? ( + + {content} + + ) : ( + content + )} + + ); + }; const { rerender } = render(page()); /** Re-route the mounted page; `undefined` returns to the unrouted workspace. */ const visit = (target?: OpenTarget) => @@ -464,6 +479,89 @@ it("hands the community roster to Channel settings and follows the republished f expect(control.closest("dd")?.querySelector("[role=alert]")).toBeNull(); }); +it("a refreshed roster that demotes the viewer retires Archive and Delete in Channel settings", async () => { + const viewer = keypair(), + authority = keypair(), + other = keypair(); + const id = "11111111-1111-4111-8111-111111111111"; + // Both records carry roles, as the relay re-signs them on a role change. + const roles = (role: "owner" | "member", at: number) => [ + signed(authority, { + kind: 39001, + created_at: at, + content: "", + tags: [ + ["d", id], + ["p", other.pubkey, "owner"], + ...(role === "owner" ? [["p", viewer.pubkey, "owner"]] : []), + ], + }), + signed(authority, { + kind: 39002, + created_at: at, + content: "", + tags: [ + ["d", id], + ["p", other.pubkey, "", "owner"], + ["p", viewer.pubkey, "", role], + ], + }), + ]; + const events = [ + metadata(authority, id, "Alpha", undefined, [["t", "stream"]]), + ...roles("owner", 1_700_000_000), + ]; + const publish = vi.fn(async () => {}); + workspace(events, viewer, authority, { lifecycle: publish, handoff: true }); + await screen.findByRole("textbox", { name: "Message #Alpha" }); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Channel actions" })); + await user.click( + await screen.findByRole("menuitem", { name: "View channel details" }), + ); + const settings = await screen.findByRole("complementary", { + name: "Channel settings", + }); + await within(settings).findByRole("button", { name: "Delete channel" }); + // Another owner demotes the viewer; this page has not seen it. + events.splice(1, 2, ...roles("member", 1_700_000_100)); + await user.click( + within(settings).getByRole("button", { name: "Archive channel" }), + ); + const confirm = await screen.findByRole("dialog", { + name: "Archive channel: Alpha", + }); + await user.click( + within(confirm).getByRole("button", { name: "Archive channel" }), + ); + const toast = await screen.findByRole("dialog", { + name: "Archive channel failed", + }); + expect(toast).toHaveAccessibleDescription( + "This action is no longer permitted. Refresh channels to see what you can do now.", + ); + fireEvent.click( + within(toast).getByRole("button", { name: "Refresh channels" }), + ); + expect( + screen.queryByRole("dialog", { name: "Archive channel failed" }), + ).toBeNull(); + // The refreshed role remounts the actions; Leave arrives with fresh permissions. + await vi.waitFor(() => + expect( + within(settings).queryByRole("button", { name: "Archive channel" }), + ).toBeNull(), + ); + await within(settings).findByRole("button", { name: "Leave channel" }); + expect( + within(settings).queryByRole("button", { name: "Archive channel" }), + ).toBeNull(); + expect( + within(settings).queryByRole("button", { name: "Delete channel" }), + ).toBeNull(); + expect(publish).not.toHaveBeenCalled(); +}); + it("selects a default channel the relay adds after the page opened", async () => { const events: RelayEvent[] = []; const { owner, viewer, authority } = workspace(events); diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index a769857efc..83919f4fbf 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1902,7 +1902,8 @@ function ChannelWorkspace({ (!current.readOnly || current.viewerRole === "guest") && current.channelType !== "dm" && ( Promise = async () => {}, ) { let ids = ["existing"]; + const refreshList = vi.fn(); const session = () => ({ - channels: { list: () => ({ channels: ids.map((id) => ({ id })) }) }, + channels: { + list: () => ({ channels: ids.map((id) => ({ id })) }), + refreshList, + }, sidebarPreferences: { setMute: async () => [] }, channelLifecycle: { run }, }) as unknown as RelaySession; @@ -58,6 +64,7 @@ function fixture( } as RelayData; return { relay, + refreshList, roster: (next: string[]) => { ids = next; }, @@ -362,3 +369,66 @@ 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("offers Refresh channels when a lifecycle action failed on stale state", async () => { + const stale = new ChannelLifecycleStale( + "This action is no longer permitted. Refresh channels to see what you can do now.", + ); + const h = fixture(async () => { + throw stale; + }); + const view = mount(h.relay, true); + act(() => + view.result.current.openLifecycle( + { id: "channel", name: "Channel" }, + "archive", + ), + ); + fireEvent.click( + await screen.findByRole("button", { name: "Archive channel" }), + ); + const toast = await screen.findByRole("dialog", { + name: "Archive channel failed", + }); + expect(toast).toHaveAccessibleDescription(stale.message); + const refresh = within(toast).getByRole("button", { + name: "Refresh channels", + }); + // The shared card puts its dismiss control first, then the actions. + expect(within(toast).getAllByRole("button")).toEqual([ + within(toast).getByRole("button", { name: "Dismiss notification" }), + refresh, + ]); + fireEvent.click(refresh); + expect(h.refreshList).toHaveBeenCalledExactlyOnceWith({ + consistency: "strong", + }); + await waitFor(() => + expect( + screen.queryByRole("dialog", { name: "Archive channel failed" }), + ).toBeNull(), + ); +}); + +it("keeps other lifecycle failures' toasts to Dismiss", async () => { + const h = fixture(async () => { + throw new Error("Archive check unavailable. Try again."); + }); + const view = mount(h.relay, true); + act(() => + view.result.current.openLifecycle( + { id: "channel", name: "Channel" }, + "archive", + ), + ); + fireEvent.click( + await screen.findByRole("button", { name: "Archive channel" }), + ); + const toast = await screen.findByRole("dialog", { + name: "Archive channel failed", + }); + expect(within(toast).getAllByRole("button")).toEqual([ + within(toast).getByRole("button", { name: "Dismiss notification" }), + ]); + expect(h.refreshList).not.toHaveBeenCalled(); +}); diff --git a/src/features/channel-navigation/ChannelNavigationState.tsx b/src/features/channel-navigation/ChannelNavigationState.tsx index 92cb360c4d..3a3199f56f 100644 --- a/src/features/channel-navigation/ChannelNavigationState.tsx +++ b/src/features/channel-navigation/ChannelNavigationState.tsx @@ -156,6 +156,7 @@ export function ChannelNavigationProvider({ session: RelaySession; title: string; message: string; + refresh: boolean; }>(); const activityThread = useRef(undefined); const activityAgent = useRef(undefined); @@ -391,8 +392,13 @@ export function ChannelNavigationProvider({ )?.completed(); value.closeLifecycle(); }} - failed={(title, message) => { - setFailure({ session: connection.session, title, message }); + failed={(title, message, refresh) => { + setFailure({ + session: connection.session, + title, + message, + refresh, + }); value.closeLifecycle(); }} /> @@ -402,7 +408,23 @@ export function ChannelNavigationProvider({ title={failure.title} description={failure.message} onDismiss={() => setFailure(undefined)} - /> + > + {failure.refresh && ( + + )} + )} ); diff --git a/src/features/relay/channel-lifecycle.test.ts b/src/features/relay/channel-lifecycle.test.ts index 9d0fed26e4..f0defa1885 100644 --- a/src/features/relay/channel-lifecycle.test.ts +++ b/src/features/relay/channel-lifecycle.test.ts @@ -17,6 +17,7 @@ import { finalizeEvent, getPublicKey, type EventTemplate } from "nostr-tools"; import { PublishRejected } from "./outbox"; import { createChannelLifecycle, + ChannelLifecycleStale, ChannelLifecycleUnconfirmed, } from "./channel-lifecycle"; import { @@ -611,8 +612,11 @@ it("rereads authority after signing; a removed admin cannot publish", async () = ]); return finalizeEvent(event, key); }); - await expect(h.owner.capability.run("archive", id)).rejects.toThrow( - "no longer permitted", + const run = h.owner.capability.run("archive", id); + // Stale state the viewer can refresh, and copy naming that control. + await expect(run).rejects.toBeInstanceOf(ChannelLifecycleStale); + await expect(run).rejects.toThrow( + /^This action is no longer permitted\. Refresh channels to see what you can do now\.$/, ); expect(h.publish).not.toHaveBeenCalled(); h.owner.dispose(); @@ -906,9 +910,14 @@ describe("Leave confirmation across access revocation", () => { h.read.mockRejectedValueOnce(cancelled()); }); try { - const rejected = expect( - h.owner.capability.run("leave", id), - ).rejects.toBeInstanceOf(ChannelLifecycleUnconfirmed); + const run = h.owner.capability.run("leave", id); + const rejected = Promise.all([ + expect(run).rejects.toBeInstanceOf(ChannelLifecycleUnconfirmed), + // The wrapper names the recovery once; the readback reason adds none. + expect(run).rejects.toThrow( + /^The request may have taken effect\. Refresh channels before trying again\. The relay accepted the request, but the change is not confirmed\.$/, + ), + ]); // The retired read is reread after a real macrotask the fake clock // does not drive; start the poll budget once that reread is issued. while (h.read.mock.calls.length < 4) diff --git a/src/features/relay/channel-lifecycle.ts b/src/features/relay/channel-lifecycle.ts index 92c7521ac0..e90c57e83e 100644 --- a/src/features/relay/channel-lifecycle.ts +++ b/src/features/relay/channel-lifecycle.ts @@ -21,8 +21,12 @@ import { type ChannelLifecycleSettings, } from "./channel-lifecycle-protocol"; +/** The channel state the action relied on may be out of date; refreshing + * channels is the recovery. */ +export class ChannelLifecycleStale extends Error {} + /** No automatic or same-dialog retry after a request may have reached the relay. */ -export class ChannelLifecycleUnconfirmed extends Error { +export class ChannelLifecycleUnconfirmed extends ChannelLifecycleStale { constructor(reason: unknown) { super( `The request may have taken effect. Refresh channels before trying again. ${reason instanceof Error ? reason.message : String(reason)}`, @@ -454,11 +458,11 @@ export function createChannelLifecycle({ hide: settings.canHide, }[action]; if (!permitted) - throw new Error( - action === "leave" && settings.leaveReason - ? settings.leaveReason - : "This action is no longer permitted. Refresh channel permissions.", - ); + throw action === "leave" && settings.leaveReason + ? new Error(settings.leaveReason) + : new ChannelLifecycleStale( + "This action is no longer permitted. Refresh channels to see what you can do now.", + ); }; if (await authorize()) return; const fresh = lifecycleTemplate(action, id); @@ -552,7 +556,7 @@ export function createChannelLifecycle({ } } throw new Error( - "The relay accepted the request, but the change is not confirmed. Refresh channels before trying again.", + "The relay accepted the request, but the change is not confirmed.", ); }); } catch (error) { From abc5a41a0e39da3ca2899625558a7182a5883daf Mon Sep 17 00:00:00 2001 From: mtoohey Date: Thu, 5 Nov 2026 16:06:53 +0000 Subject: [PATCH 03/12] fix(relay): show only the relay's own refusal words, never for echoed commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 6c4db52eb3 (show the relay's reason when it refuses workflows or emoji), addressing its review. - outbox: the refusal is forwarded only when this attempt's delivery is `failed`, the same condition that sets `refused`. A command whose verified echo was already `seen` passed the relay's text before, so receipt() stored `outcome: "rejected"` for an event the relay has. Check saved configuration only resolves `unknown` saves, so it could no longer resolve that save. It now stays `unknown`. - outbox: new `isRelayRefusal`, used by the outbox forward, the emoji directory and the auto-join switch. It holds for a sent PublishRejected whose `reason` is relay-shaped (a NIP-01 prefix) with text after the prefix, and whose `message` is not itself prefixed. Status summaries ("Relay rejected the message (403)"), the 200 `{accepted:false}` path, the client's own 409 conflict text, a plain `PublishRejected("…")` and prefixes kept for writers to match on (`auth-required:`) keep today's fixed copy. `NIP01_PREFIX` moves next to PublishRejected so transport keeps importing from outbox, with no cycle. - ChannelSettingsPanel auto-join: a refusal shows the display text, never the raw `reason`. With `forbidden:` relay-shaped, the raw text would have read "forbidden: …"; it already read "invalid: …". Without a relay reason the switch keeps "Only community owners and admins can change this." - agent-config-artifacts `refusal()`: `forbidden` is a permission refusal. The signed writer now keeps `forbidden:` text, which was classified `unknown` and treated as uncertain. - workflows.journey.mjs: reverted to the fixed-copy assertion. The fixture's `PublishRejected("Fixture non-delivery")` has no relay reason, so it keeps the fixed copy. - docs/workflows.md says which failures keep fixed copy. Corrections to 6c4db52eb3's message: - "The relay's text is shown only for a real refusal: a PublishRejected that is not a PublishNotSent" is narrowed as above, and never for a command whose echo was already seen. - "A stale save's 409 now shows the client's own conflict: …" no longer holds: the 409 has no relay reason, so it keeps the fixed workflow copy. - "The 200 {accepted:false} path still shows the raw prefixed text" holds only for generic Outbox writes. Workflow, emoji and auto-join writes keep their fixed copy on that path. - "workflows.journey.mjs now expects the fixture's refusal text" and "The journey fails without the session.ts forward" no longer hold. The journey checks fixed copy again. A real-session test in src/features/workflows/session.test.ts now covers the forward. Every other PublishRejected.reason consumer was checked (identity-archives, channel-details, channel-lifecycle, administration, native, session): none reads `reason`, so `forbidden:` changes nothing there. Tests: - outbox-receipts: echo plus a `forbidden:` PublishRejected stays seen and forwards no refusal. The journal-secrets row now has a relay reason, so its forward assertion still holds. - capability: a status summary and a reason-less refusal keep the fixed copy; Check saved configuration resolves an echoed save after a relay refusal. - emoji: a status summary and a reason-less refusal keep "Failed to add emoji." - transport: acceptPublish and the native signed writer (connectSignedTransport, `refusalText`) both turn a 400 `{error:"forbidden: …"}` into a PublishRejected whose `reason` is the raw text and that `isRelayRefusal` accepts. A table checks the predicate against real transport output for empty, kept-prefix and unprefixed text. - ChannelSettingsPanel: `invalid:` and `forbidden:` refusals show display text; proxy text and a kept prefix show the default. - agent-config-artifacts: `forbidden:` is `permission`, directly and through acceptPublish. - workflows session: through createRelaySession, a `forbidden:` refusal of a run shows the relay's words on the operation while the journal keeps fixed copy. This replaces the journey's coverage of the session.ts forward. Each new or tightened assertion fails with its production piece reverted on its own: the delivery gate (2 failures), the narrowed predicate (9), the auto-join change (4), the `forbidden` classifier (2), the emoji call site (1), `forbidden` in NIP01_PREFIX (3) and the session.ts forward (1). The journey's fixed-copy assertion fails under the old broad rule and passes now. Checked: tsc --noEmit clean; biome clean on the touched files; vitest for transport, outbox, outbox-receipts, src/features/workflows, emoji, CustomEmojiSettings, ChannelSettingsPanel, agent-config-artifacts, session-permissions and src/bundled/workflows: 35 files, 561 tests passed. workflows.journey.mjs in Chromium: 34 passed. Not changed: the outbox's fixed copy still says "refresh the saved configuration"; the control is named "Check saved configuration". Co-Authored-By: Claude Opus 5.5 Signed-off-by: mtoohey Signed-off-by: Matt Toohey --- docs/workflows.md | 10 ++-- .../channels/ChannelSettingsPanel.test.tsx | 29 ++++++++++- src/bundled/channels/ChannelSettingsPanel.tsx | 19 ++++--- src/bundled/workflows/workflows.journey.mjs | 6 ++- .../agents/agent-config-artifacts.test.ts | 6 +++ src/features/agents/agent-config-artifacts.ts | 1 + src/features/relay/emoji-directory.ts | 5 +- src/features/relay/emoji.test.ts | 24 ++++++--- src/features/relay/outbox-receipts.test.ts | 22 +++++++- src/features/relay/outbox.ts | 36 ++++++++++--- src/features/relay/transport.test.ts | 42 ++++++++++++++- src/features/relay/transport.ts | 5 +- src/features/workflows/capability.test.ts | 32 ++++++++++-- src/features/workflows/session.test.ts | 52 +++++++++++++++++++ 14 files changed, 246 insertions(+), 43 deletions(-) diff --git a/docs/workflows.md b/docs/workflows.md index 705cb77953..612d2ca104 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -113,10 +113,12 @@ See the [capability contract](../src/features/workflows/types.ts). The outbox journals intent/signature before publication. A verified echo does not replace the result-bearing receipt. Restored commands never run automatically; -result text stays ephemeral, outside the journal. A refused command shows the -relay's reason in this session only; the journal keeps fixed copy, because relay -text can quote event content. Workflow intents cannot be replayed -through generic Outbox Retry either; inspection and dismissal remain available. +result text stays ephemeral, outside the journal. A command the relay refuses +with its own reason shows that reason in this session only; the journal keeps +fixed copy, because relay text can quote event content. Other failures keep the +fixed copy, and a refusal after a verified echo leaves the result unknown. +Workflow intents cannot be replayed through generic Outbox Retry either; +inspection and dismissal remain available. **Check saved configuration** resolves an unknown save only when a fresh verified head matches its owner, channel, UUID and exact signed revision. Missing/different diff --git a/src/bundled/channels/ChannelSettingsPanel.test.tsx b/src/bundled/channels/ChannelSettingsPanel.test.tsx index 45194bebe6..e5655433c9 100644 --- a/src/bundled/channels/ChannelSettingsPanel.test.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.test.tsx @@ -517,16 +517,41 @@ it("runs the matching command, stays pending until it settles, then follows the }); it.each([ + // As the transport shows a refusal: the relay's words without their prefix. [ "the relay's own reason", - new PublishRejected("rejected", "invalid: actor not authorized"), - "invalid: actor not authorized", + new PublishRejected( + "Actor not authorized", + "invalid: actor not authorized", + ), + "Actor not authorized", + ], + [ + "the relay's forbidden reason", + new PublishRejected( + "Only owners and admins can change auto-join", + "forbidden: only owners and admins can change auto-join", + ), + "Only owners and admins can change auto-join", ], [ "the default reason", new PublishRejected("rejected"), "Only community owners and admins can change this.", ], + [ + "the default reason for text that is not the relay's", + new PublishRejected( + "Relay rejected the message (403)", + "Forbidden by proxy", + ), + "Only community owners and admins can change this.", + ], + [ + "the default reason for a machine prefix", + new PublishRejected("auth-required: sign in", "auth-required: sign in"), + "Only community owners and admins can change this.", + ], [ "a retry for an unconfirmed outcome", // As the lifecycle owner throws it when readback never shows the flag. diff --git a/src/bundled/channels/ChannelSettingsPanel.tsx b/src/bundled/channels/ChannelSettingsPanel.tsx index 709200f12a..e933e58089 100644 --- a/src/bundled/channels/ChannelSettingsPanel.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.tsx @@ -14,7 +14,11 @@ import { ChannelLifecycleUnconfirmed, type ChannelLifecycleCapability, } from "../../features/relay/channel-lifecycle"; -import { PublishNotSent, PublishRejected } from "../../features/relay/outbox"; +import { + isRelayRefusal, + PublishNotSent, + PublishRejected, +} from "../../features/relay/outbox"; import { managesCommunity } from "../../features/communities/roster"; import type { CommunityMembership } from "../moderation/membership"; import type { RelaySession } from "../../features/relay/session"; @@ -323,12 +327,13 @@ function AutoJoinSetting({ : // A rate limit or a write that never left this device, not a refusal. error instanceof PublishNotSent ? "Not sent. Try again." - : error instanceof PublishRejected - ? (error.reason ?? - "Only community owners and admins can change this.") - : error instanceof Error - ? error.message - : String(error), + : isRelayRefusal(error) + ? error.message + : error instanceof PublishRejected + ? "Only community owners and admins can change this." + : error instanceof Error + ? error.message + : String(error), }); } finally { operation.current = undefined; diff --git a/src/bundled/workflows/workflows.journey.mjs b/src/bundled/workflows/workflows.journey.mjs index 8cd5b07120..52e68fc3e9 100644 --- a/src/bundled/workflows/workflows.journey.mjs +++ b/src/bundled/workflows/workflows.journey.mjs @@ -1960,9 +1960,11 @@ test("generic Outbox offers message retry but no workflow replay", async ({ await expect( page.getByText("Failed to trigger workflow", { exact: true }), ).toBeVisible(); - // The relay's refusal text replaces the journal's fixed copy in this session. await expect( - page.getByText("Fixture non-delivery", { exact: true }), + page.getByText( + "Workflow command rejected; retain the draft and refresh the saved configuration.", + { exact: true }, + ), ).toBeVisible(); // The Outbox is page chrome beneath the modal editor. await button("Close editor").click(); diff --git a/src/features/agents/agent-config-artifacts.test.ts b/src/features/agents/agent-config-artifacts.test.ts index fe3ba59f54..d299ba209c 100644 --- a/src/features/agents/agent-config-artifacts.test.ts +++ b/src/features/agents/agent-config-artifacts.test.ts @@ -284,6 +284,9 @@ describe("delivery", () => { expect(refusal("conflict: prev is not current")).toBe("conflict"); expect(refusal("duplicate: already have this event")).toBe("duplicate"); expect(refusal("restricted: not a member")).toBe("permission"); + expect(refusal("forbidden: not a member of this channel")).toBe( + "permission", + ); expect(refusal("restricted: channel is archived")).toBe("archived"); expect(refusal("invalid: event too large")).toBe("too-large"); expect(refusal("invalid: bad envelope")).toBe("invalid"); @@ -321,6 +324,9 @@ describe("delivery", () => { expect( await through(body(403, "blocked: you are banned from this community")), ).toMatchObject({ status: "refused", refusal: "permission" }); + expect( + await through(body(400, "forbidden: not a member of this channel")), + ).toMatchObject({ status: "refused", refusal: "permission" }); expect(await through(body(500, "internal server error"))).toMatchObject({ status: "uncertain", }); diff --git a/src/features/agents/agent-config-artifacts.ts b/src/features/agents/agent-config-artifacts.ts index b0301fd837..9ac89253c7 100644 --- a/src/features/agents/agent-config-artifacts.ts +++ b/src/features/agents/agent-config-artifacts.ts @@ -277,6 +277,7 @@ export function refusal(message: string): Refusal { if (/archived/i.test(message)) return "archived"; if ( prefix === "restricted" || + prefix === "forbidden" || prefix === "auth-required" || prefix === "blocked" ) diff --git a/src/features/relay/emoji-directory.ts b/src/features/relay/emoji-directory.ts index 073d4e303c..f69caf1147 100644 --- a/src/features/relay/emoji-directory.ts +++ b/src/features/relay/emoji-directory.ts @@ -12,7 +12,7 @@ import { type CustomEmoji, } from "./emoji"; import { eventDto, newer, type RelayEvent } from "./events"; -import { PublishNotSent, PublishRejected } from "./outbox"; +import { isRelayRefusal } from "./outbox"; import type { Priority, RelayReader } from "./reader"; import type { RelayWriter } from "./transport"; @@ -246,8 +246,7 @@ export function createEmojiDirectory( ? url === undefined ? "Timed out while removing emoji." : "Timed out while adding emoji." - : error instanceof PublishRejected && - !(error instanceof PublishNotSent) + : isRelayRefusal(error) ? `${failure} ${error.message}` : failure, ); diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index 2686cd5346..fd6b13b790 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -739,17 +739,27 @@ it("rejects invalid names before any read and maps publish failure to reference /^Failed to add emoji\. You are banned$/, ); expect(h.emoji.snapshot().mine).toEqual([]); - // A write that never reached the relay has no reason to show. - h.publish.mockRejectedValueOnce(new PublishNotSent("Relay unreachable")); - const notSent = h.emoji.add?.("party", url); - await vi.waitFor(() => expect(h.wire.pending).toHaveLength(1)); - h.ownRead().respond([]); - await expect(notSent).rejects.toThrow(/^Failed to add emoji\.$/); + // A write that never reached the relay, or a refusal without the relay's + // own words, has no reason to show. + for (const error of [ + new PublishNotSent("Relay unreachable"), + new PublishRejected( + "Relay rejected the message (403)", + "Forbidden by proxy", + ), + new PublishRejected("Relay rejected the message"), + ]) { + h.publish.mockRejectedValueOnce(error); + const unexplained = h.emoji.add?.("party", url); + await vi.waitFor(() => expect(h.wire.pending).toHaveLength(1)); + h.ownRead().respond([]); + await expect(unexplained).rejects.toThrow(/^Failed to add emoji\.$/); + } const failedRead = h.emoji.add?.("party", url); await vi.waitFor(() => expect(h.wire.pending).toHaveLength(1)); h.ownRead().fail(new Error("offline")); await expect(failedRead).rejects.toThrow(/^Failed to add emoji\.$/); - expect(h.publish).toHaveBeenCalledTimes(2); + expect(h.publish).toHaveBeenCalledTimes(4); }); it("removes only from the fresh own set and preserves another member's colliding emoji", async () => { const h = authoring(); diff --git a/src/features/relay/outbox-receipts.test.ts b/src/features/relay/outbox-receipts.test.ts index d9060626b3..f91bf6d48b 100644 --- a/src/features/relay/outbox-receipts.test.ts +++ b/src/features/relay/outbox-receipts.test.ts @@ -131,6 +131,26 @@ it("echo plus lost receipt remains seen but settles command result as unavailabl undefined, ); }); +it("echo plus a relay refusal remains seen and forwards no refusal", async () => { + const h = setup(); + h.send(); + await flush(); + h.observe([h.published()]); + h.reject( + new PublishRejected( + "Workflow is disabled or inactive", + "forbidden: workflow is disabled or inactive", + ), + ); + await flush(); + // The relay has the event, so its result stays unknown, not refused. + expect(h.local.snapshot()[0]?.delivery).toBe("seen"); + expect(h.onReceipt).toHaveBeenCalledExactlyOnceWith( + h.published(), + undefined, + undefined, + ); +}); it("receipt waits are bounded and late results after disposal never publish", async () => { vi.useFakeTimers(); const h = setup(100); @@ -215,7 +235,7 @@ it.each([30620, 46020, 5])( const h = setup(); const id = h.send(kind); await flush(); - h.reject(new PublishRejected("PRIVATE")); + h.reject(new PublishRejected("PRIVATE", "blocked: PRIVATE")); await flush(); expect(h.outbox.snapshot()[0]?.delivery).toBe("failed"); expect(JSON.stringify(h.saved())).not.toContain("PRIVATE"); diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts index c011adced2..ac4873fa77 100644 --- a/src/features/relay/outbox.ts +++ b/src/features/relay/outbox.ts @@ -124,6 +124,25 @@ export class PublishRejected extends Error { * device, or the relay asked it to wait. Retrying may succeed. */ export class PublishNotSent extends PublishRejected {} +/** NIP-01 machine-readable prefixes: the shape of a relay's own refusal. */ +export const NIP01_PREFIX = + /^(invalid|blocked|restricted|forbidden|error|pow|duplicate|rate-limited|conflict|auth-required):\s*/; + +/** Whether `message` is the relay's own words for a refusal: the write was + * sent and its `reason` is relay-shaped text. Status summaries, client-side + * rejections and prefixed text kept for writers to match on are not, so + * writers show their fixed copy for those. */ +export function isRelayRefusal(error: unknown): error is PublishRejected { + if (!(error instanceof PublishRejected) || error instanceof PublishNotSent) + return false; + const prefix = NIP01_PREFIX.exec(error.reason ?? ""); + return ( + !!prefix && + !!error.reason?.slice(prefix[0].length).trim() && + !NIP01_PREFIX.test(error.message) + ); +} + export { browserOutboxStorage } from "./outbox-storage"; const abortError = () => new DOMException("Relay session closed", "AbortError"); const MAX_PENDING = 256; @@ -652,14 +671,6 @@ export function createOutbox( // A verified observation ends the attempt even if its HTTP ACK never arrives. total(!closed && !latest ? "ok" : "error"); if (closed) return; - if (latest && awaitsReceipt(latest.event)) - onReceipt( - latest.signed ?? latest.event, - undefined, - error instanceof PublishRejected && !(error instanceof PublishNotSent) - ? clip(error.message) - : undefined, - ); const delivery: Delivery | undefined = latest && (latest.delivery === "seen" || attempt.previousDelivery === "seen" @@ -669,6 +680,15 @@ export function createOutbox( : publishing && !(error instanceof PublishRejected) ? "unknown" : failedDelivery(attempt)); + // Only a failed attempt was refused; an echoed command stays unknown. + if (latest && awaitsReceipt(latest.event)) + onReceipt( + latest.signed ?? latest.event, + undefined, + delivery === "failed" && isRelayRefusal(error) + ? clip(error.message) + : undefined, + ); if (latest && delivery) saveStatus({ ...latest, diff --git a/src/features/relay/transport.test.ts b/src/features/relay/transport.test.ts index fb5d9e2acc..a438c6547e 100644 --- a/src/features/relay/transport.test.ts +++ b/src/features/relay/transport.test.ts @@ -9,7 +9,7 @@ import { ReadStateTimestampRejected, READ_STATE_TIMESTAMP_REFUSAL, } from "./read-state-host"; -import { PublishRejected } from "./outbox"; +import { isRelayRefusal, PublishRejected } from "./outbox"; import { hostSigner, keypair, signed } from "./testing"; const key = keypair(); afterEach(() => vi.unstubAllGlobals()); @@ -246,6 +246,46 @@ it("carries the relay's refusal text through both publish transports", async () reason: error, }); }); +it("keeps a forbidden: refusal as relay text through acceptPublish and the signed writer", async () => { + const error = + "forbidden: workflows with call_webhook actions require the owner or admin role"; + const event = signed(key, { kind: 9, content: "hello", tags: [["h", "c"]] }); + const transport = await connectSignedTransport( + hostSigner(key), + "https://relay.test", + "relay", + ); + assert.exists(transport.writer); + vi.stubGlobal("fetch", async () => Response.json({ error }, { status: 400 })); + for (const result of [ + await refusal(400, { error }), + await transport.writer + .publish(event, new AbortController().signal) + .catch((reason: unknown) => reason), + ]) { + expect(result).toBeInstanceOf(PublishRejected); + expect(result).toMatchObject({ + message: + "Workflows with call_webhook actions require the owner or admin role", + reason: error, + }); + expect(isRelayRefusal(result)).toBe(true); + } +}); +it.each([ + ["forbidden: not a member of this DM", true], + // Empty text leaves the status summary. + ["invalid: ", false], + // Text writers match on stays machine text, not display words. + ["auth-required: sign in", false], + // Without a NIP-01 prefix the text is not the relay's. + ["Forbidden by proxy", false], +])( + "counts only the relay's own words as its refusal: %j", + async (error, words) => { + expect(isRelayRefusal(await refusal(403, { error }))).toBe(words); + }, +); it("keeps the conflict refusal and the raw reason for classifying writers", async () => { const conflict = await refusal(409, { error: "invalid: whatever" }); expect(conflict).toHaveProperty( diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index 98fbb050c0..f780d60b10 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -67,7 +67,7 @@ import { type LiveSubscription, } from "./live"; import { subscribeBrokerTraffic } from "./broker-live"; -import { PublishNotSent, PublishRejected } from "./outbox"; +import { NIP01_PREFIX, PublishNotSent, PublishRejected } from "./outbox"; import { httpReadError, ReadError } from "./errors"; import { ARTIFACT_KIND } from "../agents/agent-config-artifacts"; import type { EventTemplate, VerifiedEvent } from "nostr-tools"; @@ -1613,9 +1613,6 @@ export async function acceptReadStatePublish(response: Response, id: string) { } const REFUSAL_STATUSES = [400, 401, 403, 404, 413, 422]; -/** NIP-01 machine-readable prefixes: the shape of a relay's own refusal. */ -const NIP01_PREFIX = - /^(invalid|blocked|restricted|forbidden|error|pow|duplicate|rate-limited|conflict|auth-required):\s*/; /** Prefixes writers match on, so the text is shown as sent. */ const KEPT_PREFIXES = new Set(["rate-limited", "conflict", "auth-required"]); /** The relay's own `{error}` text for a refused write. Admission otherwise diff --git a/src/features/workflows/capability.test.ts b/src/features/workflows/capability.test.ts index e18eedb651..38c746734d 100644 --- a/src/features/workflows/capability.test.ts +++ b/src/features/workflows/capability.test.ts @@ -405,6 +405,19 @@ it.each([ new PublishNotSent("Relay unreachable"), "Workflow command rejected; retain the draft and refresh the saved configuration.", ], + [ + "fixed copy for a status summary", + new PublishRejected( + "Relay rejected the message (403)", + "Forbidden by proxy", + ), + "Workflow command rejected; retain the draft and refresh the saved configuration.", + ], + [ + "fixed copy for a refusal without a relay reason", + new PublishRejected("Relay rejected the message"), + "Workflow command rejected; retain the draft and refresh the saved configuration.", + ], ])( "a rejected run shows %s; the journal never holds relay text", async (_, error, shown) => { @@ -827,9 +840,20 @@ it.each(["clear", "interrupt"] as const)( }, ); -it.each([true, false])( - "fresh exact saved configuration resolves a lost save receipt without replay (echo=%s)", - async (echo) => { +it.each([ + [false, new Error("lost receipt")], + [true, new Error("lost receipt")], + // The relay already has an echoed save, so its refusal leaves it unknown. + [ + true, + new PublishRejected( + "Workflow is disabled or inactive", + "forbidden: workflow is disabled or inactive", + ), + ], +])( + "fresh exact saved configuration resolves a lost save receipt without replay (echo=%s, %s)", + async (echo, error) => { const h = setup(); const operation = h.capability.save({ channelId, @@ -840,7 +864,7 @@ it.each([true, false])( const event = h.publish.mock.calls[0]?.[0]; if (!event) throw new Error("missing publication"); if (echo) h.outbox.observe([event]); - h.reject(new Error("lost receipt")); + h.reject(error); await flush(); expect(h.capability.operations.snapshot()[0]?.outcome).toBe("unknown"); h.read.mockResolvedValue([event]); diff --git a/src/features/workflows/session.test.ts b/src/features/workflows/session.test.ts index f3c14a1c49..8a441885cc 100644 --- a/src/features/workflows/session.test.ts +++ b/src/features/workflows/session.test.ts @@ -508,6 +508,58 @@ it("existing backend permits workflow commands without lifecycle metadata while expect(publish).toHaveBeenCalledTimes(1); }); +it("a relay refusal reaches the session's run operation; the journal keeps fixed copy", async () => { + const wire = scriptedTransport(viewer.pubkey, relay.pubkey); + let incoming!: (events: readonly RelayEvent[]) => void; + let saved = ""; + const publish = vi.fn(async () => { + throw new PublishRejected( + "Workflow is disabled or inactive", + "forbidden: workflow is disabled or inactive", + ); + }); + const owner = createRelaySession( + { + ...wire.transport, + writer: { + kinds: [46020], + sign: async (template) => signed(viewer, template), + publish, + }, + workflows: { runs: async () => ({ runs: [], next: null }) }, + subscribe(callbacks) { + incoming = callbacks.receive; + return { update() {}, retry() {}, dispose() {} }; + }, + }, + { + outboxStorage: { + load: async () => [], + save: async (rows) => { + saved = JSON.stringify(rows); + }, + }, + }, + ); + owners.push(owner); + incoming([roster(relay, channelId, [viewer.pubkey], 1)]); + const operation = owner.session.workflows.trigger({ + ...reference, + yaml: definition.content, + revision: definition.id, + createdAt: 10, + }); + await vi.waitFor(() => + expect(owner.session.workflows.operations.snapshot()[0]).toMatchObject({ + eventId: operation, + outcome: "rejected", + error: "Workflow is disabled or inactive", + }), + ); + await vi.waitFor(() => expect(saved).toContain("Workflow command rejected")); + expect(saved).not.toContain("disabled or inactive"); +}); + it("session fresh definition read resolves a lost save, while ordinary echo does not", async () => { const wire = scriptedTransport(viewer.pubkey, relay.pubkey); let incoming!: (events: readonly RelayEvent[]) => void; From b038ffecb1d46fe2435c454e5f87cc9be595fc7d Mon Sep 17 00:00:00 2001 From: mtoohey Date: Thu, 5 Nov 2026 16:24:02 +0000 Subject: [PATCH 04/12] fix(channels): reread Settings' actions from the writer on Refresh channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to f5eae9bdab (offer Refresh channels on stale lifecycle failures), addressing its review. That commit keyed Settings' ChannelLifecycleActions on `viewerRole` so a refreshed role would reread permissions. `viewerRole` is only the role hint on the viewer's 39002 `p` tag. Authority comes from 39001 and owner-agent attestation in kind-0 profiles. When those changed and the hint did not, Refresh channels left Archive and Delete offered under a toast that said "Refresh channels to see what you can do now." - ChannelNavigationState: new `lifecycleRefreshes` counter in the session-scoped navigation state, bumped by the failure toast's Refresh channels next to the strong refreshList. Session or scope replacement resets it to 0. - ChannelLifecycleActions: new `refreshes` prop. Each change reruns the permission lookup, clearing the old actions while it loads. Once it is nonzero, lookups pass `{ consistency: "strong" }`. ChannelsPage passes the counter through. The key keeps `viewerRole` and archive state for live roster changes. - channel-lifecycle: `load()` takes optional `{ consistency }`. When it is "strong", the 39000/39001/39002 read and the owner-agent kind-0 profile reads both go to the writer. Default reads are unchanged. - channel-lifecycle: ChannelLifecycleStale sets `name`, matching ChannelLifecycleUnconfirmed. Unconfirmed still overrides it. - docs/channels.md describes the reread and its writer routing. The counter goes in as a prop, not in the key as the review suggested. The strong read has to depend on the counter anyway, so with the counter in the key too, the first refresh's rerun had two causes, and the test still passed with the key part removed. One dependency both reruns the lookup and selects the writer, and the tests pin it. Strong reads stay on for the rest of the session after the first refresh, not only for the lookup that refresh starts. The strong refreshList can change `viewerRole` and remount the actions while the refresh's own lookup is in flight. A one-shot strong flag would let that remount read a replica that predates the change. The cost is one writer-routed lookup per Settings mount after a rare recovery action. Tests: - ChannelsPage.tabs: a real-session test changes only 39001. The 39002 hint stays "owner", so the key never changes. Archive's own check reads the demotion and shows the stale toast. A `replica` fixture option then serves the pre-change 39001 to reads that are not strong. After Refresh channels, Settings shows Leave only, Archive and Delete are gone, and nothing was published. - ChannelLifecycleActions: refreshes 0 → 1 → 2 loads three times: once with default reads, then strong twice. - channel-lifecycle: a strong load sends `consistency: "strong"` on all three channel-state filters and the owner profile read. The stale error's `name` is "ChannelLifecycleStale". - ChannelNavigationState: Refresh channels bumps `lifecycleRefreshes` from 0 to 1. With all four production files reverted to b7c2c26488, the ChannelsPage test fails: Archive stays offered after Refresh. Reverting each piece on its own also fails at least one new assertion: no `refreshes` prop from ChannelsPage (1 failure), no counter bump (2), no strong option from Settings (2), `refreshes` missing from the effect deps (2), load ignoring strong for channel state (2), profile reads not strong (1), no `name` (1). Checked: tsc --noEmit clean. biome check clean on the touched sources and tests. vitest, 10 files, 407 passed: channel-lifecycle 214, ChannelLifecycleMenu 36, ChannelLifecycleActions 22, ChannelNavigationState 11, ChannelSettingsPanel 31, ChannelsPage 10, ChannelsPage.tabs 28, ChannelsPage.solo 50, ChannelsPage.conversion 4, ChannelsPage.icon 1. tests/browser/channel-lifecycle.spec.mjs in Chromium: 10 passed. Not changed: the sidebar and header Channel actions menus still read permissions on each open with default routing. docs/relay-queries.md, which lists strong reads, was left alone because the relay-refusal commits own its recent edits. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mtoohey Signed-off-by: Matt Toohey --- docs/channels.md | 3 ++- .../channels/ChannelsPage.tabs.test.tsx | 19 ++++++++++++++--- .../ChannelNavigationState.test.tsx | 3 +++ .../ChannelNavigationState.tsx | 9 ++++++++ src/features/relay/channel-lifecycle.test.ts | 19 +++++++++++++++++ src/features/relay/channel-lifecycle.ts | 21 +++++++++++++++---- 6 files changed, 66 insertions(+), 8 deletions(-) diff --git a/docs/channels.md b/docs/channels.md index 7400c31ebc..85a3880569 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -611,7 +611,8 @@ confirmation has an uncertain outcome, the failure toast warns that the command have taken effect and disables blind resubmission. That toast, and the "no longer permitted" toast shown when signed channel state no longer allows the action, offer **Refresh channels**: a writer-consistent channel list read that closes the toast. -Settings rereads its actions when the refreshed role or archive state changes. +Settings' channel actions follow the refreshed channel state; confirmation rechecks +permissions against fresh relay state. Other failures offer only Dismiss. Cancellation/cache clear/session replacement fence late results but cannot retract a request already sent. Cancellation returns focus to the originating row; confirmed Delete, Leave or Hide moves an active conversation to another available destination diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx index 40b7a52c88..529f91c442 100644 --- a/src/bundled/channels/ChannelsPage.tabs.test.tsx +++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx @@ -311,11 +311,14 @@ function workspace( membership, lifecycle, handoff = false, + replica, }: { membership?: CommunityMembership; lifecycle?: (event: RelayEvent) => Promise; /** Mount the shell's channel navigation, which owns lifecycle actions. */ handoff?: boolean; + /** What reads that are not strong see; `events` is the writer. */ + replica?: RelayEvent[]; } = {}, ) { const owner = createRelaySession( @@ -324,9 +327,19 @@ function workspace( relayAuthor: authority.pubkey, media: () => undefined, query: async (filters) => - events.filter((event) => - filters.some((filter) => matchesEvent(event, filter)), - ), + replica + ? [ + ...new Set( + filters.flatMap((filter) => + (filter.consistency === "strong" ? events : replica).filter( + (event) => matchesEvent(event, filter), + ), + ), + ), + ] + : events.filter((event) => + filters.some((filter) => matchesEvent(event, filter)), + ), writer: { sign: async (template) => signed(viewer, template), publish: async () => {}, diff --git a/src/features/channel-navigation/ChannelNavigationState.test.tsx b/src/features/channel-navigation/ChannelNavigationState.test.tsx index 04d14918df..0fc0cf6536 100644 --- a/src/features/channel-navigation/ChannelNavigationState.test.tsx +++ b/src/features/channel-navigation/ChannelNavigationState.test.tsx @@ -399,10 +399,13 @@ it("offers Refresh channels when a lifecycle action failed on stale state", asyn within(toast).getByRole("button", { name: "Dismiss notification" }), refresh, ]); + expect(view.result.current.lifecycleRefreshes).toBe(0); fireEvent.click(refresh); expect(h.refreshList).toHaveBeenCalledExactlyOnceWith({ consistency: "strong", }); + // Settings reads its channel actions again on each one. + expect(view.result.current.lifecycleRefreshes).toBe(1); await waitFor(() => expect( screen.queryByRole("dialog", { name: "Archive channel failed" }), diff --git a/src/features/channel-navigation/ChannelNavigationState.tsx b/src/features/channel-navigation/ChannelNavigationState.tsx index 3a3199f56f..855884923e 100644 --- a/src/features/channel-navigation/ChannelNavigationState.tsx +++ b/src/features/channel-navigation/ChannelNavigationState.tsx @@ -84,6 +84,10 @@ type State = { | undefined; /** Undos chosen while another lifecycle action held the gate, oldest first. */ queuedUndos: readonly ChannelSummary[]; + /** Counts Refresh channels chosen from a lifecycle failure. Settings reads + * its channel actions again on each one: 39001 roles and owner-agent + * profiles can change without the roster's role hint. */ + lifecycleRefreshes: number; }; type ActivityThread = { channelId: string; @@ -418,6 +422,10 @@ export function ChannelNavigationProvider({ connection.session.channels.refreshList?.({ consistency: "strong", }); + update((previous) => ({ + ...previous, + lifecycleRefreshes: previous.lifecycleRefreshes + 1, + })); setFailure(undefined); }} > @@ -436,5 +444,6 @@ function restore(session: RelaySession, scope: string): State { preparingDm: undefined, lifecycleDialog: undefined, queuedUndos: [], + lifecycleRefreshes: 0, }; } diff --git a/src/features/relay/channel-lifecycle.test.ts b/src/features/relay/channel-lifecycle.test.ts index f0defa1885..5e9a4195f2 100644 --- a/src/features/relay/channel-lifecycle.test.ts +++ b/src/features/relay/channel-lifecycle.test.ts @@ -615,6 +615,7 @@ it("rereads authority after signing; a removed admin cannot publish", async () = const run = h.owner.capability.run("archive", id); // Stale state the viewer can refresh, and copy naming that control. await expect(run).rejects.toBeInstanceOf(ChannelLifecycleStale); + await expect(run).rejects.toMatchObject({ name: "ChannelLifecycleStale" }); await expect(run).rejects.toThrow( /^This action is no longer permitted\. Refresh channels to see what you can do now\.$/, ); @@ -1373,6 +1374,24 @@ describe("channel owner-agent Archive and Delete eligibility", () => { h.owner.dispose(); }, ); + it("a strong load reads channel state and owner profiles from the writer", async () => { + const h = ownedAgent("member"); + h.read.mockClear(); + expect( + await h.owner.capability.load(id, undefined, { consistency: "strong" }), + ).toMatchObject({ canArchive: true, canDelete: true }); + expect(h.read.mock.calls.map(([filters]) => filters)).toEqual([ + [39000, 39001, 39002].map((kind) => ({ + kinds: [kind], + consistency: "strong", + authors: [relayAuthor], + "#d": [id], + limit: 1, + })), + [{ kinds: [0], consistency: "strong", authors: [other], limit: 1 }], + ]); + h.owner.dispose(); + }); it("a member owning an owner-agent can archive and unarchive", async () => { const h = ownedAgent("member"); await h.owner.capability.run("archive", id); diff --git a/src/features/relay/channel-lifecycle.ts b/src/features/relay/channel-lifecycle.ts index e90c57e83e..708d0b2075 100644 --- a/src/features/relay/channel-lifecycle.ts +++ b/src/features/relay/channel-lifecycle.ts @@ -6,7 +6,7 @@ import { PublishRejected } from "./outbox"; import { isTransientFailure, readErrorKind } from "./errors"; import { retryRead } from "./read-retry"; import { yieldToHost } from "./yield"; -import { hasTag, newer, type RelayEvent } from "./events"; +import { hasTag, newer, type ReadFilter, type RelayEvent } from "./events"; import { openMetadata } from "./discovery"; import { channelTypeTag, @@ -23,7 +23,12 @@ import { /** The channel state the action relied on may be out of date; refreshing * channels is the recovery. */ -export class ChannelLifecycleStale extends Error {} +export class ChannelLifecycleStale extends Error { + constructor(message: string) { + super(message); + this.name = "ChannelLifecycleStale"; + } +} /** No automatic or same-dialog retry after a request may have reached the relay. */ export class ChannelLifecycleUnconfirmed extends ChannelLifecycleStale { @@ -48,6 +53,8 @@ export interface ChannelLifecycleCapability { /** The channel's relay records the caller already read, so its answer and * these rights share one snapshot; omitted, load reads them. */ records?: readonly RelayEvent[], + /** "strong" reads the writer, for a reread after a refresh. */ + options?: Pick, ): Promise; run( action: ChannelLifecycleCommand, @@ -183,9 +190,11 @@ export function createChannelLifecycle({ // the channel's state allows and false skips the optional profile reads. need: OwnedAgentAction | "any" | false = "any", records?: readonly RelayEvent[], + strong = false, ) { assertAccess(id); - const events = records ?? (await read([39000, 39001, 39002], id, signal)); + const events = + records ?? (await read([39000, 39001, 39002], id, signal, false, strong)); assertAccess(id); const settings = lifecycleSettings(events, id, viewer, relayAuthor); const metadata = lifecycleRecord(events, 39000, id, relayAuthor); @@ -226,6 +235,7 @@ export function createChannelLifecycle({ reader.read( batch.map((pubkey) => ({ kinds: [0], + ...(strong ? { consistency: "strong" as const } : {}), authors: [pubkey], limit: 1, })), @@ -369,11 +379,14 @@ export function createChannelLifecycle({ value: string, caller?: AbortSignal, records?: readonly RelayEvent[], + options?: Pick, ) { const id = lifecycleChannelId(value); + const strong = options?.consistency === "strong"; return owned({ id, caller }, (signal) => retryRead( - () => reread(signal, () => load(id, signal, "any", records)), + () => + reread(signal, () => load(id, signal, "any", records, strong)), signal, ), ); From 4183604de3dd07ddcf106fb7a8302c41b32a5e4b Mon Sep 17 00:00:00 2001 From: mtoohey Date: Thu, 5 Nov 2026 16:39:01 +0000 Subject: [PATCH 05/12] fix(relay): decide relay refusals by their reason and bound every display Follow-up to b7c2c26488 (show only the relay's own refusal words), addressing the second-pass review of 86ff1bbbf3. Relay-refusal area only; the lifecycle findings are left for a later change. - outbox: KEPT_PREFIXES moves next to NIP01_PREFIX, and transport imports both. isRelayRefusal rejects a kept prefix by checking the prefix of `reason` against KEPT_PREFIXES. It no longer checks whether `message` is prefixed. That check only worked because transport's refusalMessage returns kept-prefix text unchanged and capitalizes everything else. The only constructor that passes a `reason` is acceptPublish's 4xx branch, so the result is the same for every transport output. - outbox: new relayRefusal(error) returns the relay's words clipped to the outbox's 256-character bound, or undefined. The outbox receipt forward, the emoji directory and the auto-join switch all use it, so the rule and the bound live in one place. The emoji and auto-join toasts showed `message` unclipped before, and readApiFailure lets up to 4 KB of `{error}` text through. - docs/relay-queries.md: the emoji reason is described as native-host behavior. The browser host's broker reports a socket `forbidden:`, `blocked:` or `restricted:` refusal as 503 `sent:false`, so that host shows plain copy for those and appends only `invalid:` reasons. - docs/workflows.md: the same browser-host difference, which covers the Call Webhook owner-or-admin refusal. Both docs record it as an unresolved host difference. browser-host/relay-broker.mjs is unchanged. The fixed workflow copy in the outbox journal ("refresh the saved configuration") is still deliberately deferred. Tests: - outbox: a table checks isRelayRefusal and relayRefusal on built errors. A kept prefix on `reason` with an unprefixed message is not a refusal. A prefixed message with an `invalid:` reason is one. No reason and not-sent are not. - transport: a 1000-character `invalid:` reason through acceptPublish keeps its whole `reason`, and relayRefusal shows 256 characters. - emoji: an over-long `blocked:` refusal reads "Failed to add emoji. " plus the clipped words. - ChannelSettingsPanel: an over-long `invalid:` refusal shows the clipped words. - outbox-receipts: an over-long `forbidden:` refusal forwards the clipped words. Reverting each production piece on its own fails at least one of these: - the old message-shape rule in isRelayRefusal: 2 failures (the kept-prefix and prefixed-message rows) - emoji-directory.ts as at 86ff1bbbf3: 1 - ChannelSettingsPanel.tsx as at 86ff1bbbf3: 1 - relayRefusal without the clip: 4 (transport, outbox-receipts, emoji, ChannelSettingsPanel) - the outbox forward without the helper and unclipped: 1 The outbox-receipts case also passes on 86ff1bbbf3, which already clipped the forward. It guards that call site after the move to the helper. Checked: tsc --noEmit clean. biome check clean on the touched sources and tests. vitest, 12 files, 314 passed: transport 68, outbox 50, outbox-receipts 15, workflows capability 50, workflows session 18, workflows pagination 18, workflows protocol 4, workflows http 2, emoji 27, CustomEmojiSettings 12, ChannelSettingsPanel 32, agent-config-artifacts 18. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mtoohey Signed-off-by: Matt Toohey --- docs/relay-queries.md | 12 ++++--- docs/workflows.md | 6 +++- .../channels/ChannelSettingsPanel.test.tsx | 8 +++++ src/bundled/channels/ChannelSettingsPanel.tsx | 9 +++-- src/features/relay/emoji-directory.ts | 7 ++-- src/features/relay/emoji.test.ts | 13 ++++++- src/features/relay/outbox-receipts.test.ts | 14 ++++++++ src/features/relay/outbox.test.ts | 34 +++++++++++++++++++ src/features/relay/outbox.ts | 20 ++++++++--- src/features/relay/transport.test.ts | 9 ++++- src/features/relay/transport.ts | 9 +++-- 11 files changed, 118 insertions(+), 23 deletions(-) diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 3d38468c79..351f05dadb 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -159,10 +159,14 @@ shortcode, and republishes the whole set with a strictly newer `created_at`. Adds within a session run one at a time, so a stale read cannot drop an earlier add. The signed echo must match the template before publishing, the accepted set joins the palette immediately, and failures surface as the reference -`Failed to add emoji.` / `Timed out while adding emoji.` copy. A relay refusal -appends the relay's reason, such as `Failed to add emoji. You are banned from -this community`. The development broker admits kind 30030 only for the -canonical own-set template (`validEmojiSetTemplate`). +`Failed to add emoji.` / `Timed out while adding emoji.` copy. On the native +host, a relay refusal appends the relay's reason, clipped to 256 characters, +such as `Failed to add emoji. You are banned from this community`. The browser +host's broker reports a socket `forbidden:`, `blocked:` or `restricted:` refusal +as not sent, so that host shows `Failed to add emoji.` for those and appends +only `invalid:` reasons; this host difference is unresolved. The development +broker admits kind 30030 only for the canonical own-set template +(`validEmojiSetTemplate`). Live kind-30030 updates share the existing profile route, not another socket or subscription slot. Global establishment repairs an already requested catalog; diff --git a/docs/workflows.md b/docs/workflows.md index 612d2ca104..e57a0abbde 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -116,7 +116,11 @@ not replace the result-bearing receipt. Restored commands never run automaticall result text stays ephemeral, outside the journal. A command the relay refuses with its own reason shows that reason in this session only; the journal keeps fixed copy, because relay text can quote event content. Other failures keep the -fixed copy, and a refusal after a verified echo leaves the result unknown. +fixed copy, and a refusal after a verified echo leaves the result unknown. On the +browser host, the broker reports a socket `forbidden:`, `blocked:` or +`restricted:` refusal as not sent, so those keep the fixed copy there, including +the owner-or-admin refusal for a Call Webhook step; this host difference is +unresolved. Workflow intents cannot be replayed through generic Outbox Retry either; inspection and dismissal remain available. diff --git a/src/bundled/channels/ChannelSettingsPanel.test.tsx b/src/bundled/channels/ChannelSettingsPanel.test.tsx index e5655433c9..eaf860a22d 100644 --- a/src/bundled/channels/ChannelSettingsPanel.test.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.test.tsx @@ -534,6 +534,14 @@ it.each([ ), "Only owners and admins can change auto-join", ], + [ + "a long relay reason, clipped,", + new PublishRejected( + `Actor not authorized ${"x".repeat(1000)}`, + `invalid: actor not authorized ${"x".repeat(1000)}`, + ), + `Actor not authorized ${"x".repeat(1000)}`.slice(0, 256), + ], [ "the default reason", new PublishRejected("rejected"), diff --git a/src/bundled/channels/ChannelSettingsPanel.tsx b/src/bundled/channels/ChannelSettingsPanel.tsx index e933e58089..562a9134f1 100644 --- a/src/bundled/channels/ChannelSettingsPanel.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.tsx @@ -15,9 +15,9 @@ import { type ChannelLifecycleCapability, } from "../../features/relay/channel-lifecycle"; import { - isRelayRefusal, PublishNotSent, PublishRejected, + relayRefusal, } from "../../features/relay/outbox"; import { managesCommunity } from "../../features/communities/roster"; import type { CommunityMembership } from "../moderation/membership"; @@ -327,13 +327,12 @@ function AutoJoinSetting({ : // A rate limit or a write that never left this device, not a refusal. error instanceof PublishNotSent ? "Not sent. Try again." - : isRelayRefusal(error) - ? error.message - : error instanceof PublishRejected + : (relayRefusal(error) ?? + (error instanceof PublishRejected ? "Only community owners and admins can change this." : error instanceof Error ? error.message - : String(error), + : String(error))), }); } finally { operation.current = undefined; diff --git a/src/features/relay/emoji-directory.ts b/src/features/relay/emoji-directory.ts index f69caf1147..ec8a46af93 100644 --- a/src/features/relay/emoji-directory.ts +++ b/src/features/relay/emoji-directory.ts @@ -12,7 +12,7 @@ import { type CustomEmoji, } from "./emoji"; import { eventDto, newer, type RelayEvent } from "./events"; -import { isRelayRefusal } from "./outbox"; +import { relayRefusal } from "./outbox"; import type { Priority, RelayReader } from "./reader"; import type { RelayWriter } from "./transport"; @@ -241,13 +241,14 @@ export function createEmojiDirectory( accept([event]); return shortcode; } catch (error) { + const refusal = relayRefusal(error); throw new Error( timeout.aborted ? url === undefined ? "Timed out while removing emoji." : "Timed out while adding emoji." - : isRelayRefusal(error) - ? `${failure} ${error.message}` + : refusal + ? `${failure} ${refusal}` : failure, ); } diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index fd6b13b790..c0f8169616 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -739,6 +739,17 @@ it("rejects invalid names before any read and maps publish failure to reference /^Failed to add emoji\. You are banned$/, ); expect(h.emoji.snapshot().mine).toEqual([]); + // A long reason is clipped as the outbox clips it. + const words = `You are banned ${"x".repeat(1000)}`; + h.publish.mockRejectedValueOnce( + new PublishRejected(words, `blocked: ${words}`), + ); + const long = h.emoji.add?.("party", url); + await vi.waitFor(() => expect(h.wire.pending).toHaveLength(1)); + h.ownRead().respond([]); + await expect(long).rejects.toThrow( + new Error(`Failed to add emoji. ${words.slice(0, 256)}`), + ); // A write that never reached the relay, or a refusal without the relay's // own words, has no reason to show. for (const error of [ @@ -759,7 +770,7 @@ it("rejects invalid names before any read and maps publish failure to reference await vi.waitFor(() => expect(h.wire.pending).toHaveLength(1)); h.ownRead().fail(new Error("offline")); await expect(failedRead).rejects.toThrow(/^Failed to add emoji\.$/); - expect(h.publish).toHaveBeenCalledTimes(4); + expect(h.publish).toHaveBeenCalledTimes(5); }); it("removes only from the fresh own set and preserves another member's colliding emoji", async () => { const h = authoring(); diff --git a/src/features/relay/outbox-receipts.test.ts b/src/features/relay/outbox-receipts.test.ts index f91bf6d48b..9bd4dabf25 100644 --- a/src/features/relay/outbox-receipts.test.ts +++ b/src/features/relay/outbox-receipts.test.ts @@ -252,6 +252,20 @@ it.each([30620, 46020, 5])( }, ); +it("forwards a long relay refusal clipped for display", async () => { + const h = setup(); + h.send(); + await flush(); + const words = `Disabled ${"x".repeat(1000)}`; + h.reject(new PublishRejected(words, `forbidden: ${words}`)); + await flush(); + expect(h.onReceipt).toHaveBeenCalledWith( + expect.anything(), + undefined, + words.slice(0, 256), + ); +}); + it.each([30620, 46020, 5])( "unknown kind %s is inspect-only while dismissal remains available", async (kind) => { diff --git a/src/features/relay/outbox.test.ts b/src/features/relay/outbox.test.ts index f53852ef2e..26c9b6d1e3 100644 --- a/src/features/relay/outbox.test.ts +++ b/src/features/relay/outbox.test.ts @@ -4,6 +4,8 @@ import { PublishNotSent, PublishRejected, createOutbox, + isRelayRefusal, + relayRefusal, type OutboxStorage, type OutgoingEvent, } from "./outbox"; @@ -1407,3 +1409,35 @@ it("admits a live Canvas up to its own limits, persists it, and retries the same expect(second.sign).not.toHaveBeenCalled(); expect(second.publications[0]?.event).toEqual(event); }); + +it.each([ + [ + "the relay's own words", + true, + new PublishRejected("Bad tag", "invalid: bad tag"), + ], + // The reason's prefix decides, whatever the transport made of the message. + [ + "a kept prefix", + false, + new PublishRejected("Sign in", "auth-required: sign in"), + ], + [ + "a prefixed message", + true, + new PublishRejected("invalid: bad tag", "invalid: bad tag"), + ], + [ + "no relay reason", + false, + new PublishRejected("Relay rejected the message (403)"), + ], + [ + "a write that was not sent", + false, + new PublishNotSent("Bad tag", "invalid: bad tag"), + ], +])("%s is a relay refusal: %s", (_, refused, error) => { + expect(isRelayRefusal(error)).toBe(refused); + expect(relayRefusal(error)).toBe(refused ? error.message : undefined); +}); diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts index ac4873fa77..520862d426 100644 --- a/src/features/relay/outbox.ts +++ b/src/features/relay/outbox.ts @@ -127,6 +127,12 @@ export class PublishNotSent extends PublishRejected {} /** NIP-01 machine-readable prefixes: the shape of a relay's own refusal. */ export const NIP01_PREFIX = /^(invalid|blocked|restricted|forbidden|error|pow|duplicate|rate-limited|conflict|auth-required):\s*/; +/** Prefixes writers match on, so the text is shown as sent. */ +export const KEPT_PREFIXES = new Set([ + "rate-limited", + "conflict", + "auth-required", +]); /** Whether `message` is the relay's own words for a refusal: the write was * sent and its `reason` is relay-shaped text. Status summaries, client-side @@ -138,11 +144,17 @@ export function isRelayRefusal(error: unknown): error is PublishRejected { const prefix = NIP01_PREFIX.exec(error.reason ?? ""); return ( !!prefix && - !!error.reason?.slice(prefix[0].length).trim() && - !NIP01_PREFIX.test(error.message) + !KEPT_PREFIXES.has(prefix[1] ?? "") && + !!error.reason?.slice(prefix[0].length).trim() ); } +/** The relay's refusal words to show, clipped like stored diagnostics, or + * undefined when `error` is not the relay's own refusal. */ +export function relayRefusal(error: unknown): string | undefined { + return isRelayRefusal(error) ? clip(error.message) : undefined; +} + export { browserOutboxStorage } from "./outbox-storage"; const abortError = () => new DOMException("Relay session closed", "AbortError"); const MAX_PENDING = 256; @@ -685,9 +697,7 @@ export function createOutbox( onReceipt( latest.signed ?? latest.event, undefined, - delivery === "failed" && isRelayRefusal(error) - ? clip(error.message) - : undefined, + delivery === "failed" ? relayRefusal(error) : undefined, ); if (latest && delivery) saveStatus({ diff --git a/src/features/relay/transport.test.ts b/src/features/relay/transport.test.ts index a438c6547e..06cb7f27ca 100644 --- a/src/features/relay/transport.test.ts +++ b/src/features/relay/transport.test.ts @@ -9,7 +9,7 @@ import { ReadStateTimestampRejected, READ_STATE_TIMESTAMP_REFUSAL, } from "./read-state-host"; -import { isRelayRefusal, PublishRejected } from "./outbox"; +import { isRelayRefusal, PublishRejected, relayRefusal } from "./outbox"; import { hostSigner, keypair, signed } from "./testing"; const key = keypair(); afterEach(() => vi.unstubAllGlobals()); @@ -286,6 +286,13 @@ it.each([ expect(isRelayRefusal(await refusal(403, { error }))).toBe(words); }, ); +it("keeps a long refusal's whole reason but clips its display words", async () => { + // Admission keeps up to 4 KB of `{error}` text, so the bound is the display's. + const error = `invalid: ${"x".repeat(1000)}`; + const result = await refusal(400, { error }); + expect(result).toMatchObject({ reason: error }); + expect(relayRefusal(result)).toBe(`X${"x".repeat(255)}`); +}); it("keeps the conflict refusal and the raw reason for classifying writers", async () => { const conflict = await refusal(409, { error: "invalid: whatever" }); expect(conflict).toHaveProperty( diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index f780d60b10..fb884f724d 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -67,7 +67,12 @@ import { type LiveSubscription, } from "./live"; import { subscribeBrokerTraffic } from "./broker-live"; -import { NIP01_PREFIX, PublishNotSent, PublishRejected } from "./outbox"; +import { + KEPT_PREFIXES, + NIP01_PREFIX, + PublishNotSent, + PublishRejected, +} from "./outbox"; import { httpReadError, ReadError } from "./errors"; import { ARTIFACT_KIND } from "../agents/agent-config-artifacts"; import type { EventTemplate, VerifiedEvent } from "nostr-tools"; @@ -1613,8 +1618,6 @@ export async function acceptReadStatePublish(response: Response, id: string) { } const REFUSAL_STATUSES = [400, 401, 403, 404, 413, 422]; -/** Prefixes writers match on, so the text is shown as sent. */ -const KEPT_PREFIXES = new Set(["rate-limited", "conflict", "auth-required"]); /** The relay's own `{error}` text for a refused write. Admission otherwise * reduces every upstream body to a status summary, so a signed publish asks * it to keep this one, bounded by `readApiFailure`. A gateway or VPN edge From 61e632382429dfec37add1124a47d7265d8eb3fa Mon Sep 17 00:00:00 2001 From: mtoohey Date: Thu, 5 Nov 2026 16:58:23 +0000 Subject: [PATCH 06/12] fix(channels): read lifecycle preflights and Hide recovery from the writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 86ff1bbbf3 (reread Settings' actions from the writer on Refresh channels), addressing the lifecycle findings of its second-pass review. The relay-refusal logic is unchanged. - channel-lifecycle: run()'s permission preflight for Archive, Unarchive, Delete, Leave and Hide now calls load() with strong reads, both before signing and before publishing. A "no longer permitted… Refresh channels" verdict came from a replica before, so after a refresh Settings (reading the writer) could offer Archive while the preflight, reading a replica that predated a re-promotion or a new owner-agent attestation, refused it again. The 39000/39001/39002 read and any owner-agent kind-0 reads go to the writer. This matches the member-administration and channel-write preflights in relay-queries.md. It costs two writer-routed loads per action. Join and auto-join checks are not permission preflights and keep ordinary routing. - channel-lifecycle: refreshVisibility() takes optional `{ consistency }`. A strong refresh reads 30622 from the writer. It does not join a replica-backed refresh in flight: it aborts that one and starts its own. An ordinary refresh started meanwhile joins the strong one. Ordinary refreshes are otherwise unchanged. - ChannelNavigationState: the failure toast records the failed action. After a failed Hide, Refresh channels also calls refreshVisibility(undefined, { consistency: "strong" }). An unconfirmed Hide is a ChannelLifecycleStale, so its toast offered Refresh channels, but refreshList does not read DM visibility. The button now checks whether the DM was actually hidden. Hide's copy is unchanged. - docs/relay-queries.md: the Read-your-writes list adds lifecycle permission preflights, the toast's writer-backed discovery pass, Settings' writer-routed lookups after a session's first refresh and the Hide visibility reread. The "stay replica-eligible" sentence now names the Channel actions menus and ordinary DM visibility refreshes. - docs/channels.md: the recovery paragraph describes the writer-read preflight and the Hide visibility reread. Tests: - channel-lifecycle: Archive runs and publishes when the replica predates the grant and the writer has it, for a direct role and for owner-agent evidence. An ordinary load still denies Archive. A strong visibility refresh aborts a replica-backed one in flight, reads 30622 with `consistency: "strong"`, is joined by a later ordinary request, and the replica's late empty answer does not unhide the DM. - ChannelNavigationState: after an unconfirmed Hide, Refresh channels calls the strong refreshList and the strong refreshVisibility. The archive case does not call refreshVisibility. lifecycleRefreshes goes back to 0 after a session replacement and after a scope-only community switch. - ChannelsPage.tabs, real session: the DM's hide reaches the writer but publish fails, so it is unconfirmed. The replica lacks the 30622. Refresh channels makes the session's visibility snapshot hide the DM. - Existing tests that assumed replica-routed preflights now expect strong preflight reads: confirms-while-replicas-lag (4 cases), exact coordinates and owner-agent profile reads (2). Confirmation-read holds now also wait for publication. The 39001 ChannelsPage test no longer edits the replica: only the writer has the demotion, so the preflight produces the toast by reading the writer. Without the change: with both production files at 0e74fa3b51, 13 of the 259 tests in channel-lifecycle, ChannelNavigationState and ChannelsPage.tabs fail. Reverting each piece on its own also fails: - preflight without strong: 10 (both new authorize cases, the 39001 ChannelsPage test, 4 confirms cases, exact coordinates, 2 owner-agent) - strong not passed to the visibility read: 2 (the strong refresh test, the ChannelsPage Hide test) - strong joining a replica refresh in flight: 1 - the replica refresh not aborted: 1 - the toast without the Hide reread: 2 (the ChannelNavigationState and ChannelsPage Hide tests) - the scope check dropped, or the counter carried across session replacement: 1 each The reset test passes at 0e74fa3b51 too: the reset already worked, and this test guards it. Checked: tsc --noEmit clean. biome check clean on the 5 touched source and test files. git diff --check clean. vitest, 10 files, 414 passed: channel-lifecycle 217, ChannelLifecycleActions 22, ChannelLifecycleMenu 36, ChannelNavigationState 13, ChannelSettingsPanel 32, ChannelsPage 10, ChannelsPage.tabs 29, ChannelsPage.solo 50, ChannelsPage.conversion 4, ChannelsPage.icon 1. The other refreshVisibility consumers (ChannelInventory, SearchResults, NewMessage.recovery, reauthorization): 7 files, 379 passed. tests/browser/channel-lifecycle.spec.mjs in Chromium (--no-deps): 10 passed. Without --no-deps the run stopped at an unrelated webkit-measurements failure in scroll.spec.mjs (cursor paging), and the lifecycle spec did not run. Not changed: the outbox's fixed workflow copy ("refresh the saved configuration") is still deferred. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mtoohey Signed-off-by: Matt Toohey --- docs/channels.md | 7 +- docs/relay-queries.md | 22 +++-- .../channels/ChannelsPage.tabs.test.tsx | 61 ++++++++++++ .../ChannelNavigationState.test.tsx | 76 ++++++++++++++- .../ChannelNavigationState.tsx | 8 ++ src/features/relay/channel-lifecycle.test.ts | 95 +++++++++++++++++-- src/features/relay/channel-lifecycle.ts | 28 +++++- 7 files changed, 276 insertions(+), 21 deletions(-) diff --git a/docs/channels.md b/docs/channels.md index 85a3880569..28df1e4d13 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -611,8 +611,11 @@ confirmation has an uncertain outcome, the failure toast warns that the command have taken effect and disables blind resubmission. That toast, and the "no longer permitted" toast shown when signed channel state no longer allows the action, offer **Refresh channels**: a writer-consistent channel list read that closes the toast. -Settings' channel actions follow the refreshed channel state; confirmation rechecks -permissions against fresh relay state. +The permission check behind "no longer permitted", before signing and again before +publishing, reads the writer, so a lagging replica cannot refuse an action Settings +offers after a refresh. Settings' channel actions follow the refreshed channel state. +After a failed Hide, Refresh channels also rereads DM visibility from the writer: +whether a conversation is hidden is not part of the channel list. Other failures offer only Dismiss. Cancellation/cache clear/session replacement fence late results but cannot retract a request already sent. Cancellation returns focus to the originating row; confirmed Delete, Leave or Hide moves an active conversation to another available destination diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 351f05dadb..9990ba528f 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -70,11 +70,11 @@ must not let them manufacture relay-authored authority. `fresh: true` prevents sharing an older in-flight request; it does not select the writer. Channel creation/admission and recovery, DM opening, channel edits, -member administration, lifecycle confirmations, and mention preflights request -`consistency: "strong"` on their authoritative filters. Signed evidence remains -required; an accepted command is not membership, and writer routing does not -wait for asynchronous relay side effects. Existing cancellation and bounded -confirmation retries are unchanged. +member administration, lifecycle permission preflights and confirmations, and +mention preflights request `consistency: "strong"` on their authoritative +filters. Signed evidence remains required; an accepted command is not +membership, and writer routing does not wait for asynchronous relay side +effects. Existing cancellation and bounded confirmation retries are unchanged. Channel discovery accepts an explicit consistency option for post-write exact reads and the full-roster fallback. A queued writer-backed refresh survives an @@ -87,8 +87,16 @@ confirmations queues a writer-backed pass to settle those grants. If list failur disconnect or cache clear retires queued or in-flight hint confirmations, the store retains their writer requirement for the next deliberate refresh, Retry or establishment, without starting an automatic recovery pass or bypassing cooldown. -Ordinary startup, browsing, reconnect, and DM visibility refresh stay replica- -eligible. The details editor and member-administration capability use writer-backed +A lifecycle failure toast's Refresh channels requests a writer-backed discovery +pass. From a session's first such refresh, Channel settings reads its lifecycle +actions (`39000`/`39001`/`39002` and owner-agent kind-0 profiles) from the writer, +as the Archive/Unarchive/Delete/Leave/Hide preflight always does. After a failed +Hide, the same control also rereads DM visibility (`30622`) from the writer; that +read never joins a replica-backed visibility refresh in flight, though an +ordinary refresh started during it joins it. Ordinary startup, browsing, +reconnect, the row and header Channel actions menus, and ordinary DM visibility +refreshes stay replica-eligible. +The details editor and member-administration capability use writer-backed state for their shared load/preflight/confirmation reads; the member dialog's separate display-roster load remains replica-eligible. Channel-write membership preflights (including session sends and canvas saves) also use the writer, so a diff --git a/src/bundled/channels/ChannelsPage.tabs.test.tsx b/src/bundled/channels/ChannelsPage.tabs.test.tsx index 529f91c442..d9658b505a 100644 --- a/src/bundled/channels/ChannelsPage.tabs.test.tsx +++ b/src/bundled/channels/ChannelsPage.tabs.test.tsx @@ -575,6 +575,67 @@ it("a refreshed roster that demotes the viewer retires Archive and Delete in Cha expect(publish).not.toHaveBeenCalled(); }); +it("Refresh channels after an unconfirmed Hide rereads DM visibility from the writer", async () => { + const viewer = keypair(), + authority = keypair(), + alice = keypair(); + const id = "22222222-2222-4222-8222-222222222222"; + const events = [ + signed(authority, { + kind: 39000, + content: "", + tags: [["d", id], ["t", "dm"], ["hidden"]], + }), + signed(authority, { kind: 39001, content: "", tags: [["d", id]] }), + roster(authority, id, [viewer.pubkey, alice.pubkey]), + ]; + const replica = [...events]; + // The relay hides the DM, but its reply is lost: the outcome is uncertain. + const publish = vi.fn(async () => { + events.push( + signed(authority, { + kind: 30622, + content: "", + tags: [ + ["d", viewer.pubkey], + ["p", viewer.pubkey], + ["h", id], + ], + }), + ); + throw new Error("Relay connection lost"); + }); + const { owner } = workspace(events, viewer, authority, { + lifecycle: publish, + handoff: true, + replica, + }); + await screen.findByRole("textbox", { name: /^Message / }); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Channel actions" })); + await user.click( + await screen.findByRole("menuitem", { name: "Hide conversation" }), + ); + const confirm = await screen.findByRole("dialog", { + name: /^Hide conversation: /, + }); + await user.click( + within(confirm).getByRole("button", { name: "Hide conversation" }), + ); + const toast = await screen.findByRole("dialog", { + name: "Hide conversation failed", + }); + expect(owner.session.channelLifecycle.snapshot().hidden).toEqual([]); + fireEvent.click( + within(toast).getByRole("button", { name: "Refresh channels" }), + ); + // Only the writer has the hide; the replica still predates it. + await vi.waitFor(() => + expect(owner.session.channelLifecycle.snapshot().hidden).toEqual([id]), + ); + expect(publish).toHaveBeenCalledOnce(); +}); + it("selects a default channel the relay adds after the page opened", async () => { const events: RelayEvent[] = []; const { owner, viewer, authority } = workspace(events); diff --git a/src/features/channel-navigation/ChannelNavigationState.test.tsx b/src/features/channel-navigation/ChannelNavigationState.test.tsx index 0fc0cf6536..84374ad7b8 100644 --- a/src/features/channel-navigation/ChannelNavigationState.test.tsx +++ b/src/features/channel-navigation/ChannelNavigationState.test.tsx @@ -15,7 +15,10 @@ import { afterEach, expect, it, vi } from "vitest"; import { ToastProvider } from "../../shared/design-system/ui/Toast"; import type { RelayData, RelaySnapshot } from "../relay/service"; import type { RelaySession } from "../relay/session"; -import { ChannelLifecycleStale } from "../relay/channel-lifecycle"; +import { + ChannelLifecycleStale, + ChannelLifecycleUnconfirmed, +} from "../relay/channel-lifecycle"; import { PanelWorkspace } from "../panels/PanelWorkspace"; import { ChannelNavigationProvider, @@ -36,6 +39,7 @@ function fixture( ) { let ids = ["existing"]; const refreshList = vi.fn(); + const refreshVisibility = vi.fn(async () => {}); const session = () => ({ channels: { @@ -43,7 +47,7 @@ function fixture( refreshList, }, sidebarPreferences: { setMute: async () => [] }, - channelLifecycle: { run }, + channelLifecycle: { run, refreshVisibility }, }) as unknown as RelaySession; let snapshot: RelaySnapshot = { status: "ready", @@ -65,6 +69,7 @@ function fixture( return { relay, refreshList, + refreshVisibility, roster: (next: string[]) => { ids = next; }, @@ -76,6 +81,10 @@ function fixture( }; for (const listener of listeners) listener(); }, + switchScope(scope: string) { + snapshot = { ...snapshot, scope }; + for (const listener of listeners) listener(); + }, }; } function mount(relay: RelayData, toasts = false) { @@ -406,6 +415,7 @@ it("offers Refresh channels when a lifecycle action failed on stale state", asyn }); // Settings reads its channel actions again on each one. expect(view.result.current.lifecycleRefreshes).toBe(1); + expect(h.refreshVisibility).not.toHaveBeenCalled(); await waitFor(() => expect( screen.queryByRole("dialog", { name: "Archive channel failed" }), @@ -413,6 +423,68 @@ it("offers Refresh channels when a lifecycle action failed on stale state", asyn ); }); +it("also rereads DM visibility from the writer after an unconfirmed Hide", async () => { + const unconfirmed = new ChannelLifecycleUnconfirmed( + new Error("Relay connection lost"), + ); + const h = fixture(async () => { + throw unconfirmed; + }); + const view = mount(h.relay, true); + act(() => + view.result.current.openLifecycle({ id: "dm", name: "Alice" }, "hide"), + ); + fireEvent.click( + await screen.findByRole("button", { name: "Hide conversation" }), + ); + const toast = await screen.findByRole("dialog", { + name: "Hide conversation failed", + }); + expect(toast).toHaveAccessibleDescription(unconfirmed.message); + fireEvent.click( + within(toast).getByRole("button", { name: "Refresh channels" }), + ); + expect(h.refreshList).toHaveBeenCalledExactlyOnceWith({ + consistency: "strong", + }); + // Whether the DM was hidden is in its visibility, not the channel list. + expect(h.refreshVisibility).toHaveBeenCalledExactlyOnceWith(undefined, { + consistency: "strong", + }); +}); + +it("resets lifecycle refreshes on session replacement and community switch", async () => { + const h = fixture(async () => { + throw new ChannelLifecycleStale("This action is no longer permitted."); + }); + const view = mount(h.relay, true); + const refresh = async () => { + act(() => + view.result.current.openLifecycle( + { id: "channel", name: "Channel" }, + "archive", + ), + ); + fireEvent.click( + await screen.findByRole("button", { name: "Archive channel" }), + ); + const toast = await screen.findByRole("dialog", { + name: "Archive channel failed", + }); + fireEvent.click( + within(toast).getByRole("button", { name: "Refresh channels" }), + ); + }; + await refresh(); + expect(view.result.current.lifecycleRefreshes).toBe(1); + act(() => h.replace()); + expect(view.result.current.lifecycleRefreshes).toBe(0); + await refresh(); + expect(view.result.current.lifecycleRefreshes).toBe(1); + act(() => h.switchScope("community:other")); + expect(view.result.current.lifecycleRefreshes).toBe(0); +}); + it("keeps other lifecycle failures' toasts to Dismiss", async () => { const h = fixture(async () => { throw new Error("Archive check unavailable. Try again."); diff --git a/src/features/channel-navigation/ChannelNavigationState.tsx b/src/features/channel-navigation/ChannelNavigationState.tsx index 855884923e..29c3c1da6a 100644 --- a/src/features/channel-navigation/ChannelNavigationState.tsx +++ b/src/features/channel-navigation/ChannelNavigationState.tsx @@ -161,6 +161,7 @@ export function ChannelNavigationProvider({ title: string; message: string; refresh: boolean; + action: ChannelLifecycleAction | undefined; }>(); const activityThread = useRef(undefined); const activityAgent = useRef(undefined); @@ -402,6 +403,7 @@ export function ChannelNavigationProvider({ title, message, refresh, + action: state.lifecycleDialog?.action, }); value.closeLifecycle(); }} @@ -422,6 +424,12 @@ export function ChannelNavigationProvider({ connection.session.channels.refreshList?.({ consistency: "strong", }); + // Hide's effect is DM visibility, which the list does not read. + if (failure.action === "hide") + void connection.session.channelLifecycle.refreshVisibility( + undefined, + { consistency: "strong" }, + ); update((previous) => ({ ...previous, lifecycleRefreshes: previous.lifecycleRefreshes + 1, diff --git a/src/features/relay/channel-lifecycle.test.ts b/src/features/relay/channel-lifecycle.test.ts index 5e9a4195f2..e69d1ba095 100644 --- a/src/features/relay/channel-lifecycle.test.ts +++ b/src/features/relay/channel-lifecycle.test.ts @@ -286,6 +286,41 @@ describe("bounded visibility recovery", () => { h.owner.dispose(); }); + it("a strong refresh reads the writer instead of joining a replica-backed one", async () => { + const h = harness("owner", "dm"); + h.setVisible([ + h.record(30622, [ + ["d", viewer], + ["p", viewer], + ["h", id], + ]), + ]); + // The replica-backed read predates the hide. + const replica = deferred(); + h.read.mockImplementationOnce(() => replica.promise); + const ordinary = h.owner.capability.refreshVisibility(); + await vi.advanceTimersByTimeAsync(0); + const strong = h.owner.capability.refreshVisibility(undefined, { + consistency: "strong", + }); + expect(strong).not.toBe(ordinary); + expect(h.read.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + // A later ordinary request joins the writer-backed refresh. + expect(h.owner.capability.refreshVisibility()).toBe(strong); + await strong; + expect(h.read.mock.calls.map(([filters]) => filters)).toEqual([ + [expect.not.objectContaining({ consistency: expect.anything() })], + [expect.objectContaining({ kinds: [30622], consistency: "strong" })], + ]); + replica.resolve([]); + await ordinary; + expect(h.owner.capability.snapshot()).toEqual({ + status: "ready", + hidden: [id], + }); + h.owner.dispose(); + }); + it.each(["caller", "cancel", "clear", "dispose"] as const)( "%s cancels backoff without a terminal error or another read", async (action) => { @@ -373,9 +408,9 @@ it.each(["archive", "delete", "leave", "hide"] as const)( await h.owner.capability.refreshVisibility(); expect(h.read.mock.calls[0]?.[0][0]).not.toHaveProperty("consistency"); await h.owner.capability.run(action, id); + // The permission preflight, before signing and before publishing. for (const [filters] of h.read.mock.calls.slice(1, 3)) - for (const filter of filters) - expect(filter).not.toHaveProperty("consistency"); + for (const filter of filters) expect(filter.consistency).toBe("strong"); expect(h.publish).toHaveBeenCalledOnce(); expect(h.read.mock.calls.at(-1)?.[0][0]?.consistency).toBe("strong"); if (action === "hide") @@ -622,6 +657,43 @@ it("rereads authority after signing; a removed admin cannot publish", async () = expect(h.publish).not.toHaveBeenCalled(); h.owner.dispose(); }); +it.each(["a direct role", "owner-agent evidence"] as const)( + "authorizes from the writer when a replica predates %s", + async (grant) => { + const h = + grant === "a direct role" ? harness("admin") : ownedAgent("member"); + const [metadata, , members] = h.getEvents(); + assert(metadata && members); + const replica = + grant === "a direct role" + ? [metadata, h.roles("member"), members] + : h.getEvents().filter((event) => event.kind !== 0); + const currentRead = h.read.getMockImplementation(); + assert(currentRead); + h.read.mockImplementation(async (filters, options) => + filters.every((filter) => filter.consistency === "strong") + ? currentRead(filters, options) + : replica.filter((event) => + filters.some( + (filter) => + filter.kinds?.includes(event.kind) && + filter.authors?.includes(event.pubkey), + ), + ), + ); + try { + // Ordinary reads, as Settings makes before a refresh, deny Archive. + expect(await h.owner.capability.load(id)).toMatchObject({ + canArchive: false, + }); + await h.owner.capability.run("archive", id); + expect(h.publish).toHaveBeenCalledOnce(); + expect(h.acceptDiscovery).toHaveBeenCalledOnce(); + } finally { + h.owner.dispose(); + } + }, +); it("fences access loss during signing and rejects a changed timestamp", async () => { const h = harness(); h.sign.mockImplementationOnce(async (event) => { @@ -1163,7 +1235,12 @@ it.each( assert(currentRead); let held = false; h.read.mockImplementation(async (filters, options) => { - if (held || !filters.every((filter) => filter.consistency === "strong")) + // The preflight also reads the writer; confirmation follows publication. + if ( + held || + !h.publish.mock.calls.length || + !filters.every((filter) => filter.consistency === "strong") + ) return currentRead(filters, options); held = true; started.resolve(); @@ -1225,7 +1302,10 @@ it.each(["after a macrotask", "without a macrotask"] as const)( let retired = 0; let unyielded = 0; h.read.mockImplementation(async (filters, options) => { - if (!filters.every((filter) => filter.consistency === "strong")) + if ( + !h.publish.mock.calls.length || + !filters.every((filter) => filter.consistency === "strong") + ) return currentRead(filters, options); retired++; // A reread that never yields starves the clock; fail it rather than @@ -1268,11 +1348,12 @@ it.each(["after a macrotask", "without a macrotask"] as const)( } }, ); -it("reads exact relay-owned coordinates fresh both before sign and before publish", async () => { +it("reads exact relay-owned coordinates fresh from the writer both before sign and before publish", async () => { const h = harness(); await h.owner.capability.run("delete", id); const expected = [39000, 39001, 39002].map((kind) => ({ kinds: [kind], + consistency: "strong", authors: [relayAuthor], "#d": [id], limit: 1, @@ -1367,7 +1448,9 @@ describe("channel owner-agent Archive and Delete eligibility", () => { ); expect(ownershipReads).toHaveLength(2); for (const [filters, options] of ownershipReads) { - expect(filters).toEqual([{ kinds: [0], authors: [other], limit: 1 }]); + expect(filters).toEqual([ + { kinds: [0], consistency: "strong", authors: [other], limit: 1 }, + ]); expect(options).toMatchObject({ fresh: true, priority: "foreground" }); } expect(h.removed).toHaveBeenCalledWith(id); diff --git a/src/features/relay/channel-lifecycle.ts b/src/features/relay/channel-lifecycle.ts index 708d0b2075..9ab3611efc 100644 --- a/src/features/relay/channel-lifecycle.ts +++ b/src/features/relay/channel-lifecycle.ts @@ -65,7 +65,11 @@ export interface ChannelLifecycleCapability { ): Promise; snapshot(): DmVisibility; subscribe(listener: () => void): () => void; - refreshVisibility(signal?: AbortSignal): Promise; + refreshVisibility( + signal?: AbortSignal, + /** "strong" reads the writer and does not join a replica-backed refresh. */ + options?: Pick, + ): Promise; observeRemoval(listener: (channelId: string) => void): () => void; } @@ -105,6 +109,7 @@ export function createChannelLifecycle({ let refresh: Promise | undefined; let refreshSignal: AbortSignal | undefined; let refreshController: AbortController | undefined; + let refreshStrong = false; const available = !!reader && !!writer; const emit = (next: DmVisibility) => { snapshot = Object.freeze(next); @@ -327,9 +332,17 @@ export function createChannelLifecycle({ listeners.delete(listener); }; }, - refreshVisibility(caller?: AbortSignal) { + refreshVisibility( + caller?: AbortSignal, + options?: Pick, + ) { if (!reader || closed || caller?.aborted) return Promise.resolve(); - if (refresh && !refreshSignal?.aborted) return refresh; + const strong = options?.consistency === "strong"; + // A replica-backed refresh can predate the write a strong one checks; + // retire it instead of joining it. + if (refresh && !refreshSignal?.aborted && (refreshStrong || !strong)) + return refresh; + refreshController?.abort(); const started = epoch; const controller = new AbortController(); refreshController = controller; @@ -338,13 +351,16 @@ export function createChannelLifecycle({ ...(caller ? [caller] : []), ]); refreshSignal = signal; + refreshStrong = strong; emit({ status: "loading", hidden: snapshot.hidden }); // Exhausted errors omit `transient`, so session recovery cannot restart // them. A read retired by another channel's access change (`cancelled`) // retries within the same budget. const operation = retryRead( async () => { - await owned({ caller: signal }, readVisibility); + await owned({ caller: signal }, (signal) => + readVisibility(signal, strong), + ); }, signal, isTransientFailure, @@ -450,6 +466,8 @@ export function createChannelLifecycle({ if (action === "join") return joined(); if (action === "auto-join-on" || action === "auto-join-off") return flaggable(); + // Read the writer: a refusal that sends the viewer to Refresh + // channels must not come from a replica older than that refresh. const settings = await load( id, signal, @@ -458,6 +476,8 @@ export function createChannelLifecycle({ action === "delete" ? action : false, + undefined, + true, ); if (settings.deleteUnavailable) throw new Error( From f3304fe57b60e1a4535b79493c469e11a5ead4a9 Mon Sep 17 00:00:00 2001 From: mtoohey Date: Thu, 5 Nov 2026 17:06:17 +0000 Subject: [PATCH 07/12] docs(channels): scope Settings' writer reads after Refresh channels to the community Follow-up to f9b1adb0b8, addressing its review's doc nit. Docs only. channels.md and relay-queries.md said that after a lifecycle failure toast's Refresh channels, Channel settings reads its lifecycle actions from the writer for the rest of the session. The counter behind that, lifecycleRefreshes, lives in the channel navigation state. restore() rebuilds that state when the scope changes, and a ChannelNavigationState test pins the reset on a community switch. So after a refresh, switching to another community and back puts Settings on replica reads again. Behavior is fine, because the Archive/Unarchive/Delete/Leave/Hide preflight always reads the writer. Only the wording overstated it. - docs/channels.md: "for the rest of the session in that community (switching communities resets it)". - docs/relay-queries.md: "From the first such refresh in a community until the community changes". No comment in src/ repeats the claim. The lifecycleRefreshes and `refreshes` prop comments say only that each refresh rereads the actions and that later reads come from the writer, which still holds. Checked: git diff --check clean. The repo has no markdown formatter: Biome ignores .md, and prettier and markdownlint are not installed. Co-Authored-By: Claude Opus 5.5 Signed-off-by: mtoohey Signed-off-by: Matt Toohey --- docs/relay-queries.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 9990ba528f..6246005e1d 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -88,9 +88,8 @@ disconnect or cache clear retires queued or in-flight hint confirmations, the st retains their writer requirement for the next deliberate refresh, Retry or establishment, without starting an automatic recovery pass or bypassing cooldown. A lifecycle failure toast's Refresh channels requests a writer-backed discovery -pass. From a session's first such refresh, Channel settings reads its lifecycle -actions (`39000`/`39001`/`39002` and owner-agent kind-0 profiles) from the writer, -as the Archive/Unarchive/Delete/Leave/Hide preflight always does. After a failed +pass. The Archive/Unarchive/Delete/Leave/Hide preflight always reads +`39000`/`39001`/`39002` and owner-agent kind-0 profiles from the writer. After a failed Hide, the same control also rereads DM visibility (`30622`) from the writer; that read never joins a replica-backed visibility refresh in flight, though an ordinary refresh started during it joins it. Ordinary startup, browsing, From 5825bad59ff66b0b0d0efcc4050d9e4db0e2202c Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 6 Nov 2026 23:42:26 +1100 Subject: [PATCH 08/12] fix(channels): show the relay's sign-in refusal on the starter channel switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses kalvinnchau's review comment on PR 2778 (PRRT_kwDOUVULN860dV2P, against 24cb6709e; still present at 5529a67db). When an owner or admin turned "Starter channel for new members" on or off and the HTTP publish came back 401 with `auth-required: authentication required`, the switch said "Only community owners and admins can change this." That prefix is kept for writers to match on, so relayRefusal returned undefined and the PublishRejected fell through to the permission copy. Before this branch the switch showed the raw `auth-required: authentication required`. - outbox: new authRefusal(error). For a sent PublishRejected whose `reason` starts with `auth-required:`, it returns the relay's words without the prefix and capitalized, clipped to the same 256 characters as relayRefusal. A 401 with NIP-FI's missing-evidence text now reads "Authentication required". That is the readable form transport's refusalMessage gives every non-kept prefix, and the same form this switch already shows for NIP-FI's 403 `restricted: authorization denied` ("Authorization denied"). The app has no shared copy for relay `auth-required:` refusals. The sign-in copy in CommunitySignIn maps native sign-in codes, and live-recovery and artifact-sync only classify the prefix. isRelayRefusal and relayRefusal are unchanged. - ChannelSettingsPanel AutoJoinSetting: tries authRefusal after relayRefusal. The permission copy now covers only rejections without relay words. The other relayRefusal callers don't have this problem. On an `auth-required:` refusal the emoji directory says "Failed to add emoji." and the workflow outbox keeps its fixed "Workflow command rejected…" copy. Neither claims a missing role, so both are unchanged. Tests: - ChannelSettingsPanel: the row that expected the permission copy for a kept prefix now expects "Authentication required" for the transport-shaped `auth-required: authentication required` refusal. With the authRefusal call removed from the panel, it fails with "Only community owners and admins can change this." - transport: a real 401 `auth-required: authentication required` through acceptPublish keeps its raw message and reason, is not a relayRefusal, and reads "Authentication required" from authRefusal. A 1000-character one is clipped to 256. - outbox: authRefusal returns nothing for an `auth-required:` refusal with no words, another relay refusal, or a write that was not sent. Checked: tsc --noEmit clean (exit 0). biome check clean on the 5 touched files. vitest for ChannelSettingsPanel, outbox and transport: 3 files, 155 tests passed. Not changed: a 409 conflict (client text, no reason) still shows the permission copy on this switch, as it did before this branch. `rate-limited:` and `conflict:` relay text don't reach this path with a reason: rate limits come back as 429, which isn't a kept refusal status, and 409 drops the relay text. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- .../channels/ChannelSettingsPanel.test.tsx | 11 ++++++-- src/bundled/channels/ChannelSettingsPanel.tsx | 3 ++ src/features/relay/outbox.test.ts | 28 +++++++++++++++++++ src/features/relay/outbox.ts | 17 +++++++++++ src/features/relay/transport.test.ts | 19 ++++++++++++- 5 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/bundled/channels/ChannelSettingsPanel.test.tsx b/src/bundled/channels/ChannelSettingsPanel.test.tsx index eaf860a22d..a940b74f98 100644 --- a/src/bundled/channels/ChannelSettingsPanel.test.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.test.tsx @@ -555,10 +555,15 @@ it.each([ ), "Only community owners and admins can change this.", ], + // `auth-required:` stays prefixed for writers to match on; it refuses the + // sign-in, so an owner is not told they lack the role. [ - "the default reason for a machine prefix", - new PublishRejected("auth-required: sign in", "auth-required: sign in"), - "Only community owners and admins can change this.", + "the relay's sign-in refusal", + new PublishRejected( + "auth-required: authentication required", + "auth-required: authentication required", + ), + "Authentication required", ], [ "a retry for an unconfirmed outcome", diff --git a/src/bundled/channels/ChannelSettingsPanel.tsx b/src/bundled/channels/ChannelSettingsPanel.tsx index 562a9134f1..c5ba50204c 100644 --- a/src/bundled/channels/ChannelSettingsPanel.tsx +++ b/src/bundled/channels/ChannelSettingsPanel.tsx @@ -15,6 +15,7 @@ import { type ChannelLifecycleCapability, } from "../../features/relay/channel-lifecycle"; import { + authRefusal, PublishNotSent, PublishRejected, relayRefusal, @@ -328,6 +329,8 @@ function AutoJoinSetting({ error instanceof PublishNotSent ? "Not sent. Try again." : (relayRefusal(error) ?? + // A refused sign-in, which says nothing about the role. + authRefusal(error) ?? (error instanceof PublishRejected ? "Only community owners and admins can change this." : error instanceof Error diff --git a/src/features/relay/outbox.test.ts b/src/features/relay/outbox.test.ts index 26c9b6d1e3..69fa13118d 100644 --- a/src/features/relay/outbox.test.ts +++ b/src/features/relay/outbox.test.ts @@ -3,6 +3,7 @@ import { createRelaySession } from "./session"; import { PublishNotSent, PublishRejected, + authRefusal, createOutbox, isRelayRefusal, relayRefusal, @@ -1441,3 +1442,30 @@ it.each([ expect(isRelayRefusal(error)).toBe(refused); expect(relayRefusal(error)).toBe(refused ? error.message : undefined); }); +it.each([ + [ + "a refused sign-in", + new PublishRejected("auth-required: sign in", "auth-required: sign in"), + "Sign in", + ], + [ + "a sign-in refusal without words", + new PublishRejected("auth-required: ", "auth-required: "), + undefined, + ], + [ + "another relay refusal", + new PublishRejected("Bad tag", "invalid: bad tag"), + undefined, + ], + [ + "a write that was not sent", + new PublishNotSent("auth-required: sign in", "auth-required: sign in"), + undefined, + ], +])( + "shows the relay's sign-in words only for its sign-in refusal: %s", + (_, error, words) => { + expect(authRefusal(error)).toBe(words); + }, +); diff --git a/src/features/relay/outbox.ts b/src/features/relay/outbox.ts index 520862d426..bb71fe858a 100644 --- a/src/features/relay/outbox.ts +++ b/src/features/relay/outbox.ts @@ -155,6 +155,23 @@ export function relayRefusal(error: unknown): string | undefined { return isRelayRefusal(error) ? clip(error.message) : undefined; } +/** The relay's words, without their prefix and clipped like relayRefusal, + * when it refuses the sign-in rather than the write: a sent `auth-required:` + * refusal. That prefix is kept for writers to match on, so it is never a + * relayRefusal. */ +export function authRefusal(error: unknown): string | undefined { + if (!(error instanceof PublishRejected) || error instanceof PublishNotSent) + return undefined; + const prefix = NIP01_PREFIX.exec(error.reason ?? ""); + const words = + prefix?.[1] === "auth-required" + ? error.reason?.slice(prefix[0].length).trim() + : undefined; + return words + ? clip(words.charAt(0).toUpperCase() + words.slice(1)) + : undefined; +} + export { browserOutboxStorage } from "./outbox-storage"; const abortError = () => new DOMException("Relay session closed", "AbortError"); const MAX_PENDING = 256; diff --git a/src/features/relay/transport.test.ts b/src/features/relay/transport.test.ts index 06cb7f27ca..cb46d2f055 100644 --- a/src/features/relay/transport.test.ts +++ b/src/features/relay/transport.test.ts @@ -9,7 +9,12 @@ import { ReadStateTimestampRejected, READ_STATE_TIMESTAMP_REFUSAL, } from "./read-state-host"; -import { isRelayRefusal, PublishRejected, relayRefusal } from "./outbox"; +import { + authRefusal, + isRelayRefusal, + PublishRejected, + relayRefusal, +} from "./outbox"; import { hostSigner, keypair, signed } from "./testing"; const key = keypair(); afterEach(() => vi.unstubAllGlobals()); @@ -293,6 +298,18 @@ it("keeps a long refusal's whole reason but clips its display words", async () = expect(result).toMatchObject({ reason: error }); expect(relayRefusal(result)).toBe(`X${"x".repeat(255)}`); }); +it("reads a 401 sign-in refusal as the relay's words, clipped, never as a refusal of the write", async () => { + // NIP-FI's missing-evidence denial on an HTTP publish. + const error = "auth-required: authentication required"; + const result = await refusal(401, { error }); + expect(result).toMatchObject({ message: error, reason: error }); + expect(relayRefusal(result)).toBeUndefined(); + expect(authRefusal(result)).toBe("Authentication required"); + const long = await refusal(401, { + error: `auth-required: ${"x".repeat(1000)}`, + }); + expect(authRefusal(long)).toBe(`X${"x".repeat(255)}`); +}); it("keeps the conflict refusal and the raw reason for classifying writers", async () => { const conflict = await refusal(409, { error: "invalid: whatever" }); expect(conflict).toHaveProperty( From f3f50136a0a1346c43215af41c92437a9fa48b5d Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 6 Nov 2026 23:58:10 +1100 Subject: [PATCH 09/12] fix(channels): keep focus in Settings when Refresh channels retires the action Addresses kalvinnchau's review comment on PR 2778 (PRRT_kwDOUVULN860dV2L, against 24cb6709e; still present at de63146db). An owner opened Channel settings, another owner demoted them, and they chose the Archive action Settings still offered. The "no longer permitted" toast then offered Refresh channels. Pressing Enter on it removed the focused button with no handoff, and the refresh then removed Archive and Delete. Focus ended on BODY, so the next Tab went to global chrome instead of the Settings pane. - ChannelNavigationState: the failure keeps the failed action's trigger and fallback. Refresh channels returns focus to the trigger, or to the fallback if the trigger is gone, the same way finalFocus does. It does this only when focus is still on the button or was lost, so it never pulls focus from somewhere else. The mute toast's buttons hand focus back to their row in the same way. - ChannelLifecycleActions: when a refresh or a live role change removes a focused Leave, Archive/Unarchive or Delete button, focus moves to the pane's fallback: the Settings-tab close control. A ref cleanup sees the focus before the button detaches. A microtask then moves it, but only if focus would otherwise sit on BODY. This is the pattern ActivityGroupActions uses for its retired Stop buttons. - ChannelLifecycleDialog: lifecycleFocusFallback holds the fallback lookup that openLifecycle used inline, so both owners share it. finalFocus uses the same focusOrigin helper as the toast. - docs/channels.md: one sentence on where focus goes after Refresh channels. Dismiss on this toast doesn't lose focus, so it is unchanged. Keyboard Dismiss returns focus to Archive both before and after this change. Sonner's fallback restores the element focused before the toast, and Dismiss doesn't refresh anything, so Archive stays. Tests: - ChannelsPage.tabs, real session: the demotion recovery test now focuses Refresh channels and presses Enter. A new `hold` option on the workspace fixture keeps the refresh read pending while the test checks that focus is back on Archive. After release, Leave replaces Archive and Delete, and focus settles on Close Channel settings tab. - ChannelLifecycleActions: a role change that removes a focused Archive moves focus to the pane's first button. If focus had moved to another control first, it stays there. Fail-before: - With all production files as at de63146db, the ChannelsPage test fails on the toHaveFocus check right after Enter. - With only the Refresh handoff removed, it fails at the same check. - With only the retirement handoff removed, it fails at the final check. A throwaway probe in that state showed focus settled on BODY. - In the ChannelLifecycleActions test, removing the refs fails the first half, and removing both focus guards fails the second. Checked: tsc --noEmit exits 0. biome check is clean on the 5 touched source and test files. git diff --check is clean. vitest, 8 files, 278 tests passed: ChannelsPage.tabs, ChannelLifecycleActions, ChannelNavigationState, ChannelLifecycleMenu, ChannelSettingsPanel, ChannelsPage, ChannelsPage.solo, ChannelInventory. tests/browser/channel-lifecycle.spec.mjs in Chromium (--no-deps): 10 passed. That spec has no demotion recovery journey, so it is a regression check only. Not checked: the reviewer's flow in a real Chromium or WebKit build. jsdom covers the focus logic but not each engine's focus fixup when a node is removed. Not changed: the unused lifecycleRefreshes counter. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- docs/channels.md | 3 + .../channels/ChannelLifecycleActions.test.tsx | 29 ++++++++ .../channels/ChannelLifecycleActions.tsx | 28 +++++++- .../channels/ChannelLifecycleDialog.tsx | 12 ++++ .../channels/ChannelsPage.tabs.test.tsx | 66 ++++++++++++++----- .../ChannelNavigationState.tsx | 45 +++++++------ 6 files changed, 144 insertions(+), 39 deletions(-) diff --git a/docs/channels.md b/docs/channels.md index 28df1e4d13..3db25e4ffd 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -614,6 +614,9 @@ permitted" toast shown when signed channel state no longer allows the action, of The permission check behind "no longer permitted", before signing and again before publishing, reads the writer, so a lagging replica cannot refuse an action Settings offers after a refresh. Settings' channel actions follow the refreshed channel state. +Refresh channels returns focus to the failed action's control. If the refreshed +permissions remove that Settings action, focus moves to the Settings-tab close control, +unless the person has already moved focus elsewhere. After a failed Hide, Refresh channels also rereads DM visibility from the writer: whether a conversation is hidden is not part of the channel list. Other failures offer only Dismiss. Cancellation/cache clear/session replacement diff --git a/src/bundled/channels/ChannelLifecycleActions.test.tsx b/src/bundled/channels/ChannelLifecycleActions.test.tsx index 731c7ddab0..2d900d4194 100644 --- a/src/bundled/channels/ChannelLifecycleActions.test.tsx +++ b/src/bundled/channels/ChannelLifecycleActions.test.tsx @@ -95,6 +95,35 @@ it("replaces Archive with Unarchive when the live channel is archived", () => { screen.getByRole("button", { name: "Unarchive channel" }), ).toBeVisible(); }); +it("keeps focus in the pane when a role change retires the focused action, unless it moved", async () => { + const host = session(); + const pane = (role: ChannelSummary["viewerRole"]) => ( + <> + + + + ); + const view = render(pane("admin")); + screen.getByRole("button", { name: "Archive channel" }).focus(); + view.rerender(pane("member")); + expect(screen.queryByRole("button", { name: "Archive channel" })).toBeNull(); + await vi.waitFor(() => + expect(screen.getByRole("button", { name: "Settings tab" })).toHaveFocus(), + ); + view.rerender(pane("admin")); + screen.getByRole("button", { name: "Archive channel" }).focus(); + screen.getByRole("textbox", { name: "Elsewhere" }).focus(); + view.rerender(pane("member")); + await Promise.resolve(); + expect(screen.getByRole("textbox", { name: "Elsewhere" })).toHaveFocus(); +}); it("explains an unsupported host without a lookup or write", () => { const host = session(false); render( diff --git a/src/bundled/channels/ChannelLifecycleActions.tsx b/src/bundled/channels/ChannelLifecycleActions.tsx index cd56b7daae..3de334bce5 100644 --- a/src/bundled/channels/ChannelLifecycleActions.tsx +++ b/src/bundled/channels/ChannelLifecycleActions.tsx @@ -1,3 +1,4 @@ +import { useCallback } from "react"; import type { ChannelSummary } from "../../features/relay/contracts"; import type { ChannelLifecycleAction } from "../../features/relay/channel-lifecycle-protocol"; import { Button } from "../../shared/design-system/ui/Button"; @@ -6,7 +7,10 @@ import { SignOutIcon, TrashIcon, } from "../../shared/design-system/icons"; -import type { LifecycleNoun } from "./ChannelLifecycleDialog"; +import { + lifecycleFocusFallback, + type LifecycleNoun, +} from "./ChannelLifecycleDialog"; import { useLifecycleOffer, type LifecycleSession } from "./useLifecycleOffer"; /** Settings offers live-state actions; the sidebar owns confirmation. */ @@ -22,18 +26,37 @@ export function ChannelLifecycleActions({ noun?: LifecycleNoun; }) { const permissions = useLifecycleOffer(session, channel); + // A refreshed or live role can retire the focused action. Ref cleanup runs + // before its button detaches, while it still owns focus; keep focus in the + // pane unless something else took it meanwhile. + const retire = useCallback((node: HTMLButtonElement | null) => { + if (!node) return; + return () => { + const document = node.ownerDocument; + if (document.activeElement !== node) return; + const fallback = lifecycleFocusFallback(node); + queueMicrotask(() => { + if (!node.isConnected && document.activeElement === document.body) + fallback?.focus({ preventScroll: true }); + }); + }; + }, []); if (!session.channelLifecycle.available) return

Channel actions unavailable on this connection

; return ( <> {permissions?.canLeave && ( - )} {(permissions?.canArchive || permissions?.canUnarchive) && (