Skip to content

Commit ad2f49b

Browse files
forge tier 2: issues and comments as pod resources
Your words live in YOUR pod: an issue or comment is a JSON-LD resource loopback-PUT into the author's own pod under the author's own Bearer — real WAC governs every write. The forge index (pluginDir, atomic, number-allocation lock) holds only the spine: numbers, state, thread pointers — never body text. Bodies resolve at read time (bounded 8-way fan-out); delete the resource from your pod and the thread shows 'content removed by its author' — data ownership you can watch happen, test-proven with a real cross-user delete. GitHub-style Issues tab/list/thread in the same light palette; writes via a dependency-free inline JS client (login → /idp/credentials, token in localStorage, fetch+Bearer, textContent-only DOM). Findings: coordination-vs-speech split (plugin state vs pod data); the moderation gap IS api.authorize's issuer case — an owner cannot delete words in a commenter's pod, and dropping the pointer would be hiding, not deleting; read-time fan-out is the api.events invalidate case; and tier-1's default-src 'none' CSP silently blocked the first client fetch() — connect-src 'self' now asserted by test. 42/42.
1 parent 00f7b7b commit ad2f49b

3 files changed

Lines changed: 1068 additions & 28 deletions

File tree

forge/README.md

Lines changed: 135 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
# forge — a personal git forge (tier 1: hosting + browsing)
1+
# forge — a personal git forge (tier 1: hosting + browsing; tier 2: issues)
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
7-
the same model. Zero npm dependencies, zero build step, no framework:
8-
every page is server-rendered HTML with inline CSS, all git work is done
9-
by the system `git` binary, and the wire protocol is delegated to the
10-
stock `git-http-backend` CGI (gitscratch's proven plumbing, re-rooted).
7+
the same model. Tier 2 adds **issues and comments whose bodies live in
8+
the author's pod** (the forge keeps only a pointer index — see the
9+
architecture below). Zero npm dependencies, zero build step, no
10+
framework: every page is server-rendered HTML with inline CSS, all git
11+
work is done by the system `git` binary, and the wire protocol is
12+
delegated to the stock `git-http-backend` CGI (gitscratch's proven
13+
plumbing, re-rooted).
1114

