@formancy/server 0.7.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 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/model-settings.ts","../src/routes/model.ts","../src/file-store.ts","../src/server-log.ts","../src/headers.ts","../src/app.ts","../src/postgres-storage.ts","../src/postgres-rate-limits.ts","../src/db.ts","../src/file-collector.ts","../src/completers.ts"],"mappings":";;;;;;;;;;;;;;;qBASa;;KAGD,wBAAwB;KAExB;WACG;;WAEA;WACA,UAAU;WACV;WACA;;KAGV,cAAc,SAAS;;;;;;;;;;;;;;;wBA8BZ,cAAc,KAAK,cAAc;;;;UCrChC;WACN,UAAU;WACV;WACA,WAAW;;;;;;;;;;;;;;;;;;UCCL;;EAEf,IAAI,aAAa,OAAO,SAAS;;EAEjC,KAAK,cAAc,QAAQ;;EAE3B,OAAO,cAAc;;EAErB,OAAO,cAAc;;;;;;;;;;;wBAYP,qBAAqB,eAAe;;;;;;;;wBA0DpC,OAAO,OAAO;;;;;;;;;;;;;;;;;;;;;;cCzEjB;KACD,mBAAmB;;UAKd;EACf,MAAM;;;UAIS;WACN,MAAM;WACN,OAAO;;;KAoRN,YAAY,KAAK;;;;;;;;;;;qBClThB;;;;;qBASA;;;UCgCI;;EAEf;;;;;;EAMA;IAAmB;IAAe;;;;;;;;;;;EAUlC;;;;;;;;;;;;;;EAcA,iBAAiB;;;;;EAKjB;IAAwB;IAAa;;;;EAGrC,iBAAiB;;EAEjB;IAAmB;IAAa;;;;;;;;EAOhC;;;;;;EAMA;;;;;;;EAOA,YAAY;;;;;;;EAOZ;;EAEA,UAAU;;;;;;EAMV,QAAQ;;EAER;IAAmB;IAAa;;;;;;;EAMhC,MAAM;;;;;;;;;;;;;;;;;wBAkBc,UAAU,SAAS,SAAS,SAAS,aAAa,QAAQ;;;;wBC9IhE,sBAAsB,KAAK,SAAS,MAAM;;;UC2BzC;;WAEN;;WAEA;;;;;;;;WAQA,MAAM;;;UAIA;;WAEN,OAAO;;WAEP,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBA6BN,6BACd,qBACA,UAAS,2BACR;;;;;;;;wBC8GmB,gBAAgB,KAAK,SAAS,MAAM;;;;;;;;;;;;;;;;;;;;;;;;UC5KzC;EACf;;wBAGc,mBACd,SAAS,SACT,OAAO,WACP;EAAW;EAAqB;EAAqB,MAAM;IAC1D;;;;;;;;;wBCxBa,gBACd,UAAU,QAAQ;EAAiB;IACnC,SAAS,gBAAgB,MAAM,SAAS,OAAO,gBAAgB,QAAQ,YACtE"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/model-settings.ts","../src/routes/model.ts","../src/file-store.ts","../src/server-log.ts","../src/headers.ts","../src/app.ts","../src/postgres-storage.ts","../src/postgres-rate-limits.ts","../src/db.ts","../src/file-collector.ts","../src/completers.ts"],"mappings":";;;;;;;;;;;;;;;qBASa;;KAGD,wBAAwB;KAExB;WACG;;WAEA;WACA,UAAU;WACV;WACA;;KAGV,cAAc,SAAS;;;;;;;;;;;;;;;wBA8BZ,cAAc,KAAK,cAAc;;;;UCrChC;WACN,UAAU;WACV;WACA,WAAW;;;;;;;;;;;;;;;;;;UCCL;;EAEf,IAAI,aAAa,OAAO,SAAS;;EAEjC,KAAK,cAAc,QAAQ;;EAE3B,OAAO,cAAc;;EAErB,OAAO,cAAc;;;;;;;;;;;wBAYP,qBAAqB,eAAe;;;;;;;;wBA0DpC,OAAO,OAAO;;;;;;;;;;;;;;;;;;;;;;cCzEjB;KACD,mBAAmB;;UAKd;EACf,MAAM;;;UAIS;WACN,MAAM;WACN,OAAO;;;KAoRN,YAAY,KAAK;;;;;;;;;;;qBClThB;;;;;qBASA;;;UCiCI;;EAEf;;;;;;EAMA;IAAmB;IAAe;;;;;;;;;;;EAUlC;;;;;;;;;;;;;;EAcA,iBAAiB;;;;;EAKjB;IAAwB;IAAa;;;;EAGrC,iBAAiB;;EAEjB;IAAmB;IAAa;;;;;;;;EAOhC;;;;;;EAMA;;;;;;;EAOA,YAAY;;;;;;;EAOZ;;EAEA,UAAU;;;;;;EAMV,QAAQ;;EAER;IAAmB;IAAa;;;;;;;EAMhC,MAAM;;;;;;;;;;;;;;;;;wBAkBc,UAAU,SAAS,SAAS,SAAS,aAAa,QAAQ;;;;wBC/IhE,sBAAsB,KAAK,SAAS,MAAM;;;UC2BzC;;WAEN;;WAEA;;;;;;;;WAQA,MAAM;;;UAIA;;WAEN,OAAO;;WAEP,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBA6BN,6BACd,qBACA,UAAS,2BACR;;;;;;;;wBC8GmB,gBAAgB,KAAK,SAAS,MAAM;;;;;;;;;;;;;;;;;;;;;;;;UC5KzC;EACf;;wBAGc,mBACd,SAAS,SACT,OAAO,WACP;EAAW;EAAqB;EAAqB,MAAM;IAC1D;;;;;;;;;wBCxBa,gBACd,UAAU,QAAQ;EAAiB;IACnC,SAAS,gBAAgB,MAAM,SAAS,OAAO,gBAAgB,QAAQ,YACtE"}
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { _ as SUBMISSION_TOKEN_HEADER, a as createLocalFileStore, c as createPostgresStorage, g as SCHEMA_HASH_HEADER, i as startFileCollector, l as bootstrapSchema, n as MODEL_PROVIDERS, o as sha256, r as modelSettings, s as createPostgresRateLimitStore, t as createCompleter, u as createApp } from "./completers-C-E2b7Jm.mjs";
1
+ import { _ as SUBMISSION_TOKEN_HEADER, a as createLocalFileStore, c as createPostgresStorage, g as SCHEMA_HASH_HEADER, i as startFileCollector, l as bootstrapSchema, n as MODEL_PROVIDERS, o as sha256, r as modelSettings, s as createPostgresRateLimitStore, t as createCompleter, u as createApp } from "./completers-CQQDS4qr.mjs";
2
2
  export { MODEL_PROVIDERS, SCHEMA_HASH_HEADER, SUBMISSION_TOKEN_HEADER, bootstrapSchema, createApp, createCompleter, createLocalFileStore, createPostgresRateLimitStore, createPostgresStorage, modelSettings, sha256, startFileCollector };
