clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

manage: authenticated requests acquire authority after their bodies arrive

Dami committed 6a6abf5
commit
6a6abf5c169ac9567fc5126f4227259ff98e52fb
parent
5272f96ddf6f505fd91f1c8f27418c179afc5b60
author
Dami <dami.etoile@gmail.com>
5 files changed, +82 -34
Mdocs/10-architecture.md2+
Msrc/manage.ts64+++++++++++++++---------------
Msrc/relay.ts4+-
Msrc/routes.ts3+-
Mtest/object/grasp-coordination.test.ts43++++++++++++++++++++-
M docs/10-architecture.md +2 -0
M src/manage.ts +33 -31
@@ -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 }
M src/relay.ts +3 -1
M src/routes.ts +2 -1
M test/object/grasp-coordination.test.ts +42 -1
@@ -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" },