webpeel 0.20.2 → 0.20.3

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 (86) hide show
  1. package/dist/server/app.d.ts +14 -0
  2. package/dist/server/app.js +384 -0
  3. package/dist/server/auth-store.d.ts +27 -0
  4. package/dist/server/auth-store.js +88 -0
  5. package/dist/server/email-service.d.ts +21 -0
  6. package/dist/server/email-service.js +79 -0
  7. package/dist/server/job-queue.d.ts +100 -0
  8. package/dist/server/job-queue.js +145 -0
  9. package/dist/server/logger.d.ts +10 -0
  10. package/dist/server/logger.js +37 -0
  11. package/dist/server/middleware/auth.d.ts +28 -0
  12. package/dist/server/middleware/auth.js +221 -0
  13. package/dist/server/middleware/rate-limit.d.ts +24 -0
  14. package/dist/server/middleware/rate-limit.js +167 -0
  15. package/dist/server/middleware/url-validator.d.ts +15 -0
  16. package/dist/server/middleware/url-validator.js +186 -0
  17. package/dist/server/openapi.yaml +6418 -0
  18. package/dist/server/pg-auth-store.d.ts +132 -0
  19. package/dist/server/pg-auth-store.js +472 -0
  20. package/dist/server/pg-job-queue.d.ts +59 -0
  21. package/dist/server/pg-job-queue.js +375 -0
  22. package/dist/server/premium/domain-intel.d.ts +16 -0
  23. package/dist/server/premium/domain-intel.js +133 -0
  24. package/dist/server/premium/index.d.ts +17 -0
  25. package/dist/server/premium/index.js +35 -0
  26. package/dist/server/premium/swr-cache.d.ts +14 -0
  27. package/dist/server/premium/swr-cache.js +34 -0
  28. package/dist/server/routes/activity.d.ts +6 -0
  29. package/dist/server/routes/activity.js +74 -0
  30. package/dist/server/routes/answer.d.ts +5 -0
  31. package/dist/server/routes/answer.js +125 -0
  32. package/dist/server/routes/ask.d.ts +28 -0
  33. package/dist/server/routes/ask.js +229 -0
  34. package/dist/server/routes/batch.d.ts +6 -0
  35. package/dist/server/routes/batch.js +493 -0
  36. package/dist/server/routes/cli-usage.d.ts +6 -0
  37. package/dist/server/routes/cli-usage.js +127 -0
  38. package/dist/server/routes/compat.d.ts +23 -0
  39. package/dist/server/routes/compat.js +652 -0
  40. package/dist/server/routes/deep-fetch.d.ts +8 -0
  41. package/dist/server/routes/deep-fetch.js +57 -0
  42. package/dist/server/routes/demo.d.ts +24 -0
  43. package/dist/server/routes/demo.js +517 -0
  44. package/dist/server/routes/do.d.ts +8 -0
  45. package/dist/server/routes/do.js +72 -0
  46. package/dist/server/routes/extract.d.ts +8 -0
  47. package/dist/server/routes/extract.js +235 -0
  48. package/dist/server/routes/fetch.d.ts +7 -0
  49. package/dist/server/routes/fetch.js +999 -0
  50. package/dist/server/routes/health.d.ts +7 -0
  51. package/dist/server/routes/health.js +19 -0
  52. package/dist/server/routes/jobs.d.ts +7 -0
  53. package/dist/server/routes/jobs.js +573 -0
  54. package/dist/server/routes/mcp.d.ts +14 -0
  55. package/dist/server/routes/mcp.js +141 -0
  56. package/dist/server/routes/oauth.d.ts +9 -0
  57. package/dist/server/routes/oauth.js +396 -0
  58. package/dist/server/routes/playground.d.ts +17 -0
  59. package/dist/server/routes/playground.js +283 -0
  60. package/dist/server/routes/screenshot.d.ts +22 -0
  61. package/dist/server/routes/screenshot.js +816 -0
  62. package/dist/server/routes/search.d.ts +6 -0
  63. package/dist/server/routes/search.js +303 -0
  64. package/dist/server/routes/session.d.ts +15 -0
  65. package/dist/server/routes/session.js +397 -0
  66. package/dist/server/routes/stats.d.ts +6 -0
  67. package/dist/server/routes/stats.js +71 -0
  68. package/dist/server/routes/stripe.d.ts +15 -0
  69. package/dist/server/routes/stripe.js +294 -0
  70. package/dist/server/routes/users.d.ts +8 -0
  71. package/dist/server/routes/users.js +1671 -0
  72. package/dist/server/routes/watch.d.ts +15 -0
  73. package/dist/server/routes/watch.js +309 -0
  74. package/dist/server/routes/webhooks.d.ts +26 -0
  75. package/dist/server/routes/webhooks.js +170 -0
  76. package/dist/server/routes/youtube.d.ts +6 -0
  77. package/dist/server/routes/youtube.js +130 -0
  78. package/dist/server/sentry.d.ts +13 -0
  79. package/dist/server/sentry.js +38 -0
  80. package/dist/server/types.d.ts +15 -0
  81. package/dist/server/types.js +7 -0
  82. package/dist/server/utils/response.d.ts +44 -0
  83. package/dist/server/utils/response.js +69 -0
  84. package/dist/server/utils/sse.d.ts +22 -0
  85. package/dist/server/utils/sse.js +38 -0
  86. package/package.json +2 -1
