better-ship 0.17.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 (126) hide show
  1. package/dist/{application-Cf9SER-i.js → application-Dg1jIKPe.js} +5 -7
  2. package/dist/{application-Cf9SER-i.js.map → application-Dg1jIKPe.js.map} +1 -1
  3. package/dist/application.d.ts +2 -2
  4. package/dist/application.js +1 -1
  5. package/dist/better-auth/apple-client-secret.d.ts +1 -1
  6. package/dist/better-auth/apple-client-secret.js +2 -2
  7. package/dist/better-auth/apple-client-secret.js.map +1 -1
  8. package/dist/better-auth/auth-client.js +1 -1
  9. package/dist/better-auth/rate-limit.d.ts +1 -1
  10. package/dist/better-auth/rate-limit.js +4 -6
  11. package/dist/better-auth/rate-limit.js.map +1 -1
  12. package/dist/better-auth.js +1 -1
  13. package/dist/billing/client.d.ts +86 -0
  14. package/dist/billing/client.d.ts.map +1 -0
  15. package/dist/billing/client.js +77 -0
  16. package/dist/billing/client.js.map +1 -0
  17. package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
  18. package/dist/cloudflare/auth-secondary-storage.js +1 -1
  19. package/dist/cloudflare/durable-object-client.js +1 -1
  20. package/dist/cloudflare/durable-object-errors.d.ts +1 -1
  21. package/dist/cloudflare/durable-object-errors.js +1 -1
  22. package/dist/cloudflare/kv-client.js +1 -1
  23. package/dist/cloudflare/kv-errors.d.ts +1 -1
  24. package/dist/cloudflare/kv-errors.js +1 -1
  25. package/dist/cloudflare/queue.d.ts +2 -2
  26. package/dist/cloudflare/queue.d.ts.map +1 -1
  27. package/dist/cloudflare/queue.js +10 -12
  28. package/dist/cloudflare/queue.js.map +1 -1
  29. package/dist/cloudflare/r2-client.d.ts +70 -0
  30. package/dist/cloudflare/r2-client.d.ts.map +1 -0
  31. package/dist/cloudflare/r2-client.js +153 -0
  32. package/dist/cloudflare/r2-client.js.map +1 -0
  33. package/dist/cloudflare/r2-errors.d.ts +34 -0
  34. package/dist/cloudflare/r2-errors.d.ts.map +1 -0
  35. package/dist/cloudflare/r2-errors.js +90 -0
  36. package/dist/cloudflare/r2-errors.js.map +1 -0
  37. package/dist/cloudflare/scheduled.d.ts +1 -1
  38. package/dist/cloudflare/scheduled.js +2 -2
  39. package/dist/cloudflare/scheduled.js.map +1 -1
  40. package/dist/cloudflare.d.ts +3 -3
  41. package/dist/cloudflare.js +3 -3
  42. package/dist/{core-ClSP5-V9.js → core-BT1dSEQI.js} +106 -195
  43. package/dist/core-BT1dSEQI.js.map +1 -0
  44. package/dist/core.d.ts +2 -2
  45. package/dist/core.js +2 -2
  46. package/dist/http.d.ts +16 -8
  47. package/dist/http.d.ts.map +1 -1
  48. package/dist/http.js +51 -12
  49. package/dist/http.js.map +1 -1
  50. package/dist/{index-Cchdg2aQ.d.ts → index-CYHXOH08.d.ts} +2 -2
  51. package/dist/{index-Cchdg2aQ.d.ts.map → index-CYHXOH08.d.ts.map} +1 -1
  52. package/dist/{index-DvJUcSW1.d.ts → index-DUFS0SJ_.d.ts} +41 -102
  53. package/dist/index-DUFS0SJ_.d.ts.map +1 -0
  54. package/dist/{outbox-repository-options-ICOF8g2h.js → outbox-repository-options-B8TnAE1h.js} +2 -2
  55. package/dist/{outbox-repository-options-ICOF8g2h.js.map → outbox-repository-options-B8TnAE1h.js.map} +1 -1
  56. package/dist/{outbox-repository-options-AkihEaik.d.ts → outbox-repository-options-DjBgFhMi.d.ts} +3 -3
  57. package/dist/{outbox-repository-options-AkihEaik.d.ts.map → outbox-repository-options-DjBgFhMi.d.ts.map} +1 -1
  58. package/dist/postgres.d.ts +3 -3
  59. package/dist/postgres.js +3 -3
  60. package/dist/posthog/metrics.d.ts +1 -1
  61. package/dist/posthog/server.d.ts +2 -2
  62. package/dist/posthog/server.js +1 -1
  63. package/dist/resend.d.ts +5 -5
  64. package/dist/resend.d.ts.map +1 -1
  65. package/dist/resend.js +30 -57
  66. package/dist/resend.js.map +1 -1
  67. package/dist/sentry/client.d.ts +1 -1
  68. package/dist/sentry/client.d.ts.map +1 -1
  69. package/dist/sentry/client.js +10 -4
  70. package/dist/sentry/client.js.map +1 -1
  71. package/dist/sentry/server.d.ts +2 -2
  72. package/dist/sentry/server.js +5 -5
  73. package/dist/sentry/server.js.map +1 -1
  74. package/dist/stripe/payment-service.d.ts +52 -0
  75. package/dist/stripe/payment-service.d.ts.map +1 -0
  76. package/dist/stripe/payment-service.error.d.ts +13 -0
  77. package/dist/stripe/payment-service.error.d.ts.map +1 -0
  78. package/dist/stripe/payment-service.error.js +30 -0
  79. package/dist/stripe/payment-service.error.js.map +1 -0
  80. package/dist/stripe/payment-service.js +72 -0
  81. package/dist/stripe/payment-service.js.map +1 -0
  82. package/dist/stripe/payment-webhook.d.ts +18 -0
  83. package/dist/stripe/payment-webhook.d.ts.map +1 -0
  84. package/dist/stripe/payment-webhook.js +35 -0
  85. package/dist/stripe/payment-webhook.js.map +1 -0
  86. package/dist/tanstack.d.ts +1 -1
  87. package/dist/tanstack.js +4 -4
  88. package/dist/tanstack.js.map +1 -1
  89. package/dist/testing/outbox-repository.d.ts +2 -2
  90. package/dist/testing/payment-service.d.ts +10 -0
  91. package/dist/testing/payment-service.d.ts.map +1 -0
  92. package/dist/testing/payment-service.js +76 -0
  93. package/dist/testing/payment-service.js.map +1 -0
  94. package/dist/webhook-verification-D1c7BY4q.d.ts +21 -0
  95. package/dist/webhook-verification-D1c7BY4q.d.ts.map +1 -0
  96. package/dist/{webhook-verification-D6aGQfNi.js → webhook-verification-qSFXv5H3.js} +2 -2
  97. package/dist/webhook-verification-qSFXv5H3.js.map +1 -0
  98. package/package.json +13 -6
  99. package/src/application/outbox-relay.ts +3 -1
  100. package/src/core/console-log-sink.ts +61 -0
  101. package/src/core/index.ts +1 -4
  102. package/src/core/logger.ts +80 -281
  103. package/src/infrastructure/better-auth/apple-client-secret.ts +1 -1
  104. package/src/infrastructure/better-auth/rate-limit.ts +3 -1
  105. package/src/infrastructure/billing/client.ts +173 -0
  106. package/src/infrastructure/cloudflare/queue/queue-consumer.ts +8 -9
  107. package/src/infrastructure/cloudflare/r2-client.ts +162 -0
  108. package/src/infrastructure/cloudflare/r2-errors.ts +111 -0
  109. package/src/infrastructure/cloudflare/scheduled/scheduled-handler.ts +1 -1
  110. package/src/infrastructure/http/index.ts +2 -6
  111. package/src/infrastructure/http/webhook-handler.ts +69 -12
  112. package/src/infrastructure/http/webhook-verification.ts +6 -9
  113. package/src/infrastructure/resend/email-event.ts +14 -30
  114. package/src/infrastructure/resend/resend-webhook.ts +12 -22
  115. package/src/infrastructure/sentry/client.ts +6 -3
  116. package/src/infrastructure/sentry/server.ts +4 -4
  117. package/src/infrastructure/stripe/payment-service.error.ts +42 -0
  118. package/src/infrastructure/stripe/payment-service.ts +129 -0
  119. package/src/infrastructure/stripe/payment-webhook.ts +36 -0
  120. package/src/infrastructure/tanstack/resolve-failure.ts +3 -2
  121. package/src/infrastructure/testing/payment-service-contract.ts +91 -0
  122. package/dist/core-ClSP5-V9.js.map +0 -1
  123. package/dist/index-DvJUcSW1.d.ts.map +0 -1
  124. package/dist/webhook-verification-BcQQG-vY.d.ts +0 -29
  125. package/dist/webhook-verification-BcQQG-vY.d.ts.map +0 -1
  126. package/dist/webhook-verification-D6aGQfNi.js.map +0 -1
