@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 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
- /** Mint an HS256 JWT — mirrors what a real auth service would issue, for local
26
- * dev/testing (`pramen token`, and the default token for `schema status`). Signs
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";
@@ -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";
@@ -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
+ }
@@ -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),
@@ -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;
@@ -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 function verifyToken(raw: string, secret: string): Promise<FileToken | null>;
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 function isUsableFilesSecret(secret: string | undefined | null): secret is string;
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
@@ -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
- // --- base64url (shared shape with auth.ts/jwt.ts) ---
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 async function verifyToken(raw, secret) {
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 = 16;
192
- export function isUsableFilesSecret(secret) {
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 signToken({ t: cfg.tenant, k: key, op: "put", exp, ct, max: opts.maxSize, fn: opts.filename }, cfg.secret);
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 signToken({ t: cfg.tenant, k: key, op: "get", exp, fn }, cfg.secret);
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 verifyToken(raw, opts.secret);
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
+ }
@@ -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
- return { db, kv, files, env: bag, identity, tasks: tasksFacade(driver), mail: createMail(bag, kv), queue: createQueue(bag) };
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
- const { result, enqueued } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, bag, { acl: d1Acl, identity }, name, input);
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.49",
4
- "description": "pramen server runtime schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
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": ["dist", "src"],
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
- /** Mint an HS256 JWT mirrors what a real auth service would issue, for local
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
- async function sign(payload: Record<string, unknown>): Promise<string> {
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";
@@ -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";
@@ -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
+ }
@@ -92,6 +92,8 @@ export async function dispatch(
92
92
  files,
93
93
  env,
94
94
  identity: acl.identity,
95
+ tenant: acl.tenant ?? "main",
96
+ store: acl.store ?? "do",
95
97
  tasks: tasksFacade(driver, () => enqueued++),
96
98
  mail: createMail(env, kv),
97
99
  queue: createQueue(env),
@@ -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;
@@ -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
- // --- base64url (shared shape with auth.ts/jwt.ts) ---
122
-
123
- function bytesToB64url(bytes: Uint8Array): string {
124
- let bin = "";
125
- for (const b of bytes) bin += String.fromCharCode(b);
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
- async function hmacKey(secret: string): Promise<CryptoKey> {
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 async function verifyToken(raw: string, secret: string): Promise<FileToken | null> {
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 = 16;
251
- export function isUsableFilesSecret(secret: string | undefined | null): secret is string {
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 signToken({ t: cfg.tenant, k: key, op: "put", exp, ct, max: opts.maxSize, fn: opts.filename }, cfg.secret);
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 signToken({ t: cfg.tenant, k: key, op: "get", exp, fn }, cfg.secret);
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 verifyToken(raw, opts.secret);
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
+ }
@@ -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
- return { db, kv, files, env: bag, identity, tasks: tasksFacade(driver), mail: createMail(bag, kv), queue: createQueue(bag) };
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
- const { result, enqueued } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, bag, { acl: d1Acl, identity }, name, input);
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