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
4 changes: 2 additions & 2 deletions .changeset/24583-7b3-organizationless-counter-retired.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/cli': minor
'@objectstack/driver-sql': minor
Expand All @@ -15,13 +15,13 @@

**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:
Expand Down
24 changes: 24 additions & 0 deletions .changeset/25133-refuse-orgless-production-boot.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (no-migration-prescription) A boot-time refusal in `os serve`: no key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. No export, type, flag or error code is removed or renamed; `--no-server` keeps its meaning (no HTTP server plugin) and only stops being a way round the auth refusal. The remedy is a deployment one, stated below. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this boot (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**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.
30 changes: 21 additions & 9 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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
Expand All @@ -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';
Expand Down
16 changes: 14 additions & 2 deletions content/docs/upgrading.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<home>/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
`<home>/auth-secret`.

What the plan lists, how a blocked row is resolved, and every flag are in the
command's reference:
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand All @@ -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 {
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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,
);
Expand Down
Loading
Loading