Skip to content

Commit 80f9f7e

Browse files
fix(runtime, plugin-auth)!: the environment-membership gate and the organization slug guard fail closed when their own read faults (#21954)
Fixes #21941 Clause-②: no (narrowing) Two access guards used to let a request through when their own read faulted. Each now tells three answers apart: the read answered (unchanged), the object is not registered in this composition (the guard does not apply, decided from the registry), and a registered read that cannot answer (refused with `503 SERVICE_UNAVAILABLE`). This follows the triage direction in the card's triage comment: fail closed the platform's own way, not as allowed and not as the guard's own `403`. ## What changed ### `@objectstack/runtime` — `HttpDispatcher.enforceProjectMembership` (the environment-membership gate) - **The read throws** ⇒ the gate throws `AuthzStoreUnavailableError('sys_environment_member', cause)` out of `dispatch()`. This is the same loud outage the identity step and the `/keys` and activation domain gates already raise for an authorization input they could not read. The transport answers `503` with a declared `SERVICE_UNAVAILABLE` envelope. The old catch logged at debug level and returned `null` (admit). - **No ObjectQL engine resolves on the request's kernel** ⇒ refused the same way. It used to return `null` (admit). - **The request engine's registry does not register `sys_environment_member`** ⇒ the gate does not apply and nothing is read. The question is `ql.registry.getObject(...)`, the same lookup the engine's verbs make before refusing an unregistered name. An engine whose registry cannot be asked is read as before, and a fault on that read refuses. - Healthy reads keep their answers byte for byte: a member is admitted (and cached), and a non-member gets `403 PROJECT_MEMBERSHIP_REQUIRED`. ### `@objectstack/plugin-auth` — `organizationHooks.beforeUpdateOrganization` (the organization slug guard) - **The `sys_organization` read or the `sys_environment` read throws** ⇒ better-auth `APIError('SERVICE_UNAVAILABLE')` (`503`), via the new module helper `slugGuardReadFaultApiError`. The driver's error rides `cause`. Both catches used to `return`, which ended the hook without refusing, so the slug changed. - **The engine does not register `sys_environment`** (asked through `getSchema`) ⇒ the guard does not apply, and it is checked before either read. Nothing is read. - **No data engine** ⇒ the guard does not apply (unchanged `return`, now stated in code). - Healthy reads are unchanged: a slug change while an active environment references the organization is still `403 FORBIDDEN`, and anything else is allowed. ## The composition questions the triage asked to measure All readings are on `objectstack` at this branch's base, `d16b9fbf`. - **Can a composition that serves environment-scoped doors lack ObjectQL?** No, for any composition built from this repository. Only a host `KernelResolver` writes `context.environmentId`, and this repository registers none (`git grep` over `packages/**` finds `kernel-resolver` read in three places and registered in none). The gate reaches its read only for a caller the `auth` service signed in. This repository's `auth` provider, `AuthPlugin`, declares `dependencies = ['com.objectstack.engine.objectql']` (`auth-plugin.ts:291`). So the no-engine branch refuses like any other fault. - **Can a composition that mounts the organization-update door lack a data engine?** Only a standalone `AuthManager` can. `AuthPlugin` reads `ctx.getService('data')`, which throws when the service is unregistered, and the plugin hard-depends on ObjectQL. A standalone `AuthManager` runs on better-auth's in-memory store, and no engine there registers `sys_environment`. So the guard does not apply there, and the code says so. - **Is `sys_environment` registered in the open-source composition?** No. No package in this repository defines it: `git grep` finds only lookup-field references and the spec constant `CLOUD_PROVIDED_OBJECT_NAMES`, and `platform-objects/src/index.ts` says the `sys_environment*` objects are cloud-only. Pinned against the real ObjectQL registry: an engine that holds exactly `authIdentityObjects` answers `getSchema('sys_environment') === undefined`, and the guard then reads nothing. The fix decides this from registration, not from catching the throw. - **`sys_environment_member`** is also in `CLOUD_PROVIDED_OBJECT_NAMES`. The membership gate therefore asks the same registry question. ### Not measured: the cloud composition `NOT MEASURED: the cloud composition's membership gate, reason: this session was refused attaching objectstack-ai/cloud.` Two things there decide how this lands, and only the cloud tree can answer them: 1. Does the per-environment engine that `context.kernel` resolves register `sys_environment_member`? `packages/client/CHANGELOG.md` (11.0.0) records cloud#533 as retiring that object. If it is not registered there, this PR changes nothing in that composition: the gate used to admit through the caught throw and now admits by declaration. 2. If the engine does register it, does the read succeed? If that read faults on every request, every signed-in, non-platform-organization request on an environment-scoped door answers `503` after this lands. That is the triage's direction ("a real fault on a registered read refuses"), but it would be a visible change in that deployment. The cloud seat should confirm it before release. ## The HTTP door for the slug guard This was measured with a throwaway test that drove the real better-auth organization-update endpoint through `AuthManager.handleRequest`, over a memory engine double. The test was not committed. | engine | answer | slug after | `sys_environment` reads | |---|---|---|---| | does not register `sys_environment` | `200` | changed | 0 | | registers it, read faults | `503`, body `{ message }` | unchanged | 1 | | registers it, one active environment | `403`, body `{ message }` (control) | unchanged | 1 | | registers it, no environment | `200` | changed | 1 | On the `503` leg, `handleRequest` also logs one server-side line (`better-auth returned error: 503 …`). The `503` body follows better-auth's native shape, `{ message }`, the same shape the guard's own `403` refusal uses. No `code` field is added. ## Tests The pins PR #21939 added now assert the refusal. Each superseded assertion is quoted in place. - `packages/runtime/src/http-dispatcher.membership-system-context.test.ts`, 13 tests: - a read that throws (plain, on a registered engine, and an engine-side `PERMISSION_DENIED`) is refused, asserted on `code`, `status` and `object`; - no engine is refused; - an unregistered object reads nothing; - a healthy registered read still admits a member and refuses a non-member; - on the wire, through `createDispatcherPlugin` on a real `ObjectKernel`: member `501` (admitted, no automation service), non-member `403 PROJECT_MEMBERSHIP_REQUIRED`, read fault `503 SERVICE_UNAVAILABLE` (the envelope parses against `ApiErrorSchema`). - `packages/plugins/plugin-auth/src/auth-manager.org-slug-guard-system-context.test.ts`, 10 tests: - an organization read fault refuses `503` and the environment read is never made; - an environment read fault refuses `503`, both on a registering engine and on one whose registry cannot be asked; - healthy registered reads still refuse and allow; - an unregistered `sys_environment` reads nothing; - with the real ObjectQL registry, `authIdentityObjects` alone reads nothing, and a real engine fault (no driver) refuses `503`. - Fixture triage, three runtime files. They did not pin the defect: - `http-dispatcher.membership-skip-boundary.test.ts` and `packages-unscoped-environment-binding.test.ts` answered the membership read from a registry that registered nothing, which the real engine refuses with `OBJECT_NOT_FOUND`. They now register `sys_environment_member`. - `domains/meta-verb-fallthrough.test.ts` composes no ObjectQL engine at all, and the gate now refuses that composition. The gate is not that file's subject, so the file sets `enforceProjectMembership: false`, as the dispatcher option documents for tests. - Fixtures with `environmentId: 'platform'` and an engine whose registry does not register the member object (for example `meta-state-plural-tolerance`) used to pass the gate through a swallowed `TypeError`. They now pass by the registry's answer. The outcome is the same. ### Ablation Each negative pin was ablated: the fail-open answer was put back, the pin turned red, and the file was restored. The mutation went through `scripts/ablation-replace.mjs`: the anchor must hit, and the blob change and restore are verified on disk, with a script `trap` plus a HEAD-blob hash proof. The subjects are imported relatively (`./http-dispatcher.js`, `./auth-manager`), so no `dist/` leg applies. Ablation was run at head `6966166a0`, with the same red counts as an earlier run at `b275f81b`. | leg | mutation | red | |---|---|---| | A1 | membership read catch → `return null` | 4/13: the three read-fault pins, and the wire pin `expected 501 to be 503` (a non-member admitted to the domain) | | A2 | no engine → `return null` | 1/13 | | B1 | organization read catch → `return` | 2/10 (`the slug change was let through …`) | | B2 | environment read catch → `return` | 2/10 | The first A2 attempt was a no-op. Its replacement re-contained the anchor, the tool refused it (anchor 1 → 1), and no test ran. A2 was redone with a different replacement. ### Results at head `6966166a0` - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2`: 330 files, 4662 passed, 19 skipped, 0 failed. - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: 126 files, 2612 passed, 10 skipped, 0 failed. This ran at `b275f81b`. Since then `auth-manager.ts` is byte-identical, and the one changed test file was re-run at the head: 10/10. - `pnpm --filter @objectstack/runtime typecheck` and `pnpm --filter @objectstack/plugin-auth typecheck`: both exit 0, including `check:test-typecheck`. - `node scripts/pm/dispatch-gates.mjs --commands` derived 75 gate families from this diff, all run at this head, all exit 0. Reconciled with `--ran`: 75 derived, 75 run, 0 NOT-MEASURED, every exit code recorded. These include `check:dispatcher-error-vocabulary`, `check:auth-mount-ledger`, `check-system-context-census`, `check-tenant-audit-census`, `check-platform-object-tenancy-census`, `check:doc-authoring`, `check:issue-citations`, `check:nul-bytes`, `check:engine-double-contract`, `check:slot-lookup`, `check:dual-build-cjs-loads` (106 require entry points across 66 packages load) and `check-adr-0087-registration`. - `check-changeset-no-major.mjs --base origin/main --event` with this body's `Clause-②` line: exit 0. The level axis reads `no (narrowing)`, and no moved package is graded `patch`. - Lint, narrowed to the 7 changed `.ts` files with `eslint --no-inline-config --format json`: 0 errors and 0 warnings. That is 7 files linted and none ignored. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move a verdict on an untouched file. The full `pnpm lint` is left to CI. - One ledger row was added: `scripts/engine-double-contract.pinned.json` now records the new pinned `findOne` double, written by `check-engine-double-contract --write`. ## Acceptance notes - **Two neighbouring fail-opens in `enforceProjectMembership` are out of this card's scope and untouched.** The session-read catch ("Auth resolution failed — do not block the request on RBAC") and the `if (!userId) return null` fall-through both remain. Fixing either in place is not mechanical: the catch also covers a composition with no auth wired, which needs the registry's classified lookup. It is also unmeasured whether either is reachable through a public door, because the identity step reads the same session first, so this is read-only inference. Noted here, not filed. - **`environmentId: 'platform'`** (the reserved virtual id `rest-server.ts` documents) is skipped by `resolveRequestScope`'s helpers but not by this gate. If a host resolver ever writes it, the gate reads `sys_environment_member` for an environment id that has no rows. That behaviour is unchanged here. Noted, not filed. - **The wire `503` message is withheld** by the transport's 5xx sanitizer (`Internal server error`). The failed read is named only server-side, on the error's `object`. This is the same as the identity step's tenancy outage today. - **Changeset grade.** The dispatch asked for a `patch` changeset. Under `Clause-②: no (narrowing)`, `check-changeset-no-major.mjs` enforces `minor` for a package the diff moves. This was measured with a `patch` grade in a throwaway worktree: exit 1 (`enforce`). So the changeset is `minor` for both packages, carries the **BREAKING** banner, and records the ADR-0087 disposition `not-required (no-migration-prescription)`. --- _Generated by [Claude Code](https://claude.ai/code/session_01WMQprn46CND82KmY8sZWBu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6209964 commit 80f9f7e

9 files changed

Lines changed: 475 additions & 61 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/runtime': minor
3+
'@objectstack/plugin-auth': minor
4+
---
5+
6+
fix(runtime, plugin-auth)!: two access guards refuse, instead of admitting, when their own read cannot answer
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) A runtime refusal narrowing on two access guards, not a metadata change: no spec key, export, option or stored shape is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What narrows is which requests the two guards admit: a request admitted only because the guard's own read faulted is now refused with 503, and every answer from a healthy read is unchanged. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id covers either guard and this diff adds none (not registered / already-registered); and no published interface or type changes (not runtime-interface-only / type-surface-only). -->
11+
12+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention: a request that one of these two guards let through only because the guard's own read faulted is now refused. Nothing an author or caller writes changes shape.
13+
14+
- **`@objectstack/runtime` — the dispatcher's environment-membership gate.** When its `sys_environment_member` read throws, or no ObjectQL engine resolves on the request's kernel, the request is refused with `503 SERVICE_UNAVAILABLE` — the `AuthzStoreUnavailableError` answer the identity step and the domain gates already give an authorization input they could not read. Before, the gate logged at debug level and let the request through. A member is still admitted, and a non-member is still refused with `403 PROJECT_MEMBERSHIP_REQUIRED`. An engine whose registry does not register `sys_environment_member` declares the gate inapplicable, and nothing is read.
15+
- **`@objectstack/plugin-auth` — the organization slug guard** (`organizationHooks.beforeUpdateOrganization`). When its `sys_organization` or `sys_environment` read throws, the organization update is refused with `503 SERVICE_UNAVAILABLE`. Before, the hook ended without refusing and the slug changed. A slug change while an active environment references the organization is still refused (`403 FORBIDDEN`), and any other change is still allowed. An engine that does not register `sys_environment` — the open-source composition, where it is a cloud-provided object — declares the guard inapplicable from its registry (`getSchema`): nothing is read and the update proceeds as before. Without a data engine the guard does not apply either.
16+
17+
What changes for you: nothing in what you write. A `503 SERVICE_UNAVAILABLE` on these doors is a store outage that used to be hidden behind an admitted request; it clears when the store answers again.

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

