webpeel 0.12.0 → 0.12.2

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 (148) hide show
  1. package/README.md +82 -9
  2. package/dist/cli.js +97 -6
  3. package/dist/cli.js.map +1 -1
  4. package/dist/core/actions.d.ts +28 -0
  5. package/dist/core/actions.d.ts.map +1 -1
  6. package/dist/core/actions.js +60 -0
  7. package/dist/core/actions.js.map +1 -1
  8. package/dist/core/bm25-filter.d.ts +10 -0
  9. package/dist/core/bm25-filter.d.ts.map +1 -1
  10. package/dist/core/bm25-filter.js +40 -0
  11. package/dist/core/bm25-filter.js.map +1 -1
  12. package/dist/core/content-pruner.d.ts +12 -5
  13. package/dist/core/content-pruner.d.ts.map +1 -1
  14. package/dist/core/content-pruner.js +247 -190
  15. package/dist/core/content-pruner.js.map +1 -1
  16. package/dist/core/research.d.ts +67 -0
  17. package/dist/core/research.d.ts.map +1 -0
  18. package/dist/core/research.js +254 -0
  19. package/dist/core/research.js.map +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +37 -3
  22. package/dist/index.js.map +1 -1
  23. package/dist/mcp/server.js +107 -2
  24. package/dist/mcp/server.js.map +1 -1
  25. package/dist/server/app.d.ts +14 -0
  26. package/dist/server/app.d.ts.map +1 -0
  27. package/dist/server/app.js +189 -0
  28. package/dist/server/app.js.map +1 -0
  29. package/dist/server/auth-store.d.ts +28 -0
  30. package/dist/server/auth-store.d.ts.map +1 -0
  31. package/dist/server/auth-store.js +89 -0
  32. package/dist/server/auth-store.js.map +1 -0
  33. package/dist/server/job-queue.d.ts +93 -0
  34. package/dist/server/job-queue.d.ts.map +1 -0
  35. package/dist/server/job-queue.js +144 -0
  36. package/dist/server/job-queue.js.map +1 -0
  37. package/dist/server/middleware/auth.d.ts +28 -0
  38. package/dist/server/middleware/auth.d.ts.map +1 -0
  39. package/dist/server/middleware/auth.js +183 -0
  40. package/dist/server/middleware/auth.js.map +1 -0
  41. package/dist/server/middleware/rate-limit.d.ts +23 -0
  42. package/dist/server/middleware/rate-limit.d.ts.map +1 -0
  43. package/dist/server/middleware/rate-limit.js +126 -0
  44. package/dist/server/middleware/rate-limit.js.map +1 -0
  45. package/dist/server/middleware/url-validator.d.ts +16 -0
  46. package/dist/server/middleware/url-validator.d.ts.map +1 -0
  47. package/dist/server/middleware/url-validator.js +187 -0
  48. package/dist/server/middleware/url-validator.js.map +1 -0
  49. package/dist/server/pg-auth-store.d.ts +129 -0
  50. package/dist/server/pg-auth-store.d.ts.map +1 -0
  51. package/dist/server/pg-auth-store.js +457 -0
  52. package/dist/server/pg-auth-store.js.map +1 -0
  53. package/dist/server/pg-job-queue.d.ts +60 -0
  54. package/dist/server/pg-job-queue.d.ts.map +1 -0
  55. package/dist/server/pg-job-queue.js +365 -0
  56. package/dist/server/pg-job-queue.js.map +1 -0
  57. package/dist/server/premium/domain-intel.d.ts +17 -0
  58. package/dist/server/premium/domain-intel.d.ts.map +1 -0
  59. package/dist/server/premium/domain-intel.js +134 -0
  60. package/dist/server/premium/domain-intel.js.map +1 -0
  61. package/dist/server/premium/index.d.ts +18 -0
  62. package/dist/server/premium/index.d.ts.map +1 -0
  63. package/dist/server/premium/index.js +36 -0
  64. package/dist/server/premium/index.js.map +1 -0
  65. package/dist/server/premium/swr-cache.d.ts +15 -0
  66. package/dist/server/premium/swr-cache.d.ts.map +1 -0
  67. package/dist/server/premium/swr-cache.js +35 -0
  68. package/dist/server/premium/swr-cache.js.map +1 -0
  69. package/dist/server/routes/activity.d.ts +7 -0
  70. package/dist/server/routes/activity.d.ts.map +1 -0
  71. package/dist/server/routes/activity.js +66 -0
  72. package/dist/server/routes/activity.js.map +1 -0
  73. package/dist/server/routes/agent.d.ts +12 -0
  74. package/dist/server/routes/agent.d.ts.map +1 -0
  75. package/dist/server/routes/agent.js +356 -0
  76. package/dist/server/routes/agent.js.map +1 -0
  77. package/dist/server/routes/answer.d.ts +6 -0
  78. package/dist/server/routes/answer.d.ts.map +1 -0
  79. package/dist/server/routes/answer.js +124 -0
  80. package/dist/server/routes/answer.js.map +1 -0
  81. package/dist/server/routes/batch.d.ts +7 -0
  82. package/dist/server/routes/batch.d.ts.map +1 -0
  83. package/dist/server/routes/batch.js +287 -0
  84. package/dist/server/routes/batch.js.map +1 -0
  85. package/dist/server/routes/cli-usage.d.ts +7 -0
  86. package/dist/server/routes/cli-usage.d.ts.map +1 -0
  87. package/dist/server/routes/cli-usage.js +121 -0
  88. package/dist/server/routes/cli-usage.js.map +1 -0
  89. package/dist/server/routes/compat.d.ts +24 -0
  90. package/dist/server/routes/compat.d.ts.map +1 -0
  91. package/dist/server/routes/compat.js +651 -0
  92. package/dist/server/routes/compat.js.map +1 -0
  93. package/dist/server/routes/extract.d.ts +9 -0
  94. package/dist/server/routes/extract.d.ts.map +1 -0
  95. package/dist/server/routes/extract.js +121 -0
  96. package/dist/server/routes/extract.js.map +1 -0
  97. package/dist/server/routes/fetch.d.ts +7 -0
  98. package/dist/server/routes/fetch.d.ts.map +1 -0
  99. package/dist/server/routes/fetch.js +537 -0
  100. package/dist/server/routes/fetch.js.map +1 -0
  101. package/dist/server/routes/health.d.ts +8 -0
  102. package/dist/server/routes/health.d.ts.map +1 -0
  103. package/dist/server/routes/health.js +36 -0
  104. package/dist/server/routes/health.js.map +1 -0
  105. package/dist/server/routes/jobs.d.ts +8 -0
  106. package/dist/server/routes/jobs.d.ts.map +1 -0
  107. package/dist/server/routes/jobs.js +374 -0
  108. package/dist/server/routes/jobs.js.map +1 -0
  109. package/dist/server/routes/mcp.d.ts +16 -0
  110. package/dist/server/routes/mcp.d.ts.map +1 -0
  111. package/dist/server/routes/mcp.js +475 -0
  112. package/dist/server/routes/mcp.js.map +1 -0
  113. package/dist/server/routes/oauth.d.ts +10 -0
  114. package/dist/server/routes/oauth.d.ts.map +1 -0
  115. package/dist/server/routes/oauth.js +296 -0
  116. package/dist/server/routes/oauth.js.map +1 -0
  117. package/dist/server/routes/screenshot.d.ts +10 -0
  118. package/dist/server/routes/screenshot.d.ts.map +1 -0
  119. package/dist/server/routes/screenshot.js +217 -0
  120. package/dist/server/routes/screenshot.js.map +1 -0
  121. package/dist/server/routes/search.d.ts +7 -0
  122. package/dist/server/routes/search.d.ts.map +1 -0
  123. package/dist/server/routes/search.js +287 -0
  124. package/dist/server/routes/search.js.map +1 -0
  125. package/dist/server/routes/stats.d.ts +7 -0
  126. package/dist/server/routes/stats.d.ts.map +1 -0
  127. package/dist/server/routes/stats.js +65 -0
  128. package/dist/server/routes/stats.js.map +1 -0
  129. package/dist/server/routes/stripe.d.ts +9 -0
  130. package/dist/server/routes/stripe.d.ts.map +1 -0
  131. package/dist/server/routes/stripe.js +233 -0
  132. package/dist/server/routes/stripe.js.map +1 -0
  133. package/dist/server/routes/users.d.ts +9 -0
  134. package/dist/server/routes/users.d.ts.map +1 -0
  135. package/dist/server/routes/users.js +954 -0
  136. package/dist/server/routes/users.js.map +1 -0
  137. package/dist/server/routes/webhooks.d.ts +15 -0
  138. package/dist/server/routes/webhooks.d.ts.map +1 -0
  139. package/dist/server/routes/webhooks.js +73 -0
  140. package/dist/server/routes/webhooks.js.map +1 -0
  141. package/dist/server/sentry.d.ts +14 -0
  142. package/dist/server/sentry.d.ts.map +1 -0
  143. package/dist/server/sentry.js +39 -0
  144. package/dist/server/sentry.js.map +1 -0
  145. package/dist/types.d.ts +13 -0
  146. package/dist/types.d.ts.map +1 -1
  147. package/dist/types.js.map +1 -1
  148. package/package.json +3 -2
