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
40 changes: 40 additions & 0 deletions .changeset/boot-api-merge.md
Original file line numberDiff line numberDiff line change
@@ -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).
9 changes: 7 additions & 2 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -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
Expand All@@ -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`
Expand Down
75 changes: 75 additions & 0 deletions packages/cli/src/utils/merge-boot-config.test.ts
Original file line numberDiff line numberDiff line change
@@ -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 } });
});
});
51 changes: 51 additions & 0 deletions packages/cli/src/utils/merge-boot-config.ts
Original file line numberDiff line numberDiff line change
@@ -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<A extends object, B extends object>(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<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
Loading