@pramen/server 0.0.13 → 0.0.15

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 (53) hide show
  1. package/dist/auth.d.ts +17 -2
  2. package/dist/auth.js +26 -7
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +311 -0
  5. package/dist/durable-object.d.ts +22 -4
  6. package/dist/durable-object.js +121 -55
  7. package/dist/index.d.ts +4 -0
  8. package/dist/index.js +3 -0
  9. package/dist/pramen.d.ts +6 -0
  10. package/dist/pramen.js +1 -1
  11. package/dist/runtime/acl.js +28 -7
  12. package/dist/runtime/db.d.ts +6 -0
  13. package/dist/runtime/db.js +86 -10
  14. package/dist/runtime/ddl.d.ts +16 -3
  15. package/dist/runtime/ddl.js +28 -8
  16. package/dist/runtime/dispatch.js +2 -0
  17. package/dist/runtime/driver.d.ts +41 -7
  18. package/dist/runtime/driver.js +38 -11
  19. package/dist/runtime/migrate.d.ts +1 -1
  20. package/dist/runtime/migrate.js +222 -33
  21. package/dist/runtime/outbox.js +28 -6
  22. package/dist/runtime/queue-consumer.d.ts +71 -0
  23. package/dist/runtime/queue-consumer.js +63 -0
  24. package/dist/runtime/queue.d.ts +72 -0
  25. package/dist/runtime/queue.js +110 -0
  26. package/dist/runtime/read-engine.js +7 -2
  27. package/dist/runtime/schema-diff.d.ts +28 -5
  28. package/dist/runtime/schema-diff.js +111 -19
  29. package/dist/runtime/storage.d.ts +7 -0
  30. package/dist/runtime/storage.js +0 -0
  31. package/dist/sdk/handlers.d.ts +7 -0
  32. package/dist/worker.d.ts +36 -0
  33. package/dist/worker.js +128 -18
  34. package/package.json +6 -2
  35. package/src/auth.ts +64 -21
  36. package/src/cli.ts +336 -0
  37. package/src/durable-object.ts +118 -52
  38. package/src/index.ts +6 -0
  39. package/src/pramen.ts +7 -1
  40. package/src/runtime/acl.ts +25 -5
  41. package/src/runtime/db.ts +80 -9
  42. package/src/runtime/ddl.ts +26 -8
  43. package/src/runtime/dispatch.ts +2 -0
  44. package/src/runtime/driver.ts +52 -9
  45. package/src/runtime/migrate.ts +246 -34
  46. package/src/runtime/outbox.ts +30 -7
  47. package/src/runtime/queue-consumer.ts +116 -0
  48. package/src/runtime/queue.ts +155 -0
  49. package/src/runtime/read-engine.ts +7 -2
  50. package/src/runtime/schema-diff.ts +137 -23
  51. package/src/runtime/storage.ts +0 -0
  52. package/src/sdk/handlers.ts +7 -0
  53. package/src/worker.ts +162 -19
package/src/worker.ts CHANGED
@@ -4,21 +4,23 @@
4
4
  // endpoints (/tenants, /admin/recover, /admin/schema). createPramen() pairs the
5
5
  // returned fetch with the matching DO class; a consumer just re-exports both.
6
6
 
7
- import { authorizeTenant, HmacStrategy, isAdmin, JwksStrategy, resolveIdentity, type VerifyStrategy } from "./auth";
7
+ import { authorizeTenant, HmacStrategy, isAdmin, JwksStrategy, resolveIdentity, type VerifyOptions, type VerifyStrategy } from "./auth";
8
8
  import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
9
9
  import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
10
10
  import { createMail } from "./runtime/mail";
11
+ import { createQueue, type QueueProducerBinding } from "./runtime/queue";
12
+ import { dispatchQueueBatch, type QueueBatch, type QueueContext } from "./runtime/queue-consumer";
11
13
  import { migrate } from "./runtime/migrate";
