@pramen/server 0.0.47 → 0.0.49
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 +4 -3
- package/dist/cli.js +15 -9
- package/dist/durable-object.d.ts +1 -0
- package/dist/durable-object.js +10 -5
- package/dist/index.d.ts +3 -3
- package/dist/pramen.d.ts +4 -2
- package/dist/runtime/acl.d.ts +6 -5
- package/dist/runtime/acl.js +1 -5
- package/dist/runtime/db.d.ts +3 -2
- package/dist/runtime/dispatch.d.ts +3 -1
- package/dist/runtime/driver.d.ts +8 -5
- package/dist/runtime/mail.d.ts +2 -1
- package/dist/runtime/protocol.d.ts +4 -3
- package/dist/runtime/queue-consumer.d.ts +4 -2
- package/dist/runtime/queue.d.ts +3 -2
- package/dist/runtime/read-engine.d.ts +11 -9
- package/dist/runtime/read-engine.js +4 -1
- package/dist/runtime/registry.d.ts +4 -1
- package/dist/runtime/registry.js +0 -3
- package/dist/runtime/schema-diff.d.ts +6 -6
- package/dist/runtime/schema-diff.js +4 -4
- package/dist/sdk/acl.d.ts +18 -11
- package/dist/sdk/handlers.d.ts +9 -3
- package/dist/sdk/infer.d.ts +17 -0
- package/dist/worker.d.ts +2 -1
- package/dist/worker.js +16 -10
- package/package.json +1 -1
- package/src/auth.ts +7 -6
- package/src/cli.ts +21 -9
- package/src/durable-object.ts +15 -8
- package/src/index.ts +6 -2
- package/src/pramen.ts +4 -2
- package/src/runtime/acl.ts +37 -31
- package/src/runtime/db.ts +14 -12
- package/src/runtime/dispatch.ts +5 -3
- package/src/runtime/driver.ts +11 -7
- package/src/runtime/mail.ts +2 -1
- package/src/runtime/migrate.ts +2 -1
- package/src/runtime/outbox.ts +2 -1
- package/src/runtime/protocol.ts +5 -3
- package/src/runtime/queue-consumer.ts +4 -2
- package/src/runtime/queue.ts +4 -2
- package/src/runtime/read-engine.ts +27 -21
- package/src/runtime/registry.ts +5 -1
- package/src/runtime/schema-diff.ts +14 -14
- package/src/sdk/acl.ts +29 -14
- package/src/sdk/handlers.ts +10 -3
- package/src/sdk/infer.ts +21 -0
- package/src/worker.ts +20 -11
package/dist/sdk/acl.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CellValue, JsonValue, Row } from "./infer";
|
|
1
2
|
export type Action = "read" | "create" | "update" | "delete";
|
|
2
3
|
/** Runtime identity. Augment with your own properties (userId, tier, …). */
|
|
3
4
|
export interface Identity {
|
|
@@ -8,7 +9,7 @@ export interface Identity {
|
|
|
8
9
|
* expiry per message (see durable-object.ts). Absent for non-expiring / synthetic
|
|
9
10
|
* (callPrivileged) identities, which are therefore never treated as expired. */
|
|
10
11
|
exp?: number;
|
|
11
|
-
[key: string]:
|
|
12
|
+
[key: string]: JsonValue | undefined;
|
|
12
13
|
}
|
|
13
14
|
declare const IDENTITY_MARKER: unique symbol;
|
|
14
15
|
export interface IdentityMarker {
|
|
@@ -17,7 +18,7 @@ export interface IdentityMarker {
|
|
|
17
18
|
}
|
|
18
19
|
/** Reference an identity property in a policy `where`, resolved per request. */
|
|
19
20
|
export declare function $identity(path: string): IdentityMarker;
|
|
20
|
-
export declare function isIdentityMarker(v:
|
|
21
|
+
export declare function isIdentityMarker(v: WhereValue): v is IdentityMarker;
|
|
21
22
|
declare const INPUT_MARKER: unique symbol;
|
|
22
23
|
export interface InputMarker {
|
|
23
24
|
readonly [INPUT_MARKER]: true;
|
|
@@ -28,7 +29,7 @@ export interface InputMarker {
|
|
|
28
29
|
* caller can read a row only by presenting its unguessable key, without being able
|
|
29
30
|
* to enumerate. An absent input value makes the rule match nothing (safe deny). */
|
|
30
31
|
export declare function $input(path: string): InputMarker;
|
|
31
|
-
export declare function isInputMarker(v:
|
|
32
|
+
export declare function isInputMarker(v: WhereValue): v is InputMarker;
|
|
32
33
|
declare const NOW_MARKER: unique symbol;
|
|
33
34
|
export interface NowMarker {
|
|
34
35
|
readonly [NOW_MARKER]: true;
|
|
@@ -49,7 +50,7 @@ export interface NowMarker {
|
|
|
49
50
|
* `toISOString()` — as the CMS `publish` field does — or compare it against
|
|
50
51
|
* `expr.now()`-shaped values only. */
|
|
51
52
|
export declare function $now(): NowMarker;
|
|
52
|
-
export declare function isNowMarker(v:
|
|
53
|
+
export declare function isNowMarker(v: WhereValue): v is NowMarker;
|
|
53
54
|
export interface AllowMarker {
|
|
54
55
|
readonly kind: "allow";
|
|
55
56
|
}
|
|
@@ -58,8 +59,14 @@ export interface DenyMarker {
|
|
|
58
59
|
}
|
|
59
60
|
export declare function allow(): AllowMarker;
|
|
60
61
|
export declare function deny(): DenyMarker;
|
|
61
|
-
/** A where rule:
|
|
62
|
-
|
|
62
|
+
/** A value inside a `where` rule: a literal cell value, a per-request marker, an
|
|
63
|
+
* operator object (`{ gte: 5 }`), a nested relation predicate, or an AND/OR group. */
|
|
64
|
+
export type WhereValue = CellValue | IdentityMarker | InputMarker | NowMarker | WhereRule | WhereValue[];
|
|
65
|
+
/** A where rule: column (or `AND`/`OR`/`NOT`) -> value. An interface so it can recur
|
|
66
|
+
* through `WhereValue` for nested relation predicates and boolean groups. */
|
|
67
|
+
export interface WhereRule {
|
|
68
|
+
[key: string]: WhereValue;
|
|
69
|
+
}
|
|
63
70
|
/** A per-row (cell-level) field grant: `fields` are permitted only for rows that
|
|
64
71
|
* match `when`. Additive over the policy's flat `fields` — a conditional grant can
|
|
65
72
|
* only ever ADD fields, never remove them. */
|
|
@@ -71,7 +78,7 @@ export interface ConditionalFields {
|
|
|
71
78
|
/** Escape hatch for cell-level ACL: a late per-row resolver. Given the identity and
|
|
72
79
|
* the fetched (or candidate, on write) row, returns the extra permitted fields —
|
|
73
80
|
* additive over `fields`; `null` means all fields for that row. */
|
|
74
|
-
export type FieldsFn = (identity: Identity | null, row:
|
|
81
|
+
export type FieldsFn = (identity: Identity | null, row: Row) => string[] | null;
|
|
75
82
|
/** Per-relation ACL inside a parent read policy. */
|
|
76
83
|
export interface RelationAclRule {
|
|
77
84
|
/** Permit traversal to the related entity via this relation even if it has no
|
|
@@ -87,11 +94,11 @@ export interface RelationAclRule {
|
|
|
87
94
|
fieldsFn?: FieldsFn;
|
|
88
95
|
}
|
|
89
96
|
/** A forced column value on write: a literal, or computed from the identity. */
|
|
90
|
-
export type SetValue =
|
|
97
|
+
export type SetValue = CellValue | ((identity: Identity | null) => CellValue);
|
|
91
98
|
/** Server-side validation on write; throw to reject. Runs on the final values. */
|
|
92
99
|
export type Validator = (args: {
|
|
93
100
|
identity: Identity | null;
|
|
94
|
-
values:
|
|
101
|
+
values: Row;
|
|
95
102
|
}) => void;
|
|
96
103
|
export interface PolicyRules {
|
|
97
104
|
/** Row-level predicate (AND of equalities). Omit/empty = all rows. */
|
|
@@ -117,13 +124,13 @@ export interface PolicyRules {
|
|
|
117
124
|
export interface ResolverDb {
|
|
118
125
|
find(spec: {
|
|
119
126
|
from: string;
|
|
120
|
-
where?:
|
|
127
|
+
where?: WhereRule;
|
|
121
128
|
orderBy?: {
|
|
122
129
|
column: string;
|
|
123
130
|
dir?: "asc" | "desc";
|
|
124
131
|
};
|
|
125
132
|
limit?: number;
|
|
126
|
-
}): Promise<
|
|
133
|
+
}): Promise<Row[]>;
|
|
127
134
|
}
|
|
128
135
|
export interface ResolverContext {
|
|
129
136
|
readonly identity: Identity | null;
|
package/dist/sdk/handlers.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ import type { Queue } from "../runtime/queue";
|
|
|
6
6
|
import type { Identity } from "./acl";
|
|
7
7
|
import type { Files } from "./files";
|
|
8
8
|
import type { SchemaDef } from "./schema";
|
|
9
|
+
import type { JsonValue } from "./infer";
|
|
10
|
+
/** The Worker/DO environment as an open, read-only bag: Cloudflare bindings (KV, R2,
|
|
11
|
+
* D1, Queues, …) alongside vars and secrets. Deliberately open and opaque — an app
|
|
12
|
+
* declares its own bindings, so the value type cannot be enumerated here; read a value
|
|
13
|
+
* and narrow it at the use site (`ctx.env.STRIPE_SECRET_KEY as string`). */
|
|
14
|
+
export type EnvBag = Readonly<Record<string, unknown>>;
|
|
9
15
|
export interface HandlerContext<S extends SchemaDef = SchemaDef> {
|
|
10
16
|
/** Schema-typed repository: find/insert/update/delete inferred from S. */
|
|
11
17
|
readonly db: Db<S>;
|
|
@@ -25,7 +31,7 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
|
|
|
25
31
|
* Use it to call external services from handlers — Cloudflare bindings (e.g. the
|
|
26
32
|
* `send_email` binding for Cloudflare Email Sending) or third-party APIs (Stripe, …). Loosely typed;
|
|
27
33
|
* cast a value at the use site, e.g. `ctx.env.STRIPE_SECRET_KEY as string`. */
|
|
28
|
-
readonly env:
|
|
34
|
+
readonly env: EnvBag;
|
|
29
35
|
/** Resolved identity for this request (null = anonymous). */
|
|
30
36
|
readonly identity: Identity | null;
|
|
31
37
|
/** Deferred side-effects (a transactional outbox). `tasks.enqueue` persists a task
|
|
@@ -100,7 +106,7 @@ export interface Handler<I = unknown, O = unknown> {
|
|
|
100
106
|
readonly run: (ctx: HandlerContext<any>, input: I) => O | Promise<O>;
|
|
101
107
|
/** Optional boundary validator: parse/validate the raw request input, throwing
|
|
102
108
|
* to reject (surfaced as a 400). Its return type fixes the handler's input. */
|
|
103
|
-
readonly input?: (raw:
|
|
109
|
+
readonly input?: (raw: JsonValue) => unknown;
|
|
104
110
|
/** Optional DO partition this handler runs in (static, server-side). The Worker
|
|
105
111
|
* routes the request to the matching partition-DO before dispatch. Absent ⇒ the
|
|
106
112
|
* default partition (routed to the bare tenant key). */
|
|
@@ -109,7 +115,7 @@ export interface Handler<I = unknown, O = unknown> {
|
|
|
109
115
|
readonly auth?: HandlerAuth;
|
|
110
116
|
}
|
|
111
117
|
export interface HandlerOpts<I> {
|
|
112
|
-
input?: (raw:
|
|
118
|
+
input?: (raw: JsonValue) => I;
|
|
113
119
|
/** DO partition this handler runs in. Absent ⇒ the default partition. */
|
|
114
120
|
partition?: string;
|
|
115
121
|
/** Authorization to CALL this handler (see HandlerAuth) — gate non-`ctx.db` handlers. */
|
package/dist/sdk/infer.d.ts
CHANGED
|
@@ -5,6 +5,23 @@ export type { FileRef } from "./files";
|
|
|
5
5
|
export type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
6
6
|
[key: string]: JsonValue;
|
|
7
7
|
};
|
|
8
|
+
/** A JSON object — the object arm of `JsonValue`, named so it can be referenced
|
|
9
|
+
* directly (e.g. an identity's claims, a `t.json()` column's object form). */
|
|
10
|
+
export interface JsonObject {
|
|
11
|
+
[key: string]: JsonValue;
|
|
12
|
+
}
|
|
13
|
+
/** A raw value as the substrate stores/returns it, before pramen's object↔JSON codec.
|
|
14
|
+
* DO SQLite and D1 hand back exactly these; BLOB columns arrive as an ArrayBuffer. */
|
|
15
|
+
export type SqlValue = string | number | bigint | boolean | null | ArrayBuffer;
|
|
16
|
+
/** A decoded column value as handlers see it at the `Db` chokepoint: any JSON value,
|
|
17
|
+
* a `fileRef` column's metadata, or — for an eager-loaded relation — the related
|
|
18
|
+
* row(s) grafted onto the parent under the relation name. */
|
|
19
|
+
export type CellValue = SqlValue | JsonValue | FileRef | Row | Row[];
|
|
20
|
+
/** A decoded database row — column name -> decoded value. An interface (not a
|
|
21
|
+
* `Record` alias) so it can recur through `CellValue` for eager-loaded relations. */
|
|
22
|
+
export interface Row {
|
|
23
|
+
[column: string]: CellValue;
|
|
24
|
+
}
|
|
8
25
|
/** SQL field type -> TypeScript value type. */
|
|
9
26
|
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 : D["type"] extends "uuid" ? string : number;
|
|
10
27
|
/** A column is non-null iff it's NOT NULL or a primary key. */
|
package/dist/worker.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { JsonValue } from "./sdk/infer";
|
|
1
2
|
import { type QueueProducerBinding } from "./runtime/queue";
|
|
2
3
|
import { type QueueBatch } from "./runtime/queue-consumer";
|
|
3
4
|
import type { PramenApp } from "./pramen";
|
|
@@ -65,7 +66,7 @@ export declare function useD1Store(opts: {
|
|
|
65
66
|
* DO's JSON response (`{ ok, result }` / `{ ok: false, … }`). */
|
|
66
67
|
export declare function callPrivileged(env: Env, opts: {
|
|
67
68
|
name: string;
|
|
68
|
-
input?:
|
|
69
|
+
input?: JsonValue;
|
|
69
70
|
tenant?: string;
|
|
70
71
|
roles?: string[];
|
|
71
72
|
partition?: string;
|
package/dist/worker.js
CHANGED
|
@@ -18,6 +18,10 @@ import { Kv, isSessionDenied } from "./runtime/kv";
|
|
|
18
18
|
import { listDOs, partitionDoName } from "./runtime/registry";
|
|
19
19
|
import { createFiles, handleFileRequest, handleMediaRequest, R2Adapter } from "./runtime/storage";
|
|
20
20
|
import { DEFAULT_PARTITION, partitionsOf } from "./sdk/schema";
|
|
21
|
+
/** Widen the closed `Env` interface to the open `EnvBag` handlers and services see.
|
|
22
|
+
* Spreading yields an anonymous object type, which TypeScript gives an implicit index
|
|
23
|
+
* signature — so this needs no type assertion. */
|
|
24
|
+
const envBag = (env) => ({ ...env });
|
|
21
25
|
/** The secret used to sign/verify file tokens — a dedicated FILES_SECRET if set,
|
|
22
26
|
* else AUTH_SECRET (so HS256 setups work out of the box). */
|
|
23
27
|
const filesSecret = (env) => env.FILES_SECRET || env.AUTH_SECRET;
|
|
@@ -172,7 +176,8 @@ export function makeWorker(app) {
|
|
|
172
176
|
const files = createFiles({ tenant: "main", secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
|
|
173
177
|
const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
|
|
174
178
|
const kv = new Kv(env.KV);
|
|
175
|
-
|
|
179
|
+
const bag = envBag(env);
|
|
180
|
+
return { db, kv, files, env: bag, identity, tasks: tasksFacade(driver), mail: createMail(bag, kv), queue: createQueue(bag) };
|
|
176
181
|
};
|
|
177
182
|
/** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
|
|
178
183
|
* /admin/tasks/drain route with `x-pramen-store: d1`, and by `scheduled()` (Cron). */
|
|
@@ -216,7 +221,7 @@ export function makeWorker(app) {
|
|
|
216
221
|
for (const r of app.routes ?? []) {
|
|
217
222
|
if (request.method === r.method && url.pathname === r.path) {
|
|
218
223
|
const routeCtx = { callPrivileged: (opts) => callPrivileged(env, opts) };
|
|
219
|
-
return r.handler(request, env, routeCtx);
|
|
224
|
+
return r.handler(request, envBag(env), routeCtx);
|
|
220
225
|
}
|
|
221
226
|
}
|
|
222
227
|
// CORS (opt-in via CORS_ORIGINS) for cross-origin browser clients. Answer the
|
|
@@ -396,9 +401,10 @@ export function makeWorker(app) {
|
|
|
396
401
|
}
|
|
397
402
|
// (isLive is excluded by useD1Store — live always routes to the DO below.)
|
|
398
403
|
const name = url.pathname.replace(/^\/rpc\//, "");
|
|
399
|
-
|
|
404
|
+
// The RPC body is JSON — parse it into the domain type once, here at the boundary.
|
|
405
|
+
let input = null;
|
|
400
406
|
if (request.method === "POST")
|
|
401
|
-
input = await request.json().catch(() =>
|
|
407
|
+
input = ((await request.json().catch(() => null)) ?? null);
|
|
402
408
|
// Pick where the D1 session may start its first read. A mutation ALWAYS pins the
|
|
403
409
|
// primary (`first-primary` is a superset of read-your-writes) so a read-modify-write
|
|
404
410
|
// can't run off a lagging replica — an inbound bookmark must not widen that window.
|
|
@@ -414,10 +420,10 @@ export function makeWorker(app) {
|
|
|
414
420
|
start = "first-unconstrained";
|
|
415
421
|
const driver = new D1Driver(env.DB, { start });
|
|
416
422
|
const files = createFiles({ tenant, secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
|
|
417
|
-
const
|
|
423
|
+
const bag = envBag(env);
|
|
418
424
|
try {
|
|
419
425
|
await ensureD1Migrated(driver, env.PRAMEN_ALLOW_DESTRUCTIVE === "true");
|
|
420
|
-
const { result, enqueued } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files,
|
|
426
|
+
const { result, enqueued } = await dispatch(app.handlers, app.schema, driver, new Kv(env.KV), files, bag, { acl: d1Acl, identity }, name, input);
|
|
421
427
|
// Kick an immediate drain in the request tail when this handler enqueued tasks
|
|
422
428
|
// (e.g. sendMagicLinkEmail). Without this, tasks wait for the next Cron trigger
|
|
423
429
|
// — up to a full minute. `waitUntil` lets the response return now while the
|
|
@@ -488,13 +494,13 @@ export function makeWorker(app) {
|
|
|
488
494
|
// handler (ACK on success / RETRY on throw, per message). A consumer is Worker-level
|
|
489
495
|
// (no tenant DO): its ctx carries env/kv/mail/queue + callPrivileged to reach a DO.
|
|
490
496
|
async queue(batch, env) {
|
|
491
|
-
const
|
|
497
|
+
const bag = envBag(env);
|
|
492
498
|
const kv = new Kv(env.KV);
|
|
493
499
|
const ctx = {
|
|
494
|
-
env:
|
|
500
|
+
env: bag,
|
|
495
501
|
kv,
|
|
496
|
-
mail: createMail(
|
|
497
|
-
queue: createQueue(
|
|
502
|
+
mail: createMail(bag, kv),
|
|
503
|
+
queue: createQueue(bag),
|
|
498
504
|
callPrivileged: (opts) => callPrivileged(env, opts),
|
|
499
505
|
};
|
|
500
506
|
await dispatchQueueBatch(app.queues ?? {}, ctx, batch);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.49",
|
|
4
4
|
"description": "pramen server runtime — schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/auth.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// non-standard claims pass through.
|
|
12
12
|
|
|
13
13
|
import type { Identity } from "./sdk/acl";
|
|
14
|
+
import type { JsonObject } from "./sdk/infer";
|
|
14
15
|
|
|
15
16
|
const STANDARD_CLAIMS = new Set(["exp", "iat", "nbf", "iss", "aud", "jti", "sub", "role", "roles", "userId"]);
|
|
16
17
|
|
|
@@ -34,7 +35,7 @@ interface JwtHeader {
|
|
|
34
35
|
/** Verifies a JWT and returns its claims, or null if invalid. Implementations
|
|
35
36
|
* differ only in how they verify the signature. */
|
|
36
37
|
export interface VerifyStrategy {
|
|
37
|
-
verify(token: string): Promise<
|
|
38
|
+
verify(token: string): Promise<JsonObject | null>;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
/** Verify a signature over `${header}.${payload}` for the parsed header. */
|
|
@@ -69,7 +70,7 @@ async function verifyJwt(
|
|
|
69
70
|
token: string,
|
|
70
71
|
verifySignature: SignatureVerifier,
|
|
71
72
|
opts: VerifyOptions = {},
|
|
72
|
-
): Promise<
|
|
73
|
+
): Promise<JsonObject | null> {
|
|
73
74
|
const parts = token.split(".");
|
|
74
75
|
if (parts.length !== 3) return null;
|
|
75
76
|
const [h, p, sig] = parts;
|
|
@@ -89,7 +90,7 @@ async function verifyJwt(
|
|
|
89
90
|
}
|
|
90
91
|
if (!valid) return null;
|
|
91
92
|
|
|
92
|
-
let payload:
|
|
93
|
+
let payload: JsonObject;
|
|
93
94
|
try {
|
|
94
95
|
payload = JSON.parse(b64urlToString(p!));
|
|
95
96
|
} catch {
|
|
@@ -113,7 +114,7 @@ export class HmacStrategy implements VerifyStrategy {
|
|
|
113
114
|
private readonly opts: VerifyOptions = {},
|
|
114
115
|
) {}
|
|
115
116
|
|
|
116
|
-
verify(token: string): Promise<
|
|
117
|
+
verify(token: string): Promise<JsonObject | null> {
|
|
117
118
|
return verifyJwt(
|
|
118
119
|
token,
|
|
119
120
|
async (input, signature, header) => {
|
|
@@ -152,7 +153,7 @@ export class JwksStrategy implements VerifyStrategy {
|
|
|
152
153
|
private readonly opts: VerifyOptions = {},
|
|
153
154
|
) {}
|
|
154
155
|
|
|
155
|
-
verify(token: string): Promise<
|
|
156
|
+
verify(token: string): Promise<JsonObject | null> {
|
|
156
157
|
return verifyJwt(
|
|
157
158
|
token,
|
|
158
159
|
async (input, signature, header) => {
|
|
@@ -219,7 +220,7 @@ export class JwksStrategy implements VerifyStrategy {
|
|
|
219
220
|
}
|
|
220
221
|
}
|
|
221
222
|
|
|
222
|
-
function toIdentity(claims:
|
|
223
|
+
function toIdentity(claims: JsonObject): Identity {
|
|
223
224
|
const roles = Array.isArray(claims.roles)
|
|
224
225
|
? (claims.roles as string[])
|
|
225
226
|
: typeof claims.role === "string"
|
package/src/cli.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
21
21
|
import { dirname, resolve } from "node:path";
|
|
22
22
|
import { createTableSql } from "./runtime/ddl";
|
|
23
23
|
import { schemaHash } from "./runtime/migrate";
|
|
24
|
-
import {
|
|
24
|
+
import { diffSchemaFingerprint, schemaFingerprint, type SchemaFingerprint } from "./runtime/schema-diff";
|
|
25
25
|
import { entitiesInPartition, partitionsOf, type SchemaDef } from "./sdk/schema";
|
|
26
26
|
|
|
27
27
|
/** Mint an HS256 JWT — mirrors what a real auth service would issue, for local
|
|
@@ -36,6 +36,9 @@ function bytesToB64url(bytes: Uint8Array): string {
|
|
|
36
36
|
}
|
|
37
37
|
const strToB64url = (s: string) => bytesToB64url(new TextEncoder().encode(s));
|
|
38
38
|
|
|
39
|
+
/** The dev JWT claims `pramen token` mints. */
|
|
40
|
+
type TokenClaims = { sub: string; roles: string[]; tenants?: string[] };
|
|
41
|
+
|
|
39
42
|
async function sign(payload: Record<string, unknown>): Promise<string> {
|
|
40
43
|
const secret = process.env.AUTH_SECRET || DEV_SECRET;
|
|
41
44
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -102,7 +105,7 @@ Usage: pramen <command>
|
|
|
102
105
|
init [dir] scaffold a new project (app.ts + worker.ts + oblaka.ts)
|
|
103
106
|
schema sql print CREATE TABLE statements for the schema
|
|
104
107
|
schema hash print the schema hash
|
|
105
|
-
schema snapshot save the schema
|
|
108
|
+
schema snapshot save the schema fingerprint to .pramen/schema.json
|
|
106
109
|
schema diff compare the schema to the snapshot (safe vs unsafe)
|
|
107
110
|
schema status compare a deployed tenant's schema to the local schema
|
|
108
111
|
[--tenant t] [--url u] [--token jwt]
|
|
@@ -126,20 +129,27 @@ async function schemaCmd(sub: string | undefined): Promise<void> {
|
|
|
126
129
|
if (sub === "snapshot") {
|
|
127
130
|
const { schema } = await loadApp();
|
|
128
131
|
mkdirSync(dirname(snapshotPath), { recursive: true });
|
|
129
|
-
const snap = { hash: schemaHash(schema),
|
|
132
|
+
const snap = { hash: schemaHash(schema), fingerprint: schemaFingerprint(schema) };
|
|
130
133
|
writeFileSync(snapshotPath, JSON.stringify(snap, null, 2) + "\n");
|
|
131
|
-
console.log(`wrote ${snapshotPath} (${Object.keys(snap.
|
|
134
|
+
console.log(`wrote ${snapshotPath} (${Object.keys(snap.fingerprint).length} tables)`);
|
|
132
135
|
return;
|
|
133
136
|
}
|
|
134
137
|
if (sub === "diff") {
|
|
135
138
|
const { schema } = await loadApp();
|
|
136
|
-
const next =
|
|
139
|
+
const next = schemaFingerprint(schema);
|
|
137
140
|
if (!existsSync(snapshotPath)) {
|
|
138
141
|
console.log("no snapshot — run `pramen schema snapshot` to set a baseline.");
|
|
139
142
|
return;
|
|
140
143
|
}
|
|
141
|
-
|
|
142
|
-
|
|
144
|
+
// `fingerprint` was called `shape` before; keep reading an existing snapshot so an
|
|
145
|
+
// upgrade doesn't force a re-baseline.
|
|
146
|
+
const snap = JSON.parse(readFileSync(snapshotPath, "utf8")) as {
|
|
147
|
+
fingerprint?: SchemaFingerprint;
|
|
148
|
+
// the pre-rename key, quoted because it names a stored JSON field, not a symbol
|
|
149
|
+
"shape"?: SchemaFingerprint;
|
|
150
|
+
};
|
|
151
|
+
const prev = snap.fingerprint ?? snap["shape"] ?? {};
|
|
152
|
+
const changes = diffSchemaFingerprint(prev, next);
|
|
143
153
|
if (changes.length === 0) {
|
|
144
154
|
console.log("no changes since snapshot.");
|
|
145
155
|
return;
|
|
@@ -203,7 +213,7 @@ async function schemaCmd(sub: string | undefined): Promise<void> {
|
|
|
203
213
|
console.log(`live: ${live.hash ?? "(none)"}`);
|
|
204
214
|
console.log(`current: ${current}`);
|
|
205
215
|
console.log(upToDate ? "✓ up to date" : "⚠ BEHIND — migrates on the tenant's next boot");
|
|
206
|
-
const want =
|
|
216
|
+
const want = schemaFingerprint(subset);
|
|
207
217
|
for (const table of Object.keys(want)) {
|
|
208
218
|
const liveCols = new Set(live.tables[table] ?? []);
|
|
209
219
|
const missing = Object.keys(want[table]!.columns).filter((col) => !liveCols.has(col));
|
|
@@ -222,7 +232,9 @@ async function tokenCmd(args: string[]): Promise<void> {
|
|
|
222
232
|
if (!sub) fail("token: <sub> required");
|
|
223
233
|
const roles = pos.slice(1);
|
|
224
234
|
const tenants = flag("tenant")?.split(",");
|
|
225
|
-
|
|
235
|
+
const claims: TokenClaims = { sub, roles: roles.length ? roles : ["admin"] };
|
|
236
|
+
if (tenants) claims.tenants = tenants;
|
|
237
|
+
console.log(await sign(claims));
|
|
226
238
|
}
|
|
227
239
|
|
|
228
240
|
function initCmd(args: string[]): void {
|
package/src/durable-object.ts
CHANGED
|
@@ -34,6 +34,8 @@ import type { Identity } from "./sdk/acl";
|
|
|
34
34
|
import type { HandlerContext } from "./sdk/handlers";
|
|
35
35
|
import type { PramenApp } from "./pramen";
|
|
36
36
|
import type { ClientMsg, ServerMsg, Subscription } from "./runtime/protocol";
|
|
37
|
+
import type { EnvBag } from "./sdk/handlers";
|
|
38
|
+
import type { JsonValue } from "./sdk/infer";
|
|
37
39
|
|
|
38
40
|
/** Durable per-socket state — kept SMALL and stable, since it rides the WebSocket
|
|
39
41
|
* attachment which workerd caps at ~2 KB. Only auth/routing identity lives here so it
|
|
@@ -208,9 +210,10 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
208
210
|
}
|
|
209
211
|
|
|
210
212
|
const name = new URL(request.url).pathname.replace(/^\/rpc\//, "");
|
|
211
|
-
|
|
213
|
+
// The RPC body is JSON — parse it into the domain type once, here at the boundary.
|
|
214
|
+
let input: JsonValue = null;
|
|
212
215
|
if (request.method === "POST") {
|
|
213
|
-
input = await request.json().catch(() =>
|
|
216
|
+
input = ((await request.json().catch(() => null)) ?? null) as JsonValue;
|
|
214
217
|
}
|
|
215
218
|
|
|
216
219
|
try {
|
|
@@ -354,12 +357,12 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
354
357
|
|
|
355
358
|
switch (msg.type) {
|
|
356
359
|
case "subscribe":
|
|
357
|
-
return this.onSubscribe(ws, msg.id, msg.name, msg.input);
|
|
360
|
+
return this.onSubscribe(ws, msg.id, msg.name, msg.input ?? null);
|
|
358
361
|
case "unsubscribe":
|
|
359
362
|
this.setSubs(ws, this.getSubs(ws).filter((s) => s.id !== msg.id));
|
|
360
363
|
return;
|
|
361
364
|
case "call":
|
|
362
|
-
return this.onCall(ws, msg.id, msg.name, msg.input);
|
|
365
|
+
return this.onCall(ws, msg.id, msg.name, msg.input ?? null);
|
|
363
366
|
default:
|
|
364
367
|
return this.send(ws, { type: "error", id: "", error: "unknown message type" });
|
|
365
368
|
}
|
|
@@ -381,7 +384,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
381
384
|
|
|
382
385
|
// --- live-query internals ---
|
|
383
386
|
|
|
384
|
-
private async onSubscribe(ws: WebSocket, id: string, name: string, input:
|
|
387
|
+
private async onSubscribe(ws: WebSocket, id: string, name: string, input: JsonValue): Promise<void> {
|
|
385
388
|
const att = this.getAttachment(ws);
|
|
386
389
|
const subs = this.getSubs(ws);
|
|
387
390
|
try {
|
|
@@ -402,7 +405,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
402
405
|
}
|
|
403
406
|
}
|
|
404
407
|
|
|
405
|
-
private async onCall(ws: WebSocket, id: string, name: string, input:
|
|
408
|
+
private async onCall(ws: WebSocket, id: string, name: string, input: JsonValue): Promise<void> {
|
|
406
409
|
const att = this.getAttachment(ws);
|
|
407
410
|
let outcome: Awaited<ReturnType<typeof dispatch>>;
|
|
408
411
|
try {
|
|
@@ -603,8 +606,12 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
603
606
|
|
|
604
607
|
// The DO env (bindings + vars + secrets) handed to handlers as ctx.env. Loosely
|
|
605
608
|
// typed at the boundary so handlers can read any var/secret without a DoEnv cast.
|
|
606
|
-
private
|
|
607
|
-
|
|
609
|
+
private widenedEnv: EnvBag | null = null;
|
|
610
|
+
private get envBag(): EnvBag {
|
|
611
|
+
// `this.env` is fixed for the DO's lifetime, so widen it once. This getter is read
|
|
612
|
+
// inside the per-subscription live-query loop, where a copy per read would allocate
|
|
613
|
+
// one whole binding bag per subscription on every write.
|
|
614
|
+
return (this.widenedEnv ??= { ...this.env });
|
|
608
615
|
}
|
|
609
616
|
|
|
610
617
|
// One Files facade per DO (a DO serves one tenant). Backed by the R2 binding;
|
package/src/index.ts
CHANGED
|
@@ -31,7 +31,7 @@ export type {
|
|
|
31
31
|
// --- app + handlers ---
|
|
32
32
|
export { createApp } from "./sdk/app";
|
|
33
33
|
export { query, mutation, authorizeHandler } from "./sdk/handlers";
|
|
34
|
-
export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap, BootstrapContext, BootstrapFn } from "./sdk/handlers";
|
|
34
|
+
export type { EnvBag, Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, HandlerAuth, Tasks, TaskHandler, AppTaskMap, BootstrapContext, BootstrapFn } from "./sdk/handlers";
|
|
35
35
|
|
|
36
36
|
// --- ACL ---
|
|
37
37
|
export { $identity, $input, $now, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker, isNowMarker } from "./sdk/acl";
|
|
@@ -64,6 +64,10 @@ export type {
|
|
|
64
64
|
InferRow,
|
|
65
65
|
InferUpdate,
|
|
66
66
|
JsonValue,
|
|
67
|
+
JsonObject,
|
|
68
|
+
SqlValue,
|
|
69
|
+
CellValue,
|
|
70
|
+
Row,
|
|
67
71
|
ProjectedRow,
|
|
68
72
|
RelationsOf,
|
|
69
73
|
RelationsResult,
|
|
@@ -95,4 +99,4 @@ export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/erro
|
|
|
95
99
|
|
|
96
100
|
// --- substrate seam (advanced: bring your own SQL backend) ---
|
|
97
101
|
export { sqliteDialect, postgresDialect, DoSqliteDriver, D1Driver } from "./runtime/driver";
|
|
98
|
-
export type { Driver, Dialect,
|
|
102
|
+
export type { Driver, Dialect, DriverRow } from "./runtime/driver";
|
package/src/pramen.ts
CHANGED
|
@@ -18,12 +18,14 @@ import { validateTriggerTasks, type SchemaDef } from "./sdk/schema";
|
|
|
18
18
|
import type { AppTaskMap, HandlerMap, BootstrapFn } from "./sdk/handlers";
|
|
19
19
|
import type { AppQueueMap, QueueBatch } from "./runtime/queue-consumer";
|
|
20
20
|
import type { Role } from "./sdk/acl";
|
|
21
|
+
import type { EnvBag } from "./sdk/handlers";
|
|
22
|
+
import type { JsonValue } from "./sdk/infer";
|
|
21
23
|
|
|
22
24
|
/** Injected into a public route's handler — forward a privileged mutation into the
|
|
23
25
|
* tenant's DO without the handler importing any deploy-side code (so app.ts stays
|
|
24
26
|
* authoring-only). The synthetic identity defaults to the admin role. */
|
|
25
27
|
export interface RouteContext {
|
|
26
|
-
callPrivileged(opts: { name: string; input?:
|
|
28
|
+
callPrivileged(opts: { name: string; input?: JsonValue; tenant?: string; roles?: string[] }): Promise<Response>;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/** A public, pre-auth route — matched before identity resolution, so it can host a
|
|
@@ -36,7 +38,7 @@ export interface PublicRoute {
|
|
|
36
38
|
method: string;
|
|
37
39
|
/** Exact pathname to match (e.g. "/stripe/webhook"). */
|
|
38
40
|
path: string;
|
|
39
|
-
handler: (request: Request, env:
|
|
41
|
+
handler: (request: Request, env: EnvBag, ctx: RouteContext) => Response | Promise<Response>;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/** The user-facing app: a schema, the handler map, ACL roles, and optional public
|