Skip to content

Commit 45e6001

Browse files
feat(spec): register the invitation role cap refusal codes under plugin-auth (#24092)
Fixes #24082 Clause-②: yes (widening) ## What Registers three `@objectstack/plugin-auth` codes in `packages/spec/src/api/error-code-ledger.zod.ts`, one per refusal the invitation role cap (`packages/plugins/plugin-auth/src/invitation-role-cap.ts`) can return. Today `auth-manager.ts` throws all three as a bare `APIError('FORBIDDEN', { message })` from `beforeCreateInvitation` (`:3416`–`:3423`), so the Invite User dialog cannot branch on them. | Code | Producer path at `8a83cebc15` | Status `plugin-auth` pairs it with | | :--- | :--- | :--- | | `INVITE_ROLE_ABOVE_ISSUER` | `invitation-role-cap.ts:186`–`192`: the requested role grades above the issuer's own | 403 | | `INVITE_ISSUER_MEMBER_ONLY` | `invitation-role-cap.ts:194`–`199`: an issuer below admin grade asks for anything but a single plain `member` | 403 | | `INVITE_ISSUER_UNVERIFIED` | `invitation-role-cap.ts:175`–`181`: the issuer's grade is unresolved (`resolveMembershipRole` answered `null`) | 403, fail-closed | Registration only, ahead of the producer (the `FLOW_INPUT_SCHEMA_INVALID` split shape the ledger test already records). #23945 stamps these codes in `plugin-auth`; that card is not closed by this PR. ## Measurements - **The set is exactly three.** `invitationRoleCapFailure` has three non-null returns (lines 175, 186, 194) and no other refusal; its one caller (`auth-manager.ts:3416`) turns each into the same `FORBIDDEN`. Path 2 is reached only by an issuer below admin grade: an admin or owner passes `grantTableAdmits` for every role except one that outranks them, which is path 1. - **Names under the synonym gate.** `standardSynonymOf` on the built `dist` returns `undefined` for all three (`INVITE` is a token no standard member carries), and `ErrorCode.safeParse` accepts each. No `STANDARD_SYNONYM_WAIVERS` entry. Named in the existing `INVITE_*` family, for the condition rather than the status. - **Condition 3 is fail-closed, and its comment says so.** `resolveMembershipRole` (`auth-manager.ts:7612`) answers `null` for four inputs: missing keys, no engine, no membership row, or a lookup that threw. The cap cannot tell them apart, so the code's meaning is "could not be established" and it pairs with 403, fail-closed. Whether #23945 should split the thrown-lookup case onto the standard `SERVICE_UNAVAILABLE` (as the slug guard's read fault does, `auth-manager.ts:1333`) is noted below for that card. It is not decided here. ## Files - `packages/spec/src/api/error-code-ledger.zod.ts`: three rows, each with a meaning comment citing #24082. - `packages/spec/src/api/error-code-ledger.test.ts`: one case pinning the three by value (parse, owner, no synonym), mirroring the registered-ahead-of-producer precedent. - `content/docs/references/api/contract.mdx`, `content/docs/references/api/error-code-ledger.mdx`: regenerated by `pnpm --filter @objectstack/spec check:generated --fix` (the only stale artifact it found was `check:docs`). - `.changeset/24082-invite-role-cap-codes.md`: `minor` on `@objectstack/spec`. ## Verification (head `44818e2e6d`) - `pnpm --filter @objectstack/spec build`: exit 0. Then `exec vitest run src/api/error-code-ledger.test.ts src/api/api-error-code-type.test.ts`: 2 files, 24 tests passed. `typecheck`: exit 0. - `pnpm --filter @objectstack/spec check:generated`: exit 1 before regeneration (`check:docs` stale), exit 0 after `--fix`. - `node scripts/pm/dispatch-gates.mjs --commands`: 111 families derived, all run and reconciled with `--ran` (`111 derived famil(ies) accounted for — 109 run, 2 NOT-MEASURED`). Among them `check:error-code-casing`, `check:dispatcher-error-vocabulary`, `check:error-code-provenance`, `check:api-surface` and `check:docs` all exit 0. - NOT MEASURED locally: `check:dual-build-cjs-loads` and `check:lean-entry-closure` (exit 3, PREREQUISITE NOT MET: they need a whole-workspace build). Both are left to CI's `Lint & Repo Gates`. - ESLint (`--no-inline-config`) over both changed `.ts` files: exit 0. ## Acceptance notes - For #23945 (not a finding filed): `resolveMembershipRole` answers `null` both when the issuer has no membership row and when the lookup threw. Under this registration both are `INVITE_ISSUER_UNVERIFIED` with 403. If #23945 separates the thrown lookup, the existing standard `SERVICE_UNAVAILABLE` is the code that fits it, so no fourth code is registered here. --- _Generated by [Claude Code](https://claude.ai/code/session_01GDoMKJFY45jdSGzFmyumij)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d6bd98a commit 45e6001

5 files changed

Lines changed: 40 additions & 1 deletion

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): register the invitation role cap's three refusal codes under `@objectstack/plugin-auth`
6+
7+
Clause-②: yes (widening)
8+
9+
The error-code ledger (`ERROR_CODE_LEDGER`, and so `ErrorCode`) gains three `@objectstack/plugin-auth` codes, one per refusal the invitation role cap can return. Today the cap answers all three as a bare `403 FORBIDDEN`, so a client such as the Invite User dialog cannot tell them apart:
10+
11+
- `INVITE_ROLE_ABOVE_ISSUER` — the requested role grades above the issuer's own; an invitation never confers authority above its issuer.
12+
- `INVITE_ISSUER_MEMBER_ONLY` — an issuer below admin grade may invite only as a plain `member`; the requested role may carry capability of its own.
13+
- `INVITE_ISSUER_UNVERIFIED` — fail-closed: the issuer's organization membership could not be established, so the invitation may confer nothing beyond `member`.
14+
15+
Registration only: `@objectstack/plugin-auth` still throws `FORBIDDEN` until #23945 stamps these codes on its refusals. No existing code changes, and no standard-catalog member is shadowed (none needs a synonym waiver).

