Skip to content

Add REST API v1 for external PAT clients - #19

Open
maxijabase wants to merge 6 commits into
mainfrom
feat/rest-api-v1
Open

Add REST API v1 for external PAT clients#19
maxijabase wants to merge 6 commits into
mainfrom
feat/rest-api-v1

Conversation

@maxijabase

Copy link
Copy Markdown
Collaborator

Description

This ships a versioned HTTP API at /api/v1 for external clients: bots, website backends, and scripts. It is a separate product from POST /api.php. Panel JavaScript stays on the JSON RPC (cookie JWT + CSRF). REST never reads the panel cookie.

How operators use it

  1. Sign in, open Your account, mint a Personal Access Token (name + expiry, or never). The secret is sbpp_pat_ plus 64 hex characters and is shown once. The panel stores only a SHA-256 hash.
  2. Send Authorization: Bearer sbpp_pat_… on each request.
  3. Pretty URLs: /api/v1/… (Apache rewrite in the prod image and the rebuilt dev image). PATH_INFO fallback always works: /api/v1.php/….
  4. Revoke from the same card if a token leaks.

Tokens inherit that admin's web flags. There are no extra scopes. A read-only bot is an admin with list flags, not a trimmed token. Soft-retired admins (enabled = 0) cannot use a token. Password lockout does not apply. Revoke is the kill switch.

The intended staff-hub flow (website-next): mint an Owner (or Add/Edit/Delete Admins) PAT in the backend, PUT /admins/{steam64} to grant in-game admin, POST /admins/{steam64}/deactivate to demote. Discord stays in the bot. SourceBans does not know about Discord.

Request shape

  • Success: { "data": …, "meta": … } with real HTTP status (200, 201, 400, 401, 403, 404, 409, 429, 500).
  • Errors: { "error": { "code", "message", "field"? } }.
  • Steam64 values in JSON are strings, not numbers.
  • {id} on /admins/{id} is a numeric aid or a 17-digit Steam64 starting with 7. Steam2/Steam3 in the path is 400.
  • POST /bans and /comms length is minutes (0 = permanent). GET length is seconds (what is stored).
  • Writes reuse existing RPC handlers via Api::invoke() where those already exist (ban, unban, comms, comments, servers add/remove/rcon, notes, mods, protests, submissions, admin deactivate/reactivate/remove, rehash). List/get, Steam64 upsert, PATCH /servers, and GET/PATCH /settings are dedicated REST queries.
  • After admin mutate, the panel fires sm_rehash server-side when config.enableadminrehashing is on and puts the result in meta.rehash. If the caller lacks rehash permission, the mutate still succeeds and meta.rehash.attempted is false (the write is not rolled back with a 403).
  • Rate limit: 60 req/min. Token callers keyed by token id, anonymous by IP. 429 includes Retry-After.
  • CORS is off by default. Optional SB_REST_CORS_ORIGINS in config.php for a browser origin. Backend-to-panel calls do not need it.

Auth and isolation (baked into the design)

  • Only Authorization: Bearer sbpp_pat_…. Cookie JWT must not authenticate REST (that would be a CSRF trap in browsers, and would leak IPs on public GET).
  • REST does not start a PHP session. api/v1.php defines SBPP_REST before init.php; CSRF::init() no-ops. Panel POST /api.php still requires CSRF.
  • After PAT bind, Log::init is rebound to the PAT (or anonymous) userbank so audit rows are attributed to the token admin, not a leftover cookie session.
  • A well-formed token that is revoked, expired, unknown, or belongs to a soft-retired admin is 401 on every route, including public GET. Missing or junk Authorization stays anonymous.
  • GET responses never include forbidden fields: admin password / validate / attempts / lockout_until / srv_password, servers.rcon, settings.smtp.pass, settings.telemetry.instance_id.
  • PUT/PATCH of an Owner account requires an Owner editor (same gate as the details page). EDIT_ADMINS alone cannot change an Owner.
  • PUT + Steam64 upserts (create or update + reactivate). PUT + aid 404s if missing. PATCH never creates.
  • Comment create returns the comment cid, not a later audit-log insert id.
  • Anonymous GET /servers matches ?p=servers: enabled hosts only, no group_ids, ignores enabled=, 404 on a disabled {sid}. A PAT may filter enabled= and sees group_ids. rcon is never returned.
  • Anonymous GET /comms is 404 when config.enablecomms is off. A PAT still reads.
  • Public GET /bans and /comms apply the same banlist.hideplayerips / banlist.hideadminname gates as the panel lists.
  • GET comments is public and empty when config.enablepubliccomments is off (admins still see them). DELETE /comments/{id} is Owner.

