@formancy/server 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{completers-Cjn4y1ZH.mjs → completers-DWLA1A-r.mjs} +6 -5
- package/dist/completers-DWLA1A-r.mjs.map +1 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/main.mjs +1 -1
- package/dist/main.mjs.map +1 -1
- package/package.json +4 -4
- package/dist/completers-Cjn4y1ZH.mjs.map +0 -1
package/dist/index.d.mts.map
CHANGED
|
@@ -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;;;;
|
|
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;;;;UC5ChC;WACN,UAAU;WACV;WACA,WAAW;;;;;;;;;;;;;;;;;;UCQL;;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;;;KAkRN,YAAY,KAAK;;;;;;;;;;;qBChThB;;;;;qBASA;;;UC0BI;;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;;;;wBCxIhE,sBAAsB,KAAK,SAAS,MAAM;;;UC2BzC;;WAEN;;WAEA;;;;;;;;WAQA,MAAM;;;UAIA;;WAEN,OAAO;;WAEP,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBA6BN,6BAA6B,qBAAqB,UAAS,2BAAgC;;;;;;;;wBCiHrF,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-
|
|
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-DWLA1A-r.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-
|
|
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-DWLA1A-r.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";
|
package/dist/main.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main.mjs","names":["dnsLookup","undiciFetch"],"sources":["../src/webhook-url.ts","../src/deliver.ts","../src/outbox-worker.ts","../src/challenge-sweeper.ts","../src/sigv4.ts","../src/s3-file-store.ts","../src/file-store-settings.ts","../src/clamd-scanner.ts","../src/trust-proxy.ts","../src/log-settings.ts","../src/main.ts"],"sourcesContent":["import { isIpLiteral, isPrivateAddress } from '@formancy/server-core'\n\n/**\n * URL-level checks on a webhook destination.\n *\n * Here rather than in @formancy/server-core because it needs `URL`, and that\n * package deliberately has no DOM or Node types — see\n * docs/decisions/0008-layered-packages.md. Hand-rolling a URL parser to keep it\n * there would be the wrong trade: a security check built on an approximate\n * parser is the kind that is quietly wrong.\n *\n * The address classification it calls IS pure and lives there, tested\n * exhaustively.\n */\n\nexport interface WebhookUrlOptions {\n /**\n * Allow plain http. Off by default: a signature over a body anybody can read\n * and rewrite in flight is theatre. A deployment posting to a sidecar on the\n * same host may reasonably turn it on.\n */\n allowHttp?: boolean\n /**\n * Allow delivery to a private address.\n *\n * OFF by default, and the only reason it exists is the case the design\n * itself names: a receiver running as a sidecar on the same host or the same\n * private network. Without it that deployment simply cannot use webhooks,\n * which is a worse outcome than an escape hatch nobody has to touch.\n *\n * Turning it on gives up the confused-deputy protection entirely — the\n * server will then fetch whatever URL a form author writes, including the\n * cloud metadata service. A deployment that enables it should also be one\n * where form authors are trusted.\n */\n allowPrivateAddresses?: boolean\n}\n\n/**\n * What is wrong with this webhook URL, or undefined when nothing is.\n *\n * Checked when the URL is saved, so an author finds out immediately rather\n * than through a delivery that silently never works.\n */\nexport function webhookUrlProblem(\n raw: string,\n options: WebhookUrlOptions = {},\n): string | undefined {\n let url: URL\n try {\n url = new URL(raw)\n } catch {\n return 'That is not a URL.'\n }\n\n if (url.protocol !== 'https:' && url.protocol !== 'http:') {\n return `Only http and https can be delivered to, not ${url.protocol.replace(':', '')}.`\n }\n\n if (url.protocol === 'http:' && options.allowHttp !== true) {\n return 'Use https. A signature over a body that anyone can read and rewrite proves nothing.'\n }\n\n if (url.username !== '' || url.password !== '') {\n return 'Remove the credentials from the URL: they would be sent to whatever the host resolves to, and they end up in logs.'\n }\n\n // Only a LITERAL address can be judged here. A hostname is checked after\n // resolution, at delivery time, by the caller — and running the fail-closed\n // address check against one would refuse every hostname in the world.\n if (\n options.allowPrivateAddresses !== true &&\n isIpLiteral(url.hostname) &&\n isPrivateAddress(url.hostname)\n ) {\n return `${url.hostname} is not an address this server may be pointed at.`\n }\n\n return undefined\n}\n","import { lookup as dnsLookup } from 'node:dns/promises'\n// fetch from undici, NOT the global one. Node's global fetch is undici too,\n// but a DIFFERENT instance, and handing it an Agent from this one fails at\n// connect time with \"invalid onRequestStart method\" — a mismatch no unit test\n// reaches, because none of them get as far as opening a socket.\nimport { Agent, fetch as undiciFetch } from 'undici'\nimport { deliveryHeaders, isPrivateAddress } from '@formancy/server-core'\nimport { webhookUrlProblem } from './webhook-url.js'\n\n/**\n * Delivering one webhook, without letting the server be pointed at itself.\n *\n * The hard part is not checking the address. It is making sure the address\n * that was checked is the address that gets connected to.\n *\n * \"Validate the URL, then fetch it\" is defeated by DNS rebinding: the name\n * resolves to a public address while we check, and to 169.254.169.254 a\n * moment later when fetch resolves it again. The two lookups are independent,\n * and an attacker only has to win the second one. So this resolves the name\n * itself, checks every address that comes back, and then connects to a\n * checked address — while still sending the original Host header and SNI, so\n * TLS and virtual hosting keep working.\n *\n * That difference is the whole feature. Everything else here is hygiene.\n */\n\nexport interface DeliveryResult {\n ok: boolean\n status?: number\n /** Why it failed, for the admin's action log. Never the response body. */\n error?: string\n}\n\nexport interface DeliveryOptions {\n /** Hard ceiling on the whole attempt, including connect and read. */\n timeoutMs?: number\n /** Plain http, for a sidecar on the same host. Off by default. */\n allowHttp?: boolean\n /**\n * Deliver to a private address. Off by default — see webhook-url.ts. This\n * gives up the confused-deputy protection, so it is opt-in per deployment\n * and never per form.\n */\n allowPrivateAddresses?: boolean\n /** Swappable so tests do not depend on the network. */\n resolve?: (hostname: string) => Promise<string[]>\n}\n\nconst RESPONSE_CAP_BYTES = 64 * 1024\n\nasync function resolveAll(hostname: string): Promise<string[]> {\n const records = await dnsLookup(hostname, { all: true, verbatim: true })\n return records.map((record) => record.address)\n}\n\nexport async function deliver(\n input: {\n url: string\n body: string\n secret: string\n eventId: string\n attempt: number\n nowSeconds: number\n },\n options: DeliveryOptions = {},\n): Promise<DeliveryResult> {\n const problem = webhookUrlProblem(input.url, {\n allowHttp: options.allowHttp ?? false,\n allowPrivateAddresses: options.allowPrivateAddresses ?? false,\n })\n if (problem !== undefined) return { ok: false, error: problem }\n\n const url = new URL(input.url)\n const resolve = options.resolve ?? resolveAll\n\n let addresses: string[]\n try {\n addresses = await resolve(url.hostname)\n } catch (error) {\n return { ok: false, error: `Could not resolve ${url.hostname}: ${String(error)}` }\n }\n\n if (addresses.length === 0) return { ok: false, error: `${url.hostname} resolved to nothing.` }\n\n // EVERY address, not the first. A name that returns one public and one\n // private address is the rebinding attack spelled out, and picking the\n // public one to validate while the stack might use either is no check at all.\n const forbidden = options.allowPrivateAddresses === true\n ? undefined\n : addresses.find((address) => isPrivateAddress(address))\n if (forbidden !== undefined) {\n return { ok: false, error: `${url.hostname} resolves to ${forbidden}, which is not reachable from here.` }\n }\n\n const pinned = addresses[0]!\n\n // The agent connects to the address we checked and nothing else. The\n // original hostname stays in the Host header and in SNI, so TLS validates\n // against the certificate the site actually has.\n const family = pinned.includes(':') ? 6 : 4\n const agent = new Agent({\n connect: {\n // undici calls this with `all: true` and then expects an ARRAY of\n // { address, family }; the plain `(err, address, family)` form that\n // node:net uses comes back as \"Invalid IP address: undefined\". Both\n // shapes are handled because which one arrives depends on undici's\n // internals, and getting it wrong fails only at connect time — where no\n // unit test looks.\n lookup: (_hostname, opts: { all?: boolean | undefined }, callback: (...args: never[]) => void) => {\n const done = callback as unknown as (\n error: Error | null,\n result: string | Array<{ address: string; family: number }>,\n family?: number,\n ) => void\n if (opts.all === true) done(null, [{ address: pinned, family }])\n else done(null, pinned, family)\n },\n },\n })\n\n try {\n const response = await undiciFetch(url, {\n method: 'POST',\n headers: deliveryHeaders({\n eventId: input.eventId,\n secret: input.secret,\n body: input.body,\n timestampSeconds: input.nowSeconds,\n attempt: input.attempt,\n }),\n body: input.body,\n // A redirect is a second destination that nothing has checked. Following\n // one would hand an attacker the whole guard back.\n redirect: 'manual',\n signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),\n dispatcher: agent,\n })\n\n if (response.status >= 300 && response.status < 400) {\n return { ok: false, status: response.status, error: 'Redirects are not followed.' }\n }\n\n // Read a bounded amount and never interpret it. A receiver's body is not\n // ours to parse, and an unbounded read is a memory exhaustion the receiver\n // controls.\n await drain(response, RESPONSE_CAP_BYTES)\n\n return response.ok\n ? { ok: true, status: response.status }\n : { ok: false, status: response.status, error: `Receiver answered ${String(response.status)}.` }\n } catch (error) {\n // `fetch failed` on its own tells an operator nothing. The cause carries\n // the refused connection, the TLS failure or the timeout.\n const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : ''\n return { ok: false, error: `${error instanceof Error ? error.message : String(error)}${cause}` }\n } finally {\n await agent.close()\n }\n}\n\n/** Consume at most `cap` bytes so the connection can be reused, then stop. */\nasync function drain(response: { body?: unknown }, cap: number): Promise<void> {\n const body = response.body as ReadableStream<Uint8Array> | null | undefined\n const reader = body?.getReader()\n if (reader === undefined) return\n\n let read = 0\n try {\n for (;;) {\n const { done, value } = await reader.read()\n if (done) break\n read += value?.byteLength ?? 0\n if (read >= cap) break\n }\n } finally {\n await reader.cancel().catch(() => undefined)\n }\n}\n","import { drainOutbox } from '@formancy/server-core'\nimport type { Storage } from '@formancy/server-core'\nimport { deliver } from './deliver.js'\nimport { workerLog } from './server-log.js'\nimport type { WorkerLog } from './server-log.js'\n\n/**\n * Runs the outbox on a timer.\n *\n * Everything that decides anything — whether to retry, when, when to give up —\n * is in @formancy/server-core and tested without a network. This is only the\n * clock and the wiring, which is why it is short.\n *\n * NOT a distributed queue. `claimDueDeliveries` does not lock rows, so two\n * replicas would both pick up the same delivery and send it twice. The event\n * id is stable across attempts precisely so a receiver can dedupe that, but a\n * multi-replica deployment should still run exactly one worker until the claim\n * takes a lock. Said plainly here rather than discovered in production.\n */\nexport interface WorkerHandle {\n stop: () => void\n}\n\nexport function startOutboxWorker(\n storage: Storage,\n options: { intervalMs?: number; allowHttp?: boolean; allowPrivateAddresses?: boolean; log?: WorkerLog } = {},\n): WorkerHandle {\n const interval = options.intervalMs ?? 5_000\n const log = workerLog(options.log)\n let stopped = false\n let running = false\n\n const tick = async (): Promise<void> => {\n // One pass at a time. Overlapping passes would let a slow receiver cause\n // the same delivery to be picked up twice by this very process.\n if (running || stopped) return\n running = true\n try {\n await drainOutbox({\n storage,\n now: () => new Date(),\n random: () => Math.random(),\n send: async (delivery, webhook, nowSeconds) =>\n deliver(\n {\n url: webhook.url,\n body: delivery.body,\n secret: webhook.secret,\n eventId: delivery.eventId,\n attempt: delivery.attempt + 1,\n nowSeconds,\n },\n {\n allowHttp: options.allowHttp ?? false,\n allowPrivateAddresses: options.allowPrivateAddresses ?? false,\n },\n ),\n })\n } catch (error) {\n // A worker that throws is a worker that stops. Deliveries retry on their\n // own schedule, so the right response to an unexpected failure is to\n // complain and come back — with what was thrown as its kind and code, never\n // its words, which for a query are its parameters (C3).\n log.error({ event: 'outbox.failed', err: error })\n } finally {\n running = false\n }\n }\n\n const timer = setInterval(() => void tick(), interval)\n // Do not hold the process open: a server whose only remaining work is an\n // empty poll should be able to exit.\n timer.unref?.()\n\n return {\n stop: () => {\n stopped = true\n clearInterval(timer)\n },\n }\n}\n","import type { Storage } from '@formancy/server-core'\nimport { workerLog } from './server-log.js'\nimport type { WorkerLog } from './server-log.js'\n\n/**\n * Forgetting challenges that can no longer be replayed anyway.\n *\n * A solved challenge is recorded so it cannot be spent twice. Once it has\n * expired the record is doing nothing: the stateless expiry check refuses the\n * solution before the spend is ever consulted. Keeping the row past that point\n * only grows a table.\n *\n * Its own timer rather than a job on the file collector, because the two are\n * configured independently: a deployment can have public forms and no uploads,\n * and that deployment still accumulates spent challenges. Hanging this off the\n * collector would make sweeping conditional on a setting that has nothing to\n * do with it.\n *\n * The same shape as the collector and the outbox worker: a timer in the\n * process, one pass at a time, and a thrown pass complains and comes back\n * rather than stopping the timer. Also not a distributed job — running two is\n * harmless, because deleting an expired row twice is deleting it once.\n */\nexport interface SweeperHandle {\n stop: () => void\n}\n\nexport function startChallengeSweeper(\n storage: Storage,\n options: { intervalMs?: number; log?: WorkerLog } = {},\n): SweeperHandle {\n const log = workerLog(options.log)\n // Hourly. The rows expire in ten minutes and are a challenge hash each, so\n // an hour's worth of them is nothing; sweeping more often would be work\n // nobody asked for.\n const interval = options.intervalMs ?? 60 * 60_000\n let stopped = false\n let running = false\n\n const tick = async (): Promise<void> => {\n if (running || stopped) return\n running = true\n try {\n await storage.forgetExpiredChallenges(new Date().toISOString())\n } catch (error) {\n // A sweeper that throws is a sweeper that stops, and a table that grows\n // for a reason nobody is watching. Without what was thrown (C3).\n log.error({ event: 'sweeper.failed', err: error })\n } finally {\n running = false\n }\n }\n\n const timer = setInterval(() => void tick(), interval)\n timer.unref?.()\n\n return {\n stop: () => {\n stopped = true\n clearInterval(timer)\n },\n }\n}\n","import { createHash, createHmac } from 'node:crypto'\n\n/**\n * AWS Signature Version 4, for the four requests an object store needs.\n *\n * Ours rather than an SDK: sixty lines against a frozen specification, with the\n * algorithm's own vocabulary in the names so it can be read beside AWS's description of\n * it. It signs requests for **any** S3-compatible endpoint, so Garage, real S3 and\n * Backblaze take the same code path\n * ([0064](../../../docs/decisions/0064-an-object-store-behind-the-same-interface.md)).\n *\n * **What verifies it is a real S3 implementation in a container**, refusing a wrong\n * signature with a 403. The unit tests pin the mechanical properties; they cannot tell\n * whether the canonical request is *right*, only that it is consistent with itself.\n */\n\n/** The SHA-256 of an empty body, which a GET or DELETE carries. */\nexport const EMPTY_PAYLOAD_SHA256 =\n 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'\n\nexport interface SignArgs {\n readonly method: 'GET' | 'PUT' | 'DELETE' | 'HEAD'\n readonly url: URL\n /** Headers to sign and send. `host` is required; casing does not matter. */\n readonly headers: Readonly<Record<string, string>>\n /** Hex SHA-256 of the body. S3 sends it as a header as well as signing it. */\n readonly payloadHash: string\n readonly service: string\n readonly region: string\n readonly accessKeyId: string\n readonly secretAccessKey: string\n /** Injected so a test is not a test of the clock. */\n readonly now: Date\n}\n\n/**\n * The headers to send, including `authorization`.\n *\n * Returns headers rather than mutating a request so that the caller's `fetch`\n * options stay the caller's business, and so the signature is inspectable in a\n * test without a server.\n */\nexport function signRequest(args: SignArgs): Record<string, string> {\n const { method, url, payloadHash, service, region, accessKeyId, secretAccessKey, now } = args\n\n // Lowercase every name once, here, so nothing below has to wonder. A header\n // signed under one casing and sent under another is a 403 with no clue in it.\n const headers: Record<string, string> = {}\n for (const [name, value] of Object.entries(args.headers)) {\n headers[name.toLowerCase()] = value\n }\n\n if (headers['host'] === undefined) {\n // AWS requires host in the signature. Failing here, where the message can\n // say so, beats a server rejecting it for a reason it will not explain.\n throw new Error('Refusing to sign a request with no host header.')\n }\n\n const amzDate = basicIso8601(now)\n const dateStamp = amzDate.slice(0, 8)\n\n headers['x-amz-date'] = amzDate\n // S3 requires this header and requires it to equal the hash in the canonical\n // request. Set from the same value rather than computed twice: two routes to\n // one number is how they come to disagree.\n headers['x-amz-content-sha256'] = payloadHash\n\n // Sorted by name, because \"canonical\" means both sides sort identically.\n const names = Object.keys(headers).sort()\n const canonicalHeaders = names\n // Sequential spaces collapse and the value is trimmed, per the spec. A\n // header a proxy reformatted must still hash to the same bytes.\n .map((name) => `${name}:${(headers[name] ?? '').trim().replace(/\\s+/g, ' ')}\\n`)\n .join('')\n const signedHeaders = names.join(';')\n\n const canonicalRequest = [\n method,\n canonicalPath(url, service),\n canonicalQuery(url),\n canonicalHeaders,\n signedHeaders,\n payloadHash,\n ].join('\\n')\n\n const scope = `${dateStamp}/${region}/${service}/aws4_request`\n const stringToSign = [\n 'AWS4-HMAC-SHA256',\n amzDate,\n scope,\n sha256Hex(canonicalRequest),\n ].join('\\n')\n\n // The key is derived per date, region and service, so a leaked signing key is\n // useless tomorrow, elsewhere, or for another service. That is the whole\n // reason for the four-step chain rather than signing with the secret.\n const dateKey = hmac(`AWS4${secretAccessKey}`, dateStamp)\n const regionKey = hmac(dateKey, region)\n const serviceKey = hmac(regionKey, service)\n const signingKey = hmac(serviceKey, 'aws4_request')\n const signature = hmac(signingKey, stringToSign).toString('hex')\n\n headers['authorization'] =\n `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, ` +\n `SignedHeaders=${signedHeaders}, Signature=${signature}`\n\n return headers\n}\n\n/**\n * `20150830T123600Z` — basic ISO 8601, not the extended form.\n *\n * `toISOString()` gives `2015-08-30T12:36:00.000Z`, which is rejected. Stripping\n * the punctuation is the whole conversion, and the credential scope then wants\n * the same instant as `20150830`, which is why this returns the long one and the\n * caller slices it.\n */\nfunction basicIso8601(at: Date): string {\n return `${at.toISOString().replace(/[-:]/g, '').split('.')[0] ?? ''}Z`\n}\n\n/**\n * The path, encoded **once** for s3 and twice for everything else.\n *\n * This is the one place where s3 departs from the general rule, and getting it\n * wrong produces a signature that is valid for every other AWS service and\n * refused by the only one we call -- and refused only for keys containing a\n * character that needs encoding, which is the worst possible way to find out.\n */\nfunction canonicalPath(url: URL, service: string): string {\n // `url.pathname` is already percent-encoded by URL parsing, which is exactly\n // the single encoding s3 wants.\n const once = url.pathname === '' ? '/' : url.pathname\n if (service === 's3') return once\n return once\n .split('/')\n .map((segment) => uriEncode(decodeURIComponent(segment)))\n .join('/')\n}\n\n/** Sorted by encoded name, values encoded, `=` even when empty. */\nfunction canonicalQuery(url: URL): string {\n const pairs: Array<readonly [string, string]> = []\n for (const [name, value] of url.searchParams) pairs.push([uriEncode(name), uriEncode(value)])\n // Sorted after encoding, per the spec: sorting the raw names orders them\n // differently whenever an encoded byte changes the comparison.\n pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n return pairs.map(([name, value]) => `${name}=${value}`).join('&')\n}\n\n/**\n * AWS's `UriEncode`, which is not `encodeURIComponent`.\n *\n * The differences are small and each one is a 403: `!`, `'`, `(`, `)` and `*`\n * are left alone by `encodeURIComponent` and must be encoded, hex digits must be\n * uppercase, and a space is `%20` rather than `+`.\n */\nfunction uriEncode(value: string): string {\n return encodeURIComponent(value).replace(\n /[!'()*]/g,\n (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,\n )\n}\n\nfunction sha256Hex(value: string): string {\n return createHash('sha256').update(value, 'utf8').digest('hex')\n}\n\nfunction hmac(key: string | Buffer, data: string): Buffer {\n return createHmac('sha256', key).update(data, 'utf8').digest()\n}\n","import { createHash } from 'node:crypto'\nimport { Readable } from 'node:stream'\n\nimport type { FileStore } from './file-store.js'\nimport { EMPTY_PAYLOAD_SHA256, signRequest } from './sigv4.js'\n\n/**\n * Uploaded bytes in an S3-compatible object store.\n *\n * The second implementation of `FileStore`. The local one is right for a single\n * container with a volume and is the ceiling on everything above it: two replicas with\n * two volumes each accept uploads the other cannot serve, so any deployment past one\n * server needs this.\n *\n * **Not tied to Amazon.** The design calls for Garage, which is what the\n * integration test runs and what belongs in the compose file; the same code path\n * serves MinIO, Backblaze, Cloudflare R2 and real S3, because the only thing they\n * need in common is SigV4 and path-style addressing.\n *\n * **Bytes still pass through the server**, so the request body cap is the ceiling on a\n * file. A presigned PUT straight from the browser is the roadmap item that removes the\n * server from the data path; this replaces where the bytes land without touching how\n * they arrive.\n *\n * **No multipart.** A single PUT is one request, so the practical ceiling is whatever\n * the store accepts in one -- 5 GB for S3, and far above `FORMANCY_MAX_FILE_BYTES`.\n *\n * **The timeout bounds silence, not duration** -- for a download. It runs while the\n * store is being waited on, for the response headers and then for each chunk of the\n * body, and not while a chunk is in the caller's hands. A store that stops answering\n * is abandoned; a download that is still arriving is not, however long it takes.\n * `send` has the reasoning, and\n * [0155](../../../docs/decisions/0155-the-object-store-is-timed-on-its-silence.md) what\n * it costs and what it was chosen over.\n *\n * **An upload is still bounded as a whole.** The store answers a PUT once it has the\n * entire body, so sending it falls inside the wait for the headers, and a PUT that\n * takes longer than the timeout is abandoned however steadily it is going. arc42\n * section 11 carries it as debt, and a test asserts the gap is still there, so that\n * closing it is deliberate.\n */\nexport interface S3FileStoreConfig {\n /** Base URL of the endpoint, e.g. `http://garage:3900` or an S3 regional URL. */\n readonly endpoint: string\n readonly bucket: string\n /** Garage answers to any region name; real S3 does not. */\n readonly region: string\n readonly accessKeyId: string\n readonly secretAccessKey: string\n /**\n * How long the store may keep a request waiting before it is abandoned, in\n * milliseconds: for the response headers, and then for each chunk of the body.\n * A PUT's own body is sent inside the first of those, so it bounds an upload's\n * whole length.\n *\n * An object store that stops answering must not become a server that stops\n * answering. A bound on silence rather than on the whole request, because how\n * long a download takes is set by whoever is downloading it. Defaults to thirty\n * seconds, which is generous for a store that is working and still bounded for\n * one that is not.\n */\n readonly timeoutMs?: number\n}\n\nexport function createS3FileStore(config: S3FileStoreConfig): FileStore {\n const endpoint = config.endpoint.replace(/\\/+$/, '')\n const timeoutMs = config.timeoutMs ?? 30_000\n\n /**\n * The object's URL, with the key's slashes kept and everything else encoded.\n *\n * A key is a path inside the bucket, so its separators are structural. Every\n * other character has to be encoded or the request line is invalid -- and a\n * space encoded as `+`, which is what form encoding would do, names a\n * different object.\n */\n const urlFor = (key: string): URL => {\n if (key === '' || key.includes('\\\\') || key.split('/').includes('..')) {\n // Keys are minted by the server today. Checked anyway, because the local\n // store checks it and the two must not disagree about what is legal.\n throw new Error(`Refusing a storage key that could address another bucket: ${key}`)\n }\n const path = key.split('/').map(encodeSegment).join('/')\n return new URL(`${endpoint}/${encodeSegment(config.bucket)}/${path}`)\n }\n\n /**\n * One request, and the clock that abandons it.\n *\n * **The clock measures how long the store keeps us waiting.** It starts when the\n * request is sent and stops when the headers arrive -- which, for a PUT, is after\n * the whole body has gone. It starts again at once, because the store now owes the\n * body, stops when a chunk arrives, and starts again only when the caller asks for\n * the next one -- so a chunk the caller is still holding costs the store nothing.\n *\n * **Not `AbortSignal.timeout`, which is what this replaced.** That bounds the whole\n * exchange, body included, so a download still streaming when it ran out was cut\n * off mid-file however steadily it was arriving. And how long a download streams\n * is set by the person downloading it more than by the store: backpressure carries\n * a slow connection all the way back to this socket, so a bound on the whole\n * request was a bound on the downloader's bandwidth.\n *\n * The clock restarts at the headers rather than at the first read so that a body\n * nobody reads -- the error document on a path that throws -- is still abandoned\n * after `timeoutMs`, rather than holding its connection for however long something\n * else takes to close it.\n */\n const send = async (\n method: 'GET' | 'PUT' | 'DELETE' | 'HEAD',\n key: string,\n body?: Buffer,\n ): Promise<Answer> => {\n const url = urlFor(key)\n const payloadHash =\n body === undefined\n ? EMPTY_PAYLOAD_SHA256\n : createHash('sha256').update(body).digest('hex')\n\n // `content-length` is deliberately neither signed nor set. undici refuses a\n // manually supplied one -- `InvalidArgumentError: invalid content-length\n // header` -- and computes its own from the body, so setting it is both\n // impossible and unnecessary. Nothing is lost by not signing it: AWS requires\n // only `host` and the `x-amz-*` headers in the signature, and\n // `x-amz-content-sha256` is a hash of the exact bytes, which constrains the\n // length far more tightly than a signed length would.\n const headers = signRequest({\n method,\n url,\n headers: { host: url.host },\n payloadHash,\n service: 's3',\n region: config.region,\n accessKeyId: config.accessKeyId,\n secretAccessKey: config.secretAccessKey,\n now: new Date(),\n })\n\n const waiting = clock(timeoutMs, `S3 ${method} of ${key}`)\n waiting.start('no answer')\n const response = await fetch(url, {\n method,\n headers,\n ...(body === undefined ? {} : { body: new Uint8Array(body) }),\n signal: waiting.signal,\n }).finally(waiting.stop)\n\n const { ok, status, statusText } = response\n const answer = { ok, status, statusText, headers: response.headers }\n if (response.body === null) return { ...answer, body: null }\n waiting.start('no data')\n return { ...answer, body: timed(response.body, waiting) }\n }\n\n /**\n * A status that means \"there is no such object\", as opposed to one that means\n * the store could not answer.\n *\n * The distinction is the whole reason this is a named function. Reading a\n * could-not-answer as absence makes the collector delete the row for bytes that\n * still exist and makes a download return nothing for a file the person can see\n * listed.\n *\n * **404 only, and 403 deliberately not.** S3 answers 403 for an object a caller may\n * not list, but this caller owns the bucket -- so 403 means the credentials are\n * wrong, and reading that as absence would make a misconfigured deployment look like\n * one where every file has vanished and have the collector delete every row.\n */\n const isAbsent = (response: Answer): boolean => response.status === 404\n\n const failed = (method: string, key: string, response: Answer): Error =>\n new Error(`S3 ${method} of ${key} failed: ${String(response.status)} ${response.statusText}`)\n\n return {\n async put(key, bytes) {\n const response = await send('PUT', key, bytes)\n if (!response.ok) throw failed('PUT', key, response)\n // The body is an error document or nothing. Reading it releases the\n // connection back to the pool, which an undrained response does not.\n await discard(response.body)\n },\n\n async open(key) {\n const response = await send('GET', key)\n if (isAbsent(response)) {\n await discard(response.body)\n return undefined\n }\n if (!response.ok || response.body === null) throw failed('GET', key, response)\n // Streamed rather than buffered: a large attachment should not be held in\n // memory on its way to one downloader. A byte stream, as the local store's\n // is: in object mode it would hand out bare `Uint8Array`s rather than\n // `Buffer`s, and count what it buffers in chunks rather than in bytes.\n return Readable.from(response.body, { objectMode: false })\n },\n\n async remove(key) {\n const response = await send('DELETE', key)\n // Absent is success. A collector that crashed between deleting the bytes\n // and deleting the row has to be safe to run again.\n if (response.ok || isAbsent(response)) {\n await discard(response.body)\n return\n }\n throw failed('DELETE', key, response)\n },\n\n async sizeOf(key) {\n const response = await send('HEAD', key)\n if (isAbsent(response)) return undefined\n if (!response.ok) throw failed('HEAD', key, response)\n const length = response.headers.get('content-length')\n // A store that answered without a length cannot be used to check that the\n // bytes match what was promised, and pretending it said zero would fail\n // that check by declaring the file empty.\n if (length === null) throw new Error(`S3 HEAD of ${key} returned no content-length.`)\n return Number(length)\n },\n }\n}\n\n/**\n * What the store answered.\n *\n * The status and headers of the `Response`, and its body only as `send` hands it\n * out. Read through the `Response` itself, a body is read without the clock\n * stopping between chunks -- which is the whole-request bound again, and the defect\n * `send` exists to avoid -- so the `Response` is not handed out at all.\n */\ntype Answer = Pick<Response, 'ok' | 'status' | 'statusText' | 'headers'> & {\n readonly body: AsyncIterable<Uint8Array> | null\n}\n\n/**\n * A clock for one request: started while the store owes something, stopped while it\n * does not, and aborting the request if it ever runs out. `owed` names what the\n * store failed to send, for the error that says so.\n *\n * Unref'd, as `AbortSignal.timeout` was: a deadline is not work. One is left running\n * under every body nobody reads, and a referenced timer would hold the process it\n * lives in for up to `timeoutMs` after everything else had finished. It still fires\n * when it matters: a request still in flight holds the process up by its connection.\n */\nfunction clock(timeoutMs: number, request: string) {\n const controller = new AbortController()\n let timer: ReturnType<typeof setTimeout> | undefined\n const stop = (): void => clearTimeout(timer)\n const start = (owed: string): void => {\n stop()\n timer = setTimeout(() => {\n controller.abort(new Error(`${request}: ${owed} within ${String(timeoutMs)} ms`))\n }, timeoutMs)\n timer.unref()\n }\n return { signal: controller.signal, start, stop }\n}\n\n/**\n * A body chunk by chunk, with the clock running only while the next chunk is awaited.\n *\n * An abort after the headers errors the body with the clock's reason, so a body the\n * clock ran out on fails rather than ending early and looking complete.\n */\nasync function* timed(\n stream: ReadableStream<Uint8Array>,\n waiting: ReturnType<typeof clock>,\n): AsyncGenerator<Uint8Array> {\n try {\n for await (const chunk of stream) {\n waiting.stop()\n yield chunk\n waiting.start('no data')\n }\n } finally {\n waiting.stop()\n }\n}\n\n/** Reads a body to its end and drops it, which hands the connection back to the pool. */\nasync function discard(body: Answer['body']): Promise<void> {\n if (body === null) return\n for await (const _chunk of body) {\n // Nothing to keep: reaching the end is the point.\n }\n}\n\n/**\n * AWS's `UriEncode` for one path segment.\n *\n * `encodeURIComponent` leaves `!'()*` alone and S3 wants them encoded, and its\n * hex digits are already uppercase. Duplicated from the signer rather than\n * shared, because the signer's copy is about hashing and this one is about the\n * request line: if they ever need to differ, they differ, and a shared helper\n * would make that a conversation instead of an edit.\n */\nfunction encodeSegment(value: string): string {\n return encodeURIComponent(value).replace(\n /[!'()*]/g,\n (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,\n )\n}\n","import type { S3FileStoreConfig } from './s3-file-store.js'\n\n/**\n * Which store the environment names for uploaded bytes: none, a directory, or an\n * S3-compatible object store.\n *\n * A function of the environment rather than lines in `main.ts`, because it is a\n * decision with rules and `main.ts` is a composition root that no test imports.\n * The rules are where a deployment goes wrong — two stores named at once, an\n * endpoint without its credentials — and a rule nothing runs is a claim nobody\n * checked. Constructing the store stays in `main.ts`; this only says which.\n */\nexport type FileStoreSettings =\n | { readonly kind: 'none' }\n | { readonly kind: 'local'; readonly directory: string }\n | { readonly kind: 's3'; readonly config: S3FileStoreConfig }\n\ntype Environment = Readonly<Record<string, string | undefined>>\n\n/**\n * Absent — or empty, see `setting` — means this deployment accepts no files,\n * and that is a supported state rather than a misconfiguration: a form with a\n * file field still renders and still submits, and the field says plainly that\n * there is nowhere to put one. Turning uploads on is naming a directory, which\n * in a container is naming a volume — and a volume is the one thing a\n * self-hoster has to think about, so it is not defaulted here.\n *\n * Throws on a configuration that names two stores, or an object store without\n * everything it needs, so the server refuses to start rather than serving one.\n */\nexport function fileStoreSettings(env: Environment): FileStoreSettings {\n const directory = setting(env, 'FORMANCY_FILES_DIR')\n const endpoint = setting(env, 'FORMANCY_S3_ENDPOINT')\n\n if (directory !== undefined && endpoint !== undefined) {\n // Two stores means half the files are in one and half in the other, and\n // nothing records which -- so a later reader gets \"no such file\" for bytes\n // that exist in the store it did not ask. Refusing at startup is the only\n // point at which this is cheap to fix.\n throw new Error(\n 'Set FORMANCY_FILES_DIR or FORMANCY_S3_ENDPOINT, not both: two stores means ' +\n 'files land in one and are looked for in the other.',\n )\n }\n\n if (endpoint !== undefined) {\n // Every field is required once the endpoint is set, and none is defaulted. A\n // bucket name guessed wrong is a deployment that accepts uploads into\n // nothing; credentials guessed wrong are a deployment where every file reads\n // as missing. Both fail here instead.\n const required = (name: string): string => {\n const value = env[name]\n if (value === undefined || value === '') {\n throw new Error(`${name} is required when FORMANCY_S3_ENDPOINT is set.`)\n }\n return value\n }\n\n return {\n kind: 's3',\n config: {\n endpoint,\n bucket: required('FORMANCY_S3_BUCKET'),\n // Part of the credential scope, so a wrong region is a signature the\n // store computes differently and rejects. Garage answers to any name and\n // real S3 does not, which is why there is no default.\n region: required('FORMANCY_S3_REGION'),\n accessKeyId: required('FORMANCY_S3_ACCESS_KEY_ID'),\n secretAccessKey: required('FORMANCY_S3_SECRET_ACCESS_KEY'),\n },\n }\n }\n\n return directory === undefined ? { kind: 'none' } : { kind: 'local', directory }\n}\n\n/**\n * A setting, with the empty string read as unset.\n *\n * Because compose cannot remove a variable, only blank it. Both compose files\n * point FORMANCY_FILES_DIR at their volume, and `.env` switching to the object\n * store does so by setting it to `\"\"` — which, read as a directory, is two\n * stores and a server that refuses to start. Read as a path it is no better:\n * `createLocalFileStore('')` resolves to the working directory, which switches\n * uploads on in a directory nobody chose — in the published image, `/app`,\n * which its user cannot write.\n */\nfunction setting(env: Environment, name: string): string | undefined {\n const value = env[name]\n return value === '' ? undefined : value\n}\n","import { connect } from 'node:net'\nimport type { Scanner, ScanVerdict } from '@formancy/server-core'\n\n/** How much is sent per INSTREAM chunk. clamd takes chunks of any size up to its stream limit. */\nconst CHUNK = 64 * 1024\n\n/**\n * clamd's own default `StreamMaxLength`, 100 MiB — as the `clamd.conf` ClamAV 1.5.4 ships\n * says, and as measured against it: a 90 MiB stream was answered, a 110 MiB one refused.\n */\nexport const CLAMD_DEFAULT_MAX_BYTES = 100 * 1024 * 1024\n\n/**\n * ClamAV's daemon, asked over its INSTREAM command\n * ([0131](../../../docs/decisions/0131-an-upload-is-scanned-before-it-is-kept.md)).\n *\n * The protocol is clamd's own and small enough to speak directly — the command, the bytes in\n * length-prefixed chunks, a zero-length chunk to end them, and one line back — so this adds\n * no dependency to the server. Its tests run against a stand-in that speaks the protocol as\n * clamd's manual describes it; it was run once against ClamAV 1.5.4 itself, and the\n * documentation says what that showed and what it did not.\n *\n * **Only `stream: OK` is clean.** A finding is refused by its name. A file over clamd's size\n * limit is refused too, because sending it again changes nothing. Anything else — an answer\n * this does not understand, no answer, no daemon — rejects, which the use-case reads as a\n * scanner that cannot be asked, and refuses the file for.\n *\n * **`maxBytes` is clamd's `StreamMaxLength`, and is checked before a byte is sent.** Past\n * it, clamd says so and closes the connection while bytes may still be arriving. Against the\n * stand-in the bytes in flight reset it and the answer was lost to `ECONNRESET`, which reads\n * as a scanner that is down and would tell the person to try again forever; the real clamd\n * measured here answered intact. Checked first, the outcome does not depend on which.\n * Set it to what clamd is configured with.\n *\n * **`timeoutMs` is for silence, not for the whole scan.** It is the socket's idle timeout,\n * restarted by any traffic on the connection, so it abandons a clamd that has stopped\n * answering and not a scan that is still moving. Nothing here bounds how long a scan takes,\n * which is why the lease a request holds a file under can be outlasted (0153).\n */\nexport function createClamdScanner({\n host,\n port,\n maxBytes = CLAMD_DEFAULT_MAX_BYTES,\n timeoutMs = 30_000,\n}: {\n host: string\n port: number\n maxBytes?: number\n timeoutMs?: number\n}): Scanner {\n return {\n scan: (_file, bytes) =>\n new Promise<ScanVerdict>((resolve, reject) => {\n if (bytes.byteLength > maxBytes) {\n resolve({\n clean: false,\n finding: `could not be scanned: larger than the scanner accepts (${String(maxBytes)} bytes)`,\n })\n return\n }\n const socket = connect({ host, port })\n const answer: Buffer[] = []\n let failure: Error | undefined\n\n socket.setTimeout(timeoutMs, () =>\n socket.destroy(\n new Error(\n `clamd at ${host}:${String(port)} did not answer within ${String(timeoutMs)} ms`,\n ),\n ),\n )\n socket.on('connect', () => {\n socket.write('zINSTREAM\\0')\n for (let at = 0; at < bytes.byteLength; at += CHUNK) {\n const piece = bytes.subarray(at, at + CHUNK)\n const length = Buffer.alloc(4)\n length.writeUInt32BE(piece.byteLength)\n socket.write(length)\n socket.write(piece)\n }\n // The zero-length chunk that ends the stream. Written, not `end()`ed: clamd\n // answers on the same connection and closes it itself.\n socket.write(Buffer.alloc(4))\n })\n socket.on('data', (data) => answer.push(data))\n // Kept rather than rejected on: clamd past its size limit answers and hangs up\n // while bytes are still being written, and the answer is what counts.\n socket.on('error', (error) => {\n failure = error\n })\n socket.on('close', () => {\n const reply = Buffer.concat(answer).toString('utf8').replace(/\\0+$/, '').trim()\n if (reply === '') {\n reject(\n failure ?? new Error(`clamd at ${host}:${String(port)} closed without answering`),\n )\n return\n }\n const verdict = verdictOf(reply)\n if (verdict === undefined)\n reject(new Error(`clamd answered something unexpected: ${reply}`))\n else resolve(verdict)\n })\n }),\n }\n}\n\nfunction verdictOf(reply: string): ScanVerdict | undefined {\n if (reply === 'stream: OK') return { clean: true }\n const found = /^stream: (.+) FOUND$/.exec(reply)\n if (found !== null) return { clean: false, finding: found[1]! }\n if (/size limit exceeded/i.test(reply)) {\n return { clean: false, finding: `could not be scanned: ${reply.replace(/\\.?\\s*ERROR$/, '')}` }\n }\n return undefined\n}\n","import { isIP } from 'node:net'\n\nconst WHAT_TO_WRITE =\n 'Write the address of the reverse proxy in front of this server, e.g. 10.0.0.2, or a ' +\n 'comma-separated list of addresses and CIDR ranges, e.g. 10.0.0.2,172.18.0.0/16 — or ' +\n 'leave it unset to trust no proxy.'\n\n/**\n * The proxies whose `X-Forwarded-For` this server believes, read from\n * `FORMANCY_TRUST_PROXY`.\n *\n * Every limit on the public plane counts the client's address. Behind a reverse\n * proxy the socket's address is the proxy's, so trusting nothing gives every\n * respondent one shared budget; trusting the wrong thing lets a client write its\n * own `X-Forwarded-For` and choose a fresh budget per request. So the setting\n * names the proxies, and only addresses and ranges:\n *\n * - **No hop count.** Fastify's option takes a number, and the Fastify this\n * server depends on treats one as \"trust nothing\", because a count cannot\n * tell the proxy from a client that connects directly. Accepting it here\n * would be a setting that reads as configured and does nothing;\n * `trust-proxy.test.ts` checks that Fastify still ignores one.\n * - **No `true`,** which Fastify also takes, and which believes anybody.\n * - **No names,** such as `uniquelocal`, which Fastify's `proxy-addr` expands\n * into ranges nobody reading the setting can see — that one is every private\n * address, a Docker network's gateway with it, and the gateway is where\n * Docker hands published-port connections over from. Refused by the address\n * check below, as a hostname is.\n *\n * Decision 0156 has the reasoning, and what was measured against Docker.\n *\n * Absent or empty returns undefined, which trusts nothing — the behaviour before\n * this setting existed, and the right one with no proxy in front. Anything else\n * that is not an address or a range is refused here, with a sentence saying what\n * to write, rather than left to Fastify's parser, whose TypeError names neither\n * the variable nor the fix.\n */\nexport function trustProxyFrom(value: string | undefined): string[] | undefined {\n if (value === undefined || value.trim() === '') return undefined\n\n const trimmed = value.trim()\n if (/^\\d+$/.test(trimmed)) {\n throw new Error(\n `FORMANCY_TRUST_PROXY is \"${trimmed}\", a hop count, which Fastify ignores: a count cannot tell ` +\n `the proxy from a client that connects directly. ${WHAT_TO_WRITE}`,\n )\n }\n if (trimmed.toLowerCase() === 'true') {\n throw new Error(\n 'FORMANCY_TRUST_PROXY is \"true\", which would believe X-Forwarded-For from anybody and let ' +\n `every client choose the address its rate limits count. ${WHAT_TO_WRITE}`,\n )\n }\n\n const entries = trimmed.split(',').map((entry) => entry.trim())\n for (const entry of entries) {\n if (!isAddressOrRange(entry)) {\n throw new Error(\n `FORMANCY_TRUST_PROXY has \"${entry}\", which is not an IP address or a CIDR range. ${WHAT_TO_WRITE}`,\n )\n }\n }\n return entries\n}\n\n/**\n * An address, or an address with a prefix length its family allows.\n *\n * A zero prefix is refused, as Fastify's parser refuses it: `/0` is every\n * address, which is `true` spelled differently. A netmask after the slash is\n * refused too, though Fastify reads one — a second spelling of the same thing\n * is a second thing to get wrong, and a prefix length says it.\n */\nfunction isAddressOrRange(entry: string): boolean {\n const slash = entry.indexOf('/')\n if (slash === -1) return isIP(entry) !== 0\n\n const family = isIP(entry.slice(0, slash))\n const prefix = entry.slice(slash + 1)\n if (family === 0 || !/^\\d{1,3}$/.test(prefix)) return false\n const bits = Number(prefix)\n return bits >= 1 && bits <= (family === 4 ? 32 : 128)\n}\n","import { LOG_LEVELS } from './server-log.js'\nimport type { LogLevel } from './server-log.js'\n\n/**\n * How much the server logs, read from `FORMANCY_LOG_LEVEL`\n * ([0168](../../../docs/decisions/0168-the-log-is-built-from-a-list-of-fields.md)).\n *\n * Unset or empty is `info`: a line per request and per error. The log is on unless somebody\n * turns it off, because off is the state this setting replaced, where nothing said why a\n * request failed. `off` keeps no log at all; anything else that is not one of pino's level\n * names, spelled as pino spells them, stops the server at startup — a server that started\n * anyway would log at a level nobody chose, or not at all while its operator believed it\n * did. Its own module because `main.ts` connects to a database when it is imported, so\n * nothing in it can be tested.\n */\nexport function logLevelFrom(env: Readonly<Record<string, string | undefined>>): LogLevel | 'off' {\n const value = env['FORMANCY_LOG_LEVEL']?.trim() ?? ''\n if (value === '') return 'info'\n if (value === 'off') return value\n const level = LOG_LEVELS.find((known) => known === value)\n if (level === undefined) {\n throw new Error(\n `FORMANCY_LOG_LEVEL is \"${value}\", which is not a level this server has. Write one of ` +\n `off, ${LOG_LEVELS.join(', ')} — or leave it unset for info.`,\n )\n }\n return level\n}\n","import { randomBytes } from 'node:crypto'\nimport postgres from 'postgres'\nimport { createApp } from './app.js'\nimport { bootstrapSchema } from './db.js'\nimport { createPostgresStorage } from './postgres-storage.js'\nimport { createPostgresRateLimitStore } from './postgres-rate-limits.js'\nimport { startOutboxWorker } from './outbox-worker.js'\nimport { startFileCollector } from './file-collector.js'\nimport { startChallengeSweeper } from './challenge-sweeper.js'\nimport { createLocalFileStore } from './file-store.js'\nimport type { FileStore } from './file-store.js'\nimport { createS3FileStore } from './s3-file-store.js'\nimport { fileStoreSettings } from './file-store-settings.js'\nimport { CLAMD_DEFAULT_MAX_BYTES, createClamdScanner } from './clamd-scanner.js'\nimport { maxFileBytesFrom } from './upload-settings.js'\nimport { trustProxyFrom } from './trust-proxy.js'\nimport { modelSettings } from './model-settings.js'\nimport { createCompleter } from './completers.js'\nimport { logLevelFrom } from './log-settings.js'\nimport { createServerLog, databaseNotices } from './server-log.js'\n\n// recheck ships a 23 MB JVM jar and a native binary per platform as OPTIONAL\n// dependencies and falls back to a pure-JavaScript engine without them. For\n// one static analysis at publish time, the jar is a large thing to carry in a\n// self-hosted container and the per-platform binaries are an awkward thing to\n// pin in an SBOM. Choosing the pure engine explicitly also makes the verdict\n// deterministic rather than dependent on what happened to install.\nprocess.env['RECHECK_BACKEND'] ??= 'pure'\n\n/**\n * The runnable server: `node dist/main.mjs` with DATABASE_URL set, or\n * `docker compose up` from the repo root, which supplies both.\n *\n * Bootstraps its own tables on start — a self-hosted skeleton that works five\n * minutes after `git clone` is the adoption funnel; migrations arrive with the\n * lifecycle work.\n */\nconst databaseUrl = process.env['DATABASE_URL']\nif (databaseUrl === undefined || databaseUrl === '') {\n console.error('DATABASE_URL is required, e.g. postgres://formancy:formancy@localhost:5432/formancy')\n process.exit(1)\n}\n\nconst port = Number(process.env['PORT'] ?? 4380)\n\n/**\n * The server's log, on standard output: a JSON line per request and per error, each built\n * from a list of fields, so no body, header, query string, answer or credential has anywhere\n * to go (0168). `info` unless `FORMANCY_LOG_LEVEL` says otherwise; `off` keeps none, and a\n * level the server does not have stops it here. Read first, so the database's notices on the\n * first connection go through it too rather than being printed whole by the driver, and so\n * does the rate limits' counter, which is made before the app whose log it would otherwise use.\n */\nconst logLevel = logLevelFrom(process.env)\nconst log = logLevel === 'off' ? undefined : createServerLog(process.stdout, logLevel)\nconst sql = postgres(databaseUrl, { onnotice: databaseNotices(log) })\n\nlet authSecret = process.env['FORMANCY_AUTH_SECRET']\nif (authSecret === undefined || authSecret === '') {\n // A generated secret keeps `docker compose up` working, at the cost of every\n // session, every draft's key and every token a form was handed out with dying\n // on restart (0062, 0169). Say so loudly rather than failing dev cold.\n authSecret = randomBytes(33).toString('base64url')\n console.warn(\n // \"sessions will not survive a restart\" is the phrase CI's container job looks for:\n // the warning is the contract an operator who never reads the docs still meets.\n '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.',\n )\n}\n\nconst adminEmail = process.env['FORMANCY_ADMIN_EMAIL']\nconst adminPassword = process.env['FORMANCY_ADMIN_PASSWORD']\n\n/** Where uploaded bytes go: the rules are in `fileStoreSettings`, which throws. */\nconst storeSettings = fileStoreSettings(process.env)\nconst fileStore: FileStore | undefined =\n storeSettings.kind === 's3'\n ? createS3FileStore(storeSettings.config)\n : storeSettings.kind === 'local'\n ? createLocalFileStore(storeSettings.directory)\n : undefined\n\n// A whole number of bytes no larger than `files.size` holds, or the server does not start.\nconst maxFileBytes = maxFileBytesFrom(process.env)\n\n/**\n * A ClamAV daemon to ask about every upload before its bytes are kept.\n *\n * Unset is a supported state: no file is scanned, which the documentation says. Set, a\n * file is kept only once clamd has called it clean, and refused when clamd cannot be\n * reached — the point of configuring a scanner is that nothing unscanned gets in\n * ([0131](../../../docs/decisions/0131-an-upload-is-scanned-before-it-is-kept.md)).\n * Not checked at startup: clamd takes minutes to load its signatures, and a server that\n * refused to start until then would turn a slow scanner into an outage of everything.\n */\nconst clamdHost = process.env['FORMANCY_CLAMD_HOST']\nconst clamdPort = Number(process.env['FORMANCY_CLAMD_PORT'] ?? 3310)\nconst clamdMaxBytes = Number(process.env['FORMANCY_CLAMD_MAX_BYTES'] ?? CLAMD_DEFAULT_MAX_BYTES)\nif (!Number.isInteger(clamdPort) || clamdPort <= 0 || clamdPort > 65_535) {\n throw new Error('FORMANCY_CLAMD_PORT must be a port number.')\n}\nif (!Number.isFinite(clamdMaxBytes) || clamdMaxBytes <= 0) {\n throw new Error(\"FORMANCY_CLAMD_MAX_BYTES must be a positive number of bytes: clamd's StreamMaxLength.\")\n}\nconst scanner =\n clamdHost === undefined || clamdHost === ''\n ? undefined\n : createClamdScanner({ host: clamdHost, port: clamdPort, maxBytes: clamdMaxBytes })\n\n/**\n * Turns the proof-of-work challenge on for anonymous submissions.\n *\n * Unset is a supported state rather than a misconfiguration: a deployment\n * whose forms all require a session has no anonymous surface to protect, and\n * the challenge route says so with a 404 rather than failing. A deployment\n * with public forms should set it — the other layers are rate limits, an\n * origin allowlist and a body cap, all of which an attacker with a few\n * addresses walks past.\n *\n * Separate from FORMANCY_AUTH_SECRET so that rotating one does not invalidate\n * the other: rotating this one costs an unsolved puzzle, rotating that one\n * costs everybody their session.\n */\nconst challengeSecret = process.env['FORMANCY_CHALLENGE_SECRET']\nif (challengeSecret !== undefined && challengeSecret.length < 32) {\n throw new Error('FORMANCY_CHALLENGE_SECRET must be at least 32 characters, or unset.')\n}\n\n/**\n * The reverse proxies whose X-Forwarded-For names the client. Unset trusts none,\n * which is right with nothing in front and wrong behind a proxy: every\n * respondent would share its one rate-limit budget. Refused at startup when it\n * is not addresses and ranges, since a server that started anyway would count\n * the wrong client and nothing would say so.\n */\nconst trustProxy = trustProxyFrom(process.env['FORMANCY_TRUST_PROXY'])\n\n/**\n * The model the builders ask through this server, with its key kept here rather than in a\n * browser (0165). Unset is a supported state and the default: no model, no prompt pane in\n * the admin, and no form sent anywhere. Half a configuration — a key with no provider, a\n * provider with no model — is refused here, because a server that started anyway would\n * have no model while the operator believed it had one. The key is never logged.\n */\nconst modelChoice = modelSettings(process.env)\nconst model =\n modelChoice.kind === 'none'\n ? undefined\n : { provider: modelChoice.provider, model: modelChoice.model, completer: createCompleter(modelChoice) }\n\nawait bootstrapSchema(sql)\nconst storage = createPostgresStorage(sql)\n// Every limit counts in the database, so behind any number of replicas it is the limit it says\n// rather than that many times it; on connections of its own, so a counter that cannot answer\n// holds none of the ones storage queries on. No setting turns it off (0170). It says in the log\n// when it stops answering and when it answers again, and nothing when the log is off.\nconst rateLimits = createPostgresRateLimitStore(databaseUrl, log === undefined ? {} : { log })\nconst app = await createApp(storage, {\n authSecret,\n rateLimitStore: rateLimits.store,\n ...(adminEmail !== undefined && adminPassword !== undefined\n ? { bootstrapAdmin: { email: adminEmail, password: adminPassword } }\n : {}),\n ...(fileStore === undefined ? {} : { fileStore }),\n ...(challengeSecret === undefined ? {} : { challengeSecret }),\n ...(scanner === undefined ? {} : { scanner }),\n ...(trustProxy === undefined ? {} : { trustProxy }),\n ...(model === undefined ? {} : { model }),\n ...(logLevel === 'off' ? {} : { log: { sink: process.stdout, level: logLevel } }),\n maxFileBytes,\n})\n\n// Queued deliveries are useless until something sends them. Each worker says a failed pass\n// in the app's log, by the same rule, and says nothing when it is off.\nconst outbox = startOutboxWorker(storage, {\n log: app.log,\n ...(process.env['FORMANCY_WEBHOOK_ALLOW_HTTP'] === 'true' ? { allowHttp: true } : {}),\n // Gives up the SSRF guard. For a sidecar receiver on a trusted network, and\n // for nothing else.\n ...(process.env['FORMANCY_WEBHOOK_ALLOW_PRIVATE'] === 'true'\n ? { allowPrivateAddresses: true }\n : {}),\n})\n// Somebody who attaches a file and closes the tab leaves bytes behind. A disk\n// that fills up for a reason nobody is watching is the most tedious outage\n// there is.\nconst collector =\n fileStore === undefined ? undefined : startFileCollector(storage, fileStore, { log: app.log })\n\n// Independent of the collector: a deployment can have public forms and no\n// uploads, and that deployment still accumulates spent challenges.\nconst sweeper = challengeSecret === undefined ? undefined : startChallengeSweeper(storage, { log: app.log })\n\nfor (const signal of ['SIGINT', 'SIGTERM'] as const) {\n process.once(signal, () => {\n outbox.stop()\n collector?.stop()\n sweeper?.stop()\n void app.close().then(() => process.exit(0))\n })\n}\n\nawait app.listen({ port, host: process.env['HOST'] ?? '0.0.0.0' })\nconsole.log(`formancy server listening on :${port}`)\n// Said once, at startup, so an operator reading the log knows where forms are sent.\nconsole.log(model === undefined ? 'No model configured.' : `Builders ask ${model.provider}'s ${model.model}.`)\n\nfor (const signal of ['SIGINT', 'SIGTERM'] as const) {\n process.on(signal, () => {\n void app\n .close()\n .then(() => rateLimits.end())\n .then(() => sql.end())\n .then(() => process.exit(0))\n })\n}\n"],"mappings":";;;;;;;;;AA4CA,SAAgB,kBACd,KACA,UAA6B,CAAC,GACV;CACpB,IAAI;CACJ,IAAI;EACF,MAAM,IAAI,IAAI,GAAG;CACnB,QAAQ;EACN,OAAO;CACT;CAEA,IAAI,IAAI,aAAa,YAAY,IAAI,aAAa,SAChD,OAAO,gDAAgD,IAAI,SAAS,QAAQ,KAAK,EAAE,EAAE;CAGvF,IAAI,IAAI,aAAa,WAAW,QAAQ,cAAc,MACpD,OAAO;CAGT,IAAI,IAAI,aAAa,MAAM,IAAI,aAAa,IAC1C,OAAO;CAMT,IACE,QAAQ,0BAA0B,QAClC,YAAY,IAAI,QAAQ,KACxB,iBAAiB,IAAI,QAAQ,GAE7B,OAAO,GAAG,IAAI,SAAS;AAI3B;;;AC/BA,MAAM,qBAAqB;AAE3B,eAAe,WAAW,UAAqC;CAE7D,QAAO,MADeA,OAAU,UAAU;EAAE,KAAK;EAAM,UAAU;CAAK,CAAC,EAAA,CACxD,KAAK,WAAW,OAAO,OAAO;AAC/C;AAEA,eAAsB,QACpB,OAQA,UAA2B,CAAC,GACH;CACzB,MAAM,UAAU,kBAAkB,MAAM,KAAK;EAC3C,WAAW,QAAQ,aAAa;EAChC,uBAAuB,QAAQ,yBAAyB;CAC1D,CAAC;CACD,IAAI,YAAY,KAAA,GAAW,OAAO;EAAE,IAAI;EAAO,OAAO;CAAQ;CAE9D,MAAM,MAAM,IAAI,IAAI,MAAM,GAAG;CAC7B,MAAM,UAAU,QAAQ,WAAW;CAEnC,IAAI;CACJ,IAAI;EACF,YAAY,MAAM,QAAQ,IAAI,QAAQ;CACxC,SAAS,OAAO;EACd,OAAO;GAAE,IAAI;GAAO,OAAO,qBAAqB,IAAI,SAAS,IAAI,OAAO,KAAK;EAAI;CACnF;CAEA,IAAI,UAAU,WAAW,GAAG,OAAO;EAAE,IAAI;EAAO,OAAO,GAAG,IAAI,SAAS;CAAuB;CAK9F,MAAM,YAAY,QAAQ,0BAA0B,OAChD,KAAA,IACA,UAAU,MAAM,YAAY,iBAAiB,OAAO,CAAC;CACzD,IAAI,cAAc,KAAA,GAChB,OAAO;EAAE,IAAI;EAAO,OAAO,GAAG,IAAI,SAAS,eAAe,UAAU;CAAqC;CAG3G,MAAM,SAAS,UAAU;CAKzB,MAAM,SAAS,OAAO,SAAS,GAAG,IAAI,IAAI;CAC1C,MAAM,QAAQ,IAAI,MAAM,EACtB,SAAS,EAOP,SAAS,WAAW,MAAqC,aAAyC;EAChG,MAAM,OAAO;EAKb,IAAI,KAAK,QAAQ,MAAM,KAAK,MAAM,CAAC;GAAE,SAAS;GAAQ;EAAO,CAAC,CAAC;OAC1D,KAAK,MAAM,QAAQ,MAAM;CAChC,EACF,EACF,CAAC;CAED,IAAI;EACF,MAAM,WAAW,MAAMC,QAAY,KAAK;GACtC,QAAQ;GACR,SAAS,gBAAgB;IACvB,SAAS,MAAM;IACf,QAAQ,MAAM;IACd,MAAM,MAAM;IACZ,kBAAkB,MAAM;IACxB,SAAS,MAAM;GACjB,CAAC;GACD,MAAM,MAAM;GAGZ,UAAU;GACV,QAAQ,YAAY,QAAQ,QAAQ,aAAa,GAAM;GACvD,YAAY;EACd,CAAC;EAED,IAAI,SAAS,UAAU,OAAO,SAAS,SAAS,KAC9C,OAAO;GAAE,IAAI;GAAO,QAAQ,SAAS;GAAQ,OAAO;EAA8B;EAMpF,MAAM,MAAM,UAAU,kBAAkB;EAExC,OAAO,SAAS,KACZ;GAAE,IAAI;GAAM,QAAQ,SAAS;EAAO,IACpC;GAAE,IAAI;GAAO,QAAQ,SAAS;GAAQ,OAAO,qBAAqB,OAAO,SAAS,MAAM,EAAE;EAAG;CACnG,SAAS,OAAO;EAGd,MAAM,QAAQ,iBAAiB,SAAS,MAAM,iBAAiB,QAAQ,KAAK,MAAM,MAAM,YAAY;EACpG,OAAO;GAAE,IAAI;GAAO,OAAO,GAAG,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,IAAI;EAAQ;CACjG,UAAU;EACR,MAAM,MAAM,MAAM;CACpB;AACF;AAGA,eAAe,MAAM,UAA8B,KAA4B;CAE7E,MAAM,SADO,SAAS,MACD,UAAU;CAC/B,IAAI,WAAW,KAAA,GAAW;CAE1B,IAAI,OAAO;CACX,IAAI;EACF,SAAS;GACP,MAAM,EAAE,MAAM,UAAU,MAAM,OAAO,KAAK;GAC1C,IAAI,MAAM;GACV,QAAQ,OAAO,cAAc;GAC7B,IAAI,QAAQ,KAAK;EACnB;CACF,UAAU;EACR,MAAM,OAAO,OAAO,CAAC,CAAC,YAAY,KAAA,CAAS;CAC7C;AACF;;;AC1JA,SAAgB,kBACd,SACA,UAA0G,CAAC,GAC7F;CACd,MAAM,WAAW,QAAQ,cAAc;CACvC,MAAM,MAAM,UAAU,QAAQ,GAAG;CACjC,IAAI,UAAU;CACd,IAAI,UAAU;CAEd,MAAM,OAAO,YAA2B;EAGtC,IAAI,WAAW,SAAS;EACxB,UAAU;EACV,IAAI;GACF,MAAM,YAAY;IAChB;IACA,2BAAW,IAAI,KAAK;IACpB,cAAc,KAAK,OAAO;IAC1B,MAAM,OAAO,UAAU,SAAS,eAC9B,QACE;KACE,KAAK,QAAQ;KACb,MAAM,SAAS;KACf,QAAQ,QAAQ;KAChB,SAAS,SAAS;KAClB,SAAS,SAAS,UAAU;KAC5B;IACF,GACA;KACE,WAAW,QAAQ,aAAa;KAChC,uBAAuB,QAAQ,yBAAyB;IAC1D,CACF;GACJ,CAAC;EACH,SAAS,OAAO;GAKd,IAAI,MAAM;IAAE,OAAO;IAAiB,KAAK;GAAM,CAAC;EAClD,UAAU;GACR,UAAU;EACZ;CACF;CAEA,MAAM,QAAQ,kBAAkB,KAAK,KAAK,GAAG,QAAQ;CAGrD,MAAM,QAAQ;CAEd,OAAO,EACL,YAAY;EACV,UAAU;EACV,cAAc,KAAK;CACrB,EACF;AACF;;;ACrDA,SAAgB,sBACd,SACA,UAAoD,CAAC,GACtC;CACf,MAAM,MAAM,UAAU,QAAQ,GAAG;CAIjC,MAAM,WAAW,QAAQ,cAAc;CACvC,IAAI,UAAU;CACd,IAAI,UAAU;CAEd,MAAM,OAAO,YAA2B;EACtC,IAAI,WAAW,SAAS;EACxB,UAAU;EACV,IAAI;GACF,MAAM,QAAQ,yCAAwB,IAAI,KAAK,EAAA,CAAE,YAAY,CAAC;EAChE,SAAS,OAAO;GAGd,IAAI,MAAM;IAAE,OAAO;IAAkB,KAAK;GAAM,CAAC;EACnD,UAAU;GACR,UAAU;EACZ;CACF;CAEA,MAAM,QAAQ,kBAAkB,KAAK,KAAK,GAAG,QAAQ;CACrD,MAAM,QAAQ;CAEd,OAAO,EACL,YAAY;EACV,UAAU;EACV,cAAc,KAAK;CACrB,EACF;AACF;;;AC7CA,MAAa,uBACX;AAwBF,SAAgB,YAAY,MAAwC;CAClE,MAAM,EAAE,QAAQ,KAAK,aAAa,SAAS,QAAQ,aAAa,iBAAiB,QAAQ;CAIzF,MAAM,UAAkC,CAAC;CACzC,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,KAAK,OAAO,GACrD,QAAQ,KAAK,YAAY,KAAK;CAGhC,IAAI,QAAQ,YAAY,KAAA,GAGtB,MAAM,IAAI,MAAM,iDAAiD;CAGnE,MAAM,UAAU,aAAa,GAAG;CAChC,MAAM,YAAY,QAAQ,MAAM,GAAG,CAAC;CAEpC,QAAQ,gBAAgB;CAIxB,QAAQ,0BAA0B;CAGlC,MAAM,QAAQ,OAAO,KAAK,OAAO,CAAC,CAAC,KAAK;CACxC,MAAM,mBAAmB,MAGtB,KAAK,SAAS,GAAG,KAAK,IAAI,QAAQ,SAAS,GAAA,CAAI,KAAK,CAAC,CAAC,QAAQ,QAAQ,GAAG,EAAE,GAAG,CAAC,CAC/E,KAAK,EAAE;CACV,MAAM,gBAAgB,MAAM,KAAK,GAAG;CAEpC,MAAM,mBAAmB;EACvB;EACA,cAAc,KAAK,OAAO;EAC1B,eAAe,GAAG;EAClB;EACA;EACA;CACF,CAAC,CAAC,KAAK,IAAI;CAEX,MAAM,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,QAAQ;CAChD,MAAM,eAAe;EACnB;EACA;EACA;EACA,UAAU,gBAAgB;CAC5B,CAAC,CAAC,KAAK,IAAI;CAWX,QAAQ,mBACN,+BAA+B,YAAY,GAAG,MAAM,kBACnC,cAAc,cAJf,KADC,KADA,KADD,KADF,KAAK,OAAO,mBAAmB,SAClB,GAAG,MACA,GAAG,OACF,GAAG,cACJ,GAAG,YAAY,CAAC,CAAC,SAAS,KAIH;CAEvD,OAAO;AACT;AAUA,SAAS,aAAa,IAAkB;CACtC,OAAO,GAAG,GAAG,YAAY,CAAC,CAAC,QAAQ,SAAS,EAAE,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,GAAG;AACtE;AAUA,SAAS,cAAc,KAAU,SAAyB;CAGxD,MAAM,OAAO,IAAI,aAAa,KAAK,MAAM,IAAI;CAC7C,IAAI,YAAY,MAAM,OAAO;CAC7B,OAAO,KACJ,MAAM,GAAG,CAAC,CACV,KAAK,YAAY,UAAU,mBAAmB,OAAO,CAAC,CAAC,CAAC,CACxD,KAAK,GAAG;AACb;AAGA,SAAS,eAAe,KAAkB;CACxC,MAAM,QAA0C,CAAC;CACjD,KAAK,MAAM,CAAC,MAAM,UAAU,IAAI,cAAc,MAAM,KAAK,CAAC,UAAU,IAAI,GAAG,UAAU,KAAK,CAAC,CAAC;CAG5F,MAAM,MAAM,CAAC,IAAI,CAAC,OAAQ,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,CAAE;CACrD,OAAO,MAAM,KAAK,CAAC,MAAM,WAAW,GAAG,KAAK,GAAG,OAAO,CAAC,CAAC,KAAK,GAAG;AAClE;AASA,SAAS,UAAU,OAAuB;CACxC,OAAO,mBAAmB,KAAK,CAAC,CAAC,QAC/B,aACC,cAAc,IAAI,UAAU,WAAW,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,YAAY,GACtE;AACF;AAEA,SAAS,UAAU,OAAuB;CACxC,OAAO,WAAW,QAAQ,CAAC,CAAC,OAAO,OAAO,MAAM,CAAC,CAAC,OAAO,KAAK;AAChE;AAEA,SAAS,KAAK,KAAsB,MAAsB;CACxD,OAAO,WAAW,UAAU,GAAG,CAAC,CAAC,OAAO,MAAM,MAAM,CAAC,CAAC,OAAO;AAC/D;;;AC1GA,SAAgB,kBAAkB,QAAsC;CACtE,MAAM,WAAW,OAAO,SAAS,QAAQ,QAAQ,EAAE;CACnD,MAAM,YAAY,OAAO,aAAa;CAUtC,MAAM,UAAU,QAAqB;EACnC,IAAI,QAAQ,MAAM,IAAI,SAAS,IAAI,KAAK,IAAI,MAAM,GAAG,CAAC,CAAC,SAAS,IAAI,GAGlE,MAAM,IAAI,MAAM,6DAA6D,KAAK;EAEpF,MAAM,OAAO,IAAI,MAAM,GAAG,CAAC,CAAC,IAAI,aAAa,CAAC,CAAC,KAAK,GAAG;EACvD,OAAO,IAAI,IAAI,GAAG,SAAS,GAAG,cAAc,OAAO,MAAM,EAAE,GAAG,MAAM;CACtE;CAuBA,MAAM,OAAO,OACX,QACA,KACA,SACoB;EACpB,MAAM,MAAM,OAAO,GAAG;EACtB,MAAM,cACJ,SAAS,KAAA,IACL,uBACA,WAAW,QAAQ,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,OAAO,KAAK;EASpD,MAAM,UAAU,YAAY;GAC1B;GACA;GACA,SAAS,EAAE,MAAM,IAAI,KAAK;GAC1B;GACA,SAAS;GACT,QAAQ,OAAO;GACf,aAAa,OAAO;GACpB,iBAAiB,OAAO;GACxB,qBAAK,IAAI,KAAK;EAChB,CAAC;EAED,MAAM,UAAU,MAAM,WAAW,MAAM,OAAO,MAAM,KAAK;EACzD,QAAQ,MAAM,WAAW;EACzB,MAAM,WAAW,MAAM,MAAM,KAAK;GAChC;GACA;GACA,GAAI,SAAS,KAAA,IAAY,CAAC,IAAI,EAAE,MAAM,IAAI,WAAW,IAAI,EAAE;GAC3D,QAAQ,QAAQ;EAClB,CAAC,CAAC,CAAC,QAAQ,QAAQ,IAAI;EAEvB,MAAM,EAAE,IAAI,QAAQ,eAAe;EACnC,MAAM,SAAS;GAAE;GAAI;GAAQ;GAAY,SAAS,SAAS;EAAQ;EACnE,IAAI,SAAS,SAAS,MAAM,OAAO;GAAE,GAAG;GAAQ,MAAM;EAAK;EAC3D,QAAQ,MAAM,SAAS;EACvB,OAAO;GAAE,GAAG;GAAQ,MAAM,MAAM,SAAS,MAAM,OAAO;EAAE;CAC1D;CAgBA,MAAM,YAAY,aAA8B,SAAS,WAAW;CAEpE,MAAM,UAAU,QAAgB,KAAa,6BAC3C,IAAI,MAAM,MAAM,OAAO,MAAM,IAAI,WAAW,OAAO,SAAS,MAAM,EAAE,GAAG,SAAS,YAAY;CAE9F,OAAO;EACL,MAAM,IAAI,KAAK,OAAO;GACpB,MAAM,WAAW,MAAM,KAAK,OAAO,KAAK,KAAK;GAC7C,IAAI,CAAC,SAAS,IAAI,MAAM,OAAO,OAAO,KAAK,QAAQ;GAGnD,MAAM,QAAQ,SAAS,IAAI;EAC7B;EAEA,MAAM,KAAK,KAAK;GACd,MAAM,WAAW,MAAM,KAAK,OAAO,GAAG;GACtC,IAAI,SAAS,QAAQ,GAAG;IACtB,MAAM,QAAQ,SAAS,IAAI;IAC3B;GACF;GACA,IAAI,CAAC,SAAS,MAAM,SAAS,SAAS,MAAM,MAAM,OAAO,OAAO,KAAK,QAAQ;GAK7E,OAAO,SAAS,KAAK,SAAS,MAAM,EAAE,YAAY,MAAM,CAAC;EAC3D;EAEA,MAAM,OAAO,KAAK;GAChB,MAAM,WAAW,MAAM,KAAK,UAAU,GAAG;GAGzC,IAAI,SAAS,MAAM,SAAS,QAAQ,GAAG;IACrC,MAAM,QAAQ,SAAS,IAAI;IAC3B;GACF;GACA,MAAM,OAAO,UAAU,KAAK,QAAQ;EACtC;EAEA,MAAM,OAAO,KAAK;GAChB,MAAM,WAAW,MAAM,KAAK,QAAQ,GAAG;GACvC,IAAI,SAAS,QAAQ,GAAG,OAAO,KAAA;GAC/B,IAAI,CAAC,SAAS,IAAI,MAAM,OAAO,QAAQ,KAAK,QAAQ;GACpD,MAAM,SAAS,SAAS,QAAQ,IAAI,gBAAgB;GAIpD,IAAI,WAAW,MAAM,MAAM,IAAI,MAAM,cAAc,IAAI,6BAA6B;GACpF,OAAO,OAAO,MAAM;EACtB;CACF;AACF;AAwBA,SAAS,MAAM,WAAmB,SAAiB;CACjD,MAAM,aAAa,IAAI,gBAAgB;CACvC,IAAI;CACJ,MAAM,aAAmB,aAAa,KAAK;CAC3C,MAAM,SAAS,SAAuB;EACpC,KAAK;EACL,QAAQ,iBAAiB;GACvB,WAAW,sBAAM,IAAI,MAAM,GAAG,QAAQ,IAAI,KAAK,UAAU,OAAO,SAAS,EAAE,IAAI,CAAC;EAClF,GAAG,SAAS;EACZ,MAAM,MAAM;CACd;CACA,OAAO;EAAE,QAAQ,WAAW;EAAQ;EAAO;CAAK;AAClD;AAQA,gBAAgB,MACd,QACA,SAC4B;CAC5B,IAAI;EACF,WAAW,MAAM,SAAS,QAAQ;GAChC,QAAQ,KAAK;GACb,MAAM;GACN,QAAQ,MAAM,SAAS;EACzB;CACF,UAAU;EACR,QAAQ,KAAK;CACf;AACF;AAGA,eAAe,QAAQ,MAAqC;CAC1D,IAAI,SAAS,MAAM;CACnB,WAAW,MAAM,UAAU;AAG7B;AAWA,SAAS,cAAc,OAAuB;CAC5C,OAAO,mBAAmB,KAAK,CAAC,CAAC,QAC/B,aACC,cAAc,IAAI,UAAU,WAAW,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,YAAY,GACtE;AACF;;;AC7QA,SAAgB,kBAAkB,KAAqC;CACrE,MAAM,YAAY,QAAQ,KAAK,oBAAoB;CACnD,MAAM,WAAW,QAAQ,KAAK,sBAAsB;CAEpD,IAAI,cAAc,KAAA,KAAa,aAAa,KAAA,GAK1C,MAAM,IAAI,MACR,+HAEF;CAGF,IAAI,aAAa,KAAA,GAAW;EAK1B,MAAM,YAAY,SAAyB;GACzC,MAAM,QAAQ,IAAI;GAClB,IAAI,UAAU,KAAA,KAAa,UAAU,IACnC,MAAM,IAAI,MAAM,GAAG,KAAK,+CAA+C;GAEzE,OAAO;EACT;EAEA,OAAO;GACL,MAAM;GACN,QAAQ;IACN;IACA,QAAQ,SAAS,oBAAoB;IAIrC,QAAQ,SAAS,oBAAoB;IACrC,aAAa,SAAS,2BAA2B;IACjD,iBAAiB,SAAS,+BAA+B;GAC3D;EACF;CACF;CAEA,OAAO,cAAc,KAAA,IAAY,EAAE,MAAM,OAAO,IAAI;EAAE,MAAM;EAAS;CAAU;AACjF;AAaA,SAAS,QAAQ,KAAkB,MAAkC;CACnE,MAAM,QAAQ,IAAI;CAClB,OAAO,UAAU,KAAK,KAAA,IAAY;AACpC;;;ACtFA,MAAM,QAAQ;AAMd,MAAa,0BAA0B;AA6BvC,SAAgB,mBAAmB,EACjC,MACA,MACA,WAAW,yBACX,YAAY,OAMF;CACV,OAAO,EACL,OAAO,OAAO,UACZ,IAAI,SAAsB,SAAS,WAAW;EAC5C,IAAI,MAAM,aAAa,UAAU;GAC/B,QAAQ;IACN,OAAO;IACP,SAAS,0DAA0D,OAAO,QAAQ,EAAE;GACtF,CAAC;GACD;EACF;EACA,MAAM,SAAS,QAAQ;GAAE;GAAM;EAAK,CAAC;EACrC,MAAM,SAAmB,CAAC;EAC1B,IAAI;EAEJ,OAAO,WAAW,iBAChB,OAAO,wBACL,IAAI,MACF,YAAY,KAAK,GAAG,OAAO,IAAI,EAAE,yBAAyB,OAAO,SAAS,EAAE,IAC9E,CACF,CACF;EACA,OAAO,GAAG,iBAAiB;GACzB,OAAO,MAAM,aAAa;GAC1B,KAAK,IAAI,KAAK,GAAG,KAAK,MAAM,YAAY,MAAM,OAAO;IACnD,MAAM,QAAQ,MAAM,SAAS,IAAI,KAAK,KAAK;IAC3C,MAAM,SAAS,OAAO,MAAM,CAAC;IAC7B,OAAO,cAAc,MAAM,UAAU;IACrC,OAAO,MAAM,MAAM;IACnB,OAAO,MAAM,KAAK;GACpB;GAGA,OAAO,MAAM,OAAO,MAAM,CAAC,CAAC;EAC9B,CAAC;EACD,OAAO,GAAG,SAAS,SAAS,OAAO,KAAK,IAAI,CAAC;EAG7C,OAAO,GAAG,UAAU,UAAU;GAC5B,UAAU;EACZ,CAAC;EACD,OAAO,GAAG,eAAe;GACvB,MAAM,QAAQ,OAAO,OAAO,MAAM,CAAC,CAAC,SAAS,MAAM,CAAC,CAAC,QAAQ,QAAQ,EAAE,CAAC,CAAC,KAAK;GAC9E,IAAI,UAAU,IAAI;IAChB,OACE,2BAAW,IAAI,MAAM,YAAY,KAAK,GAAG,OAAO,IAAI,EAAE,0BAA0B,CAClF;IACA;GACF;GACA,MAAM,UAAU,UAAU,KAAK;GAC/B,IAAI,YAAY,KAAA,GACd,uBAAO,IAAI,MAAM,wCAAwC,OAAO,CAAC;QAC9D,QAAQ,OAAO;EACtB,CAAC;CACH,CAAC,EACL;AACF;AAEA,SAAS,UAAU,OAAwC;CACzD,IAAI,UAAU,cAAc,OAAO,EAAE,OAAO,KAAK;CACjD,MAAM,QAAQ,uBAAuB,KAAK,KAAK;CAC/C,IAAI,UAAU,MAAM,OAAO;EAAE,OAAO;EAAO,SAAS,MAAM;CAAI;CAC9D,IAAI,uBAAuB,KAAK,KAAK,GACnC,OAAO;EAAE,OAAO;EAAO,SAAS,yBAAyB,MAAM,QAAQ,gBAAgB,EAAE;CAAI;AAGjG;;;ACjHA,MAAM,gBACJ;AAkCF,SAAgB,eAAe,OAAiD;CAC9E,IAAI,UAAU,KAAA,KAAa,MAAM,KAAK,MAAM,IAAI,OAAO,KAAA;CAEvD,MAAM,UAAU,MAAM,KAAK;CAC3B,IAAI,QAAQ,KAAK,OAAO,GACtB,MAAM,IAAI,MACR,4BAA4B,QAAQ,6GACiB,eACvD;CAEF,IAAI,QAAQ,YAAY,MAAM,QAC5B,MAAM,IAAI,MACR,mJAC4D,eAC9D;CAGF,MAAM,UAAU,QAAQ,MAAM,GAAG,CAAC,CAAC,KAAK,UAAU,MAAM,KAAK,CAAC;CAC9D,KAAK,MAAM,SAAS,SAClB,IAAI,CAAC,iBAAiB,KAAK,GACzB,MAAM,IAAI,MACR,6BAA6B,MAAM,iDAAiD,eACtF;CAGJ,OAAO;AACT;AAUA,SAAS,iBAAiB,OAAwB;CAChD,MAAM,QAAQ,MAAM,QAAQ,GAAG;CAC/B,IAAI,UAAU,IAAI,OAAO,KAAK,KAAK,MAAM;CAEzC,MAAM,SAAS,KAAK,MAAM,MAAM,GAAG,KAAK,CAAC;CACzC,MAAM,SAAS,MAAM,MAAM,QAAQ,CAAC;CACpC,IAAI,WAAW,KAAK,CAAC,YAAY,KAAK,MAAM,GAAG,OAAO;CACtD,MAAM,OAAO,OAAO,MAAM;CAC1B,OAAO,QAAQ,KAAK,SAAS,WAAW,IAAI,KAAK;AACnD;;;ACnEA,SAAgB,aAAa,KAAqE;CAChG,MAAM,QAAQ,IAAI,qBAAqB,EAAE,KAAK,KAAK;CACnD,IAAI,UAAU,IAAI,OAAO;CACzB,IAAI,UAAU,OAAO,OAAO;CAC5B,MAAM,QAAQ,WAAW,MAAM,UAAU,UAAU,KAAK;CACxD,IAAI,UAAU,KAAA,GACZ,MAAM,IAAI,MACR,0BAA0B,MAAM,6DACtB,WAAW,KAAK,IAAI,EAAE,+BAClC;CAEF,OAAO;AACT;;;ACAA,QAAQ,IAAI,uBAAuB;AAUnC,MAAM,cAAc,QAAQ,IAAI;AAChC,IAAI,gBAAgB,KAAA,KAAa,gBAAgB,IAAI;CACnD,QAAQ,MAAM,qFAAqF;CACnG,QAAQ,KAAK,CAAC;AAChB;AAEA,MAAM,OAAO,OAAO,QAAQ,IAAI,WAAW,IAAI;AAU/C,MAAM,WAAW,aAAa,QAAQ,GAAG;AACzC,MAAM,MAAM,aAAa,QAAQ,KAAA,IAAY,gBAAgB,QAAQ,QAAQ,QAAQ;AACrF,MAAM,MAAM,SAAS,aAAa,EAAE,UAAU,gBAAgB,GAAG,EAAE,CAAC;AAEpE,IAAI,aAAa,QAAQ,IAAI;AAC7B,IAAI,eAAe,KAAA,KAAa,eAAe,IAAI;CAIjD,aAAa,YAAY,EAAE,CAAC,CAAC,SAAS,WAAW;CACjD,QAAQ,KAGN,gJACF;AACF;AAEA,MAAM,aAAa,QAAQ,IAAI;AAC/B,MAAM,gBAAgB,QAAQ,IAAI;AAGlC,MAAM,gBAAgB,kBAAkB,QAAQ,GAAG;AACnD,MAAM,YACJ,cAAc,SAAS,OACnB,kBAAkB,cAAc,MAAM,IACtC,cAAc,SAAS,UACrB,qBAAqB,cAAc,SAAS,IAC5C,KAAA;AAGR,MAAM,eAAe,iBAAiB,QAAQ,GAAG;AAYjD,MAAM,YAAY,QAAQ,IAAI;AAC9B,MAAM,YAAY,OAAO,QAAQ,IAAI,0BAA0B,IAAI;AACnE,MAAM,gBAAgB,OAAO,QAAQ,IAAI,+BAAA,SAAsD;AAC/F,IAAI,CAAC,OAAO,UAAU,SAAS,KAAK,aAAa,KAAK,YAAY,OAChE,MAAM,IAAI,MAAM,4CAA4C;AAE9D,IAAI,CAAC,OAAO,SAAS,aAAa,KAAK,iBAAiB,GACtD,MAAM,IAAI,MAAM,uFAAuF;AAEzG,MAAM,UACJ,cAAc,KAAA,KAAa,cAAc,KACrC,KAAA,IACA,mBAAmB;CAAE,MAAM;CAAW,MAAM;CAAW,UAAU;AAAc,CAAC;AAgBtF,MAAM,kBAAkB,QAAQ,IAAI;AACpC,IAAI,oBAAoB,KAAA,KAAa,gBAAgB,SAAS,IAC5D,MAAM,IAAI,MAAM,qEAAqE;AAUvF,MAAM,aAAa,eAAe,QAAQ,IAAI,uBAAuB;AASrE,MAAM,cAAc,cAAc,QAAQ,GAAG;AAC7C,MAAM,QACJ,YAAY,SAAS,SACjB,KAAA,IACA;CAAE,UAAU,YAAY;CAAU,OAAO,YAAY;CAAO,WAAW,gBAAgB,WAAW;AAAE;AAE1G,MAAM,gBAAgB,GAAG;AACzB,MAAM,UAAU,sBAAsB,GAAG;AAKzC,MAAM,aAAa,6BAA6B,aAAa,QAAQ,KAAA,IAAY,CAAC,IAAI,EAAE,IAAI,CAAC;AAC7F,MAAM,MAAM,MAAM,UAAU,SAAS;CACnC;CACA,gBAAgB,WAAW;CAC3B,GAAI,eAAe,KAAA,KAAa,kBAAkB,KAAA,IAC9C,EAAE,gBAAgB;EAAE,OAAO;EAAY,UAAU;CAAc,EAAE,IACjE,CAAC;CACL,GAAI,cAAc,KAAA,IAAY,CAAC,IAAI,EAAE,UAAU;CAC/C,GAAI,oBAAoB,KAAA,IAAY,CAAC,IAAI,EAAE,gBAAgB;CAC3D,GAAI,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,QAAQ;CAC3C,GAAI,eAAe,KAAA,IAAY,CAAC,IAAI,EAAE,WAAW;CACjD,GAAI,UAAU,KAAA,IAAY,CAAC,IAAI,EAAE,MAAM;CACvC,GAAI,aAAa,QAAQ,CAAC,IAAI,EAAE,KAAK;EAAE,MAAM,QAAQ;EAAQ,OAAO;CAAS,EAAE;CAC/E;AACF,CAAC;AAID,MAAM,SAAS,kBAAkB,SAAS;CACxC,KAAK,IAAI;CACT,GAAI,QAAQ,IAAI,mCAAmC,SAAS,EAAE,WAAW,KAAK,IAAI,CAAC;CAGnF,GAAI,QAAQ,IAAI,sCAAsC,SAClD,EAAE,uBAAuB,KAAK,IAC9B,CAAC;AACP,CAAC;AAID,MAAM,YACJ,cAAc,KAAA,IAAY,KAAA,IAAY,mBAAmB,SAAS,WAAW,EAAE,KAAK,IAAI,IAAI,CAAC;AAI/F,MAAM,UAAU,oBAAoB,KAAA,IAAY,KAAA,IAAY,sBAAsB,SAAS,EAAE,KAAK,IAAI,IAAI,CAAC;AAE3G,KAAK,MAAM,UAAU,CAAC,UAAU,SAAS,GACvC,QAAQ,KAAK,cAAc;CACzB,OAAO,KAAK;CACZ,WAAW,KAAK;CAChB,SAAS,KAAK;CACd,IAAS,MAAM,CAAC,CAAC,WAAW,QAAQ,KAAK,CAAC,CAAC;AAC7C,CAAC;AAGH,MAAM,IAAI,OAAO;CAAE;CAAM,MAAM,QAAQ,IAAI,WAAW;AAAU,CAAC;AACjE,QAAQ,IAAI,iCAAiC,MAAM;AAEnD,QAAQ,IAAI,UAAU,KAAA,IAAY,yBAAyB,gBAAgB,MAAM,SAAS,KAAK,MAAM,MAAM,EAAE;AAE7G,KAAK,MAAM,UAAU,CAAC,UAAU,SAAS,GACvC,QAAQ,GAAG,cAAc;CACvB,IACG,MAAM,CAAC,CACP,WAAW,WAAW,IAAI,CAAC,CAAC,CAC5B,WAAW,IAAI,IAAI,CAAC,CAAC,CACrB,WAAW,QAAQ,KAAK,CAAC,CAAC;AAC/B,CAAC"}
|
|
1
|
+
{"version":3,"file":"main.mjs","names":["dnsLookup","undiciFetch"],"sources":["../src/webhook-url.ts","../src/deliver.ts","../src/outbox-worker.ts","../src/challenge-sweeper.ts","../src/sigv4.ts","../src/s3-file-store.ts","../src/file-store-settings.ts","../src/clamd-scanner.ts","../src/trust-proxy.ts","../src/log-settings.ts","../src/main.ts"],"sourcesContent":["import { isIpLiteral, isPrivateAddress } from '@formancy/server-core'\n\n/**\n * URL-level checks on a webhook destination.\n *\n * Here rather than in @formancy/server-core because it needs `URL`, and that\n * package deliberately has no DOM or Node types — see\n * docs/decisions/0008-layered-packages.md. Hand-rolling a URL parser to keep it\n * there would be the wrong trade: a security check built on an approximate\n * parser is the kind that is quietly wrong.\n *\n * The address classification it calls IS pure and lives there, tested\n * exhaustively.\n */\n\nexport interface WebhookUrlOptions {\n /**\n * Allow plain http. Off by default: a signature over a body anybody can read\n * and rewrite in flight is theatre. A deployment posting to a sidecar on the\n * same host may reasonably turn it on.\n */\n allowHttp?: boolean\n /**\n * Allow delivery to a private address.\n *\n * OFF by default, and the only reason it exists is the case the design\n * itself names: a receiver running as a sidecar on the same host or the same\n * private network. Without it that deployment simply cannot use webhooks,\n * which is a worse outcome than an escape hatch nobody has to touch.\n *\n * Turning it on gives up the confused-deputy protection entirely — the\n * server will then fetch whatever URL a form author writes, including the\n * cloud metadata service. A deployment that enables it should also be one\n * where form authors are trusted.\n */\n allowPrivateAddresses?: boolean\n}\n\n/**\n * What is wrong with this webhook URL, or undefined when nothing is.\n *\n * Checked when the URL is saved, so an author finds out immediately rather\n * than through a delivery that silently never works.\n */\nexport function webhookUrlProblem(raw: string, options: WebhookUrlOptions = {}): string | undefined {\n let url: URL\n try {\n url = new URL(raw)\n } catch {\n return 'That is not a URL.'\n }\n\n if (url.protocol !== 'https:' && url.protocol !== 'http:') {\n return `Only http and https can be delivered to, not ${url.protocol.replace(':', '')}.`\n }\n\n if (url.protocol === 'http:' && options.allowHttp !== true) {\n return 'Use https. A signature over a body that anyone can read and rewrite proves nothing.'\n }\n\n if (url.username !== '' || url.password !== '') {\n return 'Remove the credentials from the URL: they would be sent to whatever the host resolves to, and they end up in logs.'\n }\n\n // Only a LITERAL address can be judged here. A hostname is checked after\n // resolution, at delivery time, by the caller — and running the fail-closed\n // address check against one would refuse every hostname in the world.\n if (options.allowPrivateAddresses !== true && isIpLiteral(url.hostname) && isPrivateAddress(url.hostname)) {\n return `${url.hostname} is not an address this server may be pointed at.`\n }\n\n return undefined\n}\n","import { lookup as dnsLookup } from 'node:dns/promises'\n// fetch from undici, NOT the global one. Node's global fetch is undici too,\n// but a DIFFERENT instance, and handing it an Agent from this one fails at\n// connect time with \"invalid onRequestStart method\" — a mismatch no unit test\n// reaches, because none of them get as far as opening a socket.\nimport { Agent, fetch as undiciFetch } from 'undici'\nimport { deliveryHeaders, isPrivateAddress } from '@formancy/server-core'\nimport { webhookUrlProblem } from './webhook-url.js'\n\n/**\n * Delivering one webhook, without letting the server be pointed at itself.\n *\n * The hard part is not checking the address. It is making sure the address\n * that was checked is the address that gets connected to.\n *\n * \"Validate the URL, then fetch it\" is defeated by DNS rebinding: the name\n * resolves to a public address while we check, and to 169.254.169.254 a\n * moment later when fetch resolves it again. The two lookups are independent,\n * and an attacker only has to win the second one. So this resolves the name\n * itself, checks every address that comes back, and then connects to a\n * checked address — while still sending the original Host header and SNI, so\n * TLS and virtual hosting keep working.\n *\n * That difference is the whole feature. Everything else here is hygiene.\n */\n\nexport interface DeliveryResult {\n ok: boolean\n status?: number\n /** Why it failed, for the admin's action log. Never the response body. */\n error?: string\n}\n\nexport interface DeliveryOptions {\n /** Hard ceiling on the whole attempt, including connect and read. */\n timeoutMs?: number\n /** Plain http, for a sidecar on the same host. Off by default. */\n allowHttp?: boolean\n /**\n * Deliver to a private address. Off by default — see webhook-url.ts. This\n * gives up the confused-deputy protection, so it is opt-in per deployment\n * and never per form.\n */\n allowPrivateAddresses?: boolean\n /** Swappable so tests do not depend on the network. */\n resolve?: (hostname: string) => Promise<string[]>\n}\n\nconst RESPONSE_CAP_BYTES = 64 * 1024\n\nasync function resolveAll(hostname: string): Promise<string[]> {\n const records = await dnsLookup(hostname, { all: true, verbatim: true })\n return records.map((record) => record.address)\n}\n\nexport async function deliver(\n input: {\n url: string\n body: string\n secret: string\n eventId: string\n attempt: number\n nowSeconds: number\n },\n options: DeliveryOptions = {},\n): Promise<DeliveryResult> {\n const problem = webhookUrlProblem(input.url, {\n allowHttp: options.allowHttp ?? false,\n allowPrivateAddresses: options.allowPrivateAddresses ?? false,\n })\n if (problem !== undefined) return { ok: false, error: problem }\n\n const url = new URL(input.url)\n const resolve = options.resolve ?? resolveAll\n\n let addresses: string[]\n try {\n addresses = await resolve(url.hostname)\n } catch (error) {\n return { ok: false, error: `Could not resolve ${url.hostname}: ${String(error)}` }\n }\n\n if (addresses.length === 0) return { ok: false, error: `${url.hostname} resolved to nothing.` }\n\n // EVERY address, not the first. A name that returns one public and one\n // private address is the rebinding attack spelled out, and picking the\n // public one to validate while the stack might use either is no check at all.\n const forbidden = options.allowPrivateAddresses === true ? undefined : addresses.find((address) => isPrivateAddress(address))\n if (forbidden !== undefined) {\n return { ok: false, error: `${url.hostname} resolves to ${forbidden}, which is not reachable from here.` }\n }\n\n const pinned = addresses[0]!\n\n // The agent connects to the address we checked and nothing else. The\n // original hostname stays in the Host header and in SNI, so TLS validates\n // against the certificate the site actually has.\n const family = pinned.includes(':') ? 6 : 4\n const agent = new Agent({\n connect: {\n // undici calls this with `all: true` and then expects an ARRAY of\n // { address, family }; the plain `(err, address, family)` form that\n // node:net uses comes back as \"Invalid IP address: undefined\". Both\n // shapes are handled because which one arrives depends on undici's\n // internals, and getting it wrong fails only at connect time — where no\n // unit test looks.\n lookup: (_hostname, opts: { all?: boolean | undefined }, callback: (...args: never[]) => void) => {\n const done = callback as unknown as (error: Error | null, result: string | Array<{ address: string; family: number }>, family?: number) => void\n if (opts.all === true) done(null, [{ address: pinned, family }])\n else done(null, pinned, family)\n },\n },\n })\n\n try {\n const response = await undiciFetch(url, {\n method: 'POST',\n headers: deliveryHeaders({\n eventId: input.eventId,\n secret: input.secret,\n body: input.body,\n timestampSeconds: input.nowSeconds,\n attempt: input.attempt,\n }),\n body: input.body,\n // A redirect is a second destination that nothing has checked. Following\n // one would hand an attacker the whole guard back.\n redirect: 'manual',\n signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),\n dispatcher: agent,\n })\n\n if (response.status >= 300 && response.status < 400) {\n return { ok: false, status: response.status, error: 'Redirects are not followed.' }\n }\n\n // Read a bounded amount and never interpret it. A receiver's body is not\n // ours to parse, and an unbounded read is a memory exhaustion the receiver\n // controls.\n await drain(response, RESPONSE_CAP_BYTES)\n\n return response.ok ? { ok: true, status: response.status } : { ok: false, status: response.status, error: `Receiver answered ${String(response.status)}.` }\n } catch (error) {\n // `fetch failed` on its own tells an operator nothing. The cause carries\n // the refused connection, the TLS failure or the timeout.\n const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : ''\n return { ok: false, error: `${error instanceof Error ? error.message : String(error)}${cause}` }\n } finally {\n await agent.close()\n }\n}\n\n/** Consume at most `cap` bytes so the connection can be reused, then stop. */\nasync function drain(response: { body?: unknown }, cap: number): Promise<void> {\n const body = response.body as ReadableStream<Uint8Array> | null | undefined\n const reader = body?.getReader()\n if (reader === undefined) return\n\n let read = 0\n try {\n for (;;) {\n const { done, value } = await reader.read()\n if (done) break\n read += value?.byteLength ?? 0\n if (read >= cap) break\n }\n } finally {\n await reader.cancel().catch(() => undefined)\n }\n}\n","import { drainOutbox } from '@formancy/server-core'\nimport type { Storage } from '@formancy/server-core'\nimport { deliver } from './deliver.js'\nimport { workerLog } from './server-log.js'\nimport type { WorkerLog } from './server-log.js'\n\n/**\n * Runs the outbox on a timer.\n *\n * Everything that decides anything — whether to retry, when, when to give up —\n * is in @formancy/server-core and tested without a network. This is only the\n * clock and the wiring, which is why it is short.\n *\n * NOT a distributed queue. `claimDueDeliveries` does not lock rows, so two\n * replicas would both pick up the same delivery and send it twice. The event\n * id is stable across attempts precisely so a receiver can dedupe that, but a\n * multi-replica deployment should still run exactly one worker until the claim\n * takes a lock. Said plainly here rather than discovered in production.\n */\nexport interface WorkerHandle {\n stop: () => void\n}\n\nexport function startOutboxWorker(\n storage: Storage,\n options: { intervalMs?: number; allowHttp?: boolean; allowPrivateAddresses?: boolean; log?: WorkerLog } = {},\n): WorkerHandle {\n const interval = options.intervalMs ?? 5_000\n const log = workerLog(options.log)\n let stopped = false\n let running = false\n\n const tick = async (): Promise<void> => {\n // One pass at a time. Overlapping passes would let a slow receiver cause\n // the same delivery to be picked up twice by this very process.\n if (running || stopped) return\n running = true\n try {\n await drainOutbox({\n storage,\n now: () => new Date(),\n random: () => Math.random(),\n send: async (delivery, webhook, nowSeconds) =>\n deliver(\n {\n url: webhook.url,\n body: delivery.body,\n secret: webhook.secret,\n eventId: delivery.eventId,\n attempt: delivery.attempt + 1,\n nowSeconds,\n },\n {\n allowHttp: options.allowHttp ?? false,\n allowPrivateAddresses: options.allowPrivateAddresses ?? false,\n },\n ),\n })\n } catch (error) {\n // A worker that throws is a worker that stops. Deliveries retry on their\n // own schedule, so the right response to an unexpected failure is to\n // complain and come back — with what was thrown as its kind and code, never\n // its words, which for a query are its parameters (C3).\n log.error({ event: 'outbox.failed', err: error })\n } finally {\n running = false\n }\n }\n\n const timer = setInterval(() => void tick(), interval)\n // Do not hold the process open: a server whose only remaining work is an\n // empty poll should be able to exit.\n timer.unref?.()\n\n return {\n stop: () => {\n stopped = true\n clearInterval(timer)\n },\n }\n}\n","import type { Storage } from '@formancy/server-core'\nimport { workerLog } from './server-log.js'\nimport type { WorkerLog } from './server-log.js'\n\n/**\n * Forgetting challenges that can no longer be replayed anyway.\n *\n * A solved challenge is recorded so it cannot be spent twice. Once it has\n * expired the record is doing nothing: the stateless expiry check refuses the\n * solution before the spend is ever consulted. Keeping the row past that point\n * only grows a table.\n *\n * Its own timer rather than a job on the file collector, because the two are\n * configured independently: a deployment can have public forms and no uploads,\n * and that deployment still accumulates spent challenges. Hanging this off the\n * collector would make sweeping conditional on a setting that has nothing to\n * do with it.\n *\n * The same shape as the collector and the outbox worker: a timer in the\n * process, one pass at a time, and a thrown pass complains and comes back\n * rather than stopping the timer. Also not a distributed job — running two is\n * harmless, because deleting an expired row twice is deleting it once.\n */\nexport interface SweeperHandle {\n stop: () => void\n}\n\nexport function startChallengeSweeper(storage: Storage, options: { intervalMs?: number; log?: WorkerLog } = {}): SweeperHandle {\n const log = workerLog(options.log)\n // Hourly. The rows expire in ten minutes and are a challenge hash each, so\n // an hour's worth of them is nothing; sweeping more often would be work\n // nobody asked for.\n const interval = options.intervalMs ?? 60 * 60_000\n let stopped = false\n let running = false\n\n const tick = async (): Promise<void> => {\n if (running || stopped) return\n running = true\n try {\n await storage.forgetExpiredChallenges(new Date().toISOString())\n } catch (error) {\n // A sweeper that throws is a sweeper that stops, and a table that grows\n // for a reason nobody is watching. Without what was thrown (C3).\n log.error({ event: 'sweeper.failed', err: error })\n } finally {\n running = false\n }\n }\n\n const timer = setInterval(() => void tick(), interval)\n timer.unref?.()\n\n return {\n stop: () => {\n stopped = true\n clearInterval(timer)\n },\n }\n}\n","import { createHash, createHmac } from 'node:crypto'\n\n/**\n * AWS Signature Version 4, for the four requests an object store needs.\n *\n * Ours rather than an SDK: sixty lines against a frozen specification, with the\n * algorithm's own vocabulary in the names so it can be read beside AWS's description of\n * it. It signs requests for **any** S3-compatible endpoint, so Garage, real S3 and\n * Backblaze take the same code path\n * ([0064](../../../docs/decisions/0064-an-object-store-behind-the-same-interface.md)).\n *\n * **What verifies it is a real S3 implementation in a container**, refusing a wrong\n * signature with a 403. The unit tests pin the mechanical properties; they cannot tell\n * whether the canonical request is *right*, only that it is consistent with itself.\n */\n\n/** The SHA-256 of an empty body, which a GET or DELETE carries. */\nexport const EMPTY_PAYLOAD_SHA256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'\n\nexport interface SignArgs {\n readonly method: 'GET' | 'PUT' | 'DELETE' | 'HEAD'\n readonly url: URL\n /** Headers to sign and send. `host` is required; casing does not matter. */\n readonly headers: Readonly<Record<string, string>>\n /** Hex SHA-256 of the body. S3 sends it as a header as well as signing it. */\n readonly payloadHash: string\n readonly service: string\n readonly region: string\n readonly accessKeyId: string\n readonly secretAccessKey: string\n /** Injected so a test is not a test of the clock. */\n readonly now: Date\n}\n\n/**\n * The headers to send, including `authorization`.\n *\n * Returns headers rather than mutating a request so that the caller's `fetch`\n * options stay the caller's business, and so the signature is inspectable in a\n * test without a server.\n */\nexport function signRequest(args: SignArgs): Record<string, string> {\n const { method, url, payloadHash, service, region, accessKeyId, secretAccessKey, now } = args\n\n // Lowercase every name once, here, so nothing below has to wonder. A header\n // signed under one casing and sent under another is a 403 with no clue in it.\n const headers: Record<string, string> = {}\n for (const [name, value] of Object.entries(args.headers)) {\n headers[name.toLowerCase()] = value\n }\n\n if (headers['host'] === undefined) {\n // AWS requires host in the signature. Failing here, where the message can\n // say so, beats a server rejecting it for a reason it will not explain.\n throw new Error('Refusing to sign a request with no host header.')\n }\n\n const amzDate = basicIso8601(now)\n const dateStamp = amzDate.slice(0, 8)\n\n headers['x-amz-date'] = amzDate\n // S3 requires this header and requires it to equal the hash in the canonical\n // request. Set from the same value rather than computed twice: two routes to\n // one number is how they come to disagree.\n headers['x-amz-content-sha256'] = payloadHash\n\n // Sorted by name, because \"canonical\" means both sides sort identically.\n const names = Object.keys(headers).sort()\n const canonicalHeaders = names\n // Sequential spaces collapse and the value is trimmed, per the spec. A\n // header a proxy reformatted must still hash to the same bytes.\n .map((name) => `${name}:${(headers[name] ?? '').trim().replace(/\\s+/g, ' ')}\\n`)\n .join('')\n const signedHeaders = names.join(';')\n\n const canonicalRequest = [method, canonicalPath(url, service), canonicalQuery(url), canonicalHeaders, signedHeaders, payloadHash].join('\\n')\n\n const scope = `${dateStamp}/${region}/${service}/aws4_request`\n const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\\n')\n\n // The key is derived per date, region and service, so a leaked signing key is\n // useless tomorrow, elsewhere, or for another service. That is the whole\n // reason for the four-step chain rather than signing with the secret.\n const dateKey = hmac(`AWS4${secretAccessKey}`, dateStamp)\n const regionKey = hmac(dateKey, region)\n const serviceKey = hmac(regionKey, service)\n const signingKey = hmac(serviceKey, 'aws4_request')\n const signature = hmac(signingKey, stringToSign).toString('hex')\n\n headers['authorization'] = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, ` + `SignedHeaders=${signedHeaders}, Signature=${signature}`\n\n return headers\n}\n\n/**\n * `20150830T123600Z` — basic ISO 8601, not the extended form.\n *\n * `toISOString()` gives `2015-08-30T12:36:00.000Z`, which is rejected. Stripping\n * the punctuation is the whole conversion, and the credential scope then wants\n * the same instant as `20150830`, which is why this returns the long one and the\n * caller slices it.\n */\nfunction basicIso8601(at: Date): string {\n return `${at.toISOString().replace(/[-:]/g, '').split('.')[0] ?? ''}Z`\n}\n\n/**\n * The path, encoded **once** for s3 and twice for everything else.\n *\n * This is the one place where s3 departs from the general rule, and getting it\n * wrong produces a signature that is valid for every other AWS service and\n * refused by the only one we call -- and refused only for keys containing a\n * character that needs encoding, which is the worst possible way to find out.\n */\nfunction canonicalPath(url: URL, service: string): string {\n // `url.pathname` is already percent-encoded by URL parsing, which is exactly\n // the single encoding s3 wants.\n const once = url.pathname === '' ? '/' : url.pathname\n if (service === 's3') return once\n return once\n .split('/')\n .map((segment) => uriEncode(decodeURIComponent(segment)))\n .join('/')\n}\n\n/** Sorted by encoded name, values encoded, `=` even when empty. */\nfunction canonicalQuery(url: URL): string {\n const pairs: Array<readonly [string, string]> = []\n for (const [name, value] of url.searchParams) pairs.push([uriEncode(name), uriEncode(value)])\n // Sorted after encoding, per the spec: sorting the raw names orders them\n // differently whenever an encoded byte changes the comparison.\n pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n return pairs.map(([name, value]) => `${name}=${value}`).join('&')\n}\n\n/**\n * AWS's `UriEncode`, which is not `encodeURIComponent`.\n *\n * The differences are small and each one is a 403: `!`, `'`, `(`, `)` and `*`\n * are left alone by `encodeURIComponent` and must be encoded, hex digits must be\n * uppercase, and a space is `%20` rather than `+`.\n */\nfunction uriEncode(value: string): string {\n return encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`)\n}\n\nfunction sha256Hex(value: string): string {\n return createHash('sha256').update(value, 'utf8').digest('hex')\n}\n\nfunction hmac(key: string | Buffer, data: string): Buffer {\n return createHmac('sha256', key).update(data, 'utf8').digest()\n}\n","import { createHash } from 'node:crypto'\nimport { Readable } from 'node:stream'\n\nimport type { FileStore } from './file-store.js'\nimport { EMPTY_PAYLOAD_SHA256, signRequest } from './sigv4.js'\n\n/**\n * Uploaded bytes in an S3-compatible object store.\n *\n * The second implementation of `FileStore`. The local one is right for a single\n * container with a volume and is the ceiling on everything above it: two replicas with\n * two volumes each accept uploads the other cannot serve, so any deployment past one\n * server needs this.\n *\n * **Not tied to Amazon.** The design calls for Garage, which is what the\n * integration test runs and what belongs in the compose file; the same code path\n * serves MinIO, Backblaze, Cloudflare R2 and real S3, because the only thing they\n * need in common is SigV4 and path-style addressing.\n *\n * **Bytes still pass through the server**, so the request body cap is the ceiling on a\n * file. A presigned PUT straight from the browser is the roadmap item that removes the\n * server from the data path; this replaces where the bytes land without touching how\n * they arrive.\n *\n * **No multipart.** A single PUT is one request, so the practical ceiling is whatever\n * the store accepts in one -- 5 GB for S3, and far above `FORMANCY_MAX_FILE_BYTES`.\n *\n * **The timeout bounds silence, not duration** -- for a download. It runs while the\n * store is being waited on, for the response headers and then for each chunk of the\n * body, and not while a chunk is in the caller's hands. A store that stops answering\n * is abandoned; a download that is still arriving is not, however long it takes.\n * `send` has the reasoning, and\n * [0155](../../../docs/decisions/0155-the-object-store-is-timed-on-its-silence.md) what\n * it costs and what it was chosen over.\n *\n * **An upload is still bounded as a whole.** The store answers a PUT once it has the\n * entire body, so sending it falls inside the wait for the headers, and a PUT that\n * takes longer than the timeout is abandoned however steadily it is going. arc42\n * section 11 carries it as debt, and a test asserts the gap is still there, so that\n * closing it is deliberate.\n */\nexport interface S3FileStoreConfig {\n /** Base URL of the endpoint, e.g. `http://garage:3900` or an S3 regional URL. */\n readonly endpoint: string\n readonly bucket: string\n /** Garage answers to any region name; real S3 does not. */\n readonly region: string\n readonly accessKeyId: string\n readonly secretAccessKey: string\n /**\n * How long the store may keep a request waiting before it is abandoned, in\n * milliseconds: for the response headers, and then for each chunk of the body.\n * A PUT's own body is sent inside the first of those, so it bounds an upload's\n * whole length.\n *\n * An object store that stops answering must not become a server that stops\n * answering. A bound on silence rather than on the whole request, because how\n * long a download takes is set by whoever is downloading it. Defaults to thirty\n * seconds, which is generous for a store that is working and still bounded for\n * one that is not.\n */\n readonly timeoutMs?: number\n}\n\nexport function createS3FileStore(config: S3FileStoreConfig): FileStore {\n const endpoint = config.endpoint.replace(/\\/+$/, '')\n const timeoutMs = config.timeoutMs ?? 30_000\n\n /**\n * The object's URL, with the key's slashes kept and everything else encoded.\n *\n * A key is a path inside the bucket, so its separators are structural. Every\n * other character has to be encoded or the request line is invalid -- and a\n * space encoded as `+`, which is what form encoding would do, names a\n * different object.\n */\n const urlFor = (key: string): URL => {\n if (key === '' || key.includes('\\\\') || key.split('/').includes('..')) {\n // Keys are minted by the server today. Checked anyway, because the local\n // store checks it and the two must not disagree about what is legal.\n throw new Error(`Refusing a storage key that could address another bucket: ${key}`)\n }\n const path = key.split('/').map(encodeSegment).join('/')\n return new URL(`${endpoint}/${encodeSegment(config.bucket)}/${path}`)\n }\n\n /**\n * One request, and the clock that abandons it.\n *\n * **The clock measures how long the store keeps us waiting.** It starts when the\n * request is sent and stops when the headers arrive -- which, for a PUT, is after\n * the whole body has gone. It starts again at once, because the store now owes the\n * body, stops when a chunk arrives, and starts again only when the caller asks for\n * the next one -- so a chunk the caller is still holding costs the store nothing.\n *\n * **Not `AbortSignal.timeout`, which is what this replaced.** That bounds the whole\n * exchange, body included, so a download still streaming when it ran out was cut\n * off mid-file however steadily it was arriving. And how long a download streams\n * is set by the person downloading it more than by the store: backpressure carries\n * a slow connection all the way back to this socket, so a bound on the whole\n * request was a bound on the downloader's bandwidth.\n *\n * The clock restarts at the headers rather than at the first read so that a body\n * nobody reads -- the error document on a path that throws -- is still abandoned\n * after `timeoutMs`, rather than holding its connection for however long something\n * else takes to close it.\n */\n const send = async (method: 'GET' | 'PUT' | 'DELETE' | 'HEAD', key: string, body?: Buffer): Promise<Answer> => {\n const url = urlFor(key)\n const payloadHash = body === undefined ? EMPTY_PAYLOAD_SHA256 : createHash('sha256').update(body).digest('hex')\n\n // `content-length` is deliberately neither signed nor set. undici refuses a\n // manually supplied one -- `InvalidArgumentError: invalid content-length\n // header` -- and computes its own from the body, so setting it is both\n // impossible and unnecessary. Nothing is lost by not signing it: AWS requires\n // only `host` and the `x-amz-*` headers in the signature, and\n // `x-amz-content-sha256` is a hash of the exact bytes, which constrains the\n // length far more tightly than a signed length would.\n const headers = signRequest({\n method,\n url,\n headers: { host: url.host },\n payloadHash,\n service: 's3',\n region: config.region,\n accessKeyId: config.accessKeyId,\n secretAccessKey: config.secretAccessKey,\n now: new Date(),\n })\n\n const waiting = clock(timeoutMs, `S3 ${method} of ${key}`)\n waiting.start('no answer')\n const response = await fetch(url, {\n method,\n headers,\n ...(body === undefined ? {} : { body: new Uint8Array(body) }),\n signal: waiting.signal,\n }).finally(waiting.stop)\n\n const { ok, status, statusText } = response\n const answer = { ok, status, statusText, headers: response.headers }\n if (response.body === null) return { ...answer, body: null }\n waiting.start('no data')\n return { ...answer, body: timed(response.body, waiting) }\n }\n\n /**\n * A status that means \"there is no such object\", as opposed to one that means\n * the store could not answer.\n *\n * The distinction is the whole reason this is a named function. Reading a\n * could-not-answer as absence makes the collector delete the row for bytes that\n * still exist and makes a download return nothing for a file the person can see\n * listed.\n *\n * **404 only, and 403 deliberately not.** S3 answers 403 for an object a caller may\n * not list, but this caller owns the bucket -- so 403 means the credentials are\n * wrong, and reading that as absence would make a misconfigured deployment look like\n * one where every file has vanished and have the collector delete every row.\n */\n const isAbsent = (response: Answer): boolean => response.status === 404\n\n const failed = (method: string, key: string, response: Answer): Error =>\n new Error(`S3 ${method} of ${key} failed: ${String(response.status)} ${response.statusText}`)\n\n return {\n async put(key, bytes) {\n const response = await send('PUT', key, bytes)\n if (!response.ok) throw failed('PUT', key, response)\n // The body is an error document or nothing. Reading it releases the\n // connection back to the pool, which an undrained response does not.\n await discard(response.body)\n },\n\n async open(key) {\n const response = await send('GET', key)\n if (isAbsent(response)) {\n await discard(response.body)\n return undefined\n }\n if (!response.ok || response.body === null) throw failed('GET', key, response)\n // Streamed rather than buffered: a large attachment should not be held in\n // memory on its way to one downloader. A byte stream, as the local store's\n // is: in object mode it would hand out bare `Uint8Array`s rather than\n // `Buffer`s, and count what it buffers in chunks rather than in bytes.\n return Readable.from(response.body, { objectMode: false })\n },\n\n async remove(key) {\n const response = await send('DELETE', key)\n // Absent is success. A collector that crashed between deleting the bytes\n // and deleting the row has to be safe to run again.\n if (response.ok || isAbsent(response)) {\n await discard(response.body)\n return\n }\n throw failed('DELETE', key, response)\n },\n\n async sizeOf(key) {\n const response = await send('HEAD', key)\n if (isAbsent(response)) return undefined\n if (!response.ok) throw failed('HEAD', key, response)\n const length = response.headers.get('content-length')\n // A store that answered without a length cannot be used to check that the\n // bytes match what was promised, and pretending it said zero would fail\n // that check by declaring the file empty.\n if (length === null) throw new Error(`S3 HEAD of ${key} returned no content-length.`)\n return Number(length)\n },\n }\n}\n\n/**\n * What the store answered.\n *\n * The status and headers of the `Response`, and its body only as `send` hands it\n * out. Read through the `Response` itself, a body is read without the clock\n * stopping between chunks -- which is the whole-request bound again, and the defect\n * `send` exists to avoid -- so the `Response` is not handed out at all.\n */\ntype Answer = Pick<Response, 'ok' | 'status' | 'statusText' | 'headers'> & {\n readonly body: AsyncIterable<Uint8Array> | null\n}\n\n/**\n * A clock for one request: started while the store owes something, stopped while it\n * does not, and aborting the request if it ever runs out. `owed` names what the\n * store failed to send, for the error that says so.\n *\n * Unref'd, as `AbortSignal.timeout` was: a deadline is not work. One is left running\n * under every body nobody reads, and a referenced timer would hold the process it\n * lives in for up to `timeoutMs` after everything else had finished. It still fires\n * when it matters: a request still in flight holds the process up by its connection.\n */\nfunction clock(timeoutMs: number, request: string) {\n const controller = new AbortController()\n let timer: ReturnType<typeof setTimeout> | undefined\n const stop = (): void => clearTimeout(timer)\n const start = (owed: string): void => {\n stop()\n timer = setTimeout(() => {\n controller.abort(new Error(`${request}: ${owed} within ${String(timeoutMs)} ms`))\n }, timeoutMs)\n timer.unref()\n }\n return { signal: controller.signal, start, stop }\n}\n\n/**\n * A body chunk by chunk, with the clock running only while the next chunk is awaited.\n *\n * An abort after the headers errors the body with the clock's reason, so a body the\n * clock ran out on fails rather than ending early and looking complete.\n */\nasync function* timed(stream: ReadableStream<Uint8Array>, waiting: ReturnType<typeof clock>): AsyncGenerator<Uint8Array> {\n try {\n for await (const chunk of stream) {\n waiting.stop()\n yield chunk\n waiting.start('no data')\n }\n } finally {\n waiting.stop()\n }\n}\n\n/** Reads a body to its end and drops it, which hands the connection back to the pool. */\nasync function discard(body: Answer['body']): Promise<void> {\n if (body === null) return\n for await (const _chunk of body) {\n // Nothing to keep: reaching the end is the point.\n }\n}\n\n/**\n * AWS's `UriEncode` for one path segment.\n *\n * `encodeURIComponent` leaves `!'()*` alone and S3 wants them encoded, and its\n * hex digits are already uppercase. Duplicated from the signer rather than\n * shared, because the signer's copy is about hashing and this one is about the\n * request line: if they ever need to differ, they differ, and a shared helper\n * would make that a conversation instead of an edit.\n */\nfunction encodeSegment(value: string): string {\n return encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`)\n}\n","import type { S3FileStoreConfig } from './s3-file-store.js'\n\n/**\n * Which store the environment names for uploaded bytes: none, a directory, or an\n * S3-compatible object store.\n *\n * A function of the environment rather than lines in `main.ts`, because it is a\n * decision with rules and `main.ts` is a composition root that no test imports.\n * The rules are where a deployment goes wrong — two stores named at once, an\n * endpoint without its credentials — and a rule nothing runs is a claim nobody\n * checked. Constructing the store stays in `main.ts`; this only says which.\n */\nexport type FileStoreSettings =\n { readonly kind: 'none' } | { readonly kind: 'local'; readonly directory: string } | { readonly kind: 's3'; readonly config: S3FileStoreConfig }\n\ntype Environment = Readonly<Record<string, string | undefined>>\n\n/**\n * Absent — or empty, see `setting` — means this deployment accepts no files,\n * and that is a supported state rather than a misconfiguration: a form with a\n * file field still renders and still submits, and the field says plainly that\n * there is nowhere to put one. Turning uploads on is naming a directory, which\n * in a container is naming a volume — and a volume is the one thing a\n * self-hoster has to think about, so it is not defaulted here.\n *\n * Throws on a configuration that names two stores, or an object store without\n * everything it needs, so the server refuses to start rather than serving one.\n */\nexport function fileStoreSettings(env: Environment): FileStoreSettings {\n const directory = setting(env, 'FORMANCY_FILES_DIR')\n const endpoint = setting(env, 'FORMANCY_S3_ENDPOINT')\n\n if (directory !== undefined && endpoint !== undefined) {\n // Two stores means half the files are in one and half in the other, and\n // nothing records which -- so a later reader gets \"no such file\" for bytes\n // that exist in the store it did not ask. Refusing at startup is the only\n // point at which this is cheap to fix.\n 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.')\n }\n\n if (endpoint !== undefined) {\n // Every field is required once the endpoint is set, and none is defaulted. A\n // bucket name guessed wrong is a deployment that accepts uploads into\n // nothing; credentials guessed wrong are a deployment where every file reads\n // as missing. Both fail here instead.\n const required = (name: string): string => {\n const value = env[name]\n if (value === undefined || value === '') {\n throw new Error(`${name} is required when FORMANCY_S3_ENDPOINT is set.`)\n }\n return value\n }\n\n return {\n kind: 's3',\n config: {\n endpoint,\n bucket: required('FORMANCY_S3_BUCKET'),\n // Part of the credential scope, so a wrong region is a signature the\n // store computes differently and rejects. Garage answers to any name and\n // real S3 does not, which is why there is no default.\n region: required('FORMANCY_S3_REGION'),\n accessKeyId: required('FORMANCY_S3_ACCESS_KEY_ID'),\n secretAccessKey: required('FORMANCY_S3_SECRET_ACCESS_KEY'),\n },\n }\n }\n\n return directory === undefined ? { kind: 'none' } : { kind: 'local', directory }\n}\n\n/**\n * A setting, with the empty string read as unset.\n *\n * Because compose cannot remove a variable, only blank it. Both compose files\n * point FORMANCY_FILES_DIR at their volume, and `.env` switching to the object\n * store does so by setting it to `\"\"` — which, read as a directory, is two\n * stores and a server that refuses to start. Read as a path it is no better:\n * `createLocalFileStore('')` resolves to the working directory, which switches\n * uploads on in a directory nobody chose — in the published image, `/app`,\n * which its user cannot write.\n */\nfunction setting(env: Environment, name: string): string | undefined {\n const value = env[name]\n return value === '' ? undefined : value\n}\n","import { connect } from 'node:net'\nimport type { Scanner, ScanVerdict } from '@formancy/server-core'\n\n/** How much is sent per INSTREAM chunk. clamd takes chunks of any size up to its stream limit. */\nconst CHUNK = 64 * 1024\n\n/**\n * clamd's own default `StreamMaxLength`, 100 MiB — as the `clamd.conf` ClamAV 1.5.4 ships\n * says, and as measured against it: a 90 MiB stream was answered, a 110 MiB one refused.\n */\nexport const CLAMD_DEFAULT_MAX_BYTES = 100 * 1024 * 1024\n\n/**\n * ClamAV's daemon, asked over its INSTREAM command\n * ([0131](../../../docs/decisions/0131-an-upload-is-scanned-before-it-is-kept.md)).\n *\n * The protocol is clamd's own and small enough to speak directly — the command, the bytes in\n * length-prefixed chunks, a zero-length chunk to end them, and one line back — so this adds\n * no dependency to the server. Its tests run against a stand-in that speaks the protocol as\n * clamd's manual describes it; it was run once against ClamAV 1.5.4 itself, and the\n * documentation says what that showed and what it did not.\n *\n * **Only `stream: OK` is clean.** A finding is refused by its name. A file over clamd's size\n * limit is refused too, because sending it again changes nothing. Anything else — an answer\n * this does not understand, no answer, no daemon — rejects, which the use-case reads as a\n * scanner that cannot be asked, and refuses the file for.\n *\n * **`maxBytes` is clamd's `StreamMaxLength`, and is checked before a byte is sent.** Past\n * it, clamd says so and closes the connection while bytes may still be arriving. Against the\n * stand-in the bytes in flight reset it and the answer was lost to `ECONNRESET`, which reads\n * as a scanner that is down and would tell the person to try again forever; the real clamd\n * measured here answered intact. Checked first, the outcome does not depend on which.\n * Set it to what clamd is configured with.\n *\n * **`timeoutMs` is for silence, not for the whole scan.** It is the socket's idle timeout,\n * restarted by any traffic on the connection, so it abandons a clamd that has stopped\n * answering and not a scan that is still moving. Nothing here bounds how long a scan takes,\n * which is why the lease a request holds a file under can be outlasted (0153).\n */\nexport function createClamdScanner({\n host,\n port,\n maxBytes = CLAMD_DEFAULT_MAX_BYTES,\n timeoutMs = 30_000,\n}: {\n host: string\n port: number\n maxBytes?: number\n timeoutMs?: number\n}): Scanner {\n return {\n scan: (_file, bytes) =>\n new Promise<ScanVerdict>((resolve, reject) => {\n if (bytes.byteLength > maxBytes) {\n resolve({\n clean: false,\n finding: `could not be scanned: larger than the scanner accepts (${String(maxBytes)} bytes)`,\n })\n return\n }\n const socket = connect({ host, port })\n const answer: Buffer[] = []\n let failure: Error | undefined\n\n socket.setTimeout(timeoutMs, () => socket.destroy(new Error(`clamd at ${host}:${String(port)} did not answer within ${String(timeoutMs)} ms`)))\n socket.on('connect', () => {\n socket.write('zINSTREAM\\0')\n for (let at = 0; at < bytes.byteLength; at += CHUNK) {\n const piece = bytes.subarray(at, at + CHUNK)\n const length = Buffer.alloc(4)\n length.writeUInt32BE(piece.byteLength)\n socket.write(length)\n socket.write(piece)\n }\n // The zero-length chunk that ends the stream. Written, not `end()`ed: clamd\n // answers on the same connection and closes it itself.\n socket.write(Buffer.alloc(4))\n })\n socket.on('data', (data) => answer.push(data))\n // Kept rather than rejected on: clamd past its size limit answers and hangs up\n // while bytes are still being written, and the answer is what counts.\n socket.on('error', (error) => {\n failure = error\n })\n socket.on('close', () => {\n const reply = Buffer.concat(answer).toString('utf8').replace(/\\0+$/, '').trim()\n if (reply === '') {\n reject(failure ?? new Error(`clamd at ${host}:${String(port)} closed without answering`))\n return\n }\n const verdict = verdictOf(reply)\n if (verdict === undefined) reject(new Error(`clamd answered something unexpected: ${reply}`))\n else resolve(verdict)\n })\n }),\n }\n}\n\nfunction verdictOf(reply: string): ScanVerdict | undefined {\n if (reply === 'stream: OK') return { clean: true }\n const found = /^stream: (.+) FOUND$/.exec(reply)\n if (found !== null) return { clean: false, finding: found[1]! }\n if (/size limit exceeded/i.test(reply)) {\n return { clean: false, finding: `could not be scanned: ${reply.replace(/\\.?\\s*ERROR$/, '')}` }\n }\n return undefined\n}\n","import { isIP } from 'node:net'\n\nconst WHAT_TO_WRITE =\n 'Write the address of the reverse proxy in front of this server, e.g. 10.0.0.2, or a ' +\n 'comma-separated list of addresses and CIDR ranges, e.g. 10.0.0.2,172.18.0.0/16 — or ' +\n 'leave it unset to trust no proxy.'\n\n/**\n * The proxies whose `X-Forwarded-For` this server believes, read from\n * `FORMANCY_TRUST_PROXY`.\n *\n * Every limit on the public plane counts the client's address. Behind a reverse\n * proxy the socket's address is the proxy's, so trusting nothing gives every\n * respondent one shared budget; trusting the wrong thing lets a client write its\n * own `X-Forwarded-For` and choose a fresh budget per request. So the setting\n * names the proxies, and only addresses and ranges:\n *\n * - **No hop count.** Fastify's option takes a number, and the Fastify this\n * server depends on treats one as \"trust nothing\", because a count cannot\n * tell the proxy from a client that connects directly. Accepting it here\n * would be a setting that reads as configured and does nothing;\n * `trust-proxy.test.ts` checks that Fastify still ignores one.\n * - **No `true`,** which Fastify also takes, and which believes anybody.\n * - **No names,** such as `uniquelocal`, which Fastify's `proxy-addr` expands\n * into ranges nobody reading the setting can see — that one is every private\n * address, a Docker network's gateway with it, and the gateway is where\n * Docker hands published-port connections over from. Refused by the address\n * check below, as a hostname is.\n *\n * Decision 0156 has the reasoning, and what was measured against Docker.\n *\n * Absent or empty returns undefined, which trusts nothing — the behaviour before\n * this setting existed, and the right one with no proxy in front. Anything else\n * that is not an address or a range is refused here, with a sentence saying what\n * to write, rather than left to Fastify's parser, whose TypeError names neither\n * the variable nor the fix.\n */\nexport function trustProxyFrom(value: string | undefined): string[] | undefined {\n if (value === undefined || value.trim() === '') return undefined\n\n const trimmed = value.trim()\n if (/^\\d+$/.test(trimmed)) {\n throw new Error(\n `FORMANCY_TRUST_PROXY is \"${trimmed}\", a hop count, which Fastify ignores: a count cannot tell ` +\n `the proxy from a client that connects directly. ${WHAT_TO_WRITE}`,\n )\n }\n if (trimmed.toLowerCase() === 'true') {\n throw new Error(\n 'FORMANCY_TRUST_PROXY is \"true\", which would believe X-Forwarded-For from anybody and let ' +\n `every client choose the address its rate limits count. ${WHAT_TO_WRITE}`,\n )\n }\n\n const entries = trimmed.split(',').map((entry) => entry.trim())\n for (const entry of entries) {\n if (!isAddressOrRange(entry)) {\n throw new Error(`FORMANCY_TRUST_PROXY has \"${entry}\", which is not an IP address or a CIDR range. ${WHAT_TO_WRITE}`)\n }\n }\n return entries\n}\n\n/**\n * An address, or an address with a prefix length its family allows.\n *\n * A zero prefix is refused, as Fastify's parser refuses it: `/0` is every\n * address, which is `true` spelled differently. A netmask after the slash is\n * refused too, though Fastify reads one — a second spelling of the same thing\n * is a second thing to get wrong, and a prefix length says it.\n */\nfunction isAddressOrRange(entry: string): boolean {\n const slash = entry.indexOf('/')\n if (slash === -1) return isIP(entry) !== 0\n\n const family = isIP(entry.slice(0, slash))\n const prefix = entry.slice(slash + 1)\n if (family === 0 || !/^\\d{1,3}$/.test(prefix)) return false\n const bits = Number(prefix)\n return bits >= 1 && bits <= (family === 4 ? 32 : 128)\n}\n","import { LOG_LEVELS } from './server-log.js'\nimport type { LogLevel } from './server-log.js'\n\n/**\n * How much the server logs, read from `FORMANCY_LOG_LEVEL`\n * ([0168](../../../docs/decisions/0168-the-log-is-built-from-a-list-of-fields.md)).\n *\n * Unset or empty is `info`: a line per request and per error. The log is on unless somebody\n * turns it off, because off is the state this setting replaced, where nothing said why a\n * request failed. `off` keeps no log at all; anything else that is not one of pino's level\n * names, spelled as pino spells them, stops the server at startup — a server that started\n * anyway would log at a level nobody chose, or not at all while its operator believed it\n * did. Its own module because `main.ts` connects to a database when it is imported, so\n * nothing in it can be tested.\n */\nexport function logLevelFrom(env: Readonly<Record<string, string | undefined>>): LogLevel | 'off' {\n const value = env['FORMANCY_LOG_LEVEL']?.trim() ?? ''\n if (value === '') return 'info'\n if (value === 'off') return value\n const level = LOG_LEVELS.find((known) => known === value)\n if (level === undefined) {\n throw new Error(\n `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.`,\n )\n }\n return level\n}\n","import { randomBytes } from 'node:crypto'\nimport postgres from 'postgres'\nimport { createApp } from './app.js'\nimport { bootstrapSchema } from './db.js'\nimport { createPostgresStorage } from './postgres-storage.js'\nimport { createPostgresRateLimitStore } from './postgres-rate-limits.js'\nimport { startOutboxWorker } from './outbox-worker.js'\nimport { startFileCollector } from './file-collector.js'\nimport { startChallengeSweeper } from './challenge-sweeper.js'\nimport { createLocalFileStore } from './file-store.js'\nimport type { FileStore } from './file-store.js'\nimport { createS3FileStore } from './s3-file-store.js'\nimport { fileStoreSettings } from './file-store-settings.js'\nimport { CLAMD_DEFAULT_MAX_BYTES, createClamdScanner } from './clamd-scanner.js'\nimport { maxFileBytesFrom } from './upload-settings.js'\nimport { trustProxyFrom } from './trust-proxy.js'\nimport { modelSettings } from './model-settings.js'\nimport { createCompleter } from './completers.js'\nimport { logLevelFrom } from './log-settings.js'\nimport { createServerLog, databaseNotices } from './server-log.js'\n\n// recheck ships a 23 MB JVM jar and a native binary per platform as OPTIONAL\n// dependencies and falls back to a pure-JavaScript engine without them. For\n// one static analysis at publish time, the jar is a large thing to carry in a\n// self-hosted container and the per-platform binaries are an awkward thing to\n// pin in an SBOM. Choosing the pure engine explicitly also makes the verdict\n// deterministic rather than dependent on what happened to install.\nprocess.env['RECHECK_BACKEND'] ??= 'pure'\n\n/**\n * The runnable server: `node dist/main.mjs` with DATABASE_URL set, or\n * `docker compose up` from the repo root, which supplies both.\n *\n * Bootstraps its own tables on start — a self-hosted skeleton that works five\n * minutes after `git clone` is the adoption funnel; migrations arrive with the\n * lifecycle work.\n */\nconst databaseUrl = process.env['DATABASE_URL']\nif (databaseUrl === undefined || databaseUrl === '') {\n console.error('DATABASE_URL is required, e.g. postgres://formancy:formancy@localhost:5432/formancy')\n process.exit(1)\n}\n\nconst port = Number(process.env['PORT'] ?? 4380)\n\n/**\n * The server's log, on standard output: a JSON line per request and per error, each built\n * from a list of fields, so no body, header, query string, answer or credential has anywhere\n * to go (0168). `info` unless `FORMANCY_LOG_LEVEL` says otherwise; `off` keeps none, and a\n * level the server does not have stops it here. Read first, so the database's notices on the\n * first connection go through it too rather than being printed whole by the driver, and so\n * does the rate limits' counter, which is made before the app whose log it would otherwise use.\n */\nconst logLevel = logLevelFrom(process.env)\nconst log = logLevel === 'off' ? undefined : createServerLog(process.stdout, logLevel)\nconst sql = postgres(databaseUrl, { onnotice: databaseNotices(log) })\n\nlet authSecret = process.env['FORMANCY_AUTH_SECRET']\nif (authSecret === undefined || authSecret === '') {\n // A generated secret keeps `docker compose up` working, at the cost of every\n // session, every draft's key and every token a form was handed out with dying\n // on restart (0062, 0169). Say so loudly rather than failing dev cold.\n authSecret = randomBytes(33).toString('base64url')\n console.warn(\n // \"sessions will not survive a restart\" is the phrase CI's container job looks for:\n // the warning is the contract an operator who never reads the docs still meets.\n '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.',\n )\n}\n\nconst adminEmail = process.env['FORMANCY_ADMIN_EMAIL']\nconst adminPassword = process.env['FORMANCY_ADMIN_PASSWORD']\n\n/** Where uploaded bytes go: the rules are in `fileStoreSettings`, which throws. */\nconst storeSettings = fileStoreSettings(process.env)\nconst fileStore: FileStore | undefined =\n storeSettings.kind === 's3'\n ? createS3FileStore(storeSettings.config)\n : storeSettings.kind === 'local'\n ? createLocalFileStore(storeSettings.directory)\n : undefined\n\n// A whole number of bytes no larger than `files.size` holds, or the server does not start.\nconst maxFileBytes = maxFileBytesFrom(process.env)\n\n/**\n * A ClamAV daemon to ask about every upload before its bytes are kept.\n *\n * Unset is a supported state: no file is scanned, which the documentation says. Set, a\n * file is kept only once clamd has called it clean, and refused when clamd cannot be\n * reached — the point of configuring a scanner is that nothing unscanned gets in\n * ([0131](../../../docs/decisions/0131-an-upload-is-scanned-before-it-is-kept.md)).\n * Not checked at startup: clamd takes minutes to load its signatures, and a server that\n * refused to start until then would turn a slow scanner into an outage of everything.\n */\nconst clamdHost = process.env['FORMANCY_CLAMD_HOST']\nconst clamdPort = Number(process.env['FORMANCY_CLAMD_PORT'] ?? 3310)\nconst clamdMaxBytes = Number(process.env['FORMANCY_CLAMD_MAX_BYTES'] ?? CLAMD_DEFAULT_MAX_BYTES)\nif (!Number.isInteger(clamdPort) || clamdPort <= 0 || clamdPort > 65_535) {\n throw new Error('FORMANCY_CLAMD_PORT must be a port number.')\n}\nif (!Number.isFinite(clamdMaxBytes) || clamdMaxBytes <= 0) {\n throw new Error(\"FORMANCY_CLAMD_MAX_BYTES must be a positive number of bytes: clamd's StreamMaxLength.\")\n}\nconst scanner = clamdHost === undefined || clamdHost === '' ? undefined : createClamdScanner({ host: clamdHost, port: clamdPort, maxBytes: clamdMaxBytes })\n\n/**\n * Turns the proof-of-work challenge on for anonymous submissions.\n *\n * Unset is a supported state rather than a misconfiguration: a deployment\n * whose forms all require a session has no anonymous surface to protect, and\n * the challenge route says so with a 404 rather than failing. A deployment\n * with public forms should set it — the other layers are rate limits, an\n * origin allowlist and a body cap, all of which an attacker with a few\n * addresses walks past.\n *\n * Separate from FORMANCY_AUTH_SECRET so that rotating one does not invalidate\n * the other: rotating this one costs an unsolved puzzle, rotating that one\n * costs everybody their session.\n */\nconst challengeSecret = process.env['FORMANCY_CHALLENGE_SECRET']\nif (challengeSecret !== undefined && challengeSecret.length < 32) {\n throw new Error('FORMANCY_CHALLENGE_SECRET must be at least 32 characters, or unset.')\n}\n\n/**\n * The reverse proxies whose X-Forwarded-For names the client. Unset trusts none,\n * which is right with nothing in front and wrong behind a proxy: every\n * respondent would share its one rate-limit budget. Refused at startup when it\n * is not addresses and ranges, since a server that started anyway would count\n * the wrong client and nothing would say so.\n */\nconst trustProxy = trustProxyFrom(process.env['FORMANCY_TRUST_PROXY'])\n\n/**\n * The model the builders ask through this server, with its key kept here rather than in a\n * browser (0165). Unset is a supported state and the default: no model, no prompt pane in\n * the admin, and no form sent anywhere. Half a configuration — a key with no provider, a\n * provider with no model — is refused here, because a server that started anyway would\n * have no model while the operator believed it had one. The key is never logged.\n */\nconst modelChoice = modelSettings(process.env)\nconst model = modelChoice.kind === 'none' ? undefined : { provider: modelChoice.provider, model: modelChoice.model, completer: createCompleter(modelChoice) }\n\nawait bootstrapSchema(sql)\nconst storage = createPostgresStorage(sql)\n// Every limit counts in the database, so behind any number of replicas it is the limit it says\n// rather than that many times it; on connections of its own, so a counter that cannot answer\n// holds none of the ones storage queries on. No setting turns it off (0170). It says in the log\n// when it stops answering and when it answers again, and nothing when the log is off.\nconst rateLimits = createPostgresRateLimitStore(databaseUrl, log === undefined ? {} : { log })\nconst app = await createApp(storage, {\n authSecret,\n rateLimitStore: rateLimits.store,\n ...(adminEmail !== undefined && adminPassword !== undefined ? { bootstrapAdmin: { email: adminEmail, password: adminPassword } } : {}),\n ...(fileStore === undefined ? {} : { fileStore }),\n ...(challengeSecret === undefined ? {} : { challengeSecret }),\n ...(scanner === undefined ? {} : { scanner }),\n ...(trustProxy === undefined ? {} : { trustProxy }),\n ...(model === undefined ? {} : { model }),\n ...(logLevel === 'off' ? {} : { log: { sink: process.stdout, level: logLevel } }),\n maxFileBytes,\n})\n\n// Queued deliveries are useless until something sends them. Each worker says a failed pass\n// in the app's log, by the same rule, and says nothing when it is off.\nconst outbox = startOutboxWorker(storage, {\n log: app.log,\n ...(process.env['FORMANCY_WEBHOOK_ALLOW_HTTP'] === 'true' ? { allowHttp: true } : {}),\n // Gives up the SSRF guard. For a sidecar receiver on a trusted network, and\n // for nothing else.\n ...(process.env['FORMANCY_WEBHOOK_ALLOW_PRIVATE'] === 'true' ? { allowPrivateAddresses: true } : {}),\n})\n// Somebody who attaches a file and closes the tab leaves bytes behind. A disk\n// that fills up for a reason nobody is watching is the most tedious outage\n// there is.\nconst collector = fileStore === undefined ? undefined : startFileCollector(storage, fileStore, { log: app.log })\n\n// Independent of the collector: a deployment can have public forms and no\n// uploads, and that deployment still accumulates spent challenges.\nconst sweeper = challengeSecret === undefined ? undefined : startChallengeSweeper(storage, { log: app.log })\n\nfor (const signal of ['SIGINT', 'SIGTERM'] as const) {\n process.once(signal, () => {\n outbox.stop()\n collector?.stop()\n sweeper?.stop()\n void app.close().then(() => process.exit(0))\n })\n}\n\nawait app.listen({ port, host: process.env['HOST'] ?? '0.0.0.0' })\nconsole.log(`formancy server listening on :${port}`)\n// Said once, at startup, so an operator reading the log knows where forms are sent.\nconsole.log(model === undefined ? 'No model configured.' : `Builders ask ${model.provider}'s ${model.model}.`)\n\nfor (const signal of ['SIGINT', 'SIGTERM'] as const) {\n process.on(signal, () => {\n void app\n .close()\n .then(() => rateLimits.end())\n .then(() => sql.end())\n .then(() => process.exit(0))\n })\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@formancy/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "The self-hostable formancy backend: Fastify routes and Postgres storage over @formancy/server-core.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"@anthropic-ai/sdk": "^0.132.1",
|
|
20
20
|
"@fastify/rate-limit": "^11.2.0",
|
|
21
|
-
"@formancy/builder-core": "0.
|
|
22
|
-
"@formancy/server-core": "0.
|
|
23
|
-
"@formancy/spec": "0.
|
|
21
|
+
"@formancy/builder-core": "0.13.0",
|
|
22
|
+
"@formancy/server-core": "0.13.0",
|
|
23
|
+
"@formancy/spec": "0.13.0",
|
|
24
24
|
"@node-rs/argon2": "^2.2.1",
|
|
25
25
|
"drizzle-orm": "^0.45.2",
|
|
26
26
|
"fastify": "^5.12.5",
|