@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.
- package/dist/auth.d.ts +35 -0
- package/dist/auth.js +189 -0
- package/dist/durable-object.d.ts +48 -0
- package/dist/durable-object.js +282 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +20 -0
- package/dist/pramen.d.ts +42 -0
- package/dist/pramen.js +19 -0
- package/dist/runtime/acl.d.ts +62 -0
- package/dist/runtime/acl.js +289 -0
- package/dist/runtime/db.d.ts +139 -0
- package/dist/runtime/db.js +425 -0
- package/dist/runtime/ddl.d.ts +16 -0
- package/dist/runtime/ddl.js +64 -0
- package/dist/runtime/digest.d.ts +1 -0
- package/dist/runtime/digest.js +29 -0
- package/dist/runtime/dispatch.d.ts +12 -0
- package/dist/runtime/dispatch.js +37 -0
- package/dist/runtime/driver.d.ts +45 -0
- package/dist/runtime/driver.js +70 -0
- package/dist/runtime/errors.d.ts +34 -0
- package/dist/runtime/errors.js +43 -0
- package/dist/runtime/kv.d.ts +23 -0
- package/dist/runtime/kv.js +41 -0
- package/dist/runtime/migrate.d.ts +22 -0
- package/dist/runtime/migrate.js +158 -0
- package/dist/runtime/protocol.d.ts +40 -0
- package/dist/runtime/protocol.js +12 -0
- package/dist/runtime/read-engine.d.ts +73 -0
- package/dist/runtime/read-engine.js +219 -0
- package/dist/runtime/schema-diff.d.ts +14 -0
- package/dist/runtime/schema-diff.js +41 -0
- package/dist/runtime/storage.d.ts +74 -0
- package/dist/runtime/storage.js +0 -0
- package/dist/sdk/acl.d.ts +130 -0
- package/dist/sdk/acl.js +55 -0
- package/dist/sdk/app.d.ts +7 -0
- package/dist/sdk/app.js +11 -0
- package/dist/sdk/files.d.ts +51 -0
- package/dist/sdk/files.js +4 -0
- package/dist/sdk/handlers.d.ts +36 -0
- package/dist/sdk/handlers.js +11 -0
- package/dist/sdk/infer.d.ts +79 -0
- package/dist/sdk/infer.js +5 -0
- package/dist/sdk/schema.d.ts +112 -0
- package/dist/sdk/schema.js +56 -0
- package/dist/worker-entry.d.ts +3 -0
- package/dist/worker-entry.js +8 -0
- package/dist/worker.d.ts +41 -0
- package/dist/worker.js +213 -0
- package/package.json +43 -0
- package/src/auth.ts +215 -0
- package/src/durable-object.ts +346 -0
- package/src/index.ts +77 -0
- package/src/pramen.ts +58 -0
- package/src/runtime/acl.ts +362 -0
- package/src/runtime/db.ts +550 -0
- package/src/runtime/ddl.ts +67 -0
- package/src/runtime/digest.ts +31 -0
- package/src/runtime/dispatch.ts +65 -0
- package/src/runtime/driver.ts +95 -0
- package/src/runtime/errors.ts +56 -0
- package/src/runtime/kv.ts +47 -0
- package/src/runtime/migrate.ts +193 -0
- package/src/runtime/protocol.ts +46 -0
- package/src/runtime/read-engine.ts +243 -0
- package/src/runtime/schema-diff.ts +57 -0
- package/src/runtime/storage.ts +0 -0
- package/src/sdk/acl.ts +196 -0
- package/src/sdk/app.ts +25 -0
- package/src/sdk/files.ts +53 -0
- package/src/sdk/handlers.ts +65 -0
- package/src/sdk/infer.ts +105 -0
- package/src/sdk/schema.ts +122 -0
- package/src/worker-entry.ts +9 -0
- 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";
|