@@ -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
172184Polish wave (additive):
173185
@@ -414,6 +426,71 @@ is validated (`okRef`/sha) and resolved with `rev-parse` *before* any
414426header 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