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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<process.cwd()>/.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 `<cwd>/.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.
Expand Down
10 changes: 10 additions & 0 deletions apps/studio/mocks/node-polyfills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <T extends ArrayBufferView | null>(array: T): T => array,
randomUUID: () => '00000000-0000-0000-0000-000000000000',
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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',
Expand Down
20 changes: 16 additions & 4 deletions packages/services/service-cloud/src/cloud-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand All @@ -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:<resolveDefaultDataDir()>/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,
);

Expand Down
103 changes: 103 additions & 0 deletions packages/services/service-cloud/src/data-dir.ts
Original file line number Diff line number Diff line change
@@ -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. `<cwd>/.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');
}
3 changes: 2 additions & 1 deletion packages/services/service-cloud/src/environment-registry.ts
Original file line number Diff line number Diff line change
@@ -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;

/**
Expand Down Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions packages/services/service-cloud/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion packages/services/service-cloud/src/runtime-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -144,7 +145,7 @@ export async function createRuntimeStack(config?: RuntimeStackConfig): Promise<R
const artifactPath = cfg.artifactPath
?? process.env.OS_ARTIFACT_PATH
?? resolvePath(cwd, 'dist/objectstack.json');
const dataDir = cfg.dataDir ?? resolvePath(cwd, '.objectstack/data');
const dataDir = cfg.dataDir ?? resolveDefaultDataDir();
mkdirSync(dataDir, { recursive: true });

// Control-plane DB. In single-project local mode this is the framework's
Expand Down
89 changes: 89 additions & 0 deletions packages/services/service-cloud/test/data-dir.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { resolve as resolvePath } from 'node:path';
import {
resolveDefaultDataDir,
isServerlessReadOnlyFs,
buildServerlessPersistenceError,
} from '../src/data-dir.js';

describe('resolveDefaultDataDir', () => {
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 <cwd>/.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/);
});
});

Loading