@pylonsync/webhooks 0.3.332 → 0.3.334

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.332",
6
+ "version": "0.3.334",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
@@ -12,8 +12,8 @@
12
12
  "check": "tsc -p tsconfig.json --noEmit"
13
13
  },
14
14
  "dependencies": {
15
- "@pylonsync/sdk": "0.3.332",
16
- "@pylonsync/functions": "0.3.332"
15
+ "@pylonsync/sdk": "0.3.334",
16
+ "@pylonsync/functions": "0.3.334"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "bun-types": "*"
package/src/handlers.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { action, mutation, query, v } from "@pylonsync/functions";
2
2
 
3
3
  import { signWebhook } from "./signature";
4
+ import { postWebhookSafely } from "./safe-request";
4
5
  import type { WebhookConfig } from "./types";
5
6
 
6
7
  /**
@@ -137,30 +138,29 @@ export function internalHandlers(
137
138
  secrets: [ep.secret],
138
139
  });
139
140
 
140
- const headers: Record<string, string> = {
141
- "Content-Type": "application/json",
142
- "webhook-id": sig.id,
143
- "webhook-timestamp": String(sig.timestamp),
144
- "webhook-signature": sig.signature,
145
- };
141
+ const headers: Record<string, string> = Object.create(null);
146
142
  if (ep.headers) {
147
143
  try {
148
144
  const extra = JSON.parse(ep.headers) as Record<string, string>;
149
- for (const [k, v] of Object.entries(extra)) headers[k] = v;
145
+ for (const [k, v] of Object.entries(extra)) {
146
+ if (typeof v === "string" && isAllowedCustomHeader(k, v)) {
147
+ headers[k] = v;
148
+ }
149
+ }
150
150
  } catch {
151
151
  /* malformed headers — ignore */
152
152
  }
153
153
  }
154
+ headers["Content-Type"] = "application/json";
155
+ headers["webhook-id"] = sig.id;
156
+ headers["webhook-timestamp"] = String(sig.timestamp);
157
+ headers["webhook-signature"] = sig.signature;
154
158
 
155
159
  let httpStatus = 0;
156
160
  let errorMsg: string | undefined;
157
161
  let ok = false;
158
162
  try {
159
- const res = await fetch(ep.url, {
160
- method: "POST",
161
- headers,
162
- body,
163
- });
163
+ const res = await postWebhookSafely(ep.url, headers, body);
164
164
  httpStatus = res.status;
165
165
  ok = res.ok;
166
166
  if (!ok) errorMsg = `HTTP ${res.status}`;
@@ -213,3 +213,22 @@ export function internalHandlers(
213
213
  }),
214
214
  };
215
215
  }
216
+
217
+ const RESERVED_HEADERS = new Set([
218
+ "connection",
219
+ "content-length",
220
+ "content-type",
221
+ "host",
222
+ "transfer-encoding",
223
+ "webhook-id",
224
+ "webhook-signature",
225
+ "webhook-timestamp",
226
+ ]);
227
+
228
+ function isAllowedCustomHeader(name: string, value: string): boolean {
229
+ return (
230
+ /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) &&
231
+ !RESERVED_HEADERS.has(name.toLowerCase()) &&
232
+ !/[\r\n]/.test(value)
233
+ );
234
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { buildWebhookManifest } from "./manifest";
4
+
5
+ describe("webhook manifest credentials", () => {
6
+ test("always keeps signing secrets and custom headers server-only", () => {
7
+ const endpoint = buildWebhookManifest({}).entities[0];
8
+
9
+ expect(endpoint.fields.secret._def.serverOnly).toBe(true);
10
+ expect(endpoint.fields.headers._def.serverOnly).toBe(true);
11
+ expect(endpoint.fields.secret._def.encrypted).toBeUndefined();
12
+ expect(endpoint.fields.headers._def.encrypted).toBeUndefined();
13
+ });
14
+
15
+ test("supports explicit at-rest encryption when the deployment has a key", () => {
16
+ const endpoint = buildWebhookManifest({
17
+ encryptCredentials: true,
18
+ }).entities[0];
19
+
20
+ expect(endpoint.fields.secret._def.serverOnly).toBe(true);
21
+ expect(endpoint.fields.headers._def.serverOnly).toBe(true);
22
+ expect(endpoint.fields.secret._def.encrypted).toBe(true);
23
+ expect(endpoint.fields.headers._def.encrypted).toBe(true);
24
+ });
25
+ });
package/src/manifest.ts CHANGED
@@ -18,13 +18,19 @@ export function buildWebhookManifest(
18
18
  ): WebhookManifestFragment {
19
19
  const endpointName = cfg.entities?.endpoint ?? "WebhookEndpoint";
20
20
  const attemptName = cfg.entities?.attempt ?? "WebhookAttempt";
21
+ const secretField = cfg.encryptCredentials
22
+ ? field.string().serverOnly().encrypted()
23
+ : field.string().serverOnly();
24
+ const headersField = cfg.encryptCredentials
25
+ ? field.string().serverOnly().encrypted().optional()
26
+ : field.string().serverOnly().optional();
21
27
 
22
28
  const Endpoint = entity(endpointName, {
23
29
  applicationId: field.string(),
24
30
  url: field.string(),
25
- secret: field.string(),
31
+ secret: secretField,
26
32
  eventTypes: field.string(), // JSON array
27
- headers: field.string().optional(), // JSON
33
+ headers: headersField, // JSON
28
34
  disabled: field.boolean().optional(),
29
35
  createdAt: field.string(),
30
36
  });
@@ -0,0 +1,124 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ assertPublicDestination,
5
+ isBlockedIp,
6
+ postWebhookSafely,
7
+ } from "./safe-request";
8
+
9
+ const publicDns = async () => [{ address: "93.184.216.34", family: 4 }];
10
+
11
+ describe("webhook egress guard", () => {
12
+ test("blocks non-HTTPS URLs, embedded credentials, and private DNS results", async () => {
13
+ await expect(
14
+ assertPublicDestination(new URL("http://example.com/hook"), publicDns),
15
+ ).rejects.toThrow("must use HTTPS");
16
+ await expect(
17
+ assertPublicDestination(new URL("https://user:pass@example.com/hook"), publicDns),
18
+ ).rejects.toThrow("credentials are not allowed");
19
+ await expect(
20
+ assertPublicDestination(new URL("https://example.com/hook"), async () => [
21
+ { address: "169.254.169.254", family: 4 },
22
+ ]),
23
+ ).rejects.toThrow("non-public address");
24
+ });
25
+
26
+ test("blocks private, link-local, reserved, and mapped addresses", () => {
27
+ for (const address of [
28
+ "127.0.0.1",
29
+ "10.0.0.1",
30
+ "172.16.0.1",
31
+ "192.168.1.1",
32
+ "169.254.169.254",
33
+ "100.64.0.1",
34
+ "0.1.2.3",
35
+ "224.0.0.1",
36
+ "::1",
37
+ "fe80::1",
38
+ "fd12::1",
39
+ "::192.168.1.1",
40
+ "::ffff:169.254.169.254",
41
+ "2001:db8::1",
42
+ ]) {
43
+ expect(isBlockedIp(address)).toBe(true);
44
+ }
45
+ expect(isBlockedIp("8.8.8.8")).toBe(false);
46
+ expect(isBlockedIp("2606:4700:4700::1111")).toBe(false);
47
+ });
48
+
49
+ test("revalidates same-origin redirect hops", async () => {
50
+ const seen: string[] = [];
51
+ let resolutions = 0;
52
+ const result = await postWebhookSafely(
53
+ "https://example.com/first",
54
+ {},
55
+ "{}",
56
+ {
57
+ resolve: async () => {
58
+ resolutions++;
59
+ return publicDns();
60
+ },
61
+ requestOnce: async (url) => {
62
+ seen.push(url.pathname);
63
+ return seen.length === 1
64
+ ? { status: 307, location: "/second" }
65
+ : { status: 204 };
66
+ },
67
+ },
68
+ );
69
+
70
+ expect(result).toEqual({ status: 204, ok: true });
71
+ expect(seen).toEqual(["/first", "/second"]);
72
+ expect(resolutions).toBe(2);
73
+ });
74
+
75
+ test("rejects cross-origin and method-changing redirects", async () => {
76
+ await expect(
77
+ postWebhookSafely("https://example.com/hook", {}, "{}", {
78
+ resolve: publicDns,
79
+ requestOnce: async () => ({
80
+ status: 307,
81
+ location: "https://other.example/hook",
82
+ }),
83
+ }),
84
+ ).rejects.toThrow("another origin");
85
+
86
+ await expect(
87
+ postWebhookSafely("https://example.com/hook", {}, "{}", {
88
+ resolve: publicDns,
89
+ requestOnce: async () => ({ status: 302, location: "/other" }),
90
+ }),
91
+ ).rejects.toThrow("unsafe redirect");
92
+ });
93
+
94
+ test("caps redirect chains", async () => {
95
+ await expect(
96
+ postWebhookSafely("https://example.com/hook", {}, "{}", {
97
+ resolve: publicDns,
98
+ requestOnce: async () => ({ status: 307, location: "/again" }),
99
+ }),
100
+ ).rejects.toThrow("redirect limit");
101
+ });
102
+
103
+ test("aborts requests at the delivery deadline", async () => {
104
+ await expect(
105
+ postWebhookSafely("https://example.com/hook", {}, "{}", {
106
+ resolve: publicDns,
107
+ timeoutMs: 5,
108
+ requestOnce: async (_url, { signal }) =>
109
+ new Promise((_resolve, reject) => {
110
+ signal.addEventListener("abort", () => reject(new Error("aborted")));
111
+ }),
112
+ }),
113
+ ).rejects.toThrow("timed out");
114
+ });
115
+
116
+ test("applies the delivery deadline while resolving DNS", async () => {
117
+ await expect(
118
+ postWebhookSafely("https://example.com/hook", {}, "{}", {
119
+ resolve: async () => new Promise(() => {}),
120
+ timeoutMs: 5,
121
+ }),
122
+ ).rejects.toThrow("timed out");
123
+ });
124
+ });
@@ -0,0 +1,317 @@
1
+ import { lookup } from "node:dns/promises";
2
+ import { request } from "node:https";
3
+ import { isIP } from "node:net";
4
+
5
+ const DEFAULT_TIMEOUT_MS = 10_000;
6
+ const MAX_REDIRECTS = 3;
7
+
8
+ interface ResolvedAddress {
9
+ address: string;
10
+ family: number;
11
+ }
12
+
13
+ interface ResponseHead {
14
+ status: number;
15
+ location?: string;
16
+ }
17
+
18
+ export interface SafeRequestDependencies {
19
+ resolve?: (hostname: string) => Promise<ResolvedAddress[]>;
20
+ requestOnce?: (
21
+ url: URL,
22
+ init: { headers: Record<string, string>; body: string; signal: AbortSignal },
23
+ ) => Promise<ResponseHead>;
24
+ timeoutMs?: number;
25
+ }
26
+
27
+ /**
28
+ * POST a webhook without allowing the destination to reach local services.
29
+ * Redirects stay on the original origin so credentials cannot be forwarded to
30
+ * a second host. Only 307/308 are followed because they preserve the signed
31
+ * POST body.
32
+ */
33
+ export async function postWebhookSafely(
34
+ url: string,
35
+ headers: Record<string, string>,
36
+ body: string,
37
+ dependencies: SafeRequestDependencies = {},
38
+ ): Promise<{ status: number; ok: boolean }> {
39
+ const resolve = dependencies.resolve ?? resolveHostname;
40
+ const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS;
41
+ const controller = new AbortController();
42
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
43
+
44
+ try {
45
+ let target = parseDestination(url);
46
+ const originalOrigin = target.origin;
47
+ let redirects = 0;
48
+
49
+ for (;;) {
50
+ await assertPublicDestination(target, resolve, controller.signal);
51
+ const response = await (dependencies.requestOnce ?? requestOnce)(target, {
52
+ headers,
53
+ body,
54
+ signal: controller.signal,
55
+ });
56
+
57
+ if (![301, 302, 303, 307, 308].includes(response.status)) {
58
+ return {
59
+ status: response.status,
60
+ ok: response.status >= 200 && response.status < 300,
61
+ };
62
+ }
63
+ if (response.status !== 307 && response.status !== 308) {
64
+ throw new Error("Webhook destination returned an unsafe redirect");
65
+ }
66
+ if (!response.location) {
67
+ throw new Error("Webhook destination returned an invalid redirect");
68
+ }
69
+ if (redirects >= MAX_REDIRECTS) {
70
+ throw new Error("Webhook destination exceeded the redirect limit");
71
+ }
72
+
73
+ const next = parseDestination(new URL(response.location, target).toString());
74
+ if (next.origin !== originalOrigin) {
75
+ throw new Error("Webhook destination redirected to another origin");
76
+ }
77
+ target = next;
78
+ redirects++;
79
+ }
80
+ } catch (error) {
81
+ if (controller.signal.aborted) {
82
+ throw new Error("Webhook delivery timed out");
83
+ }
84
+ if (error instanceof SafeDestinationError) throw new Error(error.message);
85
+ if (error instanceof Error && error.message.startsWith("Webhook destination")) {
86
+ throw error;
87
+ }
88
+ throw new Error("Webhook delivery request failed");
89
+ } finally {
90
+ clearTimeout(timeout);
91
+ }
92
+ }
93
+
94
+ export async function assertPublicDestination(
95
+ target: URL,
96
+ resolve: (hostname: string) => Promise<ResolvedAddress[]> = resolveHostname,
97
+ signal?: AbortSignal,
98
+ ): Promise<void> {
99
+ if (target.protocol !== "https:") {
100
+ throw new SafeDestinationError("Webhook destinations must use HTTPS");
101
+ }
102
+ if (target.username || target.password) {
103
+ throw new SafeDestinationError("Webhook destination credentials are not allowed");
104
+ }
105
+
106
+ let addresses: ResolvedAddress[];
107
+ try {
108
+ const resolution = resolve(stripIpv6Brackets(target.hostname));
109
+ addresses = signal ? await rejectOnAbort(resolution, signal) : await resolution;
110
+ } catch {
111
+ throw new SafeDestinationError("Webhook destination could not be resolved");
112
+ }
113
+ if (addresses.length === 0) {
114
+ throw new SafeDestinationError("Webhook destination could not be resolved");
115
+ }
116
+ if (addresses.some(({ address }) => isBlockedIp(address))) {
117
+ throw new SafeDestinationError("Webhook destination resolves to a non-public address");
118
+ }
119
+ }
120
+
121
+ function rejectOnAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
122
+ if (signal.aborted) return Promise.reject(new Error("aborted"));
123
+ return new Promise((resolve, reject) => {
124
+ const onAbort = () => reject(new Error("aborted"));
125
+ signal.addEventListener("abort", onAbort, { once: true });
126
+ promise.then(
127
+ (value) => {
128
+ signal.removeEventListener("abort", onAbort);
129
+ resolve(value);
130
+ },
131
+ (error) => {
132
+ signal.removeEventListener("abort", onAbort);
133
+ reject(error);
134
+ },
135
+ );
136
+ });
137
+ }
138
+
139
+ export function isBlockedIp(address: string): boolean {
140
+ const family = isIP(address);
141
+ if (family === 4) return isBlockedIpv4(address);
142
+ if (family !== 6) return true;
143
+
144
+ const bytes = parseIpv6(address);
145
+ if (!bytes) return true;
146
+ if (bytes.every((byte) => byte === 0)) return true; // unspecified
147
+ if (bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1) return true;
148
+ if ((bytes[0] & 0xfe) === 0xfc) return true; // unique-local fc00::/7
149
+ if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80) return true; // link-local fe80::/10
150
+ if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0xc0) return true; // deprecated site-local fec0::/10
151
+ if (bytes[0] === 0xff) return true; // multicast
152
+ if (bytes[0] === 0x20 && bytes[1] === 0x01 && bytes[2] === 0x0d && bytes[3] === 0xb8) {
153
+ return true; // documentation 2001:db8::/32
154
+ }
155
+ if (bytes.slice(0, 12).every((byte) => byte === 0)) {
156
+ return isBlockedIpv4(bytes.slice(12).join(".")); // IPv4-compatible
157
+ }
158
+
159
+ const mappedPrefix = bytes.slice(0, 10).every((byte) => byte === 0);
160
+ if (mappedPrefix && bytes[10] === 0xff && bytes[11] === 0xff) {
161
+ return isBlockedIpv4(bytes.slice(12).join("."));
162
+ }
163
+ return false;
164
+ }
165
+
166
+ async function resolveHostname(hostname: string): Promise<ResolvedAddress[]> {
167
+ if (isIP(hostname)) {
168
+ return [{ address: hostname, family: isIP(hostname) }];
169
+ }
170
+ return lookup(hostname, { all: true, verbatim: true });
171
+ }
172
+
173
+ function parseDestination(value: string): URL {
174
+ let target: URL;
175
+ try {
176
+ target = new URL(value);
177
+ } catch {
178
+ throw new SafeDestinationError("Webhook destination URL is invalid");
179
+ }
180
+ if (target.protocol !== "https:") {
181
+ throw new SafeDestinationError("Webhook destinations must use HTTPS");
182
+ }
183
+ if (target.username || target.password) {
184
+ throw new SafeDestinationError("Webhook destination credentials are not allowed");
185
+ }
186
+ return target;
187
+ }
188
+
189
+ function requestOnce(
190
+ target: URL,
191
+ init: { headers: Record<string, string>; body: string; signal: AbortSignal },
192
+ ): Promise<ResponseHead> {
193
+ return new Promise((resolve, reject) => {
194
+ const req = request(
195
+ target,
196
+ {
197
+ method: "POST",
198
+ headers: init.headers,
199
+ signal: init.signal,
200
+ lookup(hostname, _options, callback) {
201
+ resolveHostname(hostname)
202
+ .then((addresses) => {
203
+ if (
204
+ addresses.length === 0 ||
205
+ addresses.some(({ address }) => isBlockedIp(address))
206
+ ) {
207
+ callback(new Error("unsafe destination"), "", 4);
208
+ return;
209
+ }
210
+ const selected = addresses[0];
211
+ callback(null, selected.address, selected.family);
212
+ })
213
+ .catch(() =>
214
+ callback(new Error("destination lookup failed"), "", 4),
215
+ );
216
+ },
217
+ },
218
+ (response) => {
219
+ const head = {
220
+ status: response.statusCode ?? 0,
221
+ location: Array.isArray(response.headers.location)
222
+ ? response.headers.location[0]
223
+ : response.headers.location,
224
+ };
225
+ // Delivery semantics depend only on the response head. Close the
226
+ // body immediately so an endpoint cannot retain a worker socket by
227
+ // streaming an endless response after returning a success status.
228
+ response.destroy();
229
+ resolve(head);
230
+ },
231
+ );
232
+ req.on("error", reject);
233
+ req.end(init.body);
234
+ });
235
+ }
236
+
237
+ function isBlockedIpv4(address: string): boolean {
238
+ const octets = address.split(".").map(Number);
239
+ if (
240
+ octets.length !== 4 ||
241
+ octets.some(
242
+ (part) => !Number.isInteger(part) || part < 0 || part > 255,
243
+ )
244
+ ) {
245
+ return true;
246
+ }
247
+ const [a, b, c] = octets;
248
+ return (
249
+ a === 0 ||
250
+ a === 10 ||
251
+ a === 127 ||
252
+ (a === 100 && b >= 64 && b <= 127) ||
253
+ (a === 169 && b === 254) ||
254
+ (a === 172 && b >= 16 && b <= 31) ||
255
+ (a === 192 && b === 0 && c === 0) ||
256
+ (a === 192 && b === 168) ||
257
+ (a === 192 && b === 0 && c === 2) ||
258
+ (a === 198 && (b === 18 || b === 19)) ||
259
+ (a === 198 && b === 51 && c === 100) ||
260
+ (a === 203 && b === 0 && c === 113) ||
261
+ a >= 224
262
+ );
263
+ }
264
+
265
+ function parseIpv6(address: string): number[] | null {
266
+ const normalized = stripIpv6Brackets(address).split("%")[0];
267
+ const halves = normalized.split("::");
268
+ if (halves.length > 2) return null;
269
+ const left = parseIpv6Half(halves[0]);
270
+ const right = halves.length === 2 ? parseIpv6Half(halves[1]) : [];
271
+ if (!left || !right) return null;
272
+ const missing = 8 - left.length - right.length;
273
+ if (
274
+ (halves.length === 1 && missing !== 0) ||
275
+ (halves.length === 2 && missing < 1)
276
+ ) {
277
+ return null;
278
+ }
279
+ const segments = [...left, ...Array(missing).fill(0), ...right];
280
+ if (segments.length !== 8) return null;
281
+ return segments.flatMap((segment) => [segment >> 8, segment & 0xff]);
282
+ }
283
+
284
+ function parseIpv6Half(value: string): number[] | null {
285
+ if (!value) return [];
286
+ const parts = value.split(":");
287
+ const segments: number[] = [];
288
+ for (const part of parts) {
289
+ if (part.includes(".")) {
290
+ const octets = part.split(".").map(Number);
291
+ if (
292
+ octets.length !== 4 ||
293
+ octets.some(
294
+ (byte) => !Number.isInteger(byte) || byte < 0 || byte > 255,
295
+ )
296
+ ) {
297
+ return null;
298
+ }
299
+ segments.push(
300
+ (octets[0] << 8) | octets[1],
301
+ (octets[2] << 8) | octets[3],
302
+ );
303
+ } else {
304
+ if (!/^[0-9a-f]{1,4}$/i.test(part)) return null;
305
+ segments.push(Number.parseInt(part, 16));
306
+ }
307
+ }
308
+ return segments;
309
+ }
310
+
311
+ function stripIpv6Brackets(hostname: string): string {
312
+ return hostname.startsWith("[") && hostname.endsWith("]")
313
+ ? hostname.slice(1, -1)
314
+ : hostname;
315
+ }
316
+
317
+ class SafeDestinationError extends Error {}
package/src/types.ts CHANGED
@@ -31,15 +31,15 @@ export interface WebhookEndpoint {
31
31
  /** Logical bucket — usually the customer/org id. */
32
32
  applicationId: string;
33
33
  url: string;
34
- /** Hex-encoded HMAC secret. Plaintext on the row by design — only
35
- * the customer's app reads + uses it, and Pylon's at-rest crypto
36
- * is the secret-encryption layer apps wire when they want it. */
34
+ /** Hex-encoded HMAC secret. The plugin manifest always marks this field
35
+ * server-only and optionally encrypts it at rest. */
37
36
  secret: string;
38
37
  /** Subset of event types this endpoint subscribes to. Empty = all. */
39
38
  eventTypes: string[];
40
39
  /**
41
40
  * Headers to attach on every delivery. Useful for routing /
42
- * authentication beyond the HMAC signature.
41
+ * authentication beyond the HMAC signature. The plugin manifest always
42
+ * marks this field server-only and optionally encrypts it at rest.
43
43
  */
44
44
  headers?: Record<string, string>;
45
45
  /** Disabled endpoints don't receive deliveries. */
@@ -52,6 +52,14 @@ export interface WebhookConfig {
52
52
  endpoint?: string;
53
53
  attempt?: string;
54
54
  };
55
+ /**
56
+ * Encrypt endpoint secrets and custom headers at rest. Requires a 32-byte
57
+ * `PYLON_ENCRYPTION_KEY`; endpoint writes fail with
58
+ * `ENCRYPTION_NOT_CONFIGURED` when the key is absent. Defaults to `false`
59
+ * so enabling the plugin does not break existing deployments. Credentials
60
+ * remain server-only regardless of this setting.
61
+ */
62
+ encryptCredentials?: boolean;
55
63
  /**
56
64
  * Retry schedule (seconds offset from initial attempt). Default
57
65
  * matches Svix's published schedule. After exhausting the