@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.d.mts
CHANGED
|
@@ -1,79 +1,88 @@
|
|
|
1
|
+
import { CHALLENGE_TTL_SECONDS, Challenge, DEFAULT_MAX_NUMBER, Solution, VerifyOutcome, decodeSolution, encodeSolution, hashOf, mintChallenge, solveChallenge, verifySolution } from "@formancy/challenge";
|
|
1
2
|
import { Change, FormSchema } from "@formancy/spec";
|
|
2
3
|
import { SchemaError } from "@formancy/spec/validate";
|
|
3
4
|
import { CapabilitySource } from "@formancy/core";
|
|
4
|
-
//#region src/
|
|
5
|
+
//#region src/options-membership.d.ts
|
|
5
6
|
/**
|
|
6
|
-
*
|
|
7
|
+
* Checking that a sourced answer is one the deployment's own source offers.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* The engine already refuses a value no *document* option offers
|
|
10
|
+
* ([0076](../../../docs/decisions/0076-an-answer-is-one-of-the-options.md)), and
|
|
11
|
+
* it runs on both sides. A `optionsSource` field has no document options, so that
|
|
12
|
+
* check has nothing to compare against — deliberately, because a list living
|
|
13
|
+
* outside the document cannot be checked against the document.
|
|
14
|
+
*
|
|
15
|
+
* So the server asks the deployment instead, and the deployment answers with a
|
|
16
|
+
* database query rather than with a request to an address a form author typed.
|
|
17
|
+
* That is why the port takes a function and this package ships no HTTP client:
|
|
18
|
+
* `@formancy/server-core` imports no `URL` and no `fetch`, and adding an outbound
|
|
19
|
+
* request here would add confused-deputy surface to a regulatory set that claims
|
|
20
|
+
* none.
|
|
12
21
|
*/
|
|
13
|
-
|
|
14
|
-
interface
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
/** One named source, as the deployment configured it. */
|
|
23
|
+
interface ServerOptionsSource {
|
|
24
|
+
/**
|
|
25
|
+
* Which of these values this source does NOT offer.
|
|
26
|
+
*
|
|
27
|
+
* Phrased as the rejects rather than the members so an adapter can answer from a
|
|
28
|
+
* SQL `IN` clause without materialising a list — a source with two million rows
|
|
29
|
+
* is exactly the case `optionsSource` exists for.
|
|
30
|
+
*
|
|
31
|
+
* Absent means: *this source exists, and I cannot check membership.* That is a
|
|
32
|
+
* legitimate configuration and it weakens the guarantee, which is why the
|
|
33
|
+
* guarantee is written down rather than implied.
|
|
34
|
+
*/
|
|
35
|
+
members?(values: readonly string[]): Promise<readonly string[]>;
|
|
27
36
|
}
|
|
28
|
-
type
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
type ServerOptionsSources = Readonly<Record<string, ServerOptionsSource>>;
|
|
38
|
+
/**
|
|
39
|
+
* Every sourced answer in a submission, as `path → { source, value }`.
|
|
40
|
+
*
|
|
41
|
+
* Walked from the SCHEMA over the value, never from the value's own shape — the
|
|
42
|
+
* same way `referencedFileIds` does it. A walk driven by what arrived would let a
|
|
43
|
+
* client dodge the check by omitting a property, and hidden branches have already
|
|
44
|
+
* been stripped by the engine before this runs.
|
|
45
|
+
*/
|
|
46
|
+
export declare function sourcedAnswers(schema: FormSchema, value: unknown): ReadonlyArray<{
|
|
47
|
+
path: string;
|
|
48
|
+
source: string;
|
|
49
|
+
value: string;
|
|
50
|
+
}>;
|
|
51
|
+
/** What asking the deployment produced. */
|
|
52
|
+
type MembershipOutcome = {
|
|
41
53
|
ok: true;
|
|
42
|
-
|
|
43
|
-
|
|
54
|
+
} |
|
|
55
|
+
/** Values the deployment's own source does not offer, by field path. */
|
|
56
|
+
{
|
|
44
57
|
ok: false;
|
|
45
|
-
|
|
58
|
+
kind: 'invalid';
|
|
59
|
+
errors: Record<string, string[]>;
|
|
60
|
+
} |
|
|
46
61
|
/**
|
|
47
|
-
* A
|
|
48
|
-
*
|
|
49
|
-
*
|
|
62
|
+
* A source could not answer.
|
|
63
|
+
*
|
|
64
|
+
* Failed CLOSED, always. An accepted bogus value is undetectable afterwards; a
|
|
65
|
+
* refusal is retryable and the draft still holds the answers. It is the same rule
|
|
66
|
+
* the engine applies to a validation rule it cannot evaluate.
|
|
50
67
|
*/
|
|
51
|
-
|
|
52
|
-
email: string;
|
|
53
|
-
password: string;
|
|
54
|
-
}): Promise<AuthOutcome>;
|
|
55
|
-
type CreateApiKeyOutcome = {
|
|
56
|
-
ok: true;
|
|
57
|
-
id: string;
|
|
58
|
-
secret: string;
|
|
59
|
-
} | {
|
|
68
|
+
{
|
|
60
69
|
ok: false;
|
|
61
|
-
|
|
70
|
+
kind: 'source_unavailable';
|
|
71
|
+
source: string;
|
|
62
72
|
};
|
|
63
|
-
export declare function createApiKey(deps: AuthDeps, input: {
|
|
64
|
-
name: string;
|
|
65
|
-
role: Role;
|
|
66
|
-
}): Promise<CreateApiKeyOutcome>;
|
|
67
|
-
export declare function authenticateApiKey(deps: AuthDeps, presented: string): Promise<AuthOutcome>;
|
|
68
73
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* per
|
|
72
|
-
*
|
|
73
|
-
* omission.
|
|
74
|
+
* Ask each source about the values submitted for it.
|
|
75
|
+
*
|
|
76
|
+
* One call per source rather than per answer, so a deployment sees `IN (…)` and not
|
|
77
|
+
* a query per row.
|
|
74
78
|
*/
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
export declare function checkMembership(schema: FormSchema, value: unknown, sources: ServerOptionsSources | undefined): Promise<MembershipOutcome>;
|
|
80
|
+
/**
|
|
81
|
+
* The source names a document uses, for the publish-time vocabulary check.
|
|
82
|
+
*
|
|
83
|
+
* Walked over the schema rather than over any data, because publishing has none.
|
|
84
|
+
*/
|
|
85
|
+
export declare function sourceNamesIn(schema: FormSchema): readonly string[];
|
|
77
86
|
//#endregion
|
|
78
87
|
//#region src/ports.d.ts
|
|
79
88
|
/**
|
|
@@ -85,6 +94,22 @@ interface FormRecord {
|
|
|
85
94
|
id: string;
|
|
86
95
|
path: string;
|
|
87
96
|
currentVersionId: string | null;
|
|
97
|
+
/**
|
|
98
|
+
* Who may submit. Deliberately NOT part of the form document: a document has
|
|
99
|
+
* to mean the same thing wherever it is moved, and carrying "anyone may
|
|
100
|
+
* submit this" across a deployment boundary is how a private form becomes a
|
|
101
|
+
* public one by accident.
|
|
102
|
+
*
|
|
103
|
+
* Defaults to `authenticated`. The safe value is the default because the
|
|
104
|
+
* unsafe one should require somebody to have said it.
|
|
105
|
+
*/
|
|
106
|
+
accessSubmit: 'authenticated' | 'public';
|
|
107
|
+
/**
|
|
108
|
+
* Origins allowed to submit anonymously. `null` means no allowlist is in
|
|
109
|
+
* force; an empty array means nothing is allowed, which is not the same
|
|
110
|
+
* thing and is why this is not just an array.
|
|
111
|
+
*/
|
|
112
|
+
allowedOrigins: string[] | null;
|
|
88
113
|
}
|
|
89
114
|
interface FormVersionRecord {
|
|
90
115
|
id: string;
|
|
@@ -124,16 +149,184 @@ interface DraftRecord {
|
|
|
124
149
|
data: unknown;
|
|
125
150
|
updatedAt: string;
|
|
126
151
|
}
|
|
152
|
+
/** A webhook registered against a form. */
|
|
153
|
+
interface WebhookRecord {
|
|
154
|
+
id: string;
|
|
155
|
+
formId: string;
|
|
156
|
+
url: string;
|
|
157
|
+
/** Shared with the receiver; signs every delivery. */
|
|
158
|
+
secret: string;
|
|
159
|
+
/**
|
|
160
|
+
* Failures in a row against this destination. Reset by any success.
|
|
161
|
+
*
|
|
162
|
+
* On the record rather than in the worker's memory for two reasons: memory
|
|
163
|
+
* does not survive a restart, and a self-hoster with no operations team
|
|
164
|
+
* needs a failing webhook to be visible on a screen rather than in a log
|
|
165
|
+
* nobody is tailing.
|
|
166
|
+
*/
|
|
167
|
+
consecutiveFailures: number;
|
|
168
|
+
/** When the breaker opened, or null while the destination is answering. */
|
|
169
|
+
openedAt: string | null;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* One queued delivery — the outbox row.
|
|
173
|
+
*
|
|
174
|
+
* It exists so that "a submission was accepted" and "its webhooks will be
|
|
175
|
+
* delivered" are decided by the same COMMIT. The alternative, posting after
|
|
176
|
+
* the insert returns, produces the two failures a self-hoster cannot debug:
|
|
177
|
+
* the webhook fired and the submission rolled back, or the submission is
|
|
178
|
+
* stored and nothing was ever sent.
|
|
179
|
+
*/
|
|
180
|
+
interface DeliveryRecord {
|
|
181
|
+
id: string;
|
|
182
|
+
webhookId: string;
|
|
183
|
+
submissionId: string;
|
|
184
|
+
/** Stable across every retry, so a receiver can be idempotent. */
|
|
185
|
+
eventId: string;
|
|
186
|
+
body: string;
|
|
187
|
+
attempt: number;
|
|
188
|
+
/** Not before this instant. Set by the retry schedule. */
|
|
189
|
+
nextAttemptAt: string;
|
|
190
|
+
/** `pending`, `delivered`, or `dead` once the attempts run out. */
|
|
191
|
+
state: 'pending' | 'delivered' | 'dead';
|
|
192
|
+
lastError: string | null;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* One uploaded file, in three states.
|
|
196
|
+
*
|
|
197
|
+
* `offered` means somebody asked where to put a file and the bytes have not
|
|
198
|
+
* arrived. `stored` means they have. `claimed` means a submission that
|
|
199
|
+
* references it was accepted, which is the only state that makes a file worth
|
|
200
|
+
* keeping — everything else is rubbish waiting to be collected.
|
|
201
|
+
*
|
|
202
|
+
* The row is written BEFORE the bytes, so a file that dies halfway through
|
|
203
|
+
* uploading is still something the collector knows to look for. An orphan on
|
|
204
|
+
* disk with no row is one nothing will ever find.
|
|
205
|
+
*
|
|
206
|
+
* `name` is what the reader called it and is shown back to them; it is never
|
|
207
|
+
* part of `storageKey`, because a key built from a submitted filename is a
|
|
208
|
+
* path traversal waiting for somebody to try it.
|
|
209
|
+
*/
|
|
210
|
+
interface FileRecord {
|
|
211
|
+
id: string;
|
|
212
|
+
formId: string;
|
|
213
|
+
/** What the reader called it. Display only. */
|
|
214
|
+
name: string;
|
|
215
|
+
size: number;
|
|
216
|
+
contentType: string;
|
|
217
|
+
/** Where the bytes are, in whatever the host's storage calls a location. */
|
|
218
|
+
storageKey: string;
|
|
219
|
+
state: 'offered' | 'stored' | 'claimed';
|
|
220
|
+
createdAt: string;
|
|
221
|
+
/** The submission that claimed it, or null. One file, one submission. */
|
|
222
|
+
submissionId: string | null;
|
|
223
|
+
}
|
|
127
224
|
interface Storage {
|
|
128
225
|
getFormByPath(path: string): Promise<FormRecord | undefined>;
|
|
129
226
|
listForms(): Promise<FormRecord[]>;
|
|
130
227
|
createForm(record: FormRecord): Promise<void>;
|
|
228
|
+
updateFormAccess(formId: string, access: Pick<FormRecord, 'accessSubmit' | 'allowedOrigins'>): Promise<void>;
|
|
131
229
|
setCurrentVersion(formId: string, versionId: string): Promise<void>;
|
|
132
230
|
insertVersion(record: FormVersionRecord): Promise<void>;
|
|
231
|
+
/**
|
|
232
|
+
* A publish, as ONE commit.
|
|
233
|
+
*
|
|
234
|
+
* The three steps it replaces — create the form when it is new, insert the
|
|
235
|
+
* version, point the form at it — used to be three calls, and the middle
|
|
236
|
+
* failure is the one that hurts: a form row whose `currentVersionId` is
|
|
237
|
+
* still null resolves to nothing, so the form exists, answers its URL, and
|
|
238
|
+
* has no schema to render. `GET /f/:path` 404s for a form that is right
|
|
239
|
+
* there in the list.
|
|
240
|
+
*
|
|
241
|
+
* The audit row travels with them for the same reason it travels with a
|
|
242
|
+
* submission: a publish that rolled back must leave nothing saying it
|
|
243
|
+
* happened, and a publish that happened must leave something.
|
|
244
|
+
*
|
|
245
|
+
* `form` is present only when the form is new. An existing form is not
|
|
246
|
+
* touched apart from its pointer, because its access settings are not the
|
|
247
|
+
* publisher's business.
|
|
248
|
+
*/
|
|
249
|
+
publishVersion(input: {
|
|
250
|
+
form?: FormRecord;
|
|
251
|
+
version: FormVersionRecord;
|
|
252
|
+
audit?: AuditEntry;
|
|
253
|
+
}): Promise<void>;
|
|
133
254
|
getVersionById(id: string): Promise<FormVersionRecord | undefined>;
|
|
134
255
|
findVersionByHash(formId: string, schemaHash: string): Promise<FormVersionRecord | undefined>;
|
|
135
256
|
latestVersionNumber(formId: string): Promise<number>;
|
|
136
|
-
|
|
257
|
+
/**
|
|
258
|
+
* The submission and every delivery it triggers, in ONE transaction.
|
|
259
|
+
*
|
|
260
|
+
* One method rather than two calls, because the atomicity is the point and a
|
|
261
|
+
* port that exposes them separately invites a caller to break it. See
|
|
262
|
+
* docs/decisions/0024-postgres-over-mongodb.md, where this requirement is
|
|
263
|
+
* what chose the database.
|
|
264
|
+
*/
|
|
265
|
+
/**
|
|
266
|
+
* The submission, the webhooks it triggers and the files it claims, in ONE
|
|
267
|
+
* call because they belong in one COMMIT. A port that exposed them
|
|
268
|
+
* separately would invite a caller to break that, and the three failures it
|
|
269
|
+
* prevents — a webhook fired for a rolled-back submission, a stored
|
|
270
|
+
* submission nothing was sent about, an accepted submission whose files the
|
|
271
|
+
* collector deletes — are the ones a self-hoster cannot debug.
|
|
272
|
+
*/
|
|
273
|
+
insertSubmission(record: SubmissionRecord, deliveries?: readonly DeliveryRecord[], claimFileIds?: readonly string[], audit?: AuditEntry): Promise<void>;
|
|
274
|
+
/**
|
|
275
|
+
* Append one audit row.
|
|
276
|
+
*
|
|
277
|
+
* Append-only by contract, and the deployment is expected to back that with
|
|
278
|
+
* a database role holding INSERT and SELECT and nothing else — an audit
|
|
279
|
+
* log the application can edit is a log that says whatever the person who
|
|
280
|
+
* broke in wants it to say. There is deliberately no update and no delete
|
|
281
|
+
* on this port for anything to call.
|
|
282
|
+
*
|
|
283
|
+
* For a mutation that HAS a transaction, pass the entry to that call
|
|
284
|
+
* instead, so the row and the thing it describes commit together. See
|
|
285
|
+
* `insertSubmission`.
|
|
286
|
+
*/
|
|
287
|
+
recordAudit(entry: AuditEntry): Promise<void>;
|
|
288
|
+
/** Newest first. Reading the log is itself a management-plane action. */
|
|
289
|
+
listAudit(limit: number): Promise<AuditEntry[]>;
|
|
290
|
+
/**
|
|
291
|
+
* Spend a solved challenge, once.
|
|
292
|
+
*
|
|
293
|
+
* Returns false when it has been spent already. The signature on a challenge
|
|
294
|
+
* proves this server minted it and the hash proves somebody did the work —
|
|
295
|
+
* and neither stops the same correct solution being sent a thousand times,
|
|
296
|
+
* because a correct solution stays correct. Only a record of what has been
|
|
297
|
+
* used can.
|
|
298
|
+
*
|
|
299
|
+
* It must be ATOMIC: two requests arriving with one solution both pass every
|
|
300
|
+
* stateless check, and the database is the only thing that can decide which
|
|
301
|
+
* of them spends it. An implementation that reads then writes has a race
|
|
302
|
+
* exactly where the attacker is looking.
|
|
303
|
+
*/
|
|
304
|
+
spendChallenge(challenge: string, expiresAtIso: string): Promise<boolean>;
|
|
305
|
+
/** Drop spent challenges that can no longer be replayed anyway. */
|
|
306
|
+
forgetExpiredChallenges(beforeIso: string): Promise<number>;
|
|
307
|
+
insertFile(record: FileRecord): Promise<void>;
|
|
308
|
+
getFile(id: string): Promise<FileRecord | undefined>;
|
|
309
|
+
updateFile(record: FileRecord): Promise<void>;
|
|
310
|
+
/** Every file in a state other than `claimed`, created before `beforeIso`. */
|
|
311
|
+
abandonedFiles(beforeIso: string): Promise<FileRecord[]>;
|
|
312
|
+
deleteFiles(ids: readonly string[]): Promise<void>;
|
|
313
|
+
webhooksForForm(formId: string): Promise<WebhookRecord[]>;
|
|
314
|
+
insertWebhook(record: WebhookRecord): Promise<void>;
|
|
315
|
+
/** Health, after an attempt. The record is the breaker's only memory. */
|
|
316
|
+
updateWebhook(record: WebhookRecord): Promise<void>;
|
|
317
|
+
listWebhooks(): Promise<WebhookRecord[]>;
|
|
318
|
+
/**
|
|
319
|
+
* Deliveries that ran out of attempts.
|
|
320
|
+
*
|
|
321
|
+
* Dead, not deleted: the row is the evidence that something was supposed to
|
|
322
|
+
* be sent and never arrived. Without a way to list them the evidence is in
|
|
323
|
+
* a table nobody looks at, which is the same as not having it.
|
|
324
|
+
*/
|
|
325
|
+
deadDeliveries(limit: number): Promise<DeliveryRecord[]>;
|
|
326
|
+
getDelivery(id: string): Promise<DeliveryRecord | undefined>;
|
|
327
|
+
/** Oldest first, only those due. */
|
|
328
|
+
claimDueDeliveries(nowIso: string, limit: number): Promise<DeliveryRecord[]>;
|
|
329
|
+
updateDelivery(record: DeliveryRecord): Promise<void>;
|
|
137
330
|
listSubmissions(): Promise<SubmissionRecord[]>;
|
|
138
331
|
/** Newest first. */
|
|
139
332
|
listSubmissionsByForm(formId: string): Promise<SubmissionRecord[]>;
|
|
@@ -148,6 +341,306 @@ interface Storage {
|
|
|
148
341
|
revokeApiKey(id: string, atIso: string): Promise<void>;
|
|
149
342
|
}
|
|
150
343
|
//#endregion
|
|
344
|
+
//#region src/auth.d.ts
|
|
345
|
+
/**
|
|
346
|
+
* The management plane's identity and authorization, framework-free.
|
|
347
|
+
*
|
|
348
|
+
* Hashing and randomness are injected — this package cannot reach a CSPRNG or
|
|
349
|
+
* argon2 on its own, the composition root hands them in — which keeps every
|
|
350
|
+
* behaviour here testable with deterministic fakes while the real server gets
|
|
351
|
+
* real cryptography.
|
|
352
|
+
*/
|
|
353
|
+
type Role = 'admin' | 'editor' | 'viewer';
|
|
354
|
+
interface Actor {
|
|
355
|
+
kind: 'user' | 'apiKey';
|
|
356
|
+
id: string;
|
|
357
|
+
role: Role;
|
|
358
|
+
}
|
|
359
|
+
interface AuthDeps {
|
|
360
|
+
storage: Storage;
|
|
361
|
+
newId(): string;
|
|
362
|
+
nowIso(): string;
|
|
363
|
+
/** At least 160 bits of entropy, URL-safe. Injected, never Math.random. */
|
|
364
|
+
randomToken(): string;
|
|
365
|
+
hashSecret(secret: string): Promise<string>;
|
|
366
|
+
verifySecret(secret: string, hash: string): Promise<boolean>;
|
|
367
|
+
}
|
|
368
|
+
type CreateUserOutcome = {
|
|
369
|
+
ok: true;
|
|
370
|
+
id: string;
|
|
371
|
+
} | {
|
|
372
|
+
ok: false;
|
|
373
|
+
message: string;
|
|
374
|
+
};
|
|
375
|
+
export declare function createLocalUser(deps: AuthDeps, input: {
|
|
376
|
+
email: string;
|
|
377
|
+
password: string;
|
|
378
|
+
role: Role;
|
|
379
|
+
}): Promise<CreateUserOutcome>;
|
|
380
|
+
type AuthOutcome = {
|
|
381
|
+
ok: true;
|
|
382
|
+
actor: Actor;
|
|
383
|
+
} | {
|
|
384
|
+
ok: false;
|
|
385
|
+
};
|
|
386
|
+
/**
|
|
387
|
+
* A wrong password and an unknown email answer identically, and BOTH run a
|
|
388
|
+
* hash verification — the difference in shape or timing would be a
|
|
389
|
+
* user-enumeration oracle.
|
|
390
|
+
*/
|
|
391
|
+
export declare function authenticateLocal(deps: AuthDeps, input: {
|
|
392
|
+
email: string;
|
|
393
|
+
password: string;
|
|
394
|
+
}): Promise<AuthOutcome>;
|
|
395
|
+
type CreateApiKeyOutcome = {
|
|
396
|
+
ok: true;
|
|
397
|
+
id: string;
|
|
398
|
+
secret: string;
|
|
399
|
+
} | {
|
|
400
|
+
ok: false;
|
|
401
|
+
message: string;
|
|
402
|
+
};
|
|
403
|
+
export declare function createApiKey(deps: AuthDeps, input: {
|
|
404
|
+
name: string;
|
|
405
|
+
role: Role;
|
|
406
|
+
}): Promise<CreateApiKeyOutcome>;
|
|
407
|
+
export declare function authenticateApiKey(deps: AuthDeps, presented: string): Promise<AuthOutcome>;
|
|
408
|
+
/**
|
|
409
|
+
* The whole authorization model, in one table a reviewer can read top to
|
|
410
|
+
* bottom. Deliberately NOT Postgres RLS in this phase: RLS couples the app to
|
|
411
|
+
* per-request database roles and hides authorization from the test suite.
|
|
412
|
+
* An unknown action is denied — permissions are granted by presence, never by
|
|
413
|
+
* omission.
|
|
414
|
+
*/
|
|
415
|
+
type Action = 'form.read' | 'form.publish' | 'submission.read' | 'submission.export' | 'draft.write' | 'user.create' | 'apiKey.create';
|
|
416
|
+
export declare function can(actor: Actor, action: Action): boolean;
|
|
417
|
+
//#endregion
|
|
418
|
+
//#region src/audit.d.ts
|
|
419
|
+
/**
|
|
420
|
+
* Who did what, to which thing, and when.
|
|
421
|
+
*
|
|
422
|
+
* **Reads and exports are recorded as well as mutations** — `submission.read` and
|
|
423
|
+
* `submission.exported` — which is what lets a self-hoster answer who downloaded four
|
|
424
|
+
* thousand people's answers ([0057](../../../docs/decisions/0057-the-audit-log-records-reads.md)).
|
|
425
|
+
*
|
|
426
|
+
* **A row joins the mutation's transaction where there is one.** `insertSubmission` and
|
|
427
|
+
* `publishVersion` both commit their audit row with the change it describes, so a
|
|
428
|
+
* rollback leaves no trace saying it happened. A read has nothing to be atomic with, so
|
|
429
|
+
* its row is appended after the rows are handed over and a failure to write it is logged
|
|
430
|
+
* loudly rather than swallowed.
|
|
431
|
+
*
|
|
432
|
+
* **`detail` carries identifiers and counts, never submission content.** An audit log is
|
|
433
|
+
* read by more people, kept longer and exported more freely than the data it describes.
|
|
434
|
+
*/
|
|
435
|
+
/**
|
|
436
|
+
* Every auditable action, as a closed list.
|
|
437
|
+
*
|
|
438
|
+
* Closed so a new route cannot invent its own spelling: an audit log where one event is
|
|
439
|
+
* called two things is one nobody can query.
|
|
440
|
+
*/
|
|
441
|
+
export declare const AUDIT_ACTIONS: readonly ["auth.login", "auth.login.failed", "form.published", "form.access.changed", "submission.created", "submission.read", "submission.exported", "file.downloaded", "user.created", "apiKey.created", "apiKey.revoked", "delivery.replayed"];
|
|
442
|
+
type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
|
443
|
+
interface AuditEntry {
|
|
444
|
+
readonly id: string;
|
|
445
|
+
readonly at: string;
|
|
446
|
+
readonly action: AuditAction;
|
|
447
|
+
/**
|
|
448
|
+
* Who. Absent for an anonymous public submission, which is a real answer
|
|
449
|
+
* rather than a gap: the form was open, and nobody was signed in.
|
|
450
|
+
*/
|
|
451
|
+
readonly actorKind?: 'user' | 'apiKey';
|
|
452
|
+
readonly actorId?: string;
|
|
453
|
+
/**
|
|
454
|
+
* What it was done to, as a stable identifier: a form's path, a submission
|
|
455
|
+
* id, a user's id. Absent only where the action has no object, which is
|
|
456
|
+
* currently nothing.
|
|
457
|
+
*/
|
|
458
|
+
readonly subject?: string;
|
|
459
|
+
/**
|
|
460
|
+
* The request it happened in, so an export and the rows it returned can be
|
|
461
|
+
* tied together later.
|
|
462
|
+
*/
|
|
463
|
+
readonly requestId?: string;
|
|
464
|
+
/** Identifiers and counts. Never a submission's answers. */
|
|
465
|
+
readonly detail?: Readonly<Record<string, string | number | boolean>>;
|
|
466
|
+
}
|
|
467
|
+
/** What a caller has to supply; the id and the timestamp are minted for it. */
|
|
468
|
+
type AuditDraft = Omit<AuditEntry, 'id' | 'at'>;
|
|
469
|
+
/**
|
|
470
|
+
* An entry from an actor, with the fields spread the way the record stores
|
|
471
|
+
* them.
|
|
472
|
+
*
|
|
473
|
+
* A helper rather than a convention, because `actorKind` and `actorId` being
|
|
474
|
+
* set together is exactly the sort of thing eleven call sites get wrong once.
|
|
475
|
+
*/
|
|
476
|
+
export declare function auditedBy(actor: Actor | undefined, draft: Omit<AuditDraft, 'actorKind' | 'actorId'>): AuditDraft;
|
|
477
|
+
//#endregion
|
|
478
|
+
//#region src/outbox.d.ts
|
|
479
|
+
/**
|
|
480
|
+
* What to do with a delivery once an attempt has finished.
|
|
481
|
+
*
|
|
482
|
+
* Pure, and separate from the thing that does the posting, because this is
|
|
483
|
+
* where the decisions are: whether to retry, when, and when to stop. Tangled
|
|
484
|
+
* into the network code those decisions are only testable by making requests.
|
|
485
|
+
*/
|
|
486
|
+
interface AttemptOutcome {
|
|
487
|
+
ok: boolean;
|
|
488
|
+
status?: number;
|
|
489
|
+
error?: string;
|
|
490
|
+
}
|
|
491
|
+
export declare function afterAttempt(delivery: DeliveryRecord, outcome: AttemptOutcome, now: Date, random: () => number): DeliveryRecord;
|
|
492
|
+
interface OutboxDeps {
|
|
493
|
+
storage: Storage;
|
|
494
|
+
/** Posts one delivery. Injected so the loop is testable without a network. */
|
|
495
|
+
send: (delivery: DeliveryRecord, webhook: WebhookRecord, nowSeconds: number) => Promise<AttemptOutcome>;
|
|
496
|
+
now: () => Date;
|
|
497
|
+
random: () => number;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Drain one batch of due deliveries.
|
|
501
|
+
*
|
|
502
|
+
* Returns how many were attempted, so a caller can decide whether to come back
|
|
503
|
+
* immediately or wait. Deliberately one batch rather than a loop that runs
|
|
504
|
+
* forever: the thing that decides *when* to run belongs to the host, and a
|
|
505
|
+
* function that never returns cannot be tested.
|
|
506
|
+
*/
|
|
507
|
+
/**
|
|
508
|
+
* Returns how many were ATTEMPTED — deliveries skipped because their
|
|
509
|
+
* destination's breaker is open do not count, so a caller polling until the
|
|
510
|
+
* queue is quiet does not spin on a dead endpoint.
|
|
511
|
+
*/
|
|
512
|
+
export declare function drainOutbox(deps: OutboxDeps, limit?: number): Promise<number>;
|
|
513
|
+
/**
|
|
514
|
+
* Put a dead delivery back in the queue.
|
|
515
|
+
*
|
|
516
|
+
* Dead is where a delivery goes when the attempts run out, and the row is the
|
|
517
|
+
* evidence that something was supposed to be sent and never arrived. Replay is
|
|
518
|
+
* what makes that evidence useful: the receiver was down for a morning, it is
|
|
519
|
+
* back, and the twelve submissions it missed should go now.
|
|
520
|
+
*
|
|
521
|
+
* The attempt count resets. It is a new run against a destination somebody has
|
|
522
|
+
* looked at and decided is fixed — carrying the old count over would give it
|
|
523
|
+
* one try before dying again, which is not a replay so much as a formality.
|
|
524
|
+
*
|
|
525
|
+
* Refuses anything that is not dead. Replaying a pending delivery would
|
|
526
|
+
* duplicate it, and replaying a delivered one would send it twice: the event
|
|
527
|
+
* id is stable across retries so a well-behaved receiver would ignore the
|
|
528
|
+
* second, but this service does not get to assume the receiver is
|
|
529
|
+
* well-behaved.
|
|
530
|
+
*/
|
|
531
|
+
type ReplayOutcome = {
|
|
532
|
+
ok: true;
|
|
533
|
+
delivery: DeliveryRecord;
|
|
534
|
+
} | {
|
|
535
|
+
ok: false;
|
|
536
|
+
kind: 'unknown' | 'not-dead';
|
|
537
|
+
state?: DeliveryRecord['state'];
|
|
538
|
+
};
|
|
539
|
+
export declare function replayDelivery(deps: {
|
|
540
|
+
storage: Storage;
|
|
541
|
+
now: () => Date;
|
|
542
|
+
}, id: string): Promise<ReplayOutcome>;
|
|
543
|
+
//#endregion
|
|
544
|
+
//#region src/breaker.d.ts
|
|
545
|
+
/**
|
|
546
|
+
* Stopping, when a destination has plainly stopped listening.
|
|
547
|
+
*
|
|
548
|
+
* Failure is counted per WEBHOOK as well as per delivery. Enough consecutive failures
|
|
549
|
+
* and the breaker opens: nothing is attempted for that webhook until a cool-down passes,
|
|
550
|
+
* then exactly one delivery goes through as a probe. It succeeds and the breaker closes;
|
|
551
|
+
* it fails and the cool-down starts again.
|
|
552
|
+
*
|
|
553
|
+
* `consecutiveFailures` and `openedAt` live on the RECORD rather than in the worker's
|
|
554
|
+
* memory, so the state survives a restart and can be shown on a screen — a self-hoster
|
|
555
|
+
* has no operations team watching a dashboard
|
|
556
|
+
* ([0058](../../../docs/decisions/0058-a-breaker-per-destination.md)).
|
|
557
|
+
*
|
|
558
|
+
* Everything here is a pure function of a record and a clock: decisions tangled into
|
|
559
|
+
* network code can only be tested by making requests.
|
|
560
|
+
*/
|
|
561
|
+
/**
|
|
562
|
+
* How many consecutive failures open the breaker.
|
|
563
|
+
*
|
|
564
|
+
* Three, not one. A single failure is a bad minute — a deploy, a timeout, a
|
|
565
|
+
* receiver restarting — and opening on it would make every routine blip a
|
|
566
|
+
* visible outage. Three in a row is a destination that is not coming back on
|
|
567
|
+
* its own.
|
|
568
|
+
*/
|
|
569
|
+
export declare const BREAKER_THRESHOLD = 3;
|
|
570
|
+
/**
|
|
571
|
+
* How long the breaker stays shut before one probe is allowed through.
|
|
572
|
+
*
|
|
573
|
+
* Five minutes: long enough that a restarting receiver is finished, short
|
|
574
|
+
* enough that nobody notices the delay once it recovers. The retry schedule
|
|
575
|
+
* handles patience within a delivery; this handles patience about a
|
|
576
|
+
* destination.
|
|
577
|
+
*/
|
|
578
|
+
export declare const BREAKER_COOLDOWN_MS: number;
|
|
579
|
+
/** Shut, letting one through, or open. */
|
|
580
|
+
type BreakerState = 'closed' | 'half-open' | 'open';
|
|
581
|
+
export declare function breakerState(webhook: WebhookRecord, now: Date): BreakerState;
|
|
582
|
+
/** Whether a delivery for this webhook may be attempted now. */
|
|
583
|
+
export declare function mayAttempt(webhook: WebhookRecord, now: Date): boolean;
|
|
584
|
+
/**
|
|
585
|
+
* The webhook's health after one attempt against it.
|
|
586
|
+
*
|
|
587
|
+
* A success always closes the breaker completely. Decaying the count instead
|
|
588
|
+
* would leave a destination that fails twice for every success permanently
|
|
589
|
+
* one blip from opening, and that destination is working.
|
|
590
|
+
*/
|
|
591
|
+
export declare function afterWebhookAttempt(webhook: WebhookRecord, ok: boolean, now: Date): WebhookRecord;
|
|
592
|
+
/**
|
|
593
|
+
* What a person is shown about a webhook's health.
|
|
594
|
+
*
|
|
595
|
+
* Shaped for a screen rather than for a log: the question being answered is
|
|
596
|
+
* "is this working, and if not since when", which is what somebody asks when
|
|
597
|
+
* the CRM has no leads this week.
|
|
598
|
+
*/
|
|
599
|
+
interface WebhookHealth {
|
|
600
|
+
id: string;
|
|
601
|
+
url: string;
|
|
602
|
+
state: BreakerState;
|
|
603
|
+
consecutiveFailures: number;
|
|
604
|
+
/** When it stopped working, or null while it is working. */
|
|
605
|
+
failingSince: string | null;
|
|
606
|
+
}
|
|
607
|
+
export declare function healthOf(webhook: WebhookRecord, now: Date): WebhookHealth;
|
|
608
|
+
//#endregion
|
|
609
|
+
//#region src/redos.d.ts
|
|
610
|
+
/**
|
|
611
|
+
* Refuse a form whose `pattern` values can be made to backtrack.
|
|
612
|
+
*
|
|
613
|
+
* A form author supplies these, and the server runs them against whatever a
|
|
614
|
+
* submitter typed. That makes a regular expression an author-side denial of
|
|
615
|
+
* service vector: one bad pattern, published once, and every submission after
|
|
616
|
+
* it can be turned into unbounded CPU by anyone who can reach the form.
|
|
617
|
+
*
|
|
618
|
+
* Checked at PUBLISH time rather than at match time, because there is no way
|
|
619
|
+
* to time out a JavaScript regular expression once it has started. The only
|
|
620
|
+
* moment the cost can be refused is before the pattern is stored.
|
|
621
|
+
*
|
|
622
|
+
* The check is static — `recheck` analyses the expression rather than running
|
|
623
|
+
* it — so it costs nothing per submission, and it found a polynomial case in
|
|
624
|
+
* formancy's own built-in email format before it was ever pointed at a user's.
|
|
625
|
+
*/
|
|
626
|
+
interface UnsafePattern {
|
|
627
|
+
/** Data path of the field carrying it, e.g. `contacts[].code`. */
|
|
628
|
+
path: string;
|
|
629
|
+
pattern: string;
|
|
630
|
+
/** `exponential` or `polynomial`, with the degree where there is one. */
|
|
631
|
+
complexity: string;
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* The patterns in this schema that an attacker could exploit, or an empty array
|
|
635
|
+
* when there are none.
|
|
636
|
+
*
|
|
637
|
+
* A pattern `recheck` cannot decide about is ACCEPTED. Refusing on `unknown`
|
|
638
|
+
* would make publishing depend on an analysis timeout, which is a worse failure
|
|
639
|
+
* than the one being prevented — an author whose legitimate form stops
|
|
640
|
+
* publishing intermittently has no way to act on that.
|
|
641
|
+
*/
|
|
642
|
+
export declare function unsafePatterns(schema: FormSchema): Promise<UnsafePattern[]>;
|
|
643
|
+
//#endregion
|
|
151
644
|
//#region src/use-cases.d.ts
|
|
152
645
|
/**
|
|
153
646
|
* The backend's use-cases, framework-free.
|
|
@@ -162,6 +655,31 @@ interface ServerDeps {
|
|
|
162
655
|
nowIso(): string;
|
|
163
656
|
/** The clock/randomness the ENGINE sees during replay. */
|
|
164
657
|
capabilities: CapabilitySource;
|
|
658
|
+
/**
|
|
659
|
+
* Signs the token that proves somebody started a draft.
|
|
660
|
+
*
|
|
661
|
+
* The public plane is anonymous, so a draft has no account behind it — which
|
|
662
|
+
* is exactly why it needs a secret of its own. The host's own signing key is
|
|
663
|
+
* reused rather than a second one being configured: a draft token is a
|
|
664
|
+
* server-signed bearer token, which is what that key is already for, and an
|
|
665
|
+
* optional secret would mean drafts are unprotected whenever nobody set it.
|
|
666
|
+
*/
|
|
667
|
+
draftSecret: string;
|
|
668
|
+
/**
|
|
669
|
+
* The lists a form document may name with `optionsSource`, and how to check a
|
|
670
|
+
* value against one.
|
|
671
|
+
*
|
|
672
|
+
* A function and never an address: for a real source this is a database query, not
|
|
673
|
+
* a request to somewhere a form author typed. Shipping an HTTP adapter here would
|
|
674
|
+
* add outbound-request and confused-deputy surface to a regulatory set that
|
|
675
|
+
* currently claims neither — for a convenience nobody asked for.
|
|
676
|
+
*
|
|
677
|
+
* Absent entirely means this deployment has no vocabulary: publishing cannot be
|
|
678
|
+
* judged against it, and no submission is checked. Present but without `members`
|
|
679
|
+
* for a name means *this source exists and I cannot check membership* — a
|
|
680
|
+
* legitimate configuration, and the reason the guarantee is written down.
|
|
681
|
+
*/
|
|
682
|
+
optionsSources?: ServerOptionsSources;
|
|
165
683
|
}
|
|
166
684
|
type PublishOutcome = {
|
|
167
685
|
ok: true;
|
|
@@ -177,6 +695,16 @@ type PublishOutcome = {
|
|
|
177
695
|
ok: false;
|
|
178
696
|
kind: 'invalid_logic';
|
|
179
697
|
message: string;
|
|
698
|
+
} | {
|
|
699
|
+
ok: false;
|
|
700
|
+
kind: 'unsafe_pattern';
|
|
701
|
+
patterns: UnsafePattern[];
|
|
702
|
+
} |
|
|
703
|
+
/** The document names a list this deployment has never heard of. */
|
|
704
|
+
{
|
|
705
|
+
ok: false;
|
|
706
|
+
kind: 'unknown_options_source';
|
|
707
|
+
sources: string[];
|
|
180
708
|
};
|
|
181
709
|
/**
|
|
182
710
|
* Publish a schema as a form's next version.
|
|
@@ -190,6 +718,7 @@ type PublishOutcome = {
|
|
|
190
718
|
export declare function publishForm(deps: ServerDeps, input: {
|
|
191
719
|
path: string;
|
|
192
720
|
schema: unknown;
|
|
721
|
+
actor?: Actor;
|
|
193
722
|
}): Promise<PublishOutcome>;
|
|
194
723
|
interface ResolvedForm {
|
|
195
724
|
formId: string;
|
|
@@ -214,6 +743,23 @@ type SubmissionOutcome = {
|
|
|
214
743
|
ok: false;
|
|
215
744
|
kind: 'invalid';
|
|
216
745
|
errors: Record<string, string[]>;
|
|
746
|
+
} |
|
|
747
|
+
/** The caller may not submit this form: not public, or not from this origin. */
|
|
748
|
+
{
|
|
749
|
+
ok: false;
|
|
750
|
+
kind: 'forbidden';
|
|
751
|
+
} |
|
|
752
|
+
/**
|
|
753
|
+
* A source that should have vouched for an answer could not.
|
|
754
|
+
*
|
|
755
|
+
* Failed closed and never accepted unchecked: a bogus value that is stored is
|
|
756
|
+
* undetectable afterwards, while a refusal is retryable and the draft still holds
|
|
757
|
+
* the answers.
|
|
758
|
+
*/
|
|
759
|
+
{
|
|
760
|
+
ok: false;
|
|
761
|
+
kind: 'source_unavailable';
|
|
762
|
+
source: string;
|
|
217
763
|
};
|
|
218
764
|
/**
|
|
219
765
|
* Accept one submission: resolve the version the client says it rendered,
|
|
@@ -224,10 +770,27 @@ type SubmissionOutcome = {
|
|
|
224
770
|
* are stripped, so a client cannot smuggle data by lying about what was shown.
|
|
225
771
|
* Client validation is UX; this is truth.
|
|
226
772
|
*/
|
|
773
|
+
/**
|
|
774
|
+
* Change who may submit a form, and from where.
|
|
775
|
+
*
|
|
776
|
+
* Separate from publishing on purpose: access is a property of the deployment,
|
|
777
|
+
* and changing it must not mint a new immutable form version or invalidate the
|
|
778
|
+
* schema hash every client is holding.
|
|
779
|
+
*/
|
|
780
|
+
export declare function setFormAccess(deps: ServerDeps, input: {
|
|
781
|
+
path: string;
|
|
782
|
+
submit: 'authenticated' | 'public';
|
|
783
|
+
allowedOrigins?: string[];
|
|
784
|
+
}): Promise<{
|
|
785
|
+
ok: boolean;
|
|
786
|
+
}>;
|
|
227
787
|
export declare function createSubmission(deps: ServerDeps, input: {
|
|
228
788
|
path: string;
|
|
229
789
|
declaredSchemaHash: string;
|
|
230
790
|
data: unknown;
|
|
791
|
+
/** Defaults to anonymous: the caller must prove otherwise, not the reverse. */
|
|
792
|
+
actor?: 'anonymous' | 'authenticated';
|
|
793
|
+
origin?: string;
|
|
231
794
|
}): Promise<SubmissionOutcome>;
|
|
232
795
|
interface ListedSubmission {
|
|
233
796
|
id: string;
|
|
@@ -250,14 +813,27 @@ export declare function listSubmissions(deps: ServerDeps, path: string): Promise
|
|
|
250
813
|
* spreadsheet join a business user will try.
|
|
251
814
|
*/
|
|
252
815
|
export declare function exportCsv(deps: ServerDeps, path: string): Promise<string | undefined>;
|
|
253
|
-
/**
|
|
254
|
-
*
|
|
816
|
+
/**
|
|
817
|
+
* Start a draft: the server picks the id, and signs it.
|
|
818
|
+
*
|
|
819
|
+
* The id is the server's because an id a caller supplies is an id a caller can
|
|
820
|
+
* enumerate — and enumerating them was enough to read other people's
|
|
821
|
+
* part-filled answers.
|
|
822
|
+
*/
|
|
823
|
+
export declare function startDraft(deps: ServerDeps, input: {
|
|
824
|
+
path: string;
|
|
825
|
+
}): Promise<{
|
|
826
|
+
draftId: string;
|
|
827
|
+
token: string;
|
|
828
|
+
} | undefined>;
|
|
255
829
|
export declare function saveDraft(deps: ServerDeps, input: {
|
|
256
830
|
path: string;
|
|
257
831
|
draftId: string;
|
|
832
|
+
token: string;
|
|
258
833
|
data: unknown;
|
|
259
834
|
}): Promise<{
|
|
260
835
|
version: number;
|
|
836
|
+
saved: boolean;
|
|
261
837
|
} | undefined>;
|
|
262
838
|
interface DraftMigration {
|
|
263
839
|
severity: 'lossy';
|
|
@@ -294,6 +870,7 @@ type ResumeOutcome = {
|
|
|
294
870
|
export declare function resumeDraft(deps: ServerDeps, input: {
|
|
295
871
|
path: string;
|
|
296
872
|
draftId: string;
|
|
873
|
+
token: string;
|
|
297
874
|
}): Promise<ResumeOutcome | undefined>;
|
|
298
875
|
interface ListedForm {
|
|
299
876
|
path: string;
|
|
@@ -320,5 +897,201 @@ export declare function listVersions(deps: ServerDeps, path: string): Promise<Li
|
|
|
320
897
|
*/
|
|
321
898
|
export declare function createMemoryStorage(): Storage;
|
|
322
899
|
//#endregion
|
|
323
|
-
|
|
900
|
+
//#region src/address.d.ts
|
|
901
|
+
/**
|
|
902
|
+
* Deciding whether an address is somewhere this server may fetch from.
|
|
903
|
+
*
|
|
904
|
+
* A self-hosted formancy sits inside somebody's private network and webhook
|
|
905
|
+
* URLs are written by whoever can edit a form, which makes the server a
|
|
906
|
+
* confused deputy: it can reach the database, the metadata service and every
|
|
907
|
+
* internal admin panel, and it will fetch whatever it is told to.
|
|
908
|
+
*
|
|
909
|
+
* This half is pure — given an address, is it private — so it lives here and is
|
|
910
|
+
* tested exhaustively. The rest lives in @formancy/server, because it cannot be
|
|
911
|
+
* pure: parsing a URL needs `URL`, and resolving a hostname then connecting to
|
|
912
|
+
* the address that was checked, rather than re-resolving, needs Node's DNS and
|
|
913
|
+
* a custom agent. This package has no @types/node by design, and hand-rolling
|
|
914
|
+
* a URL parser for a security check is how such a check gets quietly wrong.
|
|
915
|
+
* All three parts are necessary. Validating a URL and then handing it to fetch is defeated by DNS
|
|
916
|
+
* rebinding — the name resolves to a public address for the check and a private
|
|
917
|
+
* one for the request — and that is the difference between a mitigation and
|
|
918
|
+
* theatre.
|
|
919
|
+
*/
|
|
920
|
+
/**
|
|
921
|
+
* Whether this address is one the server must not be pointed at.
|
|
922
|
+
*
|
|
923
|
+
* Unparseable input counts as private. An address this cannot classify is one
|
|
924
|
+
* it cannot vouch for, and the safe direction is refusing to fetch.
|
|
925
|
+
*/
|
|
926
|
+
export declare function isPrivateAddress(address: string): boolean;
|
|
927
|
+
/**
|
|
928
|
+
* Whether this is a literal IP address rather than a hostname.
|
|
929
|
+
*
|
|
930
|
+
* The two need different treatment and conflating them breaks the check in
|
|
931
|
+
* both directions: `isPrivateAddress` fails closed on anything it cannot
|
|
932
|
+
* parse, which is correct for an address that came back from DNS and wrong for
|
|
933
|
+
* `example.ch`, where it would refuse every hostname in the world.
|
|
934
|
+
*/
|
|
935
|
+
export declare function isIpLiteral(host: string): boolean;
|
|
936
|
+
//#endregion
|
|
937
|
+
//#region src/webhook.d.ts
|
|
938
|
+
/**
|
|
939
|
+
* What a webhook delivery looks like on the wire.
|
|
940
|
+
*
|
|
941
|
+
* The signature scheme is Stripe's, deliberately: receivers already have code
|
|
942
|
+
* for it, and a scheme somebody has to implement from scratch is a scheme most
|
|
943
|
+
* receivers will skip. Adopting a familiar one is the difference between a
|
|
944
|
+
* verified webhook and a verified-in-principle one.
|
|
945
|
+
*/
|
|
946
|
+
export declare const EVENT_ID_HEADER = "x-formancy-event-id";
|
|
947
|
+
export declare const SIGNATURE_HEADER = "x-formancy-signature";
|
|
948
|
+
interface DeliveryHeaders {
|
|
949
|
+
[header: string]: string;
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* The signed payload is `timestamp.body`, not `body`.
|
|
953
|
+
*
|
|
954
|
+
* Without the timestamp inside the signature, a signature captured once stays
|
|
955
|
+
* valid forever and can be replayed. With it, a receiver can reject anything
|
|
956
|
+
* older than its tolerance, and the signature covers the timestamp it is
|
|
957
|
+
* checking — so the timestamp cannot be edited either.
|
|
958
|
+
*/
|
|
959
|
+
export declare function signedPayload(timestampSeconds: number, body: string): string;
|
|
960
|
+
/** Hex HMAC-SHA256 over the signed payload. */
|
|
961
|
+
export declare function signBody(secret: string, timestampSeconds: number, body: string): string;
|
|
962
|
+
/**
|
|
963
|
+
* The headers for one delivery attempt.
|
|
964
|
+
*
|
|
965
|
+
* The event id is stable across retries, which is what makes the receiver able
|
|
966
|
+
* to be idempotent. Generating a fresh one per attempt would turn our retry
|
|
967
|
+
* into their duplicate.
|
|
968
|
+
*/
|
|
969
|
+
export declare function deliveryHeaders(input: {
|
|
970
|
+
eventId: string;
|
|
971
|
+
secret: string;
|
|
972
|
+
body: string;
|
|
973
|
+
timestampSeconds: number;
|
|
974
|
+
attempt: number;
|
|
975
|
+
}): DeliveryHeaders;
|
|
976
|
+
/**
|
|
977
|
+
* Verify a delivery the way a receiver would.
|
|
978
|
+
*
|
|
979
|
+
* Exported because a scheme nobody can check is a scheme nobody does check —
|
|
980
|
+
* this is the reference implementation, and the test for our own signing.
|
|
981
|
+
*/
|
|
982
|
+
export declare function verifySignature(input: {
|
|
983
|
+
secret: string;
|
|
984
|
+
body: string;
|
|
985
|
+
header: string;
|
|
986
|
+
nowSeconds: number;
|
|
987
|
+
toleranceSeconds?: number;
|
|
988
|
+
}): boolean;
|
|
989
|
+
/**
|
|
990
|
+
* How long to wait before attempt n, in milliseconds.
|
|
991
|
+
*
|
|
992
|
+
* Exponential, capped, with jitter. The jitter is the part that matters at
|
|
993
|
+
* scale: without it every delivery queued by the same outage retries at the
|
|
994
|
+
* same instant, and the receiver coming back up is knocked over by the
|
|
995
|
+
* thundering herd of everyone's retries.
|
|
996
|
+
*/
|
|
997
|
+
export declare function retryDelayMs(attempt: number, random: () => number): number;
|
|
998
|
+
/** Eight attempts reach roughly 24 hours with this schedule. */
|
|
999
|
+
export declare const MAX_ATTEMPTS = 8;
|
|
1000
|
+
//#endregion
|
|
1001
|
+
//#region src/uploads.d.ts
|
|
1002
|
+
/**
|
|
1003
|
+
* The file lifecycle: offered, stored, claimed, collected.
|
|
1004
|
+
*
|
|
1005
|
+
* **Bytes are not the submission's problem.** A submission stores what each
|
|
1006
|
+
* file is and where it went, never the bytes themselves, so a submission read
|
|
1007
|
+
* back years later is small, self-describing, and still says what was attached
|
|
1008
|
+
* even if the object store has since been emptied.
|
|
1009
|
+
*
|
|
1010
|
+
* **The claim happens inside the submission's transaction.** A submission
|
|
1011
|
+
* exists if and only if the files it names are claimed against it. Anything
|
|
1012
|
+
* looser gives one of the two failures nobody can debug: a rolled-back
|
|
1013
|
+
* submission leaving files claimed against nothing, or an accepted submission
|
|
1014
|
+
* referencing files the collector is about to delete.
|
|
1015
|
+
*
|
|
1016
|
+
* **Nothing here touches a disk.** This package has no filesystem and no
|
|
1017
|
+
* object store; it decides what is rubbish and the host deletes it. That is
|
|
1018
|
+
* the same split every other port here makes, and it is why the whole
|
|
1019
|
+
* lifecycle is testable without either.
|
|
1020
|
+
*/
|
|
1021
|
+
interface OfferDeps {
|
|
1022
|
+
storage: Storage;
|
|
1023
|
+
now: () => Date;
|
|
1024
|
+
newId: () => string;
|
|
1025
|
+
}
|
|
1026
|
+
interface OfferInput {
|
|
1027
|
+
schema: FormSchema;
|
|
1028
|
+
/** The form, for the access check — the same one a submission passes. */
|
|
1029
|
+
form: FormRecord;
|
|
1030
|
+
actor: 'anonymous' | 'authenticated';
|
|
1031
|
+
origin: string | undefined;
|
|
1032
|
+
/** The field the file is for, as a data path: `evidence`, `people[].passport`. */
|
|
1033
|
+
field: string;
|
|
1034
|
+
name: string;
|
|
1035
|
+
size: number;
|
|
1036
|
+
contentType: string;
|
|
1037
|
+
}
|
|
1038
|
+
type OfferOutcome = {
|
|
1039
|
+
ok: true;
|
|
1040
|
+
file: FileRecord;
|
|
1041
|
+
} | {
|
|
1042
|
+
ok: false;
|
|
1043
|
+
reason: 'not_allowed' | 'unknown_field' | 'not_a_file_field' | 'too_large' | 'not_accepted';
|
|
1044
|
+
};
|
|
1045
|
+
/**
|
|
1046
|
+
* Agree where a file will go, and refuse it before a byte is sent.
|
|
1047
|
+
*
|
|
1048
|
+
* Checking here rather than after the upload is the difference between
|
|
1049
|
+
* refusing a 2 GB file and receiving one first. The browser's own `accept` and
|
|
1050
|
+
* size limits are a convenience for the person filling the form in; these are
|
|
1051
|
+
* the rule, because somebody posting to the endpoint directly has neither.
|
|
1052
|
+
*/
|
|
1053
|
+
export declare function offerUpload(deps: OfferDeps, input: OfferInput): Promise<OfferOutcome>;
|
|
1054
|
+
type ClaimOutcome = {
|
|
1055
|
+
ok: true;
|
|
1056
|
+
ids: readonly string[];
|
|
1057
|
+
} | {
|
|
1058
|
+
ok: false;
|
|
1059
|
+
unknown: readonly string[];
|
|
1060
|
+
};
|
|
1061
|
+
/**
|
|
1062
|
+
* Which files a submission may claim, checked but not yet claimed.
|
|
1063
|
+
*
|
|
1064
|
+
* The claiming itself is `insertSubmission`'s job, in the same call that
|
|
1065
|
+
* stores the submission, so one COMMIT decides both — the same reasoning
|
|
1066
|
+
* that put the webhook queue there. A submission exists if and only if the
|
|
1067
|
+
* files it names belong to it. Claiming separately gives the two failures
|
|
1068
|
+
* nobody can debug: a rolled-back submission leaving files claimed against
|
|
1069
|
+
* nothing, or an accepted submission referencing bytes the collector is about
|
|
1070
|
+
* to delete.
|
|
1071
|
+
*
|
|
1072
|
+
* Refuses rather than skips. A submission naming a file that does not exist,
|
|
1073
|
+
* belongs to another form, or is already claimed is not a submission to fix up
|
|
1074
|
+
* quietly: the first is a bug or somebody guessing ids; the second is how an
|
|
1075
|
+
* id from a form you may submit to would let you attach a file from one you
|
|
1076
|
+
* may not; the third would make deleting a submission either leak bytes or
|
|
1077
|
+
* break a different one.
|
|
1078
|
+
*/
|
|
1079
|
+
export declare function filesToClaim(storage: Storage, input: {
|
|
1080
|
+
schema: FormSchema;
|
|
1081
|
+
formId: string;
|
|
1082
|
+
data: unknown;
|
|
1083
|
+
}): Promise<ClaimOutcome>;
|
|
1084
|
+
/**
|
|
1085
|
+
* Files nobody claimed, old enough that nobody is going to.
|
|
1086
|
+
*
|
|
1087
|
+
* Returns the rows rather than deleting anything: the host removes the bytes
|
|
1088
|
+
* and then the rows, in that order, because a row without bytes is a broken
|
|
1089
|
+
* reference and bytes without a row are invisible rubbish. Of the two, the
|
|
1090
|
+
* second is the one you never find out about.
|
|
1091
|
+
*/
|
|
1092
|
+
export declare function collectAbandonedFiles(storage: Storage, now: Date, afterHours: number): Promise<FileRecord[]>;
|
|
1093
|
+
/** Re-exported so a host can see which paths a schema's file fields live at. */
|
|
1094
|
+
export declare function fileFieldPaths(schema: FormSchema): string[];
|
|
1095
|
+
//#endregion
|
|
1096
|
+
export { type Action, type Actor, type AttemptOutcome, type AuditAction, type AuditDraft, type AuditEntry, type AuthDeps, type AuthOutcome, type BreakerState, CHALLENGE_TTL_SECONDS, type Challenge, type ClaimOutcome, DEFAULT_MAX_NUMBER, type DeliveryHeaders, type DeliveryRecord, type DraftMigration, type DraftRecord, type FileRecord, type FormRecord, type FormVersionRecord, type ListedForm, type ListedSubmission, type ListedVersion, type MembershipOutcome, type OfferDeps, type OfferInput, type OfferOutcome, type OutboxDeps, type PublishOutcome, type ReplayOutcome, type ResolvedForm, type ResumeOutcome, type Role, type ServerDeps, type ServerOptionsSource, type ServerOptionsSources, type Solution, type Storage, type SubmissionOutcome, type SubmissionRecord, type UnsafePattern, type VerifyOutcome, type WebhookHealth, type WebhookRecord, decodeSolution, encodeSolution, hashOf, mintChallenge, solveChallenge, verifySolution };
|
|
324
1097
|
//# sourceMappingURL=index.d.mts.map
|