Skip to content

Commit df8bb40

Browse files
feat(cli,driver-sql)!: a tenant table's organization-less row no longer reaches the __global__ counter, and a production server boot with no auth secret is refused (18.x step 7, part 7b-3) (#24845)
Part of #24583 — step 7, part 7b-3 (claim 6284514874, amended 6284744613). The card stays open until every step-7 part has landed. Clause-②: yes (narrowing) — measured: two accept sets narrow (the driver's autonumber counter and `os serve`'s production server boot), and four exports are added and one is removed. #24681 (steps 5+6) has merged, and this branch merged `main` after it, at `112ea871` over `main` `307ce4c4`. The diff against `main` is this part alone: 24 files, +637 / −88. `check-governed-merges --branch` puts 0 of the 24 paths on the register, and the size is under the human-merge line. ## Why Step 7 rests on the premise that the readers it retires are dead after certification. The first measurement on this part found one still live. A production `os serve` with the `auth` tier on and no `OS_AUTH_SECRET` skipped AuthPlugin with a warning and served anyway. With no AuthPlugin, `sys_organization` is never registered, so the engine answers `no-organization-object`, and the inline seed wrote rows with `organization_id` NULL and drew their autonumbers from the `__global__` counter. Measured on `examples/app-showcase`, on a fresh SQLite database, on the merged tree before this change: - the boot wrote its birth marker and answered health 200; - `showcase_field_zoo` then held 2 rows, both with NULL `organization_id`; - `_objectstack_sequences` held one row with `tenant_id` `__global__`. The seat ruled option A (amendment 6284744613, under #3963's rule A1). Close the door at the boot, then retire the counter path where the organization object exists. ## What changed 1. **`os serve` refuses a production server boot whose auth did not compose for a missing secret.** `packages/cli/src/commands/serve.ts`. - Placement: beside A1's existing no-auth refusal, in the same prescriptive shape. It fires while the kernel is only composed: no plugin has started and the database is never opened. - The message names `OS_AUTH_SECRET` and `--no-server`. - Unchanged: `--no-server` (kernel only) still boots with the warning, `--dev` still uses its fallback secret, and the `host-kernel` reason (the cloud runtime) still only warns. - `os start` spawns `serve`, so it gets the same refusal. 2. **The counter refuses an organization-less row on a tenant table, wherever the driver registered the organization object.** `SqlDriver.resolveSequenceTenantId` is the one rule both faces call; `TursoDriver`'s remote face passes the table and field through. - The refusal is the new `OrganizationlessCounterRefusal` (`packages/drivers/driver-sql/src/organizationless-counter-refusal.ts`), with code `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` and status 500. That is the engine twin's code, registered for `@objectstack/driver-sql` in the error-code ledger. - It is resolved before the lazy sequences DDL and before any counter read, so nothing is minted and nothing is written. - `__global__` stays the counter key for an object with no tenant column. It also stays for a composition with no organization object, i.e. a lean `@objectstack/objectql/core` embedding. - The composition fact is `organizationObjectRegistered`, set in `computeAndRecordTenantField`, the one funnel every registration door passes through. 3. **Texts:** - objectql's refusal message and module docblock, with `GLOBAL_TENANT` no longer exported from `@objectstack/objectql`; - the `app-plugin.ts` seed-handoff docblock; - the `UniqueScopeSchema` TSDoc in `field.zod.ts`; - the lint rule texts for `unique/legacy-organization-composite`; - `duplicates.ts`'s live-condition docblocks. Each now says that a NULL-organization row is residue an earlier release wrote, not something a new write produces. 4. **Pins:** - New `sql-driver-organizationless-counter-refusal.test.ts` (driver-sql): refusal on create and bulkCreate, carried organization, no tenant column, lean embedding. - New `serve-no-auth-secret-refusal.integration.test.ts` (cli): the refused boot's database file never exists; the control boot with the secret creates it and reaches ready. - Rewritten to the new rule, not weakened: - objectql `system-write-organization.test.ts` and runtime `system-write-tenancy-autonumber-split.integration.test.ts` assert `ADR-0131 D9` and `organization_id = NULL`, and assert that `__global__` is absent. The runtime one also asserts it is the engine's refusal, not the driver's. - lint `data-model-rules.unique-verdicts.test.ts`. - runtime `seed-tenancy-autonumber-split.integration.test.ts` now lands its 17.x residue through the same driver call with the refusal's composition fact withheld (`asA17xDriver`), so the residue is the driver's own 17.x path. - Test rigs that boot a production server for an unrelated property now get a secret (`E2E_AUTH_SECRET` in `test/helpers/serve-process.ts`). `serve-ready-degraded-boot.e2e.test.ts` reaches its degraded banner kernel-only (`--no-server`). What reads the orphaned `__global__` counter rows a 17.x install left on tenant tables, after this change: the counter does not. `os migrate duplicates` still reports them in `liveConditions`, now documented as residue. The historic seed-tenancy backfill (`kernel:ready`, and the handoff on organization create) still merges them into the organization's counter. Its migration entry is not touched here. ## Measured On HEAD `112ea871` (after `main` was merged), with a fresh `pnpm install` and `pnpm build` (exit 0): - **Premise, again.** - Production `os serve` of app-showcase with no secret: refused, with "no auth secret is set" and `OS_AUTH_SECRET` named, and the database file never created. - The same boot with the secret: health 200. 65 tables carry `organization_id`, with 0 NULL rows, and the counter is keyed by the Default Organization. - `examples/embed-objectql`: 2/2 tests passed. - **R1.** `pnpm dev -- --fresh` on a random port: health 200, seeded-admin sign-in 200, 0 error lines. - **R2 + R3.** `upgrade-18x-harness.dogfood.test.ts` (over the ceremonied fixture) and `organization-ownership-boot-refusal-order.dogfood.test.ts`: 2 files, 12 passed. - **R4.** An `isolated` production `os serve` with the real `@objectstack/organizations` runtime composed (`Organizations` in the plugin list): health 200, 33 tables carry `organization_id` with 0 NULL rows, and 0 failure lines. The walled dogfood cases (the six `wall-*` files and the three `*-walled*` files): 9 files, 133 passed. Step 4's walled e2e cases (`serve-organizations-host-resolution`, `serve-organizations-mount-failure`) ran in the CLI nightly tier below, green. - **Suites:** - driver-sql: 224 files passed, 3683 tests passed; - driver-turso: 92 files, 2430 tests; - runtime: 431 files, 6782 tests; - cli unit: 313 files, 4635 tests; - objectql: 450 files, 8490 tests; - lint: 149 files, 6958 tests; - spec: 703 files, 21360 tests; - cli integration: 163 files passed, 1 skipped; 1411 tests passed, 9 skipped; - cli nightly tier (`OS_TEST_TIERS=nightly`, every `*.e2e.test.ts`): 83 files, 879 tests; - `turbo run typecheck` over the seven touched packages and their closure: 68 of 68 tasks. - **Ablation**, on `b2fec9a9` (before the `main` merge; the ablated line is unchanged by it). `node scripts/ablation-replace.mjs` replaced the refusal line in `resolveSequenceTenantId`. The landing was proven on disk: anchor count 1 to 0, blob `0af77d32fad3` to `0828ff0cfadb`. Then `sql-driver-organizationless-counter-refusal.test.ts` went 2 failed, 3 passed: both refusal cases red, the three `__global__`/carried cases still green. Restored: blob equals HEAD and `git diff HEAD` is empty. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands` on the final head derives 124 commands over the 24 paths. All 124 ran, each exit code captured to a file before any pipe. `--ran` reconciles 124 of 124. - 122 exited 0, among them `check:adr-0087-registration`, `check-changeset-no-major`, `check:error-code-provenance`, `check:cli-test-child-env`, `check:type-check-debt` and the spec `check:generated` (14 artifacts up to date). - `check-engine-split-ratio --days 90` (exit 2) and `check-plugin-teardown-shape --self-test` (exit 3) refuse in this shallow clone: they need history older than the clone. NOT MEASURED here; this is checker health, not this diff. ## Acceptance notes - **Finding, batched by the seat into the Tier H ADR notes PR.** ADR-0131 D9 reads "a write on a tenant-column object with no organization is derived under single and refused otherwise". `Engine.resolveSystemInsertOrganization`'s `no-organization-object` exit still writes NULL. After this PR no served composition reaches that exit (`os serve` refuses it); a lean embedding still does, by design. - **`liveConditions` in `os migrate duplicates`.** The JSON shape is unchanged; only its meaning narrowed from forecast to residue. `content/docs/deployment/cli.mdx` still calls it "about to mint more duplicates". That page is outside this part's file surface, so the correction is left to the seat. - **The spec `.describe()` on `DeclaredIndexSchema.unique` and the `object.zod.ts` comment** describe the `COALESCE` DDL emitters, which are 7b-2's (held for a ruling). They are untouched. - **Turso remote face.** The refusal reaches it through the shared `resolveSequenceTenantId`. No new remote-face test was added; the driver-turso suite is green. - **Files outside the claimed surface, each forced by the widened one:** - `scripts/check-cli-test-child-env.mjs`: the new refusal pin joins its pinned census of files that spawn `bin/run.js` (13 to 14). The gate and its self-test pass. - `packages/cli/test/helpers/serve-process.ts`: gains `E2E_AUTH_SECRET`. - `uncreatable-directory-doors.integration.test.ts` and `serve-ready-degraded-boot.e2e.test.ts`: production server rigs whose property is not auth. The first now hands in the secret; the second boots `--no-server`. No assertion was weakened. - **Not touched:** `docs/adr/**`, `content/docs/releases/`, the `COALESCE(…,'__global__')` DDL emitters, the hash shadow and the index differ (7b-2), and historic migration entries. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01H7Dv7HSDeT8d2fbDq3myx5 --- _Generated by [Claude Code](https://claude.ai/code/session_01H7Dv7HSDeT8d2fbDq3myx5)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent de9cd1f commit df8bb40

