@formancy/server 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.mjs CHANGED
@@ -1,11 +1,11 @@
1
- import { a as bootstrapSchema, i as createPostgresStorage, n as createLocalFileStore, o as createApp, t as startFileCollector } from "./file-collector-CGSJAtGO.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-C-E2b7Jm.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";
5
5
  import { lookup } from "node:dns/promises";
6
6
  import { Agent, fetch as fetch$1 } from "undici";
7
7
  import { Readable } from "node:stream";
8
- import { connect } from "node:net";
8
+ import { connect, isIP } from "node:net";
9
9
  //#region src/webhook-url.ts
10
10
  /**
11
11
  * What is wrong with this webhook URL, or undefined when nothing is.
@@ -132,6 +132,7 @@ async function drain(response, cap) {
132
132
  //#region src/outbox-worker.ts
133
133
  function startOutboxWorker(storage, options = {}) {
134
134
  const interval = options.intervalMs ?? 5e3;
135
+ const log = workerLog(options.log);
135
136
  let stopped = false;
136
137
  let running = false;
137
138
  const tick = async () => {
@@ -155,7 +156,10 @@ function startOutboxWorker(storage, options = {}) {
155
156
  })
156
157
  });
157
158
  } catch (error) {
158
- console.error("outbox: pass failed", error);
159
+ log.error({
160
+ event: "outbox.failed",
161
+ err: error
162
+ });
159
163
  } finally {
160
164
  running = false;
161
165
  }
@@ -170,6 +174,7 @@ function startOutboxWorker(storage, options = {}) {
170
174
  //#endregion
171
175
  //#region src/challenge-sweeper.ts
172
176
  function startChallengeSweeper(storage, options = {}) {
177
+ const log = workerLog(options.log);
173
178
  const interval = options.intervalMs ?? 36e5;
174
179
  let stopped = false;
175
180
  let running = false;
@@ -179,7 +184,10 @@ function startChallengeSweeper(storage, options = {}) {
179
184
  try {
180
185
  await storage.forgetExpiredChallenges((/* @__PURE__ */ new Date()).toISOString());
181
186
  } catch (error) {
182
- console.error("challenges: a sweep failed", error);
187
+ log.error({
188
+ event: "sweeper.failed",
189
+ err: error
190
+ });
183
191
  } finally {
184
192
  running = false;
185
193
  }
@@ -310,6 +318,27 @@ function createS3FileStore(config) {
310
318
  const path = key.split("/").map(encodeSegment).join("/");
311
319
  return new URL(`${endpoint}/${encodeSegment(config.bucket)}/${path}`);
312
320
  };
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
+ */
313
342
  const send = async (method, key, body) => {
314
343
  const url = urlFor(key);
315
344
  const payloadHash = body === void 0 ? EMPTY_PAYLOAD_SHA256 : createHash("sha256").update(body).digest("hex");
@@ -324,12 +353,30 @@ function createS3FileStore(config) {
324
353
  secretAccessKey: config.secretAccessKey,
325
354
  now: /* @__PURE__ */ new Date()
326
355
  });
327
- return fetch(url, {
356
+ const waiting = clock(timeoutMs, `S3 ${method} of ${key}`);
357
+ waiting.start("no answer");
358
+ const response = await fetch(url, {
328
359
  method,
329
360
  headers,
330
361
  ...body === void 0 ? {} : { body: new Uint8Array(body) },
331
- signal: AbortSignal.timeout(timeoutMs)
332
- });
362
+ signal: waiting.signal
363
+ }).finally(waiting.stop);
364
+ const { ok, status, statusText } = response;
365
+ const answer = {
366
+ ok,
367
+ status,
368
+ statusText,
369
+ headers: response.headers
370
+ };
371
+ if (response.body === null) return {
372
+ ...answer,
373
+ body: null
374
+ };
375
+ waiting.start("no data");
376
+ return {
377
+ ...answer,
378
+ body: timed(response.body, waiting)
379
+ };
333
380
  };
