@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/dist/auth.d.ts +17 -2
- package/dist/auth.js +26 -7
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +311 -0
- package/dist/durable-object.d.ts +22 -4
- package/dist/durable-object.js +119 -55
- package/dist/runtime/acl.js +28 -7
- package/dist/runtime/db.d.ts +6 -0
- package/dist/runtime/db.js +86 -10
- package/dist/runtime/ddl.d.ts +16 -3
- package/dist/runtime/ddl.js +23 -4
- package/dist/runtime/migrate.js +213 -20
- package/dist/runtime/outbox.js +28 -6
- package/dist/runtime/queue-consumer.d.ts +9 -2
- package/dist/runtime/queue-consumer.js +23 -6
- package/dist/runtime/read-engine.js +7 -2
- package/dist/runtime/schema-diff.d.ts +28 -5
- package/dist/runtime/schema-diff.js +111 -19
- package/dist/runtime/storage.d.ts +7 -0
- package/dist/runtime/storage.js +0 -0
- package/dist/worker.d.ts +15 -0
- package/dist/worker.js +44 -11
- package/package.json +6 -2
- package/src/auth.ts +64 -21
- package/src/cli.ts +336 -0
- package/src/durable-object.ts +116 -52
- package/src/runtime/acl.ts +25 -5
- package/src/runtime/db.ts +80 -9
- package/src/runtime/ddl.ts +21 -4
- package/src/runtime/migrate.ts +237 -20
- package/src/runtime/outbox.ts +30 -7
- package/src/runtime/queue-consumer.ts +24 -5
- package/src/runtime/read-engine.ts +7 -2
- package/src/runtime/schema-diff.ts +137 -23
- package/src/runtime/storage.ts +0 -0
- package/src/worker.ts +62 -11
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
|
|
405
|
-
//
|
|
406
|
-
//
|
|
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 (
|
|
411
|
-
else if (
|
|
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
|