@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.
- package/dist/auth.d.ts +17 -2
- package/dist/auth.js +26 -7
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +311 -0
- package/dist/durable-object.d.ts +22 -4
- package/dist/durable-object.js +119 -55
- package/dist/runtime/acl.js +28 -7
- package/dist/runtime/db.d.ts +6 -0
- package/dist/runtime/db.js +86 -10
- package/dist/runtime/ddl.d.ts +16 -3
- package/dist/runtime/ddl.js +23 -4
- package/dist/runtime/migrate.js +213 -20
- package/dist/runtime/outbox.js +28 -6
- package/dist/runtime/queue-consumer.d.ts +9 -2
- package/dist/runtime/queue-consumer.js +23 -6
- package/dist/runtime/read-engine.js +7 -2
- package/dist/runtime/schema-diff.d.ts +28 -5
- package/dist/runtime/schema-diff.js +111 -19
- package/dist/runtime/storage.d.ts +7 -0
- package/dist/runtime/storage.js +0 -0
- package/dist/worker.d.ts +15 -0
- package/dist/worker.js +44 -11
- package/package.json +6 -2
- package/src/auth.ts +64 -21
- package/src/cli.ts +336 -0
- package/src/durable-object.ts +116 -52
- package/src/runtime/acl.ts +25 -5
- package/src/runtime/db.ts +80 -9
- package/src/runtime/ddl.ts +21 -4
- package/src/runtime/migrate.ts +237 -20
- package/src/runtime/outbox.ts +30 -7
- package/src/runtime/queue-consumer.ts +24 -5
- package/src/runtime/read-engine.ts +7 -2
- package/src/runtime/schema-diff.ts +137 -23
- package/src/runtime/storage.ts +0 -0
- package/src/worker.ts +62 -11
|
@@ -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>;
|
package/dist/runtime/storage.js
CHANGED
|
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
|
|
347
|
-
//
|
|
348
|
-
//
|
|
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 (
|
|
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.
|
|
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
|
|
45
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
"
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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();
|