Skip to content
Draft
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
76 changes: 76 additions & 0 deletions dev/relay-broker-api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -494,3 +494,79 @@ test("both real sign and publish routes admit direct replies but reject arbitrar
await h.close();
}
});

test("lifecycle uses dedicated shape-limited host routes, never the message writer", async () => {
const h = await harness((call) =>
Response.json(
call.url.endsWith("/events")
? { accepted: true, event_id: call.body.id }
: [],
),
);
try {
const transport = await connectBrokerTransport(h.base);
expect(transport.writer.kinds).not.toContain(9008);
const id = "11111111-1111-4111-8111-111111111111";
const template = {
kind: 9008,
tags: [["h", id]],
content: "",
created_at: 1700000000,
};
expect((await h.post("sign", template)).status).toBe(400);
const invalid = [
{
...template,
kind: 9002,
tags: [
["h", id],
["name", "rename"],
],
},
{
...template,
kind: 9022,
tags: [
["h", id],
["p", transport.viewer],
],
},
{ ...template, content: "extra" },
{
...template,
tags: [
["h", id],
["h", id],
],
},
];
for (const event of invalid) {
expect((await h.post("channel-lifecycle-sign", event)).status).toBe(400);
expect((await h.post("channel-lifecycle-publish", event)).status).toBe(
400,
);
}
const signal = new AbortController().signal;
const signed = await transport.channelLifecycle.sign(template, signal);
expect(verifyEvent(signed)).toBe(true);
expect(signed).toMatchObject(template);
expect((await h.post("publish", signed)).status).toBe(400);
await transport.channelLifecycle.publish(signed, signal);
expect(h.calls.filter((call) => call.url.endsWith("/events"))).toHaveLength(
1,
);
const foreignKey = new Uint8Array(32).fill(5);
const foreign = finalizeEvent(
{ ...template, tags: template.tags.map((tag) => [...tag]) },
foreignKey,
);
expect((await h.post("channel-lifecycle-publish", foreign)).status).toBe(
400,
);
expect(h.calls.filter((call) => call.url.endsWith("/events"))).toHaveLength(
1,
);
} finally {
await h.close();
}
});
200 changes: 197 additions & 3 deletions dev/relay-broker.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { validateLifecycleTemplate } from "../src/features/relay/channel-lifecycle-protocol.ts";
import {
assertSidebarStarIntent,
mutateSidebarStar,
} from "./sidebar-stars.mjs";
import {
validateWorkflowEvent,
WORKFLOW_KINDS,
Expand All @@ -23,6 +28,8 @@ import {
import { readAgentLibrary } from "./agent-library.mjs";
import {
decodeSidebarPreferences,
assertSidebarAssignmentIntent,
mutateSidebarAssignment,
SIDEBAR_REQUEST_BYTES,
SIDEBAR_UPLOAD_MS,
SIDEBAR_UPLOAD_SLOTS,
Expand Down Expand Up @@ -59,6 +66,7 @@ const MAX_FILTERS = 4,
MAX_LIMIT = 500,
MAX_INFLIGHT = 6,
MAX_MEDIA_BYTES = 20 * 1024 * 1024,
SIDEBAR_HEAD_BYTES = SIDEBAR_REQUEST_BYTES + 4096,
UPSTREAM_TIMEOUT_MS = 20000,
KEEPALIVE_MS = 60000;

Expand Down Expand Up @@ -300,6 +308,27 @@ export function relayBrokerPlugin({
const upstream = createUpstream();
// Injected fixtures bypass the pool; the live relay always uses the warm agent.
const fetchUpstream = upstreamFetch ?? upstream.fetch;
const readSidebarHead = async (response, label = "group") => {
if (!response.body)
throw new Error(`Sidebar ${label} response missing`);
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8", { fatal: true });
let bytes = 0,
text = "";
try {
while (true) {
const { value, done } = await reader.read();
if (done) return JSON.parse(text + decoder.decode());
bytes += value.byteLength;
if (bytes > SIDEBAR_HEAD_BYTES)
throw new Error(`Sidebar ${label} response exceeds capacity`);
text += decoder.decode(value, { stream: true });
}
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
};
// Discovery is lazy and independent for each community; unavailable relays never block startup.
const registered = new Map(Object.entries(aliases));
const authorities = new Map();
Expand Down Expand Up @@ -342,6 +371,7 @@ export function relayBrokerPlugin({
let inflight = 0;
let sidebarUploads = 0;
let libraryRead;
const sidebarMutations = new Map();
const streams = new Map();
const admissions = createHostAdmission();
server.httpServer?.once("close", () => {
Expand Down Expand Up @@ -528,6 +558,149 @@ export function relayBrokerPlugin({
sidebarUploads--;
}
}
if (
[
"/api/relay/sidebar-assignment",
"/api/relay/sidebar-star",
].includes(route) &&
req.method === "POST"
) {
const starring = route === "/api/relay/sidebar-star";
let raw = "";
for await (const part of req) {
raw += part;
if (Buffer.byteLength(raw) > 2048)
return json(res, 413, {
error: `Sidebar preference intent is too large`,
});
}
let intent;
try {
intent = JSON.parse(raw);
if (starring) assertSidebarStarIntent(intent);
else assertSidebarAssignmentIntent(intent);
} catch {
return json(res, 400, {
error: `Invalid sidebar preference intent`,
});
}
const request = new AbortController();
const close = () => request.abort();
res.once("close", close);
const previous = sidebarMutations.get(relay) ?? Promise.resolve();
const mutation = previous
.catch(() => {})
.then(async () => {
request.signal.throwIfAborted();
const filter = [
{
kinds: [30078],
authors: [viewer],
"#d": [starring ? "channel-stars" : "channel-sections"],
limit: 1,
},
];
const lane = admissions(relay, viewer).api;
const requestSignal = AbortSignal.any([
request.signal,
AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
]);
const dispatch = (path, body) =>
admittedApiRequest(
lane,
() => {
requestSignal.throwIfAborted();
const value = JSON.stringify(body);
const auth = finalizeEvent(
{
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: "",
tags: [
["u", `${relay}${path}`],
["method", "POST"],
[
"payload",
createHash("sha256").update(value).digest("hex"),
],
["nonce", randomBytes(16).toString("hex")],
],
},
key,
);
return fetchUpstream(`${relay}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization:
"Nostr " +
Buffer.from(JSON.stringify(auth)).toString(
"base64",
),
},
body: value,
redirect: "error",
signal: requestSignal,
});
},
requestSignal,
);
const readHead = async () => {
const response = await dispatch("/query", filter);
if (!response.ok)
throw new Error(
`Sidebar preference query failed (${response.status})`,
);
return readSidebarHead(response);
};
const publishEvent = async (event) => {
const response = await dispatch("/events", event);
if (!response.ok)
throw new Error(
`Sidebar preference publish failed (${response.status})`,
);
const receipt = await readSidebarHead(
response,
"publication",
);
if (
receipt.event_id !== event.id ||
receipt.accepted !== true
)
throw new Error(
"Sidebar preference publication was not accepted",
);
};
return (starring ? mutateSidebarStar : mutateSidebarAssignment)(
intent,
key,
readHead,
publishEvent,
);
});
sidebarMutations.set(relay, mutation);
try {
return json(res, 200, await mutation);
} catch (error) {
if (error instanceof ApiPaused)
return json(res, 429, {
error: error.message,
sent: false,
paused: true,
retryAfterMs: error.retryAfterMs,
});
return json(res, 502, {
error:
error instanceof Error
? error.message
: `Sidebar preference failed`,
});
} finally {
res.off("close", close);
if (sidebarMutations.get(relay) === mutation)
sidebarMutations.delete(relay);
}
}
if (route === "/api/relay/agent-library" && req.method === "GET") {
try {
// Share concurrent reads, never retain the local snapshot after completion.
Expand All @@ -550,9 +723,12 @@ export function relayBrokerPlugin({
...(await getAuthority(relay)),
relayUrl: relay,
writeKinds: [7, 9, ...WORKFLOW_KINDS],
channelLifecycle: true,
workflowReads: true,
sidebarPreferences: true,
readState: true,
sidebarPreferenceWrites: true,
sidebarStarWrites: true,
agentLibrary: true,
live: true,
agentActivity: true,
Expand Down Expand Up @@ -758,6 +934,8 @@ export function relayBrokerPlugin({
![
"/api/relay/query",
"/api/relay/sign",
"/api/relay/channel-lifecycle-sign",
"/api/relay/channel-lifecycle-publish",
"/api/relay/publish",
"/api/relay/read-state-sign",
"/api/relay/read-state-publish",
Expand Down Expand Up @@ -888,10 +1066,26 @@ export function relayBrokerPlugin({
sent: false,
});
const timings = [];
const signing = route === "/api/relay/sign";
const publishing = route === "/api/relay/publish";
const lifecycle =
route === "/api/relay/channel-lifecycle-sign" ||
route === "/api/relay/channel-lifecycle-publish";
const signing =
route === "/api/relay/sign" ||
route === "/api/relay/channel-lifecycle-sign";
const publishing =
route === "/api/relay/publish" ||
route === "/api/relay/channel-lifecycle-publish";
if (signing || publishing) {
if (![7, 9].includes(filters?.kind)) {
if (lifecycle) {
try {
validateLifecycleTemplate(filters);
} catch {
return json(res, 400, {
error: "Invalid channel lifecycle command",
sent: false,
});
}
} else if (![7, 9].includes(filters?.kind)) {
try {
validateWorkflowEvent(
{ ...filters, pubkey: signing ? viewer : filters.pubkey },
Expand Down
Loading