@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.
@@ -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-C-E2b7Jm.mjs.map
2383
+ //# sourceMappingURL=completers-CQQDS4qr.mjs.map