- commit
- 6a6abf5c169ac9567fc5126f4227259ff98e52fb
- parent
- 5272f96ddf6f505fd91f1c8f27418c179afc5b60
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 2 | + |
| M | src/ | 64 | +++++++++++++++--------------- |
| M | src/ | 4 | +- |
| M | src/ | 3 | +- |
| M | test/ | 43 | ++++++++++++++++++++- |
@@ -169,6 +169,8 @@ ingestion and direct lease, adoption and teardown paths use the same admission
169169 seam; nested work owned by the active token proceeds, while another live170170 operation receives a retry response. Async context carries the token, so the171171 fence replaces separate Git and control flags without becoming a durable lock.172+Management reads and verifies the request before taking ownership, so an173+incomplete unauthenticated body cannot hold the relay's admission token.172174 It does not coordinate a crashed instance, another R2 client or another object173175 using the prefix, and it never authorizes garbage collection.174176 @@ -744,36 +744,38 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
744744 745745 const auth = verifyNIP98(req.headers.get("authorization") ?? "", req.url, req.method, bodyText);746746 if (typeof auth === "string") return reply({ error: auth }, 401);747- const s = relay.settings;748- const p = s.policy;749- const t = now();750- const caller = auth.pubkey;747+ return relay.repositoryAccess.run("control", async () => {748+ const s = relay.settings;749+ const p = s.policy;750+ const t = now();751+ const caller = auth.pubkey;751752 752- const role = s.roleOf(caller);753- const outranks = (pk: string) => role !== "owner" && (s.isOwner(pk) || s.roleOf(pk) === "moderator");754- const str = (i: number) => (typeof params[i] === "string" ? (params[i] as string) : "");755- const num = (i: number) => (Number.isInteger(params[i]) ? (params[i] as number) : parseInt(str(i), 10));756- const hex64 = (v: string) => /^[0-9a-f]{64}$/.test(v);757- const call: Call = { relay, req, s, p, t, caller, role, method, params, str, num, hex64, reply, outranks };758- if (m.action === "open") return m.run(call);759- // The heir may read the succession status: a member, with no console otherwise.760- if (method === "successionstatus" && p.succession && p.succession.heir === caller) return reply({ result: await relay.succession.status() });761- // A plain member reaches their own invites when the owner opened the762- // invite tree (memberInvites); the invite methods keep them to their own.763- const ownInvites = role === "member" && p.memberInvites.depth > 0 && (method === "createinvite" || method === "listinvites" || method === "revokeinvite");764- if (role === "owner") void relay.succession.seen(caller);765- if (!ownInvites && !can(role, m.action)) {766- const why = role === "moderator" ? "restricted: moderators cannot do that" : p.owner !== "" ? "restricted: not the relay owner" : s.isLeased() ? "restricted: this is a temporary relay; claim it first" : "restricted: this relay is unclaimed";767- return reply({ error: why }, 403);768- }769- // Every method that changed something and answered 200 goes in the770- // moderation log with who called it (audit.ts). deleterelay took the771- // table with it.772- const resp = await m.run(call);773- const dryRun = method === "importconfig" && (params[1] as { dryRun?: unknown } | undefined)?.dryRun === true;774- if (resp.status === 200 && !m.reads && !dryRun && method !== "deleterelay") {775- const { target, detail } = detailOf(method, params);776- relay.audit.record(t, caller, method, target, detail);777- }778- return resp;753+ const role = s.roleOf(caller);754+ const outranks = (pk: string) => role !== "owner" && (s.isOwner(pk) || s.roleOf(pk) === "moderator");755+ const str = (i: number) => (typeof params[i] === "string" ? (params[i] as string) : "");756+ const num = (i: number) => (Number.isInteger(params[i]) ? (params[i] as number) : parseInt(str(i), 10));757+ const hex64 = (v: string) => /^[0-9a-f]{64}$/.test(v);758+ const call: Call = { relay, req, s, p, t, caller, role, method, params, str, num, hex64, reply, outranks };759+ if (m.action === "open") return m.run(call);760+ // The heir may read the succession status: a member, with no console otherwise.761+ if (method === "successionstatus" && p.succession && p.succession.heir === caller) return reply({ result: await relay.succession.status() });762+ // A plain member reaches their own invites when the owner opened the763+ // invite tree (memberInvites); the invite methods keep them to their own.764+ const ownInvites = role === "member" && p.memberInvites.depth > 0 && (method === "createinvite" || method === "listinvites" || method === "revokeinvite");765+ if (role === "owner") void relay.succession.seen(caller);766+ if (!ownInvites && !can(role, m.action)) {767+ const why = role === "moderator" ? "restricted: moderators cannot do that" : p.owner !== "" ? "restricted: not the relay owner" : s.isLeased() ? "restricted: this is a temporary relay; claim it first" : "restricted: this relay is unclaimed";768+ return reply({ error: why }, 403);769+ }770+ // Every method that changed something and answered 200 goes in the771+ // moderation log with who called it (audit.ts). deleterelay took the772+ // table with it.773+ const resp = await m.run(call);774+ const dryRun = method === "importconfig" && (params[1] as { dryRun?: unknown } | undefined)?.dryRun === true;775+ if (resp.status === 200 && !m.reads && !dryRun && method !== "deleterelay") {776+ const { target, detail } = detailOf(method, params);777+ relay.audit.record(t, caller, method, target, detail);778+ }779+ return resp;780+ }, () => new Response(JSON.stringify({ error: "restricted: relay operation in progress; retry" }), { status: 429, headers: { ...cors, "retry-after": "1" } }));779781 }@@ -18,7 +18,7 @@ import { Settings, isReplaceable, isProtected, featureOn } from "./settings.ts";
1818 import { blobBytes, type Blob } from "./blossom.ts";1919 import { DIGEST_DAYS, digestText, notify, fuelLow, fuelText } from "./notify.ts";2020 import { Succession } from "./succession.ts";21-import { isGated, route } from "./routes.ts";21+import { isGated, isManagementRequest, route } from "./routes.ts";2222 import { guestPass, readGate, writeGate } from "./gates.ts";2323 import { Fuel, fuelConfig, acceptReceipt, type Fetcher, type LnurlParams } from "./fuel.ts";2424 import { Identity } from "./identity.ts";@@ -795,6 +795,8 @@ export class Relay extends DurableObject<Env> {
795795 if (site) return serveSite(this, req, site);796796 if (upgrade) return this.acceptWebSocket(req);797797 if (isGitPath(url)) return route(this, req, url);798+ // Management parses and verifies the request before acquiring authority.799+ if (isManagementRequest(req)) return completed(await route(this, req, url));798800 if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "OPTIONS") {799801 return completed(await this.repositoryAccess.run("control", () => route(this, req, url), () => new Response(JSON.stringify({ error: "restricted: relay operation in progress; retry" }), { status: 429, headers: { "content-type": "application/json", "access-control-allow-origin": "*", "retry-after": "1" } })));800802 }@@ -42,6 +42,7 @@ const is = (...paths: string[]) => (url: URL) => paths.includes(url.pathname);
4242 const under = (prefix: string) => (url: URL) => url.pathname.startsWith(prefix);4343 const get = (when: (url: URL) => boolean) => (url: URL, req: Request) => req.method === "GET" && when(url);4444 const post = (when: (url: URL) => boolean) => (url: URL, req: Request) => req.method === "POST" && when(url);45+export const isManagementRequest = (req: Request) => req.method === "POST" && (req.headers.get("content-type")?.includes("application/nostr+json+rpc") ?? false);4546 4647 export const ROUTES: Route[] = [4748 { when: isGitPath, gated: true, answer: grasp },@@ -51,7 +52,7 @@ export const ROUTES: Route[] = [
5152 answer: (relay, _, url) => Response.json(nip11(relay, url.host), { headers: { "content-type": "application/nostr+json", ...CORS } }),5253 },5354 // NIP-86 management, by the content type.54- { when: (_, req) => req.method === "POST" && (req.headers.get("content-type")?.includes("application/nostr+json+rpc") ?? false), answer: manage },55+ { when: (_, req) => isManagementRequest(req), answer: manage },5556 // The HTTP bridge (bridge.ts).5657 { when: post(is("/events", "/query", "/count")), gated: true, answer: bridge },5758 // NIP-05 names, under the read rule for whoever asks.@@ -6,10 +6,51 @@ import { generateSecretKey } from "nostr-tools/pure";
66 import { npubEncode } from "nostr-tools/nip19";77 import { KIND_REPO, KIND_REPO_STATE } from "../../src/kinds.ts";88 import { gitRepositoryPath } from "../../src/grasp-policy.ts";9-import { ev, pk, rpc } from "../helpers/relay.ts";9+import { ev, nip98, pk, rpc } from "../helpers/relay.ts";1010 import { WS } from "../helpers/ws.ts";1111 1212 describe("GRASP coordination", () => {13+ it("keeps admission free while a management body is incomplete", async () => {14+ const host = "grasp-management-body.bind.ws";15+ const owner = generateSecretKey();16+ await rpc(host, owner, "claim");17+ await rpc(host, owner, "applypreset", "grasp");18+ const path = gitRepositoryPath(npubEncode(pk(owner)), "body");19+ const connection = await WS.connect(host);20+ expect((await connection.ok(ev(owner, KIND_REPO, "", [["d", "body"], ["clone", `https://${host}${path}`], ["relays", `wss://${host}`]]))).ok).toBe(true);21+ connection.ws.close();22+ await runInDurableObject(env.RELAY.getByName("grasp-management-body"), async (relay) => {23+ const url = `http://${host}/`;24+ const payload = { method: "setpolicy", params: [{ name: "body completed" }] };25+ const authorization = await nip98(owner, url, "POST", payload);26+ let controller!: ReadableStreamDefaultController<Uint8Array>;27+ const body = new ReadableStream<Uint8Array>({ start(value) { controller = value; } });28+ const active = relay.fetch(new Request(url, { method: "POST", headers: { authorization, "content-type": "application/nostr+json+rpc" }, body }));29+ let closed = false;30+ try {31+ await Promise.resolve();32+ expect(relay.repositoryAccess.busy).toBe(false);33+ const git = await relay.fetch(new Request(`http://${host}${path}`));34+ expect(git.status).toBe(200);35+ await git.arrayBuffer();36+ const admitted = await relay.acceptAny(ev(owner, 1, "while the body waits"), relay.virtualConn(host, pk(owner)));37+ expect(admitted.ok).toBe(true);38+ expect(relay.settings.policy.name).not.toBe("body completed");39+ controller.enqueue(new TextEncoder().encode(JSON.stringify(payload)));40+ controller.close();41+ closed = true;42+ const response = await active;43+ expect(response.status).toBe(200);44+ await response.arrayBuffer();45+ expect(relay.settings.policy.name).toBe("body completed");46+ expect(relay.repositoryAccess.busy).toBe(false);47+ } finally {48+ if (!closed) { controller.enqueue(new TextEncoder().encode(JSON.stringify(payload))); controller.close(); }49+ await active;50+ }51+ });52+ });53+1354 it.each([1455 { mode: "http", outcome: "success" }, { mode: "http", outcome: "failure" },1556 { mode: "alarm", outcome: "success" }, { mode: "alarm", outcome: "failure" },