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
46 changes: 41 additions & 5 deletions dev/builderlab.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const API = "https://app.builderlab.xyz/api/goose";
// Builderlab checks Origin on identity binding; it also seeds the challenge origin.
export const BUILDERLAB_ORIGIN = "https://app.builderlab.xyz";
const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
const MAX_RESPONSE_BYTES = 64 * 1024;
const RESPONSE_STATUS = Symbol("builderlabResponseStatus");
const COMPLETE_HTML =
"<!doctype html><meta charset=utf-8><title>Buzz authentication complete</title><p>You're signed in. You can close this window and return to Buzz.";

Expand All @@ -22,13 +24,21 @@ const ROUTES = {
create: ["/v1/buzz/communities", ["name"]],
archive: ["/v1/buzz/communities/archive", ["community_id"]],
unarchive: ["/v1/buzz/communities/unarchive", ["community_id"]],
delete: [
"/v1/buzz/communities/delete",
["community_id", "host", "request_id", "acknowledgement_version"],
],
// Builderlab's transfer endpoint takes camelCase keys.
transfer: [
"/v1/buzz/communities/transfer",
["communityId", "transfereeNpub"],
],
};

/** Upstream status is metadata, not part of the public JSON body. */
export const builderlabResponseStatus = (value) =>
value?.[RESPONSE_STATUS] ?? 200;

