feat: import auth package from monorepo

This commit is contained in:
imeepos
2026-03-19 18:26:18 +08:00
commit d847c93192
14 changed files with 2481 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.turbo/
*.tsbuildinfo

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "@bowong/auth",
"version": "1.0.5",
"type": "module",
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
},
"exports": {
".": "./src/index.ts",
"./client": "./src/client.ts"
},
"scripts": {
"build": "node -e \"require('fs').rmSync('dist',{ recursive: true, force: true })\" && bun x tsc -p tsconfig.build.json",
"typecheck": "bun x tsc --noEmit",
"clean": "node -e \"require('fs').rmSync('dist',{ recursive: true, force: true })\""
},
"dependencies": {
"@better-auth/api-key": "^1.2.0",
"better-auth": "^1.2.0",
"drizzle-orm": "^0.44.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250312.0",
"typescript": "^5.9.3"
}
}

89
src/auth-config.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* Better Auth server-side configuration factory.
* Used by web-chat (Next.js on Cloudflare Pages) to run the full auth instance.
*/
import { betterAuth } from 'better-auth'
import { bearer, openAPI, username } from 'better-auth/plugins'
import { apiKey } from '@better-auth/api-key'
import { drizzle } from 'drizzle-orm/d1'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import * as schema from './schema.js'
import { avatarPlugin } from './avatar/avatar.js'
import { s3, type S3Config } from './s3/index.js'
export interface CreateAuthOptions {
/** Raw Cloudflare D1 binding */
db: unknown
baseURL?: string
secret?: string
trustedOrigins?: string[]
s3?: S3Config
}
export function createAuth(options: CreateAuthOptions) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const drizzleDb = drizzle(options.db as any, { schema })
return betterAuth({
baseURL: options.baseURL,
secret: options.secret,
// 禁用全局限流
rateLimit: {
enabled: false,
},
advanced: {
crossSubDomainCookies: {
enabled: false, // 禁用跨子域 cookies避免 baseURL 要求
},
ipAddress: {
ipAddressHeaders: ["cf-connecting-ip", "x-real-ip"],
disableIpTracking: false,
},
},
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
freshAge: 0,
storeSessionInDatabase: true, // 启用数据库存储以保存地理位置元数据
},
trustedOrigins: (request) => {
if (!request) return ["*"];
const origin = request!.headers.get('origin') || request!.headers.get('expo-origin');
const allowedOrigins = options.trustedOrigins || ['*'];
if (!origin) return allowedOrigins;
const isAllowed = allowedOrigins.some(allowed =>
origin === allowed || origin.startsWith(allowed)
);
return isAllowed ? [origin] : allowedOrigins;
},
database: drizzleAdapter(drizzleDb, {
provider: 'sqlite',
schema: {
user: schema.user,
session: schema.session,
account: schema.account,
verification: schema.verification,
apikey: schema.apikey,
userFile: schema.userFile,
user_file: schema.userFile,
},
}),
emailAndPassword: {
enabled: true,
},
plugins: [
openAPI(),
bearer(),
username(),
apiKey({ rateLimit: { enabled: false } }),
avatarPlugin({ db: options.db as any }),
...(options.s3 ? [s3(options.s3)] : []),
]
})
}
export type Auth = ReturnType<typeof createAuth>

910
src/avatar/avatar.ts Normal file
View File

