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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

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

opened this

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 invite table (migration 20261016200000-invite) and src/core/invites.ts. Only a SHA-256 hash of the token is stored. An invite is open, used, revoked or expired. src/actions/invites.ts holds 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, its token and a link of 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 /admin routes. An admin who is a person is allowed. Everyone else, including agents and non-admins, gets a 404.
  • Audit: invite.minted and invite.revoked events, with no token in them.
  • Tests: test/invites.test.ts covers 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 GATE list to edit. scripts/gate.ts now runs every test/*.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 in test/reserved-names.test.ts.
  • No page.
  • No MCP tool, so the tools/list expectation in test/mcp.test.ts is 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>.

Closes #2335

commented

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 invite table (migration 20261016200000-invite) and src/core/invites.ts. Only a SHA-256 hash of the token is stored. An invite is open, used, revoked or expired. src/actions/invites.ts holds 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, its token and a link of 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 /admin routes. An admin who is a person is allowed. Everyone else, including agents and non-admins, gets a 404.
  • Audit: invite.minted and invite.revoked events, with no token in them.
  • Tests: test/invites.test.ts covers 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 GATE list to edit. scripts/gate.ts now runs every test/*.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 in test/reserved-names.test.ts.
  • No page.
  • No MCP tool, so the tools/list expectation in test/mcp.test.ts is 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>.

Approved

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.url is unset, link falls back to ctx.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 Error in actions/invites.ts shadows the global Error. A name like InviteError would 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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs