@pramen/server 0.0.48 → 0.0.50

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.
Files changed (66) hide show
  1. package/dist/auth.d.ts +4 -3
  2. package/dist/cli.js +17 -30
  3. package/dist/dev.d.ts +1 -0
  4. package/dist/dev.js +9 -0
  5. package/dist/durable-object.d.ts +1 -0
  6. package/dist/durable-object.js +13 -6
  7. package/dist/index.d.ts +7 -5
  8. package/dist/index.js +6 -2
  9. package/dist/pramen.d.ts +4 -2
  10. package/dist/runtime/acl.d.ts +14 -5
  11. package/dist/runtime/acl.js +1 -5
  12. package/dist/runtime/db.d.ts +3 -2
  13. package/dist/runtime/dev-token.d.ts +4 -0
  14. package/dist/runtime/dev-token.js +34 -0
  15. package/dist/runtime/dispatch.d.ts +3 -1
  16. package/dist/runtime/dispatch.js +2 -0
  17. package/dist/runtime/driver.d.ts +8 -5
  18. package/dist/runtime/errors.d.ts +6 -0
  19. package/dist/runtime/errors.js +8 -0
  20. package/dist/runtime/mail.d.ts +2 -1
  21. package/dist/runtime/protocol.d.ts +4 -3
  22. package/dist/runtime/queue-consumer.d.ts +4 -2
  23. package/dist/runtime/queue.d.ts +3 -2
  24. package/dist/runtime/read-engine.d.ts +11 -9
  25. package/dist/runtime/read-engine.js +4 -1
  26. package/dist/runtime/registry.d.ts +4 -1
  27. package/dist/runtime/registry.js +0 -3
  28. package/dist/runtime/schema-diff.d.ts +6 -6
  29. package/dist/runtime/schema-diff.js +4 -4
  30. package/dist/runtime/storage.d.ts +4 -4
  31. package/dist/runtime/storage.js +8 -63
  32. package/dist/runtime/token.d.ts +22 -0
  33. package/dist/runtime/token.js +88 -0
  34. package/dist/sdk/acl.d.ts +18 -11
  35. package/dist/sdk/handlers.d.ts +16 -3
  36. package/dist/sdk/infer.d.ts +17 -0
  37. package/dist/worker.d.ts +2 -1
  38. package/dist/worker.js +20 -10
  39. package/package.json +13 -3
  40. package/src/auth.ts +7 -6
  41. package/src/cli.ts +22 -36
  42. package/src/dev.ts +10 -0
  43. package/src/durable-object.ts +18 -9
  44. package/src/index.ts +14 -4
  45. package/src/pramen.ts +4 -2
  46. package/src/runtime/acl.ts +45 -31
  47. package/src/runtime/db.ts +14 -12
  48. package/src/runtime/dev-token.ts +36 -0
  49. package/src/runtime/dispatch.ts +7 -3
  50. package/src/runtime/driver.ts +11 -7
  51. package/src/runtime/errors.ts +9 -0
  52. package/src/runtime/mail.ts +2 -1
  53. package/src/runtime/migrate.ts +2 -1
  54. package/src/runtime/outbox.ts +2 -1
  55. package/src/runtime/protocol.ts +5 -3
  56. package/src/runtime/queue-consumer.ts +4 -2
  57. package/src/runtime/queue.ts +4 -2
  58. package/src/runtime/read-engine.ts +27 -21
  59. package/src/runtime/registry.ts +5 -1
  60. package/src/runtime/schema-diff.ts +14 -14
  61. package/src/runtime/storage.ts +14 -62
  62. package/src/runtime/token.ts +94 -0
  63. package/src/sdk/acl.ts +29 -14
  64. package/src/sdk/handlers.ts +17 -3
  65. package/src/sdk/infer.ts +21 -0
  66. package/src/worker.ts +24 -11
package/dist/worker.js CHANGED
@@ -18,6 +18,10 @@ import { Kv, isSessionDenied } from "./runtime/kv";
18
18
  import { listDOs, partitionDoName } from "./runtime/registry";
19
19
  import { createFiles, handleFileRequest, handleMediaRequest, R2Adapter } from "./runtime/storage";
20
20
  import { DEFAULT_PARTITION, partitionsOf } from "./sdk/schema";