334
381
  /**
335
382
  * A status that means "there is no such object", as opposed to one that means
@@ -351,21 +398,21 @@ function createS3FileStore(config) {
351
398
  async put(key, bytes) {
352
399
  const response = await send("PUT", key, bytes);
353
400
  if (!response.ok) throw failed("PUT", key, response);
354
- await response.arrayBuffer();
401
+ await discard(response.body);
355
402
  },
356
403
  async open(key) {
357
404
  const response = await send("GET", key);
358
405
  if (isAbsent(response)) {
359
- await response.arrayBuffer();
406
+ await discard(response.body);
360
407
  return;
361
408
  }
362
409
  if (!response.ok || response.body === null) throw failed("GET", key, response);
363
- return Readable.fromWeb(response.body);
410
+ return Readable.from(response.body, { objectMode: false });
364
411
  },
365
412
  async remove(key) {
366
413
  const response = await send("DELETE", key);
367
414
  if (response.ok || isAbsent(response)) {
368
- await response.arrayBuffer();
415
+ await discard(response.body);
369
416
  return;
370
417
  }
371
418
  throw failed("DELETE", key, response);
@@ -381,6 +428,55 @@ function createS3FileStore(config) {
381
428
  };
382
429
  }
383
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
+ function clock(timeoutMs, request) {
441
+ const controller = new AbortController();
442
+ let timer;
443
+ const stop = () => clearTimeout(timer);
444
+ const start = (owed) => {
445
+ stop();
446
+ timer = setTimeout(() => {
447
+ controller.abort(/* @__PURE__ */ new Error(`${request}: ${owed} within ${String(timeoutMs)} ms`));
448
+ }, timeoutMs);
449
+ timer.unref();
450
+ };
451
+ return {
452
+ signal: controller.signal,
453
+ start,
454
+ stop
455
+ };
456
+ }
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
+ async function* timed(stream, waiting) {
464
+ try {
465
+ for await (const chunk of stream) {
466
+ waiting.stop();
467
+ yield chunk;
468
+ waiting.start("no data");
469
+ }
470
+ } finally {
471
+ waiting.stop();
472
+ }
473
+ }
474
+ /** Reads a body to its end and drops it, which hands the connection back to the pool. */
475
+ async function discard(body) {
476
+ if (body === null) return;
477
+ for await (const _chunk of body);
478
+ }
479
+ /**
384
480
  * AWS's `UriEncode` for one path segment.
385
481
  *
386
482
  * `encodeURIComponent` leaves `!'()*` alone and S3 wants them encoded, and its
@@ -393,6 +489,60 @@ function encodeSegment(value) {
393
489
  return encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
394
490
  }
395
491
  //#endregion
492
+ //#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
+ function fileStoreSettings(env) {
505
+ const directory = setting(env, "FORMANCY_FILES_DIR");
506
+ const endpoint = setting(env, "FORMANCY_S3_ENDPOINT");
507
+ if (directory !== void 0 && endpoint !== void 0) throw new Error("Set FORMANCY_FILES_DIR or FORMANCY_S3_ENDPOINT, not both: two stores means files land in one and are looked for in the other.");
508
+ if (endpoint !== void 0) {
509
+ const required = (name) => {
510
+ const value = env[name];
511
+ if (value === void 0 || value === "") throw new Error(`${name} is required when FORMANCY_S3_ENDPOINT is set.`);
512
+ return value;
513
+ };
514
+ return {
515
+ kind: "s3",
516
+ config: {
517
+ endpoint,
518
+ bucket: required("FORMANCY_S3_BUCKET"),
519
+ region: required("FORMANCY_S3_REGION"),
520
+ accessKeyId: required("FORMANCY_S3_ACCESS_KEY_ID"),
521
+ secretAccessKey: required("FORMANCY_S3_SECRET_ACCESS_KEY")
522
+ }
523
+ };
524
+ }
525
+ return directory === void 0 ? { kind: "none" } : {
526
+ kind: "local",
527
+ directory
528
+ };
529
+ }
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
+ function setting(env, name) {
542
+ const value = env[name];
543
+ return value === "" ? void 0 : value;
544
+ }
545
+ //#endregion
396
546
  //#region src/clamd-scanner.ts
397
547
  /** How much is sent per INSTREAM chunk. clamd takes chunks of any size up to its stream limit. */
398
548
  const CHUNK = 65536;
@@ -422,6 +572,11 @@ const CLAMD_DEFAULT_MAX_BYTES = 104857600;
422
572
  * as a scanner that is down and would tell the person to try again forever; the real clamd
423
573
  * measured here answered intact. Checked first, the outcome does not depend on which.
424
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).
425
580
  */