@@ -0,0 +1,910 @@
/**
* Avatar Management Plugin for Better Auth
* 提供形象和表情包管理功能
*/
import type { BetterAuthPlugin } from 'better-auth'
import { createAuthEndpoint, sessionMiddleware } from 'better-auth/api'
import * as z from 'zod'
import {
APIKEY_AVATAR_TAG,
AVATAR_TAG,
EMOJI_TAG,
apikeyAvatarSchema,
avatarSchema,
emojiSchema,
errorSchema,
} from './schemas.js'
import {
badRequestResponse,
databaseErrorResponse,
jsonResponse,
notFoundResponse,
okResponse,
unauthorizedResponse,
} from './openapi.js'
export interface AvatarPluginOptions {
enabled?: boolean
db?: any // D1Database type from @cloudflare/workers-types
}
function generateId(): string {
return `${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
}
function isValidHttpUrl(url: string): boolean {
try {
const parsed = new URL(url)
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
} catch {
return false
}
}
function getDbFromContext(ctx: { context?: unknown }): any | null {
const context = ctx.context as
| {
db?: any
context?: { db?: any }
options?: { db?: any }
}
| undefined
return context?.db ?? context?.context?.db ?? context?.options?.db ?? null
}
function getDb(ctx: { context?: unknown }, fallbackDb?: any): any {
const db = getDbFromContext(ctx) ?? fallbackDb
if (!db) {
throw new Error('Avatar plugin database binding is missing')
}
return db
}
async function demoteOtherDefaultEmojis(
db: any,
avatarId: string,
keepEmojiId?: string,
): Promise<void> {
const query = keepEmojiId
? 'UPDATE emojis SET trigger_type = ? WHERE avatar_id = ? AND trigger_type = ? AND id != ?'
: 'UPDATE emojis SET trigger_type = ? WHERE avatar_id = ? AND trigger_type = ?'
const statement = keepEmojiId
? db.prepare(query).bind('message', avatarId, 'default', keepEmojiId)
: db.prepare(query).bind('message', avatarId, 'default')
await statement.run()
}
const avatarBodySchema = z.object({
name: z.string(),
description: z.string().optional(),
})
const emojiCreateBodySchema = z.object({
label: z.string(),
url: z.string(),
description: z.string().optional(),
trigger_type: z.string().optional(),
trigger_result: z.enum(['success', 'failure']).optional(),
sort_order: z.number().optional(),
})
const emojiUpdateBodySchema = z.object({
label: z.string().optional(),
url: z.string().optional(),
description: z.string().optional(),
trigger_type: z.string().optional(),
trigger_result: z.enum(['success', 'failure']).nullable().optional(),
sort_order: z.number().optional(),
})
const bindAvatarBodySchema = z.object({
avatar_id: z.string().nullable().optional(),
})
export const avatarPlugin = (options?: AvatarPluginOptions): BetterAuthPlugin => {
if (options?.enabled === false) {
return {
id: 'avatar',
endpoints: {},
}
}
return {
id: 'avatar',
endpoints: {
getAvatars: createAuthEndpoint(
'/avatars',
{
method: 'GET',
use: [sessionMiddleware],
requireHeaders: true,
metadata: {
openapi: {
operationId: 'getAvatars',
description: 'Get the current user avatar list',
tags: [AVATAR_TAG],
responses: {
'200': jsonResponse('Avatar list', {
type: 'object',
properties: {
ok: { type: 'boolean' },
avatars: {
type: 'array',
items: avatarSchema,
},
},
required: ['ok', 'avatars'],
}),
'401': unauthorizedResponse(),
'500': databaseErrorResponse(),
},
},
},
},
async (ctx) => {
try {
const db = getDb(ctx, options?.db)
const result = await db.prepare(
'SELECT id, name, description, created_at, updated_at FROM avatars ORDER BY created_at DESC'
).all()
return ctx.json({
ok: true,
avatars: result.results || [],
})
} catch (err) {
console.error('[avatar-plugin] getAvatars error:', err)
return ctx.json({ ok: false, error: 'Database error' }, { status: 500 })
}
},
),
createAvatar: createAuthEndpoint(
'/avatars',
{
method: 'POST',
body: avatarBodySchema,
use: [sessionMiddleware],
requireHeaders: true,
requireRequest: true,
metadata: {
openapi: {
operationId: 'createAvatar',
description: 'Create a new avatar',
tags: [AVATAR_TAG],
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
name: { type: 'string' },
description: { type: 'string' },
},
required: ['name'],
},
},
},
},
responses: {
'201': jsonResponse('Avatar created', {
type: 'object',
properties: {
ok: { type: 'boolean' },
avatar: avatarSchema,
},
required: ['ok', 'avatar'],
}),
'400': badRequestResponse('Invalid body'),
'401': unauthorizedResponse(),
'500': databaseErrorResponse(),
},
},
},
},
async (ctx) => {
const body = ctx.body
if (!body || typeof body.name !== 'string' || body.name.trim() === '') {
return ctx.json({ ok: false, error: 'Name is required' }, { status: 400 })
}
const name = body.name.trim()
const id = generateId()
const now = Date.now()
try {
const db = getDb(ctx, options?.db)
await db.prepare(
'INSERT INTO avatars (id, user_id, name, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).bind(
id,
ctx.context.session.user.id,
name,
body.description || null,
now,
now,
).run()
return ctx.json({
ok: true,
avatar: {
id,
user_id: ctx.context.session.user.id,
name,
description: body.description || null,
created_at: now,
updated_at: now,
},
}, { status: 201 })
} catch (err) {
console.error('[avatar-plugin] createAvatar error:', err)
return ctx.json({ ok: false, error: 'Database error' }, { status: 500 })
}
},
),
getAvatar: createAuthEndpoint(
'/avatars/:id',
{
method: 'GET',
use: [sessionMiddleware],
requireHeaders: true,
metadata: {
openapi: {
operationId: 'getAvatar',
description: 'Get avatar detail with emojis',
tags: [AVATAR_TAG],
responses: {
'200': jsonResponse('Avatar detail', {
type: 'object',
properties: {
ok: { type: 'boolean' },
avatar: avatarSchema,
emojis: {
type: 'array',
items: emojiSchema,
},
},
required: ['ok', 'avatar', 'emojis'],
}),
'401': unauthorizedResponse(),
'404': notFoundResponse('Avatar not found'),
'500': databaseErrorResponse(),
},
},
},
},
async (ctx) => {
try {
const db = getDb(ctx, options?.db)
const avatarResult = await db.prepare(
'SELECT id, user_id, name, description, created_at, updated_at FROM avatars WHERE id = ? AND user_id = ?'
).bind(ctx.params.id, ctx.context.session.user.id).first()
if (!avatarResult) {
return ctx.json({ ok: false, error: 'Avatar not found' }, { status: 404 })
}
const emojisResult = await db.prepare(
'SELECT id, avatar_id, label, url, description, trigger_type, trigger_result, sort_order, created_at FROM emojis WHERE avatar_id = ? ORDER BY sort_order ASC, created_at ASC'
).bind(ctx.params.id).all()
return ctx.json({
ok: true,
avatar: avatarResult,
emojis: emojisResult.results || [],
})
} catch (err) {
console.error('[avatar-plugin] getAvatar error:', err)
return ctx.json({ ok: false, error: 'Database error' }, { status: 500 })
}
},
),
updateAvatar: createAuthEndpoint(
'/avatars/:id',
{
method: 'PUT',
body: avatarBodySchema,
use: [sessionMiddleware],
requireHeaders: true,
requireRequest: true,
metadata: {
openapi: {
operationId: 'updateAvatar',
description: 'Update avatar info',
tags: [AVATAR_TAG],
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
name: { type: 'string' },
description: { type: 'string' },
},
required: ['name'],
},
},
},
},
responses: {
'200': jsonResponse('Avatar updated', {
type: 'object',
properties: {
ok: { type: 'boolean' },
avatar: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string', nullable: true },
updated_at: { type: 'number' },
},
required: ['id', 'name', 'description', 'updated_at'],
},
},
required: ['ok', 'avatar'],
}),
'400': badRequestResponse('Invalid body'),
'401': unauthorizedResponse(),
'404': notFoundResponse('Avatar not found'),
'500': databaseErrorResponse(),
},
},
},
},
async (ctx) => {
const body = ctx.body
if (!body || typeof body.name !== 'string' || body.name.trim() === '') {
return ctx.json({ ok: false, error: 'Name is required' }, { status: 400 })
}
const name = body.name.trim()
const now = Date.now()
try {
const db = getDb(ctx, options?.db)
const result = await db.prepare(
'UPDATE avatars SET name = ?, description = ?, updated_at = ? WHERE id = ? AND user_id = ?'
).bind(
name,
body.description || null,
now,
ctx.params.id,
ctx.context.session.user.id,
).run()
if (result.meta.changes === 0) {
return ctx.json({ ok: false, error: 'Avatar not found' }, { status: 404 })
}
return ctx.json({
ok: true,
avatar: {
id: ctx.params.id,
name,
description: body.description || null,
updated_at: now,
},
})
} catch (err) {
console.error('[avatar-plugin] updateAvatar error:', err)
return ctx.json({ ok: false, error: 'Database error' }, { status: 500 })
}
},
),
deleteAvatar: createAuthEndpoint(
'/avatars/:id',
{
method: 'DELETE',
disableBody: true,
use: [sessionMiddleware],
requireHeaders: true,
metadata: {
openapi: {
operationId: 'deleteAvatar',
description: 'Delete an avatar',
tags: [AVATAR_TAG],
responses: {
'200': okResponse('Avatar deleted'),
'401': unauthorizedResponse(),
'404': notFoundResponse('Avatar not found'),
'500': databaseErrorResponse(),
},
},
},
},
async (ctx) => {
try {
const db = getDb(ctx, options?.db)
const result = await db.prepare(
'DELETE FROM avatars WHERE id = ? AND user_id = ?'
).bind(ctx.params.id, ctx.context.session.user.id).run()
if (result.meta.changes === 0) {
return ctx.json({ ok: false, error: 'Avatar not found' }, { status: 404 })
}
return ctx.json({ ok: true })
} catch (err) {
console.error('[avatar-plugin] deleteAvatar error:', err)
return ctx.json({ ok: false, error: 'Database error' }, { status: 500 })
}
},
),
createEmoji: createAuthEndpoint(
'/avatars/:avatarId/emojis',
{
method: 'POST',
body: emojiCreateBodySchema,
use: [sessionMiddleware],
requireHeaders: true,
requireRequest: true,
metadata: {
openapi: {
operationId: 'createEmoji',
description: 'Create a new emoji for an avatar',
tags: [EMOJI_TAG],
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
label: { type: 'string' },
url: { type: 'string' },
description: { type: 'string' },
trigger_type: { type: 'string' },
trigger_result: { type: 'string', nullable: true },
sort_order: { type: 'number' },
},
required: ['label', 'url'],
},
},
},
},
responses: {
'201': jsonResponse('Emoji created', {
type: 'object',
properties: {
ok: { type: 'boolean' },
emoji: emojiSchema,
},
required: ['ok', 'emoji'],
}),
'400': badRequestResponse('Invalid body'),
'401': unauthorizedResponse(),
'404': notFoundResponse('Avatar not found'),
'500': databaseErrorResponse(),
},
},
},
},
async (ctx) => {
const body = ctx.body
if (!body || typeof body.label !== 'string' || body.label.trim() === '') {
return ctx.json({ ok: false, error: 'Label is required' }, { status: 400 })
}
if (typeof body.url !== 'string' || !isValidHttpUrl(body.url)) {
return ctx.json({ ok: false, error: 'Valid URL is required' }, { status: 400 })
}
const db = getDb(ctx, options?.db)
const ownerCheck = await db.prepare(
'SELECT id FROM avatars WHERE id = ? AND user_id = ?'
).bind(ctx.params.avatarId, ctx.context.session.user.id).first()
if (!ownerCheck) {
return ctx.json({ ok: false, error: 'Avatar not found' }, { status: 404 })
}
const id = generateId()
const now = Date.now()
const sortOrder = body.sort_order ?? 0
const triggerType = body.trigger_type?.trim() || 'message'
const triggerResult = body.trigger_result ?? null
try {
if (triggerType === 'default') {
await demoteOtherDefaultEmojis(db, ctx.params.avatarId)
}
await db.prepare(
'INSERT INTO emojis (id, avatar_id, label, url, description, trigger_type, trigger_result, sort_order, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
).bind(
id,
ctx.params.avatarId,
body.label.trim(),
body.url,
body.description || null,
triggerType,
triggerResult,
sortOrder,
now,
).run()
return ctx.json({
ok: true,
emoji: {
id,
avatar_id: ctx.params.avatarId,
label: body.label.trim(),
url: body.url,
description: body.description || null,
trigger_type: triggerType,
trigger_result: triggerResult,
sort_order: sortOrder,
created_at: now,
},
}, { status: 201 })
} catch (err) {
console.error('[avatar-plugin] createEmoji error:', err)
return ctx.json({ ok: false, error: 'Database error' }, { status: 500 })
}
},
),
updateEmoji: createAuthEndpoint(
'/emojis/:id',
{
method: 'PUT',
body: emojiUpdateBodySchema,
use: [sessionMiddleware],
requireHeaders: true,
requireRequest: true,
metadata: {
openapi: {
operationId: 'updateEmoji',
description: 'Update an emoji',
tags: [EMOJI_TAG],
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
label: { type: 'string' },
url: { type: 'string' },
description: { type: 'string' },
trigger_type: { type: 'string' },
trigger_result: { type: 'string', nullable: true },
sort_order: { type: 'number' },
},
},
},
},
},
responses: {
'200': okResponse('Emoji updated'),
'400': badRequestResponse('Invalid body'),
'401': unauthorizedResponse(),
'404': notFoundResponse('Emoji not found'),
'500': databaseErrorResponse(),
},
},
},
},
async (ctx) => {
const body = ctx.body
if (!body) {
return ctx.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
}
const db = getDb(ctx, options?.db)
const emojiResult = await db.prepare(
'SELECT e.id, e.avatar_id FROM emojis e JOIN avatars a ON e.avatar_id = a.id WHERE e.id = ? AND a.user_id = ?'
).bind(ctx.params.id, ctx.context.session.user.id).first()
if (!emojiResult) {
return ctx.json({ ok: false, error: 'Emoji not found' }, { status: 404 })
}
const updates: string[] = []
const bindings: (string | number | null)[] = []
if (body.label !== undefined) {
const label = body.label.trim()
if (label === '') {
return ctx.json({ ok: false, error: 'Label cannot be empty' }, { status: 400 })
}
updates.push('label = ?')
bindings.push(label)
}
if (body.url !== undefined) {
if (!isValidHttpUrl(body.url)) {
return ctx.json({ ok: false, error: 'Invalid URL' }, { status: 400 })
}
updates.push('url = ?')
bindings.push(body.url)
}
if (body.description !== undefined) {
updates.push('description = ?')
bindings.push(body.description || null)
}
if (body.trigger_type !== undefined) {
const triggerType = body.trigger_type.trim()
if (triggerType === '') {
return ctx.json({ ok: false, error: 'Trigger type cannot be empty' }, { status: 400 })
}
if (triggerType === 'default') {
await demoteOtherDefaultEmojis(db, emojiResult.avatar_id, ctx.params.id)
}
updates.push('trigger_type = ?')
bindings.push(triggerType)
}
if (body.trigger_result !== undefined) {
updates.push('trigger_result = ?')
bindings.push(body.trigger_result)
}
if (body.sort_order !== undefined) {
updates.push('sort_order = ?')
bindings.push(body.sort_order)
}
if (updates.length === 0) {
return ctx.json({ ok: false, error: 'No fields to update' }, { status: 400 })
}
bindings.push(ctx.params.id)
try {
await db.prepare(
`UPDATE emojis SET ${updates.join(', ')} WHERE id = ?`
).bind(...bindings).run()
return ctx.json({ ok: true })
} catch (err) {
console.error('[avatar-plugin] updateEmoji error:', err)
return ctx.json({ ok: false, error: 'Database error' }, { status: 500 })
}
},
),
deleteEmoji: createAuthEndpoint(
'/emojis/:id',
{
method: 'DELETE',
disableBody: true,
use: [sessionMiddleware],
requireHeaders: true,
metadata: {
openapi: {
operationId: 'deleteEmoji',
description: 'Delete an emoji',
tags: [EMOJI_TAG],
responses: {
'200': okResponse('Emoji deleted'),
'401': unauthorizedResponse(),
'404': notFoundResponse('Emoji not found'),
'500': databaseErrorResponse(),
},
},
},
},
async (ctx) => {
try {
const db = getDb(ctx, options?.db)
const result = await db.prepare(
'DELETE FROM emojis WHERE id = ? AND avatar_id IN (SELECT id FROM avatars WHERE user_id = ?)'
).bind(ctx.params.id, ctx.context.session.user.id).run()
if (result.meta.changes === 0) {
return ctx.json({ ok: false, error: 'Emoji not found' }, { status: 404 })
}
return ctx.json({ ok: true })
} catch (err) {
console.error('[avatar-plugin] deleteEmoji error:', err)
return ctx.json({ ok: false, error: 'Database error' }, { status: 500 })
}
},
),
bindAvatarToApiKey: createAuthEndpoint(
'/apikeys/:id/avatar',
{
method: 'PUT',
body: bindAvatarBodySchema,
use: [sessionMiddleware],
requireHeaders: true,
requireRequest: true,
metadata: {
openapi: {
operationId: 'bindAvatarToApiKey',
description: 'Bind or unbind an avatar to an API key',
tags: [APIKEY_AVATAR_TAG],
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
avatar_id: { type: 'string', nullable: true },
},
},
},
},
},
responses: {
'200': jsonResponse('Avatar binding updated', {
type: 'object',
properties: {
ok: { type: 'boolean' },
apikey_id: { type: 'string' },
avatar_id: { type: 'string', nullable: true },
},
required: ['ok', 'apikey_id', 'avatar_id'],
}),
'400': badRequestResponse('Invalid body'),
'401': unauthorizedResponse(),
'404': notFoundResponse('API key or avatar not found'),
'500': databaseErrorResponse(),
},
},
},
},
async (ctx) => {
const body = ctx.body
if (!body) {
return ctx.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
}
const db = getDb(ctx, options?.db)
const avatarId = body.avatar_id ?? null
if (avatarId) {
const avatarResult = await db.prepare(
'SELECT id FROM avatars WHERE id = ?'
).bind(avatarId).first()
if (!avatarResult) {
return ctx.json({ ok: false, error: 'Avatar not found' }, { status: 404 })
}
}
const now = Date.now()
try {
const result = await db.prepare(
'UPDATE apikey SET avatar_id = ?, updated_at = ? WHERE id = ? AND reference_id = ?'
).bind(
avatarId,
now,
ctx.params.id,
ctx.context.session.user.id,
).run()
if (result.meta.changes === 0) {
return ctx.json({ ok: false, error: 'API key not found' }, { status: 404 })
}
return ctx.json({
ok: true,
apikey_id: ctx.params.id,
avatar_id: avatarId,
})
} catch (err) {
console.error('[avatar-plugin] bindAvatarToApiKey error:', err)
return ctx.json({ ok: false, error: 'Database error' }, { status: 500 })
}
},
),
getApiKeyAvatar: createAuthEndpoint(
'/apikeys/:id/avatar',
{
method: 'GET',
use: [sessionMiddleware],
requireHeaders: true,
metadata: {
openapi: {
operationId: 'getApiKeyAvatar',
description: 'Get the avatar and emoji list bound to an API key',
tags: [APIKEY_AVATAR_TAG],
responses: {
'200': jsonResponse('API key avatar detail', {
type: 'object',
properties: {
ok: { type: 'boolean' },
apikey: apikeyAvatarSchema,
avatar: {
...avatarSchema,
nullable: true,
},
emojis: {
type: 'array',
items: emojiSchema,
},
},
required: ['ok', 'apikey', 'avatar', 'emojis'],
}),
'401': unauthorizedResponse(),
'404': notFoundResponse('API key not found'),
'500': databaseErrorResponse(),
},
},
},
},
async (ctx) => {
try {
const db = getDb(ctx, options?.db)
const apikeyResult = await db.prepare(
'SELECT id, name, avatar_id FROM apikey WHERE id = ?'
).bind(ctx.params.id).first() as {
id: string
name: string | null
avatar_id: string | null
} | null
if (!apikeyResult) {
return ctx.json({ ok: false, error: 'API key not found' }, { status: 404 })
}
if (!apikeyResult.avatar_id) {
return ctx.json({
ok: true,
apikey: {
id: apikeyResult.id,
name: apikeyResult.name,
avatar_id: null,
},
avatar: null,
emojis: [],
})
}
const avatarResult = await db.prepare(
'SELECT id, name, description, created_at, updated_at FROM avatars WHERE id = ?'
).bind(apikeyResult.avatar_id).first()
if (!avatarResult) {
return ctx.json({
ok: true,
apikey: {
id: apikeyResult.id,
name: apikeyResult.name,
avatar_id: apikeyResult.avatar_id,
},
avatar: null,
emojis: [],
})
}
const emojisResult = await db.prepare(
'SELECT id, avatar_id, label, url, description, trigger_type, trigger_result, sort_order, created_at FROM emojis WHERE avatar_id = ? ORDER BY sort_order ASC, created_at ASC'
).bind(apikeyResult.avatar_id).all()
return ctx.json({
ok: true,
apikey: {
id: apikeyResult.id,
name: apikeyResult.name,
avatar_id: apikeyResult.avatar_id,
},
avatar: avatarResult,
emojis: emojisResult.results || [],
})
} catch (err) {
console.error('[avatar-plugin] getApiKeyAvatar error:', err)
return ctx.json({ ok: false, error: 'Database error' }, { status: 500 })
}
},
),
},
}
}

51
src/avatar/openapi.ts Normal file
View File

@@ -0,0 +1,51 @@
import { errorSchema, okSchema } from './schemas.js'
function jsonContent(schema: Record<string, unknown>) {
return {
'application/json': {
schema,
},
}
}
export function unauthorizedResponse() {
return {
description: 'Unauthorized',
content: jsonContent(errorSchema),
}
}
export function badRequestResponse(description = 'Invalid request') {
return {
description,
content: jsonContent(errorSchema),
}
}
export function notFoundResponse(description = 'Not found') {
return {
description,
content: jsonContent(errorSchema),
}
}
export function databaseErrorResponse() {
return {
description: 'Database error',
content: jsonContent(errorSchema),
}
}
export function okResponse(description: string) {
return {
description,
content: jsonContent(okSchema),
}
}
export function jsonResponse(description: string, schema: Record<string, unknown>) {
return {
description,
content: jsonContent(schema),
}
}

59
src/avatar/schemas.ts Normal file
View File

@@ -0,0 +1,59 @@
export const AVATAR_TAG = 'Avatar'
export const EMOJI_TAG = 'Emoji'
export const APIKEY_AVATAR_TAG = 'API Key Avatar'
export const avatarSchema = {
type: 'object' as const,
properties: {
id: { type: 'string' },
user_id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string', nullable: true },
created_at: { type: 'number' },
updated_at: { type: 'number' },
},
required: ['id', 'user_id', 'name', 'description', 'created_at', 'updated_at'],
}
export const emojiSchema = {
type: 'object' as const,
properties: {
id: { type: 'string' },
avatar_id: { type: 'string' },
label: { type: 'string' },
url: { type: 'string' },
description: { type: 'string', nullable: true },
trigger_type: { type: 'string' },
trigger_result: { type: 'string', nullable: true },
sort_order: { type: 'number' },
created_at: { type: 'number' },
},
required: ['id', 'avatar_id', 'label', 'url', 'description', 'trigger_type', 'trigger_result', 'sort_order', 'created_at'],
}
export const apikeyAvatarSchema = {
type: 'object' as const,
properties: {
id: { type: 'string' },
name: { type: 'string', nullable: true },
avatar_id: { type: 'string', nullable: true },
},
required: ['id', 'name', 'avatar_id'],
}
export const okSchema = {
type: 'object' as const,
properties: {
ok: { type: 'boolean' },
},
required: ['ok'],
}
export const errorSchema = {
type: 'object' as const,
properties: {
ok: { type: 'boolean' },
error: { type: 'string' },
},
required: ['ok', 'error'],
}

313
src/client.ts Normal file
View File

@@ -0,0 +1,313 @@
/**
* Better Auth browser-side client factory.
* Used by web-chat frontend to interact with the auth API.
*/
import { createAuthClient } from 'better-auth/client'
import { jwtClient, usernameClient } from 'better-auth/client/plugins'
import { apiKeyClient } from '@better-auth/api-key/client'
const TOKEN_KEY = 'better_auth_token'
export interface AvatarRecord {
id: string
user_id: string
name: string
description: string | null
created_at: number
updated_at: number
}
export interface EmojiRecord {
id: string
avatar_id: string
label: string
url: string
description: string | null
trigger_type: string
trigger_result: 'success' | 'failure' | null
sort_order: number
created_at: number
}
export interface AvatarDetailResponse {
ok: boolean
avatar: AvatarRecord
emojis: EmojiRecord[]
}
export interface ApiKeyAvatarResponse {
ok: boolean
apikey: {
id: string
name: string | null
avatar_id: string | null
}
avatar: AvatarRecord | null
emojis: EmojiRecord[]
}
export interface UploadedFileRecord {
id: string
userId: string
filename: string
originalName: string
contentType: string
size: number
s3Key: string
url: string
uploadedAt: string | Date
}
interface ErrorResponse {
ok: false
error?: string
}
type OkResponse<T extends object> = T & { ok: true }
export interface WebRelayApiClient {
avatars: {
list: () => Promise<OkResponse<{ avatars: AvatarRecord[] }>>
get: (avatarId: string) => Promise<OkResponse<AvatarDetailResponse>>
create: (input: { name: string; description?: string }) => Promise<OkResponse<{ avatar: AvatarRecord }>>
update: (
avatarId: string,
input: { name: string; description?: string },
) => Promise<OkResponse<{ avatar: AvatarRecord }>>
delete: (avatarId: string) => Promise<OkResponse<{ ok: boolean }>>
}
emojis: {
create: (
avatarId: string,
input: {
label: string
url: string
description?: string
trigger_type?: string
trigger_result?: 'success' | 'failure'
sort_order?: number
},
) => Promise<OkResponse<{ emoji: EmojiRecord }>>
update: (
emojiId: string,
input: {
label?: string
url?: string
description?: string
trigger_type?: string
trigger_result?: 'success' | 'failure' | null
sort_order?: number
},
) => Promise<OkResponse<{ ok: boolean }>>
delete: (emojiId: string) => Promise<OkResponse<{ ok: boolean }>>
}
apiKeyAvatars: {
get: (apiKeyId: string) => Promise<OkResponse<ApiKeyAvatarResponse>>
bind: (
apiKeyId: string,
avatarId: string | null,
) => Promise<OkResponse<{ apikey_id: string; avatar_id: string | null }>>
}
files: {
uploadRaw: (
file: File,
metadata?: Record<string, unknown>,
) => Promise<{ success: true; data: UploadedFileRecord }>
}
}
async function authRequest<T extends object>(
baseURL: string,
path: string,
init?: RequestInit,
): Promise<OkResponse<T>> {
const headers = new Headers(init?.headers)
const token = getStoredToken()
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
if (init?.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
const response = await fetch(`${baseURL}${path}`, {
...init,
headers,
credentials: 'include',
})
const data = await response.json() as (T & { ok?: boolean }) | ErrorResponse
if (!response.ok || !('ok' in data) || !data.ok) {
throw new Error(('error' in data && data.error) || 'Request failed')
}
return data as OkResponse<T>
}
export function createWebAuthClient(baseURL: string) {
return createAuthClient({
baseURL,
plugins: [jwtClient(), usernameClient(), apiKeyClient()],
fetchOptions: {
credentials: 'include',
auth: {
type: 'Bearer',
token: () => getStoredToken() || ``,
},
onSuccess: ctx => {
const authToken = ctx.response.headers.get('set-auth-token')
if (authToken) {
localStorage.setItem(TOKEN_KEY, authToken)
}
},
onRequest: ctx => {
const token = getStoredToken()
if (token) {
ctx.headers.set('Authorization', `Bearer ${token}`)
}
},
},
})
}
export function createWebRelayApiClient(baseURL: string): WebRelayApiClient {
const encodeHeaderValue = (value: string): string => {
return encodeURIComponent(value)
}
const uploadRaw = async (
file: File,
metadata?: Record<string, unknown>,
): Promise<{ success: true; data: UploadedFileRecord }> => {
const headers = new Headers()
const token = getStoredToken()
headers.set('x-filename', encodeHeaderValue(file.name))
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
if (metadata && Object.keys(metadata).length > 0) {
headers.set('x-file-metadata', JSON.stringify(metadata))
}
if (file.type) {
headers.set('Content-Type', file.type)
}
const response = await fetch(`${baseURL}/api/auth/files/upload-raw`, {
method: 'POST',
headers,
body: file,
credentials: 'include',
})
const data = await response.json() as
| { success: true; data: UploadedFileRecord }
| { error?: string; message?: string }
if (!response.ok || !('success' in data) || !data.success) {
const message =
'error' in data
? data.error || data.message || 'Upload failed'
: 'Upload failed'
throw new Error(message)
}
return data
}
return {
avatars: {
list: () => authRequest<{ avatars: AvatarRecord[] }>(baseURL, '/api/auth/avatars'),
get: (avatarId: string) =>
authRequest<AvatarDetailResponse>(baseURL, `/api/auth/avatars/${avatarId}`),
create: (input: { name: string; description?: string }) =>
authRequest<{ avatar: AvatarRecord }>(baseURL, '/api/auth/avatars', {
method: 'POST',
body: JSON.stringify(input),
}),
update: (avatarId: string, input: { name: string; description?: string }) =>
authRequest<{ avatar: AvatarRecord }>(baseURL, `/api/auth/avatars/${avatarId}`, {
method: 'PUT',
body: JSON.stringify(input),
}),
delete: (avatarId: string) =>
authRequest<{ ok: boolean }>(baseURL, `/api/auth/avatars/${avatarId}`, {
method: 'DELETE',
}),
},
emojis: {
create: (
avatarId: string,
input: {
label: string
url: string
description?: string
trigger_type?: string
trigger_result?: 'success' | 'failure'
sort_order?: number
},
) =>
authRequest<{ emoji: EmojiRecord }>(baseURL, `/api/auth/avatars/${avatarId}/emojis`, {
method: 'POST',
body: JSON.stringify(input),
}),
update: (
emojiId: string,
input: {
label?: string
url?: string
description?: string
trigger_type?: string
trigger_result?: 'success' | 'failure' | null
sort_order?: number
},
) =>
authRequest<{ ok: boolean }>(baseURL, `/api/auth/emojis/${emojiId}`, {
method: 'PUT',
body: JSON.stringify(input),
}),
delete: (emojiId: string) =>
authRequest<{ ok: boolean }>(baseURL, `/api/auth/emojis/${emojiId}`, {
method: 'DELETE',
}),
},
apiKeyAvatars: {
get: (apiKeyId: string) =>
authRequest<ApiKeyAvatarResponse>(baseURL, `/api/auth/apikeys/${apiKeyId}/avatar`),
bind: (apiKeyId: string, avatarId: string | null) =>
authRequest<{ apikey_id: string; avatar_id: string | null }>(
baseURL,
`/api/auth/apikeys/${apiKeyId}/avatar`,
{
method: 'PUT',
body: JSON.stringify({ avatar_id: avatarId }),
},
),
},
files: {
uploadRaw,
},
}
}
export type WebAuthClient = ReturnType<typeof createWebAuthClient>
export function getStoredToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY)
} catch {
return null
}
}
export function clearStoredToken(): void {
try {
localStorage.removeItem(TOKEN_KEY)
} catch {
// ignore
}
}

11
src/index.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* @bowong/auth — shared auth package
*
* Server-side: createAuth() factory for Better Auth instance
* Client-side: import from "@bowong/auth/client"
*/
export { createAuth, type CreateAuthOptions, type Auth } from './auth-config.js'
export * as schema from './schema.js'
export { avatarPlugin, type AvatarPluginOptions } from './avatar/avatar.js'
export { s3, type S3Config, type S3Credentials, type S3FileMetadata } from './s3/index.js'

716
src/s3/index.ts Normal file
View File

@@ -0,0 +1,716 @@
import type { AuthContext } from 'better-auth'
import type { BetterAuthPlugin } from 'better-auth'
import { createAuthEndpoint, getSessionFromCtx, sessionMiddleware } from 'better-auth/api'
import type { DBFieldAttribute } from 'better-auth/db'
import { z, type ZodType } from 'zod'
import { schema } from './schema.js'
import type { S3Config, S3Credentials, S3FileMetadata } from './types.js'
const S3_ERROR_CODES = {
FILE_TOO_LARGE: 'File is too large. Please choose a smaller file',
INVALID_FILE_TYPE: 'File type not supported. Please choose a different file',
NO_FILE_PROVIDED: 'Please select a file to upload',
STORAGE_NOT_CONFIGURED: 'File storage is temporarily unavailable. Please try again later',
UPLOAD_FAILED: 'Upload failed. Please check your connection and try again',
FILE_ID_REQUIRED: 'File ID is required',
LIST_FILES_FAILED: 'Unable to load your files. Please refresh the page',
INVALID_METADATA: 'Invalid file information. Please try uploading again',
INVALID_FILE_RECORD: 'File information is corrupted. Please contact support',
DB_OPERATION_FAILED: 'Service temporarily unavailable. Please try again later',
} as const
const fileIdSchema = z.object({
fileId: z.string().min(1, 'File ID is required'),
})
function convertFieldAttributesToZodSchema(additionalFields: Record<string, DBFieldAttribute>) {
const zodSchema: Record<string, ZodType> = {}
for (const [key, value] of Object.entries(additionalFields)) {
let fieldSchema: ZodType
if (value.type === 'string') {
fieldSchema = z.string()
} else if (value.type === 'number') {
fieldSchema = z.number()
} else if (value.type === 'boolean') {
fieldSchema = z.boolean()
} else if (value.type === 'date') {
fieldSchema = z.date()
} else if (value.type === 'string[]') {
fieldSchema = z.array(z.string())
} else if (value.type === 'number[]') {
fieldSchema = z.array(z.number())
} else {
throw new Error(`Unsupported field type: ${value.type} for field ${key}`)
}
if (!value.required) {
fieldSchema = fieldSchema.optional()
}
zodSchema[key] = fieldSchema
}
return z.object(zodSchema)
}
function createFileMetadataSchema(additionalFields?: Record<string, DBFieldAttribute>) {
if (!additionalFields || Object.keys(additionalFields).length === 0) {
return z.record(z.string(), z.any()).optional()
}
return convertFieldAttributesToZodSchema(additionalFields).optional()
}
function sanitizeFilename(filename: string): string {
return filename.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 255)
}
function decodeHeaderFilename(rawFilename: string): string {
try {
return decodeURIComponent(rawFilename)
} catch {
return rawFilename
}
}
function getFileExtension(filename: string): string {
const lastDotIndex = filename.lastIndexOf('.')
return lastDotIndex === -1 ? '' : filename.slice(lastDotIndex + 1).toLowerCase()
}
function getContentType(file: File): string {
if (file.type) {
return file.type
}
const ext = getFileExtension(file.name)
switch (ext) {
case 'webm':
return 'video/webm'
case 'mp4':
return 'video/mp4'
case 'mov':
return 'video/quicktime'
case 'png':
return 'image/png'
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'gif':
return 'image/gif'
default:
return 'application/octet-stream'
}
}
function getMimeTypeFromExtension(extension: string): string | null {
switch (extension.toLowerCase().replace(/^\./, '')) {
case 'webm':
return 'video/webm'
case 'mp4':
return 'video/mp4'
case 'mov':
return 'video/quicktime'
case 'png':
return 'image/png'
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'gif':
return 'image/gif'
default:
return null
}
}
function createFileValidator(config: S3Config) {
const {
maxFileSize = 10 * 1024 * 1024,
allowedTypes,
} = config
return {
validateFile(file: File) {
if (file.size > maxFileSize) {
const maxSizeMB = Math.round(maxFileSize / (1024 * 1024))
throw new Error(`${S3_ERROR_CODES.FILE_TOO_LARGE} (max ${maxSizeMB}MB)`)
}
if (allowedTypes && allowedTypes.length > 0) {
const extension = getFileExtension(file.name)
const normalizedAllowedTypes = allowedTypes.map((type: string) =>
type.startsWith('.') ? type.slice(1).toLowerCase() : type.toLowerCase(),
)
if (!extension || !normalizedAllowedTypes.includes(extension)) {
const allowed = allowedTypes
.map((type: string) => (type.startsWith('.') ? type : `.${type}`))
.join(', ')
throw new Error(`${S3_ERROR_CODES.INVALID_FILE_TYPE}. Supported formats: ${allowed}`)
}
}
},
validateMetadata(metadata: Record<string, unknown>) {
const result = createFileMetadataSchema(config.additionalFields).safeParse(metadata)
if (!result.success) {
const messages = result.error.issues
.map(issue => {
const path = issue.path.length > 0 ? `${issue.path.join('.')}: ` : ''
return `${path}${issue.message}`
})
.join(', ')
throw new Error(`${S3_ERROR_CODES.INVALID_METADATA}: ${messages}`)
}
return result.data
},
}
}
function toAmzDate(date: Date): string {
return date.toISOString().replace(/[:-]|\.\d{3}/g, '')
}
function toDateStamp(date: Date): string {
return date.toISOString().slice(0, 10).replace(/-/g, '')
}
async function sha256Hex(data: string | ArrayBuffer): Promise<string> {
const input = typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data)
const digest = await crypto.subtle.digest('SHA-256', input)
return Array.from(new Uint8Array(digest))
.map(byte => byte.toString(16).padStart(2, '0'))
.join('')
}
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const buffer = new ArrayBuffer(bytes.byteLength)
new Uint8Array(buffer).set(bytes)
return buffer
}
async function hmacSha256(key: ArrayBuffer | Uint8Array | string, data: string): Promise<ArrayBuffer> {
const rawKeyBytes =
typeof key === 'string'
? new TextEncoder().encode(key)
: key instanceof Uint8Array
? key
: new Uint8Array(key)
const rawKey = toArrayBuffer(rawKeyBytes)
const cryptoKey = await crypto.subtle.importKey(
'raw',
rawKey,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
)
return crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(data))
}
function encodeS3PathSegment(segment: string): string {
return encodeURIComponent(segment).replace(/%2F/g, '/')
}
function resolveEndpoint(credentials: S3Credentials): URL {
if (credentials.endpoint) {
return new URL(credentials.endpoint)
}
return new URL(`https://${credentials.bucketName}.s3.${credentials.region}.amazonaws.com`)
}
function resolvePublicUrl(credentials: S3Credentials, key: string): string {
const encodedKey = key.split('/').map(encodeS3PathSegment).join('/')
if (credentials.publicBaseUrl) {
return `${credentials.publicBaseUrl.replace(/\/$/, '')}/${encodedKey}`
}
const endpoint = resolveEndpoint(credentials)
return `${endpoint.origin}/${encodedKey}`
}
async function createAuthorizationHeaders(
credentials: S3Credentials,
method: 'PUT' | 'DELETE',
key: string,
payload: ArrayBuffer | null,
contentType?: string,
) {
const endpoint = resolveEndpoint(credentials)
const now = new Date()
const amzDate = toAmzDate(now)
const dateStamp = toDateStamp(now)
const encodedKey = key.split('/').map(encodeS3PathSegment).join('/')
const pathname = `/${encodedKey}`
const host = endpoint.host
const payloadHash = payload ? await sha256Hex(payload) : await sha256Hex('')
const canonicalHeaders = [
`host:${host}`,
`x-amz-content-sha256:${payloadHash}`,
`x-amz-date:${amzDate}`,
]
const signedHeadersParts = ['host', 'x-amz-content-sha256', 'x-amz-date']
if (contentType) {
canonicalHeaders.push(`content-type:${contentType}`)
signedHeadersParts.push('content-type')
}
canonicalHeaders.sort()
signedHeadersParts.sort()
const canonicalRequest = [
method,
pathname,
'',
`${canonicalHeaders.join('\n')}\n`,
signedHeadersParts.join(';'),
payloadHash,
].join('\n')
const credentialScope = `${dateStamp}/${credentials.region}/s3/aws4_request`
const stringToSign = [
'AWS4-HMAC-SHA256',
amzDate,
credentialScope,
await sha256Hex(canonicalRequest),
].join('\n')
const kDate = await hmacSha256(`AWS4${credentials.secretAccessKey}`, dateStamp)
const kRegion = await hmacSha256(kDate, credentials.region)
const kService = await hmacSha256(kRegion, 's3')
const kSigning = await hmacSha256(kService, 'aws4_request')
const signature = Array.from(new Uint8Array(await hmacSha256(kSigning, stringToSign)))
.map(byte => byte.toString(16).padStart(2, '0'))
.join('')
const authorization = [
'AWS4-HMAC-SHA256 Credential=',
`${credentials.accessKeyId}/${credentialScope}, `,
`SignedHeaders=${signedHeadersParts.join(';')}, `,
`Signature=${signature}`,
].join('')
const headers = new Headers({
Authorization: authorization,
Host: host,
'x-amz-content-sha256': payloadHash,
'x-amz-date': amzDate,
})
if (contentType) {
headers.set('Content-Type', contentType)
}
return {
url: new URL(pathname, endpoint.origin),
headers,
}
}
function validateFileMetadata(record: unknown): record is S3FileMetadata {
if (!record || typeof record !== 'object') {
return false
}
const candidate = record as Record<string, unknown>
return (
typeof candidate.id === 'string' &&
typeof candidate.userId === 'string' &&
typeof candidate.filename === 'string' &&
typeof candidate.originalName === 'string' &&
typeof candidate.contentType === 'string' &&
typeof candidate.s3Key === 'string' &&
typeof candidate.url === 'string' &&
typeof candidate.size === 'number' &&
(candidate.uploadedAt instanceof Date || typeof candidate.uploadedAt === 'string')
)
}
function deriveStoredFileId(filename: string): string {
return `${Date.now()}_${Math.random().toString(36).slice(2, 11)}-${sanitizeFilename(filename)}`
}
function validateAuthContext(ctx: AuthContext): void {
if (!ctx) {
throw new Error('Auth context is not available')
}
if (!ctx.adapter) {
throw new Error('Database adapter is not properly configured')
}
}
function createS3Storage(
config: S3Config,
generateId: (options: { model: string; size?: number }) => string | false,
) {
const validator = createFileValidator(config)
return {
async uploadFile(
file: File,
originalName: string,
userId: string,
ctx: AuthContext,
customMetadata?: Record<string, unknown>,
modelName = 'userFile',
): Promise<S3FileRecord> {
validator.validateFile(file)
let validatedMetadata: Record<string, unknown> | undefined
if (customMetadata) {
validatedMetadata = validator.validateMetadata(customMetadata) as Record<string, unknown>
}
const fileId = generateId({ model: modelName }) || deriveStoredFileId(originalName)
const filename = `${fileId}-${sanitizeFilename(originalName)}`
const keyPrefix = config.credentials.keyPrefix?.replace(/^\/+|\/+$/g, '')
const s3Key = [keyPrefix, userId, filename].filter(Boolean).join('/')
const contentType = getContentType(file)
const metadata: S3FileRecord = {
id: fileId,
userId,
filename,
originalName,
contentType,
size: file.size,
s3Key,
url: resolvePublicUrl(config.credentials, s3Key),
uploadedAt: new Date(),
...(validatedMetadata ?? {}),
}
if (config.hooks?.upload?.before) {
const hookFile = Object.assign(file, {
userId,
s3Key,
metadata,
})
const result = await config.hooks.upload.before(hookFile, ctx)
if (result === null) {
throw new Error('Upload prevented by beforeUpload hook')
}
}
const body = await file.arrayBuffer()
const { url, headers } = await createAuthorizationHeaders(
config.credentials,
'PUT',
s3Key,
body,
contentType,
)
const response = await fetch(url, {
method: 'PUT',
headers,
body,
})
if (!response.ok) {
const detail = await response.text()
ctx.logger?.error?.('[S3]: Upload failed', detail)
throw new Error(`${S3_ERROR_CODES.UPLOAD_FAILED}: ${detail || response.status}`)
}
if (config.hooks?.upload?.after) {
await config.hooks.upload.after(metadata, ctx)
}
return metadata
},
async deleteFile(fileMetadata: S3FileRecord, ctx: AuthContext) {
if (config.hooks?.delete?.before) {
const result = await config.hooks.delete.before(fileMetadata, ctx)
if (result === null) {
throw new Error('Delete prevented by beforeDelete hook')
}
}
const { url, headers } = await createAuthorizationHeaders(
config.credentials,
'DELETE',
fileMetadata.s3Key,
null,
)
const response = await fetch(url, {
method: 'DELETE',
headers,
})
if (!response.ok && response.status !== 204) {
const detail = await response.text()
throw new Error(`Failed to delete S3 object: ${detail || response.status}`)
}
if (config.hooks?.delete?.after) {
await config.hooks.delete.after(fileMetadata, ctx)
}
},
}
}
function createS3Endpoints(
getS3Storage: () => ReturnType<typeof createS3Storage> | null,
s3Config?: S3Config,
) {
const allowedMediaTypes = s3Config?.allowedTypes
?.map((type: string) => getMimeTypeFromExtension(type))
.filter((value): value is string => Boolean(value))
return {
upload: createAuthEndpoint(
'/files/upload-raw',
{
method: 'POST',
metadata: {
allowedMediaTypes,
},
},
async ctx => {
const session = await getSessionFromCtx(ctx)
if (!session) {
throw ctx.error('UNAUTHORIZED', { message: 'Please sign in to upload files' })
}
try {
validateAuthContext(ctx.context)
} catch (error) {
ctx.context.logger?.error?.('[S3]: Auth context validation failed', error)
throw ctx.error('INTERNAL_SERVER_ERROR', {
message: S3_ERROR_CODES.DB_OPERATION_FAILED,
})
}
const s3Storage = getS3Storage()
if (!s3Storage || !s3Config) {
throw ctx.error('INTERNAL_SERVER_ERROR', {
message: S3_ERROR_CODES.STORAGE_NOT_CONFIGURED,
})
}
const contentLength = ctx.request?.headers?.get('content-length')
const maxFileSize = s3Config.maxFileSize || 10 * 1024 * 1024
if (contentLength && Number.parseInt(contentLength, 10) > maxFileSize) {
const maxSizeMB = Math.round(maxFileSize / (1024 * 1024))
throw ctx.error('BAD_REQUEST', {
message: `${S3_ERROR_CODES.FILE_TOO_LARGE} (max ${maxSizeMB}MB)`,
})
}
const rawFilename = ctx.request?.headers?.get('x-filename')
if (!rawFilename) {
throw ctx.error('BAD_REQUEST', { message: 'x-filename header is required' })
}
const metadataHeader = ctx.request?.headers?.get('x-file-metadata')
let additionalFields: Record<string, unknown> = {}
if (metadataHeader) {
try {
additionalFields = JSON.parse(metadataHeader) as Record<string, unknown>
} catch {
throw ctx.error('BAD_REQUEST', { message: 'Invalid JSON in x-file-metadata header' })
}
}
const body = ctx.body
if (!body) {
throw ctx.error('BAD_REQUEST', { message: S3_ERROR_CODES.NO_FILE_PROVIDED })
}
const filename = decodeHeaderFilename(rawFilename)
const file =
body instanceof File
? body
: new File([body], filename, {
type: body.type || 'application/octet-stream',
})
try {
const fileMetadata = await s3Storage.uploadFile(
file,
filename,
session.session.userId,
ctx.context,
additionalFields,
)
await ctx.context.adapter.create({
model: 'userFile',
data: {
userId: fileMetadata.userId,
filename: fileMetadata.filename,
originalName: fileMetadata.originalName,
contentType: fileMetadata.contentType,
size: fileMetadata.size,
s3Key: fileMetadata.s3Key,
url: fileMetadata.url,
uploadedAt: fileMetadata.uploadedAt,
...additionalFields,
},
})
return ctx.json({
success: true,
data: fileMetadata,
})
} catch (error) {
ctx.context.logger?.error?.('[S3]: Upload failed', error)
throw ctx.error('INTERNAL_SERVER_ERROR', {
message: error instanceof Error ? error.message : S3_ERROR_CODES.UPLOAD_FAILED,
})
}
},
),
list: createAuthEndpoint(
'/files/list',
{
method: 'GET',
use: [sessionMiddleware],
},
async ctx => {
const session = ctx.context.session
const limit = ctx.query?.limit ? Number.parseInt(ctx.query.limit as string, 10) : 50
const cursor = ctx.query?.cursor as string | undefined
if (s3Config?.hooks?.list?.before) {
const result = await s3Config.hooks.list.before(session.session.userId, ctx.context)
if (result === null) {
throw ctx.error('FORBIDDEN', { message: 'List prevented by beforeList hook' })
}
}
try {
const records = await ctx.context.adapter.findMany({
model: 'userFile',
where: [{ field: 'userId', value: session.session.userId }],
limit: Math.min(limit, 100) + 1,
sortBy: { field: 'uploadedAt', direction: 'desc' },
})
let fileRecords = records.filter(validateFileMetadata)
if (cursor) {
const cursorIndex = fileRecords.findIndex(file => file.id === cursor)
if (cursorIndex !== -1) {
fileRecords = fileRecords.slice(cursorIndex + 1)
}
}
const hasMore = fileRecords.length > limit
const files = hasMore ? fileRecords.slice(0, -1) : fileRecords
const nextCursor = hasMore ? files[files.length - 1]?.id ?? null : null
if (s3Config?.hooks?.list?.after) {
await s3Config.hooks.list.after(session.session.userId, files, ctx.context)
}
return ctx.json({ files, nextCursor, hasMore })
} catch (error) {
ctx.context.logger?.error?.('[S3]: Failed to list files', error)
throw ctx.error('INTERNAL_SERVER_ERROR', {
message: S3_ERROR_CODES.LIST_FILES_FAILED,
})
}
},
),
get: createAuthEndpoint(
'/files/get',
{
method: 'POST',
use: [sessionMiddleware],
body: fileIdSchema,
},
async ctx => {
const session = ctx.context.session
const fileRecord = await ctx.context.adapter.findOne({
model: 'userFile',
where: [
{ field: 'id', value: ctx.body.fileId },
{ field: 'userId', value: session.session.userId },
],
})
if (!fileRecord) {
throw ctx.error('NOT_FOUND', {
message: 'File not found. It may have been deleted or you do not have permission to access it',
})
}
return ctx.json({ data: fileRecord })
},
),
delete: createAuthEndpoint(
'/files/delete',
{
method: 'POST',
use: [sessionMiddleware],
body: fileIdSchema,
},
async ctx => {
const session = ctx.context.session
const s3Storage = getS3Storage()
if (!s3Storage) {
throw ctx.error('INTERNAL_SERVER_ERROR', {
message: S3_ERROR_CODES.STORAGE_NOT_CONFIGURED,
})
}
const fileRecord = await ctx.context.adapter.findOne({
model: 'userFile',
where: [
{ field: 'id', value: ctx.body.fileId },
{ field: 'userId', value: session.session.userId },
],
})
if (!fileRecord || !validateFileMetadata(fileRecord)) {
throw ctx.error('NOT_FOUND', {
message: 'File not found. It may have been deleted or you do not have permission to delete it',
})
}
try {
await s3Storage.deleteFile(fileRecord as S3FileRecord, ctx.context)
await ctx.context.adapter.delete({
model: 'userFile',
where: [{ field: 'id', value: ctx.body.fileId }],
})
return ctx.json({
message: 'File deleted successfully',
fileId: ctx.body.fileId,
})
} catch (error) {
ctx.context.logger?.error?.('[S3]: Delete failed', error)
throw ctx.error('INTERNAL_SERVER_ERROR', {
message: error instanceof Error ? error.message : S3_ERROR_CODES.UPLOAD_FAILED,
})
}
},
),
}
}
export function s3(config: S3Config): BetterAuthPlugin {
let s3Storage: ReturnType<typeof createS3Storage> | null = null
return {
id: 's3',
schema: schema(config),
endpoints: createS3Endpoints(() => s3Storage, config),
init(initCtx) {
s3Storage = createS3Storage(config, initCtx.generateId)
return {}
},
}
}
export type { S3Config, S3Credentials, S3FileMetadata } from './types.js'
type S3FileRecord = S3FileMetadata & Record<string, unknown>

