better-ship 0.4.0 → 0.5.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 (81) hide show
  1. package/dist/{application-BpSwCKJ5.js → application-DEerezCl.js} +2 -2
  2. package/dist/{application-BpSwCKJ5.js.map → application-DEerezCl.js.map} +1 -1
  3. package/dist/application.d.ts +1 -1
  4. package/dist/application.js +1 -1
  5. package/dist/better-auth/apple-client-secret.d.ts +21 -0
  6. package/dist/better-auth/apple-client-secret.d.ts.map +1 -0
  7. package/dist/better-auth/apple-client-secret.js +44 -0
  8. package/dist/better-auth/apple-client-secret.js.map +1 -0
  9. package/dist/better-auth/auth-client.d.ts +16 -0
  10. package/dist/better-auth/auth-client.d.ts.map +1 -0
  11. package/dist/better-auth/auth-client.js +24 -0
  12. package/dist/better-auth/auth-client.js.map +1 -0
  13. package/dist/better-auth/rate-limit.d.ts +34 -0
  14. package/dist/better-auth/rate-limit.d.ts.map +1 -0
  15. package/dist/better-auth/rate-limit.js +59 -0
  16. package/dist/better-auth/rate-limit.js.map +1 -0
  17. package/dist/better-auth/secondary-storage.d.ts +11 -0
  18. package/dist/better-auth/secondary-storage.d.ts.map +1 -0
  19. package/dist/better-auth/secondary-storage.js +18 -0
  20. package/dist/better-auth/secondary-storage.js.map +1 -0
  21. package/dist/better-auth/stripe.d.ts +9 -0
  22. package/dist/better-auth/stripe.d.ts.map +1 -0
  23. package/dist/better-auth/stripe.js +17 -0
  24. package/dist/better-auth/stripe.js.map +1 -0
  25. package/dist/better-auth.d.ts +100 -0
  26. package/dist/better-auth.d.ts.map +1 -0
  27. package/dist/better-auth.js +112 -0
  28. package/dist/better-auth.js.map +1 -0
  29. package/dist/cloudflare/auth-secondary-storage.d.ts +36 -0
  30. package/dist/cloudflare/auth-secondary-storage.d.ts.map +1 -0
  31. package/dist/cloudflare/auth-secondary-storage.js +98 -0
  32. package/dist/cloudflare/auth-secondary-storage.js.map +1 -0
  33. package/dist/cloudflare.d.ts +3 -3
  34. package/dist/cloudflare.js +3 -3
  35. package/dist/core-CnwWj4MY.js +620 -0
  36. package/dist/core-CnwWj4MY.js.map +1 -0
  37. package/dist/core.d.ts +2 -2
  38. package/dist/core.js +2 -2
  39. package/dist/index-B9Y1hVh6.d.ts +460 -0
  40. package/dist/index-B9Y1hVh6.d.ts.map +1 -0
  41. package/dist/{index-CkaFqbbV.d.ts → index-Bdyh3_xC.d.ts} +2 -2
  42. package/dist/{index-CkaFqbbV.d.ts.map → index-Bdyh3_xC.d.ts.map} +1 -1
  43. package/dist/postgres.d.ts +3 -3
  44. package/dist/postgres.js +3 -3
  45. package/dist/tanstack.d.ts +81 -1
  46. package/dist/tanstack.d.ts.map +1 -0
  47. package/dist/tanstack.js +214 -0
  48. package/dist/tanstack.js.map +1 -0
  49. package/dist/{unit-of-work-context-CILhzKWb.js → unit-of-work-context-C7vz5OYS.js} +2 -2
  50. package/dist/{unit-of-work-context-CILhzKWb.js.map → unit-of-work-context-C7vz5OYS.js.map} +1 -1
  51. package/dist/{unit-of-work-context-DOkIB7HK.d.ts → unit-of-work-context-ml9L9cyY.d.ts} +2 -2
  52. package/dist/{unit-of-work-context-DOkIB7HK.d.ts.map → unit-of-work-context-ml9L9cyY.d.ts.map} +1 -1
  53. package/package.json +57 -12
  54. package/src/core/authentication.error.ts +11 -0
  55. package/src/core/error-table.ts +188 -0
  56. package/src/core/index.ts +31 -0
  57. package/src/core/internal.errors.ts +21 -0
  58. package/src/core/logger.ts +40 -19
  59. package/src/core/not-authorized.error.ts +11 -0
  60. package/src/core/problem-details.ts +57 -0
  61. package/src/core/rate-limit.error.ts +12 -0
  62. package/src/core/request.errors.ts +86 -0
  63. package/src/core/stale-session.error.ts +11 -0
  64. package/src/infrastructure/better-auth/apple-client-secret.ts +54 -0
  65. package/src/infrastructure/better-auth/auth-client.ts +26 -0
  66. package/src/infrastructure/better-auth/options.ts +232 -0
  67. package/src/infrastructure/better-auth/rate-limit.ts +69 -0
  68. package/src/infrastructure/better-auth/secondary-storage.ts +21 -0
  69. package/src/infrastructure/better-auth/stripe.ts +11 -0
  70. package/src/infrastructure/cloudflare/auth-secondary-storage.ts +113 -0
  71. package/src/infrastructure/tanstack/auth.middleware.ts +89 -0
  72. package/src/infrastructure/tanstack/error.middleware.ts +81 -0
  73. package/src/infrastructure/tanstack/http-error-response.ts +30 -0
  74. package/src/infrastructure/tanstack/index.ts +12 -0
  75. package/src/infrastructure/tanstack/resolve-failure.ts +24 -0
  76. package/src/infrastructure/tanstack/session-cookies.ts +36 -0
  77. package/src/infrastructure/tanstack/to-error-payload.ts +32 -0
  78. package/dist/core-D3G-2gIx.js +0 -298
  79. package/dist/core-D3G-2gIx.js.map +0 -1
  80. package/dist/index-CT1yZd4D.d.ts +0 -212
  81. package/dist/index-CT1yZd4D.d.ts.map +0 -1
