better-ship 0.18.0 → 0.19.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/dist/{application-CNdzHIXk.js → application-Dg1jIKPe.js} +2 -2
- package/dist/{application-CNdzHIXk.js.map → application-Dg1jIKPe.js.map} +1 -1
- package/dist/application.js +1 -1
- package/dist/better-auth/apple-client-secret.js +1 -1
- package/dist/better-auth/auth-client.js +1 -1
- package/dist/better-auth/rate-limit.js +1 -1
- package/dist/better-auth.js +1 -1
- package/dist/billing/client.js +1 -1
- package/dist/cloudflare/auth-secondary-storage.js +1 -1
- package/dist/cloudflare/durable-object-client.js +1 -1
- package/dist/cloudflare/durable-object-errors.js +1 -1
- package/dist/cloudflare/kv-client.js +1 -1
- package/dist/cloudflare/kv-errors.js +1 -1
- package/dist/cloudflare/queue.js +2 -2
- package/dist/cloudflare/r2-client.js +1 -1
- package/dist/cloudflare/r2-errors.js +1 -1
- package/dist/cloudflare/scheduled.js +1 -1
- package/dist/cloudflare.js +3 -3
- package/dist/{core-UUW2unJd.js → core-BT1dSEQI.js} +2 -2
- package/dist/{core-UUW2unJd.js.map → core-BT1dSEQI.js.map} +1 -1
- package/dist/core.js +1 -1
- package/dist/http.d.ts +15 -7
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +50 -11
- package/dist/http.js.map +1 -1
- package/dist/{outbox-repository-options-CgdqU20T.js → outbox-repository-options-B8TnAE1h.js} +2 -2
- package/dist/{outbox-repository-options-CgdqU20T.js.map → outbox-repository-options-B8TnAE1h.js.map} +1 -1
- package/dist/postgres.js +3 -3
- package/dist/posthog/server.js +1 -1
- package/dist/resend.d.ts +3 -3
- package/dist/resend.d.ts.map +1 -1
- package/dist/resend.js +30 -57
- package/dist/resend.js.map +1 -1
- package/dist/sentry/client.js +1 -1
- package/dist/stripe/payment-service.error.js +1 -1
- package/dist/stripe/payment-webhook.d.ts +3 -3
- package/dist/stripe/payment-webhook.d.ts.map +1 -1
- package/dist/stripe/payment-webhook.js +6 -18
- package/dist/stripe/payment-webhook.js.map +1 -1
- package/dist/tanstack.js +1 -1
- package/dist/{webhook-verification-BuxPzdr4.d.ts → webhook-verification-D1c7BY4q.d.ts} +8 -16
- package/dist/webhook-verification-D1c7BY4q.d.ts.map +1 -0
- package/dist/{webhook-verification-BBmjkHgI.js → webhook-verification-qSFXv5H3.js} +2 -2
- package/dist/webhook-verification-qSFXv5H3.js.map +1 -0
- package/package.json +1 -1
- package/src/core/console-log-sink.ts +1 -5
- package/src/infrastructure/http/index.ts +2 -6
- package/src/infrastructure/http/webhook-handler.ts +68 -11
- package/src/infrastructure/http/webhook-verification.ts +6 -9
- package/src/infrastructure/resend/email-event.ts +14 -30
- package/src/infrastructure/resend/resend-webhook.ts +12 -22
- package/src/infrastructure/stripe/payment-webhook.ts +7 -20
- package/dist/webhook-verification-BBmjkHgI.js.map +0 -1
- package/dist/webhook-verification-BuxPzdr4.d.ts.map +0 -1
package/dist/resend.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { $ as EmailAddressSchema, J as parseBounceType, X as EmailServiceError } from "./core-
|
|
2
|
-
import { t as WebhookVerificationError } from "./webhook-verification-
|
|
1
|
+
import { $ as EmailAddressSchema, J as parseBounceType, X as EmailServiceError } from "./core-BT1dSEQI.js";
|
|
2
|
+
import { t as WebhookVerificationError } from "./webhook-verification-qSFXv5H3.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { Buffer } from "node:buffer";
|
|
5
5
|
|
|
@@ -162,58 +162,40 @@ const EventHeaderSchema = z.object({
|
|
|
162
162
|
});
|
|
163
163
|
/** Validate signed provider data before projecting it into the public event contract. */
|
|
164
164
|
function parseEmailEvent(payload, id) {
|
|
165
|
-
const header = EventHeaderSchema.
|
|
166
|
-
if (!header.
|
|
167
|
-
|
|
168
|
-
outcome: "ignored",
|
|
169
|
-
type: header.data.type
|
|
170
|
-
};
|
|
171
|
-
const parsed = EmailPayloadSchema.safeParse(payload);
|
|
172
|
-
if (!parsed.success) return { outcome: "invalid" };
|
|
173
|
-
const event = parsed.data;
|
|
165
|
+
const header = EventHeaderSchema.parse(payload);
|
|
166
|
+
if (!Object.hasOwn(EMAIL_TYPES, header.type)) return null;
|
|
167
|
+
const event = EmailPayloadSchema.parse(payload);
|
|
174
168
|
const base = {
|
|
175
169
|
id,
|
|
176
170
|
emailId: event.data.email_id,
|
|
177
171
|
recipients: event.data.to,
|
|
178
172
|
subject: event.data.subject,
|
|
179
173
|
tags: event.data.tags,
|
|
180
|
-
occurredAt: header.
|
|
174
|
+
occurredAt: header.created_at
|
|
181
175
|
};
|
|
182
176
|
switch (event.type) {
|
|
183
177
|
case "email.bounced": return {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
type: event.type,
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
subType: event.data.bounce.subType ?? null,
|
|
191
|
-
message: event.data.bounce.message
|
|
192
|
-
}
|
|
178
|
+
...base,
|
|
179
|
+
type: event.type,
|
|
180
|
+
bounce: {
|
|
181
|
+
type: parseBounceType(event.data.bounce.type),
|
|
182
|
+
subType: event.data.bounce.subType ?? null,
|
|
183
|
+
message: event.data.bounce.message
|
|
193
184
|
}
|
|
194
185
|
};
|
|
195
186
|
case "email.failed": return {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
type: event.type,
|
|
200
|
-
reason: event.data.failed.reason
|
|
201
|
-
}
|
|
187
|
+
...base,
|
|
188
|
+
type: event.type,
|
|
189
|
+
reason: event.data.failed.reason
|
|
202
190
|
};
|
|
203
191
|
case "email.clicked": return {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
type: event.type,
|
|
208
|
-
link: event.data.click.link
|
|
209
|
-
}
|
|
192
|
+
...base,
|
|
193
|
+
type: event.type,
|
|
194
|
+
link: event.data.click.link
|
|
210
195
|
};
|
|
211
196
|
default: return {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
...base,
|
|
215
|
-
type: event.type
|
|
216
|
-
}
|
|
197
|
+
...base,
|
|
198
|
+
type: event.type
|
|
217
199
|
};
|
|
218
200
|
}
|
|
219
201
|
}
|
|
@@ -238,15 +220,12 @@ var ResendWebhook = class ResendWebhook {
|
|
|
238
220
|
this.webhookSecret = webhookSecret;
|
|
239
221
|
this.verifier = verifier;
|
|
240
222
|
}
|
|
241
|
-
/** Verify the raw body and Svix headers. Invalid requests
|
|
223
|
+
/** Verify the raw body and Svix headers. Invalid requests throw typed errors; other failures propagate. */
|
|
242
224
|
async verify(body, headers) {
|
|
243
225
|
const id = headers.get("svix-id");
|
|
244
226
|
const timestamp = headers.get("svix-timestamp");
|
|
245
227
|
const signature = headers.get("svix-signature");
|
|
246
|
-
if (!id || !timestamp || !signature)
|
|
247
|
-
outcome: "rejected",
|
|
248
|
-
error: new WebhookVerificationError("missing_headers")
|
|
249
|
-
};
|
|
228
|
+
if (!id || !timestamp || !signature) throw new WebhookVerificationError("missing_headers");
|
|
250
229
|
const client = await (this.client ??= this.verifier());
|
|
251
230
|
let payload;
|
|
252
231
|
try {
|
|
@@ -260,22 +239,16 @@ var ResendWebhook = class ResendWebhook {
|
|
|
260
239
|
webhookSecret: this.webhookSecret
|
|
261
240
|
});
|
|
262
241
|
} catch (cause) {
|
|
263
|
-
if (cause instanceof SyntaxError)
|
|
264
|
-
outcome: "rejected",
|
|
265
|
-
error: new WebhookVerificationError("invalid_payload", cause)
|
|
266
|
-
};
|
|
242
|
+
if (cause instanceof SyntaxError) throw new WebhookVerificationError("invalid_payload", cause);
|
|
267
243
|
if (!SignatureErrorSchema.safeParse(cause).success) throw cause;
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
244
|
+
throw new WebhookVerificationError("invalid_signature", cause);
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
return parseEmailEvent(payload, id);
|
|
248
|
+
} catch (cause) {
|
|
249
|
+
if (cause instanceof z.ZodError) throw new WebhookVerificationError("invalid_payload", cause);
|
|
250
|
+
throw cause;
|
|
272
251
|
}
|
|
273
|
-
const result = parseEmailEvent(payload, id);
|
|
274
|
-
if (result.outcome === "invalid") return {
|
|
275
|
-
outcome: "rejected",
|
|
276
|
-
error: new WebhookVerificationError("invalid_payload")
|
|
277
|
-
};
|
|
278
|
-
return result;
|
|
279
252
|
}
|
|
280
253
|
};
|
|
281
254
|
|
package/dist/resend.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resend.js","names":[],"sources":["../src/infrastructure/resend/resend-email-service.ts","../src/infrastructure/resend/email-event.ts","../src/infrastructure/resend/resend-webhook.ts"],"sourcesContent":["import { Buffer } from 'node:buffer'\n\nimport type { Attachment, CreateEmailOptions, ErrorResponse, Resend } from 'resend'\n\nimport type { EmailAccepted, EmailAttachment, IEmailService, OutboundEmail } from '@/application'\nimport { EmailServiceError, type EmailSendFailureReason } from '@/core'\n\n// SDK additions must receive an explicit classification before the package builds.\nconst SDK_REASONS = {\n invalid_idempotency_key: 'invalid_request',\n validation_error: 'invalid_request',\n missing_api_key: 'configuration',\n restricted_api_key: 'configuration',\n invalid_api_key: 'configuration',\n not_found: 'invalid_request',\n method_not_allowed: 'invalid_request',\n invalid_idempotent_request: 'invalid_request',\n concurrent_idempotent_requests: 'idempotency_conflict',\n invalid_attachment: 'invalid_request',\n invalid_from_address: 'invalid_request',\n invalid_access: 'configuration',\n invalid_parameter: 'invalid_request',\n invalid_region: 'invalid_request',\n missing_required_field: 'invalid_request',\n monthly_quota_exceeded: 'quota_exceeded',\n daily_quota_exceeded: 'quota_exceeded',\n rate_limit_exceeded: 'rate_limited',\n security_error: 'configuration',\n application_error: 'provider_error',\n internal_server_error: 'provider_error',\n} satisfies Record<ErrorResponse['name'], EmailSendFailureReason>\n\n// Resend documents these codes before its SDK includes them in ErrorResponse.\nconst DOCUMENTED_REASONS = new Map<string, EmailSendFailureReason>([\n ['email_above_quota', 'quota_exceeded'],\n ['invalid_permission', 'configuration'],\n ['suspended_api_key', 'configuration'],\n ['resource_locked', 'idempotency_conflict'],\n ['service_unavailable', 'provider_error'],\n ['missing_required_parameter', 'invalid_request'],\n])\n\nfunction reasonFromResend(error: ErrorResponse): EmailSendFailureReason {\n if (Object.hasOwn(SDK_REASONS, error.name)) return SDK_REASONS[error.name]\n const reason = DOCUMENTED_REASONS.get(error.name)\n if (reason !== undefined) return reason\n if (error.statusCode === 429) return 'rate_limited'\n if (error.statusCode !== null && error.statusCode >= 400 && error.statusCode < 500)\n return 'invalid_request'\n return 'provider_error'\n}\n\nfunction retryAfterSeconds(value: string | undefined): number | null {\n if (value === undefined || !/^\\d+$/.test(value)) return null\n const seconds = Number(value)\n return Number.isSafeInteger(seconds) ? seconds : null\n}\n\nfunction toAttachment(attachment: EmailAttachment): Attachment {\n const result: Attachment = {\n filename: attachment.filename,\n content: ArrayBuffer.isView(attachment.content)\n ? Buffer.from(attachment.content)\n : attachment.content,\n }\n if (attachment.contentType !== undefined) result.contentType = attachment.contentType\n return result\n}\n\n/** The SDK operations required by the adapter; a test factory can return a plain object. */\nexport type ResendEmailClient = { readonly emails: Pick<Resend['emails'], 'send'> }\n\n/**\n * Send without package retries or logs. Ported from Suparelay's resend-email-service.ts.\n * The Resend SDK itself logs API errors outside production.\n */\nexport class ResendEmailService implements IEmailService {\n /** Load the SDK on the first send. Constructing the service does not load it. */\n static create(apiKey: string, fromAddress: string): ResendEmailService {\n return new ResendEmailService(\n () => import('resend').then(({ Resend }) => new Resend(apiKey)),\n fromAddress,\n )\n }\n\n private client: Promise<ResendEmailClient> | undefined\n\n /** Supply an async factory. The service calls it once, on the first send, and keeps its promise. */\n constructor(\n private readonly createClient: () => Promise<ResendEmailClient>,\n private readonly fromAddress: string,\n ) {}\n\n /** Resolve after acceptance. Provider failures throw EmailServiceError; SDK defects retain their identity. */\n async sendEmail(email: OutboundEmail): Promise<EmailAccepted> {\n if (this.client === undefined) {\n // Cache synchronous factory throws as rejections too, so later sends cannot initialize again.\n this.client = Promise.resolve().then(this.createClient)\n }\n const client = await this.client\n const payload: CreateEmailOptions = {\n from: email.from ?? this.fromAddress,\n to: [email.to].flat(),\n subject: email.subject,\n html: email.html,\n }\n if (email.text !== undefined) payload.text = email.text\n if (email.replyTo !== undefined) payload.replyTo = email.replyTo\n if (email.tags !== undefined)\n payload.tags = Object.entries(email.tags).map(([name, value]) => ({ name, value }))\n if (email.attachments !== undefined) payload.attachments = email.attachments.map(toAttachment)\n const result = await client.emails.send(\n payload,\n email.idempotencyKey === undefined ? undefined : { idempotencyKey: email.idempotencyKey },\n )\n if (result.error !== null) {\n throw new EmailServiceError({\n reason: reasonFromResend(result.error),\n provider: 'resend',\n providerCode: result.error.name,\n statusCode: result.error.statusCode,\n retryAfter: retryAfterSeconds(result.headers?.['retry-after']),\n cause: result.error,\n })\n }\n return { providerId: result.data.id }\n }\n}\n","import type { WebhookEventPayload } from 'resend'\nimport { z } from 'zod'\n\nimport { EmailAddressSchema, parseBounceType, type BounceType } from '@/core'\n\nconst SIMPLE_EMAIL_TYPES = [\n 'email.sent',\n 'email.scheduled',\n 'email.delivered',\n 'email.delivery_delayed',\n 'email.complained',\n 'email.opened',\n 'email.received',\n 'email.suppressed',\n] as const\n\n/** Resend email event names supported by this module. */\nexport type EmailEventType =\n | (typeof SIMPLE_EMAIL_TYPES)[number]\n | 'email.bounced'\n | 'email.failed'\n | 'email.clicked'\n\nconst EMAIL_TYPES = {\n 'email.sent': true,\n 'email.scheduled': true,\n 'email.delivered': true,\n 'email.delivery_delayed': true,\n 'email.complained': true,\n 'email.opened': true,\n 'email.received': true,\n 'email.suppressed': true,\n 'email.bounced': true,\n 'email.failed': true,\n 'email.clicked': true,\n} satisfies Record<Extract<WebhookEventPayload['type'], `email.${string}`>, true>\n\nconst BaseDataSchema = z.object({\n email_id: z.string().min(1),\n to: z.array(EmailAddressSchema).min(1),\n subject: z.string(),\n tags: z.record(z.string(), z.string()).default({}),\n})\n\nconst EmailPayloadSchema = z.discriminatedUnion('type', [\n z.object({ type: z.enum(SIMPLE_EMAIL_TYPES), data: BaseDataSchema }),\n z.object({\n type: z.literal('email.bounced'),\n data: BaseDataSchema.extend({\n bounce: z.object({\n type: z.string().optional(),\n subType: z.string().nullable().optional(),\n message: z.string(),\n }),\n }),\n }),\n z.object({\n type: z.literal('email.failed'),\n data: BaseDataSchema.extend({ failed: z.object({ reason: z.string() }) }),\n }),\n z.object({\n type: z.literal('email.clicked'),\n data: BaseDataSchema.extend({ click: z.object({ link: z.string() }) }),\n }),\n])\n\ntype EmailEventBase = {\n readonly id: string\n readonly emailId: string\n readonly recipients: readonly string[]\n readonly subject: string\n readonly tags: Readonly<Record<string, string>>\n readonly occurredAt: string\n}\n\n/** A verified event. `id` is the Svix identity; use `resend:event:${id}` as the command id. */\nexport type EmailEvent =\n | (EmailEventBase & {\n readonly type: 'email.bounced'\n readonly bounce: {\n readonly type: BounceType\n readonly subType: string | null\n readonly message: string\n }\n })\n | (EmailEventBase & { readonly type: 'email.failed'; readonly reason: string })\n | (EmailEventBase & { readonly type: 'email.clicked'; readonly link: string })\n | (EmailEventBase & { readonly type: (typeof SIMPLE_EMAIL_TYPES)[number] })\n\nconst EventHeaderSchema = z.object({\n type: z.string(),\n created_at: z.iso.datetime({ offset: true }),\n})\n\n/** Validate signed provider data before projecting it into the public event contract. */\nexport function parseEmailEvent(\n payload: WebhookEventPayload,\n id: string,\n):\n | { readonly outcome: 'event'; readonly event: EmailEvent }\n | { readonly outcome: 'ignored'; readonly type: string }\n | { readonly outcome: 'invalid' } {\n const header = EventHeaderSchema.safeParse(payload)\n if (!header.success) return { outcome: 'invalid' }\n if (!Object.hasOwn(EMAIL_TYPES, header.data.type))\n return { outcome: 'ignored', type: header.data.type }\n const parsed = EmailPayloadSchema.safeParse(payload)\n if (!parsed.success) return { outcome: 'invalid' }\n const event = parsed.data\n const base: EmailEventBase = {\n id,\n emailId: event.data.email_id,\n recipients: event.data.to,\n subject: event.data.subject,\n tags: event.data.tags,\n occurredAt: header.data.created_at,\n }\n switch (event.type) {\n case 'email.bounced':\n return {\n outcome: 'event',\n event: {\n ...base,\n type: event.type,\n bounce: {\n type: parseBounceType(event.data.bounce.type),\n subType: event.data.bounce.subType ?? null,\n message: event.data.bounce.message,\n },\n },\n }\n case 'email.failed':\n return {\n outcome: 'event',\n event: { ...base, type: event.type, reason: event.data.failed.reason },\n }\n case 'email.clicked':\n return { outcome: 'event', event: { ...base, type: event.type, link: event.data.click.link } }\n default:\n return { outcome: 'event', event: { ...base, type: event.type } }\n }\n}\n","import type { Resend } from 'resend'\nimport { z } from 'zod'\n\nimport {\n WebhookVerificationError,\n type WebhookVerification,\n type WebhookVerifier,\n} from '../http/webhook-verification.ts'\nimport { parseEmailEvent, type EmailEvent } from './email-event.ts'\n\n// Separate standardwebhooks versions have different prototypes; its error name remains the same.\nconst SignatureErrorSchema = z\n .instanceof(Error)\n .refine((error) => error.name === 'WebhookVerificationError')\n\n/** The SDK's local signature verifier. It makes no API request. */\nexport type ResendWebhookClient = Pick<Resend['webhooks'], 'verify'>\n\n/** Verify Svix signatures and map Resend email events. Ported from Typist's Resend route. */\nexport class ResendWebhook implements WebhookVerifier<EmailEvent> {\n /** Load the SDK on the first verification. Only the webhook secret is required. */\n static create(webhookSecret: string): ResendWebhook {\n return new ResendWebhook(webhookSecret, async () => {\n const { Resend } = await import('resend')\n // The SDK requires a nonempty key even though its local verifier never uses that key.\n return new Resend('re_webhook_verification_only').webhooks\n })\n }\n\n private client: Promise<ResendWebhookClient> | undefined\n\n /** Inject the local SDK verifier when testing. */\n constructor(\n private readonly webhookSecret: string,\n private readonly verifier: () => Promise<ResendWebhookClient>,\n ) {}\n\n /** Verify the raw body and Svix headers. Invalid requests return rejected; configuration and SDK defects propagate. */\n async verify(body: string, headers: Headers): Promise<WebhookVerification<EmailEvent>> {\n const id = headers.get('svix-id')\n const timestamp = headers.get('svix-timestamp')\n const signature = headers.get('svix-signature')\n if (!id || !timestamp || !signature)\n return { outcome: 'rejected', error: new WebhookVerificationError('missing_headers') }\n const client = await (this.client ??= this.verifier())\n let payload: ReturnType<ResendWebhookClient['verify']>\n try {\n payload = client.verify({\n payload: body,\n headers: { id, timestamp, signature },\n webhookSecret: this.webhookSecret,\n })\n } catch (cause) {\n if (cause instanceof SyntaxError)\n return {\n outcome: 'rejected',\n error: new WebhookVerificationError('invalid_payload', cause),\n }\n if (!SignatureErrorSchema.safeParse(cause).success) throw cause\n return {\n outcome: 'rejected',\n error: new WebhookVerificationError('invalid_signature', cause),\n }\n }\n const result = parseEmailEvent(payload, id)\n if (result.outcome === 'invalid')\n return { outcome: 'rejected', error: new WebhookVerificationError('invalid_payload') }\n return result\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"resend.js","names":[],"sources":["../src/infrastructure/resend/resend-email-service.ts","../src/infrastructure/resend/email-event.ts","../src/infrastructure/resend/resend-webhook.ts"],"sourcesContent":["import { Buffer } from 'node:buffer'\n\nimport type { Attachment, CreateEmailOptions, ErrorResponse, Resend } from 'resend'\n\nimport type { EmailAccepted, EmailAttachment, IEmailService, OutboundEmail } from '@/application'\nimport { EmailServiceError, type EmailSendFailureReason } from '@/core'\n\n// SDK additions must receive an explicit classification before the package builds.\nconst SDK_REASONS = {\n invalid_idempotency_key: 'invalid_request',\n validation_error: 'invalid_request',\n missing_api_key: 'configuration',\n restricted_api_key: 'configuration',\n invalid_api_key: 'configuration',\n not_found: 'invalid_request',\n method_not_allowed: 'invalid_request',\n invalid_idempotent_request: 'invalid_request',\n concurrent_idempotent_requests: 'idempotency_conflict',\n invalid_attachment: 'invalid_request',\n invalid_from_address: 'invalid_request',\n invalid_access: 'configuration',\n invalid_parameter: 'invalid_request',\n invalid_region: 'invalid_request',\n missing_required_field: 'invalid_request',\n monthly_quota_exceeded: 'quota_exceeded',\n daily_quota_exceeded: 'quota_exceeded',\n rate_limit_exceeded: 'rate_limited',\n security_error: 'configuration',\n application_error: 'provider_error',\n internal_server_error: 'provider_error',\n} satisfies Record<ErrorResponse['name'], EmailSendFailureReason>\n\n// Resend documents these codes before its SDK includes them in ErrorResponse.\nconst DOCUMENTED_REASONS = new Map<string, EmailSendFailureReason>([\n ['email_above_quota', 'quota_exceeded'],\n ['invalid_permission', 'configuration'],\n ['suspended_api_key', 'configuration'],\n ['resource_locked', 'idempotency_conflict'],\n ['service_unavailable', 'provider_error'],\n ['missing_required_parameter', 'invalid_request'],\n])\n\nfunction reasonFromResend(error: ErrorResponse): EmailSendFailureReason {\n if (Object.hasOwn(SDK_REASONS, error.name)) return SDK_REASONS[error.name]\n const reason = DOCUMENTED_REASONS.get(error.name)\n if (reason !== undefined) return reason\n if (error.statusCode === 429) return 'rate_limited'\n if (error.statusCode !== null && error.statusCode >= 400 && error.statusCode < 500)\n return 'invalid_request'\n return 'provider_error'\n}\n\nfunction retryAfterSeconds(value: string | undefined): number | null {\n if (value === undefined || !/^\\d+$/.test(value)) return null\n const seconds = Number(value)\n return Number.isSafeInteger(seconds) ? seconds : null\n}\n\nfunction toAttachment(attachment: EmailAttachment): Attachment {\n const result: Attachment = {\n filename: attachment.filename,\n content: ArrayBuffer.isView(attachment.content)\n ? Buffer.from(attachment.content)\n : attachment.content,\n }\n if (attachment.contentType !== undefined) result.contentType = attachment.contentType\n return result\n}\n\n/** The SDK operations required by the adapter; a test factory can return a plain object. */\nexport type ResendEmailClient = { readonly emails: Pick<Resend['emails'], 'send'> }\n\n/**\n * Send without package retries or logs. Ported from Suparelay's resend-email-service.ts.\n * The Resend SDK itself logs API errors outside production.\n */\nexport class ResendEmailService implements IEmailService {\n /** Load the SDK on the first send. Constructing the service does not load it. */\n static create(apiKey: string, fromAddress: string): ResendEmailService {\n return new ResendEmailService(\n () => import('resend').then(({ Resend }) => new Resend(apiKey)),\n fromAddress,\n )\n }\n\n private client: Promise<ResendEmailClient> | undefined\n\n /** Supply an async factory. The service calls it once, on the first send, and keeps its promise. */\n constructor(\n private readonly createClient: () => Promise<ResendEmailClient>,\n private readonly fromAddress: string,\n ) {}\n\n /** Resolve after acceptance. Provider failures throw EmailServiceError; SDK defects retain their identity. */\n async sendEmail(email: OutboundEmail): Promise<EmailAccepted> {\n if (this.client === undefined) {\n // Cache synchronous factory throws as rejections too, so later sends cannot initialize again.\n this.client = Promise.resolve().then(this.createClient)\n }\n const client = await this.client\n const payload: CreateEmailOptions = {\n from: email.from ?? this.fromAddress,\n to: [email.to].flat(),\n subject: email.subject,\n html: email.html,\n }\n if (email.text !== undefined) payload.text = email.text\n if (email.replyTo !== undefined) payload.replyTo = email.replyTo\n if (email.tags !== undefined)\n payload.tags = Object.entries(email.tags).map(([name, value]) => ({ name, value }))\n if (email.attachments !== undefined) payload.attachments = email.attachments.map(toAttachment)\n const result = await client.emails.send(\n payload,\n email.idempotencyKey === undefined ? undefined : { idempotencyKey: email.idempotencyKey },\n )\n if (result.error !== null) {\n throw new EmailServiceError({\n reason: reasonFromResend(result.error),\n provider: 'resend',\n providerCode: result.error.name,\n statusCode: result.error.statusCode,\n retryAfter: retryAfterSeconds(result.headers?.['retry-after']),\n cause: result.error,\n })\n }\n return { providerId: result.data.id }\n }\n}\n","import type { WebhookEventPayload } from 'resend'\nimport { z } from 'zod'\n\nimport { EmailAddressSchema, parseBounceType, type BounceType } from '@/core'\n\nconst SIMPLE_EMAIL_TYPES = [\n 'email.sent',\n 'email.scheduled',\n 'email.delivered',\n 'email.delivery_delayed',\n 'email.complained',\n 'email.opened',\n 'email.received',\n 'email.suppressed',\n] as const\n\n/** Resend email event names supported by this module. */\nexport type EmailEventType =\n | (typeof SIMPLE_EMAIL_TYPES)[number]\n | 'email.bounced'\n | 'email.failed'\n | 'email.clicked'\n\nconst EMAIL_TYPES = {\n 'email.sent': true,\n 'email.scheduled': true,\n 'email.delivered': true,\n 'email.delivery_delayed': true,\n 'email.complained': true,\n 'email.opened': true,\n 'email.received': true,\n 'email.suppressed': true,\n 'email.bounced': true,\n 'email.failed': true,\n 'email.clicked': true,\n} satisfies Record<Extract<WebhookEventPayload['type'], `email.${string}`>, true>\n\nconst BaseDataSchema = z.object({\n email_id: z.string().min(1),\n to: z.array(EmailAddressSchema).min(1),\n subject: z.string(),\n tags: z.record(z.string(), z.string()).default({}),\n})\n\nconst EmailPayloadSchema = z.discriminatedUnion('type', [\n z.object({ type: z.enum(SIMPLE_EMAIL_TYPES), data: BaseDataSchema }),\n z.object({\n type: z.literal('email.bounced'),\n data: BaseDataSchema.extend({\n bounce: z.object({\n type: z.string().optional(),\n subType: z.string().nullable().optional(),\n message: z.string(),\n }),\n }),\n }),\n z.object({\n type: z.literal('email.failed'),\n data: BaseDataSchema.extend({ failed: z.object({ reason: z.string() }) }),\n }),\n z.object({\n type: z.literal('email.clicked'),\n data: BaseDataSchema.extend({ click: z.object({ link: z.string() }) }),\n }),\n])\n\ntype EmailEventBase = {\n readonly id: string\n readonly emailId: string\n readonly recipients: readonly string[]\n readonly subject: string\n readonly tags: Readonly<Record<string, string>>\n readonly occurredAt: string\n}\n\n/** A verified event. `id` is the Svix identity; use `resend:event:${id}` as the command id. */\nexport type EmailEvent =\n | (EmailEventBase & {\n readonly type: 'email.bounced'\n readonly bounce: {\n readonly type: BounceType\n readonly subType: string | null\n readonly message: string\n }\n })\n | (EmailEventBase & { readonly type: 'email.failed'; readonly reason: string })\n | (EmailEventBase & { readonly type: 'email.clicked'; readonly link: string })\n | (EmailEventBase & { readonly type: (typeof SIMPLE_EMAIL_TYPES)[number] })\n\nconst EventHeaderSchema = z.object({\n type: z.string(),\n created_at: z.iso.datetime({ offset: true }),\n})\n\n/** Validate signed provider data before projecting it into the public event contract. */\nexport function parseEmailEvent(payload: WebhookEventPayload, id: string): EmailEvent | null {\n const header = EventHeaderSchema.parse(payload)\n if (!Object.hasOwn(EMAIL_TYPES, header.type)) return null\n const event = EmailPayloadSchema.parse(payload)\n const base: EmailEventBase = {\n id,\n emailId: event.data.email_id,\n recipients: event.data.to,\n subject: event.data.subject,\n tags: event.data.tags,\n occurredAt: header.created_at,\n }\n switch (event.type) {\n case 'email.bounced':\n return {\n ...base,\n type: event.type,\n bounce: {\n type: parseBounceType(event.data.bounce.type),\n subType: event.data.bounce.subType ?? null,\n message: event.data.bounce.message,\n },\n }\n case 'email.failed':\n return { ...base, type: event.type, reason: event.data.failed.reason }\n case 'email.clicked':\n return { ...base, type: event.type, link: event.data.click.link }\n default:\n return { ...base, type: event.type }\n }\n}\n","import type { Resend } from 'resend'\nimport { z } from 'zod'\n\nimport { WebhookVerificationError, type WebhookVerifier } from '../http/webhook-verification.ts'\nimport { parseEmailEvent, type EmailEvent } from './email-event.ts'\n\n// Separate standardwebhooks versions have different prototypes; its error name remains the same.\nconst SignatureErrorSchema = z\n .instanceof(Error)\n .refine((error) => error.name === 'WebhookVerificationError')\n\n/** The SDK's local signature verifier. It makes no API request. */\nexport type ResendWebhookClient = Pick<Resend['webhooks'], 'verify'>\n\n/** Verify Svix signatures and map Resend email events. Ported from Typist's Resend route. */\nexport class ResendWebhook implements WebhookVerifier<EmailEvent> {\n /** Load the SDK on the first verification. Only the webhook secret is required. */\n static create(webhookSecret: string): ResendWebhook {\n return new ResendWebhook(webhookSecret, async () => {\n const { Resend } = await import('resend')\n // The SDK requires a nonempty key even though its local verifier never uses that key.\n return new Resend('re_webhook_verification_only').webhooks\n })\n }\n\n private client: Promise<ResendWebhookClient> | undefined\n\n /** Inject the local SDK verifier when testing. */\n constructor(\n private readonly webhookSecret: string,\n private readonly verifier: () => Promise<ResendWebhookClient>,\n ) {}\n\n /** Verify the raw body and Svix headers. Invalid requests throw typed errors; other failures propagate. */\n async verify(body: string, headers: Headers): Promise<EmailEvent | null> {\n const id = headers.get('svix-id')\n const timestamp = headers.get('svix-timestamp')\n const signature = headers.get('svix-signature')\n if (!id || !timestamp || !signature) throw new WebhookVerificationError('missing_headers')\n const client = await (this.client ??= this.verifier())\n let payload: ReturnType<ResendWebhookClient['verify']>\n try {\n payload = client.verify({\n payload: body,\n headers: { id, timestamp, signature },\n webhookSecret: this.webhookSecret,\n })\n } catch (cause) {\n if (cause instanceof SyntaxError) throw new WebhookVerificationError('invalid_payload', cause)\n if (!SignatureErrorSchema.safeParse(cause).success) throw cause\n throw new WebhookVerificationError('invalid_signature', cause)\n }\n try {\n return parseEmailEvent(payload, id)\n } catch (cause) {\n if (cause instanceof z.ZodError) throw new WebhookVerificationError('invalid_payload', cause)\n throw cause\n }\n }\n}\n"],"mappings":"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"}
|
package/dist/sentry/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { n as WebhookVerifier } from "../webhook-verification-D1c7BY4q.js";
|
|
2
2
|
import Stripe from "stripe";
|
|
3
3
|
//#region src/infrastructure/stripe/payment-webhook.d.ts
|
|
4
4
|
/**
|
|
@@ -11,8 +11,8 @@ export declare class StripeWebhookVerifier implements WebhookVerifier<Stripe.Eve
|
|
|
11
11
|
private readonly tolerance?;
|
|
12
12
|
/** Supply the endpoint's signing secret. Tolerance defaults to Stripe's five-minute window. */
|
|
13
13
|
constructor(stripe: Stripe, signingSecret: string, tolerance?: number | undefined);
|
|
14
|
-
/**
|
|
15
|
-
verify(body: string, headers: Headers): Promise<
|
|
14
|
+
/** Throw typed signature and JSON failures. Operational failures propagate without logging. */
|
|
15
|
+
verify(body: string, headers: Headers): Promise<Stripe.Event>;
|
|
16
16
|
}
|
|
17
17
|
//#endregion
|
|
18
18
|
//# sourceMappingURL=payment-webhook.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"payment-webhook.d.ts","names":[],"sources":["../../src/infrastructure/stripe/payment-webhook.ts"],"mappings":";;;;;;;
|
|
1
|
+
{"version":3,"file":"payment-webhook.d.ts","names":[],"sources":["../../src/infrastructure/stripe/payment-webhook.ts"],"mappings":";;;;;;;qBAQa,iCAAiC,gBAAgB,OAAO;mBAGhD;mBACA;mBACA;;EAHnB,YACmB,QAAQ,QACR,uBACA;;EAIb,OAAO,cAAc,SAAS,UAAU,QAAQ,OAAO"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as WebhookVerificationError } from "../webhook-verification-
|
|
1
|
+
import { t as WebhookVerificationError } from "../webhook-verification-qSFXv5H3.js";
|
|
2
2
|
import Stripe from "stripe";
|
|
3
3
|
|
|
4
4
|
//#region src/infrastructure/stripe/payment-webhook.ts
|
|
@@ -16,27 +16,15 @@ var StripeWebhookVerifier = class {
|
|
|
16
16
|
this.signingSecret = signingSecret;
|
|
17
17
|
this.tolerance = tolerance;
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
19
|
+
/** Throw typed signature and JSON failures. Operational failures propagate without logging. */
|
|
20
20
|
async verify(body, headers) {
|
|
21
21
|
const signature = headers.get("stripe-signature");
|
|
22
|
-
if (!signature)
|
|
23
|
-
outcome: "rejected",
|
|
24
|
-
error: new WebhookVerificationError("missing_headers")
|
|
25
|
-
};
|
|
22
|
+
if (!signature) throw new WebhookVerificationError("missing_headers");
|
|
26
23
|
try {
|
|
27
|
-
return
|
|
28
|
-
outcome: "event",
|
|
29
|
-
event: await this.stripe.webhooks.constructEventAsync(body, signature, this.signingSecret, this.tolerance, Stripe.createSubtleCryptoProvider())
|
|
30
|
-
};
|
|
24
|
+
return await this.stripe.webhooks.constructEventAsync(body, signature, this.signingSecret, this.tolerance, Stripe.createSubtleCryptoProvider());
|
|
31
25
|
} catch (cause) {
|
|
32
|
-
if (cause instanceof Stripe.errors.StripeSignatureVerificationError)
|
|
33
|
-
|
|
34
|
-
error: new WebhookVerificationError("invalid_signature", cause)
|
|
35
|
-
};
|
|
36
|
-
if (cause instanceof SyntaxError) return {
|
|
37
|
-
outcome: "rejected",
|
|
38
|
-
error: new WebhookVerificationError("invalid_payload", cause)
|
|
39
|
-
};
|
|
26
|
+
if (cause instanceof Stripe.errors.StripeSignatureVerificationError) throw new WebhookVerificationError("invalid_signature", cause);
|
|
27
|
+
if (cause instanceof SyntaxError) throw new WebhookVerificationError("invalid_payload", cause);
|
|
40
28
|
throw cause;
|
|
41
29
|
}
|
|
42
30
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"payment-webhook.js","names":[],"sources":["../../src/infrastructure/stripe/payment-webhook.ts"],"sourcesContent":["import Stripe from 'stripe'\n\nimport {
|
|
1
|
+
{"version":3,"file":"payment-webhook.js","names":[],"sources":["../../src/infrastructure/stripe/payment-webhook.ts"],"sourcesContent":["import Stripe from 'stripe'\n\nimport { WebhookVerificationError, type WebhookVerifier } from '../http/webhook-verification.ts'\n\n/**\n * Authenticate raw Stripe webhook bodies with the SDK's timestamp tolerance and Web Crypto.\n * Returns native events; application conversion owns event selection and payload validation.\n */\nexport class StripeWebhookVerifier implements WebhookVerifier<Stripe.Event> {\n /** Supply the endpoint's signing secret. Tolerance defaults to Stripe's five-minute window. */\n constructor(\n private readonly stripe: Stripe,\n private readonly signingSecret: string,\n private readonly tolerance?: number,\n ) {}\n\n /** Throw typed signature and JSON failures. Operational failures propagate without logging. */\n async verify(body: string, headers: Headers): Promise<Stripe.Event> {\n const signature = headers.get('stripe-signature')\n if (!signature) throw new WebhookVerificationError('missing_headers')\n try {\n return await this.stripe.webhooks.constructEventAsync(\n body,\n signature,\n this.signingSecret,\n this.tolerance,\n Stripe.createSubtleCryptoProvider(),\n )\n } catch (cause) {\n if (cause instanceof Stripe.errors.StripeSignatureVerificationError)\n throw new WebhookVerificationError('invalid_signature', cause)\n if (cause instanceof SyntaxError) throw new WebhookVerificationError('invalid_payload', cause)\n throw cause\n }\n }\n}\n"],"mappings":";;;;;;;;AAQA,IAAa,wBAAb,MAA4E;CAGvD;CACA;CACA;;CAHnB,YACE,AAAiB,QACjB,AAAiB,eACjB,AAAiB,WACjB;EAHiB;EACA;EACA;CAChB;;CAGH,MAAM,OAAO,MAAc,SAAyC;EAClE,MAAM,YAAY,QAAQ,IAAI,kBAAkB;EAChD,IAAI,CAAC,WAAW,MAAM,IAAI,yBAAyB,iBAAiB;EACpE,IAAI;GACF,OAAO,MAAM,KAAK,OAAO,SAAS,oBAChC,MACA,WACA,KAAK,eACL,KAAK,WACL,OAAO,2BAA2B,CACpC;EACF,SAAS,OAAO;GACd,IAAI,iBAAiB,OAAO,OAAO,kCACjC,MAAM,IAAI,yBAAyB,qBAAqB,KAAK;GAC/D,IAAI,iBAAiB,aAAa,MAAM,IAAI,yBAAyB,mBAAmB,KAAK;GAC7F,MAAM;EACR;CACF;AACF"}
|
package/dist/tanstack.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { H as StaleSessionError, R as ValidationError, W as AuthenticationError, nt as AppError, r as createLogger } from "./core-
|
|
1
|
+
import { H as StaleSessionError, R as ValidationError, W as AuthenticationError, nt as AppError, r as createLogger } from "./core-BT1dSEQI.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getReasonPhrase } from "http-status-codes";
|
|
4
4
|
import { isNotFound, isRedirect, redirect } from "@tanstack/react-router";
|
|
@@ -8,22 +8,14 @@ declare class WebhookVerificationError extends AppError {
|
|
|
8
8
|
/** Keep secrets and payload details out of the public message. */
|
|
9
9
|
constructor(reason: 'missing_headers' | 'invalid_signature' | 'invalid_payload', cause?: unknown);
|
|
10
10
|
}
|
|
11
|
-
/** A verified event, an ignored event, or an invalid request. */
|
|
12
|
-
type WebhookVerification<Event> = {
|
|
13
|
-
readonly outcome: 'event';
|
|
14
|
-
readonly event: Event;
|
|
15
|
-
} | {
|
|
16
|
-
readonly outcome: 'ignored';
|
|
17
|
-
readonly type: string;
|
|
18
|
-
} | {
|
|
19
|
-
readonly outcome: 'rejected';
|
|
20
|
-
readonly error: WebhookVerificationError;
|
|
21
|
-
};
|
|
22
11
|
/** The provider owns signature verification, timestamp tolerance, and payload parsing. */
|
|
23
|
-
interface WebhookVerifier<
|
|
24
|
-
/**
|
|
25
|
-
|
|
12
|
+
interface WebhookVerifier<Verified> {
|
|
13
|
+
/**
|
|
14
|
+
* Verify the exact body before parsing it. Return null for an ignored webhook.
|
|
15
|
+
* @throws WebhookVerificationError when the request cannot be trusted or parsed.
|
|
16
|
+
*/
|
|
17
|
+
verify(body: string, headers: Headers): Promise<Verified | null>;
|
|
26
18
|
}
|
|
27
19
|
//#endregion
|
|
28
|
-
export {
|
|
29
|
-
//# sourceMappingURL=webhook-verification-
|
|
20
|
+
export { WebhookVerifier as n, WebhookVerificationError as t };
|
|
21
|
+
//# sourceMappingURL=webhook-verification-D1c7BY4q.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-verification-D1c7BY4q.d.ts","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"mappings":";;;cAGa,iCAAiC;WAMjC;WALF;WACA;;EAGT,YACW,qEACT;;;UAOa,gBAAgB;;;;;EAK/B,OAAO,cAAc,SAAS,UAAU,QAAQ"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { nt as AppError } from "./core-
|
|
1
|
+
import { nt as AppError } from "./core-BT1dSEQI.js";
|
|
2
2
|
|
|
3
3
|
//#region src/infrastructure/http/webhook-verification.ts
|
|
4
4
|
/** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */
|
|
@@ -15,4 +15,4 @@ var WebhookVerificationError = class extends AppError {
|
|
|
15
15
|
|
|
16
16
|
//#endregion
|
|
17
17
|
export { WebhookVerificationError as t };
|
|
18
|
-
//# sourceMappingURL=webhook-verification-
|
|
18
|
+
//# sourceMappingURL=webhook-verification-qSFXv5H3.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-verification-qSFXv5H3.js","names":[],"sources":["../src/infrastructure/http/webhook-verification.ts"],"sourcesContent":["import { AppError } from '@/core'\n\n/** A webhook cannot be trusted or parsed. The HTTP handler logs the rejection once. */\nexport class WebhookVerificationError extends AppError {\n readonly _tag = 'WebhookVerificationError'\n readonly classification = 'terminal'\n\n /** Keep secrets and payload details out of the public message. */\n constructor(\n readonly reason: 'missing_headers' | 'invalid_signature' | 'invalid_payload',\n cause?: unknown,\n ) {\n super(`Webhook rejected: ${reason}`, { cause })\n }\n}\n\n/** The provider owns signature verification, timestamp tolerance, and payload parsing. */\nexport interface WebhookVerifier<Verified> {\n /**\n * Verify the exact body before parsing it. Return null for an ignored webhook.\n * @throws WebhookVerificationError when the request cannot be trusted or parsed.\n */\n verify(body: string, headers: Headers): Promise<Verified | null>\n}\n"],"mappings":";;;;AAGA,IAAa,2BAAb,cAA8C,SAAS;CAM1C;CALX,AAAS,OAAO;CAChB,AAAS,iBAAiB;;CAG1B,YACE,AAAS,QACT,OACA;EACA,MAAM,qBAAqB,UAAU,EAAE,MAAM,CAAC;EAHrC;CAIX;AACF"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://www.schemastore.org/package.json",
|
|
3
3
|
"name": "better-ship",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.19.0",
|
|
5
5
|
"description": "Foundation for applications on Cloudflare Workers: logger, errors, message bus, infrastructure adapters, and UI primitives",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
@@ -47,11 +47,7 @@ export const consoleSink: LogSink = (entry) => {
|
|
|
47
47
|
const errors = entry.error === undefined ? [] : [entry.error]
|
|
48
48
|
if (format === 'pretty-browser') {
|
|
49
49
|
write(
|
|
50
|
-
|
|
51
|
-
'color: #737373',
|
|
52
|
-
'',
|
|
53
|
-
'color: #2563eb',
|
|
54
|
-
'',
|
|
50
|
+
`${entry.timestamp} [${entry.level}] [${entry.module}] ${entry.event}`,
|
|
55
51
|
...values,
|
|
56
52
|
...errors,
|
|
57
53
|
)
|
|
@@ -1,6 +1,2 @@
|
|
|
1
|
-
export { WebhookHandler } from './webhook-handler.ts'
|
|
2
|
-
export {
|
|
3
|
-
WebhookVerificationError,
|
|
4
|
-
type WebhookVerification,
|
|
5
|
-
type WebhookVerifier,
|
|
6
|
-
} from './webhook-verification.ts'
|
|
1
|
+
export { WebhookHandler, type WebhookHandlerOptions } from './webhook-handler.ts'
|
|
2
|
+
export { WebhookVerificationError, type WebhookVerifier } from './webhook-verification.ts'
|
|
@@ -4,26 +4,83 @@ import { createLogger, type Logger } from '@/core'
|
|
|
4
4
|
|
|
5
5
|
import { WebhookVerificationError, type WebhookVerifier } from './webhook-verification.ts'
|
|
6
6
|
|
|
7
|
+
/** Application policy and diagnostics for one webhook body. */
|
|
8
|
+
export type WebhookHandlerOptions = {
|
|
9
|
+
readonly maxBodyBytes: number
|
|
10
|
+
readonly logger?: Pick<Logger, 'warn'>
|
|
11
|
+
}
|
|
12
|
+
|
|
7
13
|
/** Read raw webhook requests and acknowledge effects only after the callback completes. */
|
|
8
|
-
export class WebhookHandler<
|
|
9
|
-
|
|
14
|
+
export class WebhookHandler<Verified> {
|
|
15
|
+
private readonly logger: Pick<Logger, 'warn'>
|
|
16
|
+
private readonly maxBodyBytes: number
|
|
17
|
+
|
|
18
|
+
/** Inject provider verification, the application body limit, and optional diagnostics. */
|
|
10
19
|
constructor(
|
|
11
|
-
private readonly verifier: WebhookVerifier<
|
|
12
|
-
|
|
13
|
-
) {
|
|
20
|
+
private readonly verifier: WebhookVerifier<Verified>,
|
|
21
|
+
options: WebhookHandlerOptions,
|
|
22
|
+
) {
|
|
23
|
+
this.logger = options.logger ?? createLogger('webhook')
|
|
24
|
+
this.maxBodyBytes = options.maxBodyBytes
|
|
25
|
+
}
|
|
14
26
|
|
|
15
27
|
/**
|
|
16
|
-
* Return 401 for authentication failure, 400 for invalid content, and 200 after callback completion or an ignored event.
|
|
28
|
+
* Return 413 for large content, 401 for authentication failure, 400 for invalid content, and 200 after callback completion or an ignored event.
|
|
17
29
|
* Operational and callback failures propagate to the route's error boundary without an acknowledgment.
|
|
18
30
|
*/
|
|
19
|
-
async handle(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
async handle(
|
|
32
|
+
request: Request,
|
|
33
|
+
onVerified: (value: Verified) => Promise<void>,
|
|
34
|
+
): Promise<Response> {
|
|
35
|
+
const body = await this.readBody(request)
|
|
36
|
+
if (body === null) return this.rejectBodyTooLarge()
|
|
37
|
+
let verified: Verified | null
|
|
38
|
+
try {
|
|
39
|
+
verified = await this.verifier.verify(body, request.headers)
|
|
40
|
+
} catch (cause) {
|
|
41
|
+
if (cause instanceof WebhookVerificationError) return this.reject(cause)
|
|
42
|
+
throw cause
|
|
43
|
+
}
|
|
44
|
+
if (verified !== null) await onVerified(verified)
|
|
24
45
|
return new Response(null, { status: StatusCodes.OK })
|
|
25
46
|
}
|
|
26
47
|
|
|
48
|
+
private async readBody(request: Request): Promise<string | null> {
|
|
49
|
+
const contentLength = request.headers.get('content-length')
|
|
50
|
+
if (contentLength !== null && /^\d+$/.test(contentLength)) {
|
|
51
|
+
if (Number(contentLength) > this.maxBodyBytes) return null
|
|
52
|
+
}
|
|
53
|
+
if (request.body === null) return ''
|
|
54
|
+
|
|
55
|
+
const reader = request.body.getReader()
|
|
56
|
+
const decoder = new TextDecoder()
|
|
57
|
+
let body = ''
|
|
58
|
+
let byteCount = 0
|
|
59
|
+
try {
|
|
60
|
+
while (true) {
|
|
61
|
+
const chunk = await reader.read()
|
|
62
|
+
if (chunk.done) return body + decoder.decode()
|
|
63
|
+
byteCount += chunk.value.byteLength
|
|
64
|
+
if (byteCount > this.maxBodyBytes) {
|
|
65
|
+
try {
|
|
66
|
+
await reader.cancel()
|
|
67
|
+
} catch {
|
|
68
|
+
// A cancellation failure must not replace the body-size rejection.
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
body += decoder.decode(chunk.value, { stream: true })
|
|
73
|
+
}
|
|
74
|
+
} finally {
|
|
75
|
+
reader.releaseLock()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private rejectBodyTooLarge(): Response {
|
|
80
|
+
this.logger.warn('webhook_rejected', { reason: 'body_too_large' })
|
|
81
|
+
return new Response(null, { status: StatusCodes.REQUEST_TOO_LONG })
|
|
82
|
+
}
|
|
83
|
+
|
|
27
84
|
private reject(error: WebhookVerificationError): Response {
|
|
28
85
|
this.logger.warn('webhook_rejected', { reason: error.reason })
|
|
29
86
|
const status =
|
|
@@ -14,14 +14,11 @@ export class WebhookVerificationError extends AppError {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/** A verified event, an ignored event, or an invalid request. */
|
|
18
|
-
export type WebhookVerification<Event> =
|
|
19
|
-
| { readonly outcome: 'event'; readonly event: Event }
|
|
20
|
-
| { readonly outcome: 'ignored'; readonly type: string }
|
|
21
|
-
| { readonly outcome: 'rejected'; readonly error: WebhookVerificationError }
|
|
22
|
-
|
|
23
17
|
/** The provider owns signature verification, timestamp tolerance, and payload parsing. */
|
|
24
|
-
export interface WebhookVerifier<
|
|
25
|
-
/**
|
|
26
|
-
|
|
18
|
+
export interface WebhookVerifier<Verified> {
|
|
19
|
+
/**
|
|
20
|
+
* Verify the exact body before parsing it. Return null for an ignored webhook.
|
|
21
|
+
* @throws WebhookVerificationError when the request cannot be trusted or parsed.
|
|
22
|
+
*/
|
|
23
|
+
verify(body: string, headers: Headers): Promise<Verified | null>
|
|
27
24
|
}
|
|
@@ -93,50 +93,34 @@ const EventHeaderSchema = z.object({
|
|
|
93
93
|
})
|
|
94
94
|
|
|
95
95
|
/** Validate signed provider data before projecting it into the public event contract. */
|
|
96
|
-
export function parseEmailEvent(
|
|
97
|
-
payload
|
|
98
|
-
|
|
99
|
-
)
|
|
100
|
-
| { readonly outcome: 'event'; readonly event: EmailEvent }
|
|
101
|
-
| { readonly outcome: 'ignored'; readonly type: string }
|
|
102
|
-
| { readonly outcome: 'invalid' } {
|
|
103
|
-
const header = EventHeaderSchema.safeParse(payload)
|
|
104
|
-
if (!header.success) return { outcome: 'invalid' }
|
|
105
|
-
if (!Object.hasOwn(EMAIL_TYPES, header.data.type))
|
|
106
|
-
return { outcome: 'ignored', type: header.data.type }
|
|
107
|
-
const parsed = EmailPayloadSchema.safeParse(payload)
|
|
108
|
-
if (!parsed.success) return { outcome: 'invalid' }
|
|
109
|
-
const event = parsed.data
|
|
96
|
+
export function parseEmailEvent(payload: WebhookEventPayload, id: string): EmailEvent | null {
|
|
97
|
+
const header = EventHeaderSchema.parse(payload)
|
|
98
|
+
if (!Object.hasOwn(EMAIL_TYPES, header.type)) return null
|
|
99
|
+
const event = EmailPayloadSchema.parse(payload)
|
|
110
100
|
const base: EmailEventBase = {
|
|
111
101
|
id,
|
|
112
102
|
emailId: event.data.email_id,
|
|
113
103
|
recipients: event.data.to,
|
|
114
104
|
subject: event.data.subject,
|
|
115
105
|
tags: event.data.tags,
|
|
116
|
-
occurredAt: header.
|
|
106
|
+
occurredAt: header.created_at,
|
|
117
107
|
}
|
|
118
108
|
switch (event.type) {
|
|
119
109
|
case 'email.bounced':
|
|
120
110
|
return {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
type: event.type,
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
subType: event.data.bounce.subType ?? null,
|
|
128
|
-
message: event.data.bounce.message,
|
|
129
|
-
},
|
|
111
|
+
...base,
|
|
112
|
+
type: event.type,
|
|
113
|
+
bounce: {
|
|
114
|
+
type: parseBounceType(event.data.bounce.type),
|
|
115
|
+
subType: event.data.bounce.subType ?? null,
|
|
116
|
+
message: event.data.bounce.message,
|
|
130
117
|
},
|
|
131
118
|
}
|
|
132
119
|
case 'email.failed':
|
|
133
|
-
return {
|
|
134
|
-
outcome: 'event',
|
|
135
|
-
event: { ...base, type: event.type, reason: event.data.failed.reason },
|
|
136
|
-
}
|
|
120
|
+
return { ...base, type: event.type, reason: event.data.failed.reason }
|
|
137
121
|
case 'email.clicked':
|
|
138
|
-
return {
|
|
122
|
+
return { ...base, type: event.type, link: event.data.click.link }
|
|
139
123
|
default:
|
|
140
|
-
return {
|
|
124
|
+
return { ...base, type: event.type }
|
|
141
125
|
}
|
|
142
126
|
}
|