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
text/typescript
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 };