12
14
  import { compileAcl } from "./runtime/acl";
13
15
  import { Db } from "./runtime/db";
14
- import { D1Driver, type Driver } from "./runtime/driver";
16
+ import { D1Driver, type D1SessionStart, type Driver } from "./runtime/driver";
15
17
  import { toResponse } from "./runtime/errors";
16
18
  import { Kv } from "./runtime/kv";
17
19
  import { listDOs, partitionDoName } from "./runtime/registry";
18
- import { createFiles, handleFileRequest, R2Adapter } from "./runtime/storage";
20
+ import { createFiles, handleFileRequest, handleMediaRequest, R2Adapter } from "./runtime/storage";
19
21
  import type { Identity } from "./sdk/acl";
20
22
  import type { HandlerContext } from "./sdk/handlers";
21
- import { DEFAULT_PARTITION } from "./sdk/schema";
23
+ import { DEFAULT_PARTITION, partitionsOf } from "./sdk/schema";
22
24
  import type { PramenApp } from "./pramen";
23
25
 
24
26
  export interface Env {
@@ -31,6 +33,16 @@ export interface Env {
31
33
  /** Optional: a JWKS endpoint. When set, tokens are verified as RS256 against the
32
34
  * fetched public keys (HmacStrategy/AUTH_SECRET is bypassed). */
33
35
  JWKS_URL?: string;
36
+ /** "true" to REJECT any bearer token with no numeric `exp` claim. Off by default
37
+ * (a token without exp is accepted) so existing issuers keep working; turn it on to
38
+ * refuse non-expiring tokens. Applies to both the HS256 and JWKS strategies. */
39
+ AUTH_REQUIRE_EXP?: string;
40
+ /** Optional required audience. When set, a token's `aud` (string or array) must
41
+ * contain this value or the token is rejected. Unset ⇒ `aud` is not checked. */
42
+ AUTH_AUDIENCE?: string;
43
+ /** Optional required issuer. When set, a token's `iss` must equal this exactly.
44
+ * Unset ⇒ `iss` is not checked. */
45
+ AUTH_ISSUER?: string;
34
46
  /** D1 binding. Enables the "Worker + D1 (no DO)" path — the same schema/ACL/read
35
47
  * engine over D1 instead of a Durable Object. Selected per-request via
36
48
  * `x-pramen-store: d1`. RPC only (live queries need the DO). */
@@ -45,12 +57,42 @@ export interface Env {
45
57
  CORS_ORIGINS?: string;
46
58
  /** "true" to apply destructive schema migrations on the D1 path. Off by default. */
47
59
  PRAMEN_ALLOW_DESTRUCTIVE?: string;
60
+ /** Default store for /rpc when no `x-pramen-store` header is sent: `"d1"` runs the
61
+ * Worker+D1 path by default (requires DB bound); `"do"` (the default) routes to the
62
+ * per-tenant Durable Object. The header still overrides per-request. /live always
63
+ * needs the DO regardless of this setting. */
64
+ PRAMEN_STORE?: string;
65
+ /** "true" to allow the shared D1 store to serve a non-`main` tenant. OFF by default:
66
+ * the D1 proof uses ONE database with no tenant column, so multiple tenants would
67
+ * commingle. Only set this if the app genuinely single-tenants that D1 (or has added
68
+ * its own tenant isolation). */
69
+ PRAMEN_D1_ALLOW_MULTITENANT?: string;
70
+ /** Cloudflare Queues producer binding for ctx.queue (declared in oblaka.ts). Optional —
71
+ * ctx.queue discovers any producer binding by name; this just types the common one. */
72
+ JOBS?: QueueProducerBinding;
48
73
  }
49
74
 
50
75
  /** The secret used to sign/verify file tokens — a dedicated FILES_SECRET if set,
51
76
  * else AUTH_SECRET (so HS256 setups work out of the box). */
52
77
  const filesSecret = (env: Env): string => env.FILES_SECRET || env.AUTH_SECRET;
53
78
 
79
+ /** Request/response header carrying the D1 session bookmark for read-your-writes: a
80
+ * client echoes the last response's value on its next request, anchoring a fresh
81
+ * session at that write so it reads its own writes (even off a lagging replica). */
82
+ const D1_BOOKMARK_HEADER = "x-pramen-d1-bookmark";
83
+
84
+ /** Decide whether an /rpc request runs on the D1 store. **Live queries ALWAYS use the
85
+ * DO** (they need a single writer + a socket host), regardless of header or default —
86
+ * so enabling `PRAMEN_STORE=d1` never silently breaks `/live`. Otherwise an explicit
87
+ * `x-pramen-store` header wins (`d1`/`do`), then the `PRAMEN_STORE` default. Pure +
88
+ * exported for unit testing. */
89
+ export function useD1Store(opts: { storeHeader: string | null; isLive: boolean; defaultStore: string | undefined }): boolean {
90
+ if (opts.isLive) return false; // live is DO-only — never the D1 path
91
+ if (opts.storeHeader === "d1") return true;
92
+ if (opts.storeHeader === "do") return false;
93
+ return opts.defaultStore === "d1";
94
+ }
95
+
54
96
  const json = (body: unknown, status = 200) => Response.json(body, { status });
55
97
  const forbidden = (what: string) => json({ ok: false, error: `access denied: ${what}`, code: "forbidden" }, 403);
56
98
  const badRequest = (msg: string) => json({ ok: false, error: msg, code: "bad_request" }, 400);
@@ -65,7 +107,10 @@ function corsHeaders(origin: string | null, env: Env): Record<string, string> {
65
107
  return {
66
108
  "access-control-allow-origin": allow.includes("*") ? "*" : origin,
67
109
  "access-control-allow-methods": "GET, POST, OPTIONS",
68
- "access-control-allow-headers": "content-type, authorization, x-pramen-tenant, x-pramen-store",
110
+ "access-control-allow-headers": "content-type, authorization, x-pramen-tenant, x-pramen-store, x-pramen-d1-bookmark",
111
+ // Expose the D1 read-your-writes bookmark so a browser client can read it off the
112
+ // response and carry it forward on the next request.
113
+ "access-control-expose-headers": "x-pramen-d1-bookmark",
69
114
  vary: "origin",
70
115
  };
71
116
  }
@@ -83,6 +128,15 @@ function withCors(res: Response, cors: Record<string, string>): Response {
83
128
  * `tenant` for the default partition (so `idFromName(tenant)` is byte-for-byte unchanged
84
129
  * — backward-compat) and `${tenant}:${partition}` for any other partition. */
85
130
  function partitionStubFor(env: Env, tenant: string, partition: string = DEFAULT_PARTITION): DurableObjectStub {
131
+ // Fail with a clear message rather than a cryptic `Cannot read 'get' of undefined`
132
+ // when the Durable Object isn't bound (e.g. a D1-only deployment that fell through to
133
+ // the DO path). The Worker RPC surface depends on this binding existing.
134
+ if (!env.PRAMEN) {
135
+ throw new Error(
136
+ "pramen: no Durable Object bound (PRAMEN). Pin the D1 store per request with the " +
137
+ "'x-pramen-store: d1' header (or set PRAMEN_STORE=d1), or bind the PramenDO.",
138
+ );
139
+ }
86
140
  return env.PRAMEN.get(env.PRAMEN.idFromName(partitionDoName(tenant, partition)));
87
141
  }
88
142
 
@@ -117,12 +171,20 @@ export function makeWorker(app: PramenApp) {
117
171
  // JwksStrategy caches fetched public keys, so keep one instance per isolate (keyed
118
172
  // by URL) rather than rebuilding it per request. HmacStrategy is stateless.
119
173
  let jwks: JwksStrategy | undefined;
174
+ // Opt-in claim validation from env — default OFF (unset) so existing tokens keep
175
+ // verifying. Threaded into whichever strategy the deployment uses.
176
+ const verifyOptsFor = (env: Env): VerifyOptions => ({
177
+ requireExp: env.AUTH_REQUIRE_EXP === "true",
178
+ audience: env.AUTH_AUDIENCE || undefined,
179
+ issuer: env.AUTH_ISSUER || undefined,
180
+ });
120
181
  const strategyFor = (env: Env): VerifyStrategy => {
182
+ const opts = verifyOptsFor(env);
121
183
  if (env.JWKS_URL) {
122
- if (!jwks || jwks.url !== env.JWKS_URL) jwks = new JwksStrategy(env.JWKS_URL);
184
+ if (!jwks || jwks.url !== env.JWKS_URL) jwks = new JwksStrategy(env.JWKS_URL, undefined, opts);
123
185
  return jwks;
124
186
  }
125
- return new HmacStrategy(env.AUTH_SECRET);
187
+ return new HmacStrategy(env.AUTH_SECRET, opts);
126
188
  };
127
189
 
128
190
  // ACL is compiled once per isolate; the Worker's D1 path reuses it (the DO compiles
@@ -151,21 +213,24 @@ export function makeWorker(app: PramenApp) {
151
213
  const files = createFiles({ tenant: "main", secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
152
214
  const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
153
215
  const kv = new Kv(env.KV);
154
- return { db, kv, files, env: env as unknown as Record<string, unknown>, identity, tasks: tasksFacade(driver), mail: createMail(env as unknown as Record<string, unknown>, kv) };
216
+ return { db, kv, files, env: env as unknown as Record<string, unknown>, identity, tasks: tasksFacade(driver), mail: createMail(env as unknown as Record<string, unknown>, kv), queue: createQueue(env as unknown as Record<string, unknown>) };
155
217
  };
156
218
 
157
219
  /** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
158
220
  * /admin/tasks/drain route with `x-pramen-store: d1`, and by `scheduled()` (Cron). */
159
221
  const drainD1 = async (env: Env): Promise<unknown> => {
160
222
  if (!env.DB) throw new Error("D1 store is not configured");
161
- const driver = new D1Driver(env.DB);
223
+ // The drain reads due tasks then writes their status — pin the primary so it sees
224
+ // and updates current outbox state (not a lagging replica).
225
+ const driver = new D1Driver(env.DB, { start: "first-primary" });
162
226
  await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
163
227
  return drainOutbox(driver, bindTasks(app.tasks, d1TaskCtx(driver, env)), Date.now());
164
228
  };
165
229
 
166
230
  const listD1Tasks = async (env: Env, status?: string, limit?: number): Promise<unknown> => {
167
231
  if (!env.DB) throw new Error("D1 store is not configured");
168
- const driver = new D1Driver(env.DB);
232
+ // Inspection listing pin the primary so it reflects current outbox state.
233
+ const driver = new D1Driver(env.DB, { start: "first-primary" });
169
234
  await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
170
235
  return listTasks(driver, { status, limit });
171
236
  };
@@ -181,6 +246,14 @@ export function makeWorker(app: PramenApp) {
181
246
  if (res) return res;
182
247
  }
183
248
 
249
+ // Public media serving: `GET /media/<tenant>/media/<key>` streams a CMS media blob
250
+ // from R2 (cache-friendly, no auth — published-site assets are public). Put Cloudflare
251
+ // Image Resizing (/cdn-cgi/image) in front for transforms. Restricted to media keys.
252
+ if (url.pathname.startsWith("/media/") && env.FILES) {
253
+ const res = await handleMediaRequest(request, { adapter: new R2Adapter(env.FILES) });
254
+ if (res) return res;
255
+ }
256
+
184
257
  // Public (pre-auth) routes — matched before identity resolution, so a
185
258
  // signature-authed webhook can live outside the JWT-gated /rpc surface.
186
259
  for (const r of app.routes ?? []) {
@@ -344,23 +417,53 @@ export function makeWorker(app: PramenApp) {
344
417
  const tenant = req.headers.get("x-pramen-tenant") ?? "main";
345
418
  if (!authorizeTenant(identity, tenant)) return withCors(forbidden(`tenant '${tenant}'`), cors);
346
419
 
347
- // --- Worker + D1 (no DO): the same schema/ACL/read engine over a D1 binding,
348
- // selected per-request via `x-pramen-store: d1`. RPC only live queries need the
349
- // DO (single writer + a socket host). This proof uses ONE shared D1 database
350
- // across tenants; a real product would add a tenant column or a per-tenant DB.
351
- if (req.headers.get("x-pramen-store") === "d1") {
420
+ // --- Worker + D1 (no DO): the same schema/ACL/read engine over a D1 binding.
421
+ // Selected per-request via `x-pramen-store: d1`, OR as the app-wide default when
422
+ // PRAMEN_STORE=d1 (the header still overrides: `x-pramen-store: do` forces the DO).
423
+ // RPC only live queries need the DO (single writer + a socket host). This proof
424
+ // uses ONE shared D1 database across tenants; a real product would add a tenant
425
+ // column or a per-tenant DB.
426
+ const storeHeader = req.headers.get("x-pramen-store");
427
+ const useD1 = useD1Store({ storeHeader, isLive, defaultStore: env.PRAMEN_STORE });
428
+ if (useD1) {
352
429
  if (!env.DB) return badRequest("D1 store is not configured");
353
- if (isLive) return badRequest("live queries require the default (DO) store");
430
+ // COMMINGLING GUARD: this D1 path is ONE shared database with no tenant column, so
431
+ // every tenant's rows live together. Selecting it for a non-`main` tenant (a
432
+ // multi-tenant scenario) would leak/mix tenants — and `PRAMEN_STORE=d1` makes it a
433
+ // silent global default. Fail closed unless the operator explicitly opts in.
434
+ if (tenant !== "main" && env.PRAMEN_D1_ALLOW_MULTITENANT !== "true") {
435
+ return withCors(
436
+ forbidden(`D1 store for tenant '${tenant}' (shared D1 has no tenant isolation — set PRAMEN_D1_ALLOW_MULTITENANT=true to allow)`),
437
+ cors,
438
+ );
439
+ }
440
+ // (isLive is excluded by useD1Store — live always routes to the DO below.)
354
441
  const name = url.pathname.replace(/^\/rpc\//, "");
355
442
  let input: unknown;
356
443
  if (request.method === "POST") input = await request.json().catch(() => undefined);
357
- const driver = new D1Driver(env.DB);
444
+
445
+ // Pick where the D1 session may start its first read. A mutation ALWAYS pins the
446
+ // primary (`first-primary` is a superset of read-your-writes) so a read-modify-write
447
+ // can't run off a lagging replica — an inbound bookmark must not widen that window.
448
+ // A query honors a client-supplied bookmark (read-your-writes), else the nearest replica.
449
+ const inboundBookmark = req.headers.get(D1_BOOKMARK_HEADER);
450
+ const kind = app.handlers[name]?.kind;
451
+ let start: D1SessionStart;
452
+ if (kind === "mutation") start = "first-primary";
453
+ else if (inboundBookmark) start = inboundBookmark;
454
+ else start = "first-unconstrained";
455
+
456
+ const driver = new D1Driver(env.DB, { start });
358
457
  const files = createFiles({ tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
359
458
  const envBag = env as unknown as Record<string, unknown>;
360
459
  try {
361
460
  await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
362
461
  const { result } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, envBag, { acl: d1Acl, identity }, name, input);
363
- return withCors(json({ ok: true, result }), cors);
462
+ const res = json({ ok: true, result });
463
+ // Thread the session's latest bookmark back so the client can read its own writes.
464
+ const bookmark = driver.getBookmark();
465
+ if (bookmark) res.headers.set(D1_BOOKMARK_HEADER, bookmark);
466
+ return withCors(res, cors);
364
467
  } catch (err) {
365
468
  const { status, body } = toResponse(err);
366
469
  return withCors(json(body, status), cors);
@@ -375,15 +478,39 @@ export function makeWorker(app: PramenApp) {
375
478
  const name = url.pathname.replace(/^\/rpc\//, "");
376
479
  partition = app.handlers[name]?.partition ?? DEFAULT_PARTITION;
377
480
  } else {
481
+ // /live's partition is client-supplied (?partition= / x-pramen-partition), so
482
+ // validate it against the schema's known partitions BEFORE routing — otherwise an
483
+ // anonymous caller could spin up unbounded junk DOs + permanent registry KV keys.
378
484
  partition = req.headers.get("x-pramen-partition") || DEFAULT_PARTITION;
485
+ if (!partitionsOf(app.schema).includes(partition)) {
486
+ return withCors(badRequest(`unknown partition '${partition}'`), cors);
487
+ }
379
488
  }
380
489
 
381
- // Forward a trusted identity to the DO (the DO never re-derives it).
490
+ // Forward a trusted identity to the DO (the DO never re-derives it). Also set the
491
+ // tenant header so the DO learns its own name — without it, `main` (the default when
492
+ // the client omits x-pramen-tenant) never registers and re-runs its guard forever.
382
493
  const headers = new Headers(req.headers);
383
494
  if (identity) headers.set("x-pramen-identity", JSON.stringify(identity as Identity));
384
495
  else headers.delete("x-pramen-identity");
496
+ headers.set("x-pramen-tenant", tenant);
385
497
  headers.set("x-pramen-partition", partition);
386
498
 
499
+ // Routed to the DO but no DO is bound — return a clear, actionable error instead of
500
+ // crashing the whole RPC surface. (A D1-only deployment should pin the D1 store with
501
+ // the `x-pramen-store: d1` header; the `PRAMEN_STORE` env default can be dropped by
502
+ // some adapters' env proxies, so the header is the reliable way to pin it.)
503
+ if (!env.PRAMEN) {
504
+ return withCors(
505
+ badRequest(
506
+ isLive
507
+ ? "live queries require a Durable Object, but no PRAMEN binding is configured"
508
+ : "no Durable Object (PRAMEN) is bound — pin the D1 store with the 'x-pramen-store: d1' header (or bind the DO)",
509
+ ),
510
+ cors,
511
+ );
512
+ }
513
+
387
514
  const stub = partitionStubFor(env, tenant, partition);
388
515
  // WebSocket upgrades (101) must be returned untouched; only add CORS to HTTP.
389
516
  const res = await stub.fetch(new Request(req, { headers }));
@@ -395,5 +522,21 @@ export function makeWorker(app: PramenApp) {
395
522
  async scheduled(_event: unknown, env: Env): Promise<void> {
396
523
  if (env.DB) await drainD1(env);
397
524
  },
525
+
526
+ // Cloudflare Queues consumer entry: routes a batch to the matching `app.queues`
527
+ // handler (ACK on success / RETRY on throw, per message). A consumer is Worker-level
528
+ // (no tenant DO): its ctx carries env/kv/mail/queue + callPrivileged to reach a DO.
529
+ async queue(batch: QueueBatch, env: Env): Promise<void> {
530
+ const envBag = env as unknown as Record<string, unknown>;
531
+ const kv = new Kv(env.KV);
532
+ const ctx: QueueContext = {
533
+ env: envBag,
534
+ kv,
535
+ mail: createMail(envBag, kv),
536
+ queue: createQueue(envBag),
537
+ callPrivileged: (opts) => callPrivileged(env, opts),
538
+ };
539
+ await dispatchQueueBatch(app.queues ?? {}, ctx, batch);
540
+ },
398
541
  };
399
542
  }