@@ -0,0 +1,954 @@
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
+ error: 'too_many_attempts',
37
+ message: 'Too many login attempts. Please try again in 15 minutes.',
38
+ retryAfter: Math.ceil((attempt.resetAt - now) / 1000),
39
+ });
40
+ return;
41
+ }
42
+ attempt.count++;
43
+ }
44
+ else {
45
+ loginAttempts.set(email, { count: 1, resetAt: now + 15 * 60 * 1000 });
46
+ }
47
+ next();
48
+ }
49
+ /**
50
+ * Per-IP rate limiter for refresh endpoint (brute-force protection)
51
+ */
52
+ const refreshAttempts = new Map();
53
+ setInterval(() => {
54
+ const now = Date.now();
55
+ for (const [key, attempt] of refreshAttempts.entries()) {
56
+ if (now >= attempt.resetAt) {
57
+ refreshAttempts.delete(key);
58
+ }
59
+ }
60
+ }, 15 * 60 * 1000);
61
+ function refreshRateLimiter(req, res, next) {
62
+ const ip = req.headers['cf-connecting-ip'] ||
63
+ req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
64
+ req.ip ||
65
+ 'unknown';
66
+ const now = Date.now();
67
+ const attempt = refreshAttempts.get(ip);
68
+ if (attempt && now < attempt.resetAt) {
69
+ if (attempt.count >= 10) {
70
+ res.status(429).json({
71
+ error: 'too_many_attempts',
72
+ message: 'Too many refresh attempts. Please try again in 15 minutes.',
73
+ retryAfter: Math.ceil((attempt.resetAt - now) / 1000),
74
+ });
75
+ return;
76
+ }
77
+ attempt.count++;
78
+ }
79
+ else {
80
+ refreshAttempts.set(ip, { count: 1, resetAt: now + 15 * 60 * 1000 });
81
+ }
82
+ next();
83
+ }
84
+ /**
85
+ * Validate email format
86
+ */
87
+ function isValidEmail(email) {
88
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
89
+ return emailRegex.test(email);
90
+ }
91
+ /**
92
+ * Validate password strength
93
+ */
94
+ function isValidPassword(password) {
95
+ // bcrypt silently truncates at 72 bytes — enforce a max to prevent confusion
96
+ return password.length >= 8 && password.length <= 128;
97
+ }
98
+ /**
99
+ * JWT authentication middleware
100
+ */
101
+ function jwtAuth(req, res, next) {
102
+ try {
103
+ const authHeader = req.headers.authorization;
104
+ if (!authHeader?.startsWith('Bearer ')) {
105
+ res.status(401).json({
106
+ error: 'missing_token',
107
+ message: 'JWT token required. Provide via Authorization: Bearer <token>',
108
+ });
109
+ return;
110
+ }
111
+ const token = authHeader.slice(7);
112
+ const jwtSecret = process.env.JWT_SECRET;
113
+ if (!jwtSecret) {
114
+ throw new Error('JWT_SECRET environment variable not configured');
115
+ }
116
+ const payload = jwt.verify(token, jwtSecret);
117
+ // Attach user info to request
118
+ req.user = payload;
119
+ next();
120
+ }
121
+ catch (error) {
122
+ if (error instanceof jwt.JsonWebTokenError) {
123
+ res.status(401).json({
124
+ error: 'invalid_token',
125
+ message: 'Invalid or expired JWT token',
126
+ });
127
+ return;
128
+ }
129
+ res.status(500).json({
130
+ error: 'auth_error',
131
+ message: 'Authentication failed',
132
+ });
133
+ }
134
+ }
135
+ /**
136
+ * Create user routes
137
+ */
138
+ export function createUserRouter() {
139
+ const router = Router();
140
+ const dbUrl = process.env.DATABASE_URL;
141
+ if (!dbUrl) {
142
+ throw new Error('DATABASE_URL environment variable is required');
143
+ }
144
+ const pool = new Pool({
145
+ connectionString: dbUrl,
146
+ // TLS: enabled when DATABASE_URL contains sslmode=require.
147
+ // Secure by default (rejectUnauthorized: true); set PG_REJECT_UNAUTHORIZED=false
148
+ // only for managed DBs (Render/Neon/Supabase) that use self-signed certs.
149
+ ssl: process.env.DATABASE_URL?.includes('sslmode=require')
150
+ ? { rejectUnauthorized: process.env.PG_REJECT_UNAUTHORIZED !== 'false' }
151
+ : undefined,
152
+ });
153
+ // Initialize refresh_tokens table on startup
154
+ pool.query(`
155
+ CREATE TABLE IF NOT EXISTS refresh_tokens (
156
+ id TEXT PRIMARY KEY,
157
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
158
+ expires_at TIMESTAMPTZ NOT NULL,
159
+ revoked_at TIMESTAMPTZ,
160
+ created_at TIMESTAMPTZ DEFAULT NOW()
161
+ )
162
+ `).then(() => pool.query(`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)`)).catch((err) => {
163
+ console.error('Failed to initialize refresh_tokens table:', err);
164
+ });
165
+ /**
166
+ * Helper: generate a refresh token and store its jti in the database
167
+ */
168
+ async function createRefreshToken(userId, jwtSecret) {
169
+ const jti = crypto.randomUUID();
170
+ const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
171
+ await pool.query(`INSERT INTO refresh_tokens (id, user_id, expires_at) VALUES ($1, $2, $3)`, [jti, userId, expiresAt]);
172
+ return jwt.sign({ userId, jti }, jwtSecret, { expiresIn: '30d' });
173
+ }
174
+ /**
175
+ * POST /v1/auth/register
176
+ * Register a new user and create their first API key
177
+ */
178
+ router.post('/v1/auth/register', async (req, res) => {
179
+ try {
180
+ const { email, password } = req.body;
181
+ // Input validation
182
+ if (!email || !password) {
183
+ res.status(400).json({
184
+ error: 'missing_fields',
185
+ message: 'Email and password are required',
186
+ });
187
+ return;
188
+ }
189
+ if (!isValidEmail(email)) {
190
+ res.status(400).json({
191
+ error: 'invalid_email',
192
+ message: 'Invalid email format',
193
+ });
194
+ return;
195
+ }
196
+ if (!isValidPassword(password)) {
197
+ res.status(400).json({
198
+ error: 'weak_password',
199
+ message: 'Password must be at least 8 characters',
200
+ });
201
+ return;
202
+ }
203
+ // Hash password
204
+ const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
205
+ // Create user
206
+ const userResult = await pool.query(`INSERT INTO users (email, password_hash, tier, weekly_limit, burst_limit, rate_limit)
207
+ VALUES ($1, $2, 'free', 125, 25, 10)
208
+ RETURNING id, email, tier, weekly_limit, burst_limit, rate_limit, created_at`, [email, passwordHash]);
209
+ const user = userResult.rows[0];
210
+ // Generate API key
211
+ const apiKey = PostgresAuthStore.generateApiKey();
212
+ const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
213
+ const keyPrefix = PostgresAuthStore.getKeyPrefix(apiKey);
214
+ // Store API key
215
+ await pool.query(`INSERT INTO api_keys (user_id, key_hash, key_prefix, name)
216
+ VALUES ($1, $2, $3, 'Default')`, [user.id, keyHash, keyPrefix]);
217
+ const signupTimestamp = new Date().toISOString();
218
+ res.status(201).json({
219
+ user: {
220
+ id: user.id,
221
+ email: user.email,
222
+ tier: user.tier,
223
+ weeklyLimit: user.weekly_limit,
224
+ burstLimit: user.burst_limit,
225
+ rateLimit: user.rate_limit,
226
+ createdAt: user.created_at,
227
+ },
228
+ apiKey, // SECURITY: Only returned once, never stored or shown again
229
+ });
230
+ // Fire-and-forget Discord webhook for successful signups; never block registration on webhook errors.
231
+ try {
232
+ const webhookUrl = process.env.DISCORD_SIGNUP_WEBHOOK;
233
+ if (webhookUrl) {
234
+ void fetch(webhookUrl, {
235
+ method: 'POST',
236
+ headers: { 'Content-Type': 'application/json' },
237
+ body: JSON.stringify({
238
+ embeds: [{
239
+ title: '🎉 New Signup',
240
+ color: 9133302,
241
+ fields: [
242
+ { name: 'Email', value: email, inline: true },
243
+ { name: 'Tier', value: 'Free', inline: true },
244
+ { name: 'Timestamp', value: signupTimestamp, inline: false },
245
+ ],
246
+ timestamp: signupTimestamp,
247
+ footer: { text: 'WebPeel Signups' },
248
+ }],
249
+ }),
250
+ }).catch(() => { });
251
+ }
252
+ }
253
+ catch {
254
+ // Intentionally swallow webhook failures.
255
+ }
256
+ }
257
+ catch (error) {
258
+ if (error.code === '23505') { // Unique violation
259
+ res.status(409).json({
260
+ error: 'email_exists',
261
+ message: 'Email already registered',
262
+ });
263
+ return;
264
+ }
265
+ console.error('Registration error:', error);
266
+ res.status(500).json({
267
+ error: 'registration_failed',
268
+ message: 'Failed to register user',
269
+ });
270
+ }
271
+ });
272
+ /**
273
+ * POST /v1/auth/login
274
+ * Login with email/password and get JWT token
275
+ */
276
+ router.post('/v1/auth/login', loginRateLimiter, async (req, res) => {
277
+ try {
278
+ const { email, password } = req.body;
279
+ if (!email || !password) {
280
+ res.status(400).json({
281
+ error: 'missing_fields',
282
+ message: 'Email and password are required',
283
+ });
284
+ return;
285
+ }
286
+ // Get user
287
+ const result = await pool.query('SELECT id, email, password_hash, tier FROM users WHERE email = $1', [email]);
288
+ // Constant-time auth: always run bcrypt.compare to prevent timing oracle
289
+ // (prevents user enumeration via response time differences)
290
+ const DUMMY_HASH = '$2b$12$LJ7F3mGTqKmEqFv5GsNXxeIkYwJwgJkOqSvKqGqKqGqKqGqKqGqKq';
291
+ const user = result.rows[0];
292
+ const hashToCompare = user?.password_hash ?? DUMMY_HASH;
293
+ const passwordValid = await bcrypt.compare(password, hashToCompare);
294
+ if (!user || !passwordValid) {
295
+ res.status(401).json({
296
+ error: 'invalid_credentials',
297
+ message: 'Invalid email or password',
298
+ });
299
+ return;
300
+ }
301
+ // Generate JWT
302
+ const jwtSecret = process.env.JWT_SECRET;
303
+ if (!jwtSecret) {
304
+ throw new Error('JWT_SECRET not configured');
305
+ }
306
+ const token = jwt.sign({
307
+ userId: user.id,
308
+ email: user.email,
309
+ tier: user.tier,
310
+ }, jwtSecret, { expiresIn: '1h' });
311
+ const refreshToken = await createRefreshToken(user.id, jwtSecret);
312
+ res.json({
313
+ token,
314
+ refreshToken,
315
+ expiresIn: 3600,
316
+ user: {
317
+ id: user.id,
318
+ email: user.email,
319
+ tier: user.tier,
320
+ },
321
+ });
322
+ }
323
+ catch (error) {
324
+ console.error('Login error:', error);
325
+ res.status(500).json({
326
+ error: 'login_failed',
327
+ message: 'Failed to login',
328
+ });
329
+ }
330
+ });
331
+ /**
332
+ * POST /v1/auth/refresh
333
+ * Exchange a valid refresh token for a new access token + refresh token
334
+ */
335
+ router.post('/v1/auth/refresh', refreshRateLimiter, async (req, res) => {
336
+ try {
337
+ const { refreshToken } = req.body;
338
+ if (!refreshToken) {
339
+ res.status(400).json({
340
+ error: 'missing_token',
341
+ message: 'refreshToken is required',
342
+ });
343
+ return;
344
+ }
345
+ const jwtSecret = process.env.JWT_SECRET;
346
+ if (!jwtSecret) {
347
+ throw new Error('JWT_SECRET not configured');
348
+ }
349
+ // Verify JWT signature + expiry
350
+ let payload;
351
+ try {
352
+ payload = jwt.verify(refreshToken, jwtSecret);
353
+ }
354
+ catch {
355
+ res.status(401).json({
356
+ error: 'invalid_token',
357
+ message: 'Invalid or expired refresh token',
358
+ });
359
+ return;
360
+ }
361
+ // Check token is not revoked and still exists
362
+ const tokenResult = await pool.query(`SELECT id, user_id, revoked_at FROM refresh_tokens WHERE id = $1`, [payload.jti]);
363
+ if (tokenResult.rows.length === 0 || tokenResult.rows[0].revoked_at !== null) {
364
+ res.status(401).json({
365
+ error: 'token_revoked',
366
+ message: 'Refresh token has been revoked',
367
+ });
368
+ return;
369
+ }
370
+ // Get current user info (tier may have changed)
371
+ const userResult = await pool.query('SELECT id, email, tier FROM users WHERE id = $1', [payload.userId]);
372
+ if (userResult.rows.length === 0) {
373
+ res.status(401).json({
374
+ error: 'user_not_found',
375
+ message: 'User no longer exists',
376
+ });
377
+ return;
378
+ }
379
+ const user = userResult.rows[0];
380
+ // Revoke old refresh token (rotate tokens)
381
+ await pool.query(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1`, [payload.jti]);
382
+ // Issue new access token (1h) + new refresh token (30d)
383
+ const newToken = jwt.sign({
384
+ userId: user.id,
385
+ email: user.email,
386
+ tier: user.tier,
387
+ }, jwtSecret, { expiresIn: '1h' });
388
+ const newRefreshToken = await createRefreshToken(user.id, jwtSecret);
389
+ res.json({
390
+ token: newToken,
391
+ refreshToken: newRefreshToken,
392
+ expiresIn: 3600,
393
+ });
394
+ }
395
+ catch (error) {
396
+ console.error('Refresh token error:', error);
397
+ res.status(500).json({
398
+ error: 'refresh_failed',
399
+ message: 'Failed to refresh token',
400
+ });
401
+ }
402
+ });
403
+ /**
404
+ * POST /v1/auth/revoke
405
+ * Revoke all refresh tokens for the current user (logout all devices)
406
+ */
407
+ router.post('/v1/auth/revoke', jwtAuth, async (req, res) => {
408
+ try {
409
+ const { userId } = req.user;
410
+ await pool.query(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL`, [userId]);
411
+ res.json({ success: true, message: 'All refresh tokens revoked' });
412
+ }
413
+ catch (error) {
414
+ console.error('Revoke tokens error:', error);
415
+ res.status(500).json({
416
+ error: 'revoke_failed',
417
+ message: 'Failed to revoke tokens',
418
+ });
419
+ }
420
+ });
421
+ /**
422
+ * GET /v1/me
423
+ * Get current user profile and usage
424
+ */
425
+ router.get('/v1/me', jwtAuth, async (req, res) => {
426
+ try {
427
+ const { userId } = req.user;
428
+ const result = await pool.query(`SELECT
429
+ u.id, u.email, u.tier, u.weekly_limit, u.burst_limit, u.rate_limit, u.created_at,
430
+ u.stripe_customer_id, u.stripe_subscription_id
431
+ FROM users u
432
+ WHERE u.id = $1`, [userId]);
433
+ if (result.rows.length === 0) {
434
+ res.status(404).json({
435
+ error: 'user_not_found',
436
+ message: 'User not found',
437
+ });
438
+ return;
439
+ }
440
+ const user = result.rows[0];
441
+ res.json({
442
+ id: user.id,
443
+ email: user.email,
444
+ tier: user.tier,
445
+ weeklyLimit: user.weekly_limit,
446
+ burstLimit: user.burst_limit,
447
+ rateLimit: user.rate_limit,
448
+ createdAt: user.created_at,
449
+ hasStripe: !!user.stripe_customer_id,
450
+ });
451
+ }
452
+ catch (error) {
453
+ console.error('Get profile error:', error);
454
+ res.status(500).json({
455
+ error: 'profile_failed',
456
+ message: 'Failed to get profile',
457
+ });
458
+ }
459
+ });
460
+ /**
461
+ * POST /v1/keys
462
+ * Create a new API key
463
+ */
464
+ router.post('/v1/keys', jwtAuth, async (req, res) => {
465
+ try {
466
+ const { userId } = req.user;
467
+ const { name } = req.body;
468
+ // Generate API key
469
+ const apiKey = PostgresAuthStore.generateApiKey();
470
+ const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
471
+ const keyPrefix = PostgresAuthStore.getKeyPrefix(apiKey);
472
+ // Store API key
473
+ const result = await pool.query(`INSERT INTO api_keys (user_id, key_hash, key_prefix, name)
474
+ VALUES ($1, $2, $3, $4)
475
+ RETURNING id, key_prefix, name, created_at`, [userId, keyHash, keyPrefix, name || 'Unnamed Key']);
476
+ const key = result.rows[0];
477
+ res.status(201).json({
478
+ id: key.id,
479
+ key: apiKey, // SECURITY: Only returned once
480
+ prefix: key.key_prefix,
481
+ name: key.name,
482
+ createdAt: key.created_at,
483
+ });
484
+ }
485
+ catch (error) {
486
+ console.error('Create key error:', error);
487
+ res.status(500).json({
488
+ error: 'key_creation_failed',
489
+ message: 'Failed to create API key',
490
+ });
491
+ }
492
+ });
493
+ /**
494
+ * GET /v1/keys
495
+ * List user's API keys (prefix only, never full key)
496
+ */
497
+ router.get('/v1/keys', jwtAuth, async (req, res) => {
498
+ try {
499
+ const { userId } = req.user;
500
+ const result = await pool.query(`SELECT id, key_prefix, name, is_active, created_at, last_used_at
501
+ FROM api_keys
502
+ WHERE user_id = $1
503
+ ORDER BY created_at DESC`, [userId]);
504
+ res.json({
505
+ keys: result.rows.map(key => ({
506
+ id: key.id,
507
+ prefix: key.key_prefix,
508
+ name: key.name,
509
+ isActive: key.is_active,
510
+ createdAt: key.created_at,
511
+ lastUsedAt: key.last_used_at,
512
+ })),
513
+ });
514
+ }
515
+ catch (error) {
516
+ console.error('List keys error:', error);
517
+ res.status(500).json({
518
+ error: 'list_keys_failed',
519
+ message: 'Failed to list API keys',
520
+ });
521
+ }
522
+ });
523
+ /**
524
+ * DELETE /v1/keys/:id
525
+ * Deactivate an API key
526
+ */
527
+ router.delete('/v1/keys/:id', jwtAuth, async (req, res) => {
528
+ try {
529
+ const { userId } = req.user;
530
+ const { id } = req.params;
531
+ // Verify ownership and deactivate
532
+ const result = await pool.query(`UPDATE api_keys
533
+ SET is_active = false
534
+ WHERE id = $1 AND user_id = $2
535
+ RETURNING id`, [id, userId]);
536
+ if (result.rows.length === 0) {
537
+ res.status(404).json({
538
+ error: 'key_not_found',
539
+ message: 'API key not found or access denied',
540
+ });
541
+ return;
542
+ }
543
+ res.json({
544
+ success: true,
545
+ message: 'API key deactivated',
546
+ });
547
+ }
548
+ catch (error) {
549
+ console.error('Delete key error:', error);
550
+ res.status(500).json({
551
+ error: 'delete_key_failed',
552
+ message: 'Failed to delete API key',
553
+ });
554
+ }
555
+ });
556
+ /**
557
+ * GET /v1/usage
558
+ * Get current week usage + limits + burst + extra usage
559
+ */
560
+ router.get('/v1/usage', jwtAuth, async (req, res) => {
561
+ try {
562
+ const { userId } = req.user;
563
+ // Helper: Get current ISO week
564
+ const getCurrentWeek = () => {
565
+ const now = new Date();
566
+ const year = now.getUTCFullYear();
567
+ const jan4 = new Date(Date.UTC(year, 0, 4));
568
+ const weekNum = Math.ceil(((now.getTime() - jan4.getTime()) / 86400000 + jan4.getUTCDay() + 1) / 7);
569
+ return `${year}-W${String(weekNum).padStart(2, '0')}`;
570
+ };
571
+ // Helper: Get current hour bucket
572
+ const getCurrentHour = () => {
573
+ return new Date().toISOString().substring(0, 13);
574
+ };
575
+ // Helper: Get week reset time
576
+ const getWeekResetTime = () => {
577
+ const now = new Date();
578
+ const dayOfWeek = now.getUTCDay();
579
+ const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
580
+ const nextMonday = new Date(now);
581
+ nextMonday.setUTCDate(now.getUTCDate() + daysUntilMonday);
582
+ nextMonday.setUTCHours(0, 0, 0, 0);
583
+ return nextMonday.toISOString();
584
+ };
585
+ // Helper: Get time until next hour
586
+ const getTimeUntilNextHour = () => {
587
+ const now = new Date();
588
+ const minutesRemaining = 59 - now.getUTCMinutes();
589
+ if (minutesRemaining === 0)
590
+ return '< 1 min';
591
+ return `${minutesRemaining} min`;
592
+ };
593
+ // Helper: Get next month reset
594
+ const getMonthResetTime = () => {
595
+ const now = new Date();
596
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0)).toISOString();
597
+ };
598
+ const currentWeek = getCurrentWeek();
599
+ const currentHour = getCurrentHour();
600
+ // Get user plan info
601
+ const planResult = await pool.query(`SELECT tier, weekly_limit, burst_limit FROM users WHERE id = $1`, [userId]);
602
+ if (planResult.rows.length === 0) {
603
+ res.status(404).json({
604
+ error: 'user_not_found',
605
+ message: 'User not found',
606
+ });
607
+ return;
608
+ }
609
+ const plan = planResult.rows[0];
610
+ // Get weekly usage
611
+ const weeklyResult = await pool.query(`SELECT
612
+ COALESCE(SUM(wu.basic_count), 0) as basic_used,
613
+ COALESCE(SUM(wu.stealth_count), 0) as stealth_used,
614
+ COALESCE(SUM(wu.captcha_count), 0) as captcha_used,
615
+ COALESCE(SUM(wu.search_count), 0) as search_used,
616
+ COALESCE(SUM(wu.total_count), 0) as total_used,
617
+ COALESCE(MAX(wu.rollover_credits), 0) as rollover_credits
618
+ FROM users u
619
+ LEFT JOIN api_keys ak ON ak.user_id = u.id
620
+ LEFT JOIN weekly_usage wu ON wu.api_key_id = ak.id AND wu.week = $2
621
+ WHERE u.id = $1
622
+ GROUP BY u.id`, [userId, currentWeek]);
623
+ const weeklyUsage = weeklyResult.rows[0] || {
624
+ basic_used: 0,
625
+ stealth_used: 0,
626
+ captcha_used: 0,
627
+ search_used: 0,
628
+ total_used: 0,
629
+ rollover_credits: 0,
630
+ };
631
+ const totalAvailable = plan.weekly_limit + weeklyUsage.rollover_credits;
632
+ const remaining = Math.max(0, totalAvailable - weeklyUsage.total_used);
633
+ const percentUsed = totalAvailable > 0 ? Math.round((weeklyUsage.total_used / totalAvailable) * 100) : 0;
634
+ // Get burst usage (current hour)
635
+ const burstResult = await pool.query(`SELECT COALESCE(SUM(bu.count), 0) as burst_used
636
+ FROM users u
637
+ LEFT JOIN api_keys ak ON ak.user_id = u.id
638
+ LEFT JOIN burst_usage bu ON bu.api_key_id = ak.id AND bu.hour_bucket = $2
639
+ WHERE u.id = $1`, [userId, currentHour]);
640
+ const burstUsed = burstResult.rows[0]?.burst_used || 0;
641
+ const burstPercent = plan.burst_limit > 0 ? Math.round((burstUsed / plan.burst_limit) * 100) : 0;
642
+ // Get extra usage info
643
+ const extraResult = await pool.query(`SELECT
644
+ extra_usage_enabled,
645
+ extra_usage_balance,
646
+ extra_usage_spent,
647
+ extra_usage_spending_limit,
648
+ auto_reload_enabled
649
+ FROM users
650
+ WHERE id = $1`, [userId]);
651
+ const extra = extraResult.rows[0];
652
+ const extraPercent = extra.extra_usage_spending_limit > 0
653
+ ? Math.round((parseFloat(extra.extra_usage_spent) / parseFloat(extra.extra_usage_spending_limit)) * 100)
654
+ : 0;
655
+ res.json({
656
+ plan: {
657
+ tier: plan.tier,
658
+ weeklyLimit: plan.weekly_limit,
659
+ burstLimit: plan.burst_limit,
660
+ },
661
+ session: {
662
+ burstUsed,
663
+ burstLimit: plan.burst_limit,
664
+ resetsIn: getTimeUntilNextHour(),
665
+ percentUsed: burstPercent,
666
+ },
667
+ weekly: {
668
+ week: currentWeek,
669
+ basicUsed: weeklyUsage.basic_used,
670
+ stealthUsed: weeklyUsage.stealth_used,
671
+ captchaUsed: weeklyUsage.captcha_used,
672
+ searchUsed: weeklyUsage.search_used,
673
+ totalUsed: weeklyUsage.total_used,
674
+ totalAvailable,
675
+ rolloverCredits: weeklyUsage.rollover_credits,
676
+ remaining,
677
+ percentUsed,
678
+ resetsAt: getWeekResetTime(),
679
+ },
680
+ extraUsage: {
681
+ enabled: extra.extra_usage_enabled,
682
+ spent: parseFloat(extra.extra_usage_spent),
683
+ spendingLimit: parseFloat(extra.extra_usage_spending_limit),
684
+ balance: parseFloat(extra.extra_usage_balance),
685
+ autoReload: extra.auto_reload_enabled,
686
+ percentUsed: extraPercent,
687
+ resetsAt: getMonthResetTime(),
688
+ },
689
+ });
690
+ }
691
+ catch (error) {
692
+ console.error('Get usage error:', error);
693
+ res.status(500).json({
694
+ error: 'usage_failed',
695
+ message: 'Failed to get usage',
696
+ });
697
+ }
698
+ });
699
+ /**
700
+ * POST /v1/extra-usage/toggle
701
+ * Enable/disable extra usage
702
+ */
703
+ router.post('/v1/extra-usage/toggle', jwtAuth, async (req, res) => {
704
+ try {
705
+ const { userId } = req.user;
706
+ const { enabled } = req.body;
707
+ if (typeof enabled !== 'boolean') {
708
+ res.status(400).json({
709
+ error: 'invalid_request',
710
+ message: 'enabled must be a boolean',
711
+ });
712
+ return;
713
+ }
714
+ await pool.query('UPDATE users SET extra_usage_enabled = $1, updated_at = now() WHERE id = $2', [enabled, userId]);
715
+ res.json({
716
+ success: true,
717
+ enabled,
718
+ });
719
+ }
720
+ catch (error) {
721
+ console.error('Toggle extra usage error:', error);
722
+ res.status(500).json({
723
+ error: 'toggle_failed',
724
+ message: 'Failed to toggle extra usage',
725
+ });
726
+ }
727
+ });
728
+ /**
729
+ * POST /v1/extra-usage/limit
730
+ * Adjust spending limit
731
+ */
732
+ router.post('/v1/extra-usage/limit', jwtAuth, async (req, res) => {
733
+ try {
734
+ const { userId } = req.user;
735
+ const { limit } = req.body;
736
+ if (typeof limit !== 'number' || limit < 10 || limit > 500) {
737
+ res.status(400).json({
738
+ error: 'invalid_limit',
739
+ message: 'Limit must be a number between 10 and 500',
740
+ });
741
+ return;
742
+ }
743
+ await pool.query('UPDATE users SET extra_usage_spending_limit = $1, updated_at = now() WHERE id = $2', [limit, userId]);
744
+ res.json({
745
+ success: true,
746
+ limit,
747
+ });
748
+ }
749
+ catch (error) {
750
+ console.error('Set limit error:', error);
751
+ res.status(500).json({
752
+ error: 'limit_failed',
753
+ message: 'Failed to set spending limit',
754
+ });
755
+ }
756
+ });
757
+ /**
758
+ * POST /v1/extra-usage/buy
759
+ * Add to extra usage balance (future: Stripe checkout)
760
+ */
761
+ router.post('/v1/extra-usage/buy', jwtAuth, async (_req, res) => {
762
+ // DISABLED: Stripe integration in progress
763
+ res.status(501).json({
764
+ error: 'not_implemented',
765
+ message: 'Extra usage purchases are available through our billing portal. Visit https://app.webpeel.dev/billing',
766
+ });
767
+ });
768
+ /**
769
+ * PATCH /v1/user/profile
770
+ * Update user profile (name, avatar)
771
+ */
772
+ router.patch('/v1/user/profile', jwtAuth, async (req, res) => {
773
+ try {
774
+ const { userId } = req.user;
775
+ const { name, avatarUrl } = req.body;
776
+ // Validate inputs
777
+ if (name && typeof name !== 'string') {
778
+ res.status(400).json({ error: 'invalid_name', message: 'Name must be a string' });
779
+ return;
780
+ }
781
+ if (name && name.length > 100) {
782
+ res.status(400).json({ error: 'invalid_name', message: 'Name too long (max 100 characters)' });
783
+ return;
784
+ }
785
+ if (avatarUrl && typeof avatarUrl !== 'string') {
786
+ res.status(400).json({ error: 'invalid_avatar', message: 'Avatar URL must be a string' });
787
+ return;
788
+ }
789
+ if (avatarUrl && avatarUrl.length > 500) {
790
+ res.status(400).json({ error: 'invalid_avatar', message: 'Avatar URL too long (max 500 characters)' });
791
+ return;
792
+ }
793
+ if (avatarUrl) {
794
+ try {
795
+ const parsed = new URL(avatarUrl);
796
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
797
+ res.status(400).json({ error: 'invalid_avatar', message: 'Avatar URL must use http or https protocol' });
798
+ return;
799
+ }
800
+ }
801
+ catch {
802
+ res.status(400).json({ error: 'invalid_avatar', message: 'Avatar URL must be a valid URL' });
803
+ return;
804
+ }
805
+ }
806
+ // Build update query dynamically
807
+ const updates = [];
808
+ const values = [];
809
+ let paramIndex = 1;
810
+ if (name !== undefined) {
811
+ updates.push(`name = $${paramIndex++}`);
812
+ values.push(name);
813
+ }
814
+ if (avatarUrl !== undefined) {
815
+ updates.push(`avatar_url = $${paramIndex++}`);
816
+ values.push(avatarUrl);
817
+ }
818
+ if (updates.length === 0) {
819
+ res.status(400).json({ error: 'no_updates', message: 'No fields to update' });
820
+ return;
821
+ }
822
+ updates.push(`updated_at = now()`);
823
+ values.push(userId);
824
+ const result = await pool.query(`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING id, email, name, avatar_url`, values);
825
+ if (result.rows.length === 0) {
826
+ res.status(404).json({ error: 'user_not_found', message: 'User not found' });
827
+ return;
828
+ }
829
+ res.json({
830
+ success: true,
831
+ user: {
832
+ id: result.rows[0].id,
833
+ email: result.rows[0].email,
834
+ name: result.rows[0].name,
835
+ avatar: result.rows[0].avatar_url,
836
+ },
837
+ });
838
+ }
839
+ catch (error) {
840
+ console.error('Update profile error:', error);
841
+ res.status(500).json({ error: 'update_failed', message: 'Failed to update profile' });
842
+ }
843
+ });
844
+ /**
845
+ * PATCH /v1/user/password
846
+ * Change password (verify current, hash new)
847
+ */
848
+ router.patch('/v1/user/password', jwtAuth, async (req, res) => {
849
+ try {
850
+ const { userId } = req.user;
851
+ const { currentPassword, newPassword } = req.body;
852
+ if (!currentPassword || !newPassword) {
853
+ res.status(400).json({ error: 'missing_fields', message: 'Current and new passwords are required' });
854
+ return;
855
+ }
856
+ if (!isValidPassword(newPassword)) {
857
+ res.status(400).json({ error: 'weak_password', message: 'Password must be at least 8 characters' });
858
+ return;
859
+ }
860
+ // Get current password hash
861
+ const userResult = await pool.query('SELECT password_hash FROM users WHERE id = $1', [userId]);
862
+ if (userResult.rows.length === 0) {
863
+ res.status(404).json({ error: 'user_not_found', message: 'User not found' });
864
+ return;
865
+ }
866
+ // OAuth users don't have passwords
867
+ if (!userResult.rows[0].password_hash) {
868
+ res.status(400).json({
869
+ error: 'oauth_user',
870
+ message: 'OAuth users cannot set passwords. Please use your OAuth provider to manage your account.'
871
+ });
872
+ return;
873
+ }
874
+ // Verify current password
875
+ const passwordValid = await bcrypt.compare(currentPassword, userResult.rows[0].password_hash);
876
+ if (!passwordValid) {
877
+ res.status(401).json({ error: 'invalid_password', message: 'Current password is incorrect' });
878
+ return;
879
+ }
880
+ // Hash new password
881
+ const newPasswordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
882
+ // Update password
883
+ await pool.query('UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2', [newPasswordHash, userId]);
884
+ res.json({ success: true, message: 'Password updated successfully' });
885
+ }
886
+ catch (error) {
887
+ console.error('Change password error:', error);
888
+ res.status(500).json({ error: 'update_failed', message: 'Failed to change password' });
889
+ }
890
+ });
891
+ /**
892
+ * DELETE /v1/user/account
893
+ * Delete account + cascade to api_keys, oauth_accounts
894
+ */
895
+ router.delete('/v1/user/account', jwtAuth, async (req, res) => {
896
+ try {
897
+ const { userId } = req.user;
898
+ const { password, confirmEmail } = req.body;
899
+ // Get user info
900
+ const userResult = await pool.query('SELECT email, password_hash FROM users WHERE id = $1', [userId]);
901
+ if (userResult.rows.length === 0) {
902
+ res.status(404).json({ error: 'user_not_found', message: 'User not found' });
903
+ return;
904
+ }
905
+ const user = userResult.rows[0];
906
+ // Verify email confirmation
907
+ if (confirmEmail !== user.email) {
908
+ res.status(400).json({
909
+ error: 'email_mismatch',
910
+ message: 'Email confirmation does not match account email'
911
+ });
912
+ return;
913
+ }
914
+ // Verify password (if user has one - OAuth users might not)
915
+ if (user.password_hash) {
916
+ if (!password) {
917
+ res.status(400).json({ error: 'missing_password', message: 'Password is required' });
918
+ return;
919
+ }
920
+ const passwordValid = await bcrypt.compare(password, user.password_hash);
921
+ if (!passwordValid) {
922
+ res.status(401).json({ error: 'invalid_password', message: 'Password is incorrect' });
923
+ return;
924
+ }
925
+ }
926
+ // Delete user and all related data in a transaction
927
+ const client = await pool.connect();
928
+ try {
929
+ await client.query('BEGIN');
930
+ await client.query('DELETE FROM api_keys WHERE user_id = $1', [userId]);
931
+ await client.query('DELETE FROM oauth_accounts WHERE user_id = $1', [userId]);
932
+ await client.query('DELETE FROM users WHERE id = $1', [userId]);
933
+ await client.query('COMMIT');
934
+ }
935
+ catch (txError) {
936
+ await client.query('ROLLBACK');
937
+ throw txError;
938
+ }
939
+ finally {
940
+ client.release();
941
+ }
942
+ res.json({
943
+ success: true,
944
+ message: 'Account deleted successfully. We\'re sorry to see you go!'
945
+ });
946
+ }
947
+ catch (error) {
948
+ console.error('Delete account error:', error);
949
+ res.status(500).json({ error: 'delete_failed', message: 'Failed to delete account' });
950
+ }
951
+ });
952
+ return router;
953
+ }
954
+ //# sourceMappingURL=users.js.map