clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

publish repositories with nak git instead of a local script

Dami committed 9a365ae
commit
9a365ae167630d6c0e376fc513a98ef5e2ca3eee
parent
c9b6d63b6936b8c09cebc65b825063cc8cb5437a
author
Dami <dami.etoile@gmail.com>
2 files changed, +7 -134
Mdocs/personal-relay.md12++-
Dscripts/publish-repository.mjs129------------------------------
M docs/personal-relay.md +7 -5
@@ -52,15 +52,17 @@ The owner is `npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5`.
5252 5353 ## Repository hosting5454 55-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.55+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:5656 5757 ```sh58-git remote add tiny https://tiny.tailbe516a.ts.net/<owner npub>/tinyrelay.git59-node scripts/publish-repository.mjs --remote tiny --sec bunker://... --name tiny --description "self-hosted Nostr relay"60-node scripts/publish-repository.mjs --remote tiny --sec bunker://... --state-only58+export NOSTR_SECRET_KEY="$(imagesnap -q -w 2 /tmp/amber.jpg && zbarimg -q --raw /tmp/amber.jpg)"59+export NOSTR_CLIENT_KEY="$(nak key generate)"60+nak git init --identifier tinyrelay --name tiny --owner <owner npub> \61+ --grasp-servers tiny.tailbe516a.ts.net --relays wss://tiny.tailbe516a.ts.net62+nak git push --tags6163 ```6264 63-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.65+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.6466 6567 ## Web operations6668
D scripts/publish-repository.mjs +0 -129
@@ -1,129 +0,0 @@
1-#!/usr/bin/env node2-// Publish the current Git repository to a tiny relay over GRASP-01.3-//4-// node scripts/publish-repository.mjs --relay wss://relay.example --sec bunker://... [options]5-//6-// The script announces the repository (kind 30617), publishes the signed ref7-// state (kind 30618) from the local branches and tags, then pushes through a8-// local NIP-98 signing proxy. Run it again after new commits: the relay only9-// accepts pushes that match the latest signed state.10-//11-// Options:12-// --relay URL relay websocket URL (default: derived from --remote)13-// --remote NAME git remote whose URL names the relay and identifier14-// --identifier NAME repository identifier (default: remote path or directory name)15-// --sec VALUE nsec, hex secret, or bunker:// URL; also NOSTR_SECRET_KEY16-// --name TEXT repository display name for the announcement17-// --description TEXT repository description for the announcement18-// --web URL web page to advertise19-// --private announce a private repository20-// --state-only skip the announcement and only publish state and push21-// --force force-update relay branches that diverged from local ones22-import { execFile } from "node:child_process";23-import { basename } from "node:path";24-import { promisify } from "node:util";25-import WebSocket from "ws";26-import { finalizeEvent, getPublicKey } from "nostr-tools/pure";27-import { npubEncode, decode } from "nostr-tools/nip19";28-import { BunkerSigner, parseBunkerInput } from "nostr-tools/nip46";29-import { generateSecretKey } from "nostr-tools/pure";30-import { hexToBytes } from "@noble/hashes/utils.js";31-import { startGitSigningProxy } from "./git-signing-proxy.mjs";32-33-globalThis.WebSocket = WebSocket;34-const exec = promisify(execFile);35-const args = process.argv.slice(2);36-const option = (name, fallback) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : fallback; };37-const flag = name => args.includes(name);38-const git = async (...argv) => (await exec("git", argv, { env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } })).stdout.trim();39-40-async function signerFrom(value) {41- if (!value) throw Error("Pass --sec with an nsec, hex secret or bunker:// URL, or set NOSTR_SECRET_KEY.");42- if (value.startsWith("bunker://")) {43- const pointer = await parseBunkerInput(value);44- if (!pointer) throw Error("The bunker URL could not be parsed.");45- const bunker = BunkerSigner.fromBunker(generateSecretKey(), pointer);46- await bunker.connect();47- const pubkey = await bunker.getPublicKey();48- return { pubkey, sign: template => bunker.signEvent(template), close: () => bunker.close() };49- }50- const secret = value.startsWith("nsec1") ? decode(value).data : hexToBytes(value);51- return { pubkey: getPublicKey(secret), sign: async template => finalizeEvent(template, secret), close: async () => {} };52-}53-54-// publish sends one event and waits for OK, answering a NIP-42 AUTH55-// challenge with the same signer when the relay asks for it.56-function publish(relayURL, signer, event) {57- return new Promise((resolve, reject) => {58- const socket = new WebSocket(relayURL);59- const timer = setTimeout(() => { socket.close(); reject(Error("relay did not acknowledge " + event.kind)); }, 30_000);60- const finish = (err) => { clearTimeout(timer); socket.close(); err ? reject(err) : resolve(); };61- let retried = false;62- socket.on("open", () => socket.send(JSON.stringify(["EVENT", event])));63- socket.on("error", finish);64- socket.on("message", async raw => {65- const message = JSON.parse(raw);66- if (message[0] === "AUTH" && !retried) {67- retried = true;68- const auth = await signer.sign({ kind: 22242, created_at: Math.floor(Date.now() / 1000), content: "", tags: [["relay", relayURL], ["challenge", message[1]]] });69- socket.send(JSON.stringify(["AUTH", auth]));70- socket.send(JSON.stringify(["EVENT", event]));71- return;72- }73- if (message[0] !== "OK" || message[1] !== event.id) return;74- if (message[2]) finish();75- else if (String(message[3]).startsWith("auth-required:") && !retried) return;76- else finish(Error(`relay rejected kind ${event.kind}: ${message[3]}`));77- });78- });79-}80-81-async function refsMatch(cloneURL, expected) {82- const response = await fetch(cloneURL + "/info/refs?service=git-upload-pack");83- if (!response.ok) return false;84- const text = await response.text();85- return expected.every(([ref, oid]) => text.includes(`${oid} ${ref}`));86-}87-88-async function main() {89- const remoteName = option("--remote");90- const remoteURL = remoteName ? await git("remote", "get-url", remoteName) : "";91- const relayURL = option("--relay") || (remoteURL && new URL(remoteURL).origin.replace(/^http/, "ws"));92- if (!relayURL) throw Error("Pass --relay or --remote.");93- const httpBase = relayURL.replace(/^ws/, "http").replace(/\/$/, "");94- const identifier = option("--identifier") || (remoteURL ? decodeURIComponent(basename(new URL(remoteURL).pathname).replace(/\.git$/, "")) : basename(await git("rev-parse", "--show-toplevel")));95- const signer = await signerFrom(option("--sec", process.env.NOSTR_SECRET_KEY));96- try {97- const npub = npubEncode(signer.pubkey);98- const path = `/${npub}/${encodeURIComponent(identifier)}.git`;99- const cloneURL = httpBase + path;100- const head = await git("symbolic-ref", "HEAD");101- const refs = (await git("for-each-ref", "--format=%(refname) %(objectname)", "refs/heads", "refs/tags")).split("\n").filter(Boolean).map(line => line.split(" "));102- if (!refs.length) throw Error("The repository has no branches or tags to publish.");103- const now = Math.floor(Date.now() / 1000);104-105- if (!flag("--state-only")) {106- const tags = [["d", identifier], ["clone", cloneURL], ["relays", relayURL], ["maintainers", signer.pubkey]];107- for (const name of ["name", "description", "web"]) if (option("--" + name)) tags.push([name, option("--" + name)]);108- if (flag("--private")) tags.push(["private", "true"]);109- await publish(relayURL, signer, await signer.sign({ kind: 30617, created_at: now, content: "", tags }));110- console.log("announced " + identifier);111- }112- await publish(relayURL, signer, await signer.sign({ kind: 30618, created_at: now + 1, content: "", tags: [["d", identifier], ["HEAD", "ref: " + head], ...refs] }));113- console.log(`published state for ${refs.length} refs, HEAD ${head}`);114-115- const proxy = await startGitSigningProxy(httpBase, signer.sign);116- try {117- await exec("git", ["push", ...(flag("--force") ? ["--force"] : []), proxy.url + path, "refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], { stdio: "inherit", maxBuffer: 1 << 26 });118- } finally {119- await proxy.close();120- }121- for (let attempt = 0; attempt < 40 && !(await refsMatch(cloneURL, refs)); attempt++) await new Promise(r => setTimeout(r, 500));122- if (!(await refsMatch(cloneURL, refs))) throw Error("the relay has not advertised the pushed refs yet");123- console.log("clone URL: " + cloneURL);124- } finally {125- await signer.close();126- }127-}128-129-main().then(() => process.exit(0), err => { console.error(err.message); process.exit(1); });