@pramen/server 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/auth.d.ts CHANGED
@@ -28,8 +28,12 @@ export declare class JwksStrategy implements VerifyStrategy {
28
28
  }
29
29
  export declare function resolveIdentity(request: Request, strategy: VerifyStrategy): Promise<Identity | null>;
30
30
  /** May this identity address the given tenant? Gates `X-Pramen-Tenant` so a caller
31
- * can't reach (or register) arbitrary tenants. Default policy: admins → any
32
- * tenant; everyone else only tenants listed in their `tenants` claim. Customize
33
- * for your tenancy model (e.g. tenant === identity.org, or a lookup). */
31
+ * can't reach (or register) arbitrary tenants. Default policy:
32
+ * - `main` is the open default tenant: any caller may reach it (anonymous too) —
33
+ * data access is still governed by ACL roles (single-tenant apps live here, and
34
+ * issued login tokens need no `tenants` claim to use it).
35
+ * - other tenants: admins → any; everyone else → only tenants in their `tenants`
36
+ * claim; anonymous → none.
37
+ * Customize for your tenancy model (e.g. tenant === identity.org, or a lookup). */
34
38
  export declare function authorizeTenant(identity: Identity | null, tenant: string): boolean;
35
39
  export declare function isAdmin(identity: Identity | null): boolean;
package/dist/auth.js CHANGED
@@ -170,15 +170,18 @@ export async function resolveIdentity(request, strategy) {
170
170
  return claims ? toIdentity(claims) : null;
171
171
  }
