mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
998 lines
39 KiB
TypeScript
998 lines
39 KiB
TypeScript
import { apiKey } from '@better-auth/api-key'
|
|
import { APIError, type BetterAuthOptions, type BetterAuthPlugin, betterAuth } from 'better-auth'
|
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
|
import { createAuthMiddleware, getSessionFromCtx } from 'better-auth/api'
|
|
import type { CaptchaOptions } from 'better-auth/plugins'
|
|
import {
|
|
admin,
|
|
bearer,
|
|
captcha,
|
|
deviceAuthorization,
|
|
lastLoginMethod,
|
|
openAPI,
|
|
organization,
|
|
username,
|
|
} from 'better-auth/plugins'
|
|
import { genericOAuth } from 'better-auth/plugins/generic-oauth'
|
|
import { adminAc, memberAc, ownerAc } from 'better-auth/plugins/organization/access'
|
|
import { count, eq, like } from 'drizzle-orm'
|
|
import { customAlphabet, nanoid } from 'nanoid'
|
|
import {
|
|
API_KEY_TEMPLATES,
|
|
ApiKeyTemplate,
|
|
apiKeyMetadata,
|
|
IHOST_API_KEY_PERMISSIONS,
|
|
REMOTE_DOWNLOAD_API_KEY_PERMISSIONS,
|
|
WEBDAV_API_KEY_PERMISSIONS,
|
|
WEBDAV_API_KEY_RATE_LIMIT_MAX_REQUESTS,
|
|
WEBDAV_API_KEY_RATE_LIMIT_WINDOW_MS,
|
|
WEBDAV_RATE_LIMITER_BINDING,
|
|
} from '../shared/api-key-templates'
|
|
import { DEFAULT_ORG_QUOTA, DEFAULT_ORG_TRAFFIC_QUOTA, SignupMode } from '../shared/constants'
|
|
import {
|
|
BUILTIN_PROVIDER_IDS,
|
|
OAUTH_PROVIDER_KEY_PATTERN,
|
|
OAUTH_PROVIDER_KEY_PREFIX,
|
|
type OAuthProviderConfig,
|
|
parseProviderConfig,
|
|
} from '../shared/oauth-providers'
|
|
import { generateUserOrgSlug, isPersonalOrgLike } from '../shared/org-slugs'
|
|
import { createEmailGateway } from './adapters/gateways/email'
|
|
import { deleteApiKeysScopedToOrganization, normalizeLegacyApiKeysForUser } from './adapters/repos/api-key-scopes'
|
|
import { createAuditRepo } from './adapters/repos/audit'
|
|
import { createInviteRepo } from './adapters/repos/invite'
|
|
import { createLicenseBindingRepo } from './adapters/repos/license-binding'
|
|
import { createMemberCountRepo } from './adapters/repos/member-count'
|
|
import { createNotificationRepo } from './adapters/repos/notification'
|
|
import { createOrgRepo } from './adapters/repos/org'
|
|
import { createSiteInvitationRepo } from './adapters/repos/site-invitations'
|
|
import { initialStorageUsageProjectionQueries } from './adapters/repos/storage-usage-breakdown'
|
|
import { createSystemOptionsRepo } from './adapters/repos/system-options'
|
|
import { recordUserActivity } from './adapters/repos/user-activity'
|
|
import * as authSchema from './db/auth-schema'
|
|
import { orgQuotaEntitlements, orgQuotas, systemOptions } from './db/schema'
|
|
import { executeWriteTransaction } from './db/transaction'
|
|
import { CAPTCHA_AUTH_ENDPOINTS, type CaptchaConfig } from './domain/captcha'
|
|
import { EMAIL_VERIFICATION_REQUIRED_OPTION_KEY, isEmailVerificationRequired } from './domain/email-verification'
|
|
import { currentTrafficPeriod } from './domain/quota'
|
|
import { recordAuditEffect } from './lib/audit'
|
|
import { isLocalNetworkOrigin } from './lib/local-origin'
|
|
import { hashPassword, verifyPassword as verifyPasswordHash } from './lib/password'
|
|
import { createDbProxy, createPlatformProxy } from './platform/context'
|
|
import type { Database, Platform } from './platform/interface'
|
|
import { loadCaptchaConfig } from './usecases/site/captcha'
|
|
import { checkTeamLimit, getEffectiveSignupMode } from './usecases/site/licensing'
|
|
|
|
// better-auth's default password hasher is pure-JS scrypt from @noble/hashes,
|
|
// which blows past Cloudflare Workers' CPU budget and triggers error 1102.
|
|
// We use node:crypto.scryptSync via server/lib/password.ts (native OpenSSL,
|
|
// counted as I/O rather than JS CPU time on CF Workers).
|
|
|
|
async function authHashPassword(password: string): Promise<string> {
|
|
return hashPassword(password)
|
|
}
|
|
|
|
async function authVerifyPassword({ hash, password }: { hash: string; password: string }): Promise<boolean> {
|
|
if (!hash.includes(':')) throw new Error('stored password hash is malformed: expected "<salt>:<key>"')
|
|
return verifyPasswordHash(hash, password)
|
|
}
|
|
|
|
interface ProviderConfigs {
|
|
oidc: OAuthProviderConfig[]
|
|
builtin: Array<{ providerId: string; clientId: string; clientSecret: string }>
|
|
}
|
|
|
|
// One query loads every oauth_provider_* row. Configs are snapshotted at auth
|
|
// instance creation: better-auth resolves social providers eagerly during its
|
|
// context init, so per-request dynamic loading is not possible anyway. Admin
|
|
// changes take effect on isolate recycle (CF Workers) or restart (Node).
|
|
async function loadProviderConfigs(db: Database): Promise<ProviderConfigs> {
|
|
const rows = await db
|
|
.select({ key: systemOptions.key, value: systemOptions.value })
|
|
.from(systemOptions)
|
|
.where(like(systemOptions.key, OAUTH_PROVIDER_KEY_PATTERN))
|
|
|
|
const configs: ProviderConfigs = { oidc: [], builtin: [] }
|
|
for (const row of rows) {
|
|
const config = parseProviderConfig(row.value)
|
|
if (!config?.enabled) continue
|
|
if (config.type === 'oidc') {
|
|
configs.oidc.push(config)
|
|
continue
|
|
}
|
|
const providerId = row.key.slice(OAUTH_PROVIDER_KEY_PREFIX.length)
|
|
if (config.type === 'builtin' && BUILTIN_PROVIDER_IDS.includes(providerId)) {
|
|
configs.builtin.push({ providerId, clientId: config.clientId, clientSecret: config.clientSecret })
|
|
}
|
|
}
|
|
return configs
|
|
}
|
|
|
|
// Maps stored captcha config to the better-auth captcha plugin options. Lives
|
|
// here because better-auth's CaptchaOptions type is delivery-framework-specific
|
|
// and may not leak into the framework-free usecases/ layer.
|
|
export function toBetterAuthCaptchaOptions(config: CaptchaConfig): CaptchaOptions {
|
|
const base = {
|
|
provider: config.provider,
|
|
secretKey: config.secretKey,
|
|
endpoints: [...CAPTCHA_AUTH_ENDPOINTS],
|
|
}
|
|
|
|
if (config.provider === 'google-recaptcha') {
|
|
return config.minScore === undefined ? base : { ...base, minScore: config.minScore }
|
|
}
|
|
|
|
if (config.provider === 'hcaptcha' || config.provider === 'captchafox') {
|
|
return { ...base, siteKey: config.siteKey }
|
|
}
|
|
|
|
return base
|
|
}
|
|
|
|
function dynamicCaptcha(db: Database): BetterAuthPlugin {
|
|
return {
|
|
id: 'dynamic-captcha',
|
|
onRequest: async (request, ctx) => {
|
|
// Only captcha-protected endpoints need the config — skip the DB read
|
|
// for everything else (notably get-session, the hottest auth route).
|
|
const path = new URL(request.url).pathname
|
|
if (!CAPTCHA_AUTH_ENDPOINTS.some((endpoint) => path.endsWith(endpoint))) return
|
|
const config = await loadCaptchaConfig({ systemOptions: createSystemOptionsRepo(db) })
|
|
if (!config) return
|
|
const plugin = captcha(toBetterAuthCaptchaOptions(config))
|
|
return plugin.onRequest?.(request, ctx)
|
|
},
|
|
}
|
|
}
|
|
|
|
const EMAIL_VERIFICATION_AUTH_PATHS = ['/sign-up/email', '/sign-in/email', '/sign-in/username'] as const
|
|
|
|
function usesEmailVerificationPolicy(request: Request): boolean {
|
|
const path = new URL(request.url).pathname
|
|
return EMAIL_VERIFICATION_AUTH_PATHS.some((authPath) => path.endsWith(authPath))
|
|
}
|
|
|
|
const _INVITE_CODE_ERRORS: Record<string, string> = {
|
|
not_found: 'Invalid invite code',
|
|
already_used: 'Invite code already used',
|
|
expired: 'Invite code expired',
|
|
}
|
|
|
|
// Admin user disable/enable/delete now run through better-auth's admin plugin, so
|
|
// the activity audit that the old /api/users usecases emitted is reattached here
|
|
// via a better-auth after-hook — keyed by endpoint path so it fires only for
|
|
// these three actions, with the acting admin as the recorded actor.
|
|
const ADMIN_USER_AUDIT: Record<string, { action: string; status?: 'active' | 'disabled' }> = {
|
|
'/admin/ban-user': { action: 'user_disable', status: 'disabled' },
|
|
'/admin/unban-user': { action: 'user_enable', status: 'active' },
|
|
'/admin/remove-user': { action: 'user_delete' },
|
|
}
|
|
|
|
const ORGANIZATION_AUDIT_ACTIONS: Record<string, string> = {
|
|
'/organization/create': 'team_create',
|
|
'/organization/update': 'team_settings_update',
|
|
'/organization/delete': 'team_delete',
|
|
'/organization/remove-member': 'team_member_remove',
|
|
'/organization/update-member-role': 'team_member_role_update',
|
|
'/organization/accept-invitation': 'team_member_join',
|
|
}
|
|
|
|
async function recordOrganizationAuthAudit(
|
|
db: Database,
|
|
ctx: { path: string; body?: unknown; context: { returned?: unknown } },
|
|
actorId: string,
|
|
action: string,
|
|
): Promise<void> {
|
|
const body = recordValue(ctx.body) ?? {}
|
|
const returned = recordValue(ctx.context.returned) ?? {}
|
|
const member = recordValue(returned.member) ?? (typeof returned.userId === 'string' ? returned : null)
|
|
const invitation = recordValue(returned.invitation)
|
|
const data = recordValue(body.data)
|
|
const orgId =
|
|
stringValue(body.organizationId) ??
|
|
stringValue(member?.organizationId) ??
|
|
stringValue(invitation?.organizationId) ??
|
|
stringValue(returned.id)
|
|
if (!orgId) throw new Error(`audit_organization_context_missing:${ctx.path}`)
|
|
|
|
const metadata =
|
|
action === 'team_member_remove'
|
|
? { removedUserId: stringValue(member?.userId) }
|
|
: action === 'team_member_role_update'
|
|
? { targetUserId: stringValue(member?.userId), newRole: stringValue(member?.role) ?? stringValue(body.role) }
|
|
: action === 'team_member_join'
|
|
? { targetUserId: stringValue(member?.userId) ?? actorId, role: stringValue(member?.role) }
|
|
: data
|
|
? { changedFields: Object.keys(data).sort() }
|
|
: undefined
|
|
|
|
await recordAuditEffect(action, () =>
|
|
createAuditRepo(db).record({
|
|
orgId,
|
|
userId: actorId,
|
|
action,
|
|
targetType: 'team',
|
|
targetId: orgId,
|
|
targetName: stringValue(returned.name) ?? stringValue(data?.name) ?? stringValue(body.name) ?? orgId,
|
|
metadata,
|
|
}),
|
|
)
|
|
}
|
|
|
|
function recordValue(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
|
|
}
|
|
|
|
function stringValue(value: unknown): string | undefined {
|
|
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
}
|
|
|
|
async function ensureUserRegistrationAudit(db: Database, userId: string, firstAccountId?: string): Promise<void> {
|
|
const [firstAccount] = await db
|
|
.select({ id: authSchema.account.id, providerId: authSchema.account.providerId })
|
|
.from(authSchema.account)
|
|
.where(eq(authSchema.account.userId, userId))
|
|
.orderBy(authSchema.account.createdAt, authSchema.account.id)
|
|
.limit(1)
|
|
if (firstAccountId && firstAccount?.id !== firstAccountId) return
|
|
|
|
const [registeredUser] = await db
|
|
.select({
|
|
id: authSchema.user.id,
|
|
name: authSchema.user.name,
|
|
email: authSchema.user.email,
|
|
createdAt: authSchema.user.createdAt,
|
|
})
|
|
.from(authSchema.user)
|
|
.where(eq(authSchema.user.id, userId))
|
|
.limit(1)
|
|
if (!registeredUser) throw new Error(`registered_user_missing:${userId}`)
|
|
|
|
await createAuditRepo(db).recordOnce(
|
|
{
|
|
orgId: '',
|
|
userId: registeredUser.id,
|
|
actorType: 'user',
|
|
action: 'user_register',
|
|
targetType: 'user',
|
|
targetId: registeredUser.id,
|
|
targetName: registeredUser.name || registeredUser.email,
|
|
metadata: { provider: firstAccount?.providerId || 'unknown' },
|
|
},
|
|
registeredUser.id,
|
|
registeredUser.createdAt,
|
|
)
|
|
}
|
|
|
|
function buildInvitationEmailHtml(data: {
|
|
email: string
|
|
role: string
|
|
organization: { name: string }
|
|
inviter: { user: { name: string; email: string } }
|
|
id: string
|
|
}): string {
|
|
const orgName = data.organization.name
|
|
const inviterName = data.inviter.user.name || data.inviter.user.email
|
|
const acceptUrl = `/api/auth/organization/accept-invitation/${data.id}`
|
|
return `<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px">
|
|
<h2 style="margin:0 0 16px">You've been invited to join ${orgName}</h2>
|
|
<p style="color:#555;line-height:1.5">${inviterName} has invited you to join <strong>${orgName}</strong> as <strong>${data.role}</strong>.</p>
|
|
<a href="${acceptUrl}" style="display:inline-block;margin:24px 0;padding:12px 24px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px;font-weight:600">Accept Invitation</a>
|
|
<p style="color:#999;font-size:13px">If you did not expect this invitation, you can safely ignore this email.</p>
|
|
</div>`
|
|
}
|
|
|
|
function buildVerificationEmailHtml(url: string): string {
|
|
if (!url.startsWith('https://') && !url.startsWith('http://')) {
|
|
throw new Error(`Verification URL has unsafe protocol: ${url}`)
|
|
}
|
|
return `<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px">
|
|
<h2 style="margin:0 0 16px">Verify your email</h2>
|
|
<p style="color:#555;line-height:1.5">Click the button below to verify your email address and activate your account.</p>
|
|
<a href="${url}" style="display:inline-block;margin:24px 0;padding:12px 24px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px;font-weight:600">Verify Email</a>
|
|
<p style="color:#999;font-size:13px">If you didn't create an account, you can safely ignore this email.</p>
|
|
</div>`
|
|
}
|
|
|
|
function buildResetPasswordEmailHtml(url: string): string {
|
|
if (!url.startsWith('https://') && !url.startsWith('http://')) {
|
|
throw new Error(`Reset password URL has unsafe protocol: ${url}`)
|
|
}
|
|
return `<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px">
|
|
<h2 style="margin:0 0 16px">Reset your password</h2>
|
|
<p style="color:#555;line-height:1.5">Click the button below to choose a new password. This link expires in 1 hour.</p>
|
|
<a href="${url}" style="display:inline-block;margin:24px 0;padding:12px 24px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px;font-weight:600">Reset Password</a>
|
|
<p style="color:#999;font-size:13px">If you didn't request a password reset, you can safely ignore this email.</p>
|
|
</div>`
|
|
}
|
|
|
|
export async function createAuth(
|
|
initialSource: Database | Platform,
|
|
secret: string,
|
|
baseURL?: string,
|
|
trustedOrigins?: string[],
|
|
) {
|
|
const isPlatform = 'db' in initialSource
|
|
const rawPlatform = isPlatform ? initialSource : null
|
|
const rawDb = isPlatform ? initialSource.db : initialSource
|
|
|
|
const platformProxy = rawPlatform ? createPlatformProxy(rawPlatform) : null
|
|
const dbProxy = platformProxy ? platformProxy.db : createDbProxy(rawDb)
|
|
|
|
const db = dbProxy
|
|
// The email gateway needs a Platform for the Cloudflare EMAIL binding. On the
|
|
// bare-Database path (tests, Node fallbacks) there is no platform, so wrap the
|
|
// db proxy in a binding-free Platform — matching the previous behaviour where
|
|
// a Database source had no CF binding available.
|
|
const authPlatform: Platform = platformProxy ?? { db: dbProxy, getEnv: () => undefined, getBinding: () => undefined }
|
|
const systemOptionsRepo = createSystemOptionsRepo(db)
|
|
const email = createEmailGateway(systemOptionsRepo)
|
|
const providerConfigs = await loadProviderConfigs(rawDb)
|
|
const usesNativeWebDavRateLimit = Boolean(authPlatform.getBinding(WEBDAV_RATE_LIMITER_BINDING))
|
|
const authOptions = {
|
|
database: drizzleAdapter(db, { provider: 'sqlite', schema: authSchema }),
|
|
secret,
|
|
baseURL,
|
|
// Function form: better-auth merges the result with baseURL per request.
|
|
// Loopback/LAN origins are trusted automatically so self-hosted users can
|
|
// log in via 127.0.0.1 or a LAN IP without configuring TRUSTED_ORIGINS.
|
|
trustedOrigins: (request?: Request) => {
|
|
const origin = request?.headers.get('origin')
|
|
const list = trustedOrigins ?? []
|
|
return origin && isLocalNetworkOrigin(origin) ? [...list, origin] : list
|
|
},
|
|
advanced: {
|
|
cookiePrefix: 'zp',
|
|
// Explicitly enable the origin check (production default). Without this,
|
|
// better-auth silently disables it under NODE_ENV=test, so tests would
|
|
// never exercise the real CSRF/origin behavior.
|
|
disableOriginCheck: false,
|
|
},
|
|
user: {
|
|
additionalFields: {
|
|
lastActiveAt: { type: 'date', required: false, input: false, returned: false },
|
|
},
|
|
},
|
|
emailAndPassword: {
|
|
enabled: true,
|
|
password: {
|
|
hash: authHashPassword,
|
|
verify: authVerifyPassword,
|
|
},
|
|
sendResetPassword: async ({ user, url }) => {
|
|
if (!(await email.isConfigured(authPlatform))) return
|
|
await email.send(authPlatform, {
|
|
to: user.email,
|
|
subject: 'Reset your password - ZPan',
|
|
html: buildResetPasswordEmailHtml(url),
|
|
})
|
|
},
|
|
},
|
|
emailVerification: {
|
|
sendVerificationEmail: async ({ user, url }) => {
|
|
if (!(await email.isConfigured(authPlatform))) return
|
|
await email.send(authPlatform, {
|
|
to: user.email,
|
|
subject: 'Verify your email - ZPan',
|
|
html: buildVerificationEmailHtml(url),
|
|
})
|
|
},
|
|
autoSignInAfterVerification: true,
|
|
},
|
|
session: {
|
|
cookieCache: {
|
|
enabled: true,
|
|
maxAge: 60 * 5,
|
|
},
|
|
},
|
|
socialProviders: Object.fromEntries(
|
|
providerConfigs.builtin.map((c) => [c.providerId, { clientId: c.clientId, clientSecret: c.clientSecret }]),
|
|
),
|
|
hooks: {
|
|
before: createAuthMiddleware(async (ctx) => {
|
|
if (ctx.path === '/delete-user') {
|
|
throw new APIError('FORBIDDEN', { message: 'Self-service account deletion is not available' })
|
|
}
|
|
if (ctx.path === '/api-key/list') {
|
|
const session = await getSessionFromCtx(ctx)
|
|
if (session?.user.id) await normalizeLegacyApiKeysForUser(db, session.user.id)
|
|
return
|
|
}
|
|
if (ctx.path !== '/api-key/create') return
|
|
|
|
const body = ctx.body as Record<string, unknown> | undefined
|
|
if (!body) return
|
|
const configId = body.configId
|
|
if (typeof configId !== 'string' || !API_KEY_TEMPLATES.includes(configId as ApiKeyTemplate)) return
|
|
|
|
const session = await getSessionFromCtx(ctx)
|
|
const userId = session?.user.id ?? (typeof body?.userId === 'string' ? body.userId : null)
|
|
if (!userId) throw new APIError('UNAUTHORIZED', { message: 'Unauthorized' })
|
|
|
|
if (configId === ApiKeyTemplate.WEBDAV) {
|
|
if (body?.organizationId !== undefined) {
|
|
throw new APIError('BAD_REQUEST', { message: 'WebDAV API keys cannot target a workspace' })
|
|
}
|
|
body.metadata = apiKeyMetadata({ mode: 'user-workspaces' })
|
|
return
|
|
}
|
|
|
|
const orgId = body?.organizationId
|
|
if (typeof orgId !== 'string' || !orgId) {
|
|
throw new APIError('BAD_REQUEST', { message: 'Organization ID is required' })
|
|
}
|
|
const orgs = createOrgRepo(db)
|
|
const role = await orgs.getMemberRole(orgId, userId)
|
|
if (role !== 'owner' && role !== 'editor') {
|
|
throw new APIError('FORBIDDEN', { message: 'Editor access to the workspace is required' })
|
|
}
|
|
body.metadata = apiKeyMetadata({ mode: 'workspace', orgId })
|
|
}),
|
|
// Audit admin user disable/enable (served by the admin plugin) the
|
|
// same way the old /api/users usecases did. This after-hook runs for every
|
|
// auth endpoint, so it filters by path; it also runs when the endpoint
|
|
// failed, so it skips anything that returned an APIError.
|
|
after: createAuthMiddleware(async (ctx) => {
|
|
if (ctx.context.returned instanceof APIError) return
|
|
const session = await getSessionFromCtx(ctx)
|
|
const actorId = session?.user?.id
|
|
if (!actorId) return
|
|
await recordUserActivity(db, actorId)
|
|
|
|
const adminAudit = ADMIN_USER_AUDIT[ctx.path]
|
|
if (adminAudit) {
|
|
const targetUserId = (ctx.body as { userId?: string } | undefined)?.userId
|
|
const orgId = (session?.session as { activeOrganizationId?: string } | undefined)?.activeOrganizationId
|
|
if (!targetUserId) return
|
|
if (adminAudit.action === 'user_delete') {
|
|
await db.delete(authSchema.apikey).where(eq(authSchema.apikey.referenceId, targetUserId))
|
|
}
|
|
if (!orgId) return
|
|
await recordAuditEffect(adminAudit.action, () =>
|
|
createAuditRepo(db).record({
|
|
orgId,
|
|
userId: actorId,
|
|
action: adminAudit.action,
|
|
targetType: 'user',
|
|
targetId: targetUserId,
|
|
targetName: targetUserId,
|
|
metadata: adminAudit.status ? { status: adminAudit.status } : undefined,
|
|
}),
|
|
)
|
|
return
|
|
}
|
|
|
|
const action = ORGANIZATION_AUDIT_ACTIONS[ctx.path]
|
|
if (action) await recordOrganizationAuthAudit(db, ctx, actorId, action)
|
|
}),
|
|
},
|
|
plugins: [
|
|
admin(),
|
|
// Self-documents every better-auth endpoint (incl. the device-authorization
|
|
// flow) at GET /api/auth/reference (Scalar UI) and
|
|
// /api/auth/open-api/generate-schema. Replaces the old hand-written device
|
|
// route stubs; our own routes live in the global doc at /api/openapi.json.
|
|
openAPI(),
|
|
organization({
|
|
roles: {
|
|
owner: ownerAc,
|
|
admin: adminAc,
|
|
member: memberAc,
|
|
editor: memberAc,
|
|
viewer: memberAc,
|
|
},
|
|
sendInvitationEmail: async (data) => {
|
|
if (!(await email.isConfigured(authPlatform))) return
|
|
await email.send(authPlatform, {
|
|
to: data.email,
|
|
subject: `You've been invited to join ${data.organization.name} - ZPan`,
|
|
html: buildInvitationEmailHtml(data),
|
|
})
|
|
},
|
|
organizationHooks: {
|
|
beforeCreateOrganization: async ({ user }) => {
|
|
const {
|
|
allowed,
|
|
count: current_count,
|
|
limit,
|
|
} = await checkTeamLimit(
|
|
{ memberCount: createMemberCountRepo(db), licenseBinding: createLicenseBindingRepo(db) },
|
|
user.id,
|
|
)
|
|
if (!allowed) {
|
|
throw new APIError('PAYMENT_REQUIRED', {
|
|
message:
|
|
'Team limit reached. Free includes one personal workspace plus one extra team. Upgrade to Pro for unlimited teams.',
|
|
error: 'feature_not_available',
|
|
feature: 'teams_unlimited',
|
|
currentCount: current_count,
|
|
limit,
|
|
})
|
|
}
|
|
},
|
|
afterCreateOrganization: async ({ organization }) => {
|
|
const isTeam = !isPersonalOrgLike(organization)
|
|
await createOrgQuota(db, organization.id, new Date(), isTeam)
|
|
},
|
|
afterDeleteOrganization: async ({ organization }) => {
|
|
await deleteApiKeysScopedToOrganization(db, organization.id)
|
|
},
|
|
afterAcceptInvitation: async ({ user, organization }) => {
|
|
await createNotificationRepo(db).create({
|
|
userId: user.id,
|
|
type: 'team_join',
|
|
title: `You joined ${organization.name}`,
|
|
body: "You now have access to this team's space.",
|
|
refType: 'team',
|
|
refId: organization.id,
|
|
metadata: JSON.stringify({ teamName: organization.name }),
|
|
})
|
|
},
|
|
},
|
|
}),
|
|
username(),
|
|
lastLoginMethod({
|
|
customResolveMethod: (ctx) => (ctx.path === '/sign-in/username' ? 'username' : null),
|
|
}),
|
|
dynamicCaptcha(db),
|
|
genericOAuth({
|
|
config: providerConfigs.oidc.map((c) => ({
|
|
providerId: c.providerId,
|
|
clientId: c.clientId,
|
|
clientSecret: c.clientSecret,
|
|
discoveryUrl: c.discoveryUrl,
|
|
scopes: c.scopes,
|
|
})),
|
|
}),
|
|
bearer(),
|
|
deviceAuthorization({
|
|
schema: {},
|
|
verificationUri: '/device',
|
|
validateClient: async (clientId) => clientId === 'zpan-cli',
|
|
}),
|
|
apiKey([
|
|
{
|
|
configId: ApiKeyTemplate.IHOST,
|
|
references: 'user',
|
|
enableMetadata: true,
|
|
rateLimit: {
|
|
enabled: true,
|
|
timeWindow: 60_000,
|
|
maxRequests: 60,
|
|
},
|
|
permissions: {
|
|
defaultPermissions: IHOST_API_KEY_PERMISSIONS,
|
|
},
|
|
},
|
|
{
|
|
configId: ApiKeyTemplate.WEBDAV,
|
|
references: 'user',
|
|
enableMetadata: true,
|
|
rateLimit: {
|
|
enabled: !usesNativeWebDavRateLimit,
|
|
// Filesystem clients such as macOS Finder issue bursts of PROPFIND
|
|
// and stat requests while browsing mounted folders.
|
|
timeWindow: WEBDAV_API_KEY_RATE_LIMIT_WINDOW_MS,
|
|
maxRequests: WEBDAV_API_KEY_RATE_LIMIT_MAX_REQUESTS,
|
|
},
|
|
permissions: {
|
|
defaultPermissions: WEBDAV_API_KEY_PERMISSIONS,
|
|
},
|
|
},
|
|
{
|
|
configId: ApiKeyTemplate.REMOTE_DOWNLOAD,
|
|
references: 'user',
|
|
enableMetadata: true,
|
|
rateLimit: {
|
|
enabled: true,
|
|
timeWindow: 60_000,
|
|
maxRequests: 120,
|
|
},
|
|
permissions: {
|
|
defaultPermissions: REMOTE_DOWNLOAD_API_KEY_PERMISSIONS,
|
|
},
|
|
},
|
|
]),
|
|
],
|
|
databaseHooks: {
|
|
user: {
|
|
create: {
|
|
before: async (user, context) => {
|
|
const firstUser = await isFirstUser(db)
|
|
|
|
// Registration gate: skip for the very first user so bootstrap works
|
|
if (!firstUser) {
|
|
const mode = await getEffectiveSignupMode({
|
|
systemOptions: createSystemOptionsRepo(db),
|
|
licenseBinding: createLicenseBindingRepo(db),
|
|
})
|
|
const email = String(user.email ?? '')
|
|
const siteInvitationToken = (context?.body as { siteInvitationToken?: string })?.siteInvitationToken
|
|
if (mode === SignupMode.CLOSED) {
|
|
if (!siteInvitationToken) {
|
|
throw new Error('An invitation is required to register')
|
|
}
|
|
const validation = await createSiteInvitationRepo(db).validateSiteInvitation(siteInvitationToken, email)
|
|
if (!validation.valid) {
|
|
throw new Error(validation.error ?? 'Invalid invitation')
|
|
}
|
|
}
|
|
if (mode === SignupMode.INVITE_ONLY) {
|
|
const inviteCode = (context?.body as { inviteCode?: string })?.inviteCode
|
|
if (!inviteCode) {
|
|
throw new Error('An invite code is required to register')
|
|
}
|
|
const validation = await createInviteRepo(db).validate(inviteCode)
|
|
if (!validation.valid) {
|
|
throw new Error(validation.error ?? 'Invalid invite code')
|
|
}
|
|
}
|
|
}
|
|
|
|
// Promote the very first signup to admin BEFORE the INSERT so the
|
|
// role is baked into the session cookie that the response returns.
|
|
const data: Record<string, unknown> = firstUser ? { ...user, role: 'admin' } : { ...user }
|
|
|
|
// For OAuth sign-ups, generate username before INSERT.
|
|
// Email sign-ups already have username from the registration form.
|
|
if (!data.username) {
|
|
const raw = user as Record<string, unknown>
|
|
data.username = await generateUsername(db, {
|
|
oauthUsername: String(raw.preferred_username ?? raw.login ?? ''),
|
|
email: String(user.email ?? ''),
|
|
})
|
|
data.displayUsername = data.username
|
|
}
|
|
|
|
return { data }
|
|
},
|
|
after: async (user, context) => {
|
|
// Redeem invite code after user is created (user.id is now available)
|
|
const mode = await getEffectiveSignupMode({
|
|
systemOptions: createSystemOptionsRepo(db),
|
|
licenseBinding: createLicenseBindingRepo(db),
|
|
})
|
|
if (mode === SignupMode.INVITE_ONLY) {
|
|
const inviteCode = (context?.body as { inviteCode?: string })?.inviteCode
|
|
if (inviteCode) {
|
|
await createInviteRepo(db).redeem(inviteCode, user.id)
|
|
}
|
|
}
|
|
|
|
const siteInvitationToken = (context?.body as { siteInvitationToken?: string })?.siteInvitationToken
|
|
if (siteInvitationToken) {
|
|
const result = await createSiteInvitationRepo(db).acceptSiteInvitation(
|
|
siteInvitationToken,
|
|
user.email,
|
|
user.id,
|
|
)
|
|
if (result !== 'ok' && result !== 'accepted') {
|
|
throw new Error(`Failed to redeem site invitation: ${result}`)
|
|
}
|
|
}
|
|
|
|
// Create personal org as part of registration.
|
|
// This hook is deferred until after the transaction commits, so
|
|
// when autoSignIn is enabled the org is actually created by
|
|
// session.create.before (which runs earlier, inside the txn).
|
|
// The idempotent check ensures no duplicate is created.
|
|
const existing = await createOrgRepo(db).findPersonalOrg(user.id)
|
|
if (!existing) {
|
|
await createPersonalOrg(db, user)
|
|
}
|
|
},
|
|
},
|
|
delete: {
|
|
before: async (user) => {
|
|
await ensureUserRegistrationAudit(db, user.id)
|
|
},
|
|
},
|
|
},
|
|
account: {
|
|
create: {
|
|
after: async (account) => {
|
|
await recordAuditEffect('user_register', () => ensureUserRegistrationAudit(db, account.userId, account.id))
|
|
},
|
|
},
|
|
},
|
|
session: {
|
|
create: {
|
|
before: async (session) => {
|
|
// Look up existing personal org (returning users)
|
|
let orgId = await createOrgRepo(db).findPersonalOrg(session.userId)
|
|
|
|
// For new sign-ups the org doesn't exist yet — create it now.
|
|
// This runs inside the sign-up transaction, after the user row
|
|
// is inserted but before the session row and cookie cache are
|
|
// written, so activeOrganizationId is correct from the start.
|
|
if (!orgId) {
|
|
const revokedPersonalOrgId = await findPersonalOrgFromExistingSession(db, session.userId)
|
|
// Legacy personal orgs used a deterministic slug. Preserve the
|
|
// old no-duplicate behavior for rows that still exist without
|
|
// membership (e.g. admin revoked access).
|
|
const legacySlug = `personal-${session.userId}`
|
|
const [existing] = await db
|
|
.select({ id: authSchema.organization.id })
|
|
.from(authSchema.organization)
|
|
.where(eq(authSchema.organization.slug, legacySlug))
|
|
.limit(1)
|
|
|
|
if (!revokedPersonalOrgId && !existing) {
|
|
const [user] = await db
|
|
.select({ id: authSchema.user.id, name: authSchema.user.name, username: authSchema.user.username })
|
|
.from(authSchema.user)
|
|
.where(eq(authSchema.user.id, session.userId))
|
|
if (user) {
|
|
orgId = await createPersonalOrg(db, user)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (orgId) {
|
|
return { data: { ...session, activeOrganizationId: orgId } }
|
|
}
|
|
return { data: session }
|
|
},
|
|
after: async (session) => {
|
|
await recordUserActivity(db, session.userId)
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} satisfies BetterAuthOptions
|
|
|
|
const buildAuth = (requireEmailVerification: boolean) =>
|
|
betterAuth({
|
|
...authOptions,
|
|
emailAndPassword: { ...authOptions.emailAndPassword, requireEmailVerification },
|
|
emailVerification: {
|
|
...authOptions.emailVerification,
|
|
sendOnSignUp: requireEmailVerification,
|
|
sendOnSignIn: requireEmailVerification,
|
|
},
|
|
})
|
|
|
|
const createAuthInstance = async (requireEmailVerification: boolean) => {
|
|
const auth = buildAuth(requireEmailVerification)
|
|
|
|
// betterAuth() starts its lazy $context init synchronously, inside whichever
|
|
// request constructs the instance. Resolve it here so a cached instance never
|
|
// carries a pending promise tied to its creating request — on Cloudflare
|
|
// Workers such a promise never settles when awaited from a later request,
|
|
// which would hang every auth call in the isolate.
|
|
await auth.$context
|
|
return auth
|
|
}
|
|
|
|
const defaultAuth = await createAuthInstance(false)
|
|
let verificationAuth: typeof defaultAuth | null = null
|
|
const dynamicHandler = async (request: Request): Promise<Response> => {
|
|
if (!usesEmailVerificationPolicy(request)) return defaultAuth.handler(request)
|
|
|
|
const required = isEmailVerificationRequired(
|
|
await systemOptionsRepo.getValue(EMAIL_VERIFICATION_REQUIRED_OPTION_KEY),
|
|
)
|
|
if (!required) return defaultAuth.handler(request)
|
|
|
|
verificationAuth ??= await createAuthInstance(true)
|
|
return verificationAuth.handler(request)
|
|
}
|
|
|
|
return new Proxy(defaultAuth, {
|
|
get(target, property) {
|
|
if (property === 'handler') return dynamicHandler
|
|
return Reflect.get(target, property, target)
|
|
},
|
|
})
|
|
}
|
|
|
|
export type Auth = Awaited<ReturnType<typeof createAuth>>
|
|
|
|
async function isFirstUser(db: Database): Promise<boolean> {
|
|
const [row] = await db.select({ c: count() }).from(authSchema.user)
|
|
if (!row) throw new Error('count(*) on user table returned no rows')
|
|
return row.c === 0
|
|
}
|
|
|
|
const generateRandomSuffix = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 6)
|
|
|
|
function sanitizeUsername(raw: string): string {
|
|
const cleaned = raw
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]/g, '')
|
|
.slice(0, 30)
|
|
// Must contain at least one alphanumeric character
|
|
return /[a-z0-9]/.test(cleaned) ? cleaned : ''
|
|
}
|
|
|
|
async function tryUsername(db: Database, candidate: string): Promise<boolean> {
|
|
const rows = await db
|
|
.select({ id: authSchema.user.id })
|
|
.from(authSchema.user)
|
|
.where(eq(authSchema.user.username, candidate))
|
|
.limit(1)
|
|
return rows.length === 0
|
|
}
|
|
|
|
async function generateUsername(db: Database, opts: { oauthUsername?: string; email?: string }): Promise<string> {
|
|
// 1. Try OAuth username (OIDC: preferred_username; GitHub/Gitea: login)
|
|
const oauthName = sanitizeUsername(opts.oauthUsername ?? '')
|
|
if (oauthName.length >= 3 && (await tryUsername(db, oauthName))) return oauthName
|
|
|
|
// 2. Try email prefix
|
|
const emailPrefix = sanitizeUsername((opts.email ?? '').split('@')[0])
|
|
if (emailPrefix.length >= 3 && (await tryUsername(db, emailPrefix))) return emailPrefix
|
|
|
|
// 3. Fallback: best available prefix + random suffix
|
|
const base = oauthName || emailPrefix || 'user'
|
|
return `${base}-${generateRandomSuffix()}`
|
|
}
|
|
|
|
async function createPersonalOrg(
|
|
db: Database,
|
|
user: { id: string; name: string; username?: string | null },
|
|
): Promise<string> {
|
|
const orgId = nanoid()
|
|
const now = new Date()
|
|
const displayName = user.name || user.username
|
|
const orgName = displayName ? `${displayName}'s Space` : 'Personal Space'
|
|
const orgSlug = await generateUniqueOrgSlug(db, generateUserOrgSlug)
|
|
const quotaValues = await createOrgQuotaValues(db, orgId, now)
|
|
const entitlementValues = await createFreePlanEntitlementValues(db, orgId, now, false)
|
|
await executeWriteTransaction(db, [
|
|
db.insert(authSchema.organization).values({
|
|
id: orgId,
|
|
name: orgName,
|
|
slug: orgSlug,
|
|
metadata: JSON.stringify({ type: 'personal' }),
|
|
createdAt: now,
|
|
}),
|
|
db.insert(authSchema.member).values({
|
|
id: nanoid(),
|
|
organizationId: orgId,
|
|
userId: user.id,
|
|
role: 'owner',
|
|
createdAt: now,
|
|
}),
|
|
db.insert(orgQuotas).values(quotaValues),
|
|
...initialStorageUsageProjectionQueries(db, orgId, now),
|
|
...entitlementValues.map((value) => db.insert(orgQuotaEntitlements).values(value)),
|
|
])
|
|
|
|
return orgId
|
|
}
|
|
|
|
async function findPersonalOrgFromExistingSession(db: Database, userId: string): Promise<string | null> {
|
|
const rows = await db
|
|
.select({
|
|
orgId: authSchema.organization.id,
|
|
slug: authSchema.organization.slug,
|
|
metadata: authSchema.organization.metadata,
|
|
})
|
|
.from(authSchema.session)
|
|
.innerJoin(authSchema.organization, eq(authSchema.organization.id, authSchema.session.activeOrganizationId))
|
|
.where(eq(authSchema.session.userId, userId))
|
|
|
|
return rows.find(isPersonalOrgLike)?.orgId ?? null
|
|
}
|
|
|
|
async function createOrgQuotaValues(_db: Database, orgId: string, now: Date): Promise<typeof orgQuotas.$inferInsert> {
|
|
return {
|
|
id: nanoid(),
|
|
orgId,
|
|
quota: 0,
|
|
used: 0,
|
|
trafficQuota: 0,
|
|
trafficUsed: 0,
|
|
trafficPeriod: currentTrafficPeriod(now),
|
|
}
|
|
}
|
|
|
|
async function createOrgQuota(db: Database, orgId: string, now: Date, isTeam = false): Promise<void> {
|
|
await executeWriteTransaction(db, [
|
|
db.insert(orgQuotas).values(await createOrgQuotaValues(db, orgId, now)),
|
|
...initialStorageUsageProjectionQueries(db, orgId, now),
|
|
...(await createFreePlanEntitlementValues(db, orgId, now, isTeam)).map((value) =>
|
|
db.insert(orgQuotaEntitlements).values(value),
|
|
),
|
|
])
|
|
}
|
|
|
|
async function createFreePlanEntitlementValues(
|
|
db: Database,
|
|
orgId: string,
|
|
now: Date,
|
|
isTeam: boolean,
|
|
): Promise<(typeof orgQuotaEntitlements.$inferInsert)[]> {
|
|
const storageDefault = isTeam ? await getDefaultTeamQuota(db) : null
|
|
const defaultQuota = storageDefault?.bytes ?? (await getDefaultOrgQuota(db))
|
|
const storageSettingKey = storageDefault?.settingKey ?? 'default_org_quota'
|
|
const defaultTrafficQuota = await getDefaultOrgTrafficQuota(db)
|
|
|
|
return [
|
|
freePlanEntitlementValue(orgId, 'storage', defaultQuota, now, storageSettingKey),
|
|
freePlanEntitlementValue(orgId, 'traffic', defaultTrafficQuota, now, 'default_org_monthly_traffic_quota'),
|
|
]
|
|
}
|
|
|
|
async function generateUniqueOrgSlug(db: Database, generate: () => string): Promise<string> {
|
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
const slug = generate()
|
|
const rows = await db
|
|
.select({ id: authSchema.organization.id })
|
|
.from(authSchema.organization)
|
|
.where(eq(authSchema.organization.slug, slug))
|
|
.limit(1)
|
|
if (!rows[0]) return slug
|
|
}
|
|
throw new Error('Failed to generate a unique organization slug')
|
|
}
|
|
|
|
function freePlanEntitlementValue(
|
|
orgId: string,
|
|
resourceType: 'storage' | 'traffic',
|
|
bytes: number,
|
|
now: Date,
|
|
settingKey: string,
|
|
): typeof orgQuotaEntitlements.$inferInsert {
|
|
return {
|
|
id: nanoid(),
|
|
orgId,
|
|
resourceType,
|
|
entitlementType: 'plan',
|
|
source: 'free_plan',
|
|
sourceId: `free_plan:${orgId}`,
|
|
bytes,
|
|
startsAt: now,
|
|
expiresAt: null,
|
|
status: 'active',
|
|
metadata: JSON.stringify({
|
|
packageName: 'Free',
|
|
packageId: null,
|
|
source: 'free_plan',
|
|
settingKey,
|
|
}),
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
}
|
|
}
|
|
|
|
// Default storage quota for newly created team orgs. Returns null when the
|
|
// option is unset so callers fall back to the personal-org default.
|
|
async function getDefaultTeamQuota(db: Database): Promise<{ bytes: number; settingKey: string } | null> {
|
|
const rows = await db
|
|
.select({ value: systemOptions.value })
|
|
.from(systemOptions)
|
|
.where(eq(systemOptions.key, 'default_team_quota'))
|
|
const raw = rows[0]?.value
|
|
if (raw == null) return null
|
|
const n = Number(raw)
|
|
if (!Number.isFinite(n) || n <= 0) return null
|
|
return { bytes: n, settingKey: 'default_team_quota' }
|
|
}
|
|
|
|
async function getDefaultOrgQuota(db: Database): Promise<number> {
|
|
const rows = await db
|
|
.select({ value: systemOptions.value })
|
|
.from(systemOptions)
|
|
.where(eq(systemOptions.key, 'default_org_quota'))
|
|
const raw = rows[0]?.value
|
|
if (raw == null) return DEFAULT_ORG_QUOTA
|
|
const n = Number(raw)
|
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_ORG_QUOTA
|
|
}
|
|
|
|
async function getDefaultOrgTrafficQuota(db: Database): Promise<number> {
|
|
const rows = await db
|
|
.select({ value: systemOptions.value })
|
|
.from(systemOptions)
|
|
.where(eq(systemOptions.key, 'default_org_monthly_traffic_quota'))
|
|
const raw = rows[0]?.value
|
|
if (raw == null) return DEFAULT_ORG_TRAFFIC_QUOTA
|
|
const value = raw.trim()
|
|
const n = Number(value)
|
|
if (value === '' || !Number.isInteger(n) || n < 0)
|
|
throw new Error('default_org_monthly_traffic_quota must be a non-negative integer')
|
|
return n
|
|
}
|