@pramen/server 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/auth.d.ts +35 -0
  2. package/dist/auth.js +189 -0
  3. package/dist/durable-object.d.ts +48 -0
  4. package/dist/durable-object.js +282 -0
  5. package/dist/index.d.ts +14 -0
  6. package/dist/index.js +20 -0
  7. package/dist/pramen.d.ts +42 -0
  8. package/dist/pramen.js +19 -0
  9. package/dist/runtime/acl.d.ts +62 -0
  10. package/dist/runtime/acl.js +289 -0
  11. package/dist/runtime/db.d.ts +139 -0
  12. package/dist/runtime/db.js +425 -0
  13. package/dist/runtime/ddl.d.ts +16 -0
  14. package/dist/runtime/ddl.js +64 -0
  15. package/dist/runtime/digest.d.ts +1 -0
  16. package/dist/runtime/digest.js +29 -0
  17. package/dist/runtime/dispatch.d.ts +12 -0
  18. package/dist/runtime/dispatch.js +37 -0
  19. package/dist/runtime/driver.d.ts +45 -0
  20. package/dist/runtime/driver.js +70 -0
  21. package/dist/runtime/errors.d.ts +34 -0
  22. package/dist/runtime/errors.js +43 -0
  23. package/dist/runtime/kv.d.ts +23 -0
  24. package/dist/runtime/kv.js +41 -0
  25. package/dist/runtime/migrate.d.ts +22 -0
  26. package/dist/runtime/migrate.js +158 -0
  27. package/dist/runtime/protocol.d.ts +40 -0
  28. package/dist/runtime/protocol.js +12 -0
  29. package/dist/runtime/read-engine.d.ts +73 -0
  30. package/dist/runtime/read-engine.js +219 -0
  31. package/dist/runtime/schema-diff.d.ts +14 -0
  32. package/dist/runtime/schema-diff.js +41 -0
  33. package/dist/runtime/storage.d.ts +74 -0
  34. package/dist/runtime/storage.js +0 -0
  35. package/dist/sdk/acl.d.ts +130 -0
  36. package/dist/sdk/acl.js +55 -0
  37. package/dist/sdk/app.d.ts +7 -0
  38. package/dist/sdk/app.js +11 -0
  39. package/dist/sdk/files.d.ts +51 -0
  40. package/dist/sdk/files.js +4 -0
  41. package/dist/sdk/handlers.d.ts +36 -0
  42. package/dist/sdk/handlers.js +11 -0
  43. package/dist/sdk/infer.d.ts +79 -0
  44. package/dist/sdk/infer.js +5 -0
  45. package/dist/sdk/schema.d.ts +112 -0
  46. package/dist/sdk/schema.js +56 -0
  47. package/dist/worker-entry.d.ts +3 -0
  48. package/dist/worker-entry.js +8 -0
  49. package/dist/worker.d.ts +41 -0
  50. package/dist/worker.js +213 -0
  51. package/package.json +43 -0
  52. package/src/auth.ts +215 -0
  53. package/src/durable-object.ts +346 -0
  54. package/src/index.ts +77 -0
  55. package/src/pramen.ts +58 -0
  56. package/src/runtime/acl.ts +362 -0
  57. package/src/runtime/db.ts +550 -0
  58. package/src/runtime/ddl.ts +67 -0
  59. package/src/runtime/digest.ts +31 -0
  60. package/src/runtime/dispatch.ts +65 -0
  61. package/src/runtime/driver.ts +95 -0
  62. package/src/runtime/errors.ts +56 -0
  63. package/src/runtime/kv.ts +47 -0
  64. package/src/runtime/migrate.ts +193 -0
  65. package/src/runtime/protocol.ts +46 -0
  66. package/src/runtime/read-engine.ts +243 -0
  67. package/src/runtime/schema-diff.ts +57 -0
  68. package/src/runtime/storage.ts +0 -0
  69. package/src/sdk/acl.ts +196 -0
  70. package/src/sdk/app.ts +25 -0
  71. package/src/sdk/files.ts +53 -0
  72. package/src/sdk/handlers.ts +65 -0
  73. package/src/sdk/infer.ts +105 -0
  74. package/src/sdk/schema.ts +122 -0
  75. package/src/worker-entry.ts +9 -0
  76. package/src/worker.ts +253 -0