172
172
  /** May this identity address the given tenant? Gates `X-Pramen-Tenant` so a caller
173
- * can't reach (or register) arbitrary tenants. Default policy: admins → any
174
- * tenant; everyone else only tenants listed in their `tenants` claim. Customize
175
- * for your tenancy model (e.g. tenant === identity.org, or a lookup). */
173
+ * can't reach (or register) arbitrary tenants. Default policy:
174
+ * - `main` is the open default tenant: any caller may reach it (anonymous too) —
175
+ * data access is still governed by ACL roles (single-tenant apps live here, and
176
+ * issued login tokens need no `tenants` claim to use it).
177
+ * - other tenants: admins → any; everyone else → only tenants in their `tenants`
178
+ * claim; anonymous → none.
179
+ * Customize for your tenancy model (e.g. tenant === identity.org, or a lookup). */
176
180
  export function authorizeTenant(identity, tenant) {
177
- // Anonymous (no verified token) may reach only the default tenant — enough for
178
- // first-class public flows (the `anonymous` ACL role still gates the data), while
179
- // not letting unauthenticated callers address/register arbitrary tenants.
181
+ if (tenant === "main")
182
+ return true;
180
183
  if (!identity)
181
- return tenant === "main";
184
+ return false;
182
185
  if (identity.roles?.includes("admin"))
183
186
  return true;
184
187
  const allowed = Array.isArray(identity.tenants) ? identity.tenants : [];
@@ -34,6 +34,7 @@ export declare class PramenDOBase extends DurableObject<DoEnv> {
34
34
  private ensureRegistered;
35
35
  private handleRecover;
36
36
  private handleSchema;
37
+ private handleAdminData;
37
38
  private ctxFor;
38
39
  private get envBag();
39
40
  private filesFor;
@@ -17,6 +17,7 @@
17
17
  import { DurableObject } from "cloudflare:workers";
18
18
  import { migrate } from "./runtime/migrate";
19
19
  import { dispatch } from "./runtime/dispatch";
20
+ import { Db } from "./runtime/db";
20
21
  import { digest } from "./runtime/digest";
21
22
  import { compileAcl } from "./runtime/acl";
22
23
  import { DoSqliteDriver } from "./runtime/driver";
@@ -59,6 +60,8 @@ export class PramenDOBase extends DurableObject {
59
60
  return this.handleRecover(request);
60
61
  if (path === "/__schema")
61
62
  return this.handleSchema();
63
+ if (path === "/__admin/data")
64
+ return this.handleAdminData(request);
62
65
  const identity = this.identityOf(request);
63
66
  if (request.headers.get("Upgrade") === "websocket") {
64
67
  const { 0: client, 1: server } = new WebSocketPair();
@@ -232,8 +235,59 @@ export class PramenDOBase extends DurableObject {
232
235
  }
233
236
  return Response.json({ ok: true, result: { hash: hashRow[0]?.value ?? null, tables } });
234
237
  }
238
+ // Generic admin data ops (admin-gated at the Worker). Runs through a SYSTEM-mode
239
+ // Db, so ACL is bypassed — admin can browse/edit any row of any table — while the
240
+ // json/fileRef codec, transactions, and live-query broadcast still apply.
241
+ async handleAdminData(request) {
242
+ const b = (await request.json().catch(() => ({})));
243
+ const table = typeof b.table === "string" ? b.table : "";
244
+ const op = typeof b.op === "string" ? b.op : "";
245
+ if (!table || !(table in this.app.schema)) {
246
+ return Response.json({ ok: false, error: "unknown table", code: "bad_request" }, { status: 400 });
247
+ }
248
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
249
+ const db = new Db(this.driver, { acl: this.acl, identity: null, system: true }, this.app.schema);
250
+ try {
251
+ let result;
252
+ let mutated = false;
253
+ switch (op) {
254
+ case "list":
255
+ result = await db.find({ from: table, where: b.where, orderBy: b.orderBy, limit: b.limit, offset: b.offset });
256
+ break;
257
+ case "count":
258
+ result = await db.count({ from: table, where: b.where });
259
+ break;
260
+ case "get":
261
+ result = (await db.find({ from: table, where: { id: b.id }, limit: 1 }))[0] ?? null;
262
+ break;
263
+ case "create":
264
+ result = await this.driver.transaction(() => db.insert(table, b.values));
265
+ mutated = true;
266
+ break;
267
+ case "update":
268
+ result = (await this.driver.transaction(() => db.update(table, b.id, b.patch))) ?? null;
269
+ mutated = true;
270
+ break;
271
+ case "delete":
272
+ result = await this.driver.transaction(() => db.delete(table, b.id));
273
+ mutated = true;
274
+ break;
275
+ default:
276
+ return Response.json({ ok: false, error: `unknown op: ${op}`, code: "bad_request" }, { status: 400 });
277
+ }
278
+ if (mutated && db.touched.size > 0)
279
+ await this.broadcast([...db.touched]);
280
+ return Response.json({ ok: true, result });
281
+ }
282
+ catch (err) {
283
+ const { status, body } = toResponse(err);
284
+ return Response.json(body, { status });
285
+ }
286
+ }
235
287
  ctxFor(identity) {
236
- return { acl: this.acl, identity };
288
+ // Carry the schema so any consumer of this context (not just Db) can compile
289
+ // relation-aware `where` rules into subqueries.
290
+ return { acl: this.acl, identity, schema: this.app.schema };
237
291
  }
238
292
  // The DO env (bindings + vars + secrets) handed to handlers as ctx.env. Loosely
239
293
  // typed at the boundary so handlers can read any var/secret without a DoEnv cast.
package/dist/index.d.ts CHANGED
@@ -5,7 +5,7 @@ export { query, mutation } from "./sdk/handlers";
5
5
  export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts } from "./sdk/handlers";
6
6
  export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
7
7
  export type { Action, Identity, IdentityMarker, InputMarker, Policy, PolicyRule, PolicyRules, Role, Validator, WhereRule, ConditionalFields, FieldsFn, RelationAclRule, SetValue, ResolverFn, ResolverContext, ResolverDb, } from "./sdk/acl";
8
- export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, ProjectedRow, RelationsOf, RelationsResult, WhereInput, WhereOps, } from "./sdk/infer";
8
+ export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, ProjectedRow, RelationsOf, RelationsResult, WhereClause, WhereInput, WhereOps, } from "./sdk/infer";
9
9
  export type { FileRef, Files, SignUploadOpts, SignDownloadOpts, HeadResult } from "./sdk/files";
10
10
  export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
11
11
  export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
@@ -1,6 +1,7 @@
1
1
  import { type Action, type FieldsFn, type Identity, type PolicyRule, type ResolverDb, type Role, type Validator } from "../sdk/acl";
2
2
  import { type SqlExpr } from "./read-engine";
3
3
  import { PramenError } from "./errors";