@@ -0,0 +1,188 @@
1
+ import { panic } from 'better-result'
2
+ import { z } from 'zod'
3
+
4
+ import { AppError } from './app-error.ts'
5
+ import { AuthenticationError } from './authentication.error.ts'
6
+ import { InternalServerError, ServiceUnavailableError } from './internal.errors.ts'
7
+ import { NotAuthorizedError } from './not-authorized.error.ts'
8
+ import { ValidationProblemErrorSchema } from './problem-details.ts'
9
+ import { RateLimitError } from './rate-limit.error.ts'
10
+ import {
11
+ MalformedRequestError,
12
+ RequestTimeoutError,
13
+ ResourceNotFoundError,
14
+ UpgradeRequiredError,
15
+ ValidationError,
16
+ } from './request.errors.ts'
17
+ import { StaleSessionError } from './stale-session.error.ts'
18
+
19
+ type PublicFields = z.ZodObject
20
+
21
+ /**
22
+ * One public error: its class, its HTTP status, and the extra properties a client may read.
23
+ * `fields` strips the thrown error on the server and parses the payload in the browser.
24
+ */
25
+ export type ErrorEntry<Tag extends string, Fields extends PublicFields = PublicFields> = {
26
+ readonly error: abstract new (...args: never[]) => AppError & { readonly _tag: Tag }
27
+ readonly status: number
28
+ readonly fields?: Fields
29
+ }
30
+
31
+ type AnyErrorEntry = ErrorEntry<string>
32
+
33
+ /** Entries keyed by tag. The compiler rejects a key that differs from the class's `_tag`. */
34
+ export type ErrorEntries<Entries> = {
35
+ readonly [Tag in keyof Entries & string]: ErrorEntry<Tag>
36
+ }
37
+
38
+ type EntryPayload<Tag extends string, Entry> = Entry extends {
39
+ readonly fields: infer Fields extends z.ZodType
40
+ }
41
+ ? { readonly _tag: Tag; readonly message: string } & z.output<Fields>
42
+ : { readonly _tag: Tag; readonly message: string }
43
+
44
+ type PayloadOfEntries<Entries> = {
45
+ [Tag in keyof Entries & string]: EntryPayload<Tag, Entries[Tag]>
46
+ }[keyof Entries & string]
47
+
48
+ /** The HTTP status and public payload for one resolved failure. */
49
+ export type ResolvedError<Payload> = {
50
+ readonly status: number
51
+ readonly payload: Payload
52
+ }
53
+
54
+ /**
55
+ * One application's public errors, shared by every transport boundary and the browser.
56
+ * `resolve` and `fallback` serve boundaries. `parse` and `generic` serve screens.
57
+ */
58
+ export type ErrorTable<Entries> = {
59
+ readonly entries: Entries
60
+ /** The discriminated union of every entry's `_tag`, `message`, and fields. */
61
+ readonly payloadSchema: z.ZodType<PayloadOfEntries<Entries>>
62
+ /** The `InternalServerError` payload, for screens that must show something. */
63
+ readonly generic: PayloadOfEntries<Entries>
64
+ /** Read a rejection value. Null means no known tag, so the request never reached the Worker. */
65
+ parse(cause: unknown): PayloadOfEntries<Entries> | null
66
+ /** Narrow a rejection value to one tag's payload, for a caller that acts on that one error. */
67
+ is<Tag extends keyof Entries & string>(
68
+ cause: unknown,
69
+ tag: Tag,
70
+ ): cause is Extract<PayloadOfEntries<Entries>, { readonly _tag: Tag }>
71
+ /** Match a local instance or a remote error by tag. Null means the failure is not public. */
72
+ resolve(cause: unknown): ResolvedError<PayloadOfEntries<Entries>> | null
73
+ /** The replacement for an unmapped failure: 503 when it declared itself transient, else 500. */
74
+ fallback(cause: unknown): ResolvedError<PayloadOfEntries<Entries>>
75
+ }
76
+
77
+ /** The union of payloads a table can produce. */
78
+ export type ErrorPayloadOf<Table> =
79
+ Table extends ErrorTable<infer Entries> ? PayloadOfEntries<Entries> : never
80
+
81
+ /** The union of tags a table contains. */
82
+ export type ErrorTagOf<Table> =
83
+ Table extends ErrorTable<infer Entries> ? keyof Entries & string : never
84
+
85
+ function entriesOf<const Entries extends ErrorEntries<Entries>>(entries: Entries): Entries {
86
+ return entries
87
+ }
88
+
89
+ const ValidationFields = z.object({ errors: z.array(ValidationProblemErrorSchema) })
90
+ const RateLimitFields = z.object({ retryAfter: z.number().nullable() })
91
+
92
+ /** The errors every application can throw, with their statuses. `defineErrors` includes them. */
93
+ export const sharedErrors = entriesOf({
94
+ MalformedRequestError: { error: MalformedRequestError, status: 400 },
95
+ AuthenticationError: { error: AuthenticationError, status: 401 },
96
+ StaleSessionError: { error: StaleSessionError, status: 401 },
97
+ NotAuthorizedError: { error: NotAuthorizedError, status: 403 },
98
+ ResourceNotFoundError: { error: ResourceNotFoundError, status: 404 },
99
+ ValidationError: { error: ValidationError, status: 422, fields: ValidationFields },
100
+ UpgradeRequiredError: { error: UpgradeRequiredError, status: 426 },
101
+ RateLimitError: { error: RateLimitError, status: 429, fields: RateLimitFields },
102
+ InternalServerError: { error: InternalServerError, status: 500 },
103
+ ServiceUnavailableError: { error: ServiceUnavailableError, status: 503 },
104
+ RequestTimeoutError: { error: RequestTimeoutError, status: 504 },
105
+ })
106
+
107
+ type MergedEntries<Entries> = Omit<typeof sharedErrors, keyof Entries> & Entries
108
+
109
+ // Workers RPC drops the prototype but keeps own properties and marks the error as remote.
110
+ const RemoteErrorSchema = z.object({
111
+ _tag: z.string(),
112
+ message: z.string(),
113
+ remote: z.literal(true),
114
+ })
115
+
116
+ type Match = { readonly tag: string; readonly entry: AnyErrorEntry; readonly error: Error }
117
+
118
+ function match(byTag: ReadonlyMap<string, AnyErrorEntry>, cause: unknown): Match | null {
119
+ if (cause instanceof AppError) {
120
+ const entry = byTag.get(cause._tag)
121
+ if (entry === undefined || !(cause instanceof entry.error)) return null
122
+ return { tag: cause._tag, entry, error: cause }
123
+ }
124
+ if (!(cause instanceof Error)) return null
125
+ const remote = RemoteErrorSchema.safeParse(cause)
126
+ if (!remote.success) return null
127
+ const entry = byTag.get(remote.data._tag)
128
+ return entry === undefined ? null : { tag: remote.data._tag, entry, error: cause }
129
+ }
130
+
131
+ /**
132
+ * Build the application's error table. The shared entries are always included;
133
+ * an entry with the same key replaces the shared one.
134
+ */
135
+ export function defineErrors<const Entries extends ErrorEntries<Entries>>(
136
+ entries: Entries,
137
+ ): ErrorTable<MergedEntries<Entries>> {
138
+ type Payload = PayloadOfEntries<MergedEntries<Entries>>
139
+ const merged = { ...sharedErrors, ...entries }
140
+ const byTag: ReadonlyMap<string, AnyErrorEntry> = new Map(Object.entries<AnyErrorEntry>(merged))
141
+
142
+ const resolve = (cause: unknown): ResolvedError<Payload> | null => {
143
+ const found = match(byTag, cause)
144
+ if (found === null) return null
145
+ const fields = found.entry.fields?.safeParse(found.error)
146
+ if (fields !== undefined && !fields.success) return null
147
+ // SAFETY: The entry's own `fields` schema produced these properties, so the payload has the shape declared for this tag.
148
+ const payload = { _tag: found.tag, message: found.error.message, ...fields?.data } as Payload
149
+ return { status: found.entry.status, payload }
150
+ }
151
+
152
+ const resolveShared = (error: AppError): ResolvedError<Payload> =>
153
+ resolve(error) ?? panic(`Error table cannot resolve shared error ${error._tag}`)
154
+ const generic = resolveShared(new InternalServerError())
155
+ const unavailable = resolveShared(new ServiceUnavailableError())
156
+
157
+ const variants = [...byTag].map(([tag, entry]) =>
158
+ // oxlint-disable-next-line anti-slop/no-shape-in-symbol-names -- Zod names an object schema's property map `shape`.
159
+ z.strictObject({ _tag: z.literal(tag), message: z.string(), ...entry.fields?.shape }),
160
+ )
161
+ const [first, ...rest] = variants
162
+ if (first === undefined) return panic('Error table has no entries')
163
+ // Each variant is one entry's `_tag`, `message`, and fields, which is the union `PayloadOfEntries` describes.
164
+ const payloadSchema = z.discriminatedUnion('_tag', [first, ...rest]).pipe(z.custom<Payload>())
165
+
166
+ // No return annotation: TypeScript resolves the deferred payload union differently in that position.
167
+ const parse = (cause: unknown) => {
168
+ const parsed = payloadSchema.safeParse(cause)
169
+ return parsed.success ? parsed.data : null
170
+ }
171
+
172
+ return {
173
+ entries: merged,
174
+ payloadSchema,
175
+ generic: generic.payload,
176
+ parse,
177
+ is<Tag extends keyof MergedEntries<Entries> & string>(
178
+ cause: unknown,
179
+ tag: Tag,
180
+ ): cause is Extract<Payload, { readonly _tag: Tag }> {
181
+ const parsed = parse(cause)
182
+ return parsed !== null && parsed._tag === tag
183
+ },
184
+ resolve,
185
+ fallback: (cause) =>
186
+ cause instanceof AppError && cause.classification === 'transient' ? unavailable : generic,
187
+ }
188
+ }
package/src/core/index.ts CHANGED
@@ -1,4 +1,35 @@
1
1
  export { AppError } from './app-error.ts'
