llmapi-v2 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/.env.example +40 -0
  2. package/Dockerfile +17 -0
  3. package/dist/config.d.ts +48 -0
  4. package/dist/config.js +98 -0
  5. package/dist/config.js.map +1 -0
  6. package/dist/converter/request.d.ts +6 -0
  7. package/dist/converter/request.js +184 -0
  8. package/dist/converter/request.js.map +1 -0
  9. package/dist/converter/response.d.ts +6 -0
  10. package/dist/converter/response.js +76 -0
  11. package/dist/converter/response.js.map +1 -0
  12. package/dist/converter/stream.d.ts +54 -0
  13. package/dist/converter/stream.js +318 -0
  14. package/dist/converter/stream.js.map +1 -0
  15. package/dist/converter/types.d.ts +239 -0
  16. package/dist/converter/types.js +6 -0
  17. package/dist/converter/types.js.map +1 -0
  18. package/dist/data/posts.d.ts +19 -0
  19. package/dist/data/posts.js +462 -0
  20. package/dist/data/posts.js.map +1 -0
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +233 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/middleware/api-key-auth.d.ts +6 -0
  25. package/dist/middleware/api-key-auth.js +76 -0
  26. package/dist/middleware/api-key-auth.js.map +1 -0
  27. package/dist/middleware/quota-guard.d.ts +10 -0
  28. package/dist/middleware/quota-guard.js +27 -0
  29. package/dist/middleware/quota-guard.js.map +1 -0
  30. package/dist/middleware/rate-limiter.d.ts +5 -0
  31. package/dist/middleware/rate-limiter.js +50 -0
  32. package/dist/middleware/rate-limiter.js.map +1 -0
  33. package/dist/middleware/request-logger.d.ts +6 -0
  34. package/dist/middleware/request-logger.js +37 -0
  35. package/dist/middleware/request-logger.js.map +1 -0
  36. package/dist/middleware/session-auth.d.ts +19 -0
  37. package/dist/middleware/session-auth.js +99 -0
  38. package/dist/middleware/session-auth.js.map +1 -0
  39. package/dist/providers/aliyun.d.ts +13 -0
  40. package/dist/providers/aliyun.js +20 -0
  41. package/dist/providers/aliyun.js.map +1 -0
  42. package/dist/providers/base-provider.d.ts +36 -0
  43. package/dist/providers/base-provider.js +133 -0
  44. package/dist/providers/base-provider.js.map +1 -0
  45. package/dist/providers/deepseek.d.ts +11 -0
  46. package/dist/providers/deepseek.js +18 -0
  47. package/dist/providers/deepseek.js.map +1 -0
  48. package/dist/providers/registry.d.ts +18 -0
  49. package/dist/providers/registry.js +98 -0
  50. package/dist/providers/registry.js.map +1 -0
  51. package/dist/providers/types.d.ts +17 -0
  52. package/dist/providers/types.js +3 -0
  53. package/dist/providers/types.js.map +1 -0
  54. package/dist/routes/admin.d.ts +1 -0
  55. package/dist/routes/admin.js +153 -0
  56. package/dist/routes/admin.js.map +1 -0
  57. package/dist/routes/auth.d.ts +2 -0
  58. package/dist/routes/auth.js +318 -0
  59. package/dist/routes/auth.js.map +1 -0
  60. package/dist/routes/blog.d.ts +1 -0
  61. package/dist/routes/blog.js +29 -0
  62. package/dist/routes/blog.js.map +1 -0
  63. package/dist/routes/dashboard.d.ts +1 -0
  64. package/dist/routes/dashboard.js +184 -0
  65. package/dist/routes/dashboard.js.map +1 -0
  66. package/dist/routes/messages.d.ts +1 -0
  67. package/dist/routes/messages.js +309 -0
  68. package/dist/routes/messages.js.map +1 -0
  69. package/dist/routes/models.d.ts +1 -0
  70. package/dist/routes/models.js +39 -0
  71. package/dist/routes/models.js.map +1 -0
  72. package/dist/routes/payment.d.ts +1 -0
  73. package/dist/routes/payment.js +150 -0
  74. package/dist/routes/payment.js.map +1 -0
  75. package/dist/routes/sitemap.d.ts +1 -0
  76. package/dist/routes/sitemap.js +38 -0
  77. package/dist/routes/sitemap.js.map +1 -0
  78. package/dist/services/alipay.d.ts +27 -0
  79. package/dist/services/alipay.js +106 -0
  80. package/dist/services/alipay.js.map +1 -0
  81. package/dist/services/database.d.ts +4 -0
  82. package/dist/services/database.js +170 -0
  83. package/dist/services/database.js.map +1 -0
  84. package/dist/services/health-checker.d.ts +13 -0
  85. package/dist/services/health-checker.js +95 -0
  86. package/dist/services/health-checker.js.map +1 -0
  87. package/dist/services/mailer.d.ts +3 -0
  88. package/dist/services/mailer.js +91 -0
  89. package/dist/services/mailer.js.map +1 -0
  90. package/dist/services/metrics.d.ts +56 -0
  91. package/dist/services/metrics.js +94 -0
  92. package/dist/services/metrics.js.map +1 -0
  93. package/dist/services/remote-control.d.ts +20 -0
  94. package/dist/services/remote-control.js +209 -0
  95. package/dist/services/remote-control.js.map +1 -0
  96. package/dist/services/remote-ws.d.ts +5 -0
  97. package/dist/services/remote-ws.js +143 -0
  98. package/dist/services/remote-ws.js.map +1 -0
  99. package/dist/services/usage.d.ts +13 -0
  100. package/dist/services/usage.js +39 -0
  101. package/dist/services/usage.js.map +1 -0
  102. package/dist/utils/errors.d.ts +27 -0
  103. package/dist/utils/errors.js +48 -0
  104. package/dist/utils/errors.js.map +1 -0
  105. package/dist/utils/logger.d.ts +2 -0
  106. package/dist/utils/logger.js +14 -0
  107. package/dist/utils/logger.js.map +1 -0
  108. package/docker-compose.yml +19 -0
  109. package/package.json +39 -0
  110. package/public/robots.txt +8 -0
  111. package/src/config.ts +140 -0
  112. package/src/converter/request.ts +207 -0
  113. package/src/converter/response.ts +85 -0
  114. package/src/converter/stream.ts +373 -0
  115. package/src/converter/types.ts +257 -0
  116. package/src/data/posts.ts +474 -0
  117. package/src/index.ts +219 -0
  118. package/src/middleware/api-key-auth.ts +82 -0
  119. package/src/middleware/quota-guard.ts +28 -0
  120. package/src/middleware/rate-limiter.ts +61 -0
  121. package/src/middleware/request-logger.ts +36 -0
  122. package/src/middleware/session-auth.ts +91 -0
  123. package/src/providers/aliyun.ts +16 -0
  124. package/src/providers/base-provider.ts +148 -0
  125. package/src/providers/deepseek.ts +14 -0
  126. package/src/providers/registry.ts +111 -0
  127. package/src/providers/types.ts +26 -0
  128. package/src/routes/admin.ts +169 -0
  129. package/src/routes/auth.ts +369 -0
  130. package/src/routes/blog.ts +28 -0
  131. package/src/routes/dashboard.ts +208 -0
  132. package/src/routes/messages.ts +346 -0
  133. package/src/routes/models.ts +37 -0
  134. package/src/routes/payment.ts +189 -0
  135. package/src/routes/sitemap.ts +40 -0
  136. package/src/services/alipay.ts +116 -0
  137. package/src/services/database.ts +187 -0
  138. package/src/services/health-checker.ts +115 -0
  139. package/src/services/mailer.ts +90 -0
  140. package/src/services/metrics.ts +104 -0
  141. package/src/services/remote-control.ts +226 -0
  142. package/src/services/remote-ws.ts +145 -0
  143. package/src/services/usage.ts +57 -0
  144. package/src/types/express.d.ts +46 -0
  145. package/src/utils/errors.ts +44 -0
  146. package/src/utils/logger.ts +8 -0
  147. package/tsconfig.json +17 -0
  148. package/views/pages/404.ejs +14 -0
  149. package/views/pages/admin.ejs +307 -0
  150. package/views/pages/blog-post.ejs +378 -0
  151. package/views/pages/blog.ejs +148 -0
  152. package/views/pages/dashboard.ejs +441 -0
  153. package/views/pages/docs.ejs +807 -0
  154. package/views/pages/index.ejs +416 -0
  155. package/views/pages/login.ejs +170 -0
  156. package/views/pages/orders.ejs +111 -0
  157. package/views/pages/pricing.ejs +379 -0
  158. package/views/pages/register.ejs +397 -0
  159. package/views/pages/remote.ejs +334 -0
  160. package/views/pages/settings.ejs +373 -0
  161. package/views/partials/header.ejs +70 -0
  162. package/views/partials/nav.ejs +140 -0
