Skip to content

Commit c6c0d05

Browse files
authored
fix(cli): the boot merge no longer discards the authored api block (#4002) (#4008)
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, 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 — authoring it did nothing, so anonymous requests kept getting 401 AND the boot warning meant to make a fail-open posture visible never fired) and api.enforceProjectMembership (the ADR-0024 D9 opt-out, silently falling back to the dispatcher default). `api` now merges per key via a pure mergeBootConfig helper: the author's declarations survive and the boot builder still wins on the keys it decides. Every other top-level key keeps whole-value semantics, since 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 `??` fallback, which fired precisely because the authored value had gone missing.
1 parent 39eb01b commit c6c0d05

4 files changed

Lines changed: 173 additions & 2 deletions

File tree

.changeset/boot-api-merge.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): the boot merge no longer discards the authored `api` block (#4002)
6+
7+
`objectstack serve` (and `dev`, which spawns it) assembled the effective config as
8+
`{ ...authored, ...bootResult }`. `createStandaloneStack()` /
9+
`createDefaultHostConfig()` return an `api` block carrying only the
10+
environment-scoping decision — `{ enableProjectScoping: false, projectResolution:
11+
'none' }` — and under a shallow spread that object REPLACED the author's entire
12+
`api`, silently dropping every key it did not itself set.
13+
14+
Two of those keys are live knobs the CLI reads a few lines later:
15+
16+
- **`api.requireAuth`** — the documented one-line opt-out for serving data
17+
publicly (ADR-0056 D2; the v12 migration note presents it as the whole
18+
migration). Authoring it did nothing: the value never reached the REST or
19+
dispatcher plugin, so anonymous requests kept getting `401` **and** the boot
20+
warning that exists to make a fail-open posture visible never fired either.
21+
- **`api.enforceProjectMembership`** — the ADR-0024 D9 opt-out from the
22+
`sys_environment_member` 403 gate. Silently fell back to the dispatcher default.
23+
24+
`api` now merges per key, via a small pure `mergeBootConfig` helper: the author's
25+
declarations survive, and the boot builder still wins on the keys it actually
26+
decides (environment scoping is not the author's call on a standalone host).
27+
Every other top-level key keeps the previous whole-value semantics — the
28+
artifact-serve path deliberately serves the boot result's `objects` /
29+
`permissions` / `manifest` / `plugins`, so those are untouched.
30+
31+
The auth-less carve-out was never affected and is unchanged: it lives in the
32+
`?? ((tierEnabled('auth') || hasAuthPlugin) ? true : false)` fallback, which fired
33+
precisely *because* the authored value had gone missing. Only an explicitly
34+
authored value was lost.
35+
36+
Verified end to end: with `api: { requireAuth: false }` on the CRM example, an
37+
anonymous `POST /data/crm_account/query` returned `401` before and returns records
38+
after. Worth knowing what the working flag does — the same anonymous caller can
39+
then read `sys_user` — which is the flag's documented meaning ("serve data
40+
publicly"), and the argument for retiring it (#3963).

packages/cli/src/commands/serve.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import net from 'net';
77
import chalk from 'chalk';
88
import { bundleRequire } from 'bundle-require';
99
import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
10+
import { mergeBootConfig } from '../utils/merge-boot-config.js';
1011
import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js';
1112
import { resolveDriverType, resolveStorageDefinition, UnsupportedDriverError } from '../utils/storage-driver.js';
1213
import { readEnvWithDeprecation, resolveMultiOrgEnabled, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, stampSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types';
@@ -655,7 +656,10 @@ export default class Serve extends Command {
655656
// can later install marketplace apps at runtime.
656657
const { createDefaultHostConfig } = await import('@objectstack/runtime');
657658
const bootResult = await createDefaultHostConfig({ requireArtifact: !useEmptyBoot, dev: isDev });
658-
config = { ...originalConfig, ...bootResult } as any;
659+
// [#4002] `api` merges per key — see mergeBootConfig. A shallow spread
660+
// let the boot builder's two scoping keys wipe the author's whole `api`
661+
// block, silently dropping `requireAuth` / `enforceProjectMembership`.
662+
config = mergeBootConfig(originalConfig as any, bootResult as any) as any;
659663
} else if (resolvedMode === 'standalone') {
660664
const { createStandaloneStack } = await import('@objectstack/runtime');
661665
// Anchor the default sqlite database under the project folder
@@ -669,7 +673,8 @@ export default class Serve extends Command {
669673
dev: isDev,
670674
};
671675
const bootResult = await createStandaloneStack(standaloneInput);
672-
config = { ...originalConfig, ...bootResult } as any;
676+
// [#4002] Per-key `api` merge — see mergeBootConfig.
677+
config = mergeBootConfig(originalConfig as any, bootResult as any) as any;
673678
} else {
674679
throw new Error(
675680
`Boot mode '${resolvedMode}' is not available in the open-core CLI.\n`
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { mergeBootConfig } from './merge-boot-config.js';
5+
6+
/** What `createStandaloneStack()` actually returns for the `api` block. */
7+
const BOOT_API = { enableProjectScoping: false, projectResolution: 'none' } as const;
8+
9+
describe('mergeBootConfig (#4002)', () => {
10+
it('keeps the authored api keys the boot result does not set', () => {
11+
const merged: any = mergeBootConfig(
12+
{ api: { requireAuth: false, enforceProjectMembership: false } },
13+
{ api: { ...BOOT_API }, plugins: [] },
14+
);
15+
16+
// The two live knobs the CLI reads a few lines later — both were dropped
17+
// by the old shallow spread.
18+
expect(merged.api.requireAuth).toBe(false);
19+
expect(merged.api.enforceProjectMembership).toBe(false);
20+
});
21+
22+
it('lets the boot result win on the keys it decides', () => {
23+
// Environment scoping is not the author's call on a standalone host.
24+
const merged: any = mergeBootConfig(
25+
{ api: { enableProjectScoping: true, projectResolution: 'auto', requireAuth: false } },
26+
{ api: { ...BOOT_API } },
27+
);
28+
29+
expect(merged.api.enableProjectScoping).toBe(false);
30+
expect(merged.api.projectResolution).toBe('none');
31+
expect(merged.api.requireAuth).toBe(false); // untouched by boot → survives
32+
});
33+
34+
it('still replaces every other top-level key wholesale', () => {
35+
// The artifact-serve path deliberately serves the boot result's objects /
36+
// permissions / plugins, so those keep the previous semantics.
37+
const merged: any = mergeBootConfig(
38+
{ objects: [{ name: 'authored' }], plugins: ['authored'] },
39+
{ objects: [{ name: 'from_artifact' }], plugins: ['from_boot'] },
40+
);
41+
42+
expect(merged.objects).toEqual([{ name: 'from_artifact' }]);
43+
expect(merged.plugins).toEqual(['from_boot']);
44+
});
45+
46+
it('does not invent an api block when neither side has one', () => {
47+
const merged: any = mergeBootConfig({ objects: [] }, { plugins: [] });
48+
expect('api' in merged).toBe(false);
49+
});
50+
51+
it('carries an api block through when only one side has one', () => {
52+
expect((mergeBootConfig({ api: { requireAuth: false } }, {}) as any).api)
53+
.toEqual({ requireAuth: false });
54+
expect((mergeBootConfig({}, { api: { ...BOOT_API } }) as any).api)
55+
.toEqual({ ...BOOT_API });
56+
});
57+
58+
it('ignores a non-object api on either side rather than spreading it', () => {
59+
// Defensive: a malformed authored `api` must not throw or produce
60+
// character-indexed keys from a string spread.
61+
expect((mergeBootConfig({ api: 'nonsense' as any }, { api: { ...BOOT_API } }) as any).api)
62+
.toEqual({ ...BOOT_API });
63+
expect((mergeBootConfig({ api: { requireAuth: false } }, { api: null as any }) as any).api)
64+
.toEqual({ requireAuth: false });
65+
});
66+
67+
it('does not mutate either input', () => {
68+
const authored = { api: { requireAuth: false } };
69+
const boot = { api: { ...BOOT_API } };
70+
mergeBootConfig(authored, boot);
71+
72+
expect(authored).toEqual({ api: { requireAuth: false } });
73+
expect(boot).toEqual({ api: { ...BOOT_API } });
74+
});
75+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#4002] Merge a boot result over the authored stack config.
5+
*
6+
* `objectstack serve` (and `dev`, which spawns it) assembles the effective
7+
* config as `{ ...authored, ...bootResult }`, where `bootResult` comes from
8+
* `createStandaloneStack()` / `createDefaultHostConfig()`. Those builders return
9+
* an `api` block carrying only the environment-scoping decision:
10+
*
11+
* ```js
12+
* api: { enableProjectScoping: false, projectResolution: 'none' }
13+
* ```
14+
*
15+
* Under a shallow spread that object REPLACED the author's entire `api` block,
16+
* silently discarding every key it did not itself set. Two of those keys are
17+
* live knobs the CLI reads a few lines later:
18+
*
19+
* - `api.requireAuth` — the documented one-line opt-out for serving data
20+
* publicly (ADR-0056 D2, the v12 migration note). Authoring it did nothing:
21+
* the value never reached the REST plugin, so the boot warning that is supposed
22+
* to make a fail-open posture visible never fired either.
23+
* - `api.enforceProjectMembership` — the ADR-0024 D9 opt-out from the
24+
* `sys_environment_member` 403 gate. Silently fell back to the dispatcher
25+
* default.
26+
*
27+
* So `api` merges PER KEY: the author's declarations survive, and the boot
28+
* builder still wins on the keys it actually decides (scoping is not the
29+
* author's call on a standalone host). Every other top-level key keeps the
30+
* previous whole-value semantics — for `objects` / `permissions` / `manifest` /
31+
* `plugins` the artifact-serve path deliberately serves the boot result's
32+
* version, so those are not swept into this change.
33+
*/
34+
export function mergeBootConfig<A extends object, B extends object>(authored: A, bootResult: B): A & B {
35+
const merged = { ...authored, ...bootResult } as any;
36+
const authoredApi = (authored as any)?.api;
37+
const bootApi = (bootResult as any)?.api;
38+
// Only synthesize the key when at least one side has one, so a config with
39+
// no `api` block anywhere does not gain an empty object.
40+
if (isPlainObject(authoredApi) || isPlainObject(bootApi)) {
41+
merged.api = {
42+
...(isPlainObject(authoredApi) ? authoredApi : {}),
43+
...(isPlainObject(bootApi) ? bootApi : {}),
44+
};
45+
}
46+
return merged as A & B;
47+
}
48+
49+
function isPlainObject(v: unknown): v is Record<string, unknown> {
50+
return typeof v === 'object' && v !== null && !Array.isArray(v);
51+
}

0 commit comments

Comments
 (0)