@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
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
// AND/OR groups) and ACL row-level scopes be merged before compilation. Column
|
|
7
7
|
// names come from developer code (schema keys); values are always parameterized.
|
|
8
8
|
|
|
9
|
+
import type { WhereRule, WhereValue } from "../sdk/acl";
|
|
10
|
+
import type { CellValue, Row } from "../sdk/infer";
|
|
11
|
+
|
|
9
12
|
import type { Dialect } from "./driver";
|
|
10
13
|
|
|
11
14
|
// In-process value coercion for evalExpr (SQLite-style booleans). SQL-side encoding
|
|
12
15
|
// goes through the active Dialect; this mirrors it for the cell-ACL `when` evaluator.
|
|
13
|
-
function bind(v:
|
|
16
|
+
function bind(v: CellValue): CellValue {
|
|
14
17
|
return typeof v === "boolean" ? (v ? 1 : 0) : v;
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -23,8 +26,8 @@ export type StrMode = "contains" | "prefix" | "suffix";
|
|
|
23
26
|
export type SqlExpr =
|
|
24
27
|
| { t: "true" }
|
|
25
28
|
| { t: "false" }
|
|
26
|
-
| { t: "cmp"; op: CmpOp; col: string; value:
|
|
27
|
-
| { t: "in"; col: string; values:
|
|
29
|
+
| { t: "cmp"; op: CmpOp; col: string; value: CellValue }
|
|
30
|
+
| { t: "in"; col: string; values: CellValue[]; negate: boolean }
|
|
28
31
|
| { t: "null"; col: string; negate: boolean }
|
|
29
32
|
// Structured substring match — the needle is escaped and wrapped, so `%`/`_` in the
|
|
30
33
|
// input match literally (unlike raw `like`, where the caller controls wildcards).
|
|
@@ -39,28 +42,28 @@ export type SqlExpr =
|
|
|
39
42
|
|
|
40
43
|
export const TRUE: SqlExpr = { t: "true" };
|
|
41
44
|
export const FALSE: SqlExpr = { t: "false" };
|
|
42
|
-
export const cmp = (op: CmpOp, col: string, value:
|
|
45
|
+
export const cmp = (op: CmpOp, col: string, value: CellValue): SqlExpr => ({ t: "cmp", op, col, value });
|
|
43
46
|
export const isNull = (col: string, negate = false): SqlExpr => ({ t: "null", col, negate });
|
|
44
|
-
export const inList = (col: string, values:
|
|
47
|
+
export const inList = (col: string, values: CellValue[], negate = false): SqlExpr => ({ t: "in", col, values, negate });
|
|
45
48
|
export const strMatch = (col: string, needle: string, mode: StrMode): SqlExpr => ({ t: "strmatch", col, needle, mode });
|
|
46
49
|
export const and = (...parts: SqlExpr[]): SqlExpr => ({ t: "and", parts });
|
|
47
50
|
export const or = (...parts: SqlExpr[]): SqlExpr => ({ t: "or", parts });
|
|
48
51
|
export const not = (expr: SqlExpr): SqlExpr => ({ t: "not", expr });
|
|
49
52
|
|
|
50
53
|
/** Equality (null -> IS NULL). Used by ACL scope building and relation loads. */
|
|
51
|
-
export const eq = (col: string, value:
|
|
54
|
+
export const eq = (col: string, value: CellValue): SqlExpr => (value === null ? isNull(col) : cmp("=", col, value));
|
|
52
55
|
|
|
53
56
|
/** Compile a structured user predicate into a SqlExpr.
|
|
54
57
|
* Shapes: { col: value } (eq) | { col: { gt, lt, in, like, isNull, … } } | { AND: [...] } | { OR: [...] } */
|
|
55
|
-
export function compileWhere(input:
|
|
58
|
+
export function compileWhere(input: WhereRule): SqlExpr {
|
|
56
59
|
const parts: SqlExpr[] = [];
|
|
57
60
|
for (const [k, v] of Object.entries(input)) {
|
|
58
61
|
if (k === "AND") {
|
|
59
|
-
parts.push(and(...(v as
|
|
62
|
+
parts.push(and(...(v as WhereRule[]).map(compileWhere)));
|
|
60
63
|
} else if (k === "OR") {
|
|
61
|
-
parts.push(or(...(v as
|
|
64
|
+
parts.push(or(...(v as WhereRule[]).map(compileWhere)));
|
|
62
65
|
} else if (k === "NOT") {
|
|
63
|
-
parts.push(not(compileWhere(v as
|
|
66
|
+
parts.push(not(compileWhere(v as WhereRule)));
|
|
64
67
|
} else {
|
|
65
68
|
parts.push(columnPredicate(k, v));
|
|
66
69
|
}
|
|
@@ -68,10 +71,13 @@ export function compileWhere(input: Record<string, unknown>): SqlExpr {
|
|
|
68
71
|
return parts.length ? and(...parts) : TRUE;
|
|
69
72
|
}
|
|
70
73
|
|
|
71
|
-
function columnPredicate(col: string, v:
|
|
74
|
+
function columnPredicate(col: string, v: WhereValue): SqlExpr {
|
|
72
75
|
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
|
73
76
|
const ops: SqlExpr[] = [];
|
|
74
|
-
for (const [op,
|
|
77
|
+
for (const [op, operand] of Object.entries(v as WhereRule)) {
|
|
78
|
+
// Markers are resolved upstream (runtime/acl.ts), so an operator's operand is a
|
|
79
|
+
// literal cell value by this point.
|
|
80
|
+
const val = operand as CellValue;
|
|
75
81
|
switch (op) {
|
|
76
82
|
case "eq": ops.push(eq(col, val)); break;
|
|
77
83
|
case "ne": ops.push(val === null ? isNull(col, true) : cmp("!=", col, val)); break;
|
|
@@ -83,23 +89,23 @@ function columnPredicate(col: string, v: unknown): SqlExpr {
|
|
|
83
89
|
case "contains": ops.push(strMatch(col, String(val), "contains")); break;
|
|
84
90
|
case "startsWith": ops.push(strMatch(col, String(val), "prefix")); break;
|
|
85
91
|
case "endsWith": ops.push(strMatch(col, String(val), "suffix")); break;
|
|
86
|
-
case "in": ops.push(inList(col, val as
|
|
87
|
-
case "notIn": ops.push(inList(col, val as
|
|
92
|
+
case "in": ops.push(inList(col, val as CellValue[])); break;
|
|
93
|
+
case "notIn": ops.push(inList(col, val as CellValue[], true)); break;
|
|
88
94
|
case "isNull": ops.push(isNull(col, !val)); break; // isNull:true => IS NULL
|
|
89
95
|
default: throw new Error(`unknown operator: ${op}`);
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
98
|
return ops.length ? and(...ops) : TRUE;
|
|
93
99
|
}
|
|
94
|
-
return eq(col, v);
|
|
100
|
+
return eq(col, v as CellValue);
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
export interface CompiledSql {
|
|
98
104
|
readonly sql: string;
|
|
99
|
-
readonly params:
|
|
105
|
+
readonly params: CellValue[];
|
|
100
106
|
}
|
|
101
107
|
|
|
102
|
-
export function compileExpr(expr: SqlExpr, dialect: Dialect, params:
|
|
108
|
+
export function compileExpr(expr: SqlExpr, dialect: Dialect, params: CellValue[] = []): CompiledSql {
|
|
103
109
|
switch (expr.t) {
|
|
104
110
|
case "true":
|
|
105
111
|
return { sql: "1", params };
|
|
@@ -168,7 +174,7 @@ function likeToRegex(pattern: string): RegExp {
|
|
|
168
174
|
* semantics (bound-boolean coercion, NULL compares false, empty-IN, LIKE) so the
|
|
169
175
|
* declarative cell-ACL `when` path can decide per-row field visibility without a
|
|
170
176
|
* round-trip to SQLite. */
|
|
171
|
-
export function evalExpr(expr: SqlExpr, row:
|
|
177
|
+
export function evalExpr(expr: SqlExpr, row: Row): boolean {
|
|
172
178
|
switch (expr.t) {
|
|
173
179
|
case "true":
|
|
174
180
|
return true;
|
|
@@ -245,7 +251,7 @@ export type AggFn = "count" | "sum" | "avg" | "min" | "max";
|
|
|
245
251
|
const AGG_SQL: Record<AggFn, string> = { count: "COUNT", sum: "SUM", avg: "AVG", min: "MIN", max: "MAX" };
|
|
246
252
|
|
|
247
253
|
export function compileCount(from: string, dialect: Dialect, where?: SqlExpr): CompiledSql {
|
|
248
|
-
const params:
|
|
254
|
+
const params: CellValue[] = [];
|
|
249
255
|
let sql = `SELECT COUNT(*) AS n FROM ${dialect.id(from)}`;
|
|
250
256
|
if (where && where.t !== "true") sql += ` WHERE ${compileExpr(where, dialect, params).sql}`;
|
|
251
257
|
return { sql, params };
|
|
@@ -265,7 +271,7 @@ export function compileAggregate(
|
|
|
265
271
|
},
|
|
266
272
|
dialect: Dialect,
|
|
267
273
|
): CompiledSql {
|
|
268
|
-
const params:
|
|
274
|
+
const params: CellValue[] = [];
|
|
269
275
|
const cols: string[] = [];
|
|
270
276
|
for (const g of spec.groupBy ?? []) cols.push(dialect.id(g));
|
|
271
277
|
for (const [key, agg] of Object.entries(spec.aggregations)) {
|
|
@@ -279,7 +285,7 @@ export function compileAggregate(
|
|
|
279
285
|
}
|
|
280
286
|
|
|
281
287
|
export function compileSelect(spec: QuerySpec, dialect: Dialect): CompiledSql {
|
|
282
|
-
const params:
|
|
288
|
+
const params: CellValue[] = [];
|
|
283
289
|
const cols = spec.columns && spec.columns.length > 0 ? spec.columns.map((c) => dialect.id(c)).join(", ") : "*";
|
|
284
290
|
let sql = `SELECT ${cols} FROM ${dialect.id(spec.from)}`;
|
|
285
291
|
|
package/src/runtime/registry.ts
CHANGED
|
@@ -79,7 +79,11 @@ export function parseRegistryKey(key: string): DoRef | null {
|
|
|
79
79
|
/** Enumerate every registered `(tenant, partition)` pair from the registry KV.
|
|
80
80
|
* Paginates over the full listing (cursor / list_complete) — never truncates at the
|
|
81
81
|
* 1000-key page limit. */
|
|
82
|
-
|
|
82
|
+
/** The slice of KV that DO enumeration needs — narrower than the whole namespace, so
|
|
83
|
+
* callers (and test doubles) only have to provide `list`. */
|
|
84
|
+
export type KvLister = Pick<KVNamespace, "list">;
|
|
85
|
+
|
|
86
|
+
export async function listDOs(kv: KvLister): Promise<DoRef[]> {
|
|
83
87
|
const out: DoRef[] = [];
|
|
84
88
|
let cursor: string | undefined;
|
|
85
89
|
for (;;) {
|
|
@@ -21,7 +21,7 @@ import type { FieldDef, SchemaDef } from "../sdk/schema";
|
|
|
21
21
|
import { partitionOf } from "../sdk/schema";
|
|
22
22
|
|
|
23
23
|
/** The comparable fingerprint of a single column: type + migration-relevant modifiers. */
|
|
24
|
-
export interface
|
|
24
|
+
export interface ColumnFingerprint {
|
|
25
25
|
type: string;
|
|
26
26
|
notNull?: boolean;
|
|
27
27
|
unique?: boolean;
|
|
@@ -33,16 +33,16 @@ export interface ColumnShape {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** The comparable fingerprint of a table: its partition + each column's shape. */
|
|
36
|
-
export interface
|
|
36
|
+
export interface TableFingerprint {
|
|
37
37
|
partition: string;
|
|
38
|
-
columns: Record<string,
|
|
38
|
+
columns: Record<string, ColumnFingerprint>;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/** table -> table shape. The comparable surface of a schema. */
|
|
42
|
-
export type
|
|
42
|
+
export type SchemaFingerprint = Record<string, TableFingerprint>;
|
|
43
43
|
|
|
44
|
-
function
|
|
45
|
-
const c:
|
|
44
|
+
function columnFingerprint(f: FieldDef): ColumnFingerprint {
|
|
45
|
+
const c: ColumnFingerprint = { type: f.type };
|
|
46
46
|
if (f.notNull) c.notNull = true;
|
|
47
47
|
if (f.unique) c.unique = true;
|
|
48
48
|
if (f.primaryKey) c.primaryKey = true;
|
|
@@ -53,27 +53,27 @@ function columnShape(f: FieldDef): ColumnShape {
|
|
|
53
53
|
return c;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export function
|
|
57
|
-
const out:
|
|
56
|
+
export function schemaFingerprint(schema: SchemaDef): SchemaFingerprint {
|
|
57
|
+
const out: SchemaFingerprint = {};
|
|
58
58
|
for (const [table, def] of Object.entries(schema)) {
|
|
59
|
-
const columns: Record<string,
|
|
60
|
-
for (const [col, f] of Object.entries(def.fields)) columns[col] =
|
|
59
|
+
const columns: Record<string, ColumnFingerprint> = {};
|
|
60
|
+
for (const [col, f] of Object.entries(def.fields)) columns[col] = columnFingerprint(f as FieldDef);
|
|
61
61
|
out[table] = { partition: partitionOf(schema, table), columns };
|
|
62
62
|
}
|
|
63
63
|
return out;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/** The modifier fields compared for a `change-column` (everything but `type`). */
|
|
67
|
-
const MODIFIER_KEYS: (keyof
|
|
67
|
+
const MODIFIER_KEYS: (keyof ColumnFingerprint)[] = ["notNull", "unique", "primaryKey", "generated", "hidden", "default"];
|
|
68
68
|
|
|
69
69
|
/** Does `next` tighten a constraint `prev` lacked (add NOT NULL / UNIQUE / PRIMARY KEY)?
|
|
70
70
|
* Such a change may require the destructive gate or be skipped when the live data
|
|
71
71
|
* conflicts (NULL rows / duplicates) — so the diff flags it `destructive`. */
|
|
72
|
-
function tightensConstraint(prev:
|
|
72
|
+
function tightensConstraint(prev: ColumnFingerprint, next: ColumnFingerprint): boolean {
|
|
73
73
|
return (!!next.notNull && !prev.notNull) || (!!next.unique && !prev.unique) || (!!next.primaryKey && !prev.primaryKey);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
function modifierDiff(prev:
|
|
76
|
+
function modifierDiff(prev: ColumnFingerprint, next: ColumnFingerprint): string | null {
|
|
77
77
|
const parts: string[] = [];
|
|
78
78
|
for (const k of MODIFIER_KEYS) {
|
|
79
79
|
if (prev[k] !== next[k]) parts.push(`${k}: ${fmt(prev[k])} → ${fmt(next[k])}`);
|
|
@@ -102,7 +102,7 @@ export interface SchemaChange {
|
|
|
102
102
|
appliesOnBoot: boolean;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
export function
|
|
105
|
+
export function diffSchemaFingerprint(prev: SchemaFingerprint, next: SchemaFingerprint): SchemaChange[] {
|
|
106
106
|
const changes: SchemaChange[] = [];
|
|
107
107
|
|
|
108
108
|
for (const table of Object.keys(next)) {
|
package/src/runtime/storage.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { BadRequest, PramenError } from "./errors";
|
|
19
19
|
import type { Files, FileRef, HeadResult } from "../sdk/files";
|
|
20
|
+
import { signToken, verifyToken, isUsableSecret, MIN_TOKEN_SECRET_LEN, type ExpiringToken } from "./token";
|
|
20
21
|
|
|
21
22
|
// The portable type surface (FileRef, Files, sign opts) lives in sdk/files.ts;
|
|
22
23
|
// re-export it here so runtime callers have one import site.
|
|
@@ -118,72 +119,25 @@ function bytesToStream(bytes: Uint8Array): ReadableStream {
|
|
|
118
119
|
});
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
// ---
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
127
|
-
}
|
|
128
|
-
function b64urlToBytes(s: string): Uint8Array {
|
|
129
|
-
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
|
|
130
|
-
const bin = atob(b64);
|
|
131
|
-
const out = new Uint8Array(bin.length);
|
|
132
|
-
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
133
|
-
return out;
|
|
134
|
-
}
|
|
135
|
-
const strToB64url = (s: string) => bytesToB64url(new TextEncoder().encode(s));
|
|
136
|
-
const b64urlToStr = (s: string) => new TextDecoder().decode(b64urlToBytes(s));
|
|
137
|
-
|
|
138
|
-
// --- signed file tokens (HMAC-SHA256) ---
|
|
122
|
+
// --- signed file tokens ---
|
|
123
|
+
//
|
|
124
|
+
// The HMAC/base64url machinery is shared with page-preview links and anything else that
|
|
125
|
+
// needs a signed capability url; it lives in runtime/token.ts. This file only declares
|
|
126
|
+
// what a FILE token carries.
|
|
139
127
|
|
|
140
|
-
interface FileToken {
|
|
128
|
+
interface FileToken extends ExpiringToken {
|
|
141
129
|
/** tenant */ t: string;
|
|
142
130
|
/** key */ k: string;
|
|
143
131
|
/** op */ op: "get" | "put";
|
|
144
|
-
/** expiry (epoch seconds) */ exp: number;
|
|
145
132
|
/** content-type (put: enforced; get: disposition hint) */ ct?: string;
|
|
146
133
|
/** max size in bytes (put only) */ max?: number;
|
|
147
134
|
/** filename (download disposition) */ fn?: string;
|
|
148
135
|
}
|
|
149
136
|
|
|
150
|
-
|
|
151
|
-
return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
152
|
-
"sign",
|
|
153
|
-
"verify",
|
|
154
|
-
]);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async function signToken(token: FileToken, secret: string): Promise<string> {
|
|
158
|
-
const data = strToB64url(JSON.stringify(token));
|
|
159
|
-
const key = await hmacKey(secret);
|
|
160
|
-
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
|
|
161
|
-
return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
|
|
162
|
-
}
|
|
137
|
+
const signFileToken = (token: FileToken, secret: string): Promise<string> => signToken(token, secret);
|
|
163
138
|
|
|
164
139
|
/** Verify a file token's signature + expiry; returns the payload or null. */
|
|
165
|
-
export
|
|
166
|
-
const dot = raw.indexOf(".");
|
|
167
|
-
if (dot < 0) return null;
|
|
168
|
-
const data = raw.slice(0, dot);
|
|
169
|
-
const sig = raw.slice(dot + 1);
|
|
170
|
-
let ok: boolean;
|
|
171
|
-
try {
|
|
172
|
-
const key = await hmacKey(secret);
|
|
173
|
-
ok = await crypto.subtle.verify("HMAC", key, b64urlToBytes(sig), new TextEncoder().encode(data));
|
|
174
|
-
} catch {
|
|
175
|
-
return null;
|
|
176
|
-
}
|
|
177
|
-
if (!ok) return null;
|
|
178
|
-
let payload: FileToken;
|
|
179
|
-
try {
|
|
180
|
-
payload = JSON.parse(b64urlToStr(data));
|
|
181
|
-
} catch {
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
if (typeof payload.exp !== "number" || Math.floor(Date.now() / 1000) >= payload.exp) return null;
|
|
185
|
-
return payload;
|
|
186
|
-
}
|
|
140
|
+
export const verifyFileToken = (raw: string, secret: string): Promise<FileToken | null> => verifyToken<FileToken>(raw, secret);
|
|
187
141
|
|
|
188
142
|
// --- key generation ---
|
|
189
143
|
|
|
@@ -247,10 +201,8 @@ export interface FilesConfig {
|
|
|
247
201
|
* would be forgeable (HMAC over an empty/weak key). Below this, file storage is
|
|
248
202
|
* treated as unconfigured — fail closed rather than mint forgeable urls. The dev
|
|
249
203
|
* defaults satisfy it; production should set a strong, random FILES_SECRET. */
|
|
250
|
-
export const MIN_FILES_SECRET_LEN =
|
|
251
|
-
export
|
|
252
|
-
return typeof secret === "string" && secret.length >= MIN_FILES_SECRET_LEN;
|
|
253
|
-
}
|
|
204
|
+
export const MIN_FILES_SECRET_LEN = MIN_TOKEN_SECRET_LEN;
|
|
205
|
+
export const isUsableFilesSecret = isUsableSecret;
|
|
254
206
|
const filesUnconfigured = () =>
|
|
255
207
|
new PramenError("file storage is not configured (set a strong FILES_SECRET)", 503, "unavailable");
|
|
256
208
|
|
|
@@ -271,7 +223,7 @@ export function createFiles(cfg: FilesConfig): Files {
|
|
|
271
223
|
const seg = opts.prefix ? `${safePrefix(opts.prefix)}/` : "";
|
|
272
224
|
const key = `${prefix}${seg}${randomKeySuffix()}`;
|
|
273
225
|
const exp = Math.floor(Date.now() / 1000) + (opts.expiresIn ?? 900);
|
|
274
|
-
const token = await
|
|
226
|
+
const token = await signFileToken({ t: cfg.tenant, k: key, op: "put", exp, ct, max: opts.maxSize, fn: opts.filename }, cfg.secret);
|
|
275
227
|
const ref: FileRef = { key, size: 0, contentType: ct, filename: opts.filename, uploadedAt: Date.now() };
|
|
276
228
|
return { url: `${base}/upload?token=${encodeURIComponent(token)}`, ref };
|
|
277
229
|
},
|
|
@@ -282,7 +234,7 @@ export function createFiles(cfg: FilesConfig): Files {
|
|
|
282
234
|
const key = ensureOwnKey(r.key);
|
|
283
235
|
const exp = Math.floor(Date.now() / 1000) + (opts?.expiresIn ?? 3600);
|
|
284
236
|
const fn = opts?.download ? (typeof ref === "string" ? undefined : ref.filename) : undefined;
|
|
285
|
-
const token = await
|
|
237
|
+
const token = await signFileToken({ t: cfg.tenant, k: key, op: "get", exp, fn }, cfg.secret);
|
|
286
238
|
return { url: `${base}/download?token=${encodeURIComponent(token)}`, expiresAt: exp * 1000 };
|
|
287
239
|
},
|
|
288
240
|
|
|
@@ -327,7 +279,7 @@ export async function handleFileRequest(
|
|
|
327
279
|
|
|
328
280
|
const raw = url.searchParams.get("token");
|
|
329
281
|
if (!raw) return fileError(401, "unauthorized", "missing token");
|
|
330
|
-
const token = await
|
|
282
|
+
const token = await verifyFileToken(raw, opts.secret);
|
|
331
283
|
if (!token) return fileError(403, "forbidden", "invalid or expired token");
|
|
332
284
|
|
|
333
285
|
try {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Signed, self-expiring capability tokens (HMAC-SHA256 over a JSON payload).
|
|
2
|
+
//
|
|
3
|
+
// pramen already owns the edge and a secret, so a capability url needs no session and no
|
|
4
|
+
// store: the payload IS the grant, and the signature is what makes it unforgeable. Signed
|
|
5
|
+
// file urls were the first user (`runtime/storage.ts`); page preview links are the second
|
|
6
|
+
// (`@pramen/cms`). Both mint in a handler and redeem in the Worker, unauthenticated.
|
|
7
|
+
//
|
|
8
|
+
// Pure WebCrypto — synchronous in the sense that matters (no stream I/O), so it is safe to
|
|
9
|
+
// call from inside the DO's storage.transaction().
|
|
10
|
+
//
|
|
11
|
+
// A token is `<b64url(json)>.<b64url(sig)>`. It is deliberately NOT a JWT: no alg field to
|
|
12
|
+
// confuse, no header to downgrade, one algorithm, verified before the payload is parsed.
|
|
13
|
+
|
|
14
|
+
/** Every signed token carries an expiry (epoch seconds); `verifyToken` enforces it. */
|
|
15
|
+
export interface ExpiringToken {
|
|
16
|
+
exp: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function bytesToB64url(bytes: Uint8Array): string {
|
|
20
|
+
let bin = "";
|
|
21
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
22
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function b64urlToBytes(s: string): Uint8Array {
|
|
26
|
+
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
|
|
27
|
+
const bin = atob(b64);
|
|
28
|
+
const out = new Uint8Array(bin.length);
|
|
29
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const strToB64url = (s: string): string => bytesToB64url(new TextEncoder().encode(s));
|
|
34
|
+
export const b64urlToStr = (s: string): string => new TextDecoder().decode(b64urlToBytes(s));
|
|
35
|
+
|
|
36
|
+
async function hmacKey(secret: string): Promise<CryptoKey> {
|
|
37
|
+
return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
38
|
+
"sign",
|
|
39
|
+
"verify",
|
|
40
|
+
]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Sign a payload into a capability token. */
|
|
44
|
+
export async function signToken<T extends ExpiringToken>(payload: T, secret: string): Promise<string> {
|
|
45
|
+
const data = strToB64url(JSON.stringify(payload));
|
|
46
|
+
const key = await hmacKey(secret);
|
|
47
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
|
|
48
|
+
return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Verify a token's signature and expiry; returns the payload, or `null` for anything
|
|
52
|
+
* malformed, forged, or expired. The caller decides what the payload authorizes — this
|
|
53
|
+
* only attests that we minted it and that it is still in date. */
|
|
54
|
+
export async function verifyToken<T extends ExpiringToken>(raw: string, secret: string): Promise<T | null> {
|
|
55
|
+
const dot = raw.indexOf(".");
|
|
56
|
+
if (dot < 0) return null;
|
|
57
|
+
const data = raw.slice(0, dot);
|
|
58
|
+
const sig = raw.slice(dot + 1);
|
|
59
|
+
let ok: boolean;
|
|
60
|
+
try {
|
|
61
|
+
const key = await hmacKey(secret);
|
|
62
|
+
ok = await crypto.subtle.verify("HMAC", key, b64urlToBytes(sig), new TextEncoder().encode(data));
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
if (!ok) return null;
|
|
67
|
+
let payload: T;
|
|
68
|
+
try {
|
|
69
|
+
payload = JSON.parse(b64urlToStr(data)) as T;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (typeof payload.exp !== "number" || Math.floor(Date.now() / 1000) >= payload.exp) return null;
|
|
74
|
+
return payload;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A signing secret must be present and non-trivial, else tokens are forgeable (an HMAC
|
|
78
|
+
* over an empty or weak key). Below this length the feature is treated as UNCONFIGURED and
|
|
79
|
+
* fails closed, rather than minting urls that anyone can forge. */
|
|
80
|
+
export const MIN_TOKEN_SECRET_LEN = 16;
|
|
81
|
+
|
|
82
|
+
export function isUsableSecret(secret: unknown): secret is string {
|
|
83
|
+
return typeof secret === "string" && secret.length >= MIN_TOKEN_SECRET_LEN;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Resolve a signing secret from env by preference order, skipping any that is absent or
|
|
87
|
+
* too weak. Returns `undefined` when nothing usable is configured — callers fail closed. */
|
|
88
|
+
export function resolveSecret(env: Readonly<Record<string, unknown>>, names: readonly string[]): string | undefined {
|
|
89
|
+
for (const name of names) {
|
|
90
|
+
const v = env[name];
|
|
91
|
+
if (isUsableSecret(v)) return v;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
package/src/sdk/acl.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// and/or a set of permitted `fields`. Access is deny-by-default; policies only
|
|
7
7
|
// ever grant. Across the identity's roles, grants OR-merge (any role can allow).
|
|
8
8
|
|
|
9
|
+
import type { CellValue, JsonValue, Row } from "./infer";
|
|
10
|
+
|
|
9
11
|
export type Action = "read" | "create" | "update" | "delete";
|
|
10
12
|
|
|
11
13
|
/** Runtime identity. Augment with your own properties (userId, tier, …). */
|
|
@@ -17,7 +19,7 @@ export interface Identity {
|
|
|
17
19
|
* expiry per message (see durable-object.ts). Absent for non-expiring / synthetic
|
|
18
20
|
* (callPrivileged) identities, which are therefore never treated as expired. */
|
|
19
21
|
exp?: number;
|
|
20
|
-
[key: string]:
|
|
22
|
+
[key: string]: JsonValue | undefined;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
// --- $identity markers: reference an identity property inside a where rule ---
|
|
@@ -34,8 +36,8 @@ export function $identity(path: string): IdentityMarker {
|
|
|
34
36
|
return { [IDENTITY_MARKER]: true, path };
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
export function isIdentityMarker(v:
|
|
38
|
-
return typeof v === "object" && v !== null && (v as
|
|
39
|
+
export function isIdentityMarker(v: WhereValue): v is IdentityMarker {
|
|
40
|
+
return typeof v === "object" && v !== null && (v as Partial<IdentityMarker>)[IDENTITY_MARKER] === true;
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
// --- $input markers: reference a request-input field inside a where rule, for a
|
|
@@ -56,8 +58,8 @@ export function $input(path: string): InputMarker {
|
|
|
56
58
|
return { [INPUT_MARKER]: true, path };
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
export function isInputMarker(v:
|
|
60
|
-
return typeof v === "object" && v !== null && (v as
|
|
61
|
+
export function isInputMarker(v: WhereValue): v is InputMarker {
|
|
62
|
+
return typeof v === "object" && v !== null && (v as Partial<InputMarker>)[INPUT_MARKER] === true;
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
// --- $now markers: the request's evaluation instant inside a where rule, for a
|
|
@@ -88,8 +90,8 @@ export function $now(): NowMarker {
|
|
|
88
90
|
return { [NOW_MARKER]: true };
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
export function isNowMarker(v:
|
|
92
|
-
return typeof v === "object" && v !== null && (v as
|
|
93
|
+
export function isNowMarker(v: WhereValue): v is NowMarker {
|
|
94
|
+
return typeof v === "object" && v !== null && (v as Partial<NowMarker>)[NOW_MARKER] === true;
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
// --- allow / deny markers ---
|
|
@@ -110,8 +112,21 @@ export function deny(): DenyMarker {
|
|
|
110
112
|
|
|
111
113
|
// --- policy rules ---
|
|
112
114
|
|
|
113
|
-
/** A where rule:
|
|
114
|
-
|
|
115
|
+
/** A value inside a `where` rule: a literal cell value, a per-request marker, an
|
|
116
|
+
* operator object (`{ gte: 5 }`), a nested relation predicate, or an AND/OR group. */
|
|
117
|
+
export type WhereValue =
|
|
118
|
+
| CellValue
|
|
119
|
+
| IdentityMarker
|
|
120
|
+
| InputMarker
|
|
121
|
+
| NowMarker
|
|
122
|
+
| WhereRule
|
|
123
|
+
| WhereValue[];
|
|
124
|
+
|
|
125
|
+
/** A where rule: column (or `AND`/`OR`/`NOT`) -> value. An interface so it can recur
|
|
126
|
+
* through `WhereValue` for nested relation predicates and boolean groups. */
|
|
127
|
+
export interface WhereRule {
|
|
128
|
+
[key: string]: WhereValue;
|
|
129
|
+
}
|
|
115
130
|
|
|
116
131
|
/** A per-row (cell-level) field grant: `fields` are permitted only for rows that
|
|
117
132
|
* match `when`. Additive over the policy's flat `fields` — a conditional grant can
|
|
@@ -125,7 +140,7 @@ export interface ConditionalFields {
|
|
|
125
140
|
/** Escape hatch for cell-level ACL: a late per-row resolver. Given the identity and
|
|
126
141
|
* the fetched (or candidate, on write) row, returns the extra permitted fields —
|
|
127
142
|
* additive over `fields`; `null` means all fields for that row. */
|
|
128
|
-
export type FieldsFn = (identity: Identity | null, row:
|
|
143
|
+
export type FieldsFn = (identity: Identity | null, row: Row) => string[] | null;
|
|
129
144
|
|
|
130
145
|
/** Per-relation ACL inside a parent read policy. */
|
|
131
146
|
export interface RelationAclRule {
|
|
@@ -143,10 +158,10 @@ export interface RelationAclRule {
|
|
|
143
158
|
}
|
|
144
159
|
|
|
145
160
|
/** A forced column value on write: a literal, or computed from the identity. */
|
|
146
|
-
export type SetValue =
|
|
161
|
+
export type SetValue = CellValue | ((identity: Identity | null) => CellValue);
|
|
147
162
|
|
|
148
163
|
/** Server-side validation on write; throw to reject. Runs on the final values. */
|
|
149
|
-
export type Validator = (args: { identity: Identity | null; values:
|
|
164
|
+
export type Validator = (args: { identity: Identity | null; values: Row }) => void;
|
|
150
165
|
|
|
151
166
|
export interface PolicyRules {
|
|
152
167
|
/** Row-level predicate (AND of equalities). Omit/empty = all rows. */
|
|
@@ -175,10 +190,10 @@ export interface PolicyRules {
|
|
|
175
190
|
export interface ResolverDb {
|
|
176
191
|
find(spec: {
|
|
177
192
|
from: string;
|
|
178
|
-
where?:
|
|
193
|
+
where?: WhereRule;
|
|
179
194
|
orderBy?: { column: string; dir?: "asc" | "desc" };
|
|
180
195
|
limit?: number;
|
|
181
|
-
}): Promise<
|
|
196
|
+
}): Promise<Row[]>;
|
|
182
197
|
}
|
|
183
198
|
|
|
184
199
|
export interface ResolverContext {
|
package/src/sdk/handlers.ts
CHANGED
|
@@ -10,6 +10,13 @@ import type { Queue } from "../runtime/queue";
|
|
|
10
10
|
import type { Identity } from "./acl";
|
|
11
11
|
import type { Files } from "./files";
|
|
12
12
|
import type { SchemaDef } from "./schema";
|
|
13
|
+
import type { JsonValue } from "./infer";
|
|
14
|
+
|
|
15
|
+
/** The Worker/DO environment as an open, read-only bag: Cloudflare bindings (KV, R2,
|
|
16
|
+
* D1, Queues, …) alongside vars and secrets. Deliberately open and opaque — an app
|
|
17
|
+
* declares its own bindings, so the value type cannot be enumerated here; read a value
|
|
18
|
+
* and narrow it at the use site (`ctx.env.STRIPE_SECRET_KEY as string`). */
|
|
19
|
+
export type EnvBag = Readonly<Record<string, unknown>>;
|
|
13
20
|
|
|
14
21
|
export interface HandlerContext<S extends SchemaDef = SchemaDef> {
|
|
15
22
|
/** Schema-typed repository: find/insert/update/delete inferred from S. */
|
|
@@ -30,9 +37,16 @@ export interface HandlerContext<S extends SchemaDef = SchemaDef> {
|
|
|
30
37
|
* Use it to call external services from handlers — Cloudflare bindings (e.g. the
|
|
31
38
|
* `send_email` binding for Cloudflare Email Sending) or third-party APIs (Stripe, …). Loosely typed;
|
|
32
39
|
* cast a value at the use site, e.g. `ctx.env.STRIPE_SECRET_KEY as string`. */
|
|
33
|
-
readonly env:
|
|
40
|
+
readonly env: EnvBag;
|
|
34
41
|
/** Resolved identity for this request (null = anonymous). */
|
|
35
42
|
readonly identity: Identity | null;
|
|
43
|
+
/** The tenant this request is for (the `x-pramen-tenant` value; `"main"` by default).
|
|
44
|
+
* Server-resolved, never caller-supplied — safe to embed in a signed capability. */
|
|
45
|
+
readonly tenant: string;
|
|
46
|
+
/** Which substrate is serving this request: `"do"` (a Durable Object) or `"d1"`. Only
|
|
47
|
+
* the DO path has a stub the Worker can call back into, so a handler minting a
|
|
48
|
+
* capability redeemed through `callPrivileged` must refuse on `"d1"`. */
|
|
49
|
+
readonly store: "do" | "d1";
|
|
36
50
|
/** Deferred side-effects (a transactional outbox). `tasks.enqueue` persists a task
|
|
37
51
|
* row in the SAME transaction as a mutation (atomic with the data write); a drainer
|
|
38
52
|
* runs the matching `app.tasks` handler after commit, off the write path, with
|
|
@@ -119,7 +133,7 @@ export interface Handler<I = unknown, O = unknown> {
|
|
|
119
133
|
readonly run: (ctx: HandlerContext<any>, input: I) => O | Promise<O>;
|
|
120
134
|
/** Optional boundary validator: parse/validate the raw request input, throwing
|
|
121
135
|
* to reject (surfaced as a 400). Its return type fixes the handler's input. */
|
|
122
|
-
readonly input?: (raw:
|
|
136
|
+
readonly input?: (raw: JsonValue) => unknown;
|
|
123
137
|
/** Optional DO partition this handler runs in (static, server-side). The Worker
|
|
124
138
|
* routes the request to the matching partition-DO before dispatch. Absent ⇒ the
|
|
125
139
|
* default partition (routed to the bare tenant key). */
|
|
@@ -129,7 +143,7 @@ export interface Handler<I = unknown, O = unknown> {
|
|
|
129
143
|
}
|
|
130
144
|
|
|
131
145
|
export interface HandlerOpts<I> {
|
|
132
|
-
input?: (raw:
|
|
146
|
+
input?: (raw: JsonValue) => I;
|
|
133
147
|
/** DO partition this handler runs in. Absent ⇒ the default partition. */
|
|
134
148
|
partition?: string;
|
|
135
149
|
/** Authorization to CALL this handler (see HandlerAuth) — gate non-`ctx.db` handlers. */
|
package/src/sdk/infer.ts
CHANGED
|
@@ -11,6 +11,27 @@ export type { FileRef } from "./files";
|
|
|
11
11
|
/** Any JSON-serializable value — the type of a `t.json()` column. */
|
|
12
12
|
export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
13
13
|
|
|
14
|
+
/** A JSON object — the object arm of `JsonValue`, named so it can be referenced
|
|
15
|
+
* directly (e.g. an identity's claims, a `t.json()` column's object form). */
|
|
16
|
+
export interface JsonObject {
|
|
17
|
+
[key: string]: JsonValue;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A raw value as the substrate stores/returns it, before pramen's object↔JSON codec.
|
|
21
|
+
* DO SQLite and D1 hand back exactly these; BLOB columns arrive as an ArrayBuffer. */
|
|
22
|
+
export type SqlValue = string | number | bigint | boolean | null | ArrayBuffer;
|
|
23
|
+
|
|
24
|
+
/** A decoded column value as handlers see it at the `Db` chokepoint: any JSON value,
|
|
25
|
+
* a `fileRef` column's metadata, or — for an eager-loaded relation — the related
|
|
26
|
+
* row(s) grafted onto the parent under the relation name. */
|
|
27
|
+
export type CellValue = SqlValue | JsonValue | FileRef | Row | Row[];
|
|
28
|
+
|
|
29
|
+
/** A decoded database row — column name -> decoded value. An interface (not a
|
|
30
|
+
* `Record` alias) so it can recur through `CellValue` for eager-loaded relations. */
|
|
31
|
+
export interface Row {
|
|
32
|
+
[column: string]: CellValue;
|
|
33
|
+
}
|
|
34
|
+
|
|
14
35
|
/** SQL field type -> TypeScript value type. */
|
|
15
36
|
export type FieldTsType<D extends FieldDef> = D["type"] extends "text"
|
|
16
37
|
? string
|