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.
- package/dist/server/app.d.ts +14 -0
- package/dist/server/app.js +384 -0
- package/dist/server/auth-store.d.ts +27 -0
- package/dist/server/auth-store.js +88 -0
- package/dist/server/email-service.d.ts +21 -0
- package/dist/server/email-service.js +79 -0
- package/dist/server/job-queue.d.ts +100 -0
- package/dist/server/job-queue.js +145 -0
- package/dist/server/logger.d.ts +10 -0
- package/dist/server/logger.js +37 -0
- package/dist/server/middleware/auth.d.ts +28 -0
- package/dist/server/middleware/auth.js +221 -0
- package/dist/server/middleware/rate-limit.d.ts +24 -0
- package/dist/server/middleware/rate-limit.js +167 -0
- package/dist/server/middleware/url-validator.d.ts +15 -0
- package/dist/server/middleware/url-validator.js +186 -0
- package/dist/server/openapi.yaml +6418 -0
- package/dist/server/pg-auth-store.d.ts +132 -0
- package/dist/server/pg-auth-store.js +472 -0
- package/dist/server/pg-job-queue.d.ts +59 -0
- package/dist/server/pg-job-queue.js +375 -0
- package/dist/server/premium/domain-intel.d.ts +16 -0
- package/dist/server/premium/domain-intel.js +133 -0
- package/dist/server/premium/index.d.ts +17 -0
- package/dist/server/premium/index.js +35 -0
- package/dist/server/premium/swr-cache.d.ts +14 -0
- package/dist/server/premium/swr-cache.js +34 -0
- package/dist/server/routes/activity.d.ts +6 -0
- package/dist/server/routes/activity.js +74 -0
- package/dist/server/routes/answer.d.ts +5 -0
- package/dist/server/routes/answer.js +125 -0
- package/dist/server/routes/ask.d.ts +28 -0
- package/dist/server/routes/ask.js +229 -0
- package/dist/server/routes/batch.d.ts +6 -0
- package/dist/server/routes/batch.js +493 -0
- package/dist/server/routes/cli-usage.d.ts +6 -0
- package/dist/server/routes/cli-usage.js +127 -0
- package/dist/server/routes/compat.d.ts +23 -0
- package/dist/server/routes/compat.js +652 -0
- package/dist/server/routes/deep-fetch.d.ts +8 -0
- package/dist/server/routes/deep-fetch.js +57 -0
- package/dist/server/routes/demo.d.ts +24 -0
- package/dist/server/routes/demo.js +517 -0
- package/dist/server/routes/do.d.ts +8 -0
- package/dist/server/routes/do.js +72 -0
- package/dist/server/routes/extract.d.ts +8 -0
- package/dist/server/routes/extract.js +235 -0
- package/dist/server/routes/fetch.d.ts +7 -0
- package/dist/server/routes/fetch.js +999 -0
- package/dist/server/routes/health.d.ts +7 -0
- package/dist/server/routes/health.js +19 -0
- package/dist/server/routes/jobs.d.ts +7 -0
- package/dist/server/routes/jobs.js +573 -0
- package/dist/server/routes/mcp.d.ts +14 -0
- package/dist/server/routes/mcp.js +141 -0
- package/dist/server/routes/oauth.d.ts +9 -0
- package/dist/server/routes/oauth.js +396 -0
- package/dist/server/routes/playground.d.ts +17 -0
- package/dist/server/routes/playground.js +283 -0
- package/dist/server/routes/screenshot.d.ts +22 -0
- package/dist/server/routes/screenshot.js +816 -0
- package/dist/server/routes/search.d.ts +6 -0
- package/dist/server/routes/search.js +303 -0
- package/dist/server/routes/session.d.ts +15 -0
- package/dist/server/routes/session.js +397 -0
- package/dist/server/routes/stats.d.ts +6 -0
- package/dist/server/routes/stats.js +71 -0
- package/dist/server/routes/stripe.d.ts +15 -0
- package/dist/server/routes/stripe.js +294 -0
- package/dist/server/routes/users.d.ts +8 -0
- package/dist/server/routes/users.js +1671 -0
- package/dist/server/routes/watch.d.ts +15 -0
- package/dist/server/routes/watch.js +309 -0
- package/dist/server/routes/webhooks.d.ts +26 -0
- package/dist/server/routes/webhooks.js +170 -0
- package/dist/server/routes/youtube.d.ts +6 -0
- package/dist/server/routes/youtube.js +130 -0
- package/dist/server/sentry.d.ts +13 -0
- package/dist/server/sentry.js +38 -0
- package/dist/server/types.d.ts +15 -0
- package/dist/server/types.js +7 -0
- package/dist/server/utils/response.d.ts +44 -0
- package/dist/server/utils/response.js +69 -0
- package/dist/server/utils/sse.d.ts +22 -0
- package/dist/server/utils/sse.js +38 -0
- 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
|
+
}
|