From 146a96154bce3cbf89506f0a51a2a4e770cb0731 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 00:02:03 +0000 Subject: [PATCH 1/2] feat(spec): register the invitation role cap's three refusal codes under plugin-auth INVITE_ROLE_ABOVE_ISSUER, INVITE_ISSUER_MEMBER_ONLY and INVITE_ISSUER_UNVERIFIED join the ledger ahead of their producer, so the plugin-auth half can stamp them on the cap's refusals instead of a bare FORBIDDEN. Claude-Session: https://claude.ai/code/session_01GDoMKJFY45jdSGzFmyumij Co-authored-by: Claude --- .changeset/24082-invite-role-cap-codes.md | 15 +++++++++++++++ packages/spec/src/api/error-code-ledger.test.ts | 12 ++++++++++++ packages/spec/src/api/error-code-ledger.zod.ts | 6 ++++++ 3 files changed, 33 insertions(+) create mode 100644 .changeset/24082-invite-role-cap-codes.md diff --git a/.changeset/24082-invite-role-cap-codes.md b/.changeset/24082-invite-role-cap-codes.md new file mode 100644 index 00000000000..46bb7ec8a2e --- /dev/null +++ b/.changeset/24082-invite-role-cap-codes.md @@ -0,0 +1,15 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): register the invitation role cap's three refusal codes under `@objectstack/plugin-auth` + +Clause-②: yes (widening) + +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: + +- `INVITE_ROLE_ABOVE_ISSUER` — the requested role grades above the issuer's own; an invitation never confers authority above its issuer. +- `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. +- `INVITE_ISSUER_UNVERIFIED` — fail-closed: the issuer's organization membership could not be established, so the invitation may confer nothing beyond `member`. + +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). diff --git a/packages/spec/src/api/error-code-ledger.test.ts b/packages/spec/src/api/error-code-ledger.test.ts index f7c169f9ea7..94f3b8f9dba 100644 --- a/packages/spec/src/api/error-code-ledger.test.ts +++ b/packages/spec/src/api/error-code-ledger.test.ts @@ -231,6 +231,18 @@ describe('ErrorCode (standard ∪ registered)', () => { expect(standardSynonymOf('FLOW_INPUT_SCHEMA_INVALID')).toBeUndefined(); }); + it('accepts the invitation role cap refusal codes, registered ahead of their plugin-auth producer', () => { + // #24082 is the spec half of #23945's split: the cap's three refusals are + // a bare `FORBIDDEN` today, so a client cannot tell them apart. #23945 + // stamps them and asserts these exact strings by value — pinned here too. + for (const code of ['INVITE_ROLE_ABOVE_ISSUER', 'INVITE_ISSUER_MEMBER_ONLY', 'INVITE_ISSUER_UNVERIFIED']) { + expect(ErrorCode.parse(code)).toBe(code); + expect(ERROR_CODE_LEDGER['@objectstack/plugin-auth'], `${code} registered under plugin-auth`).toContain(code); + // INVITE is a token no standard member carries — no waiver needed. + expect(standardSynonymOf(code), `${code} needs no waiver`).toBeUndefined(); + } + }); + it('accepts the nine-code batch — every code that ships in dist, door or no door (ruled: the ledger is the published face)', () => { // The #16404 ruling (director seat, decision batch #62, 2026-09-07, option // D): the published face is this ledger, so a code that ships in `dist` is diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 02b830b4cc4..d1a58557f81 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -570,7 +570,13 @@ export const ERROR_CODE_LEDGER = { 'INVALID_EMAIL', 'INVALID_PHONE', 'INVITE_EMAIL_FAILED', + // [#24082] The invitation role cap's three refusals (`invitation-role-cap.ts`, + // thrown 403 from `beforeCreateInvitation`), registered AHEAD of their + // producer: today the cap throws a bare `FORBIDDEN`, and #23945 stamps these. + '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 + '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` 'INVITE_REQUIRES_EMAIL', + 'INVITE_ROLE_ABOVE_ISSUER', // [#24082] 403: the requested role grades above the issuer's own (an invitation never confers authority above its issuer) 'INVALID_REQUEST', 'INVITE_SMS_FAILED', 'IP_NOT_ALLOWED', From 44818e2e6df0671bf0b55140423ea2327e6e3943 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 00:11:46 +0000 Subject: [PATCH 2/2] chore(spec): regenerate the reference docs for the three invitation role cap codes Claude-Session: https://claude.ai/code/session_01GDoMKJFY45jdSGzFmyumij Co-authored-by: Claude --- content/docs/references/api/contract.mdx | 5 ++++- content/docs/references/api/error-code-ledger.mdx | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 5c74e376ea4..8926962d89d 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **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) | +| **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) | | **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) | | **message** | `string` | ✅ | Readable error message | | **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); * `INVALID_SIGNATURE` * `INVALID_STATE` * `INVITE_EMAIL_FAILED` +* `INVITE_ISSUER_MEMBER_ONLY` +* `INVITE_ISSUER_UNVERIFIED` * `INVITE_REQUIRES_EMAIL` +* `INVITE_ROLE_ABOVE_ISSUER` * `INVITE_SMS_FAILED` * `IP_NOT_ALLOWED` * `ITEM_LOCKED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 6d7beb19e30..1689493dfff 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -386,7 +386,10 @@ const result = ErrorCode.parse(data); * `INVALID_SIGNATURE` * `INVALID_STATE` * `INVITE_EMAIL_FAILED` +* `INVITE_ISSUER_MEMBER_ONLY` +* `INVITE_ISSUER_UNVERIFIED` * `INVITE_REQUIRES_EMAIL` +* `INVITE_ROLE_ABOVE_ISSUER` * `INVITE_SMS_FAILED` * `IP_NOT_ALLOWED` * `ITEM_LOCKED`