clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Take comments and approvals over HTTP, as ssh makes them

cat committed 933aac1
The private server answers POST /<owner>/<repo>/reviews/<id>/comment
and .../approve by running the forge's own command, forge shell-cmd
comment|approve, as the user, with the message on stdin: what ssh's
git-shell-commands run. So the comment file, the approve line, the
rules (no self-approval, comments on an older revision, the 32 KiB
cap), the event and the rebuild are the same, byte for byte. With
FORGE_VIA=web, which the server sets, the two refusals that tell you to
check out or push (a review that moved on since the page was built, a
tip it never had) say to reload the page instead; over ssh they read as
before.

A private repo you can't read is the missing repo's 404, as for its
pages. Otherwise a post takes a user: a session, from the forge's own
Origin and carrying the session's CSRF token, or a Bearer token (one
for a single repo works there only). Basic auth doesn't post, since
browsers resend it by themselves. Fields come as a form or JSON (body,
at, line, csrf); answers are JSON in the forge's own words. Each user
gets 20 posts a minute and four run at once. /login/whoami tells a
page's script who is signed in, with the token, and is never cached.

Caddy sends these posts to the private server before it looks in www,
so a public repo's review, which is static files, takes them too.

The scenarios' HTTP client reads -d @file as curl does, for a body
longer than Linux lets one argument be.
commit
933aac1f0984126a9e608a820cf906505b1281b7
parent
f1af1324a3497463367525fb8d84b70ce8fd998a
author
cat <admin@dev.site>
ci
✓ passed in 108s, signed by cat
kind
feat risk score 1.7 of 3, Jev's estimate
review
#11 Comment and approve from the web
ci attestation
forge-attestation 1
type ci
commit 933aac1f0984126a9e608a820cf906505b1281b7
tree d74025a955a47fc2a81aa53dfc81cd0fb64a0fca
script .forge/ci 38df37c0d5a6382f60569079a0dd821094e37e4a
result pass
exit 0
started 2026-10-19T00:52:28Z
duration 108s
runner (not shown here; refs/notes/ci has it)
system Darwin 25.5.0 arm64
env host
log sha256:2295d458b0eab15b94dcbf8506ee22a1999adeee8c0ce83fe158e8a93d3e3b21 343 lines
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|94d9bc6 running .forge/ci: greet.sh: say what it is
|  | sh: /bin/sh
|  | ok   greet
|94d9bc6 pass in 0s, signed
|pushed main
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|bb79f64 running .forge/ci: wave at the world
|  | sh: /bin/sh
|  | FAIL greet : got 'hello, world o/', want 'hello, world'
|bb79f64 fail in 0s, signed
|forge: 1 commit(s) did not pass
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|81e28ba running .forge/ci: update the test for the wave
|  | sh: /bin/sh
|  | ok   greet
|81e28ba pass in 0s, signed
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|warning: You appear to have cloned an empty repository.
|site home: deployed 110e4d7 -> /sites/ana/dotfiles/home/
|created ana/notes
|remote: review #1: /ana/notes/reviews/1/
|pushed ana/todo
|landing #1 A todo list: ana/todo -> main
|179c330 running .forge/ci: web, server and Python versions of greet
|  | sh: /bin/sh
|  | ok   greet
|179c330 pass in 0s, signed
|pushed main
|2bd3b31 running .forge/ci: messages: notes for the later regions
|  | sh: /bin/sh
|  | ok   greet
|2bd3b31 pass in 0s, signed
|remote: review #7: /dami/greet/reviews/7/
|pushed dami/regions
|created dami/forge
|warning: You appear to have cloned an empty repository.
|site guide: deployed b59f921 -> /sites/dami/forge/guide/
|(node:74927) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built ana/dotfiles in 0s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:75052) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built ana/notes in 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:75190) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built dami/forge in 0s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:75320) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built dami/greet in 2s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:75987) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time
|(Use `node --trace-warnings ...` to show where the warning was created)
|built - in 0s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|226 of 226 pages render the same without 11ty
|guide: 4 files, links ok
|ok

Signature: good, cat

