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
package/src/core/logger.ts
CHANGED
|
@@ -1,28 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
* Module-scoped logging for every host.
|
|
3
|
-
*
|
|
4
|
-
* A logger emits one plain-data entry per log. The default sink hands it to `console` as an
|
|
5
|
-
* entry object for a log service or as a colored line for a person, selected at startup.
|
|
6
|
-
*/
|
|
1
|
+
import { consoleSink, setConsoleLogFormat } from './console-log-sink.ts'
|
|
7
2
|
|
|
8
|
-
/**
|
|
3
|
+
/** One JSON-compatible value in a normal log field. */
|
|
9
4
|
export type LogValue =
|
|
10
5
|
| string
|
|
11
6
|
| number
|
|
12
7
|
| boolean
|
|
13
8
|
| null
|
|
14
|
-
|
|
|
15
|
-
|
|
|
16
|
-
| LogFields
|
|
9
|
+
| readonly LogValue[]
|
|
10
|
+
| { readonly [key: string]: LogValue }
|
|
17
11
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
12
|
+
/** Flat fields for one log call. Only `error` can contain an arbitrary value. */
|
|
13
|
+
// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- A logger accepts caller-owned fields at its public boundary.
|
|
14
|
+
export type LogFields = Readonly<Record<string, unknown>>
|
|
20
15
|
|
|
21
|
-
/**
|
|
22
|
-
* The supported log severity levels, in ascending order.
|
|
23
|
-
*
|
|
24
|
-
* A const object, not an enum, so a parsed environment value typed `'INFO'` is a `LogLevel`.
|
|
25
|
-
*/
|
|
16
|
+
/** Supported log levels in ascending order. */
|
|
26
17
|
export const LogLevel = {
|
|
27
18
|
DEBUG: 'DEBUG',
|
|
28
19
|
INFO: 'INFO',
|
|
@@ -31,303 +22,111 @@ export const LogLevel = {
|
|
|
31
22
|
} as const
|
|
32
23
|
export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]
|
|
33
24
|
|
|
34
|
-
/**
|
|
35
|
-
export type
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
25
|
+
/** The minimum emitted level, or `OFF` for no console output. */
|
|
26
|
+
export type LogThreshold = LogLevel | 'OFF'
|
|
27
|
+
|
|
28
|
+
/** Explicit output formats for production, browser development, and terminal development. */
|
|
29
|
+
export type LogFormat = 'json' | 'pretty-browser' | 'pretty-terminal'
|
|
30
|
+
|
|
31
|
+
/** Process-wide logger settings supplied at startup. */
|
|
32
|
+
export interface LoggerSettings {
|
|
33
|
+
readonly level?: LogThreshold
|
|
34
|
+
readonly format?: LogFormat
|
|
42
35
|
}
|
|
43
36
|
|
|
44
|
-
/** One
|
|
37
|
+
/** One structured record sent to a log sink. */
|
|
45
38
|
export type LogEntry = {
|
|
46
39
|
readonly timestamp: string
|
|
47
40
|
readonly level: LogLevel
|
|
48
41
|
readonly module: string
|
|
49
|
-
readonly
|
|
50
|
-
|
|
42
|
+
readonly event: string
|
|
43
|
+
// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- The entry keeps caller-owned fields for its sink.
|
|
44
|
+
readonly attributes: LogFields
|
|
51
45
|
readonly userId?: string
|
|
52
|
-
/** Present on `Logger.error` entries that carried an error. */
|
|
53
|
-
readonly error?: SerializedError
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** The context an error log carries. */
|
|
57
|
-
export type ErrorLogContext = {
|
|
58
46
|
readonly error?: unknown
|
|
59
|
-
readonly userId?: string
|
|
60
|
-
readonly details?: LogFields
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** What a logger lets through: a level and everything above it, or nothing. */
|
|
64
|
-
export type LogThreshold = LogLevel | 'OFF'
|
|
65
|
-
|
|
66
|
-
/** Overrides the application settings for one logger. */
|
|
67
|
-
export interface LoggerConfig {
|
|
68
|
-
readonly level?: LogThreshold
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** `json` writes the entry object. `pretty` writes one readable line for a person. */
|
|
72
|
-
export type LogFormat = 'json' | 'pretty'
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* The application settings, set once at the composition root.
|
|
76
|
-
*
|
|
77
|
-
* An omitted field keeps its default. An explicit `undefined` is a compile error, so the root
|
|
78
|
-
* parses an environment value before passing it. The logger reads no environment itself.
|
|
79
|
-
*/
|
|
80
|
-
export interface LoggerSettings extends LoggerConfig {
|
|
81
|
-
readonly format?: LogFormat
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// The production server setting, so a root that never configures still logs safely.
|
|
85
|
-
const DEFAULT_SETTINGS: Required<LoggerSettings> = {
|
|
86
|
-
level: LogLevel.INFO,
|
|
87
|
-
format: 'json',
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// The application configures once at startup; loggers can exist before startup completes.
|
|
91
|
-
let settings = DEFAULT_SETTINGS
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Set application settings, including for existing loggers. Per-logger overrides take priority.
|
|
95
|
-
* Call at startup, never per request. An omitted field resets to INFO and JSON output.
|
|
96
|
-
*/
|
|
97
|
-
export function configureLogger(config: LoggerSettings): void {
|
|
98
|
-
settings = {
|
|
99
|
-
level: config.level ?? DEFAULT_SETTINGS.level,
|
|
100
|
-
format: config.format ?? DEFAULT_SETTINGS.format,
|
|
101
|
-
}
|
|
102
47
|
}
|
|
103
48
|
|
|
104
|
-
/** The error
|
|
49
|
+
/** The original error and fields sent to an error service. */
|
|
105
50
|
export interface ErrorCaptureEntry {
|
|
106
51
|
readonly error: unknown
|
|
107
|
-
readonly
|
|
108
|
-
|
|
52
|
+
readonly userId: string | undefined
|
|
53
|
+
// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- Sentry accepts the caller-owned fields as extra context.
|
|
54
|
+
readonly attributes: LogFields
|
|
109
55
|
}
|
|
110
56
|
|
|
111
|
-
/** A function that forwards one
|
|
57
|
+
/** A function that forwards one error to an error service. */
|
|
112
58
|
export type LoggerErrorHook = (entry: ErrorCaptureEntry) => void
|
|
113
59
|
|
|
114
|
-
/**
|
|
60
|
+
/** A destination for structured log records. */
|
|
115
61
|
export type LogSink = (entry: LogEntry) => void
|
|
116
62
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
*/
|
|
124
|
-
export function setLoggerErrorHook(fn: LoggerErrorHook): void {
|
|
125
|
-
errorHook = fn
|
|
63
|
+
/** The common flat logger API for all levels. */
|
|
64
|
+
export interface Logger {
|
|
65
|
+
debug(event: string, fields?: LogFields): void
|
|
66
|
+
info(event: string, fields?: LogFields): void
|
|
67
|
+
warn(event: string, fields?: LogFields): void
|
|
68
|
+
error(event: string, fields?: LogFields): void
|
|
126
69
|
}
|
|
127
70
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
[LogLevel.WARN]: 'warn',
|
|
133
|
-
[LogLevel.ERROR]: 'error',
|
|
134
|
-
} as const satisfies Record<LogLevel, 'debug' | 'info' | 'warn' | 'error'>
|
|
135
|
-
|
|
136
|
-
// Built from the code point so no raw control character sits in the source.
|
|
137
|
-
const ESC = String.fromCharCode(27)
|
|
138
|
-
const ANSI_RESET = `${ESC}[0m`
|
|
139
|
-
const ANSI_GRAY = `${ESC}[90m`
|
|
140
|
-
const ANSI_BLUE = `${ESC}[34m`
|
|
141
|
-
|
|
142
|
-
// Severity reads at a glance: quiet levels stay dim, and an error is the only bold line.
|
|
143
|
-
const LEVEL_ANSI = {
|
|
144
|
-
DEBUG: `${ESC}[2;36m`,
|
|
145
|
-
INFO: ANSI_BLUE,
|
|
146
|
-
WARN: `${ESC}[33m`,
|
|
147
|
-
ERROR: `${ESC}[1;31m`,
|
|
148
|
-
} satisfies Record<LogLevel, string>
|
|
71
|
+
const LEVELS = Object.values(LogLevel)
|
|
72
|
+
let threshold: LogThreshold = LogLevel.INFO
|
|
73
|
+
let logSink: LogSink = consoleSink
|
|
74
|
+
let errorHook: LoggerErrorHook | null = null
|
|
149
75
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// A browser console colors by method on its own; a terminal needs ANSI to do the same.
|
|
155
|
-
if ('window' in globalThis) return `${entry.timestamp} ${level} ${module} ${entry.message}`
|
|
156
|
-
const timestamp = `${ANSI_GRAY}${entry.timestamp}${ANSI_RESET}`
|
|
157
|
-
const coloredLevel = `${LEVEL_ANSI[entry.level]}${level}${ANSI_RESET}`
|
|
158
|
-
const coloredModule = `${ANSI_BLUE}${module}${ANSI_RESET}`
|
|
159
|
-
return `${timestamp} ${coloredLevel} ${coloredModule} ${entry.message}`
|
|
76
|
+
/** Set the process-wide level and output format. Omitted fields restore production defaults. */
|
|
77
|
+
export function configureLogger(settings: LoggerSettings): void {
|
|
78
|
+
threshold = settings.level ?? LogLevel.INFO
|
|
79
|
+
setConsoleLogFormat(settings.format ?? 'json')
|
|
160
80
|
}
|
|
161
81
|
|
|
162
|
-
/**
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
*/
|
|
166
|
-
export const consoleSink: LogSink = (entry) => {
|
|
167
|
-
// Resolved per call, so a console replaced after import still receives the output.
|
|
168
|
-
const write = console[CONSOLE_METHOD[entry.level]]
|
|
169
|
-
if (settings.format === 'json') {
|
|
170
|
-
write(entry)
|
|
171
|
-
return
|
|
172
|
-
}
|
|
173
|
-
const line = prettyLine(entry)
|
|
174
|
-
const fields =
|
|
175
|
-
entry.userId === undefined ? entry.details : { ...entry.details, userId: entry.userId }
|
|
176
|
-
const details = Object.keys(fields).length === 0 ? [] : [fields]
|
|
177
|
-
if (entry.error === undefined) {
|
|
178
|
-
write(line, ...details)
|
|
179
|
-
return
|
|
180
|
-
}
|
|
181
|
-
// A stack inside an object prints as one quoted string; as its own argument it prints as lines.
|
|
182
|
-
const { stack, ...error } = entry.error
|
|
183
|
-
if (stack === undefined) write(line, ...details, error)
|
|
184
|
-
else write(line, ...details, error, `\n${stack}`)
|
|
82
|
+
/** Set the process-wide log destination. */
|
|
83
|
+
export function setLogSink(sink: LogSink): void {
|
|
84
|
+
logSink = sink
|
|
185
85
|
}
|
|
186
86
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
* Send every log entry somewhere other than `console`.
|
|
191
|
-
*
|
|
192
|
-
* Set once at the process entry point, never per module. A logger is created
|
|
193
|
-
* by name and nothing else, so its destination is a fact about the process.
|
|
194
|
-
*/
|
|
195
|
-
export function setLogSink(fn: LogSink): void {
|
|
196
|
-
logSink = fn
|
|
87
|
+
/** Set or clear the process-wide error service hook. */
|
|
88
|
+
export function setLoggerErrorHook(hook: LoggerErrorHook | null): void {
|
|
89
|
+
errorHook = hook
|
|
197
90
|
}
|
|
198
91
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
* Diagnostics worth keeping off an error, named one by one.
|
|
204
|
-
*
|
|
205
|
-
* An allowlist rather than every own property: an error raised by a library we
|
|
206
|
-
* do not control may hang a request or a user payload off itself, and a log is
|
|
207
|
-
* the wrong place to discover that. `_tag` and `classification` are what every `AppError`
|
|
208
|
-
* declares. `remote` and the three flags after it are the ones workerd sets itself.
|
|
209
|
-
*/
|
|
210
|
-
const KEPT_ERROR_FIELDS = [
|
|
211
|
-
'_tag',
|
|
212
|
-
'classification',
|
|
213
|
-
'code',
|
|
214
|
-
'operation',
|
|
215
|
-
'status',
|
|
216
|
-
'statusCode',
|
|
217
|
-
'remote',
|
|
218
|
-
'retryable',
|
|
219
|
-
'overloaded',
|
|
220
|
-
'durableObjectReset',
|
|
221
|
-
] as const
|
|
222
|
-
|
|
223
|
-
type ErrorDiagnostics = Error & { readonly [K in (typeof KEPT_ERROR_FIELDS)[number]]?: LogValue }
|
|
224
|
-
|
|
225
|
-
type MutableSerializedError = { -readonly [K in keyof SerializedError]: SerializedError[K] }
|
|
226
|
-
|
|
227
|
-
const serializeError = (error: Error, depth = 0): SerializedError => {
|
|
228
|
-
if (depth > MAX_ERROR_DEPTH) return { name: 'Error', message: '[error nesting truncated]' }
|
|
229
|
-
|
|
230
|
-
// Every field is optional, so an `Error` is already one of these.
|
|
231
|
-
const carrier: ErrorDiagnostics = error
|
|
232
|
-
const kept = KEPT_ERROR_FIELDS.filter((key) => carrier[key] !== undefined)
|
|
233
|
-
const serialized: MutableSerializedError = { name: error.name, message: error.message }
|
|
234
|
-
if (error.stack !== undefined) serialized.stack = error.stack
|
|
235
|
-
if (kept.length > 0)
|
|
236
|
-
serialized.fields = Object.fromEntries(kept.map((key) => [key, carrier[key]]))
|
|
237
|
-
if (error.cause instanceof Error) serialized.cause = serializeError(error.cause, depth + 1)
|
|
238
|
-
else if (error.cause !== undefined)
|
|
239
|
-
serialized.cause = { name: 'Error', message: '[non-error cause]' }
|
|
240
|
-
if (error instanceof AggregateError) {
|
|
241
|
-
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- AggregateError accepts arbitrary values; instanceof Error validates each member.
|
|
242
|
-
serialized.errors = error.errors.map((failure: unknown) =>
|
|
243
|
-
failure instanceof Error
|
|
244
|
-
? serializeError(failure, depth + 1)
|
|
245
|
-
: { name: 'NonError', message: '[non-error aggregate member]' },
|
|
246
|
-
)
|
|
247
|
-
}
|
|
248
|
-
return serialized
|
|
92
|
+
/* oxlint-disable anti-slop/no-runtime-typeof -- The logger validates its reserved userId field. */
|
|
93
|
+
function splitFields(fields: LogFields): ErrorCaptureEntry {
|
|
94
|
+
const { error, userId, ...attributes } = fields
|
|
95
|
+
return { error, userId: typeof userId === 'string' ? userId : undefined, attributes }
|
|
249
96
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
private log(
|
|
264
|
-
level: LogLevel,
|
|
265
|
-
message: string,
|
|
266
|
-
details: LogFields,
|
|
267
|
-
error?: SerializedError,
|
|
268
|
-
userId?: string,
|
|
269
|
-
): void {
|
|
270
|
-
const threshold = this.config.level ?? settings.level
|
|
271
|
-
if (threshold === 'OFF') return
|
|
272
|
-
if (LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf(threshold)) return
|
|
273
|
-
|
|
274
|
-
let entry: LogEntry = {
|
|
275
|
-
timestamp: new Date().toISOString(),
|
|
276
|
-
level,
|
|
277
|
-
module: this.module,
|
|
278
|
-
message,
|
|
279
|
-
details,
|
|
280
|
-
}
|
|
281
|
-
if (userId !== undefined) entry = { ...entry, userId }
|
|
282
|
-
logSink(error === undefined ? entry : { ...entry, error })
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/** Write a debug log. */
|
|
286
|
-
debug(message: string, details: LogFields = {}): void {
|
|
287
|
-
this.log(LogLevel.DEBUG, message, details)
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/** Write an information log. */
|
|
291
|
-
info(message: string, details: LogFields = {}): void {
|
|
292
|
-
this.log(LogLevel.INFO, message, details)
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/** Write a warning log. */
|
|
296
|
-
warn(message: string, details: LogFields = {}): void {
|
|
297
|
-
this.log(LogLevel.WARN, message, details)
|
|
97
|
+
/* oxlint-enable anti-slop/no-runtime-typeof */
|
|
98
|
+
|
|
99
|
+
function writeLog(module: string, level: LogLevel, event: string, fields: LogFields): void {
|
|
100
|
+
if (threshold === 'OFF' || LEVELS.indexOf(level) < LEVELS.indexOf(threshold)) return
|
|
101
|
+
|
|
102
|
+
const { error, userId, attributes } = splitFields(fields)
|
|
103
|
+
let entry: LogEntry = { timestamp: new Date().toISOString(), level, module, event, attributes }
|
|
104
|
+
if (userId !== undefined) entry = { ...entry, userId }
|
|
105
|
+
if (error !== undefined) entry = { ...entry, error }
|
|
106
|
+
try {
|
|
107
|
+
logSink(entry)
|
|
108
|
+
} catch {
|
|
109
|
+
// A diagnostic failure must not change application control flow.
|
|
298
110
|
}
|
|
111
|
+
}
|
|
299
112
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if (thrown instanceof Error) {
|
|
311
|
-
error = serializeError(thrown)
|
|
312
|
-
} else if (thrown !== undefined) {
|
|
313
|
-
// Not an `Error`, so there is no name or stack to keep, only its JSON form.
|
|
314
|
-
let json: string
|
|
113
|
+
/** Create a logger for one module. */
|
|
114
|
+
export function createLogger(module: string): Logger {
|
|
115
|
+
return {
|
|
116
|
+
debug: (event, fields = {}) => writeLog(module, LogLevel.DEBUG, event, fields),
|
|
117
|
+
info: (event, fields = {}) => writeLog(module, LogLevel.INFO, event, fields),
|
|
118
|
+
warn: (event, fields = {}) => writeLog(module, LogLevel.WARN, event, fields),
|
|
119
|
+
error: (event, fields = {}) => {
|
|
120
|
+
writeLog(module, LogLevel.ERROR, event, fields)
|
|
121
|
+
const capture = splitFields(fields)
|
|
122
|
+
if (errorHook === null || capture.error === undefined) return
|
|
315
123
|
try {
|
|
316
|
-
|
|
124
|
+
errorHook(capture)
|
|
317
125
|
} catch {
|
|
318
|
-
|
|
126
|
+
// An error service failure must not change application control flow.
|
|
319
127
|
}
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
this.log(LogLevel.ERROR, message, details, error, context.userId)
|
|
323
|
-
|
|
324
|
-
if (errorHook !== null && thrown !== undefined) {
|
|
325
|
-
errorHook({ error: thrown, distinctId: context.userId, context: details })
|
|
326
|
-
}
|
|
128
|
+
},
|
|
327
129
|
}
|
|
328
130
|
}
|
|
329
131
|
|
|
330
|
-
|
|
331
|
-
export function createLogger(module: string, config?: LoggerConfig): Logger {
|
|
332
|
-
return new Logger(module, config)
|
|
333
|
-
}
|
|
132
|
+
export { consoleSink }
|
|
@@ -56,7 +56,7 @@ export function generateAppleClientSecret(config: AppleKeyConfig): string {
|
|
|
56
56
|
} catch (error) {
|
|
57
57
|
if (!appleKeyFailureLogged) {
|
|
58
58
|
appleKeyFailureLogged = true
|
|
59
|
-
logger.error('apple_private_key_invalid', { error,
|
|
59
|
+
logger.error('apple_private_key_invalid', { error, keyId: config.appleKeyId })
|
|
60
60
|
}
|
|
61
61
|
return ''
|
|
62
62
|
}
|
|
@@ -103,7 +103,9 @@ export function createAuthRateLimitStorage(
|
|
|
103
103
|
if (failureMode === 'block') throw new AuthRateLimitError(cause)
|
|
104
104
|
logger.error('rate_limit_binding_failed', {
|
|
105
105
|
error: cause,
|
|
106
|
-
|
|
106
|
+
window: rule.window,
|
|
107
|
+
max: rule.max,
|
|
108
|
+
failureMode,
|
|
107
109
|
})
|
|
108
110
|
return { allowed: true, retryAfter: null }
|
|
109
111
|
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { stripeClient } from '@better-auth/stripe/client'
|
|
2
|
+
import type { createAuthClient } from 'better-auth/client'
|
|
3
|
+
|
|
4
|
+
import { AppError, type FailureClassification } from '@/core'
|
|
5
|
+
|
|
6
|
+
type AuthClient = ReturnType<
|
|
7
|
+
typeof createAuthClient<{
|
|
8
|
+
plugins: [ReturnType<typeof stripeClient<{ subscription: true }>>]
|
|
9
|
+
}>
|
|
10
|
+
>
|
|
11
|
+
|
|
12
|
+
/** Native Better Auth subscription operations used by the browser billing service. */
|
|
13
|
+
export type SubscriptionBillingClient = Pick<
|
|
14
|
+
AuthClient['subscription'],
|
|
15
|
+
'upgrade' | 'billingPortal'
|
|
16
|
+
>
|
|
17
|
+
|
|
18
|
+
/** Native upgrade input. Navigation remains with the UI. */
|
|
19
|
+
export type ChangePlanInput = Omit<
|
|
20
|
+
NonNullable<Parameters<SubscriptionBillingClient['upgrade']>[0]>,
|
|
21
|
+
'disableRedirect'
|
|
22
|
+
>
|
|
23
|
+
|
|
24
|
+
/** Native portal input. Navigation remains with the UI. */
|
|
25
|
+
export type BillingPortalInput = Omit<
|
|
26
|
+
NonNullable<Parameters<SubscriptionBillingClient['billingPortal']>[0]>,
|
|
27
|
+
'disableRedirect'
|
|
28
|
+
>
|
|
29
|
+
|
|
30
|
+
/** Native successful subscription response, including portal or Checkout data. */
|
|
31
|
+
export type ChangePlanResult = NonNullable<
|
|
32
|
+
Awaited<ReturnType<SubscriptionBillingClient['upgrade']>>['data']
|
|
33
|
+
>
|
|
34
|
+
|
|
35
|
+
/** Native successful portal response. */
|
|
36
|
+
export type BillingPortalResult = NonNullable<
|
|
37
|
+
Awaited<ReturnType<SubscriptionBillingClient['billingPortal']>>['data']
|
|
38
|
+
>
|
|
39
|
+
|
|
40
|
+
/** Native Better Auth billing failure; its code remains available to application UI. */
|
|
41
|
+
export type BillingApiError = NonNullable<
|
|
42
|
+
Awaited<ReturnType<SubscriptionBillingClient['upgrade']>>['error']
|
|
43
|
+
>
|
|
44
|
+
|
|
45
|
+
/** A Better Auth billing endpoint rejected the request. The browser boundary owns reporting. */
|
|
46
|
+
export class BillingRequestError extends AppError {
|
|
47
|
+
readonly _tag = 'BillingRequestError'
|
|
48
|
+
readonly classification: FailureClassification
|
|
49
|
+
readonly code: BillingApiError['code']
|
|
50
|
+
|
|
51
|
+
/** Preserve the native code and cause without copying provider metadata. */
|
|
52
|
+
constructor(
|
|
53
|
+
readonly operation: 'subscription.upgrade' | 'subscription.billingPortal',
|
|
54
|
+
cause: BillingApiError,
|
|
55
|
+
) {
|
|
56
|
+
super('Billing request failed', { cause })
|
|
57
|
+
this.code = cause.code
|
|
58
|
+
this.classification = cause.status === 429 || cause.status >= 500 ? 'transient' : 'terminal'
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// oxlint-disable-next-line anti-slop/no-unknown-returns -- Generic constraint only; service methods retain each callback's exact return type.
|
|
63
|
+
type BillingCallback = (this: void, ...args: never[]) => Promise<unknown>
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Supply only the capabilities the application uses. API callbacks must not depend on `this`.
|
|
67
|
+
* Their parameters, results, abort signals, and typed rejections remain application-owned.
|
|
68
|
+
*/
|
|
69
|
+
export type BillingOptions = {
|
|
70
|
+
readonly subscriptions?: SubscriptionBillingClient
|
|
71
|
+
readonly createTopUp?: BillingCallback
|
|
72
|
+
readonly getAttempt?: BillingCallback
|
|
73
|
+
readonly configureAutomaticTopUp?: BillingCallback
|
|
74
|
+
readonly setupPaymentMethod?: BillingCallback
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type ConfiguredMethod<
|
|
78
|
+
Options extends BillingOptions,
|
|
79
|
+
Key extends PropertyKey,
|
|
80
|
+
> = Options extends BillingOptions ? (Key extends keyof Options ? Options[Key] : undefined) : never
|
|
81
|
+
type SubscriptionMethod<Options extends BillingOptions, Method> = Options extends {
|
|
82
|
+
subscriptions: SubscriptionBillingClient
|
|
83
|
+
}
|
|
84
|
+
? Method
|
|
85
|
+
: 'subscriptions' extends keyof Options
|
|
86
|
+
? Method | undefined
|
|
87
|
+
: undefined
|
|
88
|
+
type ChangePlan = (input: ChangePlanInput) => Promise<ChangePlanResult>
|
|
89
|
+
type CreateBillingPortal = (input: BillingPortalInput) => Promise<BillingPortalResult>
|
|
90
|
+
|
|
91
|
+
/** One browser billing API; unconfigured operations are undefined and cannot be called. */
|
|
92
|
+
export interface IBillingService<Options extends BillingOptions> {
|
|
93
|
+
/** Start or change a subscription; return native data without redirecting. */
|
|
94
|
+
readonly changePlan: SubscriptionMethod<Options, ChangePlan>
|
|
95
|
+
/** Create portal access; return native data without redirecting. */
|
|
96
|
+
readonly createBillingPortal: SubscriptionMethod<Options, CreateBillingPortal>
|
|
97
|
+
/** Call the application's top-up API with its exact input and output types. */
|
|
98
|
+
readonly createTopUp: ConfiguredMethod<Options, 'createTopUp'>
|
|
99
|
+
/** Read the application's persisted attempt state. */
|
|
100
|
+
readonly getAttempt: ConfiguredMethod<Options, 'getAttempt'>
|
|
101
|
+
/** Persist automatic top-up settings through the application's API. */
|
|
102
|
+
readonly configureAutomaticTopUp: ConfiguredMethod<Options, 'configureAutomaticTopUp'>
|
|
103
|
+
/** Start payment method setup through the application's API. */
|
|
104
|
+
readonly setupPaymentMethod: ConfiguredMethod<Options, 'setupPaymentMethod'>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Ported from Typist's CheckoutService: one browser API for Better Auth and application payments.
|
|
109
|
+
* Unwraps Better Auth failures; preserves application callbacks, cancellations, and existing errors.
|
|
110
|
+
* Does not parse payloads, retry requests, log, navigate, or credit balances.
|
|
111
|
+
*/
|
|
112
|
+
export class BillingService<
|
|
113
|
+
const Options extends BillingOptions,
|
|
114
|
+
> implements IBillingService<Options> {
|
|
115
|
+
/** Configured Better Auth upgrade operation. */
|
|
116
|
+
readonly changePlan: SubscriptionMethod<Options, ChangePlan>
|
|
117
|
+
/** Configured Better Auth portal operation. */
|
|
118
|
+
readonly createBillingPortal: SubscriptionMethod<Options, CreateBillingPortal>
|
|
119
|
+
/** Exact application checkout callback, or undefined. */
|
|
120
|
+
readonly createTopUp: ConfiguredMethod<Options, 'createTopUp'>
|
|
121
|
+
/** Exact application attempt callback, or undefined. */
|
|
122
|
+
readonly getAttempt: ConfiguredMethod<Options, 'getAttempt'>
|
|
123
|
+
/** Exact application settings callback, or undefined. */
|
|
124
|
+
readonly configureAutomaticTopUp: ConfiguredMethod<Options, 'configureAutomaticTopUp'>
|
|
125
|
+
/** Exact application setup callback, or undefined. */
|
|
126
|
+
readonly setupPaymentMethod: ConfiguredMethod<Options, 'setupPaymentMethod'>
|
|
127
|
+
|
|
128
|
+
/** Inject configured Better Auth methods and standalone application API callbacks. */
|
|
129
|
+
constructor(options: Options) {
|
|
130
|
+
const subscriptions = options.subscriptions
|
|
131
|
+
const changePlan: ChangePlan | undefined =
|
|
132
|
+
subscriptions &&
|
|
133
|
+
(async (input) => {
|
|
134
|
+
const result = await subscriptions.upgrade({
|
|
135
|
+
...input,
|
|
136
|
+
disableRedirect: true,
|
|
137
|
+
fetchOptions: { ...input.fetchOptions, throw: false },
|
|
138
|
+
})
|
|
139
|
+
if (result.error) throw new BillingRequestError('subscription.upgrade', result.error)
|
|
140
|
+
return result.data
|
|
141
|
+
})
|
|
142
|
+
const createBillingPortal: CreateBillingPortal | undefined =
|
|
143
|
+
subscriptions &&
|
|
144
|
+
(async (input) => {
|
|
145
|
+
const result = await subscriptions.billingPortal({
|
|
146
|
+
...input,
|
|
147
|
+
disableRedirect: true,
|
|
148
|
+
fetchOptions: { ...input.fetchOptions, throw: false },
|
|
149
|
+
})
|
|
150
|
+
if (result.error) throw new BillingRequestError('subscription.billingPortal', result.error)
|
|
151
|
+
return result.data
|
|
152
|
+
})
|
|
153
|
+
// SAFETY: Each subscription method exists exactly when the required subscriptions dependency exists.
|
|
154
|
+
this.changePlan = changePlan as SubscriptionMethod<Options, ChangePlan>
|
|
155
|
+
// SAFETY: Each subscription method exists exactly when the required subscriptions dependency exists.
|
|
156
|
+
this.createBillingPortal = createBillingPortal as SubscriptionMethod<
|
|
157
|
+
Options,
|
|
158
|
+
CreateBillingPortal
|
|
159
|
+
>
|
|
160
|
+
this.createTopUp = configuredMethod(options, 'createTopUp')
|
|
161
|
+
this.getAttempt = configuredMethod(options, 'getAttempt')
|
|
162
|
+
this.configureAutomaticTopUp = configuredMethod(options, 'configureAutomaticTopUp')
|
|
163
|
+
this.setupPaymentMethod = configuredMethod(options, 'setupPaymentMethod')
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function configuredMethod<Options extends BillingOptions, Key extends keyof BillingOptions>(
|
|
168
|
+
options: Options,
|
|
169
|
+
key: Key,
|
|
170
|
+
): ConfiguredMethod<Options, Key> {
|
|
171
|
+
// SAFETY: Optional callbacks are returned unchanged; absent keys read as undefined.
|
|
172
|
+
return options[key] as ConfiguredMethod<Options, Key>
|
|
173
|
+
}
|
|
@@ -65,7 +65,8 @@ export class QueueConsumer<TMessage extends QueueMessage> {
|
|
|
65
65
|
if (body === null) {
|
|
66
66
|
// An unparseable body cannot succeed on retry, so park it for inspection.
|
|
67
67
|
logger.error('invalid_message_format', {
|
|
68
|
-
|
|
68
|
+
attempt: message.attempts,
|
|
69
|
+
queueMessageId: message.id,
|
|
69
70
|
})
|
|
70
71
|
await this.parkPoison(message)
|
|
71
72
|
return
|
|
@@ -76,7 +77,7 @@ export class QueueConsumer<TMessage extends QueueMessage> {
|
|
|
76
77
|
await this.bus.handle(body)
|
|
77
78
|
})
|
|
78
79
|
message.ack()
|
|
79
|
-
logger.debug(
|
|
80
|
+
logger.debug('queue_message_processed', { name: body.name })
|
|
80
81
|
} catch (cause) {
|
|
81
82
|
this.handleFailure(message, parsed, cause)
|
|
82
83
|
}
|
|
@@ -117,12 +118,10 @@ export class QueueConsumer<TMessage extends QueueMessage> {
|
|
|
117
118
|
if (decision.action === 'ack') {
|
|
118
119
|
logger.error('message_discarded', {
|
|
119
120
|
error: cause,
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
attempt: message.attempts,
|
|
125
|
-
},
|
|
121
|
+
name: parsed?.name,
|
|
122
|
+
messageId: parsed?.id,
|
|
123
|
+
queueMessageId: message.id,
|
|
124
|
+
attempt: message.attempts,
|
|
126
125
|
})
|
|
127
126
|
message.ack()
|
|
128
127
|
return
|
|
@@ -137,7 +136,7 @@ export class QueueConsumer<TMessage extends QueueMessage> {
|
|
|
137
136
|
delaySeconds: decision.delaySeconds,
|
|
138
137
|
}
|
|
139
138
|
if (decision.classification === 'unknown' && message.attempts === 1) {
|
|
140
|
-
logger.error('queue_unknown_failure', { error: cause,
|
|
139
|
+
logger.error('queue_unknown_failure', { error: cause, ...context })
|
|
141
140
|
} else {
|
|
142
141
|
logger.debug('redelivery_requested', context)
|
|
143
142
|
}
|