// forge — a personal git forge (tier 1: hosting + browsing; tier 2: issues // + comments; tier 2.5: first-class did:nostr agents + the xlogin widget; // tier 3a: forks, compare, and pull requests with REAL merges; polish: // labels, read-time search, releases/archives, hidden compare refs; // tier 3.5: git-mark anchoring via Blocktrails — Bitcoin(testnet)-anchored, // tamper-evident repo history) as a #206 loader plugin. The useful slice of // Gogs/Gitea: push a repo, get a GitHub-style web UI for it — with the // constraints that killed the last attempt made absolute: zero build step, // every page server-rendered HTML with inline CSS, all git work done // by the system `git` binary, and one npm dependency, @noble/curves — the // host's own crypto library — for the Blocktrails secp256k1 point math // (the blocktrails npm package is deliberately NOT imported; the spec math // is implemented here and cross-checked against a reference-impl vector in // test.js). Client JavaScript is the 3-line clone-URL // copy button plus one dependency-free inline module on the issues pages // (login + fetch against the JSON API; the server always renders the truth). // // plugins: [{ id: 'forge', module: 'forge/plugin.js', prefix: '/forge', // config: { privateRepos: false } }] // // Layout (everything under the one prefix — no reservePath needed): // // smart HTTP //.git/{info/refs,git-upload-pack,git-receive-pack} // index / all repos, all owners // owner / that owner's repos // repo home // file table + README // tree/blob ///{tree,blob}// // raw ///raw// // commits ///commits/?page=N // commit ///commit/ // refs ///{branches,tags} // issues ///issues[?state=&label=|/new|/] // search ///search?q= (default branch, read-time) // releases ///releases (every tag, newest first) // archive ///archive/.{tar.gz,zip} // labels /api/repos///labels[/] (+ PUT .../{issues,pulls}//labels) // compare ///compare/...[:] // pulls ///pulls[?state=|/new|/[/commits|/files]] // anchors ///marks (HTML) + api/repos///marks (JSON) // POST api/repos///marks/enable (owner: genesis mark) // POST api/repos///marks//txo (owner: record on-chain txo) // GET ///blocktrails.json (CORS-readable trail doc) // nostr POST api/repos///announce (owner: publish NIP-34 30617 + 30618 to relays) // GET api/repos///nostr (the signed 30617 + 30618 that WOULD be published) // fork POST /api/repos///fork // web edit POST api/repos///edit {path,content,message?,branch?} // (owner-signed by default; config.openEdit => anon DEMO; CORS+preflight) // push tokens /api/token (POST, any getAgent credential) // hosted words /api/hosted// (GET public, DELETE author-only) // xlogin /xlogin.js (vendored widget, byte-identical) // // TIER 2 — issues and comments, pod-native (see README Findings): // the WORDS live in the author's pod (loopback PUT of a JSON-LD resource // under /public/forge/--/ with the author's own // forwarded Bearer, so real WAC governs the write and the resource is the // author's property); the SPINE lives in pluginDir/issues//.json // (next number, per-issue title/state/author + an ordered thread of // {author, resourceUrl, at} pointers — never body text). Bodies are // re-fetched from the pods at read time; a deleted resource renders as // "content removed by its author". // // TIER 2.5 — did:nostr agents are first-class (see README "Nostr agents"): // the canonical nostr identity is `did:nostr:<64-hex-pubkey>` (exactly what // getAgent returns for a NIP-98 signature with no WebID mapping); the hex // pubkey IS the agent's forge namespace (`//.git`). // npub (bech32) is DISPLAY-ONLY — storage paths, index keys and API ids // stay hex everywhere. Because git's static http.extraHeader cannot sign // per-request NIP-98 (a push is several requests with different URLs and // methods), `POST /api/token` exchanges ANY getAgent-accepted // credential for a short-lived macaroon-lite HMAC bearer (capability/'s // pattern) that the git lane accepts in addition to getAgent. did:nostr // agents have no pod, so their issue/comment bodies are stored under // pluginDir/hosted// ("hosted by the forge" — the podless-agent // asymmetry is a named Finding), deletable by their author over the API. // The issues pages also serve/load the vendored xlogin widget (NIP-98 / // DPoP client-side auth) beside the local username/password fallback. // // TIER 3.5 — git-mark anchoring via Blocktrails (see README "Anchors"): // the server DERIVES AND RECORDS; it NEVER touches the network. Per-repo // trail state (a forge-held testnet trail key + the mark list) lives at // pluginDir/marks//.json. Each mark commits one state // { commit, repo, branch }; its P2TR address is derived by chained // BIP-341 TapTweak (blocktrails spec v0.2) over // sha256(JSON.stringify(state)) — pure @noble/curves + node:crypto. // Spending the PREVIOUS mark's UTXO to the NEW mark's address IS the // advance; the transactions happen elsewhere (the maintainer's fund-agent // / git-mark CLIs), the owner reports { txid, vout, amount } back and the // hosted blocktrails verifier checks the chain client-side. Bitcoin // appears here only as derived bech32m addresses, mempool.space links in // HTML, and the served blocktrails.json. Testnet-only defaults // (chain 'tbtc4'); mainnet chains are refused at activate unless // config.allowMainnet is explicitly true. // // Ownership: owner = the pod username derived from the pusher's WebID // (mastodon/'s podFromWebid rule), or the 64-hex pubkey for did:nostr // agents. Push-to-create: an authenticated agent // pushing into its OWN namespace materializes the bare repo under // pluginDir/repos//.git — persistent, no TTL (this is a // forge, not a scratchpad). Pushing into someone else's namespace is 403; // anonymous push is 401 + WWW-Authenticate. Clone/fetch and the web UI are // public by default; config.privateRepos flips ALL reads to owner-only. // // The git wire protocol is NOT reimplemented: requests are delegated to // the stock git-http-backend CGI with the raw request body streamed // through (gitscratch's proven scoped pass-through parser — this plugin // does not need api.mountApp). All server-side git runs hermetic: // GIT_CONFIG_NOSYSTEM=1 and no HOME in the environment. // // SECURITY posture (see README Findings): // - raw blobs are NEVER served as text/html: text-ish content goes out // as text/plain, everything else as application/octet-stream with // content-disposition: attachment, plus X-Content-Type-Options: // nosniff — a pushed .html file cannot become stored XSS on the API // origin (GitHub solves this with a separate raw domain; we solve it // with content-type neutralization). // - every interpolated string in HTML goes through one esc() helper — // filenames, commit messages, author names, diff bodies are all // attacker-controlled. // - the README markdown renderer escapes FIRST, then transforms a // bounded subset; hrefs are allowlisted to http/https/relative. // - every owner/repo/ref/path segment is validated against strict // patterns; '..', backslash, leading dots and encoded traversal are // rejected before any path or git argument is built. import { spawn, execFile } from 'node:child_process'; import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { PassThrough } from 'node:stream'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { schnorr, secp256k1 } from '@noble/curves/secp256k1'; import { npubEncode, p2trAddressEncode, markStateHash, trailProgram, trailAddress, npubShort, } from './lib/blocktrails.js'; // re-export the four the test suite imports from plugin.js (back-compat) export { markStateHash, npubEncode, trailAddress, trailProgram } from './lib/blocktrails.js'; import { esc, okSegment, okPath, okRef, relTime, labelTextColor, labelChips, fmtBytes, looksBinary, identicon, REF_RE, ICON_DIR, ICON_FILE, ICON_REPO, ICON_BRANCH, ICON_TAG, ICON_ISSUE_OPEN, ICON_ISSUE_CLOSED, ICON_PR_OPEN, ICON_PR_TAB, ICON_PR_MERGED, ICON_PR_CLOSED, } from './lib/helpers.js'; import { renderMarkdown } from './lib/markdown.js'; const execFileP = promisify(execFile); const DEFAULT_BACKEND = '/usr/lib/git-core/git-http-backend'; const META_FILE = 'jss-forge.json'; const SERVICES = new Set(['git-upload-pack', 'git-receive-pack']); // Conservative names, gitscratch rigor. Owner and repo (UI name, no .git) // must start alphanumeric; no slashes, no traversal, no trailing .git. const OWNER_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; const REPO_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; // Refs may contain '/' (feature/x) but never '..'; shas are plain hex. const SHA_RE = /^[0-9a-f]{4,64}$/; // Path segments: reject empties, traversal, backslash, control chars and // percent-encoded dot/slash/backslash (belt and braces if decoding varies). // Web-edit (tier 3.7): a single repo-relative file path the browser edits. // One segment = a conservative filename charset (no spaces, no exotica — // the demo edits ordinary files); '..', '.git' and a leading-dot root are // refused explicitly below. Content is capped so one edit cannot balloon. const EDIT_SEG = /^[A-Za-z0-9._-]+$/; const EDIT_CONTENT_CAP = 1024 * 1024; // 1 MiB of file content per edit const EDIT_MSG_CAP = 1000; // commit-message chars const EDIT_PATH_CAP = 1024; // whole path chars // The UNSTAGED tier: a preview edit rides a NIP-01 EPHEMERAL event (kind in // 20000..29999 — relays SHOULD NOT store it), carrying the file content but // touching neither git nor Bitcoin. Every currently-connected viewer applies // it and throws it away; nothing is persisted anywhere. Promotion to the // committed tier is the /edit endpoint; to the marked tier, an anchor. const EPHEMERAL_PREVIEW_KIND = 21617; // Tier-2.5: nostr identity. A did:nostr agent's namespace is its 64-hex // pubkey — unambiguous in practice (pod names are human-chosen; a pod // literally named as 64 lowercase hex chars is theoretically possible under // OWNER_NAME, in which case hex-as-nostr-namespace wins — see README). const NOSTR_HEX = /^[0-9a-f]{64}$/; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const FORGE_TOKEN_MAX_TTL = 30 * 24 * 3600; const RENDER_CAP = 512 * 1024; // blobs/READMEs above this: "view raw" const PER_PAGE = 30; const MAX_EXEC_BUFFER = 64 * 1024 * 1024; // Tier-2 bounds: caps first, so a hostile client cannot balloon the index // or make read-time thread resolution unbounded. const ISSUES_PER_PAGE = 25; const ISSUE_TITLE_CAP = 256; const ISSUE_BODY_CAP = 64 * 1024; // markdown source, per issue/comment const DESCRIPTION_CAP = 512; const THREAD_CAP = 500; // entries per issue (1 body + comments) const THREAD_FETCH_CONCURRENCY = 8; // parallel loopback GETs at read time const THREAD_FETCH_TIMEOUT_MS = 8000; const ISSUE_NUM_RE = /^[1-9][0-9]{0,8}$/; // Polish wave: labels (GitHub's default set), search bounds, archive bounds. const DEFAULT_LABELS = [ { name: 'bug', color: 'd73a4a' }, { name: 'enhancement', color: 'a2eeef' }, { name: 'documentation', color: '0075ca' }, { name: 'question', color: 'd876e3' }, { name: 'wontfix', color: 'ffffff' }, ]; const LABEL_NAME_RE = /^[^\x00-\x1f\x7f]{1,50}$/; // printable, 1-50 chars const LABEL_COLOR_RE = /^[0-9a-fA-F]{6}$/; const LABELS_PER_ITEM = 20; const SEARCH_QUERY_CAP = 256; // chars of query const SEARCH_HIT_CAP = 100; // total grep hits returned const SEARCH_PATH_CAP = 100; // total path matches returned const SEARCH_PER_FILE_CAP = 5; // grep --max-count per file const SEARCH_EXCERPT_CAP = 200; // chars of line excerpt // Tier-3.5: Blocktrails anchoring. chain -> bech32(m) HRP (BIP-173/350) // and mempool.space explorer path. Mainnet identifiers are refused at // activate unless config.allowMainnet — this plugin's custody posture is // testnet-grade (see README Findings). const CHAIN_HRP = { btc: 'bc', mainnet: 'bc', bitcoin: 'bc', tbtc4: 'tb', tbtc3: 'tb', signet: 'tb', regtest: 'bcrt' }; const MAINNET_CHAINS = new Set(['btc', 'mainnet', 'bitcoin']); const MEMPOOL_PATH = { btc: '', mainnet: '', bitcoin: '', tbtc4: '/testnet4', tbtc3: '/testnet', signet: '/signet' }; const TXID64 = /^[0-9a-f]{64}$/; const MARK_INDEX_RE = /^(0|[1-9][0-9]{0,5})$/; const MARKS_CAP = 500; // marks per trail (each push stacks one) const SATS_CAP = 2_100_000_000_000_000; // 21M BTC in sats // ---------------------------------------------------------------- helpers // esc + path/ref validators + formatters + SVG icons moved to // ./lib/helpers.js — imported above. // ------------------------------------------------- nostr identity (2.5) // Canonical form everywhere: did:nostr:<64-hex> (did-nostr.com — exactly // what getAgent returns for an unmapped NIP-98 key). npub is DISPLAY-ONLY. /** did:nostr: -> the 64-char lowercase hex pubkey, else null. */ function nostrHexOf(agent) { const m = /^did:nostr:([0-9a-f]{64})$/.exec(String(agent ?? '')); return m ? m[1] : null; } // bech32 (npub) + bech32m (P2TR) + Blocktrails TapTweak trail math moved to // ./lib/blocktrails.js (pure, no forge state) — imported & re-exported above. /** Agent -> pod username OR 64-hex nostr namespace (2.5), or null. */ function ownerFromAgent(agent) { if (!agent) return null; const hex = nostrHexOf(agent); if (hex) return hex; // the pubkey IS the namespace let u; try { u = new URL(agent); } catch { return null; } if (u.protocol !== 'http:' && u.protocol !== 'https:') return null; // other DID methods: no namespace const segs = u.pathname.split('/').filter(Boolean); const name = (segs.length >= 2 && segs[0] !== 'profile') ? segs[0] : (u.hostname.split('.')[0] || null); return name && OWNER_NAME.test(name) && !name.includes('..') ? name : null; } /** WebID -> pod root path ('/casey/' or '/'), mastodon/'s podFromWebid rule. */ function podPathFromAgent(agent) { if (!agent) return null; let u; try { u = new URL(agent); } catch { return null; } if (u.protocol !== 'http:' && u.protocol !== 'https:') return null; const segs = u.pathname.split('/').filter(Boolean); if (segs.length >= 2 && segs[0] !== 'profile') { return OWNER_NAME.test(segs[0]) ? `/${segs[0]}/` : null; } return '/'; } /** * Collect a streamed JSON body (the forge scope's wildcard parser hands the * raw stream through for the git CGI lane, so API writes read it here). * Returns the parsed object, or null when missing/oversized/unparseable. * * Tier-2.5 wrinkle: this MUST run before getAgent on any authed write. * NIP-98's optional `payload` tag is sha256 over the wire bytes, and the * host verifier hashes `request.rawBody` (or a Buffer body) — in this * scope `request.body` is the raw stream, which the verifier would * JSON.stringify into garbage. So the buffered wire string is stashed on * `request.rawBody` (and the parsed object on `request.body`) here, which * makes body-carrying NIP-98 requests verify exactly as on core routes. */ async function readJsonBody(request, cap = ISSUE_BODY_CAP + 8192) { let body = request.body; if (body == null) return null; if (typeof body === 'object' && !Buffer.isBuffer(body) && typeof body.pipe !== 'function' && !(body instanceof Uint8Array) && !(Symbol.asyncIterator in body)) { return body; // already parsed (not the case in this scope, but harmless) } let buf; if (Buffer.isBuffer(body)) buf = body; else if (typeof body === 'string') buf = Buffer.from(body); else { const chunks = []; let total = 0; try { for await (const chunk of body) { total += chunk.length; if (total > cap) return null; chunks.push(chunk); } } catch { return null; } buf = Buffer.concat(chunks); } if (buf.length > cap) return null; const text = buf.toString('utf8'); request.rawBody = text; // NIP-98 payload-tag verification hashes this try { const parsed = JSON.parse(text); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { request.body = parsed; return parsed; } return null; } catch { return null; } } // markdown renderer (escape-first grammar + GFM tables) moved to // ./lib/markdown.js — renderMarkdown imported above. // ------------------------------------------------------------- diff parse // ONE structured parse feeds both the HTML renderer and the JSON API's // files[]/hunks[] shape (the Gitea-parity surface): each file is // { name, binary, adds, dels, hunks: [{ header, lines: [{ type: 'add'| // 'del'|'ctx'|'meta', oldLine, newLine, text }] }] }. function parsePatch(patch) { const files = []; let cur = null; let hunk = null; let oldN = 0; let newN = 0; for (const line of String(patch).split('\n')) { if (line.startsWith('diff --git ')) { cur = { name: null, binary: false, adds: 0, dels: 0, hunks: [] }; hunk = null; files.push(cur); const m = /^diff --git a\/(.*) b\/(.*)$/.exec(line); if (m) cur.name = m[2]; continue; } if (!cur) continue; if (line.startsWith('+++ ')) { const n = line.slice(4); if (n !== '/dev/null') cur.name = n.replace(/^b\//, ''); continue; } if (line.startsWith('--- ')) { const n = line.slice(4); if (!cur.name && n !== '/dev/null') cur.name = n.replace(/^a\//, ''); continue; } if (line.startsWith('Binary files')) { cur.binary = true; continue; } const h = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); if (h) { oldN = +h[1]; newN = +h[2]; hunk = { header: line, lines: [] }; cur.hunks.push(hunk); continue; } if (!hunk) continue; // index/mode/rename preamble: not table content if (line.startsWith('+')) { hunk.lines.push({ type: 'add', oldLine: null, newLine: newN++, text: line.slice(1) }); cur.adds += 1; continue; } if (line.startsWith('-')) { hunk.lines.push({ type: 'del', oldLine: oldN++, newLine: null, text: line.slice(1) }); cur.dels += 1; continue; } if (line.startsWith(' ')) { hunk.lines.push({ type: 'ctx', oldLine: oldN++, newLine: newN++, text: line.slice(1) }); continue; } if (line.startsWith('\\')) hunk.lines.push({ type: 'meta', oldLine: null, newLine: null, text: line }); } return files; } function renderDiff(files) { if (!files.length) return '