2
+ export { AuthenticationError } from './authentication.error.ts'
3
+ export { RateLimitError } from './rate-limit.error.ts'
4
+ export { StaleSessionError } from './stale-session.error.ts'
5
+ export { NotAuthorizedError } from './not-authorized.error.ts'
6
+ export { InternalServerError, ServiceUnavailableError } from './internal.errors.ts'
7
+ export {
8
+ ValidationError,
9
+ MalformedRequestError,
10
+ ResourceNotFoundError,
11
+ UpgradeRequiredError,
12
+ RequestTimeoutError,
13
+ type ValidationIssue,
14
+ } from './request.errors.ts'
15
+ export {
16
+ defineErrors,
17
+ sharedErrors,
18
+ type ErrorEntries,
19
+ type ErrorEntry,
20
+ type ErrorPayloadOf,
21
+ type ErrorTable,
22
+ type ErrorTagOf,
23
+ type ResolvedError,
24
+ } from './error-table.ts'
25
+ export {
26
+ ProblemDetailsSchema,
27
+ ValidationProblemErrorSchema,
28
+ ValidationProblemDetailsSchema,
29
+ type ProblemDetails,
30
+ type ValidationProblemError,
31
+ type ValidationProblemDetails,
32
+ } from './problem-details.ts'
2
33
  export { DatabaseError, type DatabaseFailure, type D1ConstraintKind } from './database.error.ts'
