clone

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

People: collaborators, who, invites and users #18

14 files changed, +803 -90
MREADME.md46++++++-
Mbin/forge214++++++++++++++++++++++++++----
Mlib/identity.ts171++++++++++++++++++++++--
Mlib/private-server.ts64++++++++-
Msite/_includes/login.njk17++-
Msite/_includes/ui.njk5+-
Msite/assets/login.ts111+++++++++++----
Atest/scenarios/people-collaborators-owner.sh43++++++
Atest/scenarios/people-collaborators-push.sh26++++
Atest/scenarios/people-invite-bad-key.sh42++++++
Atest/scenarios/people-invite-expired.sh42++++++
Atest/scenarios/people-invite-once.sh59++++++++
Atest/scenarios/people-users-who.sh38+++++
Atest/scenarios/people-who-private.sh15++
M README.md +43 -3
@@ -138,6 +138,43 @@ users who can read it can push branches and previews under their own name
138138 (`ana/...`) and add CI notes, which must fast-forward. Nobody can force-push139139 `main` unless `forge.allowforce` is set.140140 141+```sh142+ssh git@git.example.com collaborators dami/notes add ana # rm to take it back; no argument lists them143+ssh git@git.example.com who dami/notes # its owner, collaborators and readers144+```145+146+Only the owner changes a repo's collaborators; its collaborators can list147+them, and no one else can. The name must be a user of the forge. `who` is148+for anyone who can read the repo, so a private repo's is the same `no repo`149+as a missing one's to everyone else. The operator has the same lists for150+any repo: `forge collaborators <owner>/<repo> [add|rm <user>]`, and `forge151+readers`.152+153+#### People154+155+A user is added by the operator, with their SSH public key, or invited by156+a link that lets them give it themselves:157+158+```sh159+forge user add ana 'ssh-ed25519 AAAA... ana@laptop'160+forge user invite fox [--ttl 7d] [--repo dami/notes --as reader|collaborator]161+forge user invites [rm fox] # invites not taken up yet; rm makes a link dead162+forge users # everyone: keys, passkeys and tokens; then the invites163+```164+165+`invite` holds the name (nobody can be added or invited as it meanwhile)166+and prints `https://<domain>/login/#invite=<code>`, which works once, for 7167+days unless `--ttl` (at most 30d). The page it opens takes one SSH public168+key (`ssh-*`, `ecdsa-*` or `sk-*`, on one line, one ssh-keygen reads and169+nobody on the forge has already), then does what `user add` does: the line170+in keys, the home directory, `keys sync`. With `--repo`, the new user is171+that repo's reader or collaborator too. Then the same page offers to add a172+passkey, as the link `ssh passkey` prints does. The code is in the URL's173+fragment and identity.db keeps only its hash, like enrollment codes; the174+posts come only from the forge's own page (Origin), at most 30 a minute175+across the forge, one taking a key at a time. `user add` checks a key the176+same way, so no key adds a second line to keys.177+141178 `ssh git@git.example.com new <name>` creates a repo under your name, and so142179 does pushing to one that doesn't exist yet: `forge shell` sees the push before143180 git does, creates the repo the same way (same name rules, hooks installed),
@@ -187,6 +224,7 @@ repos.
187224 188225 ```sh189226 ssh git@git.example.com readers dami/notes add ana # rm to take it back; no argument lists them227+ssh git@git.example.com who dami/notes # who reads it, and who may push it190228 ssh git@git.example.com private dami/greet191229 ssh git@git.example.com public dami/notes [--force]192230 ssh git@git.example.com ls # yours, and the private ones you may read
@@ -264,8 +302,8 @@ ssh git@git.example.com tokens [rm <id>]
264302 `git clone https://dami:<token>@git.example.com/dami/notes.git`, a265303 credential helper, or the token alone as the user name.266304 267-identity.db keeps only SHA-256 hashes of cookies, tokens and enrollment268-codes, and every credential stops working when its user's keys go (`forge305+identity.db keeps only SHA-256 hashes of cookies, tokens, enrollment306+and invite codes, and every credential stops working when its user's keys go (`forge269307 user rm` deletes them too). Its SQL is the plain subset Turso runs as well.270308 HTTPS clones of a repo without `git-daemon-export-ok` go through Caddy's271309 `forward_auth`: the private server answers 401 without a token (so git sends
@@ -863,6 +901,8 @@ On slate, everything lives in `/srv/forge` (`src/`, `data/`, `forge.env`):
863901 ```sh864902 deploy/slate/deploy.sh # ship HEAD (git archive), docker compose up -d --build865903 ssh slate docker exec forge forge-admin user add ana 'ssh-ed25519 AAAA...'904+ssh slate docker exec forge forge-admin user invite ana --repo dami/notes --as collaborator # a link instead905+ssh slate docker exec forge forge-admin users866906 ```867907 868908 `deploy.sh [--force] [<commit>]` ships a commit, `HEAD` by default, exported
@@ -1385,7 +1425,7 @@ may be missing needs an explicit check or `or ''`.
13851425 | `/<owner>/<repo>/releases/download/<tag>/<file>` | a release file; `latest/` for the newest release's |13861426 | `/<owner>/<repo>/insights/` | charts from the lake; `insights.json` and Parquet files next to it |13871427 | `/insights/` | the same for the whole forge, and a table by repo |1388-| `/login/` | sign in with a passkey, add one (`#enroll=<code>`), sign out; `whoami` says who is signed in, for review pages |1428+| `/login/` | sign in with a passkey, add one (`#enroll=<code>`), join from an invite (`#invite=<code>`), sign out; `whoami` says who is signed in, for review pages |13891429 | `/private/` | the private repos you can read |13901430 | `/sites/<owner>/<repo>/<name>/` | a deployed site |13911431
M bin/forge +183 -31
@@ -21,6 +21,10 @@
2121 # Server commands run as the git user (see README.md):2222 # forge server init2323 # forge user add <name> <pubkey-file|key|-> (user rm <name> revokes keys)24+# forge user invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator] a one-time link to join25+# forge user invites [rm <name>] invites not taken up yet26+# forge users everyone: keys, passkeys; and who is invited27+# forge readers|collaborators <owner>/<repo> [add|rm <user>] who may read or push a repo2428 # forge keys sync2529 # forge repo new [--private] <owner>/<name> [description]2630 # forge repo import [--private] <owner>/<name> <url> [description] (server-side fetch)
@@ -1030,7 +1034,7 @@ cmd_server_init() {
10301034 # FORGE_SITES_URL=/sites/{owner}/{repo}/{site}/10311035 # FORGE_PREVIEWS_URL=/previews/{owner}/{repo}/{site}/{branch}/10321036 EOF1033- for c in new ls whoami signers site-url reviews comment approve approvals land private public readers release rm passkey passkeys sessions token tokens help; do1037+ for c in new ls whoami signers site-url reviews comment approve approvals land private public readers collaborators who release rm passkey passkeys sessions token tokens help; do10341038 printf '#!/bin/sh\nexec "%s" shell-cmd %s "$@"\n' "$FORGE_BIN" "$c" >"$FORGE_ROOT/git-shell-commands/$c"10351039 chmod +x "$FORGE_ROOT/git-shell-commands/$c"10361040 done
@@ -1227,8 +1231,50 @@ cmd_keys_sync() {
12271231 mv "$as" "$FORGE_STATE/allowed_signers"12281232 }12291233 1234+# key_ok <key>: one SSH public key, on one line: its type, its base64 and1235+# maybe a comment, which ssh-keygen reads as a key where it is installed.1236+# A key with a newline in it would add lines of its own to keys, under any1237+# name, so a key from the web (user accept) is held to this as well.1238+key_ok() {1239+ case $1 in ssh-* | ecdsa-* | sk-*) ;; *) return 1 ;; esac1240+ [ "${#1}" -le 8192 ] && [ "$(printf '%s\n' "$1" | wc -l)" -eq 1 ] || return 11241+ printf '%s\n' "$1" | LC_ALL=C grep -Eq '^[a-z0-9@.-]+ [A-Za-z0-9+/]+={0,3}( [^[:cntrl:]]*)?$' || return 11242+ command -v ssh-keygen >/dev/null 2>&1 || return 01243+ kf=$(mktemp "${TMPDIR:-/tmp}/forge-key.XXXXXX")1244+ printf '%s\n' "$1" >"$kf"1245+ kr=01246+ ssh-keygen -l -f "$kf" >/dev/null 2>&1 || kr=11247+ rm -f "$kf"1248+ return "$kr"1249+}1250+1251+# key_owner <key>: whose the key is on this forge already, if anyone's.1252+key_owner() {1253+ printf '%s\n' "$1" | awk -v f="$FORGE_STATE/keys" '{ b = $2 } END { while ((getline l < f) > 0) { split(l, w, " "); if (w[3] == b) { print w[1]; exit } } }'1254+}1255+1256+# user_add <name> <key>: the name's line in keys, its home, and keys sync;1257+# what forge user add and an accepted invite (user accept) do.1258+user_add() {1259+ key_ok "$2" || die "not an SSH public key (one line: ssh-ed25519 AAAA... [comment])"1260+ o=$(key_owner "$2")1261+ [ -z "$o" ] || die "that key is already on this forge, as $o's"1262+ printf '%s %s\n' "$1" "$2" >>"$FORGE_STATE/keys"1263+ mkdir -p "$FORGE_ROOT/$1"1264+ cmd_keys_sync1265+}1266+1267+# The name a new user may have: not taken, not one of the forge's own pages.1268+new_name() {1269+ valid_name "$1" || die "invalid user name: $1"1270+ # /login/ and /private/ are the private server's pages, not owner pages.1271+ case $1 in login | private) die "invalid user name: $1 (the forge's own /$1/ page)" ;; esac1272+ ! awk -v w="$1" '$1 == w { f = 1 } END { exit !f }' "$FORGE_STATE/keys" || die "$1 is a user already"1273+}1274+12301275 cmd_user() {1231- if [ "${1:-}" = rm ] && [ $# -eq 2 ]; then1276+ case ${1:-}:$# in1277+ rm:2)12321278 load_config12331279 grep -q "^$2 " "$FORGE_STATE/keys" || die "no user $2"12341280 grep -v "^$2 " "$FORGE_STATE/keys" >"$FORGE_STATE/keys.tmp" || true
@@ -1237,19 +1283,70 @@ cmd_user() {
12371283 # Their passkeys, sessions and tokens go with the keys.12381284 node "$FORGE_HOME/lib/identity.ts" purge "$2"12391285 say "removed $2's keys and web sign-ins (repos under $FORGE_ROOT/$2 are kept)"1240- return1286+ ;;1287+ add:3)1288+ load_config1289+ # A name already in keys gets another key; a new one must be free.1290+ awk -v w="$2" '$1 == w { f = 1 } END { exit !f }' "$FORGE_STATE/keys" || {1291+ new_name "$2"1292+ ! node "$FORGE_HOME/lib/identity.ts" invited "$2" || die "$2 is invited (forge user invites rm $2 frees the name)"1293+ }1294+ if [ "$3" = - ]; then key=$(cat); elif [ -f "$3" ]; then key=$(cat "$3"); else key=$3; fi1295+ user_add "$2" "$key"1296+ say "added $2"1297+ ;;1298+ invite:*) shift; user_invite "$@" ;;1299+ invites:*) shift; load_config; node "$FORGE_HOME/lib/identity.ts" invites "$@" ;;1300+ accept:1) user_accept ;;1301+ *) die "usage: forge user add <name> <pubkey-file|'ssh-ed25519 AAAA...'|-> | rm <name> | invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator] | invites [rm <name>]" ;;1302+ esac1303+}1304+1305+# user invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator]:1306+# a one-time link on the forge's /login/ page where whoever has it pastes1307+# an SSH public key and becomes <name>, which is held for them until then;1308+# with --repo, they are that repo's reader or collaborator too.1309+user_invite() {1310+ load_config1311+ iu="usage: forge user invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator]"1312+ [ $# -ge 1 ] || die "$iu"1313+ name=$1 life=7d repo= role=1314+ shift1315+ while [ $# -gt 0 ]; do1316+ [ $# -ge 2 ] || die "$iu"1317+ case $1 in --ttl) life=$2 ;; --repo) repo=$2 ;; --as) role=$2 ;; *) die "$iu" ;; esac1318+ shift 21319+ done1320+ new_name "$name"1321+ case ${repo:+r}:$role in :) ;; r:reader | r:collaborator) ;; *) die "$iu" ;; esac1322+ if [ -n "$repo" ]; then1323+ o=${repo%%/*} n=${repo#*/}1324+ valid_name "$o" && valid_name "$n" && [ "$o/$n" = "$repo" ] && [ -f "$FORGE_ROOT/$repo.git/HEAD" ] || die "no repo $repo"12411325 fi1242- [ "${1:-}" = add ] && [ $# -eq 3 ] || die "usage: forge user add <name> <pubkey-file|'ssh-ed25519 AAAA...'|-> | user rm <name>"1326+ node "$FORGE_HOME/lib/identity.ts" invite "$name" "$life" "${repo:--}" "${role:--}"1327+}1328+1329+# user accept: an invite's code and an SSH public key, on stdin, a line1330+# each, from /login/ (lib/private-server.ts). The key is checked before the1331+# invite is used up, so a bad one leaves the link working; then the name1332+# gets it as forge user add gives one, and the invite's repo, if any. Prints1333+# the new user's name.1334+user_accept() {12431335 load_config1244- valid_name "$2" || die "invalid user name: $2"1245- # /login/ and /private/ are the private server's pages, not owner pages.1246- case $2 in login | private) die "invalid user name: $2 (the forge's own /$2/ page)" ;; esac1247- if [ "$3" = - ]; then key=$(cat); elif [ -f "$3" ]; then key=$(cat "$3"); else key=$3; fi1248- case $key in ssh-* | ecdsa-* | sk-*) ;; *) die "not an SSH public key" ;; esac1249- printf '%s %s\n' "$2" "$key" >>"$FORGE_STATE/keys"1250- mkdir -p "$FORGE_ROOT/$2"1251- cmd_keys_sync1252- say "added $2"1336+ IFS= read -r code || die "usage: forge user accept (code and key on stdin)"1337+ key=$(cat)1338+ key_ok "$key" || die "not an SSH public key (one line: ssh-ed25519 AAAA... [comment])"1339+ o=$(key_owner "$key")1340+ [ -z "$o" ] || die "that key is already on this forge"1341+ inv=$(node "$FORGE_HOME/lib/identity.ts" accept "$code") || exit 11342+ set -- $inv1343+ [ $# -eq 3 ] || die "no invite"1344+ user_add "$1" "$key"1345+ if [ "$2" != - ]; then1346+ (cmd_names "$3s" "$2" add "$1") >/dev/null || say "forge: $1 is a user, but could not be made a $3 of $2"1347+ fi1348+ say "added $1 (from an invite)"1349+ echo "$1"12531350 }12541351 12551352 install_hooks() {
@@ -1862,36 +1959,87 @@ sc_public() {
18621959 echo "$1 is public: anyone can clone it, and its pages and sites are back once the build finishes."18631960 }18641961 1865-# readers <owner>/<repo> [add|rm <user>]: who may read it while it is1866-# private, besides its owner and collaborators; forge/readers, one name a line.1867-sc_readers() {1868- [ $# -eq 1 ] || [ $# -eq 3 ] || die "usage: readers <owner>/<repo> [add|rm <user>]"1869- open_admin "$1"1870- f=$RDIR/forge/readers1871- if [ $# -eq 1 ]; then1962+# names readers|collaborators <owner>/<repo> [add|rm <user>]: RDIR's1963+# forge/readers or forge/collaborators, one name a line: the list, or a1964+# user of the forge added or taken off it. For the ssh commands below and1965+# the operator's forge readers and forge collaborators.1966+names() {1967+ k=$1 slug=$2 f=$RDIR/forge/$11968+ if [ $# -eq 2 ]; then18721969 if [ -f "$f" ]; then cat "$f"; fi18731970 return 018741971 fi1875- who=$31876- case $2 in1877- add)1972+ who=$41973+ case $k:$3 in1974+ *:add)18781975 valid_name "$who" && awk -v w="$who" '$1 == w { f = 1 } END { exit !f }' "$FORGE_STATE/keys" || die "no user $who"1879- if [ -f "$f" ] && grep -qxF -- "$who" "$f"; then echo "$who already reads $1"; return 0; fi1976+ [ "$k" = readers ] || [ "$who" != "${slug%%/*}" ] || die "$who owns $slug"1977+ if [ -f "$f" ] && grep -qxF -- "$who" "$f"; then1978+ if [ "$k" = readers ]; then echo "$who already reads $slug"; else echo "$who already collaborates on $slug"; fi1979+ return 01980+ fi18801981 mkdir -p "$RDIR/forge"18811982 printf '%s\n' "$who" >>"$f"1882- if [ -f "$RDIR/git-daemon-export-ok" ]; then echo "$who reads $1 (anyone can while it is public; this counts once it is private)"1883- else echo "$who can read $1"; fi1983+ if [ "$k" = collaborators ]; then echo "$who collaborates on $slug: they can push anything to it, and read it while it is private"1984+ elif [ -f "$RDIR/git-daemon-export-ok" ]; then echo "$who reads $slug (anyone can while it is public; this counts once it is private)"1985+ else echo "$who can read $slug"; fi18841986 ;;1885- rm)1886- [ -f "$f" ] && grep -qxF -- "$who" "$f" || die "$who is not a reader of $1"1987+ readers:rm | collaborators:rm)1988+ [ -f "$f" ] && grep -qxF -- "$who" "$f" || die "$who is not a ${k%s} of $slug"18871989 grep -vxF -- "$who" "$f" >"$f.tmp" || true18881990 mv "$f.tmp" "$f"1889- echo "$who no longer reads $1 (a clone they have stays theirs)"1991+ if [ "$k" = readers ]; then echo "$who no longer reads $slug (a clone they have stays theirs)"1992+ else echo "$who no longer collaborates on $slug: they push only branches under $who/ now, like anyone who reads it"; fi18901993 ;;1891- *) die "usage: readers <owner>/<repo> [add|rm <user>]" ;;1994+ *) die "usage: $k <owner>/<repo> [add|rm <user>]" ;;18921995 esac18931996 }18941997 1998+# readers <owner>/<repo> [add|rm <user>]: who may read it while it is1999+# private, besides its owner and collaborators, who choose them.2000+sc_readers() {2001+ [ $# -eq 1 ] || [ $# -eq 3 ] || die "usage: readers <owner>/<repo> [add|rm <user>]"2002+ open_admin "$1"2003+ names readers "$@"2004+}2005+2006+# collaborators <owner>/<repo> [add|rm <user>]: who may push anything to2007+# it (main, tags, sites, releases) and read it, besides its owner. Its2008+# collaborators see the list, and only its owner changes it, so nobody2009+# makes themselves the owner's equal.2010+sc_collaborators() {2011+ [ $# -eq 1 ] || [ $# -eq 3 ] || die "usage: collaborators <owner>/<repo> [add|rm <user>]"2012+ open_admin "$1"2013+ [ $# -eq 1 ] || [ "$user" = "${1%%/*}" ] || die "only ${1%%/*} can change who collaborates on $1"2014+ names collaborators "$@"2015+}2016+2017+# forge readers|collaborators <owner>/<repo> [add|rm <user>]: the same2018+# lists, for the operator, whoever owns the repo.2019+cmd_names() {2020+ k=$12021+ shift2022+ [ $# -eq 1 ] || [ $# -eq 3 ] || die "usage: forge $k <owner>/<repo> [add|rm <user>]"2023+ load_config2024+ o=${1%%/*} n=${1#*/}2025+ valid_name "$o" && valid_name "$n" && [ "$o/$n" = "$1" ] && [ -f "$FORGE_ROOT/$1.git/HEAD" ] || die "no repo $1"2026+ RDIR=$FORGE_ROOT/$1.git2027+ names "$k" "$@"2028+}2029+2030+# who <owner>/<repo>: its owner, collaborators and readers, a role and a2031+# name a line, for anyone who may read it; to anyone else a private repo2032+# isn't there (open_repo).2033+sc_who() {2034+ [ $# -eq 1 ] || die "usage: who <owner>/<repo>"2035+ open_repo "$1"2036+ printf '%-13s %s\n' owner "${1%%/*}"2037+ for k in collaborators readers; do2038+ if [ -f "$RDIR/forge/$k" ]; then grep -v '^$' "$RDIR/forge/$k" | while IFS= read -r w; do printf '%-13s %s\n' "${k%s}" "$w"; done; fi2039+ done2040+ if [ -f "$RDIR/git-daemon-export-ok" ]; then echo "(it is public: anyone can read it; readers count once it is private)"; fi2041+}2042+18952043 # rm <owner>/<repo> --yes: its owner removes it (repo_rm); collaborators18962044 # can't. Without --yes it only says what would go. A repo you can't read18972045 # isn't there (open_repo).
@@ -2000,6 +2148,8 @@ cmd_shell_cmd() {
20002148 private) sc_private "$@" ;;20012149 public) sc_public "$@" ;;20022150 readers) sc_readers "$@" ;;2151+ collaborators) sc_collaborators "$@" ;;2152+ who) sc_who "$@" ;;20032153 release) sc_release "$@" ;;20042154 rm) sc_rm "$@" ;;20052155 passkey | passkeys | sessions | tokens) credentials "$c" "$@" ;;
@@ -2014,7 +2164,7 @@ cmd_shell_cmd() {
20142164 [ -z "$r" ] || open_repo "$r"20152165 credentials token "$@"20162166 ;;2017- *) printf 'commands:\n new [--private] <name> [description] create %s/<name>\n ls list your repos, and private ones you may read\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals used by forge comment, approve and land\n private <owner>/<repo> only its owner, collaborators and readers can see it\n public <owner>/<repo> [--force] anyone can, once its whole history passes the secret check\n readers <owner>/<repo> [add|rm <user>] who may read it while it is private\n release ls <owner>/<repo> its releases: tag, file, size, sha256\n release get <owner>/<repo> <tag> <file> a release file, on stdout\n rm <owner>/<repo> --yes remove a repo of yours (the admin can restore it for 14 days)\n passkey [name] a one-time link that adds a passkey, to sign in on the web\n passkeys [rm <id>] your passkeys\n sessions [rm <id>|--all] where you are signed in on the web; rm signs one out, --all all\n token [--repo <owner>/<repo>] [--ttl 30d] [name] a token for curl and HTTPS clones, shown once\n tokens [rm <id>] your tokens\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user" ;;2167+ *) printf 'commands:\n new [--private] <name> [description] create %s/<name>\n ls list your repos, and private ones you may read\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals used by forge comment, approve and land\n private <owner>/<repo> only its owner, collaborators and readers can see it\n public <owner>/<repo> [--force] anyone can, once its whole history passes the secret check\n readers <owner>/<repo> [add|rm <user>] who may read it while it is private\n collaborators <owner>/<repo> [add|rm <user>] who may push anything to it; only its owner changes the list\n who <owner>/<repo> its owner, collaborators and readers\n release ls <owner>/<repo> its releases: tag, file, size, sha256\n release get <owner>/<repo> <tag> <file> a release file, on stdout\n rm <owner>/<repo> --yes remove a repo of yours (the admin can restore it for 14 days)\n passkey [name] a one-time link that adds a passkey, to sign in on the web\n passkeys [rm <id>] your passkeys\n sessions [rm <id>|--all] where you are signed in on the web; rm signs one out, --all all\n token [--repo <owner>/<repo>] [--ttl 30d] [name] a token for curl and HTTPS clones, shown once\n tokens [rm <id>] your tokens\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user" ;;20182168 esac20192169 }20202170
@@ -3571,6 +3721,8 @@ server)
35713721 esac35723722 ;;35733723 user) cmd_user "$@" ;;3724+users) [ $# -eq 0 ] || die "usage: forge users"; load_config; node "$FORGE_HOME/lib/identity.ts" users ;;3725+readers | collaborators) cmd_names "$cmd" "$@" ;;35743726 keys) [ "${1:-}" = sync ] || die "usage: forge keys sync"; cmd_keys_sync ;;35753727 repo) cmd_repo "$@" ;;35763728 build) cmd_build "$@" ;;
M lib/identity.ts +157 -14
@@ -1,12 +1,15 @@
11 // Web credentials, in one SQLite file, $FORGE_STATE/identity.db: passkeys,2-// the sessions they sign in, tokens for curl and HTTPS clones, and one-time3-// enrollment links. SSH keys stay the root of identity: a user is a name in4-// .forge/keys, and every credential here belongs to one and stops working5-// when their keys go (lib/access.ts users). New credentials come from ssh6-// commands only (passkey, token), so losing every passkey is fixed by7-// running ssh again; there are no passwords and no email.2+// the sessions they sign in, tokens for curl and HTTPS clones, one-time3+// enrollment links, and invites. SSH keys stay the root of identity: a user4+// is a name in .forge/keys, and every credential here belongs to one and5+// stops working when their keys go (lib/access.ts users). New credentials6+// come from ssh commands only (passkey, token), so losing every passkey is7+// fixed by running ssh again; there are no passwords and no email. An8+// invite (forge user invite) is the operator's: a link that holds a name9+// for someone until they open it and give an SSH key, which makes them a10+// user (bin/forge user accept).811 //9-// Secrets (session cookies, tokens, enrollment codes) are stored as SHA-25612+// Secrets (session cookies, tokens, enrollment and invite codes) are stored as SHA-2561013 // hashes only, so the file and its backups can't sign anyone in. Every row1114 // has a short random id to list it by and remove it with.1215 //
@@ -22,6 +25,8 @@
2225 // node lib/identity.ts sessions [rm <id>|--all]2326 // node lib/identity.ts token [--repo <owner>/<repo>] [--ttl 30d] [name]2427 // node lib/identity.ts tokens [rm <id>]28+// node lib/identity.ts invite <name> <ttl> <repo|-> <reader|collaborator|-> (bin/forge user invite checks them)29+// node lib/identity.ts invites [rm <name>] | invited <name> | accept <code> | users2530 // node lib/identity.ts init | purge <user> | snapshot <file> | changed <file> | check <file>2631 import fs from 'node:fs';2732 import path from 'node:path';
@@ -47,6 +52,9 @@ export const ENROLL_TTL = 10 * 60;
4752 export const SESSION_TTL = 30 * DAY;4853 export const TOKEN_TTL = 30 * DAY;4954 export const TOKEN_MAX = 365 * DAY;55+export const INVITE_TTL = 7 * DAY;56+export const INVITE_MAX = 30 * DAY;57+export const INVITE_EXPIRED = 'This invite has expired, been used or been taken back. Ask for a new one.';5058 5159 // The clock: FORGE_NOW pins it, for the demo and tests (as for review logs).5260 export const now = (): number => Number(process.env.FORGE_NOW) || Math.floor(Date.now() / 1000);
@@ -111,6 +119,15 @@ CREATE TABLE IF NOT EXISTS enrollments (
111119 created INTEGER NOT NULL,112120 expires INTEGER NOT NULL113121 );122+CREATE TABLE IF NOT EXISTS invites (123+ id TEXT PRIMARY KEY,124+ hash TEXT NOT NULL UNIQUE,125+ user TEXT NOT NULL UNIQUE,126+ repo TEXT,127+ role TEXT,128+ created INTEGER NOT NULL,129+ expires INTEGER NOT NULL130+);114131 `;115132 116133 // A passkey's public key is a COSE key: 77 bytes for ES256, about 1.3K for
@@ -156,6 +173,16 @@ export interface Enrollment {
156173 expires: number;157174 }158175 176+// An invite: the name it holds, and the repo it grants, as reader or collaborator.177+export interface Invite {178+ id: string;179+ user: string;180+ repo: string | null;181+ role: 'reader' | 'collaborator' | null;182+ created: number;183+ expires: number;184+}185+159186 export const dbPath = (state: string): string => path.join(state, 'identity.db');160187 161188 // The session cookie: host-only (__Host-), HTTPS only, out of scripts' reach,
@@ -245,7 +272,7 @@ export class Identity {
245272 }246273 247274 private prune(t: number): void {248- for (const table of ['enrollments', 'sessions', 'tokens']) this.q(`DELETE FROM ${table} WHERE expires <= ?`).run(t);275+ for (const table of ['enrollments', 'sessions', 'tokens', 'invites']) this.q(`DELETE FROM ${table} WHERE expires <= ?`).run(t);249276 }250277 251278 // ---------------------------------------------------------- enrollment
@@ -280,6 +307,54 @@ export class Identity {
280307 });281308 }282309 310+ // ---------------------------------------------------------- invites311+312+ // A one-time code that makes whoever opens the link user, once they give313+ // an SSH key; null if user has a live invite already.314+ invite(user: string, ttl: number, repo: string | null, role: Invite['role']): { code: string; row: Invite } | null {315+ const t = now();316+ const code = secret(18);317+ const row: Invite = { id: newId(), user, repo, role, created: t, expires: t + ttl };318+ return this.write(() => {319+ this.prune(t);320+ if (this.q('SELECT 1 FROM invites WHERE user = ?').get(user)) return null;321+ this.q('INSERT INTO invites (id, hash, user, repo, role, created, expires) VALUES (?, ?, ?, ?, ?, ?, ?)').run(row.id, hash(code), user, repo, role, t, row.expires);322+ return { code, row };323+ });324+ }325+326+ invitation(code: string): Invite | null {327+ const r = this.q('SELECT id, user, repo, role, created, expires FROM invites WHERE hash = ? AND expires > ?').get(hash(code), now());328+ return (r as unknown as Invite) ?? null;329+ }330+331+ invited(user: string): Invite | null {332+ const r = this.q('SELECT id, user, repo, role, created, expires FROM invites WHERE user = ? AND expires > ?').get(user, now());333+ return (r as unknown as Invite) ?? null;334+ }335+336+ invites(): Invite[] {337+ return this.q('SELECT id, user, repo, role, created, expires FROM invites WHERE expires > ? ORDER BY created, user').all(now()) as unknown as Invite[];338+ }339+340+ removeInvite(user: string): Invite | null {341+ return this.write(() => {342+ const r = this.q('SELECT id, user, repo, role, created, expires FROM invites WHERE user = ?').get(user) as unknown as Invite | undefined;343+ if (r) this.q('DELETE FROM invites WHERE id = ?').run(r.id);344+ return r ?? null;345+ });346+ }347+348+ // Use the code up: the invite it was, or null if it is dead or was used meanwhile.349+ accept(code: string): Invite | null {350+ const t = now();351+ return this.write(() => {352+ const r = this.q('SELECT id, user, repo, role, created, expires FROM invites WHERE hash = ? AND expires > ?').get(hash(code), t) as unknown as Invite | undefined;353+ if (r) this.q('DELETE FROM invites WHERE id = ?').run(r.id);354+ return r ?? null;355+ });356+ }357+283358 // ---------------------------------------------------------- passkeys284359 285360 // The user's WebAuthn user handle: random, made the first time it is needed.
@@ -407,7 +482,7 @@ export class Identity {
407482 // Everything of a user's, when their keys are removed (forge user rm).408483 purge(user: string): void {409484 this.write(() => {410- for (const table of ['passkeys', 'sessions', 'tokens', 'enrollments']) this.q(`DELETE FROM ${table} WHERE user = ?`).run(user);485+ for (const table of ['passkeys', 'sessions', 'tokens', 'enrollments', 'invites']) this.q(`DELETE FROM ${table} WHERE user = ?`).run(user);411486 this.q('DELETE FROM users WHERE name = ?').run(user);412487 });413488 }
@@ -435,12 +510,12 @@ const fail = (msg: string): never => {
435510 throw new Usage(msg);436511 };437512 438-// 30d, 12h: seconds. At most TOKEN_MAX.439-export function ttl(s: string): number {513+// 30d, 12h: seconds. At most max (a token's, unless said).514+export function ttl(s: string, what = 'token', max = TOKEN_MAX): number {440515 const m = /^([1-9][0-9]{0,4})([hd])$/.exec(s);441- if (!m) fail(`token: --ttl wants a number of days or hours, like 30d or 12h, not ${s}`);516+ if (!m) fail(`${what}: --ttl wants a number of days or hours, like 30d or 12h, not ${s}`);442517 const n = Number(m![1]) * (m![2] === 'd' ? DAY : HOUR);443- if (n > TOKEN_MAX) fail('token: --ttl is at most 365d');518+ if (n > max) fail(`${what}: --ttl is at most ${max / DAY}d`);444519 return n;445520 }446521
@@ -538,6 +613,73 @@ function sshCommand(state: string, user: string, cmd: string, args: string[], ou
538613 fail(`unknown command ${cmd}`);539614 }540615 616+// ------------------------------------------------------------- the operator's commands617+618+const grants = (i: Invite): string => (i.repo ? `${i.role} of ${i.repo}` : '-');619+620+// forge user invite, invites, users; and user accept's part, and user add's check.621+function operatorCommand(state: string, cmd: string, args: string[], out: (s: string) => void): number {622+ const base = (process.env.FORGE_BASE_URL || '').replace(/\/$/, '');623+ const ssh = process.env.FORGE_SSH || 'git@host';624+ const id = Identity.open(state)!;625+ switch (cmd) {626+ case 'invite': {627+ const [user, life, repo, role] = args;628+ if (args.length !== 4 || !['-', 'reader', 'collaborator'].includes(role)) fail('usage: invite <name> <ttl> <repo|-> <reader|collaborator|->');629+ const r = id.invite(user, ttl(life, 'invite', INVITE_MAX), repo === '-' ? null : repo, role === '-' ? null : (role as Invite['role']));630+ if (!r) fail(`${user} is invited already (forge user invites rm ${user} takes it back, for a new link)`);631+ out(`Send this to whoever should be ${user} here. It works once, until ${when(r!.row.expires)} UTC:\n\n ${base}/login/#invite=${r!.code}\n\n`);632+ if (!base) out('(FORGE_BASE_URL is not set, so that is only the path.)\n');633+ out(`On that page they paste an SSH public key, which makes them ${user} here (ssh ${ssh}), and may then add a passkey for the web.`);634+ out(r!.row.repo ? ` They are a ${r!.row.role} of ${r!.row.repo} too.\n` : '\n');635+ out(`Until then nobody else can have the name. forge user invites rm ${user} takes the link back.\n`);636+ return 0;637+ }638+ case 'invites': {639+ if (args[0] === 'rm') {640+ if (args.length !== 2) fail('usage: forge user invites [rm <name>]');641+ const r = id.removeInvite(args[1]);642+ if (!r) fail(`no invite for ${args[1]} (forge user invites lists them)`);643+ out(`took back the invite for ${r!.user}; its link is dead\n`);644+ return 0;645+ }646+ if (args.length) fail('usage: forge user invites [rm <name>]');647+ const list = id.invites();648+ if (!list.length) return out('no invites; forge user invite <name> makes one\n'), 0;649+ out(columns(['invited', 'made', 'until', 'grants'], list.map((i) => [i.user, when(i.created), when(i.expires), grants(i)])));650+ return 0;651+ }652+ case 'invited':653+ if (args.length !== 1) fail('usage: invited <name>');654+ return id.invited(args[0]) ? 0 : 1;655+ case 'accept': {656+ if (args.length !== 1) fail('usage: accept <code>');657+ const i = id.accept(args[0]);658+ if (!i) fail(INVITE_EXPIRED);659+ out(`${i!.user} ${i!.repo ?? '-'} ${i!.role ?? '-'}\n`);660+ return 0;661+ }662+ case 'users': {663+ if (args.length) fail('usage: forge users');664+ const keys = new Map<string, number>();665+ let text = '';666+ try {667+ text = fs.readFileSync(path.join(state, 'keys'), 'utf8');668+ } catch {}669+ for (const line of text.split('\n')) {670+ const name = line.trim().split(/\s+/)[0];671+ if (name && !name.startsWith('#')) keys.set(name, (keys.get(name) ?? 0) + 1);672+ }673+ const names = [...keys.keys()].sort();674+ out(columns(['user', 'keys', 'passkeys', 'tokens'], names.map((n) => [n, String(keys.get(n)), String(id.passkeys(n).length), String(id.tokens(n).length)])));675+ const list = id.invites();676+ if (list.length) out('\n' + columns(['invited', 'made', 'until', 'grants'], list.map((i) => [i.user, when(i.created), when(i.expires), grants(i)])));677+ return 0;678+ }679+ }680+ return fail(`unknown command ${cmd}`);681+}682+541683 if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {542684 const [cmd = '', ...args] = process.argv.slice(2);543685 const state = process.env.FORGE_STATE || path.resolve('demo/root/.forge');
@@ -550,11 +692,12 @@ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import
550692 const r = Identity.check(args[0]);551693 if (r !== 'ok') fail(`${args[0]}: ${r}`);552694 }695+ else if (['invite', 'invites', 'invited', 'accept', 'users'].includes(cmd)) process.exitCode = operatorCommand(state, cmd, args, (s) => process.stdout.write(s));553696 else if (['passkey', 'passkeys', 'sessions', 'token', 'tokens'].includes(cmd)) {554697 const user = process.env.FORGE_USER;555698 if (!user) fail('no FORGE_USER');556699 sshCommand(state, user!, cmd, args, (s) => process.stdout.write(s), (s) => process.stderr.write(s));557- } else fail('usage: node lib/identity.ts passkey|passkeys|sessions|token|tokens ... | init | purge <user> | snapshot <file> | changed <file> | check <file>');700+ } else fail('usage: node lib/identity.ts passkey|passkeys|sessions|token|tokens ... | invite|invites|invited|accept|users ... | init | purge <user> | snapshot <file> | changed <file> | check <file>');558701 } catch (e) {559702 if (!(e instanceof Usage)) throw e;560703 process.stderr.write(`forge: ${e.message}\n`);
M lib/private-server.ts +59 -5
@@ -16,7 +16,8 @@
1616 // isn't there, and a repo that isn't, get the same 404, byte for byte; so1717 // does any missing page of www, since Caddy sends those here too.1818 //19-// /login/ sign in, add a passkey from an ssh link, sign out (lib/login.ts)19+// /login/ sign in, add a passkey from an ssh link, sign out (lib/login.ts);20+// join from an invite's link (POST /login/invite/...)2021 // /login/whoami who is signed in, and their CSRF token, for review pages' script2122 // /private/ the private repos you can read2223 // /assets/<file> the forge's assets, for pages here before a build has shipped them
@@ -33,7 +34,7 @@ import fs from 'node:fs';
3334 import path from 'node:path';3435 import { spawn } from 'node:child_process';3536 import { fileURLToPath } from 'node:url';36-import { Identity, SESSION_TTL, TOKEN_PREFIX, cookieValue, csrfToken, sameSecret, sessionCookie, type Session, type Token } from './identity.ts';37+import { INVITE_EXPIRED, Identity, SESSION_TTL, TOKEN_PREFIX, cookieValue, csrfToken, sameSecret, sessionCookie, type Session, type Token } from './identity.ts';3738 import { canRead, isPublic, readable, repoDir, users } from './access.ts';3839 import { DOWNLOAD, DOWNLOAD_HEADERS, resolve, sendFile } from './static.ts';3940 import { Login, LoginError, agentName, readBody, relyingParty } from './login.ts';
@@ -161,11 +162,16 @@ const RUNNING_MAX = 4;
161162 const POST_MAX = 160 * 1024;162163 163164 export function shellCmd(cfg: PrivateConfig, user: string, args: string[], input: string): Promise<{ code: number | null; out: string; err: string }> {165+ return forgeCmd(cfg, ['shell-cmd', ...args], input, { FORGE_USER: user });166+}167+168+// bin/forge, for this forge, with input on stdin.169+function forgeCmd(cfg: PrivateConfig, args: string[], input: string, extra: NodeJS.ProcessEnv = {}): Promise<{ code: number | null; out: string; err: string }> {164170 return new Promise((ok, no) => {165171 // 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' };172+ const env: NodeJS.ProcessEnv = { ...process.env, FORGE_ROOT: cfg.root, FORGE_VIA: 'web', ...extra };167173 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 });174+ const p = spawn(FORGE_BIN, args, { cwd: cfg.root, env, stdio: ['pipe', 'pipe', 'pipe'], timeout: 5 * 60 * 1000 });169175 const out: Buffer[] = [];170176 const err: Buffer[] = [];171177 p.stdout.on('data', (d: Buffer) => out.push(d));
@@ -177,6 +183,14 @@ export function shellCmd(cfg: PrivateConfig, user: string, args: string[], input
177183 });178184 }179185 186+// What bin/forge said it refused: its "forge: " lines.187+const refusal = (err: string, otherwise: string): string => err.split('\n').filter((l) => l.startsWith('forge: ')).map((l) => l.slice(7)).join('\n') || otherwise;188+189+// Invites: the forge as a whole takes at most this many posts a minute190+// (the codes can't be guessed; this keeps anyone from trying), and one191+// accept at a time, since each writes .forge/keys.192+const INVITE_POSTS_PER_MINUTE = 30;193+180194 // The sign-in and list pages: no framing, scripts and styles of our own only.181195 const PAGE_HEADERS = {182196 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'private, no-store', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'same-origin',
@@ -375,7 +389,7 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
375389 running--;376390 }377391 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`;392+ const said = refusal(r.err, `${what} failed`);379393 return json(res, /^no (review|repo) /.test(said) ? 404 : 400, { error: said }, cookie(who));380394 }381395 const message = r.out.trim();
@@ -383,6 +397,44 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
383397 json(res, 200, { ok: true, message, ...(n ? { comment: Number(n) } : {}) }, cookie(who));384398 }385399 400+ // An invite's link (/login/#invite=<code>): options says whose name it401+ // holds; accept takes an SSH public key, which bin/forge user accept402+ // checks and adds the way forge user add does, using the invite up, and403+ // answers with a code for the passkey enrollment that follows on the404+ // same page (the one ssh passkey would print). Nobody is signed in by it.405+ const invitePosts: number[] = [];406+ let accepting = false;407+ async function invite(res: http.ServerResponse, what: 'options' | 'accept', posted: unknown): Promise<void> {408+ const body = (posted && typeof posted === 'object' ? posted : {}) as Record<string, unknown>;409+ const t = Date.now();410+ while (invitePosts.length && t - invitePosts[0] >= 60_000) invitePosts.shift();411+ if (invitePosts.length >= INVITE_POSTS_PER_MINUTE) return json(res, 429, { error: 'That is a lot at once: wait a minute' }, { 'Retry-After': '60' });412+ invitePosts.push(t);413+ const code = typeof body.code === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(body.code) ? body.code : '';414+ if (what === 'options') {415+ const i = code ? gate.id.invitation(code) : null;416+ if (!i) return json(res, 400, { error: INVITE_EXPIRED, expired: true });417+ return json(res, 200, { user: i.user, repo: i.repo, role: i.role });418+ }419+ const key = typeof body.key === 'string' ? body.key.trim() : '';420+ if (!key || key.length > 8192 || /[\r\n]/.test(key)) return json(res, 400, { error: 'Paste one SSH public key: the one line of your .pub file' });421+ if (!code) return json(res, 400, { error: INVITE_EXPIRED, expired: true });422+ if (accepting) return json(res, 503, { error: 'The forge is busy: try again in a moment' }, { 'Retry-After': '5' });423+ accepting = true;424+ let r;425+ try {426+ r = await forgeCmd(cfg, ['user', 'accept'], `${code}\n${key}\n`);427+ } finally {428+ accepting = false;429+ }430+ const user = r.out.trim().split('\n').pop() ?? '';431+ if (r.code !== 0 || !user) {432+ const said = refusal(r.err, 'The invite was not taken up');433+ return json(res, 400, { error: said, ...(said === INVITE_EXPIRED ? { expired: true } : {}) });434+ }435+ json(res, 200, { user, enroll: gate.id.enroll(user, '').code });436+ }437+386438 async function post(req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void> {387439 // Only the forge's own pages may post here (and sign anyone in or out).388440 if (!login || req.headers.origin !== login.rp.origin) return json(res, 403, { error: 'not from this forge' });
@@ -407,6 +459,8 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
407459 const { user, passkey } = await login.loginVerify(body);408460 return json(res, 200, { user, signal: login.signal(user) }, signedIn(req, user, passkey.id));409461 }462+ if (url.pathname === '/login/invite/options') return await invite(res, 'options', body);463+ if (url.pathname === '/login/invite/accept') return await invite(res, 'accept', body);410464 if (url.pathname === '/login/enroll/options') return json(res, 200, await login.enrollOptions(body.code));411465 if (url.pathname === '/login/enroll/verify') {412466 const { user, passkey } = await login.enrollVerify(body.code, body.response, agentName(req.headers['user-agent']));
M site/_includes/login.njk +13 -4
@@ -1,7 +1,8 @@
11 {#- /login/, which lib/private-server.ts renders on request (it isn't built):22 sign in with a passkey, add one from the link that ssh <host> passkey3- prints (/login/#enroll=<code>; login.ts does both), and, signed in, sign4- out. Data: forge, p (title), user (or ''), csrf, signal (JSON or ''),3+ prints (/login/#enroll=<code>; login.ts does both), join from the link4+ forge user invite prints (/login/#invite=<code>: an SSH key, then a5+ passkey), and, signed in, sign out. Data: forge, p (title), user (or ''), csrf, signal (JSON or ''),56 next, ready (sign-in is set up: FORGE_BASE_URL), host. -#}67 {% extends "base.njk" %}78 {% import "ui.njk" as ui %}
@@ -19,6 +20,7 @@
1920 .login-status { min-height: 1.4em; }2021 .login-status.bad { color: var(--fail); }2122 .login form { display: inline; }23+.login .input { max-width: 36em; }2224 {% endcss %}2325 <div class="panel login" id="login" data-next="{{ next }}"{% if signal %} data-signal="{{ signal }}"{% endif %}><div class="panel-body">2426 {% if user %}
@@ -34,13 +36,20 @@
3436 <p>Private repos show their pages to their readers. Sign in with a passkey you have added to see them.</p>3537 <p>{{ ui.button('sign in with a passkey', true, 'id="signin"') }}</p>3638 </div>39+<div id="invite-part" hidden>40+<h1>Join {{ host }}</h1>41+<p>You are invited as <b id="invite-user">you</b><span id="invite-grant"></span>. Your SSH key is how you get in: paste the public half, the one line of a <code>.pub</code> file such as <code>~/.ssh/id_ed25519.pub</code>. None yet? <code>ssh-keygen -t ed25519</code> makes one.</p>42+<p>{{ ui.textarea('ssh public key', 'ssh-ed25519 AAAA... you@laptop', 'id="invite-key" rows="3" spellcheck="false" autocomplete="off" autocapitalize="off" maxlength="8192"', true) }}</p>43+<p>{{ ui.button('join', true, 'id="invite-join"') }}</p>44+</div>3745 <div id="enroll-part" hidden>3846 <h1>Add a passkey</h1>39-<p>For <b id="enroll-user">you</b> on {{ host }}. Your browser, phone or password manager keeps it, and signs you in with it from now on.</p>47+<p id="joined" hidden>You are <b id="joined-user">in</b> on {{ host }}: <code>ssh {{ forge.config.ssh }} help</code> says what you can do over ssh. A passkey signs you in on the web as well, to see the pages of private repos you may read. Your browser, phone or password manager keeps it. Not now? <code>ssh {{ forge.config.ssh }} passkey</code> prints a link for one later.</p>48+<p id="enroll-for">For <b id="enroll-user">you</b> on {{ host }}. Your browser, phone or password manager keeps it, and signs you in with it from now on.</p>4049 <p>{{ ui.button('add a passkey', true, 'id="enroll"') }}</p>4150 </div>4251 <p class="login-status" id="login-status" role="status"></p>43-<p class="desc">No passkey yet, or lost them all? <code>ssh {{ forge.config.ssh }} passkey</code> prints a link that adds one: your ssh key is how you get in.</p>52+<p class="desc" id="login-help">No passkey yet, or lost them all? <code>ssh {{ forge.config.ssh }} passkey</code> prints a link that adds one: your ssh key is how you get in.</p>4453 {% endif %}4554 </div></div>4655 {% endblock %}
M site/_includes/ui.njk +3 -2
M site/assets/login.ts +80 -31
@@ -1,7 +1,8 @@
11 // Passkeys on /login/ and /private/, with @simplewebauthn/browser (the22 // webauthn asset, which defines SimpleWebAuthnBrowser). Signs in, adds a33 // passkey from the link `ssh <host> passkey` prints (/login/#enroll=<code>),4-// and, once signed in, tells the browser or password manager which of the4+// takes up an invite (/login/#invite=<code>, from forge user invite) with5+// an SSH key and then adds a passkey, and, once signed in, tells the browser or password manager which of the56 // user's passkeys the forge still accepts (the Signal API), so one removed67 // with `ssh <host> passkeys rm` stops being offered. The server's side is78 // lib/login.ts.
@@ -76,36 +77,84 @@ interface WebAuthn {
7677 }7778 });7879 79- // An enrollment link: the code is in the fragment, which never leaves the browser.80- const code = /^#enroll=([A-Za-z0-9_-]+)$/.exec(location.hash)?.[1];80+ // Add a passkey with an enrollment code: from a link ssh passkey printed,81+ // or from an invite just taken up.8182 const enroll = document.getElementById('enroll') as HTMLButtonElement | null;82- if (!code || !enroll) return;83- document.getElementById('signin-part')!.hidden = true;84- document.getElementById('enroll-part')!.hidden = false;85- let options: Record<string, unknown> | null = null;86- enroll.disabled = true;87- post('/login/enroll/options', { code }).then((o) => {88- options = o;89- document.getElementById('enroll-user')!.textContent = (o.user as { name: string }).name;90- enroll.disabled = false;91- }, (e: Error) => say(e.message, true));92- enroll.addEventListener('click', async () => {93- if (!options) return;83+ const startEnroll = (code: string): void => {84+ if (!enroll) return;85+ document.getElementById('signin-part')!.hidden = true;86+ document.getElementById('enroll-part')!.hidden = false;87+ let options: Record<string, unknown> | null = null;9488 enroll.disabled = true;95- say('');96- try {97- const response = await wa.startRegistration({ optionsJSON: options });98- say('Adding it…');99- const done = await post('/login/enroll/verify', { code, response });100- history.replaceState(null, '', '/login/');101- await signal(done.signal as Signal);102- location.assign(next());103- } catch (e) {104- if (!dismissed(e)) say((e as Error).message, true);105- if (e instanceof Failed && e.data.expired) return;106- // A challenge works once: another try needs new options.107- options = await post('/login/enroll/options', { code }).catch((x: Error) => (say(x.message, true), null));108- enroll.disabled = !options;109- }110- });89+ post('/login/enroll/options', { code }).then((o) => {90+ options = o;91+ document.getElementById('enroll-user')!.textContent = (o.user as { name: string }).name;92+ enroll.disabled = false;93+ }, (e: Error) => say(e.message, true));94+ enroll.addEventListener('click', async () => {95+ if (!options) return;96+ enroll.disabled = true;97+ say('');98+ try {99+ const response = await wa.startRegistration({ optionsJSON: options });100+ say('Adding it…');101+ const done = await post('/login/enroll/verify', { code, response });102+ history.replaceState(null, '', '/login/');103+ await signal(done.signal as Signal);104+ location.assign(next());105+ } catch (e) {106+ if (!dismissed(e)) say((e as Error).message, true);107+ if (e instanceof Failed && e.data.expired) return;108+ // A challenge works once: another try needs new options.109+ options = await post('/login/enroll/options', { code }).catch((x: Error) => (say(x.message, true), null));110+ enroll.disabled = !options;111+ }112+ });113+ };114+115+ // An invite: an SSH public key makes the invitee a user (forge user116+ // invite printed the link), and then they may add a passkey right here.117+ // Its code is in the fragment too.118+ const invite = /^#invite=([A-Za-z0-9_-]+)$/.exec(location.hash)?.[1];119+ const join = document.getElementById('invite-join') as HTMLButtonElement | null;120+ if (invite && join) {121+ const key = document.getElementById('invite-key') as HTMLTextAreaElement;122+ document.getElementById('signin-part')!.hidden = true;123+ document.getElementById('login-help')!.hidden = true;124+ document.getElementById('invite-part')!.hidden = false;125+ join.disabled = true;126+ post('/login/invite/options', { code: invite }).then((o) => {127+ document.getElementById('invite-user')!.textContent = String(o.user);128+ if (o.repo) document.getElementById('invite-grant')!.textContent = `, a ${String(o.role)} of ${String(o.repo)}`;129+ join.disabled = false;130+ key.focus();131+ }, (e: Error) => say(e.message, true));132+ join.addEventListener('click', async () => {133+ join.disabled = true;134+ say('Adding your key…');135+ try {136+ const done = await post('/login/invite/accept', { code: invite, key: key.value });137+ history.replaceState(null, '', '/login/');138+ say('');139+ document.getElementById('invite-part')!.hidden = true;140+ document.getElementById('joined-user')!.textContent = String(done.user);141+ document.getElementById('joined')!.hidden = false;142+ document.getElementById('enroll-for')!.hidden = true;143+ if (wa.browserSupportsWebAuthn()) startEnroll(String(done.enroll));144+ else {145+ document.getElementById('enroll-part')!.hidden = false;146+ enroll!.disabled = true;147+ say("This browser can't use passkeys; ssh passkey prints a link for another one.", true);148+ }149+ } catch (e) {150+ say((e as Error).message, true);151+ join.disabled = !!(e instanceof Failed && e.data.expired);152+ }153+ });154+ return;155+ }156+157+ // An enrollment link: the code is in the fragment, which never leaves the browser.158+ const code = /^#enroll=([A-Za-z0-9_-]+)$/.exec(location.hash)?.[1];159+ if (code) startEnroll(code);111160 })();
A test/scenarios/people-collaborators-owner.sh +43 -0
@@ -0,0 +1,43 @@
1+# Only a repo's owner changes its collaborators. Collaborators see the list2+# but can't change it; a reader can't see it (who shows them everyone).3+# The name must be a user and not the owner; adding twice adds once; rm of4+# someone not on it says so. The operator's forge collaborators does the5+# same for any repo.6+. "$(dirname "$0")/../lib.sh"7+user dami8+user ana9+user bob10+user fox11+repo --private dami/secret12+ok ssh_as dami collaborators dami/secret add ana13+ok ssh_as dami readers dami/secret add bob14+15+fails_with "only dami can change who collaborates on dami/secret" ssh_as ana collaborators dami/secret add fox16+fails_with "only dami can change who collaborates on dami/secret" ssh_as ana collaborators dami/secret rm ana17+ok ssh_as ana collaborators dami/secret18+eq "$(output)" ana19+fails_with "only dami and collaborators of dami/secret" ssh_as bob collaborators dami/secret20+fails_with "^forge: no repo dami/secret$" ssh_as fox collaborators dami/secret add fox21+22+ok ssh_as dami collaborators dami/secret add ana23+eq "$(output)" "ana already collaborates on dami/secret"24+eq "$(grep -c . "$FORGE_ROOT/dami/secret.git/forge/collaborators")" 125+fails_with "no user nobody" ssh_as dami collaborators dami/secret add nobody26+fails_with "no user \\.\\./x" ssh_as dami collaborators dami/secret add ../x27+fails_with "dami owns dami/secret" ssh_as dami collaborators dami/secret add dami28+fails_with "fox is not a collaborator of dami/secret" ssh_as dami collaborators dami/secret rm fox29+fails_with "usage: collaborators" ssh_as dami collaborators dami/secret drop ana30+fails_with "usage: collaborators" ssh_as dami collaborators dami/secret add31+32+ok forge collaborators dami/secret add fox33+has "$T_LOG" '^fox collaborates on dami/secret'34+ok forge collaborators dami/secret35+eq "$(output)" "ana36+fox"37+fails_with "no repo dami/nope" forge collaborators dami/nope add fox38+39+ok ssh_as dami help40+has "$T_LOG" '^ collaborators <owner>/<repo> \[add\|rm <user>\] '41+has "$T_LOG" '^ who <owner>/<repo> '42+exists "$FORGE_ROOT/git-shell-commands/collaborators"43+exists "$FORGE_ROOT/git-shell-commands/who"
A test/scenarios/people-collaborators-push.sh +26 -0
A test/scenarios/people-invite-bad-key.sh +42 -0
@@ -0,0 +1,42 @@
1+# An invite takes one SSH public key on one line: not text that isn't a2+# key, not a key with a second line (which would add a line of its own to3+# .forge/keys, as anyone), not a key someone here has already. Each is4+# refused and the link still works for a good key. forge user add is held5+# to the same.6+. "$(dirname "$0")/../lib.sh"7+user cat8+export FORGE_BASE_URL=https://forge.test9+private_server10+ssh-keygen -q -t ed25519 -N '' -C fox -f "$T/keys/fox"11+accept() {12+ node -e 'console.log(JSON.stringify({ code: process.argv[1], key: process.argv[2] }))' -- "$1" "$2" >"$T/body"13+ http -s -w '\n%{http_code}' -H 'Origin: https://forge.test' -H 'Content-Type: application/json' -d @"$T/body" "$PRIV/login/invite/accept"14+}15+ok forge user invite fox16+code=$(sed -n 's/^ .*#invite=//p' "$T_LOG")17+cp "$FORGE_ROOT/.forge/keys" "$T/keys.before"18+fox=$(cat "$T/keys/fox.pub")19+20+for bad in "hello" "ssh-ed25519 not-base64!" "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBogus fox" "-----BEGIN OPENSSH PRIVATE KEY-----"; do21+ accept "$code" "$bad" >"$T/r"22+ eq "$(tail -n 1 "$T/r")" 40023+ has "$T/r" 'not an SSH public key'24+done25+accept "$code" "$fox26+cat $fox" >"$T/r"27+eq "$(tail -n 1 "$T/r")" 40028+has "$T/r" 'Paste one SSH public key'29+accept "$code" "$(cat "$T/keys/cat.pub")" >"$T/r"30+eq "$(tail -n 1 "$T/r")" 40031+has "$T/r" 'that key is already on this forge'32+cmp "$FORGE_ROOT/.forge/keys" "$T/keys.before" || fail "a refused key changed .forge/keys"33+# Over the shell side too, where the web's own check doesn't run.34+printf '%s\n%s\ncat %s\n' "$code" "$fox" "$fox" | forge user accept >"$T_LOG" 2>&1 && fail "user accept took two lines"35+has "$T_LOG" 'not an SSH public key'36+fails_with "not an SSH public key" forge user add ana "$(printf '%s\ncat %s' "$fox" "$fox")"37+fails_with "already on this forge, as cat's" forge user add ana "$T/keys/cat.pub"38+cmp "$FORGE_ROOT/.forge/keys" "$T/keys.before" || fail "a refused key changed .forge/keys"39+40+accept "$code" "$fox" >"$T/r"41+eq "$(tail -n 1 "$T/r")" 20042+has "$FORGE_ROOT/.forge/keys" '^fox ssh-ed25519 '
A test/scenarios/people-invite-expired.sh +42 -0
@@ -0,0 +1,42 @@
1+# An invite works until its --ttl is up (7 days unless said) and not a2+# second later; one taken back (invites rm) is dead at once. Either way the3+# name is free again for another invite or forge user add.4+. "$(dirname "$0")/../lib.sh"5+user cat6+export FORGE_BASE_URL=https://forge.test7+ssh-keygen -q -t ed25519 -N '' -C fox -f "$T/keys/fox"8+accept() {9+ node -e 'console.log(JSON.stringify({ code: process.argv[1], key: process.argv[2] }))' -- "$1" "$(cat "$T/keys/fox.pub")" >"$T/body"10+ http -s -w '\n%{http_code}' -H 'Origin: https://forge.test' -H 'Content-Type: application/json' -d @"$T/body" "$PRIV/login/invite/accept"11+}12+link() { forge user invite "$@" 2>&1 | sed -n 's/^ .*#invite=//p'; }13+14+fails_with "invite: --ttl is at most 30d" forge user invite fox --ttl 31d15+fails_with "invite: --ttl wants" forge user invite fox --ttl soon16+fails_with "usage: forge user invite" forge user invite fox --as collaborator17+fails_with "usage: forge user invite" forge user invite fox --repo cat/x --as owner18+fails_with "no repo cat/x" forge user invite fox --repo cat/x --as reader19+fails_with "cat is a user already" forge user invite cat20+fails_with "invalid user name: login" forge user invite login21+22+code=$(link fox --ttl 1h)23+ok forge user invites24+has "$T_LOG" '^fox +2026-09-01 00:00 +2026-09-01 01:00 +-$'25+export FORGE_NOW=$((T_NOW + 3601))26+private_server27+accept "$code" >"$T/r"28+eq "$(tail -n 1 "$T/r")" 40029+has "$T/r" 'expired, been used or been taken back'30+lacks "$FORGE_ROOT/.forge/keys" '^fox '31+ok forge user invites32+eq "$(output)" "no invites; forge user invite <name> makes one"33+34+# Taken back; the name was free for this new invite.35+code=$(link fox)36+[ -n "$code" ] || fail "no new invite for fox after the first expired"37+ok forge user invites rm fox38+eq "$(output)" "took back the invite for fox; its link is dead"39+fails_with "no invite for fox" forge user invites rm fox40+accept "$code" >"$T/r"41+eq "$(tail -n 1 "$T/r")" 40042+ok forge user add fox "$T/keys/fox.pub"
A test/scenarios/people-invite-once.sh +59 -0
@@ -0,0 +1,59 @@
1+# An invite: forge user invite holds a name and prints a one-time link on2+# /login/; opening it, the invitee pastes an SSH public key and is that3+# user from then on (keys, home, ssh), a collaborator of the repo the4+# invite names, and may add a passkey on the same page. The link then is5+# dead. identity.db keeps only the code's hash.6+. "$(dirname "$0")/../lib.sh"7+user cat8+repo cat/0129+clone cat cat/012 "$T/w/cat"10+at "$T/w/cat" commit "first"11+ok push_as cat "$T/w/cat" origin main12+export FORGE_BASE_URL=https://forge.test13+private_server14+ssh-keygen -q -t ed25519 -N '' -C fox@laptop -f "$T/keys/fox"15+accept() {16+ node -e 'console.log(JSON.stringify({ code: process.argv[1], key: process.argv[2] }))' -- "$1" "$2" >"$T/body"17+ http -s -w '\n%{http_code}' -H 'Origin: https://forge.test' -H 'Content-Type: application/json' -d @"$T/body" "$PRIV/login/invite/accept"18+}19+20+ok forge user invite fox --repo cat/012 --as collaborator21+has "$T_LOG" '^ https://forge\.test/login/#invite=[A-Za-z0-9_-]{24}$'22+has "$T_LOG" 'works once, until 2026-09-08 '23+has "$T_LOG" 'collaborator of cat/012 too'24+code=$(sed -n 's/^ .*#invite=//p' "$T_LOG")25+if grep -aqF "$code" "$FORGE_ROOT/.forge/identity.db" "$FORGE_ROOT/.forge/identity.db-wal" 2>/dev/null; then fail "an invite code is in identity.db as it is"; fi26+# The name is held, and the link says whose it is.27+fails_with "fox is invited" forge user add fox "$T/keys/fox.pub"28+fails_with "fox is invited already" forge user invite fox29+eq "$(http -s -H 'Origin: https://forge.test' -d "{\"code\":\"$code\"}" "$PRIV/login/invite/options")" '{"user":"fox","repo":"cat/012","role":"collaborator"}'30+31+accept "$code" "$(cat "$T/keys/fox.pub")" >"$T/r"32+eq "$(tail -n 1 "$T/r")" 20033+has "$T/r" '^\{"user":"fox","enroll":"[A-Za-z0-9_-]{24}"\}$'34+enroll=$(sed -n 's/.*"enroll":"\([^"]*\)".*/\1/p' "$T/r")35+has "$FORGE_ROOT/.forge/keys" "^fox ssh-ed25519 [A-Za-z0-9+/]+ fox@laptop$"36+grep -q 'shell fox" ssh-ed25519' "$FORGE_ROOT/.ssh/authorized_keys" || fail "fox's key isn't in authorized_keys"37+exists "$FORGE_ROOT/fox"38+eq "$(cat "$FORGE_ROOT/cat/012.git/forge/collaborators")" fox39+ok ssh_as fox whoami40+eq "$(output)" fox41+clone fox cat/012 "$T/w/fox"42+at "$T/w/fox" commit "fox was here"43+ok push_as fox "$T/w/fox" origin main44+# The passkey on the same page: the enrollment code it got back.45+ok node "$T_HOME/test/authenticator.ts" enroll "$PRIV" "$enroll" "$T/fox.key"46+ok ssh_as fox passkeys47+eq "$(grep -c '^[0-9a-f]\{8\} ' "$T_LOG")" 148+49+# Once only: the link is dead, with any key.50+ssh-keygen -q -t ed25519 -N '' -C eve -f "$T/keys/eve"51+accept "$code" "$(cat "$T/keys/eve.pub")" >"$T/r"52+eq "$(tail -n 1 "$T/r")" 40053+has "$T/r" 'expired, been used or been taken back'54+eq "$(grep -c '^fox ' "$FORGE_ROOT/.forge/keys")" 155+eq "$(http -s -o /dev/null -w '%{http_code}' -H 'Origin: https://forge.test' -d "{\"code\":\"$code\"}" "$PRIV/login/invite/options")" 40056+ok forge user invites57+eq "$(output)" "no invites; forge user invite <name> makes one"58+# Not from the forge's own page: refused.59+eq "$(http -s -o /dev/null -w '%{http_code}' -H 'Origin: https://evil.test' -d '{}' "$PRIV/login/invite/accept")" 403
A test/scenarios/people-users-who.sh +38 -0
A test/scenarios/people-who-private.sh +15 -0