@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
@@ -1,3 +1,5 @@
1
+ import type { WhereRule } from "../sdk/acl";
2
+ import type { CellValue, Row } from "../sdk/infer";
1
3
  import type { Dialect } from "./driver";
2
4
  export type CmpOp = "=" | "!=" | ">" | ">=" | "<" | "<=" | "LIKE";
3
5
  /** Substring match mode for the structured string operators (auto-escaping, so the
@@ -11,11 +13,11 @@ export type SqlExpr = {
11
13
  t: "cmp";
12
14
  op: CmpOp;
13
15
  col: string;
14
- value: unknown;
16
+ value: CellValue;
15
17
  } | {
16
18
  t: "in";
17
19
  col: string;
18
- values: unknown[];
20
+ values: CellValue[];
19
21
  negate: boolean;
20
22
  } | {
21
23
  t: "null";
@@ -45,28 +47,28 @@ export type SqlExpr = {
45
47
  };
46
48
  export declare const TRUE: SqlExpr;
47
49
  export declare const FALSE: SqlExpr;
48
- export declare const cmp: (op: CmpOp, col: string, value: unknown) => SqlExpr;
50
+ export declare const cmp: (op: CmpOp, col: string, value: CellValue) => SqlExpr;
49
51
  export declare const isNull: (col: string, negate?: boolean) => SqlExpr;
50
- export declare const inList: (col: string, values: unknown[], negate?: boolean) => SqlExpr;
52
+ export declare const inList: (col: string, values: CellValue[], negate?: boolean) => SqlExpr;
51
53
  export declare const strMatch: (col: string, needle: string, mode: StrMode) => SqlExpr;
52
54
  export declare const and: (...parts: SqlExpr[]) => SqlExpr;
53
55
  export declare const or: (...parts: SqlExpr[]) => SqlExpr;
54
56
  export declare const not: (expr: SqlExpr) => SqlExpr;
55
57
  /** Equality (null -> IS NULL). Used by ACL scope building and relation loads. */
56
- export declare const eq: (col: string, value: unknown) => SqlExpr;
58
+ export declare const eq: (col: string, value: CellValue) => SqlExpr;
57
59
  /** Compile a structured user predicate into a SqlExpr.
58
60
  * Shapes: { col: value } (eq) | { col: { gt, lt, in, like, isNull, … } } | { AND: [...] } | { OR: [...] } */
59
- export declare function compileWhere(input: Record<string, unknown>): SqlExpr;
61
+ export declare function compileWhere(input: WhereRule): SqlExpr;
60
62
  export interface CompiledSql {
61
63
  readonly sql: string;
62
- readonly params: unknown[];
64
+ readonly params: CellValue[];
63
65
  }
64
- export declare function compileExpr(expr: SqlExpr, dialect: Dialect, params?: unknown[]): CompiledSql;
66
+ export declare function compileExpr(expr: SqlExpr, dialect: Dialect, params?: CellValue[]): CompiledSql;
65
67
  /** Evaluate a compiled predicate against an in-memory row. Mirrors compileExpr's
66
68
  * semantics (bound-boolean coercion, NULL compares false, empty-IN, LIKE) so the
67
69
  * declarative cell-ACL `when` path can decide per-row field visibility without a
68
70
  * round-trip to SQLite. */
