| 1 | // GRASP-08 interoperability uses stock Git Smart HTTP with one reusable, |
| 2 | // repository-root NIP-98 proof shared through git's extraHeader option. |
| 3 | import { execFile } from "node:child_process" ; |
| 4 | import { mkdtemp, rm, writeFile } from "node:fs/promises" ; |
| 5 | import { tmpdir } from "node:os" ; |
| 6 | import { promisify } from "node:util" ; |
| 7 | import { describe, expect, it } from "vitest" ; |
| 8 | import { finalizeEvent, getPublicKey } from "nostr-tools/pure" ; |
| 9 | import { npubEncode } from "nostr-tools/nip19" ; |
| 10 | import { getToken } from "nostr-tools/nip98" ; |
| 11 | import { sha256 } from "@noble/hashes/sha2.js" ; |
| 12 | import { hexToBytes } from "@noble/hashes/utils.js" ; |
| 13 | import { Client, HTTP_URL, RELAY_URL, now } from "./helpers.ts" ; |
| 14 | |
| 15 | const exec = promisify(execFile); |
| 16 | const KIND_REPO = 30617; |
| 17 | const KIND_REPO_STATE = 30618; |
| 18 | const OWNER_SK = process.env.CLAIM_SK ? hexToBytes(process.env.CLAIM_SK) : sha256(new TextEncoder().encode("tiny conformance owner" )); |
| 19 | |
| 20 | async function git(cwd: string, ...args: string[]): Promise<string> { |
| 21 | const result = await exec("git" , args, { |
| 22 | cwd, |
| 23 | env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, |
| 24 | }); |
| 25 | return result.stdout.trim(); |
| 26 | } |
| 27 | |
| 28 | function auth(sk: Uint8Array, root: string): string { |
| 29 | const event = finalizeEvent({ kind: 27235, created_at: now(), content: "" , tags: [["u" , root], ["method" , "GET" ]] }, sk); |
| 30 | return `Nostr ${Buffer.from(JSON.stringify(event)).toString("base64")}` ; |
| 31 | } |
| 32 | |
| 33 | async function rpc(method: string, params: unknown[] = []): Promise<any> { |
| 34 | const payload = { method, params }; |
| 35 | const token = await getToken(HTTP_URL, "POST" , event => finalizeEvent(event, OWNER_SK), true, payload); |
| 36 | const response = await fetch(HTTP_URL, { |
| 37 | method: "POST" , |
| 38 | headers: { "Content-Type" : "application/nostr+json+rpc" , Authorization: token }, |
| 39 | body: JSON.stringify(payload), |
| 40 | }); |
| 41 | const result: any = await response.json(); |
| 42 | if (!response.ok || result.error) throw new Error(JSON.stringify(result)); |
| 43 | return result.result; |
| 44 | } |
| 45 | |
| 46 | async function waitForRefs(url: string, authorization: string): Promise<void> { |
| 47 | for (let attempt = 0; attempt < 60; attempt++) { |
| 48 | const response = await fetch(`${url}/info/refs?service=git-upload-pack` , { headers: { authorization } }); |
| 49 | const text = await response.text(); |
| 50 | if (response.status === 200 && text.includes("# service=git-upload-pack" )) return; |
| 51 | await new Promise(resolve => setTimeout(resolve, 100)); |
| 52 | } |
| 53 | throw new Error("GRASP-08 repository did not become available" ); |
| 54 | } |
| 55 | |
| 56 | describe("GRASP-08 stock Git" , () => { |
| 57 | it("reuses one repository-root proof for clone, fetch and push" , async ({ skip }) => { |
| 58 | if (process.env.TINY_GRASP08 !== "1" ) skip(); |
| 59 | const policy = await rpc("getpolicy" ); |
| 60 | policy.features = { ...policy.features, grasp: true, grasp08: true }; |
| 61 | policy.reads = "members" ; |
| 62 | await rpc("setpolicy" , [policy]); |
| 63 | const infoResponse = await fetch(HTTP_URL, { headers: { accept: "application/nostr+json" } }); |
| 64 | const info: any = await infoResponse.json(); |
| 65 | expect(info.supported_grasps).toContain("GRASP-08" ); |
| 66 | |
| 67 | const root = await mkdtemp(`${tmpdir()}/tiny-grasp08-` ); |
| 68 | const clone = await mkdtemp(`${tmpdir()}/tiny-grasp08-clone-` ); |
| 69 | const sk = OWNER_SK; |
| 70 | const pubkey = getPublicKey(sk); |
| 71 | const npub = npubEncode(pubkey); |
| 72 | const identifier = `stock-${Math.random().toString(36).slice(2, 10)}` ; |
| 73 | const repoURL = `${HTTP_URL}/${npub}/${encodeURIComponent(identifier)}.git` ; |
| 74 | const proof = auth(sk, repoURL); |
| 75 | const c = await Client.connect(RELAY_URL); |
| 76 | try { |
| 77 | await c.auth(OWNER_SK, RELAY_URL); |
| 78 | await git(root, "init" , "--initial-branch=main" ); |
| 79 | await git(root, "config" , "user.email" , "grasp08-conformance@example.com" ); |
| 80 | await git(root, "config" , "user.name" , "GRASP-08 conformance" ); |
| 81 | await writeFile(`${root}/README.md` , "private GRASP-08 repository\n" ); |
| 82 | await git(root, "add" , "README.md" ); |
| 83 | await git(root, "commit" , "-m" , "initial" ); |
| 84 | const first = await git(root, "rev-parse" , "HEAD" ); |
| 85 | const createdAt = now(); |
| 86 | await c.publish(finalizeEvent({ kind: KIND_REPO, content: "" , created_at: createdAt, tags: [["d" , identifier], ["clone" , repoURL], ["relays" , RELAY_URL], ["maintainers" , pubkey], ["private" , "true" ]] }, sk)); |
| 87 | await c.publish(finalizeEvent({ kind: KIND_REPO_STATE, content: "" , created_at: createdAt + 1, tags: [["d" , identifier], ["HEAD" , "ref: refs/heads/main" ], ["refs/heads/main" , first]] }, sk)); |
| 88 | const header = `Authorization: ${proof}` ; |
| 89 | await waitForRefs(repoURL, proof); |
| 90 | await git(root, "-c" , `http.extraHeader=${header}` , "push" , repoURL, "HEAD:refs/heads/main" ); |
| 91 | await git(clone, "-c" , `http.extraHeader=${header}` , "clone" , repoURL, "." ); |
| 92 | expect(await git(clone, "rev-parse" , "HEAD" )).toBe(first); |
| 93 | |
| 94 | await writeFile(`${root}/README.md` , "private GRASP-08 incremental repository\n" ); |
| 95 | await git(root, "add" , "README.md" ); |
| 96 | await git(root, "commit" , "-m" , "incremental" ); |
| 97 | const second = await git(root, "rev-parse" , "HEAD" ); |
| 98 | await c.publish(finalizeEvent({ kind: KIND_REPO_STATE, content: "" , created_at: createdAt + 2, tags: [["d" , identifier], ["HEAD" , "ref: refs/heads/main" ], ["refs/heads/main" , second]] }, sk)); |
| 99 | await waitForRefs(repoURL, proof); |
| 100 | await git(root, "-c" , `http.extraHeader=${header}` , "push" , repoURL, "HEAD:refs/heads/main" ); |
| 101 | await git(clone, "-c" , `http.extraHeader=${header}` , "fetch" , repoURL, "refs/heads/main:refs/remotes/origin/main" ); |
| 102 | expect(await git(clone, "rev-parse" , "refs/remotes/origin/main" )).toBe(second); |
| 103 | } finally { |
| 104 | c.close(); |
| 105 | await Promise.all([rm(root, { recursive: true, force: true }), rm(clone, { recursive: true, force: true })]); |
| 106 | } |
| 107 | }, 120_000); |
| 108 | }); |