webpeel 0.20.2 → 0.20.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/server/app.d.ts +14 -0
  2. package/dist/server/app.js +384 -0
  3. package/dist/server/auth-store.d.ts +27 -0
  4. package/dist/server/auth-store.js +88 -0
  5. package/dist/server/email-service.d.ts +21 -0
  6. package/dist/server/email-service.js +79 -0
  7. package/dist/server/job-queue.d.ts +100 -0
  8. package/dist/server/job-queue.js +145 -0
  9. package/dist/server/logger.d.ts +10 -0
  10. package/dist/server/logger.js +37 -0
  11. package/dist/server/middleware/auth.d.ts +28 -0
  12. package/dist/server/middleware/auth.js +221 -0
  13. package/dist/server/middleware/rate-limit.d.ts +24 -0
  14. package/dist/server/middleware/rate-limit.js +167 -0
  15. package/dist/server/middleware/url-validator.d.ts +15 -0
  16. package/dist/server/middleware/url-validator.js +186 -0
  17. package/dist/server/openapi.yaml +6418 -0
  18. package/dist/server/pg-auth-store.d.ts +132 -0
  19. package/dist/server/pg-auth-store.js +472 -0
  20. package/dist/server/pg-job-queue.d.ts +59 -0
  21. package/dist/server/pg-job-queue.js +375 -0
  22. package/dist/server/premium/domain-intel.d.ts +16 -0
  23. package/dist/server/premium/domain-intel.js +133 -0
  24. package/dist/server/premium/index.d.ts +17 -0
  25. package/dist/server/premium/index.js +35 -0
  26. package/dist/server/premium/swr-cache.d.ts +14 -0
  27. package/dist/server/premium/swr-cache.js +34 -0
  28. package/dist/server/routes/activity.d.ts +6 -0
  29. package/dist/server/routes/activity.js +74 -0
  30. package/dist/server/routes/answer.d.ts +5 -0
  31. package/dist/server/routes/answer.js +125 -0
  32. package/dist/server/routes/ask.d.ts +28 -0
  33. package/dist/server/routes/ask.js +229 -0
  34. package/dist/server/routes/batch.d.ts +6 -0
  35. package/dist/server/routes/batch.js +493 -0
  36. package/dist/server/routes/cli-usage.d.ts +6 -0
  37. package/dist/server/routes/cli-usage.js +127 -0
  38. package/dist/server/routes/compat.d.ts +23 -0
  39. package/dist/server/routes/compat.js +652 -0
  40. package/dist/server/routes/deep-fetch.d.ts +8 -0
  41. package/dist/server/routes/deep-fetch.js +57 -0
  42. package/dist/server/routes/demo.d.ts +24 -0
  43. package/dist/server/routes/demo.js +517 -0
  44. package/dist/server/routes/do.d.ts +8 -0
  45. package/dist/server/routes/do.js +72 -0
  46. package/dist/server/routes/extract.d.ts +8 -0
  47. package/dist/server/routes/extract.js +235 -0
  48. package/dist/server/routes/fetch.d.ts +7 -0
  49. package/dist/server/routes/fetch.js +999 -0
  50. package/dist/server/routes/health.d.ts +7 -0
  51. package/dist/server/routes/health.js +19 -0
  52. package/dist/server/routes/jobs.d.ts +7 -0
  53. package/dist/server/routes/jobs.js +573 -0
  54. package/dist/server/routes/mcp.d.ts +14 -0
  55. package/dist/server/routes/mcp.js +141 -0
  56. package/dist/server/routes/oauth.d.ts +9 -0
  57. package/dist/server/routes/oauth.js +396 -0
  58. package/dist/server/routes/playground.d.ts +17 -0
  59. package/dist/server/routes/playground.js +283 -0
  60. package/dist/server/routes/screenshot.d.ts +22 -0
  61. package/dist/server/routes/screenshot.js +816 -0
  62. package/dist/server/routes/search.d.ts +6 -0
  63. package/dist/server/routes/search.js +303 -0
  64. package/dist/server/routes/session.d.ts +15 -0
  65. package/dist/server/routes/session.js +397 -0
  66. package/dist/server/routes/stats.d.ts +6 -0
  67. package/dist/server/routes/stats.js +71 -0
  68. package/dist/server/routes/stripe.d.ts +15 -0
  69. package/dist/server/routes/stripe.js +294 -0
  70. package/dist/server/routes/users.d.ts +8 -0
  71. package/dist/server/routes/users.js +1671 -0
  72. package/dist/server/routes/watch.d.ts +15 -0
  73. package/dist/server/routes/watch.js +309 -0
  74. package/dist/server/routes/webhooks.d.ts +26 -0
  75. package/dist/server/routes/webhooks.js +170 -0
  76. package/dist/server/routes/youtube.d.ts +6 -0
  77. package/dist/server/routes/youtube.js +130 -0
  78. package/dist/server/sentry.d.ts +13 -0
  79. package/dist/server/sentry.js +38 -0
  80. package/dist/server/types.d.ts +15 -0
  81. package/dist/server/types.js +7 -0
  82. package/dist/server/utils/response.d.ts +44 -0
  83. package/dist/server/utils/response.js +69 -0
  84. package/dist/server/utils/sse.d.ts +22 -0
  85. package/dist/server/utils/sse.js +38 -0
  86. package/package.json +2 -1
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Hosted MCP endpoint — POST /mcp, POST /v2/mcp, POST /:apiKey/v2/mcp
3
+ *
4
+ * Thin HTTP/SSE transport wrapper. All tool logic lives in the shared handler
5
+ * registry at src/mcp/handlers/. This file handles:
6
+ * - Express routing and auth
7
+ * - MCP Streamable HTTP transport setup
8
+ * - Passing McpContext (accountId, pool) to handlers
9
+ */
10
+ import { Router } from 'express';
11
+ import '../types.js'; // Augments Express.Request with requestId
12
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
13
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
14
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
15
+ import { readFileSync } from 'fs';
16
+ import { join, dirname } from 'path';
17
+ import { fileURLToPath } from 'url';
18
+ import { getHandler } from '../../mcp/handlers/index.js';
19
+ import { toolDefinitions } from '../../mcp/handlers/definitions.js';
20
+ // Read version from package.json
21
+ let pkgVersion = '0.7.0';
22
+ try {
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = dirname(__filename);
25
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', '..', 'package.json'), 'utf-8'));
26
+ pkgVersion = pkg.version;
27
+ }
28
+ catch { /* fallback */ }
29
+ // ---------------------------------------------------------------------------
30
+ // Helper
31
+ // ---------------------------------------------------------------------------
32
+ function safeStringify(obj) {
33
+ try {
34
+ return JSON.stringify(obj, null, 2);
35
+ }
36
+ catch {
37
+ return JSON.stringify({ error: 'serialization_error', message: 'Failed to serialize result' });
38
+ }
39
+ }
40
+ // ---------------------------------------------------------------------------
41
+ // Create a fresh MCP server instance (stateless — one per request)
42
+ // ---------------------------------------------------------------------------
43
+ function createMcpServer(pool, req) {
44
+ const mcpServer = new Server({ name: 'webpeel', version: pkgVersion }, { capabilities: { tools: {} } });
45
+ mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolDefinitions }));
46
+ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
47
+ const { name, arguments: rawArgs } = request.params;
48
+ const args = (rawArgs ?? {});
49
+ // Build context: auth + pool for HTTP-specific features (webpeel_watch)
50
+ const accountId = req?.auth?.keyInfo?.accountId ||
51
+ req?.user?.userId ||
52
+ 'anonymous';
53
+ const ctx = { accountId, pool: pool ?? undefined };
54
+ try {
55
+ const handler = getHandler(name);
56
+ if (!handler)
57
+ throw new Error(`Unknown tool: ${name}`);
58
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
+ return (await handler(args, ctx));
60
+ }
61
+ catch (error) {
62
+ const err = error;
63
+ return {
64
+ content: [
65
+ {
66
+ type: 'text',
67
+ text: safeStringify({ error: err.name || 'Error', message: err.message || 'Unknown error' }),
68
+ },
69
+ ],
70
+ isError: true,
71
+ };
72
+ }
73
+ });
74
+ return mcpServer;
75
+ }
76
+ // ---------------------------------------------------------------------------
77
+ // Shared MCP request handler
78
+ // ---------------------------------------------------------------------------
79
+ async function handleMcpPost(req, res, pool) {
80
+ // Require authentication
81
+ const mcpAuthId = req.auth?.keyInfo?.accountId || req.user?.userId;
82
+ if (!mcpAuthId) {
83
+ res.status(401).json({ success: false, error: { type: 'authentication_required', message: 'Authentication required. Pass API key via Authorization: Bearer <key> header.', hint: 'Get an API key at https://app.webpeel.dev/keys', docs: 'https://webpeel.dev/docs/errors#authentication_required' }, requestId: req.requestId });
84
+ return;
85
+ }
86
+ try {
87
+ const mcpServer = createMcpServer(pool, req);
88
+ const transport = new StreamableHTTPServerTransport({
89
+ sessionIdGenerator: undefined, // stateless
90
+ });
91
+ await mcpServer.connect(transport);
92
+ await transport.handleRequest(
93
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
94
+ req, res, req.body);
95
+ transport.close().catch(() => { });
96
+ mcpServer.close().catch(() => { });
97
+ }
98
+ catch (error) {
99
+ console.error('MCP endpoint error:', error);
100
+ if (!res.headersSent) {
101
+ res.status(500).json({
102
+ success: false,
103
+ error: {
104
+ type: 'internal_error',
105
+ message: 'Internal error',
106
+ docs: 'https://webpeel.dev/docs/errors#internal_error',
107
+ },
108
+ requestId: req.requestId,
109
+ });
110
+ }
111
+ }
112
+ }
113
+ function mcpMethodNotAllowed(req, res) {
114
+ res.status(405).json({ success: false, error: { type: 'method_not_allowed', message: 'Method not allowed. Use POST to send MCP JSON-RPC messages.', hint: 'Send a POST request with a JSON-RPC body', docs: 'https://webpeel.dev/docs/errors#method_not_allowed' }, requestId: req.requestId });
115
+ }
116
+ function mcpDeleteOk(_req, res) {
117
+ res.status(200).json({ ok: true });
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // Express router
121
+ // ---------------------------------------------------------------------------
122
+ export function createMcpRouter(_authStore, pool) {
123
+ const router = Router();
124
+ const boundHandler = (req, res) => handleMcpPost(req, res, pool);
125
+ // POST /mcp — legacy path
126
+ router.post('/mcp', boundHandler);
127
+ router.get('/mcp', mcpMethodNotAllowed);
128
+ router.delete('/mcp', mcpDeleteOk);
129
+ // POST /v2/mcp — canonical v2 path; auth via Authorization: Bearer <key> header
130
+ router.post('/v2/mcp', boundHandler);
131
+ router.get('/v2/mcp', mcpMethodNotAllowed);
132
+ router.delete('/v2/mcp', mcpDeleteOk);
133
+ // SECURITY: /:apiKey/v2/mcp — BLOCKED. API keys in URLs are insecure.
134
+ const mcpInsecureAuthHandler = (req, res) => {
135
+ res.status(400).json({ success: false, error: { type: 'insecure_auth', message: 'API keys in URLs are insecure.', hint: 'Use the Authorization header instead: Authorization: Bearer wp_your_key', docs: 'https://webpeel.dev/docs/api-reference#authentication' }, requestId: req.requestId });
136
+ };
137
+ router.post('/:apiKey/v2/mcp', mcpInsecureAuthHandler);
138
+ router.get('/:apiKey/v2/mcp', mcpInsecureAuthHandler);
139
+ router.delete('/:apiKey/v2/mcp', mcpInsecureAuthHandler);
140
+ return router;
141
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * OAuth authentication routes
3
+ * Handles OAuth login from Auth.js (GitHub, Google)
4
+ */
5
+ import { Router } from 'express';
6
+ /**
7
+ * Create OAuth routes
8
+ */
9
+ export declare function createOAuthRouter(): Router;
@@ -0,0 +1,396 @@
1
+ /**
2
+ * OAuth authentication routes
3
+ * Handles OAuth login from Auth.js (GitHub, Google)
4
+ */
5
+ import { Router } from 'express';
6
+ import crypto from 'crypto';
7
+ import jwt from 'jsonwebtoken';
8
+ import pg from 'pg';
9
+ import { PostgresAuthStore } from '../pg-auth-store.js';
10
+ const { Pool } = pg;
11
+ /**
12
+ * Validate email format
13
+ */
14
+ function isValidEmail(email) {
15
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
16
+ return emailRegex.test(email);
17
+ }
18
+ /**
19
+ * Simple in-memory rate limiter for OAuth endpoint
20
+ */
21
+ class OAuthRateLimiter {
22
+ attempts = new Map();
23
+ maxAttempts = 10;
24
+ windowMs = 60000; // 1 minute
25
+ check(identifier) {
26
+ const now = Date.now();
27
+ const attempts = this.attempts.get(identifier) || [];
28
+ // Remove old attempts outside the window
29
+ const recentAttempts = attempts.filter(time => now - time < this.windowMs);
30
+ if (recentAttempts.length >= this.maxAttempts) {
31
+ return false;
32
+ }
33
+ recentAttempts.push(now);
34
+ this.attempts.set(identifier, recentAttempts);
35
+ return true;
36
+ }
37
+ cleanup() {
38
+ const now = Date.now();
39
+ for (const [key, attempts] of this.attempts.entries()) {
40
+ const recentAttempts = attempts.filter(time => now - time < this.windowMs);
41
+ if (recentAttempts.length === 0) {
42
+ this.attempts.delete(key);
43
+ }
44
+ else {
45
+ this.attempts.set(key, recentAttempts);
46
+ }
47
+ }
48
+ }
49
+ }
50
+ const rateLimiter = new OAuthRateLimiter();
51
+ // Clean up rate limiter every 2 minutes
52
+ setInterval(() => {
53
+ rateLimiter.cleanup();
54
+ }, 2 * 60 * 1000);
55
+ /**
56
+ * Create OAuth routes
57
+ */
58
+ export function createOAuthRouter() {
59
+ const router = Router();
60
+ const dbUrl = process.env.DATABASE_URL;
61
+ if (!dbUrl) {
62
+ throw new Error('DATABASE_URL environment variable is required');
63
+ }
64
+ const pool = new Pool({
65
+ connectionString: dbUrl,
66
+ // TLS: enabled when DATABASE_URL contains sslmode=require.
67
+ // Secure by default (rejectUnauthorized: true); set PG_REJECT_UNAUTHORIZED=false
68
+ // only for managed DBs (Render/Neon/Supabase) that use self-signed certs.
69
+ ssl: process.env.DATABASE_URL?.includes('sslmode=require')
70
+ ? { rejectUnauthorized: process.env.PG_REJECT_UNAUTHORIZED !== 'false' }
71
+ : undefined,
72
+ });
73
+ /**
74
+ * Helper: generate a refresh token and store its jti in the database
75
+ */
76
+ async function createRefreshToken(userId, jwtSecret) {
77
+ const jti = crypto.randomUUID();
78
+ const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
79
+ await pool.query(`INSERT INTO refresh_tokens (id, user_id, expires_at) VALUES ($1, $2, $3)`, [jti, userId, expiresAt]);
80
+ return jwt.sign({ userId, jti }, jwtSecret, { expiresIn: '30d' });
81
+ }
82
+ /**
83
+ * POST /v1/auth/oauth
84
+ * OAuth callback handler - called by Auth.js after successful OAuth flow
85
+ * Auto-creates users if they don't exist
86
+ */
87
+ router.post('/v1/auth/oauth', async (req, res) => {
88
+ try {
89
+ const { provider, accessToken, name, avatar } = req.body;
90
+ // Rate limiting — scoped per-IP per-provider (not global) to prevent DoS.
91
+ // IP extracted from cf-connecting-ip (Cloudflare) > x-forwarded-for (reverse proxy) > req.ip.
92
+ // Limit: 10 attempts per minute per IP+provider combination.
93
+ const clientIp = req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || 'unknown';
94
+ if (!rateLimiter.check(`${clientIp}:${provider || 'unknown'}`)) {
95
+ res.status(429).json({
96
+ success: false,
97
+ error: {
98
+ type: 'rate_limit_exceeded',
99
+ message: 'Too many OAuth attempts. Please try again in a minute.',
100
+ hint: 'Wait 1 minute before retrying.',
101
+ docs: 'https://webpeel.dev/docs/errors#rate-limit-exceeded',
102
+ },
103
+ requestId: req.requestId || crypto.randomUUID(),
104
+ });
105
+ return;
106
+ }
107
+ // Input validation
108
+ if (!provider || !accessToken) {
109
+ res.status(400).json({
110
+ success: false,
111
+ error: {
112
+ type: 'missing_fields',
113
+ message: 'provider and accessToken are required',
114
+ hint: 'Include both "provider" and "accessToken" in the request body.',
115
+ docs: 'https://webpeel.dev/docs/errors#missing-fields',
116
+ },
117
+ requestId: req.requestId || crypto.randomUUID(),
118
+ });
119
+ return;
120
+ }
121
+ // Validate provider
122
+ if (provider !== 'github' && provider !== 'google') {
123
+ res.status(400).json({
124
+ success: false,
125
+ error: {
126
+ type: 'invalid_provider',
127
+ message: 'provider must be "github" or "google"',
128
+ hint: 'Use "github" or "google" as the provider value.',
129
+ docs: 'https://webpeel.dev/docs/errors#invalid-provider',
130
+ },
131
+ requestId: req.requestId || crypto.randomUUID(),
132
+ });
133
+ return;
134
+ }
135
+ // SECURITY: Verify the OAuth token server-side and extract trusted identity
136
+ let providerId;
137
+ let email;
138
+ if (provider === 'github') {
139
+ // Verify GitHub access token
140
+ const ghRes = await fetch('https://api.github.com/user', {
141
+ headers: {
142
+ Authorization: `Bearer ${accessToken}`,
143
+ Accept: 'application/vnd.github+json',
144
+ },
145
+ });
146
+ if (!ghRes.ok) {
147
+ res.status(401).json({
148
+ success: false,
149
+ error: { type: 'invalid_token', message: 'Invalid GitHub access token.' },
150
+ requestId: req.requestId,
151
+ });
152
+ return;
153
+ }
154
+ const ghUser = await ghRes.json();
155
+ providerId = String(ghUser.id);
156
+ // GitHub may not return email on /user; fetch from /user/emails
157
+ if (ghUser.email) {
158
+ email = ghUser.email;
159
+ }
160
+ else {
161
+ const emailRes = await fetch('https://api.github.com/user/emails', {
162
+ headers: {
163
+ Authorization: `Bearer ${accessToken}`,
164
+ Accept: 'application/vnd.github+json',
165
+ },
166
+ });
167
+ if (emailRes.ok) {
168
+ const emails = await emailRes.json();
169
+ const primary = emails.find(e => e.primary && e.verified);
170
+ email = primary?.email || emails[0]?.email || '';
171
+ }
172
+ else {
173
+ email = '';
174
+ }
175
+ }
176
+ }
177
+ else {
178
+ // Verify Google ID token
179
+ const gRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(accessToken)}`);
180
+ if (!gRes.ok) {
181
+ res.status(401).json({
182
+ success: false,
183
+ error: { type: 'invalid_token', message: 'Invalid Google token.' },
184
+ requestId: req.requestId,
185
+ });
186
+ return;
187
+ }
188
+ const gUser = await gRes.json();
189
+ providerId = gUser.sub;
190
+ email = gUser.email || '';
191
+ }
192
+ // Validate email from verified token
193
+ if (!email || !isValidEmail(email)) {
194
+ res.status(400).json({
195
+ success: false,
196
+ error: {
197
+ type: 'invalid_email',
198
+ message: 'Could not retrieve a valid email from OAuth provider',
199
+ hint: 'Ensure your OAuth account has a verified email address.',
200
+ docs: 'https://webpeel.dev/docs/errors#invalid-email',
201
+ },
202
+ requestId: req.requestId || crypto.randomUUID(),
203
+ });
204
+ return;
205
+ }
206
+ const client = await pool.connect();
207
+ try {
208
+ await client.query('BEGIN');
209
+ // Check if OAuth account already exists
210
+ const oauthResult = await client.query(`SELECT user_id FROM oauth_accounts
211
+ WHERE provider = $1 AND provider_id = $2`, [provider, providerId]);
212
+ let userId;
213
+ let isNew = false;
214
+ let apiKey;
215
+ if (oauthResult.rows.length > 0) {
216
+ // Existing OAuth account - get user
217
+ userId = oauthResult.rows[0].user_id;
218
+ // Update OAuth account info
219
+ await client.query(`UPDATE oauth_accounts
220
+ SET email = $1, name = $2, avatar_url = $3, updated_at = now()
221
+ WHERE provider = $4 AND provider_id = $5`, [email, name || null, avatar || null, provider, providerId]);
222
+ }
223
+ else {
224
+ // New OAuth account - check if user with this email exists
225
+ const userResult = await client.query('SELECT id FROM users WHERE email = $1', [email]);
226
+ if (userResult.rows.length > 0) {
227
+ // User exists - link OAuth account to existing user
228
+ userId = userResult.rows[0].id;
229
+ // Update user info
230
+ await client.query(`UPDATE users
231
+ SET name = COALESCE($1, name),
232
+ avatar_url = COALESCE($2, avatar_url),
233
+ updated_at = now()
234
+ WHERE id = $3`, [name || null, avatar || null, userId]);
235
+ // Create OAuth account link
236
+ await client.query(`INSERT INTO oauth_accounts
237
+ (user_id, provider, provider_id, email, name, avatar_url)
238
+ VALUES ($1, $2, $3, $4, $5, $6)`, [userId, provider, providerId, email, name || null, avatar || null]);
239
+ }
240
+ else {
241
+ // New user - create account
242
+ const newUserResult = await client.query(`INSERT INTO users
243
+ (email, password_hash, tier, weekly_limit, burst_limit, rate_limit, name, avatar_url)
244
+ VALUES ($1, NULL, 'free', 500, 50, 10, $2, $3)
245
+ RETURNING id`, [email, name || null, avatar || null]);
246
+ userId = newUserResult.rows[0].id;
247
+ isNew = true;
248
+ // Create OAuth account link
249
+ await client.query(`INSERT INTO oauth_accounts
250
+ (user_id, provider, provider_id, email, name, avatar_url)
251
+ VALUES ($1, $2, $3, $4, $5, $6)`, [userId, provider, providerId, email, name || null, avatar || null]);
252
+ // Generate first API key for new user
253
+ apiKey = PostgresAuthStore.generateApiKey();
254
+ const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
255
+ const keyPrefix = PostgresAuthStore.getKeyPrefix(apiKey);
256
+ await client.query(`INSERT INTO api_keys (user_id, key_hash, key_prefix, name)
257
+ VALUES ($1, $2, $3, 'Default')`, [userId, keyHash, keyPrefix]);
258
+ }
259
+ }
260
+ // Get user info for response
261
+ const userInfoResult = await client.query('SELECT id, email, tier, name, avatar_url FROM users WHERE id = $1', [userId]);
262
+ const user = userInfoResult.rows[0];
263
+ // Generate JWT
264
+ const jwtSecret = process.env.JWT_SECRET;
265
+ if (!jwtSecret) {
266
+ throw new Error('JWT_SECRET not configured');
267
+ }
268
+ const token = jwt.sign({
269
+ userId: user.id,
270
+ email: user.email,
271
+ tier: user.tier,
272
+ }, jwtSecret, { expiresIn: '7d' });
273
+ await client.query('COMMIT');
274
+ // Generate refresh token (after commit, uses pool not client)
275
+ const refreshToken = await createRefreshToken(user.id, jwtSecret);
276
+ // Response
277
+ const response = {
278
+ user: {
279
+ id: user.id,
280
+ email: user.email,
281
+ tier: user.tier,
282
+ name: user.name,
283
+ avatar: user.avatar_url,
284
+ },
285
+ token,
286
+ refreshToken,
287
+ expiresIn: 604800,
288
+ isNew,
289
+ };
290
+ // Include API key only for new users
291
+ if (isNew && apiKey) {
292
+ response.apiKey = apiKey;
293
+ }
294
+ res.json(response);
295
+ }
296
+ catch (error) {
297
+ await client.query('ROLLBACK');
298
+ throw error;
299
+ }
300
+ finally {
301
+ client.release();
302
+ }
303
+ }
304
+ catch (error) {
305
+ console.error('OAuth error:', error);
306
+ // Handle specific errors
307
+ if (error.code === '23505') { // Unique violation
308
+ res.status(409).json({
309
+ success: false,
310
+ error: {
311
+ type: 'oauth_conflict',
312
+ message: 'OAuth account already exists',
313
+ hint: 'This OAuth account is already linked to another user.',
314
+ docs: 'https://webpeel.dev/docs/errors#oauth-conflict',
315
+ },
316
+ requestId: req.requestId || crypto.randomUUID(),
317
+ });
318
+ return;
319
+ }
320
+ res.status(500).json({
321
+ success: false,
322
+ error: {
323
+ type: 'oauth_failed',
324
+ message: 'Failed to process OAuth login',
325
+ docs: 'https://webpeel.dev/docs/errors#oauth-failed',
326
+ },
327
+ requestId: req.requestId || crypto.randomUUID(),
328
+ });
329
+ }
330
+ });
331
+ /**
332
+ * POST /v1/auth/recover
333
+ * Email-based session recovery — used by the dashboard when the OAuth token
334
+ * has expired but the user is still authenticated via NextAuth.
335
+ * Trusts the email from the NextAuth JWT and verifies via shared secret.
336
+ */
337
+ router.post('/v1/auth/recover', async (req, res) => {
338
+ try {
339
+ const { email, secret } = req.body;
340
+ if (!email || !secret) {
341
+ return res.status(400).json({
342
+ success: false,
343
+ error: {
344
+ type: 'missing_fields',
345
+ message: 'email and secret required',
346
+ hint: 'Include both "email" and "secret" in the request body.',
347
+ docs: 'https://webpeel.dev/docs/errors#missing-fields',
348
+ },
349
+ requestId: req.requestId || crypto.randomUUID(),
350
+ });
351
+ }
352
+ // Verify the shared secret — proves the request comes from our own dashboard
353
+ const expectedSecret = process.env.DASHBOARD_RECOVER_SECRET || process.env.NEXTAUTH_SECRET;
354
+ if (!expectedSecret || secret !== expectedSecret) {
355
+ return res.status(401).json({ success: false, error: { type: 'unauthorized', message: 'Invalid recovery secret.' }, requestId: req.requestId });
356
+ }
357
+ // Look up user by email
358
+ const result = await pool.query('SELECT id, email, tier, weekly_limit FROM users WHERE email = $1', [email]);
359
+ if (result.rows.length === 0) {
360
+ return res.status(404).json({
361
+ success: false,
362
+ error: {
363
+ type: 'user_not_found',
364
+ message: 'No account found for this email',
365
+ hint: 'Sign up at https://app.webpeel.dev to create an account.',
366
+ docs: 'https://webpeel.dev/docs/errors#user-not-found',
367
+ },
368
+ requestId: req.requestId || crypto.randomUUID(),
369
+ });
370
+ }
371
+ const user = result.rows[0];
372
+ const jwtSecret = process.env.JWT_SECRET || '';
373
+ const token = jwt.sign({ userId: user.id, email: user.email, tier: user.tier }, jwtSecret, { expiresIn: '30d' });
374
+ // Get an active API key prefix for the user (if any)
375
+ const keyResult = await pool.query('SELECT key_prefix FROM api_keys WHERE user_id = $1 AND is_active = true LIMIT 1', [user.id]);
376
+ return res.json({
377
+ token,
378
+ user: { id: user.id, email: user.email, tier: user.tier },
379
+ apiKey: keyResult.rows[0]?.key_prefix ? `${keyResult.rows[0].key_prefix}...` : null,
380
+ });
381
+ }
382
+ catch (err) {
383
+ console.error('Recovery endpoint error:', err);
384
+ return res.status(500).json({
385
+ success: false,
386
+ error: {
387
+ type: 'server_error',
388
+ message: 'An unexpected server error occurred.',
389
+ docs: 'https://webpeel.dev/docs/errors#server-error',
390
+ },
391
+ requestId: req.requestId || crypto.randomUUID(),
392
+ });
393
+ }
394
+ });
395
+ return router;
396
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Playground endpoint — GET /v1/playground?url=<encoded_url>
3
+ * GET /v1/playground/search?q=<query>
4
+ *
5
+ * Unauthenticated endpoints for the WebPeel playground page.
6
+ * Lets visitors try the product without signing up.
7
+ *
8
+ * Security:
9
+ * - CORS-locked to webpeel.dev and localhost
10
+ * - IP-based rate limit: 10 requests per 15 minutes (shared across /fetch and /search)
11
+ * - Simple HTTP-only fetch (no browser rendering)
12
+ * - 5-second timeout
13
+ * - Content truncated to 5,000 chars
14
+ * - No screenshots
15
+ */
16
+ import { Router } from 'express';
17
+ export declare function createPlaygroundRouter(): Router;