1- # forge — a personal git forge (tier 1: hosting + browsing; tier 2: issues)
1+ # forge — a personal git forge (tier 1: hosting + browsing; tier 2: issues; tier 2.5: nostr agents + xlogin )
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
55table, rendered README, tree/blob/raw views, commit log with pagination,
66green/red unified diffs, branches and tags — plus a clean JSON API over
77the same model. Tier 2 adds ** issues and comments whose bodies live in
88the author's pod** (the forge keeps only a pointer index — see the
9- architecture below). Zero npm dependencies, zero build step, no
9+ architecture below). Tier 2.5 makes ** did: nostr agents first-class**
10+ (hex-pubkey namespaces, a NIP-98 → push-token exchange, forge-hosted
11+ issue bodies for podless agents) and puts the vendored ** xlogin** widget
12+ on the issues pages. Zero npm dependencies, zero build step, no
1013framework: every page is server-rendered HTML with inline CSS, all git
1114work is done by the system ` git ` binary, and the wire protocol is
1215delegated to the stock ` git-http-backend ` CGI (gitscratch's proven
@@ -17,6 +20,8 @@ plugins: [{ id: 'forge', module: 'forge/plugin.js', prefix: '/forge',
1720 config: {
1821 privateRepos: false , // true: ALL reads become owner-only
1922 gitHttpBackend: ' /usr/lib/git-core/git-http-backend' , // optional
23+ pushTokenTtl: 3600 , // default lifetime of exchanged push tokens (s)
24+ cspConnect: [], // extra connect-src origins (e.g. external Solid IdPs)
2025 } }]
2126```
2227
@@ -31,15 +36,23 @@ git -c http.extraHeader="Authorization: Bearer <token>" push forge main
3136```
3237
3338- ** Ownership** : owner = pod username derived from the pusher's WebID
34- (first path segment, mastodon/'s ` podFromWebid ` rule). Pushing into
39+ (first path segment, mastodon/'s ` podFromWebid ` rule), ** or the 64-hex
40+ pubkey for ` did:nostr: ` agents** (see "Nostr agents"). Pushing into
3541 your own namespace materializes the bare repo under
3642 ` pluginDir/repos/<owner>/<name>.git ` — persistent, no TTL. Pushing into
3743 someone else's namespace is 403; anonymous push is 401 +
3844 ` WWW-Authenticate ` . Clone/fetch and the UI are public by default;
3945 ` privateRepos: true ` flips every read (git, HTML, JSON) to owner-only.
40- - ** ` api ` is a reserved owner name** (the JSON surface lives at
41- ` <prefix>/api ` ); a pod user literally named ` api ` cannot have a forge
42- namespace.
46+ - ** ` api ` and ` xlogin.js ` are reserved owner names** (the JSON surface
47+ lives at ` <prefix>/api ` , the vendored widget at ` <prefix>/xlogin.js ` );
48+ pod users literally named that cannot have a forge namespace.
49+ - ** Hex-name collision, decided** : a pod username that is exactly 64
50+ lowercase hex characters is theoretically registrable and would collide
51+ with a nostr namespace. Hex-as-nostr wins for display and semantics
52+ (the UI shows npub-short); the push check is a string comparison, so
53+ such a pod user and the matching keyholder would share the namespace —
54+ noted rather than papered over, because pod names are human-chosen and
55+ a 64-hex username is not an accident.
4356
4457## Routes
4558
@@ -58,6 +71,9 @@ git -c http.extraHeader="Authorization: Bearer <token>" push forge main
5871| ` .../issues/<n> ` | thread: issue body then comments in comment boxes (identicon, author → WebID link, relative time, ` owner ` badge), markdown bodies |
5972| ` .../issues/new ` | new-issue form (vanilla-JS client, see below) |
6073| ` <prefix>/<owner>/<name>.git/... ` | git smart HTTP (` info/refs ` , ` git-upload-pack ` , ` git-receive-pack ` ) |
74+ | ` <prefix>/api/token ` | POST: exchange any ` getAgent ` credential for a push token (see "Nostr agents") |
75+ | ` <prefix>/api/hosted/<hex>/<uuid> ` | GET (public) / DELETE (author-only): a podless agent's hosted issue words |
76+ | ` <prefix>/xlogin.js ` | the vendored xlogin widget, byte-identical, ` application/javascript ` , immutable cache |
6177
6278Refs may contain ` / ` (` feature/x ` ): the tree/blob/raw/commits routes
6379resolve the ref greedily against the real ref list (longest match wins),
@@ -128,15 +144,21 @@ the reverse-proxy override.
128144
129145- ` GET api/repos/<o>/<n>/issues?state=open|closed&page=N ` →
130146 `{ state, page, perPage: 25, hasMore, openCount, closedCount,
131- issues: [ { number, title, state, author, createdAt, comments }] }`
147+ issues: [ { number, title, state, author, authorInfo, createdAt, comments }] }`
132148- ` GET api/repos/<o>/<n>/issues/<num> ` →
133- `{ number, title, state, author, createdAt, thread: [ { author, at,
134- resourceUrl, body|null, removed, html|null }] }` — ` html` is the
135- server-side markdown renderer's already-escaped output; ` body ` is the
136- raw markdown straight from the pod (JSON is the escape); ` removed ` is
137- true when the author has deleted the pod resource.
149+ `{ number, title, state, author, authorInfo, createdAt, thread: [ { author,
150+ authorInfo, at, resourceUrl, hosted, body|null, removed, html|null }] }`
151+ — ` html ` is the server-side markdown renderer's already-escaped output;
152+ ` body ` is the raw markdown straight from the pod (JSON is the escape);
153+ ` removed ` is true when the author has deleted the pod (or hosted)
154+ resource. ** Additive since 2.5** : ` authorInfo ` is
155+ ` { id, displayName, npub?, kind: 'webid'|'nostr' } ` (` id ` is the
156+ canonical agent string — a WebID or ` did:nostr:<hex> ` ; ` npub ` only for
157+ nostr authors, display-only), and ` hosted ` is true when the entry's
158+ words are forge-hosted rather than pod-stored. ` author ` strings are
159+ unchanged, so pre-2.5 consumers keep working.
138160- ` POST api/repos/<o>/<n>/issues ` ` {title, body} ` → 201
139- ` { number, url, resourceUrl } ` (Bearer required)
161+ ` { number, url, resourceUrl, hosted? } ` (auth required)
140162- ` POST .../issues/<num>/comments ` ` {body} ` → 201
141163 ` { number, comments, resourceUrl } `
142164- ` POST .../issues/<num>/close ` / ` .../reopen ` → ` { number, state } `
@@ -160,6 +182,116 @@ Every issues page embeds one dependency-free inline
160182With JS off the pages stay fully readable and a ` <noscript> ` note says
161183interactive actions need JavaScript and sign-in.
162184
185+ ## Nostr agents (tier 2.5)
186+
187+ ### Identity model — hex canonical, npub display-only
188+
189+ Per [ did-nostr.com] ( https://did-nostr.com ) , the canonical nostr identity
190+ is ` did:nostr:<64-char-lowercase-hex-pubkey> ` — ** exactly the string
191+ ` getAgent ` returns** when a NIP-98 signature verifies and no WebID
192+ mapping exists. The forge keys everything on it:
193+
194+ - ** namespace / storage paths / index keys / API ` id ` fields: hex.**
195+ A nostr agent's repos live at ` <prefix>/<hex>/<name>.git ` ; hosted
196+ content under ` pluginDir/hosted/<hex>/ ` .
197+ - ** UI rendering: shortened npub** (` npub1abcd…wxyz ` ), produced by a
198+ ~ 30-line pure-node bech32 (BIP-173, full checksum) encoder, unit-tested
199+ against the canonical NIP-19 vector
200+ (` 3bf0c63f…459d ` → ` npub180cvv07…jh6w6 ` ). Raw hex is never rendered as
201+ a display name.
202+ - ** Author links** point at core's did: nostr DID-document route,
203+ ` /.well-known/did/nostr/<hex> ` (` src/idp/well-known-did-nostr.js ` —
204+ real in the published server; it 404s for keys with no local account
205+ linkage, which is honest: there is no doc to show).
206+
207+ ### NIP-98 → push-token exchange
208+
209+ git's static ` http.extraHeader ` cannot carry NIP-98 for a push: each
210+ kind-27235 event signs one ` u ` (url) + ` method ` pair, and a push is at
211+ least ` GET …/info/refs ` + ` POST …/git-receive-pack ` (Finding 9). So the
212+ flow is one exchange:
213+
214+ ```
215+ POST <prefix>/api/token[?ttl=seconds]
216+ Authorization: <anything getAgent accepts — NIP-98 included>
217+ → 201 { token: "f1.…", tokenType: "Bearer", agent, iat, exp }
218+ ```
219+
220+ The token is macaroon-lite (capability/'s pattern): `f1.<base64url
221+ payload>.<base64url HMAC-SHA256 >` , payload ` { v:1, agent, iat, exp }`,
222+ secret in ` pluginDir/token-secret ` . Scope is ` {agent, ttl} ` with
223+ ` ttl = config.pushTokenTtl ?? 3600 ` (a ` ?ttl= ` override is capped at 30
224+ days; non-positive values mint an already-expired token — used by the
225+ tests). The forge accepts it wherever it authenticates — the git lane
226+ first, then every core scheme via ` getAgent ` . A forge token cannot mint
227+ another forge token (no self-refresh). WebID users don't need it (the
228+ pod bearer works in ` extraHeader ` as before) but may use it.
229+
230+ Copy-paste client flow using the [ ` nip98 ` ] ( https://www.npmjs.com/package/nip98 )
231+ npm lib's ` getToken ` for the exchange request (docs only — the forge
232+ itself has no npm dependencies):
233+
234+ ``` js
235+ import { getToken } from ' nip98' ;
236+
237+ const url = ' http://localhost:3000/forge/api/token' ;
238+ const auth = await getToken (url, ' POST' , (e ) => window .nostr .signEvent (e), true );
239+ const { token } = await (await fetch (url, { method: ' POST' , headers: { authorization: auth } })).json ();
240+ // pubkey-hex namespace: your pubkey IS your owner segment
241+ ```
242+
243+ ``` sh
244+ git -c http.extraHeader=" Authorization: Bearer $TOKEN " \
245+ push http://localhost:3000/forge/< pubkey-hex> /myrepo.git main
246+ ```
247+
248+ ### Hosted content — the podless-agent asymmetry
249+
250+ A did: nostr agent can authenticate and own a namespace, ** but has no pod
251+ of its own to keep its words in** — the tier-2 loopback-PUT beat has
252+ nowhere to land. So nostr-authored issue/comment bodies are stored under
253+ ` pluginDir/hosted/<hex>/<uuid>.json ` , thread pointers carry
254+ ` {hosted: true} ` , the JSON API says so, and the UI renders a muted
255+ * "hosted by the forge"* tag on those entries (pod-stored ones stay
256+ pure). The author keeps the deletion beat: `DELETE
257+ <prefix >/api/hosted/<hex >/<uuid >` (same did: nostr identity, verified by
258+ NIP-98 or a forge token) removes the words and the thread renders the
259+ same * "content removed by its author"* placeholder as a pod delete.
260+ Anyone else gets 403. This asymmetry ** is** the ` api.podOf(agent) ` /
261+ pods-for-keys ask — see Finding 10.
262+
263+ ## xlogin (the vendored login widget)
264+
265+ ` forge/xlogin.js ` is [ xlogin] ( https://github.com/melvincarvalho/xlogin )
266+ 0.0.15, vendored ** verbatim** (AGPL, attribution header only — AGENT.md's
267+ pattern) and served byte-identical at ` <prefix>/xlogin.js ` with an
268+ immutable cache header. Issues pages load it via ` <script src> ` ; if
269+ ` window.xlogin ` initialises, the auth area shows a ** Sign in with
270+ xlogin** button beside the local username/password box (the fallback
271+ tab), and all API writes go through ` window.xlogin.authFetch ` — NIP-98
272+ for nostr sessions, DPoP for Solid sessions, both verified server-side
273+ by the same ` getAgent ` the rest of the forge uses.
274+
275+ What works locally vs. what CSP blocks (deliberately):
276+
277+ - ** Works** : the widget itself (` script-src 'self' ` ), its crypto — with
278+ the caveat below — client-side NIP-98 signing, and every same-origin
279+ ` authFetch ` (` connect-src 'self' ` ). Logging in with a same-origin
280+ Solid IdP (the widget's first provider button is
281+ ` window.location.origin ` ) also works.
282+ - ** Caveat, eyes open** : xlogin 0.0.15 dynamically ` import() ` s
283+ ` @noble/secp256k1 ` , ` nip98 ` and ` solid-oidc ` from ` https://esm.sh ` —
284+ and dynamic ` import() ` is governed by ** script-src** , not connect-src.
285+ The page CSP therefore admits ` script-src https://esm.sh ` , the one
286+ external origin, or the widget would render a button that can do
287+ nothing (Finding 11).
288+ - ** Blocked and NOTED, not opened** : login against an ** external**
289+ Solid IdP needs ` connect-src ` to that IdP (OIDC discovery + token
290+ fetches). The default CSP keeps ` connect-src 'self' ` , so external-IdP
291+ login fails in the browser console rather than the forge opening
292+ connect-src wide. Operators who want it opt in explicitly:
293+ ` config.cspConnect: ['https://solidcommunity.net', …] ` .
294+
163295## The markdown subset (hand-rolled, bounded)
164296
165297Escape ** first** , then transform — the renderer is structurally
@@ -251,8 +383,11 @@ bodies through Fastify — `api.mountApp` (#583) was not needed here.
251383 fallback remains for repos that never set one.
252384- ** Identity mapping is convention, not contract** — owner = first
253385 WebID path segment (podFromWebid) works for this host's pods but is a
254- heuristic; did: nostr agents have no pod namespace at all and therefore
255- cannot push. An ` api.podOf(agent) ` seam would make ownership honest.
386+ heuristic. Tier 2.5 gave did: nostr agents a namespace (their hex
387+ pubkey) so they * can* push now, but the mapping is still forge
388+ convention; an ` api.podOf(agent) ` seam would make ownership honest —
389+ and Finding 10 shows the same seam is what hosted content is standing
390+ in for.
256391
257392### 4. The JSON API doubles as the Gitea-parity surface
258393
@@ -321,3 +456,83 @@ delete, keeping the removal semantics exact. One more real discovery:
321456once a page carries a JSON-driven client, CSP needs ` connect-src 'self' `
322457— tier 1's ` default-src 'none' ` silently blocks every ` fetch() ` , which
323458is invisible until the first interactive page exists.
459+
460+ ### 9. git cannot sign per-request NIP-98 — and core's leniencies show it knows
461+
462+ A NIP-98 event binds ONE ` u ` (url) + ` method ` ; a push is at least an
463+ ` info/refs ` GET and a ` git-receive-pack ` POST with different URLs and
464+ methods, and ` http.extraHeader ` is static for the whole operation — so
465+ "just put a NIP-98 header on git" is structurally impossible. Reading
466+ ` src/auth/nostr.js ` shows core already fights this with git-mode
467+ leniencies: it accepts NIP-98 smuggled inside `Basic base64("nostr:" +
468+ token)` (git credential helpers), allows ` method: "* "`, and allows the
469+ event's ` u ` to be a PREFIX of the request URL — which means a
470+ pre-signed base-URL event could ride a whole push, but only inside the
471+ ±60 s ` created_at ` window, and only by weakening exactly the bindings
472+ NIP-98 exists to make. The forge's answer is the honest one: a single
473+ ` POST api/token ` exchange (NIP-98-authenticated, per-request-correct)
474+ for a bearer whose TTL is a real, chosen number instead of 60 seconds
475+ of accidental slack. The exchange endpoint deliberately refuses to
476+ accept its own tokens as the minting credential.
477+
478+ ### 10. The podless-agent asymmetry IS the api.podOf ask
479+
480+ Tier 2's proudest property — words live in the author's pod under the
481+ author's WAC — simply has no home for a did: nostr agent: the key can
482+ authenticate (getAgent says so), can own a namespace, but owns no
483+ storage on this host. The forge hosts those bodies itself
484+ (` pluginDir/hosted/<hex>/ ` ), marks them ` hosted: true ` , renders the tag,
485+ and gives the author the same delete beat — but the asymmetry is now
486+ visible in every thread: pod users' words are their property under
487+ their ACLs; key users' words are the forge's tenant data. The seam this
488+ begs for is ` api.podOf(agent) ` / pods-for-keys: if core could answer
489+ "where does this agent keep things?" (or provision key-addressed
490+ storage), hosted content would collapse back into the tier-2 path and
491+ the ` hosted ` flag would disappear. Until then the forge is honest about
492+ being a landlord for the podless.
493+
494+ ### 11. Dynamic import() is script-src, not connect-src — the widget's CDN coupling is CSP-visible
495+
496+ The plan said "extend connect-src for whatever xlogin's NOSTR flows
497+ need locally" — measuring showed the flows need no connect-src at all
498+ (signing is client-side; the fetches are same-origin) but DO need
499+ ` script-src https://esm.sh ` , because xlogin 0.0.15 hard-codes dynamic
500+ ` import() ` s of its crypto from esm.sh and CSP governs module loads with
501+ script-src. That's the whole CSP delta: one external script origin,
502+ taken knowingly; connect-src stays ` 'self' ` so external Solid-IdP login
503+ is blocked-and-documented rather than silently allowed
504+ (` config.cspConnect ` is the operator's opt-in). The plugin-shaped fix
505+ is upstream: a self-contained xlogin build would let script-src drop to
506+ ` 'self' ` — worth filing against xlogin rather than working around here.
507+
508+ ### 12. NIP-98 verification gotchas, measured against auth.js
509+
510+ What the host verifier (` src/auth/nostr.js ` ) actually demands, found by
511+ signing real events in the tests:
512+
513+ - ** URL exactness is Host exactness.** ` u ` must equal
514+ ` <proto>://<Host header><request.url> ` (x-forwarded-* honored,
515+ trailing-slash normalized, query droppable). Sign for ` localhost ` and
516+ push to ` 127.0.0.1 ` and you 401 — the same Host-sensitivity already
517+ written down in NOTES.md for loopback WebID checks now applies to
518+ every NIP-98 client. The tests only pass because helpers.js uses
519+ ` 127.0.0.1 ` on both sides.
520+ - ** ` created_at ` window is ±60 s** — clock skew between a signing
521+ device and the server is a real 401 source; the push-token exchange
522+ also conveniently narrows NIP-98 to one instant per session.
523+ - ** ` payload ` tags need the wire bytes.** The verifier hashes
524+ ` request.rawBody ` (captured by core's JSON parser) — but this
525+ plugin's scope replaces the content parser with a raw pass-through
526+ stream for the git CGI lane, so at getAgent time the body was an
527+ unread stream and any body-carrying NIP-98 request (e.g. xlogin's
528+ authFetch POSTing an issue) would fail its payload check. The fix:
529+ API writes buffer the body FIRST, stash the exact wire string on
530+ ` request.rawBody ` , and only then authenticate. Order of operations
531+ as a correctness bug — invisible until the first signed-body client.
532+ - ** Unmapped keys cost an outbound fetch.** For a pubkey with no local
533+ WebID linkage, verification falls through profile → local index →
534+ an EXTERNAL did: nostr resolver (` nostr.social ` , 5 s timeout, 60 s
535+ failure cache) before returning ` did:nostr:<hex> ` — a network
536+ round-trip on the auth hot path that the agent cannot opt out of.
537+ One more reason the token exchange is the right shape: it pays that
538+ cost once per TTL, not per request.
0 commit comments