21
+ /** Widen the closed `Env` interface to the open `EnvBag` handlers and services see.
22
+ * Spreading yields an anonymous object type, which TypeScript gives an implicit index
23
+ * signature — so this needs no type assertion. */
24
+ const envBag = (env) => ({ ...env });
21
25
  /** The secret used to sign/verify file tokens — a dedicated FILES_SECRET if set,
22
26
  * else AUTH_SECRET (so HS256 setups work out of the box). */
23
27
  const filesSecret = (env) => env.FILES_SECRET || env.AUTH_SECRET;
@@ -172,7 +176,10 @@ export function makeWorker(app) {
172
176
  const files = createFiles({ tenant: "main", secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
173
177
  const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
174
178
  const kv = new Kv(env.KV);
175
- return { db, kv, files, env: env, identity, tasks: tasksFacade(driver), mail: createMail(env, kv), queue: createQueue(env) };
179
+ const bag = envBag(env);
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) };
176
183
  };
177
184
  /** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
178
185
  * /admin/tasks/drain route with `x-pramen-store: d1`, and by `scheduled()` (Cron). */
@@ -216,7 +223,7 @@ export function makeWorker(app) {
216
223
  for (const r of app.routes ?? []) {
217
224
  if (request.method === r.method && url.pathname === r.path) {
218
225
  const routeCtx = { callPrivileged: (opts) => callPrivileged(env, opts) };
219
- return r.handler(request, env, routeCtx);
226
+ return r.handler(request, envBag(env), routeCtx);
220
227
  }
221
228
  }
222
229
  // CORS (opt-in via CORS_ORIGINS) for cross-origin browser clients. Answer the
@@ -396,9 +403,10 @@ export function makeWorker(app) {
396
403
  }
397
404
  // (isLive is excluded by useD1Store — live always routes to the DO below.)
398
405
  const name = url.pathname.replace(/^\/rpc\//, "");
399
- let input;
406
+ // The RPC body is JSON — parse it into the domain type once, here at the boundary.
407
+ let input = null;
400
408
  if (request.method === "POST")
401
- input = await request.json().catch(() => undefined);
409
+ input = ((await request.json().catch(() => null)) ?? null);
402
410
  // Pick where the D1 session may start its first read. A mutation ALWAYS pins the
403
411
  // primary (`first-primary` is a superset of read-your-writes) so a read-modify-write
404
412
  // can't run off a lagging replica — an inbound bookmark must not widen that window.
@@ -414,10 +422,12 @@ export function makeWorker(app) {
414
422
  start = "first-unconstrained";
415
423
  const driver = new D1Driver(env.DB, { start });
416
424
  const files = createFiles({ tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
417
- const envBag = env;
425
+ const bag = envBag(env);
418
426
  try {
419
427
  await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
420
- const { result, enqueued } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, envBag, { 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);
421
431
  // Kick an immediate drain in the request tail when this handler enqueued tasks
422
432
  // (e.g. sendMagicLinkEmail). Without this, tasks wait for the next Cron trigger
423
433
  // — up to a full minute. `waitUntil` lets the response return now while the
@@ -488,13 +498,13 @@ export function makeWorker(app) {
488
498
  // handler (ACK on success / RETRY on throw, per message). A consumer is Worker-level
489
499
  // (no tenant DO): its ctx carries env/kv/mail/queue + callPrivileged to reach a DO.
490
500
  async queue(batch, env) {
491
- const envBag = env;
501
+ const bag = envBag(env);
492
502
  const kv = new Kv(env.KV);
493
503
  const ctx = {
494
- env: envBag,
504
+ env: bag,
495
505
  kv,
496
- mail: createMail(envBag, kv),
497
- queue: createQueue(envBag),
506
+ mail: createMail(bag, kv),
507
+ queue: createQueue(bag),
498
508
  callPrivileged: (opts) => callPrivileged(env, opts),
499
509
  };
500
510
  await dispatchQueueBatch(app.queues ?? {}, ctx, batch);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pramen/server",
3
- "version": "0.0.48",
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.50",
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/auth.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  // non-standard claims pass through.
12
12
 
13
13
  import type { Identity } from "./sdk/acl";
14
+ import type { JsonObject } from "./sdk/infer";
14
15
 
15
16
  const STANDARD_CLAIMS = new Set(["exp", "iat", "nbf", "iss", "aud", "jti", "sub", "role", "roles", "userId"]);
16
17
 
@@ -34,7 +35,7 @@ interface JwtHeader {
34
35
  /** Verifies a JWT and returns its claims, or null if invalid. Implementations
35
36
  * differ only in how they verify the signature. */
36
37
  export interface VerifyStrategy {
37
- verify(token: string): Promise<Record<string, unknown> | null>;
38
+ verify(token: string): Promise<JsonObject | null>;
38
39
  }
39
40
 
40
41
  /** Verify a signature over `${header}.${payload}` for the parsed header. */
@@ -69,7 +70,7 @@ async function verifyJwt(
69
70
  token: string,
70
71
  verifySignature: SignatureVerifier,
71
72
  opts: VerifyOptions = {},
72
- ): Promise<Record<string, unknown> | null> {
73
+ ): Promise<JsonObject | null> {
73
74
  const parts = token.split(".");
74
75
  if (parts.length !== 3) return null;
75
76
  const [h, p, sig] = parts;
@@ -89,7 +90,7 @@ async function verifyJwt(
89
90
  }
90
91
  if (!valid) return null;
91
92
 
92
- let payload: Record<string, unknown>;
93
+ let payload: JsonObject;
93
94
  try {
94
95
  payload = JSON.parse(b64urlToString(p!));
95
96
  } catch {
@@ -113,7 +114,7 @@ export class HmacStrategy implements VerifyStrategy {
113
114
  private readonly opts: VerifyOptions = {},
114
115
  ) {}
115
116
 
116
- verify(token: string): Promise<Record<string, unknown> | null> {
117
+ verify(token: string): Promise<JsonObject | null> {
117
118
  return verifyJwt(
118
119
  token,
119
120
  async (input, signature, header) => {
@@ -152,7 +153,7 @@ export class JwksStrategy implements VerifyStrategy {
152
153
  private readonly opts: VerifyOptions = {},
153
154
  ) {}
154
155
 
155
- verify(token: string): Promise<Record<string, unknown> | null> {
156
+ verify(token: string): Promise<JsonObject | null> {
156
157
  return verifyJwt(
157
158
  token,
158
159
  async (input, signature, header) => {
@@ -219,7 +220,7 @@ export class JwksStrategy implements VerifyStrategy {
219
220
  }
220
221
  }
221
222
 
222
- function toIdentity(claims: Record<string, unknown>): Identity {
223
+ function toIdentity(claims: JsonObject): Identity {
223
224
  const roles = Array.isArray(claims.roles)
224
225
  ? (claims.roles as string[])
225
226
  : typeof claims.role === "string"
package/src/cli.ts CHANGED
@@ -21,37 +21,14 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
21
  import { dirname, resolve } from "node:path";
22
22
  import { createTableSql } from "./runtime/ddl";
23
23
  import { schemaHash } from "./runtime/migrate";
24
- import { diffSchemaShape, schemaShape, type SchemaShape } from "./runtime/schema-diff";
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";
28
+ /** The dev JWT claims `pramen token` mints. See `runtime/dev-token.ts` for the signer. */
29
+ type TokenClaims = { sub: string; roles: string[]; tenants?: string[] };
31
30
 
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
- async function sign(payload: Record<string, unknown>): Promise<string> {
40
- const secret = process.env.AUTH_SECRET || DEV_SECRET;
41
- const now = Math.floor(Date.now() / 1000);
42
- const header = strToB64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
43
- const body = strToB64url(JSON.stringify({ iat: now, exp: now + 3600, ...payload }));
44
- const data = `${header}.${body}`;
45
- const key = await crypto.subtle.importKey(
46
- "raw",
47
- new TextEncoder().encode(secret),
48
- { name: "HMAC", hash: "SHA-256" },
49
- false,
50
- ["sign"],
51
- );
52
- const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
53
- return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
54
- }
31
+ const sign = (payload: Record<string, unknown>): Promise<string> => signDevToken(payload);
55
32
 
56
33
  /** The sub-schema of a single partition (used to mirror the DO's per-partition hash,
57
34
  * which migrate() computes over exactly this subset). For a single-partition app the
@@ -102,7 +79,7 @@ Usage: pramen <command>
102
79
  init [dir] scaffold a new project (app.ts + worker.ts + oblaka.ts)
103
80
  schema sql print CREATE TABLE statements for the schema
104
81
  schema hash print the schema hash
105
- schema snapshot save the schema shape to .pramen/schema.json
82
+ schema snapshot save the schema fingerprint to .pramen/schema.json
106
83
  schema diff compare the schema to the snapshot (safe vs unsafe)
107
84
  schema status compare a deployed tenant's schema to the local schema
108
85
  [--tenant t] [--url u] [--token jwt]
@@ -126,20 +103,27 @@ async function schemaCmd(sub: string | undefined): Promise<void> {
126
103
  if (sub === "snapshot") {
127
104
  const { schema } = await loadApp();
128
105
  mkdirSync(dirname(snapshotPath), { recursive: true });
129
- const snap = { hash: schemaHash(schema), shape: schemaShape(schema) };
106
+ const snap = { hash: schemaHash(schema), fingerprint: schemaFingerprint(schema) };
130
107
  writeFileSync(snapshotPath, JSON.stringify(snap, null, 2) + "\n");
131
- console.log(`wrote ${snapshotPath} (${Object.keys(snap.shape).length} tables)`);
108
+ console.log(`wrote ${snapshotPath} (${Object.keys(snap.fingerprint).length} tables)`);
132
109
  return;
133
110
  }
134
111
  if (sub === "diff") {
135
112
  const { schema } = await loadApp();
136
- const next = schemaShape(schema);
113
+ const next = schemaFingerprint(schema);
137
114
  if (!existsSync(snapshotPath)) {
138
115
  console.log("no snapshot — run `pramen schema snapshot` to set a baseline.");
139
116
  return;
140
117
  }
141
- const prev = (JSON.parse(readFileSync(snapshotPath, "utf8")) as { shape: SchemaShape }).shape;
142
- const changes = diffSchemaShape(prev, next);
118
+ // `fingerprint` was called `shape` before; keep reading an existing snapshot so an
119
+ // upgrade doesn't force a re-baseline.
120
+ const snap = JSON.parse(readFileSync(snapshotPath, "utf8")) as {
121
+ fingerprint?: SchemaFingerprint;
122
+ // the pre-rename key, quoted because it names a stored JSON field, not a symbol
123
+ "shape"?: SchemaFingerprint;
124
+ };
125
+ const prev = snap.fingerprint ?? snap["shape"] ?? {};
126
+ const changes = diffSchemaFingerprint(prev, next);
143
127
  if (changes.length === 0) {
144
128
  console.log("no changes since snapshot.");
145
129
  return;
@@ -203,7 +187,7 @@ async function schemaCmd(sub: string | undefined): Promise<void> {
203
187
  console.log(`live: ${live.hash ?? "(none)"}`);
204
188
  console.log(`current: ${current}`);
205
189
  console.log(upToDate ? "✓ up to date" : "⚠ BEHIND — migrates on the tenant's next boot");
206
- const want = schemaShape(subset);
190
+ const want = schemaFingerprint(subset);
207
191
  for (const table of Object.keys(want)) {
208
192
  const liveCols = new Set(live.tables[table] ?? []);
209
193
  const missing = Object.keys(want[table]!.columns).filter((col) => !liveCols.has(col));
@@ -222,7 +206,9 @@ async function tokenCmd(args: string[]): Promise<void> {
222
206
  if (!sub) fail("token: <sub> required");
223
207
  const roles = pos.slice(1);
224
208
  const tenants = flag("tenant")?.split(",");
225
- console.log(await sign({ sub, roles: roles.length ? roles : ["admin"], ...(tenants ? { tenants } : {}) }));
209
+ const claims: TokenClaims = { sub, roles: roles.length ? roles : ["admin"] };
210
+ if (tenants) claims.tenants = tenants;
211
+ console.log(await sign(claims));
226
212
  }
227
213
 
228
214
  function initCmd(args: string[]): void {
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";
@@ -34,6 +34,8 @@ import type { Identity } from "./sdk/acl";
34
34
  import type { HandlerContext } from "./sdk/handlers";
35
35
  import type { PramenApp } from "./pramen";
36
36
  import type { ClientMsg, ServerMsg, Subscription } from "./runtime/protocol";
37
+ import type { EnvBag } from "./sdk/handlers";
38
+ import type { JsonValue } from "./sdk/infer";
37
39
 
38
40
  /** Durable per-socket state — kept SMALL and stable, since it rides the WebSocket
39
41
  * attachment which workerd caps at ~2 KB. Only auth/routing identity lives here so it
@@ -208,9 +210,10 @@ export class PramenDOBase extends DurableObject<DoEnv> {
208
210
  }
209
211
 
210
212
  const name = new URL(request.url).pathname.replace(/^\/rpc\//, "");
211
- let input: unknown;
213
+ // The RPC body is JSON — parse it into the domain type once, here at the boundary.
214
+ let input: JsonValue = null;
212
215
  if (request.method === "POST") {
213
- input = await request.json().catch(() => undefined);
216
+ input = ((await request.json().catch(() => null)) ?? null) as JsonValue;
214
217
  }
215
218
 
216
219
  try {
@@ -260,6 +263,8 @@ export class PramenDOBase extends DurableObject<DoEnv> {
260
263
  files: this.filesFor(this.tenant),
261
264
  env: this.envBag,
262
265
  identity,
266
+ tenant: this.tenant,
267
+ store: "do",
263
268
  tasks: tasksFacade(this.driver),
264
269
  mail: createMail(this.envBag, this.kv),
265
270
  queue: createQueue(this.envBag),
@@ -354,12 +359,12 @@ export class PramenDOBase extends DurableObject<DoEnv> {
354
359
 
355
360
  switch (msg.type) {
356
361
  case "subscribe":
357
- return this.onSubscribe(ws, msg.id, msg.name, msg.input);
362
+ return this.onSubscribe(ws, msg.id, msg.name, msg.input ?? null);
358
363
  case "unsubscribe":
359
364
  this.setSubs(ws, this.getSubs(ws).filter((s) => s.id !== msg.id));
360
365
  return;
361
366
  case "call":
362
- return this.onCall(ws, msg.id, msg.name, msg.input);
367
+ return this.onCall(ws, msg.id, msg.name, msg.input ?? null);
363
368
  default:
364
369
  return this.send(ws, { type: "error", id: "", error: "unknown message type" });
365
370
  }
@@ -381,7 +386,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
381
386
 
382
387
  // --- live-query internals ---
383
388
 
384
- private async onSubscribe(ws: WebSocket, id: string, name: string, input: unknown): Promise<void> {
389
+ private async onSubscribe(ws: WebSocket, id: string, name: string, input: JsonValue): Promise<void> {
385
390
  const att = this.getAttachment(ws);
386
391
  const subs = this.getSubs(ws);
387
392
  try {
@@ -402,7 +407,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
402
407
  }
403
408
  }
404
409
 
405
- private async onCall(ws: WebSocket, id: string, name: string, input: unknown): Promise<void> {
410
+ private async onCall(ws: WebSocket, id: string, name: string, input: JsonValue): Promise<void> {
406
411
  const att = this.getAttachment(ws);
407
412
  let outcome: Awaited<ReturnType<typeof dispatch>>;
408
413
  try {
@@ -598,13 +603,17 @@ export class PramenDOBase extends DurableObject<DoEnv> {
598
603
  // Carry the schema so any consumer of this context (not just Db) can compile
599
604
  // relation-aware `where` rules into subqueries, and the active partition so Db's
600
605
  // table-access guard rejects any table outside this DO's partition.
601
- return { acl: this.acl, identity, schema: this.app.schema, partition };
606
+ return { acl: this.acl, identity, schema: this.app.schema, partition, tenant: this.tenant };
602
607
  }
603
608
 
604
609
  // The DO env (bindings + vars + secrets) handed to handlers as ctx.env. Loosely
605
610
  // typed at the boundary so handlers can read any var/secret without a DoEnv cast.
606
- private get envBag(): Readonly<Record<string, unknown>> {
607
- return this.env as unknown as Record<string, unknown>;
611
+ private widenedEnv: EnvBag | null = null;
612
+ private get envBag(): EnvBag {
613
+ // `this.env` is fixed for the DO's lifetime, so widen it once. This getter is read
614
+ // inside the per-subscription live-query loop, where a copy per read would allocate
615
+ // one whole binding bag per subscription on every write.
616
+ return (this.widenedEnv ??= { ...this.env });
608
617
  }
609
618
 
610
619
  // One Files facade per DO (a DO serves one tenant). Backed by the R2 binding;
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 {
@@ -31,7 +31,7 @@ export type {
31
31
  // --- app + handlers ---
32
32
  export { createApp } from "./sdk/app";
33
33
  export { query, mutation, authorizeHandler } from "./sdk/handlers";
34
- export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap, BootstrapContext, BootstrapFn } from "./sdk/handlers";
34
+ export type { EnvBag, Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap, BootstrapContext, BootstrapFn } from "./sdk/handlers";
35
35
 
36
36
  // --- ACL ---
37
37
  export { $identity, $input, $now, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker, isNowMarker } from "./sdk/acl";
@@ -64,6 +64,10 @@ export type {
64
64
  InferRow,
65
65
  InferUpdate,
66
66
  JsonValue,
67
+ JsonObject,
68
+ SqlValue,
69
+ CellValue,
70
+ Row,
67
71
  ProjectedRow,
68
72
  RelationsOf,
69
73
  RelationsResult,
@@ -78,6 +82,12 @@ export { Kv, denySession, allowSession, isSessionDenied } from "./runtime/kv";
78
82
  // --- files ---
79
83
  export type { FileRef, Files, SignUploadOpts, SignDownloadOpts, HeadResult } from "./sdk/files";
80
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";
81
91
  export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
82
92
 
83
93
  // --- mail (ctx.mail) ---
@@ -91,8 +101,8 @@ export { routeQueue, dispatchQueueBatch } from "./runtime/queue-consumer";
91
101
  export type { QueueContext, QueueHandler, QueueMessage, QueueBatch, AppQueueMap } from "./runtime/queue-consumer";
92
102
 
93
103
  // --- errors ---
94
- export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
104
+ export { PramenError, BadRequest, Unauthorized, Forbidden, Conflict } from "./runtime/errors";
95
105
 
96
106
  // --- substrate seam (advanced: bring your own SQL backend) ---
97
107
  export { sqliteDialect, postgresDialect, DoSqliteDriver, D1Driver } from "./runtime/driver";
98
- export type { Driver, Dialect, Row } from "./runtime/driver";
108
+ export type { Driver, Dialect, DriverRow } from "./runtime/driver";
package/src/pramen.ts CHANGED
@@ -18,12 +18,14 @@ import { validateTriggerTasks, type SchemaDef } from "./sdk/schema";
18
18
  import type { AppTaskMap, HandlerMap, BootstrapFn } from "./sdk/handlers";
19
19
  import type { AppQueueMap, QueueBatch } from "./runtime/queue-consumer";
20
20
  import type { Role } from "./sdk/acl";
21
+ import type { EnvBag } from "./sdk/handlers";
22
+ import type { JsonValue } from "./sdk/infer";
21
23
 
22
24
  /** Injected into a public route's handler — forward a privileged mutation into the
23
25
  * tenant's DO without the handler importing any deploy-side code (so app.ts stays
24
26
  * authoring-only). The synthetic identity defaults to the admin role. */
25
27
  export interface RouteContext {
26
- callPrivileged(opts: { name: string; input?: unknown; tenant?: string; roles?: string[] }): Promise<Response>;
28
+ callPrivileged(opts: { name: string; input?: JsonValue; tenant?: string; roles?: string[] }): Promise<Response>;
27
29
  }
28
30
 
29
31
  /** A public, pre-auth route — matched before identity resolution, so it can host a
@@ -36,7 +38,7 @@ export interface PublicRoute {
36
38
  method: string;
37
39
  /** Exact pathname to match (e.g. "/stripe/webhook"). */
38
40
  path: string;
39
- handler: (request: Request, env: Readonly<Record<string, unknown>>, ctx: RouteContext) => Response | Promise<Response>;
41
+ handler: (request: Request, env: EnvBag, ctx: RouteContext) => Response | Promise<Response>;
40
42
  }
41
43
 
42
44
  /** The user-facing app: a schema, the handler map, ACL roles, and optional public