Skip to content

Commit fc6e542

Browse files
forge: HTTP write tiers — web-edit (committed) + ephemeral preview (unstaged)
Surface git's own three states as three tiers of permanence on the forge's HTTP write path: - POST api/repos/<o>/<n>/edit {path,content,message?,branch?} — the COMMITTED tier: commit one file in a disposable local clone, push to the bare, ride the push→anchor→NIP-34 beats. Owner-signed by default; config.openEdit relaxes to anonymous for throwaway demo repos only. CORS-open + preflight. - POST api/repos/<o>/<n>/preview {path,content,branch?} — the UNSTAGED tier: publish a NIP-01 ephemeral event (kind 21617, the 20000..29999 range relays don't store) carrying the file content. No commit, no push, no disk, no Bitcoin. Only currently-connected subscribers receive it — a live, throwaway draft that leaves no trace. Same auth surface as /edit. The marked tier (a Bitcoin-anchored checkpoint) already exists via the marks API; a subscriber picks which tier it reacts to, so preview-vs-production and requireAnchor = staging-vs-settled fall out of the layering. Tests: +1 (126 total) — /preview emits a validly-signed 21617 carrying the content, HEAD is UNCHANGED (no commit), anon is 401 when openEdit is unset.
1 parent 6db3f32 commit fc6e542

4 files changed

Lines changed: 552 additions & 3 deletions

File tree

forge/AGENT.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,23 @@ CD) is any Nostr subscriber — e.g. a git-sync daemon.
3535
matching **JSON API** under `<prefix>/api/...`. Zero deps, zero build.
3636
- **Forks & PRs**`git clone --local` forks, cross-fork compare, and real
3737
merges (`git merge-tree``commit-tree` → compare-and-swap `update-ref`).
38+
- **Web edit** — `POST <prefix>/api/repos/<o>/<n>/edit {path, content,
39+
message?, branch?}` commits ONE file over HTTP (GitHub's web editor) in a
40+
disposable local clone pushed back to the bare, then rides the same
41+
push→anchor→NIP-34 beats. Owner-signed by default; `config.openEdit` relaxes
42+
to anonymous edits for **throwaway demo repos only** (never a default — the
43+
edit surface is otherwise an open-vandalism vector). CORS-open + preflight.
44+
- **Unstaged preview** — `POST <prefix>/api/repos/<o>/<n>/preview {path,
45+
content, branch?}` is the tier *below* a commit: it publishes a NIP-01
46+
**ephemeral** event (kind 21617, the 20000–29999 range relays SHOULD NOT
47+
store) carrying the file content, and touches neither git nor disk nor
48+
Bitcoin. Only currently-connected subscribers receive it — a live, throwaway
49+
draft that vanishes the moment nobody's looking. Same auth as `/edit`. This
50+
surfaces git's own three states as three tiers of permanence: **unstaged**
51+
(ephemeral event) → **committed** (a commit + 30618) → **marked** (a
52+
Bitcoin-anchored checkpoint). A subscriber picks which tier it reacts to, so
53+
preview-vs-production (and `requireAnchor` = staging-vs-settled) falls out of
54+
the layering rather than being wired.
3855
- **Issues & PRs as data you own** — an issue/comment body is a JSON-LD
3956
resource in the **author's own pod** (WAC-governed); the forge stores only a
4057
validated pointer in its index. Delete the resource from your pod and the

forge/README.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ plugins: [{ id: 'forge', module: 'forge/plugin.js', prefix: '/forge',
4242
allowMainnet: false, // … unless this is explicitly true (see Finding 18)
4343
announceRelays: [], // NIP-34 relays (opt-in); empty => emission OFF
4444
announceKey: undefined, // optional 32-byte hex nostr privkey; else generated once
45+
openEdit: false, // DEMO ONLY: anonymous web edits (see caveat below) — never a default
4546
} }]
4647
```
4748

@@ -168,6 +169,17 @@ Tier 3a (additive):
168169
(stale `expectedBase`, or a race caught by update-ref's old-value
169170
guard); 501 naming the git version when `merge-tree --write-tree` is
170171
missing (needs git ≥ 2.38).
172+
- `POST .../edit` `{path, content, message?, branch?}` (owner-signed;
173+
`config.openEdit` relaxes to anonymous DEMO) → 200 `{ ok, commit,
174+
changed, url? }`; 400 (bad path / non-string content), 413 (over the
175+
1 MiB cap), 401/403 (auth), 404 (no repo), 500 (git failure). CORS-open
176+
with an `OPTIONS` preflight — see **Web edit** below.
177+
- `POST .../preview` `{path, content, branch?}` (same auth as `/edit`) — the
178+
**unstaged** tier: publishes a NIP-01 ephemeral event (kind 21617) with the
179+
file content, making NO commit and writing nothing. → 200 `{ ok, published,
180+
kind, id, path, relays }`, or `{ ok, published:false }` with no relays
181+
configured. Only currently-connected subscribers see it; relays don't store
182+
it. Same 400/401/403/413 surface as `/edit`. CORS-open + preflight.
171183

172184
Polish wave (additive):
173185

@@ -414,6 +426,71 @@ is validated (`okRef`/sha) and resolved with `rev-parse` *before* any
414426
header goes out, so a bogus ref is a clean 404. Any resolvable ref works
415427
— tags, branches, shas — which is exactly GitHub's archive behavior.
416428

429+
## Web edit (tier 3.7): commit one file over HTTP
430+
431+
`POST <prefix>/api/repos/<owner>/<name>/edit` is the **GitHub-web-editor
432+
equivalent**: a browser edits one file and the change lands as a real
433+
commit, then rides the forge's existing NIP-34 emission out to relays. No
434+
server-side index surgery — the commit is made in a **disposable local
435+
clone** and pushed back to the bare repo.
436+
437+
Body (`application/json`): `{ path, content, message?, branch? }`.
438+
439+
- **`path`** — repo-relative, validated hard: a non-empty string ≤ 1024
440+
chars, no leading `/`, no `..`, no backslash, no control chars; every
441+
`/`-separated segment matches `[A-Za-z0-9._-]+`; a `.git` segment is
442+
refused anywhere and a **leading-dot file at the repo root** (`.acl`,
443+
`.htaccess`, …) is refused to avoid surprises. Anything else → **400**.
444+
- **`content`** — a string, capped at **1 MiB**; over the cap → **413**
445+
(a wildly oversized wire body is a **400** — the reader caps it before
446+
parse).
447+
- **`message`** — optional; control chars stripped, capped at 1000 chars;
448+
defaults to `web edit: <path>`.
449+
- **`branch`** — optional (`REF_RE`, no `..`); defaults to the repo's
450+
default branch (`symbolic-ref`). A branch that does not exist yet is
451+
created off the current HEAD.
452+
453+
**Auth — owner-signed by default (the principled mode).** Resolve the
454+
agent (any scheme the git lane accepts, NIP-98 included), map it to a
455+
namespace, and require it equals `<owner>`: anonymous → **401**, wrong
456+
owner → **403** — the same gate as marks/enable and announce. The
457+
principled browser upgrade is a **NIP-07 / browser-signature** path (sign
458+
a NIP-98 event in the page, or exchange one for a push token at
459+
`POST <prefix>/api/token`) so the edit carries the editor's own identity.
460+
461+
**`config.openEdit: true` relaxes this to anonymous edits (DEMO ONLY).**
462+
It is **never a default**; the plugin logs a one-time loud warning at
463+
activate when it is on. ⚠️ **Spam/abuse caveat:** with `openEdit` on,
464+
*anyone on the internet can rewrite any file in any repo on the
465+
instance* — reserve it for **throwaway testnet-demo repos** you are happy
466+
to see vandalized, never a real forge. There is no rate limit and no
467+
per-file authorization; the honest posture is owner-signed.
468+
469+
Behavior & responses (all CORS-readable — the demo page is cross-origin,
470+
so success **and** error replies carry `Access-Control-Allow-Origin: *`
471+
and an `OPTIONS` preflight is answered):
472+
473+
- Unknown repo → **404**.
474+
- Identical content → **no commit** is made: `200 { ok:true,
475+
commit:<currentHead>, changed:false }` (`git diff --cached --quiet`
476+
decides — no empty commits).
477+
- A real change → clone → `checkout <branch>` → write (parents made) →
478+
`git add` → `git -c user.name='forge web edit' -c
479+
user.email=[email protected] commit``git push <bare>
480+
HEAD:refs/heads/<branch>``200 { ok:true, commit:<full sha>,
481+
changed:true, url:<repo web url> }`. The author of record is the
482+
authenticated agent (or `anonymous` in demo mode); the committer is the
483+
forge. The temp clone is always removed (`finally`).
484+
- Git failure → **500** with a generic message (the tmp path and raw git
485+
stderr are never leaked).
486+
487+
Because the push moves the tip, a web edit triggers the **same
488+
downstream beats as a `git push`**: any Blocktrails anchor advances
489+
(`recordTip`) and the NIP-34 **30617 + 30618** go out
490+
(`announceRepoSafe`, fire-and-forget, a no-op with no relays). Every git
491+
spawn runs hermetic — `GIT_CONFIG_NOSYSTEM=1`, no `HOME`, `execFile`
492+
(never a shell).
493+
417494
## Anchors (tier 3.5): git-mark anchoring via Blocktrails
418495

419496
**The design stance, absolute: the server derives and records; it NEVER

0 commit comments

Comments
 (0)