@@ -0,0 +1,162 @@
1
+ import { AppError, isAbortError } from '@/core'
2
+
3
+ import { R2Error, R2JsonError, type R2Operation } from './r2-errors.ts'
4
+
5
+ /** Values accepted by the R2 binding. */
6
+ export type R2Value = Parameters<R2Bucket['put']>[1]
7
+
8
+ /** Missing objects, failed conditions, and returned bodies are distinct outcomes. */
9
+ export type R2GetResult =
10
+ | { status: 'missing' }
11
+ | { status: 'precondition-failed'; object: R2Object }
12
+ | { status: 'found'; object: R2ObjectBody }
13
+
14
+ /** A failed condition does not establish whether an earlier attempt committed. */
15
+ export type R2PutResult = { status: 'precondition-failed' } | { status: 'stored'; object: R2Object }
16
+
17
+ /** Untrusted JSON data, with absence separate from a stored null. The application supplies its schema. */
18
+ export type R2JsonResult = { status: 'missing' } | { status: 'found'; value: unknown }
19
+
20
+ /**
21
+ * R2 access with one attempt per operation and typed failures. Inject the bucket at the composition root.
22
+ * Callers own retries, stream replay, and recovery. The client does not log or buffer streamed uploads.
23
+ */
24
+ export class R2Client {
25
+ constructor(private readonly bucket: R2Bucket) {}
26
+
27
+ /** Read native metadata, or return null when the object does not exist. */
28
+ head(key: string): Promise<R2Object | null> {
29
+ return callR2('head', () => this.bucket.head(key))
30
+ }
31
+
32
+ /** Return a native body without consuming it. The consumer owns later body-read failures. */
33
+ async get(key: string, options?: R2GetOptions): Promise<R2GetResult> {
34
+ const object = await callR2('get', () => this.bucket.get(key, options))
35
+ if (object === null) return { status: 'missing' }
36
+ if (!('body' in object)) return { status: 'precondition-failed', object }
37
+ return { status: 'found', object }
38
+ }
39
+
40
+ /** Buffer one stored JSON body. Read failures throw R2Error; invalid JSON throws R2JsonError. */
41
+ async getJson(key: string): Promise<R2JsonResult> {
42
+ const text = await callR2('getJson', async () => {
43
+ const object = await this.bucket.get(key)
44
+ return object === null ? null : await object.text()
45
+ })
46
+ if (text === null) return { status: 'missing' }
47
+ try {
48
+ return { status: 'found', value: JSON.parse(text) }
49
+ } catch (cause) {
50
+ throw new R2JsonError({ operation: 'decode', cause })
51
+ }
52
+ }
53
+
54
+ /** Write once, including conditional writes. Pass streams directly to the binding. */
55
+ async put(key: string, body: R2Value, options?: R2PutOptions): Promise<R2PutResult> {
56
+ const object = await callR2('put', () => this.bucket.put(key, body, options))
57
+ return object === null ? { status: 'precondition-failed' } : { status: 'stored', object }
58
+ }
59
+
60
+ /** Serialize once and default the content type to application/json without changing caller options. */
61
+ async putJson(
62
+ key: string,
63
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- This serialization boundary accepts any value and rejects values without a JSON representation.
64
+ value: unknown,
65
+ options?: R2PutOptions,
66
+ ): Promise<R2PutResult> {
67
+ let text: string | undefined
68
+ try {
69
+ text = JSON.stringify(value)
70
+ } catch (cause) {
71
+ throw new R2JsonError({ operation: 'encode', cause })
72
+ }
73
+ if (text === undefined) throw new R2JsonError({ operation: 'encode', cause: undefined })
74
+ return this.put(key, text, { ...options, httpMetadata: jsonMetadata(options?.httpMetadata) })
75
+ }
76
+
77
+ /** Delete keys in one binding call. Missing keys succeed; R2 enforces its batch limit. */
78
+ delete(keys: string | string[]): Promise<void> {
79
+ return callR2('delete', () => this.bucket.delete(keys))
80
+ }
81
+
82
+ /** Return one native page with the requested metadata, cursor, and delimiter results. */
83
+ list(options?: R2ListOptions): Promise<R2Objects> {
84
+ return callR2('list', () => this.bucket.list(options))
85
+ }
86
+
87
+ /** Create once. A failed response can leave an upload whose ID the caller never received. */
88
+ async createMultipartUpload(
89
+ key: string,
90
+ options?: R2MultipartOptions,
91
+ ): Promise<R2MultipartClient> {
92
+ const upload = await callR2('createMultipartUpload', () =>
93
+ this.bucket.createMultipartUpload(key, options),
94
+ )
95
+ return new R2MultipartClient(upload)
96
+ }
97
+
98
+ /** Construct a handle without I/O. Each subsequent operation checks whether the upload exists. */
99
+ resumeMultipartUpload(key: string, uploadId: string): R2MultipartClient {
100
+ return new R2MultipartClient(this.bucket.resumeMultipartUpload(key, uploadId))
101
+ }
102
+ }
103
+
104
+ /** An upload handle that binds its key and ID once. Every operation makes one attempt. */
105
+ export class R2MultipartClient {
106
+ constructor(private readonly upload: R2MultipartUpload) {}
107
+
108
+ /** The destination object key. */
109
+ get key(): string {
110
+ return this.upload.key
111
+ }
112
+
113
+ /** The provider ID to persist when the application needs to resume this upload. */
114
+ get uploadId(): string {
115
+ return this.upload.uploadId
116
+ }
117
+
118
+ /** Upload one part without buffering or replaying its body. */
119
+ uploadPart(
120
+ partNumber: number,
121
+ body: Exclude<R2Value, null>,
122
+ options?: R2UploadPartOptions,
123
+ ): Promise<R2UploadedPart> {
124
+ return callR2('uploadPart', () => this.upload.uploadPart(partNumber, body, options))
125
+ }
126
+
127
+ /** Sort a copy of the parts and return native metadata after completion. Callers own uncertain outcomes. */
128
+ complete(parts: ReadonlyArray<R2UploadedPart>): Promise<R2Object> {
129
+ return callR2('complete', () =>
130
+ this.upload.complete([...parts].sort((a, b) => a.partNumber - b.partNumber)),
131
+ )
132
+ }
133
+
134
+ /** Ensure no active upload remains. NoSuchUpload succeeds; a completed object remains intact. */
135
+ async abort(): Promise<void> {
136
+ try {
137
+ await callR2('abort', () => this.upload.abort())
138
+ } catch (cause) {
139
+ if (cause instanceof R2Error && cause.operation === 'abort' && cause.r2?.code === 10024)
140
+ return
141
+ throw cause
142
+ }
143
+ }
144
+ }
145
+
146
+ async function callR2<T>(operation: R2Operation, work: () => Promise<T>): Promise<T> {
147
+ try {
148
+ return await work()
149
+ } catch (cause) {
150
+ if (isAbortError(cause) || cause instanceof AppError) throw cause
151
+ throw new R2Error({ operation, cause })
152
+ }
153
+ }
154
+
155
+ function jsonMetadata(metadata: R2HTTPMetadata | Headers | undefined): R2HTTPMetadata | Headers {
156
+ if (metadata instanceof Headers) {
157
+ const headers = new Headers(metadata)
158
+ if (!headers.has('content-type')) headers.set('content-type', 'application/json')
159
+ return headers
160
+ }
161
+ return { ...metadata, contentType: metadata?.contentType ?? 'application/json' }
162
+ }
@@ -0,0 +1,111 @@
1
+ import { z } from 'zod'
2
+
3
+ import { AppError, classifyBoundaryError, isAbortError, type FailureClassification } from '@/core'
4
+
5
+ /** The storage operation that failed. */
6
+ export type R2Operation =
7
+ | 'head'
8
+ | 'get'
9
+ | 'getJson'
10
+ | 'put'
11
+ | 'delete'
12
+ | 'list'
13
+ | 'createMultipartUpload'
14
+ | 'uploadPart'
15
+ | 'complete'
16
+ | 'abort'
17
+
18
+ /** Provider diagnostics from the outer failure, without request bodies or credentials. */
19
+ export type R2Failure = {
20
+ readonly code: number
21
+ readonly action?: string | undefined
22
+ }
23
+
24
+ const R2FailureSchema = z.object({
25
+ code: z.number().int().positive(),
26
+ action: z.string().optional(),
27
+ })
28
+
29
+ // R2 codes describe failures; the caller owns retry decisions and repeat safety.
30
+ const classifications: ReadonlyMap<number, FailureClassification> = new Map([
31
+ [10001, 'transient'],
32
+ [10043, 'transient'],
33
+ [10054, 'transient'],
34
+ [10058, 'transient'],
35
+ [10013, 'transient'],
36
+ [10002, 'terminal'],
37
+ [10003, 'terminal'],
38
+ [10018, 'terminal'],
39
+ [10035, 'terminal'],
40
+ [10042, 'terminal'],
41
+ [10005, 'terminal'],
42
+ [10006, 'terminal'],
43
+ [10008, 'terminal'],
44
+ [10009, 'terminal'],
45
+ [10073, 'terminal'],
46
+ [10007, 'terminal'],
47
+ [10020, 'terminal'],
48
+ [100100, 'terminal'],
49
+ [10012, 'terminal'],
50
+ [10069, 'terminal'],
51
+ [10033, 'terminal'],
52
+ [10014, 'terminal'],
53
+ [10037, 'terminal'],
54
+ [10039, 'terminal'],
55
+ [10031, 'terminal'],
56
+ [10011, 'terminal'],
57
+ [10024, 'terminal'],
58
+ [10025, 'terminal'],
59
+ [10048, 'terminal'],
60
+ ])
61
+
62
+ function extractR2Failure(cause: unknown): R2Failure | undefined {
63
+ if (!(cause instanceof Error)) return undefined
64
+ const parsed = R2FailureSchema.safeParse(cause)
65
+ if (parsed.success) return parsed.data
66
+ // Cloudflare documents the numeric suffix for bindings that omit structured fields.
67
+ const match = /\((\d{5,6})\)\s*$/.exec(cause.message)
68
+ return match?.[1] === undefined ? undefined : { code: Number(match[1]) }
69
+ }
70
+
71
+ function classifyR2Code(cause: unknown): FailureClassification | undefined {
72
+ const failure = extractR2Failure(cause)
73
+ return failure === undefined ? undefined : (classifications.get(failure.code) ?? 'unknown')
74
+ }
75
+
76
+ function classifyR2Error(cause: unknown): FailureClassification {
77
+ if (isAbortError(cause)) return 'terminal'
78
+ if (cause instanceof AppError) return cause.classification
79
+ return classifyBoundaryError({ cause, classifyBoundary: classifyR2Code, includeCloudflare: true })
80
+ }
81
+
82
+ /** A raw binding failure after one attempt. Classification does not establish whether a mutation committed. */
83
+ export class R2Error extends AppError {
84
+ readonly _tag = 'R2Error'
85
+ readonly classification: FailureClassification
86
+ readonly operation: R2Operation
87
+ readonly r2?: R2Failure | undefined
88
+ override readonly cause: unknown
89
+
90
+ constructor(input: { operation: R2Operation; cause: unknown }) {
91
+ super(`R2 ${input.operation} failed`, { cause: input.cause })
92
+ this.operation = input.operation
93
+ this.cause = input.cause
94
+ this.r2 = extractR2Failure(input.cause)
95
+ this.classification = classifyR2Error(input.cause)
96
+ }
97
+ }
98
+
99
+ /** JSON serialization or syntax failed. Binding and body-read failures use R2Error. */
100
+ export class R2JsonError extends AppError {
101
+ readonly _tag = 'R2JsonError'
102
+ readonly classification = 'terminal'
103
+ readonly operation: 'encode' | 'decode'
104
+ override readonly cause: unknown
105
+
106
+ constructor(input: { operation: 'encode' | 'decode'; cause: unknown }) {
107
+ super(`R2 JSON ${input.operation} failed`, { cause: input.cause })
108
+ this.operation = input.operation
109
+ this.cause = input.cause
110
+ }
111
+ }
@@ -45,7 +45,7 @@ export function defineSchedules<const Schedules extends Readonly<Record<string,
45
45
  handler: (runs) => async (controller, deps) => {
46
46
  const { cron } = controller
47
47
  if (!schema.validate(cron)) {
48
- logger.error('scheduled_unknown_cron', { details: { cron } })
48
+ logger.error('scheduled_unknown_cron', { cron })
49
49
  return
50
50
  }
51
51
  await runs[cron](deps, new Date(controller.scheduledTime))
@@ -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,28 +4,85 @@ 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
- this.logger.warn('Webhook rejected', { reason: error.reason })
85
+ this.logger.warn('webhook_rejected', { reason: error.reason })
29
86
  const status =
30
87
  error.reason === 'missing_headers' || error.reason === 'invalid_signature'
31
88
  ? StatusCodes.UNAUTHORIZED
@@ -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
  }