Schema, rewrite, docs

  • New table :prefix_api_tokens (fresh install in struc.sql, upgrade via updater 812.php). Hash + prefix only.
  • Account RPC: account.tokens_create / list / revoke (panel UI, not REST).
  • Apache rewrite for /api/v1 in prod + dev conf. Docs nginx snippet + HTTP_AUTHORIZATION note.
  • Operator docs: docs/src/content/docs/configuring/rest-api.mdx. OpenAPI: web/api/openapi-v1.yaml, also served at GET /api/v1/openapi.yaml.
  • Registry: Sbpp\Rest\Routes::all(). Adding a write route requires a row in RestPermissionMatrixTest.

Endpoints

Base: /api/v1 or /api/v1.php. All authenticated routes need a PAT unless marked public.

Meta

Method Path Auth Notes
GET /openapi.yaml Public OpenAPI 3 spec

Caller

Method Path Auth Notes
GET /me PAT Admin bound to this token

Admins and groups

{id} is aid or Steam64.

Method Path Auth / perm Notes
GET /admins List/Add/Edit Admins Paginated (page, per_page, cap 100)
GET /admins/{id} List/Add/Edit Admins
PUT /admins/{id} Add/Edit Admins Steam64 upserts (201 create, 200 update, reactivates inactive). Aid 404s if missing
PATCH /admins/{id} Edit Admins Merge. Never creates. Owner targets need an Owner editor
POST /admins/{id}/deactivate Delete Admins Soft retire (enabled=0). Ban history keeps the name
POST /admins/{id}/reactivate Delete Admins Restore
DELETE /admins/{id} Delete Admins Hard delete. Optional reason
GET /groups List Groups or Add/Edit Admins Web groups + SourceMod groups (gid mapping). No group write in this version
POST /system/rehash Add/Edit Admins or Edit Groups Optional { "sids": [1,2] }. Admin mutate already rehashes when the setting is on

Bans

Method Path Auth / perm Notes
GET /bans Public Hide IP / admin name like the panel. PAT sees hidden fields
GET /bans/{bid} Public Same hide-*
POST /bans Add Ban length minutes. Optional kick: true fans RCON (meta.kick)
POST /bans/{bid}/unban Unban flags Requires non-empty ureason

Comm blocks

Method Path Auth / perm Notes
GET /comms Public* kind is mute or gag. Silence is two rows. *404 for anonymous when Comm blocks are off
GET /comms/{cid} Public* Same
POST /comms Add Ban kind: mute, gag, or silence
POST /comms/{cid}/unblock Unban flags Requires ureason
DELETE /comms/{cid} Delete Ban Hard delete

Servers

Method Path Auth / perm Notes
GET /servers Public Enabled hosts, A2S in query, never rcon. Anonymous omits group_ids and ignores enabled=. PAT may filter
GET /servers/{sid} Public Anonymous 404 if disabled. PAT can fetch a disabled host
POST /servers Add Server ip / address, port, mod. enabled defaults true
PATCH /servers/{sid} Edit Servers Merge. Omit rcon to keep the stored password
DELETE /servers/{sid} Delete Servers Hard delete
POST /servers/{sid}/rcon SM RCON or Root and per-server mapping

