diff --git a/docs/channels.md b/docs/channels.md index 1bb44799d3..3db25e4ffd 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -607,10 +607,20 @@ 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. +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 +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/docs/relay-queries.md b/docs/relay-queries.md index d28031d339..c0b5f72ba2 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,15 @@ 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. 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, +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 @@ -159,7 +166,15 @@ 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 +`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`. A timeout +refusal (`restricted: you are timed out until `) shows its expiry +as a local date and time, such as `You are timed out until Tue, Nov 10 at +2:19 PM`; other refusal text is shown as the relay sent it. 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`). diff --git a/docs/workflows.md b/docs/workflows.md index 298b65616c..11d006b201 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -113,8 +113,17 @@ 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 -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, with a timeout's +unix-seconds expiry shown as a local date and time; 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. 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. **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/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) && ( + )} + )} ); diff --git a/src/features/relay/channel-lifecycle.test.ts b/src/features/relay/channel-lifecycle.test.ts index 9d0fed26e4..fc5a7d77d1 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 { @@ -285,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) => { @@ -372,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") @@ -611,12 +647,53 @@ 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.toMatchObject({ name: "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(); }); +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 { + // An ordinary load reads the replica, which denies 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) => { @@ -906,9 +983,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) @@ -1153,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(); @@ -1215,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 @@ -1258,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, @@ -1357,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 92c7521ac0..b3252db2c3 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, @@ -21,8 +21,17 @@ 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 { + 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 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)}`, @@ -54,7 +63,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; } @@ -94,6 +107,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); @@ -179,9 +193,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); @@ -222,6 +238,7 @@ export function createChannelLifecycle({ reader.read( batch.map((pubkey) => ({ kinds: [0], + ...(strong ? { consistency: "strong" as const } : {}), authors: [pubkey], limit: 1, })), @@ -313,9 +330,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; @@ -324,13 +349,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, @@ -433,6 +461,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, @@ -441,6 +471,8 @@ export function createChannelLifecycle({ action === "delete" ? action : false, + undefined, + true, ); if (settings.deleteUnavailable) throw new Error( @@ -454,11 +486,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 +584,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) { diff --git a/src/features/relay/emoji-directory.ts b/src/features/relay/emoji-directory.ts index 723e8b22fc..ec8a46af93 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 { relayRefusal } 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) { + const refusal = relayRefusal(error); throw new Error( timeout.aborted ? url === undefined ? "Timed out while removing emoji." : "Timed out while adding emoji." - : failure, + : refusal + ? `${failure} ${refusal}` + : failure, ); } } diff --git a/src/features/relay/emoji.test.ts b/src/features/relay/emoji.test.ts index 633002990e..1f70a5c2d8 100644 --- a/src/features/relay/emoji.test.ts +++ b/src/features/relay/emoji.test.ts @@ -10,7 +10,8 @@ import { } from "./emoji"; import { createRelaySession } from "./session"; import { foldMessages } from "./fold"; -import { PublishRejected } from "./outbox"; +import { PublishNotSent, PublishRejected } from "./outbox"; +import { formatItemTimestamp } from "../../shared/datetime"; import { flush, keypair, @@ -728,17 +729,67 @@ 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([]); + // The relay's timeout refusal carries its expiry in unix seconds, which is + // shown as a local date and time. + const until = 1794000000; + h.publish.mockRejectedValueOnce( + new PublishRejected( + `You are timed out until ${until}`, + `restricted: you are timed out until ${until}`, + ), + ); + const timedOut = h.emoji.add?.("party", url); + await vi.waitFor(() => expect(h.wire.pending).toHaveLength(1)); + h.ownRead().respond([]); + await expect(timedOut).rejects.toThrow( + new Error( + `Failed to add emoji. You are timed out until ${formatItemTimestamp(until, { withTime: true })}`, + ), + ); + await expect(timedOut).rejects.not.toThrow(/1794000000/); + // 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 [ + 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(1); + await expect(failedRead).rejects.toThrow(/^Failed to add emoji\.$/); + expect(h.publish).toHaveBeenCalledTimes(6); }); it("removes only from the fresh own set and preserves another member's colliding emoji", async () => { const h = authoring(); @@ -823,12 +874,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..9bd4dabf25 100644 --- a/src/features/relay/outbox-receipts.test.ts +++ b/src/features/relay/outbox-receipts.test.ts @@ -125,7 +125,31 @@ 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("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(); @@ -135,7 +159,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); @@ -207,10 +235,16 @@ 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"); + // 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); @@ -218,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..4718e5d360 100644 --- a/src/features/relay/outbox.test.ts +++ b/src/features/relay/outbox.test.ts @@ -3,13 +3,17 @@ import { createRelaySession } from "./session"; import { PublishNotSent, PublishRejected, + authRefusal, createOutbox, + isRelayRefusal, + relayRefusal, type OutboxStorage, type OutgoingEvent, } from "./outbox"; import type { EventTemplate } from "nostr-tools"; import type { RelayEvent } from "./events"; import { byteSize } from "./budget"; +import { formatItemTimestamp } from "../../shared/datetime"; import { LIVE_CANVAS_BYTES } from "../channel-templates/canvas-limits.ts"; import { bounds, @@ -1407,3 +1411,94 @@ 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); +}); +it("shows a timeout refusal's expiry as a local date and time", () => { + vi.useFakeTimers(); + // At :10 past the hour, the local clock is nowhere near midnight in any zone, + // so a minute later is still today and reads as a bare clock time. + const now = Date.UTC(2026, 10, 7, 12, 10); + vi.setSystemTime(now); + const until = now / 1000 + 60; + const refusal = (reason: string) => + relayRefusal( + new PublishRejected( + reason.replace(/^\w+(?:-\w+)?:\s*(.)/, (_, c: string) => + c.toUpperCase(), + ), + reason, + ), + ); + const shown = refusal(`restricted: you are timed out until ${until}`); + expect(shown).toMatch(/^You are timed out until \d{1,2}:\d{2}(?: [AP]M)?$/); + expect(shown).toBe( + `You are timed out until ${formatItemTimestamp(until, { withTime: true })}`, + ); + // Only the relay's exact form is a timeout; other text is shown as sent. + expect(refusal("restricted: you are timed out until soon")).toBe( + "You are timed out until soon", + ); + expect(refusal(`restricted: timed out until ${until}`)).toBe( + `Timed out until ${until}`, + ); + expect(refusal(`blocked: you are timed out until ${until}`)).toBe( + `You are timed out until ${until}`, + ); +}); +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 cd3f63178a..bf375f00fe 100644 --- a/src/features/relay/outbox.ts +++ b/src/features/relay/outbox.ts @@ -6,6 +6,7 @@ import type { RelayWriter } from "./transport"; import { ByteLru, byteSize, OUTBOX_INPUT_MAX_BYTES } from "./budget"; import { createRelayProfiler, type RelayProfiler } from "./profiling"; import { channelRowKind } from "./membership"; +import { formatItemTimestamp } from "../../shared/datetime"; import { MessageClock } from "./message-order"; import { bytes, @@ -124,6 +125,66 @@ 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*/; +/** 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 + * 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 && + !KEPT_PREFIXES.has(prefix[1] ?? "") && + !!error.reason?.slice(prefix[0].length).trim() + ); +} + +/** The relay's refusal of a timed-out member's write, in its ingest's exact + * form: `restricted: you are timed out until `. */ +const TIMEOUT_REFUSAL = /^restricted:\s*you are timed out until (\d{1,12})$/; + +/** The relay's refusal words to show, clipped like stored diagnostics, or + * undefined when `error` is not the relay's own refusal. A timeout's expiry + * is shown as a local date and time; other text is shown as the relay sent + * it. */ +export function relayRefusal(error: unknown): string | undefined { + if (!isRelayRefusal(error)) return undefined; + const until = TIMEOUT_REFUSAL.exec(error.reason?.trim() ?? "")?.[1]; + return clip( + until + ? `You are timed out until ${formatItemTimestamp(Number(until), { withTime: true })}` + : error.message, + ); +} + +/** 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; @@ -172,7 +233,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; @@ -646,8 +713,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); const delivery: Delivery | undefined = latest && (latest.delivery === "seen" || attempt.previousDelivery === "seen" @@ -657,6 +722,13 @@ 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" ? relayRefusal(error) : undefined, + ); if (latest && delivery) saveStatus({ ...latest, diff --git a/src/features/relay/session-leave.test.ts b/src/features/relay/session-leave.test.ts index d3e2707744..6ed5f678f9 100644 --- a/src/features/relay/session-leave.test.ts +++ b/src/features/relay/session-leave.test.ts @@ -112,9 +112,11 @@ it.each(cases)( expect(owner.session.channels.list().channels).toHaveLength(0); expect(outcome).toBe("confirmed"); if (visibility === "private") expect(content.snapshot().events).toEqual([]); + // Leave's permission preflights also read the writer, but by `#d` alone; + // only the post-publish roster confirmation names the viewer. const confirmations = query.mock.calls .flatMap(([filters]) => filters) - .filter((filter) => filter.consistency === "strong"); + .filter((filter) => filter.consistency === "strong" && filter["#p"]); expect(confirmations).toHaveLength(ordering === "before ACK" ? 1 : 2); for (const filter of confirmations) expect(filter).toEqual({ 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..7a66487d75 100644 --- a/src/features/relay/transport.test.ts +++ b/src/features/relay/transport.test.ts @@ -9,8 +9,14 @@ import { ReadStateTimestampRejected, READ_STATE_TIMESTAMP_REFUSAL, } from "./read-state-host"; -import { PublishRejected } from "./outbox"; +import { + authRefusal, + isRelayRefusal, + PublishRejected, + relayRefusal, +} from "./outbox"; import { hostSigner, keypair, signed } from "./testing"; +import { formatItemTimestamp } from "../../shared/datetime"; const key = keypair(); afterEach(() => vi.unstubAllGlobals()); it.each([ @@ -148,6 +154,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"], @@ -242,6 +252,77 @@ 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("shows a 403 timeout refusal's unix-seconds expiry as a local date and time", async () => { + const until = 1794000000; + const error = `restricted: you are timed out until ${until}`; + const result = await refusal(403, { error }); + expect(result).toMatchObject({ + message: `You are timed out until ${until}`, + reason: error, + }); + expect(relayRefusal(result)).toBe( + `You are timed out until ${formatItemTimestamp(until, { withTime: true })}`, + ); +}); +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("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( diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index c658d52385..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 { 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,11 +1618,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|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 * 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 diff --git a/src/features/workflows/capability.test.ts b/src/features/workflows/capability.test.ts index 62abe668e1..38c746734d 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,50 @@ 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.", + ], + [ + "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) => { + 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({ @@ -794,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, @@ -807,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/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; } 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;