Skip to content

Commit 1bd6f53

Browse files
committed
2 parents b2552d1 + ea10e85 commit 1bd6f53

10 files changed

Lines changed: 231 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
- **Hardcoded `permissions: ['member_default']` removed** from two raw-fallback HTTP entry points (`@objectstack/plugin-hono` `/data/:object` handlers and `@objectstack/rest` multi-kernel path). Both now mirror the canonical link-table lookup in `runtime/src/security/resolve-execution-context.ts`, including matching cross-tenant rows (`organization_id IS NULL`) so platform-admin grants apply regardless of the active organization.
2121

2222
### Fixed
23+
- **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`.
24+
- **`@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.
2325
- **`@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`.
2426
- **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:
2527
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.

apps/studio/mocks/node-polyfills.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,13 @@ export const rename = async () => {};
102102
export const renameSync = () => {};
103103
export const createHash = () => ({ update: () => ({ digest: () => '' }) });
104104
export const randomUUID = () => '00000000-0000-0000-0000-000000000000';
105+
106+
// node:crypto webcrypto polyfill — maps to the browser's native Web Crypto API.
107+
// The runtime package uses `import { webcrypto as crypto } from "crypto"` to access
108+
// SubtleCrypto/getRandomValues; in the browser, globalThis.crypto provides the same surface.
109+
export const webcrypto =
110+
(typeof globalThis !== 'undefined' && (globalThis as any).crypto) || {
111+
subtle: undefined,
112+
getRandomValues: <T extends ArrayBufferView | null>(array: T): T => array,
113+
randomUUID: () => '00000000-0000-0000-0000-000000000000',
114+
};

packages/services/service-cloud/src/artifact-environment-registry.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import type * as Contracts from '@objectstack/spec/contracts';
1919
import type { EnvironmentDriverRegistry } from './environment-registry.js';
2020
import type { ArtifactApiClient, ProjectRuntimeConfig } from './artifact-api-client.js';
21+
import { resolveDefaultDataDir } from './data-dir.js';
2122

2223
type IDataDriver = Contracts.IDataDriver;
2324

