@formancy/server-core 0.1.0 → 0.2.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/index.mjs CHANGED
@@ -1,6 +1,666 @@
1
- import { diffSchemas, schemaHash } from "@formancy/spec";
1
+ import { CHALLENGE_TTL_SECONDS, DEFAULT_MAX_NUMBER, decodeSolution, encodeSolution, hashOf, mintChallenge, solveChallenge, verifySolution } from "@formancy/challenge";
2
+ import { hmac } from "@noble/hashes/hmac.js";
3
+ import { sha256 } from "@noble/hashes/sha2.js";
4
+ import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
5
+ import { diffSchemas, modelDataPaths, schemaHash } from "@formancy/spec";
2
6
  import { validateSchema } from "@formancy/spec/validate";
3
- import { createFormEngine } from "@formancy/core";
7
+ import { createFormEngine, expressionProblems } from "@formancy/core";
8
+ import { check } from "recheck";
9
+ //#region src/options-membership.ts
10
+ /**
11
+ * Every sourced answer in a submission, as `path → { source, value }`.
12
+ *
13
+ * Walked from the SCHEMA over the value, never from the value's own shape — the
14
+ * same way `referencedFileIds` does it. A walk driven by what arrived would let a
15
+ * client dodge the check by omitting a property, and hidden branches have already
16
+ * been stripped by the engine before this runs.
17
+ */
18
+ function sourcedAnswers(schema, value) {
19
+ const found = [];
20
+ const walk = (fields, data, prefix) => {
21
+ if (typeof data !== "object" || data === null) return;
22
+ const record = data;
23
+ for (const field of fields) {
24
+ const at = prefix === "" ? field.key : `${prefix}.${field.key}`;
25
+ const held = record[field.key];
26
+ if (field.fields !== void 0) {
27
+ if (Array.isArray(held)) for (const [index, row] of held.entries()) walk(field.fields, row, `${at}[${String(index)}]`);
28
+ else walk(field.fields, held, at);
29
+ continue;
30
+ }
31
+ if (field.optionsSource === void 0) continue;
32
+ if (typeof held !== "string" || held === "") continue;
33
+ found.push({
34
+ path: at,
35
+ source: field.optionsSource,
36
+ value: held
37
+ });
38
+ }
39
+ };
40
+ walk(schema.model.fields, value, "");
41
+ return found;
42
+ }
43
+ /**
44
+ * Ask each source about the values submitted for it.
45
+ *
46
+ * One call per source rather than per answer, so a deployment sees `IN (…)` and not
47
+ * a query per row.
48
+ */
49
+ async function checkMembership(schema, value, sources) {
50
+ const answers = sourcedAnswers(schema, value);
51
+ if (answers.length === 0 || sources === void 0) return { ok: true };
52
+ const bySource = /* @__PURE__ */ new Map();
53
+ for (const answer of answers) {
54
+ const group = bySource.get(answer.source) ?? [];
55
+ group.push({
56
+ path: answer.path,
57
+ value: answer.value
58
+ });
59
+ bySource.set(answer.source, group);
60
+ }
61
+ const errors = {};
62
+ for (const [name, group] of bySource) {
63
+ const source = sources[name];
64
+ if (source === void 0) return {
65
+ ok: false,
66
+ kind: "source_unavailable",
67
+ source: name
68
+ };
69
+ if (source.members === void 0) continue;
70
+ let rejected;
71
+ try {
72
+ rejected = await source.members(group.map((entry) => entry.value));
73
+ } catch {
74
+ return {
75
+ ok: false,
76
+ kind: "source_unavailable",
77
+ source: name
78
+ };
79
+ }
80
+ const refused = new Set(rejected);
81
+ for (const entry of group) {
82
+ if (!refused.has(entry.value)) continue;
83
+ errors[entry.path] = [...errors[entry.path] ?? [], "option"];
84
+ }
85
+ }
86
+ return Object.keys(errors).length === 0 ? { ok: true } : {
87
+ ok: false,
88
+ kind: "invalid",
89
+ errors
90
+ };
91
+ }
92
+ /**
93
+ * The source names a document uses, for the publish-time vocabulary check.
94
+ *
95
+ * Walked over the schema rather than over any data, because publishing has none.
96
+ */
97
+ function sourceNamesIn(schema) {
98
+ const names = /* @__PURE__ */ new Set();
99
+ const walk = (fields) => {
100
+ for (const field of fields) {
101
+ if (field.optionsSource !== void 0) names.add(field.optionsSource);
102
+ if (field.fields !== void 0) walk(field.fields);
103
+ }
104
+ };
105
+ walk(schema.model.fields);
106
+ return [...names];
107
+ }
108
+ //#endregion
109
+ //#region src/audit.ts
110
+ /**
111
+ * Who did what, to which thing, and when.
112
+ *
113
+ * **Reads and exports are recorded as well as mutations** — `submission.read` and
114
+ * `submission.exported` — which is what lets a self-hoster answer who downloaded four
115
+ * thousand people's answers ([0057](../../../docs/decisions/0057-the-audit-log-records-reads.md)).
116
+ *
117
+ * **A row joins the mutation's transaction where there is one.** `insertSubmission` and
118
+ * `publishVersion` both commit their audit row with the change it describes, so a
119
+ * rollback leaves no trace saying it happened. A read has nothing to be atomic with, so
120
+ * its row is appended after the rows are handed over and a failure to write it is logged
121
+ * loudly rather than swallowed.
122
+ *
123
+ * **`detail` carries identifiers and counts, never submission content.** An audit log is
124
+ * read by more people, kept longer and exported more freely than the data it describes.
125
+ */
126
+ /**
127
+ * Every auditable action, as a closed list.
128
+ *
129
+ * Closed so a new route cannot invent its own spelling: an audit log where one event is
130
+ * called two things is one nobody can query.
131
+ */
132
+ const AUDIT_ACTIONS = [
133
+ "auth.login",
134
+ "auth.login.failed",
135
+ "form.published",
136
+ "form.access.changed",
137
+ "submission.created",
138
+ "submission.read",
139
+ "submission.exported",
140
+ "file.downloaded",
141
+ "user.created",
142
+ "apiKey.created",
143
+ "apiKey.revoked",
144
+ "delivery.replayed"
145
+ ];
146
+ /**
147
+ * An entry from an actor, with the fields spread the way the record stores
148
+ * them.
149
+ *
150
+ * A helper rather than a convention, because `actorKind` and `actorId` being
151
+ * set together is exactly the sort of thing eleven call sites get wrong once.
152
+ */
153
+ function auditedBy(actor, draft) {
154
+ return actor === void 0 ? draft : {
155
+ ...draft,
156
+ actorKind: actor.kind,
157
+ actorId: actor.id
158
+ };
159
+ }
160
+ //#endregion
161
+ //#region src/webhook.ts
162
+ /**
163
+ * What a webhook delivery looks like on the wire.
164
+ *
165
+ * The signature scheme is Stripe's, deliberately: receivers already have code
166
+ * for it, and a scheme somebody has to implement from scratch is a scheme most
167
+ * receivers will skip. Adopting a familiar one is the difference between a
168
+ * verified webhook and a verified-in-principle one.
169
+ */
170
+ const EVENT_ID_HEADER = "x-formancy-event-id";
171
+ const SIGNATURE_HEADER = "x-formancy-signature";
172
+ /**
173
+ * The signed payload is `timestamp.body`, not `body`.
174
+ *
175
+ * Without the timestamp inside the signature, a signature captured once stays
176
+ * valid forever and can be replayed. With it, a receiver can reject anything
177
+ * older than its tolerance, and the signature covers the timestamp it is
178
+ * checking — so the timestamp cannot be edited either.
179
+ */
180
+ function signedPayload(timestampSeconds, body) {
181
+ return `${String(timestampSeconds)}.${body}`;
182
+ }
183
+ /** Hex HMAC-SHA256 over the signed payload. */
184
+ function signBody(secret, timestampSeconds, body) {
185
+ return bytesToHex(hmac(sha256, utf8ToBytes(secret), utf8ToBytes(signedPayload(timestampSeconds, body))));
186
+ }
187
+ /**
188
+ * The headers for one delivery attempt.
189
+ *
190
+ * The event id is stable across retries, which is what makes the receiver able
191
+ * to be idempotent. Generating a fresh one per attempt would turn our retry
192
+ * into their duplicate.
193
+ */
194
+ function deliveryHeaders(input) {
195
+ return {
196
+ "content-type": "application/json",
197
+ [EVENT_ID_HEADER]: input.eventId,
198
+ [SIGNATURE_HEADER]: `t=${String(input.timestampSeconds)},v1=${signBody(input.secret, input.timestampSeconds, input.body)}`,
199
+ "x-formancy-attempt": String(input.attempt)
200
+ };
201
+ }
202
+ /**
203
+ * Verify a delivery the way a receiver would.
204
+ *
205
+ * Exported because a scheme nobody can check is a scheme nobody does check —
206
+ * this is the reference implementation, and the test for our own signing.
207
+ */
208
+ function verifySignature(input) {
209
+ const parts = new Map(input.header.split(",").map((part) => {
210
+ const at = part.indexOf("=");
211
+ return [part.slice(0, at).trim(), part.slice(at + 1).trim()];
212
+ }));
213
+ const timestamp = Number(parts.get("t"));
214
+ const provided = parts.get("v1");
215
+ if (!Number.isFinite(timestamp) || provided === void 0) return false;
216
+ const tolerance = input.toleranceSeconds ?? 300;
217
+ if (Math.abs(input.nowSeconds - timestamp) > tolerance) return false;
218
+ return timingSafeEqual(signBody(input.secret, timestamp, input.body), provided);
219
+ }
220
+ /**
221
+ * Compare without leaking where the first difference is.
222
+ *
223
+ * `a === b` on a hex string returns as soon as two characters differ, and the
224
+ * time it takes says how many leading characters were right — enough, over
225
+ * many attempts, to recover a signature a byte at a time.
226
+ */
227
+ function timingSafeEqual(a, b) {
228
+ if (a.length !== b.length) return false;
229
+ let difference = 0;
230
+ for (let index = 0; index < a.length; index += 1) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
231
+ return difference === 0;
232
+ }
233
+ /**
234
+ * How long to wait before attempt n, in milliseconds.
235
+ *
236
+ * Exponential, capped, with jitter. The jitter is the part that matters at
237
+ * scale: without it every delivery queued by the same outage retries at the
238
+ * same instant, and the receiver coming back up is knocked over by the
239
+ * thundering herd of everyone's retries.
240
+ */
241
+ function retryDelayMs(attempt, random) {
242
+ const base = Math.min(3e4 * 2 ** (attempt - 1), 216e5);
243
+ return Math.floor(base * random());
244
+ }
245
+ /** Eight attempts reach roughly 24 hours with this schedule. */
246
+ const MAX_ATTEMPTS = 8;
247
+ //#endregion
248
+ //#region src/breaker.ts
249
+ /**
250
+ * Stopping, when a destination has plainly stopped listening.
251
+ *
252
+ * Failure is counted per WEBHOOK as well as per delivery. Enough consecutive failures
253
+ * and the breaker opens: nothing is attempted for that webhook until a cool-down passes,
254
+ * then exactly one delivery goes through as a probe. It succeeds and the breaker closes;
255
+ * it fails and the cool-down starts again.
256
+ *
257
+ * `consecutiveFailures` and `openedAt` live on the RECORD rather than in the worker's
258
+ * memory, so the state survives a restart and can be shown on a screen — a self-hoster
259
+ * has no operations team watching a dashboard
260
+ * ([0058](../../../docs/decisions/0058-a-breaker-per-destination.md)).
261
+ *
262
+ * Everything here is a pure function of a record and a clock: decisions tangled into
263
+ * network code can only be tested by making requests.
264
+ */
265
+ /**
266
+ * How many consecutive failures open the breaker.
267
+ *
268
+ * Three, not one. A single failure is a bad minute — a deploy, a timeout, a
269
+ * receiver restarting — and opening on it would make every routine blip a
270
+ * visible outage. Three in a row is a destination that is not coming back on
271
+ * its own.
272
+ */
273
+ const BREAKER_THRESHOLD = 3;
274
+ /**
275
+ * How long the breaker stays shut before one probe is allowed through.
276
+ *
277
+ * Five minutes: long enough that a restarting receiver is finished, short
278
+ * enough that nobody notices the delay once it recovers. The retry schedule
279
+ * handles patience within a delivery; this handles patience about a
280
+ * destination.
281
+ */
282
+ const BREAKER_COOLDOWN_MS = 3e5;
283
+ function breakerState(webhook, now) {
284
+ if (webhook.consecutiveFailures < 3) return "closed";
285
+ if (webhook.openedAt === null) return "closed";
286
+ return now.getTime() - new Date(webhook.openedAt).getTime() >= 3e5 ? "half-open" : "open";
287
+ }
288
+ /** Whether a delivery for this webhook may be attempted now. */
289
+ function mayAttempt(webhook, now) {
290
+ return breakerState(webhook, now) !== "open";
291
+ }
292
+ /**
293
+ * The webhook's health after one attempt against it.
294
+ *
295
+ * A success always closes the breaker completely. Decaying the count instead
296
+ * would leave a destination that fails twice for every success permanently
297
+ * one blip from opening, and that destination is working.
298
+ */
299
+ function afterWebhookAttempt(webhook, ok, now) {
300
+ if (ok) return {
301
+ ...webhook,
302
+ consecutiveFailures: 0,
303
+ openedAt: null
304
+ };
305
+ const consecutiveFailures = webhook.consecutiveFailures + 1;
306
+ const opening = consecutiveFailures >= 3;
307
+ return {
308
+ ...webhook,
309
+ consecutiveFailures,
310
+ openedAt: opening ? now.toISOString() : webhook.openedAt
311
+ };
312
+ }
313
+ function healthOf(webhook, now) {
314
+ return {
315
+ id: webhook.id,
316
+ url: webhook.url,
317
+ state: breakerState(webhook, now),
318
+ consecutiveFailures: webhook.consecutiveFailures,
319
+ failingSince: webhook.openedAt
320
+ };
321
+ }
322
+ //#endregion
323
+ //#region src/outbox.ts
324
+ function afterAttempt(delivery, outcome, now, random) {
325
+ const attempt = delivery.attempt + 1;
326
+ if (outcome.ok) return {
327
+ ...delivery,
328
+ attempt,
329
+ state: "delivered",
330
+ lastError: null
331
+ };
332
+ const reason = outcome.error ?? `Receiver answered ${String(outcome.status ?? 0)}.`;
333
+ if (attempt >= 8) return {
334
+ ...delivery,
335
+ attempt,
336
+ state: "dead",
337
+ lastError: reason
338
+ };
339
+ return {
340
+ ...delivery,
341
+ attempt,
342
+ state: "pending",
343
+ nextAttemptAt: new Date(now.getTime() + retryDelayMs(attempt, random)).toISOString(),
344
+ lastError: reason
345
+ };
346
+ }
347
+ /**
348
+ * Drain one batch of due deliveries.
349
+ *
350
+ * Returns how many were attempted, so a caller can decide whether to come back
351
+ * immediately or wait. Deliberately one batch rather than a loop that runs
352
+ * forever: the thing that decides *when* to run belongs to the host, and a
353
+ * function that never returns cannot be tested.
354
+ */
355
+ /**
356
+ * Returns how many were ATTEMPTED — deliveries skipped because their
357
+ * destination's breaker is open do not count, so a caller polling until the
358
+ * queue is quiet does not spin on a dead endpoint.
359
+ */
360
+ async function drainOutbox(deps, limit = 20) {
361
+ const now = deps.now();
362
+ const due = await deps.storage.claimDueDeliveries(now.toISOString(), limit);
363
+ let skipped = 0;
364
+ for (const delivery of due) {
365
+ const webhook = await findWebhook(deps.storage, delivery.webhookId);
366
+ if (webhook === void 0) {
367
+ await deps.storage.updateDelivery({
368
+ ...delivery,
369
+ state: "dead",
370
+ lastError: "The webhook it was queued for no longer exists."
371
+ });
372
+ continue;
373
+ }
374
+ if (!mayAttempt(webhook, now)) {
375
+ skipped += 1;
376
+ await deps.storage.updateDelivery({
377
+ ...delivery,
378
+ nextAttemptAt: new Date(now.getTime() + BREAKER_COOLDOWN_MS).toISOString()
379
+ });
380
+ continue;
381
+ }
382
+ let outcome;
383
+ try {
384
+ outcome = await deps.send(delivery, webhook, Math.floor(now.getTime() / 1e3));
385
+ } catch (error) {
386
+ outcome = {
387
+ ok: false,
388
+ error: error instanceof Error ? error.message : String(error)
389
+ };
390
+ }
391
+ await deps.storage.updateDelivery(afterAttempt(delivery, outcome, now, deps.random));
392
+ await deps.storage.updateWebhook(afterWebhookAttempt(webhook, outcome.ok, now));
393
+ }
394
+ return due.length - skipped;
395
+ }
396
+ async function findWebhook(storage, id) {
397
+ const forms = await storage.listForms();
398
+ for (const form of forms) {
399
+ const found = (await storage.webhooksForForm(form.id)).find((hook) => hook.id === id);
400
+ if (found !== void 0) return found;
401
+ }
402
+ }
403
+ async function replayDelivery(deps, id) {
404
+ const delivery = await deps.storage.getDelivery(id);
405
+ if (delivery === void 0) return {
406
+ ok: false,
407
+ kind: "unknown"
408
+ };
409
+ if (delivery.state !== "dead") return {
410
+ ok: false,
411
+ kind: "not-dead",
412
+ state: delivery.state
413
+ };
414
+ const revived = {
415
+ ...delivery,
416
+ attempt: 0,
417
+ state: "pending",
418
+ nextAttemptAt: deps.now().toISOString()
419
+ };
420
+ await deps.storage.updateDelivery(revived);
421
+ return {
422
+ ok: true,
423
+ delivery: revived
424
+ };
425
+ }
426
+ //#endregion
427
+ //#region src/access.ts
428
+ /**
429
+ * Whether this caller may submit this form at all, before any of the work.
430
+ *
431
+ * Every branch that is not an explicit permission returns false. An allowlist
432
+ * that exists but does not name the origin refuses; an origin header that is
433
+ * absent refuses, because absent is not the same as allowed; an empty
434
+ * allowlist refuses everything, because a list of no origins is a list.
435
+ */
436
+ function maySubmit(form, actor, origin) {
437
+ if (actor === "authenticated") return true;
438
+ if (form.accessSubmit !== "public") return false;
439
+ const allowed = form.allowedOrigins;
440
+ if (allowed === null) return true;
441
+ if (origin === void 0) return false;
442
+ return allowed.includes(origin);
443
+ }
444
+ //#endregion
445
+ //#region src/uploads.ts
446
+ /**
447
+ * Agree where a file will go, and refuse it before a byte is sent.
448
+ *
449
+ * Checking here rather than after the upload is the difference between
450
+ * refusing a 2 GB file and receiving one first. The browser's own `accept` and
451
+ * size limits are a convenience for the person filling the form in; these are
452
+ * the rule, because somebody posting to the endpoint directly has neither.
453
+ */
454
+ async function offerUpload(deps, input) {
455
+ if (!maySubmit(input.form, input.actor, input.origin)) return {
456
+ ok: false,
457
+ reason: "not_allowed"
458
+ };
459
+ const def = fileFieldAt(input.schema, input.field);
460
+ if (def === void 0) return {
461
+ ok: false,
462
+ reason: "unknown_field"
463
+ };
464
+ if (def.type !== "file") return {
465
+ ok: false,
466
+ reason: "not_a_file_field"
467
+ };
468
+ if (def.maxFileSize !== void 0 && input.size > def.maxFileSize) return {
469
+ ok: false,
470
+ reason: "too_large"
471
+ };
472
+ if (!accepted(def.accept, input.name, input.contentType)) return {
473
+ ok: false,
474
+ reason: "not_accepted"
475
+ };
476
+ const id = deps.newId();
477
+ const file = {
478
+ id,
479
+ formId: input.form.id,
480
+ name: input.name,
481
+ size: input.size,
482
+ contentType: input.contentType,
483
+ storageKey: `${input.form.id}/${id}`,
484
+ state: "offered",
485
+ createdAt: deps.now().toISOString(),
486
+ submissionId: null
487
+ };
488
+ await deps.storage.insertFile(file);
489
+ return {
490
+ ok: true,
491
+ file
492
+ };
493
+ }
494
+ /**
495
+ * Which files a submission may claim, checked but not yet claimed.
496
+ *
497
+ * The claiming itself is `insertSubmission`'s job, in the same call that
498
+ * stores the submission, so one COMMIT decides both — the same reasoning
499
+ * that put the webhook queue there. A submission exists if and only if the
500
+ * files it names belong to it. Claiming separately gives the two failures
501
+ * nobody can debug: a rolled-back submission leaving files claimed against
502
+ * nothing, or an accepted submission referencing bytes the collector is about
503
+ * to delete.
504
+ *
505
+ * Refuses rather than skips. A submission naming a file that does not exist,
506
+ * belongs to another form, or is already claimed is not a submission to fix up
507
+ * quietly: the first is a bug or somebody guessing ids; the second is how an
508
+ * id from a form you may submit to would let you attach a file from one you
509
+ * may not; the third would make deleting a submission either leak bytes or
510
+ * break a different one.
511
+ */
512
+ async function filesToClaim(storage, input) {
513
+ const ids = referencedFileIds(input.schema, input.data);
514
+ if (ids.length === 0) return {
515
+ ok: true,
516
+ ids: []
517
+ };
518
+ const unknown = [];
519
+ const claimable = [];
520
+ for (const id of ids) {
521
+ const file = await storage.getFile(id);
522
+ if (file === void 0 || file.formId !== input.formId || file.state !== "stored") {
523
+ unknown.push(id);
524
+ continue;
525
+ }
526
+ claimable.push(file);
527
+ }
528
+ if (unknown.length > 0) return {
529
+ ok: false,
530
+ unknown
531
+ };
532
+ return {
533
+ ok: true,
534
+ ids: claimable.map((file) => file.id)
535
+ };
536
+ }
537
+ /**
538
+ * Files nobody claimed, old enough that nobody is going to.
539
+ *
540
+ * Returns the rows rather than deleting anything: the host removes the bytes
541
+ * and then the rows, in that order, because a row without bytes is a broken
542
+ * reference and bytes without a row are invisible rubbish. Of the two, the
543
+ * second is the one you never find out about.
544
+ */
545
+ async function collectAbandonedFiles(storage, now, afterHours) {
546
+ const before = (/* @__PURE__ */ new Date(now.getTime() - afterHours * 36e5)).toISOString();
547
+ return storage.abandonedFiles(before);
548
+ }
549
+ /** The field at a data path, with `[]` treated as "inside this repeater". */
550
+ function fileFieldAt(schema, path) {
551
+ const segments = path.replace(/\[\]/g, "").split(".").filter((segment) => segment !== "");
552
+ let fields = flattenPages(schema.model.fields);
553
+ let found;
554
+ for (const segment of segments) {
555
+ found = fields.find((field) => field.key === segment);
556
+ if (found === void 0) return void 0;
557
+ fields = flattenPages(found.fields ?? []);
558
+ }
559
+ return found;
560
+ }
561
+ /** A page contributes no segment to a data path. */
562
+ function flattenPages(fields) {
563
+ const out = [];
564
+ for (const field of fields) if (field.type === "page") out.push(...flattenPages(field.fields ?? []));
565
+ else out.push(field);
566
+ return out;
567
+ }
568
+ /** The HTML `accept` grammar, as the engine reads it. */
569
+ function accepted(accept, name, contentType) {
570
+ if (accept === void 0 || accept.length === 0) return true;
571
+ const lowerName = name.toLowerCase();
572
+ const lowerType = contentType.toLowerCase();
573
+ return accept.some((raw) => {
574
+ const rule = raw.trim().toLowerCase();
575
+ if (rule === "") return false;
576
+ if (rule.startsWith(".")) return lowerName.endsWith(rule);
577
+ if (rule.endsWith("/*")) return lowerType.startsWith(rule.slice(0, -1));
578
+ return lowerType === rule;
579
+ });
580
+ }
581
+ /**
582
+ * Every file id a submission references.
583
+ *
584
+ * Driven by the SCHEMA, not by the shape of the values. Recognising an
585
+ * attachment by "an object with an id" would let a client omit a property and
586
+ * have its reference quietly not claimed — the submission would be accepted
587
+ * carrying a dangling file id, and the collector would delete the bytes a day
588
+ * later. Reading only the paths the model says are file fields cannot be
589
+ * fooled that way, and a text answer that happens to look like a file is not
590
+ * one.
591
+ *
592
+ * A repeater contributes rows the schema cannot know about, so the walk
593
+ * expands over whatever rows the data actually has.
594
+ */
595
+ function referencedFileIds(schema, data) {
596
+ const ids = [];
597
+ const walk = (fields, value) => {
598
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return;
599
+ const record = value;
600
+ for (const field of flattenPages(fields)) {
601
+ const held = record[field.key];
602
+ if (held === void 0) continue;
603
+ if (field.type === "file") {
604
+ if (!Array.isArray(held)) continue;
605
+ for (const entry of held) {
606
+ const id = entry?.["id"];
607
+ if (typeof id === "string") ids.push(id);
608
+ }
609
+ continue;
610
+ }
611
+ if (field.type === "group") {
612
+ walk(field.fields ?? [], held);
613
+ continue;
614
+ }
615
+ if (field.type === "repeater" && Array.isArray(held)) for (const row of held) walk(field.fields ?? [], row);
616
+ }
617
+ };
618
+ walk(schema.model.fields, data);
619
+ return ids;
620
+ }
621
+ /** Re-exported so a host can see which paths a schema's file fields live at. */
622
+ function fileFieldPaths(schema) {
623
+ return modelDataPaths(schema.model).filter((path) => fileFieldAt(schema, path)?.type === "file");
624
+ }
625
+ //#endregion
626
+ //#region src/redos.ts
627
+ /** Every `pattern` in the model, with the data path of the field it sits on. */
628
+ function patternsIn(fields, prefix) {
629
+ const found = [];
630
+ for (const field of fields) {
631
+ const path = field.type === "page" ? prefix : field.type === "repeater" ? `${prefix}${field.key}[].` : field.type === "group" ? `${prefix}${field.key}.` : `${prefix}${field.key}`;
632
+ if (typeof field.pattern === "string" && field.pattern !== "") found.push({
633
+ path,
634
+ pattern: field.pattern
635
+ });
636
+ if (field.fields !== void 0) found.push(...patternsIn(field.fields, path));
637
+ }
638
+ return found;
639
+ }
640
+ /**
641
+ * The patterns in this schema that an attacker could exploit, or an empty array
642
+ * when there are none.
643
+ *
644
+ * A pattern `recheck` cannot decide about is ACCEPTED. Refusing on `unknown`
645
+ * would make publishing depend on an analysis timeout, which is a worse failure
646
+ * than the one being prevented — an author whose legitimate form stops
647
+ * publishing intermittently has no way to act on that.
648
+ */
649
+ async function unsafePatterns(schema) {
650
+ const unsafe = [];
651
+ for (const { path, pattern } of patternsIn(schema.model.fields, "")) {
652
+ const diagnostics = await check(`^(?:${pattern})$`, "");
653
+ if (diagnostics.status !== "vulnerable") continue;
654
+ const complexity = diagnostics.complexity;
655
+ unsafe.push({
656
+ path,
657
+ pattern,
658
+ complexity: complexity === void 0 ? "unknown" : complexity.type === "polynomial" ? `polynomial degree ${String(complexity.degree)}` : complexity.type
659
+ });
660
+ }
661
+ return unsafe;
662
+ }
663
+ //#endregion
4
664
  //#region src/use-cases.ts