13 files changed, +492 -12
Mbin/forge26+++++-
Mlib/login.ts6+-
Mlib/private-server.ts133++++++++++++++++++++++++++++++-
Mtest/http.ts9++-
Mtest/lib.sh16++++
Mtest/scenarios/private-web-caddy.sh20++++-
Atest/scenarios/web-approve.sh36++++++++
Atest/scenarios/web-comment-csrf.sh48+++++++++++
Atest/scenarios/web-comment-limit.sh38+++++++++
Atest/scenarios/web-comment-outdated.sh48+++++++++++
Atest/scenarios/web-comment-private.sh42+++++++++
Atest/scenarios/web-comment-same.sh47+++++++++++
Atest/scenarios/web-comment-token.sh35++++++++
M bin/forge +22 -4
@@ -1158,8 +1158,18 @@ EOF
11581158 # their release downloads (sent as attachments by that server, and left11591159 # uncompressed by @compress), and the 404 every other missing page gets11601160 # (lib/private-server.ts). So Caddy needs no list of private repos.1161+ #1162+ # Comments and approvals posted from review pages go to the private1163+ # server too, public repo or not, whatever www has at the path.11611164 hashed='^/assets/([^/]+\.)?[0-9A-Za-z_-]{10}\.[a-z]+$'11621165 cat <<EOF1166+ @discuss {1167+ method POST1168+ path_regexp ^/[^/]+/[^/]+/reviews/[0-9]+/(comment|approve)$1169+ }1170+ handle @discuss {1171+ reverse_proxy 127.0.0.1:${FORGE_PRIVATE_PORT:-8093}1172+ }11631173 @public file {11641174 root $FORGE_WWW11651175 try_files {path} {path}/
@@ -1614,7 +1624,9 @@ review_changed() {
16141624 #16151625 # Stored as <repo>.git/forge/comments/<review>/<n>: header lines, a blank16161626 # line, the message. A line comment records the line's text too, so it still1617-# reads right once the review moves on.1627+# reads right once the review moves on. With FORGE_VIA=web (a review page,1628+# through lib/private-server.ts), comment and approve word a refusal for a1629+# page: reload it, rather than push or check out.16181630 sc_comment() {16191631 [ $# -ge 2 ] || die "usage: comment <owner>/<repo> <id|branch> [--at <sha>] [--line <path>:<line>] <message"16201632 open_review "$1" "$2"
@@ -1632,7 +1644,10 @@ sc_comment() {
16321644 # You commented on the revision you have checked out; if the review has16331645 # moved on since, the comment belongs to that older revision.16341646 if [ -n "$at" ] && [ "$at" != "$RTIP" ]; then1635- rev_of "$at" >/dev/null || die "$(printf %.7s "$at") isn't a pushed revision of #$RID; push it, or name the review by number"1647+ if ! rev_of "$at" >/dev/null; then1648+ [ "${FORGE_VIA:-}" != web ] || die "$(printf %.7s "$at") isn't a revision of #$RID; reload the page and comment again"1649+ die "$(printf %.7s "$at") isn't a pushed revision of #$RID; push it, or name the review by number"1650+ fi16361651 sha=$at16371652 fi16381653 path= line= text=
@@ -1686,8 +1701,11 @@ sc_approve() {
16861701 [ "$RLIVE" = 1 ] || die "#$RID is closed"16871702 [ "$user" != "$RAUTHOR" ] || die "you opened #$RID; an approval has to come from someone else"16881703 rev=$(rev_of "$RTIP" || true)1689- [ -z "$at" ] || [ "$at" = "$RTIP" ] ||1690- die "#$RID has moved on to ${rev:+r$rev }$(printf %.7s "$RTIP") since $(printf %.7s "$at"); run forge checkout $RID and look again"1704+ if [ -n "$at" ] && [ "$at" != "$RTIP" ]; then1705+ moved="#$RID has moved on to ${rev:+r$rev }$(printf %.7s "$RTIP") since $(printf %.7s "$at")"1706+ [ "${FORGE_VIA:-}" != web ] || die "$moved; reload the page to see what changed, and approve that"1707+ die "$moved; run forge checkout $RID and look again"1708+ fi16911709 tree=$(git --git-dir="$RDIR" rev-parse "$RTIP^{tree}")16921710 if awk -v u="$user" -v t="$tree" '$1 == "approve" && $3 == u && $5 == t { f = 1 } END { exit !f }' "$RF"; then16931711 echo "you already approved this tree of #$RID"
M lib/login.ts +3 -3
M lib/private-server.ts +132 -1
@@ -17,16 +17,21 @@
1717 // does any missing page of www, since Caddy sends those here too.1818 //1919 // /login/ sign in, add a passkey from an ssh link, sign out (lib/login.ts)20+// /login/whoami who is signed in, and their CSRF token, for review pages' script2021 // /private/ the private repos you can read2122 // /assets/<file> the forge's assets, for pages here before a build has shipped them2223 // /<owner>/<repo>/ a private repo's pages2324 // /auth/git Caddy's forward_auth for HTTPS clones of repos without2425 // git-daemon-export-ok: may this token read X-Forge-Repo?26+// POST /<owner>/<repo>/reviews/<id>/comment, .../approve27+// a comment or approval from a review page, public or28+// private, made the way ssh makes it (discuss, below)2529 //2630 // node lib/private-server.ts (FORGE_ROOT, FORGE_BASE_URL, FORGE_PRIVATE_PORT: 8093)2731 import http from 'node:http';2832 import fs from 'node:fs';2933 import path from 'node:path';34+import { spawn } from 'node:child_process';3035 import { fileURLToPath } from 'node:url';3136 import { Identity, SESSION_TTL, TOKEN_PREFIX, cookieValue, csrfToken, sameSecret, sessionCookie, type Session, type Token } from './identity.ts';3237 import { canRead, isPublic, readable, repoDir, users } from './access.ts';
@@ -135,6 +140,43 @@ export class Gate {
135140 }136141 }137142 143+// Only Authorization: Bearer, for posts: a browser resends basic auth it144+// has cached by itself, which would make it as ambient as a cookie.145+export function bearer(req: http.IncomingMessage): string | null {146+ return /^Bearer +(\S+)$/i.exec(req.headers.authorization ?? '')?.[1] ?? null;147+}148+149+// Comments and approvals from review pages (site/assets/discuss.ts) are150+// made by the forge's own command, the one ssh's git-shell-commands run:151+// forge shell-cmd comment|approve, as the user, with the message on stdin.152+// So the comment file, the approve line in the review log, the rules (who153+// may approve, outdated revisions, the 32 KiB cap), the event and the154+// rebuild are exactly what forge comment and forge approve get.155+const FORGE_BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'forge');156+export const POST_PATH = /^\/([^/]+)\/([^/]+)\/reviews\/([1-9][0-9]{0,8})\/(comment|approve)$/;157+// Each user posts at most this many times a minute, and this many run at once.158+const POSTS_PER_MINUTE = 20;159+const RUNNING_MAX = 4;160+// A comment is at most 32 KiB (sc_comment); escaped as JSON, a few times that.161+const POST_MAX = 160 * 1024;162+163+export function shellCmd(cfg: PrivateConfig, user: string, args: string[], input: string): Promise<{ code: number | null; out: string; err: string }> {164+ return new Promise((ok, no) => {165+ // FORGE_VIA: refusals worded for a page (reload it), not for a checkout.166+ const env: NodeJS.ProcessEnv = { ...process.env, FORGE_ROOT: cfg.root, FORGE_USER: user, FORGE_VIA: 'web' };167+ if (cfg.baseUrl) env.FORGE_BASE_URL = cfg.baseUrl;168+ const p = spawn(FORGE_BIN, ['shell-cmd', ...args], { cwd: cfg.root, env, stdio: ['pipe', 'pipe', 'pipe'], timeout: 5 * 60 * 1000 });169+ const out: Buffer[] = [];170+ const err: Buffer[] = [];171+ p.stdout.on('data', (d: Buffer) => out.push(d));172+ p.stderr.on('data', (d: Buffer) => err.push(d));173+ p.on('error', no);174+ p.on('close', (code) => ok({ code, out: Buffer.concat(out).toString('utf8'), err: Buffer.concat(err).toString('utf8') }));175+ p.stdin.on('error', () => {});176+ p.stdin.end(input);177+ });178+}179+138180 // The sign-in and list pages: no framing, scripts and styles of our own only.139181 const PAGE_HEADERS = {140182 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'private, no-store', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'same-origin',
@@ -255,6 +297,92 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
255297 return { 'Set-Cookie': sessionCookie(secret, SESSION_TTL) };256298 }257299 300+ // Who is signed in, for the script of static review pages, which shows301+ // the comment form and Approve button to someone signed in, with the CSRF302+ // token their posts carry. Never kept by any cache.303+ function whoami(req: http.IncomingMessage, res: http.ServerResponse): void {304+ const who = gate.bySession(cookieValue(req.headers.cookie));305+ const body = who ? { user: who.user, csrf: csrfToken(who.secret!) } : { user: null };306+ json(res, 200, body, { 'Cache-Control': 'private, no-store', Vary: 'Cookie', ...cookie(who) });307+ }308+309+ // A comment or approval. A private repo you can't read, signed in or310+ // not, is the 404 a missing repo gets, as for its pages. Then it takes a311+ // user, signed in or with a Bearer token (which may be for this repo312+ // only), and for a session, a post from the forge's own pages (Origin)313+ // that carries the session's CSRF token. The fields come as a form or314+ // JSON: body (markdown), at (the tip the page showed, so a comment315+ // belongs to the revision its writer saw and an approval of a review that316+ // has moved on is refused), line (path:n, on the new side), csrf. The317+ // answer is JSON, { ok, message, comment } or { error }, in the forge's318+ // own words.319+ const recent = new Map<string, number[]>();320+ let running = 0;321+ async function discuss(req: http.IncomingMessage, res: http.ServerResponse, m: RegExpExecArray): Promise<void> {322+ const who = gate.byToken(bearer(req)) ?? gate.bySession(cookieValue(req.headers.cookie));323+ let slug: string;324+ try {325+ slug = `${decodeURIComponent(m[1])}/${decodeURIComponent(m[2])}`;326+ } catch {327+ return notFound(res);328+ }329+ const dir = repoDir(cfg.root, slug);330+ if (!dir || !(isPublic(dir) || gate.reads(who, slug, dir))) return notFound(res);331+ const what = m[4] === 'comment' ? 'comment' : 'approve';332+ if (!who) return json(res, 401, { error: `Sign in to ${what}` });333+ if (who.token?.repo && who.token.repo !== slug) return json(res, 403, { error: `This token is for ${who.token.repo} only` });334+ if (who.session && (!login || req.headers.origin !== login.rp.origin)) return json(res, 403, { error: 'not from this forge' });335+ let raw: string;336+ try {337+ if (Number(req.headers['content-length'] ?? 0) > POST_MAX) throw new Error('too large');338+ raw = await readBody(req, POST_MAX);339+ } catch {340+ return json(res, 413, { error: `${what}: longer than 32 KiB` });341+ }342+ let form: Record<string, unknown>;343+ if (/^application\/json\b/i.test(req.headers['content-type'] ?? '')) {344+ try {345+ form = JSON.parse(raw || '{}');346+ } catch {347+ return json(res, 400, { error: 'not JSON' });348+ }349+ if (!form || typeof form !== 'object' || Array.isArray(form)) return json(res, 400, { error: 'not a JSON object' });350+ } else form = Object.fromEntries(new URLSearchParams(raw));351+ const field = (k: string): string => (form[k] == null ? '' : String(form[k]));352+ if (who.session && !sameSecret(field('csrf'), csrfToken(who.secret!))) return json(res, 403, { error: 'This page is out of date: reload it and try again' });353+ const at = field('at');354+ if (at && !/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(at)) return json(res, 400, { error: `${what}: at is not a commit id` });355+ const t = Date.now();356+ const mine = (recent.get(who.user) ?? []).filter((x) => t - x < 60_000);357+ if (mine.length >= POSTS_PER_MINUTE) return json(res, 429, { error: 'That is a lot at once: wait a minute' }, { 'Retry-After': '60' });358+ if (running >= RUNNING_MAX) return json(res, 503, { error: 'The forge is busy: try again in a moment' }, { 'Retry-After': '5' });359+ mine.push(t);360+ recent.set(who.user, mine);361+ const args = [what, slug, m[3]];362+ if (at) args.push('--at', at);363+ let input = '';364+ if (what === 'comment') {365+ if (field('line')) args.push('--line', field('line'));366+ // Lines of text, as forge comment -m sends it.367+ input = field('body');368+ if (input && !input.endsWith('\n')) input += '\n';369+ }370+ running++;371+ let r;372+ try {373+ r = await shellCmd(cfg, who.user, args, input);374+ } finally {375+ running--;376+ }377+ if (r.code !== 0) {378+ const said = r.err.split('\n').filter((l) => l.startsWith('forge: ')).map((l) => l.slice(7)).join('\n') || `${what} failed`;379+ return json(res, /^no (review|repo) /.test(said) ? 404 : 400, { error: said }, cookie(who));380+ }381+ const message = r.out.trim();382+ const n = /^comment (\d+) /.exec(message)?.[1];383+ json(res, 200, { ok: true, message, ...(n ? { comment: Number(n) } : {}) }, cookie(who));384+ }385+258386 async function post(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void> {259387 // Only the forge's own pages may post here (and sign anyone in or out).260388 if (!login || req.headers.origin !== login.rp.origin) return json(res, 403, { error: 'not from this forge' });
@@ -300,7 +428,10 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
300428 try {301429 const url = new URL(req.url ?? '/', 'http://x');302430 const get = req.method === 'GET' || req.method === 'HEAD';303- if (url.pathname.startsWith('/login/') && req.method === 'POST') post(req, res, url).catch(fail);431+ const write = req.method === 'POST' ? POST_PATH.exec(url.pathname) : null;432+ if (write) discuss(req, res, write).catch(fail);433+ else if (url.pathname === '/login/whoami' && get) whoami(req, res);434+ else if (url.pathname.startsWith('/login/') && req.method === 'POST') post(req, res, url).catch(fail);304435 else if (url.pathname === '/login/' && get) loginPage(req, res, url);305436 else if (url.pathname === '/private/' && get) listPage(req, res);306437 else if (url.pathname === '/login' || url.pathname === '/private') res.writeHead(308, { Location: url.pathname + '/' + url.search }).end();
M test/http.ts +7 -2
M test/lib.sh +16 -0
M test/scenarios/private-web-caddy.sh +18 -2
@@ -2,7 +2,9 @@
22 # to the private server, so it needs no list of private repos. With caddy33 # installed, the real thing: public pages come from www with Caddy's cache44 # headers, a private page goes to its reader with a token, and anyone else5-# gets the same response as for a page that isn't there.5+# gets the same response as for a page that isn't there. A comment posted6+# to a public repo's review, whose pages are static files, reaches the7+# private server as well.68 . "$(dirname "$0")/../lib.sh"79 user dami810 user cat
@@ -13,6 +15,9 @@ for r in secret open; do
1315 at "$T/w/$r" commit "first"1416 ok push_as dami "$T/w/$r" origin main1517 done18+at "$T/w/open" git switch -q -c dami/feature19+at "$T/w/open" commit "feature"20+ok push_as dami "$T/w/open" origin dami/feature1621 build1722 1823 ok forge server caddyfile git.test
@@ -22,7 +27,10 @@ has "$T/Caddyfile" '^ try_files \{path\} \{path\}/$'
2227 has "$T/Caddyfile" '^ reverse_proxy 127\.0\.0\.1:8093$'2328 lacks "$T/Caddyfile" 'private/www'2429 # The private server is the last handler: only what nothing else took.25-eq "$(grep -n 'reverse_proxy 127.0.0.1:8093' "$T/Caddyfile" | cut -d: -f1)" "$(($(grep -n '^}$' "$T/Caddyfile" | head -n 1 | cut -d: -f1) - 2))"30+eq "$(grep -n 'reverse_proxy 127.0.0.1:8093' "$T/Caddyfile" | tail -n 1 | cut -d: -f1)" "$(($(grep -n '^}$' "$T/Caddyfile" | head -n 1 | cut -d: -f1) - 2))"31+# And comments and approvals, before www's files.32+has "$T/Caddyfile" '^ path_regexp \^/\[\^/\]\+/\[\^/\]\+/reviews/\[0-9\]\+/\(comment\|approve\)\$$'33+[ "$(grep -n '^ handle @discuss {$' "$T/Caddyfile" | cut -d: -f1)" -lt "$(grep -n '^ handle @public {$' "$T/Caddyfile" | cut -d: -f1)" ] || fail "@discuss comes after @public"2634 command -v caddy >/dev/null 2>&1 || { note "caddy is not installed: skipped running it"; exit 0; }2735 ok caddy adapt --config "$T/Caddyfile" --adapter caddyfile2836
@@ -66,3 +74,11 @@ same "$c/dami/secret/"
6674 same "$c/dami/secret/" -H "Authorization: Bearer $cat"6775 same "$c/dami/open/nope/"6876 same "$c/nobody/x/y/"77+78+# A comment on a public review, through Caddy, with a token.79+exists "$FORGE_WWW/dami/open/reviews/1/index.html"80+eq "$(http -s -o "$T/res" -w '%{http_code}' -H "Authorization: Bearer $cat" -d 'body=Through Caddy' "$c/dami/open/reviews/1/comment")" 20081+has "$T/res" '"ok":true,"message":"comment 1 on #1'82+has "$FORGE_ROOT/dami/open.git/forge/comments/1/1" '^Through Caddy$'83+eq "$(http -s -o "$T/res" -w '%{http_code}' -d 'body=Signed out' "$c/dami/open/reviews/1/comment")" 40184+same "$c/dami/secret/reviews/1/comment" -X POST -H "Authorization: Bearer $cat" -d 'body=hi'
A test/scenarios/web-approve.sh +36 -0
@@ -0,0 +1,36 @@
1+# Approve on a review page, signed in, writes the approve line forge2+# approve writes, so forge.requireapproval lets main move to that tree. The3+# review's author can't approve it from the web either, and approving the4+# same tree twice adds nothing.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+repo dami/greet9+clone dami dami/greet "$T/w/dami"10+at "$T/w/dami" commit "first"11+ok push_as dami "$T/w/dami" origin main12+git --git-dir="$FORGE_ROOT/dami/greet.git" config forge.requireapproval true13+at "$T/w/dami" git switch -q -c dami/feature14+at "$T/w/dami" commit "feature"15+ok push_as dami "$T/w/dami" origin dami/feature16+tip=$(git --git-dir="$FORGE_ROOT/dami/greet.git" rev-parse dami/feature)17+export FORGE_BASE_URL=https://forge.test18+private_server19+ana=$(signin ana) || fail "ana can't sign in"20+dami=$(signin dami) || fail "dami can't sign in"21+approve() { http -s -w ' %{http_code}' -H "Cookie: __Host-forge=$1" -H 'Origin: https://forge.test' -d "at=$tip&csrf=$(csrf_of "$1")" "$PRIV/dami/greet/reviews/1/approve"; }22+log=$FORGE_ROOT/dami/greet.git/forge/reviews/123+24+approve "$dami" >"$T/res"25+has "$T/res" '"error":"you opened #1; an approval has to come from someone else"\} 400$'26+lacks "$log" '^approve '27+fails_with "is not approved" push_as dami "$T/w/dami" origin dami/feature:main28+29+approve "$ana" >"$T/res"30+has "$T/res" '"ok":true,"message":"approved #1 r1 \('"$(printf %.7s "$tip")"'\): https://forge.test/dami/greet/reviews/1/"\} 200$'31+has "$log" "^approve $T_NOW ana $tip $(git --git-dir="$FORGE_ROOT/dami/greet.git" rev-parse "$tip^{tree}")\$"32+approve "$ana" >"$T/res"33+has "$T/res" '"message":"you already approved this tree of #1"'34+eq "$(grep -c '^approve ' "$log")" 135+has "$FORGE_ROOT/.forge/events.jsonl" '"type":"approve","user":"ana","review":1'36+ok push_as dami "$T/w/dami" origin dami/feature:main
A test/scenarios/web-comment-csrf.sh +48 -0
@@ -0,0 +1,48 @@
1+# A comment or approval that rides on the session cookie must come from2+# the forge's own pages: the Origin is the forge's and the post carries the3+# session's CSRF token, which another session's token isn't. Signed out,4+# nothing is posted. None of these write anything.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+user cat9+repo dami/greet10+clone dami dami/greet "$T/w/dami"11+at "$T/w/dami" commit "first"12+ok push_as dami "$T/w/dami" origin main13+at "$T/w/dami" git switch -q -c dami/feature14+at "$T/w/dami" commit "feature"15+ok push_as dami "$T/w/dami" origin dami/feature16+export FORGE_BASE_URL=https://forge.test17+private_server18+ana=$(signin ana) || fail "ana can't sign in"19+cat=$(signin cat) || fail "cat can't sign in"20+csrf=$(csrf_of "$ana")21+[ -n "$csrf" ] || fail "no csrf token for ana"22+[ "$csrf" != "$(csrf_of "$cat")" ] || fail "two sessions have one csrf token"23+code() { http -s -o "$T/res" -w '%{http_code}' -X POST "$@"; }24+for a in comment approve; do25+ u=$PRIV/dami/greet/reviews/1/$a26+ eq "$(code -H "Cookie: __Host-forge=$ana" -H 'Origin: https://forge.test' -d 'body=hi' "$u")" 40327+ has "$T/res" '"error":"This page is out of date: reload it and try again"'28+ eq "$(code -H "Cookie: __Host-forge=$ana" -H 'Origin: https://forge.test' -d "body=hi&csrf=$(csrf_of "$cat")" "$u")" 40329+ eq "$(code -H "Cookie: __Host-forge=$ana" -H 'Origin: https://evil.test' -d "body=hi&csrf=$csrf" "$u")" 40330+ has "$T/res" '"error":"not from this forge"'31+ eq "$(code -H "Cookie: __Host-forge=$ana" -d "body=hi&csrf=$csrf" "$u")" 40332+ eq "$(code -H 'Origin: https://forge.test' -d "body=hi&csrf=$csrf" "$u")" 40133+ has "$T/res" "\"error\":\"Sign in to $a\""34+ eq "$(code -H 'Cookie: __Host-forge=nope' -H 'Origin: https://forge.test' -d "body=hi&csrf=$csrf" "$u")" 40135+done36+missing "$FORGE_ROOT/dami/greet.git/forge/comments/1"37+lacks "$FORGE_ROOT/dami/greet.git/forge/reviews/1" '^approve '38+# The real thing, to show the rest were refused for what they lacked.39+eq "$(code -H "Cookie: __Host-forge=$ana" -H 'Origin: https://forge.test' -d "body=hi&csrf=$csrf" "$PRIV/dami/greet/reviews/1/comment")" 20040+exists "$FORGE_ROOT/dami/greet.git/forge/comments/1/1"41+42+# who is asking, for the page's script: never kept by a cache.43+http -s -D "$T/h" -o "$T/who" -H "Cookie: __Host-forge=$ana" "$PRIV/login/whoami"44+has "$T/h" '^Cache-Control: private, no-store'45+has "$T/h" '^Vary: Cookie'46+has "$T/who" "^\\{\"user\":\"ana\",\"csrf\":\"$csrf\"\\}\$"47+http -s -o "$T/who" "$PRIV/login/whoami"48+has "$T/who" '^\{"user":null\}$'
A test/scenarios/web-comment-limit.sh +38 -0
A test/scenarios/web-comment-outdated.sh +48 -0
@@ -0,0 +1,48 @@
1+# A review page posts the tip it shows. When the review has moved on since2+# the page was built, a comment from it belongs to the revision it showed,3+# as one from a checkout of that revision does, and an approval from it is4+# refused. A tip the review never had is refused too. The forge words5+# these refusals for a page (reload it); over ssh they still say to check6+# out or push.7+. "$(dirname "$0")/../lib.sh"8+user dami9+user ana10+repo dami/greet11+clone dami dami/greet "$T/w/dami"12+at "$T/w/dami" commit "first"13+ok push_as dami "$T/w/dami" origin main14+at "$T/w/dami" git switch -q -c dami/feature15+at "$T/w/dami" commit "feature" greet.sh16+ok push_as dami "$T/w/dami" origin dami/feature17+r1=$(git --git-dir="$FORGE_ROOT/dami/greet.git" rev-parse dami/feature)18+at "$T/w/dami" commit "more" greet.sh19+ok push_as dami "$T/w/dami" origin dami/feature20+r2=$(git --git-dir="$FORGE_ROOT/dami/greet.git" rev-parse dami/feature)21+export FORGE_BASE_URL=https://forge.test22+private_server23+ana=$(signin ana) || fail "ana can't sign in"24+csrf=$(csrf_of "$ana")25+post() { http -s -w ' %{http_code}' -H "Cookie: __Host-forge=$ana" -H 'Origin: https://forge.test' -d "$2&csrf=$csrf" "$PRIV/dami/greet/reviews/1/$1"; }26+d=$FORGE_ROOT/dami/greet.git/forge/comments/127+28+post comment "body=On the first&at=$r1&line=greet.sh:1" >"$T/res"29+has "$T/res" '"message":"comment 1 on #1 r1: https://forge.test/dami/greet/reviews/1/#comment-1\\n\(#1 has a newer revision, so this shows as outdated\)"'30+has "$d/1" "^sha $r1\$"31+has "$d/1" '^rev 1$'32+post comment "body=On the second&at=$r2" >"$T/res"33+has "$d/2" "^sha $r2\$"34+has "$d/2" '^rev 2$'35+36+post approve "at=$r1" >"$T/res"37+has "$T/res" "\"error\":\"#1 has moved on to r2 $(printf %.7s "$r2") since $(printf %.7s "$r1"); reload the page to see what changed, and approve that\"\\} 400\$"38+fails_with "^forge: #1 has moved on to r2 $(printf %.7s "$r2") since $(printf %.7s "$r1"); run forge checkout 1 and look again\$" ssh_as ana approve dami/greet 1 --at "$r1"39+lacks "$FORGE_ROOT/dami/greet.git/forge/reviews/1" '^approve '40+main=$(git --git-dir="$FORGE_ROOT/dami/greet.git" rev-parse main)41+post comment "body=Nowhere&at=$main" >"$T/res"42+has "$T/res" "\"error\":\"$(printf %.7s "$main") isn't a revision of #1; reload the page and comment again\"\\} 400\$"43+echo Nowhere | fails_with "^forge: $(printf %.7s "$main") isn't a pushed revision of #1; push it, or name the review by number\$" ssh_as ana comment dami/greet 1 --at "$main"44+post comment "body=Nowhere&at=nope" >"$T/res"45+has "$T/res" '"error":"comment: at is not a commit id"\} 400$'46+post approve "at=$r2" >"$T/res"47+has "$T/res" '"ok":true'48+missing "$d/3"
A test/scenarios/web-comment-private.sh +42 -0
@@ -0,0 +1,42 @@
1+# On a private repo, a reader comments and approves from its pages like2+# anyone on a public one. Anyone else, signed in or not, with a token or3+# not, gets the response a repo that isn't there gets, byte for byte, and4+# nothing is written.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+user cat9+repo --private dami/secret10+clone dami dami/secret "$T/w/dami"11+at "$T/w/dami" commit "first"12+ok push_as dami "$T/w/dami" origin main13+at "$T/w/dami" git switch -q -c dami/feature14+at "$T/w/dami" commit "feature"15+ok push_as dami "$T/w/dami" origin dami/feature16+ok ssh_as dami readers dami/secret add ana17+export FORGE_BASE_URL=https://forge.test18+private_server19+ana=$(signin ana) || fail "ana can't sign in"20+cat=$(signin cat) || fail "cat can't sign in"21+cat_token=$(ssh_as cat token 2>/dev/null)22+web() { http -s -o "$T/res" -w '%{http_code}' -H "Cookie: __Host-forge=$1" -H 'Origin: https://forge.test' -d "body=hi&csrf=$(csrf_of "$1")" "$PRIV/dami/secret/reviews/1/$2"; }23+24+eq "$(web "$ana" comment)" 20025+eq "$(web "$ana" approve)" 20026+has "$FORGE_ROOT/dami/secret.git/forge/comments/1/1" '^user ana$'27+has "$FORGE_ROOT/dami/secret.git/forge/reviews/1" '^approve [0-9]+ ana '28+has "$FORGE_ROOT/.forge/events.jsonl" '"type":"comment","user":"ana","review":1,"private":true'29+30+for a in comment approve; do31+ response "$PRIV/dami/nope/reviews/1/$a" -X POST -H 'Origin: https://forge.test' -d 'body=hi' >"$T/missing"32+ has "$T/missing" '^HTTP/1.1 404 Not Found'33+ same() {34+ response "$PRIV/dami/secret/reviews/1/$a" -X POST -H 'Origin: https://forge.test' "$@" >"$T/got"35+ cmp -s "$T/missing" "$T/got" || { diff "$T/missing" "$T/got" >&2 || true; fail "$a on dami/secret with $* is not the missing repo's 404"; }36+ }37+ same -d 'body=hi'38+ same -H "Cookie: __Host-forge=$cat" -d "body=hi&csrf=$(csrf_of "$cat")"39+ same -H "Authorization: Bearer $cat_token" -d 'body=hi'40+done41+eq "$(ls "$FORGE_ROOT/dami/secret.git/forge/comments/1")" 142+eq "$(grep -c '^approve ' "$FORGE_ROOT/dami/secret.git/forge/reviews/1")" 1
A test/scenarios/web-comment-same.sh +47 -0
@@ -0,0 +1,47 @@
1+# A comment posted from a review page, signed in with a passkey, is the2+# comment forge comment makes over ssh: the same file, byte for byte, for3+# the same text at the same time, on the whole review and on a line, and4+# the same event for open pages. The answer says where it went.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+repo dami/greet9+clone dami dami/greet "$T/w/dami"10+at "$T/w/dami" commit "first" greet.sh11+ok push_as dami "$T/w/dami" origin main12+at "$T/w/dami" git switch -q -c dami/feature13+at "$T/w/dami" commit "feature" greet.sh14+ok push_as dami "$T/w/dami" origin dami/feature15+tip=$(git --git-dir="$FORGE_ROOT/dami/greet.git" rev-parse dami/feature)16+export FORGE_BASE_URL=https://forge.test17+private_server18+ana=$(signin ana) || fail "ana can't sign in"19+csrf=$(csrf_of "$ana")20+post() { http -s -H "Cookie: __Host-forge=$ana" -H 'Origin: https://forge.test' -H 'Content-Type: application/json' -d "$1" "$PRIV/dami/greet/reviews/1/comment"; }21+d=$FORGE_ROOT/dami/greet.git/forge/comments/122+23+printf 'Looks *good*\n\nOne thing.\n' | ssh_as ana comment dami/greet 1 >/dev/null24+post '{"body":"Looks *good*\n\nOne thing.","at":"'"$tip"'","csrf":"'"$csrf"'"}' >"$T/res"25+has "$T/res" '"ok":true'26+has "$T/res" '"comment":2'27+has "$T/res" '"message":"comment 2 on #1 r1: https://forge.test/dami/greet/reviews/1/#comment-2"'28+cmp "$d/1" "$d/2" || fail "the web's comment differs from ssh's"29+has "$d/2" '^user ana$'30+31+printf 'Why 2?\n' | ssh_as ana comment dami/greet 1 --line greet.sh:2 >/dev/null32+post '{"body":"Why 2?","line":"greet.sh:2","at":"'"$tip"'","csrf":"'"$csrf"'"}' >"$T/res"33+has "$T/res" '"comment":4'34+cmp "$d/3" "$d/4" || fail "the web's line comment differs from ssh's"35+has "$d/4" '^path greet.sh$'36+has "$d/4" '^line 2$'37+38+# Open pages hear of each the same way.39+eq "$(grep -c '"type":"comment","user":"ana","review":1' "$FORGE_ROOT/.forge/events.jsonl")" 440+eq "$(grep '"type":"comment"' "$FORGE_ROOT/.forge/events.jsonl" | sort -u | wc -l | tr -d ' ')" 141+42+# What the forge refuses, it refuses in its own words.43+post '{"body":"x","line":"nope.sh:1","at":"'"$tip"'","csrf":"'"$csrf"'"}' >"$T/res"44+has "$T/res" '"error":"comment: no file nope.sh in #1 at '"$(printf %.7s "$tip")"'"'45+post '{"body":" ","csrf":"'"$csrf"'"}' >"$T/res"46+has "$T/res" '"error":"comment: empty message"'47+missing "$d/5"
A test/scenarios/web-comment-token.sh +35 -0
@@ -0,0 +1,35 @@
1+# A token (Authorization: Bearer) comments and approves without a cookie,2+# so without a CSRF token: curl and scripts can. A token for one repo works3+# on that repo only, and a token in basic auth, which browsers resend by4+# themselves, doesn't post.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+repo dami/greet9+repo dami/other10+for r in greet other; do11+ clone dami "dami/$r" "$T/w/$r"12+ at "$T/w/$r" commit "first"13+ ok push_as dami "$T/w/$r" origin main14+ at "$T/w/$r" git switch -q -c dami/feature15+ at "$T/w/$r" commit "feature"16+ ok push_as dami "$T/w/$r" origin dami/feature17+done18+export FORGE_BASE_URL=https://forge.test19+private_server20+all=$(ssh_as ana token 2>/dev/null)21+one=$(ssh_as ana token --repo dami/other 2>/dev/null)22+code() { http -s -o "$T/res" -w '%{http_code}' -X POST "$@"; }23+24+eq "$(code -H "Authorization: Bearer $all" -d 'body=From curl' "$PRIV/dami/greet/reviews/1/comment")" 20025+has "$FORGE_ROOT/dami/greet.git/forge/comments/1/1" '^user ana$'26+has "$FORGE_ROOT/dami/greet.git/forge/comments/1/1" '^From curl$'27+eq "$(code -H "Authorization: Bearer $all" -H 'Content-Type: application/json' -d '{}' "$PRIV/dami/greet/reviews/1/approve")" 20028+has "$FORGE_ROOT/dami/greet.git/forge/reviews/1" '^approve [0-9]+ ana '29+30+eq "$(code -H "Authorization: Bearer $one" -d 'body=Wrong repo' "$PRIV/dami/greet/reviews/1/comment")" 40331+has "$T/res" '"error":"This token is for dami/other only"'32+eq "$(code -H "Authorization: Bearer $one" -d 'body=Right repo' "$PRIV/dami/other/reviews/1/comment")" 20033+eq "$(code -u "ana:$all" -d 'body=Basic' "$PRIV/dami/greet/reviews/1/comment")" 40134+eq "$(code -H "Authorization: Bearer forge_nope" -d 'body=Revoked' "$PRIV/dami/greet/reviews/1/comment")" 40135+eq "$(ls "$FORGE_ROOT/dami/greet.git/forge/comments/1")" 1