Notes, mods, queues

Method Path Auth / perm Notes
GET /notes Any web admin Requires ?steam=
POST /notes Any web admin steam + body
DELETE /notes/{nid} Author or Owner
GET /mods, /mods/{mid} List/Add/Edit Mods
POST /mods Add Mods name + folder
DELETE /mods/{mid} Delete Mods Optional ureason
GET /protests, /protests/{pid} Ban Protests Current queue. archived=true for the archive
DELETE /protests/{pid} Ban Protests Hard delete (archiv=0)
GET /submissions, /submissions/{sid} Ban Submissions Current queue. archived=true for archive
DELETE /submissions/{sid} Ban Submissions Hard delete

Comments and settings

Method Path Auth / perm Notes
GET /bans/{bid}/comments, /comms/{cid}/comments Public Empty when public comments are off. Admins still see them
POST /bans/{bid}/comments, /comms/{cid}/comments Any web admin body. Response id is the comment cid
PATCH /comments/{cid} Any web admin body
DELETE /comments/{cid} Owner
GET /settings Web Settings Flat key/value. Never smtp.pass or telemetry.instance_id
PATCH /settings Web Settings Existing keys only. Same forbidden keys

Motivation and Context

External clients (website-next staff hub, Discord bots, scripts) need a real HTTP API with Bearer tokens, real status codes, and a stable resource model. POST /api.php is the panel RPC: cookie + CSRF, chrome envelopes, no PAT. Mixing those clients onto RPC would either leak session auth into browsers or force bots through CSRF. This API is the supported path for that work.

Not in this version (by design): generated password on PUT create, a designed-from-scratch rate limiter beyond the file limiter, comment PATCH author check, OpenAPI completeness polish, kick: true vs enablekickit, blockit fan-out on POST /comms. Group create/edit stays in the panel.

How Has This Been Tested?

  • PHPUnit REST suite (RestAuth, RestAdmins, RestBans, RestComms, RestServers, RestNotes, RestMods, RestProtests, RestSubmissions, RestComments, RestSettings, RestSession, RestPermissionMatrix) plus account token RPC snapshots.
  • Playwright web/tests/e2e/specs/flows/rest-api.spec.ts (mint PAT on Your account, call /me and a write).
  • OpenAPI and operator docs land in this PR. Apache rewrite in prod + dev conf.

Screenshots (if appropriate):

N/A (API + Your account token card). Operator docs at Configuring → REST API.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.

@Rushaway

Rushaway commented Sep 2, 2026

Copy link
Copy Markdown
Member

Review — REST API v1

Read the whole surface: FrontController / Router / Routes, PatAuthenticator, RateLimiter, all nine services, the CSRF / UserManager hooks, the schema + updater, the Apache confs and the test suite. CI is green across PHPUnit, PHPStan, Playwright and docs.

The shape is right. A declarative route table locked by RestPermissionMatrixTest (including "every write route must declare a mask") is the correct way to make this reviewable, and delegating writes to Api::invoke() means the registered RPC mask is re-checked underneath the route mask — real defence in depth rather than a second, drifting copy of the rules. RestAuthTest covers the cases that actually matter: revoked, expired, soft-retired admin, cookie-JWT-does-not-authenticate, well-formed-but-unknown PAT → 401 even on public GET, and audit attribution when a different cookie session is present. Token storage is hash-only with a display prefix, a once-shown secret and a throttled last_used. Good work.

Two things I would want fixed before merge, then a handful of smaller ones.


1. The panel cookie is still read — and written — on every REST request

api/v1.php defines SBPP_REST and then includes init.php, which at line 205 runs:

$userbank = new CUserManager(Auth::verify());

