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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions dev/channel-kit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,20 @@ export function validCanvas(event) {
typeof event.content === "string" &&
Buffer.byteLength(event.content) <= 24 * 1024 &&
Array.isArray(event.tags) &&
event.tags.filter((t) => t[0] === "h").length === 1 &&
event.tags.filter((t) => t?.[0] === "h").length === 1 &&
event.tags.filter((t) => t?.[0] === "expected-revision").length <= 1 &&
event.tags.every(
(t) =>
Array.isArray(t) &&
t.length === 2 &&
(t[0] === "h"
? /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/.test(t[1])
: t[0] === "client-id" &&
typeof t[1] === "string" &&
t[1].length <= 128),
: t[0] === "expected-revision"
? typeof t[1] === "string" &&
(t[1] === "none" || /^[0-9a-f]{64}$/.test(t[1]))
: t[0] === "client-id" &&
typeof t[1] === "string" &&
t[1].length <= 128),
)
);
}
16 changes: 16 additions & 0 deletions dev/channel-kit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,23 @@ it("admits only bounded Canvas writes with one exact channel and no notification
tags: [["h", "11111111-1111-4111-8111-111111111111"]],
};
expect(validCanvas(canvas)).toBe(true);
for (const revision of ["none", "a".repeat(64)])
expect(
validCanvas({
...canvas,
tags: [...canvas.tags, ["expected-revision", revision]],
}),
).toBe(true);
for (const tags of [
[...canvas.tags, ["expected-revision", "bad"]],
[...canvas.tags, ["expected-revision", "A".repeat(64)]],
[...canvas.tags, ["expected-revision", "none", "extra"]],
[
...canvas.tags,
["expected-revision", "none"],
["expected-revision", "none"],
],
[...canvas.tags, null],
[],
[...canvas.tags, ...canvas.tags],
[...canvas.tags, ["p", owner.pubkey]],
Expand Down
27 changes: 21 additions & 6 deletions dev/relay-broker-live.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1377,11 +1377,23 @@ test.each([
);

test.each([
["conflict: artifact head changed", "failed", "conflict: the relay state"],
["error: internal server error", "unknown", "could not be confirmed"],
[
45010,
"conflict: artifact head changed",
"failed",
"conflict: the relay state",
],
[45010, "error: internal server error", "unknown", "could not be confirmed"],
[
40100,
"conflict: canvas head changed",
"failed",
"conflict: the relay state",
],
[40100, "error: internal server error", "unknown", "could not be confirmed"],
])(
"artifact refusal %s reaches broker/outbox as %s / %s",
async (reason, delivery, error) => {
"kind %s refusal %s reaches broker/outbox as %s / %s",
async (kind, reason, delivery, error) => {
const h = await harness();
let traffic, owner;
try {
Expand All @@ -1394,9 +1406,12 @@ test.each([
save() {},
});
const id = owner.outbox.send({
kind: 45010,
kind,
content: "",
tags: [["h", "00000000-0000-4000-8000-000000000001"]],
tags: [
["h", "00000000-0000-4000-8000-000000000001"],
["expected-revision", "none"],
],
});
await until(() => h.publications.length === 1);
await h.sockets[0].receive(["OK", id, false, reason]);
Expand Down
24 changes: 20 additions & 4 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,10 +266,26 @@ Task actions pause while saving; the new-item input stays editable and retains
its text when the save finishes. If the loaded Canvas was written in the current second,
a single cancellable wait respects its timestamp ordering; there is no background
retry loop. Failures and recovered drafts expose Retry rather than silently publishing
on reopen. Save uses the existing session Canvas/outbox contract, including its 24 KiB limit, fresh membership check,
optimistic head comparison and exact confirmation. This is **not atomic concurrency
control**; simultaneous saves can overwrite edits. Detected conflicts retain the
local draft and require reviewing the saved Canvas. Refresh confirms before discarding
on reopen. Save uses the existing session Canvas/outbox contract, including its
24 KiB limit, fresh membership check, writer-backed Canvas head/editor-confirmation
reads and exact signed-event recovery. Canvas reads default to strong consistency
for editor/Todos bases and setup preconditions. Setup delivery and its separate
exact-ID confirmation both use writer-backed reads; unknown seed outcomes are
checked without automatically replaying the seed. Template copies explicitly opt
out and remain replica-eligible; Channel Settings no longer reads a Canvas preview.
Editor/Todos saves carry `expected-revision=<loaded
head id>` (or `none` when absent); template seeds carry `none`. On relays supporting
Canvas compare-and-swap, stale preconditions are refused atomically before mutation.
A proven conflict keeps the local draft and dismisses only that rejected outbox
operation so a reviewed save can proceed. Unknown outcomes remain in Outbox and
block replacement; exact signed retries keep their original precondition. The
post-write different-head check remains conservative and also retains the draft.

**Compatibility gate:** deploy with a relay supporting Canvas revision preconditions
([block/buzz#6780](https://github.com/block/buzz/pull/6780)) for atomic protection.
An older relay may ignore the tag; strong reads and client head comparison alone
cannot prevent concurrent overwrite. This client change does not upgrade the relay
or add Canvas history/restore. Detected conflicts require reviewing the saved Canvas. Refresh confirms before discarding
edits. Local recovery drafts are partitioned by community/viewer/channel; if browser
storage is unavailable they survive only while the editor stays open. Save never
promotes local recovery storage to shared state. Already accepted outbox operations
Expand Down
6 changes: 4 additions & 2 deletions docs/relay-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,10 @@ just-added member does not fail the next operation.
Template setup also confirms exact Canvas/member events and selected Canvas heads
against the writer without replaying accepted commands. Agent deletion discovers
member channels and confirms each removal with writer-backed rosters; unreadable
rosters still fail closed. Ordinary Canvas browsing and standalone Canvas/recipe
save confirmation routing are unchanged by this policy.
rosters still fail closed. Standalone recipe saves use writer-backed exact-ID
confirmation; recipe head and catalog reads remain replica-eligible. For
writer-backed Canvas editor/Todos reads and replica-eligible template copies, see
the [Canvas/outbox contract](plugin-architecture.md#optional-canvas-todos).

## Community emoji

Expand Down
52 changes: 39 additions & 13 deletions src-tauri/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ fn validate_event(community: &str, event: &EventTemplate) -> Result<()> {
if !channel_writes::creation(event) {
return Err("Agent enrollment or channel operation unavailable or invalid".into());
}
} else if event.kind == 40100 {
if !valid_canvas(event) {
return Err("Malformed Canvas save".into());
}
} else if event.kind == 28936 {
// A NIP-43 leave request revokes the signer's own membership: empty
// content and exactly the NIP-70 protected tag, nothing else.
Expand All @@ -223,25 +227,47 @@ fn validate_event(community: &str, event: &EventTemplate) -> Result<()> {
}
} else if !matches!(
event.kind,
0 | 7
| 9
| 1984
| 9000
| 9001
| 20001
| 30030
| 30177
| 30315
| 40003
| 40100
| 42000
| 45010
0 | 7 | 9 | 1984 | 9000 | 9001 | 20001 | 30030 | 30177 | 30315 | 40003 | 42000 | 45010
) {
return Err("This event is not supported by the packaged relay connection".into());
}
Ok(())
}

// Match the broker's purpose-bound Canvas admission, including legacy untagged retries.
fn valid_canvas(event: &EventTemplate) -> bool {
event.content.len() <= 24 * 1024
&& event
.tags
.iter()
.filter(|tag| tag.first().map(String::as_str) == Some("h"))
.count()
== 1
&& event
.tags
.iter()
.filter(|tag| tag.first().map(String::as_str) == Some("expected-revision"))
.count()
<= 1
&& event.tags.iter().all(|tag| {
if tag.len() != 2 {
return false;
}
match tag[0].as_str() {
"h" => channel_writes::uuid(&tag[1]),
"client-id" => tag[1].encode_utf16().count() <= 128,
"expected-revision" => {
tag[1] == "none"
|| (tag[1].len() == 64
&& tag[1]
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)))
}
_ => false,
}
})
}

/** Keep the shared kind-5 writer aligned with the broker's channel-local deletion shape. */
fn valid_message_deletion(event: &EventTemplate) -> bool {
if event.kind != 5
Expand Down
53 changes: 53 additions & 0 deletions src-tauri/src/relay/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1800,3 +1800,56 @@ async fn preference_batches_reject_invalid_ciphertext_after_signature_verificati
}
}
}

#[test]
fn canvas_signing_bounds_revision_preconditions_and_allows_exact_legacy_retries() {
let channel = vec![
"h".to_string(),
"11111111-1111-4111-8111-111111111111".to_string(),
];
let event = |tags| EventTemplate {
kind: 40100,
content: "# Plan".into(),
created_at: 100,
tags,
};
assert!(validate_event("https://relay.test", &event(vec![channel.clone()])).is_ok());
for revision in ["none".to_string(), "a".repeat(64)] {
assert!(validate_event(
"https://relay.test",
&event(vec![
channel.clone(),
vec!["expected-revision".into(), revision]
])
)
.is_ok());
}
for tags in [
vec![],
vec![channel.clone(), channel.clone()],
vec![channel.clone(), vec!["p".into(), "a".repeat(64)]],
vec![
channel.clone(),
vec!["expected-revision".into(), "bad".into()],
],
vec![
channel.clone(),
vec!["expected-revision".into(), "A".repeat(64)],
],
vec![
channel.clone(),
vec!["expected-revision".into(), "none".into(), "extra".into()],
],
vec![
channel.clone(),
vec!["expected-revision".into(), "none".into()],
vec!["expected-revision".into(), "none".into()],
],
vec![channel.clone(), vec![]],
] {
assert!(validate_event("https://relay.test", &event(tags)).is_err());
}
let mut too_large = event(vec![channel]);
too_large.content = "é".repeat(13 * 1024);
assert!(validate_event("https://relay.test", &too_large).is_err());
}
2 changes: 1 addition & 1 deletion src/bundled/channel-templates/TemplateSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export function SaveAsTemplate({
setBusy(true);
setError("");
try {
const canvas = await session.canvas.read(channel.id);
const canvas = await session.canvas.read(channel.id, { strong: false });
if (!active()) return;
setCopyWarning(
catalog.agentsComplete
Expand Down
11 changes: 9 additions & 2 deletions src/bundled/channel-templates/agent-selection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -515,12 +515,16 @@ it("does not consume a legacy group default while its required identity is still
it("copies a complete managed lineup without an unused legacy warning", async () => {
const test = harness(),
user = userEvent.setup();
const read = vi.fn(test.owner.session.canvas.read);
try {
await test.owner.session.agentChoices.refresh();
await test.owner.session.archives.ensure();
render(
<SaveAsTemplate
session={test.owner.session}
session={{
...test.owner.session,
canvas: { ...test.owner.session.canvas, read },
}}
channel={{
id: "11111111-1111-4111-8111-111111111111",
name: "Partial",
Expand All @@ -540,6 +544,9 @@ it("copies a complete managed lineup without an unused legacy warning", async ()
expect(
screen.queryByText(/Incomplete agent inventory/),
).not.toBeInTheDocument();
expect(read).toHaveBeenCalledWith("11111111-1111-4111-8111-111111111111", {
strong: false,
});
expect(test.published).toEqual([]);
} finally {
cleanup();
Expand Down Expand Up @@ -1324,7 +1331,7 @@ it.each(["", "# Seed plan"])(
);
// Ordinary browsing still uses the lagging replica, not the writer.
await expect(
test.owner.session.canvas.read(id),
test.owner.session.canvas.read(id, { strong: false }),
).resolves.toBeUndefined();
expect(test.reads.at(-1)?.consistency).toBeUndefined();
}
Expand Down
33 changes: 33 additions & 0 deletions src/features/channel-templates/canvas-conflict.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { expect, it } from "vitest";
import type { OutgoingEvent } from "../relay/outbox";
import { keypair, signed } from "../relay/testing";
import { isDefinitiveCanvasConflict } from "./canvas-conflict";

const viewer = keypair();

it.each([
[40100, "failed", "conflict: the relay state changed", true],
[30078, "failed", "conflict: the relay state changed", false],
[40100, "unknown", "conflict: the relay state changed", false],
[40100, "unknown", "Retry blocked: conflict: the relay state changed", false],
[40100, "failed", "Retry blocked: conflict: the relay state changed", false],
[40100, "accepted", "conflict: the relay state changed", false],
[40100, "seen", "conflict: the relay state changed", false],
[40100, "sending", "conflict: the relay state changed", false],
[40100, "failed", "forbidden: membership changed", false],
[40100, "failed", undefined, false],
] as const)(
"classifies kind=%s delivery=%s error=%s as definitive=%s",
(kind, delivery, error, expected) => {
const operation: OutgoingEvent = {
event: signed(viewer, { kind, content: "Draft", tags: [] }),
delivery,
error,
};
expect(isDefinitiveCanvasConflict(operation)).toBe(expected);
},
);

it("does not classify missing delivery evidence as a refusal", () => {
expect(isDefinitiveCanvasConflict(undefined)).toBe(false);
});
12 changes: 12 additions & 0 deletions src/features/channel-templates/canvas-conflict.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { OutgoingEvent } from "../relay/outbox";

/** A refused retry cannot prove an earlier uncertain publication never landed. */
export function isDefinitiveCanvasConflict(
operation: OutgoingEvent | undefined,
): boolean {
return (
operation?.event.kind === 40100 &&
operation.delivery === "failed" &&
operation.error?.startsWith("conflict:") === true
);
}
Loading
Loading