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