Repository navigation
Commit 03ac4ec
fix(plugin-auth): the invitation role cap's refusals carry their error codes, and a membership read fault answers 503 (#24238)
Fixes #23945
Clause-②: no
## What changes
The invitation role cap in `beforeCreateInvitation` refused with `403`
and a message, but no `code`. better-auth's own `owner` refusal on the
same endpoint (`POST /api/v1/auth/organization/invite-member`) carries
one. Each of the cap's three refusals now carries the code #24082
registered for it under `@objectstack/plugin-auth`. A membership read
that throws now answers as a store outage, not as a refusal.
| the cap's branch (`invitation-role-cap.ts`) | status | body `code` |
|:--|:--|:--|
| the requested role grades above the issuer's own | 403 |
`INVITE_ROLE_ABOVE_ISSUER` |
| an issuer below admin grade asked for a role other than `member` | 403
| `INVITE_ISSUER_MEMBER_ONLY` |
| the issuer's role was not established (an answered read with no usable
row, or no data engine) | 403 | `INVITE_ISSUER_UNVERIFIED` |
| the issuer's `sys_member` read **threw** (was 403 with no code,
through the branch above) | **503** | `SERVICE_UNAVAILABLE` |
- `invitationRoleCapFailure()` returns `{ code, message }` from each
branch instead of a bare string. The three codes are exported constants
typed `RegisteredErrorCode`, so a removed or misspelled ledger row fails
`tsc` (reverse-verified below). The verdicts do not change: the same
invitations are admitted and refused.
- The hook throws `new APIError('FORBIDDEN', { message, code })`, the
body shape `remove-member` and the vendor's `owner` refusal already use.
better-call passes `body` through verbatim, so `code` reaches the wire
(measured below).
- `resolveMembershipRole` used to end in `catch { return null; }`, which
turned a thrown read into the unverified verdict. It now throws
`invitationRoleCapReadFaultApiError(err)`: `503 SERVICE_UNAVAILABLE`,
the driver's error on `cause`. This is the slug guard's shape
(`slugGuardReadFaultApiError`), with one difference: this body carries
`code: 'SERVICE_UNAVAILABLE'` too, so every answer from this endpoint
has a code. Still fail-closed: the throw ends the endpoint, so no
invitation row is written.
- The ledger comment on `INVITE_ISSUER_UNVERIFIED` is reworded to what
the emitter does now. The phrase "or the lookup did not answer" is gone.
The comment now covers the no-engine exit and names the 503 for a read
that throws. The three-line block comment above it said "today the cap
throws a bare `FORBIDDEN`". That is false after this PR, so it is
reworded too (see Deviations). Both are comment-only, and no code is
added, removed or renamed.
## The seat's rulings on #24082's four review points (premise measured
first)
1. **Split the outage from the refusal.** Premise holds. The cap's read
path could not tell a thrown read from an empty answer, and the place
that merged them is the cap's own read: `resolveMembershipRole`'s `catch
{ return null; }` (`auth-manager.ts`, the last line of the method on
`6e43b7bc3f`). Its one caller is `beforeCreateInvitation` (`git grep
resolveMembershipRole` finds the definition and one call). The split is
made at that read.
2. **The no-engine exit fails closed.** It already returned `null`,
which grades below a plain member, so it answers
`INVITE_ISSUER_UNVERIFIED` (403). This PR keeps it that way and pins it.
Reachability: no shipped composition reaches the hook without an engine.
`AuthPlugin` hard-depends on ObjectQL (`dependencies`,
`requiresServices: ['data', …]`), and `ctx.getService('data')` throws
when the service is absent. The one production construction,
`auth-plugin.ts` `new AuthManager(authConfig)`, always passes
`dataEngine`. A bare `AuthManager` built without `dataEngine` (an
exported class, documented as usable outside the plugin) runs
better-auth on its in-memory adapter and does reach the hook with
nothing to read. The ledger comment covers that exit.
3. **Is the "no row" input (`:175`) reachable through the public door?**
Measured on the wire, once, with a throwaway dogfood probe that was
deleted afterwards (showcase boot at `313560aef`):
- a user with a live session and no membership row, inviting as `admin`:
**400** `{"message":"Member not found","code":"MEMBER_NOT_FOUND"}`.
better-auth's own refusal, so the hook never ran. (The first try
answered 401: ending a membership ends its sessions, so the probe signs
in again first.)
- a plain `member`, inviting as `admin`: **403** `{"message":"You are
not allowed to invite users to this
organization","code":"YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION"}`.
Again before the hook.
- The installed better-auth 1.7.3 source agrees
(`plugins/organization/routes/crud-invites.mjs`). `findMemberByOrgId`,
then `MEMBER_NOT_FOUND`, then `hasPermission(invitation:create)`, all
run before `beforeCreateInvitation`. The cap then re-reads the same
`sys_member` rows in system context.
- So `INVITE_ISSUER_UNVERIFIED` has no input that reaches it through
this door. Getting there needs the row to vanish between the two reads.
It is pinned at the unit level (`auth-manager.test.ts`, "an unresolvable
issuer membership fails CLOSED", which says why in a comment). The two
reachable codes are pinned on the wire in the dogfood file.
4. **All three codes get their emitter here.** Each refusal carries its
`code` through better-auth's `APIError` body.
`check:error-code-provenance` sees the three constant definitions as
stamp sites listed under `@objectstack/plugin-auth` (exit 0, "every
registered-code stamp site is listed under its own owner key").
## PM mechanism hypotheses
- `auth-manager.ts` threw `new APIError('FORBIDDEN', { message:
capFailure })`, and `invitationRoleCapFailure()` returned a bare string
from three branches. **Holds** on `6e43b7bc3f`.
- better-auth serialises `APIError`'s `code` into the body. **Holds, and
a custom code travels the same way.** better-call 1.4.0
`InternalAPIError` stores `body` verbatim. `APIError.from` (the vendor's
`owner` refusal) only builds `{ message, code }` from its error record.
Measured on the wire: `INVITE_ROLE_ABOVE_ISSUER` and
`INVITE_ISSUER_MEMBER_ONLY` arrive as `body.code` (dogfood, below).
- `delegated-admin-invite.dogfood.test.ts` already drives these
refusals. **Holds** for `admin`. It had no `delegated_admin`-by-delegate
case, so one is added.
## Clause-② measured
`no`. On the built entry declarations:
`packages/plugins/plugin-auth/dist/index.d.ts` and `index.d.mts` carry 0
occurrences of `invitationRoleCapFailure`, `InvitationRoleCapRefusal`
and the three `*_CODE` constants. The control `class AuthManager`
appears 1 time, and `rate-limit-storage.d.ts` has 0.
`invitation-role-cap.ts` is not re-exported from `src/index.ts`. The
only declaration text that moved is the doc comment on the private
`resolveMembershipRole`. For `@objectstack/spec`, `check:api-surface`
answered "public API surface + factory signatures unchanged". The ledger
edit is a comment, so `ErrorCode` accepts the same strings. Nothing
widens an accept-set: no invitation is newly admitted, and no code is
added.
## Tests (at `313560aef`)
- `pnpm --filter @objectstack/plugin-auth exec vitest run
--maxWorkers=2` (the whole package): `Test Files 158 passed (158)`,
`Tests 3096 passed | 10 skipped (3106)`.
- `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0. It
includes `check:test-typecheck`: "test layer compiles … 10 file(s) / 88
error(s) / 23 pinned signature(s) held".
- `pnpm --filter @objectstack/spec exec vitest run
src/api/error-code-ledger.test.ts src/api/api-error-code-type.test.ts`:
`Tests 24 passed (24)`. `pnpm --filter @objectstack/spec typecheck`:
exit 0.
- `pnpm --filter @objectstack/dogfood typecheck`: exit 0. `tsc
--listFiles` lists the edited dogfood file once.
- Dogfood on the wire, after building `@objectstack/dogfood^...` (66
tasks): `test/delegated-admin-invite.dogfood.test.ts`, `Tests 5 passed`,
including "the same delegate issuing `admin` is REFUSED" (`body.code`
`INVITE_ROLE_ABOVE_ISSUER`) and the new "the same delegate minting
another delegate is REFUSED as member-only" (`body.code`
`INVITE_ISSUER_MEMBER_ONLY`, no `sys_invitation` row).
- ESLint `--no-inline-config` over the seven changed `.ts` files: exit
0.
Refusal tests assert the envelope (`statusCode`, `status`, `body.code`),
not a bare throw. The cap's unit tests now assert codes rather than
prose. The parity oracle in `invitation-role-grant-offer.test.ts` spells
the codes as literals, so its grid of over 100 refusals also pins which
branch answers which code.
**Ablation** (one-time, through `scripts/ablation-replace.mjs` with
restore proven by blob hash and an empty `git diff HEAD`). The unit
tests import `./auth-manager` from source, so no `dist/` rebuild
applies:
- A: drop `code: refusal.code` from the throw. The run went red with
`Tests 5 failed | 3 passed`: the `admin`, member-only, unresolvable,
no-engine and before-placement cases. The two admit cases and the 503
case stayed green. Restored, blob `698973c2e6c6` equal to HEAD.
- B: replace the read fault's `throw` with `return null` (the old
swallow). The run went red with `Tests 1 failed | 7 passed`: the 503
case failed with "expected 403 to be 503". Restored, blob equal to HEAD.
**Reverse verification** of the `RegisteredErrorCode` annotation:
misspelling the first constant as `INVITE_ROLE_ABOVE_ISSUER_TYPO` turns
`tsc --noEmit` red with `TS2820 … Did you mean
'"INVITE_ROLE_ABOVE_ISSUER"'?`. Restored.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on this diff derives the same 91 commands as
the seat's list. All 91 were run locally at `313560aef`, each exit
captured before any pipe. 90 exited 0. `pnpm check:dual-build-cjs-loads`
exited 3 (PREREQUISITE NOT MET: it needs a whole-workspace build), so it
is NOT MEASURED locally and left to CI's `Build Core`. `--ran`: "✓
dispatch-gates --ran: 91 derived famil(ies) accounted for — 90 run, 1
NOT-MEASURED (1 DERIVED from a recorded exit 3)." The PR's check-runs
had not reported when this was written.
## Acceptance notes
- **The organization slug guard's 503 body carries no `code`**
(`slugGuardReadFaultApiError`). That is the same gap this PR addresses
for the cap's read fault, on `update-organization`. It is reachable only
through a store fault, and no public door produces one on demand, so it
is noted here and not filed. Carrier: none.
- **`packages/spec/src/api/error-code-ledger.test.ts`** still describes
the cap's refusals as "a bare `FORBIDDEN` today" and names the case
"registered ahead of their plugin-auth producer". That is history once
this lands. It is not on this PR's surface, so it is left alone.
Carrier: none.
- **`docs/qa/platform-checklist/areas/identity-auth.json`**
`identity-auth.invitation-scope-gates` scores the admin-role refusal as
"non-2xx". It could now name `INVITE_ROLE_ABOVE_ISSUER`. Carrier: none.
## Deviations
- The ledger's three-line block comment above the rows is reworded as
well as the `INVITE_ISSUER_UNVERIFIED` comment. It said "today the cap
throws a bare `FORBIDDEN`, and #23945 stamps these", which this PR makes
false. Comment-only, same file, same rows.
- `origin/main` moved 6 commits after the branch point (`6e43b7bc3` to
`7715d049e`). None touches a file in this diff. It is not merged in:
this PR's CI runs on the merge ref, and the queue re-verifies on its
rebuilt generation.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent af284b0 commit 03ac4ec
8 files changed
Lines changed: 288 additions & 85 deletions
File tree
- .changeset
- packages
- plugins/plugin-auth/src
- qa/dogfood/test
- spec/src/api
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1198 | 1198 | | |
1199 | 1199 | | |
1200 | 1200 | | |
| 1201 | + | |
| 1202 | + | |
| 1203 | + | |
| 1204 | + | |
| 1205 | + | |
| 1206 | + | |
| 1207 | + | |
| 1208 | + | |
| 1209 | + | |
| 1210 | + | |
| 1211 | + | |
| 1212 | + | |
| 1213 | + | |
| 1214 | + | |
| 1215 | + | |
| 1216 | + | |
| 1217 | + | |
| 1218 | + | |
| 1219 | + | |
| 1220 | + | |
| 1221 | + | |
| 1222 | + | |
1201 | 1223 | | |
1202 | 1224 | | |
1203 | 1225 | | |
1204 | 1226 | | |
1205 | 1227 | | |
1206 | 1228 | | |
1207 | 1229 | | |
1208 | | - | |
1209 | | - | |
1210 | | - | |
1211 | | - | |
1212 | | - | |
1213 | | - | |
1214 | | - | |
| 1230 | + | |
| 1231 | + | |
| 1232 | + | |
| 1233 | + | |
| 1234 | + | |
| 1235 | + | |
| 1236 | + | |
| 1237 | + | |
| 1238 | + | |
| 1239 | + | |
| 1240 | + | |
| 1241 | + | |
| 1242 | + | |
| 1243 | + | |
1215 | 1244 | | |
1216 | 1245 | | |
1217 | 1246 | | |
| |||
1244 | 1273 | | |
1245 | 1274 | | |
1246 | 1275 | | |
| 1276 | + | |
| 1277 | + | |
| 1278 | + | |
| 1279 | + | |
| 1280 | + | |
| 1281 | + | |
1247 | 1282 | | |
1248 | 1283 | | |
1249 | 1284 | | |
1250 | 1285 | | |
1251 | | - | |
1252 | | - | |
1253 | | - | |
1254 | | - | |
1255 | | - | |
1256 | | - | |
1257 | | - | |
| 1286 | + | |
| 1287 | + | |
| 1288 | + | |
| 1289 | + | |
| 1290 | + | |
| 1291 | + | |
| 1292 | + | |
| 1293 | + | |
| 1294 | + | |
| 1295 | + | |
| 1296 | + | |
| 1297 | + | |
| 1298 | + | |
| 1299 | + | |
| 1300 | + | |
| 1301 | + | |
| 1302 | + | |
| 1303 | + | |
| 1304 | + | |
| 1305 | + | |
| 1306 | + | |
| 1307 | + | |
| 1308 | + | |
| 1309 | + | |
| 1310 | + | |
| 1311 | + | |
| 1312 | + | |
| 1313 | + | |
| 1314 | + | |
| 1315 | + | |
| 1316 | + | |
| 1317 | + | |
| 1318 | + | |
| 1319 | + | |
1258 | 1320 | | |
1259 | 1321 | | |
1260 | 1322 | | |
| |||
1278 | 1340 | | |
1279 | 1341 | | |
1280 | 1342 | | |
1281 | | - | |
| 1343 | + | |
1282 | 1344 | | |
1283 | 1345 | | |
1284 | 1346 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
73 | 73 | | |
74 | 74 | | |
75 | 75 | | |
| 76 | + | |
76 | 77 | | |
77 | 78 | | |
78 | 79 | | |
| |||
1364 | 1365 | | |
1365 | 1366 | | |
1366 | 1367 | | |
| 1368 | + | |
| 1369 | + | |
| 1370 | + | |
| 1371 | + | |
| 1372 | + | |
| 1373 | + | |
| 1374 | + | |
| 1375 | + | |
| 1376 | + | |
| 1377 | + | |
| 1378 | + | |
| 1379 | + | |
| 1380 | + | |
| 1381 | + | |
| 1382 | + | |
| 1383 | + | |
| 1384 | + | |
| 1385 | + | |
| 1386 | + | |
| 1387 | + | |
| 1388 | + | |
| 1389 | + | |
| 1390 | + | |
| 1391 | + | |
| 1392 | + | |
| 1393 | + | |
| 1394 | + | |
| 1395 | + | |
1367 | 1396 | | |
1368 | 1397 | | |
1369 | 1398 | | |
| |||
3487 | 3516 | | |
3488 | 3517 | | |
3489 | 3518 | | |
3490 | | - | |
| 3519 | + | |
| 3520 | + | |
| 3521 | + | |
| 3522 | + | |
| 3523 | + | |
| 3524 | + | |
3491 | 3525 | | |
3492 | 3526 | | |
3493 | 3527 | | |
3494 | | - | |
| 3528 | + | |
3495 | 3529 | | |
3496 | | - | |
| 3530 | + | |
3497 | 3531 | | |
3498 | 3532 | | |
3499 | 3533 | | |
| |||
7813 | 7847 | | |
7814 | 7848 | | |
7815 | 7849 | | |
7816 | | - | |
7817 | | - | |
7818 | | - | |
7819 | | - | |
| 7850 | + | |
| 7851 | + | |
| 7852 | + | |
| 7853 | + | |
| 7854 | + | |
| 7855 | + | |
| 7856 | + | |
| 7857 | + | |
| 7858 | + | |
| 7859 | + | |
| 7860 | + | |
| 7861 | + | |
7820 | 7862 | | |
7821 | 7863 | | |
7822 | 7864 | | |
| |||
7830 | 7872 | | |
7831 | 7873 | | |
7832 | 7874 | | |
| 7875 | + | |
7833 | 7876 | | |
7834 | | - | |
| 7877 | + | |
7835 | 7878 | | |
7836 | 7879 | | |
7837 | 7880 | | |
7838 | | - | |
7839 | | - | |
7840 | | - | |
7841 | | - | |
7842 | | - | |
7843 | | - | |
| 7881 | + | |
| 7882 | + | |
7844 | 7883 | | |
| 7884 | + | |
| 7885 | + | |
| 7886 | + | |
| 7887 | + | |
7845 | 7888 | | |
7846 | 7889 | | |
7847 | 7890 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
14 | 14 | | |
15 | 15 | | |
16 | 16 | | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
17 | 20 | | |
18 | 21 | | |
19 | 22 | | |
| |||
22 | 25 | | |
23 | 26 | | |
24 | 27 | | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
25 | 31 | | |
26 | 32 | | |
27 | 33 | | |
| |||
79 | 85 | | |
80 | 86 | | |
81 | 87 | | |
82 | | - | |
83 | | - | |
| 88 | + | |
84 | 89 | | |
85 | 90 | | |
86 | 91 | | |
87 | | - | |
| 92 | + | |
88 | 93 | | |
89 | 94 | | |
90 | 95 | | |
91 | | - | |
| 96 | + | |
92 | 97 | | |
93 | 98 | | |
94 | 99 | | |
| |||
97 | 102 | | |
98 | 103 | | |
99 | 104 | | |
100 | | - | |
101 | | - | |
102 | | - | |
| 105 | + | |
103 | 106 | | |
104 | 107 | | |
105 | 108 | | |
106 | | - | |
| 109 | + | |
107 | 110 | | |
108 | 111 | | |
109 | 112 | | |
| |||
123 | 126 | | |
124 | 127 | | |
125 | 128 | | |
126 | | - | |
| 129 | + | |
127 | 130 | | |
128 | 131 | | |
129 | 132 | | |
130 | 133 | | |
131 | 134 | | |
132 | 135 | | |
133 | 136 | | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
134 | 155 | | |
135 | 156 | | |
136 | 157 | | |
137 | 158 | | |
138 | | - | |
139 | | - | |
140 | | - | |
141 | | - | |
| 159 | + | |
| 160 | + | |
142 | 161 | | |
143 | 162 | | |
144 | 163 | | |
| |||
0 commit comments