/** Signs the kind 24243 challenge exactly as block/buzz desktop does, after the same checks. */
export function bindingEvent(key, challenge, now = Date.now()) {
const { challenge_id, nonce, verification_code, origin, expires_at } =
Expand Down Expand Up @@ -161,10 +171,23 @@ export function createBuilderlab({
redirect: "error",
signal: AbortSignal.timeout(60000),
});
const value = await response.json().catch(() => undefined);
const text = await response.text();
if (Buffer.byteLength(text) > MAX_RESPONSE_BYTES)
throw new Error("Builderlab response was too large");
let value;
try {
value = JSON.parse(text);
} catch {
throw new Error("Builderlab returned an invalid response");
}
// Structured `{ error: { code, ... } }` bodies pass through for friendly UI messages.
if (value && typeof value === "object" && (response.ok || value.error))
if (value && typeof value === "object" && (response.ok || value.error)) {
Object.defineProperty(value, RESPONSE_STATUS, {
value: response.status,
enumerable: false,
});
return value;
}
throw new Error(`Builderlab request failed (HTTP ${response.status}).`);
};
const me = async (session, signal) => {
Expand All @@ -177,8 +200,16 @@ export function createBuilderlab({
throw new Error(
`Builderlab session check failed with HTTP ${response.status}`,
);
const { email, name, expires_at } = await response.json();
return { email, name, expiresAt: expires_at };
const { email, name, expires_at, capabilities } = await response.json();
return {
email,
name,
expiresAt: expires_at,
capabilities: {
can_delete_buzz_communities:
capabilities?.can_delete_buzz_communities === true,
},
};
};
// Sign-in and sign-out bump the generation; late results from an older one never
// write, clear or describe the current session.
Expand Down Expand Up @@ -271,7 +302,12 @@ export function createBuilderlab({
const body = {};
for (const field of fields) {
const value = input?.[field];
if (typeof value !== "string" || !value || value.length > 200)
if (field === "acknowledgement_version") {
if (!Number.isInteger(value)) throw new Error(`Missing ${field}`);
body[field] = value;
continue;
}
if (typeof value !== "string" || !value || value.length > 253)
throw new Error(`Missing ${field}`);
body[field] = value;
}
Expand Down
86 changes: 86 additions & 0 deletions dev/builderlab.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools";
import {
BUILDERLAB_ORIGIN,
bindingEvent,
builderlabResponseStatus,
createBuilderlab,
} from "./builderlab.mjs";

Expand Down Expand Up @@ -107,6 +108,7 @@ it("completes browser sign-in through a loopback callback without exposing the c
email: "a@example.com",
name: "A",
expiresAt: "2030",
capabilities: { can_delete_buzz_communities: false },
});
expect(JSON.stringify(auth)).not.toContain("secret");
expect(h.opened().pathname).toBe("/api/goose/v1/auth/login");
Expand Down Expand Up @@ -153,6 +155,90 @@ it("forwards only allowlisted fields and ignores unknown actions", async () => {
expect(await h.builderlab.call("../auth/me", {})).toBeUndefined();
});

it("forwards the exact deletion tuple for both admission and same-UUID replay", async () => {
const h = account({
"/v1/buzz/communities/delete": () =>
Response.json({ status: "submitted" }, { status: 202 }),
});
await signIn(h);
const request = {
community_id: "community",
host: "North.communities.buzz.xyz",
request_id: "11111111-1111-4111-8111-111111111111",
acknowledgement_version: 1,
owner_pubkey: "must-not-pass",
extra: "dropped",
};
const admitted = await h.builderlab.call("delete", request);
expect(builderlabResponseStatus(admitted)).toBe(202);
expect(h.requests.at(-1).path).toBe("/v1/buzz/communities/delete");
expect(JSON.parse(h.requests.at(-1).init.body)).toEqual({
community_id: request.community_id,
host: request.host,
request_id: request.request_id,
acknowledgement_version: 1,
});
const replay = await h.builderlab.call("delete", request);
expect(builderlabResponseStatus(replay)).toBe(202);
expect(h.requests.at(-1).path).toBe("/v1/buzz/communities/delete");
expect(JSON.parse(h.requests.at(-1).init.body)).toEqual({
community_id: request.community_id,
host: request.host,
request_id: request.request_id,
acknowledgement_version: 1,
});
expect(
h.requests.filter((item) => item.path === "/v1/buzz/communities/delete"),
).toHaveLength(2);
});

it("does not expose a removed deletion receipt action", async () => {
const h = account({});
expect(await h.builderlab.call("delete-receipt", {})).toBeUndefined();
expect(h.requests).toHaveLength(0);
});

it.each([
[true, true],
["true", false],
[1, false],
[undefined, false],
])("maps delete capability %j to literal true=%s", async (value, expected) => {
const h = account({
"/v1/auth/me": () =>
Response.json({
email: "a@example.com",
expires_at: "2030",
capabilities: { can_delete_buzz_communities: value },
}),
});
expect((await signIn(h)).capabilities).toEqual({
can_delete_buzz_communities: expected,
});
});

it.each([
["malformed", "{"],
["oversize", JSON.stringify({ value: "x".repeat(70_000) })],
])("rejects a %s downstream response after dispatch", async (_label, body) => {
const h = account({
"/v1/buzz/communities/delete": () =>
new Response(body, {
status: 202,
headers: { "Content-Type": "application/json" },
}),
});
await signIn(h);
await expect(
h.builderlab.call("delete", {
community_id: "community",
host: "north.communities.buzz.xyz",
request_id: "11111111-1111-4111-8111-111111111111",
acknowledgement_version: 1,
}),
).rejects.toThrow(/invalid response|too large/);
});

it("binds the local key by verifying a signed challenge and passes structured errors through", async () => {
const h = account({
"/v1/buzz/nostr-identities/challenge": () =>
Expand Down
72 changes: 71 additions & 1 deletion dev/relay-broker-api.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ beforeEach(() => {
afterEach(() => vi.restoreAllMocks());

// Real browser HTTP -> production broker. Ephemeral key; upstream I/O is entirely local.
async function harness(respond, capabilities = {}, relayUrl = fixtureRelayUrl) {
async function harness(
respond,
capabilities = {},
relayUrl = fixtureRelayUrl,
builderlab = {},
) {
const key = new Uint8Array(32);
key[31] = 7;
const viewer = getPublicKey(key);
Expand All @@ -51,6 +56,7 @@ async function harness(respond, capabilities = {}, relayUrl = fixtureRelayUrl) {
});
const plugin = relayBrokerPlugin({
relayUrl,
builderlab,
communityAliases: fixtureAliases,
identity: () => key,
socketFactory: socket.factory,
Expand Down Expand Up @@ -125,6 +131,70 @@ async function harness(respond, capabilities = {}, relayUrl = fixtureRelayUrl) {
};
}
const filters = [{ kinds: [0], limit: 1 }];

test.each([
[202, { status: "aborted" }],
[409, { error: { code: "must_archive" } }],
])(
"Builderlab HTTP forwards structured deletion status %s",
async (status, result) => {
let openLogin;
const loginOpened = new Promise((resolve) => {
openLogin = resolve;
});
const request = {
community_id: "11111111-1111-4111-8111-111111111111",
host: "north.communities.buzz.xyz",
request_id: "22222222-2222-4222-8222-222222222222",
acknowledgement_version: 1,
};
const upstream = [];
const h = await harness(() => Response.json([]), {}, fixtureRelayUrl, {
open: async (url) => openLogin(url),
fetch: async (url, init) => {
const path = new URL(url).pathname;
if (path.endsWith("/v1/auth/login/exchange"))
return Response.json({
session_credential: "fixture-only",
expires_at: "2030",
});
if (path.endsWith("/v1/auth/me"))
return Response.json({
email: "fixture@example.com",
expires_at: "2030",
});
if (path.endsWith("/v1/buzz/communities/delete")) {
upstream.push(JSON.parse(init.body));
return Response.json({ ...request, ...result }, { status });
}
throw new Error(`Unexpected fixture request: ${path}`);
},
});
try {
const login = fetch(`${h.base}/api/builderlab/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const opened = new URL(await loginOpened);
const callback = opened.searchParams.get("returnTo");
expect(callback).toBeTruthy();
expect((await fetch(`${callback}?code=fixture`)).status).toBe(200);
expect((await login).status).toBe(200);
const response = await fetch(`${h.base}/api/builderlab/delete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
expect(response.status).toBe(status);
expect(await response.json()).toEqual({ ...request, ...result });
expect(upstream).toEqual([request]);
} finally {
await h.close();
}
},
);

const success = (call) =>
Response.json(
call.url.endsWith("/events")
Expand Down
4 changes: 2 additions & 2 deletions dev/relay-broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ import {
readSnapshotCommunity,
} from "../src/features/relay/read-state-snapshot.ts";
import { readAgentLibrary } from "./agent-library.mjs";
import { createBuilderlab } from "./builderlab.mjs";
import { builderlabResponseStatus, createBuilderlab } from "./builderlab.mjs";
import {
decodeSidebarPreferences,
assertSidebarAssignmentIntent,
Expand Down Expand Up @@ -824,7 +824,7 @@ export function relayBrokerPlugin({
raw ? JSON.parse(raw) : {},
);
return result
? json(res, 200, result)
? json(res, builderlabResponseStatus(result), result)
: json(res, 404, { error: "Unknown Builderlab route" });
} catch (error) {
return json(res, 502, {
Expand Down
16 changes: 14 additions & 2 deletions docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,20 @@ personal groups and the existing + creation buttons, independently of this plugi
Hosted communities (`block.hosted-communities`) is a Block-specific bundled plugin
under Settings → Communities. It manages Block-hosted relays through a Builderlab
account: browser sign-in, binding the local Buzz identity (a locally signed kind
24243 challenge), and create/archive/unarchive/transfer. Joining stays in the
existing Add a community dialog; the card only copies the new relay address. Its
24243 challenge), and create/archive/unarchive/transfer. A server-declared,
default-off capability also exposes owner deletion for archived communities. The
card persists the bound four-field request before admission. A fresh request can
terminate on a known structured pre-admission code and HTTP status pair;
ambiguous first responses stay pending until an explicit same-UUID delete replay.
Only a tuple-bound non-aborted 202 confirms progress; an aborted 202 ends recovery
without claiming deletion. The card displays valid server quota when available;
without it, Create remains available and the server enforces its owner limit.
`can_create: false` alone disables Create; usage is informational and is never
estimated from visible rows. One origin-wide pending slot is
re-read and verified before dispatch; browser local storage has no atomic compare-and-set,
so exactly simultaneous contexts remain a documented client-side race;
it never signs deletion or infers acceptance from a missing list row. Joining
stays in the existing Add a community dialog; the card only copies the new relay address. Its
`/api/builderlab/*` routes live in the development broker (`dev/builderlab.mjs`),
which keeps the session credential and signing key in Node. Packaged builds ship no
broker, so this plugin cannot sign in or manage communities there until a native
Expand Down
Loading
Loading