@formancy/server-core 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +25 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +37 -472
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
package/dist/index.mjs
CHANGED
|
@@ -1,29 +1,20 @@
|
|
|
1
|
+
import { createFormEngine, expressionProblems, getAt, runScenarios, unknownReferences } from "@formancy/core";
|
|
1
2
|
import { CHALLENGE_TTL_SECONDS, DEFAULT_MAX_NUMBER, decodeSolution, encodeSolution, hashOf, mintChallenge, solveChallenge, verifySolution } from "@formancy/challenge";
|
|
2
3
|
import { hmac } from "@noble/hashes/hmac.js";
|
|
3
4
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
4
5
|
import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
|
|
5
|
-
import { createFormEngine, expressionProblems, runScenarios, unknownReferences } from "@formancy/core";
|
|
6
6
|
import { diffSchemas, modelDataPaths, schemaHash } from "@formancy/spec";
|
|
7
7
|
import { check } from "recheck";
|
|
8
8
|
import { validateSchema } from "@formancy/spec/validate";
|
|
9
9
|
import { comparedToLastRun, isModelRequestKind, modelBriefing, readScenario } from "@formancy/builder-core";
|
|
10
10
|
//#region src/options-membership.ts
|
|
11
|
-
/**
|
|
12
|
-
* Every sourced answer in a submission, as `path → { source, value }`.
|
|
13
|
-
*
|
|
14
|
-
* Walked from the SCHEMA over the value, never from the value's own shape — the
|
|
15
|
-
* same way `referencedFileIds` does it. A walk driven by what arrived would let a
|
|
16
|
-
* client dodge the check by omitting a property, and hidden branches have already
|
|
17
|
-
* been stripped by the engine before this runs.
|
|
18
|
-
*/
|
|
19
11
|
function sourcedAnswers(schema, value) {
|
|
20
12
|
const found = [];
|
|
21
13
|
const walk = (fields, data, prefix) => {
|
|
22
14
|
if (typeof data !== "object" || data === null) return;
|
|
23
|
-
const record = data;
|
|
24
15
|
for (const field of fields) {
|
|
25
16
|
const at = prefix === "" ? field.key : `${prefix}.${field.key}`;
|
|
26
|
-
const held =
|
|
17
|
+
const held = getAt(data, [field.key]);
|
|
27
18
|
if (field.fields !== void 0) {
|
|
28
19
|
if (Array.isArray(held)) for (const [index, row] of held.entries()) walk(field.fields, row, `${at}[${String(index)}]`);
|
|
29
20
|
else walk(field.fields, held, at);
|
|
@@ -44,12 +35,6 @@ function sourcedAnswers(schema, value) {
|
|
|
44
35
|
walk(schema.model.fields, value, "");
|
|
45
36
|
return found;
|
|
46
37
|
}
|
|
47
|
-
/**
|
|
48
|
-
* Ask each source about the values submitted for it.
|
|
49
|
-
*
|
|
50
|
-
* One call per source rather than per answer, so a deployment sees `IN (…)` and not
|
|
51
|
-
* a query per row.
|
|
52
|
-
*/
|
|
53
38
|
async function checkMembership(schema, value, sources) {
|
|
54
39
|
const answers = sourcedAnswers(schema, value);
|
|
55
40
|
if (answers.length === 0 || sources === void 0) return { ok: true };
|
|
@@ -64,7 +49,7 @@ async function checkMembership(schema, value, sources) {
|
|
|
64
49
|
}
|
|
65
50
|
const errors = {};
|
|
66
51
|
for (const [name, group] of bySource) {
|
|
67
|
-
const source = sources[name];
|
|
52
|
+
const source = Object.hasOwn(sources, name) ? sources[name] : void 0;
|
|
68
53
|
if (source === void 0) return {
|
|
69
54
|
ok: false,
|
|
70
55
|
kind: "source_unavailable",
|
|
@@ -93,11 +78,6 @@ async function checkMembership(schema, value, sources) {
|
|
|
93
78
|
errors
|
|
94
79
|
};
|
|
95
80
|
}
|
|
96
|
-
/**
|
|
97
|
-
* The source names a document uses, for the publish-time vocabulary check.
|
|
98
|
-
*
|
|
99
|
-
* Walked over the schema rather than over any data, because publishing has none.
|
|
100
|
-
*/
|
|
101
81
|
function sourceNamesIn(schema) {
|
|
102
82
|
const names = /* @__PURE__ */ new Set();
|
|
103
83
|
const walk = (fields) => {
|
|
@@ -111,28 +91,6 @@ function sourceNamesIn(schema) {
|
|
|
111
91
|
}
|
|
112
92
|
//#endregion
|
|
113
93
|
//#region src/audit.ts
|
|
114
|
-
/**
|
|
115
|
-
* Who did what, to which thing, and when.
|
|
116
|
-
*
|
|
117
|
-
* **Reads and exports are recorded as well as mutations** — `submission.read` and
|
|
118
|
-
* `submission.exported` — which is what lets a self-hoster answer who downloaded four
|
|
119
|
-
* thousand people's answers ([0057](../../../docs/decisions/0057-the-audit-log-records-reads.md)).
|
|
120
|
-
*
|
|
121
|
-
* **A row joins the mutation's transaction where there is one.** `insertSubmission` and
|
|
122
|
-
* `publishVersion` both commit their audit row with the change it describes, so a
|
|
123
|
-
* rollback leaves no trace saying it happened. A read has nothing to be atomic with, so
|
|
124
|
-
* its row is appended after the rows are handed over and a failure to write it is logged
|
|
125
|
-
* loudly rather than swallowed.
|
|
126
|
-
*
|
|
127
|
-
* **`detail` carries identifiers and counts, never submission content.** An audit log is
|
|
128
|
-
* read by more people, kept longer and exported more freely than the data it describes.
|
|
129
|
-
*/
|
|
130
|
-
/**
|
|
131
|
-
* Every auditable action, as a closed list.
|
|
132
|
-
*
|
|
133
|
-
* Closed so a new route cannot invent its own spelling: an audit log where one event is
|
|
134
|
-
* called two things is one nobody can query.
|
|
135
|
-
*/
|
|
136
94
|
const AUDIT_ACTIONS = [
|
|
137
95
|
"auth.login",
|
|
138
96
|
"auth.login.failed",
|
|
@@ -149,13 +107,6 @@ const AUDIT_ACTIONS = [
|
|
|
149
107
|
"delivery.replayed",
|
|
150
108
|
"model.asked"
|
|
151
109
|
];
|
|
152
|
-
/**
|
|
153
|
-
* An entry from an actor, with the fields spread the way the record stores
|
|
154
|
-
* them.
|
|
155
|
-
*
|
|
156
|
-
* A helper rather than a convention, because `actorKind` and `actorId` being
|
|
157
|
-
* set together is exactly the sort of thing eleven call sites get wrong once.
|
|
158
|
-
*/
|
|
159
110
|
function auditedBy(actor, draft) {
|
|
160
111
|
return actor === void 0 ? draft : {
|
|
161
112
|
...draft,
|
|
@@ -165,38 +116,14 @@ function auditedBy(actor, draft) {
|
|
|
165
116
|
}
|
|
166
117
|
//#endregion
|
|
167
118
|
//#region src/webhook.ts
|
|
168
|
-
/**
|
|
169
|
-
* What a webhook delivery looks like on the wire.
|
|
170
|
-
*
|
|
171
|
-
* The signature scheme is Stripe's, deliberately: receivers already have code
|
|
172
|
-
* for it, and a scheme somebody has to implement from scratch is a scheme most
|
|
173
|
-
* receivers will skip. Adopting a familiar one is the difference between a
|
|
174
|
-
* verified webhook and a verified-in-principle one.
|
|
175
|
-
*/
|
|
176
119
|
const EVENT_ID_HEADER = "x-formancy-event-id";
|
|
177
120
|
const SIGNATURE_HEADER = "x-formancy-signature";
|
|
178
|
-
/**
|
|
179
|
-
* The signed payload is `timestamp.body`, not `body`.
|
|
180
|
-
*
|
|
181
|
-
* Without the timestamp inside the signature, a signature captured once stays
|
|
182
|
-
* valid forever and can be replayed. With it, a receiver can reject anything
|
|
183
|
-
* older than its tolerance, and the signature covers the timestamp it is
|
|
184
|
-
* checking — so the timestamp cannot be edited either.
|
|
185
|
-
*/
|
|
186
121
|
function signedPayload(timestampSeconds, body) {
|
|
187
122
|
return `${String(timestampSeconds)}.${body}`;
|
|
188
123
|
}
|
|
189
|
-
/** Hex HMAC-SHA256 over the signed payload. */
|
|
190
124
|
function signBody(secret, timestampSeconds, body) {
|
|
191
125
|
return bytesToHex(hmac(sha256, utf8ToBytes(secret), utf8ToBytes(signedPayload(timestampSeconds, body))));
|
|
192
126
|
}
|
|
193
|
-
/**
|
|
194
|
-
* The headers for one delivery attempt.
|
|
195
|
-
*
|
|
196
|
-
* The event id is stable across retries, which is what makes the receiver able
|
|
197
|
-
* to be idempotent. Generating a fresh one per attempt would turn our retry
|
|
198
|
-
* into their duplicate.
|
|
199
|
-
*/
|
|
200
127
|
function deliveryHeaders(input) {
|
|
201
128
|
return {
|
|
202
129
|
"content-type": "application/json",
|
|
@@ -205,12 +132,6 @@ function deliveryHeaders(input) {
|
|
|
205
132
|
"x-formancy-attempt": String(input.attempt)
|
|
206
133
|
};
|
|
207
134
|
}
|
|
208
|
-
/**
|
|
209
|
-
* Verify a delivery the way a receiver would.
|
|
210
|
-
*
|
|
211
|
-
* Exported because a scheme nobody can check is a scheme nobody does check —
|
|
212
|
-
* this is the reference implementation, and the test for our own signing.
|
|
213
|
-
*/
|
|
214
135
|
function verifySignature(input) {
|
|
215
136
|
const parts = new Map(input.header.split(",").map((part) => {
|
|
216
137
|
const at = part.indexOf("=");
|
|
@@ -223,85 +144,29 @@ function verifySignature(input) {
|
|
|
223
144
|
if (Math.abs(input.nowSeconds - timestamp) > tolerance) return false;
|
|
224
145
|
return timingSafeEqual(signBody(input.secret, timestamp, input.body), provided);
|
|
225
146
|
}
|
|
226
|
-
/**
|
|
227
|
-
* Compare without leaking where the first difference is.
|
|
228
|
-
*
|
|
229
|
-
* `a === b` on a hex string returns as soon as two characters differ, and the
|
|
230
|
-
* time it takes says how many leading characters were right — enough, over
|
|
231
|
-
* many attempts, to recover a signature a byte at a time.
|
|
232
|
-
*/
|
|
233
147
|
function timingSafeEqual(a, b) {
|
|
234
148
|
if (a.length !== b.length) return false;
|
|
235
149
|
let difference = 0;
|
|
236
150
|
for (let index = 0; index < a.length; index += 1) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
237
151
|
return difference === 0;
|
|
238
152
|
}
|
|
239
|
-
/**
|
|
240
|
-
* How long to wait before attempt n, in milliseconds.
|
|
241
|
-
*
|
|
242
|
-
* Exponential, capped, with jitter. The jitter is the part that matters at
|
|
243
|
-
* scale: without it every delivery queued by the same outage retries at the
|
|
244
|
-
* same instant, and the receiver coming back up is knocked over by the
|
|
245
|
-
* thundering herd of everyone's retries.
|
|
246
|
-
*/
|
|
247
153
|
function retryDelayMs(attempt, random) {
|
|
248
154
|
const base = Math.min(3e4 * 2 ** (attempt - 1), 216e5);
|
|
249
155
|
return Math.floor(base * random());
|
|
250
156
|
}
|
|
251
|
-
/** Eight attempts reach roughly 24 hours with this schedule. */
|
|
252
157
|
const MAX_ATTEMPTS = 8;
|
|
253
158
|
//#endregion
|
|
254
159
|
//#region src/breaker.ts
|
|
255
|
-
/**
|
|
256
|
-
* Stopping, when a destination has plainly stopped listening.
|
|
257
|
-
*
|
|
258
|
-
* Failure is counted per WEBHOOK as well as per delivery. Enough consecutive failures
|
|
259
|
-
* and the breaker opens: nothing is attempted for that webhook until a cool-down passes,
|
|
260
|
-
* then exactly one delivery goes through as a probe. It succeeds and the breaker closes;
|
|
261
|
-
* it fails and the cool-down starts again.
|
|
262
|
-
*
|
|
263
|
-
* `consecutiveFailures` and `openedAt` live on the RECORD rather than in the worker's
|
|
264
|
-
* memory, so the state survives a restart and can be shown on a screen — a self-hoster
|
|
265
|
-
* has no operations team watching a dashboard
|
|
266
|
-
* ([0058](../../../docs/decisions/0058-a-breaker-per-destination.md)).
|
|
267
|
-
*
|
|
268
|
-
* Everything here is a pure function of a record and a clock: decisions tangled into
|
|
269
|
-
* network code can only be tested by making requests.
|
|
270
|
-
*/
|
|
271
|
-
/**
|
|
272
|
-
* How many consecutive failures open the breaker.
|
|
273
|
-
*
|
|
274
|
-
* Three, not one. A single failure is a bad minute — a deploy, a timeout, a
|
|
275
|
-
* receiver restarting — and opening on it would make every routine blip a
|
|
276
|
-
* visible outage. Three in a row is a destination that is not coming back on
|
|
277
|
-
* its own.
|
|
278
|
-
*/
|
|
279
160
|
const BREAKER_THRESHOLD = 3;
|
|
280
|
-
/**
|
|
281
|
-
* How long the breaker stays shut before one probe is allowed through.
|
|
282
|
-
*
|
|
283
|
-
* Five minutes: long enough that a restarting receiver is finished, short
|
|
284
|
-
* enough that nobody notices the delay once it recovers. The retry schedule
|
|
285
|
-
* handles patience within a delivery; this handles patience about a
|
|
286
|
-
* destination.
|
|
287
|
-
*/
|
|
288
161
|
const BREAKER_COOLDOWN_MS = 3e5;
|
|
289
162
|
function breakerState(webhook, now) {
|
|
290
163
|
if (webhook.consecutiveFailures < 3) return "closed";
|
|
291
164
|
if (webhook.openedAt === null) return "closed";
|
|
292
165
|
return now.getTime() - new Date(webhook.openedAt).getTime() >= 3e5 ? "half-open" : "open";
|
|
293
166
|
}
|
|
294
|
-
/** Whether a delivery for this webhook may be attempted now. */
|
|
295
167
|
function mayAttempt(webhook, now) {
|
|
296
168
|
return breakerState(webhook, now) !== "open";
|
|
297
169
|
}
|
|
298
|
-
/**
|
|
299
|
-
* The webhook's health after one attempt against it.
|
|
300
|
-
*
|
|
301
|
-
* A success always closes the breaker completely. Decaying the count instead
|
|
302
|
-
* would leave a destination that fails twice for every success permanently
|
|
303
|
-
* one blip from opening, and that destination is working.
|
|
304
|
-
*/
|
|
305
170
|
function afterWebhookAttempt(webhook, ok, now) {
|
|
306
171
|
if (ok) return {
|
|
307
172
|
...webhook,
|
|
@@ -350,19 +215,6 @@ function afterAttempt(delivery, outcome, now, random) {
|
|
|
350
215
|
lastError: reason
|
|
351
216
|
};
|
|
352
217
|
}
|
|
353
|
-
/**
|
|
354
|
-
* Drain one batch of due deliveries.
|
|
355
|
-
*
|
|
356
|
-
* Returns how many were attempted, so a caller can decide whether to come back
|
|
357
|
-
* immediately or wait. Deliberately one batch rather than a loop that runs
|
|
358
|
-
* forever: the thing that decides *when* to run belongs to the host, and a
|
|
359
|
-
* function that never returns cannot be tested.
|
|
360
|
-
*/
|
|
361
|
-
/**
|
|
362
|
-
* Returns how many were ATTEMPTED — deliveries skipped because their
|
|
363
|
-
* destination's breaker is open do not count, so a caller polling until the
|
|
364
|
-
* queue is quiet does not spin on a dead endpoint.
|
|
365
|
-
*/
|
|
366
218
|
async function drainOutbox(deps, limit = 20) {
|
|
367
219
|
const now = deps.now();
|
|
368
220
|
const due = await deps.storage.claimDueDeliveries(now.toISOString(), limit);
|
|
@@ -431,14 +283,6 @@ async function replayDelivery(deps, id) {
|
|
|
431
283
|
}
|
|
432
284
|
//#endregion
|
|
433
285
|
//#region src/access.ts
|
|
434
|
-
/**
|
|
435
|
-
* Whether this caller may submit this form at all, before any of the work.
|
|
436
|
-
*
|
|
437
|
-
* Every branch that is not an explicit permission returns false. An allowlist
|
|
438
|
-
* that exists but does not name the origin refuses; an origin header that is
|
|
439
|
-
* absent refuses, because absent is not the same as allowed; an empty
|
|
440
|
-
* allowlist refuses everything, because a list of no origins is a list.
|
|
441
|
-
*/
|
|
442
286
|
function maySubmit(form, actor, origin) {
|
|
443
287
|
if (actor === "authenticated") return true;
|
|
444
288
|
if (form.accessSubmit !== "public") return false;
|
|
@@ -449,14 +293,6 @@ function maySubmit(form, actor, origin) {
|
|
|
449
293
|
}
|
|
450
294
|
//#endregion
|
|
451
295
|
//#region src/uploads.ts
|
|
452
|
-
/**
|
|
453
|
-
* Agree where a file will go, and refuse it before a byte is sent.
|
|
454
|
-
*
|
|
455
|
-
* Checking here rather than after the upload is the difference between
|
|
456
|
-
* refusing a 2 GB file and receiving one first. The browser's own `accept` and
|
|
457
|
-
* size limits are a convenience for the person filling the form in; these are
|
|
458
|
-
* the rule, because somebody posting to the endpoint directly has neither.
|
|
459
|
-
*/
|
|
460
296
|
async function offerUpload(deps, input) {
|
|
461
297
|
if (!maySubmit(input.form, input.actor, input.origin)) return {
|
|
462
298
|
ok: false,
|
|
@@ -498,16 +334,6 @@ async function offerUpload(deps, input) {
|
|
|
498
334
|
file
|
|
499
335
|
};
|
|
500
336
|
}
|
|
501
|
-
/**
|
|
502
|
-
* Whether an upload's bytes may be kept.
|
|
503
|
-
*
|
|
504
|
-
* **Fail closed.** A scanner that cannot be asked refuses the file, the same as one that
|
|
505
|
-
* found something: a deployment that configured a scanner did so to keep unscanned files
|
|
506
|
-
* out, and its being down is not a reason to let one in. The person is told to try again,
|
|
507
|
-
* and the field offers exactly that. Without a scanner every upload is kept, as it was
|
|
508
|
-
* before there was one — a supported state, said in the documentation rather than here
|
|
509
|
-
* ([0131](../../../docs/decisions/0131-an-upload-is-scanned-before-it-is-kept.md)).
|
|
510
|
-
*/
|
|
511
337
|
async function screenUpload(scanner, file, bytes) {
|
|
512
338
|
if (scanner === void 0) return { ok: true };
|
|
513
339
|
let verdict;
|
|
@@ -530,24 +356,6 @@ async function screenUpload(scanner, file, bytes) {
|
|
|
530
356
|
finding: verdict.finding
|
|
531
357
|
};
|
|
532
358
|
}
|
|
533
|
-
/**
|
|
534
|
-
* Which files a submission may claim, checked but not yet claimed.
|
|
535
|
-
*
|
|
536
|
-
* The claiming itself is `insertSubmission`'s job, in the same call that
|
|
537
|
-
* stores the submission, so one COMMIT decides both — the same reasoning
|
|
538
|
-
* that put the webhook queue there. A submission exists if and only if the
|
|
539
|
-
* files it names belong to it. Claiming separately gives the two failures
|
|
540
|
-
* nobody can debug: a rolled-back submission leaving files claimed against
|
|
541
|
-
* nothing, or an accepted submission referencing bytes the collector is about
|
|
542
|
-
* to delete.
|
|
543
|
-
*
|
|
544
|
-
* Refuses rather than skips. A submission naming a file that does not exist,
|
|
545
|
-
* belongs to another form, or is already claimed is not a submission to fix up
|
|
546
|
-
* quietly: the first is a bug or somebody guessing ids; the second is how an
|
|
547
|
-
* id from a form you may submit to would let you attach a file from one you
|
|
548
|
-
* may not; the third would make deleting a submission either leak bytes or
|
|
549
|
-
* break a different one.
|
|
550
|
-
*/
|
|
551
359
|
async function filesToClaim(storage, input) {
|
|
552
360
|
const ids = referencedFileIds(input.schema, input.data);
|
|
553
361
|
if (ids.length === 0) return {
|
|
@@ -573,19 +381,10 @@ async function filesToClaim(storage, input) {
|
|
|
573
381
|
ids: claimable.map((file) => file.id)
|
|
574
382
|
};
|
|
575
383
|
}
|
|
576
|
-
/**
|
|
577
|
-
* Files nobody claimed, old enough that nobody is going to.
|
|
578
|
-
*
|
|
579
|
-
* Returns the rows rather than deleting anything: the host removes the bytes
|
|
580
|
-
* and then the rows, in that order, because a row without bytes is a broken
|
|
581
|
-
* reference and bytes without a row are invisible rubbish. Of the two, the
|
|
582
|
-
* second is the one you never find out about.
|
|
583
|
-
*/
|
|
584
384
|
async function collectAbandonedFiles(storage, now, afterHours) {
|
|
585
385
|
const before = (/* @__PURE__ */ new Date(now.getTime() - afterHours * 36e5)).toISOString();
|
|
586
386
|
return storage.abandonedFiles(before);
|
|
587
387
|
}
|
|
588
|
-
/** The field at a data path, with `[]` treated as "inside this repeater". */
|
|
589
388
|
function fileFieldAt(schema, path) {
|
|
590
389
|
const segments = path.replace(/\[\]/g, "").split(".").filter((segment) => segment !== "");
|
|
591
390
|
let fields = flattenPages(schema.model.fields);
|
|
@@ -597,14 +396,12 @@ function fileFieldAt(schema, path) {
|
|
|
597
396
|
}
|
|
598
397
|
return found;
|
|
599
398
|
}
|
|
600
|
-
/** A page contributes no segment to a data path. */
|
|
601
399
|
function flattenPages(fields) {
|
|
602
400
|
const out = [];
|
|
603
401
|
for (const field of fields) if (field.type === "page") out.push(...flattenPages(field.fields ?? []));
|
|
604
402
|
else out.push(field);
|
|
605
403
|
return out;
|
|
606
404
|
}
|
|
607
|
-
/** The HTML `accept` grammar, as the engine reads it. */
|
|
608
405
|
function accepted(accept, name, contentType) {
|
|
609
406
|
if (accept === void 0 || accept.length === 0) return true;
|
|
610
407
|
const lowerName = name.toLowerCase();
|
|
@@ -617,27 +414,12 @@ function accepted(accept, name, contentType) {
|
|
|
617
414
|
return lowerType === rule;
|
|
618
415
|
});
|
|
619
416
|
}
|
|
620
|
-
/**
|
|
621
|
-
* Every file id a submission references.
|
|
622
|
-
*
|
|
623
|
-
* Driven by the SCHEMA, not by the shape of the values. Recognising an
|
|
624
|
-
* attachment by "an object with an id" would let a client omit a property and
|
|
625
|
-
* have its reference quietly not claimed — the submission would be accepted
|
|
626
|
-
* carrying a dangling file id, and the collector would delete the bytes a day
|
|
627
|
-
* later. Reading only the paths the model says are file fields cannot be
|
|
628
|
-
* fooled that way, and a text answer that happens to look like a file is not
|
|
629
|
-
* one.
|
|
630
|
-
*
|
|
631
|
-
* A repeater contributes rows the schema cannot know about, so the walk
|
|
632
|
-
* expands over whatever rows the data actually has.
|
|
633
|
-
*/
|
|
634
417
|
function referencedFileIds(schema, data) {
|
|
635
418
|
const ids = [];
|
|
636
419
|
const walk = (fields, value) => {
|
|
637
420
|
if (value === null || typeof value !== "object" || Array.isArray(value)) return;
|
|
638
|
-
const record = value;
|
|
639
421
|
for (const field of flattenPages(fields)) {
|
|
640
|
-
const held =
|
|
422
|
+
const held = getAt(value, [field.key]);
|
|
641
423
|
if (held === void 0) continue;
|
|
642
424
|
if (field.type === "file") {
|
|
643
425
|
if (!Array.isArray(held)) continue;
|
|
@@ -657,68 +439,26 @@ function referencedFileIds(schema, data) {
|
|
|
657
439
|
walk(schema.model.fields, data);
|
|
658
440
|
return ids;
|
|
659
441
|
}
|
|
660
|
-
/** Re-exported so a host can see which paths a schema's file fields live at. */
|
|
661
442
|
function fileFieldPaths(schema) {
|
|
662
443
|
return modelDataPaths(schema.model).filter((path) => fileFieldAt(schema, path)?.type === "file");
|
|
663
444
|
}
|
|
664
445
|
//#endregion
|
|
665
446
|
//#region src/signing.ts
|
|
666
|
-
/**
|
|
667
|
-
* The keys the public plane hands out: a draft's and a response's, each an HMAC under the
|
|
668
|
-
* deployment's signing key over what it opens.
|
|
669
|
-
*
|
|
670
|
-
* Stateless, the shape the proof-of-work challenge uses
|
|
671
|
-
* ([0059](../../../docs/decisions/0059-proof-of-work-not-a-captcha.md)): handing one out is
|
|
672
|
-
* public and unauthenticated, so it must not be a write — a key kept in a table is a table an
|
|
673
|
-
* attacker fills by asking for keys they never use. Both in one file, so what each is signed
|
|
674
|
-
* over can be read side by side, and nobody has to check two files to see that one cannot
|
|
675
|
-
* stand in for the other.
|
|
676
|
-
*/
|
|
677
447
|
function signed(secret, message) {
|
|
678
448
|
return bytesToHex(hmac(sha256, utf8ToBytes(secret), utf8ToBytes(message)));
|
|
679
449
|
}
|
|
680
|
-
/**
|
|
681
|
-
* The key to a draft: HMAC over the form and the draft id
|
|
682
|
-
* ([0062](../../../docs/decisions/0062-a-draft-carries-its-own-key.md)).
|
|
683
|
-
*
|
|
684
|
-
* Bound to the FORM as well as the id so a key cannot be carried to a draft of another form
|
|
685
|
-
* that happens to share an id. The message is unchanged since 0.2.0, which is what keeps every
|
|
686
|
-
* key handed out since then opening its draft.
|
|
687
|
-
*/
|
|
688
450
|
function draftKey(secret, formId, draftId) {
|
|
689
451
|
return signed(secret, `${formId}:${draftId}`);
|
|
690
452
|
}
|
|
691
|
-
/**
|
|
692
|
-
* The token a response is sent with: the id it will be stored under, and an HMAC over the form
|
|
693
|
-
* and that id ([0169](../../../docs/decisions/0169-a-response-is-stored-once.md)).
|
|
694
|
-
*
|
|
695
|
-
* The id travels in the clear because the server has to read it back, and the signature is
|
|
696
|
-
* what makes it the server's choice rather than the sender's. The message begins with a word a
|
|
697
|
-
* form id never is, so a draft's key — signed over `form:draft` — is never also a valid
|
|
698
|
-
* signature for a response with the same id, and the reverse.
|
|
699
|
-
*/
|
|
700
453
|
function submissionToken(secret, formId, id) {
|
|
701
454
|
return `${id}.${signed(secret, `submission:${formId}:${id}`)}`;
|
|
702
455
|
}
|
|
703
|
-
/**
|
|
704
|
-
* The id a token names, when this server signed it for this form; otherwise undefined.
|
|
705
|
-
*
|
|
706
|
-
* Read from the LAST dot, since the signature is hex and the id is whatever `newId` minted. A
|
|
707
|
-
* forged token, another form's, one signed under a key this server no longer has, and one that
|
|
708
|
-
* is not a token at all are the same answer: no id.
|
|
709
|
-
*/
|
|
710
456
|
function submissionTokenId(secret, formId, token) {
|
|
711
457
|
const dot = token.lastIndexOf(".");
|
|
712
458
|
if (dot <= 0) return void 0;
|
|
713
459
|
const id = token.slice(0, dot);
|
|
714
460
|
return sameToken(token, submissionToken(secret, formId, id)) ? id : void 0;
|
|
715
461
|
}
|
|
716
|
-
/**
|
|
717
|
-
* Compared in constant time.
|
|
718
|
-
*
|
|
719
|
-
* A byte-at-a-time comparison that returns early leaks how much of a guess was
|
|
720
|
-
* right, and a token is guessed one byte at a time by exactly that signal.
|
|
721
|
-
*/
|
|
722
462
|
function sameToken(a, b) {
|
|
723
463
|
if (a.length !== b.length) return false;
|
|
724
464
|
let differences = 0;
|
|
@@ -740,13 +480,6 @@ async function resolveForm(deps, path) {
|
|
|
740
480
|
schemaHash: current.schemaHash
|
|
741
481
|
};
|
|
742
482
|
}
|
|
743
|
-
/**
|
|
744
|
-
* Change who may submit a form, and from where.
|
|
745
|
-
*
|
|
746
|
-
* Separate from publishing on purpose: access is a property of the deployment,
|
|
747
|
-
* and changing it must not mint a new immutable form version or invalidate the
|
|
748
|
-
* schema hash every client is holding.
|
|
749
|
-
*/
|
|
750
483
|
async function setFormAccess(deps, input) {
|
|
751
484
|
const form = await deps.storage.getFormByPath(input.path);
|
|
752
485
|
if (form === void 0) return { ok: false };
|
|
@@ -756,9 +489,6 @@ async function setFormAccess(deps, input) {
|
|
|
756
489
|
});
|
|
757
490
|
return { ok: true };
|
|
758
491
|
}
|
|
759
|
-
/** The submissions of one form, newest first, each tagged with the schema
|
|
760
|
-
* version that produced it. Undefined for an unknown form: the caller must
|
|
761
|
-
* 404, and an empty list would lie about that. */
|
|
762
492
|
async function listSubmissions(deps, path) {
|
|
763
493
|
const form = await deps.storage.getFormByPath(path);
|
|
764
494
|
if (form === void 0) return void 0;
|
|
@@ -771,16 +501,6 @@ async function listSubmissions(deps, path) {
|
|
|
771
501
|
data: record.data
|
|
772
502
|
}));
|
|
773
503
|
}
|
|
774
|
-
/**
|
|
775
|
-
* Every submission of a form as CSV, columns UNIONED across schema versions:
|
|
776
|
-
* the current version's columns lead in its own order, and columns that only
|
|
777
|
-
* exist in older versions follow — data outlives the field that collected it,
|
|
778
|
-
* and an export that silently dropped extinct columns would lose it.
|
|
779
|
-
*
|
|
780
|
-
* A repeater is one column carrying its rows as JSON: rows-per-cell beats
|
|
781
|
-
* exploding one submission across several CSV lines, which breaks every
|
|
782
|
-
* spreadsheet join a business user will try.
|
|
783
|
-
*/
|
|
784
504
|
async function exportCsv(deps, path) {
|
|
785
505
|
const form = await deps.storage.getFormByPath(path);
|
|
786
506
|
if (form === void 0) return void 0;
|
|
@@ -810,8 +530,6 @@ async function exportCsv(deps, path) {
|
|
|
810
530
|
}
|
|
811
531
|
return lines.join("\n") + "\n";
|
|
812
532
|
}
|
|
813
|
-
/** One column per collected answer: leaves as dotted paths through groups,
|
|
814
|
-
* a repeater as a single column, pages transparent as always. */
|
|
815
533
|
function dataColumns(schema) {
|
|
816
534
|
const columns = [];
|
|
817
535
|
const walk = (fields, prefix) => {
|
|
@@ -823,11 +541,7 @@ function dataColumns(schema) {
|
|
|
823
541
|
return columns;
|
|
824
542
|
}
|
|
825
543
|
function cellValue(data, column) {
|
|
826
|
-
|
|
827
|
-
for (const segment of column.split(".")) {
|
|
828
|
-
if (current === null || typeof current !== "object") return "";
|
|
829
|
-
current = current[segment];
|
|
830
|
-
}
|
|
544
|
+
const current = getAt(data, column.split("."));
|
|
831
545
|
if (current === void 0 || current === null) return "";
|
|
832
546
|
if (typeof current === "object") return JSON.stringify(current);
|
|
833
547
|
if (typeof current === "string" && FORMULA_STARTERS.has(current.charAt(0))) return "'" + current;
|
|
@@ -836,18 +550,6 @@ function cellValue(data, column) {
|
|
|
836
550
|
function csvCell(value) {
|
|
837
551
|
return /[",\n\r]/.test(value) ? `"${value.replaceAll("\"", "\"\"")}"` : value;
|
|
838
552
|
}
|
|
839
|
-
/**
|
|
840
|
-
* Start a draft: the server picks the id, and signs it.
|
|
841
|
-
*
|
|
842
|
-
* The id is the server's because an id a caller supplies is an id a caller can
|
|
843
|
-
* enumerate — and enumerating them was enough to read other people's
|
|
844
|
-
* part-filled answers.
|
|
845
|
-
*
|
|
846
|
-
* A draft is one response, so it comes with the token that response is sent with, naming the
|
|
847
|
-
* draft's own id ([0169](../../../docs/decisions/0169-a-response-is-stored-once.md)). Resuming
|
|
848
|
-
* hands back the same one, so a response whose answer was lost and is sent again after a reload
|
|
849
|
-
* is refused as already sent rather than stored twice.
|
|
850
|
-
*/
|
|
851
553
|
async function startDraft(deps, input) {
|
|
852
554
|
const current = await resolveForm(deps, input.path);
|
|
853
555
|
if (current === void 0) return void 0;
|
|
@@ -858,8 +560,6 @@ async function startDraft(deps, input) {
|
|
|
858
560
|
submissionToken: submissionToken(deps.draftSecret, current.formId, draftId)
|
|
859
561
|
};
|
|
860
562
|
}
|
|
861
|
-
/** Save (or re-save) a partial form, bound to the CURRENT version. Undefined
|
|
862
|
-
* for an unknown form. */
|
|
863
563
|
async function saveDraft(deps, input) {
|
|
864
564
|
const current = await resolveForm(deps, input.path);
|
|
865
565
|
if (current === void 0) return void 0;
|
|
@@ -879,18 +579,6 @@ async function saveDraft(deps, input) {
|
|
|
879
579
|
saved: true
|
|
880
580
|
};
|
|
881
581
|
}
|
|
882
|
-
/**
|
|
883
|
-
* Resume a draft — and migrate it LAZILY if the form was republished since,
|
|
884
|
-
* which is versioning rule five: never migrate eagerly on publish, because
|
|
885
|
-
* most drafts are abandoned and eager migration multiplies every publish by
|
|
886
|
-
* every draft.
|
|
887
|
-
*
|
|
888
|
-
* diffSchemas drives the decision: `compatible` rebinds silently, `lossy`
|
|
889
|
-
* rebinds with a report — declared renames carry their value to the new key,
|
|
890
|
-
* removed fields move their value into `__orphaned` rather than deleting it —
|
|
891
|
-
* and `breaking` refuses to rebind at all. A successful rebind is persisted,
|
|
892
|
-
* so the migration runs once, not on every resume.
|
|
893
|
-
*/
|
|
894
582
|
async function resumeDraft(deps, input) {
|
|
895
583
|
const current = await resolveForm(deps, input.path);
|
|
896
584
|
if (current === void 0) return void 0;
|
|
@@ -974,33 +662,26 @@ function applyRenames(fields, data) {
|
|
|
974
662
|
applyRenames(field.fields ?? [], data);
|
|
975
663
|
continue;
|
|
976
664
|
}
|
|
977
|
-
if (field.renamedFrom !== void 0 && !(field.key
|
|
665
|
+
if (field.renamedFrom !== void 0 && !Object.hasOwn(data, field.key) && Object.hasOwn(data, field.renamedFrom)) {
|
|
978
666
|
data[field.key] = data[field.renamedFrom];
|
|
979
667
|
delete data[field.renamedFrom];
|
|
980
668
|
}
|
|
981
|
-
|
|
669
|
+
const held = getAt(data, [field.key]);
|
|
670
|
+
if (field.type === "group" && typeof held === "object" && held !== null) applyRenames(field.fields ?? [], held);
|
|
982
671
|
}
|
|
983
672
|
}
|
|
984
|
-
/** Remove and return the value at a dotted path, pruning nothing else. */
|
|
985
673
|
function takeAtDottedPath(data, dottedPath) {
|
|
986
674
|
const segments = dottedPath.split(".");
|
|
987
|
-
|
|
988
|
-
for (const segment of segments.slice(0, -1)) {
|
|
989
|
-
const next = parent[segment];
|
|
990
|
-
if (next === null || typeof next !== "object" || Array.isArray(next)) return void 0;
|
|
991
|
-
parent = next;
|
|
992
|
-
}
|
|
675
|
+
const parent = getAt(data, segments.slice(0, -1));
|
|
993
676
|
const last = segments[segments.length - 1];
|
|
677
|
+
if (parent === null || typeof parent !== "object" || Array.isArray(parent) || !Object.hasOwn(parent, last)) return;
|
|
994
678
|
const value = parent[last];
|
|
995
679
|
delete parent[last];
|
|
996
680
|
return value;
|
|
997
681
|
}
|
|
998
|
-
/** JSON-safe deep clone: draft data is JSON by construction, and this package
|
|
999
|
-
* runs without structuredClone in its lib on purpose. */
|
|
1000
682
|
function structuredCloneJson(value) {
|
|
1001
683
|
return JSON.parse(JSON.stringify(value));
|
|
1002
684
|
}
|
|
1003
|
-
/** Every form with its current version — the admin's landing view. */
|
|
1004
685
|
async function listForms(deps) {
|
|
1005
686
|
const forms = await deps.storage.listForms();
|
|
1006
687
|
const listed = [];
|
|
@@ -1017,8 +698,6 @@ async function listForms(deps) {
|
|
|
1017
698
|
}
|
|
1018
699
|
return listed;
|
|
1019
700
|
}
|
|
1020
|
-
/** A form's version history, newest first — metadata only, because a history
|
|
1021
|
-
* view rendering fifty full schemas would be its own denial of service. */
|
|
1022
701
|
async function listVersions(deps, path) {
|
|
1023
702
|
const form = await deps.storage.getFormByPath(path);
|
|
1024
703
|
if (form === void 0) return void 0;
|
|
@@ -1028,17 +707,30 @@ async function listVersions(deps, path) {
|
|
|
1028
707
|
title: version.schema.title
|
|
1029
708
|
}));
|
|
1030
709
|
}
|
|
710
|
+
async function formVersion(deps, path, version) {
|
|
711
|
+
const form = await deps.storage.getFormByPath(path);
|
|
712
|
+
if (form === void 0) return {
|
|
713
|
+
ok: false,
|
|
714
|
+
kind: "unknown_form"
|
|
715
|
+
};
|
|
716
|
+
if (!/^[1-9]\d{0,8}$/.test(version)) return {
|
|
717
|
+
ok: false,
|
|
718
|
+
kind: "unknown_version"
|
|
719
|
+
};
|
|
720
|
+
const found = (await deps.storage.listVersionsByForm(form.id)).find((candidate) => candidate.version === Number(version));
|
|
721
|
+
if (found === void 0) return {
|
|
722
|
+
ok: false,
|
|
723
|
+
kind: "unknown_version"
|
|
724
|
+
};
|
|
725
|
+
return {
|
|
726
|
+
ok: true,
|
|
727
|
+
version: found.version,
|
|
728
|
+
schemaHash: found.schemaHash,
|
|
729
|
+
schema: found.schema
|
|
730
|
+
};
|
|
731
|
+
}
|
|
1031
732
|
//#endregion
|
|
1032
733
|
//#region src/submitting.ts
|
|
1033
|
-
/**
|
|
1034
|
-
* The form a respondent fills in, with the token their response is sent with.
|
|
1035
|
-
*
|
|
1036
|
-
* A fresh id on every call, because every reading is a response that might be sent: two
|
|
1037
|
-
* people handed one token would find the second refused as already sent. Minting writes
|
|
1038
|
-
* nothing — an HMAC over the form and the id — so handing one to everybody who reads the form
|
|
1039
|
-
* costs this server a hash and fills no table
|
|
1040
|
-
* ([0169](../../../docs/decisions/0169-a-response-is-stored-once.md)).
|
|
1041
|
-
*/
|
|
1042
734
|
async function formToFill(deps, path) {
|
|
1043
735
|
const resolved = await resolveForm(deps, path);
|
|
1044
736
|
if (resolved === void 0) return void 0;
|
|
@@ -1047,20 +739,6 @@ async function formToFill(deps, path) {
|
|
|
1047
739
|
submissionToken: submissionToken(deps.draftSecret, resolved.formId, deps.newId())
|
|
1048
740
|
};
|
|
1049
741
|
}
|
|
1050
|
-
/**
|
|
1051
|
-
* Accept one submission: resolve the version the client says it rendered,
|
|
1052
|
-
* replay the engine over the submitted data, and store the CANONICAL result.
|
|
1053
|
-
*
|
|
1054
|
-
* The replay is the whole point. Computed values are recomputed and overwrite
|
|
1055
|
-
* whatever arrived; visibility is evaluated server-side and hidden branches
|
|
1056
|
-
* are stripped, so a client cannot smuggle data by lying about what was shown.
|
|
1057
|
-
* Client validation is UX; this is truth.
|
|
1058
|
-
*
|
|
1059
|
-
* And it is stored once. A response is stored under the id its token names, and the storage
|
|
1060
|
-
* refuses that id twice, so the same response sent again — a retry after an answer that never
|
|
1061
|
-
* arrived, a replay — is told it was sent rather than stored as somebody else
|
|
1062
|
-
* ([0169](../../../docs/decisions/0169-a-response-is-stored-once.md)).
|
|
1063
|
-
*/
|
|
1064
742
|
async function createSubmission(deps, input) {
|
|
1065
743
|
const form = await deps.storage.getFormByPath(input.path);
|
|
1066
744
|
if (form === void 0) return {
|
|
@@ -1104,7 +782,7 @@ async function createSubmission(deps, input) {
|
|
|
1104
782
|
};
|
|
1105
783
|
const engine = createFormEngine({
|
|
1106
784
|
schema: current.schema,
|
|
1107
|
-
initialValue: input.data,
|
|
785
|
+
initialValue: input.data ?? {},
|
|
1108
786
|
capabilities: deps.capabilities,
|
|
1109
787
|
mode: "server",
|
|
1110
788
|
...deps.checks === void 0 ? {} : { checks: deps.checks }
|
|
@@ -1184,7 +862,6 @@ async function createSubmission(deps, input) {
|
|
|
1184
862
|
}
|
|
1185
863
|
//#endregion
|
|
1186
864
|
//#region src/redos.ts
|
|
1187
|
-
/** Every `pattern` in the model, with the data path of the field it sits on. */
|
|
1188
865
|
function patternsIn(fields, prefix) {
|
|
1189
866
|
const found = [];
|
|
1190
867
|
for (const field of fields) {
|
|
@@ -1197,15 +874,6 @@ function patternsIn(fields, prefix) {
|
|
|
1197
874
|
}
|
|
1198
875
|
return found;
|
|
1199
876
|
}
|
|
1200
|
-
/**
|
|
1201
|
-
* The patterns in this schema that an attacker could exploit, or an empty array
|
|
1202
|
-
* when there are none.
|
|
1203
|
-
*
|
|
1204
|
-
* A pattern `recheck` cannot decide about is ACCEPTED. Refusing on `unknown`
|
|
1205
|
-
* would make publishing depend on an analysis timeout, which is a worse failure
|
|
1206
|
-
* than the one being prevented — an author whose legitimate form stops
|
|
1207
|
-
* publishing intermittently has no way to act on that.
|
|
1208
|
-
*/
|
|
1209
877
|
async function unsafePatterns(schema) {
|
|
1210
878
|
const unsafe = [];
|
|
1211
879
|
for (const { path, pattern } of patternsIn(schema.model.fields, "")) {
|
|
@@ -1222,11 +890,6 @@ async function unsafePatterns(schema) {
|
|
|
1222
890
|
}
|
|
1223
891
|
//#endregion
|
|
1224
892
|
//#region src/testing/memory-storage.ts
|
|
1225
|
-
/**
|
|
1226
|
-
* The in-memory Storage — the second implementation that keeps the port
|
|
1227
|
-
* honest, and the one unit tests run against. Deliberately dumb: Maps and
|
|
1228
|
-
* scans, no cleverness to accidentally diverge from SQL semantics.
|
|
1229
|
-
*/
|
|
1230
893
|
function createMemoryStorage() {
|
|
1231
894
|
const forms = /* @__PURE__ */ new Map();
|
|
1232
895
|
const webhooks = /* @__PURE__ */ new Map();
|
|
@@ -1418,11 +1081,6 @@ async function createLocalUser(deps, input) {
|
|
|
1418
1081
|
id
|
|
1419
1082
|
};
|
|
1420
1083
|
}
|
|
1421
|
-
/**
|
|
1422
|
-
* A wrong password and an unknown email answer identically, and BOTH run a
|
|
1423
|
-
* hash verification — the difference in shape or timing would be a
|
|
1424
|
-
* user-enumeration oracle.
|
|
1425
|
-
*/
|
|
1426
1084
|
async function authenticateLocal(deps, input) {
|
|
1427
1085
|
const user = await deps.storage.getUserByEmail(input.email);
|
|
1428
1086
|
const hash = user?.passwordHash ?? await deps.hashSecret(`decoy:${deps.newId()}`);
|
|
@@ -1437,8 +1095,6 @@ async function authenticateLocal(deps, input) {
|
|
|
1437
1095
|
}
|
|
1438
1096
|
};
|
|
1439
1097
|
}
|
|
1440
|
-
/** The visible prefix of an API key: enough to index and to name in a UI
|
|
1441
|
-
* ("fmc_tok_a2c…"), never enough to authenticate. */
|
|
1442
1098
|
const KEY_PREFIX_LENGTH = 12;
|
|
1443
1099
|
async function createApiKey(deps, input) {
|
|
1444
1100
|
if (input.name.trim() === "") return {
|
|
@@ -1499,26 +1155,6 @@ function can(actor, action) {
|
|
|
1499
1155
|
}
|
|
1500
1156
|
//#endregion
|
|
1501
1157
|
//#region src/address.ts
|
|
1502
|
-
/**
|
|
1503
|
-
* Deciding whether an address is somewhere this server may fetch from.
|
|
1504
|
-
*
|
|
1505
|
-
* A self-hosted formancy sits inside somebody's private network and webhook
|
|
1506
|
-
* URLs are written by whoever can edit a form, which makes the server a
|
|
1507
|
-
* confused deputy: it can reach the database, the metadata service and every
|
|
1508
|
-
* internal admin panel, and it will fetch whatever it is told to.
|
|
1509
|
-
*
|
|
1510
|
-
* This half is pure — given an address, is it private — so it lives here and is
|
|
1511
|
-
* tested exhaustively. The rest lives in @formancy/server, because it cannot be
|
|
1512
|
-
* pure: parsing a URL needs `URL`, and resolving a hostname then connecting to
|
|
1513
|
-
* the address that was checked, rather than re-resolving, needs Node's DNS and
|
|
1514
|
-
* a custom agent. This package has no @types/node by design, and hand-rolling
|
|
1515
|
-
* a URL parser for a security check is how such a check gets quietly wrong.
|
|
1516
|
-
* All three parts are necessary. Validating a URL and then handing it to fetch is defeated by DNS
|
|
1517
|
-
* rebinding — the name resolves to a public address for the check and a private
|
|
1518
|
-
* one for the request — and that is the difference between a mitigation and
|
|
1519
|
-
* theatre.
|
|
1520
|
-
*/
|
|
1521
|
-
/** Everything before the first dot, as four octets, or undefined. */
|
|
1522
1158
|
function ipv4Octets(address) {
|
|
1523
1159
|
const parts = address.split(".");
|
|
1524
1160
|
if (parts.length !== 4) return void 0;
|
|
@@ -1529,12 +1165,6 @@ function ipv4Octets(address) {
|
|
|
1529
1165
|
if (octets.some((octet) => Number.isNaN(octet) || octet > 255)) return void 0;
|
|
1530
1166
|
return octets;
|
|
1531
1167
|
}
|
|
1532
|
-
/**
|
|
1533
|
-
* Whether this address is one the server must not be pointed at.
|
|
1534
|
-
*
|
|
1535
|
-
* Unparseable input counts as private. An address this cannot classify is one
|
|
1536
|
-
* it cannot vouch for, and the safe direction is refusing to fetch.
|
|
1537
|
-
*/
|
|
1538
1168
|
function isPrivateAddress(address) {
|
|
1539
1169
|
const trimmed = address.trim().toLowerCase();
|
|
1540
1170
|
if (trimmed === "") return true;
|
|
@@ -1565,14 +1195,6 @@ function isPrivateAddress(address) {
|
|
|
1565
1195
|
}
|
|
1566
1196
|
return true;
|
|
1567
1197
|
}
|
|
1568
|
-
/**
|
|
1569
|
-
* Whether this is a literal IP address rather than a hostname.
|
|
1570
|
-
*
|
|
1571
|
-
* The two need different treatment and conflating them breaks the check in
|
|
1572
|
-
* both directions: `isPrivateAddress` fails closed on anything it cannot
|
|
1573
|
-
* parse, which is correct for an address that came back from DNS and wrong for
|
|
1574
|
-
* `example.ch`, where it would refuse every hostname in the world.
|
|
1575
|
-
*/
|
|
1576
1198
|
function isIpLiteral(host) {
|
|
1577
1199
|
const bare = host.trim().replace(/^\[|\]$/g, "");
|
|
1578
1200
|
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(bare)) return true;
|
|
@@ -1580,7 +1202,6 @@ function isIpLiteral(host) {
|
|
|
1580
1202
|
}
|
|
1581
1203
|
//#endregion
|
|
1582
1204
|
//#region src/examples.ts
|
|
1583
|
-
/** The examples kept for the form at `path`; none, for a form nobody wrote any for. */
|
|
1584
1205
|
async function readExamples(deps, input) {
|
|
1585
1206
|
if (!can(input.actor, "form.publish")) return {
|
|
1586
1207
|
ok: false,
|
|
@@ -1600,14 +1221,6 @@ async function readExamples(deps, input) {
|
|
|
1600
1221
|
}
|
|
1601
1222
|
};
|
|
1602
1223
|
}
|
|
1603
|
-
/**
|
|
1604
|
-
* Replace the examples of the form at `path`, and the sample they start from.
|
|
1605
|
-
*
|
|
1606
|
-
* Each is read as an example the way a builder reads a model's draft — `readScenario`, one
|
|
1607
|
-
* decision for both — and a list with any item that is not one keeps nothing. Paths are not
|
|
1608
|
-
* checked against the published form: an example is written for the form being edited, which
|
|
1609
|
-
* may name a field no version has yet, and the runner says so when it runs.
|
|
1610
|
-
*/
|
|
1611
1224
|
async function keepExamples(deps, input) {
|
|
1612
1225
|
if (!can(input.actor, "form.publish")) return {
|
|
1613
1226
|
ok: false,
|
|
@@ -1647,23 +1260,6 @@ async function keepExamples(deps, input) {
|
|
|
1647
1260
|
examples: asExamples(read.scenarios, read.sample)
|
|
1648
1261
|
};
|
|
1649
1262
|
}
|
|
1650
|
-
/**
|
|
1651
|
-
* What publishing says about the form's examples: one sentence for each that held against the
|
|
1652
|
-
* version the form has and does not hold against the one being published.
|
|
1653
|
-
*
|
|
1654
|
-
* **Run as the server replays a submission**, in `server` mode, from the kept sample, and
|
|
1655
|
-
* compared by `comparedToLastRun` — the scenario panel's own verdict, so the publish cannot
|
|
1656
|
-
* call something a regression the panel would not
|
|
1657
|
-
* ([0091](../../../docs/decisions/0091-a-second-builder-is-a-binding.md)). An example that did
|
|
1658
|
-
* not hold before either is not this publish's doing, and one that holds again is good news;
|
|
1659
|
-
* neither is a warning.
|
|
1660
|
-
*
|
|
1661
|
-
* **It never refuses.** A publish may warn and never refuses over its examples
|
|
1662
|
-
* ([0097](../../../docs/decisions/0097-a-publish-may-warn.md)): a rule changed on purpose stops
|
|
1663
|
-
* its old example holding, and the person decides. A stored row that is not an example — one
|
|
1664
|
-
* edited around `keepExamples` — is read out before the run, as the admin reads it out, and
|
|
1665
|
-
* named; run as it was, the runner throws on it and every publish of the form is a 500.
|
|
1666
|
-
*/
|
|
1667
1263
|
async function examplesThatStopHolding(storage, formId, before, after) {
|
|
1668
1264
|
if (before === void 0) return [];
|
|
1669
1265
|
const kept = readKept(await storage.getExamples(formId));
|
|
@@ -1686,7 +1282,6 @@ function asExamples(scenarios, sample) {
|
|
|
1686
1282
|
...sample === null ? {} : { sample }
|
|
1687
1283
|
};
|
|
1688
1284
|
}
|
|
1689
|
-
/** `{ scenarios, sample? }` from a request body, or a sentence for everything that is not. */
|
|
1690
1285
|
function readExamplesBody(body) {
|
|
1691
1286
|
if (!isRecord(body) || !Array.isArray(body["scenarios"])) return { problems: ["Send { scenarios, sample? }, where scenarios is a list of examples."] };
|
|
1692
1287
|
const read = readList(body["scenarios"], body["sample"]);
|
|
@@ -1695,11 +1290,6 @@ function readExamplesBody(body) {
|
|
|
1695
1290
|
sample: read.sample
|
|
1696
1291
|
};
|
|
1697
1292
|
}
|
|
1698
|
-
/**
|
|
1699
|
-
* What a storage keeps, read as a list sent to be kept is read. The storage checks nothing
|
|
1700
|
-
* (`Storage.keepExamples`), so a row written around the use-case comes back here as it was
|
|
1701
|
-
* written, and only what reads as an example is handed on.
|
|
1702
|
-
*/
|
|
1703
1293
|
function readKept(kept) {
|
|
1704
1294
|
if (kept === void 0) return {
|
|
1705
1295
|
scenarios: [],
|
|
@@ -1714,7 +1304,6 @@ function readKept(kept) {
|
|
|
1714
1304
|
problems: ["What is kept as the examples is not a list of them.", ...read.problems]
|
|
1715
1305
|
};
|
|
1716
1306
|
}
|
|
1717
|
-
/** Each item through `readScenario`, names once each, and the sample as answers by field. */
|
|
1718
1307
|
function readList(items, sample) {
|
|
1719
1308
|
const problems = [];
|
|
1720
1309
|
if (sample !== void 0 && sample !== null && !isRecord(sample)) problems.push("The sample is the answers every example starts from, by field, and this is not that.");
|
|
@@ -1744,7 +1333,6 @@ function readList(items, sample) {
|
|
|
1744
1333
|
function which(position, name) {
|
|
1745
1334
|
return `Example ${String(position)}${name === void 0 ? "" : ` ("${name}")`}`;
|
|
1746
1335
|
}
|
|
1747
|
-
/** What each reason `readScenario` gives means, said for whoever sent the list. */
|
|
1748
1336
|
const SHAPE_PROBLEMS = {
|
|
1749
1337
|
"not-an-object": () => "is not an object.",
|
|
1750
1338
|
"unknown-key": ({ part }) => `has "${part ?? ""}", which an example does not have.`,
|
|
@@ -1758,15 +1346,6 @@ function isRecord(value) {
|
|
|
1758
1346
|
}
|
|
1759
1347
|
//#endregion
|
|
1760
1348
|
//#region src/publishing.ts
|
|
1761
|
-
/**
|
|
1762
|
-
* Publish a schema as a form's next version.
|
|
1763
|
-
*
|
|
1764
|
-
* This is the save gate: structural validation, then the ENGINE's own compile
|
|
1765
|
-
* — type-checked expressions, kind policies and the cycle graph — so a form
|
|
1766
|
-
* that can loop or read a ghost field is refused here and never persisted.
|
|
1767
|
-
* Republishing the identical document is idempotent, because "deploy again"
|
|
1768
|
-
* must never manufacture a version.
|
|
1769
|
-
*/
|
|
1770
1349
|
async function publishForm(deps, input) {
|
|
1771
1350
|
const validated = validateSchema(input.schema);
|
|
1772
1351
|
if (!validated.valid) return {
|
|
@@ -1800,8 +1379,9 @@ async function publishForm(deps, input) {
|
|
|
1800
1379
|
kind: "unsafe_pattern",
|
|
1801
1380
|
patterns: unsafe
|
|
1802
1381
|
};
|
|
1803
|
-
|
|
1804
|
-
|
|
1382
|
+
const sources = deps.optionsSources;
|
|
1383
|
+
if (sources !== void 0) {
|
|
1384
|
+
const unknown = sourceNamesIn(schema).filter((name) => !Object.hasOwn(sources, name) || sources[name] === void 0);
|
|
1805
1385
|
if (unknown.length > 0) return {
|
|
1806
1386
|
ok: false,
|
|
1807
1387
|
kind: "unknown_options_source",
|
|
@@ -1904,14 +1484,6 @@ async function publishForm(deps, input) {
|
|
|
1904
1484
|
warnings
|
|
1905
1485
|
};
|
|
1906
1486
|
}
|
|
1907
|
-
/**
|
|
1908
|
-
* The audit row for a publish, built here so it can travel INSIDE the
|
|
1909
|
-
* transaction rather than being appended after it.
|
|
1910
|
-
*
|
|
1911
|
-
* Before this it was written by the route once all three storage calls had
|
|
1912
|
-
* returned, which meant a publish could half-apply and still be recorded as
|
|
1913
|
-
* having happened. Now it rolls back with everything else.
|
|
1914
|
-
*/
|
|
1915
1487
|
function publishAudit(deps, input, about) {
|
|
1916
1488
|
return {
|
|
1917
1489
|
id: deps.newId(),
|
|
@@ -1932,13 +1504,6 @@ function publishAudit(deps, input, about) {
|
|
|
1932
1504
|
}
|
|
1933
1505
|
//#endregion
|
|
1934
1506
|
//#region src/model.ts
|
|
1935
|
-
/**
|
|
1936
|
-
* Ask the deployment's model a builder's request, under the briefing for its kind.
|
|
1937
|
-
*
|
|
1938
|
-
* Whatever else the request carries is not read — a `system` above all. Resolves on every
|
|
1939
|
-
* ending: an adapter that throws broke its contract, and is still a model that could not
|
|
1940
|
-
* be asked.
|
|
1941
|
-
*/
|
|
1942
1507
|
async function completeBuilderRequest(completer, request, cancellation) {
|
|
1943
1508
|
const { kind, user } = request;
|
|
1944
1509
|
if (!isModelRequestKind(kind)) return {
|
|
@@ -1963,6 +1528,6 @@ async function completeBuilderRequest(completer, request, cancellation) {
|
|
|
1963
1528
|
}
|
|
1964
1529
|
}
|
|
1965
1530
|
//#endregion
|
|
1966
|
-
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, completeBuilderRequest, createApiKey, createLocalUser, createMemoryStorage, createSubmission, decodeSolution, deliveryHeaders, drainOutbox, encodeSolution, exportCsv, fileFieldPaths, filesToClaim, formToFill, hashOf, healthOf, isIpLiteral, isPrivateAddress, keepExamples, listForms, listSubmissions, listVersions, mayAttempt, mintChallenge, offerUpload, publishForm, readExamples, replayDelivery, resolveForm, resumeDraft, retryDelayMs, saveDraft, screenUpload, setFormAccess, signBody, signedPayload, solveChallenge, sourceNamesIn, sourcedAnswers, startDraft, unsafePatterns, verifySignature, verifySolution };
|
|
1531
|
+
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, completeBuilderRequest, createApiKey, createLocalUser, createMemoryStorage, createSubmission, decodeSolution, deliveryHeaders, drainOutbox, encodeSolution, exportCsv, fileFieldPaths, filesToClaim, formToFill, formVersion, hashOf, healthOf, isIpLiteral, isPrivateAddress, keepExamples, listForms, listSubmissions, listVersions, mayAttempt, mintChallenge, offerUpload, publishForm, readExamples, replayDelivery, resolveForm, resumeDraft, retryDelayMs, saveDraft, screenUpload, setFormAccess, signBody, signedPayload, solveChallenge, sourceNamesIn, sourcedAnswers, startDraft, unsafePatterns, verifySignature, verifySolution };
|
|
1967
1532
|
|
|
1968
1533
|
//# sourceMappingURL=index.mjs.map
|