| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Name | Name | Last commit date | ||
|---|---|---|---|---|
parent directory.. | ||||
The useful slice of Gogs/Gitea as a JSS plugin: push a repo over smart HTTP, get a GitHub-style (light theme) web UI for it — repo list, file table, rendered README, tree/blob/raw views, commit log with pagination, green/red unified diffs, branches and tags — plus a clean JSON API over the same model. Tier 2 adds issues and comments whose bodies live in the author's pod (the forge keeps only a pointer index — see the architecture below). Tier 2.5 makes did:nostr agents first-class (hex-pubkey namespaces, a NIP-98 → push-token exchange, forge-hosted issue bodies for podless agents) and puts the vendored xlogin widget on the issues pages. Tier 3a adds forks, compare, and pull requests with real merges (merge-tree --write-tree + commit-tree + compare-and-swap update-ref — see "Forks & pull requests"). The polish wave adds labels on issues and PRs (GitHub's default set, colored chips, ?label= filters), read-time search (repo-list filter + in-repo git grep, bounded, no index), releases (every tag with streamed tar.gz/zip archives), and hides the internal compare refs from ls-remote (closing Finding 15). Tier 3.5 adds git-mark anchoring via Blocktrails — Bitcoin(testnet)-anchored, tamper-evident repo history: every default-branch tip derives a fresh taproot address; spending the previous mark's output to the new address IS the advance; the forge only derives and records (see "Anchors"). Zero build step, no framework: every page is server-rendered HTML with inline CSS, all git work is done by the system git binary, and the wire protocol is delegated to the stock git-http-backend CGI (gitscratch's proven plumbing, re-rooted). One npm dependency — @noble/curves, the host's own crypto library — arrived with tier 3.5 for the secp256k1 point math (the blocktrails npm package is deliberately not imported: the spec math is implemented locally and cross-checked in test.js against a vector generated from the maintainer's reference implementation).
plugins: [{ id: 'forge', module: 'forge/plugin.js', prefix: '/forge',
config: {
privateRepos: false, // true: ALL reads become owner-only
gitHttpBackend: '/usr/lib/git-core/git-http-backend', // optional
pushTokenTtl: 3600, // default lifetime of exchanged push tokens (s)
cspConnect: [], // extra connect-src origins (e.g. external Solid IdPs)
chain: 'tbtc4', // anchoring chain (testnet4); 'btc' is REFUSED …
allowMainnet: false, // … unless this is explicitly true (see Finding 18)
announceRelays: [], // NIP-34 relays (opt-in); empty => emission OFF
announceKey: undefined, // optional 32-byte hex nostr privkey; else generated once
openEdit: false, // DEMO ONLY: anonymous web edits (see caveat below) — never a default
} }]# push-to-create: your pod username is your namespace
git remote add forge http://localhost:3000/forge/casey/demo.git
git -c http.extraHeader="Authorization: Bearer <token>" push forge main
# then browse http://localhost:3000/forge/casey/demo| route | page |
|---|---|
| <prefix>/ | repo list across owners (description, last-push time) |
| <prefix>/<owner> | that owner's repos |
| <prefix>/<owner>/<name> | repo home: branch selector, file table, latest-commit bar, README card, clone box |
| .../tree/<ref>/<path> | directory listing (folders first, per-entry last commit) |
| .../blob/<ref>/<path> | file view: line numbers, monospace; binary/too-large fall back to a raw link |
| .../raw/<ref>/<path> | raw bytes — text/plain or application/octet-stream+attachment, never text/html |
| .../commits/<ref>?page=N | log, 30/page: message, short sha, author, relative time, identicon |
| .../commit/<sha> | full commit with GitHub-style unified diff (collapsible per-file sections, +N/−M counts) |
| .../branches, .../tags | ref lists |
| .../issues?state=open|closed&label=<name>&page=N | issue list: GitHub-style filter tabs, label chips, green open / purple closed icons, relative times, comment counts |
| .../search?q=<text> | read-time code search over the DEFAULT branch: git grep hits (path:line + escaped excerpt) plus matching file paths — bounded, no index |
| .../releases | every tag, newest first: annotated-tag message, commit link, tar.gz + zip download buttons (linked from the Tags tab) |
| .../archive/<ref>.tar.gz, .../archive/<ref>.zip | git archive streamed (attachment, --prefix=<repo>-<ref>/); unresolvable refs are 404 |
| .../issues/<n> | thread: issue body then comments in comment boxes (identicon, author → WebID link, relative time, owner badge), markdown bodies |
| .../issues/new | new-issue form (vanilla-JS client, see below) |
| .../compare/<base>...[<owner>:]<ref> | compare view: ahead/behind counts, the ahead commit list, the structured diff, an "Open pull request" button |
| .../pulls?state=open|merged|closed&label=<name>&page=N | PR list: three-state filter tabs — green open, purple merged, red closed — plus label chips |
| .../pulls/<n> | PR conversation: state banner (merged / closed / clean-with-merge-button / conflict list), thread, comment form |
| .../pulls/<n>/commits, .../pulls/<n>/files | GitHub-style sub-tabs: the ahead commits, the structured diff |
| .../pulls/new?base=...&head=... | new-PR form (from the compare page) |
| .../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 |
| .../blocktrails.json | the verifier-compatible trail document — the ONE CORS-open (Access-Control-Allow-Origin: *) route |
| <prefix>/<owner>/<name>.git/... | git smart HTTP (info/refs, git-upload-pack, git-receive-pack) |
| <prefix>/api/token | POST: exchange any getAgent credential for a push token (see "Nostr agents") |
| <prefix>/api/hosted/<hex>/<uuid> | GET (public) / DELETE (author-only): a podless agent's hosted issue words |
| <prefix>/xlogin.js | the vendored xlogin widget, byte-identical, application/javascript, immutable cache |
Refs may contain / (feature/x): the tree/blob/raw/commits routes resolve the ref greedily against the real ref list (longest match wins), so tree/feature/x/src is unambiguous.
All under GET <prefix>/api, application/json, strings raw (JSON is the escape) — except readme.html, which is the server-side markdown renderer's already-escaped HTML, safe to inject as markup. Shapes:
Tier 3a (additive):
Polish wave (additive):
Tier 3.5 (additive):
Errors are { error } with 4xx. cloneUrl is absolute: the origin comes from api.serverInfo (#601) at request time, with config.baseUrl as the reverse-proxy override.
the AUTHOR's pod (WAC-governed, author-owned) pluginDir/issues/<owner>/<repo>.json
/casey/public/forge/o--r/issue-<uuid>.jsonld { next: 4, issues: { 1: {
/dana/public/forge/o--r/comment-<uuid>.jsonld number, title, state: open|closed,
{ type: ForgeIssue|ForgeComment, repo, author, createdAt,
issue, title?, body, published, author } thread: [{ author, resourceUrl, at }, …] } } }
Every issues page embeds one dependency-free inline <script type="module"> (no build, no framework) that:
With JS off the pages stay fully readable and a <noscript> note says interactive actions need JavaScript and sign-in.
The flow: fork (POST api/repos/<o>/<n>/fork) runs git clone --local --bare into the caller's namespace — same repo name, 409 on collision, optional {name} override (that's how you fork your own repo, which GitHub also allows; a same-name self-fork would always collide). Lineage is recorded in the fork's bare-repo config (forge.parent = <o>/<n>); the fork's home and the repo lists show "forked from …", the parent shows a fork count, and a freshly pushed non-default branch on a fork earns an "open a pull request?" hint. Compare (/compare/<base>...<head>) takes a ref of THIS repo as base and either a same-repo ref or <owner>:<ref> as head. The fork rule is deliberately simple and name-based: the head owner's repo with the same name — lineage chains are not chased. Cross-repo heads are fetched by filesystem path (both ends forge-owned, never a user-supplied URL) into a hidden, reusable ref in the base repo (refs/forge/heads/<owner>/<ref>, force-updated per call — repeat compares refresh it, nothing to clean up). A pull request is the issues architecture verbatim: the body/comments live in their authors' pods (or forge-hosted for podless nostr agents), the spine lives in pluginDir/pulls/<owner>/<repo>.json with the same atomic-write + per-repo-lock discipline, plus base ref, head {owner, repo, ref} and merge info.
Merge semantics — real git, bare-repo safe. The target repos are bare, so nothing ever checks out a worktree:
ff-when-possible policy: when the base is an ancestor of the head, the merge is a plain compare-and-swapped update-ref — no synthetic merge commit — recorded as merged with fastForward: true and the head sha as the merge sha. (GitHub's default always creates a merge commit; this forge prefers not to invent history where none is needed. The Commits/Files tabs of a merged PR stay exact either way: the shas are frozen in the merge record.)
Separate numbering, documented deviation: GitHub numbers issues and PRs from one shared counter; here pulls/<owner>/<repo>.json numbers independently of the issues index. Sharing would entangle every PR write with the issues index (one more lock, one more file rewritten, crossed failure modes) for no user value in a personal forge — #N is unambiguous because issues and pulls live under different routes.
Auth: fork needs any authenticated agent (it writes to the CALLER's namespace); PR create/comment need an authenticated agent with somewhere to keep the words (pod or hosted); close/reopen are for the target repo owner or the PR author; merge is target repo owner only. Anonymous writes are 401, everything else 403 — house rules.
The per-repo label SET lives in the repo's issues index file (idx.labels); until the first label write, GitHub's default five apply (and are what GET .../labels serves), so every repo has useful labels with zero setup. Items — issues AND pull requests — store label names only: a recolor is one index write with no cascade, while rename/delete cascade over both the issues and pulls indexes under their locks. Chips render GitHub-style (rounded-full, background = the label color, black-ish/white text picked by perceptual luminance, hairline border so wontfix's white survives). Authorization is two-level, matching the close/reopen beat: the label set is the OWNER's (CRUD), a given item's labels are settable by the owner or that item's author.
Deliberately read-time only — nothing is indexed:
.../releases lists every tag, newest first (for-each-ref --sort=-creatordate) — annotated tags show the first line of their tag message, lightweight tags are marked as such — each with tar.gz and zip download buttons. .../archive/<ref>.tar.gz|.zip runs git archive --format=… --prefix=<repo>-<ref>/ <ref> and streams the child's stdout to the response (attachment disposition, no buffering); the ref is validated (okRef/sha) and resolved with rev-parse before any header goes out, so a bogus ref is a clean 404. Any resolvable ref works — tags, branches, shas — which is exactly GitHub's archive behavior.
POST <prefix>/api/repos/<owner>/<name>/edit is the GitHub-web-editor equivalent: a browser edits one file and the change lands as a real commit, then rides the forge's existing NIP-34 emission out to relays. No server-side index surgery — the commit is made in a disposable local clone and pushed back to the bare repo.
Body (application/json): { path, content, message?, branch? }.
Auth — owner-signed by default (the principled mode). Resolve the agent (any scheme the git lane accepts, NIP-98 included), map it to a namespace, and require it equals <owner>: anonymous → 401, wrong owner → 403 — the same gate as marks/enable and announce. The principled browser upgrade is a NIP-07 / browser-signature path (sign a NIP-98 event in the page, or exchange one for a push token at POST <prefix>/api/token) so the edit carries the editor's own identity.
config.openEdit: true relaxes this to anonymous edits (DEMO ONLY). It is never a default; the plugin logs a one-time loud warning at activate when it is on. ⚠️ Spam/abuse caveat: with openEdit on, anyone on the internet can rewrite any file in any repo on the instance — reserve it for throwaway testnet-demo repos you are happy to see vandalized, never a real forge. There is no rate limit and no per-file authorization; the honest posture is owner-signed.
Behavior & responses (all CORS-readable — the demo page is cross-origin, so success and error replies carry Access-Control-Allow-Origin: * and an OPTIONS preflight is answered):
Because the push moves the tip, a web edit triggers the same downstream beats as a git push: any Blocktrails anchor advances (recordTip) and the NIP-34 30617 + 30618 go out (announceRepoSafe, fire-and-forget, a no-op with no relays). Every git spawn runs hermetic — GIT_CONFIG_NOSYSTEM=1, no HOME, execFile (never a shell).
The design stance, absolute: the server derives and records; it NEVER touches the network. All trail math is pure @noble/curves + node:crypto; Bitcoin appears only as derived bech32m addresses, mempool.space links in HTML (the user clicks), the served blocktrails.json, and README instructions for the maintainer's own CLIs (fund-agent, git mark) which do the funding/broadcasting elsewhere. No fetch to any chain API from the plugin, ever (Finding 19).
The derivation (blocktrails spec v0.2, normative — implemented from the spec, not imported):
state = { commit, repo, branch } one per default-branch tip
h = sha256(JSON.stringify(state)) the git-mark-demo state-hash convention
tᵢ = tagged_hash("TapTweak", x_only(Pᵢ₋₁) || h) mod n BIP-341 TapTweak
Pᵢ = Pᵢ₋₁ + tᵢ·G chained; P₋₁ = the trail base key
address = bech32m(hrp, 1, x_only(Pᵢ)) tb1p… on testnet4
Each state derives a new address by tweaking; spending the previous mark's UTXO to the new address IS the advance — a linear, Bitcoin-ordered chain of commitments. The plugin derives and records addresses and the expected chain; the transactions happen outside.
The lifecycle:
blocktrails.json is exactly the shape the zero-build verifier consumes (@type: Blocktrail, profile: gitmark, pubkeyBase, chain, states[] = the marked commits, txo[] = txo:<chain>:<txid>:<vout>?amount=…&commit=…&pubkey=… URIs), plus additive self-description (derivation, stateHash, and the full marks[] list including pendings). It is served with Access-Control-Allow-Origin: * — a deliberate one-route CORS grant: the document is a public verification artifact whose entire purpose is to be fetched cross-origin by a verifier page; everything in it is already public on the marks page, so CORS widens reach, not exposure. (The hosted-verifier hand-off is a plain <a href> navigation, which CSP's connect-src 'self' does not govern — no CSP delta needed.)
Testnet-only defaults: chain: 'tbtc4' (testnet4). Mainnet chain identifiers (btc/mainnet/bitcoin) are refused at activate — loudly, naming the stakes — unless config.allowMainnet: true; unknown chains are refused too (no address is better than a wrong-network address someone might fund).
A repo lives on many forges (mirrors); the Blocktrails mark (a Bitcoin tx) is the source of truth; NIP-34 Nostr events are the discovery + notification layer. The Anchors work above records the source of truth; this layer makes it findable and syncable to a precise commit. The forge publishes two correlated NIP-34 events, signed by the SAME stable per-instance key, to the operator-configured relays:
A stable per-instance Nostr key. config.announceKey (32-byte hex privkey) wins if set; otherwise one is generated once and persisted at pluginDir/announce-key (mode 0600), so the forge keeps one identity across restarts. Its npub-short is logged at boot. The announce pubkey is the x-only 32-byte schnorr key (event.pubkey).
config.announceRelays is an array of wss:// relay URLs. Empty or unset ⇒ emission is DISABLED (a logged no-op) — relays are never hardcoded always-on; the operator opts in. A reasonable ngit-compatible set to opt into:
announceRelays: [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
]buildRepoEvent(owner, name) returns a signed event (NIP-01: id = sha256 of the canonical [0,pubkey,created_at,kind,tags,content] serialization, sig = schnorr over the id). Tags:
| tag | value | when |
|---|---|---|
| d | <owner>/<name> | always (the NIP-34 repo id) |
| name | <name> | always |
| description | first line of the repo description/README | always (also the content) |
| web | <origin><prefix>/<owner>/<name> | always (browse mirror URL) |
| clone | <origin><prefix>/<owner>/<name>.git | always (clone mirror URL) |
| relays | the configured relay URLs | when announceRelays set |
| maintainers | <owner> | nostr-owned repos (owner is the hex pubkey) |
| r | <origin><prefix>/<owner>/<name>/blocktrails.json | when anchored |
| anchor | [<chain>, <genesis-txid>] | when anchored |
The last two tie the discovery event back to the Bitcoin source of truth: r hands a consumer the CORS-readable, verifier-compatible trail doc, and the custom anchor tag carries the chain + genesis txid (a marked mark 0 — marks are a linear spend chain, so a marked genesis is the on-chain root). created_at is Math.floor(Date.now()/1000) (normal plugin runtime).
buildStateEvent(owner, name) returns a signed kind-30618 event (same NIP-01 id/sig helper, same announce key). It is a replaceable event (NIP-33) keyed by the same d as the 30617, so a consumer correlates the announcement with its state. It carries the repo's refs, read straight from the bare repo (symbolic-ref HEAD for the default branch, for-each-ref refs/heads for each head's full commit sha):
| tag | value | when |
|---|---|---|
| d | <owner>/<name> | always (same repo id as the 30617) |
| refs/heads/<branch> | <full-commit-sha> | one per local head (default branch first) |
| HEAD | ref: refs/heads/<defaultBranch> | always (the symbolic default) |
| r | <origin><prefix>/<owner>/<name>/blocktrails.json | when anchored |
| anchor | [<chain>, <genesis-txid>] | when anchored |
content is empty (per the state-event convention). Each refs/heads/<branch> tag hands a subscriber a precise commit to check out (nostr-git-sync's kind:30618 → git checkout <commit>). When the repo is anchored, the state event carries the SAME anchor (chain + genesis txid) and r tags as the 30617 — so a subscriber can require Bitcoin-anchored state (verify the txid on-chain) before pulling the announced commit.
A tip change is announced by either (a) anchoring (a mark flipping to marked) or (b) calling the announce endpoint — there is no filesystem watcher (git over the forge is shadowed by core --git on the live box, so pushes may not flow through the forge; the reliable triggers are anchor + the manual endpoint).
publishEvent(event) opens a ws WebSocket to each relay, sends ["EVENT", event], and waits (bounded ~5 s per relay) for the relay's ["OK", <id>, true|false, …], returning {relay, ok, error?}[]. A single relay failing never throws; the ws client is loaded lazily (only when a publish actually happens), so activation never depends on it and the no-relays path never touches it.
Every announce emits both events (30617 then 30618) to the relays.
The forge signs with its own instance key, not the maintainer's nostr key. For a mirror announcement that is the honest claim — "this instance hosts these clone URLs, anchored to this tx" — and it needs no custody of the maintainer's key. The maintainers tag still names the maintainer's pubkey (for nostr-owned repos the owner is that hex pubkey), so a canonical maintainer-signed 30617 can supersede the forge's mirror announcement in any viewer that prefers the maintainer. This mirrors the anchoring custody posture (Finding 18): the forge-instance key is a POC-grade signer for a discovery claim, and the principled upgrade is the same one — the maintainer signs client-side (NIP-07/xlogin) and the forge only relays. Until then, forge-signed mirror announcements are a sound, clearly-scoped discovery layer.
Per did-nostr.com, the canonical nostr identity is did:nostr:<64-char-lowercase-hex-pubkey> — exactly the string getAgent returns when a NIP-98 signature verifies and no WebID mapping exists. The forge keys everything on it:
git's static http.extraHeader cannot carry NIP-98 for a push: each kind-27235 event signs one u (url) + method pair, and a push is at least GET …/info/refs + POST …/git-receive-pack (Finding 9). So the flow is one exchange:
POST <prefix>/api/token[?ttl=seconds]
Authorization: <anything getAgent accepts — NIP-98 included>
→ 201 { token: "f1.…", tokenType: "Bearer", agent, iat, exp }
The token is macaroon-lite (capability/'s pattern): f1.<base64url payload>.<base64url HMAC-SHA256>, payload { v:1, agent, iat, exp }, secret in pluginDir/token-secret. Scope is {agent, ttl} with ttl = config.pushTokenTtl ?? 3600 (a ?ttl= override is capped at 30 days; non-positive values mint an already-expired token — used by the tests). The forge accepts it wherever it authenticates — the git lane first, then every core scheme via getAgent. A forge token cannot mint another forge token (no self-refresh). WebID users don't need it (the pod bearer works in extraHeader as before) but may use it.
Copy-paste client flow using the nip98 npm lib's getToken for the exchange request (docs only — the forge itself has no npm dependencies):
import { getToken } from 'nip98';
const url = 'http://localhost:3000/forge/api/token';
const auth = await getToken(url, 'POST', (e) => window.nostr.signEvent(e), true);
const { token } = await (await fetch(url, { method: 'POST', headers: { authorization: auth } })).json();
// pubkey-hex namespace: your pubkey IS your owner segmentgit -c http.extraHeader="Authorization: Bearer $TOKEN" \
push http://localhost:3000/forge/<pubkey-hex>/myrepo.git mainA did:nostr agent can authenticate and own a namespace, but has no pod of its own to keep its words in — the tier-2 loopback-PUT beat has nowhere to land. So nostr-authored issue/comment bodies are stored under pluginDir/hosted/<hex>/<uuid>.json, thread pointers carry {hosted: true}, the JSON API says so, and the UI renders a muted "hosted by the forge" tag on those entries (pod-stored ones stay pure). The author keeps the deletion beat: DELETE <prefix>/api/hosted/<hex>/<uuid> (same did:nostr identity, verified by NIP-98 or a forge token) removes the words and the thread renders the same "content removed by its author" placeholder as a pod delete. Anyone else gets 403. This asymmetry is the api.podOf(agent) / pods-for-keys ask — see Finding 10.
forge/xlogin.js is xlogin 0.0.15, vendored verbatim (AGPL, attribution header only — AGENT.md's pattern) and served byte-identical at <prefix>/xlogin.js with an immutable cache header. Issues pages load it via <script src>; if window.xlogin initialises, the auth area shows a Sign in with xlogin button beside the local username/password box (the fallback tab), and all API writes go through window.xlogin.authFetch — NIP-98 for nostr sessions, DPoP for Solid sessions, both verified server-side by the same getAgent the rest of the forge uses.
What works locally vs. what CSP blocks (deliberately):
Escape first, then transform — the renderer is structurally XSS-proof (test-proven: a README containing <script>alert(1)</script> renders it as visible escaped text). Grammar:
READMEs and blobs over 512 KiB are not rendered ("view raw"); binary detection is a NUL sniff over the first 8 KB.
A forge serves attacker-authored bytes from the API origin: a pushed evil.html fetched as text/html would run scripts with the origin's cookies/storage — classic stored XSS. GitHub's fix is architectural (a separate raw.githubusercontent.com domain); a single-origin plugin can't have that, so the raw endpoint neutralizes instead: text-ish blobs go out as text/plain, everything else as application/octet-stream + content-disposition: attachment, never text/html, plus X-Content-Type-Options: nosniff so browsers can't second-guess. Test-proven. The same reasoning puts the README renderer server-side and escape-first: HTML in a README is data, never markup.
Like micropub/, this plugin needs zero protocol-fixed paths outside its prefix — smart HTTP, UI and JSON API all live under <prefix>, which the loader WAC-exempts. api.reservePath (#602) exists and is the right tool for protocol-pinned roots (xrpc, .well-known), but a forge is evidence the common case needs nothing beyond api.prefix. gitscratch's scoped pass-through content parser was also enough to stream raw pack bodies through Fastify — api.mountApp (#583) was not needed here.
The maintainer wants clients with JSON and JS; the api layer (<prefix>/api/...) is deliberately shaped so a future vanilla-JS client (or a Gitea-compatible tool, loosely) can drive everything the HTML shows: repo meta with pre-rendered README html, typed tree entries, blob flags (binary/tooLarge), paginated commits, and a structured diff (files[] → hunks[] → typed lines with old/new line numbers) that is the same parse the HTML diff is rendered from — one parser, two surfaces, so they cannot drift. HTML stayed server-rendered because it was finished and correct; the JSON API is the extension point for a richer client, not a rewrite hook.
An issue body or comment is a JSON-LD resource the author owns: the forge loopback-PUTs it into the author's own pod with the author's own forwarded Bearer, so the pod's real WAC decides the write and the resource remains the author's property. The forge keeps only the spine — number allocation, title, open/closed state, and an ordered list of {author, resourceUrl, at} pointers — and re-fetches the words at read time. The split is exact: everything that is coordination (numbering, state, ordering) is plugin state; everything that is speech is pod data. The killer beat, test-proven: when the commenter DELETEs her resource from her own pod (a plain LDP DELETE the forge never sees), the thread renders "content removed by its author" — real deletion with no forge cooperation required, GDPR-shaped for free, and the pointer preserves the thread's who/when integrity.
The flip side of Finding 6: the repo owner cannot edit or delete the words in a commenter's pod — melvin can close the issue or retitle it (index operations), but a spam comment's body sits in the spammer's pod under the spammer's WAC, where melvin has no write bit. All the forge could do today is drop the pointer (hiding, not deleting — the resource survives at a public URL). Real moderation needs a third authority level: "this forge rules this thread", i.e. the plugin asserting authority over content it indexes but does not store — exactly the api.authorize (#604) issuer-authority case. Until that seam exists, moderation here is honest about being curation of pointers.
A thread with N entries costs N loopback GETs on every render, because the index refuses to cache body text (a cache would resurrect deleted content — the one thing the architecture promises not to do). Bounded here: 8-way parallelism, 8 s per-fetch timeout, 500-entry thread cap, 64 KiB body cap; a full 500-entry thread is ~63 sequential rounds of 8. That is fine at plugin scale and wrong at any bigger scale — the correct fix is an invalidation signal, not a TTL: api.events (#603) firing on pod resource change would let the forge cache bodies and evict on delete, keeping the removal semantics exact. One more real discovery: once a page carries a JSON-driven client, CSP needs connect-src 'self' — tier 1's default-src 'none' silently blocks every fetch(), which is invisible until the first interactive page exists.
A NIP-98 event binds ONE u (url) + method; a push is at least an info/refs GET and a git-receive-pack POST with different URLs and methods, and http.extraHeader is static for the whole operation — so "just put a NIP-98 header on git" is structurally impossible. Reading src/auth/nostr.js shows core already fights this with git-mode leniencies: it accepts NIP-98 smuggled inside Basic base64("nostr:" + token) (git credential helpers), allows method: "*", and allows the event's u to be a PREFIX of the request URL — which means a pre-signed base-URL event could ride a whole push, but only inside the ±60 s created_at window, and only by weakening exactly the bindings NIP-98 exists to make. The forge's answer is the honest one: a single POST api/token exchange (NIP-98-authenticated, per-request-correct) for a bearer whose TTL is a real, chosen number instead of 60 seconds of accidental slack. The exchange endpoint deliberately refuses to accept its own tokens as the minting credential.
Tier 2's proudest property — words live in the author's pod under the author's WAC — simply has no home for a did:nostr agent: the key can authenticate (getAgent says so), can own a namespace, but owns no storage on this host. The forge hosts those bodies itself (pluginDir/hosted/<hex>/), marks them hosted: true, renders the tag, and gives the author the same delete beat — but the asymmetry is now visible in every thread: pod users' words are their property under their ACLs; key users' words are the forge's tenant data. The seam this begs for is api.podOf(agent) / pods-for-keys: if core could answer "where does this agent keep things?" (or provision key-addressed storage), hosted content would collapse back into the tier-2 path and the hosted flag would disappear. Until then the forge is honest about being a landlord for the podless.
The plan said "extend connect-src for whatever xlogin's NOSTR flows need locally" — measuring showed the flows need no connect-src at all (signing is client-side; the fetches are same-origin) but DO need script-src https://esm.sh, because xlogin 0.0.15 hard-codes dynamic import()s of its crypto from esm.sh and CSP governs module loads with script-src. That's the whole CSP delta: one external script origin, taken knowingly; connect-src stays 'self' so external Solid-IdP login is blocked-and-documented rather than silently allowed (config.cspConnect is the operator's opt-in). The plugin-shaped fix is upstream: a self-contained xlogin build would let script-src drop to 'self' — worth filing against xlogin rather than working around here.
What the host verifier (src/auth/nostr.js) actually demands, found by signing real events in the tests:
Everything GitHub does with a merge queue and a worktree farm, a bare repo can do with three plumbing commands: merge-tree --write-tree (compute the merged tree OR the conflicted paths, no worktree), commit-tree -p A -p B (make the two-parent commit), update-ref <ref> <new> <old> (land it atomically with a compare-and-swap). But merge-tree --write-tree only exists since git 2.38 (2022); on older gits there is NO bare-safe conflict-aware merge without a scratch clone. The forge probes git --version at activate and the merge route answers 501 naming the installed version rather than pretending — the dev machine's git 2.53.0 is fine, and the conflict output's --name-only section parses cleanly into the banner's path list. One sharp edge found while parsing: merge-tree signals conflicts via exit code 1, so the child-process wrapper must treat exit 1 + stdout as data, not as failure.
The CVE-2022-39253 hardening (git ≥ 2.38.1) demoted protocol.file.allow to user, which kills file:///local-path transports when triggered indirectly (submodule clones). The forge's cross-repo fetches (git -C <base> fetch <abs-path-to-head> +refs/heads/x:refs/forge/...) are direct top-level invocations, which the user default still permits — measured on git 2.53.0: no -c protocol.file.allow=always override was required. Written down because it is one hardening release away from mattering: if a future git demotes direct local fetches too, the per-invocation override is the correct, bounded fix — both ends are forge-owned paths under pluginDir, no user-supplied URLs ever reach the fetch argv (owner/name/ref are strictly validated first).
The reusable compare refs (refs/forge/heads/<owner>/<ref>) live outside refs/heads and refs/tags, so no UI surface lists them — but git ls-remote shows them and a fetching client may copy them, because upload-pack advertises everything. That is accepted, not accidental: the objects they pin are exactly the fork branches a compare or PR already published, so nothing leaks; the cost is advertisement noise proportional to compared branches. The alternative (fetch into a temp ref + delete) re-downloads objects on every compare AND still leaves the objects in the odb until gc. If it ever grates, uploadpack.hideRefs=refs/forge per repo is the one-line cure.
Closed in the polish wave: the one-line cure is applied — uploadpack.hideRefs=refs/forge/ is set in every repo's git config at creation (materialize AND fork), and lazily on the first compare-fetch for repos that predate it (a cheap config-file read skips the git spawn once the line exists). Test-proven with a real git ls-remote: the internal refs exist in the repo, and do not ride the wire.
The search boxes are the no-JS ideal — <form method="get">, a text input, the server filters — and they silently did nothing under the tier-1 CSP, because form-action 'none' governs ALL form submissions, navigation-only GET forms included (unlike connect-src, which only sees script-initiated fetches). The delta is form-action 'self': form targets stay same-origin, which is exactly what the search forms need and all they can reach. Worth writing down because the failure mode is invisible — the page renders, the button clicks, nothing happens, and only the browser console says why. Same lesson as Finding 8's connect-src discovery: each new kind of page interactivity trips a different CSP directive.
Core's ISSUES.md argued a git-mark hook must live in core because only core owns the receive path. The forge is the counter-witness: because this plugin delegates the wire protocol to git-http-backend but OWNS the CGI child, "receive finished, refs are final" is simply the child's close event — a one-line onExit seam on the existing runBackend. Both tip-movers route through one recordTip helper: receive-pack via onExit, PR merges via a direct call after update-ref (they move the tip without any receive-pack). recordTip re-checks everything under the per-repo mark lock (compares the tip to the last mark's state), so firing it on any child exit — even a partially failed push — is correct rather than optimistic. What made it plugin-able wasn't a new api seam; it was owning the receive path in the first place. The test wrinkle: the hook fires after the response, so push-advance assertions poll (the merge route, by contrast, awaits recordTip before answering — the mark is in the response's happens-before).
Enabling anchoring mints the trail privkey server-side and keeps it in pluginDir/marks/<owner>/<repo>.json (0600, never crossing an API surface — test-proven against every marks route). That is CUSTODY: the operator can read the key, and whoever holds the key controls all future transitions and the funds in the head output (the spec's own threat model). On testnet4 — the enforced default — the stakes are demonstration sats, and the convenience (the forge can derive every future address without a signing round-trip) is worth it. On mainnet it would not be, which is exactly why chain: 'btc' is refused at activate unless allowMainnet is explicit. The honest next step is client-side keys: the owner's OWN key signs each advance in the browser (xlogin already ships NIP-07/NIP-98 plumbing on these very pages), the forge stores only pubkeyBase and derives addresses from public material — the derivation needs no secrets, only the spend does. Then the forge is purely a recorder and the custody Finding dissolves.
The obvious design — server calls mempool.space to check funding, maybe broadcasts too — makes the pod server an outbound HTTP client steered by user-shaped data (SSRF surface, availability coupling, and a trusted third party smuggled into the trust chain). The split here keeps the server pure: it DERIVES addresses (local math) and RECORDS owner-shaped claims (shape-validated JSON); funding and broadcasting happen in the owner's own CLIs; verification happens in an independent, zero-build client-side verifier that anyone can point at the served blocktrails.json. The server never learns whether a claim is true — and doesn't need to, because the verifier checks the actual chain and the claims are cheap-talk until it does. One deliberate CORS grant (Access-Control-Allow-Origin: * on blocktrails.json alone) is the entire integration surface between the two halves. Bitcoin appears in the plugin as: bech32m strings, https://mempool.space/... hrefs, and instructions. grep -c fetch on the anchoring code: zero.
Two real discoveries from implementing the derivation instead of importing it:
Tier 2's storage beat is a server-side loopback PUT into the author's pod that FORWARDS the caller's Authorization header. That works for a plain Bearer (a bearer is replayable to any URL), but it is STRUCTURALLY impossible for the two proofs that actually matter for a pod-native forge: Solid-OIDC DPoP and NIP-98 are bound to ONE request URI (DPoP's htu, NIP-98's u). The browser signs a proof whose URI is the forge API URL; forward that same proof to a different URL (the pod-write) and it has no valid binding → the pod answers 401 → "your pod refused the write". The server cannot re-sign, because the signing key lives in the browser (WebAuthn/NIP-07), not on the host. This is the same request-bound wall Finding 9 hit for git pushes — here it blocks the pod WRITE instead of the git wire.
The WAC-correct fix moves the write to the only party that can sign for it. The client (window.xlogin.authFetch, which mints a FRESH DPoP or NIP-98 proof per request) PUTs the JSON-LD body directly into the author's OWN pod — htu/u now name the pod URL, so WAC governs a real write the author owns — and then POSTs the forge only a POINTER (resourceUrl). The forge stores it in the index identically to a server-written pointer ({author, resourceUrl, at}), so the display path (public loopback GET → doc.body → rendered markdown) reads a pointer-based issue byte-for-byte the same as a body-based one. Proven in the tests: a pointer issue renders the same <strong> as a body-written one.
Two design points that make it safe:
Backward compatibility is total: resourceUrl is optional, and only a pod agent (podPathFromAgent truthy) whose request carries it takes the pointer branch. A plain Bearer with no resourceUrl still gets the server-side write (tier 2 unchanged); a did:nostr agent still gets forge-hosted storage (tier 2.5 unchanged, resourceUrl ignored — a key has no pod to point at, which is Finding 10 restated). The client only takes the pod-write path for xlogin.type === 'solid'; nostr sessions and plain-Bearer logins post {title, body} as before. No CSP change: the authFetch PUT is same-origin (connect-src 'self' already admits it). The one seam this still wants is the same api.podOf ask from Finding 10 — a did:nostr key with provisioned storage could take this exact client-write path and the hosted asymmetry would finally vanish.
Three tiers stack into a coherent role: the forge hosts the bytes (tier 1 git), anchors their history to Bitcoin (tier 3.5 marks — the source of truth), and now announces them over Nostr (NIP-34 30617 discovery + 30618 repo-state — the latter carries the refs so a subscriber syncs to a precise commit, anchored to the same Bitcoin txid). The load-bearing idea is that these are separable and a repo is not owned by any one forge: the same repo lives on many mirrors, each a node that hosts a clone URL and points at the one shared anchor. The clone/web tags carry this node's mirror URLs; the anchor tag carries the mirror-independent Bitcoin root; a viewer that sees two forges announce the same d/anchor learns they are mirrors of one repo. So the forge is deliberately a replaceable node in a mirror-set, not the repo's home — losing one node loses a mirror, not the project.
Who signs the announcement is the one real custody choice, and it is the same shape as the anchoring one (Finding 18). The forge signs with a forge-instance key (persisted 0600, POC custody), not the maintainer's nostr key. For a mirror announcement that is the honest claim and needs no maintainer-key custody: the event asserts "this node hosts these URLs, anchored here," which is exactly what the node is entitled to say. The maintainers tag still names the maintainer's pubkey (for nostr-owned repos the owner is that hex pubkey), leaving room for a canonical maintainer-signed 30617 to supersede the mirror's in any viewer that prefers it. The principled upgrade is, again, client-side signing (NIP-07/xlogin) with the forge as pure relay — at which point the forge signs nothing and this custody note dissolves, just as Finding 18's does for the trail key. Until then a forge-signed mirror announcement is a sound, clearly-scoped discovery layer, and keeping emission opt-in (config.announceRelays, empty ⇒ off) means a node broadcasts to the wider network only when its operator says so.
| Back | FazBrowse Home | New Git URL |