Skip to content

Commit 230d42a

Browse files
forge: first-class did:nostr agents + vendored xlogin (tier 2.5)
A Nostr key is now a full forge citizen: its namespace is the 64-hex pubkey (did:nostr:<hex> canonical per did-nostr.com; npub is display-only, rendered via a 30-line pure-node NIP-19 encoder), one NIP-98-signed request mints a short-lived macaroon-lite push token (git's static http.extraHeader can't re-sign per request — the finding that shaped the design), and podless agents' issue words are hosted by the forge under a visible badge — the api.podOf asymmetry, sharpened. xlogin 0.0.15 vendored verbatim onto the issues UI (Nostr + Solid tabs, authFetch writes), local login kept as fallback. Findings 9-12 include an order-of-operations bug class: NIP-98 payload verification reads request.rawBody, but a raw-streaming parser scope leaves the body unread at auth time — buffer first, authenticate second; and the u-tag's host exactness ties into NOTES' Host- sensitivity finding. 56/56 tests, real schnorr-signed NIP-98 server-side.
1 parent 4aa589b commit 230d42a

4 files changed

Lines changed: 1676 additions & 78 deletions

File tree

forge/README.md

Lines changed: 230 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
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

33
The useful slice of Gogs/Gitea as a JSS plugin: push a repo over smart
44
HTTP, get a GitHub-style (light theme) web UI for it — repo list, file
55
table, rendered README, tree/blob/raw views, commit log with pagination,
66
green/red unified diffs, branches and tags — plus a clean JSON API over
77
the same model. Tier 2 adds **issues and comments whose bodies live in
88
the 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
1013
framework: every page is server-rendered HTML with inline CSS, all git
1114
work is done by the system `git` binary, and the wire protocol is
1215
delegated 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

6278
Refs may contain `/` (`feature/x`): the tree/blob/raw/commits routes
6379
resolve 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
160182
With JS off the pages stay fully readable and a `<noscript>` note says
161183
interactive 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

165297
Escape **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:
321456
once a page carries a JSON-driven client, CSP needs `connect-src 'self'`
322457
— tier 1's `default-src 'none'` silently blocks every `fetch()`, which
323458
is 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

Comments
 (0)