package/dist/main.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as createLocalFileStore, c as createPostgresStorage, d as LOG_LEVELS, f as createServerLog, h as maxFileBytesFrom, i as startFileCollector, l as bootstrapSchema, m as workerLog, p as databaseNotices, r as modelSettings, s as createPostgresRateLimitStore, t as createCompleter, u as createApp } from "./completers-C-E2b7Jm.mjs";
1
+ import { a as createLocalFileStore, c as createPostgresStorage, d as LOG_LEVELS, f as createServerLog, h as maxFileBytesFrom, i as startFileCollector, l as bootstrapSchema, m as workerLog, p as databaseNotices, r as modelSettings, s as createPostgresRateLimitStore, t as createCompleter, u as createApp } from "./completers-CQQDS4qr.mjs";
2
2
  import { createHash, createHmac, randomBytes } from "node:crypto";
3
3
  import { deliveryHeaders, drainOutbox, isIpLiteral, isPrivateAddress } from "@formancy/server-core";
4
4
  import postgres from "postgres";
@@ -7,12 +7,6 @@ import { Agent, fetch as fetch$1 } from "undici";
7
7
  import { Readable } from "node:stream";
8
8
  import { connect, isIP } from "node:net";
9
9
  //#region src/webhook-url.ts
10
- /**
11
- * What is wrong with this webhook URL, or undefined when nothing is.
12
- *
13
- * Checked when the URL is saved, so an author finds out immediately rather
14
- * than through a delivery that silently never works.
15
- */
16
10
  function webhookUrlProblem(raw, options = {}) {
17
11
  let url;
18
12
  try {
@@ -112,7 +106,6 @@ async function deliver(input, options = {}) {
112
106
  await agent.close();
113
107
  }
114
108
  }
115
- /** Consume at most `cap` bytes so the connection can be reused, then stop. */
116
109
  async function drain(response, cap) {
117
110
  const reader = response.body?.getReader();
118
111
  if (reader === void 0) return;
@@ -201,28 +194,7 @@ function startChallengeSweeper(storage, options = {}) {
201
194
  }
202
195
  //#endregion
203
196
  //#region src/sigv4.ts
204
- /**
205
- * AWS Signature Version 4, for the four requests an object store needs.
206
- *
207
- * Ours rather than an SDK: sixty lines against a frozen specification, with the
208
- * algorithm's own vocabulary in the names so it can be read beside AWS's description of
209
- * it. It signs requests for **any** S3-compatible endpoint, so Garage, real S3 and
210
- * Backblaze take the same code path
211
- * ([0064](../../../docs/decisions/0064-an-object-store-behind-the-same-interface.md)).
212
- *
213
- * **What verifies it is a real S3 implementation in a container**, refusing a wrong
214
- * signature with a 403. The unit tests pin the mechanical properties; they cannot tell
215
- * whether the canonical request is *right*, only that it is consistent with itself.
216
- */
217
- /** The SHA-256 of an empty body, which a GET or DELETE carries. */
218
197
  const EMPTY_PAYLOAD_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
219
- /**
220
- * The headers to send, including `authorization`.
221
- *
222
- * Returns headers rather than mutating a request so that the caller's `fetch`
223
- * options stay the caller's business, and so the signature is inspectable in a
224
- * test without a server.
225
- */
226
198
  function signRequest(args) {
227
199
  const { method, url, payloadHash, service, region, accessKeyId, secretAccessKey, now } = args;
228
200
  const headers = {};
@@ -253,44 +225,20 @@ function signRequest(args) {
253
225
  headers["authorization"] = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${hmac(hmac(hmac(hmac(hmac(`AWS4${secretAccessKey}`, dateStamp), region), service), "aws4_request"), stringToSign).toString("hex")}`;
254
226
  return headers;
255
227
  }
256
- /**
257
- * `20150830T123600Z` — basic ISO 8601, not the extended form.
258
- *
259
- * `toISOString()` gives `2015-08-30T12:36:00.000Z`, which is rejected. Stripping
260
- * the punctuation is the whole conversion, and the credential scope then wants
261
- * the same instant as `20150830`, which is why this returns the long one and the
262
- * caller slices it.
263
- */
264
228
  function basicIso8601(at) {
265
229
  return `${at.toISOString().replace(/[-:]/g, "").split(".")[0] ?? ""}Z`;
266
230
  }
267
- /**
268
- * The path, encoded **once** for s3 and twice for everything else.
269
- *
270
- * This is the one place where s3 departs from the general rule, and getting it
271
- * wrong produces a signature that is valid for every other AWS service and
272
- * refused by the only one we call -- and refused only for keys containing a
273
- * character that needs encoding, which is the worst possible way to find out.
274
- */
275
231
  function canonicalPath(url, service) {
276
232
  const once = url.pathname === "" ? "/" : url.pathname;
277
233
  if (service === "s3") return once;
278
234
  return once.split("/").map((segment) => uriEncode(decodeURIComponent(segment))).join("/");
279
235
  }
280
- /** Sorted by encoded name, values encoded, `=` even when empty. */
281
236
  function canonicalQuery(url) {
282
237
  const pairs = [];
283
238
  for (const [name, value] of url.searchParams) pairs.push([uriEncode(name), uriEncode(value)]);
284
239
  pairs.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
285
240
  return pairs.map(([name, value]) => `${name}=${value}`).join("&");
286
241
  }
287
- /**
288
- * AWS's `UriEncode`, which is not `encodeURIComponent`.
289
- *
290
- * The differences are small and each one is a 403: `!`, `'`, `(`, `)` and `*`
291
- * are left alone by `encodeURIComponent` and must be encoded, hex digits must be
292
- * uppercase, and a space is `%20` rather than `+`.
293
- */
294
242
  function uriEncode(value) {
295
243
  return encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
296
244
  }
@@ -305,40 +253,11 @@ function hmac(key, data) {
305
253
  function createS3FileStore(config) {
306
254
  const endpoint = config.endpoint.replace(/\/+$/, "");
307
255
  const timeoutMs = config.timeoutMs ?? 3e4;
308
- /**
309
- * The object's URL, with the key's slashes kept and everything else encoded.
310
- *
311
- * A key is a path inside the bucket, so its separators are structural. Every
312
- * other character has to be encoded or the request line is invalid -- and a
313
- * space encoded as `+`, which is what form encoding would do, names a
314
- * different object.
315
- */
316
256
  const urlFor = (key) => {
317
257
  if (key === "" || key.includes("\\") || key.split("/").includes("..")) throw new Error(`Refusing a storage key that could address another bucket: ${key}`);
318
258
  const path = key.split("/").map(encodeSegment).join("/");
319
259
  return new URL(`${endpoint}/${encodeSegment(config.bucket)}/${path}`);
320
260
  };
321
- /**
322
- * One request, and the clock that abandons it.
323
- *
324
- * **The clock measures how long the store keeps us waiting.** It starts when the
325
- * request is sent and stops when the headers arrive -- which, for a PUT, is after
326
- * the whole body has gone. It starts again at once, because the store now owes the
327
- * body, stops when a chunk arrives, and starts again only when the caller asks for
328
- * the next one -- so a chunk the caller is still holding costs the store nothing.
329
- *
330
- * **Not `AbortSignal.timeout`, which is what this replaced.** That bounds the whole
331
- * exchange, body included, so a download still streaming when it ran out was cut
332
- * off mid-file however steadily it was arriving. And how long a download streams
333
- * is set by the person downloading it more than by the store: backpressure carries
334
- * a slow connection all the way back to this socket, so a bound on the whole
335
- * request was a bound on the downloader's bandwidth.
336
- *
337
- * The clock restarts at the headers rather than at the first read so that a body
338
- * nobody reads -- the error document on a path that throws -- is still abandoned
339
- * after `timeoutMs`, rather than holding its connection for however long something
340
- * else takes to close it.
341
- */
342
261
  const send = async (method, key, body) => {
343
262
  const url = urlFor(key);
344
263
  const payloadHash = body === void 0 ? EMPTY_PAYLOAD_SHA256 : createHash("sha256").update(body).digest("hex");
@@ -378,20 +297,6 @@ function createS3FileStore(config) {
378
297
  body: timed(response.body, waiting)
379
298
  };
380
299
  };
381
- /**
382
- * A status that means "there is no such object", as opposed to one that means
383
- * the store could not answer.
384
- *
385
- * The distinction is the whole reason this is a named function. Reading a
386
- * could-not-answer as absence makes the collector delete the row for bytes that
387
- * still exist and makes a download return nothing for a file the person can see
388
- * listed.
389
- *
390
- * **404 only, and 403 deliberately not.** S3 answers 403 for an object a caller may
391
- * not list, but this caller owns the bucket -- so 403 means the credentials are
392
- * wrong, and reading that as absence would make a misconfigured deployment look like
393
- * one where every file has vanished and have the collector delete every row.
394
- */
395
300
  const isAbsent = (response) => response.status === 404;
396
301
  const failed = (method, key, response) => /* @__PURE__ */ new Error(`S3 ${method} of ${key} failed: ${String(response.status)} ${response.statusText}`);
397
302
  return {
@@ -427,16 +332,6 @@ function createS3FileStore(config) {
427
332
  }
428
333
  };
429
334
  }
430
- /**
431
- * A clock for one request: started while the store owes something, stopped while it
432
- * does not, and aborting the request if it ever runs out. `owed` names what the
433
- * store failed to send, for the error that says so.
434
- *
435
- * Unref'd, as `AbortSignal.timeout` was: a deadline is not work. One is left running
436
- * under every body nobody reads, and a referenced timer would hold the process it
437
- * lives in for up to `timeoutMs` after everything else had finished. It still fires
438
- * when it matters: a request still in flight holds the process up by its connection.
439
- */
440
335
  function clock(timeoutMs, request) {
441
336
  const controller = new AbortController();
442
337
  let timer;
@@ -454,12 +349,6 @@ function clock(timeoutMs, request) {
454
349
  stop
455
350
  };
456
351
  }
457
- /**
458
- * A body chunk by chunk, with the clock running only while the next chunk is awaited.
459
- *
460
- * An abort after the headers errors the body with the clock's reason, so a body the
461
- * clock ran out on fails rather than ending early and looking complete.
462
- */
463
352
  async function* timed(stream, waiting) {
464
353
  try {
465
354
  for await (const chunk of stream) {
@@ -471,36 +360,15 @@ async function* timed(stream, waiting) {
471
360
  waiting.stop();
472
361
  }
473
362
  }
474
- /** Reads a body to its end and drops it, which hands the connection back to the pool. */
475
363
  async function discard(body) {
476
364
  if (body === null) return;
477
365
  for await (const _chunk of body);
478
366
  }
479
- /**
480
- * AWS's `UriEncode` for one path segment.
481
- *
482
- * `encodeURIComponent` leaves `!'()*` alone and S3 wants them encoded, and its
483
- * hex digits are already uppercase. Duplicated from the signer rather than
484
- * shared, because the signer's copy is about hashing and this one is about the
485
- * request line: if they ever need to differ, they differ, and a shared helper
486
- * would make that a conversation instead of an edit.
487
- */
488
367
  function encodeSegment(value) {
489
368
  return encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
490
369
  }
491
370
  //#endregion
492
371
  //#region src/file-store-settings.ts
493
- /**
494
- * Absent — or empty, see `setting` — means this deployment accepts no files,
495
- * and that is a supported state rather than a misconfiguration: a form with a
496
- * file field still renders and still submits, and the field says plainly that
497
- * there is nowhere to put one. Turning uploads on is naming a directory, which
498
- * in a container is naming a volume — and a volume is the one thing a
499
- * self-hoster has to think about, so it is not defaulted here.
500
- *
501
- * Throws on a configuration that names two stores, or an object store without
502
- * everything it needs, so the server refuses to start rather than serving one.
503
- */
504
372
  function fileStoreSettings(env) {
505
373
  const directory = setting(env, "FORMANCY_FILES_DIR");
506
374
  const endpoint = setting(env, "FORMANCY_S3_ENDPOINT");
@@ -527,57 +395,14 @@ function fileStoreSettings(env) {
527
395
  directory
528
396
  };
529
397
  }
530
- /**
531
- * A setting, with the empty string read as unset.
532
- *
533
- * Because compose cannot remove a variable, only blank it. Both compose files
534
- * point FORMANCY_FILES_DIR at their volume, and `.env` switching to the object
535
- * store does so by setting it to `""` — which, read as a directory, is two
536
- * stores and a server that refuses to start. Read as a path it is no better:
537
- * `createLocalFileStore('')` resolves to the working directory, which switches
538
- * uploads on in a directory nobody chose — in the published image, `/app`,
539
- * which its user cannot write.
540
- */
541
398
  function setting(env, name) {
542
399
  const value = env[name];
543
400
  return value === "" ? void 0 : value;
544
401
  }
545
402
  //#endregion
546
403
  //#region src/clamd-scanner.ts
547
- /** How much is sent per INSTREAM chunk. clamd takes chunks of any size up to its stream limit. */
548
404
  const CHUNK = 65536;
549
- /**
550
- * clamd's own default `StreamMaxLength`, 100 MiB — as the `clamd.conf` ClamAV 1.5.4 ships
551
- * says, and as measured against it: a 90 MiB stream was answered, a 110 MiB one refused.
552
- */
553
405
  const CLAMD_DEFAULT_MAX_BYTES = 104857600;
554
- /**
555
- * ClamAV's daemon, asked over its INSTREAM command
556
- * ([0131](../../../docs/decisions/0131-an-upload-is-scanned-before-it-is-kept.md)).
557
- *
558
- * The protocol is clamd's own and small enough to speak directly — the command, the bytes in
559
- * length-prefixed chunks, a zero-length chunk to end them, and one line back — so this adds
560
- * no dependency to the server. Its tests run against a stand-in that speaks the protocol as
561
- * clamd's manual describes it; it was run once against ClamAV 1.5.4 itself, and the
562
- * documentation says what that showed and what it did not.
563
- *
564
- * **Only `stream: OK` is clean.** A finding is refused by its name. A file over clamd's size
565
- * limit is refused too, because sending it again changes nothing. Anything else — an answer
566
- * this does not understand, no answer, no daemon — rejects, which the use-case reads as a
567
- * scanner that cannot be asked, and refuses the file for.
568
- *
569
- * **`maxBytes` is clamd's `StreamMaxLength`, and is checked before a byte is sent.** Past
570
- * it, clamd says so and closes the connection while bytes may still be arriving. Against the
571
- * stand-in the bytes in flight reset it and the answer was lost to `ECONNRESET`, which reads
572
- * as a scanner that is down and would tell the person to try again forever; the real clamd
573
- * measured here answered intact. Checked first, the outcome does not depend on which.
574
- * Set it to what clamd is configured with.
575
- *
576
- * **`timeoutMs` is for silence, not for the whole scan.** It is the socket's idle timeout,
577
- * restarted by any traffic on the connection, so it abandons a clamd that has stopped
578
- * answering and not a scan that is still moving. Nothing here bounds how long a scan takes,
579
- * which is why the lease a request holds a file under can be outlasted (0153).
580
- */
581
406
  function createClamdScanner({ host, port, maxBytes = CLAMD_DEFAULT_MAX_BYTES, timeoutMs = 3e4 }) {
582
407
  return { scan: (_file, bytes) => new Promise((resolve, reject) => {
583
408
  if (bytes.byteLength > maxBytes) {
@@ -636,36 +461,6 @@ function verdictOf(reply) {
636
461
  //#endregion
637
462
  //#region src/trust-proxy.ts
638
463
  const WHAT_TO_WRITE = "Write the address of the reverse proxy in front of this server, e.g. 10.0.0.2, or a comma-separated list of addresses and CIDR ranges, e.g. 10.0.0.2,172.18.0.0/16 — or leave it unset to trust no proxy.";
639
- /**
640
- * The proxies whose `X-Forwarded-For` this server believes, read from
641
- * `FORMANCY_TRUST_PROXY`.
642
- *
643
- * Every limit on the public plane counts the client's address. Behind a reverse
644
- * proxy the socket's address is the proxy's, so trusting nothing gives every
645
- * respondent one shared budget; trusting the wrong thing lets a client write its
646
- * own `X-Forwarded-For` and choose a fresh budget per request. So the setting
647
- * names the proxies, and only addresses and ranges:
648
- *
649
- * - **No hop count.** Fastify's option takes a number, and the Fastify this
650
- * server depends on treats one as "trust nothing", because a count cannot
651
- * tell the proxy from a client that connects directly. Accepting it here
652
- * would be a setting that reads as configured and does nothing;
653
- * `trust-proxy.test.ts` checks that Fastify still ignores one.
654
- * - **No `true`,** which Fastify also takes, and which believes anybody.
655
- * - **No names,** such as `uniquelocal`, which Fastify's `proxy-addr` expands
656
- * into ranges nobody reading the setting can see — that one is every private
657
- * address, a Docker network's gateway with it, and the gateway is where
658
- * Docker hands published-port connections over from. Refused by the address
659
- * check below, as a hostname is.
660
- *
661
- * Decision 0156 has the reasoning, and what was measured against Docker.
662
- *
663
- * Absent or empty returns undefined, which trusts nothing — the behaviour before
664
- * this setting existed, and the right one with no proxy in front. Anything else
665
- * that is not an address or a range is refused here, with a sentence saying what
666
- * to write, rather than left to Fastify's parser, whose TypeError names neither
667
- * the variable nor the fix.
668
- */
669
464
  function trustProxyFrom(value) {
670
465
  if (value === void 0 || value.trim() === "") return void 0;
671
466
  const trimmed = value.trim();
@@ -675,14 +470,6 @@ function trustProxyFrom(value) {
675
470
  for (const entry of entries) if (!isAddressOrRange(entry)) throw new Error(`FORMANCY_TRUST_PROXY has "${entry}", which is not an IP address or a CIDR range. ${WHAT_TO_WRITE}`);
676
471
  return entries;
677
472
  }
678
- /**
679
- * An address, or an address with a prefix length its family allows.
680
- *
681
- * A zero prefix is refused, as Fastify's parser refuses it: `/0` is every
682
- * address, which is `true` spelled differently. A netmask after the slash is
683
- * refused too, though Fastify reads one — a second spelling of the same thing
684
- * is a second thing to get wrong, and a prefix length says it.
685
- */
686
473
  function isAddressOrRange(entry) {
687
474
  const slash = entry.indexOf("/");
688
475
  if (slash === -1) return isIP(entry) !== 0;
@@ -694,18 +481,6 @@ function isAddressOrRange(entry) {
694
481
  }
695
482
  //#endregion
696
483
  //#region src/log-settings.ts
697
- /**
698
- * How much the server logs, read from `FORMANCY_LOG_LEVEL`
699
- * ([0168](../../../docs/decisions/0168-the-log-is-built-from-a-list-of-fields.md)).
700
- *
701
- * Unset or empty is `info`: a line per request and per error. The log is on unless somebody
702
- * turns it off, because off is the state this setting replaced, where nothing said why a
703
- * request failed. `off` keeps no log at all; anything else that is not one of pino's level
704
- * names, spelled as pino spells them, stops the server at startup — a server that started
705
- * anyway would log at a level nobody chose, or not at all while its operator believed it
706
- * did. Its own module because `main.ts` connects to a database when it is imported, so
707
- * nothing in it can be tested.
708
- */
709
484
  function logLevelFrom(env) {
710
485
  const value = env["FORMANCY_LOG_LEVEL"]?.trim() ?? "";
711
486
  if (value === "") return "info";
@@ -717,28 +492,12 @@ function logLevelFrom(env) {
717
492
  //#endregion
718
493
  //#region src/main.ts
719
494
  process.env["RECHECK_BACKEND"] ??= "pure";
720
- /**
721
- * The runnable server: `node dist/main.mjs` with DATABASE_URL set, or
722
- * `docker compose up` from the repo root, which supplies both.
723
- *
724
- * Bootstraps its own tables on start — a self-hosted skeleton that works five
725
- * minutes after `git clone` is the adoption funnel; migrations arrive with the
726
- * lifecycle work.
727
- */
728
495
  const databaseUrl = process.env["DATABASE_URL"];
729
496
  if (databaseUrl === void 0 || databaseUrl === "") {
730
497
  console.error("DATABASE_URL is required, e.g. postgres://formancy:formancy@localhost:5432/formancy");
731
498
  process.exit(1);
732
499
  }
733
500
  const port = Number(process.env["PORT"] ?? 4380);
734
- /**
735
- * The server's log, on standard output: a JSON line per request and per error, each built
736
- * from a list of fields, so no body, header, query string, answer or credential has anywhere
737
- * to go (0168). `info` unless `FORMANCY_LOG_LEVEL` says otherwise; `off` keeps none, and a
738
- * level the server does not have stops it here. Read first, so the database's notices on the
739
- * first connection go through it too rather than being printed whole by the driver, and so
740
- * does the rate limits' counter, which is made before the app whose log it would otherwise use.
741
- */
742
501
  const logLevel = logLevelFrom(process.env);
743
502
  const log = logLevel === "off" ? void 0 : createServerLog(process.stdout, logLevel);
744
503
  const sql = postgres(databaseUrl, { onnotice: databaseNotices(log) });
@@ -749,20 +508,9 @@ if (authSecret === void 0 || authSecret === "") {
749
508
  }
750
509
  const adminEmail = process.env["FORMANCY_ADMIN_EMAIL"];
751
510
  const adminPassword = process.env["FORMANCY_ADMIN_PASSWORD"];
752
- /** Where uploaded bytes go: the rules are in `fileStoreSettings`, which throws. */
753
511
  const storeSettings = fileStoreSettings(process.env);
754
512
  const fileStore = storeSettings.kind === "s3" ? createS3FileStore(storeSettings.config) : storeSettings.kind === "local" ? createLocalFileStore(storeSettings.directory) : void 0;
755
513
  const maxFileBytes = maxFileBytesFrom(process.env);
756
- /**
757
- * A ClamAV daemon to ask about every upload before its bytes are kept.
758
- *
759
- * Unset is a supported state: no file is scanned, which the documentation says. Set, a
760
- * file is kept only once clamd has called it clean, and refused when clamd cannot be
761
- * reached — the point of configuring a scanner is that nothing unscanned gets in
762
- * ([0131](../../../docs/decisions/0131-an-upload-is-scanned-before-it-is-kept.md)).
763
- * Not checked at startup: clamd takes minutes to load its signatures, and a server that
764
- * refused to start until then would turn a slow scanner into an outage of everything.
765
- */
766
514
  const clamdHost = process.env["FORMANCY_CLAMD_HOST"];
767
515
  const clamdPort = Number(process.env["FORMANCY_CLAMD_PORT"] ?? 3310);
768
516
  const clamdMaxBytes = Number(process.env["FORMANCY_CLAMD_MAX_BYTES"] ?? 104857600);
@@ -773,37 +521,9 @@ const scanner = clamdHost === void 0 || clamdHost === "" ? void 0 : createClamdS
773
521
  port: clamdPort,
774
522
  maxBytes: clamdMaxBytes
775
523
  });
776
- /**
777
- * Turns the proof-of-work challenge on for anonymous submissions.
778
- *
779
- * Unset is a supported state rather than a misconfiguration: a deployment
780
- * whose forms all require a session has no anonymous surface to protect, and
781
- * the challenge route says so with a 404 rather than failing. A deployment
782
- * with public forms should set it — the other layers are rate limits, an
783
- * origin allowlist and a body cap, all of which an attacker with a few
784
- * addresses walks past.
785
- *
786
- * Separate from FORMANCY_AUTH_SECRET so that rotating one does not invalidate
787
- * the other: rotating this one costs an unsolved puzzle, rotating that one
788
- * costs everybody their session.
789
- */
790
524
  const challengeSecret = process.env["FORMANCY_CHALLENGE_SECRET"];
791
525
  if (challengeSecret !== void 0 && challengeSecret.length < 32) throw new Error("FORMANCY_CHALLENGE_SECRET must be at least 32 characters, or unset.");
792
- /**
793
- * The reverse proxies whose X-Forwarded-For names the client. Unset trusts none,
794
- * which is right with nothing in front and wrong behind a proxy: every
795
- * respondent would share its one rate-limit budget. Refused at startup when it
796
- * is not addresses and ranges, since a server that started anyway would count
797
- * the wrong client and nothing would say so.
798
- */
799
526
  const trustProxy = trustProxyFrom(process.env["FORMANCY_TRUST_PROXY"]);
800
- /**
801
- * The model the builders ask through this server, with its key kept here rather than in a
802
- * browser (0165). Unset is a supported state and the default: no model, no prompt pane in
803
- * the admin, and no form sent anywhere. Half a configuration — a key with no provider, a
804
- * provider with no model — is refused here, because a server that started anyway would
805
- * have no model while the operator believed it had one. The key is never logged.
806
- */
807
527
  const modelChoice = modelSettings(process.env);
808
528
  const model = modelChoice.kind === "none" ? void 0 : {
809
529
  provider: modelChoice.provider,