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

cat description

collaborators and who over ssh (and forge readers|collaborators for the operator); forge user invite: a one-time /login/#invite= link that takes an SSH key, adds the user as user add does, optionally grants a repo, then offers a passkey; forge user invites; forge users. SSH keys are now held to one line, and a key already on the forge is refused.

commits3oldest firstcombined diff 14 files, +803 -90
discussion0 comments

No comments yet: forge comment 18 adds one.

1/3 Collaborators by ssh, and who reads and writes a repo

Write access to a repo was the names in <repo>.git/forge/collaborators,
edited by hand inside the container; read access already had an ssh
command (readers). Now collaborators has one too, of the same shape:

    ssh git@src.rest collaborators cat/012 add fox   # rm takes it back; no argument lists them

Only the owner changes the list. Its collaborators can see it but not
change it, so nobody makes themselves the owner's equal; a collaborator
still chooses readers, as before. The name must be a user, not the owner,
and adding twice adds once. readers and collaborators share one function
(names), and the operator has both for any repo as forge readers and
forge collaborators, which an invite will use.

ssh who <owner>/<repo> lists the owner, collaborators and readers, a
role and a name a line, for anyone who can read the repo; to anyone else
a private repo's answer is a missing one's, byte for byte.
5 files changed, +171 -19
MREADME.md13++++
Mbin/forge93++++++++++++++++++++++++------
Atest/scenarios/people-collaborators-owner.sh43++++++++++++++
Atest/scenarios/people-collaborators-push.sh26++++++++
Atest/scenarios/people-who-private.sh15+++++
M README.md +13 -0
M bin/forge +74 -19
@@ -21,6 +21,7 @@
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 readers|collaborators <owner>/<repo> [add|rm <user>] who may read or push a repo2425 # forge keys sync2526 # forge repo new [--private] <owner>/<name> [description]2627 # forge repo import [--private] <owner>/<name> <url> [description] (server-side fetch)
@@ -1030,7 +1031,7 @@ cmd_server_init() {
10301031 # FORGE_SITES_URL=/sites/{owner}/{repo}/{site}/10311032 # FORGE_PREVIEWS_URL=/previews/{owner}/{repo}/{site}/{branch}/10321033 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; do1034+ 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; do10341035 printf '#!/bin/sh\nexec "%s" shell-cmd %s "$@"\n' "$FORGE_BIN" "$c" >"$FORGE_ROOT/git-shell-commands/$c"10351036 chmod +x "$FORGE_ROOT/git-shell-commands/$c"10361037 done
@@ -1862,36 +1863,87 @@ sc_public() {
18621863 echo "$1 is public: anyone can clone it, and its pages and sites are back once the build finishes."18631864 }18641865 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 ]; then1866+# names readers|collaborators <owner>/<repo> [add|rm <user>]: RDIR's1867+# forge/readers or forge/collaborators, one name a line: the list, or a1868+# user of the forge added or taken off it. For the ssh commands below and1869+# the operator's forge readers and forge collaborators.1870+names() {1871+ k=$1 slug=$2 f=$RDIR/forge/$11872+ if [ $# -eq 2 ]; then18721873 if [ -f "$f" ]; then cat "$f"; fi18731874 return 018741875 fi1875- who=$31876- case $2 in1877- add)1876+ who=$41877+ case $k:$3 in1878+ *:add)18781879 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; fi1880+ [ "$k" = readers ] || [ "$who" != "${slug%%/*}" ] || die "$who owns $slug"1881+ if [ -f "$f" ] && grep -qxF -- "$who" "$f"; then1882+ if [ "$k" = readers ]; then echo "$who already reads $slug"; else echo "$who already collaborates on $slug"; fi1883+ return 01884+ fi18801885 mkdir -p "$RDIR/forge"18811886 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"; fi1887+ if [ "$k" = collaborators ]; then echo "$who collaborates on $slug: they can push anything to it, and read it while it is private"1888+ elif [ -f "$RDIR/git-daemon-export-ok" ]; then echo "$who reads $slug (anyone can while it is public; this counts once it is private)"1889+ else echo "$who can read $slug"; fi18841890 ;;1885- rm)1886- [ -f "$f" ] && grep -qxF -- "$who" "$f" || die "$who is not a reader of $1"1891+ readers:rm | collaborators:rm)1892+ [ -f "$f" ] && grep -qxF -- "$who" "$f" || die "$who is not a ${k%s} of $slug"18871893 grep -vxF -- "$who" "$f" >"$f.tmp" || true18881894 mv "$f.tmp" "$f"1889- echo "$who no longer reads $1 (a clone they have stays theirs)"1895+ if [ "$k" = readers ]; then echo "$who no longer reads $slug (a clone they have stays theirs)"1896+ else echo "$who no longer collaborates on $slug: they push only branches under $who/ now, like anyone who reads it"; fi18901897 ;;1891- *) die "usage: readers <owner>/<repo> [add|rm <user>]" ;;1898+ *) die "usage: $k <owner>/<repo> [add|rm <user>]" ;;18921899 esac18931900 }18941901 1902+# readers <owner>/<repo> [add|rm <user>]: who may read it while it is1903+# private, besides its owner and collaborators, who choose them.1904+sc_readers() {1905+ [ $# -eq 1 ] || [ $# -eq 3 ] || die "usage: readers <owner>/<repo> [add|rm <user>]"1906+ open_admin "$1"1907+ names readers "$@"1908+}1909+1910+# collaborators <owner>/<repo> [add|rm <user>]: who may push anything to1911+# it (main, tags, sites, releases) and read it, besides its owner. Its1912+# collaborators see the list, and only its owner changes it, so nobody1913+# makes themselves the owner's equal.1914+sc_collaborators() {1915+ [ $# -eq 1 ] || [ $# -eq 3 ] || die "usage: collaborators <owner>/<repo> [add|rm <user>]"1916+ open_admin "$1"1917+ [ $# -eq 1 ] || [ "$user" = "${1%%/*}" ] || die "only ${1%%/*} can change who collaborates on $1"1918+ names collaborators "$@"1919+}1920+1921+# forge readers|collaborators <owner>/<repo> [add|rm <user>]: the same1922+# lists, for the operator, whoever owns the repo.1923+cmd_names() {1924+ k=$11925+ shift1926+ [ $# -eq 1 ] || [ $# -eq 3 ] || die "usage: forge $k <owner>/<repo> [add|rm <user>]"1927+ load_config1928+ o=${1%%/*} n=${1#*/}1929+ valid_name "$o" && valid_name "$n" && [ "$o/$n" = "$1" ] && [ -f "$FORGE_ROOT/$1.git/HEAD" ] || die "no repo $1"1930+ RDIR=$FORGE_ROOT/$1.git1931+ names "$k" "$@"1932+}1933+1934+# who <owner>/<repo>: its owner, collaborators and readers, a role and a1935+# name a line, for anyone who may read it; to anyone else a private repo1936+# isn't there (open_repo).1937+sc_who() {1938+ [ $# -eq 1 ] || die "usage: who <owner>/<repo>"1939+ open_repo "$1"1940+ printf '%-13s %s\n' owner "${1%%/*}"1941+ for k in collaborators readers; do1942+ 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; fi1943+ done1944+ if [ -f "$RDIR/git-daemon-export-ok" ]; then echo "(it is public: anyone can read it; readers count once it is private)"; fi1945+}1946+18951947 # rm <owner>/<repo> --yes: its owner removes it (repo_rm); collaborators18961948 # can't. Without --yes it only says what would go. A repo you can't read18971949 # isn't there (open_repo).
@@ -2000,6 +2052,8 @@ cmd_shell_cmd() {
20002052 private) sc_private "$@" ;;20012053 public) sc_public "$@" ;;20022054 readers) sc_readers "$@" ;;2055+ collaborators) sc_collaborators "$@" ;;2056+ who) sc_who "$@" ;;20032057 release) sc_release "$@" ;;20042058 rm) sc_rm "$@" ;;20052059 passkey | passkeys | sessions | tokens) credentials "$c" "$@" ;;
@@ -2014,7 +2068,7 @@ cmd_shell_cmd() {
20142068 [ -z "$r" ] || open_repo "$r"20152069 credentials token "$@"20162070 ;;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" ;;2071+ *) 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" ;;20182072 esac20192073 }20202074
@@ -3571,6 +3625,7 @@ server)
35713625 esac35723626 ;;35733627 user) cmd_user "$@" ;;3628+readers | collaborators) cmd_names "$cmd" "$@" ;;35743629 keys) [ "${1:-}" = sync ] || die "usage: forge keys sync"; cmd_keys_sync ;;35753630 repo) cmd_repo "$@" ;;35763631 build) cmd_build "$@" ;;
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-who-private.sh +15 -0

