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.- commit
- 577d58d35085c0d554dcf9b0ede9a81a892b96bc
- parent
- 294d4fa920a1bc4cee21bc98c4218030163f305c
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 82s, signed by cat
- kind
- feat risk score 1.8 of 3, Jev's estimate
- review
- #18 People: collaborators, who, invites and users
ci attestation
forge-attestation 1 type ci commit 577d58d35085c0d554dcf9b0ede9a81a892b96bc tree d796a426bb9cd868be5687882b18ea0b891ffc30 script .forge/ci 2dbcda6e044dc4b05503abde8369e2528413d3c7 result pass exit 0 started 2026-10-19T04:13:28Z duration 82s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:6415c6a51f90f9125367d3cd8eca1f2efa149f9d85d681df94a84ea0625266cc 273 lines |pushed wip/i18n |37f6cd2 running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |37f6cd2 pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |b890761 running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |b890761 fail in 0s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |01924f4 running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |01924f4 pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed 7d8750d -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |946ced5 running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |946ced5 pass in 0s, signed |pushed main |e06b3cc running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |e06b3cc pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/ |site guide: deployed 934638b -> /sites/dami/forge/guide/ |(node:72482) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.DUGYRP/wt/demo/root/.forge/www |(node:72609) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.DUGYRP/wt/demo/root/.forge/www |(node:72750) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.DUGYRP/wt/demo/root/.forge/www |(node:72890) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.DUGYRP/wt/demo/root/.forge/www |(node:73557) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.DUGYRP/wt/demo/root/.forge/www |234 of 234 pages render the same without 11ty |guide: 8 files, links ok |ok
Signature: good, cat
| M | README.md | 31 | +++++- |
| M | bin/ | 119 | +++++++++++++++++++++-- |
| M | lib/ | 154 | +++++++++++++++++++++++++++--- |
| M | lib/ | 64 | +++++++++++- |
| M | site/ | 17 | +++- |
| M | site/ | 5 | +- |
| M | site/ | 111 | ++++++++++++++++------ |
| A | test/ | 42 | ++++++++ |
| A | test/ | 42 | ++++++++ |
| A | test/ | 59 | +++++++++++ |
@@ -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 @@ -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() {@@ -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`);@@ -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']));@@ -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 %}@@ -69,8 +69,9 @@
6969 <input class="input{% if mono %} mono{% endif %}" aria-label="{{ label }}"{% if value %} value="{{ value }}"{% endif %}{% if attrs %} {{ attrs | safe }}{% endif %}>7070 {%- endmacro %}7171 72-{% macro textarea(label, placeholder = '', attrs = '') -%}73-<textarea class="input" aria-label="{{ label }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if attrs %} {{ attrs | safe }}{% endif %}></textarea>72+{# A box of lines of text. mono: for what is copied, like a key. #}73+{% macro textarea(label, placeholder = '', attrs = '', mono = false) -%}74+<textarea class="input{% if mono %} mono{% endif %}" aria-label="{{ label }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if attrs %} {{ attrs | safe }}{% endif %}></textarea>7475 {%- endmacro %}7576 7677 {# A panel: where content sits (lists, file viewers, READMEs, diffs,@@ -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 })();@@ -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 '@@ -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"@@ -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