Auth::verify() reads the sbpp_auth cookie, parses the JWT, and on a valid token calls self::updateLastAccessed($jti) — a write against :prefix_login_tokens. PatAuthenticator::bindUserbank() then throws that UserManager away, so the cookie never authenticates anything. But it has already been parsed and its session sliding-expiry has already been refreshed.

Consequences:

  • A browser hitting the public GET /api/v1/bans from the panel's own origin silently keeps that admin's panel session alive. A session that should have aged out does not.
  • Every anonymous REST call pays a JWT parse plus a DB write it has no use for.

This contradicts the PR description's own invariant ("REST never reads the panel cookie"). The PR already establishes the right pattern — CSRF::init() returns early when SBPP_REST is defined. The auth bootstrap in init.php wants the same guard, so REST starts from an anonymous UserManager and PatAuthenticator is the only thing that can bind an identity. RestSessionTest would be the natural home for the regression test, alongside the existing CSRF one.

2. REST admin create and update write no audit log

The mutations that go through Api::invoke() log correctly — admins.deactivate, admins.reactivate, admins.remove all land in the audit log attributed to the PAT admin, which is exactly what the Log::init rebind was for.

create() and update() do not:

  • AdminsService::create() calls $userbank->AddAdmin(...) directly. The panel path, api_admins_add(), logs Log::add(LogType::Message, 'Admin added', ...) (web/api/handlers/admins.php:699). The REST path emits nothing.
  • AdminsService::update() issues a raw UPDATE :prefix_admins. The panel equivalent, web/pages/admin.edit.admindetails.php, logs. The REST path emits nothing.

So the two highest-value writes in the API — granting in-game admin, and changing an existing admin's Steam ID, web group, immunity or server access — are the only ones invisible in the audit log. That is the inverse of the priority you would want, and it is precisely the flow the staff-hub integration is built around (PUT /admins/{steam64} to grant). A Log::add() in both, mirroring the panel's wording so the log reads consistently regardless of origin, closes it.


Worth fixing

3. GET /comms/{cid}/comments bypasses the config.enablecomms gate. CommsService::list() and get() both open with assertPublicFeature(), so anonymous callers get a 404 when comm blocks are off. CommentsService::assertParent() queries :prefix_comms directly with no such check, so on a comms-disabled install an anonymous caller still distinguishes "block exists" (200, empty data) from "does not" (404). The comment bodies are correctly hidden by commentsVisible() — it is only existence that leaks. Routing the parent lookup through the same gate fixes it.

4. Rate-limit files are never garbage collected. RateLimiter writes one SB_CACHE/rest-rl/<sha1>.json per key, and only resetForTests() ever deletes. Every distinct IP that touches the public banlist leaves a file behind permanently — unbounded inode growth on a busy public panel, and nothing in the PR prunes it. A cheap sweep of stale windows on write, or a note in the operator docs, would do.

Two related notes while you are in there: the read-modify-write is not atomic, so concurrent requests undercount (fine for a limiter, worth a comment so the next reader does not file it as a bug); and anonymous keying on REMOTE_ADDR only separates callers where a trusted-proxy setup rewrites it. docker/apache/sbpp-prod.conf sets RemoteIPHeader X-Forwarded-For, so the shipped image is fine — but an operator terminating TLS at their own proxy without that config gets one shared 60/min bucket for all anonymous traffic. That belongs in rest-api.mdx next to the nginx snippet.

5. Minting a PAT needs no re-authentication, and a password change does not revoke tokens. account.tokens_create is registered with no permission mask and requires only a live session, while account.change_password right next to it requires the current password. So a stolen cookie or an XSS mints a never-expiring token that survives both logout and a password reset — a strictly better foothold than the session it came from. Revoke is the documented kill switch, but it only helps an operator who knows to look. Requiring the current password to mint (matching change_password) and revoking an admin's tokens on password change would close the gap cheaply. Soft-retire is already handled correctly via the enabled join in resolve().


