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