No changes.

'; return files.map((f) => { const name = esc(f.name ?? '(unknown)'); let body; if (f.binary) { body = '
Binary file not shown.
'; } else { const rows = f.hunks.map((h) => [ `${esc(h.header)}`, ...h.lines.map((l) => { if (l.type === 'meta') return `${esc(l.text)}`; const cls = l.type === 'add' ? 'add' : l.type === 'del' ? 'del' : 'ctx'; const sign = l.type === 'add' ? '+' : l.type === 'del' ? '-' : ' '; return `${l.oldLine ?? ''}${l.newLine ?? ''}${sign}${esc(l.text)}`; }), ].join('')).join(''); body = `${rows}
`; } return `
${name}` + `+${f.adds} −${f.dels}${body}
`; }).join('\n'); } // ----------------------------------------------------------- the html shell const CSS = ` :root{color-scheme:light} *{box-sizing:border-box} body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif; font-size:14px;line-height:1.5;color:#1f2328;background:#ffffff} a{color:#0969da;text-decoration:none} a:hover{text-decoration:underline} code,pre,.mono{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace} .container{max-width:1216px;margin:0 auto;padding:0 24px} .muted{color:#59636e} .icon{vertical-align:text-bottom} .avatar{border-radius:4px;vertical-align:middle} .topbar{background:#f6f8fa;border-bottom:1px solid #d0d7de;padding:12px 0} .topbar .container{display:flex;align-items:center;gap:8px} .topbar a.brand{color:#1f2328;font-weight:600} .repo-strip{background:#f6f8fa;border-bottom:1px solid #d0d7de;padding-top:16px} .crumb{font-size:20px;display:flex;align-items:center;gap:8px} .crumb a{color:#0969da} .badge{display:inline-block;border:1px solid #d0d7de;color:#59636e;border-radius:999px; padding:0 7px;font-size:12px;font-weight:500;line-height:18px;vertical-align:middle} .tabs{display:flex;gap:8px;margin-top:12px;overflow-x:auto;scrollbar-width:none} .tabs::-webkit-scrollbar{display:none} .tab{display:inline-flex;align-items:center;gap:6px;padding:8px 14px 10px;color:#1f2328; border-bottom:2px solid transparent;font-size:14px;flex:none;white-space:nowrap} .tab:hover{text-decoration:none;border-bottom-color:#d0d7de} .tab.active{font-weight:600;border-bottom-color:#fd8c73} main{padding:24px 0} .btn{display:inline-block;padding:5px 16px;font-size:14px;font-weight:500;line-height:20px; border:1px solid #d0d7de;border-radius:6px;background:#f6f8fa;color:#1f2328;cursor:pointer} .btn:hover{background:#eef1f4;text-decoration:none} .btn-primary{background:#1f883d;border-color:rgba(31,35,40,0.15);color:#ffffff} .btn-primary:hover{background:#1a7f37} .box{border:1px solid #d0d7de;border-radius:8px;overflow:hidden} .commitbar{display:flex;align-items:center;gap:8px;background:#f6f8fa;padding:10px 16px; border-bottom:1px solid #d0d7de;font-size:13px} .commitbar .msg{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} table.files{width:100%;border-spacing:0;font-size:14px} table.files td{padding:0 16px;height:40px;border-top:1px solid #d0d7de} table.files tr:first-child td{border-top:0} table.files tr:hover{background:#f6f8fa} table.files td.name{width:35%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} table.files td.name a{color:#1f2328} table.files td.name a:hover{color:#0969da} table.files td.cmsg{color:#59636e;max-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} table.files td.cmsg a{color:#59636e} table.files td.age{color:#59636e;text-align:right;white-space:nowrap} .readme{border:1px solid #d0d7de;border-radius:8px;margin-top:16px} .readme .rtitle{padding:10px 16px;border-bottom:1px solid #d0d7de;font-weight:600;font-size:14px} .markdown-body{padding:16px 32px;font-size:16px;overflow-x:auto} .markdown-body h1,.markdown-body h2{border-bottom:1px solid #d0d7de;padding-bottom:.3em} .markdown-body h1{font-size:2em;margin:.67em 0 16px} .markdown-body h2{font-size:1.5em} .markdown-body code{background:rgba(129,139,152,0.12);border-radius:6px;padding:.2em .4em;font-size:85%} .markdown-body pre{background:#f6f8fa;border-radius:6px;padding:16px;overflow:auto;font-size:85%;line-height:1.45} .markdown-body pre code{background:transparent;padding:0;font-size:100%} .markdown-body table{border-collapse:collapse;margin:0 0 16px;display:block;width:max-content;max-width:100%;overflow-x:auto} .markdown-body th,.markdown-body td{border:1px solid #d0d7de;padding:6px 13px} .markdown-body th{background:#f6f8fa;font-weight:600} .markdown-body tr:nth-child(2n) td{background:#f6f8fa} .markdown-body blockquote{margin:0 0 16px;padding:0 1em;color:#59636e;border-left:.25em solid #d0d7de} .markdown-body img{max-width:100%} .clonebox{display:flex;gap:8px;align-items:center} .clonebox input{flex:1;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px; padding:5px 12px;border:1px solid #d0d7de;border-radius:6px;color:#1f2328;background:#f6f8fa;min-width:280px} details.dd{position:relative;display:inline-block} details.dd summary{list-style:none;cursor:pointer} details.dd summary::-webkit-details-marker{display:none} details.dd .menu{position:absolute;z-index:10;margin-top:6px;background:#ffffff;border:1px solid #d0d7de; border-radius:8px;box-shadow:0 8px 24px rgba(140,149,159,0.2);padding:8px;min-width:300px} .menu .mtitle{font-weight:600;font-size:12px;padding:4px 8px;border-bottom:1px solid #d0d7de;margin-bottom:6px} .menu a{display:block;padding:6px 8px;border-radius:6px;color:#1f2328;font-size:13px} .menu a:hover{background:#f6f8fa;text-decoration:none} table.blob{width:100%;border-spacing:0;font-size:12px;line-height:20px} table.blob td.num{width:1%;min-width:50px;text-align:right;padding:0 10px;color:#59636e; background:rgba(0,0,0,0.01);user-select:none;vertical-align:top} table.blob td.code{padding:0 10px;white-space:pre;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} .blobhead{display:flex;align-items:center;gap:12px;background:#f6f8fa;border-bottom:1px solid #d0d7de; padding:8px 16px;font-size:12px} .dfile{border:1px solid #d0d7de;border-radius:8px;margin-bottom:16px;overflow:hidden} .dhead{display:flex;justify-content:space-between;align-items:center;background:#f6f8fa; padding:8px 12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;cursor:pointer} .dhead .counts{font-weight:600} .adds{color:#1a7f37} .dels{color:#cf222e} table.diff{width:100%;border-spacing:0;font-size:12px;line-height:20px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} table.diff td{padding:0 10px} table.diff td.num{width:1%;min-width:40px;text-align:right;color:#59636e;background:rgba(0,0,0,0.01);user-select:none} table.diff td.code{white-space:pre-wrap;word-break:break-all;width:100%} tr.add td.code{background:#dafbe1} tr.add td.num{background:#aceebb} tr.del td.code{background:#ffebe9} tr.del td.num{background:#ffcecb} tr.hunk td{background:#ddf4ff;color:#0969da} .dbinary{padding:16px} .list{border:1px solid #d0d7de;border-radius:8px} .list .row{display:flex;align-items:center;gap:10px;padding:12px 16px;border-top:1px solid #d0d7de} .list .row:first-child{border-top:0} .list .grow{flex:1;min-width:0} .pager{display:flex;justify-content:center;gap:8px;margin-top:16px} .repocard{border:1px solid #d0d7de;border-radius:8px;padding:16px;margin-bottom:12px} .repocard h3{margin:0 0 4px;font-size:16px} .empty{border:1px solid #d0d7de;border-radius:8px;padding:32px;margin-top:16px} h1.page{font-size:24px;margin:0 0 16px} .commitpage h2{margin:0;font-size:18px} .cmeta{display:flex;align-items:center;gap:8px;background:#f6f8fa;border:1px solid #d0d7de; border-radius:8px;padding:10px 16px;margin:12px 0 20px;font-size:13px} .sha{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;color:#59636e} .state-pill{display:inline-block;padding:5px 14px;border-radius:999px;color:#ffffff;font-size:14px;font-weight:500} .state-open{background:#1f883d} .state-closed{background:#8250df} .fstate{display:flex;gap:16px;padding:12px 16px;background:#f6f8fa;border-bottom:1px solid #d0d7de;font-size:14px} .fstate a{color:#59636e} .fstate a.active{color:#1f2328;font-weight:600} .ititle{color:#1f2328;font-weight:600;font-size:16px} .ititle:hover{color:#0969da} .cbox{border:1px solid #d0d7de;border-radius:8px;margin-bottom:16px;overflow:hidden} .chead{display:flex;align-items:center;gap:8px;background:#f6f8fa;border-bottom:1px solid #d0d7de; padding:8px 16px;font-size:13px} .chead a{color:#1f2328} .cbox .markdown-body{font-size:14px;padding:16px} .removed{padding:16px;color:#59636e;font-style:italic;background:#f6f8fa} .authbox{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:16px;padding:8px 12px; border:1px solid #d0d7de;border-radius:8px;background:#f6f8fa;font-size:13px} .authbox input{padding:5px 12px;border:1px solid #d0d7de;border-radius:6px;font-size:13px} .issueform input[type="text"],.issueform textarea{width:100%;padding:8px 12px;border:1px solid #d0d7de; border-radius:6px;font-size:14px;font-family:inherit;margin-bottom:8px;background:#ffffff} .issueform textarea{min-height:140px;line-height:1.5;resize:vertical} .formmsg{color:#cf222e;font-size:13px} .state-merged{background:#8250df} .state-closed-red{background:#cf222e} .mergebox{border:1px solid #d0d7de;border-radius:8px;margin-bottom:16px;padding:12px 16px} .mergebox.clean{border-color:#1f883d} .mergebox.conflict{border-color:#cf222e} .mergebox h3{margin:0 0 4px;font-size:14px} .mergebox .clean-note{color:#1a7f37} .mergebox .conflict-note{color:#cf222e} .pushhint{display:flex;align-items:center;gap:8px;border:1px solid #d4a72c66;background:#fff8c5; border-radius:8px;padding:10px 16px;margin-bottom:16px;font-size:13px} .aheadbehind{display:inline-block;border:1px solid #d0d7de;border-radius:6px;padding:2px 10px; font-size:12px;color:#59636e} .label-chip{display:inline-block;padding:0 8px;border-radius:999px;font-size:12px;font-weight:500; line-height:18px;border:1px solid rgba(31,35,40,0.12);vertical-align:middle;white-space:nowrap} .searchform{display:flex;gap:8px;align-items:center} .searchform input{padding:5px 12px;border:1px solid #d0d7de;border-radius:6px;font-size:14px; background:#ffffff;min-width:220px} .excerpt{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px; white-space:pre-wrap;word-break:break-all;color:#1f2328} .chip{display:inline-block;padding:0 10px;border-radius:999px;font-size:12px;font-weight:500;line-height:20px;white-space:nowrap} .chip-pending{background:#fff8c5;color:#7d4e00;border:1px solid #d4a72c66} .chip-marked{background:#dafbe1;color:#1a7f37;border:1px solid #1f883d55} table.marks{width:100%;border-spacing:0;font-size:13px} table.marks th,table.marks td{padding:8px 12px;border-top:1px solid #d0d7de;text-align:left;vertical-align:top} table.marks th{border-top:0;background:#f6f8fa;font-size:12px;color:#59636e;font-weight:600} table.marks code{font-size:12px;word-break:break-all} .markstats{display:flex;flex-wrap:wrap;gap:12px;margin:0 0 16px} .mstat{flex:1 1 160px;border:1px solid #d0d7de;border-radius:8px;padding:12px 14px;background:#f6f8fa} .mstat .k{font-size:12px;color:#59636e;text-transform:uppercase;letter-spacing:.03em} .mstat .v{font-size:20px;font-weight:600;margin-top:3px;line-height:1.2} .mstat .v .unit{font-size:13px;font-weight:400;color:#59636e} .mstat .s{font-size:12px;color:#59636e;margin-top:3px} .fundbox{border:1px solid #d4a72c66;background:#fff8c5;border-radius:8px;padding:12px 16px;margin-top:16px} .fundbox pre{background:#ffffffaa;border-radius:6px;padding:12px;overflow-x:auto;font-size:12px} @media (max-width:640px){ .container{padding:0 14px} .crumb{font-size:17px} .tabs{margin-top:8px} .tab{padding:8px 10px 10px} .topbar .container{flex-wrap:wrap;row-gap:6px} main{padding:16px 0} .markdown-body{padding:12px 16px} .issueform input,.issueform textarea{min-width:0} } `; // connect-src 'self' is load-bearing for tier 2: the issues client drives // the JSON API and /idp/credentials with fetch(), which CSP counts as // connect-src — under `default-src 'none'` alone every fetch is blocked. // // Tier 2.5: script-src gains 'self' (the vendored xlogin widget loads via // breaker. // The vendored xlogin widget loads first (script-src 'self'); when it is // present the auth area offers its button beside the local // username/password fallback, and writes go through // window.xlogin.authFetch (NIP-98 or DPoP — both verified server-side // by getAgent). All DOM writes stay textContent/createElement. return ` `; } const authBox = (verb) => `
` + `
`; async function issuesListPage(reply, owner, name, query) { const branch = await defaultBranch(repoDirOf(owner, name)); const idx = loadIssueIndex(owner, name); const all = Object.values(idx.issues).sort((a, b) => b.number - a.number); const openCount = all.filter((i) => i.state === 'open').length; const closedCount = all.length - openCount; const state = query?.state === 'closed' ? 'closed' : 'open'; const label = typeof query?.label === 'string' && query.label ? query.label.slice(0, 50) : null; const lq = label ? `&label=${encodeURIComponent(label)}` : ''; const defs = Array.isArray(idx.labels) ? idx.labels : DEFAULT_LABELS; const pageNo = Math.max(1, Math.min(10000, parseInt(query?.page, 10) || 1)); const filtered = all.filter((i) => i.state === state && (!label || (Array.isArray(i.labels) && i.labels.includes(label)))); const slice = filtered.slice((pageNo - 1) * ISSUES_PER_PAGE, pageNo * ISSUES_PER_PAGE); const base = `${prefix}/${owner}/${name}`; const rows = slice.map((i) => { const n = i.thread.length - 1; return `
${i.state === 'open' ? ICON_ISSUE_OPEN : ICON_ISSUE_CLOSED}
${esc(i.title)} ${labelChips(resolveLabels(i.labels, defs))}
#${i.number} opened ${relTime(i.createdAt)} by ${esc(displayName(i.author))}
${n ? `${n} comment${n === 1 ? '' : 's'}` : ''}
`; }).join('\n'); const filterTabs = `
${ICON_ISSUE_OPEN} ${openCount} Open ${ICON_ISSUE_CLOSED} ${closedCount} Closed ${label ? `label: ${labelChips(resolveLabels([label], defs))} × clear` : ''}
`; const pager = `
${pageNo > 1 ? `← Newer` : ''} ${filtered.length > pageNo * ISSUES_PER_PAGE ? `Older →` : ''}
`; const body = `${repoStrip(owner, name, 'issues', branch)}

