better-ship 0.4.1 → 0.6.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 (109) hide show
  1. package/dist/{application-rpZoTfzU.js → application-DjsQtpgw.js} +2 -2
  2. package/dist/application-DjsQtpgw.js.map +1 -0
  3. package/dist/application.d.ts +3 -2
  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 +7 -7
  34. package/dist/cloudflare.d.ts.map +1 -1
  35. package/dist/cloudflare.js +3 -3
  36. package/dist/cloudflare.js.map +1 -1
  37. package/dist/core-Cx5SeyKV.js +709 -0
  38. package/dist/core-Cx5SeyKV.js.map +1 -0
  39. package/dist/core.d.ts +2 -2
  40. package/dist/core.js +2 -2
  41. package/dist/index-BStnggQO.d.ts +584 -0
  42. package/dist/index-BStnggQO.d.ts.map +1 -0
  43. package/dist/{index-BN_tjxME.d.ts → index-DWIRrJMP.d.ts} +21 -50
  44. package/dist/index-DWIRrJMP.d.ts.map +1 -0
  45. package/dist/postgres.d.ts +7 -7
  46. package/dist/postgres.d.ts.map +1 -1
  47. package/dist/postgres.js +3 -3
  48. package/dist/postgres.js.map +1 -1
  49. package/dist/tanstack.d.ts +88 -1
  50. package/dist/tanstack.d.ts.map +1 -0
  51. package/dist/tanstack.js +225 -0
  52. package/dist/tanstack.js.map +1 -0
  53. package/dist/{unit-of-work-context-CbUJGHlL.d.ts → unit-of-work-context-CPz9rdSS.d.ts} +3 -2
  54. package/dist/unit-of-work-context-CPz9rdSS.d.ts.map +1 -0
  55. package/dist/{unit-of-work-context-BLNj72v0.js → unit-of-work-context-DToDpAkv.js} +2 -2
  56. package/dist/unit-of-work-context-DToDpAkv.js.map +1 -0
  57. package/package.json +57 -12
  58. package/src/application/event-collector.ts +3 -3
  59. package/src/application/handlers.ts +7 -4
  60. package/src/application/index.ts +1 -1
  61. package/src/application/message-already-processed.error.ts +1 -3
  62. package/src/application/message-bus.ts +14 -8
  63. package/src/application/message-store.ts +2 -2
  64. package/src/application/registry.ts +14 -13
  65. package/src/application/unit-of-work.ts +2 -1
  66. package/src/core/authentication.error.ts +11 -0
  67. package/src/core/define-messages.ts +136 -0
  68. package/src/core/entity.ts +33 -0
  69. package/src/core/error-table.ts +188 -0
  70. package/src/core/identity.ts +29 -0
  71. package/src/core/index.ts +50 -0
  72. package/src/core/internal.errors.ts +21 -0
  73. package/src/core/logger.ts +27 -15
  74. package/src/{application → core}/messages.ts +4 -4
  75. package/src/core/not-authorized.error.ts +11 -0
  76. package/src/core/problem-details.ts +57 -0
  77. package/src/core/rate-limit.error.ts +12 -0
  78. package/src/core/request.errors.ts +86 -0
  79. package/src/core/stale-session.error.ts +11 -0
  80. package/src/infrastructure/better-auth/apple-client-secret.ts +54 -0
  81. package/src/infrastructure/better-auth/auth-client.ts +26 -0
  82. package/src/infrastructure/better-auth/options.ts +232 -0
  83. package/src/infrastructure/better-auth/rate-limit.ts +69 -0
  84. package/src/infrastructure/better-auth/secondary-storage.ts +21 -0
  85. package/src/infrastructure/better-auth/stripe.ts +11 -0
  86. package/src/infrastructure/cloudflare/auth-secondary-storage.ts +113 -0
  87. package/src/infrastructure/cloudflare/base-d1-repository.ts +2 -2
  88. package/src/infrastructure/cloudflare/d1-message-store.ts +3 -7
  89. package/src/infrastructure/cloudflare/d1-schema.ts +2 -2
  90. package/src/infrastructure/postgres/base-postgres-repository.ts +2 -2
  91. package/src/infrastructure/postgres/postgres-message-store.ts +3 -7
  92. package/src/infrastructure/postgres/postgres-schema.ts +2 -2
  93. package/src/infrastructure/tanstack/auth.middleware.ts +89 -0
  94. package/src/infrastructure/tanstack/error.middleware.ts +81 -0
  95. package/src/infrastructure/tanstack/http-error-response.ts +30 -0
  96. package/src/infrastructure/tanstack/index.ts +13 -0
  97. package/src/infrastructure/tanstack/last-login-method.ts +13 -0
  98. package/src/infrastructure/tanstack/resolve-failure.ts +24 -0
  99. package/src/infrastructure/tanstack/session-cookies.ts +36 -0
  100. package/src/infrastructure/tanstack/to-error-payload.ts +32 -0
  101. package/src/infrastructure/unit-of-work-context.ts +3 -2
  102. package/dist/application-rpZoTfzU.js.map +0 -1
  103. package/dist/core-MjwyZJ4i.js +0 -302
  104. package/dist/core-MjwyZJ4i.js.map +0 -1
  105. package/dist/index-BN_tjxME.d.ts.map +0 -1
  106. package/dist/index-ToddX0m3.d.ts +0 -213
  107. package/dist/index-ToddX0m3.d.ts.map +0 -1
  108. package/dist/unit-of-work-context-BLNj72v0.js.map +0 -1
  109. package/dist/unit-of-work-context-CbUJGHlL.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
