Exercise membership, delegation, NIP-98 request binding and MCP results with local identities. Reconcile overlapping histories between two relays and check private Git challenges for anonymous and unrelated signers. Keep repeated proof generation unique and restore tenant policy even when cleanup fails.
- commit
- b174a740aa0487bcdf8a899e2514959683a20741
- parent
- f90315b7cbeaee261066160278feb9bea528c041
- author
- Dami <dami.etoile@gmail.com>
| M | scripts/ | 15 | ++ |
| A | scripts/ | 284 | ++++++++++++++++++++++++++++++ |
| A | scripts/ | 87 | +++++++++ |
| M | scripts/ | 14 | +- |
| M | scripts/ | 3 | + |
@@ -13,3 +13,18 @@ Each run preserves `environment.txt`, `stdout.log`, `stderr.log`, and `status` u
1313 The NIP-11 test does not require a numeric `limitation.max_limit` because self-hosted relays have no hosted quota.1414 1515 The GRASP-08 stock Git test is opt-in because it changes the tenant policy to a members-only private service. Run it against a disposable tenant with `TINY_GRASP08=1 TINY_PREPROVISIONED=1 CLAIM_SK=<owner-secret> RELAY_URL=ws://127.0.0.1:<port> node scripts/conformance/run.mjs grasp08`. It publishes temporary repository metadata and uses one reusable repository-root proof for push, clone and fetch.16+17+The combined-host actor profile is opt-in and mutates membership, policy and agent state on a disposable tenant. It uses deterministic process-local keys derived from `CONFORMANCE_SEED` plus the labels `member`, `agent` and `outsider`; `CLAIM_SK` is the local owner key. Run it only against the combined host, with the tenant already provisioned:18+19+```sh20+CONFORMANCE_ACTORS=1 CONFORMANCE_SEED=slate-docker-2026-09-13 \21+ CLAIM_SK=<local-owner-secret> TINY_PREPROVISIONED=1 \22+ RELAY_URL=ws://127.0.0.1:17477 \23+ node scripts/conformance/run.mjs actor-authz24+```25+26+The profile checks owner, member and outsider visibility over WebSocket, the HTTP bridge and MCP, then grants and revokes an agent and checks NIP-98 method, body and path binding. It restores the original policy in cleanup. Use a disposable tenant and set `RELAY_URL` to its configured canonical URL.27+28+The cross-instance profile uses four local signing identities and an independent TypeScript negentropy client to reconcile 192 events split between two relays. Set `RELAY_SECONDARY_URL` to a second disposable relay and run `node scripts/conformance/run.mjs cross-relay` with `RELAY_URL`, `CLAIM_SK` and `TINY_PREPROVISIONED=1`. It authenticates each identity on both connections, transfers missing events in both directions and checks that both histories converge. The primary relay must allow these test authors to publish.29+30+See [relay stress checks](../qa/relay-stress.md) for concurrent actor runs, slow readers and native protocol fuzzing.@@ -0,0 +1,284 @@
1+// Combined-host actor matrix. This is deliberately opt-in: it mutates a2+// disposable tenant policy, membership and agent state.3+import { describe, expect, it } from "vitest";4+import { randomUUID } from "node:crypto";5+import { finalizeEvent, getPublicKey } from "nostr-tools/pure";6+import { getToken } from "nostr-tools/nip98";7+import { hexToBytes } from "@noble/hashes/utils.js";8+import {9+ Client,10+ fixedKey,11+ HTTP_URL,12+ RELAY_URL,13+ newEvent,14+ now,15+} from "./helpers.ts";16+17+const enabled = process.env.CONFORMANCE_ACTORS === "1";18+const owner = process.env.CLAIM_SK ? hexToBytes(process.env.CLAIM_SK) : null;19+const member = fixedKey(20+ `${process.env.CONFORMANCE_SEED ?? "local-default"}:member`,21+);22+const outsider = fixedKey(23+ `${process.env.CONFORMANCE_SEED ?? "local-default"}:outsider`,24+);25+const agent = fixedKey(26+ `${process.env.CONFORMANCE_SEED ?? "local-default"}:agent`,27+);28+const mcpVersion = "2026-07-28";29+30+async 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+}48+49+async 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+}66+67+async 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+}80+81+async 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+}117+118+describe.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));142+143+ 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+ }165+166+ 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);177+178+ 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);198+199+ // 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);239+240+ 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+});@@ -0,0 +1,87 @@
1+import { describe, expect, it } from "vitest";2+import { hexToBytes } from "@noble/hashes/utils.js";3+import { Client, item, newEvent, newKey, now, RELAY_URL } from "./helpers.ts";4+5+const secondary = process.env.RELAY_SECONDARY_URL;6+7+describe.skipIf(!secondary)("cross-instance reconciliation", () => {8+ it("reconciles overlapping actor histories and converges both relays", async () => {9+ if (!process.env.CLAIM_SK || !secondary)10+ throw new Error(11+ "explicit disposable relay URLs and CLAIM_SK are required",12+ );13+ const keys = [14+ hexToBytes(process.env.CLAIM_SK),15+ newKey(),16+ newKey(),17+ newKey(),18+ ];19+ const tag = `cross-relay-${Date.now()}-${Math.random().toString(16).slice(2)}`;20+ const events = keys.flatMap((key, actor) =>21+ Array.from({ length: 48 }, (_, n) =>22+ newEvent(23+ key,24+ 1,25+ `actor ${actor} event ${n}`,26+ [["t", tag]],27+ now() - 100 + n,28+ ),29+ ),30+ );31+ const local = await Client.connect(RELAY_URL);32+ const remote = await Client.connect(secondary);33+ try {34+ for (const key of keys) {35+ await local.auth(key, RELAY_URL);36+ await remote.auth(key, secondary);37+ }38+ const left = events.filter((_, n) => n % 3 !== 0);39+ const right = events.filter((_, n) => n % 3 !== 1);40+ await Promise.all([41+ (async () => {42+ for (const event of left) await local.publish(event);43+ })(),44+ (async () => {45+ for (const event of right) await remote.publish(event);46+ })(),47+ ]);48+ const filter = { kinds: [1], "#t": [tag] };49+ const diff = await remote.sync(left.map(item), filter);50+ const leftIDs = new Set(left.map((event) => event.id));51+ const rightIDs = new Set(right.map((event) => event.id));52+ expect(diff.have).toEqual(53+ [...leftIDs].filter((id) => !rightIDs.has(id)).sort(),54+ );55+ expect(diff.need).toEqual(56+ [...rightIDs].filter((id) => !leftIDs.has(id)).sort(),57+ );58+ const missingHere = await remote.req({ ids: diff.need });59+ const missingThere = await local.req({ ids: diff.have });60+ for (const event of missingHere.events) await local.publish(event);61+ for (const event of missingThere.events) await remote.publish(event);62+ const expected = events.map((event) => event.id).sort();63+ for (const client of [local, remote]) {64+ const result = await client.req(filter);65+ expect(result.events.map((event) => event.id).sort()).toEqual(expected);66+ }67+ const converged = await remote.sync(68+ events.map(item),69+ filter,70+ "converged",71+ );72+ expect(converged.have).toEqual([]);73+ expect(converged.need).toEqual([]);74+ console.log(75+ JSON.stringify({76+ actors: keys.length,77+ events: events.length,78+ transferredEachWay: diff.have.length,79+ converged: true,80+ }),81+ );82+ } finally {83+ local.close();84+ remote.close();85+ }86+ }, 90_000);87+});@@ -10,7 +10,7 @@ import { npubEncode } from "nostr-tools/nip19";
1010 import { getToken } from "nostr-tools/nip98";1111 import { sha256 } from "@noble/hashes/sha2.js";1212 import { hexToBytes } from "@noble/hashes/utils.js";13-import { Client, HTTP_URL, RELAY_URL, now } from "./helpers.ts";13+import { Client, HTTP_URL, RELAY_URL, newKey, now } from "./helpers.ts";1414 1515 const exec = promisify(execFile);1616 const KIND_REPO = 30617;@@ -72,6 +72,18 @@ describe("GRASP-08 stock Git", () => {
7272 const identifier = `stock-${Math.random().toString(36).slice(2, 10)}`;7373 const repoURL = `${HTTP_URL}/${npub}/${encodeURIComponent(identifier)}.git`;7474 const proof = auth(sk, repoURL);75+ const outsiderProof = auth(newKey(), repoURL);76+ for (const service of ["git-upload-pack", "git-receive-pack"]) {77+ const endpoint = `${repoURL}/info/refs?service=${service}`;78+ const anonymous = await fetch(endpoint);79+ expect(anonymous.status).toBe(401);80+ expect(await anonymous.text()).toBe("");81+ expect(anonymous.headers.get("www-authenticate")).toBe(`Nostr method="GET"`);82+ const outsider = await fetch(endpoint, { headers: { authorization: outsiderProof } });83+ expect(outsider.status).toBe(401);84+ expect(await outsider.text()).toBe("");85+ expect(outsider.headers.get("www-authenticate")).toBe(`Nostr method="GET"`);86+ }7587 const c = await Client.connect(RELAY_URL);7688 try {7789 await c.auth(OWNER_SK, RELAY_URL);@@ -13,6 +13,9 @@ export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
1313 1414 export type Key = Uint8Array;1515 export const newKey = (): Key => generateSecretKey();16+// fixedKey gives multi-process conformance runs stable actors without ever17+// reading a signing key from the environment.18+export const fixedKey = (seed: string): Key => sha256(new TextEncoder().encode(`tinyrelay conformance actor:${seed}`));1619 export const pub = (sk: Key) => getPublicKey(sk);1720 1821 export function newEvent(sk: Key, kind: number, content: string, tags: string[][] = [], created_at = now()): NostrEvent {