Smaller things

  • Boolean coercion is inconsistent across the API. ServersService accepts true | "true" | 1 | "1"; BansService::wantsKick() accepts only true | 1, so {"kick": "true"} silently no-ops and the ban lands without the kick fan-out. One shared helper.
  • NotesService::toResource() does not normalise steam64 the way BansService and AdminsService do ($converted !== false plus a (string) cast). It can emit false where the rest of the API emits null, and it is not guaranteed to be the string the spec promises.
  • N+1 on both list endpoints. AdminsService::list() runs serverIds() per row and ServersService::toResource() runs groupIds() per row — up to 100 extra round trips at per_page=100. The repo already has QueryCountAssertions; a single WHERE admin_id IN (...) collapses each into one query.
  • Router::matchPath() does not preg_quote() the literal part of the pattern, so /openapi.yaml also matches /openapiXyaml. Harmless with today's table, one line to fix.
  • Path params are not decoded consistently between the two entry shapes. PATH_INFO arrives percent-decoded; the REQUEST_URI fallback in requestPath() does not. Only numeric ids today, so it is latent — but the two documented entry points should not disagree.
  • CORS Vary: Origin is only emitted when the origin is allowed. A shared cache can then hand a no-CORS response to an allowed origin. Emit it whenever SB_REST_CORS_ORIGINS is set.
  • length is minutes on POST and seconds on GET. Documented, and I see why (minutes matches bans.add, seconds matches storage) — but same field name, same resource, different unit by verb will bite an integrator. length_minutes on write, or seconds everywhere, is worth the churn now while there are no clients.
  • updater/data/812.php hardcodes CHARSET=utf8mb4 while struc.sql uses {charset}, so an upgraded install on a different charset diverges from a fresh one. 700.php already uses the parameterised :charset form.
  • Rehasher::allEnabledSids() reaches for $GLOBALS['PDO'] directly, skipping the instanceof Database guard every service's db() uses.
  • CommsService::create() identifies created rows via MAX(bid) before/after. The authid + aid scoping narrows it a lot, but two concurrent identical calls from the same token can still cross-attribute — plausible for a retrying bot, which is the stated audience. Having comms.add return the ids it inserted would be robust by construction.
  • SettingsService::patch() does no per-key validation. Any non-forbidden key accepts any scalar, so a Web Settings token can write values the settings page would reject. Also one SELECT per key.
  • Nothing checks openapi-v1.yaml against Routes::all(). 1860 hand-written lines will drift on the first route added. The repo already runs an "API contract" workflow; a paths-and-methods parity assertion in RestPermissionMatrixTest would be a few lines and keeps the spec honest.
  • PATCH /comments/{cid} lets any admin edit any commentbans.edit_comment is registered with perm 0 and has no authorship check, unlike notes.delete which correctly enforces author-or-Owner. That is pre-existing panel behaviour, not something this PR introduces, but the REST route makes it reachable by bot tokens for the first time. Worth deciding deliberately rather than inheriting.

Items 1 and 2 are the ones I would hold the merge on — both are cases where the implementation quietly falls short of an invariant the PR description states, which is the kind of gap that gets trusted later. Everything below that is comfortably follow-up material.

@Rushaway

Rushaway commented Sep 2, 2026

Copy link
Copy Markdown
Member

Second review pass

Model: Claude Opus 5 (claude-opus-5), running at high reasoning effort. This is a second pass over the same branch — the first pass focused on the auth/dispatch core and the admin service; this one covers what it did not reach: the four remaining services, the token UI, and the docs and OpenAPI spec checked against the implementation rather than read on their own terms.

Two things I want to correct or firm up from the first pass before the new material.

Verified clean, so ignore any worry here: the token UI is not an XSS vector. {$token.name} and data-name="{$token.name}" in page_youraccount.tpl are unescaped in the template source, but init.php:300 sets $theme->setEscapeHtml(true), so Smarty's global auto-escape covers both. I went looking for a stored-XSS finding on a 64-char attacker-controlled token name and there isn't one.

