diff --git a/.changeset/24254-plugin-auth-hook-codes.md b/.changeset/24254-plugin-auth-hook-codes.md new file mode 100644 index 00000000000..6c7ad14bc18 --- /dev/null +++ b/.changeset/24254-plugin-auth-hook-codes.md @@ -0,0 +1,16 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): register four organization and invitation-placement refusal codes under `@objectstack/plugin-auth` + +Clause-②: yes (widening) + +The error-code ledger (`ERROR_CODE_LEDGER`, and so `ErrorCode`) gains four `@objectstack/plugin-auth` codes. Each names a hook refusal that today answers a bare `403 FORBIDDEN` with a message and no `code`, so a client such as the console cannot tell it apart from any other refusal on the same endpoint: + +- `ORGANIZATION_CREATION_DISABLED`: creating an organization is refused for every caller, because the deployment's effective tenancy posture enforces no organization wall (`/auth/config` reports `features.multiOrgEnabled: false`). +- `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS`: an organization's slug cannot change while the organization has an environment whose status is neither archived nor failed. +- `INVITE_PLACEMENT_UNSUPPORTED`: fail-closed. The invitation carries a placement (a business unit and positions), and this deployment has no delegated-administration runtime to authorize it. +- `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`: the delegated-administration gate refused the invitation's placement, because the business unit or a position lies outside the issuer's delegated scope. + +Registration only: `@objectstack/plugin-auth` still throws these refusals without a code until #24243 stamps them. The same change names a standard code, which needs no ledger row, for the plugin's other four code-less refusals: `RATE_LIMIT_EXCEEDED` for both phone-OTP send walls, which stay indistinguishable; `SERVICE_UNAVAILABLE` for the slug guard's read fault; and `VALIDATION_ERROR` for the reserved `__global__` organization id or slug. No existing code changes, and no standard-catalog member is shadowed (none needs a synonym waiver). diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 8926962d89d..b9d95113cd7 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' \| … +335 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' \| … +339 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`. | @@ -221,6 +221,8 @@ const result = ApiErrorSchema.parse(data); * `INVITE_EMAIL_FAILED` * `INVITE_ISSUER_MEMBER_ONLY` * `INVITE_ISSUER_UNVERIFIED` +* `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE` +* `INVITE_PLACEMENT_UNSUPPORTED` * `INVITE_REQUIRES_EMAIL` * `INVITE_ROLE_ABOVE_ISSUER` * `INVITE_SMS_FAILED` @@ -268,7 +270,9 @@ const result = ApiErrorSchema.parse(data); * `OBJECT_OWNERSHIP_CONFLICT` * `OBJECT_PACKAGE_DISABLED` * `OPENAPI_UNAVAILABLE` +* `ORGANIZATION_CREATION_DISABLED` * `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED` +* `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS` * `OS_PROTOCOL_INCOMPATIBLE` * `PACKAGE_DELETE_FAILED` * `PACKAGE_DELETE_PARTIAL` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 1689493dfff..af0915a155b 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -388,6 +388,8 @@ const result = ErrorCode.parse(data); * `INVITE_EMAIL_FAILED` * `INVITE_ISSUER_MEMBER_ONLY` * `INVITE_ISSUER_UNVERIFIED` +* `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE` +* `INVITE_PLACEMENT_UNSUPPORTED` * `INVITE_REQUIRES_EMAIL` * `INVITE_ROLE_ABOVE_ISSUER` * `INVITE_SMS_FAILED` @@ -435,7 +437,9 @@ const result = ErrorCode.parse(data); * `OBJECT_OWNERSHIP_CONFLICT` * `OBJECT_PACKAGE_DISABLED` * `OPENAPI_UNAVAILABLE` +* `ORGANIZATION_CREATION_DISABLED` * `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED` +* `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS` * `OS_PROTOCOL_INCOMPATIBLE` * `PACKAGE_DELETE_FAILED` * `PACKAGE_DELETE_PARTIAL` diff --git a/packages/spec/src/api/error-code-ledger.test.ts b/packages/spec/src/api/error-code-ledger.test.ts index 94f3b8f9dba..1fa24b51f4e 100644 --- a/packages/spec/src/api/error-code-ledger.test.ts +++ b/packages/spec/src/api/error-code-ledger.test.ts @@ -243,6 +243,24 @@ describe('ErrorCode (standard ∪ registered)', () => { } }); + it('accepts the organization and placement hook refusal codes, registered ahead of their plugin-auth producer', () => { + // #24254 is the spec half of #24243's split: four of plugin-auth's eight + // code-less hook refusals get a new row; the other four answer a standard + // member and need none. #24243 stamps them and asserts these exact strings + // by value — pinned here too. + for (const code of [ + 'INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE', + 'INVITE_PLACEMENT_UNSUPPORTED', + 'ORGANIZATION_CREATION_DISABLED', + 'ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS', + ]) { + expect(ErrorCode.parse(code)).toBe(code); + expect(ERROR_CODE_LEDGER['@objectstack/plugin-auth'], `${code} registered under plugin-auth`).toContain(code); + // INVITE and ORGANIZATION are tokens 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 69956f14fc9..c1463e2555f 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -574,6 +574,11 @@ export const ERROR_CODE_LEDGER = { // each thrown 403 from `beforeCreateInvitation` with its code (#23945). '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 was not established (the read answered with no usable row, or the composition has no data engine to read), so the invitation may confer nothing beyond `member`; a read that throws answers `SERVICE_UNAVAILABLE` (503) instead + // [#24254] The invitation placement gate's two refusals (ADR-0105 D8), thrown + // 403 from `beforeCreateInvitation` after the role cap, registered AHEAD of + // their producer: today both are a bare `FORBIDDEN`, and #24243 stamps these. + 'INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE', // [#24254] 403: the delegated-administration gate refused the invitation's placement intent (its business unit, or a position's permission sets, lies outside the issuer's `adminScope`, ADR-0090 D12), so no invitation is created; the gate's own verdict, not a failure to reach one + 'INVITE_PLACEMENT_UNSUPPORTED', // [#24254] 403, fail-closed: the invitation carries placement intent (a business unit and positions) and this deployment has no delegated-administration runtime (plugin-security's `invitation-placement` service) to authorize it, so no invitation is created; the same invitation without placement is unaffected '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', @@ -582,6 +587,11 @@ export const ERROR_CODE_LEDGER = { 'LAST_LOCAL_CREDENTIAL', // refusing to remove the user's only local credential 'NO_IDENTITY', // import row has neither email nor phone 'NO_PENDING_VERIFICATION', // pass-through from better-auth + // [#24254] The organization hooks' two refusals no standard member names + // (`beforeCreateOrganization`, `beforeUpdateOrganization`), registered AHEAD + // of their producer: today both are a bare `FORBIDDEN`, and #24243 stamps these. + 'ORGANIZATION_CREATION_DISABLED', // [#24254] 403: the deployment's effective tenancy posture enforces no organization wall (ADR-0105), so creating an organization is refused for every caller; the same derivation `/auth/config` publishes as `features.multiOrgEnabled: false`, a deployment setting and not a grant the caller lacks + 'ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS', // [#24254] 403: a change to a new slug is refused while the organization has an active environment, meaning a `sys_environment` row with its `organization_id` whose `status` is neither `archived` nor `failed` (a row with no status counts), and no hostname is read; a guard read that could not answer is `SERVICE_UNAVAILABLE` (503) instead 'PASSWORD_ALREADY_SET', // pass-through from better-auth 'PASSWORD_EXPIRED', 'PASSWORD_POLICY_VIOLATION',