@marktiderman/genesis-forms 0.8.0 → 0.9.1

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.
Files changed (36) hide show
  1. package/README.md +299 -10
  2. package/dist/destinations.cjs +331 -114
  3. package/dist/destinations.cjs.map +1 -1
  4. package/dist/destinations.d.cts +67 -45
  5. package/dist/destinations.d.ts +67 -45
  6. package/dist/destinations.js +327 -111
  7. package/dist/destinations.js.map +1 -1
  8. package/dist/{engine-Bj23YJlB.d.cts → engine-BaHPC2-1.d.cts} +2 -2
  9. package/dist/{engine-CzinTJph.d.ts → engine-Da99Tv-E.d.ts} +2 -2
  10. package/dist/index.cjs +996 -167
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +288 -11
  13. package/dist/index.d.ts +288 -11
  14. package/dist/index.js +975 -168
  15. package/dist/index.js.map +1 -1
  16. package/dist/server.cjs +563 -173
  17. package/dist/server.cjs.map +1 -1
  18. package/dist/server.d.cts +3 -474
  19. package/dist/server.d.ts +3 -474
  20. package/dist/server.js +558 -174
  21. package/dist/server.js.map +1 -1
  22. package/dist/submit-protocol-CFi-0Qd0.d.ts +840 -0
  23. package/dist/submit-protocol-D0cOckmK.d.cts +840 -0
  24. package/dist/testing.cjs +250 -154
  25. package/dist/testing.cjs.map +1 -1
  26. package/dist/testing.d.cts +4 -4
  27. package/dist/testing.d.ts +4 -4
  28. package/dist/testing.js +250 -154
  29. package/dist/testing.js.map +1 -1
  30. package/dist/{types-jqDKcmP4.d.cts → types-C0pUfew3.d.cts} +7 -1
  31. package/dist/{types-jqDKcmP4.d.ts → types-C0pUfew3.d.ts} +7 -1
  32. package/dist/{validate-DvfQHYnu.d.ts → validate-DpmBssFk.d.ts} +258 -140
  33. package/dist/{validate-irIPubxc.d.cts → validate-U5LoW3eR.d.cts} +258 -140
  34. package/package.json +3 -2
  35. package/dist/upload-protocol-BOe018kL.d.cts +0 -122
  36. package/dist/upload-protocol-BUO74WkS.d.ts +0 -122
