UNPKG

better-auth-cloudflare

Version:

Seamlessly integrate better-auth with Cloudflare Workers, D1, Hyperdrive, KV, R2, and geolocation services.

314 lines (311 loc) 10.5 kB
import { KVNamespace } from '@cloudflare/workers-types'; import { AuthContext, Session, User } from 'better-auth'; import { DrizzleAdapterConfig } from 'better-auth/adapters/drizzle'; import { FieldAttribute } from 'better-auth/db'; import { drizzle } from 'drizzle-orm/d1'; import { drizzle as drizzle$2 } from 'drizzle-orm/mysql2'; import { drizzle as drizzle$1 } from 'drizzle-orm/postgres-js'; interface CloudflarePluginOptions { /** * Auto-detect IP address * @default true */ autoDetectIpAddress?: boolean; /** * Track geolocation data in the session table * @default true */ geolocationTracking?: boolean; /** * Cloudflare geolocation context */ cf?: CloudflareGeolocation | Promise<CloudflareGeolocation | null> | null; /** * R2 configuration for user file tracking * If provided, enables file tracking features automatically */ r2?: R2Config; } /** * Generic drizzle database configuration */ type DrizzleConfig<T extends typeof drizzle | typeof drizzle$1 | typeof drizzle$2> = { /** * The drizzle database instance */ db: ReturnType<T>; /** * Drizzle adapter options */ options?: Omit<DrizzleAdapterConfig, "provider">; }; interface WithCloudflareOptions extends CloudflarePluginOptions { /** * D1 database configuration for SQLite */ d1?: DrizzleConfig<typeof drizzle>; /** * Postgres database configuration for Hyperdrive */ postgres?: DrizzleConfig<typeof drizzle$1>; /** * MySQL database configuration for Hyperdrive */ mysql?: DrizzleConfig<typeof drizzle$2>; /** * KV namespace for secondary storage, if you want to use that. */ kv?: KVNamespace<string>; } /** * Cloudflare geolocation data */ interface CloudflareGeolocation { timezone?: string | null; city?: string | null; country?: string | null; region?: string | null; regionCode?: string | null; colo?: string | null; latitude?: string | null; longitude?: string | null; } /** * Session type enhanced with Cloudflare geolocation data * This is what gets returned by /api/auth/get-session when using better-auth-cloudflare */ interface CloudflareSession extends Session { timezone?: string | null; city?: string | null; country?: string | null; region?: string | null; regionCode?: string | null; colo?: string | null; latitude?: string | null; longitude?: string | null; } /** * The response structure from /api/auth/get-session */ interface CloudflareSessionResponse { session: CloudflareSession; user: User; } /** * Minimal R2Bucket interface - only what we actually need for file storage * Avoids complex type conflicts between DOM and Cloudflare Worker types */ interface R2Bucket { put(key: string, value: Blob | File, options?: any): Promise<any>; get(key: string): Promise<{ body: ReadableStream; } | null>; delete(key: string): Promise<void>; head(key: string): Promise<any>; list(options?: { prefix?: string; }): Promise<{ objects: any[]; }>; } /** * R2 configuration for file storage and tracking * * Usage with full type inference: * ```ts * const r2Config = { * bucket, * additionalFields: { * category: { type: "string" }, * priority: { type: "number" } * }, * hooks: { * upload: { * after: (file, ctx) => { * file.category // string (fully typed!) * file.priority // number (fully typed!) * } * } * } * } as const satisfies R2Config; * ``` */ interface R2Config { /** * R2 bucket instance */ bucket: R2Bucket; /** * Additional fields to track in the file metadata schema. * Uses Better Auth's standard FieldAttribute type for consistency */ additionalFields?: Record<string, FieldAttribute>; /** * Maximum file size in bytes * @default 10485760 (10MB) */ maxFileSize?: number; /** * Allowed file types/extensions * @default undefined (all files allowed) */ allowedTypes?: string[]; /** * Lifecycle hooks for file operations * Only define the hooks you need - much cleaner than individual callbacks */ hooks?: { /** * Upload lifecycle hooks */ upload?: { /** * Called before a file upload. Return null/undefined to prevent upload. * Throw ctx.error for structured errors. */ before?: (file: File & { userId: string; r2Key: string; metadata: any; }, ctx: AuthContext) => void | null | Promise<void | null | undefined>; /** * Called after successful file upload */ after?: (file: any, // Will be properly typed when used with inferR2Config ctx: AuthContext) => void | Promise<void>; }; /** * Download lifecycle hooks */ download?: { /** * Called before a file download. Return null/undefined to prevent download. * Throw ctx.error for structured errors. */ before?: (file: any, // Will be properly typed when used with inferR2Config ctx: AuthContext) => void | null | Promise<void | null | undefined>; /** * Called after successful file download */ after?: (file: any, // Will be properly typed when used with inferR2Config ctx: AuthContext) => void | Promise<void>; }; /** * Delete lifecycle hooks */ delete?: { /** * Called before a file deletion. Return null/undefined to prevent deletion. * Throw ctx.error for structured errors. */ before?: (file: any, // Will be properly typed when used with inferR2Config ctx: AuthContext) => void | null | Promise<void | null | undefined>; /** * Called after successful file deletion */ after?: (file: any, // Will be properly typed when used with inferR2Config ctx: AuthContext) => void | Promise<void>; }; /** * List files lifecycle hooks */ list?: { /** * Called before listing files. Return null/undefined to prevent listing. * Throw ctx.error for structured errors. */ before?: (userId: string, ctx: AuthContext) => void | null | Promise<void | null | undefined>; /** * Called after successful file listing */ after?: (userId: string, files: any, ctx: AuthContext) => void | Promise<void>; }; }; } type InferFieldType<T extends FieldAttribute> = T["type"] extends "string" ? string : T["type"] extends "number" ? number : T["type"] extends "boolean" ? boolean : T["type"] extends "date" ? Date : any; type InferAdditionalFields<T extends Record<string, FieldAttribute>> = { [K in keyof T]: InferFieldType<T[K]>; }; /** * File metadata stored in database with typed additional fields */ interface FileMetadata { id: string; userId: string; filename: string; originalName: string; contentType: string; size: number; r2Key: string; uploadedAt: Date; } /** * File metadata with additional fields merged */ type FileMetadataWithAdditionalFields<T extends Record<string, FieldAttribute>> = FileMetadata & InferAdditionalFields<T>; type InferR2Config<T extends R2Config> = T["additionalFields"] extends Record<string, FieldAttribute> ? Omit<T, "hooks"> & { hooks?: { upload?: { before?: (file: File & { userId: string; r2Key: string; metadata: FileMetadataWithAdditionalFields<T["additionalFields"]>; }, ctx: AuthContext) => Promise<void | null | undefined>; after?: (file: FileMetadataWithAdditionalFields<T["additionalFields"]>, ctx: AuthContext) => Promise<void>; }; download?: { before?: (file: FileMetadataWithAdditionalFields<T["additionalFields"]>, ctx: AuthContext) => Promise<void | null | undefined>; after?: (file: FileMetadataWithAdditionalFields<T["additionalFields"]>, ctx: AuthContext) => Promise<void>; }; delete?: { before?: (file: FileMetadataWithAdditionalFields<T["additionalFields"]>, ctx: AuthContext) => Promise<void | null | undefined>; after?: (file: FileMetadataWithAdditionalFields<T["additionalFields"]>, ctx: AuthContext) => Promise<void>; }; list?: { before?: (userId: string, ctx: AuthContext) => Promise<void | null | undefined>; after?: (userId: string, files: any, ctx: AuthContext) => Promise<void>; }; }; } : T; /** * Helper to create a fully typed R2 config with automatic type inference * * Usage: * ```ts * const r2Config = createR2Config({ * bucket, * maxFileSize: 10 * 1024 * 1024, // 10MB built-in validation * allowedTypes: ['.jpg', '.png', '.pdf'], // Built-in file type validation * additionalFields: { * category: { type: "string" }, * isPublic: { type: "boolean" }, * priority: { type: "number" } * }, * hooks: { * upload: { * before: (file, ctx) => { * if (file.metadata.category === "restricted") return null; // business logic * }, * after: (file, ctx) => { * file.category // string (fully typed!) * file.priority // number (fully typed!) * sendNotification(file.userId, `Uploaded ${file.filename}`); * } * }, * download: { * before: (file, ctx) => { * if (!file.isPublic && file.userId !== ctx.session?.userId) return null; * } * }, * list: { * before: (userId, ctx) => { * if (!userHasPermission(userId, "list_files")) return null; * } * } * } * }); * ``` */ declare function createR2Config<T extends R2Config>(config: T): InferR2Config<T>; export { createR2Config }; export type { CloudflareGeolocation, CloudflarePluginOptions, CloudflareSession, CloudflareSessionResponse, DrizzleConfig, FileMetadata, FileMetadataWithAdditionalFields, InferR2Config, R2Bucket, R2Config, WithCloudflareOptions };