clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / scripts / conformance / actor-authz.test.ts
actor-authz.test.ts 9.5K
1// Combined-host actor matrix. This is deliberately opt-in: it mutates a2// disposable tenant policy, membership and agent state.3import { describe, expect, it } from "vitest";4import { randomUUID } from "node:crypto";5import { finalizeEvent, getPublicKey } from "nostr-tools/pure";6import { getToken } from "nostr-tools/nip98";7import { hexToBytes } from "@noble/hashes/utils.js";8import {9 Client,10 fixedKey,11 HTTP_URL,12 RELAY_URL,13 newEvent,14 now,15} from "./helpers.ts";1617const enabled = process.env.CONFORMANCE_ACTORS === "1";18const owner = process.env.CLAIM_SK ? hexToBytes(process.env.CLAIM_SK) : null;19const member = fixedKey(20 `${process.env.CONFORMANCE_SEED ?? "local-default"}:member`,21);22const outsider = fixedKey(23 `${process.env.CONFORMANCE_SEED ?? "local-default"}:outsider`,24);25const agent = fixedKey(26 `${process.env.CONFORMANCE_SEED ?? "local-default"}:agent`,27);28const mcpVersion = "2026-07-28";2930async function nip98(31 url: string,32 method: string,33 sk: Uint8Array,34 body?: unknown,35): Promise<string> {36 return getToken(37 url,38 method,39 (event) =>40 finalizeEvent(41 { ...event, tags: [...event.tags, ["request_id", randomUUID()]] },42 sk,43 ),44 true,45 body,46 );47}4849async function rpc(50 sk: Uint8Array,51 method: string,52 params: unknown[] = [],53): Promise<{ response: Response; value: any }> {54 const body = { method, params };55 const response = await fetch(HTTP_URL, {56 method: "POST",57 headers: {58 "Content-Type": "application/nostr+json+rpc",59 Authorization: await nip98(HTTP_URL, "POST", sk, body),60 },61 body: JSON.stringify(body),62 });63 const value: any = await response.json();64 return { response, value };65}6667async function query(68 sk: Uint8Array,69 body = [{ kinds: [1], limit: 20 }],70): Promise<Response> {71 return fetch(`${HTTP_URL}/query`, {72 method: "POST",73 headers: {74 "Content-Type": "application/json",75 Authorization: await nip98(`${HTTP_URL}/query`, "POST", sk, body),76 },77 body: JSON.stringify(body),78 });79}8081async function mcp(82 sk: Uint8Array,83 method: string,84 name?: string,85 arguments_: Record<string, unknown> = {},86): Promise<Response> {87 const params: Record<string, unknown> = {88 _meta: {89 "io.modelcontextprotocol/protocolVersion": mcpVersion,90 "io.modelcontextprotocol/clientCapabilities": {},91 },92 };93 if (name) {94 params.name = name;95 params.arguments = arguments_;96 }97 const body = {98 jsonrpc: "2.0",99 id: `${method}-${Date.now()}`,100 method,101 params,102 };103 const path = `${HTTP_URL}/mcp`;104 return fetch(path, {105 method: "POST",106 headers: {107 "Content-Type": "application/json",108 Accept: "application/json",109 "MCP-Protocol-Version": mcpVersion,110 "Mcp-Method": method,111 ...(name ? { "Mcp-Name": name } : {}),112 Authorization: await nip98(path, "POST", sk, body),113 },114 body: JSON.stringify(body),115 });116}117118describe.skipIf(!enabled)("combined-host actors and authorization", () => {119 it("enforces membership, agent grants and NIP-98 request binding across transports", async () => {120 if (!owner) throw new Error("CLAIM_SK must be set for actor conformance");121 const original = await rpc(owner, "getpolicy");122 expect(original.response.ok, JSON.stringify(original.value)).toBe(true);123 expect(original.value.error).toBeUndefined();124 const policy = original.value.result;125 const memberPub = getPublicKey(member);126 const agentPub = getPublicKey(agent);127 try {128 const changed = { ...policy, reads: "members", writes: "open" };129 const set = await rpc(owner, "setpolicy", [changed]);130 expect(set.response.ok, JSON.stringify(set.value)).toBe(true);131 expect(set.value.error).toBeUndefined();132 const joined = await rpc(owner, "setmember", [133 memberPub,134 { role: "member" },135 ]);136 expect(joined.response.ok, JSON.stringify(joined.value)).toBe(true);137 expect(joined.value.error).toBeUndefined();138 // Policy and membership changes close subscriptions asynchronously after139 // the management response. Let that invalidation settle before opening140 // the actor sockets used by this matrix.141 await new Promise((resolve) => setTimeout(resolve, 250));142143 const ownerWS = await Client.connect(RELAY_URL);144 const memberWS = await Client.connect(RELAY_URL);145 const outsiderWS = await Client.connect(RELAY_URL);146 try {147 await ownerWS.auth(owner, RELAY_URL);148 await memberWS.auth(member, RELAY_URL);149 await outsiderWS.auth(outsider, RELAY_URL);150 const note = newEvent(owner, 1, `actor-matrix-${now()}`);151 await ownerWS.publish(note);152 for (const [client, visible] of [153 [ownerWS, true],154 [memberWS, true],155 [outsiderWS, false],156 ] as const) {157 client.send("REQ", `actor-${visible}`, { ids: [note.id] });158 if (visible) {159 expect((await client.expect("EVENT"))[2].id).toBe(note.id);160 await client.expect("EOSE");161 } else {162 await client.expectClosed("restricted");163 }164 }165166 const [ownerQuery, memberQuery, outsiderQuery] = await Promise.all([167 query(owner),168 query(member),169 query(outsider),170 ]);171 expect(ownerQuery.status).toBe(200);172 expect(memberQuery.status).toBe(200);173 expect(outsiderQuery.status).toBe(403);174 expect(175 (await memberQuery.json()).some((event: any) => event.id === note.id),176 ).toBe(true);177178 const ownerTools = await mcp(owner, "tools/list");179 const memberTools = await mcp(member, "tools/call", "list_rooms");180 const outsiderTools = await mcp(outsider, "tools/call", "list_rooms");181 expect(ownerTools.status).toBe(200);182 const ownerToolJSON: any = await ownerTools.json();183 expect(ownerToolJSON.error).toBeUndefined();184 expect(ownerToolJSON.result?.isError).not.toBe(true);185 expect(186 ownerToolJSON.result?.tools?.some(187 (tool: any) => tool.name === "list_rooms",188 ),189 ).toBe(true);190 expect(memberTools.status).toBe(200);191 const memberResult: any = await memberTools.json();192 expect(memberResult.error).toBeUndefined();193 expect(memberResult.result).toBeDefined();194 expect(memberResult.result.isError).not.toBe(true);195 expect(Array.isArray(memberResult.result.content)).toBe(true);196 expect(outsiderTools.status).toBe(200);197 expect((await outsiderTools.json()).result?.isError).toBe(true);198199 // NIP-98 binds all three request components independently.200 const body = [{ kinds: [1] }];201 const validToken = await nip98(202 `${HTTP_URL}/query`,203 "POST",204 member,205 body,206 );207 const wrongMethod = await fetch(`${HTTP_URL}/query`, {208 method: "POST",209 headers: {210 "Content-Type": "application/json",211 Authorization: await nip98(212 `${HTTP_URL}/query`,213 "GET",214 member,215 body,216 ),217 },218 body: JSON.stringify(body),219 });220 expect(wrongMethod.status).toBe(401);221 const wrongBody = await fetch(`${HTTP_URL}/query`, {222 method: "POST",223 headers: {224 "Content-Type": "application/json",225 Authorization: validToken,226 },227 body: JSON.stringify([{ kinds: [0] }]),228 });229 expect(wrongBody.status).toBe(401);230 const wrongPath = await fetch(`${HTTP_URL}/count`, {231 method: "POST",232 headers: {233 "Content-Type": "application/json",234 Authorization: validToken,235 },236 body: JSON.stringify(body),237 });238 expect(wrongPath.status).toBe(401);239240 const grant = newEvent(owner, 30392, "", [241 ["d", agentPub],242 ["p", agentPub],243 ["name", "local-agent"],244 ["expiration", String(now() + 3600)],245 ["k", "1"],246 ]);247 await ownerWS.publish(grant);248 const agentWS = await Client.connect(RELAY_URL);249 try {250 await agentWS.auth(agent, RELAY_URL);251 await agentWS.publish(newEvent(agent, 1, "agent-allowed"));252 const revoked = await rpc(owner, "revokeagent", [agentPub]);253 expect(revoked.response.ok, JSON.stringify(revoked.value)).toBe(true);254 expect(revoked.value.error).toBeUndefined();255 const revokedEvent = newEvent(agent, 1, "agent-revoked");256 agentWS.send("EVENT", revokedEvent);257 expect(await agentWS.expectOK(revokedEvent.id, false)).toBeDefined();258 } finally {259 agentWS.close();260 }261 const removed = await rpc(owner, "removemember", [memberPub]);262 expect(removed.response.ok, JSON.stringify(removed.value)).toBe(true);263 expect(removed.value.error).toBeUndefined();264 expect((await query(member, [{ kinds: [1], limit: 19 }])).status).toBe(265 403,266 );267 } finally {268 ownerWS.close();269 memberWS.close();270 outsiderWS.close();271 }272 } finally {273 try {274 const cleaned = await rpc(owner, "removemember", [memberPub]);275 expect(cleaned.response.ok, JSON.stringify(cleaned.value)).toBe(true);276 expect(cleaned.value.error).toBeUndefined();277 } finally {278 const restored = await rpc(owner, "setpolicy", [policy]);279 expect(restored.response.ok, JSON.stringify(restored.value)).toBe(true);280 expect(restored.value.error).toBeUndefined();281 }282 }283 }, 120_000);284});