24 files changed

Lines changed: 644 additions & 88 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/cli': minor
3+
'@objectstack/driver-sql': minor
4+
'@objectstack/driver-turso': minor
5+
'@objectstack/objectql': minor
6+
'@objectstack/lint': patch
7+
'@objectstack/spec': patch
8+
---
9+
10+
feat!: an organization-less row on a tenant table no longer draws its autonumber from the `__global__` counter, and a production server boot with no auth secret is refused
11+
12+
Clause-②: yes (narrowing)
13+
14+
<!-- adr-0087: not-required (runtime-export-retired @objectstack/objectql#GLOBAL_TENANT) The one author-reachable removal is a runtime export: the string constant `GLOBAL_TENANT` leaves `@objectstack/objectql`'s published entry. 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; the two run-time narrowings (the driver's counter, `os serve`'s boot) carry their own remedy below. -->
15+
16+
**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, stage 8d).
17+
18+
- **`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.
19+
- **`@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.
20+
- **`@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.
21+
- **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.
22+
23+
**The remedy.**
24+
- 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`.
25+
- 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`.
26+
27+
The retired export:
28+
29+
- FROM `GLOBAL_TENANT` TO `GLOBAL_TENANT` imported from `@objectstack/driver-sql`, where the counter and the index emitters keep it (or the literal `'__global__'`).

‎packages/cli/src/commands/migrate/duplicates.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,14 @@ import type { RuntimeIndexPreflight, SeedTenancyExec } from '@objectstack/metada
4343
* schema, no rows written, nothing left behind.
4444
* 4. **The live CONDITION is reported too** — an install still holding a
4545
* `__global__` counter beside an organization-scoped one for the same
46-
* object is about to mint more duplicates. The only forward-looking line in
47-
* the report.
46+
* object. Under 17.x that install was about to mint more duplicates, and
47+
* this was the only forward-looking line in the report. Since ADR-0131 D9
48+
* (stage 8d) the counter refuses an organization-less row on a tenant
49+
* table wherever the organization object is registered, so such a
50+
* `__global__` row is no longer drawn from: the line now names the residue
51+
* the split left in `_objectstack_sequences` — the evidence of which
52+
* counter minted the duplicates above — rather than a forecast. The report
53+
* shape is unchanged.
4854
* 5. **A data-side probe** — `GROUP BY <field> HAVING COUNT(*) > 1` within the
4955
* object's own table, never an enumeration of `_objectstack_sequences`. A
5056
* duplicate on an object whose counter was since merged is therefore still
@@ -154,10 +160,12 @@ export interface DuplicateIdentifier {
154160
}
155161

156162
/**
157-
* One object/field still running two counters — the live condition (point 4).
163+
* One object/field still holding two counters — the live condition (point 4).
158164
*
159-
* Not damage: a prediction. While both counters stand, each allocates within
160-
* its own partition and neither can see the other's numbers.
165+
* Not damage. Under 17.x it was a prediction: while both counters stood, each
166+
* allocated within its own partition and neither could see the other's
167+
* numbers. Since ADR-0131 D9 the `__global__` one is residue the counter no
168+
* longer draws from on a tenant table (point 4 above).
161169
*/
162170
export interface DuplicateLiveCondition {
163171
object: string;

‎packages/cli/src/commands/serve.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4674,6 +4674,29 @@ export default class Serve extends Command {
46744674
+ "view, a share link, or `book.audience: 'public'`.",
46754675
);
46764676
}
4677+
// [ADR-0131 D9] The same case reached the other way: the `auth` tier is
4678+
// on, but a production boot resolved no secret, so AuthPlugin is not
4679+
// composed (rule 4 of `resolvePlatformAuthComposition`). A1's conclusion
4680+
// holds unchanged — no security model, so no data API — and it holds for
4681+
// a second reason too: with no AuthPlugin there is no organization
4682+
// object, so every write the boot makes (the inline seed first) would land
4683+
// with no organization and draw its autonumbers from an organization-less
4684+
// counter. Refused here, while the kernel has only been composed: no
4685+
// plugin has started, so nothing has been written. The `host-kernel`
4686+
// reason (the cloud runtime, where auth is each project's) is not this
4687+
// case and still only warns above.
4688+
if (flags.server && !authComposition.composes && authComposition.reason === 'no-secret') {
4689+
throw new Error(
4690+
'This stack enables auth, but no auth secret is set, so AuthPlugin was not composed and no caller '
4691+
+ 'can authenticate — anonymous access to object data is always denied, so the data API would be '
4692+
+ 'unusable, and with no organization every row this boot writes would have no owner.\n'
4693+
+ 'Fix it one of two ways:\n'
4694+
+ ' • set OS_AUTH_SECRET (a long random value, identical across restarts and nodes), e.g. '
4695+
+ 'OS_AUTH_SECRET=$(openssl rand -hex 32);\n'
4696+
+ ' • or serve without the data API — run with --no-server, or drop the REST/dispatcher plugins.\n'
4697+
+ 'A local trial does not need the secret: `os dev` (or `os serve --dev`) uses a development fallback.',
4698+
);
4699+
}
46774700

46784701
try {
46794702
const { createRestApiPlugin } = await import('@objectstack/rest');

‎packages/cli/test/helpers/serve-process.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,16 @@ export function portDriftError(
792792
*/
793793
export const E2E_SECRET_KEY = '0e2e'.repeat(16);
794794

795+
/**
796+
* A fixed, obviously-synthetic auth secret for spawned PRODUCTION server boots
797+
* (`bin/run.js`, `NODE_ENV` unset). Since ADR-0131 D9 such a boot is refused
798+
* before the kernel starts when no secret composes AuthPlugin
799+
* (`serve-no-auth-secret-refusal.integration.test.ts`), so a rig whose
800+
* property is not about auth hands this in rather than steering around the
801+
* refusal. ⛔ Test fixtures only; it signs nothing anyone keeps.
802+
*/
803+
export const E2E_AUTH_SECRET = 'os-e2e-auth-secret-not-a-real-credential-0e2e0e2e';
804+
795805
export const VITEST_WORKER_ENV_KEYS = [
796806
'TEST',
797807
'VITEST',
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
4+
import { spawn, type ChildProcessByStdio } from 'node:child_process';
5+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
6+
import { tmpdir } from 'node:os';
7+
import { join, resolve } from 'node:path';
8+
import type { Readable } from 'node:stream';
9+
import { fileURLToPath } from 'node:url';
10+
import {
11+
childEnv,
12+
E2E_SECRET_KEY,
13+
KERNEL_ASSIGNED_PORT,
14+
requireBuiltCli,
15+
RUN_JS_RESOLVES_FROM_DIST,
16+
} from './helpers/serve-process.js';
17+
18+
/**
19+
* ADR-0131 D9 — a production `os serve` whose `auth` tier is on but whose auth
20+
* secret is missing is REFUSED before the kernel starts.
21+
*
22+
* Without the secret `resolvePlatformAuthComposition` composes no AuthPlugin,
23+
* so the boot has no security model (anonymous object data is always denied,
24+
* the data API is unusable) and no organization object — and every row its
25+
* inline seed writes on a tenant table landed with no organization, its
26+
* autonumber drawn from the organization-less counter. Measured before this
27+
* refusal on `examples/app-showcase`: a fresh install answered health 200 and
28+
* held two rows with a NULL `organization_id` and a `__global__` counter row.
29+
*
30+
* The property pinned is the order as much as the refusal: it fires while the
31+
* kernel is only composed, so the database is never even opened. The healthy
32+
* leg is the control — the same fixture, entrypoint and posture with the
33+
* secret set reaches the ready banner and creates the database file, which is
34+
* what makes the absent file in the refused leg mean something.
35+
*
36+
* Spawned through `bin/run.js` with `NODE_ENV` unset, the only shape that
37+
* reaches production posture (`bin/run-dev.js` sets `development`, and a
38+
* development boot falls back to a local secret).
39+
*/
40+
41+
const READY_BANNER_TAIL = /Press Ctrl\+C to stop/;
42+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
43+
const CLI = resolve(HERE, '../bin/run.js');
44+
45+
type BootChild = ChildProcessByStdio<null, Readable, Readable>;
46+
47+
const children: BootChild[] = [];
48+
let fixtureDir: string;
49+
50+
interface Outcome {
51+
output: string;
52+
exitCode: number | null;
53+
}
54+
55+
/** Boot until the ready banner (returns `exitCode: null`) or until the process exits. */
56+
async function boot(databaseFile: string, env: Record<string, string | undefined> = {}): Promise<Outcome> {
57+
const child = spawn(process.execPath, [CLI, 'serve', 'objectstack.config.ts', '--port', KERNEL_ASSIGNED_PORT], {
58+
cwd: fixtureDir,
59+
stdio: ['ignore', 'pipe', 'pipe'],
60+
// `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00).
61+
env: childEnv({
62+
NO_COLOR: '1',
63+
OS_DATABASE_URL: `file:${databaseFile}`,
64+
OS_LOG_LEVEL: '',
65+
OS_DISABLE_CONSOLE: '1',
66+
OS_SECRET_KEY: E2E_SECRET_KEY,
67+
// Truly unset in both legs unless a leg sets it: the refusal's subject.
68+
// The two legacy spellings `resolveAuthSecret` still reads are unset too,
69+
// so an ambient one cannot compose auth behind the leg's back.
70+
OS_AUTH_SECRET: undefined,
71+
AUTH_SECRET: undefined,
72+
BETTER_AUTH_SECRET: undefined,
73+
// Production posture — see the header.
74+
NODE_ENV: undefined,
75+
...env,
76+
}),
77+
}) as BootChild;
78+
children.push(child);
79+
80+
let output = '';
81+
return new Promise<Outcome>((done, fail) => {
82+
const timer = setTimeout(() => {
83+
fail(new Error(`serve neither refused nor reached ready\n${output}`));
84+
}, 150_000);
85+
const onData = (d: unknown) => {
86+
output += String(d);
87+
if (READY_BANNER_TAIL.test(output)) {
88+
clearTimeout(timer);
89+
done({ output, exitCode: null });
90+
}
91+
};
92+
child.stdout.on('data', onData);
93+
child.stderr.on('data', onData);
94+
child.on('exit', (code) => {
95+
clearTimeout(timer);
96+
done({ output, exitCode: code });
97+
});
98+
});
99+
}
100+
101+
beforeAll(() => {
102+
requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST);
103+
fixtureDir = mkdtempSync(join(tmpdir(), 'os-no-auth-secret-'));
104+
writeFileSync(join(fixtureDir, 'objectstack.config.ts'), 'export default {};\n', 'utf8');
105+
});
106+
107+
afterAll(() => {
108+
for (const child of children) {
109+
try { child.kill('SIGTERM'); } catch { /* already gone */ }
110+
}
111+
if (fixtureDir) rmSync(fixtureDir, { recursive: true, force: true });
112+
});
113+
114+
describe('os serve — a production server boot with no auth secret is refused (ADR-0131 D9)', () => {
115+
it('refuses before the kernel starts: non-zero exit, the remedy named, the database never opened', async () => {
116+
const databaseFile = join(fixtureDir, 'refused.db');
117+
const { output, exitCode } = await boot(databaseFile);
118+
119+
expect(exitCode, `the boot was not refused\n${output}`).not.toBeNull();
120+
expect(exitCode).not.toBe(0);
121+
expect(output).not.toMatch(READY_BANNER_TAIL);
122+
expect(output).toContain('no auth secret is set');
123+
expect(output).toContain('OS_AUTH_SECRET');
124+
expect(existsSync(databaseFile), `the refused boot opened its database\n${output}`).toBe(false);
125+
}, 240_000);
126+
127+
it('control: the same boot with the secret reaches ready and opens its database', async () => {
128+
const databaseFile = join(fixtureDir, 'healthy.db');
129+
const { output, exitCode } = await boot(databaseFile, {
130+
OS_AUTH_SECRET: 'os-adr-0131-d9-control-leg-secret-not-a-real-credential',
131+
});
132+
133+
expect(exitCode, `the control leg exited\n${output}`).toBeNull();
134+
expect(output).not.toContain('no auth secret is set');
135+
expect(existsSync(databaseFile), `the control leg never opened its database\n${output}`).toBe(true);
136+
}, 240_000);
137+
});

