@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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { WhereRule } from "../sdk/acl";
|
|
2
|
+
import type { CellValue, Row } from "../sdk/infer";
|
|
1
3
|
import type { Dialect } from "./driver";
|
|
2
4
|
export type CmpOp = "=" | "!=" | ">" | ">=" | "<" | "<=" | "LIKE";
|
|
3
5
|
/** Substring match mode for the structured string operators (auto-escaping, so the
|
|
@@ -11,11 +13,11 @@ export type SqlExpr = {
|
|
|
11
13
|
t: "cmp";
|
|
12
14
|
op: CmpOp;
|
|
13
15
|
col: string;
|
|
14
|
-
value:
|
|
16
|
+
value: CellValue;
|
|
15
17
|
} | {
|
|
16
18
|
t: "in";
|
|
17
19
|
col: string;
|
|
18
|
-
values:
|
|
20
|
+
values: CellValue[];
|
|
19
21
|
negate: boolean;
|
|
20
22
|
} | {
|
|
21
23
|
t: "null";
|
|
@@ -45,28 +47,28 @@ export type SqlExpr = {
|
|
|
45
47
|
};
|
|
46
48
|
export declare const TRUE: SqlExpr;
|
|
47
49
|
export declare const FALSE: SqlExpr;
|
|
48
|
-
export declare const cmp: (op: CmpOp, col: string, value:
|
|
50
|
+
export declare const cmp: (op: CmpOp, col: string, value: CellValue) => SqlExpr;
|
|
49
51
|
export declare const isNull: (col: string, negate?: boolean) => SqlExpr;
|
|
50
|
-
export declare const inList: (col: string, values:
|
|
52
|
+
export declare const inList: (col: string, values: CellValue[], negate?: boolean) => SqlExpr;
|
|
51
53
|
export declare const strMatch: (col: string, needle: string, mode: StrMode) => SqlExpr;
|
|
52
54
|
export declare const and: (...parts: SqlExpr[]) => SqlExpr;
|
|
53
55
|
export declare const or: (...parts: SqlExpr[]) => SqlExpr;
|
|
54
56
|
export declare const not: (expr: SqlExpr) => SqlExpr;
|
|
55
57
|
/** Equality (null -> IS NULL). Used by ACL scope building and relation loads. */
|
|
56
|
-
export declare const eq: (col: string, value:
|
|
58
|
+
export declare const eq: (col: string, value: CellValue) => SqlExpr;
|
|
57
59
|
/** Compile a structured user predicate into a SqlExpr.
|
|
58
60
|
* Shapes: { col: value } (eq) | { col: { gt, lt, in, like, isNull, … } } | { AND: [...] } | { OR: [...] } */
|
|
59
|
-
export declare function compileWhere(input:
|
|
61
|
+
export declare function compileWhere(input: WhereRule): SqlExpr;
|
|
60
62
|
export interface CompiledSql {
|
|
61
63
|
readonly sql: string;
|
|
62
|
-
readonly params:
|
|
64
|
+
readonly params: CellValue[];
|
|
63
65
|
}
|
|
64
|
-
export declare function compileExpr(expr: SqlExpr, dialect: Dialect, params?:
|
|
66
|
+
export declare function compileExpr(expr: SqlExpr, dialect: Dialect, params?: CellValue[]): CompiledSql;
|
|
65
67
|
/** Evaluate a compiled predicate against an in-memory row. Mirrors compileExpr's
|
|
66
68
|
* semantics (bound-boolean coercion, NULL compares false, empty-IN, LIKE) so the
|
|
67
69
|
* declarative cell-ACL `when` path can decide per-row field visibility without a
|
|
68
70
|
* round-trip to SQLite. */
|
|
69
|
-
export declare function evalExpr(expr: SqlExpr, row:
|
|
71
|
+
export declare function evalExpr(expr: SqlExpr, row: Row): boolean;
|
|
70
72
|
export interface OrderBy {
|
|
71
73
|
column: string;
|
|
72
74
|
dir?: "asc" | "desc";
|
|
@@ -44,7 +44,10 @@ export function compileWhere(input) {
|
|
|
44
44
|
function columnPredicate(col, v) {
|
|
45
45
|
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
|
46
46
|
const ops = [];
|
|
47
|
-
for (const [op,
|
|
47
|
+
for (const [op, operand] of Object.entries(v)) {
|
|
48
|
+
// Markers are resolved upstream (runtime/acl.ts), so an operator's operand is a
|
|
49
|
+
// literal cell value by this point.
|
|
50
|
+
const val = operand;
|
|
48
51
|
switch (op) {
|
|
49
52
|
case "eq":
|
|
50
53
|
ops.push(eq(col, val));
|
|
@@ -25,4 +25,7 @@ export declare function parseRegistryKey(key: string): DoRef | null;
|
|
|
25
25
|
/** Enumerate every registered `(tenant, partition)` pair from the registry KV.
|
|
26
26
|
* Paginates over the full listing (cursor / list_complete) — never truncates at the
|
|
27
27
|
* 1000-key page limit. */
|
|
28
|
-
|
|
28
|
+
/** The slice of KV that DO enumeration needs — narrower than the whole namespace, so
|
|
29
|
+
* callers (and test doubles) only have to provide `list`. */
|
|
30
|
+
export type KvLister = Pick<KVNamespace, "list">;
|
|
31
|
+
export declare function listDOs(kv: KvLister): Promise<DoRef[]>;
|
package/dist/runtime/registry.js
CHANGED
|
@@ -65,9 +65,6 @@ export function parseRegistryKey(key) {
|
|
|
65
65
|
return { tenant: rest, partition: DEFAULT_PARTITION };
|
|
66
66
|
return { tenant: rest.slice(0, sep), partition: rest.slice(sep + 1) };
|
|
67
67
|
}
|
|
68
|
-
/** Enumerate every registered `(tenant, partition)` pair from the registry KV.
|
|
69
|
-
* Paginates over the full listing (cursor / list_complete) — never truncates at the
|
|
70
|
-
* 1000-key page limit. */
|
|
71
68
|
export async function listDOs(kv) {
|
|
72
69
|
const out = [];
|
|
73
70
|
let cursor;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SchemaDef } from "../sdk/schema";
|
|
2
2
|
/** The comparable fingerprint of a single column: type + migration-relevant modifiers. */
|
|
3
|
-
export interface
|
|
3
|
+
export interface ColumnFingerprint {
|
|
4
4
|
type: string;
|
|
5
5
|
notNull?: boolean;
|
|
6
6
|
unique?: boolean;
|
|
@@ -11,13 +11,13 @@ export interface ColumnShape {
|
|
|
11
11
|
default?: string;
|
|
12
12
|
}
|
|
13
13
|
/** The comparable fingerprint of a table: its partition + each column's shape. */
|
|
14
|
-
export interface
|
|
14
|
+
export interface TableFingerprint {
|
|
15
15
|
partition: string;
|
|
16
|
-
columns: Record<string,
|
|
16
|
+
columns: Record<string, ColumnFingerprint>;
|
|
17
17
|
}
|
|
18
18
|
/** table -> table shape. The comparable surface of a schema. */
|
|
19
|
-
export type
|
|
20
|
-
export declare function
|
|
19
|
+
export type SchemaFingerprint = Record<string, TableFingerprint>;
|
|
20
|
+
export declare function schemaFingerprint(schema: SchemaDef): SchemaFingerprint;
|
|
21
21
|
export interface SchemaChange {
|
|
22
22
|
kind: "add-table" | "drop-table" | "add-column" | "drop-column" | "change-type" | "change-column" | "move-partition";
|
|
23
23
|
table: string;
|
|
@@ -34,4 +34,4 @@ export interface SchemaChange {
|
|
|
34
34
|
* cross-DO data migration). Reported for honesty. */
|
|
35
35
|
appliesOnBoot: boolean;
|
|
36
36
|
}
|
|
37
|
-
export declare function
|
|
37
|
+
export declare function diffSchemaFingerprint(prev: SchemaFingerprint, next: SchemaFingerprint): SchemaChange[];
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// still CANNOT be enacted on boot (a partition is a separate Durable Object — it needs a
|
|
18
18
|
// manual cross-DO data migration), so it stays `appliesOnBoot: false`.
|
|
19
19
|
import { partitionOf } from "../sdk/schema";
|
|
20
|
-
function
|
|
20
|
+
function columnFingerprint(f) {
|
|
21
21
|
const c = { type: f.type };
|
|
22
22
|
if (f.notNull)
|
|
23
23
|
c.notNull = true;
|
|
@@ -35,12 +35,12 @@ function columnShape(f) {
|
|
|
35
35
|
c.default = JSON.stringify(f.default);
|
|
36
36
|
return c;
|
|
37
37
|
}
|
|
38
|
-
export function
|
|
38
|
+
export function schemaFingerprint(schema) {
|
|
39
39
|
const out = {};
|
|
40
40
|
for (const [table, def] of Object.entries(schema)) {
|
|
41
41
|
const columns = {};
|
|
42
42
|
for (const [col, f] of Object.entries(def.fields))
|
|
43
|
-
columns[col] =
|
|
43
|
+
columns[col] = columnFingerprint(f);
|
|
44
44
|
out[table] = { partition: partitionOf(schema, table), columns };
|
|
45
45
|
}
|
|
46
46
|
return out;
|
|
@@ -64,7 +64,7 @@ function modifierDiff(prev, next) {
|
|
|
64
64
|
function fmt(v) {
|
|
65
65
|
return v === undefined ? "—" : String(v);
|
|
66
66
|
}
|
|
67
|
-
export function
|
|
67
|
+
export function diffSchemaFingerprint(prev, next) {
|
|
68
68
|
const changes = [];
|
|
69
69
|
for (const table of Object.keys(next)) {
|
|
70
70
|
const pt = prev[table];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Files, HeadResult } from "../sdk/files";
|
|
2
|
+
import { isUsableSecret, type ExpiringToken } from "./token";
|
|
2
3
|
export type { Files, FileRef, HeadResult, SignDownloadOpts, SignUploadOpts } from "../sdk/files";
|
|
3
4
|
export interface PutResult {
|
|
4
5
|
key: string;
|
|
@@ -40,17 +41,16 @@ export declare class MemoryAdapter implements StorageAdapter {
|
|
|
40
41
|
head(key: string): Promise<HeadResult | null>;
|
|
41
42
|
delete(key: string): Promise<void>;
|
|
42
43
|
}
|
|
43
|
-
interface FileToken {
|
|
44
|
+
interface FileToken extends ExpiringToken {
|
|
44
45
|
/** tenant */ t: string;
|
|
45
46
|
/** key */ k: string;
|
|
46
47
|
/** op */ op: "get" | "put";
|
|
47
|
-
/** expiry (epoch seconds) */ exp: number;
|
|
48
48
|
/** content-type (put: enforced; get: disposition hint) */ ct?: string;
|
|
49
49
|
/** max size in bytes (put only) */ max?: number;
|
|
50
50
|
/** filename (download disposition) */ fn?: string;
|
|
51
51
|
}
|
|
52
52
|
/** Verify a file token's signature + expiry; returns the payload or null. */
|
|
53
|
-
export declare
|
|
53
|
+
export declare const verifyFileToken: (raw: string, secret: string) => Promise<FileToken | null>;
|
|
54
54
|
export interface FilesConfig {
|
|
55
55
|
tenant: string;
|
|
56
56
|
secret: string;
|
|
@@ -63,7 +63,7 @@ export interface FilesConfig {
|
|
|
63
63
|
* treated as unconfigured — fail closed rather than mint forgeable urls. The dev
|
|
64
64
|
* defaults satisfy it; production should set a strong, random FILES_SECRET. */
|
|
65
65
|
export declare const MIN_FILES_SECRET_LEN = 16;
|
|
66
|
-
export declare
|
|
66
|
+
export declare const isUsableFilesSecret: typeof isUsableSecret;
|
|
67
67
|
/** Construct the per-tenant `ctx.files` facade. */
|
|
68
68
|
export declare function createFiles(cfg: FilesConfig): Files;
|
|
69
69
|
/** Serve the file endpoints. Returns a Response for any `/files/*` path, or null
|
package/dist/runtime/storage.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// R2 binding and stays backend-agnostic. URLs are RELATIVE so the server never
|
|
16
16
|
// needs to know its own public origin — the client resolves them against its base.
|
|
17
17
|
import { BadRequest, PramenError } from "./errors";
|
|
18
|
+
import { signToken, verifyToken, isUsableSecret, MIN_TOKEN_SECRET_LEN } from "./token";
|
|
18
19
|
/** R2 — the Cloudflare default. Wraps an R2 bucket binding. Streaming: bytes flow
|
|
19
20
|
* directly between the client and R2 in the Worker, never through the DO. */
|
|
20
21
|
export class R2Adapter {
|
|
@@ -84,63 +85,9 @@ function bytesToStream(bytes) {
|
|
|
84
85
|
},
|
|
85
86
|
});
|
|
86
87
|
}
|
|
87
|
-
|
|
88
|
-
function bytesToB64url(bytes) {
|
|
89
|
-
let bin = "";
|
|
90
|
-
for (const b of bytes)
|
|
91
|
-
bin += String.fromCharCode(b);
|
|
92
|
-
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
93
|
-
}
|
|
94
|
-
function b64urlToBytes(s) {
|
|
95
|
-
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
|
|
96
|
-
const bin = atob(b64);
|
|
97
|
-
const out = new Uint8Array(bin.length);
|
|
98
|
-
for (let i = 0; i < bin.length; i++)
|
|
99
|
-
out[i] = bin.charCodeAt(i);
|
|
100
|
-
return out;
|
|
101
|
-
}
|
|
102
|
-
const strToB64url = (s) => bytesToB64url(new TextEncoder().encode(s));
|
|
103
|
-
const b64urlToStr = (s) => new TextDecoder().decode(b64urlToBytes(s));
|
|
104
|
-
async function hmacKey(secret) {
|
|
105
|
-
return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
106
|
-
"sign",
|
|
107
|
-
"verify",
|
|
108
|
-
]);
|
|
109
|
-
}
|
|
110
|
-
async function signToken(token, secret) {
|
|
111
|
-
const data = strToB64url(JSON.stringify(token));
|
|
112
|
-
const key = await hmacKey(secret);
|
|
113
|
-
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
|
|
114
|
-
return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
|
|
115
|
-
}
|
|
88
|
+
const signFileToken = (token, secret) => signToken(token, secret);
|
|
116
89
|
/** Verify a file token's signature + expiry; returns the payload or null. */
|
|
117
|
-
export
|
|
118
|
-
const dot = raw.indexOf(".");
|
|
119
|
-
if (dot < 0)
|
|
120
|
-
return null;
|
|
121
|
-
const data = raw.slice(0, dot);
|
|
122
|
-
const sig = raw.slice(dot + 1);
|
|
123
|
-
let ok;
|
|
124
|
-
try {
|
|
125
|
-
const key = await hmacKey(secret);
|
|
126
|
-
ok = await crypto.subtle.verify("HMAC", key, b64urlToBytes(sig), new TextEncoder().encode(data));
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
if (!ok)
|
|
132
|
-
return null;
|
|
133
|
-
let payload;
|
|
134
|
-
try {
|
|
135
|
-
payload = JSON.parse(b64urlToStr(data));
|
|
136
|
-
}
|
|
137
|
-
catch {
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
if (typeof payload.exp !== "number" || Math.floor(Date.now() / 1000) >= payload.exp)
|
|
141
|
-
return null;
|
|
142
|
-
return payload;
|
|
143
|
-
}
|
|
90
|
+
export const verifyFileToken = (raw, secret) => verifyToken(raw, secret);
|
|
144
91
|
// --- key generation ---
|
|
145
92
|
function randomKeySuffix() {
|
|
146
93
|
const bytes = new Uint8Array(16);
|
|
@@ -188,10 +135,8 @@ function contentDisposition(name) {
|
|
|
188
135
|
* would be forgeable (HMAC over an empty/weak key). Below this, file storage is
|
|
189
136
|
* treated as unconfigured — fail closed rather than mint forgeable urls. The dev
|
|
190
137
|
* defaults satisfy it; production should set a strong, random FILES_SECRET. */
|
|
191
|
-
export const MIN_FILES_SECRET_LEN =
|
|
192
|
-
export
|
|
193
|
-
return typeof secret === "string" && secret.length >= MIN_FILES_SECRET_LEN;
|
|
194
|
-
}
|
|
138
|
+
export const MIN_FILES_SECRET_LEN = MIN_TOKEN_SECRET_LEN;
|
|
139
|
+
export const isUsableFilesSecret = isUsableSecret;
|
|
195
140
|
const filesUnconfigured = () => new PramenError("file storage is not configured (set a strong FILES_SECRET)", 503, "unavailable");
|
|
196
141
|
/** Construct the per-tenant `ctx.files` facade. */
|
|
197
142
|
export function createFiles(cfg) {
|
|
@@ -210,7 +155,7 @@ export function createFiles(cfg) {
|
|
|
210
155
|
const seg = opts.prefix ? `${safePrefix(opts.prefix)}/` : "";
|
|
211
156
|
const key = `${prefix}${seg}${randomKeySuffix()}`;
|
|
212
157
|
const exp = Math.floor(Date.now() / 1000) + (opts.expiresIn ?? 900);
|
|
213
|
-
const token = await
|
|
158
|
+
const token = await signFileToken({ t: cfg.tenant, k: key, op: "put", exp, ct, max: opts.maxSize, fn: opts.filename }, cfg.secret);
|
|
214
159
|
const ref = { key, size: 0, contentType: ct, filename: opts.filename, uploadedAt: Date.now() };
|
|
215
160
|
return { url: `${base}/upload?token=${encodeURIComponent(token)}`, ref };
|
|
216
161
|
},
|
|
@@ -221,7 +166,7 @@ export function createFiles(cfg) {
|
|
|
221
166
|
const key = ensureOwnKey(r.key);
|
|
222
167
|
const exp = Math.floor(Date.now() / 1000) + (opts?.expiresIn ?? 3600);
|
|
223
168
|
const fn = opts?.download ? (typeof ref === "string" ? undefined : ref.filename) : undefined;
|
|
224
|
-
const token = await
|
|
169
|
+
const token = await signFileToken({ t: cfg.tenant, k: key, op: "get", exp, fn }, cfg.secret);
|
|
225
170
|
return { url: `${base}/download?token=${encodeURIComponent(token)}`, expiresAt: exp * 1000 };
|
|
226
171
|
},
|
|
227
172
|
head: (key) => cfg.adapter.head(ensureOwnKey(key)),
|
|
@@ -258,7 +203,7 @@ export async function handleFileRequest(request, opts) {
|
|
|
258
203
|
const raw = url.searchParams.get("token");
|
|
259
204
|
if (!raw)
|
|
260
205
|
return fileError(401, "unauthorized", "missing token");
|
|
261
|
-
const token = await
|
|
206
|
+
const token = await verifyFileToken(raw, opts.secret);
|
|
262
207
|
if (!token)
|
|
263
208
|
return fileError(403, "forbidden", "invalid or expired token");
|
|
264
209
|
try {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Every signed token carries an expiry (epoch seconds); `verifyToken` enforces it. */
|
|
2
|
+
export interface ExpiringToken {
|
|
3
|
+
exp: number;
|
|
4
|
+
}
|
|
5
|
+
export declare function bytesToB64url(bytes: Uint8Array): string;
|
|
6
|
+
export declare function b64urlToBytes(s: string): Uint8Array;
|
|
7
|
+
export declare const strToB64url: (s: string) => string;
|
|
8
|
+
export declare const b64urlToStr: (s: string) => string;
|
|
9
|
+
/** Sign a payload into a capability token. */
|
|
10
|
+
export declare function signToken<T extends ExpiringToken>(payload: T, secret: string): Promise<string>;
|
|
11
|
+
/** Verify a token's signature and expiry; returns the payload, or `null` for anything
|
|
12
|
+
* malformed, forged, or expired. The caller decides what the payload authorizes — this
|
|
13
|
+
* only attests that we minted it and that it is still in date. */
|
|
14
|
+
export declare function verifyToken<T extends ExpiringToken>(raw: string, secret: string): Promise<T | null>;
|
|
15
|
+
/** A signing secret must be present and non-trivial, else tokens are forgeable (an HMAC
|
|
16
|
+
* over an empty or weak key). Below this length the feature is treated as UNCONFIGURED and
|
|
17
|
+
* fails closed, rather than minting urls that anyone can forge. */
|
|
18
|
+
export declare const MIN_TOKEN_SECRET_LEN = 16;
|
|
19
|
+
export declare function isUsableSecret(secret: unknown): secret is string;
|
|
20
|
+
/** Resolve a signing secret from env by preference order, skipping any that is absent or
|
|
21
|
+
* too weak. Returns `undefined` when nothing usable is configured — callers fail closed. */
|
|
22
|
+
export declare function resolveSecret(env: Readonly<Record<string, unknown>>, names: readonly string[]): string | undefined;
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
export function bytesToB64url(bytes) {
|
|
14
|
+
let bin = "";
|
|
15
|
+
for (const b of bytes)
|
|
16
|
+
bin += String.fromCharCode(b);
|
|
17
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
18
|
+
}
|
|
19
|
+
export function b64urlToBytes(s) {
|
|
20
|
+
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
|
|
21
|
+
const bin = atob(b64);
|
|
22
|
+
const out = new Uint8Array(bin.length);
|
|
23
|
+
for (let i = 0; i < bin.length; i++)
|
|
24
|
+
out[i] = bin.charCodeAt(i);
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
export const strToB64url = (s) => bytesToB64url(new TextEncoder().encode(s));
|
|
28
|
+
export const b64urlToStr = (s) => new TextDecoder().decode(b64urlToBytes(s));
|
|
29
|
+
async function hmacKey(secret) {
|
|
30
|
+
return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
|
|
31
|
+
"sign",
|
|
32
|
+
"verify",
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
35
|
+
/** Sign a payload into a capability token. */
|
|
36
|
+
export async function signToken(payload, secret) {
|
|
37
|
+
const data = strToB64url(JSON.stringify(payload));
|
|
38
|
+
const key = await hmacKey(secret);
|
|
39
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
|
|
40
|
+
return `${data}.${bytesToB64url(new Uint8Array(sig))}`;
|
|
41
|
+
}
|
|
42
|
+
/** Verify a token's signature and expiry; returns the payload, or `null` for anything
|
|
43
|
+
* malformed, forged, or expired. The caller decides what the payload authorizes — this
|
|
44
|
+
* only attests that we minted it and that it is still in date. */
|
|
45
|
+
export async function verifyToken(raw, secret) {
|
|
46
|
+
const dot = raw.indexOf(".");
|
|
47
|
+
if (dot < 0)
|
|
48
|
+
return null;
|
|
49
|
+
const data = raw.slice(0, dot);
|
|
50
|
+
const sig = raw.slice(dot + 1);
|
|
51
|
+
let ok;
|
|
52
|
+
try {
|
|
53
|
+
const key = await hmacKey(secret);
|
|
54
|
+
ok = await crypto.subtle.verify("HMAC", key, b64urlToBytes(sig), new TextEncoder().encode(data));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (!ok)
|
|
60
|
+
return null;
|
|
61
|
+
let payload;
|
|
62
|
+
try {
|
|
63
|
+
payload = JSON.parse(b64urlToStr(data));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (typeof payload.exp !== "number" || Math.floor(Date.now() / 1000) >= payload.exp)
|
|
69
|
+
return null;
|
|
70
|
+
return payload;
|
|
71
|
+
}
|
|
72
|
+
/** A signing secret must be present and non-trivial, else tokens are forgeable (an HMAC
|
|
73
|
+
* over an empty or weak key). Below this length the feature is treated as UNCONFIGURED and
|
|
74
|
+
* fails closed, rather than minting urls that anyone can forge. */
|
|
75
|
+
export const MIN_TOKEN_SECRET_LEN = 16;
|
|
76
|
+
export function isUsableSecret(secret) {
|
|
77
|
+
return typeof secret === "string" && secret.length >= MIN_TOKEN_SECRET_LEN;
|
|
78
|
+
}
|
|
79
|
+
/** Resolve a signing secret from env by preference order, skipping any that is absent or
|
|
80
|
+
* too weak. Returns `undefined` when nothing usable is configured — callers fail closed. */
|
|
81
|
+
export function resolveSecret(env, names) {
|
|
82
|
+
for (const name of names) {
|
|
83
|
+
const v = env[name];
|
|
84
|
+
if (isUsableSecret(v))
|
|
85
|
+
return v;
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
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,9 +31,16 @@ 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;
|
|
37
|
+
/** The tenant this request is for (the `x-pramen-tenant` value; `"main"` by default).
|
|
38
|
+
* Server-resolved, never caller-supplied — safe to embed in a signed capability. */
|
|
39
|
+
readonly tenant: string;
|
|
40
|
+
/** Which substrate is serving this request: `"do"` (a Durable Object) or `"d1"`. Only
|
|
41
|
+
* the DO path has a stub the Worker can call back into, so a handler minting a
|
|
42
|
+
* capability redeemed through `callPrivileged` must refuse on `"d1"`. */
|
|
43
|
+
readonly store: "do" | "d1";
|
|
31
44
|
/** Deferred side-effects (a transactional outbox). `tasks.enqueue` persists a task
|
|
32
45
|
* row in the SAME transaction as a mutation (atomic with the data write); a drainer
|
|
33
46
|
* runs the matching `app.tasks` handler after commit, off the write path, with
|
|
@@ -100,7 +113,7 @@ export interface Handler<I = unknown, O = unknown> {
|
|
|
100
113
|
readonly run: (ctx: HandlerContext<any>, input: I) => O | Promise<O>;
|
|
101
114
|
/** Optional boundary validator: parse/validate the raw request input, throwing
|
|
102
115
|
* to reject (surfaced as a 400). Its return type fixes the handler's input. */
|
|
103
|
-
readonly input?: (raw:
|
|
116
|
+
readonly input?: (raw: JsonValue) => unknown;
|
|
104
117
|
/** Optional DO partition this handler runs in (static, server-side). The Worker
|
|
105
118
|
* routes the request to the matching partition-DO before dispatch. Absent ⇒ the
|
|
106
119
|
* default partition (routed to the bare tenant key). */
|
|
@@ -109,7 +122,7 @@ export interface Handler<I = unknown, O = unknown> {
|
|
|
109
122
|
readonly auth?: HandlerAuth;
|
|
110
123
|
}
|
|
111
124
|
export interface HandlerOpts<I> {
|
|
112
|
-
input?: (raw:
|
|
125
|
+
input?: (raw: JsonValue) => I;
|
|
113
126
|
/** DO partition this handler runs in. Absent ⇒ the default partition. */
|
|
114
127
|
partition?: string;
|
|
115
128
|
/** 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;
|