@formancy/server 0.6.0 → 0.8.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/{completers-C-E2b7Jm.mjs → completers-CQQDS4qr.mjs} +12 -740
- package/dist/completers-CQQDS4qr.mjs.map +1 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/main.mjs +1 -281
- package/dist/main.mjs.map +1 -1
- package/package.json +4 -4
- package/dist/completers-C-E2b7Jm.mjs.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
-
import { AUDIT_ACTIONS, auditedBy, authenticateApiKey, authenticateLocal, can, collectAbandonedFiles, completeBuilderRequest, createApiKey, createLocalUser, createSubmission, decodeSolution, exportCsv, formToFill, healthOf, keepExamples, listForms, listSubmissions, listVersions, mintChallenge, offerUpload, publishForm, readExamples, replayDelivery, resolveForm, resumeDraft, saveDraft, screenUpload, setFormAccess, startDraft, verifySolution } from "@formancy/server-core";
|
|
2
|
+
import { AUDIT_ACTIONS, auditedBy, authenticateApiKey, authenticateLocal, can, collectAbandonedFiles, completeBuilderRequest, createApiKey, createLocalUser, createSubmission, decodeSolution, exportCsv, formToFill, formVersion, healthOf, keepExamples, listForms, listSubmissions, listVersions, mintChallenge, offerUpload, publishForm, readExamples, replayDelivery, resolveForm, resumeDraft, saveDraft, screenUpload, setFormAccess, startDraft, verifySolution } from "@formancy/server-core";
|
|
3
3
|
import { MODEL_REQUEST_KINDS } from "@formancy/builder-core";
|
|
4
4
|
import Fastify, { LogController } from "fastify";
|
|
5
5
|
import rateLimit from "@fastify/rate-limit";
|
|
@@ -16,51 +16,12 @@ import { dirname, join, resolve, sep } from "node:path";
|
|
|
16
16
|
import Anthropic, { APIError } from "@anthropic-ai/sdk";
|
|
17
17
|
import OpenAI, { APIError as APIError$1 } from "openai";
|
|
18
18
|
//#region src/header-values.ts
|
|
19
|
-
/**
|
|
20
|
-
* A response header's value made from data: a name somebody else chose
|
|
21
|
-
* ([0175](../../../docs/decisions/0175-a-name-goes-into-a-header-encoded.md)).
|
|
22
|
-
*
|
|
23
|
-
* Node writes a header only when every character of its value is a tab, printable ASCII or one
|
|
24
|
-
* of the bytes 0x80 to 0xFF, and throws `ERR_INVALID_CHAR` for anything else — when the reply is
|
|
25
|
-
* written, after the route has answered. Nothing about a file's name or a form's path keeps it
|
|
26
|
-
* inside that: a respondent names a file `日本 €.pdf`, an author publishes a path with a line
|
|
27
|
-
* break in it. And the bytes 0x80 to 0xFF it does accept are no better: the server sent `é` as one
|
|
28
|
-
* byte before a stream and as two, its UTF-8, before a string body, so a client read `café` on a
|
|
29
|
-
* download and `café` on an export.
|
|
30
|
-
*
|
|
31
|
-
* So a value is made here, from the data, in printable ASCII only, and a route writes what this
|
|
32
|
-
* returns rather than a template of its own. `header-values.test.ts` holds every export to
|
|
33
|
-
* every UTF-16 code unit, and reads the server's sources for a header written any other way.
|
|
34
|
-
*/
|
|
35
|
-
/**
|
|
36
|
-
* `content-disposition` for a download, by RFC 6266: `attachment`, and the name twice when
|
|
37
|
-
* ASCII cannot carry it — once readable, once exact.
|
|
38
|
-
*
|
|
39
|
-
* `filename` is a quoted string of printable ASCII, with every character it cannot hold as
|
|
40
|
-
* itself made `_`: anything outside it, the quote that would end it, the backslash a reader may
|
|
41
|
-
* or may not take as an escape, and the percent sign RFC 6266 (appendix D) says some readers
|
|
42
|
-
* decode. `filename*` is RFC 8187's extended value, the name's UTF-8 percent-encoded, which
|
|
43
|
-
* RFC 6266 has a reader prefer when both are there. It is added only when the readable one
|
|
44
|
-
* lost something, so an ordinary name keeps the header it always had.
|
|
45
|
-
*
|
|
46
|
-
* The name is not cleaned of anything: a control character or a path separator goes to the
|
|
47
|
-
* reader's browser encoded, and making a file name its system can hold is the browser's, as it
|
|
48
|
-
* is for every download.
|
|
49
|
-
*/
|
|
50
19
|
function attachment(filename) {
|
|
51
20
|
const readable = filename.replace(/[^\x20-\x7e]|["\\%]/gu, "_");
|
|
52
21
|
if (readable === filename) return `attachment; filename="${readable}"`;
|
|
53
22
|
return `attachment; filename="${readable}"; filename*=UTF-8''${percentEncoded(filename)}`;
|
|
54
23
|
}
|
|
55
|
-
/** RFC 8187's attr-char: what an extended value may hold as itself. */
|
|
56
24
|
const ATTR_CHAR = /^[A-Za-z0-9!#$&+\-.^_`|~]$/;
|
|
57
|
-
/**
|
|
58
|
-
* `text` as UTF-8, each byte that is not an attr-char written `%XX`.
|
|
59
|
-
*
|
|
60
|
-
* Through a `Buffer` rather than `encodeURIComponent`, which leaves `'`, `(`, `)` and `*` as they
|
|
61
|
-
* are — and a `'` ends an extended value's charset — and throws on a lone surrogate, which a
|
|
62
|
-
* JSON body can carry. A `Buffer` writes one as U+FFFD.
|
|
63
|
-
*/
|
|
64
25
|
function percentEncoded(text) {
|
|
65
26
|
let encoded = "";
|
|
66
27
|
for (const byte of Buffer.from(text, "utf8")) {
|
|
@@ -71,14 +32,6 @@ function percentEncoded(text) {
|
|
|
71
32
|
}
|
|
72
33
|
//#endregion
|
|
73
34
|
//#region src/rate-limits.ts
|
|
74
|
-
/**
|
|
75
|
-
* A route's `config.rateLimit`.
|
|
76
|
-
*
|
|
77
|
-
* The failure mode is an argument, so no limit can be written without saying it; left to the
|
|
78
|
-
* plugin, it is an error, which a route answers as a 500 nobody chose. And the window is
|
|
79
|
-
* translated here, once: `timeWindowMs` is this app's name for it and the plugin does not
|
|
80
|
-
* read it, which one route found out by silently taking the global window instead.
|
|
81
|
-
*/
|
|
82
35
|
function limited(budget, whenUncounted) {
|
|
83
36
|
return {
|
|
84
37
|
max: budget.max,
|
|
@@ -88,18 +41,7 @@ function limited(budget, whenUncounted) {
|
|
|
88
41
|
}
|
|
89
42
|
//#endregion
|
|
90
43
|
//#region src/routes/files.ts
|
|
91
|
-
/**
|
|
92
|
-
* How long one request holds a file while its bytes are scanned and written (0153).
|
|
93
|
-
*
|
|
94
|
-
* A judgement, not a bound the adapters guarantee. The object store this package supplies
|
|
95
|
-
* abandons a PUT after 30 seconds, as a whole; its clamd adapter's 30 seconds are of silence
|
|
96
|
-
* — any traffic on the connection restarts them — so nothing here limits how long a scan
|
|
97
|
-
* takes. Longer makes an overrun rarer, and keeps a PUT of the same file busy for longer
|
|
98
|
-
* after a request that died holding it. A request that does overrun it can still replace a
|
|
99
|
-
* claimed file's bytes, and `docs/architecture/11-risks-and-debt.md` says what that costs.
|
|
100
|
-
*/
|
|
101
44
|
const RECEIVE_LEASE_MS = 12e4;
|
|
102
|
-
/** A request whose lease ran out is not accepted, whichever check found it. */
|
|
103
45
|
const OUTLASTED = {
|
|
104
46
|
status: 409,
|
|
105
47
|
body: {
|
|
@@ -107,22 +49,9 @@ const OUTLASTED = {
|
|
|
107
49
|
message: "This upload took longer than the server holds a file for one request, so it was not accepted. Try again."
|
|
108
50
|
}
|
|
109
51
|
};
|
|
110
|
-
/**
|
|
111
|
-
* A form's files: offering one, receiving its bytes, and serving it back.
|
|
112
|
-
*
|
|
113
|
-
* One route family per plugin, the seam `app.ts`'s size budget names. It moved when
|
|
114
|
-
* scanning an upload's bytes needed lines that file had no room for.
|
|
115
|
-
*/
|
|
116
52
|
async function fileRoutes(app, { deps, fileStore, maxFileBytes, limit, actorOf }) {
|
|
117
53
|
const { storage } = deps;
|
|
118
54
|
const submissionLimit = limit;
|
|
119
|
-
/**
|
|
120
|
-
* Ask where to put a file.
|
|
121
|
-
*
|
|
122
|
-
* Refusing here, before a byte is sent, is the difference between rejecting
|
|
123
|
-
* a 2 GB upload and receiving one first. The reply carries the id the
|
|
124
|
-
* submission will reference and the URL to PUT the bytes to.
|
|
125
|
-
*/
|
|
126
55
|
app.post("/f/:path/files", { config: { rateLimit: limited(submissionLimit, "admit") } }, async (request, reply) => {
|
|
127
56
|
if (fileStore === void 0) return reply.code(501).send({
|
|
128
57
|
error: "uploads_unavailable",
|
|
@@ -179,17 +108,6 @@ async function fileRoutes(app, { deps, fileStore, maxFileBytes, limit, actorOf }
|
|
|
179
108
|
uploadUrl: `/f/${encodeURIComponent(path)}/files/${outcome.file.id}`
|
|
180
109
|
});
|
|
181
110
|
});
|
|
182
|
-
/**
|
|
183
|
-
* Receive the bytes for a file that was offered.
|
|
184
|
-
*
|
|
185
|
-
* In a context of its own, with every body parser removed and one put back that hands
|
|
186
|
-
* any body over as bytes. A file is whatever the reader had — the form's `accept` list
|
|
187
|
-
* decides whether it is allowed, at the offer — and Fastify parses `text/plain` and
|
|
188
|
-
* `application/json` itself: a catch-all added beside those only ever saw the types with
|
|
189
|
-
* no parser of their own, so a `.txt` or `.json` file arrived here as a string or an
|
|
190
|
-
* object and was refused as no body. Encapsulated, so that no other route is handed raw
|
|
191
|
-
* bytes, nor a body as large as a file past the request body cap.
|
|
192
|
-
*/
|
|
193
111
|
await app.register(async (byteRoutes) => {
|
|
194
112
|
byteRoutes.removeAllContentTypeParsers();
|
|
195
113
|
byteRoutes.addContentTypeParser("*", {
|
|
@@ -227,15 +145,6 @@ async function fileRoutes(app, { deps, fileStore, maxFileBytes, limit, actorOf }
|
|
|
227
145
|
return reply.code(answer.status).send(answer.body);
|
|
228
146
|
});
|
|
229
147
|
});
|
|
230
|
-
/**
|
|
231
|
-
* Give a file back after a request that kept nothing, without letting the release decide
|
|
232
|
-
* the reply.
|
|
233
|
-
*
|
|
234
|
-
* A release that fails is most likely the database the request has already failed on, and
|
|
235
|
-
* its error would stand in for the one that says why the bytes were not kept — or turn a
|
|
236
|
-
* scanner's refusal into a 500. The lease runs out on its own, so all a failed release
|
|
237
|
-
* costs is a `PUT` of this same file being busy until it does.
|
|
238
|
-
*/
|
|
239
148
|
async function giveBack(id, lease, log) {
|
|
240
149
|
await storage.releaseFile(id, lease).catch((cause) => {
|
|
241
150
|
log.error({
|
|
@@ -244,11 +153,6 @@ async function fileRoutes(app, { deps, fileStore, maxFileBytes, limit, actorOf }
|
|
|
244
153
|
});
|
|
245
154
|
});
|
|
246
155
|
}
|
|
247
|
-
/**
|
|
248
|
-
* Scan a file's bytes, write them and settle its row, for a request holding its lease.
|
|
249
|
-
*
|
|
250
|
-
* Returns the answer rather than sending it, so the route can give the lease back first.
|
|
251
|
-
*/
|
|
252
156
|
async function receive(store, file, bytes, lease, log) {
|
|
253
157
|
const screened = await screenUpload(deps.scanner, file, bytes);
|
|
254
158
|
if (!screened.ok) {
|
|
@@ -276,16 +180,6 @@ async function fileRoutes(app, { deps, fileStore, maxFileBytes, limit, actorOf }
|
|
|
276
180
|
if (await storage.settleFile(file.id, lease)) return { status: 204 };
|
|
277
181
|
return OUTLASTED;
|
|
278
182
|
}
|
|
279
|
-
/**
|
|
280
|
-
* Serve a file back.
|
|
281
|
-
*
|
|
282
|
-
* Always as an attachment, never inline, and `nosniff` alongside it. Stored
|
|
283
|
-
* cross-site scripting through an uploaded HTML or SVG file is the most
|
|
284
|
-
* commonly exploited vulnerability in this product category, and the real
|
|
285
|
-
* answer is a separate hostname — which a single-container deployment does
|
|
286
|
-
* not have. Forcing a download is the accommodation, and it is the one place
|
|
287
|
-
* this deployment model genuinely costs something.
|
|
288
|
-
*/
|
|
289
183
|
app.get("/f/:path/files/:id", async (request, reply) => {
|
|
290
184
|
if (fileStore === void 0) return reply.code(501).send({ error: "uploads_unavailable" });
|
|
291
185
|
const { path, id } = request.params;
|
|
@@ -300,31 +194,11 @@ async function fileRoutes(app, { deps, fileStore, maxFileBytes, limit, actorOf }
|
|
|
300
194
|
}
|
|
301
195
|
//#endregion
|
|
302
196
|
//#region src/headers.ts
|
|
303
|
-
/**
|
|
304
|
-
* The header a client declares what it was looking at with.
|
|
305
|
-
*
|
|
306
|
-
* One name, read by the submission route and by the publish plugin: a
|
|
307
|
-
* submission says which version it rendered, and a publish says which version it
|
|
308
|
-
* opened. Two spellings of one header is a bug nobody sees until a client sends
|
|
309
|
-
* the other one.
|
|
310
|
-
*/
|
|
311
197
|
const SCHEMA_HASH_HEADER = "x-formancy-schema-hash";
|
|
312
|
-
/** A solved proof-of-work challenge, as base64 JSON: the shape an ALTCHA client already produces. */
|
|
313
198
|
const CHALLENGE_HEADER = "x-formancy-challenge";
|
|
314
|
-
/**
|
|
315
|
-
* The token a response is sent with: handed out with the form, and with a draft, naming the id
|
|
316
|
-
* the response is stored under (0169). Required of an anonymous submission.
|
|
317
|
-
*/
|
|
318
199
|
const SUBMISSION_TOKEN_HEADER = "x-formancy-submission-token";
|
|
319
200
|
//#endregion
|
|
320
201
|
//#region src/routes/publish.ts
|
|
321
|
-
/**
|
|
322
|
-
* Publishing a form, as a plugin of its own.
|
|
323
|
-
*
|
|
324
|
-
* One route family per plugin, which is Fastify's own unit and what `app.ts`'s
|
|
325
|
-
* size budget named as the seam. It moved when the budget refused the next thing
|
|
326
|
-
* added to it rather than letting the number drift.
|
|
327
|
-
*/
|
|
328
202
|
async function publishRoutes(app, { deps, requires }) {
|
|
329
203
|
app.post("/forms", { preHandler: requires("form.publish") }, async (request, reply) => {
|
|
330
204
|
const body = request.body;
|
|
@@ -379,14 +253,6 @@ async function publishRoutes(app, { deps, requires }) {
|
|
|
379
253
|
}
|
|
380
254
|
//#endregion
|
|
381
255
|
//#region src/routes/access.ts
|
|
382
|
-
/**
|
|
383
|
-
* Who may submit a form, and from where: a property of the deployment, not of the document,
|
|
384
|
-
* so changing it mints no version and leaves every client's schema hash standing
|
|
385
|
-
* ([0044](../../../../docs/decisions/0044-access-outside-the-document.md)).
|
|
386
|
-
*
|
|
387
|
-
* One route family per plugin, the seam `app.ts`'s size budget names. It left `app.ts` when
|
|
388
|
-
* the examples kept beside a form needed a plugin registered there (0166).
|
|
389
|
-
*/
|
|
390
256
|
async function accessRoutes(app, { deps, requires, audit }) {
|
|
391
257
|
app.put("/f/:path/access", { preHandler: requires("form.publish") }, async (request, reply) => {
|
|
392
258
|
const { path } = request.params;
|
|
@@ -419,17 +285,6 @@ async function accessRoutes(app, { deps, requires, audit }) {
|
|
|
419
285
|
}
|
|
420
286
|
//#endregion
|
|
421
287
|
//#region src/routes/examples.ts
|
|
422
|
-
/**
|
|
423
|
-
* A form's examples, and the sample they start from, kept beside the form (0166).
|
|
424
|
-
*
|
|
425
|
-
* `GET /f/:path/examples` answers `{ scenarios, sample? }`, with `unreadable` beside them when
|
|
426
|
-
* something kept is not an example, and `PUT` replaces both with `{ scenarios, sample? }`. The routes authenticate and carry: who may read and change them, what an
|
|
427
|
-
* example is, and what a publish says about them are `server-core`'s, so the 403 here is the
|
|
428
|
-
* use-case's answer and not a `requires` of the route's own. Publishing needs nothing from
|
|
429
|
-
* here — `publishForm` reads the examples itself, and its warnings ride on the `201`.
|
|
430
|
-
*
|
|
431
|
-
* One route family per plugin, the seam `app.ts`'s size budget names.
|
|
432
|
-
*/
|
|
433
288
|
async function exampleRoutes(app, { deps, actorOf }) {
|
|
434
289
|
app.get("/f/:path/examples", async (request, reply) => {
|
|
435
290
|
const actor = await actorOf(request);
|
|
@@ -452,7 +307,6 @@ async function exampleRoutes(app, { deps, actorOf }) {
|
|
|
452
307
|
}));
|
|
453
308
|
});
|
|
454
309
|
}
|
|
455
|
-
/** An outcome as a response: the examples as kept, or the refusal, in the plane's shapes. */
|
|
456
310
|
function answer$1(reply, outcome) {
|
|
457
311
|
if (outcome.ok) return reply.send(outcome.examples);
|
|
458
312
|
switch (outcome.kind) {
|
|
@@ -469,23 +323,7 @@ function answer$1(reply, outcome) {
|
|
|
469
323
|
}
|
|
470
324
|
//#endregion
|
|
471
325
|
//#region src/routes/deliveries.ts
|
|
472
|
-
/**
|
|
473
|
-
* Webhooks as an operator sees them: their health, the deliveries that died, and
|
|
474
|
-
* sending one of those again.
|
|
475
|
-
*
|
|
476
|
-
* One route family per plugin, the seam `app.ts`'s size budget names. It moved
|
|
477
|
-
* when that file needed room to say which proxy it believes about a client.
|
|
478
|
-
*/
|
|
479
326
|
async function deliveryRoutes(app, { deps, requires, audit }) {
|
|
480
|
-
/**
|
|
481
|
-
* Webhook health, and the deliveries that died.
|
|
482
|
-
*
|
|
483
|
-
* The reason the breaker keeps its counters on the row rather than in the
|
|
484
|
-
* worker's memory. A self-hoster has no operations team watching a
|
|
485
|
-
* dashboard, so a destination refusing deliveries since Tuesday has to be
|
|
486
|
-
* answerable from the product — otherwise it is found when somebody asks
|
|
487
|
-
* why the CRM has no leads this week.
|
|
488
|
-
*/
|
|
489
327
|
app.get("/webhooks", { preHandler: requires("form.publish") }, async (_request, reply) => {
|
|
490
328
|
const now = new Date(deps.nowIso());
|
|
491
329
|
const hooks = await deps.storage.listWebhooks();
|
|
@@ -529,30 +367,8 @@ async function deliveryRoutes(app, { deps, requires, audit }) {
|
|
|
529
367
|
}
|
|
530
368
|
//#endregion
|
|
531
369
|
//#region src/routes/drafts.ts
|
|
532
|
-
/** Proves the bearer started this draft. Lower-case: Fastify normalises. */
|
|
533
370
|
const DRAFT_TOKEN_HEADER = "x-formancy-draft-token";
|
|
534
|
-
/**
|
|
535
|
-
* A respondent's draft: started, written and read back, each with the key only its starter
|
|
536
|
-
* holds ([0062](../../../../docs/decisions/0062-a-draft-carries-its-own-key.md)).
|
|
537
|
-
*
|
|
538
|
-
* One route family per plugin, the seam `app.ts`'s size budget names. It moved when the
|
|
539
|
-
* deployment's model needed lines that file had no room for.
|
|
540
|
-
*/
|
|
541
371
|
async function draftRoutes(app, { deps, limit }) {
|
|
542
|
-
/**
|
|
543
|
-
* Start a draft, and get the only key to it.
|
|
544
|
-
*
|
|
545
|
-
* The public plane is anonymous, so a draft has no account behind it. It was
|
|
546
|
-
* previously addressed by an id the CALLER chose, with no check on either
|
|
547
|
-
* route: anybody who knew or guessed an id could read a stranger's part-filled
|
|
548
|
-
* form, and overwrite it — after which that person submits the substituted
|
|
549
|
-
* content under their own name and nothing says otherwise
|
|
550
|
-
* ([0062](../../../../docs/decisions/0062-a-draft-carries-its-own-key.md)).
|
|
551
|
-
*
|
|
552
|
-
* So the server picks the id and signs it, and the token comes back exactly
|
|
553
|
-
* once. Losing it means losing the draft, which is the correct trade for the
|
|
554
|
-
* alternative.
|
|
555
|
-
*/
|
|
556
372
|
app.post("/f/:path/drafts", { config: { rateLimit: limited(limit, "admit") } }, async (request, reply) => {
|
|
557
373
|
const { path } = request.params;
|
|
558
374
|
const started = await startDraft(deps, { path });
|
|
@@ -588,31 +404,7 @@ async function draftRoutes(app, { deps, limit }) {
|
|
|
588
404
|
}
|
|
589
405
|
//#endregion
|
|
590
406
|
//#region src/routes/model.ts
|
|
591
|
-
/**
|
|
592
|
-
* The largest request the route reads, in bytes, set on the route rather than inherited.
|
|
593
|
-
*
|
|
594
|
-
* Every byte is an input token the operator pays for, so the cap is the route's own:
|
|
595
|
-
* raising the server's limit for something else does not raise what an editor can send a
|
|
596
|
-
* model. It has to hold what the builders send about a form the server publishes, and the
|
|
597
|
-
* largest of that is a translation, not the form: each answer's row repeats the question
|
|
598
|
-
* it answers as its context, pretty-printed and escaped again as a string.
|
|
599
|
-
* `model-route.test.ts` sends all three requests about the largest questionnaire the server's
|
|
600
|
-
* default limit publishes, its questions a sentence long, and they are taken. Longer
|
|
601
|
-
* questions over many answers make a translation past this, refused before it is read (0165).
|
|
602
|
-
*/
|
|
603
407
|
const MODEL_BODY_LIMIT_BYTES = 1048576;
|
|
604
|
-
/**
|
|
605
|
-
* The builders' requests to the deployment's model (0165).
|
|
606
|
-
*
|
|
607
|
-
* `GET /model` says whether there is one, and which, to somebody who may edit a form —
|
|
608
|
-
* `{ provider, model }`, both `null` when there is none (0183) — and the admin draws the
|
|
609
|
-
* prompt pane only when there is. `POST /model/complete` takes
|
|
610
|
-
* `{ kind, user }` and answers `{ text }` — the model's answer, for the browser to check
|
|
611
|
-
* — or `{ declined }` when the provider refused, which the admin's `AskModel` hands back as
|
|
612
|
-
* a decline (0158). Everything else is an error with a sentence for the person.
|
|
613
|
-
*
|
|
614
|
-
* One route family per plugin, the seam `app.ts`'s size budget names.
|
|
615
|
-
*/
|
|
616
408
|
async function modelRoutes(app, { model, requires, audit, limit }) {
|
|
617
409
|
app.get("/model", { preHandler: requires("form.publish") }, async (_request, reply) => reply.send(model === void 0 ? {
|
|
618
410
|
provider: null,
|
|
@@ -650,16 +442,6 @@ async function modelRoutes(app, { model, requires, audit, limit }) {
|
|
|
650
442
|
return answer(reply, request, outcome);
|
|
651
443
|
});
|
|
652
444
|
}
|
|
653
|
-
/**
|
|
654
|
-
* The browser going away, as a cancellation: the response closing before it was written.
|
|
655
|
-
*
|
|
656
|
-
* Every response closes, so one that finished is not a browser that left; only a close
|
|
657
|
-
* with nothing written yet tells the adapter to abandon the request at the provider.
|
|
658
|
-
*
|
|
659
|
-
* A browser can also have gone before the handler ran, while the session or an API key was
|
|
660
|
-
* still being checked. That response has closed already and will not close again, so it
|
|
661
|
-
* is read as gone from the start, and the adapter is told at once — before it sends.
|
|
662
|
-
*/
|
|
663
445
|
function cancelledWith(reply) {
|
|
664
446
|
let gone = reply.raw.destroyed && !reply.raw.writableFinished;
|
|
665
447
|
const listeners = [];
|
|
@@ -673,7 +455,6 @@ function cancelledWith(reply) {
|
|
|
673
455
|
else listeners.push(listener);
|
|
674
456
|
} };
|
|
675
457
|
}
|
|
676
|
-
/** An outcome as a response, every failure with a sentence for the person reading it. */
|
|
677
458
|
function answer(reply, request, outcome) {
|
|
678
459
|
if (outcome.ok) return reply.send({ text: outcome.text });
|
|
679
460
|
switch (outcome.failure) {
|
|
@@ -702,7 +483,6 @@ function answer(reply, request, outcome) {
|
|
|
702
483
|
});
|
|
703
484
|
}
|
|
704
485
|
}
|
|
705
|
-
/** What a provider's status means for the person whose turn it ended. */
|
|
706
486
|
function unavailableSentence(status) {
|
|
707
487
|
if (status === 401 || status === 403) return "The model service refused this server’s key.";
|
|
708
488
|
if (status === 429) return "The model service is limiting this server’s requests. Try again in a while.";
|
|
@@ -711,35 +491,8 @@ function unavailableSentence(status) {
|
|
|
711
491
|
}
|
|
712
492
|
//#endregion
|
|
713
493
|
//#region src/routes/submissions.ts
|
|
714
|
-
/**
|
|
715
|
-
* What a respondent's browser does with a form: read it, ask for a challenge, and submit it.
|
|
716
|
-
*
|
|
717
|
-
* One route family per plugin, the seam `app.ts`'s size budget names. It moved when the
|
|
718
|
-
* submission token needed lines that file had no room for.
|
|
719
|
-
*/
|
|
720
494
|
async function submissionRoutes(app, { deps, challengeSecret, limit, actorOf }) {
|
|
721
495
|
const submissionLimit = limit;
|
|
722
|
-
/**
|
|
723
|
-
* Fetch a published form. The one public route deliberately NOT rate-limited.
|
|
724
|
-
*
|
|
725
|
-
* Every other unauthenticated route is: submitting, starting a draft, writing
|
|
726
|
-
* one, reading one, minting a challenge, asking for an upload target. Those are
|
|
727
|
-
* writes, or guesses, or work somebody can demand of the server. This is the
|
|
728
|
-
* read every visitor has to make before they can do anything at all.
|
|
729
|
-
*
|
|
730
|
-
* Limiting it by IP would refuse the form to real people sharing an address —
|
|
731
|
-
* an office, a school, a phone network behind CGNAT — and the failure would
|
|
732
|
-
* look like the form being broken rather than like a limit. That is a worse
|
|
733
|
-
* outcome than the cheap read it would prevent.
|
|
734
|
-
*
|
|
735
|
-
* Written down because the asymmetry is deliberate and looks like an omission.
|
|
736
|
-
*
|
|
737
|
-
* It also hands out the token the response is sent with, and that changes neither half of
|
|
738
|
-
* the argument: minting one is an HMAC and writes nothing, and it is not a defence against
|
|
739
|
-
* anybody who can read the form, so limiting it would buy nothing (0169). What it does change
|
|
740
|
-
* is that the reply is per reading: `no-store`, because a cache that kept it would hand one
|
|
741
|
-
* token to everybody behind it, and only the first of them could send a response.
|
|
742
|
-
*/
|
|
743
496
|
app.get("/f/:path", async (request, reply) => {
|
|
744
497
|
const { path } = request.params;
|
|
745
498
|
const form = await formToFill(deps, path);
|
|
@@ -751,13 +504,6 @@ async function submissionRoutes(app, { deps, challengeSecret, limit, actorOf })
|
|
|
751
504
|
submissionToken: form.submissionToken
|
|
752
505
|
});
|
|
753
506
|
});
|
|
754
|
-
/**
|
|
755
|
-
* A puzzle for an anonymous visitor to solve before submitting.
|
|
756
|
-
*
|
|
757
|
-
* Public because the form is: requiring a session to obtain a challenge for
|
|
758
|
-
* an unauthenticated submission would be a circle. It is cheap to mint and
|
|
759
|
-
* stateless, so handing one out costs this server a hash.
|
|
760
|
-
*/
|
|
761
507
|
app.get("/f/:path/challenge", { config: { rateLimit: limited(submissionLimit, "admit") } }, async (request, reply) => {
|
|
762
508
|
if (challengeSecret === void 0) return reply.code(404).send({ error: "challenge_not_enabled" });
|
|
763
509
|
const { path } = request.params;
|
|
@@ -829,15 +575,6 @@ async function submissionRoutes(app, { deps, challengeSecret, limit, actorOf })
|
|
|
829
575
|
}
|
|
830
576
|
});
|
|
831
577
|
}
|
|
832
|
-
/**
|
|
833
|
-
* Check a submitted solution, and spend it.
|
|
834
|
-
*
|
|
835
|
-
* Returns the refusal to send, or undefined when it passed. The spend is
|
|
836
|
-
* separate from the verification and comes last: the stateless checks say
|
|
837
|
-
* the solution is correct, and only the database can say it has not been
|
|
838
|
-
* used — a correct solution stays correct, so without this one puzzle
|
|
839
|
-
* would buy a thousand submissions.
|
|
840
|
-
*/
|
|
841
578
|
async function checkChallenge(deps, challengeSecret, request) {
|
|
842
579
|
const header = request.headers[CHALLENGE_HEADER];
|
|
843
580
|
if (typeof header !== "string" || header === "") return {
|
|
@@ -862,17 +599,6 @@ async function checkChallenge(deps, challengeSecret, request) {
|
|
|
862
599
|
}
|
|
863
600
|
//#endregion
|
|
864
601
|
//#region src/auth-runtime.ts
|
|
865
|
-
/**
|
|
866
|
-
* The real cryptography, confined to the composition root: argon2id for
|
|
867
|
-
* secrets at rest, an HMAC-signed short-lived access token for sessions, and
|
|
868
|
-
* a CSPRNG for API keys. server-core never sees any of this — it is handed
|
|
869
|
-
* hash/verify/random functions and stays deterministic under test.
|
|
870
|
-
*
|
|
871
|
-
* HS256 rather than EdDSA for the session token, deliberately for this phase:
|
|
872
|
-
* one secret in one env var is the self-hosting story a single-container
|
|
873
|
-
* deployment can operate, and the signing scheme is an implementation detail
|
|
874
|
-
* of a token that lives fifteen minutes. Revisit with key rotation.
|
|
875
|
-
*/
|
|
876
602
|
function realSecretHashing() {
|
|
877
603
|
return {
|
|
878
604
|
hashSecret: (secret) => hash(secret),
|
|
@@ -918,40 +644,12 @@ function createSessionTokens(secret) {
|
|
|
918
644
|
}
|
|
919
645
|
//#endregion
|
|
920
646
|
//#region src/upload-settings.ts
|
|
921
|
-
/**
|
|
922
|
-
* How large a file this deployment accepts.
|
|
923
|
-
*
|
|
924
|
-
* Its own module because `main.ts` is a composition root that connects to a database
|
|
925
|
-
* when it is imported, so nothing in it can be tested — and this is a setting whose wrong
|
|
926
|
-
* values all look plausible.
|
|
927
|
-
*/
|
|
928
|
-
/** Ten megabytes: what the documentation and `.env.example` give as the default. */
|
|
929
647
|
const DEFAULT_MAX_FILE_BYTES = 10485760;
|
|
930
|
-
/**
|
|
931
|
-
* The largest ceiling a deployment may set: the largest number `files.size` can hold.
|
|
932
|
-
*
|
|
933
|
-
* That column is a PostgreSQL `integer` (`db.ts`), a signed 32-bit number. Every size an
|
|
934
|
-
* offer accepts is written to it, so a ceiling above this would let an offer through the
|
|
935
|
-
* route and fail it at the insert. Raising it means widening the column first;
|
|
936
|
-
* `server.integration.test.ts` reads the column's precision from the database and fails
|
|
937
|
-
* when this number and the column disagree.
|
|
938
|
-
*/
|
|
939
648
|
const LARGEST_MAX_FILE_BYTES = 2147483647;
|
|
940
|
-
/**
|
|
941
|
-
* `value`, once it is a ceiling the server can honour: a whole number of bytes, at least
|
|
942
|
-
* one, and no more than `files.size` holds. Throws naming `setting`, because the message
|
|
943
|
-
* is all an operator has to go on.
|
|
944
|
-
*/
|
|
945
649
|
function checkedMaxFileBytes(value, setting) {
|
|
946
650
|
if (!Number.isInteger(value) || value < 1 || value > 2147483647) throw new Error(`${setting} must be a whole number of bytes from 1 to ${String(LARGEST_MAX_FILE_BYTES)}, the largest size the files table holds.`);
|
|
947
651
|
return value;
|
|
948
652
|
}
|
|
949
|
-
/**
|
|
950
|
-
* The ceiling `FORMANCY_MAX_FILE_BYTES` sets, or the default when it is unset.
|
|
951
|
-
*
|
|
952
|
-
* Empty is refused rather than read as unset: the compose files already substitute the
|
|
953
|
-
* default for an empty value, so an empty one here was set by hand, to something.
|
|
954
|
-
*/
|
|
955
653
|
function maxFileBytesFrom(env) {
|
|
956
654
|
const raw = env["FORMANCY_MAX_FILE_BYTES"];
|
|
957
655
|
if (raw === void 0) return DEFAULT_MAX_FILE_BYTES;
|
|
@@ -959,25 +657,6 @@ function maxFileBytesFrom(env) {
|
|
|
959
657
|
}
|
|
960
658
|
//#endregion
|
|
961
659
|
//#region src/server-log.ts
|
|
962
|
-
/**
|
|
963
|
-
* The server's log: a JSON line for every request, one for every error that answered one, and
|
|
964
|
-
* one for each thing a route or a background worker has to say
|
|
965
|
-
* ([0168](../../../docs/decisions/0168-the-log-is-built-from-a-list-of-fields.md)).
|
|
966
|
-
*
|
|
967
|
-
* **What keeps everything else out is construction, not a filter over text.** A line is made
|
|
968
|
-
* from the fields `LOG_FIELDS` names, each kept only when its value is of that field's kind,
|
|
969
|
-
* and from nothing else. A key a call hands over that is not on the list is never read, so a
|
|
970
|
-
* body, a header, a query string, an answer, a password, a draft's key or a file's name has no
|
|
971
|
-
* field to go in. The words of a call are never written at all — pino's message argument,
|
|
972
|
-
* Fastify's `error.message`, its `Route GET:/the/raw/url not found` — so neither are the
|
|
973
|
-
* parameters a database error quotes: a line says what happened with an `event` from
|
|
974
|
-
* `LOG_EVENTS`, and a thrown error is written as its kind and its code.
|
|
975
|
-
*
|
|
976
|
-
* Its own logger rather than pino's options, because pino's rule for what to keep would be
|
|
977
|
-
* three hooks — a log formatter, a bindings formatter and a method hook to drop the message —
|
|
978
|
-
* whose order is pino's internals; here it is one function, `written`, and a list.
|
|
979
|
-
*/
|
|
980
|
-
/** Most severe first: `fatal` writes the least, `trace` the most. The names are pino's. */
|
|
981
660
|
const LOG_LEVELS = [
|
|
982
661
|
"fatal",
|
|
983
662
|
"error",
|
|
@@ -994,7 +673,6 @@ const SEVERITY = {
|
|
|
994
673
|
debug: 20,
|
|
995
674
|
trace: 10
|
|
996
675
|
};
|
|
997
|
-
/** What a line can say happened. Anything else is `unlisted`, or not written. */
|
|
998
676
|
const LOG_EVENTS = [
|
|
999
677
|
"request",
|
|
1000
678
|
"request.abandoned",
|
|
@@ -1014,11 +692,6 @@ const LOG_EVENTS = [
|
|
|
1014
692
|
"database.notice",
|
|
1015
693
|
"unlisted"
|
|
1016
694
|
];
|
|
1017
|
-
/**
|
|
1018
|
-
* Every field a line can have, in the order it is written, each with the kind of value it
|
|
1019
|
-
* holds. A field is kept only when its value is of its kind, so a string in a number's place,
|
|
1020
|
-
* or a sentence in an identifier's, is dropped rather than written.
|
|
1021
|
-
*/
|
|
1022
695
|
const KINDS = {
|
|
1023
696
|
time: (value) => typeof value === "string",
|
|
1024
697
|
level: (value) => LOG_LEVELS.includes(value),
|
|
@@ -1034,10 +707,6 @@ const KINDS = {
|
|
|
1034
707
|
upstream: isStatus
|
|
1035
708
|
};
|
|
1036
709
|
const LOG_FIELDS = Object.keys(KINDS);
|
|
1037
|
-
/**
|
|
1038
|
-
* The one place a line becomes text: dropped below `level`, and otherwise each listed field
|
|
1039
|
-
* whose value is of its kind, in the listed order.
|
|
1040
|
-
*/
|
|
1041
710
|
function written(sink, level) {
|
|
1042
711
|
const threshold = SEVERITY[level];
|
|
1043
712
|
return (line) => {
|
|
@@ -1051,17 +720,9 @@ function written(sink, level) {
|
|
|
1051
720
|
sink.write(`${JSON.stringify(kept)}\n`);
|
|
1052
721
|
};
|
|
1053
722
|
}
|
|
1054
|
-
/** A log whose every line is built by the rule above. What `createApp` hands Fastify. */
|
|
1055
723
|
function createServerLog(sink, level) {
|
|
1056
724
|
return logger(written(sink, level), level, void 0);
|
|
1057
725
|
}
|
|
1058
|
-
/**
|
|
1059
|
-
* Fastify's logger interface over the rule: each call contributes its event, its error, and
|
|
1060
|
-
* the status, code, action and upstream status a caller may name, and nothing else it was
|
|
1061
|
-
* handed. A
|
|
1062
|
-
* child keeps the request id it is bound to, which is how Fastify gives a request's lines its
|
|
1063
|
-
* id, and nothing else bound beside it.
|
|
1064
|
-
*/
|
|
1065
726
|
function logger(write, level, reqId) {
|
|
1066
727
|
const at = (lineLevel) => (first) => {
|
|
1067
728
|
const given = first instanceof Error ? { err: first } : isRecord$1(first) ? first : {};
|
|
@@ -1090,32 +751,12 @@ function logger(write, level, reqId) {
|
|
|
1090
751
|
child: (bindings) => logger(write, level, KINDS.reqId(bindings["reqId"]) ? bindings["reqId"] : reqId)
|
|
1091
752
|
};
|
|
1092
753
|
}
|
|
1093
|
-
/**
|
|
1094
|
-
* When Fastify writes about a request, written as this log's lines: Fastify's own extension
|
|
1095
|
-
* point for its internal lines, rather than hooks beside a logger that also writes them.
|
|
1096
|
-
*
|
|
1097
|
-
* One line when a request is answered, not one when it arrives as well — half the lines,
|
|
1098
|
-
* and the one that has the status. A route nobody registered gets no line of its own: its
|
|
1099
|
-
* request line says 404 and names no route.
|
|
1100
|
-
*
|
|
1101
|
-
* **Every request, including the ones Fastify answers past its own request line.** Fastify
|
|
1102
|
-
* writes that line when an answer has been sent, so three kinds left none: a request whose
|
|
1103
|
-
* client went away first, whose answer is never sent; one refused for an unreadable URL or an
|
|
1104
|
-
* over-long parameter, answered by hand before routing unless `frameworkErrors` is set; and
|
|
1105
|
-
* one that arrives while the server closes, refused with a `503` before routing and said at
|
|
1106
|
-
* `info` in words, which this log drops. Each is written here as well.
|
|
1107
|
-
*/
|
|
1108
754
|
var RequestLines = class extends LogController {
|
|
1109
755
|
#write;
|
|
1110
756
|
constructor(write) {
|
|
1111
757
|
super();
|
|
1112
758
|
this.#write = write;
|
|
1113
759
|
}
|
|
1114
|
-
/**
|
|
1115
|
-
* Nothing written on arrival; but a response that closes before it has finished sending is
|
|
1116
|
-
* a client that left. Read from the response's own `finish`, which a socket and an injected
|
|
1117
|
-
* request both emit, rather than `writableFinished`, which an injected one never sets.
|
|
1118
|
-
*/
|
|
1119
760
|
incomingRequest(request, reply) {
|
|
1120
761
|
let sent = false;
|
|
1121
762
|
reply.raw.once("finish", () => sent = true);
|
|
@@ -1130,7 +771,6 @@ var RequestLines = class extends LogController {
|
|
|
1130
771
|
});
|
|
1131
772
|
}
|
|
1132
773
|
routeNotFound() {}
|
|
1133
|
-
/** At `error` for a 5xx, thrown or answered by a route, so a level kept for failures keeps it. */
|
|
1134
774
|
requestCompleted(error, request, reply) {
|
|
1135
775
|
this.#write({
|
|
1136
776
|
level: error != null || reply.statusCode >= 500 ? "error" : "info",
|
|
@@ -1141,17 +781,10 @@ var RequestLines = class extends LogController {
|
|
|
1141
781
|
...described(error)
|
|
1142
782
|
});
|
|
1143
783
|
}
|
|
1144
|
-
/**
|
|
1145
|
-
* A request refused before it was routed, because its URL cannot be decoded or a parameter
|
|
1146
|
-
* is longer than the router takes: answered through the reply, so the error handler names it
|
|
1147
|
-
* as it names any refusal, and its request line written once the answer has gone. Fastify
|
|
1148
|
-
* calls this `frameworkErrors` and starts no clock for it, so `ms` is 0.
|
|
1149
|
-
*/
|
|
1150
784
|
refusedBeforeRouting = (error, request, reply) => {
|
|
1151
785
|
reply.raw.once("finish", () => this.requestCompleted(null, request, reply));
|
|
1152
786
|
reply.send(error);
|
|
1153
787
|
};
|
|
1154
|
-
/** Words and no event from Fastify, so written here: the `503` and the request's id. */
|
|
1155
788
|
serviceUnavailable(logger) {
|
|
1156
789
|
logger.error({
|
|
1157
790
|
event: "request",
|
|
@@ -1176,12 +809,6 @@ function where(request) {
|
|
|
1176
809
|
route: request.routeOptions.url
|
|
1177
810
|
};
|
|
1178
811
|
}
|
|
1179
|
-
/**
|
|
1180
|
-
* Fastify's options for a server that keeps this log, or none for one that keeps no log —
|
|
1181
|
-
* `createApp` given no `log`, which is how a host embedding it keeps its own output (0115).
|
|
1182
|
-
* Without one, Fastify answers what it refuses before routing by hand, as it always has; with
|
|
1183
|
-
* one, through the reply so the refusal can be written — the same status and the same fields.
|
|
1184
|
-
*/
|
|
1185
812
|
function serverLogOptions(log) {
|
|
1186
813
|
if (log === void 0) return {};
|
|
1187
814
|
const write = written(log.sink, log.level);
|
|
@@ -1192,11 +819,6 @@ function serverLogOptions(log) {
|
|
|
1192
819
|
frameworkErrors: lines.refusedBeforeRouting
|
|
1193
820
|
};
|
|
1194
821
|
}
|
|
1195
|
-
/**
|
|
1196
|
-
* What a thrown thing is, from what it calls itself: its class, and the first code along its
|
|
1197
|
-
* causes — Drizzle wraps PostgreSQL's error, whose SQLSTATE is the useful part. Never its
|
|
1198
|
-
* message or its stack, whose first line is the message.
|
|
1199
|
-
*/
|
|
1200
822
|
function described(error) {
|
|
1201
823
|
if (!(error instanceof Error)) return {};
|
|
1202
824
|
let code;
|
|
@@ -1211,7 +833,6 @@ function described(error) {
|
|
|
1211
833
|
...code === void 0 ? {} : { code }
|
|
1212
834
|
};
|
|
1213
835
|
}
|
|
1214
|
-
/** `name`, which most errors set; a class that leaves it as `Error` by its constructor. */
|
|
1215
836
|
function kindOf(error) {
|
|
1216
837
|
if (error.name !== "Error" && isIdentifier(error.name)) return error.name;
|
|
1217
838
|
const constructed = error.constructor.name;
|
|
@@ -1220,10 +841,6 @@ function kindOf(error) {
|
|
|
1220
841
|
function isIdentifier(value) {
|
|
1221
842
|
return typeof value === "string" && /^[A-Za-z_$][\w$]{0,63}$/.test(value);
|
|
1222
843
|
}
|
|
1223
|
-
/**
|
|
1224
|
-
* `ECONNREFUSED`, `22P02`, `FST_ERR_CTP_BODY_TOO_LARGE`: capitals, digits and underscores. The
|
|
1225
|
-
* one rule for what a code looks like, which the reply to a 5xx keeps a code by as well (0172).
|
|
1226
|
-
*/
|
|
1227
844
|
function isCode(value) {
|
|
1228
845
|
return typeof value === "string" && /^[A-Z0-9_]{1,64}$/.test(value);
|
|
1229
846
|
}
|
|
@@ -1233,21 +850,9 @@ function isStatus(value) {
|
|
|
1233
850
|
function isRecord$1(value) {
|
|
1234
851
|
return typeof value === "object" && value !== null;
|
|
1235
852
|
}
|
|
1236
|
-
/**
|
|
1237
|
-
* The log a worker was given, or one writing its failures to standard error by the same rule:
|
|
1238
|
-
* where they went before there was a log, and so where a host that starts a worker without
|
|
1239
|
-
* one still finds them.
|
|
1240
|
-
*/
|
|
1241
853
|
function workerLog(given) {
|
|
1242
854
|
return given ?? createServerLog(process.stderr, "error");
|
|
1243
855
|
}
|
|
1244
|
-
/**
|
|
1245
|
-
* What the database says beside its answers, as lines by the same rule: its SQLSTATE, never
|
|
1246
|
-
* its words. postgres.js prints each notice whole to standard output unless it is handed
|
|
1247
|
-
* somewhere else to put it, and the bootstrap draws one on every start for each table, column
|
|
1248
|
-
* and trigger that already exists. Those are `debug`; a `WARNING` is `warn`. With no log,
|
|
1249
|
-
* nowhere.
|
|
1250
|
-
*/
|
|
1251
856
|
function databaseNotices(log) {
|
|
1252
857
|
return (notice) => {
|
|
1253
858
|
if (log === void 0) return;
|
|
@@ -1261,43 +866,6 @@ function databaseNotices(log) {
|
|
|
1261
866
|
}
|
|
1262
867
|
//#endregion
|
|
1263
868
|
//#region src/error-replies.ts
|
|
1264
|
-
/**
|
|
1265
|
-
* What an error answers its client with
|
|
1266
|
-
* ([0172](../../../docs/decisions/0172-a-5xx-says-nothing-of-what-was-thrown.md)).
|
|
1267
|
-
*
|
|
1268
|
-
* Fastify answers an error with `{ statusCode, code, error, message }`, the message being the
|
|
1269
|
-
* error's own. For an error that names its own 4xx that is a sentence written for the client —
|
|
1270
|
-
* the body did not parse, the file is too large — and it stays. For a 5xx it is whatever was
|
|
1271
|
-
* thrown: a database error's message is the failing query and its parameters, a disk's names
|
|
1272
|
-
* the path it could not write. So a 5xx keeps the shape and says only its status: the status's
|
|
1273
|
-
* own name as `error` and as `message`, and a `code` only when the error named its own status —
|
|
1274
|
-
* made to be answered, as `RATE_LIMIT_UNAVAILABLE` is (0170) — and only in the shape the log
|
|
1275
|
-
* keeps a code in. A fault's code, `ENOSPC` or an SQLSTATE, is the operator's, in the log. And a
|
|
1276
|
-
* fault answered under a 4xx — Fastify keeps a status of 400 or more that the reply already had
|
|
1277
|
-
* — says only that status too (0175): its words were no more written for the client there.
|
|
1278
|
-
*
|
|
1279
|
-
* **Two hooks rather than an error handler**, because Fastify writes its line about an error —
|
|
1280
|
-
* `request.failed` and `request.refused`, with the error's kind and code (`server-log.ts`) —
|
|
1281
|
-
* only from its own default handler. One set with `setErrorHandler` replaces it, and the line
|
|
1282
|
-
* would go with it. So the default handler still sets the status and writes the line, and the
|
|
1283
|
-
* body is replaced on its way out, in `onSend`, where the status is final. `onError` is how
|
|
1284
|
-
* `onSend` knows the body is an error's: a route that answers a 5xx itself — no file store, a
|
|
1285
|
-
* scanner that could not be asked — chose its words for the person reading them.
|
|
1286
|
-
*
|
|
1287
|
-
* **An error's reply is written without a header Node cannot write**
|
|
1288
|
-
* ([0175](../../../docs/decisions/0175-a-name-goes-into-a-header-encoded.md)). Node checks a
|
|
1289
|
-
* header's value when the reply is written, after the route has answered, and throws. Fastify
|
|
1290
|
-
* answers that error with the headers the reply already had, so its own reply failed the same
|
|
1291
|
-
* way, and Fastify's last resort — which runs no hook — wrote it with Node's code and sentence,
|
|
1292
|
-
* under whatever status the reply had. Dropping the header here is what lets this reply be the
|
|
1293
|
-
* one that goes out, because no `onSend` hook runs after this one but Fastify's own on a `HEAD`
|
|
1294
|
-
* route, which sets only a length — `error-replies.test.ts` puts such a header on a request to
|
|
1295
|
-
* every route and reads each answer. A route's own value is built so it cannot be refused
|
|
1296
|
-
* (`header-values.ts`); this is for the one that was not.
|
|
1297
|
-
*
|
|
1298
|
-
* On the root instance, before any route is registered, so every route and every plugin's
|
|
1299
|
-
* context has both. Not tied to the log: an app given none withholds the same words.
|
|
1300
|
-
*/
|
|
1301
869
|
function withholdServerErrors(app) {
|
|
1302
870
|
const thrown = /* @__PURE__ */ new WeakMap();
|
|
1303
871
|
app.addHook("onError", async (_request, reply, error) => {
|
|
@@ -1311,15 +879,9 @@ function withholdServerErrors(app) {
|
|
|
1311
879
|
return JSON.stringify(withheld(reply.statusCode, error));
|
|
1312
880
|
});
|
|
1313
881
|
}
|
|
1314
|
-
/** Remove every header Node would refuse to write, by its own checks rather than a copy of them. */
|
|
1315
882
|
function dropUnwritable(reply) {
|
|
1316
883
|
for (const [name, value] of Object.entries(reply.getHeaders())) if (!writable(name, value)) reply.removeHeader(name);
|
|
1317
884
|
}
|
|
1318
|
-
/**
|
|
1319
|
-
* A header as Node writes it: a number as its digits, and a list one value at a time. Fastify
|
|
1320
|
-
* keeps no header without a value — `reply.header` defaults one to `''` — though `getHeaders`
|
|
1321
|
-
* is typed as if it might.
|
|
1322
|
-
*/
|
|
1323
885
|
function writable(name, value) {
|
|
1324
886
|
try {
|
|
1325
887
|
validateHeaderName(name);
|
|
@@ -1329,15 +891,9 @@ function writable(name, value) {
|
|
|
1329
891
|
return false;
|
|
1330
892
|
}
|
|
1331
893
|
}
|
|
1332
|
-
/**
|
|
1333
|
-
* Whether an error names its own status: made to be answered, by this server or by Fastify, so
|
|
1334
|
-
* its code is part of the answer and, below 500, its sentence too. One that names none is a
|
|
1335
|
-
* fault from below, whatever status the reply already had when it was thrown.
|
|
1336
|
-
*/
|
|
1337
894
|
function answerable(error) {
|
|
1338
895
|
return isRecord(error) && (typeof error["statusCode"] === "number" || typeof error["status"] === "number");
|
|
1339
896
|
}
|
|
1340
|
-
/** Fastify's shape for an error's reply, with nothing in it the thrown error said. */
|
|
1341
897
|
function withheld(status, error) {
|
|
1342
898
|
const name = STATUS_CODES[status] ?? "Server Error";
|
|
1343
899
|
const own = isRecord(error) ? error : {};
|
|
@@ -1354,26 +910,6 @@ function isRecord(value) {
|
|
|
1354
910
|
}
|
|
1355
911
|
//#endregion
|
|
1356
912
|
//#region src/nul-characters.ts
|
|
1357
|
-
/**
|
|
1358
|
-
* A body with a NUL character in it is refused as malformed
|
|
1359
|
-
* ([0173](../../../docs/decisions/0173-a-nul-character-names-nothing-and-a-body-with-one-is-refused.md)).
|
|
1360
|
-
*
|
|
1361
|
-
* PostgreSQL holds no U+0000 in `text`, `22021`, nor in `jsonb`, `22P05`, so a string with one
|
|
1362
|
-
* can be kept nowhere this server keeps anything: an answer, a draft, a schema, an example, a
|
|
1363
|
-
* file's name or type, an origin, an email, a key's name. Each was a `500` where its route
|
|
1364
|
-
* wrote it, and a line at `error`. Refused here, once, on the root instance before any route,
|
|
1365
|
-
* so every route and every plugin's context has it and a route added later needs nothing of
|
|
1366
|
-
* its own.
|
|
1367
|
-
*
|
|
1368
|
-
* In `preValidation`, which is after the body is parsed — by whichever parser the request's
|
|
1369
|
-
* type chose, so a `text/plain` string is looked at as well as JSON — and before any route's
|
|
1370
|
-
* own `preHandler`. That makes it what a body that does not parse is: answered by the body
|
|
1371
|
-
* alone, whoever sent it and whatever accounts exist, before a session is asked for. A file's
|
|
1372
|
-
* bytes arrive as a `Buffer` and are not looked at; a NUL byte is ordinary in a file.
|
|
1373
|
-
*
|
|
1374
|
-
* The answer is the route plane's shape for a refusal, with the JSON Pointer (RFC 6901) to the
|
|
1375
|
-
* string or the member's name the NUL is in, so a client can say which answer to correct.
|
|
1376
|
-
*/
|
|
1377
913
|
function refuseNulCharacters(app) {
|
|
1378
914
|
app.addHook("preValidation", async (request, reply) => {
|
|
1379
915
|
const pointer = nulAt(request.body);
|
|
@@ -1385,16 +921,6 @@ function refuseNulCharacters(app) {
|
|
|
1385
921
|
});
|
|
1386
922
|
});
|
|
1387
923
|
}
|
|
1388
|
-
/**
|
|
1389
|
-
* The JSON Pointer to the first string or member name in `body` with a NUL in it, in the order
|
|
1390
|
-
* the body was written, or `undefined` when there is none.
|
|
1391
|
-
*
|
|
1392
|
-
* A loop over a stack of its own rather than a recursion: `JSON.parse` takes a body nested
|
|
1393
|
-
* deeper than the call stack goes, and a walk that threw on it would make that body a `500` on
|
|
1394
|
-
* every route. And every body is walked, so nothing is built for a value on the way — a step
|
|
1395
|
-
* for each container, and the pointer only for the string it finds. Building a pointer for
|
|
1396
|
-
* every value made the walk of a body at the cap take longer than parsing it.
|
|
1397
|
-
*/
|
|
1398
924
|
function nulAt(body) {
|
|
1399
925
|
const values = [body];
|
|
1400
926
|
const containers = [void 0];
|
|
@@ -1432,7 +958,6 @@ function nulAt(body) {
|
|
|
1432
958
|
}
|
|
1433
959
|
}
|
|
1434
960
|
}
|
|
1435
|
-
/** RFC 6901's spelling of the place `key` names in `container`: '' for the body itself. */
|
|
1436
961
|
function pointer(container, key) {
|
|
1437
962
|
const steps = [];
|
|
1438
963
|
for (let at = key === void 0 ? container : {
|
|
@@ -1441,11 +966,9 @@ function pointer(container, key) {
|
|
|
1441
966
|
}; at !== void 0; at = at.up) steps.push(at.key);
|
|
1442
967
|
return steps.reverse().map((step) => `/${typeof step === "number" ? String(step) : token(step)}`).join("");
|
|
1443
968
|
}
|
|
1444
|
-
/** A member's name as one step of a JSON Pointer: `~` and `/` escaped, as RFC 6901 has them. */
|
|
1445
969
|
function token(name) {
|
|
1446
970
|
return name.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
1447
971
|
}
|
|
1448
|
-
/** What a parser makes of a JSON object, and nothing else — not a `Buffer`, not a stream. */
|
|
1449
972
|
function isPlainObject(value) {
|
|
1450
973
|
if (typeof value !== "object" || value === null) return false;
|
|
1451
974
|
const prototype = Object.getPrototypeOf(value);
|
|
@@ -1453,24 +976,6 @@ function isPlainObject(value) {
|
|
|
1453
976
|
}
|
|
1454
977
|
//#endregion
|
|
1455
978
|
//#region src/audit-trail.ts
|
|
1456
|
-
/**
|
|
1457
|
-
* Append an audit row for something that has already happened.
|
|
1458
|
-
*
|
|
1459
|
-
* Never throws. An export that succeeded and an audit row that did not is
|
|
1460
|
-
* bad; an export that 500s because the audit row failed, after the rows have
|
|
1461
|
-
* already been written to the response, is worse and helps nobody. So the
|
|
1462
|
-
* failure is written to the log at error level, as `audit.unwritten` with the
|
|
1463
|
-
* row's action and the error's kind — never its subject, which for a failed
|
|
1464
|
-
* login is the email somebody tried — and the request stands.
|
|
1465
|
-
*
|
|
1466
|
-
* For a mutation that HAS a transaction, do not use this — pass the entry
|
|
1467
|
-
* into that call so the two commit together. A publish, a change to a form's
|
|
1468
|
-
* examples and a submission are those: their use-cases build the row, and
|
|
1469
|
-
* the route hands them `request.id` so it names the request as this one does.
|
|
1470
|
-
*
|
|
1471
|
-
* Its own module because what happens when a row cannot be written is the log's
|
|
1472
|
-
* business (0168), and `app.ts` had no room for it.
|
|
1473
|
-
*/
|
|
1474
979
|
function auditTrail(deps) {
|
|
1475
980
|
return async (request, draft) => {
|
|
1476
981
|
const actor = request.actor;
|
|
@@ -1493,21 +998,6 @@ function auditTrail(deps) {
|
|
|
1493
998
|
//#endregion
|
|
1494
999
|
//#region src/app.ts
|
|
1495
1000
|
const API_KEY_HEADER = "x-formancy-api-key";
|
|
1496
|
-
/**
|
|
1497
|
-
* The HTTP surface, split into two planes.
|
|
1498
|
-
*
|
|
1499
|
-
* PUBLIC — what a person filling a form needs: resolve, submit, drafts. These
|
|
1500
|
-
* stay unauthenticated by design; anonymous-submission hardening is its own
|
|
1501
|
-
* upcoming step and is opt-in per form.
|
|
1502
|
-
*
|
|
1503
|
-
* MANAGEMENT — everything an operator does: publish, catalog, submissions,
|
|
1504
|
-
* export, users, keys. Authenticated via a session token (login) or an API
|
|
1505
|
-
* key header, authorized through server-core's one-table can().
|
|
1506
|
-
*
|
|
1507
|
-
* This file is the composition root, so THIS is where ambient reality enters:
|
|
1508
|
-
* randomUUID, the real clock, argon2 and the CSPRNG. Everything below stays
|
|
1509
|
-
* injected and replayable.
|
|
1510
|
-
*/
|
|
1511
1001
|
async function createApp(storage, options) {
|
|
1512
1002
|
const app = Fastify({
|
|
1513
1003
|
...serverLogOptions(options.log),
|
|
@@ -1572,8 +1062,6 @@ async function createApp(storage, options) {
|
|
|
1572
1062
|
return outcome.ok ? outcome.actor : void 0;
|
|
1573
1063
|
}
|
|
1574
1064
|
}
|
|
1575
|
-
/** 401 without an identity, 403 with one that lacks the permission — the
|
|
1576
|
-
* difference tells a client whether to log in or to give up. */
|
|
1577
1065
|
function requires(action) {
|
|
1578
1066
|
return async (request, reply) => {
|
|
1579
1067
|
const actor = await actorOf(request);
|
|
@@ -1663,14 +1151,6 @@ async function createApp(storage, options) {
|
|
|
1663
1151
|
deps,
|
|
1664
1152
|
actorOf
|
|
1665
1153
|
});
|
|
1666
|
-
/**
|
|
1667
|
-
* The log itself.
|
|
1668
|
-
*
|
|
1669
|
-
* Admin only, and reading it is NOT audited. A log that records its own
|
|
1670
|
-
* reads grows without bound from a dashboard that polls it, and the entry
|
|
1671
|
-
* would say nothing an access log does not — the events worth recording
|
|
1672
|
-
* are the ones that touch somebody else's data.
|
|
1673
|
-
*/
|
|
1674
1154
|
app.get("/audit", { preHandler: requires("user.create") }, async (request, reply) => {
|
|
1675
1155
|
const raw = request.query?.limit;
|
|
1676
1156
|
const asked = typeof raw === "string" ? Number.parseInt(raw, 10) : 100;
|
|
@@ -1701,6 +1181,16 @@ async function createApp(storage, options) {
|
|
|
1701
1181
|
if (versions === void 0) return reply.code(404).send({ error: "unknown_form" });
|
|
1702
1182
|
return reply.send({ versions });
|
|
1703
1183
|
});
|
|
1184
|
+
app.get("/f/:path/versions/:version", { preHandler: requires("form.read") }, async (request, reply) => {
|
|
1185
|
+
const { path, version } = request.params;
|
|
1186
|
+
const found = await formVersion(deps, path, version);
|
|
1187
|
+
if (!found.ok) return reply.code(404).send({ error: found.kind });
|
|
1188
|
+
return reply.send({
|
|
1189
|
+
version: found.version,
|
|
1190
|
+
schemaHash: found.schemaHash,
|
|
1191
|
+
schema: found.schema
|
|
1192
|
+
});
|
|
1193
|
+
});
|
|
1704
1194
|
app.get("/f/:path/submissions", { preHandler: requires("submission.read") }, async (request, reply) => {
|
|
1705
1195
|
const { path } = request.params;
|
|
1706
1196
|
const listed = await listSubmissions(deps, path);
|
|
@@ -1744,16 +1234,6 @@ async function createApp(storage, options) {
|
|
|
1744
1234
|
}
|
|
1745
1235
|
//#endregion
|
|
1746
1236
|
//#region src/db.ts
|
|
1747
|
-
/**
|
|
1748
|
-
* The thin slice's tables. Three rules from the design spec are enforced HERE,
|
|
1749
|
-
* in the database, because application code can be bypassed and these must not
|
|
1750
|
-
* be:
|
|
1751
|
-
*
|
|
1752
|
-
* - published versions are immutable (a trigger refuses UPDATE),
|
|
1753
|
-
* - a submission binds to its exact version by real foreign key,
|
|
1754
|
-
* - that key is ON DELETE RESTRICT, so orphaning a submission from the schema
|
|
1755
|
-
* that produced it is structurally impossible.
|
|
1756
|
-
*/
|
|
1757
1237
|
const forms = pgTable("forms", {
|
|
1758
1238
|
id: uuid("id").primaryKey(),
|
|
1759
1239
|
path: text("path").notNull().unique(),
|
|
@@ -1801,11 +1281,6 @@ const webhooks = pgTable("webhooks", {
|
|
|
1801
1281
|
consecutiveFailures: integer("consecutive_failures").notNull().default(0),
|
|
1802
1282
|
openedAt: timestamp("opened_at", { withTimezone: true })
|
|
1803
1283
|
});
|
|
1804
|
-
/**
|
|
1805
|
-
* The outbox. A row here and the submission that caused it are written by the
|
|
1806
|
-
* same transaction, which is what makes "accepted" and "will be delivered" one
|
|
1807
|
-
* decision rather than two.
|
|
1808
|
-
*/
|
|
1809
1284
|
const deliveries = pgTable("deliveries", {
|
|
1810
1285
|
id: uuid("id").primaryKey(),
|
|
1811
1286
|
webhookId: uuid("webhook_id").notNull(),
|
|
@@ -1831,23 +1306,12 @@ const auditLog = pgTable("audit_log", {
|
|
|
1831
1306
|
requestId: text("request_id"),
|
|
1832
1307
|
detail: jsonb("detail")
|
|
1833
1308
|
});
|
|
1834
|
-
/**
|
|
1835
|
-
* A form's examples and the sample they start from, one row per form, beside it and never in
|
|
1836
|
-
* a version (0166). The examples change while the form does not, and a version cannot.
|
|
1837
|
-
*/
|
|
1838
1309
|
const formExamples = pgTable("form_examples", {
|
|
1839
1310
|
formId: uuid("form_id").primaryKey().references(() => forms.id),
|
|
1840
1311
|
scenarios: jsonb("scenarios").notNull(),
|
|
1841
1312
|
sample: jsonb("sample"),
|
|
1842
1313
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull()
|
|
1843
1314
|
});
|
|
1844
|
-
/**
|
|
1845
|
-
* An audit entry as the table holds it.
|
|
1846
|
-
*
|
|
1847
|
-
* Absent and null are the same thing here, and the conversion is in one place, beside the
|
|
1848
|
-
* table, so the storage's call sites — a publish, a submission, a form's examples — cannot
|
|
1849
|
-
* disagree about which they write.
|
|
1850
|
-
*/
|
|
1851
1315
|
function auditRow(entry) {
|
|
1852
1316
|
return {
|
|
1853
1317
|
id: entry.id,
|
|
@@ -1879,31 +1343,12 @@ const submissions = pgTable("submissions", {
|
|
|
1879
1343
|
data: jsonb("data").notNull(),
|
|
1880
1344
|
submittedAt: timestamp("submitted_at", { withTimezone: true }).notNull()
|
|
1881
1345
|
});
|
|
1882
|
-
/**
|
|
1883
|
-
* Whether `id` can be compared with a `uuid` column: the hyphenated form every id this server
|
|
1884
|
-
* mints has. PostgreSQL refuses anything it cannot read as a uuid with an error, `22P02`, where
|
|
1885
|
-
* a `text` column — or the in-memory storage — finds no row; so a lookup by an id from a URL
|
|
1886
|
-
* that is not one answers no row without asking (0172). A spelling PostgreSQL would also read,
|
|
1887
|
-
* braced or without hyphens, names no row either: no id was handed out in one.
|
|
1888
|
-
*/
|
|
1889
1346
|
function isUuid(id) {
|
|
1890
1347
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
|
|
1891
1348
|
}
|
|
1892
|
-
/**
|
|
1893
|
-
* Whether `value` can be compared with a `text` column: PostgreSQL holds no NUL character in
|
|
1894
|
-
* text and refuses one with an error, `22021`, where the in-memory storage finds no row. So a
|
|
1895
|
-
* lookup by a value with one — a form's path from a URL, an API key's prefix from a header —
|
|
1896
|
-
* answers no row without asking, as `isUuid` does for its column (0173). No row was ever
|
|
1897
|
-
* written with one, since no write of one ever succeeded.
|
|
1898
|
-
*/
|
|
1899
1349
|
function fitsText(value) {
|
|
1900
1350
|
return !value.includes("\0");
|
|
1901
1351
|
}
|
|
1902
|
-
/**
|
|
1903
|
-
* Bootstrap DDL for the thin slice — drizzle-kit migrations arrive with the
|
|
1904
|
-
* lifecycle work; a self-hosted skeleton that creates its own tables on first
|
|
1905
|
-
* start is worth more right now than a migration pipeline.
|
|
1906
|
-
*/
|
|
1907
1352
|
async function bootstrapSchema(sql) {
|
|
1908
1353
|
await sql`
|
|
1909
1354
|
CREATE TABLE IF NOT EXISTS forms (
|
|
@@ -2099,13 +1544,6 @@ async function bootstrapSchema(sql) {
|
|
|
2099
1544
|
}
|
|
2100
1545
|
//#endregion
|
|
2101
1546
|
//#region src/postgres-examples.ts
|
|
2102
|
-
/**
|
|
2103
|
-
* A form's examples over PostgreSQL: one row per form in `form_examples`, replaced whole
|
|
2104
|
-
* (0166).
|
|
2105
|
-
*
|
|
2106
|
-
* Its own file because it changes for its own reason, and because `postgres-storage.ts`
|
|
2107
|
-
* would otherwise have crossed the size budget with it.
|
|
2108
|
-
*/
|
|
2109
1547
|
function examplesStorage(db) {
|
|
2110
1548
|
return {
|
|
2111
1549
|
async getExamples(formId) {
|
|
@@ -2141,15 +1579,6 @@ function examplesStorage(db) {
|
|
|
2141
1579
|
}
|
|
2142
1580
|
//#endregion
|
|
2143
1581
|
//#region src/postgres-identities.ts
|
|
2144
|
-
/**
|
|
2145
|
-
* Who may use the management plane, over PostgreSQL: users with a password and API keys, each
|
|
2146
|
-
* stored as a hash and looked up by what a request presents — an email, a key's prefix.
|
|
2147
|
-
*
|
|
2148
|
-
* Its own file because it changes for its own reason — the way somebody is let in, not the
|
|
2149
|
-
* forms and responses everything else here stores — and because `postgres-storage.ts` would
|
|
2150
|
-
* otherwise have crossed the size budget when its lookups learned to find nothing for a NUL
|
|
2151
|
-
* (0173).
|
|
2152
|
-
*/
|
|
2153
1582
|
function identitiesStorage(db) {
|
|
2154
1583
|
return {
|
|
2155
1584
|
async insertUser(record) {
|
|
@@ -2196,7 +1625,6 @@ function identitiesStorage(db) {
|
|
|
2196
1625
|
}
|
|
2197
1626
|
};
|
|
2198
1627
|
}
|
|
2199
|
-
/** An API key's row as the port describes it: timestamps as ISO strings. */
|
|
2200
1628
|
function toApiKeyRecord(row) {
|
|
2201
1629
|
return {
|
|
2202
1630
|
id: row.id,
|
|
@@ -2210,7 +1638,6 @@ function toApiKeyRecord(row) {
|
|
|
2210
1638
|
}
|
|
2211
1639
|
//#endregion
|
|
2212
1640
|
//#region src/postgres-storage.ts
|
|
2213
|
-
/** The Storage port over Postgres — the mirror of server-core's in-memory one. */
|
|
2214
1641
|
function createPostgresStorage(sql) {
|
|
2215
1642
|
const db = drizzle(sql);
|
|
2216
1643
|
return {
|
|
@@ -2494,7 +1921,6 @@ function toSubmissionRecord(row) {
|
|
|
2494
1921
|
submittedAt: row.submittedAt.toISOString()
|
|
2495
1922
|
};
|
|
2496
1923
|
}
|
|
2497
|
-
/** A delivery row as the port describes it. */
|
|
2498
1924
|
function toDeliveryRecord(row) {
|
|
2499
1925
|
return {
|
|
2500
1926
|
id: row.id,
|
|
@@ -2508,7 +1934,6 @@ function toDeliveryRecord(row) {
|
|
|
2508
1934
|
lastError: row.lastError
|
|
2509
1935
|
};
|
|
2510
1936
|
}
|
|
2511
|
-
/** A files row as the port describes it: timestamps as ISO strings. */
|
|
2512
1937
|
function toFileRecord(row) {
|
|
2513
1938
|
return {
|
|
2514
1939
|
id: row.id,
|
|
@@ -2523,7 +1948,6 @@ function toFileRecord(row) {
|
|
|
2523
1948
|
receivingUntil: row.receivingUntil === null ? null : row.receivingUntil.toISOString()
|
|
2524
1949
|
};
|
|
2525
1950
|
}
|
|
2526
|
-
/** A webhook row as the port describes it, health included. */
|
|
2527
1951
|
function toWebhookRecord(row) {
|
|
2528
1952
|
return {
|
|
2529
1953
|
id: row.id,
|
|
@@ -2534,32 +1958,6 @@ function toWebhookRecord(row) {
|
|
|
2534
1958
|
openedAt: row.openedAt === null ? null : row.openedAt.toISOString()
|
|
2535
1959
|
};
|
|
2536
1960
|
}
|
|
2537
|
-
/**
|
|
2538
|
-
* `@fastify/rate-limit`'s store over the PostgreSQL every replica already shares (0170).
|
|
2539
|
-
*
|
|
2540
|
-
* The plugin's own store keeps its counts in the process, so behind N replicas each limit
|
|
2541
|
-
* allowed N times what it said. This one keeps them in `rate_limit_counters`, one row per
|
|
2542
|
-
* route and client, and counts with one statement: the upsert takes the row's lock, so two
|
|
2543
|
-
* replicas counting the same client at once are counted one after the other and neither
|
|
2544
|
-
* increment is lost.
|
|
2545
|
-
*
|
|
2546
|
-
* A window starts at a key's first request and lasts the route's `timeWindow`, as the plugin's
|
|
2547
|
-
* own stores do it, on the database's clock — one clock for every replica, where each
|
|
2548
|
-
* process's own would end the window at a different moment on each.
|
|
2549
|
-
*
|
|
2550
|
-
* It opens its own connections from the database's address rather than taking a pool, because
|
|
2551
|
-
* which connections it counts on is what keeps a counter that cannot answer from stopping
|
|
2552
|
-
* everything else: handed the pool storage queries on, it would count correctly and bring that
|
|
2553
|
-
* outage back. A count that does not come back within the bound is abandoned: one still waiting
|
|
2554
|
-
* here to be sent is dropped unsent, and one already sent is ended by the database once it runs,
|
|
2555
|
-
* by the `statement_timeout` of these connections, with no cancel request — a connection more —
|
|
2556
|
-
* needed for it. The error the plugin is handed says nothing about the database, because a route
|
|
2557
|
-
* that refuses without a count sends it to whoever asked; what each route does then is its own
|
|
2558
|
-
* declaration (`rate-limits.ts`).
|
|
2559
|
-
*
|
|
2560
|
-
* The plugin's in-memory store is the second implementation of the same interface, and what
|
|
2561
|
-
* `createApp` counts in when it is given none.
|
|
2562
|
-
*/
|
|
2563
1961
|
function createPostgresRateLimitStore(databaseUrl, options = {}) {
|
|
2564
1962
|
const timeoutMs = options.timeoutMs ?? 1e3;
|
|
2565
1963
|
const sweepEveryMs = options.sweepEveryMs ?? 6e5;
|
|
@@ -2606,7 +2004,6 @@ function createPostgresRateLimitStore(databaseUrl, options = {}) {
|
|
|
2606
2004
|
else waiting.push(send);
|
|
2607
2005
|
});
|
|
2608
2006
|
}
|
|
2609
|
-
/** On the back of a count, at most once per interval, one at a time, and never waited for. */
|
|
2610
2007
|
function sweep() {
|
|
2611
2008
|
const now = Date.now();
|
|
2612
2009
|
if (sweeping || running >= 32 || now - sweptAt < sweepEveryMs) return;
|
|
@@ -2658,32 +2055,17 @@ function createPostgresRateLimitStore(databaseUrl, options = {}) {
|
|
|
2658
2055
|
end: () => sql.end({ timeout: timeoutMs / 1e3 })
|
|
2659
2056
|
};
|
|
2660
2057
|
}
|
|
2661
|
-
/** What a route that refuses without a count answers: a 503, and nothing about the database. */
|
|
2662
2058
|
function uncounted() {
|
|
2663
2059
|
return Object.assign(/* @__PURE__ */ new Error("This request could not be counted against its limit, so it was not taken. Try again in a moment."), {
|
|
2664
2060
|
statusCode: 503,
|
|
2665
2061
|
code: "RATE_LIMIT_UNAVAILABLE"
|
|
2666
2062
|
});
|
|
2667
2063
|
}
|
|
2668
|
-
/**
|
|
2669
|
-
* A count not answered within the bound. Its own code, because the log writes an error's kind
|
|
2670
|
-
* and code and nothing else: without one, a database too slow to count — a lock on the table, a
|
|
2671
|
-
* flood — reads the same as any other failure, and the operator cannot tell which to look for.
|
|
2672
|
-
*/
|
|
2673
2064
|
function late() {
|
|
2674
2065
|
return Object.assign(/* @__PURE__ */ new Error("The count was not answered within its bound."), { code: "RATE_LIMIT_TIMEOUT" });
|
|
2675
2066
|
}
|
|
2676
2067
|
//#endregion
|
|
2677
2068
|
//#region src/file-store.ts
|
|
2678
|
-
/**
|
|
2679
|
-
* Bytes under one directory, one file per key.
|
|
2680
|
-
*
|
|
2681
|
-
* **Keys are checked, not trusted.** A key is minted by the server from a form
|
|
2682
|
-
* id and a file id, so it cannot contain a traversal — but this resolves it
|
|
2683
|
-
* and refuses anything landing outside the root regardless. A path check that
|
|
2684
|
-
* only runs when the input is untrusted is a path check that stops running the
|
|
2685
|
-
* day somebody adds a caller.
|
|
2686
|
-
*/
|
|
2687
2069
|
function createLocalFileStore(root) {
|
|
2688
2070
|
const base = resolve(root);
|
|
2689
2071
|
const pathFor = (key) => {
|
|
@@ -2719,13 +2101,6 @@ function createLocalFileStore(root) {
|
|
|
2719
2101
|
}
|
|
2720
2102
|
};
|
|
2721
2103
|
}
|
|
2722
|
-
/**
|
|
2723
|
-
* The checksum a receiver can compare against.
|
|
2724
|
-
*
|
|
2725
|
-
* Returned when the bytes are stored and never asked of the client: a checksum
|
|
2726
|
-
* the uploader computed is a checksum the uploader can lie about, so this one
|
|
2727
|
-
* is only useful for telling a *later* reader that the bytes have not changed.
|
|
2728
|
-
*/
|
|
2729
2104
|
function sha256(bytes) {
|
|
2730
2105
|
return createHash("sha256").update(bytes).digest("hex");
|
|
2731
2106
|
}
|
|
@@ -2767,47 +2142,16 @@ function startFileCollector(storage, store, options = {}) {
|
|
|
2767
2142
|
}
|
|
2768
2143
|
//#endregion
|
|
2769
2144
|
//#region src/model-settings.ts
|
|
2770
|
-
/**
|
|
2771
|
-
* Which model the environment names for the builders: none, or a provider, its key and
|
|
2772
|
-
* the model.
|
|
2773
|
-
*
|
|
2774
|
-
* A function of the environment rather than lines in `main.ts`, as `fileStoreSettings` is:
|
|
2775
|
-
* the rules are where a deployment goes wrong, and `main.ts` is a composition root no test
|
|
2776
|
-
* imports. Constructing the adapter stays in `main.ts`; this only says which
|
|
2777
|
-
* ([0165](../../../docs/decisions/0165-a-deployments-model-is-asked-through-its-server.md)).
|
|
2778
|
-
*/
|
|
2779
2145
|
const MODEL_PROVIDERS = [
|
|
2780
2146
|
"anthropic",
|
|
2781
2147
|
"openai",
|
|
2782
2148
|
"xai"
|
|
2783
2149
|
];
|
|
2784
|
-
/**
|
|
2785
|
-
* The variable each provider's SDK reads for headers of its own to add to every request.
|
|
2786
|
-
*
|
|
2787
|
-
* The adapters name the base URL, the organisation, the project, the credential and the log
|
|
2788
|
-
* level rather than leave them to the environment; these headers no option undoes. So a
|
|
2789
|
-
* server with a model refuses to start while the variable its provider's SDK reads is set.
|
|
2790
|
-
* xAI is asked through OpenAI's client, so it is OpenAI's variable.
|
|
2791
|
-
*/
|
|
2792
2150
|
const SDK_HEADERS_VARIABLE = {
|
|
2793
2151
|
anthropic: "ANTHROPIC_CUSTOM_HEADERS",
|
|
2794
2152
|
openai: "OPENAI_CUSTOM_HEADERS",
|
|
2795
2153
|
xai: "OPENAI_CUSTOM_HEADERS"
|
|
2796
2154
|
};
|
|
2797
|
-
/**
|
|
2798
|
-
* Absent — all three unset or blank — is a supported state, and the default: this
|
|
2799
|
-
* deployment has no model, the server says so to the admin, and no prompt pane is drawn.
|
|
2800
|
-
*
|
|
2801
|
-
* Throws on anything in between, so the server refuses to start rather than starting
|
|
2802
|
-
* without the model the operator thinks they configured: a key or a model with no
|
|
2803
|
-
* provider, a provider formancy has no adapter for, or a provider without its key or
|
|
2804
|
-
* its model. Throws too when the environment would add headers to every request the
|
|
2805
|
-
* provider's SDK sends. **No provider has a default model.** A model id goes stale when
|
|
2806
|
-
* the provider retires it, and then every turn fails as a model that could not be
|
|
2807
|
-
* reached, after an upgrade nobody made; and which model runs is what the operator pays
|
|
2808
|
-
* for. Neither is this file's to decide. The message never repeats a value, because the
|
|
2809
|
-
* key is one.
|
|
2810
|
-
*/
|
|
2811
2155
|
function modelSettings(env) {
|
|
2812
2156
|
const provider = setting(env, "FORMANCY_MODEL_PROVIDER");
|
|
2813
2157
|
const apiKey = setting(env, "FORMANCY_MODEL_API_KEY");
|
|
@@ -2831,46 +2175,15 @@ function modelSettings(env) {
|
|
|
2831
2175
|
function isProvider(value) {
|
|
2832
2176
|
return MODEL_PROVIDERS.includes(value);
|
|
2833
2177
|
}
|
|
2834
|
-
/** A setting, with the empty string read as unset — compose blanks a variable, it cannot drop one. */
|
|
2835
2178
|
function setting(env, name) {
|
|
2836
2179
|
const value = env[name];
|
|
2837
2180
|
return value === void 0 || value.trim() === "" ? void 0 : value;
|
|
2838
2181
|
}
|
|
2839
2182
|
//#endregion
|
|
2840
2183
|
//#region src/anthropic-completer.ts
|
|
2841
|
-
/**
|
|
2842
|
-
* Where the request goes. Named rather than left to the SDK, which would otherwise take
|
|
2843
|
-
* `ANTHROPIC_BASE_URL` from the environment: the deployment view says which host a form is
|
|
2844
|
-
* sent to, and an ambient variable could change it without anybody reading this file.
|
|
2845
|
-
*/
|
|
2846
2184
|
const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
|
2847
|
-
/**
|
|
2848
|
-
* The most a turn may write, thinking included.
|
|
2849
|
-
*
|
|
2850
|
-
* Generous on purpose: an answer is a whole form document, and one cut off at the limit is
|
|
2851
|
-
* not an answer at all (`truncated`), so a limit too low wastes the turn it stopped.
|
|
2852
|
-
* Streamed, so a long answer is not cut off by the HTTP timeout either. It is the ceiling
|
|
2853
|
-
* on what one turn can cost — the operator pays per token written, not per token allowed.
|
|
2854
|
-
*/
|
|
2855
2185
|
const ANTHROPIC_MAX_OUTPUT_TOKENS = 64e3;
|
|
2856
|
-
/** What a refused turn says when Anthropic gave no explanation. */
|
|
2857
2186
|
const DECLINED = "The model service declined this request.";
|
|
2858
|
-
/**
|
|
2859
|
-
* Claude, through Anthropic's official SDK (0165).
|
|
2860
|
-
*
|
|
2861
|
-
* One system prompt and one user message, with adaptive thinking — the model decides how
|
|
2862
|
-
* much to think — streamed and read with the SDK's final-message helper. **The stop reason
|
|
2863
|
-
* is read before the content.** A refusal ends the person's run as a decline, with
|
|
2864
|
-
* Anthropic's explanation; an answer stopped at the output limit, or by the context
|
|
2865
|
-
* window, is `truncated` and never handed over as the first half of a document. Thinking
|
|
2866
|
-
* blocks are not part of the answer.
|
|
2867
|
-
*
|
|
2868
|
-
* Adaptive thinking needs a model that has it — Claude Opus 4.6, Sonnet 4.6 and later. A
|
|
2869
|
-
* model without it is refused by Anthropic with a 400, and every turn ends `unavailable`.
|
|
2870
|
-
*
|
|
2871
|
-
* The SDK retries what it calls temporary failures (a 429, a 5xx, a dropped connection)
|
|
2872
|
-
* twice, by its own default.
|
|
2873
|
-
*/
|
|
2874
2187
|
function createAnthropicCompleter(options) {
|
|
2875
2188
|
const client = new Anthropic({
|
|
2876
2189
|
apiKey: options.apiKey,
|
|
@@ -2908,7 +2221,6 @@ function createAnthropicCompleter(options) {
|
|
|
2908
2221
|
return read$1(message);
|
|
2909
2222
|
} };
|
|
2910
2223
|
}
|
|
2911
|
-
/** A finished message, by its stop reason first. */
|
|
2912
2224
|
function read$1(message) {
|
|
2913
2225
|
switch (message.stop_reason) {
|
|
2914
2226
|
case "refusal": {
|
|
@@ -2936,7 +2248,6 @@ function read$1(message) {
|
|
|
2936
2248
|
};
|
|
2937
2249
|
}
|
|
2938
2250
|
}
|
|
2939
|
-
/** An error from the SDK, as a failure: the status when Anthropic answered, the message for the log. */
|
|
2940
2251
|
function unavailable$1(error) {
|
|
2941
2252
|
const status = error instanceof APIError && typeof error.status === "number" ? error.status : void 0;
|
|
2942
2253
|
const cause = error instanceof Error ? error.message : String(error);
|
|
@@ -2949,41 +2260,10 @@ function unavailable$1(error) {
|
|
|
2949
2260
|
}
|
|
2950
2261
|
//#endregion
|
|
2951
2262
|
//#region src/openai-completer.ts
|
|
2952
|
-
/**
|
|
2953
|
-
* Where each provider's requests go. Named rather than left to the SDK, which would take
|
|
2954
|
-
* `OPENAI_BASE_URL` from the environment and send every form wherever it pointed.
|
|
2955
|
-
*/
|
|
2956
2263
|
const OPENAI_BASE_URL = "https://api.openai.com/v1";
|
|
2957
|
-
/**
|
|
2958
|
-
* xAI's API, which xAI documents as compatible with OpenAI's SDKs and their Responses API at
|
|
2959
|
-
* this base URL. Taken from xAI's documentation as a web search summarised it on
|
|
2960
|
-
* 2026-10-10; the pages themselves refused the fetch, and no test here reaches xAI (0165).
|
|
2961
|
-
*/
|
|
2962
2264
|
const XAI_BASE_URL = "https://api.x.ai/v1";
|
|
2963
|
-
/**
|
|
2964
|
-
* The most a turn may write, reasoning included — as generous as Anthropic's, and for the
|
|
2965
|
-
* same reason: an answer cut off at the limit is not an answer (`truncated`).
|
|
2966
|
-
*/
|
|
2967
2265
|
const OPENAI_MAX_OUTPUT_TOKENS = 64e3;
|
|
2968
|
-
/** What a withheld answer says: the provider gives no words of its own for a filter. */
|
|
2969
2266
|
const FILTERED = "The model service withheld its answer to this request.";
|
|
2970
|
-
/**
|
|
2971
|
-
* OpenAI's models, or xAI's Grok, through OpenAI's official SDK and the Responses API (0165).
|
|
2972
|
-
*
|
|
2973
|
-
* The briefing as a system message and the request as a user message, streamed and read
|
|
2974
|
-
* with the SDK's final-response helper. `store: false`, so the provider keeps no copy of
|
|
2975
|
-
* the form for later retrieval; what it keeps under its own terms is not this adapter's
|
|
2976
|
-
* to say. **The response's status is read before its text.** Incomplete at the output
|
|
2977
|
-
* limit is `truncated`, never half a document; withheld by the provider's filter, or a
|
|
2978
|
-
* refusal in the output, ends the person's run as a decline.
|
|
2979
|
-
*
|
|
2980
|
-
* The SDK would take an organisation and a project from the environment and send them as
|
|
2981
|
-
* headers — to xAI as well — so both are set to nothing here. It would take a log level
|
|
2982
|
-
* too, and with OPENAI_LOG=debug write a line for every request and response to the
|
|
2983
|
-
* console — a second request log, outside the rule the server's own is built by
|
|
2984
|
-
* (SAFETY-ANALYSIS C3) — so the level is set. OPENAI_CUSTOM_HEADERS cannot be undone here, so `modelSettings` refuses to start
|
|
2985
|
-
* with it.
|
|
2986
|
-
*/
|
|
2987
2267
|
function createOpenAiCompleter(options) {
|
|
2988
2268
|
const client = new OpenAI({
|
|
2989
2269
|
apiKey: options.apiKey,
|
|
@@ -3024,7 +2304,6 @@ function createOpenAiCompleter(options) {
|
|
|
3024
2304
|
return read(response);
|
|
3025
2305
|
} };
|
|
3026
2306
|
}
|
|
3027
|
-
/** A finished response, by its status first. */
|
|
3028
2307
|
function read(response) {
|
|
3029
2308
|
if (response.status === "incomplete") {
|
|
3030
2309
|
const reason = response.incomplete_details?.reason;
|
|
@@ -3063,7 +2342,6 @@ function read(response) {
|
|
|
3063
2342
|
text: parts.flatMap((part) => part.type === "output_text" ? [part.text] : []).join("")
|
|
3064
2343
|
};
|
|
3065
2344
|
}
|
|
3066
|
-
/** An error from the SDK, as a failure: the status when the provider answered, the message for the log. */
|
|
3067
2345
|
function unavailable(error) {
|
|
3068
2346
|
const status = error instanceof APIError$1 && typeof error.status === "number" ? error.status : void 0;
|
|
3069
2347
|
const cause = error instanceof Error ? error.message : String(error);
|
|
@@ -3076,12 +2354,6 @@ function unavailable(error) {
|
|
|
3076
2354
|
}
|
|
3077
2355
|
//#endregion
|
|
3078
2356
|
//#region src/completers.ts
|
|
3079
|
-
/**
|
|
3080
|
-
* The adapter for the provider the operator named (0165): Anthropic through its own SDK,
|
|
3081
|
-
* OpenAI and xAI through OpenAI's, at each one's base URL.
|
|
3082
|
-
*
|
|
3083
|
-
* `fetch` is for the tests, which check where each provider's requests go.
|
|
3084
|
-
*/
|
|
3085
2357
|
function createCompleter(settings, fetch) {
|
|
3086
2358
|
const { apiKey, model } = settings;
|
|
3087
2359
|
const transport = fetch === void 0 ? {} : { fetch };
|
|
@@ -3108,4 +2380,4 @@ function createCompleter(settings, fetch) {
|
|
|
3108
2380
|
//#endregion
|
|
3109
2381
|
export { SUBMISSION_TOKEN_HEADER as _, createLocalFileStore as a, createPostgresStorage as c, LOG_LEVELS as d, createServerLog as f, SCHEMA_HASH_HEADER as g, maxFileBytesFrom as h, startFileCollector as i, bootstrapSchema as l, workerLog as m, MODEL_PROVIDERS as n, sha256 as o, databaseNotices as p, modelSettings as r, createPostgresRateLimitStore as s, createCompleter as t, createApp as u };
|
|
3110
2382
|
|
|
3111
|
-
//# sourceMappingURL=completers-
|
|
2383
|
+
//# sourceMappingURL=completers-CQQDS4qr.mjs.map
|