From 163cb345408ed3fec897b745977e7238f8edc52d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 02:47:02 +0000 Subject: [PATCH] fix(runtime): ship a declared `api.projectResolution` from the standalone boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createStandaloneStack()` returned `projectResolution: 'none'`, a value `RestApiConfigSchema` does not declare, and `os serve` forwarded it unchanged. Nothing caught it because `RestServer` cast its config instead of parsing it. Routing behaviour is unchanged and measured: every reader that acts on the key is gated on `enableProjectScoping` first. The discovery advertisement is NOT — `RestServer` copies the value into `discovery.scoping.resolution` unconditionally, where `DiscoverySchema` declares the same three-member enum, so every `os serve` boot advertised a payload the platform's own schema rejects. Adds the pin that never existed: the CLI's real boot block and the block the factory actually returns are both parsed against the declared schema, each asserting the refusal of `'none'` alongside the acceptance of `'auto'`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd --- .../standalone-project-resolution-declared.md | 41 +++++++++ .../cli/src/utils/merge-boot-config.test.ts | 86 ++++++++++++++++++- packages/cli/src/utils/merge-boot-config.ts | 9 +- packages/runtime/src/standalone-stack.test.ts | 29 +++++++ packages/runtime/src/standalone-stack.ts | 35 +++++++- 5 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 .changeset/standalone-project-resolution-declared.md diff --git a/.changeset/standalone-project-resolution-declared.md b/.changeset/standalone-project-resolution-declared.md new file mode 100644 index 0000000000..05c15074e8 --- /dev/null +++ b/.changeset/standalone-project-resolution-declared.md @@ -0,0 +1,41 @@ +--- +"@objectstack/runtime": patch +"@objectstack/cli": patch +--- + +Ship a **declared** `api.projectResolution` from the standalone boot path (#11999) + +`@objectstack/runtime`'s `createStandaloneStack()` / `createDefaultHostConfig()` +returned `api: { enableProjectScoping: false, projectResolution: 'none' }`, and +`os serve` forwarded it unchanged. `'none'` is not a member of the declared enum: +`RestApiConfigSchema` (`packages/spec/src/api/rest-server.zod.ts`) declares +`z.enum(['required', 'optional', 'auto'])`. Three packages disagreed about this +key's vocabulary, and the disagreement survived because nothing ever executed +the schema — `RestServer` cast its config instead of parsing it. + +`StandaloneStackResult['api']` now declares, and the factory now emits, +`projectResolution: 'auto'`. + +**Behaviour on the routing path is unchanged, and that is measured, not assumed.** +Every reader that acts on this key is gated on `enableProjectScoping` first: +`RestServer.registerRoutes` takes its `else` arm, `mountAndRecordDirectRoutes` +mounts `[versionedBase]`, and the Dispatcher plugin's two +`enableProjectScoping && … === 'required'` guards short-circuit. With scoping off +the strategy really is moot for routing — which is why this migrates the value +rather than teaching the enum a fourth member. + +**One reader is not gated, and that is the user-visible fix.** `RestServer`'s +discovery handler copies `api.projectResolution` into +`discovery.scoping.resolution` unconditionally, and `DiscoverySchema` declares +that field as the same three-member enum. So `GET /api/v1` on every `os serve` +boot advertised a payload the platform's own schema rejects. Clients that +validate discovery — or switch on `scoping.resolution` — now receive a declared +value. + +Both halves are pinned rather than described: `merge-boot-config.test.ts` parses +the CLI's real boot block against `RestApiConfigSchema` and against the discovery +field's enum, and `standalone-stack.test.ts` parses the block the factory +actually returns. Each pin asserts the refusal of `'none'` alongside the +acceptance of `'auto'`, so it can be seen to say no. The CLI constant is now +typed as `StandaloneStackResult['api']`, so it can no longer drift from the +producer without failing `tsc`. diff --git a/packages/cli/src/utils/merge-boot-config.test.ts b/packages/cli/src/utils/merge-boot-config.test.ts index c7ff88d3e5..888f86a97f 100644 --- a/packages/cli/src/utils/merge-boot-config.test.ts +++ b/packages/cli/src/utils/merge-boot-config.test.ts @@ -1,10 +1,24 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { RestApiConfigSchema, DiscoverySchema } from '@objectstack/spec/api'; +import type { StandaloneStackResult } from '@objectstack/runtime'; import { mergeBootConfig } from './merge-boot-config.js'; -/** What `createStandaloneStack()` actually returns for the `api` block. */ -const BOOT_API = { enableProjectScoping: false, projectResolution: 'none' } as const; +/** + * What `createStandaloneStack()` actually returns for the `api` block. + * + * Annotated with the producer's own declared literal type rather than left as + * a bare `as const`, so this stops being a HAND-COPY that can drift from the + * thing it claims to mirror. A copy is precisely how #11999 survived: this + * constant, `merge-boot-config.ts`'s doc block and + * `StandaloneStackResult.api` each spelled the value separately, and nothing + * held them equal. Change the runtime's literal without changing this line + * and `tsc --noEmit` fails here — `packages/cli`'s `typecheck` compiles + * `include: ["src"]` with no test exclusion, so this pin is live (the build + * tsconfig excludes tests, this one does not). + */ +const BOOT_API: StandaloneStackResult['api'] = { enableProjectScoping: false, projectResolution: 'auto' }; describe('mergeBootConfig (#4002)', () => { it('keeps an authored api key the boot result does not set', () => { @@ -26,7 +40,7 @@ describe('mergeBootConfig (#4002)', () => { ); expect(merged.api.enableProjectScoping).toBe(false); - expect(merged.api.projectResolution).toBe('none'); + expect(merged.api.projectResolution).toBe('auto'); expect(merged.api.enforceProjectMembership).toBe(false); // untouched by boot → survives }); @@ -72,3 +86,69 @@ describe('mergeBootConfig (#4002)', () => { expect(boot).toEqual({ api: { ...BOOT_API } }); }); }); + +/** + * [#11999] The check that did not exist — and whose absence is the whole + * reason three packages disagreed about `api.projectResolution`'s vocabulary + * for as long as nothing executed the schema. + * + * `@objectstack/spec` declared `z.enum(['required','optional','auto'])`, + * `@objectstack/runtime` shipped `'none'`, and `os serve` forwarded it + * unchanged (`apiConfig.projectResolution ?? 'auto'` never fires — `'none'` + * is not nullish). `RestServer` CAST this config instead of parsing it, so + * the enum never ran on any deployment path; downstream, every reader that + * acts on the key is gated on `enableProjectScoping` first, so `'none'` + * silently took `'auto'`'s branch without ever being named as such. + * + * These cases run the declared schema against the value this package + * actually boots with, which is the one thing none of the three did. + */ +describe('[#11999] the boot api block is a DECLARED config, not just a working one', () => { + it('parses clean against RestApiConfigSchema', () => { + const parsed = RestApiConfigSchema.parse({ ...BOOT_API }); + expect(parsed.enableProjectScoping).toBe(false); + expect(parsed.projectResolution).toBe('auto'); + }); + + it('still parses after the merge — the block `serve` actually forwards', () => { + // Parsing BOOT_API alone would not cover the seam: what reaches + // `createRestApiPlugin` and the Dispatcher plugin is the MERGED api + // block, so an authored key surviving the merge must not break it. + const merged: any = mergeBootConfig( + { api: { enforceProjectMembership: false } }, + { api: { ...BOOT_API }, plugins: [] }, + ); + const parsed = RestApiConfigSchema.parse(merged.api); + expect(parsed.projectResolution).toBe('auto'); + }); + + it('REFUSES the undeclared `none` this shipped before #11999', () => { + // A pin is only worth having if it can say no, so the negative is + // asserted here rather than trusted. Asserted on the refusal's + // identity — the offending path and the issue code — not on the bare + // fact that something failed: a `safeParse` that went false for an + // unrelated key would otherwise read as this case passing. + const r = RestApiConfigSchema.safeParse({ ...BOOT_API, projectResolution: 'none' }); + expect(r.success).toBe(false); + const issue = r.success ? undefined : r.error.issues.find( + (i) => i.path.join('.') === 'projectResolution', + ); + expect(issue?.code).toBe('invalid_value'); + expect(issue?.message).toContain('auto'); + }); + + it('is a value the DISCOVERY advertisement may also carry', () => { + // The second declared contract this key answers to, and the reason + // `enableProjectScoping: false` does NOT make the value moot. + // `RestServer`'s discovery handler copies `api.projectResolution` + // into `discovery.scoping.resolution` UNCONDITIONALLY — no + // `enableProjectScoping` guard — and `DiscoverySchema` declares that + // field as the same three-member enum. Shipping `'none'` therefore + // published a discovery payload the platform's own schema rejects, on + // every `os serve` boot. + const scoping = { enabled: BOOT_API.enableProjectScoping, resolution: BOOT_API.projectResolution, scoped: false }; + const field = DiscoverySchema.shape.scoping.unwrap().shape.resolution; + expect(field.safeParse(scoping.resolution).success).toBe(true); + expect(field.safeParse('none').success).toBe(false); + }); +}); diff --git a/packages/cli/src/utils/merge-boot-config.ts b/packages/cli/src/utils/merge-boot-config.ts index ba1551d3c5..4f58dcffc7 100644 --- a/packages/cli/src/utils/merge-boot-config.ts +++ b/packages/cli/src/utils/merge-boot-config.ts @@ -9,9 +9,16 @@ * an `api` block carrying only the environment-scoping decision: * * ```js - * api: { enableProjectScoping: false, projectResolution: 'none' } + * api: { enableProjectScoping: false, projectResolution: 'auto' } * ``` * + * (`projectResolution` was the undeclared `'none'` until #11999 — see + * `StandaloneStackResult.api` in `@objectstack/runtime` for why it moved. + * `merge-boot-config.test.ts` pins that this block parses clean against + * `RestApiConfigSchema`, the check whose absence let the three packages + * disagree about this key's vocabulary for as long as nothing executed + * the schema.) + * * Under a shallow spread that object REPLACED the author's entire `api` block, * silently discarding every key it did not itself set. Two of those keys are * live knobs the CLI reads a few lines later: diff --git a/packages/runtime/src/standalone-stack.test.ts b/packages/runtime/src/standalone-stack.test.ts index 1c7f4ad93c..f66861b64d 100644 --- a/packages/runtime/src/standalone-stack.test.ts +++ b/packages/runtime/src/standalone-stack.test.ts @@ -20,6 +20,11 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createStandaloneStack } from './standalone-stack.js'; import { createDefaultHostConfig, resolveDefaultArtifactPath } from './default-host.js'; +// [#11999] The DECLARED contract for the `api` block this factory emits. The +// producer side of the pin whose absence let `'none'` ship: asserted against +// the value `createStandaloneStack` actually returns, never against a copy of +// it. `@objectstack/spec` is a plain `dependencies` entry of this package. +import { RestApiConfigSchema } from '@objectstack/spec/api'; // The REAL resolution, imported — not reproduced. `@objectstack/plugin-security` // is a plain `dependencies` entry of this package (and another test in this same // package, src/domains/share-links-enforcement-context.test.ts, already imports @@ -126,6 +131,30 @@ describe('createStandaloneStack — surfaces app RBAC from the artifact (ADR-005 expect(result.manifest?.id).toBe('com.test.scope-app'); }); + it('[#11999] the emitted `api` block is a value RestApiConfigSchema accepts', () => { + // Parsed off the REAL boot result, not off a restatement of it — the + // hand-copies are what let three packages disagree here. + // + // Until #11999 this shipped `projectResolution: 'none'`, which the + // declared enum does not contain. Nothing caught it because `RestServer` + // CAST this config rather than parsing it, and every reader that acts on + // the key is gated on `enableProjectScoping` first — so the undeclared + // value silently took `'auto'`'s branch without ever being named as such. + const parsed = RestApiConfigSchema.parse({ ...result.api }); + expect(parsed.enableProjectScoping).toBe(false); + expect(parsed.projectResolution).toBe('auto'); + + // …and the schema is genuinely discriminating here, not vacuously green: + // the value that shipped is refused by the very same instrument, on the + // `projectResolution` path specifically. + const shipped = RestApiConfigSchema.safeParse({ ...result.api, projectResolution: 'none' }); + expect(shipped.success).toBe(false); + const issue = shipped.success ? undefined : shipped.error.issues.find( + (i) => i.path.join('.') === 'projectResolution', + ); + expect(issue?.code).toBe('invalid_value'); + }); + it('the surfaced config feeds the REAL appSecurityPluginOptions → the app profile', () => { // Reproduce serve.ts's merge: `config = { ...originalConfig, ...standaloneStack }`, // then `new SecurityPlugin(appSecurityPluginOptions(config))`. diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index 8f6d1e9e11..625f281d5f 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -244,7 +244,35 @@ export type StandaloneStackConfig = z.input; export interface StandaloneStackResult { plugins: any[]; - api: { enableProjectScoping: false; projectResolution: 'none' }; + /** + * The environment-scoping decision for a standalone host, and the ONLY + * `api` keys this builder decides (`mergeBootConfig` in packages/cli + * merges the rest per key, so an author's other `api` declarations + * survive). + * + * `projectResolution` is `'auto'` — a member of the declared enum + * (`RestApiConfigSchema`, `packages/spec/src/api/rest-server.zod.ts`) — + * and NOT the `'none'` this shipped until #11999. `'none'` was never a + * declared value; it read as "no scoping at all" and got `'auto'`'s + * behaviour by fallthrough, because every reader that acts on this key + * is gated on `enableProjectScoping` first: + * + * - `RestServer.registerRoutes` takes the `else` arm and never looks; + * - `mountAndRecordDirectRoutes` mounts `[versionedBase]` and never looks; + * - `DispatcherPlugin`'s two `enableProjectScoping && … === 'required'` + * guards short-circuit. + * + * So with `enableProjectScoping: false` the strategy really is moot for + * ROUTING — which is why this migrates to a declared value rather than + * teaching the enum a fourth member. But it is not moot for the + * ADVERTISEMENT: `RestServer`'s discovery handler copies this value into + * `discovery.scoping.resolution` unconditionally, and `DiscoverySchema` + * declares that field as the same three-member enum. Shipping `'none'` + * therefore published a discovery payload that the platform's own schema + * rejects, on every `os serve` boot. `'auto'` is what the routing already + * did and what the advertisement is allowed to say. + */ + api: { enableProjectScoping: false; projectResolution: 'auto' }; /** * Top-level metadata copied from the loaded artifact bundle (when an * artifact was successfully loaded). These are surfaced so callers @@ -757,9 +785,12 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro return { plugins, + // #11999 — `'auto'`, not the undeclared `'none'` this used to ship. + // See StandaloneStackResult.api for why the two are the same for + // routing here and NOT the same for the discovery advertisement. api: { enableProjectScoping: false, - projectResolution: 'none', + projectResolution: 'auto', }, ...(requires ? { requires } : {}), ...(objects ? { objects } : {}),