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.
- package/dist/{application-BpSwCKJ5.js → application-DEerezCl.js} +2 -2
- package/dist/{application-BpSwCKJ5.js.map → application-DEerezCl.js.map} +1 -1
- package/dist/application.d.ts +1 -1
- package/dist/application.js +1 -1
- package/dist/better-auth/apple-client-secret.d.ts +21 -0
- package/dist/better-auth/apple-client-secret.d.ts.map +1 -0
- package/dist/better-auth/apple-client-secret.js +44 -0
- package/dist/better-auth/apple-client-secret.js.map +1 -0
- package/dist/better-auth/auth-client.d.ts +16 -0
- package/dist/better-auth/auth-client.d.ts.map +1 -0
- package/dist/better-auth/auth-client.js +24 -0
- package/dist/better-auth/auth-client.js.map +1 -0
- package/dist/better-auth/rate-limit.d.ts +34 -0
- package/dist/better-auth/rate-limit.d.ts.map +1 -0
- package/dist/better-auth/rate-limit.js +59 -0
- package/dist/better-auth/rate-limit.js.map +1 -0
- package/dist/better-auth/secondary-storage.d.ts +11 -0
- package/dist/better-auth/secondary-storage.d.ts.map +1 -0
- package/dist/better-auth/secondary-storage.js +18 -0
- package/dist/better-auth/secondary-storage.js.map +1 -0
- package/dist/better-auth/stripe.d.ts +9 -0
- package/dist/better-auth/stripe.d.ts.map +1 -0
- package/dist/better-auth/stripe.js +17 -0
- package/dist/better-auth/stripe.js.map +1 -0
- package/dist/better-auth.d.ts +100 -0
- package/dist/better-auth.d.ts.map +1 -0
- package/dist/better-auth.js +112 -0
- package/dist/better-auth.js.map +1 -0
- package/dist/cloudflare/auth-secondary-storage.d.ts +36 -0
- package/dist/cloudflare/auth-secondary-storage.d.ts.map +1 -0
- package/dist/cloudflare/auth-secondary-storage.js +98 -0
- package/dist/cloudflare/auth-secondary-storage.js.map +1 -0
- package/dist/cloudflare.d.ts +3 -3
- package/dist/cloudflare.js +3 -3
- package/dist/core-CnwWj4MY.js +620 -0
- package/dist/core-CnwWj4MY.js.map +1 -0
- package/dist/core.d.ts +2 -2
- package/dist/core.js +2 -2
- package/dist/index-B9Y1hVh6.d.ts +460 -0
- package/dist/index-B9Y1hVh6.d.ts.map +1 -0
- package/dist/{index-CkaFqbbV.d.ts → index-Bdyh3_xC.d.ts} +2 -2
- package/dist/{index-CkaFqbbV.d.ts.map → index-Bdyh3_xC.d.ts.map} +1 -1
- package/dist/postgres.d.ts +3 -3
- package/dist/postgres.js +3 -3
- package/dist/tanstack.d.ts +81 -1
- package/dist/tanstack.d.ts.map +1 -0
- package/dist/tanstack.js +214 -0
- package/dist/tanstack.js.map +1 -0
- package/dist/{unit-of-work-context-CILhzKWb.js → unit-of-work-context-C7vz5OYS.js} +2 -2
- package/dist/{unit-of-work-context-CILhzKWb.js.map → unit-of-work-context-C7vz5OYS.js.map} +1 -1
- package/dist/{unit-of-work-context-DOkIB7HK.d.ts → unit-of-work-context-ml9L9cyY.d.ts} +2 -2
- package/dist/{unit-of-work-context-DOkIB7HK.d.ts.map → unit-of-work-context-ml9L9cyY.d.ts.map} +1 -1
- package/package.json +57 -12
- package/src/core/authentication.error.ts +11 -0
- package/src/core/error-table.ts +188 -0
- package/src/core/index.ts +31 -0
- package/src/core/internal.errors.ts +21 -0
- package/src/core/logger.ts +40 -19
- package/src/core/not-authorized.error.ts +11 -0
- package/src/core/problem-details.ts +57 -0
- package/src/core/rate-limit.error.ts +12 -0
- package/src/core/request.errors.ts +86 -0
- package/src/core/stale-session.error.ts +11 -0
- package/src/infrastructure/better-auth/apple-client-secret.ts +54 -0
- package/src/infrastructure/better-auth/auth-client.ts +26 -0
- package/src/infrastructure/better-auth/options.ts +232 -0
- package/src/infrastructure/better-auth/rate-limit.ts +69 -0
- package/src/infrastructure/better-auth/secondary-storage.ts +21 -0
- package/src/infrastructure/better-auth/stripe.ts +11 -0
- package/src/infrastructure/cloudflare/auth-secondary-storage.ts +113 -0
- package/src/infrastructure/tanstack/auth.middleware.ts +89 -0
- package/src/infrastructure/tanstack/error.middleware.ts +81 -0
- package/src/infrastructure/tanstack/http-error-response.ts +30 -0
- package/src/infrastructure/tanstack/index.ts +12 -0
- package/src/infrastructure/tanstack/resolve-failure.ts +24 -0
- package/src/infrastructure/tanstack/session-cookies.ts +36 -0
- package/src/infrastructure/tanstack/to-error-payload.ts +32 -0
- package/dist/core-D3G-2gIx.js +0 -298
- package/dist/core-D3G-2gIx.js.map +0 -1
- package/dist/index-CT1yZd4D.d.ts +0 -212
- 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
|
+
}
|
package/src/core/logger.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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, ...
|
|
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, ...
|
|
179
|
-
else write(line, ...
|
|
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
|
|
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 >
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
266
|
-
this.log(LogLevel.DEBUG, message,
|
|
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,
|
|
271
|
-
this.log(LogLevel.INFO, message,
|
|
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,
|
|
276
|
-
this.log(LogLevel.WARN, message,
|
|
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.
|
|
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,
|
|
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
|
+
}
|