@@ -0,0 +1,840 @@
1
+ import { H as HostOptions, A as AnswerSnapshot, F as FieldValidator } from './validate-DpmBssFk.js';
2
+ import { a as FieldBlock, J as Json, F as FormDefinition, e as FormEvent } from './types-C0pUfew3.js';
3
+
4
+ /**
5
+ * The `photo` field type: a respondent attaches up to a few photos, such as a
6
+ * picture of broken equipment. The answer holds references the host's storage
7
+ * handed back (`PhotoRef`), never the bytes: the renderer uploads through the
8
+ * host (`onUpload` on genesis-forms-web's `Form`), so the engine and the
9
+ * server stay storage-agnostic. A host's server should check that each
10
+ * reference is a file it stored for this respondent before saving.
11
+ *
12
+ * Opt-in in a builder palette (`UPLOAD_FIELD_TYPES`), since a host must be
13
+ * able to store the files.
14
+ */
15
+ declare const PHOTO_TYPE = "photo";
16
+ /** Field types that need the host to store files; a builder offers them only when the host names them. */
17
+ declare const UPLOAD_FIELD_TYPES: readonly ["photo"];
18
+ /** One stored photo, as the host's upload returned it. */
19
+ type PhotoRef = {
20
+ /** The host's id for the stored file. */
21
+ id: string;
22
+ /** The file's name on the respondent's device. */
23
+ name: string;
24
+ /** Its MIME type, such as `image/jpeg`. */
25
+ type: string;
26
+ /** Bytes. */
27
+ size: number;
28
+ };
29
+ type PhotoFormat = "jpeg" | "png" | "heic" | "webp";
30
+ /** The MIME types each format covers. */
31
+ declare const PHOTO_FORMATS: Record<PhotoFormat, readonly string[]>;
32
+ declare const DEFAULT_PHOTO_FILES = 3;
33
+ declare const MAX_PHOTO_FILES = 5;
34
+ declare const DEFAULT_PHOTO_SIZE_MB = 10;
35
+ declare const MAX_PHOTO_SIZE_MB = 25;
36
+ /** The longest reference id and file name a photo answer may carry. */
37
+ declare const MAX_PHOTO_ID_LENGTH = 200;
38
+ declare const MAX_PHOTO_NAME_LENGTH = 255;
39
+ declare const isPhotoFormat: (v: unknown) => v is PhotoFormat;
40
+ interface PhotoLimits {
41
+ maxFiles: number;
42
+ maxSizeMb: number;
43
+ maxBytes: number;
44
+ formats: PhotoFormat[];
45
+ /** Every MIME type the formats cover. */
46
+ mimeTypes: string[];
47
+ }
48
+ /** A photo field's limits; a prop that is missing or out of range uses its default. */
49
+ declare function photoLimits(field: FieldBlock): PhotoLimits;
50
+ /** Whether `v` has a photo reference's shape (any type and size). */
51
+ declare function isPhotoRef(v: unknown): v is PhotoRef;
52
+ /** The well-formed references in a photo answer, as `{ id, name, type, size }` only. */
53
+ declare function photoRefs(value: Json | undefined): PhotoRef[];
54
+ /** "JPEG, PNG or WebP". */
55
+ declare function photoFormatList(formats: readonly PhotoFormat[]): string;
56
+ /** The message for a file of a type the field doesn't take. */
57
+ declare const photoTypeMessage: (field: FieldBlock) => string;
58
+ /** The message for a file over the field's size limit. */
59
+ declare const photoSizeMessage: (field: FieldBlock) => string;
60
+ /** The message for more photos than the field takes. */
61
+ declare const photoCountMessage: (field: FieldBlock) => string;
62
+
63
+ /**
64
+ * The upload handshake's JSON, shared by the server (`createUploadHandler` in
65
+ * `/server`) and the browser (genesis-forms-web's `signedUploadHost`). A
66
+ * respondent's browser asks the app's server for one signed upload per file,
67
+ * then sends the bytes straight to storage with it; the answer keeps only the
68
+ * reference the server handed back.
69
+ *
70
+ * Moved up from the host app's feedback photo uploads; the field names and
71
+ * status codes are the ones it used, so its browsers and server keep talking.
72
+ */
73
+
74
+ /**
75
+ * What the browser POSTs (`content-type: application/json`, at most
76
+ * `MAX_UPLOAD_REQUEST_BYTES`). Exactly one of `attemptId` (a new response: the
77
+ * visit's attempt id, a UUID) and `edit` (an edit link's token, while changing
78
+ * a sent response).
79
+ */
80
+ interface UploadRequest {
81
+ /** The upload field's id. */
82
+ fieldId: string;
83
+ /** The file's name on the respondent's device. */
84
+ name: string;
85
+ /** Its MIME type. */
86
+ type: string;
87
+ /** Bytes. */
88
+ size: number;
89
+ attemptId?: string | undefined;
90
+ edit?: string | undefined;
91
+ }
92
+ /**
93
+ * Where the browser sends the bytes, as the app's storage signed it. Supabase
94
+ * Storage hands back `path` and `token` (with `bucket`, for
95
+ * `uploadToSignedUrl`); a store that signs a plain URL sets `url`, which
96
+ * `signedUploadHost` PUTs the file to when no `send` is given.
97
+ */
98
+ interface SignedUpload {
99
+ path: string;
100
+ token: string;
101
+ bucket?: string | undefined;
102
+ url?: string | undefined;
103
+ }
104
+ /** 200: the reference the answer keeps, and where to send the bytes. */
105
+ interface UploadGrant {
106
+ ref: PhotoRef;
107
+ upload: SignedUpload;
108
+ }
109
+ /**
110
+ * Any other status: words to show the respondent, and for some a `code`:
111
+ * `closed` (409, an edit while the form takes no changes), `bad_link` (403, an
112
+ * edit link that doesn't check out) or `too_many_photos` (429, the attempt or
113
+ * edit has as many uploads as the store allows; the name the host app's
114
+ * browsers already know, kept for every upload field type).
115
+ */
116
+ interface UploadRefusal {
117
+ error: string;
118
+ code?: string | undefined;
119
+ }
120
+ /** The longest upload request body: a few fields of JSON. */
121
+ declare const MAX_UPLOAD_REQUEST_BYTES = 4000;
122
+
123
+ interface MergeOptions {
124
+ /** Hidden inputs by name, when logic reads them (the same ones the form runs with). */
125
+ hidden?: Record<string, Json | undefined> | undefined;
126
+ /** The app's option lists, for choice fields with `optionsFrom` (see `withHostOptions`). */
127
+ hostOptions?: HostOptions | undefined;
128
+ /**
129
+ * Answers to questions the merged answers no longer reach (hidden by logic, jumped over):
130
+ * `"drop"` them, or `"keep"` them. Default: the form's own rule, `"keep"` only with
131
+ * `settings.clearHiddenValues: false`, as `validateSubmission` does.
132
+ */
133
+ offPath?: "drop" | "keep" | undefined;
134
+ }
135
+ /**
136
+ * `base` with `patch` on top, by field id, as the answers to store. The rules, in order:
137
+ *
138
+ * 1. A field in `patch` takes the patch's value; `null` (or `undefined`) clears it.
139
+ * 2. Only the definition's fields are kept: unknown ids, `calculated` fields (worked out
140
+ * again from the answers) and, on an anonymous form, identifying fields are dropped.
141
+ * 3. Empty answers (`""`, blank text, `[]`) are dropped.
142
+ * 4. A pick a filtered list no longer shows (`props.optionsFrom.filter`, after the answer it
143
+ * depends on changed) is dropped, as the engine drops it.
144
+ * 5. Branching: the path is worked out from the merged answers. Answers on the path are kept;
145
+ * answers off it are dropped, unless `offPath` is `"keep"` (default: kept only when the form
146
+ * has `settings.clearHiddenValues: false`).
147
+ *
148
+ * Values are kept as given, in the definition's order: nothing is checked or normalized here, so
149
+ * pass the result to `validateSubmission` (or `createForm`'s `initialValues`) as usual.
150
+ */
151
+ declare function mergeResponses(definition: FormDefinition, base: Readonly<Record<string, Json | undefined>>, patch: Readonly<Record<string, Json | undefined>>, options?: MergeOptions): Record<string, Json>;
152
+
153
+ /**
154
+ * Edit links for a sent response: `<id>.<nonce>.<expires>.<sig>`, where sig is the
155
+ * base64url HMAC-SHA256 of `edit-link:v2:<id>.<nonce>.<expires>` under a server-only
156
+ * secret of at least 32 bytes.
157
+ *
158
+ * - id: the response's uuid.
159
+ * - nonce: the response's current edit nonce, kept by the app. The app refuses a link
160
+ * whose nonce is no longer the response's, so changing the nonce revokes every link
161
+ * made before (change it after each edit: only the newest link works).
162
+ * - expires: when the link stops working, in Unix seconds, about 30 days after it was
163
+ * made (`EDIT_LINK_TTL_SECONDS`).
164
+ *
165
+ * Without a usable secret (missing, or shorter than `MIN_EDIT_SECRET_BYTES`) there are no
166
+ * edit links: signing returns null and every token fails to verify.
167
+ *
168
+ * Web Crypto only, no storage: the app keeps the nonce and checks it. Moved up from
169
+ * The host app's src/lib/forms/edit-link.ts unchanged, so links it signed still verify.
170
+ */
171
+ /** How long a new edit link works: 30 days. */
172
+ declare const EDIT_LINK_TTL_SECONDS: number;
173
+ /** The shortest secret edit links are signed with: 32 bytes (UTF-8), as long as the HMAC. */
174
+ declare const MIN_EDIT_SECRET_BYTES = 32;
175
+ /** The query parameter an edit link's token travels in: `?edit=<token>`. */
176
+ declare const EDIT_LINK_PARAM = "edit";
177
+ /** Whether `secret` can sign edit links: a string of at least `MIN_EDIT_SECRET_BYTES` bytes. */
178
+ declare function isUsableEditSecret(secret: string | null | undefined): secret is string;
179
+ /** What an edit link says: the response, its nonce when the link was made, and when it stops working. */
180
+ interface EditLinkClaims {
181
+ /** The response's id, in lower case. */
182
+ id: string;
183
+ nonce: string;
184
+ /** Unix seconds. */
185
+ expiresAt: number;
186
+ }
187
+ interface EditLinkOptions {
188
+ /** The current time in milliseconds (tests); `Date.now()` by default. */
189
+ now?: number | undefined;
190
+ /** How long the link works, in seconds; `EDIT_LINK_TTL_SECONDS` by default. */
191
+ ttlSeconds?: number | undefined;
192
+ }
193
+ /**
194
+ * The edit token for a response and its current nonce, working for `ttlSeconds` from
195
+ * now; null when there is no usable secret (no edit links) or the id or nonce is malformed.
196
+ */
197
+ declare function signEditToken(link: {
198
+ id: string;
199
+ nonce: string;
200
+ }, secret: string | null | undefined, options?: EditLinkOptions): Promise<string | null>;
201
+ /**
202
+ * What a token says, or null when it is malformed, its signature does not match, it has
203
+ * expired (or claims to last longer than `ttlSeconds` from now), or there is no usable
204
+ * secret. Whether the nonce is still the response's is the caller's check. The
205
+ * comparison is Web Crypto's HMAC verify, which runs in constant time.
206
+ */
207
+ declare function verifyEditToken(token: string | null | undefined, secret: string | null | undefined, options?: EditLinkOptions): Promise<EditLinkClaims | null>;
208
+ /**
209
+ * A form link with the edit token added: `<formUrl>?edit=<token>` (or `&edit=` when it
210
+ * has a query). `param` names the parameter; `EDIT_LINK_PARAM` by default.
211
+ */
212
+ declare function withEditToken(formUrl: string, token: string, param?: string): string;
213
+ interface EditLinkUrlOptions extends EditLinkOptions {
214
+ /** The query parameter; `EDIT_LINK_PARAM` by default. */
215
+ param?: string | undefined;
216
+ }
217
+ /**
218
+ * The link that reopens a response for editing: `formUrl` (the page that shows the form,
219
+ * with any query it needs) plus a token signed for the response's current nonce. Null
220
+ * when there are no edit links (no usable secret) or the id or nonce is malformed.
221
+ */
222
+ declare function editLinkUrl(formUrl: string, link: {
223
+ id: string;
224
+ nonce: string;
225
+ }, secret: string | null | undefined, options?: EditLinkUrlOptions): Promise<string | null>;
226
+ /** Where the edit-secret reader reports a missing or short secret. */
227
+ type EditSecretLog = (message: string) => void;
228
+ /** Reads the edit-link secret from an environment, warning once when there is none. */
229
+ interface EditSecretReader {
230
+ /** The secret, or null (no edit links) when it is missing or too short; warns once, never with the secret. */
231
+ (env: Record<string, string | undefined>, log?: EditSecretLog): string | null;
232
+ /** Tests only: warn again. */
233
+ reset(): void;
234
+ }
235
+ /**
236
+ * A reader for the environment variable that holds the edit-link secret (such as
237
+ * `FEEDBACK_EDIT_SECRET`). Without one of at least `MIN_EDIT_SECRET_BYTES` bytes there are
238
+ * no edit links, and the first read says so through `log` (the call's, else the
239
+ * reader's, else `console.warn`); later reads stay quiet until `reset()`.
240
+ */
241
+ declare function createEditSecretReader(options: {
242
+ variable: string;
243
+ log?: EditSecretLog | undefined;
244
+ }): EditSecretReader;
245
+ interface EditLinkRequestOptions {
246
+ /** The query parameter; `EDIT_LINK_PARAM` by default. */
247
+ param?: string | undefined;
248
+ /**
249
+ * Whether the URL is a page that shows a form (an edit link is only honoured there,
250
+ * such as `/f/<slug>`). Every URL by default.
251
+ */
252
+ isFormPage?: ((url: URL) => boolean) | undefined;
253
+ }
254
+ /** Whether this request is a form page opened with an edit link (the parameter present, even empty). */
255
+ declare function isEditLinkRequest(url: URL, options?: EditLinkRequestOptions): boolean;
256
+ /**
257
+ * The response, with `Referrer-Policy: no-referrer` and `Cache-Control: private, no-store`
258
+ * added when the request carried an edit link; otherwise the same response, untouched.
259
+ * The link is a credential, so it never leaves in a Referer header (an HTTP header, not
260
+ * only the page's meta tag, so it holds before the page has parsed and for every request
261
+ * the page makes), and the page, prefilled with the response, is never cached.
262
+ */
263
+ declare function withEditLinkHeaders(request: Request, response: Response, options?: EditLinkRequestOptions): Response;
264
+
265
+ /**
266
+ * The pieces of a submit endpoint's rate limit that aren't storage: the client's
267
+ * address, the part of it that names one client, a key that never holds the address,
268
+ * and a check that fails open. The counter itself is the app's (`RateLimitStore`), so
269
+ * every server instance can share it (a row in Postgres, a key in Redis).
270
+ *
271
+ * Web Crypto only. Moved up from the host app's src/lib/feedback/rate-limit.server.ts;
272
+ * keys made here equal the ones it made, so a deploy doesn't reset anyone's count.
273
+ */
274
+ /**
275
+ * The headers a platform reports the client's address in, most trusted first: Vercel's
276
+ * `x-vercel-forwarded-for` and Cloudflare's `cf-connecting-ip`, which the platform sets
277
+ * itself. `x-forwarded-for` is not read by default: its leftmost entries are whatever the
278
+ * client sent (MDN), so only the hop your own proxy appended can be trusted; opt in with
279
+ * `forwardedForHops`. A header is only trustworthy behind the platform that sets it: on any
280
+ * other host a client can send it, so pass the list your host actually has.
281
+ */
282
+ declare const DEFAULT_TRUSTED_IP_HEADERS: readonly string[];
283
+ /** Where `clientIp` looks for the address. */
284
+ interface ClientIpOptions {
285
+ /** Headers your platform sets (and overwrites), most trusted first. Default `DEFAULT_TRUSTED_IP_HEADERS`. */
286
+ headers?: readonly string[] | undefined;
287
+ /**
288
+ * Read `x-forwarded-for` too, after `headers`: the number of proxies you run in front of
289
+ * the app that each append the address they saw. The entry that many places from the
290
+ * right is the client (1: the rightmost). Entries left of it are the client's own and
291
+ * never read. Unset (the default), `x-forwarded-for` is ignored. Behind only your own
292
+ * proxies, pass `headers: []` as well, so a client can't send a platform header.
293
+ */
294
+ forwardedForHops?: number | undefined;
295
+ }
296
+ /**
297
+ * How many submits, saves as they go and photo uploads one client may make per scope
298
+ * (a practice, an account) in a window.
299
+ *
300
+ * - submit: 60 responses per 10 minutes: a class at one gym's Wi-Fi fits.
301
+ * - progress: saving answers as they go. A visit counts once, against `submit`, at its
302
+ * first save; its later saves (each Next, the send, a reload picking up where it was)
303
+ * count here, in the same window: 60 people answering 10 questions each on one Wi-Fi fit.
304
+ * - upload: 120 photos per 10 minutes: that class, a couple of photos each.
305
+ */
306
+ declare const RATE_LIMIT_DEFAULTS: {
307
+ readonly submit: {
308
+ readonly limit: 60;
309
+ readonly windowSeconds: 600;
310
+ };
311
+ readonly progress: {
312
+ readonly limit: 900;
313
+ readonly windowSeconds: 600;
314
+ };
315
+ readonly upload: {
316
+ readonly limit: 120;
317
+ readonly windowSeconds: 600;
318
+ };
319
+ };
320
+ /**
321
+ * The client's address as the platform reports it, used only as a rate-limit key: the
322
+ * first entry of the first trusted header that is set (`DEFAULT_TRUSTED_IP_HEADERS` by
323
+ * default), then, only when `forwardedForHops` is set, the `x-forwarded-for` entry that
324
+ * many places from the right; else `"unknown"`. The second argument may also be the
325
+ * header list alone.
326
+ */
327
+ declare function clientIp(headers: Pick<Headers, "get">, options?: readonly string[] | ClientIpOptions): string;
328
+ /** The part of an address that names one client: an IPv4 address whole, an IPv6 address by its /64. */
329
+ declare function addressPrefix(ip: string): string;
330
+ /**
331
+ * The stored key, `<kind>:<scope>:<hex HMAC-SHA256 of the address prefix>`: what is
332
+ * counted (such as `submit`, `progress`, `upload`), whose count it is (a practice id),
333
+ * and the client, which is never stored as its address. `secret` is a server secret.
334
+ */
335
+ declare function rateLimitKey(input: {
336
+ kind: string;
337
+ scope: string;
338
+ ip: string;
339
+ secret: string;
340
+ }): Promise<string>;
341
+ /** The app's shared counter. */
342
+ interface RateLimitStore {
343
+ /**
344
+ * Count one hit on `key` in its current `windowSeconds` window: true while the count is
345
+ * within `limit`, false past it, null when it could not count.
346
+ */
347
+ hit(key: string, limit: number, windowSeconds: number): Promise<boolean | null>;
348
+ }
349
+ /**
350
+ * True when the request may go on. Fails open: when the store can't count (it answers
351
+ * null or throws) the request goes on and `log` says so with `message`.
352
+ */
353
+ declare function allow(store: RateLimitStore, check: {
354
+ key: string;
355
+ limit: number;
356
+ windowSeconds: number;
357
+ }, options?: {
358
+ log?: ((message: string) => void) | undefined;
359
+ message?: string | undefined;
360
+ }): Promise<boolean>;
361
+
362
+ /** What the store records when an upload begins. */
363
+ interface BeginUploadInput {
364
+ formId: string;
365
+ fieldId: string;
366
+ name: string;
367
+ contentType: string;
368
+ size: number;
369
+ /** A new response's attempt (`attemptHash` of its attempt id), or… */
370
+ attemptHash: string | null;
371
+ /** …the id of the sent response an edit link is changing. Exactly one of the two. */
372
+ editOf: string | null;
373
+ }
374
+ /** One upload as the store reads it back, with what storage holds for it. */
375
+ interface StoredUpload {
376
+ id: string;
377
+ fieldId: string;
378
+ attemptHash: string | null;
379
+ editOf: string | null;
380
+ /** The response that took it, once one did. */
381
+ responseId: string | null;
382
+ name: string;
383
+ contentType: string;
384
+ size: number;
385
+ /** Whether the file is in storage (the respondent's browser finished sending it). */
386
+ stored: boolean;
387
+ /** The type and size storage holds, which are trusted over what the browser said. */
388
+ storedType: string | null;
389
+ storedSize: number | null;
390
+ }
391
+ /** What uploads need from the app's database and storage; the app's service-role client, say. */
392
+ interface UploadStore {
393
+ /**
394
+ * Records one upload and names its object in storage; `{ tooMany: true }` once the
395
+ * attempt or edit has as many as the app allows (the host app: 20).
396
+ */
397
+ beginUpload(input: BeginUploadInput): Promise<{
398
+ id: string;
399
+ objectName: string;
400
+ } | {
401
+ tooMany: true;
402
+ }>;
403
+ /** Signs an upload of that object, sent to the browser as `upload` (`SignedUpload`). */
404
+ signUpload(objectName: string): Promise<SignedUpload>;
405
+ /** This form's uploads with these ids; others are left out. */
406
+ uploadRefs(formId: string, ids: string[]): Promise<StoredUpload[]>;
407
+ }
408
+ /** Who may use an upload: a new response's attempt, or an edit of a sent response. */
409
+ type UploadOwner = {
410
+ attemptHash: string;
411
+ } | {
412
+ responseId: string;
413
+ };
414
+ /** The form's upload fields (`UPLOAD_FIELD_TYPES`: photo). */
415
+ declare function uploadFields(definition: FormDefinition): FieldBlock[];
416
+ /** An upload field type's limits and its words for a file it doesn't take. */
417
+ interface UploadRules {
418
+ maxFiles: number;
419
+ maxBytes: number;
420
+ mimeTypes: readonly string[];
421
+ typeMessage: string;
422
+ sizeMessage: string;
423
+ countMessage: string;
424
+ }
425
+ /** The rules for an upload field, or null when the field doesn't take files. */
426
+ declare function uploadRules(field: FieldBlock): UploadRules | null;
427
+ /** Said when a reference isn't a file this respondent finished uploading here. */
428
+ declare const NOT_UPLOADED_MESSAGE = "A photo didn't finish uploading. Remove it and add it again.";
429
+ interface CheckUploadsOptions {
430
+ /** The form's id, as the store keys its uploads. */
431
+ formId: string;
432
+ owner: UploadOwner;
433
+ /** Needed only when the answers name a file. */
434
+ store?: Pick<UploadStore, "uploadRefs"> | undefined;
435
+ }
436
+ type CheckedUploads = {
437
+ ok: true;
438
+ answers: Record<string, Json>;
439
+ } | {
440
+ ok: false;
441
+ errors: Record<string, string>;
442
+ };
443
+ /**
444
+ * Checks every file the answers name: uploaded by this attempt (or for this edit) to this
445
+ * form's field, in storage, of a type and size the field takes, and no more files than it
446
+ * takes. The stored name, type and size replace what the browser sent. Answers without
447
+ * files are returned as they are, without asking the store. Errors are keyed by field id;
448
+ * a store that throws throws (answer 503: nothing can be checked).
449
+ */
450
+ declare function checkUploads(definition: FormDefinition, answers: Record<string, Json>, options: CheckUploadsOptions): Promise<CheckedUploads>;
451
+ /**
452
+ * The stored hash of a visit's attempt id: hex SHA-256 (Web Crypto). It equals
453
+ * The host app's `resumeTokenHash`, so uploads recorded before the move still match.
454
+ */
455
+ declare function attemptHash(attemptId: string): Promise<string>;
456
+
457
+ /** The form an upload is for, as the app found it from the request. */
458
+ interface UploadForm {
459
+ /** The form's id, as the store keys uploads. */
460
+ id: string;
461
+ /** The published definition respondents answer. */
462
+ definition: FormDefinition;
463
+ /** Whether it takes responses now (not paused, closed, full…). */
464
+ open: boolean;
465
+ /** Whose rate-limit count this is: a practice, an account. */
466
+ scope: string;
467
+ /** Whether its edit links work. Default false: no uploads for an edit. */
468
+ allowsEdits?: boolean | undefined;
469
+ /** Names the form in logs, such as its slug. */
470
+ label?: string | undefined;
471
+ }
472
+ /** A sent response an edit link may change, as the app loads it. */
473
+ interface EditableResponse {
474
+ id: string;
475
+ /** The response's current nonce: only a link made with it works. */
476
+ editNonce: string;
477
+ }
478
+ /** The handler's words; each default is what the host app said. */
479
+ interface UploadMessages {
480
+ /** 415 / 400: not a JSON object. */
481
+ notJson: string;
482
+ /** 413: past `maxRequestBytes`. */
483
+ tooLong: string;
484
+ /** 400: no attempt id, or not a UUID. */
485
+ reload: string;
486
+ /** 404: `resolveForm` found no form. */
487
+ unavailable: string;
488
+ /** 409: the form isn't open. */
489
+ notOpen: string;
490
+ /** 409 (`code: "closed"`): an edit while the form isn't open. */
491
+ notTakingChanges: string;
492
+ /** 403 (`code: "bad_link"`): an edit link that doesn't check out, or a form without edits. */
493
+ badEditLink: string;
494
+ /** 400: the field isn't an upload field of this form. */
495
+ notUploadField: string;
496
+ /** 429: the rate limit. */
497
+ rateLimited: string;
498
+ /** 429 (`code: "too_many_photos"`): the store has as many uploads for this attempt or edit as it allows. */
499
+ tooMany: string;
500
+ /** 503 / 500: something failed on the server; nothing more is said. */
501
+ failed: string;
502
+ }
503
+ declare const UPLOAD_MESSAGES: UploadMessages;
504
+ type Secret = string | null | undefined;
505
+ interface UploadHandlerOptions<P = void> {
506
+ /**
507
+ * The form the request is for (from its host, path or `params`), or null (404). A throw
508
+ * answers 503 and logs the error's message.
509
+ */
510
+ resolveForm(request: Request, params: P): Promise<UploadForm | null>;
511
+ store: UploadStore;
512
+ /**
513
+ * Uploads for an edit link (`edit` in the request). Without it, or without a usable
514
+ * secret, every edit link is refused (403).
515
+ */
516
+ edit?: {
517
+ /** The edit-link secret, or a reader of it (`createEditSecretReader`). */
518
+ secret: Secret | (() => Secret);
519
+ /**
520
+ * The sent response with this id, of this form (and its scope), or null (403).
521
+ * A throw answers 503.
522
+ */
523
+ response(id: string, form: UploadForm): Promise<EditableResponse | null>;
524
+ } | undefined;
525
+ /** Counted per scope and client address, as `kind: "upload"`. Without it, nothing is counted. */
526
+ rateLimit?: {
527
+ store: RateLimitStore;
528
+ /** The server secret the address is hashed with (`rateLimitKey`). */
529
+ secret: string;
530
+ /** Default `RATE_LIMIT_DEFAULTS.upload`: 120 per 600 seconds. */
531
+ limit?: number | undefined;
532
+ windowSeconds?: number | undefined;
533
+ /** Default `DEFAULT_TRUSTED_IP_HEADERS`. */
534
+ trustedIpHeaders?: readonly string[] | undefined;
535
+ /** Read `x-forwarded-for` at this many proxies from the right (`ClientIpOptions.forwardedForHops`). Default unset: never read. */
536
+ forwardedForHops?: number | undefined;
537
+ } | undefined;
538
+ /** The stored hash of an attempt id. Default `attemptHash` (hex SHA-256). */
539
+ hashAttempt?: ((attemptId: string) => string | Promise<string>) | undefined;
540
+ /** Default `MAX_UPLOAD_REQUEST_BYTES` (4,000). */
541
+ maxRequestBytes?: number | undefined;
542
+ /** The current time (tests): edit links expire. */
543
+ now?: (() => Date) | undefined;
544
+ /** Where failures are said; `console.error` with an `[upload]` prefix by default. Never sent to the browser. */
545
+ log?: ((message: string) => void) | undefined;
546
+ messages?: Partial<UploadMessages> | undefined;
547
+ }
548
+ /**
549
+ * The upload endpoint: `(request, params) => Response`, for a POST route. `params` is
550
+ * whatever the route has (a form slug, say), passed to `resolveForm`. See the README's
551
+ * "Uploads" for the JSON and every status.
552
+ */
553
+ declare function createUploadHandler<P = void>(options: UploadHandlerOptions<P>): (request: Request, params: P) => Promise<Response>;
554
+
555
+ /** What the store keeps for one response: everything `submitResponse` checked, as JSON. */
556
+ interface SubmissionRecord {
557
+ /** `options.formId`, else the definition's id. */
558
+ formId: string;
559
+ /** The device's key. The store keeps one response per `formId` and key (a unique index). */
560
+ idempotencyKey: string;
561
+ /**
562
+ * Hex SHA-256 of the answers, events and hidden inputs as sent. A retry sends the same
563
+ * body, so the same hash; the store hands it back with a duplicate so a key reused for
564
+ * other answers is refused instead of being taken as delivered.
565
+ */
566
+ inputHash: string;
567
+ /** `options.version`, else `fingerprint` of the published definition. */
568
+ definitionVersion: string;
569
+ /** The server's time, ISO 8601. */
570
+ receivedAt: string;
571
+ /** The device's time of submitting, ISO 8601, or null. Untrusted. */
572
+ clientSubmittedAt: string | null;
573
+ /** Checked answers by field id: visible ones only, uploads as stored, calculated fields worked out here. */
574
+ answers: Record<string, Json>;
575
+ /** Untrusted URL and host inputs, coerced to their types. */
576
+ hidden: Record<string, Json>;
577
+ /** Trusted server inputs (`options.context`). */
578
+ context: Record<string, Json>;
579
+ ending: string | null;
580
+ /** Client-sent keys that were dropped (see `CheckedSubmission.ignored`). */
581
+ ignored: string[];
582
+ /** Each answer to a question with options from the app, as `{ value, label }` now (`answerSnapshots`). */
583
+ snapshots: Record<string, AnswerSnapshot>;
584
+ /** The ids of the uploads the answers name, checked: mark them taken by this response in the same transaction. */
585
+ uploadIds: string[];
586
+ }
587
+ /** A response the store already holds under an idempotency key. */
588
+ interface StoredSubmissionRef {
589
+ id: string;
590
+ /** ISO 8601, as it was saved. */
591
+ receivedAt: string;
592
+ /** `SubmissionRecord.inputHash` as it was saved. */
593
+ inputHash: string;
594
+ }
595
+ /** Where responses go: the app's database (a service-role client, say). */
596
+ interface SubmissionStore {
597
+ /**
598
+ * Store one response. Exactly one response per `formId` and `idempotencyKey`: when one is
599
+ * already there (a unique index refused the insert), answer `{ duplicate }` with it rather
600
+ * than storing again. Throw when it can't store (answered `store_failed`, and the device
601
+ * retries). Save the response and queue its deliveries in one transaction, so a stored
602
+ * response is always delivered.
603
+ */
604
+ save(record: SubmissionRecord): Promise<{
605
+ id: string;
606
+ } | {
607
+ duplicate: StoredSubmissionRef;
608
+ }>;
609
+ /**
610
+ * The response stored under this key, or null. Asked first, so a retry of a response that
611
+ * went through is answered `duplicate` even after the form has closed, the client is rate
612
+ * limited, the definition changed or its uploads were taken. `save` must still refuse a
613
+ * duplicate on its own (two retries can race).
614
+ */
615
+ find(formId: string, idempotencyKey: string): Promise<StoredSubmissionRef | null>;
616
+ }
617
+ interface SubmitResponseOptions extends Omit<SubmissionOptions, "complete" | "hostOptions"> {
618
+ /** The published definition respondents answer, loaded by the server. */
619
+ definition: FormDefinition;
620
+ store: SubmissionStore;
621
+ /**
622
+ * The option lists for `optionsFrom: { source: "host" }` questions, loaded here afresh
623
+ * (never taken from the client): an answer must be one of them.
624
+ */
625
+ hostOptions?: SubmissionOptions["hostOptions"];
626
+ /** The id the store keys this form's responses (and uploads) by. Default `definition.id`. */
627
+ formId?: string | undefined;
628
+ /** The published version's label, stored as `definitionVersion`. Default `fingerprint(definition)`. */
629
+ version?: string | undefined;
630
+ /** Whether the form takes responses now (not paused, closed, full). Default true; false answers `closed`. */
631
+ open?: boolean | undefined;
632
+ /**
633
+ * The rate limit: true lets the submission on; false, or `{ retryAfterSeconds }`, answers
634
+ * `rate_limited`. Typically
635
+ * `() => allow(store, { key: await rateLimitKey(...), ...RATE_LIMIT_DEFAULTS.submit })`.
636
+ * A throw lets it on (fails open, as `allow` does) and is logged.
637
+ */
638
+ rateLimit?: (() => RateLimitAnswer | Promise<RateLimitAnswer>) | undefined;
639
+ /**
640
+ * Needed when the form has upload fields: where uploads are recorded, and who may use the
641
+ * ones named. `owner` defaults to the request's attempt (`attemptHash(input.attemptId)`);
642
+ * without either, answers that name files are refused. Without `uploads` such a form
643
+ * throws, on every submit.
644
+ */
645
+ uploads?: {
646
+ store: Pick<UploadStore, "uploadRefs">;
647
+ owner?: UploadOwner | undefined;
648
+ } | undefined;
649
+ /** The current time (tests). */
650
+ now?: (() => Date) | undefined;
651
+ /** Where failures are said; `console.error` with a `[submit]` prefix by default. Never holds an answer. */
652
+ log?: ((message: string) => void) | undefined;
653
+ }
654
+ /** What `rateLimit` answers: true to let the submission on. */
655
+ type RateLimitAnswer = boolean | {
656
+ retryAfterSeconds: number;
657
+ };
658
+ declare function submitResponse(input: SubmitRequest, options: SubmitResponseOptions): Promise<SubmitResult>;
659
+
660
+ interface SubmissionInput {
661
+ /** Answers keyed by field id. Ignored when `events` is given. */
662
+ answers?: Record<string, Json> | undefined;
663
+ /** The respondent's event log. Replayed instead of trusting `answers`. */
664
+ events?: FormEvent[] | undefined;
665
+ /** URL and host inputs sent by the client. Server inputs here are ignored. */
666
+ hidden?: Record<string, Json> | undefined;
667
+ }
668
+ interface SubmissionOptions {
669
+ /** Values for `source: "server"` inputs, resolved by the server (account id, location, user id…). */
670
+ context?: Record<string, Json> | undefined;
671
+ /** `false` checks a draft: formats only, nothing required. Default true. */
672
+ complete?: boolean | undefined;
673
+ validators?: Record<string, FieldValidator> | undefined;
674
+ /**
675
+ * Most events accepted, counted after `compactEvents`. Default `MAX_EVENTS`
676
+ * (10,000). At most `RAW_EVENTS_PER_EVENT` (10) times as many may be sent.
677
+ */
678
+ maxEvents?: number | undefined;
679
+ /**
680
+ * Most JSON values in the answers, answer events and hidden inputs
681
+ * together (each number, text, true / false, list item, row and cell
682
+ * counts one; a run of answers to one field, as typing in a table sends,
683
+ * counts its last only). Default `MAX_SUBMISSION_VALUES` (200,000).
684
+ */
685
+ maxValues?: number | undefined;
686
+ /**
687
+ * Most work replaying the events may take: one unit per block each time
688
+ * the path is walked, one per calculated field each time the answers
689
+ * settle, and a formula's size (its numbers, answers and operators) each
690
+ * time it is worked out. Default `MAX_REPLAY_WORK` (1,000,000). Only the
691
+ * replay counts: the submit that follows it does not.
692
+ */
693
+ maxReplayWork?: number | undefined;
694
+ /**
695
+ * The option lists the app supplies for `optionsFrom: { source: "host" }`
696
+ * choice fields, loaded here on the server (never taken from the client):
697
+ * an answer must be one of the field's own options or of these. Pass what
698
+ * the form was shown with, loaded afresh, such as the current staff.
699
+ */
700
+ hostOptions?: HostOptions | undefined;
701
+ }
702
+ interface CheckedSubmission {
703
+ status: "draft" | "completed";
704
+ /**
705
+ * Visible answers only, keyed by field id (kept hidden answers too, with
706
+ * `clearHiddenValues: false`). Calculated fields hold the value worked out
707
+ * here from these answers.
708
+ */
709
+ answers: Record<string, Json>;
710
+ /** Untrusted URL and host inputs, coerced to their declared types. Identifying ones are left out on anonymous forms. */
711
+ hidden: Record<string, Json>;
712
+ /** Trusted server inputs. Identifying ones are left out on anonymous forms. */
713
+ context: Record<string, Json>;
714
+ ending: string | null;
715
+ /** Client-sent keys that were dropped, each once: unknown fields, server inputs, undeclared or withheld hidden names. */
716
+ ignored: string[];
717
+ }
718
+ type SubmissionResult = {
719
+ ok: true;
720
+ submission: CheckedSubmission;
721
+ } | {
722
+ ok: false;
723
+ errors: Record<string, string>;
724
+ };
725
+ /** Default for `SubmissionOptions.maxEvents`. */
726
+ declare const MAX_EVENTS = 10000;
727
+ /** Default for `SubmissionOptions.maxValues`. */
728
+ declare const MAX_SUBMISSION_VALUES = 200000;
729
+ /** Default for `SubmissionOptions.maxReplayWork`. */
730
+ declare const MAX_REPLAY_WORK = 1000000;
731
+ /**
732
+ * Most events sent, before compaction, as a multiple of `maxEvents`: a
733
+ * keystroke log compacts to far fewer, but every event sent is looked at.
734
+ */
735
+ declare const RAW_EVENTS_PER_EVENT = 10;
736
+ /**
737
+ * Check a submission against the published definition. Never throws on a
738
+ * malformed submission: it returns `{ ok: false, errors: { "": … } }`.
739
+ */
740
+ declare function validateSubmission(published: FormDefinition, submission: SubmissionInput, options?: SubmissionOptions): SubmissionResult;
741
+ /**
742
+ * The definition as respondents may see it. R1 forms hold nothing secret;
743
+ * assessment answer keys (R4) will be stripped here.
744
+ */
745
+ declare function publicDefinition(definition: FormDefinition): FormDefinition;
746
+
747
+ /**
748
+ * What a respondent's device sends to submit a response, and what comes back. Shared by
749
+ * `submitResponse` (server) and `createSubmitOutbox` (client), so both sides read one
750
+ * definition. Plain JSON both ways: a host's endpoint passes the request body to
751
+ * `submitResponse` and answers with its result as it is.
752
+ */
753
+
754
+ /**
755
+ * One submission as the device sends it: the answers (or the event log) and hidden inputs
756
+ * `validateSubmission` takes, plus a key that stays the same across every retry of it.
757
+ */
758
+ interface SubmitRequest extends SubmissionInput {
759
+ /**
760
+ * Made once, on the device, when the respondent submits (a UUID), and sent again on every
761
+ * retry, so the server stores the response once however often it arrives.
762
+ * 16 to 128 characters: letters, digits, `-` and `_` (`isIdempotencyKey`).
763
+ */
764
+ idempotencyKey: string;
765
+ /**
766
+ * When the respondent submitted, by the device's clock (milliseconds since 1970). Untrusted
767
+ * and informational: an outbox may deliver it hours later. The server's own time is
768
+ * `receivedAt`.
769
+ */
770
+ clientSubmittedAt?: number | undefined;
771
+ /**
772
+ * The visit's attempt id (a UUID made when the form opened), when the form has upload
773
+ * fields: the server hashes it (`attemptHash`) to find the uploads this visit made. Sent in
774
+ * the body so a retry from the outbox, after a restart or from a native app, still carries it.
775
+ */
776
+ attemptId?: string | undefined;
777
+ }
778
+ /** Why a submission was not stored as a new response. */
779
+ type SubmitFailureReason = "invalid" | "duplicate" | "conflict" | "closed" | "rate_limited" | "store_failed";
780
+ /**
781
+ * What `submitResponse` answers. A new response was stored only when `ok` is true.
782
+ *
783
+ * - `invalid`: the submission (or its idempotency key) doesn't pass; `errors` is keyed by
784
+ * field id, `""` for the submission as a whole. Sending it again won't help.
785
+ * - `duplicate`: a response with this idempotency key and these answers is already stored,
786
+ * as `id`. The device's earlier attempt went through: treat it as delivered, and deliver
787
+ * nothing again.
788
+ * - `conflict`: a response with this idempotency key is stored, with other answers. The
789
+ * device must not take it as delivered, and sending it again won't help.
790
+ * - `closed`: the form isn't taking responses. Sending it again won't help.
791
+ * - `rate_limited`: too many from this client; try again later, after `retryAfterSeconds`
792
+ * when it is given.
793
+ * - `store_failed`: it couldn't be checked or stored this time; try again later.
794
+ *
795
+ * More reasons may be added. A device treats one it doesn't know as `retry`
796
+ * (`submitOutcome`), so it keeps the submission.
797
+ */
798
+ type SubmitResult = {
799
+ ok: true;
800
+ id: string;
801
+ receivedAt: string;
802
+ } | {
803
+ ok: false;
804
+ reason: "duplicate";
805
+ id: string;
806
+ receivedAt: string;
807
+ } | {
808
+ ok: false;
809
+ reason: "invalid";
810
+ errors: Record<string, string>;
811
+ } | {
812
+ ok: false;
813
+ reason: "conflict";
814
+ } | {
815
+ ok: false;
816
+ reason: "closed";
817
+ } | {
818
+ ok: false;
819
+ reason: "rate_limited";
820
+ retryAfterSeconds?: number;
821
+ } | {
822
+ ok: false;
823
+ reason: "store_failed";
824
+ };
825
+ /** What an idempotency key matches: 16 to 128 letters, digits, `-` and `_`. */
826
+ declare const IDEMPOTENCY_KEY_PATTERN: RegExp;
827
+ declare function isIdempotencyKey(value: unknown): value is string;
828
+ /** What a device does with an answer to a submit. */
829
+ type SubmitOutcome = "delivered" | "retry" | "rejected";
830
+ /**
831
+ * What a device does with an answer: `delivered` (stored now or before: drop it from the
832
+ * outbox), `retry` (keep it and try again later) or `rejected` (keep it, marked failed, for
833
+ * the respondent to fix or discard). Anything that isn't a `SubmitResult` is `retry`, so an
834
+ * unexpected answer (a proxy's error page, say) never loses a submission.
835
+ */
836
+ declare function submitOutcome(result: unknown): SubmitOutcome;
837
+ /** Whether `value` is a `SubmitResult`, for a device reading an endpoint's JSON. */
838
+ declare function isSubmitResult(value: unknown): value is SubmitResult;
839
+
840
+ export { MAX_SUBMISSION_VALUES as $, photoRefs as A, photoSizeMessage as B, photoTypeMessage as C, DEFAULT_PHOTO_FILES as D, submitOutcome as E, type BeginUploadInput as F, type CheckUploadsOptions as G, type CheckedSubmission as H, IDEMPOTENCY_KEY_PATTERN as I, type CheckedUploads as J, type ClientIpOptions as K, DEFAULT_TRUSTED_IP_HEADERS as L, MAX_PHOTO_FILES as M, EDIT_LINK_PARAM as N, EDIT_LINK_TTL_SECONDS as O, type PhotoFormat as P, type EditLinkClaims as Q, type EditLinkOptions as R, type SubmissionInput as S, type EditLinkRequestOptions as T, UPLOAD_FIELD_TYPES as U, type EditLinkUrlOptions as V, type EditSecretLog as W, type EditSecretReader as X, type EditableResponse as Y, MAX_EVENTS as Z, MAX_REPLAY_WORK as _, type PhotoRef as a, MIN_EDIT_SECRET_BYTES as a0, NOT_UPLOADED_MESSAGE as a1, RATE_LIMIT_DEFAULTS as a2, RAW_EVENTS_PER_EVENT as a3, type RateLimitAnswer as a4, type RateLimitStore as a5, type StoredSubmissionRef as a6, type StoredUpload as a7, type SubmissionOptions as a8, type SubmissionRecord as a9, validateSubmission as aA, verifyEditToken as aB, withEditLinkHeaders as aC, withEditToken as aD, type SubmissionResult as aa, type SubmissionStore as ab, type SubmitResponseOptions as ac, UPLOAD_MESSAGES as ad, type UploadForm as ae, type UploadHandlerOptions as af, type UploadMessages as ag, type UploadOwner as ah, type UploadRules as ai, type UploadStore as aj, addressPrefix as ak, allow as al, attemptHash as am, checkUploads as an, clientIp as ao, createEditSecretReader as ap, createUploadHandler as aq, editLinkUrl as ar, isEditLinkRequest as as, isUsableEditSecret as at, publicDefinition as au, rateLimitKey as av, signEditToken as aw, submitResponse as ax, uploadFields as ay, uploadRules as az, type SubmitRequest as b, type SubmitFailureReason as c, type SubmitResult as d, DEFAULT_PHOTO_SIZE_MB as e, MAX_PHOTO_ID_LENGTH as f, MAX_PHOTO_NAME_LENGTH as g, MAX_PHOTO_SIZE_MB as h, MAX_UPLOAD_REQUEST_BYTES as i, type MergeOptions as j, PHOTO_FORMATS as k, PHOTO_TYPE as l, type PhotoLimits as m, type SignedUpload as n, type SubmitOutcome as o, type UploadGrant as p, type UploadRefusal as q, type UploadRequest as r, isIdempotencyKey as s, isPhotoFormat as t, isPhotoRef as u, isSubmitResult as v, mergeResponses as w, photoCountMessage as x, photoFormatList as y, photoLimits as z };