Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/standalone-project-resolution-declared.md
Original file line numberDiff line numberDiff line change
@@ -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`.
86 changes: 83 additions & 3 deletions packages/cli/src/utils/merge-boot-config.test.ts
Original file line numberDiff line numberDiff line change
@@ -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', () => {
Expand All@@ -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
});

Expand DownExpand Up@@ -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);
});
});
9 changes: 8 additions & 1 deletion packages/cli/src/utils/merge-boot-config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand Down
29 changes: 29 additions & 0 deletions packages/runtime/src/standalone-stack.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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))`.
Expand Down
35 changes: 33 additions & 2 deletions packages/runtime/src/standalone-stack.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,7 +244,35 @@ export type StandaloneStackConfig = z.input<typeof StandaloneStackConfigSchema>;

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
Expand DownExpand Up@@ -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 } : {}),
Expand Down
Loading