@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 +157 -10
- 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-DC4YTPdr.d.cts → engine-Bj23YJlB.d.cts} +2 -2
- package/dist/{engine-CpfOz8FN.d.ts → engine-CzinTJph.d.ts} +2 -2
- package/dist/index.cjs +4 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -66
- package/dist/index.d.ts +9 -66
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +228 -0
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +199 -6
- package/dist/server.d.ts +199 -6
- package/dist/server.js +221 -1
- package/dist/server.js.map +1 -1
- 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.map +1 -1
- 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-BOe018kL.d.cts +122 -0
- package/dist/upload-protocol-BUO74WkS.d.ts +122 -0
- package/dist/{validate-GpTXPwyl.d.ts → validate-DvfQHYnu.d.ts} +3 -3
- package/dist/{validate-B5WKHbst.d.cts → validate-irIPubxc.d.cts} +3 -3
- package/package.json +1 -1
package/dist/destinations.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { J as Json, F as FormDefinition, a as FieldBlock } from './types-
|
|
1
|
+
import { J as Json, F as FormDefinition, a as FieldBlock, C as Condition } from './types-jqDKcmP4.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The contract every destination keeps. A destination sends one checked
|
|
@@ -53,12 +53,26 @@ interface DeliveryOptions {
|
|
|
53
53
|
redelivery?: boolean | undefined;
|
|
54
54
|
/** Aborts the delivery. Each request also has its own timeout. */
|
|
55
55
|
signal?: AbortSignal | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* The submission was delivered before and this is its update (an edit).
|
|
58
|
+
* The sheet overwrites the response's row where it is (appending only when
|
|
59
|
+
* it is gone), and the webhook's event type becomes `form.updated`.
|
|
60
|
+
*/
|
|
61
|
+
update?: boolean | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* The app's id for the destination. Passed on to option functions (a
|
|
64
|
+
* secret, an account, an access token, a URL), so they can look up what
|
|
65
|
+
* is stored for it.
|
|
66
|
+
*/
|
|
67
|
+
destinationId?: string | undefined;
|
|
56
68
|
}
|
|
57
69
|
/** One submission in a batch: each carries its own definition and delivery id. */
|
|
58
70
|
interface DeliveryItem {
|
|
59
71
|
submission: DeliverySubmission;
|
|
60
72
|
definition: FormDefinition;
|
|
61
73
|
deliveryId?: string | undefined;
|
|
74
|
+
/** This item is an update of a submission delivered before (see `DeliveryOptions.update`). */
|
|
75
|
+
update?: boolean | undefined;
|
|
62
76
|
}
|
|
63
77
|
type DeliveryResult = {
|
|
64
78
|
ok: true;
|
|
@@ -66,6 +80,12 @@ type DeliveryResult = {
|
|
|
66
80
|
state?: Record<string, Json>;
|
|
67
81
|
/** Submission ids already there (a redelivery), so nothing was sent for them. */
|
|
68
82
|
skipped?: string[];
|
|
83
|
+
/**
|
|
84
|
+
* Set when the destination deliberately sent nothing (no address to
|
|
85
|
+
* send to, a daily cap reached): the reason, safe to store. Not a
|
|
86
|
+
* failure and never retried; `createDeliveryRunner` records it as skipped.
|
|
87
|
+
*/
|
|
88
|
+
notSent?: string;
|
|
69
89
|
} | DeliveryFailure;
|
|
70
90
|
interface DeliveryFailure {
|
|
71
91
|
ok: false;
|
|
@@ -149,6 +169,15 @@ interface HttpDeps {
|
|
|
149
169
|
/** Per request. Default `REQUEST_TIMEOUT_MS`. */
|
|
150
170
|
timeoutMs?: number | undefined;
|
|
151
171
|
}
|
|
172
|
+
/** What an option function is told besides the config. */
|
|
173
|
+
interface ResolveContext {
|
|
174
|
+
/** `DeliveryOptions.destinationId`, when the app gave one. */
|
|
175
|
+
destinationId?: string | undefined;
|
|
176
|
+
}
|
|
177
|
+
/** A factory option worked out per delivery from the destination's config. */
|
|
178
|
+
type OptionResolver<T> = (config: DestinationConfig, context: ResolveContext) => T | Promise<T>;
|
|
179
|
+
/** WebCrypto's key type, named without depending on the DOM lib. */
|
|
180
|
+
type WebCryptoKey = Awaited<ReturnType<typeof crypto.subtle.importKey>>;
|
|
152
181
|
|
|
153
182
|
interface RowOptions {
|
|
154
183
|
/**
|
|
@@ -342,8 +371,18 @@ interface SheetConfig {
|
|
|
342
371
|
sheetName?: string;
|
|
343
372
|
}
|
|
344
373
|
interface SheetsDestinationOptions extends HttpDeps {
|
|
345
|
-
/**
|
|
346
|
-
|
|
374
|
+
/**
|
|
375
|
+
* The service account the sheet is shared with, or a function that finds
|
|
376
|
+
* the one for a destination's config. Give this or `accessToken`.
|
|
377
|
+
*/
|
|
378
|
+
account?: GoogleServiceAccount | OptionResolver<GoogleServiceAccount> | undefined;
|
|
379
|
+
/**
|
|
380
|
+
* An access token for the sheet instead of a service account: a sheet a
|
|
381
|
+
* person connected with Google sign-in, say (see `googleOAuthAccessToken`).
|
|
382
|
+
* Gets the config and `{ destinationId }`; called once per delivery or
|
|
383
|
+
* batch, so cache inside it.
|
|
384
|
+
*/
|
|
385
|
+
accessToken?: OptionResolver<string> | undefined;
|
|
347
386
|
/**
|
|
348
387
|
* Prefix text a spreadsheet could run as a formula with `'` (see
|
|
349
388
|
* `escapeFormula`), headers included. Default false: writes are RAW, so
|
|
@@ -381,6 +420,47 @@ interface SheetState {
|
|
|
381
420
|
}
|
|
382
421
|
declare function sheetsDestination(options: SheetsDestinationOptions): Destination;
|
|
383
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Where a Slack or webhook destination may post, checked against SSRF: https
|
|
425
|
+
* only, on the default port, with no credentials in the address, and only to
|
|
426
|
+
* a public host, never a private, loopback, link-local or otherwise internal
|
|
427
|
+
* one. `parseHookUrl` checks the text (use it when someone adds the address);
|
|
428
|
+
* `checkHookHost` checks every address the name resolves to right now (use it
|
|
429
|
+
* before every request: what a name points to can change).
|
|
430
|
+
*
|
|
431
|
+
* A check before the request still leaves a short window in which the name
|
|
432
|
+
* could be re-pointed (DNS rebinding). Where untrusted people choose URLs,
|
|
433
|
+
* also send from a network that cannot reach internal services.
|
|
434
|
+
*/
|
|
435
|
+
|
|
436
|
+
type HookKind = "slack" | "webhook";
|
|
437
|
+
/** Resolves a host name to its addresses (IPv4 and IPv6), as the server's DNS sees them. In Node: `dns.promises.lookup(host, { all: true })` mapped to `.address`. */
|
|
438
|
+
type ResolveHost = (host: string) => Promise<string[]>;
|
|
439
|
+
declare const MAX_HOOK_URL_LENGTH = 2048;
|
|
440
|
+
/** The hosts a Slack incoming webhook lives on. */
|
|
441
|
+
declare const SLACK_HOSTS: ReadonlySet<string>;
|
|
442
|
+
/** An address that can't be used, with a reason safe to show and to store (never the address). */
|
|
443
|
+
declare class HookUrlError extends DeliveryError {
|
|
444
|
+
name: string;
|
|
445
|
+
}
|
|
446
|
+
/** Whether an IP address (v4 or v6) is a public one a hook may reach. Anything unparsable is not. */
|
|
447
|
+
declare function isPublicAddress(ip: string): boolean;
|
|
448
|
+
/**
|
|
449
|
+
* The address as it will be used (its fragment dropped), or a `HookUrlError`
|
|
450
|
+
* saying what is wrong with it. Checks the text only; `checkHookHost` checks
|
|
451
|
+
* where the name resolves. A `"slack"` address must be a Slack incoming
|
|
452
|
+
* webhook (`https://hooks.slack.com/services/…`).
|
|
453
|
+
*/
|
|
454
|
+
declare function parseHookUrl(raw: string, kind?: HookKind): URL;
|
|
455
|
+
/**
|
|
456
|
+
* Checks that every address the URL's host resolves to is public. A host
|
|
457
|
+
* that resolves to nothing, or whose lookup fails, is refused too, as a
|
|
458
|
+
* retryable failure (DNS may be briefly down); a private address is final.
|
|
459
|
+
*/
|
|
460
|
+
declare function checkHookHost(url: URL, resolve: ResolveHost): Promise<void>;
|
|
461
|
+
/** The host as a screen may show it (never the path: a Slack or webhook path can be the secret). */
|
|
462
|
+
declare function hookHost(url: URL): string;
|
|
463
|
+
|
|
384
464
|
/** What the webhook destination reads from a destination's config. */
|
|
385
465
|
interface WebhookConfig {
|
|
386
466
|
url: string;
|
|
@@ -395,15 +475,28 @@ interface WebhookDestinationOptions extends HttpDeps {
|
|
|
395
475
|
* base64 fails permanently. Or a function that finds the one for a
|
|
396
476
|
* destination's config. Never put it in config.
|
|
397
477
|
*/
|
|
398
|
-
secret: string |
|
|
478
|
+
secret: string | OptionResolver<string>;
|
|
399
479
|
/** Allow `http:` URLs. Default false. */
|
|
400
480
|
allowInsecureUrls?: boolean | undefined;
|
|
401
481
|
/** Allow localhost and private, loopback and link-local address literals. Default false. */
|
|
402
482
|
allowPrivateHosts?: boolean | undefined;
|
|
403
483
|
/** Sensitive fields: `"include"` (default), `"mask"` or `"omit"`. */
|
|
404
484
|
sensitive?: RowOptions["sensitive"];
|
|
405
|
-
/** The event type in the payload. Default `"form.submitted"
|
|
485
|
+
/** The event type in the payload. Default `"form.submitted"`, or `"form.updated"` for an update. */
|
|
406
486
|
type?: string | undefined;
|
|
487
|
+
/**
|
|
488
|
+
* Where to post, when it is not `config.url`: the address kept apart from
|
|
489
|
+
* config (encrypted, say, since a webhook URL can carry a token). Gets the
|
|
490
|
+
* config and `{ destinationId }`. It passes the same checks.
|
|
491
|
+
*/
|
|
492
|
+
url?: OptionResolver<string> | undefined;
|
|
493
|
+
/**
|
|
494
|
+
* Resolves the URL's host before every request, and the delivery is refused
|
|
495
|
+
* unless every address is public (see `checkHookHost`). This catches a
|
|
496
|
+
* hostname that points at a private address, which the URL checks alone
|
|
497
|
+
* cannot. Recommended wherever untrusted people choose URLs.
|
|
498
|
+
*/
|
|
499
|
+
resolveHost?: ResolveHost | undefined;
|
|
407
500
|
now?: (() => number) | undefined;
|
|
408
501
|
}
|
|
409
502
|
/** The JSON body a webhook receives. */
|
|
@@ -431,6 +524,12 @@ interface WebhookPayload {
|
|
|
431
524
|
row: Record<string, Json>;
|
|
432
525
|
};
|
|
433
526
|
}
|
|
527
|
+
/**
|
|
528
|
+
* A new signing secret: `whsec_` and standard base64 of 32 random bytes, the
|
|
529
|
+
* form Standard Webhooks libraries read. Show it to the receiver once; keep it
|
|
530
|
+
* out of config.
|
|
531
|
+
*/
|
|
532
|
+
declare function newWebhookSecret(): string;
|
|
434
533
|
/** The `webhook-signature` value for a message: `v1,<base64>`. */
|
|
435
534
|
declare function signWebhook(secret: string, id: string, timestamp: number, body: string): Promise<string>;
|
|
436
535
|
interface VerifyWebhookInput {
|
|
@@ -462,4 +561,293 @@ declare function webhookPayload(submission: DeliverySubmission, definition: Form
|
|
|
462
561
|
}): WebhookPayload;
|
|
463
562
|
declare function webhookDestination(options: WebhookDestinationOptions): Destination;
|
|
464
563
|
|
|
465
|
-
|
|
564
|
+
/**
|
|
565
|
+
* The Slack destination: one message per submission to a Slack incoming
|
|
566
|
+
* webhook (the channel is the webhook's). The message is a heading (the
|
|
567
|
+
* form's title), each answer as "label: value" in form order, and a footer
|
|
568
|
+
* with a link back to the app when one is given. Every value is escaped, so
|
|
569
|
+
* text a respondent typed can't mention `@channel` or make a link.
|
|
570
|
+
*
|
|
571
|
+
* The webhook URL is itself the credential, so it is usually kept out of
|
|
572
|
+
* config: pass `url` to look it up. It must be a Slack incoming webhook
|
|
573
|
+
* (`https://hooks.slack.com/services/…`); redirects are not followed.
|
|
574
|
+
*/
|
|
575
|
+
|
|
576
|
+
/** Longest answer shown in full; longer ones are cut (the app has the rest). */
|
|
577
|
+
declare const SLACK_VALUE_LIMIT = 1500;
|
|
578
|
+
/** Text for Slack's mrkdwn: `&`, `<` and `>` escaped, which is all Slack asks. */
|
|
579
|
+
declare function escapeSlack(text: string): string;
|
|
580
|
+
interface SlackMessageOptions {
|
|
581
|
+
/** The heading, as plain text (it is escaped). Default `New response: <title>`, or `Updated response: …` for an update. */
|
|
582
|
+
heading?: string | undefined;
|
|
583
|
+
/** An update of a submission delivered before. */
|
|
584
|
+
update?: boolean | undefined;
|
|
585
|
+
/** Lines shown before the answers, as `[label, value]` plain text (a location, a session). */
|
|
586
|
+
lines?: readonly (readonly [string, string])[] | undefined;
|
|
587
|
+
/** A link back to the response in the app: http(s) only. */
|
|
588
|
+
link?: {
|
|
589
|
+
url: string;
|
|
590
|
+
text?: string | undefined;
|
|
591
|
+
} | null | undefined;
|
|
592
|
+
/** Sensitive fields: `"mask"` (default), `"omit"` or `"include"`. A channel is usually wider than an inbox. */
|
|
593
|
+
sensitive?: RowOptions["sensitive"];
|
|
594
|
+
/** Field keys left out (contact details, say). */
|
|
595
|
+
omit?: readonly string[] | undefined;
|
|
596
|
+
}
|
|
597
|
+
/** The message Slack gets for one submission: `{ text }` in Slack mrkdwn. */
|
|
598
|
+
declare function slackMessage(submission: DeliverySubmission, definition: FormDefinition, options?: SlackMessageOptions): {
|
|
599
|
+
text: string;
|
|
600
|
+
};
|
|
601
|
+
interface SlackMessageInput {
|
|
602
|
+
submission: DeliverySubmission;
|
|
603
|
+
definition: FormDefinition;
|
|
604
|
+
config: DestinationConfig;
|
|
605
|
+
/** `DeliveryOptions.update`. */
|
|
606
|
+
update: boolean;
|
|
607
|
+
destinationId?: string | undefined;
|
|
608
|
+
}
|
|
609
|
+
interface SlackDestinationOptions extends HttpDeps {
|
|
610
|
+
/**
|
|
611
|
+
* The incoming webhook URL, when it is not `config.url`. Gets the config
|
|
612
|
+
* and `{ destinationId }`.
|
|
613
|
+
*/
|
|
614
|
+
url?: OptionResolver<string> | undefined;
|
|
615
|
+
/** Resolves the host before every request; the delivery is refused unless every address is public. */
|
|
616
|
+
resolveHost?: ResolveHost | undefined;
|
|
617
|
+
/** The message's options per delivery (heading, extra lines, a link back). */
|
|
618
|
+
message?: ((input: SlackMessageInput) => SlackMessageOptions | Promise<SlackMessageOptions>) | undefined;
|
|
619
|
+
}
|
|
620
|
+
declare function slackDestination(options?: SlackDestinationOptions): Destination;
|
|
621
|
+
|
|
622
|
+
/** How deep `all` / `any` / `not` may nest in a stored condition. */
|
|
623
|
+
declare const MAX_CONDITION_DEPTH = 10;
|
|
624
|
+
/** Whether a stored value has a `Condition`'s shape, nested at most `MAX_CONDITION_DEPTH` deep. */
|
|
625
|
+
declare function isCondition(v: unknown, depth?: number): v is Condition;
|
|
626
|
+
/**
|
|
627
|
+
* The destination's condition (`config.when`), or null when it has none. One
|
|
628
|
+
* that can't be read throws a permanent `DeliveryError` rather than meaning
|
|
629
|
+
* "send everything" or "send nothing".
|
|
630
|
+
*/
|
|
631
|
+
declare function conditionOf(config: DestinationConfig): Condition | null;
|
|
632
|
+
/**
|
|
633
|
+
* Whether a submission matches its destination's condition (true when there
|
|
634
|
+
* is none). Throws a `DeliveryError` when the condition can't be read.
|
|
635
|
+
*/
|
|
636
|
+
declare function passesCondition(submission: Pick<DeliverySubmission, "answers" | "hidden" | "context">, definition: FormDefinition, config: DestinationConfig): boolean;
|
|
637
|
+
|
|
638
|
+
/** Google's scope for files the person picked or created through the app. */
|
|
639
|
+
declare const DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
|
640
|
+
/** The app's OAuth client, from the Google Cloud console. Server-side only. */
|
|
641
|
+
interface GoogleOAuthClient {
|
|
642
|
+
clientId: string;
|
|
643
|
+
clientSecret: string;
|
|
644
|
+
}
|
|
645
|
+
interface GoogleOAuthTokenOptions extends HttpDeps {
|
|
646
|
+
now?: (() => number) | undefined;
|
|
647
|
+
signal?: AbortSignal | undefined;
|
|
648
|
+
}
|
|
649
|
+
interface RefreshedGoogleToken {
|
|
650
|
+
accessToken: string;
|
|
651
|
+
/** Seconds the access token is good for. */
|
|
652
|
+
expiresIn: number;
|
|
653
|
+
/** The scopes granted, space-separated. */
|
|
654
|
+
scope: string;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Trades a refresh token for an access token, uncached. `invalid_grant`
|
|
658
|
+
* (access revoked, or the account gone) is a permanent failure saying to
|
|
659
|
+
* connect the sheet again; 429 and 5xx are retryable. Never puts a token in
|
|
660
|
+
* an error message.
|
|
661
|
+
*/
|
|
662
|
+
declare function refreshGoogleAccessToken(client: GoogleOAuthClient, refreshToken: string, options?: GoogleOAuthTokenOptions): Promise<RefreshedGoogleToken>;
|
|
663
|
+
/** Forgets cached OAuth access tokens (tests, or after a sheet is disconnected). */
|
|
664
|
+
declare function clearGoogleOAuthTokenCache(): void;
|
|
665
|
+
/** A cached or fresh access token for a refresh token, cached until a minute before it expires. */
|
|
666
|
+
declare function googleOAuthAccessToken(client: GoogleOAuthClient, refreshToken: string | null | undefined, options?: GoogleOAuthTokenOptions): Promise<string>;
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Keeping a destination's secrets at rest: a webhook or Slack URL, a signing
|
|
670
|
+
* secret, a Google refresh token. AES-256-GCM (WebCrypto) under a key derived
|
|
671
|
+
* with HKDF-SHA-256 from one 32-byte app secret and a purpose string, so no
|
|
672
|
+
* two purposes share a key; each ciphertext is bound to associated data (the
|
|
673
|
+
* row it belongs to), so one moved onto another row does not decrypt.
|
|
674
|
+
*
|
|
675
|
+
* The sealed form is `v1.<iv>.<ciphertext and tag>`, base64url. Where it is
|
|
676
|
+
* stored is the app's; the key never is.
|
|
677
|
+
*/
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* The 32 key bytes from base64 or base64url text (as `openssl rand -base64 32`
|
|
681
|
+
* prints), or the bytes themselves. Throws a `DeliveryError` naming the
|
|
682
|
+
* problem, never the key.
|
|
683
|
+
*/
|
|
684
|
+
declare function secretKeyBytes(key: string | Uint8Array): Uint8Array<ArrayBuffer>;
|
|
685
|
+
/**
|
|
686
|
+
* An AES-GCM key for one purpose, derived from the app secret with HKDF-SHA-256
|
|
687
|
+
* (empty salt, `info` naming the purpose). Use a different `info` for each
|
|
688
|
+
* kind of secret; keep it fixed once anything is sealed with it.
|
|
689
|
+
*/
|
|
690
|
+
declare function deriveSecretKey(key: string | Uint8Array, info: string): Promise<WebCryptoKey>;
|
|
691
|
+
/** Encrypts `plaintext` bound to `aad` (the row it belongs to): `v1.<iv>.<ciphertext>`. */
|
|
692
|
+
declare function sealSecret(key: WebCryptoKey, plaintext: string, aad: string): Promise<string>;
|
|
693
|
+
/**
|
|
694
|
+
* Decrypts what `sealSecret` sealed with the same key and `aad`. Throws a
|
|
695
|
+
* permanent `DeliveryError` for an unknown format, a wrong key (rotated?) or
|
|
696
|
+
* a ciphertext from another row; never echoes the ciphertext.
|
|
697
|
+
*/
|
|
698
|
+
declare function openSecret(key: WebCryptoKey, sealed: string, aad: string): Promise<string>;
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* The outbox runner: claim a batch of due deliveries, send each to its
|
|
702
|
+
* destination, and record what happened. The store is the app's (a SQL
|
|
703
|
+
* outbox, a queue, a KV list): it hands over claimed deliveries and takes
|
|
704
|
+
* back outcomes, and decides how claims and leases work. The runner owns the
|
|
705
|
+
* rest: the destination's condition (`config.when`), batching deliveries to
|
|
706
|
+
* one destination into one request where the destination can, keeping its
|
|
707
|
+
* state, and turning each result into an outcome with a retry time.
|
|
708
|
+
*
|
|
709
|
+
* Outcomes:
|
|
710
|
+
* - `sent`: delivered (or already there, on a redelivery).
|
|
711
|
+
* - `filtered`: the submission doesn't match the destination's condition; nothing was sent, nothing to retry.
|
|
712
|
+
* - `skipped`: the destination deliberately sent nothing (`notSent`); never retried.
|
|
713
|
+
* - `retry`: a retryable failure. With the service's own Retry-After it is
|
|
714
|
+
* deferred without counting an attempt (`counted: false`); otherwise it backs
|
|
715
|
+
* off by attempt (`counted: true`), until `maxAttempts`.
|
|
716
|
+
* - `failed`: permanent, an unknown kind, or out of attempts.
|
|
717
|
+
*
|
|
718
|
+
* It never throws for a delivery's failure, and never logs answers, contact
|
|
719
|
+
* details or secrets: only ids, kinds and the destinations' safe messages.
|
|
720
|
+
*/
|
|
721
|
+
|
|
722
|
+
/** One claimed delivery, as the app's store hands it over. */
|
|
723
|
+
interface ClaimedDelivery {
|
|
724
|
+
/** The delivery's id: stable across its retries (the email idempotency key, the webhook-id). */
|
|
725
|
+
id: string;
|
|
726
|
+
/** The destination's id. Deliveries to one destination are batched, and its state is kept by it. */
|
|
727
|
+
destinationId: string;
|
|
728
|
+
/** The destination's kind: a key of `destinations`. */
|
|
729
|
+
kind: string;
|
|
730
|
+
/** The destination's config (no secrets). */
|
|
731
|
+
config: DestinationConfig;
|
|
732
|
+
/** What the destination cached last time (`DeliveryResult.state`), as stored. */
|
|
733
|
+
state?: Record<string, Json> | undefined;
|
|
734
|
+
submission: DeliverySubmission;
|
|
735
|
+
definition: FormDefinition;
|
|
736
|
+
/** This delivery's attempt number, from 1 for its first. */
|
|
737
|
+
attempt: number;
|
|
738
|
+
/**
|
|
739
|
+
* How many times it was deferred so far (outcomes with `counted: false`).
|
|
740
|
+
* Keep and pass it back, so `maxDeferrals` can stop a service that answers
|
|
741
|
+
* "try later" forever. Default 0.
|
|
742
|
+
*/
|
|
743
|
+
deferrals?: number | undefined;
|
|
744
|
+
/**
|
|
745
|
+
* An earlier attempt may have gone through (it was claimed before and timed
|
|
746
|
+
* out, or its lease ran out), so destinations that can look first do.
|
|
747
|
+
*/
|
|
748
|
+
redelivery?: boolean | undefined;
|
|
749
|
+
/** An update of a submission delivered before (an edit). */
|
|
750
|
+
update?: boolean | undefined;
|
|
751
|
+
}
|
|
752
|
+
type DeliveryOutcome = {
|
|
753
|
+
status: "sent";
|
|
754
|
+
/** It was already there (a redelivery), so nothing was sent this time. */
|
|
755
|
+
alreadyThere?: boolean;
|
|
756
|
+
} | {
|
|
757
|
+
status: "filtered";
|
|
758
|
+
} | {
|
|
759
|
+
status: "skipped";
|
|
760
|
+
/** Why, safe to store. */
|
|
761
|
+
reason: string;
|
|
762
|
+
} | {
|
|
763
|
+
status: "retry";
|
|
764
|
+
error: string;
|
|
765
|
+
/** When to try again. */
|
|
766
|
+
retryAt: Date;
|
|
767
|
+
retryAfterSeconds: number;
|
|
768
|
+
/** Whether this attempt counts toward `maxAttempts`. False when the service said when to come back. */
|
|
769
|
+
counted: boolean;
|
|
770
|
+
httpStatus?: number;
|
|
771
|
+
} | {
|
|
772
|
+
status: "failed";
|
|
773
|
+
error: string;
|
|
774
|
+
httpStatus?: number;
|
|
775
|
+
};
|
|
776
|
+
interface ClaimRequest<Filter> {
|
|
777
|
+
/** Most deliveries to claim. */
|
|
778
|
+
limit: number;
|
|
779
|
+
/** What `run` was given: the app's own narrowing (one response, one account). */
|
|
780
|
+
filter?: Filter | undefined;
|
|
781
|
+
}
|
|
782
|
+
interface DeliveryRunnerOptions<Filter = undefined> {
|
|
783
|
+
/** Claims up to `limit` due deliveries, each held (leased) so no other runner sends it meanwhile. */
|
|
784
|
+
claim(request: ClaimRequest<Filter>): Promise<readonly ClaimedDelivery[]>;
|
|
785
|
+
/**
|
|
786
|
+
* Records an outcome. Resolve `false` when the claim was no longer held
|
|
787
|
+
* (its lease ran out and another runner took it): it is counted as stale.
|
|
788
|
+
* A rejection is logged and counted as unrecorded; the lease then runs out
|
|
789
|
+
* and the delivery comes back on its own.
|
|
790
|
+
*/
|
|
791
|
+
record(delivery: ClaimedDelivery, outcome: DeliveryOutcome): Promise<void | boolean>;
|
|
792
|
+
/** Keeps a destination's new state (a sheet's header and last row). Failures are logged, never fatal. */
|
|
793
|
+
saveState?: ((destinationId: string, state: Record<string, Json>) => Promise<void>) | undefined;
|
|
794
|
+
/** The destinations by kind. */
|
|
795
|
+
destinations: Readonly<Record<string, Destination>>;
|
|
796
|
+
now?: (() => number) | undefined;
|
|
797
|
+
/** Safe lines (ids, kinds, short reasons). Default: nothing is logged. */
|
|
798
|
+
log?: ((message: string) => void) | undefined;
|
|
799
|
+
/** Deliveries claimed per run. Default 25. */
|
|
800
|
+
batchSize?: number | undefined;
|
|
801
|
+
/** Counted attempts before a retryable failure is given up. Default `DEFAULT_MAX_ATTEMPTS` (8). */
|
|
802
|
+
maxAttempts?: number | undefined;
|
|
803
|
+
/**
|
|
804
|
+
* Deferrals (Retry-After, no attempt counted) allowed before a further one
|
|
805
|
+
* counts as an attempt and backs off. Default `DEFAULT_MAX_DEFERRALS` (24).
|
|
806
|
+
*/
|
|
807
|
+
maxDeferrals?: number | undefined;
|
|
808
|
+
/** Seconds to wait after a counted failed attempt. Default `defaultBackoff`. */
|
|
809
|
+
backoff?: ((attempt: number) => number) | undefined;
|
|
810
|
+
}
|
|
811
|
+
interface DeliveryReport {
|
|
812
|
+
claimed: number;
|
|
813
|
+
sent: number;
|
|
814
|
+
filtered: number;
|
|
815
|
+
skipped: number;
|
|
816
|
+
/** Retryable failures, backed off by attempt. */
|
|
817
|
+
retrying: number;
|
|
818
|
+
/** Retryable failures the service said when to retry; no attempt used. */
|
|
819
|
+
deferred: number;
|
|
820
|
+
failed: number;
|
|
821
|
+
/** Recorded after the claim was lost (`record` resolved false). */
|
|
822
|
+
stale: number;
|
|
823
|
+
/** `record` threw. */
|
|
824
|
+
unrecorded: number;
|
|
825
|
+
}
|
|
826
|
+
interface RunOptions<Filter> {
|
|
827
|
+
filter?: Filter | undefined;
|
|
828
|
+
/** Overrides `batchSize` for this run. */
|
|
829
|
+
limit?: number | undefined;
|
|
830
|
+
/** Aborts the sends in flight; their outcomes are recorded as retries. */
|
|
831
|
+
signal?: AbortSignal | undefined;
|
|
832
|
+
}
|
|
833
|
+
interface DeliveryRunner<Filter = undefined> {
|
|
834
|
+
/** One claim, its sends and their records. Never rejects for a delivery's failure; a failing `claim` rejects. */
|
|
835
|
+
run(options?: RunOptions<Filter>): Promise<DeliveryReport>;
|
|
836
|
+
}
|
|
837
|
+
declare const DEFAULT_MAX_ATTEMPTS = 8;
|
|
838
|
+
declare const DEFAULT_MAX_DEFERRALS = 24;
|
|
839
|
+
declare const DEFAULT_BATCH_SIZE = 25;
|
|
840
|
+
/** 1 minute, doubling each attempt, at most 6 hours: 1m, 2m, 4m … 6h. */
|
|
841
|
+
declare function defaultBackoff(attempt: number): number;
|
|
842
|
+
/** Turns a destination's result into an outcome for a delivery on its `attempt`. */
|
|
843
|
+
declare function deliveryOutcome(result: DeliveryResult, attempt: number, options?: {
|
|
844
|
+
now?: number | undefined;
|
|
845
|
+
maxAttempts?: number | undefined;
|
|
846
|
+
backoff?: ((attempt: number) => number) | undefined;
|
|
847
|
+
/** Deferrals so far (`ClaimedDelivery.deferrals`). */
|
|
848
|
+
deferrals?: number | undefined;
|
|
849
|
+
maxDeferrals?: number | undefined;
|
|
850
|
+
}): DeliveryOutcome;
|
|
851
|
+
declare function createDeliveryRunner<Filter = undefined>(options: DeliveryRunnerOptions<Filter>): DeliveryRunner<Filter>;
|
|
852
|
+
|
|
853
|
+
export { type Cell, type ClaimRequest, type ClaimedDelivery, DEDUPE_WINDOW, DEFAULT_BATCH_SIZE, DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_DEFERRALS, DEFAULT_SHEET_NAME, DRIVE_FILE_SCOPE, DeliveryError, type DeliveryFailure, type DeliveryItem, type DeliveryOptions, type DeliveryOutcome, type DeliveryReport, type DeliveryResult, type DeliveryRunner, type DeliveryRunnerOptions, type DeliverySubmission, type Destination, type DestinationConfig, type EmailConfig, type EmailContent, type EmailDestinationOptions, type EmailMessage, type EmailProvider, type EmailRenderInput, GOOGLE_TOKEN_URL, type GoogleOAuthClient, type GoogleOAuthTokenOptions, type GoogleServiceAccount, type GoogleTokenOptions, HEADER_SCAN_ROWS, type HookKind, HookUrlError, type HttpDeps, MAX_CONDITION_DEPTH, MAX_HOOK_URL_LENGTH, MAX_RECIPIENTS, type OptionResolver, REQUEST_TIMEOUT_MS, RESEND_API, type RefreshedGoogleToken, type ResendOptions, type ResolveContext, type ResolveHost, type ResponseRow, type RowOptions, type RunOptions, SENSITIVE_MASK, SHEETS_API, SHEETS_SCOPE, SLACK_HOSTS, SLACK_VALUE_LIMIT, type SheetConfig, type SheetState, type SheetsDestinationOptions, type SlackDestinationOptions, type SlackMessageInput, type SlackMessageOptions, type VerifyWebhookInput, type WebCryptoKey, type WebhookConfig, type WebhookDestinationOptions, type WebhookPayload, answerCell, checkHookHost, checkWebhookUrl, clearGoogleOAuthTokenCache, clearGoogleTokenCache, columnLetter, conditionOf, createDeliveryRunner, defaultBackoff, deliveryFailure, deliveryOutcome, deriveSecretKey, emailBody, emailDestination, escapeFormula, escapeHtml, escapeSlack, googleAccessToken, googleOAuthAccessToken, headerSafe, hookHost, httpError, idempotencyKey, isCondition, isPrivateHost, isPublicAddress, isRetryableStatus, isSafeAddress, isSafeFrom, mergeHeader, newWebhookSecret, openSecret, parseHookUrl, passesCondition, refreshGoogleAccessToken, renderEmail, resendProvider, responseRow, retryAfterSeconds, rowFor, sealSecret, secretKeyBytes, sheetsDestination, signJwt, signWebhook, slackDestination, slackMessage, verifyWebhook, visibleInputs, webhookDestination, webhookPayload };
|