@formancy/server-core 0.1.0 → 0.3.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,88 +1,632 @@
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";
6
+ import { createFormEngine, expressionProblems, unknownReferences } from "@formancy/core";
7
+ import { check } from "recheck";
2
8
  import { validateSchema } from "@formancy/spec/validate";
3
- import { createFormEngine } from "@formancy/core";
4
- //#region src/use-cases.ts
9
+ //#region src/options-membership.ts
5
10
  /**
6
- * Publish a schema as a form's next version.
11
+ * Every sourced answer in a submission, as `path → { source, value }`.
7
12
  *
8
- * This is the save gate: structural validation, then the ENGINE's own compile
9
- * — type-checked expressions, kind policies and the cycle graph — so a form
10
- * that can loop or read a ghost field is refused here and never persisted.
11
- * Republishing the identical document is idempotent, because "deploy again"
12
- * must never manufacture a version.
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.
13
17
  */
14
- async function publishForm(deps, input) {
15
- const validated = validateSchema(input.schema);
16
- if (!validated.valid) return {
17
- ok: false,
18
- kind: "invalid_schema",
19
- errors: validated.errors
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
+ const answers = Array.isArray(held) ? held : [held];
33
+ for (const answer of answers) {
34
+ if (typeof answer !== "string" || answer === "") continue;
35
+ found.push({
36
+ path: at,
37
+ source: field.optionsSource,
38
+ value: answer
39
+ });
40
+ }
41
+ }
20
42
  };