3
34
  export { notImplemented, unreachable } from './defects.ts'
4
35
  export {
@@ -0,0 +1,21 @@
1
+ import { AppError } from './app-error.ts'
2
+
3
+ /** Safe public replacement for an unknown failure, ported from Porte's internal.errors.ts. */
4
+ export class InternalServerError extends AppError {
5
+ readonly _tag = 'InternalServerError'
6
+ readonly classification = 'unknown'
7
+
8
+ constructor() {
9
+ super('Something went wrong')
10
+ }
11
+ }
12
+
13
+ /** Safe public replacement for a classified transient failure. */
14
+ export class ServiceUnavailableError extends AppError {
15
+ readonly _tag = 'ServiceUnavailableError'
16
+ readonly classification = 'transient'
17
+
18
+ constructor() {
19
+ super('Try again shortly')
20
+ }
21
+ }
@@ -31,13 +31,14 @@ export const LogLevel = {
31
31
  } as const
32
32
  export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]
33
33
 
34
- /** An error as plain data: name, message, stack, allowlisted fields, and the cause chain. */
34
+ /** An error as plain data, including diagnostics, causes, and aggregate members. */
35
35
  export type SerializedError = {
36
36
  readonly name: string
37
37
  readonly message: string
38
38
  readonly stack?: string
39
39
  readonly fields?: LogFields
40
40
  readonly cause?: SerializedError
41
+ readonly errors?: ReadonlyArray<SerializedError>
41
42
  }
