diff --git a/CHANGELOG.md b/CHANGELOG.md index 621093d9b..bd7832d20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **Cold-start `ENOENT: mkdir '/var/task/.objectstack'` on Vercel/Lambda** (`@objectstack/service-cloud`) — `createCloudStack()`, `createRuntimeStack()`, `DefaultProjectKernelFactory`, `DefaultEnvironmentDriverRegistry`, and `ArtifactEnvironmentDriverRegistry` previously hard-coded the SQLite/InMemoryDriver default data directory to `/.objectstack/data`. On serverless platforms (Vercel `/var/task`, AWS Lambda, Netlify) the bundle root is read-only, so `apps/cloud` failed to boot whenever no explicit persistent DB URL was configured. Centralised the resolution in a new `resolveDefaultDataDir()` helper (`packages/services/service-cloud/src/data-dir.ts`) that honours `OS_DATA_DIR`, returns `/.objectstack/data` on writable filesystems, and **throws a fail-fast error on serverless** pointing at `TURSO_DATABASE_URL` (recommended on Vercel — Turso is the default ObjectStack pairing for serverless), `OS_CONTROL_DATABASE_URL`, and `OS_DATA_DIR` (escape hatch for EFS / mounted volumes). File-backed SQLite on serverless `/tmp` is rejected by design because `/tmp` is per-instance and ephemeral, which silently corrupts data across concurrent invocations. The `cloud-stack` control-driver default is now lazy so deployments that set `TURSO_DATABASE_URL` never hit the throw. 15 unit tests cover the precedence, error message contents, and platform detection in `test/data-dir.test.ts`. +- **`@objectstack/studio` Vercel build: `webcrypto` not exported by `mocks/node-polyfills.ts`** — `@objectstack/runtime` imports `webcrypto` from `crypto`, but the studio Vite alias swaps `crypto` for the local node polyfill which did not export it. Added a `webcrypto` shim that proxies to `globalThis.crypto`, restoring the rolldown build. - **`@objectstack/driver-sql` tests failing in CI** — Added `vitest.config.ts` with resolve aliases for `@objectstack/spec/*` subpath exports (`/data`, `/contracts`, `/system`). Without these aliases, vitest could not resolve the source paths at test time, causing all 81 tests to fail with `ERR_MODULE_NOT_FOUND`. - **RLS fail-open across tenants** (`@objectstack/plugin-security`) — A logged-in user with no active organization (e.g. immediately after sign-up, before joining or creating one) was previously seeing every tenant's data on `account`, `sys_member`, `sys_organization`, etc. Multiple compounding bugs were responsible: 1. `RLSCompiler.compileFilter` returned `null` when policies were applicable but none compiled — interpreted by callers as "no RLS configured" → no filter → all rows. **Fix:** introduced exported `RLS_DENY_FILTER` sentinel (`{ id: '__rls_deny__:00000000-0000-0000-0000-000000000000' }`); `compileFilter` now returns it when `policies.length > 0` but every policy expression failed to compile (missing `current_user.*` variable, unsupported expression, etc.). This naturally yields zero rows on every driver without throwing. diff --git a/apps/studio/mocks/node-polyfills.ts b/apps/studio/mocks/node-polyfills.ts index c02500a4a..0fbc8c696 100644 --- a/apps/studio/mocks/node-polyfills.ts +++ b/apps/studio/mocks/node-polyfills.ts @@ -102,3 +102,13 @@ export const rename = async () => {}; export const renameSync = () => {}; export const createHash = () => ({ update: () => ({ digest: () => '' }) }); export const randomUUID = () => '00000000-0000-0000-0000-000000000000'; + +// node:crypto webcrypto polyfill — maps to the browser's native Web Crypto API. +// The runtime package uses `import { webcrypto as crypto } from "crypto"` to access +// SubtleCrypto/getRandomValues; in the browser, globalThis.crypto provides the same surface. +export const webcrypto = + (typeof globalThis !== 'undefined' && (globalThis as any).crypto) || { + subtle: undefined, + getRandomValues: (array: T): T => array, + randomUUID: () => '00000000-0000-0000-0000-000000000000', + }; diff --git a/packages/services/service-cloud/src/artifact-environment-registry.ts b/packages/services/service-cloud/src/artifact-environment-registry.ts index 601f9cbaf..b4c0fcbc1 100644 --- a/packages/services/service-cloud/src/artifact-environment-registry.ts +++ b/packages/services/service-cloud/src/artifact-environment-registry.ts @@ -18,6 +18,7 @@ import type * as Contracts from '@objectstack/spec/contracts'; import type { EnvironmentDriverRegistry } from './environment-registry.js'; import type { ArtifactApiClient, ProjectRuntimeConfig } from './artifact-api-client.js'; +import { resolveDefaultDataDir } from './data-dir.js'; type IDataDriver = Contracts.IDataDriver; @@ -219,7 +220,7 @@ async function createDriver(driverType: string, databaseUrl: string, authToken: const { resolve: resolvePath } = await import('node:path'); const dbName = databaseUrl.replace(/^memory:\/\//, '').trim(); const filePath = dbName - ? resolvePath(process.cwd(), '.objectstack/data/projects', `${dbName}.json`) + ? resolvePath(resolveDefaultDataDir(), 'projects', `${dbName}.json`) : undefined; return new InMemoryDriver({ persistence: filePath ? { type: 'file', path: filePath } : 'file', diff --git a/packages/services/service-cloud/src/cloud-stack.ts b/packages/services/service-cloud/src/cloud-stack.ts index 47cca4cb3..37ffb29b5 100644 --- a/packages/services/service-cloud/src/cloud-stack.ts +++ b/packages/services/service-cloud/src/cloud-stack.ts @@ -20,6 +20,7 @@ import { MultiProjectPlugin } from './multi-project-plugin.js'; import { createControlPlanePlugins } from './control-plane-preset.js'; import { createStudioRuntimeConfigPlugin, createTemplatesRoutePlugin } from './multi-project-plugins.js'; import { createCloudArtifactApiPlugin } from './cloud-artifact-api-plugin.js'; +import { resolveDefaultDataDir } from './data-dir.js'; type IDataDriver = Contracts.IDataDriver; @@ -70,7 +71,11 @@ export async function createCloudStack(config: CloudStackConfig): Promise<{ const { authSecret, baseUrl, - controlDriverUrl = `file:${resolvePath(process.cwd(), '.objectstack/data/control.db')}`, + // NOTE: no eager default here. The file-backed fallback is computed + // lazily below so that serverless deployments which configure + // TURSO_DATABASE_URL / OS_CONTROL_DATABASE_URL never trip the + // resolveDefaultDataDir() throw-on-serverless guard. + controlDriverUrl, controlDriverAuthToken, basePlugins, appBundles, @@ -88,12 +93,19 @@ export async function createCloudStack(config: CloudStackConfig): Promise<{ // 3. OS_DATABASE_URL (legacy alias — only used here when no // higher-priority source is set; reserved // going forward for the project's data DB) - // 4. TURSO_DATABASE_URL (legacy alias) - // 5. file:./.objectstack/data/control.db (default) + // 4. TURSO_DATABASE_URL (legacy alias — recommended on Vercel) + // 5. file:/control.db on writable filesystems. + // On serverless (Vercel / Lambda / Netlify) without any of the above, + // resolveDefaultDataDir() throws with a message pointing at Turso — + // we never silently fall back to ephemeral /tmp SQLite. const explicitControlUrl = process.env.OS_CONTROL_DATABASE_URL?.trim(); const legacyControlUrl = (process.env.OS_DATABASE_URL || process.env.TURSO_DATABASE_URL)?.trim(); + const resolvedControlUrl = explicitControlUrl + || controlDriverUrl + || legacyControlUrl + || `file:${resolvePath(resolveDefaultDataDir(), 'control.db')}`; const controlDriverPromise = buildControlDriver( - explicitControlUrl || controlDriverUrl || legacyControlUrl || `file:${resolvePath(process.cwd(), '.objectstack/data/control.db')}`, + resolvedControlUrl, process.env.OS_CONTROL_DATABASE_AUTH_TOKEN || process.env.OS_DATABASE_AUTH_TOKEN || process.env.TURSO_AUTH_TOKEN || controlDriverAuthToken, ); diff --git a/packages/services/service-cloud/src/data-dir.ts b/packages/services/service-cloud/src/data-dir.ts new file mode 100644 index 000000000..193985ab6 --- /dev/null +++ b/packages/services/service-cloud/src/data-dir.ts @@ -0,0 +1,103 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Default data-directory + serverless-platform detection. + * + * Single source of truth for the on-disk location of the control-plane + * SQLite file (`control.db`), per-project SQLite files, and InMemoryDriver + * persistence JSON files in **non-serverless** deployments. + * + * On serverless platforms with a read-only application bundle (Vercel, + * AWS Lambda, Netlify Functions, Cloudflare Workers Node compat) the + * file-backed default is unsupported — `/var/task` is read-only and + * `/tmp` is per-instance, ephemeral, and not shared between concurrent + * cold starts. Persisting business data there silently corrupts + * deployments. The recommended (and only sensible) default for these + * platforms is **Turso / libSQL** — set `TURSO_DATABASE_URL` (or + * `OS_CONTROL_DATABASE_URL=libsql://…`) and the cloud-stack driver + * factory will pick it up automatically. + * + * Resolution order for {@link resolveDefaultDataDir}: + * + * 1. `OS_DATA_DIR` environment variable (explicit override — wins + * always, even on serverless; intended for self-managed mounts + * such as a network volume or EFS share). + * 2. `/.objectstack/data` on a writable filesystem (the default + * for `objectstack dev`, `objectstack serve`, Docker, bare metal, …). + * 3. **THROWS** on a detected serverless read-only filesystem. The + * error message tells the user exactly which env var to set. + * + * Centralising this logic prevents both + * (a) the "ENOENT: mkdir '/var/task/.objectstack'" cold-start crash, and + * (b) the worse failure mode where an ephemeral `/tmp` SQLite "works" + * for a single cold start and silently loses data on the next one. + */ + +import { resolve as resolvePath } from 'node:path'; + +/** + * Returns `true` when the current process is running on a serverless + * platform whose application bundle is a read-only filesystem and whose + * `/tmp` is per-instance / ephemeral. The set of detected platforms + * intentionally matches the ones where ObjectStack is regularly deployed + * today; new platforms can be added via the `OS_READONLY_FS=1` escape + * hatch. + */ +export function isServerlessReadOnlyFs(env: NodeJS.ProcessEnv = process.env): boolean { + if (env.OS_READONLY_FS && ['1', 'true', 'yes', 'on'].includes(env.OS_READONLY_FS.trim().toLowerCase())) { + return true; + } + // Vercel sets VERCEL=1 in all build & runtime environments. + if (env.VERCEL === '1') return true; + // AWS Lambda & Lambda@Edge. + if (env.AWS_LAMBDA_FUNCTION_NAME) return true; + // Netlify Functions. + if (env.NETLIFY === 'true' || env.NETLIFY_DEV) return true; + return false; +} + +/** + * Build the standard "configure a persistent database" error message + * shown when a file-backed default is requested on serverless. + * @internal + */ +export function buildServerlessPersistenceError(role: 'control' | 'project' = 'control'): Error { + const urlVar = role === 'control' ? 'TURSO_DATABASE_URL (or OS_CONTROL_DATABASE_URL)' : 'OS_DATABASE_URL'; + const tokenVar = role === 'control' ? 'TURSO_AUTH_TOKEN (or OS_CONTROL_DATABASE_AUTH_TOKEN)' : 'OS_DATABASE_AUTH_TOKEN'; + return new Error( + `[objectstack/service-cloud] Detected a serverless read-only filesystem ` + + `(Vercel / AWS Lambda / Netlify) but no persistent database is configured ` + + `for the ${role === 'control' ? 'control plane' : 'project data plane'}. ` + + `Set ${urlVar} to a libsql:// URL (recommended on Vercel — Turso is the ` + + `default ObjectStack pairing for serverless) and ${tokenVar} to the ` + + `matching auth token. ` + + `For self-hosted Postgres / MySQL, set the same variable to a ` + + `postgres:// or mysql:// URL instead. ` + + `If you have a writable persistent mount (EFS, network volume, …), ` + + `set OS_DATA_DIR to its path to opt out of this check. ` + + `File-backed SQLite is rejected on these platforms because /tmp is ` + + `per-instance and ephemeral, which silently corrupts data across ` + + `concurrent invocations.`, + ); +} + +/** + * Resolve the canonical default data directory for SQLite / file-backed + * driver persistence. See module docstring for precedence rules. + * + * Throws on serverless platforms unless `OS_DATA_DIR` is set — see + * {@link buildServerlessPersistenceError} for the rationale. + * + * @param env - Optional process-env override, primarily for tests. + * @returns Absolute filesystem path. Never returns a trailing slash. + */ +export function resolveDefaultDataDir(env: NodeJS.ProcessEnv = process.env): string { + const explicit = env.OS_DATA_DIR?.trim(); + if (explicit) return resolvePath(explicit); + + if (isServerlessReadOnlyFs(env)) { + throw buildServerlessPersistenceError('control'); + } + + return resolvePath(process.cwd(), '.objectstack/data'); +} diff --git a/packages/services/service-cloud/src/environment-registry.ts b/packages/services/service-cloud/src/environment-registry.ts index e8380252f..3d1fb3f9d 100644 --- a/packages/services/service-cloud/src/environment-registry.ts +++ b/packages/services/service-cloud/src/environment-registry.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type * as Contracts from '@objectstack/spec/contracts'; +import { resolveDefaultDataDir } from './data-dir.js'; type IDataDriver = Contracts.IDataDriver; /** @@ -284,7 +285,7 @@ export class DefaultEnvironmentDriverRegistry implements EnvironmentDriverRegist const { resolve: resolvePath } = await import('node:path'); const dbName = databaseUrl.replace(/^memory:\/\//, '').trim(); const filePath = dbName - ? resolvePath(process.cwd(), '.objectstack/data/projects', `${dbName}.json`) + ? resolvePath(resolveDefaultDataDir(), 'projects', `${dbName}.json`) : undefined; return new InMemoryDriver({ persistence: filePath ? { type: 'file', path: filePath } : 'file', diff --git a/packages/services/service-cloud/src/index.ts b/packages/services/service-cloud/src/index.ts index 6473ea75f..b9452fe44 100644 --- a/packages/services/service-cloud/src/index.ts +++ b/packages/services/service-cloud/src/index.ts @@ -4,6 +4,9 @@ export { createCloudStack } from './cloud-stack.js'; export type { CloudStackConfig } from './cloud-stack.js'; +// ── Data-directory resolution ───────────────────────────────────────────────── +export { resolveDefaultDataDir, isServerlessReadOnlyFs } from './data-dir.js'; + // ── Multi-project orchestration ─────────────────────────────────────────────── export { MultiProjectPlugin } from './multi-project-plugin.js'; export type { diff --git a/packages/services/service-cloud/src/project-kernel-factory.ts b/packages/services/service-cloud/src/project-kernel-factory.ts index a339b7633..b4fdecbc9 100644 --- a/packages/services/service-cloud/src/project-kernel-factory.ts +++ b/packages/services/service-cloud/src/project-kernel-factory.ts @@ -7,6 +7,7 @@ import { ControlPlaneProxyDriver } from './control-plane-proxy-driver.js'; import type { ProjectKernelFactory } from './kernel-manager.js'; import type { EnvironmentDriverRegistry, SecretEncryptor } from './environment-registry.js'; import { NoopSecretEncryptor } from './environment-registry.js'; +import { resolveDefaultDataDir } from './data-dir.js'; type IDataDriver = Contracts.IDataDriver; @@ -317,7 +318,7 @@ export class DefaultProjectKernelFactory implements ProjectKernelFactory { const { resolve: resolvePath } = await import('node:path'); const dbName = databaseUrl.replace(/^memory:\/\//, '').trim(); const filePath = dbName - ? resolvePath(process.cwd(), '.objectstack/data/projects', `${dbName}.json`) + ? resolvePath(resolveDefaultDataDir(), 'projects', `${dbName}.json`) : undefined; return new InMemoryDriver({ persistence: filePath ? { type: 'file', path: filePath } : 'file', diff --git a/packages/services/service-cloud/src/runtime-stack.ts b/packages/services/service-cloud/src/runtime-stack.ts index f8e950158..a6fe994ac 100644 --- a/packages/services/service-cloud/src/runtime-stack.ts +++ b/packages/services/service-cloud/src/runtime-stack.ts @@ -33,6 +33,7 @@ import { createSingleProjectPlugin } from './single-project-plugin.js'; import { resolveAuthSecret, resolveBaseUrl } from './boot-env.js'; import type { AppBundleResolver } from './project-kernel-factory.js'; import { createObjectOSStack } from './objectos-stack.js'; +import { resolveDefaultDataDir } from './data-dir.js'; /** * Infer the storage driver type from a database connection-URL scheme. @@ -144,7 +145,7 @@ export async function createRuntimeStack(config?: RuntimeStackConfig): Promise { + it('honours OS_DATA_DIR when set', () => { + const dir = resolveDefaultDataDir({ OS_DATA_DIR: '/custom/path' }); + expect(dir).toBe(resolvePath('/custom/path')); + }); + + it('OS_DATA_DIR wins over serverless detection (escape hatch for EFS / mounted volumes)', () => { + const dir = resolveDefaultDataDir({ OS_DATA_DIR: '/mnt/efs', VERCEL: '1' }); + expect(dir).toBe(resolvePath('/mnt/efs')); + }); + + it('defaults to /.objectstack/data on a writable filesystem', () => { + const dir = resolveDefaultDataDir({}); + expect(dir).toBe(resolvePath(process.cwd(), '.objectstack/data')); + }); + + it('throws on Vercel without OS_DATA_DIR — points at TURSO_DATABASE_URL', () => { + expect(() => resolveDefaultDataDir({ VERCEL: '1' })).toThrowError(/TURSO_DATABASE_URL/); + }); + + it('throws on AWS Lambda without OS_DATA_DIR', () => { + expect(() => resolveDefaultDataDir({ AWS_LAMBDA_FUNCTION_NAME: 'fn' })).toThrowError( + /serverless read-only filesystem/, + ); + }); + + it('throws on Netlify without OS_DATA_DIR', () => { + expect(() => resolveDefaultDataDir({ NETLIFY: 'true' })).toThrowError(/Netlify/); + }); + + it('throws when OS_READONLY_FS=1 escape hatch is set without OS_DATA_DIR', () => { + expect(() => resolveDefaultDataDir({ OS_READONLY_FS: '1' })).toThrowError( + /TURSO_DATABASE_URL/, + ); + }); + + it('error message mentions both URL and auth-token env vars and explains why /tmp is rejected', () => { + try { + resolveDefaultDataDir({ VERCEL: '1' }); + expect.fail('should have thrown'); + } catch (e: any) { + expect(e.message).toMatch(/TURSO_DATABASE_URL/); + expect(e.message).toMatch(/TURSO_AUTH_TOKEN/); + expect(e.message).toMatch(/OS_CONTROL_DATABASE_URL/); + expect(e.message).toMatch(/OS_DATA_DIR/); + expect(e.message).toMatch(/per-instance|ephemeral/); + } + }); +}); + +describe('isServerlessReadOnlyFs', () => { + it('detects Vercel via VERCEL=1', () => { + expect(isServerlessReadOnlyFs({ VERCEL: '1' })).toBe(true); + }); + it('detects AWS Lambda via AWS_LAMBDA_FUNCTION_NAME', () => { + expect(isServerlessReadOnlyFs({ AWS_LAMBDA_FUNCTION_NAME: 'fn' })).toBe(true); + }); + it('detects Netlify via NETLIFY=true', () => { + expect(isServerlessReadOnlyFs({ NETLIFY: 'true' })).toBe(true); + }); + it('returns false for an empty environment', () => { + expect(isServerlessReadOnlyFs({})).toBe(false); + }); + it('respects the OS_READONLY_FS escape hatch', () => { + expect(isServerlessReadOnlyFs({ OS_READONLY_FS: '1' })).toBe(true); + expect(isServerlessReadOnlyFs({ OS_READONLY_FS: 'true' })).toBe(true); + expect(isServerlessReadOnlyFs({ OS_READONLY_FS: '0' })).toBe(false); + }); +}); + +describe('buildServerlessPersistenceError', () => { + it('control-plane variant mentions TURSO_DATABASE_URL', () => { + expect(buildServerlessPersistenceError('control').message).toMatch(/TURSO_DATABASE_URL/); + }); + it('project variant mentions OS_DATABASE_URL', () => { + expect(buildServerlessPersistenceError('project').message).toMatch(/OS_DATABASE_URL/); + }); +}); +