Lines changed: 135 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,19 @@
1818
* 2. the guard KEEPS refusing on an engine that refuses a principal-less,
1919
* non-system context — its reads are not one, so the catch below never
2020
* turns the refusal into a skipped guard;
21-
* 3. the catches around the two reads are unchanged: a read that throws ends
22-
* the hook without refusing, as it did before. That is pre-existing
23-
* behaviour, pinned as it stands, not endorsed here.
21+
* 3. the guard FAILS CLOSED when a read it makes cannot answer (#21941): a
22+
* read that throws is refused with better-auth's `SERVICE_UNAVAILABLE`
23+
* (503) — never the guard's own `FORBIDDEN` (403), and never "ends the hook
24+
* without refusing" — while an engine that does not register
25+
* `sys_environment` (the open-source composition) declares the guard
26+
* inapplicable without reading anything. The real ObjectQL registry
27+
* answers that question in the last section.
2428
*/
2529

2630
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
31+
import { ObjectQL, assertEngineFindOnePredicate } from '@objectstack/objectql';
2732
import { AuthManager } from './auth-manager';
33+
import { authIdentityObjects } from './manifest';
2834

2935
vi.mock('better-auth', () => ({
3036
betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })),
@@ -172,27 +178,149 @@ describe('organization slug guard — on an engine that refuses a principal-less
172178
});
173179
});
174180