426
581
  function createClamdScanner({ host, port, maxBytes = CLAMD_DEFAULT_MAX_BYTES, timeoutMs = 3e4 }) {
427
582
  return { scan: (_file, bytes) => new Promise((resolve, reject) => {
@@ -479,6 +634,87 @@ function verdictOf(reply) {
479
634
  };
480
635
  }
481
636
  //#endregion
637
+ //#region src/trust-proxy.ts
638
+ 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
+ function trustProxyFrom(value) {
670
+ if (value === void 0 || value.trim() === "") return void 0;
671
+ const trimmed = value.trim();
672
+ if (/^\d+$/.test(trimmed)) throw new Error(`FORMANCY_TRUST_PROXY is "${trimmed}", a hop count, which Fastify ignores: a count cannot tell the proxy from a client that connects directly. ${WHAT_TO_WRITE}`);
673
+ if (trimmed.toLowerCase() === "true") throw new Error(`FORMANCY_TRUST_PROXY is "true", which would believe X-Forwarded-For from anybody and let every client choose the address its rate limits count. ${WHAT_TO_WRITE}`);
674
+ const entries = trimmed.split(",").map((entry) => entry.trim());
675
+ 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
+ return entries;
677
+ }
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
+ function isAddressOrRange(entry) {
687
+ const slash = entry.indexOf("/");
688
+ if (slash === -1) return isIP(entry) !== 0;
689
+ const family = isIP(entry.slice(0, slash));
690
+ const prefix = entry.slice(slash + 1);
691
+ if (family === 0 || !/^\d{1,3}$/.test(prefix)) return false;
692
+ const bits = Number(prefix);
693
+ return bits >= 1 && bits <= (family === 4 ? 32 : 128);
694
+ }
695
+ //#endregion
696
+ //#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
+ function logLevelFrom(env) {
710
+ const value = env["FORMANCY_LOG_LEVEL"]?.trim() ?? "";
711
+ if (value === "") return "info";
712
+ if (value === "off") return value;
713
+ const level = LOG_LEVELS.find((known) => known === value);
714
+ if (level === void 0) throw new Error(`FORMANCY_LOG_LEVEL is "${value}", which is not a level this server has. Write one of off, ${LOG_LEVELS.join(", ")} — or leave it unset for info.`);
715
+ return level;
716
+ }
717
+ //#endregion
482
718
  //#region src/main.ts
483
719
  process.env["RECHECK_BACKEND"] ??= "pure";
484
720
  /**
@@ -495,44 +731,28 @@ if (databaseUrl === void 0 || databaseUrl === "") {
495
731
  process.exit(1);
496
732
  }
497
733
  const port = Number(process.env["PORT"] ?? 4380);
498
- const sql = postgres(databaseUrl);
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
+ const logLevel = logLevelFrom(process.env);
743
+ const log = logLevel === "off" ? void 0 : createServerLog(process.stdout, logLevel);
744
+ const sql = postgres(databaseUrl, { onnotice: databaseNotices(log) });
499
745
  let authSecret = process.env["FORMANCY_AUTH_SECRET"];
500
746
  if (authSecret === void 0 || authSecret === "") {
501
747
  authSecret = randomBytes(33).toString("base64url");
502
- console.warn("FORMANCY_AUTH_SECRET is not set: generated an ephemeral one, sessions will not survive a restart.");
748
+ console.warn("FORMANCY_AUTH_SECRET is not set: generated an ephemeral one, so sessions will not survive a restart, nor will drafts or forms being filled in.");
503
749
  }
504
750
  const adminEmail = process.env["FORMANCY_ADMIN_EMAIL"];
505
751
  const adminPassword = process.env["FORMANCY_ADMIN_PASSWORD"];
506
- /**
507
- * Where uploaded bytes go.
508
- *
509
- * Absent means this deployment accepts no files, and that is a supported
510
- * state rather than a misconfiguration: a form with a file field still renders
511
- * and still submits, and the field says plainly that there is nowhere to put
512
- * one. Turning uploads on is naming a directory, which in a container is
513
- * naming a volume — and a volume is the one thing a self-hoster has to think
514
- * about, so it is not defaulted.
515
- */
516
- const filesDirectory = process.env["FORMANCY_FILES_DIR"];
517
- const s3Endpoint = process.env["FORMANCY_S3_ENDPOINT"];
518
- if (filesDirectory !== void 0 && s3Endpoint !== void 0) throw new Error("Set FORMANCY_FILES_DIR or FORMANCY_S3_ENDPOINT, not both: two stores means files land in one and are looked for in the other.");
519
- const fileStore = (() => {
520
- if (s3Endpoint === void 0) return void 0;
521
- const required = (name) => {
522
- const value = process.env[name];
523
- if (value === void 0 || value === "") throw new Error(`${name} is required when FORMANCY_S3_ENDPOINT is set.`);
524
- return value;
525
- };
526
- return createS3FileStore({
527
- endpoint: s3Endpoint,
528
- bucket: required("FORMANCY_S3_BUCKET"),
529
- region: required("FORMANCY_S3_REGION"),
530
- accessKeyId: required("FORMANCY_S3_ACCESS_KEY_ID"),
531
- secretAccessKey: required("FORMANCY_S3_SECRET_ACCESS_KEY")
532
- });
533
- })() ?? (filesDirectory === void 0 ? void 0 : createLocalFileStore(filesDirectory));
534
- const maxFileBytes = Number(process.env["FORMANCY_MAX_FILE_BYTES"] ?? 10485760);
535
- if (!Number.isFinite(maxFileBytes) || maxFileBytes <= 0) throw new Error("FORMANCY_MAX_FILE_BYTES must be a positive number of bytes.");
752
+ /** Where uploaded bytes go: the rules are in `fileStoreSettings`, which throws. */
753
+ const storeSettings = fileStoreSettings(process.env);
754
+ const fileStore = storeSettings.kind === "s3" ? createS3FileStore(storeSettings.config) : storeSettings.kind === "local" ? createLocalFileStore(storeSettings.directory) : void 0;
755
+ const maxFileBytes = maxFileBytesFrom(process.env);
536
756
  /**
537
757
  * A ClamAV daemon to ask about every upload before its bytes are kept.
538
758
  *
@@ -569,10 +789,33 @@ const scanner = clamdHost === void 0 || clamdHost === "" ? void 0 : createClamdS
569
789
  */
570
790
  const challengeSecret = process.env["FORMANCY_CHALLENGE_SECRET"];
571
791
  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
+ 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
+ const modelChoice = modelSettings(process.env);
808
+ const model = modelChoice.kind === "none" ? void 0 : {
809
+ provider: modelChoice.provider,
810
+ model: modelChoice.model,
811
+ completer: createCompleter(modelChoice)
812
+ };
572
813
  await bootstrapSchema(sql);
573
814
  const storage = createPostgresStorage(sql);
815
+ const rateLimits = createPostgresRateLimitStore(databaseUrl, log === void 0 ? {} : { log });
574
816
  const app = await createApp(storage, {
575
817
  authSecret,
818
+ rateLimitStore: rateLimits.store,
576
819
  ...adminEmail !== void 0 && adminPassword !== void 0 ? { bootstrapAdmin: {
577
820
  email: adminEmail,
578
821
  password: adminPassword
@@ -580,14 +823,21 @@ const app = await createApp(storage, {
580
823
  ...fileStore === void 0 ? {} : { fileStore },
581
824
  ...challengeSecret === void 0 ? {} : { challengeSecret },
582
825
  ...scanner === void 0 ? {} : { scanner },
826
+ ...trustProxy === void 0 ? {} : { trustProxy },
827
+ ...model === void 0 ? {} : { model },
828
+ ...logLevel === "off" ? {} : { log: {
829
+ sink: process.stdout,
830
+ level: logLevel
831
+ } },
583
832
  maxFileBytes
584
833
  });
585
834
  const outbox = startOutboxWorker(storage, {
835
+ log: app.log,
586
836
  ...process.env["FORMANCY_WEBHOOK_ALLOW_HTTP"] === "true" ? { allowHttp: true } : {},
587
837
  ...process.env["FORMANCY_WEBHOOK_ALLOW_PRIVATE"] === "true" ? { allowPrivateAddresses: true } : {}
588
838
  });
589
- const collector = fileStore === void 0 ? void 0 : startFileCollector(storage, fileStore);
590
- const sweeper = challengeSecret === void 0 ? void 0 : startChallengeSweeper(storage);
839
+ const collector = fileStore === void 0 ? void 0 : startFileCollector(storage, fileStore, { log: app.log });
840
+ const sweeper = challengeSecret === void 0 ? void 0 : startChallengeSweeper(storage, { log: app.log });
591
841
  for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => {
592
842
  outbox.stop();
593
843
  collector?.stop();
@@ -599,8 +849,9 @@ await app.listen({
599
849
  host: process.env["HOST"] ?? "0.0.0.0"
600
850
  });
601
851
  console.log(`formancy server listening on :${port}`);
852
+ console.log(model === void 0 ? "No model configured." : `Builders ask ${model.provider}'s ${model.model}.`);
602
853
  for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => {
603
- app.close().then(() => sql.end()).then(() => process.exit(0));
854
+ app.close().then(() => rateLimits.end()).then(() => sql.end()).then(() => process.exit(0));
604
855
  });
605
856
  //#endregion
606
857
  export {};