@marktiderman/genesis-forms 0.5.0 → 0.7.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 +187 -1
- package/dist/destinations.cjs +55 -3
- package/dist/destinations.cjs.map +1 -1
- package/dist/destinations.d.cts +1 -1
- package/dist/destinations.d.ts +1 -1
- package/dist/destinations.js +55 -3
- package/dist/destinations.js.map +1 -1
- package/dist/{engine-Qzp6bX6O.d.ts → engine-C5tb3mkf.d.cts} +10 -3
- package/dist/{engine-BDq0XJ21.d.cts → engine-DcNQ-6oJ.d.ts} +10 -3
- package/dist/index.cjs +488 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +31 -66
- package/dist/index.d.ts +31 -66
- package/dist/index.js +464 -63
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +769 -21
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +386 -3
- package/dist/server.d.ts +386 -3
- package/dist/server.js +742 -22
- package/dist/server.js.map +1 -1
- package/dist/testing.cjs +314 -21
- 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 +314 -21
- package/dist/testing.js.map +1 -1
- package/dist/{types-BhYUbLJ-.d.cts → types-oAckGR_Z.d.cts} +10 -0
- package/dist/{types-BhYUbLJ-.d.ts → types-oAckGR_Z.d.ts} +10 -0
- package/dist/upload-protocol-Bw0CWRJc.d.cts +122 -0
- package/dist/upload-protocol-PX0jroTV.d.ts +122 -0
- package/dist/validate-BFpNJbjf.d.cts +322 -0
- package/dist/validate-qwkdcr3H.d.ts +322 -0
- package/package.json +1 -1
- package/dist/validate-Bs1lyoOl.d.ts +0 -131
- package/dist/validate-BsZ0YTQS.d.cts +0 -131
package/README.md
CHANGED
|
@@ -143,7 +143,7 @@ A `photo` field asks for up to a few photos, such as a picture of broken equipme
|
|
|
143
143
|
returned, never the bytes: `PhotoRef[]`, each `{ id, name, type, size }`. The renderer uploads
|
|
144
144
|
through the host (`onUpload` on genesis-forms-web's `Form`), so the engine and `validateSubmission`
|
|
145
145
|
stay storage-agnostic; the host's server should check that every reference is a file it stored
|
|
146
|
-
for this respondent and form before saving.
|
|
146
|
+
for this respondent and form before saving (`checkUploads`, see [Uploads](#uploads)).
|
|
147
147
|
|
|
148
148
|
| Prop | Default | Rule |
|
|
149
149
|
| --- | --- | --- |
|
|
@@ -262,6 +262,91 @@ options only. Answers store the option's value; `summarizeValue` shows its label
|
|
|
262
262
|
it. `parseForm` checks the shape of `optionsFrom`, and doesn't warn about rules comparing with
|
|
263
263
|
values only the app knows.
|
|
264
264
|
|
|
265
|
+
### Option details, filtered lists, search and links
|
|
266
|
+
|
|
267
|
+
An option can carry more than its value and label, from the question's own `options` or the
|
|
268
|
+
app's list:
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
{ value: "riverside", label: "Riverside Gym",
|
|
272
|
+
description: "12 Main St", // a muted line under the label
|
|
273
|
+
image: "https://cdn.example.com/riverside.png", // a small picture; https only
|
|
274
|
+
attributes: { region: "north", coaches: ["ada", "sam"] } } // for filters and recall
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`readOption` keeps each detail only when it is well formed and within `OPTION_LIMITS`
|
|
278
|
+
(description 500 characters, image URL 2048 and `https://` only (`isHttpsUrl`), at most 20
|
|
279
|
+
attributes, each a text or a list of up to 50 texts of 200 characters). `parseForm` reports a
|
|
280
|
+
definition's malformed details; an app's list is read leniently (the bad detail is dropped).
|
|
281
|
+
|
|
282
|
+
**Filtered by another answer.** `optionsFrom.filter` shows only the options whose attribute
|
|
283
|
+
matches an earlier question's answer, such as coaches at the chosen location:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
f.dropdown("location", { label: "Where?", optionsFrom: { source: "host", key: "locations" } });
|
|
287
|
+
f.singleChoice("coach", {
|
|
288
|
+
label: "Who coached you?",
|
|
289
|
+
optionsFrom: { source: "host", key: "coaches", filter: { attribute: "location", field: "location" } },
|
|
290
|
+
});
|
|
291
|
+
// a coach: { value: "ada", label: "Ada", attributes: { location: ["riverside", "hilltop"] } }
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
`field` is the earlier question's key (`parseForm` checks it exists and comes first). An option
|
|
295
|
+
matches when its attribute (a text or a list) shares a value with the answer (a text, or a
|
|
296
|
+
multiple choice's list); one without the attribute never matches. Until that question is answered
|
|
297
|
+
(or while it is off the path) every option shows. `visibleOptions(definition, field, values)`
|
|
298
|
+
says what shows, `answersOnPath(state)` gives it the answers it should read, and React renderers
|
|
299
|
+
use `useChoiceOptions` (genesis-forms-react). When the answer changes, the engine clears picks the
|
|
300
|
+
filter no longer shows (`clearFilteredAnswers`, following on through filters of filters); a pick
|
|
301
|
+
outside the filtered list is refused by the engine and by `validateSubmission`
|
|
302
|
+
(`filteredChoiceError`).
|
|
303
|
+
|
|
304
|
+
**Search.** `isSearchable(field, count)` says whether a renderer puts a search box over the
|
|
305
|
+
options: `props.searchable` when it is true or false, else more than `SEARCHABLE_OPTION_COUNT`
|
|
306
|
+
(10) options. `searchOptions(options, query)` is the match every renderer uses: every word of the
|
|
307
|
+
query in the label, value or description, ignoring case and accents.
|
|
308
|
+
|
|
309
|
+
**Preselect from a link.** `props.prefill: { param }` on a choice question takes a link's query
|
|
310
|
+
parameter, such as `?location=hilltop`:
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
f.dropdown("location", { label: "Where?", optionsFrom: { source: "host", key: "locations" },
|
|
314
|
+
prefill: { param: "location", hideWhenPrefilled: true } });
|
|
315
|
+
|
|
316
|
+
createForm(definition, { hostOptions: lists, linkParams: new URLSearchParams(location.search) });
|
|
317
|
+
// or <Form hostOptions={lists} linkParams={new URLSearchParams(location.search)}>
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
`prefillFromLink(definition, params, values)` resolves it: the value is taken only when it is one
|
|
321
|
+
of the options the question shows (a filtered list too), anything else is ignored; a multiple
|
|
322
|
+
choice takes repeated parameters or one comma-separated value. The picks become `initialValues`
|
|
323
|
+
(logged as `source: "prefill"` answers; the host's own `initialValues` win), so the server checks
|
|
324
|
+
them like any answer. With `hideWhenPrefilled` the question is skipped while it holds the link's
|
|
325
|
+
answer: `hiddenByPrefill(definition, state)` lists such questions, the engine's Next and Back step
|
|
326
|
+
over them, and `respondentPath(definition, state)` is the path a renderer shows and counts. The
|
|
327
|
+
answer is stored as usual. A parameter name is letters, digits, `_`, `-` and `.`, at most 64
|
|
328
|
+
characters (`isPrefillParam`).
|
|
329
|
+
|
|
330
|
+
**Snapshots of what was picked.** Store the option's label with its value, so reports group by the
|
|
331
|
+
id and history keeps the name it had then:
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
import { answerSnapshots, answersFromSnapshots, withHostOptions } from "@marktiderman/genesis-forms/server";
|
|
335
|
+
|
|
336
|
+
const shown = withHostOptions(definition, lists);
|
|
337
|
+
const snapshots = answerSnapshots(shown, result.submission.answers);
|
|
338
|
+
// { location: { value: "riverside", label: "Riverside Gym" }, helpers: [{ value: "ada", label: "Ada" }] }
|
|
339
|
+
|
|
340
|
+
// An edit link: find each pick again in today's lists.
|
|
341
|
+
const { answers, missing } = answersFromSnapshots(withHostOptions(definition, listsNow), stored);
|
|
342
|
+
createForm(definition, { hostOptions: listsNow, initialValues: answers });
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
`answerSnapshots` covers questions with `optionsFrom`; a typed "Other" answer is marked `other`.
|
|
346
|
+
`answersFromSnapshots` keeps a value still offered, else takes an option with the same label
|
|
347
|
+
(ignoring case and accents), keeps a typed "Other" answer when the question still allows one, and
|
|
348
|
+
reports the rest in `missing`. Both are also exported from the main entry.
|
|
349
|
+
|
|
265
350
|
### An "Other" answer
|
|
266
351
|
|
|
267
352
|
```ts
|
|
@@ -302,6 +387,107 @@ limits above (`maxEvents`, `maxValues`, `maxReplayWork`). Events of an unknown t
|
|
|
302
387
|
client's log still replays. With `settings.clearHiddenValues: false`, a hidden field's value is kept
|
|
303
388
|
only if its format is valid.
|
|
304
389
|
|
|
390
|
+
### Edit links
|
|
391
|
+
|
|
392
|
+
A respondent can change a sent response through a signed link, `?edit=<id>.<nonce>.<expires>.<sig>`:
|
|
393
|
+
an HMAC-SHA256 (Web Crypto) under a server secret of at least 32 bytes, working for 30 days. The app
|
|
394
|
+
keeps a nonce per response and changes it after each edit, so only the newest link works; it checks
|
|
395
|
+
the nonce itself. Without a usable secret there are no edit links: signing returns null and nothing
|
|
396
|
+
verifies.
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
import {
|
|
400
|
+
createEditSecretReader,
|
|
401
|
+
editLinkUrl,
|
|
402
|
+
verifyEditToken,
|
|
403
|
+
withEditLinkHeaders,
|
|
404
|
+
} from "@marktiderman/genesis-forms/server";
|
|
405
|
+
|
|
406
|
+
const editSecret = createEditSecretReader({ variable: "FORM_EDIT_SECRET" }); // warns once when missing
|
|
407
|
+
const editUrl = await editLinkUrl(`/f/${slug}`, { id: response.id, nonce: response.editNonce }, editSecret(env));
|
|
408
|
+
const claims = await verifyEditToken(url.searchParams.get("edit"), editSecret(env)); // then compare claims.nonce
|
|
409
|
+
// The link is a credential: no Referer, no caching, on the page it opens.
|
|
410
|
+
return withEditLinkHeaders(request, response, { isFormPage: (u) => u.pathname.startsWith("/f/") });
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
`signEditToken`, `withEditToken`, `isEditLinkRequest`, `isUsableEditSecret`, `EDIT_LINK_TTL_SECONDS`,
|
|
414
|
+
`MIN_EDIT_SECRET_BYTES` and `EDIT_LINK_PARAM` are the parts. `carryAnswers` (main entry) moves a
|
|
415
|
+
stored response's answers onto a newer version of the form by field key, for the edit page's prefill.
|
|
416
|
+
|
|
417
|
+
### Rate limits
|
|
418
|
+
|
|
419
|
+
The submit endpoint's limit, without the counter: `clientIp(headers, trusted?)` reads the first
|
|
420
|
+
trusted proxy header (`DEFAULT_TRUSTED_IP_HEADERS`: `x-vercel-forwarded-for`, `x-forwarded-for`,
|
|
421
|
+
`x-real-ip`; trust only headers your proxy overwrites), `addressPrefix` keeps an IPv4 address whole
|
|
422
|
+
and an IPv6 one by its /64, and `rateLimitKey({ kind, scope, ip, secret })` is
|
|
423
|
+
`<kind>:<scope>:<hex HMAC-SHA256 of the prefix>`, so the address is never stored. The app's
|
|
424
|
+
`RateLimitStore.hit(key, limit, windowSeconds)` counts (shared by every instance); `allow` fails
|
|
425
|
+
open, and logs, when it answers null or throws. `RATE_LIMIT_DEFAULTS` holds 60 submits, 900 saves as
|
|
426
|
+
they go and 120 photo uploads per 10 minutes.
|
|
427
|
+
|
|
428
|
+
```ts
|
|
429
|
+
const key = await rateLimitKey({ kind: "submit", scope: practiceId, ip: clientIp(request.headers), secret });
|
|
430
|
+
if (!(await allow(store, { key, ...RATE_LIMIT_DEFAULTS.submit }, { log }))) return tooMany();
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### Uploads
|
|
434
|
+
|
|
435
|
+
A `photo` answer holds references, never bytes, so the server must check each one is a file it
|
|
436
|
+
stored for this respondent. Two pieces do it, with the app's database and storage behind an
|
|
437
|
+
`UploadStore` (`beginUpload`, `signUpload`, `uploadRefs`):
|
|
438
|
+
|
|
439
|
+
- `createUploadHandler({ resolveForm, store, edit?, rateLimit?, ... })` is the endpoint the browser
|
|
440
|
+
asks for one signed upload per file (`(request, params) => Response`). It checks the form is open,
|
|
441
|
+
the field takes files and the file's type and size, the visit's attempt id or the edit link
|
|
442
|
+
(`edit.response` loads the response, the handler compares its nonce), and the rate limit
|
|
443
|
+
(`kind: "upload"`, 120 per 10 minutes by default), then records the upload and signs it.
|
|
444
|
+
- `checkUploads(definition, answers, { formId, owner, store })`, on submit after
|
|
445
|
+
`validateSubmission`: every reference must be an upload this attempt (`{ attemptHash }`) or this
|
|
446
|
+
edit (`{ responseId }`: the response's own files, or ones uploaded for the edit) made, for that
|
|
447
|
+
field, finished, of a type and size the field takes, and no more than it takes. The stored name,
|
|
448
|
+
type and size replace what the browser sent; a refusal is keyed by field id.
|
|
449
|
+
|
|
450
|
+
```ts
|
|
451
|
+
import { attemptHash, checkUploads, createUploadHandler } from "@marktiderman/genesis-forms/server";
|
|
452
|
+
|
|
453
|
+
export const POST = createUploadHandler<{ slug: string }>({
|
|
454
|
+
resolveForm: async (request, { slug }) => {
|
|
455
|
+
const found = await loadForm(request, slug); // null: 404
|
|
456
|
+
return found && { id: found.id, definition: found.definition, open: found.open, scope: found.practiceId, allowsEdits: found.allowEdit };
|
|
457
|
+
},
|
|
458
|
+
store: uploadStore, // beginUpload, signUpload, uploadRefs
|
|
459
|
+
edit: { secret: editSecret, response: (id, form) => loadSentResponse(id, form) }, // { id, editNonce } or null
|
|
460
|
+
rateLimit: { store: counter, secret: rateLimitSecret },
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// On submit:
|
|
464
|
+
const checked = await checkUploads(definition, result.submission.answers, {
|
|
465
|
+
formId,
|
|
466
|
+
owner: editing ? { responseId } : { attemptHash: await attemptHash(body.attemptId) },
|
|
467
|
+
store: uploadStore,
|
|
468
|
+
}); // throws when the store can't read: answer 503
|
|
469
|
+
if (!checked.ok) return Response.json({ errors: checked.errors }, { status: 422 });
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
The JSON (`UploadRequest`, `UploadGrant`, `UploadRefusal` in the main entry, for the browser; every
|
|
473
|
+
response is `cache-control: no-store`):
|
|
474
|
+
|
|
475
|
+
| Status | Body | When |
|
|
476
|
+
| --- | --- | --- |
|
|
477
|
+
| 200 | `{ ref: { id, name, type, size }, upload: { path, token, bucket?, url? } }` | Recorded and signed; `upload` is what `signUpload` returned. |
|
|
478
|
+
| 400 | `{ error }` | Not a JSON object; no `attemptId` (a UUID) without `edit`; the field isn't an upload field. |
|
|
479
|
+
| 403 | `{ error, code: "bad_link" }` | An edit link that doesn't verify, isn't the response's newest, or a form without edits. |
|
|
480
|
+
| 404 | `{ error }` | `resolveForm` found no form. |
|
|
481
|
+
| 409 | `{ error }`, or `code: "closed"` for an edit | The form isn't open. |
|
|
482
|
+
| 413 | `{ error }` | The request is over 4,000 bytes, or the file is empty, not whole bytes or over the field's size. |
|
|
483
|
+
| 415 | `{ error }` | Not `application/json`, or a file type the field doesn't take. |
|
|
484
|
+
| 429 | `{ error }`, or `code: "too_many_photos"` | The rate limit, or the store's `{ tooMany: true }`. |
|
|
485
|
+
| 500 / 503 | `{ error }` | Recording or signing failed / the form or the edit's response couldn't be loaded; the cause is logged, never sent. |
|
|
486
|
+
|
|
487
|
+
The request is `{ fieldId, name, type, size }` plus `attemptId` or `edit`. `attemptHash(attemptId)`
|
|
488
|
+
is the hex SHA-256 the store keeps for the attempt. `messages` overrides any of the words
|
|
489
|
+
(`UPLOAD_MESSAGES`). genesis-forms-web's `signedUploadHost` is the browser half.
|
|
490
|
+
|
|
305
491
|
## Destinations
|
|
306
492
|
|
|
307
493
|
`@marktiderman/genesis-forms/destinations` sends a checked submission somewhere. No dependencies:
|
package/dist/destinations.cjs
CHANGED
|
@@ -436,6 +436,57 @@ function summarizeValue(value, field) {
|
|
|
436
436
|
if (Array.isArray(value)) return value.join(", ");
|
|
437
437
|
return String(value);
|
|
438
438
|
}
|
|
439
|
+
var OPTION_LIMITS = {
|
|
440
|
+
/** Characters in a description. */
|
|
441
|
+
description: 500,
|
|
442
|
+
/** Characters in an image URL. */
|
|
443
|
+
image: 2048,
|
|
444
|
+
/** Attributes on one option. */
|
|
445
|
+
attributes: 20,
|
|
446
|
+
/** Characters in an attribute's name. */
|
|
447
|
+
attributeName: 64,
|
|
448
|
+
/** Characters in one attribute value. */
|
|
449
|
+
attributeValue: 200,
|
|
450
|
+
/** Values in one attribute's list. */
|
|
451
|
+
attributeItems: 50
|
|
452
|
+
};
|
|
453
|
+
function isHttpsUrl(url) {
|
|
454
|
+
return typeof url === "string" && url.length <= OPTION_LIMITS.image && /^https:\/\/[^\s/?#@\\]+(?:[/?#][^\s]*)?$/i.test(url) && // No control characters anywhere (a line break would end a CSV row or an HTML attribute).
|
|
455
|
+
![...url].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
|
|
456
|
+
}
|
|
457
|
+
function attributeName(name) {
|
|
458
|
+
return name.trim() !== "" && name.length <= OPTION_LIMITS.attributeName && !(name in Object.prototype);
|
|
459
|
+
}
|
|
460
|
+
function readAttributes(raw) {
|
|
461
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
|
|
462
|
+
const out = {};
|
|
463
|
+
let count = 0;
|
|
464
|
+
for (const name of Object.keys(raw)) {
|
|
465
|
+
if (count >= OPTION_LIMITS.attributes) break;
|
|
466
|
+
if (!attributeName(name)) continue;
|
|
467
|
+
const v = raw[name];
|
|
468
|
+
const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
|
|
469
|
+
if (fits(v)) out[name] = v;
|
|
470
|
+
else if (Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits)) out[name] = [...v];
|
|
471
|
+
else continue;
|
|
472
|
+
count++;
|
|
473
|
+
}
|
|
474
|
+
return count ? out : void 0;
|
|
475
|
+
}
|
|
476
|
+
function readOption(raw) {
|
|
477
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
478
|
+
const o = raw;
|
|
479
|
+
if (typeof o.value !== "string") return null;
|
|
480
|
+
const out = { value: o.value };
|
|
481
|
+
if (typeof o.label === "string") out.label = o.label;
|
|
482
|
+
if (typeof o.description === "string" && o.description.trim() && o.description.length <= OPTION_LIMITS.description) {
|
|
483
|
+
out.description = o.description;
|
|
484
|
+
}
|
|
485
|
+
if (isHttpsUrl(o.image)) out.image = o.image;
|
|
486
|
+
const attributes = readAttributes(o.attributes);
|
|
487
|
+
if (attributes) out.attributes = attributes;
|
|
488
|
+
return out;
|
|
489
|
+
}
|
|
439
490
|
var OTHER_MAX_LENGTH = 200;
|
|
440
491
|
function otherOption(field) {
|
|
441
492
|
const other = field.props?.other;
|
|
@@ -453,9 +504,10 @@ var typedOther = (field, value) => isOtherValue(field, value) && value.trim() !=
|
|
|
453
504
|
function choiceOptions(field) {
|
|
454
505
|
const raw = field.props?.options;
|
|
455
506
|
if (!Array.isArray(raw)) return [];
|
|
456
|
-
return raw.flatMap(
|
|
457
|
-
|
|
458
|
-
|
|
507
|
+
return raw.flatMap((o) => {
|
|
508
|
+
const option = readOption(o);
|
|
509
|
+
return option ? [option] : [];
|
|
510
|
+
});
|
|
459
511
|
}
|
|
460
512
|
function optionLabel(field, value) {
|
|
461
513
|
const option = choiceOptions(field).find((o) => o.value === value);
|