From 6658f377f567da99f3f18c6f25fe04afd9655aa9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:39:35 +0000 Subject: [PATCH] fix(cli): the boot merge no longer discards the authored api block (#4002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit serve (and dev, which spawns it) assembled the effective config as `{ ...authored, ...bootResult }`. createStandaloneStack() / createDefaultHostConfig() return an `api` block carrying only the environment-scoping decision — { enableProjectScoping: false, projectResolution: 'none' } — and under a shallow spread that object REPLACED the author's entire `api`, silently dropping every key it did not itself set. Two of those keys are live knobs the CLI reads a few lines later: - api.requireAuth — the documented one-line opt-out for serving data publicly (ADR-0056 D2; v12 presents it as the whole migration). Authoring it did nothing: the value never reached the REST or dispatcher plugin, so anonymous requests kept getting 401 AND the boot warning that exists to make a fail-open posture visible never fired either. - api.enforceProjectMembership — the ADR-0024 D9 opt-out from the sys_environment_member 403 gate. Silently fell back to the dispatcher default. `api` now merges per key via a small pure mergeBootConfig helper: the author's declarations survive and the boot builder still wins on the keys it decides (scoping is not the author's call on a standalone host). Every other top-level key keeps whole-value semantics — the artifact-serve path deliberately serves the boot result's objects / permissions / manifest / plugins. The auth-less carve-out was never affected: it lives in the `?? ((tierEnabled('auth') || hasAuthPlugin) ? true : false)` fallback, which fired precisely BECAUSE the authored value had gone missing. Only an explicitly authored value was lost. Verified end to end on the CRM example: with api: { requireAuth: false } an anonymous POST /data/crm_account/query returned 401 before and returns records after. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TzLE9cw4gZKNyPN2ZP4iTt --- .changeset/boot-api-merge.md | 40 ++++++++++ packages/cli/src/commands/serve.ts | 9 ++- .../cli/src/utils/merge-boot-config.test.ts | 75 +++++++++++++++++++ packages/cli/src/utils/merge-boot-config.ts | 51 +++++++++++++ 4 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 .changeset/boot-api-merge.md create mode 100644 packages/cli/src/utils/merge-boot-config.test.ts create mode 100644 packages/cli/src/utils/merge-boot-config.ts diff --git a/.changeset/boot-api-merge.md b/.changeset/boot-api-merge.md new file mode 100644 index 0000000000..26e036f6fb --- /dev/null +++ b/.changeset/boot-api-merge.md @@ -0,0 +1,40 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): the boot merge no longer discards the authored `api` block (#4002) + +`objectstack serve` (and `dev`, which spawns it) assembled the effective config as +`{ ...authored, ...bootResult }`. `createStandaloneStack()` / +`createDefaultHostConfig()` return an `api` block carrying only the +environment-scoping decision — `{ enableProjectScoping: false, projectResolution: +'none' }` — and under a shallow spread that object REPLACED the author's entire +`api`, silently dropping every key it did not itself set. + +Two of those keys are live knobs the CLI reads a few lines later: + +- **`api.requireAuth`** — the documented one-line opt-out for serving data + publicly (ADR-0056 D2; the v12 migration note presents it as the whole + migration). Authoring it did nothing: the value never reached the REST or + dispatcher plugin, so anonymous requests kept getting `401` **and** the boot + warning that exists to make a fail-open posture visible never fired either. +- **`api.enforceProjectMembership`** — the ADR-0024 D9 opt-out from the + `sys_environment_member` 403 gate. Silently fell back to the dispatcher default. + +`api` now merges per key, via a small pure `mergeBootConfig` helper: the author's +declarations survive, and the boot builder still wins on the keys it actually +decides (environment scoping is not the author's call on a standalone host). +Every other top-level key keeps the previous whole-value semantics — the +artifact-serve path deliberately serves the boot result's `objects` / +`permissions` / `manifest` / `plugins`, so those are untouched. + +The auth-less carve-out was never affected and is unchanged: it lives in the +`?? ((tierEnabled('auth') || hasAuthPlugin) ? true : false)` fallback, which fired +precisely *because* the authored value had gone missing. Only an explicitly +authored value was lost. + +Verified end to end: with `api: { requireAuth: false }` on the CRM example, an +anonymous `POST /data/crm_account/query` returned `401` before and returns records +after. Worth knowing what the working flag does — the same anonymous caller can +then read `sys_user` — which is the flag's documented meaning ("serve data +publicly"), and the argument for retiring it (#3963). diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 2b0fd5c10d..43dda943b3 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -7,6 +7,7 @@ import net from 'net'; import chalk from 'chalk'; import { bundleRequire } from 'bundle-require'; import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; +import { mergeBootConfig } from '../utils/merge-boot-config.js'; import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js'; import { resolveDriverType, resolveStorageDefinition, UnsupportedDriverError } from '../utils/storage-driver.js'; import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, resolveSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types'; @@ -655,7 +656,10 @@ export default class Serve extends Command { // can later install marketplace apps at runtime. const { createDefaultHostConfig } = await import('@objectstack/runtime'); const bootResult = await createDefaultHostConfig({ requireArtifact: !useEmptyBoot, dev: isDev }); - config = { ...originalConfig, ...bootResult } as any; + // [#4002] `api` merges per key — see mergeBootConfig. A shallow spread + // let the boot builder's two scoping keys wipe the author's whole `api` + // block, silently dropping `requireAuth` / `enforceProjectMembership`. + config = mergeBootConfig(originalConfig as any, bootResult as any) as any; } else if (resolvedMode === 'standalone') { const { createStandaloneStack } = await import('@objectstack/runtime'); // Anchor the default sqlite database under the project folder @@ -669,7 +673,8 @@ export default class Serve extends Command { dev: isDev, }; const bootResult = await createStandaloneStack(standaloneInput); - config = { ...originalConfig, ...bootResult } as any; + // [#4002] Per-key `api` merge — see mergeBootConfig. + config = mergeBootConfig(originalConfig as any, bootResult as any) as any; } else { throw new Error( `Boot mode '${resolvedMode}' is not available in the open-core CLI.\n` diff --git a/packages/cli/src/utils/merge-boot-config.test.ts b/packages/cli/src/utils/merge-boot-config.test.ts new file mode 100644 index 0000000000..a335a602f0 --- /dev/null +++ b/packages/cli/src/utils/merge-boot-config.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { mergeBootConfig } from './merge-boot-config.js'; + +/** What `createStandaloneStack()` actually returns for the `api` block. */ +const BOOT_API = { enableProjectScoping: false, projectResolution: 'none' } as const; + +describe('mergeBootConfig (#4002)', () => { + it('keeps the authored api keys the boot result does not set', () => { + const merged: any = mergeBootConfig( + { api: { requireAuth: false, enforceProjectMembership: false } }, + { api: { ...BOOT_API }, plugins: [] }, + ); + + // The two live knobs the CLI reads a few lines later — both were dropped + // by the old shallow spread. + expect(merged.api.requireAuth).toBe(false); + expect(merged.api.enforceProjectMembership).toBe(false); + }); + + it('lets the boot result win on the keys it decides', () => { + // Environment scoping is not the author's call on a standalone host. + const merged: any = mergeBootConfig( + { api: { enableProjectScoping: true, projectResolution: 'auto', requireAuth: false } }, + { api: { ...BOOT_API } }, + ); + + expect(merged.api.enableProjectScoping).toBe(false); + expect(merged.api.projectResolution).toBe('none'); + expect(merged.api.requireAuth).toBe(false); // untouched by boot → survives + }); + + it('still replaces every other top-level key wholesale', () => { + // The artifact-serve path deliberately serves the boot result's objects / + // permissions / plugins, so those keep the previous semantics. + const merged: any = mergeBootConfig( + { objects: [{ name: 'authored' }], plugins: ['authored'] }, + { objects: [{ name: 'from_artifact' }], plugins: ['from_boot'] }, + ); + + expect(merged.objects).toEqual([{ name: 'from_artifact' }]); + expect(merged.plugins).toEqual(['from_boot']); + }); + + it('does not invent an api block when neither side has one', () => { + const merged: any = mergeBootConfig({ objects: [] }, { plugins: [] }); + expect('api' in merged).toBe(false); + }); + + it('carries an api block through when only one side has one', () => { + expect((mergeBootConfig({ api: { requireAuth: false } }, {}) as any).api) + .toEqual({ requireAuth: false }); + expect((mergeBootConfig({}, { api: { ...BOOT_API } }) as any).api) + .toEqual({ ...BOOT_API }); + }); + + it('ignores a non-object api on either side rather than spreading it', () => { + // Defensive: a malformed authored `api` must not throw or produce + // character-indexed keys from a string spread. + expect((mergeBootConfig({ api: 'nonsense' as any }, { api: { ...BOOT_API } }) as any).api) + .toEqual({ ...BOOT_API }); + expect((mergeBootConfig({ api: { requireAuth: false } }, { api: null as any }) as any).api) + .toEqual({ requireAuth: false }); + }); + + it('does not mutate either input', () => { + const authored = { api: { requireAuth: false } }; + const boot = { api: { ...BOOT_API } }; + mergeBootConfig(authored, boot); + + expect(authored).toEqual({ api: { requireAuth: false } }); + expect(boot).toEqual({ api: { ...BOOT_API } }); + }); +}); diff --git a/packages/cli/src/utils/merge-boot-config.ts b/packages/cli/src/utils/merge-boot-config.ts new file mode 100644 index 0000000000..ba1551d3c5 --- /dev/null +++ b/packages/cli/src/utils/merge-boot-config.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#4002] Merge a boot result over the authored stack config. + * + * `objectstack serve` (and `dev`, which spawns it) assembles the effective + * config as `{ ...authored, ...bootResult }`, where `bootResult` comes from + * `createStandaloneStack()` / `createDefaultHostConfig()`. Those builders return + * an `api` block carrying only the environment-scoping decision: + * + * ```js + * api: { enableProjectScoping: false, projectResolution: 'none' } + * ``` + * + * 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: + * + * - `api.requireAuth` — the documented one-line opt-out for serving data + * publicly (ADR-0056 D2, the v12 migration note). Authoring it did nothing: + * the value never reached the REST plugin, so the boot warning that is supposed + * to make a fail-open posture visible never fired either. + * - `api.enforceProjectMembership` — the ADR-0024 D9 opt-out from the + * `sys_environment_member` 403 gate. Silently fell back to the dispatcher + * default. + * + * So `api` merges PER KEY: the author's declarations survive, and the boot + * builder still wins on the keys it actually decides (scoping is not the + * author's call on a standalone host). Every other top-level key keeps the + * previous whole-value semantics — for `objects` / `permissions` / `manifest` / + * `plugins` the artifact-serve path deliberately serves the boot result's + * version, so those are not swept into this change. + */ +export function mergeBootConfig(authored: A, bootResult: B): A & B { + const merged = { ...authored, ...bootResult } as any; + const authoredApi = (authored as any)?.api; + const bootApi = (bootResult as any)?.api; + // Only synthesize the key when at least one side has one, so a config with + // no `api` block anywhere does not gain an empty object. + if (isPlainObject(authoredApi) || isPlainObject(bootApi)) { + merged.api = { + ...(isPlainObject(authoredApi) ? authoredApi : {}), + ...(isPlainObject(bootApi) ? bootApi : {}), + }; + } + return merged as A & B; +} + +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +}