Skip to content

Commit 49421a8

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-21922-host-code-datasource-set
2 parents 80fbcfd + 80f9f7e commit 49421a8

10 files changed

Lines changed: 483 additions & 63 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.

‎osv-scanner.toml‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
# yet, where the alternative is pinning a required check red indefinitely, and
99
# a permanently red required check is worth exactly as much as no scan at all.
1010
#
11-
# This ledger currently holds ZERO exemptions. That is the intended steady
12-
# state, not a coincidence — read the whole header before you change it.
11+
# This ledger's intended steady state is ZERO exemptions, not a coincidence:
12+
# every entry below is a dated exception with its own renewal date. Read the
13+
# whole header before you change it.
1314
#
1415
# ---------------------------------------------------------------------------
1516
# Three conventions govern every entry (decided on #4965)
@@ -83,3 +84,8 @@
8384
#
8485
# Verify locally: node scripts/check-osv-exemptions.mjs
8586
# Prove the check: node scripts/check-osv-exemptions.mjs --self-test
87+
88+
[[IgnoredVulns]]
89+
id = "GHSA-hp3w-g68c-fv3c"
90+
ignoreUntil = 2026-11-05
91+
reason = "https://github.com/advisories/GHSA-hp3w-g68c-fv3c — no fixed sprintf-js exists (1.1.3, the latest release, is the last affected version), and it arrives only transitively through tedious 18.6.2 (driver-sql's optional mssql peer) and fengari 0.1.5 (under ioredis-mock, a service-cluster-redis devDependency), whose latest releases (tedious 20.3.3, fengari 0.1.5) still require ^1.1.3; remove when sprintf-js publishes a fix or both parents drop it."

‎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)