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.
Files changed (54) hide show
  1. package/dist/{application-CNdzHIXk.js → application-Dg1jIKPe.js} +2 -2
  2. package/dist/{application-CNdzHIXk.js.map → application-Dg1jIKPe.js.map} +1 -1
  3. package/dist/application.js +1 -1
  4. package/dist/better-auth/apple-client-secret.js +1 -1
  5. package/dist/better-auth/auth-client.js +1 -1
  6. package/dist/better-auth/rate-limit.js +1 -1
  7. package/dist/better-auth.js +1 -1
  8. package/dist/billing/client.js +1 -1
  9. package/dist/cloudflare/auth-secondary-storage.js +1 -1
  10. package/dist/cloudflare/durable-object-client.js +1 -1
  11. package/dist/cloudflare/durable-object-errors.js +1 -1
  12. package/dist/cloudflare/kv-client.js +1 -1
  13. package/dist/cloudflare/kv-errors.js +1 -1
  14. package/dist/cloudflare/queue.js +2 -2
  15. package/dist/cloudflare/r2-client.js +1 -1
  16. package/dist/cloudflare/r2-errors.js +1 -1
  17. package/dist/cloudflare/scheduled.js +1 -1
  18. package/dist/cloudflare.js +3 -3
  19. package/dist/{core-UUW2unJd.js → core-BT1dSEQI.js} +2 -2
  20. package/dist/{core-UUW2unJd.js.map → core-BT1dSEQI.js.map} +1 -1
  21. package/dist/core.js +1 -1
  22. package/dist/http.d.ts +15 -7
  23. package/dist/http.d.ts.map +1 -1
  24. package/dist/http.js +50 -11
  25. package/dist/http.js.map +1 -1
  26. package/dist/{outbox-repository-options-CgdqU20T.js → outbox-repository-options-B8TnAE1h.js} +2 -2
  27. package/dist/{outbox-repository-options-CgdqU20T.js.map → outbox-repository-options-B8TnAE1h.js.map} +1 -1
  28. package/dist/postgres.js +3 -3
  29. package/dist/posthog/server.js +1 -1
  30. package/dist/resend.d.ts +3 -3
  31. package/dist/resend.d.ts.map +1 -1
  32. package/dist/resend.js +30 -57
  33. package/dist/resend.js.map +1 -1
  34. package/dist/sentry/client.js +1 -1
  35. package/dist/stripe/payment-service.error.js +1 -1
  36. package/dist/stripe/payment-webhook.d.ts +3 -3
  37. package/dist/stripe/payment-webhook.d.ts.map +1 -1
  38. package/dist/stripe/payment-webhook.js +6 -18
  39. package/dist/stripe/payment-webhook.js.map +1 -1
  40. package/dist/tanstack.js +1 -1
  41. package/dist/{webhook-verification-BuxPzdr4.d.ts → webhook-verification-D1c7BY4q.d.ts} +8 -16
  42. package/dist/webhook-verification-D1c7BY4q.d.ts.map +1 -0
  43. package/dist/{webhook-verification-BBmjkHgI.js → webhook-verification-qSFXv5H3.js} +2 -2
  44. package/dist/webhook-verification-qSFXv5H3.js.map +1 -0
  45. package/package.json +1 -1
  46. package/src/core/console-log-sink.ts +1 -5
  47. package/src/infrastructure/http/index.ts +2 -6
  48. package/src/infrastructure/http/webhook-handler.ts +68 -11
  49. package/src/infrastructure/http/webhook-verification.ts +6 -9
  50. package/src/infrastructure/resend/email-event.ts +14 -30
  51. package/src/infrastructure/resend/resend-webhook.ts +12 -22
  52. package/src/infrastructure/stripe/payment-webhook.ts +7 -20
  53. package/dist/webhook-verification-BBmjkHgI.js.map +0 -1
  54. 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-UUW2unJd.js";
2
- import { t as WebhookVerificationError } from "./webhook-verification-BBmjkHgI.js";
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.safeParse(payload);
166
- if (!header.success) return { outcome: "invalid" };
167
- if (!Object.hasOwn(EMAIL_TYPES, header.data.type)) return {
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.data.created_at
174
+ occurredAt: header.created_at
181
175
  };
