@marktiderman/genesis-forms 0.5.0 → 0.6.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 +128 -0
- 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-BDq0XJ21.d.cts → engine-CpfOz8FN.d.ts} +10 -3
- package/dist/{engine-Qzp6bX6O.d.ts → engine-DC4YTPdr.d.cts} +10 -3
- package/dist/index.cjs +484 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -7
- package/dist/index.d.ts +29 -7
- package/dist/index.js +461 -63
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +541 -21
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +193 -3
- package/dist/server.d.ts +193 -3
- package/dist/server.js +522 -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/validate-B5WKHbst.d.cts +322 -0
- package/dist/validate-GpTXPwyl.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
|
@@ -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: "cff", label: "CrossFit Fairfield",
|
|
272
|
+
description: "12 Main St", // a muted line under the label
|
|
273
|
+
image: "https://cdn.example.com/cff.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: ["cff", "cornerstone"] } }
|
|
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=cornerstone`:
|
|
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: "cff", label: "CrossFit Fairfield" }, 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,49 @@ 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
|
+
|
|
305
433
|
## Destinations
|
|
306
434
|
|
|
307
435
|
`@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);
|