- commit
- dbc664c37157583038672ace3cf09e825b88f08a
- parent
- ecec699f1907bd9d740ddff9274ea5122c66b81e
- author
- Dami <dami.etoile@gmail.com>
| M | test/ | 3 | + |
| M | test/ | 6 | + |
| M | test/ | 4 | +- |
| A | test/ | 203 | ++++++++++++++++++++++++++++++ |
| A | test/ | 179 | ++++++++++++++++++++++++++ |
| M | test/ | 5 | +- |
@@ -93,6 +93,8 @@ describe("portable backups", () => {
9393 relay.store.save(held, 12);9494 relay.settings.setEvent(held.id, "hide");9595 relay.sql.exec(`INSERT INTO grasp_pending(id,until) VALUES(?,?)`, held.id, 9999999999);96+ relay.sql.exec(`INSERT INTO grasp_pr_refs(repo,ref,until) VALUES(?,?,?)`, `pr:${pk(writer)}:repo`, `refs/nostr/${"a".repeat(64)}`, 9999999999);97+ relay.sql.exec(`INSERT INTO grasp_pr_refs(repo,ref,until) VALUES(?,?,?)`, `pr:${pk(writer)}:repo`, `refs/nostr/${"b".repeat(64)}`, 0);9698 await storeBlob(relay, new TextEncoder().encode("site bytes"), "text/plain", pk(writer), 10);9799 await relay.media.put("backup-source/git/test-object", new TextEncoder().encode("git bytes"));98100 await relay.store.save(ev(owner, 30390, '{"callback":"https://secret.invalid"}'), 10);@@ -115,6 +117,7 @@ describe("portable backups", () => {
115117 expect(preview.git).toBe(1);116118 await runInDurableObject(env.RELAY.getByName("backup-target"), async (relay: Relay) => {117119 expect(relay.settings.policy.name).toBe("Recovered");120+ expect(relay.sql.exec(`SELECT repo,ref,until FROM grasp_pr_refs ORDER BY ref`).toArray()).toEqual([{ repo: `pr:${pk(writer)}:repo`, ref: `refs/nostr/${"a".repeat(64)}`, until: 9999999999 }, { repo: `pr:${pk(writer)}:repo`, ref: `refs/nostr/${"b".repeat(64)}`, until: 0 }]);118121 expect(relay.store.listHistory(pk(owner), Math.floor(Date.now() / 1000))).toHaveLength(1);119122 expect(relay.settings.hiddenEvents.size).toBe(1);120123 expect(relay.sql.exec(`SELECT count(*) n FROM grasp_pending`).one().n).toBe(1);@@ -52,6 +52,10 @@ async function seed(host: string): Promise<Fixture> {
5252 function doors(f: Fixture): { path: string; host?: string; method?: string; gated: boolean; blossom?: string }[] {5353 return [5454 { path: `/${npubEncode(pk(f.eve))}/member.git`, gated: false },55+ { path: `/prs/${npubEncode(pk(f.eve))}/member.git`, gated: false },56+ { path: `/prs/${npubEncode(pk(f.eve))}/member.git/info/refs?service=git-upload-pack`, gated: false },57+ { path: `/prs/${npubEncode(pk(f.eve))}/member.git/git-upload-pack`, method: "POST", gated: false },58+ { path: `/prs/${npubEncode(pk(f.eve))}/member.git/git-receive-pack`, method: "POST", gated: false },5559 { host: f.siteHost, path: "/", gated: true },5660 { host: f.siteHost, path: "/index.html", method: "HEAD", gated: true },5761 { host: f.siteHost, path: "/.well-known/nsite/auth", gated: true },@@ -114,6 +118,8 @@ describe("the read rule at every door", () => {
114118 expect((await rpc(host, stranger, "gitstorage", pk(owner), "missing")).status).toBe(403);115119 const methods = (await rpc(host, owner, "supportedmethods")).result as string[];116120 expect(methods).toContain("gitstorage");121+ expect((await rpc(host, stranger, "storagestats")).status).toBe(403);122+ expect((await rpc(host, owner, "storagestats")).result.graspSync).toMatchObject({ enabled: false, partial: false, events: { jobs: 0, failed: 0, history: 0 }, git: { jobs: 0, failed: 0 } });117123 });118124 119125 it("shows a stranger nothing of a member, her note or her file", async () => {@@ -10,12 +10,12 @@ import { upload } from "../helpers/media.ts";
1010 const status = async (host: string, path: string) => (await get(host, path)).status;1111 1212 describe("features", () => {13- it("are all on by default, and setpolicy takes a map that survives export and import", async () => {13+ it("have explicit defaults, and setpolicy takes a map that survives export and import", async () => {1414 const host = "feat-default.bind.ws";1515 const owner = generateSecretKey();1616 await rpc(host, owner, "claim");1717 const p = (await rpc(host, owner, "getpolicy")).result;18- expect(p.features).toEqual({ search: "prose", sync: true, count: true, discovery: true, names: true, files: true, pages: true, signer: true, sites: { enabled: true, mirror: true }, marmot: false, grasp: false, push: false });18+ expect(p.features).toEqual({ search: "prose", sync: true, count: true, discovery: true, names: true, files: true, pages: true, signer: true, sites: { enabled: true, mirror: true }, marmot: false, grasp: false, grasp02: false, grasp03: false, grasp05: false, grasp06: false, push: false });1919 for (const n of [5, 45, 46, 50, 66, 77]) expect((await info(host)).supported_nips).toContain(n);2020 const r = await rpc(host, owner, "setpolicy", { features: { search: "full", sync: false, bogus: true, count: "no" } });2121 expect(r.result.features).toMatchObject({ search: "full", sync: false, count: true });@@ -0,0 +1,203 @@
1+// Extension tests exercise public PR transport and bounded signed-state sync in workerd.2+import { env, runInDurableObject } from "cloudflare:test";3+import { describe, expect, it } from "vitest";4+import { generateSecretKey } from "nostr-tools/pure";5+import { npubEncode } from "nostr-tools/nip19";6+import { createGitHandler, MemoryStore, NativeGitEngine, WalRepository, encodePack, readObjects } from "ntig";7+import { ev, pk, rpc } from "../helpers/relay.ts";8+import { KIND_REPO, KIND_REPO_STATE, KIND_GIT_PR } from "../../src/kinds.ts";9+import { gitRepository, graspTick } from "../../src/grasp.ts";10+import { repository, graspVisible } from "../../src/grasp-state.ts";11+import { syncGitTick, gitSource } from "../../src/grasp-git-sync.ts";12+import { nip11 } from "../../src/nip11.ts";13+import type { Relay } from "../../src/relay.ts";14+15+const enc = new TextEncoder();16+const packet = (text: string) => { const bytes = enc.encode(text); return new Uint8Array([...enc.encode((bytes.length + 4).toString(16).padStart(4, "0")), ...bytes]); };17+const join = (...parts: Uint8Array[]) => { const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); let at = 0; for (const p of parts) { out.set(p, at); at += p.length; } return out; };18+const post = async (relay: Relay, host: string, path: string, name: string, tip: string | null, pack?: Uint8Array, old: string | null = null, id = crypto.randomUUID()) => {19+ const response = await relay.fetch(new Request(`https://${host}${path}/git-receive-pack`, { method: "POST", headers: { "content-type": "application/x-git-receive-pack-request", "x-git-request-id": id }, body: join(packet(`${old ?? "0".repeat(40)} ${tip ?? "0".repeat(40)} ${name}\0report-status\n`), enc.encode("0000"), ...(pack ? [pack] : [])) }));20+ expect(response.status).toBe(200);21+ return response.text();22+};23+const advertisement = async (relay: Relay, host: string, path: string) => (await relay.fetch(new Request(`https://${host}${path}/info/refs?service=git-upload-pack`))).text();24+async function fixture() {25+ const pack = await encodePack([{ type: "tree", data: new Uint8Array() }]);26+ const tree = [...(await readObjects([pack])).keys()][0];27+ const full = await encodePack([{ type: "tree", data: new Uint8Array() }, { type: "commit", data: enc.encode(`tree ${tree}\nauthor Test <test@example.com> 1 +0000\ncommitter Test <test@example.com> 1 +0000\n\ncommit\n`) }]);28+ const tip = [...(await readObjects([full])).values()].find(o => o.type === "commit")!.oid;29+ return { pack: full, tip };30+}31+const hosted = (owner: Uint8Array, host: string, identifier: string) => ev(owner, KIND_REPO, "", [["d", identifier], ["clone", `https://${host}/${npubEncode(pk(owner))}/${identifier}.git`, "https://source.example/repo.git"], ["relays", `wss://${host}`]]);32+33+async function scope(name: string, action: (relay: Relay, owner: Uint8Array, host: string) => Promise<void>) {34+ const owner = generateSecretKey(), host = `${name}.bind.ws`;35+ await rpc(host, owner, "claim");36+ await runInDurableObject(env.RELAY.getByName(name), async (relay, context) => {37+ await context.storage.deleteAlarm();38+ for (let i = 0; relay.repositoryAccess.busy && i < 100; i++) await new Promise(resolve => setTimeout(resolve, 10));39+ await relay.repositoryAccess.run("alarm", async () => {40+ relay.settings.update({ writes: "open", reads: "open", features: { ...relay.settings.policy.features, grasp: true, grasp02: false, grasp06: true } });41+ try { await action(relay, owner, host); }42+ finally {43+ relay.settings.update({ features: { ...relay.settings.policy.features, grasp02: false, grasp06: false } });44+ await context.storage.deleteAlarm();45+ }46+ }, () => { throw new Error("fixture did not acquire authority"); });47+ });48+}49+50+describe("GRASP extension transport", () => {51+ it.each(["event-first", "git-first"])("serves %s alternative PRs without target announcements", async order => {52+ await scope(`grasp06-${order}`, async (relay, owner, host) => {53+ const { pack, tip } = await fixture();54+ const identifier = "with:colon";55+ const path = `/prs/${npubEncode(pk(owner))}/with%3Acolon.git`;56+ const e = ev(owner, KIND_GIT_PR, "", [["a", `30617:${"b".repeat(64)}:${identifier}`], ["clone", "https://foreign.example/repo.git", `https://${host}${path}`], ["c", tip]]);57+ expect(await advertisement(relay, host, path)).toContain("capabilities^{}");58+ expect(relay.sql.exec("SELECT * FROM grasp_objects").toArray()).toHaveLength(0);59+ const accept = () => relay.accept(e, relay.virtualConn(host, pk(owner)));60+ if (order === "event-first") expect(accept().ok).toBe(true);61+ expect(await post(relay, host, path, `refs/nostr/${e.id}`, tip, pack)).toContain(`ok refs/nostr/${e.id}`);62+ if (order === "git-first") { expect(accept().ok).toBe(true); await graspTick(relay); }63+ expect(graspVisible(relay, e.id)).toBe(true);64+ expect(await advertisement(relay, host, path)).toContain(tip);65+ expect(await post(relay, host, path, `refs/nostr/${e.id}`, null, undefined, tip)).toContain(`ng refs/nostr/${e.id}`);66+ expect(await post(relay, host, path, "refs/heads/main", tip, pack)).toContain("ng refs/heads/main");67+ const ordinary = path.replace("/prs", "");68+ expect((await relay.fetch(new Request(`https://${host}${ordinary}/info/refs?service=git-upload-pack`))).status).toBe(404);69+ });70+ });71+72+ it("advertises only enabled validated profiles and closes every alternative endpoint with restricted reads", async () => {73+ await scope("grasp06-private", async (relay, owner, host) => {74+ expect(nip11(relay, host).supported_grasps).toEqual(["GRASP-01", "GRASP-06"]);75+ relay.settings.update({ reads: "members" });76+ expect(nip11(relay, host).supported_grasps).toBeUndefined();77+ const path = `/prs/${npubEncode(pk(owner))}/repo.git`;78+ for (const [suffix, method] of [["", "GET"], ["/info/refs?service=git-upload-pack", "GET"], ["/git-upload-pack", "POST"], ["/git-receive-pack", "POST"]]) {79+ const r = await relay.fetch(new Request(`https://${host}${path}${suffix}`, { method }));80+ expect(r.status).toBe(403);81+ }82+ expect(relay.sql.exec("SELECT * FROM grasp_objects").toArray()).toHaveLength(0);83+ });84+ });85+86+ it("rejects wrong signer and foreign clone relationships without rejecting ordinary hosted PRs", async () => {87+ await scope("grasp06-authority", async (relay, owner, host) => {88+ const { tip } = await fixture();89+ const other = generateSecretKey();90+ const path = `/prs/${npubEncode(pk(owner))}/repo.git`;91+ for (const clone of [`https://foreign.example${path}`, `https://${host}/prs/${npubEncode(pk(other))}/repo.git`]) {92+ const e = ev(owner, KIND_GIT_PR, "", [["a", `30617:${pk(other)}:repo`], ["clone", clone], ["c", tip]]);93+ expect(relay.accept(e, relay.virtualConn(host, pk(owner))).ok).toBe(false);94+ }95+ const repo = hosted(owner, host, "repo");96+ expect(relay.accept(repo, relay.virtualConn(host, pk(owner))).ok).toBe(true);97+ const pr = ev(other, KIND_GIT_PR, "", [["a", `30617:${pk(owner)}:repo`], ["c", tip]]);98+ expect(relay.accept(pr, relay.virtualConn(host, pk(other))).ok).toBe(true);99+ });100+ });101+102+ it("keeps unknown expiry fixed across retries and keeps deleted accepted PRs hidden", async () => {103+ await scope("grasp06-expiry", async (relay, owner, host) => {104+ const { pack, tip } = await fixture();105+ const path = `/prs/${npubEncode(pk(owner))}/repo.git`, ref = `refs/nostr/${"a".repeat(64)}`;106+ expect(await post(relay, host, path, ref, tip, pack, null, "same-upload")).toContain(`ok ${ref}`);107+ relay.sql.exec("UPDATE grasp_pr_refs SET until=until-100 WHERE ref=?", ref);108+ const deadline = relay.sql.exec<{ until: number }>("SELECT until FROM grasp_pr_refs WHERE ref=?", ref).one().until;109+ expect(await post(relay, host, path, ref, tip, pack, null, "same-upload")).toContain(`ok ${ref}`);110+ expect(relay.sql.exec<{ until: number }>("SELECT until FROM grasp_pr_refs WHERE ref=?", ref).one().until).toBe(deadline);111+ relay.sql.exec("UPDATE grasp_pr_refs SET until=1 WHERE ref=?", ref);112+ expect(await advertisement(relay, host, path)).not.toContain(tip);113+ await graspTick(relay);114+ expect(relay.sql.exec("SELECT * FROM grasp_pr_refs WHERE ref=?", ref).toArray()).toHaveLength(0);115+ const e = ev(owner, KIND_GIT_PR, "", [["a", `30617:${pk(owner)}:repo`], ["clone", "https://foreign.example/repo.git", `https://${host}${path}`], ["c", tip]]);116+ expect(relay.accept(e, relay.virtualConn(host, pk(owner))).ok).toBe(true);117+ expect(await post(relay, host, path, `refs/nostr/${e.id}`, tip, pack)).toContain(`ok refs/nostr/${e.id}`);118+ relay.sql.exec("DELETE FROM events WHERE id=?", e.id);119+ expect(await advertisement(relay, host, path)).not.toContain(tip);120+ });121+ });122+123+ it("reconciles signed state including deletions and skips network for locally available data", async () => {124+ await scope("grasp02-state", async (relay, owner, host) => {125+ const { pack, tip } = await fixture();126+ const announcement = hosted(owner, host, "repo");127+ expect(relay.accept(announcement, relay.virtualConn(host, pk(owner))).ok).toBe(true);128+ const state = ev(owner, KIND_REPO_STATE, "", [["d", "repo"], ["HEAD", "ref: refs/heads/main"], ["refs/heads/main", tip]]);129+ expect(relay.accept(state, relay.virtualConn(host, pk(owner))).ok).toBe(true);130+ const source = new WalRepository(new MemoryStore(), new NativeGitEngine());131+ await source.commit({ id: "seed", pack, updates: [{ name: "refs/heads/foreign-name", old: null, new: tip }] });132+ const handler = createGitHandler(source);133+ let requests = 0;134+ relay.fetcher = async (url, init) => { requests++; return handler(new Request(url, init)); };135+ relay.settings.update({ features: { ...relay.settings.policy.features, grasp02: true } });136+ await syncGitTick(relay);137+ const wal = await gitRepository(relay, repository(relay, pk(owner), "repo")!);138+ expect((await wal.loadRefs()).refs).toEqual({ "refs/heads/main": tip });139+ expect(requests).toBe(2);140+ expect(graspVisible(relay, state.id)).toBe(true);141+ const next = ev(owner, KIND_REPO_STATE, "", [["d", "repo"], ["HEAD", "ref: refs/heads/release"], ["refs/heads/release", tip]], state.created_at + 1);142+ expect(relay.accept(next, relay.virtualConn(host, pk(owner))).ok).toBe(true);143+ await syncGitTick(relay);144+ expect((await wal.loadRefs()).refs).toEqual({ "refs/heads/release": tip });145+ expect(requests).toBe(2);146+ await syncGitTick(relay);147+ expect(requests).toBe(2);148+ });149+ });150+151+ it("imports accepted PR data into the target repository without requiring a local alternative clone URL", async () => {152+ await scope("grasp02-pr", async (relay, owner, host) => {153+ const { pack, tip } = await fixture();154+ expect(relay.accept(hosted(owner, host, "repo"), relay.virtualConn(host, pk(owner))).ok).toBe(true);155+ const contributor = generateSecretKey();156+ const pr = ev(contributor, KIND_GIT_PR, "", [["a", `30617:${pk(owner)}:repo`], ["clone", `https://${host}/self.git`, "https://source.example/repo.git"], ["c", tip]]);157+ expect(relay.accept(pr, relay.virtualConn(host, pk(contributor))).ok).toBe(true);158+ const source = new WalRepository(new MemoryStore(), new NativeGitEngine());159+ await source.commit({ id: "seed", pack, updates: [{ name: `refs/nostr/${pr.id}`, old: null, new: tip }] });160+ const handler = createGitHandler(source);161+ relay.fetcher = (url, init) => handler(new Request(url, init));162+ relay.settings.update({ features: { ...relay.settings.policy.features, grasp02: true } });163+ await syncGitTick(relay);164+ const wal = await gitRepository(relay, repository(relay, pk(owner), "repo")!);165+ expect((await wal.loadRefs()).refs[`refs/nostr/${pr.id}`]).toBe(tip);166+ expect(graspVisible(relay, pr.id)).toBe(true);167+ });168+ });169+170+ it("copies a locally hosted alternative PR into its target repository without self-fetching", async () => {171+ await scope("grasp02-local-pr", async (relay, owner, host) => {172+ const { pack, tip } = await fixture(), contributor = generateSecretKey();173+ expect(relay.accept(hosted(owner, host, "repo"), relay.virtualConn(host, pk(owner))).ok).toBe(true);174+ const path = `/prs/${npubEncode(pk(contributor))}/repo.git`;175+ const pr = ev(contributor, KIND_GIT_PR, "", [["a", `30617:${pk(owner)}:repo`], ["clone", `https://${host}${path}`], ["c", tip]]);176+ expect(relay.accept(pr, relay.virtualConn(host, pk(contributor))).ok).toBe(true);177+ expect(await post(relay, host, path, `refs/nostr/${pr.id}`, tip, pack)).toContain(`ok refs/nostr/${pr.id}`);178+ relay.fetcher = async () => { throw new Error("local PR must not use external fetch"); };179+ relay.settings.update({ features: { ...relay.settings.policy.features, grasp02: true } });180+ await syncGitTick(relay);181+ const target = await gitRepository(relay, repository(relay, pk(owner), "repo")!);182+ expect((await target.loadRefs()).refs).toEqual({ [`refs/nostr/${pr.id}`]: tip });183+ });184+ });185+186+ it("rejects unsafe sources and backs failures off without busy retrying", async () => {187+ await scope("grasp02-backoff", async (relay, owner, host) => {188+ for (const url of ["https://127.0.0.1/repo.git", "https://169.254.169.254/repo.git", "https://localhost/repo.git", "https://private.internal/repo.git", `https://${host}/repo.git`, "https://user:pass@source.example/repo.git", "https://source.example:8443/repo.git"]) expect(gitSource(relay, url)).toBeNull();189+ const { tip } = await fixture();190+ expect(relay.accept(hosted(owner, host, "repo"), relay.virtualConn(host, pk(owner))).ok).toBe(true);191+ expect(relay.accept(ev(owner, KIND_REPO_STATE, "", [["d", "repo"], ["refs/heads/main", tip]]), relay.virtualConn(host, pk(owner))).ok).toBe(true);192+ let calls = 0;193+ relay.fetcher = async () => { calls++; return new Response(null, { status: 302, headers: { location: "https://127.0.0.1/" } }); };194+ relay.settings.update({ features: { ...relay.settings.policy.features, grasp02: true } });195+ const next = await syncGitTick(relay);196+ expect(calls).toBe(1);197+ expect(next).toBeGreaterThan(Math.floor(Date.now() / 1000));198+ await syncGitTick(relay);199+ expect(calls).toBe(1);200+ expect((await (await gitRepository(relay, repository(relay, pk(owner), "repo")!)).loadRefs()).sequence).toBe(0);201+ });202+ });203+});@@ -0,0 +1,179 @@
1+import { describe, expect, it } from "vitest";2+import { env, runInDurableObject } from "cloudflare:test";3+import { generateSecretKey } from "nostr-tools/pure";4+import { npubEncode } from "nostr-tools/nip19";5+import { DEFAULT_POLICY, featureOn } from "../../src/settings.ts";6+import { KIND_GIT_ISSUE, KIND_GIT_PR, KIND_REPO, KIND_REPO_STATE } from "../../src/kinds.ts";7+import { graspSyncTick, eventSource, GRASP_SYNC_PAGE, type GraspSyncConnect } from "../../src/grasp-sync.ts";8+import { nip11 } from "../../src/nip11.ts";9+import { now, type Event } from "../../src/event.ts";10+import { ev, pk, rpc } from "../helpers/relay.ts";11+import type { Relay } from "../../src/relay.ts";12+13+const SOURCE = "wss://history.example";14+async 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+}27+function 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+}33+function 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+}47+const empty = (_source: string, sub: string) => [["EOSE", sub]];48+const has = (relay: Relay, event: Event) => relay.sql.exec("SELECT id FROM events WHERE id=?", event.id).toArray().length > 0;49+50+describe("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+ });61+62+ 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+ });72+73+ 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+ });95+96+ 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+ });124+125+ 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+ });148+149+ 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+ });159+160+ 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+ const maintainer = generateSecretKey();163+ const announcement = (n: number) => ev(owner, KIND_REPO, "", [["d", `archive-${n}`], ["maintainers", pk(maintainer)], ["clone", `https://git.example/archive-${n}.git`], ["relays", SOURCE]]);164+ expect(relay.accept(announcement(0), relay.virtualConn(host, pk(owner))).ok).toBe(false);165+ relay.settings.update({ features: { ...relay.settings.policy.features, grasp05: true } });166+ expect(nip11(relay, host).supported_grasps).toEqual(["GRASP-01"]);167+ expect(nip11(relay, host).repo_acceptance_criteria).toContain("without naming this service");168+ for (let n = 0; n < 16; n++) expect(relay.accept(announcement(n), relay.virtualConn(host, pk(owner))).ok).toBe(true);169+ expect(relay.accept(announcement(16), relay.virtualConn(host, pk(owner))).msg).toContain("at most 16 repositories");170+ expect(relay.sql.exec("SELECT * FROM grasp_hosted").toArray()).toHaveLength(16);171+ await graspSyncTick(relay, fake(empty).connect);172+ expect(relay.sql.exec("SELECT * FROM grasp_event_sync").toArray()).toHaveLength(16);173+ relay.settings.update({ features: { ...relay.settings.policy.features, grasp05: false } });174+ const companion = ev(maintainer, KIND_REPO, "", [["d", "archive-0"]]);175+ expect(relay.accept(companion, relay.virtualConn(host, pk(maintainer))).ok).toBe(true);176+ expect(relay.sql.exec("SELECT * FROM grasp_hosted").toArray()).toHaveLength(16);177+ });178+ });179+});@@ -62,8 +62,9 @@ describe("report thresholds", () => {
6262 const note = ev(author, 1, "contested");6363 expect((await c.ok(note)).ok).toBe(true);6464 const report = (sk: Uint8Array) => ev(sk, 1984, "nope", [["e", note.id, "spam"], ["p", pk(author)]]);65- expect((await c.ok(report(r1))).ok).toBe(true);66- expect((await c.ok(report(r1))).ok).toBe(true); // the same reporter twice counts once65+ const firstReport = report(r1);66+ expect((await c.ok(firstReport)).ok).toBe(true);67+ expect((await c.ok(firstReport)).ok).toBe(true); // retry the same signed report across clock ticks6768 expect((await c.req({ ids: [note.id] })).length).toBe(1);6869 expect((await c.ok(report(r2))).ok).toBe(true);6970 // Hidden everywhere stored events are read.