@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 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/auth.d.ts
5
+ //#region src/options-membership.d.ts
5
6
  /**
6
- * The management plane's identity and authorization, framework-free.
7
+ * Checking that a sourced answer is one the deployment's own source offers.
7
8
  *
8
- * Hashing and randomness are injected — this package cannot reach a CSPRNG or
9
- * argon2 on its own, the composition root hands them in — which keeps every
10
- * behaviour here testable with deterministic fakes while the real server gets
11
- * real cryptography.
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
- type Role = 'admin' | 'editor' | 'viewer';
14
- interface Actor {
15
- kind: 'user' | 'apiKey';
16
- id: string;
17
- role: Role;
18
- }
19
- interface AuthDeps {
20
- storage: Storage;
21
- newId(): string;
22
- nowIso(): string;
23
- /** At least 160 bits of entropy, URL-safe. Injected, never Math.random. */
24
- randomToken(): string;
25
- hashSecret(secret: string): Promise<string>;
26
- verifySecret(secret: string, hash: string): Promise<boolean>;
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 CreateUserOutcome = {
29
- ok: true;
30
- id: string;
31
- } | {
32
- ok: false;
33
- message: string;
34
- };
35
- export declare function createLocalUser(deps: AuthDeps, input: {
36
- email: string;
37
- password: string;
38
- role: Role;
39
- }): Promise<CreateUserOutcome>;
40
- type AuthOutcome = {
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
- actor: Actor;
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 wrong password and an unknown email answer identically, and BOTH run a
48
- * hash verification — the difference in shape or timing would be a
49
- * user-enumeration oracle.
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
- export declare function authenticateLocal(deps: AuthDeps, input: {
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
- message: string;
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
- * The whole authorization model, in one table a reviewer can read top to
70
- * bottom. Deliberately NOT Postgres RLS in this phase: RLS couples the app to
71
- * per-request database roles and hides authorization from the test suite.
72
- * An unknown action is denied — permissions are granted by presence, never by
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
- type Action = 'form.read' | 'form.publish' | 'submission.read' | 'submission.export' | 'draft.write' | 'user.create' | 'apiKey.create';
76
- export declare function can(actor: Actor, action: Action): boolean;
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
- insertSubmission(record: SubmissionRecord): Promise<void>;
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
- /** Save (or re-save) a partial form, bound to the CURRENT version. Undefined
254
- * for an unknown form. */
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
- export type { Action, Actor, AuthDeps, AuthOutcome, DraftMigration, DraftRecord, FormRecord, FormVersionRecord, ListedForm, ListedSubmission, ListedVersion, PublishOutcome, ResolvedForm, ResumeOutcome, Role, ServerDeps, Storage, SubmissionOutcome, SubmissionRecord };
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