4
+ import type { SchemaDef } from "../sdk/schema";
4
5
  export declare class AclDenied extends PramenError {
5
6
  readonly entity: string;
6
7
  readonly action: Action;
@@ -22,6 +23,9 @@ export interface AclContext {
22
23
  readonly resolved?: Map<number, PolicyRule>;
23
24
  /** SYSTEM mode bypasses all ACL — used for warmup reads and internal ops. */
24
25
  readonly system?: boolean;
26
+ /** The app schema — lets `where` rules traverse relations (`{ rel: { col } }`),
27
+ * compiled to a subquery with the related entity's read scope AND-merged in. */
28
+ readonly schema?: SchemaDef;
25
29
  }
26
30
  /** Evaluate every resolver reachable by the identity's roles, once per request.
27
31
  * Resolvers read through a SYSTEM-mode db (ACL bypassed) to avoid recursion. */
@@ -44,7 +48,29 @@ export interface Scope {
44
48
  readonly fieldsFns: FieldsFn[];
45
49
  }
46
50
  export declare const ALLOW_ALL: Scope;
47
- export declare function resolveScope(ctx: AclContext, entity: string, action: Action): Scope;
51
+ /** Max relation-traversal nesting depth in a `where` (guards cyclic relations).
52
+ * Kept in lockstep with the `WhereClause` type's depth bound in sdk/infer.ts — if
53
+ * you change one, change the other. */
54
+ export declare const MAX_REL_DEPTH = 5;
55
+ /** Compile a where-rule (user query or policy) into a SqlExpr. Plain columns go
56
+ * through the marker-resolving compiler; relation keys (`{ rel: { … } }`) become
57
+ * security-scoped subqueries. Supports operators, AND/OR, and $identity/$input
58
+ * markers; an unresolvable marker makes its branch match nothing. Schema-less
59
+ * contexts (no relations) behave exactly like the flat compiler.
60
+ *
61
+ * Marker semantics: AND/OR branches compile independently, so an unresolvable
62
+ * marker collapses ONLY its own branch to FALSE — it does not nullify sibling
63
+ * branches. `OR: [{ ownerId: $identity("userId") }, { public: true }]` therefore
64
+ * still grants the `public` branch to a caller whose `userId` can't resolve (and,
65
+ * conversely, an unresolvable marker in one OR branch no longer revokes access the
66
+ * other branches would grant). This is plain boolean logic; the cases are covered
67
+ * by the relwhere suite.
68
+ *
69
+ * `allowRelations` is false for single-table contexts (cell-level `when`, which is
70
+ * evaluated in memory and cannot do a SQL round-trip): a relation key then raises a
71
+ * clear authoring error instead of emitting a `sub` node that throws at read time. */
72
+ export declare function compileScopedWhere(rule: Record<string, unknown>, entity: string, ctx: AclContext, depth?: number, allowRelations?: boolean): SqlExpr;
73
+ export declare function resolveScope(ctx: AclContext, entity: string, action: Action, depth?: number): Scope;
48
74
  /** Effective visible fields for one row = base ∪ matching-conditional ∪ fn-output.
49
75
  * Returns null (all fields) when the base is null or a resolver grants everything. */
50
76
  export declare function effectiveFields(scope: Scope, row: Record<string, unknown>, identity: Identity | null): string[] | null;
@@ -3,8 +3,8 @@
3
3
  // granted, the row-level predicate to merge into the query, and any field
4
4
  // restriction. Deny-by-default; grants OR-merge across the identity's roles.
5
5
  import { deny, isAllow, isDeny, isIdentityMarker, isInputMarker, isResolver, } from "../sdk/acl";
6
- import { compileWhere, evalExpr, FALSE, or } from "./read-engine";
7
- import { PramenError } from "./errors";
6
+ import { and, compileWhere, evalExpr, FALSE, or, TRUE } from "./read-engine";
7
+ import { BadRequest, PramenError } from "./errors";
8
8
  export class AclDenied extends PramenError {
9
9
  entity;
10
10
  action;
@@ -55,14 +55,16 @@ export function compileAcl(roles) {
55
55
  }
56
56
  export const ALLOW_ALL = { allowed: true, where: null, fields: null, conditional: [], fieldsFns: [] };
57
57
  const DENIED = { allowed: false, where: null, fields: null, conditional: [], fieldsFns: [] };
58
- /** Build a grant from a policy/relation rule, resolving $identity markers in
59
- * `where` and each conditional `when`. */
60
- function grantOf(rule, where, identity, input) {
58
+ /** Build a grant from a policy/relation rule, resolving markers in `where` and each
59
+ * conditional `when` (the `when` predicate is single-table — evaluated in memory). */
60
+ function grantOf(rule, where, entity, ctx, depth) {
61
61
  return {
62
62
  where,
63
63
  fields: rule.fields ?? null,
64
64
  conditional: (rule.conditionalFields ?? []).map((cf) => ({
65
- when: whereToExpr(cf.when, identity, input),
65
+ // Cell-level `when` is evaluated per-row in memory (evalExpr), so it must stay
66
+ // single-table — `allowRelations: false` rejects a relation key up front.
67
+ when: compileScopedWhere(cf.when, entity, ctx, depth, false),
66
68
  fields: cf.fields,
67
69
  })),
68
70
  fieldsFns: rule.fieldsFn ? [rule.fieldsFn] : [],
@@ -97,23 +99,18 @@ function resolveValue(v, identity, input) {
97
99
  }
98
100
  return v;
99
101
  }
100
- // Resolve every $identity marker in a policy where-rule (bare values, operator
101
- // objects, in/notIn arrays, AND/OR groups). Returns a plain WhereInput, or null
102
- // if any marker is unresolvable in which case the rule matches nothing.
102
+ // Resolve every $identity/$input marker in a SINGLE level of a where-rule (bare
103
+ // values, operator objects, in/notIn arrays). AND/OR groups are split off by
104
+ // `compileScopedWhere` before this runs, so this only ever sees plain columns.
105
+ // Returns a plain WhereInput, or null if any marker is unresolvable — in which
106
+ // case this branch matches nothing (FALSE). Note: because branches are resolved
107
+ // independently, an unresolvable marker nullifies only its own branch, not the
108
+ // whole rule — so `OR: [{ x: $identity(...) }, { public: true }]` still matches
109
+ // the `public` branch for a caller whose marker can't resolve. (See the comment
110
+ // on `compileScopedWhere`.)
103
111
  function resolveMarkers(rule, identity, input) {
104
112
  const out = {};
105
113
  for (const [key, v] of Object.entries(rule)) {
106
- if (key === "AND" || key === "OR") {
107
- const groups = [];
108
- for (const g of v) {
109
- const resolved = resolveMarkers(g, identity, input);
110
- if (resolved === null)
111
- return null;
112
- groups.push(resolved);
113
- }
114
- out[key] = groups;
115
- continue;
116
- }
117
114
  const isMarker = isIdentityMarker(v) || isInputMarker(v);
118
115
  if (v !== null && typeof v === "object" && !isMarker && !Array.isArray(v)) {
119
116
  const ops = {};
@@ -153,12 +150,106 @@ function resolveMarkers(rule, identity, input) {
153
150
  }
154
151
  return out;
155
152
  }
156
- /** Turn a policy where-rule into an expression. Supports the full user query
157
- * surface (operators, AND/OR) with $identity / $input markers; an unresolvable
158
- * marker makes the rule match nothing. */
159
- function whereToExpr(rule, identity, input) {
160
- const resolved = resolveMarkers(rule, identity, input);
161
- return resolved === null ? FALSE : compileWhere(resolved);
153
+ /** Max relation-traversal nesting depth in a `where` (guards cyclic relations).
154
+ * Kept in lockstep with the `WhereClause` type's depth bound in sdk/infer.ts — if
155
+ * you change one, change the other. */
156
+ export const MAX_REL_DEPTH = 5;
157
+ /** Primary-key column of an entity (the column a belongsTo points at / a hasMany
158
+ * joins back to). Defaults to `id`. */
159
+ function pkOf(schema, entity) {
160
+ const fields = schema?.[entity]?.fields;
161
+ if (fields)
162
+ for (const [n, f] of Object.entries(fields))
163
+ if (f.primaryKey)
164
+ return n;
165
+ return "id";
166
+ }
167
+ /** Compile a relation predicate `{ rel: { … } }` to a subquery, AND-merging the
168
+ * related entity's read scope (and rejecting filters on fields it can't read) so
169
+ * traversal can never widen access beyond a direct read of the target. */
170
+ function relationPredicate(rel, nested, parentEntity, ctx, depth) {
171
+ if (depth >= MAX_REL_DEPTH)
172
+ throw new BadRequest("relation `where` is nested too deep");
173
+ if (nested === null || typeof nested !== "object" || Array.isArray(nested)) {
174
+ throw new BadRequest(`relation filter for '${rel.target}' must be an object`);
175
+ }
176
+ let inner = compileScopedWhere(nested, rel.target, ctx, depth + 1);
177
+ // Security: a relation filter must respect the target's read ACL (else it leaks).
178
+ // Two distinct "no" outcomes, matching how the rest of the read path behaves:
179
+ // - No read grant on the target at all -> the relation simply yields no rows
180
+ // to match against (FALSE, empty result) — a valid query over a table you
181
+ // can't see.
182
+ // - Readable target, but the filter names a column you can't read -> 403, the
183
+ // same as ordering/aggregating by a hidden column (you referenced something
184
+ // forbidden). Top-level user `where` enforces the same rule (Db.readWhere).
185
+ if (!ctx.system) {
186
+ const tScope = resolveScope(ctx, rel.target, "read", depth + 1);
187
+ if (!tScope.allowed) {
188
+ inner = FALSE; // can't filter through a relation you can't read
189
+ }
190
+ else {
191
+ if (tScope.fields !== null) {
192
+ const targetRels = (ctx.schema?.[rel.target]?.relations ?? {});
193
+ for (const k of Object.keys(nested)) {
194
+ if (k === "AND" || k === "OR" || targetRels[k])
195
+ continue;
196
+ if (!tScope.fields.includes(k))
197
+ throw new AclDenied(rel.target, "read", k);
198
+ }
199
+ }
200
+ if (tScope.where)
201
+ inner = and(inner, tScope.where);
202
+ }
203
+ }
204
+ // belongsTo: parent.<fk> IN (SELECT <target pk> FROM target WHERE inner)
205
+ // hasMany: parent.<pk> IN (SELECT <target fk> FROM target WHERE inner)
206
+ return rel.kind === "belongsTo"
207
+ ? { t: "sub", outerCol: rel.column, from: rel.target, selectCol: pkOf(ctx.schema, rel.target), where: inner, negate: false }
208
+ : { t: "sub", outerCol: pkOf(ctx.schema, parentEntity), from: rel.target, selectCol: rel.column, where: inner, negate: false };
209
+ }
210
+ /** Compile a where-rule (user query or policy) into a SqlExpr. Plain columns go
211
+ * through the marker-resolving compiler; relation keys (`{ rel: { … } }`) become
212
+ * security-scoped subqueries. Supports operators, AND/OR, and $identity/$input
213
+ * markers; an unresolvable marker makes its branch match nothing. Schema-less
214
+ * contexts (no relations) behave exactly like the flat compiler.
215
+ *
216
+ * Marker semantics: AND/OR branches compile independently, so an unresolvable
217
+ * marker collapses ONLY its own branch to FALSE — it does not nullify sibling
218
+ * branches. `OR: [{ ownerId: $identity("userId") }, { public: true }]` therefore
219
+ * still grants the `public` branch to a caller whose `userId` can't resolve (and,
220
+ * conversely, an unresolvable marker in one OR branch no longer revokes access the
221
+ * other branches would grant). This is plain boolean logic; the cases are covered
222
+ * by the relwhere suite.
223
+ *
224
+ * `allowRelations` is false for single-table contexts (cell-level `when`, which is
225
+ * evaluated in memory and cannot do a SQL round-trip): a relation key then raises a
226
+ * clear authoring error instead of emitting a `sub` node that throws at read time. */
227
+ export function compileScopedWhere(rule, entity, ctx, depth = 0, allowRelations = true) {
228
+ const relations = (ctx.schema?.[entity]?.relations ?? {});
229
+ const parts = [];
230
+ const plain = {};
231
+ for (const [k, v] of Object.entries(rule)) {
232
+ if (k === "AND" || k === "OR") {
233
+ const groups = v.map((g) => compileScopedWhere(g, entity, ctx, depth, allowRelations));
234
+ parts.push(k === "AND" ? and(...groups) : or(...groups));
235
+ }
236
+ else if (relations[k]) {
237
+ if (!allowRelations) {
238
+ throw new BadRequest(`cell-level \`when\` cannot traverse relations: '${k}' (relations need a SQL round-trip)`);
239
+ }
240
+ parts.push(relationPredicate(relations[k], v, entity, ctx, depth));
241
+ }
242
+ else {
243
+ plain[k] = v;
244
+ }
245
+ }
246
+ if (Object.keys(plain).length > 0) {
247
+ const resolvedPlain = resolveMarkers(plain, ctx.identity, ctx.input);
248
+ parts.push(resolvedPlain === null ? FALSE : compileWhere(resolvedPlain));
249
+ }
250
+ if (parts.length === 0)
251
+ return TRUE; // empty where -> match all
252
+ return parts.length === 1 ? parts[0] : and(...parts);
162
253
  }
163
254
  /** The concrete rules that apply for (entity, action) under this identity — with
164
255
  * resolvers replaced by their warmup result and resolver/unresolved entries dropped. */
@@ -211,15 +302,17 @@ function mergeGrants(grants) {
211
302
  fieldsFns,
212
303
  };
213
304
  }
214
- export function resolveScope(ctx, entity, action) {
305
+ export function resolveScope(ctx, entity, action, depth = 0) {
215
306
  const grants = [];
216
307
  for (const rule of matchedRules(ctx, entity, action)) {
217
308
  if (isDeny(rule))
218
309
  continue;
219
310
  if (isAllow(rule))
220
311
  grants.push(ALLOW_GRANT);
221
- else
222
- grants.push(grantOf(rule, whereToExpr(rule.where ?? {}, ctx.identity, ctx.input), ctx.identity, ctx.input));
312
+ else {
313
+ const where = compileScopedWhere((rule.where ?? {}), entity, ctx, depth);
314
+ grants.push(grantOf(rule, where, entity, ctx, depth));
315
+ }
223
316
  }
224
317
  return mergeGrants(grants);
225
318
  }
@@ -272,7 +365,8 @@ export function resolveRelationScope(ctx, parentEntity, relName, target) {
272
365
  continue;
273
366
  const rel = rule.relations?.[relName];
274
367
  if (rel?.directAccess) {
275
- grants.push(grantOf(rel, rel.where ? whereToExpr(rel.where, ctx.identity, ctx.input) : null, ctx.identity, ctx.input));
368
+ const relWhere = rel.where ? compileScopedWhere(rel.where, target, ctx, 0) : null;
369
+ grants.push(grantOf(rel, relWhere, target, ctx, 0));
276
370
  }
277
371
  }
278
372
  return mergeGrants(grants);
@@ -2,7 +2,7 @@ import { type AclContext } from "./acl";
2
2
  import { type AggFn } from "./read-engine";
3
3
  import type { Driver } from "./driver";
4
4
  import type { EntityFields, SchemaDef } from "../sdk/schema";
5
- import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult, WhereInput } from "../sdk/infer";
5
+ import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult, WhereClause } from "../sdk/infer";
6
6
  type Row = Record<string, unknown>;
7
7
  type Id = string | number | bigint;
8
8
  type OrderSpec<S extends SchemaDef, T extends keyof S> = {
@@ -11,7 +11,7 @@ type OrderSpec<S extends SchemaDef, T extends keyof S> = {
11
11
  };
12
12
  export interface FindSpec<S extends SchemaDef, T extends keyof S> {
13
13
  from: T;
14
- where?: WhereInput<FieldsOf<S[T]>>;
14
+ where?: WhereClause<S, T>;
15
15
  orderBy?: OrderSpec<S, T> | OrderSpec<S, T>[];
16
16
  limit?: number;
17
17
  offset?: number;
@@ -21,7 +21,7 @@ export interface FindSpec<S extends SchemaDef, T extends keyof S> {
21
21
  /** Cursor (keyset) pagination input — `after` is an opaque cursor from a prior page. */
22
22
  export interface PageSpec<S extends SchemaDef, T extends keyof S> {
23
23
  from: T;
24
- where?: WhereInput<FieldsOf<S[T]>>;
24
+ where?: WhereClause<S, T>;
25
25
  orderBy?: OrderSpec<S, T> | OrderSpec<S, T>[];
26
26
  limit?: number;
27
27
  after?: string;
@@ -40,7 +40,7 @@ type Aggregations<F extends EntityFields> = Record<string, {
40
40
  }>;
41
41
  export interface AggregateSpec<S extends SchemaDef, T extends keyof S> {
42
42
  from: T;
43
- where?: WhereInput<FieldsOf<S[T]>>;
43
+ where?: WhereClause<S, T>;
44
44
  groupBy?: (keyof FieldsOf<S[T]> & string) | (keyof FieldsOf<S[T]> & string)[];
45
45
  aggregations: Aggregations<FieldsOf<S[T]>>;
46
46
  }
@@ -59,11 +59,11 @@ export type AggregateResult<F extends EntityFields, G, A extends Aggregations<F>
59
59
  };
60
60
  export declare class Db<S extends SchemaDef = SchemaDef> {
61
61
  private readonly driver;
62
- private readonly acl;
63
62
  private readonly schema;
64
63
  /** Tables read or written during this Db's lifetime. */
65
64
  readonly touched: Set<string>;
66
65
  private readonly dialect;
66
+ private readonly acl;
67
67
  constructor(driver: Driver, acl: AclContext, schema: SchemaDef);
68
68
  /** Resolve the ACL scope for an operation, or grant everything in SYSTEM mode. */
69
69
  private scopeFor;
@@ -91,7 +91,7 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
91
91
  /** Count rows visible to the caller (ACL read scope applied). */
92
92
  count<T extends keyof S & string>(spec: {
93
93
  from: T;
94
- where?: WhereInput<FieldsOf<S[T]>>;
94
+ where?: WhereClause<S, T>;
95
95
  }): Promise<number>;
96
96
  /** Grouped aggregation (count/sum/avg/min/max). ACL read scope is applied, and
97
97
  * every referenced column must be readable under field permissions. The result
@@ -99,11 +99,18 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
99
99
  * each aggregation gets its computed value type. */
100
100
  aggregate<T extends keyof S & string, A extends Aggregations<FieldsOf<S[T]>>, G extends (keyof FieldsOf<S[T]> & string) | (keyof FieldsOf<S[T]> & string)[] = never>(spec: {
101
101
  from: T;
102
- where?: WhereInput<FieldsOf<S[T]>>;
102
+ where?: WhereClause<S, T>;
103
103
  groupBy?: G;
104
104
  aggregations: A;
105
105
  }): Promise<AggregateResult<FieldsOf<S[T]>, G, A>[]>;
106
106
  private readWhere;
107
+ /** Reject a user `where` that filters on a column the caller cannot read (closes
108
+ * the same info-leak as ordering by a hidden column: a filter is an oracle for a
109
+ * hidden field's values). Mirrors `assertReadableCols`. Relation keys are skipped
110
+ * here — they're filtered through the TARGET's read scope in acl.relationPredicate
111
+ * — and AND/OR groups recurse. Operator objects (`{ gt: … }`) sit under the column
112
+ * key, so checking the top-level keys is sufficient. */
113
+ private assertReadableWhere;
107
114
  private selectRaw;
108
115
  private jsonColsOf;
109
116
  private decodeRows;
@@ -10,8 +10,8 @@
10
10
  // Every Db also records the tables it touched during one handler run (`touched`),
11
11
  // which the live-query layer uses to decide which subscriptions to re-check.
12
12
  // Create a fresh Db per handler run so identity and `touched` are scoped.
13
- import { AclDenied, ALLOW_ALL, effectiveFields, projectRow, resolveRelationScope, resolveScope, resolveWriteRules, } from "./acl";
14
- import { and, cmp, compileAggregate, compileCount, compileExpr, compileSelect, compileWhere, eq, inList, or, TRUE, } from "./read-engine";
13
+ import { AclDenied, ALLOW_ALL, compileScopedWhere, effectiveFields, projectRow, resolveRelationScope, resolveScope, resolveWriteRules, } from "./acl";
14
+ import { and, cmp, compileAggregate, compileCount, compileExpr, compileSelect, eq, inList, or, TRUE, } from "./read-engine";
15
15
  import { BadRequest } from "./errors";
16
16
  const DEFAULT_PAGE_SIZE = 50;
17
17
  function normalizeOrder(orderBy) {
@@ -50,15 +50,18 @@ function keysetAfter(order, values) {
50
50
  }
51
51
  export class Db {
52
52
  driver;
53
- acl;
54
53
  schema;
55
54
  /** Tables read or written during this Db's lifetime. */
56
55
  touched = new Set();
57
56
  dialect;
57
+ acl;
58
58
  constructor(driver, acl, schema) {
59
59
  this.driver = driver;
60
- this.acl = acl;
61
60
  this.schema = schema;
61
+ // The ACL context normally already carries the schema (set at the dispatch / DO
62
+ // boundary). Inject it as a safety net so a context built without one can still
63
+ // compile relation-aware `where` rules into subqueries rather than failing obscurely.
64
+ this.acl = acl.schema ? acl : { ...acl, schema };
62
65
  this.dialect = driver.dialect;
63
66
  }
64
67
  /** Resolve the ACL scope for an operation, or grant everything in SYSTEM mode. */
@@ -116,7 +119,7 @@ export class Db {
116
119
  const scope = this.scopeFor(from, "read");
117
120
  if (!scope.allowed)
118
121
  throw new AclDenied(from, "read");
119
- const where = this.readWhere(spec.where, scope);
122
+ const where = this.readWhere(from, spec.where, scope);
120
123
  const orderBy = normalizeOrder(spec.orderBy);
121
124
  if (orderBy)
122
125
  this.assertReadableCols(from, scope, orderBy.map((o) => o.column));
@@ -134,7 +137,7 @@ export class Db {
134
137
  throw new AclDenied(from, "read");
135
138
  const order = this.orderWithPk(from, spec.orderBy);
136
139
  this.assertReadableCols(from, scope, order.map((o) => o.column)); // order + cursor must not leak hidden cols
137
- let where = this.readWhere(spec.where, scope);
140
+ let where = this.readWhere(from, spec.where, scope);
138
141
  if (spec.after != null)
139
142
  where = and(where, keysetAfter(order, decodeCursor(spec.after)));
140
143
  const limit = spec.limit ?? DEFAULT_PAGE_SIZE;
@@ -154,7 +157,7 @@ export class Db {
154
157
  const scope = this.scopeFor(from, "read");
155
158
  if (!scope.allowed)
156
159
  throw new AclDenied(from, "read");
157
- const where = this.readWhere(spec.where, scope);
160
+ const where = this.readWhere(from, spec.where, scope);
158
161
  const { sql, params } = compileCount(from, this.dialect, where);
159
162
  const rows = (await this.driver.exec(sql, params));
160
163
  return Number(rows[0]?.n ?? 0);
@@ -190,15 +193,42 @@ export class Db {
190
193
  if (!scope.fields.includes(c))
191
194
  throw new AclDenied(from, "read", c);
192
195
  }
193
- const where = this.readWhere(spec.where, scope);
196
+ const where = this.readWhere(from, spec.where, scope);
194
197
  const { sql, params } = compileAggregate({ from, where, groupBy, aggregations: spec.aggregations }, this.dialect);
195
198
  return (await this.driver.exec(sql, params));
196
199
  }
197
200
  // --- read internals shared by find/page ---
198
- readWhere(userWhere, scope) {
199
- const userExpr = userWhere ? compileWhere(userWhere) : TRUE;
201
+ readWhere(from, userWhere, scope) {
202
+ // Compiles the user's where relation-aware (relation keys → security-scoped
203
+ // subqueries), then AND-merges the entity's own ACL row scope.
204
+ if (userWhere)
205
+ this.assertReadableWhere(from, scope, userWhere);
206
+ const userExpr = userWhere ? compileScopedWhere(userWhere, from, this.acl) : TRUE;
200
207
  return scope.where ? and(userExpr, scope.where) : userExpr;
201
208
  }
209
+ /** Reject a user `where` that filters on a column the caller cannot read (closes
210
+ * the same info-leak as ordering by a hidden column: a filter is an oracle for a
211
+ * hidden field's values). Mirrors `assertReadableCols`. Relation keys are skipped
212
+ * here — they're filtered through the TARGET's read scope in acl.relationPredicate
213
+ * — and AND/OR groups recurse. Operator objects (`{ gt: … }`) sit under the column
214
+ * key, so checking the top-level keys is sufficient. */
215
+ assertReadableWhere(from, scope, where) {
216
+ if (scope.fields === null || where == null || typeof where !== "object")
217
+ return;
218
+ const relations = this.schema[from]?.relations ?? {};
219
+ for (const [k, v] of Object.entries(where)) {
220
+ if (k === "AND" || k === "OR") {
221
+ for (const g of v)
222
+ this.assertReadableWhere(from, scope, g);
223
+ }
224
+ else if (relations[k]) {
225
+ continue; // relation traversal: enforced against the target's scope downstream
226
+ }
227
+ else if (!scope.fields.includes(k)) {
228
+ throw new AclDenied(from, "read", k);
229
+ }
230
+ }
231
+ }
202
232
  async selectRaw(from, where, orderBy, limit, offset) {
203
233
  const { sql, params } = compileSelect({ from, where, orderBy, limit, offset }, this.dialect);
204
234
  return this.decodeRows(from, await this.driver.exec(sql, params));
@@ -26,9 +26,9 @@ export async function dispatch(handlers, schema, driver, kv, files, env, acl, na
26
26
  }
27
27
  // Warmup: evaluate dynamic resolvers once, reading through a SYSTEM-mode db
28
28
  // (separate from the handler's db, so its reads don't pollute `touched`).
29
- const systemDb = new Db(driver, { acl: acl.acl, identity: acl.identity, system: true }, schema);
29
+ const systemDb = new Db(driver, { acl: acl.acl, identity: acl.identity, system: true, schema }, schema);
30
30
  const resolved = await warmup(acl.acl, acl.identity, systemDb);
31
- const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved }, schema);
31
+ const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved, schema }, schema);
32
32
  const ctx = { db, kv, files, env, identity: acl.identity };
33
33
  const result = handler.kind === "query"
34
34
  ? await handler.run(ctx, parsed)
@@ -24,6 +24,13 @@ export type SqlExpr = {
24
24
  } | {
25
25
  t: "or";
26
26
  parts: SqlExpr[];
27
+ } | {
28
+ t: "sub";
29
+ outerCol: string;
30
+ from: string;
31
+ selectCol: string;
32
+ where: SqlExpr;
33
+ negate: boolean;
27
34
  };
28
35
  export declare const TRUE: SqlExpr;
29
36
  export declare const FALSE: SqlExpr;
@@ -105,6 +105,16 @@ export function compileExpr(expr, dialect, params = []) {
105
105
  const sql = expr.parts.map((p) => compileExpr(p, dialect, params).sql).join(sep);
106
106
  return { sql: expr.parts.length > 1 ? `(${sql})` : sql, params };
107
107
  }
108
+ case "sub": {
109
+ // Inner predicate shares `params`, so placeholder numbering stays correct
110
+ // across dialects (? and $n alike).
111
+ const inner = compileExpr(expr.where, dialect, params).sql;
112
+ const op = expr.negate ? "NOT IN" : "IN";
113
+ return {
114
+ sql: `${dialect.id(expr.outerCol)} ${op} (SELECT ${dialect.id(expr.selectCol)} FROM ${dialect.id(expr.from)} WHERE ${inner})`,
115
+ params,
116
+ };
117
+ }
108
118
  }
109
119
  }
110
120
  // SQL LIKE -> RegExp. `%` is any run, `_` is any single char; SQLite LIKE is
@@ -167,6 +177,10 @@ export function evalExpr(expr, row) {
167
177
  return expr.parts.every((p) => evalExpr(p, row));
168
178
  case "or":
169
179
  return expr.parts.some((p) => evalExpr(p, row));
180
+ case "sub":
181
+ // Relation traversal needs a SQL round-trip; it isn't supported in the
182
+ // in-memory cell-ACL `when` evaluator (those predicates must be single-table).
183
+ throw new Error("relation predicates are not supported in cell-level `when`");
170
184
  }
171
185
  }
172
186
  const AGG_SQL = { count: "COUNT", sum: "SUM", avg: "AVG", min: "MIN", max: "MAX" };