Issues

New issue
${filterTabs}${rows || `
No ${state} issues.
`}
${pager}
`; return sendHtml(reply, 200, page(`Issues · ${owner}/${name}`, body)); } /** The comment boxes of a thread — issues and pulls render identically. */ function threadBoxes(entries, ctx, owner, openVerb) { return entries.map((e, i) => { const who = displayName(e.author); const ownerBadge = ownerFromAgent(e.author) === owner ? ' owner' : ''; // Podless authors: their words live in pluginDir, not a pod — say so. const hostedTag = e.hosted ? ' hosted by the forge' : ''; // Author-only Delete (server-enforced: pod WAC on the body, or the hosted // DELETE endpoint — a non-author's request 403s). Shown to any signed-in // user like the close/reopen button; deleting shows the existing tombstone. const del = (e.removed || !e.resourceUrl) ? '' : ``; // Author-only Edit: fetch the raw body from resourceUrl, PUT it back // (pod WAC for WebID authors; the hosted PUT endpoint for did:nostr). const edit = (e.removed || !e.resourceUrl) ? '' : ``; const head = `${del}${edit}${identicon(who)} ${esc(who)}${ownerBadge}${hostedTag} ${i === 0 ? openVerb : 'commented'} ${relTime(e.at)}`; const slot = e.removed ? '
content removed by its author
' : `
${renderMarkdown(e.body, ctx)}
`; return `
${head}
${slot}
`; }).join('\n'); } async function issueThreadPage(reply, owner, name, number) { const idx = loadIssueIndex(owner, name); const issue = idx.issues[number]; if (!issue) return notFound(reply); const branch = await defaultBranch(repoDirOf(owner, name)); const entries = await resolveThread(issue.thread); const ctx = issueMdCtx(owner, name); const base = `${prefix}/${owner}/${name}`; const boxes = threadBoxes(entries, ctx, owner, 'opened this issue'); const open = issue.state === 'open'; const nComments = issue.thread.length - 1; const body = `${repoStrip(owner, name, 'issues', branch)}

${esc(issue.title)} #${issue.number}

${open ? 'Open' : 'Closed'} ${labelChips(resolveLabels(issue.labels, Array.isArray(idx.labels) ? idx.labels : DEFAULT_LABELS))} ${esc(displayName(issue.author))} opened this issue ${relTime(issue.createdAt)} · ${nComments} comment${nComments === 1 ? '' : 's'}
${boxes} ${authBox('Commenting, closing, or reopening')}
Add a comment (stored in YOUR pod, markdown supported)
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, owner, name, base, thread: `/issues/${issue.number}`, state: issue.state })}`; return sendHtml(reply, 200, page(`${issue.title} · #${issue.number} · ${owner}/${name}`, body)); } async function newIssuePage(reply, owner, name) { const branch = await defaultBranch(repoDirOf(owner, name)); const base = `${prefix}/${owner}/${name}`; const body = `${repoStrip(owner, name, 'issues', branch)}

New issue

${authBox('Opening an issue')}
Describe the issue (the body is stored in YOUR pod, markdown supported)
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, owner, name, base, thread: null, state: null })}`; return sendHtml(reply, 200, page(`New issue · ${owner}/${name}`, body)); } // -------------------------------------------- compare + pulls pages (3a) const PR_STATE = { open: { icon: ICON_PR_OPEN, pill: 'state-open', label: 'Open' }, merged: { icon: ICON_PR_MERGED, pill: 'state-merged', label: 'Merged' }, closed: { icon: ICON_PR_CLOSED, pill: 'state-closed-red', label: 'Closed' }, }; function commitRows(owner, name, commits) { const base = `${prefix}/${owner}/${name}`; return commits.map((c) => `
${identicon(c.email)}
${esc(c.author)} committed ${relTime(c.at)}
${esc(c.short)}
`).join('\n'); } async function comparePage(reply, owner, name, spec) { const dots = spec.indexOf('...'); if (dots < 1) return notFound(reply); const baseRef = spec.slice(0, dots); const headSpec = spec.slice(dots + 3); const cmp = await compareData(owner, name, baseRef, headSpec); if (!cmp) return notFound(reply); const branch = await defaultBranch(repoDirOf(owner, name)); const base = `${prefix}/${owner}/${name}`; const headLabel = `${dispOwner(cmp.head.owner)}:${cmp.head.ref}`; const prHref = `${base}/pulls/new?base=${encodeURIComponent(baseRef)}&head=${encodeURIComponent(headSpec)}`; const n = cmp.commits.length; const body = `${repoStrip(owner, name, 'pulls', branch)}

Comparing ${esc(baseRef)}...${esc(headLabel)}

${cmp.aheadBy} ahead, ${cmp.behindBy} behind ${esc(baseRef)} ${cmp.aheadBy ? `Open pull request` : 'Nothing to compare: the base contains the head.'}

${n}${cmp.hasMore ? '+' : ''} commit${n === 1 ? '' : 's'}

${commitRows(owner, name, cmp.commits) || '
No commits.
'}
${renderDiff(cmp.files)}
`; return sendHtml(reply, 200, page(`Comparing ${baseRef}...${headLabel} · ${owner}/${name}`, body)); } async function pullsListPage(reply, owner, name, query) { const branch = await defaultBranch(repoDirOf(owner, name)); const idx = loadPullIndex(owner, name); const all = Object.values(idx.pulls).sort((a, b) => b.number - a.number); const count = (s) => all.filter((p) => p.state === s).length; const state = ['merged', 'closed'].includes(query?.state) ? query.state : 'open'; const label = typeof query?.label === 'string' && query.label ? query.label.slice(0, 50) : null; const lq = label ? `&label=${encodeURIComponent(label)}` : ''; const defs = labelDefs(owner, name); // the label SET lives with the issues index const pageNo = Math.max(1, Math.min(10000, parseInt(query?.page, 10) || 1)); const filtered = all.filter((p) => p.state === state && (!label || (Array.isArray(p.labels) && p.labels.includes(label)))); const slice = filtered.slice((pageNo - 1) * ISSUES_PER_PAGE, pageNo * ISSUES_PER_PAGE); const base = `${prefix}/${owner}/${name}`; const rows = slice.map((p) => { const nc = p.thread.length - 1; return `
${PR_STATE[p.state].icon}
${esc(p.title)} ${labelChips(resolveLabels(p.labels, defs))}
#${p.number} opened ${relTime(p.createdAt)} by ${esc(displayName(p.author))} · ${esc(dispOwner(p.head.owner))}:${esc(p.head.ref)} → ${esc(p.base)}
${nc ? `${nc} comment${nc === 1 ? '' : 's'}` : ''}
`; }).join('\n'); const filterTabs = ``; const pager = `
${pageNo > 1 ? `← Newer` : ''} ${filtered.length > pageNo * ISSUES_PER_PAGE ? `Older →` : ''}
`; const body = `${repoStrip(owner, name, 'pulls', branch)}

Pull requests

${filterTabs}${rows || `
No ${state} pull requests.
`}
${pager}
`; return sendHtml(reply, 200, page(`Pull requests · ${owner}/${name}`, body)); } function pullSubTabs(base, number, active) { const tabs = [ ['conversation', 'Conversation', `${base}/pulls/${number}`], ['commits', 'Commits', `${base}/pulls/${number}/commits`], ['files', 'Files changed', `${base}/pulls/${number}/files`], ].map(([id, label, href]) => `${label}`).join('\n'); return `
${tabs}
`; } function pullHeadline(pr, owner, name) { const st = PR_STATE[pr.state]; const who = pr.state === 'merged' ? displayName(pr.merged?.mergedBy ?? pr.author) : displayName(pr.author); const verb = pr.state === 'merged' ? `merged ${(pr.merged?.headSha ?? '').slice(0, 7) ? `${esc(pr.merged.headSha.slice(0, 7))} ` : ''}into ${esc(pr.base)} ${relTime(pr.merged?.at ?? pr.createdAt)}` : `wants to merge ${esc(dispOwner(pr.head.owner))}:${esc(pr.head.ref)} into ${esc(pr.base)}`; return `
${st.label} ${labelChips(resolveLabels(pr.labels, labelDefs(owner, name)))} ${esc(who)} ${verb}
`; } async function pullPage(reply, owner, name, number) { const pr = loadPullIndex(owner, name).pulls[number]; if (!pr) return notFound(reply); const branch = await defaultBranch(repoDirOf(owner, name)); const entries = await resolveThread(pr.thread); const ctx = issueMdCtx(owner, name); const base = `${prefix}/${owner}/${name}`; const boxes = threadBoxes(entries, ctx, owner, 'opened this pull request'); const open = pr.state === 'open'; const info = open ? await pullMergeInfo(owner, name, pr) : null; // The state banner: merged (purple), closed (red), open with a clean / // conflicting / unavailable merge verdict (GitHub's beats). let mergeBox = ''; if (pr.state === 'merged') { mergeBox = `

${ICON_PR_MERGED} Merged

merge commit ${esc((pr.merged?.sha ?? '').slice(0, 12))} by ${esc(displayName(pr.merged?.mergedBy ?? ''))} ${relTime(pr.merged?.at ?? pr.createdAt)}${pr.merged?.fastForward ? ' (fast-forward)' : ''}
`; } else if (pr.state === 'closed') { mergeBox = `

${ICON_PR_CLOSED} Closed

This pull request was closed without merging.
`; } else if (info.mergeable === true) { mergeBox = `

This branch has no conflicts with the base branch

${info.fastForward ? 'Fast-forward merge (no merge commit needed).' : 'A merge commit will join the histories.'}
`; } else if (info.mergeable === false && info.upToDate) { mergeBox = `

Nothing to merge

The base branch already contains every commit of this branch.
`; } else if (info.mergeable === false) { mergeBox = `

This branch has conflicts that must be resolved

Conflicting file${info.conflicts.length === 1 ? '' : 's'}:
    ${info.conflicts.map((c) => `
  • ${esc(c)}
  • `).join('')}
`; } else { mergeBox = `

Merge status unknown

${mergeTreeOk ? 'The head branch (or the base branch) is no longer available.' : `This server's ${esc(gitVersion)} lacks merge-tree --write-tree (needs git ≥ 2.38).`}
`; } const nComments = pr.thread.length - 1; const body = `${repoStrip(owner, name, 'pulls', branch)}

${esc(pr.title)} #${pr.number}

${pullHeadline(pr, owner, name)} ${pullSubTabs(base, pr.number, 'conversation')} ${boxes} ${mergeBox} ${authBox('Commenting, merging, closing, or reopening')}
Add a comment (stored in YOUR pod, markdown supported)
${pr.state !== 'merged' ? `` : ''}
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, owner, name, base, thread: `/pulls/${pr.number}`, state: pr.state, expectedBase: info?.baseSha ?? null })}`; return sendHtml(reply, 200, page(`${pr.title} · #${pr.number} · ${owner}/${name}`, body)); } async function pullCommitsPage(reply, owner, name, number) { const pr = loadPullIndex(owner, name).pulls[number]; if (!pr) return notFound(reply); const branch = await defaultBranch(repoDirOf(owner, name)); const { commits, hasMore } = await pullDiffData(owner, name, pr); const base = `${prefix}/${owner}/${name}`; const body = `${repoStrip(owner, name, 'pulls', branch)}

${esc(pr.title)} #${pr.number}

${pullHeadline(pr, owner, name)} ${pullSubTabs(base, pr.number, 'commits')}
${commitRows(owner, name, commits) || '
No commits.
'}
${hasMore ? '

Only the first 30 commits are shown.

' : ''}
`; return sendHtml(reply, 200, page(`Commits · #${pr.number} · ${owner}/${name}`, body)); } async function pullFilesPage(reply, owner, name, number) { const pr = loadPullIndex(owner, name).pulls[number]; if (!pr) return notFound(reply); const branch = await defaultBranch(repoDirOf(owner, name)); const { files } = await pullDiffData(owner, name, pr); const base = `${prefix}/${owner}/${name}`; const body = `${repoStrip(owner, name, 'pulls', branch)}

${esc(pr.title)} #${pr.number}

${pullHeadline(pr, owner, name)} ${pullSubTabs(base, pr.number, 'files')} ${renderDiff(files)}
`; return sendHtml(reply, 200, page(`Files changed · #${pr.number} · ${owner}/${name}`, body)); } async function newPullPage(reply, owner, name, query) { const baseRef = typeof query?.base === 'string' ? query.base : ''; const headSpec = typeof query?.head === 'string' ? query.head : ''; const cmp = await compareData(owner, name, baseRef, headSpec); if (!cmp) return notFound(reply); const branch = await defaultBranch(repoDirOf(owner, name)); const base = `${prefix}/${owner}/${name}`; const headLabel = `${dispOwner(cmp.head.owner)}:${cmp.head.ref}`; const body = `${repoStrip(owner, name, 'pulls', branch)}

Open a pull request

${esc(headLabel)}${esc(baseRef)} · ${cmp.aheadBy} commit${cmp.aheadBy === 1 ? '' : 's'} ahead · view the full comparison

${authBox('Opening a pull request')}
Describe the change (the body is stored in YOUR pod, markdown supported)
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, owner, name, base, thread: null, state: null, prBase: baseRef, prHead: headSpec })}`; return sendHtml(reply, 200, page(`New pull request · ${owner}/${name}`, body)); } // ------------------------------------- search + releases pages (polish) async function searchPage(reply, owner, name, query) { const q = typeof query?.q === 'string' ? query.q.trim().slice(0, SEARCH_QUERY_CAP) : ''; const branch = await defaultBranch(repoDirOf(owner, name)); const base = `${prefix}/${owner}/${name}`; const form = `
`; let results; if (!q) { results = `

Greps the default branch (${esc(branch)}) for a literal, case-insensitive substring — bounded, no index.

`; } else { const data = await searchData(owner, name, q); const matchRows = data.matches.map((m) => `
${ICON_FILE}
`).join('\n'); const pathRows = data.paths.map((p) => `
${ICON_FILE}
`).join('\n'); results = (data.matches.length || data.paths.length) ? `${data.matches.length ? `

${data.matches.length}${data.truncated ? '+' : ''} code match${data.matches.length === 1 ? '' : 'es'}

${matchRows}
` : ''} ${data.paths.length ? `

${data.paths.length} matching file path${data.paths.length === 1 ? '' : 's'}

${pathRows}
` : ''} ${data.truncated ? `

Results are capped (${SEARCH_HIT_CAP} code matches, ${SEARCH_PATH_CAP} paths); narrow the query for the rest.

` : ''}` : `

No results

Nothing on ${esc(branch)} matches ${esc(q)}.

`; } const body = `${repoStrip(owner, name, 'code', branch)}

Search

${form}
${results}
`; return sendHtml(reply, 200, page(`Search · ${owner}/${name}`, body)); } async function releasesPage(reply, owner, name) { const branch = await defaultBranch(repoDirOf(owner, name)); const rels = await listReleases(owner, name); const base = `${prefix}/${owner}/${name}`; const cards = rels.map((r) => `

${ICON_TAG} ${esc(r.tag)}${r.annotated ? '' : ' lightweight'}

${r.message ? `
${esc(r.message)}
` : ''}
${esc(r.sha.slice(0, 7))} · ${relTime(r.at)}
`).join('\n'); const body = `${repoStrip(owner, name, 'tags', branch)}

Releases every tag, newest first