@@ -1,11 +1,7 @@
1
1
  import type { Resend } from 'resend'
2
2
  import { z } from 'zod'
3
3
 
4
- import {
5
- WebhookVerificationError,
6
- type WebhookVerification,
7
- type WebhookVerifier,
8
- } from '../http/webhook-verification.ts'
4
+ import { WebhookVerificationError, type WebhookVerifier } from '../http/webhook-verification.ts'
9
5
  import { parseEmailEvent, type EmailEvent } from './email-event.ts'
10
6
 
11
7
  // Separate standardwebhooks versions have different prototypes; its error name remains the same.
@@ -35,13 +31,12 @@ export class ResendWebhook implements WebhookVerifier<EmailEvent> {
35
31
  private readonly verifier: () => Promise<ResendWebhookClient>,
36
32
  ) {}
37
33
 
38
- /** Verify the raw body and Svix headers. Invalid requests return rejected; configuration and SDK defects propagate. */
39
- async verify(body: string, headers: Headers): Promise<WebhookVerification<EmailEvent>> {
34
+ /** Verify the raw body and Svix headers. Invalid requests throw typed errors; other failures propagate. */
35
+ async verify(body: string, headers: Headers): Promise<EmailEvent | null> {
40
36
  const id = headers.get('svix-id')
41
37
  const timestamp = headers.get('svix-timestamp')
42
38
  const signature = headers.get('svix-signature')
43
- if (!id || !timestamp || !signature)
44
- return { outcome: 'rejected', error: new WebhookVerificationError('missing_headers') }
39
+ if (!id || !timestamp || !signature) throw new WebhookVerificationError('missing_headers')
45
40
  const client = await (this.client ??= this.verifier())
46
41
  let payload: ReturnType<ResendWebhookClient['verify']>
47
42
  try {
@@ -51,20 +46,15 @@ export class ResendWebhook implements WebhookVerifier<EmailEvent> {
51
46
  webhookSecret: this.webhookSecret,
52
47
  })
53
48
  } catch (cause) {
54
- if (cause instanceof SyntaxError)
55
- return {
56
- outcome: 'rejected',
57
- error: new WebhookVerificationError('invalid_payload', cause),
58
- }
49
+ if (cause instanceof SyntaxError) throw new WebhookVerificationError('invalid_payload', cause)
59
50
  if (!SignatureErrorSchema.safeParse(cause).success) throw cause
60
- return {
61
- outcome: 'rejected',
62
- error: new WebhookVerificationError('invalid_signature', cause),
63
- }
51
+ throw new WebhookVerificationError('invalid_signature', cause)
52
+ }
53
+ try {
54
+ return parseEmailEvent(payload, id)
55
+ } catch (cause) {
56
+ if (cause instanceof z.ZodError) throw new WebhookVerificationError('invalid_payload', cause)
57
+ throw cause
64
58
  }
65
- const result = parseEmailEvent(payload, id)
66
- if (result.outcome === 'invalid')
67
- return { outcome: 'rejected', error: new WebhookVerificationError('invalid_payload') }
68
- return result
69
59
  }