Also verified, and it makes the earlier suggestion cheaper than I implied: openapi-v1.yaml and Routes::all() are in exact parity today — 47 paths/methods on each side, zero drift in either direction. So the parity assertion I suggested would pass on day one. It costs nothing to add now and it is the only thing standing between 1860 hand-maintained lines and the first route that forgets them.


6. mods.add and comms.add still guess which row they just created — and this PR already shipped the fix for a third case

This is the sharpest thing in the second pass, because the correct pattern is already in the diff.

Look at how the five write paths recover the id of what they inserted:

RPC Returns its insert id? What the service does
bans.add yes, bid uses it
notes.add yes, nid uses it
bans.add_comment yes — added by this PR ('cid' => $cid) uses it
mods.add no WHERE modfolder = :folder OR name = :name ORDER BY mid DESC
comms.add no MAX(bid) before, then bid > :before AND authid AND aid after

The PR author clearly identified the problem and solved it properly once — the one-line addition of $cid to api_bans_add_comment() is exactly right. It just was not carried to the other two. Two of five write paths are left inferring their own result from a SELECT, in an API whose stated audience is bots that retry.

ModsService::create() is the worse of the two: OR across two columns plus ORDER BY mid DESC means that if mods.add ever succeeds in a way that does not produce the highest matching mid, or if an unrelated older mod matches on name where the new one matched on folder, the 201 describes the wrong resource. CommsService::create() is better fenced by the authid + aid scoping, but two concurrent identical calls from the same token still cross-attribute.

Adding a return value to mods.add and comms.add the way bans.add_comment just got one removes both SELECTs and both races. It also lets CommentsService::create() drop its latestCid() fallback, which is now unreachable in practice and is itself racy — ORDER BY cid DESC on the parent can pick up a concurrent comment from a different admin.

7. The protest and submission queues can be destroyed but not archived

GET /protests and GET /submissions both accept ?archived=true and read the archive. Nothing in the route table can write that flag. The only mutation exposed is DELETE, which both services hard-delete with archiv => '0':

Api::invoke('protests.remove', ['pid' => $pid, 'archiv' => '0']);

So a bot triaging the queue has exactly one option, and it is the irreversible one. The panel's normal action for a handled report is to archive it — reversible, auditable, and the thing the archived=true filter exists to read back. The API can read the result of that action and can destroy rows, but cannot perform it.

Given submissions.remove and protests.remove already take archiv as a parameter, this looks like one route each (POST /{pid}/archive, or archived on a PATCH) rather than new handler work. Worth doing before external clients start reaching for DELETE because it is the only verb on offer.

8. The nginx snippet in the docs will 404 for most people who follow it

docs/.../rest-api.mdx gives this as the rewrite for non-Docker installs:

location /api/v1 {
    rewrite ^/api/v1$ /api/v1.php last;
    rewrite ^/api/v1/(.*)$ /api/v1.php/$1 last;
}

That produces /api/v1.php/me, which is correct — and then a stock location ~ \.php$ block hands PHP-FPM SCRIPT_FILENAME=…/api/v1.php/me and gets "Primary script unknown", or never gets there at all because try_files $uri =404 rejects the path first. PATH_INFO on nginx needs the companion directives, which the page does not mention:

fastcgi_split_path_info ^(.+\.php)(/.*)$;
fastcgi_param PATH_INFO $fastcgi_path_info;

This matters more than a normal docs nit because it is the only documented route for the non-Docker install base, and the page currently oversells it twice — line 47 calls the PATH_INFO form "always works, including tarball installs without rewrite", and line 206 repeats it. FrontController::requestPath() does have a REQUEST_URI regex fallback that saves the pretty-URL case, but it cannot save a request PHP-FPM refused to route. The Authorization half of the same section is fine — PatAuthenticator::authorizationHeader() falling back to getallheaders() genuinely does cover apache2handler.

