clone

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

Admins, and the people commands over ssh for them

cat committed c222129
Inviting someone or listing users meant forge-admin inside the container.
Now the forge has admins: the users named in forge.admins in the server's
git config, $FORGE_ROOT/.gitconfig (several values, or names split by
spaces or commas), read by one helper, is_admin <user>. Owning repos
doesn't make anyone one.

An admin runs the operator's people commands over ssh:

    ssh git@src.rest invite fox [--ttl 7d] [--repo cat/012 --as collaborator]
    ssh git@src.rest invites [rm fox]
    ssh git@src.rest users

They are the same code as forge user invite, user invites and users,
which stay for the server, and their hints name the form you used. They
live in a git-shell-commands of their own, .forge/admin (server init
writes it), and forge shell points git-shell there only for an admin's
admin command. Anyone else's goes to git-shell as any unknown command
does, so the answer is git-shell's own, byte for byte and with its exit
status, and help lists them only for admins.
commit
c22212941adb076fda008e3cf7e8ddd1ea4e6978
parent
57b0493496c24455406cb386d8bbd697ab1dab6f
author
cat <admin@dev.site>
ci
✓ passed in 84s, signed by cat
kind
feat risk score 1.5 of 3, Jev's estimate
review
#20 Admins, and the people commands over ssh for them
ci attestation
forge-attestation 1
type ci
commit c22212941adb076fda008e3cf7e8ddd1ea4e6978
tree 875e1d162bc1447aa920218a0eed768a84a99a01
script .forge/ci 2dbcda6e044dc4b05503abde8369e2528413d3c7
result pass
exit 0
started 2026-10-19T04:25:50Z
duration 84s
runner (not shown here; refs/notes/ci has it)
system Darwin 25.5.0 arm64
env host
log sha256:3b99ef38897d3089102263be754d140dd59dcdd9e5a36cf5c38d7b0fc561a96c 276 lines
|pushed wip/i18n
|10aefba running .forge/ci: greet.sh: say what it is
|  | sh: /bin/sh
|  | ok   greet
|10aefba pass in 0s, signed
|pushed main
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|73bac59 running .forge/ci: wave at the world
|  | sh: /bin/sh
|  | FAIL greet : got 'hello, world o/', want 'hello, world'
|73bac59 fail in 1s, signed
|forge: 1 commit(s) did not pass
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|03d18af running .forge/ci: update the test for the wave
|  | sh: /bin/sh
|  | ok   greet
|03d18af 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 ae86a57 -> /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
|2baa555 running .forge/ci: web, server and Python versions of greet
|  | sh: /bin/sh
|  | ok   greet
|2baa555 pass in 0s, signed
|pushed main
|8b4fce2 running .forge/ci: messages: notes for the later regions
|  | sh: /bin/sh
|  | ok   greet
|8b4fce2 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 6c3b261 -> /sites/dami/forge/guide/
|(node:44664) 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.meaK7x/wt/demo/root/.forge/www
|(node:44791) 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.meaK7x/wt/demo/root/.forge/www
|(node:44931) 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.meaK7x/wt/demo/root/.forge/www
|(node:45104) 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.meaK7x/wt/demo/root/.forge/www
|(node:45783) 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.meaK7x/wt/demo/root/.forge/www
|234 of 234 pages render the same without 11ty
|guide: 8 files, links ok
|ok

Signature: good, cat

