@marktiderman/genesis-forms 0.8.0 → 0.9.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.
Files changed (36) hide show
  1. package/README.md +299 -10
  2. package/dist/destinations.cjs +202 -39
  3. package/dist/destinations.cjs.map +1 -1
  4. package/dist/destinations.d.cts +67 -21
  5. package/dist/destinations.d.ts +67 -21
  6. package/dist/destinations.js +202 -40
  7. package/dist/destinations.js.map +1 -1
  8. package/dist/{engine-Bj23YJlB.d.cts → engine-BaHPC2-1.d.cts} +2 -2
  9. package/dist/{engine-CzinTJph.d.ts → engine-Da99Tv-E.d.ts} +2 -2
  10. package/dist/index.cjs +996 -167
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +288 -11
  13. package/dist/index.d.ts +288 -11
  14. package/dist/index.js +975 -168
  15. package/dist/index.js.map +1 -1
  16. package/dist/server.cjs +563 -173
  17. package/dist/server.cjs.map +1 -1
  18. package/dist/server.d.cts +3 -474
  19. package/dist/server.d.ts +3 -474
  20. package/dist/server.js +558 -174
  21. package/dist/server.js.map +1 -1
  22. package/dist/submit-protocol-CFi-0Qd0.d.ts +840 -0
  23. package/dist/submit-protocol-D0cOckmK.d.cts +840 -0
  24. package/dist/testing.cjs +250 -154
  25. package/dist/testing.cjs.map +1 -1
  26. package/dist/testing.d.cts +4 -4
  27. package/dist/testing.d.ts +4 -4
  28. package/dist/testing.js +250 -154
  29. package/dist/testing.js.map +1 -1
  30. package/dist/{types-jqDKcmP4.d.cts → types-C0pUfew3.d.cts} +7 -1
  31. package/dist/{types-jqDKcmP4.d.ts → types-C0pUfew3.d.ts} +7 -1
  32. package/dist/{validate-DvfQHYnu.d.ts → validate-DpmBssFk.d.ts} +258 -140
  33. package/dist/{validate-irIPubxc.d.cts → validate-U5LoW3eR.d.cts} +258 -140
  34. package/package.json +2 -2
  35. package/dist/upload-protocol-BOe018kL.d.cts +0 -122
  36. package/dist/upload-protocol-BUO74WkS.d.ts +0 -122
package/README.md CHANGED
@@ -78,7 +78,8 @@ const version = eventFeedback.hash(); // content fingerprint for publish
78
78
  | `parseForm(json)` | Check a stored or built definition: structure, text fields, references, operators, recall. Never throws. |
79
79
  | `createForm(def)` | The engine: values, visibility, path, Next/Back with history, validation, submit, an event log. Framework-agnostic `subscribe` / `getState`. |
80
80
  | `replay(def, events)` | Rebuild a response from its event log. |