76
src/s3/schema.ts Normal file
View File

@@ -0,0 +1,76 @@
import type { BetterAuthPluginDBSchema, DBFieldAttribute } from 'better-auth/db'
import type { S3Config } from './types.js'
const coreFileFields = {
userId: {
type: 'string',
required: true,
input: false,
references: {
model: 'user',
field: 'id',
},
} as DBFieldAttribute,
filename: {
type: 'string',
required: true,
input: false,
} as DBFieldAttribute,
originalName: {
type: 'string',
required: true,
input: false,
} as DBFieldAttribute,
contentType: {
type: 'string',
required: true,
input: false,
} as DBFieldAttribute,
size: {
type: 'number',
required: true,
input: false,
} as DBFieldAttribute,
s3Key: {
type: 'string',
required: true,
input: false,
} as DBFieldAttribute,
url: {
type: 'string',
required: true,
input: false,
} as DBFieldAttribute,
uploadedAt: {
type: 'date',
required: true,
input: false,
} as DBFieldAttribute,
}
function generateFileFields(additionalFields?: Record<string, DBFieldAttribute>) {
const fields = { ...coreFileFields }
if (!additionalFields) {
return fields
}
for (const [fieldName, fieldConfig] of Object.entries(additionalFields)) {
fields[fieldName as keyof typeof fields] = fieldConfig
}
return fields
}
export const schema = (config?: S3Config): BetterAuthPluginDBSchema => {
const authSchema: BetterAuthPluginDBSchema = {}
if (config) {
authSchema.userFile = {
modelName: 'user_file',
fields: generateFileFields(config.additionalFields),
}
}
return authSchema
}