5
665
  /**
6
666
  * Publish a schema as a form's next version.
@@ -31,6 +691,26 @@ async function publishForm(deps, input) {
31
691
  message: error instanceof Error ? error.message : String(error)
32
692
  };
33
693
  }
694
+ const problems = expressionProblems(schema);
695
+ if (problems.length > 0) return {
696
+ ok: false,
697
+ kind: "invalid_logic",
698
+ message: problems.map((problem) => problem.message).join("\n")
699
+ };
700
+ const unsafe = await unsafePatterns(schema);
701
+ if (unsafe.length > 0) return {
702
+ ok: false,
703
+ kind: "unsafe_pattern",
704
+ patterns: unsafe
705
+ };
706
+ if (deps.optionsSources !== void 0) {
707
+ const unknown = sourceNamesIn(schema).filter((name) => deps.optionsSources?.[name] === void 0);
708
+ if (unknown.length > 0) return {
709
+ ok: false,
710
+ kind: "unknown_options_source",
711
+ sources: unknown
712
+ };
713
+ }
34
714
  const hash = schemaHash(schema);
35
715
  const existing = await deps.storage.getFormByPath(input.path);
36
716
  if (existing !== void 0) {
@@ -44,14 +724,21 @@ async function publishForm(deps, input) {
44
724
  };
45
725
  const version = await deps.storage.latestVersionNumber(existing.id) + 1;
46
726
  const versionId = deps.newId();
47
- await deps.storage.insertVersion({
48
- id: versionId,
49
- formId: existing.id,
50
- version,
51
- schema,
52
- schemaHash: hash
727
+ await deps.storage.publishVersion({
728
+ version: {
729
+ id: versionId,
730
+ formId: existing.id,
731
+ version,
732
+ schema,
733
+ schemaHash: hash
734
+ },
735
+ audit: publishAudit(deps, input, {
736
+ path: input.path,
737
+ version,
738
+ hash,
739
+ sources: sourceNamesIn(schema)
740
+ })
53
741
  });
54
- await deps.storage.setCurrentVersion(existing.id, versionId);
55
742
  return {
56
743
  ok: true,
57
744
  formId: existing.id,
@@ -62,19 +749,28 @@ async function publishForm(deps, input) {
62
749
  }
63
750
  const formId = deps.newId();
64
751
  const versionId = deps.newId();
65
- await deps.storage.createForm({
66
- id: formId,
67
- path: input.path,
68
- currentVersionId: null
69
- });
70
- await deps.storage.insertVersion({
71
- id: versionId,
72
- formId,
73
- version: 1,
74
- schema,
75
- schemaHash: hash
752
+ await deps.storage.publishVersion({
753
+ form: {
754
+ id: formId,
755
+ path: input.path,
756
+ currentVersionId: null,
757
+ accessSubmit: "authenticated",
758
+ allowedOrigins: null
759
+ },
760
+ version: {
761
+ id: versionId,
762
+ formId,
763
+ version: 1,
764
+ schema,
765
+ schemaHash: hash
766
+ },
767
+ audit: publishAudit(deps, input, {
768
+ path: input.path,
769
+ version: 1,
770
+ hash,
771
+ sources: sourceNamesIn(schema)
772
+ })
76
773
  });
77
- await deps.storage.setCurrentVersion(formId, versionId);
78
774
  return {
79
775
  ok: true,
80
776
  formId,
@@ -83,6 +779,31 @@ async function publishForm(deps, input) {
83
779
  schemaHash: hash
84
780
  };
85
781
  }
782
+ /**
783
+ * The audit row for a publish, built here so it can travel INSIDE the
784
+ * transaction rather than being appended after it.
785
+ *
786
+ * Before this it was written by the route once all three storage calls had
787
+ * returned, which meant a publish could half-apply and still be recorded as
788
+ * having happened. Now it rolls back with everything else.
789
+ */
790
+ function publishAudit(deps, input, about) {
791
+ return {
792
+ id: deps.newId(),
793
+ at: deps.nowIso(),
794
+ action: "form.published",
795
+ subject: about.path,
796
+ ...input.actor === void 0 ? {} : {
797
+ actorKind: input.actor.kind,
798
+ actorId: input.actor.id
799
+ },
800
+ detail: {
801
+ version: about.version,
802
+ schemaHash: about.hash,
803
+ ...about.sources.length === 0 ? {} : { optionsSources: [...about.sources].sort().join(", ") }
804
+ }
805
+ };
806
+ }
86
807
  async function resolveForm(deps, path) {
87
808
  const form = await deps.storage.getFormByPath(path);
88
809
  if (form === void 0 || form.currentVersionId === null) return void 0;
@@ -105,7 +826,32 @@ async function resolveForm(deps, path) {
105
826
  * are stripped, so a client cannot smuggle data by lying about what was shown.
106
827
  * Client validation is UX; this is truth.
107
828
  */
829
+ /**
830
+ * Change who may submit a form, and from where.
831
+ *
832
+ * Separate from publishing on purpose: access is a property of the deployment,
833
+ * and changing it must not mint a new immutable form version or invalidate the
834
+ * schema hash every client is holding.
835
+ */
836
+ async function setFormAccess(deps, input) {
837
+ const form = await deps.storage.getFormByPath(input.path);
838
+ if (form === void 0) return { ok: false };
839
+ await deps.storage.updateFormAccess(form.id, {
840
+ accessSubmit: input.submit,
841
+ allowedOrigins: input.allowedOrigins ?? null
842
+ });
843
+ return { ok: true };
844
+ }
108
845
  async function createSubmission(deps, input) {
846
+ const form = await deps.storage.getFormByPath(input.path);
847
+ if (form === void 0) return {
848
+ ok: false,
849
+ kind: "unknown_form"
850
+ };
851
+ if (!maySubmit(form, input.actor ?? "anonymous", input.origin)) return {
852
+ ok: false,
853
+ kind: "forbidden"
854
+ };
109
855
  const current = await resolveForm(deps, input.path);
110
856
  if (current === void 0) return {
111
857
  ok: false,
@@ -127,13 +873,61 @@ async function createSubmission(deps, input) {
127
873
  kind: "invalid",
128
874
  errors: outcome.errors
129
875
  };
876
+ const membership = await checkMembership(current.schema, engine.value(), deps.optionsSources);
877
+ if (!membership.ok) return membership.kind === "invalid" ? {
878
+ ok: false,
879
+ kind: "invalid",
880
+ errors: membership.errors
881
+ } : {
882
+ ok: false,
883
+ kind: "source_unavailable",
884
+ source: membership.source
885
+ };
130
886
  const id = deps.newId();
887
+ const hooks = await deps.storage.webhooksForForm(current.formId);
888
+ const body = JSON.stringify({
889
+ id,
890
+ form: input.path,
891
+ data: engine.value()
892
+ });
893
+ const queued = hooks.map((hook) => ({
894
+ id: deps.newId(),
895
+ webhookId: hook.id,
896
+ submissionId: id,
897
+ eventId: deps.newId(),
898
+ body,
899
+ attempt: 0,
900
+ nextAttemptAt: deps.nowIso(),
901
+ state: "pending",
902
+ lastError: null
903
+ }));
904
+ const claimable = await filesToClaim(deps.storage, {
905
+ schema: current.schema,
906
+ formId: current.formId,
907
+ data: engine.value()
908
+ });
909
+ if (!claimable.ok) return {
910
+ ok: false,
911
+ kind: "invalid",
912
+ errors: { _files: [`unknown_file:${claimable.unknown.join(",")}`] }
913
+ };
914
+ const at = deps.nowIso();
131
915
  await deps.storage.insertSubmission({
132
916
  id,
133
917
  formId: current.formId,
134
918
  formVersionId: current.versionId,
135
919
  data: engine.value(),
136
- submittedAt: deps.nowIso()
920
+ submittedAt: at
921
+ }, queued, claimable.ids, {
922
+ id: deps.newId(),
923
+ at,
924
+ action: "submission.created",
925
+ subject: input.path,
926
+ detail: {
927
+ submissionId: id,
928
+ version: current.version,
929
+ authenticated: input.actor === "authenticated"
930
+ }
137
931
  });
138
932
  return {
139
933
  ok: true,
@@ -223,9 +1017,54 @@ function csvCell(value) {
223
1017
  }
224
1018
  /** Save (or re-save) a partial form, bound to the CURRENT version. Undefined
225
1019
  * for an unknown form. */
1020
+ /**
1021
+ * The token that proves the bearer started this draft.
1022
+ *
1023
+ * HMAC over the form and the draft id, the same stateless shape the
1024
+ * proof-of-work challenge uses
1025
+ * ([0059](../../../docs/decisions/0059-proof-of-work-not-a-captcha.md)): no
1026
+ * second table, and no lookup before the check. Bound to the FORM as well as the
1027
+ * id so a token cannot be carried to a draft of another form that happens to
1028
+ * share an id.
1029
+ */
1030
+ function draftToken(secret, formId, draftId) {
1031
+ return bytesToHex(hmac(sha256, utf8ToBytes(secret), utf8ToBytes(`${formId}:${draftId}`)));
1032
+ }
1033
+ /**
1034
+ * Compared in constant time.
1035
+ *
1036
+ * A byte-at-a-time comparison that returns early leaks how much of a guess was
1037
+ * right, and a token is guessed one byte at a time by exactly that signal.
1038
+ */
1039
+ function sameToken(a, b) {
1040
+ if (a.length !== b.length) return false;
1041
+ let differences = 0;
1042
+ for (let at = 0; at < a.length; at += 1) differences |= a.charCodeAt(at) ^ b.charCodeAt(at);
1043
+ return differences === 0;
1044
+ }
1045
+ /**
1046
+ * Start a draft: the server picks the id, and signs it.
1047
+ *
1048
+ * The id is the server's because an id a caller supplies is an id a caller can
1049
+ * enumerate — and enumerating them was enough to read other people's
1050
+ * part-filled answers.
1051
+ */
1052
+ async function startDraft(deps, input) {
1053
+ const current = await resolveForm(deps, input.path);
1054
+ if (current === void 0) return void 0;
1055
+ const draftId = deps.newId();
1056
+ return {
1057
+ draftId,
1058
+ token: draftToken(deps.draftSecret, current.formId, draftId)
1059
+ };
1060
+ }
226
1061
  async function saveDraft(deps, input) {
227
1062
  const current = await resolveForm(deps, input.path);
228
1063
  if (current === void 0) return void 0;
1064
+ if (!sameToken(input.token, draftToken(deps.draftSecret, current.formId, input.draftId))) return {
1065
+ version: current.version,
1066
+ saved: false
1067
+ };
229
1068
  await deps.storage.upsertDraft({
230
1069
  id: input.draftId,
231
1070
  formId: current.formId,
@@ -233,7 +1072,10 @@ async function saveDraft(deps, input) {
233
1072
  data: input.data,
234
1073
  updatedAt: deps.nowIso()
235
1074
  });
236
- return { version: current.version };
1075
+ return {
1076
+ version: current.version,
1077
+ saved: true
1078
+ };
237
1079
  }
238
1080
  /**
239
1081
  * Resume a draft — and migrate it LAZILY if the form was republished since,
@@ -250,6 +1092,7 @@ async function saveDraft(deps, input) {
250
1092
  async function resumeDraft(deps, input) {
251
1093
  const current = await resolveForm(deps, input.path);
252
1094
  if (current === void 0) return void 0;
1095
+ if (!sameToken(input.token, draftToken(deps.draftSecret, current.formId, input.draftId))) return;
253
1096
  const draft = await deps.storage.getDraft(current.formId, input.draftId);
254
1097
  if (draft === void 0) return void 0;
255
1098
  if (draft.formVersionId === current.versionId) return {
@@ -389,14 +1232,26 @@ async function listVersions(deps, path) {
389
1232
  */
390
1233
  function createMemoryStorage() {
391
1234
  const forms = /* @__PURE__ */ new Map();
1235
+ const webhooks = /* @__PURE__ */ new Map();
1236
+ const deliveries = /* @__PURE__ */ new Map();
392
1237
  const versions = /* @__PURE__ */ new Map();
393
1238
  const submissions = [];
1239
+ const audits = [];
1240
+ const spentChallenges = /* @__PURE__ */ new Map();
394
1241
  const drafts = /* @__PURE__ */ new Map();
395
1242
  const users = /* @__PURE__ */ new Map();
396
1243
  const apiKeys = /* @__PURE__ */ new Map();
1244
+ const files = /* @__PURE__ */ new Map();
397
1245
  return {
398
1246
  getFormByPath: async (path) => [...forms.values()].find((form) => form.path === path),
399
1247
  listForms: async () => [...forms.values()].map((form) => ({ ...form })),
1248
+ updateFormAccess: async (formId, access) => {
1249
+ const form = forms.get(formId);
1250
+ if (form !== void 0) forms.set(formId, {
1251
+ ...form,
1252
+ ...access
1253
+ });
1254
+ },
400
1255
  createForm: async (record) => {
401
1256
  forms.set(record.id, { ...record });
402
1257
  },
@@ -408,12 +1263,78 @@ function createMemoryStorage() {
408
1263
  insertVersion: async (record) => {
409
1264
  versions.set(record.id, { ...record });
410
1265
  },
1266
+ publishVersion: async ({ form, version, audit }) => {
1267
+ if (form !== void 0) forms.set(form.id, { ...form });
1268
+ const target = forms.get(version.formId);
1269
+ if (target === void 0) throw new Error(`No form "${version.formId}"`);
1270
+ versions.set(version.id, { ...version });
1271
+ target.currentVersionId = version.id;
1272
+ if (audit !== void 0) audits.push({ ...audit });
1273
+ },
411
1274
  getVersionById: async (id) => versions.get(id),
412
1275
  findVersionByHash: async (formId, hash) => [...versions.values()].find((version) => version.formId === formId && version.schemaHash === hash),
413
1276
  latestVersionNumber: async (formId) => Math.max(0, ...[...versions.values()].filter((version) => version.formId === formId).map((version) => version.version)),
414
- insertSubmission: async (record) => {
1277
+ webhooksForForm: async (formId) => [...webhooks.values()].filter((hook) => hook.formId === formId).map((hook) => ({ ...hook })),
1278
+ insertWebhook: async (record) => {
1279
+ webhooks.set(record.id, { ...record });
1280
+ },
1281
+ updateWebhook: async (record) => {
1282
+ webhooks.set(record.id, { ...record });
1283
+ },
1284
+ listWebhooks: async () => [...webhooks.values()].map((w) => ({ ...w })),
1285
+ deadDeliveries: async (limit) => [...deliveries.values()].filter((delivery) => delivery.state === "dead").slice(0, limit).map((delivery) => ({ ...delivery })),
1286
+ getDelivery: async (id) => {
1287
+ const found = deliveries.get(id);
1288
+ return found === void 0 ? void 0 : { ...found };
1289
+ },
1290
+ claimDueDeliveries: async (nowIso, limit) => [...deliveries.values()].filter((entry) => entry.state === "pending" && entry.nextAttemptAt <= nowIso).sort((a, b) => a.nextAttemptAt.localeCompare(b.nextAttemptAt)).slice(0, limit).map((entry) => ({ ...entry })),
1291
+ updateDelivery: async (record) => {
1292
+ deliveries.set(record.id, { ...record });
1293
+ },
1294
+ insertFile: async (record) => {
1295
+ files.set(record.id, { ...record });
1296
+ },
1297
+ getFile: async (id) => {
1298
+ const found = files.get(id);
1299
+ return found === void 0 ? void 0 : { ...found };
1300
+ },
1301
+ updateFile: async (record) => {
1302
+ files.set(record.id, { ...record });
1303
+ },
1304
+ abandonedFiles: async (beforeIso) => [...files.values()].filter((file) => file.state !== "claimed" && file.createdAt < beforeIso).map((file) => ({ ...file })),
1305
+ deleteFiles: async (ids) => {
1306
+ for (const id of ids) files.delete(id);
1307
+ },
1308
+ insertSubmission: async (record, queued, claimFileIds, audit) => {
415
1309
  submissions.push({ ...record });
1310
+ for (const delivery of queued ?? []) deliveries.set(delivery.id, { ...delivery });
1311
+ for (const id of claimFileIds ?? []) {
1312
+ const file = files.get(id);
1313
+ if (file !== void 0) files.set(id, {
1314
+ ...file,
1315
+ state: "claimed",
1316
+ submissionId: record.id
1317
+ });
1318
+ }
1319
+ if (audit !== void 0) audits.push({ ...audit });
1320
+ },
1321
+ spendChallenge: async (challenge, expiresAtIso) => {
1322
+ if (spentChallenges.has(challenge)) return false;
1323
+ spentChallenges.set(challenge, expiresAtIso);
1324
+ return true;
1325
+ },
1326
+ forgetExpiredChallenges: async (beforeIso) => {
1327
+ let dropped = 0;
1328
+ for (const [challenge, expires] of [...spentChallenges]) if (expires < beforeIso) {
1329
+ spentChallenges.delete(challenge);
1330
+ dropped += 1;
1331
+ }
1332
+ return dropped;
1333
+ },
1334
+ recordAudit: async (entry) => {
1335
+ audits.push({ ...entry });
416
1336
  },
1337
+ listAudit: async (limit) => [...audits].reverse().slice(0, limit),
417
1338
  listSubmissions: async () => [...submissions],
418
1339
  listSubmissionsByForm: async (formId) => submissions.filter((record) => record.formId === formId).reverse(),
419
1340
  listVersionsByForm: async (formId) => [...versions.values()].filter((version) => version.formId === formId).sort((a, b) => b.version - a.version),
@@ -541,6 +1462,87 @@ function can(actor, action) {
541
1462
  return PERMISSIONS[actor.role].has(action);
542
1463
  }
543
1464
  //#endregion
544
- export { authenticateApiKey, authenticateLocal, can, createApiKey, createLocalUser, createMemoryStorage, createSubmission, exportCsv, listForms, listSubmissions, listVersions, publishForm, resolveForm, resumeDraft, saveDraft };
1465
+ //#region src/address.ts
1466
+ /**
1467
+ * Deciding whether an address is somewhere this server may fetch from.
1468
+ *
1469
+ * A self-hosted formancy sits inside somebody's private network and webhook
1470
+ * URLs are written by whoever can edit a form, which makes the server a
1471
+ * confused deputy: it can reach the database, the metadata service and every
1472
+ * internal admin panel, and it will fetch whatever it is told to.
1473
+ *
1474
+ * This half is pure — given an address, is it private — so it lives here and is
1475
+ * tested exhaustively. The rest lives in @formancy/server, because it cannot be
1476
+ * pure: parsing a URL needs `URL`, and resolving a hostname then connecting to
1477
+ * the address that was checked, rather than re-resolving, needs Node's DNS and
1478
+ * a custom agent. This package has no @types/node by design, and hand-rolling
1479
+ * a URL parser for a security check is how such a check gets quietly wrong.
1480
+ * All three parts are necessary. Validating a URL and then handing it to fetch is defeated by DNS
1481
+ * rebinding — the name resolves to a public address for the check and a private
1482
+ * one for the request — and that is the difference between a mitigation and
1483
+ * theatre.
1484
+ */
1485
+ /** Everything before the first dot, as four octets, or undefined. */
1486
+ function ipv4Octets(address) {
1487
+ const parts = address.split(".");
1488
+ if (parts.length !== 4) return void 0;
1489
+ const octets = parts.map((part) => {
1490
+ if (!/^\d{1,3}$/.test(part)) return NaN;
1491
+ return Number(part);
1492
+ });
1493
+ if (octets.some((octet) => Number.isNaN(octet) || octet > 255)) return void 0;
1494
+ return octets;
1495
+ }
1496
+ /**
1497
+ * Whether this address is one the server must not be pointed at.
1498
+ *
1499
+ * Unparseable input counts as private. An address this cannot classify is one
1500
+ * it cannot vouch for, and the safe direction is refusing to fetch.
1501
+ */
1502
+ function isPrivateAddress(address) {
1503
+ const trimmed = address.trim().toLowerCase();
1504
+ if (trimmed === "") return true;
1505
+ const bare = trimmed.replace(/^\[|\]$/g, "").split("%")[0] ?? "";
1506
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(bare);
1507
+ if (mapped?.[1] !== void 0) return isPrivateAddress(mapped[1]);
1508
+ const octets = ipv4Octets(bare);
1509
+ if (octets !== void 0) {
1510
+ const [a, b] = octets;
1511
+ if (a === 0) return true;
1512
+ if (a === 10) return true;
1513
+ if (a === 127) return true;
1514
+ if (a === 169 && b === 254) return true;
1515
+ if (a === 172 && b >= 16 && b <= 31) return true;
1516
+ if (a === 192 && b === 168) return true;
1517
+ if (a === 100 && b >= 64 && b <= 127) return true;
1518
+ if (a === 192 && b === 0) return true;
1519
+ if (a === 198 && (b === 18 || b === 19)) return true;
1520
+ if (a >= 224) return true;
1521
+ return false;
1522
+ }
1523
+ if (bare.includes(":")) {
1524
+ if (bare === "::" || bare === "::1") return true;
1525
+ if (/^f[cd]/.test(bare)) return true;
1526
+ if (/^fe[89ab]/.test(bare)) return true;
1527
+ if (/^ff/.test(bare)) return true;
1528
+ return false;
1529
+ }
1530
+ return true;
1531
+ }
1532
+ /**
1533
+ * Whether this is a literal IP address rather than a hostname.
1534
+ *
1535
+ * The two need different treatment and conflating them breaks the check in
1536
+ * both directions: `isPrivateAddress` fails closed on anything it cannot
1537
+ * parse, which is correct for an address that came back from DNS and wrong for
1538
+ * `example.ch`, where it would refuse every hostname in the world.
1539
+ */
1540
+ function isIpLiteral(host) {
1541
+ const bare = host.trim().replace(/^\[|\]$/g, "");
1542
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(bare)) return true;
1543
+ return bare.includes(":");
1544
+ }
1545
+ //#endregion
1546
+ export { AUDIT_ACTIONS, BREAKER_COOLDOWN_MS, BREAKER_THRESHOLD, CHALLENGE_TTL_SECONDS, DEFAULT_MAX_NUMBER, EVENT_ID_HEADER, MAX_ATTEMPTS, SIGNATURE_HEADER, afterAttempt, afterWebhookAttempt, auditedBy, authenticateApiKey, authenticateLocal, breakerState, can, checkMembership, collectAbandonedFiles, createApiKey, createLocalUser, createMemoryStorage, createSubmission, decodeSolution, deliveryHeaders, drainOutbox, encodeSolution, exportCsv, fileFieldPaths, filesToClaim, hashOf, healthOf, isIpLiteral, isPrivateAddress, listForms, listSubmissions, listVersions, mayAttempt, mintChallenge, offerUpload, publishForm, replayDelivery, resolveForm, resumeDraft, retryDelayMs, saveDraft, setFormAccess, signBody, signedPayload, solveChallenge, sourceNamesIn, sourcedAnswers, startDraft, unsafePatterns, verifySignature, verifySolution };
545
1547
 
546
1548
  //# sourceMappingURL=index.mjs.map