@marktiderman/genesis-forms 0.7.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 +93 -4
- package/dist/destinations.cjs +658 -18
- package/dist/destinations.cjs.map +1 -1
- package/dist/destinations.d.cts +394 -6
- package/dist/destinations.d.ts +394 -6
- package/dist/destinations.js +629 -19
- package/dist/destinations.js.map +1 -1
- package/dist/{engine-C5tb3mkf.d.cts → engine-Bj23YJlB.d.cts} +2 -2
- package/dist/{engine-DcNQ-6oJ.d.ts → engine-CzinTJph.d.ts} +2 -2
- package/dist/index.d.cts +8 -8
- package/dist/index.d.ts +8 -8
- package/dist/server.d.cts +5 -5
- package/dist/server.d.ts +5 -5
- package/dist/testing.d.cts +4 -4
- package/dist/testing.d.ts +4 -4
- package/dist/{types-oAckGR_Z.d.cts → types-jqDKcmP4.d.cts} +1 -1
- package/dist/{types-oAckGR_Z.d.ts → types-jqDKcmP4.d.ts} +1 -1
- package/dist/{upload-protocol-Bw0CWRJc.d.cts → upload-protocol-BOe018kL.d.cts} +1 -1
- package/dist/{upload-protocol-PX0jroTV.d.ts → upload-protocol-BUO74WkS.d.ts} +1 -1
- package/dist/{validate-qwkdcr3H.d.ts → validate-DvfQHYnu.d.ts} +1 -1
- package/dist/{validate-BFpNJbjf.d.cts → validate-irIPubxc.d.cts} +1 -1
- package/package.json +1 -1
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
|
|
|
@@ -542,7 +542,10 @@ store and log: never a secret, an answer, contact details or the webhook URL. Co
|
|
|
542
542
|
has a timeout (`timeoutMs`, default 10 s) and honours `options.signal`. `deliverBatch(items,
|
|
543
543
|
config)` (the sheet) sends many in one request; all succeed or fail together. Write your own by
|
|
544
544
|
implementing `Destination` and throwing `DeliveryError` inside; `deliveryFailure(error)` turns any
|
|
545
|
-
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.
|
|
546
549
|
|
|
547
550
|
**Rows.** `responseRow(submission, definition)` flattens a submission: `id`, `submitted_at`, the
|
|
548
551
|
app's `meta` columns, then one column per field key in form order. Numbers stay numbers, choices
|
|
@@ -553,8 +556,94 @@ submission. On an anonymous form identifying fields get no column. `sensitive` f
|
|
|
553
556
|
| Destination | Config | Notes |
|
|
554
557
|
| --- | --- | --- |
|
|
555
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. |
|
|
556
|
-
| `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. |
|
|
557
|
-
| `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 `
|
|
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).
|
|
558
647
|
|
|
559
648
|
## Testing
|
|
560
649
|
|