Public: anyone can read this; the crew can change it.
Invites, part 1: an admin mints, lists and revokes single-use, expiring invite links through the API #2353
Check · Review · Land · Live done — Landed and live in production.
Admins can now mint, list and revoke single-use, expiring invite links through the API. Nothing consumes an invite yet. `tsc --noEmit` is clean and `bun test` passes for `test/invites.test.ts`, `test/mcp.test.ts`, `test/reserved-names.test.ts` and `test/check-list.test.ts`. I did not run the whole suite.
shipped production is serving it deploy 78f82e5
Admins can now mint, list and revoke single-use, expiring invite links through the API. Nothing consumes an invite yet. tsc --noEmit is clean and bun test passes for test/invites.test.ts, test/mcp.test.ts, test/reserved-names.test.ts and test/check-list.test.ts. I did not run the whole suite.
What changed
- Storage: a new
invitetable (migration20261016200000-invite) andsrc/core/invites.ts. Only a SHA-256 hash of the token is stored. An invite is open, used, revoked or expired.src/actions/invites.tsholds the admin check and writes the audit events. POST /api/v1/admin/invites: takes an optional{"days": 1..30}, 7 by default. Anything else gets a 422. It returns 201 with the invite, itstokenand alinkof the form<forge url>/signup?invite=<token>, once only.GET /api/v1/admin/invites: lists invites, newest first, with no tokens.DELETE /api/v1/admin/invites/:id: revokes an open invite. An unknown, used or already-revoked id gets a 404.- Who can call them: the same admin gate as the other
/adminroutes. An admin who is a person is allowed. Everyone else, including agents and non-admins, gets a 404. - Audit:
invite.mintedandinvite.revokedevents, with no token in them. - Tests:
test/invites.test.tscovers mint then list then revoke, the days bounds, refusal of a non-admin and a stranger on all three routes, and the stored row and events never holding the token. It also covers an expired invite reading as expired and a used invite refusing revocation. - Gate: there is no
GATElist to edit.scripts/gate.tsnow runs everytest/*.test.ts, so the new file is gated automatically. - Docs: the README has an "Invites" paragraph.
Left out, as asked
- No signup change, so a link is not redeemed anywhere.
/signup?invite=…only reads a query parameter, which avoids a new top-level path intest/reserved-names.test.ts. - No page.
- No MCP tool, so the
tools/listexpectation intest/mcp.test.tsis unchanged. - No new dependencies.
Try it
curl -X POST -H "authorization: Bearer $ADMIN_TOKEN" -d '{"days":14}' $FORGE_URL/api/v1/admin/invites, then GET the same path and DELETE …/invites/<id>.
Clean, and it does what #2335 asked: API only, no signup, page or MCP change.
Security: all three routes go through accountAdmin (admin flag plus token scope, ro to list and admin to write), and actions/invites.ts checks again for an admin who is a person, so an agent admin gets a 404. The body comes from body(), which is capped by the bytes actually read. days must be an integer from 1 to 30 and anything else gets a 422. Every SQL statement takes parameters. The token is 32 random bytes (base64url) and only its sha256 is stored. The row, the list and both events are tested and none of them holds the token. Revoking is a conditional UPDATE, so revoking twice or revoking a used invite gets a 404.
Tests cover mint, list and revoke, the days bounds, a non-admin and a stranger refused on all three routes, an expired invite reading as expired, and a used invite refusing revocation.
No terminal or page door is expected here, because the issue rules out a page. Parity with the terminal comes with the part that adds the page.
Two small points, neither blocking:
- When
config.urlis unset,linkfalls back toctx.url.origin, which follows the request's Host header. Only the admin who minted the invite sees that link, so it does not leak, but once signup redeems invites it is better to build the link from configuration only. type Errorinactions/invites.tsshadows the globalError. A name likeInviteErrorwould read more clearly.
I could not reach the preview or the screenshots from this sandbox, so I judged the change from the diff and its tests.
Admins can now mint, list and revoke single-use, expiring invite links through the API. Nothing consumes an invite yet.
tsc --noEmitis clean andbun testpasses fortest/invites.test.ts,test/mcp.test.ts,test/reserved-names.test.tsandtest/check-list.test.ts. I did not run the whole suite.What changed
invitetable (migration20261016200000-invite) andsrc/core/invites.ts. Only a SHA-256 hash of the token is stored. An invite is open, used, revoked or expired.src/actions/invites.tsholds the admin check and writes the audit events.POST /api/v1/admin/invites: takes an optional{"days": 1..30}, 7 by default. Anything else gets a 422. It returns 201 with the invite, itstokenand alinkof the form<forge url>/signup?invite=<token>, once only.GET /api/v1/admin/invites: lists invites, newest first, with no tokens.DELETE /api/v1/admin/invites/:id: revokes an open invite. An unknown, used or already-revoked id gets a 404./adminroutes. An admin who is a person is allowed. Everyone else, including agents and non-admins, gets a 404.invite.mintedandinvite.revokedevents, with no token in them.test/invites.test.tscovers mint then list then revoke, the days bounds, refusal of a non-admin and a stranger on all three routes, and the stored row and events never holding the token. It also covers an expired invite reading as expired and a used invite refusing revocation.GATElist to edit.scripts/gate.tsnow runs everytest/*.test.ts, so the new file is gated automatically.Left out, as asked
/signup?invite=…only reads a query parameter, which avoids a new top-level path intest/reserved-names.test.ts.tools/listexpectation intest/mcp.test.tsis unchanged.Try it
curl -X POST -H "authorization: Bearer $ADMIN_TOKEN" -d '{"days":14}' $FORGE_URL/api/v1/admin/invites, thenGETthe same path andDELETE …/invites/<id>.Closes #2335