@marktiderman/genesis-forms 0.6.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
  | --- | --- | --- |
@@ -268,9 +268,9 @@ An option can carry more than its value and label, from the question's own `opti
268
268
  app's list:
269
269
 
270
270
  ```ts
271
- { value: "cff", label: "CrossFit Fairfield",
271
+ { value: "riverside", label: "Riverside Gym",
272
272
  description: "12 Main St", // a muted line under the label
273
- image: "https://cdn.example.com/cff.png", // a small picture; https only
273
+ image: "https://cdn.example.com/riverside.png", // a small picture; https only
274
274
  attributes: { region: "north", coaches: ["ada", "sam"] } } // for filters and recall
275
275
  ```
276
276
 
@@ -288,7 +288,7 @@ f.singleChoice("coach", {
288
288
  label: "Who coached you?",
289
289
  optionsFrom: { source: "host", key: "coaches", filter: { attribute: "location", field: "location" } },
290
290
  });
291
- // a coach: { value: "ada", label: "Ada", attributes: { location: ["cff", "cornerstone"] } }
291
+ // a coach: { value: "ada", label: "Ada", attributes: { location: ["riverside", "hilltop"] } }
292
292
  ```
293
293
 
294
294
  `field` is the earlier question's key (`parseForm` checks it exists and comes first). An option
@@ -307,7 +307,7 @@ options: `props.searchable` when it is true or false, else more than `SEARCHABLE
307
307
  query in the label, value or description, ignoring case and accents.
308
308
 
309
309
  **Preselect from a link.** `props.prefill: { param }` on a choice question takes a link's query
310
- parameter, such as `?location=cornerstone`:
310
+ parameter, such as `?location=hilltop`:
311
311
 
312
312
  ```ts
313
313
  f.dropdown("location", { label: "Where?", optionsFrom: { source: "host", key: "locations" },
@@ -335,7 +335,7 @@ import { answerSnapshots, answersFromSnapshots, withHostOptions } from "@marktid
335
335
 
336
336
  const shown = withHostOptions(definition, lists);
337
337
  const snapshots = answerSnapshots(shown, result.submission.answers);
338
- // { location: { value: "cff", label: "CrossFit Fairfield" }, helpers: [{ value: "ada", label: "Ada" }] }
338
+ // { location: { value: "riverside", label: "Riverside Gym" }, helpers: [{ value: "ada", label: "Ada" }] }
339
339
 
340
340
  // An edit link: find each pick again in today's lists.
341
341
  const { answers, missing } = answersFromSnapshots(withHostOptions(definition, listsNow), stored);
@@ -430,6 +430,64 @@ const key = await rateLimitKey({ kind: "submit", scope: practiceId, ip: clientIp
430
430
  if (!(await allow(store, { key, ...RATE_LIMIT_DEFAULTS.submit }, { log }))) return tooMany();
431
431
  ```
432
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
+
433
491
  ## Destinations
434
492
 
435
493
  `@marktiderman/genesis-forms/destinations` sends a checked submission somewhere. No dependencies: