@keystrokehq/resend 0.0.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 +184 -0
- package/dist/_official/index.d.mts +2 -0
- package/dist/_official/index.mjs +3 -0
- package/dist/_runtime/index.d.mts +1 -0
- package/dist/_runtime/index.mjs +1 -0
- package/dist/api-keys.d.mts +67 -0
- package/dist/api-keys.mjs +90 -0
- package/dist/broadcasts.d.mts +238 -0
- package/dist/broadcasts.mjs +178 -0
- package/dist/client.d.mts +27 -0
- package/dist/client.mjs +40 -0
- package/dist/connection.d.mts +2 -0
- package/dist/connection.mjs +3 -0
- package/dist/contact-properties.d.mts +139 -0
- package/dist/contact-properties.mjs +115 -0
- package/dist/contacts.d.mts +218 -0
- package/dist/contacts.mjs +225 -0
- package/dist/domains.d.mts +293 -0
- package/dist/domains.mjs +160 -0
- package/dist/emails-receiving.d.mts +177 -0
- package/dist/emails-receiving.mjs +114 -0
- package/dist/emails.d.mts +307 -0
- package/dist/emails.mjs +241 -0
- package/dist/events.d.mts +571 -0
- package/dist/events.mjs +178 -0
- package/dist/factory-B3VyPRsL.mjs +8 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +1 -0
- package/dist/integration-BR1nTAnU.mjs +28 -0
- package/dist/integration-ClH0F7x-.d.mts +49 -0
- package/dist/logs.d.mts +71 -0
- package/dist/logs.mjs +61 -0
- package/dist/schemas.d.mts +77 -0
- package/dist/schemas.mjs +68 -0
- package/dist/segments.d.mts +112 -0
- package/dist/segments.mjs +120 -0
- package/dist/templates.d.mts +193 -0
- package/dist/templates.mjs +151 -0
- package/dist/topics.d.mts +125 -0
- package/dist/topics.mjs +113 -0
- package/dist/triggers.d.mts +68 -0
- package/dist/triggers.mjs +141 -0
- package/dist/verification.d.mts +49 -0
- package/dist/verification.mjs +139 -0
- package/dist/webhooks.d.mts +285 -0
- package/dist/webhooks.mjs +124 -0
- package/package.json +137 -0
package/dist/topics.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createResendClient } from "./client.mjs";
|
|
2
|
+
import { paginationQuerySchema, resendListEnvelope } from "./schemas.mjs";
|
|
3
|
+
import { t as resendOperation } from "./factory-B3VyPRsL.mjs";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
//#region src/topics.ts
|
|
7
|
+
/**
|
|
8
|
+
* resend/topics.ts
|
|
9
|
+
*
|
|
10
|
+
* Operations backing the Resend `/topics` endpoints.
|
|
11
|
+
* PLAN.md § 6.9: 5 operations.
|
|
12
|
+
*
|
|
13
|
+
* Topics model recipient subscription preferences. A broadcast sent
|
|
14
|
+
* with `topic_id` honours each recipient's opt-in/opt-out state.
|
|
15
|
+
*/
|
|
16
|
+
const topicDefaultSubscriptionSchema = z.enum(["opt_in", "opt_out"]);
|
|
17
|
+
const topicSchema = z.object({
|
|
18
|
+
id: z.string(),
|
|
19
|
+
name: z.string().optional(),
|
|
20
|
+
description: z.string().optional().nullable(),
|
|
21
|
+
default_subscription: topicDefaultSubscriptionSchema.optional(),
|
|
22
|
+
created_at: z.string().optional(),
|
|
23
|
+
updated_at: z.string().optional()
|
|
24
|
+
}).passthrough();
|
|
25
|
+
const listTopicsResponseSchema = resendListEnvelope(topicSchema);
|
|
26
|
+
const createTopic = resendOperation({
|
|
27
|
+
id: "create_resend_topic",
|
|
28
|
+
name: "Create Resend Topic",
|
|
29
|
+
description: "Create a new subscription topic.",
|
|
30
|
+
input: z.object({
|
|
31
|
+
name: z.string().min(1),
|
|
32
|
+
default_subscription: topicDefaultSubscriptionSchema,
|
|
33
|
+
description: z.string().optional()
|
|
34
|
+
}),
|
|
35
|
+
output: topicSchema,
|
|
36
|
+
needsApproval: true,
|
|
37
|
+
run: async (input, credentials) => {
|
|
38
|
+
return createResendClient(credentials).request({
|
|
39
|
+
method: "POST",
|
|
40
|
+
path: "/topics",
|
|
41
|
+
body: input
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
const listTopics = resendOperation({
|
|
46
|
+
id: "list_resend_topics",
|
|
47
|
+
name: "List Resend Topics",
|
|
48
|
+
description: "Retrieve a cursor-paginated list of topics.",
|
|
49
|
+
input: paginationQuerySchema,
|
|
50
|
+
output: listTopicsResponseSchema,
|
|
51
|
+
run: async (input, credentials) => {
|
|
52
|
+
return createResendClient(credentials).request({
|
|
53
|
+
method: "GET",
|
|
54
|
+
path: "/topics",
|
|
55
|
+
query: input
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
const retrieveTopic = resendOperation({
|
|
60
|
+
id: "retrieve_resend_topic",
|
|
61
|
+
name: "Retrieve Resend Topic",
|
|
62
|
+
description: "Retrieve a single topic by id.",
|
|
63
|
+
input: z.object({ id: z.string().min(1) }),
|
|
64
|
+
output: topicSchema,
|
|
65
|
+
run: async (input, credentials) => {
|
|
66
|
+
return createResendClient(credentials).request({
|
|
67
|
+
method: "GET",
|
|
68
|
+
path: `/topics/${encodeURIComponent(input.id)}`
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
const updateTopic = resendOperation({
|
|
73
|
+
id: "update_resend_topic",
|
|
74
|
+
name: "Update Resend Topic",
|
|
75
|
+
description: "Update an existing topic.",
|
|
76
|
+
input: z.object({
|
|
77
|
+
id: z.string().min(1),
|
|
78
|
+
name: z.string().optional(),
|
|
79
|
+
description: z.string().optional(),
|
|
80
|
+
default_subscription: topicDefaultSubscriptionSchema.optional()
|
|
81
|
+
}),
|
|
82
|
+
output: topicSchema,
|
|
83
|
+
needsApproval: true,
|
|
84
|
+
run: async (input, credentials) => {
|
|
85
|
+
const { id, ...body } = input;
|
|
86
|
+
return createResendClient(credentials).request({
|
|
87
|
+
method: "PATCH",
|
|
88
|
+
path: `/topics/${encodeURIComponent(id)}`,
|
|
89
|
+
body
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
const deleteTopic = resendOperation({
|
|
94
|
+
id: "delete_resend_topic",
|
|
95
|
+
name: "Delete Resend Topic",
|
|
96
|
+
description: "Delete a topic.",
|
|
97
|
+
input: z.object({ id: z.string().min(1) }),
|
|
98
|
+
output: z.object({
|
|
99
|
+
object: z.literal("topic").optional(),
|
|
100
|
+
id: z.string(),
|
|
101
|
+
deleted: z.boolean().optional()
|
|
102
|
+
}).passthrough(),
|
|
103
|
+
needsApproval: true,
|
|
104
|
+
run: async (input, credentials) => {
|
|
105
|
+
return createResendClient(credentials).request({
|
|
106
|
+
method: "DELETE",
|
|
107
|
+
path: `/topics/${encodeURIComponent(input.id)}`
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
//#endregion
|
|
113
|
+
export { createTopic, deleteTopic, listTopics, retrieveTopic, updateTopic };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { n as resend, t as ResendCredentials } from "./integration-ClH0F7x-.mjs";
|
|
2
|
+
import { ResendGenericWebhookEvent, ResendWebhookEventName } from "./events.mjs";
|
|
3
|
+
import { IntegrationTriggerBindingOptions } from "@keystrokehq/integration-authoring";
|
|
4
|
+
import { BoundTrigger, WebhookRequest } from "@keystrokehq/core";
|
|
5
|
+
import { WebhookTriggerManifest } from "@keystrokehq/core/trigger";
|
|
6
|
+
|
|
7
|
+
//#region src/triggers.d.ts
|
|
8
|
+
type ResendTriggerCredentialSets = readonly [typeof resend];
|
|
9
|
+
type ResendWebhookBoundTrigger<TOutput = ResendGenericWebhookEvent> = BoundTrigger<ResendGenericWebhookEvent, ResendTriggerCredentialSets, WebhookTriggerManifest, TOutput, WebhookRequest>;
|
|
10
|
+
type ResendWebhookBindingOptions<TOutput = ResendGenericWebhookEvent> = IntegrationTriggerBindingOptions<ResendGenericWebhookEvent, TOutput, WebhookRequest>;
|
|
11
|
+
type ResendWebhookBindingSurface = <TOutput = ResendGenericWebhookEvent>(options?: ResendWebhookBindingOptions<TOutput>) => ResendWebhookBoundTrigger<TOutput>;
|
|
12
|
+
interface ResendTriggersNamespace {
|
|
13
|
+
readonly emailSent: ResendWebhookBindingSurface;
|
|
14
|
+
readonly emailDelivered: ResendWebhookBindingSurface;
|
|
15
|
+
readonly emailDeliveryDelayed: ResendWebhookBindingSurface;
|
|
16
|
+
readonly emailBounced: ResendWebhookBindingSurface;
|
|
17
|
+
readonly emailComplained: ResendWebhookBindingSurface;
|
|
18
|
+
/** MPP-opens are filtered by default; pass your own `filter` to opt in. */
|
|
19
|
+
readonly emailOpened: ResendWebhookBindingSurface;
|
|
20
|
+
readonly emailClicked: ResendWebhookBindingSurface;
|
|
21
|
+
readonly emailFailed: ResendWebhookBindingSurface;
|
|
22
|
+
readonly emailReceived: ResendWebhookBindingSurface;
|
|
23
|
+
readonly emailScheduled: ResendWebhookBindingSurface;
|
|
24
|
+
readonly emailSuppressed: ResendWebhookBindingSurface;
|
|
25
|
+
readonly domainCreated: ResendWebhookBindingSurface;
|
|
26
|
+
readonly domainUpdated: ResendWebhookBindingSurface;
|
|
27
|
+
readonly domainDeleted: ResendWebhookBindingSurface;
|
|
28
|
+
readonly contactCreated: ResendWebhookBindingSurface;
|
|
29
|
+
readonly contactUpdated: ResendWebhookBindingSurface;
|
|
30
|
+
readonly contactDeleted: ResendWebhookBindingSurface;
|
|
31
|
+
}
|
|
32
|
+
declare const triggers: ResendTriggersNamespace;
|
|
33
|
+
/**
|
|
34
|
+
* Idempotently register (or update) a Resend webhook endpoint and
|
|
35
|
+
* return its id + signing secret. Workflows that bind triggers
|
|
36
|
+
* typically call this once to persist the `whsec_…` into the
|
|
37
|
+
* connection credentials before a trigger can verify inbound payloads.
|
|
38
|
+
*
|
|
39
|
+
* Behavior:
|
|
40
|
+
*
|
|
41
|
+
* 1. Look up existing webhooks via `GET /webhooks`.
|
|
42
|
+
* 2. If one already points to `endpointUrl`, reuse it. If its event
|
|
43
|
+
* list drifted, `PATCH` to reconcile.
|
|
44
|
+
* 3. Otherwise `POST /webhooks` with the requested events.
|
|
45
|
+
* 4. Return `{ webhookId, signingSecret, endpointUrl, events }`.
|
|
46
|
+
*
|
|
47
|
+
* Does NOT mutate the vault. Callers are responsible for writing
|
|
48
|
+
* the returned `signingSecret` into
|
|
49
|
+
* `credentials.RESEND_WEBHOOK_SIGNING_SECRETS[webhookId]`.
|
|
50
|
+
*/
|
|
51
|
+
interface EnsureResendWebhookParams {
|
|
52
|
+
readonly credentials: ResendCredentials;
|
|
53
|
+
readonly endpointUrl: string;
|
|
54
|
+
/** Events to subscribe to. Pass `'all'` to subscribe to every event. */
|
|
55
|
+
readonly events: 'all' | readonly ResendWebhookEventName[];
|
|
56
|
+
readonly status?: 'enabled' | 'disabled';
|
|
57
|
+
}
|
|
58
|
+
interface EnsureResendWebhookResult {
|
|
59
|
+
readonly webhookId: string;
|
|
60
|
+
readonly signingSecret: string;
|
|
61
|
+
readonly endpointUrl: string;
|
|
62
|
+
readonly events: readonly ResendWebhookEventName[] | 'all';
|
|
63
|
+
readonly created: boolean;
|
|
64
|
+
readonly updated: boolean;
|
|
65
|
+
}
|
|
66
|
+
declare function ensureResendWebhook(params: EnsureResendWebhookParams): Promise<EnsureResendWebhookResult>;
|
|
67
|
+
//#endregion
|
|
68
|
+
export { EnsureResendWebhookParams, EnsureResendWebhookResult, ResendTriggersNamespace, ensureResendWebhook, triggers };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { t as resend } from "./integration-BR1nTAnU.mjs";
|
|
2
|
+
import { createResendClient } from "./client.mjs";
|
|
3
|
+
import { RESEND_WEBHOOK_EVENT_NAMES, resendGenericWebhookEventSchema } from "./events.mjs";
|
|
4
|
+
import { verifyResendWebhookRequest } from "./verification.mjs";
|
|
5
|
+
import { createWebhookTriggerBindingFactory } from "@keystrokehq/integration-authoring";
|
|
6
|
+
|
|
7
|
+
//#region src/triggers.ts
|
|
8
|
+
function resolveSigningSecret(credentials) {
|
|
9
|
+
const map = credentials.RESEND_WEBHOOK_SIGNING_SECRETS;
|
|
10
|
+
if (map === void 0) return [];
|
|
11
|
+
return Object.values(map);
|
|
12
|
+
}
|
|
13
|
+
function idempotencyFromPayload(payload) {
|
|
14
|
+
const data = payload.data;
|
|
15
|
+
for (const key of ["email_id", "id"]) {
|
|
16
|
+
const value = data[key];
|
|
17
|
+
if (typeof value === "string" && value.length > 0) return `${payload.type}:${value}`;
|
|
18
|
+
}
|
|
19
|
+
return `${payload.type}:unknown`;
|
|
20
|
+
}
|
|
21
|
+
function isAppleMppOpen(payload) {
|
|
22
|
+
if (payload.type !== "email.opened") return false;
|
|
23
|
+
return payload.data.is_machine_opened === true;
|
|
24
|
+
}
|
|
25
|
+
function defaultDefinitionFilter(eventType) {
|
|
26
|
+
if (eventType === "email.opened") return (payload) => payload.type === eventType && !isAppleMppOpen(payload);
|
|
27
|
+
return (payload) => payload.type === eventType;
|
|
28
|
+
}
|
|
29
|
+
function buildBinding(eventType) {
|
|
30
|
+
return createWebhookTriggerBindingFactory({
|
|
31
|
+
defaultName: `Resend ${eventType}`,
|
|
32
|
+
defaultDescription: `Fires when Resend emits the ${eventType} webhook event.`,
|
|
33
|
+
path: "/resend",
|
|
34
|
+
method: "POST",
|
|
35
|
+
payload: resendGenericWebhookEventSchema,
|
|
36
|
+
credentialSets: [resend],
|
|
37
|
+
verify: (request, ctx) => {
|
|
38
|
+
const credentials = ctx.credentials.resend;
|
|
39
|
+
const secrets = resolveSigningSecret(credentials);
|
|
40
|
+
if (secrets.length === 0) throw new Error("Resend webhook signing secret is not configured. Populate `RESEND_WEBHOOK_SIGNING_SECRETS` on the connection (e.g. via `ensureResendWebhook`) before binding webhook triggers.");
|
|
41
|
+
for (const signingSecret of secrets) if (verifyResendWebhookRequest({
|
|
42
|
+
rawBody: request.rawBody,
|
|
43
|
+
headers: request.headers,
|
|
44
|
+
signingSecret
|
|
45
|
+
}).ok) return;
|
|
46
|
+
throw new Error("Invalid Resend webhook signature.");
|
|
47
|
+
},
|
|
48
|
+
definitionFilter: defaultDefinitionFilter(eventType),
|
|
49
|
+
idempotencyKey: (payload) => idempotencyFromPayload(payload),
|
|
50
|
+
response: { successStatus: 200 }
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const bindingTable = Object.fromEntries(RESEND_WEBHOOK_EVENT_NAMES.map((eventName) => [eventName, buildBinding(eventName)]));
|
|
54
|
+
function bind(eventType) {
|
|
55
|
+
return (options) => bindingTable[eventType](options);
|
|
56
|
+
}
|
|
57
|
+
const triggers = Object.freeze({
|
|
58
|
+
emailSent: bind("email.sent"),
|
|
59
|
+
emailDelivered: bind("email.delivered"),
|
|
60
|
+
emailDeliveryDelayed: bind("email.delivery_delayed"),
|
|
61
|
+
emailBounced: bind("email.bounced"),
|
|
62
|
+
emailComplained: bind("email.complained"),
|
|
63
|
+
emailOpened: bind("email.opened"),
|
|
64
|
+
emailClicked: bind("email.clicked"),
|
|
65
|
+
emailFailed: bind("email.failed"),
|
|
66
|
+
emailReceived: bind("email.received"),
|
|
67
|
+
emailScheduled: bind("email.scheduled"),
|
|
68
|
+
emailSuppressed: bind("email.suppressed"),
|
|
69
|
+
domainCreated: bind("domain.created"),
|
|
70
|
+
domainUpdated: bind("domain.updated"),
|
|
71
|
+
domainDeleted: bind("domain.deleted"),
|
|
72
|
+
contactCreated: bind("contact.created"),
|
|
73
|
+
contactUpdated: bind("contact.updated"),
|
|
74
|
+
contactDeleted: bind("contact.deleted")
|
|
75
|
+
});
|
|
76
|
+
async function ensureResendWebhook(params) {
|
|
77
|
+
const client = createResendClient(params.credentials);
|
|
78
|
+
const existing = (await client.request({
|
|
79
|
+
method: "GET",
|
|
80
|
+
path: "/webhooks"
|
|
81
|
+
})).data.find((w) => w.endpoint_url === params.endpointUrl);
|
|
82
|
+
if (existing) {
|
|
83
|
+
const existingEvents = new Set(existing.events ?? []);
|
|
84
|
+
const desiredEvents = params.events === "all" ? /* @__PURE__ */ new Set() : new Set(params.events);
|
|
85
|
+
const eventsChanged = params.events === "all" ? false : existingEvents.size !== desiredEvents.size || [...desiredEvents].some((e) => !existingEvents.has(e));
|
|
86
|
+
const statusChanged = params.status !== void 0 && existing.status !== params.status;
|
|
87
|
+
if (eventsChanged || statusChanged) {
|
|
88
|
+
const secret = (await client.request({
|
|
89
|
+
method: "PATCH",
|
|
90
|
+
path: `/webhooks/${encodeURIComponent(existing.id)}`,
|
|
91
|
+
body: {
|
|
92
|
+
...eventsChanged ? { events: params.events } : {},
|
|
93
|
+
...statusChanged ? { status: params.status } : {}
|
|
94
|
+
}
|
|
95
|
+
})).signing_secret ?? (await client.request({
|
|
96
|
+
method: "GET",
|
|
97
|
+
path: `/webhooks/${encodeURIComponent(existing.id)}`
|
|
98
|
+
})).signing_secret;
|
|
99
|
+
return {
|
|
100
|
+
webhookId: existing.id,
|
|
101
|
+
signingSecret: secret,
|
|
102
|
+
endpointUrl: params.endpointUrl,
|
|
103
|
+
events: params.events,
|
|
104
|
+
created: false,
|
|
105
|
+
updated: true
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const refreshed = await client.request({
|
|
109
|
+
method: "GET",
|
|
110
|
+
path: `/webhooks/${encodeURIComponent(existing.id)}`
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
webhookId: existing.id,
|
|
114
|
+
signingSecret: refreshed.signing_secret,
|
|
115
|
+
endpointUrl: params.endpointUrl,
|
|
116
|
+
events: params.events,
|
|
117
|
+
created: false,
|
|
118
|
+
updated: false
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const created = await client.request({
|
|
122
|
+
method: "POST",
|
|
123
|
+
path: "/webhooks",
|
|
124
|
+
body: {
|
|
125
|
+
endpoint_url: params.endpointUrl,
|
|
126
|
+
events: params.events,
|
|
127
|
+
...params.status ? { status: params.status } : {}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return {
|
|
131
|
+
webhookId: created.id,
|
|
132
|
+
signingSecret: created.signing_secret,
|
|
133
|
+
endpointUrl: params.endpointUrl,
|
|
134
|
+
events: params.events,
|
|
135
|
+
created: true,
|
|
136
|
+
updated: false
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
//#endregion
|
|
141
|
+
export { ensureResendWebhook, triggers };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { WebhookRequest } from "@keystrokehq/core";
|
|
2
|
+
|
|
3
|
+
//#region src/verification.d.ts
|
|
4
|
+
declare const RESEND_SVIX_ID_HEADER = "svix-id";
|
|
5
|
+
declare const RESEND_SVIX_TIMESTAMP_HEADER = "svix-timestamp";
|
|
6
|
+
declare const RESEND_SVIX_SIGNATURE_HEADER = "svix-signature";
|
|
7
|
+
/** Default replay window: 5 minutes either side of `now`. */
|
|
8
|
+
declare const RESEND_DEFAULT_TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
9
|
+
type ResendWebhookVerificationResult = {
|
|
10
|
+
readonly ok: true;
|
|
11
|
+
readonly svixId: string;
|
|
12
|
+
readonly svixTimestamp: number;
|
|
13
|
+
} | {
|
|
14
|
+
readonly ok: false;
|
|
15
|
+
readonly reason: 'missing_headers' | 'bad_secret_format' | 'bad_timestamp' | 'stale_timestamp' | 'bad_signature';
|
|
16
|
+
};
|
|
17
|
+
interface VerifyResendWebhookOptions {
|
|
18
|
+
/** Raw, UTF-8 request body. Must be preserved byte-for-byte. */
|
|
19
|
+
readonly rawBody: string | Buffer;
|
|
20
|
+
/**
|
|
21
|
+
* Inbound request headers. Case-insensitive; values may be string
|
|
22
|
+
* or string[] (HTTP/2 / Node req.headers).
|
|
23
|
+
*/
|
|
24
|
+
readonly headers: Headers | Record<string, string | string[] | undefined> | Pick<WebhookRequest, 'headers'>['headers'];
|
|
25
|
+
/** `whsec_<base64>` as returned by Resend. */
|
|
26
|
+
readonly signingSecret: string;
|
|
27
|
+
/** Max allowed age of the request in seconds. Defaults to 300. */
|
|
28
|
+
readonly toleranceSeconds?: number;
|
|
29
|
+
/** Override the clock for testing. Returns milliseconds since epoch. */
|
|
30
|
+
readonly now?: () => number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Verify a Resend webhook request using Svix v1 signatures.
|
|
34
|
+
*
|
|
35
|
+
* Returns a structured result instead of a boolean so that callers
|
|
36
|
+
* (and tests) can differentiate replay-window failures from signature
|
|
37
|
+
* failures from bad configuration.
|
|
38
|
+
*/
|
|
39
|
+
declare function verifyResendWebhookRequest(options: VerifyResendWebhookOptions): ResendWebhookVerificationResult;
|
|
40
|
+
/**
|
|
41
|
+
* Parse the raw (verified) webhook body as JSON.
|
|
42
|
+
*
|
|
43
|
+
* Returns `undefined` on parse failure. Callers are expected to run
|
|
44
|
+
* the result through `resendWebhookEventSchema.parse(...)` (from
|
|
45
|
+
* `./events`) to get a discriminated-union-typed event.
|
|
46
|
+
*/
|
|
47
|
+
declare function parseResendWebhookBody(rawBody: string | Buffer): unknown;
|
|
48
|
+
//#endregion
|
|
49
|
+
export { RESEND_DEFAULT_TIMESTAMP_TOLERANCE_SECONDS, RESEND_SVIX_ID_HEADER, RESEND_SVIX_SIGNATURE_HEADER, RESEND_SVIX_TIMESTAMP_HEADER, ResendWebhookVerificationResult, VerifyResendWebhookOptions, parseResendWebhookBody, verifyResendWebhookRequest };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
//#region src/verification.ts
|
|
4
|
+
/**
|
|
5
|
+
* resend/verification.ts
|
|
6
|
+
*
|
|
7
|
+
* Svix v1 webhook signature verification for Resend webhooks.
|
|
8
|
+
*
|
|
9
|
+
* Resend signs every webhook POST with Svix. Every request carries:
|
|
10
|
+
*
|
|
11
|
+
* svix-id: unique message id (also used as the idempotency key)
|
|
12
|
+
* svix-timestamp: unix-seconds send time
|
|
13
|
+
* svix-signature: space-separated list of `v1,<base64>` tokens
|
|
14
|
+
*
|
|
15
|
+
* The signing secret is per-endpoint and returned as `whsec_<base64>`
|
|
16
|
+
* on `POST /webhooks` and `GET /webhooks/{id}`.
|
|
17
|
+
*
|
|
18
|
+
* Wire format (Svix v1):
|
|
19
|
+
*
|
|
20
|
+
* signing_input = `${svixId}.${svixTimestamp}.${rawBody}`
|
|
21
|
+
* hmac_key_bytes = base64_decode(secret.slice('whsec_'.length))
|
|
22
|
+
* expected_token = `v1,${base64(HMAC_SHA256(hmac_key_bytes, signing_input))}`
|
|
23
|
+
*
|
|
24
|
+
* A request is valid when at least one `v1,*` token in
|
|
25
|
+
* `svix-signature` matches `expected_token`, verified with a
|
|
26
|
+
* constant-time comparison, and `svix-timestamp` is within the
|
|
27
|
+
* configurable tolerance (default: 300 s) of the current time.
|
|
28
|
+
*
|
|
29
|
+
* Why hand-rolled:
|
|
30
|
+
*
|
|
31
|
+
* - Runtime: no `svix` npm dependency, keeping the bundle
|
|
32
|
+
* edge-friendly. See `IMPLEMENTATION_NOTES.md` § 3.
|
|
33
|
+
* - The shared `verifyHmacWebhook` helper in
|
|
34
|
+
* `@keystrokehq/integration-authoring/webhooks` digests as hex + strips a prefix;
|
|
35
|
+
* Svix uses base64, multiple tokens, and a base64-decoded secret, so
|
|
36
|
+
* we need a Svix-specific path.
|
|
37
|
+
*/
|
|
38
|
+
const RESEND_SVIX_ID_HEADER = "svix-id";
|
|
39
|
+
const RESEND_SVIX_TIMESTAMP_HEADER = "svix-timestamp";
|
|
40
|
+
const RESEND_SVIX_SIGNATURE_HEADER = "svix-signature";
|
|
41
|
+
/** Secrets Resend returns on webhook create/retrieve. Always `whsec_<base64>`. */
|
|
42
|
+
const WEBHOOK_SECRET_PREFIX = "whsec_";
|
|
43
|
+
/** Default replay window: 5 minutes either side of `now`. */
|
|
44
|
+
const RESEND_DEFAULT_TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
45
|
+
function getHeader(headers, name) {
|
|
46
|
+
if (headers === void 0) return void 0;
|
|
47
|
+
if (typeof Headers !== "undefined" && headers instanceof Headers) return headers.get(name) ?? void 0;
|
|
48
|
+
const lower = name.toLowerCase();
|
|
49
|
+
for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === lower) {
|
|
50
|
+
if (Array.isArray(value)) return value[0];
|
|
51
|
+
return value ?? void 0;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Verify a Resend webhook request using Svix v1 signatures.
|
|
56
|
+
*
|
|
57
|
+
* Returns a structured result instead of a boolean so that callers
|
|
58
|
+
* (and tests) can differentiate replay-window failures from signature
|
|
59
|
+
* failures from bad configuration.
|
|
60
|
+
*/
|
|
61
|
+
function verifyResendWebhookRequest(options) {
|
|
62
|
+
const svixId = getHeader(options.headers, RESEND_SVIX_ID_HEADER);
|
|
63
|
+
const svixTimestampRaw = getHeader(options.headers, RESEND_SVIX_TIMESTAMP_HEADER);
|
|
64
|
+
const svixSignature = getHeader(options.headers, RESEND_SVIX_SIGNATURE_HEADER);
|
|
65
|
+
if (!svixId || !svixTimestampRaw || !svixSignature) return {
|
|
66
|
+
ok: false,
|
|
67
|
+
reason: "missing_headers"
|
|
68
|
+
};
|
|
69
|
+
if (!options.signingSecret.startsWith(WEBHOOK_SECRET_PREFIX)) return {
|
|
70
|
+
ok: false,
|
|
71
|
+
reason: "bad_secret_format"
|
|
72
|
+
};
|
|
73
|
+
const svixTimestamp = Number(svixTimestampRaw);
|
|
74
|
+
if (!Number.isFinite(svixTimestamp) || !Number.isInteger(svixTimestamp)) return {
|
|
75
|
+
ok: false,
|
|
76
|
+
reason: "bad_timestamp"
|
|
77
|
+
};
|
|
78
|
+
const nowSeconds = Math.floor((options.now?.() ?? Date.now()) / 1e3);
|
|
79
|
+
const tolerance = options.toleranceSeconds ?? RESEND_DEFAULT_TIMESTAMP_TOLERANCE_SECONDS;
|
|
80
|
+
if (Math.abs(nowSeconds - svixTimestamp) > tolerance) return {
|
|
81
|
+
ok: false,
|
|
82
|
+
reason: "stale_timestamp"
|
|
83
|
+
};
|
|
84
|
+
let secretBytes;
|
|
85
|
+
try {
|
|
86
|
+
secretBytes = Buffer.from(options.signingSecret.slice(6), "base64");
|
|
87
|
+
if (secretBytes.length === 0) return {
|
|
88
|
+
ok: false,
|
|
89
|
+
reason: "bad_secret_format"
|
|
90
|
+
};
|
|
91
|
+
} catch {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
reason: "bad_secret_format"
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const signingInput = `${svixId}.${svixTimestamp}.${typeof options.rawBody === "string" ? options.rawBody : options.rawBody.toString("utf8")}`;
|
|
98
|
+
const expected = createHmac("sha256", secretBytes).update(signingInput).digest();
|
|
99
|
+
const expectedBuffer = Buffer.from(expected);
|
|
100
|
+
const tokens = svixSignature.split(" ");
|
|
101
|
+
for (const token of tokens) {
|
|
102
|
+
if (!token.startsWith("v1,")) continue;
|
|
103
|
+
const candidate = token.slice(3);
|
|
104
|
+
let candidateBytes;
|
|
105
|
+
try {
|
|
106
|
+
candidateBytes = Buffer.from(candidate, "base64");
|
|
107
|
+
} catch {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (candidateBytes.length !== expectedBuffer.length) continue;
|
|
111
|
+
if (timingSafeEqual(candidateBytes, expectedBuffer)) return {
|
|
112
|
+
ok: true,
|
|
113
|
+
svixId,
|
|
114
|
+
svixTimestamp
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
reason: "bad_signature"
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Parse the raw (verified) webhook body as JSON.
|
|
124
|
+
*
|
|
125
|
+
* Returns `undefined` on parse failure. Callers are expected to run
|
|
126
|
+
* the result through `resendWebhookEventSchema.parse(...)` (from
|
|
127
|
+
* `./events`) to get a discriminated-union-typed event.
|
|
128
|
+
*/
|
|
129
|
+
function parseResendWebhookBody(rawBody) {
|
|
130
|
+
const text = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(text);
|
|
133
|
+
} catch {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
//#endregion
|
|
139
|
+
export { RESEND_DEFAULT_TIMESTAMP_TOLERANCE_SECONDS, RESEND_SVIX_ID_HEADER, RESEND_SVIX_SIGNATURE_HEADER, RESEND_SVIX_TIMESTAMP_HEADER, parseResendWebhookBody, verifyResendWebhookRequest };
|