+ }
@@ -0,0 +1,29 @@
1
+ import { v7 as uuidv7 } from 'uuid'
2
+ import { z } from 'zod'
3
+
4
+ /** A string only `create()` or `schema.parse()` of the same kind can produce. */
5
+ export type Id<Brand extends string> = string & z.core.$brand<Brand>
6
+
7
+ /** One id kind the application mints. `schema` parses untrusted input; `create` mints a uuid v7. */
8
+ export type IdDefinition<Brand extends string> = {
9
+ readonly schema: z.core.$ZodBranded<z.ZodUUID, Brand>
10
+ /** Mint a new time-ordered id. No parse: we produced it, so it is valid by construction. */
11
+ create(): Id<Brand>
12
+ }
13
+
14
+ /** The id type of a definition: `type CallId = IdOf<typeof CallId>`. */
15
+ export type IdOf<Definition extends IdDefinition<string>> = z.output<Definition['schema']>
16
+
17
+ /**
18
+ * Define an id kind the application mints: `export const CallId = defineId('CallId')`.
19
+ * An id another system mints, such as a provider's call id, stays a plain branded schema.
20
+ */
21
+ export function defineId<const Brand extends string>(brand: Brand): IdDefinition<Brand> {
22
+ const schema = z.uuidv7({ error: `Invalid ${brand}` })
23
+ return {
24
+ // SAFETY: `brand()` is type-only and returns a conditional TypeScript cannot reduce for a generic brand; for every literal it is this branded schema.
25
+ schema: schema.brand<Brand>() as z.core.$ZodBranded<z.ZodUUID, Brand>,
26
+ // SAFETY: uuidv7() always yields a valid v7 uuid, and the brand is a compile-time mark only.
27
+ create: () => uuidv7() as Id<Brand>,
28
+ }
29
+ }
package/src/core/index.ts CHANGED
@@ -1,6 +1,56 @@
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'
35
+ export { defineId, type Id, type IdDefinition, type IdOf } from './identity.ts'
36
+ export {
37
+ defineMessages,
38
+ type AppMessages,
39
+ type CommandOf,
40
+ type EventOf,
41
+ type MessageDefinition,
42
+ type MessageOf,
43
+ type PayloadSchemas,
44
+ type QueryOf,
45
+ } from './define-messages.ts'
46
+ export type {
47
+ DomainCommand,
48
+ DomainEvent,
49
+ DomainMessage,
50
+ DomainQuery,
51
+ MessageId,
52
+ } from './messages.ts'
53
+ export { Entity, type EntityData } from './entity.ts'
4
54
  export {
5
55
  shouldRetryFailure,
6
56
  type FailureClassification,
@@ -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
+ }
@@ -47,7 +47,8 @@ export type LogEntry = {
47
47
  readonly level: LogLevel
48
48
  readonly module: string
49
49
  readonly message: string
50
- readonly data: ReadonlyArray<LogValue>
50
+ readonly details: LogFields
51
+ readonly userId?: string
51
52
  /** Present on `Logger.error` entries that carried an error. */
52
53
  readonly error?: SerializedError
53
54
  }
@@ -159,7 +160,7 @@ function prettyLine(entry: LogEntry): string {
159
160
  }
160
161
 
161
162
  /**
162
- * 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,
163
164
  * the error, and the stack as separate console arguments.
164
165
  */
