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,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,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;
|