42
43
 
43
44
  /** One log, as the sink receives it. Plain data, safe to stringify. */
@@ -46,7 +47,8 @@ export type LogEntry = {
46
47
  readonly level: LogLevel
47
48
  readonly module: string
48
49
  readonly message: string
49
- readonly data: ReadonlyArray<LogValue>
50
+ readonly details: LogFields
51
+ readonly userId?: string
50
52
  /** Present on `Logger.error` entries that carried an error. */
51
53
  readonly error?: SerializedError
52
54
  }
@@ -158,7 +160,7 @@ function prettyLine(entry: LogEntry): string {
158
160
  }
159
161
 
160
162
  /**
161
- * JSON format writes the entry object. Pretty format writes one line, then the data,
163
+ * JSON format writes the entry object. Pretty format writes one line, then the details,
162
164
  * the error, and the stack as separate console arguments.
163
165
  */
164
166
  export const consoleSink: LogSink = (entry) => {
@@ -169,14 +171,17 @@ export const consoleSink: LogSink = (entry) => {
169
171
  return
170
172
  }
171
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]
172
177
  if (entry.error === undefined) {
173
- write(line, ...entry.data)
178
+ write(line, ...details)
174
179
  return
175
180
  }
176
181
  // A stack inside an object prints as one quoted string; as its own argument it prints as lines.
177
182
  const { stack, ...error } = entry.error
178
- if (stack === undefined) write(line, ...entry.data, error)
179
- else write(line, ...entry.data, error, `\n${stack}`)
183
+ if (stack === undefined) write(line, ...details, error)
184
+ else write(line, ...details, error, `\n${stack}`)
180
185
  }
181
186
 
182
187
  let logSink: LogSink = consoleSink
@@ -192,7 +197,7 @@ export function setLogSink(fn: LogSink): void {
192
197
  }
193
198
 
194
199
  const LOG_LEVELS = Object.values(LogLevel)
195
- const MAX_ERROR_CAUSE_DEPTH = 3
200
+ const MAX_ERROR_DEPTH = 3
196
201
 
197
202
  /**
198
203
  * Diagnostics worth keeping off an error, named one by one.
@@ -220,18 +225,26 @@ type ErrorDiagnostics = Error & { readonly [K in (typeof KEPT_ERROR_FIELDS)[numb
220
225
  type MutableSerializedError = { -readonly [K in keyof SerializedError]: SerializedError[K] }
221
226
 
222
227
  const serializeError = (error: Error, depth = 0): SerializedError => {
223
- if (depth > MAX_ERROR_CAUSE_DEPTH) return { name: 'Error', message: '[cause chain truncated]' }
228
+ if (depth > MAX_ERROR_DEPTH) return { name: 'Error', message: '[error nesting truncated]' }
224
229
 
225
230
  // Every field is optional, so an `Error` is already one of these.
226
231
  const carrier: ErrorDiagnostics = error
227
232
  const kept = KEPT_ERROR_FIELDS.filter((key) => carrier[key] !== undefined)
228
233
  const serialized: MutableSerializedError = { name: error.name, message: error.message }
229
- if (depth === 0 && error.stack !== undefined) serialized.stack = error.stack
234
+ if (error.stack !== undefined) serialized.stack = error.stack
230
235
  if (kept.length > 0)
231
236
  serialized.fields = Object.fromEntries(kept.map((key) => [key, carrier[key]]))
232
237
  if (error.cause instanceof Error) serialized.cause = serializeError(error.cause, depth + 1)
233
238
  else if (error.cause !== undefined)
234
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
+ }
235
248
  return serialized
236
249
  }
237
250
 
@@ -250,37 +263,45 @@ export class Logger {
250
263
  private log(
251
264
  level: LogLevel,
252
265
  message: string,
253
- data: ReadonlyArray<LogValue>,
266
+ details: LogFields,
254
267
  error?: SerializedError,
268
+ userId?: string,
255
269
  ): void {
256
270
  const threshold = this.config.level ?? settings.level
257
271
  if (threshold === 'OFF') return
258
272
  if (LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf(threshold)) return
259
273
 
260
- const entry = { timestamp: new Date().toISOString(), level, module: this.module, message, data }
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 }
261
282
  logSink(error === undefined ? entry : { ...entry, error })
262
283
  }
263
284
 
264
285
  /** Write a debug log. */
