diff --git a/.changeset/24583-7b3-organizationless-counter-retired.md b/.changeset/24583-7b3-organizationless-counter-retired.md index 60c1fb88c2e..61be475293b 100644 --- a/.changeset/24583-7b3-organizationless-counter-retired.md +++ b/.changeset/24583-7b3-organizationless-counter-retired.md @@ -15,13 +15,13 @@ Clause-②: yes (narrowing) **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, stage 8d). -- **`os serve`: a production server boot whose auth did not compose for a missing secret is refused.** With the `auth` tier on and no `OS_AUTH_SECRET` (outside `--dev`), `serve` used to skip AuthPlugin with a warning and serve anyway. That boot had no security model: anonymous object data is always denied, so its data API was unusable. It also had no organization object, so its inline seed wrote rows with `organization_id` NULL and drew their autonumbers from the `__global__` counter. Measured on `examples/app-showcase` before this change: health 200, two NULL-organization rows and a `__global__` counter row on a fresh install. The boot now stops before the kernel starts, so nothing is written, and the message names both fixes. Unchanged: `--no-server` (kernel only) still boots with the warning, a development boot still uses its fallback secret, and the cloud host kernel (`ObjectOSEnvironmentPlugin`, where auth is each project's) still only warns. `os start` never reaches the refusal: it always supplies a secret to the `serve` child it spawns. +- **`os serve`: a production server boot whose auth did not compose for a missing secret is refused.** With the `auth` tier on and no `OS_AUTH_SECRET` (outside `--dev`), `serve` used to skip AuthPlugin with a warning and serve anyway. That boot had no security model: anonymous object data is always denied, so its data API was unusable. It also had no organization object, so its inline seed wrote rows with `organization_id` NULL and drew their autonumbers from the `__global__` counter. Measured on `examples/app-showcase` before this change: health 200, two NULL-organization rows and a `__global__` counter row on a fresh install. The boot now stops before the kernel starts, so nothing is written, and the message names both fixes. A kernel-only boot (`--no-server`) is refused the same way: it runs the same seed, so it would write the same ownerless rows. Unchanged: a development boot still uses its fallback secret, and the cloud host kernel (`ObjectOSEnvironmentPlugin`, where auth is each project's) still only warns. `os start` never reaches the refusal: it always supplies a secret to the `serve` child it spawns. - **`@objectstack/driver-sql` (and `@objectstack/driver-turso`'s remote face, which calls the same rule): the counter refuses an organization-less row on a tenant table.** Wherever the driver registered the organization object (`sys_organization`), a `create` / `bulkCreate` / `upsert` on an object with a tenant column, whose row carries no organization and whose options carry no `tenantId`, is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500), the code the engine already answers for the same write. It is refused before any counter row is read or written, and nothing is written. New exports: `OrganizationlessCounterRefusal`, `ORGANIZATIONLESS_COUNTER_REFUSAL_CODE`, `ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS`, `renderOrganizationlessCounterRefusal`. Unchanged: `__global__` stays the counter key of an object with no tenant column, and of a composition that registers no organization object on the driver (a lean `@objectstack/objectql/core` embedding), where no organization can exist. Counter rows a 17.x install filed under `__global__` for a tenant table stay in `_objectstack_sequences`; the counter no longer reads them, and `os migrate duplicates` still reports them. - **`@objectstack/objectql`: `GLOBAL_TENANT` is no longer exported**, and `SystemWriteOrganizationRequiredError`'s message no longer describes the `__global__` counter as where the write would land. The code, status and fields are unchanged. - **Texts.** The lint explanation of `unique/legacy-organization-composite`, the `UniqueScopeSchema` TSDoc and the error-code ledger now say that a NULL-organization row is residue an earlier release wrote, not something a new write produces. **The remedy.** -- A production boot refused for the missing secret: set `OS_AUTH_SECRET` (a long random value, identical across restarts and nodes), or run kernel-only with `--no-server`. +- A production boot refused for the missing secret: set `OS_AUTH_SECRET` (a long random value, identical across restarts and nodes), or compose auth by other means (mount `AuthPlugin` in `plugins`). Running kernel-only with `--no-server` does not avoid the refusal. - A direct driver write refused by the counter: carry the organization — `driver.create(object, { ...row, organization_id: orgId })` or `driver.create(object, row, { tenantId: orgId })` — or write through the engine, which derives it under `single`. The retired export: diff --git a/.changeset/25133-refuse-orgless-production-boot.md b/.changeset/25133-refuse-orgless-production-boot.md new file mode 100644 index 00000000000..1fddf894cec --- /dev/null +++ b/.changeset/25133-refuse-orgless-production-boot.md @@ -0,0 +1,24 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli)!: a production `os serve` that composes no organization object is refused whether or not `--server`, so `--no-server` no longer writes ownerless rows + +Clause-②: yes (narrowing) +Metadata-protocol: none + + + +**BREAKING** boot-refusal narrowing (ADR-0131 D1, "NULL is not a state", and D9). It changes which production boots start, not what a metadata author can write, so it ships as `patch` with this banner. + +- FROM: a production `os serve --no-server` (no `--dev`; `os start` has no `--no-server` flag) booted the kernel when the stack's `auth` tier was on and no auth secret resolved, or when the stack mounted no auth at all (the `auth` tier off, `--preset minimal` included, with no `AuthPlugin` of its own), warning `⚠ AuthPlugin skipped — set OS_AUTH_SECRET …`. Neither boot composes an organization object, so every row it wrote, its inline seed first, landed with no organization. Measured on an 18.0.0-born store: a deleted seed row was written back with `organization_id` NULL. On a fresh store the seed failed instead, every write refused by the NOT NULL tenant column. Both refusal texts named "run with --no-server" as a remedy. +- TO: both boots are refused whether or not `--server`, before any plugin starts. That includes a production `os serve --preset minimal`: the minimal preset composes no `auth` tier, so unless the stack mounts `AuthPlugin` itself it is refused, kernel-only or not (with `--server` it already was), so nothing is written and the database is never opened. That covers a `--no-server` migrate-and-exit job (`OS_MIGRATE_AND_EXIT=1`), which runs the same kernel bootstrap. Neither refusal text offers `--no-server` any more: each says a kernel-only boot does not avoid it. + +**Unchanged:** `os dev` and `--dev` (a development boot resolves its fallback secret, and a no-auth development boot is still refused only when it serves); an embedded kernel (`createStandaloneStack`, `@objectstack/objectql/core`), which never runs this command; and the cloud runtime's host kernel (`ObjectOSEnvironmentPlugin`, where auth is each project's), which keeps its warn-only path. `os start` always hands its `serve` child an auth secret, so it reaches the no-auth case only. + +**The remedy.** +- Refused for the missing secret: set `OS_AUTH_SECRET` (a long random value, identical across restarts and nodes, e.g. `OS_AUTH_SECRET=$(openssl rand -hex 32)`). +- Refused because the stack mounts no auth: enable auth (add the `auth` tier, or mount `AuthPlugin` in `plugins`) and set `OS_AUTH_SECRET`. Declaring the stack's own objects `tenancy: { enabled: false }` does not lift this refusal: the platform's own system objects are tenant-scoped, so a production stack with no organization object would still write ownerless rows. +- A cloud migrate-and-exit job (`OS_MIGRATE_AND_EXIT=1`) must carry `OS_AUTH_SECRET` in production; without it the job is now refused, naming the variable. + +**Release-notes correction.** The 18.0 release page says a production `os serve` with no auth secret can "serve without the data API (`--no-server`)". That remedy is withdrawn: `--no-server` is refused the same way. The pending changeset `24583-7b3-organizationless-counter-retired` is corrected in this change to match. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 21e36533d19..edc6603c569 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -354,20 +354,28 @@ os serve # Default: port 3000 os serve -p 4000 # Custom port os serve --dev # Development mode (pretty logs, devPlugins) os serve --dev --ui # Dev mode with Console UI -os serve --no-server # Skip HTTP server (kernel only) -os serve --preset minimal # Skip auto-loaded auth/i18n/ui plugins +os serve --no-server # Skip HTTP server (kernel only; production still needs OS_AUTH_SECRET) +os serve --preset minimal # Skip auto-loaded auth/i18n/ui plugins (production: refused unless the stack mounts AuthPlugin) ``` **Options:** - `-p, --port ` — Server port (default: env `OS_PORT` or `3000`) - `--dev` — Development mode (loads devPlugins, pretty logging) - `--ui / --no-ui` — Toggle Console UI at `/_console/` (default `on`) -- `--server / --no-server` — Toggle HTTP server plugin +- `--server / --no-server` — Toggle HTTP server plugin. `--no-server` serves no + data API but still boots the kernel and writes its seed, so a production boot + (no `--dev`) is held to the same auth rule as a server boot: a stack that + enables auth needs `OS_AUTH_SECRET`, and a stack that mounts no auth is + refused. Both are refused before any plugin starts, and so is a + `--no-server` migrate-and-exit job (`OS_MIGRATE_AND_EXIT=1`) without the + secret. - `--prebuilt` — Skip esbuild / `bundle-require` and load the config as native ESM (use this in production builds where the config is already pre-compiled) - `--preset minimal | default | full` — Override the auto-registration - tier (see below) + tier (see below). `minimal` composes no `auth` tier, so a production boot + (no `--dev`) with it is refused, with or without `--server`, unless the + stack mounts `AuthPlugin` in its own `plugins`. **Probes.** A served process exposes `GET /api/v1/health` (liveness — process only, and deliberately blind to configuration and credentials, so a @@ -386,14 +394,18 @@ only gate the **automatic** registration of optional plugins. | Preset | Tiers | Auto-loaded optional plugins | |:---|:---|:---| -| `minimal` | `core` | none | +| `minimal` | `core` | none — in production, refused unless the stack mounts `AuthPlugin` itself (see below) | | `default` *(default)* | `core`, `i18n`, `ui`, `ai`, `auth` | i18n service, Console UI, AI service, Auth + Security + Audit | | `full` | `core`, `i18n`, `ui`, `ai`, `auth` | currently an alias of `default` — same tiers, no additional plugins | -The `auth` tier requires `OS_AUTH_SECRET` to be set; otherwise `AuthPlugin` -is skipped with a yellow warning and the `/api/v1/auth/*` endpoints will -return 404. (In `--dev` mode the CLI falls back to an insecure local secret so -login works out of the box.) To take full control, set `tiers` on the stack config: +The `auth` tier requires `OS_AUTH_SECRET` in production: without it the boot +is refused before any plugin starts, with or without `--server`, because a boot +with no `AuthPlugin` has no organization and every row it wrote would have no +owner. A production boot whose tiers leave `auth` out (`--preset minimal` +included) is refused the same way unless the stack mounts `AuthPlugin` itself. +In `--dev` mode the CLI falls back to an insecure local secret, so login works +out of the box. (The cloud runtime's host kernel, where auth is each project's, +only warns.) To take full control, set `tiers` on the stack config: ```typescript import { defineStack } from '@objectstack/spec'; diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 07f9199202f..c8e044a8b0a 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -410,8 +410,20 @@ one it is refused: `✖ FATAL: tenancy posture 'group' is walled, but this deplo **A production `os serve` deployment needs an auth secret.** A production `os serve` whose stack enables auth and which resolves no `OS_AUTH_SECRET` is refused before any plugin starts, so set the secret before you start such a -deployment on 18.0. `os start` is not affected: it generates an auth secret on -its first run and persists it to `/auth-secret`. +deployment on 18.0. A production boot whose stack mounts no auth at all (the +`auth` tier off and no `AuthPlugin` of its own) is refused the same way: enable +auth and set the secret. Either boot has no organization, so every row it wrote +would have no owner. + +`--no-server` does not avoid either refusal: a kernel-only boot runs the same +seed and would write the same ownerless rows. Earlier 18 releases let a +`--no-server` boot through, and their refusal text offered `--no-server` as a +remedy; it is not one. The same holds for a migrate-and-exit job +(`OS_MIGRATE_AND_EXIT=1`): give it `OS_AUTH_SECRET` too. A development boot +(`os dev`, `--dev`) and an embedded kernel are not affected, and neither is the +cloud runtime's host kernel, whose auth is each project's. `os start` is not +affected: it generates an auth secret on its first run and persists it to +`/auth-secret`. What the plan lists, how a blocked row is resolved, and every flag are in the command's reference: diff --git a/packages/cli/src/commands/serve-kernel-only-lifetime.integration.test.ts b/packages/cli/src/commands/serve-kernel-only-lifetime.integration.test.ts index 32c4336cead..f74db2407bc 100644 --- a/packages/cli/src/commands/serve-kernel-only-lifetime.integration.test.ts +++ b/packages/cli/src/commands/serve-kernel-only-lifetime.integration.test.ts @@ -1,5 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// test-budget: 40 — spawns the real producer once per single-cause fixture: three boots of the built CLI (production --no-server --preset minimal, --dev --no-server, a default composed boot), each differing only in the input under test, held 3 s past its banner and then stopped by SIGTERM; 27.0 s, the slower of two local runs on a shared container, x 1.25, no CI reading yet +// test-budget: 40 — spawns the real producer once per single-cause fixture: three boots of the built CLI (production --no-server on the host kernel, --dev --no-server, a default composed boot), each differing only in the input under test, held 3 s past its banner and then stopped by SIGTERM; 27.0 s, the slower of two local runs on a shared container, x 1.25, no CI reading yet import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { spawn, type ChildProcess } from 'node:child_process'; @@ -30,9 +30,18 @@ import { fileURLToPath } from 'node:url'; * Each boot is read for its banner and `objectstack:seed-settled`, then left * alone for {@link HOLD_WINDOW_MS}, then sent SIGTERM: * - * 1. production `--no-server --preset minimal` (the card's command): still - * alive after the window, and SIGTERM ends it with the kernel's code, 0, - * the kernel's own handler exiting the process (no signal death); + * 1. production `--no-server` with no store and no auth composed (the card's + * shape): still alive after the window, and SIGTERM ends it with the + * kernel's code, 0, the kernel's own handler exiting the process (no + * signal death). [ADR-0131 D9] The card's own `--preset minimal` composes + * no auth, and a production boot with no auth is now refused whether or + * not `--server`; the one production composition that still boots with + * no auth is the cloud runtime's host kernel (auth is each project's + * there), so this leg boots that — {@link HOST_KERNEL_PLUGIN} in the + * config, `tiers: ['core', 'auth']` so the `auth` tier is on and skipped + * by the host-kernel path, still `--preset minimal`, no secret. It keeps + * what made the defect: no storage driver, so no pool holding the event + * loop; * 2. CONTROL: `--dev --no-server` behaves as before: the same; * 3. CONTROL: a default composed production boot sends * `[objectstack:listening, objectstack:seed-settled]`, stays up, and stops @@ -83,6 +92,8 @@ const OBJECT = `{ name: 'fx_task', label: 'Task', pluralLabel: 'Tasks', fields: const MANIFEST = `{ id: 'com.example.fx23744', namespace: 'fx', version: '1.0.0', type: 'app', name: 'Fx' }`; /** An instantiated plugin, which is what makes this a HOST config. */ const HOST_PLUGIN = `{ name: 'com.example.fx23744.host-plugin', version: '1.0.0', init: async () => {}, start: async () => {} }`; +/** The cloud runtime's host kernel, as `isHostKernelComposition` recognises it: auth is skipped with a warning. */ +const HOST_KERNEL_PLUGIN = `{ name: 'com.objectstack.runtime.objectos-environment', version: '0.0.0', init: async () => {}, start: async () => {} }`; interface IpcMessage { type?: string } interface Exit { code: number | null; signal: NodeJS.Signals | null } @@ -99,6 +110,8 @@ interface Lifetime { const children: ChildProcess[] = []; let root: string; let plainDir: string; +/** The same, on the cloud runtime's host kernel — see shape 1. */ +let hostKernelDir: string; /** The root of an installed dependency of this package, found through `node_modules`. */ function packageRoot(specifier: string): string { @@ -249,6 +262,15 @@ beforeAll(() => { + `export default defineStack({ manifest: ${MANIFEST}, objects: [${OBJECT}], plugins: [${HOST_PLUGIN}] });\n`, ); symlinkSync(packageRoot('@objectstack/spec'), join(plainDir, 'node_modules', '@objectstack', 'spec'), 'dir'); + hostKernelDir = join(root, 'host-kernel'); + mkdirSync(join(hostKernelDir, 'node_modules', '@objectstack'), { recursive: true }); + writeFileSync(join(hostKernelDir, 'package.json'), JSON.stringify({ name: 'fx-23744-host-kernel', private: true, type: 'module' })); + writeFileSync( + join(hostKernelDir, 'objectstack.config.ts'), + "import { defineStack } from '@objectstack/spec';\n" + + `export default defineStack({ manifest: ${MANIFEST}, tiers: ['core', 'auth'], objects: [${OBJECT}], plugins: [${HOST_PLUGIN}, ${HOST_KERNEL_PLUGIN}] });\n`, + ); + symlinkSync(packageRoot('@objectstack/spec'), join(hostKernelDir, 'node_modules', '@objectstack', 'spec'), 'dir'); }); afterAll(() => { @@ -260,9 +282,9 @@ afterAll(() => { describe('os serve — a boot with no HTTP transport stays up until it is stopped (#23744)', () => { it( - 'production --no-server --preset minimal (the card\'s command): alive after its banner, and SIGTERM ends it with 0', + 'production --no-server with no store and no auth (the card\'s shape, on the host kernel): alive after its banner, and SIGTERM ends it with 0', async () => { - expectHeldThenStopped(await bootHoldStop(plainDir, {}, ['--no-server', '--preset', 'minimal'])); + expectHeldThenStopped(await bootHoldStop(hostKernelDir, {}, ['--no-server', '--preset', 'minimal'])); }, 240_000, ); diff --git a/packages/cli/src/commands/serve-no-server-banner.integration.test.ts b/packages/cli/src/commands/serve-no-server-banner.integration.test.ts index 887ae71d7e1..5454a3ccef1 100644 --- a/packages/cli/src/commands/serve-no-server-banner.integration.test.ts +++ b/packages/cli/src/commands/serve-no-server-banner.integration.test.ts @@ -302,7 +302,11 @@ describe('os serve — the ready banner names an address only when an HTTP serve it( 'production --no-server (the card\'s command): no address row, one line naming --no-server', async () => { - const booted = await boot(plainDir, {}, ['--no-server', '--preset', 'minimal']); + // [ADR-0131 D9] The card's `--preset minimal` composes no auth, and a + // production boot with no auth is now refused whether or not `--server`; + // the same kernel-only boot with auth composed (a secret and a store) is + // today's production `--no-server`. + const booted = await boot(plainDir, { OS_DATABASE_URL: 'file:./nos.db', OS_AUTH_SECRET: AUTH_SECRET }, ['--no-server']); expectNoServerShape(booted); expectDatasourceAdminDegradeLine(booted.output); }, @@ -345,7 +349,8 @@ describe('os serve — the ready banner names an address only when an HTTP serve it( 'control: --no-server with a config-mounted HonoServerPlugin prints API:, and that port answers', async () => { - const { banner, output, stop } = await boot(honoDir, { OS_DATABASE_URL: 'file:./ctl.db' }, ['--no-server', '--preset', 'minimal']); + // [ADR-0131 D9] With auth composed — `--preset minimal` composes none, which a production boot now refuses. + const { banner, output, stop } = await boot(honoDir, { OS_DATABASE_URL: 'file:./ctl.db', OS_AUTH_SECRET: AUTH_SECRET }, ['--no-server']); const seen = `\n--- banner ---\n${banner}`; try { // ⭐ The read is off the kernel, not off the flag: the flag skipped serve's diff --git a/packages/cli/src/commands/serve-no-transport-announcements.integration.test.ts b/packages/cli/src/commands/serve-no-transport-announcements.integration.test.ts index d285575d91d..04050197e3d 100644 --- a/packages/cli/src/commands/serve-no-transport-announcements.integration.test.ts +++ b/packages/cli/src/commands/serve-no-transport-announcements.integration.test.ts @@ -302,7 +302,15 @@ describe('os serve — the machine channels announce an address only when an HTT it( 'production --no-server (the card\'s command): no objectstack:listening, no state file, seed-settled alone', async () => { - const { banner, messages, stateFilesEverSeen, stop } = await boot(plainDir, {}, ['--no-server', '--preset', 'minimal']); + // [ADR-0131 D9] The card's `--preset minimal` composes no auth, and a + // production boot with no auth is now refused whether or not `--server`; + // the same kernel-only boot with auth composed (a secret and a store) is + // today's production `--no-server`. + const { banner, messages, stateFilesEverSeen, stop } = await boot( + plainDir, + { OS_DATABASE_URL: 'file:./nos.db', OS_AUTH_SECRET: AUTH_SECRET }, + ['--no-server'], + ); stop(); const seen = `\n--- messages ---\n${JSON.stringify(messages)}\n--- banner ---\n${banner}`; // `soft`: three independent channels, each reported on its own when it regresses. @@ -356,8 +364,9 @@ describe('os serve — the machine channels announce an address only when an HTT async () => { const { banner, messages, stateAtListening, stop } = await boot( honoDir, - { OS_DATABASE_URL: 'file:./ctl.db' }, - ['--no-server', '--preset', 'minimal'], + // [ADR-0131 D9] With auth composed — `--preset minimal` composes none, which a production boot now refuses. + { OS_DATABASE_URL: 'file:./ctl.db', OS_AUTH_SECRET: AUTH_SECRET }, + ['--no-server'], ); const seen = `\n--- messages ---\n${JSON.stringify(messages)}\n--- banner ---\n${banner}`; try { diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 462b361f4fa..4f5e52a6937 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -4663,13 +4663,23 @@ export default class Serve extends Command { // and it should say so at boot instead of quietly serving object data to // the internet. Auth availability = the tier auto-registers it OR the // stack mounts AuthPlugin explicitly. - if (flags.server && !(tierEnabled('auth') || hasAuthPlugin)) { + // + // [ADR-0131 D1/D9] A production boot is refused here whether or not it + // serves: with no AuthPlugin there is no organization object, so a + // `--no-server` boot (a migrate-and-exit job included) would still write + // every seed row with no owner. `--no-server` is therefore no remedy, and + // the text no longer offers it. A development boot keeps the old shape — + // refused only when it serves — and so does programmatic embedding, which + // never passes through this command. + const productionBoot = !isDev; + if ((flags.server || productionBoot) && !(tierEnabled('auth') || hasAuthPlugin)) { throw new Error( 'This stack mounts no auth, so no caller can authenticate — and anonymous access to object ' - + 'data is always denied, with no setting that turns that off, which would leave the data API unusable.\n' - + 'Fix it one of two ways:\n' - + ` • enable auth — add the 'auth' tier (or mount AuthPlugin in \`plugins\`);\n` - + ' • or serve without the data API — run with --no-server, or drop the REST/dispatcher plugins.\n' + + 'data is always denied, with no setting that turns that off, which would leave the data API unusable; ' + + 'and with no organization every row this boot writes would have no owner.\n' + + `Fix it: enable auth — add the 'auth' tier (or mount AuthPlugin in \`plugins\`); a production boot ` + + 'also needs OS_AUTH_SECRET set.\n' + + 'Running kernel-only (--no-server) does not avoid this: the boot still writes rows, with no owner.\n' + "Publishing a genuinely public surface does not need anonymous data access: use a public form " + "view, a share link, or `book.audience: 'public'`.", ); @@ -4685,15 +4695,20 @@ export default class Serve extends Command { // plugin has started, so nothing has been written. The `host-kernel` // reason (the cloud runtime, where auth is each project's) is not this // case and still only warns above. - if (flags.server && !authComposition.composes && authComposition.reason === 'no-secret') { + // + // The ownerless writes do not depend on the listener, so this is refused + // whether or not `--server`: a `--no-server` boot, and an + // `OS_MIGRATE_AND_EXIT=1` job, run the same seed. `no-secret` is only + // ever the reason on a production boot — a development boot resolves the + // fallback secret — so the condition needs no posture of its own. + if (!authComposition.composes && authComposition.reason === 'no-secret') { throw new Error( 'This stack enables auth, but no auth secret is set, so AuthPlugin was not composed and no caller ' + 'can authenticate — anonymous access to object data is always denied, so the data API would be ' + 'unusable, and with no organization every row this boot writes would have no owner.\n' - + 'Fix it one of two ways:\n' - + ' • set OS_AUTH_SECRET (a long random value, identical across restarts and nodes), e.g. ' - + 'OS_AUTH_SECRET=$(openssl rand -hex 32);\n' - + ' • or serve without the data API — run with --no-server, or drop the REST/dispatcher plugins.\n' + + 'Set OS_AUTH_SECRET (a long random value, identical across restarts and nodes), e.g. ' + + 'OS_AUTH_SECRET=$(openssl rand -hex 32). A migrate-and-exit job (OS_MIGRATE_AND_EXIT=1) needs it too.\n' + + 'Running kernel-only (--no-server) does not avoid this: the boot still writes rows, with no owner.\n' + 'A local trial does not need the secret: `os dev` (or `os serve --dev`) uses a development fallback.', ); } diff --git a/packages/cli/test/serve-no-auth-secret-refusal.integration.test.ts b/packages/cli/test/serve-no-auth-secret-refusal.integration.test.ts index b97633453b0..9806064922c 100644 --- a/packages/cli/test/serve-no-auth-secret-refusal.integration.test.ts +++ b/packages/cli/test/serve-no-auth-secret-refusal.integration.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { spawn, type ChildProcessByStdio } from 'node:child_process'; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import type { Readable } from 'node:stream'; @@ -16,22 +16,32 @@ import { } from './helpers/serve-process.js'; /** - * ADR-0131 D9 — a production `os serve` whose `auth` tier is on but whose auth - * secret is missing is REFUSED before the kernel starts. + * ADR-0131 D1/D9 — a production `os serve` that composes no organization object + * is REFUSED before the kernel starts, whether or not it serves a data API. * - * Without the secret `resolvePlatformAuthComposition` composes no AuthPlugin, - * so the boot has no security model (anonymous object data is always denied, - * the data API is unusable) and no organization object — and every row its - * inline seed writes on a tenant table landed with no organization, its - * autonumber drawn from the organization-less counter. Measured before this - * refusal on `examples/app-showcase`: a fresh install answered health 200 and - * held two rows with a NULL `organization_id` and a `__global__` counter row. + * Two compositions reach it. The `auth` tier is on but no auth secret resolves, + * so `resolvePlatformAuthComposition` composes no AuthPlugin (`no-secret`); or + * the stack mounts no auth at all (the tier off, no AuthPlugin of its own). + * Either way the boot has no security model (anonymous object data is always + * denied, the data API is unusable) and no organization object — and every row + * its inline seed writes on a tenant table lands with no organization, its + * autonumber drawn from the organization-less counter. Measured before the + * server-boot refusal on `examples/app-showcase`: a fresh install answered + * health 200 and held two rows with a NULL `organization_id` and a + * `__global__` counter row. + * + * `--no-server` is no way round it: the seed does not depend on the listener, + * and a kernel-only boot on an 18.0.0-born store re-wrote a deleted seed row + * with a NULL `organization_id` (the measurement behind ruling 6328987873, + * letter A). So both refusals are pinned with and without `--server`, and once + * as the `OS_MIGRATE_AND_EXIT=1` job, which runs the same kernel bootstrap. * * The property pinned is the order as much as the refusal: it fires while the - * kernel is only composed, so the database is never even opened. The healthy - * leg is the control — the same fixture, entrypoint and posture with the - * secret set reaches the ready banner and creates the database file, which is - * what makes the absent file in the refused leg mean something. + * kernel is only composed, so the database is never even opened. The control + * legs are what make the absent file mean something — the same entrypoint and + * posture with a secret, under `--dev`, and on the cloud runtime's host kernel + * (whose auth is each project's, and which keeps its warn-only path) each + * reach the ready banner and create the database file. * * Spawned through `bin/run.js` with `NODE_ENV` unset, the only shape that * reaches production posture (`bin/run-dev.js` sets `development`, and a @@ -42,19 +52,53 @@ const READY_BANNER_TAIL = /Press Ctrl\+C to stop/; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run.js'); +/** Auth on (the default preset's tiers), nothing else declared. */ +const AUTH_TIER_CONFIG = 'export default {};\n'; +/** The `auth` tier off, and no AuthPlugin of the stack's own. */ +const NO_AUTH_CONFIG = "export default { tiers: ['core'] };\n"; +/** + * The cloud runtime's host kernel, as `isHostKernelComposition` recognises it: + * a plugin registered under `ObjectOSEnvironmentPlugin`'s name. Auth is each + * project's there, so a missing secret only warns. + */ +const HOST_KERNEL_CONFIG = `export default { + plugins: [{ + name: 'com.objectstack.runtime.objectos-environment', + version: '0.0.0', + init: async () => {}, + start: async () => {}, + }], + // One object of the app's own, so \`serve\` composes the query engine: a + // config that mounts plugins and declares no object composes none. + objects: [{ name: 'host_kernel_probe', label: 'Probe', fields: { title: { type: 'text', label: 'Title' } } }], +}; +`; + +const NO_SECRET_TEXT = 'no auth secret is set'; +const NO_AUTH_TEXT = 'This stack mounts no auth'; +const SECRET = 'os-adr-0131-d9-control-leg-secret-not-a-real-credential'; + type BootChild = ChildProcessByStdio; const children: BootChild[] = []; -let fixtureDir: string; +let root: string; +const fixtures: Record<'authTier' | 'noAuth' | 'hostKernel', string> = { authTier: '', noAuth: '', hostKernel: '' }; interface Outcome { output: string; exitCode: number | null; } +interface Leg { + fixtureDir: string; + databaseFile: string; + args?: string[]; + env?: Record; +} + /** Boot until the ready banner (returns `exitCode: null`) or until the process exits. */ -async function boot(databaseFile: string, env: Record = {}): Promise { - const child = spawn(process.execPath, [CLI, 'serve', 'objectstack.config.ts', '--port', KERNEL_ASSIGNED_PORT], { +async function boot({ fixtureDir, databaseFile, args = [], env = {} }: Leg): Promise { + const child = spawn(process.execPath, [CLI, 'serve', 'objectstack.config.ts', '--port', KERNEL_ASSIGNED_PORT, ...args], { cwd: fixtureDir, stdio: ['ignore', 'pipe', 'pipe'], // `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00). @@ -64,15 +108,18 @@ async function boot(databaseFile: string, env: Record { + const { output, exitCode } = await boot(leg); + expect(exitCode, `the boot was not refused\n${output}`).not.toBeNull(); + expect(exitCode).not.toBe(0); + expect(output).not.toMatch(READY_BANNER_TAIL); + expect(output).toContain(reason); + expect(output).toContain('OS_AUTH_SECRET'); + // The remedy the old text offered is gone: `--no-server` is named only as + // what does NOT avoid the refusal. + expect(output).not.toContain('serve without the data API'); + expect(existsSync(leg.databaseFile), `the refused boot opened its database\n${output}`).toBe(false); + return output; +} + +/** A control leg: reaches ready and opens its database, with neither refusal. */ +async function expectBoots(leg: Leg): Promise { + const { output, exitCode } = await boot(leg); + expect(exitCode, `the control leg exited\n${output}`).toBeNull(); + expect(output).not.toContain(NO_SECRET_TEXT); + expect(output).not.toContain(NO_AUTH_TEXT); + expect(existsSync(leg.databaseFile), `the control leg never opened its database\n${output}`).toBe(true); + return output; +} + +function fixture(name: string, config: string): string { + const dir = join(root, name); + mkdirSync(dir); + writeFileSync(join(dir, 'objectstack.config.ts'), config, 'utf8'); + return dir; +} + +const db = (name: string) => join(root, `${name}.db`); + beforeAll(() => { requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST); - fixtureDir = mkdtempSync(join(tmpdir(), 'os-no-auth-secret-')); - writeFileSync(join(fixtureDir, 'objectstack.config.ts'), 'export default {};\n', 'utf8'); + root = mkdtempSync(join(tmpdir(), 'os-no-auth-secret-')); + fixtures.authTier = fixture('auth-tier', AUTH_TIER_CONFIG); + fixtures.noAuth = fixture('no-auth', NO_AUTH_CONFIG); + fixtures.hostKernel = fixture('host-kernel', HOST_KERNEL_CONFIG); }); afterAll(() => { for (const child of children) { try { child.kill('SIGTERM'); } catch { /* already gone */ } } - if (fixtureDir) rmSync(fixtureDir, { recursive: true, force: true }); + if (root) rmSync(root, { recursive: true, force: true }); }); -describe('os serve — a production server boot with no auth secret is refused (ADR-0131 D9)', () => { - it('refuses before the kernel starts: non-zero exit, the remedy named, the database never opened', async () => { - const databaseFile = join(fixtureDir, 'refused.db'); - const { output, exitCode } = await boot(databaseFile); - - expect(exitCode, `the boot was not refused\n${output}`).not.toBeNull(); - expect(exitCode).not.toBe(0); - expect(output).not.toMatch(READY_BANNER_TAIL); - expect(output).toContain('no auth secret is set'); - expect(output).toContain('OS_AUTH_SECRET'); - expect(existsSync(databaseFile), `the refused boot opened its database\n${output}`).toBe(false); +describe('os serve — a production boot with no auth secret is refused, --server or not (ADR-0131 D9)', () => { + it('refuses the server boot before the kernel starts', async () => { + await expectRefused({ fixtureDir: fixtures.authTier, databaseFile: db('no-secret-server') }, NO_SECRET_TEXT); }, 240_000); - it('control: the same boot with the secret reaches ready and opens its database', async () => { - const databaseFile = join(fixtureDir, 'healthy.db'); - const { output, exitCode } = await boot(databaseFile, { - OS_AUTH_SECRET: 'os-adr-0131-d9-control-leg-secret-not-a-real-credential', + it('refuses the kernel-only (--no-server) boot before the kernel starts', async () => { + await expectRefused( + { fixtureDir: fixtures.authTier, databaseFile: db('no-secret-no-server'), args: ['--no-server'] }, + NO_SECRET_TEXT, + ); + }, 240_000); + + it('refuses a --no-server migrate-and-exit job before the kernel starts', async () => { + await expectRefused( + { + fixtureDir: fixtures.authTier, + databaseFile: db('no-secret-migrate'), + args: ['--no-server'], + env: { OS_MIGRATE_AND_EXIT: '1' }, + }, + NO_SECRET_TEXT, + ); + }, 240_000); + + it('control: the server boot with the secret reaches ready and opens its database', async () => { + await expectBoots({ fixtureDir: fixtures.authTier, databaseFile: db('secret-server'), env: { OS_AUTH_SECRET: SECRET } }); + }, 240_000); + + it('control: the --no-server boot with the secret reaches ready and opens its database', async () => { + await expectBoots({ + fixtureDir: fixtures.authTier, + databaseFile: db('secret-no-server'), + args: ['--no-server'], + env: { OS_AUTH_SECRET: SECRET }, + }); + }, 240_000); + + it('control: --dev with no secret boots kernel-only on the development fallback', async () => { + await expectBoots({ fixtureDir: fixtures.authTier, databaseFile: db('dev-no-server'), args: ['--dev', '--no-server'] }); + }, 240_000); + + it('control: the cloud host kernel with no secret keeps its warn-only path', async () => { + const output = await expectBoots({ + fixtureDir: fixtures.hostKernel, + databaseFile: db('host-kernel-no-server'), + args: ['--no-server'], }); + expect(output).toContain('AuthPlugin skipped on host kernel'); + }, 240_000); +}); + +describe('os serve — a production boot that mounts no auth is refused, --server or not (ADR-0131 D9)', () => { + it('refuses the server boot before the kernel starts', async () => { + await expectRefused({ fixtureDir: fixtures.noAuth, databaseFile: db('no-auth-server') }, NO_AUTH_TEXT); + }, 240_000); + + it('refuses the kernel-only (--no-server) boot before the kernel starts, even with a secret', async () => { + await expectRefused( + { + fixtureDir: fixtures.noAuth, + databaseFile: db('no-auth-no-server'), + args: ['--no-server'], + env: { OS_AUTH_SECRET: SECRET }, + }, + NO_AUTH_TEXT, + ); + }, 240_000); - expect(exitCode, `the control leg exited\n${output}`).toBeNull(); - expect(output).not.toContain('no auth secret is set'); - expect(existsSync(databaseFile), `the control leg never opened its database\n${output}`).toBe(true); + it('control: --dev --no-server with no auth still boots — a development boot is refused only when it serves', async () => { + await expectBoots({ fixtureDir: fixtures.noAuth, databaseFile: db('no-auth-dev-no-server'), args: ['--dev', '--no-server'] }); }, 240_000); }); diff --git a/packages/cli/test/serve-no-storage-boot-guard.integration.test.ts b/packages/cli/test/serve-no-storage-boot-guard.integration.test.ts index f3c3128e128..c210b9f6ece 100644 --- a/packages/cli/test/serve-no-storage-boot-guard.integration.test.ts +++ b/packages/cli/test/serve-no-storage-boot-guard.integration.test.ts @@ -37,8 +37,14 @@ import { * 1. no driver + the auth service under the `single` posture ⇒ REFUSED, exit 1, * naming the remedies, before any start() (the fixture's own plugin * initialized and never started), and neither downstream line prints; - * 2. no driver + no auth composed (`--no-server --preset minimal`) ⇒ ONE line - * before any start(), and the boot still reaches its banner; + * 2. no driver + no auth composed ⇒ ONE line before any start(), and the + * boot still reaches its banner. [ADR-0131 D9] This used to be reached + * with `--no-server --preset minimal`; a production boot that composes no + * auth is now refused whether or not `--server`, so the leg boots the one + * production composition that still composes no auth — the cloud + * runtime's host kernel ({@link HOST_KERNEL_PLUGIN}, `tiers: ['core', + * 'auth']` so the tier is on and skipped there), still `--no-server + * --preset minimal`, with no secret; * 3. a stack plugin whose NAME no driver test matches registers a `driver.*` * service in init() ⇒ silent, and the boot reaches its banner (this shape * booted before the guard and must keep booting); @@ -95,6 +101,9 @@ const HOST_PLUGIN = `{ start: async () => { process.stderr.write(${JSON.stringify(`${MARKER_START}\n`)}); }, }`; +/** The cloud runtime's host kernel, as `isHostKernelComposition` recognises it: auth is skipped with a warning. */ +const HOST_KERNEL_PLUGIN = `{ name: 'com.objectstack.runtime.objectos-environment', version: '0.0.0', init: async () => {}, start: async () => {} }`; + /** What `spawn(…, { stdio: ['ignore', 'pipe', 'pipe'] })` returns — no `stdin`. */ type BootChild = ChildProcessByStdio; @@ -104,6 +113,8 @@ let root: string; let plainDir: string; /** The same, plus a plugin that registers a SQL driver as `driver.*` in init(). */ let driverDir: string; +/** The same, on the cloud runtime's host kernel — see case 2. */ +let hostKernelDir: string; /** * Make `@objectstack/driver-sql` resolvable from `dir`: a symlink to the @@ -123,10 +134,10 @@ function linkDriverSql(dir: string): void { symlinkSync(pkgRoot, join(scope, 'driver-sql'), 'dir'); } -function writeFixture(dir: string, header: string, plugins: string): void { +function writeFixture(dir: string, header: string, plugins: string, extra = ''): void { mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fx-23573', private: true, type: 'module' })); - const source = defineStackSourceFromLiteral(`{ manifest: ${MANIFEST}, objects: [${OBJECT}], plugins: [${plugins}] }`); + const source = defineStackSourceFromLiteral(`{ manifest: ${MANIFEST}, ${extra}objects: [${OBJECT}], plugins: [${plugins}] }`); writeFileSync(join(dir, 'objectstack.config.ts'), `${header}${source}`); linkSpec(dir); } @@ -205,6 +216,9 @@ beforeAll(() => { root = mkdtempSync(join(tmpdir(), 'os-serve-no-storage-')); plainDir = join(root, 'plain'); writeFixture(plainDir, '', HOST_PLUGIN); + hostKernelDir = join(root, 'host-kernel'); + // `tiers` opens `auth`, which the host-kernel path skips; `--preset minimal` keeps the always-on slate out, as before. + writeFixture(hostKernelDir, '', `${HOST_PLUGIN}, ${HOST_KERNEL_PLUGIN}`, "tiers: ['core', 'auth'], "); driverDir = join(root, 'driver'); writeFixture( driverDir, @@ -260,7 +274,7 @@ describe('os serve — a production host config with no storage (#23573)', () => it( 'prints one line before any plugin starts and keeps booting when no auth is composed', async () => { - const { output, ready } = await boot(plainDir, {}, ['--no-server', '--preset', 'minimal']); + const { output, ready } = await boot(hostKernelDir, {}, ['--no-server', '--preset', 'minimal']); const seen = `\n--- output ---\n${output}`; expect(ready, `a boot that booted before the guard no longer reaches its banner${seen}`).toBe(true); diff --git a/packages/cli/test/serve-ready-degraded-boot.e2e.test.ts b/packages/cli/test/serve-ready-degraded-boot.e2e.test.ts index 9ee1b403861..8aa844216a4 100644 --- a/packages/cli/test/serve-ready-degraded-boot.e2e.test.ts +++ b/packages/cli/test/serve-ready-degraded-boot.e2e.test.ts @@ -48,11 +48,15 @@ import { * incidents reached by other routes: a plugin that did not load, and a core * service that is not there. * - * [ADR-0131 D9] Both legs boot `--no-server`. A production SERVER boot with no - * secret is now refused before the kernel starts - * (`serve-no-auth-secret-refusal.integration.test.ts`), so it reaches no banner; the - * kernel-only boot still skips auth with the warning and still prints the - * ready banner, which is the property under test here. + * [ADR-0131 D9] A production boot with no secret is now refused before the + * kernel starts whether or not it serves + * (`serve-no-auth-secret-refusal.integration.test.ts`), so it reaches no banner. + * The one production composition that still skips auth with a warning and + * still prints the ready banner is the cloud runtime's host kernel + * (`isHostKernelComposition`: a plugin registered as `ObjectOSEnvironmentPlugin`, + * where auth is each project's), so the degraded leg boots that composition — + * the {@link HOST_KERNEL_CONFIG} stub — and the healthy leg the bare config + * with a secret. Both legs boot `--no-server`. * * ⛔ `runServe()` cannot reach it. That helper spawns `bin/run-dev.js`, which * sets `NODE_ENV = 'development'` before argv is parsed, so `isDev` is true, @@ -78,11 +82,30 @@ const CLI = resolve(HERE, '../bin/run.js'); const BARE_CONFIG = 'export default {};\n'; +/** + * The cloud runtime's host kernel, as `isHostKernelComposition` recognises it. + * With no secret, auth is skipped by the warn-only host-kernel path and `auth` + * — a `core` service with no in-memory fallback — is genuinely absent. + */ +const HOST_KERNEL_CONFIG = `export default { + plugins: [{ + name: 'com.objectstack.runtime.objectos-environment', + version: '0.0.0', + init: async () => {}, + start: async () => {}, + }], + // One object of the app's own, so \`serve\` composes the query engine: a + // config that mounts plugins and declares no object composes none. + objects: [{ name: 'host_kernel_probe', label: 'Probe', fields: { title: { type: 'text', label: 'Title' } } }], +}; +`; + /** What `spawn(…, { stdio: ['ignore', 'pipe', 'pipe'] })` returns — no `stdin`. */ type BootChild = ChildProcessByStdio; const children: BootChild[] = []; let fixtureDir: string; +let hostKernelDir: string; interface Boot { out: string; @@ -94,12 +117,12 @@ interface Boot { * Spawn `os serve` in production posture and resolve once the COMPLETE banner * is on the stream. Extra `env` entries override the base; `undefined` unsets. */ -async function boot(env: Record = {}): Promise { +async function boot(env: Record = {}, cwd: string = fixtureDir): Promise { // A kernel-assigned port (#24295): production posture refuses to auto-shift, // so a port drawn and released beforehand is a red whenever another process // takes it in the gap. Nothing here talks to the port. const child = spawn(process.execPath, [CLI, 'serve', 'objectstack.config.ts', '--no-server', '--port', KERNEL_ASSIGNED_PORT], { - cwd: fixtureDir, + cwd, stdio: ['ignore', 'pipe', 'pipe'], // `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00). env: childEnv({ @@ -150,6 +173,8 @@ beforeAll(() => { requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST); fixtureDir = mkdtempSync(join(tmpdir(), 'os-ready-degraded-')); writeFileSync(join(fixtureDir, 'objectstack.config.ts'), BARE_CONFIG, 'utf8'); + hostKernelDir = mkdtempSync(join(tmpdir(), 'os-ready-degraded-host-kernel-')); + writeFileSync(join(hostKernelDir, 'objectstack.config.ts'), HOST_KERNEL_CONFIG, 'utf8'); }); afterAll(() => { @@ -157,13 +182,14 @@ afterAll(() => { try { child.kill('SIGTERM'); } catch { /* already gone */ } } if (fixtureDir) rmSync(fixtureDir, { recursive: true, force: true }); + if (hostKernelDir) rmSync(hostKernelDir, { recursive: true, force: true }); }); describe('os serve — the ready line reports a degraded boot (#16630)', () => { it( 'prints ready AND the missing core service in the same output, with no unconditional tick', async () => { - const { out, err, child } = await boot(); + const { out, err, child } = await boot({}, hostKernelDir); const seen = `\n--- stdout ---\n${out}\n--- stderr ---\n${err}`; const output = out + err; @@ -195,8 +221,9 @@ describe('os serve — the ready line reports a degraded boot (#16630)', () => { it( 'leaves a healthy boot printing exactly the tick it always printed', async () => { - // Same fixture, same entrypoint, same posture — the ONLY difference is - // that auth can now register, so nothing is missing. An "always append a + // Same entrypoint, same posture — the bare config with a secret, so auth + // can register and nothing is missing (the degraded leg's host-kernel + // stub is the other difference; see the header). An "always append a // status line" implementation passes the degraded leg and fails here. const { out, err } = await boot({ OS_AUTH_SECRET: 'os-16630-healthy-leg-secret-not-a-real-credential',