@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/auth.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { Identity } from "./sdk/acl";
2
+ import type { JsonObject } from "./sdk/infer";
2
3
  /** Verifies a JWT and returns its claims, or null if invalid. Implementations
3
4
  * differ only in how they verify the signature. */
4
5
  export interface VerifyStrategy {
5
- verify(token: string): Promise<Record<string, unknown> | null>;
6
+ verify(token: string): Promise<JsonObject | null>;
6
7
  }
7
8
  /** Optional, opt-in claim validation layered on top of signature + exp/nbf. All
8
9
  * default OFF (unset) so existing tokens keep verifying; a deployment turns these on
@@ -22,7 +23,7 @@ export declare class HmacStrategy implements VerifyStrategy {
22
23
  private readonly secret;
23
24
  private readonly opts;
24
25
  constructor(secret: string, opts?: VerifyOptions);
25
- verify(token: string): Promise<Record<string, unknown> | null>;
26
+ verify(token: string): Promise<JsonObject | null>;
26
27
  }
27
28
  /** RS256 verified against a remote JWKS. Public keys are fetched once and cached
28
29
  * (TTL); a token with an unknown `kid` triggers one forced refetch to pick up key
@@ -35,7 +36,7 @@ export declare class JwksStrategy implements VerifyStrategy {
35
36
  private fetchedAt;
36
37
  private inflight;
37
38
  constructor(url: string, ttlMs?: number, opts?: VerifyOptions);
38
- verify(token: string): Promise<Record<string, unknown> | null>;
39
+ verify(token: string): Promise<JsonObject | null>;
39
40
  private lookup;
40
41
  private keyFor;
41
42
  private refresh;
package/dist/cli.js CHANGED
@@ -20,29 +20,10 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
20
  import { dirname, resolve } from "node:path";
21
21
  import { createTableSql } from "./runtime/ddl";
22
22
  import { schemaHash } from "./runtime/migrate";
23
- import { diffSchemaShape, schemaShape } from "./runtime/schema-diff";
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. */
@@ -90,7 +71,7 @@ Usage: pramen <command>
90
71
  init [dir] scaffold a new project (app.ts + worker.ts + oblaka.ts)
91
72
  schema sql print CREATE TABLE statements for the schema
92
73
  schema hash print the schema hash
93
- schema snapshot save the schema shape to .pramen/schema.json
74
+ schema snapshot save the schema fingerprint to .pramen/schema.json
94
75
  schema diff compare the schema to the snapshot (safe vs unsafe)
95
76
  schema status compare a deployed tenant's schema to the local schema
96
77
  [--tenant t] [--url u] [--token jwt]
