@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
@@ -6,11 +6,14 @@
6
6
  // AND/OR groups) and ACL row-level scopes be merged before compilation. Column
7
7
  // names come from developer code (schema keys); values are always parameterized.
8
8
 
9
+ import type { WhereRule, WhereValue } from "../sdk/acl";
10
+ import type { CellValue, Row } from "../sdk/infer";
11
+
9
12
  import type { Dialect } from "./driver";
10
13
 
11
14
  // In-process value coercion for evalExpr (SQLite-style booleans). SQL-side encoding
12
15
  // goes through the active Dialect; this mirrors it for the cell-ACL `when` evaluator.
13
- function bind(v: unknown): unknown {
16
+ function bind(v: CellValue): CellValue {
14
17
  return typeof v === "boolean" ? (v ? 1 : 0) : v;
15
18
  }
16
19
 
@@ -23,8 +26,8 @@ export type StrMode = "contains" | "prefix" | "suffix";
23
26
  export type SqlExpr =
24
27
  | { t: "true" }
25
28
  | { t: "false" }
26
- | { t: "cmp"; op: CmpOp; col: string; value: unknown }
27
- | { t: "in"; col: string; values: unknown[]; negate: boolean }
29
+ | { t: "cmp"; op: CmpOp; col: string; value: CellValue }
30
+ | { t: "in"; col: string; values: CellValue[]; negate: boolean }
28
31
  | { t: "null"; col: string; negate: boolean }
29
32
  // Structured substring match — the needle is escaped and wrapped, so `%`/`_` in the
30
33
  // input match literally (unlike raw `like`, where the caller controls wildcards).
@@ -39,28 +42,28 @@ export type SqlExpr =
39
42
 
40
43
  export const TRUE: SqlExpr = { t: "true" };
41
44
  export const FALSE: SqlExpr = { t: "false" };
42
- export const cmp = (op: CmpOp, col: string, value: unknown): SqlExpr => ({ t: "cmp", op, col, value });
45
+ export const cmp = (op: CmpOp, col: string, value: CellValue): SqlExpr => ({ t: "cmp", op, col, value });
43
46
  export const isNull = (col: string, negate = false): SqlExpr => ({ t: "null", col, negate });
44
- export const inList = (col: string, values: unknown[], negate = false): SqlExpr => ({ t: "in", col, values, negate });
47
+ export const inList = (col: string, values: CellValue[], negate = false): SqlExpr => ({ t: "in", col, values, negate });
45
48
  export const strMatch = (col: string, needle: string, mode: StrMode): SqlExpr => ({ t: "strmatch", col, needle, mode });
46
49
  export const and = (...parts: SqlExpr[]): SqlExpr => ({ t: "and", parts });
47
50
  export const or = (...parts: SqlExpr[]): SqlExpr => ({ t: "or", parts });
48
51
  export const not = (expr: SqlExpr): SqlExpr => ({ t: "not", expr });
49
52
 
50
53
  /** Equality (null -> IS NULL). Used by ACL scope building and relation loads. */
51
- export const eq = (col: string, value: unknown): SqlExpr => (value === null ? isNull(col) : cmp("=", col, value));
54
+ export const eq = (col: string, value: CellValue): SqlExpr => (value === null ? isNull(col) : cmp("=", col, value));
52
55
 
53
56
  /** Compile a structured user predicate into a SqlExpr.
54
57
  * Shapes: { col: value } (eq) | { col: { gt, lt, in, like, isNull, … } } | { AND: [...] } | { OR: [...] } */
55
- export function compileWhere(input: Record<string, unknown>): SqlExpr {
58
+ export function compileWhere(input: WhereRule): SqlExpr {
56
59
  const parts: SqlExpr[] = [];
57
60
  for (const [k, v] of Object.entries(input)) {
58
61
  if (k === "AND") {
59
- parts.push(and(...(v as Record<string, unknown>[]).map(compileWhere)));
62
+ parts.push(and(...(v as WhereRule[]).map(compileWhere)));
60
63
  } else if (k === "OR") {
61
- parts.push(or(...(v as Record<string, unknown>[]).map(compileWhere)));
64
+ parts.push(or(...(v as WhereRule[]).map(compileWhere)));
62
65
  } else if (k === "NOT") {
63
- parts.push(not(compileWhere(v as Record<string, unknown>)));
66
+ parts.push(not(compileWhere(v as WhereRule)));
64
67
  } else {
65
68
  parts.push(columnPredicate(k, v));
66
69
  }
@@ -68,10 +71,13 @@ export function compileWhere(input: Record<string, unknown>): SqlExpr {
68
71
  return parts.length ? and(...parts) : TRUE;
69
72
  }
70
73
 
71
- function columnPredicate(col: string, v: unknown): SqlExpr {
74
+ function columnPredicate(col: string, v: WhereValue): SqlExpr {
72
75
  if (v !== null && typeof v === "object" && !Array.isArray(v)) {
73
76
  const ops: SqlExpr[] = [];
74
- for (const [op, val] of Object.entries(v as Record<string, unknown>)) {
77
+ for (const [op, operand] of Object.entries(v as WhereRule)) {
78
+ // Markers are resolved upstream (runtime/acl.ts), so an operator's operand is a
79
+ // literal cell value by this point.
80
+ const val = operand as CellValue;
75
81
  switch (op) {
76
82
  case "eq": ops.push(eq(col, val)); break;
77
83
  case "ne": ops.push(val === null ? isNull(col, true) : cmp("!=", col, val)); break;
@@ -83,23 +89,23 @@ function columnPredicate(col: string, v: unknown): SqlExpr {
83
89
  case "contains": ops.push(strMatch(col, String(val), "contains")); break;
84
90
  case "startsWith": ops.push(strMatch(col, String(val), "prefix")); break;
85
91
  case "endsWith": ops.push(strMatch(col, String(val), "suffix")); break;
86
- case "in": ops.push(inList(col, val as unknown[])); break;
87
- case "notIn": ops.push(inList(col, val as unknown[], true)); break;
92
+ case "in": ops.push(inList(col, val as CellValue[])); break;
93
+ case "notIn": ops.push(inList(col, val as CellValue[], true)); break;
88
94
  case "isNull": ops.push(isNull(col, !val)); break; // isNull:true => IS NULL
89
95
  default: throw new Error(`unknown operator: ${op}`);
90
96
  }
91
97
  }
92
98
  return ops.length ? and(...ops) : TRUE;
93
99
  }
94
- return eq(col, v);
100
+ return eq(col, v as CellValue);
95
101
  }
96
102
 
97
103
  export interface CompiledSql {
98
104
  readonly sql: string;
99
- readonly params: unknown[];
105
+ readonly params: CellValue[];
100
106
  }
101
107
 
102
- export function compileExpr(expr: SqlExpr, dialect: Dialect, params: unknown[] = []): CompiledSql {
108
+ export function compileExpr(expr: SqlExpr, dialect: Dialect, params: CellValue[] = []): CompiledSql {
103
109
  switch (expr.t) {
104
110
  case "true":
105
111
  return { sql: "1", params };
@@ -168,7 +174,7 @@ function likeToRegex(pattern: string): RegExp {
168
174
  * semantics (bound-boolean coercion, NULL compares false, empty-IN, LIKE) so the
169
175
  * declarative cell-ACL `when` path can decide per-row field visibility without a
170
176
  * round-trip to SQLite. */
171
- export function evalExpr(expr: SqlExpr, row: Record<string, unknown>): boolean {
177
+ export function evalExpr(expr: SqlExpr, row: Row): boolean {
172
178
  switch (expr.t) {
173
179
  case "true":
174
180
  return true;
@@ -245,7 +251,7 @@ export type AggFn = "count" | "sum" | "avg" | "min" | "max";
245
251
  const AGG_SQL: Record<AggFn, string> = { count: "COUNT", sum: "SUM", avg: "AVG", min: "MIN", max: "MAX" };
246
252
 
247
253
  export function compileCount(from: string, dialect: Dialect, where?: SqlExpr): CompiledSql {
248
- const params: unknown[] = [];
254
+ const params: CellValue[] = [];
249
255
  let sql = `SELECT COUNT(*) AS n FROM ${dialect.id(from)}`;
250
256
  if (where && where.t !== "true") sql += ` WHERE ${compileExpr(where, dialect, params).sql}`;
251
257
  return { sql, params };
@@ -265,7 +271,7 @@ export function compileAggregate(
265
271
  },
266
272
  dialect: Dialect,
267
273
  ): CompiledSql {
268
- const params: unknown[] = [];
274
+ const params: CellValue[] = [];
269
275
  const cols: string[] = [];
270
276
  for (const g of spec.groupBy ?? []) cols.push(dialect.id(g));
271
277
  for (const [key, agg] of Object.entries(spec.aggregations)) {
@@ -279,7 +285,7 @@ export function compileAggregate(
279
285
  }
280
286
 
281
287
  export function compileSelect(spec: QuerySpec, dialect: Dialect): CompiledSql {
282
- const params: unknown[] = [];
288
+ const params: CellValue[] = [];
283
289
  const cols = spec.columns && spec.columns.length > 0 ? spec.columns.map((c) => dialect.id(c)).join(", ") : "*";
284
290
  let sql = `SELECT ${cols} FROM ${dialect.id(spec.from)}`;
285
291
 
@@ -79,7 +79,11 @@ export function parseRegistryKey(key: string): DoRef | null {
79
79
  /** Enumerate every registered `(tenant, partition)` pair from the registry KV.
80
80
  * Paginates over the full listing (cursor / list_complete) — never truncates at the
81
81
  * 1000-key page limit. */
82
- export async function listDOs(kv: KVNamespace): Promise<DoRef[]> {
82
+ /** The slice of KV that DO enumeration needs — narrower than the whole namespace, so
83
+ * callers (and test doubles) only have to provide `list`. */
84
+ export type KvLister = Pick<KVNamespace, "list">;
85
+
86
+ export async function listDOs(kv: KvLister): Promise<DoRef[]> {
83
87
  const out: DoRef[] = [];
84
88
  let cursor: string | undefined;
85
89
  for (;;) {
@@ -21,7 +21,7 @@ import type { FieldDef, SchemaDef } from "../sdk/schema";
21
21
  import { partitionOf } from "../sdk/schema";
22
22
 
23
23
  /** The comparable fingerprint of a single column: type + migration-relevant modifiers. */
24
- export interface ColumnShape {
24
+ export interface ColumnFingerprint {
25
25
  type: string;
26
26
  notNull?: boolean;
27
27
  unique?: boolean;
@@ -33,16 +33,16 @@ export interface ColumnShape {
33
33
  }
34
34
 
35
35
  /** The comparable fingerprint of a table: its partition + each column's shape. */
36
- export interface TableShape {
36
+ export interface TableFingerprint {
37
37
  partition: string;
38
- columns: Record<string, ColumnShape>;
38
+ columns: Record<string, ColumnFingerprint>;
39
39
  }
40
40
 
41
41
  /** table -> table shape. The comparable surface of a schema. */
42
- export type SchemaShape = Record<string, TableShape>;
42
+ export type SchemaFingerprint = Record<string, TableFingerprint>;
43
43
 
44
- function columnShape(f: FieldDef): ColumnShape {
45
- const c: ColumnShape = { type: f.type };
44
+ function columnFingerprint(f: FieldDef): ColumnFingerprint {
45
+ const c: ColumnFingerprint = { type: f.type };
46
46
  if (f.notNull) c.notNull = true;
47
47
  if (f.unique) c.unique = true;
48
48
  if (f.primaryKey) c.primaryKey = true;
@@ -53,27 +53,27 @@ function columnShape(f: FieldDef): ColumnShape {
53
53
  return c;
54
54
  }
55
55
 
56
- export function schemaShape(schema: SchemaDef): SchemaShape {
57
- const out: SchemaShape = {};
56
+ export function schemaFingerprint(schema: SchemaDef): SchemaFingerprint {
57
+ const out: SchemaFingerprint = {};
58
58
  for (const [table, def] of Object.entries(schema)) {
59
- const columns: Record<string, ColumnShape> = {};
60
- for (const [col, f] of Object.entries(def.fields)) columns[col] = columnShape(f as FieldDef);
59
+ const columns: Record<string, ColumnFingerprint> = {};
60
+ for (const [col, f] of Object.entries(def.fields)) columns[col] = columnFingerprint(f as FieldDef);
61
61
  out[table] = { partition: partitionOf(schema, table), columns };
62
62
  }
63
63
  return out;
64
64
  }
65
65
 
66
66
  /** The modifier fields compared for a `change-column` (everything but `type`). */
67
- const MODIFIER_KEYS: (keyof ColumnShape)[] = ["notNull", "unique", "primaryKey", "generated", "hidden", "default"];
67
+ const MODIFIER_KEYS: (keyof ColumnFingerprint)[] = ["notNull", "unique", "primaryKey", "generated", "hidden", "default"];
68
68
 
69
69
  /** Does `next` tighten a constraint `prev` lacked (add NOT NULL / UNIQUE / PRIMARY KEY)?
70
70
  * Such a change may require the destructive gate or be skipped when the live data
71
71
  * conflicts (NULL rows / duplicates) — so the diff flags it `destructive`. */
72
- function tightensConstraint(prev: ColumnShape, next: ColumnShape): boolean {
72
+ function tightensConstraint(prev: ColumnFingerprint, next: ColumnFingerprint): boolean {
73
73
  return (!!next.notNull && !prev.notNull) || (!!next.unique && !prev.unique) || (!!next.primaryKey && !prev.primaryKey);
74
74
  }
75
75
 
76
- function modifierDiff(prev: ColumnShape, next: ColumnShape): string | null {
76
+ function modifierDiff(prev: ColumnFingerprint, next: ColumnFingerprint): string | null {
77
77
  const parts: string[] = [];
78
78
  for (const k of MODIFIER_KEYS) {
79
79
  if (prev[k] !== next[k]) parts.push(`${k}: ${fmt(prev[k])} → ${fmt(next[k])}`);
@@ -102,7 +102,7 @@ export interface SchemaChange {
102
102
  appliesOnBoot: boolean;
103
103
  }
104
104
 
105
- export function diffSchemaShape(prev: SchemaShape, next: SchemaShape): SchemaChange[] {
105
+ export function diffSchemaFingerprint(prev: SchemaFingerprint, next: SchemaFingerprint): SchemaChange[] {
106
106
  const changes: SchemaChange[] = [];
107
107
 
108
108
  for (const table of Object.keys(next)) {
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { BadRequest, PramenError } from "./errors";
19
19
  import type { Files, FileRef, HeadResult } from "../sdk/files";
20
+ import { signToken, verifyToken, isUsableSecret, MIN_TOKEN_SECRET_LEN, type ExpiringToken } from "./token";
20
21
 
21
22
  // The portable type surface (FileRef, Files, sign opts) lives in sdk/files.ts;
22
23
  // re-export it here so runtime callers have one import site.
@@ -118,72 +119,25 @@ function bytesToStream(bytes: Uint8Array): ReadableStream {
118
119
  });
119
120
  }
120
121
 
121
- // --- base64url (shared shape with auth.ts/jwt.ts) ---
122
-
123
- function bytesToB64url(bytes: Uint8Array): string {
124
- let bin = "";
125
- for (const b of bytes) bin += String.fromCharCode(b);
126
- return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
127
- }
128
- function b64urlToBytes(s: string): Uint8Array {
129
- const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
130
- const bin = atob(b64);
131
- const out = new Uint8Array(bin.length);
132
- for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
133
- return out;
134
- }
135
- const strToB64url = (s: string) => bytesToB64url(new TextEncoder().encode(s));
136
- const b64urlToStr = (s: string) => new TextDecoder().decode(b64urlToBytes(s));
137
-
138
- // --- signed file tokens (HMAC-SHA256) ---
122
+ // --- signed file tokens ---
123
+ //
124
+ // The HMAC/base64url machinery is shared with page-preview links and anything else that
125
+ // needs a signed capability url; it lives in runtime/token.ts. This file only declares
126
+ // what a FILE token carries.
139
127
 
140
- interface FileToken {
128
+ interface FileToken extends ExpiringToken {
141
129
  /** tenant */ t: string;
142
130
  /** key */ k: string;
143
131
  /** op */ op: "get" | "put";
144
- /** expiry (epoch seconds) */ exp: number;
145
132
  /** content-type (put: enforced; get: disposition hint) */ ct?: string;
146
133
  /** max size in bytes (put only) */ max?: number;
147
134
  /** filename (download disposition) */ fn?: string;
148
135
  }
149
136
 
150
- async function hmacKey(secret: string): Promise<CryptoKey> {
151
- return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
152
- "sign",
153
- "verify",
154
- ]);
155
- }
156
-
157
- async function signToken(token: FileToken, secret: string): Promise<string> {
158
- const data = strToB64url(JSON.stringify(token));
159
- const key = await hmacKey(secret);
160
- const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
161
- return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
162
- }
137
+ const signFileToken = (token: FileToken, secret: string): Promise<string> => signToken(token, secret);
163
138
 
164
139
  /** Verify a file token's signature + expiry; returns the payload or null. */
165
- export async function verifyToken(raw: string, secret: string): Promise<FileToken | null> {
166
- const dot = raw.indexOf(".");
167
- if (dot < 0) return null;
168
- const data = raw.slice(0, dot);
169
- const sig = raw.slice(dot + 1);
170
- let ok: boolean;
171
- try {
172
- const key = await hmacKey(secret);
173
- ok = await crypto.subtle.verify("HMAC", key, b64urlToBytes(sig), new TextEncoder().encode(data));
174
- } catch {
175
- return null;
176
- }
177
- if (!ok) return null;
178
- let payload: FileToken;
179
- try {
180
- payload = JSON.parse(b64urlToStr(data));
181
- } catch {
182
- return null;
183
- }
184
- if (typeof payload.exp !== "number" || Math.floor(Date.now() / 1000) >= payload.exp) return null;
185
- return payload;
186
- }
140
+ export const verifyFileToken = (raw: string, secret: string): Promise<FileToken | null> => verifyToken<FileToken>(raw, secret);
187
141
 
188
142
  // --- key generation ---
189
143
 
@@ -247,10 +201,8 @@ export interface FilesConfig {
247
201
  * would be forgeable (HMAC over an empty/weak key). Below this, file storage is
248
202
  * treated as unconfigured — fail closed rather than mint forgeable urls. The dev
249
203
  * defaults satisfy it; production should set a strong, random FILES_SECRET. */
250
- export const MIN_FILES_SECRET_LEN = 16;
251
- export function isUsableFilesSecret(secret: string | undefined | null): secret is string {
252
- return typeof secret === "string" && secret.length >= MIN_FILES_SECRET_LEN;
253
- }
204
+ export const MIN_FILES_SECRET_LEN = MIN_TOKEN_SECRET_LEN;
205
+ export const isUsableFilesSecret = isUsableSecret;
254
206
  const filesUnconfigured = () =>
255
207
  new PramenError("file storage is not configured (set a strong FILES_SECRET)", 503, "unavailable");
256
208
 
@@ -271,7 +223,7 @@ export function createFiles(cfg: FilesConfig): Files {
271
223
  const seg = opts.prefix ? `${safePrefix(opts.prefix)}/` : "";
272
224
  const key = `${prefix}${seg}${randomKeySuffix()}`;
273
225
  const exp = Math.floor(Date.now() / 1000) + (opts.expiresIn ?? 900);
274
- const token = await signToken({ t: cfg.tenant, k: key, op: "put", exp, ct, max: opts.maxSize, fn: opts.filename }, cfg.secret);
226
+ const token = await signFileToken({ t: cfg.tenant, k: key, op: "put", exp, ct, max: opts.maxSize, fn: opts.filename }, cfg.secret);
275
227
  const ref: FileRef = { key, size: 0, contentType: ct, filename: opts.filename, uploadedAt: Date.now() };
276
228
  return { url: `${base}/upload?token=${encodeURIComponent(token)}`, ref };
277
229
  },
@@ -282,7 +234,7 @@ export function createFiles(cfg: FilesConfig): Files {
282
234
  const key = ensureOwnKey(r.key);
283
235
  const exp = Math.floor(Date.now() / 1000) + (opts?.expiresIn ?? 3600);
284
236
  const fn = opts?.download ? (typeof ref === "string" ? undefined : ref.filename) : undefined;
285
- const token = await signToken({ t: cfg.tenant, k: key, op: "get", exp, fn }, cfg.secret);
237
+ const token = await signFileToken({ t: cfg.tenant, k: key, op: "get", exp, fn }, cfg.secret);
286
238
  return { url: `${base}/download?token=${encodeURIComponent(token)}`, expiresAt: exp * 1000 };
287
239
  },
288
240
 
@@ -327,7 +279,7 @@ export async function handleFileRequest(
327
279
 
328
280
  const raw = url.searchParams.get("token");
329
281
  if (!raw) return fileError(401, "unauthorized", "missing token");
330
- const token = await verifyToken(raw, opts.secret);
282
+ const token = await verifyFileToken(raw, opts.secret);
331
283
  if (!token) return fileError(403, "forbidden", "invalid or expired token");
332
284
 
333
285
  try {
@@ -0,0 +1,94 @@
1
+ // Signed, self-expiring capability tokens (HMAC-SHA256 over a JSON payload).
2
+ //
3
+ // pramen already owns the edge and a secret, so a capability url needs no session and no
4
+ // store: the payload IS the grant, and the signature is what makes it unforgeable. Signed
5
+ // file urls were the first user (`runtime/storage.ts`); page preview links are the second
6
+ // (`@pramen/cms`). Both mint in a handler and redeem in the Worker, unauthenticated.
7
+ //
8
+ // Pure WebCrypto — synchronous in the sense that matters (no stream I/O), so it is safe to
9
+ // call from inside the DO's storage.transaction().
10
+ //
11
+ // A token is `<b64url(json)>.<b64url(sig)>`. It is deliberately NOT a JWT: no alg field to
12
+ // confuse, no header to downgrade, one algorithm, verified before the payload is parsed.
13
+
14
+ /** Every signed token carries an expiry (epoch seconds); `verifyToken` enforces it. */
15
+ export interface ExpiringToken {
16
+ exp: number;
17
+ }
18
+
19
+ export function bytesToB64url(bytes: Uint8Array): string {
20
+ let bin = "";
21
+ for (const b of bytes) bin += String.fromCharCode(b);
22
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
23
+ }
24
+
25
+ export function b64urlToBytes(s: string): Uint8Array {
26
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
27
+ const bin = atob(b64);
28
+ const out = new Uint8Array(bin.length);
29
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
30
+ return out;
31
+ }
32
+
33
+ export const strToB64url = (s: string): string => bytesToB64url(new TextEncoder().encode(s));
34
+ export const b64urlToStr = (s: string): string => new TextDecoder().decode(b64urlToBytes(s));
35
+
36
+ async function hmacKey(secret: string): Promise<CryptoKey> {
37
+ return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
38
+ "sign",
39
+ "verify",
40
+ ]);
41
+ }
42
+
43
+ /** Sign a payload into a capability token. */
44
+ export async function signToken<T extends ExpiringToken>(payload: T, secret: string): Promise<string> {
45
+ const data = strToB64url(JSON.stringify(payload));
46
+ const key = await hmacKey(secret);
47
+ const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
48
+ return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
49
+ }
50
+
51
+ /** Verify a token's signature and expiry; returns the payload, or `null` for anything
52
+ * malformed, forged, or expired. The caller decides what the payload authorizes — this
53
+ * only attests that we minted it and that it is still in date. */
54
+ export async function verifyToken<T extends ExpiringToken>(raw: string, secret: string): Promise<T | null> {
55
+ const dot = raw.indexOf(".");
56
+ if (dot < 0) return null;
57
+ const data = raw.slice(0, dot);
58
+ const sig = raw.slice(dot + 1);
59
+ let ok: boolean;
60
+ try {
61
+ const key = await hmacKey(secret);
62
+ ok = await crypto.subtle.verify("HMAC", key, b64urlToBytes(sig), new TextEncoder().encode(data));
63
+ } catch {
64
+ return null;
65
+ }
66
+ if (!ok) return null;
67
+ let payload: T;
68
+ try {
69
+ payload = JSON.parse(b64urlToStr(data)) as T;
70
+ } catch {
71
+ return null;
72
+ }
73
+ if (typeof payload.exp !== "number" || Math.floor(Date.now() / 1000) >= payload.exp) return null;
74
+ return payload;
75
+ }
76
+
77
+ /** A signing secret must be present and non-trivial, else tokens are forgeable (an HMAC
78
+ * over an empty or weak key). Below this length the feature is treated as UNCONFIGURED and
79
+ * fails closed, rather than minting urls that anyone can forge. */
80
+ export const MIN_TOKEN_SECRET_LEN = 16;
81
+
82
+ export function isUsableSecret(secret: unknown): secret is string {
83
+ return typeof secret === "string" && secret.length >= MIN_TOKEN_SECRET_LEN;
84
+ }
85
+
86
+ /** Resolve a signing secret from env by preference order, skipping any that is absent or
87
+ * too weak. Returns `undefined` when nothing usable is configured — callers fail closed. */
88
+ export function resolveSecret(env: Readonly<Record<string, unknown>>, names: readonly string[]): string | undefined {
89
+ for (const name of names) {
90
+ const v = env[name];
91
+ if (isUsableSecret(v)) return v;
92
+ }
93
+ return undefined;
94
+ }
package/src/sdk/acl.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  // and/or a set of permitted `fields`. Access is deny-by-default; policies only
7
7
  // ever grant. Across the identity's roles, grants OR-merge (any role can allow).
8
8
 
9
+ import type { CellValue, JsonValue, Row } from "./infer";
10
+
9
11
  export type Action = "read" | "create" | "update" | "delete";
10
12
 
11
13
  /** Runtime identity. Augment with your own properties (userId, tier, …). */
@@ -17,7 +19,7 @@ export interface Identity {
17
19
  * expiry per message (see durable-object.ts). Absent for non-expiring / synthetic
18
20
  * (callPrivileged) identities, which are therefore never treated as expired. */
19
21
  exp?: number;
20
- [key: string]: unknown;
22
+ [key: string]: JsonValue | undefined;
21
23
  }
22
24
 
23
25
  // --- $identity markers: reference an identity property inside a where rule ---
@@ -34,8 +36,8 @@ export function $identity(path: string): IdentityMarker {
34
36
  return { [IDENTITY_MARKER]: true, path };
35
37
  }
36
38
 
37
- export function isIdentityMarker(v: unknown): v is IdentityMarker {
38
- return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[IDENTITY_MARKER] === true;
39
+ export function isIdentityMarker(v: WhereValue): v is IdentityMarker {
40
+ return typeof v === "object" && v !== null && (v as Partial<IdentityMarker>)[IDENTITY_MARKER] === true;
39
41
  }
40
42
 
41
43
  // --- $input markers: reference a request-input field inside a where rule, for a
@@ -56,8 +58,8 @@ export function $input(path: string): InputMarker {
56
58
  return { [INPUT_MARKER]: true, path };
57
59
  }
58
60
 
59
- export function isInputMarker(v: unknown): v is InputMarker {
60
- return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[INPUT_MARKER] === true;
61
+ export function isInputMarker(v: WhereValue): v is InputMarker {
62
+ return typeof v === "object" && v !== null && (v as Partial<InputMarker>)[INPUT_MARKER] === true;
61
63
  }
62
64
 
63
65
  // --- $now markers: the request's evaluation instant inside a where rule, for a
@@ -88,8 +90,8 @@ export function $now(): NowMarker {
88
90
  return { [NOW_MARKER]: true };
89
91
  }
90
92
 
91
- export function isNowMarker(v: unknown): v is NowMarker {
92
- return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[NOW_MARKER] === true;
93
+ export function isNowMarker(v: WhereValue): v is NowMarker {
94
+ return typeof v === "object" && v !== null && (v as Partial<NowMarker>)[NOW_MARKER] === true;
93
95
  }
94
96
 
95
97
  // --- allow / deny markers ---
@@ -110,8 +112,21 @@ export function deny(): DenyMarker {
110
112
 
111
113
  // --- policy rules ---
112
114
 
113
- /** A where rule: column -> value, where value may be a literal or an $identity marker. */
114
- export type WhereRule = Record<string, unknown | IdentityMarker>;
115
+ /** A value inside a `where` rule: a literal cell value, a per-request marker, an
116
+ * operator object (`{ gte: 5 }`), a nested relation predicate, or an AND/OR group. */
117
+ export type WhereValue =
118
+ | CellValue
119
+ | IdentityMarker
120
+ | InputMarker
121
+ | NowMarker
122
+ | WhereRule
123
+ | WhereValue[];
124
+
125
+ /** A where rule: column (or `AND`/`OR`/`NOT`) -> value. An interface so it can recur
126
+ * through `WhereValue` for nested relation predicates and boolean groups. */
127
+ export interface WhereRule {
128
+ [key: string]: WhereValue;
129
+ }
115
130
 
116
131
  /** A per-row (cell-level) field grant: `fields` are permitted only for rows that
117
132
  * match `when`. Additive over the policy's flat `fields` — a conditional grant can
@@ -125,7 +140,7 @@ export interface ConditionalFields {
125
140
  /** Escape hatch for cell-level ACL: a late per-row resolver. Given the identity and
126
141
  * the fetched (or candidate, on write) row, returns the extra permitted fields —
127
142
  * additive over `fields`; `null` means all fields for that row. */
128
- export type FieldsFn = (identity: Identity | null, row: Record<string, unknown>) => string[] | null;
143
+ export type FieldsFn = (identity: Identity | null, row: Row) => string[] | null;
129
144
 
130
145
  /** Per-relation ACL inside a parent read policy. */
131
146
  export interface RelationAclRule {
@@ -143,10 +158,10 @@ export interface RelationAclRule {
143
158
  }
144
159
 
145
160
  /** A forced column value on write: a literal, or computed from the identity. */
146
- export type SetValue = unknown | ((identity: Identity | null) => unknown);
161
+ export type SetValue = CellValue | ((identity: Identity | null) => CellValue);
147
162
 
148
163
  /** Server-side validation on write; throw to reject. Runs on the final values. */
149
- export type Validator = (args: { identity: Identity | null; values: Record<string, unknown> }) => void;
164
+ export type Validator = (args: { identity: Identity | null; values: Row }) => void;
150
165
 
151
166
  export interface PolicyRules {
152
167
  /** Row-level predicate (AND of equalities). Omit/empty = all rows. */
@@ -175,10 +190,10 @@ export interface PolicyRules {
175
190
  export interface ResolverDb {
176
191
  find(spec: {
177
192
  from: string;
178
- where?: Record<string, unknown>;
193
+ where?: WhereRule;
179
194
  orderBy?: { column: string; dir?: "asc" | "desc" };
180
195
  limit?: number;
181
- }): Promise<Array<Record<string, unknown>>>;
196
+ }): Promise<Row[]>;
182
197
  }
183
198
 
184
199
  export interface ResolverContext {
@@ -10,6 +10,13 @@ import type { Queue } from "../runtime/queue";
10
10
  import type { Identity } from "./acl";
11
11
  import type { Files } from "./files";
12
12
  import type { SchemaDef } from "./schema";
13
+ import type { JsonValue } from "./infer";
14
+
15
+ /** The Worker/DO environment as an open, read-only bag: Cloudflare bindings (KV, R2,
16
+ * D1, Queues, …) alongside vars and secrets. Deliberately open and opaque — an app
17
+ * declares its own bindings, so the value type cannot be enumerated here; read a value
18
+ * and narrow it at the use site (`ctx.env.STRIPE_SECRET_KEY as string`). */
19
+ export type EnvBag = Readonly<Record<string, unknown>>;
13
20
 
14
21
  export interface HandlerContext<S extends SchemaDef = SchemaDef> {
15
22
  /** Schema-typed repository: find/insert/update/delete inferred from S. */
@@ -30,9 +37,16 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
30
37
  * Use it to call external services from handlers — Cloudflare bindings (e.g. the
31
38
  * `send_email` binding for Cloudflare Email Sending) or third-party APIs (Stripe, …). Loosely typed;
32
39
  * cast a value at the use site, e.g. `ctx.env.STRIPE_SECRET_KEY as string`. */
33
- readonly env: Readonly<Record<string, unknown>>;
40
+ readonly env: EnvBag;
34
41
  /** Resolved identity for this request (null = anonymous). */
35
42
  readonly identity: Identity | null;
43
+ /** The tenant this request is for (the `x-pramen-tenant` value; `"main"` by default).
44
+ * Server-resolved, never caller-supplied — safe to embed in a signed capability. */
45
+ readonly tenant: string;
46
+ /** Which substrate is serving this request: `"do"` (a Durable Object) or `"d1"`. Only
47
+ * the DO path has a stub the Worker can call back into, so a handler minting a
48
+ * capability redeemed through `callPrivileged` must refuse on `"d1"`. */
49
+ readonly store: "do" | "d1";
36
50
  /** Deferred side-effects (a transactional outbox). `tasks.enqueue` persists a task
37
51
  * row in the SAME transaction as a mutation (atomic with the data write); a drainer
38
52
  * runs the matching `app.tasks` handler after commit, off the write path, with
@@ -119,7 +133,7 @@ export interface Handler<I = unknown, O = unknown> {
119
133
  readonly run: (ctx: HandlerContext<any>, input: I) => O | Promise<O>;
120
134
  /** Optional boundary validator: parse/validate the raw request input, throwing
121
135
  * to reject (surfaced as a 400). Its return type fixes the handler's input. */
122
- readonly input?: (raw: unknown) => unknown;
136
+ readonly input?: (raw: JsonValue) => unknown;
123
137
  /** Optional DO partition this handler runs in (static, server-side). The Worker
124
138
  * routes the request to the matching partition-DO before dispatch. Absent ⇒ the
125
139
  * default partition (routed to the bare tenant key). */
@@ -129,7 +143,7 @@ export interface Handler<I = unknown, O = unknown> {
129
143
  }
130
144
 
131
145
  export interface HandlerOpts<I> {
132
- input?: (raw: unknown) => I;
146
+ input?: (raw: JsonValue) => I;
133
147
  /** DO partition this handler runs in. Absent ⇒ the default partition. */
134
148
  partition?: string;
135
149
  /** Authorization to CALL this handler (see HandlerAuth) — gate non-`ctx.db` handlers. */
package/src/sdk/infer.ts CHANGED
@@ -11,6 +11,27 @@ export type { FileRef } from "./files";
11
11
  /** Any JSON-serializable value — the type of a `t.json()` column. */
12
12
  export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
13
13
 
14
+ /** A JSON object — the object arm of `JsonValue`, named so it can be referenced
15
+ * directly (e.g. an identity's claims, a `t.json()` column's object form). */
16
+ export interface JsonObject {
17
+ [key: string]: JsonValue;
18
+ }
19
+
20
+ /** A raw value as the substrate stores/returns it, before pramen's object↔JSON codec.
21
+ * DO SQLite and D1 hand back exactly these; BLOB columns arrive as an ArrayBuffer. */
22
+ export type SqlValue = string | number | bigint | boolean | null | ArrayBuffer;
23
+
24
+ /** A decoded column value as handlers see it at the `Db` chokepoint: any JSON value,
25
+ * a `fileRef` column's metadata, or — for an eager-loaded relation — the related
26
+ * row(s) grafted onto the parent under the relation name. */
27
+ export type CellValue = SqlValue | JsonValue | FileRef | Row | Row[];
28
+
29
+ /** A decoded database row — column name -> decoded value. An interface (not a
30
+ * `Record` alias) so it can recur through `CellValue` for eager-loaded relations. */
31
+ export interface Row {
32
+ [column: string]: CellValue;
33
+ }
34
+
14
35
  /** SQL field type -> TypeScript value type. */
15
36
  export type FieldTsType<D extends FieldDef> = D["type"] extends "text"
16
37
  ? string