diff --git a/.changeset/formula-add-days-months.md b/.changeset/formula-add-days-months.md new file mode 100644 index 0000000000..87733caef9 --- /dev/null +++ b/.changeset/formula-add-days-months.md @@ -0,0 +1,5 @@ +--- +"@objectstack/formula": minor +--- + +Add `addDays(date, n)` and `addMonths(date, n)` to the CEL standard library — shift an arbitrary date by a (possibly negative) number of days or months. Unlike `daysFromNow`, these operate on a *given* date (the "next service date = last service + cycle" shape). `addMonths` clamps to the target month's last day (`addMonths(date('2026-01-31'), 1)` → Feb 28, never overflowing into March). Both coerce their inputs (Date | ISO string | epoch) and type `n` as `dyn` so a record number field arriving as a `double` doesn't fault `no such overload` (#1928). diff --git a/.changeset/rls-converge-docs-dynamic-membership.md b/.changeset/rls-converge-docs-dynamic-membership.md new file mode 100644 index 0000000000..2d3d52f6f0 --- /dev/null +++ b/.changeset/rls-converge-docs-dynamic-membership.md @@ -0,0 +1,10 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-security": minor +--- + +Converge the RLS contract with the reference compiler, and wire §7.3.1 dynamic membership. + +- **spec (docs)**: narrow `rls.zod.ts` to the four expression forms the compiler actually implements — `field = current_user.`, `field = 'literal'`, `field IN (current_user.)`, and `1 = 1`. Removed the over-promised surface (subqueries, `AND`/`OR`/`NOT`, `LIKE`/`ILIKE`, regex, `ANY`/`ALL`, `NOT IN`, `IS NULL`, `NOW()`/`CURRENT_DATE`) from the operator list, context-variable list, and `@example` policies, and documented the fail-closed behaviour explicitly. +- **spec (schema)**: `ExecutionContext` gains `rlsMembership?: Record` — a bag of pre-resolved dynamic-membership id arrays (team members, territory accounts, shared records) that the runtime stages so RLS can scope via `field IN (current_user.)` without subquery support. Generalizes the previously hard-coded `org_user_ids`. +- **plugin-security**: `RLSCompiler.compileFilter` merges `rlsMembership` keys into the user context (arrays only, never clobbering the named `id`/`organization_id`/`roles`/`org_user_ids` fields), so §7.3.1 hierarchy- and sharing-based policies compile. `compileExpression` now recognizes `1 = 1` as always-true (empty filter), making `RLS.allowAllPolicy` grant access instead of silently failing closed. Missing/empty membership sets still fail closed. diff --git a/.changeset/sandbox-ctx-api-transaction.md b/.changeset/sandbox-ctx-api-transaction.md new file mode 100644 index 0000000000..8fb7d26e51 --- /dev/null +++ b/.changeset/sandbox-ctx-api-transaction.md @@ -0,0 +1,11 @@ +--- +"@objectstack/spec": minor +"@objectstack/objectql": minor +"@objectstack/runtime": minor +--- + +Add a transaction boundary to sandboxed hook/action bodies: `ctx.api.transaction(async () => { … })`. Every `ctx.api` read/write inside the callback runs in one driver transaction — committed when the callback returns, rolled back if it throws (or if the body leaves the transaction open at timeout). Guarded by the new `api.transaction` capability. + +- **spec**: new `api.transaction` capability token on `HookBodyCapability`. +- **objectql**: `ScopedContext` gains discrete `beginTransaction()` / `commitTransaction(handle)` / `rollbackTransaction(handle)` primitives. The handle is threaded **explicitly** through a child context (`resolveTx` honors it ahead of the ambient `txStore`), because the sandbox drives the body across many host event-loop turns where AsyncLocalStorage context does not survive. Degrades to non-transactional execution when the driver has no transaction support. +- **runtime**: the QuickJS runner wires `ctx.api.transaction` over three deferred-promise host leaves (begin/commit/rollback), routes in-transaction ops through the tx-scoped context, and rolls back a transaction the body left open before disposing the VM. diff --git a/packages/formula/src/cel-engine.test.ts b/packages/formula/src/cel-engine.test.ts index 2a7bf275ea..3cbdc4c60a 100644 --- a/packages/formula/src/cel-engine.test.ts +++ b/packages/formula/src/cel-engine.test.ts @@ -49,6 +49,30 @@ describe('celEngine', () => { if (r.ok) expect((r.value as Date).toISOString()).toBe('2026-02-14T00:00:00.000Z'); }); + it('addDays(date, n) shifts an arbitrary date — the "next service date" shape', () => { + // 下次维保日期 = 上次维保 + 周期天数. Operates on record.date, not now(). + const r = celEngine.evaluate(cel('addDays(record.last_service, record.cycle_days)'), { + record: { last_service: '2026-06-18T00:00:00Z', cycle_days: 90 }, + }); + expect(r.ok).toBe(true); + if (r.ok) expect((r.value as Date).toISOString()).toBe('2026-09-16T00:00:00.000Z'); + }); + + it('addDays accepts a negative offset', () => { + const r = celEngine.evaluate(cel("addDays(date('2026-03-01'), -1)"), {}); + expect(r.ok && (r.value as Date).toISOString()).toBe('2026-02-28T00:00:00.000Z'); + }); + + it('addMonths clamps to the target month\'s last day (Jan 31 + 1mo → Feb 28)', () => { + const r = celEngine.evaluate(cel("addMonths(date('2026-01-31'), 1)"), {}); + expect(r.ok && (r.value as Date).toISOString()).toBe('2026-02-28T00:00:00.000Z'); + }); + + it('addMonths handles year roll-over and the common 6-month cycle', () => { + const r = celEngine.evaluate(cel("addMonths(date('2026-09-30'), 6)"), {}); + expect(r.ok && (r.value as Date).toISOString()).toBe('2027-03-30T00:00:00.000Z'); + }); + it('today()/daysFromNow()/daysAgo() resolve the calendar day in the reference timezone', () => { // 2026-01-15T02:00Z is still Jan 14 in America/New_York (UTC-5). const pinned = new Date('2026-01-15T02:00:00Z'); @@ -368,6 +392,7 @@ describe('celEngine', () => { const call: Record = { now: 'now()', today: 'today()', daysFromNow: 'daysFromNow(30)', daysAgo: 'daysAgo(7)', daysBetween: 'daysBetween(today(), daysFromNow(7))', date: 'date("2026-03-15")', + addDays: 'addDays(today(), 7)', addMonths: 'addMonths(today(), 3)', datetime: 'datetime("2026-03-15T08:00:00Z")', abs: 'abs(-3.5)', round: 'round(2.6)', min: 'min(1, 2)', max: 'max(1, 2)', upper: 'upper("hi")', lower: 'lower("HI")', trim: 'trim(" x ")', contains: 'contains("hello", "ell")', startsWith: 'startsWith("hi", "h")', diff --git a/packages/formula/src/stdlib.ts b/packages/formula/src/stdlib.ts index 88540ae4f4..b409ef2e46 100644 --- a/packages/formula/src/stdlib.ts +++ b/packages/formula/src/stdlib.ts @@ -73,6 +73,22 @@ function addDaysUtc(d: Date, n: number): Date { return out; } +/** + * Add `n` calendar months to a Date in UTC; returns a new Date. Clamps the day + * to the target month's last day so `addMonths(date('2026-01-31'), 1)` yields + * Feb 28, never an overflow into March — matching how authors expect a + * "next service date = last + N months" rule to behave. + */ +function addMonthsUtc(d: Date, n: number): Date { + const out = new Date(d.getTime()); + const day = out.getUTCDate(); + out.setUTCDate(1); // avoid roll-over while shifting the month + out.setUTCMonth(out.getUTCMonth() + n); + const lastDay = new Date(Date.UTC(out.getUTCFullYear(), out.getUTCMonth() + 1, 0)).getUTCDate(); + out.setUTCDate(Math.min(day, lastDay)); + return out; +} + /** * Register the ObjectStack standard library into a CEL environment. * @@ -161,6 +177,22 @@ export function registerStdLib( (a: unknown, b: unknown) => BigInt(Math.round((toDate(b).getTime() - toDate(a).getTime()) / MS_PER_DAY)), ) + // Shift an arbitrary date by a (possibly negative) number of days/months. + // Unlike `daysFromNow`, these operate on a *given* date — the shape behind + // "next service date = last service + cycle". Args are coerced (Date | ISO + // string | epoch) so a `Field.date` arriving as a string works directly. + // `addMonths` clamps to the target month's last day (Jan 31 +1mo → Feb 28). + // `n` is typed `dyn` (not `int`): a record number field arrives as cel-js + // `double`, so an `int` overload would fault `no such overload` (#1928). + // We coerce defensively with `Number(...)`. + .registerFunction( + 'addDays(dyn, dyn): google.protobuf.Timestamp', + (d: unknown, n: unknown) => addDaysUtc(toDate(d), Math.trunc(Number(n))), + ) + .registerFunction( + 'addMonths(dyn, dyn): google.protobuf.Timestamp', + (d: unknown, n: unknown) => addMonthsUtc(toDate(d), Math.trunc(Number(n))), + ) // Parse an ISO date / date-time string to a Timestamp. `date` and `datetime` // are aliases — both accept either form (the field's own type decides the // intent); kept distinct because authors reach for whichever reads clearer. diff --git a/packages/formula/src/validate.ts b/packages/formula/src/validate.ts index 5bd9f68763..1781685849 100644 --- a/packages/formula/src/validate.ts +++ b/packages/formula/src/validate.ts @@ -264,7 +264,7 @@ export function introspectScope(role: FieldRole, schema?: ExprSchemaHint): { */ export const CEL_STDLIB_FUNCTIONS: string[] = [ // Dates (registered stdlib) - 'now', 'today', 'daysFromNow', 'daysAgo', 'daysBetween', 'date', 'datetime', + 'now', 'today', 'daysFromNow', 'daysAgo', 'daysBetween', 'addDays', 'addMonths', 'date', 'datetime', // Numbers (registered stdlib) 'abs', 'round', 'min', 'max', // Strings (registered stdlib) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index f919f98e2c..cce5745ab2 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -3081,6 +3081,64 @@ export class ScopedContext { } } + /** + * Resolve the default driver, if it exposes transaction primitives. + * Shared by {@link transaction} and the discrete begin/commit/rollback trio. + */ + private txDriver(): any | undefined { + const engine = this.engine as any; + const driver = engine.defaultDriver + ? engine.drivers?.get(engine.defaultDriver) + : undefined; + return driver?.beginTransaction ? driver : undefined; + } + + /** + * Discrete transaction primitives — `begin` / `commit` / `rollback` as three + * separate calls, in contrast to {@link transaction}'s single-callback form. + * + * This trio exists for callers that cannot keep a JS closure on the stack for + * the lifetime of the transaction — chiefly the sandbox runner, where the + * hook/action body's `ctx.api.transaction(fn)` is driven across many host + * event-loop turns via deferred promises. Across those `setImmediate` + * boundaries the engine's ambient `txStore` (AsyncLocalStorage) does NOT + * survive, so the transaction handle is threaded **explicitly**: `begin` + * returns a child ScopedContext carrying `transaction: trx` in its execution + * context, and `resolveTx` honors that explicit handle ahead of the ambient + * store. Every `object(...)` op on the returned context therefore reuses the + * one connection without relying on ALS. + * + * Returns `null` when the driver has no transaction support — the caller then + * runs non-transactionally against `this` (same graceful degrade as + * {@link transaction}). + */ + async beginTransaction(): Promise<{ ctx: ScopedContext; handle: unknown } | null> { + const driver = this.txDriver(); + if (!driver) return null; + const trx = await driver.beginTransaction(); + const ctx = new ScopedContext( + { ...this.executionContext, transaction: trx }, + this.engine + ); + return { ctx, handle: trx }; + } + + /** Commit a handle obtained from {@link beginTransaction}. */ + async commitTransaction(handle: unknown): Promise { + const driver = this.txDriver(); + if (!driver) return; + if (driver.commit) await driver.commit(handle); + else if (driver.commitTransaction) await driver.commitTransaction(handle); + } + + /** Roll back a handle obtained from {@link beginTransaction}. */ + async rollbackTransaction(handle: unknown): Promise { + const driver = this.txDriver(); + if (!driver) return; + if (driver.rollback) await driver.rollback(handle); + else if (driver.rollbackTransaction) await driver.rollbackTransaction(handle); + } + get userId() { return this.executionContext.userId; } get tenantId() { return this.executionContext.tenantId; } /** Alias for tenantId — matches ObjectQLContext.spaceId convention */ diff --git a/packages/plugins/plugin-security/src/rls-compiler.ts b/packages/plugins/plugin-security/src/rls-compiler.ts index b3b7c77b53..6a2c840f15 100644 --- a/packages/plugins/plugin-security/src/rls-compiler.ts +++ b/packages/plugins/plugin-security/src/rls-compiler.ts @@ -23,7 +23,9 @@ interface RLSUserContext { * current user (incl. self). Pre-resolved by the runtime so RLS can * scope identity tables like `sys_user` via * `id IN (current_user.org_user_ids)` without needing subquery - * support in the compiler. + * support in the compiler. This is the one well-known membership set; + * arbitrary §7.3.1 sets arrive via `ExecutionContext.rlsMembership` + * and are merged in under their own keys (see {@link RLSCompiler.compileFilter}). */ org_user_ids?: string[]; [key: string]: unknown; @@ -81,6 +83,21 @@ export class RLSCompiler { org_user_ids: (executionContext as any)?.org_user_ids, }; + // §7.3.1 dynamic membership: the runtime pre-resolves arbitrary + // set-membership (team members, territory accounts, shared records) + // into `ExecutionContext.rlsMembership`. Merge each set under its key + // so `field IN (current_user.)` resolves without subquery support. + // Arrays only; a missing/empty set still fails closed downstream. + // We never let a membership key clobber the named fields above. + const membership = (executionContext as any)?.rlsMembership; + if (membership && typeof membership === 'object') { + for (const [key, value] of Object.entries(membership)) { + if (Array.isArray(value) && userCtx[key] === undefined) { + userCtx[key] = value; + } + } + } + const filters: Record[] = []; for (const policy of policies) { @@ -107,11 +124,18 @@ export class RLSCompiler { /** * Compile a single RLS expression into a query filter. - * - * Supports simple expressions like: - * - "field_name = current_user.property" - * - "field_name IN (current_user.array_property)" - * - "field_name = 'literal_value'" + * + * This reference compiler recognizes exactly four forms — anything else + * returns `null` and (via {@link compileFilter}) fails closed: + * - `field = current_user.property` → `{ field: }` + * - `field = 'literal_value'` → `{ field: 'literal_value' }` + * - `field IN (current_user.array)` → `{ field: { $in: [...] } }` + * (the array may be a §7.3.1 pre-resolved membership set) + * - `1 = 1` → `{}` (always-true / no restriction) + * + * There is intentionally no support for subqueries, `LIKE`/`ILIKE`, + * regex, `ANY`/`ALL`, `AND`/`OR`/`NOT`, or `NULL` checks — express those + * needs as a `current_user.*` property the runtime pre-resolves instead. */ compileExpression( expression: string, @@ -119,6 +143,14 @@ export class RLSCompiler { ): Record | null { if (!expression) return null; + // Always-true literal: "1 = 1" → no restriction (match every row). + // Lets RLS.allowAllPolicy ('1 = 1' for privileged roles) grant access + // instead of silently failing closed. An empty filter AND's onto the + // caller's where clause as a no-op. + if (/^\s*1\s*=\s*1\s*$/.test(expression)) { + return {}; + } + // Handle simple equality: "field = current_user.property" const eqMatch = expression.match(/^\s*(\w+)\s*=\s*current_user\.(\w+)\s*$/); if (eqMatch) { diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 2380c0b80d..acfa994f7a 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -6,6 +6,7 @@ import { PermissionEvaluator } from './permission-evaluator.js'; import { FieldMasker } from './field-masker.js'; import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; import type { PermissionSet } from '@objectstack/spec/security'; +import { RLS } from '@objectstack/spec/security'; // --------------------------------------------------------------------------- // SecurityPlugin – basic metadata @@ -1096,4 +1097,76 @@ describe('RLSCompiler', () => { const contactFind = compiler.getApplicablePolicies('contact', 'find', policies); expect(contactFind).toHaveLength(2); // contact all + * all }); + + // §7.3.1 dynamic membership — arbitrary pre-resolved sets in rlsMembership + it('should resolve IN against a §7.3.1 pre-resolved rlsMembership set', () => { + // Manager hierarchy: the runtime resolved the manager's reports into + // ctx.rlsMembership.team_member_ids. The compiler merges that bag into + // the user context so `IN (current_user.team_member_ids)` resolves + // without any subquery support. + const compiler = new RLSCompiler(); + const policy: any = { + object: 'task', + operation: 'select', + using: 'assigned_to_id IN (current_user.team_member_ids)', + }; + const ctx: any = { + userId: 'mgr-1', + tenantId: 'org-1', + roles: ['manager'], + rlsMembership: { team_member_ids: ['u2', 'u3', 'u4'] }, + }; + const filter = compiler.compileFilter([policy], ctx); + expect(filter).toEqual({ assigned_to_id: { $in: ['u2', 'u3', 'u4'] } }); + }); + + it('should fail-closed when a §7.3.1 membership set is empty', () => { + // A manager with no reports → empty team_member_ids → the IN policy + // drops out → sole policy → deny sentinel (zero rows). Never fail-open. + const compiler = new RLSCompiler(); + const policy: any = { + object: 'task', + operation: 'select', + using: 'assigned_to_id IN (current_user.team_member_ids)', + }; + const ctx: any = { userId: 'mgr-1', tenantId: 'org-1', roles: [], rlsMembership: { team_member_ids: [] } }; + const filter = compiler.compileFilter([policy], ctx); + expect(filter).toEqual(RLS_DENY_FILTER); + }); + + it('should not let a rlsMembership key clobber a named context field', () => { + // roles is a first-class field; a hostile/misconfigured membership bag + // must not override it. The named `roles` (['real-role']) wins; the + // policy compiles against it, not the injected ['spoofed']. + const compiler = new RLSCompiler(); + const policy: any = { object: 'x', operation: 'select', using: 'role_id IN (current_user.roles)' }; + const ctx: any = { + userId: 'u1', + tenantId: 'org-1', + roles: ['real-role'], + rlsMembership: { roles: ['spoofed'] }, + }; + const filter = compiler.compileFilter([policy], ctx); + expect(filter).toEqual({ role_id: { $in: ['real-role'] } }); + }); + + // Always-true literal — makes RLS.allowAllPolicy grant access instead of + // silently failing closed. + it('should compile "1 = 1" to an unrestricted (empty) filter', () => { + const compiler = new RLSCompiler(); + const policy: any = { object: 'account', operation: 'all', using: '1 = 1', roles: ['ceo'] }; + const ctx: any = { userId: 'u1', tenantId: 'org-1', roles: ['ceo'] }; + const filter = compiler.compileFilter([policy], ctx); + expect(filter).toEqual({}); // matches every row + }); + + it('should grant allow-all via RLS.allowAllPolicy instead of denying', () => { + // Regression guard: '1 = 1' used to be unsupported → deny sentinel, + // making the helper do the opposite of its name. Now it grants. + const compiler = new RLSCompiler(); + const policy = RLS.allowAllPolicy('account', ['ceo', 'cfo']) as any; + const filter = compiler.compileFilter([policy]); + expect(filter).toEqual({}); + expect(filter).not.toEqual(RLS_DENY_FILTER); + }); }); diff --git a/packages/runtime/src/sandbox/quickjs-runner.test.ts b/packages/runtime/src/sandbox/quickjs-runner.test.ts index 71897c4156..bada24928b 100644 --- a/packages/runtime/src/sandbox/quickjs-runner.test.ts +++ b/packages/runtime/src/sandbox/quickjs-runner.test.ts @@ -312,3 +312,222 @@ describe('QuickJSScriptRunner — long-running async host work (pump budget)', ( ).rejects.toThrow(/timeout/i); }, 10000); }); + +// --------------------------------------------------------------------------- +// ctx.api.transaction(fn) — explicit transaction boundary inside the sandbox. +// +// The body drives begin / op / op / commit through deferred promises across +// many pump iterations; we assert the handle is threaded explicitly (every +// in-tx op carries the SAME tx number, out-of-tx ops carry none), commit/ +// rollback fire correctly, and a tx left open by a throw or a timeout is +// rolled back by the runner's finally. +// --------------------------------------------------------------------------- +describe('QuickJSScriptRunner — ctx.api.transaction', () => { + /** A ScopedContext-shaped mock that records every op with its tx binding. */ + function makeTxApi() { + const events: Array<{ op: string; name?: string; tx: number | null }> = []; + let nextTx = 0; + const repoFor = (tx: number | null) => (name: string) => ({ + insert: async (rec: unknown) => { events.push({ op: 'insert', name, tx }); return { id: 'r', tx, rec }; }, + findOne: async () => { events.push({ op: 'findOne', name, tx }); return { tx }; }, + count: async () => { events.push({ op: 'count', name, tx }); return 0; }, + }); + const api = { + object: repoFor(null), + beginTransaction: async () => { + const handle = ++nextTx; + events.push({ op: 'begin', tx: handle }); + return { ctx: { object: repoFor(handle) }, handle }; + }, + commitTransaction: async (handle: number) => { events.push({ op: 'commit', tx: handle }); }, + rollbackTransaction: async (handle: number) => { events.push({ op: 'rollback', tx: handle }); }, + }; + return { api, events }; + } + + it('threads one tx handle through all in-tx ops and commits on success', async () => { + const { api, events } = makeTxApi(); + const r = await runner.runScript( + { + language: 'js', + source: ` + await ctx.api.object('a').insert({ pre: 1 }); // out of tx + const out = await ctx.api.transaction(async () => { + await ctx.api.object('a').insert({ x: 1 }); + await ctx.api.object('b').insert({ y: 2 }); + return 'done'; + }); + await ctx.api.object('a').insert({ post: 1 }); // out of tx + return out; + `, + capabilities: ['api.write', 'api.transaction'], + timeoutMs: 30000, + }, + ctx({ api }), + actionOpts, + ); + + // The callback's return value is forwarded. + expect(r.value).toBe('done'); + // Strict ordering + handle threading. + expect(events).toEqual([ + { op: 'insert', name: 'a', tx: null }, // before tx + { op: 'begin', tx: 1 }, + { op: 'insert', name: 'a', tx: 1 }, // both in-tx ops share handle #1 + { op: 'insert', name: 'b', tx: 1 }, + { op: 'commit', tx: 1 }, + { op: 'insert', name: 'a', tx: null }, // after tx — unbound again + ]); + }, 30000); + + it('reads inside the tx also reuse the handle', async () => { + const { api, events } = makeTxApi(); + await runner.runScript( + { + language: 'js', + source: ` + await ctx.api.transaction(async () => { + await ctx.api.object('a').findOne({ id: 1 }); + await ctx.api.object('a').insert({ x: 1 }); + }); + `, + capabilities: ['api.read', 'api.write', 'api.transaction'], + timeoutMs: 30000, + }, + ctx({ api }), + actionOpts, + ); + expect(events).toEqual([ + { op: 'begin', tx: 1 }, + { op: 'findOne', name: 'a', tx: 1 }, + { op: 'insert', name: 'a', tx: 1 }, + { op: 'commit', tx: 1 }, + ]); + }, 30000); + + it('rolls back (not commits) when the callback throws, and re-throws the original error', async () => { + const { api, events } = makeTxApi(); + await expect( + runner.runScript( + { + language: 'js', + source: ` + await ctx.api.transaction(async () => { + await ctx.api.object('a').insert({ x: 1 }); + throw new Error('boom'); + }); + `, + capabilities: ['api.write', 'api.transaction'], + timeoutMs: 30000, + }, + ctx({ api }), + actionOpts, + ), + ).rejects.toThrow(/boom/); + + expect(events.map((e) => e.op)).toEqual(['begin', 'insert', 'rollback']); + expect(events.some((e) => e.op === 'commit')).toBe(false); + }, 30000); + + it('rejects a nested transaction', async () => { + const { api } = makeTxApi(); + await expect( + runner.runScript( + { + language: 'js', + source: ` + await ctx.api.transaction(async () => { + await ctx.api.transaction(async () => {}); + }); + `, + capabilities: ['api.write', 'api.transaction'], + timeoutMs: 30000, + }, + ctx({ api }), + actionOpts, + ), + ).rejects.toThrow(/nested/i); + }, 30000); + + it('requires the api.transaction capability', async () => { + const { api } = makeTxApi(); + await expect( + runner.runScript( + { + language: 'js', + source: `await ctx.api.transaction(async () => {});`, + capabilities: ['api.write'], // no api.transaction + timeoutMs: 30000, + }, + ctx({ api }), + actionOpts, + ), + ).rejects.toThrow(/api\.transaction/); + }, 30000); + + it('rolls back a transaction the body leaves open when the deadline fires', async () => { + const events: Array<{ op: string; tx: number | null }> = []; + let nextTx = 0; + const api = { + object: () => ({ + // never settles — the in-tx op stalls until the deadline cuts in + insert: () => new Promise(() => {}), + }), + beginTransaction: async () => { + const handle = ++nextTx; + events.push({ op: 'begin', tx: handle }); + return { ctx: { object: () => ({ insert: () => new Promise(() => {}) }) }, handle }; + }, + commitTransaction: async (h: number) => { events.push({ op: 'commit', tx: h }); }, + rollbackTransaction: async (h: number) => { events.push({ op: 'rollback', tx: h }); }, + }; + + await expect( + runner.runScript( + { + language: 'js', + source: ` + await ctx.api.transaction(async () => { + await ctx.api.object('a').insert({ x: 1 }); + }); + `, + capabilities: ['api.write', 'api.transaction'], + timeoutMs: 300, + }, + ctx({ api }), + actionOpts, + ), + ).rejects.toThrow(/timeout/i); + + // begin happened, the op stalled, deadline fired → finally rolled it back. + expect(events.map((e) => e.op)).toEqual(['begin', 'rollback']); + }, 10000); + + it('degrades to non-transactional when the driver lacks tx support', async () => { + const events: Array<{ op: string; tx: number | null }> = []; + // No beginTransaction — mimics an in-memory driver without tx primitives. + const api = { + object: () => ({ + insert: async () => { events.push({ op: 'insert', tx: null }); return { id: 'r' }; }, + }), + }; + const r = await runner.runScript( + { + language: 'js', + source: ` + return await ctx.api.transaction(async () => { + await ctx.api.object('a').insert({ x: 1 }); + return 'ok'; + }); + `, + capabilities: ['api.write', 'api.transaction'], + timeoutMs: 30000, + }, + ctx({ api }), + actionOpts, + ); + // Callback still runs and returns; the op simply isn't wrapped in a tx. + expect(r.value).toBe('ok'); + expect(events).toEqual([{ op: 'insert', tx: null }]); + }, 30000); +}); diff --git a/packages/runtime/src/sandbox/quickjs-runner.ts b/packages/runtime/src/sandbox/quickjs-runner.ts index a5d64a9ee4..87c4fcade8 100644 --- a/packages/runtime/src/sandbox/quickjs-runner.ts +++ b/packages/runtime/src/sandbox/quickjs-runner.ts @@ -132,8 +132,15 @@ export class QuickJSScriptRunner implements ScriptRunner { const deadline = start + args.timeoutMs; runtime.setInterruptHandler(() => Date.now() > deadline); + // Shared, per-invocation transaction state. `ctx.api.transaction(fn)` opens + // it (routing subsequent ctx.api ops through the tx-scoped context) and + // closes it on commit/rollback. The execute() finally consults it to roll + // back a transaction the body left open (threw mid-tx, or timed out before + // its commit/rollback settled). + const txState: TxState = { api: null, handle: null, open: false }; + try { - this.installCtx(vm, args.ctx, new Set(args.capabilities), args.origin); + this.installCtx(vm, args.ctx, new Set(args.capabilities), args.origin, txState); // L1 expressions are pure-sync: evaluate and read __result. if (args.isExpression) { @@ -227,6 +234,23 @@ export class QuickJSScriptRunner implements ScriptRunner { pumps++; } } finally { + // If the body left a transaction open — it threw between begin and + // commit/rollback, or the deadline cut the pump loop off while a tx was + // live — roll it back before tearing down the VM, so the driver + // connection isn't leaked with a half-applied transaction. Best-effort: + // the script result (success or the original error) is already decided; + // a rollback failure here must not mask it. + if (txState.open && txState.handle != null) { + const apiTx = args.ctx.api as Record | undefined; + const rollback = apiTx?.rollbackTransaction; + if (typeof rollback === 'function') { + try { + await (rollback as (h: unknown) => Promise).call(apiTx, txState.handle); + } catch { + /* best-effort cleanup — swallow so the real outcome surfaces */ + } + } + } // newAsyncContext() owns its WASM module; disposing the context disposes // the runtime + module together. vm.dispose(); @@ -247,6 +271,7 @@ export class QuickJSScriptRunner implements ScriptRunner { ctx: ScriptContext, caps: Set, origin: ScriptOrigin, + txState: TxState, ): void { setGlobalJson(vm, '__input', ctx.input); setGlobalJson(vm, '__previous', ctx.previous); @@ -284,12 +309,91 @@ export class QuickJSScriptRunner implements ScriptRunner { const wrap = vm.newObject(); const READ = ['find', 'findOne', 'count', 'aggregate'] as const; const WRITE = ['insert', 'update', 'delete', 'updateMany', 'deleteMany', 'upsert'] as const; - for (const m of READ) installApiMethod(vm, wrap, m, objectName, ctx, caps, 'api.read', origin); - for (const m of WRITE) installApiMethod(vm, wrap, m, objectName, ctx, caps, 'api.write', origin); + for (const m of READ) installApiMethod(vm, wrap, m, objectName, ctx, caps, 'api.read', origin, txState); + for (const m of WRITE) installApiMethod(vm, wrap, m, objectName, ctx, caps, 'api.write', origin, txState); return wrap; }); vm.setProp(apiObj, 'object', objectFn); objectFn.dispose(); + + // Transaction control. The VM-facing surface is a single `ctx.api.transaction(fn)` + // (defined as JS sugar below); under the hood it drives three host leaves so + // begin / commit / rollback each settle through the same deferred-promise + + // pump mechanism every other host call uses (asyncify can't unwind twice). + // + // The handle is threaded EXPLICITLY through `txState` rather than via the + // engine's ambient AsyncLocalStorage: the body runs across many host + // event-loop turns, and ALS context does not survive those `setImmediate` + // boundaries. While a tx is open, `installApiMethod` resolves its repository + // from `txState.api` (the tx-scoped ScopedContext) so every op reuses the + // one connection. + const apiTx = ctx.api as Record | undefined; + const installTxLeaf = (name: string, run: () => Promise): void => { + const fn = vm.newFunction(name, () => { + if (!caps.has('api.transaction')) { + throw new SandboxError( + `capability 'api.transaction' not granted to ${origin.kind} '${origin.name}' (called ctx.api.transaction)`, + ); + } + const deferred = vm.newPromise(); + void (async () => { + try { + await run(); + if (!vm.alive) return; + deferred.resolve(vm.undefined); + } catch (err) { + if (!vm.alive) return; + const errH = + err instanceof Error + ? vm.newError({ name: err.name || 'Error', message: err.message }) + : vm.newError({ name: 'Error', message: String(err) }); + deferred.reject(errH); + errH.dispose(); + } + })(); + return deferred.handle; + }); + vm.setProp(apiObj, name, fn); + fn.dispose(); + }; + + installTxLeaf('__txBegin', async () => { + if (txState.open) throw new SandboxError('nested ctx.api.transaction is not supported'); + const begin = apiTx?.beginTransaction; + if (typeof begin === 'function') { + const r = (await (begin as () => Promise<{ ctx: unknown; handle: unknown } | null>).call(apiTx)) ?? null; + if (r) { + txState.api = r.ctx as Record; + txState.handle = r.handle; + } + } + // else (or null result): driver without tx support → degrade to + // non-transactional execution, same as ScopedContext.transaction(). + txState.open = true; + }); + + installTxLeaf('__txCommit', async () => { + const { handle, open } = txState; + txState.api = null; + txState.handle = null; + txState.open = false; + const commit = apiTx?.commitTransaction; + if (open && handle != null && typeof commit === 'function') { + await (commit as (h: unknown) => Promise).call(apiTx, handle); + } + }); + + installTxLeaf('__txRollback', async () => { + const { handle, open } = txState; + txState.api = null; + txState.handle = null; + txState.open = false; + const rollback = apiTx?.rollbackTransaction; + if (open && handle != null && typeof rollback === 'function') { + await (rollback as (h: unknown) => Promise).call(apiTx, handle); + } + }); + vm.setProp(ctxObj, 'api', apiObj); apiObj.dispose(); @@ -325,9 +429,48 @@ export class QuickJSScriptRunner implements ScriptRunner { vm.setProp(vm.global, '__ctx', ctxObj); ctxObj.dispose(); + + // VM-side sugar: `ctx.api.transaction(async () => { … })`. Begin runs + // OUTSIDE the try so a begin failure (e.g. missing capability) propagates + // without attempting a rollback there is no transaction for. The body's + // return value is forwarded; any throw triggers rollback then re-throws, + // so the caller observes the original error. + const sugar = vm.evalCode( + `__ctx.api.transaction = async function (fn) { + await __ctx.api.__txBegin(); + try { + var r = await fn(); + await __ctx.api.__txCommit(); + return r; + } catch (e) { + await __ctx.api.__txRollback(); + throw e; + } + };`, + ); + if (sugar.error) { + const msg = vm.dump(sugar.error); + sugar.error.dispose(); + throw new SandboxError(`failed to install ctx.api.transaction: ${formatErr(msg)}`); + } + sugar.value.dispose(); } } +/** + * Per-invocation transaction state shared between {@link QuickJSScriptRunner.execute} + * (which rolls back a tx the body left open) and the `ctx.api.transaction` + * host leaves (which open/close it). `api` is the tx-scoped ScopedContext that + * `installApiMethod` routes repository ops through while a tx is live; `handle` + * is the driver transaction handle; `open` guards against nesting and tells the + * finally block whether cleanup is owed. + */ +interface TxState { + api: Record | null; + handle: unknown; + open: boolean; +} + /** * Host-bound API method, exposed to the VM as an async function. * @@ -358,6 +501,7 @@ function installApiMethod( caps: Set, required: HookBodyCapability, origin: ScriptOrigin, + txState: TxState, ): void { const fn = vm.newFunction(method, (...argHandles) => { // Capability gate — throw synchronously so the VM sees a normal exception at @@ -378,7 +522,13 @@ function installApiMethod( const deferred = vm.newPromise(); void (async () => { try { - const proxy = (apiAny.object as (n: string) => Record)(objectName); + // While a transaction is open, resolve the repository from the + // tx-scoped context so this op reuses the transaction's connection; + // otherwise use the base ctx.api. Read `txState` HERE (at call time, + // inside the async body) — the tx may have opened after this method + // was installed. + const source = (txState.api ?? apiAny) as Record; + const proxy = (source.object as (n: string) => Record)(objectName); const m = proxy[method] as ((...a: unknown[]) => unknown) | undefined; if (typeof m !== 'function') { throw new SandboxError(`ctx.api.object('${objectName}').${method} not implemented`); diff --git a/packages/spec/src/data/hook-body.zod.ts b/packages/spec/src/data/hook-body.zod.ts index cb6f0abc11..b06458ed4f 100644 --- a/packages/spec/src/data/hook-body.zod.ts +++ b/packages/spec/src/data/hook-body.zod.ts @@ -10,6 +10,10 @@ import { z } from 'zod'; * * - `api.read` — `ctx.api.object(...).find / findOne / count / aggregate` * - `api.write` — `ctx.api.object(...).insert / update / delete` + * - `api.transaction` — `ctx.api.transaction(async () => { … })` — runs the + * callback's `ctx.api` writes/reads inside one driver transaction, committed + * on return and rolled back if the callback throws. Requires `api.write` + * alongside it to be useful (the transaction body still needs write access). * - `crypto.uuid` — `ctx.crypto.randomUUID()` * - `crypto.hash` — `ctx.crypto.hash(algo, data)` * - `log` — `ctx.log.info / warn / error` @@ -20,6 +24,7 @@ import { z } from 'zod'; export const HookBodyCapability = z.enum([ 'api.read', 'api.write', + 'api.transaction', 'crypto.uuid', 'crypto.hash', 'log', diff --git a/packages/spec/src/kernel/execution-context.zod.ts b/packages/spec/src/kernel/execution-context.zod.ts index 18b0ec2c0b..26e561a3ed 100644 --- a/packages/spec/src/kernel/execution-context.zod.ts +++ b/packages/spec/src/kernel/execution-context.zod.ts @@ -70,7 +70,26 @@ export const ExecutionContextSchema = lazySchema(() => z.object({ * resolveExecutionContext from `sys_member`. */ org_user_ids: z.array(z.string()).optional(), - + + /** + * Pre-resolved dynamic-membership arrays for RLS (§7.3.1). The runtime + * resolves set-membership that would otherwise need a subquery — team + * members under a manager, accounts in a sales rep's territories, + * records shared with the user — and stages each set here under a + * stable key. RLS policies then reference a key via + * `field IN (current_user.)`, which the compiler resolves against + * this bag without any subquery support. + * + * `org_user_ids` is the one well-known, always-populated membership set + * and stays a named field for back-compat; everything else lives here. + * Keys are arbitrary `current_user.*` names; values are id arrays. A + * missing or empty array makes the referencing policy drop out and + * (if it was the only policy) fail closed — never fail open. + * + * @example { team_member_ids: ['u2', 'u3'], territory_account_ids: ['a7'] } + */ + rlsMembership: z.record(z.string(), z.array(z.string())).optional(), + /** Whether this is a system-level operation (bypasses permission checks) */ isSystem: z.boolean().default(false), diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index f37f4ee102..c7906f45c4 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -24,17 +24,20 @@ import { z } from 'zod'; * - Users only see records they own * - `using: "owner_id = current_user.id"` * - * 3. **Department-Based Access** - * - Users only see records from their department - * - `using: "department = current_user.department"` - * - * 4. **Regional Access Control** - * - Sales reps only see accounts in their territory - * - `using: "region IN (current_user.assigned_regions)"` - * - * 5. **Time-Based Access** - * - Users can only access active records - * - `using: "status = 'active' AND expiry_date > NOW()"` + * 3. **Organization Member Visibility** + * - Users see fellow members of their active organization + * - `using: "id IN (current_user.org_user_ids)"` + * (`org_user_ids` is pre-resolved by the runtime) + * + * 4. **Territory / Regional Access (§7.3.1 dynamic membership)** + * - Sales reps only see accounts in their assigned territories + * - `using: "account_id IN (current_user.territory_account_ids)"` + * (the runtime stages `territory_account_ids` in `ExecutionContext.rlsMembership`) + * + * 5. **Manager / Hierarchy Access (§7.3.1 dynamic membership)** + * - Managers see records assigned to anyone they manage + * - `using: "assigned_to_id IN (current_user.team_member_ids)"` + * (the runtime pre-resolves `team_member_ids`, no subquery needed) * * ## PostgreSQL RLS Comparison * @@ -70,9 +73,9 @@ import { z } from 'zod'; * - Manual Sharing: Individual record sharing * * ObjectStack RLS: - * - More flexible formula-based conditions - * - Direct SQL-like syntax - * - Supports complex logic with AND/OR/NOT + * - A small, fixed expression grammar (equality, set-membership, always-true) + * - Subquery-shaped needs are pre-resolved by the runtime (§7.3.1) + * - Multiple policies OR-combine for union (any-match-allows) semantics * * ## Best Practices * @@ -139,14 +142,16 @@ export type RLSOperation = z.infer; * } * ``` * - * @example Manager Can View Team Records + * @example Manager Can View Team Records (§7.3.1 dynamic membership) * ```typescript * { * name: 'manager_team_access', * label: 'Managers Can View Team Records', * object: 'task', * operation: 'select', - * using: 'assigned_to_id IN (SELECT id FROM users WHERE manager_id = current_user.id)', + * // The runtime resolves the manager's reports into + * // ExecutionContext.rlsMembership.team_member_ids — no subquery needed. + * using: 'assigned_to_id IN (current_user.team_member_ids)', * roles: ['manager', 'director'], * enabled: true * } @@ -164,27 +169,29 @@ export type RLSOperation = z.infer; * } * ``` * - * @example Regional Sales Access + * @example Regional Sales Access (§7.3.1 dynamic membership) * ```typescript * { * name: 'regional_sales_access', * label: 'Sales Reps Access Regional Accounts', * object: 'account', * operation: 'select', - * using: 'region = current_user.region OR region IS NULL', + * // The runtime stages the rep's territory accounts in + * // ExecutionContext.rlsMembership.territory_account_ids. + * using: 'id IN (current_user.territory_account_ids)', * roles: ['sales_rep'], * enabled: true * } * ``` - * - * @example Time-Based Access Control + * + * @example Status-Based Access (literal match) * ```typescript * { - * name: 'active_records_only', - * label: 'Users Only Access Active Records', + * name: 'published_only', + * label: 'Users Only Access Published Records', * object: 'contract', * operation: 'select', - * using: 'status = "active" AND start_date <= NOW() AND end_date >= NOW()', + * using: "status = 'published'", * enabled: true * } * ``` @@ -261,55 +268,67 @@ export const RowLevelSecurityPolicySchema = lazySchema(() => z.object({ /** * USING clause - Filter condition for SELECT/UPDATE/DELETE. * - * This is a SQL-like expression evaluated for each row. - * Only rows where this expression returns TRUE are accessible. - * + * This is a constrained, SQL-like expression compiled into an ObjectQL + * filter (see the supported grammar below). Only rows the compiled filter + * matches are accessible. + * * **Note**: For INSERT-only policies, USING is not required (only CHECK is needed). * For SELECT/UPDATE/DELETE operations, USING is required. - * - * **Security Note**: RLS conditions are executed at the database level with - * parameterized queries. The implementation must use prepared statements - * to prevent SQL injection. Never concatenate user input directly into - * RLS conditions. - * - * **SQL Dialect**: Compatible with PostgreSQL SQL syntax. Implementations - * may adapt to other databases (MySQL, SQL Server, etc.) but should maintain - * semantic equivalence. - * - * Available context variables: - * - `current_user.id` - Current user's ID - * - `current_user.organization_id` - Active organization id (maps to `tenantId` in RLSUserContext / `ExecutionContext.tenantId`) - * - `current_user.role` - Current user's role - * - `current_user.department` - Current user's department - * - `current_user.*` - Any custom user field - * - `NOW()` - Current timestamp - * - `CURRENT_DATE` - Current date - * - `CURRENT_TIME` - Current time - * - * **Context Variable Mapping**: The RLSUserContext schema uses camelCase (e.g., `tenantId`), - * but expressions use snake_case with `current_user.` prefix (e.g., `current_user.organization_id`). - * Implementations must handle this mapping. - * - * Supported operators: - * - Comparison: =, !=, <, >, <=, >=, <> (not equal) - * - Logical: AND, OR, NOT - * - NULL checks: IS NULL, IS NOT NULL - * - Set operations: IN, NOT IN - * - String: LIKE, NOT LIKE, ILIKE (case-insensitive) - * - Pattern matching: ~ (regex), !~ (not regex) - * - Subqueries: (SELECT ...) - * - Array operations: ANY, ALL - * + * + * **Security Note**: the compiler maps each form to a structured filter and + * binds context values as parameters at the driver layer — context values + * are never string-concatenated into SQL. Policy `using` strings are + * authored by administrators, not end users. + * + * **Supported expression grammar (reference compiler)** + * + * The reference RLS compiler implements a deliberately **small, fixed + * grammar** rather than a general SQL parser. Exactly four forms compile; + * anything else fails closed (the policy matches zero rows). Keep `using` + * to one of: + * + * 1. `field = current_user.` — equality against a context value + * 2. `field = 'literal'` — equality against a single-quoted string literal + * 3. `field IN (current_user.)` — set membership against a + * pre-resolved id array (see "Dynamic membership" below) + * 4. `1 = 1` — always true / no restriction (privileged-role allow-all) + * + * There is intentionally **no** support for `AND`/`OR`/`NOT`, comparison + * operators other than `=`, `IS NULL`/`IS NOT NULL`, `NOT IN`, `LIKE`/ + * `ILIKE`, regex (`~`/`!~`), `ANY`/`ALL`, subqueries, or `NOW()`/ + * `CURRENT_DATE`/`CURRENT_TIME`. Combine conditions by defining multiple + * policies (they OR-combine); express anything subquery-shaped as a + * pre-resolved `current_user.*` array instead. + * + * **Context values** — `current_user.*` resolves against the request's + * execution context (camelCase fields map to snake_case placeholders): + * - `current_user.id` → `ExecutionContext.userId` + * - `current_user.organization_id` → `ExecutionContext.tenantId` + * - `current_user.roles` → `ExecutionContext.roles` (array) + * - `current_user.org_user_ids` → ids of fellow members of the active org + * - any key the runtime stages in `ExecutionContext.rlsMembership` + * + * A referenced value that is missing/`null` (scalar) or empty (array) + * makes that policy drop out — **fail-closed**, never fail-open. + * + * **Dynamic membership (§7.3.1)** — set-membership that would otherwise + * need a subquery ("tasks assigned to anyone I manage", "accounts in my + * territories") is resolved by the runtime into + * `ExecutionContext.rlsMembership` under a stable key, then referenced as + * `field IN (current_user.)`. This keeps the compiler subquery-free + * while still supporting hierarchy- and sharing-based access. + * * **Prohibited**: Dynamic SQL, DDL statements, DML statements (INSERT/UPDATE/DELETE) - * + * * @example "organization_id = current_user.organization_id" - * @example "owner_id = current_user.id OR created_by = current_user.id" - * @example "department IN (SELECT department FROM user_departments WHERE user_id = current_user.id)" - * @example "status = 'active' AND expiry_date > NOW()" + * @example "owner_id = current_user.id" + * @example "status = 'published'" + * @example "assigned_to_id IN (current_user.team_member_ids)" // §7.3.1 pre-resolved + * @example "1 = 1" // privileged-role allow-all */ using: z.string() .optional() - .describe('Filter condition for SELECT/UPDATE/DELETE (PostgreSQL SQL WHERE clause syntax with parameterized context variables). Optional for INSERT-only policies.'), + .describe('Filter condition for SELECT/UPDATE/DELETE. One of the four compiler-supported forms: `field = current_user.`, `field = \'literal\'`, `field IN (current_user.)`, or `1 = 1`. Optional for INSERT-only policies.'), /** * CHECK clause - Validation for INSERT/UPDATE operations. diff --git a/skills/objectstack-formula/SKILL.md b/skills/objectstack-formula/SKILL.md index e581861328..8eabbcd7d8 100644 --- a/skills/objectstack-formula/SKILL.md +++ b/skills/objectstack-formula/SKILL.md @@ -139,6 +139,8 @@ test asserts every entry resolves at runtime, so this table stays in sync with i | `daysFromNow(n)` | timestamp | `now()` + `n` days — **keeps the current time-of-day** (NOT midnight) | | `daysAgo(n)` | timestamp | `now()` − `n` days — keeps the current time-of-day | | `daysBetween(a, b)` | int | Whole days from `a` to `b` (negative if `b` precedes `a`). `daysBetween(today(), record.due)` = days remaining | +| `addDays(d, n)` | timestamp | Shift **any** date by `n` days (negative ok). `addDays(record.last_service, record.cycle_days)` = next due date | +| `addMonths(d, n)` | timestamp | Shift **any** date by `n` months; clamps to month-end (`addMonths(date('2026-01-31'), 1)` → Feb 28) | | `date(s)` / `datetime(s)` | timestamp | Parse an ISO date / date-time string to a timestamp | **Numbers**