182
176
  switch (event.type) {
183
177
  case "email.bounced": return {
184
- outcome: "event",
185
- event: {
186
- ...base,
187
- type: event.type,
188
- bounce: {
189
- type: parseBounceType(event.data.bounce.type),
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
- outcome: "event",
197
- event: {
198
- ...base,
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
- outcome: "event",
205
- event: {
206
- ...base,
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
- outcome: "event",
213
- event: {
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 return rejected; configuration and SDK defects propagate. */
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) return {
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) return {
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
- return {
269
- outcome: "rejected",
270
- error: new WebhookVerificationError("invalid_signature", cause)
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
 
@@ -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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"}
@@ -1,4 +1,4 @@
1
- import { a as setLoggerErrorHook } from "../core-UUW2unJd.js";
1
+ import { a as setLoggerErrorHook } from "../core-BT1dSEQI.js";
2
2
  import * as Sentry from "@sentry/tanstackstart-react";
3
3
  import { isNotFound, isRedirect } from "@tanstack/react-router";
4
4
 
@@ -1,4 +1,4 @@
1
- import { nt as AppError } from "../core-UUW2unJd.js";
1
+ import { nt as AppError } from "../core-BT1dSEQI.js";
2
2
  import Stripe from "stripe";
3
3
 
4
4
  //#region src/infrastructure/stripe/payment-service.error.ts
@@ -1,4 +1,4 @@
1
- import { r as WebhookVerifier, t as WebhookVerification } from "../webhook-verification-BuxPzdr4.js";
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
- /** Reject signature and JSON failures. Operational failures propagate without logging. */
15
- verify(body: string, headers: Headers): Promise<WebhookVerification<Stripe.Event>>;
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":";;;;;;;qBAYa,iCAAiC,gBAAgB,OAAO;mBAGhD;mBACA;mBACA;;EAHnB,YACmB,QAAQ,QACR,uBACA;;EAIb,OAAO,cAAc,SAAS,UAAU,QAAQ,oBAAoB,OAAO"}
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-BBmjkHgI.js";
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
- /** Reject signature and JSON failures. Operational failures propagate without logging. */
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) return {
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) return {
33
- outcome: "rejected",
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 {\n WebhookVerificationError,\n type WebhookVerification,\n type WebhookVerifier,\n} 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 /** Reject signature and JSON failures. Operational failures propagate without logging. */\n async verify(body: string, headers: Headers): Promise<WebhookVerification<Stripe.Event>> {\n const signature = headers.get('stripe-signature')\n if (!signature)\n return { outcome: 'rejected', error: new WebhookVerificationError('missing_headers') }\n try {\n const event = await this.stripe.webhooks.constructEventAsync(\n body,\n signature,\n this.signingSecret,\n this.tolerance,\n Stripe.createSubtleCryptoProvider(),\n )\n return { outcome: 'event', event }\n } catch (cause) {\n if (cause instanceof Stripe.errors.StripeSignatureVerificationError)\n return {\n outcome: 'rejected',\n error: new WebhookVerificationError('invalid_signature', cause),\n }\n if (cause instanceof SyntaxError)\n return {\n outcome: 'rejected',\n error: new WebhookVerificationError('invalid_payload', cause),\n }\n throw cause\n }\n }\n}\n"],"mappings":";;;;;;;;AAYA,IAAa,wBAAb,MAA4E;CAGvD;CACA;CACA;;CAHnB,YACE,AAAiB,QACjB,AAAiB,eACjB,AAAiB,WACjB;EAHiB;EACA;EACA;CAChB;;CAGH,MAAM,OAAO,MAAc,SAA8D;EACvF,MAAM,YAAY,QAAQ,IAAI,kBAAkB;EAChD,IAAI,CAAC,WACH,OAAO;GAAE,SAAS;GAAY,OAAO,IAAI,yBAAyB,iBAAiB;EAAE;EACvF,IAAI;GAQF,OAAO;IAAE,SAAS;IAAS,aAPP,KAAK,OAAO,SAAS,oBACvC,MACA,WACA,KAAK,eACL,KAAK,WACL,OAAO,2BAA2B,CACpC;GACiC;EACnC,SAAS,OAAO;GACd,IAAI,iBAAiB,OAAO,OAAO,kCACjC,OAAO;IACL,SAAS;IACT,OAAO,IAAI,yBAAyB,qBAAqB,KAAK;GAChE;GACF,IAAI,iBAAiB,aACnB,OAAO;IACL,SAAS;IACT,OAAO,IAAI,yBAAyB,mBAAmB,KAAK;GAC9D;GACF,MAAM;EACR;CACF;AACF"}
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-UUW2unJd.js";
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<Event> {
24
- /** Verify the exact body before parsing it. Return invalid requests; throw operational failures. */
25
- verify(body: string, headers: Headers): Promise<WebhookVerification<Event>>;
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 { WebhookVerificationError as n, WebhookVerifier as r, WebhookVerification as t };
29
- //# sourceMappingURL=webhook-verification-BuxPzdr4.d.ts.map
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-UUW2unJd.js";
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-BBmjkHgI.js.map
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.18.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
- `%c${entry.timestamp}%c [${entry.level}] %c[${entry.module}]%c ${entry.event}`,
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<Event> {
9
- /** Inject provider verification and an optional logger with the application's module name. */
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<Event>,
12
- private readonly logger: Pick<Logger, 'warn'> = createLogger('webhook'),
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(request: Request, onEvent: (event: Event) => Promise<void>): Promise<Response> {
20
- const body = await request.text()
21
- const result = await this.verifier.verify(body, request.headers)
22
- if (result.outcome === 'rejected') return this.reject(result.error)
23
- if (result.outcome === 'event') await onEvent(result.event)
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<Event> {
25
- /** Verify the exact body before parsing it. Return invalid requests; throw operational failures. */
26
- verify(body: string, headers: Headers): Promise<WebhookVerification<Event>>
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: WebhookEventPayload,
98
- id: string,
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.data.created_at,
106
+ occurredAt: header.created_at,
117
107
  }
118
108
  switch (event.type) {
119
109
  case 'email.bounced':
120
110
  return {
121
- outcome: 'event',
122
- event: {
123
- ...base,
124
- type: event.type,
125
- bounce: {
126
- type: parseBounceType(event.data.bounce.type),
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 { outcome: 'event', event: { ...base, type: event.type, link: event.data.click.link } }
122
+ return { ...base, type: event.type, link: event.data.click.link }
139
123
  default:
140
- return { outcome: 'event', event: { ...base, type: event.type } }
124
+ return { ...base, type: event.type }
141
125
  }
142
126
  }