@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/pramen.ts ADDED
@@ -0,0 +1,58 @@
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
+
15
+ import { makeWorker, type Env } from "./worker";
16
+ import { pramenDO, type DoEnv } from "./durable-object";
17
+ import type { SchemaDef } from "./sdk/schema";
18
+ import type { HandlerMap } from "./sdk/handlers";
19
+ import type { Role } from "./sdk/acl";
20
+
21
+ /** Injected into a public route's handler — forward a privileged mutation into the
22
+ * tenant's DO without the handler importing any deploy-side code (so app.ts stays
23
+ * authoring-only). The synthetic identity defaults to the admin role. */
24
+ export interface RouteContext {
25
+ callPrivileged(opts: { name: string; input?: unknown; tenant?: string; roles?: string[] }): Promise<Response>;
26
+ }
27
+
28
+ /** A public, pre-auth route — matched before identity resolution, so it can host a
29
+ * signature-authenticated endpoint (e.g. a Stripe webhook) that doesn't fit the
30
+ * JWT-gated /rpc surface. The handler verifies its own auth (a signature), then can
31
+ * `ctx.callPrivileged(...)` to apply a mutation. `env` is loosely typed here so the
32
+ * app definition stays platform-agnostic. */
33
+ export interface PublicRoute {
34
+ /** HTTP method to match (e.g. "POST"). */
35
+ method: string;
36
+ /** Exact pathname to match (e.g. "/stripe/webhook"). */
37
+ path: string;
38
+ handler: (request: Request, env: Readonly<Record<string, unknown>>, ctx: RouteContext) => Response | Promise<Response>;
39
+ }
40
+
41
+ /** The user-facing app: a schema, the handler map, ACL roles, and optional public
42
+ * (pre-auth) routes. `example/app.ts` exports this shape. */
43
+ export interface PramenApp {
44
+ schema: SchemaDef;
45
+ handlers: HandlerMap;
46
+ acl?: Role[];
47
+ routes?: PublicRoute[];
48
+ }
49
+
50
+ export type { Env, DoEnv };
51
+
52
+ /** Build the deployable pair for an app. */
53
+ export function createPramen(app: PramenApp): {
54
+ fetch: (request: Request, env: Env) => Promise<Response>;
55
+ PramenDO: ReturnType<typeof pramenDO>;
56
+ } {
57
+ return { fetch: makeWorker(app).fetch, PramenDO: pramenDO(app) };
58
+ }
@@ -0,0 +1,362 @@
1
+ // ACL resolution — the runtime counterpart of sdk/acl.ts.
2
+ // Given an identity + (entity, action), resolves a Scope: whether access is
3
+ // granted, the row-level predicate to merge into the query, and any field
4
+ // restriction. Deny-by-default; grants OR-merge across the identity's roles.
5
+
6
+ import {
7
+ type Action,
8
+ type AllowMarker,
9
+ type DenyMarker,
10
+ type FieldsFn,
11
+ type Identity,
12
+ type PolicyRule,
13
+ type PolicyRules,
14
+ type RelationAclRule,
15
+ type ResolverDb,
16
+ type Role,
17
+ type Validator,
18
+ type WhereRule,
19
+ deny,
20
+ isAllow,
21
+ isDeny,
22
+ isIdentityMarker,
23
+ isInputMarker,
24
+ isResolver,
25
+ } from "../sdk/acl";
26
+ import { compileWhere, evalExpr, FALSE, or, type SqlExpr } from "./read-engine";
27
+ import { PramenError } from "./errors";
28
+
29
+ export class AclDenied extends PramenError {
30
+ constructor(
31
+ readonly entity: string,
32
+ readonly action: Action,
33
+ readonly field?: string,
34
+ ) {
35
+ super(
36
+ field ? `access denied: ${entity}.${field} (${action})` : `access denied: ${action} ${entity}`,
37
+ 403,
38
+ "forbidden",
39
+ );
40
+ this.name = "AclDenied";
41
+ }
42
+ }
43
+
44
+ export interface CompiledAcl {
45
+ /** roleName -> entity\0action -> rules that grant it. */
46
+ readonly byRole: Map<string, Map<string, PolicyRule[]>>;
47
+ }
48
+
49
+ /** Per-request ACL context: the compiled policies plus the caller's identity. */
50
+ export interface AclContext {
51
+ readonly acl: CompiledAcl;
52
+ readonly identity: Identity | null;
53
+ /** The request input (handler args), so policy `where` rules can reference an
54
+ * `$input(...)` marker — a capability / by-unguessable-key read grant. */
55
+ readonly input?: unknown;
56
+ /** Resolver results for this request (resolverId -> rule), from warmup(). */
57
+ readonly resolved?: Map<number, PolicyRule>;
58
+ /** SYSTEM mode bypasses all ACL — used for warmup reads and internal ops. */
59
+ readonly system?: boolean;
60
+ }
61
+
62
+ /** Evaluate every resolver reachable by the identity's roles, once per request.
63
+ * Resolvers read through a SYSTEM-mode db (ACL bypassed) to avoid recursion. */
64
+ export async function warmup(
65
+ acl: CompiledAcl,
66
+ identity: Identity | null,
67
+ db: ResolverDb,
68
+ ): Promise<Map<number, PolicyRule>> {
69
+ const out = new Map<number, PolicyRule>();
70
+ for (const roleName of rolesOf(identity)) {
71
+ const byKey = acl.byRole.get(roleName);
72
+ if (!byKey) continue;
73
+ for (const rules of byKey.values()) {
74
+ for (const rule of rules) {
75
+ if (!isResolver(rule) || out.has(rule.id)) continue;
76
+ try {
77
+ out.set(rule.id, await rule.fn({ identity, db }));
78
+ } catch {
79
+ out.set(rule.id, deny()); // a throwing resolver denies
80
+ }
81
+ }
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+
87
+ const key = (entity: string, action: Action) => `${entity}\0${action}`;
88
+
89
+ export function compileAcl(roles: Role[]): CompiledAcl {
90
+ const byRole = new Map<string, Map<string, PolicyRule[]>>();
91
+ for (const r of roles) {
92
+ const byKey = byRole.get(r.name) ?? new Map<string, PolicyRule[]>();
93
+ for (const p of r.policies) {
94
+ const k = key(p.entity, p.action);
95
+ (byKey.get(k) ?? byKey.set(k, []).get(k)!).push(p.rule);
96
+ }
97
+ byRole.set(r.name, byKey);
98
+ }
99
+ return { byRole };
100
+ }
101
+
102
+ /** A per-row additive field grant: `fields` apply to rows where `when` holds. */
103
+ export interface ConditionalGrant {
104
+ readonly when: SqlExpr;
105
+ readonly fields: string[];
106
+ }
107
+
108
+ export interface Scope {
109
+ readonly allowed: boolean;
110
+ /** Row-level predicate to AND into the query. null = unrestricted. */
111
+ readonly where: SqlExpr | null;
112
+ /** Statically-granted base fields. null = all fields (no per-row narrowing). */
113
+ readonly fields: string[] | null;
114
+ /** Cell-level grants evaluated per row; additive over `fields`. */
115
+ readonly conditional: ConditionalGrant[];
116
+ /** Cell-level function resolvers evaluated per row; additive over `fields`. */
117
+ readonly fieldsFns: FieldsFn[];
118
+ }
119
+
120
+ export const ALLOW_ALL: Scope = { allowed: true, where: null, fields: null, conditional: [], fieldsFns: [] };
121
+ const DENIED: Scope = { allowed: false, where: null, fields: null, conditional: [], fieldsFns: [] };
122
+
123
+ /** A single grant collected during resolution, before OR-merge. */
124
+ interface Grant {
125
+ where: SqlExpr | null;
126
+ fields: string[] | null;
127
+ conditional: ConditionalGrant[];
128
+ fieldsFns: FieldsFn[];
129
+ }
130
+
131
+ /** Build a grant from a policy/relation rule, resolving $identity markers in
132
+ * `where` and each conditional `when`. */
133
+ function grantOf(rule: PolicyRules | RelationAclRule, where: SqlExpr | null, identity: Identity | null, input: unknown): Grant {
134
+ return {
135
+ where,
136
+ fields: rule.fields ?? null,
137
+ conditional: (rule.conditionalFields ?? []).map((cf) => ({
138
+ when: whereToExpr(cf.when, identity, input),
139
+ fields: cf.fields,
140
+ })),
141
+ fieldsFns: rule.fieldsFn ? [rule.fieldsFn] : [],
142
+ };
143
+ }
144
+
145
+ /** The roles to evaluate for a caller. An unauthenticated caller (no verified
146
+ * token) is treated as the `anonymous` role, so an app can grant first-class
147
+ * public reads/writes; if no `anonymous` role is defined this matches nothing
148
+ * (still deny-by-default). */
149
+ function rolesOf(identity: Identity | null): string[] {
150
+ if (!identity) return ["anonymous"];
151
+ if (identity.roles?.length) return identity.roles;
152
+ return identity.role ? [identity.role] : ["anonymous"];
153
+ }
154
+
155
+ function getPath(obj: unknown, path: string): unknown {
156
+ return path.split(".").reduce<unknown>((acc, seg) => (acc == null ? undefined : (acc as Record<string, unknown>)[seg]), obj ?? undefined);
157
+ }
158
+
159
+ const UNRESOLVED = Symbol("unresolved");
160
+
161
+ // Resolve a value that may be an $identity marker (against the caller) or an
162
+ // $input marker (against the request input — a capability/by-key grant). An
163
+ // unresolvable marker yields UNRESOLVED, which makes its rule match nothing.
164
+ function resolveValue(v: unknown, identity: Identity | null, input: unknown): unknown {
165
+ if (isIdentityMarker(v)) {
166
+ const value = getPath(identity, v.path);
167
+ return value === undefined ? UNRESOLVED : value;
168
+ }
169
+ if (isInputMarker(v)) {
170
+ const value = getPath(input, v.path);
171
+ return value === undefined ? UNRESOLVED : value;
172
+ }
173
+ return v;
174
+ }
175
+
176
+ // Resolve every $identity marker in a policy where-rule (bare values, operator
177
+ // objects, in/notIn arrays, AND/OR groups). Returns a plain WhereInput, or null
178
+ // if any marker is unresolvable — in which case the rule matches nothing.
179
+ function resolveMarkers(rule: Record<string, unknown>, identity: Identity | null, input: unknown): Record<string, unknown> | null {
180
+ const out: Record<string, unknown> = {};
181
+ for (const [key, v] of Object.entries(rule)) {
182
+ if (key === "AND" || key === "OR") {
183
+ const groups: Record<string, unknown>[] = [];
184
+ for (const g of v as Record<string, unknown>[]) {
185
+ const resolved = resolveMarkers(g, identity, input);
186
+ if (resolved === null) return null;
187
+ groups.push(resolved);
188
+ }
189
+ out[key] = groups;
190
+ continue;
191
+ }
192
+
193
+ const isMarker = isIdentityMarker(v) || isInputMarker(v);
194
+ if (v !== null && typeof v === "object" && !isMarker && !Array.isArray(v)) {
195
+ const ops: Record<string, unknown> = {};
196
+ for (const [op, val] of Object.entries(v as Record<string, unknown>)) {
197
+ if (op === "in" || op === "notIn") {
198
+ let arr: unknown;
199
+ if (isIdentityMarker(val) || isInputMarker(val)) {
200
+ arr = resolveValue(val, identity, input);
201
+ if (arr === UNRESOLVED) return null;
202
+ } else {
203
+ const mapped = (val as unknown[]).map((x) => resolveValue(x, identity, input));
204
+ if (mapped.some((x) => x === UNRESOLVED)) return null;
205
+ arr = mapped;
206
+ }
207
+ if (!Array.isArray(arr)) return null; // marker must resolve to a list
208
+ ops[op] = arr;
209
+ } else {
210
+ const rv = resolveValue(val, identity, input);
211
+ if (rv === UNRESOLVED) return null;
212
+ ops[op] = rv;
213
+ }
214
+ }
215
+ out[key] = ops;
216
+ } else {
217
+ const rv = resolveValue(v, identity, input);
218
+ if (rv === UNRESOLVED) return null;
219
+ out[key] = rv;
220
+ }
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /** Turn a policy where-rule into an expression. Supports the full user query
226
+ * surface (operators, AND/OR) with $identity / $input markers; an unresolvable
227
+ * marker makes the rule match nothing. */
228
+ function whereToExpr(rule: WhereRule, identity: Identity | null, input: unknown): SqlExpr {
229
+ const resolved = resolveMarkers(rule as Record<string, unknown>, identity, input);
230
+ return resolved === null ? FALSE : compileWhere(resolved);
231
+ }
232
+
233
+ /** The concrete rules that apply for (entity, action) under this identity — with
234
+ * resolvers replaced by their warmup result and resolver/unresolved entries dropped. */
235
+ function matchedRules(ctx: AclContext, entity: string, action: Action): (AllowMarker | DenyMarker | PolicyRules)[] {
236
+ const k = key(entity, action);
237
+ const out: (AllowMarker | DenyMarker | PolicyRules)[] = [];
238
+ for (const roleName of rolesOf(ctx.identity)) {
239
+ const forRole = ctx.acl.byRole.get(roleName)?.get(k);
240
+ if (!forRole) continue;
241
+ for (const raw of forRole) {
242
+ const rule = isResolver(raw) ? ctx.resolved?.get(raw.id) : raw;
243
+ if (rule && !isResolver(rule)) out.push(rule);
244
+ }
245
+ }
246
+ return out;
247
+ }
248
+
249
+ const ALLOW_GRANT: Grant = { where: null, fields: null, conditional: [], fieldsFns: [] };
250
+
251
+ /** OR-merge a set of grants into a Scope. No grants -> denied. Conditional/function
252
+ * field grants concatenate; they only ADD fields to a non-null base. */
253
+ function mergeGrants(grants: Grant[]): Scope {
254
+ if (grants.length === 0) return DENIED;
255
+ let unrestrictedWhere = false;
256
+ let unrestrictedFields = false;
257
+ const orParts: SqlExpr[] = [];
258
+ const fields = new Set<string>();
259
+ const conditional: ConditionalGrant[] = [];
260
+ const fieldsFns: FieldsFn[] = [];
261
+ for (const g of grants) {
262
+ if (g.where === null || g.where.t === "true") unrestrictedWhere = true;
263
+ else orParts.push(g.where);
264
+ if (g.fields === null) unrestrictedFields = true;
265
+ else for (const f of g.fields) fields.add(f);
266
+ conditional.push(...g.conditional);
267
+ fieldsFns.push(...g.fieldsFns);
268
+ }
269
+ const where = unrestrictedWhere ? null : orParts.length === 1 ? orParts[0]! : or(...orParts);
270
+ return {
271
+ allowed: true,
272
+ where,
273
+ fields: unrestrictedFields ? null : [...fields],
274
+ conditional,
275
+ fieldsFns,
276
+ };
277
+ }
278
+
279
+ export function resolveScope(ctx: AclContext, entity: string, action: Action): Scope {
280
+ const grants: Grant[] = [];
281
+ for (const rule of matchedRules(ctx, entity, action)) {
282
+ if (isDeny(rule)) continue;
283
+ if (isAllow(rule)) grants.push(ALLOW_GRANT);
284
+ else grants.push(grantOf(rule, whereToExpr(rule.where ?? {}, ctx.identity, ctx.input), ctx.identity, ctx.input));
285
+ }
286
+ return mergeGrants(grants);
287
+ }
288
+
289
+ /** Effective visible fields for one row = base ∪ matching-conditional ∪ fn-output.
290
+ * Returns null (all fields) when the base is null or a resolver grants everything. */
291
+ export function effectiveFields(
292
+ scope: Scope,
293
+ row: Record<string, unknown>,
294
+ identity: Identity | null,
295
+ ): string[] | null {
296
+ if (scope.fields === null) return null;
297
+ const out = new Set(scope.fields);
298
+ for (const g of scope.conditional) if (evalExpr(g.when, row)) for (const f of g.fields) out.add(f);
299
+ for (const fn of scope.fieldsFns) {
300
+ const extra = fn(identity, row);
301
+ if (extra === null) return null;
302
+ for (const f of extra) out.add(f);
303
+ }
304
+ return [...out];
305
+ }
306
+
307
+ /** Forced values + validators for a write, gathered from matched write policies.
308
+ * `set` values are resolved against the identity; later policies override earlier. */
309
+ export interface WriteRules {
310
+ set: Record<string, unknown>;
311
+ validators: Validator[];
312
+ }
313
+
314
+ export function resolveWriteRules(ctx: AclContext, entity: string, action: Action): WriteRules {
315
+ const set: Record<string, unknown> = {};
316
+ const validators: Validator[] = [];
317
+ for (const rule of matchedRules(ctx, entity, action)) {
318
+ if (isAllow(rule) || isDeny(rule)) continue;
319
+ if (rule.set) {
320
+ for (const [col, v] of Object.entries(rule.set)) {
321
+ set[col] = typeof v === "function" ? (v as (i: Identity | null) => unknown)(ctx.identity) : v;
322
+ }
323
+ }
324
+ if (rule.validate) validators.push(rule.validate);
325
+ }
326
+ return { set, validators };
327
+ }
328
+
329
+ /** Scope for traversing `parentEntity.relName` to `target`. Grants come from the
330
+ * target's own read scope OR a parent read policy's relation rule with directAccess. */
331
+ export function resolveRelationScope(
332
+ ctx: AclContext,
333
+ parentEntity: string,
334
+ relName: string,
335
+ target: string,
336
+ ): Scope {
337
+ if (ctx.system) return ALLOW_ALL;
338
+
339
+ const grants: Grant[] = [];
340
+
341
+ const base = resolveScope(ctx, target, "read");
342
+ if (base.allowed)
343
+ grants.push({ where: base.where, fields: base.fields, conditional: base.conditional, fieldsFns: base.fieldsFns });
344
+
345
+ for (const rule of matchedRules(ctx, parentEntity, "read")) {
346
+ if (isAllow(rule) || isDeny(rule)) continue;
347
+ const rel = rule.relations?.[relName];
348
+ if (rel?.directAccess) {
349
+ grants.push(grantOf(rel, rel.where ? whereToExpr(rel.where, ctx.identity, ctx.input) : null, ctx.identity, ctx.input));
350
+ }
351
+ }
352
+
353
+ return mergeGrants(grants);
354
+ }
355
+
356
+ /** Project a row to the permitted fields. null = all. */
357
+ export function projectRow(row: Record<string, unknown>, fields: string[] | null): Record<string, unknown> {
358
+ if (!fields) return row;
359
+ const out: Record<string, unknown> = {};
360
+ for (const f of fields) if (f in row) out[f] = row[f];
361
+ return out;
362
+ }