|
1 | | -# forge — a personal git forge (tier 1: hosting + browsing) |
| 1 | +# forge — a personal git forge (tier 1: hosting + browsing; tier 2: issues) |
2 | 2 |
|
3 | 3 | The useful slice of Gogs/Gitea as a JSS plugin: push a repo over smart |
4 | 4 | HTTP, get a GitHub-style (light theme) web UI for it — repo list, file |
5 | 5 | table, rendered README, tree/blob/raw views, commit log with pagination, |
6 | 6 | 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). |
11 | 14 |
|
12 | 15 | ```js |
13 | 16 | plugins: [{ id: 'forge', module: 'forge/plugin.js', prefix: '/forge', |
@@ -51,6 +54,9 @@ git -c http.extraHeader="Authorization: Bearer <token>" push forge main |
51 | 54 | | `.../commits/<ref>?page=N` | log, 30/page: message, short sha, author, relative time, identicon | |
52 | 55 | | `.../commit/<sha>` | full commit with GitHub-style unified diff (collapsible per-file sections, +N/−M counts) | |
53 | 56 | | `.../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) | |
54 | 60 | | `<prefix>/<owner>/<name>.git/...` | git smart HTTP (`info/refs`, `git-upload-pack`, `git-receive-pack`) | |
55 | 61 |
|
56 | 62 | 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 |
86 | 92 | from `api.serverInfo` (#601) at request time, with `config.baseUrl` as |
87 | 93 | the reverse-proxy override. |
88 | 94 |
|
| 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 | + |
89 | 163 | ## The markdown subset (hand-rolled, bounded) |
90 | 164 |
|
91 | 165 | Escape **first**, then transform — the renderer is structurally |
@@ -117,16 +191,19 @@ detection is a NUL sniff over the first 8 KB. |
117 | 191 | `./`/`\` forms. All git reads use `execFile` argv arrays (no shell), |
118 | 192 | NUL-separated `--format`s (never parsed human output), with |
119 | 193 | `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. |
122 | 198 |
|
123 | 199 | ## Deliberate cuts |
124 | 200 |
|
125 | 201 | - **No syntax highlighting** — that's where the old attempt's 1 MB |
126 | 202 | bundle came from. A `<pre>` with line numbers covers tier-1 browsing; |
127 | 203 | highlighting is a candidate for a later wave *if* it can be done |
128 | 204 | 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. |
130 | 207 | - **No browser-git** — every byte of git logic is the system binary; |
131 | 208 | server-side rendering made the old client bundle unnecessary. |
132 | 209 |
|
@@ -166,11 +243,12 @@ bodies through Fastify — `api.mountApp` (#583) was not needed here. |
166 | 243 | `api.events` (#603). The post-receive hook is where the fact is known; |
167 | 244 | today it can only fix HEAD, not notify anyone. |
168 | 245 | - **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. |
174 | 252 | - **Identity mapping is convention, not contract** — owner = first |
175 | 253 | WebID path segment (podFromWebid) works for this host's pods but is a |
176 | 254 | heuristic; did:nostr agents have no pod namespace at all and therefore |
@@ -199,3 +277,47 @@ richer client, not a rewrite hook. |
199 | 277 | - No `api.events` also means no push-time cache invalidation, so |
200 | 278 | everything is computed read-time — consistent with the sparql//rss/ |
201 | 279 | 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