@palbase/backend 17.3.0 → 18.0.0
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/bin/palbase-backend.cjs +1848 -0
- package/dist/bin/palbase-backend.cjs.map +1 -0
- package/dist/bin/palbase-backend.d.cts +1 -0
- package/dist/bin/palbase-backend.d.ts +1 -0
- package/dist/bin/palbase-backend.js +168 -0
- package/dist/bin/palbase-backend.js.map +1 -0
- package/dist/chunk-7D4SUZUM.js +38 -0
- package/dist/chunk-7D4SUZUM.js.map +1 -0
- package/dist/chunk-N32VDWKH.js +172 -0
- package/dist/chunk-N32VDWKH.js.map +1 -0
- package/dist/chunk-POYAFBLF.js +189 -0
- package/dist/chunk-POYAFBLF.js.map +1 -0
- package/dist/chunk-QMVK4X3V.js +200 -0
- package/dist/chunk-QMVK4X3V.js.map +1 -0
- package/dist/chunk-SSGAMC26.js +342 -0
- package/dist/chunk-SSGAMC26.js.map +1 -0
- package/dist/chunk-VYH4U7ZQ.js +1138 -0
- package/dist/chunk-VYH4U7ZQ.js.map +1 -0
- package/dist/{chunk-IBIME7N2.js → chunk-W5ODXPY3.js} +2 -321
- package/dist/chunk-W5ODXPY3.js.map +1 -0
- package/dist/chunk-YL4C5NRY.js +90 -0
- package/dist/chunk-YL4C5NRY.js.map +1 -0
- package/dist/db/env.cjs.map +1 -1
- package/dist/db/env.d.cts +21 -1
- package/dist/db/env.d.ts +21 -1
- package/dist/db/index.cjs +15 -0
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +2 -1
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +9 -6
- package/dist/{index-J1tnAnFj.d.ts → endpoint-B0LpZixz.d.cts} +124 -667
- package/dist/{index-D6mRh4yq.d.cts → endpoint-B0LpZixz.d.ts} +124 -667
- package/dist/engine/index.cjs +1797 -0
- package/dist/engine/index.cjs.map +1 -0
- package/dist/engine/index.d.cts +7 -0
- package/dist/engine/index.d.ts +7 -0
- package/dist/engine/index.js +43 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/index-B46CGNvx.d.cts +839 -0
- package/dist/index-BGSCWlUa.d.cts +674 -0
- package/dist/index-DZDUMth5.d.ts +839 -0
- package/dist/index-g-EzitI-.d.ts +674 -0
- package/dist/index.cjs +1046 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +290 -532
- package/dist/index.d.ts +290 -532
- package/dist/index.js +999 -509
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs +6464 -0
- package/dist/openapi/index.cjs.map +1 -0
- package/dist/openapi/index.d.cts +170 -0
- package/dist/openapi/index.d.ts +170 -0
- package/dist/openapi/index.js +6248 -0
- package/dist/openapi/index.js.map +1 -0
- package/dist/registry-3BLYv4si.d.ts +338 -0
- package/dist/registry-Cw0YEYCg.d.cts +338 -0
- package/dist/test/index.js +2 -0
- package/dist/test/index.js.map +1 -1
- package/docs/database.md +16 -3
- package/docs/llms-full.txt +16 -3
- package/package.json +43 -13
- package/stager/package.json +4 -0
- package/stager/return_types.js +338 -0
- package/stager/stage.js +78 -0
- package/stager/throw_analysis.js +726 -0
- package/template/AGENTS.md +261 -0
- package/template/config/secrets.ts +24 -0
- package/template/controllers/health.controller.ts +30 -0
- package/template/db/schema.ts +35 -0
- package/template/package.json +18 -0
- package/template/tsconfig.json +30 -0
- package/LICENSE +0 -21
- package/dist/chunk-IBIME7N2.js.map +0 -1
|
@@ -0,0 +1,1797 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/engine/index.ts
|
|
21
|
+
var engine_exports = {};
|
|
22
|
+
__export(engine_exports, {
|
|
23
|
+
AuthVerifier: () => AuthVerifier,
|
|
24
|
+
BootRefused: () => BootRefused,
|
|
25
|
+
RateLimiter: () => RateLimiter,
|
|
26
|
+
buildRouteTable: () => buildRouteTable,
|
|
27
|
+
createApp: () => createApp,
|
|
28
|
+
createLazyTransaction: () => createLazyTransaction,
|
|
29
|
+
createOps: () => createOps,
|
|
30
|
+
createRequestDatabase: () => createRequestDatabase,
|
|
31
|
+
effectiveAuth: () => effectiveAuth,
|
|
32
|
+
hostAllowed: () => hostAllowed,
|
|
33
|
+
installEgressFence: () => installEgressFence,
|
|
34
|
+
loadConfig: () => loadConfig,
|
|
35
|
+
makeMemoryCache: () => makeMemoryCache,
|
|
36
|
+
matchRoute: () => matchRoute,
|
|
37
|
+
quoteIdent: () => quoteIdent,
|
|
38
|
+
scrubSecrets: () => scrubSecrets,
|
|
39
|
+
withTables: () => withTables
|
|
40
|
+
});
|
|
41
|
+
module.exports = __toCommonJS(engine_exports);
|
|
42
|
+
|
|
43
|
+
// src/runtime.ts
|
|
44
|
+
var import_node_async_hooks = require("async_hooks");
|
|
45
|
+
|
|
46
|
+
// src/db/tx-plan.ts
|
|
47
|
+
var TxRefError = class extends Error {
|
|
48
|
+
constructor(message) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = "TxRefError";
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
var TxPlanError = class extends Error {
|
|
54
|
+
constructor(message) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "TxPlanError";
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
var EXPR = /* @__PURE__ */ Symbol.for("palbase.tx.expr");
|
|
60
|
+
var REF = /* @__PURE__ */ Symbol.for("palbase.tx.ref");
|
|
61
|
+
var ROW = /* @__PURE__ */ Symbol.for("palbase.tx.row");
|
|
62
|
+
var ROWS = /* @__PURE__ */ Symbol.for("palbase.tx.rows");
|
|
63
|
+
var TRAPPED_PROPS = [
|
|
64
|
+
"then",
|
|
65
|
+
"valueOf",
|
|
66
|
+
"toString",
|
|
67
|
+
"toJSON",
|
|
68
|
+
Symbol.toPrimitive
|
|
69
|
+
];
|
|
70
|
+
function trap(prop, what, hint) {
|
|
71
|
+
const name = typeof prop === "symbol" ? prop.description ?? String(prop) : prop;
|
|
72
|
+
throw new TxRefError(
|
|
73
|
+
`${what} was used as a value (via \`${name}\`). Nothing in a transaction callback has run yet, so there is no value to read. ${hint}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
function makeRef(op, field) {
|
|
77
|
+
const target = { [REF]: { op, field } };
|
|
78
|
+
return new Proxy(target, {
|
|
79
|
+
get(t, prop) {
|
|
80
|
+
if (prop === REF) return t[REF];
|
|
81
|
+
if (TRAPPED_PROPS.includes(prop)) {
|
|
82
|
+
trap(
|
|
83
|
+
prop,
|
|
84
|
+
`\`${field}\` of a row this transaction has not written yet`,
|
|
85
|
+
"Pass it to another operation in the same plan, or return it from the callback and read it after `transaction()` resolves."
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return void 0;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function makeRowHandle(op) {
|
|
93
|
+
const target = { [ROW]: op };
|
|
94
|
+
return new Proxy(target, {
|
|
95
|
+
get(t, prop) {
|
|
96
|
+
if (prop === ROW) return t[ROW];
|
|
97
|
+
if (TRAPPED_PROPS.includes(prop)) {
|
|
98
|
+
trap(
|
|
99
|
+
prop,
|
|
100
|
+
"A row this transaction has not written yet",
|
|
101
|
+
"Read one of its columns to reference it, or return the row from the callback and read it after `transaction()` resolves."
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (typeof prop === "symbol") return void 0;
|
|
105
|
+
return makeRef(op, prop);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function refDescriptor(v) {
|
|
110
|
+
if (typeof v !== "object" || v === null) return null;
|
|
111
|
+
const d = v[REF];
|
|
112
|
+
return isRefDescriptor(d) ? d : null;
|
|
113
|
+
}
|
|
114
|
+
function isRefDescriptor(d) {
|
|
115
|
+
return typeof d === "object" && d !== null && typeof d.op === "number" && typeof d.field === "string";
|
|
116
|
+
}
|
|
117
|
+
function rowOpIndex(v) {
|
|
118
|
+
if (typeof v !== "object" || v === null) return null;
|
|
119
|
+
const op = v[ROW];
|
|
120
|
+
return typeof op === "number" ? op : null;
|
|
121
|
+
}
|
|
122
|
+
function exprOf(v) {
|
|
123
|
+
if (typeof v !== "object" || v === null) return null;
|
|
124
|
+
const e = v[EXPR];
|
|
125
|
+
return typeof e === "object" && e !== null ? e : null;
|
|
126
|
+
}
|
|
127
|
+
function isRowsHandle(v) {
|
|
128
|
+
return typeof v === "object" && v !== null && v[ROWS] !== void 0;
|
|
129
|
+
}
|
|
130
|
+
function encodeValue(value, column, allowColumnExpr) {
|
|
131
|
+
const ref = refDescriptor(value);
|
|
132
|
+
if (ref) return { $ref: { op: ref.op, field: ref.field } };
|
|
133
|
+
const expr = exprOf(value);
|
|
134
|
+
if (expr) {
|
|
135
|
+
if (expr.fn !== "now" && !allowColumnExpr) {
|
|
136
|
+
throw new TxPlanError(
|
|
137
|
+
`\`${column}\`: ${expr.fn}() reads the column's current value, so it is only valid in updateWhere(where, set).`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return { $expr: expr };
|
|
141
|
+
}
|
|
142
|
+
if (rowOpIndex(value) !== null) {
|
|
143
|
+
throw new TxPlanError(
|
|
144
|
+
`\`${column}\`: a row handle is not a value. Read the column you meant (e.g. \`row.id\`).`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (isRowsHandle(value)) {
|
|
148
|
+
throw new TxPlanError(
|
|
149
|
+
`\`${column}\`: an operation result is not a value. Declare an expectation first (\`.expectOne(err)\`) and read a column from the row.`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
assertNoNestedHandles(value, column);
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
function assertNoNestedHandles(value, column) {
|
|
156
|
+
if (typeof value !== "object" || value === null) return;
|
|
157
|
+
if (value instanceof Date) return;
|
|
158
|
+
if (refDescriptor(value) || exprOf(value) || rowOpIndex(value) !== null || isRowsHandle(value)) {
|
|
159
|
+
throw new TxPlanError(
|
|
160
|
+
`\`${column}\`: a plan handle is nested inside a value. The server would store it as literal JSON, not resolve it. Put the reference directly in the column.`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (Array.isArray(value)) {
|
|
164
|
+
for (const item of value) assertNoNestedHandles(item, column);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
for (const item of Object.values(value)) {
|
|
168
|
+
assertNoNestedHandles(item, column);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function encodeMap(map, allowColumnExpr) {
|
|
172
|
+
const out = {};
|
|
173
|
+
for (const key of Object.keys(map).sort()) {
|
|
174
|
+
const value = map[key];
|
|
175
|
+
if (value === void 0) continue;
|
|
176
|
+
out[key] = encodeValue(value, key, allowColumnExpr);
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
var SKIPPED_OP = -1;
|
|
181
|
+
var TxRowsImpl = class {
|
|
182
|
+
constructor(builder, opIndex, what) {
|
|
183
|
+
this.builder = builder;
|
|
184
|
+
this.opIndex = opIndex;
|
|
185
|
+
this.what = what;
|
|
186
|
+
}
|
|
187
|
+
builder;
|
|
188
|
+
opIndex;
|
|
189
|
+
what;
|
|
190
|
+
// Present so `isRowsHandle` recognises the object; never read for its value.
|
|
191
|
+
[ROWS] = true;
|
|
192
|
+
guarded = false;
|
|
193
|
+
// The type-level `await` guard made real: TS rejects `await rows` at compile
|
|
194
|
+
// time, and reaching this means someone called `.then(...)` by hand.
|
|
195
|
+
then() {
|
|
196
|
+
throw new TxRefError(
|
|
197
|
+
`${this.what} cannot be awaited: a transaction callback builds a plan, it does not run statements. Remove the \`await\`.`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
expectOne(error) {
|
|
201
|
+
this.declareGuard("one", 1, error);
|
|
202
|
+
if (this.opIndex === SKIPPED_OP) throw error;
|
|
203
|
+
return makeRowHandle(this.opIndex);
|
|
204
|
+
}
|
|
205
|
+
expectNone(error) {
|
|
206
|
+
this.declareGuard("none", 0, error);
|
|
207
|
+
}
|
|
208
|
+
expectAtLeast(n, error) {
|
|
209
|
+
assertGuardCount(n, "expectAtLeast");
|
|
210
|
+
this.declareGuard("atLeast", n, error);
|
|
211
|
+
if (this.opIndex === SKIPPED_OP && n > 0) throw error;
|
|
212
|
+
}
|
|
213
|
+
expectAtMost(n, error) {
|
|
214
|
+
assertGuardCount(n, "expectAtMost");
|
|
215
|
+
this.declareGuard("atMost", n, error);
|
|
216
|
+
}
|
|
217
|
+
declareGuard(kind, n, error) {
|
|
218
|
+
if (!(error instanceof Error)) {
|
|
219
|
+
throw new TxPlanError(
|
|
220
|
+
`${this.what}: an expectation needs the Error to throw when it does not hold (e.g. \`.expect\u2026(new Conflict("already accepted"))\`).`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (this.guarded) {
|
|
224
|
+
throw new TxPlanError(
|
|
225
|
+
`${this.what} already has an expectation. One operation carries one expectation; declare the second one on its own operation.`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
this.guarded = true;
|
|
229
|
+
if (this.opIndex === SKIPPED_OP) return;
|
|
230
|
+
this.builder.attachGuard(this.opIndex, kind, n, error);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
function assertGuardCount(n, fn) {
|
|
234
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
235
|
+
throw new TxPlanError(`${fn}(n) needs a non-negative integer, got ${String(n)}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
var MAX_OPS = 1e3;
|
|
239
|
+
var MAX_ROWS = 5e3;
|
|
240
|
+
var TxPlanBuilder = class {
|
|
241
|
+
ops = [];
|
|
242
|
+
/** Errors handed to expectations, indexed by the `slot` the server echoes. */
|
|
243
|
+
slots = [];
|
|
244
|
+
/** The table surface handed to the callback. Untyped here; the public
|
|
245
|
+
* `transaction()` signatures put the schema types on top. */
|
|
246
|
+
table(name) {
|
|
247
|
+
return {
|
|
248
|
+
insert: (values) => {
|
|
249
|
+
const encoded = encodeMap(values, false);
|
|
250
|
+
if (Object.keys(encoded).length === 0) {
|
|
251
|
+
throw new TxPlanError(`${name}.insert() needs at least one column`);
|
|
252
|
+
}
|
|
253
|
+
return this.push({ op: "insert", table: name, values: encoded }, `${name}.insert()`);
|
|
254
|
+
},
|
|
255
|
+
insertMany: (rows) => {
|
|
256
|
+
if (rows.length === 0) {
|
|
257
|
+
return new TxRowsImpl(this, SKIPPED_OP, `${name}.insertMany()`);
|
|
258
|
+
}
|
|
259
|
+
if (rows.length > MAX_ROWS) {
|
|
260
|
+
throw new TxPlanError(
|
|
261
|
+
`${name}.insertMany() has ${rows.length} rows; the limit is ${MAX_ROWS}. Split the write across requests.`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
const encoded = rows.map((row) => encodeMap(row, false));
|
|
265
|
+
assertUniformRows(encoded, name);
|
|
266
|
+
return this.push({ op: "insertMany", table: name, rows: encoded }, `${name}.insertMany()`);
|
|
267
|
+
},
|
|
268
|
+
updateWhere: (where, set) => {
|
|
269
|
+
const encodedWhere = encodeMap(where, false);
|
|
270
|
+
const encodedSet = encodeMap(set, true);
|
|
271
|
+
if (Object.keys(encodedWhere).length === 0) {
|
|
272
|
+
throw new TxPlanError(
|
|
273
|
+
`${name}.updateWhere() needs a filter. An update with no filter rewrites the whole table.`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
if (Object.keys(encodedSet).length === 0) {
|
|
277
|
+
throw new TxPlanError(`${name}.updateWhere() needs at least one column to set`);
|
|
278
|
+
}
|
|
279
|
+
return this.push(
|
|
280
|
+
{ op: "update", table: name, set: encodedSet, where: encodedWhere },
|
|
281
|
+
`${name}.updateWhere()`
|
|
282
|
+
);
|
|
283
|
+
},
|
|
284
|
+
deleteWhere: (where) => {
|
|
285
|
+
const encodedWhere = encodeMap(where, false);
|
|
286
|
+
if (Object.keys(encodedWhere).length === 0) {
|
|
287
|
+
throw new TxPlanError(
|
|
288
|
+
`${name}.deleteWhere() needs a filter. A delete with no filter empties the table.`
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
return this.push(
|
|
292
|
+
{ op: "delete", table: name, where: encodedWhere },
|
|
293
|
+
`${name}.deleteWhere()`
|
|
294
|
+
);
|
|
295
|
+
},
|
|
296
|
+
select: (where, options) => {
|
|
297
|
+
const op = { op: "select", table: name };
|
|
298
|
+
const encodedWhere = encodeMap(where ?? {}, false);
|
|
299
|
+
if (Object.keys(encodedWhere).length > 0) op.where = encodedWhere;
|
|
300
|
+
if (options?.limit !== void 0) {
|
|
301
|
+
if (!Number.isInteger(options.limit) || options.limit < 0) {
|
|
302
|
+
throw new TxPlanError(
|
|
303
|
+
`${name}.select(): limit needs a non-negative integer, got ${String(options.limit)}`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
op.limit = options.limit;
|
|
307
|
+
}
|
|
308
|
+
if (options?.lock !== void 0) op.lock = options.lock;
|
|
309
|
+
return this.push(op, `${name}.select()`);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
push(op, what) {
|
|
314
|
+
if (this.ops.length >= MAX_OPS) {
|
|
315
|
+
throw new TxPlanError(
|
|
316
|
+
`this transaction has ${MAX_OPS} operations, which is the limit. Use insertMany() for bulk writes, or split the work across requests.`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const index = this.ops.length;
|
|
320
|
+
this.ops.push(op);
|
|
321
|
+
return new TxRowsImpl(this, index, what);
|
|
322
|
+
}
|
|
323
|
+
/** Attach an expectation to an op and record its error in the slot table. */
|
|
324
|
+
attachGuard(opIndex, kind, n, error) {
|
|
325
|
+
const op = this.ops[opIndex];
|
|
326
|
+
if (!op) throw new TxPlanError(`internal: expectation on unknown operation ${opIndex}`);
|
|
327
|
+
const slot = this.slots.length;
|
|
328
|
+
this.slots.push(error);
|
|
329
|
+
op.guard = { kind, n, slot };
|
|
330
|
+
}
|
|
331
|
+
/** The serialisable plan. Empty when the callback described no writes. */
|
|
332
|
+
body() {
|
|
333
|
+
return { ops: this.ops };
|
|
334
|
+
}
|
|
335
|
+
/** The error the server's `slot` selects, or `null` when it names one this
|
|
336
|
+
* plan never declared (a server/client disagreement, not a tenant error). */
|
|
337
|
+
errorForSlot(slot) {
|
|
338
|
+
return this.slots[slot] ?? null;
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
function assertUniformRows(rows, table) {
|
|
342
|
+
const first = rows[0];
|
|
343
|
+
if (!first) return;
|
|
344
|
+
const want = Object.keys(first);
|
|
345
|
+
const wantKey = want.join(",");
|
|
346
|
+
for (let i = 1; i < rows.length; i++) {
|
|
347
|
+
const got = Object.keys(rows[i]);
|
|
348
|
+
if (got.join(",") !== wantKey) {
|
|
349
|
+
throw new TxPlanError(
|
|
350
|
+
`${table}.insertMany(): every row must set the same columns. Row 0 sets [${want.join(", ")}] but row ${i} sets [${got.join(", ")}]. (A property set to \`undefined\` counts as absent \u2014 use \`null\`.)`
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function materializeResult(value, results) {
|
|
356
|
+
const ref = refDescriptor(value);
|
|
357
|
+
if (ref) {
|
|
358
|
+
const row = rowOf(results, ref.op, `\`${ref.field}\``);
|
|
359
|
+
if (!(ref.field in row)) {
|
|
360
|
+
throw new TxPlanError(
|
|
361
|
+
`the transaction's operation ${ref.op} returned no column \`${ref.field}\`.`
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
return row[ref.field];
|
|
365
|
+
}
|
|
366
|
+
const rowOp = rowOpIndex(value);
|
|
367
|
+
if (rowOp !== null) return rowOf(results, rowOp, "a row");
|
|
368
|
+
if (isRowsHandle(value)) {
|
|
369
|
+
throw new TxPlanError(
|
|
370
|
+
"an operation result cannot be returned from a transaction callback: its row count is not known until the plan runs. Declare an expectation (`.expectOne(err)`) and return the row, or a column of it."
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
if (Array.isArray(value)) return value.map((item) => materializeResult(item, results));
|
|
374
|
+
if (isPlainObject(value)) {
|
|
375
|
+
const out = {};
|
|
376
|
+
for (const [key, item] of Object.entries(value)) out[key] = materializeResult(item, results);
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
return value;
|
|
380
|
+
}
|
|
381
|
+
function rowOf(results, opIndex, what) {
|
|
382
|
+
const result = results[opIndex];
|
|
383
|
+
if (!result) {
|
|
384
|
+
throw new TxPlanError(
|
|
385
|
+
`the transaction returned no result for operation ${opIndex}, so ${what} cannot be read.`
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
const row = result.rows[0];
|
|
389
|
+
if (!row) {
|
|
390
|
+
throw new TxPlanError(
|
|
391
|
+
`the transaction's operation ${opIndex} returned no row, so ${what} cannot be read.`
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
return row;
|
|
395
|
+
}
|
|
396
|
+
function isPlainObject(value) {
|
|
397
|
+
if (typeof value !== "object" || value === null) return false;
|
|
398
|
+
const proto = Object.getPrototypeOf(value);
|
|
399
|
+
return proto === Object.prototype || proto === null;
|
|
400
|
+
}
|
|
401
|
+
async function runTxPlan(transport, tables, builder, fn) {
|
|
402
|
+
const returned = fn({ tables });
|
|
403
|
+
const body = builder.body();
|
|
404
|
+
if (body.ops.length === 0) {
|
|
405
|
+
return materializeResult(returned, []);
|
|
406
|
+
}
|
|
407
|
+
let response;
|
|
408
|
+
try {
|
|
409
|
+
response = await transport.txPlan(body);
|
|
410
|
+
} catch (err) {
|
|
411
|
+
throw translateRejection(err, builder);
|
|
412
|
+
}
|
|
413
|
+
return materializeResult(returned, response.results);
|
|
414
|
+
}
|
|
415
|
+
function translateRejection(err, builder) {
|
|
416
|
+
if (typeof err !== "object" || err === null) return err;
|
|
417
|
+
const rejection = err;
|
|
418
|
+
if (rejection.error_code !== "tx_guard_failed" || typeof rejection.slot !== "number") {
|
|
419
|
+
return err;
|
|
420
|
+
}
|
|
421
|
+
return builder.errorForSlot(rejection.slot) ?? err;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// src/runtime.ts
|
|
425
|
+
var __requestALS = new import_node_async_hooks.AsyncLocalStorage();
|
|
426
|
+
var runtime = null;
|
|
427
|
+
function __runWithRuntime(services, fn) {
|
|
428
|
+
return __requestALS.run({ runtime: services }, fn);
|
|
429
|
+
}
|
|
430
|
+
function __getRuntime() {
|
|
431
|
+
const scoped = __requestALS.getStore();
|
|
432
|
+
if (scoped) return scoped.runtime;
|
|
433
|
+
if (runtime === null) {
|
|
434
|
+
throw new Error(
|
|
435
|
+
"Palbase services accessed outside a request scope. The Database/Documents/\u2026 singletons are only available inside an endpoint handler (or after the runtime has called __runWithRuntime / __setRuntime)."
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
return runtime;
|
|
439
|
+
}
|
|
440
|
+
function makeServiceProxy(key) {
|
|
441
|
+
const handler = {
|
|
442
|
+
get(_target, prop, receiver) {
|
|
443
|
+
const client = __getRuntime()[key];
|
|
444
|
+
const value = Reflect.get(client, prop, receiver);
|
|
445
|
+
return typeof value === "function" ? value.bind(client) : value;
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
return new Proxy({}, handler);
|
|
449
|
+
}
|
|
450
|
+
function makeTablesAccessor(ops) {
|
|
451
|
+
const tablesProxy = new Proxy(
|
|
452
|
+
{},
|
|
453
|
+
{
|
|
454
|
+
get(_t, prop) {
|
|
455
|
+
if (typeof prop !== "string") return void 0;
|
|
456
|
+
const name = prop;
|
|
457
|
+
return {
|
|
458
|
+
insert: (data) => ops().insert(name, data),
|
|
459
|
+
update: (id, data) => ops().update(name, id, data),
|
|
460
|
+
delete: (id) => ops().delete(name, id),
|
|
461
|
+
findById: (id) => ops().findById(name, id),
|
|
462
|
+
findMany: (query) => ops().findMany(name, query)
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
);
|
|
467
|
+
return tablesProxy;
|
|
468
|
+
}
|
|
469
|
+
var rawDatabase = makeServiceProxy("Database");
|
|
470
|
+
function makeTypedSurface(raw) {
|
|
471
|
+
const ops = {
|
|
472
|
+
query: (sql, params) => raw.query(sql, params),
|
|
473
|
+
insert: (table, data) => raw.insert(table, data),
|
|
474
|
+
update: (table, id, data) => raw.update(table, id, data),
|
|
475
|
+
delete: (table, id) => raw.delete(table, id),
|
|
476
|
+
findById: (table, id) => raw.findById(table, id),
|
|
477
|
+
findMany: (table, query) => raw.findMany(table, query)
|
|
478
|
+
};
|
|
479
|
+
return Object.assign(ops, {
|
|
480
|
+
tables: makeTablesAccessor(() => raw),
|
|
481
|
+
transaction(fn) {
|
|
482
|
+
const builder = new TxPlanBuilder();
|
|
483
|
+
return runTxPlan(raw, makeTxTablesAccessor(builder), builder, fn);
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
function makeTxTablesAccessor(builder) {
|
|
488
|
+
const tablesProxy = new Proxy(
|
|
489
|
+
{},
|
|
490
|
+
{
|
|
491
|
+
get(_t, prop) {
|
|
492
|
+
if (typeof prop !== "string") return void 0;
|
|
493
|
+
return builder.table(prop);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
);
|
|
497
|
+
return tablesProxy;
|
|
498
|
+
}
|
|
499
|
+
var Database = Object.assign(makeTypedSurface(rawDatabase), {
|
|
500
|
+
/**
|
|
501
|
+
* Lazily resolve the runtime's service-role sibling on each call. We do NOT
|
|
502
|
+
* cache it: `rawDatabase.asService()` reads the CURRENT request scope through
|
|
503
|
+
* the runtime proxy, and the per-request runtime injects a service client
|
|
504
|
+
* bound to that request's identity headers — caching would leak one request's
|
|
505
|
+
* sibling into another concurrent request.
|
|
506
|
+
*/
|
|
507
|
+
asService() {
|
|
508
|
+
return makeTypedSurface(rawDatabase.asService());
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
var Documents = makeServiceProxy("Documents");
|
|
512
|
+
function makeBucketsAccessor(storage) {
|
|
513
|
+
return new Proxy(
|
|
514
|
+
{},
|
|
515
|
+
{
|
|
516
|
+
get(_t, prop) {
|
|
517
|
+
if (typeof prop !== "string") return void 0;
|
|
518
|
+
return storage().bucket(prop);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
var rawStorage = makeServiceProxy("Storage");
|
|
524
|
+
var Storage = Object.assign(
|
|
525
|
+
{
|
|
526
|
+
// FORWARDED explicitly, not assigned onto the service proxy.
|
|
527
|
+
//
|
|
528
|
+
// `Object.assign(rawStorage, {buckets})` writes onto the proxy's TARGET, and
|
|
529
|
+
// the proxy's only trap is `get`, which forwards every read to the module
|
|
530
|
+
// client — so the property landed somewhere nothing reads and
|
|
531
|
+
// `Storage.buckets.docs` was `undefined` in a deployed handler. It
|
|
532
|
+
// typechecked, every test passed, and the live call answered
|
|
533
|
+
// "TypeError: undefined is not an object". `Database` never had the bug
|
|
534
|
+
// because it builds a plain surface the same way this now does.
|
|
535
|
+
bucket: (name) => rawStorage.bucket(name)
|
|
536
|
+
},
|
|
537
|
+
{ buckets: makeBucketsAccessor(() => rawStorage) }
|
|
538
|
+
);
|
|
539
|
+
var Cache = makeServiceProxy("Cache");
|
|
540
|
+
var Secrets = makeServiceProxy("Secrets");
|
|
541
|
+
var Log = makeServiceProxy("Log");
|
|
542
|
+
var Notifications = makeServiceProxy("Notifications");
|
|
543
|
+
var Purchases = makeServiceProxy("Purchases");
|
|
544
|
+
var rawFlags = makeServiceProxy("Flags");
|
|
545
|
+
var Flags = Object.assign(
|
|
546
|
+
{
|
|
547
|
+
isEnabled(flagName, context) {
|
|
548
|
+
return rawFlags.isEnabled(flagName, context);
|
|
549
|
+
},
|
|
550
|
+
getVariant(flagName, context) {
|
|
551
|
+
return rawFlags.getVariant(flagName, context);
|
|
552
|
+
},
|
|
553
|
+
getAll(context) {
|
|
554
|
+
return rawFlags.getAll(context);
|
|
555
|
+
},
|
|
556
|
+
/**
|
|
557
|
+
* Resolve a flag's value, with an optional fallback.
|
|
558
|
+
*
|
|
559
|
+
* FORWARDED as of 2026-08-15. This surface is written out by hand, method
|
|
560
|
+
* by method, and `get` was missing from it — so the client implemented it,
|
|
561
|
+
* thirty assertions covered it, and `Flags.get("x")` was `undefined` in a
|
|
562
|
+
* deployed handler. Exactly the shape of the `Storage.buckets` defect found
|
|
563
|
+
* the same day: a hand-maintained forwarding list is a list somebody has to
|
|
564
|
+
* remember to update.
|
|
565
|
+
*/
|
|
566
|
+
get(flagName, defaultOrContext, maybeContext) {
|
|
567
|
+
return rawFlags.get(flagName, defaultOrContext, maybeContext);
|
|
568
|
+
},
|
|
569
|
+
setOverride(key, value) {
|
|
570
|
+
return rawFlags.setOverride(key, value);
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
/**
|
|
575
|
+
* Lazily resolve the runtime's cross-user sibling on each call. We do NOT
|
|
576
|
+
* cache it: `rawFlags.asService()` reads the CURRENT request scope through
|
|
577
|
+
* the runtime proxy, so caching would leak one request's sibling into
|
|
578
|
+
* another concurrent request. Mirrors `Database.asService()`.
|
|
579
|
+
*/
|
|
580
|
+
asService() {
|
|
581
|
+
return rawFlags.asService();
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
);
|
|
585
|
+
var Realtime = makeServiceProxy("Realtime");
|
|
586
|
+
|
|
587
|
+
// src/errors.ts
|
|
588
|
+
var HTTP_ERROR_BRAND = /* @__PURE__ */ Symbol.for("palbase.backend.httpError");
|
|
589
|
+
function isHttpError(err) {
|
|
590
|
+
if (typeof err !== "object" || err === null) return false;
|
|
591
|
+
const e = err;
|
|
592
|
+
return e[HTTP_ERROR_BRAND] === true && typeof e.status === "number" && typeof e.error === "string" && typeof e.errorDescription === "string";
|
|
593
|
+
}
|
|
594
|
+
var HttpError = class extends Error {
|
|
595
|
+
status;
|
|
596
|
+
error;
|
|
597
|
+
errorDescription;
|
|
598
|
+
data;
|
|
599
|
+
/** See {@link HTTP_ERROR_BRAND} — how the engine recognises this across SDK copies. */
|
|
600
|
+
[HTTP_ERROR_BRAND] = true;
|
|
601
|
+
constructor(status, error, errorDescription, data) {
|
|
602
|
+
super(errorDescription);
|
|
603
|
+
this.name = "HttpError";
|
|
604
|
+
this.status = status;
|
|
605
|
+
this.error = error;
|
|
606
|
+
this.errorDescription = errorDescription;
|
|
607
|
+
if (data !== void 0) {
|
|
608
|
+
this.data = data;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Serialize to the standard Palbase error response format.
|
|
613
|
+
* The `requestId` is injected by the runtime layer from the request context.
|
|
614
|
+
* When called without arguments (e.g. JSON.stringify), request_id is omitted.
|
|
615
|
+
* When `data` is set, it is appended as a strict-superset field.
|
|
616
|
+
*/
|
|
617
|
+
toJSON(requestId) {
|
|
618
|
+
const result = {
|
|
619
|
+
error: this.error,
|
|
620
|
+
error_description: this.errorDescription,
|
|
621
|
+
status: this.status
|
|
622
|
+
};
|
|
623
|
+
if (requestId) {
|
|
624
|
+
result.request_id = requestId;
|
|
625
|
+
}
|
|
626
|
+
if (this.data !== void 0) {
|
|
627
|
+
result.data = this.data;
|
|
628
|
+
}
|
|
629
|
+
return result;
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
// src/engine/config.ts
|
|
634
|
+
var BootRefused = class extends Error {
|
|
635
|
+
missing;
|
|
636
|
+
constructor(missing, message) {
|
|
637
|
+
super(message);
|
|
638
|
+
this.name = "BootRefused";
|
|
639
|
+
this.missing = missing;
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
var MANDATORY = [
|
|
643
|
+
{ key: "DATABASE_URL", what: "the stack's Postgres (Database module)" },
|
|
644
|
+
{ key: "AUTH_JWKS_URL", what: "where this stack publishes its token signing keys (Auth module)" }
|
|
645
|
+
];
|
|
646
|
+
function loadConfig(env) {
|
|
647
|
+
const missing = MANDATORY.filter((m) => !env[m.key]?.trim()).map((m) => m.key);
|
|
648
|
+
if (missing.length > 0) {
|
|
649
|
+
const detail = MANDATORY.filter((m) => missing.includes(m.key)).map((m) => ` ${m.key.padEnd(16)}${m.what}`).join("\n");
|
|
650
|
+
throw new BootRefused(
|
|
651
|
+
missing,
|
|
652
|
+
`boot refused: mandatory module not configured \u2014 missing ${missing.join(", ")}.
|
|
653
|
+
${detail}`
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
const port = Number(env.PORT ?? 3e3);
|
|
657
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
658
|
+
throw new BootRefused([], `boot refused: PORT is not a valid port number (got ${env.PORT}).`);
|
|
659
|
+
}
|
|
660
|
+
const poolMax = Number(env.DB_POOL_MAX ?? 10);
|
|
661
|
+
if (!Number.isInteger(poolMax) || poolMax < 1) {
|
|
662
|
+
throw new BootRefused([], `boot refused: DB_POOL_MAX must be a positive integer (got ${env.DB_POOL_MAX}).`);
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
databaseUrl: env.DATABASE_URL.trim(),
|
|
666
|
+
authJwksUrl: env.AUTH_JWKS_URL.trim(),
|
|
667
|
+
authIssuer: env.AUTH_ISSUER?.trim() || void 0,
|
|
668
|
+
moduleBaseUrl: (env.MODULE_BASE_URL ?? "").replace(/\/+$/, ""),
|
|
669
|
+
// The secret storage signs its two internal calls with (authorize, and the
|
|
670
|
+
// completion that runs an @Upload handler). Empty means uploads are not
|
|
671
|
+
// wired, and both calls REFUSE — an unsigned completion would let anyone
|
|
672
|
+
// who knows a route path invent an upload that never happened.
|
|
673
|
+
uploadSecret: env.PALBASE_UPLOAD_SECRET ?? "",
|
|
674
|
+
anonKey: env.PALBASE_ANON_KEY ?? "",
|
|
675
|
+
serviceRoleKey: env.PALBASE_SERVICE_ROLE_KEY ?? "",
|
|
676
|
+
realtimeSecret: env.REALTIME_INGESTION_SECRET ?? "",
|
|
677
|
+
port,
|
|
678
|
+
dbRole: env.DB_ROLE ?? "backend_authenticated",
|
|
679
|
+
// Verified against the stack that provisions them, not from memory: the six
|
|
680
|
+
// roles and their attributes are declared in v2/internal/migrate/provision.go
|
|
681
|
+
// (`roleBackendServiceRole = "backend_service_role"`, NOLOGIN BYPASSRLS),
|
|
682
|
+
// and the live database agrees (pg_roles.rolbypassrls = true).
|
|
683
|
+
dbServiceRole: env.DB_SERVICE_ROLE ?? "backend_service_role",
|
|
684
|
+
poolMax
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// src/engine/auth.ts
|
|
689
|
+
function b64urlToBytes(s) {
|
|
690
|
+
const pad = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
691
|
+
const full = pad.padEnd(Math.ceil(pad.length / 4) * 4, "=");
|
|
692
|
+
const bin = atob(full);
|
|
693
|
+
const out = new Uint8Array(new ArrayBuffer(bin.length));
|
|
694
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
695
|
+
return out;
|
|
696
|
+
}
|
|
697
|
+
var AuthVerifier = class {
|
|
698
|
+
keys = /* @__PURE__ */ new Map();
|
|
699
|
+
fetchedAt = 0;
|
|
700
|
+
inflight = null;
|
|
701
|
+
jwksUrl;
|
|
702
|
+
issuer;
|
|
703
|
+
fetchImpl;
|
|
704
|
+
ttl;
|
|
705
|
+
constructor(opts) {
|
|
706
|
+
this.jwksUrl = opts.jwksUrl;
|
|
707
|
+
this.issuer = opts.issuer;
|
|
708
|
+
this.fetchImpl = opts.fetchImpl ?? ((...a) => fetch(...a));
|
|
709
|
+
this.ttl = opts.keysetTtlMs ?? 5 * 6e4;
|
|
710
|
+
}
|
|
711
|
+
/** Fetch the keyset at most once per TTL, and at most once concurrently. */
|
|
712
|
+
async refresh() {
|
|
713
|
+
if (this.inflight) return this.inflight;
|
|
714
|
+
this.inflight = (async () => {
|
|
715
|
+
try {
|
|
716
|
+
const res = await this.fetchImpl(this.jwksUrl);
|
|
717
|
+
if (!res.ok) return;
|
|
718
|
+
const body = await res.json();
|
|
719
|
+
const next = /* @__PURE__ */ new Map();
|
|
720
|
+
for (const jwk of body.keys ?? []) {
|
|
721
|
+
if (jwk.kty !== "EC" || jwk.crv !== "P-256") continue;
|
|
722
|
+
try {
|
|
723
|
+
next.set(
|
|
724
|
+
jwk.kid,
|
|
725
|
+
await crypto.subtle.importKey(
|
|
726
|
+
"jwk",
|
|
727
|
+
{ kty: "EC", crv: jwk.crv, x: jwk.x, y: jwk.y, ext: true },
|
|
728
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
729
|
+
true,
|
|
730
|
+
["verify"]
|
|
731
|
+
)
|
|
732
|
+
);
|
|
733
|
+
} catch {
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (next.size > 0) {
|
|
737
|
+
this.keys = next;
|
|
738
|
+
this.fetchedAt = Date.now();
|
|
739
|
+
}
|
|
740
|
+
} finally {
|
|
741
|
+
this.inflight = null;
|
|
742
|
+
}
|
|
743
|
+
})();
|
|
744
|
+
return this.inflight;
|
|
745
|
+
}
|
|
746
|
+
async key(kid) {
|
|
747
|
+
const stale = Date.now() - this.fetchedAt > this.ttl;
|
|
748
|
+
if (!this.keys.has(kid) || stale) await this.refresh();
|
|
749
|
+
return this.keys.get(kid) ?? null;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Verify an `Authorization` header value.
|
|
753
|
+
*
|
|
754
|
+
* @returns the verified claims, or `null` for absent / malformed / expired /
|
|
755
|
+
* wrong-issuer / bad-signature. One `null` for every failure on purpose:
|
|
756
|
+
* the caller answers 401 either way, and a detailed reason is an oracle.
|
|
757
|
+
*/
|
|
758
|
+
async verify(authorization) {
|
|
759
|
+
if (!authorization || !authorization.startsWith("Bearer ")) return null;
|
|
760
|
+
const parts = authorization.slice(7).trim().split(".");
|
|
761
|
+
if (parts.length !== 3) return null;
|
|
762
|
+
const h = parts[0];
|
|
763
|
+
const p = parts[1];
|
|
764
|
+
const sig = parts[2];
|
|
765
|
+
if (h === void 0 || p === void 0 || sig === void 0) return null;
|
|
766
|
+
let header;
|
|
767
|
+
let claims;
|
|
768
|
+
try {
|
|
769
|
+
header = JSON.parse(new TextDecoder().decode(b64urlToBytes(h)));
|
|
770
|
+
claims = JSON.parse(new TextDecoder().decode(b64urlToBytes(p)));
|
|
771
|
+
} catch {
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
if (header.alg !== "ES256" || !header.kid) return null;
|
|
775
|
+
const key = await this.key(header.kid);
|
|
776
|
+
if (!key) return null;
|
|
777
|
+
let ok = false;
|
|
778
|
+
try {
|
|
779
|
+
ok = await crypto.subtle.verify(
|
|
780
|
+
{ name: "ECDSA", hash: "SHA-256" },
|
|
781
|
+
key,
|
|
782
|
+
b64urlToBytes(sig),
|
|
783
|
+
new TextEncoder().encode(`${h}.${p}`)
|
|
784
|
+
);
|
|
785
|
+
} catch {
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
if (!ok) return null;
|
|
789
|
+
if (typeof claims.exp === "number" && claims.exp * 1e3 <= Date.now()) return null;
|
|
790
|
+
if (this.issuer && claims.iss !== this.issuer) return null;
|
|
791
|
+
return claims;
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
function effectiveAuth(routeAuth, controllerAuth) {
|
|
795
|
+
const spec = routeAuth !== void 0 ? routeAuth : controllerAuth;
|
|
796
|
+
if (spec === false) return { required: false, verifiedEmail: false };
|
|
797
|
+
if (spec === true || spec === void 0 || spec === null) return { required: true, verifiedEmail: false };
|
|
798
|
+
if (typeof spec !== "object") return { required: true, verifiedEmail: false };
|
|
799
|
+
const o = spec;
|
|
800
|
+
const role = typeof o.role === "string" && o.role.trim() !== "" ? o.role.trim() : void 0;
|
|
801
|
+
return {
|
|
802
|
+
required: o.required !== false,
|
|
803
|
+
role,
|
|
804
|
+
verifiedEmail: o.verifiedEmail === true
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// src/engine/ratelimit.ts
|
|
809
|
+
var RateLimiter = class {
|
|
810
|
+
/** Bound on distinct keys held, so an attacker cycling identities cannot
|
|
811
|
+
* grow this map without limit. On overflow the oldest windows are dropped —
|
|
812
|
+
* forgiving, consistent with the restart behaviour above. */
|
|
813
|
+
constructor(maxKeys = 1e5) {
|
|
814
|
+
this.maxKeys = maxKeys;
|
|
815
|
+
}
|
|
816
|
+
maxKeys;
|
|
817
|
+
buckets = /* @__PURE__ */ new Map();
|
|
818
|
+
/**
|
|
819
|
+
* Identify the caller: the signed-in user when the route resolved one,
|
|
820
|
+
* otherwise the address the edge forwarded. Callers the edge did not
|
|
821
|
+
* identify share one bucket — deliberately conservative, since the
|
|
822
|
+
* alternative is a limit anyone resets by omitting a header.
|
|
823
|
+
*/
|
|
824
|
+
static key(routeId, userId, headers) {
|
|
825
|
+
if (userId) return `${routeId}\0u:${userId}`;
|
|
826
|
+
const fwd = headers.get("x-forwarded-for");
|
|
827
|
+
const addr = (fwd ? fwd.split(",")[0] ?? "" : headers.get("x-real-ip") ?? "").trim();
|
|
828
|
+
return `${routeId}\0a:${addr || "anonymous"}`;
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* @returns `null` when the request may proceed, or the number of seconds to
|
|
832
|
+
* wait (never 0 — a caller told to wait 0 comes straight back to the same
|
|
833
|
+
* refusal).
|
|
834
|
+
*/
|
|
835
|
+
check(rule, key, now) {
|
|
836
|
+
if (!rule || !(rule.max > 0) || !(rule.window > 0)) return null;
|
|
837
|
+
const bucket = this.buckets.get(key);
|
|
838
|
+
if (!bucket || now >= bucket.resetAt) {
|
|
839
|
+
if (this.buckets.size >= this.maxKeys) this.evict(now);
|
|
840
|
+
this.buckets.set(key, { count: 1, resetAt: now + rule.window * 1e3 });
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
if (bucket.count < rule.max) {
|
|
844
|
+
bucket.count++;
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
return Math.max(1, Math.ceil((bucket.resetAt - now) / 1e3));
|
|
848
|
+
}
|
|
849
|
+
/** Drop expired windows; if none are expired, drop the earliest-resetting
|
|
850
|
+
* quarter so the map cannot wedge at the ceiling. */
|
|
851
|
+
evict(now) {
|
|
852
|
+
let dropped = 0;
|
|
853
|
+
for (const [k, b] of this.buckets) {
|
|
854
|
+
if (now >= b.resetAt) {
|
|
855
|
+
this.buckets.delete(k);
|
|
856
|
+
dropped++;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (dropped > 0) return;
|
|
860
|
+
const byReset = [...this.buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt);
|
|
861
|
+
for (let i = 0; i < Math.ceil(byReset.length / 4); i++) {
|
|
862
|
+
const victim = byReset[i];
|
|
863
|
+
if (victim) this.buckets.delete(victim[0]);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
/** Test seam. */
|
|
867
|
+
get size() {
|
|
868
|
+
return this.buckets.size;
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
// src/engine/cache.ts
|
|
873
|
+
function makeMemoryCache(opts = {}) {
|
|
874
|
+
const maxEntries = opts.maxEntries ?? 5e4;
|
|
875
|
+
const now = opts.now ?? (() => Date.now());
|
|
876
|
+
const store = /* @__PURE__ */ new Map();
|
|
877
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
878
|
+
const live = (key) => {
|
|
879
|
+
const e = store.get(key);
|
|
880
|
+
if (!e) return void 0;
|
|
881
|
+
if (e.expiresAt !== 0 && e.expiresAt <= now()) {
|
|
882
|
+
store.delete(key);
|
|
883
|
+
return void 0;
|
|
884
|
+
}
|
|
885
|
+
return e;
|
|
886
|
+
};
|
|
887
|
+
const evict = () => {
|
|
888
|
+
const t = now();
|
|
889
|
+
let dropped = 0;
|
|
890
|
+
for (const [k, e] of store) {
|
|
891
|
+
if (e.expiresAt !== 0 && e.expiresAt <= t) {
|
|
892
|
+
store.delete(k);
|
|
893
|
+
dropped++;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (dropped > 0) return;
|
|
897
|
+
const order = [...store.entries()].sort(
|
|
898
|
+
(a, b) => (a[1].expiresAt || Infinity) - (b[1].expiresAt || Infinity)
|
|
899
|
+
);
|
|
900
|
+
for (let i = 0; i < Math.ceil(order.length / 4); i++) {
|
|
901
|
+
const victim = order[i];
|
|
902
|
+
if (victim) store.delete(victim[0]);
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
const set = async (key, value, ttl) => {
|
|
906
|
+
if (store.size >= maxEntries && !store.has(key)) evict();
|
|
907
|
+
store.set(key, { value, expiresAt: ttl && ttl > 0 ? now() + ttl * 1e3 : 0 });
|
|
908
|
+
};
|
|
909
|
+
return {
|
|
910
|
+
async get(key) {
|
|
911
|
+
const e = live(key);
|
|
912
|
+
return e ? e.value : null;
|
|
913
|
+
},
|
|
914
|
+
set,
|
|
915
|
+
async del(key) {
|
|
916
|
+
store.delete(key);
|
|
917
|
+
},
|
|
918
|
+
async incr(key) {
|
|
919
|
+
const e = live(key);
|
|
920
|
+
const next = (typeof e?.value === "number" ? e.value : 0) + 1;
|
|
921
|
+
store.set(key, { value: next, expiresAt: e?.expiresAt ?? 0 });
|
|
922
|
+
return next;
|
|
923
|
+
},
|
|
924
|
+
async getOrSet(key, ttl, fn) {
|
|
925
|
+
const hit = live(key);
|
|
926
|
+
if (hit) return hit.value;
|
|
927
|
+
const running = inflight.get(key);
|
|
928
|
+
if (running) return running;
|
|
929
|
+
const fill = (async () => {
|
|
930
|
+
try {
|
|
931
|
+
const value = await fn();
|
|
932
|
+
await set(key, value, ttl);
|
|
933
|
+
return value;
|
|
934
|
+
} finally {
|
|
935
|
+
inflight.delete(key);
|
|
936
|
+
}
|
|
937
|
+
})();
|
|
938
|
+
inflight.set(key, fill);
|
|
939
|
+
return fill;
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// src/engine/db.ts
|
|
945
|
+
function quoteIdent(name) {
|
|
946
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
947
|
+
}
|
|
948
|
+
var BIND_SQL = "select set_config('role',$1,true), set_config('search_path','public',true), set_config('request.jwt.claims',$2,true)";
|
|
949
|
+
function createLazyTransaction(sql, role, claimsJson, options = {}) {
|
|
950
|
+
const { lockTimeout } = options;
|
|
951
|
+
const bindSql = lockTimeout ? `${BIND_SQL}, set_config('lock_timeout',$3,true)` : BIND_SQL;
|
|
952
|
+
const bindParams = lockTimeout ? [role, claimsJson, lockTimeout] : [role, claimsJson];
|
|
953
|
+
let opening = null;
|
|
954
|
+
let release = null;
|
|
955
|
+
let fail = null;
|
|
956
|
+
let settled = null;
|
|
957
|
+
const ensure = () => {
|
|
958
|
+
if (opening) return opening;
|
|
959
|
+
opening = new Promise((resolveTx2, rejectTx) => {
|
|
960
|
+
const parked = new Promise((res, rej) => {
|
|
961
|
+
release = res;
|
|
962
|
+
fail = rej;
|
|
963
|
+
});
|
|
964
|
+
settled = sql.begin(async (tx) => {
|
|
965
|
+
await tx.unsafe(bindSql, bindParams);
|
|
966
|
+
resolveTx2(tx);
|
|
967
|
+
await parked;
|
|
968
|
+
}).catch((e) => {
|
|
969
|
+
rejectTx(e);
|
|
970
|
+
throw e;
|
|
971
|
+
});
|
|
972
|
+
});
|
|
973
|
+
return opening;
|
|
974
|
+
};
|
|
975
|
+
return {
|
|
976
|
+
ensure,
|
|
977
|
+
get opened() {
|
|
978
|
+
return opening !== null;
|
|
979
|
+
},
|
|
980
|
+
async commit() {
|
|
981
|
+
if (!opening) return;
|
|
982
|
+
release();
|
|
983
|
+
await settled;
|
|
984
|
+
},
|
|
985
|
+
async rollback(reason) {
|
|
986
|
+
if (!opening) return;
|
|
987
|
+
fail(reason);
|
|
988
|
+
await settled?.catch(() => void 0);
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
var resolveTx = async (tx) => typeof tx.ensure === "function" ? await tx.ensure() : tx;
|
|
993
|
+
function asWireValue(value) {
|
|
994
|
+
if (value instanceof Date) return value.toISOString();
|
|
995
|
+
if (Array.isArray(value)) return value.map(asWireValue);
|
|
996
|
+
return value;
|
|
997
|
+
}
|
|
998
|
+
function asWireRow(row) {
|
|
999
|
+
if (row === null || typeof row !== "object") return row;
|
|
1000
|
+
const out = {};
|
|
1001
|
+
for (const [key, value] of Object.entries(row)) out[key] = asWireValue(value);
|
|
1002
|
+
return out;
|
|
1003
|
+
}
|
|
1004
|
+
function asWireRows(rows) {
|
|
1005
|
+
return rows.map((row) => asWireRow(row));
|
|
1006
|
+
}
|
|
1007
|
+
function createOps(tx) {
|
|
1008
|
+
const at = () => resolveTx(tx);
|
|
1009
|
+
const ops = {
|
|
1010
|
+
async query(sql, params = []) {
|
|
1011
|
+
return asWireRows(await (await at()).unsafe(sql, params));
|
|
1012
|
+
},
|
|
1013
|
+
async insert(table, data) {
|
|
1014
|
+
const cols = Object.keys(data);
|
|
1015
|
+
if (cols.length === 0) throw new Error(`insert into ${table}: no columns given`);
|
|
1016
|
+
const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
|
|
1017
|
+
const sql = `INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${placeholders}) RETURNING *`;
|
|
1018
|
+
const rows = await (await at()).unsafe(sql, cols.map((c) => data[c]));
|
|
1019
|
+
const inserted = rows[0];
|
|
1020
|
+
if (!inserted) {
|
|
1021
|
+
throw new Error(
|
|
1022
|
+
`insert into ${table} returned no row \u2014 the write was rejected (an RLS policy, most likely).`
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
return asWireRow(inserted);
|
|
1026
|
+
},
|
|
1027
|
+
async update(table, id, data) {
|
|
1028
|
+
const cols = Object.keys(data);
|
|
1029
|
+
if (cols.length === 0) return ops.findById(table, id);
|
|
1030
|
+
const assignments = cols.map((c, i) => `${quoteIdent(c)} = $${i + 1}`).join(", ");
|
|
1031
|
+
const sql = `UPDATE ${quoteIdent(table)} SET ${assignments} WHERE id = $${cols.length + 1} RETURNING *`;
|
|
1032
|
+
const rows = await (await at()).unsafe(sql, [...cols.map((c) => data[c]), id]);
|
|
1033
|
+
return rows[0] ? asWireRow(rows[0]) : null;
|
|
1034
|
+
},
|
|
1035
|
+
async delete(table, id) {
|
|
1036
|
+
await (await at()).unsafe(`DELETE FROM ${quoteIdent(table)} WHERE id = $1`, [id]);
|
|
1037
|
+
},
|
|
1038
|
+
async findById(table, id) {
|
|
1039
|
+
const rows = await (await at()).unsafe(
|
|
1040
|
+
`SELECT * FROM ${quoteIdent(table)} WHERE id = $1`,
|
|
1041
|
+
[id]
|
|
1042
|
+
);
|
|
1043
|
+
return rows[0] ? asWireRow(rows[0]) : null;
|
|
1044
|
+
},
|
|
1045
|
+
async findMany(table, query = {}) {
|
|
1046
|
+
const cols = Object.keys(query);
|
|
1047
|
+
const where = cols.length ? ` WHERE ${cols.map((c, i) => `${quoteIdent(c)} = $${i + 1}`).join(" AND ")}` : "";
|
|
1048
|
+
return asWireRows(await (await at()).unsafe(
|
|
1049
|
+
`SELECT * FROM ${quoteIdent(table)}${where}`,
|
|
1050
|
+
cols.map((c) => query[c])
|
|
1051
|
+
));
|
|
1052
|
+
},
|
|
1053
|
+
/** A real SAVEPOINT inside the request's transaction. */
|
|
1054
|
+
async transaction(cb) {
|
|
1055
|
+
const live = await at();
|
|
1056
|
+
return live.savepoint(async (sp) => cb(withTables(createOps(sp), currentSchema)));
|
|
1057
|
+
},
|
|
1058
|
+
/**
|
|
1059
|
+
* Execute a whole transaction plan — what `Database.transaction(fn)` builds.
|
|
1060
|
+
*
|
|
1061
|
+
* WHY IT RUNS HERE. The platform used to carry a complete implementation
|
|
1062
|
+
* of this at `/internal-api/db/tx`, for tenant code that ran in an isolate
|
|
1063
|
+
* with no connection of its own. Running the plan there means running it on
|
|
1064
|
+
* a DIFFERENT connection: a transaction would not see the uncommitted
|
|
1065
|
+
* writes of the request that started it, and the two would hold separate
|
|
1066
|
+
* RLS bindings of the same identity. In this stack the tenant's code and
|
|
1067
|
+
* the connection share a process, so the plan runs on the request's own
|
|
1068
|
+
* transaction inside one SAVEPOINT — and that surface was removed on
|
|
1069
|
+
* 2026-08-15, once this was the last thing that could have called it.
|
|
1070
|
+
*
|
|
1071
|
+
* Until 2026-08-15 it ran NOWHERE: `runTxPlan` called `transport.txPlan` and
|
|
1072
|
+
* nothing here implemented it, so a live handler answered
|
|
1073
|
+
* "transport.txPlan is not a function" while every test that covered
|
|
1074
|
+
* transactions passed against a mock that did implement it.
|
|
1075
|
+
*/
|
|
1076
|
+
async txPlan(plan) {
|
|
1077
|
+
const live = await at();
|
|
1078
|
+
return live.savepoint(async (sp) => {
|
|
1079
|
+
const results = [];
|
|
1080
|
+
for (const op of plan.ops) {
|
|
1081
|
+
const rows = await runPlanOp(sp, op, results);
|
|
1082
|
+
const result = { rows, rows_affected: rows.length };
|
|
1083
|
+
results.push(result);
|
|
1084
|
+
assertGuard(op, result);
|
|
1085
|
+
}
|
|
1086
|
+
return { results };
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
return ops;
|
|
1091
|
+
}
|
|
1092
|
+
var currentSchema = {};
|
|
1093
|
+
function setSchema(schema) {
|
|
1094
|
+
const s = schema;
|
|
1095
|
+
currentSchema = (s && "default" in s ? s.default : s) ?? {};
|
|
1096
|
+
}
|
|
1097
|
+
function withTables(ops, schema = currentSchema) {
|
|
1098
|
+
const tables = {};
|
|
1099
|
+
for (const key of Object.keys(schema.tables ?? {})) {
|
|
1100
|
+
const name = schema.tables?.[key]?.name ?? key;
|
|
1101
|
+
tables[key] = {
|
|
1102
|
+
insert: (data) => ops.insert(name, data),
|
|
1103
|
+
update: (id, data) => ops.update(name, id, data),
|
|
1104
|
+
delete: (id) => ops.delete(name, id),
|
|
1105
|
+
findById: (id) => ops.findById(name, id),
|
|
1106
|
+
findMany: (query) => ops.findMany(name, query ?? {})
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
const base = /* @__PURE__ */ Object.create(null);
|
|
1110
|
+
return Object.assign(base, ops, { tables });
|
|
1111
|
+
}
|
|
1112
|
+
var SERVICE_LOCK_TIMEOUT = "5s";
|
|
1113
|
+
function isLockTimeout(e) {
|
|
1114
|
+
const code = e?.code;
|
|
1115
|
+
const message = String(e?.message ?? "");
|
|
1116
|
+
return code === "55P03" || /lock timeout/i.test(message);
|
|
1117
|
+
}
|
|
1118
|
+
function diagnosingDriver(sql) {
|
|
1119
|
+
const explain = (e) => isLockTimeout(e) ? new Error(
|
|
1120
|
+
`Database.asService() waited too long for a row lock. It runs in its OWN transaction, so a row this request already wrote through Database.* is locked against it until the request commits \u2014 a wait that cannot end. Do that row's work on one surface or the other. (${String(e?.message ?? e)})`
|
|
1121
|
+
) : e;
|
|
1122
|
+
const wrapTx = (tx) => ({
|
|
1123
|
+
async unsafe(text, params) {
|
|
1124
|
+
try {
|
|
1125
|
+
return await tx.unsafe(text, params);
|
|
1126
|
+
} catch (e) {
|
|
1127
|
+
throw explain(e);
|
|
1128
|
+
}
|
|
1129
|
+
},
|
|
1130
|
+
savepoint(cb) {
|
|
1131
|
+
return tx.savepoint((sp) => cb(wrapTx(sp)));
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
return {
|
|
1135
|
+
unsafe: (text, params) => sql.unsafe(text, params),
|
|
1136
|
+
begin: (cb) => sql.begin((tx) => cb(wrapTx(tx)))
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
function createRequestDatabase(sql, identity) {
|
|
1140
|
+
const tx = createLazyTransaction(sql, identity.role, identity.claimsJson);
|
|
1141
|
+
let serviceTx = null;
|
|
1142
|
+
let serviceClient = null;
|
|
1143
|
+
const asService = () => {
|
|
1144
|
+
if (serviceClient === null) {
|
|
1145
|
+
serviceTx = createLazyTransaction(
|
|
1146
|
+
diagnosingDriver(sql),
|
|
1147
|
+
identity.serviceRole,
|
|
1148
|
+
identity.claimsJson,
|
|
1149
|
+
{ lockTimeout: SERVICE_LOCK_TIMEOUT }
|
|
1150
|
+
);
|
|
1151
|
+
serviceClient = withTables(createOps(serviceTx));
|
|
1152
|
+
}
|
|
1153
|
+
return serviceClient;
|
|
1154
|
+
};
|
|
1155
|
+
return {
|
|
1156
|
+
client: Object.assign(withTables(createOps(tx)), { asService }),
|
|
1157
|
+
async commit() {
|
|
1158
|
+
await tx.commit();
|
|
1159
|
+
await serviceTx?.commit();
|
|
1160
|
+
},
|
|
1161
|
+
async rollback(reason) {
|
|
1162
|
+
await tx.rollback(reason);
|
|
1163
|
+
await serviceTx?.rollback(reason);
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
var Args = class {
|
|
1168
|
+
values = [];
|
|
1169
|
+
bind(value) {
|
|
1170
|
+
this.values.push(value);
|
|
1171
|
+
return `$${this.values.length}`;
|
|
1172
|
+
}
|
|
1173
|
+
};
|
|
1174
|
+
function isRef(v) {
|
|
1175
|
+
return typeof v === "object" && v !== null && "$ref" in v;
|
|
1176
|
+
}
|
|
1177
|
+
function isExpr(v) {
|
|
1178
|
+
return typeof v === "object" && v !== null && "$expr" in v;
|
|
1179
|
+
}
|
|
1180
|
+
function renderValue(value, column, args, results) {
|
|
1181
|
+
if (isRef(value)) {
|
|
1182
|
+
const source = results[value.$ref.op];
|
|
1183
|
+
const row = source?.rows[0];
|
|
1184
|
+
if (!row || !(value.$ref.field in row)) {
|
|
1185
|
+
throw Object.assign(new Error(`op ${value.$ref.op} has no column "${value.$ref.field}" to reference`), {
|
|
1186
|
+
error_code: "tx_ref_unresolved"
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
return args.bind(row[value.$ref.field]);
|
|
1190
|
+
}
|
|
1191
|
+
if (isExpr(value)) {
|
|
1192
|
+
const fn = value.$expr;
|
|
1193
|
+
if (fn.fn === "now") return "now()";
|
|
1194
|
+
const operator = fn.fn === "inc" ? "+" : "-";
|
|
1195
|
+
return `${quoteIdent(column)} ${operator} ${args.bind(fn.by)}`;
|
|
1196
|
+
}
|
|
1197
|
+
return args.bind(value);
|
|
1198
|
+
}
|
|
1199
|
+
function renderWhere(where, args, results) {
|
|
1200
|
+
const cols = Object.keys(where ?? {});
|
|
1201
|
+
if (cols.length === 0) return "";
|
|
1202
|
+
const terms = cols.map((c) => {
|
|
1203
|
+
const v = where[c];
|
|
1204
|
+
if (v === null) return `${quoteIdent(c)} IS NULL`;
|
|
1205
|
+
return `${quoteIdent(c)} = ${renderValue(v, c, args, results)}`;
|
|
1206
|
+
});
|
|
1207
|
+
return ` WHERE ${terms.join(" AND ")}`;
|
|
1208
|
+
}
|
|
1209
|
+
async function runPlanOp(sp, op, results) {
|
|
1210
|
+
const args = new Args();
|
|
1211
|
+
const table = quoteIdent(op.table);
|
|
1212
|
+
let sql;
|
|
1213
|
+
switch (op.op) {
|
|
1214
|
+
case "insert": {
|
|
1215
|
+
const cols = Object.keys(op.values ?? {});
|
|
1216
|
+
const rendered = cols.map((c) => renderValue(op.values[c], c, args, results));
|
|
1217
|
+
sql = cols.length ? `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES (${rendered.join(", ")}) RETURNING *` : `INSERT INTO ${table} DEFAULT VALUES RETURNING *`;
|
|
1218
|
+
break;
|
|
1219
|
+
}
|
|
1220
|
+
case "insertMany": {
|
|
1221
|
+
const rows = op.rows ?? [];
|
|
1222
|
+
if (rows.length === 0 || !rows[0]) return [];
|
|
1223
|
+
const cols = Object.keys(rows[0]);
|
|
1224
|
+
const tuples = rows.map(
|
|
1225
|
+
(r) => `(${cols.map((c) => renderValue(r[c], c, args, results)).join(", ")})`
|
|
1226
|
+
);
|
|
1227
|
+
sql = `INSERT INTO ${table} (${cols.map(quoteIdent).join(", ")}) VALUES ${tuples.join(", ")} RETURNING *`;
|
|
1228
|
+
break;
|
|
1229
|
+
}
|
|
1230
|
+
case "update": {
|
|
1231
|
+
const cols = Object.keys(op.set ?? {});
|
|
1232
|
+
if (cols.length === 0) throw new Error(`update ${op.table}: nothing to set`);
|
|
1233
|
+
const assignments = cols.map(
|
|
1234
|
+
(c) => `${quoteIdent(c)} = ${renderValue(op.set[c], c, args, results)}`
|
|
1235
|
+
);
|
|
1236
|
+
sql = `UPDATE ${table} SET ${assignments.join(", ")}${renderWhere(op.where, args, results)} RETURNING *`;
|
|
1237
|
+
break;
|
|
1238
|
+
}
|
|
1239
|
+
case "delete": {
|
|
1240
|
+
sql = `DELETE FROM ${table}${renderWhere(op.where, args, results)} RETURNING *`;
|
|
1241
|
+
break;
|
|
1242
|
+
}
|
|
1243
|
+
case "select": {
|
|
1244
|
+
const limit = op.limit !== void 0 ? ` LIMIT ${Number(op.limit)}` : "";
|
|
1245
|
+
const lock = op.lock === "update" ? " FOR UPDATE" : "";
|
|
1246
|
+
sql = `SELECT * FROM ${table}${renderWhere(op.where, args, results)}${limit}${lock}`;
|
|
1247
|
+
break;
|
|
1248
|
+
}
|
|
1249
|
+
default:
|
|
1250
|
+
throw new Error(`unknown operation "${String(op.op)}" in a transaction plan`);
|
|
1251
|
+
}
|
|
1252
|
+
return await sp.unsafe(sql, args.values);
|
|
1253
|
+
}
|
|
1254
|
+
function assertGuard(op, result) {
|
|
1255
|
+
const guard = op.guard;
|
|
1256
|
+
if (!guard) return;
|
|
1257
|
+
const n = result.rows.length;
|
|
1258
|
+
const ok = guard.kind === "one" ? n === 1 : guard.kind === "none" ? n === 0 : guard.kind === "atLeast" ? n >= guard.n : n <= guard.n;
|
|
1259
|
+
if (ok) return;
|
|
1260
|
+
throw Object.assign(new Error(`transaction expectation failed: ${guard.kind} (${n} row(s))`), {
|
|
1261
|
+
error_code: "tx_guard_failed",
|
|
1262
|
+
slot: guard.slot
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// src/decorators/registry.ts
|
|
1267
|
+
var ROUTES = /* @__PURE__ */ Symbol.for("palbase.backend.routes");
|
|
1268
|
+
var RETURN_BUFFER = /* @__PURE__ */ Symbol.for("palbase.backend.returnBuffer");
|
|
1269
|
+
var THROWS_BUFFER = /* @__PURE__ */ Symbol.for("palbase.backend.throwsBuffer");
|
|
1270
|
+
function carrierOf(target) {
|
|
1271
|
+
const ctor = typeof target === "function" ? target : target.constructor ?? target;
|
|
1272
|
+
return ctor;
|
|
1273
|
+
}
|
|
1274
|
+
function getRoutes(ctor) {
|
|
1275
|
+
const carrier = carrierOf(ctor);
|
|
1276
|
+
const routes = carrier[ROUTES] ?? [];
|
|
1277
|
+
const returnBuffer = carrier[RETURN_BUFFER];
|
|
1278
|
+
if (returnBuffer) {
|
|
1279
|
+
for (const route of routes) {
|
|
1280
|
+
const buffered = returnBuffer[route.fnName];
|
|
1281
|
+
if (buffered && route.returnSchema === void 0) {
|
|
1282
|
+
route.returnSchema = buffered;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
const throwsBuffer = carrier[THROWS_BUFFER];
|
|
1287
|
+
if (throwsBuffer) {
|
|
1288
|
+
for (const route of routes) {
|
|
1289
|
+
const buffered = throwsBuffer[route.fnName];
|
|
1290
|
+
if (buffered && route.throws === void 0) {
|
|
1291
|
+
route.throws = buffered;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
return routes.map((r) => ({
|
|
1296
|
+
...r,
|
|
1297
|
+
params: r.params.slice(),
|
|
1298
|
+
...r.throws !== void 0 ? { throws: r.throws.slice() } : {}
|
|
1299
|
+
}));
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// src/engine/router.ts
|
|
1303
|
+
var CONTROLLER_META = /* @__PURE__ */ Symbol.for("palbase.backend.controllerMeta");
|
|
1304
|
+
function toSegments(path) {
|
|
1305
|
+
return path.split("/").filter(Boolean).map((s) => s.startsWith("{") && s.endsWith("}") ? `:${s.slice(1, -1)}` : s);
|
|
1306
|
+
}
|
|
1307
|
+
function buildRouteTable(controllers) {
|
|
1308
|
+
const table = [];
|
|
1309
|
+
for (const Ctrl of controllers) {
|
|
1310
|
+
const ctor = Ctrl;
|
|
1311
|
+
const meta = ctor[CONTROLLER_META];
|
|
1312
|
+
const basePath = meta?.basePath ?? "";
|
|
1313
|
+
const routes = getRoutes(Ctrl);
|
|
1314
|
+
if (routes.length === 0) {
|
|
1315
|
+
const name = Ctrl.name ?? "<anonymous>";
|
|
1316
|
+
throw new Error(
|
|
1317
|
+
`controller ${name} collected zero routes. Either it declares no @Get/@Post/\u2026 , or its decorator metadata was erased at build time \u2014 check that the bundle was compiled with experimentalDecorators enabled.`
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
const instance = new ctor();
|
|
1321
|
+
for (const r of routes) {
|
|
1322
|
+
const full = `${basePath}${r.subpath ?? ""}` || "/";
|
|
1323
|
+
table.push({
|
|
1324
|
+
method: r.method,
|
|
1325
|
+
segments: toSegments(full),
|
|
1326
|
+
meta: r,
|
|
1327
|
+
instance,
|
|
1328
|
+
id: `${r.method} ${full}`,
|
|
1329
|
+
controllerAuth: meta?.defaultAuth
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
return table;
|
|
1334
|
+
}
|
|
1335
|
+
function matchRoute(table, method, pathname) {
|
|
1336
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
1337
|
+
for (const entry of table) {
|
|
1338
|
+
if (entry.method !== method || entry.segments.length !== parts.length) continue;
|
|
1339
|
+
const params = {};
|
|
1340
|
+
let ok = true;
|
|
1341
|
+
for (let i = 0; i < entry.segments.length; i++) {
|
|
1342
|
+
const seg = entry.segments[i];
|
|
1343
|
+
const got = parts[i];
|
|
1344
|
+
if (seg === void 0 || got === void 0) {
|
|
1345
|
+
ok = false;
|
|
1346
|
+
break;
|
|
1347
|
+
}
|
|
1348
|
+
if (seg.charCodeAt(0) === 58) {
|
|
1349
|
+
params[seg.slice(1)] = decodeURIComponent(got);
|
|
1350
|
+
} else if (seg !== got) {
|
|
1351
|
+
ok = false;
|
|
1352
|
+
break;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
if (ok) return { entry, params };
|
|
1356
|
+
}
|
|
1357
|
+
return null;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// src/engine/upload.ts
|
|
1361
|
+
var AUTHORIZE_PATH = "/__palbase/upload/authorize";
|
|
1362
|
+
var SIGNATURE_HEADER = "x-palbase-upload-signature";
|
|
1363
|
+
function renderPath(template, tokens) {
|
|
1364
|
+
return template.replaceAll("{userId}", sanitizeSegment(tokens.userId ?? "anonymous")).replaceAll("{uploadId}", sanitizeSegment(tokens.uploadId)).replaceAll("{filename}", sanitizeSegment(tokens.filename ?? "file"));
|
|
1365
|
+
}
|
|
1366
|
+
function sanitizeSegment(raw) {
|
|
1367
|
+
const cleaned = raw.replace(/[-]/g, "").replace(/[/\\]/g, "-").replace(/\.{2,}/g, ".").replace(/^\.+/, "").trim();
|
|
1368
|
+
return cleaned === "" ? "file" : cleaned.slice(0, 200);
|
|
1369
|
+
}
|
|
1370
|
+
function grantFor(entry, ctx, bucketLimits) {
|
|
1371
|
+
const cfg = entry?.meta?.options?.uploadConfig;
|
|
1372
|
+
if (!cfg) return null;
|
|
1373
|
+
return {
|
|
1374
|
+
bucket: cfg.bucket,
|
|
1375
|
+
path: renderPath(cfg.pathTemplate, {
|
|
1376
|
+
userId: ctx.userId,
|
|
1377
|
+
uploadId: ctx.uploadId,
|
|
1378
|
+
filename: ctx.filename
|
|
1379
|
+
}),
|
|
1380
|
+
maxBytes: bucketLimits?.maxBytes ?? null,
|
|
1381
|
+
mimeTypes: bucketLimits?.mimeTypes ?? null
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
var CompletionLedger = class {
|
|
1385
|
+
constructor(capacity = 1024) {
|
|
1386
|
+
this.capacity = capacity;
|
|
1387
|
+
}
|
|
1388
|
+
capacity;
|
|
1389
|
+
seen = /* @__PURE__ */ new Map();
|
|
1390
|
+
recall(uploadId) {
|
|
1391
|
+
return this.seen.get(uploadId);
|
|
1392
|
+
}
|
|
1393
|
+
remember(uploadId, response) {
|
|
1394
|
+
this.seen.delete(uploadId);
|
|
1395
|
+
this.seen.set(uploadId, response);
|
|
1396
|
+
while (this.seen.size > this.capacity) {
|
|
1397
|
+
const oldest = this.seen.keys().next();
|
|
1398
|
+
if (oldest.done) break;
|
|
1399
|
+
this.seen.delete(oldest.value);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
get size() {
|
|
1403
|
+
return this.seen.size;
|
|
1404
|
+
}
|
|
1405
|
+
};
|
|
1406
|
+
function verifySignature(presented, expected) {
|
|
1407
|
+
if (presented.length !== expected.length) return false;
|
|
1408
|
+
let diff = 0;
|
|
1409
|
+
for (let i = 0; i < presented.length; i++) {
|
|
1410
|
+
diff |= presented.charCodeAt(i) ^ expected.charCodeAt(i);
|
|
1411
|
+
}
|
|
1412
|
+
return diff === 0;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// src/engine/fence.ts
|
|
1416
|
+
var SECRET_ENV = [
|
|
1417
|
+
"DATABASE_URL",
|
|
1418
|
+
"PALBASE_SERVICE_ROLE_KEY",
|
|
1419
|
+
"REALTIME_INGESTION_SECRET",
|
|
1420
|
+
"INTERNAL_API_SECRET",
|
|
1421
|
+
"STACK_ROOT_KEY",
|
|
1422
|
+
"PEPPER",
|
|
1423
|
+
"LOCAL_JWT_PEM"
|
|
1424
|
+
];
|
|
1425
|
+
function scrubSecrets(env) {
|
|
1426
|
+
const removed = [];
|
|
1427
|
+
const kept = [];
|
|
1428
|
+
for (const name of SECRET_ENV) {
|
|
1429
|
+
if (env[name] === void 0) continue;
|
|
1430
|
+
delete env[name];
|
|
1431
|
+
removed.push(name);
|
|
1432
|
+
}
|
|
1433
|
+
for (const name of Object.keys(env)) if (name.startsWith("PALBASE_VAR_")) kept.push(name);
|
|
1434
|
+
return { removed, kept };
|
|
1435
|
+
}
|
|
1436
|
+
function hostAllowed(host, allow) {
|
|
1437
|
+
const h = host.toLowerCase();
|
|
1438
|
+
for (const raw of allow) {
|
|
1439
|
+
const pattern = raw.trim().toLowerCase();
|
|
1440
|
+
if (!pattern) continue;
|
|
1441
|
+
if (pattern === h) return true;
|
|
1442
|
+
if (pattern.startsWith("*.") && h.endsWith(pattern.slice(1)) && h.length > pattern.length - 1) {
|
|
1443
|
+
return true;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
return false;
|
|
1447
|
+
}
|
|
1448
|
+
function installEgressFence(policy) {
|
|
1449
|
+
const original = globalThis.fetch.bind(globalThis);
|
|
1450
|
+
const declared = policy.allow.length > 0;
|
|
1451
|
+
const platform = (policy.alwaysAllow ?? []).map((h) => h.toLowerCase()).filter(Boolean);
|
|
1452
|
+
if (!declared && policy.whenUndeclared === "allow") return original;
|
|
1453
|
+
const fenced = async (input, init) => {
|
|
1454
|
+
const url = typeof input === "string" ? new URL(input) : input instanceof URL ? input : new URL(input.url);
|
|
1455
|
+
if (platform.includes(url.hostname.toLowerCase())) return original(input, init);
|
|
1456
|
+
if (!declared || !hostAllowed(url.hostname, policy.allow)) {
|
|
1457
|
+
throw new Error(
|
|
1458
|
+
`egress denied: ${url.hostname} is not in this backend's declared allowlist. Add it to config/egress.ts and deploy.`
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
if (policy.timeoutMs > 0) {
|
|
1462
|
+
const controller = new AbortController();
|
|
1463
|
+
const timer = setTimeout(() => controller.abort(), policy.timeoutMs);
|
|
1464
|
+
try {
|
|
1465
|
+
return await original(input, { ...init, signal: init?.signal ?? controller.signal });
|
|
1466
|
+
} finally {
|
|
1467
|
+
clearTimeout(timer);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
return original(input, init);
|
|
1471
|
+
};
|
|
1472
|
+
globalThis.fetch = fenced;
|
|
1473
|
+
return original;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// src/engine/index.ts
|
|
1477
|
+
var JSON_HEADERS = { "content-type": "application/json" };
|
|
1478
|
+
function envelope(error, description, status, requestId, extra) {
|
|
1479
|
+
return new Response(
|
|
1480
|
+
JSON.stringify({ error, error_description: description, status, request_id: requestId, ...extra }),
|
|
1481
|
+
{ status, headers: JSON_HEADERS }
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
function unavailable(name) {
|
|
1485
|
+
throw new Error(
|
|
1486
|
+
`${name} is unavailable. Either this backend was started without module clients (set MODULE_BASE_URL and the API keys so the engine can reach the module surface), or ${name} is not one of the modules this backend provides.`
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
function stubModule(name) {
|
|
1490
|
+
return new Proxy(
|
|
1491
|
+
{},
|
|
1492
|
+
{
|
|
1493
|
+
get: () => unavailable(name),
|
|
1494
|
+
apply: () => unavailable(name)
|
|
1495
|
+
}
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
async function defaultSqlDriver(config) {
|
|
1499
|
+
const g = globalThis;
|
|
1500
|
+
if (!g.Bun?.SQL) {
|
|
1501
|
+
throw new BootRefused(
|
|
1502
|
+
[],
|
|
1503
|
+
"boot refused: no SQL driver. Running outside Bun means the driver must be supplied \u2014 pass `sql` to createApp()."
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
return new g.Bun.SQL({ url: config.databaseUrl, max: config.poolMax });
|
|
1507
|
+
}
|
|
1508
|
+
async function createApp(opts) {
|
|
1509
|
+
const { config, controllers } = opts;
|
|
1510
|
+
setSchema(opts.schema ?? {});
|
|
1511
|
+
const routes = buildRouteTable(controllers);
|
|
1512
|
+
if (routes.length === 0) {
|
|
1513
|
+
throw new BootRefused([], "boot refused: zero endpoints collected \u2014 nothing would answer.");
|
|
1514
|
+
}
|
|
1515
|
+
const sql = opts.sql ?? await defaultSqlDriver(config);
|
|
1516
|
+
await sql.unsafe("select 1");
|
|
1517
|
+
const auth = new AuthVerifier({ jwksUrl: config.authJwksUrl, issuer: config.authIssuer });
|
|
1518
|
+
const limiter = new RateLimiter();
|
|
1519
|
+
const cache = opts.cache ?? makeMemoryCache();
|
|
1520
|
+
const log = opts.logger ?? console;
|
|
1521
|
+
const modules = opts.modules ?? {};
|
|
1522
|
+
const runWithRuntime = opts.runtimeHooks?.__runWithRuntime ?? __runWithRuntime;
|
|
1523
|
+
const requestALS = opts.runtimeHooks?.__requestALS ?? __requestALS;
|
|
1524
|
+
const uploadSecret = config.uploadSecret ?? "";
|
|
1525
|
+
const completions = new CompletionLedger();
|
|
1526
|
+
async function handleAuthorize(req, requestId) {
|
|
1527
|
+
if (uploadSecret === "" || !verifySignature(req.headers.get(SIGNATURE_HEADER) ?? "", uploadSecret)) {
|
|
1528
|
+
return envelope("unauthorized", "This endpoint is not callable directly", 401, requestId);
|
|
1529
|
+
}
|
|
1530
|
+
const body = await req.json().catch(() => null);
|
|
1531
|
+
if (!body?.path || !body.uploadId) {
|
|
1532
|
+
return envelope("bad_request", "authorize needs a path and an uploadId", 400, requestId);
|
|
1533
|
+
}
|
|
1534
|
+
const target = matchRoute(routes, body.method ?? "POST", body.path);
|
|
1535
|
+
const spec = effectiveAuth(target?.entry.meta.options?.auth, target?.entry.controllerAuth);
|
|
1536
|
+
const callerClaims = await auth.verify(req.headers.get("authorization"));
|
|
1537
|
+
if (spec.required && !callerClaims) {
|
|
1538
|
+
return envelope("unauthorized", "A valid access token is required", 401, requestId);
|
|
1539
|
+
}
|
|
1540
|
+
if (callerClaims && spec.role && callerClaims.role !== spec.role) {
|
|
1541
|
+
return envelope("forbidden", `This endpoint requires the "${spec.role}" role`, 403, requestId);
|
|
1542
|
+
}
|
|
1543
|
+
const grant = grantFor(target?.entry, {
|
|
1544
|
+
userId: typeof callerClaims?.sub === "string" ? callerClaims.sub : null,
|
|
1545
|
+
uploadId: body.uploadId,
|
|
1546
|
+
filename: body.filename
|
|
1547
|
+
});
|
|
1548
|
+
if (!grant) {
|
|
1549
|
+
return envelope(
|
|
1550
|
+
"not_an_upload_route",
|
|
1551
|
+
`${body.method ?? "POST"} ${body.path} does not declare @Upload`,
|
|
1552
|
+
400,
|
|
1553
|
+
requestId
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
return new Response(JSON.stringify(grant), { status: 200, headers: JSON_HEADERS });
|
|
1557
|
+
}
|
|
1558
|
+
async function handle(req) {
|
|
1559
|
+
const requestId = `req_${crypto.randomUUID()}`;
|
|
1560
|
+
const url = new URL(req.url);
|
|
1561
|
+
if (url.pathname === AUTHORIZE_PATH) {
|
|
1562
|
+
return handleAuthorize(req, requestId);
|
|
1563
|
+
}
|
|
1564
|
+
const hit = matchRoute(routes, req.method, url.pathname);
|
|
1565
|
+
if (!hit) return envelope("not_found", "No route matches this method and path", 404, requestId);
|
|
1566
|
+
const { meta } = hit.entry;
|
|
1567
|
+
const spec = effectiveAuth(meta.options?.auth, hit.entry.controllerAuth);
|
|
1568
|
+
const claims = await auth.verify(req.headers.get("authorization"));
|
|
1569
|
+
if (spec.required && !claims) {
|
|
1570
|
+
return envelope("unauthorized", "A valid access token is required", 401, requestId);
|
|
1571
|
+
}
|
|
1572
|
+
const userId = typeof claims?.sub === "string" ? claims.sub : void 0;
|
|
1573
|
+
if (claims && spec.role && claims.role !== spec.role) {
|
|
1574
|
+
return envelope("forbidden", `This endpoint requires the "${spec.role}" role`, 403, requestId);
|
|
1575
|
+
}
|
|
1576
|
+
if (claims && spec.verifiedEmail && claims.email_verified !== true) {
|
|
1577
|
+
return envelope("email_not_verified", "A verified email address is required", 403, requestId);
|
|
1578
|
+
}
|
|
1579
|
+
const retryAfter = limiter.check(
|
|
1580
|
+
meta.options?.rateLimit,
|
|
1581
|
+
RateLimiter.key(hit.entry.id, userId, req.headers),
|
|
1582
|
+
Date.now()
|
|
1583
|
+
);
|
|
1584
|
+
if (retryAfter !== null) {
|
|
1585
|
+
return new Response(
|
|
1586
|
+
JSON.stringify({
|
|
1587
|
+
error: "too_many_requests",
|
|
1588
|
+
error_description: "Rate limit exceeded for this endpoint",
|
|
1589
|
+
status: 429,
|
|
1590
|
+
request_id: requestId
|
|
1591
|
+
}),
|
|
1592
|
+
{ status: 429, headers: { ...JSON_HEADERS, "retry-after": String(retryAfter) } }
|
|
1593
|
+
);
|
|
1594
|
+
}
|
|
1595
|
+
let completionUploadId = null;
|
|
1596
|
+
const args = [];
|
|
1597
|
+
let parsedBody;
|
|
1598
|
+
let bodyRead = false;
|
|
1599
|
+
for (const p of meta.params ?? []) {
|
|
1600
|
+
switch (p.kind) {
|
|
1601
|
+
case "body": {
|
|
1602
|
+
if (!bodyRead) {
|
|
1603
|
+
parsedBody = await req.json().catch(() => ({}));
|
|
1604
|
+
bodyRead = true;
|
|
1605
|
+
}
|
|
1606
|
+
const r = p.schema.safeParse(parsedBody);
|
|
1607
|
+
if (!r.success) {
|
|
1608
|
+
return envelope("bad_request", "Request body failed validation", 400, requestId, {
|
|
1609
|
+
fields: r.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
args[p.index] = r.data;
|
|
1613
|
+
break;
|
|
1614
|
+
}
|
|
1615
|
+
case "query": {
|
|
1616
|
+
const r = p.schema.safeParse(Object.fromEntries(url.searchParams));
|
|
1617
|
+
if (!r.success) {
|
|
1618
|
+
return envelope("bad_request", "Query parameters failed validation", 400, requestId, {
|
|
1619
|
+
fields: r.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
args[p.index] = r.data;
|
|
1623
|
+
break;
|
|
1624
|
+
}
|
|
1625
|
+
case "param":
|
|
1626
|
+
args[p.index] = hit.params[p.name];
|
|
1627
|
+
break;
|
|
1628
|
+
case "headers":
|
|
1629
|
+
args[p.index] = Object.fromEntries(req.headers);
|
|
1630
|
+
break;
|
|
1631
|
+
case "user":
|
|
1632
|
+
case "optionalUser":
|
|
1633
|
+
args[p.index] = claims ? {
|
|
1634
|
+
id: userId,
|
|
1635
|
+
email: claims.email,
|
|
1636
|
+
role: claims.role,
|
|
1637
|
+
emailVerified: claims.email_verified === true,
|
|
1638
|
+
metadata: claims.metadata ?? {}
|
|
1639
|
+
} : null;
|
|
1640
|
+
break;
|
|
1641
|
+
case "uploadedObject": {
|
|
1642
|
+
if (uploadSecret === "" || !verifySignature(req.headers.get(SIGNATURE_HEADER) ?? "", uploadSecret)) {
|
|
1643
|
+
return envelope(
|
|
1644
|
+
"unauthorized",
|
|
1645
|
+
"This endpoint accepts uploads through storage, not directly",
|
|
1646
|
+
401,
|
|
1647
|
+
requestId
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
if (!bodyRead) {
|
|
1651
|
+
parsedBody = await req.json().catch(() => ({}));
|
|
1652
|
+
bodyRead = true;
|
|
1653
|
+
}
|
|
1654
|
+
const envelopeIn = parsedBody;
|
|
1655
|
+
if (!envelopeIn?.uploadedObject) {
|
|
1656
|
+
return envelope("bad_request", "the completion call carried no uploaded object", 400, requestId);
|
|
1657
|
+
}
|
|
1658
|
+
const uploadId = envelopeIn.uploadedObject.uploadId;
|
|
1659
|
+
if (typeof uploadId === "string" && uploadId !== "") {
|
|
1660
|
+
const already = completions.recall(uploadId);
|
|
1661
|
+
if (already) {
|
|
1662
|
+
return new Response(already.body, {
|
|
1663
|
+
status: already.status,
|
|
1664
|
+
headers: already.contentType ? { "content-type": already.contentType } : void 0
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
completionUploadId = uploadId;
|
|
1668
|
+
}
|
|
1669
|
+
args[p.index] = envelopeIn.uploadedObject;
|
|
1670
|
+
parsedBody = envelopeIn.body ?? {};
|
|
1671
|
+
break;
|
|
1672
|
+
}
|
|
1673
|
+
case "requestId":
|
|
1674
|
+
args[p.index] = requestId;
|
|
1675
|
+
break;
|
|
1676
|
+
case "traceId":
|
|
1677
|
+
args[p.index] = requestId;
|
|
1678
|
+
break;
|
|
1679
|
+
case "req":
|
|
1680
|
+
args[p.index] = req;
|
|
1681
|
+
break;
|
|
1682
|
+
default:
|
|
1683
|
+
args[p.index] = void 0;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
const db = createRequestDatabase(sql, {
|
|
1687
|
+
role: config.dbRole,
|
|
1688
|
+
serviceRole: config.dbServiceRole,
|
|
1689
|
+
claimsJson: JSON.stringify(claims ?? {})
|
|
1690
|
+
});
|
|
1691
|
+
try {
|
|
1692
|
+
const services = {
|
|
1693
|
+
Database: db.client,
|
|
1694
|
+
Cache: cache,
|
|
1695
|
+
Log: log,
|
|
1696
|
+
Documents: modules.Documents ?? stubModule("Documents"),
|
|
1697
|
+
Storage: modules.Storage ?? stubModule("Storage"),
|
|
1698
|
+
Notifications: modules.Notifications ?? stubModule("Notifications"),
|
|
1699
|
+
Flags: modules.Flags ?? stubModule("Flags"),
|
|
1700
|
+
Realtime: modules.Realtime ?? stubModule("Realtime"),
|
|
1701
|
+
Purchases: modules.Purchases ?? stubModule("Purchases"),
|
|
1702
|
+
// Named, never undefined. A backend started without a secrets client
|
|
1703
|
+
// that returned `undefined` here would fail inside the handler as
|
|
1704
|
+
// "Cannot read properties of undefined", which says nothing about what
|
|
1705
|
+
// to configure — the stub says the name and the variable.
|
|
1706
|
+
Secrets: modules.Secrets ?? stubModule("Secrets")
|
|
1707
|
+
};
|
|
1708
|
+
const result = await runWithRuntime(services, () => {
|
|
1709
|
+
const box = requestALS.getStore();
|
|
1710
|
+
if (box) {
|
|
1711
|
+
box.userId = userId ?? null;
|
|
1712
|
+
box.requestId = requestId;
|
|
1713
|
+
box.idempotencyKey = req.headers.get("idempotency-key");
|
|
1714
|
+
}
|
|
1715
|
+
const method = hit.entry.instance[meta.fnName];
|
|
1716
|
+
if (typeof method !== "function") {
|
|
1717
|
+
throw new Error(
|
|
1718
|
+
`route ${hit.entry.id} names method ${meta.fnName}, which the controller does not define`
|
|
1719
|
+
);
|
|
1720
|
+
}
|
|
1721
|
+
return method.apply(hit.entry.instance, args);
|
|
1722
|
+
});
|
|
1723
|
+
await db.commit();
|
|
1724
|
+
if (meta.returnSchema) {
|
|
1725
|
+
const v = meta.returnSchema.safeParse(result);
|
|
1726
|
+
if (!v.success) {
|
|
1727
|
+
log.error(`[engine] ${hit.entry.id} returned a value its declared type rejects`, v.error.issues);
|
|
1728
|
+
return envelope(
|
|
1729
|
+
"output_invalid",
|
|
1730
|
+
"The handler returned a value its declared return type rejects",
|
|
1731
|
+
500,
|
|
1732
|
+
requestId
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
if (result === void 0 || result === null) {
|
|
1737
|
+
if (completionUploadId) {
|
|
1738
|
+
completions.remember(completionUploadId, { status: 204, body: null, contentType: null });
|
|
1739
|
+
}
|
|
1740
|
+
return new Response(null, { status: 204 });
|
|
1741
|
+
}
|
|
1742
|
+
const payload = JSON.stringify(result);
|
|
1743
|
+
if (completionUploadId) {
|
|
1744
|
+
completions.remember(completionUploadId, {
|
|
1745
|
+
status: 200,
|
|
1746
|
+
body: payload,
|
|
1747
|
+
contentType: JSON_HEADERS["content-type"] ?? "application/json"
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
return new Response(payload, { status: 200, headers: JSON_HEADERS });
|
|
1751
|
+
} catch (err) {
|
|
1752
|
+
await db.rollback(err);
|
|
1753
|
+
if (isHttpError(err)) {
|
|
1754
|
+
return envelope(
|
|
1755
|
+
err.error,
|
|
1756
|
+
err.errorDescription,
|
|
1757
|
+
err.status,
|
|
1758
|
+
requestId,
|
|
1759
|
+
err.data !== void 0 ? { data: err.data } : void 0
|
|
1760
|
+
);
|
|
1761
|
+
}
|
|
1762
|
+
log.error(`[engine] unhandled error in ${hit.entry.id}`, err);
|
|
1763
|
+
return envelope("internal_error", "The request could not be completed", 500, requestId);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
return {
|
|
1767
|
+
handle,
|
|
1768
|
+
routes,
|
|
1769
|
+
config,
|
|
1770
|
+
async shutdown() {
|
|
1771
|
+
const closable = sql;
|
|
1772
|
+
await closable.close?.();
|
|
1773
|
+
await closable.end?.();
|
|
1774
|
+
}
|
|
1775
|
+
};
|
|
1776
|
+
}
|
|
1777
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1778
|
+
0 && (module.exports = {
|
|
1779
|
+
AuthVerifier,
|
|
1780
|
+
BootRefused,
|
|
1781
|
+
RateLimiter,
|
|
1782
|
+
buildRouteTable,
|
|
1783
|
+
createApp,
|
|
1784
|
+
createLazyTransaction,
|
|
1785
|
+
createOps,
|
|
1786
|
+
createRequestDatabase,
|
|
1787
|
+
effectiveAuth,
|
|
1788
|
+
hostAllowed,
|
|
1789
|
+
installEgressFence,
|
|
1790
|
+
loadConfig,
|
|
1791
|
+
makeMemoryCache,
|
|
1792
|
+
matchRoute,
|
|
1793
|
+
quoteIdent,
|
|
1794
|
+
scrubSecrets,
|
|
1795
|
+
withTables
|
|
1796
|
+
});
|
|
1797
|
+
//# sourceMappingURL=index.cjs.map
|