@pramen/server 0.0.51 → 0.0.53
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/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/dist/runtime/driver.d.ts +9 -0
- package/dist/runtime/driver.js +16 -0
- package/dist/worker-entry.d.ts +1 -1
- package/dist/worker-entry.js +2 -0
- package/dist/worker.d.ts +16 -0
- package/dist/worker.js +106 -18
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/runtime/driver.ts +17 -0
- package/src/worker-entry.ts +3 -1
- package/src/worker.ts +120 -17
package/dist/index.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export { Kv, denySession, allowSession, isSessionDenied } from "./runtime/kv";
|
|
|
12
12
|
export type { FileRef, Files, SignUploadOpts, SignDownloadOpts, HeadResult } from "./sdk/files";
|
|
13
13
|
export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
|
|
14
14
|
export { signToken, verifyToken, isUsableSecret, resolveSecret, MIN_TOKEN_SECRET_LEN } from "./runtime/token";
|
|
15
|
+
export { HmacStrategy, JwksStrategy, type VerifyStrategy, type VerifyOptions } from "./auth";
|
|
15
16
|
export type { ExpiringToken } from "./runtime/token";
|
|
16
17
|
export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
|
|
17
18
|
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,10 @@ export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runt
|
|
|
21
21
|
// page-preview links. Exported so an app (or @pramen/cms) can mint its own capability url
|
|
22
22
|
// without a second signing implementation.
|
|
23
23
|
export { signToken, verifyToken, isUsableSecret, resolveSecret, MIN_TOKEN_SECRET_LEN } from "./runtime/token";
|
|
24
|
+
// Verify strategies live on the AUTHORING entry, not `/worker`: they carry no
|
|
25
|
+
// `cloudflare:workers` import, and @pramen/auth's OIDC flow verifies a provider's RS256 ID
|
|
26
|
+
// token with the same JWKS cache (and its key-rotation handling) the Worker uses.
|
|
27
|
+
export { HmacStrategy, JwksStrategy } from "./auth";
|
|
24
28
|
// --- mail (ctx.mail) ---
|
|
25
29
|
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
26
30
|
// --- queue (ctx.queue — Cloudflare Queues) ---
|
package/dist/runtime/driver.d.ts
CHANGED
|
@@ -83,6 +83,15 @@ export declare class D1Driver implements Driver {
|
|
|
83
83
|
start?: D1SessionStart;
|
|
84
84
|
});
|
|
85
85
|
exec(sql: string, params: CellValue[]): Promise<DriverRow[]>;
|
|
86
|
+
/** How many write statements this session has auto-committed.
|
|
87
|
+
*
|
|
88
|
+
* There is no rollback here (see the ATOMICITY LIMIT above), so a mutation that throws
|
|
89
|
+
* midway leaves whatever it had already written. Counting the writes lets the caller say
|
|
90
|
+
* so out loud instead of surfacing a half-applied mutation as an ordinary 500 — the
|
|
91
|
+
* difference between "the request failed" and "the request failed and your data is now
|
|
92
|
+
* in a state no code path intended". */
|
|
93
|
+
writtenCount(): number;
|
|
94
|
+
private writes;
|
|
86
95
|
/** The session's latest bookmark (null before any query). Threaded back to the client
|
|
87
96
|
* via the `x-pramen-d1-bookmark` response header so a subsequent request can anchor a
|
|
88
97
|
* fresh session at it and read its own writes. */
|
package/dist/runtime/driver.js
CHANGED
|
@@ -59,6 +59,9 @@ export class DoSqliteDriver {
|
|
|
59
59
|
return this.storage.transaction(fn);
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
/** Does this statement mutate? Used only to notice a partial write after a failed
|
|
63
|
+
* mutation — deliberately coarse: over-reporting a warning is harmless, missing one is not. */
|
|
64
|
+
const WRITE_SQL = /^\s*(insert|update|delete|replace|create|drop|alter)\b/i;
|
|
62
65
|
/** D1 — SQLite over RPC. Async by nature.
|
|
63
66
|
*
|
|
64
67
|
* Read replicas (Sessions API): every D1Driver opens ONE `db.withSession(start)` and
|
|
@@ -80,10 +83,23 @@ export class D1Driver {
|
|
|
80
83
|
this.session = db.withSession(opts?.start ?? "first-unconstrained");
|
|
81
84
|
}
|
|
82
85
|
async exec(sql, params) {
|
|
86
|
+
if (WRITE_SQL.test(sql))
|
|
87
|
+
this.writes++;
|
|
83
88
|
const stmt = params.length ? this.session.prepare(sql).bind(...params) : this.session.prepare(sql);
|
|
84
89
|
const { results } = await stmt.all();
|
|
85
90
|
return results ?? [];
|
|
86
91
|
}
|
|
92
|
+
/** How many write statements this session has auto-committed.
|
|
93
|
+
*
|
|
94
|
+
* There is no rollback here (see the ATOMICITY LIMIT above), so a mutation that throws
|
|
95
|
+
* midway leaves whatever it had already written. Counting the writes lets the caller say
|
|
96
|
+
* so out loud instead of surfacing a half-applied mutation as an ordinary 500 — the
|
|
97
|
+
* difference between "the request failed" and "the request failed and your data is now
|
|
98
|
+
* in a state no code path intended". */
|
|
99
|
+
writtenCount() {
|
|
100
|
+
return this.writes;
|
|
101
|
+
}
|
|
102
|
+
writes = 0;
|
|
87
103
|
/** The session's latest bookmark (null before any query). Threaded back to the client
|
|
88
104
|
* via the `x-pramen-d1-bookmark` response header so a subsequent request can anchor a
|
|
89
105
|
* fresh session at it and read its own writes. */
|
package/dist/worker-entry.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { createPramen, type PramenApp, type PublicRoute, type Env, type DoEnv } from "./pramen";
|
|
1
|
+
export { createPramen, type PramenApp, type PublicRoute, type RouteContext, type Env, type DoEnv } from "./pramen";
|
|
2
2
|
export { makeWorker, callPrivileged } from "./worker";
|
|
3
3
|
export { pramenDO, PramenDOBase } from "./durable-object";
|
package/dist/worker-entry.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// the main authoring entry: tools that merely load an app.ts to read its schema
|
|
4
4
|
// (the CLI, tests, codegen) import from "@pramen/server" and never drag in the DO
|
|
5
5
|
// runtime. A Worker's entry imports createPramen from here.
|
|
6
|
+
// `RouteContext` stays exported (it predates nothing — @pramen/auth's OIDC routes are
|
|
7
|
+
// typed against it); `WorkerOpts` goes with the reverted basePath.
|
|
6
8
|
export { createPramen } from "./pramen";
|
|
7
9
|
export { makeWorker, callPrivileged } from "./worker";
|
|
8
10
|
export { pramenDO, PramenDOBase } from "./durable-object";
|
package/dist/worker.d.ts
CHANGED
|
@@ -60,6 +60,22 @@ export declare function useD1Store(opts: {
|
|
|
60
60
|
isLive: boolean;
|
|
61
61
|
defaultStore: string | undefined;
|
|
62
62
|
}): boolean;
|
|
63
|
+
/** Should we warn that no Cron trigger seems to be wired?
|
|
64
|
+
*
|
|
65
|
+
* The DO store self-drains via an alarm; the D1 store has none, so a DELAYED task — a
|
|
66
|
+
* scheduled publish, a retry backoff — runs only when a Cron trigger calls
|
|
67
|
+
* `createPramen().scheduled`. Forgetting that is silent: the row simply never goes live,
|
|
68
|
+
* and nothing anywhere says why.
|
|
69
|
+
*
|
|
70
|
+
* A request-tail drain that leaves something due in the FUTURE is exactly the situation
|
|
71
|
+
* that depends on the cron, so it is the moment to say so. Once a cron actually fires, the
|
|
72
|
+
* question is settled and we never warn again. Exported for tests — the decision is pure. */
|
|
73
|
+
export declare function shouldWarnMissingCron(opts: {
|
|
74
|
+
cronSeen: boolean;
|
|
75
|
+
warned: boolean;
|
|
76
|
+
nextRunAt: number | null;
|
|
77
|
+
now: number;
|
|
78
|
+
}): boolean;
|
|
63
79
|
/** Forward a privileged mutation into a tenant's DO from a public route. The
|
|
64
80
|
* synthetic identity (default `["admin"]`) is trusted because the call originates
|
|
65
81
|
* in the Worker — the same internal mechanism the admin endpoints use. Returns the
|
package/dist/worker.js
CHANGED
|
@@ -13,7 +13,7 @@ import { migrate } from "./runtime/migrate";
|
|
|
13
13
|
import { compileAcl } from "./runtime/acl";
|
|
14
14
|
import { Db } from "./runtime/db";
|
|
15
15
|
import { D1Driver } from "./runtime/driver";
|
|
16
|
-
import { toResponse } from "./runtime/errors";
|
|
16
|
+
import { BadRequest, Forbidden, toResponse } from "./runtime/errors";
|
|
17
17
|
import { Kv, isSessionDenied } from "./runtime/kv";
|
|
18
18
|
import { listDOs, partitionDoName } from "./runtime/registry";
|
|
19
19
|
import { createFiles, handleFileRequest, handleMediaRequest, R2Adapter } from "./runtime/storage";
|
|
@@ -43,6 +43,21 @@ export function useD1Store(opts) {
|
|
|
43
43
|
return false;
|
|
44
44
|
return opts.defaultStore === "d1";
|
|
45
45
|
}
|
|
46
|
+
/** Should we warn that no Cron trigger seems to be wired?
|
|
47
|
+
*
|
|
48
|
+
* The DO store self-drains via an alarm; the D1 store has none, so a DELAYED task — a
|
|
49
|
+
* scheduled publish, a retry backoff — runs only when a Cron trigger calls
|
|
50
|
+
* `createPramen().scheduled`. Forgetting that is silent: the row simply never goes live,
|
|
51
|
+
* and nothing anywhere says why.
|
|
52
|
+
*
|
|
53
|
+
* A request-tail drain that leaves something due in the FUTURE is exactly the situation
|
|
54
|
+
* that depends on the cron, so it is the moment to say so. Once a cron actually fires, the
|
|
55
|
+
* question is settled and we never warn again. Exported for tests — the decision is pure. */
|
|
56
|
+
export function shouldWarnMissingCron(opts) {
|
|
57
|
+
if (opts.cronSeen || opts.warned)
|
|
58
|
+
return false;
|
|
59
|
+
return opts.nextRunAt != null && opts.nextRunAt > opts.now;
|
|
60
|
+
}
|
|
46
61
|
const json = (body, status = 200) => Response.json(body, { status });
|
|
47
62
|
const forbidden = (what) => json({ ok: false, error: `access denied: ${what}`, code: "forbidden" }, 403);
|
|
48
63
|
const badRequest = (msg) => json({ ok: false, error: msg, code: "bad_request" }, 400);
|
|
@@ -155,6 +170,47 @@ export function makeWorker(app) {
|
|
|
155
170
|
}
|
|
156
171
|
};
|
|
157
172
|
let d1Ready;
|
|
173
|
+
/** Run one handler against the D1 store, in the Worker. The request path and the
|
|
174
|
+
* PRIVILEGED path (routes, which have no ctx.db) both come through here, so the two
|
|
175
|
+
* cannot drift on migration, bootstrap, the multi-tenant guard or the outbox drain.
|
|
176
|
+
*
|
|
177
|
+
* Returns the `{ ok, result }` envelope rather than a Response so each caller can add
|
|
178
|
+
* what only it needs — CORS and the session bookmark for a request, nothing for an
|
|
179
|
+
* internal call. */
|
|
180
|
+
const dispatchD1 = async (env, ctx, opts) => {
|
|
181
|
+
if (!env.DB)
|
|
182
|
+
throw new BadRequest("D1 store is not configured");
|
|
183
|
+
// Same COMMINGLING GUARD as the request path: shared D1 has no tenant column, so a
|
|
184
|
+
// non-`main` tenant would mix rows unless the operator opted in explicitly.
|
|
185
|
+
if (opts.tenant !== "main" && env.PRAMEN_D1_ALLOW_MULTITENANT !== "true") {
|
|
186
|
+
throw new Forbidden(`D1 store for tenant '${opts.tenant}' (shared D1 has no tenant isolation — set PRAMEN_D1_ALLOW_MULTITENANT=true to allow)`);
|
|
187
|
+
}
|
|
188
|
+
const driver = new D1Driver(env.DB, { start: opts.start });
|
|
189
|
+
const files = createFiles({ tenant: opts.tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
|
|
190
|
+
await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
|
|
191
|
+
let dispatched;
|
|
192
|
+
try {
|
|
193
|
+
dispatched = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, envBag(env), { acl: d1Acl, identity: opts.identity, tenant: opts.tenant, store: "d1" }, opts.name, opts.input);
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
// D1 has no interactive transactions, so `transaction(fn)` runs `fn` as-is and a
|
|
197
|
+
// mutation that throws midway keeps whatever it already wrote. Say so: a partially
|
|
198
|
+
// applied mutation looks exactly like an ordinary 500 in the logs, and the difference
|
|
199
|
+
// — data left in a state no code path intended — is the whole point.
|
|
200
|
+
const written = driver.writtenCount();
|
|
201
|
+
if (written > 0) {
|
|
202
|
+
console.error(`pramen: '${opts.name}' failed on the D1 store AFTER ${written} write statement(s) had committed. ` +
|
|
203
|
+
`D1 has no interactive transactions, so this mutation is PARTIALLY APPLIED and will not roll back. ` +
|
|
204
|
+
`Use the Durable Object store if this mutation must be atomic.`);
|
|
205
|
+
}
|
|
206
|
+
throw err;
|
|
207
|
+
}
|
|
208
|
+
const { result, enqueued } = dispatched;
|
|
209
|
+
// Drain in the request tail so an enqueued task does not wait for the next Cron tick.
|
|
210
|
+
if (enqueued > 0 && ctx)
|
|
211
|
+
ctx.waitUntil(drainD1(env));
|
|
212
|
+
return { driver, result: result };
|
|
213
|
+
};
|
|
158
214
|
const ensureD1Migrated = (driver, allowDestructive) => {
|
|
159
215
|
if (!d1Ready) {
|
|
160
216
|
d1Ready = migrate(driver, app.schema, { allowDestructive })
|
|
@@ -183,14 +239,29 @@ export function makeWorker(app) {
|
|
|
183
239
|
};
|
|
184
240
|
/** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
|
|
185
241
|
* /admin/tasks/drain route with `x-pramen-store: d1`, and by `scheduled()` (Cron). */
|
|
186
|
-
|
|
242
|
+
// Whether a Cron trigger has ever driven a drain in this isolate, and whether we have
|
|
243
|
+
// already said it looks missing. See `shouldWarnMissingCron`.
|
|
244
|
+
let cronSeen = false;
|
|
245
|
+
let warnedNoCron = false;
|
|
246
|
+
const drainD1 = async (env, source = "request") => {
|
|
187
247
|
if (!env.DB)
|
|
188
248
|
throw new Error("D1 store is not configured");
|
|
249
|
+
if (source === "cron")
|
|
250
|
+
cronSeen = true;
|
|
189
251
|
// The drain reads due tasks then writes their status — pin the primary so it sees
|
|
190
252
|
// and updates current outbox state (not a lagging replica).
|
|
191
253
|
const driver = new D1Driver(env.DB, { start: "first-primary" });
|
|
192
254
|
await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
|
|
193
|
-
|
|
255
|
+
const result = await drainOutbox(driver, bindTasks(app.tasks, d1TaskCtx(driver, env)), Date.now());
|
|
256
|
+
if (source === "request" && shouldWarnMissingCron({ cronSeen, warned: warnedNoCron, nextRunAt: result.nextRunAt, now: Date.now() })) {
|
|
257
|
+
warnedNoCron = true;
|
|
258
|
+
const inSeconds = Math.round(((result.nextRunAt ?? 0) - Date.now()) / 1000);
|
|
259
|
+
console.warn(`pramen: a task is queued on the D1 store to run in ~${inSeconds}s, but no Cron trigger has drained this Worker. ` +
|
|
260
|
+
`The D1 store has no Durable Object alarm, so a DELAYED task (a scheduled publish, a retry) runs ONLY when a Cron ` +
|
|
261
|
+
`trigger calls createPramen().scheduled. Add \`triggers: { crons: ["* * * * *"] }\` to your Worker config, or drain ` +
|
|
262
|
+
`manually via POST /admin/tasks/drain. This warning appears once per isolate and stops once a Cron drain is seen.`);
|
|
263
|
+
}
|
|
264
|
+
return result;
|
|
194
265
|
};
|
|
195
266
|
const listD1Tasks = async (env, status, limit) => {
|
|
196
267
|
if (!env.DB)
|
|
@@ -222,7 +293,35 @@ export function makeWorker(app) {
|
|
|
222
293
|
// signature-authed webhook can live outside the JWT-gated /rpc surface.
|
|
223
294
|
for (const r of app.routes ?? []) {
|
|
224
295
|
if (request.method === r.method && url.pathname === r.path) {
|
|
225
|
-
|
|
296
|
+
// A public route has no ctx.db, so it reaches a handler through here. On the D1
|
|
297
|
+
// store there is no Durable Object to forward to — the engine runs in THIS Worker
|
|
298
|
+
// — so dispatch locally instead. Without this, everything built on a pre-auth
|
|
299
|
+
// route (a signed preview link, the sitemap) was DO-only, and the CMS had to
|
|
300
|
+
// refuse to mint preview links on D1 rather than hand out a dead one.
|
|
301
|
+
const routeCtx = {
|
|
302
|
+
callPrivileged: async (opts) => {
|
|
303
|
+
const store = useD1Store({ storeHeader: request.headers.get("x-pramen-store"), isLive: false, defaultStore: env.PRAMEN_STORE });
|
|
304
|
+
if (!store)
|
|
305
|
+
return callPrivileged(env, opts);
|
|
306
|
+
try {
|
|
307
|
+
const { result } = await dispatchD1(env, ctx, {
|
|
308
|
+
name: opts.name,
|
|
309
|
+
input: opts.input ?? null,
|
|
310
|
+
tenant: opts.tenant ?? "main",
|
|
311
|
+
// The same synthetic identity the DO path sends in `x-pramen-identity`.
|
|
312
|
+
identity: { roles: opts.roles ?? ["admin"] },
|
|
313
|
+
// A privileged call may write (a redeemed one-time token), so pin the
|
|
314
|
+
// primary rather than risk a read-modify-write off a lagging replica.
|
|
315
|
+
start: "first-primary",
|
|
316
|
+
});
|
|
317
|
+
return json({ ok: true, result });
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
const { status, body } = toResponse(err);
|
|
321
|
+
return json(body, status);
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
};
|
|
226
325
|
return r.handler(request, envBag(env), routeCtx);
|
|
227
326
|
}
|
|
228
327
|
}
|
|
@@ -323,7 +422,7 @@ export function makeWorker(app) {
|
|
|
323
422
|
return forbidden("tasks");
|
|
324
423
|
if (request.headers.get("x-pramen-store") === "d1") {
|
|
325
424
|
try {
|
|
326
|
-
return withCors(json({ ok: true, result: await drainD1(env) }), cors);
|
|
425
|
+
return withCors(json({ ok: true, result: await drainD1(env, "admin") }), cors);
|
|
327
426
|
}
|
|
328
427
|
catch (err) {
|
|
329
428
|
const { status, body } = toResponse(err);
|
|
@@ -420,21 +519,10 @@ export function makeWorker(app) {
|
|
|
420
519
|
start = inboundBookmark;
|
|
421
520
|
else
|
|
422
521
|
start = "first-unconstrained";
|
|
423
|
-
const driver = new D1Driver(env.DB, { start });
|
|
424
|
-
const files = createFiles({ tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
|
|
425
|
-
const bag = envBag(env);
|
|
426
522
|
try {
|
|
427
|
-
await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
|
|
428
523
|
// `tenant` matters here: a handler minting a tenant-scoped capability (a signed
|
|
429
524
|
// preview link) would otherwise stamp it "main" while reading acme's rows.
|
|
430
|
-
const {
|
|
431
|
-
// Kick an immediate drain in the request tail when this handler enqueued tasks
|
|
432
|
-
// (e.g. sendMagicLinkEmail). Without this, tasks wait for the next Cron trigger
|
|
433
|
-
// — up to a full minute. `waitUntil` lets the response return now while the
|
|
434
|
-
// drain runs; the Cron trigger remains the safety net for delayed / retried
|
|
435
|
-
// tasks that no request happens to coincide with.
|
|
436
|
-
if (enqueued > 0)
|
|
437
|
-
ctx.waitUntil(drainD1(env));
|
|
525
|
+
const { driver, result } = await dispatchD1(env, ctx, { name, input, tenant, identity, start });
|
|
438
526
|
const res = json({ ok: true, result });
|
|
439
527
|
// Thread the session's latest bookmark back so the client can read its own writes.
|
|
440
528
|
const bookmark = driver.getBookmark();
|
|
@@ -492,7 +580,7 @@ export function makeWorker(app) {
|
|
|
492
580
|
// so it needs no cron). Wire a `[triggers] crons` in wrangler/oblaka to call this.
|
|
493
581
|
async scheduled(_event, env) {
|
|
494
582
|
if (env.DB)
|
|
495
|
-
await drainD1(env);
|
|
583
|
+
await drainD1(env, "cron");
|
|
496
584
|
},
|
|
497
585
|
// Cloudflare Queues consumer entry: routes a batch to the matching `app.queues`
|
|
498
586
|
// handler (ACK on success / RETRY on throw, per message). A consumer is Worker-level
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.53",
|
|
4
4
|
"description": "pramen server runtime \u2014 schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/index.ts
CHANGED
|
@@ -87,6 +87,10 @@ export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runt
|
|
|
87
87
|
// page-preview links. Exported so an app (or @pramen/cms) can mint its own capability url
|
|
88
88
|
// without a second signing implementation.
|
|
89
89
|
export { signToken, verifyToken, isUsableSecret, resolveSecret, MIN_TOKEN_SECRET_LEN } from "./runtime/token";
|
|
90
|
+
// Verify strategies live on the AUTHORING entry, not `/worker`: they carry no
|
|
91
|
+
// `cloudflare:workers` import, and @pramen/auth's OIDC flow verifies a provider's RS256 ID
|
|
92
|
+
// token with the same JWKS cache (and its key-rotation handling) the Worker uses.
|
|
93
|
+
export { HmacStrategy, JwksStrategy, type VerifyStrategy, type VerifyOptions } from "./auth";
|
|
90
94
|
export type { ExpiringToken } from "./runtime/token";
|
|
91
95
|
export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
|
|
92
96
|
|
package/src/runtime/driver.ts
CHANGED
|
@@ -107,6 +107,10 @@ export class DoSqliteDriver implements Driver {
|
|
|
107
107
|
* A bookmark always wins over a constraint when one is supplied. */
|
|
108
108
|
export type D1SessionStart = "first-primary" | "first-unconstrained" | (string & {});
|
|
109
109
|
|
|
110
|
+
/** Does this statement mutate? Used only to notice a partial write after a failed
|
|
111
|
+
* mutation — deliberately coarse: over-reporting a warning is harmless, missing one is not. */
|
|
112
|
+
const WRITE_SQL = /^\s*(insert|update|delete|replace|create|drop|alter)\b/i;
|
|
113
|
+
|
|
110
114
|
/** D1 — SQLite over RPC. Async by nature.
|
|
111
115
|
*
|
|
112
116
|
* Read replicas (Sessions API): every D1Driver opens ONE `db.withSession(start)` and
|
|
@@ -129,11 +133,24 @@ export class D1Driver implements Driver {
|
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
async exec(sql: string, params: CellValue[]): Promise<DriverRow[]> {
|
|
136
|
+
if (WRITE_SQL.test(sql)) this.writes++;
|
|
132
137
|
const stmt = params.length ? this.session.prepare(sql).bind(...params) : this.session.prepare(sql);
|
|
133
138
|
const { results } = await stmt.all<DriverRow>();
|
|
134
139
|
return results ?? [];
|
|
135
140
|
}
|
|
136
141
|
|
|
142
|
+
/** How many write statements this session has auto-committed.
|
|
143
|
+
*
|
|
144
|
+
* There is no rollback here (see the ATOMICITY LIMIT above), so a mutation that throws
|
|
145
|
+
* midway leaves whatever it had already written. Counting the writes lets the caller say
|
|
146
|
+
* so out loud instead of surfacing a half-applied mutation as an ordinary 500 — the
|
|
147
|
+
* difference between "the request failed" and "the request failed and your data is now
|
|
148
|
+
* in a state no code path intended". */
|
|
149
|
+
writtenCount(): number {
|
|
150
|
+
return this.writes;
|
|
151
|
+
}
|
|
152
|
+
private writes = 0;
|
|
153
|
+
|
|
137
154
|
/** The session's latest bookmark (null before any query). Threaded back to the client
|
|
138
155
|
* via the `x-pramen-d1-bookmark` response header so a subsequent request can anchor a
|
|
139
156
|
* fresh session at it and read its own writes. */
|
package/src/worker-entry.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// (the CLI, tests, codegen) import from "@pramen/server" and never drag in the DO
|
|
5
5
|
// runtime. A Worker's entry imports createPramen from here.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// `RouteContext` stays exported (it predates nothing — @pramen/auth's OIDC routes are
|
|
8
|
+
// typed against it); `WorkerOpts` goes with the reverted basePath.
|
|
9
|
+
export { createPramen, type PramenApp, type PublicRoute, type RouteContext, type Env, type DoEnv } from "./pramen";
|
|
8
10
|
export { makeWorker, callPrivileged } from "./worker";
|
|
9
11
|
export { pramenDO, PramenDOBase } from "./durable-object";
|
package/src/worker.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { migrate } from "./runtime/migrate";
|
|
|
16
16
|
import { compileAcl } from "./runtime/acl";
|
|
17
17
|
import { Db } from "./runtime/db";
|
|
18
18
|
import { D1Driver, type D1SessionStart, type Driver } from "./runtime/driver";
|
|
19
|
-
import { toResponse } from "./runtime/errors";
|
|
19
|
+
import { BadRequest, Forbidden, toResponse } from "./runtime/errors";
|
|
20
20
|
import { Kv, isSessionDenied } from "./runtime/kv";
|
|
21
21
|
import { listDOs, partitionDoName } from "./runtime/registry";
|
|
22
22
|
import { createFiles, handleFileRequest, handleMediaRequest, R2Adapter } from "./runtime/storage";
|
|
@@ -100,6 +100,21 @@ export function useD1Store(opts: { storeHeader: string | null; isLive: boolean;
|
|
|
100
100
|
return opts.defaultStore === "d1";
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/** Should we warn that no Cron trigger seems to be wired?
|
|
104
|
+
*
|
|
105
|
+
* The DO store self-drains via an alarm; the D1 store has none, so a DELAYED task — a
|
|
106
|
+
* scheduled publish, a retry backoff — runs only when a Cron trigger calls
|
|
107
|
+
* `createPramen().scheduled`. Forgetting that is silent: the row simply never goes live,
|
|
108
|
+
* and nothing anywhere says why.
|
|
109
|
+
*
|
|
110
|
+
* A request-tail drain that leaves something due in the FUTURE is exactly the situation
|
|
111
|
+
* that depends on the cron, so it is the moment to say so. Once a cron actually fires, the
|
|
112
|
+
* question is settled and we never warn again. Exported for tests — the decision is pure. */
|
|
113
|
+
export function shouldWarnMissingCron(opts: { cronSeen: boolean; warned: boolean; nextRunAt: number | null; now: number }): boolean {
|
|
114
|
+
if (opts.cronSeen || opts.warned) return false;
|
|
115
|
+
return opts.nextRunAt != null && opts.nextRunAt > opts.now;
|
|
116
|
+
}
|
|
117
|
+
|
|
103
118
|
const json = (body: unknown, status = 200) => Response.json(body, { status });
|
|
104
119
|
const forbidden = (what: string) => json({ ok: false, error: `access denied: ${what}`, code: "forbidden" }, 403);
|
|
105
120
|
const badRequest = (msg: string) => json({ ok: false, error: msg, code: "bad_request" }, 400);
|
|
@@ -220,6 +235,61 @@ export function makeWorker(app: PramenApp) {
|
|
|
220
235
|
};
|
|
221
236
|
|
|
222
237
|
let d1Ready: Promise<void> | undefined;
|
|
238
|
+
/** Run one handler against the D1 store, in the Worker. The request path and the
|
|
239
|
+
* PRIVILEGED path (routes, which have no ctx.db) both come through here, so the two
|
|
240
|
+
* cannot drift on migration, bootstrap, the multi-tenant guard or the outbox drain.
|
|
241
|
+
*
|
|
242
|
+
* Returns the `{ ok, result }` envelope rather than a Response so each caller can add
|
|
243
|
+
* what only it needs — CORS and the session bookmark for a request, nothing for an
|
|
244
|
+
* internal call. */
|
|
245
|
+
const dispatchD1 = async (
|
|
246
|
+
env: Env,
|
|
247
|
+
ctx: ExecutionContext | undefined,
|
|
248
|
+
opts: { name: string; input: JsonValue; tenant: string; identity: Identity | null; start: D1SessionStart },
|
|
249
|
+
): Promise<{ driver: D1Driver; result: JsonValue }> => {
|
|
250
|
+
if (!env.DB) throw new BadRequest("D1 store is not configured");
|
|
251
|
+
// Same COMMINGLING GUARD as the request path: shared D1 has no tenant column, so a
|
|
252
|
+
// non-`main` tenant would mix rows unless the operator opted in explicitly.
|
|
253
|
+
if (opts.tenant !== "main" && env.PRAMEN_D1_ALLOW_MULTITENANT !== "true") {
|
|
254
|
+
throw new Forbidden(`D1 store for tenant '${opts.tenant}' (shared D1 has no tenant isolation — set PRAMEN_D1_ALLOW_MULTITENANT=true to allow)`);
|
|
255
|
+
}
|
|
256
|
+
const driver = new D1Driver(env.DB, { start: opts.start });
|
|
257
|
+
const files = createFiles({ tenant: opts.tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
|
|
258
|
+
await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
|
|
259
|
+
let dispatched;
|
|
260
|
+
try {
|
|
261
|
+
dispatched = await dispatch(
|
|
262
|
+
app.handlers,
|
|
263
|
+
app.schema,
|
|
264
|
+
driver,
|
|
265
|
+
new Kv(env.KV),
|
|
266
|
+
files,
|
|
267
|
+
envBag(env),
|
|
268
|
+
{ acl: d1Acl, identity: opts.identity, tenant: opts.tenant, store: "d1" },
|
|
269
|
+
opts.name,
|
|
270
|
+
opts.input,
|
|
271
|
+
);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
// D1 has no interactive transactions, so `transaction(fn)` runs `fn` as-is and a
|
|
274
|
+
// mutation that throws midway keeps whatever it already wrote. Say so: a partially
|
|
275
|
+
// applied mutation looks exactly like an ordinary 500 in the logs, and the difference
|
|
276
|
+
// — data left in a state no code path intended — is the whole point.
|
|
277
|
+
const written = driver.writtenCount();
|
|
278
|
+
if (written > 0) {
|
|
279
|
+
console.error(
|
|
280
|
+
`pramen: '${opts.name}' failed on the D1 store AFTER ${written} write statement(s) had committed. ` +
|
|
281
|
+
`D1 has no interactive transactions, so this mutation is PARTIALLY APPLIED and will not roll back. ` +
|
|
282
|
+
`Use the Durable Object store if this mutation must be atomic.`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
287
|
+
const { result, enqueued } = dispatched;
|
|
288
|
+
// Drain in the request tail so an enqueued task does not wait for the next Cron tick.
|
|
289
|
+
if (enqueued > 0 && ctx) ctx.waitUntil(drainD1(env));
|
|
290
|
+
return { driver, result: result as JsonValue };
|
|
291
|
+
};
|
|
292
|
+
|
|
223
293
|
const ensureD1Migrated = (driver: Driver, allowDestructive: boolean): Promise<void> => {
|
|
224
294
|
if (!d1Ready) {
|
|
225
295
|
d1Ready = migrate(driver, app.schema, { allowDestructive })
|
|
@@ -250,13 +320,30 @@ export function makeWorker(app: PramenApp) {
|
|
|
250
320
|
|
|
251
321
|
/** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
|
|
252
322
|
* /admin/tasks/drain route with `x-pramen-store: d1`, and by `scheduled()` (Cron). */
|
|
253
|
-
|
|
323
|
+
// Whether a Cron trigger has ever driven a drain in this isolate, and whether we have
|
|
324
|
+
// already said it looks missing. See `shouldWarnMissingCron`.
|
|
325
|
+
let cronSeen = false;
|
|
326
|
+
let warnedNoCron = false;
|
|
327
|
+
|
|
328
|
+
const drainD1 = async (env: Env, source: "request" | "cron" | "admin" = "request"): Promise<unknown> => {
|
|
254
329
|
if (!env.DB) throw new Error("D1 store is not configured");
|
|
330
|
+
if (source === "cron") cronSeen = true;
|
|
255
331
|
// The drain reads due tasks then writes their status — pin the primary so it sees
|
|
256
332
|
// and updates current outbox state (not a lagging replica).
|
|
257
333
|
const driver = new D1Driver(env.DB, { start: "first-primary" });
|
|
258
334
|
await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
|
|
259
|
-
|
|
335
|
+
const result = await drainOutbox(driver, bindTasks(app.tasks, d1TaskCtx(driver, env)), Date.now());
|
|
336
|
+
if (source === "request" && shouldWarnMissingCron({ cronSeen, warned: warnedNoCron, nextRunAt: result.nextRunAt, now: Date.now() })) {
|
|
337
|
+
warnedNoCron = true;
|
|
338
|
+
const inSeconds = Math.round(((result.nextRunAt ?? 0) - Date.now()) / 1000);
|
|
339
|
+
console.warn(
|
|
340
|
+
`pramen: a task is queued on the D1 store to run in ~${inSeconds}s, but no Cron trigger has drained this Worker. ` +
|
|
341
|
+
`The D1 store has no Durable Object alarm, so a DELAYED task (a scheduled publish, a retry) runs ONLY when a Cron ` +
|
|
342
|
+
`trigger calls createPramen().scheduled. Add \`triggers: { crons: ["* * * * *"] }\` to your Worker config, or drain ` +
|
|
343
|
+
`manually via POST /admin/tasks/drain. This warning appears once per isolate and stops once a Cron drain is seen.`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
return result;
|
|
260
347
|
};
|
|
261
348
|
|
|
262
349
|
const listD1Tasks = async (env: Env, status?: string, limit?: number): Promise<unknown> => {
|
|
@@ -290,7 +377,33 @@ export function makeWorker(app: PramenApp) {
|
|
|
290
377
|
// signature-authed webhook can live outside the JWT-gated /rpc surface.
|
|
291
378
|
for (const r of app.routes ?? []) {
|
|
292
379
|
if (request.method === r.method && url.pathname === r.path) {
|
|
293
|
-
|
|
380
|
+
// A public route has no ctx.db, so it reaches a handler through here. On the D1
|
|
381
|
+
// store there is no Durable Object to forward to — the engine runs in THIS Worker
|
|
382
|
+
// — so dispatch locally instead. Without this, everything built on a pre-auth
|
|
383
|
+
// route (a signed preview link, the sitemap) was DO-only, and the CMS had to
|
|
384
|
+
// refuse to mint preview links on D1 rather than hand out a dead one.
|
|
385
|
+
const routeCtx = {
|
|
386
|
+
callPrivileged: async (opts: Parameters<typeof callPrivileged>[1]): Promise<Response> => {
|
|
387
|
+
const store = useD1Store({ storeHeader: request.headers.get("x-pramen-store"), isLive: false, defaultStore: env.PRAMEN_STORE });
|
|
388
|
+
if (!store) return callPrivileged(env, opts);
|
|
389
|
+
try {
|
|
390
|
+
const { result } = await dispatchD1(env, ctx, {
|
|
391
|
+
name: opts.name,
|
|
392
|
+
input: opts.input ?? null,
|
|
393
|
+
tenant: opts.tenant ?? "main",
|
|
394
|
+
// The same synthetic identity the DO path sends in `x-pramen-identity`.
|
|
395
|
+
identity: { roles: opts.roles ?? ["admin"] },
|
|
396
|
+
// A privileged call may write (a redeemed one-time token), so pin the
|
|
397
|
+
// primary rather than risk a read-modify-write off a lagging replica.
|
|
398
|
+
start: "first-primary",
|
|
399
|
+
});
|
|
400
|
+
return json({ ok: true, result });
|
|
401
|
+
} catch (err) {
|
|
402
|
+
const { status, body } = toResponse(err);
|
|
403
|
+
return json(body, status);
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
};
|
|
294
407
|
return r.handler(request, envBag(env), routeCtx);
|
|
295
408
|
}
|
|
296
409
|
}
|
|
@@ -395,7 +508,7 @@ export function makeWorker(app: PramenApp) {
|
|
|
395
508
|
if (!isAdmin(identity)) return forbidden("tasks");
|
|
396
509
|
if (request.headers.get("x-pramen-store") === "d1") {
|
|
397
510
|
try {
|
|
398
|
-
return withCors(json({ ok: true, result: await drainD1(env) }), cors);
|
|
511
|
+
return withCors(json({ ok: true, result: await drainD1(env, "admin") }), cors);
|
|
399
512
|
} catch (err) {
|
|
400
513
|
const { status, body } = toResponse(err);
|
|
401
514
|
return withCors(json(body, status), cors);
|
|
@@ -498,20 +611,10 @@ export function makeWorker(app: PramenApp) {
|
|
|
498
611
|
else if (inboundBookmark) start = inboundBookmark;
|
|
499
612
|
else start = "first-unconstrained";
|
|
500
613
|
|
|
501
|
-
const driver = new D1Driver(env.DB, { start });
|
|
502
|
-
const files = createFiles({ tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
|
|
503
|
-
const bag = envBag(env);
|
|
504
614
|
try {
|
|
505
|
-
await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
|
|
506
615
|
// `tenant` matters here: a handler minting a tenant-scoped capability (a signed
|
|
507
616
|
// preview link) would otherwise stamp it "main" while reading acme's rows.
|
|
508
|
-
const {
|
|
509
|
-
// Kick an immediate drain in the request tail when this handler enqueued tasks
|
|
510
|
-
// (e.g. sendMagicLinkEmail). Without this, tasks wait for the next Cron trigger
|
|
511
|
-
// — up to a full minute. `waitUntil` lets the response return now while the
|
|
512
|
-
// drain runs; the Cron trigger remains the safety net for delayed / retried
|
|
513
|
-
// tasks that no request happens to coincide with.
|
|
514
|
-
if (enqueued > 0) ctx.waitUntil(drainD1(env));
|
|
617
|
+
const { driver, result } = await dispatchD1(env, ctx, { name, input, tenant, identity, start });
|
|
515
618
|
const res = json({ ok: true, result });
|
|
516
619
|
// Thread the session's latest bookmark back so the client can read its own writes.
|
|
517
620
|
const bookmark = driver.getBookmark();
|
|
@@ -573,7 +676,7 @@ export function makeWorker(app: PramenApp) {
|
|
|
573
676
|
// Cron Trigger entry: drains the D1 outbox (the DO path self-drains via an alarm,
|
|
574
677
|
// so it needs no cron). Wire a `[triggers] crons` in wrangler/oblaka to call this.
|
|
575
678
|
async scheduled(_event: unknown, env: Env): Promise<void> {
|
|
576
|
-
if (env.DB) await drainD1(env);
|
|
679
|
+
if (env.DB) await drainD1(env, "cron");
|
|
577
680
|
},
|
|
578
681
|
|
|
579
682
|
// Cloudflare Queues consumer entry: routes a batch to the matching `app.queues`
|