| 1 | import type { APIError } from "better-auth"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 4 | |
| 5 | /** |
| 6 | * better-call stows the response headers accumulated by the endpoint |
| 7 | * (including `set-cookie` written before the failure) on the thrown |
| 8 | * `APIError` under this well-known symbol. It is registered with |
| 9 | * `Symbol.for`, so we can reference it without depending on `better-call`. |
| 10 | */ |
| 11 | const kAPIErrorHeaders = Symbol.for("better-call:api-error-headers"); |
| 12 | |
| 13 | /** |
| 14 | * Structural `APIError` detection. Deliberately not `instanceof` — the |
| 15 | * error may originate from a different module instance (bundled better-auth |
| 16 | * vs peer) or realm. |
| 17 | */ |
| 18 | export const isAPIErrorLike = (error: unknown): error is APIError => |
| 19 | typeof error === "object" && |
| 20 | error !== null && |
| 21 | error instanceof Error && |
| 22 | error.name === "APIError" && |
| 23 | typeof (error as APIError).statusCode === "number"; |
| 24 | |
| 25 | /** |
| 26 | * Merge an `APIError`'s visible `headers` with the hidden response headers |
| 27 | * better-call carries on {@link kAPIErrorHeaders}. `set-cookie` values are |
| 28 | * appended (never collapsed) so session cookies written before the failure |
| 29 | * survive. |
| 30 | */ |
| 31 | export const mergeAPIErrorHeaders = (error: APIError): Headers => { |
| 32 | const merged = new Headers(error.headers as HeadersInit | undefined); |
| 33 | const hidden = (error as unknown as Record<symbol, unknown>)[ |
| 34 | kAPIErrorHeaders |
| 35 | ]; |
| 36 | if (hidden instanceof Headers) { |
| 37 | const cookies = |
| 38 | typeof hidden.getSetCookie === "function" ? hidden.getSetCookie() : []; |
| 39 | hidden.forEach((value, key) => { |
| 40 | if (key.toLowerCase() !== "set-cookie") { |
| 41 | merged.set(key, value); |
| 42 | } |
| 43 | }); |
| 44 | for (const cookie of cookies) { |
| 45 | merged.append("set-cookie", cookie); |
| 46 | } |
| 47 | } |
| 48 | return merged; |
| 49 | }; |
| 50 | |
| 51 | /** |
| 52 | * Typed failure of an effectified `auth.api.*` call. |
| 53 | * |
| 54 | * Better Auth throws `APIError` from `better-call`; this error carries the |
| 55 | * same information as first-class fields: |
| 56 | * |
| 57 | * - `status` — the better-call status KEY, e.g. `"UNAUTHORIZED"` (a string) |
| 58 | * - `statusCode` — the numeric HTTP status, e.g. `401` |
| 59 | * - `body` — the JSON error body (`message`, `code`, ...) |
| 60 | * - `headers` — merged response headers, INCLUDING the `set-cookie` |
| 61 | * headers better-call hides on a symbol property of the thrown error |
| 62 | * |
| 63 | * Match on `statusCode` (or `body.code`) — `status` is a string key, not a |
| 64 | * number. |
| 65 | */ |
| 66 | export class BetterAuthApiError extends Data.TaggedError("BetterAuthApiError")<{ |
| 67 | readonly status: string; |
| 68 | readonly statusCode: number; |
| 69 | readonly body: |
| 70 | | ({ message?: string; code?: string } & Record<string, unknown>) |
| 71 | | undefined; |
| 72 | readonly headers: Headers; |
| 73 | readonly cause: APIError; |
| 74 | }> { |
| 75 | static fromAPIError(error: APIError): BetterAuthApiError { |
| 76 | return new BetterAuthApiError({ |
| 77 | status: String(error.status), |
| 78 | statusCode: error.statusCode, |
| 79 | body: error.body as BetterAuthApiError["body"], |
| 80 | headers: mergeAPIErrorHeaders(error), |
| 81 | cause: error, |
| 82 | }); |
| 83 | } |
| 84 | |
| 85 | override get message(): string { |
| 86 | return this.body?.message ?? `Better Auth API error (${this.statusCode})`; |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Render the error as an HTTP response (status + JSON body + merged |
| 91 | * headers) — for handlers that want to pass the failure through to the |
| 92 | * client exactly as Better Auth would have. |
| 93 | */ |
| 94 | toResponse(): HttpServerResponse.HttpServerResponse { |
| 95 | return HttpServerResponse.fromWeb( |
| 96 | new Response(JSON.stringify(this.body ?? {}), { |
| 97 | status: this.statusCode, |
| 98 | headers: withJsonContentType(this.headers), |
| 99 | }), |
| 100 | ); |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | const withJsonContentType = (headers: Headers): Headers => { |
| 105 | const out = new Headers(headers); |
| 106 | if (!out.has("content-type")) { |
| 107 | out.set("content-type", "application/json"); |
| 108 | } |
| 109 | return out; |
| 110 | }; |
| 111 | |
| 112 | /** Failure of the deploy-time schema migration. */ |
| 113 | export class BetterAuthMigrationError extends Data.TaggedError( |
| 114 | "BetterAuthMigrationError", |
| 115 | )<{ |
| 116 | readonly message: string; |
| 117 | readonly cause?: unknown; |
| 118 | }> {} |
| 119 | |
| 120 | /** Failure of a `secondaryStorage` operation. */ |
| 121 | export class BetterAuthStorageError extends Data.TaggedError( |
| 122 | "BetterAuthStorageError", |
| 123 | )<{ |
| 124 | readonly message: string; |
| 125 | readonly cause?: unknown; |
| 126 | }> {} |