@pramen/server 0.0.13 → 0.0.15
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 +121 -55
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/pramen.d.ts +6 -0
- package/dist/pramen.js +1 -1
- 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 +28 -8
- package/dist/runtime/dispatch.js +2 -0
- package/dist/runtime/driver.d.ts +41 -7
- package/dist/runtime/driver.js +38 -11
- package/dist/runtime/migrate.d.ts +1 -1
- package/dist/runtime/migrate.js +222 -33
- package/dist/runtime/outbox.js +28 -6
- package/dist/runtime/queue-consumer.d.ts +71 -0
- package/dist/runtime/queue-consumer.js +63 -0
- package/dist/runtime/queue.d.ts +72 -0
- package/dist/runtime/queue.js +110 -0
- 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/sdk/handlers.d.ts +7 -0
- package/dist/worker.d.ts +36 -0
- package/dist/worker.js +128 -18
- package/package.json +6 -2
- package/src/auth.ts +64 -21
- package/src/cli.ts +336 -0
- package/src/durable-object.ts +118 -52
- package/src/index.ts +6 -0
- package/src/pramen.ts +7 -1
- package/src/runtime/acl.ts +25 -5
- package/src/runtime/db.ts +80 -9
- package/src/runtime/ddl.ts +26 -8
- package/src/runtime/dispatch.ts +2 -0
- package/src/runtime/driver.ts +52 -9
- package/src/runtime/migrate.ts +246 -34
- package/src/runtime/outbox.ts +30 -7
- package/src/runtime/queue-consumer.ts +116 -0
- package/src/runtime/queue.ts +155 -0
- 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/sdk/handlers.ts +7 -0
- package/src/worker.ts +162 -19
package/dist/auth.d.ts
CHANGED
|
@@ -4,10 +4,24 @@ import type { Identity } from "./sdk/acl";
|
|
|
4
4
|
export interface VerifyStrategy {
|
|
5
5
|
verify(token: string): Promise<Record<string, unknown> | null>;
|
|
6
6
|
}
|
|
7
|
+
/** Optional, opt-in claim validation layered on top of signature + exp/nbf. All
|
|
8
|
+
* default OFF (unset) so existing tokens keep verifying; a deployment turns these on
|
|
9
|
+
* to tighten what it accepts. Shared by every strategy (they all run verifyJwt). */
|
|
10
|
+
export interface VerifyOptions {
|
|
11
|
+
/** Reject a token that has no numeric `exp` (RFC 7519 leaves exp optional; a strict
|
|
12
|
+
* deployment can require it so no non-expiring token is ever accepted). */
|
|
13
|
+
requireExp?: boolean;
|
|
14
|
+
/** Required audience. `payload.aud` (string or string[]) must contain at least one of
|
|
15
|
+
* these; a missing `aud` is rejected. Unset ⇒ `aud` not checked. */
|
|
16
|
+
audience?: string | string[];
|
|
17
|
+
/** Required issuer. `payload.iss` must equal this exactly. Unset ⇒ `iss` not checked. */
|
|
18
|
+
issuer?: string;
|
|
19
|
+
}
|
|
7
20
|
/** HS256 via a shared secret. The dev/default strategy. */
|
|
8
21
|
export declare class HmacStrategy implements VerifyStrategy {
|
|
9
22
|
private readonly secret;
|
|
10
|
-
|
|
23
|
+
private readonly opts;
|
|
24
|
+
constructor(secret: string, opts?: VerifyOptions);
|
|
11
25
|
verify(token: string): Promise<Record<string, unknown> | null>;
|
|
12
26
|
}
|
|
13
27
|
/** RS256 verified against a remote JWKS. Public keys are fetched once and cached
|
|
@@ -16,10 +30,11 @@ export declare class HmacStrategy implements VerifyStrategy {
|
|
|
16
30
|
export declare class JwksStrategy implements VerifyStrategy {
|
|
17
31
|
readonly url: string;
|
|
18
32
|
private readonly ttlMs;
|
|
33
|
+
private readonly opts;
|
|
19
34
|
private keys;
|
|
20
35
|
private fetchedAt;
|
|
21
36
|
private inflight;
|
|
22
|
-
constructor(url: string, ttlMs?: number);
|
|
37
|
+
constructor(url: string, ttlMs?: number, opts?: VerifyOptions);
|
|
23
38
|
verify(token: string): Promise<Record<string, unknown> | null>;
|
|
24
39
|
private lookup;
|
|
25
40
|
private keyFor;
|
package/dist/auth.js
CHANGED
|
@@ -21,9 +21,17 @@ function b64urlToBytes(s) {
|
|
|
21
21
|
function b64urlToString(s) {
|
|
22
22
|
return new TextDecoder().decode(b64urlToBytes(s));
|
|
23
23
|
}
|
|
24
|
+
/** Does the token's `aud` claim satisfy the required audience? Token aud may be a
|
|
25
|
+
* string or an array; a match is any overlap with the expected audience(s). */
|
|
26
|
+
function audienceMatches(aud, expected) {
|
|
27
|
+
const claim = Array.isArray(aud) ? aud.filter((a) => typeof a === "string") : typeof aud === "string" ? [aud] : [];
|
|
28
|
+
const want = Array.isArray(expected) ? expected : [expected];
|
|
29
|
+
return want.some((w) => claim.includes(w));
|
|
30
|
+
}
|
|
24
31
|
// Shared JWT pipeline: parse, verify the signature via the supplied function, then
|
|
25
|
-
// validate exp/nbf. Any malformed part or a
|
|
26
|
-
|
|
32
|
+
// validate exp/nbf and the opt-in exp-required/aud/iss claims. Any malformed part or a
|
|
33
|
+
// verification throw -> null (reject).
|
|
34
|
+
async function verifyJwt(token, verifySignature, opts = {}) {
|
|
27
35
|
const parts = token.split(".");
|
|
28
36
|
if (parts.length !== 3)
|
|
29
37
|
return null;
|
|
@@ -52,17 +60,26 @@ async function verifyJwt(token, verifySignature) {
|
|
|
52
60
|
return null;
|
|
53
61
|
}
|
|
54
62
|
const now = Math.floor(Date.now() / 1000);
|
|
55
|
-
|
|
63
|
+
const hasExp = typeof payload.exp === "number";
|
|
64
|
+
if (opts.requireExp && !hasExp)
|
|
65
|
+
return null;
|
|
66
|
+
if (hasExp && now >= payload.exp)
|
|
56
67
|
return null;
|
|
57
68
|
if (typeof payload.nbf === "number" && now < payload.nbf)
|
|
58
69
|
return null;
|
|
70
|
+
if (opts.audience !== undefined && !audienceMatches(payload.aud, opts.audience))
|
|
71
|
+
return null;
|
|
72
|
+
if (opts.issuer !== undefined && payload.iss !== opts.issuer)
|
|
73
|
+
return null;
|
|
59
74
|
return payload;
|
|
60
75
|
}
|
|
61
76
|
/** HS256 via a shared secret. The dev/default strategy. */
|
|
62
77
|
export class HmacStrategy {
|
|
63
78
|
secret;
|
|
64
|
-
|
|
79
|
+
opts;
|
|
80
|
+
constructor(secret, opts = {}) {
|
|
65
81
|
this.secret = secret;
|
|
82
|
+
this.opts = opts;
|
|
66
83
|
}
|
|
67
84
|
verify(token) {
|
|
68
85
|
return verifyJwt(token, async (input, signature, header) => {
|
|
@@ -70,7 +87,7 @@ export class HmacStrategy {
|
|
|
70
87
|
return false;
|
|
71
88
|
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(this.secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
|
72
89
|
return crypto.subtle.verify("HMAC", key, signature, new TextEncoder().encode(input));
|
|
73
|
-
});
|
|
90
|
+
}, this.opts);
|
|
74
91
|
}
|
|
75
92
|
}
|
|
76
93
|
const SINGLE_KEY = "\0single";
|
|
@@ -80,12 +97,14 @@ const SINGLE_KEY = "\0single";
|
|
|
80
97
|
export class JwksStrategy {
|
|
81
98
|
url;
|
|
82
99
|
ttlMs;
|
|
100
|
+
opts;
|
|
83
101
|
keys = new Map();
|
|
84
102
|
fetchedAt = 0;
|
|
85
103
|
inflight = null;
|
|
86
|
-
constructor(url, ttlMs = 600_000) {
|
|
104
|
+
constructor(url, ttlMs = 600_000, opts = {}) {
|
|
87
105
|
this.url = url;
|
|
88
106
|
this.ttlMs = ttlMs;
|
|
107
|
+
this.opts = opts;
|
|
89
108
|
}
|
|
90
109
|
verify(token) {
|
|
91
110
|
return verifyJwt(token, async (input, signature, header) => {
|
|
@@ -95,7 +114,7 @@ export class JwksStrategy {
|
|
|
95
114
|
if (!key)
|
|
96
115
|
return false;
|
|
97
116
|
return crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, new TextEncoder().encode(input));
|
|
98
|
-
});
|
|
117
|
+
}, this.opts);
|
|
99
118
|
}
|
|
100
119
|
lookup(kid) {
|
|
101
120
|
if (kid)
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
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
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, resolve } from "node:path";
|
|
21
|
+
import { createTableSql } from "./runtime/ddl";
|
|
22
|
+
import { schemaHash } from "./runtime/migrate";
|
|
23
|
+
import { diffSchemaShape, schemaShape } from "./runtime/schema-diff";
|
|
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
|
+
}
|
|
46
|
+
/** The sub-schema of a single partition (used to mirror the DO's per-partition hash,
|
|
47
|
+
* which migrate() computes over exactly this subset). For a single-partition app the
|
|
48
|
+
* subset equals the whole schema, so the hash is identical to the unpartitioned case. */
|
|
49
|
+
function partitionSchema(schema, partition) {
|
|
50
|
+
return Object.fromEntries(entitiesInPartition(schema, partition).map((t) => [t, schema[t]]));
|
|
51
|
+
}
|
|
52
|
+
const argv = process.argv.slice(2);
|
|
53
|
+
function flag(name) {
|
|
54
|
+
const i = argv.indexOf(`--${name}`);
|
|
55
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
56
|
+
}
|
|
57
|
+
function positionals(args) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (let i = 0; i < args.length; i++) {
|
|
60
|
+
if (args[i].startsWith("--"))
|
|
61
|
+
i++; // skip flag + its value
|
|
62
|
+
else
|
|
63
|
+
out.push(args[i]);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
function fail(msg) {
|
|
68
|
+
console.error(`pramen: ${msg}`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
async function loadApp() {
|
|
72
|
+
const explicit = flag("app");
|
|
73
|
+
const candidates = explicit ? [explicit] : ["./app.ts", "./example/app.ts"];
|
|
74
|
+
for (const c of candidates) {
|
|
75
|
+
const p = resolve(process.cwd(), c);
|
|
76
|
+
if (existsSync(p)) {
|
|
77
|
+
const mod = (await import(p));
|
|
78
|
+
if (mod.app?.schema)
|
|
79
|
+
return mod.app;
|
|
80
|
+
fail(`${c} does not export { app }`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return fail(`no app found (looked for ${candidates.join(", ")}); pass --app <path>`);
|
|
84
|
+
}
|
|
85
|
+
const HELP = `pramen — reactive backend on Cloudflare
|
|
86
|
+
|
|
87
|
+
Usage: pramen <command>
|
|
88
|
+
|
|
89
|
+
help show this help
|
|
90
|
+
init [dir] scaffold a new project (app.ts + worker.ts + oblaka.ts)
|
|
91
|
+
schema sql print CREATE TABLE statements for the schema
|
|
92
|
+
schema hash print the schema hash
|
|
93
|
+
schema snapshot save the schema shape to .pramen/schema.json
|
|
94
|
+
schema diff compare the schema to the snapshot (safe vs unsafe)
|
|
95
|
+
schema status compare a deployed tenant's schema to the local schema
|
|
96
|
+
[--tenant t] [--url u] [--token jwt]
|
|
97
|
+
token <sub> [roles...] mint a dev JWT [--tenant a,b]
|
|
98
|
+
|
|
99
|
+
Flags: --app <path> to point at your app module (default ./app.ts or ./example/app.ts).`;
|
|
100
|
+
async function schemaCmd(sub) {
|
|
101
|
+
const snapshotPath = resolve(process.cwd(), ".pramen/schema.json");
|
|
102
|
+
if (sub === "sql") {
|
|
103
|
+
const { schema } = await loadApp();
|
|
104
|
+
for (const [table, def] of Object.entries(schema))
|
|
105
|
+
console.log(createTableSql(table, def) + ";");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (sub === "hash") {
|
|
109
|
+
const { schema } = await loadApp();
|
|
110
|
+
console.log(schemaHash(schema));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (sub === "snapshot") {
|
|
114
|
+
const { schema } = await loadApp();
|
|
115
|
+
mkdirSync(dirname(snapshotPath), { recursive: true });
|
|
116
|
+
const snap = { hash: schemaHash(schema), shape: schemaShape(schema) };
|
|
117
|
+
writeFileSync(snapshotPath, JSON.stringify(snap, null, 2) + "\n");
|
|
118
|
+
console.log(`wrote ${snapshotPath} (${Object.keys(snap.shape).length} tables)`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (sub === "diff") {
|
|
122
|
+
const { schema } = await loadApp();
|
|
123
|
+
const next = schemaShape(schema);
|
|
124
|
+
if (!existsSync(snapshotPath)) {
|
|
125
|
+
console.log("no snapshot — run `pramen schema snapshot` to set a baseline.");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const prev = JSON.parse(readFileSync(snapshotPath, "utf8")).shape;
|
|
129
|
+
const changes = diffSchemaShape(prev, next);
|
|
130
|
+
if (changes.length === 0) {
|
|
131
|
+
console.log("no changes since snapshot.");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
for (const c of changes) {
|
|
135
|
+
const where = c.column ? `${c.table}.${c.column}` : c.table;
|
|
136
|
+
const note = c.detail ? ` (${c.detail})` : "";
|
|
137
|
+
const glyph = c.destructive ? "⚠" : !c.appliesOnBoot ? "•" : "+";
|
|
138
|
+
const tags = (c.destructive ? " [destructive]" : "") + (!c.appliesOnBoot ? " [NOT applied on boot]" : "");
|
|
139
|
+
console.log(` ${glyph} ${c.kind} ${where}${note}${tags}`);
|
|
140
|
+
}
|
|
141
|
+
console.log("\nOn the next DO boot: additive changes (new table/column) auto-apply; destructive changes\n" +
|
|
142
|
+
"(drops, type changes, table rebuilds) are SKIPPED unless the deploy sets PRAMEN_ALLOW_DESTRUCTIVE=true\n" +
|
|
143
|
+
"(the schema hash is then left unwritten so a later opt-in deploy retries).");
|
|
144
|
+
if (changes.some((c) => c.destructive))
|
|
145
|
+
console.log("⚠ destructive changes rebuild the table and CAN lose data. A drop+add is applied as such unless\n" +
|
|
146
|
+
" the column declares `renamedFrom` (which migrates the data).");
|
|
147
|
+
if (changes.some((c) => !c.appliesOnBoot))
|
|
148
|
+
console.log("• [NOT applied on boot] a partition move — the entity's data lives in a different Durable Object,\n" +
|
|
149
|
+
" which needs a manual cross-DO data migration. The boot migrator won't move it; apply it yourself.\n" +
|
|
150
|
+
" (Modifier/constraint changes on an existing column ARE applied on boot now — a tightening one\n" +
|
|
151
|
+
" like adding NOT NULL/UNIQUE is gated by PRAMEN_ALLOW_DESTRUCTIVE and skipped if it can't apply.)");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (sub === "status") {
|
|
155
|
+
const { schema } = await loadApp();
|
|
156
|
+
const url = flag("url") ?? "http://localhost:8787";
|
|
157
|
+
const tenant = flag("tenant") ?? "main";
|
|
158
|
+
const token = flag("token") ?? (await sign({ sub: "cli", roles: ["admin"] }));
|
|
159
|
+
// Each partition is a distinct Durable Object class, migrated and hashed
|
|
160
|
+
// independently — so compare them one at a time, fetching each partition's applied
|
|
161
|
+
// schema from its DO. A single-(default-)partition app loops exactly once and reads
|
|
162
|
+
// identically to before. The default partition is addressed with no partition param.
|
|
163
|
+
const partitions = partitionsOf(schema);
|
|
164
|
+
console.log(`tenant: ${tenant}`);
|
|
165
|
+
for (const partition of partitions) {
|
|
166
|
+
if (partitions.length > 1)
|
|
167
|
+
console.log(`\npartition: ${partition}`);
|
|
168
|
+
const qs = partition === "default" ? "" : `&partition=${encodeURIComponent(partition)}`;
|
|
169
|
+
const res = await fetch(`${url}/admin/schema?tenant=${encodeURIComponent(tenant)}${qs}`, {
|
|
170
|
+
headers: { authorization: `Bearer ${token}` },
|
|
171
|
+
});
|
|
172
|
+
const body = (await res.json().catch(() => ({})));
|
|
173
|
+
if (!res.ok || !body.ok || !body.result)
|
|
174
|
+
fail(`status failed (partition ${partition}): ${body.error ?? res.status}`);
|
|
175
|
+
const live = body.result;
|
|
176
|
+
// The DO hashes only its partition's entities — mirror that here so the compared
|
|
177
|
+
// hashes line up (for a single partition this equals the whole-schema hash).
|
|
178
|
+
const subset = partitionSchema(schema, partition);
|
|
179
|
+
const current = schemaHash(subset);
|
|
180
|
+
const upToDate = live.hash === current;
|
|
181
|
+
console.log(`live: ${live.hash ?? "(none)"}`);
|
|
182
|
+
console.log(`current: ${current}`);
|
|
183
|
+
console.log(upToDate ? "✓ up to date" : "⚠ BEHIND — migrates on the tenant's next boot");
|
|
184
|
+
const want = schemaShape(subset);
|
|
185
|
+
for (const table of Object.keys(want)) {
|
|
186
|
+
const liveCols = new Set(live.tables[table] ?? []);
|
|
187
|
+
const missing = Object.keys(want[table].columns).filter((col) => !liveCols.has(col));
|
|
188
|
+
if (!live.tables[table])
|
|
189
|
+
console.log(` + table ${table} (not yet created live)`);
|
|
190
|
+
else if (missing.length)
|
|
191
|
+
console.log(` + ${table}: ${missing.join(", ")} (not yet added live)`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
console.log(HELP);
|
|
197
|
+
}
|
|
198
|
+
async function tokenCmd(args) {
|
|
199
|
+
const pos = positionals(args);
|
|
200
|
+
const sub = pos[0];
|
|
201
|
+
if (!sub)
|
|
202
|
+
fail("token: <sub> required");
|
|
203
|
+
const roles = pos.slice(1);
|
|
204
|
+
const tenants = flag("tenant")?.split(",");
|
|
205
|
+
console.log(await sign({ sub, roles: roles.length ? roles : ["admin"], ...(tenants ? { tenants } : {}) }));
|
|
206
|
+
}
|
|
207
|
+
function initCmd(args) {
|
|
208
|
+
const dir = resolve(process.cwd(), positionals(args)[0] ?? ".");
|
|
209
|
+
mkdirSync(dir, { recursive: true });
|
|
210
|
+
const write = (name, content) => {
|
|
211
|
+
const p = resolve(dir, name);
|
|
212
|
+
if (existsSync(p))
|
|
213
|
+
return void console.log(` skip ${name} (exists)`);
|
|
214
|
+
writeFileSync(p, content);
|
|
215
|
+
console.log(` + ${name}`);
|
|
216
|
+
};
|
|
217
|
+
write("app.ts", APP_TEMPLATE);
|
|
218
|
+
write("worker.ts", WORKER_TEMPLATE);
|
|
219
|
+
write("oblaka.ts", OBLAKA_TEMPLATE);
|
|
220
|
+
console.log(`\nScaffolded a pramen project in ${dir}.\n`);
|
|
221
|
+
console.log("Next steps:");
|
|
222
|
+
console.log(" 1. Install deps: bun add @pramen/server && bun add -d oblaka-iac wrangler");
|
|
223
|
+
console.log(" 2. Generate config: bunx oblaka oblaka.ts (writes wrangler.jsonc)");
|
|
224
|
+
console.log(" 3. Run locally: bunx wrangler dev (serves http://localhost:8787)");
|
|
225
|
+
console.log("");
|
|
226
|
+
console.log("First request: POST http://localhost:8787/rpc/listNotes returns [] (not 403) —");
|
|
227
|
+
console.log("the scaffold's ACL grants the anonymous role read+create on `notes`. Tighten it");
|
|
228
|
+
console.log("in app.ts before shipping (see the comments there).");
|
|
229
|
+
}
|
|
230
|
+
const APP_TEMPLATE = `import { Entity, defineSchema, createApp, role, policy, allow } from "@pramen/server";
|
|
231
|
+
|
|
232
|
+
const schema = defineSchema({
|
|
233
|
+
notes: Entity((t) => ({ id: t.id(), title: t.text(), body: t.text(), createdAt: t.int() })),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const { query, mutation } = createApp(schema);
|
|
237
|
+
|
|
238
|
+
const handlers = {
|
|
239
|
+
listNotes: query((ctx) => ctx.db.find({ from: "notes", orderBy: { column: "id", dir: "desc" } })),
|
|
240
|
+
createNote: mutation((ctx, input: { title: string; body: string }) =>
|
|
241
|
+
ctx.db.insert("notes", { title: input.title, body: input.body, createdAt: Date.now() }),
|
|
242
|
+
),
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// ACL — deny-by-default; roles only GRANT. A caller with no verified token is the
|
|
246
|
+
// \`anonymous\` role, so this grants the scaffold's handlers on the first request (no
|
|
247
|
+
// token needed). TIGHTEN THIS before shipping: gate writes behind an authenticated
|
|
248
|
+
// role and scope reads with \`$identity(...)\` (see @pramen/auth and the pramen docs).
|
|
249
|
+
const acl = [
|
|
250
|
+
role("anonymous", [
|
|
251
|
+
policy("anon:notes:read", "notes", "read", allow()),
|
|
252
|
+
policy("anon:notes:create", "notes", "create", allow()),
|
|
253
|
+
]),
|
|
254
|
+
];
|
|
255
|
+
|
|
256
|
+
export const app = { schema, handlers, acl };
|
|
257
|
+
`;
|
|
258
|
+
const WORKER_TEMPLATE = `// The whole server entry: hand your app to createPramen and re-export the pair.
|
|
259
|
+
import { createPramen } from "@pramen/server/worker";
|
|
260
|
+
import { app } from "./app";
|
|
261
|
+
|
|
262
|
+
const pramen = createPramen(app);
|
|
263
|
+
|
|
264
|
+
export default { fetch: pramen.fetch };
|
|
265
|
+
export const PramenDO = pramen.PramenDO; // wrangler binds this by class_name
|
|
266
|
+
`;
|
|
267
|
+
const OBLAKA_TEMPLATE = `import { define, DurableObject, KVNamespace, R2Bucket, Worker } from "oblaka-iac";
|
|
268
|
+
|
|
269
|
+
const PROJECT = "my-pramen-app"; // unique per project — namespaces all CF resources
|
|
270
|
+
|
|
271
|
+
export default define(({ env }) => {
|
|
272
|
+
const vars =
|
|
273
|
+
env === "local" ? { AUTH_SECRET: "dev-secret-change-me", FILES_SECRET: "dev-files-secret-change-me" } : {};
|
|
274
|
+
return new Worker({
|
|
275
|
+
dir: ".",
|
|
276
|
+
name: PROJECT,
|
|
277
|
+
main: "./worker.ts",
|
|
278
|
+
compatibility_date: "2026-06-19",
|
|
279
|
+
compatibility_flags: ["nodejs_compat"],
|
|
280
|
+
observability: { enabled: true },
|
|
281
|
+
bindings: {
|
|
282
|
+
PRAMEN: new DurableObject({ name: PROJECT + "-store", className: "PramenDO" }),
|
|
283
|
+
KV: new KVNamespace({ name: PROJECT + "-kv" }),
|
|
284
|
+
FILES: new R2Bucket({ name: PROJECT + "-files" }),
|
|
285
|
+
},
|
|
286
|
+
vars,
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
`;
|
|
290
|
+
async function main() {
|
|
291
|
+
const cmd = argv[0];
|
|
292
|
+
switch (cmd) {
|
|
293
|
+
case undefined:
|
|
294
|
+
case "help":
|
|
295
|
+
case "-h":
|
|
296
|
+
case "--help":
|
|
297
|
+
console.log(HELP);
|
|
298
|
+
return;
|
|
299
|
+
case "init":
|
|
300
|
+
return initCmd(argv.slice(1));
|
|
301
|
+
case "schema":
|
|
302
|
+
return schemaCmd(argv[1]);
|
|
303
|
+
case "token":
|
|
304
|
+
return tokenCmd(argv.slice(1));
|
|
305
|
+
default:
|
|
306
|
+
console.error(`pramen: unknown command "${cmd}"\n`);
|
|
307
|
+
console.log(HELP);
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
await main();
|
package/dist/durable-object.d.ts
CHANGED
|
@@ -35,6 +35,14 @@ export declare class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
35
35
|
* task context — a DO can't introspect its own idFromName. */
|
|
36
36
|
private identityPersisted;
|
|
37
37
|
private identityLoaded;
|
|
38
|
+
/** Live subscriptions per socket — held IN MEMORY, not in the WS attachment. The
|
|
39
|
+
* attachment is capped at ~2 KB by workerd, and 64 subs (each with arbitrary input
|
|
40
|
+
* JSON + a read-set + digest) blow past that well before MAX_SUBSCRIPTIONS. The
|
|
41
|
+
* tradeoff: this map is lost on DO hibernation/eviction, so a woken socket has no
|
|
42
|
+
* entry and is treated as having no active subscriptions — acceptable because the
|
|
43
|
+
* client replays its subscriptions on (re)connect. Keyed by the WebSocket object;
|
|
44
|
+
* cleaned up in webSocketClose. */
|
|
45
|
+
private readonly subsBySocket;
|
|
38
46
|
constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp);
|
|
39
47
|
private ensureMigrated;
|
|
40
48
|
fetch(request: Request): Promise<Response>;
|
|
@@ -42,13 +50,16 @@ export declare class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
42
50
|
* pending alarm; a near-future time batches a burst of enqueues into one drain. */
|
|
43
51
|
private armDrain;
|
|
44
52
|
/** A privileged, system-scoped context for running task handlers (outside a request).
|
|
45
|
-
* Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks.
|
|
53
|
+
* Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks.
|
|
54
|
+
* Returns the `db` alongside the ctx so the drainer can broadcast the tables the task
|
|
55
|
+
* handlers touched (live queries would otherwise go stale after deferred/trigger work). */
|
|
46
56
|
private taskCtx;
|
|
47
57
|
/** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
|
|
48
58
|
* task context is scoped correctly. No-op once loaded/persisted this instance. */
|
|
49
59
|
private loadIdentity;
|
|
50
60
|
/** Drain due tasks. Called by the alarm (DO path) and the /__admin/tasks/drain route
|
|
51
|
-
* (manual / cron). Returns the drain stats incl. `nextRunAt` for rescheduling
|
|
61
|
+
* (manual / cron). Returns the drain stats incl. `nextRunAt` for rescheduling, plus
|
|
62
|
+
* the union of tables the drained task handlers touched (for a post-commit broadcast). */
|
|
52
63
|
private drainTasks;
|
|
53
64
|
alarm(): Promise<void>;
|
|
54
65
|
private handleDrain;
|
|
@@ -67,8 +78,15 @@ export declare class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
67
78
|
private get envBag();
|
|
68
79
|
private filesFor;
|
|
69
80
|
private identityOf;
|
|
70
|
-
|
|
71
|
-
|
|
81
|
+
/** The durable per-socket auth/routing state (identity + tenant + partition), read
|
|
82
|
+
* from the WS attachment. Survives hibernation; kept tiny to stay under workerd's cap. */
|
|
83
|
+
private getAttachment;
|
|
84
|
+
private setAttachment;
|
|
85
|
+
/** This socket's live subscriptions from the in-memory map (see `subsBySocket`). A
|
|
86
|
+
* hibernated/woken socket has no entry → no active subscriptions until the client
|
|
87
|
+
* replays them on reconnect. */
|
|
88
|
+
private getSubs;
|
|
89
|
+
private setSubs;
|
|
72
90
|
private send;
|
|
73
91
|
}
|
|
74
92
|
/** Produce the concrete, app-bound Durable Object class. A DO is constructed by the
|