@pramen/server 0.0.48 → 0.0.50
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 +17 -30
- package/dist/dev.d.ts +1 -0
- package/dist/dev.js +9 -0
- package/dist/durable-object.d.ts +1 -0
- package/dist/durable-object.js +13 -6
- package/dist/index.d.ts +7 -5
- package/dist/index.js +6 -2
- package/dist/pramen.d.ts +4 -2
- package/dist/runtime/acl.d.ts +14 -5
- package/dist/runtime/acl.js +1 -5
- package/dist/runtime/db.d.ts +3 -2
- package/dist/runtime/dev-token.d.ts +4 -0
- package/dist/runtime/dev-token.js +34 -0
- package/dist/runtime/dispatch.d.ts +3 -1
- package/dist/runtime/dispatch.js +2 -0
- package/dist/runtime/driver.d.ts +8 -5
- package/dist/runtime/errors.d.ts +6 -0
- package/dist/runtime/errors.js +8 -0
- 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/runtime/storage.d.ts +4 -4
- package/dist/runtime/storage.js +8 -63
- package/dist/runtime/token.d.ts +22 -0
- package/dist/runtime/token.js +88 -0
- package/dist/sdk/acl.d.ts +18 -11
- package/dist/sdk/handlers.d.ts +16 -3
- package/dist/sdk/infer.d.ts +17 -0
- package/dist/worker.d.ts +2 -1
- package/dist/worker.js +20 -10
- package/package.json +13 -3
- package/src/auth.ts +7 -6
- package/src/cli.ts +22 -36
- package/src/dev.ts +10 -0
- package/src/durable-object.ts +18 -9
- package/src/index.ts +14 -4
- package/src/pramen.ts +4 -2
- package/src/runtime/acl.ts +45 -31
- package/src/runtime/db.ts +14 -12
- package/src/runtime/dev-token.ts +36 -0
- package/src/runtime/dispatch.ts +7 -3
- package/src/runtime/driver.ts +11 -7
- package/src/runtime/errors.ts +9 -0
- 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/runtime/storage.ts +14 -62
- package/src/runtime/token.ts +94 -0
- package/src/sdk/acl.ts +29 -14
- package/src/sdk/handlers.ts +17 -3
- package/src/sdk/infer.ts +21 -0
- package/src/worker.ts +24 -11
package/src/runtime/acl.ts
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
type ResolverDb,
|
|
16
16
|
type Role,
|
|
17
17
|
type Validator,
|
|
18
|
+
type WhereRule,
|
|
19
|
+
type WhereValue,
|
|
18
20
|
deny,
|
|
19
21
|
isAllow,
|
|
20
22
|
isDeny,
|
|
@@ -25,7 +27,8 @@ import {
|
|
|
25
27
|
} from "../sdk/acl";
|
|
26
28
|
import { and, compileWhere, evalExpr, FALSE, not, or, TRUE, type SqlExpr } from "./read-engine";
|
|
27
29
|
import { BadRequest, PramenError } from "./errors";
|
|
28
|
-
import type {
|
|
30
|
+
import type { CellValue, Row } from "../sdk/infer";
|
|
31
|
+
import type { FieldDef, RelationDef, RelationDefs, SchemaDef } from "../sdk/schema";
|
|
29
32
|
|
|
30
33
|
export class AclDenied extends PramenError {
|
|
31
34
|
constructor(
|
|
@@ -61,6 +64,14 @@ export interface AclContext {
|
|
|
61
64
|
/** The app schema — lets `where` rules traverse relations (`{ rel: { col } }`),
|
|
62
65
|
* compiled to a subquery with the related entity's read scope AND-merged in. */
|
|
63
66
|
readonly schema?: SchemaDef;
|
|
67
|
+
/** The tenant this request is for — the `x-pramen-tenant` value the DO was addressed
|
|
68
|
+
* with. Carried so a handler can mint a tenant-scoped capability (e.g. a signed page
|
|
69
|
+
* preview link) without the caller supplying, and thus being able to forge, a tenant. */
|
|
70
|
+
readonly tenant?: string;
|
|
71
|
+
/** Which substrate is serving this request — `"do"` (a Durable Object, the default) or
|
|
72
|
+
* `"d1"` (the shared D1 database, selected per-request with `x-pramen-store: d1`). A
|
|
73
|
+
* handler needs this when a capability it mints can only be redeemed on one of them. */
|
|
74
|
+
readonly store?: "do" | "d1";
|
|
64
75
|
/** The partition this DO serves. When set, Db rejects any access to a table that
|
|
65
76
|
* lives in a different partition (a partition-DO only owns its own tables). Unset
|
|
66
77
|
* (e.g. the D1/Worker shared-store path) disables the guard — a no-op. */
|
|
@@ -150,7 +161,7 @@ function grantOf(rule: PolicyRules | RelationAclRule, where: SqlExpr | null, ent
|
|
|
150
161
|
conditional: (rule.conditionalFields ?? []).map((cf) => ({
|
|
151
162
|
// Cell-level `when` is evaluated per-row in memory (evalExpr), so it must stay
|
|
152
163
|
// single-table — `allowRelations: false` rejects a relation key up front.
|
|
153
|
-
when: compileScopedWhere(cf.when as
|
|
164
|
+
when: compileScopedWhere(cf.when as WhereRule, entity, ctx, depth, false),
|
|
154
165
|
fields: cf.fields,
|
|
155
166
|
})),
|
|
156
167
|
fieldsFns: rule.fieldsFn ? [rule.fieldsFn] : [],
|
|
@@ -168,7 +179,7 @@ function rolesOf(identity: Identity | null): string[] {
|
|
|
168
179
|
}
|
|
169
180
|
|
|
170
181
|
function getPath(obj: unknown, path: string): unknown {
|
|
171
|
-
return path.split(".").reduce<unknown>((acc, seg) => (acc == null ? undefined : (acc as
|
|
182
|
+
return path.split(".").reduce<unknown>((acc, seg) => (acc == null ? undefined : (acc as WhereRule)[seg]), obj ?? undefined);
|
|
172
183
|
}
|
|
173
184
|
|
|
174
185
|
const UNRESOLVED = Symbol("unresolved");
|
|
@@ -177,15 +188,18 @@ const UNRESOLVED = Symbol("unresolved");
|
|
|
177
188
|
// $input marker (against the request input — a capability/by-key grant), or a
|
|
178
189
|
// $now marker (the evaluation instant). An unresolvable marker yields UNRESOLVED,
|
|
179
190
|
// which makes its rule match nothing. $now always resolves.
|
|
180
|
-
|
|
191
|
+
/** A resolved where value, or the sentinel meaning "this marker could not resolve". */
|
|
192
|
+
type ResolvedWhereValue = WhereValue | typeof UNRESOLVED;
|
|
193
|
+
|
|
194
|
+
function resolveValue(v: WhereValue, identity: Identity | null, input: unknown): ResolvedWhereValue {
|
|
181
195
|
if (isNowMarker(v)) return new Date().toISOString();
|
|
182
196
|
if (isIdentityMarker(v)) {
|
|
183
197
|
const value = getPath(identity, v.path);
|
|
184
|
-
return value === undefined ? UNRESOLVED : value;
|
|
198
|
+
return value === undefined ? UNRESOLVED : (value as WhereValue);
|
|
185
199
|
}
|
|
186
200
|
if (isInputMarker(v)) {
|
|
187
201
|
const value = getPath(input, v.path);
|
|
188
|
-
return value === undefined ? UNRESOLVED : value;
|
|
202
|
+
return value === undefined ? UNRESOLVED : (value as WhereValue);
|
|
189
203
|
}
|
|
190
204
|
return v;
|
|
191
205
|
}
|
|
@@ -199,22 +213,22 @@ function resolveValue(v: unknown, identity: Identity | null, input: unknown): un
|
|
|
199
213
|
// whole rule — so `OR: [{ x: $identity(...) }, { public: true }]` still matches
|
|
200
214
|
// the `public` branch for a caller whose marker can't resolve. (See the comment
|
|
201
215
|
// on `compileScopedWhere`.)
|
|
202
|
-
function resolveMarkers(rule:
|
|
203
|
-
const out:
|
|
216
|
+
function resolveMarkers(rule: WhereRule, identity: Identity | null, input: unknown): WhereRule | null {
|
|
217
|
+
const out: WhereRule = {};
|
|
204
218
|
for (const [key, v] of Object.entries(rule)) {
|
|
205
219
|
const isMarker = isIdentityMarker(v) || isInputMarker(v) || isNowMarker(v);
|
|
206
220
|
if (v !== null && typeof v === "object" && !isMarker && !Array.isArray(v)) {
|
|
207
|
-
const ops:
|
|
208
|
-
for (const [op, val] of Object.entries(v as
|
|
221
|
+
const ops: WhereRule = {};
|
|
222
|
+
for (const [op, val] of Object.entries(v as WhereRule)) {
|
|
209
223
|
if (op === "in" || op === "notIn") {
|
|
210
|
-
let arr:
|
|
224
|
+
let arr: ResolvedWhereValue;
|
|
211
225
|
if (isIdentityMarker(val) || isInputMarker(val)) {
|
|
212
226
|
arr = resolveValue(val, identity, input);
|
|
213
227
|
if (arr === UNRESOLVED) return null;
|
|
214
228
|
} else {
|
|
215
|
-
const mapped = (val as
|
|
229
|
+
const mapped = (val as WhereValue[]).map((x) => resolveValue(x, identity, input));
|
|
216
230
|
if (mapped.some((x) => x === UNRESOLVED)) return null;
|
|
217
|
-
arr = mapped;
|
|
231
|
+
arr = mapped as WhereValue[];
|
|
218
232
|
}
|
|
219
233
|
if (!Array.isArray(arr)) return null; // marker must resolve to a list
|
|
220
234
|
ops[op] = arr;
|
|
@@ -258,13 +272,13 @@ function pkOf(schema: SchemaDef | undefined, entity: string): string {
|
|
|
258
272
|
* unreadable column is LIKE-oracle'able through the subquery). Nested relation keys
|
|
259
273
|
* are skipped: they're re-scoped against THEIR own target's read scope downstream.
|
|
260
274
|
* Mirrors Db.assertReadableWhere's recursion for the top-level user `where`. */
|
|
261
|
-
function assertReadableRelationWhere(where:
|
|
262
|
-
const targetRels =
|
|
275
|
+
function assertReadableRelationWhere(where: WhereRule, target: string, fields: string[], ctx: AclContext): void {
|
|
276
|
+
const targetRels: RelationDefs = ctx.schema?.[target]?.relations ?? {};
|
|
263
277
|
for (const [k, v] of Object.entries(where)) {
|
|
264
278
|
if (k === "AND" || k === "OR") {
|
|
265
|
-
for (const g of v as
|
|
279
|
+
for (const g of v as WhereRule[]) assertReadableRelationWhere(g, target, fields, ctx);
|
|
266
280
|
} else if (k === "NOT") {
|
|
267
|
-
assertReadableRelationWhere(v as
|
|
281
|
+
assertReadableRelationWhere(v as WhereRule, target, fields, ctx);
|
|
268
282
|
} else if (targetRels[k]) {
|
|
269
283
|
continue;
|
|
270
284
|
} else if (!fields.includes(k)) {
|
|
@@ -281,7 +295,7 @@ function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: stri
|
|
|
281
295
|
if (nested === null || typeof nested !== "object" || Array.isArray(nested)) {
|
|
282
296
|
throw new BadRequest(`relation filter for '${rel.target}' must be an object`);
|
|
283
297
|
}
|
|
284
|
-
let inner = compileScopedWhere(nested as
|
|
298
|
+
let inner = compileScopedWhere(nested as WhereRule, rel.target, ctx, depth + 1);
|
|
285
299
|
|
|
286
300
|
// Security: a relation filter must respect the target's read ACL (else it leaks).
|
|
287
301
|
// Two distinct "no" outcomes, matching how the rest of the read path behaves:
|
|
@@ -297,7 +311,7 @@ function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: stri
|
|
|
297
311
|
inner = FALSE; // can't filter through a relation you can't read
|
|
298
312
|
} else {
|
|
299
313
|
if (tScope.fields !== null) {
|
|
300
|
-
assertReadableRelationWhere(nested as
|
|
314
|
+
assertReadableRelationWhere(nested as WhereRule, rel.target, tScope.fields, ctx);
|
|
301
315
|
}
|
|
302
316
|
if (tScope.where) inner = and(inner, tScope.where);
|
|
303
317
|
}
|
|
@@ -336,7 +350,7 @@ function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: stri
|
|
|
336
350
|
* evaluated in memory and cannot do a SQL round-trip): a relation key then raises a
|
|
337
351
|
* clear authoring error instead of emitting a `sub` node that throws at read time. */
|
|
338
352
|
export function compileScopedWhere(
|
|
339
|
-
rule:
|
|
353
|
+
rule: WhereRule,
|
|
340
354
|
entity: string,
|
|
341
355
|
ctx: AclContext,
|
|
342
356
|
depth = 0,
|
|
@@ -344,13 +358,13 @@ export function compileScopedWhere(
|
|
|
344
358
|
): SqlExpr {
|
|
345
359
|
const relations = (ctx.schema?.[entity]?.relations ?? {}) as Record<string, RelationDef>;
|
|
346
360
|
const parts: SqlExpr[] = [];
|
|
347
|
-
const plain:
|
|
361
|
+
const plain: WhereRule = {};
|
|
348
362
|
for (const [k, v] of Object.entries(rule)) {
|
|
349
363
|
if (k === "AND" || k === "OR") {
|
|
350
|
-
const groups = (v as
|
|
364
|
+
const groups = (v as WhereRule[]).map((g) => compileScopedWhere(g, entity, ctx, depth, allowRelations));
|
|
351
365
|
parts.push(k === "AND" ? and(...groups) : or(...groups));
|
|
352
366
|
} else if (k === "NOT") {
|
|
353
|
-
parts.push(not(compileScopedWhere(v as
|
|
367
|
+
parts.push(not(compileScopedWhere(v as WhereRule, entity, ctx, depth, allowRelations)));
|
|
354
368
|
} else if (relations[k]) {
|
|
355
369
|
if (!allowRelations) {
|
|
356
370
|
throw new BadRequest(`cell-level \`when\` cannot traverse relations: '${k}' (relations need a SQL round-trip)`);
|
|
@@ -420,7 +434,7 @@ export function resolveScope(ctx: AclContext, entity: string, action: Action, de
|
|
|
420
434
|
if (isDeny(rule)) continue;
|
|
421
435
|
if (isAllow(rule)) grants.push(ALLOW_GRANT);
|
|
422
436
|
else {
|
|
423
|
-
const where = compileScopedWhere((rule.where ?? {}) as
|
|
437
|
+
const where = compileScopedWhere((rule.where ?? {}) as WhereRule, entity, ctx, depth);
|
|
424
438
|
grants.push(grantOf(rule, where, entity, ctx, depth));
|
|
425
439
|
}
|
|
426
440
|
}
|
|
@@ -431,7 +445,7 @@ export function resolveScope(ctx: AclContext, entity: string, action: Action, de
|
|
|
431
445
|
* Returns null (all fields) when the base is null or a resolver grants everything. */
|
|
432
446
|
export function effectiveFields(
|
|
433
447
|
scope: Scope,
|
|
434
|
-
row:
|
|
448
|
+
row: Row,
|
|
435
449
|
identity: Identity | null,
|
|
436
450
|
): string[] | null {
|
|
437
451
|
if (scope.fields === null) return null;
|
|
@@ -448,18 +462,18 @@ export function effectiveFields(
|
|
|
448
462
|
/** Forced values + validators for a write, gathered from matched write policies.
|
|
449
463
|
* `set` values are resolved against the identity; later policies override earlier. */
|
|
450
464
|
export interface WriteRules {
|
|
451
|
-
set:
|
|
465
|
+
set: Row;
|
|
452
466
|
validators: Validator[];
|
|
453
467
|
}
|
|
454
468
|
|
|
455
469
|
export function resolveWriteRules(ctx: AclContext, entity: string, action: Action): WriteRules {
|
|
456
|
-
const set:
|
|
470
|
+
const set: Row = {};
|
|
457
471
|
const validators: Validator[] = [];
|
|
458
472
|
for (const rule of matchedRules(ctx, entity, action)) {
|
|
459
473
|
if (isAllow(rule) || isDeny(rule)) continue;
|
|
460
474
|
if (rule.set) {
|
|
461
475
|
for (const [col, v] of Object.entries(rule.set)) {
|
|
462
|
-
set[col] = typeof v === "function" ? (v as (i: Identity | null) =>
|
|
476
|
+
set[col] = typeof v === "function" ? (v as (i: Identity | null) => CellValue)(ctx.identity) : v;
|
|
463
477
|
}
|
|
464
478
|
}
|
|
465
479
|
if (rule.validate) validators.push(rule.validate);
|
|
@@ -487,7 +501,7 @@ export function resolveRelationScope(
|
|
|
487
501
|
if (isAllow(rule) || isDeny(rule)) continue;
|
|
488
502
|
const rel = rule.relations?.[relName];
|
|
489
503
|
if (rel?.directAccess) {
|
|
490
|
-
const relWhere = rel.where ? compileScopedWhere(rel.where as
|
|
504
|
+
const relWhere = rel.where ? compileScopedWhere(rel.where as WhereRule, target, ctx, 0) : null;
|
|
491
505
|
grants.push(grantOf(rel, relWhere, target, ctx, 0));
|
|
492
506
|
}
|
|
493
507
|
}
|
|
@@ -496,9 +510,9 @@ export function resolveRelationScope(
|
|
|
496
510
|
}
|
|
497
511
|
|
|
498
512
|
/** Project a row to the permitted fields. null = all. */
|
|
499
|
-
export function projectRow(row:
|
|
513
|
+
export function projectRow(row: Row, fields: string[] | null): Row {
|
|
500
514
|
if (!fields) return row;
|
|
501
|
-
const out:
|
|
515
|
+
const out: Row = {};
|
|
502
516
|
for (const f of fields) if (f in row) out[f] = row[f];
|
|
503
517
|
return out;
|
|
504
518
|
}
|
package/src/runtime/db.ts
CHANGED
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
type Scope,
|
|
25
25
|
} from "./acl";
|
|
26
26
|
import type { Validator } from "../sdk/acl";
|
|
27
|
+
import type { CellValue, Row as SharedRow } from "../sdk/infer";
|
|
28
|
+
import type { WhereRule } from "../sdk/acl";
|
|
27
29
|
import {
|
|
28
30
|
and,
|
|
29
31
|
cmp,
|
|
@@ -48,7 +50,7 @@ import { partitionOf, triggersOf, triggerFires, type EntityFields, type FieldDef
|
|
|
48
50
|
import { isValidUuid } from "../sdk/uuid";
|
|
49
51
|
import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult, WhereClause } from "../sdk/infer";
|
|
50
52
|
|
|
51
|
-
type Row =
|
|
53
|
+
type Row = SharedRow;
|
|
52
54
|
type Action = "read" | "create" | "update" | "delete";
|
|
53
55
|
type Id = string | number | bigint;
|
|
54
56
|
type Selected = Partial<Record<string, true>> | undefined;
|
|
@@ -174,7 +176,7 @@ function encodeCursor(order: OrderBy[], row: Row): string {
|
|
|
174
176
|
return btoa(JSON.stringify(vals)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
175
177
|
}
|
|
176
178
|
|
|
177
|
-
function decodeCursor(s: string):
|
|
179
|
+
function decodeCursor(s: string): CellValue[] {
|
|
178
180
|
try {
|
|
179
181
|
const arr = JSON.parse(atob(s.replace(/-/g, "+").replace(/_/g, "/")));
|
|
180
182
|
if (!Array.isArray(arr)) throw new Error("not an array");
|
|
@@ -191,7 +193,7 @@ function decodeCursor(s: string): unknown[] {
|
|
|
191
193
|
// v non-null-> col > v (NULL cols excluded, they sort before)
|
|
192
194
|
// DESC: v null -> nothing is strictly after a null (FALSE; PK tiebreak advances)
|
|
193
195
|
// v non-null-> col < v OR col IS NULL (nulls sort after all non-nulls)
|
|
194
|
-
function keysetCmp(o: OrderBy, value:
|
|
196
|
+
function keysetCmp(o: OrderBy, value: CellValue): SqlExpr {
|
|
195
197
|
const desc = o.dir === "desc";
|
|
196
198
|
if (value === null) return desc ? FALSE : isNull(o.column, true);
|
|
197
199
|
return desc ? or(cmp("<", o.column, value), isNull(o.column)) : cmp(">", o.column, value);
|
|
@@ -200,7 +202,7 @@ function keysetCmp(o: OrderBy, value: unknown): SqlExpr {
|
|
|
200
202
|
// Strictly-after predicate for a composite key: lexicographic comparison,
|
|
201
203
|
// e.g. (a,b) after (a0,b0) => a>a0 OR (a=a0 AND b>b0); DESC columns flip to <. Each
|
|
202
204
|
// column's comparison and the eq-tiebreaker are NULL-aware (eq() maps null→IS NULL).
|
|
203
|
-
function keysetAfter(order: OrderBy[], values:
|
|
205
|
+
function keysetAfter(order: OrderBy[], values: CellValue[]): SqlExpr {
|
|
204
206
|
const ors: SqlExpr[] = [];
|
|
205
207
|
for (let i = 0; i < order.length; i++) {
|
|
206
208
|
const parts: SqlExpr[] = [];
|
|
@@ -433,7 +435,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
433
435
|
// Compiles the user's where relation-aware (relation keys → security-scoped
|
|
434
436
|
// subqueries), then AND-merges the entity's own ACL row scope.
|
|
435
437
|
if (userWhere) this.assertReadableWhere(from, scope, userWhere);
|
|
436
|
-
const userExpr: SqlExpr = userWhere ? compileScopedWhere(userWhere as
|
|
438
|
+
const userExpr: SqlExpr = userWhere ? compileScopedWhere(userWhere as WhereRule, from, this.acl) : TRUE;
|
|
437
439
|
const where = scope.where ? and(userExpr, scope.where) : userExpr;
|
|
438
440
|
// A relation-traversal `where` (or a relation-traversing ACL scope) compiles to a
|
|
439
441
|
// `sub` node over another table. Record those tables in `touched` so the live-query
|
|
@@ -473,7 +475,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
473
475
|
const relations = this.schema[from]?.relations ?? {};
|
|
474
476
|
for (const [k, v] of Object.entries(where as Record<string, unknown>)) {
|
|
475
477
|
if (k === "AND" || k === "OR") {
|
|
476
|
-
for (const g of v as
|
|
478
|
+
for (const g of v as WhereRule[]) this.assertReadableWhere(from, scope, g);
|
|
477
479
|
} else if (k === "NOT") {
|
|
478
480
|
this.assertReadableWhere(from, scope, v);
|
|
479
481
|
} else if (relations[k]) {
|
|
@@ -706,7 +708,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
706
708
|
}
|
|
707
709
|
|
|
708
710
|
/** Encode one write cell: JSON-stringify a json/fileRef value, then dialect-encode. */
|
|
709
|
-
private encodeCell(jsonCols: Set<string>, col: string, v:
|
|
711
|
+
private encodeCell(jsonCols: Set<string>, col: string, v: CellValue): CellValue {
|
|
710
712
|
if (v != null && jsonCols.has(col)) return this.dialect.encode(JSON.stringify(v));
|
|
711
713
|
return this.dialect.encode(v);
|
|
712
714
|
}
|
|
@@ -767,7 +769,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
767
769
|
const project = (row: Row): Row => this.stripHidden(rel.target, projectRow(row, effectiveFields(scope, row, this.acl.identity)));
|
|
768
770
|
// One IN query per relation (no N+1). Match column before projecting (which
|
|
769
771
|
// may drop the join column).
|
|
770
|
-
const fetchBy = async (col: string, values:
|
|
772
|
+
const fetchBy = async (col: string, values: CellValue[]): Promise<Array<{ key: CellValue; row: Row }>> => {
|
|
771
773
|
if (values.length === 0) return [];
|
|
772
774
|
const where = scope.where ? and(inList(col, values), scope.where) : inList(col, values);
|
|
773
775
|
// Project the target to its readable columns (+ the join `col`, matched before
|
|
@@ -920,7 +922,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
920
922
|
this.runValidators(validators, p);
|
|
921
923
|
this.assertValidUuids(table, p);
|
|
922
924
|
|
|
923
|
-
const params:
|
|
925
|
+
const params: CellValue[] = [];
|
|
924
926
|
const jsonCols = new Set(this.jsonColsOf(table));
|
|
925
927
|
const assignments = cols
|
|
926
928
|
.map((c) => {
|
|
@@ -943,7 +945,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
943
945
|
this.assertInPartition(table);
|
|
944
946
|
const scope = this.scopeFor(table, "delete");
|
|
945
947
|
if (!scope.allowed) throw new AclDenied(table, "delete");
|
|
946
|
-
const params:
|
|
948
|
+
const params: CellValue[] = [this.dialect.encode(id)];
|
|
947
949
|
let sql = `DELETE FROM ${this.dialect.id(table)} WHERE ${this.dialect.id(this.pkOf(table))} = ${this.dialect.placeholder(1)}`;
|
|
948
950
|
sql += this.scopeClause(scope.where, params);
|
|
949
951
|
sql += this.returningClause("*");
|
|
@@ -953,7 +955,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
953
955
|
}
|
|
954
956
|
|
|
955
957
|
/** Escape hatch — raw SQL, NOT ACL-checked. For system/internal use only. */
|
|
956
|
-
async exec(sql: string, ...params:
|
|
958
|
+
async exec(sql: string, ...params: CellValue[]): Promise<Row[]> {
|
|
957
959
|
return this.driver.exec(sql, params.map((p) => this.dialect.encode(p)));
|
|
958
960
|
}
|
|
959
961
|
|
|
@@ -963,7 +965,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
963
965
|
return this.dialect.returning ? ` RETURNING ${cols}` : "";
|
|
964
966
|
}
|
|
965
967
|
|
|
966
|
-
private scopeClause(where: SqlExpr | null, params:
|
|
968
|
+
private scopeClause(where: SqlExpr | null, params: CellValue[]): string {
|
|
967
969
|
if (!where) return "";
|
|
968
970
|
const compiled = compileExpr(where, this.dialect, params);
|
|
969
971
|
return compiled.sql === "1" ? "" : ` AND (${compiled.sql})`;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Mint an HS256 JWT for local development and tooling — what a real auth service would
|
|
2
|
+
// issue, without running one.
|
|
3
|
+
//
|
|
4
|
+
// Extracted because it was already written twice (the `pramen` CLI and the repo's test
|
|
5
|
+
// helper) and `@pramen/cms`'s own bin would have been a third. One implementation, in the
|
|
6
|
+
// package that owns tokens.
|
|
7
|
+
//
|
|
8
|
+
// NOT an auth system: the dev fallback secret is public, so a token signed with it is
|
|
9
|
+
// worthless anywhere `AUTH_SECRET` is set to something real. That is the point — it makes
|
|
10
|
+
// a forgotten secret fail loudly rather than quietly accept dev tokens in production.
|
|
11
|
+
|
|
12
|
+
/** The scaffolded oblaka.ts dev secret. Used only when AUTH_SECRET is unset. */
|
|
13
|
+
export const DEV_SECRET = "dev-secret-change-me";
|
|
14
|
+
|
|
15
|
+
function bytesToB64url(bytes: Uint8Array): string {
|
|
16
|
+
let bin = "";
|
|
17
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
18
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
19
|
+
}
|
|
20
|
+
const strToB64url = (s: string): string => bytesToB64url(new TextEncoder().encode(s));
|
|
21
|
+
|
|
22
|
+
/** Sign a dev JWT (1h). Secret: the `secret` argument, else `AUTH_SECRET`, else DEV_SECRET. */
|
|
23
|
+
export async function signDevToken(payload: Record<string, unknown>, secret?: string): Promise<string> {
|
|
24
|
+
// `||`, not `??`: an AUTH_SECRET set to the empty string previously fell through to
|
|
25
|
+
// DEV_SECRET, and signing with an empty HMAC key instead would produce tokens the server
|
|
26
|
+
// rejects with no useful message.
|
|
27
|
+
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env;
|
|
28
|
+
const key = secret || env?.AUTH_SECRET || DEV_SECRET;
|
|
29
|
+
const now = Math.floor(Date.now() / 1000);
|
|
30
|
+
const header = strToB64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
31
|
+
const body = strToB64url(JSON.stringify({ iat: now, exp: now + 3600, ...payload }));
|
|
32
|
+
const data = `${header}.${body}`;
|
|
33
|
+
const cryptoKey = await crypto.subtle.importKey("raw", new TextEncoder().encode(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
34
|
+
const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(data));
|
|
35
|
+
return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
|
|
36
|
+
}
|
package/src/runtime/dispatch.ts
CHANGED
|
@@ -20,6 +20,8 @@ import type { Files } from "../sdk/files";
|
|
|
20
20
|
import type { ResolverDb } from "../sdk/acl";
|
|
21
21
|
import type { SchemaDef } from "../sdk/schema";
|
|
22
22
|
import { authorizeHandler, type AppTaskMap, type HandlerContext, type HandlerKind, type HandlerMap, type Tasks } from "../sdk/handlers";
|
|
23
|
+
import type { EnvBag } from "../sdk/handlers";
|
|
24
|
+
import type { JsonValue } from "../sdk/infer";
|
|
23
25
|
|
|
24
26
|
export interface DispatchResult {
|
|
25
27
|
readonly result: unknown;
|
|
@@ -53,10 +55,10 @@ export async function dispatch(
|
|
|
53
55
|
driver: Driver,
|
|
54
56
|
kv: Kv,
|
|
55
57
|
files: Files,
|
|
56
|
-
env:
|
|
58
|
+
env: EnvBag,
|
|
57
59
|
acl: AclContext,
|
|
58
60
|
name: string,
|
|
59
|
-
input:
|
|
61
|
+
input: JsonValue,
|
|
60
62
|
): Promise<DispatchResult> {
|
|
61
63
|
const handler = handlers[name];
|
|
62
64
|
if (!handler) throw new BadRequest(`unknown handler: ${name}`);
|
|
@@ -68,7 +70,7 @@ export async function dispatch(
|
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
// Validate/parse the request input at the boundary, if the handler declares it.
|
|
71
|
-
let parsed = input;
|
|
73
|
+
let parsed: unknown = input;
|
|
72
74
|
if (handler.input) {
|
|
73
75
|
try {
|
|
74
76
|
parsed = handler.input(input);
|
|
@@ -90,6 +92,8 @@ export async function dispatch(
|
|
|
90
92
|
files,
|
|
91
93
|
env,
|
|
92
94
|
identity: acl.identity,
|
|
95
|
+
tenant: acl.tenant ?? "main",
|
|
96
|
+
store: acl.store ?? "do",
|
|
93
97
|
tasks: tasksFacade(driver, () => enqueued++),
|
|
94
98
|
mail: createMail(env, kv),
|
|
95
99
|
queue: createQueue(env),
|
package/src/runtime/driver.ts
CHANGED
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
// in a Driver, rather than rewriting the engine. Live queries remain a DO-only
|
|
12
12
|
// capability (they need a single writer + a stateful socket host).
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/** A raw row exactly as the substrate returns it, before pramen's object↔JSON codec.
|
|
15
|
+
* Distinct from the decoded `Row` handlers see at the `Db` chokepoint. */
|
|
16
|
+
export type DriverRow = Record<string, SqlValue>;
|
|
17
|
+
|
|
18
|
+
import type { CellValue, SqlValue } from "../sdk/infer";
|
|
15
19
|
|
|
16
20
|
export interface Dialect {
|
|
17
21
|
/** Render an identifier (table/column), quoting as the backend requires. */
|
|
@@ -21,7 +25,7 @@ export interface Dialect {
|
|
|
21
25
|
/** Whether INSERT/UPDATE/DELETE ... RETURNING is supported (SQLite/Postgres yes; MySQL no). */
|
|
22
26
|
readonly returning: boolean;
|
|
23
27
|
/** Coerce a JS value for binding (e.g. boolean → 0/1 on SQLite). */
|
|
24
|
-
encode(v:
|
|
28
|
+
encode(v: CellValue): CellValue;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
@@ -66,7 +70,7 @@ export interface Driver {
|
|
|
66
70
|
readonly dialect: Dialect;
|
|
67
71
|
/** Run a parameterized statement and return the result rows (empty for writes
|
|
68
72
|
* without RETURNING). Params are already dialect-encoded by the caller. */
|
|
69
|
-
exec(sql: string, params:
|
|
73
|
+
exec(sql: string, params: CellValue[]): Promise<DriverRow[]>;
|
|
70
74
|
/** Run `fn` inside a transaction: commit on resolve, roll back on throw. */
|
|
71
75
|
transaction<T>(fn: () => Promise<T>): Promise<T>;
|
|
72
76
|
/** Run a fixed sequence of write statements ATOMICALLY with FK checks deferred to the
|
|
@@ -83,8 +87,8 @@ export class DoSqliteDriver implements Driver {
|
|
|
83
87
|
readonly dialect = sqliteDialect;
|
|
84
88
|
constructor(private readonly storage: DurableObjectStorage) {}
|
|
85
89
|
|
|
86
|
-
async exec(sql: string, params:
|
|
87
|
-
return this.storage.sql.exec(sql, ...params).toArray() as
|
|
90
|
+
async exec(sql: string, params: CellValue[]): Promise<DriverRow[]> {
|
|
91
|
+
return this.storage.sql.exec(sql, ...params).toArray() as DriverRow[];
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
transaction<T>(fn: () => Promise<T>): Promise<T> {
|
|
@@ -124,9 +128,9 @@ export class D1Driver implements Driver {
|
|
|
124
128
|
this.session = db.withSession(opts?.start ?? "first-unconstrained");
|
|
125
129
|
}
|
|
126
130
|
|
|
127
|
-
async exec(sql: string, params:
|
|
131
|
+
async exec(sql: string, params: CellValue[]): Promise<DriverRow[]> {
|
|
128
132
|
const stmt = params.length ? this.session.prepare(sql).bind(...params) : this.session.prepare(sql);
|
|
129
|
-
const { results } = await stmt.all<
|
|
133
|
+
const { results } = await stmt.all<DriverRow>();
|
|
130
134
|
return results ?? [];
|
|
131
135
|
}
|
|
132
136
|
|
package/src/runtime/errors.ts
CHANGED
|
@@ -34,6 +34,15 @@ export class Forbidden extends PramenError {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/** 409 — the request conflicts with the current state of the resource. For
|
|
38
|
+
* optimistic concurrency (a stale `expectedVersion`) and for uniqueness clashes the
|
|
39
|
+
* caller could resolve by retrying with different input. */
|
|
40
|
+
export class Conflict extends PramenError {
|
|
41
|
+
constructor(message = "conflict") {
|
|
42
|
+
super(message, 409, "conflict");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
37
46
|
export interface ErrorBody {
|
|
38
47
|
ok: false;
|
|
39
48
|
error: string;
|
package/src/runtime/mail.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// in-memory — so handlers work unchanged off-platform.
|
|
12
12
|
|
|
13
13
|
import type { Kv } from "./kv";
|
|
14
|
+
import type { EnvBag } from "../sdk/handlers";
|
|
14
15
|
|
|
15
16
|
export interface MailAddress {
|
|
16
17
|
email: string;
|
|
@@ -120,7 +121,7 @@ export class UnconfiguredMailAdapter implements MailAdapter {
|
|
|
120
121
|
* a synthetic dev sender — an EXPLICIT dev opt-in, never the production default.
|
|
121
122
|
* - else → fail closed: a `send` throws (so a missing-MAIL_FROM prod doesn't silently
|
|
122
123
|
* stash security emails in KV). */
|
|
123
|
-
export function createMail(env:
|
|
124
|
+
export function createMail(env: EnvBag, kv?: Kv): Mail {
|
|
124
125
|
const binding = env.EMAIL as SendEmailBinding | undefined;
|
|
125
126
|
const fromAddr = typeof env.MAIL_FROM === "string" && env.MAIL_FROM ? env.MAIL_FROM : undefined;
|
|
126
127
|
if (binding && fromAddr) {
|
package/src/runtime/migrate.ts
CHANGED
|
@@ -43,6 +43,7 @@ import { digest } from "./digest";
|
|
|
43
43
|
import { quoteIdent, type Driver } from "./driver";
|
|
44
44
|
import { entitiesInPartition, partitionOf, validateSchema } from "../sdk/schema";
|
|
45
45
|
import type { EntityFields, FieldDef, SchemaDef } from "../sdk/schema";
|
|
46
|
+
import type { CellValue } from "../sdk/infer";
|
|
46
47
|
|
|
47
48
|
export interface MigrationReport {
|
|
48
49
|
changed: boolean;
|
|
@@ -306,7 +307,7 @@ async function rebuildTable(
|
|
|
306
307
|
// rebuilt table whose FKs momentarily see stale rows), so run the whole sequence
|
|
307
308
|
// ATOMICALLY: the D1 driver's batch() defers FK checks to the batch commit, and on the
|
|
308
309
|
// DO the ambient boot transaction (+ defer set at migrate start) already covers it.
|
|
309
|
-
const stmts: { sql: string; params:
|
|
310
|
+
const stmts: { sql: string; params: CellValue[] }[] = [];
|
|
310
311
|
// Quarantine tables are bare column lists — untyped, no constraints, no FKs. Values
|
|
311
312
|
// round-trip verbatim (they were already coerced by the original table's affinity).
|
|
312
313
|
const bareCopy = (name: string, quotedCols: string[]): string => `CREATE TABLE ${quoteIdent(name)} (${quotedCols.join(", ")})`;
|
package/src/runtime/outbox.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// task `id` as an idempotency key so they can dedupe across the rare retry.
|
|
20
20
|
|
|
21
21
|
import type { Driver } from "./driver";
|
|
22
|
+
import type { CellValue } from "../sdk/infer";
|
|
22
23
|
|
|
23
24
|
export const OUTBOX_TABLE = "_pramen_outbox";
|
|
24
25
|
|
|
@@ -34,7 +35,7 @@ function backoffMs(attempts: number): number {
|
|
|
34
35
|
return Math.min(2 ** attempts * 1000, 5 * 60_000); // 2s, 4s, 8s, … capped at 5min
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
const enc = (driver: Driver, params:
|
|
38
|
+
const enc = (driver: Driver, params: CellValue[]): CellValue[] => params.map((p) => driver.dialect.encode(p));
|
|
38
39
|
|
|
39
40
|
/** Create the outbox table if absent. Idempotent — run on DO boot (and lazily on the
|
|
40
41
|
* D1 path). Internal table (`_pramen_` prefix), never part of the user schema. */
|
package/src/runtime/protocol.ts
CHANGED
|
@@ -10,11 +10,13 @@
|
|
|
10
10
|
// { type: "result", id, result } // reply to a one-shot call
|
|
11
11
|
// { type: "error", id, error }
|
|
12
12
|
|
|
13
|
+
import type { JsonValue } from "../sdk/infer";
|
|
14
|
+
|
|
13
15
|
export interface SubscribeMsg {
|
|
14
16
|
type: "subscribe";
|
|
15
17
|
id: string;
|
|
16
18
|
name: string;
|
|
17
|
-
input?:
|
|
19
|
+
input?: JsonValue;
|
|
18
20
|
}
|
|
19
21
|
export interface UnsubscribeMsg {
|
|
20
22
|
type: "unsubscribe";
|
|
@@ -24,7 +26,7 @@ export interface CallMsg {
|
|
|
24
26
|
type: "call";
|
|
25
27
|
id: string;
|
|
26
28
|
name: string;
|
|
27
|
-
input?:
|
|
29
|
+
input?: JsonValue;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export type ClientMsg = SubscribeMsg | UnsubscribeMsg | CallMsg;
|
|
@@ -38,7 +40,7 @@ export type ServerMsg =
|
|
|
38
40
|
export interface Subscription {
|
|
39
41
|
id: string;
|
|
40
42
|
name: string;
|
|
41
|
-
input:
|
|
43
|
+
input: JsonValue;
|
|
42
44
|
/** Tables the query read — the coarse prefilter for which writes might matter. */
|
|
43
45
|
tables: string[];
|
|
44
46
|
/** Digest of the last result pushed — used to suppress no-op (row-level) pushes. */
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import type { Mail } from "./mail";
|
|
13
13
|
import type { Queue } from "./queue";
|
|
14
14
|
import type { Kv } from "./kv";
|
|
15
|
+
import type { EnvBag } from "../sdk/handlers";
|
|
16
|
+
import type { JsonValue } from "../sdk/infer";
|
|
15
17
|
|
|
16
18
|
/** One received message (the Cloudflare Queues `Message` shape). */
|
|
17
19
|
export interface QueueMessage<Body = unknown> {
|
|
@@ -40,7 +42,7 @@ export interface QueueBatch<Body = unknown> {
|
|
|
40
42
|
* tenant data via `ctx.callPrivileged`. */
|
|
41
43
|
export interface QueueContext {
|
|
42
44
|
/** The Worker environment (bindings + vars + secrets). */
|
|
43
|
-
readonly env:
|
|
45
|
+
readonly env: EnvBag;
|
|
44
46
|
/** Project KV (cross-tenant). */
|
|
45
47
|
readonly kv: Kv;
|
|
46
48
|
/** Send email (the notification path). */
|
|
@@ -49,7 +51,7 @@ export interface QueueContext {
|
|
|
49
51
|
readonly queue: Queue;
|
|
50
52
|
/** Apply a privileged mutation into a tenant's DO (the consumer has no direct db).
|
|
51
53
|
* The message body should carry the `tenant`. */
|
|
52
|
-
callPrivileged(opts: { name: string; input?:
|
|
54
|
+
callPrivileged(opts: { name: string; input?: JsonValue; tenant?: string; roles?: string[]; partition?: string }): Promise<Response>;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/** A queue consumer handler — runs once per message. Resolving ACKs the message;
|
package/src/runtime/queue.ts
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
// that isn't bound FAILS CLOSED (throws) rather than silently dropping the message —
|
|
20
20
|
// mirroring how ctx.mail fails closed without a transport.
|
|
21
21
|
|
|
22
|
+
import type { EnvBag } from "../sdk/handlers";
|
|
23
|
+
|
|
22
24
|
/** Cloudflare Queues content type for a sent message. Omitted ⇒ the platform default
|
|
23
25
|
* (v8 structured clone). Use "json" for cross-runtime / external consumers. */
|
|
24
26
|
export type QueueContentType = "text" | "bytes" | "json" | "v8";
|
|
@@ -131,7 +133,7 @@ export class MemoryQueueAdapter implements QueueAdapter {
|
|
|
131
133
|
/** Discover the Cloudflare Queues producer bindings in an environment: any value that
|
|
132
134
|
* exposes BOTH `send` and `sendBatch` functions (which excludes the email `send`-only
|
|
133
135
|
* binding, KV, R2, D1, the DO namespace, …). Returns name → binding. */
|
|
134
|
-
export function discoverQueueBindings(env:
|
|
136
|
+
export function discoverQueueBindings(env: EnvBag): Record<string, QueueProducerBinding> {
|
|
135
137
|
const out: Record<string, QueueProducerBinding> = {};
|
|
136
138
|
for (const [name, value] of Object.entries(env)) {
|
|
137
139
|
if (
|
|
@@ -150,6 +152,6 @@ export function discoverQueueBindings(env: Readonly<Record<string, unknown>>): R
|
|
|
150
152
|
* producer bindings. Sending to an undeclared queue fails closed (the adapter throws).
|
|
151
153
|
* There is no silent capture fallback — declare the `Queue` binding and it exists in
|
|
152
154
|
* dev (lopata) and miniflare too. */
|
|
153
|
-
export function createQueue(env:
|
|
155
|
+
export function createQueue(env: EnvBag): Queue {
|
|
154
156
|
return new Queue(new CloudflareQueueAdapter(discoverQueueBindings(env)));
|
|
155
157
|
}
|