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
2 changes: 1 addition & 1 deletion packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ export default class Dev extends Command {
printKV('Artifact', isUrl ? artifactPath : path.relative(process.cwd(), artifactPath), '📦');
if (effectiveDb) printKV('Database', redactDbUrl(effectiveDb), '🗄️');

const port = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT');
const port = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true });
const binPath = process.argv[1];
const serveChild = spawn(
process.execPath,
Expand Down
69 changes: 53 additions & 16 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export default class Serve extends Command {
};

static override flags = {
port: Flags.string({ char: 'p', description: 'Server port', default: readEnvWithDeprecation('OS_PORT', 'PORT') ?? '3000' }),
port: Flags.string({ char: 'p', description: 'Server port', default: readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true }) ?? '3000' }),
dev: Flags.boolean({ description: 'Run in development mode (load devPlugins)' }),
ui: Flags.boolean({ description: 'Enable the bundled Console portal at /_console/ when @object-ui/console is installed (default: true)', default: true, allowNo: true }),
console: Flags.boolean({
Expand Down Expand Up @@ -641,16 +641,21 @@ export default class Serve extends Command {
resolvedDriverLabel = 'SqlDriver(mysql2)';
resolvedDatabaseUrl = databaseUrl;
} else if (isDev) {
// Default in dev: prefer SQLite for production-like SQL semantics,
// falling back to the pure-JS in-memory driver (mingo) only when the
// native `better-sqlite3` binary is unavailable — not built, ABI
// mismatch after a Node upgrade, or a blocked prebuild download.
// Default in dev: prefer native SQLite for production-like SQL
// semantics at native speed. When the native `better-sqlite3`
// binary is unavailable — not built, ABI mismatch after a Node
// upgrade (e.g. Node 25 → NODE_MODULE_VERSION mismatch), or a
// blocked prebuild download — fall back to the pure-JS wasm SQLite
// driver, which keeps *real* SQL semantics (and on-disk
// persistence) without any native build step. Only if wasm also
// fails to load do we drop to the in-memory driver (mingo), which
// is neither real SQL nor persistent.
//
// knex loads its client lazily (at first query, not at construction),
// so the only reliable signal inside this registration window is to
// actually open a connection: connect() runs `SELECT 1`, which forces
// better-sqlite3 to load. If that throws we drop to the in-memory
// driver instead of letting the failure surface much later — as a
// better-sqlite3 to load. If that throws we step down the chain here
// instead of letting the failure surface much later — as a
// missing-module crash on the first real query — or be swallowed by
// the silent catch below, leaving the kernel with no driver at all.
let sqliteDriver: any;
Expand All @@ -677,15 +682,47 @@ export default class Serve extends Command {
resolvedDriverLabel = 'SqlDriver(sqlite)';
resolvedDatabaseUrl = ':memory:';
} else {
const { InMemoryDriver } = await import('@objectstack/driver-memory');
await kernel.use(new DriverPlugin(new InMemoryDriver()));
trackPlugin('MemoryDriver');
resolvedDriverLabel = 'InMemoryDriver';
resolvedDatabaseUrl = '(in-memory)';
console.warn(chalk.yellow(
' ⚠ better-sqlite3 unavailable — dev falling back to InMemoryDriver (mingo, not real SQL).\n' +
' Rebuild better-sqlite3, or set OS_DATABASE_URL / OS_DATABASE_DRIVER for SQL fidelity.'
));
// Native unavailable → try the pure-JS wasm SQLite driver before
// giving up on SQL fidelity entirely. Same probe-by-connect
// approach: actually open the connection so a load failure is
// caught here rather than on the first real query.
let wasmDriver: any;
let wasmOk = false;
try {
const { SqliteWasmDriver } = await import('@objectstack/driver-sqlite-wasm');
wasmDriver = new SqliteWasmDriver({
filename: ':memory:',
persist: 'on-disconnect',
});
await wasmDriver.connect();
wasmOk = true;
} catch {
wasmOk = false;
if (wasmDriver?.disconnect) {
try { await wasmDriver.disconnect(); } catch { /* ignore */ }
}
}

if (wasmOk) {
await kernel.use(new DriverPlugin(wasmDriver));
trackPlugin('SqliteWasmDriver');
resolvedDriverLabel = 'SqliteWasmDriver';
resolvedDatabaseUrl = ':memory:';
console.warn(chalk.yellow(
' ⚠ native better-sqlite3 unavailable (ABI mismatch or not built) — dev using wasm SQLite (real SQL, slower).\n' +
' Rebuild better-sqlite3 for native speed, or set OS_DATABASE_DRIVER=sqlite-wasm to silence this.'
));
} else {
const { InMemoryDriver } = await import('@objectstack/driver-memory');
await kernel.use(new DriverPlugin(new InMemoryDriver()));
trackPlugin('MemoryDriver');
resolvedDriverLabel = 'InMemoryDriver';
resolvedDatabaseUrl = '(in-memory)';
console.warn(chalk.yellow(
' ⚠ neither native nor wasm SQLite available — dev falling back to InMemoryDriver (mingo, not real SQL).\n' +
' Rebuild better-sqlite3, or set OS_DATABASE_URL / OS_DATABASE_DRIVER for SQL fidelity.'
));
}
}
}
} catch (e: any) {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ export default class Start extends Command {
// Resolve the port the child `serve` will actually bind, matching its
// flag default (`--port` > $OS_PORT/$PORT > 3000). Using `flags.port`
// alone printed the wrong URL whenever the port came from the env.
const bannerPort = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT') ?? 3000;
const bannerPort = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true }) ?? 3000;
if (flags.ui) printKV('Console', `http://localhost:${bannerPort}/_console/`, '🖥️');

printStep('Starting server...');
Expand Down
74 changes: 74 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,80 @@ describe('AuthManager', () => {
});
});