5 files changed, +163 -12
MREADME.md29+++++++++++++++++
Mbin/forge52++++++++++++++++++++++++++++--
Mlib/identity.ts19++++++-----
Atest/scenarios/people-admin-hidden.sh37+++++++++++++++++++++
Atest/scenarios/people-admin-invite.sh38++++++++++++++++++++++
M README.md +29 -0
@@ -175,6 +175,35 @@ posts come only from the forge's own page (Origin), at most 30 a minute
175175 across the forge, one taking a key at a time. `user add` checks a key the176176 same way, so no key adds a second line to keys.177177 178+#### Admins179+180+The forge's admins are the users named in `forge.admins` in the server's181+git config, `/srv/git/.gitconfig` (the git user's own): one value each, or182+several names split by spaces or commas. Owning repos doesn't make anyone183+an admin. Set it on the server, as git:184+185+```sh186+git config --file /srv/git/.gitconfig --add forge.admins dami # in the container:187+ssh slate docker exec -u git forge git config --file /srv/git/.gitconfig --add forge.admins cat188+git config --file /srv/git/.gitconfig --get-all forge.admins # the list; --unset-all forge.admins empties it189+```190+191+An admin runs the people commands above over ssh, from their own machine,192+the same as on the server:193+194+```sh195+ssh git@git.example.com invite fox [--ttl 7d] [--repo dami/notes --as reader|collaborator]196+ssh git@git.example.com invites [rm fox]197+ssh git@git.example.com users198+```199+200+An admin's ssh session finds them in a `git-shell-commands` of their own,201+`.forge/admin/git-shell-commands` (`forge server init` writes it), so to202+anyone else they don't exist: git-shell answers `fatal: unrecognized203+command`, with the exit status of any command that isn't there, and204+`help` doesn't list them. A change to `forge.admins` counts from the next205+ssh command on.206+178207 `ssh git@git.example.com new <name>` creates a repo under your name, and so179208 does pushing to one that doesn't exist yet: `forge shell` sees the push before180209 git does, creates the repo the same way (same name rules, hooks installed),
M bin/forge +48 -4
@@ -56,6 +56,9 @@ NS=forge-ci
5656 # What SHA256SUMS.sig of a release is signed for (forge release publish).5757 RNS=forge-release5858 ZERO=000000000000000000000000000000000000000059+# ssh commands for the forge's admins only (is_admin): the operator's60+# forge user invite, user invites and users.61+ADMIN_CMDS='invite invites users'5962 CI_SCRIPT=.forge/ci6063 6164 die() { printf 'forge: %s\n' "$*" >&2; exit 1; }
@@ -1038,6 +1041,14 @@ cmd_server_init() {
10381041 printf '#!/bin/sh\nexec "%s" shell-cmd %s "$@"\n' "$FORGE_BIN" "$c" >"$FORGE_ROOT/git-shell-commands/$c"10391042 chmod +x "$FORGE_ROOT/git-shell-commands/$c"10401043 done1044+ # The admins' commands, in a git-shell-commands of their own that only1045+ # an admin's ssh session gets (cmd_shell): to anyone else they aren't1046+ # there, and git-shell says so itself.1047+ mkdir -p "$FORGE_STATE/admin/git-shell-commands"1048+ for c in $ADMIN_CMDS; do1049+ printf '#!/bin/sh\nexec "%s" shell-cmd %s "$@"\n' "$FORGE_BIN" "$c" >"$FORGE_STATE/admin/git-shell-commands/$c"1050+ chmod +x "$FORGE_STATE/admin/git-shell-commands/$c"1051+ done10411052 # Existing repos get this forge's hooks and current settings (the10421053 # container runs this on every boot).10431054 for d in "$FORGE_ROOT"/*/*.git; do
@@ -1302,13 +1313,16 @@ cmd_user() {
13021313 esac13031314 }13041315 1305-# user invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator]:1316+# user invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator]1317+# (and ssh invite, for admins):13061318 # a one-time link on the forge's /login/ page where whoever has it pastes13071319 # an SSH public key and becomes <name>, which is held for them until then;13081320 # with --repo, they are that repo's reader or collaborator too.13091321 user_invite() {13101322 load_config1311- iu="usage: forge user invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator]"1323+ # Over ssh (an admin, FORGE_USER) it is ssh <host> invite.1324+ if [ -n "${FORGE_USER:-}" ]; then iu="usage: invite"; else iu="usage: forge user invite"; fi1325+ iu="$iu <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator]"13121326 [ $# -ge 1 ] || die "$iu"13131327 name=$1 life=7d repo= role=13141328 shift
@@ -1494,6 +1508,14 @@ can_read() {
14941508 return 114951509 }14961510 1511+# is_admin <user>: is user one of the forge's admins? They are the values1512+# of forge.admins in the server's git config, $FORGE_ROOT/.gitconfig (the1513+# git user's own; several values, or names split by spaces or commas).1514+is_admin() {1515+ [ -n "$1" ] || return 11516+ git config --file "$FORGE_ROOT/.gitconfig" --get-all forge.admins 2>/dev/null | tr ' ,' '\n\n' | grep -qxF -- "$1"1517+}1518+14971519 cmd_shell() {14981520 user=${1:?usage: forge shell <user>}14991521 load_config
@@ -1510,6 +1532,15 @@ cmd_shell() {
15101532 no_such_repo15111533 fi15121534 [ "$GSERVICE" != receive-pack ] || push_create1535+ # An admin's admin command runs from the admins' git-shell-commands1536+ # (git-shell looks for them in HOME); anyone else's goes to git-shell1537+ # as any unknown command does.1538+ if [ -z "$GSERVICE" ] && is_admin "$user"; then1539+ set -f1540+ set -- $SSH_ORIGINAL_COMMAND1541+ set +f1542+ case " $ADMIN_CMDS " in *" ${1:-} "*) HOME=$FORGE_STATE/admin exec git-shell -c "$SSH_ORIGINAL_COMMAND" ;; esac1543+ fi15131544 exec git-shell -c "$SSH_ORIGINAL_COMMAND"15141545 }15151546
@@ -2153,6 +2184,15 @@ cmd_shell_cmd() {
21532184 release) sc_release "$@" ;;21542185 rm) sc_rm "$@" ;;21552186 passkey | passkeys | sessions | tokens) credentials "$c" "$@" ;;2187+ invite | invites | users)2188+ # Reached only through the admins' git-shell-commands (cmd_shell).2189+ is_admin "$user" || die "unknown command $c"2190+ case $c in2191+ invite) user_invite "$@" ;;2192+ invites) node "$FORGE_HOME/lib/identity.ts" invites "$@" ;;2193+ users) [ $# -eq 0 ] || die "usage: users"; node "$FORGE_HOME/lib/identity.ts" users ;;2194+ esac2195+ ;;21562196 token)21572197 # A token for one repo needs a repo you can read, or it isn't there.21582198 r= next=0
@@ -2164,7 +2204,11 @@ cmd_shell_cmd() {
21642204 [ -z "$r" ] || open_repo "$r"21652205 credentials token "$@"21662206 ;;2167- *) printf 'commands:\n new [--private] <name> [description] create %s/<name>\n ls list your repos, and private ones you may read\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals used by forge comment, approve and land\n private <owner>/<repo> only its owner, collaborators and readers can see it\n public <owner>/<repo> [--force] anyone can, once its whole history passes the secret check\n readers <owner>/<repo> [add|rm <user>] who may read it while it is private\n collaborators <owner>/<repo> [add|rm <user>] who may push anything to it; only its owner changes the list\n who <owner>/<repo> its owner, collaborators and readers\n release ls <owner>/<repo> its releases: tag, file, size, sha256\n release get <owner>/<repo> <tag> <file> a release file, on stdout\n rm <owner>/<repo> --yes remove a repo of yours (the admin can restore it for 14 days)\n passkey [name] a one-time link that adds a passkey, to sign in on the web\n passkeys [rm <id>] your passkeys\n sessions [rm <id>|--all] where you are signed in on the web; rm signs one out, --all all\n token [--repo <owner>/<repo>] [--ttl 30d] [name] a token for curl and HTTPS clones, shown once\n tokens [rm <id>] your tokens\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user" ;;2207+ *) 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"2208+ if is_admin "$user"; then2209+ printf 'for admins of the forge:\n invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator] a one-time link that makes someone a user\n invites [rm <name>] invites not taken up yet; rm makes a link dead\n users everyone here: keys, passkeys, tokens; then the invites\n'2210+ fi2211+ ;;21682212 esac21692213 }21702214
@@ -3128,7 +3172,7 @@ stage_repo() {
31283172 # copied.31293173 derived_state() {31303174 case $1 in3131- www | store | cache | backup | secrets | allowed_signers | config.restored | restore.tmp | lake | private | \3175+ www | store | cache | backup | secrets | allowed_signers | config.restored | restore.tmp | lake | private | admin | \31323176 identity.db | identity.db-* | identity.db.* | .identity.db-litestream | litestream.yml | \31333177 *.log | *.lock | *.tmp | backup.* | build.* | events.* | metrics.* | mirror.* | lake.*) return 0 ;;31343178 esac
M lib/identity.ts +11 -8
@@ -617,35 +617,38 @@ 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, invites, users; and user accept's part, and user add's check.620+// forge user invite, invites, users (and their ssh forms, for admins, run621+// with FORGE_USER); and user accept's part, and user add's check.621622 function operatorCommand(state: string, cmd: string, args: string[], out: (s: string) => void): number {622623 const base = (process.env.FORGE_BASE_URL || '').replace(/\/$/, '');623624 const ssh = process.env.FORGE_SSH || 'git@host';624625 const id = Identity.open(state)!;626+ // How the user who ran this runs the others: over ssh, or on the server.627+ const again = process.env.FORGE_USER ? `ssh ${ssh} ` : 'forge user ';625628 switch (cmd) {626629 case 'invite': {627630 const [user, life, repo, role] = args;628631 if (args.length !== 4 || !['-', 'reader', 'collaborator'].includes(role)) fail('usage: invite <name> <ttl> <repo|-> <reader|collaborator|->');629632 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)`);633+ if (!r) fail(`${user} is invited already (${again}invites rm ${user} takes it back, for a new link)`);631634 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`);632635 if (!base) out('(FORGE_BASE_URL is not set, so that is only the path.)\n');633636 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.`);634637 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`);638+ out(`Until then nobody else can have the name. ${again}invites rm ${user} takes the link back.\n`);636639 return 0;637640 }638641 case 'invites': {639642 if (args[0] === 'rm') {640- if (args.length !== 2) fail('usage: forge user invites [rm <name>]');643+ if (args.length !== 2) fail(`usage: ${process.env.FORGE_USER ? '' : 'forge user '}invites [rm <name>]`);641644 const r = id.removeInvite(args[1]);642- if (!r) fail(`no invite for ${args[1]} (forge user invites lists them)`);645+ if (!r) fail(`no invite for ${args[1]} (${again}invites lists them)`);643646 out(`took back the invite for ${r!.user}; its link is dead\n`);644647 return 0;645648 }646- if (args.length) fail('usage: forge user invites [rm <name>]');649+ if (args.length) fail(`usage: ${process.env.FORGE_USER ? '' : 'forge user '}invites [rm <name>]`);647650 const list = id.invites();648- if (!list.length) return out('no invites; forge user invite <name> makes one\n'), 0;651+ if (!list.length) return out(`no invites; ${again}invite <name> makes one\n`), 0;649652 out(columns(['invited', 'made', 'until', 'grants'], list.map((i) => [i.user, when(i.created), when(i.expires), grants(i)])));650653 return 0;651654 }
@@ -660,7 +663,7 @@ function operatorCommand(state: string, cmd: string, args: string[], out: (s: st
660663 return 0;661664 }662665 case 'users': {663- if (args.length) fail('usage: forge users');666+ if (args.length) fail(`usage: ${process.env.FORGE_USER ? '' : 'forge '}users`);664667 const keys = new Map<string, number>();665668 let text = '';666669 try {
A test/scenarios/people-admin-hidden.sh +37 -0
A test/scenarios/people-admin-invite.sh +38 -0
@@ -0,0 +1,38 @@
1+# An admin (forge.admins in the server's git config) runs the operator's2+# people commands over ssh: invite prints the one-time link, invites lists3+# and takes back, users lists everyone, and help lists them. The server4+# forms (forge user invite, ...) still work, for anyone on the server.5+. "$(dirname "$0")/../lib.sh"6+user cat7+user ana8+repo cat/0129+export FORGE_BASE_URL=https://forge.test10+git config --file "$FORGE_ROOT/.gitconfig" --add forge.admins "dami, cat"11+12+ok ssh_as cat invite fox --ttl 2d --repo cat/012 --as collaborator13+has "$T_LOG" '^ https://forge\.test/login/#invite=[A-Za-z0-9_-]{24}$'14+has "$T_LOG" 'until 2026-09-03 00:00 UTC'15+has "$T_LOG" 'ssh git@forge\.test invites rm fox takes the link back'16+ok ssh_as cat invites17+has "$T_LOG" '^fox +2026-09-01 00:00 +2026-09-03 00:00 +collaborator of cat/012$'18+ok ssh_as cat users19+eq "$(head -n 3 "$T_LOG")" "user keys passkeys tokens20+ana 1 0 021+cat 1 0 0"22+fails_with "fox is invited already \(ssh git@forge\.test invites rm fox" ssh_as cat invite fox23+fails_with "^forge: usage: invite <name>" ssh_as cat invite24+fails_with "^forge: usage: users$" ssh_as cat users now25+ok ssh_as cat invites rm fox26+eq "$(output)" "took back the invite for fox; its link is dead"27+ok ssh_as cat invites28+eq "$(output)" "no invites; ssh git@forge.test invite <name> makes one"29+30+ok ssh_as cat help31+has "$T_LOG" '^for admins of the forge:$'32+for c in 'invite <name> \[--ttl 7d\]' 'invites \[rm <name>\]' 'users '; do has "$T_LOG" "^ $c"; done33+34+# The server's forms, as before.35+ok forge user invite owl36+has "$T_LOG" 'forge user invites rm owl takes the link back'37+ok forge users38+has "$T_LOG" '^owl '