70
60
  }
@@ -27,10 +27,13 @@ export function createSentryClientOptions(options: SentryClientOptions): Sentry.
27
27
 
28
28
  /** Forward logged browser errors once, excluding TanStack navigation control flow. */
29
29
  export function createSentryLoggerErrorHook(): LoggerErrorHook {
30
- return ({ error, context }) => {
30
+ return ({ error, userId, attributes }) => {
31
31
  if (isNotFound(error) || isRedirect(error)) return
32
- const captured = error instanceof Error ? error : new Error('Logger received a non-Error value')
33
- Sentry.captureException(captured, { extra: context })
32
+ if (userId !== undefined) {
33
+ Sentry.captureException(error, { extra: attributes, user: { id: userId } })
34
+ return
35
+ }
36
+ Sentry.captureException(error, { extra: attributes })
34
37
  }
35
38
  }
36
39
 
@@ -26,12 +26,12 @@ export function createSentryOptions(options: SentryServerOptions): Sentry.Cloudf
26
26
 
27
27
  /** Register with setLoggerErrorHook once at startup; an explicit user applies only to this report. */
28
28
  export function createSentryLoggerErrorHook(): LoggerErrorHook {
29
- return ({ error, distinctId, context }) => {
30
- if (distinctId !== undefined) {
31
- Sentry.captureException(error, { extra: context, user: { id: distinctId } })
29
+ return ({ error, userId, attributes }) => {
30
+ if (userId !== undefined) {
31
+ Sentry.captureException(error, { extra: attributes, user: { id: userId } })
32
32
  return
33
33
  }
34
- Sentry.captureException(error, { extra: context })
34
+ Sentry.captureException(error, { extra: attributes })
35
35
  }
36
36
  }
37
37
 
@@ -0,0 +1,42 @@
1
+ import Stripe from 'stripe'
2
+
3
+ import { AppError, type FailureClassification } from '@/core'
4
+
5
+ /** A final Stripe request failure. The caller logs once; native details remain in cause. */
6
+ export class PaymentServiceError extends AppError {
7
+ readonly _tag = 'PaymentServiceError'
8
+ readonly classification: FailureClassification
9
+
10
+ /** Classify the final SDK error without scheduling another payment attempt. */
11
+ constructor(
12
+ readonly operation: string,
13
+ cause: Stripe.errors.StripeError,
14
+ ) {
15
+ super(`Stripe request failed: ${operation}`, { cause })
16
+ this.classification = classifyStripeError(cause)
17
+ }
18
+ }
19
+
20
+ function classifyStripeError(error: Stripe.errors.StripeError): FailureClassification {
21
+ if (error.headers?.['stripe-should-retry'] === 'false') return 'terminal'
22
+ const status = error.statusCode
23
+ if (status !== undefined && status >= 400 && status < 500 && status !== 409 && status !== 429)
24
+ return 'terminal'
25
+ if (
26
+ error instanceof Stripe.errors.StripeAuthenticationError ||
27
+ error instanceof Stripe.errors.StripePermissionError ||
28
+ error instanceof Stripe.errors.StripeInvalidRequestError ||
29
+ error instanceof Stripe.errors.StripeIdempotencyError
30
+ )
31
+ return 'terminal'
32
+ if (error.headers?.['stripe-should-retry'] === 'true') return 'transient'
33
+ if (status === 409 || status === 429 || (status !== undefined && status >= 500))
34
+ return 'transient'
35
+ if (
36
+ error instanceof Stripe.errors.StripeConnectionError ||
37
+ error instanceof Stripe.errors.StripeAPIError ||
38
+ error instanceof Stripe.errors.StripeRateLimitError
39
+ )
40
+ return 'transient'
41
+ return 'unknown'
42
+ }