| 1 | diff --git a/docs/personal-relay.md b/docs/personal-relay.md |
| 2 | index 4b0bf1b..74ac27a 100644 |
| 3 | --- a/docs/personal-relay.md |
| 4 | +++ b/docs/personal-relay.md |
| 5 | @@ -52,15 +52,17 @@ The owner is `npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5`. |
| 6 | |
| 7 | ## Repository hosting |
| 8 | |
| 9 | -The relay hosts Git repositories over GRASP. Publishing needs the owner's signature on the repository announcement, on every ref state, and on each push, so pushes go through `scripts/publish-repository.mjs` rather than plain `git push`. The script signs with a bunker URL from Amber or with a local key and never sends a private key to the relay. |
| 10 | +The relay hosts Git repositories over GRASP. Publishing needs the owner's signature on the announcement, on every ref state, and on each push, so use `nak git` with a remote signer rather than plain `git push`. Amber shows a bunker QR code under Applications; scan it into the shell with the webcam so the key stays on the phone: |
| 11 | |
| 12 | ```sh |
| 13 | -git remote add tiny https://tiny.tailbe516a.ts.net/<owner npub>/tinyrelay.git |
| 14 | -node scripts/publish-repository.mjs --remote tiny --sec bunker://... --name tiny --description "self-hosted Nostr relay" |
| 15 | -node scripts/publish-repository.mjs --remote tiny --sec bunker://... --state-only |
| 16 | +export NOSTR_SECRET_KEY="$(imagesnap -q -w 2 /tmp/amber.jpg && zbarimg -q --raw /tmp/amber.jpg)" |
| 17 | +export NOSTR_CLIENT_KEY="$(nak key generate)" |
| 18 | +nak git init --identifier tinyrelay --name tiny --owner <owner npub> \ |
| 19 | + --grasp-servers tiny.tailbe516a.ts.net --relays wss://tiny.tailbe516a.ts.net |
| 20 | +nak git push --tags |
| 21 | ``` |
| 22 | |
| 23 | -Run the first form once to announce the repository. Run the second after new commits: it publishes the signed state for the local branches and tags, pushes them, and waits until the relay advertises the new refs. Anyone can clone from the remote URL without signing. |
| 24 | +Keep the same client key so Amber remembers the approval. Run `nak git push` again after new commits. Anyone can clone from `https://tiny.tailbe516a.ts.net/<owner npub>/tinyrelay.git` without signing. |
| 25 | |
| 26 | ## Web operations |
| 27 | |
| 28 | diff --git a/scripts/publish-repository.mjs b/scripts/publish-repository.mjs |
| 29 | deleted file mode 100644 |
| 30 | index 6f1df35..0000000 |
| 31 | --- a/scripts/publish-repository.mjs |
| 32 | +++ /dev/null |
| 33 | @@ -1,129 +0,0 @@ |
| 34 | -#!/usr/bin/env node |
| 35 | -// Publish the current Git repository to a tiny relay over GRASP-01. |
| 36 | -// |
| 37 | -// node scripts/publish-repository.mjs --relay wss://relay.example --sec bunker://... [options] |
| 38 | -// |
| 39 | -// The script announces the repository (kind 30617), publishes the signed ref |
| 40 | -// state (kind 30618) from the local branches and tags, then pushes through a |
| 41 | -// local NIP-98 signing proxy. Run it again after new commits: the relay only |
| 42 | -// accepts pushes that match the latest signed state. |
| 43 | -// |
| 44 | -// Options: |
| 45 | -// --relay URL relay websocket URL (default: derived from --remote) |
| 46 | -// --remote NAME git remote whose URL names the relay and identifier |
| 47 | -// --identifier NAME repository identifier (default: remote path or directory name) |
| 48 | -// --sec VALUE nsec, hex secret, or bunker:// URL; also NOSTR_SECRET_KEY |
| 49 | -// --name TEXT repository display name for the announcement |
| 50 | -// --description TEXT repository description for the announcement |
| 51 | -// --web URL web page to advertise |
| 52 | -// --private announce a private repository |
| 53 | -// --state-only skip the announcement and only publish state and push |
| 54 | -// --force force-update relay branches that diverged from local ones |
| 55 | -import { execFile } from "node:child_process"; |
| 56 | -import { basename } from "node:path"; |
| 57 | -import { promisify } from "node:util"; |
| 58 | -import WebSocket from "ws"; |
| 59 | -import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; |
| 60 | -import { npubEncode, decode } from "nostr-tools/nip19"; |
| 61 | -import { BunkerSigner, parseBunkerInput } from "nostr-tools/nip46"; |
| 62 | -import { generateSecretKey } from "nostr-tools/pure"; |
| 63 | -import { hexToBytes } from "@noble/hashes/utils.js"; |
| 64 | -import { startGitSigningProxy } from "./git-signing-proxy.mjs"; |
| 65 | - |
| 66 | -globalThis.WebSocket = WebSocket; |
| 67 | -const exec = promisify(execFile); |
| 68 | -const args = process.argv.slice(2); |
| 69 | -const option = (name, fallback) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : fallback; }; |
| 70 | -const flag = name => args.includes(name); |
| 71 | -const git = async (...argv) => (await exec("git", argv, { env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } })).stdout.trim(); |
| 72 | - |
| 73 | -async function signerFrom(value) { |
| 74 | - if (!value) throw Error("Pass --sec with an nsec, hex secret or bunker:// URL, or set NOSTR_SECRET_KEY."); |
| 75 | - if (value.startsWith("bunker://")) { |
| 76 | - const pointer = await parseBunkerInput(value); |
| 77 | - if (!pointer) throw Error("The bunker URL could not be parsed."); |
| 78 | - const bunker = BunkerSigner.fromBunker(generateSecretKey(), pointer); |
| 79 | - await bunker.connect(); |
| 80 | - const pubkey = await bunker.getPublicKey(); |
| 81 | - return { pubkey, sign: template => bunker.signEvent(template), close: () => bunker.close() }; |
| 82 | - } |
| 83 | - const secret = value.startsWith("nsec1") ? decode(value).data : hexToBytes(value); |
| 84 | - return { pubkey: getPublicKey(secret), sign: async template => finalizeEvent(template, secret), close: async () => {} }; |
| 85 | -} |
| 86 | - |
| 87 | -// publish sends one event and waits for OK, answering a NIP-42 AUTH |
| 88 | -// challenge with the same signer when the relay asks for it. |
| 89 | -function publish(relayURL, signer, event) { |
| 90 | - return new Promise((resolve, reject) => { |
| 91 | - const socket = new WebSocket(relayURL); |
| 92 | - const timer = setTimeout(() => { socket.close(); reject(Error("relay did not acknowledge " + event.kind)); }, 30_000); |
| 93 | - const finish = (err) => { clearTimeout(timer); socket.close(); err ? reject(err) : resolve(); }; |
| 94 | - let retried = false; |
| 95 | - socket.on("open", () => socket.send(JSON.stringify(["EVENT", event]))); |
| 96 | - socket.on("error", finish); |
| 97 | - socket.on("message", async raw => { |
| 98 | - const message = JSON.parse(raw); |
| 99 | - if (message[0] === "AUTH" && !retried) { |
| 100 | - retried = true; |
| 101 | - const auth = await signer.sign({ kind: 22242, created_at: Math.floor(Date.now() / 1000), content: "", tags: [["relay", relayURL], ["challenge", message[1]]] }); |
| 102 | - socket.send(JSON.stringify(["AUTH", auth])); |
| 103 | - socket.send(JSON.stringify(["EVENT", event])); |
| 104 | - return; |
| 105 | - } |
| 106 | - if (message[0] !== "OK" || message[1] !== event.id) return; |
| 107 | - if (message[2]) finish(); |
| 108 | - else if (String(message[3]).startsWith("auth-required:") && !retried) return; |
| 109 | - else finish(Error(`relay rejected kind ${event.kind}: ${message[3]}`)); |
| 110 | - }); |
| 111 | - }); |
| 112 | -} |
| 113 | - |
| 114 | -async function refsMatch(cloneURL, expected) { |
| 115 | - const response = await fetch(cloneURL + "/info/refs?service=git-upload-pack"); |
| 116 | - if (!response.ok) return false; |
| 117 | - const text = await response.text(); |
| 118 | - return expected.every(([ref, oid]) => text.includes(`${oid} ${ref}`)); |
| 119 | -} |
| 120 | - |
| 121 | -async function main() { |
| 122 | - const remoteName = option("--remote"); |
| 123 | - const remoteURL = remoteName ? await git("remote", "get-url", remoteName) : ""; |
| 124 | - const relayURL = option("--relay") || (remoteURL && new URL(remoteURL).origin.replace(/^http/, "ws")); |
| 125 | - if (!relayURL) throw Error("Pass --relay or --remote."); |
| 126 | - const httpBase = relayURL.replace(/^ws/, "http").replace(/\/$/, ""); |
| 127 | - const identifier = option("--identifier") || (remoteURL ? decodeURIComponent(basename(new URL(remoteURL).pathname).replace(/\.git$/, "")) : basename(await git("rev-parse", "--show-toplevel"))); |
| 128 | - const signer = await signerFrom(option("--sec", process.env.NOSTR_SECRET_KEY)); |
| 129 | - try { |
| 130 | - const npub = npubEncode(signer.pubkey); |
| 131 | - const path = `/${npub}/${encodeURIComponent(identifier)}.git`; |
| 132 | - const cloneURL = httpBase + path; |
| 133 | - const head = await git("symbolic-ref", "HEAD"); |
| 134 | - const refs = (await git("for-each-ref", "--format=%(refname) %(objectname)", "refs/heads", "refs/tags")).split("\n").filter(Boolean).map(line => line.split(" ")); |
| 135 | - if (!refs.length) throw Error("The repository has no branches or tags to publish."); |
| 136 | - const now = Math.floor(Date.now() / 1000); |
| 137 | - |
| 138 | - if (!flag("--state-only")) { |
| 139 | - const tags = [["d", identifier], ["clone", cloneURL], ["relays", relayURL], ["maintainers", signer.pubkey]]; |
| 140 | - for (const name of ["name", "description", "web"]) if (option("--" + name)) tags.push([name, option("--" + name)]); |
| 141 | - if (flag("--private")) tags.push(["private", "true"]); |
| 142 | - await publish(relayURL, signer, await signer.sign({ kind: 30617, created_at: now, content: "", tags })); |
| 143 | - console.log("announced " + identifier); |
| 144 | - } |
| 145 | - await publish(relayURL, signer, await signer.sign({ kind: 30618, created_at: now + 1, content: "", tags: [["d", identifier], ["HEAD", "ref: " + head], ...refs] })); |
| 146 | - console.log(`published state for ${refs.length} refs, HEAD ${head}`); |
| 147 | - |
| 148 | - const proxy = await startGitSigningProxy(httpBase, signer.sign); |
| 149 | - try { |
| 150 | - await exec("git", ["push", ...(flag("--force") ? ["--force"] : []), proxy.url + path, "refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], { stdio: "inherit", maxBuffer: 1 << 26 }); |
| 151 | - } finally { |
| 152 | - await proxy.close(); |
| 153 | - } |
| 154 | - for (let attempt = 0; attempt < 40 && !(await refsMatch(cloneURL, refs)); attempt++) await new Promise(r => setTimeout(r, 500)); |
| 155 | - if (!(await refsMatch(cloneURL, refs))) throw Error("the relay has not advertised the pushed refs yet"); |
| 156 | - console.log("clone URL: " + cloneURL); |
| 157 | - } finally { |
| 158 | - await signer.close(); |
| 159 | - } |
| 160 | -} |
| 161 | - |
| 162 | -main().then(() => process.exit(0), err => { console.error(err.message); process.exit(1); }); |