2/3 Invite someone by a link: forge user invite

Adding a user meant getting their SSH public key out of band and running
forge user add with it. Now the operator can send a link instead:

    forge user invite fox [--ttl 7d] [--repo cat/012 --as collaborator]
    forge user invites [rm fox]

invite holds the name (user add and another invite refuse it meanwhile)
and prints https://<domain>/login/#invite=<code>, which works once, for 7
days unless --ttl (at most 30d). Codes are kept as identity.db keeps
enrollment codes: only their SHA-256, in a new invites table, expiring,
used up in one transaction; user rm and purge clear them too.

On /login/ the link shows whose name it holds and takes an SSH public key.
The private server hands it to forge user accept, which checks the key
first (so a bad one leaves the link working), uses the invite up, and adds
the key the way user add does: the line in keys, the home directory, keys
sync; then, with --repo, forge readers or forge collaborators. The answer
carries an enrollment code, so the same page goes on to add a passkey
with the flow ssh passkey links use. Nobody is signed in by an invite.
The posts come only from the forge's own origin, at most 30 a minute
across the forge, and one accept at a time, since each writes keys.

A key from the web must not be able to add lines of its own to keys, so
key_ok holds every key, user add's too, to one line: a type, base64 and
an optional comment, that ssh-keygen reads where it is installed. A key
already on the forge is refused, so it can't sign as two people.
10 files changed, +573 -71
MREADME.md31+++++-
Mbin/forge119+++++++++++++++++++++--
Mlib/identity.ts154+++++++++++++++++++++++++++---
Mlib/private-server.ts64+++++++++++-
Msite/_includes/login.njk17+++-
Msite/_includes/ui.njk5+-
Msite/assets/login.ts111++++++++++++++++------
Atest/scenarios/people-invite-bad-key.sh42++++++++
Atest/scenarios/people-invite-expired.sh42++++++++
Atest/scenarios/people-invite-once.sh59+++++++++++
M README.md +28 -3
@@ -150,6 +150,30 @@ as a missing one's to everyone else. The operator has the same lists for
150150 any repo: `forge collaborators <owner>/<repo> [add|rm <user>]`, and `forge151151 readers`.152152 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+```163+164+`invite` holds the name (nobody can be added or invited as it meanwhile)165+and prints `https://<domain>/login/#invite=<code>`, which works once, for 7166+days unless `--ttl` (at most 30d). The page it opens takes one SSH public167+key (`ssh-*`, `ecdsa-*` or `sk-*`, on one line, one ssh-keygen reads and168+nobody on the forge has already), then does what `user add` does: the line169+in keys, the home directory, `keys sync`. With `--repo`, the new user is170+that repo's reader or collaborator too. Then the same page offers to add a171+passkey, as the link `ssh passkey` prints does. The code is in the URL's172+fragment and identity.db keeps only its hash, like enrollment codes; the173+posts come only from the forge's own page (Origin), at most 30 a minute174+across the forge, one taking a key at a time. `user add` checks a key the175+same way, so no key adds a second line to keys.176+153177 `ssh git@git.example.com new <name>` creates a repo under your name, and so154178 does pushing to one that doesn't exist yet: `forge shell` sees the push before155179 git does, creates the repo the same way (same name rules, hooks installed),
@@ -277,8 +301,8 @@ ssh git@git.example.com tokens [rm <id>]
277301 `git clone https://dami:<token>@git.example.com/dami/notes.git`, a278302 credential helper, or the token alone as the user name.279303 280-identity.db keeps only SHA-256 hashes of cookies, tokens and enrollment281-codes, and every credential stops working when its user's keys go (`forge304+identity.db keeps only SHA-256 hashes of cookies, tokens, enrollment305+and invite codes, and every credential stops working when its user's keys go (`forge282306 user rm` deletes them too). Its SQL is the plain subset Turso runs as well.283307 HTTPS clones of a repo without `git-daemon-export-ok` go through Caddy's284308 `forward_auth`: the private server answers 401 without a token (so git sends
@@ -876,6 +900,7 @@ On slate, everything lives in `/srv/forge` (`src/`, `data/`, `forge.env`):
876900 ```sh877901 deploy/slate/deploy.sh # ship HEAD (git archive), docker compose up -d --build878902 ssh slate docker exec forge forge-admin user add ana 'ssh-ed25519 AAAA...'903+ssh slate docker exec forge forge-admin user invite ana --repo dami/notes --as collaborator # a link instead879904 ```880905 881906 `deploy.sh [--force] [<commit>]` ships a commit, `HEAD` by default, exported
@@ -1398,7 +1423,7 @@ may be missing needs an explicit check or `or ''`.
13981423 | `/<owner>/<repo>/releases/download/<tag>/<file>` | a release file; `latest/` for the newest release's |13991424 | `/<owner>/<repo>/insights/` | charts from the lake; `insights.json` and Parquet files next to it |14001425 | `/insights/` | the same for the whole forge, and a table by repo |1401-| `/login/` | sign in with a passkey, add one (`#enroll=<code>`), sign out; `whoami` says who is signed in, for review pages |1426+| `/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 |14021427 | `/private/` | the private repos you can read |14031428 | `/sites/<owner>/<repo>/<name>/` | a deployed site |14041429
M bin/forge +107 -12
@@ -21,6 +21,8 @@
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 yet2426 # forge readers|collaborators <owner>/<repo> [add|rm <user>] who may read or push a repo2527 # forge keys sync2628 # forge repo new [--private] <owner>/<name> [description]
@@ -1228,8 +1230,50 @@ cmd_keys_sync() {
12281230 mv "$as" "$FORGE_STATE/allowed_signers"12291231 }12301232 1233+# key_ok <key>: one SSH public key, on one line: its type, its base64 and1234+# maybe a comment, which ssh-keygen reads as a key where it is installed.1235+# A key with a newline in it would add lines of its own to keys, under any1236+# name, so a key from the web (user accept) is held to this as well.1237+key_ok() {1238+ case $1 in ssh-* | ecdsa-* | sk-*) ;; *) return 1 ;; esac1239+ [ "${#1}" -le 8192 ] && [ "$(printf '%s\n' "$1" | wc -l)" -eq 1 ] || return 11240+ printf '%s\n' "$1" | LC_ALL=C grep -Eq '^[a-z0-9@.-]+ [A-Za-z0-9+/]+={0,3}( [^[:cntrl:]]*)?$' || return 11241+ command -v ssh-keygen >/dev/null 2>&1 || return 01242+ kf=$(mktemp "${TMPDIR:-/tmp}/forge-key.XXXXXX")1243+ printf '%s\n' "$1" >"$kf"1244+ kr=01245+ ssh-keygen -l -f "$kf" >/dev/null 2>&1 || kr=11246+ rm -f "$kf"1247+ return "$kr"1248+}1249+1250+# key_owner <key>: whose the key is on this forge already, if anyone's.1251+key_owner() {1252+ 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 } } }'1253+}1254+1255+# user_add <name> <key>: the name's line in keys, its home, and keys sync;1256+# what forge user add and an accepted invite (user accept) do.1257+user_add() {1258+ key_ok "$2" || die "not an SSH public key (one line: ssh-ed25519 AAAA... [comment])"1259+ o=$(key_owner "$2")1260+ [ -z "$o" ] || die "that key is already on this forge, as $o's"1261+ printf '%s %s\n' "$1" "$2" >>"$FORGE_STATE/keys"1262+ mkdir -p "$FORGE_ROOT/$1"1263+ cmd_keys_sync1264+}1265+1266+# The name a new user may have: not taken, not one of the forge's own pages.1267+new_name() {1268+ valid_name "$1" || die "invalid user name: $1"1269+ # /login/ and /private/ are the private server's pages, not owner pages.1270+ case $1 in login | private) die "invalid user name: $1 (the forge's own /$1/ page)" ;; esac1271+ ! awk -v w="$1" '$1 == w { f = 1 } END { exit !f }' "$FORGE_STATE/keys" || die "$1 is a user already"1272+}1273+12311274 cmd_user() {1232- if [ "${1:-}" = rm ] && [ $# -eq 2 ]; then1275+ case ${1:-}:$# in1276+ rm:2)12331277 load_config12341278 grep -q "^$2 " "$FORGE_STATE/keys" || die "no user $2"12351279 grep -v "^$2 " "$FORGE_STATE/keys" >"$FORGE_STATE/keys.tmp" || true
@@ -1238,19 +1282,70 @@ cmd_user() {
12381282 # Their passkeys, sessions and tokens go with the keys.12391283 node "$FORGE_HOME/lib/identity.ts" purge "$2"12401284 say "removed $2's keys and web sign-ins (repos under $FORGE_ROOT/$2 are kept)"1241- return1285+ ;;1286+ add:3)1287+ load_config1288+ # A name already in keys gets another key; a new one must be free.1289+ awk -v w="$2" '$1 == w { f = 1 } END { exit !f }' "$FORGE_STATE/keys" || {1290+ new_name "$2"1291+ ! node "$FORGE_HOME/lib/identity.ts" invited "$2" || die "$2 is invited (forge user invites rm $2 frees the name)"1292+ }1293+ if [ "$3" = - ]; then key=$(cat); elif [ -f "$3" ]; then key=$(cat "$3"); else key=$3; fi1294+ user_add "$2" "$key"1295+ say "added $2"1296+ ;;1297+ invite:*) shift; user_invite "$@" ;;1298+ invites:*) shift; load_config; node "$FORGE_HOME/lib/identity.ts" invites "$@" ;;1299+ accept:1) user_accept ;;1300+ *) 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>]" ;;1301+ esac1302+}1303+1304+# user invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator]:1305+# a one-time link on the forge's /login/ page where whoever has it pastes1306+# an SSH public key and becomes <name>, which is held for them until then;1307+# with --repo, they are that repo's reader or collaborator too.1308+user_invite() {1309+ load_config1310+ iu="usage: forge user invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator]"1311+ [ $# -ge 1 ] || die "$iu"1312+ name=$1 life=7d repo= role=1313+ shift1314+ while [ $# -gt 0 ]; do1315+ [ $# -ge 2 ] || die "$iu"1316+ case $1 in --ttl) life=$2 ;; --repo) repo=$2 ;; --as) role=$2 ;; *) die "$iu" ;; esac1317+ shift 21318+ done1319+ new_name "$name"1320+ case ${repo:+r}:$role in :) ;; r:reader | r:collaborator) ;; *) die "$iu" ;; esac1321+ if [ -n "$repo" ]; then1322+ o=${repo%%/*} n=${repo#*/}1323+ valid_name "$o" && valid_name "$n" && [ "$o/$n" = "$repo" ] && [ -f "$FORGE_ROOT/$repo.git/HEAD" ] || die "no repo $repo"12421324 fi1243- [ "${1:-}" = add ] && [ $# -eq 3 ] || die "usage: forge user add <name> <pubkey-file|'ssh-ed25519 AAAA...'|-> | user rm <name>"1325+ node "$FORGE_HOME/lib/identity.ts" invite "$name" "$life" "${repo:--}" "${role:--}"1326+}1327+1328+# user accept: an invite's code and an SSH public key, on stdin, a line1329+# each, from /login/ (lib/private-server.ts). The key is checked before the1330+# invite is used up, so a bad one leaves the link working; then the name1331+# gets it as forge user add gives one, and the invite's repo, if any. Prints1332+# the new user's name.1333+user_accept() {12441334 load_config1245- valid_name "$2" || die "invalid user name: $2"1246- # /login/ and /private/ are the private server's pages, not owner pages.1247- case $2 in login | private) die "invalid user name: $2 (the forge's own /$2/ page)" ;; esac1248- if [ "$3" = - ]; then key=$(cat); elif [ -f "$3" ]; then key=$(cat "$3"); else key=$3; fi1249- case $key in ssh-* | ecdsa-* | sk-*) ;; *) die "not an SSH public key" ;; esac1250- printf '%s %s\n' "$2" "$key" >>"$FORGE_STATE/keys"1251- mkdir -p "$FORGE_ROOT/$2"1252- cmd_keys_sync1253- say "added $2"1335+ IFS= read -r code || die "usage: forge user accept (code and key on stdin)"1336+ key=$(cat)1337+ key_ok "$key" || die "not an SSH public key (one line: ssh-ed25519 AAAA... [comment])"1338+ o=$(key_owner "$key")1339+ [ -z "$o" ] || die "that key is already on this forge"1340+ inv=$(node "$FORGE_HOME/lib/identity.ts" accept "$code") || exit 11341+ set -- $inv1342+ [ $# -eq 3 ] || die "no invite"1343+ user_add "$1" "$key"1344+ if [ "$2" != - ]; then1345+ (cmd_names "$3s" "$2" add "$1") >/dev/null || say "forge: $1 is a user, but could not be made a $3 of $2"1346+ fi1347+ say "added $1 (from an invite)"1348+ echo "$1"12541349 }12551350 12561351 install_hooks() {
M lib/identity.ts +140 -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>2530 // 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,56 @@ 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 and invites; 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+ }663+ return fail(`unknown command ${cmd}`);664+}665+541666 if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {542667 const [cmd = '', ...args] = process.argv.slice(2);543668 const state = process.env.FORGE_STATE || path.resolve('demo/root/.forge');
@@ -550,11 +675,12 @@ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import
550675 const r = Identity.check(args[0]);551676 if (r !== 'ok') fail(`${args[0]}: ${r}`);552677 }678+ else if (['invite', 'invites', 'invited', 'accept'].includes(cmd)) process.exitCode = operatorCommand(state, cmd, args, (s) => process.stdout.write(s));553679 else if (['passkey', 'passkeys', 'sessions', 'token', 'tokens'].includes(cmd)) {554680 const user = process.env.FORGE_USER;555681 if (!user) fail('no FORGE_USER');556682 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>');683+ } else fail('usage: node lib/identity.ts passkey|passkeys|sessions|token|tokens ... | invite|invites|invited|accept ... | init | purge <user> | snapshot <file> | changed <file> | check <file>');558684 } catch (e) {559685 if (!(e instanceof Usage)) throw e;560686 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-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

3/3 forge users: everyone on the forge, and who is invited

There was no way to see who is on the forge short of reading .forge/keys.
forge users lists each user with how many SSH keys, passkeys and live
tokens they have, then the invites not taken up yet, with when they
expire and what they grant:

    user  keys  passkeys  tokens
    ana   2     1         0
    bob   1     0         1

    invited  made              until             grants
    fox      2026-09-01 00:00  2026-09-08 00:00  reader of dami/notes
4 files changed, +63 -4
MREADME.md2++
Mbin/forge2++
Mlib/identity.ts25+++++++++++++++++---
Atest/scenarios/people-users-who.sh38++++++++++++++++++++++++++++++
M README.md +2 -0
M bin/forge +2 -0
M lib/identity.ts +21 -4
@@ -26,7 +26,7 @@
2626 // node lib/identity.ts token [--repo <owner>/<repo>] [--ttl 30d] [name]2727 // node lib/identity.ts tokens [rm <id>]2828 // 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>29+// node lib/identity.ts invites [rm <name>] | invited <name> | accept <code> | users3030 // node lib/identity.ts init | purge <user> | snapshot <file> | changed <file> | check <file>3131 import fs from 'node:fs';3232 import path from 'node:path';
@@ -617,7 +617,7 @@ function sshCommand(state: string, user: string, cmd: string, args: string[], ou
617617 618618 const grants = (i: Invite): string => (i.repo ? `${i.role} of ${i.repo}` : '-');619619 620-// forge user invite and invites; and user accept's part, and user add's check.620+// forge user invite, invites, users; and user accept's part, and user add's check.621621 function operatorCommand(state: string, cmd: string, args: string[], out: (s: string) => void): number {622622 const base = (process.env.FORGE_BASE_URL || '').replace(/\/$/, '');623623 const ssh = process.env.FORGE_SSH || 'git@host';
@@ -659,6 +659,23 @@ function operatorCommand(state: string, cmd: string, args: string[], out: (s: st
659659 out(`${i!.user} ${i!.repo ?? '-'} ${i!.role ?? '-'}\n`);660660 return 0;661661 }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+ }662679 }663680 return fail(`unknown command ${cmd}`);664681 }
@@ -675,12 +692,12 @@ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import
675692 const r = Identity.check(args[0]);676693 if (r !== 'ok') fail(`${args[0]}: ${r}`);677694 }678- else if (['invite', 'invites', 'invited', 'accept'].includes(cmd)) process.exitCode = operatorCommand(state, cmd, args, (s) => process.stdout.write(s));695+ else if (['invite', 'invites', 'invited', 'accept', 'users'].includes(cmd)) process.exitCode = operatorCommand(state, cmd, args, (s) => process.stdout.write(s));679696 else if (['passkey', 'passkeys', 'sessions', 'token', 'tokens'].includes(cmd)) {680697 const user = process.env.FORGE_USER;681698 if (!user) fail('no FORGE_USER');682699 sshCommand(state, user!, cmd, args, (s) => process.stdout.write(s), (s) => process.stderr.write(s));683- } else fail('usage: node lib/identity.ts passkey|passkeys|sessions|token|tokens ... | invite|invites|invited|accept ... | 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>');684701 } catch (e) {685702 if (!(e instanceof Usage)) throw e;686703 process.stderr.write(`forge: ${e.message}\n`);
A test/scenarios/people-users-who.sh +38 -0