@@ -113,20 +94,23 @@ async function schemaCmd(sub) {
113
94
  if (sub === "snapshot") {
114
95
  const { schema } = await loadApp();
115
96
  mkdirSync(dirname(snapshotPath), { recursive: true });
116
- const snap = { hash: schemaHash(schema), shape: schemaShape(schema) };
97
+ const snap = { hash: schemaHash(schema), fingerprint: schemaFingerprint(schema) };
117
98
  writeFileSync(snapshotPath, JSON.stringify(snap, null, 2) + "\n");
118
- console.log(`wrote ${snapshotPath} (${Object.keys(snap.shape).length} tables)`);
99
+ console.log(`wrote ${snapshotPath} (${Object.keys(snap.fingerprint).length} tables)`);
119
100
  return;
120
101
  }
121
102
  if (sub === "diff") {
122
103
  const { schema } = await loadApp();
123
- const next = schemaShape(schema);
104
+ const next = schemaFingerprint(schema);
124
105
  if (!existsSync(snapshotPath)) {
125
106
  console.log("no snapshot — run `pramen schema snapshot` to set a baseline.");
126
107
  return;
127
108
  }
128
- const prev = JSON.parse(readFileSync(snapshotPath, "utf8")).shape;
129
- const changes = diffSchemaShape(prev, next);
109
+ // `fingerprint` was called `shape` before; keep reading an existing snapshot so an
110
+ // upgrade doesn't force a re-baseline.
111
+ const snap = JSON.parse(readFileSync(snapshotPath, "utf8"));
112
+ const prev = snap.fingerprint ?? snap["shape"] ?? {};
113
+ const changes = diffSchemaFingerprint(prev, next);
130
114
  if (changes.length === 0) {
131
115
  console.log("no changes since snapshot.");
132
116
  return;
@@ -181,7 +165,7 @@ async function schemaCmd(sub) {
181
165
  console.log(`live: ${live.hash ?? "(none)"}`);
182
166
  console.log(`current: ${current}`);
183
167
  console.log(upToDate ? "✓ up to date" : "⚠ BEHIND — migrates on the tenant's next boot");
184
- const want = schemaShape(subset);
168
+ const want = schemaFingerprint(subset);
185
169
  for (const table of Object.keys(want)) {
186
170
  const liveCols = new Set(live.tables[table] ?? []);
187
171
  const missing = Object.keys(want[table].columns).filter((col) => !liveCols.has(col));
@@ -202,7 +186,10 @@ async function tokenCmd(args) {
202
186
  fail("token: <sub> required");
203
187
  const roles = pos.slice(1);
204
188
  const tenants = flag("tenant")?.split(",");
205
- console.log(await sign({ sub, roles: roles.length ? roles : ["admin"], ...(tenants ? { tenants } : {}) }));
189
+ const claims = { sub, roles: roles.length ? roles : ["admin"] };
190
+ if (tenants)
191
+ claims.tenants = tenants;
192
+ console.log(await sign(claims));
206
193
  }
207
194
  function initCmd(args) {
208
195
  const dir = resolve(process.cwd(), positionals(args)[0] ?? ".");
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";
@@ -76,6 +76,7 @@ export declare class PramenDOBase extends DurableObject<DoEnv> {
76
76
  private handleSchema;
77
77
  private handleAdminData;
78
78
  private ctxFor;
79
+ private widenedEnv;
79
80
  private get envBag();
80
81
  private filesFor;
81
82
  private identityOf;
@@ -164,9 +164,10 @@ export class PramenDOBase extends DurableObject {
164
164
  return new Response(null, { status: 101, webSocket: client });
165
165
  }
166
166
  const name = new URL(request.url).pathname.replace(/^\/rpc\//, "");
167
- let input;
167
+ // The RPC body is JSON — parse it into the domain type once, here at the boundary.
168
+ let input = null;
168
169
  if (request.method === "POST") {
169
- input = await request.json().catch(() => undefined);
170
+ input = ((await request.json().catch(() => null)) ?? null);
170
171
  }
171
172
  try {
172
173
  const { result, kind, touched, enqueued } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(this.tenant), this.envBag, this.ctxFor(identity), name, input);
@@ -202,6 +203,8 @@ export class PramenDOBase extends DurableObject {
202
203
  files: this.filesFor(this.tenant),
203
204
  env: this.envBag,
204
205
  identity,
206
+ tenant: this.tenant,
207
+ store: "do",
205
208
  tasks: tasksFacade(this.driver),
206
209
  mail: createMail(this.envBag, this.kv),
207
210
  queue: createQueue(this.envBag),
@@ -291,12 +294,12 @@ export class PramenDOBase extends DurableObject {
291
294
  await this.ensureMigrated();
292
295
  switch (msg.type) {
293
296
  case "subscribe":
294
- return this.onSubscribe(ws, msg.id, msg.name, msg.input);
297
+ return this.onSubscribe(ws, msg.id, msg.name, msg.input ?? null);
295
298
  case "unsubscribe":
296
299
  this.setSubs(ws, this.getSubs(ws).filter((s) => s.id !== msg.id));
297
300
  return;
298
301
  case "call":
299
- return this.onCall(ws, msg.id, msg.name, msg.input);
302
+ return this.onCall(ws, msg.id, msg.name, msg.input ?? null);
300
303
  default:
301
304
  return this.send(ws, { type: "error", id: "", error: "unknown message type" });
302
305
  }
@@ -532,12 +535,16 @@ export class PramenDOBase extends DurableObject {
532
535
  // Carry the schema so any consumer of this context (not just Db) can compile
533
536
  // relation-aware `where` rules into subqueries, and the active partition so Db's
534
537
  // table-access guard rejects any table outside this DO's partition.
535
- return { acl: this.acl, identity, schema: this.app.schema, partition };
538
+ return { acl: this.acl, identity, schema: this.app.schema, partition, tenant: this.tenant };
536
539
  }
537
540
  // The DO env (bindings + vars + secrets) handed to handlers as ctx.env. Loosely
538
541
  // typed at the boundary so handlers can read any var/secret without a DoEnv cast.
542
+ widenedEnv = null;
539
543
  get envBag() {
540
- return this.env;
544
+ // `this.env` is fixed for the DO's lifetime, so widen it once. This getter is read
545
+ // inside the per-subscription live-query loop, where a copy per read would allocate
546
+ // one whole binding bag per subscription on every write.
547
+ return (this.widenedEnv ??= { ...this.env });
541
548
  }
542
549
  // One Files facade per DO (a DO serves one tenant). Backed by the R2 binding;
543
550
  // signing uses FILES_SECRET. Handlers mint signed urls; the bytes never enter here.
package/dist/index.d.ts CHANGED
@@ -1,16 +1,18 @@
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";
5
5
  export { createApp } from "./sdk/app";
6
6
  export { query, mutation, authorizeHandler } from "./sdk/handlers";
7
- export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap, BootstrapContext, BootstrapFn } from "./sdk/handlers";
7
+ export type { EnvBag, Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap, BootstrapContext, BootstrapFn } from "./sdk/handlers";
8
8
  export { $identity, $input, $now, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker, isNowMarker } from "./sdk/acl";
9
9
  export type { Action, Identity, IdentityMarker, InputMarker, NowMarker, Policy, PolicyRule, PolicyRules, Role, Validator, WhereRule, ConditionalFields, FieldsFn, RelationAclRule, SetValue, ResolverFn, ResolverContext, ResolverDb, } from "./sdk/acl";
10
- export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, ProjectedRow, RelationsOf, RelationsResult, WhereClause, WhereInput, WhereOps, } from "./sdk/infer";
10
+ export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, JsonObject, SqlValue, CellValue, Row, ProjectedRow, RelationsOf, RelationsResult, WhereClause, WhereInput, WhereOps, } from "./sdk/infer";
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
- export type { Driver, Dialect, Row } from "./runtime/driver";
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/pramen.d.ts CHANGED
@@ -4,13 +4,15 @@ import { type SchemaDef } from "./sdk/schema";
4
4
  import type { AppTaskMap, HandlerMap, BootstrapFn } from "./sdk/handlers";
5
5
  import type { AppQueueMap, QueueBatch } from "./runtime/queue-consumer";
6
6
  import type { Role } from "./sdk/acl";
7
+ import type { EnvBag } from "./sdk/handlers";
8
+ import type { JsonValue } from "./sdk/infer";
7
9
  /** Injected into a public route's handler — forward a privileged mutation into the
8
10
  * tenant's DO without the handler importing any deploy-side code (so app.ts stays
9
11
  * authoring-only). The synthetic identity defaults to the admin role. */
10
12
  export interface RouteContext {
11
13
  callPrivileged(opts: {
12
14
  name: string;
13
- input?: unknown;
15
+ input?: JsonValue;
14
16
  tenant?: string;
15
17
  roles?: string[];
16
18
  }): Promise<Response>;
@@ -25,7 +27,7 @@ export interface PublicRoute {
25
27
  method: string;
26
28
  /** Exact pathname to match (e.g. "/stripe/webhook"). */
27
29
  path: string;
28
- handler: (request: Request, env: Readonly<Record<string, unknown>>, ctx: RouteContext) => Response | Promise<Response>;
30
+ handler: (request: Request, env: EnvBag, ctx: RouteContext) => Response | Promise<Response>;
29
31
  }
30
32
  /** The user-facing app: a schema, the handler map, ACL roles, and optional public
31
33
  * (pre-auth) routes. `example/app.ts` exports this shape. */
@@ -1,6 +1,7 @@
1
- import { type Action, type FieldsFn, type Identity, type PolicyRule, type ResolverDb, type Role, type Validator } from "../sdk/acl";
1
+ import { type Action, type FieldsFn, type Identity, type PolicyRule, type ResolverDb, type Role, type Validator, type WhereRule } from "../sdk/acl";
2
2
  import { type SqlExpr } from "./read-engine";
3
3
  import { PramenError } from "./errors";
4
+ import type { Row } from "../sdk/infer";
4
5
  import type { SchemaDef } from "../sdk/schema";
5
6
  export declare class AclDenied extends PramenError {
6
7
  readonly entity: string;
@@ -26,6 +27,14 @@ export interface AclContext {
26
27
  /** The app schema — lets `where` rules traverse relations (`{ rel: { col } }`),
27
28
  * compiled to a subquery with the related entity's read scope AND-merged in. */
28
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";
29
38
  /** The partition this DO serves. When set, Db rejects any access to a table that
30
39
  * lives in a different partition (a partition-DO only owns its own tables). Unset
31
40
  * (e.g. the D1/Worker shared-store path) disables the guard — a no-op. */
@@ -78,15 +87,15 @@ export declare const MAX_REL_DEPTH = 5;
78
87
  * `allowRelations` is false for single-table contexts (cell-level `when`, which is
79
88
  * evaluated in memory and cannot do a SQL round-trip): a relation key then raises a
80
89
  * clear authoring error instead of emitting a `sub` node that throws at read time. */
81
- export declare function compileScopedWhere(rule: Record<string, unknown>, entity: string, ctx: AclContext, depth?: number, allowRelations?: boolean): SqlExpr;
90
+ export declare function compileScopedWhere(rule: WhereRule, entity: string, ctx: AclContext, depth?: number, allowRelations?: boolean): SqlExpr;
82
91
  export declare function resolveScope(ctx: AclContext, entity: string, action: Action, depth?: number): Scope;
83
92
  /** Effective visible fields for one row = base ∪ matching-conditional ∪ fn-output.
84
93
  * Returns null (all fields) when the base is null or a resolver grants everything. */
85
- export declare function effectiveFields(scope: Scope, row: Record<string, unknown>, identity: Identity | null): string[] | null;
94
+ export declare function effectiveFields(scope: Scope, row: Row, identity: Identity | null): string[] | null;
86
95
  /** Forced values + validators for a write, gathered from matched write policies.
87
96
  * `set` values are resolved against the identity; later policies override earlier. */
88
97
  export interface WriteRules {
89
- set: Record<string, unknown>;
98
+ set: Row;
90
99
  validators: Validator[];
91
100
  }
92
101
  export declare function resolveWriteRules(ctx: AclContext, entity: string, action: Action): WriteRules;
@@ -94,4 +103,4 @@ export declare function resolveWriteRules(ctx: AclContext, entity: string, actio
94
103
  * target's own read scope OR a parent read policy's relation rule with directAccess. */
95
104
  export declare function resolveRelationScope(ctx: AclContext, parentEntity: string, relName: string, target: string): Scope;
96
105
  /** Project a row to the permitted fields. null = all. */
97
- export declare function projectRow(row: Record<string, unknown>, fields: string[] | null): Record<string, unknown>;
106
+ export declare function projectRow(row: Row, fields: string[] | null): Row;
@@ -85,10 +85,6 @@ function getPath(obj, path) {
85
85
  return path.split(".").reduce((acc, seg) => (acc == null ? undefined : acc[seg]), obj ?? undefined);
86
86
  }
87
87
  const UNRESOLVED = Symbol("unresolved");
88
- // Resolve a value that may be an $identity marker (against the caller), an
89
- // $input marker (against the request input — a capability/by-key grant), or a
90
- // $now marker (the evaluation instant). An unresolvable marker yields UNRESOLVED,
91
- // which makes its rule match nothing. $now always resolves.
92
88
  function resolveValue(v, identity, input) {
93
89
  if (isNowMarker(v))
94
90
  return new Date().toISOString();
@@ -180,7 +176,7 @@ function pkOf(schema, entity) {
180
176
  * are skipped: they're re-scoped against THEIR own target's read scope downstream.
181
177
  * Mirrors Db.assertReadableWhere's recursion for the top-level user `where`. */
182
178
  function assertReadableRelationWhere(where, target, fields, ctx) {
183
- const targetRels = (ctx.schema?.[target]?.relations ?? {});
179
+ const targetRels = ctx.schema?.[target]?.relations ?? {};
184
180
  for (const [k, v] of Object.entries(where)) {
185
181
  if (k === "AND" || k === "OR") {
186
182
  for (const g of v)
@@ -1,9 +1,10 @@
1
1
  import { type AclContext } from "./acl";
2
+ import type { CellValue, Row as SharedRow } from "../sdk/infer";
2
3
  import { type AggFn } from "./read-engine";
3
4
  import type { Driver } from "./driver";
4
5
  import { type EntityFields, type SchemaDef } from "../sdk/schema";
5
6
  import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult, WhereClause } from "../sdk/infer";
6
- type Row = Record<string, unknown>;
7
+ type Row = SharedRow;
7
8
  type Id = string | number | bigint;
8
9
  type OrderSpec<S extends SchemaDef, T extends keyof S> = {
9
10
  column: keyof FieldsOf<S[T]> & string;
@@ -215,7 +216,7 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
215
216
  /** Delete a row by id within scope. Returns whether a row was deleted. */
216
217
  delete<T extends keyof S & string>(table: T, id: Id): Promise<boolean>;
217
218
  /** Escape hatch — raw SQL, NOT ACL-checked. For system/internal use only. */
218
- exec(sql: string, ...params: unknown[]): Promise<Row[]>;
219
+ exec(sql: string, ...params: CellValue[]): Promise<Row[]>;
219
220
  private returningClause;
220
221
  private scopeClause;
221
222
  }
@@ -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
+ }
@@ -5,6 +5,8 @@ import type { Kv } from "./kv";
5
5
  import type { Files } from "../sdk/files";
6
6
  import type { SchemaDef } from "../sdk/schema";
7
7
  import { type AppTaskMap, type HandlerContext, type HandlerKind, type HandlerMap, type Tasks } from "../sdk/handlers";
8
+ import type { EnvBag } from "../sdk/handlers";
9
+ import type { JsonValue } from "../sdk/infer";
8
10
  export interface DispatchResult {
9
11
  readonly result: unknown;
10
12
  readonly kind: HandlerKind;
@@ -17,4 +19,4 @@ export interface DispatchResult {
17
19
  export declare function tasksFacade(driver: Driver, onEnqueue?: () => void): Tasks;
18
20
  /** Bind `app.tasks` (which take a ctx) into the ctx-free `TaskMap` the drainer calls. */
19
21
  export declare function bindTasks(appTasks: AppTaskMap | undefined, ctx: HandlerContext): TaskMap;
20
- export declare function dispatch(handlers: HandlerMap, schema: SchemaDef, driver: Driver, kv: Kv, files: Files, env: Readonly<Record<string, unknown>>, acl: AclContext, name: string, input: unknown): Promise<DispatchResult>;
22
+ export declare function dispatch(handlers: HandlerMap, schema: SchemaDef, driver: Driver, kv: Kv, files: Files, env: EnvBag, acl: AclContext, name: string, input: JsonValue): Promise<DispatchResult>;
@@ -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),
@@ -1,4 +1,7 @@
1
- export type Row = Record<string, unknown>;
1
+ /** A raw row exactly as the substrate returns it, before pramen's object↔JSON codec.
2
+ * Distinct from the decoded `Row` handlers see at the `Db` chokepoint. */
3
+ export type DriverRow = Record<string, SqlValue>;
4
+ import type { CellValue, SqlValue } from "../sdk/infer";
2
5
  export interface Dialect {
3
6
  /** Render an identifier (table/column), quoting as the backend requires. */
4
7
  id(name: string): string;
@@ -7,7 +10,7 @@ export interface Dialect {
7
10
  /** Whether INSERT/UPDATE/DELETE ... RETURNING is supported (SQLite/Postgres yes; MySQL no). */
8
11
  readonly returning: boolean;
9
12
  /** Coerce a JS value for binding (e.g. boolean → 0/1 on SQLite). */
10
- encode(v: unknown): unknown;
13
+ encode(v: CellValue): CellValue;
11
14
  }
12
15
  /** Render an identifier as a standard double-quoted name (`"order"`), guarding its
13
16
  * shape first. SQLite (DO SQLite + D1) and Postgres all accept double-quoted
@@ -27,7 +30,7 @@ export interface Driver {
27
30
  readonly dialect: Dialect;
28
31
  /** Run a parameterized statement and return the result rows (empty for writes
29
32
  * without RETURNING). Params are already dialect-encoded by the caller. */
30
- exec(sql: string, params: unknown[]): Promise<Row[]>;
33
+ exec(sql: string, params: CellValue[]): Promise<DriverRow[]>;
31
34
  /** Run `fn` inside a transaction: commit on resolve, roll back on throw. */
32
35
  transaction<T>(fn: () => Promise<T>): Promise<T>;
33
36
  /** Run a fixed sequence of write statements ATOMICALLY with FK checks deferred to the
@@ -46,7 +49,7 @@ export declare class DoSqliteDriver implements Driver {
46
49
  private readonly storage;
47
50
  readonly dialect: Dialect;
48
51
  constructor(storage: DurableObjectStorage);
49
- exec(sql: string, params: unknown[]): Promise<Row[]>;
52
+ exec(sql: string, params: CellValue[]): Promise<DriverRow[]>;
50
53
  transaction<T>(fn: () => Promise<T>): Promise<T>;
51
54
  }
52
55
  /** How a D1Driver's session is anchored (passed to `db.withSession`):
@@ -79,7 +82,7 @@ export declare class D1Driver implements Driver {
79
82
  constructor(db: D1Database, opts?: {
80
83
  start?: D1SessionStart;
81
84
  });
82
- exec(sql: string, params: unknown[]): Promise<Row[]>;
85
+ exec(sql: string, params: CellValue[]): Promise<DriverRow[]>;
83
86
  /** The session's latest bookmark (null before any query). Threaded back to the client
84
87
  * via the `x-pramen-d1-bookmark` response header so a subsequent request can anchor a
85
88
  * fresh session at it and read its own writes. */
@@ -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 { Kv } from "./kv";
2
+ import type { EnvBag } from "../sdk/handlers";
2
3
  export interface MailAddress {
3
4
  email: string;
4
5
  name?: string;
@@ -77,4 +78,4 @@ export declare class UnconfiguredMailAdapter implements MailAdapter {
77
78
  * a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
78
79
  * - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
79
80
  * stash security emails in KV). */
80
- export declare function createMail(env: Readonly<Record<string, unknown>>, kv?: Kv): Mail;
81
+ export declare function createMail(env: EnvBag, kv?: Kv): Mail;
@@ -1,8 +1,9 @@
1
+ import type { JsonValue } from "../sdk/infer";
1
2
  export interface SubscribeMsg {
2
3
  type: "subscribe";
3
4
  id: string;
4
5
  name: string;
5
- input?: unknown;
6
+ input?: JsonValue;
6
7
  }
7
8
  export interface UnsubscribeMsg {
8
9
  type: "unsubscribe";
@@ -12,7 +13,7 @@ export interface CallMsg {
12
13
  type: "call";
13
14
  id: string;
14
15
  name: string;
15
- input?: unknown;
16
+ input?: JsonValue;
16
17
  }
17
18
  export type ClientMsg = SubscribeMsg | UnsubscribeMsg | CallMsg;
18
19
  export type ServerMsg = {
@@ -32,7 +33,7 @@ export type ServerMsg = {
32
33
  export interface Subscription {
33
34
  id: string;
34
35
  name: string;
35
- input: unknown;
36
+ input: JsonValue;
36
37
  /** Tables the query read — the coarse prefilter for which writes might matter. */
37
38
  tables: string[];
38
39
  /** Digest of the last result pushed — used to suppress no-op (row-level) pushes. */
@@ -1,6 +1,8 @@
1
1
  import type { Mail } from "./mail";
2
2
  import type { Queue } from "./queue";
3
3
  import type { Kv } from "./kv";
4
+ import type { EnvBag } from "../sdk/handlers";
5
+ import type { JsonValue } from "../sdk/infer";
4
6
  /** One received message (the Cloudflare Queues `Message` shape). */
5
7
  export interface QueueMessage<Body = unknown> {
6
8
  readonly id: string;
@@ -30,7 +32,7 @@ export interface QueueBatch<Body = unknown> {
30
32
  * tenant data via `ctx.callPrivileged`. */
31
33
  export interface QueueContext {
32
34
  /** The Worker environment (bindings + vars + secrets). */
33
- readonly env: Readonly<Record<string, unknown>>;
35
+ readonly env: EnvBag;
34
36
  /** Project KV (cross-tenant). */
35
37
  readonly kv: Kv;
36
38
  /** Send email (the notification path). */
@@ -41,7 +43,7 @@ export interface QueueContext {
41
43
  * The message body should carry the `tenant`. */
42
44
  callPrivileged(opts: {
43
45
  name: string;
44
- input?: unknown;
46
+ input?: JsonValue;
45
47
  tenant?: string;
46
48
  roles?: string[];
47
49
  partition?: string;
@@ -1,3 +1,4 @@
1
+ import type { EnvBag } from "../sdk/handlers";
1
2
  /** Cloudflare Queues content type for a sent message. Omitted ⇒ the platform default
2
3
  * (v8 structured clone). Use "json" for cross-runtime / external consumers. */
3
4
  export type QueueContentType = "text" | "bytes" | "json" | "v8";
@@ -64,9 +65,9 @@ export declare class MemoryQueueAdapter implements QueueAdapter {
64
65
  /** Discover the Cloudflare Queues producer bindings in an environment: any value that
65
66
  * exposes BOTH `send` and `sendBatch` functions (which excludes the email `send`-only
66
67
  * binding, KV, R2, D1, the DO namespace, …). Returns name → binding. */
67
- export declare function discoverQueueBindings(env: Readonly<Record<string, unknown>>): Record<string, QueueProducerBinding>;
68
+ export declare function discoverQueueBindings(env: EnvBag): Record<string, QueueProducerBinding>;
68
69
  /** Build `ctx.queue` from the environment: a Cloudflare adapter over the discovered
69
70
  * producer bindings. Sending to an undeclared queue fails closed (the adapter throws).
70
71
  * There is no silent capture fallback — declare the `Queue` binding and it exists in
71
72
  * dev (lopata) and miniflare too. */
72
- export declare function createQueue(env: Readonly<Record<string, unknown>>): Queue;
73
+ export declare function createQueue(env: EnvBag): Queue;