Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/24254-plugin-auth-hook-codes.md
Original file line number Diff line number Diff line change
@@ -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).
6 changes: 5 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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`
Expand Down
4 changes: 4 additions & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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`
Expand Down
18 changes: 18 additions & 0 deletions packages/spec/src/api/error-code-ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/spec/src/api/error-code-ledger.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
Loading