@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 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:
@@ -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
- (o) => o && typeof o === "object" && !Array.isArray(o) && typeof o.value === "string" ? [{ value: o.value, label: typeof o.label === "string" ? o.label : void 0 }] : []
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);