From 9417e3408017389956c345ade1ce23491c93a87d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:59:04 +0000 Subject: [PATCH 1/5] fix(plugin-auth): the organization, invitation-placement and phone-OTP hook refusals carry their ADR-0112 codes Eight APIError throws in auth-manager.ts answered with a message and no code. Each now carries the code the spec seat's table names, typed RegisteredErrorCode or StandardErrorCode at the throw: - both phone-OTP send walls (daily SMS quota, per-number limit): one shared RATE_LIMIT_EXCEEDED, so the two walls stay indistinguishable; - the slug guard's read fault: SERVICE_UNAVAILABLE; - the reserved __global__ organization id or slug: VALIDATION_ERROR; - organization creation on a posture with no wall: ORGANIZATION_CREATION_DISABLED; - placement intent with no placement runtime: INVITE_PLACEMENT_UNSUPPORTED; - the placement gate's own refusal: INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE; any other throw out of assertIssuable (a store fault) now answers 503 SERVICE_UNAVAILABLE instead of a 403 scope refusal; - a slug change while the organization has an active environment: ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS, with its message corrected to what the guard reads (no hostname). The comment above SMS_QUOTA_EXCEEDED_CODE no longer calls the service's envelope token an ADR-0112 error code. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-manager.ts | 130 ++++++++++++++++-- 1 file changed, 117 insertions(+), 13 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 698973c2e6c..e2240b81d20 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -73,7 +73,7 @@ import { } from '@objectstack/spec'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; -import type { StandardErrorCode } from '@objectstack/spec/api'; +import type { RegisteredErrorCode, StandardErrorCode } from '@objectstack/spec/api'; import { createObjectQLAdapterFactory, withSystemContext, withSystemReadContext } from './objectql-adapter.js'; import { permissionSetInEffect } from './catalog-set-in-effect.js'; import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js'; @@ -604,6 +604,11 @@ export function isOAuthEligibleBaseUrl(url: string): boolean { * plugin-auth gains no dependency on plugin-security. */ export interface InvitationPlacementServiceLike { + /** + * Refuses with the gate's `PermissionDeniedError` (`code: 'PERMISSION_DENIED'`) + * when the issuer may not place this invitation. Any other throw is a failure + * to reach that verdict, such as a store that could not answer. + */ assertIssuable(args: { intent: { businessUnitId: string; positions: string[] }; /** The ISSUER's user id — the service resolves their grants itself. */ @@ -1153,9 +1158,13 @@ export function ipMatchesRange(ip: string, range: string): boolean { * plugin-auth's `normalizePhoneNumber` … kept local: the two packages must not * depend on each other"). * - * Only the CODE crosses the boundary — a closed-vocabulary ADR-0112 error code, - * not prose. The message half of the envelope is service-owned and may be - * reworded without touching this file. + * Only the envelope's CODE token crosses the boundary, never its prose: the + * message half is service-owned and may be reworded without touching this + * file. That token is the service's own spelling, which is better-call's + * status NAME for 429; it is not an ADR-0112 error code (`ErrorCode` refuses + * it as a reason-phrase alias of `RATE_LIMIT_EXCEEDED`). So it is only ever + * MATCHED here, never put in a body: the code the 429 carries to the caller + * is {@link PHONE_OTP_SEND_REFUSAL_CODE}. */ const SMS_QUOTA_EXCEEDED_CODE = 'TOO_MANY_REQUESTS'; @@ -1337,12 +1346,31 @@ function acceptLanguageHeader(source: unknown): string | undefined { * which budget they hit, and a legitimate caller needs no more than "not now". * (The per-number wall names its own retry window because it can compute one; * this wall states no time it cannot honestly promise.) + * + * The status is spelled out as better-call's name for 429 rather than read off + * {@link SMS_QUOTA_EXCEEDED_CODE}: that constant is the service envelope's + * token, matched on the way in, and the HTTP status is this endpoint's own. */ async function smsQuotaExceededApiError(message: string): Promise { const { APIError } = await import('better-auth/api'); - return new APIError(SMS_QUOTA_EXCEEDED_CODE, { message }); + return new APIError('TOO_MANY_REQUESTS', { message, code: PHONE_OTP_SEND_REFUSAL_CODE }); } +/** + * [#24243, ADR-0112] The ONE code both phone-OTP send walls answer with: the + * deployment's daily SMS quota ({@link smsQuotaExceededApiError}) and the + * per-number cooldown and hourly cap + * ({@link AuthManager.assertPhoneOtpSendAllowed}). One code, not one per wall: + * #2814 requires the two walls to be indistinguishable from outside, so a + * caller learns "not now" and never which budget it hit. The standard + * catalog's member for request pacing; `QUOTA_EXCEEDED` would name the first + * wall alone, and so would tell them apart. + */ +const PHONE_OTP_SEND_REFUSAL_CODE: StandardErrorCode = 'RATE_LIMIT_EXCEEDED'; + +/** [#24243] The standard ADR-0112 code the slug guard's read fault answers with — the status's own name. */ +const SLUG_GUARD_READ_FAULT_CODE: StandardErrorCode = 'SERVICE_UNAVAILABLE'; + /** * [#21941] The organization slug guard's answer when one of its own reads * cannot answer: `503 SERVICE_UNAVAILABLE`, the existing ADR-0112 code for a @@ -1357,9 +1385,10 @@ async function slugGuardReadFaultApiError(object: string, cause: unknown): Promi const { APIError } = await import('better-auth/api'); const err = new APIError('SERVICE_UNAVAILABLE', { message: - `The organization slug guard could not read \`${object}\`, so whether an active environment ` + - `still references this organization was never determined, and the slug was not changed. ` + + `The organization slug guard could not read \`${object}\`, so whether this organization has an ` + + `active environment was never determined, and the slug was not changed. ` + `This is a server-side outage, not a refusal of the new slug: retry once the data store is reachable.`, + code: SLUG_GUARD_READ_FAULT_CODE, }); (err as { cause?: unknown }).cause = cause; return err; @@ -1376,9 +1405,9 @@ const INVITATION_ROLE_CAP_READ_FAULT_CODE: StandardErrorCode = 'SERVICE_UNAVAILA * Not the cap's `403 INVITE_ISSUER_UNVERIFIED`: that code is a verdict — the * store answered and established no role — while a read that threw reached no * verdict, so it must not wear the refusal's code. Still fail-closed: the - * throw ends the endpoint, so no invitation row is written. Unlike the slug - * guard's body, this one carries the code too, so every answer from the - * invitation endpoint has one. The driver's own failure rides `cause`. + * throw ends the endpoint, so no invitation row is written. Like the slug + * guard's body (#24243), this one carries the code too, so every answer from + * the invitation endpoint has one. The driver's own failure rides `cause`. */ async function invitationRoleCapReadFaultApiError(cause: unknown): Promise { const { APIError } = await import('better-auth/api'); @@ -1393,6 +1422,64 @@ async function invitationRoleCapReadFaultApiError(cause: unknown): Promise { + const { APIError } = await import('better-auth/api'); + const err = new APIError('SERVICE_UNAVAILABLE', { + message: + 'The invitation placement check failed before it reached a verdict, so whether the issuer may ' + + 'place this invitation was never determined, and no invitation was created. This is a ' + + 'server-side outage, not a refusal of the placement: retry once the data store is reachable.', + code: INVITE_PLACEMENT_READ_FAULT_CODE, + }); + (err as { cause?: unknown }).cause = cause; + return err; +} + /** * [#24166, ruling 6232069920 A] The access-token claim that carries the * organization an OAuth consent bound. Minted by `customAccessTokenClaims` on @@ -3465,6 +3552,7 @@ export class AuthManager { message: "'__global__' is reserved for the platform (no-organization) bucket " + '(ADR-0120 D3) and cannot be used as an organization id or slug.', + code: ORGANIZATION_RESERVED_IDENTIFIER_CODE, }); } if (!this.multiOrgPostureEffective()) { @@ -3472,6 +3560,7 @@ export class AuthManager { throw new APIError('FORBIDDEN', { message: 'Creating additional organizations is disabled on this deployment.', + code: ORGANIZATION_CREATION_DISABLED_CODE, }); } }, @@ -3540,6 +3629,7 @@ export class AuthManager { throw new APIError('FORBIDDEN', { message: 'Invitation placement (business unit + positions) requires the delegated-administration runtime; this deployment cannot authorize it.', + code: INVITE_PLACEMENT_UNSUPPORTED_CODE, }); } @@ -3554,9 +3644,17 @@ export class AuthManager { organizationId, }); } catch (err: any) { + // [#24243] Only the gate's own refusal is a scope verdict. A throw + // that is not one (the issuer's grants could not be read) reached + // no verdict, so it answers 503 with its code rather than wearing + // the refusal's 403, its code and a driver's message. + if (!isInvitationPlacementGateRefusal(err)) { + throw await invitationPlacementReadFaultApiError(err); + } const { APIError } = await import('better-auth/api'); throw new APIError('FORBIDDEN', { message: err?.message ?? 'Invitation placement is outside your delegated administration scope.', + code: INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE_CODE, }); } }, @@ -3665,11 +3763,15 @@ export class AuthManager { if (activeEnvs > 0) { const { APIError } = await import('better-auth/api'); + // [#24243] The message states only what the guard read: environment + // rows by `organization_id`, counted by `status`. No hostname is + // read, so none is named as a remedy. throw new APIError('FORBIDDEN', { message: - `Cannot change organization slug while ${activeEnvs} active ` + - `environment(s) still reference it. Archive those environments ` + - `or rename their hostnames first.`, + `Cannot change the organization slug while the organization has ${activeEnvs} ` + + `active environment(s), meaning any environment that is neither archived nor ` + + `failed. Archive those environments first, or keep the current slug.`, + code: ORGANIZATION_SLUG_LOCKED_CODE, }); } }, @@ -5760,6 +5862,8 @@ export class AuthManager { const { APIError } = await import('better-auth/api'); throw new APIError('TOO_MANY_REQUESTS', { message: `Too many verification codes requested for this phone number. Retry in ${decision.retryAfterSeconds ?? 60}s.`, + // The quota wall's code too (#2814): see PHONE_OTP_SEND_REFUSAL_CODE. + code: PHONE_OTP_SEND_REFUSAL_CODE, }); } } From cd27c533a4b932c3faed8322a5b4d90614be2b76 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:02:22 +0000 Subject: [PATCH 2/5] test(plugin-auth): pin that every new APIError( in plugin-auth/src carries a code Parses every non-test source file under src/, finds every APIError construction and requires a top-level code in the body, or a body relayed from judgeHeldSession's ADR-0112 envelope. A comment-free textual count and a fixture battery keep the walk from passing on a shape it cannot see. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../src/api-error-code.pin.test.ts | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/api-error-code.pin.test.ts diff --git a/packages/plugins/plugin-auth/src/api-error-code.pin.test.ts b/packages/plugins/plugin-auth/src/api-error-code.pin.test.ts new file mode 100644 index 00000000000..1b52bd4f99a --- /dev/null +++ b/packages/plugins/plugin-auth/src/api-error-code.pin.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24243, ADR-0112] Every `new APIError(` this package throws carries a `code`. + * + * better-auth serialises an `APIError` body as it is given. A body without a + * `code` reaches the caller as `message` alone, so a client (the console among + * them) can only show or pattern-match English prose, on doors where + * better-auth's own refusals carry codes. Eight throws in `auth-manager.ts` + * shipped that way, found by hand; this pin is the enumeration, so the next one + * cannot ship without a code. It is a test in this package, not a CI gate. + * + * It PARSES every non-test source file under `src/` and finds every + * `new APIError(...)`. A site passes when its body carries the code in one of + * two shapes, and is reported with its file and line otherwise: + * + * 1. an object literal with a top-level `code` property; or + * 2. a body destructured, in the same function, from the result of + * `judgeHeldSession(...)`: the held-session judge's ADR-0112 envelope, + * whose code sits under `error` (`platform-admin-gate.held-session.test.ts` + * pins that body's shape). + * + * No body, a literal without `code`, a spread, or an identifier from anywhere + * else is a site this pin cannot read a code from, so it fails. + * + * It parses and does not type-check, so the code's VALUE is not judged here. + * Each stamp is a constant typed `RegisteredErrorCode` or `StandardErrorCode`, + * so `tsc` judges the value, and `check:error-code-provenance` judges whether a + * registered one is listed under this package. + * + * Two controls keep the walk honest: + * - a textual count over the comment-free source (the TypeScript printer, with + * comments removed) must equal the sites the walk found in each file, so a + * constructor spelling the walk does not recognise cannot slip past it; + * - the classifier must report each shape that shipped without a code and + * accept each shape that carries one (the fixture battery below). + */ + +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +interface ApiErrorSite { + readonly file: string; + readonly line: number; + /** `null` when the body carries a code; otherwise why this pin cannot read one. */ + readonly missing: string | null; +} + +const isTestFile = (name: string): boolean => /\.(test|spec)\.tsx?$/.test(name) || name.endsWith('.d.ts'); + +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) out.push(...sourceFiles(path)); + else if (/\.tsx?$/.test(entry.name) && !isTestFile(entry.name)) out.push(path); + } + return out.sort(); +} + +const isApiErrorCallee = (callee: ts.Expression): boolean => + (ts.isIdentifier(callee) && callee.text === 'APIError') || + (ts.isPropertyAccessExpression(callee) && callee.name.text === 'APIError'); + +function propertyName(name: ts.PropertyName): string | undefined { + if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text; + return undefined; +} + +function enclosingFunction(node: ts.Node): ts.Node | undefined { + for (let cur = node.parent; cur; cur = cur.parent) { + if (ts.isFunctionLike(cur)) return cur; + } + return undefined; +} + +/** Every variable declaration inside `scope`, nested blocks included. */ +function declarationsIn(scope: ts.Node): ts.VariableDeclaration[] { + const found: ts.VariableDeclaration[] = []; + const visit = (node: ts.Node): void => { + if (ts.isVariableDeclaration(node)) found.push(node); + ts.forEachChild(node, visit); + }; + visit(scope); + return found; +} + +const unwrapAwait = (e: ts.Expression | undefined): ts.Expression | undefined => + e && ts.isAwaitExpression(e) ? e.expression : e; + +/** + * Shape 2: `const { body } = held;` (or `{ body: X }`) where `held` is + * `judgeHeldSession(…)`'s result, both in the throw's own function. + */ +function isHeldSessionBody(body: ts.Identifier, site: ts.NewExpression): boolean { + const scope = enclosingFunction(site); + if (!scope) return false; + const decls = declarationsIn(scope); + const holders = decls + .filter((d) => ts.isObjectBindingPattern(d.name) && d.initializer && ts.isIdentifier(d.initializer)) + .filter((d) => + (d.name as ts.ObjectBindingPattern).elements.some((el) => { + const key = el.propertyName ? propertyName(el.propertyName) : ts.isIdentifier(el.name) ? el.name.text : undefined; + return key === 'body' && ts.isIdentifier(el.name) && el.name.text === body.text; + }), + ) + .map((d) => (d.initializer as ts.Identifier).text); + return holders.some((holder) => + decls.some((d) => { + if (!ts.isIdentifier(d.name) || d.name.text !== holder) return false; + const init = unwrapAwait(d.initializer); + return ( + !!init && ts.isCallExpression(init) && ts.isIdentifier(init.expression) && init.expression.text === 'judgeHeldSession' + ); + }), + ); +} + +function whyCodeless(site: ts.NewExpression, sf: ts.SourceFile): string | null { + const body = site.arguments?.[1]; + if (!body) return 'it passes no body at all'; + if (ts.isObjectLiteralExpression(body)) { + const carriesCode = body.properties.some( + (p) => (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) && propertyName(p.name) === 'code', + ); + return carriesCode ? null : 'its body has no top-level `code` property'; + } + if (ts.isIdentifier(body) && isHeldSessionBody(body, site)) return null; + return `its body is \`${body.getText(sf)}\`, which this pin cannot read a code from`; +} + +/** Every `new APIError(…)` in one source text, each with its verdict. */ +function apiErrorSites(file: string, text: string): ApiErrorSite[] { + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const sites: ApiErrorSite[] = []; + const visit = (node: ts.Node): void => { + if (ts.isNewExpression(node) && isApiErrorCallee(node.expression)) { + sites.push({ + file, + line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, + missing: whyCodeless(node, sf), + }); + } + ts.forEachChild(node, visit); + }; + visit(sf); + return sites; +} + +/** The textual control: constructor calls counted in the source with its comments removed. */ +function textualCount(file: string, text: string): number { + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); + const printed = ts.createPrinter({ removeComments: true }).printFile(sf); + return printed.match(/\bnew\s+(?:[A-Za-z_$][\w$]*\s*\.\s*)*APIError\s*\(/g)?.length ?? 0; +} + +describe('every new APIError( in plugin-auth/src carries an ADR-0112 code (#24243)', () => { + const files = sourceFiles(HERE); + const perFile = files.map((path) => { + const text = readFileSync(path, 'utf8'); + const file = relative(HERE, path); + return { file, sites: apiErrorSites(file, text), textual: textualCount(file, text) }; + }); + const sites = perFile.flatMap((f) => f.sites); + + it('the walk reaches the throws: a floor of sites and files, so a walk that reads nothing cannot pass', () => { + // Measured when this pin landed: 30 sites in 4 files (`auth-manager.ts` + // 24, `objectql-adapter.ts` 3, `impersonation-bearer-rotation.ts` 2, + // `admin-impersonate-endpoint.ts` 1). A floor, not an exact count, so a new + // throw does not have to edit this line; only losing them does. + expect(sites.length).toBeGreaterThanOrEqual(30); + expect(new Set(sites.map((s) => s.file)).size).toBeGreaterThanOrEqual(4); + expect(perFile.find((f) => f.file === 'auth-manager.ts')?.sites.length ?? 0).toBeGreaterThanOrEqual(24); + }); + + it('the textual control agrees with the walk in every file', () => { + const disagreements = perFile + .filter((f) => f.textual !== f.sites.length) + .map((f) => `${f.file}: ${f.textual} textual, ${f.sites.length} walked`); + expect(disagreements).toEqual([]); + }); + + it('no site throws a body without a code', () => { + const codeless = sites.filter((s) => s.missing !== null).map((s) => `${s.file}:${s.line}: ${s.missing}`); + expect(codeless).toEqual([]); + }); +}); + +describe('the classifier, against the shapes it must tell apart', () => { + const verdicts = (body: string) => apiErrorSites('fixture.ts', body).map((s) => s.missing === null); + + it('reports both shapes the eight code-less throws had (the card enumeration)', () => { + // As they stood before #24243: a status read off a constant with a + // message-only body (the SMS quota wall), and a status literal with a + // message-only body (the other seven). + const fixture = ` + async function quota(message: string) { return new APIError(SMS_QUOTA_EXCEEDED_CODE, { message }); } + async function hook() { + throw new APIError('FORBIDDEN', { message: 'Creating additional organizations is disabled.' }); + } + async function relayed(err: any) { + throw new APIError('FORBIDDEN', { message: err?.message ?? 'outside your scope' }); + }`; + expect(verdicts(fixture)).toEqual([false, false, false]); + }); + + it('reports a site with no body, a spread with no code, and an identifier it cannot trace', () => { + const fixture = ` + function a() { throw new APIError('FORBIDDEN'); } + function b(base: object) { throw new APIError('FORBIDDEN', { ...base }); } + function c(body: object) { throw new APIError('FORBIDDEN', body); } + function d() { const { body } = somethingElse(); throw new APIError('FORBIDDEN', body); }`; + expect(verdicts(fixture)).toEqual([false, false, false, false]); + }); + + it('accepts a literal or shorthand `code`, a string key, a member callee and the held-session relay', () => { + const fixture = ` + function a() { throw new APIError('FORBIDDEN', { message: 'm', code: SOME_CODE }); } + function b(code: string) { throw new APIError('FORBIDDEN', { message: 'm', code }); } + function c() { throw new APIError('FORBIDDEN', { 'code': 'X', message: 'm' }); } + function d() { throw new api.APIError('FORBIDDEN', { message: 'm', code: SOME_CODE }); } + async function e() { + const held = judgeHeldSession({ authGate: await gateOf() }); + if (!held) return; + const { body } = held; + throw new APIError('FORBIDDEN', body); + }`; + expect(verdicts(fixture)).toEqual([true, true, true, true, true]); + }); + + it('the textual control sees past comments, and counts a member-callee site', () => { + const fixture = ` + // new APIError('FORBIDDEN', { message: 'in a comment' }) + /** new APIError('X') in a doc block */ + function a() { throw new api.APIError('FORBIDDEN', { message: 'm', code: C }); }`; + expect(textualCount('fixture.ts', fixture)).toBe(1); + expect(apiErrorSites('fixture.ts', fixture)).toHaveLength(1); + }); +}); From 617f419958af37a058fed898f5a8ffb350e493d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:07:31 +0000 Subject: [PATCH 3/5] test(plugin-auth): pin each hook refusal on its ADR-0112 code, and the placement catch's two arms - placement: the gate's PermissionDeniedError answers 403 INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE with its message; a store fault (AuthzStoreUnavailableError) answers 503 SERVICE_UNAVAILABLE with the failure on cause; no placement runtime answers INVITE_PLACEMENT_UNSUPPORTED; - the reserved __global__ id or slug answers 400 VALIDATION_ERROR, also on a single-organization posture; - both phone-OTP walls answer the same RATE_LIMIT_EXCEEDED; - the slug guard's refusal and read fault carry their codes; - organization/create on a posture with no wall answers ORGANIZATION_CREATION_DISABLED through the real better-auth route. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- ...ager.org-slug-guard-system-context.test.ts | 25 +++- .../plugin-auth/src/auth-manager.test.ts | 125 ++++++++++++++---- .../src/org-create-posture-gate.test.ts | 6 + 3 files changed, 125 insertions(+), 31 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts b/packages/plugins/plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts index 59fd34e8c30..8e9edaeb28e 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts @@ -25,6 +25,13 @@ * `sys_environment` (the open-source composition) declares the guard * inapplicable without reading anything. The real ObjectQL registry * answers that question in the last section. + * + * [#24243] Both answers are pinned on their ADR-0112 codes, the refusal's + * registered `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS` and the read + * fault's standard `SERVICE_UNAVAILABLE`, at this level rather than on the + * wire: `sys_environment` is a cloud-provided object that no composition in this + * repository registers, so on a stock dogfood boot the guard does not apply and + * neither answer has a door. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -108,15 +115,19 @@ async function slugGuard(dataEngine: unknown) { return (slug: string) => guard({ organization: { slug }, member: { organizationId: ORG.id } }); } -/** The guard's refusal, asserted on its envelope: better-auth's `FORBIDDEN` / 403, naming the active environments. */ +/** + * The guard's refusal, asserted on its envelope: better-auth's `FORBIDDEN` / 403 + * with its registered ADR-0112 code (#24243), naming the active environments. + */ async function expectSlugRefusal(attempt: Promise) { const err = (await attempt.then( () => undefined, (e: unknown) => e, - )) as { status?: unknown; statusCode?: unknown; body?: { message?: unknown } } | undefined; + )) as { status?: unknown; statusCode?: unknown; body?: { code?: unknown; message?: unknown } } | undefined; expect(err, 'the slug change was not refused').toBeTruthy(); expect(err?.statusCode).toBe(403); expect(err?.status).toBe('FORBIDDEN'); + expect(err?.body?.code).toBe('ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS'); expect(String(err?.body?.message)).toMatch(/active.*environment/i); } @@ -180,18 +191,20 @@ describe('organization slug guard — on an engine that refuses a principal-less /** * A read that could not answer, asserted on its envelope: better-auth's - * `SERVICE_UNAVAILABLE` / 503, naming the object whose read failed — and - * explicitly NOT the guard's `FORBIDDEN` / 403, which is a verdict about the - * slug change that the guard never reached. + * `SERVICE_UNAVAILABLE` / 503 with the standard code of the same name (#24243), + * naming the object whose read failed — and explicitly NOT the guard's + * `FORBIDDEN` / 403, which is a verdict about the slug change that the guard + * never reached. */ async function expectReadFaultRefusal(attempt: Promise, object: string) { const err = (await attempt.then( () => undefined, (e: unknown) => e, - )) as { status?: unknown; statusCode?: unknown; body?: { message?: unknown }; cause?: unknown } | undefined; + )) as { status?: unknown; statusCode?: unknown; body?: { code?: unknown; message?: unknown }; cause?: unknown } | undefined; expect(err, 'the slug change was let through on a read that could not answer').toBeTruthy(); expect(err?.statusCode).toBe(503); expect(err?.status).toBe('SERVICE_UNAVAILABLE'); + expect(err?.body?.code).toBe('SERVICE_UNAVAILABLE'); expect(String(err?.body?.message)).toContain(`\`${object}\``); return err; } diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 326e0c71089..f1692b1ff45 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -37,6 +37,8 @@ vi.mock('better-auth/plugins/haveibeenpwned', () => ({ import { betterAuth } from 'better-auth'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit'; import { ADMIN_FULL_ACCESS, ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec'; +import { AuthzStoreUnavailableError } from '@objectstack/core'; +import { PermissionDeniedError } from '@objectstack/plugin-security'; describe('AuthManager', () => { let consoleSpy: ReturnType; @@ -1003,35 +1005,92 @@ describe('AuthManager', () => { }); }); + /** + * [#24243] A placement answer asserted on its envelope (ADR-0112): the + * status and the `code` better-auth serialises into the body. + */ + const placementAnswer = async (attempt: Promise) => { + const err = (await attempt.then( + () => undefined, + (e: unknown) => e, + )) as + | { status?: unknown; statusCode?: unknown; body?: { code?: unknown; message?: unknown }; cause?: unknown } + | undefined; + expect(err, 'the invitation was admitted').toBeTruthy(); + return err; + }; + it('issuance: a refused placement REJECTS the whole invitation (no row is created)', async () => { + // The gate's own refusal, as `invitation-placement.ts` throws it: the + // real `PermissionDeniedError`, so the pin follows the class's code. const assertIssuable = vi.fn(async () => { - throw new Error("business unit 'bu_plant_b' is outside the delegated subtree"); + throw new PermissionDeniedError("business unit 'bu_plant_b' is outside the delegated subtree"); }); const { hooks, warnSpy } = await bootWithPlacement({ assertIssuable, apply: vi.fn() }); warnSpy.mockRestore(); - await expect( + const err = await placementAnswer( hooks.beforeCreateInvitation({ invitation: PLACEMENT_INVITE, inviter: { id: 'u_issuer' }, organization: { id: 'org-42' }, }), - ).rejects.toThrow(/outside the delegated subtree/); + ); + expect(err?.statusCode).toBe(403); + expect(err?.status).toBe('FORBIDDEN'); + expect(err?.body?.code).toBe('INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE'); + // The gate's verdict is relayed as its message. + expect(String(err?.body?.message)).toMatch(/outside the delegated subtree/); + }); + + it('issuance: a store fault out of the placement check is an outage (503 SERVICE_UNAVAILABLE), not a scope refusal', async () => { + // [#24243] `assertIssuable` resolves the issuer's grants from the store + // BEFORE the gate runs; a store that cannot answer throws there, as the + // shared resolver's loud failure. Pinned at the unit level: on a booted + // stack the store answers, so no request reaches this arm on demand. + const fault = new AuthzStoreUnavailableError('sys_user_position', new Error('connection refused')); + const assertIssuable = vi.fn(async () => { + throw fault; + }); + const { hooks, warnSpy } = await bootWithPlacement({ assertIssuable, apply: vi.fn() }); + warnSpy.mockRestore(); + + const err = await placementAnswer( + hooks.beforeCreateInvitation({ + invitation: PLACEMENT_INVITE, + inviter: { id: 'u_issuer' }, + organization: { id: 'org-42' }, + }), + ); + expect(err?.statusCode).toBe(503); + expect(err?.status).toBe('SERVICE_UNAVAILABLE'); + expect(err?.body?.code).toBe('SERVICE_UNAVAILABLE'); + // Not the refusal's code, and the store's own text does not ride the body. + expect(err?.body?.code).not.toBe('INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE'); + expect(String(err?.body?.message)).not.toContain('connection refused'); + // The failure is kept for the operator. + expect(err?.cause).toBe(fault); }); it('issuance: NO placement runtime ⇒ a placement-carrying invitation is refused (fail closed)', async () => { // Without plugin-security there is no gate; honouring the request would // hand org_admin the RBAC-write authority it is deliberately denied. + // [#24243] Pinned at the unit level: the dogfood harness (`bootStack`) + // always boots plugin-security, which registers the placement service, + // so no door on a stock dogfood boot reaches this refusal. const { hooks, warnSpy } = await bootWithPlacement(undefined); warnSpy.mockRestore(); - await expect( + const err = await placementAnswer( hooks.beforeCreateInvitation({ invitation: PLACEMENT_INVITE, inviter: { id: 'u_issuer' }, organization: { id: 'org-42' }, }), - ).rejects.toThrow(/requires the delegated-administration runtime/); + ); + expect(err?.statusCode).toBe(403); + expect(err?.body?.code).toBe('INVITE_PLACEMENT_UNSUPPORTED'); + expect(String(err?.body?.message)).toMatch(/requires the delegated-administration runtime/); }); it('issuance: an ordinary invitation (no placement) never consults the gate', async () => { @@ -1361,24 +1420,30 @@ describe('AuthManager', () => { else process.env.OS_MULTI_ORG_ENABLED = OLD_MULTI; }); + /** + * [#24243] The reservation's envelope (ADR-0112): 400 with the standard + * `VALIDATION_ERROR`, a value the request may not carry. + */ + const RESERVED_IDENTIFIER_ANSWER = { statusCode: 400, status: 'BAD_REQUEST', body: { code: 'VALIDATION_ERROR' } }; + it("rejects an organization whose slug is '__global__'", async () => { const orgPlugin = await bootOrgPlugin(); - await expect( - orgPlugin._opts.organizationHooks.beforeCreateOrganization({ - organization: { name: 'Global Inc', slug: '__global__' }, - user: { id: 'u1' }, - }), - ).rejects.toThrow(/'__global__' is reserved .* \(ADR-0120 D3\)/); + const attempt = orgPlugin._opts.organizationHooks.beforeCreateOrganization({ + organization: { name: 'Global Inc', slug: '__global__' }, + user: { id: 'u1' }, + }); + await expect(attempt).rejects.toThrow(/'__global__' is reserved .* \(ADR-0120 D3\)/); + await expect(attempt).rejects.toMatchObject(RESERVED_IDENTIFIER_ANSWER); }); it("rejects an organization whose id is '__global__'", async () => { const orgPlugin = await bootOrgPlugin(); - await expect( - orgPlugin._opts.organizationHooks.beforeCreateOrganization({ - organization: { id: '__global__', name: 'Global Inc', slug: 'global-inc' }, - user: { id: 'u1' }, - }), - ).rejects.toThrow(/'__global__' is reserved .* \(ADR-0120 D3\)/); + const attempt = orgPlugin._opts.organizationHooks.beforeCreateOrganization({ + organization: { id: '__global__', name: 'Global Inc', slug: 'global-inc' }, + user: { id: 'u1' }, + }); + await expect(attempt).rejects.toThrow(/'__global__' is reserved .* \(ADR-0120 D3\)/); + await expect(attempt).rejects.toMatchObject(RESERVED_IDENTIFIER_ANSWER); }); it('an ordinary organization passes the reservation guard', async () => { @@ -1394,12 +1459,13 @@ describe('AuthManager', () => { it('the reservation is judged before the multi-org gate (precise error in single-org mode too)', async () => { delete process.env.OS_MULTI_ORG_ENABLED; // default: single-org const orgPlugin = await bootOrgPlugin(); - await expect( - orgPlugin._opts.organizationHooks.beforeCreateOrganization({ - organization: { name: 'Global Inc', slug: '__global__' }, - user: { id: 'u1' }, - }), - ).rejects.toThrow(/'__global__' is reserved/); + const attempt = orgPlugin._opts.organizationHooks.beforeCreateOrganization({ + organization: { name: 'Global Inc', slug: '__global__' }, + user: { id: 'u1' }, + }); + await expect(attempt).rejects.toThrow(/'__global__' is reserved/); + // [#24243] …and its code, not the posture gate's `ORGANIZATION_CREATION_DISABLED`. + await expect(attempt).rejects.toMatchObject(RESERVED_IDENTIFIER_ANSWER); }); }); @@ -2341,6 +2407,8 @@ describe('AuthManager', () => { expect(err.name).toBe('APIError'); expect(err.status).toBe('TOO_MANY_REQUESTS'); expect(err.statusCode).toBe(429); + // [#24243] The standard ADR-0112 code a client branches on. + expect(err.body?.code).toBe('RATE_LIMIT_EXCEEDED'); // #2780 standing requirement: the code never travels in an error. expect(String(err.message)).not.toContain('424242'); }); @@ -2354,6 +2422,7 @@ describe('AuthManager', () => { expect(isAPIError(err)).toBe(true); expect(err.status).toBe('TOO_MANY_REQUESTS'); expect(err.statusCode).toBe(429); + expect(err.body?.code).toBe('RATE_LIMIT_EXCEEDED'); }); it('both walls on the endpoint present the SAME outward shape', async () => { @@ -2367,17 +2436,23 @@ describe('AuthManager', () => { const quota = await rejection(() => opts.sendOTP({ phoneNumber: PHONE, code: '111111' })); expect(outwardShape(quota)).toEqual(outwardShape(perNumber)); + // [#24243] One code for both walls: a code per wall would tell them apart. + // Pinned at the unit level: the dogfood harness mounts no phone-number + // plugin (`bootStack` builds `AuthPlugin` from its secret alone, and + // `OS_AUTH_PHONE_NUMBER_ENABLED` is read only by `objectstack serve`), + // so neither wall has a door on a stock dogfood boot. expect(outwardShape(quota)).toEqual({ name: 'APIError', status: 'TOO_MANY_REQUESTS', statusCode: 429, - bodyCode: undefined, + bodyCode: 'RATE_LIMIT_EXCEEDED', }); }); it('matches the CODE prefix, so the service may reword the message half', async () => { const { manager, opts } = await bootOtp(); - // Only `TOO_MANY_REQUESTS` — an ADR-0112 error code — is restated across + // Only the envelope's `TOO_MANY_REQUESTS` token (the service's spelling, + // better-call's status name, not an ADR-0112 code) is restated across // the package boundary; the prose after the colon is service-owned and // free to change without breaking this mapping. manager.setSmsService( diff --git a/packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts b/packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts index f711d21fdf3..75c9c54d886 100644 --- a/packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts +++ b/packages/plugins/plugin-auth/src/org-create-posture-gate.test.ts @@ -294,6 +294,9 @@ describe('#5233 — no tenancy service wired: the gate reads OS_TENANCY_POSTURE, const run = await runGuidedWorkspaceCreation({ posture: 'single' }, 'single-posture'); expect(run.status).toBe(403); + // [#24243] The registered ADR-0112 code the console branches on, serialised + // by the real better-auth route — not only the prose. + expect(run.body?.code).toBe('ORGANIZATION_CREATION_DISABLED'); expect(JSON.stringify(run.body)).toContain( 'Creating additional organizations is disabled on this deployment.', ); @@ -352,6 +355,9 @@ describe('#5233 — with a `tenancy` service wired, as a real kernel boot has', ); expect(run.status).toBe(403); + // [#24243] The same code as a posture that asked for no wall: the code names + // the deployment's effective posture, not what was requested. + expect(run.body?.code).toBe('ORGANIZATION_CREATION_DISABLED'); expect(JSON.stringify(run.body)).toContain( 'Creating additional organizations is disabled on this deployment.', ); From 00cafa8b23d761fe7efb60830b1946d7f956ac5b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:14:52 +0000 Subject: [PATCH 4/5] test(dogfood): the organization/create and invitation-placement refusals answer their codes on the wire - a stock single-posture showcase refuses organization/create with 403 ORGANIZATION_CREATION_DISABLED, and the reserved __global__ slug with 400 VALIDATION_ERROR ahead of it, writing no row; - a delegated_admin with no adminScope requesting placement is refused by the real gate with 403 INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE, writing no row. The unit pin of the gate's refusal now uses a structural stand-in for PermissionDeniedError, which the dogfood pin covers with the real class. Adds the plugin-auth patch changeset. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../24243-plugin-auth-hook-refusal-codes.md | 15 ++++ .../plugin-auth/src/auth-manager.test.ts | 16 +++- .../delegated-admin-invite.dogfood.test.ts | 32 +++++++ ...ation-create-refusal-codes.dogfood.test.ts | 84 +++++++++++++++++++ 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 .changeset/24243-plugin-auth-hook-refusal-codes.md create mode 100644 packages/qa/dogfood/test/organization-create-refusal-codes.dogfood.test.ts diff --git a/.changeset/24243-plugin-auth-hook-refusal-codes.md b/.changeset/24243-plugin-auth-hook-refusal-codes.md new file mode 100644 index 00000000000..7698b07bd4d --- /dev/null +++ b/.changeset/24243-plugin-auth-hook-refusal-codes.md @@ -0,0 +1,15 @@ +--- +"@objectstack/plugin-auth": patch +--- + +fix(plugin-auth): the organization, invitation-placement and phone-OTP hook refusals carry their ADR-0112 codes + +Clause-②: no + +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. + +- `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`. +- `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`. +- `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`. +- Phone OTP sends (`/auth/phone-number/send-otp`, `/auth/phone-number/request-password-reset`) and the SMS invitation: 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. +- **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. diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index f1692b1ff45..ed1d0550614 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -38,7 +38,6 @@ import { betterAuth } from 'better-auth'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit'; import { ADMIN_FULL_ACCESS, ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec'; import { AuthzStoreUnavailableError } from '@objectstack/core'; -import { PermissionDeniedError } from '@objectstack/plugin-security'; describe('AuthManager', () => { let consoleSpy: ReturnType; @@ -1021,10 +1020,19 @@ describe('AuthManager', () => { }; it('issuance: a refused placement REJECTS the whole invitation (no row is created)', async () => { - // The gate's own refusal, as `invitation-placement.ts` throws it: the - // real `PermissionDeniedError`, so the pin follows the class's code. + // The gate's own refusal, in the shape `invitation-placement.ts` throws + // it: plugin-security's `PermissionDeniedError`, read by its ADR-0112 + // code. A structural stand-in, because importing plugin-security here + // measured roughly 1–2 s more import time on this file; the real class + // reaches this catch on the wire in the dogfood `delegated-admin-invite` + // file. const assertIssuable = vi.fn(async () => { - throw new PermissionDeniedError("business unit 'bu_plant_b' is outside the delegated subtree"); + throw Object.assign(new Error("business unit 'bu_plant_b' is outside the delegated subtree"), { + name: 'PermissionDeniedError', + code: 'PERMISSION_DENIED', + status: 403, + statusCode: 403, + }); }); const { hooks, warnSpy } = await bootWithPlacement({ assertIssuable, apply: vi.fn() }); warnSpy.mockRestore(); diff --git a/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts b/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts index 335de43514e..2ed4072de69 100644 --- a/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts +++ b/packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts @@ -32,6 +32,14 @@ * through this door (better-auth refuses a non-member before the hook runs); * it is pinned at the unit level in plugin-auth's `auth-manager.test.ts`. * + * [#24243] The placement gate behind the cap is pinned on its code here too: + * the delegate's out-of-scope placement answers + * `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`. Its two other answers have no door + * on this boot and are pinned at the unit level beside the cap's: the harness + * always registers plugin-security's placement service, so + * `INVITE_PLACEMENT_UNSUPPORTED` is unreachable, and a store fault (503 + * `SERVICE_UNAVAILABLE`) needs a store that cannot answer. + * * Harness note: `bootStack` boots the production `single` shape, so the * Default Organization exists from the boot (ADR-0131 D3); this file sets * membership roles in system context, which is the only writer @@ -171,6 +179,30 @@ describe('#3697: delegated_admin may invite — as `member` only', () => { ).toBe(0); }, 30_000); + it('the same delegate requesting PLACEMENT outside their scope is REFUSED by the gate, on its code, with no invitation row', async () => { + // [ADR-0105 D8, #24243] A plain `member` invitation passes the role cap, so + // the only thing left to judge is the placement intent: the delegated + // administration gate dry-runs the `sys_user_position` rows acceptance would + // write, against the ISSUER's adminScope (ADR-0090 D12). This delegate holds + // the org role and no adminScope at all, so any unit is outside it. The + // refusal is the gate's own `PermissionDeniedError`, so it answers the + // registered `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`, not a store outage. + const res = await stack.apiAs(delegateToken, 'POST', '/auth/organization/invite-member', { + email: 'invitee.placed.3697@example.com', + role: 'member', + organizationId: orgId, + businessUnitId: 'bu_outside_24243', + positions: ['placement_probe_24243'], + }); + + const body = await res.clone().json(); + expect(res.status, JSON.stringify(body)).toBe(403); + expect(body?.code).toBe('INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE'); + expect( + (await findRows(ql, 'sys_invitation', { email: 'invitee.placed.3697@example.com' }, 5)).length, + ).toBe(0); + }, 30_000); + it('an org owner CAN invite someone AS a delegated_admin — the provisioning flow', async () => { // How a delegate comes to exist in the first place. Both `sys_invitation.role` // and `sys_member.role` are enforced selects, so this is also the proof that diff --git a/packages/qa/dogfood/test/organization-create-refusal-codes.dogfood.test.ts b/packages/qa/dogfood/test/organization-create-refusal-codes.dogfood.test.ts new file mode 100644 index 00000000000..60ada44c0d4 --- /dev/null +++ b/packages/qa/dogfood/test/organization-create-refusal-codes.dogfood.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24243, ADR-0112] `POST /auth/organization/create`'s two refusals answer with + * their codes on the real door, on the production `single` posture. + * + * `beforeCreateOrganization` refuses in this order: + * + * 1. the reserved `__global__` id or slug (ADR-0120 D3): 400 with the standard + * `VALIDATION_ERROR`, judged first, on every posture; + * 2. a deployment whose effective tenancy posture enforces no organization + * wall: 403 with the registered `ORGANIZATION_CREATION_DISABLED`, the same + * derivation `/auth/config` publishes as `features.multiOrgEnabled`. + * + * Both used to answer `message` alone, so a client could only match prose. The + * plain showcase boot IS that posture, so this file reads each answer's status, + * its `code`, and the absence of a row. The in-package halves are + * plugin-auth's `org-create-posture-gate.test.ts` (the gate on every posture, + * through the real better-auth route) and `auth-manager.test.ts` (the + * reservation). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const SYSTEM_CTX = { isSystem: true }; + +describe('#24243: organization/create refusals carry their ADR-0112 codes on a single-organization deployment', () => { + let stack: VerifyStack; + let token: string; + let ql: any; + + /** Rows read in system context: the refusal must have written nothing. */ + const organizationsWithSlug = async (slug: string): Promise => { + const rows = await ql.find('sys_organization', { where: { slug }, limit: 5 }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : (rows?.records ?? []); + }; + + beforeAll(async () => { + stack = await bootShowcase({}); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + }, 120_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('boots the posture it asserts: no organization wall, as /auth/config publishes it', async () => { + // Guard the guard: were the boot ever to open the route, the refusal below + // would read as a regression in the gate rather than a changed fixture. + const cfg = await stack.api('/auth/config'); + expect(cfg.status).toBe(200); + const body = (await cfg.json()) as { data?: { features?: Record } }; + const features = body.data?.features; + expect(features, `no features in /auth/config: ${JSON.stringify(body)}`).toBeDefined(); + expect(features!.multiOrgEnabled).toBe(false); + }); + + it('a new organization is refused 403 ORGANIZATION_CREATION_DISABLED, and none is created', async () => { + const res = await stack.apiAs(token, 'POST', '/auth/organization/create', { + name: 'Second Org 24243', + slug: 'second-org-24243', + }); + + const body = await res.clone().json(); + expect(res.status, JSON.stringify(body)).toBe(403); + expect(body?.code).toBe('ORGANIZATION_CREATION_DISABLED'); + expect(await organizationsWithSlug('second-org-24243')).toHaveLength(0); + }); + + it("the reserved '__global__' slug is refused 400 VALIDATION_ERROR, ahead of the posture's refusal", async () => { + const res = await stack.apiAs(token, 'POST', '/auth/organization/create', { + name: 'Global 24243', + slug: '__global__', + }); + + const body = await res.clone().json(); + expect(res.status, JSON.stringify(body)).toBe(400); + expect(body?.code).toBe('VALIDATION_ERROR'); + expect(await organizationsWithSlug('__global__')).toHaveLength(0); + }); +}); From 0410c8612381289a69b0ed45a197bfe663e734c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 14:18:46 +0000 Subject: [PATCH 5/5] docs(error-handling): the phone-OTP send walls answer RATE_LIMIT_EXCEEDED, flat This branch stamps RATE_LIMIT_EXCEEDED on both phone-OTP send walls, which made two statements on the page false: that the SMS daily quota answers TOO_MANY_REQUESTS (it is better-call's status name, never the code), and that RATE_LIMIT_EXCEEDED is never flat. The walls answer better-auth's flat code-and-message body with no Retry-After header and no details. The changeset drops the SMS invitation from its list: the import route catches that send's failure into a per-row INVITE_SMS_FAILED, so the 429 code never reaches its wire. Claude-Session: https://claude.ai/code/session_01Nk17Z26X5DB59iQztDBADH Co-authored-by: Claude --- .../24243-plugin-auth-hook-refusal-codes.md | 2 +- content/docs/protocol/kernel/error-handling.mdx | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.changeset/24243-plugin-auth-hook-refusal-codes.md b/.changeset/24243-plugin-auth-hook-refusal-codes.md index 7698b07bd4d..7d1f2174bb7 100644 --- a/.changeset/24243-plugin-auth-hook-refusal-codes.md +++ b/.changeset/24243-plugin-auth-hook-refusal-codes.md @@ -11,5 +11,5 @@ Eight refusals thrown from plugin-auth's better-auth hooks answered with `messag - `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`. - `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`. - `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`. -- Phone OTP sends (`/auth/phone-number/send-otp`, `/auth/phone-number/request-password-reset`) and the SMS invitation: 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. +- 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. - **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. diff --git a/content/docs/protocol/kernel/error-handling.mdx b/content/docs/protocol/kernel/error-handling.mdx index a99b48c2656..8f9080be774 100644 --- a/content/docs/protocol/kernel/error-handling.mdx +++ b/content/docs/protocol/kernel/error-handling.mdx @@ -738,8 +738,9 @@ have let them count the dependents; `dependentObject` is named either way. Treat #### `RATE_LIMIT_EXCEEDED` **HTTP Status:** 429 **Meaning:** Too many requests, rate limit exceeded -**Envelope:** nested — built by the inbound rate limiter, which sits **ahead of every -route**, so it is never flat whichever family you were calling +**Envelope:** nested from the inbound rate limiter, which sits **ahead of every +route**, so its refusal is never flat whichever family you were calling. The phone-OTP +send walls answer this code too, flat (see the end of `QUOTA_EXCEEDED` below) **Example:** ```json @@ -835,10 +836,14 @@ Don't feed this 429 to a retry loop that only checks the status, such as the `RATE_LIMIT_EXCEEDED` example above. It has no `Retry-After` to wait on, and every retry before `resetAt` is refused again. -Other quota enforcement answers with its own code rather than this one -(the SMS daily quota answers `TOO_MANY_REQUESTS`). For request pacing, the code -on the wire is `RATE_LIMIT_EXCEEDED` above, whose `Retry-After` header and -`retryAfterSeconds` / `resetAt` details are what a client acts on. +Other quota enforcement answers with a code other than this one. The phone-OTP send +walls (`/auth/phone-number/send-otp` and `/auth/phone-number/request-password-reset`), a +phone number's own limit and the deployment's daily SMS quota, both answer 429 +`RATE_LIMIT_EXCEEDED`: one code for both, so a caller cannot tell which budget it hit. +That body is better-auth's flat `code` and `message`, with no `Retry-After` header and no +`details`. At the inbound rate limiter, the code on the wire is `RATE_LIMIT_EXCEEDED` +above, whose `Retry-After` header and `retryAfterSeconds` / `resetAt` details are what a +client acts on. ### Server Errors