@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
@@ -0,0 +1,79 @@
1
+ import type { DefaultValue, EntityDef, EntityFields, FieldDef, RelationDefs, SchemaDef } from "./schema";
2
+ import type { FileRef } from "./files";
3
+ export type { FileRef } from "./files";
4
+ /** Any JSON-serializable value — the type of a `t.json()` column. */
5
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
6
+ [key: string]: JsonValue;
7
+ };
8
+ /** SQL field type -> TypeScript value type. */
9
+ export type FieldTsType<D extends FieldDef> = D["type"] extends "text" ? string : D["type"] extends "boolean" ? boolean : D["type"] extends "json" ? JsonValue : D["type"] extends "fileRef" ? FileRef : number;
10
+ /** A column is non-null iff it's NOT NULL or a primary key. */
11
+ type IsNotNull<D extends FieldDef> = D extends {
12
+ notNull: true;
13
+ } ? true : D extends {
14
+ primaryKey: true;
15
+ } ? true : false;
16
+ export type Cell<D extends FieldDef> = IsNotNull<D> extends true ? FieldTsType<D> : FieldTsType<D> | null;
17
+ /** Row shape returned from reads. */
18
+ export type InferRow<F extends EntityFields> = {
19
+ [K in keyof F]: Cell<F[K]>;
20
+ };
21
+ /** A row whose fields may be projected away by field-level (incl. cell-level) ACL:
22
+ * every column optional. The honest type for a handler that reads through a policy
23
+ * which can drop columns per row — `InferRow` over-claims presence by design. */
24
+ export type ProjectedRow<F extends EntityFields> = {
25
+ [K in keyof F]?: Cell<F[K]>;
26
+ };
27
+ /** Operators available on a column predicate. `like` is string-only. */
28
+ export interface WhereOps<V> {
29
+ eq?: V | null;
30
+ ne?: V | null;
31
+ gt?: V;
32
+ gte?: V;
33
+ lt?: V;
34
+ lte?: V;
35
+ in?: V[];
36
+ notIn?: V[];
37
+ like?: V extends string ? string : never;
38
+ isNull?: boolean;
39
+ }
40
+ /** Predicate input: per-column equality shorthand or an operator object, plus
41
+ * nestable AND/OR groups. */
42
+ export type WhereInput<F extends EntityFields> = {
43
+ [K in keyof F]?: FieldTsType<F[K]> | null | WhereOps<FieldTsType<F[K]>>;
44
+ } & {
45
+ AND?: WhereInput<F>[];
46
+ OR?: WhereInput<F>[];
47
+ };
48
+ /** Patch input for updates: every column optional, value typed (nullable). */
49
+ export type InferUpdate<F extends EntityFields> = Partial<{
50
+ [K in keyof F]: FieldTsType<F[K]> | null;
51
+ }>;
52
+ type RequiredInsertKeys<F extends EntityFields> = {
53
+ [K in keyof F]: IsNotNull<F[K]> extends true ? F[K] extends {
54
+ autoIncrement: true;
55
+ } ? never : F[K] extends {
56
+ default: DefaultValue;
57
+ } ? never : K : never;
58
+ }[keyof F];
59
+ type OptionalInsertKeys<F extends EntityFields> = Exclude<keyof F, RequiredInsertKeys<F>>;
60
+ export type InferInsert<F extends EntityFields> = {
61
+ [K in RequiredInsertKeys<F>]: FieldTsType<F[K]>;
62
+ } & {
63
+ [K in OptionalInsertKeys<F>]?: FieldTsType<F[K]> | null;
64
+ };
65
+ /** Extract a schema entry's fields, e.g. FieldsOf<S["notes"]>. */
66
+ export type FieldsOf<E> = E extends EntityDef<infer F, RelationDefs> ? F : never;
67
+ /** Extract a schema entry's relations. */
68
+ export type RelationsOf<E> = E extends EntityDef<EntityFields, infer R> ? R : Record<string, never>;
69
+ type RelValue<S extends SchemaDef, Rel> = Rel extends {
70
+ kind: "belongsTo";
71
+ target: infer Tg;
72
+ } ? Tg extends keyof S ? InferRow<FieldsOf<S[Tg]>> | null : never : Rel extends {
73
+ kind: "hasMany";
74
+ target: infer Tg;
75
+ } ? Tg extends keyof S ? InferRow<FieldsOf<S[Tg]>>[] : never : never;
76
+ /** The relation properties added to a row by `with`. Optional (present only when selected). */
77
+ export type RelationsResult<S extends SchemaDef, T extends keyof S> = Partial<{
78
+ [K in keyof RelationsOf<S[T]>]: RelValue<S, RelationsOf<S[T]>[K]>;
79
+ }>;
@@ -0,0 +1,5 @@
1
+ // Type-level inference from a schema's field definitions: WhereInput / InferInsert
2
+ // / InferRow. Pure types; erased at runtime. Relies on field builders preserving
3
+ // literals (`as const`), so e.g.
4
+ // `t.id()` is `{ type: "integer"; primaryKey: true; autoIncrement: true; notNull: true }`.
5
+ export {};
@@ -0,0 +1,112 @@
1
+ export type FieldType = "text" | "integer" | "real" | "boolean" | "json" | "fileRef";
2
+ /** A SQL DEFAULT literal (used by the migrator + DDL). */
3
+ export type DefaultValue = string | number | boolean | null;
4
+ export interface FieldDef {
5
+ readonly type: FieldType;
6
+ readonly primaryKey?: boolean;
7
+ readonly autoIncrement?: boolean;
8
+ readonly notNull?: boolean;
9
+ /** A UNIQUE constraint (enforced via a unique index). */
10
+ readonly unique?: boolean;
11
+ /** A (non-unique) index on this column. */
12
+ readonly index?: boolean;
13
+ /** A column DEFAULT (a literal). Makes the column optional on insert. */
14
+ readonly default?: DefaultValue;
15
+ /** Migration hint: this column was previously named X. On boot the migrator
16
+ * rebuilds the table, copying data from the old column. A diff cannot tell a
17
+ * rename from a drop+add, so the rename must be declared explicitly. */
18
+ readonly renamedFrom?: string;
19
+ }
20
+ declare const builders: {
21
+ id: () => {
22
+ readonly type: "integer";
23
+ readonly primaryKey: true;
24
+ readonly autoIncrement: true;
25
+ readonly notNull: true;
26
+ };
27
+ textId: () => {
28
+ readonly type: "text";
29
+ readonly primaryKey: true;
30
+ readonly notNull: true;
31
+ };
32
+ text: () => {
33
+ readonly type: "text";
34
+ };
35
+ int: () => {
36
+ readonly type: "integer";
37
+ };
38
+ real: () => {
39
+ readonly type: "real";
40
+ };
41
+ bool: () => {
42
+ readonly type: "boolean";
43
+ };
44
+ /** Arbitrary JSON, stored in a TEXT column. Handlers read/write the parsed value
45
+ * (a JsonValue); db.ts stringifies on write and parses on read. */
46
+ json: () => {
47
+ readonly type: "json";
48
+ };
49
+ /** A reference to a stored file (R2 object). Holds JSON metadata (a FileRef),
50
+ * not the bytes — upload/download go through ctx.files + the Worker /files/* route. */
51
+ fileRef: () => {
52
+ readonly type: "fileRef";
53
+ };
54
+ };
55
+ export type FieldBuilders = typeof builders;
56
+ export type EntityFields = Record<string, FieldDef>;
57
+ export interface BelongsToDef<T extends string = string> {
58
+ readonly kind: "belongsTo";
59
+ readonly target: T;
60
+ /** Local column holding the target's primary key. */
61
+ readonly column: string;
62
+ }
63
+ export interface HasManyDef<T extends string = string> {
64
+ readonly kind: "hasMany";
65
+ readonly target: T;
66
+ /** Column on the target referring back to this entity's primary key. */
67
+ readonly column: string;
68
+ }
69
+ export type RelationDef = BelongsToDef | HasManyDef;
70
+ export type RelationDefs = Record<string, RelationDef>;
71
+ declare const relationBuilders: {
72
+ belongsTo: <T extends string>(target: T, column: string) => {
73
+ readonly kind: "belongsTo";
74
+ readonly target: T;
75
+ readonly column: string;
76
+ };
77
+ hasMany: <T extends string>(target: T, column: string) => {
78
+ readonly kind: "hasMany";
79
+ readonly target: T;
80
+ readonly column: string;
81
+ };
82
+ };
83
+ export type RelationBuilders = typeof relationBuilders;
84
+ export interface EntityDef<F extends EntityFields = EntityFields, R extends RelationDefs = Record<string, never>> {
85
+ readonly fields: F;
86
+ readonly relations: R;
87
+ }
88
+ export declare function Entity<F extends EntityFields, R extends RelationDefs = Record<string, never>>(build: (t: FieldBuilders) => F, relations?: (r: RelationBuilders) => R): EntityDef<F, R>;
89
+ /** Annotate a field as renamed from a previous column name (migration hint). Wraps
90
+ * a builder result, preserving its literal field type: `title: renamedFrom(t.text(), "name")`. */
91
+ export declare function renamedFrom<F extends FieldDef>(field: F, from: string): F & {
92
+ readonly renamedFrom: string;
93
+ };
94
+ /** Mark a column NOT NULL. */
95
+ export declare function notNull<F extends FieldDef>(field: F): F & {
96
+ readonly notNull: true;
97
+ };
98
+ /** Add a UNIQUE constraint (enforced via a unique index). */
99
+ export declare function unique<F extends FieldDef>(field: F): F & {
100
+ readonly unique: true;
101
+ };
102
+ /** Add a (non-unique) index on the column. */
103
+ export declare function indexed<F extends FieldDef>(field: F): F & {
104
+ readonly index: true;
105
+ };
106
+ /** Give the column a DEFAULT (a literal) — also makes it optional on insert. */
107
+ export declare function defaultTo<F extends FieldDef, D extends DefaultValue>(field: F, value: D): F & {
108
+ readonly default: D;
109
+ };
110
+ export type SchemaDef = Record<string, EntityDef<EntityFields, RelationDefs>>;
111
+ export declare function defineSchema<S extends SchemaDef>(entities: S): S;
112
+ export {};
@@ -0,0 +1,56 @@
1
+ // Schema definition — the portable layer: an `Entity(t => ({...}))` factory and
2
+ // `defineSchema({...})`. Field builders return `as const` literals so
3
+ // the exact field shape survives into the type system; sdk/infer.ts turns that
4
+ // shape into row/where/insert types.
5
+ //
6
+ // Entities may also declare relations (belongsTo / hasMany) via a second builder.
7
+ // Relations reference their target table by name (resolved at runtime) and the
8
+ // foreign-key column. Relation traversal is ACL-governed (see runtime/acl.ts).
9
+ const builders = {
10
+ id: () => ({ type: "integer", primaryKey: true, autoIncrement: true, notNull: true }),
11
+ textId: () => ({ type: "text", primaryKey: true, notNull: true }),
12
+ text: () => ({ type: "text" }),
13
+ int: () => ({ type: "integer" }),
14
+ real: () => ({ type: "real" }),
15
+ bool: () => ({ type: "boolean" }),
16
+ /** Arbitrary JSON, stored in a TEXT column. Handlers read/write the parsed value
17
+ * (a JsonValue); db.ts stringifies on write and parses on read. */
18
+ json: () => ({ type: "json" }),
19
+ /** A reference to a stored file (R2 object). Holds JSON metadata (a FileRef),
20
+ * not the bytes — upload/download go through ctx.files + the Worker /files/* route. */
21
+ fileRef: () => ({ type: "fileRef" }),
22
+ };
23
+ const relationBuilders = {
24
+ belongsTo: (target, column) => ({ kind: "belongsTo", target, column }),
25
+ hasMany: (target, column) => ({ kind: "hasMany", target, column }),
26
+ };
27
+ export function Entity(build, relations) {
28
+ return { fields: build(builders), relations: (relations ? relations(relationBuilders) : {}) };
29
+ }
30
+ /** Annotate a field as renamed from a previous column name (migration hint). Wraps
31
+ * a builder result, preserving its literal field type: `title: renamedFrom(t.text(), "name")`. */
32
+ export function renamedFrom(field, from) {
33
+ return { ...field, renamedFrom: from };
34
+ }
35
+ // --- field modifiers — wrap a builder result, preserving its literal type. They
36
+ // compose: `unique(notNull(t.text()))`, `defaultTo(t.int(), 0)`. (Wrapper style,
37
+ // like renamedFrom — avoids the method/field name clash a `.notNull()` chain hits.)
38
+ /** Mark a column NOT NULL. */
39
+ export function notNull(field) {
40
+ return { ...field, notNull: true };
41
+ }
42
+ /** Add a UNIQUE constraint (enforced via a unique index). */
43
+ export function unique(field) {
44
+ return { ...field, unique: true };
45
+ }
46
+ /** Add a (non-unique) index on the column. */
47
+ export function indexed(field) {
48
+ return { ...field, index: true };
49
+ }
50
+ /** Give the column a DEFAULT (a literal) — also makes it optional on insert. */
51
+ export function defaultTo(field, value) {
52
+ return { ...field, default: value };
53
+ }
54
+ export function defineSchema(entities) {
55
+ return entities;
56
+ }
@@ -0,0 +1,3 @@
1
+ export { createPramen, type PramenApp, type PublicRoute, type Env, type DoEnv } from "./pramen";
2
+ export { makeWorker, callPrivileged } from "./worker";
3
+ export { pramenDO, PramenDOBase } from "./durable-object";
@@ -0,0 +1,8 @@
1
+ // @pramen/server/worker — the DEPLOY entry. This is the only entry that pulls in
2
+ // the Durable Object (and thus `cloudflare:workers`), so it is kept separate from
3
+ // the main authoring entry: tools that merely load an app.ts to read its schema
4
+ // (the CLI, tests, codegen) import from "@pramen/server" and never drag in the DO
5
+ // runtime. A Worker's entry imports createPramen from here.
6
+ export { createPramen } from "./pramen";
7
+ export { makeWorker, callPrivileged } from "./worker";
8
+ export { pramenDO, PramenDOBase } from "./durable-object";
@@ -0,0 +1,41 @@
1
+ import type { PramenApp } from "./pramen";
2
+ export interface Env {
3
+ PRAMEN: DurableObjectNamespace;
4
+ /** Project KV — tenant registry (`tenant:` keys) + handler ctx.kv (`app:` keys). */
5
+ KV: KVNamespace;
6
+ /** HMAC secret for verifying HS256 bearer JWTs. Dev value in wrangler.jsonc;
7
+ * production via `wrangler secret put AUTH_SECRET`. Ignored if JWKS_URL is set. */
8
+ AUTH_SECRET: string;
9
+ /** Optional: a JWKS endpoint. When set, tokens are verified as RS256 against the
10
+ * fetched public keys (HmacStrategy/AUTH_SECRET is bypassed). */
11
+ JWKS_URL?: string;
12
+ /** D1 binding. Enables the "Worker + D1 (no DO)" path — the same schema/ACL/read
13
+ * engine over D1 instead of a Durable Object. Selected per-request via
14
+ * `x-pramen-store: d1`. RPC only (live queries need the DO). */
15
+ DB?: D1Database;
16
+ /** R2 bucket backing file storage (ctx.files + the /files/* route). */
17
+ FILES: R2Bucket;
18
+ /** HMAC secret for signing file tokens. Falls back to AUTH_SECRET if unset. */
19
+ FILES_SECRET?: string;
20
+ /** Optional CORS allowlist for /rpc + /live: comma-separated origins, or `*`.
21
+ * Unset = no CORS headers (same-origin only). Lets a browser client call a
22
+ * cross-origin Worker directly (e.g. a separate dev port). */
23
+ CORS_ORIGINS?: string;
24
+ /** "true" to apply destructive schema migrations on the D1 path. Off by default. */
25
+ PRAMEN_ALLOW_DESTRUCTIVE?: string;
26
+ }
27
+ /** Forward a privileged mutation into a tenant's DO from a public route. The
28
+ * synthetic identity (default `["admin"]`) is trusted because the call originates
29
+ * in the Worker — the same internal mechanism the admin endpoints use. Returns the
30
+ * DO's JSON response (`{ ok, result }` / `{ ok: false, … }`). */
31
+ export declare function callPrivileged(env: Env, opts: {
32
+ name: string;
33
+ input?: unknown;
34
+ tenant?: string;
35
+ roles?: string[];
36
+ }): Promise<Response>;
37
+ /** Build the Worker fetch handler for an app. State (the JWKS cache, the D1
38
+ * compiled-ACL + one-time migration) is per-app, held in this closure. */
39
+ export declare function makeWorker(app: PramenApp): {
40
+ fetch(request: Request, env: Env): Promise<Response>;
41
+ };
package/dist/worker.js ADDED
@@ -0,0 +1,213 @@
1
+ // makeWorker(app) — builds the stateless HTTP front door bound to an app. It
2
+ // authenticates the request, authorizes the tenant, and routes /rpc/<handler> and
3
+ // /live to the per-tenant Durable Object, plus the /files/* route and admin
4
+ // endpoints (/tenants, /admin/recover, /admin/schema). createPramen() pairs the
5
+ // returned fetch with the matching DO class; a consumer just re-exports both.
6
+ import { authorizeTenant, HmacStrategy, isAdmin, JwksStrategy, resolveIdentity } from "./auth";
7
+ import { dispatch } from "./runtime/dispatch";
8
+ import { migrate } from "./runtime/migrate";
9
+ import { compileAcl } from "./runtime/acl";
10
+ import { D1Driver } from "./runtime/driver";
11
+ import { toResponse } from "./runtime/errors";
12
+ import { Kv } from "./runtime/kv";
13
+ import { createFiles, handleFileRequest, R2Adapter } from "./runtime/storage";
14
+ /** The secret used to sign/verify file tokens — a dedicated FILES_SECRET if set,
15
+ * else AUTH_SECRET (so HS256 setups work out of the box). */
16
+ const filesSecret = (env) => env.FILES_SECRET || env.AUTH_SECRET;
17
+ const json = (body, status = 200) => Response.json(body, { status });
18
+ const forbidden = (what) => json({ ok: false, error: `access denied: ${what}`, code: "forbidden" }, 403);
19
+ const badRequest = (msg) => json({ ok: false, error: msg, code: "bad_request" }, 400);
20
+ /** CORS response headers for an allowed origin, or `{}` when CORS is off / the
21
+ * origin isn't allowlisted. Authorization is a request header, never a cookie, so
22
+ * `*` is safe (no credentials mode). */
23
+ function corsHeaders(origin, env) {
24
+ if (!origin || !env.CORS_ORIGINS)
25
+ return {};
26
+ const allow = env.CORS_ORIGINS.split(",").map((s) => s.trim()).filter(Boolean);
27
+ if (!allow.includes("*") && !allow.includes(origin))
28
+ return {};
29
+ return {
30
+ "access-control-allow-origin": allow.includes("*") ? "*" : origin,
31
+ "access-control-allow-methods": "GET, POST, OPTIONS",
32
+ "access-control-allow-headers": "content-type, authorization, x-pramen-tenant, x-pramen-store",
33
+ vary: "origin",
34
+ };
35
+ }
36
+ /** Return a copy of `res` with the CORS headers merged in (no-op if none). */
37
+ function withCors(res, cors) {
38
+ if (Object.keys(cors).length === 0)
39
+ return res;
40
+ const headers = new Headers(res.headers);
41
+ for (const [k, v] of Object.entries(cors))
42
+ headers.set(k, v);
43
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
44
+ }
45
+ /** Forward a privileged mutation into a tenant's DO from a public route. The
46
+ * synthetic identity (default `["admin"]`) is trusted because the call originates
47
+ * in the Worker — the same internal mechanism the admin endpoints use. Returns the
48
+ * DO's JSON response (`{ ok, result }` / `{ ok: false, … }`). */
49
+ export async function callPrivileged(env, opts) {
50
+ const tenant = opts.tenant ?? "main";
51
+ const stub = env.PRAMEN.get(env.PRAMEN.idFromName(tenant));
52
+ return stub.fetch(new Request(`https://do/rpc/${opts.name}`, {
53
+ method: "POST",
54
+ headers: {
55
+ "content-type": "application/json",
56
+ "x-pramen-tenant": tenant,
57
+ "x-pramen-identity": JSON.stringify({ roles: opts.roles ?? ["admin"] }),
58
+ },
59
+ body: JSON.stringify(opts.input ?? {}),
60
+ }));
61
+ }
62
+ /** Build the Worker fetch handler for an app. State (the JWKS cache, the D1
63
+ * compiled-ACL + one-time migration) is per-app, held in this closure. */
64
+ export function makeWorker(app) {
65
+ // JwksStrategy caches fetched public keys, so keep one instance per isolate (keyed
66
+ // by URL) rather than rebuilding it per request. HmacStrategy is stateless.
67
+ let jwks;
68
+ const strategyFor = (env) => {
69
+ if (env.JWKS_URL) {
70
+ if (!jwks || jwks.url !== env.JWKS_URL)
71
+ jwks = new JwksStrategy(env.JWKS_URL);
72
+ return jwks;
73
+ }
74
+ return new HmacStrategy(env.AUTH_SECRET);
75
+ };
76
+ // ACL is compiled once per isolate; the Worker's D1 path reuses it (the DO compiles
77
+ // its own). Schema migration over D1 runs once per isolate (and short-circuits on a
78
+ // stored schema hash thereafter); a failed run is not cached.
79
+ const d1Acl = compileAcl(app.acl ?? []);
80
+ let d1Ready;
81
+ const ensureD1Migrated = (driver, allowDestructive) => {
82
+ if (!d1Ready) {
83
+ d1Ready = migrate(driver, app.schema, { allowDestructive })
84
+ .then(() => undefined)
85
+ .catch((e) => {
86
+ d1Ready = undefined;
87
+ throw e;
88
+ });
89
+ }
90
+ return d1Ready;
91
+ };
92
+ return {
93
+ async fetch(request, env) {
94
+ const url = new URL(request.url);
95
+ // File upload/download stream through the Worker (bytes never touch the DO),
96
+ // authorized purely by the HMAC token in the url — no JWT/tenant routing.
97
+ if (url.pathname.startsWith("/files/")) {
98
+ const res = await handleFileRequest(request, { adapter: new R2Adapter(env.FILES), secret: filesSecret(env) });
99
+ if (res)
100
+ return res;
101
+ }
102
+ // Public (pre-auth) routes — matched before identity resolution, so a
103
+ // signature-authed webhook can live outside the JWT-gated /rpc surface.
104
+ for (const r of app.routes ?? []) {
105
+ if (request.method === r.method && url.pathname === r.path) {
106
+ const routeCtx = { callPrivileged: (opts) => callPrivileged(env, opts) };
107
+ return r.handler(request, env, routeCtx);
108
+ }
109
+ }
110
+ // CORS (opt-in via CORS_ORIGINS) for cross-origin browser clients. Answer the
111
+ // preflight before any auth so the actual request can carry the bearer token.
112
+ const cors = corsHeaders(request.headers.get("origin"), env);
113
+ if (request.method === "OPTIONS" && Object.keys(cors).length > 0) {
114
+ return new Response(null, { status: 204, headers: cors });
115
+ }
116
+ const isWs = request.headers.get("Upgrade") === "websocket";
117
+ // Browser WebSockets can't set headers, so /live accepts the bearer token and
118
+ // tenant via the query string; fold them into headers for the rest of the flow.
119
+ let req = request;
120
+ if (isWs) {
121
+ const h = new Headers(request.headers);
122
+ const qToken = url.searchParams.get("token");
123
+ if (qToken && !h.get("authorization"))
124
+ h.set("authorization", `Bearer ${qToken}`);
125
+ const qTenant = url.searchParams.get("tenant");
126
+ if (qTenant && !h.get("x-pramen-tenant"))
127
+ h.set("x-pramen-tenant", qTenant);
128
+ req = new Request(request, { headers: h });
129
+ }
130
+ const identity = await resolveIdentity(req, strategyFor(env));
131
+ // --- admin: list known tenants ---
132
+ if (url.pathname === "/tenants") {
133
+ if (!isAdmin(identity))
134
+ return forbidden("tenants");
135
+ const list = await env.KV.list({ prefix: "tenant:" });
136
+ return json({ ok: true, result: list.keys.map((k) => k.name.slice("tenant:".length)) });
137
+ }
138
+ // --- admin: point-in-time recovery for a tenant ---
139
+ if (url.pathname === "/admin/recover" && request.method === "POST") {
140
+ if (!isAdmin(identity))
141
+ return forbidden("recover");
142
+ const body = (await request.json().catch(() => ({})));
143
+ if (typeof body.tenant !== "string" || !body.tenant)
144
+ return badRequest("tenant required");
145
+ if (typeof body.timestamp !== "number" && typeof body.timestamp !== "string")
146
+ return badRequest("timestamp required");
147
+ const stub = env.PRAMEN.get(env.PRAMEN.idFromName(body.tenant));
148
+ const internal = new Request("https://do/__recover", {
149
+ method: "POST",
150
+ headers: { "content-type": "application/json", "x-pramen-tenant": body.tenant },
151
+ body: JSON.stringify({ timestamp: body.timestamp }),
152
+ });
153
+ return stub.fetch(internal);
154
+ }
155
+ // --- admin: a tenant's applied schema (hash + tables) ---
156
+ if (url.pathname === "/admin/schema") {
157
+ if (!isAdmin(identity))
158
+ return forbidden("schema");
159
+ const tenant = url.searchParams.get("tenant") ?? "main";
160
+ const stub = env.PRAMEN.get(env.PRAMEN.idFromName(tenant));
161
+ return stub.fetch(new Request("https://do/__schema", { headers: { "x-pramen-tenant": tenant } }));
162
+ }
163
+ const isRpc = url.pathname.startsWith("/rpc/");
164
+ const isLive = url.pathname === "/live";
165
+ if (!isRpc && !(isLive && isWs)) {
166
+ return new Response("pramen — POST /rpc/<handler> (JSON body), or WebSocket /live for live queries. " +
167
+ "Header X-Pramen-Tenant selects the store (default: main). " +
168
+ "Admin: GET /tenants, POST /admin/recover {tenant,timestamp}, GET /admin/schema?tenant=.\n", { headers: { "content-type": "text/plain" } });
169
+ }
170
+ // Authorize the tenant against the identity before reaching the DO, so a
171
+ // caller can't address (or register) tenants they have no claim to.
172
+ const tenant = req.headers.get("x-pramen-tenant") ?? "main";
173
+ if (!authorizeTenant(identity, tenant))
174
+ return withCors(forbidden(`tenant '${tenant}'`), cors);
175
+ // --- Worker + D1 (no DO): the same schema/ACL/read engine over a D1 binding,
176
+ // selected per-request via `x-pramen-store: d1`. RPC only — live queries need the
177
+ // DO (single writer + a socket host). This proof uses ONE shared D1 database
178
+ // across tenants; a real product would add a tenant column or a per-tenant DB.
179
+ if (req.headers.get("x-pramen-store") === "d1") {
180
+ if (!env.DB)
181
+ return badRequest("D1 store is not configured");
182
+ if (isLive)
183
+ return badRequest("live queries require the default (DO) store");
184
+ const name = url.pathname.replace(/^\/rpc\//, "");
185
+ let input;
186
+ if (request.method === "POST")
187
+ input = await request.json().catch(() => undefined);
188
+ const driver = new D1Driver(env.DB);
189
+ const files = createFiles({ tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
190
+ const envBag = env;
191
+ try {
192
+ await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
193
+ const { result } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, envBag, { acl: d1Acl, identity }, name, input);
194
+ return withCors(json({ ok: true, result }), cors);
195
+ }
196
+ catch (err) {
197
+ const { status, body } = toResponse(err);
198
+ return withCors(json(body, status), cors);
199
+ }
200
+ }
201
+ // Forward a trusted identity to the DO (the DO never re-derives it).
202
+ const headers = new Headers(req.headers);
203
+ if (identity)
204
+ headers.set("x-pramen-identity", JSON.stringify(identity));
205
+ else
206
+ headers.delete("x-pramen-identity");
207
+ const stub = env.PRAMEN.get(env.PRAMEN.idFromName(tenant));
208
+ // WebSocket upgrades (101) must be returned untouched; only add CORS to HTTP.
209
+ const res = await stub.fetch(new Request(req, { headers }));
210
+ return isWs ? res : withCors(res, cors);
211
+ },
212
+ };
213
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@pramen/server",
3
+ "version": "0.0.1",
4
+ "description": "pramen server runtime — schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/netvarec/pramen.git",
9
+ "directory": "packages/server"
10
+ },
11
+ "homepage": "https://github.com/netvarec/pramen#readme",
12
+ "bugs": "https://github.com/netvarec/pramen/issues",
13
+ "type": "module",
14
+ "sideEffects": false,
15
+ "exports": {
16
+ ".": {
17
+ "development": "./src/index.ts",
18
+ "bun": "./src/index.ts",
19
+ "workerd": "./src/index.ts",
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./worker": {
24
+ "development": "./src/worker-entry.ts",
25
+ "bun": "./src/worker-entry.ts",
26
+ "workerd": "./src/worker-entry.ts",
27
+ "types": "./dist/worker-entry.d.ts",
28
+ "default": "./dist/worker-entry.js"
29
+ }
30
+ },
31
+ "main": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "files": ["dist", "src"],
34
+ "scripts": {
35
+ "build": "rm -rf dist && tsc -p tsconfig.build.json"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "devDependencies": {
41
+ "@cloudflare/workers-types": "^4.20250101.0"
42
+ }
43
+ }