Files
moeka-project/apps/server/src/routes/auth/index.ts
T
RainbowBird c627bce9c9 refactor(server): split services into domain/adapter layers, drop dead code
Why
- src/services/ was an unordered mix of single-file services and module
  directories with no shared classification axis, plus several long-dead
  admin batch helpers that survived the move to the simpler synchronous
  admin-flux-grants flow.

What
- services/ now has two top-level layers:
    domain/   — DB state + business rules (billing, characters, chats,
                flux, flux-transaction, llm-router, providers, request-log,
                stripe, user-deletion, admin/{flux-grants,router-config})
    adapters/ — thin wrappers over external SDKs / infra (config-kv, email,
                posthog, tts/)
- admin/* moved under domain/admin/ with consistent plural names
  (flux-grants, router-config).
- tts-adapters/ collapsed to adapters/tts/ (no redundant -adapters suffix
  once nested under adapters/).
- 63 src files + scripts/e2e-llm-router.ts + tests/verifications/_harness.ts
  had relative imports rewritten; git mv preserves blame.
- apps/server/CLAUDE.md and docs/ai-context/*.md updated to match new paths.

Dead code removed
- services/admin-flux-grant-batches/ (service + worker + tests, 1090 LOC) —
  superseded by admin-flux-grants and never wired into app.ts.
- routes/admin/flux-grant-batches/ — same.
- utils/redis-compressed.ts + test — zero production call sites.
- llm-router/index.ts re-exports trimmed from 26 to 6; only symbols with
  external consumers are kept.

Intentionally kept
- schemas/flux-grant-batch.ts and its schemas/index.ts export remain so the
  drizzle-kit generate diff stays empty. Removing them is a separate PR
  that owns the drop-table migration for flux_grant_batch /
  flux_grant_batch_recipient.

Verification
- pnpm -F @proj-airi/server typecheck: passes.
- pnpm exec eslint apps/server: 49 errors, identical to main baseline
  (all are pre-existing node/prefer-global/buffer in envelope-crypto and
  scripts/e2e-llm-router; untouched by this change).
- Vitest passes per-file; the 6 mockDB hook timeouts under full-parallel
  run are the known pushSchema-per-worker infra cost, not a regression.
2026-05-18 23:36:45 +08:00

213 lines
8.8 KiB
TypeScript

import type { AuthInstance } from '../../libs/auth'
import type { Database } from '../../libs/db'
import type { Env } from '../../libs/env'
import type { RateLimitMetrics } from '../../otel'
import type { ConfigKVService } from '../../services/adapters/config-kv'
import type { HonoEnv } from '../../types/hono'
import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from '@better-auth/oauth-provider'
import { serveStatic } from '@hono/node-server/serve-static'
import { and, eq } from 'drizzle-orm'
import { Hono } from 'hono'
import { ensureDynamicFirstPartyRedirectUri } from '../../libs/auth'
import { rateLimiter } from '../../middlewares/rate-limit'
import { account, user } from '../../schemas/accounts'
import { createBadRequestError } from '../../utils/error'
import { getServerAuthUiDistDir, renderServerAuthUiHtml, SERVER_AUTH_UI_BASE_PATH } from '../../utils/server-auth-ui'
import { createElectronCallbackRelay } from '../oidc/electron-callback'
import { createOIDCTokenAuthRoute } from '../oidc/token-auth'
// NOTICE:
// Loose RFC-5322-ish regex used to fail fast on obviously malformed input.
// Authoritative validation happens in better-auth on sign-in/sign-up;
// this is just a pre-flight gate for the email-first identifier step so we
// avoid hitting the DB with garbage.
const EMAIL_SHAPE_RE = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/
const RE_SERVER_AUTH_UI_BASE_PATH = /^\/auth/
export interface AuthRoutesDeps {
auth: AuthInstance
db: Database
env: Env
configKV: ConfigKVService
rateLimitMetrics?: RateLimitMetrics | null
}
/**
* All auth-related routes: sign-in page, rate-limited better-auth
* helper routes, electron callback relay, catch-all, and
* well-known metadata endpoints.
*
* Mounted at the root level because routes span multiple prefixes
* (`/auth/*`, `/api/auth/*`, `/.well-known/*`).
*/
export async function createAuthRoutes(deps: AuthRoutesDeps) {
async function handleAuthRequest(request: Request): Promise<Response> {
const response = await deps.auth.handler(request)
if (!(response instanceof Response))
throw new TypeError('Expected auth handler to return a Response')
return response
}
return new Hono<HonoEnv>()
.use(`${SERVER_AUTH_UI_BASE_PATH}/*`, serveStatic({
root: getServerAuthUiDistDir(),
rewriteRequestPath: (path: string) => path.replace(RE_SERVER_AUTH_UI_BASE_PATH, ''),
}))
/**
* Login page for the OIDC Provider flow, served under the ui-server-auth
* vue-router base (`/auth/sign-in`). When an unauthenticated
* user hits `/api/auth/oauth2/authorize`, better-auth redirects here
* because of `oauthProvider({ loginPage })`. After the user signs in via
* a social provider, the social callback redirects to `callbackURL`,
* which points back to the OIDC authorize endpoint.
*
* If a `provider` query parameter is present (e.g. `?provider=github`),
* skip the picker page and redirect directly to the social provider.
*
* Registered BEFORE the SPA `/auth/*` wildcard fallback so
* the provider shortcut gets a chance to short-circuit. Hono matches
* routes in registration order — specific path before wildcard wins.
*/
.on('GET', `${SERVER_AUTH_UI_BASE_PATH}/sign-in`, (c) => {
const provider = c.req.query('provider')
// Reconstruct the OIDC authorize URL from query params so the flow
// resumes after social login. The oauthProvider plugin appends all
// authorization request params when redirecting to loginPage.
const url = new URL(c.req.url)
const oidcParams = new URLSearchParams(url.searchParams)
oidcParams.delete('provider')
// Strip prompt so the post-sign-in redirect to authorize doesn't force
// another sign-in — prompt=login should only apply on the first pass.
oidcParams.delete('prompt')
const callbackURL = oidcParams.toString()
? `${deps.env.API_SERVER_URL}/api/auth/oauth2/authorize?${oidcParams.toString()}`
: '/'
if (!!provider && ['google', 'github'].includes(provider)) {
const socialUrl = `${deps.env.API_SERVER_URL}/api/auth/sign-in/social?provider=${provider}&callbackURL=${encodeURIComponent(callbackURL)}`
return c.redirect(socialUrl)
}
return c.html(renderServerAuthUiHtml({
apiServerUrl: deps.env.API_SERVER_URL,
currentUrl: c.req.url,
}))
})
/**
* SPA fallback for the ui-server-auth bundle.
*
* vue-router runs with `createWebHistory('/auth/')`, so any
* client-side route — `/auth/verify-email`,
* `/auth/forgot-password`, `/auth/reset-password`,
* etc. — appears in the URL bar but has no matching file in the dist.
* Without this handler, deep-link hits (verification email links, page
* refresh on a SPA route, copy-pasted URLs) fall through `serveStatic`
* to the global 404 JSON.
*
* Mounted AFTER the static middleware so real assets under
* `/auth/assets/...` still resolve to the file on disk;
* `serveStatic` short-circuits on hits and only calls through on misses.
*/
.on('GET', `${SERVER_AUTH_UI_BASE_PATH}/*`, (c) => {
return c.html(renderServerAuthUiHtml({
apiServerUrl: deps.env.API_SERVER_URL,
currentUrl: c.req.url,
}))
})
/**
* Auth routes are handled by the auth instance directly,
* Powered by better-auth.
* Rate limited by IP: 20 requests per minute.
*/
.use('/api/auth/*', rateLimiter({
max: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_MAX'),
windowSec: await deps.configKV.getOrThrow('AUTH_RATE_LIMIT_WINDOW_SEC'),
keyGenerator: c => c.req.header('x-forwarded-for') ?? c.req.header('x-real-ip') ?? 'unknown',
metrics: deps.rateLimitMetrics,
routeLabel: 'auth.api',
}))
.use('/api/auth/oauth2/authorize', async (c, next) => {
await ensureDynamicFirstPartyRedirectUri(deps.db, c.req.raw, deps.env.ADDITIONAL_TRUSTED_ORIGINS)
await next()
})
.route('/api/auth', createOIDCTokenAuthRoute(deps))
/**
* Electron OIDC callback relay: serves an HTML page that forwards the
* authorization code to the Electron loopback server via JS fetch().
* This avoids navigating the browser to http://127.0.0.1:{port}.
*/
.route('/api/auth/oidc/electron-callback', createElectronCallbackRelay())
/**
* OAuth 2.1 Authorization Server metadata must live at the root-level
* well-known path with the issuer path inserted for non-root issuers.
*/
.on('GET', '/.well-known/oauth-authorization-server/api/auth', async (c) => {
return oauthProviderAuthServerMetadata(deps.auth)(c.req.raw)
})
/**
* OpenID Connect discovery metadata uses path appending for issuers with
* paths, so `/api/auth` serves its own `/.well-known/openid-configuration`.
*/
.on('GET', '/api/auth/.well-known/openid-configuration', async (c) => {
return oauthProviderOpenIdConfigMetadata(deps.auth)(c.req.raw)
})
/**
* Email-first identifier check.
*
* Powers the unified sign-in/up UI: the user types an email, the UI calls
* this to decide whether to render a password input (existing user with
* a credential account) or the new-account form (or steer them to a
* social provider when only social accounts exist).
*
* Returns:
* - `exists`: a `user` row matches the email (case-insensitive).
* - `hasPassword`: that user has an account row with `providerId='credential'`,
* i.e. can sign in via email + password (vs. social-only).
*
* Account-enumeration tradeoff: this confirms whether an email is
* registered, mirroring the standard set by Google/Linear/Notion. We
* accept the disclosure since the existing rate limiter applied to
* `/api/auth/*` (`AUTH_RATE_LIMIT_MAX` per IP per window) already throttles
* enumeration attempts.
*/
.on('POST', '/api/auth/check-email', async (c) => {
const body = await c.req.json().catch(() => null) as { email?: unknown } | null
const raw = typeof body?.email === 'string' ? body.email.trim() : ''
const email = raw.toLowerCase()
if (!email || !EMAIL_SHAPE_RE.test(email))
throw createBadRequestError('Invalid email', 'INVALID_EMAIL')
const [matched] = await deps.db
.select({ id: user.id })
.from(user)
.where(eq(user.email, email))
.limit(1)
if (!matched)
return c.json({ exists: false, hasPassword: false })
const [credential] = await deps.db
.select({ id: account.id })
.from(account)
.where(and(
eq(account.userId, matched.id),
eq(account.providerId, 'credential'),
))
.limit(1)
return c.json({ exists: true, hasPassword: !!credential })
})
.on(['POST', 'GET'], '/api/auth/*', async (c) => {
return handleAuthRequest(c.req.raw)
})
}