69
- export declare function evalExpr(expr: SqlExpr, row: Record<string, unknown>): boolean;
71
+ export declare function evalExpr(expr: SqlExpr, row: Row): boolean;
70
72
  export interface OrderBy {
71
73
  column: string;
72
74
  dir?: "asc" | "desc";
@@ -44,7 +44,10 @@ export function compileWhere(input) {
44
44
  function columnPredicate(col, v) {
45
45
  if (v !== null && typeof v === "object" && !Array.isArray(v)) {
46
46
  const ops = [];
47
- for (const [op, val] of Object.entries(v)) {
47
+ for (const [op, operand] of Object.entries(v)) {
48
+ // Markers are resolved upstream (runtime/acl.ts), so an operator's operand is a
49
+ // literal cell value by this point.
50
+ const val = operand;
48
51
  switch (op) {
49
52
  case "eq":
50
53
  ops.push(eq(col, val));
@@ -25,4 +25,7 @@ export declare function parseRegistryKey(key: string): DoRef | null;
25
25
  /** Enumerate every registered `(tenant, partition)` pair from the registry KV.
26
26
  * Paginates over the full listing (cursor / list_complete) — never truncates at the
27
27
  * 1000-key page limit. */
28
- export declare function listDOs(kv: KVNamespace): Promise<DoRef[]>;
28
+ /** The slice of KV that DO enumeration needs — narrower than the whole namespace, so
29
+ * callers (and test doubles) only have to provide `list`. */
30
+ export type KvLister = Pick<KVNamespace, "list">;
31
+ export declare function listDOs(kv: KvLister): Promise<DoRef[]>;
@@ -65,9 +65,6 @@ export function parseRegistryKey(key) {
65
65
  return { tenant: rest, partition: DEFAULT_PARTITION };
66
66
  return { tenant: rest.slice(0, sep), partition: rest.slice(sep + 1) };
67
67
  }
68
- /** Enumerate every registered `(tenant, partition)` pair from the registry KV.
69
- * Paginates over the full listing (cursor / list_complete) — never truncates at the
70
- * 1000-key page limit. */
71
68
  export async function listDOs(kv) {
72
69
  const out = [];
73
70
  let cursor;
@@ -1,6 +1,6 @@
1
1
  import type { SchemaDef } from "../sdk/schema";
2
2
  /** The comparable fingerprint of a single column: type + migration-relevant modifiers. */
3
- export interface ColumnShape {
3
+ export interface ColumnFingerprint {
4
4
  type: string;
5
5
  notNull?: boolean;
6
6
  unique?: boolean;
@@ -11,13 +11,13 @@ export interface ColumnShape {
11
11
  default?: string;
12
12
  }
13
13
  /** The comparable fingerprint of a table: its partition + each column's shape. */
14
- export interface TableShape {
14
+ export interface TableFingerprint {
15
15
  partition: string;
16
- columns: Record<string, ColumnShape>;
16
+ columns: Record<string, ColumnFingerprint>;
17
17
  }
18
18
  /** table -> table shape. The comparable surface of a schema. */
19
- export type SchemaShape = Record<string, TableShape>;
20
- export declare function schemaShape(schema: SchemaDef): SchemaShape;
19
+ export type SchemaFingerprint = Record<string, TableFingerprint>;
20
+ export declare function schemaFingerprint(schema: SchemaDef): SchemaFingerprint;
21
21
  export interface SchemaChange {
22
22
  kind: "add-table" | "drop-table" | "add-column" | "drop-column" | "change-type" | "change-column" | "move-partition";
23
23
  table: string;
@@ -34,4 +34,4 @@ export interface SchemaChange {
34
34
  * cross-DO data migration). Reported for honesty. */
35
35
  appliesOnBoot: boolean;
36
36
  }
37
- export declare function diffSchemaShape(prev: SchemaShape, next: SchemaShape): SchemaChange[];
37
+ export declare function diffSchemaFingerprint(prev: SchemaFingerprint, next: SchemaFingerprint): SchemaChange[];
@@ -17,7 +17,7 @@
17
17
  // still CANNOT be enacted on boot (a partition is a separate Durable Object — it needs a
18
18
  // manual cross-DO data migration), so it stays `appliesOnBoot: false`.
19
19
  import { partitionOf } from "../sdk/schema";
20
- function columnShape(f) {
20
+ function columnFingerprint(f) {
21
21
  const c = { type: f.type };
22
22
  if (f.notNull)
23
23
  c.notNull = true;
@@ -35,12 +35,12 @@ function columnShape(f) {
35
35
  c.default = JSON.stringify(f.default);
36
36
  return c;
37
37
  }
38
- export function schemaShape(schema) {
38
+ export function schemaFingerprint(schema) {
39
39
  const out = {};
40
40
  for (const [table, def] of Object.entries(schema)) {
41
41
  const columns = {};
42
42
  for (const [col, f] of Object.entries(def.fields))
43
- columns[col] = columnShape(f);
43
+ columns[col] = columnFingerprint(f);
44
44
  out[table] = { partition: partitionOf(schema, table), columns };
45
45
  }
46
46
  return out;
@@ -64,7 +64,7 @@ function modifierDiff(prev, next) {
64
64
  function fmt(v) {
65
65
  return v === undefined ? "—" : String(v);
66
66
  }
67
- export function diffSchemaShape(prev, next) {
67
+ export function diffSchemaFingerprint(prev, next) {
68
68
  const changes = [];
69
69
  for (const table of Object.keys(next)) {
70
70
  const pt = prev[table];
@@ -1,4 +1,5 @@
1
1
  import type { Files, HeadResult } from "../sdk/files";
2
+ import { isUsableSecret, type ExpiringToken } from "./token";
2
3
  export type { Files, FileRef, HeadResult, SignDownloadOpts, SignUploadOpts } from "../sdk/files";
3
4
  export interface PutResult {
4
5
  key: string;
@@ -40,17 +41,16 @@ export declare class MemoryAdapter implements StorageAdapter {
40
41
  head(key: string): Promise<HeadResult | null>;
41
42
  delete(key: string): Promise<void>;
42
43
  }
43
- interface FileToken {
44
+ interface FileToken extends ExpiringToken {
44
45
  /** tenant */ t: string;
45
46
  /** key */ k: string;
46
47
  /** op */ op: "get" | "put";
47
- /** expiry (epoch seconds) */ exp: number;
48
48
  /** content-type (put: enforced; get: disposition hint) */ ct?: string;
49
49
  /** max size in bytes (put only) */ max?: number;
50
50
  /** filename (download disposition) */ fn?: string;
51
51
  }
52
52
  /** Verify a file token's signature + expiry; returns the payload or null. */
53
- export declare function verifyToken(raw: string, secret: string): Promise<FileToken | null>;
53
+ export declare const verifyFileToken: (raw: string, secret: string) => Promise<FileToken | null>;
54
54
  export interface FilesConfig {
55
55
  tenant: string;
56
56
  secret: string;
@@ -63,7 +63,7 @@ export interface FilesConfig {
63
63
  * treated as unconfigured — fail closed rather than mint forgeable urls. The dev
64
64
  * defaults satisfy it; production should set a strong, random FILES_SECRET. */
65
65
  export declare const MIN_FILES_SECRET_LEN = 16;
66
- export declare function isUsableFilesSecret(secret: string | undefined | null): secret is string;
66
+ export declare const isUsableFilesSecret: typeof isUsableSecret;
67
67
  /** Construct the per-tenant `ctx.files` facade. */
68
68
  export declare function createFiles(cfg: FilesConfig): Files;
69
69
  /** Serve the file endpoints. Returns a Response for any `/files/*` path, or null
@@ -15,6 +15,7 @@
15
15
  // R2 binding and stays backend-agnostic. URLs are RELATIVE so the server never
16
16
  // needs to know its own public origin — the client resolves them against its base.
17
17
  import { BadRequest, PramenError } from "./errors";
18
+ import { signToken, verifyToken, isUsableSecret, MIN_TOKEN_SECRET_LEN } from "./token";
18
19
  /** R2 — the Cloudflare default. Wraps an R2 bucket binding. Streaming: bytes flow
19
20
  * directly between the client and R2 in the Worker, never through the DO. */
20
21
  export class R2Adapter {
@@ -84,63 +85,9 @@ function bytesToStream(bytes) {
84
85
  },
85
86
  });
86
87
  }
87
- // --- base64url (shared shape with auth.ts/jwt.ts) ---
88
- function bytesToB64url(bytes) {
89
- let bin = "";
90
- for (const b of bytes)
91
- bin += String.fromCharCode(b);
92
- return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
93
- }
94
- function b64urlToBytes(s) {
95
- const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
96
- const bin = atob(b64);
97
- const out = new Uint8Array(bin.length);
98
- for (let i = 0; i < bin.length; i++)
99
- out[i] = bin.charCodeAt(i);
100
- return out;
101
- }
102
- const strToB64url = (s) => bytesToB64url(new TextEncoder().encode(s));
103
- const b64urlToStr = (s) => new TextDecoder().decode(b64urlToBytes(s));
104
- async function hmacKey(secret) {
105
- return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
106
- "sign",
107
- "verify",
108
- ]);
109
- }
110
- async function signToken(token, secret) {
111
- const data = strToB64url(JSON.stringify(token));
112
- const key = await hmacKey(secret);
113
- const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
114
- return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
115
- }
88
+ const signFileToken = (token, secret) => signToken(token, secret);
116
89
  /** Verify a file token's signature + expiry; returns the payload or null. */
117
- export async function verifyToken(raw, secret) {
118
- const dot = raw.indexOf(".");
119
- if (dot < 0)
120
- return null;
121
- const data = raw.slice(0, dot);
122
- const sig = raw.slice(dot + 1);
123
- let ok;
124
- try {
125
- const key = await hmacKey(secret);
126
- ok = await crypto.subtle.verify("HMAC", key, b64urlToBytes(sig), new TextEncoder().encode(data));
127
- }
128
- catch {
129
- return null;
130
- }
131
- if (!ok)
132
- return null;
133
- let payload;
134
- try {
135
- payload = JSON.parse(b64urlToStr(data));
136
- }
137
- catch {
138
- return null;
139
- }
140
- if (typeof payload.exp !== "number" || Math.floor(Date.now() / 1000) >= payload.exp)
141
- return null;
142
- return payload;
143
- }
90
+ export const verifyFileToken = (raw, secret) => verifyToken(raw, secret);
144
91
  // --- key generation ---
145
92
  function randomKeySuffix() {
146
93
  const bytes = new Uint8Array(16);
@@ -188,10 +135,8 @@ function contentDisposition(name) {
188
135
  * would be forgeable (HMAC over an empty/weak key). Below this, file storage is
189
136
  * treated as unconfigured — fail closed rather than mint forgeable urls. The dev
190
137
  * defaults satisfy it; production should set a strong, random FILES_SECRET. */
191
- export const MIN_FILES_SECRET_LEN = 16;
192
- export function isUsableFilesSecret(secret) {
193
- return typeof secret === "string" && secret.length >= MIN_FILES_SECRET_LEN;
194
- }
138
+ export const MIN_FILES_SECRET_LEN = MIN_TOKEN_SECRET_LEN;
139
+ export const isUsableFilesSecret = isUsableSecret;
195
140
  const filesUnconfigured = () => new PramenError("file storage is not configured (set a strong FILES_SECRET)", 503, "unavailable");
196
141
  /** Construct the per-tenant `ctx.files` facade. */
197
142
  export function createFiles(cfg) {
@@ -210,7 +155,7 @@ export function createFiles(cfg) {
210
155
  const seg = opts.prefix ? `${safePrefix(opts.prefix)}/` : "";
211
156
  const key = `${prefix}${seg}${randomKeySuffix()}`;
212
157
  const exp = Math.floor(Date.now() / 1000) + (opts.expiresIn ?? 900);
213
- const token = await signToken({ t: cfg.tenant, k: key, op: "put", exp, ct, max: opts.maxSize, fn: opts.filename }, cfg.secret);
158
+ const token = await signFileToken({ t: cfg.tenant, k: key, op: "put", exp, ct, max: opts.maxSize, fn: opts.filename }, cfg.secret);
214
159
  const ref = { key, size: 0, contentType: ct, filename: opts.filename, uploadedAt: Date.now() };
215
160
  return { url: `${base}/upload?token=${encodeURIComponent(token)}`, ref };
216
161
  },
@@ -221,7 +166,7 @@ export function createFiles(cfg) {
221
166
  const key = ensureOwnKey(r.key);
222
167
  const exp = Math.floor(Date.now() / 1000) + (opts?.expiresIn ?? 3600);
223
168
  const fn = opts?.download ? (typeof ref === "string" ? undefined : ref.filename) : undefined;
224
- const token = await signToken({ t: cfg.tenant, k: key, op: "get", exp, fn }, cfg.secret);
169
+ const token = await signFileToken({ t: cfg.tenant, k: key, op: "get", exp, fn }, cfg.secret);
225
170
  return { url: `${base}/download?token=${encodeURIComponent(token)}`, expiresAt: exp * 1000 };
226
171
  },
227
172
  head: (key) => cfg.adapter.head(ensureOwnKey(key)),
@@ -258,7 +203,7 @@ export async function handleFileRequest(request, opts) {
258
203
  const raw = url.searchParams.get("token");
259
204
  if (!raw)
260
205
  return fileError(401, "unauthorized", "missing token");
261
- const token = await verifyToken(raw, opts.secret);
206
+ const token = await verifyFileToken(raw, opts.secret);
262
207
  if (!token)
263
208
  return fileError(403, "forbidden", "invalid or expired token");
264
209
  try {
@@ -0,0 +1,22 @@
1
+ /** Every signed token carries an expiry (epoch seconds); `verifyToken` enforces it. */
2
+ export interface ExpiringToken {
3
+ exp: number;
4
+ }
5
+ export declare function bytesToB64url(bytes: Uint8Array): string;
6
+ export declare function b64urlToBytes(s: string): Uint8Array;
7
+ export declare const strToB64url: (s: string) => string;
8
+ export declare const b64urlToStr: (s: string) => string;
9
+ /** Sign a payload into a capability token. */
10
+ export declare function signToken<T extends ExpiringToken>(payload: T, secret: string): Promise<string>;
11
+ /** Verify a token's signature and expiry; returns the payload, or `null` for anything
12
+ * malformed, forged, or expired. The caller decides what the payload authorizes — this
13
+ * only attests that we minted it and that it is still in date. */
14
+ export declare function verifyToken<T extends ExpiringToken>(raw: string, secret: string): Promise<T | null>;
15
+ /** A signing secret must be present and non-trivial, else tokens are forgeable (an HMAC
16
+ * over an empty or weak key). Below this length the feature is treated as UNCONFIGURED and
17
+ * fails closed, rather than minting urls that anyone can forge. */
18
+ export declare const MIN_TOKEN_SECRET_LEN = 16;
19
+ export declare function isUsableSecret(secret: unknown): secret is string;
20
+ /** Resolve a signing secret from env by preference order, skipping any that is absent or
21
+ * too weak. Returns `undefined` when nothing usable is configured — callers fail closed. */
22
+ export declare function resolveSecret(env: Readonly<Record<string, unknown>>, names: readonly string[]): string | undefined;
@@ -0,0 +1,88 @@
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
+ export function bytesToB64url(bytes) {
14
+ let bin = "";
15
+ for (const b of bytes)
16
+ bin += String.fromCharCode(b);
17
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
18
+ }
19
+ export function b64urlToBytes(s) {
20
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
21
+ const bin = atob(b64);
22
+ const out = new Uint8Array(bin.length);
23
+ for (let i = 0; i < bin.length; i++)
24
+ out[i] = bin.charCodeAt(i);
25
+ return out;
26
+ }
27
+ export const strToB64url = (s) => bytesToB64url(new TextEncoder().encode(s));
28
+ export const b64urlToStr = (s) => new TextDecoder().decode(b64urlToBytes(s));
29
+ async function hmacKey(secret) {
30
+ return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
31
+ "sign",
32
+ "verify",
33
+ ]);
34
+ }
35
+ /** Sign a payload into a capability token. */
36
+ export async function signToken(payload, secret) {
37
+ const data = strToB64url(JSON.stringify(payload));
38
+ const key = await hmacKey(secret);
39
+ const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
40
+ return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
41
+ }
42
+ /** Verify a token's signature and expiry; returns the payload, or `null` for anything
43
+ * malformed, forged, or expired. The caller decides what the payload authorizes — this
44
+ * only attests that we minted it and that it is still in date. */
45
+ export async function verifyToken(raw, secret) {
46
+ const dot = raw.indexOf(".");
47
+ if (dot < 0)
48
+ return null;
49
+ const data = raw.slice(0, dot);
50
+ const sig = raw.slice(dot + 1);
51
+ let ok;
52
+ try {
53
+ const key = await hmacKey(secret);
54
+ ok = await crypto.subtle.verify("HMAC", key, b64urlToBytes(sig), new TextEncoder().encode(data));
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ if (!ok)
60
+ return null;
61
+ let payload;
62
+ try {
63
+ payload = JSON.parse(b64urlToStr(data));
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ if (typeof payload.exp !== "number" || Math.floor(Date.now() / 1000) >= payload.exp)
69
+ return null;
70
+ return payload;
71
+ }
72
+ /** A signing secret must be present and non-trivial, else tokens are forgeable (an HMAC
73
+ * over an empty or weak key). Below this length the feature is treated as UNCONFIGURED and
74
+ * fails closed, rather than minting urls that anyone can forge. */
75
+ export const MIN_TOKEN_SECRET_LEN = 16;
76
+ export function isUsableSecret(secret) {
77
+ return typeof secret === "string" && secret.length >= MIN_TOKEN_SECRET_LEN;
78
+ }
79
+ /** Resolve a signing secret from env by preference order, skipping any that is absent or
80
+ * too weak. Returns `undefined` when nothing usable is configured — callers fail closed. */
81
+ export function resolveSecret(env, names) {
82
+ for (const name of names) {
83
+ const v = env[name];
84
+ if (isUsableSecret(v))
85
+ return v;
86
+ }
87
+ return undefined;
88
+ }
package/dist/sdk/acl.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { CellValue, JsonValue, Row } from "./infer";
1
2
  export type Action = "read" | "create" | "update" | "delete";
2
3
  /** Runtime identity. Augment with your own properties (userId, tier, …). */
3
4
  export interface Identity {
@@ -8,7 +9,7 @@ export interface Identity {
8
9
  * expiry per message (see durable-object.ts). Absent for non-expiring / synthetic
9
10
  * (callPrivileged) identities, which are therefore never treated as expired. */
10
11
  exp?: number;
11
- [key: string]: unknown;
12
+ [key: string]: JsonValue | undefined;
12
13
  }
13
14
  declare const IDENTITY_MARKER: unique symbol;
14
15
  export interface IdentityMarker {
@@ -17,7 +18,7 @@ export interface IdentityMarker {
17
18
  }
18
19
  /** Reference an identity property in a policy `where`, resolved per request. */
19
20
  export declare function $identity(path: string): IdentityMarker;
20
- export declare function isIdentityMarker(v: unknown): v is IdentityMarker;
21
+ export declare function isIdentityMarker(v: WhereValue): v is IdentityMarker;
21
22
  declare const INPUT_MARKER: unique symbol;
22
23
  export interface InputMarker {
23
24
  readonly [INPUT_MARKER]: true;
@@ -28,7 +29,7 @@ export interface InputMarker {
28
29
  * caller can read a row only by presenting its unguessable key, without being able
29
30
  * to enumerate. An absent input value makes the rule match nothing (safe deny). */
30
31
  export declare function $input(path: string): InputMarker;
31
- export declare function isInputMarker(v: unknown): v is InputMarker;
32
+ export declare function isInputMarker(v: WhereValue): v is InputMarker;
32
33
  declare const NOW_MARKER: unique symbol;
33
34
  export interface NowMarker {
34
35
  readonly [NOW_MARKER]: true;
@@ -49,7 +50,7 @@ export interface NowMarker {
49
50
  * `toISOString()` — as the CMS `publish` field does — or compare it against
50
51
  * `expr.now()`-shaped values only. */
51
52
  export declare function $now(): NowMarker;
52
- export declare function isNowMarker(v: unknown): v is NowMarker;
53
+ export declare function isNowMarker(v: WhereValue): v is NowMarker;
53
54
  export interface AllowMarker {
54
55
  readonly kind: "allow";
55
56
  }
@@ -58,8 +59,14 @@ export interface DenyMarker {
58
59
  }
59
60
  export declare function allow(): AllowMarker;
60
61
  export declare function deny(): DenyMarker;
61
- /** A where rule: column -> value, where value may be a literal or an $identity marker. */
62
- export type WhereRule = Record<string, unknown | IdentityMarker>;
62
+ /** A value inside a `where` rule: a literal cell value, a per-request marker, an
63
+ * operator object (`{ gte: 5 }`), a nested relation predicate, or an AND/OR group. */
64
+ export type WhereValue = CellValue | IdentityMarker | InputMarker | NowMarker | WhereRule | WhereValue[];
65
+ /** A where rule: column (or `AND`/`OR`/`NOT`) -> value. An interface so it can recur
66
+ * through `WhereValue` for nested relation predicates and boolean groups. */
67
+ export interface WhereRule {
68
+ [key: string]: WhereValue;
69
+ }
63
70
  /** A per-row (cell-level) field grant: `fields` are permitted only for rows that
64
71
  * match `when`. Additive over the policy's flat `fields` — a conditional grant can
65
72
  * only ever ADD fields, never remove them. */
@@ -71,7 +78,7 @@ export interface ConditionalFields {
71
78
  /** Escape hatch for cell-level ACL: a late per-row resolver. Given the identity and
72
79
  * the fetched (or candidate, on write) row, returns the extra permitted fields —
73
80
  * additive over `fields`; `null` means all fields for that row. */
74
- export type FieldsFn = (identity: Identity | null, row: Record<string, unknown>) => string[] | null;
81
+ export type FieldsFn = (identity: Identity | null, row: Row) => string[] | null;
75
82
  /** Per-relation ACL inside a parent read policy. */
76
83
  export interface RelationAclRule {
77
84
  /** Permit traversal to the related entity via this relation even if it has no
@@ -87,11 +94,11 @@ export interface RelationAclRule {
87
94
  fieldsFn?: FieldsFn;
88
95
  }
89
96
  /** A forced column value on write: a literal, or computed from the identity. */
90
- export type SetValue = unknown | ((identity: Identity | null) => unknown);
97
+ export type SetValue = CellValue | ((identity: Identity | null) => CellValue);
91
98
  /** Server-side validation on write; throw to reject. Runs on the final values. */
92
99
  export type Validator = (args: {
93
100
  identity: Identity | null;
94
- values: Record<string, unknown>;
101
+ values: Row;
95
102
  }) => void;
96
103
  export interface PolicyRules {
97
104
  /** Row-level predicate (AND of equalities). Omit/empty = all rows. */
@@ -117,13 +124,13 @@ export interface PolicyRules {
117
124
  export interface ResolverDb {
118
125
  find(spec: {
119
126
  from: string;
120
- where?: Record<string, unknown>;
127
+ where?: WhereRule;
121
128
  orderBy?: {
122
129
  column: string;
123
130
  dir?: "asc" | "desc";
124
131
  };
125
132
  limit?: number;
126
- }): Promise<Array<Record<string, unknown>>>;
133
+ }): Promise<Row[]>;
127
134
  }
128
135
  export interface ResolverContext {
129
136
  readonly identity: Identity | null;
@@ -6,6 +6,12 @@ import type { Queue } from "../runtime/queue";
6
6
  import type { Identity } from "./acl";
7
7
  import type { Files } from "./files";
8
8
  import type { SchemaDef } from "./schema";
9
+ import type { JsonValue } from "./infer";
10
+ /** The Worker/DO environment as an open, read-only bag: Cloudflare bindings (KV, R2,
11
+ * D1, Queues, …) alongside vars and secrets. Deliberately open and opaque — an app
12
+ * declares its own bindings, so the value type cannot be enumerated here; read a value
13
+ * and narrow it at the use site (`ctx.env.STRIPE_SECRET_KEY as string`). */
14
+ export type EnvBag = Readonly<Record<string, unknown>>;
9
15
  export interface HandlerContext<S extends SchemaDef = SchemaDef> {
10
16
  /** Schema-typed repository: find/insert/update/delete inferred from S. */
11
17
  readonly db: Db<S>;
@@ -25,9 +31,16 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
25
31
  * Use it to call external services from handlers — Cloudflare bindings (e.g. the
26
32
  * `send_email` binding for Cloudflare Email Sending) or third-party APIs (Stripe, …). Loosely typed;
27
33
  * cast a value at the use site, e.g. `ctx.env.STRIPE_SECRET_KEY as string`. */
28
- readonly env: Readonly<Record<string, unknown>>;
34
+ readonly env: EnvBag;
29
35
  /** Resolved identity for this request (null = anonymous). */
30
36
  readonly identity: Identity | null;
37
+ /** The tenant this request is for (the `x-pramen-tenant` value; `"main"` by default).
38
+ * Server-resolved, never caller-supplied — safe to embed in a signed capability. */
39
+ readonly tenant: string;
40
+ /** Which substrate is serving this request: `"do"` (a Durable Object) or `"d1"`. Only
41
+ * the DO path has a stub the Worker can call back into, so a handler minting a
42
+ * capability redeemed through `callPrivileged` must refuse on `"d1"`. */
43
+ readonly store: "do" | "d1";
31
44
  /** Deferred side-effects (a transactional outbox). `tasks.enqueue` persists a task
32
45
  * row in the SAME transaction as a mutation (atomic with the data write); a drainer
33
46
  * runs the matching `app.tasks` handler after commit, off the write path, with
@@ -100,7 +113,7 @@ export interface Handler<I = unknown, O = unknown> {
100
113
  readonly run: (ctx: HandlerContext<any>, input: I) => O | Promise<O>;
101
114
  /** Optional boundary validator: parse/validate the raw request input, throwing
102
115
  * to reject (surfaced as a 400). Its return type fixes the handler's input. */
103
- readonly input?: (raw: unknown) => unknown;
116
+ readonly input?: (raw: JsonValue) => unknown;
104
117
  /** Optional DO partition this handler runs in (static, server-side). The Worker
105
118
  * routes the request to the matching partition-DO before dispatch. Absent ⇒ the
106
119
  * default partition (routed to the bare tenant key). */
@@ -109,7 +122,7 @@ export interface Handler<I = unknown, O = unknown> {
109
122
  readonly auth?: HandlerAuth;
110
123
  }
111
124
  export interface HandlerOpts<I> {
112
- input?: (raw: unknown) => I;
125
+ input?: (raw: JsonValue) => I;
113
126
  /** DO partition this handler runs in. Absent ⇒ the default partition. */
114
127
  partition?: string;
115
128
  /** Authorization to CALL this handler (see HandlerAuth) — gate non-`ctx.db` handlers. */
@@ -5,6 +5,23 @@ export type { FileRef } from "./files";
5
5
  export type JsonValue = string | number | boolean | null | JsonValue[] | {
6
6
  [key: string]: JsonValue;
7
7
  };
8
+ /** A JSON object — the object arm of `JsonValue`, named so it can be referenced
9
+ * directly (e.g. an identity's claims, a `t.json()` column's object form). */
10
+ export interface JsonObject {
11
+ [key: string]: JsonValue;
12
+ }
13
+ /** A raw value as the substrate stores/returns it, before pramen's object↔JSON codec.
14
+ * DO SQLite and D1 hand back exactly these; BLOB columns arrive as an ArrayBuffer. */
15
+ export type SqlValue = string | number | bigint | boolean | null | ArrayBuffer;
16
+ /** A decoded column value as handlers see it at the `Db` chokepoint: any JSON value,
17
+ * a `fileRef` column's metadata, or — for an eager-loaded relation — the related
18
+ * row(s) grafted onto the parent under the relation name. */
19
+ export type CellValue = SqlValue | JsonValue | FileRef | Row | Row[];
20
+ /** A decoded database row — column name -> decoded value. An interface (not a
21
+ * `Record` alias) so it can recur through `CellValue` for eager-loaded relations. */
22
+ export interface Row {
23
+ [column: string]: CellValue;
24
+ }
8
25
  /** SQL field type -> TypeScript value type. */
9
26
  export type FieldTsType<D extends FieldDef> = D["type"] extends "text" ? string : D["type"] extends "boolean" ? boolean : D["type"] extends "json" ? JsonValue : D["type"] extends "fileRef" ? FileRef : D["type"] extends "uuid" ? string : number;
10
27
  /** A column is non-null iff it's NOT NULL or a primary key. */
package/dist/worker.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { JsonValue } from "./sdk/infer";
1
2
  import { type QueueProducerBinding } from "./runtime/queue";
2
3
  import { type QueueBatch } from "./runtime/queue-consumer";
3
4
  import type { PramenApp } from "./pramen";
@@ -65,7 +66,7 @@ export declare function useD1Store(opts: {
65
66
  * DO's JSON response (`{ ok, result }` / `{ ok: false, … }`). */
66
67
  export declare function callPrivileged(env: Env, opts: {
67
68
  name: string;
68
- input?: unknown;
69
+ input?: JsonValue;
69
70
  tenant?: string;
70
71
  roles?: string[];
71
72
  partition?: string;