${cards || '

No releases yet

Push a tag and it appears here with tarball and zip downloads.

'}
`; return sendHtml(reply, 200, page(`Releases · ${owner}/${name}`, body)); } /** * GET .../archive/.tar.gz|.zip — `git archive` STREAMED to the * response (child stdout, no buffering), --prefix=-/. * The ref is validated and resolved BEFORE any header goes out, so a * bogus ref is a clean 404. */ async function archiveResp(reply, owner, name, tail) { const spec = tail.join('/'); let format = null; let ext = null; if (spec.endsWith('.tar.gz')) { format = 'tar.gz'; ext = '.tar.gz'; } else if (spec.endsWith('.zip')) { format = 'zip'; ext = '.zip'; } if (!format) return notFound(reply); const ref = spec.slice(0, -ext.length); if (!okRef(ref) && !SHA_RE.test(ref)) return notFound(reply); const dir = repoDirOf(owner, name); if (!(await revParse(dir, `${ref}^{commit}`))) return notFound(reply); const flat = ref.replace(/\//g, '-'); // feature/x -> feature-x (validated charset: header-safe) const child = spawn('git', ['-C', dir, 'archive', `--format=${format}`, `--prefix=${name}-${flat}/`, ref], { env: gitEnv, stdio: ['ignore', 'pipe', 'pipe'] }); let stderr = ''; child.stderr.on('data', (d) => { stderr += d; }); child.on('close', (code) => { if (code !== 0) api.log.warn(`forge: git archive ${owner}/${name} ${ref} exited ${code}${stderr ? `: ${stderr.trim()}` : ''}`); }); reply.raw.on('close', () => { if (child.exitCode === null && !reply.raw.writableFinished) child.kill(); }); return reply.code(200) .header('content-type', format === 'zip' ? 'application/zip' : 'application/gzip') .header('content-disposition', `attachment; filename="${name}-${flat}${ext}"`) .header('x-content-type-options', 'nosniff') .send(child.stdout); } // ------------------------------------------------- marks page (3.5) /** Human chain badge: tbtc4 -> "testnet4", plus an honest testnet tag. */ function chainBadge(c) { const label = c === 'tbtc4' ? 'testnet4' : c === 'tbtc3' ? 'testnet3' : c; return `${esc(label)}${MAINNET_CHAINS.has(c) ? '' : ' (testnet — demonstrations, not value)'}`; } async function marksPage(reply, owner, name) { const branch = await defaultBranch(repoDirOf(owner, name)); const base = `${prefix}/${owner}/${name}`; const trail = loadTrail(owner, name); if (!trail) { // Not enabled: the Enable pitch. The button is the house client-JS // beat (sign in, authFetch POST, reload); the API enforces owner. const body = `${repoStrip(owner, name, 'anchors', branch)}

Anchors ${chainBadge(chain)}

Anchoring is not enabled

Anchor this repository's history to Bitcoin (${esc(chain)}) via Blocktrails: each default-branch tip derives a fresh taproot address by BIP-341 TapTweak; spending the previous mark's output to the new address advances a tamper-evident, Bitcoin-ordered trail. The forge only derives and records — funding, spending and verification all happen off-server.

Enabling mints a forge-held trail key (${esc(chain)} custody — see the README) and derives mark 0 from the current ${esc(branch)} tip.

${authBox('Enabling anchoring')}
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, owner, name, base, thread: null, state: null })}`; return sendHtml(reply, 200, page(`Anchors · ${owner}/${name}`, body)); } const explorerTx = (txid) => (mempoolBase ? `${esc(txid.slice(0, 10))}…` : `${esc(txid.slice(0, 10))}…`); const rows = trail.marks.map((m) => ` #${m.index} ${esc(m.state.commit.slice(0, 7))}
${esc(m.state.branch)}
${esc(m.stateHash.slice(0, 12))}… ${esc(m.address)} ${m.status === 'marked' ? 'marked' : 'pending'} ${m.status === 'marked' ? explorerTx(m.txid) : ''} `).join('\n'); const trailUrl = `${publicOrigin()}${base}/blocktrails.json`; const verifyHref = `https://blocktrails.github.io/verify/?uri=${encodeURIComponent(trailUrl)}`; // The next mark to land on-chain: first pending (marks are recorded // in order — the trail is a linear spend chain). const firstPending = trail.marks.find((m) => m.status !== 'marked') ?? null; const latest = trail.marks.at(-1); // Settled frontier: marks are a linear spend chain, so the 'marked' ones // form a prefix and the highest of them is the on-chain tip. Its output is // the live (unspent) UTXO — the trail's current funded balance. Everything // after it is derived-but-unfunded (pending), i.e. how far HEAD is ahead // of what Bitcoin has settled. All from trail data — no explorer call. const markedList = trail.marks.filter((m) => m.status === 'marked'); const frontier = markedList.at(-1) ?? null; const pendingCount = trail.marks.length - markedList.length; const groupSats = (n) => String(Math.max(0, Math.floor(Number(n) || 0))).replace(/\B(?=(\d{3})+(?!\d))/g, ','); const tipSats = frontier ? groupSats(frontier.amount) : null; const statStrip = `
Settled frontier
${frontier ? `#${frontier.index} ${esc(frontier.state.commit.slice(0, 7))}` : 'none yet'}
${frontier ? `on-chain tip · ${esc(frontier.state.branch)}` : 'genesis not yet funded'}
Balance at tip
${tipSats !== null ? `${tipSats} sat` : ''}
${frontier ? `live ${esc(trail.chain)} output` : 'unfunded'}
Pending
${pendingCount === 0 ? 'settled ✓' : `${pendingCount} mark${pendingCount === 1 ? '' : 's'}`}
${pendingCount === 0 ? 'trail matches HEAD on-chain' : 'ahead of the settled frontier'}
`; const fundBox = latest && latest.status !== 'marked' ? `

Fund this mark pending

Mark #${firstPending.index} is waiting for its on-chain output. Send ${esc(chain)} sats to the derived address${firstPending.index > 0 ? ' — by spending the previous mark’s output, which IS the advance' : ' (the genesis funding)'} — the forge never broadcasts anything itself.

${esc(firstPending.address)}
# on YOUR machine — funding and spending happen off-server
# 1. redeem a TXO voucher into git config nostr.privkey (maintainer's fund-agent):
npx fund-agent "txo:${esc(chain)}:<txid>:<vout>?amount=<sats>&key=<privkey>"
# 2. send/spend to the address above (any ${esc(chain)} wallet, or the git-mark CLI, npm: git-seal):
git mark ${firstPending.index === 0 ? 'genesis' : 'advance'} --txid <txid> --vout <n> --amount <sats>
# 3. report where it landed (repo owner), and the mark turns green:
#    POST ${esc(`${prefix}/api/repos/${owner}/${name}/marks/${firstPending.index}/txo`)}  {"txid":"…","vout":0,"amount":…}
${authBox('Recording a transaction')}
` : ''; const body = `${repoStrip(owner, name, 'anchors', branch)}

Anchors ${chainBadge(trail.chain)}

Trail key ${esc(trail.pubkeyBase.slice(0, 10))}… (forge-held, ${esc(trail.chain)}) · ${trail.marks.length} mark${trail.marks.length === 1 ? '' : 's'} · the server derives and records; verification runs client-side in the hosted verifier against the chain.

