@pramen/server 0.0.48 → 0.0.50

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.
Files changed (66) hide show
  1. package/dist/auth.d.ts +4 -3
  2. package/dist/cli.js +17 -30
  3. package/dist/dev.d.ts +1 -0
  4. package/dist/dev.js +9 -0
  5. package/dist/durable-object.d.ts +1 -0
  6. package/dist/durable-object.js +13 -6
  7. package/dist/index.d.ts +7 -5
  8. package/dist/index.js +6 -2
  9. package/dist/pramen.d.ts +4 -2
  10. package/dist/runtime/acl.d.ts +14 -5
  11. package/dist/runtime/acl.js +1 -5
  12. package/dist/runtime/db.d.ts +3 -2
  13. package/dist/runtime/dev-token.d.ts +4 -0
  14. package/dist/runtime/dev-token.js +34 -0
  15. package/dist/runtime/dispatch.d.ts +3 -1
  16. package/dist/runtime/dispatch.js +2 -0
  17. package/dist/runtime/driver.d.ts +8 -5
  18. package/dist/runtime/errors.d.ts +6 -0
  19. package/dist/runtime/errors.js +8 -0
  20. package/dist/runtime/mail.d.ts +2 -1
  21. package/dist/runtime/protocol.d.ts +4 -3
  22. package/dist/runtime/queue-consumer.d.ts +4 -2
  23. package/dist/runtime/queue.d.ts +3 -2
  24. package/dist/runtime/read-engine.d.ts +11 -9
  25. package/dist/runtime/read-engine.js +4 -1
  26. package/dist/runtime/registry.d.ts +4 -1
  27. package/dist/runtime/registry.js +0 -3
  28. package/dist/runtime/schema-diff.d.ts +6 -6
  29. package/dist/runtime/schema-diff.js +4 -4
  30. package/dist/runtime/storage.d.ts +4 -4
  31. package/dist/runtime/storage.js +8 -63
  32. package/dist/runtime/token.d.ts +22 -0
  33. package/dist/runtime/token.js +88 -0
  34. package/dist/sdk/acl.d.ts +18 -11
  35. package/dist/sdk/handlers.d.ts +16 -3
  36. package/dist/sdk/infer.d.ts +17 -0
  37. package/dist/worker.d.ts +2 -1
  38. package/dist/worker.js +20 -10
  39. package/package.json +13 -3
  40. package/src/auth.ts +7 -6
  41. package/src/cli.ts +22 -36
  42. package/src/dev.ts +10 -0
  43. package/src/durable-object.ts +18 -9
  44. package/src/index.ts +14 -4
  45. package/src/pramen.ts +4 -2
  46. package/src/runtime/acl.ts +45 -31
  47. package/src/runtime/db.ts +14 -12
  48. package/src/runtime/dev-token.ts +36 -0
  49. package/src/runtime/dispatch.ts +7 -3
  50. package/src/runtime/driver.ts +11 -7
  51. package/src/runtime/errors.ts +9 -0
  52. package/src/runtime/mail.ts +2 -1
  53. package/src/runtime/migrate.ts +2 -1
  54. package/src/runtime/outbox.ts +2 -1
  55. package/src/runtime/protocol.ts +5 -3
  56. package/src/runtime/queue-consumer.ts +4 -2
  57. package/src/runtime/queue.ts +4 -2
  58. package/src/runtime/read-engine.ts +27 -21
  59. package/src/runtime/registry.ts +5 -1
  60. package/src/runtime/schema-diff.ts +14 -14
  61. package/src/runtime/storage.ts +14 -62
  62. package/src/runtime/token.ts +94 -0
  63. package/src/sdk/acl.ts +29 -14
  64. package/src/sdk/handlers.ts +17 -3
  65. package/src/sdk/infer.ts +21 -0
  66. package/src/worker.ts +24 -11