describe('handleRequest – dev Origin injection (CSRF DX)', () => {
const ORIGINAL_NODE_ENV = process.env.NODE_ENV;

afterEach(() => {
if (ORIGINAL_NODE_ENV === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = ORIGINAL_NODE_ENV;
});

const makeManager = () => {
const captured: { request?: Request } = {};
const mockHandler = vi.fn().mockImplementation((req: Request) => {
captured.request = req;
return new Response(null, { status: 200 });
});
(betterAuth as any).mockReturnValue({ handler: mockHandler, api: {} });
const manager = new AuthManager({
secret: 'test-secret-at-least-32-chars-long',
baseUrl: 'http://localhost:3000',
});
return { manager, captured };
};

it('injects a same-origin Origin header in dev when none is present', async () => {
process.env.NODE_ENV = 'development';
const { manager, captured } = makeManager();

await manager.handleRequest(new Request('http://localhost:3000/sign-in/email', {
method: 'POST',
body: JSON.stringify({ email: 'a@b.com', password: 'pass' }),
headers: { 'Content-Type': 'application/json' },
}));

expect(captured.request?.headers.get('origin')).toBe('http://localhost:3000');
});

it('does NOT inject an Origin header in production', async () => {
process.env.NODE_ENV = 'production';
const { manager, captured } = makeManager();

await manager.handleRequest(new Request('http://localhost:3000/sign-in/email', {
method: 'POST',
body: JSON.stringify({ email: 'a@b.com', password: 'pass' }),
headers: { 'Content-Type': 'application/json' },
}));

expect(captured.request?.headers.get('origin')).toBeNull();
});

it('preserves a caller-supplied Origin header in dev', async () => {
process.env.NODE_ENV = 'development';
const { manager, captured } = makeManager();

await manager.handleRequest(new Request('http://localhost:3000/sign-in/email', {
method: 'POST',
body: JSON.stringify({ email: 'a@b.com', password: 'pass' }),
headers: { 'Content-Type': 'application/json', origin: 'http://example.test' },
}));

expect(captured.request?.headers.get('origin')).toBe('http://example.test');
});

it('does not inject when a Referer is already present in dev', async () => {
process.env.NODE_ENV = 'development';
const { manager, captured } = makeManager();

await manager.handleRequest(new Request('http://localhost:3000/sign-in/email', {
method: 'POST',
headers: { referer: 'http://localhost:3000/login' },
}));

expect(captured.request?.headers.get('origin')).toBeNull();
});
});

describe('createDatabaseConfig – adapter wrapping', () => {
it('should pass a function (AdapterFactory) to betterAuth when dataEngine is provided', () => {
const mockDataEngine = {
Expand Down
24 changes: 24 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1221,6 +1221,30 @@ export class AuthManager {
* @returns Web standard Response object
*/
async handleRequest(request: Request): Promise<Response> {
// Dev DX: better-auth's CSRF protection rejects state-changing requests
// (e.g. `/sign-in/email`) with a 403 when neither `Origin` nor `Referer`
// is present. Browsers always send one; non-browser API clients (curl,
// fetch from a script, integration tests) often don't, so they hit an
// opaque 403 in local dev. A request with *no* Origin header is never a
// browser-driven cross-site attack — CSRF is fundamentally cross-origin —
// so in non-production we synthesize a same-origin `Origin` from the
// request URL. It matches the dev localhost trusted-origins set, so the
// CSRF check passes without weakening protection in production (gated on
// NODE_ENV, the same dev signal used for the fallback auth secret above).
if (
process.env.NODE_ENV !== 'production' &&
!request.headers.get('origin') &&
!request.headers.get('referer')
) {
try {
const headers = new Headers(request.headers);
headers.set('origin', new URL(request.url).origin);
request = new Request(request, { headers });
} catch {
/* malformed URL — leave the request untouched */
}
}

const auth = await this.getOrCreateAuth();
// better-auth's HTTP entrypoint (`createBetterAuth.handler`) wraps execution
// in `runWithAdapter` but NOT `runWithRequestState`. Endpoints that read
Expand Down
10 changes: 10 additions & 0 deletions packages/types/src/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ describe('readEnvWithDeprecation', () => {
expect(String(warn.mock.calls[0][0])).toContain('deprecated');
});

it('returns the legacy value without warning when silent is set', () => {
delete process.env.OS_TEST_FOO;
process.env.TEST_FOO = 'legacy-value';
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(
readEnvWithDeprecation('OS_TEST_FOO', 'TEST_FOO', { silent: true }),
).toBe('legacy-value');
expect(warn).not.toHaveBeenCalled();
});

it('returns undefined and does not warn when neither var is set', () => {
delete process.env.OS_TEST_FOO;
delete process.env.TEST_FOO;
Expand Down
8 changes: 7 additions & 1 deletion packages/types/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,17 @@ const _warnedKeys = new Set<string>();
*
* @param preferred Canonical OS_*-prefixed env var name.
* @param legacy Older name (or array of older names) to fall back on.
* @param options Optional behaviour flags. Set `silent: true` for aliases
* that remain accepted conventions rather than true legacy
* names — e.g. `PORT`, which PaaS platforms (Render, Railway,
* Heroku, Fly, …) inject automatically. Warning on those
* would nag operators about env they never set.
* @returns The resolved value, or `undefined` if neither is set.
*/
export function readEnvWithDeprecation(
preferred: string,
legacy: string | readonly string[],
options?: { silent?: boolean },
): string | undefined {
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } })
.process?.env;
Expand All @@ -54,7 +60,7 @@ export function readEnvWithDeprecation(
const legacyValue = env[legacyName];
if (legacyValue !== undefined) {
const dedupeKey = `${preferred}|${legacyName}`;
if (!_warnedKeys.has(dedupeKey)) {
if (!options?.silent && !_warnedKeys.has(dedupeKey)) {
_warnedKeys.add(dedupeKey);
const consoleRef = (globalThis as { console?: { warn?: (msg: string) => void } }).console;
try {
Expand Down
Loading