64
src/s3/types.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { AuthContext } from 'better-auth'
import type { DBFieldAttribute } from 'better-auth/db'
export interface S3Credentials {
accessKeyId: string
secretAccessKey: string
bucketName: string
region: string
endpoint?: string
publicBaseUrl?: string
keyPrefix?: string
}
export interface S3FileMetadata {
id: string
userId: string
filename: string
originalName: string
contentType: string
size: number
s3Key: string
url: string
uploadedAt: Date
}
export interface S3Config {
credentials: S3Credentials
additionalFields?: Record<string, DBFieldAttribute>
maxFileSize?: number
allowedTypes?: string[]
hooks?: {
upload?: {
before?: (
file: File & {
userId: string
s3Key: string
metadata: S3FileMetadata & Record<string, unknown>
},
ctx: AuthContext,
) => void | null | Promise<void | null | undefined>
after?: (
file: S3FileMetadata & Record<string, unknown>,
ctx: AuthContext,
) => void | Promise<void>
}
delete?: {
before?: (
file: S3FileMetadata & Record<string, unknown>,
ctx: AuthContext,
) => void | null | Promise<void | null | undefined>
after?: (
file: S3FileMetadata & Record<string, unknown>,
ctx: AuthContext,
) => void | Promise<void>
}
list?: {
before?: (
userId: string,
ctx: AuthContext,
) => void | null | Promise<void | null | undefined>
after?: (userId: string, files: unknown[], ctx: AuthContext) => void | Promise<void>
}
}
}

