@pramen/server 0.0.14 → 0.0.16

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.
package/src/worker.ts CHANGED
@@ -4,7 +4,7 @@
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";
@@ -17,10 +17,10 @@ import { D1Driver, type D1SessionStart, type Driver } from "./runtime/driver";
17
17
  import { toResponse } from "./runtime/errors";
18
18
  import { Kv } from "./runtime/kv";
19
19
  import { listDOs, partitionDoName } from "./runtime/registry";
20
- import { createFiles, handleFileRequest, R2Adapter } from "./runtime/storage";
20
+ import { createFiles, handleFileRequest, handleMediaRequest, R2Adapter } from "./runtime/storage";
21
21
  import type { Identity } from "./sdk/acl";
22
22
  import type { HandlerContext } from "./sdk/handlers";
23
- import { DEFAULT_PARTITION } from "./sdk/schema";
23
+ import { DEFAULT_PARTITION, partitionsOf } from "./sdk/schema";
24
24
  import type { PramenApp } from "./pramen";
25
25
 
26
26
  export interface Env {
@@ -33,6 +33,16 @@ export interface Env {
33
33
  /** Optional: a JWKS endpoint. When set, tokens are verified as RS256 against the
34
34
  * fetched public keys (HmacStrategy/AUTH_SECRET is bypassed). */
35
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;
36
46
  /** D1 binding. Enables the "Worker + D1 (no DO)" path — the same schema/ACL/read
37
47
  * engine over D1 instead of a Durable Object. Selected per-request via
38
48
  * `x-pramen-store: d1`. RPC only (live queries need the DO). */
@@ -52,6 +62,11 @@ export interface Env {
52
62
  * per-tenant Durable Object. The header still overrides per-request. /live always
53
63
  * needs the DO regardless of this setting. */
54
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;
55
70
  /** Cloudflare Queues producer binding for ctx.queue (declared in oblaka.ts). Optional —
56
71
  * ctx.queue discovers any producer binding by name; this just types the common one. */
57
72
  JOBS?: QueueProducerBinding;
@@ -156,12 +171,20 @@ export function makeWorker(app: PramenApp) {
156
171
  // JwksStrategy caches fetched public keys, so keep one instance per isolate (keyed
157
172
  // by URL) rather than rebuilding it per request. HmacStrategy is stateless.
158
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
+ });
159
181
  const strategyFor = (env: Env): VerifyStrategy => {
182
+ const opts = verifyOptsFor(env);
160
183
  if (env.JWKS_URL) {
161
- 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);
162
185
  return jwks;
163
186
  }
164
- return new HmacStrategy(env.AUTH_SECRET);
187
+ return new HmacStrategy(env.AUTH_SECRET, opts);
165
188
  };
166
189
 
167
190
  // ACL is compiled once per isolate; the Worker's D1 path reuses it (the DO compiles
@@ -223,6 +246,14 @@ export function makeWorker(app: PramenApp) {
223
246
  if (res) return res;
224
247
  }
225
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
+
226
257
  // Public (pre-auth) routes — matched before identity resolution, so a
227
258
  // signature-authed webhook can live outside the JWT-gated /rpc surface.
228
259
  for (const r of app.routes ?? []) {
@@ -396,19 +427,30 @@ export function makeWorker(app: PramenApp) {
396
427
  const useD1 = useD1Store({ storeHeader, isLive, defaultStore: env.PRAMEN_STORE });
397
428
  if (useD1) {
398
429
  if (!env.DB) return badRequest("D1 store is not configured");
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
+ }
399
440
  // (isLive is excluded by useD1Store — live always routes to the DO below.)
400
441
  const name = url.pathname.replace(/^\/rpc\//, "");
401
442
  let input: unknown;
402
443
  if (request.method === "POST") input = await request.json().catch(() => undefined);
403
444
 
404
- // Pick where the D1 session may start its first read. A client-supplied bookmark
405
- // wins (read-your-writes); otherwise default by handler kind: a mutation pins the
406
- // primary so its reads see current data, a query may begin at the nearest replica.
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.
407
449
  const inboundBookmark = req.headers.get(D1_BOOKMARK_HEADER);
408
450
  const kind = app.handlers[name]?.kind;
409
451
  let start: D1SessionStart;
410
- if (inboundBookmark) start = inboundBookmark;
411
- else if (kind === "mutation") start = "first-primary";
452
+ if (kind === "mutation") start = "first-primary";
453
+ else if (inboundBookmark) start = inboundBookmark;
412
454
  else start = "first-unconstrained";
413
455
 
414
456
  const driver = new D1Driver(env.DB, { start });
@@ -436,13 +478,22 @@ export function makeWorker(app: PramenApp) {
436
478
  const name = url.pathname.replace(/^\/rpc\//, "");
437
479
  partition = app.handlers[name]?.partition ?? DEFAULT_PARTITION;
438
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.
439
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
+ }
440
488
  }
441
489
 
442
- // 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.
443
493
  const headers = new Headers(req.headers);
444
494
  if (identity) headers.set("x-pramen-identity", JSON.stringify(identity as Identity));
445
495
  else headers.delete("x-pramen-identity");
496
+ headers.set("x-pramen-tenant", tenant);
446
497
  headers.set("x-pramen-partition", partition);
447
498
 
448
499
  // Routed to the DO but no DO is bound — return a clear, actionable error instead of