Skip to content

Commit 83dfbc0

Browse files
fix(plugin-auth): the organization, invitation-placement and phone-OTP hook refusals carry their ADR-0112 codes (#24350)
Fixes #24243 Clause-②: no Eight `APIError` throws from plugin-auth's better-auth hooks answered with `message` alone, so a client could only show or pattern-match prose. Each now carries the ADR-0112 code in the spec seat's table (card comment `6248819342`, the codes landed on `main` with PR #24294), typed at the throw: `RegisteredErrorCode` for the four registered codes and `StandardErrorCode` for the four standard ones, as the invitation role cap's constants are typed, so a removed or misspelled ledger row fails `tsc`. Every refusal keeps its HTTP status. | position at `ec749fbb4` | door | status | code | |:--|:--|:--|:--| | `:1343` daily SMS quota | `/auth/phone-number/send-otp`, `/auth/phone-number/request-password-reset` | 429 | `RATE_LIMIT_EXCEEDED` (standard) | | `:5761` per-number OTP limit | the same two | 429 | `RATE_LIMIT_EXCEEDED` (the same constant) | | `:1358` slug guard read fault | `/auth/organization/update` | 503 | `SERVICE_UNAVAILABLE` (standard) | | `:3464` reserved `__global__` id or slug | `/auth/organization/create` | 400 | `VALIDATION_ERROR` (standard) | | `:3472` no organization wall | `/auth/organization/create` | 403 | `ORGANIZATION_CREATION_DISABLED` | | `:3540` no placement runtime | `/auth/organization/invite-member` | 403 | `INVITE_PLACEMENT_UNSUPPORTED` | | `:3558` placement outside the issuer's scope | `/auth/organization/invite-member` | 403 | `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE` | | `:3668` slug change with an active environment | `/auth/organization/update` | 403 | `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS` | ## The three implementer notes 1. **The placement catch is split.** It used to answer every throw out of `assertIssuable` with 403 and the thrown message. Now only the gate's own refusal (plugin-security's `PermissionDeniedError`, read structurally by its `PERMISSION_DENIED` code, because plugin-auth has no runtime dependency on plugin-security) answers 403 `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE` with the gate's message. Any other throw, such as the store fault `resolveUserAuthzGrants` raises as `AuthzStoreUnavailableError`, answers 503 `SERVICE_UNAVAILABLE` with its code. The failure rides `cause` and its message is not relayed. This is the one behaviour change: a store fault was a 403 scope refusal and is now a 503. No invitation row is written either way. The structural interface `InvitationPlacementServiceLike` gains a doc line that states this refusal contract. 2. **The `:3668` message says only what the guard reads.** It no longer says environments "still reference" the organization or tells the caller to rename hostnames. The guard counts `sys_environment` rows by `organization_id` and `status`, and reads no hostname. The new text: "Cannot change the organization slug while the organization has N active environment(s), meaning any environment that is neither archived nor failed. Archive those environments first, or keep the current slug." The read-fault message at `:1358` had the same "still references" wording and now says "whether this organization has an active environment was never determined". 3. **The comment above `SMS_QUOTA_EXCEEDED_CODE`** no longer calls `'TOO_MANY_REQUESTS'` an ADR-0112 error code. It is the service envelope's token (better-call's status name), only matched on the way in. In the same edit, the quota wall's status is spelled `'TOO_MANY_REQUESTS'` directly rather than read off that constant, so both walls now construct the identical status and code. A sentence in the role cap's read-fault helper that this branch made false ("Unlike the slug guard's body, this one carries the code too") now reads "Like". The console's map row for `ORGANIZATION_CREATION_DISABLED` is objectui's and is not in this PR; the seat files it at landing. ## One docs correction, outside the claim's file surface `content/docs/protocol/kernel/error-handling.mdx` had two statements this branch makes false: the `QUOTA_EXCEEDED` entry said the SMS daily quota answers `TOO_MANY_REQUESTS`, and the `RATE_LIMIT_EXCEEDED` entry said that code's envelope is never flat. Both are corrected: the phone-OTP walls answer 429 `RATE_LIMIT_EXCEEDED` in better-auth's flat `code` and `message` body, with no `Retry-After` header (measured: the constructed `APIError` carries `headers {}`) and no `details`. This page is not in the claim's declared surface; the report names it. ## Tests - **The enumeration pin**, `packages/plugins/plugin-auth/src/api-error-code.pin.test.ts` (a test, not a CI gate). It parses every non-test source file under `plugin-auth/src`, finds every `new APIError(...)`, and requires a top-level `code` in the body, or a body destructured from `judgeHeldSession(...)` in the same function (the held-session judge's envelope, whose code is nested under `error`). A comment-free textual count must equal the walked count per file, a floor (30 sites in 4 files) stops a walk that reads nothing, and a fixture battery shows the classifier both reports the code-less shapes and accepts the coded ones. - **Control against the card's table** (one-time): with `auth-manager.ts` swapped to its `ec749fbb4` blob, the pin reports exactly `auth-manager.ts:1343, :1358, :3464, :3472, :3540, :3558, :3668, :5761`, the eight positions of the corrected enumeration (`6245494426`), and does not flag the two relayed `judgeHeldSession` bodies. Restored to the HEAD blob, `git diff HEAD` empty. - **Ablation, one stamp** (`scripts/ablation-replace.mjs`, WRAP mode): deleting the `:3668` stamp turns the pin red at that one site (`auth-manager.ts:3769` in HEAD numbering); restored, blob equal to HEAD, `git diff HEAD` empty. - **Ablation, the behaviour change**: deleting the catch's discrimination turns the store-fault pin red (`expected 403 to be 503`); restored the same way. `auth-manager.test.ts` imports `./auth-manager` from source, so no build sits between the mutation and the reading. - **Wire pins**, reached on a stock showcase boot (the production `single` posture): - `:3472`: `organization-create-refusal-codes.dogfood.test.ts`, a new organization answers 403 `ORGANIZATION_CREATION_DISABLED`, no row; it first asserts `/auth/config` publishes `multiOrgEnabled: false`. - `:3464`: the same file, the `__global__` slug answers 400 `VALIDATION_ERROR` ahead of the posture refusal, no row. - `:3558`: `delegated-admin-invite.dogfood.test.ts`, a `delegated_admin` with no adminScope who requests placement is refused by the real gate with 403 `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`, no row. - **Unit pins**, each with its reason in the test: - `:1343` and `:5761`: `bootStack` mounts no phone-number plugin (`OS_AUTH_PHONE_NUMBER_ENABLED` is read only by `objectstack serve`). The existing "both walls present the SAME outward shape" pin now asserts the one shared `RATE_LIMIT_EXCEEDED`. - `:3668` and `:1358`: `sys_environment` is cloud-provided and no composition in this repository registers it, so the guard does not apply on a stock boot. - `:3540`: `bootStack` always boots plugin-security, which registers the placement service. - The placement store fault: it needs a store that cannot answer. - `:3472` is also pinned in-package through the real better-auth route (`org-create-posture-gate.test.ts`, the single and degraded postures), and `:3464` in `auth-manager.test.ts`. The gate-refusal unit pin uses a structural stand-in for `PermissionDeniedError`. Importing plugin-security there measured roughly 1 to 2 s more import time on `auth-manager.test.ts`, and the real class reaches the catch on the wire in the dogfood pin above. ## Local readings All on `4fc739dfb` (this branch merged with `main` at `51ad8e4fb`), the head this PR opens with: - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0. `pnpm --filter @objectstack/dogfood typecheck`: exit 0. - plugin-auth's suite (`vitest run`): 161 files passed, 3126 tests passed, 10 skipped. - dogfood, the two touched files: 2 files, 9 tests passed. - Gates: `node scripts/pm/dispatch-gates.mjs --commands` derives 99 commands for this diff, and all 99 exited 0 on this head. Two first refused with PREREQUISITE NOT MET (exit 3) because packages outside this diff had no `dist/`: `check:skill-examples` (needs `client-react`) and `check:dual-build-cjs-loads` (seven unbuilt packages). After building those, both re-ran with exit 0, and so did the four other dist-reading gates, re-run on a quiet tree. `--ran` reconciliation: 99 derived, 99 run, 0 NOT-MEASURED. Outside the derivation, `spec check:error-code-provenance` and `spec check:generated` (spec moved on `main`'s side of the merge) both exit 0. - The table control and the stamp ablation ran on `cd27c533a`, the catch ablation on `00cafa8b2`. The merge brought four plugin-auth source files from `main`, which the enumeration pin also reads; run alone on this head it passes 7 of 7. - Not run locally, and left to CI: the repo-wide `pnpm lint`, `test:repo`, and the full dogfood suite. ## Acceptance notes - `packages/services/service-sms/src/sms-daily-quota.ts` (the comment above its `SMS_QUOTA_EXCEEDED_CODE`) says the envelope token is "the same code the per-number guard raises in plugin-auth (`APIError('TOO_MANY_REQUESTS')`)". After this PR that spelling is the shared status name, and the code both walls carry is `RATE_LIMIT_EXCEEDED`. Comment only, outside this card's surface, not touched. Carrier: none. - The comment block above `organizationHooks` in `auth-manager.ts` still says "any lookup error is treated as 'no envs to protect'". Since the slug guard fails closed on a read fault, that is not what the code does. Comment only, not touched. Carrier: none. - The contract review's item 8 (`beforeUpdateOrganization` does not reserve `__global__` while `beforeCreateOrganization` does) is unchanged here; the review escalated it to the seat. - Exclusions held: no `packages/spec` edit, none of the four files PR #24325 touches, nothing in objectui. --- _Generated by [Claude Code](https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a21fd6c commit 83dfbc0

9 files changed

Lines changed: 636 additions & 50 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(plugin-auth): the organization, invitation-placement and phone-OTP hook refusals carry their ADR-0112 codes
6+
7+
Clause-②: no
8+
9+
Eight refusals thrown from plugin-auth's better-auth hooks answered with `message` alone, so a client could only show or match English prose. Each now carries a `code` it can branch on, and keeps its HTTP status.
10+
11+
- `POST /auth/organization/create`: the reserved `__global__` id or slug answers 400 `VALIDATION_ERROR`. Where the deployment's effective tenancy posture enforces no organization wall (`/auth/config` publishes `features.multiOrgEnabled: false`), creating an organization answers 403 `ORGANIZATION_CREATION_DISABLED`.
12+
- `POST /auth/organization/invite-member` with placement intent (`businessUnitId` and `positions`): with no delegated-administration runtime to authorize it, 403 `INVITE_PLACEMENT_UNSUPPORTED`; refused by the delegated-administration gate, 403 `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`.
13+
- `POST /auth/organization/update` with a new slug: while the organization has an active environment (any environment that is neither archived nor failed), 403 `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS`. Its message no longer says environments "still reference" the organization or tells you to rename their hostnames: the guard reads no hostname, and archiving those environments is the remedy. When the guard's own read cannot answer, 503 `SERVICE_UNAVAILABLE`.
14+
- Phone OTP sends (`/auth/phone-number/send-otp`, `/auth/phone-number/request-password-reset`): the per-number limit and the deployment's daily SMS quota both answer 429 `RATE_LIMIT_EXCEEDED`. It is one code for both walls, so a caller still cannot tell which budget it hit. The body is better-auth's flat `code` and `message`, with no `Retry-After` header.
15+
- **One behaviour change:** a placement check that fails before the gate reaches a verdict (the issuer's grants could not be read from the store) now answers 503 `SERVICE_UNAVAILABLE`, not a 403 scope refusal carrying the store's message. No invitation is created either way.

‎content/docs/protocol/kernel/error-handling.mdx‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -738,8 +738,9 @@ have let them count the dependents; `dependentObject` is named either way. Treat
738738
#### `RATE_LIMIT_EXCEEDED`
739739
**HTTP Status:** 429
740740
**Meaning:** Too many requests, rate limit exceeded
741-
**Envelope:** nested — built by the inbound rate limiter, which sits **ahead of every
742-
route**, so it is never flat whichever family you were calling
741+
**Envelope:** nested from the inbound rate limiter, which sits **ahead of every
742+
route**, so its refusal is never flat whichever family you were calling. The phone-OTP
743+
send walls answer this code too, flat (see the end of `QUOTA_EXCEEDED` below)
743744
744745
**Example:**
745746
```json
@@ -835,10 +836,14 @@ Don't feed this 429 to a retry loop that only checks the status, such as the
835836
`RATE_LIMIT_EXCEEDED` example above. It has no `Retry-After` to wait on, and every
836837
retry before `resetAt` is refused again.
837838
838-
Other quota enforcement answers with its own code rather than this one
839-
(the SMS daily quota answers `TOO_MANY_REQUESTS`). For request pacing, the code
840-
on the wire is `RATE_LIMIT_EXCEEDED` above, whose `Retry-After` header and
841-
`retryAfterSeconds` / `resetAt` details are what a client acts on.
839+
Other quota enforcement answers with a code other than this one. The phone-OTP send
840+
walls (`/auth/phone-number/send-otp` and `/auth/phone-number/request-password-reset`), a
841+
phone number's own limit and the deployment's daily SMS quota, both answer 429
842+
`RATE_LIMIT_EXCEEDED`: one code for both, so a caller cannot tell which budget it hit.
843+
That body is better-auth's flat `code` and `message`, with no `Retry-After` header and no
844+
`details`. At the inbound rate limiter, the code on the wire is `RATE_LIMIT_EXCEEDED`
845+
above, whose `Retry-After` header and `retryAfterSeconds` / `resetAt` details are what a
846+
client acts on.
842847
843848
### Server Errors
844849
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24243, ADR-0112] Every `new APIError(` this package throws carries a `code`.
5+
*
6+
* better-auth serialises an `APIError` body as it is given. A body without a
7+
* `code` reaches the caller as `message` alone, so a client (the console among
8+
* them) can only show or pattern-match English prose, on doors where
9+
* better-auth's own refusals carry codes. Eight throws in `auth-manager.ts`
10+
* shipped that way, found by hand; this pin is the enumeration, so the next one
11+
* cannot ship without a code. It is a test in this package, not a CI gate.
12+
*
13+
* It PARSES every non-test source file under `src/` and finds every
14+
* `new APIError(...)`. A site passes when its body carries the code in one of
15+
* two shapes, and is reported with its file and line otherwise:
16+
*
17+
* 1. an object literal with a top-level `code` property; or
18+
* 2. a body destructured, in the same function, from the result of
19+
* `judgeHeldSession(...)`: the held-session judge's ADR-0112 envelope,
20+
* whose code sits under `error` (`platform-admin-gate.held-session.test.ts`
21+
* pins that body's shape).
22+
*
23+
* No body, a literal without `code`, a spread, or an identifier from anywhere
24+
* else is a site this pin cannot read a code from, so it fails.
25+
*
26+
* It parses and does not type-check, so the code's VALUE is not judged here.
27+
* Each stamp is a constant typed `RegisteredErrorCode` or `StandardErrorCode`,
28+
* so `tsc` judges the value, and `check:error-code-provenance` judges whether a
29+
* registered one is listed under this package.
30+
*
31+
* Two controls keep the walk honest:
32+
* - a textual count over the comment-free source (the TypeScript printer, with
33+
* comments removed) must equal the sites the walk found in each file, so a
34+
* constructor spelling the walk does not recognise cannot slip past it;
35+
* - the classifier must report each shape that shipped without a code and
36+
* accept each shape that carries one (the fixture battery below).
37+
*/
38+
39+
import { describe, it, expect } from 'vitest';
40+
import { readdirSync, readFileSync } from 'node:fs';
41+
import { dirname, join, relative } from 'node:path';
42+
import { fileURLToPath } from 'node:url';
43+
import ts from 'typescript';
44+
45+
const HERE = dirname(fileURLToPath(import.meta.url));
46+
47+
interface ApiErrorSite {
48+
readonly file: string;
49+
readonly line: number;
50+
/** `null` when the body carries a code; otherwise why this pin cannot read one. */
51+
readonly missing: string | null;
52+
}
53+
54+
const isTestFile = (name: string): boolean => /\.(test|spec)\.tsx?$/.test(name) || name.endsWith('.d.ts');
55+
56+
function sourceFiles(dir: string): string[] {
57+
const out: string[] = [];
58+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
59+
const path = join(dir, entry.name);
60+
if (entry.isDirectory()) out.push(...sourceFiles(path));
61+
else if (/\.tsx?$/.test(entry.name) && !isTestFile(entry.name)) out.push(path);
62+
}
63+
return out.sort();
64+
}
65+
66+
const isApiErrorCallee = (callee: ts.Expression): boolean =>
67+
(ts.isIdentifier(callee) && callee.text === 'APIError') ||
68+
(ts.isPropertyAccessExpression(callee) && callee.name.text === 'APIError');
69+
70+
function propertyName(name: ts.PropertyName): string | undefined {
71+
if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
72+
return undefined;
73+
}
74+
75+
function enclosingFunction(node: ts.Node): ts.Node | undefined {
76+
for (let cur = node.parent; cur; cur = cur.parent) {
77+
if (ts.isFunctionLike(cur)) return cur;
78+
}
79+
return undefined;
80+
}
81+
82+
/** Every variable declaration inside `scope`, nested blocks included. */
83+
function declarationsIn(scope: ts.Node): ts.VariableDeclaration[] {
84+
const found: ts.VariableDeclaration[] = [];
85+
const visit = (node: ts.Node): void => {
86+
if (ts.isVariableDeclaration(node)) found.push(node);
87+
ts.forEachChild(node, visit);
88+
};
89+
visit(scope);
90+
return found;
91+
}
92+
93+
const unwrapAwait = (e: ts.Expression | undefined): ts.Expression | undefined =>
94+
e && ts.isAwaitExpression(e) ? e.expression : e;
95+
96+
/**
97+
* Shape 2: `const { body } = held;` (or `{ body: X }`) where `held` is
98+
* `judgeHeldSession(…)`'s result, both in the throw's own function.
99+
*/
100+
function isHeldSessionBody(body: ts.Identifier, site: ts.NewExpression): boolean {
101+
const scope = enclosingFunction(site);
102+
if (!scope) return false;
103+
const decls = declarationsIn(scope);
104+
const holders = decls
105+
.filter((d) => ts.isObjectBindingPattern(d.name) && d.initializer && ts.isIdentifier(d.initializer))
106+
.filter((d) =>
107+
(d.name as ts.ObjectBindingPattern).elements.some((el) => {
108+
const key = el.propertyName ? propertyName(el.propertyName) : ts.isIdentifier(el.name) ? el.name.text : undefined;
109+
return key === 'body' && ts.isIdentifier(el.name) && el.name.text === body.text;
110+
}),
111+
)
112+
.map((d) => (d.initializer as ts.Identifier).text);
113+
return holders.some((holder) =>
114+
decls.some((d) => {
115+
if (!ts.isIdentifier(d.name) || d.name.text !== holder) return false;
116+
const init = unwrapAwait(d.initializer);
117+
return (
118+
!!init && ts.isCallExpression(init) && ts.isIdentifier(init.expression) && init.expression.text === 'judgeHeldSession'
119+
);
120+
}),
121+
);
122+
}
123+
124+
function whyCodeless(site: ts.NewExpression, sf: ts.SourceFile): string | null {
125+
const body = site.arguments?.[1];
126+
if (!body) return 'it passes no body at all';
127+
if (ts.isObjectLiteralExpression(body)) {
128+
const carriesCode = body.properties.some(
129+
(p) => (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && propertyName(p.name) === 'code',
130+
);
131+
return carriesCode ? null : 'its body has no top-level `code` property';
132+
}
133+
if (ts.isIdentifier(body) && isHeldSessionBody(body, site)) return null;
134+
return `its body is \`${body.getText(sf)}\`, which this pin cannot read a code from`;
135+
}
136+
137+
/** Every `new APIError(…)` in one source text, each with its verdict. */
138+
function apiErrorSites(file: string, text: string): ApiErrorSite[] {
139+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
140+
const sites: ApiErrorSite[] = [];
141+
const visit = (node: ts.Node): void => {
142+
if (ts.isNewExpression(node) && isApiErrorCallee(node.expression)) {
143+
sites.push({
144+
file,
145+
line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1,
146+
missing: whyCodeless(node, sf),
147+
});
148+
}
149+
ts.forEachChild(node, visit);
150+
};
151+
visit(sf);
152+
return sites;
153+
}
154+
155+
/** The textual control: constructor calls counted in the source with its comments removed. */
156+
function textualCount(file: string, text: string): number {
157+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
158+
const printed = ts.createPrinter({ removeComments: true }).printFile(sf);
159+
return printed.match(/\bnew\s+(?:[A-Za-z_$][\w$]*\s*\.\s*)*APIError\s*\(/g)?.length ?? 0;
160+
}
161+
162+
describe('every new APIError( in plugin-auth/src carries an ADR-0112 code (#24243)', () => {
163+
const files = sourceFiles(HERE);
164+
const perFile = files.map((path) => {
165+
const text = readFileSync(path, 'utf8');
166+
const file = relative(HERE, path);
167+
return { file, sites: apiErrorSites(file, text), textual: textualCount(file, text) };
168+
});
169+
const sites = perFile.flatMap((f) => f.sites);
170+
171+
it('the walk reaches the throws: a floor of sites and files, so a walk that reads nothing cannot pass', () => {
172+
// Measured when this pin landed: 30 sites in 4 files (`auth-manager.ts`
173+
// 24, `objectql-adapter.ts` 3, `impersonation-bearer-rotation.ts` 2,
174+
// `admin-impersonate-endpoint.ts` 1). A floor, not an exact count, so a new
175+
// throw does not have to edit this line; only losing them does.
176+
expect(sites.length).toBeGreaterThanOrEqual(30);
177+
expect(new Set(sites.map((s) => s.file)).size).toBeGreaterThanOrEqual(4);
178+
expect(perFile.find((f) => f.file === 'auth-manager.ts')?.sites.length ?? 0).toBeGreaterThanOrEqual(24);
179+
});
180+
181+
it('the textual control agrees with the walk in every file', () => {
182+
const disagreements = perFile
183+
.filter((f) => f.textual !== f.sites.length)
184+
.map((f) => `${f.file}: ${f.textual} textual, ${f.sites.length} walked`);
185+
expect(disagreements).toEqual([]);
186+
});
187+
188+
it('no site throws a body without a code', () => {
189+
const codeless = sites.filter((s) => s.missing !== null).map((s) => `${s.file}:${s.line}: ${s.missing}`);
190+
expect(codeless).toEqual([]);
191+
});
192+
});
193+
194+
describe('the classifier, against the shapes it must tell apart', () => {
195+
const verdicts = (body: string) => apiErrorSites('fixture.ts', body).map((s) => s.missing === null);
196+
197+
it('reports both shapes the eight code-less throws had (the card enumeration)', () => {
198+
// As they stood before #24243: a status read off a constant with a
199+
// message-only body (the SMS quota wall), and a status literal with a
200+
// message-only body (the other seven).
201+
const fixture = `
202+
async function quota(message: string) { return new APIError(SMS_QUOTA_EXCEEDED_CODE, { message }); }
203+
async function hook() {
204+
throw new APIError('FORBIDDEN', { message: 'Creating additional organizations is disabled.' });
205+
}
206+
async function relayed(err: any) {
207+
throw new APIError('FORBIDDEN', { message: err?.message ?? 'outside your scope' });
208+
}`;
209+
expect(verdicts(fixture)).toEqual([false, false, false]);
210+
});
211+
212+
it('reports a site with no body, a spread with no code, and an identifier it cannot trace', () => {
213+
const fixture = `
214+
function a() { throw new APIError('FORBIDDEN'); }
215+
function b(base: object) { throw new APIError('FORBIDDEN', { ...base }); }
216+
function c(body: object) { throw new APIError('FORBIDDEN', body); }
217+
function d() { const { body } = somethingElse(); throw new APIError('FORBIDDEN', body); }`;
218+
expect(verdicts(fixture)).toEqual([false, false, false, false]);
219+
});
220+
221+
it('accepts a literal or shorthand `code`, a string key, a member callee and the held-session relay', () => {
222+
const fixture = `
223+
function a() { throw new APIError('FORBIDDEN', { message: 'm', code: SOME_CODE }); }
224+
function b(code: string) { throw new APIError('FORBIDDEN', { message: 'm', code }); }
225+
function c() { throw new APIError('FORBIDDEN', { 'code': 'X', message: 'm' }); }
226+
function d() { throw new api.APIError('FORBIDDEN', { message: 'm', code: SOME_CODE }); }
227+
async function e() {
228+
const held = judgeHeldSession({ authGate: await gateOf() });
229+
if (!held) return;
230+
const { body } = held;
231+
throw new APIError('FORBIDDEN', body);
232+
}`;
233+
expect(verdicts(fixture)).toEqual([true, true, true, true, true]);
234+
});
235+
236+
it('the textual control sees past comments, and counts a member-callee site', () => {
237+
const fixture = `
238+
// new APIError('FORBIDDEN', { message: 'in a comment' })
239+
/** new APIError('X') in a doc block */
240+
function a() { throw new api.APIError('FORBIDDEN', { message: 'm', code: C }); }`;
241+
expect(textualCount('fixture.ts', fixture)).toBe(1);
242+
expect(apiErrorSites('fixture.ts', fixture)).toHaveLength(1);
243+
});
244+
});

‎packages/plugins/plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@
2525
* `sys_environment` (the open-source composition) declares the guard
2626
* inapplicable without reading anything. The real ObjectQL registry
2727
* answers that question in the last section.
28+
*
29+
* [#24243] Both answers are pinned on their ADR-0112 codes, the refusal's
30+
* registered `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS` and the read
31+
* fault's standard `SERVICE_UNAVAILABLE`, at this level rather than on the
32+
* wire: `sys_environment` is a cloud-provided object that no composition in this
33+
* repository registers, so on a stock dogfood boot the guard does not apply and
34+
* neither answer has a door.
2835
*/
2936

3037
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -108,15 +115,19 @@ async function slugGuard(dataEngine: unknown) {
108115
return (slug: string) => guard({ organization: { slug }, member: { organizationId: ORG.id } });
109116
}
110117

111-
/** The guard's refusal, asserted on its envelope: better-auth's `FORBIDDEN` / 403, naming the active environments. */
118+
/**
119+
* The guard's refusal, asserted on its envelope: better-auth's `FORBIDDEN` / 403
120+
* with its registered ADR-0112 code (#24243), naming the active environments.
121+
*/
112122
async function expectSlugRefusal(attempt: Promise<unknown>) {
113123
const err = (await attempt.then(
114124
() => undefined,
115125
(e: unknown) => e,
116-
)) as { status?: unknown; statusCode?: unknown; body?: { message?: unknown } } | undefined;
126+
)) as { status?: unknown; statusCode?: unknown; body?: { code?: unknown; message?: unknown } } | undefined;
117127
expect(err, 'the slug change was not refused').toBeTruthy();
118128
expect(err?.statusCode).toBe(403);
119129
expect(err?.status).toBe('FORBIDDEN');
130+
expect(err?.body?.code).toBe('ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS');
120131
expect(String(err?.body?.message)).toMatch(/active.*environment/i);
121132
}
122133

@@ -180,18 +191,20 @@ describe('organization slug guard — on an engine that refuses a principal-less
180191

181192
/**
182193
* A read that could not answer, asserted on its envelope: better-auth's
183-
* `SERVICE_UNAVAILABLE` / 503, naming the object whose read failed — and
184-
* explicitly NOT the guard's `FORBIDDEN` / 403, which is a verdict about the
185-
* slug change that the guard never reached.
194+
* `SERVICE_UNAVAILABLE` / 503 with the standard code of the same name (#24243),
195+
* naming the object whose read failed — and explicitly NOT the guard's
196+
* `FORBIDDEN` / 403, which is a verdict about the slug change that the guard
197+
* never reached.
186198
*/
187199
async function expectReadFaultRefusal(attempt: Promise<unknown>, object: string) {
188200
const err = (await attempt.then(
189201
() => undefined,
190202
(e: unknown) => e,
191-
)) as { status?: unknown; statusCode?: unknown; body?: { message?: unknown }; cause?: unknown } | undefined;
203+
)) as { status?: unknown; statusCode?: unknown; body?: { code?: unknown; message?: unknown }; cause?: unknown } | undefined;
192204
expect(err, 'the slug change was let through on a read that could not answer').toBeTruthy();
193205
expect(err?.statusCode).toBe(503);
194206
expect(err?.status).toBe('SERVICE_UNAVAILABLE');
207+
expect(err?.body?.code).toBe('SERVICE_UNAVAILABLE');
195208
expect(String(err?.body?.message)).toContain(`\`${object}\``);
196209
return err;
197210
}

0 commit comments

Comments
 (0)