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.
- package/README.md +82 -9
- package/dist/cli.js +97 -6
- package/dist/cli.js.map +1 -1
- package/dist/core/actions.d.ts +28 -0
- package/dist/core/actions.d.ts.map +1 -1
- package/dist/core/actions.js +60 -0
- package/dist/core/actions.js.map +1 -1
- package/dist/core/bm25-filter.d.ts +10 -0
- package/dist/core/bm25-filter.d.ts.map +1 -1
- package/dist/core/bm25-filter.js +40 -0
- package/dist/core/bm25-filter.js.map +1 -1
- package/dist/core/content-pruner.d.ts +12 -5
- package/dist/core/content-pruner.d.ts.map +1 -1
- package/dist/core/content-pruner.js +247 -190
- package/dist/core/content-pruner.js.map +1 -1
- package/dist/core/research.d.ts +67 -0
- package/dist/core/research.d.ts.map +1 -0
- package/dist/core/research.js +254 -0
- package/dist/core/research.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +37 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +107 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/server/app.d.ts +14 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/app.js +189 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/auth-store.d.ts +28 -0
- package/dist/server/auth-store.d.ts.map +1 -0
- package/dist/server/auth-store.js +89 -0
- package/dist/server/auth-store.js.map +1 -0
- package/dist/server/job-queue.d.ts +93 -0
- package/dist/server/job-queue.d.ts.map +1 -0
- package/dist/server/job-queue.js +144 -0
- package/dist/server/job-queue.js.map +1 -0
- package/dist/server/middleware/auth.d.ts +28 -0
- package/dist/server/middleware/auth.d.ts.map +1 -0
- package/dist/server/middleware/auth.js +183 -0
- package/dist/server/middleware/auth.js.map +1 -0
- package/dist/server/middleware/rate-limit.d.ts +23 -0
- package/dist/server/middleware/rate-limit.d.ts.map +1 -0
- package/dist/server/middleware/rate-limit.js +126 -0
- package/dist/server/middleware/rate-limit.js.map +1 -0
- package/dist/server/middleware/url-validator.d.ts +16 -0
- package/dist/server/middleware/url-validator.d.ts.map +1 -0
- package/dist/server/middleware/url-validator.js +187 -0
- package/dist/server/middleware/url-validator.js.map +1 -0
- package/dist/server/pg-auth-store.d.ts +129 -0
- package/dist/server/pg-auth-store.d.ts.map +1 -0
- package/dist/server/pg-auth-store.js +457 -0
- package/dist/server/pg-auth-store.js.map +1 -0
- package/dist/server/pg-job-queue.d.ts +60 -0
- package/dist/server/pg-job-queue.d.ts.map +1 -0
- package/dist/server/pg-job-queue.js +365 -0
- package/dist/server/pg-job-queue.js.map +1 -0
- package/dist/server/premium/domain-intel.d.ts +17 -0
- package/dist/server/premium/domain-intel.d.ts.map +1 -0
- package/dist/server/premium/domain-intel.js +134 -0
- package/dist/server/premium/domain-intel.js.map +1 -0
- package/dist/server/premium/index.d.ts +18 -0
- package/dist/server/premium/index.d.ts.map +1 -0
- package/dist/server/premium/index.js +36 -0
- package/dist/server/premium/index.js.map +1 -0
- package/dist/server/premium/swr-cache.d.ts +15 -0
- package/dist/server/premium/swr-cache.d.ts.map +1 -0
- package/dist/server/premium/swr-cache.js +35 -0
- package/dist/server/premium/swr-cache.js.map +1 -0
- package/dist/server/routes/activity.d.ts +7 -0
- package/dist/server/routes/activity.d.ts.map +1 -0
- package/dist/server/routes/activity.js +66 -0
- package/dist/server/routes/activity.js.map +1 -0
- package/dist/server/routes/agent.d.ts +12 -0
- package/dist/server/routes/agent.d.ts.map +1 -0
- package/dist/server/routes/agent.js +356 -0
- package/dist/server/routes/agent.js.map +1 -0
- package/dist/server/routes/answer.d.ts +6 -0
- package/dist/server/routes/answer.d.ts.map +1 -0
- package/dist/server/routes/answer.js +124 -0
- package/dist/server/routes/answer.js.map +1 -0
- package/dist/server/routes/batch.d.ts +7 -0
- package/dist/server/routes/batch.d.ts.map +1 -0
- package/dist/server/routes/batch.js +287 -0
- package/dist/server/routes/batch.js.map +1 -0
- package/dist/server/routes/cli-usage.d.ts +7 -0
- package/dist/server/routes/cli-usage.d.ts.map +1 -0
- package/dist/server/routes/cli-usage.js +121 -0
- package/dist/server/routes/cli-usage.js.map +1 -0
- package/dist/server/routes/compat.d.ts +24 -0
- package/dist/server/routes/compat.d.ts.map +1 -0
- package/dist/server/routes/compat.js +651 -0
- package/dist/server/routes/compat.js.map +1 -0
- package/dist/server/routes/extract.d.ts +9 -0
- package/dist/server/routes/extract.d.ts.map +1 -0
- package/dist/server/routes/extract.js +121 -0
- package/dist/server/routes/extract.js.map +1 -0
- package/dist/server/routes/fetch.d.ts +7 -0
- package/dist/server/routes/fetch.d.ts.map +1 -0
- package/dist/server/routes/fetch.js +537 -0
- package/dist/server/routes/fetch.js.map +1 -0
- package/dist/server/routes/health.d.ts +8 -0
- package/dist/server/routes/health.d.ts.map +1 -0
- package/dist/server/routes/health.js +36 -0
- package/dist/server/routes/health.js.map +1 -0
- package/dist/server/routes/jobs.d.ts +8 -0
- package/dist/server/routes/jobs.d.ts.map +1 -0
- package/dist/server/routes/jobs.js +374 -0
- package/dist/server/routes/jobs.js.map +1 -0
- package/dist/server/routes/mcp.d.ts +16 -0
- package/dist/server/routes/mcp.d.ts.map +1 -0
- package/dist/server/routes/mcp.js +475 -0
- package/dist/server/routes/mcp.js.map +1 -0
- package/dist/server/routes/oauth.d.ts +10 -0
- package/dist/server/routes/oauth.d.ts.map +1 -0
- package/dist/server/routes/oauth.js +296 -0
- package/dist/server/routes/oauth.js.map +1 -0
- package/dist/server/routes/screenshot.d.ts +10 -0
- package/dist/server/routes/screenshot.d.ts.map +1 -0
- package/dist/server/routes/screenshot.js +217 -0
- package/dist/server/routes/screenshot.js.map +1 -0
- package/dist/server/routes/search.d.ts +7 -0
- package/dist/server/routes/search.d.ts.map +1 -0
- package/dist/server/routes/search.js +287 -0
- package/dist/server/routes/search.js.map +1 -0
- package/dist/server/routes/stats.d.ts +7 -0
- package/dist/server/routes/stats.d.ts.map +1 -0
- package/dist/server/routes/stats.js +65 -0
- package/dist/server/routes/stats.js.map +1 -0
- package/dist/server/routes/stripe.d.ts +9 -0
- package/dist/server/routes/stripe.d.ts.map +1 -0
- package/dist/server/routes/stripe.js +233 -0
- package/dist/server/routes/stripe.js.map +1 -0
- package/dist/server/routes/users.d.ts +9 -0
- package/dist/server/routes/users.d.ts.map +1 -0
- package/dist/server/routes/users.js +954 -0
- package/dist/server/routes/users.js.map +1 -0
- package/dist/server/routes/webhooks.d.ts +15 -0
- package/dist/server/routes/webhooks.d.ts.map +1 -0
- package/dist/server/routes/webhooks.js +73 -0
- package/dist/server/routes/webhooks.js.map +1 -0
- package/dist/server/sentry.d.ts +14 -0
- package/dist/server/sentry.d.ts.map +1 -0
- package/dist/server/sentry.js +39 -0
- package/dist/server/sentry.js.map +1 -0
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- 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
|