clone

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

Stream a private repo's events to its signed-in readers

cat committed 6010fa0
/events/<owner>/<repo>/ of a private repo streams to a page whose
session cookie is valid in identity.db (opened read-only) and whose user
is still a user and may read the repo (lib/access.ts); anyone else keeps
getting the 404 of a repo that doesn't exist, and /events/ still skips
private repos. Each private event checks the session and the right to
read again, so signing out, losing the session or being removed as a
reader ends the stream at the next event, as making a repo private does.
commit
6010fa07e38f59814b3b456713f53ee6093fe872
parent
a6d10bc30524ec373b36ea37189f345149e46e33
author
cat <admin@dev.site>
kind
feat risk score 1.5 of 3, Jev's estimate
2 files changed, +89 -29
Mlib/events-server.ts55+++++++++++++++++++-------
Mtest/scenarios/private-events.sh63+++++++++++++++++++++++-------
M lib/events-server.ts +40 -15
@@ -6,14 +6,20 @@
66 // page's live status (ssexi swaps it in), and a named event (push, built) the77 // page's script acts on, e.g. refreshing its content when a build finishes.88 //9-// Private repos stream nothing: their events stay off /events/, and their10-// /events/<owner>/<repo>/ is a 404, like a repo that doesn't exist. Whether a11-// repo is private is read from its git-daemon-export-ok at each event, not12-// only from the event's flag, so making a repo private takes effect at once.9+// A private repo's events stay off /events/, and its /events/<owner>/<repo>/10+// streams only to a reader signed in with a passkey: the session cookie is11+// checked against identity.db (read-only), the user against .forge/keys and12+// the repo's readers (lib/access.ts). Anyone else gets the 404 a repo that13+// doesn't exist gets. Whether a repo is private is read from its14+// git-daemon-export-ok at each event, not only from the event's flag, and15+// the session and the right to read are checked again too, so making a16+// repo private, removing a reader or signing out takes effect at once.1317 import http from 'node:http';1418 import fs from 'node:fs';1519 import path from 'node:path';1620 import { fileURLToPath } from 'node:url';21+import { canRead, isPublic as flagged, repoDir, users } from './access.ts';22+import { Identity, cookieValue } from './identity.ts';1723 1824 export interface ForgeEvent {1925 t: number;
@@ -30,17 +36,16 @@ export interface ForgeEvent {
3036 3137 interface Client {3238 repo: string | null;39+ // Watching a private repo: the session that may.40+ session: string | null;3341 res: http.ServerResponse;3442 ping: NodeJS.Timeout;3543 }3644 37-const NAME = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;38-3945 // A repo is public while its bare repo has git-daemon-export-ok.4046 export function isPublic(root: string, repo: string): boolean {41- const [owner, name, ...rest] = repo.split('/');42- if (rest.length || !NAME.test(owner ?? '') || !NAME.test(name ?? '')) return false;43- return fs.existsSync(path.join(root, owner, `${name}.git`, 'git-daemon-export-ok'));47+ const dir = repoDir(root, repo);48+ return !!dir && flagged(dir);4449 }4550 4651 const ENTITIES: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
@@ -68,16 +73,32 @@ function send(res: http.ServerResponse, ev: ForgeEvent): void {
6873 export function serveEvents(state: string, port: number, root = path.dirname(state)): http.Server {6974 const file = path.join(state, 'events.jsonl');7075 const clients = new Set<Client>();76+ // identity.db, read-only, once it exists.77+ let id: Identity | null = null;78+ // May the holder of this session cookie read the private repo?79+ const reader = (secret: string | null, repo: string): boolean => {80+ if (!secret) return false;81+ id ??= Identity.open(state, { readOnly: true });82+ const s = id?.session(secret);83+ const dir = repoDir(root, repo);84+ return !!s && !!dir && !flagged(dir) && users(state).has(s.user) && canRead(dir, s.user);85+ };7186 const drop = (c: Client): void => {7287 clearInterval(c.ping);7388 clients.delete(c);7489 };7590 const broadcast = (ev: ForgeEvent): void => {7691 if (ev.private || !isPublic(root, ev.repo)) {77- // Pages of a repo made private since they connected hear no more of it.78- for (const c of clients) if (c.repo === ev.repo) {79- drop(c);80- c.res.end();92+ // Only its signed-in readers hear of it; pages of a repo made private93+ // since they connected, or whose reader may not read any more, hear94+ // no more of it.95+ for (const c of clients) {96+ if (c.repo !== ev.repo) continue;97+ if (reader(c.session, ev.repo)) send(c.res, ev);98+ else {99+ drop(c);100+ c.res.end();101+ }81102 }82103 return;83104 }
@@ -113,7 +134,11 @@ export function serveEvents(state: string, port: number, root = path.dirname(sta
113134 114135 return http.createServer((req, res) => {115136 const m = /^\/events\/(?:([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/?)?$/.exec(new URL(req.url ?? '/', 'http://x').pathname);116- if (!m || req.method !== 'GET' || (m[1] && !isPublic(root, `${m[1]}/${m[2]}`))) {137+ const repo = m?.[1] ? `${m[1]}/${m[2]}` : null;138+ const secret = cookieValue(req.headers.cookie);139+ let session: string | null = null;140+ if (repo && !isPublic(root, repo) && reader(secret, repo)) session = secret;141+ if (!m || req.method !== 'GET' || (repo && !isPublic(root, repo) && !session)) {117142 res.writeHead(404).end();118143 return;119144 }
@@ -121,7 +146,7 @@ export function serveEvents(state: string, port: number, root = path.dirname(sta
121146 res.write('retry: 5000\n');122147 // Connected and idle: nothing to show until someone pushes.123148 res.write('data: \n\n');124- const client: Client = { repo: m[1] ? `${m[1]}/${m[2]}` : null, res, ping: setInterval(() => res.write(': ping\n\n'), 20000) };149+ const client: Client = { repo, session, res, ping: setInterval(() => res.write(': ping\n\n'), 20000) };125150 clients.add(client);126151 req.on('close', () => drop(client));127152 }).listen(port, '127.0.0.1', () => console.log(`events on 127.0.0.1:${port}, following ${file}`));
M test/scenarios/private-events.sh +49 -14
@@ -1,9 +1,13 @@
1-# The events server streams nothing of a private repo: /events/ skips its2-# events, flagged or not (it reads git-daemon-export-ok), and3-# /events/<owner>/<repo>/ is the 404 a missing repo gets. A repo made4-# private while a page watches it stops streaming to that page at once.1+# The events server streams a private repo only to its readers, signed in2+# with a passkey: /events/ skips its events, flagged or not (it reads3+# git-daemon-export-ok), and /events/<owner>/<repo>/ is the 404 a missing4+# repo gets for anyone else, signed in or not. A repo made private while a5+# page watches it stops streaming to that page at once, and so does a6+# reader's page once they may not read it any more.57 . "$(dirname "$0")/../lib.sh"68 user dami9+user ana10+user cat711 repo --private dami/secret812 repo dami/open913 port=$(node -e 'const s = require("net").createServer().listen(0, "127.0.0.1", () => { console.log(s.address().port); s.close(); })')
@@ -11,19 +15,19 @@ ev=$FORGE_ROOT/.forge/events.jsonl
1115 : >"$ev"1216 FORGE_STATE=$FORGE_ROOT/.forge FORGE_EVENTS_PORT=$port node "$T_HOME/lib/events-server.ts" >"$T/events.log" 2>&1 &1317 T_PIDS="$T_PIDS $!"14-u=http://127.0.0.1:$port/events18+es=http://127.0.0.1:$port/events1519 n=016-until curl -s -o /dev/null "$u/x/y/" || [ "$n" -ge 50 ]; do sleep 0.1; n=$((n + 1)); done20+until curl -s -o /dev/null "$es/x/y/" || [ "$n" -ge 50 ]; do sleep 0.1; n=$((n + 1)); done1721 18-code() { curl -s -o /dev/null -w '%{http_code}' --max-time 1 "$1" || true; }19-eq "$(code "$u/dami/secret/")" 40420-eq "$(code "$u/dami/nope/")" 40421-eq "$(code "$u/dami/open/")" 20022-eq "$(code "$u/")" 20022+code() { curl -s -o /dev/null -w '%{http_code}' --max-time 1 "$@" || true; }23+eq "$(code "$es/dami/secret/")" 40424+eq "$(code "$es/dami/nope/")" 40425+eq "$(code "$es/dami/open/")" 20026+eq "$(code "$es/")" 2002327 24-curl -sN --max-time 4 "$u/" >"$T/all" &28+curl -sN --max-time 4 "$es/" >"$T/all" &2529 all=$!26-curl -sN --max-time 4 "$u/dami/open/" >"$T/open" &30+curl -sN --max-time 4 "$es/dami/open/" >"$T/open" &2731 one=$!2832 T_PIDS="$T_PIDS $all $one"2933 sleep 1
@@ -42,4 +46,35 @@ lacks "$T/all" 'dami/secret'
4246 lacks "$T/all" 'refs/heads/after'4347 has "$T/open" '"refs":"refs/heads/before"'4448 lacks "$T/open" 'refs/heads/after'45-eq "$(code "$u/dami/open/")" 40449+eq "$(code "$es/dami/open/")" 40450+51+# Signed in: a reader hears it, nobody else does.52+export FORGE_BASE_URL=https://forge.test53+private_server54+ok ssh_as dami readers dami/secret add ana55+signin() {56+ c=$(ssh_as "$1" passkey | sed -n 's/^ .*#enroll=//p')57+ node "$T_HOME/test/authenticator.ts" enroll "$PRIV" "$c" "$T/$1.key"58+}59+ana=$(signin ana) || fail "ana"60+cat=$(signin cat) || fail "cat"61+eq "$(code -H "Cookie: __Host-forge=$ana" "$es/dami/secret/")" 20062+eq "$(code -H "Cookie: __Host-forge=$cat" "$es/dami/secret/")" 40463+eq "$(code -H 'Cookie: __Host-forge=nonsense' "$es/dami/secret/")" 40464+eq "$(code -H "Cookie: __Host-forge=$ana" "$es/dami/nope/")" 40465+curl -sN --max-time 4 -H "Cookie: __Host-forge=$ana" "$es/dami/secret/" >"$T/ana" &66+one=$!67+curl -sN --max-time 4 -H "Cookie: __Host-forge=$cat" "$es/" >"$T/cat" &68+all=$!69+T_PIDS="$T_PIDS $one $all"70+sleep 171+push dami/secret for-readers ',"private":true'72+sleep 173+ok ssh_as dami readers dami/secret rm ana74+push dami/secret after-rm ',"private":true'75+if wait "$one"; then :; else fail "ana's stream of dami/secret wasn't closed (curl exited $?)"; fi76+wait "$all" || true77+has "$T/ana" '"refs":"refs/heads/for-readers"'78+lacks "$T/ana" 'after-rm'79+lacks "$T/cat" 'dami/secret'80+eq "$(code -H "Cookie: __Host-forge=$ana" "$es/dami/secret/")" 404