@@ -219,7 +220,7 @@ async function createDriver(driverType: string, databaseUrl: string, authToken:
219220
const { resolve: resolvePath } = await import('node:path');
220221
const dbName = databaseUrl.replace(/^memory:\/\//, '').trim();
221222
const filePath = dbName
222-
? resolvePath(process.cwd(), '.objectstack/data/projects', `${dbName}.json`)
223+
? resolvePath(resolveDefaultDataDir(), 'projects', `${dbName}.json`)
223224
: undefined;
224225
return new InMemoryDriver({
225226
persistence: filePath ? { type: 'file', path: filePath } : 'file',

packages/services/service-cloud/src/cloud-stack.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { MultiProjectPlugin } from './multi-project-plugin.js';
2020
import { createControlPlanePlugins } from './control-plane-preset.js';
2121
import { createStudioRuntimeConfigPlugin, createTemplatesRoutePlugin } from './multi-project-plugins.js';
2222
import { createCloudArtifactApiPlugin } from './cloud-artifact-api-plugin.js';
23+
import { resolveDefaultDataDir } from './data-dir.js';
2324

2425
type IDataDriver = Contracts.IDataDriver;
2526

@@ -70,7 +71,11 @@ export async function createCloudStack(config: CloudStackConfig): Promise<{
7071
const {
7172
authSecret,
7273
baseUrl,
73-
controlDriverUrl = `file:${resolvePath(process.cwd(), '.objectstack/data/control.db')}`,
74+
// NOTE: no eager default here. The file-backed fallback is computed
75+
// lazily below so that serverless deployments which configure
76+
// TURSO_DATABASE_URL / OS_CONTROL_DATABASE_URL never trip the
77+
// resolveDefaultDataDir() throw-on-serverless guard.
78+
controlDriverUrl,
7479
controlDriverAuthToken,
7580
basePlugins,
7681
appBundles,
@@ -88,12 +93,19 @@ export async function createCloudStack(config: CloudStackConfig): Promise<{
8893
// 3. OS_DATABASE_URL (legacy alias — only used here when no
8994
// higher-priority source is set; reserved
9095
// going forward for the project's data DB)
91-
// 4. TURSO_DATABASE_URL (legacy alias)
92-
// 5. file:./.objectstack/data/control.db (default)
96+
// 4. TURSO_DATABASE_URL (legacy alias — recommended on Vercel)
97+
// 5. file:<resolveDefaultDataDir()>/control.db on writable filesystems.
98+
// On serverless (Vercel / Lambda / Netlify) without any of the above,
99+
// resolveDefaultDataDir() throws with a message pointing at Turso —
100+
// we never silently fall back to ephemeral /tmp SQLite.
93101
const explicitControlUrl = process.env.OS_CONTROL_DATABASE_URL?.trim();
94102
const legacyControlUrl = (process.env.OS_DATABASE_URL || process.env.TURSO_DATABASE_URL)?.trim();
103+
const resolvedControlUrl = explicitControlUrl
104+
|| controlDriverUrl
105+
|| legacyControlUrl
106+
|| `file:${resolvePath(resolveDefaultDataDir(), 'control.db')}`;
95107
const controlDriverPromise = buildControlDriver(
96-
explicitControlUrl || controlDriverUrl || legacyControlUrl || `file:${resolvePath(process.cwd(), '.objectstack/data/control.db')}`,
108+
resolvedControlUrl,
97109
process.env.OS_CONTROL_DATABASE_AUTH_TOKEN || process.env.OS_DATABASE_AUTH_TOKEN || process.env.TURSO_AUTH_TOKEN || controlDriverAuthToken,
98110
);
99111

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Default data-directory + serverless-platform detection.
5+
*
6+
* Single source of truth for the on-disk location of the control-plane
7+
* SQLite file (`control.db`), per-project SQLite files, and InMemoryDriver
8+
* persistence JSON files in **non-serverless** deployments.
9+
*
10+
* On serverless platforms with a read-only application bundle (Vercel,
11+
* AWS Lambda, Netlify Functions, Cloudflare Workers Node compat) the
12+
* file-backed default is unsupported — `/var/task` is read-only and
13+
* `/tmp` is per-instance, ephemeral, and not shared between concurrent
14+
* cold starts. Persisting business data there silently corrupts
15+
* deployments. The recommended (and only sensible) default for these
16+
* platforms is **Turso / libSQL** — set `TURSO_DATABASE_URL` (or
17+
* `OS_CONTROL_DATABASE_URL=libsql://…`) and the cloud-stack driver
18+
* factory will pick it up automatically.
19+
*
20+
* Resolution order for {@link resolveDefaultDataDir}:
21+
*
22+
* 1. `OS_DATA_DIR` environment variable (explicit override — wins
23+
* always, even on serverless; intended for self-managed mounts
24+
* such as a network volume or EFS share).
25+
* 2. `<cwd>/.objectstack/data` on a writable filesystem (the default
26+
* for `objectstack dev`, `objectstack serve`, Docker, bare metal, …).
27+
* 3. **THROWS** on a detected serverless read-only filesystem. The
28+
* error message tells the user exactly which env var to set.
29+
*
30+
* Centralising this logic prevents both
31+
* (a) the "ENOENT: mkdir '/var/task/.objectstack'" cold-start crash, and
32+
* (b) the worse failure mode where an ephemeral `/tmp` SQLite "works"
33+
* for a single cold start and silently loses data on the next one.
34+
*/
35+
36+
import { resolve as resolvePath } from 'node:path';
37+
38+
/**
39+
* Returns `true` when the current process is running on a serverless
40+
* platform whose application bundle is a read-only filesystem and whose
41+
* `/tmp` is per-instance / ephemeral. The set of detected platforms
42+
* intentionally matches the ones where ObjectStack is regularly deployed
43+
* today; new platforms can be added via the `OS_READONLY_FS=1` escape
44+
* hatch.
45+
*/
46+
export function isServerlessReadOnlyFs(env: NodeJS.ProcessEnv = process.env): boolean {
47+
if (env.OS_READONLY_FS && ['1', 'true', 'yes', 'on'].includes(env.OS_READONLY_FS.trim().toLowerCase())) {
48+
return true;
49+
}
50+
// Vercel sets VERCEL=1 in all build & runtime environments.
51+
if (env.VERCEL === '1') return true;
52+
// AWS Lambda & Lambda@Edge.
53+
if (env.AWS_LAMBDA_FUNCTION_NAME) return true;
54+
// Netlify Functions.
55+
if (env.NETLIFY === 'true' || env.NETLIFY_DEV) return true;
56+
return false;
57+
}
58+
59+
/**
60+
* Build the standard "configure a persistent database" error message
61+
* shown when a file-backed default is requested on serverless.
62+
* @internal
63+
*/
64+
export function buildServerlessPersistenceError(role: 'control' | 'project' = 'control'): Error {
65+
const urlVar = role === 'control' ? 'TURSO_DATABASE_URL (or OS_CONTROL_DATABASE_URL)' : 'OS_DATABASE_URL';
66+
const tokenVar = role === 'control' ? 'TURSO_AUTH_TOKEN (or OS_CONTROL_DATABASE_AUTH_TOKEN)' : 'OS_DATABASE_AUTH_TOKEN';
67+
return new Error(
68+
`[objectstack/service-cloud] Detected a serverless read-only filesystem ` +
69+
`(Vercel / AWS Lambda / Netlify) but no persistent database is configured ` +
70+
`for the ${role === 'control' ? 'control plane' : 'project data plane'}. ` +
71+
`Set ${urlVar} to a libsql:// URL (recommended on Vercel — Turso is the ` +
72+
`default ObjectStack pairing for serverless) and ${tokenVar} to the ` +
73+
`matching auth token. ` +
74+
`For self-hosted Postgres / MySQL, set the same variable to a ` +
75+
`postgres:// or mysql:// URL instead. ` +
76+
`If you have a writable persistent mount (EFS, network volume, …), ` +
77+
`set OS_DATA_DIR to its path to opt out of this check. ` +
78+
`File-backed SQLite is rejected on these platforms because /tmp is ` +
79+
`per-instance and ephemeral, which silently corrupts data across ` +
80+
`concurrent invocations.`,
81+
);
82+
}
83+
84+
/**
85+
* Resolve the canonical default data directory for SQLite / file-backed
86+
* driver persistence. See module docstring for precedence rules.
87+
*
88+
* Throws on serverless platforms unless `OS_DATA_DIR` is set — see
89+
* {@link buildServerlessPersistenceError} for the rationale.
90+
*
91+
* @param env - Optional process-env override, primarily for tests.
92+
* @returns Absolute filesystem path. Never returns a trailing slash.
93+
*/
94+
export function resolveDefaultDataDir(env: NodeJS.ProcessEnv = process.env): string {
95+
const explicit = env.OS_DATA_DIR?.trim();
96+
if (explicit) return resolvePath(explicit);
97+
98+
if (isServerlessReadOnlyFs(env)) {
99+
throw buildServerlessPersistenceError('control');
100+
}
101+
102+
return resolvePath(process.cwd(), '.objectstack/data');
103+
}

packages/services/service-cloud/src/environment-registry.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type * as Contracts from '@objectstack/spec/contracts';
4+
import { resolveDefaultDataDir } from './data-dir.js';
45
type IDataDriver = Contracts.IDataDriver;
56

67
/**
@@ -284,7 +285,7 @@ export class DefaultEnvironmentDriverRegistry implements EnvironmentDriverRegist
284285
const { resolve: resolvePath } = await import('node:path');
285286
const dbName = databaseUrl.replace(/^memory:\/\//, '').trim();
286287
const filePath = dbName
287-
? resolvePath(process.cwd(), '.objectstack/data/projects', `${dbName}.json`)
288+
? resolvePath(resolveDefaultDataDir(), 'projects', `${dbName}.json`)
288289
: undefined;
289290
return new InMemoryDriver({
290291
persistence: filePath ? { type: 'file', path: filePath } : 'file',

packages/services/service-cloud/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
export { createCloudStack } from './cloud-stack.js';
55
export type { CloudStackConfig } from './cloud-stack.js';
66

7+
// ── Data-directory resolution ─────────────────────────────────────────────────
8+
export { resolveDefaultDataDir, isServerlessReadOnlyFs } from './data-dir.js';
9+
710
// ── Multi-project orchestration ───────────────────────────────────────────────
811
export { MultiProjectPlugin } from './multi-project-plugin.js';
912
export type {

packages/services/service-cloud/src/project-kernel-factory.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ControlPlaneProxyDriver } from './control-plane-proxy-driver.js';
77
import type { ProjectKernelFactory } from './kernel-manager.js';
88
import type { EnvironmentDriverRegistry, SecretEncryptor } from './environment-registry.js';
99
import { NoopSecretEncryptor } from './environment-registry.js';
10+
import { resolveDefaultDataDir } from './data-dir.js';
1011

1112
type IDataDriver = Contracts.IDataDriver;
1213

@@ -317,7 +318,7 @@ export class DefaultProjectKernelFactory implements ProjectKernelFactory {
317318
const { resolve: resolvePath } = await import('node:path');
318319
const dbName = databaseUrl.replace(/^memory:\/\//, '').trim();
319320
const filePath = dbName
320-
? resolvePath(process.cwd(), '.objectstack/data/projects', `${dbName}.json`)
321+
? resolvePath(resolveDefaultDataDir(), 'projects', `${dbName}.json`)
321322
: undefined;
322323
return new InMemoryDriver({
323324
persistence: filePath ? { type: 'file', path: filePath } : 'file',

packages/services/service-cloud/src/runtime-stack.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { createSingleProjectPlugin } from './single-project-plugin.js';
3333
import { resolveAuthSecret, resolveBaseUrl } from './boot-env.js';
3434
import type { AppBundleResolver } from './project-kernel-factory.js';
3535
import { createObjectOSStack } from './objectos-stack.js';
36+
import { resolveDefaultDataDir } from './data-dir.js';
3637

3738
/**
3839
* Infer the storage driver type from a database connection-URL scheme.
@@ -144,7 +145,7 @@ export async function createRuntimeStack(config?: RuntimeStackConfig): Promise<R
144145
const artifactPath = cfg.artifactPath
145146
?? process.env.OS_ARTIFACT_PATH
146147
?? resolvePath(cwd, 'dist/objectstack.json');
147-
const dataDir = cfg.dataDir ?? resolvePath(cwd, '.objectstack/data');
148+
const dataDir = cfg.dataDir ?? resolveDefaultDataDir();
148149
mkdirSync(dataDir, { recursive: true });
149150

150151
// Control-plane DB. In single-project local mode this is the framework's
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { resolve as resolvePath } from 'node:path';
5+
import {
6+
resolveDefaultDataDir,
7+
isServerlessReadOnlyFs,
8+
buildServerlessPersistenceError,
9+
} from '../src/data-dir.js';
10+
11+
describe('resolveDefaultDataDir', () => {
12+
it('honours OS_DATA_DIR when set', () => {
13+
const dir = resolveDefaultDataDir({ OS_DATA_DIR: '/custom/path' });
14+
expect(dir).toBe(resolvePath('/custom/path'));
15+
});
16+
17+
it('OS_DATA_DIR wins over serverless detection (escape hatch for EFS / mounted volumes)', () => {
18+
const dir = resolveDefaultDataDir({ OS_DATA_DIR: '/mnt/efs', VERCEL: '1' });
19+
expect(dir).toBe(resolvePath('/mnt/efs'));
20+
});
21+
22+
it('defaults to <cwd>/.objectstack/data on a writable filesystem', () => {
23+
const dir = resolveDefaultDataDir({});
24+
expect(dir).toBe(resolvePath(process.cwd(), '.objectstack/data'));
25+
});
26+
27+
it('throws on Vercel without OS_DATA_DIR — points at TURSO_DATABASE_URL', () => {
28+
expect(() => resolveDefaultDataDir({ VERCEL: '1' })).toThrowError(/TURSO_DATABASE_URL/);
29+
});
30+
31+
it('throws on AWS Lambda without OS_DATA_DIR', () => {
32+
expect(() => resolveDefaultDataDir({ AWS_LAMBDA_FUNCTION_NAME: 'fn' })).toThrowError(
33+
/serverless read-only filesystem/,
34+
);
35+
});
36+
37+
it('throws on Netlify without OS_DATA_DIR', () => {
38+
expect(() => resolveDefaultDataDir({ NETLIFY: 'true' })).toThrowError(/Netlify/);
39+
});
40+
41+
it('throws when OS_READONLY_FS=1 escape hatch is set without OS_DATA_DIR', () => {
42+
expect(() => resolveDefaultDataDir({ OS_READONLY_FS: '1' })).toThrowError(
43+
/TURSO_DATABASE_URL/,
44+
);
45+
});
46+
47+
it('error message mentions both URL and auth-token env vars and explains why /tmp is rejected', () => {
48+
try {
49+
resolveDefaultDataDir({ VERCEL: '1' });
50+
expect.fail('should have thrown');
51+
} catch (e: any) {
52+
expect(e.message).toMatch(/TURSO_DATABASE_URL/);
53+
expect(e.message).toMatch(/TURSO_AUTH_TOKEN/);
54+
expect(e.message).toMatch(/OS_CONTROL_DATABASE_URL/);
55+
expect(e.message).toMatch(/OS_DATA_DIR/);
56+
expect(e.message).toMatch(/per-instance|ephemeral/);
57+
}
58+
});
59+
});
60+
61+
describe('isServerlessReadOnlyFs', () => {
62+
it('detects Vercel via VERCEL=1', () => {
63+
expect(isServerlessReadOnlyFs({ VERCEL: '1' })).toBe(true);
64+
});
65+
it('detects AWS Lambda via AWS_LAMBDA_FUNCTION_NAME', () => {
66+
expect(isServerlessReadOnlyFs({ AWS_LAMBDA_FUNCTION_NAME: 'fn' })).toBe(true);
67+
});
68+
it('detects Netlify via NETLIFY=true', () => {
69+
expect(isServerlessReadOnlyFs({ NETLIFY: 'true' })).toBe(true);
70+
});
71+
it('returns false for an empty environment', () => {
72+
expect(isServerlessReadOnlyFs({})).toBe(false);
73+
});
74+
it('respects the OS_READONLY_FS escape hatch', () => {
75+
expect(isServerlessReadOnlyFs({ OS_READONLY_FS: '1' })).toBe(true);
76+
expect(isServerlessReadOnlyFs({ OS_READONLY_FS: 'true' })).toBe(true);
77+
expect(isServerlessReadOnlyFs({ OS_READONLY_FS: '0' })).toBe(false);
78+
});
79+
});
80+
81+
describe('buildServerlessPersistenceError', () => {
82+
it('control-plane variant mentions TURSO_DATABASE_URL', () => {
83+
expect(buildServerlessPersistenceError('control').message).toMatch(/TURSO_DATABASE_URL/);
84+
});
85+
it('project variant mentions OS_DATABASE_URL', () => {
86+
expect(buildServerlessPersistenceError('project').message).toMatch(/OS_DATABASE_URL/);
87+
});
88+
});
89+

0 commit comments

Comments
 (0)