Skip to content

Commit 18b6041

Browse files
os-zhuangclaude
andauthored
fix(dev-dx): wasm sqlite fallback, silence PORT warning, dev auth Origin (#1875) (#1898)
Three dev-DX fixes surfaced while runtime-testing templates: 1. SQLite ABI mismatch: dev now falls back native better-sqlite3 → wasm SQLite → in-memory. When the native binary's ABI doesn't match the running Node (e.g. Node 25 NODE_MODULE_VERSION mismatch), dev keeps real SQL + persistence via SqliteWasmDriver instead of dropping to the non-persistent mingo in-memory driver. 2. PORT warning: add a `{ silent: true }` option to readEnvWithDeprecation and use it at the OS_PORT/PORT call sites. PORT is an industry-standard alias that PaaS platforms inject automatically, so warning nagged operators about env they never set. Other legacy vars still warn. 3. Auth Origin 403: in dev, when a request has neither Origin nor Referer, handleRequest synthesizes a same-origin Origin from the request URL so better-auth's CSRF check passes for non-browser clients (curl/scripts). Production is untouched (gated on NODE_ENV). Tests: types 7/7, plugin-auth 118/118 (4 new Origin cases + silent option), cli 322/322; full turbo build (tsc typecheck) passes. Closes #1875 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6b6c065 commit 18b6041

7 files changed

Lines changed: 170 additions & 19 deletions

File tree

packages/cli/src/commands/dev.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ export default class Dev extends Command {
254254
printKV('Artifact', isUrl ? artifactPath : path.relative(process.cwd(), artifactPath), '📦');
255255
if (effectiveDb) printKV('Database', redactDbUrl(effectiveDb), '🗄️');
256256

257-
const port = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT');
257+
const port = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true });
258258
const binPath = process.argv[1];
259259
const serveChild = spawn(
260260
process.execPath,

packages/cli/src/commands/serve.ts

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ export default class Serve extends Command {
145145
};
146146

147147
static override flags = {
148-
port: Flags.string({ char: 'p', description: 'Server port', default: readEnvWithDeprecation('OS_PORT', 'PORT') ?? '3000' }),
148+
port: Flags.string({ char: 'p', description: 'Server port', default: readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true }) ?? '3000' }),
149149
dev: Flags.boolean({ description: 'Run in development mode (load devPlugins)' }),
150150
ui: Flags.boolean({ description: 'Enable the bundled Console portal at /_console/ when @object-ui/console is installed (default: true)', default: true, allowNo: true }),
151151
console: Flags.boolean({
@@ -641,16 +641,21 @@ export default class Serve extends Command {
641641
resolvedDriverLabel = 'SqlDriver(mysql2)';
642642
resolvedDatabaseUrl = databaseUrl;
643643
} else if (isDev) {
644-
// Default in dev: prefer SQLite for production-like SQL semantics,
645-
// falling back to the pure-JS in-memory driver (mingo) only when the
646-
// native `better-sqlite3` binary is unavailable — not built, ABI
647-
// mismatch after a Node upgrade, or a blocked prebuild download.
644+
// Default in dev: prefer native SQLite for production-like SQL
645+
// semantics at native speed. When the native `better-sqlite3`
646+
// binary is unavailable — not built, ABI mismatch after a Node
647+
// upgrade (e.g. Node 25 → NODE_MODULE_VERSION mismatch), or a
648+
// blocked prebuild download — fall back to the pure-JS wasm SQLite
649+
// driver, which keeps *real* SQL semantics (and on-disk
650+
// persistence) without any native build step. Only if wasm also
651+
// fails to load do we drop to the in-memory driver (mingo), which
652+
// is neither real SQL nor persistent.
648653
//
649654
// knex loads its client lazily (at first query, not at construction),
650655
// so the only reliable signal inside this registration window is to
651656
// actually open a connection: connect() runs `SELECT 1`, which forces
652-
// better-sqlite3 to load. If that throws we drop to the in-memory
653-
// driver instead of letting the failure surface much later — as a
657+
// better-sqlite3 to load. If that throws we step down the chain here
658+
// instead of letting the failure surface much later — as a
654659
// missing-module crash on the first real query — or be swallowed by
655660
// the silent catch below, leaving the kernel with no driver at all.
656661
let sqliteDriver: any;
@@ -677,15 +682,47 @@ export default class Serve extends Command {
677682
resolvedDriverLabel = 'SqlDriver(sqlite)';
678683
resolvedDatabaseUrl = ':memory:';
679684
} else {
680-
const { InMemoryDriver } = await import('@objectstack/driver-memory');
681-
await kernel.use(new DriverPlugin(new InMemoryDriver()));
682-
trackPlugin('MemoryDriver');
683-
resolvedDriverLabel = 'InMemoryDriver';
684-
resolvedDatabaseUrl = '(in-memory)';
685-
console.warn(chalk.yellow(
686-
' ⚠ better-sqlite3 unavailable — dev falling back to InMemoryDriver (mingo, not real SQL).\n' +
687-
' Rebuild better-sqlite3, or set OS_DATABASE_URL / OS_DATABASE_DRIVER for SQL fidelity.'
688-
));
685+
// Native unavailable → try the pure-JS wasm SQLite driver before
686+
// giving up on SQL fidelity entirely. Same probe-by-connect
687+
// approach: actually open the connection so a load failure is
688+
// caught here rather than on the first real query.
689+
let wasmDriver: any;
690+
let wasmOk = false;
691+
try {
692+
const { SqliteWasmDriver } = await import('@objectstack/driver-sqlite-wasm');
693+
wasmDriver = new SqliteWasmDriver({
694+
filename: ':memory:',
695+
persist: 'on-disconnect',
696+
});
697+
await wasmDriver.connect();
698+
wasmOk = true;
699+
} catch {
700+
wasmOk = false;
701+
if (wasmDriver?.disconnect) {
702+
try { await wasmDriver.disconnect(); } catch { /* ignore */ }
703+
}
704+
}
705+
706+
if (wasmOk) {
707+
await kernel.use(new DriverPlugin(wasmDriver));
708+
trackPlugin('SqliteWasmDriver');
709+
resolvedDriverLabel = 'SqliteWasmDriver';
710+
resolvedDatabaseUrl = ':memory:';
711+
console.warn(chalk.yellow(
712+
' ⚠ native better-sqlite3 unavailable (ABI mismatch or not built) — dev using wasm SQLite (real SQL, slower).\n' +
713+
' Rebuild better-sqlite3 for native speed, or set OS_DATABASE_DRIVER=sqlite-wasm to silence this.'
714+
));
715+
} else {
716+
const { InMemoryDriver } = await import('@objectstack/driver-memory');
717+
await kernel.use(new DriverPlugin(new InMemoryDriver()));
718+
trackPlugin('MemoryDriver');
719+
resolvedDriverLabel = 'InMemoryDriver';
720+
resolvedDatabaseUrl = '(in-memory)';
721+
console.warn(chalk.yellow(
722+
' ⚠ neither native nor wasm SQLite available — dev falling back to InMemoryDriver (mingo, not real SQL).\n' +
723+
' Rebuild better-sqlite3, or set OS_DATABASE_URL / OS_DATABASE_DRIVER for SQL fidelity.'
724+
));
725+
}
689726
}
690727
}
691728
} catch (e: any) {

packages/cli/src/commands/start.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ export default class Start extends Command {
214214
// Resolve the port the child `serve` will actually bind, matching its
215215
// flag default (`--port` > $OS_PORT/$PORT > 3000). Using `flags.port`
216216
// alone printed the wrong URL whenever the port came from the env.
217-
const bannerPort = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT') ?? 3000;
217+
const bannerPort = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true }) ?? 3000;
218218
if (flags.ui) printKV('Console', `http://localhost:${bannerPort}/_console/`, '🖥️');
219219

220220
printStep('Starting server...');

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,80 @@ describe('AuthManager', () => {
124124
});
125125
});
126126

