Repository navigation
Commit 0b44840
feat(spec): name the error codes for plugin-auth's eight code-less hook refusals, registering the four no standard code fits (#24294)
Fixes #24254
Clause-②: yes
## What
`plugin-auth` throws eight hook refusals as a better-auth `APIError`
with a `message` and no `code`, so a client such as the console cannot
branch on them. This PR names one ADR-0112 code for each of them.
- **Four get a standard code**, so they need no ledger row.
- **Four get a new `@objectstack/plugin-auth` row** in
`packages/spec/src/api/error-code-ledger.zod.ts`, each with its meaning
comment.
This is registration only, ahead of the producer. Stamping the codes in
`plugin-auth` is #24243's work. #24243 remains open, and this PR does
not touch `packages/plugins/plugin-auth/**`.
## The table #24243 stamps from
Line numbers are `packages/plugins/plugin-auth/src/auth-manager.ts` on
this PR's base, `origin/main` `93b4acb57`. They are the same as the
card's.
| refusal (line, door) | status | code | standard or new | why |
|:--|:--|:--|:--|:--|
| `:1343`, phone OTP send: the deployment-wide SMS quota
(`smsQuotaExceededApiError`) | 429 | `RATE_LIMIT_EXCEEDED` | standard |
It must equal `:5761`. The header above `:1343` records that the two
walls stay indistinguishable from outside, so a caller cannot learn
which budget it hit. `RATE_LIMIT_EXCEEDED` is the 429 member for request
pacing platform-wide: the runtime's inbound limiter answers with it, and
the retired-code prescription in `retired-error-codes.ts` points pacing
clients at it. |
| `:5761`, phone OTP send: the per-number cooldown and hourly cap
(`assertPhoneOtpSendAllowed`) | 429 | `RATE_LIMIT_EXCEEDED` | standard |
The same code as `:1343`, by the constraint above. |
| `:1358`, update-organization: the slug guard's read could not answer
(`slugGuardReadFaultApiError`) | 503 | `SERVICE_UNAVAILABLE` | standard
| The #23945 precedent applies unchanged. `03ac4eca2` stamped the same
condition (a store read that reached no verdict) on the invitation role
cap as `INVITATION_ROLE_CAP_READ_FAULT_CODE: StandardErrorCode =
'SERVICE_UNAVAILABLE'` (`auth-manager.ts:1369`). There is no reason for
the slug guard to differ. |
| `:3464`, create-organization hook: the reserved `__global__` id or
slug (ADR-0120 D3) | 400 | `VALIDATION_ERROR` | standard | The refusal
rejects an input value, which is the generic validation member's
condition. No client branches on it. The console cannot send
`__global__`, because its create dialog folds `_` into `-` when it
derives a slug (objectui `CreateWorkspaceDialog.tsx:53`). |
| `:3472`, create-organization hook: creating more organizations is
disabled on this deployment | 403 | `ORGANIZATION_CREATION_DISABLED` |
**new** | The judge is the deployment's effective tenancy posture
(`multiOrgPostureEffective()`, ADR-0105), which `/auth/config` publishes
as `features.multiOrgEnabled`. The console already holds its own
sentence for this posture (`workspace.multiOrgDisabled`, objectui
`CreateWorkspaceDialog.tsx:190`), but the server's refusal gives it no
code to map. The name is a sibling of `DOMAIN_VERIFICATION_DISABLED` in
the same owner row. |
| `:3540`, invitation placement hook: no delegated-administration
runtime on this deployment | 403 | `INVITE_PLACEMENT_UNSUPPORTED` |
**new** | This is a fact about the composition. It is not the actor's
permission and not a transient outage. Retrying the same request cannot
succeed, while the same invitation without a placement can.
`UNSUPPORTED` follows `RAW_SQL_UNSUPPORTED` and
`MONGODB_MULTI_TENANT_UNSUPPORTED`. |
| `:3558`, invitation placement hook: the placement is outside the
issuer's delegated scope | 403 | `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`
| **new** | It is named in the shape of `INVITE_ROLE_ABOVE_ISSUER`:
subject, relation, issuer. The console's Invite User dialog sends a
placement and maps each refusal by code (objectui
`InviteMemberDialog.tsx:133`), so it can tell "this placement" apart
from "this invitation". |
| `:3668`, update-organization slug guard: the organization still has an
environment whose status is neither archived nor failed | 403 |
`ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS` | **new** | Renamed in
patch round 1 from `ORGANIZATION_SLUG_REFERENCED_BY_ENVIRONMENTS`,
because the guard establishes no reference: it reads no hostname, only
whether a `sys_environment` row with this `organization_id` has a
`status` other than `archived` or `failed`
(`auth-manager.ts:3654`–`:3661`). The new name states that cause and
what it blocks, and it matches the console's `slugLocked` state, which
reads the same predicate (objectui `orgEnvironments.ts:85`). It still
cannot be confused with better-auth's `ORGANIZATION_SLUG_ALREADY_TAKEN`
on the same door. The console's settings page holds a translated
sentence for this state, but it shows the server's English when a save
is refused (objectui `SettingsPage.tsx:154`). |
objectui is cited at `32b492b67`.
## Naming rationale: the alternatives considered and not taken
- **`:1343` / `:5761`: not `QUOTA_EXCEEDED`.** One code has to describe
both walls, and the per-number wall is a cooldown, not a quota.
`QUOTA_EXCEEDED` also carries the console chatbot's AI-usage reading
(`details.resetAt`).
- **`:1343` / `:5761`: not `TOO_MANY_REQUESTS`.** That string is the SMS
service's internal prefix and better-call's status name.
`standardSynonymOf('TOO_MANY_REQUESTS')` returns `RATE_LIMIT_EXCEEDED`,
so it cannot be registered without a synonym waiver.
- **`:3464`: not `INVALID_FIELD`.** In this repo that code refuses the
field a request names, not the value it carries: an unknown field
(`metadata-protocol`), a mapping target the object does not declare
(`core`'s import mapping), and a column a door will not filter on
(`mcp`).
- **`:3464`: not a new code.** A specific code can be registered when a
client needs to branch on it. Today none does.
- **`:3472`: not `PERMISSION_DENIED`.** No grant changes the answer,
because the refusal applies to every caller.
- **`:3472`: not better-auth's
`YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION`.** That is a per-user
verdict (`allowUserToCreateOrganization(user)`). The console maps it to
a different sentence ("You are not allowed to create a new
organization", objectui `orgErrorMessage.ts`). Reusing it would also
make a future per-user gate indistinguishable from the posture.
- **`:3540`: not `PERMISSION_DENIED`, `SERVICE_UNAVAILABLE`,
`NOT_IMPLEMENTED` or `LICENSE_REQUIRED`.** Each names a different
condition: a grant, a temporary outage, unbuilt code (501), or a
licence.
- **`:3558`: not `PERMISSION_DENIED`.** It is the one real standard
alternative, because it is the code the delegated-administration gate's
own `PermissionDeniedError` carries (plugin-security `errors.ts:42`). It
was not chosen because #24082 named this door's issuer-authority
refusals for the condition, not the status, and the card asks for
siblings of those.
- **`:3668`: not `PERMISSION_DENIED`.** An owner is refused too.
- **`:3668`: not `RESOURCE_CONFLICT`.** That code is a 409, and the
refusal stays a 403.
- **`:3668`: not `ORGANIZATION_SLUG_IN_USE`.** It reads as "taken by
another organization".
- **`:3668`: not `ORGANIZATION_SLUG_REFERENCED_BY_ENVIRONMENTS`**, this
PR's first spelling. It claimed a reference that the guard never checks,
because no hostname is read.
## Measurements (head `0ddc5f00b`)
- **The eight throw sites (PM assumption A1, held).** On `93b4acb57`,
all eight sit at the card's line numbers. None passes a `code:`.
better-call 1.4.0's `APIError` stores `body` verbatim and derives no
code. Measured with `better-auth/api`: `new
APIError('TOO_MANY_REQUESTS', { message })` has `body` `{ message }`
only.
- **The read fault (A2, held).** `:1358` takes the standard
`SERVICE_UNAVAILABLE` and needs no row, as `03ac4eca2` did for the role
cap's read fault.
- **The OTP walls (A3, held).** The walls take the standard
`RATE_LIMIT_EXCEEDED`. `standardSynonymOf` was asked on the BUILT dist
(`@objectstack/spec/api`, resolved to `packages/spec/dist/api/index.mjs`
after `pnpm --filter @objectstack/spec build`, exit 0):
| name | StandardErrorCode | ErrorCode | standardSynonymOf |
|:--|:--|:--|:--|
| `RATE_LIMIT_EXCEEDED`, `QUOTA_EXCEEDED`, `SERVICE_UNAVAILABLE`,
`VALIDATION_ERROR`, `INVALID_FIELD`, `PERMISSION_DENIED` | member |
parses | `undefined` |
| `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`,
`INVITE_PLACEMENT_UNSUPPORTED`, `ORGANIZATION_CREATION_DISABLED`,
`ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS` | no | parses (new
rows) | `undefined` |
| `TOO_MANY_REQUESTS` | no | refused | `RATE_LIMIT_EXCEEDED` |
| `FORBIDDEN` | no | parses (waived row) | `PERMISSION_DENIED` |
| `YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION` | no | refused |
`undefined` |
`standardSynonymViolations()` on the same dist returns `[]`, so no
waiver is needed.
- **Name collisions.** Each of the four new names has 0 hits in this
repo outside this diff and 0 in objectui. A control name,
`INVITE_ROLE_ABOVE_ISSUER`, does hit. So `check:error-code-provenance`
has no existing stamp site to red on.
- **Gates that enumerate the ledger (A4).** I searched for tests and
gates that iterate the ledger, `REGISTERED_ERROR_CODES` or `ErrorCode`
as a whole set. None of them goes red because a row is added:
- Inside `packages/spec`: the ledger admission test (casing, duplicates,
shadowing, synonyms, the sorted union) and
`check:error-code-provenance`, which reds only on a stamp site with no
row.
- At the repo root: `check:error-status-conformance`, whose header says
registering a ledger code "moves no literal here";
`check:dispatcher-error-vocabulary`; `check:error-code-casing`; and the
two regenerated reference pages.
- Outside `packages/spec`, the one test that iterates every owner row is
`packages/rest/src/rest-field-visibility-fault-envelope.test.ts`. It
asserts that no row lists `RECORD_NOT_ACCESSIBLE`. Run after building
`@objectstack/types`: 1 file, 4 tests passed. The first attempt stopped
at module resolution because `@objectstack/types` was not built. That
was a prerequisite, not a reading.
## Notes for #24243's implementer
- **`:1343`.** The first argument of `new
APIError(SMS_QUOTA_EXCEEDED_CODE, { message })` is better-call's status
name. `SMS_QUOTA_EXCEEDED_CODE` is `'TOO_MANY_REQUESTS'`, so it happens
to work as one. The body `code` is `RATE_LIMIT_EXCEEDED`.
`TOO_MANY_REQUESTS` is not an admissible body code (see the table
above).
- **`:3558`.** The `catch` maps every throw out of `assertIssuable` to a
403 and passes on its message. `assertIssuable` also reads the store
(`resolveUserAuthzGrants`, plugin-security
`invitation-placement.ts:153`), so a store fault there would come back
as a scope refusal, carrying the driver's message.
`INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE` means the gate's refusal
(`PermissionDeniedError`). A throw that is not the gate's refusal
reached no verdict, and #23945 answered that case `SERVICE_UNAVAILABLE`
for the role cap. Whether to split it here is #24243's call.
- **`:3540`.** The service lookup at `:3537` ends in a `.catch` that
answers `undefined`, and the resolver in `auth-plugin.ts` has its own
`catch`. Both treat a lookup that throws the same as an absent service.
## Files
- `packages/spec/src/api/error-code-ledger.zod.ts`: four rows under
`@objectstack/plugin-auth`, each with a meaning comment, in two
commented groups.
- `packages/spec/src/api/error-code-ledger.test.ts`: one case that pins
the four rows by value: each parses, is under `plugin-auth`, and has no
synonym. Same shape as #24082's.
- `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`. The enum tail moved from `+335 more`
to `+339 more`.
- `.changeset/24254-plugin-auth-hook-codes.md`: `minor` on
`@objectstack/spec`, `Clause-②: yes (widening)`.
## Verification
Every reading below is on head `0ddc5f00b`, the final commit after patch
round 1. Exit codes were captured before any pipe.
- `pnpm --filter @objectstack/spec build`: exit 0.
- `pnpm --filter @objectstack/spec exec vitest run
src/api/error-code-ledger.test.ts src/api/api-error-code-type.test.ts`:
2 files, 25 tests passed.
- `pnpm --filter @objectstack/spec test` (the `local` project): 693
files passed; 21,003 tests passed and 1 todo.
- `pnpm --filter @objectstack/spec typecheck`: exit 0. It includes
`check:test-typecheck` over `tsconfig.test.json`.
- **Ablation of the renamed row, done once.** The rows were committed
first. Then `node scripts/ablation-replace.mjs` turned the
`ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS` row into a comment:
- The anchor went from 1 to 0, and the blob changed from `c1463e2555f9`
to `d761b61aca77`.
- The ledger test then reported 1 failed and 22 passed. The failure was
exactly the new case, `accepts the organization and placement hook
refusal codes…`.
- Restore: the blob equals HEAD (`c1463e2555f9`), `git diff HEAD` is
empty, and `git status` is clean.
- Round 0 had ablated the `INVITE_PLACEMENT_UNSUPPORTED` row the same
way, with the same result. The test imports the ledger by relative path,
so it reads source and needs no `dist` rebuild.
- `pnpm --filter @objectstack/spec check:generated`: exit 1 before
regenerating (`check:docs` stale), exit 0 after `--fix`.
- The intermediate head `070661f71` carried the rename without the
regenerated pages. That is the red `Type Check · source gates` on that
head; `0ddc5f00b` carries the pages.
- `standardSynonymOf('ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS')`
on the built dist: `undefined`.
- The new name has 0 hits in this repo outside this diff and 0 in
objectui. The old name has 0 hits anywhere in the tree.
- **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` derived 113 commands, the same list
as round 0, and all of them were run in one pass after a full workspace
build.
- All 113 exit 0, among them `check:error-code-casing`,
`check:error-status-conformance`, `check:error-code-provenance`,
`check:dispatcher-error-vocabulary`, `check:api-surface`, `check:docs`,
`check:type-check-debt`, `check:dual-build-cjs-loads` and
`check:lean-entry-closure`.
- The `--ran` reconciliation exits 0: `113 derived famil(ies) accounted
for — 113 run, 0 NOT-MEASURED (a DERIVED zero — all 113 recorded an exit
code and none of them is 3)`.
- `check:dev-prereqs` and `check:skill-frame-freshness` (local-only):
exit 0 both.
- ESLint (`--no-inline-config`) over the two changed `.ts` files: exit
0.
- **Not run locally.** `pnpm --filter @objectstack/spec test:repo` (the
`repo` project) belongs to CI's `Test Core`. The repo-wide `pnpm lint`
belongs to `Lint & Repo Gates`.
- **Not merged with main.** The branch is on `93b4acb57`. The
`origin/main` commits since then share 0 files with this diff, and none
touches the ledger or the two regenerated pages. So the PR's CI on the
merge ref is the joint check.
## Acceptance notes
None of these was filed. Each is a reading or an inference, not a
reproduced defect.
- **A console map row nothing on this tree emits.** objectui
`orgErrorMessage.ts` calls
`YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_ORGANIZATION` "the
multi-org-disabled refusal". No producer in `packages/**` emits that
code: nothing sets `allowUserToCreateOrganization`, so better-auth's
default allows creation. Once #24243 stamps
`ORGANIZATION_CREATION_DISABLED`, the console needs a row mapping it to
its existing `workspace.multiOrgDisabled`. Carrier: none.
- **The slug guard's remedy may not clear the guard.** The refusal tells
the caller to "rename their hostnames first". The guard counts
environments only by `organization_id` and `status`
(`auth-manager.ts:3660`), so renaming a hostname alone would not clear
it. This is an inference from the source. It was not reproduced, because
that needs cloud's `sys_environment`. Carrier: none.
- **The reservation is checked on create only.**
`beforeCreateOrganization` reserves `__global__`, but
`beforeUpdateOrganization` does not refuse a slug change to it. This is
an inference from the source. Carrier: none.
- **A comment calls a non-member an ADR-0112 code.** The comment on
`SMS_QUOTA_EXCEEDED_CODE` calls `'TOO_MANY_REQUESTS'` "a
closed-vocabulary ADR-0112 error code". It is neither a
`StandardErrorCode` member nor a ledger row: `ErrorCode.safeParse`
refuses it, measured above. Carrier: none.
---
_Generated by [Claude
Code](https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 30da04f commit 0b44840
5 files changed
Lines changed: 53 additions & 1 deletion
File tree
- .changeset
- content/docs/references/api
- packages/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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
28 | 28 | | |
29 | 29 | | |
30 | 30 | | |
31 | | - | |
| 31 | + | |
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
| |||
221 | 221 | | |
222 | 222 | | |
223 | 223 | | |
| 224 | + | |
| 225 | + | |
224 | 226 | | |
225 | 227 | | |
226 | 228 | | |
| |||
268 | 270 | | |
269 | 271 | | |
270 | 272 | | |
| 273 | + | |
271 | 274 | | |
| 275 | + | |
272 | 276 | | |
273 | 277 | | |
274 | 278 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
388 | 388 | | |
389 | 389 | | |
390 | 390 | | |
| 391 | + | |
| 392 | + | |
391 | 393 | | |
392 | 394 | | |
393 | 395 | | |
| |||
435 | 437 | | |
436 | 438 | | |
437 | 439 | | |
| 440 | + | |
438 | 441 | | |
| 442 | + | |
439 | 443 | | |
440 | 444 | | |
441 | 445 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
243 | 243 | | |
244 | 244 | | |
245 | 245 | | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
246 | 264 | | |
247 | 265 | | |
248 | 266 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
574 | 574 | | |
575 | 575 | | |
576 | 576 | | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
577 | 582 | | |
578 | 583 | | |
579 | 584 | | |
| |||
582 | 587 | | |
583 | 588 | | |
584 | 589 | | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
585 | 595 | | |
586 | 596 | | |
587 | 597 | | |
| |||
0 commit comments