@@ -0,0 +1,14 @@
1
+ import { BaseProvider } from './base-provider';
2
+
3
+ /**
4
+ * DeepSeek provider.
5
+ * Native Anthropic endpoint: https://api.deepseek.com/anthropic
6
+ *
7
+ * Best for: simple conversations, light tasks, cost-sensitive routing.
8
+ * Note: tool calling accuracy (~81.5%) is lower than Qwen.
9
+ */
10
+ export class DeepSeekProvider extends BaseProvider {
11
+ constructor(anthropicBaseUrl: string, apiKey: string, timeout: number, openaiBaseUrl?: string) {
12
+ super('deepseek', anthropicBaseUrl, apiKey, timeout, openaiBaseUrl);
13
+ }
14
+ }
@@ -0,0 +1,111 @@
1
+ import type { AppConfig, ProviderConfig, SmartRoutingConfig } from '../config';
2
+ import type { LLMProvider, ResolvedRoute } from './types';
3
+ import { AliyunProvider } from './aliyun';
4
+ import { DeepSeekProvider } from './deepseek';
5
+ import { logger } from '../utils/logger';
6
+
7
+ const providers = new Map<string, LLMProvider>();
8
+ let modelRouting: Record<string, { provider: string; model: string; priority: number }[]> = {};
9
+ let smartRouting: SmartRoutingConfig;
10
+
11
+ export function initRegistry(config: AppConfig): void {
12
+ providers.clear();
13
+
14
+ for (const pc of config.providers) {
15
+ const provider = createProvider(pc);
16
+ if (provider) {
17
+ providers.set(pc.name, provider);
18
+ logger.info({ provider: pc.name, endpoint: pc.anthropicBaseUrl }, 'Provider registered (native Anthropic endpoint)');
19
+ }
20
+ }
21
+
22
+ modelRouting = config.modelRouting;
23
+ smartRouting = config.smartRouting;
24
+
25
+ if (providers.size === 0) {
26
+ logger.warn('No providers configured! API requests will fail.');
27
+ }
28
+ }
29
+
30
+ function createProvider(pc: ProviderConfig): LLMProvider | null {
31
+ if (!pc.enabled || !pc.apiKey) return null;
32
+
33
+ switch (pc.name) {
34
+ case 'aliyun':
35
+ return new AliyunProvider(pc.anthropicBaseUrl, pc.apiKey, pc.timeout, pc.openaiBaseUrl);
36
+ case 'deepseek':
37
+ return new DeepSeekProvider(pc.anthropicBaseUrl, pc.apiKey, pc.timeout, pc.openaiBaseUrl);
38
+ default:
39
+ logger.warn({ provider: pc.name }, 'Unknown provider type');
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Smart routing: decide which provider/model based on request characteristics.
46
+ *
47
+ * Rules:
48
+ * - If request has tools -> coding task -> use primary coding model (Qwen)
49
+ * - If request is simple (no tools, few messages) -> use light model (DeepSeek)
50
+ * - Model name mapping: claude-opus -> best available, claude-haiku -> cheapest
51
+ * - Fallback: try providers in priority order
52
+ */
53
+ export function smartResolve(
54
+ claudeModel: string,
55
+ hasTools: boolean,
56
+ messageCount: number,
57
+ ): ResolvedRoute | null {
58
+ // Claude-haiku always goes to the cheap model
59
+ if (claudeModel.includes('haiku')) {
60
+ const p = providers.get(smartRouting.lightModel.provider);
61
+ if (p?.isHealthy()) {
62
+ return { provider: p, backendModel: smartRouting.lightModel.model };
63
+ }
64
+ }
65
+
66
+ // No tools + short conversation = light task
67
+ if (!hasTools && messageCount <= smartRouting.lightTaskMaxMessages) {
68
+ const p = providers.get(smartRouting.lightModel.provider);
69
+ if (p?.isHealthy()) {
70
+ return { provider: p, backendModel: smartRouting.lightModel.model };
71
+ }
72
+ }
73
+
74
+ // Tools present or complex conversation = coding task
75
+ if (hasTools) {
76
+ const p = providers.get(smartRouting.codingModel.provider);
77
+ if (p?.isHealthy()) {
78
+ return { provider: p, backendModel: smartRouting.codingModel.model };
79
+ }
80
+ }
81
+
82
+ // Fallback to model-based routing
83
+ return null;
84
+ }
85
+
86
+ /**
87
+ * Resolve with failover: yield providers to try in priority order.
88
+ */
89
+ export async function* resolveWithFailover(
90
+ claudeModel: string,
91
+ ): AsyncGenerator<ResolvedRoute> {
92
+ const routes = modelRouting[claudeModel];
93
+ if (!routes || routes.length === 0) {
94
+ logger.error({ model: claudeModel }, 'No routes configured for model');
95
+ return;
96
+ }
97
+
98
+ for (const route of routes) {
99
+ const provider = providers.get(route.provider);
100
+ if (!provider) continue;
101
+ if (!provider.isHealthy()) {
102
+ logger.debug({ provider: route.provider }, 'Skipping unhealthy provider');
103
+ continue;
104
+ }
105
+ yield { provider, backendModel: route.model };
106
+ }
107
+ }
108
+
109
+ export function getProviders(): Map<string, LLMProvider> {
110
+ return providers;
111
+ }
@@ -0,0 +1,26 @@
1
+ import type { IncomingMessage } from 'http';
2
+
3
+ export interface LLMProvider {
4
+ readonly name: string;
5
+ readonly anthropicBaseUrl: string;
6
+
7
+ isHealthy(): boolean;
8
+ markUnhealthy(reason: string): void;
9
+ markHealthy(): void;
10
+
11
+ /**
12
+ * Transparent proxy: forward raw Anthropic request to provider's native endpoint.
13
+ * Returns the raw HTTP response for direct piping back to the client.
14
+ */
15
+ proxy(
16
+ path: string,
17
+ body: Buffer | string,
18
+ headers: Record<string, string>,
19
+ isStream: boolean,
20
+ ): Promise<IncomingMessage>;
21
+ }
22
+
23
+ export interface ResolvedRoute {
24
+ provider: LLMProvider;
25
+ backendModel: string;
26
+ }
@@ -0,0 +1,169 @@
1
+ import { Router } from 'express';
2
+ import type { Request, Response } from 'express';
3
+ import { getPool } from '../services/database';
4
+ import { adminAuth } from '../middleware/session-auth';
5
+ import { getHealthStatus } from '../services/health-checker';
6
+ import { logger } from '../utils/logger';
7
+
8
+ export const adminRouter = Router();
9
+
10
+ // All routes require admin auth
11
+ adminRouter.use(adminAuth);
12
+
13
+ /**
14
+ * GET /api/admin/stats
15
+ * Platform-wide statistics.
16
+ */
17
+ adminRouter.get('/stats', async (_req: Request, res: Response) => {
18
+ try {
19
+ const pool = getPool();
20
+
21
+ const [users] = await pool.execute(`
22
+ SELECT
23
+ COUNT(*) as total,
24
+ SUM(CASE WHEN DATE(created_at) = CURRENT_DATE THEN 1 ELSE 0 END) as today_new
25
+ FROM users
26
+ `);
27
+
28
+ const [activeUsers] = await pool.execute(`
29
+ SELECT COUNT(DISTINCT user_id) as cnt FROM usage_logs
30
+ WHERE created_at >= NOW() - INTERVAL '7 days'
31
+ `);
32
+
33
+ const [requests] = await pool.execute(`
34
+ SELECT
35
+ COUNT(*) as total,
36
+ SUM(CASE WHEN DATE(created_at) = CURRENT_DATE THEN 1 ELSE 0 END) as today
37
+ FROM usage_logs
38
+ `);
39
+
40
+ const [tokens] = await pool.execute(`
41
+ SELECT
42
+ COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
43
+ COALESCE(SUM(provider_cost), 0) as total_cost
44
+ FROM usage_logs
45
+ `);
46
+
47
+ const [monthCost] = await pool.execute(`
48
+ SELECT COALESCE(SUM(provider_cost), 0) as cost
49
+ FROM usage_logs WHERE created_at >= date_trunc('month', NOW())
50
+ `);
51
+
52
+ const [revenue] = await pool.execute(`
53
+ SELECT COALESCE(SUM(amount), 0) as total FROM orders WHERE status = 'paid'
54
+ `);
55
+
56
+ const [monthRevenue] = await pool.execute(`
57
+ SELECT COALESCE(SUM(amount), 0) as total FROM orders
58
+ WHERE status = 'paid' AND paid_at >= date_trunc('month', NOW())
59
+ `);
60
+
61
+ // Provider health
62
+ const providerHealth = getHealthStatus();
63
+
64
+ res.json({
65
+ success: true,
66
+ stats: {
67
+ users: (users as any[])[0],
68
+ activeUsers7d: (activeUsers as any[])[0].cnt,
69
+ requests: (requests as any[])[0],
70
+ tokens: (tokens as any[])[0],
71
+ monthCost: parseFloat((monthCost as any[])[0].cost),
72
+ revenue: parseFloat((revenue as any[])[0].total),
73
+ monthRevenue: parseFloat((monthRevenue as any[])[0].total),
74
+ providerHealth,
75
+ },
76
+ });
77
+ } catch (err) {
78
+ logger.error({ err }, 'admin stats error');
79
+ res.status(500).json({ success: false, error: 'Internal error' });
80
+ }
81
+ });
82
+
83
+ /**
84
+ * GET /api/admin/users
85
+ * Full user list with plan and usage info.
86
+ */
87
+ adminRouter.get('/users', async (_req: Request, res: Response) => {
88
+ try {
89
+ const pool = getPool();
90
+ const [rows] = await pool.execute(`
91
+ SELECT
92
+ u.id, u.email, u.name, u.role, u.status, u.created_at,
93
+ p.name as plan_name, p.display_name as plan_display,
94
+ COALESCE(s.tokens_used, 0) as tokens_used,
95
+ p.token_limit_monthly,
96
+ (SELECT COUNT(*) FROM usage_logs WHERE user_id = u.id) as total_requests,
97
+ (SELECT COUNT(*) FROM api_keys WHERE user_id = u.id AND status = 'active') as api_key_count
98
+ FROM users u
99
+ LEFT JOIN subscriptions s ON s.user_id = u.id
100
+ LEFT JOIN plans p ON s.plan_id = p.id
101
+ ORDER BY u.created_at DESC
102
+ `);
103
+
104
+ res.json({ success: true, users: rows });
105
+ } catch (err) {
106
+ logger.error({ err }, 'admin users error');
107
+ res.status(500).json({ success: false, error: 'Internal error' });
108
+ }
109
+ });
110
+
111
+ /**
112
+ * PUT /api/admin/users/:id
113
+ * Update user status or plan.
114
+ */
115
+ adminRouter.put('/users/:id', async (req: Request, res: Response) => {
116
+ try {
117
+ const pool = getPool();
118
+ const userId = req.params.id;
119
+ const { status, plan } = req.body;
120
+
121
+ if (status) {
122
+ await pool.execute('UPDATE users SET status = ? WHERE id = ?', [status, userId]);
123
+ }
124
+
125
+ if (plan) {
126
+ const [plans] = await pool.execute('SELECT id FROM plans WHERE name = ?', [plan]);
127
+ const planId = (plans as any[])[0]?.id;
128
+ if (planId) {
129
+ await pool.execute(
130
+ 'UPDATE subscriptions SET plan_id = ?, tokens_used = 0 WHERE id = (SELECT id FROM subscriptions WHERE user_id = ? ORDER BY period_start DESC LIMIT 1)',
131
+ [planId, userId],
132
+ );
133
+ }
134
+ }
135
+
136
+ res.json({ success: true });
137
+ } catch (err) {
138
+ logger.error({ err }, 'admin update user error');
139
+ res.status(500).json({ success: false, error: 'Internal error' });
140
+ }
141
+ });
142
+
143
+ /**
144
+ * GET /api/admin/cost-report
145
+ * Provider cost breakdown.
146
+ */
147
+ adminRouter.get('/cost-report', async (_req: Request, res: Response) => {
148
+ try {
149
+ const pool = getPool();
150
+ const [daily] = await pool.execute(`
151
+ SELECT
152
+ DATE(created_at) as date,
153
+ provider_name,
154
+ COUNT(*) as requests,
155
+ COALESCE(SUM(input_tokens), 0) as input_tokens,
156
+ COALESCE(SUM(output_tokens), 0) as output_tokens,
157
+ COALESCE(SUM(provider_cost), 0) as cost
158
+ FROM usage_logs
159
+ WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'
160
+ GROUP BY DATE(created_at), provider_name
161
+ ORDER BY date DESC, provider_name
162
+ `);
163
+
164
+ res.json({ success: true, daily });
165
+ } catch (err) {
166
+ logger.error({ err }, 'cost report error');
167
+ res.status(500).json({ success: false, error: 'Internal error' });
168
+ }
169
+ });
@@ -0,0 +1,369 @@
1
+ import { Router } from 'express';
2
+ import type { Request, Response } from 'express';
3
+ import bcrypt from 'bcryptjs';
4
+ import jwt from 'jsonwebtoken';
5
+ import crypto from 'crypto';
6
+ import { getPool } from '../services/database';
7
+ import { sessionAuth } from '../middleware/session-auth';
8
+ import { sendVerificationEmail, sendWelcomeEmail } from '../services/mailer';
9
+ import { logger } from '../utils/logger';
10
+
11
+ const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '128504392054-4rfrtk05umm9fvfpd83l9qn9r4qnaa98.apps.googleusercontent.com';
12
+
13
+ export function createAuthRouter(jwtSecret: string): Router {
14
+ const router = Router();
15
+
16
+ // In-memory pending registrations (code -> {email, hash, name, expiresAt})
17
+ const pending = new Map<string, {
18
+ email: string;
19
+ passwordHash: string;
20
+ name: string;
21
+ code: string;
22
+ expiresAt: number;
23
+ }>();
24
+
25
+ // Cleanup expired entries every 5 min
26
+ setInterval(() => {
27
+ const now = Date.now();
28
+ for (const [key, val] of pending) {
29
+ if (val.expiresAt < now) pending.delete(key);
30
+ }
31
+ }, 5 * 60_000);
32
+
33
+ /**
34
+ * POST /api/auth/send-code
35
+ * Step 1 of registration: validate inputs, send verification code.
36
+ */
37
+ router.post('/send-code', async (req: Request, res: Response) => {
38
+ try {
39
+ const { email, password, name } = req.body;
40
+
41
+ if (!email || !password || !name) {
42
+ res.status(400).json({ success: false, error: 'Missing required fields' });
43
+ return;
44
+ }
45
+ if (password.length < 8) {
46
+ res.status(400).json({ success: false, error: 'Password must be at least 8 characters' });
47
+ return;
48
+ }
49
+
50
+ const pool = getPool();
51
+ const [existing] = await pool.execute('SELECT id FROM users WHERE email = ?', [email]);
52
+ if ((existing as any[]).length > 0) {
53
+ res.status(409).json({ success: false, error: 'Email already registered' });
54
+ return;
55
+ }
56
+
57
+ const code = String(Math.floor(100000 + Math.random() * 900000));
58
+ const passwordHash = await bcrypt.hash(password, 10);
59
+
60
+ pending.set(email, {
61
+ email,
62
+ passwordHash,
63
+ name,
64
+ code,
65
+ expiresAt: Date.now() + 30 * 60_000, // 30 min
66
+ });
67
+
68
+ await sendVerificationEmail(email, code, name);
69
+
70
+ res.json({ success: true, message: 'Verification code sent' });
71
+ } catch (err) {
72
+ logger.error({ err }, 'send-code error');
73
+ res.status(500).json({ success: false, error: 'Internal error' });
74
+ }
75
+ });
76
+
77
+ /**
78
+ * POST /api/auth/register
79
+ * Step 2: verify code, create user, issue JWT.
80
+ */
81
+ router.post('/register', async (req: Request, res: Response) => {
82
+ try {
83
+ const { email, code } = req.body;
84
+
85
+ const entry = pending.get(email);
86
+ if (!entry || entry.code !== code || entry.expiresAt < Date.now()) {
87
+ res.status(400).json({ success: false, error: 'Invalid or expired verification code' });
88
+ return;
89
+ }
90
+
91
+ const pool = getPool();
92
+
93
+ // Create user
94
+ const [result] = await pool.execute(
95
+ 'INSERT INTO users (email, password_hash, name, verified, status) VALUES (?, ?, ?, 1, ?)',
96
+ [entry.email, entry.passwordHash, entry.name, 'active'],
97
+ );
98
+ const userId = (result as any).insertId;
99
+
100
+ // Assign free plan
101
+ const [plans] = await pool.execute('SELECT id FROM plans WHERE name = ?', ['free']);
102
+ const planId = (plans as any[])[0]?.id;
103
+ if (planId) {
104
+ await pool.execute(
105
+ "INSERT INTO subscriptions (user_id, plan_id, period_end) VALUES (?, ?, NOW() + INTERVAL '100 years')",
106
+ [userId, planId],
107
+ );
108
+ }
109
+
110
+ pending.delete(email);
111
+
112
+ // Issue JWT
113
+ const token = jwt.sign({ id: userId }, jwtSecret, { expiresIn: '7d' });
114
+ res.cookie('token', token, { httpOnly: true, maxAge: 7 * 24 * 3600_000, sameSite: 'lax' });
115
+
116
+ // Send welcome email (async, don't block)
117
+ sendWelcomeEmail(entry.email, entry.name).catch(() => {});
118
+
119
+ res.json({ success: true, user: { id: userId, email: entry.email, name: entry.name } });
120
+ } catch (err) {
121
+ logger.error({ err }, 'register error');
122
+ res.status(500).json({ success: false, error: 'Internal error' });
123
+ }
124
+ });
125
+
126
+ /**
127
+ * POST /api/auth/login
128
+ */
129
+ router.post('/login', async (req: Request, res: Response) => {
130
+ try {
131
+ const { email, password } = req.body;
132
+ if (!email || !password) {
133
+ res.status(400).json({ success: false, error: 'Email and password required' });
134
+ return;
135
+ }
136
+
137
+ const pool = getPool();
138
+ const [rows] = await pool.execute('SELECT * FROM users WHERE email = ?', [email]);
139
+ const user = (rows as any[])[0];
140
+
141
+ if (!user || !(await bcrypt.compare(password, user.password_hash))) {
142
+ res.status(401).json({ success: false, error: 'Invalid email or password' });
143
+ return;
144
+ }
145
+ if (user.status !== 'active') {
146
+ res.status(403).json({ success: false, error: 'Account suspended' });
147
+ return;
148
+ }
149
+
150
+ const token = jwt.sign({ id: user.id }, jwtSecret, { expiresIn: '7d' });
151
+ res.cookie('token', token, { httpOnly: true, maxAge: 7 * 24 * 3600_000, sameSite: 'lax' });
152
+
153
+ res.json({ success: true, user: { id: user.id, email: user.email, name: user.name } });
154
+ } catch (err) {
155
+ logger.error({ err }, 'login error');
156
+ res.status(500).json({ success: false, error: 'Internal error' });
157
+ }
158
+ });
159
+
160
+ /**
161
+ * POST /api/auth/logout
162
+ */
163
+ router.post('/logout', (_req: Request, res: Response) => {
164
+ res.clearCookie('token');
165
+ res.json({ success: true });
166
+ });
167
+
168
+ /**
169
+ * GET /api/auth/me
170
+ */
171
+ router.get('/me', sessionAuth, async (req: Request, res: Response) => {
172
+ try {
173
+ const pool = getPool();
174
+ const [subs] = await pool.execute(`
175
+ SELECT s.*, p.name as plan_name, p.display_name, p.token_limit_monthly, p.rate_limit_rpm, p.max_api_keys, p.price_monthly
176
+ FROM subscriptions s JOIN plans p ON s.plan_id = p.id
177
+ WHERE s.user_id = ? ORDER BY s.period_start DESC LIMIT 1
178
+ `, [req.userId!]);
179
+
180
+ res.json({
181
+ success: true,
182
+ user: req.user,
183
+ subscription: (subs as any[])[0] || null,
184
+ });
185
+ } catch (err) {
186
+ logger.error({ err }, 'me error');
187
+ res.status(500).json({ success: false, error: 'Internal error' });
188
+ }
189
+ });
190
+
191
+ /**
192
+ * POST /api/auth/update-profile
193
+ */
194
+ router.post('/update-profile', sessionAuth, async (req: Request, res: Response) => {
195
+ try {
196
+ const { name } = req.body;
197
+ if (!name || name.length < 2) {
198
+ res.status(400).json({ success: false, error: 'Name must be at least 2 characters' });
199
+ return;
200
+ }
201
+ const pool = getPool();
202
+ await pool.execute('UPDATE users SET name = ? WHERE id = ?', [name, req.userId!]);
203
+ res.json({ success: true });
204
+ } catch (err) {
205
+ logger.error({ err }, 'update-profile error');
206
+ res.status(500).json({ success: false, error: 'Internal error' });
207
+ }
208
+ });
209
+
210
+ /**
211
+ * POST /api/auth/change-password
212
+ */
213
+ router.post('/change-password', sessionAuth, async (req: Request, res: Response) => {
214
+ try {
215
+ const { currentPassword, newPassword } = req.body;
216
+ if (!currentPassword || !newPassword || newPassword.length < 8) {
217
+ res.status(400).json({ success: false, error: 'Invalid password' });
218
+ return;
219
+ }
220
+
221
+ const pool = getPool();
222
+ const [rows] = await pool.execute('SELECT password_hash FROM users WHERE id = ?', [req.userId!]);
223
+ const user = (rows as any[])[0];
224
+
225
+ if (!user || !(await bcrypt.compare(currentPassword, user.password_hash))) {
226
+ res.status(401).json({ success: false, error: 'Current password is incorrect' });
227
+ return;
228
+ }
229
+
230
+ const hash = await bcrypt.hash(newPassword, 10);
231
+ await pool.execute('UPDATE users SET password_hash = ? WHERE id = ?', [hash, req.userId!]);
232
+
233
+ res.json({ success: true });
234
+ } catch (err) {
235
+ logger.error({ err }, 'change-password error');
236
+ res.status(500).json({ success: false, error: 'Internal error' });
237
+ }
238
+ });
239
+
240
+ /**
241
+ * POST /api/auth/delete-account
242
+ */
243
+ router.post('/delete-account', sessionAuth, async (req: Request, res: Response) => {
244
+ try {
245
+ const { email } = req.body;
246
+ if (email !== req.user?.email) {
247
+ res.status(400).json({ success: false, error: 'Email confirmation does not match' });
248
+ return;
249
+ }
250
+
251
+ const pool = getPool();
252
+ await pool.execute('DELETE FROM users WHERE id = ?', [req.userId!]);
253
+ res.clearCookie('token');
254
+ res.json({ success: true });
255
+ } catch (err) {
256
+ logger.error({ err }, 'delete-account error');
257
+ res.status(500).json({ success: false, error: 'Internal error' });
258
+ }
259
+ });
260
+
261
+ // ============================================================
262
+ // Google Sign-In (frontend-based, no server-to-Google network calls)
263
+ // ============================================================
264
+
265
+ /**
266
+ * Decode a Google ID token (JWT) without network access.
267
+ * We trust the token because it comes from Google's Sign-In SDK
268
+ * running in the user's browser with our client_id configured.
269
+ */
270
+ function decodeGoogleIdToken(idToken: string) {
271
+ const parts = idToken.split('.');
272
+ if (parts.length !== 3) throw new Error('Invalid token');
273
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
274
+ if (!['accounts.google.com', 'https://accounts.google.com'].includes(payload.iss)) {
275
+ throw new Error('Invalid issuer');
276
+ }
277
+ if (payload.aud !== GOOGLE_CLIENT_ID) {
278
+ throw new Error('Invalid audience');
279
+ }
280
+ if (payload.exp * 1000 < Date.now()) {
281
+ throw new Error('Token expired');
282
+ }
283
+ return payload as { email: string; name?: string; sub: string; picture?: string };
284
+ }
285
+
286
+ /**
287
+ * POST /api/auth/google/token
288
+ * Receive a Google ID token from the frontend, verify it, create/login user.
289
+ */
290
+ router.post('/google/token', async (req: Request, res: Response) => {
291
+ try {
292
+ const { idToken } = req.body;
293
+ if (!idToken || typeof idToken !== 'string') {
294
+ res.status(400).json({ success: false, error: 'Missing idToken' });
295
+ return;
296
+ }
297
+
298
+ if (!GOOGLE_CLIENT_ID) {
299
+ res.status(500).json({ success: false, error: 'Google OAuth not configured' });
300
+ return;
301
+ }
302
+
303
+ let googleUser: { email: string; name?: string; sub: string; picture?: string };
304
+ try {
305
+ googleUser = decodeGoogleIdToken(idToken);
306
+ } catch (err: any) {
307
+ logger.warn({ err: err.message }, 'Invalid Google ID token');
308
+ res.status(401).json({ success: false, error: 'Invalid Google token' });
309
+ return;
310
+ }
311
+
312
+ if (!googleUser.email) {
313
+ res.status(400).json({ success: false, error: 'No email in Google token' });
314
+ return;
315
+ }
316
+
317
+ const pool = getPool();
318
+
319
+ // Check if user exists
320
+ const [existing] = await pool.execute('SELECT * FROM users WHERE email = ?', [googleUser.email]);
321
+ let userId: number;
322
+
323
+ if ((existing as any[]).length > 0) {
324
+ // Existing user — login
325
+ const user = (existing as any[])[0];
326
+ if (user.status !== 'active') {
327
+ res.status(403).json({ success: false, error: 'Account suspended' });
328
+ return;
329
+ }
330
+ userId = user.id;
331
+ } else {
332
+ // New user — register
333
+ const randomPassword = crypto.randomBytes(32).toString('hex');
334
+ const hash = await bcrypt.hash(randomPassword, 10);
335
+ const name = googleUser.name || googleUser.email.split('@')[0];
336
+
337
+ const [result] = await pool.execute(
338
+ 'INSERT INTO users (email, password_hash, name, verified, status) VALUES (?, ?, ?, 1, ?) RETURNING id',
339
+ [googleUser.email, hash, name, 'active'],
340
+ );
341
+ userId = (result as any[])[0].id;
342
+
343
+ // Assign free plan
344
+ const [plans] = await pool.execute('SELECT id FROM plans WHERE name = ?', ['free']);
345
+ const planId = (plans as any[])[0]?.id;
346
+ if (planId) {
347
+ await pool.execute(
348
+ "INSERT INTO subscriptions (user_id, plan_id, period_end) VALUES (?, ?, NOW() + INTERVAL '100 years')",
349
+ [userId, planId],
350
+ );
351
+ }
352
+
353
+ // Welcome email (async)
354
+ sendWelcomeEmail(googleUser.email, name).catch(() => {});
355
+ }
356
+
357
+ // Issue JWT
358
+ const token = jwt.sign({ id: userId }, jwtSecret, { expiresIn: '7d' });
359
+ res.cookie('token', token, { httpOnly: true, maxAge: 7 * 24 * 3600_000, sameSite: 'lax' });
360
+ res.json({ success: true, user: { id: userId, email: googleUser.email, name: googleUser.name } });
361
+
362
+ } catch (err) {
363
+ logger.error({ err }, 'Google token login error');
364
+ res.status(500).json({ success: false, error: 'Internal error' });
365
+ }
366
+ });
367
+
368
+ return router;
369
+ }