128
src/schema.ts Normal file
View File

@@ -0,0 +1,128 @@
/**
* Drizzle SQLite schema for Better Auth tables.
* These tables are created/managed by Better Auth but defined here
* for type-safe queries (e.g. relay D1 validation).
*/
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
export const user = sqliteTable('user', {
id: text('id').primaryKey(),
name: text('name'),
email: text('email').notNull().unique(),
emailVerified: integer('email_verified', { mode: 'boolean' })
.notNull()
.default(false),
image: text('image'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
})
export const avatars = sqliteTable('avatars', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
})
export const emojis = sqliteTable('emojis', {
id: text('id').primaryKey(),
avatarId: text('avatar_id')
.notNull()
.references(() => avatars.id, { onDelete: 'cascade' }),
label: text('label').notNull(),
url: text('url').notNull(),
description: text('description'),
sortOrder: integer('sort_order').default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
})
export const session = sqliteTable('session', {
id: text('id').primaryKey(),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
token: text('token').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => user.id),
})
export const account = sqliteTable('account', {
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: integer('access_token_expires_at', {
mode: 'timestamp',
}),
refreshTokenExpiresAt: integer('refresh_token_expires_at', {
mode: 'timestamp',
}),
scope: text('scope'),
password: text('password'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
})
export const verification = sqliteTable('verification', {
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }),
updatedAt: integer('updated_at', { mode: 'timestamp' }),
})
export const apikey = sqliteTable('apikey', {
id: text('id').primaryKey(),
configId: text('config_id'),
name: text('name'),
start: text('start'),
prefix: text('prefix'),
key: text('key').notNull(),
referenceId: text('reference_id'),
enabled: integer('enabled', { mode: 'boolean' }).default(true),
rateLimitEnabled: integer('rate_limit_enabled', { mode: 'boolean' }).default(true),
rateLimitTimeWindow: integer('rate_limit_time_window'),
rateLimitMax: integer('rate_limit_max'),
requestCount: integer('request_count').default(0),
remaining: integer('remaining'),
refillInterval: integer('refill_interval'),
refillAmount: integer('refill_amount'),
lastRefillAt: integer('last_refill_at', { mode: 'timestamp' }),
lastRequest: integer('last_request', { mode: 'timestamp' }),
permissions: text('permissions'),
metadata: text('metadata'),
expiresAt: integer('expires_at', { mode: 'timestamp' }),
avatarId: text('avatar_id').references(() => avatars.id, {
onDelete: 'set null',
}),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
})
export const userFile = sqliteTable('user_file', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
filename: text('filename').notNull(),
originalName: text('original_name').notNull(),
contentType: text('content_type').notNull(),
size: integer('size').notNull(),
s3Key: text('s3_key').notNull(),
url: text('url').notNull(),
uploadedAt: integer('uploaded_at', { mode: 'timestamp' }).notNull(),
})

14
tsconfig.build.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "./src",
"lib": ["ES2022", "DOM"]
},
"include": ["src"],
"exclude": ["node_modules"]
}

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "src/client.ts"]
}