@marktiderman/genesis-forms 0.8.0 → 0.9.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/README.md +299 -10
- package/dist/destinations.cjs +202 -39
- package/dist/destinations.cjs.map +1 -1
- package/dist/destinations.d.cts +67 -21
- package/dist/destinations.d.ts +67 -21
- package/dist/destinations.js +202 -40
- package/dist/destinations.js.map +1 -1
- package/dist/{engine-Bj23YJlB.d.cts → engine-BaHPC2-1.d.cts} +2 -2
- package/dist/{engine-CzinTJph.d.ts → engine-Da99Tv-E.d.ts} +2 -2
- package/dist/index.cjs +996 -167
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +288 -11
- package/dist/index.d.ts +288 -11
- package/dist/index.js +975 -168
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +563 -173
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +3 -474
- package/dist/server.d.ts +3 -474
- package/dist/server.js +558 -174
- package/dist/server.js.map +1 -1
- package/dist/submit-protocol-CFi-0Qd0.d.ts +840 -0
- package/dist/submit-protocol-D0cOckmK.d.cts +840 -0
- package/dist/testing.cjs +250 -154
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +4 -4
- package/dist/testing.d.ts +4 -4
- package/dist/testing.js +250 -154
- package/dist/testing.js.map +1 -1
- package/dist/{types-jqDKcmP4.d.cts → types-C0pUfew3.d.cts} +7 -1
- package/dist/{types-jqDKcmP4.d.ts → types-C0pUfew3.d.ts} +7 -1
- package/dist/{validate-DvfQHYnu.d.ts → validate-DpmBssFk.d.ts} +258 -140
- package/dist/{validate-irIPubxc.d.cts → validate-U5LoW3eR.d.cts} +258 -140
- package/package.json +2 -2
- package/dist/upload-protocol-BOe018kL.d.cts +0 -122
- 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 };
|