@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/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Identity } from "./sdk/acl";
|
|
2
|
+
/** Verifies a JWT and returns its claims, or null if invalid. Implementations
|
|
3
|
+
* differ only in how they verify the signature. */
|
|
4
|
+
export interface VerifyStrategy {
|
|
5
|
+
verify(token: string): Promise<Record<string, unknown> | null>;
|
|
6
|
+
}
|
|
7
|
+
/** HS256 via a shared secret. The dev/default strategy. */
|
|
8
|
+
export declare class HmacStrategy implements VerifyStrategy {
|
|
9
|
+
private readonly secret;
|
|
10
|
+
constructor(secret: string);
|
|
11
|
+
verify(token: string): Promise<Record<string, unknown> | null>;
|
|
12
|
+
}
|
|
13
|
+
/** RS256 verified against a remote JWKS. Public keys are fetched once and cached
|
|
14
|
+
* (TTL); a token with an unknown `kid` triggers one forced refetch to pick up key
|
|
15
|
+
* rotation. Stale keys are kept if a refetch fails. */
|
|
16
|
+
export declare class JwksStrategy implements VerifyStrategy {
|
|
17
|
+
readonly url: string;
|
|
18
|
+
private readonly ttlMs;
|
|
19
|
+
private keys;
|
|
20
|
+
private fetchedAt;
|
|
21
|
+
private inflight;
|
|
22
|
+
constructor(url: string, ttlMs?: number);
|
|
23
|
+
verify(token: string): Promise<Record<string, unknown> | null>;
|
|
24
|
+
private lookup;
|
|
25
|
+
private keyFor;
|
|
26
|
+
private refresh;
|
|
27
|
+
private fetchKeys;
|
|
28
|
+
}
|
|
29
|
+
export declare function resolveIdentity(request: Request, strategy: VerifyStrategy): Promise<Identity | null>;
|
|
30
|
+
/** May this identity address the given tenant? Gates `X-Pramen-Tenant` so a caller
|
|
31
|
+
* can't reach (or register) arbitrary tenants. Default policy: admins → any
|
|
32
|
+
* tenant; everyone else → only tenants listed in their `tenants` claim. Customize
|
|
33
|
+
* for your tenancy model (e.g. tenant === identity.org, or a lookup). */
|
|
34
|
+
export declare function authorizeTenant(identity: Identity | null, tenant: string): boolean;
|
|
35
|
+
export declare function isAdmin(identity: Identity | null): boolean;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
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
|
+
const STANDARD_CLAIMS = new Set(["exp", "iat", "nbf", "iss", "aud", "jti", "sub", "role", "roles", "userId"]);
|
|
13
|
+
function b64urlToBytes(s) {
|
|
14
|
+
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
|
|
15
|
+
const bin = atob(b64);
|
|
16
|
+
const out = new Uint8Array(bin.length);
|
|
17
|
+
for (let i = 0; i < bin.length; i++)
|
|
18
|
+
out[i] = bin.charCodeAt(i);
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
function b64urlToString(s) {
|
|
22
|
+
return new TextDecoder().decode(b64urlToBytes(s));
|
|
23
|
+
}
|
|
24
|
+
// Shared JWT pipeline: parse, verify the signature via the supplied function, then
|
|
25
|
+
// validate exp/nbf. Any malformed part or a verification throw -> null (reject).
|
|
26
|
+
async function verifyJwt(token, verifySignature) {
|
|
27
|
+
const parts = token.split(".");
|
|
28
|
+
if (parts.length !== 3)
|
|
29
|
+
return null;
|
|
30
|
+
const [h, p, sig] = parts;
|
|
31
|
+
let header;
|
|
32
|
+
try {
|
|
33
|
+
header = JSON.parse(b64urlToString(h));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
let valid;
|
|
39
|
+
try {
|
|
40
|
+
valid = await verifySignature(`${h}.${p}`, b64urlToBytes(sig), header);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (!valid)
|
|
46
|
+
return null;
|
|
47
|
+
let payload;
|
|
48
|
+
try {
|
|
49
|
+
payload = JSON.parse(b64urlToString(p));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const now = Math.floor(Date.now() / 1000);
|
|
55
|
+
if (typeof payload.exp === "number" && now >= payload.exp)
|
|
56
|
+
return null;
|
|
57
|
+
if (typeof payload.nbf === "number" && now < payload.nbf)
|
|
58
|
+
return null;
|
|
59
|
+
return payload;
|
|
60
|
+
}
|
|
61
|
+
/** HS256 via a shared secret. The dev/default strategy. */
|
|
62
|
+
export class HmacStrategy {
|
|
63
|
+
secret;
|
|
64
|
+
constructor(secret) {
|
|
65
|
+
this.secret = secret;
|
|
66
|
+
}
|
|
67
|
+
verify(token) {
|
|
68
|
+
return verifyJwt(token, async (input, signature, header) => {
|
|
69
|
+
if (header.alg !== "HS256" || !this.secret)
|
|
70
|
+
return false;
|
|
71
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(this.secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
|
72
|
+
return crypto.subtle.verify("HMAC", key, signature, new TextEncoder().encode(input));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const SINGLE_KEY = "\0single";
|
|
77
|
+
/** RS256 verified against a remote JWKS. Public keys are fetched once and cached
|
|
78
|
+
* (TTL); a token with an unknown `kid` triggers one forced refetch to pick up key
|
|
79
|
+
* rotation. Stale keys are kept if a refetch fails. */
|
|
80
|
+
export class JwksStrategy {
|
|
81
|
+
url;
|
|
82
|
+
ttlMs;
|
|
83
|
+
keys = new Map();
|
|
84
|
+
fetchedAt = 0;
|
|
85
|
+
inflight = null;
|
|
86
|
+
constructor(url, ttlMs = 600_000) {
|
|
87
|
+
this.url = url;
|
|
88
|
+
this.ttlMs = ttlMs;
|
|
89
|
+
}
|
|
90
|
+
verify(token) {
|
|
91
|
+
return verifyJwt(token, async (input, signature, header) => {
|
|
92
|
+
if (header.alg !== "RS256")
|
|
93
|
+
return false;
|
|
94
|
+
const key = await this.keyFor(header.kid);
|
|
95
|
+
if (!key)
|
|
96
|
+
return false;
|
|
97
|
+
return crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signature, new TextEncoder().encode(input));
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
lookup(kid) {
|
|
101
|
+
if (kid)
|
|
102
|
+
return this.keys.get(kid) ?? null;
|
|
103
|
+
if (this.keys.size === 1)
|
|
104
|
+
return [...this.keys.values()][0]; // no kid + single key
|
|
105
|
+
return this.keys.get(SINGLE_KEY) ?? null;
|
|
106
|
+
}
|
|
107
|
+
async keyFor(kid) {
|
|
108
|
+
await this.refresh(false);
|
|
109
|
+
let key = this.lookup(kid);
|
|
110
|
+
if (!key) {
|
|
111
|
+
await this.refresh(true); // unknown kid -> force a refetch (key rotation)
|
|
112
|
+
key = this.lookup(kid);
|
|
113
|
+
}
|
|
114
|
+
return key;
|
|
115
|
+
}
|
|
116
|
+
async refresh(force) {
|
|
117
|
+
if (!force && this.keys.size > 0 && Date.now() - this.fetchedAt < this.ttlMs)
|
|
118
|
+
return;
|
|
119
|
+
if (this.inflight)
|
|
120
|
+
return this.inflight;
|
|
121
|
+
this.inflight = this.fetchKeys();
|
|
122
|
+
try {
|
|
123
|
+
await this.inflight;
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
this.inflight = null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async fetchKeys() {
|
|
130
|
+
try {
|
|
131
|
+
const res = await fetch(this.url);
|
|
132
|
+
if (!res.ok)
|
|
133
|
+
return; // keep stale keys
|
|
134
|
+
const body = (await res.json());
|
|
135
|
+
const next = new Map();
|
|
136
|
+
for (const jwk of body.keys ?? []) {
|
|
137
|
+
if (jwk.kty !== "RSA")
|
|
138
|
+
continue;
|
|
139
|
+
const key = await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]);
|
|
140
|
+
next.set(jwk.kid ?? SINGLE_KEY, key);
|
|
141
|
+
}
|
|
142
|
+
if (next.size > 0) {
|
|
143
|
+
this.keys = next;
|
|
144
|
+
this.fetchedAt = Date.now();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// network error -> keep whatever keys we have
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function toIdentity(claims) {
|
|
153
|
+
const roles = Array.isArray(claims.roles)
|
|
154
|
+
? claims.roles
|
|
155
|
+
: typeof claims.role === "string"
|
|
156
|
+
? [claims.role]
|
|
157
|
+
: [];
|
|
158
|
+
const identity = { roles, userId: (claims.sub ?? claims.userId) };
|
|
159
|
+
for (const [k, v] of Object.entries(claims)) {
|
|
160
|
+
if (!STANDARD_CLAIMS.has(k))
|
|
161
|
+
identity[k] = v; // carry custom claims (tier, …)
|
|
162
|
+
}
|
|
163
|
+
return identity;
|
|
164
|
+
}
|
|
165
|
+
export async function resolveIdentity(request, strategy) {
|
|
166
|
+
const token = request.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
167
|
+
if (!token)
|
|
168
|
+
return null;
|
|
169
|
+
const claims = await strategy.verify(token);
|
|
170
|
+
return claims ? toIdentity(claims) : null;
|
|
171
|
+
}
|
|
172
|
+
/** May this identity address the given tenant? Gates `X-Pramen-Tenant` so a caller
|
|
173
|
+
* can't reach (or register) arbitrary tenants. Default policy: admins → any
|
|
174
|
+
* tenant; everyone else → only tenants listed in their `tenants` claim. Customize
|
|
175
|
+
* for your tenancy model (e.g. tenant === identity.org, or a lookup). */
|
|
176
|
+
export function authorizeTenant(identity, tenant) {
|
|
177
|
+
// Anonymous (no verified token) may reach only the default tenant — enough for
|
|
178
|
+
// first-class public flows (the `anonymous` ACL role still gates the data), while
|
|
179
|
+
// not letting unauthenticated callers address/register arbitrary tenants.
|
|
180
|
+
if (!identity)
|
|
181
|
+
return tenant === "main";
|
|
182
|
+
if (identity.roles?.includes("admin"))
|
|
183
|
+
return true;
|
|
184
|
+
const allowed = Array.isArray(identity.tenants) ? identity.tenants : [];
|
|
185
|
+
return allowed.includes(tenant);
|
|
186
|
+
}
|
|
187
|
+
export function isAdmin(identity) {
|
|
188
|
+
return identity?.roles?.includes("admin") ?? false;
|
|
189
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
import type { PramenApp } from "./pramen";
|
|
3
|
+
export interface DoEnv {
|
|
4
|
+
/** Project KV — tenant registry (`tenant:`) + handler ctx.kv (`app:`). */
|
|
5
|
+
KV: KVNamespace;
|
|
6
|
+
/** R2 bucket backing ctx.files + the Worker /files/* route. */
|
|
7
|
+
FILES: R2Bucket;
|
|
8
|
+
/** HMAC secret for signing file upload/download tokens (falls back to AUTH_SECRET). */
|
|
9
|
+
FILES_SECRET?: string;
|
|
10
|
+
/** Bearer-JWT secret; also the fallback for signing file tokens. */
|
|
11
|
+
AUTH_SECRET?: string;
|
|
12
|
+
/** "true" to apply destructive schema migrations (drop/rebuild/type-change). Off by
|
|
13
|
+
* default — data-loss is gated behind this explicit opt-in. */
|
|
14
|
+
PRAMEN_ALLOW_DESTRUCTIVE?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare class PramenDOBase extends DurableObject<DoEnv> {
|
|
17
|
+
private readonly app;
|
|
18
|
+
private readonly acl;
|
|
19
|
+
private readonly kv;
|
|
20
|
+
private readonly driver;
|
|
21
|
+
private registered;
|
|
22
|
+
/** Tenant this DO serves (one per idFromName). Learned from the Worker-forwarded
|
|
23
|
+
* x-pramen-tenant header; defaults to "main". */
|
|
24
|
+
private tenant;
|
|
25
|
+
private files?;
|
|
26
|
+
constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp);
|
|
27
|
+
fetch(request: Request): Promise<Response>;
|
|
28
|
+
webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise<void>;
|
|
29
|
+
webSocketClose(ws: WebSocket): Promise<void>;
|
|
30
|
+
webSocketError(ws: WebSocket, error: unknown): Promise<void>;
|
|
31
|
+
private onSubscribe;
|
|
32
|
+
private onCall;
|
|
33
|
+
private broadcast;
|
|
34
|
+
private ensureRegistered;
|
|
35
|
+
private handleRecover;
|
|
36
|
+
private handleSchema;
|
|
37
|
+
private ctxFor;
|
|
38
|
+
private get envBag();
|
|
39
|
+
private filesFor;
|
|
40
|
+
private identityOf;
|
|
41
|
+
private getState;
|
|
42
|
+
private setState;
|
|
43
|
+
private send;
|
|
44
|
+
}
|
|
45
|
+
/** Produce the concrete, app-bound Durable Object class. A DO is constructed by the
|
|
46
|
+
* platform with just (ctx, env), so the app is closed over here. Re-export the
|
|
47
|
+
* result from your Worker entry under the class name wrangler expects ("PramenDO"). */
|
|
48
|
+
export declare function pramenDO(app: PramenApp): typeof PramenDOBase;
|
|
@@ -0,0 +1,282 @@
|
|
|
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
|
+
import { DurableObject } from "cloudflare:workers";
|
|
18
|
+
import { migrate } from "./runtime/migrate";
|
|
19
|
+
import { dispatch } from "./runtime/dispatch";
|
|
20
|
+
import { digest } from "./runtime/digest";
|
|
21
|
+
import { compileAcl } from "./runtime/acl";
|
|
22
|
+
import { DoSqliteDriver } from "./runtime/driver";
|
|
23
|
+
import { BadRequest, toResponse, toWsError } from "./runtime/errors";
|
|
24
|
+
import { Kv } from "./runtime/kv";
|
|
25
|
+
import { createFiles, R2Adapter } from "./runtime/storage";
|
|
26
|
+
/** Per-socket subscription cap — bounds memory and per-mutation re-run cost. */
|
|
27
|
+
const MAX_SUBSCRIPTIONS = 64;
|
|
28
|
+
export class PramenDOBase extends DurableObject {
|
|
29
|
+
app;
|
|
30
|
+
acl;
|
|
31
|
+
kv;
|
|
32
|
+
driver;
|
|
33
|
+
registered = false;
|
|
34
|
+
/** Tenant this DO serves (one per idFromName). Learned from the Worker-forwarded
|
|
35
|
+
* x-pramen-tenant header; defaults to "main". */
|
|
36
|
+
tenant = "main";
|
|
37
|
+
files;
|
|
38
|
+
constructor(ctx, env, app) {
|
|
39
|
+
super(ctx, env);
|
|
40
|
+
this.app = app;
|
|
41
|
+
this.acl = compileAcl(this.app.acl ?? []);
|
|
42
|
+
this.kv = new Kv(env.KV); // app:-prefixed, handed to handlers as ctx.kv
|
|
43
|
+
// The data layer runs over a Driver. The DO's store is its own in-process SQLite;
|
|
44
|
+
// the D1 substrate (D1Driver) lives in the Worker (the "Worker + D1, no DO" path).
|
|
45
|
+
this.driver = new DoSqliteDriver(ctx.storage);
|
|
46
|
+
// Reconcile the store with the schema before any request is served (create/alter
|
|
47
|
+
// tables; destructive changes rebuild the table). Wrapped in a transaction so a
|
|
48
|
+
// partial migration can't leave a half-rebuilt table.
|
|
49
|
+
const allowDestructive = env.PRAMEN_ALLOW_DESTRUCTIVE === "true";
|
|
50
|
+
ctx.blockConcurrencyWhile(() => this.driver.transaction(() => migrate(this.driver, this.app.schema, { allowDestructive }).then(() => { })));
|
|
51
|
+
}
|
|
52
|
+
async fetch(request) {
|
|
53
|
+
await this.ensureRegistered(request);
|
|
54
|
+
const tenantHeader = request.headers.get("x-pramen-tenant");
|
|
55
|
+
if (tenantHeader)
|
|
56
|
+
this.tenant = tenantHeader;
|
|
57
|
+
const path = new URL(request.url).pathname;
|
|
58
|
+
if (path === "/__recover")
|
|
59
|
+
return this.handleRecover(request);
|
|
60
|
+
if (path === "/__schema")
|
|
61
|
+
return this.handleSchema();
|
|
62
|
+
const identity = this.identityOf(request);
|
|
63
|
+
if (request.headers.get("Upgrade") === "websocket") {
|
|
64
|
+
const { 0: client, 1: server } = new WebSocketPair();
|
|
65
|
+
this.ctx.acceptWebSocket(server); // hibernatable
|
|
66
|
+
this.setState(server, { identity, tenant: this.tenant, subs: [] });
|
|
67
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
68
|
+
}
|
|
69
|
+
const name = new URL(request.url).pathname.replace(/^\/rpc\//, "");
|
|
70
|
+
let input;
|
|
71
|
+
if (request.method === "POST") {
|
|
72
|
+
input = await request.json().catch(() => undefined);
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(this.tenant), this.envBag, this.ctxFor(identity), name, input);
|
|
76
|
+
if (kind === "mutation" && touched.length > 0)
|
|
77
|
+
await this.broadcast(touched);
|
|
78
|
+
return Response.json({ ok: true, result });
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
const { status, body } = toResponse(err);
|
|
82
|
+
return Response.json(body, { status });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// --- Hibernatable WebSocket handlers ---
|
|
86
|
+
async webSocketMessage(ws, raw) {
|
|
87
|
+
let msg;
|
|
88
|
+
try {
|
|
89
|
+
msg = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return this.send(ws, { type: "error", id: "", error: "invalid JSON" });
|
|
93
|
+
}
|
|
94
|
+
switch (msg.type) {
|
|
95
|
+
case "subscribe":
|
|
96
|
+
return this.onSubscribe(ws, msg.id, msg.name, msg.input);
|
|
97
|
+
case "unsubscribe": {
|
|
98
|
+
const state = this.getState(ws);
|
|
99
|
+
this.setState(ws, { ...state, subs: state.subs.filter((s) => s.id !== msg.id) });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
case "call":
|
|
103
|
+
return this.onCall(ws, msg.id, msg.name, msg.input);
|
|
104
|
+
default:
|
|
105
|
+
return this.send(ws, { type: "error", id: "", error: "unknown message type" });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async webSocketClose(ws) {
|
|
109
|
+
ws.close();
|
|
110
|
+
}
|
|
111
|
+
async webSocketError(ws, error) {
|
|
112
|
+
console.error("pramen: websocket error", error);
|
|
113
|
+
try {
|
|
114
|
+
ws.close(1011, "error");
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
/* already closing */
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// --- live-query internals ---
|
|
121
|
+
async onSubscribe(ws, id, name, input) {
|
|
122
|
+
const state = this.getState(ws);
|
|
123
|
+
try {
|
|
124
|
+
const replacing = state.subs.some((s) => s.id === id);
|
|
125
|
+
if (!replacing && state.subs.length >= MAX_SUBSCRIPTIONS) {
|
|
126
|
+
return this.send(ws, toWsError(id, new BadRequest("subscription limit reached")));
|
|
127
|
+
}
|
|
128
|
+
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);
|
|
129
|
+
if (kind !== "query") {
|
|
130
|
+
return this.send(ws, toWsError(id, new BadRequest(`${name} is not a query`)));
|
|
131
|
+
}
|
|
132
|
+
const subs = state.subs.filter((s) => s.id !== id);
|
|
133
|
+
subs.push({ id, name, input, tables: touched, digest: digest(result) });
|
|
134
|
+
this.setState(ws, { ...state, subs });
|
|
135
|
+
this.send(ws, { type: "data", id, result });
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
this.send(ws, toWsError(id, err));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async onCall(ws, id, name, input) {
|
|
142
|
+
const state = this.getState(ws);
|
|
143
|
+
try {
|
|
144
|
+
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);
|
|
145
|
+
this.send(ws, { type: "result", id, result });
|
|
146
|
+
if (kind === "mutation" && touched.length > 0)
|
|
147
|
+
await this.broadcast(touched);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
this.send(ws, toWsError(id, err));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Re-run every subscription whose read-set intersects the written tables, each
|
|
154
|
+
// under its own socket's identity, and push only when its result changed.
|
|
155
|
+
async broadcast(touched) {
|
|
156
|
+
const written = new Set(touched);
|
|
157
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
158
|
+
const state = this.getState(ws);
|
|
159
|
+
let dirty = false;
|
|
160
|
+
for (const sub of state.subs) {
|
|
161
|
+
if (!sub.tables.some((t) => written.has(t)))
|
|
162
|
+
continue;
|
|
163
|
+
try {
|
|
164
|
+
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);
|
|
165
|
+
const next = digest(result);
|
|
166
|
+
if (next === sub.digest)
|
|
167
|
+
continue; // result unchanged for this subscription
|
|
168
|
+
sub.digest = next;
|
|
169
|
+
dirty = true;
|
|
170
|
+
this.send(ws, { type: "data", id: sub.id, result });
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
this.send(ws, toWsError(sub.id, err));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (dirty)
|
|
177
|
+
this.setState(ws, state);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// --- helpers ---
|
|
181
|
+
// A DO addressed by idFromName(tenant) doesn't know its own name — the Worker
|
|
182
|
+
// forwards it. On the first touch ever (guarded by a persisted meta flag), the
|
|
183
|
+
// tenant records itself in the registry KV so it stays discoverable. Exactly
|
|
184
|
+
// one KV write per tenant across its whole lifetime.
|
|
185
|
+
async ensureRegistered(request) {
|
|
186
|
+
if (this.registered)
|
|
187
|
+
return;
|
|
188
|
+
const name = request.headers.get("x-pramen-tenant");
|
|
189
|
+
if (!name)
|
|
190
|
+
return;
|
|
191
|
+
const seen = await this.driver.exec(`SELECT 1 FROM _pramen_meta WHERE key = 'registered'`, []);
|
|
192
|
+
if (seen.length > 0) {
|
|
193
|
+
this.registered = true;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
await this.env.KV.put(`tenant:${name}`, JSON.stringify({ firstSeen: Date.now() }));
|
|
197
|
+
await this.driver.exec(`INSERT OR REPLACE INTO _pramen_meta (key, value) VALUES ('registered', ?)`, [name]);
|
|
198
|
+
this.registered = true;
|
|
199
|
+
}
|
|
200
|
+
// Point-in-time recovery (admin-gated at the Worker). Arms a restore to the
|
|
201
|
+
// given time and returns the `undo` bookmark (the point just before recovery,
|
|
202
|
+
// so the operation is reversible). We intentionally do NOT call ctx.abort()
|
|
203
|
+
// here, so this response can return the undo bookmark — the restore completes
|
|
204
|
+
// when the DO next restarts. PITR is unavailable in local dev (no change-log).
|
|
205
|
+
async handleRecover(request) {
|
|
206
|
+
const body = (await request.json().catch(() => ({})));
|
|
207
|
+
const ts = typeof body.timestamp === "number" ? body.timestamp : Date.parse(String(body.timestamp));
|
|
208
|
+
if (!Number.isFinite(ts)) {
|
|
209
|
+
return Response.json({ ok: false, error: "invalid timestamp", code: "bad_request" }, { status: 400 });
|
|
210
|
+
}
|
|
211
|
+
const storage = this.ctx.storage;
|
|
212
|
+
try {
|
|
213
|
+
const bookmark = await storage.getBookmarkForTime(ts);
|
|
214
|
+
const undo = await storage.onNextSessionRestoreBookmark(bookmark);
|
|
215
|
+
return Response.json({ ok: true, result: { restoredTo: ts, bookmark, undo, applied: false } });
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
// PITR is a platform feature — unavailable in local dev, and can otherwise
|
|
219
|
+
// fail operationally. Report 501 (not a generic 500); log the real reason.
|
|
220
|
+
console.error("pramen: recovery unavailable", err);
|
|
221
|
+
return Response.json({ ok: false, error: "point-in-time recovery is unavailable in this environment", code: "unavailable" }, { status: 501 });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Introspection: this tenant's applied schema hash + live table/column shape
|
|
225
|
+
// (admin-gated at the Worker). Powers the CLI's `schema status`.
|
|
226
|
+
async handleSchema() {
|
|
227
|
+
const hashRow = (await this.driver.exec(`SELECT value FROM _pramen_meta WHERE key = 'schema_hash'`, []));
|
|
228
|
+
const tableRows = (await this.driver.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> '_pramen_meta'`, []));
|
|
229
|
+
const tables = {};
|
|
230
|
+
for (const { name } of tableRows) {
|
|
231
|
+
tables[name] = (await this.driver.exec(`PRAGMA table_info(${name})`, [])).map((r) => r.name);
|
|
232
|
+
}
|
|
233
|
+
return Response.json({ ok: true, result: { hash: hashRow[0]?.value ?? null, tables } });
|
|
234
|
+
}
|
|
235
|
+
ctxFor(identity) {
|
|
236
|
+
return { acl: this.acl, identity };
|
|
237
|
+
}
|
|
238
|
+
// The DO env (bindings + vars + secrets) handed to handlers as ctx.env. Loosely
|
|
239
|
+
// typed at the boundary so handlers can read any var/secret without a DoEnv cast.
|
|
240
|
+
get envBag() {
|
|
241
|
+
return this.env;
|
|
242
|
+
}
|
|
243
|
+
// One Files facade per DO (a DO serves one tenant). Backed by the R2 binding;
|
|
244
|
+
// signing uses FILES_SECRET. Handlers mint signed urls; the bytes never enter here.
|
|
245
|
+
filesFor(tenant) {
|
|
246
|
+
if (!this.files) {
|
|
247
|
+
const secret = this.env.FILES_SECRET || this.env.AUTH_SECRET || "";
|
|
248
|
+
this.files = createFiles({ tenant, secret, adapter: new R2Adapter(this.env.FILES) });
|
|
249
|
+
}
|
|
250
|
+
return this.files;
|
|
251
|
+
}
|
|
252
|
+
identityOf(request) {
|
|
253
|
+
const raw = request.headers.get("x-pramen-identity");
|
|
254
|
+
if (!raw)
|
|
255
|
+
return null;
|
|
256
|
+
try {
|
|
257
|
+
return JSON.parse(raw);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
getState(ws) {
|
|
264
|
+
return ws.deserializeAttachment() ?? { identity: null, tenant: this.tenant, subs: [] };
|
|
265
|
+
}
|
|
266
|
+
setState(ws, state) {
|
|
267
|
+
ws.serializeAttachment(state);
|
|
268
|
+
}
|
|
269
|
+
send(ws, msg) {
|
|
270
|
+
ws.send(JSON.stringify(msg));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Produce the concrete, app-bound Durable Object class. A DO is constructed by the
|
|
274
|
+
* platform with just (ctx, env), so the app is closed over here. Re-export the
|
|
275
|
+
* result from your Worker entry under the class name wrangler expects ("PramenDO"). */
|
|
276
|
+
export function pramenDO(app) {
|
|
277
|
+
return class PramenDO extends PramenDOBase {
|
|
278
|
+
constructor(ctx, env) {
|
|
279
|
+
super(ctx, env, app);
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo } from "./sdk/schema";
|
|
2
|
+
export type { DefaultValue, FieldType, FieldDef, EntityFields, EntityDef, SchemaDef, RelationDef, RelationDefs, BelongsToDef, HasManyDef, } from "./sdk/schema";
|
|
3
|
+
export { createApp } from "./sdk/app";
|
|
4
|
+
export { query, mutation } from "./sdk/handlers";
|
|
5
|
+
export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts } from "./sdk/handlers";
|
|
6
|
+
export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
|
|
7
|
+
export type { Action, Identity, IdentityMarker, InputMarker, Policy, PolicyRule, PolicyRules, Role, Validator, WhereRule, ConditionalFields, FieldsFn, RelationAclRule, SetValue, ResolverFn, ResolverContext, ResolverDb, } from "./sdk/acl";
|
|
8
|
+
export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, ProjectedRow, RelationsOf, RelationsResult, WhereInput, WhereOps, } from "./sdk/infer";
|
|
9
|
+
export type { FileRef, Files, SignUploadOpts, SignDownloadOpts, HeadResult } from "./sdk/files";
|
|
10
|
+
export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
|
|
11
|
+
export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
|
|
12
|
+
export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
|
|
13
|
+
export { sqliteDialect, postgresDialect, DoSqliteDriver, D1Driver } from "./runtime/driver";
|
|
14
|
+
export type { Driver, Dialect, Row } from "./runtime/driver";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
// --- schema authoring ---
|
|
10
|
+
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, defaultTo } from "./sdk/schema";
|
|
11
|
+
// --- app + handlers ---
|
|
12
|
+
export { createApp } from "./sdk/app";
|
|
13
|
+
export { query, mutation } from "./sdk/handlers";
|
|
14
|
+
// --- ACL ---
|
|
15
|
+
export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
|
|
16
|
+
export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
|
|
17
|
+
// --- errors ---
|
|
18
|
+
export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
|
|
19
|
+
// --- substrate seam (advanced: bring your own SQL backend) ---
|
|
20
|
+
export { sqliteDialect, postgresDialect, DoSqliteDriver, D1Driver } from "./runtime/driver";
|
package/dist/pramen.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type Env } from "./worker";
|
|
2
|
+
import { pramenDO, type DoEnv } from "./durable-object";
|
|
3
|
+
import type { SchemaDef } from "./sdk/schema";
|
|
4
|
+
import type { HandlerMap } from "./sdk/handlers";
|
|
5
|
+
import type { Role } from "./sdk/acl";
|
|
6
|
+
/** Injected into a public route's handler — forward a privileged mutation into the
|
|
7
|
+
* tenant's DO without the handler importing any deploy-side code (so app.ts stays
|
|
8
|
+
* authoring-only). The synthetic identity defaults to the admin role. */
|
|
9
|
+
export interface RouteContext {
|
|
10
|
+
callPrivileged(opts: {
|
|
11
|
+
name: string;
|
|
12
|
+
input?: unknown;
|
|
13
|
+
tenant?: string;
|
|
14
|
+
roles?: string[];
|
|
15
|
+
}): Promise<Response>;
|
|
16
|
+
}
|
|
17
|
+
/** A public, pre-auth route — matched before identity resolution, so it can host a
|
|
18
|
+
* signature-authenticated endpoint (e.g. a Stripe webhook) that doesn't fit the
|
|
19
|
+
* JWT-gated /rpc surface. The handler verifies its own auth (a signature), then can
|
|
20
|
+
* `ctx.callPrivileged(...)` to apply a mutation. `env` is loosely typed here so the
|
|
21
|
+
* app definition stays platform-agnostic. */
|
|
22
|
+
export interface PublicRoute {
|
|
23
|
+
/** HTTP method to match (e.g. "POST"). */
|
|
24
|
+
method: string;
|
|
25
|
+
/** Exact pathname to match (e.g. "/stripe/webhook"). */
|
|
26
|
+
path: string;
|
|
27
|
+
handler: (request: Request, env: Readonly<Record<string, unknown>>, ctx: RouteContext) => Response | Promise<Response>;
|
|
28
|
+
}
|
|
29
|
+
/** The user-facing app: a schema, the handler map, ACL roles, and optional public
|
|
30
|
+
* (pre-auth) routes. `example/app.ts` exports this shape. */
|
|
31
|
+
export interface PramenApp {
|
|
32
|
+
schema: SchemaDef;
|
|
33
|
+
handlers: HandlerMap;
|
|
34
|
+
acl?: Role[];
|
|
35
|
+
routes?: PublicRoute[];
|
|
36
|
+
}
|
|
37
|
+
export type { Env, DoEnv };
|
|
38
|
+
/** Build the deployable pair for an app. */
|
|
39
|
+
export declare function createPramen(app: PramenApp): {
|
|
40
|
+
fetch: (request: Request, env: Env) => Promise<Response>;
|
|
41
|
+
PramenDO: ReturnType<typeof pramenDO>;
|
|
42
|
+
};
|
package/dist/pramen.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// createPramen(app) — the server library entry. Turns an app (schema + handlers +
|
|
2
|
+
// ACL) into the two things a Cloudflare deployment needs: a Worker `fetch` and the
|
|
3
|
+
// `PramenDO` Durable Object class. A consumer's whole entry is three lines:
|
|
4
|
+
//
|
|
5
|
+
// import { createPramen } from "@pramen/server";
|
|
6
|
+
// import { app } from "./app";
|
|
7
|
+
// const pramen = createPramen(app);
|
|
8
|
+
// export default { fetch: pramen.fetch };
|
|
9
|
+
// export const PramenDO = pramen.PramenDO; // wrangler binds this by class_name
|
|
10
|
+
//
|
|
11
|
+
// The DO class is produced per-app (pramenDO closes over `app`) because the platform
|
|
12
|
+
// constructs a DO with only (ctx, env). PramenApp is defined here and imported
|
|
13
|
+
// type-only by worker.ts / durable-object.ts, so there is no runtime import cycle.
|
|
14
|
+
import { makeWorker } from "./worker";
|
|
15
|
+
import { pramenDO } from "./durable-object";
|
|
16
|
+
/** Build the deployable pair for an app. */
|
|
17
|
+
export function createPramen(app) {
|
|
18
|
+
return { fetch: makeWorker(app).fetch, PramenDO: pramenDO(app) };
|
|
19
|
+
}
|