@pramen/server 0.0.49 → 0.0.51
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/cli.js +2 -21
- package/dist/dev.d.ts +1 -0
- package/dist/dev.js +9 -0
- package/dist/durable-object.js +3 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +6 -2
- package/dist/runtime/acl.d.ts +8 -0
- package/dist/runtime/dev-token.d.ts +4 -0
- package/dist/runtime/dev-token.js +34 -0
- package/dist/runtime/dispatch.js +2 -0
- package/dist/runtime/errors.d.ts +6 -0
- package/dist/runtime/errors.js +8 -0
- package/dist/runtime/storage.d.ts +4 -4
- package/dist/runtime/storage.js +8 -63
- package/dist/runtime/token.d.ts +22 -0
- package/dist/runtime/token.js +88 -0
- package/dist/sdk/handlers.d.ts +7 -0
- package/dist/worker.js +6 -2
- package/package.json +13 -3
- package/src/cli.ts +3 -29
- package/src/dev.ts +10 -0
- package/src/durable-object.ts +3 -1
- package/src/index.ts +8 -2
- package/src/runtime/acl.ts +8 -0
- package/src/runtime/dev-token.ts +36 -0
- package/src/runtime/dispatch.ts +2 -0
- package/src/runtime/errors.ts +9 -0
- package/src/runtime/storage.ts +14 -62
- package/src/runtime/token.ts +94 -0
- package/src/sdk/handlers.ts +7 -0
- package/src/worker.ts +6 -2
package/dist/cli.js
CHANGED
|
@@ -22,27 +22,8 @@ import { createTableSql } from "./runtime/ddl";
|
|
|
22
22
|
import { schemaHash } from "./runtime/migrate";
|
|
23
23
|
import { diffSchemaFingerprint, schemaFingerprint } from "./runtime/schema-diff";
|
|
24
24
|
import { entitiesInPartition, partitionsOf } from "./sdk/schema";
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
* with AUTH_SECRET when set, else the dev secret from the scaffolded oblaka.ts. */
|
|
28
|
-
const DEV_SECRET = "dev-secret-change-me";
|
|
29
|
-
function bytesToB64url(bytes) {
|
|
30
|
-
let bin = "";
|
|
31
|
-
for (const b of bytes)
|
|
32
|
-
bin += String.fromCharCode(b);
|
|
33
|
-
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
34
|
-
}
|
|
35
|
-
const strToB64url = (s) => bytesToB64url(new TextEncoder().encode(s));
|
|
36
|
-
async function sign(payload) {
|
|
37
|
-
const secret = process.env.AUTH_SECRET || DEV_SECRET;
|
|
38
|
-
const now = Math.floor(Date.now() / 1000);
|
|
39
|
-
const header = strToB64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
40
|
-
const body = strToB64url(JSON.stringify({ iat: now, exp: now + 3600, ...payload }));
|
|
41
|
-
const data = `${header}.${body}`;
|
|
42
|
-
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
43
|
-
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
|
|
44
|
-
return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
|
|
45
|
-
}
|
|
25
|
+
import { signDevToken } from "./runtime/dev-token";
|
|
26
|
+
const sign = (payload) => signDevToken(payload);
|
|
46
27
|
/** The sub-schema of a single partition (used to mirror the DO's per-partition hash,
|
|
47
28
|
* which migrate() computes over exactly this subset). For a single-partition app the
|
|
48
29
|
* subset equals the whole schema, so the hash is identical to the unpartitioned case. */
|
package/dist/dev.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { signDevToken, DEV_SECRET } from "./runtime/dev-token";
|
package/dist/dev.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// `@pramen/server/dev` — tooling helpers, deliberately NOT on the authoring entry point.
|
|
2
|
+
//
|
|
3
|
+
// `signDevToken` mints an HS256 JWT for local development: the `pramen` and `pramen-cms`
|
|
4
|
+
// bins and the repo's test helper all use it. It is kept off `@pramen/server` because its
|
|
5
|
+
// secret lookup reads `process.env.AUTH_SECRET`, which does not exist in a deployed
|
|
6
|
+
// Worker (bindings and secrets arrive on `env`) — so an app handler importing it from the
|
|
7
|
+
// main entry would silently sign with the PUBLISHED dev constant while appearing to honour
|
|
8
|
+
// AUTH_SECRET. A separate subpath makes "this is dev tooling" part of the import.
|
|
9
|
+
export { signDevToken, DEV_SECRET } from "./runtime/dev-token";
|
package/dist/durable-object.js
CHANGED
|
@@ -203,6 +203,8 @@ export class PramenDOBase extends DurableObject {
|
|
|
203
203
|
files: this.filesFor(this.tenant),
|
|
204
204
|
env: this.envBag,
|
|
205
205
|
identity,
|
|
206
|
+
tenant: this.tenant,
|
|
207
|
+
store: "do",
|
|
206
208
|
tasks: tasksFacade(this.driver),
|
|
207
209
|
mail: createMail(this.envBag, this.kv),
|
|
208
210
|
queue: createQueue(this.envBag),
|
|
@@ -533,7 +535,7 @@ export class PramenDOBase extends DurableObject {
|
|
|
533
535
|
// Carry the schema so any consumer of this context (not just Db) can compile
|
|
534
536
|
// relation-aware `where` rules into subqueries, and the active partition so Db's
|
|
535
537
|
// table-access guard rejects any table outside this DO's partition.
|
|
536
|
-
return { acl: this.acl, identity, schema: this.app.schema, partition };
|
|
538
|
+
return { acl: this.acl, identity, schema: this.app.schema, partition, tenant: this.tenant };
|
|
537
539
|
}
|
|
538
540
|
// The DO env (bindings + vars + secrets) handed to handlers as ctx.env. Loosely
|
|
539
541
|
// typed at the boundary so handlers can read any var/secret without a DoEnv cast.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, trigger } from "./sdk/schema";
|
|
1
|
+
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, trigger, partitionOf, DEFAULT_PARTITION } from "./sdk/schema";
|
|
2
2
|
export type { TriggerDef, TriggerOp } from "./sdk/schema";
|
|
3
3
|
export { isValidUuid } from "./sdk/uuid";
|
|
4
4
|
export type { DefaultValue, FieldType, FieldDef, EntityFields, EntityDef, SchemaDef, RelationDef, RelationDefs, BelongsToDef, HasManyDef, ManyToManyDef, OneHasOneDef, OneHasOneInverseDef, OnDelete, } from "./sdk/schema";
|
|
@@ -11,6 +11,8 @@ export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, Jso
|
|
|
11
11
|
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
|
+
export { signToken, verifyToken, isUsableSecret, resolveSecret, MIN_TOKEN_SECRET_LEN } from "./runtime/token";
|
|
15
|
+
export type { ExpiringToken } from "./runtime/token";
|
|
14
16
|
export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
|
|
15
17
|
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
16
18
|
export type { MailMessage, MailAddress, MailAdapter, SendEmailBinding } from "./runtime/mail";
|
|
@@ -18,6 +20,6 @@ export { Queue, CloudflareQueueAdapter, MemoryQueueAdapter, createQueue, discove
|
|
|
18
20
|
export type { QueueAdapter, QueueProducerBinding, QueueSendOptions, QueueSendRequest, QueueBatchOptions, QueueContentType } from "./runtime/queue";
|
|
19
21
|
export { routeQueue, dispatchQueueBatch } from "./runtime/queue-consumer";
|
|
20
22
|
export type { QueueContext, QueueHandler, QueueMessage, QueueBatch, AppQueueMap } from "./runtime/queue-consumer";
|
|
21
|
-
export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
|
|
23
|
+
export { PramenError, BadRequest, Unauthorized, Forbidden, Conflict } from "./runtime/errors";
|
|
22
24
|
export { sqliteDialect, postgresDialect, DoSqliteDriver, D1Driver } from "./runtime/driver";
|
|
23
25
|
export type { Driver, Dialect, DriverRow } from "./runtime/driver";
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// which only exists in the Workers runtime; keeping it separate lets the CLI, tests,
|
|
8
8
|
// and codegen load an app.ts for its schema without dragging in the DO runtime.
|
|
9
9
|
// --- schema authoring ---
|
|
10
|
-
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, trigger } from "./sdk/schema";
|
|
10
|
+
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, trigger, partitionOf, DEFAULT_PARTITION } from "./sdk/schema";
|
|
11
11
|
export { isValidUuid } from "./sdk/uuid";
|
|
12
12
|
// --- app + handlers ---
|
|
13
13
|
export { createApp } from "./sdk/app";
|
|
@@ -17,12 +17,16 @@ export { $identity, $input, $now, allow, deny, policy, resolve, role, isAllow, i
|
|
|
17
17
|
// --- kv (ctx.kv) + session denylist (hard token revocation) ---
|
|
18
18
|
export { Kv, denySession, allowSession, isSessionDenied } from "./runtime/kv";
|
|
19
19
|
export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
|
|
20
|
+
// Signed capability tokens (HMAC-SHA256) — the machinery behind signed file urls and
|
|
21
|
+
// page-preview links. Exported so an app (or @pramen/cms) can mint its own capability url
|
|
22
|
+
// without a second signing implementation.
|
|
23
|
+
export { signToken, verifyToken, isUsableSecret, resolveSecret, MIN_TOKEN_SECRET_LEN } from "./runtime/token";
|
|
20
24
|
// --- mail (ctx.mail) ---
|
|
21
25
|
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
22
26
|
// --- queue (ctx.queue — Cloudflare Queues) ---
|
|
23
27
|
export { Queue, CloudflareQueueAdapter, MemoryQueueAdapter, createQueue, discoverQueueBindings } from "./runtime/queue";
|
|
24
28
|
export { routeQueue, dispatchQueueBatch } from "./runtime/queue-consumer";
|
|
25
29
|
// --- errors ---
|
|
26
|
-
export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
|
|
30
|
+
export { PramenError, BadRequest, Unauthorized, Forbidden, Conflict } from "./runtime/errors";
|
|
27
31
|
// --- substrate seam (advanced: bring your own SQL backend) ---
|
|
28
32
|
export { sqliteDialect, postgresDialect, DoSqliteDriver, D1Driver } from "./runtime/driver";
|
package/dist/runtime/acl.d.ts
CHANGED
|
@@ -27,6 +27,14 @@ export interface AclContext {
|
|
|
27
27
|
/** The app schema — lets `where` rules traverse relations (`{ rel: { col } }`),
|
|
28
28
|
* compiled to a subquery with the related entity's read scope AND-merged in. */
|
|
29
29
|
readonly schema?: SchemaDef;
|
|
30
|
+
/** The tenant this request is for — the `x-pramen-tenant` value the DO was addressed
|
|
31
|
+
* with. Carried so a handler can mint a tenant-scoped capability (e.g. a signed page
|
|
32
|
+
* preview link) without the caller supplying, and thus being able to forge, a tenant. */
|
|
33
|
+
readonly tenant?: string;
|
|
34
|
+
/** Which substrate is serving this request — `"do"` (a Durable Object, the default) or
|
|
35
|
+
* `"d1"` (the shared D1 database, selected per-request with `x-pramen-store: d1`). A
|
|
36
|
+
* handler needs this when a capability it mints can only be redeemed on one of them. */
|
|
37
|
+
readonly store?: "do" | "d1";
|
|
30
38
|
/** The partition this DO serves. When set, Db rejects any access to a table that
|
|
31
39
|
* lives in a different partition (a partition-DO only owns its own tables). Unset
|
|
32
40
|
* (e.g. the D1/Worker shared-store path) disables the guard — a no-op. */
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** The scaffolded oblaka.ts dev secret. Used only when AUTH_SECRET is unset. */
|
|
2
|
+
export declare const DEV_SECRET = "dev-secret-change-me";
|
|
3
|
+
/** Sign a dev JWT (1h). Secret: the `secret` argument, else `AUTH_SECRET`, else DEV_SECRET. */
|
|
4
|
+
export declare function signDevToken(payload: Record<string, unknown>, secret?: string): Promise<string>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Mint an HS256 JWT for local development and tooling — what a real auth service would
|
|
2
|
+
// issue, without running one.
|
|
3
|
+
//
|
|
4
|
+
// Extracted because it was already written twice (the `pramen` CLI and the repo's test
|
|
5
|
+
// helper) and `@pramen/cms`'s own bin would have been a third. One implementation, in the
|
|
6
|
+
// package that owns tokens.
|
|
7
|
+
//
|
|
8
|
+
// NOT an auth system: the dev fallback secret is public, so a token signed with it is
|
|
9
|
+
// worthless anywhere `AUTH_SECRET` is set to something real. That is the point — it makes
|
|
10
|
+
// a forgotten secret fail loudly rather than quietly accept dev tokens in production.
|
|
11
|
+
/** The scaffolded oblaka.ts dev secret. Used only when AUTH_SECRET is unset. */
|
|
12
|
+
export const DEV_SECRET = "dev-secret-change-me";
|
|
13
|
+
function bytesToB64url(bytes) {
|
|
14
|
+
let bin = "";
|
|
15
|
+
for (const b of bytes)
|
|
16
|
+
bin += String.fromCharCode(b);
|
|
17
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
18
|
+
}
|
|
19
|
+
const strToB64url = (s) => bytesToB64url(new TextEncoder().encode(s));
|
|
20
|
+
/** Sign a dev JWT (1h). Secret: the `secret` argument, else `AUTH_SECRET`, else DEV_SECRET. */
|
|
21
|
+
export async function signDevToken(payload, secret) {
|
|
22
|
+
// `||`, not `??`: an AUTH_SECRET set to the empty string previously fell through to
|
|
23
|
+
// DEV_SECRET, and signing with an empty HMAC key instead would produce tokens the server
|
|
24
|
+
// rejects with no useful message.
|
|
25
|
+
const env = globalThis.process?.env;
|
|
26
|
+
const key = secret || env?.AUTH_SECRET || DEV_SECRET;
|
|
27
|
+
const now = Math.floor(Date.now() / 1000);
|
|
28
|
+
const header = strToB64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
29
|
+
const body = strToB64url(JSON.stringify({ iat: now, exp: now + 3600, ...payload }));
|
|
30
|
+
const data = `${header}.${body}`;
|
|
31
|
+
const cryptoKey = await crypto.subtle.importKey("raw", new TextEncoder().encode(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
32
|
+
const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(data));
|
|
33
|
+
return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
|
|
34
|
+
}
|
package/dist/runtime/dispatch.js
CHANGED
|
@@ -62,6 +62,8 @@ export async function dispatch(handlers, schema, driver, kv, files, env, acl, na
|
|
|
62
62
|
files,
|
|
63
63
|
env,
|
|
64
64
|
identity: acl.identity,
|
|
65
|
+
tenant: acl.tenant ?? "main",
|
|
66
|
+
store: acl.store ?? "do",
|
|
65
67
|
tasks: tasksFacade(driver, () => enqueued++),
|
|
66
68
|
mail: createMail(env, kv),
|
|
67
69
|
queue: createQueue(env),
|
package/dist/runtime/errors.d.ts
CHANGED
|
@@ -15,6 +15,12 @@ export declare class Unauthorized extends PramenError {
|
|
|
15
15
|
export declare class Forbidden extends PramenError {
|
|
16
16
|
constructor(message?: string);
|
|
17
17
|
}
|
|
18
|
+
/** 409 — the request conflicts with the current state of the resource. For
|
|
19
|
+
* optimistic concurrency (a stale `expectedVersion`) and for uniqueness clashes the
|
|
20
|
+
* caller could resolve by retrying with different input. */
|
|
21
|
+
export declare class Conflict extends PramenError {
|
|
22
|
+
constructor(message?: string);
|
|
23
|
+
}
|
|
18
24
|
export interface ErrorBody {
|
|
19
25
|
ok: false;
|
|
20
26
|
error: string;
|
package/dist/runtime/errors.js
CHANGED
|
@@ -29,6 +29,14 @@ export class Forbidden extends PramenError {
|
|
|
29
29
|
super(message, 403, "forbidden");
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
/** 409 — the request conflicts with the current state of the resource. For
|
|
33
|
+
* optimistic concurrency (a stale `expectedVersion`) and for uniqueness clashes the
|
|
34
|
+
* caller could resolve by retrying with different input. */
|
|
35
|
+
export class Conflict extends PramenError {
|
|
36
|
+
constructor(message = "conflict") {
|
|
37
|
+
super(message, 409, "conflict");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
32
40
|
function classify(err) {
|
|
33
41
|
if (err instanceof PramenError) {
|
|
34
42
|
return { status: err.status, body: { ok: false, error: err.message, code: err.code } };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Files, HeadResult } from "../sdk/files";
|
|
2
|
+
import { isUsableSecret, type ExpiringToken } from "./token";
|
|
2
3
|
export type { Files, FileRef, HeadResult, SignDownloadOpts, SignUploadOpts } from "../sdk/files";
|
|
3
4
|
export interface PutResult {
|
|
4
5
|
key: string;
|
|
@@ -40,17 +41,16 @@ export declare class MemoryAdapter implements StorageAdapter {
|
|
|
40
41
|
head(key: string): Promise<HeadResult | null>;
|
|
41
42
|
delete(key: string): Promise<void>;
|
|
42
43
|
}
|
|
43
|
-
interface FileToken {
|
|
44
|
+
interface FileToken extends ExpiringToken {
|
|
44
45
|
/** tenant */ t: string;
|
|
45
46
|
/** key */ k: string;
|
|
46
47
|
/** op */ op: "get" | "put";
|
|
47
|
-
/** expiry (epoch seconds) */ exp: number;
|
|
48
48
|
/** content-type (put: enforced; get: disposition hint) */ ct?: string;
|
|
49
49
|
/** max size in bytes (put only) */ max?: number;
|
|
50
50
|
/** filename (download disposition) */ fn?: string;
|
|
51
51
|
}
|
|
52
52
|
/** Verify a file token's signature + expiry; returns the payload or null. */
|
|
53
|
-
export declare
|
|
53
|
+
export declare const verifyFileToken: (raw: string, secret: string) => Promise<FileToken | null>;
|
|
54
54
|
export interface FilesConfig {
|
|
55
55
|
tenant: string;
|
|
56
56
|
secret: string;
|
|
@@ -63,7 +63,7 @@ export interface FilesConfig {
|
|
|
63
63
|
* treated as unconfigured — fail closed rather than mint forgeable urls. The dev
|
|
64
64
|
* defaults satisfy it; production should set a strong, random FILES_SECRET. */
|
|
65
65
|
export declare const MIN_FILES_SECRET_LEN = 16;
|
|
66
|
-
export declare
|
|
66
|
+
export declare const isUsableFilesSecret: typeof isUsableSecret;
|
|
67
67
|
/** Construct the per-tenant `ctx.files` facade. */
|
|
68
68
|
export declare function createFiles(cfg: FilesConfig): Files;
|
|
69
69
|
/** Serve the file endpoints. Returns a Response for any `/files/*` path, or null
|
package/dist/runtime/storage.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// R2 binding and stays backend-agnostic. URLs are RELATIVE so the server never
|
|
16
16
|
// needs to know its own public origin — the client resolves them against its base.
|
|
17
17
|
import { BadRequest, PramenError } from "./errors";
|
|
18
|
+
import { signToken, verifyToken, isUsableSecret, MIN_TOKEN_SECRET_LEN } from "./token";
|
|
18
19
|
/** R2 — the Cloudflare default. Wraps an R2 bucket binding. Streaming: bytes flow
|
|
19
20
|
* directly between the client and R2 in the Worker, never through the DO. */
|
|
20
21
|
export class R2Adapter {
|
|
@@ -84,63 +85,9 @@ function bytesToStream(bytes) {
|
|
|
84
85
|
},
|
|
85
86
|
});
|
|
86
87
|
}
|
|
87
|
-
|
|
88
|
-
function bytesToB64url(bytes) {
|
|
89
|
-
let bin = "";
|
|
90
|
-
for (const b of bytes)
|
|
91
|
-
bin += String.fromCharCode(b);
|
|
92
|
-
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
93
|
-
}
|
|
94
|
-
function b64urlToBytes(s) {
|
|
95
|
-
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
|
|
96
|
-
const bin = atob(b64);
|
|
97
|
-
const out = new Uint8Array(bin.length);
|
|
98
|
-
for (let i = 0; i < bin.length; i++)
|
|
99
|
-
out[i] = bin.charCodeAt(i);
|
|
100
|
-
return out;
|
|
101
|
-
}
|
|
102
|
-
const strToB64url = (s) => bytesToB64url(new TextEncoder().encode(s));
|
|
103
|
-
const b64urlToStr = (s) => new TextDecoder().decode(b64urlToBytes(s));
|
|
104
|
-
async function hmacKey(secret) {
|
|
105
|
-
return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
106
|
-
"sign",
|
|
107
|
-
"verify",
|
|
108
|
-
]);
|
|
109
|
-
}
|
|
110
|
-
async function signToken(token, secret) {
|
|
111
|
-
const data = strToB64url(JSON.stringify(token));
|
|
112
|
-
const key = await hmacKey(secret);
|
|
113
|
-
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
|
|
114
|
-
return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
|
|
115
|
-
}
|
|
88
|
+
const signFileToken = (token, secret) => signToken(token, secret);
|
|
116
89
|
/** Verify a file token's signature + expiry; returns the payload or null. */
|
|
117
|
-
export
|
|
118
|
-
const dot = raw.indexOf(".");
|
|
119
|
-
if (dot < 0)
|
|
120
|
-
return null;
|
|
121
|
-
const data = raw.slice(0, dot);
|
|
122
|
-
const sig = raw.slice(dot + 1);
|
|
123
|
-
let ok;
|
|
124
|
-
try {
|
|
125
|
-
const key = await hmacKey(secret);
|
|
126
|
-
ok = await crypto.subtle.verify("HMAC", key, b64urlToBytes(sig), new TextEncoder().encode(data));
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
if (!ok)
|
|
132
|
-
return null;
|
|
133
|
-
let payload;
|
|
134
|
-
try {
|
|
135
|
-
payload = JSON.parse(b64urlToStr(data));
|
|
136
|
-
}
|
|
137
|
-
catch {
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
if (typeof payload.exp !== "number" || Math.floor(Date.now() / 1000) >= payload.exp)
|
|
141
|
-
return null;
|
|
142
|
-
return payload;
|
|
143
|
-
}
|
|
90
|
+
export const verifyFileToken = (raw, secret) => verifyToken(raw, secret);
|
|
144
91
|
// --- key generation ---
|
|
145
92
|
function randomKeySuffix() {
|
|
146
93
|
const bytes = new Uint8Array(16);
|
|
@@ -188,10 +135,8 @@ function contentDisposition(name) {
|
|
|
188
135
|
* would be forgeable (HMAC over an empty/weak key). Below this, file storage is
|
|
189
136
|
* treated as unconfigured — fail closed rather than mint forgeable urls. The dev
|
|
190
137
|
* defaults satisfy it; production should set a strong, random FILES_SECRET. */
|
|
191
|
-
export const MIN_FILES_SECRET_LEN =
|
|
192
|
-
export
|
|
193
|
-
return typeof secret === "string" && secret.length >= MIN_FILES_SECRET_LEN;
|
|
194
|
-
}
|
|
138
|
+
export const MIN_FILES_SECRET_LEN = MIN_TOKEN_SECRET_LEN;
|
|
139
|
+
export const isUsableFilesSecret = isUsableSecret;
|
|
195
140
|
const filesUnconfigured = () => new PramenError("file storage is not configured (set a strong FILES_SECRET)", 503, "unavailable");
|
|
196
141
|
/** Construct the per-tenant `ctx.files` facade. */
|
|
197
142
|
export function createFiles(cfg) {
|
|
@@ -210,7 +155,7 @@ export function createFiles(cfg) {
|
|
|
210
155
|
const seg = opts.prefix ? `${safePrefix(opts.prefix)}/` : "";
|
|
211
156
|
const key = `${prefix}${seg}${randomKeySuffix()}`;
|
|
212
157
|
const exp = Math.floor(Date.now() / 1000) + (opts.expiresIn ?? 900);
|
|
213
|
-
const token = await
|
|
158
|
+
const token = await signFileToken({ t: cfg.tenant, k: key, op: "put", exp, ct, max: opts.maxSize, fn: opts.filename }, cfg.secret);
|
|
214
159
|
const ref = { key, size: 0, contentType: ct, filename: opts.filename, uploadedAt: Date.now() };
|
|
215
160
|
return { url: `${base}/upload?token=${encodeURIComponent(token)}`, ref };
|
|
216
161
|
},
|
|
@@ -221,7 +166,7 @@ export function createFiles(cfg) {
|
|
|
221
166
|
const key = ensureOwnKey(r.key);
|
|
222
167
|
const exp = Math.floor(Date.now() / 1000) + (opts?.expiresIn ?? 3600);
|
|
223
168
|
const fn = opts?.download ? (typeof ref === "string" ? undefined : ref.filename) : undefined;
|
|
224
|
-
const token = await
|
|
169
|
+
const token = await signFileToken({ t: cfg.tenant, k: key, op: "get", exp, fn }, cfg.secret);
|
|
225
170
|
return { url: `${base}/download?token=${encodeURIComponent(token)}`, expiresAt: exp * 1000 };
|
|
226
171
|
},
|
|
227
172
|
head: (key) => cfg.adapter.head(ensureOwnKey(key)),
|
|
@@ -258,7 +203,7 @@ export async function handleFileRequest(request, opts) {
|
|
|
258
203
|
const raw = url.searchParams.get("token");
|
|
259
204
|
if (!raw)
|
|
260
205
|
return fileError(401, "unauthorized", "missing token");
|
|
261
|
-
const token = await
|
|
206
|
+
const token = await verifyFileToken(raw, opts.secret);
|
|
262
207
|
if (!token)
|
|
263
208
|
return fileError(403, "forbidden", "invalid or expired token");
|
|
264
209
|
try {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Every signed token carries an expiry (epoch seconds); `verifyToken` enforces it. */
|
|
2
|
+
export interface ExpiringToken {
|
|
3
|
+
exp: number;
|
|
4
|
+
}
|
|
5
|
+
export declare function bytesToB64url(bytes: Uint8Array): string;
|
|
6
|
+
export declare function b64urlToBytes(s: string): Uint8Array;
|
|
7
|
+
export declare const strToB64url: (s: string) => string;
|
|
8
|
+
export declare const b64urlToStr: (s: string) => string;
|
|
9
|
+
/** Sign a payload into a capability token. */
|
|
10
|
+
export declare function signToken<T extends ExpiringToken>(payload: T, secret: string): Promise<string>;
|
|
11
|
+
/** Verify a token's signature and expiry; returns the payload, or `null` for anything
|
|
12
|
+
* malformed, forged, or expired. The caller decides what the payload authorizes — this
|
|
13
|
+
* only attests that we minted it and that it is still in date. */
|
|
14
|
+
export declare function verifyToken<T extends ExpiringToken>(raw: string, secret: string): Promise<T | null>;
|
|
15
|
+
/** A signing secret must be present and non-trivial, else tokens are forgeable (an HMAC
|
|
16
|
+
* over an empty or weak key). Below this length the feature is treated as UNCONFIGURED and
|
|
17
|
+
* fails closed, rather than minting urls that anyone can forge. */
|
|
18
|
+
export declare const MIN_TOKEN_SECRET_LEN = 16;
|
|
19
|
+
export declare function isUsableSecret(secret: unknown): secret is string;
|
|
20
|
+
/** Resolve a signing secret from env by preference order, skipping any that is absent or
|
|
21
|
+
* too weak. Returns `undefined` when nothing usable is configured — callers fail closed. */
|
|
22
|
+
export declare function resolveSecret(env: Readonly<Record<string, unknown>>, names: readonly string[]): string | undefined;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Signed, self-expiring capability tokens (HMAC-SHA256 over a JSON payload).
|
|
2
|
+
//
|
|
3
|
+
// pramen already owns the edge and a secret, so a capability url needs no session and no
|
|
4
|
+
// store: the payload IS the grant, and the signature is what makes it unforgeable. Signed
|
|
5
|
+
// file urls were the first user (`runtime/storage.ts`); page preview links are the second
|
|
6
|
+
// (`@pramen/cms`). Both mint in a handler and redeem in the Worker, unauthenticated.
|
|
7
|
+
//
|
|
8
|
+
// Pure WebCrypto — synchronous in the sense that matters (no stream I/O), so it is safe to
|
|
9
|
+
// call from inside the DO's storage.transaction().
|
|
10
|
+
//
|
|
11
|
+
// A token is `<b64url(json)>.<b64url(sig)>`. It is deliberately NOT a JWT: no alg field to
|
|
12
|
+
// confuse, no header to downgrade, one algorithm, verified before the payload is parsed.
|
|
13
|
+
export function bytesToB64url(bytes) {
|
|
14
|
+
let bin = "";
|
|
15
|
+
for (const b of bytes)
|
|
16
|
+
bin += String.fromCharCode(b);
|
|
17
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
18
|
+
}
|
|
19
|
+
export function b64urlToBytes(s) {
|
|
20
|
+
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
|
|
21
|
+
const bin = atob(b64);
|
|
22
|
+
const out = new Uint8Array(bin.length);
|
|
23
|
+
for (let i = 0; i < bin.length; i++)
|
|
24
|
+
out[i] = bin.charCodeAt(i);
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
export const strToB64url = (s) => bytesToB64url(new TextEncoder().encode(s));
|
|
28
|
+
export const b64urlToStr = (s) => new TextDecoder().decode(b64urlToBytes(s));
|
|
29
|
+
async function hmacKey(secret) {
|
|
30
|
+
return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
31
|
+
"sign",
|
|
32
|
+
"verify",
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
35
|
+
/** Sign a payload into a capability token. */
|
|
36
|
+
export async function signToken(payload, secret) {
|
|
37
|
+
const data = strToB64url(JSON.stringify(payload));
|
|
38
|
+
const key = await hmacKey(secret);
|
|
39
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
|
|
40
|
+
return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
|
|
41
|
+
}
|
|
42
|
+
/** Verify a token's signature and expiry; returns the payload, or `null` for anything
|
|
43
|
+
* malformed, forged, or expired. The caller decides what the payload authorizes — this
|
|
44
|
+
* only attests that we minted it and that it is still in date. */
|
|
45
|
+
export async function verifyToken(raw, secret) {
|
|
46
|
+
const dot = raw.indexOf(".");
|
|
47
|
+
if (dot < 0)
|
|
48
|
+
return null;
|
|
49
|
+
const data = raw.slice(0, dot);
|
|
50
|
+
const sig = raw.slice(dot + 1);
|
|
51
|
+
let ok;
|
|
52
|
+
try {
|
|
53
|
+
const key = await hmacKey(secret);
|
|
54
|
+
ok = await crypto.subtle.verify("HMAC", key, b64urlToBytes(sig), new TextEncoder().encode(data));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (!ok)
|
|
60
|
+
return null;
|
|
61
|
+
let payload;
|
|
62
|
+
try {
|
|
63
|
+
payload = JSON.parse(b64urlToStr(data));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (typeof payload.exp !== "number" || Math.floor(Date.now() / 1000) >= payload.exp)
|
|
69
|
+
return null;
|
|
70
|
+
return payload;
|
|
71
|
+
}
|
|
72
|
+
/** A signing secret must be present and non-trivial, else tokens are forgeable (an HMAC
|
|
73
|
+
* over an empty or weak key). Below this length the feature is treated as UNCONFIGURED and
|
|
74
|
+
* fails closed, rather than minting urls that anyone can forge. */
|
|
75
|
+
export const MIN_TOKEN_SECRET_LEN = 16;
|
|
76
|
+
export function isUsableSecret(secret) {
|
|
77
|
+
return typeof secret === "string" && secret.length >= MIN_TOKEN_SECRET_LEN;
|
|
78
|
+
}
|
|
79
|
+
/** Resolve a signing secret from env by preference order, skipping any that is absent or
|
|
80
|
+
* too weak. Returns `undefined` when nothing usable is configured — callers fail closed. */
|
|
81
|
+
export function resolveSecret(env, names) {
|
|
82
|
+
for (const name of names) {
|
|
83
|
+
const v = env[name];
|
|
84
|
+
if (isUsableSecret(v))
|
|
85
|
+
return v;
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
package/dist/sdk/handlers.d.ts
CHANGED
|
@@ -34,6 +34,13 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
|
|
|
34
34
|
readonly env: EnvBag;
|
|
35
35
|
/** Resolved identity for this request (null = anonymous). */
|
|
36
36
|
readonly identity: Identity | null;
|
|
37
|
+
/** The tenant this request is for (the `x-pramen-tenant` value; `"main"` by default).
|
|
38
|
+
* Server-resolved, never caller-supplied — safe to embed in a signed capability. */
|
|
39
|
+
readonly tenant: string;
|
|
40
|
+
/** Which substrate is serving this request: `"do"` (a Durable Object) or `"d1"`. Only
|
|
41
|
+
* the DO path has a stub the Worker can call back into, so a handler minting a
|
|
42
|
+
* capability redeemed through `callPrivileged` must refuse on `"d1"`. */
|
|
43
|
+
readonly store: "do" | "d1";
|
|
37
44
|
/** Deferred side-effects (a transactional outbox). `tasks.enqueue` persists a task
|
|
38
45
|
* row in the SAME transaction as a mutation (atomic with the data write); a drainer
|
|
39
46
|
* runs the matching `app.tasks` handler after commit, off the write path, with
|
package/dist/worker.js
CHANGED
|
@@ -177,7 +177,9 @@ export function makeWorker(app) {
|
|
|
177
177
|
const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
|
|
178
178
|
const kv = new Kv(env.KV);
|
|
179
179
|
const bag = envBag(env);
|
|
180
|
-
|
|
180
|
+
// The D1 store is not per-tenant addressed (one shared database, no DO), so the
|
|
181
|
+
// task context runs as the default tenant — matching the `files` scope just above.
|
|
182
|
+
return { db, kv, files, env: bag, identity, tenant: "main", store: "d1", tasks: tasksFacade(driver), mail: createMail(bag, kv), queue: createQueue(bag) };
|
|
181
183
|
};
|
|
182
184
|
/** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
|
|
183
185
|
* /admin/tasks/drain route with `x-pramen-store: d1`, and by `scheduled()` (Cron). */
|
|
@@ -423,7 +425,9 @@ export function makeWorker(app) {
|
|
|
423
425
|
const bag = envBag(env);
|
|
424
426
|
try {
|
|
425
427
|
await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
|
|
426
|
-
|
|
428
|
+
// `tenant` matters here: a handler minting a tenant-scoped capability (a signed
|
|
429
|
+
// preview link) would otherwise stamp it "main" while reading acme's rows.
|
|
430
|
+
const { result, enqueued } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, bag, { acl: d1Acl, identity, tenant, store: "d1" }, name, input);
|
|
427
431
|
// Kick an immediate drain in the request tail when this handler enqueued tasks
|
|
428
432
|
// (e.g. sendMagicLinkEmail). Without this, tasks wait for the next Cron trigger
|
|
429
433
|
// — up to a full minute. `waitUntil` lets the response return now while the
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/server",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "pramen server runtime
|
|
3
|
+
"version": "0.0.51",
|
|
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": {
|
|
7
7
|
"type": "git",
|
|
@@ -26,6 +26,13 @@
|
|
|
26
26
|
"workerd": "./src/worker-entry.ts",
|
|
27
27
|
"types": "./dist/worker-entry.d.ts",
|
|
28
28
|
"default": "./dist/worker-entry.js"
|
|
29
|
+
},
|
|
30
|
+
"./dev": {
|
|
31
|
+
"development": "./src/dev.ts",
|
|
32
|
+
"bun": "./src/dev.ts",
|
|
33
|
+
"workerd": "./src/dev.ts",
|
|
34
|
+
"types": "./dist/dev.d.ts",
|
|
35
|
+
"default": "./dist/dev.js"
|
|
29
36
|
}
|
|
30
37
|
},
|
|
31
38
|
"main": "./dist/index.js",
|
|
@@ -33,7 +40,10 @@
|
|
|
33
40
|
"bin": {
|
|
34
41
|
"pramen": "./dist/cli.js"
|
|
35
42
|
},
|
|
36
|
-
"files": [
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"src"
|
|
46
|
+
],
|
|
37
47
|
"scripts": {
|
|
38
48
|
"build": "rm -rf dist && tsc -p tsconfig.build.json"
|
|
39
49
|
},
|
package/src/cli.ts
CHANGED
|
@@ -23,38 +23,12 @@ import { createTableSql } from "./runtime/ddl";
|
|
|
23
23
|
import { schemaHash } from "./runtime/migrate";
|
|
24
24
|
import { diffSchemaFingerprint, schemaFingerprint, type SchemaFingerprint } from "./runtime/schema-diff";
|
|
25
25
|
import { entitiesInPartition, partitionsOf, type SchemaDef } from "./sdk/schema";
|
|
26
|
+
import { signDevToken } from "./runtime/dev-token";
|
|
26
27
|
|
|
27
|
-
/**
|
|
28
|
-
* dev/testing (`pramen token`, and the default token for `schema status`). Signs
|
|
29
|
-
* with AUTH_SECRET when set, else the dev secret from the scaffolded oblaka.ts. */
|
|
30
|
-
const DEV_SECRET = "dev-secret-change-me";
|
|
31
|
-
|
|
32
|
-
function bytesToB64url(bytes: Uint8Array): string {
|
|
33
|
-
let bin = "";
|
|
34
|
-
for (const b of bytes) bin += String.fromCharCode(b);
|
|
35
|
-
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
36
|
-
}
|
|
37
|
-
const strToB64url = (s: string) => bytesToB64url(new TextEncoder().encode(s));
|
|
38
|
-
|
|
39
|
-
/** The dev JWT claims `pramen token` mints. */
|
|
28
|
+
/** The dev JWT claims `pramen token` mints. See `runtime/dev-token.ts` for the signer. */
|
|
40
29
|
type TokenClaims = { sub: string; roles: string[]; tenants?: string[] };
|
|
41
30
|
|
|
42
|
-
|
|
43
|
-
const secret = process.env.AUTH_SECRET || DEV_SECRET;
|
|
44
|
-
const now = Math.floor(Date.now() / 1000);
|
|
45
|
-
const header = strToB64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
46
|
-
const body = strToB64url(JSON.stringify({ iat: now, exp: now + 3600, ...payload }));
|
|
47
|
-
const data = `${header}.${body}`;
|
|
48
|
-
const key = await crypto.subtle.importKey(
|
|
49
|
-
"raw",
|
|
50
|
-
new TextEncoder().encode(secret),
|
|
51
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
52
|
-
false,
|
|
53
|
-
["sign"],
|
|
54
|
-
);
|
|
55
|
-
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
|
|
56
|
-
return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
|
|
57
|
-
}
|
|
31
|
+
const sign = (payload: Record<string, unknown>): Promise<string> => signDevToken(payload);
|
|
58
32
|
|
|
59
33
|
/** The sub-schema of a single partition (used to mirror the DO's per-partition hash,
|
|
60
34
|
* which migrate() computes over exactly this subset). For a single-partition app the
|
package/src/dev.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// `@pramen/server/dev` — tooling helpers, deliberately NOT on the authoring entry point.
|
|
2
|
+
//
|
|
3
|
+
// `signDevToken` mints an HS256 JWT for local development: the `pramen` and `pramen-cms`
|
|
4
|
+
// bins and the repo's test helper all use it. It is kept off `@pramen/server` because its
|
|
5
|
+
// secret lookup reads `process.env.AUTH_SECRET`, which does not exist in a deployed
|
|
6
|
+
// Worker (bindings and secrets arrive on `env`) — so an app handler importing it from the
|
|
7
|
+
// main entry would silently sign with the PUBLISHED dev constant while appearing to honour
|
|
8
|
+
// AUTH_SECRET. A separate subpath makes "this is dev tooling" part of the import.
|
|
9
|
+
|
|
10
|
+
export { signDevToken, DEV_SECRET } from "./runtime/dev-token";
|
package/src/durable-object.ts
CHANGED
|
@@ -263,6 +263,8 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
263
263
|
files: this.filesFor(this.tenant),
|
|
264
264
|
env: this.envBag,
|
|
265
265
|
identity,
|
|
266
|
+
tenant: this.tenant,
|
|
267
|
+
store: "do",
|
|
266
268
|
tasks: tasksFacade(this.driver),
|
|
267
269
|
mail: createMail(this.envBag, this.kv),
|
|
268
270
|
queue: createQueue(this.envBag),
|
|
@@ -601,7 +603,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
601
603
|
// Carry the schema so any consumer of this context (not just Db) can compile
|
|
602
604
|
// relation-aware `where` rules into subqueries, and the active partition so Db's
|
|
603
605
|
// table-access guard rejects any table outside this DO's partition.
|
|
604
|
-
return { acl: this.acl, identity, schema: this.app.schema, partition };
|
|
606
|
+
return { acl: this.acl, identity, schema: this.app.schema, partition, tenant: this.tenant };
|
|
605
607
|
}
|
|
606
608
|
|
|
607
609
|
// The DO env (bindings + vars + secrets) handed to handlers as ctx.env. Loosely
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// and codegen load an app.ts for its schema without dragging in the DO runtime.
|
|
9
9
|
|
|
10
10
|
// --- schema authoring ---
|
|
11
|
-
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, trigger } from "./sdk/schema";
|
|
11
|
+
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, trigger, partitionOf, DEFAULT_PARTITION } from "./sdk/schema";
|
|
12
12
|
export type { TriggerDef, TriggerOp } from "./sdk/schema";
|
|
13
13
|
export { isValidUuid } from "./sdk/uuid";
|
|
14
14
|
export type {
|
|
@@ -82,6 +82,12 @@ export { Kv, denySession, allowSession, isSessionDenied } from "./runtime/kv";
|
|
|
82
82
|
// --- files ---
|
|
83
83
|
export type { FileRef, Files, SignUploadOpts, SignDownloadOpts, HeadResult } from "./sdk/files";
|
|
84
84
|
export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
|
|
85
|
+
|
|
86
|
+
// Signed capability tokens (HMAC-SHA256) — the machinery behind signed file urls and
|
|
87
|
+
// page-preview links. Exported so an app (or @pramen/cms) can mint its own capability url
|
|
88
|
+
// without a second signing implementation.
|
|
89
|
+
export { signToken, verifyToken, isUsableSecret, resolveSecret, MIN_TOKEN_SECRET_LEN } from "./runtime/token";
|
|
90
|
+
export type { ExpiringToken } from "./runtime/token";
|
|
85
91
|
export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
|
|
86
92
|
|
|
87
93
|
// --- mail (ctx.mail) ---
|
|
@@ -95,7 +101,7 @@ export { routeQueue, dispatchQueueBatch } from "./runtime/queue-consumer";
|
|
|
95
101
|
export type { QueueContext, QueueHandler, QueueMessage, QueueBatch, AppQueueMap } from "./runtime/queue-consumer";
|
|
96
102
|
|
|
97
103
|
// --- errors ---
|
|
98
|
-
export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
|
|
104
|
+
export { PramenError, BadRequest, Unauthorized, Forbidden, Conflict } from "./runtime/errors";
|
|
99
105
|
|
|
100
106
|
// --- substrate seam (advanced: bring your own SQL backend) ---
|
|
101
107
|
export { sqliteDialect, postgresDialect, DoSqliteDriver, D1Driver } from "./runtime/driver";
|
package/src/runtime/acl.ts
CHANGED
|
@@ -64,6 +64,14 @@ export interface AclContext {
|
|
|
64
64
|
/** The app schema — lets `where` rules traverse relations (`{ rel: { col } }`),
|
|
65
65
|
* compiled to a subquery with the related entity's read scope AND-merged in. */
|
|
66
66
|
readonly schema?: SchemaDef;
|
|
67
|
+
/** The tenant this request is for — the `x-pramen-tenant` value the DO was addressed
|
|
68
|
+
* with. Carried so a handler can mint a tenant-scoped capability (e.g. a signed page
|
|
69
|
+
* preview link) without the caller supplying, and thus being able to forge, a tenant. */
|
|
70
|
+
readonly tenant?: string;
|
|
71
|
+
/** Which substrate is serving this request — `"do"` (a Durable Object, the default) or
|
|
72
|
+
* `"d1"` (the shared D1 database, selected per-request with `x-pramen-store: d1`). A
|
|
73
|
+
* handler needs this when a capability it mints can only be redeemed on one of them. */
|
|
74
|
+
readonly store?: "do" | "d1";
|
|
67
75
|
/** The partition this DO serves. When set, Db rejects any access to a table that
|
|
68
76
|
* lives in a different partition (a partition-DO only owns its own tables). Unset
|
|
69
77
|
* (e.g. the D1/Worker shared-store path) disables the guard — a no-op. */
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Mint an HS256 JWT for local development and tooling — what a real auth service would
|
|
2
|
+
// issue, without running one.
|
|
3
|
+
//
|
|
4
|
+
// Extracted because it was already written twice (the `pramen` CLI and the repo's test
|
|
5
|
+
// helper) and `@pramen/cms`'s own bin would have been a third. One implementation, in the
|
|
6
|
+
// package that owns tokens.
|
|
7
|
+
//
|
|
8
|
+
// NOT an auth system: the dev fallback secret is public, so a token signed with it is
|
|
9
|
+
// worthless anywhere `AUTH_SECRET` is set to something real. That is the point — it makes
|
|
10
|
+
// a forgotten secret fail loudly rather than quietly accept dev tokens in production.
|
|
11
|
+
|
|
12
|
+
/** The scaffolded oblaka.ts dev secret. Used only when AUTH_SECRET is unset. */
|
|
13
|
+
export const DEV_SECRET = "dev-secret-change-me";
|
|
14
|
+
|
|
15
|
+
function bytesToB64url(bytes: Uint8Array): string {
|
|
16
|
+
let bin = "";
|
|
17
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
18
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
19
|
+
}
|
|
20
|
+
const strToB64url = (s: string): string => bytesToB64url(new TextEncoder().encode(s));
|
|
21
|
+
|
|
22
|
+
/** Sign a dev JWT (1h). Secret: the `secret` argument, else `AUTH_SECRET`, else DEV_SECRET. */
|
|
23
|
+
export async function signDevToken(payload: Record<string, unknown>, secret?: string): Promise<string> {
|
|
24
|
+
// `||`, not `??`: an AUTH_SECRET set to the empty string previously fell through to
|
|
25
|
+
// DEV_SECRET, and signing with an empty HMAC key instead would produce tokens the server
|
|
26
|
+
// rejects with no useful message.
|
|
27
|
+
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;
|
|
28
|
+
const key = secret || env?.AUTH_SECRET || DEV_SECRET;
|
|
29
|
+
const now = Math.floor(Date.now() / 1000);
|
|
30
|
+
const header = strToB64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
31
|
+
const body = strToB64url(JSON.stringify({ iat: now, exp: now + 3600, ...payload }));
|
|
32
|
+
const data = `${header}.${body}`;
|
|
33
|
+
const cryptoKey = await crypto.subtle.importKey("raw", new TextEncoder().encode(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
34
|
+
const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(data));
|
|
35
|
+
return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
|
|
36
|
+
}
|
package/src/runtime/dispatch.ts
CHANGED
package/src/runtime/errors.ts
CHANGED
|
@@ -34,6 +34,15 @@ export class Forbidden extends PramenError {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** 409 — the request conflicts with the current state of the resource. For
|
|
38
|
+
* optimistic concurrency (a stale `expectedVersion`) and for uniqueness clashes the
|
|
39
|
+
* caller could resolve by retrying with different input. */
|
|
40
|
+
export class Conflict extends PramenError {
|
|
41
|
+
constructor(message = "conflict") {
|
|
42
|
+
super(message, 409, "conflict");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
37
46
|
export interface ErrorBody {
|
|
38
47
|
ok: false;
|
|
39
48
|
error: string;
|
package/src/runtime/storage.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { BadRequest, PramenError } from "./errors";
|
|
19
19
|
import type { Files, FileRef, HeadResult } from "../sdk/files";
|
|
20
|
+
import { signToken, verifyToken, isUsableSecret, MIN_TOKEN_SECRET_LEN, type ExpiringToken } from "./token";
|
|
20
21
|
|
|
21
22
|
// The portable type surface (FileRef, Files, sign opts) lives in sdk/files.ts;
|
|
22
23
|
// re-export it here so runtime callers have one import site.
|
|
@@ -118,72 +119,25 @@ function bytesToStream(bytes: Uint8Array): ReadableStream {
|
|
|
118
119
|
});
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
// ---
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
127
|
-
}
|
|
128
|
-
function b64urlToBytes(s: string): Uint8Array {
|
|
129
|
-
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
|
|
130
|
-
const bin = atob(b64);
|
|
131
|
-
const out = new Uint8Array(bin.length);
|
|
132
|
-
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
133
|
-
return out;
|
|
134
|
-
}
|
|
135
|
-
const strToB64url = (s: string) => bytesToB64url(new TextEncoder().encode(s));
|
|
136
|
-
const b64urlToStr = (s: string) => new TextDecoder().decode(b64urlToBytes(s));
|
|
137
|
-
|
|
138
|
-
// --- signed file tokens (HMAC-SHA256) ---
|
|
122
|
+
// --- signed file tokens ---
|
|
123
|
+
//
|
|
124
|
+
// The HMAC/base64url machinery is shared with page-preview links and anything else that
|
|
125
|
+
// needs a signed capability url; it lives in runtime/token.ts. This file only declares
|
|
126
|
+
// what a FILE token carries.
|
|
139
127
|
|
|
140
|
-
interface FileToken {
|
|
128
|
+
interface FileToken extends ExpiringToken {
|
|
141
129
|
/** tenant */ t: string;
|
|
142
130
|
/** key */ k: string;
|
|
143
131
|
/** op */ op: "get" | "put";
|
|
144
|
-
/** expiry (epoch seconds) */ exp: number;
|
|
145
132
|
/** content-type (put: enforced; get: disposition hint) */ ct?: string;
|
|
146
133
|
/** max size in bytes (put only) */ max?: number;
|
|
147
134
|
/** filename (download disposition) */ fn?: string;
|
|
148
135
|
}
|
|
149
136
|
|
|
150
|
-
|
|
151
|
-
return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
152
|
-
"sign",
|
|
153
|
-
"verify",
|
|
154
|
-
]);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async function signToken(token: FileToken, secret: string): Promise<string> {
|
|
158
|
-
const data = strToB64url(JSON.stringify(token));
|
|
159
|
-
const key = await hmacKey(secret);
|
|
160
|
-
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
|
|
161
|
-
return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
|
|
162
|
-
}
|
|
137
|
+
const signFileToken = (token: FileToken, secret: string): Promise<string> => signToken(token, secret);
|
|
163
138
|
|
|
164
139
|
/** Verify a file token's signature + expiry; returns the payload or null. */
|
|
165
|
-
export
|
|
166
|
-
const dot = raw.indexOf(".");
|
|
167
|
-
if (dot < 0) return null;
|
|
168
|
-
const data = raw.slice(0, dot);
|
|
169
|
-
const sig = raw.slice(dot + 1);
|
|
170
|
-
let ok: boolean;
|
|
171
|
-
try {
|
|
172
|
-
const key = await hmacKey(secret);
|
|
173
|
-
ok = await crypto.subtle.verify("HMAC", key, b64urlToBytes(sig), new TextEncoder().encode(data));
|
|
174
|
-
} catch {
|
|
175
|
-
return null;
|
|
176
|
-
}
|
|
177
|
-
if (!ok) return null;
|
|
178
|
-
let payload: FileToken;
|
|
179
|
-
try {
|
|
180
|
-
payload = JSON.parse(b64urlToStr(data));
|
|
181
|
-
} catch {
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
if (typeof payload.exp !== "number" || Math.floor(Date.now() / 1000) >= payload.exp) return null;
|
|
185
|
-
return payload;
|
|
186
|
-
}
|
|
140
|
+
export const verifyFileToken = (raw: string, secret: string): Promise<FileToken | null> => verifyToken<FileToken>(raw, secret);
|
|
187
141
|
|
|
188
142
|
// --- key generation ---
|
|
189
143
|
|
|
@@ -247,10 +201,8 @@ export interface FilesConfig {
|
|
|
247
201
|
* would be forgeable (HMAC over an empty/weak key). Below this, file storage is
|
|
248
202
|
* treated as unconfigured — fail closed rather than mint forgeable urls. The dev
|
|
249
203
|
* defaults satisfy it; production should set a strong, random FILES_SECRET. */
|
|
250
|
-
export const MIN_FILES_SECRET_LEN =
|
|
251
|
-
export
|
|
252
|
-
return typeof secret === "string" && secret.length >= MIN_FILES_SECRET_LEN;
|
|
253
|
-
}
|
|
204
|
+
export const MIN_FILES_SECRET_LEN = MIN_TOKEN_SECRET_LEN;
|
|
205
|
+
export const isUsableFilesSecret = isUsableSecret;
|
|
254
206
|
const filesUnconfigured = () =>
|
|
255
207
|
new PramenError("file storage is not configured (set a strong FILES_SECRET)", 503, "unavailable");
|
|
256
208
|
|
|
@@ -271,7 +223,7 @@ export function createFiles(cfg: FilesConfig): Files {
|
|
|
271
223
|
const seg = opts.prefix ? `${safePrefix(opts.prefix)}/` : "";
|
|
272
224
|
const key = `${prefix}${seg}${randomKeySuffix()}`;
|
|
273
225
|
const exp = Math.floor(Date.now() / 1000) + (opts.expiresIn ?? 900);
|
|
274
|
-
const token = await
|
|
226
|
+
const token = await signFileToken({ t: cfg.tenant, k: key, op: "put", exp, ct, max: opts.maxSize, fn: opts.filename }, cfg.secret);
|
|
275
227
|
const ref: FileRef = { key, size: 0, contentType: ct, filename: opts.filename, uploadedAt: Date.now() };
|
|
276
228
|
return { url: `${base}/upload?token=${encodeURIComponent(token)}`, ref };
|
|
277
229
|
},
|
|
@@ -282,7 +234,7 @@ export function createFiles(cfg: FilesConfig): Files {
|
|
|
282
234
|
const key = ensureOwnKey(r.key);
|
|
283
235
|
const exp = Math.floor(Date.now() / 1000) + (opts?.expiresIn ?? 3600);
|
|
284
236
|
const fn = opts?.download ? (typeof ref === "string" ? undefined : ref.filename) : undefined;
|
|
285
|
-
const token = await
|
|
237
|
+
const token = await signFileToken({ t: cfg.tenant, k: key, op: "get", exp, fn }, cfg.secret);
|
|
286
238
|
return { url: `${base}/download?token=${encodeURIComponent(token)}`, expiresAt: exp * 1000 };
|
|
287
239
|
},
|
|
288
240
|
|
|
@@ -327,7 +279,7 @@ export async function handleFileRequest(
|
|
|
327
279
|
|
|
328
280
|
const raw = url.searchParams.get("token");
|
|
329
281
|
if (!raw) return fileError(401, "unauthorized", "missing token");
|
|
330
|
-
const token = await
|
|
282
|
+
const token = await verifyFileToken(raw, opts.secret);
|
|
331
283
|
if (!token) return fileError(403, "forbidden", "invalid or expired token");
|
|
332
284
|
|
|
333
285
|
try {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Signed, self-expiring capability tokens (HMAC-SHA256 over a JSON payload).
|
|
2
|
+
//
|
|
3
|
+
// pramen already owns the edge and a secret, so a capability url needs no session and no
|
|
4
|
+
// store: the payload IS the grant, and the signature is what makes it unforgeable. Signed
|
|
5
|
+
// file urls were the first user (`runtime/storage.ts`); page preview links are the second
|
|
6
|
+
// (`@pramen/cms`). Both mint in a handler and redeem in the Worker, unauthenticated.
|
|
7
|
+
//
|
|
8
|
+
// Pure WebCrypto — synchronous in the sense that matters (no stream I/O), so it is safe to
|
|
9
|
+
// call from inside the DO's storage.transaction().
|
|
10
|
+
//
|
|
11
|
+
// A token is `<b64url(json)>.<b64url(sig)>`. It is deliberately NOT a JWT: no alg field to
|
|
12
|
+
// confuse, no header to downgrade, one algorithm, verified before the payload is parsed.
|
|
13
|
+
|
|
14
|
+
/** Every signed token carries an expiry (epoch seconds); `verifyToken` enforces it. */
|
|
15
|
+
export interface ExpiringToken {
|
|
16
|
+
exp: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function bytesToB64url(bytes: Uint8Array): string {
|
|
20
|
+
let bin = "";
|
|
21
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
22
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function b64urlToBytes(s: string): Uint8Array {
|
|
26
|
+
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
|
|
27
|
+
const bin = atob(b64);
|
|
28
|
+
const out = new Uint8Array(bin.length);
|
|
29
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const strToB64url = (s: string): string => bytesToB64url(new TextEncoder().encode(s));
|
|
34
|
+
export const b64urlToStr = (s: string): string => new TextDecoder().decode(b64urlToBytes(s));
|
|
35
|
+
|
|
36
|
+
async function hmacKey(secret: string): Promise<CryptoKey> {
|
|
37
|
+
return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
38
|
+
"sign",
|
|
39
|
+
"verify",
|
|
40
|
+
]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Sign a payload into a capability token. */
|
|
44
|
+
export async function signToken<T extends ExpiringToken>(payload: T, secret: string): Promise<string> {
|
|
45
|
+
const data = strToB64url(JSON.stringify(payload));
|
|
46
|
+
const key = await hmacKey(secret);
|
|
47
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
|
|
48
|
+
return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Verify a token's signature and expiry; returns the payload, or `null` for anything
|
|
52
|
+
* malformed, forged, or expired. The caller decides what the payload authorizes — this
|
|
53
|
+
* only attests that we minted it and that it is still in date. */
|
|
54
|
+
export async function verifyToken<T extends ExpiringToken>(raw: string, secret: string): Promise<T | null> {
|
|
55
|
+
const dot = raw.indexOf(".");
|
|
56
|
+
if (dot < 0) return null;
|
|
57
|
+
const data = raw.slice(0, dot);
|
|
58
|
+
const sig = raw.slice(dot + 1);
|
|
59
|
+
let ok: boolean;
|
|
60
|
+
try {
|
|
61
|
+
const key = await hmacKey(secret);
|
|
62
|
+
ok = await crypto.subtle.verify("HMAC", key, b64urlToBytes(sig), new TextEncoder().encode(data));
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
if (!ok) return null;
|
|
67
|
+
let payload: T;
|
|
68
|
+
try {
|
|
69
|
+
payload = JSON.parse(b64urlToStr(data)) as T;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (typeof payload.exp !== "number" || Math.floor(Date.now() / 1000) >= payload.exp) return null;
|
|
74
|
+
return payload;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A signing secret must be present and non-trivial, else tokens are forgeable (an HMAC
|
|
78
|
+
* over an empty or weak key). Below this length the feature is treated as UNCONFIGURED and
|
|
79
|
+
* fails closed, rather than minting urls that anyone can forge. */
|
|
80
|
+
export const MIN_TOKEN_SECRET_LEN = 16;
|
|
81
|
+
|
|
82
|
+
export function isUsableSecret(secret: unknown): secret is string {
|
|
83
|
+
return typeof secret === "string" && secret.length >= MIN_TOKEN_SECRET_LEN;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Resolve a signing secret from env by preference order, skipping any that is absent or
|
|
87
|
+
* too weak. Returns `undefined` when nothing usable is configured — callers fail closed. */
|
|
88
|
+
export function resolveSecret(env: Readonly<Record<string, unknown>>, names: readonly string[]): string | undefined {
|
|
89
|
+
for (const name of names) {
|
|
90
|
+
const v = env[name];
|
|
91
|
+
if (isUsableSecret(v)) return v;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
package/src/sdk/handlers.ts
CHANGED
|
@@ -40,6 +40,13 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
|
|
|
40
40
|
readonly env: EnvBag;
|
|
41
41
|
/** Resolved identity for this request (null = anonymous). */
|
|
42
42
|
readonly identity: Identity | null;
|
|
43
|
+
/** The tenant this request is for (the `x-pramen-tenant` value; `"main"` by default).
|
|
44
|
+
* Server-resolved, never caller-supplied — safe to embed in a signed capability. */
|
|
45
|
+
readonly tenant: string;
|
|
46
|
+
/** Which substrate is serving this request: `"do"` (a Durable Object) or `"d1"`. Only
|
|
47
|
+
* the DO path has a stub the Worker can call back into, so a handler minting a
|
|
48
|
+
* capability redeemed through `callPrivileged` must refuse on `"d1"`. */
|
|
49
|
+
readonly store: "do" | "d1";
|
|
43
50
|
/** Deferred side-effects (a transactional outbox). `tasks.enqueue` persists a task
|
|
44
51
|
* row in the SAME transaction as a mutation (atomic with the data write); a drainer
|
|
45
52
|
* runs the matching `app.tasks` handler after commit, off the write path, with
|
package/src/worker.ts
CHANGED
|
@@ -243,7 +243,9 @@ export function makeWorker(app: PramenApp) {
|
|
|
243
243
|
const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
|
|
244
244
|
const kv = new Kv(env.KV);
|
|
245
245
|
const bag = envBag(env);
|
|
246
|
-
|
|
246
|
+
// The D1 store is not per-tenant addressed (one shared database, no DO), so the
|
|
247
|
+
// task context runs as the default tenant — matching the `files` scope just above.
|
|
248
|
+
return { db, kv, files, env: bag, identity, tenant: "main", store: "d1", tasks: tasksFacade(driver), mail: createMail(bag, kv), queue: createQueue(bag) };
|
|
247
249
|
};
|
|
248
250
|
|
|
249
251
|
/** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
|
|
@@ -501,7 +503,9 @@ export function makeWorker(app: PramenApp) {
|
|
|
501
503
|
const bag = envBag(env);
|
|
502
504
|
try {
|
|
503
505
|
await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
|
|
504
|
-
|
|
506
|
+
// `tenant` matters here: a handler minting a tenant-scoped capability (a signed
|
|
507
|
+
// preview link) would otherwise stamp it "main" while reading acme's rows.
|
|
508
|
+
const { result, enqueued } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, bag, { acl: d1Acl, identity, tenant, store: "d1" }, name, input);
|
|
505
509
|
// Kick an immediate drain in the request tail when this handler enqueued tasks
|
|
506
510
|
// (e.g. sendMagicLinkEmail). Without this, tasks wait for the next Cron trigger
|
|
507
511
|
// — up to a full minute. `waitUntil` lets the response return now while the
|