@marktiderman/genesis-forms 0.8.0 → 0.9.1
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 +299 -10
- package/dist/destinations.cjs +331 -114
- package/dist/destinations.cjs.map +1 -1
- package/dist/destinations.d.cts +67 -45
- package/dist/destinations.d.ts +67 -45
- package/dist/destinations.js +327 -111
- package/dist/destinations.js.map +1 -1
- package/dist/{engine-Bj23YJlB.d.cts → engine-BaHPC2-1.d.cts} +2 -2
- package/dist/{engine-CzinTJph.d.ts → engine-Da99Tv-E.d.ts} +2 -2
- package/dist/index.cjs +996 -167
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +288 -11
- package/dist/index.d.ts +288 -11
- package/dist/index.js +975 -168
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +563 -173
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +3 -474
- package/dist/server.d.ts +3 -474
- package/dist/server.js +558 -174
- package/dist/server.js.map +1 -1
- package/dist/submit-protocol-CFi-0Qd0.d.ts +840 -0
- package/dist/submit-protocol-D0cOckmK.d.cts +840 -0
- package/dist/testing.cjs +250 -154
- 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 +250 -154
- package/dist/testing.js.map +1 -1
- package/dist/{types-jqDKcmP4.d.cts → types-C0pUfew3.d.cts} +7 -1
- package/dist/{types-jqDKcmP4.d.ts → types-C0pUfew3.d.ts} +7 -1
- package/dist/{validate-DvfQHYnu.d.ts → validate-DpmBssFk.d.ts} +258 -140
- package/dist/{validate-irIPubxc.d.cts → validate-U5LoW3eR.d.cts} +258 -140
- package/package.json +3 -2
- package/dist/upload-protocol-BOe018kL.d.cts +0 -122
- 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
|
|
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,
|
|
420
|
-
trusted
|
|
421
|
-
`
|
|
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
|
|
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
|
-
|
|
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
|
|
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`:
|
|
644
|
-
|
|
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
|
|