You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
There is no operator lever to act on abuse or breakage. Every mutation here must be gated by requireAdmin and written to admin_audit_log.
Scope
Users:POST /v1/admin/users/:id/suspend + /unsuspend (a suspended flag on users, enforced in requireUser so a suspended user can't call the API). PUT /v1/admin/users/:id/roles (grant/revoke creator/admin).
Agents:POST /v1/admin/agents/:id/unpublish (set visibility=draft) and admin DELETE /v1/admin/agents/:id (reuse owner-or-admin delete already in agents.ts).
Instances:POST /v1/admin/instances/:id/cancel (operator cancel of a runaway/abusive instance).
Keys:POST /v1/admin/users/:id/keys/revoke (delete a stored provider key by provider — never reveal it).
Every action → admin_audit_log row.
Acceptance criteria
Suspended user is blocked at requireUser (401/403) until unsuspended; verified by test.
Done in 5b7fe86. None of this existed before — verified by grep, not by reading the issue body.
Routes (all in workers/api/src/routes/admin-moderation.ts, mounted at /v1/admin):
POST /users/:id/suspend{reason?} / POST /users/:id/unsuspend
reversible, reason stored + audited
PUT /users/:id/roles{roles:[…]}
closed set user/creator/admin; user always implied
POST /users/:id/keys/revoke{provider}
deletes by provider name, never decrypts
POST /agents/:id/unpublish
visibility → draft, records the previous value
DELETE /agents/:id{confirm, force?}
see below
POST /instances/:id/cancel{reason?}
operator cancel, by instance id only
Suspension enforcement is in requireUser (migration 0078 adds users.suspended + suspended_at + suspended_reason), so it gates every authenticated route at once rather than being sprinkled per-handler where the next new route silently forgets it. Revoking the token would not have worked — sessions live 30 days and the user can simply sign in again for a fresh one. The check fails open on a D1 error: a moderation gate briefly not applying is a far smaller failure than 403ing the entire platform on a database blip.
The authorization story, since these routes deliberately break the tenant-isolation invariant every other route upholds:
Every handler calls the existing requireAdminitself — no fourth notion of "admin" invented, and operatorUserIds (lib/external-usage.ts) already reads the same users.roles + ADMIN_ALLOWLIST sources, so "who is an operator" stays one definition. The tests assert the 403 per route rather than inferring it from a shared helper: a handler that forgets the line must fail its own test.
Every successful mutation writes exactly oneadmin_audit_log row including the before-state (previous visibility, previous roles, previous status), so an action is reconstructable afterwards. Asserted per route.
Destructive actions name their target explicitly. There is no filter-shaped delete or cancel. Key revoke requires a provider — an omitted field must not become "wipe every key this user owns". Agent delete requires the caller to echo the agent's own slug (a mistyped id then 400s instead of destroying a different creator's agent) and 409s on an agent with live subscribers unless force, in which case those instances are canceled in the same batch rather than left stranded on a template that no longer exists.
Self-suspension and self-demotion are refused. With requireAdmin reading live roles, either one locks the only operator out of the portal that would undo it, effective on the very next request.
Parameterized SQL throughout; no secret is read, decrypted or returned.
The admin delete is separate from the owner-or-admin DELETE /v1/agents/:id in agents.ts on purpose: that one trusts session.roles, which only sees the role baked into a token, while this goes through the full live gate and audits.
#108 (Cloudflare Access on the /v1/admin perimeter) is still open, so nothing upstream is covering for a missing gate here — these routes are safe on their own merits and the tests say so.
suspended / suspended_at / suspended_reason are also surfaced on the admin user list + detail so an operator can see who is blocked.
Tests: 41 new (routes/admin-moderation.test.ts + the suspension gate in lib/auth.test.ts) — 14 per-route auth-boundary cases, one-audit-row-per-mutation, the confirm/force guards, the self-protection refusals, the no-wildcard-revoke case, and fail-open-on-D1-error.
Not covered: MCP is a separate worker with its own OAuth token verification and does not go through requireUser, so a suspended user's existing MCP token is not blocked by this. Worth a follow-up if suspension is ever used in anger.
Correction to the last paragraph above — I checked, and I had it backwards.
The MCP worker does not read D1 directly: its tools proxy to api.proagentstore.online via apiCallAuthed, and the token they send (props.authToken, set in oauth-provider.ts at grant time) is the real PAGS session JWT. So those calls land on requireUser and the suspension check does apply — a suspended user's existing MCP grant stops working on its next tool call, no re-auth needed.
The narrow residual gap is the handful of MCP tools that never touch the API: the repo/scaffold family in repo-tools.ts acts on GitHub with the worker's own GITHUB_TOKEN. Those bypass requireUser entirely and would keep working for a suspended user. Small surface, but a real one — worth closing if suspension is ever used in anger.
Activity
feat(admin): moderation levers + cross-tenant agent/instance filters (#…
serge-ivo commentedon Aug 6, 2026
Done in 5b7fe86. None of this existed before — verified by grep, not by reading the issue body.
Routes (all in
workers/api/src/routes/admin-moderation.ts, mounted at/v1/admin):POST /users/:id/suspend{reason?}/POST /users/:id/unsuspendPUT /users/:id/roles{roles:[…]}user/creator/admin;useralways impliedPOST /users/:id/keys/revoke{provider}POST /agents/:id/unpublishDELETE /agents/:id{confirm, force?}POST /instances/:id/cancel{reason?}Suspension enforcement is in
requireUser(migration0078addsusers.suspended+suspended_at+suspended_reason), so it gates every authenticated route at once rather than being sprinkled per-handler where the next new route silently forgets it. Revoking the token would not have worked — sessions live 30 days and the user can simply sign in again for a fresh one. The check fails open on a D1 error: a moderation gate briefly not applying is a far smaller failure than 403ing the entire platform on a database blip.The authorization story, since these routes deliberately break the tenant-isolation invariant every other route upholds:
requireAdminitself — no fourth notion of "admin" invented, andoperatorUserIds(lib/external-usage.ts) already reads the sameusers.roles+ADMIN_ALLOWLISTsources, so "who is an operator" stays one definition. The tests assert the 403 per route rather than inferring it from a shared helper: a handler that forgets the line must fail its own test.admin_audit_logrow including the before-state (previous visibility, previous roles, previous status), so an action is reconstructable afterwards. Asserted per route.force, in which case those instances are canceled in the same batch rather than left stranded on a template that no longer exists.requireAdminreading live roles, either one locks the only operator out of the portal that would undo it, effective on the very next request.DELETE /v1/agents/:idinagents.tson purpose: that one trustssession.roles, which only sees the role baked into a token, while this goes through the full live gate and audits.#108 (Cloudflare Access on the /v1/admin perimeter) is still open, so nothing upstream is covering for a missing gate here — these routes are safe on their own merits and the tests say so.
suspended/suspended_at/suspended_reasonare also surfaced on the admin user list + detail so an operator can see who is blocked.Tests: 41 new (
routes/admin-moderation.test.ts+ the suspension gate inlib/auth.test.ts) — 14 per-route auth-boundary cases, one-audit-row-per-mutation, the confirm/force guards, the self-protection refusals, the no-wildcard-revoke case, and fail-open-on-D1-error.Not covered: MCP is a separate worker with its own OAuth token verification and does not go through
requireUser, so a suspended user's existing MCP token is not blocked by this. Worth a follow-up if suspension is ever used in anger.serge-ivo commentedon Aug 6, 2026
Correction to the last paragraph above — I checked, and I had it backwards.
The MCP worker does not read D1 directly: its tools proxy to
api.proagentstore.onlineviaapiCallAuthed, and the token they send (props.authToken, set inoauth-provider.tsat grant time) is the real PAGS session JWT. So those calls land onrequireUserand the suspension check does apply — a suspended user's existing MCP grant stops working on its next tool call, no re-auth needed.The narrow residual gap is the handful of MCP tools that never touch the API: the repo/scaffold family in
repo-tools.tsacts on GitHub with the worker's ownGITHUB_TOKEN. Those bypassrequireUserentirely and would keep working for a suspended user. Small surface, but a real one — worth closing if suspension is ever used in anger.fix(auth): the suspension lever reaches every MCP tool, not the ones …