1215
```js
1316
plugins: [{ id: 'forge', module: 'forge/plugin.js', prefix: '/forge',
@@ -51,6 +54,9 @@ git -c http.extraHeader="Authorization: Bearer <token>" push forge main
5154
| `.../commits/<ref>?page=N` | log, 30/page: message, short sha, author, relative time, identicon |
5255
| `.../commit/<sha>` | full commit with GitHub-style unified diff (collapsible per-file sections, +N/−M counts) |
5356
| `.../branches`, `.../tags` | ref lists |
57+
| `.../issues?state=open\|closed&page=N` | issue list: GitHub-style filter tabs, green open / purple closed icons, relative times, comment counts |
58+
| `.../issues/<n>` | thread: issue body then comments in comment boxes (identicon, author → WebID link, relative time, `owner` badge), markdown bodies |
59+
| `.../issues/new` | new-issue form (vanilla-JS client, see below) |
5460
| `<prefix>/<owner>/<name>.git/...` | git smart HTTP (`info/refs`, `git-upload-pack`, `git-receive-pack`) |
5561

5662
Refs may contain `/` (`feature/x`): the tree/blob/raw/commits routes
@@ -86,6 +92,74 @@ Errors are `{ error }` with 4xx. `cloneUrl` is absolute: the origin comes
8692
from `api.serverInfo` (#601) at request time, with `config.baseUrl` as
8793
the reverse-proxy override.
8894

95+
## Issues (tier 2): pods hold the words, pluginDir holds the spine
96+
97+
```
98+
the AUTHOR's pod (WAC-governed, author-owned) pluginDir/issues/<owner>/<repo>.json
99+
/casey/public/forge/o--r/issue-<uuid>.jsonld { next: 4, issues: { 1: {
100+
/dana/public/forge/o--r/comment-<uuid>.jsonld number, title, state: open|closed,
101+
{ type: ForgeIssue|ForgeComment, repo, author, createdAt,
102+
issue, title?, body, published, author } thread: [{ author, resourceUrl, at }, …] } } }
103+
```
104+
105+
- **Content in the author's pod.** When casey opens an issue on
106+
`melvin/plugins`, the forge loopback-PUTs the JSON-LD document to
107+
`<casey's pod>/public/forge/<owner>--<repo>/issue-<uuid>.jsonld`
108+
**with casey's own forwarded Bearer**, so real WAC governs the write
109+
and the resource is casey's property, not the forge's. Comments work
110+
identically into the *commenter's* pod (`comment-<uuid>.jsonld`).
111+
- **Index + state in pluginDir** (atomic tmp+rename writes, one writer
112+
per repo): next issue number, denormalized title, open/closed state,
113+
author WebID, createdAt, and the thread as an ordered list of
114+
`{author, resourceUrl, at}` pointers. The index **never** copies body
115+
text — bodies are re-fetched from the pods at read time over loopback
116+
(public read; 8-way bounded parallelism, 8 s per-fetch timeout,
117+
500-entry thread cap, 64 KiB body cap).
118+
- **The honest consequence (feature, not bug):** if an author deletes
119+
the resource from their own pod, the thread renders that slot as a
120+
muted *"content removed by its author"* placeholder — the pointer
121+
(who/when) remains, the words are gone everywhere. Test-proven.
122+
- **State transitions** (close/reopen, title edit) are index operations,
123+
allowed for the repo owner OR the issue author, via Bearer-authed
124+
routes (`getAgent`). Anonymous writes are 401; a third party closing
125+
someone else's issue is 403.
126+
127+
### Issue JSON API
128+
129+
- `GET api/repos/<o>/<n>/issues?state=open|closed&page=N`
130+
`{ state, page, perPage: 25, hasMore, openCount, closedCount,
131+
issues: [{ number, title, state, author, createdAt, comments }] }`
132+
- `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.
138+
- `POST api/repos/<o>/<n>/issues` `{title, body}` → 201
139+
`{ number, url, resourceUrl }` (Bearer required)
140+
- `POST .../issues/<num>/comments` `{body}` → 201
141+
`{ number, comments, resourceUrl }`
142+
- `POST .../issues/<num>/close` / `.../reopen``{ number, state }`
143+
(repo owner or issue author)
144+
- `PATCH .../issues/<num>` `{title}``{ number, title }` (owner or author)
145+
- `PATCH api/repos/<o>/<n>` `{description}` → writes the bare repo's
146+
`description` file (repo owner only); shown on the repo home and list.
147+
148+
### The vanilla-JS client (deliberately tiny)
149+
150+
Every issues page embeds one dependency-free inline
151+
`<script type="module">` (no build, no framework) that:
152+
153+
- offers a login box — username/password → `POST /idp/credentials`,
154+
token kept in localStorage, "Signed in as X" + sign-out;
155+
- drives new-issue/comment/close/reopen through `fetch` + Bearer against
156+
the JSON API, then **reloads — the server always renders the truth**;
157+
- touches the DOM only via `textContent`/`createElement`; fetched
158+
strings never meet `innerHTML`.
159+
160+
With JS off the pages stay fully readable and a `<noscript>` note says
161+
interactive actions need JavaScript and sign-in.
162+
89163
## The markdown subset (hand-rolled, bounded)
90164

91165
Escape **first**, then transform — the renderer is structurally
@@ -117,16 +191,19 @@ detection is a NUL sniff over the first 8 KB.
117191
`./`/`\` forms. All git reads use `execFile` argv arrays (no shell),
118192
NUL-separated `--format`s (never parsed human output), with
119193
`GIT_CONFIG_NOSYSTEM=1` and no `HOME`.
120-
- The only client-side JavaScript is the clone-box copy button, which
121-
degrades to a selectable input.
194+
- Client-side JavaScript is the clone-box copy button (degrades to a
195+
selectable input) and the issues client above (degrades to read-only
196+
pages). The page CSP grants `connect-src 'self'` — exactly enough for
197+
the client's same-origin fetches, nothing outbound.
122198

123199
## Deliberate cuts
124200

125201
- **No syntax highlighting** — that's where the old attempt's 1 MB
126202
bundle came from. A `<pre>` with line numbers covers tier-1 browsing;
127203
highlighting is a candidate for a later wave *if* it can be done
128204
server-side and dependency-free.
129-
- **No search, no issues/PRs/webhooks** — later tiers (see Findings 3).
205+
- **No search, no PRs/webhooks** — later tiers (see Findings 3). Issues
206+
arrived in tier 2; PRs want the same pod-native treatment.
130207
- **No browser-git** — every byte of git logic is the system binary;
131208
server-side rendering made the old client bundle unnecessary.
132209

@@ -166,11 +243,12 @@ bodies through Fastify — `api.mountApp` (#583) was not needed here.
166243
`api.events` (#603). The post-receive hook is where the fact is known;
167244
today it can only fix HEAD, not notify anyone.
168245
- **Repo metadata as pod resources** — description, topics, default
169-
branch override should live in the owner's pod (tier-2 plan) so they
170-
are WAC-governed and portable; today description is derived (git's
171-
`description` file or README first line) because there is no clean
172-
write path from a plugin into a pod except loopback-with-the-user's-
173-
token, and browsing is anonymous.
246+
branch override should live in the owner's pod so they are
247+
WAC-governed and portable. Tier 2 added `PATCH api/repos/<o>/<n>`
248+
(owner-authed) writing git's `description` file — a forge-owned file,
249+
deliberately, because *repo* metadata is coordination state like the
250+
issue index (Finding 6), not the owner's speech. The README-first-line
251+
fallback remains for repos that never set one.
174252
- **Identity mapping is convention, not contract** — owner = first
175253
WebID path segment (podFromWebid) works for this host's pods but is a
176254
heuristic; did:nostr agents have no pod namespace at all and therefore
@@ -199,3 +277,47 @@ richer client, not a rewrite hook.
199277
- No `api.events` also means no push-time cache invalidation, so
200278
everything is computed read-time — consistent with the sparql//rss/
201279
finding that read-time walks are the only option today.
280+
281+
### 6. Content ownership with a plugin-owned index (the tier-2 pattern)
282+
283+
An issue body or comment is a JSON-LD resource **the author owns**: the
284+
forge loopback-PUTs it into the author's own pod with the author's own
285+
forwarded Bearer, so the pod's real WAC decides the write and the
286+
resource remains the author's property. The forge keeps only the
287+
*spine* — number allocation, title, open/closed state, and an ordered
288+
list of `{author, resourceUrl, at}` pointers — and re-fetches the words
289+
at read time. The split is exact: everything that is *coordination*
290+
(numbering, state, ordering) is plugin state; everything that is
291+
*speech* is pod data. The killer beat, test-proven: when the commenter
292+
DELETEs her resource from her own pod (a plain LDP DELETE the forge
293+
never sees), the thread renders "content removed by its author" — real
294+
deletion with no forge cooperation required, GDPR-shaped for free, and
295+
the pointer preserves the thread's who/when integrity.
296+
297+
### 7. The moderation gap is `api.authorize`'s issuer-authority case, named
298+
299+
The flip side of Finding 6: the repo owner **cannot edit or delete the
300+
words** in a commenter's pod — melvin can close the issue or retitle it
301+
(index operations), but a spam comment's body sits in the spammer's pod
302+
under the spammer's WAC, where melvin has no write bit. All the forge
303+
could do today is drop the pointer (hiding, not deleting — the resource
304+
survives at a public URL). Real moderation needs a third authority
305+
level: "this *forge* rules this *thread*", i.e. the plugin asserting
306+
authority over content it indexes but does not store — exactly the
307+
`api.authorize` (#604) issuer-authority case. Until that seam exists,
308+
moderation here is honest about being curation of pointers.
309+
310+
### 8. Read-time fan-out is the price of not copying (the api.events/cache case)
311+
312+
A thread with N entries costs N loopback GETs on every render, because
313+
the index refuses to cache body text (a cache would resurrect deleted
314+
content — the one thing the architecture promises not to do). Bounded
315+
here: 8-way parallelism, 8 s per-fetch timeout, 500-entry thread cap,
316+
64 KiB body cap; a full 500-entry thread is ~63 sequential rounds of 8.
317+
That is fine at plugin scale and wrong at any bigger scale — the correct
318+
fix is an invalidation signal, not a TTL: `api.events` (#603) firing on
319+
pod resource change would let the forge cache bodies *and* evict on
320+
delete, keeping the removal semantics exact. One more real discovery:
321+
once a page carries a JSON-driven client, CSP needs `connect-src 'self'`
322+
— tier 1's `default-src 'none'` silently blocks every `fetch()`, which
323+
is invisible until the first interactive page exists.

0 commit comments

Comments
 (0)