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,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
+ }
@@ -0,0 +1,232 @@
1
+ import type {
2
+ BetterAuthOptions,
3
+ BetterAuthPlugin,
4
+ BetterAuthRateLimitStorage,
5
+ DBAdapterInstance,
6
+ } from 'better-auth'
7
+ import { isAPIError } from 'better-auth/api'
8
+ import {
9
+ anonymous,
10
+ bearer,
11
+ captcha,
12
+ deviceAuthorization,
13
+ lastLoginMethod,
14
+ magicLink,
15
+ openAPI,
16
+ type CloudflareTurnstileOptions,
17
+ type LastLoginMethodOptions,
18
+ } from 'better-auth/plugins'
19
+ import { v7 as uuidv7 } from 'uuid'
20
+
21
+ import { createLogger } from '@/core'
22
+
23
+ const logger = createLogger('auth')
24
+
25
+ // Better Auth does not export its default captcha paths; keep them when adding magic-link requests.
26
+ const MAGIC_LINK_CAPTCHA_ENDPOINTS = [
27
+ '/sign-up/email',
28
+ '/sign-in/email',
29
+ '/request-password-reset',
30
+ '/sign-in/magic-link',
31
+ ]
32
+
33
+ /**
34
+ * Shared policy from Porte and Typist. The factory merges application overrides into these defaults.
35
+ */
36
+ export const authDefaults = {
37
+ onAPIError: {
38
+ throw: false,
39
+ onError(error) {
40
+ if (isAPIError(error) && error.statusCode < 500) return
41
+ logger.error('auth_operation_failed', { error })
42
+ },
43
+ },
44
+ rateLimit: { window: 60, max: 100 },
45
+ session: {
46
+ expiresIn: 60 * 60 * 24 * 7,
47
+ updateAge: 60 * 60 * 24,
48
+ freshAge: 60 * 60 * 24,
49
+ cookieCache: { enabled: true, maxAge: 300 },
50
+ },
51
+ advanced: {
52
+ database: { generateId: () => uuidv7(), joins: true },
53
+ ipAddress: { ipAddressHeaders: ['cf-connecting-ip', 'x-forwarded-for'] },
54
+ },
55
+ } satisfies BetterAuthOptions
56
+
57
+ /** Configure built-in plugins with native options. Runtime auth requires captcha; lastLoginMethod is enabled unless explicitly disabled. */
58
+ export type AuthPluginOptions = {
59
+ readonly magicLink?: Parameters<typeof magicLink>[0]
60
+ readonly anonymous?: NonNullable<Parameters<typeof anonymous>[0]>
61
+ readonly captcha?: Omit<CloudflareTurnstileOptions, 'provider'>
62
+ readonly lastLoginMethod?: false | NonNullable<Parameters<typeof lastLoginMethod>[0]>
63
+ readonly openAPI?: NonNullable<Parameters<typeof openAPI>[0]>
64
+ readonly bearer?: NonNullable<Parameters<typeof bearer>[0]>
65
+ readonly deviceAuthorization?: NonNullable<Parameters<typeof deviceAuthorization>[0]>
66
+ }
67
+
68
+ /**
69
+ * Runtime auth requires rate limiting and Turnstile credentials. Schema generation omits both.
70
+ * Both modes select plugins through `integrations`, so schema generation must repeat the runtime selection.
71
+ */
72
+ export type AuthConfig = Omit<BetterAuthOptions, 'database' | 'plugins' | 'rateLimit'> & {
73
+ readonly rateLimit?: Omit<
74
+ NonNullable<BetterAuthOptions['rateLimit']>,
75
+ 'enabled' | 'storage' | 'customStorage'
76
+ >
77
+ } & (
78
+ | {
79
+ readonly mode?: 'runtime'
80
+ readonly rateLimitStorage: BetterAuthRateLimitStorage
81
+ readonly integrations: AuthPluginOptions & {
82
+ readonly captcha: Omit<CloudflareTurnstileOptions, 'provider'>
83
+ }
84
+ }
85
+ | {
86
+ readonly mode: 'schema'
87
+ readonly rateLimitStorage?: never
88
+ readonly integrations: AuthPluginOptions
89
+ }
90
+ )
91
+
92
+ type PluginTypes = {
93
+ magicLink: ReturnType<typeof magicLink>
94
+ anonymous: ReturnType<typeof anonymous>
95
+ captcha: ReturnType<typeof captcha>
96
+ lastLoginMethod: ReturnType<typeof lastLoginMethod<{}>>
97
+ openAPI: ReturnType<typeof openAPI>
98
+ bearer: ReturnType<typeof bearer>
99
+ deviceAuthorization: ReturnType<typeof deviceAuthorization>
100
+ }
101
+
102
+ type LastLoginPlugin<Options extends AuthPluginOptions> = Options extends {
103
+ readonly lastLoginMethod: infer Settings extends LastLoginMethodOptions
104
+ }
105
+ ? ReturnType<typeof lastLoginMethod<Settings>>
106
+ : PluginTypes['lastLoginMethod']
107
+
108
+ type SelectedPlugin<Options extends AuthPluginOptions, Key extends keyof PluginTypes> =
109
+ Options extends Record<Key, false | undefined>
110
+ ? []
111
+ : Options extends Record<Key, NonNullable<AuthPluginOptions[Key]>>
112
+ ? [Key extends 'lastLoginMethod' ? LastLoginPlugin<Options> : PluginTypes[Key]]
113
+ : Key extends 'lastLoginMethod'
114
+ ? [PluginTypes[Key]]
115
+ : []
116
+
117
+ type SelectedPlugins<Options extends AuthPluginOptions> = [
118
+ ...SelectedPlugin<Options, 'captcha'>,
119
+ ...SelectedPlugin<Options, 'lastLoginMethod'>,
120
+ ...SelectedPlugin<Options, 'openAPI'>,
121
+ ...SelectedPlugin<Options, 'magicLink'>,
122
+ ...SelectedPlugin<Options, 'anonymous'>,
123
+ ...SelectedPlugin<Options, 'deviceAuthorization'>,
124
+ ...SelectedPlugin<Options, 'bearer'>,
125
+ ]
126
+
127
+ type ConfiguredPlugins<Config extends AuthConfig> = SelectedPlugins<Config['integrations']>
128
+
129
+ type MergeOptions<Defaults, Overrides> = Omit<Defaults, keyof Overrides> & Overrides
130
+
131
+ type SessionOptions<Config extends AuthConfig> = MergeOptions<
132
+ typeof authDefaults.session,
133
+ NonNullable<Config['session']>
134
+ >
135
+
136
+ type ConfiguredOptions<Config extends AuthConfig, Plugins extends BetterAuthPlugin[]> = Omit<
137
+ Config,
138
+ 'mode' | 'integrations' | 'rateLimitStorage' | 'rateLimit' | 'session' | 'advanced' | 'onAPIError'
139
+ > & {
140
+ database: DBAdapterInstance
141
+ onAPIError: MergeOptions<typeof authDefaults.onAPIError, NonNullable<Config['onAPIError']>>
142
+ rateLimit: NonNullable<BetterAuthOptions['rateLimit']>
143
+ session: Omit<SessionOptions<Config>, 'cookieCache'> & {
144
+ cookieCache: MergeOptions<
145
+ typeof authDefaults.session.cookieCache,
146
+ NonNullable<NonNullable<Config['session']>['cookieCache']>
147
+ >
148
+ }
149
+ advanced: MergeOptions<typeof authDefaults.advanced, NonNullable<Config['advanced']>> & {
150
+ database: MergeOptions<
151
+ typeof authDefaults.advanced.database,
152
+ NonNullable<NonNullable<Config['advanced']>['database']>
153
+ >
154
+ ipAddress: MergeOptions<
155
+ typeof authDefaults.advanced.ipAddress,
156
+ NonNullable<NonNullable<Config['advanced']>['ipAddress']>
157
+ >
158
+ }
159
+ plugins: [...ConfiguredPlugins<Config>, ...Plugins]
160
+ }
161
+
162
+ /** A required database adapter and application config produce options with inferred plugin and session types. */
163
+ export type BetterAuthOptionsFactory = <
164
+ const Config extends AuthConfig,
165
+ const Plugins extends BetterAuthPlugin[] = [],
166
+ >(
167
+ database: DBAdapterInstance,
168
+ config: Config,
169
+ additionalPlugins?: Plugins,
170
+ ) => ConfiguredOptions<Config, Plugins>
171
+
172
+ function buildPlugins<const Options extends AuthPluginOptions>(
173
+ options: Options,
174
+ ): SelectedPlugins<Options> {
175
+ const plugins: BetterAuthPlugin[] = []
176
+ if (options.captcha !== undefined) {
177
+ const captchaOptions = { ...options.captcha }
178
+ if (options.magicLink !== undefined && captchaOptions.endpoints === undefined)
179
+ captchaOptions.endpoints = [...MAGIC_LINK_CAPTCHA_ENDPOINTS]
180
+ plugins.push(captcha({ ...captchaOptions, provider: 'cloudflare-turnstile' }))
181
+ }
182
+ if (options.lastLoginMethod !== false) plugins.push(lastLoginMethod(options.lastLoginMethod))
183
+ if (options.openAPI !== undefined) plugins.push(openAPI(options.openAPI))
184
+ if (options.magicLink !== undefined)
185
+ plugins.push(magicLink({ expiresIn: 300, ...options.magicLink }))
186
+ if (options.anonymous !== undefined) plugins.push(anonymous(options.anonymous))
187
+ if (options.deviceAuthorization !== undefined)
188
+ plugins.push(deviceAuthorization(options.deviceAuthorization))
189
+ if (options.bearer !== undefined) plugins.push(bearer(options.bearer))
190
+ // SAFETY: Each branch adds exactly the plugin selected by the corresponding configuration key.
191
+ return plugins as SelectedPlugins<Options>
192
+ }
193
+
194
+ /**
195
+ * Build native Better Auth options with shared defaults and configured plugins; both arguments are required.
196
+ * Pass Stripe and other plugins in the third argument. Put tanstackStartCookies() last.
197
+ */
198
+ export const createBetterAuthOptions: BetterAuthOptionsFactory = (
199
+ database,
200
+ config,
201
+ additionalPlugins,
202
+ ) => {
203
+ const { mode, integrations, rateLimitStorage, ...options } = config
204
+ const result: BetterAuthOptions = {
205
+ ...options,
206
+ database,
207
+ onAPIError: { ...authDefaults.onAPIError, ...options.onAPIError },
208
+ rateLimit:
209
+ mode === 'schema'
210
+ ? { enabled: false }
211
+ : {
212
+ ...authDefaults.rateLimit,
213
+ ...options.rateLimit,
214
+ enabled: true,
215
+ customStorage: rateLimitStorage,
216
+ },
217
+ session: {
218
+ ...authDefaults.session,
219
+ ...options.session,
220
+ cookieCache: { ...authDefaults.session.cookieCache, ...options.session?.cookieCache },
221
+ },
222
+ advanced: {
223
+ ...authDefaults.advanced,
224
+ ...options.advanced,
225
+ database: { ...authDefaults.advanced.database, ...options.advanced?.database },
226
+ ipAddress: { ...authDefaults.advanced.ipAddress, ...options.advanced?.ipAddress },
227
+ },
228
+ plugins: [...buildPlugins(integrations), ...(additionalPlugins ?? [])],
229
+ }
230
+ // SAFETY: The factory preserves configured field schemas and plugins while merging only the documented default fields.
231
+ return result as ConfiguredOptions<typeof config, Exclude<typeof additionalPlugins, undefined>>
232
+ }
@@ -0,0 +1,69 @@
1
+ import type { BetterAuthRateLimitStorage } from 'better-auth'
2
+
3
+ import { AppError, createLogger } from '@/core'
4
+
5
+ /** Each rule must match its binding's deployed limit and period. Cloudflare accepts only 10 or 60 seconds. */
6
+ export type AuthRateLimitBinding = {
7
+ readonly binding: Pick<RateLimit, 'limit'>
8
+ readonly window: 10 | 60
9
+ readonly max: number
10
+ }
11
+
12
+ /** A binding failure with no known retry classification. The cause preserves the original failure. */
13
+ export class AuthRateLimitError extends AppError {
14
+ readonly _tag = 'AuthRateLimitError'
15
+ readonly classification = 'unknown'
16
+
17
+ constructor(cause: unknown) {
18
+ super('Authentication rate limiter is unavailable', { cause })
19
+ }
20
+ }
21
+
22
+ /** No configured binding matches this Better Auth rule. Outage policy cannot bypass a configuration error. */
23
+ export class UnsupportedAuthRateLimitRuleError extends AppError {
24
+ readonly _tag = 'UnsupportedAuthRateLimitRuleError'
25
+ readonly classification = 'terminal'
26
+
27
+ constructor(
28
+ readonly window: number,
29
+ readonly max: number,
30
+ ) {
31
+ super('No Cloudflare binding supports the authentication rate limit rule')
32
+ }
33
+ }
34
+
35
+ /** Typist allows requests during binding outages. Select block to propagate an error instead. */
36
+ export type AuthRateLimitOptions = {
37
+ readonly failureMode?: 'allow' | 'block'
38
+ }
39
+
40
+ /**
41
+ * Port Typist and Porte's binding adapters with explicit rule matching and configurable outage behavior.
42
+ * Bindings enforce approximate limits per Cloudflare location; retryAfter is a conservative full window.
43
+ */
44
+ export function createAuthRateLimitStorage(
45
+ bindings: readonly AuthRateLimitBinding[],
46
+ { failureMode = 'allow' }: AuthRateLimitOptions = {},
47
+ ): BetterAuthRateLimitStorage {
48
+ const logger = createLogger('auth-rate-limit')
49
+ return {
50
+ async consume(key, rule) {
51
+ const configured = bindings.find(
52
+ (entry) => entry.window === rule.window && entry.max === rule.max,
53
+ )
54
+ if (configured === undefined)
55
+ throw new UnsupportedAuthRateLimitRuleError(rule.window, rule.max)
56
+ try {
57
+ const { success } = await configured.binding.limit({ key })
58
+ return { allowed: success, retryAfter: success ? null : rule.window }
59
+ } catch (cause) {
60
+ if (failureMode === 'block') throw new AuthRateLimitError(cause)
61
+ logger.error('rate_limit_binding_failed', {
62
+ error: cause,
63
+ details: { window: rule.window, max: rule.max, failureMode },
64
+ })
65
+ return { allowed: true, retryAfter: null }
66
+ }
67
+ },
68
+ }
69
+ }
@@ -0,0 +1,21 @@
1
+ import type { BetterAuthOptions } from 'better-auth'
2
+
3
+ import type { AuthSecondaryStorage } from '../cloudflare/auth-secondary-storage.ts'
4
+
5
+ type SecondaryStorage = NonNullable<BetterAuthOptions['secondaryStorage']>
6
+
7
+ /**
8
+ * Implement Better Auth's SecondaryStorage interface through the application's AuthSecondaryStorage binding.
9
+ * Operations preserve TTL and atomic semantics; failures propagate without logging or retries.
10
+ */
11
+ export function createAuthSecondaryStorage(
12
+ binding: Pick<DurableObjectNamespace<AuthSecondaryStorage>, 'getByName'>,
13
+ ): SecondaryStorage {
14
+ return {
15
+ get: (key) => binding.getByName(key).get(),
16
+ set: (key, value, ttl) => binding.getByName(key).set(value, ttl),
17
+ delete: (key) => binding.getByName(key).delete(),
18
+ getAndDelete: (key) => binding.getByName(key).getAndDelete(),
19
+ increment: (key, ttl) => binding.getByName(key).increment(ttl),
20
+ }
21
+ }
@@ -0,0 +1,11 @@
1
+ import { stripe } from '@better-auth/stripe'
2
+
3
+ /**
4
+ * Configure the official Stripe plugin with Typist's customer creation policy.
5
+ * Customers are created at checkout by default. Native plans, callbacks, and overrides remain available.
6
+ */
7
+ export function createStripePlugin<const Options extends Parameters<typeof stripe>[0]>(
8
+ options: Options,
9
+ ): ReturnType<typeof stripe<Options>> {
10
+ return stripe({ createCustomerOnSignUp: false, ...options })
11
+ }
@@ -0,0 +1,113 @@
1
+ import { DurableObject } from 'cloudflare:workers'
2
+
3
+ import { AppError } from '@/core'
4
+
5
+ // Better Auth supplies whole seconds; an omitted TTL retains the value until explicit deletion.
6
+ const MIN_TTL_SECONDS = 1
7
+ const MILLISECONDS_PER_SECOND = 1000
8
+
9
+ type StoredValue = { value: string; expiresAt: number | null }
10
+
11
+ /** The supplied TTL cannot produce a valid expiry timestamp. Retrying the same input cannot fix it. */
12
+ export class InvalidTtlError extends AppError {
13
+ readonly _tag = 'InvalidTtlError'
14
+ readonly classification = 'terminal'
15
+
16
+ constructor() {
17
+ super('TTL must be positive integer seconds with a safe expiry timestamp')
18
+ }
19
+ }
20
+
21
+ /** The stored value cannot be incremented safely. The failed operation leaves it unchanged. */
22
+ export class InvalidCounterError extends AppError {
23
+ readonly _tag = 'InvalidCounterError'
24
+ readonly classification = 'terminal'
25
+
26
+ constructor() {
27
+ super('Counter must contain a safe integer that can be incremented')
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Store one Better Auth secondary storage key per object. Export this class from the application's Worker entrypoint.
33
+ * Schedule cleanup at the caller's expiry; reads enforce expiry even when the alarm runs late.
34
+ * Requires compatibility date 2026-02-24 or later so deleteAll also removes the alarm atomically.
35
+ */
36
+ export class AuthSecondaryStorage extends DurableObject {
37
+ /** Return null for absent or expired values, even when the cleanup alarm has not run. */
38
+ async get(): Promise<string | null> {
39
+ const record = await this.ctx.storage.get<StoredValue>('value')
40
+ return unexpiredRecord(record)?.value ?? null
41
+ }
42
+
43
+ /** Replace the value and its expiry. Omit ttl to keep the value until deletion. */
44
+ async set(value: string, ttl?: number): Promise<void> {
45
+ const expiresAt = ttl === undefined ? null : expiry(ttl)
46
+ await this.ctx.storage.transaction(async (transaction) => {
47
+ await transaction.put('value', { value, expiresAt })
48
+ if (expiresAt === null) await transaction.deleteAlarm()
49
+ else await transaction.setAlarm(expiresAt)
50
+ })
51
+ }
52
+
53
+ /** Remove the value and its cleanup alarm before answering. */
54
+ async delete(): Promise<void> {
55
+ await this.ctx.storage.deleteAll()
56
+ }
57
+
58
+ /** Consume the value once. Concurrent callers receive null after the first successful consumption. */
59
+ async getAndDelete(): Promise<string | null> {
60
+ // Storage input gates prevent another request from replacing the value between these operations.
61
+ const record = await this.ctx.storage.get<StoredValue>('value')
62
+ await this.ctx.storage.deleteAll()
63
+ return unexpiredRecord(record)?.value ?? null
64
+ }
65
+
66
+ /** Increment atomically. Apply ttl only when creating a counter or replacing an expired counter. */
67
+ async increment(ttl: number): Promise<number> {
68
+ const expiresAt = expiry(ttl)
69
+ return this.ctx.storage.transaction(async (transaction) => {
70
+ const record = unexpiredRecord(await transaction.get<StoredValue>('value'))
71
+ const count = record === undefined ? 1 : nextCount(record.value)
72
+ await transaction.put('value', {
73
+ value: String(count),
74
+ expiresAt: record === undefined ? expiresAt : record.expiresAt,
75
+ })
76
+ if (record === undefined) await transaction.setAlarm(expiresAt)
77
+ return count
78
+ })
79
+ }
80
+
81
+ /** Delete expired data. An old alarm must not delete a value whose expiry was extended. */
82
+ override async alarm(): Promise<void> {
83
+ const record = await this.ctx.storage.get<StoredValue>('value')
84
+ if (record?.expiresAt === null) return
85
+ if (record !== undefined && record.expiresAt > Date.now()) {
86
+ await this.ctx.storage.setAlarm(record.expiresAt)
87
+ return
88
+ }
89
+ await this.ctx.storage.deleteAll()
90
+ }
91
+ }
92
+
93
+ function unexpiredRecord(record: StoredValue | undefined): StoredValue | undefined {
94
+ if (record === undefined) return undefined
95
+ if (record.expiresAt !== null && record.expiresAt <= Date.now()) return undefined
96
+ return record
97
+ }
98
+
99
+ function expiry(ttl: number): number {
100
+ const expiresAt = Date.now() + ttl * MILLISECONDS_PER_SECOND
101
+ if (!Number.isSafeInteger(ttl) || ttl < MIN_TTL_SECONDS || !Number.isSafeInteger(expiresAt)) {
102
+ throw new InvalidTtlError()
103
+ }
104
+ return expiresAt
105
+ }
106
+
107
+ function nextCount(value: string): number {
108
+ const count = Number(value)
109
+ if (String(count) !== value || !Number.isSafeInteger(count) || !Number.isSafeInteger(count + 1)) {
110
+ throw new InvalidCounterError()
111
+ }
112
+ return count + 1
113
+ }
@@ -1,6 +1,6 @@
1
1
  import type { BatchItem } from 'drizzle-orm/batch'
2
2
 
3
- import type { Event } from '@/application'
3
+ import type { DomainEvent } from '@/core'
4
4
 
5
5
  import type { DrizzleSchema } from '../drizzle-schema.ts'
6
6
  import { eventCollector, stageD1Statement } from '../unit-of-work-context.ts'
@@ -9,7 +9,7 @@ import type { D1Db } from './d1-database-types.ts'
9
9
 
10
10
  /** A domain aggregate that accumulates events for the unit of work to drain. */
11
11
  export interface AggregateEvents {
12
- collectEvents(): ReadonlyArray<Event>
12
+ collectEvents(): ReadonlyArray<DomainEvent>
13
13
  clearEvents(): void
14
14
  }
15
15
 
@@ -1,11 +1,7 @@
1
1
  import { eq } from 'drizzle-orm'
2
2
 
3
- import {
4
- MessageAlreadyProcessedError,
5
- type Event,
6
- type IMessageStore,
7
- type MessageId,
8
- } from '@/application'
3
+ import { MessageAlreadyProcessedError, type IMessageStore, type MessageId } from '@/application'
4
+ import type { DomainEvent } from '@/core'
9
5
 
10
6
  import type { DrizzleSchema } from '../drizzle-schema.ts'
11
7
  import { BaseD1Repository } from './base-d1-repository.ts'
@@ -30,7 +26,7 @@ export class D1MessageStore<Schema extends DrizzleSchema>
30
26
  this.stage(this.db.insert(processedMessages).values({ messageId }))
31
27
  }
32
28
 
33
- async persistEvents(events: ReadonlyArray<Event>): Promise<void> {
29
+ async persistEvents(events: ReadonlyArray<DomainEvent>): Promise<void> {
34
30
  if (events.length === 0) return
35
31
  this.stage(
36
32
  this.db
@@ -1,7 +1,7 @@
1
1
  import { sql } from 'drizzle-orm'
2
2
  import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
3
3
 
4
- import type { Event } from '@/application'
4
+ import type { DomainEvent } from '@/core'
5
5
 
6
6
  /** One row per committed message. The primary key is the idempotency claim. */
7
7
  export const processedMessages = sqliteTable(
@@ -20,7 +20,7 @@ export const messageOutbox = sqliteTable(
20
20
  'message_outbox',
21
21
  {
22
22
  id: text('id').primaryKey(),
23
- payload: text('payload', { mode: 'json' }).$type<Event>().notNull(),
23
+ payload: text('payload', { mode: 'json' }).$type<DomainEvent>().notNull(),
24
24
  createdAt: integer('created_at', { mode: 'timestamp' })
25
25
  .notNull()
26
26
  .$defaultFn(() => new Date()),
@@ -1,4 +1,4 @@
1
- import type { Event } from '@/application'
1
+ import type { DomainEvent } from '@/core'
2
2
 
3
3
  import type { DrizzleSchema } from '../drizzle-schema.ts'
4
4
  import { eventCollector } from '../unit-of-work-context.ts'
@@ -7,7 +7,7 @@ import type { PostgresDbTransaction } from './postgres-database-types.ts'
7
7
 
8
8
  /** A domain aggregate that accumulates events for the unit of work to drain. */
9
9
  export interface AggregateEvents {
10
- collectEvents(): ReadonlyArray<Event>
10
+ collectEvents(): ReadonlyArray<DomainEvent>
11
11
  clearEvents(): void
12
12
  }
13
13
 
@@ -1,9 +1,5 @@
1
- import {
2
- MessageAlreadyProcessedError,
3
- type Event,
4
- type IMessageStore,
5
- type MessageId,
6
- } from '@/application'
1
+ import { MessageAlreadyProcessedError, type IMessageStore, type MessageId } from '@/application'
2
+ import type { DomainEvent } from '@/core'
7
3
 
8
4
  import type { DrizzleSchema } from '../drizzle-schema.ts'
9
5
  import type { PostgresDbTransaction } from './postgres-database-types.ts'
@@ -23,7 +19,7 @@ export class PostgresMessageStore<Schema extends DrizzleSchema> implements IMess
23
19
  if (claimed.length === 0) throw new MessageAlreadyProcessedError(messageId)
24
20
  }
25
21
 
26
- async persistEvents(events: ReadonlyArray<Event>): Promise<void> {
22
+ async persistEvents(events: ReadonlyArray<DomainEvent>): Promise<void> {
27
23
  if (events.length === 0) return
28
24
  await this.db
29
25
  .insert(messageOutbox)
@@ -1,7 +1,7 @@
1
1
  import { isNull } from 'drizzle-orm'
2
2
  import { index, integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
3
3
 
4
- import type { Event } from '@/application'
4
+ import type { DomainEvent } from '@/core'
5
5
 
6
6
  /** One row per committed message. The primary key is the idempotency claim. */
7
7
  export const processedMessages = pgTable('processed_messages', {
@@ -14,7 +14,7 @@ export const messageOutbox = pgTable(
14
14
  'message_outbox',
15
15
  {
16
16
  id: text('id').primaryKey(),
17
- payload: jsonb('payload').$type<Event>().notNull(),
17
+ payload: jsonb('payload').$type<DomainEvent>().notNull(),
18
18
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
19
19
  availableAt: timestamp('available_at', { withTimezone: true }).notNull().defaultNow(),
20
20
  attempts: integer('attempts').notNull().default(0),