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,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
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createMiddleware, type Register, type RequestServerOptions } from '@tanstack/react-start'
|
|
2
|
+
import { getRequestHeaders } from '@tanstack/react-start/server'
|
|
3
|
+
import { getSessionCookie } from 'better-auth/cookies'
|
|
4
|
+
|
|
5
|
+
import { AuthenticationError, StaleSessionError, createLogger } from '@/core'
|
|
6
|
+
|
|
7
|
+
const logger = createLogger('auth-middleware')
|
|
8
|
+
|
|
9
|
+
/** The fields middleware reads; generic callers retain every additional session and user field. */
|
|
10
|
+
export type AuthSession = {
|
|
11
|
+
readonly user: { readonly id: string }
|
|
12
|
+
readonly session: { readonly id: string }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Authenticated context retains the application's complete user and session types. */
|
|
16
|
+
export type RequiredAuthContext<Session extends AuthSession> = {
|
|
17
|
+
readonly user: Session['user']
|
|
18
|
+
readonly session: Session['session']
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Optional authentication supplies both fields together or neither field. */
|
|
22
|
+
export type AuthContext<Session extends AuthSession> =
|
|
23
|
+
| RequiredAuthContext<Session>
|
|
24
|
+
| {
|
|
25
|
+
readonly user: undefined
|
|
26
|
+
readonly session: undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Select the current request's Better Auth instance from TanStack's registered context. */
|
|
30
|
+
export type AuthMiddlewareOptions<Session extends AuthSession> = {
|
|
31
|
+
readonly getAuth: (context: RequestServerOptions<Register, undefined>['context']) => {
|
|
32
|
+
readonly api: {
|
|
33
|
+
readonly getSession: (options: { headers: Headers }) => Promise<Session | null>
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Preserve Porte's function and request middleware contracts with application-owned services.
|
|
40
|
+
* Optional lookup failures log once and continue as guests. Required lookup failures propagate.
|
|
41
|
+
*/
|
|
42
|
+
export function createAuthMiddleware<Session extends AuthSession>(
|
|
43
|
+
options: AuthMiddlewareOptions<Session>,
|
|
44
|
+
) {
|
|
45
|
+
const extractAuth = createMiddleware({ type: 'function' }).server<AuthContext<Session>>(
|
|
46
|
+
async ({ next, context }) => {
|
|
47
|
+
let resolved: Session | null
|
|
48
|
+
try {
|
|
49
|
+
resolved = await options.getAuth(context).api.getSession({ headers: getRequestHeaders() })
|
|
50
|
+
} catch (error) {
|
|
51
|
+
logger.error('get_session_failed', { error })
|
|
52
|
+
resolved = null
|
|
53
|
+
}
|
|
54
|
+
if (resolved === null)
|
|
55
|
+
return next<AuthContext<Session>>({ context: { user: undefined, session: undefined } })
|
|
56
|
+
return next<AuthContext<Session>>({
|
|
57
|
+
context: { user: resolved.user, session: resolved.session },
|
|
58
|
+
})
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async function resolveRequiredSession(
|
|
63
|
+
context: RequestServerOptions<Register, undefined>['context'],
|
|
64
|
+
headers: Headers,
|
|
65
|
+
): Promise<Session> {
|
|
66
|
+
const resolved = await options.getAuth(context).api.getSession({ headers })
|
|
67
|
+
if (resolved === null) {
|
|
68
|
+
if (getSessionCookie(headers)) throw new StaleSessionError()
|
|
69
|
+
throw new AuthenticationError()
|
|
70
|
+
}
|
|
71
|
+
return resolved
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const requireAuth = createMiddleware({ type: 'function' }).server(async ({ next, context }) => {
|
|
75
|
+
const resolved = await resolveRequiredSession(context, getRequestHeaders())
|
|
76
|
+
return next<RequiredAuthContext<Session>>({
|
|
77
|
+
context: { user: resolved.user, session: resolved.session },
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
const requireAuthRequest = createMiddleware({ type: 'request' }).server(
|
|
81
|
+
async ({ request, next, context }) => {
|
|
82
|
+
const resolved = await resolveRequiredSession(context, request.headers)
|
|
83
|
+
return next<RequiredAuthContext<Session>>({
|
|
84
|
+
context: { user: resolved.user, session: resolved.session },
|
|
85
|
+
})
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
return { extractAuth, requireAuth, requireAuthRequest }
|
|
89
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { isNotFound, isRedirect, type AnyRedirect } from '@tanstack/react-router'
|
|
2
|
+
import { createMiddleware, type Register, type RequestServerOptions } from '@tanstack/react-start'
|
|
3
|
+
import type { BetterAuthCookies } from 'better-auth'
|
|
4
|
+
|
|
5
|
+
import { StaleSessionError, type ErrorTable } from '@/core'
|
|
6
|
+
|
|
7
|
+
import { createHttpErrorResponseConverter } from './http-error-response.ts'
|
|
8
|
+
import { clearStaleSessionCookies } from './session-cookies.ts'
|
|
9
|
+
import { createErrorPayloadConverter } from './to-error-payload.ts'
|
|
10
|
+
|
|
11
|
+
type RequestContext = RequestServerOptions<Register, undefined>['context']
|
|
12
|
+
|
|
13
|
+
/** The part of a Better Auth instance the boundary reads: its computed cookie definitions. */
|
|
14
|
+
export type AuthCookieSource = {
|
|
15
|
+
readonly $context: Promise<{ readonly authCookies: BetterAuthCookies }>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The application's error table, its stale-session redirect, and its Better Auth instance. */
|
|
19
|
+
export type ErrorMiddlewareOptions<Entries> = {
|
|
20
|
+
readonly errors: ErrorTable<Entries>
|
|
21
|
+
/** Build the redirect a server function throws for a stale session. */
|
|
22
|
+
readonly signIn: () => AnyRedirect
|
|
23
|
+
/** Select the request's Better Auth instance; its cookie definitions say what to clear. */
|
|
24
|
+
readonly getAuth: (context: RequestContext) => AuthCookieSource
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build both transport boundaries from one error table.
|
|
29
|
+
* Register `functionErrorMiddleware` globally. Put `routeErrorMiddleware` first in an API route's `server.middleware`.
|
|
30
|
+
*/
|
|
31
|
+
export function createErrorMiddleware<Entries>(options: ErrorMiddlewareOptions<Entries>) {
|
|
32
|
+
const toErrorPayload = createErrorPayloadConverter(options.errors)
|
|
33
|
+
const toHttpErrorResponse = createHttpErrorResponseConverter(options.errors)
|
|
34
|
+
|
|
35
|
+
async function clearSession(context: RequestContext): Promise<void> {
|
|
36
|
+
const { authCookies } = await options.getAuth(context).$context
|
|
37
|
+
clearStaleSessionCookies(authCookies)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function handleFunctionError(cause: unknown, context: RequestContext): Promise<never> {
|
|
41
|
+
// oxlint-disable-next-line typescript/only-throw-error -- TanStack uses throwable control-flow objects.
|
|
42
|
+
if (isNotFound(cause) || isRedirect(cause)) throw cause
|
|
43
|
+
if (cause instanceof StaleSessionError) {
|
|
44
|
+
await clearSession(context)
|
|
45
|
+
// oxlint-disable-next-line typescript/only-throw-error -- Router redirects by throwing this.
|
|
46
|
+
throw options.signIn()
|
|
47
|
+
}
|
|
48
|
+
// oxlint-disable-next-line typescript/only-throw-error -- The client rejects with this and reads its tag.
|
|
49
|
+
throw toErrorPayload(cause)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A route answers 401 instead of a redirect because fetch would follow it into the HTML shell.
|
|
53
|
+
async function handleRouteError(cause: unknown, context: RequestContext): Promise<Response> {
|
|
54
|
+
// oxlint-disable-next-line typescript/only-throw-error -- TanStack uses throwable control-flow objects.
|
|
55
|
+
if (isNotFound(cause) || isRedirect(cause)) throw cause
|
|
56
|
+
if (cause instanceof StaleSessionError) await clearSession(context)
|
|
57
|
+
return toHttpErrorResponse(cause)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const functionErrorMiddleware = createMiddleware({ type: 'function' }).server(
|
|
61
|
+
async ({ next, context }) => {
|
|
62
|
+
try {
|
|
63
|
+
return await next()
|
|
64
|
+
} catch (cause) {
|
|
65
|
+
return handleFunctionError(cause, context)
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const routeErrorMiddleware = createMiddleware({ type: 'request' }).server(
|
|
71
|
+
async ({ next, context }) => {
|
|
72
|
+
try {
|
|
73
|
+
return await next()
|
|
74
|
+
} catch (cause) {
|
|
75
|
+
return handleRouteError(cause, context)
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return { functionErrorMiddleware, routeErrorMiddleware, toErrorPayload, toHttpErrorResponse }
|
|
81
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getReasonPhrase } from 'http-status-codes'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
import type { ErrorTable, ProblemDetails } from '@/core'
|
|
5
|
+
|
|
6
|
+
import { resolveFailure } from './resolve-failure.ts'
|
|
7
|
+
|
|
8
|
+
// A known delay becomes the header; a null delay sends no header.
|
|
9
|
+
const RetryAfterSchema = z.object({ retryAfter: z.number() })
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build the API route converter for one error table.
|
|
13
|
+
* The body is RFC 9457 problem details; public fields become extension members.
|
|
14
|
+
*/
|
|
15
|
+
export function createHttpErrorResponseConverter<Entries>(table: ErrorTable<Entries>) {
|
|
16
|
+
return function toHttpErrorResponse(cause: unknown): Response {
|
|
17
|
+
const { status, payload } = resolveFailure(table, cause)
|
|
18
|
+
const { _tag, message, ...fields } = payload
|
|
19
|
+
const body: ProblemDetails = {
|
|
20
|
+
type: 'about:blank',
|
|
21
|
+
title: getReasonPhrase(status),
|
|
22
|
+
detail: message,
|
|
23
|
+
...fields,
|
|
24
|
+
}
|
|
25
|
+
const headers = new Headers({ 'Content-Type': 'application/problem+json' })
|
|
26
|
+
const retry = RetryAfterSchema.safeParse(payload)
|
|
27
|
+
if (retry.success) headers.set('Retry-After', String(retry.data.retryAfter))
|
|
28
|
+
return Response.json(body, { status, headers })
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createAuthMiddleware,
|
|
3
|
+
type AuthSession,
|
|
4
|
+
type AuthContext,
|
|
5
|
+
type RequiredAuthContext,
|
|
6
|
+
type AuthMiddlewareOptions,
|
|
7
|
+
} from './auth.middleware.ts'
|
|
8
|
+
export {
|
|
9
|
+
createErrorMiddleware,
|
|
10
|
+
type AuthCookieSource,
|
|
11
|
+
type ErrorMiddlewareOptions,
|
|
12
|
+
} from './error.middleware.ts'
|