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.
- package/dist/{application-Cf9SER-i.js → application-Dg1jIKPe.js} +5 -7
- package/dist/{application-Cf9SER-i.js.map → application-Dg1jIKPe.js.map} +1 -1
- package/dist/application.d.ts +2 -2
- package/dist/application.js +1 -1
- package/dist/better-auth/apple-client-secret.d.ts +1 -1
- package/dist/better-auth/apple-client-secret.js +2 -2
- package/dist/better-auth/apple-client-secret.js.map +1 -1
- package/dist/better-auth/auth-client.js +1 -1
- package/dist/better-auth/rate-limit.d.ts +1 -1
- package/dist/better-auth/rate-limit.js +4 -6
- package/dist/better-auth/rate-limit.js.map +1 -1
- package/dist/better-auth.js +1 -1
- package/dist/billing/client.d.ts +86 -0
- package/dist/billing/client.d.ts.map +1 -0
- package/dist/billing/client.js +77 -0
- package/dist/billing/client.js.map +1 -0
- package/dist/cloudflare/auth-secondary-storage.d.ts +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.d.ts +1 -1
- package/dist/cloudflare/durable-object-errors.js +1 -1
- package/dist/cloudflare/kv-client.js +1 -1
- package/dist/cloudflare/kv-errors.d.ts +1 -1
- package/dist/cloudflare/kv-errors.js +1 -1
- package/dist/cloudflare/queue.d.ts +2 -2
- package/dist/cloudflare/queue.d.ts.map +1 -1
- package/dist/cloudflare/queue.js +10 -12
- package/dist/cloudflare/queue.js.map +1 -1
- package/dist/cloudflare/r2-client.d.ts +70 -0
- package/dist/cloudflare/r2-client.d.ts.map +1 -0
- package/dist/cloudflare/r2-client.js +153 -0
- package/dist/cloudflare/r2-client.js.map +1 -0
- package/dist/cloudflare/r2-errors.d.ts +34 -0
- package/dist/cloudflare/r2-errors.d.ts.map +1 -0
- package/dist/cloudflare/r2-errors.js +90 -0
- package/dist/cloudflare/r2-errors.js.map +1 -0
- package/dist/cloudflare/scheduled.d.ts +1 -1
- package/dist/cloudflare/scheduled.js +2 -2
- package/dist/cloudflare/scheduled.js.map +1 -1
- package/dist/cloudflare.d.ts +3 -3
- package/dist/cloudflare.js +3 -3
- package/dist/{core-ClSP5-V9.js → core-BT1dSEQI.js} +106 -195
- package/dist/core-BT1dSEQI.js.map +1 -0
- package/dist/core.d.ts +2 -2
- package/dist/core.js +2 -2
- package/dist/http.d.ts +16 -8
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +51 -12
- package/dist/http.js.map +1 -1
- package/dist/{index-Cchdg2aQ.d.ts → index-CYHXOH08.d.ts} +2 -2
- package/dist/{index-Cchdg2aQ.d.ts.map → index-CYHXOH08.d.ts.map} +1 -1
- package/dist/{index-DvJUcSW1.d.ts → index-DUFS0SJ_.d.ts} +41 -102
- package/dist/index-DUFS0SJ_.d.ts.map +1 -0
- package/dist/{outbox-repository-options-ICOF8g2h.js → outbox-repository-options-B8TnAE1h.js} +2 -2
- package/dist/{outbox-repository-options-ICOF8g2h.js.map → outbox-repository-options-B8TnAE1h.js.map} +1 -1
- package/dist/{outbox-repository-options-AkihEaik.d.ts → outbox-repository-options-DjBgFhMi.d.ts} +3 -3
- package/dist/{outbox-repository-options-AkihEaik.d.ts.map → outbox-repository-options-DjBgFhMi.d.ts.map} +1 -1
- package/dist/postgres.d.ts +3 -3
- package/dist/postgres.js +3 -3
- package/dist/posthog/metrics.d.ts +1 -1
- package/dist/posthog/server.d.ts +2 -2
- package/dist/posthog/server.js +1 -1
- package/dist/resend.d.ts +5 -5
- 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.d.ts +1 -1
- package/dist/sentry/client.d.ts.map +1 -1
- package/dist/sentry/client.js +10 -4
- package/dist/sentry/client.js.map +1 -1
- package/dist/sentry/server.d.ts +2 -2
- package/dist/sentry/server.js +5 -5
- package/dist/sentry/server.js.map +1 -1
- package/dist/stripe/payment-service.d.ts +52 -0
- package/dist/stripe/payment-service.d.ts.map +1 -0
- package/dist/stripe/payment-service.error.d.ts +13 -0
- package/dist/stripe/payment-service.error.d.ts.map +1 -0
- package/dist/stripe/payment-service.error.js +30 -0
- package/dist/stripe/payment-service.error.js.map +1 -0
- package/dist/stripe/payment-service.js +72 -0
- package/dist/stripe/payment-service.js.map +1 -0
- package/dist/stripe/payment-webhook.d.ts +18 -0
- package/dist/stripe/payment-webhook.d.ts.map +1 -0
- package/dist/stripe/payment-webhook.js +35 -0
- package/dist/stripe/payment-webhook.js.map +1 -0
- package/dist/tanstack.d.ts +1 -1
- package/dist/tanstack.js +4 -4
- package/dist/tanstack.js.map +1 -1
- package/dist/testing/outbox-repository.d.ts +2 -2
- package/dist/testing/payment-service.d.ts +10 -0
- package/dist/testing/payment-service.d.ts.map +1 -0
- package/dist/testing/payment-service.js +76 -0
- package/dist/testing/payment-service.js.map +1 -0
- package/dist/webhook-verification-D1c7BY4q.d.ts +21 -0
- package/dist/webhook-verification-D1c7BY4q.d.ts.map +1 -0
- package/dist/{webhook-verification-D6aGQfNi.js → webhook-verification-qSFXv5H3.js} +2 -2
- package/dist/webhook-verification-qSFXv5H3.js.map +1 -0
- package/package.json +13 -6
- package/src/application/outbox-relay.ts +3 -1
- package/src/core/console-log-sink.ts +61 -0
- package/src/core/index.ts +1 -4
- package/src/core/logger.ts +80 -281
- package/src/infrastructure/better-auth/apple-client-secret.ts +1 -1
- package/src/infrastructure/better-auth/rate-limit.ts +3 -1
- package/src/infrastructure/billing/client.ts +173 -0
- package/src/infrastructure/cloudflare/queue/queue-consumer.ts +8 -9
- package/src/infrastructure/cloudflare/r2-client.ts +162 -0
- package/src/infrastructure/cloudflare/r2-errors.ts +111 -0
- package/src/infrastructure/cloudflare/scheduled/scheduled-handler.ts +1 -1
- package/src/infrastructure/http/index.ts +2 -6
- package/src/infrastructure/http/webhook-handler.ts +69 -12
- 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/sentry/client.ts +6 -3
- package/src/infrastructure/sentry/server.ts +4 -4
- package/src/infrastructure/stripe/payment-service.error.ts +42 -0
- package/src/infrastructure/stripe/payment-service.ts +129 -0
- package/src/infrastructure/stripe/payment-webhook.ts +36 -0
- package/src/infrastructure/tanstack/resolve-failure.ts +3 -2
- package/src/infrastructure/testing/payment-service-contract.ts +91 -0
- package/dist/core-ClSP5-V9.js.map +0 -1
- package/dist/index-DvJUcSW1.d.ts.map +0 -1
- package/dist/webhook-verification-BcQQG-vY.d.ts +0 -29
- package/dist/webhook-verification-BcQQG-vY.d.ts.map +0 -1
- 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', {
|
|
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<
|
|
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
|
-
this.logger.warn('
|
|
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<
|
|
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
|
}
|
|
@@ -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
|
|
39
|
-
async verify(body: string, headers: Headers): Promise<
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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,
|
|
30
|
+
return ({ error, userId, attributes }) => {
|
|
31
31
|
if (isNotFound(error) || isRedirect(error)) return
|
|
32
|
-
|
|
33
|
-
|
|
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,
|
|
30
|
-
if (
|
|
31
|
-
Sentry.captureException(error, { extra:
|
|
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:
|
|
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
|
+
}
|