clone

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

dependabot/npm_and_yarn/npm-f01a459b0f
bindws / test / object / grasp-extensions.test.ts
grasp-extensions.test.ts 14.2K
1// Extension tests exercise public PR transport and bounded signed-state sync in workerd.2import { env, runInDurableObject } from "cloudflare:test";3import { describe, expect, it } from "vitest";4import { generateSecretKey } from "nostr-tools/pure";5import { npubEncode } from "nostr-tools/nip19";6import { createGitHandler, MemoryStore, NativeGitEngine, WalRepository, encodePack, readObjects } from "ntig";7import { ev, pk, rpc } from "../helpers/relay.ts";8import { KIND_REPO, KIND_REPO_STATE, KIND_GIT_PR } from "../../src/kinds.ts";9import { gitRepository, graspTick } from "../../src/grasp.ts";10import { repository, graspVisible } from "../../src/grasp-state.ts";11import { syncGitTick, gitSource } from "../../src/grasp-git-sync.ts";12import { nip11 } from "../../src/nip11.ts";13import type { Relay } from "../../src/relay.ts";1415const enc = new TextEncoder();16const packet = (text: string) => { const bytes = enc.encode(text); return new Uint8Array([...enc.encode((bytes.length + 4).toString(16).padStart(4, "0")), ...bytes]); };17const 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; };18const 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};23const advertisement = async (relay: Relay, host: string, path: string) => (await relay.fetch(new Request(`https://${host}${path}/info/refs?service=git-upload-pack`))).text();24async 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}31const 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}`]]);3233async 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}4950describe("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 git_sqlite_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 });7172 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 git_sqlite_objects").toArray()).toHaveLength(0);83 });84 });8586 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 });101102 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 });122123 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 });150151 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 });169170 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 });185186 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});