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- commit
- 81d803a9b9d79d8d9c7ab0cea86a0ed6fec7db19
- parent
- 577d58d35085c0d554dcf9b0ede9a81a892b96bc
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 83s, signed by cat
- kind
- feat risk score 1.1 of 3, Jev's estimate
- review
- #18 People: collaborators, who, invites and users
ci attestation
forge-attestation 1 type ci commit 81d803a9b9d79d8d9c7ab0cea86a0ed6fec7db19 tree 1ad54cdf47b060ac2c677e834e00846555fd8a5f script .forge/ci 2dbcda6e044dc4b05503abde8369e2528413d3c7 result pass exit 0 started 2026-10-19T04:14:51Z duration 83s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:3277bb4acb9768429bd3dcea30e38538f1852039045cacfe1c1de3cfa7cf93b9 274 lines |pushed wip/i18n |5338ae9 running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |5338ae9 pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |dee2364 running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |dee2364 fail in 0s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |57e698d running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |57e698d 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 31861ee -> /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 |b5ad12f running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |b5ad12f pass in 0s, signed |pushed main |e3ecc6c running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |e3ecc6c 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 c3c83eb -> /sites/dami/forge/guide/ |(node:65731) 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.1mqNjC/wt/demo/root/.forge/www |(node:65859) 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.1mqNjC/wt/demo/root/.forge/www |(node:66000) 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.1mqNjC/wt/demo/root/.forge/www |(node:66140) 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.1mqNjC/wt/demo/root/.forge/www |(node:66825) 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.1mqNjC/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 | 2 | ++ |
| M | bin/ | 2 | ++ |
| M | lib/ | 25 | +++++++++++++++++--- |
| A | test/ | 38 | ++++++++++++++++++++++++++++++ |
@@ -159,6 +159,7 @@ a link that lets them give it themselves:
159159 forge user add ana 'ssh-ed25519 AAAA... ana@laptop'160160 forge user invite fox [--ttl 7d] [--repo dami/notes --as reader|collaborator]161161 forge user invites [rm fox] # invites not taken up yet; rm makes a link dead162+forge users # everyone: keys, passkeys and tokens; then the invites162163 ```163164 164165 `invite` holds the name (nobody can be added or invited as it meanwhile)@@ -901,6 +902,7 @@ On slate, everything lives in `/srv/forge` (`src/`, `data/`, `forge.env`):
901902 deploy/slate/deploy.sh # ship HEAD (git archive), docker compose up -d --build902903 ssh slate docker exec forge forge-admin user add ana 'ssh-ed25519 AAAA...'903904 ssh slate docker exec forge forge-admin user invite ana --repo dami/notes --as collaborator # a link instead905+ssh slate docker exec forge forge-admin users904906 ```905907 906908 `deploy.sh [--force] [<commit>]` ships a commit, `HEAD` by default, exported@@ -23,6 +23,7 @@
2323 # forge user add <name> <pubkey-file|key|-> (user rm <name> revokes keys)2424 # forge user invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator] a one-time link to join2525 # forge user invites [rm <name>] invites not taken up yet26+# forge users everyone: keys, passkeys; and who is invited2627 # forge readers|collaborators <owner>/<repo> [add|rm <user>] who may read or push a repo2728 # forge keys sync2829 # forge repo new [--private] <owner>/<name> [description]@@ -3720,6 +3721,7 @@ server)
37203721 esac37213722 ;;37223723 user) cmd_user "$@" ;;3724+users) [ $# -eq 0 ] || die "usage: forge users"; load_config; node "$FORGE_HOME/lib/identity.ts" users ;;37233725 readers | collaborators) cmd_names "$cmd" "$@" ;;37243726 keys) [ "${1:-}" = sync ] || die "usage: forge keys sync"; cmd_keys_sync ;;37253727 repo) cmd_repo "$@" ;;@@ -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`);@@ -0,0 +1,38 @@
1+# forge users lists everyone with their keys, passkeys and tokens, then the2+# invites not taken up; ssh who lists a repo's owner, collaborators and3+# readers, for anyone who may read it.4+. "$(dirname "$0")/../lib.sh"5+user dami6+user ana7+user bob8+ssh-keygen -q -t ed25519 -N '' -C ana2 -f "$T/keys/ana2"9+ok forge user add ana "$T/keys/ana2.pub"10+export FORGE_BASE_URL=https://forge.test11+private_server12+signin ana >/dev/null || fail "ana's passkey"13+ok ssh_as bob token14+repo --private dami/notes15+ok forge user invite fox --repo dami/notes --as reader16+17+ok forge users18+eq "$(output)" "user keys passkeys tokens19+ana 2 1 020+bob 1 0 121+dami 1 0 022+23+invited made until grants24+fox 2026-09-01 00:00 2026-09-08 00:00 reader of dami/notes"25+26+ok ssh_as dami collaborators dami/notes add ana27+ok ssh_as dami readers dami/notes add bob28+for u in dami ana bob; do29+ ok ssh_as "$u" who dami/notes30+ eq "$(output)" "owner dami31+collaborator ana32+reader bob"33+done34+repo dami/open35+ok ssh_as bob who dami/open36+eq "$(output)" "owner dami37+(it is public: anyone can read it; readers count once it is private)"38+fails_with "usage: who" ssh_as bob who