1- # forge — a personal git forge (tier 1: hosting + browsing; tier 2: issues; tier 2.5: nostr agents + xlogin; tier 3a: forks + pull requests; polish: labels, search, releases)
1+ # forge — a personal git forge (tier 1: hosting + browsing; tier 2: issues; tier 2.5: nostr agents + xlogin; tier 3a: forks + pull requests; polish: labels, search, releases; tier 3.5: Bitcoin-anchored history via Blocktrails )
22
33The useful slice of Gogs/Gitea as a JSS plugin: push a repo over smart
44HTTP, get a GitHub-style (light theme) web UI for it — repo list, file
@@ -16,12 +16,20 @@ wave adds **labels** on issues and PRs (GitHub's default set, colored
1616chips, ` ?label= ` filters), ** read-time search** (repo-list filter +
1717in-repo ` git grep ` , bounded, no index), ** releases** (every tag with
1818streamed tar.gz/zip archives), and hides the internal compare refs from
19- ` ls-remote ` (closing Finding 15).
20- Zero npm dependencies, zero build step, no
21- framework: every page is server-rendered HTML with inline CSS, all git
22- work is done by the system ` git ` binary, and the wire protocol is
23- delegated to the stock ` git-http-backend ` CGI (gitscratch's proven
24- plumbing, re-rooted).
19+ ` ls-remote ` (closing Finding 15). Tier 3.5 adds ** git-mark anchoring via
20+ [ Blocktrails] ( https://blocktrails.org ) ** — Bitcoin(testnet)-anchored,
21+ tamper-evident repo history: every default-branch tip derives a fresh
22+ taproot address; spending the previous mark's output to the new address
23+ IS the advance; the forge only ** derives and records** (see "Anchors").
24+ Zero build step, no framework: every page is server-rendered HTML with
25+ inline CSS, all git work is done by the system ` git ` binary, and the
26+ wire protocol is delegated to the stock ` git-http-backend ` CGI
27+ (gitscratch's proven plumbing, re-rooted). One npm dependency —
28+ ` @noble/curves ` , the host's own crypto library — arrived with tier 3.5
29+ for the secp256k1 point math (the ` blocktrails ` npm package is
30+ deliberately ** not** imported: the spec math is implemented locally and
31+ cross-checked in test.js against a vector generated from the
32+ maintainer's reference implementation).
2533
2634``` js
2735plugins: [{ id: ' forge' , module: ' forge/plugin.js' , prefix: ' /forge' ,
@@ -30,6 +38,8 @@ plugins: [{ id: 'forge', module: 'forge/plugin.js', prefix: '/forge',
3038 gitHttpBackend: ' /usr/lib/git-core/git-http-backend' , // optional
3139 pushTokenTtl: 3600 , // default lifetime of exchanged push tokens (s)
3240 cspConnect: [], // extra connect-src origins (e.g. external Solid IdPs)
41+ chain: ' tbtc4' , // anchoring chain (testnet4); 'btc' is REFUSED …
42+ allowMainnet: false , // … unless this is explicitly true (see Finding 18)
3343 } }]
3444```
3545
@@ -86,6 +96,8 @@ git -c http.extraHeader="Authorization: Bearer <token>" push forge main
8696| ` .../pulls/<n> ` | PR conversation: state banner (merged / closed / clean-with-merge-button / conflict list), thread, comment form |
8797| ` .../pulls/<n>/commits ` , ` .../pulls/<n>/files ` | GitHub-style sub-tabs: the ahead commits, the structured diff |
8898| ` .../pulls/new?base=...&head=... ` | new-PR form (from the compare page) |
99+ | ` .../marks ` | Anchors: the mark table (commit, state hash, derived tb1p… address, pending/marked chips, mempool.space tx links), chain badge, "Verify independently" hand-off, fund-this-mark box; the Enable pitch when anchoring is off |
100+ | ` .../blocktrails.json ` | the verifier-compatible trail document — the ONE CORS-open (` Access-Control-Allow-Origin: * ` ) route |
89101| ` <prefix>/<owner>/<name>.git/... ` | git smart HTTP (` info/refs ` , ` git-upload-pack ` , ` git-receive-pack ` ) |
90102| ` <prefix>/api/token ` | POST: exchange any ` getAgent ` credential for a push token (see "Nostr agents") |
91103| ` <prefix>/api/hosted/<hex>/<uuid> ` | GET (public) / DELETE (author-only): a podless agent's hosted issue words |
@@ -185,6 +197,26 @@ Polish wave (additive):
185197 — every tag, newest first; ` message ` is the first line of an annotated
186198 tag's message, ` null ` for lightweight tags; ` sha ` is the peeled commit.
187199
200+ Tier 3.5 (additive):
201+
202+ - ` GET api/repos/<o>/<n>/marks ` → ` { enabled: false, chain } ` or
203+ `{ enabled: true, chain, pubkeyBase, marks: [ { index,
204+ state: {commit, repo, branch}, stateHash, program, address, status:
205+ 'pending'|'marked', at, txid?, vout?, amount?, markedAt? }] }` —
206+ never the trail privkey (test-proven).
207+ - ` POST .../marks/enable ` (OWNER only) → 201 `{ enabled, chain,
208+ pubkeyBase, mark }` — genesis: mints the trail key and derives mark 0
209+ from the current default-branch tip; 422 on an empty repo, 409 when
210+ already enabled (no silent key rotation).
211+ - ` POST .../marks/<i>/txo ` ` {txid, vout, amount} ` (OWNER only) →
212+ ` { index, status: 'marked', txid, vout, amount } ` — records where the
213+ mark landed on-chain. Shape-validated (64-hex txid, integer vout,
214+ positive integer sats) and ** recorded, never verified** — the server
215+ does no chain I/O. 409 out of order (the trail is a linear spend
216+ chain), 409 when already marked.
217+ - ` GET <prefix>/<o>/<n>/blocktrails.json ` → the verifier document (see
218+ "Anchors"); 404 (still CORS-readable) when anchoring is off.
219+
188220Errors are ` { error } ` with 4xx. ` cloneUrl ` is absolute: the origin comes
189221from ` api.serverInfo ` (#601 ) at request time, with ` config.baseUrl ` as
190222the reverse-proxy override.
@@ -375,6 +407,79 @@ is validated (`okRef`/sha) and resolved with `rev-parse` *before* any
375407header goes out, so a bogus ref is a clean 404. Any resolvable ref works
376408— tags, branches, shas — which is exactly GitHub's archive behavior.
377409
410+ ## Anchors (tier 3.5): git-mark anchoring via Blocktrails
411+
412+ ** The design stance, absolute: the server derives and records; it NEVER
413+ touches the network.** All trail math is pure ` @noble/curves ` +
414+ ` node:crypto ` ; Bitcoin appears only as derived bech32m addresses,
415+ mempool.space ** links** in HTML (the user clicks), the served
416+ ` blocktrails.json ` , and README instructions for the maintainer's own
417+ CLIs (` fund-agent ` , ` git mark ` ) which do the funding/broadcasting
418+ elsewhere. No fetch to any chain API from the plugin, ever (Finding 19).
419+
420+ ** The derivation** (blocktrails spec v0.2, normative — implemented from
421+ the spec, not imported):
422+
423+ ```
424+ state = { commit, repo, branch } one per default-branch tip
425+ h = sha256(JSON.stringify(state)) the git-mark-demo state-hash convention
426+ tᵢ = tagged_hash("TapTweak", x_only(Pᵢ₋₁) || h) mod n BIP-341 TapTweak
427+ Pᵢ = Pᵢ₋₁ + tᵢ·G chained; P₋₁ = the trail base key
428+ address = bech32m(hrp, 1, x_only(Pᵢ)) tb1p… on testnet4
429+ ```
430+
431+ Each state derives a ** new** address by tweaking; ** spending the
432+ previous mark's UTXO to the new address IS the advance** — a linear,
433+ Bitcoin-ordered chain of commitments. The plugin derives and records
434+ addresses and the expected chain; the transactions happen outside.
435+
436+ ** The lifecycle** :
437+
438+ 1 . ** Enable** (` POST .../marks/enable ` , owner): mints a fresh trail key
439+ (forge-held, 0600 in ` pluginDir/marks/<owner>/<repo>.json ` — the
440+ custody trade-off is Finding 18) and derives mark 0 from the CURRENT
441+ default-branch tip.
442+ 2 . ** Advance on tip change** : when ` git-receive-pack ` completes on an
443+ anchoring-enabled repo (the CGI child's exit is the post-receive
444+ moment — Finding 17), or a PR merge lands via ` update-ref ` , one
445+ ` recordTip ` helper compares the default-branch tip to the last mark
446+ and appends a new ** pending** mark. Multiple pushes stack pending
447+ marks honestly — each mark is one state; only funded/spent marks ever
448+ become ` marked ` . Non-default-branch pushes never advance
449+ (test-proven). 500-mark cap per trail.
450+ 3 . ** Fund/spend off-server** : the marks page shows the first pending
451+ mark's address plus copy-paste ` npx fund-agent "txo:tbtc4:…" ` /
452+ ` git mark advance ` instructions per the maintainer's READMEs.
453+ 4 . ** Record** (` POST .../marks/<i>/txo ` , owner): report `{txid, vout,
454+ amount}` ; the mark flips to ` marked`. Recorded in order — the trail
455+ is a linear spend chain. Shape-checked, never chain-checked.
456+ 5 . ** Verify independently** : the marks page hands off to the hosted
457+ verifier (`https://blocktrails.github.io/verify/?uri= <this repo's
458+ blocktrails.json>`), which fetches the trail document cross-origin
459+ and checks every mark against the chain ** client-side** — tx exists,
460+ confirmed, amount matches, spends the previous mark. The server
461+ records claims; an independent client verifies them. That split is
462+ the architecture's point.
463+
464+ ** ` blocktrails.json ` ** is exactly the shape the zero-build verifier
465+ consumes (` @type: Blocktrail ` , ` profile: gitmark ` , ` pubkeyBase ` ,
466+ ` chain ` , ` states[] ` = the marked commits, ` txo[] ` =
467+ ` txo:<chain>:<txid>:<vout>?amount=…&commit=…&pubkey=… ` URIs), plus
468+ additive self-description (` derivation ` , ` stateHash ` , and the full
469+ ` marks[] ` list including pendings). It is served with
470+ ` Access-Control-Allow-Origin: * ` — a deliberate one-route CORS grant:
471+ the document is a public verification artifact whose entire purpose is
472+ to be fetched cross-origin by a verifier page; everything in it is
473+ already public on the marks page, so CORS widens reach, not exposure.
474+ (The hosted-verifier hand-off is a plain ` <a href> ` navigation, which
475+ CSP's ` connect-src 'self' ` does not govern — no CSP delta needed.)
476+
477+ ** Testnet-only defaults** : ` chain: 'tbtc4' ` (testnet4). Mainnet chain
478+ identifiers (` btc ` /` mainnet ` /` bitcoin ` ) are ** refused at activate** —
479+ loudly, naming the stakes — unless ` config.allowMainnet: true ` ; unknown
480+ chains are refused too (no address is better than a wrong-network
481+ address someone might fund).
482+
378483## Nostr agents (tier 2.5)
379484
380485### Identity model — hex canonical, npub display-only
@@ -798,3 +903,92 @@ invisible — the page renders, the button clicks, nothing happens, and
798903only the browser console says why. Same lesson as Finding 8's
799904connect-src discovery: each new * kind* of page interactivity trips a
800905different CSP directive.
906+
907+ ### 17. Core called git-mark post-receive "core, not a plugin" (#271 ) — the forge moved that line
908+
909+ Core's ISSUES.md argued a git-mark hook must live in core because only
910+ core owns the receive path. The forge is the counter-witness: because
911+ this plugin delegates the wire protocol to ` git-http-backend ` but OWNS
912+ the CGI child, "receive finished, refs are final" is simply the child's
913+ ` close ` event — a one-line ` onExit ` seam on the existing ` runBackend ` .
914+ Both tip-movers route through one ` recordTip ` helper: receive-pack via
915+ ` onExit ` , PR merges via a direct call after ` update-ref ` (they move the
916+ tip without any receive-pack). ` recordTip ` re-checks everything under
917+ the per-repo mark lock (compares the tip to the last mark's state), so
918+ firing it on * any* child exit — even a partially failed push — is
919+ correct rather than optimistic. What made it plugin-able wasn't a new
920+ api seam; it was owning the receive path in the first place. The test
921+ wrinkle: the hook fires after the response, so push-advance assertions
922+ poll (the merge route, by contrast, awaits ` recordTip ` before
923+ answering — the mark is in the response's happens-before).
924+
925+ ### 18. Forge-held trail keys are testnet custody — real custody is the xlogin/NIP-07 path
926+
927+ Enabling anchoring mints the trail privkey server-side and keeps it in
928+ ` pluginDir/marks/<owner>/<repo>.json ` (0600, never crossing an API
929+ surface — test-proven against every marks route). That is CUSTODY: the
930+ operator can read the key, and whoever holds the key controls all
931+ future transitions and the funds in the head output (the spec's own
932+ threat model). On testnet4 — the enforced default — the stakes are
933+ demonstration sats, and the convenience (the forge can derive every
934+ future address without a signing round-trip) is worth it. On mainnet it
935+ would not be, which is exactly why ` chain: 'btc' ` is refused at
936+ activate unless ` allowMainnet ` is explicit. The honest next step is
937+ client-side keys: the owner's OWN key signs each advance in the browser
938+ (xlogin already ships NIP-07/NIP-98 plumbing on these very pages), the
939+ forge stores only ` pubkeyBase ` and derives addresses from public
940+ material — the derivation needs no secrets, only the spend does. Then
941+ the forge is purely a recorder and the custody Finding dissolves.
942+
943+ ### 19. Derive-and-record vs. broadcast-and-verify: the SSRF-free anchoring architecture
944+
945+ The obvious design — server calls mempool.space to check funding, maybe
946+ broadcasts too — makes the pod server an outbound HTTP client steered
947+ by user-shaped data (SSRF surface, availability coupling, and a trusted
948+ third party smuggled into the trust chain). The split here keeps the
949+ server pure: it DERIVES addresses (local math) and RECORDS owner-shaped
950+ claims (shape-validated JSON); funding and broadcasting happen in the
951+ owner's own CLIs; verification happens in an independent, zero-build
952+ client-side verifier that anyone can point at the served
953+ ` blocktrails.json ` . The server never learns whether a claim is true —
954+ and doesn't need to, because the verifier checks the actual chain and
955+ the claims are cheap-talk until it does. One deliberate CORS grant
956+ (` Access-Control-Allow-Origin: * ` on ` blocktrails.json ` alone) is the
957+ entire integration surface between the two halves. Bitcoin appears in
958+ the plugin as: bech32m strings, ` https://mempool.space/... ` hrefs, and
959+ instructions. ` grep -c fetch ` on the anchoring code: zero.
960+
961+ ### 20. x-only parity in TapTweak land — and the maintainer's own CLI disagrees with the spec
962+
963+ Two real discoveries from implementing the derivation instead of
964+ importing it:
965+
966+ - ** Parity is an output-encoding concern, not a chain concern.** The
967+ spec keeps FULL points through the chain (` Pᵢ = Pᵢ₋₁ + tᵢ·G ` , parity
968+ and all) and takes ` x_only ` twice per step: as the tweak input (the
969+ raw x of the possibly-odd-Y running point — NOT re-lifted to even-Y
970+ as BIP-341 wallet stacks do between steps) and at the output boundary
971+ (where ` x(P) == x(−P) ` makes comparison parity-free; only a SPENDER
972+ must negate per BIP-340, and this plugin never spends). Get the
973+ lift-x placement wrong and addresses diverge silently — which is why
974+ the test suite pins a vector generated by RUNNING the maintainer's
975+ reference implementation (` blocktrails ` deriveChainedPublicKey +
976+ p2trXonly on fixed inputs; the mirror carries no hard-coded spec
977+ vectors, so the reference impl is the vector source), plus an
978+ independent bech32m decode round-trip. bech32 vs bech32m turned out
979+ to be ONE constant (` 1 ` vs ` 0x2bc830a3 ` ) over the same polymod — the
980+ tier-2.5 npub encoder and the tb1p… encoder now share their guts.
981+ - ** The git-mark CLI (npm ` git-seal ` ) does not implement the spec's
982+ derivation.** It uses the raw commit hash as the scalar
983+ (` t = commit mod n ` — no TapTweak, no state hash, no pubkey
984+ binding), while the spec mandates
985+ ` tagged_hash("TapTweak", x_only(P) || sha256(state)) ` and the
986+ git-mark-demo hashes ` {commit, repo, branch} ` . This plugin follows
987+ the SPEC (the task's normative reference) with the demo's state
988+ convention — so addresses derived here will not match `git mark
989+ address` for the same commits under the same key. Interop survives
990+ because the hosted verifier checks the SPEND CHAIN (tx exists,
991+ confirmed, amount, spends-previous), not the derivation; the
992+ divergence is written down here and self-described in the served
993+ document (` derivation ` , ` stateHash ` fields) so a future re-deriving
994+ verifier knows exactly what it is looking at.
0 commit comments