‎content/docs/references/api/contract.mdx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +332 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +335 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3232
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -219,7 +219,10 @@ const result = ApiErrorSchema.parse(data);
219219
* `INVALID_SIGNATURE`
220220
* `INVALID_STATE`
221221
* `INVITE_EMAIL_FAILED`
222+
* `INVITE_ISSUER_MEMBER_ONLY`
223+
* `INVITE_ISSUER_UNVERIFIED`
222224
* `INVITE_REQUIRES_EMAIL`
225+
* `INVITE_ROLE_ABOVE_ISSUER`
223226
* `INVITE_SMS_FAILED`
224227
* `IP_NOT_ALLOWED`
225228
* `ITEM_LOCKED`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,10 @@ const result = ErrorCode.parse(data);
386386
* `INVALID_SIGNATURE`
387387
* `INVALID_STATE`
388388
* `INVITE_EMAIL_FAILED`
389+
* `INVITE_ISSUER_MEMBER_ONLY`
390+
* `INVITE_ISSUER_UNVERIFIED`
389391
* `INVITE_REQUIRES_EMAIL`
392+
* `INVITE_ROLE_ABOVE_ISSUER`
390393
* `INVITE_SMS_FAILED`
391394
* `IP_NOT_ALLOWED`
392395
* `ITEM_LOCKED`

‎packages/spec/src/api/error-code-ledger.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,18 @@ describe('ErrorCode (standard ∪ registered)', () => {
231231
expect(standardSynonymOf('FLOW_INPUT_SCHEMA_INVALID')).toBeUndefined();
232232
});
233233

234+
it('accepts the invitation role cap refusal codes, registered ahead of their plugin-auth producer', () => {
235+
// #24082 is the spec half of #23945's split: the cap's three refusals are
236+
// a bare `FORBIDDEN` today, so a client cannot tell them apart. #23945
237+
// stamps them and asserts these exact strings by value — pinned here too.
238+
for (const code of ['INVITE_ROLE_ABOVE_ISSUER', 'INVITE_ISSUER_MEMBER_ONLY', 'INVITE_ISSUER_UNVERIFIED']) {
239+
expect(ErrorCode.parse(code)).toBe(code);
240+
expect(ERROR_CODE_LEDGER['@objectstack/plugin-auth'], `${code} registered under plugin-auth`).toContain(code);
241+
// INVITE is a token no standard member carries — no waiver needed.
242+
expect(standardSynonymOf(code), `${code} needs no waiver`).toBeUndefined();
243+
}
244+
});
245+
234246
it('accepts the nine-code batch — every code that ships in dist, door or no door (ruled: the ledger is the published face)', () => {
235247
// The #16404 ruling (director seat, decision batch #62, 2026-09-07, option
236248
// D): the published face is this ledger, so a code that ships in `dist` is

‎packages/spec/src/api/error-code-ledger.zod.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,13 @@ export const ERROR_CODE_LEDGER = {
570570
'INVALID_EMAIL',
571571
'INVALID_PHONE',
572572
'INVITE_EMAIL_FAILED',
573+
// [#24082] The invitation role cap's three refusals (`invitation-role-cap.ts`,
574+
// thrown 403 from `beforeCreateInvitation`), registered AHEAD of their
575+
// producer: today the cap throws a bare `FORBIDDEN`, and #23945 stamps these.
576+
'INVITE_ISSUER_MEMBER_ONLY', // [#24082] 403: an issuer below admin grade may invite only as a plain `member`; the requested role does not outrank theirs but may carry capability of its own
577+
'INVITE_ISSUER_UNVERIFIED', // [#24082] 403, fail-closed: the issuer's `sys_member` role could not be established (no row, or the lookup did not answer), so the invitation may confer nothing beyond `member`
573578
'INVITE_REQUIRES_EMAIL',
579+
'INVITE_ROLE_ABOVE_ISSUER', // [#24082] 403: the requested role grades above the issuer's own (an invitation never confers authority above its issuer)
574580
'INVALID_REQUEST',
575581
'INVITE_SMS_FAILED',
576582
'IP_NOT_ALLOWED',

0 commit comments

Comments
 (0)