package/src/auth.ts ADDED
@@ -0,0 +1,215 @@
1
+ // Identity resolution at the edge. The Worker verifies a signed token and
2
+ // forwards a trusted identity to the DO; the DO never re-derives it, and the
3
+ // client-supplied X-Pramen-Identity header is stripped unless a token verified
4
+ // (see src/index.ts), so a validly-signed JWT is the only path to an identity.
5
+ //
6
+ // Verification is pluggable via VerifyStrategy: HmacStrategy (HS256, shared secret
7
+ // in env.AUTH_SECRET) for dev/symmetric setups, JwksStrategy (RS256 against a remote
8
+ // JWKS, with key caching) for real identity providers. The header parse, exp/nbf
9
+ // checks, and claim->Identity mapping are shared; only signature verification
10
+ // differs. Claims map to Identity: `sub` -> userId, `roles`/`role` -> roles, other
11
+ // non-standard claims pass through.
12
+
13
+ import type { Identity } from "./sdk/acl";
14
+
15
+ const STANDARD_CLAIMS = new Set(["exp", "iat", "nbf", "iss", "aud", "jti", "sub", "role", "roles", "userId"]);
16
+
17
+ function b64urlToBytes(s: string): Uint8Array {
18
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
19
+ const bin = atob(b64);
20
+ const out = new Uint8Array(bin.length);
21
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
22
+ return out;
23
+ }
24
+
25
+ function b64urlToString(s: string): string {
26
+ return new TextDecoder().decode(b64urlToBytes(s));
27
+ }
28
+
29
+ interface JwtHeader {
30
+ alg?: string;
31
+ kid?: string;
32
+ }
33
+
34
+ /** Verifies a JWT and returns its claims, or null if invalid. Implementations
35
+ * differ only in how they verify the signature. */
36
+ export interface VerifyStrategy {
37
+ verify(token: string): Promise<Record<string, unknown> | null>;
38
+ }
39
+
40
+ /** Verify a signature over `${header}.${payload}` for the parsed header. */
41
+ type SignatureVerifier = (signingInput: string, signature: Uint8Array, header: JwtHeader) => Promise<boolean>;
42
+
43
+ // 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> {
46
+ const parts = token.split(".");
47
+ if (parts.length !== 3) return null;
48
+ const [h, p, sig] = parts;
49
+
50
+ let header: JwtHeader;
51
+ try {
52
+ header = JSON.parse(b64urlToString(h!));
53
+ } catch {
54
+ return null;
55
+ }
56
+
57
+ let valid: boolean;
58
+ try {
59
+ valid = await verifySignature(`${h}.${p}`, b64urlToBytes(sig!), header);
60
+ } catch {
61
+ return null;
62
+ }
63
+ if (!valid) return null;
64
+
65
+ let payload: Record<string, unknown>;
66
+ try {
67
+ payload = JSON.parse(b64urlToString(p!));
68
+ } catch {
69
+ return null;
70
+ }
71
+
72
+ const now = Math.floor(Date.now() / 1000);
73
+ if (typeof payload.exp === "number" && now >= payload.exp) return null;
74
+ if (typeof payload.nbf === "number" && now < payload.nbf) return null;
75
+ return payload;
76
+ }
77
+
78
+ /** HS256 via a shared secret. The dev/default strategy. */
79
+ export class HmacStrategy implements VerifyStrategy {
80
+ constructor(private readonly secret: string) {}
81
+
82
+ 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
+ });
94
+ }
95
+ }
96
+
97
+ interface Jwk extends JsonWebKey {
98
+ kid?: string;
99
+ }
100
+
101
+ const SINGLE_KEY = "\0single";
102
+
103
+ /** RS256 verified against a remote JWKS. Public keys are fetched once and cached
104
+ * (TTL); a token with an unknown `kid` triggers one forced refetch to pick up key
105
+ * rotation. Stale keys are kept if a refetch fails. */
106
+ export class JwksStrategy implements VerifyStrategy {
107
+ private keys = new Map<string, CryptoKey>();
108
+ private fetchedAt = 0;
109
+ private inflight: Promise<void> | null = null;
110
+
111
+ constructor(
112
+ readonly url: string,
113
+ private readonly ttlMs = 600_000,
114
+ ) {}
115
+
116
+ 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
+ });
123
+ }
124
+
125
+ private lookup(kid?: string): CryptoKey | null {
126
+ if (kid) return this.keys.get(kid) ?? null;
127
+ if (this.keys.size === 1) return [...this.keys.values()][0]!; // no kid + single key
128
+ return this.keys.get(SINGLE_KEY) ?? null;
129
+ }
130
+
131
+ private async keyFor(kid?: string): Promise<CryptoKey | null> {
132
+ await this.refresh(false);
133
+ let key = this.lookup(kid);
134
+ if (!key) {
135
+ await this.refresh(true); // unknown kid -> force a refetch (key rotation)
136
+ key = this.lookup(kid);
137
+ }
138
+ return key;
139
+ }
140
+
141
+ private async refresh(force: boolean): Promise<void> {
142
+ if (!force && this.keys.size > 0 && Date.now() - this.fetchedAt < this.ttlMs) return;
143
+ if (this.inflight) return this.inflight;
144
+ this.inflight = this.fetchKeys();
145
+ try {
146
+ await this.inflight;
147
+ } finally {
148
+ this.inflight = null;
149
+ }
150
+ }
151
+
152
+ private async fetchKeys(): Promise<void> {
153
+ try {
154
+ const res = await fetch(this.url);
155
+ if (!res.ok) return; // keep stale keys
156
+ const body = (await res.json()) as { keys?: Jwk[] };
157
+ const next = new Map<string, CryptoKey>();
158
+ for (const jwk of body.keys ?? []) {
159
+ if (jwk.kty !== "RSA") continue;
160
+ const key = await crypto.subtle.importKey(
161
+ "jwk",
162
+ jwk,
163
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
164
+ false,
165
+ ["verify"],
166
+ );
167
+ next.set(jwk.kid ?? SINGLE_KEY, key);
168
+ }
169
+ if (next.size > 0) {
170
+ this.keys = next;
171
+ this.fetchedAt = Date.now();
172
+ }
173
+ } catch {
174
+ // network error -> keep whatever keys we have
175
+ }
176
+ }
177
+ }
178
+
179
+ function toIdentity(claims: Record<string, unknown>): Identity {
180
+ const roles = Array.isArray(claims.roles)
181
+ ? (claims.roles as string[])
182
+ : typeof claims.role === "string"
183
+ ? [claims.role]
184
+ : [];
185
+ const identity: Identity = { roles, userId: (claims.sub ?? claims.userId) as string | undefined };
186
+ for (const [k, v] of Object.entries(claims)) {
187
+ if (!STANDARD_CLAIMS.has(k)) identity[k] = v; // carry custom claims (tier, …)
188
+ }
189
+ return identity;
190
+ }
191
+
192
+ export async function resolveIdentity(request: Request, strategy: VerifyStrategy): Promise<Identity | null> {
193
+ const token = request.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
194
+ if (!token) return null;
195
+ const claims = await strategy.verify(token);
196
+ return claims ? toIdentity(claims) : null;
197
+ }
198
+
199
+ /** May this identity address the given tenant? Gates `X-Pramen-Tenant` so a caller
200
+ * can't reach (or register) arbitrary tenants. Default policy: admins → any
201
+ * tenant; everyone else → only tenants listed in their `tenants` claim. Customize
202
+ * for your tenancy model (e.g. tenant === identity.org, or a lookup). */
203
+ export function authorizeTenant(identity: Identity | null, tenant: string): boolean {
204
+ // Anonymous (no verified token) may reach only the default tenant — enough for
205
+ // first-class public flows (the `anonymous` ACL role still gates the data), while
206
+ // not letting unauthenticated callers address/register arbitrary tenants.
207
+ if (!identity) return tenant === "main";
208
+ if (identity.roles?.includes("admin")) return true;
209
+ const allowed = Array.isArray(identity.tenants) ? (identity.tenants as string[]) : [];
210
+ return allowed.includes(tenant);
211
+ }
212
+
213
+ export function isAdmin(identity: Identity | null): boolean {
214
+ return identity?.roles?.includes("admin") ?? false;
215
+ }
@@ -0,0 +1,346 @@
1
+ // PramenDOBase — the database. One instance per tenant (see worker.ts routing).
2
+ // Holds the SQLite store in-process, applies the schema on boot, dispatches
3
+ // handler RPCs over HTTP, and serves live queries over WebSockets. The concrete,
4
+ // app-bound class is produced by pramenDO(app) (and createPramen) — the DO can't
5
+ // take constructor args beyond (ctx, env), so the app is closed over.
6
+ //
7
+ // ACL: policies are compiled once on boot. Identity is resolved by the Worker
8
+ // and forwarded in the X-Pramen-Identity header. For HTTP it's per request; for a
9
+ // WebSocket it's fixed at connect time and stored on the socket, so live queries
10
+ // are evaluated per-identity (row-level scopes apply to pushes too).
11
+ //
12
+ // Reactivity: the DO is the single writer, so it sees every mutation. After a
13
+ // mutation commits we re-run each subscription whose read-set intersects the
14
+ // written tables, but push only when that subscription's result actually changed
15
+ // (digest diff). Connections use Hibernatable WebSockets; per-socket state
16
+ // (identity + subscriptions) is stored via serializeAttachment().
17
+
18
+ import { DurableObject } from "cloudflare:workers";
19
+ import { migrate } from "./runtime/migrate";
20
+ import { dispatch } from "./runtime/dispatch";
21
+ import { digest } from "./runtime/digest";
22
+ import { compileAcl, type AclContext, type CompiledAcl } from "./runtime/acl";
23
+ import { DoSqliteDriver, type Driver } from "./runtime/driver";
24
+ import { BadRequest, toResponse, toWsError } from "./runtime/errors";
25
+ import { Kv } from "./runtime/kv";
26
+ import { createFiles, R2Adapter, type Files } from "./runtime/storage";
27
+ import type { Identity } from "./sdk/acl";
28
+ import type { PramenApp } from "./pramen";
29
+ import type { ClientMsg, ServerMsg, Subscription } from "./runtime/protocol";
30
+
31
+ interface SocketState {
32
+ identity: Identity | null;
33
+ /** Tenant fixed at connect time (survives hibernation via the attachment). */
34
+ tenant: string;
35
+ subs: Subscription[];
36
+ }
37
+
38
+ export interface DoEnv {
39
+ /** Project KV — tenant registry (`tenant:`) + handler ctx.kv (`app:`). */
40
+ KV: KVNamespace;
41
+ /** R2 bucket backing ctx.files + the Worker /files/* route. */
42
+ FILES: R2Bucket;
43
+ /** HMAC secret for signing file upload/download tokens (falls back to AUTH_SECRET). */
44
+ FILES_SECRET?: string;
45
+ /** Bearer-JWT secret; also the fallback for signing file tokens. */
46
+ AUTH_SECRET?: string;
47
+ /** "true" to apply destructive schema migrations (drop/rebuild/type-change). Off by
48
+ * default — data-loss is gated behind this explicit opt-in. */
49
+ PRAMEN_ALLOW_DESTRUCTIVE?: string;
50
+ }
51
+
52
+ /** Per-socket subscription cap — bounds memory and per-mutation re-run cost. */
53
+ const MAX_SUBSCRIPTIONS = 64;
54
+
55
+ export class PramenDOBase extends DurableObject<DoEnv> {
56
+ private readonly app: PramenApp;
57
+ private readonly acl: CompiledAcl;
58
+ private readonly kv: Kv;
59
+ private readonly driver: Driver;
60
+ private registered = false;
61
+ /** Tenant this DO serves (one per idFromName). Learned from the Worker-forwarded
62
+ * x-pramen-tenant header; defaults to "main". */
63
+ private tenant = "main";
64
+ private files?: Files;
65
+
66
+ constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp) {
67
+ super(ctx, env);
68
+ this.app = app;
69
+ this.acl = compileAcl(this.app.acl ?? []);
70
+ this.kv = new Kv(env.KV); // app:-prefixed, handed to handlers as ctx.kv
71
+
72
+ // The data layer runs over a Driver. The DO's store is its own in-process SQLite;
73
+ // the D1 substrate (D1Driver) lives in the Worker (the "Worker + D1, no DO" path).
74
+ this.driver = new DoSqliteDriver(ctx.storage);
75
+
76
+ // Reconcile the store with the schema before any request is served (create/alter
77
+ // tables; destructive changes rebuild the table). Wrapped in a transaction so a
78
+ // partial migration can't leave a half-rebuilt table.
79
+ const allowDestructive = env.PRAMEN_ALLOW_DESTRUCTIVE === "true";
80
+ ctx.blockConcurrencyWhile(() =>
81
+ this.driver.transaction(() => migrate(this.driver, this.app.schema, { allowDestructive }).then(() => {})),
82
+ );
83
+ }
84
+
85
+ override async fetch(request: Request): Promise<Response> {
86
+ await this.ensureRegistered(request);
87
+ const tenantHeader = request.headers.get("x-pramen-tenant");
88
+ if (tenantHeader) this.tenant = tenantHeader;
89
+
90
+ const path = new URL(request.url).pathname;
91
+ if (path === "/__recover") return this.handleRecover(request);
92
+ if (path === "/__schema") return this.handleSchema();
93
+
94
+ const identity = this.identityOf(request);
95
+
96
+ if (request.headers.get("Upgrade") === "websocket") {
97
+ const { 0: client, 1: server } = new WebSocketPair();
98
+ this.ctx.acceptWebSocket(server); // hibernatable
99
+ this.setState(server, { identity, tenant: this.tenant, subs: [] });
100
+ return new Response(null, { status: 101, webSocket: client });
101
+ }
102
+
103
+ const name = new URL(request.url).pathname.replace(/^\/rpc\//, "");
104
+ let input: unknown;
105
+ if (request.method === "POST") {
106
+ input = await request.json().catch(() => undefined);
107
+ }
108
+
109
+ try {
110
+ const { result, kind, touched } = await dispatch(
111
+ this.app.handlers,
112
+ this.app.schema,
113
+ this.driver,
114
+ this.kv,
115
+ this.filesFor(this.tenant),
116
+ this.envBag,
117
+ this.ctxFor(identity),
118
+ name,
119
+ input,
120
+ );
121
+ if (kind === "mutation" && touched.length > 0) await this.broadcast(touched);
122
+ return Response.json({ ok: true, result });
123
+ } catch (err) {
124
+ const { status, body } = toResponse(err);
125
+ return Response.json(body, { status });
126
+ }
127
+ }
128
+
129
+ // --- Hibernatable WebSocket handlers ---
130
+
131
+ override async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise<void> {
132
+ let msg: ClientMsg;
133
+ try {
134
+ msg = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw));
135
+ } catch {
136
+ return this.send(ws, { type: "error", id: "", error: "invalid JSON" });
137
+ }
138
+
139
+ switch (msg.type) {
140
+ case "subscribe":
141
+ return this.onSubscribe(ws, msg.id, msg.name, msg.input);
142
+ case "unsubscribe": {
143
+ const state = this.getState(ws);
144
+ this.setState(ws, { ...state, subs: state.subs.filter((s) => s.id !== msg.id) });
145
+ return;
146
+ }
147
+ case "call":
148
+ return this.onCall(ws, msg.id, msg.name, msg.input);
149
+ default:
150
+ return this.send(ws, { type: "error", id: "", error: "unknown message type" });
151
+ }
152
+ }
153
+
154
+ override async webSocketClose(ws: WebSocket): Promise<void> {
155
+ ws.close();
156
+ }
157
+
158
+ override async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
159
+ console.error("pramen: websocket error", error);
160
+ try {
161
+ ws.close(1011, "error");
162
+ } catch {
163
+ /* already closing */
164
+ }
165
+ }
166
+
167
+ // --- live-query internals ---
168
+
169
+ private async onSubscribe(ws: WebSocket, id: string, name: string, input: unknown): Promise<void> {
170
+ const state = this.getState(ws);
171
+ try {
172
+ const replacing = state.subs.some((s) => s.id === id);
173
+ if (!replacing && state.subs.length >= MAX_SUBSCRIPTIONS) {
174
+ return this.send(ws, toWsError(id, new BadRequest("subscription limit reached")));
175
+ }
176
+ const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity), name, input);
177
+ if (kind !== "query") {
178
+ return this.send(ws, toWsError(id, new BadRequest(`${name} is not a query`)));
179
+ }
180
+ const subs = state.subs.filter((s) => s.id !== id);
181
+ subs.push({ id, name, input, tables: touched, digest: digest(result) });
182
+ this.setState(ws, { ...state, subs });
183
+ this.send(ws, { type: "data", id, result });
184
+ } catch (err) {
185
+ this.send(ws, toWsError(id, err));
186
+ }
187
+ }
188
+
189
+ private async onCall(ws: WebSocket, id: string, name: string, input: unknown): Promise<void> {
190
+ const state = this.getState(ws);
191
+ try {
192
+ const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity), name, input);
193
+ this.send(ws, { type: "result", id, result });
194
+ if (kind === "mutation" && touched.length > 0) await this.broadcast(touched);
195
+ } catch (err) {
196
+ this.send(ws, toWsError(id, err));
197
+ }
198
+ }
199
+
200
+ // Re-run every subscription whose read-set intersects the written tables, each
201
+ // under its own socket's identity, and push only when its result changed.
202
+ private async broadcast(touched: string[]): Promise<void> {
203
+ const written = new Set(touched);
204
+ for (const ws of this.ctx.getWebSockets()) {
205
+ const state = this.getState(ws);
206
+ let dirty = false;
207
+ for (const sub of state.subs) {
208
+ if (!sub.tables.some((t) => written.has(t))) continue;
209
+ try {
210
+ const { result } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity), sub.name, sub.input);
211
+ const next = digest(result);
212
+ if (next === sub.digest) continue; // result unchanged for this subscription
213
+ sub.digest = next;
214
+ dirty = true;
215
+ this.send(ws, { type: "data", id: sub.id, result });
216
+ } catch (err) {
217
+ this.send(ws, toWsError(sub.id, err));
218
+ }
219
+ }
220
+ if (dirty) this.setState(ws, state);
221
+ }
222
+ }
223
+
224
+ // --- helpers ---
225
+
226
+ // A DO addressed by idFromName(tenant) doesn't know its own name — the Worker
227
+ // forwards it. On the first touch ever (guarded by a persisted meta flag), the
228
+ // tenant records itself in the registry KV so it stays discoverable. Exactly
229
+ // one KV write per tenant across its whole lifetime.
230
+ private async ensureRegistered(request: Request): Promise<void> {
231
+ if (this.registered) return;
232
+ const name = request.headers.get("x-pramen-tenant");
233
+ if (!name) return;
234
+
235
+ const seen = await this.driver.exec(`SELECT 1 FROM _pramen_meta WHERE key = 'registered'`, []);
236
+ if (seen.length > 0) {
237
+ this.registered = true;
238
+ return;
239
+ }
240
+ await this.env.KV.put(`tenant:${name}`, JSON.stringify({ firstSeen: Date.now() }));
241
+ await this.driver.exec(`INSERT OR REPLACE INTO _pramen_meta (key, value) VALUES ('registered', ?)`, [name]);
242
+ this.registered = true;
243
+ }
244
+
245
+ // Point-in-time recovery (admin-gated at the Worker). Arms a restore to the
246
+ // given time and returns the `undo` bookmark (the point just before recovery,
247
+ // so the operation is reversible). We intentionally do NOT call ctx.abort()
248
+ // here, so this response can return the undo bookmark — the restore completes
249
+ // when the DO next restarts. PITR is unavailable in local dev (no change-log).
250
+ private async handleRecover(request: Request): Promise<Response> {
251
+ const body = (await request.json().catch(() => ({}))) as { timestamp?: unknown };
252
+ const ts = typeof body.timestamp === "number" ? body.timestamp : Date.parse(String(body.timestamp));
253
+ if (!Number.isFinite(ts)) {
254
+ return Response.json({ ok: false, error: "invalid timestamp", code: "bad_request" }, { status: 400 });
255
+ }
256
+
257
+ const storage = this.ctx.storage as unknown as {
258
+ getBookmarkForTime?: (t: number) => Promise<string>;
259
+ onNextSessionRestoreBookmark?: (b: string) => Promise<string>;
260
+ };
261
+
262
+ try {
263
+ const bookmark = await storage.getBookmarkForTime!(ts);
264
+ const undo = await storage.onNextSessionRestoreBookmark!(bookmark);
265
+ return Response.json({ ok: true, result: { restoredTo: ts, bookmark, undo, applied: false } });
266
+ } catch (err) {
267
+ // PITR is a platform feature — unavailable in local dev, and can otherwise
268
+ // fail operationally. Report 501 (not a generic 500); log the real reason.
269
+ console.error("pramen: recovery unavailable", err);
270
+ return Response.json(
271
+ { ok: false, error: "point-in-time recovery is unavailable in this environment", code: "unavailable" },
272
+ { status: 501 },
273
+ );
274
+ }
275
+ }
276
+
277
+ // Introspection: this tenant's applied schema hash + live table/column shape
278
+ // (admin-gated at the Worker). Powers the CLI's `schema status`.
279
+ private async handleSchema(): Promise<Response> {
280
+ const hashRow = (await this.driver.exec(`SELECT value FROM _pramen_meta WHERE key = 'schema_hash'`, [])) as {
281
+ value: string;
282
+ }[];
283
+ const tableRows = (await this.driver.exec(
284
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> '_pramen_meta'`,
285
+ [],
286
+ )) as { name: string }[];
287
+ const tables: Record<string, string[]> = {};
288
+ for (const { name } of tableRows) {
289
+ tables[name] = ((await this.driver.exec(`PRAGMA table_info(${name})`, [])) as { name: string }[]).map((r) => r.name);
290
+ }
291
+ return Response.json({ ok: true, result: { hash: hashRow[0]?.value ?? null, tables } });
292
+ }
293
+
294
+ private ctxFor(identity: Identity | null): AclContext {
295
+ return { acl: this.acl, identity };
296
+ }
297
+
298
+ // The DO env (bindings + vars + secrets) handed to handlers as ctx.env. Loosely
299
+ // typed at the boundary so handlers can read any var/secret without a DoEnv cast.
300
+ private get envBag(): Readonly<Record<string, unknown>> {
301
+ return this.env as unknown as Record<string, unknown>;
302
+ }
303
+
304
+ // One Files facade per DO (a DO serves one tenant). Backed by the R2 binding;
305
+ // signing uses FILES_SECRET. Handlers mint signed urls; the bytes never enter here.
306
+ private filesFor(tenant: string): Files {
307
+ if (!this.files) {
308
+ const secret = this.env.FILES_SECRET || this.env.AUTH_SECRET || "";
309
+ this.files = createFiles({ tenant, secret, adapter: new R2Adapter(this.env.FILES) });
310
+ }
311
+ return this.files;
312
+ }
313
+
314
+ private identityOf(request: Request): Identity | null {
315
+ const raw = request.headers.get("x-pramen-identity");
316
+ if (!raw) return null;
317
+ try {
318
+ return JSON.parse(raw) as Identity;
319
+ } catch {
320
+ return null;
321
+ }
322
+ }
323
+
324
+ private getState(ws: WebSocket): SocketState {
325
+ return (ws.deserializeAttachment() as SocketState | null) ?? { identity: null, tenant: this.tenant, subs: [] };
326
+ }
327
+
328
+ private setState(ws: WebSocket, state: SocketState): void {
329
+ ws.serializeAttachment(state);
330
+ }
331
+
332
+ private send(ws: WebSocket, msg: ServerMsg): void {
333
+ ws.send(JSON.stringify(msg));
334
+ }
335
+ }
336
+
337
+ /** Produce the concrete, app-bound Durable Object class. A DO is constructed by the
338
+ * platform with just (ctx, env), so the app is closed over here. Re-export the
339
+ * result from your Worker entry under the class name wrangler expects ("PramenDO"). */
340
+ export function pramenDO(app: PramenApp): typeof PramenDOBase {
341
+ return class PramenDO extends PramenDOBase {
342
+ constructor(ctx: DurableObjectState, env: DoEnv) {
343
+ super(ctx, env, app);
344
+ }
345
+ };
346
+ }
package/src/index.ts ADDED
@@ -0,0 +1,77 @@
1
+ // @pramen/server — the authoring entry: schema, handlers, ACL, files, errors, and
2
+ // the substrate seam. A pramen project is just an app.ts (schema + handlers + ACL),
3
+ // an oblaka.ts (topology), and a 3-line Worker entry.
4
+ //
5
+ // The deploy half — createPramen / the Durable Object — lives at "@pramen/server/worker"
6
+ // (see worker-entry.ts). It is split off because it imports `cloudflare:workers`,
7
+ // which only exists in the Workers runtime; keeping it separate lets the CLI, tests,
8
+ // and codegen load an app.ts for its schema without dragging in the DO runtime.
9
+
10
+ // --- schema authoring ---
11
+ export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo } from "./sdk/schema";
12
+ export type {
13
+ DefaultValue,
14
+ FieldType,
15
+ FieldDef,
16
+ EntityFields,
17
+ EntityDef,
18
+ SchemaDef,
19
+ RelationDef,
20
+ RelationDefs,
21
+ BelongsToDef,
22
+ HasManyDef,
23
+ } from "./sdk/schema";
24
+
25
+ // --- app + handlers ---
26
+ export { createApp } from "./sdk/app";
27
+ export { query, mutation } from "./sdk/handlers";
28
+ export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts } from "./sdk/handlers";
29
+
30
+ // --- ACL ---
31
+ export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
32
+ export type {
33
+ Action,
34
+ Identity,
35
+ IdentityMarker,
36
+ InputMarker,
37
+ Policy,
38
+ PolicyRule,
39
+ PolicyRules,
40
+ Role,
41
+ Validator,
42
+ WhereRule,
43
+ ConditionalFields,
44
+ FieldsFn,
45
+ RelationAclRule,
46
+ SetValue,
47
+ ResolverFn,
48
+ ResolverContext,
49
+ ResolverDb,
50
+ } from "./sdk/acl";
51
+
52
+ // --- inference (types only) ---
53
+ export type {
54
+ Cell,
55
+ FieldsOf,
56
+ InferInsert,
57
+ InferRow,
58
+ InferUpdate,
59
+ JsonValue,
60
+ ProjectedRow,
61
+ RelationsOf,
62
+ RelationsResult,
63
+ WhereInput,
64
+ WhereOps,
65
+ } from "./sdk/infer";
66
+
67
+ // --- files ---
68
+ export type { FileRef, Files, SignUploadOpts, SignDownloadOpts, HeadResult } from "./sdk/files";
69
+ export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
70
+ export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
71
+
72
+ // --- errors ---
73
+ export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
74
+
75
+ // --- substrate seam (advanced: bring your own SQL backend) ---
76
+ export { sqliteDialect, postgresDialect, DoSqliteDriver, D1Driver } from "./runtime/driver";
77
+ export type { Driver, Dialect, Row } from "./runtime/driver";