9. The data-exposure inventory omits the two most identifying tables

The PR description and the docs both carry a careful list of what GET never returns: admin password / validate / attempts / lockout_until / srv_password, servers.rcon, settings.smtp.pass, settings.telemetry.instance_id. Neither mentions that GET /submissions returns email, ip and submitter_ip (sip), and GET /protests returns email and ip (pip).

To be fair to the implementation: I checked the panel, and page_admin_bans_submissions.tpl and page_admin_bans_protests.tpl show sip and pip to admins holding the same flags. This is not a permission bug — the fields match what the flag already grants.

What changes is the shape of the access. The panel shows one queue page to a signed-in human; the API hands a bot token 100 reporter email addresses and IPs per request, with ?archived=true to sweep the historical set as well. That is a bulk-egress surface that did not previously exist, sitting behind a token that (per finding 5 in the first pass) can be minted without re-authentication and never expires. It does not need a code change to be defensible, but it does need to be stated — the inventory that carefully lists smtp.pass should not be silent about the reporters' personal data.

10. Smaller things this pass turned up

  • GroupsService::list() is the only unpaginated list endpoint. Every other one caps per_page at 100; this returns every web group and every SourceMod group in one response, with flags for both.
  • SubmissionsService::toResource() emits raw unvalidated text as steam. The fallback is $steam2 ?? ($rawSteam !== '' ? $rawSteam : null), so a malformed SteamId in the DB is passed through verbatim into a field the spec documents as a Steam2 id. BansService and AdminsService both return null in that position.
  • The boolean-coercion split is now settled, and wantsKick() is the outlier. ServersService (enabled), SubmissionsService and ProtestsService (archived) all accept true | 1 | "1" | "true". Only BansService::wantsKick() narrows to true | 1, so {"kick": "true"} silently drops the kick fan-out while {"archived": "true"} on the next endpoint works. One shared helper, and wantsKick is the one to change.
  • Two docs rows are silent exactly where they are most permissive. The routes table documents authorization for its neighbours — DELETE /notes/{nid} says "Author or Owner", DELETE /comments/{cid} says "Owner only" — but PATCH /comments/{cid} says only "body", and that is the row where any web admin can edit any other admin's comment (first pass, last bullet). Same pattern one row up: GET /comms/{cid}/comments is listed as plain "Public" with no note that it skips the config.enablecomms gate the /comms rows document so carefully — which is the behaviour I flagged as finding 3.
  • Envelope::statusForCode() defaults to 400. The known codes are mapped well, but an unmapped server-fault code coming out of a legacy handler surfaces to the client as a 4xx, telling a bot to fix its request when the panel is the thing that broke. A default => 500 for codes ending in _failed, or an explicit allowlist, would fail in the safer direction.
  • Two rough edges in the token card. Creating a token does not insert a row into the table — the secret appears, but the list only catches up on reload, and if it was the admin's first token the "No tokens yet." message stays on screen next to the secret they just minted. Revoking the last token removes its row but does not restore that empty state.

Where this leaves the PR

Nothing in this second pass displaces the first two items — the Auth::verify() cookie write and the missing audit log on admin create/update are still the ones I would gate on, and both are cases where the code falls short of an invariant the PR itself asserts.

Of the new material, 6 (return the insert ids) is the one I would most like to see land with this PR rather than after it, because the fix is already in the diff for the third case and the pattern is at its most obvious right now. 8 is a fast docs fix with an outsized blast radius for self-hosters. 7 and 9 are design and disclosure calls that are yours to make, not defects.

The overall judgement from the first pass is unchanged and if anything firmer after reading the rest: the architecture is sound, the permission matrix is the right shape, the AGENTS.md and ARCHITECTURE.md updates actually honour the repo's own documentation contract instead of skipping it, and the OpenAPI spec is genuinely in sync on day one. This is good work with a short, concrete list between it and merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants