@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 +64 -6
- package/dist/destinations.cjs.map +1 -1
- package/dist/destinations.js.map +1 -1
- package/dist/{engine-DC4YTPdr.d.cts → engine-C5tb3mkf.d.cts} +1 -1
- package/dist/{engine-CpfOz8FN.d.ts → engine-DcNQ-6oJ.d.ts} +1 -1
- package/dist/index.cjs +4 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -65
- package/dist/index.d.ts +8 -65
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +228 -0
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +199 -6
- package/dist/server.d.ts +199 -6
- package/dist/server.js +221 -1
- package/dist/server.js.map +1 -1
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +3 -3
- package/dist/testing.d.ts +3 -3
- package/dist/testing.js.map +1 -1
- package/dist/upload-protocol-Bw0CWRJc.d.cts +122 -0
- package/dist/upload-protocol-PX0jroTV.d.ts +122 -0
- package/dist/{validate-B5WKHbst.d.cts → validate-BFpNJbjf.d.cts} +2 -2
- package/dist/{validate-GpTXPwyl.d.ts → validate-qwkdcr3H.d.ts} +2 -2
- package/package.json +1 -1
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: "
|
|
271
|
+
{ value: "riverside", label: "Riverside Gym",
|
|
272
272
|
description: "12 Main St", // a muted line under the label
|
|
273
|
-
image: "https://cdn.example.com/
|
|
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: ["
|
|
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=
|
|
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: "
|
|
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:
|