@@ -15,6 +15,8 @@ import {
15
15
  type ResolverDb,
16
16
  type Role,
17
17
  type Validator,
18
+ type WhereRule,
19
+ type WhereValue,
18
20
  deny,
19
21
  isAllow,
20
22
  isDeny,
@@ -25,7 +27,8 @@ import {
25
27
  } from "../sdk/acl";
26
28
  import { and, compileWhere, evalExpr, FALSE, not, or, TRUE, type SqlExpr } from "./read-engine";
27
29
  import { BadRequest, PramenError } from "./errors";
28
- import type { FieldDef, RelationDef, SchemaDef } from "../sdk/schema";
30
+ import type { CellValue, Row } from "../sdk/infer";
31
+ import type { FieldDef, RelationDef, RelationDefs, SchemaDef } from "../sdk/schema";
29
32
 
30
33
  export class AclDenied extends PramenError {
31
34
  constructor(
@@ -61,6 +64,14 @@ export interface AclContext {
61
64
  /** The app schema — lets `where` rules traverse relations (`{ rel: { col } }`),
62
65
  * compiled to a subquery with the related entity's read scope AND-merged in. */
63
66
  readonly schema?: SchemaDef;
67
+ /** The tenant this request is for — the `x-pramen-tenant` value the DO was addressed
68
+ * with. Carried so a handler can mint a tenant-scoped capability (e.g. a signed page
69
+ * preview link) without the caller supplying, and thus being able to forge, a tenant. */
70
+ readonly tenant?: string;
71
+ /** Which substrate is serving this request — `"do"` (a Durable Object, the default) or
72
+ * `"d1"` (the shared D1 database, selected per-request with `x-pramen-store: d1`). A
73
+ * handler needs this when a capability it mints can only be redeemed on one of them. */
74
+ readonly store?: "do" | "d1";
64
75
  /** The partition this DO serves. When set, Db rejects any access to a table that
65
76
  * lives in a different partition (a partition-DO only owns its own tables). Unset
66
77
  * (e.g. the D1/Worker shared-store path) disables the guard — a no-op. */
@@ -150,7 +161,7 @@ function grantOf(rule: PolicyRules | RelationAclRule, where: SqlExpr | null, ent
150
161
  conditional: (rule.conditionalFields ?? []).map((cf) => ({
151
162
  // Cell-level `when` is evaluated per-row in memory (evalExpr), so it must stay
152
163
  // single-table — `allowRelations: false` rejects a relation key up front.
153
- when: compileScopedWhere(cf.when as Record<string, unknown>, entity, ctx, depth, false),
164
+ when: compileScopedWhere(cf.when as WhereRule, entity, ctx, depth, false),
154
165
  fields: cf.fields,
155
166
  })),
156
167
  fieldsFns: rule.fieldsFn ? [rule.fieldsFn] : [],
@@ -168,7 +179,7 @@ function rolesOf(identity: Identity | null): string[] {
168
179
  }
169
180
 
170
181
  function getPath(obj: unknown, path: string): unknown {
171
- return path.split(".").reduce<unknown>((acc, seg) => (acc == null ? undefined : (acc as Record<string, unknown>)[seg]), obj ?? undefined);
182
+ return path.split(".").reduce<unknown>((acc, seg) => (acc == null ? undefined : (acc as WhereRule)[seg]), obj ?? undefined);
172
183
  }
173
184
 
174
185
  const UNRESOLVED = Symbol("unresolved");
@@ -177,15 +188,18 @@ const UNRESOLVED = Symbol("unresolved");
177
188
  // $input marker (against the request input — a capability/by-key grant), or a
178
189
  // $now marker (the evaluation instant). An unresolvable marker yields UNRESOLVED,
179
190
  // which makes its rule match nothing. $now always resolves.
180
- function resolveValue(v: unknown, identity: Identity | null, input: unknown): unknown {
191
+ /** A resolved where value, or the sentinel meaning "this marker could not resolve". */
192
+ type ResolvedWhereValue = WhereValue | typeof UNRESOLVED;
193
+
194
+ function resolveValue(v: WhereValue, identity: Identity | null, input: unknown): ResolvedWhereValue {
181
195
  if (isNowMarker(v)) return new Date().toISOString();
182
196
  if (isIdentityMarker(v)) {
183
197
  const value = getPath(identity, v.path);
184
- return value === undefined ? UNRESOLVED : value;
198
+ return value === undefined ? UNRESOLVED : (value as WhereValue);
185
199
  }
186
200
  if (isInputMarker(v)) {
187
201
  const value = getPath(input, v.path);
188
- return value === undefined ? UNRESOLVED : value;
202
+ return value === undefined ? UNRESOLVED : (value as WhereValue);
189
203
  }
190
204
  return v;
191
205
  }
@@ -199,22 +213,22 @@ function resolveValue(v: unknown, identity: Identity | null, input: unknown): un
199
213
  // whole rule — so `OR: [{ x: $identity(...) }, { public: true }]` still matches
200
214
  // the `public` branch for a caller whose marker can't resolve. (See the comment
201
215
  // on `compileScopedWhere`.)
202
- function resolveMarkers(rule: Record<string, unknown>, identity: Identity | null, input: unknown): Record<string, unknown> | null {
203
- const out: Record<string, unknown> = {};
216
+ function resolveMarkers(rule: WhereRule, identity: Identity | null, input: unknown): WhereRule | null {
217
+ const out: WhereRule = {};
204
218
  for (const [key, v] of Object.entries(rule)) {
205
219
  const isMarker = isIdentityMarker(v) || isInputMarker(v) || isNowMarker(v);
206
220
  if (v !== null && typeof v === "object" && !isMarker && !Array.isArray(v)) {
207
- const ops: Record<string, unknown> = {};
208
- for (const [op, val] of Object.entries(v as Record<string, unknown>)) {
221
+ const ops: WhereRule = {};
222
+ for (const [op, val] of Object.entries(v as WhereRule)) {
209
223
  if (op === "in" || op === "notIn") {
210
- let arr: unknown;
224
+ let arr: ResolvedWhereValue;
211
225
  if (isIdentityMarker(val) || isInputMarker(val)) {
212
226
  arr = resolveValue(val, identity, input);
213
227
  if (arr === UNRESOLVED) return null;
214
228
  } else {
215
- const mapped = (val as unknown[]).map((x) => resolveValue(x, identity, input));
229
+ const mapped = (val as WhereValue[]).map((x) => resolveValue(x, identity, input));
216
230
  if (mapped.some((x) => x === UNRESOLVED)) return null;
217
- arr = mapped;
231
+ arr = mapped as WhereValue[];
218
232
  }
219
233
  if (!Array.isArray(arr)) return null; // marker must resolve to a list
220
234
  ops[op] = arr;
@@ -258,13 +272,13 @@ function pkOf(schema: SchemaDef | undefined, entity: string): string {
258
272
  * unreadable column is LIKE-oracle'able through the subquery). Nested relation keys
259
273
  * are skipped: they're re-scoped against THEIR own target's read scope downstream.
260
274
  * Mirrors Db.assertReadableWhere's recursion for the top-level user `where`. */
261
- function assertReadableRelationWhere(where: Record<string, unknown>, target: string, fields: string[], ctx: AclContext): void {
262
- const targetRels = (ctx.schema?.[target]?.relations ?? {}) as Record<string, unknown>;
275
+ function assertReadableRelationWhere(where: WhereRule, target: string, fields: string[], ctx: AclContext): void {
276
+ const targetRels: RelationDefs = ctx.schema?.[target]?.relations ?? {};
263
277
  for (const [k, v] of Object.entries(where)) {
264
278
  if (k === "AND" || k === "OR") {
265
- for (const g of v as Record<string, unknown>[]) assertReadableRelationWhere(g, target, fields, ctx);
279
+ for (const g of v as WhereRule[]) assertReadableRelationWhere(g, target, fields, ctx);
266
280
  } else if (k === "NOT") {
267
- assertReadableRelationWhere(v as Record<string, unknown>, target, fields, ctx);
281
+ assertReadableRelationWhere(v as WhereRule, target, fields, ctx);
268
282
  } else if (targetRels[k]) {
269
283
  continue;
270
284
  } else if (!fields.includes(k)) {
@@ -281,7 +295,7 @@ function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: stri
281
295
  if (nested === null || typeof nested !== "object" || Array.isArray(nested)) {
282
296
  throw new BadRequest(`relation filter for '${rel.target}' must be an object`);
283
297
  }
284
- let inner = compileScopedWhere(nested as Record<string, unknown>, rel.target, ctx, depth + 1);
298
+ let inner = compileScopedWhere(nested as WhereRule, rel.target, ctx, depth + 1);
285
299
 
286
300
  // Security: a relation filter must respect the target's read ACL (else it leaks).
287
301
  // Two distinct "no" outcomes, matching how the rest of the read path behaves:
@@ -297,7 +311,7 @@ function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: stri
297
311
  inner = FALSE; // can't filter through a relation you can't read
298
312
  } else {
299
313
  if (tScope.fields !== null) {
300
- assertReadableRelationWhere(nested as Record<string, unknown>, rel.target, tScope.fields, ctx);
314
+ assertReadableRelationWhere(nested as WhereRule, rel.target, tScope.fields, ctx);
301
315
  }
302
316
  if (tScope.where) inner = and(inner, tScope.where);
303
317
  }
@@ -336,7 +350,7 @@ function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: stri
336
350
  * evaluated in memory and cannot do a SQL round-trip): a relation key then raises a
337
351
  * clear authoring error instead of emitting a `sub` node that throws at read time. */
338
352
  export function compileScopedWhere(
339
- rule: Record<string, unknown>,
353
+ rule: WhereRule,
340
354
  entity: string,
341
355
  ctx: AclContext,
342
356
  depth = 0,
@@ -344,13 +358,13 @@ export function compileScopedWhere(
344
358
  ): SqlExpr {
345
359
  const relations = (ctx.schema?.[entity]?.relations ?? {}) as Record<string, RelationDef>;
346
360
  const parts: SqlExpr[] = [];
347
- const plain: Record<string, unknown> = {};
361
+ const plain: WhereRule = {};
348
362
  for (const [k, v] of Object.entries(rule)) {
349
363
  if (k === "AND" || k === "OR") {
350
- const groups = (v as Record<string, unknown>[]).map((g) => compileScopedWhere(g, entity, ctx, depth, allowRelations));
364
+ const groups = (v as WhereRule[]).map((g) => compileScopedWhere(g, entity, ctx, depth, allowRelations));
351
365
  parts.push(k === "AND" ? and(...groups) : or(...groups));
352
366
  } else if (k === "NOT") {
353
- parts.push(not(compileScopedWhere(v as Record<string, unknown>, entity, ctx, depth, allowRelations)));
367
+ parts.push(not(compileScopedWhere(v as WhereRule, entity, ctx, depth, allowRelations)));
354
368
  } else if (relations[k]) {
355
369
  if (!allowRelations) {
356
370
  throw new BadRequest(`cell-level \`when\` cannot traverse relations: '${k}' (relations need a SQL round-trip)`);
@@ -420,7 +434,7 @@ export function resolveScope(ctx: AclContext, entity: string, action: Action, de
420
434
  if (isDeny(rule)) continue;
421
435
  if (isAllow(rule)) grants.push(ALLOW_GRANT);
422
436
  else {
423
- const where = compileScopedWhere((rule.where ?? {}) as Record<string, unknown>, entity, ctx, depth);
437
+ const where = compileScopedWhere((rule.where ?? {}) as WhereRule, entity, ctx, depth);
424
438
  grants.push(grantOf(rule, where, entity, ctx, depth));
425
439
  }
426
440
  }
@@ -431,7 +445,7 @@ export function resolveScope(ctx: AclContext, entity: string, action: Action, de
431
445
  * Returns null (all fields) when the base is null or a resolver grants everything. */
432
446
  export function effectiveFields(
433
447
  scope: Scope,
434
- row: Record<string, unknown>,
448
+ row: Row,
435
449
  identity: Identity | null,
436
450
  ): string[] | null {
437
451
  if (scope.fields === null) return null;
@@ -448,18 +462,18 @@ export function effectiveFields(
448
462
  /** Forced values + validators for a write, gathered from matched write policies.
449
463
  * `set` values are resolved against the identity; later policies override earlier. */
450
464
  export interface WriteRules {
451
- set: Record<string, unknown>;
465
+ set: Row;
452
466
  validators: Validator[];
453
467
  }
454
468
 
455
469
  export function resolveWriteRules(ctx: AclContext, entity: string, action: Action): WriteRules {
456
- const set: Record<string, unknown> = {};
470
+ const set: Row = {};
457
471
  const validators: Validator[] = [];
458
472
  for (const rule of matchedRules(ctx, entity, action)) {
459
473
  if (isAllow(rule) || isDeny(rule)) continue;
460
474
  if (rule.set) {
461
475
  for (const [col, v] of Object.entries(rule.set)) {
462
- set[col] = typeof v === "function" ? (v as (i: Identity | null) => unknown)(ctx.identity) : v;
476
+ set[col] = typeof v === "function" ? (v as (i: Identity | null) => CellValue)(ctx.identity) : v;
463
477
  }
464
478
  }
465
479
  if (rule.validate) validators.push(rule.validate);
@@ -487,7 +501,7 @@ export function resolveRelationScope(
487
501
  if (isAllow(rule) || isDeny(rule)) continue;
488
502
  const rel = rule.relations?.[relName];
489
503
  if (rel?.directAccess) {
490
- const relWhere = rel.where ? compileScopedWhere(rel.where as Record<string, unknown>, target, ctx, 0) : null;
504
+ const relWhere = rel.where ? compileScopedWhere(rel.where as WhereRule, target, ctx, 0) : null;
491
505
  grants.push(grantOf(rel, relWhere, target, ctx, 0));
492
506
  }
493
507
  }
@@ -496,9 +510,9 @@ export function resolveRelationScope(
496
510
  }
497
511
 
498
512
  /** Project a row to the permitted fields. null = all. */
499
- export function projectRow(row: Record<string, unknown>, fields: string[] | null): Record<string, unknown> {
513
+ export function projectRow(row: Row, fields: string[] | null): Row {
500
514
  if (!fields) return row;
501
- const out: Record<string, unknown> = {};
515
+ const out: Row = {};
502
516
  for (const f of fields) if (f in row) out[f] = row[f];
503
517
  return out;
504
518
  }
package/src/runtime/db.ts CHANGED
@@ -24,6 +24,8 @@ import {
24
24
  type Scope,
25
25
  } from "./acl";
26
26
  import type { Validator } from "../sdk/acl";
27
+ import type { CellValue, Row as SharedRow } from "../sdk/infer";
28
+ import type { WhereRule } from "../sdk/acl";
27
29
  import {
28
30
  and,
29
31
  cmp,
@@ -48,7 +50,7 @@ import { partitionOf, triggersOf, triggerFires, type EntityFields, type FieldDef
48
50
  import { isValidUuid } from "../sdk/uuid";
49
51
  import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult, WhereClause } from "../sdk/infer";
50
52
 
51
- type Row = Record<string, unknown>;
53
+ type Row = SharedRow;
52
54
  type Action = "read" | "create" | "update" | "delete";
53
55
  type Id = string | number | bigint;
54
56
  type Selected = Partial<Record<string, true>> | undefined;
@@ -174,7 +176,7 @@ function encodeCursor(order: OrderBy[], row: Row): string {
174
176
  return btoa(JSON.stringify(vals)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
175
177
  }
176
178
 
177
- function decodeCursor(s: string): unknown[] {
179
+ function decodeCursor(s: string): CellValue[] {
178
180
  try {
179
181
  const arr = JSON.parse(atob(s.replace(/-/g, "+").replace(/_/g, "/")));
180
182
  if (!Array.isArray(arr)) throw new Error("not an array");
@@ -191,7 +193,7 @@ function decodeCursor(s: string): unknown[] {
191
193
  // v non-null-> col > v (NULL cols excluded, they sort before)
192
194
  // DESC: v null -> nothing is strictly after a null (FALSE; PK tiebreak advances)
193
195
  // v non-null-> col < v OR col IS NULL (nulls sort after all non-nulls)
194
- function keysetCmp(o: OrderBy, value: unknown): SqlExpr {
196
+ function keysetCmp(o: OrderBy, value: CellValue): SqlExpr {
195
197
  const desc = o.dir === "desc";
196
198
  if (value === null) return desc ? FALSE : isNull(o.column, true);
197
199
  return desc ? or(cmp("<", o.column, value), isNull(o.column)) : cmp(">", o.column, value);
@@ -200,7 +202,7 @@ function keysetCmp(o: OrderBy, value: unknown): SqlExpr {
200
202
  // Strictly-after predicate for a composite key: lexicographic comparison,
201
203
  // e.g. (a,b) after (a0,b0) => a>a0 OR (a=a0 AND b>b0); DESC columns flip to <. Each
202
204
  // column's comparison and the eq-tiebreaker are NULL-aware (eq() maps null→IS NULL).
203
- function keysetAfter(order: OrderBy[], values: unknown[]): SqlExpr {
205
+ function keysetAfter(order: OrderBy[], values: CellValue[]): SqlExpr {
204
206
  const ors: SqlExpr[] = [];
205
207
  for (let i = 0; i < order.length; i++) {
206
208
  const parts: SqlExpr[] = [];
@@ -433,7 +435,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
433
435
  // Compiles the user's where relation-aware (relation keys → security-scoped
434
436
  // subqueries), then AND-merges the entity's own ACL row scope.
435
437
  if (userWhere) this.assertReadableWhere(from, scope, userWhere);
436
- const userExpr: SqlExpr = userWhere ? compileScopedWhere(userWhere as Record<string, unknown>, from, this.acl) : TRUE;
438
+ const userExpr: SqlExpr = userWhere ? compileScopedWhere(userWhere as WhereRule, from, this.acl) : TRUE;
437
439
  const where = scope.where ? and(userExpr, scope.where) : userExpr;
438
440
  // A relation-traversal `where` (or a relation-traversing ACL scope) compiles to a
439
441
  // `sub` node over another table. Record those tables in `touched` so the live-query
@@ -473,7 +475,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
473
475
  const relations = this.schema[from]?.relations ?? {};
474
476
  for (const [k, v] of Object.entries(where as Record<string, unknown>)) {
475
477
  if (k === "AND" || k === "OR") {
476
- for (const g of v as unknown[]) this.assertReadableWhere(from, scope, g);
478
+ for (const g of v as WhereRule[]) this.assertReadableWhere(from, scope, g);
477
479
  } else if (k === "NOT") {
478
480
  this.assertReadableWhere(from, scope, v);
479
481
  } else if (relations[k]) {
@@ -706,7 +708,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
706
708
  }
707
709
 
708
710
  /** Encode one write cell: JSON-stringify a json/fileRef value, then dialect-encode. */
709
- private encodeCell(jsonCols: Set<string>, col: string, v: unknown): unknown {
711
+ private encodeCell(jsonCols: Set<string>, col: string, v: CellValue): CellValue {
710
712
  if (v != null && jsonCols.has(col)) return this.dialect.encode(JSON.stringify(v));
711
713
  return this.dialect.encode(v);
712
714
  }
@@ -767,7 +769,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
767
769
  const project = (row: Row): Row => this.stripHidden(rel.target, projectRow(row, effectiveFields(scope, row, this.acl.identity)));
768
770
  // One IN query per relation (no N+1). Match column before projecting (which
769
771
  // may drop the join column).
770
- const fetchBy = async (col: string, values: unknown[]): Promise<Array<{ key: unknown; row: Row }>> => {
772
+ const fetchBy = async (col: string, values: CellValue[]): Promise<Array<{ key: CellValue; row: Row }>> => {
771
773
  if (values.length === 0) return [];
772
774
  const where = scope.where ? and(inList(col, values), scope.where) : inList(col, values);
773
775
  // Project the target to its readable columns (+ the join `col`, matched before
@@ -920,7 +922,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
920
922
  this.runValidators(validators, p);
921
923
  this.assertValidUuids(table, p);
922
924
 
923
- const params: unknown[] = [];
925
+ const params: CellValue[] = [];
924
926
  const jsonCols = new Set(this.jsonColsOf(table));
925
927
  const assignments = cols
926
928
  .map((c) => {
@@ -943,7 +945,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
943
945
  this.assertInPartition(table);
944
946
  const scope = this.scopeFor(table, "delete");
945
947
  if (!scope.allowed) throw new AclDenied(table, "delete");
946
- const params: unknown[] = [this.dialect.encode(id)];
948
+ const params: CellValue[] = [this.dialect.encode(id)];
947
949
  let sql = `DELETE FROM ${this.dialect.id(table)} WHERE ${this.dialect.id(this.pkOf(table))} = ${this.dialect.placeholder(1)}`;
948
950
  sql += this.scopeClause(scope.where, params);
949
951
  sql += this.returningClause("*");
@@ -953,7 +955,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
953
955
  }
954
956
 
955
957
  /** Escape hatch — raw SQL, NOT ACL-checked. For system/internal use only. */
956
- async exec(sql: string, ...params: unknown[]): Promise<Row[]> {
958
+ async exec(sql: string, ...params: CellValue[]): Promise<Row[]> {
957
959
  return this.driver.exec(sql, params.map((p) => this.dialect.encode(p)));
958
960
  }
959
961
 
@@ -963,7 +965,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
963
965
  return this.dialect.returning ? ` RETURNING ${cols}` : "";
964
966
  }
965
967
 
966
- private scopeClause(where: SqlExpr | null, params: unknown[]): string {
968
+ private scopeClause(where: SqlExpr | null, params: CellValue[]): string {
967
969
  if (!where) return "";
968
970
  const compiled = compileExpr(where, this.dialect, params);
969
971
  return compiled.sql === "1" ? "" : ` AND (${compiled.sql})`;
@@ -0,0 +1,36 @@
1
+ // Mint an HS256 JWT for local development and tooling — what a real auth service would
2
+ // issue, without running one.
3
+ //
4
+ // Extracted because it was already written twice (the `pramen` CLI and the repo's test
5
+ // helper) and `@pramen/cms`'s own bin would have been a third. One implementation, in the
6
+ // package that owns tokens.
7
+ //
8
+ // NOT an auth system: the dev fallback secret is public, so a token signed with it is
9
+ // worthless anywhere `AUTH_SECRET` is set to something real. That is the point — it makes
10
+ // a forgotten secret fail loudly rather than quietly accept dev tokens in production.
11
+
12
+ /** The scaffolded oblaka.ts dev secret. Used only when AUTH_SECRET is unset. */
13
+ export const DEV_SECRET = "dev-secret-change-me";
14
+
15
+ function bytesToB64url(bytes: Uint8Array): string {
16
+ let bin = "";
17
+ for (const b of bytes) bin += String.fromCharCode(b);
18
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
19
+ }
20
+ const strToB64url = (s: string): string => bytesToB64url(new TextEncoder().encode(s));
21
+
22
+ /** Sign a dev JWT (1h). Secret: the `secret` argument, else `AUTH_SECRET`, else DEV_SECRET. */
23
+ export async function signDevToken(payload: Record<string, unknown>, secret?: string): Promise<string> {
24
+ // `||`, not `??`: an AUTH_SECRET set to the empty string previously fell through to
25
+ // DEV_SECRET, and signing with an empty HMAC key instead would produce tokens the server
26
+ // rejects with no useful message.
27
+ const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;
28
+ const key = secret || env?.AUTH_SECRET || DEV_SECRET;
29
+ const now = Math.floor(Date.now() / 1000);
30
+ const header = strToB64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
31
+ const body = strToB64url(JSON.stringify({ iat: now, exp: now + 3600, ...payload }));
32
+ const data = `${header}.${body}`;
33
+ const cryptoKey = await crypto.subtle.importKey("raw", new TextEncoder().encode(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
34
+ const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(data));
35
+ return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
36
+ }
@@ -20,6 +20,8 @@ import type { Files } from "../sdk/files";
20
20
  import type { ResolverDb } from "../sdk/acl";
21
21
  import type { SchemaDef } from "../sdk/schema";
22
22
  import { authorizeHandler, type AppTaskMap, type HandlerContext, type HandlerKind, type HandlerMap, type Tasks } from "../sdk/handlers";
23
+ import type { EnvBag } from "../sdk/handlers";
24
+ import type { JsonValue } from "../sdk/infer";
23
25
 
24
26
  export interface DispatchResult {
25
27
  readonly result: unknown;
@@ -53,10 +55,10 @@ export async function dispatch(
53
55
  driver: Driver,
54
56
  kv: Kv,
55
57
  files: Files,
56
- env: Readonly<Record<string, unknown>>,
58
+ env: EnvBag,
57
59
  acl: AclContext,
58
60
  name: string,
59
- input: unknown,
61
+ input: JsonValue,
60
62
  ): Promise<DispatchResult> {
61
63
  const handler = handlers[name];
62
64
  if (!handler) throw new BadRequest(`unknown handler: ${name}`);
@@ -68,7 +70,7 @@ export async function dispatch(
68
70
  }
69
71
 
70
72
  // Validate/parse the request input at the boundary, if the handler declares it.
71
- let parsed = input;
73
+ let parsed: unknown = input;
72
74
  if (handler.input) {
73
75
  try {
74
76
  parsed = handler.input(input);
@@ -90,6 +92,8 @@ export async function dispatch(
90
92
  files,
91
93
  env,
92
94
  identity: acl.identity,
95
+ tenant: acl.tenant ?? "main",
96
+ store: acl.store ?? "do",
93
97
  tasks: tasksFacade(driver, () => enqueued++),
94
98
  mail: createMail(env, kv),
95
99
  queue: createQueue(env),
@@ -11,7 +11,11 @@
11
11
  // in a Driver, rather than rewriting the engine. Live queries remain a DO-only
12
12
  // capability (they need a single writer + a stateful socket host).
13
13
 
14
- export type Row = Record<string, unknown>;
14
+ /** A raw row exactly as the substrate returns it, before pramen's object↔JSON codec.
15
+ * Distinct from the decoded `Row` handlers see at the `Db` chokepoint. */
16
+ export type DriverRow = Record<string, SqlValue>;
17
+
18
+ import type { CellValue, SqlValue } from "../sdk/infer";
15
19
 
16
20
  export interface Dialect {
17
21
  /** Render an identifier (table/column), quoting as the backend requires. */
@@ -21,7 +25,7 @@ export interface Dialect {
21
25
  /** Whether INSERT/UPDATE/DELETE ... RETURNING is supported (SQLite/Postgres yes; MySQL no). */
22
26
  readonly returning: boolean;
23
27
  /** Coerce a JS value for binding (e.g. boolean → 0/1 on SQLite). */
24
- encode(v: unknown): unknown;
28
+ encode(v: CellValue): CellValue;
25
29
  }
26
30
 
27
31
  const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -66,7 +70,7 @@ export interface Driver {
66
70
  readonly dialect: Dialect;
67
71
  /** Run a parameterized statement and return the result rows (empty for writes
68
72
  * without RETURNING). Params are already dialect-encoded by the caller. */
69
- exec(sql: string, params: unknown[]): Promise<Row[]>;
73
+ exec(sql: string, params: CellValue[]): Promise<DriverRow[]>;
70
74
  /** Run `fn` inside a transaction: commit on resolve, roll back on throw. */
71
75
  transaction<T>(fn: () => Promise<T>): Promise<T>;
72
76
  /** Run a fixed sequence of write statements ATOMICALLY with FK checks deferred to the
@@ -83,8 +87,8 @@ export class DoSqliteDriver implements Driver {
83
87
  readonly dialect = sqliteDialect;
84
88
  constructor(private readonly storage: DurableObjectStorage) {}
85
89
 
86
- async exec(sql: string, params: unknown[]): Promise<Row[]> {
87
- return this.storage.sql.exec(sql, ...params).toArray() as Row[];
90
+ async exec(sql: string, params: CellValue[]): Promise<DriverRow[]> {
91
+ return this.storage.sql.exec(sql, ...params).toArray() as DriverRow[];
88
92
  }
89
93
 
90
94
  transaction<T>(fn: () => Promise<T>): Promise<T> {
@@ -124,9 +128,9 @@ export class D1Driver implements Driver {
124
128
  this.session = db.withSession(opts?.start ?? "first-unconstrained");
125
129
  }
126
130
 
127
- async exec(sql: string, params: unknown[]): Promise<Row[]> {
131
+ async exec(sql: string, params: CellValue[]): Promise<DriverRow[]> {
128
132
  const stmt = params.length ? this.session.prepare(sql).bind(...params) : this.session.prepare(sql);
129
- const { results } = await stmt.all<Row>();
133
+ const { results } = await stmt.all<DriverRow>();
130
134
  return results ?? [];
131
135
  }
132
136
 
@@ -34,6 +34,15 @@ export class Forbidden extends PramenError {
34
34
  }
35
35
  }
36
36
 
37
+ /** 409 — the request conflicts with the current state of the resource. For
38
+ * optimistic concurrency (a stale `expectedVersion`) and for uniqueness clashes the
39
+ * caller could resolve by retrying with different input. */
40
+ export class Conflict extends PramenError {
41
+ constructor(message = "conflict") {
42
+ super(message, 409, "conflict");
43
+ }
44
+ }
45
+
37
46
  export interface ErrorBody {
38
47
  ok: false;
39
48
  error: string;
@@ -11,6 +11,7 @@
11
11
  // in-memory — so handlers work unchanged off-platform.
12
12
 
13
13
  import type { Kv } from "./kv";
14
+ import type { EnvBag } from "../sdk/handlers";
14
15
 
15
16
  export interface MailAddress {
16
17
  email: string;
@@ -120,7 +121,7 @@ export class UnconfiguredMailAdapter implements MailAdapter {
120
121
  * a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
121
122
  * - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
122
123
  * stash security emails in KV). */
123
- export function createMail(env: Readonly<Record<string, unknown>>, kv?: Kv): Mail {
124
+ export function createMail(env: EnvBag, kv?: Kv): Mail {
124
125
  const binding = env.EMAIL as SendEmailBinding | undefined;
125
126
  const fromAddr = typeof env.MAIL_FROM === "string" && env.MAIL_FROM ? env.MAIL_FROM : undefined;
126
127
  if (binding && fromAddr) {
@@ -43,6 +43,7 @@ import { digest } from "./digest";
43
43
  import { quoteIdent, type Driver } from "./driver";
44
44
  import { entitiesInPartition, partitionOf, validateSchema } from "../sdk/schema";
45
45
  import type { EntityFields, FieldDef, SchemaDef } from "../sdk/schema";
46
+ import type { CellValue } from "../sdk/infer";
46
47
 
47
48
  export interface MigrationReport {
48
49
  changed: boolean;
@@ -306,7 +307,7 @@ async function rebuildTable(
306
307
  // rebuilt table whose FKs momentarily see stale rows), so run the whole sequence
307
308
  // ATOMICALLY: the D1 driver's batch() defers FK checks to the batch commit, and on the
308
309
  // DO the ambient boot transaction (+ defer set at migrate start) already covers it.
309
- const stmts: { sql: string; params: unknown[] }[] = [];
310
+ const stmts: { sql: string; params: CellValue[] }[] = [];
310
311
  // Quarantine tables are bare column lists — untyped, no constraints, no FKs. Values
311
312
  // round-trip verbatim (they were already coerced by the original table's affinity).
312
313
  const bareCopy = (name: string, quotedCols: string[]): string => `CREATE TABLE ${quoteIdent(name)} (${quotedCols.join(", ")})`;
@@ -19,6 +19,7 @@
19
19
  // task `id` as an idempotency key so they can dedupe across the rare retry.
20
20
 
21
21
  import type { Driver } from "./driver";
22
+ import type { CellValue } from "../sdk/infer";
22
23
 
23
24
  export const OUTBOX_TABLE = "_pramen_outbox";
24
25
 
@@ -34,7 +35,7 @@ function backoffMs(attempts: number): number {
34
35
  return Math.min(2 ** attempts * 1000, 5 * 60_000); // 2s, 4s, 8s, … capped at 5min
35
36
  }
36
37
 
37
- const enc = (driver: Driver, params: unknown[]): unknown[] => params.map((p) => driver.dialect.encode(p));
38
+ const enc = (driver: Driver, params: CellValue[]): CellValue[] => params.map((p) => driver.dialect.encode(p));
38
39
 
39
40
  /** Create the outbox table if absent. Idempotent — run on DO boot (and lazily on the
40
41
  * D1 path). Internal table (`_pramen_` prefix), never part of the user schema. */
@@ -10,11 +10,13 @@
10
10
  // { type: "result", id, result } // reply to a one-shot call
11
11
  // { type: "error", id, error }
12
12
 
13
+ import type { JsonValue } from "../sdk/infer";
14
+
13
15
  export interface SubscribeMsg {
14
16
  type: "subscribe";
15
17
  id: string;
16
18
  name: string;
17
- input?: unknown;
19
+ input?: JsonValue;
18
20
  }
19
21
  export interface UnsubscribeMsg {
20
22
  type: "unsubscribe";
@@ -24,7 +26,7 @@ export interface CallMsg {
24
26
  type: "call";
25
27
  id: string;
26
28
  name: string;
27
- input?: unknown;
29
+ input?: JsonValue;
28
30
  }
29
31
 
30
32
  export type ClientMsg = SubscribeMsg | UnsubscribeMsg | CallMsg;
@@ -38,7 +40,7 @@ export type ServerMsg =
38
40
  export interface Subscription {
39
41
  id: string;
40
42
  name: string;
41
- input: unknown;
43
+ input: JsonValue;
42
44
  /** Tables the query read — the coarse prefilter for which writes might matter. */
43
45
  tables: string[];
44
46
  /** Digest of the last result pushed — used to suppress no-op (row-level) pushes. */
@@ -12,6 +12,8 @@
12
12
  import type { Mail } from "./mail";
13
13
  import type { Queue } from "./queue";
14
14
  import type { Kv } from "./kv";
15
+ import type { EnvBag } from "../sdk/handlers";
16
+ import type { JsonValue } from "../sdk/infer";
15
17
 
16
18
  /** One received message (the Cloudflare Queues `Message` shape). */
17
19
  export interface QueueMessage<Body = unknown> {
@@ -40,7 +42,7 @@ export interface QueueBatch<Body = unknown> {
40
42
  * tenant data via `ctx.callPrivileged`. */
41
43
  export interface QueueContext {
42
44
  /** The Worker environment (bindings + vars + secrets). */
43
- readonly env: Readonly<Record<string, unknown>>;
45
+ readonly env: EnvBag;
44
46
  /** Project KV (cross-tenant). */
45
47
  readonly kv: Kv;
46
48
  /** Send email (the notification path). */
@@ -49,7 +51,7 @@ export interface QueueContext {
49
51
  readonly queue: Queue;
50
52
  /** Apply a privileged mutation into a tenant's DO (the consumer has no direct db).
51
53
  * The message body should carry the `tenant`. */
52
- callPrivileged(opts: { name: string; input?: unknown; tenant?: string; roles?: string[]; partition?: string }): Promise<Response>;
54
+ callPrivileged(opts: { name: string; input?: JsonValue; tenant?: string; roles?: string[]; partition?: string }): Promise<Response>;
53
55
  }
54
56
 
55
57
  /** A queue consumer handler — runs once per message. Resolving ACKs the message;
@@ -19,6 +19,8 @@
19
19
  // that isn't bound FAILS CLOSED (throws) rather than silently dropping the message —
20
20
  // mirroring how ctx.mail fails closed without a transport.
21
21
 
22
+ import type { EnvBag } from "../sdk/handlers";
23
+
22
24
  /** Cloudflare Queues content type for a sent message. Omitted ⇒ the platform default
23
25
  * (v8 structured clone). Use "json" for cross-runtime / external consumers. */
24
26
  export type QueueContentType = "text" | "bytes" | "json" | "v8";
@@ -131,7 +133,7 @@ export class MemoryQueueAdapter implements QueueAdapter {
131
133
  /** Discover the Cloudflare Queues producer bindings in an environment: any value that
132
134
  * exposes BOTH `send` and `sendBatch` functions (which excludes the email `send`-only
133
135
  * binding, KV, R2, D1, the DO namespace, …). Returns name → binding. */
134
- export function discoverQueueBindings(env: Readonly<Record<string, unknown>>): Record<string, QueueProducerBinding> {
136
+ export function discoverQueueBindings(env: EnvBag): Record<string, QueueProducerBinding> {
135
137
  const out: Record<string, QueueProducerBinding> = {};
136
138
  for (const [name, value] of Object.entries(env)) {
137
139
  if (
@@ -150,6 +152,6 @@ export function discoverQueueBindings(env: Readonly<Record<string, unknown>>): R
150
152
  * producer bindings. Sending to an undeclared queue fails closed (the adapter throws).
151
153
  * There is no silent capture fallback — declare the `Queue` binding and it exists in
152
154
  * dev (lopata) and miniflare too. */
153
- export function createQueue(env: Readonly<Record<string, unknown>>): Queue {
155
+ export function createQueue(env: EnvBag): Queue {
154
156
  return new Queue(new CloudflareQueueAdapter(discoverQueueBindings(env)));
155
157
  }