127+
describe('handleRequest – dev Origin injection (CSRF DX)', () => {
128+
const ORIGINAL_NODE_ENV = process.env.NODE_ENV;
129+
130+
afterEach(() => {
131+
if (ORIGINAL_NODE_ENV === undefined) delete process.env.NODE_ENV;
132+
else process.env.NODE_ENV = ORIGINAL_NODE_ENV;
133+
});
134+
135+
const makeManager = () => {
136+
const captured: { request?: Request } = {};
137+
const mockHandler = vi.fn().mockImplementation((req: Request) => {
138+
captured.request = req;
139+
return new Response(null, { status: 200 });
140+
});
141+
(betterAuth as any).mockReturnValue({ handler: mockHandler, api: {} });
142+
const manager = new AuthManager({
143+
secret: 'test-secret-at-least-32-chars-long',
144+
baseUrl: 'http://localhost:3000',
145+
});
146+
return { manager, captured };
147+
};
148+
149+
it('injects a same-origin Origin header in dev when none is present', async () => {
150+
process.env.NODE_ENV = 'development';
151+
const { manager, captured } = makeManager();
152+
153+
await manager.handleRequest(new Request('http://localhost:3000/sign-in/email', {
154+
method: 'POST',
155+
body: JSON.stringify({ email: 'a@b.com', password: 'pass' }),
156+
headers: { 'Content-Type': 'application/json' },
157+
}));
158+
159+
expect(captured.request?.headers.get('origin')).toBe('http://localhost:3000');
160+
});
161+
162+
it('does NOT inject an Origin header in production', async () => {
163+
process.env.NODE_ENV = 'production';
164+
const { manager, captured } = makeManager();
165+
166+
await manager.handleRequest(new Request('http://localhost:3000/sign-in/email', {
167+
method: 'POST',
168+
body: JSON.stringify({ email: 'a@b.com', password: 'pass' }),
169+
headers: { 'Content-Type': 'application/json' },
170+
}));
171+
172+
expect(captured.request?.headers.get('origin')).toBeNull();
173+
});
174+
175+
it('preserves a caller-supplied Origin header in dev', async () => {
176+
process.env.NODE_ENV = 'development';
177+
const { manager, captured } = makeManager();
178+
179+
await manager.handleRequest(new Request('http://localhost:3000/sign-in/email', {
180+
method: 'POST',
181+
body: JSON.stringify({ email: 'a@b.com', password: 'pass' }),
182+
headers: { 'Content-Type': 'application/json', origin: 'http://example.test' },
183+
}));
184+
185+
expect(captured.request?.headers.get('origin')).toBe('http://example.test');
186+
});
187+
188+
it('does not inject when a Referer is already present in dev', async () => {
189+
process.env.NODE_ENV = 'development';
190+
const { manager, captured } = makeManager();
191+
192+
await manager.handleRequest(new Request('http://localhost:3000/sign-in/email', {
193+
method: 'POST',
194+
headers: { referer: 'http://localhost:3000/login' },
195+
}));
196+
197+
expect(captured.request?.headers.get('origin')).toBeNull();
198+
});
199+
});
200+
127201
describe('createDatabaseConfig – adapter wrapping', () => {
128202
it('should pass a function (AdapterFactory) to betterAuth when dataEngine is provided', () => {
129203
const mockDataEngine = {

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1221,6 +1221,30 @@ export class AuthManager {
12211221
* @returns Web standard Response object
12221222
*/
12231223
async handleRequest(request: Request): Promise<Response> {
1224+
// Dev DX: better-auth's CSRF protection rejects state-changing requests
1225+
// (e.g. `/sign-in/email`) with a 403 when neither `Origin` nor `Referer`
1226+
// is present. Browsers always send one; non-browser API clients (curl,
1227+
// fetch from a script, integration tests) often don't, so they hit an
1228+
// opaque 403 in local dev. A request with *no* Origin header is never a
1229+
// browser-driven cross-site attack — CSRF is fundamentally cross-origin —
1230+
// so in non-production we synthesize a same-origin `Origin` from the
1231+
// request URL. It matches the dev localhost trusted-origins set, so the
1232+
// CSRF check passes without weakening protection in production (gated on
1233+
// NODE_ENV, the same dev signal used for the fallback auth secret above).
1234+
if (
1235+
process.env.NODE_ENV !== 'production' &&
1236+
!request.headers.get('origin') &&
1237+
!request.headers.get('referer')
1238+
) {
1239+
try {
1240+
const headers = new Headers(request.headers);
1241+
headers.set('origin', new URL(request.url).origin);
1242+
request = new Request(request, { headers });
1243+
} catch {
1244+
/* malformed URL — leave the request untouched */
1245+
}
1246+
}
1247+
12241248
const auth = await this.getOrCreateAuth();
12251249
// better-auth's HTTP entrypoint (`createBetterAuth.handler`) wraps execution
12261250
// in `runWithAdapter` but NOT `runWithRequestState`. Endpoints that read

packages/types/src/env.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ describe('readEnvWithDeprecation', () => {
3636
expect(String(warn.mock.calls[0][0])).toContain('deprecated');
3737
});
3838

39+
it('returns the legacy value without warning when silent is set', () => {
40+
delete process.env.OS_TEST_FOO;
41+
process.env.TEST_FOO = 'legacy-value';
42+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
43+
expect(
44+
readEnvWithDeprecation('OS_TEST_FOO', 'TEST_FOO', { silent: true }),
45+
).toBe('legacy-value');
46+
expect(warn).not.toHaveBeenCalled();
47+
});
48+
3949
it('returns undefined and does not warn when neither var is set', () => {
4050
delete process.env.OS_TEST_FOO;
4151
delete process.env.TEST_FOO;

packages/types/src/env.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,17 @@ const _warnedKeys = new Set<string>();
3636
*
3737
* @param preferred Canonical OS_*-prefixed env var name.
3838
* @param legacy Older name (or array of older names) to fall back on.
39+
* @param options Optional behaviour flags. Set `silent: true` for aliases
40+
* that remain accepted conventions rather than true legacy
41+
* names — e.g. `PORT`, which PaaS platforms (Render, Railway,
42+
* Heroku, Fly, …) inject automatically. Warning on those
43+
* would nag operators about env they never set.
3944
* @returns The resolved value, or `undefined` if neither is set.
4045
*/
4146
export function readEnvWithDeprecation(
4247
preferred: string,
4348
legacy: string | readonly string[],
49+
options?: { silent?: boolean },
4450
): string | undefined {
4551
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } })
4652
.process?.env;
@@ -54,7 +60,7 @@ export function readEnvWithDeprecation(
5460
const legacyValue = env[legacyName];
5561
if (legacyValue !== undefined) {
5662
const dedupeKey = `${preferred}|${legacyName}`;
57-
if (!_warnedKeys.has(dedupeKey)) {
63+
if (!options?.silent && !_warnedKeys.has(dedupeKey)) {
5864
_warnedKeys.add(dedupeKey);
5965
const consoleRef = (globalThis as { console?: { warn?: (msg: string) => void } }).console;
6066
try {

0 commit comments

Comments
 (0)