81
- | `registerFieldType` | Add a field type. Non-JSON values must provide `serialize` and `deserialize`; `normalize` sets the stored form (email trims). A field whose type is not registered accepts no answer. |
81
+ | `registerFieldType`, `registerContentType` | Add a field or content kind: one definition the engine, `parseForm`, validation, the server and snapshots all read. See [Kinds](#kinds-the-content-type-registry). A field whose type is not registered accepts no answer. |
82
+ | `mergeResponses(def, base, patch)` | A saved draft plus what changed since, as the answers to store: for resuming and partial saves. See [Merging partial responses](#merging-partial-responses). |
82
83
  | `coerceHidden(def, raw, { sources })` | Type hidden inputs by their declarations; reports dropped names in `ignored`. |
83
84
  | `isFormEvent(value)` | Guard for one well-formed event-log entry. |
84
85
  | `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. |
@@ -362,6 +363,65 @@ one such value per question, not blank and at most `OTHER_MAX_LENGTH` (200) char
362
363
  a question with `other`. `otherOption(field)` reads the setting; `isOtherValue(field, value)` tells a
363
364
  typed answer from an option.
364
365
 
366
+ ### Kinds: the content-type registry
367
+
368
+ Every field kind (`short-text`, `single-choice`, `photo`…) and content kind (`heading`, `text`) is
369
+ one registered definition. Nothing else in the package switches on a type name, so a host's own
370
+ kind gets everything a built-in gets:
371
+
372
+ | Member | What reads it |
373
+ | --- | --- |
374
+ | `checkProps(props, check)` | `parseForm`: the kind's settings, reported at `blocks[i].props.<path>`. `check` has `issue`, `text`, `optionalText`, `optionalBoolean`, `optionalInt`, `ordered` and `name`. |
375
+ | `choice: { multiple }` | The answer shape: one option (a text) or several (a list). A choice kind takes `options`, `optionsFrom` (with filters), `prefill`, `other`, search, and snapshots, like `single-choice` and `multi-choice`. |
376
+ | `literal` | `parseForm`'s warning about a logic literal that can never match: `"string"`, `"number"`, `"boolean"`, or a function of the props. |
377
+ | `validate(value, field)` | The engine and `validateSubmission`. A validator that throws refuses the answer with `VALIDATOR_FAILED_MESSAGE`. |
378
+ | `isEmpty`, `normalize`, `summarize` | The empty check, the stored form, the display text (recall, exports, emails). |
379
+ | `snapshot`, `fromSnapshot` | `answerSnapshots` and `answersFromSnapshots` for a kind that isn't a choice. |
380
+ | `samples` | `coverage()` and the other test tools. |
381
+
382
+ ```ts
383
+ import { registerFieldType, registerContentType } from "@marktiderman/genesis-forms";
384
+
385
+ registerFieldType({
386
+ type: "acme/cards", label: "Cards", category: "choice", value: { json: true },
387
+ operators: ["eq", "neq", "answered", "empty"], a11y: { semantics: "group" },
388
+ choice: { multiple: false }, literal: "string",
389
+ checkProps: (props, check) => check.optionalInt(props.columns, "columns", 1),
390
+ });
391
+ registerContentType({ type: "acme/message", label: "Message", text: (block) => block.text });
392
+ ```
393
+
394
+ The registry is shared by every form in the process (the server's checks too), so:
395
+
396
+ - a built-in kind's name is refused unless you pass `{ replace: true }`;
397
+ - what you register is stored as a frozen copy: changing your object, or what `getFieldType`
398
+ returns, changes nothing;
399
+ - a choice kind without its own `validate` gets the built-in check for its shape, so its answers
400
+ are always one of its options (or a typed "Other");
401
+ - a `validate` that throws refuses the answer, a `checkProps` that throws is one issue, and a
402
+ throwing `literal`, `snapshot` or `fromSnapshot` is skipped; `isEmpty`, `normalize` and
403
+ `summarize` must not throw;
404
+ - an unknown content kind's issue names every registered kind, so registering one changes that
405
+ message for every form ("Must be heading, text or acme/message.").
406
+
407
+ A content block carries `text` and, for a host's kind, `props`. `contentText(block)` is its display
408
+ text. A renderer draws a content kind it doesn't know as that text.
409
+
410
+ ### Merging partial responses
411
+
412
+ `mergeResponses(definition, base, patch, { hidden?, hostOptions?, offPath? })` lays `patch` over
413
+ `base` (both answers by field id) and returns the answers to store, in the form's order:
414
+
415
+ 1. a field in `patch` takes its value; `null` clears it;
416
+ 2. unknown ids, `calculated` fields (worked out again) and, on an anonymous form, identifying
417
+ fields are dropped; so are empty answers;
418
+ 3. a pick a filtered list no longer shows is dropped, whichever side changed;
419
+ 4. answers on a path the merged answers no longer take (hidden by logic, jumped over) are
420
+ **dropped**, unless `offPath: "keep"`. The default follows the form: kept only with
421
+ `settings.clearHiddenValues: false`, as `validateSubmission` keeps them.
422
+
423
+ It checks nothing else: pass the result to `validateSubmission` or `createForm`'s `initialValues`.
424
+
365
425
  ### Props for the host
366
426
 
367
427
  Props a field type doesn't use are kept and ignored: the engine, `parseForm` and
@@ -416,9 +476,21 @@ stored response's answers onto a newer version of the form by field key, for the
416
476
 
417
477
  ### Rate limits
418
478
 
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
479
+ The submit endpoint's limit, without the counter: `clientIp(headers, options?)` reads the first
480
+ trusted platform header (`DEFAULT_TRUSTED_IP_HEADERS`: `x-vercel-forwarded-for` and
481
+ `cf-connecting-ip`, which Vercel and Cloudflare set themselves). `x-forwarded-for` is not read by
482
+ default: its leftmost entries are whatever the client sent, so only the entry your own proxy
483
+ appended can be trusted. Behind your own proxies, opt in with `forwardedForHops`, the number of
484
+ proxies that each append an address, and `clientIp` takes the entry that many places from the
485
+ right (1: the rightmost); pass `headers: []` as well, since off Vercel or Cloudflare a client can
486
+ send those headers itself. A list of header names on its own also works as the second argument.
487
+
488
+ ```ts
489
+ clientIp(request.headers); // on Vercel or behind Cloudflare
490
+ clientIp(request.headers, { headers: [], forwardedForHops: 1 }); // behind one load balancer you run
491
+ ```
492
+
493
+ `addressPrefix` keeps an IPv4 address whole
422
494
  and an IPv6 one by its /64, and `rateLimitKey({ kind, scope, ip, secret })` is
423
495
  `<kind>:<scope>:<hex HMAC-SHA256 of the prefix>`, so the address is never stored. The app's
424
496
  `RateLimitStore.hit(key, limit, windowSeconds)` counts (shared by every instance); `allow` fails
@@ -488,6 +560,214 @@ The request is `{ fieldId, name, type, size }` plus `attemptId` or `edit`. `atte
488
560
  is the hex SHA-256 the store keeps for the attempt. `messages` overrides any of the words
489
561
  (`UPLOAD_MESSAGES`). genesis-forms-web's `signedUploadHost` is the browser half.
490
562
 
563
+ ### Submitting: `submitResponse`
564
+
565
+ The one call a submit endpoint makes. It checks the submission again (`validateSubmission`, then
566
+ `checkUploads`), snapshots the options picked (`answerSnapshots`) and hands one record to the app's
567
+ store, keyed by the device's idempotency key, so a retry never stores a second response. No HTTP and
568
+ no delivery: the endpoint, the store and the destinations' outbox are the app's. Edits to a stored response and progress saves are out of scope: it takes a finished submission only.
569
+
570
+ ```ts
571
+ import { submitResponse, type SubmissionStore } from "@marktiderman/genesis-forms/server";
572
+
573
+ const result = await submitResponse(await request.json(), {
574
+ definition: published, // loaded by the server
575
+ hostOptions: { locations: await locationsFor(practice) }, // loaded afresh, never from the client
576
+ context: { member: memberId }, // server inputs
577
+ store, // SubmissionStore
578
+ formId: form.id, // how the store keys it; default definition.id
579
+ version: form.publishedVersion, // stored as definitionVersion; default fingerprint(definition)
580
+ open: form.acceptingResponses, // false answers "closed"
581
+ rateLimit: async () => allow(counter, { key: await rateLimitKey({ kind: "submit", scope: practice, ip, secret }), ...RATE_LIMIT_DEFAULTS.submit }),
582
+ uploads: { store: uploadStore }, // needed when the form has upload fields; the owner is the request's attemptId
583
+ });
584
+ return Response.json(result, { status: result.ok ? 201 : 200 });
585
+ ```
586
+
587
+ Answer `duplicate` as a success: the response is already stored, so the device must clear it from its outbox, not retry it. The outbox treats `ok: true` and `reason: "duplicate"` alike.
588
+
589
+ The contract, which the client outbox and the server side are both built against:
590
+
591
+ ```ts
592
+ function submitResponse(input: SubmitRequest, options: SubmitResponseOptions): Promise<SubmitResult>;
593
+
594
+ interface SubmitRequest extends SubmissionInput { // answers | events, hidden
595
+ idempotencyKey: string; // made once on the device (a UUID); /^[A-Za-z0-9_-]{16,128}$/
596
+ clientSubmittedAt?: number; // the device's clock, untrusted
597
+ attemptId?: string; // the visit's attempt, for upload fields
598
+ }
599
+
600
+ type SubmitResult =
601
+ | { ok: true; id: string; receivedAt: string }
602
+ | { ok: false; reason: "duplicate"; id: string; receivedAt: string }
603
+ | { ok: false; reason: "invalid"; errors: Record<string, string> }
604
+ | { ok: false; reason: "conflict" }
605
+ | { ok: false; reason: "closed" }
606
+ | { ok: false; reason: "rate_limited"; retryAfterSeconds?: number }
607
+ | { ok: false; reason: "store_failed" };
608
+
609
+ interface SubmissionStore {
610
+ find(formId: string, idempotencyKey: string): Promise<StoredSubmissionRef | null>;
611
+ save(record: SubmissionRecord): Promise<{ id: string } | { duplicate: StoredSubmissionRef }>;
612
+ }
613
+ interface StoredSubmissionRef { id: string; receivedAt: string; inputHash: string }
614
+ ```
615
+
616
+ The checks run in this order: the idempotency key, `store.find` (a response already stored under
617
+ it), `open`, `rateLimit`, `validateSubmission` (always complete; drafts are never submitted), the
618
+ uploads named, then `store.save`.
619
+
620
+ | Result | Meaning | The device |
621
+ |---|---|---|
622
+ | `ok: true` | Stored now, as `id`. Queue its deliveries. | Drops it from the outbox. |
623
+ | `duplicate` | Already stored under this key with these answers, as `id`. Deliver nothing again. | Drops it: the earlier try went through. |
624
+ | `invalid` | `errors` keyed by field id, `""` for the whole: a failed check, or a malformed body or key. | Keeps it as failed, for the respondent to fix or discard. |
625
+ | `conflict` | A response is stored under this key with other answers (the hashes differ). | Keeps it as failed. |
626
+ | `closed` | `open: false`. | Keeps it as failed. |
627
+ | `rate_limited` | `rateLimit` answered false, or `{ retryAfterSeconds }` (sent back, rounded up). A throw lets it on (fails open) and is logged. | Retries later, no sooner than `retryAfterSeconds`. |
628
+ | `store_failed` | `find`, `save` or the upload store threw, `save` answered no id, or a duplicate came back without its id, time or hash. The cause is logged, without answers. | Retries later. |
629
+
630
+ More reasons may be added; a device treats one it doesn't know as a retry, so it keeps the submission.
631
+
632
+ Answer every result as JSON with whatever status code suits (the outbox reads the body, not the
633
+ status). `submitResponse` throws only for the server's own mistakes: a published definition that
634
+ doesn't parse, or a form with upload fields and no `uploads`; answer those 500 and the device retries.
635
+
636
+ The store's side:
637
+
638
+ - **One response per `formId` and `idempotencyKey`.** Put a unique index on the pair. `find` reads
639
+ the row under a key (it is asked first, so a retry of a response that went through is answered
640
+ `duplicate` even after the form closed, the client is rate limited, the definition changed or its
641
+ uploads were taken). When `save`'s insert hits the index anyway (two retries raced), read that row
642
+ and answer `{ duplicate: { id, receivedAt, inputHash } }`.
643
+ - **Keep `inputHash`.** It is the hex SHA-256 of the answers, events and hidden inputs as sent. A
644
+ duplicate is answered `duplicate` only when the hashes match; a key reused for other answers is
645
+ `conflict`, never taken as delivered. A ref without its hash is answered `store_failed`.
646
+ - **Mark `uploadIds` taken by the response** in the same transaction (the checked ids of the files
647
+ the answers name), so they aren't purged as abandoned.
648
+ - **Save and queue deliveries in one transaction**, so a stored response is always delivered. For a
649
+ destination, `{ id, answers, hidden, context, ending, submittedAt: receivedAt }` is a
650
+ `DeliverySubmission`.
651
+
652
+ `SubmissionRecord` holds `formId`, `idempotencyKey`, `inputHash`, `definitionVersion`, `receivedAt`
653
+ (ISO), `clientSubmittedAt` (ISO or null, untrusted), the checked `answers` (uploads as stored,
654
+ calculated fields worked out here), `hidden`, `context`, `ending`, `ignored`, `snapshots` and
655
+ `uploadIds`.
656
+
657
+ ## Drafts and the offline outbox
658
+
659
+ Both run on the device, in the browser and in React Native, and keep their data in a
660
+ `KeyValueStorage`: `{ getItem, setItem, removeItem }`, each answering at once or with a promise.
661
+ `localStorage` is one (`webStorage()` looks it up on each call, so server rendering is safe), React
662
+ Native's AsyncStorage is one, and MMKV takes three lines:
663
+
664
+ ```ts
665
+ const storage: KeyValueStorage = {
666
+ getItem: (key) => mmkv.getString(key) ?? null,
667
+ setItem: (key, value) => mmkv.set(key, value),
668
+ removeItem: (key) => mmkv.delete(key),
669
+ };
670
+ ```
671
+
672
+ `memoryStorage()` keeps nothing past the page or app, for tests.
673
+
674
+ ### Drafts
675
+
676
+ ```ts
677
+ import { createDraftStore, webStorage } from "@marktiderman/genesis-forms";
678
+ import { useFormDraft } from "@marktiderman/genesis-forms-react";
679
+
680
+ const drafts = createDraftStore(webStorage()); // or AsyncStorage, MMKV
681
+ const { engine, draft, savedAt, clear } = useFormDraft(definition, { store: drafts, respondent: sessionId });
682
+ ```
683
+
684
+ `useFormDraft` is `useForm` with a draft: the saved answers are put back when the form opens, at
685
+ the furthest question reached (`resumeAt`), saved again 800 ms after each change (`delayMs`), and
686
+ flushed when the page is hidden or the component unmounts. With a store that answers at once the
687
+ engine is ready on the first render; with an async one `engine` is null until the draft is read.
688
+ `draft` says what happened: `restored`, `discarded`, `none`, `error` (it couldn't be read: the form
689
+ starts empty, and the stored draft is left alone until the respondent answers something) or
690
+ `loading`. In React Native, call `flush()` from an `AppState` listener when the app goes to the
691
+ background; on the web the hook flushes on `pagehide` and when the page is hidden.
692
+
693
+ - A draft is keyed by form and respondent (a user id, or a session id made once on the device).
694
+ `version` (default `fingerprint(definition)`) is stored inside it. At the same version it is put
695
+ back as it is. At another version it is put back only when every answer still fits (the question
696
+ is there, of the same type, and the answer passes its format checks); otherwise the whole draft is
697
+ removed, never half put back (`restoreDraft`, reason `version`). Drafts older than 30 days
698
+ (`maxAgeMs`) are removed too (`expired`).
699
+ - Sensitive answers (`isSensitive`) are left out unless `keepSensitive: true`, so they never sit in
700
+ the device's storage; calculated fields and withheld identifying ones are never saved.
701
+ - Answers cleared to nothing remove the draft (one that was restored, or one this visit saved). A
702
+ save that fails goes to `onError` (default `console.warn`); autosave never throws.
703
+ - Call `clear()` only once the submission is safe: after `outbox.enqueue` resolved.
704
+
705
+ Without React: `createDraftAutosave(engine, store, key, options)` (with `flush`, `clear`,
706
+ `dispose`), `draftOf(engine, key)` and `restoreDraft(definition, draft, { version })`.
707
+
708
+ ### The outbox
709
+
710
+ ```ts
711
+ import { createSubmitOutbox, webStorage } from "@marktiderman/genesis-forms";
712
+ import { useSubmitOutbox } from "@marktiderman/genesis-forms-react";
713
+
714
+ // Once, for the whole app.
715
+ export const outbox = createSubmitOutbox({
716
+ storage: webStorage(),
717
+ send: async (item, { signal }) => {
718
+ const res = await fetch(`/api/forms/${item.formId}/submit`, {
719
+ method: "POST",
720
+ headers: { "content-type": "application/json" },
721
+ body: JSON.stringify(item.request),
722
+ signal,
723
+ });
724
+ return res.json(); // the SubmitResult, whatever the status
725
+ },
726
+ });
727
+
728
+ // On submit: queue it, then clear the draft.
729
+ if (engine.submit()) {
730
+ await outbox.enqueue({ formId, submission: { events: engine.getState().events }, attemptId });
731
+ await clear();
732
+ }
733
+
734
+ // Or with useFormController: the same thing as its onSubmit. It resolves once the submission
735
+ // is stored on the device, so the ending shows offline; a storage failure sets phase "failed".
736
+ const controller = useFormController(engine, { onSubmit: outboxSubmit(outbox, { formId, attemptId, clearDraft: clear }) });
737
+
738
+ // Anywhere: what's waiting.
739
+ const { pending, failed, items, online } = useSubmitOutbox(outbox);
740
+ ```
741
+
742
+ - `enqueue` resolves once the submission is in storage, with a new idempotency key (or the one
743
+ passed: the same key twice queues it once). It rejects when storage can't take it; keep the draft
744
+ then and tell the respondent.
745
+ - It sends when online, oldest first and one at a time, and backs off between tries (about 2 s,
746
+ doubling to 15 min, with jitter; `backoff`), the same body and key every time. Coming online sends
747
+ what's pending at once. `useSubmitOutbox` follows the browser's `online` and `offline` events; in
748
+ React Native pass `trackOnline: false` and call `outbox.setOnline` from NetInfo.
749
+ - `send` answers the endpoint's `SubmitResult` (`submitOutcome` maps it). `ok` and `duplicate` are
750
+ delivered and removed (`onDelivered`). `invalid` and `closed` keep it as `failed` with the server's
751
+ `rejection`. Anything else (a throw, a timeout after 30 s, `rate_limited`, `store_failed`, a body
752
+ that isn't a `SubmitResult`) is tried again, up to 10 tries (`maxAttempts`), then kept as `failed`.
753
+ Nothing is dropped silently: a failed submission stays, in storage too, until `retry(id)` or
754
+ `discard(id)`. One that ran out of tries (not one the server refused) starts again from its first
755
+ try each time the outbox starts and each time the device comes back online. A `rate_limited`
756
+ answer's `retryAfterSeconds` is waited out.
757
+ - To let the respondent fix a refused submission, open the form from `item.request` (`replay` its
758
+ `events`, or `initialValues` from its `answers`), `discard` the old one and `enqueue` the corrected
759
+ one: it gets a new key.
760
+ - `discard` takes it out of the queue at once: no send starts after it is called.
761
+ - The outbox holds the whole submission, sensitive answers too, in the device's storage until the
762
+ server has it. Pick the storage with that in mind (MMKV with encryption, say).
763
+ - It is kept across restarts: each submission under its own key and an index beside it. The index
764
+ is changed one outbox at a time (in one JS context, and across tabs where Web Locks exist), and
765
+ each change writes back every submission that outbox holds, so two tabs on one storage keep each
766
+ other's. Both may send one; its key makes the server store it once. An entry that can't be read is
767
+ logged, listed in `unreadable` and left in storage.
768
+ - Where there is no `crypto.randomUUID` (React Native without a polyfill) pass `createKey`
769
+ (expo-crypto's `randomUUID`); `createSubmitOutbox` throws without one.
770
+
491
771
  ## Destinations
492
772
 
493
773
  `@marktiderman/genesis-forms/destinations` sends a checked submission somewhere. No dependencies:
@@ -557,7 +837,7 @@ submission. On an anonymous form identifying fields get no column. `sensitive` f
557
837
  | --- | --- | --- |
558
838
  | `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. |
559
839
  | `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). |
840
+ | `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 24 bytes decoded, `MIN_WEBHOOK_SECRET_BYTES`, as the spec recommends), 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()` (32 bytes). To rotate without downtime, pass `secret` as an array (new and old, or a resolver returning one): every delivery is signed with each, space-separated in `webhook-signature`, so the receiver verifies with either until it drops the old one. Body `{ type: "form.submitted", timestamp, data: { form, submission, row } }` (`form.updated` on `update`); `timestamp` is when the event happened (the submission's `submittedAt`, else the send time), while the `webhook-timestamp` header is the send time. A 410 Gone fails permanently with `disable: true`, the receiver asking to be switched off; the runner passes it on as `disable: true` on the `failed` outcome, so the app can disable the destination. `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 through an egress proxy that refuses internal addresses (see below). Receivers check with `verifyWebhook({ secret, headers, body })` (constant-time, five-minute tolerance; `secret` may be an array during a rotation). |
561
841
  | `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
842
 
563
843
  ### Sending by answer
@@ -581,8 +861,11 @@ destinations) resolves the name and refuses it unless every address is public by
581
861
  benchmarking, multicast, IPv4-mapped, NAT64, 6to4 and Teredo ranges. A lookup that fails is
582
862
  retryable; a private address is not. In Node, `resolveHost` can be
583
863
  `(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.
864
+ name is checked and then fetched, so a name re-pointed in between (DNS rebinding) is not caught,
865
+ and no check made before `fetch` can close that gap. Where untrusted people choose URLs, send
866
+ through an egress proxy that resolves the name itself and refuses internal addresses at connect
867
+ time (Smokescreen, say, or a host's own outbound proxy), passing a `fetch` that goes through it,
868
+ or from a network that cannot reach internal services at all.
586
869
 
587
870
  ### Google sign-in for sheets
588
871
 
@@ -604,7 +887,11 @@ sheetsDestination({
604
887
  Webhook and Slack URLs, signing secrets and refresh tokens should not sit in config. The helpers
605
888
  seal them for whatever store the app uses: `deriveSecretKey(appKey, purpose)` derives an AES-256-GCM
606
889
  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>`
890
+ HKDF-SHA-256, one key per purpose. `deriveSecretKey(appKey, purpose, { salt })` takes an HKDF salt
891
+ (bytes, or text as UTF-8); it defaults to empty, which every key derived before the option used,
892
+ so leave it out to keep opening what is already sealed. For a new app, a fixed app-wide random salt
893
+ (32 random bytes kept as config) is recommended; it need not be secret but must never change once
894
+ anything is sealed with it. `sealSecret(key, plaintext, aad)` gives `v1.<iv>.<ciphertext>`
608
895
  (base64url), bound to `aad` (the row it belongs to), and `openSecret(key, sealed, aad)` opens it,
609
896
  failing permanently for another row, another purpose or a rotated key.
610
897
 
@@ -640,8 +927,10 @@ match `config.when`), `skipped` (the destination returned `notSent`, a deliberat
640
927
  cap; never retried), `retry` with `retryAt`, and `failed`. A retryable failure with the service's
641
928
  Retry-After is deferred without counting an attempt (`counted: false`), up to `maxDeferrals`
642
929
  (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
930
+ failure backs off by attempt (`defaultBackoff`: 5 minutes, quadrupling, at most 24 hours, each
931
+ wait jittered by ±20%; about 3 days across the default attempts, as Standard Webhooks suggests)
932
+ until `maxAttempts` (default 8), then fails. A `failed` outcome with `disable: true` is a receiver
933
+ asking to be switched off (a webhook's 410). Permanent failures and unknown kinds fail at once. A destination that
645
934
  throws is reduced to a safe message. `run` returns a `DeliveryReport` of counts, including `stale`
646
935
  (record resolved false) and `unrecorded` (record threw; the lease runs out and the row comes back).
647
936