${statStrip}
${rows}
markcommitstate hashderived addressstatustx
${fundBox}
${issuesScript({ api: `${prefix}/api/repos/${owner}/${name}`, owner, name, base, thread: null, state: null, markIndex: firstPending ? firstPending.index : null })}`; return sendHtml(reply, 200, page(`Anchors · ${owner}/${name}`, body)); } function notFound(reply) { return sendHtml(reply, 404, page('Not found · Forge', '

404

This is not the repository you are looking for.

')); } // --------------------------------------------------------------- JSON API // The same model, machine-readable, under /api — the Gitea-parity // surface. Strings are RAW (JSON is the escape); the one exception is // readme.html, which is the server-side renderer's already-escaped HTML. // Shapes are documented in README.md and exercised by test.js. function sendJson(reply, status, obj) { return reply.code(status) .header('content-type', 'application/json; charset=utf-8') .header('x-content-type-options', 'nosniff') .send(JSON.stringify(obj)); } const apiErr = (reply, status, error) => sendJson(reply, status, { error }); // CORS for the web-edit lane: the demo edit page runs on another origin and // must be able to read the JSON reply (success AND error). Same permissive // grant as blocktrails.json — the edit endpoint is auth-gated (or explicitly // openEdit), so the CORS header widens reach, not authority. The matching // OPTIONS preflight is answered generically in the routing scope. const CORS_HEADERS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'content-type, authorization', 'access-control-max-age': '600', }; function withCors(reply) { for (const [k, v] of Object.entries(CORS_HEADERS)) reply.header(k, v); return reply; } const corsJson = (reply, status, obj) => sendJson(withCors(reply), status, obj); const corsErr = (reply, status, error) => corsJson(reply, status, { error }); async function apiRepoList(reply, owners) { const repos = []; for (const owner of owners) { for (const name of listRepoNames(owner)) { if (repos.length >= 200) break; const s = await repoSummary(owner, name); repos.push({ ...s, cloneUrl: cloneUrlOf(owner, name), url: `${prefix}/${owner}/${name}` }); } } repos.sort((a, b) => (b.lastPush ?? 0) - (a.lastPush ?? 0)); return sendJson(reply, 200, { repos }); } async function apiRepoMeta(reply, owner, name) { const dir = repoDirOf(owner, name); const summary = await repoSummary(owner, name); const branch = await defaultBranch(dir); const branches = await listRefs(dir, 'heads'); const tags = await listRefs(dir, 'tags'); let readme = null; if (branches.length) { try { const entries = await lsTree(dir, branch, ''); const r = await findReadme(dir, branch, entries); if (r && !r.tooLarge) { const base = `${prefix}/${owner}/${name}`; readme = { name: r.name, html: r.markdown ? renderMarkdown(r.text, { rawBase: `${base}/raw/${branch}`, blobBase: `${base}/blob/${branch}` }) : `
${esc(r.text)}
`, }; } } catch { /* unreadable README is not an error */ } } return sendJson(reply, 200, { owner, name, description: summary.description, lastPush: summary.lastPush, parent: summary.parent, // additive (3a): "/" | null forks: countForks(owner, name), // additive (3a) empty: branches.length === 0, defaultBranch: branch, branches, tags, cloneUrl: cloneUrlOf(owner, name), readme, }); } async function apiTree(reply, owner, name, segs) { const dir = repoDirOf(owner, name); const [ref, rest] = await splitRefPath(dir, segs); if (!okRef(ref) || !okPath(rest)) return apiErr(reply, 404, 'not found'); const dirPath = rest.join('/'); let entries; try { entries = await lsTree(dir, ref, dirPath); } catch { return apiErr(reply, 404, 'not found'); } const enriched = await Promise.all(entries.map(async (e, i) => ({ ...e, lastCommit: i < 100 ? await lastCommit(dir, ref, dirPath ? `${dirPath}/${e.name}` : e.name) : null, }))); return sendJson(reply, 200, { ref, path: dirPath, entries: enriched }); } async function apiBlob(reply, owner, name, segs) { const dir = repoDirOf(owner, name); const [ref, rest] = await splitRefPath(dir, segs); if (!okRef(ref) || !rest.length || !okPath(rest)) return apiErr(reply, 404, 'not found'); const p = rest.join('/'); if ((await objectType(dir, ref, p)) !== 'blob') return apiErr(reply, 404, 'not found'); const size = await blobSize(dir, ref, p); const out = { ref, path: p, size, binary: false, tooLarge: false, content: null }; if (size > RENDER_CAP) { out.tooLarge = true; } else { const buf = await catBlob(dir, ref, p); if (looksBinary(buf)) out.binary = true; else out.content = buf.toString('utf8'); } return sendJson(reply, 200, out); } async function apiCommits(reply, owner, name, segs, query) { const dir = repoDirOf(owner, name); const ref = segs.join('/'); if (!okRef(ref) && !SHA_RE.test(ref)) return apiErr(reply, 404, 'not found'); const pageNo = Math.max(1, Math.min(10000, parseInt(query?.page, 10) || 1)); try { const { commits, hasMore } = await commitLog(dir, ref, pageNo); return sendJson(reply, 200, { ref, page: pageNo, perPage: PER_PAGE, hasMore, commits }); } catch { return apiErr(reply, 404, 'not found'); } } async function apiCommit(reply, owner, name, sha) { if (!SHA_RE.test(sha)) return apiErr(reply, 404, 'not found'); const dir = repoDirOf(owner, name); const meta = await commitMeta(dir, sha); if (!meta) return apiErr(reply, 404, 'not found'); const files = await commitDiff(dir, sha); return sendJson(reply, 200, { sha: meta.full, short: meta.short, author: meta.author, email: meta.email, at: meta.at, parents: meta.parents, message: meta.message, files, }); } // ---- tier 2: issue endpoints (writes are Bearer-authed via getAgent) ---- /** * requestAgent (forge push token OR any getAgent scheme) or a JSON 401. * Returns null after replying. Callers that may receive a body MUST * buffer it (readJsonBody) BEFORE calling this — see readJsonBody's * NIP-98 payload-tag note. */ async function apiAgent(request, reply) { const agent = await requestAgent(request); if (!agent) { reply.header('WWW-Authenticate', 'Bearer realm="jss-forge"'); await apiErr(reply, 401, 'authentication required'); return null; } return agent; } function apiIssueList(reply, owner, name, query) { const idx = loadIssueIndex(owner, name); const all = Object.values(idx.issues).sort((a, b) => b.number - a.number); const openCount = all.filter((i) => i.state === 'open').length; const state = query?.state === 'closed' ? 'closed' : 'open'; const label = typeof query?.label === 'string' && query.label ? query.label.slice(0, 50) : null; const defs = Array.isArray(idx.labels) ? idx.labels : DEFAULT_LABELS; const pageNo = Math.max(1, Math.min(10000, parseInt(query?.page, 10) || 1)); const filtered = all.filter((i) => i.state === state && (!label || (Array.isArray(i.labels) && i.labels.includes(label)))); return sendJson(reply, 200, { state, page: pageNo, perPage: ISSUES_PER_PAGE, hasMore: filtered.length > pageNo * ISSUES_PER_PAGE, openCount, closedCount: all.length - openCount, issues: filtered.slice((pageNo - 1) * ISSUES_PER_PAGE, pageNo * ISSUES_PER_PAGE).map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, authorInfo: authorMeta(i.author), // additive (2.5): {id, displayName, npub?, kind} createdAt: i.createdAt, labels: resolveLabels(i.labels, defs), // additive (polish): [{name, color}] comments: i.thread.length - 1, })), }); } async function apiIssueGet(reply, owner, name, number) { const idx = loadIssueIndex(owner, name); const issue = idx.issues[number]; if (!issue) return apiErr(reply, 404, 'not found'); const resolved = await resolveThread(issue.thread); const ctx = issueMdCtx(owner, name); return sendJson(reply, 200, { number: issue.number, title: issue.title, state: issue.state, author: issue.author, authorInfo: authorMeta(issue.author), // additive (2.5) createdAt: issue.createdAt, labels: resolveLabels(issue.labels, Array.isArray(idx.labels) ? idx.labels : DEFAULT_LABELS), // additive (polish) thread: resolved.map((e) => ({ ...e, // includes hosted (2.5): true for forge-hosted podless content authorInfo: authorMeta(e.author), html: e.removed ? null : renderMarkdown(e.body, ctx), })), }); } async function apiIssueCreate(request, reply, owner, name) { // Body BEFORE auth: NIP-98 payload-tag verification needs the buffered // wire bytes on request.rawBody (see readJsonBody). const p = await readJsonBody(request); const agent = await apiAgent(request, reply); if (!agent) return reply; const podPath = podPathFromAgent(agent); const nostrHex = nostrHexOf(agent); if (!podPath && !nostrHex) return apiErr(reply, 403, 'no pod namespace for this agent'); if (!p) return apiErr(reply, 400, 'invalid JSON body'); const title = typeof p.title === 'string' ? p.title.trim() : ''; const bodyText = typeof p.body === 'string' ? p.body : ''; // Capstone: a pod agent may hand a POINTER (client already wrote the body // into its own pod via authFetch) instead of a server-written body. const usePointer = !!podPath && typeof p.resourceUrl === 'string' && p.resourceUrl.length > 0; if (!title || title.length > ISSUE_TITLE_CAP) return apiErr(reply, 422, `title required (1-${ISSUE_TITLE_CAP} chars)`); if (bodyText.length > ISSUE_BODY_CAP) return apiErr(reply, 422, 'body too large'); return withIssueLock(owner, name, async () => { const idx = loadIssueIndex(owner, name); const number = idx.next; const doc = { type: 'ForgeIssue', repo: `${owner}/${name}`, issue: number, title, body: bodyText, published: new Date().toISOString(), author: agent, }; // did:nostr agents have no pod: the forge hosts their words (2.5). const stored = nostrHex ? storeHosted(nostrHex, { ...doc, hosted: true }) : usePointer ? await registerPointer(request, agent, owner, name, p.resourceUrl) : await storeAuthored(request, podPath, owner, name, doc, `issue-${crypto.randomUUID()}.jsonld`); if (stored.error) return apiErr(reply, stored.status, stored.error); const at = Math.floor(Date.now() / 1000); idx.next = number + 1; idx.issues[number] = { number, title, state: 'open', author: agent, createdAt: at, thread: [{ author: agent, resourceUrl: stored.url, at, ...(nostrHex ? { hosted: true } : {}) }], }; saveIssueIndex(owner, name, idx); return sendJson(reply, 201, { number, url: `${prefix}/${owner}/${name}/issues/${number}`, resourceUrl: stored.url, ...(nostrHex ? { hosted: true } : {}), }); }); } async function apiIssueComment(request, reply, owner, name, number) { const p = await readJsonBody(request); // body before auth (NIP-98 payload tag) const agent = await apiAgent(request, reply); if (!agent) return reply; const podPath = podPathFromAgent(agent); const nostrHex = nostrHexOf(agent); if (!podPath && !nostrHex) return apiErr(reply, 403, 'no pod namespace for this agent'); if (!p) return apiErr(reply, 400, 'invalid JSON body'); const bodyText = typeof p.body === 'string' ? p.body : ''; const usePointer = !!podPath && typeof p.resourceUrl === 'string' && p.resourceUrl.length > 0; if (!usePointer && !bodyText.trim()) return apiErr(reply, 422, 'body required'); if (bodyText.length > ISSUE_BODY_CAP) return apiErr(reply, 422, 'body too large'); return withIssueLock(owner, name, async () => { const idx = loadIssueIndex(owner, name); const issue = idx.issues[number]; if (!issue) return apiErr(reply, 404, 'not found'); if (issue.thread.length >= THREAD_CAP) return apiErr(reply, 422, 'thread is full'); const doc = { type: 'ForgeComment', repo: `${owner}/${name}`, issue: number, body: bodyText, published: new Date().toISOString(), author: agent, }; const stored = nostrHex ? storeHosted(nostrHex, { ...doc, hosted: true }) : usePointer ? await registerPointer(request, agent, owner, name, p.resourceUrl) : await storeAuthored(request, podPath, owner, name, doc, `comment-${crypto.randomUUID()}.jsonld`); if (stored.error) return apiErr(reply, stored.status, stored.error); issue.thread.push({ author: agent, resourceUrl: stored.url, at: Math.floor(Date.now() / 1000), ...(nostrHex ? { hosted: true } : {}), }); saveIssueIndex(owner, name, idx); return sendJson(reply, 201, { number, comments: issue.thread.length - 1, resourceUrl: stored.url, ...(nostrHex ? { hosted: true } : {}), }); }); } /** close/reopen/retitle: index-only operations, repo owner OR issue author. */ const mayModerate = (agent, owner, issue) => ownerFromAgent(agent) === owner || agent === issue.author; async function apiIssueState(request, reply, owner, name, number, state) { await readJsonBody(request); // content unused; buffered for NIP-98 payload tags const agent = await apiAgent(request, reply); if (!agent) return reply; return withIssueLock(owner, name, async () => { const idx = loadIssueIndex(owner, name); const issue = idx.issues[number]; if (!issue) return apiErr(reply, 404, 'not found'); if (!mayModerate(agent, owner, issue)) return apiErr(reply, 403, 'only the repo owner or the issue author may do that'); issue.state = state; saveIssueIndex(owner, name, idx); return sendJson(reply, 200, { number, state }); }); } async function apiIssueRetitle(request, reply, owner, name, number) { const p = await readJsonBody(request); // body before auth (NIP-98 payload tag) const agent = await apiAgent(request, reply); if (!agent) return reply; if (!p) return apiErr(reply, 400, 'invalid JSON body'); const title = typeof p.title === 'string' ? p.title.trim() : ''; if (!title || title.length > ISSUE_TITLE_CAP) return apiErr(reply, 422, `title required (1-${ISSUE_TITLE_CAP} chars)`); return withIssueLock(owner, name, async () => { const idx = loadIssueIndex(owner, name); const issue = idx.issues[number]; if (!issue) return apiErr(reply, 404, 'not found'); if (!mayModerate(agent, owner, issue)) return apiErr(reply, 403, 'only the repo owner or the issue author may do that'); issue.title = title; saveIssueIndex(owner, name, idx); return sendJson(reply, 200, { number, title }); }); } async function apiRepoPatch(request, reply, owner, name) { const p = await readJsonBody(request); // body before auth (NIP-98 payload tag) const agent = await apiAgent(request, reply); if (!agent) return reply; if (ownerFromAgent(agent) !== owner) return apiErr(reply, 403, 'only the repo owner may edit the repo'); if (!p) return apiErr(reply, 400, 'invalid JSON body'); const description = typeof p.description === 'string' ? p.description.trim() : null; if (description === null || description.length > DESCRIPTION_CAP) { return apiErr(reply, 422, `description must be a string (max ${DESCRIPTION_CAP} chars)`); } fs.writeFileSync(path.join(repoDirOf(owner, name), 'description'), `${description}\n`); return sendJson(reply, 200, { owner, name, description }); } // ---- polish: label CRUD (owner-only) + per-item assignment ------------- async function apiLabelCreate(request, reply, owner, name) { const p = await readJsonBody(request); // body before auth (NIP-98 payload tag) const agent = await apiAgent(request, reply); if (!agent) return reply; if (ownerFromAgent(agent) !== owner) return apiErr(reply, 403, 'only the repo owner may manage labels'); if (!p) return apiErr(reply, 400, 'invalid JSON body'); const lname = typeof p.name === 'string' ? p.name.trim() : ''; if (!LABEL_NAME_RE.test(lname)) return apiErr(reply, 422, 'label name required (1-50 printable chars)'); if (typeof p.color !== 'string' || !LABEL_COLOR_RE.test(p.color)) return apiErr(reply, 422, 'color must be 6 hex digits'); const color = p.color.toLowerCase(); return withIssueLock(owner, name, async () => { const idx = loadIssueIndex(owner, name); const defs = ensureLabels(idx); // first write materializes the defaults if (defs.some((d) => d.name.toLowerCase() === lname.toLowerCase())) return apiErr(reply, 409, 'label exists'); defs.push({ name: lname, color }); saveIssueIndex(owner, name, idx); return sendJson(reply, 201, { name: lname, color }); }); } /** Rename cascades over items in BOTH indexes (items store names). */ async function apiLabelPatch(request, reply, owner, name, labelName) { const p = await readJsonBody(request); // body before auth (NIP-98 payload tag) const agent = await apiAgent(request, reply); if (!agent) return reply; if (ownerFromAgent(agent) !== owner) return apiErr(reply, 403, 'only the repo owner may manage labels'); if (!p) return apiErr(reply, 400, 'invalid JSON body'); return withIssueLock(owner, name, async () => { const idx = loadIssueIndex(owner, name); const defs = ensureLabels(idx); const def = defs.find((d) => d.name === labelName); if (!def) return apiErr(reply, 404, 'not found'); let newName = def.name; if (p.name !== undefined) { newName = typeof p.name === 'string' ? p.name.trim() : ''; if (!LABEL_NAME_RE.test(newName)) return apiErr(reply, 422, 'label name required (1-50 printable chars)'); if (newName !== def.name && defs.some((d) => d !== def && d.name.toLowerCase() === newName.toLowerCase())) { return apiErr(reply, 409, 'label exists'); } } if (p.color !== undefined) { if (typeof p.color !== 'string' || !LABEL_COLOR_RE.test(p.color)) return apiErr(reply, 422, 'color must be 6 hex digits'); def.color = p.color.toLowerCase(); } const oldName = def.name; def.name = newName; if (newName !== oldName) { for (const i of Object.values(idx.issues)) { if (Array.isArray(i.labels)) i.labels = i.labels.map((n) => (n === oldName ? newName : n)); } } saveIssueIndex(owner, name, idx); if (newName !== oldName) { await withPullLock(owner, name, async () => { const pidx = loadPullIndex(owner, name); let touched = false; for (const pr of Object.values(pidx.pulls)) { if (Array.isArray(pr.labels) && pr.labels.includes(oldName)) { pr.labels = pr.labels.map((n) => (n === oldName ? newName : n)); touched = true; } } if (touched) savePullIndex(owner, name, pidx); }); } return sendJson(reply, 200, { name: def.name, color: def.color }); }); } /** Delete cascades the label OFF every issue and pull that carries it. */ async function apiLabelDelete(request, reply, owner, name, labelName) { await readJsonBody(request); // content unused; buffered for NIP-98 payload tags const agent = await apiAgent(request, reply); if (!agent) return reply; if (ownerFromAgent(agent) !== owner) return apiErr(reply, 403, 'only the repo owner may manage labels'); return withIssueLock(owner, name, async () => { const idx = loadIssueIndex(owner, name); const defs = ensureLabels(idx); const at = defs.findIndex((d) => d.name === labelName); if (at === -1) return apiErr(reply, 404, 'not found'); defs.splice(at, 1); for (const i of Object.values(idx.issues)) { if (Array.isArray(i.labels)) i.labels = i.labels.filter((n) => n !== labelName); } saveIssueIndex(owner, name, idx); await withPullLock(owner, name, async () => { const pidx = loadPullIndex(owner, name); let touched = false; for (const pr of Object.values(pidx.pulls)) { if (Array.isArray(pr.labels) && pr.labels.includes(labelName)) { pr.labels = pr.labels.filter((n) => n !== labelName); touched = true; } } if (touched) savePullIndex(owner, name, pidx); }); return sendJson(reply, 200, { name: labelName, deleted: true }); }); } async function apiLabelsHandler(request, reply, owner, name, tail) { const method = request.method; if (tail.length === 0) { if (method === 'GET' || method === 'HEAD') return sendJson(reply, 200, { labels: labelDefs(owner, name) }); if (method === 'POST') return apiLabelCreate(request, reply, owner, name); return apiErr(reply, 405, 'method not allowed'); } if (tail.length === 1) { let labelName; // the segment arrives raw-encoded (the dispatcher does not decode) try { labelName = decodeURIComponent(tail[0]); } catch { return apiErr(reply, 404, 'not found'); } if (method === 'PATCH') return apiLabelPatch(request, reply, owner, name, labelName); if (method === 'DELETE') return apiLabelDelete(request, reply, owner, name, labelName); return apiErr(reply, 405, 'method not allowed'); } return apiErr(reply, 404, 'not found'); } /** * PUT .../{issues,pulls}//labels {labels: [names]} — replaces the * item's label set; repo owner or the item's author. Every name must * exist in the repo's label set (names are validated, never invented). */ async function apiItemLabels(request, reply, owner, name, number, kind) { const p = await readJsonBody(request); // body before auth (NIP-98 payload tag) const agent = await apiAgent(request, reply); if (!agent) return reply; if (!p || !Array.isArray(p.labels)) return apiErr(reply, 400, 'body must be { labels: [names] }'); if (p.labels.length > LABELS_PER_ITEM) return apiErr(reply, 422, `at most ${LABELS_PER_ITEM} labels per item`); const defs = labelDefs(owner, name); const names = []; for (const n of p.labels) { if (typeof n !== 'string' || !defs.some((d) => d.name === n)) { return apiErr(reply, 422, `unknown label: ${String(n).slice(0, 60)}`); } if (!names.includes(n)) names.push(n); } const [withLock, load, save, itemsKey] = kind === 'issues' ? [withIssueLock, loadIssueIndex, saveIssueIndex, 'issues'] : [withPullLock, loadPullIndex, savePullIndex, 'pulls']; return withLock(owner, name, async () => { const idx = load(owner, name); const item = idx[itemsKey][number]; if (!item) return apiErr(reply, 404, 'not found'); if (!mayModerate(agent, owner, item)) return apiErr(reply, 403, 'only the repo owner or the item author may set labels'); item.labels = names; save(owner, name, idx); return sendJson(reply, 200, { number: item.number, labels: names }); }); } // ---- polish: read-time search + releases ------------------------------- async function apiSearch(reply, owner, name, query) { const q = typeof query?.q === 'string' ? query.q.trim() : ''; if (!q) return apiErr(reply, 422, 'q required'); if (q.length > SEARCH_QUERY_CAP) return apiErr(reply, 422, `q too long (max ${SEARCH_QUERY_CAP} chars)`); return sendJson(reply, 200, await searchData(owner, name, q)); } async function apiReleases(reply, owner, name) { return sendJson(reply, 200, { releases: await listReleases(owner, name) }); } async function apiIssuesHandler(request, reply, owner, name, tail) { const method = request.method; if (tail.length === 0) { if (method === 'POST') return apiIssueCreate(request, reply, owner, name); if (method === 'GET' || method === 'HEAD') return apiIssueList(reply, owner, name, request.query); return apiErr(reply, 405, 'method not allowed'); } if (!ISSUE_NUM_RE.test(tail[0])) return apiErr(reply, 404, 'not found'); const number = +tail[0]; if (tail.length === 1) { if (method === 'GET' || method === 'HEAD') return apiIssueGet(reply, owner, name, number); if (method === 'PATCH') return apiIssueRetitle(request, reply, owner, name, number); return apiErr(reply, 405, 'method not allowed'); } if (tail.length === 2 && tail[1] === 'labels') { if (method !== 'PUT') return apiErr(reply, 405, 'method not allowed'); return apiItemLabels(request, reply, owner, name, number, 'issues'); } if (tail.length === 2 && ['comments', 'close', 'reopen'].includes(tail[1])) { if (method !== 'POST') return apiErr(reply, 405, 'method not allowed'); if (tail[1] === 'comments') return apiIssueComment(request, reply, owner, name, number); return apiIssueState(request, reply, owner, name, number, tail[1] === 'close' ? 'closed' : 'open'); } return apiErr(reply, 404, 'not found'); } // ---- tier 3a: forks, compare, pull requests with real merges ---------- /** * POST api/repos///fork — `git clone --local --bare` into the * CALLER's namespace (same name; optional {name} override so an owner * can fork their own repo — GitHub allows self-forks, and a same-name * self-fork would always collide). 409 when the target exists. Lineage * is recorded in the fork's git config (forge.parent = /). */ async function apiForkCreate(request, reply, owner, name) { const p = await readJsonBody(request); // body before auth (NIP-98 payload tag) const agent = await apiAgent(request, reply); if (!agent) return reply; const caller = ownerFromAgent(agent); if (!caller) return apiErr(reply, 403, 'no namespace for this agent'); let target = name; if (p && p.name !== undefined) { if (typeof p.name !== 'string' || !REPO_NAME.test(p.name) || p.name.includes('..') || p.name.endsWith('.git')) { return apiErr(reply, 422, 'invalid fork name'); } target = p.name; } if (repoExists(caller, target)) return apiErr(reply, 409, `${caller}/${target} already exists`); const dir = repoDirOf(caller, target); fs.mkdirSync(path.join(reposDir, caller), { recursive: true }); await execFileP('git', ['clone', '--local', '--bare', '--quiet', repoDirOf(owner, name), dir], { env: gitEnv, maxBuffer: MAX_EXEC_BUFFER }); await execFileP('git', ['-C', dir, 'config', 'http.receivepack', 'true'], { env: gitEnv }); await execFileP('git', ['-C', dir, 'config', 'uploadpack.hideRefs', 'refs/forge/'], { env: gitEnv }); await execFileP('git', ['-C', dir, 'config', 'forge.parent', `${owner}/${name}`], { env: gitEnv }); // the clone's origin remote is an internal filesystem path — drop it await execFileP('git', ['-C', dir, 'remote', 'remove', 'origin'], { env: gitEnv }).catch(() => {}); fs.writeFileSync(path.join(dir, 'hooks', 'post-receive'), POST_RECEIVE_HOOK, { mode: 0o755 }); fs.writeFileSync(path.join(dir, META_FILE), JSON.stringify({ createdAt: Date.now(), creator: agent, forkedFrom: `${owner}/${name}` }, null, 2)); api.log.info(`forge: ${agent} forked ${owner}/${name} -> ${caller}/${target}`); return sendJson(reply, 201, { owner: caller, name: target, parent: `${owner}/${name}`, url: `${prefix}/${caller}/${target}`, cloneUrl: cloneUrlOf(caller, target), }); } // ---- tier 3.7: web-edit — commit one file over HTTP (GitHub's web editor) /** * Validate a repo-relative edit path. Returns the clean path or null. * Rejects: non-strings, empties, a leading '/', '..', backslashes, control * chars, empty/oversized segments, any '.git' segment, and a leading-dot * path at the repo ROOT (.acl / .htaccess surprises). Each segment must * match EDIT_SEG. Kept deliberately strict — the demo edits ordinary files. */ function cleanEditPath(p) { if (typeof p !== 'string' || p.length === 0 || p.length > EDIT_PATH_CAP) return null; if (p[0] === '/' || p.includes('\\') || /[\x00-\x1f]/.test(p)) return null; const segs = p.split('/'); for (const s of segs) { if (s === '' || s === '..' || s === '.git' || !EDIT_SEG.test(s)) return null; } if (segs[0].startsWith('.')) return null; // no dotfile at the repo root return segs.join('/'); } /** * POST api/repos///preview { path, content, branch? } — the UNSTAGED * tier. Publishes an EPHEMERAL Nostr event (kind 21617) carrying the file * content; makes NO commit, no push, no anchor, writes nothing to disk. * Relays don't store ephemeral events, so only currently-connected viewers * receive it — a live, throwaway preview that vanishes the moment you stop * looking. Same auth as /edit (owner-signed unless config.openEdit). Returns * {ok, published, kind, id, path, relays}; {published:false} if no relays. */ async function apiRepoPreview(request, reply, owner, name) { const p = await readJsonBody(request, EDIT_CONTENT_CAP + 128 * 1024); const agent = await requestAgent(request); const authedOwner = agent ? ownerFromAgent(agent) : null; if (!openEdit) { if (!agent) { reply.header('WWW-Authenticate', 'Bearer realm="jss-forge"'); return corsErr(reply, 401, 'authentication required'); } if (authedOwner !== owner) return corsErr(reply, 403, 'only the repo owner may preview this repository'); } if (!p || typeof p !== 'object') return corsErr(reply, 400, 'invalid JSON body'); const relPath = cleanEditPath(p.path); if (!relPath) return corsErr(reply, 400, 'invalid path'); if (typeof p.content !== 'string') return corsErr(reply, 400, 'content must be a string'); if (Buffer.byteLength(p.content, 'utf8') > EDIT_CONTENT_CAP) { return corsErr(reply, 413, `content exceeds the ${EDIT_CONTENT_CAP}-byte cap`); } if (!repoExists(owner, name)) return corsErr(reply, 404, 'not found'); let branch; if (p.branch !== undefined) { if (typeof p.branch !== 'string' || !REF_RE.test(p.branch) || p.branch.includes('..')) { return corsErr(reply, 400, 'invalid branch'); } branch = p.branch; } else { branch = await defaultBranch(repoDirOf(owner, name)); } if (!announceRelays.length) { return corsJson(reply, 200, { ok: true, published: false, reason: 'no relays configured (set config.announceRelays)' }); } const ev = signAnnounceEvent({ kind: EPHEMERAL_PREVIEW_KIND, tags: [['d', `${owner}/${name}`], ['f', relPath], ['branch', branch]], content: p.content, }); const relays = await publishEvent(ev); return corsJson(reply, 200, { ok: true, published: true, kind: ev.kind, id: ev.id, path: relPath, relays }); } /** * POST api/repos///edit { path, content, message?, branch? } — the * GitHub-web-editor equivalent: commit ONE file change over HTTP, then let * the change ride the forge's NIP-34 emission. Owner-signed by default * (401 anon / 403 wrong owner); config.openEdit relaxes to anonymous for a * throwaway demo. The commit is made in a disposable local clone (a temp * working tree) and pushed back to the bare — no server-side index games. * A no-op edit (identical content) makes NO commit ({changed:false}). */ async function apiRepoEdit(request, reply, owner, name) { // Body BEFORE auth: a NIP-98 payload tag hashes the wire bytes. const p = await readJsonBody(request, EDIT_CONTENT_CAP + 128 * 1024); const agent = await requestAgent(request); const authedOwner = agent ? ownerFromAgent(agent) : null; if (!openEdit) { if (!agent) { reply.header('WWW-Authenticate', 'Bearer realm="jss-forge"'); return corsErr(reply, 401, 'authentication required'); } if (authedOwner !== owner) return corsErr(reply, 403, 'only the repo owner may edit this repository'); } if (!p || typeof p !== 'object') return corsErr(reply, 400, 'invalid JSON body'); const relPath = cleanEditPath(p.path); if (!relPath) return corsErr(reply, 400, 'invalid path'); if (typeof p.content !== 'string') return corsErr(reply, 400, 'content must be a string'); if (Buffer.byteLength(p.content, 'utf8') > EDIT_CONTENT_CAP) { return corsErr(reply, 413, `content exceeds the ${EDIT_CONTENT_CAP}-byte cap`); } let message = typeof p.message === 'string' ? p.message.replace(/[\x00-\x1f]+/g, ' ').trim() : ''; if (message.length > EDIT_MSG_CAP) message = message.slice(0, EDIT_MSG_CAP); if (!message) message = `web edit: ${relPath}`; const bareDir = repoDirOf(owner, name); if (!repoExists(owner, name)) return corsErr(reply, 404, 'not found'); let branch; if (p.branch !== undefined) { if (typeof p.branch !== 'string' || !REF_RE.test(p.branch) || p.branch.includes('..')) { return corsErr(reply, 400, 'invalid branch'); } branch = p.branch; } else { branch = await defaultBranch(bareDir); } const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-edit-')); const gitTmp = (args) => execFileP('git', ['-C', tmpDir, ...args], { env: gitEnv, maxBuffer: MAX_EXEC_BUFFER }); try { // Disposable local clone (fast, hard-linked), then land on the branch — // creating it off the current HEAD if it does not exist yet. await execFileP('git', ['clone', '--local', '--quiet', bareDir, tmpDir], { env: gitEnv, maxBuffer: MAX_EXEC_BUFFER }); await gitTmp(['checkout', branch]).catch(() => gitTmp(['checkout', '-b', branch])); // Write the file (parents made), stage it, and detect a real change. const abs = path.join(tmpDir, relPath); const resolved = path.resolve(abs); if (resolved !== path.resolve(tmpDir) && !resolved.startsWith(path.resolve(tmpDir) + path.sep)) { return corsErr(reply, 400, 'invalid path'); // defense in depth; cleanEditPath already blocks this } fs.mkdirSync(path.dirname(abs), { recursive: true }); fs.writeFileSync(abs, p.content); await gitTmp(['add', '--', relPath]); let changed = true; try { await gitTmp(['diff', '--cached', '--quiet']); changed = false; } catch { changed = true; } if (!changed) { const head = (await gitTmp(['rev-parse', 'HEAD']).then((r) => r.stdout.trim()).catch(() => '')) || null; return corsJson(reply, 200, { ok: true, commit: head, changed: false }); } // Committer identity is forced via -c (no HOME, GIT_CONFIG_NOSYSTEM): // the author of record is whoever authenticated (or 'anonymous' in // openEdit demo mode); the committer is the forge itself. const who = authedOwner || 'anonymous'; const env = { ...gitEnv, GIT_AUTHOR_NAME: who, GIT_AUTHOR_EMAIL: `${who}@forge.invalid`, }; await execFileP('git', ['-C', tmpDir, '-c', 'user.name=forge web edit', '-c', 'user.email=forge@forge.invalid', 'commit', '--quiet', '-m', message], { env, maxBuffer: MAX_EXEC_BUFFER }); await gitTmp(['push', '--quiet', bareDir, `HEAD:refs/heads/${branch}`]); const commit = (await gitTmp(['rev-parse', 'HEAD'])).stdout.trim(); api.log.info(`forge: web edit ${owner}/${name}@${branch} ${relPath} -> ${commit.slice(0, 7)} by ${agent ?? 'anonymous'}`); // The tip moved: advance any Blocktrails anchor exactly as a push would, // then fire the NIP-34 emission (fire-and-forget; a no-op with no relays). await recordTipSafe(owner, name); announceRepoSafe(owner, name); return corsJson(reply, 200, { ok: true, commit, changed: true, url: `${prefix}/${owner}/${name}` }); } catch (err) { // Never leak the tmp path or raw git stderr. api.log.warn(`forge: web edit ${owner}/${name} failed: ${err.message}`); return corsErr(reply, 500, 'edit failed'); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } } async function apiCompare(reply, owner, name, spec) { const dots = spec.indexOf('...'); if (dots < 1) return apiErr(reply, 404, 'not found'); const cmp = await compareData(owner, name, spec.slice(0, dots), spec.slice(dots + 3)); if (!cmp) return apiErr(reply, 404, 'not found'); return sendJson(reply, 200, { base: { ref: cmp.baseRef, sha: cmp.baseSha }, head: { owner: cmp.head.owner, repo: cmp.head.repo, ref: cmp.head.ref, sha: cmp.head.sha }, aheadBy: cmp.aheadBy, behindBy: cmp.behindBy, mergeBase: cmp.mergeBase, hasMore: cmp.hasMore, commits: cmp.commits, files: cmp.files, }); } function apiPullList(reply, owner, name, query) { const idx = loadPullIndex(owner, name); const all = Object.values(idx.pulls).sort((a, b) => b.number - a.number); const count = (s) => all.filter((p) => p.state === s).length; const state = ['merged', 'closed'].includes(query?.state) ? query.state : 'open'; const label = typeof query?.label === 'string' && query.label ? query.label.slice(0, 50) : null; const defs = labelDefs(owner, name); const pageNo = Math.max(1, Math.min(10000, parseInt(query?.page, 10) || 1)); const filtered = all.filter((p) => p.state === state && (!label || (Array.isArray(p.labels) && p.labels.includes(label)))); return sendJson(reply, 200, { state, page: pageNo, perPage: ISSUES_PER_PAGE, hasMore: filtered.length > pageNo * ISSUES_PER_PAGE, openCount: count('open'), mergedCount: count('merged'), closedCount: count('closed'), pulls: filtered.slice((pageNo - 1) * ISSUES_PER_PAGE, pageNo * ISSUES_PER_PAGE).map((p) => ({ number: p.number, title: p.title, state: p.state, author: p.author, authorInfo: authorMeta(p.author), createdAt: p.createdAt, base: p.base, head: p.head, merged: p.merged ?? null, labels: resolveLabels(p.labels, defs), // additive (polish) comments: p.thread.length - 1, })), }); } async function apiPullGet(reply, owner, name, number) { const pr = loadPullIndex(owner, name).pulls[number]; if (!pr) return apiErr(reply, 404, 'not found'); const resolved = await resolveThread(pr.thread); const ctx = issueMdCtx(owner, name); const info = pr.state === 'open' ? await pullMergeInfo(owner, name, pr) : { baseSha: await revParse(repoDirOf(owner, name), `refs/heads/${pr.base}`), headSha: null, mergeable: null, conflicts: [], fastForward: false }; return sendJson(reply, 200, { number: pr.number, title: pr.title, state: pr.state, author: pr.author, authorInfo: authorMeta(pr.author), createdAt: pr.createdAt, base: pr.base, baseSha: info.baseSha, head: { ...pr.head, sha: info.headSha ?? undefined }, merged: pr.merged ?? null, mergeable: info.mergeable, conflicts: info.conflicts, labels: resolveLabels(pr.labels, labelDefs(owner, name)), // additive (polish) thread: resolved.map((e) => ({ ...e, authorInfo: authorMeta(e.author), html: e.removed ? null : renderMarkdown(e.body, ctx), })), }); } async function apiPullCreate(request, reply, owner, name) { const p = await readJsonBody(request); // body before auth (NIP-98 payload tag) const agent = await apiAgent(request, reply); if (!agent) return reply; if (!p) return apiErr(reply, 400, 'invalid JSON body'); const title = typeof p.title === 'string' ? p.title.trim() : ''; const bodyText = typeof p.body === 'string' ? p.body : ''; if (!title || title.length > ISSUE_TITLE_CAP) return apiErr(reply, 422, `title required (1-${ISSUE_TITLE_CAP} chars)`); if (bodyText.length > ISSUE_BODY_CAP) return apiErr(reply, 422, 'body too large'); const baseRef = typeof p.base === 'string' ? p.base : ''; if (!okRef(baseRef)) return apiErr(reply, 422, 'base must be a branch of this repository'); const dir = repoDirOf(owner, name); const baseSha = await revParse(dir, `refs/heads/${baseRef}`); if (!baseSha) return apiErr(reply, 422, `base branch ${baseRef} does not exist`); const head = await resolveHead(owner, name, typeof p.head === 'string' ? p.head : ''); if (!head) return apiErr(reply, 422, 'head must be or : of a same-named repo'); const counts = (await gitText(dir, ['rev-list', '--left-right', '--count', `${baseSha}...${head.sha}`])) .trim().split(/\s+/); if (!+counts[1]) return apiErr(reply, 422, 'no commits between base and head'); return withPullLock(owner, name, async () => { const idx = loadPullIndex(owner, name); const number = idx.next; const doc = { type: 'ForgePullRequest', repo: `${owner}/${name}`, pull: number, title, body: bodyText, base: baseRef, head: `${head.owner}:${head.ref}`, published: new Date().toISOString(), author: agent, }; const usePointer = !!podPathFromAgent(agent) && typeof p.resourceUrl === 'string' && p.resourceUrl.length > 0; const stored = usePointer ? await registerPointer(request, agent, owner, name, p.resourceUrl) : await persistBody(request, agent, owner, name, doc, 'pull'); if (stored.error) return apiErr(reply, stored.status, stored.error); const at = Math.floor(Date.now() / 1000); idx.next = number + 1; idx.pulls[number] = { number, title, state: 'open', author: agent, createdAt: at, base: baseRef, head: { owner: head.owner, repo: head.repo, ref: head.ref }, merged: null, thread: [{ author: agent, resourceUrl: stored.url, at, ...(stored.hosted ? { hosted: true } : {}) }], }; savePullIndex(owner, name, idx); return sendJson(reply, 201, { number, url: `${prefix}/${owner}/${name}/pulls/${number}`, resourceUrl: stored.url, ...(stored.hosted ? { hosted: true } : {}), }); }); } async function apiPullComment(request, reply, owner, name, number) { const p = await readJsonBody(request); // body before auth (NIP-98 payload tag) const agent = await apiAgent(request, reply); if (!agent) return reply; if (!p) return apiErr(reply, 400, 'invalid JSON body'); const bodyText = typeof p.body === 'string' ? p.body : ''; const usePointer = !!podPathFromAgent(agent) && typeof p.resourceUrl === 'string' && p.resourceUrl.length > 0; if (!usePointer && !bodyText.trim()) return apiErr(reply, 422, 'body required'); if (bodyText.length > ISSUE_BODY_CAP) return apiErr(reply, 422, 'body too large'); return withPullLock(owner, name, async () => { const idx = loadPullIndex(owner, name); const pr = idx.pulls[number]; if (!pr) return apiErr(reply, 404, 'not found'); if (pr.thread.length >= THREAD_CAP) return apiErr(reply, 422, 'thread is full'); const doc = { type: 'ForgeComment', repo: `${owner}/${name}`, pull: number, body: bodyText, published: new Date().toISOString(), author: agent, }; const stored = usePointer ? await registerPointer(request, agent, owner, name, p.resourceUrl) : await persistBody(request, agent, owner, name, doc, 'comment'); if (stored.error) return apiErr(reply, stored.status, stored.error); pr.thread.push({ author: agent, resourceUrl: stored.url, at: Math.floor(Date.now() / 1000), ...(stored.hosted ? { hosted: true } : {}), }); savePullIndex(owner, name, idx); return sendJson(reply, 201, { number, comments: pr.thread.length - 1, resourceUrl: stored.url, ...(stored.hosted ? { hosted: true } : {}), }); }); } /** close/reopen: target repo owner or the PR author; merged is final. */ async function apiPullState(request, reply, owner, name, number, state) { await readJsonBody(request); // content unused; buffered for NIP-98 payload tags const agent = await apiAgent(request, reply); if (!agent) return reply; return withPullLock(owner, name, async () => { const idx = loadPullIndex(owner, name); const pr = idx.pulls[number]; if (!pr) return apiErr(reply, 404, 'not found'); if (!mayModerate(agent, owner, pr)) return apiErr(reply, 403, 'only the repo owner or the pull-request author may do that'); if (pr.state === 'merged') return apiErr(reply, 422, 'a merged pull request cannot be reopened or closed'); pr.state = state; savePullIndex(owner, name, idx); return sendJson(reply, 200, { number, state }); }); } /** * POST .../pulls//merge — a REAL merge on the bare repo (target repo * owner only): fetch the head objects, `merge-tree --write-tree` (or a * fast-forward when the base is an ancestor — ff-when-possible policy), * `commit-tree` with two parents, then `update-ref` with an old-value * guard. Optional {expectedBase: } is the UI's CAS token: 409 when * the base moved since the diff the merger saw. */ async function apiPullMerge(request, reply, owner, name, number) { const p = await readJsonBody(request); const agent = await apiAgent(request, reply); if (!agent) return reply; if (ownerFromAgent(agent) !== owner) return apiErr(reply, 403, 'only the repo owner may merge'); if (!mergeTreeOk) { return apiErr(reply, 501, `merging needs git >= 2.38 (merge-tree --write-tree); this server has "${gitVersion}"`); } return withPullLock(owner, name, async () => { const idx = loadPullIndex(owner, name); const pr = idx.pulls[number]; if (!pr) return apiErr(reply, 404, 'not found'); if (pr.state !== 'open') return apiErr(reply, 422, `pull request is ${pr.state}`); const dir = repoDirOf(owner, name); const baseSha = await revParse(dir, `refs/heads/${pr.base}`); if (!baseSha) return apiErr(reply, 422, `base branch ${pr.base} no longer exists`); const expected = typeof p?.expectedBase === 'string' && p.expectedBase ? p.expectedBase : null; if (expected !== null && !SHA_RE.test(expected)) return apiErr(reply, 422, 'expectedBase must be a sha'); if (expected !== null && !baseSha.startsWith(expected)) { return sendJson(reply, 409, { error: `the base branch has moved since the diff you saw (now at ${baseSha.slice(0, 7)}) — review and retry`, baseSha }); } const head = await resolveHead(owner, name, prHeadSpec(pr)); if (!head) return apiErr(reply, 422, 'the head branch no longer exists'); if (head.sha === baseSha || await isAncestor(dir, head.sha, baseSha)) { return apiErr(reply, 422, 'nothing to merge: the base already contains the head'); } let mergedSha; let fastForward = false; if (await isAncestor(dir, baseSha, head.sha)) { // ff-when-possible: no synthetic merge commit when none is needed. fastForward = true; mergedSha = head.sha; } else { const mt = await mergeTreeOf(dir, baseSha, head.sha); if (!mt.clean) { return sendJson(reply, 409, { error: 'merge conflict', conflicts: mt.conflicts }); } // Honest authorship: author = the merging agent, committer = forge. const who = ownerFromAgent(agent) ?? 'agent'; const env = { ...gitEnv, GIT_AUTHOR_NAME: displayName(agent), GIT_AUTHOR_EMAIL: `${who}@forge.invalid`, GIT_COMMITTER_NAME: 'forge', GIT_COMMITTER_EMAIL: 'forge@forge.invalid', }; const msg = `Merge pull request #${pr.number} from ${prHeadSpec(pr)}`; const { stdout } = await execFileP('git', ['-C', dir, 'commit-tree', mt.tree, '-p', baseSha, '-p', head.sha, '-m', msg], { env }); mergedSha = stdout.trim(); } // Compare-and-swap: update-ref's old-value guard rejects the write // if the base moved between our read and now (e.g. a racing push). try { await execFileP('git', ['-C', dir, 'update-ref', `refs/heads/${pr.base}`, mergedSha, baseSha], { env: gitEnv }); } catch { return sendJson(reply, 409, { error: 'the base branch moved during the merge — retry' }); } pr.state = 'merged'; pr.merged = { sha: mergedSha, mergedBy: agent, at: Math.floor(Date.now() / 1000), baseSha, headSha: head.sha, fastForward, }; savePullIndex(owner, name, idx); api.log.info(`forge: merged PR #${pr.number} into ${owner}/${name}@${pr.base} (${mergedSha.slice(0, 7)}${fastForward ? ', ff' : ''})`); // Anchoring advance (3.5): a merge moves the base ref via update-ref // — the same tip-change beat as a push, routed through the same // helper. recordTip no-ops when the DEFAULT branch didn't move // (e.g. a merge into a side branch). Awaited so the response only // goes out once the mark (if any) is derived and recorded. await recordTipSafe(owner, name); return sendJson(reply, 200, { number: pr.number, state: 'merged', sha: mergedSha, fastForward }); }); } async function apiPullsHandler(request, reply, owner, name, tail) { const method = request.method; if (tail.length === 0) { if (method === 'POST') return apiPullCreate(request, reply, owner, name); if (method === 'GET' || method === 'HEAD') return apiPullList(reply, owner, name, request.query); return apiErr(reply, 405, 'method not allowed'); } if (!ISSUE_NUM_RE.test(tail[0])) return apiErr(reply, 404, 'not found'); const number = +tail[0]; if (tail.length === 1) { if (method === 'GET' || method === 'HEAD') return apiPullGet(reply, owner, name, number); return apiErr(reply, 405, 'method not allowed'); } if (tail.length === 2 && tail[1] === 'labels') { if (method !== 'PUT') return apiErr(reply, 405, 'method not allowed'); return apiItemLabels(request, reply, owner, name, number, 'pulls'); } if (tail.length === 2 && ['comments', 'close', 'reopen', 'merge'].includes(tail[1])) { if (method !== 'POST') return apiErr(reply, 405, 'method not allowed'); if (tail[1] === 'comments') return apiPullComment(request, reply, owner, name, number); if (tail[1] === 'merge') return apiPullMerge(request, reply, owner, name, number); return apiPullState(request, reply, owner, name, number, tail[1] === 'close' ? 'closed' : 'open'); } return apiErr(reply, 404, 'not found'); } // ---- tier 3.5: git-mark anchoring via Blocktrails -------------------- /** * POST api/repos///marks/enable (owner only) — genesis: mint a * fresh trail key (forge-held, testnet posture — README Findings) and * derive mark 0's address from the CURRENT default-branch tip. Needs a * commit to commit to; an empty repo is 422. */ async function apiMarksEnable(request, reply, owner, name) { await readJsonBody(request); // content unused; buffered for NIP-98 payload tags const agent = await apiAgent(request, reply); if (!agent) return reply; if (ownerFromAgent(agent) !== owner) return apiErr(reply, 403, 'only the repo owner may enable anchoring'); const dir = repoDirOf(owner, name); const branch = await defaultBranch(dir); const tip = await revParse(dir, `refs/heads/${branch}`); if (!tip) return apiErr(reply, 422, 'anchoring needs a commit on the default branch — push first'); return withMarkLock(owner, name, async () => { if (loadTrail(owner, name)) return apiErr(reply, 409, 'anchoring is already enabled for this repository'); const priv = secp256k1.utils.randomPrivateKey(); // throwaway-grade custody, by design const trail = { v: 1, chain, privkey: Buffer.from(priv).toString('hex'), pubkeyBase: Buffer.from(secp256k1.getPublicKey(priv, true)).toString('hex'), createdAt: Math.floor(Date.now() / 1000), marks: [], }; const mark = appendMark(trail, { commit: tip, repo: `${owner}/${name}`, branch }); saveTrail(owner, name, trail); api.log.info(`forge: anchoring enabled for ${owner}/${name} on ${chain} — genesis mark ${mark.address} ` + '(trail key is FORGE-HELD in pluginDir/marks; testnet custody)'); return sendJson(reply, 201, { enabled: true, chain, pubkeyBase: trail.pubkeyBase, mark: publicMark(mark) }); }); } /** * POST api/repos///marks//txo (owner only) { txid, vout, * amount } — record where the mark landed on-chain. Shape-validated and * RECORDED, never verified: the server does no chain fetches, ever — * independent verification is the hosted verifier's job, client-side * (that split is the point; README Findings). Marks are recorded in * order because the trail is a linear spend chain. */ async function apiMarkTxo(request, reply, owner, name, index) { const p = await readJsonBody(request); // body before auth (NIP-98 payload tag) const agent = await apiAgent(request, reply); if (!agent) return reply; if (ownerFromAgent(agent) !== owner) return apiErr(reply, 403, 'only the repo owner may record a mark transaction'); if (!p) return apiErr(reply, 400, 'invalid JSON body'); const txid = typeof p.txid === 'string' ? p.txid.toLowerCase() : ''; if (!TXID64.test(txid)) return apiErr(reply, 422, 'txid must be 64 hex chars'); if (!Number.isInteger(p.vout) || p.vout < 0 || p.vout > 0x7fffffff) return apiErr(reply, 422, 'vout must be a non-negative integer'); if (!Number.isInteger(p.amount) || p.amount <= 0 || p.amount > SATS_CAP) return apiErr(reply, 422, 'amount must be a positive integer (satoshis)'); return withMarkLock(owner, name, async () => { const trail = loadTrail(owner, name); if (!trail) return apiErr(reply, 404, 'anchoring is not enabled for this repository'); const mark = trail.marks[index]; if (!mark) return apiErr(reply, 404, 'no such mark'); if (mark.status === 'marked') return apiErr(reply, 409, 'this mark already has a recorded transaction'); if (index > 0 && trail.marks[index - 1].status !== 'marked') { return apiErr(reply, 409, 'record marks in order: the trail is a linear spend chain (mark N spends mark N-1)'); } Object.assign(mark, { txid, vout: p.vout, amount: p.amount, status: 'marked', markedAt: Math.floor(Date.now() / 1000) }); saveTrail(owner, name, trail); api.log.info(`forge: mark #${mark.index} of ${owner}/${name} recorded as ${txid.slice(0, 10)}…:${p.vout} (claim recorded, not verified)`); // Discovery layer (NIP-34): a mark flipping to 'marked' means the repo // now has a Bitcoin anchor — announce it (WITH its anchor tags) so ngit // and relays learn the source-of-truth. Fire-and-forget: a no-op when // no relays are configured, and never blocks the txo response. announceRepoSafe(owner, name); return sendJson(reply, 200, { index: mark.index, status: 'marked', txid, vout: p.vout, amount: p.amount }); }); } /** GET api/repos///marks — the full mark list (never the key). */ function apiMarksList(reply, owner, name) { const trail = loadTrail(owner, name); if (!trail) return sendJson(reply, 200, { enabled: false, chain }); return sendJson(reply, 200, { enabled: true, chain: trail.chain, pubkeyBase: trail.pubkeyBase, marks: trail.marks.map(publicMark), }); } async function apiMarksHandler(request, reply, owner, name, tail) { const method = request.method; if (tail.length === 0) { if (method === 'GET' || method === 'HEAD') return apiMarksList(reply, owner, name); return apiErr(reply, 405, 'method not allowed'); } if (tail.length === 1 && tail[0] === 'enable') { if (method !== 'POST') return apiErr(reply, 405, 'method not allowed'); return apiMarksEnable(request, reply, owner, name); } if (tail.length === 2 && tail[1] === 'txo' && MARK_INDEX_RE.test(tail[0])) { if (method !== 'POST') return apiErr(reply, 405, 'method not allowed'); return apiMarkTxo(request, reply, owner, name, +tail[0]); } return apiErr(reply, 404, 'not found'); } // ---- tier 3.6: NIP-34 nostr discovery (announce + read-back) -------- /** * POST api/repos///announce (owner only) — build BOTH the kind-30617 * repo announcement and the kind-30618 repo-state event and publish them to * the configured relays. With no relays configured this is a clear 200 no-op * (so callers/tests need no live relay). Returns { published, event: * {id, kind, tags} (30617), state: {id, kind, tags} (30618), relays: * [{relay, ok}], stateRelays: [{relay, ok}] }. */ async function apiAnnounce(request, reply, owner, name) { await readJsonBody(request); // no body needed; buffered for NIP-98 payload tags const agent = await apiAgent(request, reply); if (!agent) return reply; if (ownerFromAgent(agent) !== owner) return apiErr(reply, 403, 'only the repo owner may announce this repository'); const event = await buildRepoEvent(owner, name); const state = await buildStateEvent(owner, name); const viewOf = (e) => ({ id: e.id, pubkey: e.pubkey, kind: e.kind, tags: e.tags }); const eventView = viewOf(event); const stateView = viewOf(state); if (!announceRelays.length) { return sendJson(reply, 200, { published: false, reason: 'no relays configured — set config.announceRelays to publish (NIP-34 emission is opt-in)', event: eventView, state: stateView, relays: [], }); } const relays = await publishEvent(event); const stateRelays = await publishEvent(state); api.log.info(`forge: ${owner}/${name} announced on demand (30617 ${event.id.slice(0, 8)}… + 30618 ${state.id.slice(0, 8)}…) to ` + `${relays.filter((r) => r.ok).length}/${relays.length} relay(s)`); const relayView = (rs) => rs.map((r) => ({ relay: r.relay, ok: r.ok, ...(r.error ? { error: r.error } : {}) })); return sendJson(reply, 200, { published: true, event: eventView, state: stateView, relays: relayView(relays), stateRelays: relayView(stateRelays), }); } /** * GET api/repos///nostr — the (freshly built, signed) events that * WOULD be published: the kind-30617 announcement (`event`) AND the * kind-30618 repo-state (`state`), plus both in `events`, the announce * pubkey/npub and the configured relays. A human/subscriber can inspect the * exact NIP-34 shapes (and the commit-precise refs) without a relay. */ async function apiNostr(reply, owner, name) { const event = await buildRepoEvent(owner, name); const state = await buildStateEvent(owner, name); return sendJson(reply, 200, { pubkey: announcePubkey, npub: npubEncode(announcePubkey), relays: announceRelays, relaysConfigured: announceRelays.length > 0, event, // the kind-30617 announcement (backward-compat field) state, // the kind-30618 repo-state event (commit-precise refs) events: [event, state], }); } /** * GET ///blocktrails.json — the verifier-compatible * trail document (the shape blocktrails/verify consumes: pubkeyBase, * chain, states[], txo[] as `txo:` URIs whose amounts/spend-chain the * verifier checks on-chain, client-side). Served with * Access-Control-Allow-Origin: * — this ONE route is a public * verification document whose whole purpose is to be fetched * cross-origin by the hosted verifier page; everything in it is already * public via the marks page, so the CORS grant widens reach, not * exposure (README Findings). Only MARKED marks enter states/txo (the * verifier walks the spend chain; pending marks have nothing on-chain * yet) — the full list, pending included, rides in the additive `marks` * field. */ function blocktrailsResp(reply, owner, name) { const trail = loadTrail(owner, name); if (!trail) { return reply.code(404) .header('content-type', 'application/json; charset=utf-8') .header('access-control-allow-origin', '*') .header('x-content-type-options', 'nosniff') .send(JSON.stringify({ error: 'anchoring is not enabled for this repository' })); } const marked = []; for (const m of trail.marks) { if (m.status !== 'marked') break; // recorded strictly in order; the chain stops at the first pending marked.push(m); } const doc = { ...(marked.length ? { '@id': txoUriOf(trail, marked[0]) } : {}), '@type': 'Blocktrail', version: '0.0.3', profile: 'gitmark', pubkeyBase: trail.pubkeyBase, chain: trail.chain, // Additive, self-describing fields (the verifier ignores them): // how addresses are derived and how states are hashed, so a future // re-deriving verifier needs nothing out-of-band. derivation: 'blocktrails-v0.2 chained BIP-341 TapTweak', stateHash: 'sha256(JSON.stringify({commit,repo,branch}))', states: marked.map((m) => m.state.commit), txo: marked.map((m) => txoUriOf(trail, m)), marks: trail.marks.map(publicMark), }; return reply.code(200) .header('content-type', 'application/json; charset=utf-8') .header('access-control-allow-origin', '*') .header('x-content-type-options', 'nosniff') .send(JSON.stringify(doc, null, 2)); } // ---- tier 2.5: NIP-98 -> push-token exchange + hosted-content routes ---- /** * POST /api/token[?ttl=seconds] — exchange ANY credential * getAgent accepts (NIP-98 included) for a forge bearer the git lane * takes in a static http.extraHeader. Deliberately getAgent-only: a * forge token cannot mint another forge token (no self-refresh — the * TTL is real). ttl is uncapped downward (ttl<=0 mints an already- * expired token, handy for testing) and capped upward at 30 days. * The exchange request itself needs no body, so a NIP-98 event with * just [["u",...],["method","POST"]] verifies. */ async function apiTokenMint(request, reply) { if (request.method !== 'POST') return apiErr(reply, 405, 'method not allowed'); await readJsonBody(request); // tolerate an (unused) JSON body under NIP-98 payload tags const agent = await api.auth.getAgent(request); if (!agent) { reply.header('WWW-Authenticate', 'Bearer realm="jss-forge"'); return apiErr(reply, 401, 'authentication required'); } let ttl = pushTokenTtl; if (request.query?.ttl !== undefined) { const n = Number(request.query.ttl); if (!Number.isFinite(n) || n > FORGE_TOKEN_MAX_TTL) { return apiErr(reply, 422, `ttl must be a number of seconds <= ${FORGE_TOKEN_MAX_TTL}`); } ttl = n; } const { token, iat, exp } = mintForgeToken(agent, ttl); api.log.info(`forge: minted push token for ${agent} (ttl ${ttl}s)`); return sendJson(reply, 201, { token, tokenType: 'Bearer', agent, iat, exp }); } /** * /api/hosted// — a podless agent's words, hosted by * the forge. GET is public (like a public pod resource); PUT (edit) and * DELETE (removal) are author-only — the same did:nostr identity that * wrote it. Editing only ever changes `body`; every other field is kept * from the stored doc, so a signed request can't rewrite authorship. */ async function apiHosted(request, reply, segs) { if (segs.length !== 2 || !NOSTR_HEX.test(segs[0]) || !UUID_RE.test(segs[1])) { return apiErr(reply, 404, 'not found'); } const [hex, id] = segs; if (request.method === 'GET' || request.method === 'HEAD') { if (privateRepos && !(await requestAgent(request))) { reply.header('WWW-Authenticate', 'Bearer realm="jss-forge"'); return apiErr(reply, 401, 'authentication required'); } const doc = readHosted(hex, id); return doc ? sendJson(reply, 200, doc) : apiErr(reply, 404, 'not found'); } if (request.method === 'PUT' || request.method === 'PATCH') { const incoming = await readJsonBody(request); // buffer before auth (NIP-98 payload tag) const agent = await apiAgent(request, reply); if (!agent) return reply; if (agent !== `did:nostr:${hex}`) return apiErr(reply, 403, 'only the author may edit hosted content'); const existing = readHosted(hex, id); if (!existing) return apiErr(reply, 404, 'not found'); const body = incoming && typeof incoming.body === 'string' ? incoming.body : null; if (body === null) return apiErr(reply, 400, 'body (string) required'); if (body.length > ISSUE_BODY_CAP) return apiErr(reply, 413, 'body too large'); const updated = { ...existing, body, edited: new Date().toISOString() }; fs.writeFileSync(hostedPathOf(hex, id), JSON.stringify(updated)); api.log.info(`forge: hosted content ${hex}/${id} edited by its author`); return sendJson(reply, 200, { edited: true }); } if (request.method === 'DELETE') { const agent = await apiAgent(request, reply); if (!agent) return reply; if (agent !== `did:nostr:${hex}`) return apiErr(reply, 403, 'only the author may delete hosted content'); if (!fs.existsSync(hostedPathOf(hex, id))) return apiErr(reply, 404, 'not found'); fs.rmSync(hostedPathOf(hex, id)); api.log.info(`forge: hosted content ${hex}/${id} deleted by its author`); return sendJson(reply, 200, { deleted: true }); } return apiErr(reply, 405, 'method not allowed'); } /** Dispatch /api/... (segs excludes the leading 'api'). */ async function apiHandler(request, reply, segs) { if (!['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) return apiErr(reply, 405, 'method not allowed'); if (segs.length === 1 && segs[0] === 'token') return apiTokenMint(request, reply); if (segs[0] === 'hosted') return apiHosted(request, reply, segs.slice(1)); if (segs[0] !== 'repos') return apiErr(reply, 404, 'not found'); const rest = segs.slice(1); let agentOwner = null; if (privateRepos) { const agent = await requestAgent(request); if (!agent) { reply.header('WWW-Authenticate', 'Basic realm="jss-forge", charset="UTF-8"'); return apiErr(reply, 401, 'authentication required'); } agentOwner = ownerFromAgent(agent); if (!agentOwner) return apiErr(reply, 403, 'no pod namespace for this agent'); } const isRead = request.method === 'GET' || request.method === 'HEAD'; if (rest.length === 0) { return isRead ? apiRepoList(reply, privateRepos ? [agentOwner] : listOwners()) : apiErr(reply, 405, 'method not allowed'); } const owner = rest[0]; if (!OWNER_NAME.test(owner) || owner.includes('..')) return apiErr(reply, 404, 'not found'); if (privateRepos && owner !== agentOwner) return apiErr(reply, 403, 'private forge'); if (rest.length === 1) { return isRead ? apiRepoList(reply, [owner]) : apiErr(reply, 405, 'method not allowed'); } const name = rest[1]; if (!REPO_NAME.test(name) || name.includes('..') || name.endsWith('.git')) return apiErr(reply, 404, 'not found'); if (!repoExists(owner, name)) return apiErr(reply, 404, 'not found'); if (rest.length === 2) { if (request.method === 'PATCH') return apiRepoPatch(request, reply, owner, name); return isRead ? apiRepoMeta(reply, owner, name) : apiErr(reply, 405, 'method not allowed'); } const action = rest[2]; const tail = rest.slice(3); if (!['issues', 'pulls', 'fork', 'labels', 'marks', 'announce', 'edit', 'preview'].includes(action) && !isRead) return apiErr(reply, 405, 'method not allowed'); try { switch (action) { case 'issues': return await apiIssuesHandler(request, reply, owner, name, tail); case 'pulls': return await apiPullsHandler(request, reply, owner, name, tail); case 'labels': return await apiLabelsHandler(request, reply, owner, name, tail); case 'marks': return await apiMarksHandler(request, reply, owner, name, tail); case 'announce': if (tail.length !== 0) return apiErr(reply, 404, 'not found'); if (request.method !== 'POST') return apiErr(reply, 405, 'method not allowed'); return await apiAnnounce(request, reply, owner, name); case 'edit': if (tail.length !== 0) return apiErr(reply, 404, 'not found'); if (request.method !== 'POST') return apiErr(reply, 405, 'method not allowed'); return await apiRepoEdit(request, reply, owner, name); case 'preview': if (tail.length !== 0) return apiErr(reply, 404, 'not found'); if (request.method !== 'POST') return apiErr(reply, 405, 'method not allowed'); return await apiRepoPreview(request, reply, owner, name); case 'nostr': return tail.length === 0 && isRead ? await apiNostr(reply, owner, name) : apiErr(reply, 404, 'not found'); case 'search': return tail.length === 0 ? await apiSearch(reply, owner, name, request.query) : apiErr(reply, 404, 'not found'); case 'releases': return tail.length === 0 ? await apiReleases(reply, owner, name) : apiErr(reply, 404, 'not found'); case 'fork': if (tail.length !== 0) return apiErr(reply, 404, 'not found'); if (request.method !== 'POST') return apiErr(reply, 405, 'method not allowed'); return await apiForkCreate(request, reply, owner, name); case 'compare': return tail.length ? await apiCompare(reply, owner, name, tail.join('/')) : apiErr(reply, 404, 'not found'); case 'tree': return tail.length ? await apiTree(reply, owner, name, tail) : apiErr(reply, 404, 'not found'); case 'blob': return tail.length >= 2 ? await apiBlob(reply, owner, name, tail) : apiErr(reply, 404, 'not found'); case 'commits': return tail.length ? await apiCommits(reply, owner, name, tail, request.query) : apiErr(reply, 404, 'not found'); case 'commit': return tail.length === 1 ? await apiCommit(reply, owner, name, tail[0]) : apiErr(reply, 404, 'not found'); default: return apiErr(reply, 404, 'not found'); } } catch (err) { api.log.warn(`forge: api ${request.url} failed: ${err.message}`); return apiErr(reply, 404, 'not found'); } } // ---------------------------------------------------------------- routing // ONE catch-all in a scope whose content-type parser hands the raw stream // through (gitscratch's pattern): git POST bodies reach the CGI byte-exact // and every UI route is a bodyless GET. A single dispatcher also dodges // find-my-way's param-name conflicts between //.git/* and // ///tree/... shapes. await api.fastify.register(async (scope) => { scope.removeAllContentTypeParsers(); scope.addContentTypeParser('*', (req, payload, done) => done(null, payload)); const handler = async (request, reply) => { // CORS preflight: the cross-origin web-edit page sends OPTIONS before the // POST. Answer generically (the actual grant rides on the JSON reply). if (request.method === 'OPTIONS') { return withCors(reply).code(204).send(); } const rest = request.params['*'] ?? ''; if (rest.includes('%')) { // Percent-forms of . / \ never name a real object here; refuse // rather than guess at double-decoding. if (/%2e|%2f|%5c|%00/i.test(rest)) return reply.code(400).send('bad path\n'); } const segs = rest.split('/').filter((s) => s !== ''); if (segs.length === 0) { if (request.method !== 'GET' && request.method !== 'HEAD') return reply.code(405).send(); if (privateRepos) { const agent = await requestAgent(request); const agentOwner = ownerFromAgent(agent); if (!agent) { reply.header('WWW-Authenticate', 'Basic realm="jss-forge", charset="UTF-8"'); return reply.code(401).send('authentication required\n'); } if (!agentOwner) return reply.code(403).send('private forge\n'); return indexPage(reply, agentOwner, request.query); } return indexPage(reply, null, request.query); } // /api/... — the JSON surface. 'api' is a reserved owner // name: a pod user literally named "api" cannot have a namespace. if (segs[0] === 'api') return apiHandler(request, reply, segs.slice(1)); // /xlogin.js — the vendored widget, byte-identical, cached // hard (it only changes by re-vendoring + restart). 'xlogin.js' is // a reserved owner name like 'api'. Served without auth even under // privateRepos: it is static widget code, not forge data. if (segs.length === 1 && segs[0] === 'xlogin.js') { if (request.method !== 'GET' && request.method !== 'HEAD') return reply.code(405).send(); return reply.code(200) .header('content-type', 'application/javascript; charset=utf-8') .header('cache-control', 'public, max-age=31536000, immutable') .header('x-content-type-options', 'nosniff') .send(xloginSrc); } const owner = segs[0]; if (!OWNER_NAME.test(owner) || owner.includes('..')) return notFound(reply); // ---- git smart-HTTP lane: //.git/... if (segs.length >= 2 && segs[1].endsWith('.git')) { const name = segs[1].slice(0, -4); if (!REPO_NAME.test(name) || name.includes('..') || name.endsWith('.git')) return notFound(reply); const subPath = segs.slice(2).join('/'); if (!subPath && (request.method === 'GET' || request.method === 'HEAD')) { return reply.redirect(`${prefix}/${owner}/${name}`); } const isInfoRefs = request.method === 'GET' && subPath === 'info/refs'; const isService = request.method === 'POST' && SERVICES.has(subPath); if (!isInfoRefs && !isService) return reply.code(404).send('smart HTTP endpoints only\n'); const service = isInfoRefs ? String(request.query?.service ?? '') : subPath; if (!SERVICES.has(service)) { return reply.code(400).send('dumb HTTP protocol is not supported; use git >= 1.6.6\n'); } const isWrite = service === 'git-receive-pack'; // Forge push tokens (the NIP-98 exchange) are accepted here in // addition to every core scheme — a static extraHeader cannot // sign per-request NIP-98, a pod bearer works as before. const agent = await requestAgent(request); const agentOwner = ownerFromAgent(agent); if ((isWrite || privateRepos) && !agent) { reply.header('WWW-Authenticate', 'Basic realm="jss-forge", charset="UTF-8"'); return reply.code(401).send('authentication required\n'); } if ((isWrite || privateRepos) && agentOwner !== owner) { return reply.code(403).send(`this namespace belongs to ${owner}\n`); } if (!repoExists(owner, name)) { if (!isWrite) return reply.code(404).send('no such repository\n'); await materialize(owner, name, agent); // push-to-create, own namespace only } // Anchoring advance (3.5): when a receive-pack on an enabled repo // finishes, compare the default-branch tip and stack a pending // mark if it moved. recordTip re-checks everything under the lock. const onExit = (isWrite && marksEnabled(owner, name)) ? () => recordTipSafe(owner, name) : undefined; return runBackend(request, reply, { owner, name, subPath, agent, onExit }); } // ---- web UI lane (GET only from here on) if (request.method !== 'GET' && request.method !== 'HEAD') return reply.code(405).send(); if (privateRepos) { const agent = await requestAgent(request); if (!agent) { reply.header('WWW-Authenticate', 'Basic realm="jss-forge", charset="UTF-8"'); return reply.code(401).send('authentication required\n'); } if (ownerFromAgent(agent) !== owner) return reply.code(403).send('private forge\n'); } if (segs.length === 1) return ownerPage(reply, owner); const name = segs[1]; if (!REPO_NAME.test(name) || name.includes('..') || name.endsWith('.git')) return notFound(reply); if (!repoExists(owner, name)) return notFound(reply); if (segs.length === 2) return repoHome(reply, owner, name); const action = segs[2]; const tail = segs.slice(3); try { switch (action) { case 'tree': return tail.length ? await treePage(reply, owner, name, tail) : notFound(reply); case 'blob': return tail.length >= 2 ? await blobPage(reply, owner, name, tail) : notFound(reply); case 'raw': return tail.length >= 2 ? await rawResp(reply, owner, name, tail) : notFound(reply); case 'commits': return tail.length ? await commitsPage(reply, owner, name, tail, request.query) : notFound(reply); case 'commit': return tail.length === 1 ? await commitPage(reply, owner, name, tail[0]) : notFound(reply); case 'branches': return tail.length === 0 ? await refsPage(reply, owner, name, 'branches') : notFound(reply); case 'tags': return tail.length === 0 ? await refsPage(reply, owner, name, 'tags') : notFound(reply); case 'search': return tail.length === 0 ? await searchPage(reply, owner, name, request.query) : notFound(reply); case 'releases': return tail.length === 0 ? await releasesPage(reply, owner, name) : notFound(reply); case 'marks': return tail.length === 0 ? await marksPage(reply, owner, name) : notFound(reply); case 'blocktrails.json': return tail.length === 0 ? blocktrailsResp(reply, owner, name) : notFound(reply); case 'archive': return tail.length ? await archiveResp(reply, owner, name, tail) : notFound(reply); case 'compare': return tail.length ? await comparePage(reply, owner, name, tail.join('/')) : notFound(reply); case 'pulls': { if (tail.length === 0) return await pullsListPage(reply, owner, name, request.query); if (tail.length === 1 && tail[0] === 'new') return await newPullPage(reply, owner, name, request.query); if (ISSUE_NUM_RE.test(tail[0])) { if (tail.length === 1) return await pullPage(reply, owner, name, +tail[0]); if (tail.length === 2 && tail[1] === 'commits') return await pullCommitsPage(reply, owner, name, +tail[0]); if (tail.length === 2 && tail[1] === 'files') return await pullFilesPage(reply, owner, name, +tail[0]); } return notFound(reply); } case 'issues': { if (tail.length === 0) return await issuesListPage(reply, owner, name, request.query); if (tail.length === 1 && tail[0] === 'new') return await newIssuePage(reply, owner, name); if (tail.length === 1 && ISSUE_NUM_RE.test(tail[0])) return await issueThreadPage(reply, owner, name, +tail[0]); return notFound(reply); } default: return notFound(reply); } } catch (err) { api.log.warn(`forge: ${request.method} ${request.url} failed: ${err.message}`); return notFound(reply); } }; scope.route({ method: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], url: prefix || '/', handler }); scope.route({ method: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], url: `${prefix}/*`, handler }); }); api.log.info( `forge: repos at ${prefix}//.git, UI at ${prefix}/, issues at ${prefix}///issues, ` + `pulls at ${prefix}///pulls, push tokens at ${prefix}/api/token, xlogin at ${prefix}/xlogin.js ` + `(backend ${backend}, reads ${privateRepos ? 'owner-only' : 'public'}, ` + `merges ${mergeTreeOk ? 'on' : `OFF — ${gitVersion} lacks merge-tree --write-tree`}, ` + `anchoring on ${chain} — derive-and-record only, no chain I/O)`, ); return { deactivate() { /* nothing persistent to tear down */ } }; }