@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.
- 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 +121 -55
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/pramen.d.ts +6 -0
- package/dist/pramen.js +1 -1
- 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 +28 -8
- package/dist/runtime/dispatch.js +2 -0
- package/dist/runtime/driver.d.ts +41 -7
- package/dist/runtime/driver.js +38 -11
- package/dist/runtime/migrate.d.ts +1 -1
- package/dist/runtime/migrate.js +222 -33
- package/dist/runtime/outbox.js +28 -6
- package/dist/runtime/queue-consumer.d.ts +71 -0
- package/dist/runtime/queue-consumer.js +63 -0
- package/dist/runtime/queue.d.ts +72 -0
- package/dist/runtime/queue.js +110 -0
- 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/sdk/handlers.d.ts +7 -0
- package/dist/worker.d.ts +36 -0
- package/dist/worker.js +128 -18
- package/package.json +6 -2
- package/src/auth.ts +64 -21
- package/src/cli.ts +336 -0
- package/src/durable-object.ts +118 -52
- package/src/index.ts +6 -0
- package/src/pramen.ts +7 -1
- package/src/runtime/acl.ts +25 -5
- package/src/runtime/db.ts +80 -9
- package/src/runtime/ddl.ts +26 -8
- package/src/runtime/dispatch.ts +2 -0
- package/src/runtime/driver.ts +52 -9
- package/src/runtime/migrate.ts +246 -34
- package/src/runtime/outbox.ts +30 -7
- package/src/runtime/queue-consumer.ts +116 -0
- package/src/runtime/queue.ts +155 -0
- 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/sdk/handlers.ts +7 -0
- package/src/worker.ts +162 -19
package/dist/worker.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type QueueProducerBinding } from "./runtime/queue";
|
|
2
|
+
import { type QueueBatch } from "./runtime/queue-consumer";
|
|
1
3
|
import type { PramenApp } from "./pramen";
|
|
2
4
|
export interface Env {
|
|
3
5
|
PRAMEN: DurableObjectNamespace;
|
|
@@ -9,6 +11,16 @@ export interface Env {
|
|
|
9
11
|
/** Optional: a JWKS endpoint. When set, tokens are verified as RS256 against the
|
|
10
12
|
* fetched public keys (HmacStrategy/AUTH_SECRET is bypassed). */
|
|
11
13
|
JWKS_URL?: string;
|
|
14
|
+
/** "true" to REJECT any bearer token with no numeric `exp` claim. Off by default
|
|
15
|
+
* (a token without exp is accepted) so existing issuers keep working; turn it on to
|
|
16
|
+
* refuse non-expiring tokens. Applies to both the HS256 and JWKS strategies. */
|
|
17
|
+
AUTH_REQUIRE_EXP?: string;
|
|
18
|
+
/** Optional required audience. When set, a token's `aud` (string or array) must
|
|
19
|
+
* contain this value or the token is rejected. Unset ⇒ `aud` is not checked. */
|
|
20
|
+
AUTH_AUDIENCE?: string;
|
|
21
|
+
/** Optional required issuer. When set, a token's `iss` must equal this exactly.
|
|
22
|
+
* Unset ⇒ `iss` is not checked. */
|
|
23
|
+
AUTH_ISSUER?: string;
|
|
12
24
|
/** D1 binding. Enables the "Worker + D1 (no DO)" path — the same schema/ACL/read
|
|
13
25
|
* engine over D1 instead of a Durable Object. Selected per-request via
|
|
14
26
|
* `x-pramen-store: d1`. RPC only (live queries need the DO). */
|
|
@@ -23,7 +35,30 @@ export interface Env {
|
|
|
23
35
|
CORS_ORIGINS?: string;
|
|
24
36
|
/** "true" to apply destructive schema migrations on the D1 path. Off by default. */
|
|
25
37
|
PRAMEN_ALLOW_DESTRUCTIVE?: string;
|
|
38
|
+
/** Default store for /rpc when no `x-pramen-store` header is sent: `"d1"` runs the
|
|
39
|
+
* Worker+D1 path by default (requires DB bound); `"do"` (the default) routes to the
|
|
40
|
+
* per-tenant Durable Object. The header still overrides per-request. /live always
|
|
41
|
+
* needs the DO regardless of this setting. */
|
|
42
|
+
PRAMEN_STORE?: string;
|
|
43
|
+
/** "true" to allow the shared D1 store to serve a non-`main` tenant. OFF by default:
|
|
44
|
+
* the D1 proof uses ONE database with no tenant column, so multiple tenants would
|
|
45
|
+
* commingle. Only set this if the app genuinely single-tenants that D1 (or has added
|
|
46
|
+
* its own tenant isolation). */
|
|
47
|
+
PRAMEN_D1_ALLOW_MULTITENANT?: string;
|
|
48
|
+
/** Cloudflare Queues producer binding for ctx.queue (declared in oblaka.ts). Optional —
|
|
49
|
+
* ctx.queue discovers any producer binding by name; this just types the common one. */
|
|
50
|
+
JOBS?: QueueProducerBinding;
|
|
26
51
|
}
|
|
52
|
+
/** Decide whether an /rpc request runs on the D1 store. **Live queries ALWAYS use the
|
|
53
|
+
* DO** (they need a single writer + a socket host), regardless of header or default —
|
|
54
|
+
* so enabling `PRAMEN_STORE=d1` never silently breaks `/live`. Otherwise an explicit
|
|
55
|
+
* `x-pramen-store` header wins (`d1`/`do`), then the `PRAMEN_STORE` default. Pure +
|
|
56
|
+
* exported for unit testing. */
|
|
57
|
+
export declare function useD1Store(opts: {
|
|
58
|
+
storeHeader: string | null;
|
|
59
|
+
isLive: boolean;
|
|
60
|
+
defaultStore: string | undefined;
|
|
61
|
+
}): boolean;
|
|
27
62
|
/** Forward a privileged mutation into a tenant's DO from a public route. The
|
|
28
63
|
* synthetic identity (default `["admin"]`) is trusted because the call originates
|
|
29
64
|
* in the Worker — the same internal mechanism the admin endpoints use. Returns the
|
|
@@ -40,4 +75,5 @@ export declare function callPrivileged(env: Env, opts: {
|
|
|
40
75
|
export declare function makeWorker(app: PramenApp): {
|
|
41
76
|
fetch(request: Request, env: Env): Promise<Response>;
|
|
42
77
|
scheduled(_event: unknown, env: Env): Promise<void>;
|
|
78
|
+
queue(batch: QueueBatch, env: Env): Promise<void>;
|
|
43
79
|
};
|
package/dist/worker.js
CHANGED
|
@@ -7,6 +7,8 @@ import { authorizeTenant, HmacStrategy, isAdmin, JwksStrategy, resolveIdentity }
|
|
|
7
7
|
import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
|
|
8
8
|
import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
|
|
9
9
|
import { createMail } from "./runtime/mail";
|
|
10
|
+
import { createQueue } from "./runtime/queue";
|
|
11
|
+
import { dispatchQueueBatch } from "./runtime/queue-consumer";
|
|
10
12
|
import { migrate } from "./runtime/migrate";
|
|
11
13
|
import { compileAcl } from "./runtime/acl";
|
|
12
14
|
import { Db } from "./runtime/db";
|
|
@@ -14,11 +16,29 @@ import { D1Driver } from "./runtime/driver";
|
|
|
14
16
|
import { toResponse } from "./runtime/errors";
|
|
15
17
|
import { Kv } from "./runtime/kv";
|
|
16
18
|
import { listDOs, partitionDoName } from "./runtime/registry";
|
|
17
|
-
import { createFiles, handleFileRequest, R2Adapter } from "./runtime/storage";
|
|
18
|
-
import { DEFAULT_PARTITION } from "./sdk/schema";
|
|
19
|
+
import { createFiles, handleFileRequest, handleMediaRequest, R2Adapter } from "./runtime/storage";
|
|
20
|
+
import { DEFAULT_PARTITION, partitionsOf } from "./sdk/schema";
|
|
19
21
|
/** The secret used to sign/verify file tokens — a dedicated FILES_SECRET if set,
|
|
20
22
|
* else AUTH_SECRET (so HS256 setups work out of the box). */
|
|
21
23
|
const filesSecret = (env) => env.FILES_SECRET || env.AUTH_SECRET;
|
|
24
|
+
/** Request/response header carrying the D1 session bookmark for read-your-writes: a
|
|
25
|
+
* client echoes the last response's value on its next request, anchoring a fresh
|
|
26
|
+
* session at that write so it reads its own writes (even off a lagging replica). */
|
|
27
|
+
const D1_BOOKMARK_HEADER = "x-pramen-d1-bookmark";
|
|
28
|
+
/** Decide whether an /rpc request runs on the D1 store. **Live queries ALWAYS use the
|
|
29
|
+
* DO** (they need a single writer + a socket host), regardless of header or default —
|
|
30
|
+
* so enabling `PRAMEN_STORE=d1` never silently breaks `/live`. Otherwise an explicit
|
|
31
|
+
* `x-pramen-store` header wins (`d1`/`do`), then the `PRAMEN_STORE` default. Pure +
|
|
32
|
+
* exported for unit testing. */
|
|
33
|
+
export function useD1Store(opts) {
|
|
34
|
+
if (opts.isLive)
|
|
35
|
+
return false; // live is DO-only — never the D1 path
|
|
36
|
+
if (opts.storeHeader === "d1")
|
|
37
|
+
return true;
|
|
38
|
+
if (opts.storeHeader === "do")
|
|
39
|
+
return false;
|
|
40
|
+
return opts.defaultStore === "d1";
|
|
41
|
+
}
|
|
22
42
|
const json = (body, status = 200) => Response.json(body, { status });
|
|
23
43
|
const forbidden = (what) => json({ ok: false, error: `access denied: ${what}`, code: "forbidden" }, 403);
|
|
24
44
|
const badRequest = (msg) => json({ ok: false, error: msg, code: "bad_request" }, 400);
|
|
@@ -34,7 +54,10 @@ function corsHeaders(origin, env) {
|
|
|
34
54
|
return {
|
|
35
55
|
"access-control-allow-origin": allow.includes("*") ? "*" : origin,
|
|
36
56
|
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
37
|
-
"access-control-allow-headers": "content-type, authorization, x-pramen-tenant, x-pramen-store",
|
|
57
|
+
"access-control-allow-headers": "content-type, authorization, x-pramen-tenant, x-pramen-store, x-pramen-d1-bookmark",
|
|
58
|
+
// Expose the D1 read-your-writes bookmark so a browser client can read it off the
|
|
59
|
+
// response and carry it forward on the next request.
|
|
60
|
+
"access-control-expose-headers": "x-pramen-d1-bookmark",
|
|
38
61
|
vary: "origin",
|
|
39
62
|
};
|
|
40
63
|
}
|
|
@@ -52,6 +75,13 @@ function withCors(res, cors) {
|
|
|
52
75
|
* `tenant` for the default partition (so `idFromName(tenant)` is byte-for-byte unchanged
|
|
53
76
|
* — backward-compat) and `${tenant}:${partition}` for any other partition. */
|
|
54
77
|
function partitionStubFor(env, tenant, partition = DEFAULT_PARTITION) {
|
|
78
|
+
// Fail with a clear message rather than a cryptic `Cannot read 'get' of undefined`
|
|
79
|
+
// when the Durable Object isn't bound (e.g. a D1-only deployment that fell through to
|
|
80
|
+
// the DO path). The Worker RPC surface depends on this binding existing.
|
|
81
|
+
if (!env.PRAMEN) {
|
|
82
|
+
throw new Error("pramen: no Durable Object bound (PRAMEN). Pin the D1 store per request with the " +
|
|
83
|
+
"'x-pramen-store: d1' header (or set PRAMEN_STORE=d1), or bind the PramenDO.");
|
|
84
|
+
}
|
|
55
85
|
return env.PRAMEN.get(env.PRAMEN.idFromName(partitionDoName(tenant, partition)));
|
|
56
86
|
}
|
|
57
87
|
/** Forward a privileged mutation into a tenant's DO from a public route. The
|
|
@@ -79,13 +109,21 @@ export function makeWorker(app) {
|
|
|
79
109
|
// JwksStrategy caches fetched public keys, so keep one instance per isolate (keyed
|
|
80
110
|
// by URL) rather than rebuilding it per request. HmacStrategy is stateless.
|
|
81
111
|
let jwks;
|
|
112
|
+
// Opt-in claim validation from env — default OFF (unset) so existing tokens keep
|
|
113
|
+
// verifying. Threaded into whichever strategy the deployment uses.
|
|
114
|
+
const verifyOptsFor = (env) => ({
|
|
115
|
+
requireExp: env.AUTH_REQUIRE_EXP === "true",
|
|
116
|
+
audience: env.AUTH_AUDIENCE || undefined,
|
|
117
|
+
issuer: env.AUTH_ISSUER || undefined,
|
|
118
|
+
});
|
|
82
119
|
const strategyFor = (env) => {
|
|
120
|
+
const opts = verifyOptsFor(env);
|
|
83
121
|
if (env.JWKS_URL) {
|
|
84
122
|
if (!jwks || jwks.url !== env.JWKS_URL)
|
|
85
|
-
jwks = new JwksStrategy(env.JWKS_URL);
|
|
123
|
+
jwks = new JwksStrategy(env.JWKS_URL, undefined, opts);
|
|
86
124
|
return jwks;
|
|
87
125
|
}
|
|
88
|
-
return new HmacStrategy(env.AUTH_SECRET);
|
|
126
|
+
return new HmacStrategy(env.AUTH_SECRET, opts);
|
|
89
127
|
};
|
|
90
128
|
// ACL is compiled once per isolate; the Worker's D1 path reuses it (the DO compiles
|
|
91
129
|
// its own). Schema migration over D1 runs once per isolate (and short-circuits on a
|
|
@@ -112,21 +150,24 @@ export function makeWorker(app) {
|
|
|
112
150
|
const files = createFiles({ tenant: "main", secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
|
|
113
151
|
const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
|
|
114
152
|
const kv = new Kv(env.KV);
|
|
115
|
-
return { db, kv, files, env: env, identity, tasks: tasksFacade(driver), mail: createMail(env, kv) };
|
|
153
|
+
return { db, kv, files, env: env, identity, tasks: tasksFacade(driver), mail: createMail(env, kv), queue: createQueue(env) };
|
|
116
154
|
};
|
|
117
155
|
/** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
|
|
118
156
|
* /admin/tasks/drain route with `x-pramen-store: d1`, and by `scheduled()` (Cron). */
|
|
119
157
|
const drainD1 = async (env) => {
|
|
120
158
|
if (!env.DB)
|
|
121
159
|
throw new Error("D1 store is not configured");
|
|
122
|
-
|
|
160
|
+
// The drain reads due tasks then writes their status — pin the primary so it sees
|
|
161
|
+
// and updates current outbox state (not a lagging replica).
|
|
162
|
+
const driver = new D1Driver(env.DB, { start: "first-primary" });
|
|
123
163
|
await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
|
|
124
164
|
return drainOutbox(driver, bindTasks(app.tasks, d1TaskCtx(driver, env)), Date.now());
|
|
125
165
|
};
|
|
126
166
|
const listD1Tasks = async (env, status, limit) => {
|
|
127
167
|
if (!env.DB)
|
|
128
168
|
throw new Error("D1 store is not configured");
|
|
129
|
-
|
|
169
|
+
// Inspection listing — pin the primary so it reflects current outbox state.
|
|
170
|
+
const driver = new D1Driver(env.DB, { start: "first-primary" });
|
|
130
171
|
await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
|
|
131
172
|
return listTasks(driver, { status, limit });
|
|
132
173
|
};
|
|
@@ -140,6 +181,14 @@ export function makeWorker(app) {
|
|
|
140
181
|
if (res)
|
|
141
182
|
return res;
|
|
142
183
|
}
|
|
184
|
+
// Public media serving: `GET /media/<tenant>/media/<key>` streams a CMS media blob
|
|
185
|
+
// from R2 (cache-friendly, no auth — published-site assets are public). Put Cloudflare
|
|
186
|
+
// Image Resizing (/cdn-cgi/image) in front for transforms. Restricted to media keys.
|
|
187
|
+
if (url.pathname.startsWith("/media/") && env.FILES) {
|
|
188
|
+
const res = await handleMediaRequest(request, { adapter: new R2Adapter(env.FILES) });
|
|
189
|
+
if (res)
|
|
190
|
+
return res;
|
|
191
|
+
}
|
|
143
192
|
// Public (pre-auth) routes — matched before identity resolution, so a
|
|
144
193
|
// signature-authed webhook can live outside the JWT-gated /rpc surface.
|
|
145
194
|
for (const r of app.routes ?? []) {
|
|
@@ -294,26 +343,54 @@ export function makeWorker(app) {
|
|
|
294
343
|
const tenant = req.headers.get("x-pramen-tenant") ?? "main";
|
|
295
344
|
if (!authorizeTenant(identity, tenant))
|
|
296
345
|
return withCors(forbidden(`tenant '${tenant}'`), cors);
|
|
297
|
-
// --- Worker + D1 (no DO): the same schema/ACL/read engine over a D1 binding
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
|
|
346
|
+
// --- Worker + D1 (no DO): the same schema/ACL/read engine over a D1 binding.
|
|
347
|
+
// Selected per-request via `x-pramen-store: d1`, OR as the app-wide default when
|
|
348
|
+
// PRAMEN_STORE=d1 (the header still overrides: `x-pramen-store: do` forces the DO).
|
|
349
|
+
// RPC only — live queries need the DO (single writer + a socket host). This proof
|
|
350
|
+
// uses ONE shared D1 database across tenants; a real product would add a tenant
|
|
351
|
+
// column or a per-tenant DB.
|
|
352
|
+
const storeHeader = req.headers.get("x-pramen-store");
|
|
353
|
+
const useD1 = useD1Store({ storeHeader, isLive, defaultStore: env.PRAMEN_STORE });
|
|
354
|
+
if (useD1) {
|
|
302
355
|
if (!env.DB)
|
|
303
356
|
return badRequest("D1 store is not configured");
|
|
304
|
-
|
|
305
|
-
|
|
357
|
+
// COMMINGLING GUARD: this D1 path is ONE shared database with no tenant column, so
|
|
358
|
+
// every tenant's rows live together. Selecting it for a non-`main` tenant (a
|
|
359
|
+
// multi-tenant scenario) would leak/mix tenants — and `PRAMEN_STORE=d1` makes it a
|
|
360
|
+
// silent global default. Fail closed unless the operator explicitly opts in.
|
|
361
|
+
if (tenant !== "main" && env.PRAMEN_D1_ALLOW_MULTITENANT !== "true") {
|
|
362
|
+
return withCors(forbidden(`D1 store for tenant '${tenant}' (shared D1 has no tenant isolation — set PRAMEN_D1_ALLOW_MULTITENANT=true to allow)`), cors);
|
|
363
|
+
}
|
|
364
|
+
// (isLive is excluded by useD1Store — live always routes to the DO below.)
|
|
306
365
|
const name = url.pathname.replace(/^\/rpc\//, "");
|
|
307
366
|
let input;
|
|
308
367
|
if (request.method === "POST")
|
|
309
368
|
input = await request.json().catch(() => undefined);
|
|
310
|
-
|
|
369
|
+
// Pick where the D1 session may start its first read. A mutation ALWAYS pins the
|
|
370
|
+
// primary (`first-primary` is a superset of read-your-writes) so a read-modify-write
|
|
371
|
+
// can't run off a lagging replica — an inbound bookmark must not widen that window.
|
|
372
|
+
// A query honors a client-supplied bookmark (read-your-writes), else the nearest replica.
|
|
373
|
+
const inboundBookmark = req.headers.get(D1_BOOKMARK_HEADER);
|
|
374
|
+
const kind = app.handlers[name]?.kind;
|
|
375
|
+
let start;
|
|
376
|
+
if (kind === "mutation")
|
|
377
|
+
start = "first-primary";
|
|
378
|
+
else if (inboundBookmark)
|
|
379
|
+
start = inboundBookmark;
|
|
380
|
+
else
|
|
381
|
+
start = "first-unconstrained";
|
|
382
|
+
const driver = new D1Driver(env.DB, { start });
|
|
311
383
|
const files = createFiles({ tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
|
|
312
384
|
const envBag = env;
|
|
313
385
|
try {
|
|
314
386
|
await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
|
|
315
387
|
const { result } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, envBag, { acl: d1Acl, identity }, name, input);
|
|
316
|
-
|
|
388
|
+
const res = json({ ok: true, result });
|
|
389
|
+
// Thread the session's latest bookmark back so the client can read its own writes.
|
|
390
|
+
const bookmark = driver.getBookmark();
|
|
391
|
+
if (bookmark)
|
|
392
|
+
res.headers.set(D1_BOOKMARK_HEADER, bookmark);
|
|
393
|
+
return withCors(res, cors);
|
|
317
394
|
}
|
|
318
395
|
catch (err) {
|
|
319
396
|
const { status, body } = toResponse(err);
|
|
@@ -329,15 +406,33 @@ export function makeWorker(app) {
|
|
|
329
406
|
partition = app.handlers[name]?.partition ?? DEFAULT_PARTITION;
|
|
330
407
|
}
|
|
331
408
|
else {
|
|
409
|
+
// /live's partition is client-supplied (?partition= / x-pramen-partition), so
|
|
410
|
+
// validate it against the schema's known partitions BEFORE routing — otherwise an
|
|
411
|
+
// anonymous caller could spin up unbounded junk DOs + permanent registry KV keys.
|
|
332
412
|
partition = req.headers.get("x-pramen-partition") || DEFAULT_PARTITION;
|
|
413
|
+
if (!partitionsOf(app.schema).includes(partition)) {
|
|
414
|
+
return withCors(badRequest(`unknown partition '${partition}'`), cors);
|
|
415
|
+
}
|
|
333
416
|
}
|
|
334
|
-
// Forward a trusted identity to the DO (the DO never re-derives it).
|
|
417
|
+
// Forward a trusted identity to the DO (the DO never re-derives it). Also set the
|
|
418
|
+
// tenant header so the DO learns its own name — without it, `main` (the default when
|
|
419
|
+
// the client omits x-pramen-tenant) never registers and re-runs its guard forever.
|
|
335
420
|
const headers = new Headers(req.headers);
|
|
336
421
|
if (identity)
|
|
337
422
|
headers.set("x-pramen-identity", JSON.stringify(identity));
|
|
338
423
|
else
|
|
339
424
|
headers.delete("x-pramen-identity");
|
|
425
|
+
headers.set("x-pramen-tenant", tenant);
|
|
340
426
|
headers.set("x-pramen-partition", partition);
|
|
427
|
+
// Routed to the DO but no DO is bound — return a clear, actionable error instead of
|
|
428
|
+
// crashing the whole RPC surface. (A D1-only deployment should pin the D1 store with
|
|
429
|
+
// the `x-pramen-store: d1` header; the `PRAMEN_STORE` env default can be dropped by
|
|
430
|
+
// some adapters' env proxies, so the header is the reliable way to pin it.)
|
|
431
|
+
if (!env.PRAMEN) {
|
|
432
|
+
return withCors(badRequest(isLive
|
|
433
|
+
? "live queries require a Durable Object, but no PRAMEN binding is configured"
|
|
434
|
+
: "no Durable Object (PRAMEN) is bound — pin the D1 store with the 'x-pramen-store: d1' header (or bind the DO)"), cors);
|
|
435
|
+
}
|
|
341
436
|
const stub = partitionStubFor(env, tenant, partition);
|
|
342
437
|
// WebSocket upgrades (101) must be returned untouched; only add CORS to HTTP.
|
|
343
438
|
const res = await stub.fetch(new Request(req, { headers }));
|
|
@@ -349,5 +444,20 @@ export function makeWorker(app) {
|
|
|
349
444
|
if (env.DB)
|
|
350
445
|
await drainD1(env);
|
|
351
446
|
},
|
|
447
|
+
// Cloudflare Queues consumer entry: routes a batch to the matching `app.queues`
|
|
448
|
+
// handler (ACK on success / RETRY on throw, per message). A consumer is Worker-level
|
|
449
|
+
// (no tenant DO): its ctx carries env/kv/mail/queue + callPrivileged to reach a DO.
|
|
450
|
+
async queue(batch, env) {
|
|
451
|
+
const envBag = env;
|
|
452
|
+
const kv = new Kv(env.KV);
|
|
453
|
+
const ctx = {
|
|
454
|
+
env: envBag,
|
|
455
|
+
kv,
|
|
456
|
+
mail: createMail(envBag, kv),
|
|
457
|
+
queue: createQueue(envBag),
|
|
458
|
+
callPrivileged: (opts) => callPrivileged(env, opts),
|
|
459
|
+
};
|
|
460
|
+
await dispatchQueueBatch(app.queues ?? {}, ctx, batch);
|
|
461
|
+
},
|
|
352
462
|
};
|
|
353
463
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.15",
|
|
4
4
|
"description": "pramen server runtime — schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
},
|
|
31
31
|
"main": "./dist/index.js",
|
|
32
32
|
"types": "./dist/index.d.ts",
|
|
33
|
+
"bin": {
|
|
34
|
+
"pramen": "./dist/cli.js"
|
|
35
|
+
},
|
|
33
36
|
"files": ["dist", "src"],
|
|
34
37
|
"scripts": {
|
|
35
38
|
"build": "rm -rf dist && tsc -p tsconfig.build.json"
|
|
@@ -38,6 +41,7 @@
|
|
|
38
41
|
"access": "public"
|
|
39
42
|
},
|
|
40
43
|
"devDependencies": {
|
|
41
|
-
"@cloudflare/workers-types": "^4.20250101.0"
|
|
44
|
+
"@cloudflare/workers-types": "^4.20250101.0",
|
|
45
|
+
"@types/node": "^22.0.0"
|
|
42
46
|
}
|
|
43
47
|
}
|
package/src/auth.ts
CHANGED
|
@@ -40,9 +40,36 @@ export interface VerifyStrategy {
|
|
|
40
40
|
/** Verify a signature over `${header}.${payload}` for the parsed header. */
|
|
41
41
|
type SignatureVerifier = (signingInput: string, signature: Uint8Array, header: JwtHeader) => Promise<boolean>;
|
|
42
42
|
|
|
43
|
+
/** Optional, opt-in claim validation layered on top of signature + exp/nbf. All
|
|
44
|
+
* default OFF (unset) so existing tokens keep verifying; a deployment turns these on
|
|
45
|
+
* to tighten what it accepts. Shared by every strategy (they all run verifyJwt). */
|
|
46
|
+
export interface VerifyOptions {
|
|
47
|
+
/** Reject a token that has no numeric `exp` (RFC 7519 leaves exp optional; a strict
|
|
48
|
+
* deployment can require it so no non-expiring token is ever accepted). */
|
|
49
|
+
requireExp?: boolean;
|
|
50
|
+
/** Required audience. `payload.aud` (string or string[]) must contain at least one of
|
|
51
|
+
* these; a missing `aud` is rejected. Unset ⇒ `aud` not checked. */
|
|
52
|
+
audience?: string | string[];
|
|
53
|
+
/** Required issuer. `payload.iss` must equal this exactly. Unset ⇒ `iss` not checked. */
|
|
54
|
+
issuer?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Does the token's `aud` claim satisfy the required audience? Token aud may be a
|
|
58
|
+
* string or an array; a match is any overlap with the expected audience(s). */
|
|
59
|
+
function audienceMatches(aud: unknown, expected: string | string[]): boolean {
|
|
60
|
+
const claim = Array.isArray(aud) ? aud.filter((a): a is string => typeof a === "string") : typeof aud === "string" ? [aud] : [];
|
|
61
|
+
const want = Array.isArray(expected) ? expected : [expected];
|
|
62
|
+
return want.some((w) => claim.includes(w));
|
|
63
|
+
}
|
|
64
|
+
|
|
43
65
|
// Shared JWT pipeline: parse, verify the signature via the supplied function, then
|
|
44
|
-
// validate exp/nbf. Any malformed part or a
|
|
45
|
-
|
|
66
|
+
// validate exp/nbf and the opt-in exp-required/aud/iss claims. Any malformed part or a
|
|
67
|
+
// verification throw -> null (reject).
|
|
68
|
+
async function verifyJwt(
|
|
69
|
+
token: string,
|
|
70
|
+
verifySignature: SignatureVerifier,
|
|
71
|
+
opts: VerifyOptions = {},
|
|
72
|
+
): Promise<Record<string, unknown> | null> {
|
|
46
73
|
const parts = token.split(".");
|
|
47
74
|
if (parts.length !== 3) return null;
|
|
48
75
|
const [h, p, sig] = parts;
|
|
@@ -70,27 +97,38 @@ async function verifyJwt(token: string, verifySignature: SignatureVerifier): Pro
|
|
|
70
97
|
}
|
|
71
98
|
|
|
72
99
|
const now = Math.floor(Date.now() / 1000);
|
|
73
|
-
|
|
100
|
+
const hasExp = typeof payload.exp === "number";
|
|
101
|
+
if (opts.requireExp && !hasExp) return null;
|
|
102
|
+
if (hasExp && now >= (payload.exp as number)) return null;
|
|
74
103
|
if (typeof payload.nbf === "number" && now < payload.nbf) return null;
|
|
104
|
+
if (opts.audience !== undefined && !audienceMatches(payload.aud, opts.audience)) return null;
|
|
105
|
+
if (opts.issuer !== undefined && payload.iss !== opts.issuer) return null;
|
|
75
106
|
return payload;
|
|
76
107
|
}
|
|
77
108
|
|
|
78
109
|
/** HS256 via a shared secret. The dev/default strategy. */
|
|
79
110
|
export class HmacStrategy implements VerifyStrategy {
|
|
80
|
-
constructor(
|
|
111
|
+
constructor(
|
|
112
|
+
private readonly secret: string,
|
|
113
|
+
private readonly opts: VerifyOptions = {},
|
|
114
|
+
) {}
|
|
81
115
|
|
|
82
116
|
verify(token: string): Promise<Record<string, unknown> | null> {
|
|
83
|
-
return verifyJwt(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
"
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
117
|
+
return verifyJwt(
|
|
118
|
+
token,
|
|
119
|
+
async (input, signature, header) => {
|
|
120
|
+
if (header.alg !== "HS256" || !this.secret) return false;
|
|
121
|
+
const key = await crypto.subtle.importKey(
|
|
122
|
+
"raw",
|
|
123
|
+
new TextEncoder().encode(this.secret),
|
|
124
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
125
|
+
false,
|
|
126
|
+
["verify"],
|
|
127
|
+
);
|
|
128
|
+
return crypto.subtle.verify("HMAC", key, signature, new TextEncoder().encode(input));
|
|
129
|
+
},
|
|
130
|
+
this.opts,
|
|
131
|
+
);
|
|
94
132
|
}
|
|
95
133
|
}
|
|
96
134
|
|
|
@@ -111,15 +149,20 @@ export class JwksStrategy implements VerifyStrategy {
|
|
|
111
149
|
constructor(
|
|
112
150
|
readonly url: string,
|
|
113
151
|
private readonly ttlMs = 600_000,
|
|
152
|
+
private readonly opts: VerifyOptions = {},
|
|
114
153
|
) {}
|
|
115
154
|
|
|
116
155
|
verify(token: string): Promise<Record<string, unknown> | null> {
|
|
117
|
-
return verifyJwt(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
156
|
+
return verifyJwt(
|
|
157
|
+
token,
|
|
158
|
+
async (input, signature, header) => {
|
|
159
|
+
if (header.alg !== "RS256") return false;
|
|
160
|
+
const key = await this.keyFor(header.kid);
|
|
161
|
+
if (!key) return false;
|
|
162
|
+
return crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, new TextEncoder().encode(input));
|
|
163
|
+
},
|
|
164
|
+
this.opts,
|
|
165
|
+
);
|
|
123
166
|
}
|
|
124
167
|
|
|
125
168
|
private lookup(kid?: string): CryptoKey | null {
|