165
166
  export const consoleSink: LogSink = (entry) => {
@@ -170,14 +171,17 @@ export const consoleSink: LogSink = (entry) => {
170
171
  return
171
172
  }
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]
173
177
  if (entry.error === undefined) {
174
- write(line, ...entry.data)
178
+ write(line, ...details)
175
179
  return
176
180
  }
177
181
  // A stack inside an object prints as one quoted string; as its own argument it prints as lines.
178
182
  const { stack, ...error } = entry.error
179
- if (stack === undefined) write(line, ...entry.data, error)
180
- else write(line, ...entry.data, error, `\n${stack}`)
183
+ if (stack === undefined) write(line, ...details, error)
184
+ else write(line, ...details, error, `\n${stack}`)
181
185
  }
182
186
 
183
187
  let logSink: LogSink = consoleSink
@@ -259,37 +263,45 @@ export class Logger {
259
263
  private log(
260
264
  level: LogLevel,
261
265
  message: string,
262
- data: ReadonlyArray<LogValue>,
266
+ details: LogFields,
263
267
  error?: SerializedError,
268
+ userId?: string,
264
269
  ): void {
265
270
  const threshold = this.config.level ?? settings.level
266
271
  if (threshold === 'OFF') return
267
272
  if (LOG_LEVELS.indexOf(level) < LOG_LEVELS.indexOf(threshold)) return
268
273
 
269
- 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 }
270
282
  logSink(error === undefined ? entry : { ...entry, error })
271
283
  }
272
284
 
273
285
  /** Write a debug log. */
274
- debug(message: string, ...values: ReadonlyArray<LogValue>): void {
275
- this.log(LogLevel.DEBUG, message, values)
286
+ debug(message: string, details: LogFields = {}): void {
287
+ this.log(LogLevel.DEBUG, message, details)
276
288
  }
277
289
 
278
290
  /** Write an information log. */
279
- info(message: string, ...values: ReadonlyArray<LogValue>): void {
280
- this.log(LogLevel.INFO, message, values)
291
+ info(message: string, details: LogFields = {}): void {
292
+ this.log(LogLevel.INFO, message, details)
281
293
  }
282
294
 
283
295
  /** Write a warning log. */
284
- warn(message: string, ...values: ReadonlyArray<LogValue>): void {
285
- this.log(LogLevel.WARN, message, values)
296
+ warn(message: string, details: LogFields = {}): void {
297
+ this.log(LogLevel.WARN, message, details)
286
298
  }
287
299
 
288
300
  /**
289
301
  * Write an error log and send the error to the configured hook.
290
302
  *
291
303
  * The details say which call this was, and the error says what went wrong
292
- * 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.
293
305
  */
294
306
  error(message: string, context: ErrorLogContext = {}): void {
295
307
  const details = context.details ?? {}
@@ -307,7 +319,7 @@ export class Logger {
307
319
  }
308
320
  error = { name: 'NonError', message: json }
309
321
  }
310
- this.log(LogLevel.ERROR, message, context.details === undefined ? [] : [details], error)
322
+ this.log(LogLevel.ERROR, message, details, error, context.userId)
311
323
 
312
324
  if (errorHook !== null && thrown !== undefined) {
313
325
  errorHook({ error: thrown, distinctId: context.userId, context: details })
@@ -8,24 +8,24 @@
8
8
  export type MessageId = string
9
9
 
10
10
  /** An action to perform. One handler, returns a value. The product adds its payload. */
11
- export type Command<Name extends string = string> = {
11
+ export type DomainCommand<Name extends string = string> = {
12
12
  readonly type: 'command'
13
13
  readonly name: Name
14
14
  readonly id: MessageId
15
15
  }
16
16
 
17
17
  /** A request for data. One handler, returns a value. No id: nothing claims a read. */
18
- export type Query<Name extends string = string> = {
18
+ export type DomainQuery<Name extends string = string> = {
19
19
  readonly type: 'query'
20
20
  readonly name: Name
21
21
  }
22
22
 
23
23
  /** A fact that happened. Zero or more subscribers, returns nothing. The product adds its payload. */
24
- export type Event<Name extends string = string> = {
24
+ export type DomainEvent<Name extends string = string> = {
25
25
  readonly type: 'event'
26
26
  readonly name: Name
27
27
  readonly id: MessageId
28
28
  }
29
29
 
30
30
  /** The routing fields the bus reads. `type` selects the table, `name` selects the slot. */
31
- export type Message = Command | Event | Query
31
+ export type DomainMessage = DomainCommand | DomainEvent | DomainQuery
@@ -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
+ }