@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.
@@ -45,6 +45,15 @@ export type WhereInput<F extends EntityFields> = {
45
45
  AND?: WhereInput<F>[];
46
46
  OR?: WhereInput<F>[];
47
47
  };
48
+ type PrevDepth = [never, 0, 1, 2, 3, 4, 5];
49
+ type RelTargetTable<S extends SchemaDef, R> = R extends {
50
+ target: infer Tg;
51
+ } ? (Tg extends keyof S ? Tg : never) : never;
52
+ /** A `where` clause: column predicates + AND/OR, plus relation keys that take a
53
+ * nested `WhereClause` over the related entity (traversal). */
54
+ export type WhereClause<S extends SchemaDef, T extends keyof S, D extends number = 5> = WhereInput<FieldsOf<S[T]>> & ([D] extends [never] ? object : {
55
+ [K in keyof RelationsOf<S[T]> as string extends K ? never : number extends K ? never : K]?: WhereClause<S, RelTargetTable<S, RelationsOf<S[T]>[K]>, PrevDepth[D]>;
56
+ });
48
57
  /** Patch input for updates: every column optional, value typed (nullable). */
49
58
  export type InferUpdate<F extends EntityFields> = Partial<{
50
59
  [K in keyof F]: FieldTsType<F[K]> | null;
package/dist/worker.js CHANGED
@@ -131,9 +131,9 @@ export function makeWorker(app) {
131
131
  // --- admin: list known tenants ---
132
132
  if (url.pathname === "/tenants") {
133
133
  if (!isAdmin(identity))
134
- return forbidden("tenants");
134
+ return withCors(forbidden("tenants"), cors);
135
135
  const list = await env.KV.list({ prefix: "tenant:" });
136
- return json({ ok: true, result: list.keys.map((k) => k.name.slice("tenant:".length)) });
136
+ return withCors(json({ ok: true, result: list.keys.map((k) => k.name.slice("tenant:".length)) }), cors);
137
137
  }
138
138
  // --- admin: point-in-time recovery for a tenant ---
139
139
  if (url.pathname === "/admin/recover" && request.method === "POST") {
@@ -155,17 +155,35 @@ export function makeWorker(app) {
155
155
  // --- admin: a tenant's applied schema (hash + tables) ---
156
156
  if (url.pathname === "/admin/schema") {
157
157
  if (!isAdmin(identity))
158
- return forbidden("schema");
158
+ return withCors(forbidden("schema"), cors);
159
159
  const tenant = url.searchParams.get("tenant") ?? "main";
160
160
  const stub = env.PRAMEN.get(env.PRAMEN.idFromName(tenant));
161
- return stub.fetch(new Request("https://do/__schema", { headers: { "x-pramen-tenant": tenant } }));
161
+ const res = await stub.fetch(new Request("https://do/__schema", { headers: { "x-pramen-tenant": tenant } }));
162
+ return withCors(res, cors);
163
+ }
164
+ // --- admin: generic data ops over a tenant's tables (browse/edit any row).
165
+ // Body: { tenant, table, op: list|get|create|update|delete|count, ... }. Runs
166
+ // in the DO under SYSTEM scope (ACL bypassed) — gated to admins here. ---
167
+ if (url.pathname === "/admin/data" && request.method === "POST") {
168
+ if (!isAdmin(identity))
169
+ return forbidden("data");
170
+ const body = (await request.json().catch(() => ({})));
171
+ const tenant = typeof body.tenant === "string" && body.tenant ? body.tenant : "main";
172
+ const stub = env.PRAMEN.get(env.PRAMEN.idFromName(tenant));
173
+ const res = await stub.fetch(new Request("https://do/__admin/data", {
174
+ method: "POST",
175
+ headers: { "content-type": "application/json", "x-pramen-tenant": tenant },
176
+ body: JSON.stringify(body),
177
+ }));
178
+ return withCors(res, cors);
162
179
  }
163
180
  const isRpc = url.pathname.startsWith("/rpc/");
164
181
  const isLive = url.pathname === "/live";
165
182
  if (!isRpc && !(isLive && isWs)) {
166
183
  return new Response("pramen — POST /rpc/<handler> (JSON body), or WebSocket /live for live queries. " +
167
184
  "Header X-Pramen-Tenant selects the store (default: main). " +
168
- "Admin: GET /tenants, POST /admin/recover {tenant,timestamp}, GET /admin/schema?tenant=.\n", { headers: { "content-type": "text/plain" } });
185
+ "Admin: GET /tenants, POST /admin/recover {tenant,timestamp}, GET /admin/schema?tenant=, " +
186
+ "POST /admin/data {tenant,table,op}.\n", { headers: { "content-type": "text/plain" } });
169
187
  }
170
188
  // Authorize the tenant against the identity before reaching the DO, so a
171
189
  // caller can't address (or register) tenants they have no claim to.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/server",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "pramen server runtime — schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/auth.ts CHANGED
@@ -197,14 +197,16 @@ export async function resolveIdentity(request: Request, strategy: VerifyStrategy
197
197
  }
198
198
 
199
199
  /** May this identity address the given tenant? Gates `X-Pramen-Tenant` so a caller
200
- * can't reach (or register) arbitrary tenants. Default policy: admins → any
201
- * tenant; everyone else only tenants listed in their `tenants` claim. Customize
202
- * for your tenancy model (e.g. tenant === identity.org, or a lookup). */
200
+ * can't reach (or register) arbitrary tenants. Default policy:
201
+ * - `main` is the open default tenant: any caller may reach it (anonymous too) —
202
+ * data access is still governed by ACL roles (single-tenant apps live here, and
203
+ * issued login tokens need no `tenants` claim to use it).
204
+ * - other tenants: admins → any; everyone else → only tenants in their `tenants`
205
+ * claim; anonymous → none.
206
+ * Customize for your tenancy model (e.g. tenant === identity.org, or a lookup). */
203
207
  export function authorizeTenant(identity: Identity | null, tenant: string): boolean {
204
- // Anonymous (no verified token) may reach only the default tenant — enough for
205
- // first-class public flows (the `anonymous` ACL role still gates the data), while
206
- // not letting unauthenticated callers address/register arbitrary tenants.
207
- if (!identity) return tenant === "main";
208
+ if (tenant === "main") return true;
209
+ if (!identity) return false;
208
210
  if (identity.roles?.includes("admin")) return true;
209
211
  const allowed = Array.isArray(identity.tenants) ? (identity.tenants as string[]) : [];
210
212
  return allowed.includes(tenant);
@@ -18,6 +18,7 @@
18
18
  import { DurableObject } from "cloudflare:workers";
19
19
  import { migrate } from "./runtime/migrate";
20
20
  import { dispatch } from "./runtime/dispatch";
21
+ import { Db } from "./runtime/db";
21
22
  import { digest } from "./runtime/digest";
22
23
  import { compileAcl, type AclContext, type CompiledAcl } from "./runtime/acl";
23
24
  import { DoSqliteDriver, type Driver } from "./runtime/driver";
@@ -90,6 +91,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
90
91
  const path = new URL(request.url).pathname;
91
92
  if (path === "/__recover") return this.handleRecover(request);
92
93
  if (path === "/__schema") return this.handleSchema();
94
+ if (path === "/__admin/data") return this.handleAdminData(request);
93
95
 
94
96
  const identity = this.identityOf(request);
95
97
 
@@ -291,8 +293,58 @@ export class PramenDOBase extends DurableObject<DoEnv> {
291
293
  return Response.json({ ok: true, result: { hash: hashRow[0]?.value ?? null, tables } });
292
294
  }
293
295
 
296
+ // Generic admin data ops (admin-gated at the Worker). Runs through a SYSTEM-mode
297
+ // Db, so ACL is bypassed — admin can browse/edit any row of any table — while the
298
+ // json/fileRef codec, transactions, and live-query broadcast still apply.
299
+ private async handleAdminData(request: Request): Promise<Response> {
300
+ const b = (await request.json().catch(() => ({}))) as Record<string, unknown>;
301
+ const table = typeof b.table === "string" ? b.table : "";
302
+ const op = typeof b.op === "string" ? b.op : "";
303
+ if (!table || !(table in this.app.schema)) {
304
+ return Response.json({ ok: false, error: "unknown table", code: "bad_request" }, { status: 400 });
305
+ }
306
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
307
+ const db = new Db(this.driver, { acl: this.acl, identity: null, system: true }, this.app.schema) as any;
308
+ try {
309
+ let result: unknown;
310
+ let mutated = false;
311
+ switch (op) {
312
+ case "list":
313
+ result = await db.find({ from: table, where: b.where, orderBy: b.orderBy, limit: b.limit, offset: b.offset });
314
+ break;
315
+ case "count":
316
+ result = await db.count({ from: table, where: b.where });
317
+ break;
318
+ case "get":
319
+ result = (await db.find({ from: table, where: { id: b.id }, limit: 1 }))[0] ?? null;
320
+ break;
321
+ case "create":
322
+ result = await this.driver.transaction(() => db.insert(table, b.values));
323
+ mutated = true;
324
+ break;
325
+ case "update":
326
+ result = (await this.driver.transaction(() => db.update(table, b.id, b.patch))) ?? null;
327
+ mutated = true;
328
+ break;
329
+ case "delete":
330
+ result = await this.driver.transaction(() => db.delete(table, b.id));
331
+ mutated = true;
332
+ break;
333
+ default:
334
+ return Response.json({ ok: false, error: `unknown op: ${op}`, code: "bad_request" }, { status: 400 });
335
+ }
336
+ if (mutated && db.touched.size > 0) await this.broadcast([...db.touched]);
337
+ return Response.json({ ok: true, result });
338
+ } catch (err) {
339
+ const { status, body } = toResponse(err);
340
+ return Response.json(body, { status });
341
+ }
342
+ }
343
+
294
344
  private ctxFor(identity: Identity | null): AclContext {
295
- return { acl: this.acl, identity };
345
+ // Carry the schema so any consumer of this context (not just Db) can compile
346
+ // relation-aware `where` rules into subqueries.
347
+ return { acl: this.acl, identity, schema: this.app.schema };
296
348
  }
297
349
 
298
350
  // The DO env (bindings + vars + secrets) handed to handlers as ctx.env. Loosely
package/src/index.ts CHANGED
@@ -60,6 +60,7 @@ export type {
60
60
  ProjectedRow,
61
61
  RelationsOf,
62
62
  RelationsResult,
63
+ WhereClause,
63
64
  WhereInput,
64
65
  WhereOps,
65
66
  } from "./sdk/infer";
@@ -15,7 +15,6 @@ import {
15
15
  type ResolverDb,
16
16
  type Role,
17
17
  type Validator,
18
- type WhereRule,
19
18
  deny,
20
19
  isAllow,
21
20
  isDeny,
@@ -23,8 +22,9 @@ import {
23
22
  isInputMarker,
24
23
  isResolver,
25
24
  } from "../sdk/acl";
26
- import { compileWhere, evalExpr, FALSE, or, type SqlExpr } from "./read-engine";
27
- import { PramenError } from "./errors";
25
+ import { and, compileWhere, evalExpr, FALSE, or, TRUE, type SqlExpr } from "./read-engine";
26
+ import { BadRequest, PramenError } from "./errors";
27
+ import type { FieldDef, RelationDef, SchemaDef } from "../sdk/schema";
28
28
 
29
29
  export class AclDenied extends PramenError {
30
30
  constructor(
@@ -57,6 +57,9 @@ export interface AclContext {
57
57
  readonly resolved?: Map<number, PolicyRule>;
58
58
  /** SYSTEM mode bypasses all ACL — used for warmup reads and internal ops. */
59
59
  readonly system?: boolean;
60
+ /** The app schema — lets `where` rules traverse relations (`{ rel: { col } }`),
61
+ * compiled to a subquery with the related entity's read scope AND-merged in. */
62
+ readonly schema?: SchemaDef;
60
63
  }
61
64
 
62
65
  /** Evaluate every resolver reachable by the identity's roles, once per request.
@@ -128,14 +131,16 @@ interface Grant {
128
131
  fieldsFns: FieldsFn[];
129
132
  }
130
133
 
131
- /** Build a grant from a policy/relation rule, resolving $identity markers in
132
- * `where` and each conditional `when`. */
133
- function grantOf(rule: PolicyRules | RelationAclRule, where: SqlExpr | null, identity: Identity | null, input: unknown): Grant {
134
+ /** Build a grant from a policy/relation rule, resolving markers in `where` and each
135
+ * conditional `when` (the `when` predicate is single-table — evaluated in memory). */
136
+ function grantOf(rule: PolicyRules | RelationAclRule, where: SqlExpr | null, entity: string, ctx: AclContext, depth: number): Grant {
134
137
  return {
135
138
  where,
136
139
  fields: rule.fields ?? null,
137
140
  conditional: (rule.conditionalFields ?? []).map((cf) => ({
138
- when: whereToExpr(cf.when, identity, input),
141
+ // Cell-level `when` is evaluated per-row in memory (evalExpr), so it must stay
142
+ // single-table — `allowRelations: false` rejects a relation key up front.
143
+ when: compileScopedWhere(cf.when as Record<string, unknown>, entity, ctx, depth, false),
139
144
  fields: cf.fields,
140
145
  })),
141
146
  fieldsFns: rule.fieldsFn ? [rule.fieldsFn] : [],
@@ -173,23 +178,18 @@ function resolveValue(v: unknown, identity: Identity | null, input: unknown): un
173
178
  return v;
174
179
  }
175
180
 
176
- // Resolve every $identity marker in a policy where-rule (bare values, operator
177
- // objects, in/notIn arrays, AND/OR groups). Returns a plain WhereInput, or null
178
- // if any marker is unresolvable in which case the rule matches nothing.
181
+ // Resolve every $identity/$input marker in a SINGLE level of a where-rule (bare
182
+ // values, operator objects, in/notIn arrays). AND/OR groups are split off by
183
+ // `compileScopedWhere` before this runs, so this only ever sees plain columns.
184
+ // Returns a plain WhereInput, or null if any marker is unresolvable — in which
185
+ // case this branch matches nothing (FALSE). Note: because branches are resolved
186
+ // independently, an unresolvable marker nullifies only its own branch, not the
187
+ // whole rule — so `OR: [{ x: $identity(...) }, { public: true }]` still matches
188
+ // the `public` branch for a caller whose marker can't resolve. (See the comment
189
+ // on `compileScopedWhere`.)
179
190
  function resolveMarkers(rule: Record<string, unknown>, identity: Identity | null, input: unknown): Record<string, unknown> | null {
180
191
  const out: Record<string, unknown> = {};
181
192
  for (const [key, v] of Object.entries(rule)) {
182
- if (key === "AND" || key === "OR") {
183
- const groups: Record<string, unknown>[] = [];
184
- for (const g of v as Record<string, unknown>[]) {
185
- const resolved = resolveMarkers(g, identity, input);
186
- if (resolved === null) return null;
187
- groups.push(resolved);
188
- }
189
- out[key] = groups;
190
- continue;
191
- }
192
-
193
193
  const isMarker = isIdentityMarker(v) || isInputMarker(v);
194
194
  if (v !== null && typeof v === "object" && !isMarker && !Array.isArray(v)) {
195
195
  const ops: Record<string, unknown> = {};
@@ -222,12 +222,106 @@ function resolveMarkers(rule: Record<string, unknown>, identity: Identity | null
222
222
  return out;
223
223
  }
224
224
 
225
- /** Turn a policy where-rule into an expression. Supports the full user query
226
- * surface (operators, AND/OR) with $identity / $input markers; an unresolvable
227
- * marker makes the rule match nothing. */
228
- function whereToExpr(rule: WhereRule, identity: Identity | null, input: unknown): SqlExpr {
229
- const resolved = resolveMarkers(rule as Record<string, unknown>, identity, input);
230
- return resolved === null ? FALSE : compileWhere(resolved);
225
+ /** Max relation-traversal nesting depth in a `where` (guards cyclic relations).
226
+ * Kept in lockstep with the `WhereClause` type's depth bound in sdk/infer.ts — if
227
+ * you change one, change the other. */
228
+ export const MAX_REL_DEPTH = 5;
229
+
230
+ /** Primary-key column of an entity (the column a belongsTo points at / a hasMany
231
+ * joins back to). Defaults to `id`. */
232
+ function pkOf(schema: SchemaDef | undefined, entity: string): string {
233
+ const fields = schema?.[entity]?.fields;
234
+ if (fields) for (const [n, f] of Object.entries(fields)) if ((f as FieldDef).primaryKey) return n;
235
+ return "id";
236
+ }
237
+
238
+ /** Compile a relation predicate `{ rel: { … } }` to a subquery, AND-merging the
239
+ * related entity's read scope (and rejecting filters on fields it can't read) so
240
+ * traversal can never widen access beyond a direct read of the target. */
241
+ function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: string, ctx: AclContext, depth: number): SqlExpr {
242
+ if (depth >= MAX_REL_DEPTH) throw new BadRequest("relation `where` is nested too deep");
243
+ if (nested === null || typeof nested !== "object" || Array.isArray(nested)) {
244
+ throw new BadRequest(`relation filter for '${rel.target}' must be an object`);
245
+ }
246
+ let inner = compileScopedWhere(nested as Record<string, unknown>, rel.target, ctx, depth + 1);
247
+
248
+ // Security: a relation filter must respect the target's read ACL (else it leaks).
249
+ // Two distinct "no" outcomes, matching how the rest of the read path behaves:
250
+ // - No read grant on the target at all -> the relation simply yields no rows
251
+ // to match against (FALSE, empty result) — a valid query over a table you
252
+ // can't see.
253
+ // - Readable target, but the filter names a column you can't read -> 403, the
254
+ // same as ordering/aggregating by a hidden column (you referenced something
255
+ // forbidden). Top-level user `where` enforces the same rule (Db.readWhere).
256
+ if (!ctx.system) {
257
+ const tScope = resolveScope(ctx, rel.target, "read", depth + 1);
258
+ if (!tScope.allowed) {
259
+ inner = FALSE; // can't filter through a relation you can't read
260
+ } else {
261
+ if (tScope.fields !== null) {
262
+ const targetRels = (ctx.schema?.[rel.target]?.relations ?? {}) as Record<string, unknown>;
263
+ for (const k of Object.keys(nested as Record<string, unknown>)) {
264
+ if (k === "AND" || k === "OR" || targetRels[k]) continue;
265
+ if (!tScope.fields.includes(k)) throw new AclDenied(rel.target, "read", k);
266
+ }
267
+ }
268
+ if (tScope.where) inner = and(inner, tScope.where);
269
+ }
270
+ }
271
+
272
+ // belongsTo: parent.<fk> IN (SELECT <target pk> FROM target WHERE inner)
273
+ // hasMany: parent.<pk> IN (SELECT <target fk> FROM target WHERE inner)
274
+ return rel.kind === "belongsTo"
275
+ ? { t: "sub", outerCol: rel.column, from: rel.target, selectCol: pkOf(ctx.schema, rel.target), where: inner, negate: false }
276
+ : { t: "sub", outerCol: pkOf(ctx.schema, parentEntity), from: rel.target, selectCol: rel.column, where: inner, negate: false };
277
+ }
278
+
279
+ /** Compile a where-rule (user query or policy) into a SqlExpr. Plain columns go
280
+ * through the marker-resolving compiler; relation keys (`{ rel: { … } }`) become
281
+ * security-scoped subqueries. Supports operators, AND/OR, and $identity/$input
282
+ * markers; an unresolvable marker makes its branch match nothing. Schema-less
283
+ * contexts (no relations) behave exactly like the flat compiler.
284
+ *
285
+ * Marker semantics: AND/OR branches compile independently, so an unresolvable
286
+ * marker collapses ONLY its own branch to FALSE — it does not nullify sibling
287
+ * branches. `OR: [{ ownerId: $identity("userId") }, { public: true }]` therefore
288
+ * still grants the `public` branch to a caller whose `userId` can't resolve (and,
289
+ * conversely, an unresolvable marker in one OR branch no longer revokes access the
290
+ * other branches would grant). This is plain boolean logic; the cases are covered
291
+ * by the relwhere suite.
292
+ *
293
+ * `allowRelations` is false for single-table contexts (cell-level `when`, which is
294
+ * evaluated in memory and cannot do a SQL round-trip): a relation key then raises a
295
+ * clear authoring error instead of emitting a `sub` node that throws at read time. */
296
+ export function compileScopedWhere(
297
+ rule: Record<string, unknown>,
298
+ entity: string,
299
+ ctx: AclContext,
300
+ depth = 0,
301
+ allowRelations = true,
302
+ ): SqlExpr {
303
+ const relations = (ctx.schema?.[entity]?.relations ?? {}) as Record<string, RelationDef>;
304
+ const parts: SqlExpr[] = [];
305
+ const plain: Record<string, unknown> = {};
306
+ for (const [k, v] of Object.entries(rule)) {
307
+ if (k === "AND" || k === "OR") {
308
+ const groups = (v as Record<string, unknown>[]).map((g) => compileScopedWhere(g, entity, ctx, depth, allowRelations));
309
+ parts.push(k === "AND" ? and(...groups) : or(...groups));
310
+ } else if (relations[k]) {
311
+ if (!allowRelations) {
312
+ throw new BadRequest(`cell-level \`when\` cannot traverse relations: '${k}' (relations need a SQL round-trip)`);
313
+ }
314
+ parts.push(relationPredicate(relations[k]!, v, entity, ctx, depth));
315
+ } else {
316
+ plain[k] = v;
317
+ }
318
+ }
319
+ if (Object.keys(plain).length > 0) {
320
+ const resolvedPlain = resolveMarkers(plain, ctx.identity, ctx.input);
321
+ parts.push(resolvedPlain === null ? FALSE : compileWhere(resolvedPlain));
322
+ }
323
+ if (parts.length === 0) return TRUE; // empty where -> match all
324
+ return parts.length === 1 ? parts[0]! : and(...parts);
231
325
  }
232
326
 
233
327
  /** The concrete rules that apply for (entity, action) under this identity — with
@@ -276,12 +370,15 @@ function mergeGrants(grants: Grant[]): Scope {
276
370
  };
277
371
  }
278
372
 
279
- export function resolveScope(ctx: AclContext, entity: string, action: Action): Scope {
373
+ export function resolveScope(ctx: AclContext, entity: string, action: Action, depth = 0): Scope {
280
374
  const grants: Grant[] = [];
281
375
  for (const rule of matchedRules(ctx, entity, action)) {
282
376
  if (isDeny(rule)) continue;
283
377
  if (isAllow(rule)) grants.push(ALLOW_GRANT);
284
- else grants.push(grantOf(rule, whereToExpr(rule.where ?? {}, ctx.identity, ctx.input), ctx.identity, ctx.input));
378
+ else {
379
+ const where = compileScopedWhere((rule.where ?? {}) as Record<string, unknown>, entity, ctx, depth);
380
+ grants.push(grantOf(rule, where, entity, ctx, depth));
381
+ }
285
382
  }
286
383
  return mergeGrants(grants);
287
384
  }
@@ -346,7 +443,8 @@ export function resolveRelationScope(
346
443
  if (isAllow(rule) || isDeny(rule)) continue;
347
444
  const rel = rule.relations?.[relName];
348
445
  if (rel?.directAccess) {
349
- grants.push(grantOf(rel, rel.where ? whereToExpr(rel.where, ctx.identity, ctx.input) : null, ctx.identity, ctx.input));
446
+ const relWhere = rel.where ? compileScopedWhere(rel.where as Record<string, unknown>, target, ctx, 0) : null;
447
+ grants.push(grantOf(rel, relWhere, target, ctx, 0));
350
448
  }
351
449
  }
352
450
 
package/src/runtime/db.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  import {
15
15
  AclDenied,
16
16
  ALLOW_ALL,
17
+ compileScopedWhere,
17
18
  effectiveFields,
18
19
  projectRow,
19
20
  resolveRelationScope,
@@ -30,7 +31,6 @@ import {
30
31
  compileCount,
31
32
  compileExpr,
32
33
  compileSelect,
33
- compileWhere,
34
34
  eq,
35
35
  inList,
36
36
  or,
@@ -42,7 +42,7 @@ import {
42
42
  import { BadRequest } from "./errors";
43
43
  import type { Dialect, Driver } from "./driver";
44
44
  import type { EntityFields, FieldDef, RelationDef, SchemaDef } from "../sdk/schema";
45
- import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult, WhereInput } from "../sdk/infer";
45
+ import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult, WhereClause } from "../sdk/infer";
46
46
 
47
47
  type Row = Record<string, unknown>;
48
48
  type Action = "read" | "create" | "update" | "delete";
@@ -63,7 +63,7 @@ type OrderSpec<S extends SchemaDef, T extends keyof S> = {
63
63
 
64
64
  export interface FindSpec<S extends SchemaDef, T extends keyof S> {
65
65
  from: T;
66
- where?: WhereInput<FieldsOf<S[T]>>;
66
+ where?: WhereClause<S, T>;
67
67
  orderBy?: OrderSpec<S, T> | OrderSpec<S, T>[];
68
68
  limit?: number;
69
69
  offset?: number;
@@ -74,7 +74,7 @@ export interface FindSpec<S extends SchemaDef, T extends keyof S> {
74
74
  /** Cursor (keyset) pagination input — `after` is an opaque cursor from a prior page. */
75
75
  export interface PageSpec<S extends SchemaDef, T extends keyof S> {
76
76
  from: T;
77
- where?: WhereInput<FieldsOf<S[T]>>;
77
+ where?: WhereClause<S, T>;
78
78
  orderBy?: OrderSpec<S, T> | OrderSpec<S, T>[];
79
79
  limit?: number;
80
80
  after?: string;
@@ -93,7 +93,7 @@ type Aggregations<F extends EntityFields> = Record<string, { fn: AggFn; column?:
93
93
 
94
94
  export interface AggregateSpec<S extends SchemaDef, T extends keyof S> {
95
95
  from: T;
96
- where?: WhereInput<FieldsOf<S[T]>>;
96
+ where?: WhereClause<S, T>;
97
97
  groupBy?: (keyof FieldsOf<S[T]> & string) | (keyof FieldsOf<S[T]> & string)[];
98
98
  aggregations: Aggregations<FieldsOf<S[T]>>;
99
99
  }
@@ -154,12 +154,17 @@ export class Db<S extends SchemaDef = SchemaDef> {
154
154
  /** Tables read or written during this Db's lifetime. */
155
155
  readonly touched = new Set<string>();
156
156
  private readonly dialect: Dialect;
157
+ private readonly acl: AclContext;
157
158
 
158
159
  constructor(
159
160
  private readonly driver: Driver,
160
- private readonly acl: AclContext,
161
+ acl: AclContext,
161
162
  private readonly schema: SchemaDef,
162
163
  ) {
164
+ // The ACL context normally already carries the schema (set at the dispatch / DO
165
+ // boundary). Inject it as a safety net so a context built without one can still
166
+ // compile relation-aware `where` rules into subqueries rather than failing obscurely.
167
+ this.acl = acl.schema ? acl : { ...acl, schema };
163
168
  this.dialect = driver.dialect;
164
169
  }
165
170
 
@@ -224,7 +229,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
224
229
  const scope = this.scopeFor(from, "read");
225
230
  if (!scope.allowed) throw new AclDenied(from, "read");
226
231
 
227
- const where = this.readWhere(spec.where, scope);
232
+ const where = this.readWhere(from, spec.where, scope);
228
233
  const orderBy = normalizeOrder(spec.orderBy);
229
234
  if (orderBy) this.assertReadableCols(from, scope, orderBy.map((o) => o.column));
230
235
  const raw = await this.selectRaw(from, where, orderBy, spec.limit, spec.offset);
@@ -245,7 +250,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
245
250
 
246
251
  const order = this.orderWithPk(from, spec.orderBy);
247
252
  this.assertReadableCols(from, scope, order.map((o) => o.column)); // order + cursor must not leak hidden cols
248
- let where = this.readWhere(spec.where, scope);
253
+ let where = this.readWhere(from, spec.where, scope);
249
254
  if (spec.after != null) where = and(where, keysetAfter(order, decodeCursor(spec.after)));
250
255
 
251
256
  const limit = spec.limit ?? DEFAULT_PAGE_SIZE;
@@ -261,12 +266,12 @@ export class Db<S extends SchemaDef = SchemaDef> {
261
266
  }
262
267
 
263
268
  /** Count rows visible to the caller (ACL read scope applied). */
264
- async count<T extends keyof S & string>(spec: { from: T; where?: WhereInput<FieldsOf<S[T]>> }): Promise<number> {
269
+ async count<T extends keyof S & string>(spec: { from: T; where?: WhereClause<S, T> }): Promise<number> {
265
270
  const from = spec.from as string;
266
271
  this.touched.add(from);
267
272
  const scope = this.scopeFor(from, "read");
268
273
  if (!scope.allowed) throw new AclDenied(from, "read");
269
- const where = this.readWhere(spec.where, scope);
274
+ const where = this.readWhere(from, spec.where, scope);
270
275
  const { sql, params } = compileCount(from, this.dialect, where);
271
276
  const rows = (await this.driver.exec(sql, params)) as { n: number }[];
272
277
  return Number(rows[0]?.n ?? 0);
@@ -282,7 +287,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
282
287
  G extends (keyof FieldsOf<S[T]> & string) | (keyof FieldsOf<S[T]> & string)[] = never,
283
288
  >(spec: {
284
289
  from: T;
285
- where?: WhereInput<FieldsOf<S[T]>>;
290
+ where?: WhereClause<S, T>;
286
291
  groupBy?: G;
287
292
  aggregations: A;
288
293
  }): Promise<AggregateResult<FieldsOf<S[T]>, G, A>[]> {
@@ -309,18 +314,41 @@ export class Db<S extends SchemaDef = SchemaDef> {
309
314
  for (const c of refs) if (!scope.fields.includes(c)) throw new AclDenied(from, "read", c);
310
315
  }
311
316
 
312
- const where = this.readWhere(spec.where, scope);
317
+ const where = this.readWhere(from, spec.where, scope);
313
318
  const { sql, params } = compileAggregate({ from, where, groupBy, aggregations: spec.aggregations }, this.dialect);
314
319
  return (await this.driver.exec(sql, params)) as AggregateResult<FieldsOf<S[T]>, G, A>[];
315
320
  }
316
321
 
317
322
  // --- read internals shared by find/page ---
318
323
 
319
- private readWhere(userWhere: unknown, scope: Scope): SqlExpr {
320
- const userExpr: SqlExpr = userWhere ? compileWhere(userWhere as Row) : TRUE;
324
+ private readWhere(from: string, userWhere: unknown, scope: Scope): SqlExpr {
325
+ // Compiles the user's where relation-aware (relation keys security-scoped
326
+ // subqueries), then AND-merges the entity's own ACL row scope.
327
+ if (userWhere) this.assertReadableWhere(from, scope, userWhere);
328
+ const userExpr: SqlExpr = userWhere ? compileScopedWhere(userWhere as Record<string, unknown>, from, this.acl) : TRUE;
321
329
  return scope.where ? and(userExpr, scope.where) : userExpr;
322
330
  }
323
331
 
332
+ /** Reject a user `where` that filters on a column the caller cannot read (closes
333
+ * the same info-leak as ordering by a hidden column: a filter is an oracle for a
334
+ * hidden field's values). Mirrors `assertReadableCols`. Relation keys are skipped
335
+ * here — they're filtered through the TARGET's read scope in acl.relationPredicate
336
+ * — and AND/OR groups recurse. Operator objects (`{ gt: … }`) sit under the column
337
+ * key, so checking the top-level keys is sufficient. */
338
+ private assertReadableWhere(from: string, scope: Scope, where: unknown): void {
339
+ if (scope.fields === null || where == null || typeof where !== "object") return;
340
+ const relations = this.schema[from]?.relations ?? {};
341
+ for (const [k, v] of Object.entries(where as Record<string, unknown>)) {
342
+ if (k === "AND" || k === "OR") {
343
+ for (const g of v as unknown[]) this.assertReadableWhere(from, scope, g);
344
+ } else if (relations[k]) {
345
+ continue; // relation traversal: enforced against the target's scope downstream
346
+ } else if (!scope.fields.includes(k)) {
347
+ throw new AclDenied(from, "read", k);
348
+ }
349
+ }
350
+ }
351
+
324
352
  private async selectRaw(from: string, where: SqlExpr, orderBy?: OrderBy[], limit?: number, offset?: number): Promise<Row[]> {
325
353
  const { sql, params } = compileSelect({ from, where, orderBy, limit, offset }, this.dialect);
326
354
  return this.decodeRows(from, await this.driver.exec(sql, params));
@@ -50,10 +50,10 @@ export async function dispatch(
50
50
 
51
51
  // Warmup: evaluate dynamic resolvers once, reading through a SYSTEM-mode db
52
52
  // (separate from the handler's db, so its reads don't pollute `touched`).
53
- const systemDb = new Db(driver, { acl: acl.acl, identity: acl.identity, system: true }, schema);
53
+ const systemDb = new Db(driver, { acl: acl.acl, identity: acl.identity, system: true, schema }, schema);
54
54
  const resolved = await warmup(acl.acl, acl.identity, systemDb as unknown as ResolverDb);
55
55
 
56
- const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved }, schema);
56
+ const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved, schema }, schema);
57
57
  const ctx: HandlerContext = { db, kv, files, env, identity: acl.identity };
58
58
 
59
59
  const result =
@@ -23,7 +23,11 @@ export type SqlExpr =
23
23
  | { t: "in"; col: string; values: unknown[]; negate: boolean }
24
24
  | { t: "null"; col: string; negate: boolean }
25
25
  | { t: "and"; parts: SqlExpr[] }
26
- | { t: "or"; parts: SqlExpr[] };
26
+ | { t: "or"; parts: SqlExpr[] }
27
+ // Relation traversal: `outerCol IN (SELECT selectCol FROM from WHERE where)`.
28
+ // Built by the ACL layer (which knows schema + the target's read scope); the
29
+ // inner predicate compiles inline so placeholders share the outer param sequence.
30
+ | { t: "sub"; outerCol: string; from: string; selectCol: string; where: SqlExpr; negate: boolean };
27
31
 
28
32
  export const TRUE: SqlExpr = { t: "true" };
29
33
  export const FALSE: SqlExpr = { t: "false" };
@@ -104,6 +108,16 @@ export function compileExpr(expr: SqlExpr, dialect: Dialect, params: unknown[] =
104
108
  const sql = expr.parts.map((p) => compileExpr(p, dialect, params).sql).join(sep);
105
109
  return { sql: expr.parts.length > 1 ? `(${sql})` : sql, params };
106
110
  }
111
+ case "sub": {
112
+ // Inner predicate shares `params`, so placeholder numbering stays correct
113
+ // across dialects (? and $n alike).
114
+ const inner = compileExpr(expr.where, dialect, params).sql;
115
+ const op = expr.negate ? "NOT IN" : "IN";
116
+ return {
117
+ sql: `${dialect.id(expr.outerCol)} ${op} (SELECT ${dialect.id(expr.selectCol)} FROM ${dialect.id(expr.from)} WHERE ${inner})`,
118
+ params,
119
+ };
120
+ }
107
121
  }
108
122
  }
109
123
 
@@ -161,6 +175,10 @@ export function evalExpr(expr: SqlExpr, row: Record<string, unknown>): boolean {
161
175
  return expr.parts.every((p) => evalExpr(p, row));
162
176
  case "or":
163
177
  return expr.parts.some((p) => evalExpr(p, row));
178
+ case "sub":
179
+ // Relation traversal needs a SQL round-trip; it isn't supported in the
180
+ // in-memory cell-ACL `when` evaluator (those predicates must be single-table).
181
+ throw new Error("relation predicates are not supported in cell-level `when`");
164
182
  }
165
183
  }
166
184