@@ -0,0 +1,1671 @@
1
+ /**
2
+ * User authentication and API key management routes
3
+ */
4
+ import { Router } from 'express';
5
+ import crypto from 'crypto';
6
+ import bcrypt from 'bcrypt';
7
+ import jwt from 'jsonwebtoken';
8
+ import pg from 'pg';
9
+ import { PostgresAuthStore } from '../pg-auth-store.js';
10
+ const { Pool } = pg;
11
+ const BCRYPT_ROUNDS = 12;
12
+ /**
13
+ * Per-email rate limiter for login attempts (brute-force protection)
14
+ */
15
+ const loginAttempts = new Map();
16
+ // Clean up expired entries every 15 minutes
17
+ setInterval(() => {
18
+ const now = Date.now();
19
+ for (const [key, attempt] of loginAttempts.entries()) {
20
+ if (now >= attempt.resetAt) {
21
+ loginAttempts.delete(key);
22
+ }
23
+ }
24
+ }, 15 * 60 * 1000);
25
+ function loginRateLimiter(req, res, next) {
26
+ const email = req.body?.email?.toLowerCase();
27
+ if (!email) {
28
+ next();
29
+ return;
30
+ }
31
+ const now = Date.now();
32
+ const attempt = loginAttempts.get(email);
33
+ if (attempt && now < attempt.resetAt) {
34
+ if (attempt.count >= 5) {
35
+ res.status(429).json({
36
+ success: false,
37
+ error: {
38
+ type: 'too_many_attempts',
39
+ message: 'Too many login attempts. Please try again in 15 minutes.',
40
+ hint: 'Wait 15 minutes before trying again.',
41
+ docs: 'https://webpeel.dev/docs/errors#too_many_attempts',
42
+ },
43
+ retryAfter: Math.ceil((attempt.resetAt - now) / 1000),
44
+ requestId: crypto.randomUUID(),
45
+ });
46
+ return;
47
+ }
48
+ attempt.count++;
49
+ }
50
+ else {
51
+ loginAttempts.set(email, { count: 1, resetAt: now + 15 * 60 * 1000 });
52
+ }
53
+ next();
54
+ }
55
+ /**
56
+ * Per-IP rate limiter for refresh endpoint (brute-force protection)
57
+ */
58
+ const refreshAttempts = new Map();
59
+ setInterval(() => {
60
+ const now = Date.now();
61
+ for (const [key, attempt] of refreshAttempts.entries()) {
62
+ if (now >= attempt.resetAt) {
63
+ refreshAttempts.delete(key);
64
+ }
65
+ }
66
+ }, 15 * 60 * 1000);
67
+ function refreshRateLimiter(req, res, next) {
68
+ const ip = req.headers['cf-connecting-ip'] ||
69
+ req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
70
+ req.ip ||
71
+ 'unknown';
72
+ const now = Date.now();
73
+ const attempt = refreshAttempts.get(ip);
74
+ if (attempt && now < attempt.resetAt) {
75
+ if (attempt.count >= 10) {
76
+ res.status(429).json({
77
+ success: false,
78
+ error: {
79
+ type: 'too_many_attempts',
80
+ message: 'Too many refresh attempts. Please try again in 15 minutes.',
81
+ hint: 'Wait 15 minutes before trying again.',
82
+ docs: 'https://webpeel.dev/docs/errors#too_many_attempts',
83
+ },
84
+ retryAfter: Math.ceil((attempt.resetAt - now) / 1000),
85
+ requestId: crypto.randomUUID(),
86
+ });
87
+ return;
88
+ }
89
+ attempt.count++;
90
+ }
91
+ else {
92
+ refreshAttempts.set(ip, { count: 1, resetAt: now + 15 * 60 * 1000 });
93
+ }
94
+ next();
95
+ }
96
+ /**
97
+ * Validate email format
98
+ */
99
+ function isValidEmail(email) {
100
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
101
+ return emailRegex.test(email);
102
+ }
103
+ /**
104
+ * Validate password strength
105
+ */
106
+ function isValidPassword(password) {
107
+ // bcrypt silently truncates at 72 bytes — enforce a max to prevent confusion
108
+ return password.length >= 8 && password.length <= 128;
109
+ }
110
+ /**
111
+ * JWT authentication middleware
112
+ */
113
+ function jwtAuth(req, res, next) {
114
+ try {
115
+ const authHeader = req.headers.authorization;
116
+ if (!authHeader?.startsWith('Bearer ')) {
117
+ res.status(401).json({
118
+ success: false,
119
+ error: {
120
+ type: 'missing_token',
121
+ message: 'JWT token required. Provide via Authorization: Bearer <token>',
122
+ hint: 'Include your JWT in the Authorization header: Bearer <token>',
123
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
124
+ },
125
+ requestId: crypto.randomUUID(),
126
+ });
127
+ return;
128
+ }
129
+ const token = authHeader.slice(7);
130
+ const jwtSecret = process.env.JWT_SECRET;
131
+ if (!jwtSecret) {
132
+ throw new Error('JWT_SECRET environment variable not configured');
133
+ }
134
+ const payload = jwt.verify(token, jwtSecret);
135
+ // Attach user info to request
136
+ req.user = payload;
137
+ next();
138
+ }
139
+ catch (error) {
140
+ if (error instanceof jwt.JsonWebTokenError) {
141
+ res.status(401).json({
142
+ success: false,
143
+ error: {
144
+ type: 'invalid_token',
145
+ message: 'Invalid or expired JWT token',
146
+ hint: 'Log in again to get a new token.',
147
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
148
+ },
149
+ requestId: crypto.randomUUID(),
150
+ });
151
+ return;
152
+ }
153
+ res.status(500).json({
154
+ success: false,
155
+ error: {
156
+ type: 'auth_error',
157
+ message: 'Authentication failed',
158
+ docs: 'https://webpeel.dev/docs/errors#auth_error',
159
+ },
160
+ requestId: crypto.randomUUID(),
161
+ });
162
+ }
163
+ }
164
+ /**
165
+ * Create user routes
166
+ */
167
+ export function createUserRouter() {
168
+ const router = Router();
169
+ const dbUrl = process.env.DATABASE_URL;
170
+ if (!dbUrl) {
171
+ throw new Error('DATABASE_URL environment variable is required');
172
+ }
173
+ const pool = new Pool({
174
+ connectionString: dbUrl,
175
+ // TLS: enabled when DATABASE_URL contains sslmode=require.
176
+ // Secure by default (rejectUnauthorized: true); set PG_REJECT_UNAUTHORIZED=false
177
+ // only for managed DBs (Render/Neon/Supabase) that use self-signed certs.
178
+ ssl: process.env.DATABASE_URL?.includes('sslmode=require')
179
+ ? { rejectUnauthorized: process.env.PG_REJECT_UNAUTHORIZED !== 'false' }
180
+ : undefined,
181
+ });
182
+ // Initialize refresh_tokens table on startup
183
+ pool.query(`
184
+ CREATE TABLE IF NOT EXISTS refresh_tokens (
185
+ id TEXT PRIMARY KEY,
186
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
187
+ expires_at TIMESTAMPTZ NOT NULL,
188
+ revoked_at TIMESTAMPTZ,
189
+ created_at TIMESTAMPTZ DEFAULT NOW()
190
+ )
191
+ `).then(() => pool.query(`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)`)).catch((err) => {
192
+ console.error('Failed to initialize refresh_tokens table:', err);
193
+ });
194
+ /**
195
+ * Helper: generate a refresh token and store its jti in the database
196
+ */
197
+ async function createRefreshToken(userId, jwtSecret) {
198
+ const jti = crypto.randomUUID();
199
+ const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
200
+ await pool.query(`INSERT INTO refresh_tokens (id, user_id, expires_at) VALUES ($1, $2, $3)`, [jti, userId, expiresAt]);
201
+ return jwt.sign({ userId, jti }, jwtSecret, { expiresIn: '30d' });
202
+ }
203
+ /**
204
+ * POST /v1/auth/register
205
+ * Register a new user and create their first API key
206
+ */
207
+ router.post('/v1/auth/register', async (req, res) => {
208
+ try {
209
+ const { email, password } = req.body;
210
+ // Input validation
211
+ if (!email || !password) {
212
+ res.status(400).json({
213
+ success: false,
214
+ error: {
215
+ type: 'missing_fields',
216
+ message: 'Email and password are required',
217
+ hint: 'Provide both email and password in the request body.',
218
+ docs: 'https://webpeel.dev/docs/errors#missing_fields',
219
+ },
220
+ requestId: crypto.randomUUID(),
221
+ });
222
+ return;
223
+ }
224
+ if (!isValidEmail(email)) {
225
+ res.status(400).json({
226
+ success: false,
227
+ error: {
228
+ type: 'invalid_email',
229
+ message: 'Invalid email format',
230
+ hint: 'Provide a valid email address (e.g. user@example.com).',
231
+ docs: 'https://webpeel.dev/docs/errors#invalid_email',
232
+ },
233
+ requestId: crypto.randomUUID(),
234
+ });
235
+ return;
236
+ }
237
+ if (!isValidPassword(password)) {
238
+ res.status(400).json({
239
+ success: false,
240
+ error: {
241
+ type: 'weak_password',
242
+ message: 'Password must be at least 8 characters',
243
+ hint: 'Choose a password with at least 8 characters.',
244
+ docs: 'https://webpeel.dev/docs/errors#weak_password',
245
+ },
246
+ requestId: crypto.randomUUID(),
247
+ });
248
+ return;
249
+ }
250
+ // Hash password
251
+ const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
252
+ // Create user
253
+ const userResult = await pool.query(`INSERT INTO users (email, password_hash, tier, weekly_limit, burst_limit, rate_limit)
254
+ VALUES ($1, $2, 'free', 500, 50, 10)
255
+ RETURNING id, email, tier, weekly_limit, burst_limit, rate_limit, created_at`, [email, passwordHash]);
256
+ const user = userResult.rows[0];
257
+ // Generate API key
258
+ const apiKey = PostgresAuthStore.generateApiKey();
259
+ const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
260
+ const keyPrefix = PostgresAuthStore.getKeyPrefix(apiKey);
261
+ // Store API key
262
+ await pool.query(`INSERT INTO api_keys (user_id, key_hash, key_prefix, name)
263
+ VALUES ($1, $2, $3, 'Default')`, [user.id, keyHash, keyPrefix]);
264
+ const signupTimestamp = new Date().toISOString();
265
+ res.status(201).json({
266
+ user: {
267
+ id: user.id,
268
+ email: user.email,
269
+ tier: user.tier,
270
+ weeklyLimit: user.weekly_limit,
271
+ burstLimit: user.burst_limit,
272
+ rateLimit: user.rate_limit,
273
+ createdAt: user.created_at,
274
+ },
275
+ apiKey, // SECURITY: Only returned once, never stored or shown again
276
+ });
277
+ // Fire-and-forget Discord webhook for successful signups; never block registration on webhook errors.
278
+ try {
279
+ const webhookUrl = process.env.DISCORD_SIGNUP_WEBHOOK;
280
+ if (webhookUrl) {
281
+ void fetch(webhookUrl, {
282
+ method: 'POST',
283
+ headers: { 'Content-Type': 'application/json' },
284
+ body: JSON.stringify({
285
+ embeds: [{
286
+ title: '🎉 New Signup',
287
+ color: 9133302,
288
+ fields: [
289
+ { name: 'Email', value: email, inline: true },
290
+ { name: 'Tier', value: 'Free', inline: true },
291
+ { name: 'Timestamp', value: signupTimestamp, inline: false },
292
+ ],
293
+ timestamp: signupTimestamp,
294
+ footer: { text: 'WebPeel Signups' },
295
+ }],
296
+ }),
297
+ }).catch(() => { });
298
+ }
299
+ }
300
+ catch (e) {
301
+ if (process.env.DEBUG)
302
+ console.debug('[webpeel]', 'discord webhook failed:', e instanceof Error ? e.message : e);
303
+ }
304
+ }
305
+ catch (error) {
306
+ if (error.code === '23505') { // Unique violation
307
+ res.status(409).json({
308
+ success: false,
309
+ error: {
310
+ type: 'email_exists',
311
+ message: 'Email already registered',
312
+ hint: 'Try logging in instead, or use a different email.',
313
+ docs: 'https://webpeel.dev/docs/errors#email_exists',
314
+ },
315
+ requestId: crypto.randomUUID(),
316
+ });
317
+ return;
318
+ }
319
+ console.error('Registration error:', error);
320
+ res.status(500).json({
321
+ success: false,
322
+ error: {
323
+ type: 'registration_failed',
324
+ message: 'Failed to register user',
325
+ docs: 'https://webpeel.dev/docs/errors#registration_failed',
326
+ },
327
+ requestId: crypto.randomUUID(),
328
+ });
329
+ }
330
+ });
331
+ /**
332
+ * POST /v1/auth/login
333
+ * Login with email/password and get JWT token
334
+ */
335
+ router.post('/v1/auth/login', loginRateLimiter, async (req, res) => {
336
+ try {
337
+ const { email, password } = req.body;
338
+ if (!email || !password) {
339
+ res.status(400).json({
340
+ success: false,
341
+ error: {
342
+ type: 'missing_fields',
343
+ message: 'Email and password are required',
344
+ hint: 'Provide both email and password in the request body.',
345
+ docs: 'https://webpeel.dev/docs/errors#missing_fields',
346
+ },
347
+ requestId: crypto.randomUUID(),
348
+ });
349
+ return;
350
+ }
351
+ // Get user
352
+ const result = await pool.query('SELECT id, email, password_hash, tier FROM users WHERE email = $1', [email]);
353
+ // Constant-time auth: always run bcrypt.compare to prevent timing oracle
354
+ // (prevents user enumeration via response time differences)
355
+ const DUMMY_HASH = '$2b$12$LJ7F3mGTqKmEqFv5GsNXxeIkYwJwgJkOqSvKqGqKqGqKqGqKqGqKq';
356
+ const user = result.rows[0];
357
+ const hashToCompare = user?.password_hash ?? DUMMY_HASH;
358
+ const passwordValid = await bcrypt.compare(password, hashToCompare);
359
+ if (!user || !passwordValid) {
360
+ res.status(401).json({
361
+ success: false,
362
+ error: {
363
+ type: 'invalid_credentials',
364
+ message: 'Invalid email or password',
365
+ hint: 'Check your email and password and try again.',
366
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
367
+ },
368
+ requestId: crypto.randomUUID(),
369
+ });
370
+ return;
371
+ }
372
+ // Generate JWT
373
+ const jwtSecret = process.env.JWT_SECRET;
374
+ if (!jwtSecret) {
375
+ throw new Error('JWT_SECRET not configured');
376
+ }
377
+ const token = jwt.sign({
378
+ userId: user.id,
379
+ email: user.email,
380
+ tier: user.tier,
381
+ }, jwtSecret, { expiresIn: '7d' });
382
+ let refreshToken = null;
383
+ try {
384
+ refreshToken = await createRefreshToken(user.id, jwtSecret);
385
+ }
386
+ catch (refreshErr) {
387
+ console.error('Refresh token creation failed (login will continue without it):', refreshErr);
388
+ }
389
+ res.json({
390
+ token,
391
+ ...(refreshToken ? { refreshToken } : {}),
392
+ expiresIn: 604800,
393
+ user: {
394
+ id: user.id,
395
+ email: user.email,
396
+ tier: user.tier,
397
+ },
398
+ });
399
+ }
400
+ catch (error) {
401
+ console.error('Login error:', error);
402
+ res.status(500).json({
403
+ success: false,
404
+ error: {
405
+ type: 'login_failed',
406
+ message: 'Failed to login',
407
+ docs: 'https://webpeel.dev/docs/errors#login_failed',
408
+ },
409
+ requestId: crypto.randomUUID(),
410
+ });
411
+ }
412
+ });
413
+ /**
414
+ * POST /v1/auth/refresh
415
+ * Exchange a valid refresh token for a new access token + refresh token
416
+ */
417
+ router.post('/v1/auth/refresh', refreshRateLimiter, async (req, res) => {
418
+ try {
419
+ const { refreshToken } = req.body;
420
+ if (!refreshToken) {
421
+ res.status(400).json({
422
+ success: false,
423
+ error: {
424
+ type: 'missing_token',
425
+ message: 'refreshToken is required',
426
+ hint: 'Include the refreshToken from your previous login response.',
427
+ docs: 'https://webpeel.dev/docs/errors#missing_token',
428
+ },
429
+ requestId: crypto.randomUUID(),
430
+ });
431
+ return;
432
+ }
433
+ const jwtSecret = process.env.JWT_SECRET;
434
+ if (!jwtSecret) {
435
+ throw new Error('JWT_SECRET not configured');
436
+ }
437
+ // Verify JWT signature + expiry
438
+ let payload;
439
+ try {
440
+ payload = jwt.verify(refreshToken, jwtSecret);
441
+ }
442
+ catch {
443
+ res.status(401).json({
444
+ success: false,
445
+ error: {
446
+ type: 'invalid_token',
447
+ message: 'Invalid or expired refresh token',
448
+ hint: 'Log in again to get a new refresh token.',
449
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
450
+ },
451
+ requestId: crypto.randomUUID(),
452
+ });
453
+ return;
454
+ }
455
+ // Check token is not revoked and still exists
456
+ const tokenResult = await pool.query(`SELECT id, user_id, revoked_at FROM refresh_tokens WHERE id = $1`, [payload.jti]);
457
+ if (tokenResult.rows.length === 0 || tokenResult.rows[0].revoked_at !== null) {
458
+ res.status(401).json({
459
+ success: false,
460
+ error: {
461
+ type: 'token_revoked',
462
+ message: 'Refresh token has been revoked',
463
+ hint: 'Log in again to get a new refresh token.',
464
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
465
+ },
466
+ requestId: crypto.randomUUID(),
467
+ });
468
+ return;
469
+ }
470
+ // Get current user info (tier may have changed)
471
+ const userResult = await pool.query('SELECT id, email, tier FROM users WHERE id = $1', [payload.userId]);
472
+ if (userResult.rows.length === 0) {
473
+ res.status(401).json({
474
+ success: false,
475
+ error: {
476
+ type: 'user_not_found',
477
+ message: 'User no longer exists',
478
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
479
+ },
480
+ requestId: crypto.randomUUID(),
481
+ });
482
+ return;
483
+ }
484
+ const user = userResult.rows[0];
485
+ // Revoke old refresh token (rotate tokens)
486
+ await pool.query(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1`, [payload.jti]);
487
+ // Issue new access token (7d) + new refresh token (30d)
488
+ const newToken = jwt.sign({
489
+ userId: user.id,
490
+ email: user.email,
491
+ tier: user.tier,
492
+ }, jwtSecret, { expiresIn: '7d' });
493
+ const newRefreshToken = await createRefreshToken(user.id, jwtSecret);
494
+ res.json({
495
+ token: newToken,
496
+ refreshToken: newRefreshToken,
497
+ expiresIn: 604800,
498
+ });
499
+ }
500
+ catch (error) {
501
+ console.error('Refresh token error:', error);
502
+ res.status(500).json({
503
+ success: false,
504
+ error: {
505
+ type: 'refresh_failed',
506
+ message: 'Failed to refresh token',
507
+ docs: 'https://webpeel.dev/docs/errors#refresh_failed',
508
+ },
509
+ requestId: crypto.randomUUID(),
510
+ });
511
+ }
512
+ });
513
+ /**
514
+ * POST /v1/auth/revoke
515
+ * Revoke all refresh tokens for the current user (logout all devices)
516
+ */
517
+ router.post('/v1/auth/revoke', jwtAuth, async (req, res) => {
518
+ try {
519
+ const { userId } = req.user;
520
+ await pool.query(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL`, [userId]);
521
+ res.json({ success: true, message: 'All refresh tokens revoked' });
522
+ }
523
+ catch (error) {
524
+ console.error('Revoke tokens error:', error);
525
+ res.status(500).json({
526
+ success: false,
527
+ error: {
528
+ type: 'revoke_failed',
529
+ message: 'Failed to revoke tokens',
530
+ docs: 'https://webpeel.dev/docs/errors#revoke_failed',
531
+ },
532
+ requestId: crypto.randomUUID(),
533
+ });
534
+ }
535
+ });
536
+ /**
537
+ * GET /v1/me
538
+ * Get current user profile and usage
539
+ */
540
+ router.get('/v1/me', jwtAuth, async (req, res) => {
541
+ try {
542
+ const { userId } = req.user;
543
+ const result = await pool.query(`SELECT
544
+ u.id, u.email, u.tier, u.weekly_limit, u.burst_limit, u.rate_limit, u.created_at,
545
+ u.stripe_customer_id, u.stripe_subscription_id
546
+ FROM users u
547
+ WHERE u.id = $1`, [userId]);
548
+ if (result.rows.length === 0) {
549
+ res.status(404).json({
550
+ success: false,
551
+ error: {
552
+ type: 'user_not_found',
553
+ message: 'User not found',
554
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
555
+ },
556
+ requestId: crypto.randomUUID(),
557
+ });
558
+ return;
559
+ }
560
+ const user = result.rows[0];
561
+ res.json({
562
+ id: user.id,
563
+ email: user.email,
564
+ tier: user.tier,
565
+ weeklyLimit: user.weekly_limit,
566
+ burstLimit: user.burst_limit,
567
+ rateLimit: user.rate_limit,
568
+ createdAt: user.created_at,
569
+ hasStripe: !!user.stripe_customer_id,
570
+ });
571
+ }
572
+ catch (error) {
573
+ console.error('Get profile error:', error);
574
+ res.status(500).json({
575
+ success: false,
576
+ error: {
577
+ type: 'profile_failed',
578
+ message: 'Failed to get profile',
579
+ docs: 'https://webpeel.dev/docs/errors#profile_failed',
580
+ },
581
+ requestId: crypto.randomUUID(),
582
+ });
583
+ }
584
+ });
585
+ /**
586
+ * PATCH /v1/me
587
+ * Update current user's profile (name)
588
+ */
589
+ router.patch('/v1/me', jwtAuth, async (req, res) => {
590
+ try {
591
+ const { userId } = req.user;
592
+ const { name } = req.body;
593
+ if (!name || typeof name !== 'string' || name.trim().length === 0) {
594
+ res.status(400).json({
595
+ success: false,
596
+ error: {
597
+ type: 'name_required',
598
+ message: 'Name is required',
599
+ hint: 'Provide a non-empty "name" field in the request body.',
600
+ docs: 'https://webpeel.dev/docs/errors#name_required',
601
+ },
602
+ requestId: crypto.randomUUID(),
603
+ });
604
+ return;
605
+ }
606
+ if (name.length > 100) {
607
+ res.status(400).json({
608
+ success: false,
609
+ error: {
610
+ type: 'invalid_name',
611
+ message: 'Name must be 100 characters or less',
612
+ docs: 'https://webpeel.dev/docs/errors#invalid_name',
613
+ },
614
+ requestId: crypto.randomUUID(),
615
+ });
616
+ return;
617
+ }
618
+ const result = await pool.query('UPDATE users SET name = $1, updated_at = now() WHERE id = $2 RETURNING id, email, name, tier', [name.trim(), userId]);
619
+ if (result.rows.length === 0) {
620
+ res.status(404).json({
621
+ success: false,
622
+ error: {
623
+ type: 'user_not_found',
624
+ message: 'User not found',
625
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
626
+ },
627
+ requestId: crypto.randomUUID(),
628
+ });
629
+ return;
630
+ }
631
+ res.json({ user: result.rows[0] });
632
+ }
633
+ catch (error) {
634
+ console.error('Update me error:', error);
635
+ res.status(500).json({
636
+ success: false,
637
+ error: {
638
+ type: 'update_failed',
639
+ message: 'Failed to update profile',
640
+ docs: 'https://webpeel.dev/docs/errors#update_failed',
641
+ },
642
+ requestId: crypto.randomUUID(),
643
+ });
644
+ }
645
+ });
646
+ /**
647
+ * Parse expiresIn parameter to a Date or null (null = never expires)
648
+ */
649
+ function parseExpiresIn(expiresIn) {
650
+ if (!expiresIn || expiresIn === 'never')
651
+ return null;
652
+ const now = new Date();
653
+ switch (expiresIn) {
654
+ case '7d': return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
655
+ case '30d': return new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
656
+ case '90d': return new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000);
657
+ case '1y': return new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
658
+ default: {
659
+ // Try ISO date string
660
+ const parsed = new Date(expiresIn);
661
+ if (!isNaN(parsed.getTime()) && parsed > now)
662
+ return parsed;
663
+ return null;
664
+ }
665
+ }
666
+ }
667
+ /**
668
+ * POST /v1/keys
669
+ * Create a new API key
670
+ */
671
+ router.post('/v1/keys', jwtAuth, async (req, res) => {
672
+ try {
673
+ const { userId } = req.user;
674
+ const { name, expiresIn } = req.body;
675
+ // Parse optional expiration
676
+ const expiresAt = parseExpiresIn(expiresIn);
677
+ // Generate API key
678
+ const apiKey = PostgresAuthStore.generateApiKey();
679
+ const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
680
+ const keyPrefix = PostgresAuthStore.getKeyPrefix(apiKey);
681
+ // Store API key
682
+ const result = await pool.query(`INSERT INTO api_keys (user_id, key_hash, key_prefix, name, expires_at)
683
+ VALUES ($1, $2, $3, $4, $5)
684
+ RETURNING id, key_prefix, name, created_at, expires_at`, [userId, keyHash, keyPrefix, name || 'Unnamed Key', expiresAt]);
685
+ const key = result.rows[0];
686
+ res.status(201).json({
687
+ id: key.id,
688
+ key: apiKey, // SECURITY: Only returned once
689
+ prefix: key.key_prefix,
690
+ name: key.name,
691
+ createdAt: key.created_at,
692
+ expiresAt: key.expires_at,
693
+ });
694
+ }
695
+ catch (error) {
696
+ console.error('Create key error:', error);
697
+ res.status(500).json({
698
+ success: false,
699
+ error: {
700
+ type: 'key_creation_failed',
701
+ message: 'Failed to create API key',
702
+ docs: 'https://webpeel.dev/docs/errors#key_creation_failed',
703
+ },
704
+ requestId: crypto.randomUUID(),
705
+ });
706
+ }
707
+ });
708
+ /**
709
+ * Format expiry as human-readable string
710
+ */
711
+ function formatExpiresIn(expiresAt) {
712
+ if (!expiresAt)
713
+ return null;
714
+ const now = new Date();
715
+ const diffMs = expiresAt.getTime() - now.getTime();
716
+ const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000));
717
+ if (diffDays < 0) {
718
+ const absDays = Math.abs(diffDays);
719
+ return absDays === 1 ? 'expired 1 day ago' : `expired ${absDays} days ago`;
720
+ }
721
+ if (diffDays === 0)
722
+ return 'expires today';
723
+ if (diffDays === 1)
724
+ return 'in 1 day';
725
+ return `in ${diffDays} days`;
726
+ }
727
+ /**
728
+ * GET /v1/keys
729
+ * List user's API keys (prefix only, never full key)
730
+ */
731
+ router.get('/v1/keys', jwtAuth, async (req, res) => {
732
+ try {
733
+ const { userId } = req.user;
734
+ const result = await pool.query(`SELECT id, key_prefix, name, is_active, created_at, last_used_at, expires_at
735
+ FROM api_keys
736
+ WHERE user_id = $1
737
+ ORDER BY created_at DESC`, [userId]);
738
+ const now = new Date();
739
+ res.json({
740
+ keys: result.rows.map(key => {
741
+ const expiresAt = key.expires_at ? new Date(key.expires_at) : null;
742
+ const isExpired = expiresAt !== null && expiresAt <= now;
743
+ return {
744
+ id: key.id,
745
+ prefix: key.key_prefix,
746
+ name: key.name,
747
+ isActive: key.is_active,
748
+ createdAt: key.created_at,
749
+ lastUsedAt: key.last_used_at,
750
+ expiresAt: key.expires_at,
751
+ isExpired,
752
+ expiresIn: formatExpiresIn(expiresAt),
753
+ };
754
+ }),
755
+ });
756
+ }
757
+ catch (error) {
758
+ console.error('List keys error:', error);
759
+ res.status(500).json({
760
+ success: false,
761
+ error: {
762
+ type: 'list_keys_failed',
763
+ message: 'Failed to list API keys',
764
+ docs: 'https://webpeel.dev/docs/errors#list_keys_failed',
765
+ },
766
+ requestId: crypto.randomUUID(),
767
+ });
768
+ }
769
+ });
770
+ /**
771
+ * PATCH /v1/keys/:id
772
+ * Update an API key (currently: name only)
773
+ */
774
+ router.patch('/v1/keys/:id', jwtAuth, async (req, res) => {
775
+ try {
776
+ const { userId } = req.user;
777
+ const { id } = req.params;
778
+ const { name } = req.body;
779
+ if (!name || typeof name !== 'string' || name.trim().length === 0) {
780
+ res.status(400).json({
781
+ success: false,
782
+ error: {
783
+ type: 'invalid_name',
784
+ message: 'Key name is required',
785
+ hint: 'Provide a non-empty "name" field in the request body.',
786
+ docs: 'https://webpeel.dev/docs/errors#invalid_name',
787
+ },
788
+ requestId: crypto.randomUUID(),
789
+ });
790
+ return;
791
+ }
792
+ if (name.length > 64) {
793
+ res.status(400).json({
794
+ success: false,
795
+ error: {
796
+ type: 'invalid_name',
797
+ message: 'Key name must be 64 characters or less',
798
+ docs: 'https://webpeel.dev/docs/errors#invalid_name',
799
+ },
800
+ requestId: crypto.randomUUID(),
801
+ });
802
+ return;
803
+ }
804
+ const result = await pool.query(`UPDATE api_keys SET name = $1 WHERE id = $2 AND user_id = $3 RETURNING id, name, key_prefix, created_at, last_used_at, is_active, expires_at`, [name.trim(), id, userId]);
805
+ if (result.rowCount === 0) {
806
+ res.status(404).json({
807
+ success: false,
808
+ error: {
809
+ type: 'not_found',
810
+ message: 'API key not found',
811
+ docs: 'https://webpeel.dev/docs/errors#not_found',
812
+ },
813
+ requestId: crypto.randomUUID(),
814
+ });
815
+ return;
816
+ }
817
+ const key = result.rows[0];
818
+ res.json({
819
+ id: key.id,
820
+ name: key.name,
821
+ prefix: key.key_prefix,
822
+ createdAt: key.created_at,
823
+ lastUsedAt: key.last_used_at,
824
+ isActive: key.is_active,
825
+ expiresAt: key.expires_at,
826
+ });
827
+ }
828
+ catch (error) {
829
+ console.error('Update key error:', error);
830
+ res.status(500).json({
831
+ success: false,
832
+ error: {
833
+ type: 'update_failed',
834
+ message: 'Failed to update API key',
835
+ docs: 'https://webpeel.dev/docs/errors#update_failed',
836
+ },
837
+ requestId: crypto.randomUUID(),
838
+ });
839
+ }
840
+ });
841
+ /**
842
+ * DELETE /v1/keys/:id
843
+ * Deactivate an API key
844
+ */
845
+ router.delete('/v1/keys/:id', jwtAuth, async (req, res) => {
846
+ try {
847
+ const { userId } = req.user;
848
+ const { id } = req.params;
849
+ // Verify ownership and deactivate
850
+ const result = await pool.query(`UPDATE api_keys
851
+ SET is_active = false
852
+ WHERE id = $1 AND user_id = $2
853
+ RETURNING id`, [id, userId]);
854
+ if (result.rows.length === 0) {
855
+ res.status(404).json({
856
+ success: false,
857
+ error: {
858
+ type: 'key_not_found',
859
+ message: 'API key not found or access denied',
860
+ docs: 'https://webpeel.dev/docs/errors#not_found',
861
+ },
862
+ requestId: crypto.randomUUID(),
863
+ });
864
+ return;
865
+ }
866
+ res.json({
867
+ success: true,
868
+ message: 'API key deactivated',
869
+ });
870
+ }
871
+ catch (error) {
872
+ console.error('Delete key error:', error);
873
+ res.status(500).json({
874
+ success: false,
875
+ error: {
876
+ type: 'delete_key_failed',
877
+ message: 'Failed to delete API key',
878
+ docs: 'https://webpeel.dev/docs/errors#delete_key_failed',
879
+ },
880
+ requestId: crypto.randomUUID(),
881
+ });
882
+ }
883
+ });
884
+ /**
885
+ * GET /v1/usage
886
+ * Get current week usage + limits + burst + extra usage
887
+ */
888
+ router.get('/v1/usage', async (req, res) => {
889
+ try {
890
+ // Accept both JWT session tokens and API keys
891
+ const userId = req.user?.userId || req.auth?.keyInfo?.accountId;
892
+ if (!userId) {
893
+ // Fall back to jwtAuth behavior for informative error
894
+ res.status(401).json({
895
+ success: false,
896
+ error: {
897
+ type: 'unauthorized',
898
+ message: 'Authentication required. Provide a JWT token or API key.',
899
+ hint: 'Get a free API key at https://app.webpeel.dev/keys',
900
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
901
+ },
902
+ requestId: crypto.randomUUID(),
903
+ });
904
+ return;
905
+ }
906
+ // Helper: Get current ISO week
907
+ const getCurrentWeek = () => {
908
+ const now = new Date();
909
+ const year = now.getUTCFullYear();
910
+ const jan4 = new Date(Date.UTC(year, 0, 4));
911
+ const weekNum = Math.ceil(((now.getTime() - jan4.getTime()) / 86400000 + jan4.getUTCDay() + 1) / 7);
912
+ return `${year}-W${String(weekNum).padStart(2, '0')}`;
913
+ };
914
+ // Helper: Get current hour bucket
915
+ const getCurrentHour = () => {
916
+ return new Date().toISOString().substring(0, 13);
917
+ };
918
+ // Helper: Get week reset time
919
+ const getWeekResetTime = () => {
920
+ const now = new Date();
921
+ const dayOfWeek = now.getUTCDay();
922
+ const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
923
+ const nextMonday = new Date(now);
924
+ nextMonday.setUTCDate(now.getUTCDate() + daysUntilMonday);
925
+ nextMonday.setUTCHours(0, 0, 0, 0);
926
+ return nextMonday.toISOString();
927
+ };
928
+ // Helper: Get time until next hour
929
+ const getTimeUntilNextHour = () => {
930
+ const now = new Date();
931
+ const minutesRemaining = 59 - now.getUTCMinutes();
932
+ if (minutesRemaining === 0)
933
+ return '< 1 min';
934
+ return `${minutesRemaining} min`;
935
+ };
936
+ // Helper: Get next month reset
937
+ const getMonthResetTime = () => {
938
+ const now = new Date();
939
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0)).toISOString();
940
+ };
941
+ const currentWeek = getCurrentWeek();
942
+ const currentHour = getCurrentHour();
943
+ // Get user plan info
944
+ const planResult = await pool.query(`SELECT tier, weekly_limit, burst_limit FROM users WHERE id = $1`, [userId]);
945
+ if (planResult.rows.length === 0) {
946
+ res.status(404).json({
947
+ success: false,
948
+ error: {
949
+ type: 'user_not_found',
950
+ message: 'User not found',
951
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
952
+ },
953
+ requestId: crypto.randomUUID(),
954
+ });
955
+ return;
956
+ }
957
+ const plan = planResult.rows[0];
958
+ // Get weekly usage
959
+ const weeklyResult = await pool.query(`SELECT
960
+ COALESCE(SUM(wu.basic_count), 0) as basic_used,
961
+ COALESCE(SUM(wu.stealth_count), 0) as stealth_used,
962
+ COALESCE(SUM(wu.captcha_count), 0) as captcha_used,
963
+ COALESCE(SUM(wu.search_count), 0) as search_used,
964
+ COALESCE(SUM(wu.total_count), 0) as total_used,
965
+ COALESCE(MAX(wu.rollover_credits), 0) as rollover_credits
966
+ FROM users u
967
+ LEFT JOIN api_keys ak ON ak.user_id = u.id
968
+ LEFT JOIN weekly_usage wu ON wu.api_key_id = ak.id AND wu.week = $2
969
+ WHERE u.id = $1
970
+ GROUP BY u.id`, [userId, currentWeek]);
971
+ let weeklyUsage = weeklyResult.rows[0] || {
972
+ basic_used: 0,
973
+ stealth_used: 0,
974
+ captcha_used: 0,
975
+ search_used: 0,
976
+ total_used: 0,
977
+ rollover_credits: 0,
978
+ };
979
+ // Fallback: if weekly_usage is 0 but usage_logs has entries (e.g. playground/JWT auth),
980
+ // count from usage_logs for the current week so the counter reflects real activity
981
+ if (parseInt(weeklyUsage.total_used) === 0) {
982
+ const weekStart = currentWeek; // e.g. "2026-W10"
983
+ const [weekYear, weekNum] = weekStart.split('-W').map(Number);
984
+ // Compute the start of the week (Monday) from ISO week number
985
+ const jan4 = new Date(weekYear, 0, 4);
986
+ const dayOfWeek = jan4.getDay() || 7;
987
+ const weekStartDate = new Date(jan4);
988
+ weekStartDate.setDate(jan4.getDate() - (dayOfWeek - 1) + (weekNum - 1) * 7);
989
+ weekStartDate.setHours(0, 0, 0, 0);
990
+ const logResult = await pool.query(`SELECT COUNT(*) as total_used FROM usage_logs WHERE user_id = $1 AND created_at >= $2`, [userId, weekStartDate.toISOString()]).catch(() => ({ rows: [{ total_used: 0 }] }));
991
+ const logCount = parseInt(logResult.rows[0]?.total_used) || 0;
992
+ if (logCount > 0) {
993
+ weeklyUsage = { ...weeklyUsage, total_used: logCount, basic_used: logCount };
994
+ }
995
+ }
996
+ const totalAvailable = plan.weekly_limit + weeklyUsage.rollover_credits;
997
+ const remaining = Math.max(0, totalAvailable - weeklyUsage.total_used);
998
+ const percentUsed = totalAvailable > 0 ? Math.round((weeklyUsage.total_used / totalAvailable) * 100) : 0;
999
+ // Get burst usage (current hour)
1000
+ const burstResult = await pool.query(`SELECT COALESCE(SUM(bu.count), 0) as burst_used
1001
+ FROM users u
1002
+ LEFT JOIN api_keys ak ON ak.user_id = u.id
1003
+ LEFT JOIN burst_usage bu ON bu.api_key_id = ak.id AND bu.hour_bucket = $2
1004
+ WHERE u.id = $1`, [userId, currentHour]);
1005
+ const burstUsed = burstResult.rows[0]?.burst_used || 0;
1006
+ const burstPercent = plan.burst_limit > 0 ? Math.round((burstUsed / plan.burst_limit) * 100) : 0;
1007
+ // Get extra usage info
1008
+ const extraResult = await pool.query(`SELECT
1009
+ extra_usage_enabled,
1010
+ extra_usage_balance,
1011
+ extra_usage_spent,
1012
+ extra_usage_spending_limit,
1013
+ auto_reload_enabled
1014
+ FROM users
1015
+ WHERE id = $1`, [userId]);
1016
+ const extra = extraResult.rows[0];
1017
+ const extraPercent = extra.extra_usage_spending_limit > 0
1018
+ ? Math.round((parseFloat(extra.extra_usage_spent) / parseFloat(extra.extra_usage_spending_limit)) * 100)
1019
+ : 0;
1020
+ res.json({
1021
+ plan: {
1022
+ tier: plan.tier,
1023
+ weeklyLimit: plan.weekly_limit,
1024
+ burstLimit: plan.burst_limit,
1025
+ },
1026
+ session: {
1027
+ burstUsed,
1028
+ burstLimit: plan.burst_limit,
1029
+ resetsIn: getTimeUntilNextHour(),
1030
+ percentUsed: burstPercent,
1031
+ },
1032
+ weekly: {
1033
+ week: currentWeek,
1034
+ basicUsed: weeklyUsage.basic_used,
1035
+ stealthUsed: weeklyUsage.stealth_used,
1036
+ captchaUsed: weeklyUsage.captcha_used,
1037
+ searchUsed: weeklyUsage.search_used,
1038
+ totalUsed: weeklyUsage.total_used,
1039
+ totalAvailable,
1040
+ rolloverCredits: weeklyUsage.rollover_credits,
1041
+ remaining,
1042
+ percentUsed,
1043
+ resetsAt: getWeekResetTime(),
1044
+ },
1045
+ extraUsage: {
1046
+ enabled: extra.extra_usage_enabled,
1047
+ spent: parseFloat(extra.extra_usage_spent),
1048
+ spendingLimit: parseFloat(extra.extra_usage_spending_limit),
1049
+ balance: parseFloat(extra.extra_usage_balance),
1050
+ autoReload: extra.auto_reload_enabled,
1051
+ percentUsed: extraPercent,
1052
+ resetsAt: getMonthResetTime(),
1053
+ },
1054
+ });
1055
+ }
1056
+ catch (error) {
1057
+ console.error('Get usage error:', error);
1058
+ res.status(500).json({
1059
+ success: false,
1060
+ error: {
1061
+ type: 'usage_failed',
1062
+ message: 'Failed to get usage',
1063
+ docs: 'https://webpeel.dev/docs/errors#usage_failed',
1064
+ },
1065
+ requestId: crypto.randomUUID(),
1066
+ });
1067
+ }
1068
+ });
1069
+ /**
1070
+ * GET /v1/usage/history
1071
+ * Get daily usage history for the past N days (default 7)
1072
+ */
1073
+ router.get('/v1/usage/history', async (req, res) => {
1074
+ try {
1075
+ const userId = req.user?.userId || req.auth?.keyInfo?.accountId;
1076
+ if (!userId) {
1077
+ res.status(401).json({
1078
+ success: false,
1079
+ error: {
1080
+ type: 'unauthorized',
1081
+ message: 'Authentication required.',
1082
+ hint: 'Get a free API key at https://app.webpeel.dev/keys',
1083
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
1084
+ },
1085
+ requestId: crypto.randomUUID(),
1086
+ });
1087
+ return;
1088
+ }
1089
+ const days = Math.min(Math.max(parseInt(req.query.days) || 7, 1), 90);
1090
+ // Get daily usage from usage_logs table
1091
+ const result = await pool.query(`SELECT
1092
+ DATE(created_at) as date,
1093
+ COUNT(*) FILTER (WHERE method = 'basic' OR method IS NULL) as fetches,
1094
+ COUNT(*) FILTER (WHERE method = 'stealth') as stealth,
1095
+ COUNT(*) FILTER (WHERE method = 'search') as search
1096
+ FROM usage_logs
1097
+ WHERE user_id = $1
1098
+ AND created_at >= NOW() - INTERVAL '1 day' * $2
1099
+ GROUP BY DATE(created_at)
1100
+ ORDER BY date ASC`, [userId, days]);
1101
+ // Fill in missing days with zeros
1102
+ const history = [];
1103
+ const now = new Date();
1104
+ for (let i = days - 1; i >= 0; i--) {
1105
+ const d = new Date(now);
1106
+ d.setUTCDate(d.getUTCDate() - i);
1107
+ const dateStr = d.toISOString().substring(0, 10);
1108
+ const row = result.rows.find((r) => r.date?.toISOString?.().substring(0, 10) === dateStr || r.date === dateStr);
1109
+ history.push({
1110
+ date: dateStr,
1111
+ fetches: parseInt(row?.fetches || '0', 10),
1112
+ stealth: parseInt(row?.stealth || '0', 10),
1113
+ search: parseInt(row?.search || '0', 10),
1114
+ });
1115
+ }
1116
+ res.json({ history });
1117
+ }
1118
+ catch (error) {
1119
+ console.error('Get usage history error:', error);
1120
+ res.status(500).json({
1121
+ success: false,
1122
+ error: {
1123
+ type: 'history_failed',
1124
+ message: 'Failed to get usage history',
1125
+ docs: 'https://webpeel.dev/docs/errors#history_failed',
1126
+ },
1127
+ requestId: crypto.randomUUID(),
1128
+ });
1129
+ }
1130
+ });
1131
+ /**
1132
+ * POST /v1/extra-usage/toggle
1133
+ * Enable/disable extra usage
1134
+ */
1135
+ router.post('/v1/extra-usage/toggle', jwtAuth, async (req, res) => {
1136
+ try {
1137
+ const { userId } = req.user;
1138
+ const { enabled } = req.body;
1139
+ if (typeof enabled !== 'boolean') {
1140
+ res.status(400).json({
1141
+ success: false,
1142
+ error: {
1143
+ type: 'invalid_request',
1144
+ message: 'enabled must be a boolean',
1145
+ hint: 'Pass enabled: true or enabled: false in the request body.',
1146
+ docs: 'https://webpeel.dev/docs/errors#invalid_request',
1147
+ },
1148
+ requestId: crypto.randomUUID(),
1149
+ });
1150
+ return;
1151
+ }
1152
+ await pool.query('UPDATE users SET extra_usage_enabled = $1, updated_at = now() WHERE id = $2', [enabled, userId]);
1153
+ res.json({
1154
+ success: true,
1155
+ enabled,
1156
+ });
1157
+ }
1158
+ catch (error) {
1159
+ console.error('Toggle extra usage error:', error);
1160
+ res.status(500).json({
1161
+ success: false,
1162
+ error: {
1163
+ type: 'toggle_failed',
1164
+ message: 'Failed to toggle extra usage',
1165
+ docs: 'https://webpeel.dev/docs/errors#toggle_failed',
1166
+ },
1167
+ requestId: crypto.randomUUID(),
1168
+ });
1169
+ }
1170
+ });
1171
+ /**
1172
+ * POST /v1/extra-usage/limit
1173
+ * Adjust spending limit
1174
+ */
1175
+ router.post('/v1/extra-usage/limit', jwtAuth, async (req, res) => {
1176
+ try {
1177
+ const { userId } = req.user;
1178
+ const { limit } = req.body;
1179
+ if (typeof limit !== 'number' || limit < 10 || limit > 500) {
1180
+ res.status(400).json({
1181
+ success: false,
1182
+ error: {
1183
+ type: 'invalid_limit',
1184
+ message: 'Limit must be a number between 10 and 500',
1185
+ hint: 'Pass a numeric limit between 10 and 500 in the request body.',
1186
+ docs: 'https://webpeel.dev/docs/errors#invalid_limit',
1187
+ },
1188
+ requestId: crypto.randomUUID(),
1189
+ });
1190
+ return;
1191
+ }
1192
+ await pool.query('UPDATE users SET extra_usage_spending_limit = $1, updated_at = now() WHERE id = $2', [limit, userId]);
1193
+ res.json({
1194
+ success: true,
1195
+ limit,
1196
+ });
1197
+ }
1198
+ catch (error) {
1199
+ console.error('Set limit error:', error);
1200
+ res.status(500).json({
1201
+ success: false,
1202
+ error: {
1203
+ type: 'limit_failed',
1204
+ message: 'Failed to set spending limit',
1205
+ docs: 'https://webpeel.dev/docs/errors#limit_failed',
1206
+ },
1207
+ requestId: crypto.randomUUID(),
1208
+ });
1209
+ }
1210
+ });
1211
+ /**
1212
+ * POST /v1/extra-usage/buy
1213
+ * Add to extra usage balance (future: Stripe checkout)
1214
+ */
1215
+ router.post('/v1/extra-usage/buy', jwtAuth, async (_req, res) => {
1216
+ // DISABLED: Stripe integration in progress
1217
+ res.status(501).json({
1218
+ success: false,
1219
+ error: {
1220
+ type: 'not_implemented',
1221
+ message: 'Extra usage purchases are available through our billing portal. Visit https://app.webpeel.dev/billing',
1222
+ hint: 'Visit https://app.webpeel.dev/billing to manage your usage.',
1223
+ docs: 'https://webpeel.dev/docs/errors#not_implemented',
1224
+ },
1225
+ requestId: crypto.randomUUID(),
1226
+ });
1227
+ });
1228
+ /**
1229
+ * PATCH /v1/user/profile
1230
+ * Update user profile (name, avatar)
1231
+ */
1232
+ router.patch('/v1/user/profile', jwtAuth, async (req, res) => {
1233
+ try {
1234
+ const { userId } = req.user;
1235
+ const { name, avatarUrl } = req.body;
1236
+ // Validate inputs
1237
+ if (name && typeof name !== 'string') {
1238
+ res.status(400).json({
1239
+ success: false,
1240
+ error: {
1241
+ type: 'invalid_name',
1242
+ message: 'Name must be a string',
1243
+ docs: 'https://webpeel.dev/docs/errors#invalid_name',
1244
+ },
1245
+ requestId: crypto.randomUUID(),
1246
+ });
1247
+ return;
1248
+ }
1249
+ if (name && name.length > 100) {
1250
+ res.status(400).json({
1251
+ success: false,
1252
+ error: {
1253
+ type: 'invalid_name',
1254
+ message: 'Name too long (max 100 characters)',
1255
+ docs: 'https://webpeel.dev/docs/errors#invalid_name',
1256
+ },
1257
+ requestId: crypto.randomUUID(),
1258
+ });
1259
+ return;
1260
+ }
1261
+ if (avatarUrl && typeof avatarUrl !== 'string') {
1262
+ res.status(400).json({
1263
+ success: false,
1264
+ error: {
1265
+ type: 'invalid_avatar',
1266
+ message: 'Avatar URL must be a string',
1267
+ docs: 'https://webpeel.dev/docs/errors#invalid_avatar',
1268
+ },
1269
+ requestId: crypto.randomUUID(),
1270
+ });
1271
+ return;
1272
+ }
1273
+ if (avatarUrl && avatarUrl.length > 500) {
1274
+ res.status(400).json({
1275
+ success: false,
1276
+ error: {
1277
+ type: 'invalid_avatar',
1278
+ message: 'Avatar URL too long (max 500 characters)',
1279
+ docs: 'https://webpeel.dev/docs/errors#invalid_avatar',
1280
+ },
1281
+ requestId: crypto.randomUUID(),
1282
+ });
1283
+ return;
1284
+ }
1285
+ if (avatarUrl) {
1286
+ try {
1287
+ const parsed = new URL(avatarUrl);
1288
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
1289
+ res.status(400).json({
1290
+ success: false,
1291
+ error: {
1292
+ type: 'invalid_avatar',
1293
+ message: 'Avatar URL must use http or https protocol',
1294
+ docs: 'https://webpeel.dev/docs/errors#invalid_avatar',
1295
+ },
1296
+ requestId: crypto.randomUUID(),
1297
+ });
1298
+ return;
1299
+ }
1300
+ }
1301
+ catch {
1302
+ res.status(400).json({
1303
+ success: false,
1304
+ error: {
1305
+ type: 'invalid_avatar',
1306
+ message: 'Avatar URL must be a valid URL',
1307
+ hint: 'Provide a fully-qualified URL starting with https://',
1308
+ docs: 'https://webpeel.dev/docs/errors#invalid_avatar',
1309
+ },
1310
+ requestId: crypto.randomUUID(),
1311
+ });
1312
+ return;
1313
+ }
1314
+ }
1315
+ // Build update query dynamically
1316
+ const updates = [];
1317
+ const values = [];
1318
+ let paramIndex = 1;
1319
+ if (name !== undefined) {
1320
+ updates.push(`name = $${paramIndex++}`);
1321
+ values.push(name);
1322
+ }
1323
+ if (avatarUrl !== undefined) {
1324
+ updates.push(`avatar_url = $${paramIndex++}`);
1325
+ values.push(avatarUrl);
1326
+ }
1327
+ if (updates.length === 0) {
1328
+ res.status(400).json({
1329
+ success: false,
1330
+ error: {
1331
+ type: 'no_updates',
1332
+ message: 'No fields to update',
1333
+ hint: 'Provide at least one of: name, avatarUrl.',
1334
+ docs: 'https://webpeel.dev/docs/errors#no_updates',
1335
+ },
1336
+ requestId: crypto.randomUUID(),
1337
+ });
1338
+ return;
1339
+ }
1340
+ updates.push(`updated_at = now()`);
1341
+ values.push(userId);
1342
+ const result = await pool.query(`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING id, email, name, avatar_url`, values);
1343
+ if (result.rows.length === 0) {
1344
+ res.status(404).json({
1345
+ success: false,
1346
+ error: {
1347
+ type: 'user_not_found',
1348
+ message: 'User not found',
1349
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
1350
+ },
1351
+ requestId: crypto.randomUUID(),
1352
+ });
1353
+ return;
1354
+ }
1355
+ res.json({
1356
+ success: true,
1357
+ user: {
1358
+ id: result.rows[0].id,
1359
+ email: result.rows[0].email,
1360
+ name: result.rows[0].name,
1361
+ avatar: result.rows[0].avatar_url,
1362
+ },
1363
+ });
1364
+ }
1365
+ catch (error) {
1366
+ console.error('Update profile error:', error);
1367
+ res.status(500).json({
1368
+ success: false,
1369
+ error: {
1370
+ type: 'update_failed',
1371
+ message: 'Failed to update profile',
1372
+ docs: 'https://webpeel.dev/docs/errors#update_failed',
1373
+ },
1374
+ requestId: crypto.randomUUID(),
1375
+ });
1376
+ }
1377
+ });
1378
+ /**
1379
+ * PATCH /v1/user/password
1380
+ * Change password (verify current, hash new)
1381
+ */
1382
+ router.patch('/v1/user/password', jwtAuth, async (req, res) => {
1383
+ try {
1384
+ const { userId } = req.user;
1385
+ const { currentPassword, newPassword } = req.body;
1386
+ if (!currentPassword || !newPassword) {
1387
+ res.status(400).json({
1388
+ success: false,
1389
+ error: {
1390
+ type: 'missing_fields',
1391
+ message: 'Current and new passwords are required',
1392
+ hint: 'Provide both currentPassword and newPassword in the request body.',
1393
+ docs: 'https://webpeel.dev/docs/errors#missing_fields',
1394
+ },
1395
+ requestId: crypto.randomUUID(),
1396
+ });
1397
+ return;
1398
+ }
1399
+ if (!isValidPassword(newPassword)) {
1400
+ res.status(400).json({
1401
+ success: false,
1402
+ error: {
1403
+ type: 'weak_password',
1404
+ message: 'Password must be at least 8 characters',
1405
+ hint: 'Choose a password with at least 8 characters.',
1406
+ docs: 'https://webpeel.dev/docs/errors#weak_password',
1407
+ },
1408
+ requestId: crypto.randomUUID(),
1409
+ });
1410
+ return;
1411
+ }
1412
+ // Get current password hash
1413
+ const userResult = await pool.query('SELECT password_hash FROM users WHERE id = $1', [userId]);
1414
+ if (userResult.rows.length === 0) {
1415
+ res.status(404).json({
1416
+ success: false,
1417
+ error: {
1418
+ type: 'user_not_found',
1419
+ message: 'User not found',
1420
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
1421
+ },
1422
+ requestId: crypto.randomUUID(),
1423
+ });
1424
+ return;
1425
+ }
1426
+ // OAuth users don't have passwords
1427
+ if (!userResult.rows[0].password_hash) {
1428
+ res.status(400).json({
1429
+ success: false,
1430
+ error: {
1431
+ type: 'oauth_user',
1432
+ message: 'OAuth users cannot set passwords. Please use your OAuth provider to manage your account.',
1433
+ hint: 'Manage your account through your OAuth provider (e.g. Google, GitHub).',
1434
+ docs: 'https://webpeel.dev/docs/errors#oauth_user',
1435
+ },
1436
+ requestId: crypto.randomUUID(),
1437
+ });
1438
+ return;
1439
+ }
1440
+ // Verify current password
1441
+ const passwordValid = await bcrypt.compare(currentPassword, userResult.rows[0].password_hash);
1442
+ if (!passwordValid) {
1443
+ res.status(401).json({
1444
+ success: false,
1445
+ error: {
1446
+ type: 'invalid_password',
1447
+ message: 'Current password is incorrect',
1448
+ hint: 'Double-check your current password and try again.',
1449
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
1450
+ },
1451
+ requestId: crypto.randomUUID(),
1452
+ });
1453
+ return;
1454
+ }
1455
+ // Hash new password
1456
+ const newPasswordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
1457
+ // Update password
1458
+ await pool.query('UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2', [newPasswordHash, userId]);
1459
+ res.json({ success: true, message: 'Password updated successfully' });
1460
+ }
1461
+ catch (error) {
1462
+ console.error('Change password error:', error);
1463
+ res.status(500).json({
1464
+ success: false,
1465
+ error: {
1466
+ type: 'update_failed',
1467
+ message: 'Failed to change password',
1468
+ docs: 'https://webpeel.dev/docs/errors#update_failed',
1469
+ },
1470
+ requestId: crypto.randomUUID(),
1471
+ });
1472
+ }
1473
+ });
1474
+ /**
1475
+ * DELETE /v1/user/account
1476
+ * Delete account + cascade to api_keys, oauth_accounts
1477
+ */
1478
+ router.delete('/v1/user/account', jwtAuth, async (req, res) => {
1479
+ try {
1480
+ const { userId } = req.user;
1481
+ const { password, confirmEmail } = req.body;
1482
+ // Get user info
1483
+ const userResult = await pool.query('SELECT email, password_hash FROM users WHERE id = $1', [userId]);
1484
+ if (userResult.rows.length === 0) {
1485
+ res.status(404).json({
1486
+ success: false,
1487
+ error: {
1488
+ type: 'user_not_found',
1489
+ message: 'User not found',
1490
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
1491
+ },
1492
+ requestId: crypto.randomUUID(),
1493
+ });
1494
+ return;
1495
+ }
1496
+ const user = userResult.rows[0];
1497
+ // Verify email confirmation
1498
+ if (confirmEmail !== user.email) {
1499
+ res.status(400).json({
1500
+ success: false,
1501
+ error: {
1502
+ type: 'email_mismatch',
1503
+ message: 'Email confirmation does not match account email',
1504
+ hint: 'Provide your exact account email in the confirmEmail field.',
1505
+ docs: 'https://webpeel.dev/docs/errors#email_mismatch',
1506
+ },
1507
+ requestId: crypto.randomUUID(),
1508
+ });
1509
+ return;
1510
+ }
1511
+ // Verify password (if user has one - OAuth users might not)
1512
+ if (user.password_hash) {
1513
+ if (!password) {
1514
+ res.status(400).json({
1515
+ success: false,
1516
+ error: {
1517
+ type: 'missing_password',
1518
+ message: 'Password is required',
1519
+ hint: 'Provide your account password to confirm account deletion.',
1520
+ docs: 'https://webpeel.dev/docs/errors#missing_password',
1521
+ },
1522
+ requestId: crypto.randomUUID(),
1523
+ });
1524
+ return;
1525
+ }
1526
+ const passwordValid = await bcrypt.compare(password, user.password_hash);
1527
+ if (!passwordValid) {
1528
+ res.status(401).json({
1529
+ success: false,
1530
+ error: {
1531
+ type: 'invalid_password',
1532
+ message: 'Password is incorrect',
1533
+ hint: 'Double-check your password and try again.',
1534
+ docs: 'https://webpeel.dev/docs/errors#unauthorized',
1535
+ },
1536
+ requestId: crypto.randomUUID(),
1537
+ });
1538
+ return;
1539
+ }
1540
+ }
1541
+ // Delete user and all related data in a transaction
1542
+ const client = await pool.connect();
1543
+ try {
1544
+ await client.query('BEGIN');
1545
+ await client.query('DELETE FROM api_keys WHERE user_id = $1', [userId]);
1546
+ await client.query('DELETE FROM oauth_accounts WHERE user_id = $1', [userId]);
1547
+ await client.query('DELETE FROM users WHERE id = $1', [userId]);
1548
+ await client.query('COMMIT');
1549
+ }
1550
+ catch (txError) {
1551
+ await client.query('ROLLBACK');
1552
+ throw txError;
1553
+ }
1554
+ finally {
1555
+ client.release();
1556
+ }
1557
+ res.json({
1558
+ success: true,
1559
+ message: 'Account deleted successfully. We\'re sorry to see you go!'
1560
+ });
1561
+ }
1562
+ catch (error) {
1563
+ console.error('Delete account error:', error);
1564
+ res.status(500).json({
1565
+ success: false,
1566
+ error: {
1567
+ type: 'delete_failed',
1568
+ message: 'Failed to delete account',
1569
+ docs: 'https://webpeel.dev/docs/errors#delete_failed',
1570
+ },
1571
+ requestId: crypto.randomUUID(),
1572
+ });
1573
+ }
1574
+ });
1575
+ /**
1576
+ * GET /v1/user/alert-preferences
1577
+ * Returns current alert threshold and email
1578
+ */
1579
+ router.get('/v1/user/alert-preferences', jwtAuth, async (req, res) => {
1580
+ try {
1581
+ const { userId } = req.user;
1582
+ const result = await pool.query('SELECT alert_threshold, alert_email FROM users WHERE id = $1', [userId]);
1583
+ if (result.rows.length === 0) {
1584
+ res.status(404).json({
1585
+ success: false,
1586
+ error: {
1587
+ type: 'user_not_found',
1588
+ message: 'User not found',
1589
+ docs: 'https://webpeel.dev/docs/errors#user_not_found',
1590
+ },
1591
+ requestId: crypto.randomUUID(),
1592
+ });
1593
+ return;
1594
+ }
1595
+ res.json({
1596
+ threshold: result.rows[0].alert_threshold,
1597
+ email: result.rows[0].alert_email,
1598
+ });
1599
+ }
1600
+ catch (error) {
1601
+ console.error('Get alert preferences error:', error);
1602
+ res.status(500).json({
1603
+ success: false,
1604
+ error: {
1605
+ type: 'get_prefs_failed',
1606
+ message: 'Failed to get alert preferences',
1607
+ docs: 'https://webpeel.dev/docs/errors#get_prefs_failed',
1608
+ },
1609
+ requestId: crypto.randomUUID(),
1610
+ });
1611
+ }
1612
+ });
1613
+ /**
1614
+ * PUT /v1/user/alert-preferences
1615
+ * Save alert threshold and/or email
1616
+ * Body: { threshold: number | null, email: string | null }
1617
+ */
1618
+ router.put('/v1/user/alert-preferences', jwtAuth, async (req, res) => {
1619
+ try {
1620
+ const { userId } = req.user;
1621
+ const { threshold, email } = req.body;
1622
+ // Validate threshold: must be null or a number between 1 and 100
1623
+ if (threshold !== null && threshold !== undefined) {
1624
+ if (typeof threshold !== 'number' || threshold < 1 || threshold > 100) {
1625
+ res.status(400).json({
1626
+ success: false,
1627
+ error: {
1628
+ type: 'invalid_threshold',
1629
+ message: 'Threshold must be a number between 1 and 100, or null to disable',
1630
+ hint: 'Pass a number between 1 and 100, or null to disable alerts.',
1631
+ docs: 'https://webpeel.dev/docs/errors#invalid_threshold',
1632
+ },
1633
+ requestId: crypto.randomUUID(),
1634
+ });
1635
+ return;
1636
+ }
1637
+ }
1638
+ // Validate email if provided
1639
+ if (email !== null && email !== undefined) {
1640
+ if (typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
1641
+ res.status(400).json({
1642
+ success: false,
1643
+ error: {
1644
+ type: 'invalid_email',
1645
+ message: 'Email must be a valid email address, or null to use account email',
1646
+ hint: 'Provide a valid email address (e.g. user@example.com), or null.',
1647
+ docs: 'https://webpeel.dev/docs/errors#invalid_email',
1648
+ },
1649
+ requestId: crypto.randomUUID(),
1650
+ });
1651
+ return;
1652
+ }
1653
+ }
1654
+ await pool.query('UPDATE users SET alert_threshold = $1, alert_email = $2, updated_at = now() WHERE id = $3', [threshold ?? null, email ?? null, userId]);
1655
+ res.json({ success: true });
1656
+ }
1657
+ catch (error) {
1658
+ console.error('Save alert preferences error:', error);
1659
+ res.status(500).json({
1660
+ success: false,
1661
+ error: {
1662
+ type: 'save_prefs_failed',
1663
+ message: 'Failed to save alert preferences',
1664
+ docs: 'https://webpeel.dev/docs/errors#save_prefs_failed',
1665
+ },
1666
+ requestId: crypto.randomUUID(),
1667
+ });
1668
+ }
1669
+ });
1670
+ return router;
1671
+ }