| 1 | diff --git a/docs/personal-relay.md b/docs/personal-relay.md |
| 2 | index 3e9a1ed..4b0bf1b 100644 |
| 3 | --- a/docs/personal-relay.md |
| 4 | +++ b/docs/personal-relay.md |
| 5 | @@ -50,6 +50,18 @@ sudo systemctl restart tiny |
| 6 | |
| 7 | The owner is `npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5`. Sign in with that identity to manage the relay. |
| 8 | |
| 9 | +## Repository hosting |
| 10 | + |
| 11 | +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. |
| 12 | + |
| 13 | +```sh |
| 14 | +git remote add tiny https://tiny.tailbe516a.ts.net/<owner npub>/tinyrelay.git |
| 15 | +node scripts/publish-repository.mjs --remote tiny --sec bunker://... --name tiny --description "self-hosted Nostr relay" |
| 16 | +node scripts/publish-repository.mjs --remote tiny --sec bunker://... --state-only |
| 17 | +``` |
| 18 | + |
| 19 | +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. |
| 20 | + |
| 21 | ## Web operations |
| 22 | |
| 23 | Sign in at the relay URL with a Nostr signer before using owner controls. The Manage pages provide relay configuration, jobs, connections, backups, and file operations. Use Manage data or the browser tools page to start and inspect backups. The [WebMCP guide](webmcp.md) describes native browser-agent controls and their existing signer authorization path. |
| 24 | diff --git a/scripts/git-signing-proxy.mjs b/scripts/git-signing-proxy.mjs |
| 25 | index 5f95720..c8b009b 100644 |
| 26 | --- a/scripts/git-signing-proxy.mjs |
| 27 | +++ b/scripts/git-signing-proxy.mjs |
| 28 | @@ -1,7 +1,10 @@ |
| 29 | -// Benchmark-only NIP-98 signer for native Git's smart HTTP requests. |
| 30 | -// The ephemeral fixture secret never leaves this process. Both request and |
| 31 | -// response bodies stream through files/pipes rather than accumulating in RAM. |
| 32 | +// Local NIP-98 signing proxy for native Git's smart HTTP requests. Git talks |
| 33 | +// plain HTTP to this proxy; each request is hashed, signed as a kind 27235 |
| 34 | +// proof by the supplied signer, and forwarded to the relay with the proof in |
| 35 | +// the Authorization header. Request and response bodies stream through |
| 36 | +// files and pipes rather than accumulating in memory. |
| 37 | import http from "node:http"; |
| 38 | +import https from "node:https"; |
| 39 | import { createHash, randomUUID } from "node:crypto"; |
| 40 | import { createReadStream, createWriteStream } from "node:fs"; |
| 41 | import { mkdtemp, rm } from "node:fs/promises"; |
| 42 | @@ -11,7 +14,11 @@ import { Transform } from "node:stream"; |
| 43 | import { pipeline } from "node:stream/promises"; |
| 44 | import { finalizeEvent } from "nostr-tools/pure"; |
| 45 | |
| 46 | -export async function startGitSigningProxy(target, secret) { |
| 47 | +// startGitSigningProxy forwards to target (an http or https base URL). The |
| 48 | +// signer is either a secret key (Uint8Array) or an async function that takes |
| 49 | +// an event template and returns the signed event, such as a NIP-46 bunker. |
| 50 | +export async function startGitSigningProxy(target, signer) { |
| 51 | + const sign = typeof signer === "function" ? signer : template => finalizeEvent(template, signer); |
| 52 | const server = http.createServer(async (request, response) => { |
| 53 | let directory; |
| 54 | try { |
| 55 | @@ -25,14 +32,15 @@ export async function startGitSigningProxy(target, secret) { |
| 56 | const url = new URL(target + request.url); |
| 57 | const tags = [["u", url.href], ["method", request.method], ["nonce", randomUUID()]]; |
| 58 | if (size) tags.push(["payload", hash.digest("hex")]); |
| 59 | - const proof = finalizeEvent({kind: 27235, created_at: Math.floor(Date.now()/1000), tags, content: ""}, secret); |
| 60 | + const proof = await sign({kind: 27235, created_at: Math.floor(Date.now()/1000), tags, content: ""}); |
| 61 | const headers = {...request.headers, host: url.host, "content-length": String(size), |
| 62 | authorization: "Nostr " + Buffer.from(JSON.stringify(proof)).toString("base64")}; |
| 63 | delete headers["transfer-encoding"]; |
| 64 | delete headers.connection; |
| 65 | + const client = url.protocol === "https:" ? https : http; |
| 66 | let upstream; |
| 67 | const received = new Promise((resolve, reject) => { |
| 68 | - upstream = http.request(url, {method: request.method, headers}, incoming => { |
| 69 | + upstream = client.request(url, {method: request.method, headers}, incoming => { |
| 70 | response.writeHead(incoming.statusCode, incoming.headers); |
| 71 | pipeline(incoming, response).then(resolve, reject); |
| 72 | }); |
| 73 | diff --git a/scripts/publish-repository.mjs b/scripts/publish-repository.mjs |
| 74 | new file mode 100644 |
| 75 | index 0000000..6f1df35 |
| 76 | --- /dev/null |
| 77 | +++ b/scripts/publish-repository.mjs |
| 78 | @@ -0,0 +1,129 @@ |
| 79 | +#!/usr/bin/env node |
| 80 | +// Publish the current Git repository to a tiny relay over GRASP-01. |
| 81 | +// |
| 82 | +// node scripts/publish-repository.mjs --relay wss://relay.example --sec bunker://... [options] |
| 83 | +// |
| 84 | +// The script announces the repository (kind 30617), publishes the signed ref |
| 85 | +// state (kind 30618) from the local branches and tags, then pushes through a |
| 86 | +// local NIP-98 signing proxy. Run it again after new commits: the relay only |
| 87 | +// accepts pushes that match the latest signed state. |
| 88 | +// |
| 89 | +// Options: |
| 90 | +// --relay URL relay websocket URL (default: derived from --remote) |
| 91 | +// --remote NAME git remote whose URL names the relay and identifier |
| 92 | +// --identifier NAME repository identifier (default: remote path or directory name) |
| 93 | +// --sec VALUE nsec, hex secret, or bunker:// URL; also NOSTR_SECRET_KEY |
| 94 | +// --name TEXT repository display name for the announcement |
| 95 | +// --description TEXT repository description for the announcement |
| 96 | +// --web URL web page to advertise |
| 97 | +// --private announce a private repository |
| 98 | +// --state-only skip the announcement and only publish state and push |
| 99 | +// --force force-update relay branches that diverged from local ones |
| 100 | +import { execFile } from "node:child_process"; |
| 101 | +import { basename } from "node:path"; |
| 102 | +import { promisify } from "node:util"; |
| 103 | +import WebSocket from "ws"; |
| 104 | +import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; |
| 105 | +import { npubEncode, decode } from "nostr-tools/nip19"; |
| 106 | +import { BunkerSigner, parseBunkerInput } from "nostr-tools/nip46"; |
| 107 | +import { generateSecretKey } from "nostr-tools/pure"; |
| 108 | +import { hexToBytes } from "@noble/hashes/utils.js"; |
| 109 | +import { startGitSigningProxy } from "./git-signing-proxy.mjs"; |
| 110 | + |
| 111 | +globalThis.WebSocket = WebSocket; |
| 112 | +const exec = promisify(execFile); |
| 113 | +const args = process.argv.slice(2); |
| 114 | +const option = (name, fallback) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : fallback; }; |
| 115 | +const flag = name => args.includes(name); |
| 116 | +const git = async (...argv) => (await exec("git", argv, { env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } })).stdout.trim(); |
| 117 | + |
| 118 | +async function signerFrom(value) { |
| 119 | + if (!value) throw Error("Pass --sec with an nsec, hex secret or bunker:// URL, or set NOSTR_SECRET_KEY."); |
| 120 | + if (value.startsWith("bunker://")) { |
| 121 | + const pointer = await parseBunkerInput(value); |
| 122 | + if (!pointer) throw Error("The bunker URL could not be parsed."); |
| 123 | + const bunker = BunkerSigner.fromBunker(generateSecretKey(), pointer); |
| 124 | + await bunker.connect(); |
| 125 | + const pubkey = await bunker.getPublicKey(); |
| 126 | + return { pubkey, sign: template => bunker.signEvent(template), close: () => bunker.close() }; |
| 127 | + } |
| 128 | + const secret = value.startsWith("nsec1") ? decode(value).data : hexToBytes(value); |
| 129 | + return { pubkey: getPublicKey(secret), sign: async template => finalizeEvent(template, secret), close: async () => {} }; |
| 130 | +} |
| 131 | + |
| 132 | +// publish sends one event and waits for OK, answering a NIP-42 AUTH |
| 133 | +// challenge with the same signer when the relay asks for it. |
| 134 | +function publish(relayURL, signer, event) { |
| 135 | + return new Promise((resolve, reject) => { |
| 136 | + const socket = new WebSocket(relayURL); |
| 137 | + const timer = setTimeout(() => { socket.close(); reject(Error("relay did not acknowledge " + event.kind)); }, 30_000); |
| 138 | + const finish = (err) => { clearTimeout(timer); socket.close(); err ? reject(err) : resolve(); }; |
| 139 | + let retried = false; |
| 140 | + socket.on("open", () => socket.send(JSON.stringify(["EVENT", event]))); |
| 141 | + socket.on("error", finish); |
| 142 | + socket.on("message", async raw => { |
| 143 | + const message = JSON.parse(raw); |
| 144 | + if (message[0] === "AUTH" && !retried) { |
| 145 | + retried = true; |
| 146 | + const auth = await signer.sign({ kind: 22242, created_at: Math.floor(Date.now() / 1000), content: "", tags: [["relay", relayURL], ["challenge", message[1]]] }); |
| 147 | + socket.send(JSON.stringify(["AUTH", auth])); |
| 148 | + socket.send(JSON.stringify(["EVENT", event])); |
| 149 | + return; |
| 150 | + } |
| 151 | + if (message[0] !== "OK" || message[1] !== event.id) return; |
| 152 | + if (message[2]) finish(); |
| 153 | + else if (String(message[3]).startsWith("auth-required:") && !retried) return; |
| 154 | + else finish(Error(`relay rejected kind ${event.kind}: ${message[3]}`)); |
| 155 | + }); |
| 156 | + }); |
| 157 | +} |
| 158 | + |
| 159 | +async function refsMatch(cloneURL, expected) { |
| 160 | + const response = await fetch(cloneURL + "/info/refs?service=git-upload-pack"); |
| 161 | + if (!response.ok) return false; |
| 162 | + const text = await response.text(); |
| 163 | + return expected.every(([ref, oid]) => text.includes(`${oid} ${ref}`)); |
| 164 | +} |
| 165 | + |
| 166 | +async function main() { |
| 167 | + const remoteName = option("--remote"); |
| 168 | + const remoteURL = remoteName ? await git("remote", "get-url", remoteName) : ""; |
| 169 | + const relayURL = option("--relay") || (remoteURL && new URL(remoteURL).origin.replace(/^http/, "ws")); |
| 170 | + if (!relayURL) throw Error("Pass --relay or --remote."); |
| 171 | + const httpBase = relayURL.replace(/^ws/, "http").replace(/\/$/, ""); |
| 172 | + const identifier = option("--identifier") || (remoteURL ? decodeURIComponent(basename(new URL(remoteURL).pathname).replace(/\.git$/, "")) : basename(await git("rev-parse", "--show-toplevel"))); |
| 173 | + const signer = await signerFrom(option("--sec", process.env.NOSTR_SECRET_KEY)); |
| 174 | + try { |
| 175 | + const npub = npubEncode(signer.pubkey); |
| 176 | + const path = `/${npub}/${encodeURIComponent(identifier)}.git`; |
| 177 | + const cloneURL = httpBase + path; |
| 178 | + const head = await git("symbolic-ref", "HEAD"); |
| 179 | + const refs = (await git("for-each-ref", "--format=%(refname) %(objectname)", "refs/heads", "refs/tags")).split("\n").filter(Boolean).map(line => line.split(" ")); |
| 180 | + if (!refs.length) throw Error("The repository has no branches or tags to publish."); |
| 181 | + const now = Math.floor(Date.now() / 1000); |
| 182 | + |
| 183 | + if (!flag("--state-only")) { |
| 184 | + const tags = [["d", identifier], ["clone", cloneURL], ["relays", relayURL], ["maintainers", signer.pubkey]]; |
| 185 | + for (const name of ["name", "description", "web"]) if (option("--" + name)) tags.push([name, option("--" + name)]); |
| 186 | + if (flag("--private")) tags.push(["private", "true"]); |
| 187 | + await publish(relayURL, signer, await signer.sign({ kind: 30617, created_at: now, content: "", tags })); |
| 188 | + console.log("announced " + identifier); |
| 189 | + } |
| 190 | + await publish(relayURL, signer, await signer.sign({ kind: 30618, created_at: now + 1, content: "", tags: [["d", identifier], ["HEAD", "ref: " + head], ...refs] })); |
| 191 | + console.log(`published state for ${refs.length} refs, HEAD ${head}`); |
| 192 | + |
| 193 | + const proxy = await startGitSigningProxy(httpBase, signer.sign); |
| 194 | + try { |
| 195 | + await exec("git", ["push", ...(flag("--force") ? ["--force"] : []), proxy.url + path, "refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], { stdio: "inherit", maxBuffer: 1 << 26 }); |
| 196 | + } finally { |
| 197 | + await proxy.close(); |
| 198 | + } |
| 199 | + for (let attempt = 0; attempt < 40 && !(await refsMatch(cloneURL, refs)); attempt++) await new Promise(r => setTimeout(r, 500)); |
| 200 | + if (!(await refsMatch(cloneURL, refs))) throw Error("the relay has not advertised the pushed refs yet"); |
| 201 | + console.log("clone URL: " + cloneURL); |
| 202 | + } finally { |
| 203 | + await signer.close(); |
| 204 | + } |
| 205 | +} |
| 206 | + |
| 207 | +main().then(() => process.exit(0), err => { console.error(err.message); process.exit(1); }); |
| 208 | diff --git a/scripts/qa/nip46-bunker.mjs b/scripts/qa/nip46-bunker.mjs |
| 209 | index e8e4f14..a6cc546 100644 |
| 210 | --- a/scripts/qa/nip46-bunker.mjs |
| 211 | +++ b/scripts/qa/nip46-bunker.mjs |
| 212 | @@ -2,17 +2,19 @@ |
| 213 | // A throwaway NIP-46 bunker for trying the Bunker URL sign-in flow locally. |
| 214 | // It runs a minimal in-memory relay on 127.0.0.1:8789, answers connect, |
| 215 | // ping, get_public_key and sign_event requests for a fresh user key, and |
| 216 | -// prints the bunker URL to paste into the sign-in page. |
| 217 | +// prints the bunker URL to paste into the sign-in page. Set BUNKER_USER_SECRET |
| 218 | +// to a hex secret to sign as a specific key. |
| 219 | import { WebSocketServer, WebSocket } from "ws"; |
| 220 | import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure"; |
| 221 | import * as nip44 from "nostr-tools/nip44"; |
| 222 | +import { hexToBytes } from "@noble/hashes/utils.js"; |
| 223 | |
| 224 | globalThis.WebSocket = WebSocket; |
| 225 | |
| 226 | const port = Number(process.env.BUNKER_PORT ?? 8789); |
| 227 | const bunkerSecret = generateSecretKey(); |
| 228 | const bunkerPubkey = getPublicKey(bunkerSecret); |
| 229 | -const userSecret = generateSecretKey(); |
| 230 | +const userSecret = process.env.BUNKER_USER_SECRET ? hexToBytes(process.env.BUNKER_USER_SECRET) : generateSecretKey(); |
| 231 | const userPubkey = getPublicKey(userSecret); |
| 232 | const clients = new Map(); |
| 233 | const events = []; |