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