‎packages/cli/test/serve-ready-degraded-boot.e2e.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ import {
4848
* incidents reached by other routes: a plugin that did not load, and a core
4949
* service that is not there.
5050
*
51+
* [ADR-0131 D9] Both legs boot `--no-server`. A production SERVER boot with no
52+
* secret is now refused before the kernel starts
53+
* (`serve-no-auth-secret-refusal.integration.test.ts`), so it reaches no banner; the
54+
* kernel-only boot still skips auth with the warning and still prints the
55+
* ready banner, which is the property under test here.
56+
*
5157
* ⛔ `runServe()` cannot reach it. That helper spawns `bin/run-dev.js`, which
5258
* sets `NODE_ENV = 'development'` before argv is parsed, so `isDev` is true,
5359
* the dev fallback secret applies, auth ALWAYS loads and the boot is never
@@ -92,7 +98,7 @@ async function boot(env: Record<string, string | undefined> = {}): Promise<Boot>
9298
// A kernel-assigned port (#24295): production posture refuses to auto-shift,
9399
// so a port drawn and released beforehand is a red whenever another process
94100
// takes it in the gap. Nothing here talks to the port.
95-
const child = spawn(process.execPath, [CLI, 'serve', 'objectstack.config.ts', '--port', KERNEL_ASSIGNED_PORT], {
101+
const child = spawn(process.execPath, [CLI, 'serve', 'objectstack.config.ts', '--no-server', '--port', KERNEL_ASSIGNED_PORT], {
96102
cwd: fixtureDir,
97103
stdio: ['ignore', 'pipe', 'pipe'],
98104
// `childEnv`, never a bare `...process.env` — see its header (commit 1ddda1d00).

‎packages/cli/test/uncreatable-directory-doors.integration.test.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync,
7474
import { tmpdir } from 'node:os';
7575
import { join, resolve } from 'node:path';
7676
import { fileURLToPath } from 'node:url';
77-
import { E2E_SECRET_KEY, RUN_JS_RESOLVES_FROM_DIST, requireBuiltCli } from './helpers/serve-process.js';
77+
import { E2E_AUTH_SECRET, E2E_SECRET_KEY, RUN_JS_RESOLVES_FROM_DIST, requireBuiltCli } from './helpers/serve-process.js';
7878

7979
const HERE = resolve(fileURLToPath(import.meta.url), '..');
8080
const RUN_JS = resolve(HERE, '../bin/run.js');
@@ -171,7 +171,15 @@ function runCli(args: string[], env: Record<string, string>, opts: { cwd?: strin
171171
function runServe(home: string): Promise<Run> {
172172
return runCli(
173173
['serve', '--port', '0'],
174-
{ OS_HOME: home, OS_DATABASE_URL: ':memory:', OS_SECRET_KEY: E2E_SECRET_KEY, OS_DISABLE_CONSOLE: '1' },
174+
{
175+
OS_HOME: home,
176+
OS_DATABASE_URL: ':memory:',
177+
OS_SECRET_KEY: E2E_SECRET_KEY,
178+
// A production server boot with no auth secret is refused (ADR-0131 D9);
179+
// this file's property is about OS_HOME, not auth.
180+
OS_AUTH_SECRET: E2E_AUTH_SECRET,
181+
OS_DISABLE_CONSOLE: '1',
182+
},
175183
{ cwd: serveDir, until: BANNER_TAIL },
176184
);
177185
}

‎packages/drivers/driver-sql/src/index.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ export {
2929
UnsupportedDialectEmissionError,
3030
renderDialectEmissionRefusal,
3131
} from './dialect-emission-refusal.js';
32+
// [ADR-0131 D9] The autonumber counter's refusal of an organization-less row on
33+
// a tenant table — exported for the same reason: a caller recognises it by `code`.
34+
export {
35+
ORGANIZATIONLESS_COUNTER_REFUSAL_CODE,
36+
ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS,
37+
OrganizationlessCounterRefusal,
38+
renderOrganizationlessCounterRefusal,
39+
} from './organizationless-counter-refusal.js';
3240
export type {
3341
SqlDriverConfig,
3442
SqliteJournalMode,

0 commit comments

Comments
 (0)