Skip to content

Commit 6abee03

Browse files
fix(plugin-auth, driver-sql)!: fail closed on a missing auth secret outside development; compile window functions from closed tables (#25563)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoFczGz9PyHzzvPyUf2P99fn)_ Clause-②: no (narrowing) Metadata-protocol: none Two small hardenings, one commit each, each with its own changeset. Draft: it waits for the maintainer's review. ## 1 · plugin-auth: the temporary auth secret is admitted on a development boot only `generateSecret` (`auth-manager-database-secret.ts`) fell back to a temporary `dev-secret-` value whenever `NODE_ENV` was anything but exactly `production`. It now admits that fallback only when `isDevelopmentBoot` from `@objectstack/core` holds (`NODE_ENV=development`) or under `NODE_ENV=test`, the same pair core's own production fuse reads (`isCloudManaged`). Every other boot without `OS_AUTH_SECRET` (or its legacy spellings, or a `secret` option) refuses to start, as `production` already did. - **CLI boots are unchanged.** `os serve` sets `NODE_ENV=production` when it is unset and `development` under `--dev`; `os start` does the same for the child it spawns. So only an embedder that constructs `AuthPlugin` / `AuthManager` itself with no secret and no `NODE_ENV=development` sees the new refusal. - **Why `test` is kept.** Vitest sets `NODE_ENV=test`, and the full plugin-auth suite constructs managers without a secret. Core's own fuse treats `test` as non-production for the same reason. - **Tests:** the existing guard block in `auth-manager.test.ts` gains an unset `NODE_ENV` case, four custom values (`staging`, `Production`, `dev`, empty), and a `test` case. ## 2 · driver-sql: `findWithWindowFunctions()` compiles from closed tables and quoted columns `buildWindowFunction` assembled `FUNC() OVER (PARTITION BY … ORDER BY …)` as text. Now: - the function name is looked up in a closed table, `row_number`, `rank`, `dense_rank`, `percent_rank`, `cume_dist` (any case). These are the argument-less functions the door's flat shape can express. Anything else answers `INVALID_QUERY` / 400 before a statement is sent. - the sort direction is `asc` or `desc` (any case, default `asc`); anything else answers `INVALID_QUERY` / 400. - every `partitionBy` / `orderBy` field is emitted with `knex.ref(...).toQuery()`, the quoting idiom this file already uses for columns. **Why parameterised, not removed.** The dispatch preferred deleting the method. Measured first: nothing in objectstack, objectui or hotcrm calls it outside driver-sql's own tests and the turso override. It is still a published door, though. The protocol-17 migration `query-window-functions-retired` and the `query.windowFunctions` tombstone in `packages/spec/src/data/query.zod.ts` both prescribe `SqlDriver.findWithWindowFunctions()` as the replacement for the retired request key, and the 17.0 release notes and `content/docs/data-modeling/queries.mdx` document it. Removing it would retire a capability those texts tell embedders to migrate to. This PR keeps it and closes the string assembly instead. If the maintainer prefers removal, that is a follow-up that also rewrites those prescriptions. - **Tests:** a new `sql-driver-window-function-compilation.test.ts` covers the five functions in mixed case, three refused names, both directions plus the default, a refused direction, the exact compiled text, and a field-slot expression read as a column name. ## Red on `main`, green here Each fix was committed first, then the source file alone was restored from `main` (`19d3720`) and the new tests were run: - plugin-auth guard block: **6 failed / 3 passed** on `main`'s source; **9 passed** with the fix. - window-function compilation: **6 failed / 6 passed** on `main`'s source (the six passes are the accepted spellings); **12 passed** with the fix. ## Local verification - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: **191 files, 3613 passed, 10 skipped** - `pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2`: **235 files passed, 12 skipped; 3787 passed, 212 skipped** - Derived gate list (`dispatch-gates.mjs --commands`) run locally: all **74** derived families exit 0 at `329dd94a` (`dispatch-gates.mjs --ran` reconciles 74 run, 0 NOT-MEASURED). Four needed a real-declaration build or a deeper fetch first and were re-run after it. - `pnpm --filter @objectstack/plugin-auth --filter @objectstack/driver-sql run typecheck` after a real-declaration build of both closures: exit 0 - Dogfood battery (owed by the narrowing changesets): run on this head after the PR opens; the result goes to the reviewer thread. ## Acceptance notes - `driver-sqlite-wasm` and `driver-turso` (local and replica transports) inherit the builder, so they get the same closed compilation. The turso remote transport already refused this door. - No issue is linked: this work was dispatched directly. --- _Generated by [Claude Code](https://claude.ai/code/session_01SNJgKweepTFKCrYgaKeFZK)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ce18e73 commit 6abee03

6 files changed

Lines changed: 273 additions & 16 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/driver-sql': patch
3+
---
4+
5+
fix(driver-sql)!: `findWithWindowFunctions()` compiles the function name and sort direction from closed sets and quotes every column it names
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, 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 added, removed or renamed: the refusal is the existing `INVALID_QUERY`. What narrows is the set of `windowFunctions[]` entries one driver method accepts, and the remedy is the caller using a supported function name and `asc` / `desc`. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this method (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
11+
12+
**BREAKING** (the window-function door's accept set narrows), shipped as `patch`: it changes which calls one driver method serves, not what a metadata author can write.
13+
14+
`SqlDriver.findWithWindowFunctions()` (and the drivers that inherit it: `driver-sqlite-wasm`, and `driver-turso` on its local and replica transports) assembled its `OVER (…)` clause as text. It now builds it from closed tables and quoted identifiers:
15+
16+
- **Function:** one of `row_number`, `rank`, `dense_rank`, `percent_rank`, `cume_dist`, in any case. These are the argument-less functions the door's flat shape can express. Any other name answers `400 INVALID_QUERY` before a statement is sent.
17+
- **Direction:** `asc` or `desc`, in any case, defaulting to `asc`. Anything else answers `400 INVALID_QUERY`.
18+
- **Fields:** each `partitionBy` and `orderBy` field is emitted as a quoted column reference, so a field names a column and nothing else.
19+
- **Unchanged:** every call that names a supported function, a column and `asc` / `desc` compiles to the same window and returns the same rows.
20+
- **Fix:** use one of the five functions above and `asc` / `desc`. A window that needs an argument (`lag`, `lead`, `ntile`, `first_value`) was never expressible through this door; send it through the driver's `execute()` with bound parameters.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth)!: the temporary auth secret is used on a development boot only; any other boot without `OS_AUTH_SECRET` refuses to start
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, 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 added, removed or renamed. What narrows is the set of environments in which `AuthManager` boots without a configured secret, and the remedy is configuration: set `OS_AUTH_SECRET`, or `NODE_ENV=development` for a local boot. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this behaviour (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
11+
12+
**BREAKING** (the boots that start without a configured secret narrow), shipped as `patch`: it changes which environments boot, not what a metadata author can write.
13+
14+
With no `OS_AUTH_SECRET` (nor its legacy spellings `AUTH_SECRET` / `BETTER_AUTH_SECRET`) and no `secret` option, `AuthManager` used to fall back to a temporary development secret whenever `NODE_ENV` was anything but exactly `production`: unset, `staging`, a typo. It now does so only on a development boot (`isDevelopmentBoot` from `@objectstack/core`: `NODE_ENV=development`) or under `NODE_ENV=test`, and refuses to boot otherwise, the way `production` already did.
15+
16+
- **FROM:** an embedder that never set `NODE_ENV`, or set a custom value, booted with a temporary secret and a console warning.
17+
- **TO:** the same boot throws `OS_AUTH_SECRET is required outside development`.
18+
- **Unchanged:** `os serve` and `os start` default an unset `NODE_ENV` to `production` and `os dev` / `--dev` set `development`, so CLI boots behave as before. A configured secret is used exactly as before in every environment.
19+
- **Fix:** set `OS_AUTH_SECRET` to a strong random value. For a local development boot, set `NODE_ENV=development`.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* How `findWithWindowFunctions()` turns a `windowFunctions[]` entry into SQL.
5+
*
6+
* Nothing the caller sends may reach the statement as text: the function name
7+
* and the sort direction come from closed tables (anything else is refused with
8+
* `INVALID_QUERY` / 400), and `partitionBy` / `orderBy` fields are emitted as
9+
* quoted column references. Each refusal case below is a spelling that the
10+
* builder used to splice into the statement verbatim.
11+
*/
12+
13+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
14+
import { SqlDriver } from './index.js';
15+
16+
describe('findWithWindowFunctions — compiling a windowFunctions[] entry', () => {
17+
let driver: SqlDriver;
18+
let knexInstance: any;
19+
20+
beforeEach(async () => {
21+
driver = new SqlDriver({
22+
client: 'better-sqlite3',
23+
connection: { filename: ':memory:' },
24+
useNullAsDefault: true,
25+
});
26+
knexInstance = (driver as any).knex;
27+
await knexInstance.schema.createTable('orders', (t: any) => {
28+
t.string('id').primary();
29+
t.string('customer');
30+
t.float('amount');
31+
});
32+
await knexInstance('orders').insert([
33+
{ id: '1', customer: 'Alice', amount: 10 },
34+
{ id: '2', customer: 'Bob', amount: 20 },
35+
{ id: '3', customer: 'Alice', amount: 30 },
36+
]);
37+
});
38+
39+
afterEach(async () => {
40+
await knexInstance.destroy();
41+
});
42+
43+
const expectInvalidQuery = async (promise: Promise<unknown>) => {
44+
const err: any = await promise.then(
45+
() => {
46+
throw new Error('expected the window-function read to be refused');
47+
},
48+
(e) => e,
49+
);
50+
expect(err.code).toBe('INVALID_QUERY');
51+
expect(err.status).toBe(400);
52+
};
53+
54+
describe('the function name is a closed set', () => {
55+
it.each(['row_number', 'RANK', 'Dense_Rank', 'percent_rank', 'CUME_DIST'])('compiles %s', async (fn) => {
56+
const rows = await driver.findWithWindowFunctions('orders', {
57+
windowFunctions: [{ function: fn, alias: 'w', orderBy: [{ field: 'amount', order: 'desc' }] }],
58+
});
59+
expect(rows).toHaveLength(3);
60+
expect(rows.every((r: any) => r.w !== undefined)).toBe(true);
61+
});
62+
63+
it.each([
64+
['an unsupported function', 'median'],
65+
['a function that needs an argument', 'lag'],
66+
['an expression in the name slot', 'row_number() over () as x, (select count(*) from orders) as n, row_number'],
67+
])('refuses %s with INVALID_QUERY / 400', async (_label, fn) => {
68+
await expectInvalidQuery(
69+
driver.findWithWindowFunctions('orders', { windowFunctions: [{ function: fn, alias: 'w' }] }),
70+
);
71+
});
72+
});
73+
74+
describe('the sort direction is asc or desc', () => {
75+
it('accepts either direction in any case, and defaults to asc', async () => {
76+
for (const order of ['asc', 'DESC', undefined] as const) {
77+
const rows = await driver.findWithWindowFunctions('orders', {
78+
windowFunctions: [{ function: 'row_number', alias: 'w', orderBy: [{ field: 'amount', order: order as any }] }],
79+
orderBy: [{ field: 'amount', order: 'asc' }],
80+
});
81+
expect(rows.map((r: any) => r.w)).toEqual(order === 'DESC' ? [3, 2, 1] : [1, 2, 3]);
82+
}
83+
});
84+
85+
it('refuses any other direction with INVALID_QUERY / 400', async () => {
86+
await expectInvalidQuery(
87+
driver.findWithWindowFunctions('orders', {
88+
windowFunctions: [
89+
{ function: 'row_number', alias: 'w', orderBy: [{ field: 'amount', order: 'desc, (select 1)' as any }] },
90+
],
91+
}),
92+
);
93+
});
94+
});
95+
96+
describe('partitionBy / orderBy fields are quoted column references', () => {
97+
it('emits every field through the dialect quoting', () => {
98+
const sql = (driver as any).buildWindowFunction({
99+
function: 'rank',
100+
alias: 'w',
101+
partitionBy: ['customer'],
102+
orderBy: [{ field: 'amount', order: 'desc' }],
103+
});
104+
expect(sql).toBe('RANK() OVER (PARTITION BY `customer` ORDER BY `amount` DESC)');
105+
});
106+
107+
it('reads an expression in a field slot as a column name, never as SQL', async () => {
108+
// As text, `length(customer)` is a valid partition expression and the read
109+
// would succeed; as a quoted identifier it names no column.
110+
await expect(
111+
driver.findWithWindowFunctions('orders', {
112+
windowFunctions: [{ function: 'row_number', alias: 'w', partitionBy: ['length(customer)'] }],
113+
}),
114+
).rejects.toThrow(/no such column/);
115+
await expect(
116+
driver.findWithWindowFunctions('orders', {
117+
windowFunctions: [{ function: 'row_number', alias: 'w', orderBy: [{ field: 'length(customer)' }] }],
118+
}),
119+
).rejects.toThrow(/no such column/);
120+
});
121+
});
122+
});

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

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1866,6 +1866,44 @@ function refuseAggregateFunction(func: string): never {
18661866
: undeclaredAggregateFunctionError(func);
18671867
}
18681868

1869+
/**
1870+
* The window functions {@link SqlDriver.findWithWindowFunctions} compiles —
1871+
* a CLOSED set, keyed by the lowercased name a caller may spell in any case.
1872+
*
1873+
* The door emits a function ARGUMENT-LESS (`FUNC() OVER (…)`, see
1874+
* {@link SqlWindowFunctionSpec}), so the set is exactly the standard ranking
1875+
* functions that take no argument, each one available on every dialect this
1876+
* driver targets (PostgreSQL, MySQL 8, SQLite 3.25+). The name used to be
1877+
* spliced into the statement verbatim; it is now looked up here and only the
1878+
* value from this table reaches SQL, so the statement's text is never the
1879+
* caller's.
1880+
*/
1881+
const SQL_WINDOW_FUNCTIONS: ReadonlyMap<string, string> = new Map([
1882+
['row_number', 'ROW_NUMBER'],
1883+
['rank', 'RANK'],
1884+
['dense_rank', 'DENSE_RANK'],
1885+
['percent_rank', 'PERCENT_RANK'],
1886+
['cume_dist', 'CUME_DIST'],
1887+
]);
1888+
1889+
/** The two `OVER (ORDER BY …)` directions, keyed by the lowercased spelling. */
1890+
const SQL_WINDOW_SORT_DIRECTIONS: ReadonlyMap<string, 'ASC' | 'DESC'> = new Map([
1891+
['asc', 'ASC'],
1892+
['desc', 'DESC'],
1893+
]);
1894+
1895+
/**
1896+
* A `windowFunctions[]` entry this door cannot compile: an unknown function
1897+
* name or sort direction. A malformed query (`INVALID_QUERY`, 400), the class
1898+
* {@link undeclaredAggregateFunctionError} answers at the aggregate door.
1899+
*/
1900+
function invalidWindowFunctionQueryError(message: string): Error {
1901+
const err = new Error(message) as Error & { code?: string; status?: number };
1902+
err.code = StandardErrorCode.enum.INVALID_QUERY;
1903+
err.status = 400;
1904+
return err;
1905+
}
1906+
18691907
/**
18701908
* [#6212] A `groupBy` entry asks for a date BUCKET this face cannot emit.
18711909
*
@@ -5107,13 +5145,17 @@ export interface IntrospectedSchema extends SpecIntrospectedSchema {
51075145
* input that works.
51085146
*/
51095147
export interface SqlWindowFunctionSpec {
5110-
/** Window function name, emitted argument-less and uppercased (`rank` → `RANK()`). */
5148+
/**
5149+
* Window function name, emitted argument-less and uppercased (`rank` → `RANK()`):
5150+
* one of `row_number`, `rank`, `dense_rank`, `percent_rank`, `cume_dist`, in any
5151+
* case. Any other name is refused with `INVALID_QUERY` (400).
5152+
*/
51115153
function: string;
51125154
/** Column alias the computed value is projected as. */
51135155
alias: string;
51145156
/** `PARTITION BY` targets, mapped through the driver's storage-name mapping. */
51155157
partitionBy?: string[];
5116-
/** `ORDER BY` inside the `OVER (…)` clause; `order` defaults to `asc`. */
5158+
/** `ORDER BY` inside the `OVER (…)` clause; `order` defaults to `asc`, and any value but `asc` / `desc` (any case) is refused. */
51175159
orderBy?: { field: string; order?: 'asc' | 'desc' }[];
51185160
}
51195161

@@ -18573,23 +18615,43 @@ export class SqlDriver implements IDataDriver {
1857318615

1857418616
// ── Window function builder ─────────────────────────────────────────────────
1857518617

18618+
/**
18619+
* Compile one {@link SqlWindowFunctionSpec} to `FUNC() OVER (…)`. Nothing the
18620+
* caller sent reaches the statement as text: the function name and each sort
18621+
* direction are looked up in closed tables ({@link SQL_WINDOW_FUNCTIONS},
18622+
* {@link SQL_WINDOW_SORT_DIRECTIONS}) and refused when absent, and every
18623+
* `partitionBy` / `orderBy` field is emitted as a quoted column reference
18624+
* (`knex.ref`, the idiom the rest of this file quotes columns with).
18625+
*/
1857618626
protected buildWindowFunction(spec: SqlWindowFunctionSpec): string {
18577-
const func = spec.function.toUpperCase();
18627+
const func = SQL_WINDOW_FUNCTIONS.get(String(spec.function).toLowerCase());
18628+
if (func === undefined) {
18629+
throw invalidWindowFunctionQueryError(
18630+
`Window function ${JSON.stringify(String(spec.function))} is not one this door compiles. ` +
18631+
`Supported (argument-less, any case): ${[...SQL_WINDOW_FUNCTIONS.keys()].join(', ')}. ` +
18632+
`Fix the "function" key of the windowFunctions[] entry.`,
18633+
);
18634+
}
18635+
const column = (field: string) => this.knex.ref(this.mapSortField(String(field))).toQuery();
1857818636
let sql = `${func}()`;
1857918637

1858018638
const overParts: string[] = [];
1858118639

1858218640
if (spec.partitionBy && Array.isArray(spec.partitionBy) && spec.partitionBy.length > 0) {
18583-
const partitionFields = spec.partitionBy.map((f: string) => this.mapSortField(f)).join(', ');
18584-
overParts.push(`PARTITION BY ${partitionFields}`);
18641+
overParts.push(`PARTITION BY ${spec.partitionBy.map(column).join(', ')}`);
1858518642
}
1858618643

1858718644
if (spec.orderBy && Array.isArray(spec.orderBy) && spec.orderBy.length > 0) {
1858818645
const orderFields = spec.orderBy
1858918646
.map((s) => {
18590-
const field = this.mapSortField(s.field);
18591-
const order = (s.order || 'asc').toUpperCase();
18592-
return `${field} ${order}`;
18647+
const order = SQL_WINDOW_SORT_DIRECTIONS.get(String(s.order || 'asc').toLowerCase());
18648+
if (order === undefined) {
18649+
throw invalidWindowFunctionQueryError(
18650+
`Window sort direction ${JSON.stringify(String(s.order))} is not "asc" or "desc". ` +
18651+
`Fix the "order" key of the windowFunctions[].orderBy entry.`,
18652+
);
18653+
}
18654+
return `${column(s.field)} ${order}`;
1859318655
})
1859418656
.join(', ');
1859518657
overParts.push(`ORDER BY ${orderFields}`);

‎packages/plugins/plugin-auth/src/auth-manager-database-secret.ts‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
* `AuthManager` method of the same name as it stood, re-indented one level
1212
* and otherwise unedited: the same adapter and created-user staging, the same
1313
* fallback store, the same secret precedence, the same production refusal and
14-
* development warning.
14+
* development warning. (Since then that refusal's gate fails closed:
15+
* the fallback is admitted on a development boot or under `NODE_ENV=test`
16+
* only — see `generateSecret`.)
1517
*
1618
* ## How the moved bodies reach the class
1719
*
@@ -40,6 +42,7 @@
4042
* Nothing re-exports this module, so the published surface does not move.
4143
*/
4244

45+
import { isDevelopmentBoot } from '@objectstack/core';
4346
import { readEnvWithDeprecation } from '@objectstack/types';
4447
import type { AuthManager } from './auth-manager.js';
4548
import { createObjectQLAdapterFactory } from './objectql-adapter.js';
@@ -140,14 +143,24 @@ export function generateSecret(): string {
140143
const envSecret = readEnvWithDeprecation('OS_AUTH_SECRET', ['AUTH_SECRET', 'BETTER_AUTH_SECRET'], { silent: true });
141144
if (envSecret) return envSecret;
142145

143-
// No secret configured. In production this is FATAL: a predictable
144-
// `dev-secret-<timestamp>` makes session tokens forgeable (session
145-
// forgery). Refuse to boot rather than run insecurely.
146-
if (process.env.NODE_ENV === 'production') {
146+
// No secret configured. Outside development this is FATAL: a predictable
147+
// `dev-secret-<timestamp>` makes session tokens forgeable. The fallback is
148+
// fail-CLOSED: it is admitted only on a development boot
149+
// (`isDevelopmentBoot`, `@objectstack/core` — `NODE_ENV` exactly
150+
// `development`) or under `NODE_ENV=test`, the pair core's own production
151+
// fuse reads (`isCloudManaged`). An unset, misspelled or custom `NODE_ENV`
152+
// is treated as a real deployment and refused, so an embedder who forgets
153+
// to export it gets a refusal to boot, never a guessable secret. `os serve`
154+
// and `os start` default an unset `NODE_ENV` to `production` (and `--dev`
155+
// sets `development`), so the CLI boots are unaffected.
156+
const developmentOrTest = isDevelopmentBoot(undefined) || process.env.NODE_ENV === 'test';
157+
if (!developmentOrTest) {
147158
throw new Error(
148-
'[auth] OS_AUTH_SECRET is required in production but is not set. ' +
159+
'[auth] OS_AUTH_SECRET is required outside development but is not set ' +
160+
`(NODE_ENV is ${process.env.NODE_ENV === undefined ? 'unset' : JSON.stringify(process.env.NODE_ENV)}). ` +
149161
'Refusing to boot with a temporary development secret — session tokens ' +
150-
'would be forgeable. Set OS_AUTH_SECRET to a strong random value.'
162+
'would be forgeable. Set OS_AUTH_SECRET to a strong random value, or set ' +
163+
'NODE_ENV=development for a local development boot.'
151164
);
152165
}
153166

‎packages/plugins/plugin-auth/src/auth-manager.test.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3658,7 +3658,28 @@ describe('AuthManager', () => {
36583658
it('throws (fails boot) in production when no secret is configured', () => {
36593659
process.env.NODE_ENV = 'production';
36603660
const m = new AuthManager({ baseUrl: 'http://localhost:3000' } as any);
3661-
expect(() => (m as any).generateSecret()).toThrow(/OS_AUTH_SECRET is required in production/);
3661+
expect(() => (m as any).generateSecret()).toThrow(/OS_AUTH_SECRET is required outside development/);
3662+
});
3663+
3664+
// The fallback is fail-closed: only a development boot (`isDevelopmentBoot`,
3665+
// `@objectstack/core`) or NODE_ENV=test admits it. An embedder that never
3666+
// exported NODE_ENV, or exported a custom value, is a real deployment.
3667+
it('throws when NODE_ENV is unset and no secret is configured', () => {
3668+
delete process.env.NODE_ENV;
3669+
const m = new AuthManager({ baseUrl: 'http://localhost:3000' } as any);
3670+
expect(() => (m as any).generateSecret()).toThrow(/OS_AUTH_SECRET is required outside development/);
3671+
});
3672+
3673+
it.each(['staging', 'Production', 'dev', ''])('throws when NODE_ENV is %j and no secret is configured', (value) => {
3674+
process.env.NODE_ENV = value;
3675+
const m = new AuthManager({ baseUrl: 'http://localhost:3000' } as any);
3676+
expect(() => (m as any).generateSecret()).toThrow(/OS_AUTH_SECRET is required outside development/);
3677+
});
3678+
3679+
it('falls back to an ephemeral secret under NODE_ENV=test', () => {
3680+
process.env.NODE_ENV = 'test';
3681+
const m = new AuthManager({ baseUrl: 'http://localhost:3000' } as any);
3682+
expect((m as any).generateSecret()).toMatch(/^dev-secret-/);
36623683
});
36633684

36643685
it('falls back to an ephemeral dev secret outside production', () => {

0 commit comments

Comments
 (0)