1import { describe, expect, it } from "vitest";2import { env, runInDurableObject } from "cloudflare:test";3import { generateSecretKey } from "nostr-tools/pure";4import { npubEncode } from "nostr-tools/nip19";5import { DEFAULT_POLICY, featureOn } from "../../src/settings.ts";6import { KIND_GIT_ISSUE, KIND_GIT_PR, KIND_REPO, KIND_REPO_STATE } from "../../src/kinds.ts";7import { graspSyncTick, eventSource, GRASP_SYNC_PAGE, type GraspSyncConnect } from "../../src/grasp-sync.ts";8import { nip11 } from "../../src/nip11.ts";9import { now, type Event } from "../../src/event.ts";10import { ev, pk, rpc } from "../helpers/relay.ts";11import type { Relay } from "../../src/relay.ts";1213const SOURCE = "wss://history.example";14async function scope(name: string, action: (relay: Relay, owner: Uint8Array, host: string) => Promise<void>) {15 const owner = generateSecretKey(), host = `${name}.bind.ws`;16 await rpc(host, owner, "claim");17 await runInDurableObject(env.RELAY.getByName(name), async (relay, context) => {18 await context.storage.deleteAlarm();19 for (let i = 0; relay.repositoryAccess.busy && i < 100; i++) await new Promise(resolve => setTimeout(resolve, 10));20 await relay.repositoryAccess.run("alarm", async () => {21 relay.settings.update({ writes: "open", reads: "open", features: { ...relay.settings.policy.features, grasp: true, grasp02: true, grasp03: false, grasp05: false } });22 try { await action(relay, owner, host); }23 finally { relay.settings.update({ features: { ...relay.settings.policy.features, grasp02: false, grasp03: false, grasp05: false } }); await context.storage.deleteAlarm(); }24 }, () => { throw new Error("fixture did not acquire authority"); });25 });26}27function seed(relay: Relay, owner: Uint8Array, host: string) {28 const repo = ev(owner, KIND_REPO, "", [["d", "repo"], ["clone", `https://${host}/${npubEncode(pk(owner))}/repo.git`], ["relays", `wss://${host}`, SOURCE]]);29 expect(relay.accept(repo, relay.virtualConn(host, pk(owner))).ok).toBe(true);30 relay.sql.exec("DELETE FROM grasp_pending WHERE id=?", repo.id);31 return { repo, coordinate: `30617:${pk(owner)}:repo` };32}33function fake(reply: (source: string, sub: string, filters: unknown[]) => unknown[][]) {34 const sent: { source: string; m: unknown[] }[] = [];35 let connections = 0, closed = 0;36 const connect: GraspSyncConnect = async (_relay, source) => {37 connections++;38 const queue: unknown[][] = [];39 return {40 send(...m) { sent.push({ source, m }); if (m[0] === "REQ") queue.push(...reply(source, String(m[1]), m.slice(2))); },41 recv() { const m = queue.shift(); return m ? Promise.resolve(m) : Promise.reject(new Error("idle")); },42 close() { closed++; },43 };44 };45 return { connect, sent, counts: () => ({ connections, closed }) };46}47const empty = (_source: string, sub: string) => [["EOSE", sub]];48const has = (relay: Relay, event: Event) => relay.sql.exec("SELECT id FROM events WHERE id=?", event.id).toArray().length > 0;4950describe("GRASP event synchronization", () => {51 it("keeps later profiles opt-in and dependency gated", () => {52 const legacy = { ...DEFAULT_POLICY, features: { ...DEFAULT_POLICY.features, grasp: true } };53 delete (legacy.features as Partial<typeof legacy.features>).grasp02;54 expect(featureOn(legacy, "grasp02")).toBe(false);55 for (const name of ["grasp02", "grasp03", "grasp05", "grasp06"] as const) expect(featureOn(DEFAULT_POLICY, name)).toBe(false);56 expect(featureOn({ ...DEFAULT_POLICY, features: { ...DEFAULT_POLICY.features, grasp02: true } }, "grasp02")).toBe(false);57 expect(featureOn({ ...DEFAULT_POLICY, features: { ...DEFAULT_POLICY.features, grasp: true, grasp03: true, grasp05: true } }, "grasp05")).toBe(false);58 expect(featureOn({ ...DEFAULT_POLICY, features: { ...DEFAULT_POLICY.features, grasp: true, grasp02: true, grasp03: true } }, "grasp03")).toBe(true);59 expect(featureOn({ ...DEFAULT_POLICY, features: { ...DEFAULT_POLICY.features, grasp: true, grasp06: true } }, "grasp06")).toBe(true);60 });6162 it("schedules a wake when synchronization is enabled on an otherwise idle relay", async () => {63 const host = "grasp-sync-enable.bind.ws", owner = generateSecretKey();64 await rpc(host, owner, "claim");65 expect((await rpc(host, owner, "setpolicy", { features: { grasp: true, grasp02: true } })).status).toBe(200);66 await runInDurableObject(env.RELAY.getByName("grasp-sync-enable"), async (relay, context) => {67 expect(await context.storage.getAlarm()).toBeLessThanOrEqual(Date.now() + 2000);68 relay.settings.update({ features: { ...relay.settings.policy.features, grasp02: false } });69 await context.storage.deleteAlarm();70 });71 });7273 it("imports signed state and PRs with 03 off, checks scope and subscription, and honors normal write policy", async () => {74 await scope("grasp-sync-scope", async (relay, owner, host) => {75 const { coordinate } = seed(relay, owner, host), other = generateSecretKey();76 const state = ev(owner, KIND_REPO_STATE, "", [["d", "repo"]]);77 const pr = ev(other, KIND_GIT_PR, "", [["a", coordinate], ["c", "a".repeat(40)], ["clone", "https://git.example/repo.git"]]);78 const unrelated = ev(other, KIND_REPO_STATE, "", [["d", "repo"]]);79 const wrongSub = ev(other, KIND_GIT_ISSUE, "wrong subscription", [["a", coordinate]]);80 const blocked = ev(other, KIND_GIT_ISSUE, "blocked author", [["a", coordinate]]);81 relay.settings.setBan(pk(other), true);82 const f = fake((_source, sub) => sub.includes("history") ? [["EVENT", sub, unrelated], ["EVENT", "wrong", wrongSub], ["EVENT", sub, blocked], ["EVENT", sub, state], ["EOSE", sub]] : empty("", sub));83 await graspSyncTick(relay, f.connect);84 expect(has(relay, state)).toBe(true);85 for (const e of [unrelated, wrongSub, blocked]) expect(has(relay, e)).toBe(false);86 expect(relay.sql.exec<{ error: string }>("SELECT error FROM grasp_event_sync").one().error).toBe("admission-gap");87 relay.settings.setBan(pk(other), false);88 relay.sql.exec("UPDATE grasp_event_sync SET due=0");89 const p = fake((_source, sub) => [["EVENT", sub, pr], ["EOSE", sub]]);90 await graspSyncTick(relay, p.connect);91 expect(has(relay, pr)).toBe(true);92 expect(p.counts()).toEqual({ connections: 1, closed: 1 });93 });94 });9596 it("imports post-EOSE outbox replies without a tags and discovers only write relays", async () => {97 await scope("grasp-sync-outbox", async (relay, owner, host) => {98 const { coordinate } = seed(relay, owner, host), author = generateSecretKey(), responder = generateSecretKey();99 const issue = ev(author, KIND_GIT_ISSUE, "issue", [["a", coordinate]]);100 const list = ev(author, 10002, "", [["r", "wss://outbox.example", "write"], ["r", "wss://read.example", "read"]]);101 for (const e of [issue, list]) expect(relay.accept(e, relay.virtualConn(host, pk(owner))).ok).toBe(true);102 relay.settings.update({ features: { ...relay.settings.policy.features, grasp03: true } });103 await graspSyncTick(relay, fake(empty).connect);104 const rows = relay.sql.exec<{ id: string; source: string; scope: string }>("SELECT id,source,scope FROM grasp_event_sync").toArray();105 expect(rows.some(r => r.source.includes("read.example"))).toBe(false);106 const target = rows.find(r => r.source.includes("outbox.example") && JSON.parse(r.scope).mode === "thread")!;107 expect(target).toBeDefined();108 relay.sql.exec("UPDATE grasp_event_sync SET due=?", now() + 3600);109 relay.sql.exec("UPDATE grasp_event_sync SET due=0 WHERE id=?", target.id);110 const reply = ev(responder, 1111, "after EOSE", [["E", issue.id]]);111 const f = fake((_source, sub) => sub.includes("live") ? [["EOSE", sub], ["EVENT", sub, reply]] : empty("", sub));112 await graspSyncTick(relay, f.connect);113 expect(has(relay, reply)).toBe(true);114 expect(f.counts()).toEqual({ connections: 1, closed: 1 });115 expect(f.sent.some(({ m }) => JSON.stringify(m).includes('"#E"'))).toBe(true);116 await graspSyncTick(relay, fake(empty).connect);117 const metadata = relay.sql.exec<{ scope: string }>("SELECT scope FROM grasp_event_sync").toArray().map(r => JSON.parse(r.scope)).filter(s => s.mode === "metadata");118 expect(metadata.some(s => s.filters[0].authors.includes(pk(responder)) && s.filters[0].kinds.includes(10317))).toBe(true);119 relay.settings.update({ features: { ...relay.settings.policy.features, grasp03: false } });120 await graspSyncTick(relay, fake(empty).connect);121 expect(relay.sql.exec("SELECT id FROM grasp_event_sync WHERE source LIKE '%outbox.example%'").toArray()).toHaveLength(0);122 });123 });124125 it("retains incomplete and saturated history, never skips a full timestamp, and reserves retries", async () => {126 await scope("grasp-sync-history", async (relay, owner, host) => {127 const { coordinate } = seed(relay, owner, host);128 const noEose = fake((_source, sub) => [["EOSE", "wrong-" + sub]]);129 await graspSyncTick(relay, noEose.connect);130 const original = relay.sql.exec<{ windows: string; due: number; failures: number }>("SELECT windows,due,failures FROM grasp_event_sync").one();131 expect(JSON.parse(original.windows)).toHaveLength(1);132 expect(original.failures).toBe(1);133 expect(original.due).toBeGreaterThan(now());134 await graspSyncTick(relay, noEose.connect);135 expect(noEose.counts().connections).toBe(1);136 const time = now() - 10, window = JSON.stringify([{ since: time, until: time }]);137 relay.sql.exec("UPDATE grasp_event_sync SET windows=?,due=0", window);138 const events = Array.from({ length: GRASP_SYNC_PAGE }, (_, i) => ev(owner, KIND_GIT_ISSUE, String(i), [["a", coordinate]], time));139 const full = fake((_source, sub) => sub.includes("history") ? [...events.map(e => ["EVENT", sub, e]), ["EOSE", sub]] : empty("", sub));140 await graspSyncTick(relay, full.connect);141 const row = relay.sql.exec<{ windows: string; error: string; live_since: number }>("SELECT windows,error,live_since FROM grasp_event_sync").one();142 expect(row.windows).toBe(window);143 expect(row.error).toBe("partial-single-second");144 expect(row.live_since).toBeGreaterThanOrEqual(time);145 expect(has(relay, events[255])).toBe(true);146 });147 });148149 it("rejects unsafe sources and stays idle when disabled", async () => {150 await scope("grasp-sync-disabled", async (relay, owner, host) => {151 seed(relay, owner, host);152 for (const source of [`wss://${host}`, "wss://localhost", "wss://127.0.0.1", "ws://source.example", "wss://a:b@source.example", "wss://source.example/path?secret=x"]) expect(eventSource(relay, source)).toBeNull();153 const f = fake(empty);154 relay.settings.update({ features: { ...relay.settings.policy.features, grasp02: false } });155 expect(await graspSyncTick(relay, f.connect)).toBe(0);156 expect(f.counts().connections).toBe(0);157 });158 });159160 it("archives unlisted announcements only with 05 enabled and counts them toward the quota", async () => {161 await scope("grasp-sync-archive", async (relay, owner, host) => {162 relay.settings.update({ git: { ...relay.settings.policy.git, maxRepositories: 16 } });163 const maintainer = generateSecretKey();164 const announcement = (n: number) => ev(owner, KIND_REPO, "", [["d", `archive-${n}`], ["maintainers", pk(maintainer)], ["clone", `https://git.example/archive-${n}.git`], ["relays", SOURCE]]);165 expect(relay.accept(announcement(0), relay.virtualConn(host, pk(owner))).ok).toBe(false);166 relay.settings.update({ features: { ...relay.settings.policy.features, grasp05: true } });167 expect(nip11(relay, host).supported_grasps).toEqual(["GRASP-01"]);168 expect(nip11(relay, host).repo_acceptance_criteria).toContain("without naming this service");169 for (let n = 0; n < 16; n++) expect(relay.accept(announcement(n), relay.virtualConn(host, pk(owner))).ok).toBe(true);170 expect(relay.accept(announcement(16), relay.virtualConn(host, pk(owner))).msg).toContain("at most 16 repositories");171 expect(relay.sql.exec("SELECT * FROM grasp_hosted").toArray()).toHaveLength(16);172 await graspSyncTick(relay, fake(empty).connect);173 expect(relay.sql.exec("SELECT * FROM grasp_event_sync").toArray()).toHaveLength(16);174 relay.settings.update({ features: { ...relay.settings.policy.features, grasp05: false } });175 const companion = ev(maintainer, KIND_REPO, "", [["d", "archive-0"]]);176 expect(relay.accept(companion, relay.virtualConn(host, pk(maintainer))).ok).toBe(true);177 expect(relay.sql.exec("SELECT * FROM grasp_hosted").toArray()).toHaveLength(16);178 });179 });180});