@avelonjs/neon 0.3.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.
@@ -0,0 +1,462 @@
1
+ import {
2
+ Invalid,
3
+ Unauthenticated,
4
+ type EmailVerificationIdentitySurface,
5
+ type IdentityDriver,
6
+ type MagicLinkIdentitySurface,
7
+ type MfaChallenge,
8
+ type MfaIdentitySurface,
9
+ type PasswordIdentitySurface,
10
+ type RequestCookies,
11
+ } from '@avelonjs/core'
12
+ import {
13
+ clearSessionCookie,
14
+ DEFAULT_SESSION_COOKIE,
15
+ readSessionCookie,
16
+ writeSessionCookie,
17
+ } from './cookies'
18
+ import { describeAuthFailure, mapAuthError, readAuthResponse } from './errors'
19
+ import type { NeonActor, NeonSession } from './types'
20
+
21
+ /** Exact capability declaration for the Neon identity driver. */
22
+ export const neonIdentityCapabilities = {
23
+ passwords: true,
24
+ magicLinks: true,
25
+ oauth: false,
26
+ organizations: false,
27
+ mfa: ['totp'] as const,
28
+ emailVerification: true,
29
+ } as const
30
+
31
+ type NeonMfaFactor = (typeof neonIdentityCapabilities.mfa)[number]
32
+
33
+ /** Construction options for {@link createNeonIdentity}. */
34
+ export interface NeonIdentityOptions {
35
+ /** Neon Auth / Better Auth base URL, e.g. `https://ep-xxx.neonauth.net`. */
36
+ authUrl?: string
37
+ /** Configured identity instance name. */
38
+ instance?: string
39
+ /** Session cookie name written into the request cookie jar. */
40
+ cookieName?: string
41
+ }
42
+
43
+ interface AuthUser {
44
+ id?: string
45
+ email?: string | null
46
+ }
47
+
48
+ interface AuthSessionBody {
49
+ user?: AuthUser
50
+ session?: { id?: string; expiresAt?: string; token?: string }
51
+ token?: string
52
+ error?: string
53
+ message?: string
54
+ }
55
+
56
+ /**
57
+ * Neon Auth identity driver.
58
+ *
59
+ * Talks to the Better Auth HTTP surface Neon Auth exposes. The config-time factory receives
60
+ * request-scoped cookies explicitly so SSR and route handlers share one request-local identity view.
61
+ */
62
+ export class NeonIdentity
63
+ implements
64
+ IdentityDriver<typeof neonIdentityCapabilities, { authUrl: string }, NeonActor, NeonSession>,
65
+ PasswordIdentitySurface<NeonActor>,
66
+ MagicLinkIdentitySurface,
67
+ MfaIdentitySurface<NeonMfaFactor>,
68
+ EmailVerificationIdentitySurface
69
+ {
70
+ readonly name = 'neon'
71
+ readonly instance: string
72
+ readonly capabilities = neonIdentityCapabilities
73
+
74
+ readonly #cookies: RequestCookies
75
+ readonly #authUrl: string
76
+ readonly #cookieName: string
77
+
78
+ constructor(cookies: RequestCookies, options: Required<NeonIdentityOptions>) {
79
+ this.#cookies = cookies
80
+ this.#authUrl = options.authUrl.replace(/\/$/, '')
81
+ this.#cookieName = options.cookieName
82
+ this.instance = options.instance
83
+ }
84
+
85
+ raw(): { authUrl: string } {
86
+ return { authUrl: this.#authUrl }
87
+ }
88
+
89
+ async user(): Promise<NeonActor | null> {
90
+ const session = readSessionCookie(this.#cookies, this.#cookieName)
91
+ if (!session) return null
92
+ const response = await this.#request('/get-session', {
93
+ method: 'GET',
94
+ headers: this.#authHeaders(session.accessToken),
95
+ })
96
+ if (!response.ok) return null
97
+ const body = asSessionBody((await readAuthResponse(response)).json)
98
+ const user = body.user
99
+ if (typeof user?.id !== 'string' || typeof user.email !== 'string') return null
100
+ return { id: user.id, email: user.email }
101
+ }
102
+
103
+ async session(): Promise<NeonSession | null> {
104
+ return readSessionCookie(this.#cookies, this.#cookieName)
105
+ }
106
+
107
+ async signOut(): Promise<void> {
108
+ const session = readSessionCookie(this.#cookies, this.#cookieName)
109
+ if (!session) {
110
+ throw new Unauthenticated('No authenticated session is present.', {
111
+ metadata: { guard: 'identity' },
112
+ })
113
+ }
114
+ await this.#request('/sign-out', {
115
+ method: 'POST',
116
+ headers: this.#authHeaders(session.accessToken),
117
+ })
118
+ clearSessionCookie(this.#cookies, this.#cookieName)
119
+ }
120
+
121
+ async register(email: string, password: string): Promise<NeonActor> {
122
+ const response = await this.#request('/sign-up/email', {
123
+ method: 'POST',
124
+ headers: this.#jsonHeaders(),
125
+ body: JSON.stringify({ email, password }),
126
+ })
127
+ const body = await readAuthResponse(response)
128
+ if (!response.ok) {
129
+ mapAuthError(describeAuthFailure(response, body, 'Registration failed.'), {
130
+ operation: 'identity.register',
131
+ field: 'email',
132
+ rejection: 'unavailable',
133
+ cause: body.text,
134
+ })
135
+ }
136
+ const parsed = asSessionBody(body.json)
137
+ // Better Auth answers a signup that still needs email verification with the user and a null
138
+ // token. The actor exists; reporting that as a failed registration says the opposite of what
139
+ // happened, and the retry then collides with the account the first attempt created.
140
+ const pending = pendingVerificationActor(parsed)
141
+ if (pending !== undefined) return pending
142
+ return this.#commitSession(parsed)
143
+ }
144
+
145
+ async signInWithPassword(email: string, password: string): Promise<NeonActor> {
146
+ const response = await this.#request('/sign-in/email', {
147
+ method: 'POST',
148
+ headers: this.#jsonHeaders(),
149
+ body: JSON.stringify({ email, password }),
150
+ })
151
+ const body = await readAuthResponse(response)
152
+ if (!response.ok) {
153
+ mapAuthError(describeAuthFailure(response, body, 'Invalid login credentials.'), {
154
+ operation: 'identity.signInWithPassword',
155
+ field: 'email',
156
+ rejection: 'unauthenticated',
157
+ cause: body.text,
158
+ })
159
+ }
160
+ return this.#commitSession(asSessionBody(body.json))
161
+ }
162
+
163
+ /**
164
+ * Asks Neon Auth to email a recovery link.
165
+ *
166
+ * Better Auth answers 200 for an address it does not know, so reporting the failure statuses does
167
+ * not reveal whether an account exists. Discarding them does hide a throttled project behind a
168
+ * screen that claims the message was sent.
169
+ */
170
+ async sendPasswordReset(email: string): Promise<void> {
171
+ const response = await this.#request('/forget-password', {
172
+ method: 'POST',
173
+ headers: this.#jsonHeaders(),
174
+ body: JSON.stringify({ email }),
175
+ })
176
+ if (!response.ok) {
177
+ const body = await readAuthResponse(response)
178
+ mapAuthError(describeAuthFailure(response, body, 'Password reset could not be sent.'), {
179
+ operation: 'identity.sendPasswordReset',
180
+ field: 'email',
181
+ rejection: 'unavailable',
182
+ cause: body.text,
183
+ })
184
+ }
185
+ }
186
+
187
+ async resetPassword(token: string, password: string): Promise<void> {
188
+ const response = await this.#request('/reset-password', {
189
+ method: 'POST',
190
+ headers: this.#jsonHeaders(),
191
+ body: JSON.stringify({ token, newPassword: password }),
192
+ })
193
+ if (!response.ok) {
194
+ const body = await readAuthResponse(response)
195
+ mapAuthError(describeAuthFailure(response, body, 'Invalid recovery token.'), {
196
+ operation: 'identity.resetPassword',
197
+ field: 'token',
198
+ rejection: 'unauthenticated',
199
+ cause: body.text,
200
+ })
201
+ }
202
+ }
203
+
204
+ async updatePassword(password: string): Promise<void> {
205
+ const session = readSessionCookie(this.#cookies, this.#cookieName)
206
+ if (!session) {
207
+ throw new Unauthenticated('No authenticated session is present.', {
208
+ metadata: { guard: 'identity' },
209
+ })
210
+ }
211
+ const response = await this.#request('/change-password', {
212
+ method: 'POST',
213
+ headers: { ...this.#jsonHeaders(), ...this.#authHeaders(session.accessToken) },
214
+ body: JSON.stringify({ newPassword: password }),
215
+ })
216
+ if (!response.ok) {
217
+ throw new Unauthenticated('No authenticated session is present.', {
218
+ metadata: { guard: 'identity' },
219
+ })
220
+ }
221
+ }
222
+
223
+ async sendMagicLink(email: string, redirectTo?: string): Promise<void> {
224
+ const response = await this.#request('/sign-in/magic-link', {
225
+ method: 'POST',
226
+ headers: this.#jsonHeaders(),
227
+ body: JSON.stringify({
228
+ email,
229
+ ...(redirectTo === undefined ? {} : { callbackURL: redirectTo }),
230
+ }),
231
+ })
232
+ if (!response.ok) {
233
+ const body = await readAuthResponse(response)
234
+ mapAuthError(describeAuthFailure(response, body, 'Magic link could not be sent.'), {
235
+ operation: 'identity.sendMagicLink',
236
+ field: 'email',
237
+ rejection: 'unavailable',
238
+ cause: body.text,
239
+ })
240
+ }
241
+ }
242
+
243
+ /** Completes a sign-in from the token the emailed link carried. */
244
+ async signInWithMagicLink(token: string): Promise<NeonActor> {
245
+ const response = await this.#request(`/magic-link/verify?token=${encodeURIComponent(token)}`, {
246
+ method: 'GET',
247
+ headers: this.#jsonHeaders(),
248
+ })
249
+ const body = await readAuthResponse(response)
250
+ if (!response.ok) {
251
+ mapAuthError(describeAuthFailure(response, body, 'Invalid sign-in link.'), {
252
+ operation: 'identity.signInWithMagicLink',
253
+ field: 'token',
254
+ rejection: 'unauthenticated',
255
+ cause: body.text,
256
+ })
257
+ }
258
+ return this.#commitSession(asSessionBody(body.json))
259
+ }
260
+
261
+ async challengeMfa(factor?: NeonMfaFactor): Promise<MfaChallenge<NeonMfaFactor>> {
262
+ const selected: string = factor ?? 'totp'
263
+ if (selected !== 'totp') {
264
+ throw invalidField('factor', 'Unsupported multi-factor method.')
265
+ }
266
+ const accessToken = this.#requireAccessToken()
267
+ if (!(await this.#isTotpEnrolled(accessToken))) {
268
+ throw invalidField('factor', 'No TOTP factor is enrolled.')
269
+ }
270
+ const expiresAt = new Date(Date.now() + 5 * 60 * 1000)
271
+ return {
272
+ id: `totp:${expiresAt.getTime()}`,
273
+ factor: 'totp',
274
+ expiresAt,
275
+ }
276
+ }
277
+
278
+ async verifyMfa(challengeId: string, code: string): Promise<void> {
279
+ const accessToken = this.#requireAccessToken()
280
+ const encoded = parseEncodedTotpChallenge(challengeId)
281
+ if (encoded.expiresAt <= Date.now()) {
282
+ throw invalidField('challengeId', 'MFA challenge has expired.')
283
+ }
284
+ const verified = await this.#request('/two-factor/verify-totp', {
285
+ method: 'POST',
286
+ headers: { ...this.#jsonHeaders(), ...this.#authHeaders(accessToken) },
287
+ body: JSON.stringify({ code }),
288
+ })
289
+ if (!verified.ok) {
290
+ if (verified.status === 401) {
291
+ throw unauthenticated('No authenticated session is present.')
292
+ }
293
+ throw invalidField('code', 'Invalid multi-factor challenge.')
294
+ }
295
+ }
296
+
297
+ async sendEmailVerification(email?: string): Promise<void> {
298
+ let target = email
299
+ if (target === undefined) {
300
+ const actor = await this.user()
301
+ if (!actor) throw invalidField('email', 'No email to verify.')
302
+ target = actor.email
303
+ }
304
+ const session = readSessionCookie(this.#cookies, this.#cookieName)
305
+ const response = await this.#request('/send-verification-email', {
306
+ method: 'POST',
307
+ headers: {
308
+ ...this.#jsonHeaders(),
309
+ ...(session ? this.#authHeaders(session.accessToken) : {}),
310
+ },
311
+ body: JSON.stringify({ email: target }),
312
+ })
313
+ if (!response.ok) {
314
+ const body = await readAuthResponse(response)
315
+ mapAuthError(describeAuthFailure(response, body, 'Verification email could not be sent.'), {
316
+ operation: 'identity.sendEmailVerification',
317
+ field: 'email',
318
+ rejection: 'unavailable',
319
+ cause: body.text,
320
+ })
321
+ }
322
+ }
323
+
324
+ async verifyEmail(token: string): Promise<void> {
325
+ const verified = await this.#request(`/verify-email?token=${encodeURIComponent(token)}`, {
326
+ method: 'GET',
327
+ })
328
+ if (!verified.ok) {
329
+ throw invalidField('token', 'Invalid email verification token.')
330
+ }
331
+ }
332
+
333
+ #commitSession(body: AuthSessionBody): NeonActor {
334
+ const user = body.user
335
+ const token = body.token ?? body.session?.token
336
+ const sessionId = body.session?.id ?? token
337
+ if (
338
+ typeof user?.id !== 'string' ||
339
+ typeof user.email !== 'string' ||
340
+ typeof token !== 'string' ||
341
+ typeof sessionId !== 'string'
342
+ ) {
343
+ throw new Invalid('Auth response was missing a session.', {
344
+ metadata: { fields: { session: ['Incomplete auth response'] } },
345
+ })
346
+ }
347
+ const expiresAt = body.session?.expiresAt
348
+ ? new Date(body.session.expiresAt)
349
+ : new Date(Date.now() + 3600 * 1000)
350
+ const session: NeonSession = {
351
+ id: sessionId,
352
+ accessToken: token,
353
+ refreshToken: token,
354
+ expiresAt,
355
+ }
356
+ writeSessionCookie(this.#cookies, this.#cookieName, session)
357
+ return { id: user.id, email: user.email }
358
+ }
359
+
360
+ #jsonHeaders(): Record<string, string> {
361
+ return { 'Content-Type': 'application/json' }
362
+ }
363
+
364
+ #authHeaders(accessToken: string): Record<string, string> {
365
+ return { Authorization: `Bearer ${accessToken}` }
366
+ }
367
+
368
+ #requireAccessToken(): string {
369
+ const session = readSessionCookie(this.#cookies, this.#cookieName)
370
+ if (!session) throw unauthenticated('No authenticated session is present.')
371
+ return session.accessToken
372
+ }
373
+
374
+ async #isTotpEnrolled(accessToken: string): Promise<boolean> {
375
+ const sessionResponse = await this.#request('/get-session', {
376
+ method: 'GET',
377
+ headers: this.#authHeaders(accessToken),
378
+ })
379
+ if (!sessionResponse.ok) {
380
+ throw unauthenticated('No authenticated session is present.')
381
+ }
382
+ if (twoFactorEnabled(await readVendorJson(sessionResponse))) return true
383
+ const statusResponse = await this.#request('/two-factor/status', {
384
+ method: 'GET',
385
+ headers: this.#authHeaders(accessToken),
386
+ })
387
+ if (!statusResponse.ok) {
388
+ if (statusResponse.status === 401) {
389
+ throw unauthenticated('No authenticated session is present.')
390
+ }
391
+ return false
392
+ }
393
+ return twoFactorEnabled(await readVendorJson(statusResponse))
394
+ }
395
+
396
+ async #request(path: string, init: RequestInit): Promise<Response> {
397
+ return fetch(`${this.#authUrl}${path}`, init)
398
+ }
399
+ }
400
+
401
+ function unauthenticated(message: string): Unauthenticated {
402
+ return new Unauthenticated(message, { metadata: { guard: 'identity' } })
403
+ }
404
+
405
+ function invalidField(field: string, message: string): Invalid {
406
+ return new Invalid(message, { metadata: { fields: { [field]: [message] } } })
407
+ }
408
+
409
+ function isRecord(value: unknown): value is Record<string, unknown> {
410
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
411
+ }
412
+
413
+ /** Reads a Better Auth JSON body at the vendor HTTP boundary. */
414
+ async function readVendorJson(response: Response): Promise<unknown> {
415
+ return (await readAuthResponse(response)).json
416
+ }
417
+
418
+ function asSessionBody(json: unknown): AuthSessionBody {
419
+ return isRecord(json) ? (json as AuthSessionBody) : {}
420
+ }
421
+
422
+ /** The actor from a signup that succeeded but issued no session token. */
423
+ function pendingVerificationActor(body: AuthSessionBody): NeonActor | undefined {
424
+ if (body.token !== undefined && body.token !== null) return undefined
425
+ if (body.session?.token !== undefined && body.session.token !== null) return undefined
426
+ const user = body.user
427
+ if (typeof user?.id !== 'string' || typeof user.email !== 'string') return undefined
428
+ return { id: user.id, email: user.email }
429
+ }
430
+
431
+ function twoFactorEnabled(body: unknown): boolean {
432
+ if (!isRecord(body)) return false
433
+ if (body.enabled === true) return true
434
+ const user = body.user
435
+ return isRecord(user) && user.twoFactorEnabled === true
436
+ }
437
+
438
+ function parseEncodedTotpChallenge(challengeId: string): { expiresAt: number } {
439
+ if (!challengeId.startsWith('totp:') || challengeId.length <= 'totp:'.length) {
440
+ throw invalidField('challengeId', 'MFA challenge id is malformed.')
441
+ }
442
+ const expiresAt = Number(challengeId.slice('totp:'.length))
443
+ if (!Number.isFinite(expiresAt)) {
444
+ throw invalidField('challengeId', 'MFA challenge id is malformed.')
445
+ }
446
+ return { expiresAt }
447
+ }
448
+
449
+ /**
450
+ * Creates the config-time identity factory pinned by conformance:
451
+ * `(cookies: RequestCookies) => NeonIdentity`.
452
+ */
453
+ export function createNeonIdentity(
454
+ options: NeonIdentityOptions = {},
455
+ ): (cookies: RequestCookies) => NeonIdentity {
456
+ const resolved: Required<NeonIdentityOptions> = {
457
+ authUrl: options.authUrl ?? process.env.NEON_AUTH_URL ?? 'http://127.0.0.1:3000/api/auth',
458
+ instance: options.instance ?? 'default',
459
+ cookieName: options.cookieName ?? DEFAULT_SESSION_COOKIE,
460
+ }
461
+ return (cookies) => new NeonIdentity(cookies, resolved)
462
+ }
@@ -0,0 +1,206 @@
1
+ import { Conflict, Invalid, RateLimited, Unauthenticated, Unavailable } from '@avelonjs/core'
2
+
3
+ /**
4
+ * A Better Auth response body read once, before anything decides whether it succeeded.
5
+ *
6
+ * Neon Auth answers a rejected route with HTML or an empty body, so `response.json()` on the
7
+ * failure path throws a `SyntaxError` that never reaches the error taxonomy. Reading the text
8
+ * first keeps the raw payload for `.cause`.
9
+ */
10
+ export interface AuthResponseBody {
11
+ /** Parsed JSON, or `undefined` when the body was empty or not JSON. */
12
+ readonly json: unknown
13
+ /** Raw response text. */
14
+ readonly text: string
15
+ }
16
+
17
+ /** Reads a Better Auth body at the vendor HTTP boundary without throwing on a non-JSON payload. */
18
+ export async function readAuthResponse(response: Response): Promise<AuthResponseBody> {
19
+ const text = await response.text()
20
+ if (text.length === 0) return { json: undefined, text }
21
+ try {
22
+ return { json: JSON.parse(text) as unknown, text }
23
+ } catch {
24
+ return { json: undefined, text }
25
+ }
26
+ }
27
+
28
+ function isRecord(value: unknown): value is Record<string, unknown> {
29
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
30
+ }
31
+
32
+ function stringOrUndefined(value: unknown): string | undefined {
33
+ return typeof value === 'string' && value.length > 0 ? value : undefined
34
+ }
35
+
36
+ /**
37
+ * Whether a string is a machine code rather than prose.
38
+ *
39
+ * Better Auth puts a code in `error` on the routes it inherits from the OAuth shape and a sentence
40
+ * in `error` on the ones it does not. One field, two meanings, separated by whether it has a space.
41
+ */
42
+ function isCodeLike(value: string): boolean {
43
+ return !/\s/.test(value)
44
+ }
45
+
46
+ /** The human-readable reason, across the flat and nested shapes Better Auth returns. */
47
+ export function authErrorMessage(json: unknown): string | undefined {
48
+ if (!isRecord(json)) return undefined
49
+ const nested = isRecord(json.error) ? json.error : undefined
50
+ const bare = stringOrUndefined(json.error)
51
+ return (
52
+ stringOrUndefined(json.message) ??
53
+ stringOrUndefined(nested?.message) ??
54
+ stringOrUndefined(json.statusText) ??
55
+ (bare !== undefined && !isCodeLike(bare) ? bare : undefined)
56
+ )
57
+ }
58
+
59
+ /** The stable Better Auth error code, upper-cased so a shape change in casing does not matter. */
60
+ export function authErrorCode(json: unknown): string | undefined {
61
+ if (!isRecord(json)) return undefined
62
+ const nested = isRecord(json.error) ? json.error : undefined
63
+ const bare = stringOrUndefined(json.error)
64
+ const code =
65
+ stringOrUndefined(json.code) ??
66
+ stringOrUndefined(nested?.code) ??
67
+ (bare !== undefined && isCodeLike(bare) ? bare : undefined)
68
+ return code?.toUpperCase()
69
+ }
70
+
71
+ const DUPLICATE_CODES = new Set([
72
+ 'USER_ALREADY_EXISTS',
73
+ 'USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL',
74
+ 'EMAIL_ALREADY_EXISTS',
75
+ 'USER_EXISTS',
76
+ ])
77
+
78
+ const RATE_LIMIT_CODES = new Set(['TOO_MANY_REQUESTS', 'RATE_LIMITED'])
79
+
80
+ const DISABLED_CODES = new Set(['SIGN_UP_DISABLED', 'EMAIL_AND_PASSWORD_DISABLED'])
81
+
82
+ const CREDENTIAL_CODES = new Set([
83
+ 'INVALID_EMAIL_OR_PASSWORD',
84
+ 'INVALID_GRANT',
85
+ 'NOT_AUTHENTICATED',
86
+ 'INVALID_PASSWORD',
87
+ 'EMAIL_NOT_VERIFIED',
88
+ 'USER_NOT_FOUND',
89
+ 'SESSION_EXPIRED',
90
+ 'UNAUTHORIZED',
91
+ ])
92
+
93
+ const WEAK_PASSWORD_CODES = new Set([
94
+ 'PASSWORD_TOO_SHORT',
95
+ 'PASSWORD_TOO_LONG',
96
+ 'WEAK_PASSWORD',
97
+ 'INVALID_PASSWORD_FORMAT',
98
+ ])
99
+
100
+ /** How a Better Auth failure is described once the vendor shape has been read. */
101
+ export interface AuthFailure {
102
+ /** HTTP status the Auth surface returned. */
103
+ readonly status: number
104
+ /** Stable Better Auth error code when the body carried one. */
105
+ readonly code: string | undefined
106
+ /** Vendor message, already falling back to the caller's wording. */
107
+ readonly message: string
108
+ }
109
+
110
+ /**
111
+ * Describes a failed Auth response without deciding which taxonomy member it belongs to.
112
+ *
113
+ * A body with no readable message leaves only the status to report, so the status is stated.
114
+ */
115
+ export function describeAuthFailure(
116
+ response: Response,
117
+ body: AuthResponseBody,
118
+ fallback: string,
119
+ ): AuthFailure {
120
+ const message = authErrorMessage(body.json)
121
+ return {
122
+ status: response.status,
123
+ code: authErrorCode(body.json),
124
+ message: message ?? `${fallback} Neon Auth returned HTTP ${response.status}.`,
125
+ }
126
+ }
127
+
128
+ /** Where a failure belongs in the form: the field that carries its message. */
129
+ export type AuthField = 'email' | 'password' | 'token' | 'code'
130
+
131
+ /** How a caller wants a 401 or 403 that is not a rejected key classified. */
132
+ export type AuthRejection = 'unauthenticated' | 'unavailable'
133
+
134
+ /**
135
+ * Maps a failed Better Auth response onto the framework taxonomy, keeping the body as `.cause`.
136
+ *
137
+ * `rejection` exists because a 401 means different things per operation. Sign-in answers one with
138
+ * `Unauthenticated`, which the kernel turns into a redirect to the login page. Registration answers
139
+ * the same status with `Unavailable`: nobody was authenticating, so bouncing to a login page hides
140
+ * a project that is refusing the call.
141
+ */
142
+ export function mapAuthError(
143
+ failure: AuthFailure,
144
+ options: {
145
+ readonly operation: string
146
+ readonly field: AuthField
147
+ readonly rejection: AuthRejection
148
+ readonly cause: unknown
149
+ },
150
+ ): never {
151
+ const { message, code, status } = failure
152
+ const cause = { status, code, body: options.cause, operation: options.operation }
153
+
154
+ if (
155
+ DUPLICATE_CODES.has(code ?? '') ||
156
+ /already (registered|been registered|exists)/i.test(message)
157
+ ) {
158
+ throw new Conflict(message, { metadata: { resource: 'actor', key: 'email' }, cause })
159
+ }
160
+
161
+ if (RATE_LIMIT_CODES.has(code ?? '') || status === 429) {
162
+ throw new RateLimited(message, { metadata: { key: options.operation }, cause })
163
+ }
164
+
165
+ if (DISABLED_CODES.has(code ?? '')) {
166
+ throw new Unavailable(message, { metadata: { service: 'identity' }, cause })
167
+ }
168
+
169
+ // Better Auth serves every documented path; a 404 or 405 means the configured URL is not one.
170
+ if (status === 404 || status === 405) {
171
+ throw new Unavailable(message, { metadata: { service: 'identity' }, cause })
172
+ }
173
+
174
+ if (WEAK_PASSWORD_CODES.has(code ?? '')) {
175
+ throw new Invalid(message, { metadata: { fields: { password: [message] } }, cause })
176
+ }
177
+
178
+ if (CREDENTIAL_CODES.has(code ?? '') || status === 401 || status === 403) {
179
+ if (options.rejection === 'unavailable') {
180
+ throw new Unavailable(message, { metadata: { service: 'identity' }, cause })
181
+ }
182
+ throw new Unauthenticated(message, { metadata: { guard: 'identity' }, cause })
183
+ }
184
+
185
+ if (status >= 500) {
186
+ throw new Unavailable(message, { metadata: { service: 'identity' }, cause })
187
+ }
188
+
189
+ throw new Invalid(message, { metadata: { fields: { [options.field]: [message] } }, cause })
190
+ }
191
+
192
+ /** Better Auth error codes this driver maps, and the taxonomy member each becomes. */
193
+ export const NEON_IDENTITY_ERROR_MAP = [
194
+ { code: 'USER_ALREADY_EXISTS', framework: 'Conflict', meaning: 'email is already registered' },
195
+ { code: 'EMAIL_ALREADY_EXISTS', framework: 'Conflict', meaning: 'email is already registered' },
196
+ { code: 'TOO_MANY_REQUESTS', framework: 'RateLimited', meaning: 'request throttled' },
197
+ { code: 'SIGN_UP_DISABLED', framework: 'Unavailable', meaning: 'project refuses new signups' },
198
+ {
199
+ code: 'INVALID_EMAIL_OR_PASSWORD',
200
+ framework: 'Unauthenticated',
201
+ meaning: 'credentials did not match',
202
+ },
203
+ { code: 'EMAIL_NOT_VERIFIED', framework: 'Unauthenticated', meaning: 'email is unverified' },
204
+ { code: 'PASSWORD_TOO_SHORT', framework: 'Invalid', meaning: 'password fails the policy' },
205
+ { code: 'WEAK_PASSWORD', framework: 'Invalid', meaning: 'password fails the policy' },
206
+ ] as const
@@ -0,0 +1,15 @@
1
+ export {
2
+ clearSessionCookie,
3
+ DEFAULT_SESSION_COOKIE,
4
+ readSessionCookie,
5
+ writeSessionCookie,
6
+ } from './cookies'
7
+ export { NEON_IDENTITY_ERROR_MAP } from './errors'
8
+ export {
9
+ createNeonIdentity,
10
+ NeonIdentity,
11
+ neonIdentityCapabilities,
12
+ type NeonIdentityOptions,
13
+ } from './driver'
14
+ export { LocalAuthServer } from './local-auth'
15
+ export type { NeonActor, NeonSession } from './types'