@pramen/server 0.0.14 → 0.0.16

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.
@@ -72,3 +72,10 @@ export declare function handleFileRequest(request: Request, opts: {
72
72
  adapter: StorageAdapter;
73
73
  secret: string;
74
74
  }): Promise<Response | null>;
75
+ /** Serve a PUBLIC media blob by key: `GET /media/<key>`. Cache-friendly + nosniff, no
76
+ * auth (published-site assets are public; the random tenant-scoped key is the capability).
77
+ * Returns a Response for any `/media/*` path, or null if not a media request. Restricted
78
+ * to `<tenant>/media/` keys so it can't serve arbitrary (e.g. signed-private) objects. */
79
+ export declare function handleMediaRequest(request: Request, opts: {
80
+ adapter: StorageAdapter;
81
+ }): Promise<Response | null>;
Binary file
package/dist/worker.d.ts CHANGED
@@ -11,6 +11,16 @@ export interface Env {
11
11
  /** Optional: a JWKS endpoint. When set, tokens are verified as RS256 against the
12
12
  * fetched public keys (HmacStrategy/AUTH_SECRET is bypassed). */
13
13
  JWKS_URL?: string;
14
+ /** "true" to REJECT any bearer token with no numeric `exp` claim. Off by default
15
+ * (a token without exp is accepted) so existing issuers keep working; turn it on to
16
+ * refuse non-expiring tokens. Applies to both the HS256 and JWKS strategies. */
17
+ AUTH_REQUIRE_EXP?: string;
18
+ /** Optional required audience. When set, a token's `aud` (string or array) must
19
+ * contain this value or the token is rejected. Unset ⇒ `aud` is not checked. */
20
+ AUTH_AUDIENCE?: string;
21
+ /** Optional required issuer. When set, a token's `iss` must equal this exactly.
22
+ * Unset ⇒ `iss` is not checked. */
23
+ AUTH_ISSUER?: string;
14
24
  /** D1 binding. Enables the "Worker + D1 (no DO)" path — the same schema/ACL/read
15
25
  * engine over D1 instead of a Durable Object. Selected per-request via
16
26
  * `x-pramen-store: d1`. RPC only (live queries need the DO). */
@@ -30,6 +40,11 @@ export interface Env {
30
40
  * per-tenant Durable Object. The header still overrides per-request. /live always
31
41
  * needs the DO regardless of this setting. */
32
42
  PRAMEN_STORE?: string;
43
+ /** "true" to allow the shared D1 store to serve a non-`main` tenant. OFF by default:
44
+ * the D1 proof uses ONE database with no tenant column, so multiple tenants would
45
+ * commingle. Only set this if the app genuinely single-tenants that D1 (or has added
46
+ * its own tenant isolation). */
47
+ PRAMEN_D1_ALLOW_MULTITENANT?: string;
33
48
  /** Cloudflare Queues producer binding for ctx.queue (declared in oblaka.ts). Optional —
34
49
  * ctx.queue discovers any producer binding by name; this just types the common one. */
35
50
  JOBS?: QueueProducerBinding;
package/dist/worker.js CHANGED
@@ -16,8 +16,8 @@ import { D1Driver } from "./runtime/driver";
16
16
  import { toResponse } from "./runtime/errors";
17
17
  import { Kv } from "./runtime/kv";
18
18
  import { listDOs, partitionDoName } from "./runtime/registry";
19
- import { createFiles, handleFileRequest, R2Adapter } from "./runtime/storage";
20
- import { DEFAULT_PARTITION } from "./sdk/schema";
19
+ import { createFiles, handleFileRequest, handleMediaRequest, R2Adapter } from "./runtime/storage";
20
+ import { DEFAULT_PARTITION, partitionsOf } from "./sdk/schema";
21
21
  /** The secret used to sign/verify file tokens — a dedicated FILES_SECRET if set,
22
22
  * else AUTH_SECRET (so HS256 setups work out of the box). */
23
23
  const filesSecret = (env) => env.FILES_SECRET || env.AUTH_SECRET;
@@ -109,13 +109,21 @@ export function makeWorker(app) {
109
109
  // JwksStrategy caches fetched public keys, so keep one instance per isolate (keyed
110
110
  // by URL) rather than rebuilding it per request. HmacStrategy is stateless.
111
111
  let jwks;
112
+ // Opt-in claim validation from env — default OFF (unset) so existing tokens keep
113
+ // verifying. Threaded into whichever strategy the deployment uses.
114
+ const verifyOptsFor = (env) => ({
115
+ requireExp: env.AUTH_REQUIRE_EXP === "true",
116
+ audience: env.AUTH_AUDIENCE || undefined,
117
+ issuer: env.AUTH_ISSUER || undefined,
118
+ });
112
119
  const strategyFor = (env) => {
120
+ const opts = verifyOptsFor(env);
113
121
  if (env.JWKS_URL) {
114
122
  if (!jwks || jwks.url !== env.JWKS_URL)
115
- jwks = new JwksStrategy(env.JWKS_URL);
123
+ jwks = new JwksStrategy(env.JWKS_URL, undefined, opts);
116
124
  return jwks;
117
125
  }
118
- return new HmacStrategy(env.AUTH_SECRET);
126
+ return new HmacStrategy(env.AUTH_SECRET, opts);
119
127
  };
120
128
  // ACL is compiled once per isolate; the Worker's D1 path reuses it (the DO compiles
121
129
  // its own). Schema migration over D1 runs once per isolate (and short-circuits on a
@@ -173,6 +181,14 @@ export function makeWorker(app) {
173
181
  if (res)
174
182
  return res;
175
183
  }
184
+ // Public media serving: `GET /media/<tenant>/media/<key>` streams a CMS media blob
185
+ // from R2 (cache-friendly, no auth — published-site assets are public). Put Cloudflare
186
+ // Image Resizing (/cdn-cgi/image) in front for transforms. Restricted to media keys.
187
+ if (url.pathname.startsWith("/media/") && env.FILES) {
188
+ const res = await handleMediaRequest(request, { adapter: new R2Adapter(env.FILES) });
189
+ if (res)
190
+ return res;
191
+ }
176
192
  // Public (pre-auth) routes — matched before identity resolution, so a
177
193
  // signature-authed webhook can live outside the JWT-gated /rpc surface.
178
194
  for (const r of app.routes ?? []) {
@@ -338,21 +354,29 @@ export function makeWorker(app) {
338
354
  if (useD1) {
339
355
  if (!env.DB)
340
356
  return badRequest("D1 store is not configured");
357
+ // COMMINGLING GUARD: this D1 path is ONE shared database with no tenant column, so
358
+ // every tenant's rows live together. Selecting it for a non-`main` tenant (a
359
+ // multi-tenant scenario) would leak/mix tenants — and `PRAMEN_STORE=d1` makes it a
360
+ // silent global default. Fail closed unless the operator explicitly opts in.
361
+ if (tenant !== "main" && env.PRAMEN_D1_ALLOW_MULTITENANT !== "true") {
362
+ return withCors(forbidden(`D1 store for tenant '${tenant}' (shared D1 has no tenant isolation — set PRAMEN_D1_ALLOW_MULTITENANT=true to allow)`), cors);
363
+ }
341
364
  // (isLive is excluded by useD1Store — live always routes to the DO below.)
342
365
  const name = url.pathname.replace(/^\/rpc\//, "");
343
366
  let input;
344
367
  if (request.method === "POST")
345
368
  input = await request.json().catch(() => undefined);
346
- // Pick where the D1 session may start its first read. A client-supplied bookmark
347
- // wins (read-your-writes); otherwise default by handler kind: a mutation pins the
348
- // primary so its reads see current data, a query may begin at the nearest replica.
369
+ // Pick where the D1 session may start its first read. A mutation ALWAYS pins the
370
+ // primary (`first-primary` is a superset of read-your-writes) so a read-modify-write
371
+ // can't run off a lagging replica an inbound bookmark must not widen that window.
372
+ // A query honors a client-supplied bookmark (read-your-writes), else the nearest replica.
349
373
  const inboundBookmark = req.headers.get(D1_BOOKMARK_HEADER);
350
374
  const kind = app.handlers[name]?.kind;
351
375
  let start;
352
- if (inboundBookmark)
353
- start = inboundBookmark;
354
- else if (kind === "mutation")
376
+ if (kind === "mutation")
355
377
  start = "first-primary";
378
+ else if (inboundBookmark)
379
+ start = inboundBookmark;
356
380
  else
357
381
  start = "first-unconstrained";
358
382
  const driver = new D1Driver(env.DB, { start });
@@ -382,14 +406,23 @@ export function makeWorker(app) {
382
406
  partition = app.handlers[name]?.partition ?? DEFAULT_PARTITION;
383
407
  }
384
408
  else {
409
+ // /live's partition is client-supplied (?partition= / x-pramen-partition), so
410
+ // validate it against the schema's known partitions BEFORE routing — otherwise an
411
+ // anonymous caller could spin up unbounded junk DOs + permanent registry KV keys.
385
412
  partition = req.headers.get("x-pramen-partition") || DEFAULT_PARTITION;
413
+ if (!partitionsOf(app.schema).includes(partition)) {
414
+ return withCors(badRequest(`unknown partition '${partition}'`), cors);
415
+ }
386
416
  }
387
- // Forward a trusted identity to the DO (the DO never re-derives it).
417
+ // Forward a trusted identity to the DO (the DO never re-derives it). Also set the
418
+ // tenant header so the DO learns its own name — without it, `main` (the default when
419
+ // the client omits x-pramen-tenant) never registers and re-runs its guard forever.
388
420
  const headers = new Headers(req.headers);
389
421
  if (identity)
390
422
  headers.set("x-pramen-identity", JSON.stringify(identity));
391
423
  else
392
424
  headers.delete("x-pramen-identity");
425
+ headers.set("x-pramen-tenant", tenant);
393
426
  headers.set("x-pramen-partition", partition);
394
427
  // Routed to the DO but no DO is bound — return a clear, actionable error instead of
395
428
  // crashing the whole RPC surface. (A D1-only deployment should pin the D1 store with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/server",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "description": "pramen server runtime — schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,6 +30,9 @@
30
30
  },
31
31
  "main": "./dist/index.js",
32
32
  "types": "./dist/index.d.ts",
33
+ "bin": {
34
+ "pramen": "./dist/cli.js"
35
+ },
33
36
  "files": ["dist", "src"],
34
37
  "scripts": {
35
38
  "build": "rm -rf dist && tsc -p tsconfig.build.json"
@@ -38,6 +41,7 @@
38
41
  "access": "public"
39
42
  },
40
43
  "devDependencies": {
41
- "@cloudflare/workers-types": "^4.20250101.0"
44
+ "@cloudflare/workers-types": "^4.20250101.0",
45
+ "@types/node": "^22.0.0"
42
46
  }
43
47
  }
package/src/auth.ts CHANGED
@@ -40,9 +40,36 @@ export interface VerifyStrategy {
40
40
  /** Verify a signature over `${header}.${payload}` for the parsed header. */
41
41
  type SignatureVerifier = (signingInput: string, signature: Uint8Array, header: JwtHeader) => Promise<boolean>;
42
42
 
43
+ /** Optional, opt-in claim validation layered on top of signature + exp/nbf. All
44
+ * default OFF (unset) so existing tokens keep verifying; a deployment turns these on
45
+ * to tighten what it accepts. Shared by every strategy (they all run verifyJwt). */
46
+ export interface VerifyOptions {
47
+ /** Reject a token that has no numeric `exp` (RFC 7519 leaves exp optional; a strict
48
+ * deployment can require it so no non-expiring token is ever accepted). */
49
+ requireExp?: boolean;
50
+ /** Required audience. `payload.aud` (string or string[]) must contain at least one of
51
+ * these; a missing `aud` is rejected. Unset ⇒ `aud` not checked. */
52
+ audience?: string | string[];
53
+ /** Required issuer. `payload.iss` must equal this exactly. Unset ⇒ `iss` not checked. */
54
+ issuer?: string;
55
+ }
56
+
57
+ /** Does the token's `aud` claim satisfy the required audience? Token aud may be a
58
+ * string or an array; a match is any overlap with the expected audience(s). */
59
+ function audienceMatches(aud: unknown, expected: string | string[]): boolean {
60
+ const claim = Array.isArray(aud) ? aud.filter((a): a is string => typeof a === "string") : typeof aud === "string" ? [aud] : [];
61
+ const want = Array.isArray(expected) ? expected : [expected];
62
+ return want.some((w) => claim.includes(w));
63
+ }
64
+
43
65
  // Shared JWT pipeline: parse, verify the signature via the supplied function, then
44
- // validate exp/nbf. Any malformed part or a verification throw -> null (reject).
45
- async function verifyJwt(token: string, verifySignature: SignatureVerifier): Promise<Record<string, unknown> | null> {
66
+ // validate exp/nbf and the opt-in exp-required/aud/iss claims. Any malformed part or a
67
+ // verification throw -> null (reject).
68
+ async function verifyJwt(
69
+ token: string,
70
+ verifySignature: SignatureVerifier,
71
+ opts: VerifyOptions = {},
72
+ ): Promise<Record<string, unknown> | null> {
46
73
  const parts = token.split(".");
47
74
  if (parts.length !== 3) return null;
48
75
  const [h, p, sig] = parts;
@@ -70,27 +97,38 @@ async function verifyJwt(token: string, verifySignature: SignatureVerifier): Pro
70
97
  }
71
98
 
72
99
  const now = Math.floor(Date.now() / 1000);
73
- if (typeof payload.exp === "number" && now >= payload.exp) return null;
100
+ const hasExp = typeof payload.exp === "number";
101
+ if (opts.requireExp && !hasExp) return null;
102
+ if (hasExp && now >= (payload.exp as number)) return null;
74
103
  if (typeof payload.nbf === "number" && now < payload.nbf) return null;
104
+ if (opts.audience !== undefined && !audienceMatches(payload.aud, opts.audience)) return null;
105
+ if (opts.issuer !== undefined && payload.iss !== opts.issuer) return null;
75
106
  return payload;
76
107
  }
77
108
 
78
109
  /** HS256 via a shared secret. The dev/default strategy. */
79
110
  export class HmacStrategy implements VerifyStrategy {
80
- constructor(private readonly secret: string) {}
111
+ constructor(
112
+ private readonly secret: string,
113
+ private readonly opts: VerifyOptions = {},
114
+ ) {}
81
115
 
82
116
  verify(token: string): Promise<Record<string, unknown> | null> {
83
- return verifyJwt(token, async (input, signature, header) => {
84
- if (header.alg !== "HS256" || !this.secret) return false;
85
- const key = await crypto.subtle.importKey(
86
- "raw",
87
- new TextEncoder().encode(this.secret),
88
- { name: "HMAC", hash: "SHA-256" },
89
- false,
90
- ["verify"],
91
- );
92
- return crypto.subtle.verify("HMAC", key, signature, new TextEncoder().encode(input));
93
- });
117
+ return verifyJwt(
118
+ token,
119
+ async (input, signature, header) => {
120
+ if (header.alg !== "HS256" || !this.secret) return false;
121
+ const key = await crypto.subtle.importKey(
122
+ "raw",
123
+ new TextEncoder().encode(this.secret),
124
+ { name: "HMAC", hash: "SHA-256" },
125
+ false,
126
+ ["verify"],
127
+ );
128
+ return crypto.subtle.verify("HMAC", key, signature, new TextEncoder().encode(input));
129
+ },
130
+ this.opts,
131
+ );
94
132
  }
95
133
  }
96
134
 
@@ -111,15 +149,20 @@ export class JwksStrategy implements VerifyStrategy {
111
149
  constructor(
112
150
  readonly url: string,
113
151
  private readonly ttlMs = 600_000,
152
+ private readonly opts: VerifyOptions = {},
114
153
  ) {}
115
154
 
116
155
  verify(token: string): Promise<Record<string, unknown> | null> {
117
- return verifyJwt(token, async (input, signature, header) => {
118
- if (header.alg !== "RS256") return false;
119
- const key = await this.keyFor(header.kid);
120
- if (!key) return false;
121
- return crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, new TextEncoder().encode(input));
122
- });
156
+ return verifyJwt(
157
+ token,
158
+ async (input, signature, header) => {
159
+ if (header.alg !== "RS256") return false;
160
+ const key = await this.keyFor(header.kid);
161
+ if (!key) return false;
162
+ return crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, new TextEncoder().encode(input));
163
+ },
164
+ this.opts,
165
+ );
123
166
  }
124
167
 
125
168
  private lookup(kid?: string): CryptoKey | null {
package/src/cli.ts ADDED
@@ -0,0 +1,336 @@
1
+ #!/usr/bin/env bun
2
+ // pramen CLI — ships as the `pramen` bin of @pramen/server (see package.json `bin`).
3
+ // In-repo it's invoked via `bun run pramen <command>` (scripts/cli.ts is a thin
4
+ // wrapper around this module); published consumers get it as `pramen <command>`.
5
+ //
6
+ // pramen help
7
+ // pramen init [dir]
8
+ // pramen schema sql print CREATE TABLE for the schema
9
+ // pramen schema hash print the schema hash
10
+ // pramen schema snapshot save the current schema to .pramen/schema.json
11
+ // pramen schema diff compare the schema to the snapshot (safe vs unsafe changes)
12
+ // pramen schema status [--tenant t] [--url u] [--token jwt] compare a deployed tenant to the schema
13
+ // pramen token <sub> [roles...] [--tenant a,b] mint a dev JWT
14
+ //
15
+ // The bin uses a `bun` shebang: the `schema *` commands import your app module (a .ts
16
+ // file), and the built package's dist/ uses extensionless ESM imports — both of which
17
+ // bun resolves out of the box. Under plain Node the extensionless imports don't resolve
18
+ // (a property of the whole @pramen/server dist, not just this file), so run via bun.
19
+
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { dirname, resolve } from "node:path";
22
+ import { createTableSql } from "./runtime/ddl";
23
+ import { schemaHash } from "./runtime/migrate";
24
+ import { diffSchemaShape, schemaShape, type SchemaShape } from "./runtime/schema-diff";
25
+ import { entitiesInPartition, partitionsOf, type SchemaDef } from "./sdk/schema";
26
+
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
+ 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
+ }
55
+
56
+ /** The sub-schema of a single partition (used to mirror the DO's per-partition hash,
57
+ * which migrate() computes over exactly this subset). For a single-partition app the
58
+ * subset equals the whole schema, so the hash is identical to the unpartitioned case. */
59
+ function partitionSchema(schema: SchemaDef, partition: string): SchemaDef {
60
+ return Object.fromEntries(entitiesInPartition(schema, partition).map((t) => [t, schema[t]!]));
61
+ }
62
+
63
+ const argv = process.argv.slice(2);
64
+
65
+ function flag(name: string): string | undefined {
66
+ const i = argv.indexOf(`--${name}`);
67
+ return i >= 0 ? argv[i + 1] : undefined;
68
+ }
69
+ function positionals(args: string[]): string[] {
70
+ const out: string[] = [];
71
+ for (let i = 0; i < args.length; i++) {
72
+ if (args[i]!.startsWith("--")) i++; // skip flag + its value
73
+ else out.push(args[i]!);
74
+ }
75
+ return out;
76
+ }
77
+
78
+ function fail(msg: string): never {
79
+ console.error(`pramen: ${msg}`);
80
+ process.exit(1);
81
+ }
82
+
83
+ async function loadApp(): Promise<{ schema: SchemaDef }> {
84
+ const explicit = flag("app");
85
+ const candidates = explicit ? [explicit] : ["./app.ts", "./example/app.ts"];
86
+ for (const c of candidates) {
87
+ const p = resolve(process.cwd(), c);
88
+ if (existsSync(p)) {
89
+ const mod = (await import(p)) as { app?: { schema?: SchemaDef } };
90
+ if (mod.app?.schema) return mod.app as { schema: SchemaDef };
91
+ fail(`${c} does not export { app }`);
92
+ }
93
+ }
94
+ return fail(`no app found (looked for ${candidates.join(", ")}); pass --app <path>`);
95
+ }
96
+
97
+ const HELP = `pramen — reactive backend on Cloudflare
98
+
99
+ Usage: pramen <command>
100
+
101
+ help show this help
102
+ init [dir] scaffold a new project (app.ts + worker.ts + oblaka.ts)
103
+ schema sql print CREATE TABLE statements for the schema
104
+ schema hash print the schema hash
105
+ schema snapshot save the schema shape to .pramen/schema.json
106
+ schema diff compare the schema to the snapshot (safe vs unsafe)
107
+ schema status compare a deployed tenant's schema to the local schema
108
+ [--tenant t] [--url u] [--token jwt]
109
+ token <sub> [roles...] mint a dev JWT [--tenant a,b]
110
+
111
+ Flags: --app <path> to point at your app module (default ./app.ts or ./example/app.ts).`;
112
+
113
+ async function schemaCmd(sub: string | undefined): Promise<void> {
114
+ const snapshotPath = resolve(process.cwd(), ".pramen/schema.json");
115
+
116
+ if (sub === "sql") {
117
+ const { schema } = await loadApp();
118
+ for (const [table, def] of Object.entries(schema)) console.log(createTableSql(table, def) + ";");
119
+ return;
120
+ }
121
+ if (sub === "hash") {
122
+ const { schema } = await loadApp();
123
+ console.log(schemaHash(schema));
124
+ return;
125
+ }
126
+ if (sub === "snapshot") {
127
+ const { schema } = await loadApp();
128
+ mkdirSync(dirname(snapshotPath), { recursive: true });
129
+ const snap = { hash: schemaHash(schema), shape: schemaShape(schema) };
130
+ writeFileSync(snapshotPath, JSON.stringify(snap, null, 2) + "\n");
131
+ console.log(`wrote ${snapshotPath} (${Object.keys(snap.shape).length} tables)`);
132
+ return;
133
+ }
134
+ if (sub === "diff") {
135
+ const { schema } = await loadApp();
136
+ const next = schemaShape(schema);
137
+ if (!existsSync(snapshotPath)) {
138
+ console.log("no snapshot — run `pramen schema snapshot` to set a baseline.");
139
+ return;
140
+ }
141
+ const prev = (JSON.parse(readFileSync(snapshotPath, "utf8")) as { shape: SchemaShape }).shape;
142
+ const changes = diffSchemaShape(prev, next);
143
+ if (changes.length === 0) {
144
+ console.log("no changes since snapshot.");
145
+ return;
146
+ }
147
+ for (const c of changes) {
148
+ const where = c.column ? `${c.table}.${c.column}` : c.table;
149
+ const note = c.detail ? ` (${c.detail})` : "";
150
+ const glyph = c.destructive ? "⚠" : !c.appliesOnBoot ? "•" : "+";
151
+ const tags =
152
+ (c.destructive ? " [destructive]" : "") + (!c.appliesOnBoot ? " [NOT applied on boot]" : "");
153
+ console.log(` ${glyph} ${c.kind} ${where}${note}${tags}`);
154
+ }
155
+ console.log(
156
+ "\nOn the next DO boot: additive changes (new table/column) auto-apply; destructive changes\n" +
157
+ "(drops, type changes, table rebuilds) are SKIPPED unless the deploy sets PRAMEN_ALLOW_DESTRUCTIVE=true\n" +
158
+ "(the schema hash is then left unwritten so a later opt-in deploy retries).",
159
+ );
160
+ if (changes.some((c) => c.destructive))
161
+ console.log(
162
+ "⚠ destructive changes rebuild the table and CAN lose data. A drop+add is applied as such unless\n" +
163
+ " the column declares `renamedFrom` (which migrates the data).",
164
+ );
165
+ if (changes.some((c) => !c.appliesOnBoot))
166
+ console.log(
167
+ "• [NOT applied on boot] a partition move — the entity's data lives in a different Durable Object,\n" +
168
+ " which needs a manual cross-DO data migration. The boot migrator won't move it; apply it yourself.\n" +
169
+ " (Modifier/constraint changes on an existing column ARE applied on boot now — a tightening one\n" +
170
+ " like adding NOT NULL/UNIQUE is gated by PRAMEN_ALLOW_DESTRUCTIVE and skipped if it can't apply.)",
171
+ );
172
+ return;
173
+ }
174
+ if (sub === "status") {
175
+ const { schema } = await loadApp();
176
+ const url = flag("url") ?? "http://localhost:8787";
177
+ const tenant = flag("tenant") ?? "main";
178
+ const token = flag("token") ?? (await sign({ sub: "cli", roles: ["admin"] }));
179
+ // Each partition is a distinct Durable Object class, migrated and hashed
180
+ // independently — so compare them one at a time, fetching each partition's applied
181
+ // schema from its DO. A single-(default-)partition app loops exactly once and reads
182
+ // identically to before. The default partition is addressed with no partition param.
183
+ const partitions = partitionsOf(schema);
184
+ console.log(`tenant: ${tenant}`);
185
+ for (const partition of partitions) {
186
+ if (partitions.length > 1) console.log(`\npartition: ${partition}`);
187
+ const qs = partition === "default" ? "" : `&partition=${encodeURIComponent(partition)}`;
188
+ const res = await fetch(`${url}/admin/schema?tenant=${encodeURIComponent(tenant)}${qs}`, {
189
+ headers: { authorization: `Bearer ${token}` },
190
+ });
191
+ const body = (await res.json().catch(() => ({}))) as {
192
+ ok?: boolean;
193
+ result?: { hash: string | null; tables: Record<string, string[]> };
194
+ error?: string;
195
+ };
196
+ if (!res.ok || !body.ok || !body.result) fail(`status failed (partition ${partition}): ${body.error ?? res.status}`);
197
+ const live = body.result!;
198
+ // The DO hashes only its partition's entities — mirror that here so the compared
199
+ // hashes line up (for a single partition this equals the whole-schema hash).
200
+ const subset = partitionSchema(schema, partition);
201
+ const current = schemaHash(subset);
202
+ const upToDate = live.hash === current;
203
+ console.log(`live: ${live.hash ?? "(none)"}`);
204
+ console.log(`current: ${current}`);
205
+ console.log(upToDate ? "✓ up to date" : "⚠ BEHIND — migrates on the tenant's next boot");
206
+ const want = schemaShape(subset);
207
+ for (const table of Object.keys(want)) {
208
+ const liveCols = new Set(live.tables[table] ?? []);
209
+ const missing = Object.keys(want[table]!.columns).filter((col) => !liveCols.has(col));
210
+ if (!live.tables[table]) console.log(` + table ${table} (not yet created live)`);
211
+ else if (missing.length) console.log(` + ${table}: ${missing.join(", ")} (not yet added live)`);
212
+ }
213
+ }
214
+ return;
215
+ }
216
+ console.log(HELP);
217
+ }
218
+
219
+ async function tokenCmd(args: string[]): Promise<void> {
220
+ const pos = positionals(args);
221
+ const sub = pos[0];
222
+ if (!sub) fail("token: <sub> required");
223
+ const roles = pos.slice(1);
224
+ const tenants = flag("tenant")?.split(",");
225
+ console.log(await sign({ sub, roles: roles.length ? roles : ["admin"], ...(tenants ? { tenants } : {}) }));
226
+ }
227
+
228
+ function initCmd(args: string[]): void {
229
+ const dir = resolve(process.cwd(), positionals(args)[0] ?? ".");
230
+ mkdirSync(dir, { recursive: true });
231
+ const write = (name: string, content: string) => {
232
+ const p = resolve(dir, name);
233
+ if (existsSync(p)) return void console.log(` skip ${name} (exists)`);
234
+ writeFileSync(p, content);
235
+ console.log(` + ${name}`);
236
+ };
237
+ write("app.ts", APP_TEMPLATE);
238
+ write("worker.ts", WORKER_TEMPLATE);
239
+ write("oblaka.ts", OBLAKA_TEMPLATE);
240
+ console.log(`\nScaffolded a pramen project in ${dir}.\n`);
241
+ console.log("Next steps:");
242
+ console.log(" 1. Install deps: bun add @pramen/server && bun add -d oblaka-iac wrangler");
243
+ console.log(" 2. Generate config: bunx oblaka oblaka.ts (writes wrangler.jsonc)");
244
+ console.log(" 3. Run locally: bunx wrangler dev (serves http://localhost:8787)");
245
+ console.log("");
246
+ console.log("First request: POST http://localhost:8787/rpc/listNotes returns [] (not 403) —");
247
+ console.log("the scaffold's ACL grants the anonymous role read+create on `notes`. Tighten it");
248
+ console.log("in app.ts before shipping (see the comments there).");
249
+ }
250
+
251
+ const APP_TEMPLATE = `import { Entity, defineSchema, createApp, role, policy, allow } from "@pramen/server";
252
+
253
+ const schema = defineSchema({
254
+ notes: Entity((t) => ({ id: t.id(), title: t.text(), body: t.text(), createdAt: t.int() })),
255
+ });
256
+
257
+ const { query, mutation } = createApp(schema);
258
+
259
+ const handlers = {
260
+ listNotes: query((ctx) => ctx.db.find({ from: "notes", orderBy: { column: "id", dir: "desc" } })),
261
+ createNote: mutation((ctx, input: { title: string; body: string }) =>
262
+ ctx.db.insert("notes", { title: input.title, body: input.body, createdAt: Date.now() }),
263
+ ),
264
+ };
265
+
266
+ // ACL — deny-by-default; roles only GRANT. A caller with no verified token is the
267
+ // \`anonymous\` role, so this grants the scaffold's handlers on the first request (no
268
+ // token needed). TIGHTEN THIS before shipping: gate writes behind an authenticated
269
+ // role and scope reads with \`$identity(...)\` (see @pramen/auth and the pramen docs).
270
+ const acl = [
271
+ role("anonymous", [
272
+ policy("anon:notes:read", "notes", "read", allow()),
273
+ policy("anon:notes:create", "notes", "create", allow()),
274
+ ]),
275
+ ];
276
+
277
+ export const app = { schema, handlers, acl };
278
+ `;
279
+
280
+ const WORKER_TEMPLATE = `// The whole server entry: hand your app to createPramen and re-export the pair.
281
+ import { createPramen } from "@pramen/server/worker";
282
+ import { app } from "./app";
283
+
284
+ const pramen = createPramen(app);
285
+
286
+ export default { fetch: pramen.fetch };
287
+ export const PramenDO = pramen.PramenDO; // wrangler binds this by class_name
288
+ `;
289
+
290
+ const OBLAKA_TEMPLATE = `import { define, DurableObject, KVNamespace, R2Bucket, Worker } from "oblaka-iac";
291
+
292
+ const PROJECT = "my-pramen-app"; // unique per project — namespaces all CF resources
293
+
294
+ export default define(({ env }) => {
295
+ const vars =
296
+ env === "local" ? { AUTH_SECRET: "dev-secret-change-me", FILES_SECRET: "dev-files-secret-change-me" } : {};
297
+ return new Worker({
298
+ dir: ".",
299
+ name: PROJECT,
300
+ main: "./worker.ts",
301
+ compatibility_date: "2026-06-19",
302
+ compatibility_flags: ["nodejs_compat"],
303
+ observability: { enabled: true },
304
+ bindings: {
305
+ PRAMEN: new DurableObject({ name: PROJECT + "-store", className: "PramenDO" }),
306
+ KV: new KVNamespace({ name: PROJECT + "-kv" }),
307
+ FILES: new R2Bucket({ name: PROJECT + "-files" }),
308
+ },
309
+ vars,
310
+ });
311
+ });
312
+ `;
313
+
314
+ async function main(): Promise<void> {
315
+ const cmd = argv[0];
316
+ switch (cmd) {
317
+ case undefined:
318
+ case "help":
319
+ case "-h":
320
+ case "--help":
321
+ console.log(HELP);
322
+ return;
323
+ case "init":
324
+ return initCmd(argv.slice(1));
325
+ case "schema":
326
+ return schemaCmd(argv[1]);
327
+ case "token":
328
+ return tokenCmd(argv.slice(1));
329
+ default:
330
+ console.error(`pramen: unknown command "${cmd}"\n`);
331
+ console.log(HELP);
332
+ process.exit(1);
333
+ }
334
+ }
335
+
336
+ await main();