265
- debug(message: string, ...values: ReadonlyArray<LogValue>): void {
266
- this.log(LogLevel.DEBUG, message, values)
286
+ debug(message: string, details: LogFields = {}): void {
287
+ this.log(LogLevel.DEBUG, message, details)
267
288
  }
268
289
 
269
290
  /** Write an information log. */
270
- info(message: string, ...values: ReadonlyArray<LogValue>): void {
271
- this.log(LogLevel.INFO, message, values)
291
+ info(message: string, details: LogFields = {}): void {
292
+ this.log(LogLevel.INFO, message, details)
272
293
  }
273
294
 
274
295
  /** Write a warning log. */
275
- warn(message: string, ...values: ReadonlyArray<LogValue>): void {
276
- this.log(LogLevel.WARN, message, values)
296
+ warn(message: string, details: LogFields = {}): void {
297
+ this.log(LogLevel.WARN, message, details)
277
298
  }
278
299
 
279
300
  /**
280
301
  * Write an error log and send the error to the configured hook.
281
302
  *
282
303
  * The details say which call this was, and the error says what went wrong
283
- * inside it. The entry carries the details as data and the error beside them.
304
+ * inside it. Both the entry and the error hook receive the supplied user ID.
284
305
  */
285
306
  error(message: string, context: ErrorLogContext = {}): void {
286
307
  const details = context.details ?? {}
@@ -298,7 +319,7 @@ export class Logger {
298
319
  }
299
320
  error = { name: 'NonError', message: json }
300
321
  }
301
- this.log(LogLevel.ERROR, message, context.details === undefined ? [] : [details], error)
322
+ this.log(LogLevel.ERROR, message, details, error, context.userId)
302
323
 
303
324
  if (errorHook !== null && thrown !== undefined) {
304
325
  errorHook({ error: thrown, distinctId: context.userId, context: details })
@@ -0,0 +1,11 @@
1
+ import { AppError } from './app-error.ts'
2
+
3
+ /** The authenticated caller cannot perform this operation. Ported from Porte's auth.errors.ts. */
4
+ export class NotAuthorizedError extends AppError {
5
+ readonly _tag = 'NotAuthorizedError'
6
+ readonly classification = 'terminal'
7
+
8
+ constructor() {
9
+ super('Not allowed')
10
+ }
11
+ }
@@ -0,0 +1,57 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * The standard RFC 9457 body for a failed HTTP response.
5
+ * The HTTP response status remains authoritative; extensions may add problem-specific data.
6
+ * @see https://http.dev/problem-details
7
+ */
8
+ export const ProblemDetailsSchema = z.looseObject({
9
+ /**
10
+ * Stable URI for the problem type; use `about:blank` when the HTTP status is sufficient.
11
+ * @example "about:blank"
12
+ */
13
+ type: z.string(),
14
+ /**
15
+ * Stable human-readable name for the problem type.
16
+ * @example "Service Unavailable"
17
+ */
18
+ title: z.string(),
19
+ /**
20
+ * Safe human-readable explanation for this occurrence; clients must not parse it.
21
+ * @example "The service is unavailable."
22
+ */
23
+ detail: z.string().optional(),
24
+ /**
25
+ * URI reference for this occurrence; omit it when no correlation resource exists.
26
+ * @example "/problems/occurrences/01K5F6Y7"
27
+ */
28
+ instance: z.string().optional(),
29
+ })
30
+
31
+ /** An RFC 9457 problem body for a failed HTTP response. */
32
+ export type ProblemDetails = z.infer<typeof ProblemDetailsSchema>
33
+
34
+ /** One invalid value in a `422` validation problem. */
35
+ export const ValidationProblemErrorSchema = z.object({
36
+ /**
37
+ * Safe explanation of why this value is invalid.
38
+ * @example "Must be a positive integer."
39
+ */
40
+ detail: z.string(),
41
+ /**
42
+ * RFC 6901 JSON Pointer fragment for the invalid value.
43
+ * @example "#/age"
44
+ */
45
+ pointer: z.string().regex(/^#(?:\/(?:[^~]|~[01])*)*$/u),
46
+ })
47
+
48
+ /** One invalid value in a `422` validation problem. */
49
+ export type ValidationProblemError = z.infer<typeof ValidationProblemErrorSchema>
50
+
51
+ /** An RFC 9457 validation problem with one or more invalid content values. */
52
+ export const ValidationProblemDetailsSchema = ProblemDetailsSchema.extend({
53
+ errors: z.array(ValidationProblemErrorSchema).min(1),
54
+ })
55
+
56
+ /** An RFC 9457 validation problem with one or more invalid content values. */
57
+ export type ValidationProblemDetails = z.infer<typeof ValidationProblemDetailsSchema>
@@ -0,0 +1,12 @@
1
+ import { AppError } from './app-error.ts'
2
+
3
+ /** HTTP 429 with a retry delay in seconds. A null delay means the response supplies no valid duration. */
4
+ export class RateLimitError extends AppError {
5
+ readonly _tag = 'RateLimitError'
6
+ readonly classification = 'transient'
7
+ readonly status = 429
8
+
9
+ constructor(readonly retryAfter: number | null) {
10
+ super('Too many requests')
11
+ }
12
+ }
@@ -0,0 +1,86 @@
1
+ import { z } from 'zod'
2
+
3
+ import { AppError } from './app-error.ts'
4
+ import type { ValidationProblemError } from './problem-details.ts'
5
+
6
+ /** One invalid value as a validator reports it: a message and a path of keys. */
7
+ export type ValidationIssue = {
8
+ readonly message: string
9
+ readonly path: readonly PropertyKey[]
10
+ }
11
+
12
+ /** Invalid user input, one entry per invalid value in the RFC 9457 shape. Technical failures use another error. */
13
+ export class ValidationError extends AppError {
14
+ readonly _tag = 'ValidationError'
15
+ readonly classification = 'terminal'
16
+
17
+ constructor(
18
+ readonly errors: readonly ValidationProblemError[],
19
+ message = 'The request was not valid',
20
+ ) {
21
+ super(message)
22
+ }
23
+
24
+ /** Convert a Zod error. Each issue path becomes a JSON Pointer. */
25
+ static fromZod(error: z.ZodError, message?: string): ValidationError {
26
+ return ValidationError.fromIssues(error.issues, message)
27
+ }
28
+
29
+ /** Convert issues from any validator, such as issues TanStack serialized into an error message. */
30
+ static fromIssues(issues: readonly ValidationIssue[], message?: string): ValidationError {
31
+ return new ValidationError(
32
+ issues.map((issue) => ({ detail: issue.message, pointer: jsonPointer(issue.path) })),
33
+ message,
34
+ )
35
+ }
36
+ }
37
+
38
+ /** The server cannot parse the request syntax. */
39
+ export class MalformedRequestError extends AppError {
40
+ readonly _tag = 'MalformedRequestError'
41
+ readonly classification = 'terminal'
42
+
43
+ constructor(args: { cause: unknown }) {
44
+ super('The request was malformed', args)
45
+ }
46
+ }
47
+
48
+ /** No resource matches the requested HTTP path. */
49
+ export class ResourceNotFoundError extends AppError {
50
+ readonly _tag = 'ResourceNotFoundError'
51
+ readonly classification = 'terminal'
52
+
53
+ constructor() {
54
+ super('Resource not found')
55
+ }
56
+ }
57
+
58
+ /** A WebSocket route received a request without an upgrade. */
59
+ export class UpgradeRequiredError extends AppError {
60
+ readonly _tag = 'UpgradeRequiredError'
61
+ readonly classification = 'terminal'
62
+
63
+ constructor() {
64
+ super('WebSocket upgrade required')
65
+ }
66
+ }
67
+
68
+ /** The requested operation did not answer before its deadline. */
69
+ export class RequestTimeoutError extends AppError {
70
+ readonly _tag = 'RequestTimeoutError'
71
+ readonly classification = 'transient'
72
+
73
+ constructor() {
74
+ super('The request timed out')
75
+ }
76
+ }
77
+
78
+ const readablePathSchema = z.array(z.union([z.string(), z.number()]))
79
+
80
+ // A symbol step cannot be shown, so the pointer keeps only the steps a person can read.
81
+ function jsonPointer(path: readonly PropertyKey[]): string {
82
+ const parsed = readablePathSchema.safeParse(path)
83
+ const steps = parsed.success ? parsed.data : []
84
+ if (steps.length === 0) return '#'
85
+ return `#/${steps.map((step) => String(step).replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}`
86
+ }
@@ -0,0 +1,11 @@
1
+ import { AppError } from './app-error.ts'
2
+
3
+ /** A presented session cookie no longer resolves; the application owns cleanup and redirect policy. */
4
+ export class StaleSessionError extends AppError {
5
+ readonly _tag = 'StaleSessionError'
6
+ readonly classification = 'terminal'
7
+
8
+ constructor() {
9
+ super('Session is no longer valid. Sign in again.')
10
+ }
11
+ }
@@ -0,0 +1,54 @@
1
+ import { importPKCS8, SignJWT } from 'jose'
2
+
3
+ import { AppError, createLogger } from '@/core'
4
+
5
+ const logger = createLogger('auth')
6
+ // Typist reports an invalid key once per isolate because session checks repeatedly construct auth options.
7
+ let appleKeyFailureLogged = false
8
+
9
+ /** Apple sends its OAuth callback from this origin. Include it in trustedOrigins when enabling Apple. */
10
+ export const APPLE_ORIGIN = 'https://appleid.apple.com'
11
+
12
+ /** Apple credentials used to sign a client secret. Ported from Porte's apple-client-secret.ts. */
13
+ export type AppleKeyConfig = {
14
+ readonly appleClientId: string
15
+ readonly appleTeamId: string
16
+ readonly appleKeyId: string
17
+ readonly applePrivateKey: string
18
+ }
19
+
20
+ /** Apple signing failed. The application's error boundary owns reporting. */
21
+ export class AppleClientSecretError extends AppError {
22
+ readonly _tag = 'AppleClientSecretError'
23
+ readonly classification = 'terminal'
24
+
25
+ constructor(cause: unknown) {
26
+ super('Apple client secret signing failed', { cause })
27
+ }
28
+ }
29
+
30
+ /** Sign Apple's six-month client secret. Invalid private keys return an empty secret and log once per isolate, as in Typist. */
31
+ export async function generateAppleClientSecret(config: AppleKeyConfig): Promise<string> {
32
+ let key: Awaited<ReturnType<typeof importPKCS8>>
33
+ try {
34
+ key = await importPKCS8(config.applePrivateKey.replace(/\\n/g, '\n'), 'ES256')
35
+ } catch (error) {
36
+ if (!appleKeyFailureLogged) {
37
+ appleKeyFailureLogged = true
38
+ logger.error('apple_private_key_invalid', { error, details: { keyId: config.appleKeyId } })
39
+ }
40
+ return ''
41
+ }
42
+ try {
43
+ return await new SignJWT({})
44
+ .setProtectedHeader({ alg: 'ES256', kid: config.appleKeyId })
45
+ .setIssuer(config.appleTeamId)
46
+ .setSubject(config.appleClientId)
47
+ .setAudience(APPLE_ORIGIN)
48
+ .setIssuedAt()
49
+ .setExpirationTime('180d')
50
+ .sign(key)
51
+ } catch (cause) {
52
+ throw new AppleClientSecretError(cause)
53
+ }
54
+ }
@@ -0,0 +1,26 @@
1
+ import { createAuthClient, type BetterAuthClientOptions } from 'better-auth/client'
2
+
3
+ import { RateLimitError } from '@/core'
4
+
5
+ type AuthErrorHandler = NonNullable<NonNullable<BetterAuthClientOptions['fetchOptions']>['onError']>
6
+
7
+ /**
8
+ * Pass to native fetchOptions.onError to reject HTTP 429 with RateLimitError.
9
+ * Other errors remain in the native result. This handler does not schedule retries or log errors.
10
+ */
11
+ export const handleAuthError: AuthErrorHandler = ({ response }) => {
12
+ if (response.status !== 429) return
13
+ throw new RateLimitError(retryAfterSeconds(response.headers))
14
+ }
15
+
16
+ /** Native client for same-origin /api/auth requests. HTTP 429 rejects with RateLimitError. */
17
+ export const authClient = createAuthClient({
18
+ fetchOptions: { onError: handleAuthError },
19
+ })
20
+
21
+ function retryAfterSeconds(headers: Headers): number | null {
22
+ const value = headers.get('X-Retry-After')
23
+ if (value === null || !/^\d+$/.test(value)) return null
24
+ const seconds = Number(value)
25
+ return Number.isSafeInteger(seconds) ? seconds : null
26
+ }