Observed while testing per-site roles end to end. Not diagnosed — filing with what I have rather than sitting on it.
What happened
A user holding a viewer membership on a site sent:
PATCH /api/sites/{siteId}
Authorization: Bearer <viewer token>
Content-Type: application/json
{"name": "renamed"}and got 400. Every other rank check in the same probe behaved correctly: an outsider got 403 on the members and invites endpoints, a viewer got 403 when trying to invite, and the same viewer got 200 on GET .../stats. Only this endpoint answered 400.
Why that is odd
The handler checks the role first, before it looks at the body at all:
route.patch('/api/sites/{siteId}', async (request: any) => {
const siteId = request.params.siteId
const denied = await requireSiteRole(request, siteId, 'admin')
if (denied) return denied // <- 401 / 403 / 404, never 400
const body = request.jsonBody ?? {}
...
if (!name) return json({ error: 'name cannot be empty' }, 400)requireSiteRole only ever returns 401, 403 or 404. satisfies('viewer', 'admin') is false and is covered by unit tests. And the body sent was a non-empty string, so the one 400 in the handler should not have fired either.
So either the request never reached this handler, or something upstream answered first.
Candidates I did not rule out
- A competing route.
app/Models/Site.tsdeclaresuseApi: { uri: 'sites', routes: [..., 'update', ...] }. If auto-CRUD registers its ownPATCHfor this path and wins, the app's guard never runs — which would be an authorization problem, not just a wrong status code.routes/auth.ts:30-38says no framework default routes are mounted in this app, which argues against it, but I did not verify it for model-generated routes specifically. - CSRF or body-parsing middleware rejecting before the handler. The route does carry
.skipCsrf(). - Something in my probe. Possible, though the same helper produced correct 403s against four other endpoints in the same run.
Why it is worth chasing
tests/unit/api-authz.test.ts asserts the guard is present in the source, by reading the file as text. That is a good guard against someone deleting the check, and no guard at all against the check never executing. If a generated route is shadowing a hand-written one anywhere, that test would keep passing while the endpoint went unprotected.
So the status code is the symptom worth following, whatever the cause turns out to be.
Reproduction
Owner creates a site, adds a second user as viewer in site_members, second user PATCHes the site with a valid {"name": "..."}. Expected 403, observed 400. Response body was not captured — capturing it is the obvious first step.