21
- const schema = validated.schema;
22
- try {
23
- createFormEngine({
24
- schema,
25
- capabilities: deps.capabilities
43
+ walk(schema.model.fields, value, "");
44
+ return found;
45
+ }
46
+ /**
47
+ * Ask each source about the values submitted for it.
48
+ *
49
+ * One call per source rather than per answer, so a deployment sees `IN (…)` and not
50
+ * a query per row.
51
+ */
52
+ async function checkMembership(schema, value, sources) {
53
+ const answers = sourcedAnswers(schema, value);
54
+ if (answers.length === 0 || sources === void 0) return { ok: true };
55
+ const bySource = /* @__PURE__ */ new Map();
56
+ for (const answer of answers) {
57
+ const group = bySource.get(answer.source) ?? [];
58
+ group.push({
59
+ path: answer.path,
60
+ value: answer.value
26
61
  });
27
- } catch (error) {
28
- return {
62
+ bySource.set(answer.source, group);
63
+ }
64
+ const errors = {};
65
+ for (const [name, group] of bySource) {
66
+ const source = sources[name];
67
+ if (source === void 0) return {
29
68
  ok: false,
30
- kind: "invalid_logic",
31
- message: error instanceof Error ? error.message : String(error)
69
+ kind: "source_unavailable",
70
+ source: name
32
71
  };
72
+ if (source.members === void 0) continue;
73
+ let rejected;
74
+ try {
75
+ rejected = await source.members(group.map((entry) => entry.value));
76
+ } catch {
77
+ return {
78
+ ok: false,
79
+ kind: "source_unavailable",
80
+ source: name
81
+ };
82
+ }
83
+ const refused = new Set(rejected);
84
+ for (const entry of group) {
85
+ if (!refused.has(entry.value)) continue;
86
+ errors[entry.path] = [...errors[entry.path] ?? [], "option"];
87
+ }
33
88
  }
34
- const hash = schemaHash(schema);
35
- const existing = await deps.storage.getFormByPath(input.path);
36
- if (existing !== void 0) {
37
- const current = existing.currentVersionId === null ? void 0 : await deps.storage.getVersionById(existing.currentVersionId);
38
- if (current !== void 0 && current.schemaHash === hash) return {
39
- ok: true,
40
- formId: existing.id,
41
- versionId: current.id,
42
- version: current.version,
43
- schemaHash: hash
44
- };
45
- const version = await deps.storage.latestVersionNumber(existing.id) + 1;
46
- const versionId = deps.newId();
47
- await deps.storage.insertVersion({
48
- id: versionId,
49
- formId: existing.id,
50
- version,
51
- schema,
52
- schemaHash: hash
53
- });
54
- await deps.storage.setCurrentVersion(existing.id, versionId);
55
- return {
56
- ok: true,
57
- formId: existing.id,
58
- versionId,
59
- version,
60
- schemaHash: hash
61
- };
89
+ return Object.keys(errors).length === 0 ? { ok: true } : {
90
+ ok: false,
91
+ kind: "invalid",
92
+ errors
93
+ };
94
+ }
95
+ /**
96
+ * The source names a document uses, for the publish-time vocabulary check.
97
+ *
98
+ * Walked over the schema rather than over any data, because publishing has none.
99
+ */
100
+ function sourceNamesIn(schema) {
101
+ const names = /* @__PURE__ */ new Set();
102
+ const walk = (fields) => {
103
+ for (const field of fields) {
104
+ if (field.optionsSource !== void 0) names.add(field.optionsSource);
105
+ if (field.fields !== void 0) walk(field.fields);
106
+ }
107
+ };
108
+ walk(schema.model.fields);
109
+ return [...names];
110
+ }
111
+ //#endregion
112
+ //#region src/audit.ts
113
+ /**
114
+ * Who did what, to which thing, and when.
115
+ *
116
+ * **Reads and exports are recorded as well as mutations** — `submission.read` and
117
+ * `submission.exported` — which is what lets a self-hoster answer who downloaded four
118
+ * thousand people's answers ([0057](../../../docs/decisions/0057-the-audit-log-records-reads.md)).
119
+ *
120
+ * **A row joins the mutation's transaction where there is one.** `insertSubmission` and
121
+ * `publishVersion` both commit their audit row with the change it describes, so a
122
+ * rollback leaves no trace saying it happened. A read has nothing to be atomic with, so
123
+ * its row is appended after the rows are handed over and a failure to write it is logged
124
+ * loudly rather than swallowed.
125
+ *
126
+ * **`detail` carries identifiers and counts, never submission content.** An audit log is
127
+ * read by more people, kept longer and exported more freely than the data it describes.
128
+ */
129
+ /**
130
+ * Every auditable action, as a closed list.
131
+ *
132
+ * Closed so a new route cannot invent its own spelling: an audit log where one event is
133
+ * called two things is one nobody can query.
134
+ */
135
+ const AUDIT_ACTIONS = [
136
+ "auth.login",
137
+ "auth.login.failed",
138
+ "form.published",
139
+ "form.access.changed",
140
+ "submission.created",
141
+ "submission.read",
142
+ "submission.exported",
143
+ "file.downloaded",
144
+ "user.created",
145
+ "apiKey.created",
146
+ "apiKey.revoked",
147
+ "delivery.replayed"
148
+ ];
149
+ /**
150
+ * An entry from an actor, with the fields spread the way the record stores
151
+ * them.
152
+ *
153
+ * A helper rather than a convention, because `actorKind` and `actorId` being
154
+ * set together is exactly the sort of thing eleven call sites get wrong once.
155
+ */
156
+ function auditedBy(actor, draft) {
157
+ return actor === void 0 ? draft : {
158
+ ...draft,
159
+ actorKind: actor.kind,
160
+ actorId: actor.id
161
+ };
162
+ }
163
+ //#endregion
164
+ //#region src/webhook.ts
165
+ /**
166
+ * What a webhook delivery looks like on the wire.
167
+ *
168
+ * The signature scheme is Stripe's, deliberately: receivers already have code
169
+ * for it, and a scheme somebody has to implement from scratch is a scheme most
170
+ * receivers will skip. Adopting a familiar one is the difference between a
171
+ * verified webhook and a verified-in-principle one.
172
+ */
173
+ const EVENT_ID_HEADER = "x-formancy-event-id";
174
+ const SIGNATURE_HEADER = "x-formancy-signature";
175
+ /**
176
+ * The signed payload is `timestamp.body`, not `body`.
177
+ *
178
+ * Without the timestamp inside the signature, a signature captured once stays
179
+ * valid forever and can be replayed. With it, a receiver can reject anything
180
+ * older than its tolerance, and the signature covers the timestamp it is
181
+ * checking — so the timestamp cannot be edited either.
182
+ */
183
+ function signedPayload(timestampSeconds, body) {
184
+ return `${String(timestampSeconds)}.${body}`;
185
+ }
186
+ /** Hex HMAC-SHA256 over the signed payload. */
187
+ function signBody(secret, timestampSeconds, body) {
188
+ return bytesToHex(hmac(sha256, utf8ToBytes(secret), utf8ToBytes(signedPayload(timestampSeconds, body))));
189
+ }
190
+ /**
191
+ * The headers for one delivery attempt.
192
+ *
193
+ * The event id is stable across retries, which is what makes the receiver able
194
+ * to be idempotent. Generating a fresh one per attempt would turn our retry
195
+ * into their duplicate.
196
+ */
197
+ function deliveryHeaders(input) {
198
+ return {
199
+ "content-type": "application/json",
200
+ [EVENT_ID_HEADER]: input.eventId,
201
+ [SIGNATURE_HEADER]: `t=${String(input.timestampSeconds)},v1=${signBody(input.secret, input.timestampSeconds, input.body)}`,
202
+ "x-formancy-attempt": String(input.attempt)
203
+ };
204
+ }
205
+ /**
206
+ * Verify a delivery the way a receiver would.
207
+ *
208
+ * Exported because a scheme nobody can check is a scheme nobody does check —
209
+ * this is the reference implementation, and the test for our own signing.
210
+ */
211
+ function verifySignature(input) {
212
+ const parts = new Map(input.header.split(",").map((part) => {
213
+ const at = part.indexOf("=");
214
+ return [part.slice(0, at).trim(), part.slice(at + 1).trim()];
215
+ }));
216
+ const timestamp = Number(parts.get("t"));
217
+ const provided = parts.get("v1");
218
+ if (!Number.isFinite(timestamp) || provided === void 0) return false;
219
+ const tolerance = input.toleranceSeconds ?? 300;
220
+ if (Math.abs(input.nowSeconds - timestamp) > tolerance) return false;
221
+ return timingSafeEqual(signBody(input.secret, timestamp, input.body), provided);
222
+ }
223
+ /**
224
+ * Compare without leaking where the first difference is.
225
+ *
226
+ * `a === b` on a hex string returns as soon as two characters differ, and the
227
+ * time it takes says how many leading characters were right — enough, over
228
+ * many attempts, to recover a signature a byte at a time.
229
+ */
230
+ function timingSafeEqual(a, b) {
231
+ if (a.length !== b.length) return false;
232
+ let difference = 0;
233
+ for (let index = 0; index < a.length; index += 1) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
234
+ return difference === 0;
235
+ }
236
+ /**
237
+ * How long to wait before attempt n, in milliseconds.
238
+ *
239
+ * Exponential, capped, with jitter. The jitter is the part that matters at
240
+ * scale: without it every delivery queued by the same outage retries at the
241
+ * same instant, and the receiver coming back up is knocked over by the
242
+ * thundering herd of everyone's retries.
243
+ */
244
+ function retryDelayMs(attempt, random) {
245
+ const base = Math.min(3e4 * 2 ** (attempt - 1), 216e5);
246
+ return Math.floor(base * random());
247
+ }
248
+ /** Eight attempts reach roughly 24 hours with this schedule. */
249
+ const MAX_ATTEMPTS = 8;
250
+ //#endregion
251
+ //#region src/breaker.ts
252
+ /**
253
+ * Stopping, when a destination has plainly stopped listening.
254
+ *
255
+ * Failure is counted per WEBHOOK as well as per delivery. Enough consecutive failures
256
+ * and the breaker opens: nothing is attempted for that webhook until a cool-down passes,
257
+ * then exactly one delivery goes through as a probe. It succeeds and the breaker closes;
258
+ * it fails and the cool-down starts again.
259
+ *
260
+ * `consecutiveFailures` and `openedAt` live on the RECORD rather than in the worker's
261
+ * memory, so the state survives a restart and can be shown on a screen — a self-hoster
262
+ * has no operations team watching a dashboard
263
+ * ([0058](../../../docs/decisions/0058-a-breaker-per-destination.md)).
264
+ *
265
+ * Everything here is a pure function of a record and a clock: decisions tangled into
266
+ * network code can only be tested by making requests.
267
+ */
268
+ /**
269
+ * How many consecutive failures open the breaker.
270
+ *
271
+ * Three, not one. A single failure is a bad minute — a deploy, a timeout, a
272
+ * receiver restarting — and opening on it would make every routine blip a
273
+ * visible outage. Three in a row is a destination that is not coming back on
274
+ * its own.
275
+ */
276
+ const BREAKER_THRESHOLD = 3;
277
+ /**
278
+ * How long the breaker stays shut before one probe is allowed through.
279
+ *
280
+ * Five minutes: long enough that a restarting receiver is finished, short
281
+ * enough that nobody notices the delay once it recovers. The retry schedule
282
+ * handles patience within a delivery; this handles patience about a
283
+ * destination.
284
+ */
285
+ const BREAKER_COOLDOWN_MS = 3e5;
286
+ function breakerState(webhook, now) {
287
+ if (webhook.consecutiveFailures < 3) return "closed";
288
+ if (webhook.openedAt === null) return "closed";
289
+ return now.getTime() - new Date(webhook.openedAt).getTime() >= 3e5 ? "half-open" : "open";
290
+ }
291
+ /** Whether a delivery for this webhook may be attempted now. */
292
+ function mayAttempt(webhook, now) {
293
+ return breakerState(webhook, now) !== "open";
294
+ }
295
+ /**
296
+ * The webhook's health after one attempt against it.
297
+ *
298
+ * A success always closes the breaker completely. Decaying the count instead
299
+ * would leave a destination that fails twice for every success permanently
300
+ * one blip from opening, and that destination is working.
301
+ */
302
+ function afterWebhookAttempt(webhook, ok, now) {
303
+ if (ok) return {
304
+ ...webhook,
305
+ consecutiveFailures: 0,
306
+ openedAt: null
307
+ };
308
+ const consecutiveFailures = webhook.consecutiveFailures + 1;
309
+ const opening = consecutiveFailures >= 3;
310
+ return {
311
+ ...webhook,
312
+ consecutiveFailures,
313
+ openedAt: opening ? now.toISOString() : webhook.openedAt
314
+ };
315
+ }
316
+ function healthOf(webhook, now) {
317
+ return {
318
+ id: webhook.id,
319
+ url: webhook.url,
320
+ state: breakerState(webhook, now),
321
+ consecutiveFailures: webhook.consecutiveFailures,
322
+ failingSince: webhook.openedAt
323
+ };
324
+ }
325
+ //#endregion
326
+ //#region src/outbox.ts
327
+ function afterAttempt(delivery, outcome, now, random) {
328
+ const attempt = delivery.attempt + 1;
329
+ if (outcome.ok) return {
330
+ ...delivery,
331
+ attempt,
332
+ state: "delivered",
333
+ lastError: null
334
+ };
335
+ const reason = outcome.error ?? `Receiver answered ${String(outcome.status ?? 0)}.`;
336
+ if (attempt >= 8) return {
337
+ ...delivery,
338
+ attempt,
339
+ state: "dead",
340
+ lastError: reason
341
+ };
342
+ return {
343
+ ...delivery,
344
+ attempt,
345
+ state: "pending",
346
+ nextAttemptAt: new Date(now.getTime() + retryDelayMs(attempt, random)).toISOString(),
347
+ lastError: reason
348
+ };
349
+ }
350
+ /**
351
+ * Drain one batch of due deliveries.
352
+ *
353
+ * Returns how many were attempted, so a caller can decide whether to come back
354
+ * immediately or wait. Deliberately one batch rather than a loop that runs
355
+ * forever: the thing that decides *when* to run belongs to the host, and a
356
+ * function that never returns cannot be tested.
357
+ */
358
+ /**
359
+ * Returns how many were ATTEMPTED — deliveries skipped because their
360
+ * destination's breaker is open do not count, so a caller polling until the
361
+ * queue is quiet does not spin on a dead endpoint.
362
+ */
363
+ async function drainOutbox(deps, limit = 20) {
364
+ const now = deps.now();
365
+ const due = await deps.storage.claimDueDeliveries(now.toISOString(), limit);
366
+ let skipped = 0;
367
+ for (const delivery of due) {
368
+ const webhook = await findWebhook(deps.storage, delivery.webhookId);
369
+ if (webhook === void 0) {
370
+ await deps.storage.updateDelivery({
371
+ ...delivery,
372
+ state: "dead",
373
+ lastError: "The webhook it was queued for no longer exists."
374
+ });
375
+ continue;
376
+ }
377
+ if (!mayAttempt(webhook, now)) {
378
+ skipped += 1;
379
+ await deps.storage.updateDelivery({
380
+ ...delivery,
381
+ nextAttemptAt: new Date(now.getTime() + BREAKER_COOLDOWN_MS).toISOString()
382
+ });
383
+ continue;
384
+ }
385
+ let outcome;
386
+ try {
387
+ outcome = await deps.send(delivery, webhook, Math.floor(now.getTime() / 1e3));
388
+ } catch (error) {
389
+ outcome = {
390
+ ok: false,
391
+ error: error instanceof Error ? error.message : String(error)
392
+ };
393
+ }
394
+ await deps.storage.updateDelivery(afterAttempt(delivery, outcome, now, deps.random));
395
+ await deps.storage.updateWebhook(afterWebhookAttempt(webhook, outcome.ok, now));
62
396
  }
63
- const formId = deps.newId();
64
- 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
76
- });
77
- await deps.storage.setCurrentVersion(formId, versionId);
397
+ return due.length - skipped;
398
+ }
399
+ async function findWebhook(storage, id) {
400
+ const forms = await storage.listForms();
401
+ for (const form of forms) {
402
+ const found = (await storage.webhooksForForm(form.id)).find((hook) => hook.id === id);
403
+ if (found !== void 0) return found;
404
+ }
405
+ }
406
+ async function replayDelivery(deps, id) {
407
+ const delivery = await deps.storage.getDelivery(id);
408
+ if (delivery === void 0) return {
409
+ ok: false,
410
+ kind: "unknown"
411
+ };
412
+ if (delivery.state !== "dead") return {
413
+ ok: false,
414
+ kind: "not-dead",
415
+ state: delivery.state
416
+ };
417
+ const revived = {
418
+ ...delivery,
419
+ attempt: 0,
420
+ state: "pending",
421
+ nextAttemptAt: deps.now().toISOString()
422
+ };
423
+ await deps.storage.updateDelivery(revived);
78
424
  return {
79
425
  ok: true,
80
- formId,
81
- versionId,
82
- version: 1,
83
- schemaHash: hash
426
+ delivery: revived
427
+ };
428
+ }
429
+ //#endregion
430
+ //#region src/access.ts
431
+ /**
432
+ * Whether this caller may submit this form at all, before any of the work.
433
+ *
434
+ * Every branch that is not an explicit permission returns false. An allowlist
435
+ * that exists but does not name the origin refuses; an origin header that is
436
+ * absent refuses, because absent is not the same as allowed; an empty
437
+ * allowlist refuses everything, because a list of no origins is a list.
438
+ */
439
+ function maySubmit(form, actor, origin) {
440
+ if (actor === "authenticated") return true;
441
+ if (form.accessSubmit !== "public") return false;
442
+ const allowed = form.allowedOrigins;
443
+ if (allowed === null) return true;
444
+ if (origin === void 0) return false;
445
+ return allowed.includes(origin);
446
+ }
447
+ //#endregion
448
+ //#region src/uploads.ts
449
+ /**
450
+ * Agree where a file will go, and refuse it before a byte is sent.
451
+ *
452
+ * Checking here rather than after the upload is the difference between
453
+ * refusing a 2 GB file and receiving one first. The browser's own `accept` and
454
+ * size limits are a convenience for the person filling the form in; these are
455
+ * the rule, because somebody posting to the endpoint directly has neither.
456
+ */
457
+ async function offerUpload(deps, input) {
458
+ if (!maySubmit(input.form, input.actor, input.origin)) return {
459
+ ok: false,
460
+ reason: "not_allowed"
461
+ };
462
+ const def = fileFieldAt(input.schema, input.field);
463
+ if (def === void 0) return {
464
+ ok: false,
465
+ reason: "unknown_field"
466
+ };
467
+ if (def.type !== "file") return {
468
+ ok: false,
469
+ reason: "not_a_file_field"
470
+ };
471
+ if (def.maxFileSize !== void 0 && input.size > def.maxFileSize) return {
472
+ ok: false,
473
+ reason: "too_large"
474
+ };
475
+ if (!accepted(def.accept, input.name, input.contentType)) return {
476
+ ok: false,
477
+ reason: "not_accepted"
478
+ };
479
+ const id = deps.newId();
480
+ const file = {
481
+ id,
482
+ formId: input.form.id,
483
+ name: input.name,
484
+ size: input.size,
485
+ contentType: input.contentType,
486
+ storageKey: `${input.form.id}/${id}`,
487
+ state: "offered",
488
+ createdAt: deps.now().toISOString(),
489
+ submissionId: null
490
+ };
491
+ await deps.storage.insertFile(file);
492
+ return {
493
+ ok: true,
494
+ file
495
+ };
496
+ }
497
+ /**
498
+ * Which files a submission may claim, checked but not yet claimed.
499
+ *
500
+ * The claiming itself is `insertSubmission`'s job, in the same call that
501
+ * stores the submission, so one COMMIT decides both — the same reasoning
502
+ * that put the webhook queue there. A submission exists if and only if the
503
+ * files it names belong to it. Claiming separately gives the two failures
504
+ * nobody can debug: a rolled-back submission leaving files claimed against
505
+ * nothing, or an accepted submission referencing bytes the collector is about
506
+ * to delete.
507
+ *
508
+ * Refuses rather than skips. A submission naming a file that does not exist,
509
+ * belongs to another form, or is already claimed is not a submission to fix up
510
+ * quietly: the first is a bug or somebody guessing ids; the second is how an
511
+ * id from a form you may submit to would let you attach a file from one you
512
+ * may not; the third would make deleting a submission either leak bytes or
513
+ * break a different one.
514
+ */
515
+ async function filesToClaim(storage, input) {
516
+ const ids = referencedFileIds(input.schema, input.data);
517
+ if (ids.length === 0) return {
518
+ ok: true,
519
+ ids: []
520
+ };
521
+ const unknown = [];
522
+ const claimable = [];
523
+ for (const id of ids) {
524
+ const file = await storage.getFile(id);
525
+ if (file === void 0 || file.formId !== input.formId || file.state !== "stored") {
526
+ unknown.push(id);
527
+ continue;
528
+ }
529
+ claimable.push(file);
530
+ }
531
+ if (unknown.length > 0) return {
532
+ ok: false,
533
+ unknown
534
+ };
535
+ return {
536
+ ok: true,
537
+ ids: claimable.map((file) => file.id)
538
+ };
539
+ }
540
+ /**
541
+ * Files nobody claimed, old enough that nobody is going to.
542
+ *
543
+ * Returns the rows rather than deleting anything: the host removes the bytes
544
+ * and then the rows, in that order, because a row without bytes is a broken
545
+ * reference and bytes without a row are invisible rubbish. Of the two, the
546
+ * second is the one you never find out about.
547
+ */
548
+ async function collectAbandonedFiles(storage, now, afterHours) {
549
+ const before = (/* @__PURE__ */ new Date(now.getTime() - afterHours * 36e5)).toISOString();
550
+ return storage.abandonedFiles(before);
551
+ }
552
+ /** The field at a data path, with `[]` treated as "inside this repeater". */
553
+ function fileFieldAt(schema, path) {
554
+ const segments = path.replace(/\[\]/g, "").split(".").filter((segment) => segment !== "");
555
+ let fields = flattenPages(schema.model.fields);
556
+ let found;
557
+ for (const segment of segments) {
558
+ found = fields.find((field) => field.key === segment);
559
+ if (found === void 0) return void 0;
560
+ fields = flattenPages(found.fields ?? []);
561
+ }
562
+ return found;
563
+ }
564
+ /** A page contributes no segment to a data path. */
565
+ function flattenPages(fields) {
566
+ const out = [];
567
+ for (const field of fields) if (field.type === "page") out.push(...flattenPages(field.fields ?? []));
568
+ else out.push(field);
569
+ return out;
570
+ }
571
+ /** The HTML `accept` grammar, as the engine reads it. */
572
+ function accepted(accept, name, contentType) {
573
+ if (accept === void 0 || accept.length === 0) return true;
574
+ const lowerName = name.toLowerCase();
575
+ const lowerType = contentType.toLowerCase();
576
+ return accept.some((raw) => {
577
+ const rule = raw.trim().toLowerCase();
578
+ if (rule === "") return false;
579
+ if (rule.startsWith(".")) return lowerName.endsWith(rule);
580
+ if (rule.endsWith("/*")) return lowerType.startsWith(rule.slice(0, -1));
581
+ return lowerType === rule;
582
+ });
583
+ }
584
+ /**
585
+ * Every file id a submission references.
586
+ *
587
+ * Driven by the SCHEMA, not by the shape of the values. Recognising an
588
+ * attachment by "an object with an id" would let a client omit a property and
589
+ * have its reference quietly not claimed — the submission would be accepted
590
+ * carrying a dangling file id, and the collector would delete the bytes a day
591
+ * later. Reading only the paths the model says are file fields cannot be
592
+ * fooled that way, and a text answer that happens to look like a file is not
593
+ * one.
594
+ *
595
+ * A repeater contributes rows the schema cannot know about, so the walk
596
+ * expands over whatever rows the data actually has.
597
+ */
598
+ function referencedFileIds(schema, data) {
599
+ const ids = [];
600
+ const walk = (fields, value) => {
601
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return;
602
+ const record = value;
603
+ for (const field of flattenPages(fields)) {
604
+ const held = record[field.key];
605
+ if (held === void 0) continue;
606
+ if (field.type === "file") {
607
+ if (!Array.isArray(held)) continue;
608
+ for (const entry of held) {
609
+ const id = entry?.["id"];
610
+ if (typeof id === "string") ids.push(id);
611
+ }
612
+ continue;
613
+ }
614
+ if (field.type === "group") {
615
+ walk(field.fields ?? [], held);
616
+ continue;
617
+ }
618
+ if (field.type === "repeater" && Array.isArray(held)) for (const row of held) walk(field.fields ?? [], row);
619
+ }
84
620
  };
621
+ walk(schema.model.fields, data);
622
+ return ids;
623
+ }
624
+ /** Re-exported so a host can see which paths a schema's file fields live at. */
625
+ function fileFieldPaths(schema) {
626
+ return modelDataPaths(schema.model).filter((path) => fileFieldAt(schema, path)?.type === "file");
85
627
  }
628
+ //#endregion
629
+ //#region src/use-cases.ts
86
630
  async function resolveForm(deps, path) {
87
631
  const form = await deps.storage.getFormByPath(path);
88
632
  if (form === void 0 || form.currentVersionId === null) return void 0;
@@ -105,7 +649,32 @@ async function resolveForm(deps, path) {
105
649
  * are stripped, so a client cannot smuggle data by lying about what was shown.
106
650
  * Client validation is UX; this is truth.
107
651
  */
652
+ /**
653
+ * Change who may submit a form, and from where.
654
+ *
655
+ * Separate from publishing on purpose: access is a property of the deployment,
656
+ * and changing it must not mint a new immutable form version or invalidate the
657
+ * schema hash every client is holding.
658
+ */
659
+ async function setFormAccess(deps, input) {
660
+ const form = await deps.storage.getFormByPath(input.path);
661
+ if (form === void 0) return { ok: false };
662
+ await deps.storage.updateFormAccess(form.id, {
663
+ accessSubmit: input.submit,
664
+ allowedOrigins: input.allowedOrigins ?? null
665
+ });
666
+ return { ok: true };
667
+ }
108
668
  async function createSubmission(deps, input) {
669
+ const form = await deps.storage.getFormByPath(input.path);
670
+ if (form === void 0) return {
671
+ ok: false,
672
+ kind: "unknown_form"
673
+ };
674
+ if (!maySubmit(form, input.actor ?? "anonymous", input.origin)) return {
675
+ ok: false,
676
+ kind: "forbidden"
677
+ };
109
678
  const current = await resolveForm(deps, input.path);
110
679
  if (current === void 0) return {
111
680
  ok: false,
@@ -119,21 +688,72 @@ async function createSubmission(deps, input) {
119
688
  const engine = createFormEngine({
120
689
  schema: current.schema,
121
690
  initialValue: input.data,
122
- capabilities: deps.capabilities
691
+ capabilities: deps.capabilities,
692
+ mode: "server",
693
+ ...deps.checks === void 0 ? {} : { checks: deps.checks }
123
694
  });
695
+ await engine.settle();
124
696
  const outcome = engine.submit();
125
697
  if (!outcome.ok) return {
126
698
  ok: false,
127
699
  kind: "invalid",
128
700
  errors: outcome.errors
129
701
  };
702
+ const membership = await checkMembership(current.schema, engine.value(), deps.optionsSources);
703
+ if (!membership.ok) return membership.kind === "invalid" ? {
704
+ ok: false,
705
+ kind: "invalid",
706
+ errors: membership.errors
707
+ } : {
708
+ ok: false,
709
+ kind: "source_unavailable",
710
+ source: membership.source
711
+ };
130
712
  const id = deps.newId();
713
+ const hooks = await deps.storage.webhooksForForm(current.formId);
714
+ const body = JSON.stringify({
715
+ id,
716
+ form: input.path,
717
+ data: engine.value()
718
+ });
719
+ const queued = hooks.map((hook) => ({
720
+ id: deps.newId(),
721
+ webhookId: hook.id,
722
+ submissionId: id,
723
+ eventId: deps.newId(),
724
+ body,
725
+ attempt: 0,
726
+ nextAttemptAt: deps.nowIso(),
727
+ state: "pending",
728
+ lastError: null
729
+ }));
730
+ const claimable = await filesToClaim(deps.storage, {
731
+ schema: current.schema,
732
+ formId: current.formId,
733
+ data: engine.value()
734
+ });
735
+ if (!claimable.ok) return {
736
+ ok: false,
737
+ kind: "invalid",
738
+ errors: { _files: [`unknown_file:${claimable.unknown.join(",")}`] }
739
+ };
740
+ const at = deps.nowIso();
131
741
  await deps.storage.insertSubmission({
132
742
  id,
133
743
  formId: current.formId,
134
744
  formVersionId: current.versionId,
135
745
  data: engine.value(),
136
- submittedAt: deps.nowIso()
746
+ submittedAt: at
747
+ }, queued, claimable.ids, {
748
+ id: deps.newId(),
749
+ at,
750
+ action: "submission.created",
751
+ subject: input.path,
752
+ detail: {
753
+ submissionId: id,
754
+ version: current.version,
755
+ authenticated: input.actor === "authenticated"
756
+ }
137
757
  });
138
758
  return {
139
759
  ok: true,
@@ -223,9 +843,54 @@ function csvCell(value) {
223
843
  }
224
844
  /** Save (or re-save) a partial form, bound to the CURRENT version. Undefined
225
845
  * for an unknown form. */
846
+ /**
847
+ * The token that proves the bearer started this draft.
848
+ *
849
+ * HMAC over the form and the draft id, the same stateless shape the
850
+ * proof-of-work challenge uses
851
+ * ([0059](../../../docs/decisions/0059-proof-of-work-not-a-captcha.md)): no
852
+ * second table, and no lookup before the check. Bound to the FORM as well as the
853
+ * id so a token cannot be carried to a draft of another form that happens to
854
+ * share an id.
855
+ */
856
+ function draftToken(secret, formId, draftId) {
857
+ return bytesToHex(hmac(sha256, utf8ToBytes(secret), utf8ToBytes(`${formId}:${draftId}`)));
858
+ }
859
+ /**
860
+ * Compared in constant time.
861
+ *
862
+ * A byte-at-a-time comparison that returns early leaks how much of a guess was
863
+ * right, and a token is guessed one byte at a time by exactly that signal.
864
+ */
865
+ function sameToken(a, b) {
866
+ if (a.length !== b.length) return false;
867
+ let differences = 0;
868
+ for (let at = 0; at < a.length; at += 1) differences |= a.charCodeAt(at) ^ b.charCodeAt(at);
869
+ return differences === 0;
870
+ }
871
+ /**
872
+ * Start a draft: the server picks the id, and signs it.
873
+ *
874
+ * The id is the server's because an id a caller supplies is an id a caller can
875
+ * enumerate — and enumerating them was enough to read other people's
876
+ * part-filled answers.
877
+ */
878
+ async function startDraft(deps, input) {
879
+ const current = await resolveForm(deps, input.path);
880
+ if (current === void 0) return void 0;
881
+ const draftId = deps.newId();
882
+ return {
883
+ draftId,
884
+ token: draftToken(deps.draftSecret, current.formId, draftId)
885
+ };
886
+ }
226
887
  async function saveDraft(deps, input) {
227
888
  const current = await resolveForm(deps, input.path);
228
889
  if (current === void 0) return void 0;
890
+ if (!sameToken(input.token, draftToken(deps.draftSecret, current.formId, input.draftId))) return {
891
+ version: current.version,
892
+ saved: false
893
+ };
229
894
  await deps.storage.upsertDraft({
230
895
  id: input.draftId,
231
896
  formId: current.formId,
@@ -233,7 +898,10 @@ async function saveDraft(deps, input) {
233
898
  data: input.data,
234
899
  updatedAt: deps.nowIso()
235
900
  });
236
- return { version: current.version };
901
+ return {
902
+ version: current.version,
903
+ saved: true
904
+ };
237
905
  }
238
906
  /**
239
907
  * Resume a draft — and migrate it LAZILY if the form was republished since,
@@ -250,6 +918,7 @@ async function saveDraft(deps, input) {
250
918
  async function resumeDraft(deps, input) {
251
919
  const current = await resolveForm(deps, input.path);
252
920
  if (current === void 0) return void 0;
921
+ if (!sameToken(input.token, draftToken(deps.draftSecret, current.formId, input.draftId))) return;
253
922
  const draft = await deps.storage.getDraft(current.formId, input.draftId);
254
923
  if (draft === void 0) return void 0;
255
924
  if (draft.formVersionId === current.versionId) return {
@@ -381,6 +1050,44 @@ async function listVersions(deps, path) {
381
1050
  }));
382
1051
  }
383
1052
  //#endregion
1053
+ //#region src/redos.ts
1054
+ /** Every `pattern` in the model, with the data path of the field it sits on. */
1055
+ function patternsIn(fields, prefix) {
1056
+ const found = [];
1057
+ for (const field of fields) {
1058
+ const path = field.type === "page" ? prefix : field.type === "repeater" ? `${prefix}${field.key}[].` : field.type === "group" ? `${prefix}${field.key}.` : `${prefix}${field.key}`;
1059
+ if (typeof field.pattern === "string" && field.pattern !== "") found.push({
1060
+ path,
1061
+ pattern: field.pattern
1062
+ });
1063
+ if (field.fields !== void 0) found.push(...patternsIn(field.fields, path));
1064
+ }
1065
+ return found;
1066
+ }
1067
+ /**
1068
+ * The patterns in this schema that an attacker could exploit, or an empty array
1069
+ * when there are none.
1070
+ *
1071
+ * A pattern `recheck` cannot decide about is ACCEPTED. Refusing on `unknown`
1072
+ * would make publishing depend on an analysis timeout, which is a worse failure
1073
+ * than the one being prevented — an author whose legitimate form stops
1074
+ * publishing intermittently has no way to act on that.
1075
+ */
1076
+ async function unsafePatterns(schema) {
1077
+ const unsafe = [];
1078
+ for (const { path, pattern } of patternsIn(schema.model.fields, "")) {
1079
+ const diagnostics = await check(`^(?:${pattern})$`, "");
1080
+ if (diagnostics.status !== "vulnerable") continue;
1081
+ const complexity = diagnostics.complexity;
1082
+ unsafe.push({
1083
+ path,
1084
+ pattern,
1085
+ complexity: complexity === void 0 ? "unknown" : complexity.type === "polynomial" ? `polynomial degree ${String(complexity.degree)}` : complexity.type
1086
+ });
1087
+ }
1088
+ return unsafe;
1089
+ }
1090
+ //#endregion
384
1091
  //#region src/testing/memory-storage.ts
385
1092
  /**
386
1093
  * The in-memory Storage — the second implementation that keeps the port
@@ -389,14 +1096,26 @@ async function listVersions(deps, path) {
389
1096
  */
390
1097
  function createMemoryStorage() {
391
1098
  const forms = /* @__PURE__ */ new Map();
1099
+ const webhooks = /* @__PURE__ */ new Map();
1100
+ const deliveries = /* @__PURE__ */ new Map();
392
1101
  const versions = /* @__PURE__ */ new Map();
393
1102
  const submissions = [];
1103
+ const audits = [];
1104
+ const spentChallenges = /* @__PURE__ */ new Map();
394
1105
  const drafts = /* @__PURE__ */ new Map();
395
1106
  const users = /* @__PURE__ */ new Map();
396
1107
  const apiKeys = /* @__PURE__ */ new Map();
1108
+ const files = /* @__PURE__ */ new Map();
397
1109
  return {
398
1110
  getFormByPath: async (path) => [...forms.values()].find((form) => form.path === path),
399
1111
  listForms: async () => [...forms.values()].map((form) => ({ ...form })),
1112
+ updateFormAccess: async (formId, access) => {
1113
+ const form = forms.get(formId);
1114
+ if (form !== void 0) forms.set(formId, {
1115
+ ...form,
1116
+ ...access
1117
+ });
1118
+ },
400
1119
  createForm: async (record) => {
401
1120
  forms.set(record.id, { ...record });
402
1121
  },
@@ -408,12 +1127,78 @@ function createMemoryStorage() {
408
1127
  insertVersion: async (record) => {
409
1128
  versions.set(record.id, { ...record });
410
1129
  },
1130
+ publishVersion: async ({ form, version, audit }) => {
1131
+ if (form !== void 0) forms.set(form.id, { ...form });
1132
+ const target = forms.get(version.formId);
1133
+ if (target === void 0) throw new Error(`No form "${version.formId}"`);
1134
+ versions.set(version.id, { ...version });
1135
+ target.currentVersionId = version.id;
1136
+ if (audit !== void 0) audits.push({ ...audit });
1137
+ },
411
1138
  getVersionById: async (id) => versions.get(id),
412
1139
  findVersionByHash: async (formId, hash) => [...versions.values()].find((version) => version.formId === formId && version.schemaHash === hash),
413
1140
  latestVersionNumber: async (formId) => Math.max(0, ...[...versions.values()].filter((version) => version.formId === formId).map((version) => version.version)),
414
- insertSubmission: async (record) => {
1141
+ webhooksForForm: async (formId) => [...webhooks.values()].filter((hook) => hook.formId === formId).map((hook) => ({ ...hook })),
1142
+ insertWebhook: async (record) => {
1143
+ webhooks.set(record.id, { ...record });
1144
+ },
1145
+ updateWebhook: async (record) => {
1146
+ webhooks.set(record.id, { ...record });
1147
+ },
1148
+ listWebhooks: async () => [...webhooks.values()].map((w) => ({ ...w })),
1149
+ deadDeliveries: async (limit) => [...deliveries.values()].filter((delivery) => delivery.state === "dead").slice(0, limit).map((delivery) => ({ ...delivery })),
1150
+ getDelivery: async (id) => {
1151
+ const found = deliveries.get(id);
1152
+ return found === void 0 ? void 0 : { ...found };
1153
+ },
1154
+ 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 })),
1155
+ updateDelivery: async (record) => {
1156
+ deliveries.set(record.id, { ...record });
1157
+ },
1158
+ insertFile: async (record) => {
1159
+ files.set(record.id, { ...record });
1160
+ },
1161
+ getFile: async (id) => {
1162
+ const found = files.get(id);
1163
+ return found === void 0 ? void 0 : { ...found };
1164
+ },
1165
+ updateFile: async (record) => {
1166
+ files.set(record.id, { ...record });
1167
+ },
1168
+ abandonedFiles: async (beforeIso) => [...files.values()].filter((file) => file.state !== "claimed" && file.createdAt < beforeIso).map((file) => ({ ...file })),
1169
+ deleteFiles: async (ids) => {
1170
+ for (const id of ids) files.delete(id);
1171
+ },
1172
+ insertSubmission: async (record, queued, claimFileIds, audit) => {
415
1173
  submissions.push({ ...record });
1174
+ for (const delivery of queued ?? []) deliveries.set(delivery.id, { ...delivery });
1175
+ for (const id of claimFileIds ?? []) {
1176
+ const file = files.get(id);
1177
+ if (file !== void 0) files.set(id, {
1178
+ ...file,
1179
+ state: "claimed",
1180
+ submissionId: record.id
1181
+ });
1182
+ }
1183
+ if (audit !== void 0) audits.push({ ...audit });
1184
+ },
1185
+ spendChallenge: async (challenge, expiresAtIso) => {
1186
+ if (spentChallenges.has(challenge)) return false;
1187
+ spentChallenges.set(challenge, expiresAtIso);
1188
+ return true;
1189
+ },
1190
+ forgetExpiredChallenges: async (beforeIso) => {
1191
+ let dropped = 0;
1192
+ for (const [challenge, expires] of [...spentChallenges]) if (expires < beforeIso) {
1193
+ spentChallenges.delete(challenge);
1194
+ dropped += 1;
1195
+ }
1196
+ return dropped;
1197
+ },
1198
+ recordAudit: async (entry) => {
1199
+ audits.push({ ...entry });
416
1200
  },
1201
+ listAudit: async (limit) => [...audits].reverse().slice(0, limit),
417
1202
  listSubmissions: async () => [...submissions],
418
1203
  listSubmissionsByForm: async (formId) => submissions.filter((record) => record.formId === formId).reverse(),
419
1204
  listVersionsByForm: async (formId) => [...versions.values()].filter((version) => version.formId === formId).sort((a, b) => b.version - a.version),
@@ -541,6 +1326,256 @@ function can(actor, action) {
541
1326
  return PERMISSIONS[actor.role].has(action);
542
1327
  }
543
1328
  //#endregion
544
- export { authenticateApiKey, authenticateLocal, can, createApiKey, createLocalUser, createMemoryStorage, createSubmission, exportCsv, listForms, listSubmissions, listVersions, publishForm, resolveForm, resumeDraft, saveDraft };
1329
+ //#region src/address.ts
1330
+ /**
1331
+ * Deciding whether an address is somewhere this server may fetch from.
1332
+ *
1333
+ * A self-hosted formancy sits inside somebody's private network and webhook
1334
+ * URLs are written by whoever can edit a form, which makes the server a
1335
+ * confused deputy: it can reach the database, the metadata service and every
1336
+ * internal admin panel, and it will fetch whatever it is told to.
1337
+ *
1338
+ * This half is pure — given an address, is it private — so it lives here and is
1339
+ * tested exhaustively. The rest lives in @formancy/server, because it cannot be
1340
+ * pure: parsing a URL needs `URL`, and resolving a hostname then connecting to
1341
+ * the address that was checked, rather than re-resolving, needs Node's DNS and
1342
+ * a custom agent. This package has no @types/node by design, and hand-rolling
1343
+ * a URL parser for a security check is how such a check gets quietly wrong.
1344
+ * All three parts are necessary. Validating a URL and then handing it to fetch is defeated by DNS
1345
+ * rebinding — the name resolves to a public address for the check and a private
1346
+ * one for the request — and that is the difference between a mitigation and
1347
+ * theatre.
1348
+ */
1349
+ /** Everything before the first dot, as four octets, or undefined. */
1350
+ function ipv4Octets(address) {
1351
+ const parts = address.split(".");
1352
+ if (parts.length !== 4) return void 0;
1353
+ const octets = parts.map((part) => {
1354
+ if (!/^\d{1,3}$/.test(part)) return NaN;
1355
+ return Number(part);
1356
+ });
1357
+ if (octets.some((octet) => Number.isNaN(octet) || octet > 255)) return void 0;
1358
+ return octets;
1359
+ }
1360
+ /**
1361
+ * Whether this address is one the server must not be pointed at.
1362
+ *
1363
+ * Unparseable input counts as private. An address this cannot classify is one
1364
+ * it cannot vouch for, and the safe direction is refusing to fetch.
1365
+ */
1366
+ function isPrivateAddress(address) {
1367
+ const trimmed = address.trim().toLowerCase();
1368
+ if (trimmed === "") return true;
1369
+ const bare = trimmed.replace(/^\[|\]$/g, "").split("%")[0] ?? "";
1370
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(bare);
1371
+ if (mapped?.[1] !== void 0) return isPrivateAddress(mapped[1]);
1372
+ const octets = ipv4Octets(bare);
1373
+ if (octets !== void 0) {
1374
+ const [a, b] = octets;
1375
+ if (a === 0) return true;
1376
+ if (a === 10) return true;
1377
+ if (a === 127) return true;
1378
+ if (a === 169 && b === 254) return true;
1379
+ if (a === 172 && b >= 16 && b <= 31) return true;
1380
+ if (a === 192 && b === 168) return true;
1381
+ if (a === 100 && b >= 64 && b <= 127) return true;
1382
+ if (a === 192 && b === 0) return true;
1383
+ if (a === 198 && (b === 18 || b === 19)) return true;
1384
+ if (a >= 224) return true;
1385
+ return false;
1386
+ }
1387
+ if (bare.includes(":")) {
1388
+ if (bare === "::" || bare === "::1") return true;
1389
+ if (/^f[cd]/.test(bare)) return true;
1390
+ if (/^fe[89ab]/.test(bare)) return true;
1391
+ if (/^ff/.test(bare)) return true;
1392
+ return false;
1393
+ }
1394
+ return true;
1395
+ }
1396
+ /**
1397
+ * Whether this is a literal IP address rather than a hostname.
1398
+ *
1399
+ * The two need different treatment and conflating them breaks the check in
1400
+ * both directions: `isPrivateAddress` fails closed on anything it cannot
1401
+ * parse, which is correct for an address that came back from DNS and wrong for
1402
+ * `example.ch`, where it would refuse every hostname in the world.
1403
+ */
1404
+ function isIpLiteral(host) {
1405
+ const bare = host.trim().replace(/^\[|\]$/g, "");
1406
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(bare)) return true;
1407
+ return bare.includes(":");
1408
+ }
1409
+ //#endregion
1410
+ //#region src/publishing.ts
1411
+ /**
1412
+ * Publish a schema as a form's next version.
1413
+ *
1414
+ * This is the save gate: structural validation, then the ENGINE's own compile
1415
+ * — type-checked expressions, kind policies and the cycle graph — so a form
1416
+ * that can loop or read a ghost field is refused here and never persisted.
1417
+ * Republishing the identical document is idempotent, because "deploy again"
1418
+ * must never manufacture a version.
1419
+ */
1420
+ async function publishForm(deps, input) {
1421
+ const validated = validateSchema(input.schema);
1422
+ if (!validated.valid) return {
1423
+ ok: false,
1424
+ kind: "invalid_schema",
1425
+ errors: validated.errors
1426
+ };
1427
+ const schema = validated.schema;
1428
+ try {
1429
+ createFormEngine({
1430
+ schema,
1431
+ capabilities: deps.capabilities
1432
+ });
1433
+ } catch (error) {
1434
+ return {
1435
+ ok: false,
1436
+ kind: "invalid_logic",
1437
+ message: error instanceof Error ? error.message : String(error)
1438
+ };
1439
+ }
1440
+ const problems = expressionProblems(schema);
1441
+ if (problems.length > 0) return {
1442
+ ok: false,
1443
+ kind: "invalid_logic",
1444
+ message: problems.map((problem) => problem.message).join("\n")
1445
+ };
1446
+ const warnings = unknownReferences(schema).map((found) => found.message);
1447
+ const unsafe = await unsafePatterns(schema);
1448
+ if (unsafe.length > 0) return {
1449
+ ok: false,
1450
+ kind: "unsafe_pattern",
1451
+ patterns: unsafe
1452
+ };
1453
+ if (deps.optionsSources !== void 0) {
1454
+ const unknown = sourceNamesIn(schema).filter((name) => deps.optionsSources?.[name] === void 0);
1455
+ if (unknown.length > 0) return {
1456
+ ok: false,
1457
+ kind: "unknown_options_source",
1458
+ sources: unknown
1459
+ };
1460
+ }
1461
+ const hash = schemaHash(schema);
1462
+ const existing = await deps.storage.getFormByPath(input.path);
1463
+ if (existing !== void 0) {
1464
+ const current = existing.currentVersionId === null ? void 0 : await deps.storage.getVersionById(existing.currentVersionId);
1465
+ if (current !== void 0 && current.schemaHash === hash) return {
1466
+ ok: true,
1467
+ formId: existing.id,
1468
+ versionId: current.id,
1469
+ version: current.version,
1470
+ schemaHash: hash,
1471
+ warnings
1472
+ };
1473
+ if (input.basedOnSchemaHash !== void 0 && input.basedOnSchemaHash !== current?.schemaHash) return {
1474
+ ok: false,
1475
+ kind: "version_changed",
1476
+ ...current === void 0 ? {} : { current: {
1477
+ formId: existing.id,
1478
+ versionId: current.id,
1479
+ version: current.version,
1480
+ schema: current.schema,
1481
+ schemaHash: current.schemaHash
1482
+ } }
1483
+ };
1484
+ const already = (await deps.storage.listVersionsByForm(existing.id)).find((candidate) => candidate.schemaHash === hash);
1485
+ if (already !== void 0) return {
1486
+ ok: false,
1487
+ kind: "already_published",
1488
+ version: already.version
1489
+ };
1490
+ const version = await deps.storage.latestVersionNumber(existing.id) + 1;
1491
+ const versionId = deps.newId();
1492
+ await deps.storage.publishVersion({
1493
+ version: {
1494
+ id: versionId,
1495
+ formId: existing.id,
1496
+ version,
1497
+ schema,
1498
+ schemaHash: hash
1499
+ },
1500
+ audit: publishAudit(deps, input, {
1501
+ path: input.path,
1502
+ version,
1503
+ hash,
1504
+ sources: sourceNamesIn(schema)
1505
+ })
1506
+ });
1507
+ return {
1508
+ ok: true,
1509
+ formId: existing.id,
1510
+ versionId,
1511
+ version,
1512
+ schemaHash: hash,
1513
+ warnings
1514
+ };
1515
+ }
1516
+ if (input.basedOnSchemaHash !== void 0) return {
1517
+ ok: false,
1518
+ kind: "version_changed"
1519
+ };
1520
+ const formId = deps.newId();
1521
+ const versionId = deps.newId();
1522
+ await deps.storage.publishVersion({
1523
+ form: {
1524
+ id: formId,
1525
+ path: input.path,
1526
+ currentVersionId: null,
1527
+ accessSubmit: "authenticated",
1528
+ allowedOrigins: null
1529
+ },
1530
+ version: {
1531
+ id: versionId,
1532
+ formId,
1533
+ version: 1,
1534
+ schema,
1535
+ schemaHash: hash
1536
+ },
1537
+ audit: publishAudit(deps, input, {
1538
+ path: input.path,
1539
+ version: 1,
1540
+ hash,
1541
+ sources: sourceNamesIn(schema)
1542
+ })
1543
+ });
1544
+ return {
1545
+ ok: true,
1546
+ formId,
1547
+ versionId,
1548
+ version: 1,
1549
+ schemaHash: hash,
1550
+ warnings
1551
+ };
1552
+ }
1553
+ /**
1554
+ * The audit row for a publish, built here so it can travel INSIDE the
1555
+ * transaction rather than being appended after it.
1556
+ *
1557
+ * Before this it was written by the route once all three storage calls had
1558
+ * returned, which meant a publish could half-apply and still be recorded as
1559
+ * having happened. Now it rolls back with everything else.
1560
+ */
1561
+ function publishAudit(deps, input, about) {
1562
+ return {
1563
+ id: deps.newId(),
1564
+ at: deps.nowIso(),
1565
+ action: "form.published",
1566
+ subject: about.path,
1567
+ ...input.actor === void 0 ? {} : {
1568
+ actorKind: input.actor.kind,
1569
+ actorId: input.actor.id
1570
+ },
1571
+ detail: {
1572
+ version: about.version,
1573
+ schemaHash: about.hash,
1574
+ ...about.sources.length === 0 ? {} : { optionsSources: [...about.sources].sort().join(", ") }
1575
+ }
1576
+ };
1577
+ }
1578
+ //#endregion
1579
+ 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
1580
 
546
1581
  //# sourceMappingURL=index.mjs.map