175-
describe('organization slug guard — the catches around the reads are unchanged (pre-existing)', () => {
176-
it('an organization read that throws ends the hook without refusing, as before', async () => {
181+
/**
182+
* 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.
186+
*/
187+
async function expectReadFaultRefusal(attempt: Promise<unknown>, object: string) {
188+
const err = (await attempt.then(
189+
() => undefined,
190+
(e: unknown) => e,
191+
)) as { status?: unknown; statusCode?: unknown; body?: { message?: unknown }; cause?: unknown } | undefined;
192+
expect(err, 'the slug change was let through on a read that could not answer').toBeTruthy();
193+
expect(err?.statusCode).toBe(503);
194+
expect(err?.status).toBe('SERVICE_UNAVAILABLE');
195+
expect(String(err?.body?.message)).toContain(`\`${object}\``);
196+
return err;
197+
}
198+
199+
/** The registration answer an ObjectQL engine gives through `getSchema`. */
200+
const schemaFor = (...registered: string[]) => vi.fn((object: string) => (registered.includes(object) ? { name: object } : undefined));
201+
202+
/**
203+
* An engine double that answers the registration question (`getSchema`) beside
204+
* its two reads. Its `findOne` keeps ObjectQL's own predicate contract
205+
* (`assertEngineFindOnePredicate`), so it is no looser than the engine it
206+
* stands in for.
207+
*/
208+
function registeringEngine(
209+
registered: string[],
210+
answers: { findOne: () => Promise<unknown>; find: () => Promise<unknown> },
211+
) {
212+
return {
213+
getSchema: schemaFor(...registered),
214+
findOne: vi.fn(async (object: string, q?: Query) => {
215+
assertEngineFindOnePredicate(object, q as never);
216+
return answers.findOne();
217+
}),
218+
find: vi.fn(answers.find),
219+
};
220+
}
221+
222+
describe('organization slug guard — fails closed when a read cannot answer (#21941)', () => {
223+
it('an organization read that throws is refused (503), and the environment read is never made', async () => {
224+
// SUPERSEDED PIN, quoted — what the guard answered before:
225+
// await expect(update('acme-new')).resolves.toBeUndefined(); // ends the hook without refusing
177226
const engine = {
178227
findOne: vi.fn(async () => {
179228
throw new Error('store unavailable');
180229
}),
181230
find: vi.fn(async () => ENVS),
182231
};
183232
const update = await slugGuard(engine);
184-
await expect(update('acme-new')).resolves.toBeUndefined();
233+
const err = await expectReadFaultRefusal(update('acme-new'), 'sys_organization');
234+
expect((err?.cause as Error | undefined)?.message).toBe('store unavailable');
185235
expect(engine.find).not.toHaveBeenCalled();
186236
});
187237

188-
it('an environment read that throws ends the hook without refusing, as before', async () => {
238+
it('an environment read that throws is refused (503) — on an engine that registers the object', async () => {
239+
// SUPERSEDED PIN, quoted — `find` threw and the hook resolved `undefined`.
240+
const engine = registeringEngine(['sys_organization', 'sys_environment'], {
241+
findOne: async () => ORG,
242+
find: async () => {
243+
throw new Error('connection reset');
244+
},
245+
});
246+
const update = await slugGuard(engine);
247+
await expectReadFaultRefusal(update('acme-new'), 'sys_environment');
248+
expect(engine.find).toHaveBeenCalledTimes(1);
249+
});
250+
251+
it('an environment read that throws on an engine whose registry cannot be asked is refused too — it is asked, and the fault refuses', async () => {
189252
const engine = {
190253
findOne: vi.fn(async () => ORG),
191254
find: vi.fn(async () => {
192255
throw new Error('object sys_environment is not registered');
193256
}),
194257
};
195258
const update = await slugGuard(engine);
259+
await expectReadFaultRefusal(update('acme-new'), 'sys_environment');
260+
});
261+
262+
it('a healthy read on a registered object still refuses the change while an active environment references the org, and allows it otherwise', async () => {
263+
const refusing = registeringEngine(['sys_organization', 'sys_environment'], {
264+
findOne: async () => ORG,
265+
find: async () => ENVS,
266+
});
267+
await expectSlugRefusal((await slugGuard(refusing))('acme-new'));
268+
const allowing = registeringEngine(['sys_organization', 'sys_environment'], {
269+
findOne: async () => ORG,
270+
find: async () => [{ id: 'e2', status: 'archived' }],
271+
});
272+
await expect((await slugGuard(allowing))('acme-new')).resolves.toBeUndefined();
273+
});
274+
275+
it('an engine that does not register `sys_environment` declares the guard inapplicable — nothing is read', async () => {
276+
const engine = registeringEngine(['sys_organization'], {
277+
findOne: async () => {
278+
throw new Error('must not be read');
279+
},
280+
find: async () => {
281+
throw new Error('must not be read');
282+
},
283+
});
284+
const update = await slugGuard(engine);
285+
await expect(update('acme-new')).resolves.toBeUndefined();
286+
expect(engine.getSchema).toHaveBeenCalledWith('sys_environment');
287+
expect(engine.findOne).not.toHaveBeenCalled();
288+
expect(engine.find).not.toHaveBeenCalled();
289+
});
290+
});
291+
292+
describe('organization slug guard — the real ObjectQL registry answers the composition question', () => {
293+
/** A real engine holding exactly the identity objects this package registers — no `sys_environment`. */
294+
function engineWithAuthObjects(): ObjectQL {
295+
const engine = new ObjectQL({ logger: { debug() {}, info() {}, warn() {}, error() {}, child() { return this; } } } as never);
296+
for (const object of authIdentityObjects) {
297+
engine.registry.registerObject(object as never, '@objectstack/plugin-auth');
298+
}
299+
return engine;
300+
}
301+
302+
it('this package registers no `sys_environment`, so on its own object set the guard does not apply and reads nothing', async () => {
303+
const engine = engineWithAuthObjects();
304+
expect(engine.getSchema('sys_organization'), 'POSITIVE CONTROL: the registry does answer for an object this package registers').toBeTruthy();
305+
expect(engine.getSchema('sys_environment')).toBeUndefined();
306+
const findOne = vi.spyOn(engine, 'findOne');
307+
const find = vi.spyOn(engine, 'find');
308+
const update = await slugGuard(engine);
196309
await expect(update('acme-new')).resolves.toBeUndefined();
310+
expect(findOne).not.toHaveBeenCalled();
311+
expect(find).not.toHaveBeenCalled();
312+
});
313+
314+
it('once `sys_environment` is registered the guard reads, and a real engine fault on that read refuses (503)', async () => {
315+
// No driver is registered, so the engine itself cannot serve the read: a
316+
// genuine fault of the real engine, not a double's.
317+
const engine = engineWithAuthObjects();
318+
engine.registry.registerObject(
319+
{ name: 'sys_environment', label: 'Environment', fields: { organization_id: { name: 'organization_id', type: 'text' } } } as never,
320+
'@objectstack/test-cloud-objects',
321+
);
322+
expect(engine.getSchema('sys_environment')).toBeTruthy();
323+
const update = await slugGuard(engine);
324+
await expectReadFaultRefusal(update('acme-new'), 'sys_organization');
197325
});
198326
});

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1275,6 +1275,28 @@ async function smsQuotaExceededApiError(message: string): Promise<Error> {
12751275
return new APIError(SMS_QUOTA_EXCEEDED_CODE, { message });
12761276
}
12771277

1278+
/**
1279+
* [#21941] The organization slug guard's answer when one of its own reads
1280+
* cannot answer: `503 SERVICE_UNAVAILABLE`, the existing ADR-0112 code for a
1281+
* store that is temporarily down.
1282+
*
1283+
* Not the guard's `403 FORBIDDEN`: a read that could not answer is not a
1284+
* verdict about the slug change, so it must not wear the refusal's words — and
1285+
* never "allowed", which is what swallowing the fault used to answer. The
1286+
* driver's own failure rides `cause`, so its diagnostic is not lost.
1287+
*/
1288+
async function slugGuardReadFaultApiError(object: string, cause: unknown): Promise<Error> {
1289+
const { APIError } = await import('better-auth/api');
1290+
const err = new APIError('SERVICE_UNAVAILABLE', {
1291+
message:
1292+
`The organization slug guard could not read \`${object}\`, so whether an active environment ` +
1293+
`still references this organization was never determined, and the slug was not changed. ` +
1294+
`This is a server-side outage, not a refusal of the new slug: retry once the data store is reachable.`,
1295+
});
1296+
(err as { cause?: unknown }).cause = cause;
1297+
return err;
1298+
}
1299+
12781300
export class AuthManager {
12791301
private auth: Auth<any> | null = null;
12801302
/**
@@ -3334,13 +3356,35 @@ export class AuthManager {
33343356
const orgId = member?.organizationId;
33353357
if (!newSlug || !orgId) return;
33363358

3359+
// [#21941] The guard reads only what this composition registers,
3360+
// and FAILS CLOSED when a registered read cannot answer.
3361+
//
3362+
// - NO DATA ENGINE ⇒ the guard does not apply. Only a standalone
3363+
// `AuthManager` lacks one (`AuthPlugin` declares ObjectQL a hard
3364+
// dependency), and it then runs on better-auth's in-memory store:
3365+
// no engine registers `sys_environment`, so no environment can
3366+
// reference the organization.
3367+
// - `sys_environment` NOT REGISTERED ⇒ the guard does not apply,
3368+
// for the same reason, and nothing is read. It is a cloud-provided
3369+
// object (`CLOUD_PROVIDED_OBJECT_NAMES`), so this is the
3370+
// open-source composition's answer. The question is the
3371+
// registry's own (`getSchema`), never a caught throw; an engine
3372+
// that cannot be asked reads as before — it is asked, and a fault
3373+
// refuses.
3374+
// - A REGISTERED READ THAT THROWS ⇒ refused, `503
3375+
// SERVICE_UNAVAILABLE` (`slugGuardReadFaultApiError`). It used to
3376+
// end the hook without refusing, which let the slug change through
3377+
// whenever either read faulted.
3378+
const rawEngine = this.config.dataEngine;
3379+
if (!rawEngine) return;
3380+
const schemaOf = (rawEngine as { getSchema?: (object: string) => unknown }).getSchema;
3381+
if (typeof schemaOf === 'function' && !schemaOf.call(rawEngine, 'sys_environment')) return;
3382+
33373383
// Both reads run as the platform (`withSystemContext`): this hook
33383384
// IS the slug guard — the organization id is the `where`, not the
33393385
// reader — so neither read reaches the engine with no principal
33403386
// and no opt-in (the security middleware's principal-less
3341-
// hand-off, ADR-0096). The catches below are unchanged.
3342-
const rawEngine = this.config.dataEngine;
3343-
if (!rawEngine) return;
3387+
// hand-off, ADR-0096).
33443388
const dataEngine = withSystemContext(rawEngine) as any;
33453389

33463390
let currentSlug: string | undefined;
@@ -3349,8 +3393,8 @@ export class AuthManager {
33493393
where: { id: orgId },
33503394
});
33513395
currentSlug = current?.slug;
3352-
} catch {
3353-
return;
3396+
} catch (err) {
3397+
throw await slugGuardReadFaultApiError('sys_organization', err);
33543398
}
33553399
if (!currentSlug || currentSlug === newSlug) return;
33563400

@@ -3362,8 +3406,8 @@ export class AuthManager {
33623406
activeEnvs = (envs ?? []).filter(
33633407
(e: any) => e?.status !== 'archived' && e?.status !== 'failed',
33643408
).length;
3365-
} catch {
3366-
return;
3409+
} catch (err) {
3410+
throw await slugGuardReadFaultApiError('sys_environment', err);
33673411
}
33683412

33693413
if (activeEnvs > 0) {

‎packages/runtime/src/domains/meta-verb-fallthrough.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,12 @@ function boot() {
129129
} as any;
130130

131131
return {
132-
dispatcher: new HttpDispatcher(kernel),
132+
// The environment-membership gate is not this file's subject, and this
133+
// fixture composes no ObjectQL engine — which the gate refuses as an
134+
// outage (503) rather than letting the request through (#21941). It is
135+
// switched off here the way the dispatcher option documents for tests
136+
// that seed no membership.
137+
dispatcher: new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }),
133138
getMetaItem,
134139
saveMetaItem,
135140
deleteMetaItem,

‎packages/runtime/src/http-dispatcher.membership-skip-boundary.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,14 @@ function makeGate() {
5151
const objectql = {
5252
find,
5353
getObjects: vi.fn().mockReturnValue({}),
54-
registry: { getObject: vi.fn().mockReturnValue(null), getRegisteredTypes: vi.fn().mockReturnValue([]) },
54+
// The engine REGISTERS `sys_environment_member`: the gate asks the
55+
// registry before it reads, and an engine that registers no such object
56+
// declares the gate inapplicable (#21941) — which would make "skipped"
57+
// and "checked" both return `null` again.
58+
registry: {
59+
getObject: vi.fn((name: string) => (name === 'sys_environment_member' ? { name } : null)),
60+
getRegisteredTypes: vi.fn().mockReturnValue([]),
61+
},
5562
};
5663
const auth = {
5764
getApi: async () => ({

0 commit comments

Comments
 (0)