@marktiderman/genesis-forms 0.6.0 → 0.8.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
@@ -83,7 +83,7 @@ const version = eventFeedback.hash(); // content fingerprint for publish
83
83
  | `isFormEvent(value)` | Guard for one well-formed event-log entry. |
84
84
  | `isSensitive(block)` | `sensitive: true` on a field, or the `props.sensitive` alias. Hidden inputs take `sensitive: true` too (`f.hidden.string({ source: "server", sensitive: true })`); devtools masks both. |
85
85
  | `/server` → `validateSubmission`, `publicDefinition` | Re-check a submission against the published definition: drops unknown and logic-hidden answers, ignores client values for server inputs, checks drafts by format only. Never throws on a malformed body. No IO. |
86
- | `/destinations` → `emailDestination` + `resendProvider`, `sheetsDestination`, `webhookDestination`, `responseRow` | Send a checked submission to an inbox, a Google Sheet or a signed webhook. fetch and WebCrypto only. See [Destinations](#destinations). |
86
+ | `/destinations` → `emailDestination` + `resendProvider`, `sheetsDestination`, `webhookDestination`, `slackDestination`, `responseRow`, `createDeliveryRunner` | Send a checked submission to an inbox, a Google Sheet, Slack or a signed webhook, and run an app's outbox of deliveries. fetch and WebCrypto only. See [Destinations](#destinations). |
87
87
  | `explain(engine, blockId)` | "Why is this hidden?": the path, the jump that skipped a block, each show/hide rule with its result in plain English, and the field's validation. |
88
88
  | `/testing` → `simulate`, `coverage`, `runScenario`, `recordScenario`, `compactEvents` | Walk a form by key, enumerate its paths, and run JSON scenarios. See [Testing](#testing). |
89
89
 
@@ -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:
@@ -484,7 +542,10 @@ store and log: never a secret, an answer, contact details or the webhook URL. Co
484
542
  has a timeout (`timeoutMs`, default 10 s) and honours `options.signal`. `deliverBatch(items,
485
543
  config)` (the sheet) sends many in one request; all succeed or fail together. Write your own by
486
544
  implementing `Destination` and throwing `DeliveryError` inside; `deliveryFailure(error)` turns any
487
- error into a safe result.
545
+ error into a safe result. A destination that deliberately sends nothing (no address, a cap reached)
546
+ resolves `{ ok: true, notSent: reason }`. `options.update` marks the delivery of an edit, and
547
+ `options.destinationId` is handed to option functions (a secret, an account, a URL, a token) as
548
+ their second argument, `{ destinationId }`, so they can look up what is stored for it.
488
549
 
489
550
  **Rows.** `responseRow(submission, definition)` flattens a submission: `id`, `submitted_at`, the
490
551
  app's `meta` columns, then one column per field key in form order. Numbers stay numbers, choices
@@ -495,8 +556,94 @@ submission. On an anonymous form identifying fields get no column. `sensitive` f
495
556
  | Destination | Config | Notes |
496
557
  | --- | --- | --- |
497
558
  | `emailDestination({ provider, from, replyTo?, render?, sensitive? })` | `{ to: string \| string[], subject? }` | HTML and plain text from the definition (`renderEmail`), every value HTML-escaped. Subject, From, To and Reply-To are stripped of line breaks or refused, so no answer can add a header. At most 50 recipients. Sensitive fields masked by default. The idempotency key is the delivery id plus a hash of the recipients. `resendProvider({ apiKey })` is built in; any `EmailProvider` works. `render` and `emailBody(lines)` let an app write its own content and keep the escaping. |
498
- | `sheetsDestination({ account, escapeFormulas?, sensitive? })` | `{ spreadsheetId, sheetName? }` | Service-account JWT signed with WebCrypto (RS256), token cached per process. Creates the tab, writes and freezes the header, adds a column for a new key without moving others, appends with `valueInputOption=RAW`, so Sheets stores text exactly as typed and never runs it as a formula (`@team thanks` stays `@team thanks`). No `'` is added by default, since RAW would store it literally; `escapeFormulas: true` adds one (headers included) for a sheet that is exported and opened elsewhere. `escapeFormula(cell)` is the same opt-in guard for CSV you write yourself: text starting with `=`, `@`, tab or CR, or with `+`/`-` followed by anything but number punctuation, gets a leading `'`. Keeps the tab, header and last row in `state`, so a normal delivery is one request. On `redelivery`, skips ids already in the last 500 rows. A 403/404 says to share the sheet with the service account. |
499
- | `webhookDestination({ secret, allowInsecureUrls?, allowPrivateHosts?, sensitive? })` | `{ url }` | [Standard Webhooks](https://www.standardwebhooks.com): `webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<HMAC-SHA256>`. The secret is base64 (at least 16 bytes decoded), usually `whsec_`-prefixed; as in every Standard Webhooks library the prefix is optional and the rest is always base64-decoded, so receivers verify with the same string. A secret that is not padded standard base64 (a raw passphrase, say) fails permanently rather than signing deliveries no receiver could verify. Generate one with `"whsec_" + btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32))))`. Body `{ type: "form.submitted", timestamp, data: { form, submission, row } }`. https only, no URL credentials, no localhost or private, loopback or link-local address literals, redirects not followed. A hostname that resolves to a private address is not caught: if untrusted users pick URLs, send from a network that cannot reach internal services. Receivers check with `verifyWebhook({ secret, headers, body })` (constant-time, five-minute tolerance). |
559
+ | `sheetsDestination({ account \| accessToken, escapeFormulas?, sensitive? })` | `{ spreadsheetId, sheetName? }` | Signs in with a service account (`account`) or with an access token the app supplies (`accessToken(config, { destinationId })`, for a sheet a person connected with Google sign-in: see `googleOAuthAccessToken`). Service-account JWT signed with WebCrypto (RS256), token cached per process. Creates the tab, writes and freezes the header, adds a column for a new key without moving others, appends with `valueInputOption=RAW`, so Sheets stores text exactly as typed and never runs it as a formula (`@team thanks` stays `@team thanks`). No `'` is added by default, since RAW would store it literally; `escapeFormulas: true` adds one (headers included) for a sheet that is exported and opened elsewhere. `escapeFormula(cell)` is the same opt-in guard for CSV you write yourself: text starting with `=`, `@`, tab or CR, or with `+`/`-` followed by anything but number punctuation, gets a leading `'`. Keeps the tab, header and last row in `state`, so a normal delivery is one request. On `redelivery`, skips ids already in the last 500 rows. On `update` (an edit), reads the id column and overwrites the response's row where it is, appending only when the row is gone. A 403/404 says to share the sheet with the service account (or, with `accessToken`, that it is no longer connected). |
560
+ | `webhookDestination({ secret, url?, resolveHost?, allowInsecureUrls?, allowPrivateHosts?, sensitive? })` | `{ url }` | [Standard Webhooks](https://www.standardwebhooks.com): `webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<HMAC-SHA256>`. The secret is base64 (at least 16 bytes decoded), usually `whsec_`-prefixed; as in every Standard Webhooks library the prefix is optional and the rest is always base64-decoded, so receivers verify with the same string. A secret that is not padded standard base64 (a raw passphrase, say) fails permanently rather than signing deliveries no receiver could verify. Generate one with `newWebhookSecret()`. Body `{ type: "form.submitted", timestamp, data: { form, submission, row } }` (`form.updated` on `update`). `url(config, { destinationId })` looks the address up when it is kept out of config. https only, no URL credentials, no localhost or private, loopback or link-local address literals, redirects not followed. Pass `resolveHost` to also refuse a hostname that resolves to a non-public address (checked before every request; see below). Without it, such a hostname is not caught; either way, if untrusted users pick URLs, send from a network that cannot reach internal services. Receivers check with `verifyWebhook({ secret, headers, body })` (constant-time, five-minute tolerance). |
561
+ | `slackDestination({ url?, resolveHost?, message? })` | `{ url }` | One message per submission to a Slack incoming webhook (`https://hooks.slack.com/services/…` only; anything else fails permanently). The heading (`New response: <title>`, `Updated response` on `update`), each answer as `label: value` in form order, then a link back or the response id. Every value is escaped (`escapeSlack`), so typed text can't mention `@channel` or make a link; answers over 1,500 characters are cut. Sensitive fields masked by default. `message(input)` sets the heading, extra lines, a link and fields to `omit` per delivery; `slackMessage(submission, definition, options)` builds the same text on its own. The URL is the credential, so `url(config, { destinationId })` can look it up. |
562
+
563
+ ### Sending by answer
564
+
565
+ A destination's `config.when` is a form `Condition` (the same shape logic rules use). The runner
566
+ sends a submission only when it matches, reading answers by field id and hidden inputs by name
567
+ (trusted `context` wins over a hidden input of the same name). A submission that doesn't match is
568
+ recorded as `filtered`. A `when` that is not a valid condition fails the delivery permanently
569
+ rather than guessing. `passesCondition(submission, definition, config)`, `conditionOf(config)` and
570
+ `isCondition(value)` are exported for apps that check it themselves.
571
+
572
+ ### Hook URLs and SSRF
573
+
574
+ `parseHookUrl(raw, "webhook" | "slack")` checks an address when someone adds it: https on port 443,
575
+ no credentials, at most 2,048 characters, and a public host (no localhost, single-label or
576
+ `.local`/`.internal`/`.lan`-style names, no private or reserved IP literal); a Slack address must
577
+ be a Slack incoming webhook. It throws a `HookUrlError` with a message safe to show. Before every
578
+ request, `checkHookHost(url, resolveHost)` (what `resolveHost` turns on in the webhook and Slack
579
+ destinations) resolves the name and refuses it unless every address is public by
580
+ `isPublicAddress`, which knows the private, loopback, link-local, CGNAT, documentation,
581
+ benchmarking, multicast, IPv4-mapped, NAT64, 6to4 and Teredo ranges. A lookup that fails is
582
+ retryable; a private address is not. In Node, `resolveHost` can be
583
+ `(host) => dns.promises.lookup(host, { all: true }).then((a) => a.map((x) => x.address))`. The
584
+ name is checked and then fetched, so a name re-pointed in between (DNS rebinding) is not caught:
585
+ where untrusted people choose URLs, also send from a network that cannot reach internal services.
586
+
587
+ ### Google sign-in for sheets
588
+
589
+ `googleOAuthAccessToken({ clientId, clientSecret }, refreshToken)` trades a refresh token from the
590
+ app's own consent flow (`DRIVE_FILE_SCOPE` reaches only files the person picked or created through
591
+ the app) for an access token, cached per grant until a minute before it expires.
592
+ `refreshGoogleAccessToken` is the uncached call. `invalid_grant` fails permanently with "Connect
593
+ the sheet again"; 429 and 5xx are retryable. The app keeps the refresh token; this only trades it.
594
+
595
+ ```ts
596
+ sheetsDestination({
597
+ accessToken: async (config, { destinationId }) =>
598
+ googleOAuthAccessToken(oauthClient, await refreshTokenFor(destinationId)),
599
+ });
600
+ ```
601
+
602
+ ### Secrets at rest
603
+
604
+ Webhook and Slack URLs, signing secrets and refresh tokens should not sit in config. The helpers
605
+ seal them for whatever store the app uses: `deriveSecretKey(appKey, purpose)` derives an AES-256-GCM
606
+ key from a 32-byte app secret (base64 or base64url, as `openssl rand -base64 32` prints) with
607
+ HKDF-SHA-256, one key per purpose; `sealSecret(key, plaintext, aad)` gives `v1.<iv>.<ciphertext>`
608
+ (base64url), bound to `aad` (the row it belongs to), and `openSecret(key, sealed, aad)` opens it,
609
+ failing permanently for another row, another purpose or a rotated key.
610
+
611
+ ### The outbox runner
612
+
613
+ `createDeliveryRunner` claims a batch of due deliveries from the app's store, sends each to its
614
+ destination, and records the outcome. The store maps the three calls to its own tables:
615
+
616
+ ```ts
617
+ import { createDeliveryRunner } from "@marktiderman/genesis-forms/destinations";
618
+
619
+ const runner = createDeliveryRunner<{ responseId?: string }>({
620
+ // Lease up to `limit` due rows (a claim function in SQL, say) with their submission and definition.
621
+ claim: ({ limit, filter }) => store.claimDeliveries(limit, filter),
622
+ // Write the outcome; resolve false if the lease had run out.
623
+ record: (delivery, outcome) => store.finishDelivery(delivery.id, outcome),
624
+ saveState: (destinationId, state) => store.saveDestinationState(destinationId, state),
625
+ destinations: { email, sheet, slack, webhook },
626
+ log: (line) => console.warn(line), // ids, kinds and safe reasons only
627
+ });
628
+
629
+ await runner.run({ filter: { responseId } }); // right after a save, or from a job
630
+ ```
631
+
632
+ Each `ClaimedDelivery` carries its `id` (stable across retries), `destinationId`, `kind`, `config`,
633
+ `state`, `submission`, `definition`, its `attempt` (from 1), how many times it was `deferrals`
634
+ (deferred), and `redelivery` and `update` flags. A response sent and edited in one sheet batch is
635
+ written once, as its latest version.
636
+ Deliveries to one destination go in one `deliverBatch` call where the destination has one (a sheet
637
+ appends them in one request), else one by one with the state passed along; new state is saved
638
+ once. Outcomes: `sent` (`alreadyThere` on a redelivery that found its row), `filtered` (didn't
639
+ match `config.when`), `skipped` (the destination returned `notSent`, a deliberate no-send such as a
640
+ cap; never retried), `retry` with `retryAt`, and `failed`. A retryable failure with the service's
641
+ Retry-After is deferred without counting an attempt (`counted: false`), up to `maxDeferrals`
642
+ (default 24, counted by the store in `ClaimedDelivery.deferrals`); any other retryable
643
+ failure backs off by attempt (`defaultBackoff`: 1 minute doubling to 6 hours) until `maxAttempts`
644
+ (default 8), then fails. Permanent failures and unknown kinds fail at once. A destination that
645
+ throws is reduced to a safe message. `run` returns a `DeliveryReport` of counts, including `stale`
646
+ (record resolved false) and `unrecorded` (record threw; the lease runs out and the row comes back).
500
647
 
501
648
  ## Testing
502
649