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
{{ message }}
Repository navigation
Commit 8a49e37
Browse filesBrowse the repository at this point in the historyBrowse files
fix(plugin-auth): the last-administrator guard answers a read fault 503 SERVICE_UNAVAILABLE, the code its sibling guards answer with (#25388)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} -->
_Requested by **Tesla** · [project
thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao2WtPchvUshT9LuGkEhb1h3)_Fixes#25364
Clause-②: no
Metadata-protocol: none
## Measured first: the shared shape, not one half
The card asked whether to change one half of the last-administrator
guard or the shared `failClosed` shape. Measured on `main` at d12e650:
- Every half that judges a write (`enforce` for the two `sys_user`
halves, `enforceStanding` for the membership, grant, activation-ledger
and organization halves, and `judgeOrganizationDelete`) runs inside
`failClosed`, which answered a read fault with `refuse()`: 403
`PERMISSION_DENIED`.
- The Default Organization slug-capture rule
(`guardOrganizationDefaultSlug`, from #25286) does **not** use
`failClosed`. It has its own `catch`, which also answered 403
`PERMISSION_DENIED`.
- So changing the slug rule alone would have split the guard: a read
fault on the slug-capture rule would answer 503 while the same fault on
the update half of the same `organization/update` request answered 403.
Both now throw one shape.
## What changes
- **`last-admin-guard.ts`:** a new module-private `readFault()` beside
`refuse()`: `code: 'SERVICE_UNAVAILABLE'` (typed `StandardErrorCode`, an
existing ADR-0112 member, no new code), `status: 503`, `object` = the
table the caller was writing, `cause` = the read's own failure.
`failClosed` and the slug-capture rule's `catch` both throw it, with
their messages unchanged. Both now pass an inner read fault through
unchanged as well as an inner refusal (`judgeOrganizationDelete` nests
one `failClosed` inside another), so the fault is never wrapped twice.
- **Refusals on the merits stay 403 `PERMISSION_DENIED`, byte for
byte.** That includes the `maxScan` overflow: that read completed and
proved too large, so it is a refusal with a remedy, not a read fault.
- **`objectql-adapter.ts` (the better-auth door):**
`rethrowAsBetterAuthError` gains an arm for an engine error that
declares `code: 'SERVICE_UNAVAILABLE'` **and** `status: 503`, answering
`APIError('SERVICE_UNAVAILABLE')` with that code and a fixed message
(the driver's text is not relayed, as `mapDataError`'s declared-5xx
relay withholds it). Without this arm the 503 reached better-call's
router as an unhandled fault: measured by reverting only this file,
`organization/update` answered **500 with no code**. The arm is keyed on
the declared pair, not on the guard, so any engine error declaring that
pair gets the answer `/data` already gives it.
- **`auth-manager-actor-guards.ts` (`organization/delete`):** the
Default Organization's standing judge mapped only `PERMISSION_DENIED`.
It now maps the guard's read fault to `APIError('SERVICE_UNAVAILABLE')`
with `ORGANIZATION_DELETE_READ_FAULT_CODE`, the code that admission's
own read faults (#25059) already answer with, and a message that does
not relay the driver's text.
The expected landing was `last-admin-guard.ts`. The two other files are
in the same package (`@objectstack/plugin-auth`) and are the doors the
guard's refusal crosses: without them, the code moves at the engine and
the `/data` door but the better-auth doors answer 500.
## Security floor
No change turns a refusal into an allow. Every changed site still
throws, from the same place, before any write. Only the code and status
of a fail-closed refusal move (403 to 503). The pins below assert that
nothing was written in each fault case (`bannedFlag`, `userExists`,
`memberRole`, `slugOf`, and no attempted write on
`organization/delete`).
## Pins (all assert `code` + `status`)
- **HTTP door, `organization/update`**
(`single-reslug-default-anchor.test.ts`): a failed read of the Default
Organization now answers `{ status: 503, code: 'SERVICE_UNAVAILABLE' }`
(was 403 `PERMISSION_DENIED`), the body does not contain the injected
driver text, and the slug is unchanged. That is the code
`auth-manager.ts`'s organization slug guard answers its own read fault
with (`auth-manager.org-slug-guard-system-context.test.ts`).
- **Engine door, system-context update:** the same fault gives `{ code:
'SERVICE_UNAVAILABLE', status: 503 }`.
- **Flipped** in `last-admin-guard.test.ts`: the ban, user-delete and
role-downgrade fail-closed cases now assert `{ code:
'SERVICE_UNAVAILABLE', status: 503 }`. Their message assertions (the
reason, "could not be verified") are unchanged.
- **New:** the `maxScan` overflow keeps `{ code: 'PERMISSION_DENIED',
status: 403 }`; `judgeOrganizationDelete` with a failed inner read
answers the read fault once (one `SERVICE_UNAVAILABLE:` prefix); and
`organization/delete` of the Default Organization through the real guard
answers 503 `SERVICE_UNAVAILABLE` with nothing attempted. Its CONTROL,
with the read back, is the guard's verdict on the same door: 403
`PERMISSION_DENIED`.
- **Repo sweep for pins on the old shape:** `git grep` for the guard's
fail-closed prose and for tests driving the guard through a failing read
found pins only in `plugin-auth`: the 5 flipped above. No doc or ADR
states the read-fault code.
## Verification
- Reverse check, run against `main`'s three source files (`git restore
--source=d12e650` on those files only, then restored from HEAD with `git
diff HEAD` empty): **7 failed / 137 passed** across the four touched
test files, every new or flipped pin red. Adapter alone reverted: the
HTTP pin reads `expected { status: 500, code: null }`.
- `@objectstack/plugin-auth` full suite at 3aee198: **187/187 files,
3528 passed, 10 skipped**. `pnpm --filter @objectstack/plugin-auth
typecheck` (src, examples, test-typecheck): exit 0.
- Derived gate families (`dispatch-gates.mjs --commands`, reconciled
with `--ran`): 69 derived, 67 run with exit 0, 0 unrun. Two came back
NOT MEASURED locally and are left to CI: `check-plugin-teardown-shape
--self-test` needs history this shallow clone does not have, and
`check:dual-build-cjs-loads` needs a full monorepo build.
## Acceptance notes
- The adapter arm also gives every other engine error that declares `{
status: 503, code: 'SERVICE_UNAVAILABLE' }` the 503 answer at the
better-auth door, where it used to be an empty 500. That is the same
answer `mapDataError` gives on `/data`. The other in-repo producers of
that pair found by `git grep` (`objectql/src/action-activation.ts`,
`service-automation/src/engine.ts`) are action and flow paths, not
adapter writes; this was read, not measured.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01CuJfNjvts78GKesupEdgbz
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(plugin-auth): the last-administrator guard answers a read it could not complete with 503 `SERVICE_UNAVAILABLE`, the code the sibling guards on the same doors answer their read faults with
6
+
7
+
Clause-②: no
8
+
Metadata-protocol: none
9
+
10
+
The last-administrator guard fails closed: when a read its verdict depends on throws, it refuses the write. That refusal answered 403 `PERMISSION_DENIED`, the same code as a refusal on the merits, so a caller could not tell "you may not" from "try again" by the code. The organization slug guard (`auth-manager.ts`) and the organization delete admission already answer their read faults with 503 `SERVICE_UNAVAILABLE`.
11
+
12
+
-**Every half of the guard, one shape.** The shared fail-closed envelope and the Default Organization slug-capture rule now throw the same read fault: `code: 'SERVICE_UNAVAILABLE'`, `status: 503`, the read's own failure on `cause`. The write is still refused and nothing is written. A refusal on the merits, and an enumeration that completed but returned more rows than the guard can judge, keep 403 `PERMISSION_DENIED` unchanged.
13
+
-**The data API** answers the read fault 503 `SERVICE_UNAVAILABLE` with the server-fault message, as it does every declared 5xx.
14
+
-**better-auth's endpoints** (`organization/update`, the admin ban and remove-user endpoints, and every other endpoint that writes through the adapter) answer it 503 `SERVICE_UNAVAILABLE`. The adapter now maps any engine error that declares `status: 503` and `code: 'SERVICE_UNAVAILABLE'` to that answer; before, such an error reached better-auth's router as an unhandled 500 with an empty body. The driver's message is not relayed.
15
+
-**`organization/delete`** of the Default Organization answers the guard's read fault 503 `SERVICE_UNAVAILABLE`, the code the admission's own read faults answer with. A refusal on the merits there stays 403 `PERMISSION_DENIED`.
16
+
-**For a client:** branch on the code. `SERVICE_UNAVAILABLE` means retry once the data store is reachable; `PERMISSION_DENIED` means the write itself is refused.
0 commit comments