tinyrelaycommit

Commit c9b6d63b6936

browse files at this commit | history

1diff --git a/docs/personal-relay.md b/docs/personal-relay.md
2index 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.
24diff --git a/scripts/git-signing-proxy.mjs b/scripts/git-signing-proxy.mjs
25index 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 });
73diff --git a/scripts/publish-repository.mjs b/scripts/publish-repository.mjs
74new file mode 100644
75index 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); });
208diff --git a/scripts/qa/nip46-bunker.mjs b/scripts/qa/nip46-bunker.mjs
209index 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 = [];

About

ownerc01503e8035f
visibilitypublic
headc9b6d63b6936

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs