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,167 @@
1
+ /**
2
+ * Sliding window rate limiting middleware
3
+ */
4
+ import '../types.js'; // Augments Express.Request with requestId
5
+ export class RateLimiter {
6
+ store = new Map();
7
+ windowMs;
8
+ constructor(windowMs = 60000) {
9
+ this.windowMs = windowMs;
10
+ }
11
+ /**
12
+ * Check if request is allowed under rate limit
13
+ * @param cost - Number of credits this request costs (default: 1)
14
+ */
15
+ checkLimit(identifier, limit, cost = 1) {
16
+ const now = Date.now();
17
+ const windowStart = now - this.windowMs;
18
+ // Get or create entry
19
+ let entry = this.store.get(identifier);
20
+ if (!entry) {
21
+ entry = { timestamps: [] };
22
+ this.store.set(identifier, entry);
23
+ }
24
+ // Remove timestamps outside the window
25
+ entry.timestamps = entry.timestamps.filter(ts => ts > windowStart);
26
+ // Check if limit would be exceeded by this request's cost
27
+ if (entry.timestamps.length + cost > limit) {
28
+ const oldestTimestamp = entry.timestamps[0];
29
+ const retryAfter = oldestTimestamp
30
+ ? Math.ceil((oldestTimestamp + this.windowMs - now) / 1000)
31
+ : 1;
32
+ return {
33
+ allowed: false,
34
+ remaining: Math.max(0, limit - entry.timestamps.length),
35
+ retryAfter,
36
+ };
37
+ }
38
+ // Add `cost` timestamps to represent the weight of this request
39
+ for (let i = 0; i < cost; i++) {
40
+ entry.timestamps.push(now);
41
+ }
42
+ return {
43
+ allowed: true,
44
+ remaining: limit - entry.timestamps.length,
45
+ };
46
+ }
47
+ /**
48
+ * Clean up old entries (call periodically)
49
+ */
50
+ cleanup() {
51
+ const now = Date.now();
52
+ const windowStart = now - this.windowMs;
53
+ for (const [identifier, entry] of this.store.entries()) {
54
+ entry.timestamps = entry.timestamps.filter(ts => ts > windowStart);
55
+ if (entry.timestamps.length === 0) {
56
+ this.store.delete(identifier);
57
+ }
58
+ }
59
+ }
60
+ }
61
+ /**
62
+ * Hourly burst limits per tier.
63
+ * These are the hard caps enforced by the in-memory sliding window.
64
+ * Free: 50/hr, Pro: 100/hr, Max: 500/hr (matches pricing page + stripe.ts).
65
+ */
66
+ const TIER_BURST_LIMITS = {
67
+ free: 50,
68
+ pro: 100,
69
+ max: 500,
70
+ admin: 999999,
71
+ };
72
+ /**
73
+ * System/documentation endpoints that should be exempt from rate limiting.
74
+ * These are low-cost informational endpoints that should never be throttled.
75
+ */
76
+ const EXEMPT_PATHS = [
77
+ '/health',
78
+ '/openapi.json',
79
+ '/openapi.yaml',
80
+ '/docs',
81
+ '/v1/usage',
82
+ '/v1/me',
83
+ '/v1/keys',
84
+ '/v1/activity',
85
+ '/v1/stats',
86
+ ];
87
+ export function createRateLimitMiddleware(limiter) {
88
+ return (req, res, next) => {
89
+ try {
90
+ // Skip rate limiting for system/documentation endpoints
91
+ if (EXEMPT_PATHS.some(p => req.path === p || req.path.startsWith(p + '/'))) {
92
+ return next();
93
+ }
94
+ // Use API key or real client IP as identifier.
95
+ // Prefer Cloudflare CF-Connecting-IP, then x-forwarded-for first
96
+ // entry (real client), then x-real-ip, then req.ip.
97
+ const forwardedFor = req.headers['x-forwarded-for'];
98
+ const firstForwardedIp = typeof forwardedFor === 'string'
99
+ ? forwardedFor.split(',')[0].trim()
100
+ : Array.isArray(forwardedFor) ? forwardedFor[0] : undefined;
101
+ const clientIp = req.headers['cf-connecting-ip']
102
+ || firstForwardedIp
103
+ || req.headers['x-real-ip']
104
+ || req.ip
105
+ || 'unknown';
106
+ const identifier = req.auth?.keyInfo?.key || clientIp;
107
+ // Use tier-based hourly burst limits (matches the 1-hour sliding window)
108
+ const limit = TIER_BURST_LIMITS[req.auth?.tier || 'free'] || 50;
109
+ // Weighted cost based on route — heavier operations consume more credits
110
+ let cost = 1;
111
+ const path = req.path;
112
+ if (path.includes('/crawl') || path.includes('/map'))
113
+ cost = 5;
114
+ else if (path.includes('/batch'))
115
+ cost = 2;
116
+ else if (path.includes('/screenshot'))
117
+ cost = 2;
118
+ else if (req.query.render === 'true' || req.body?.render === true)
119
+ cost = 3;
120
+ const result = limiter.checkLimit(identifier, limit, cost);
121
+ // Calculate reset timestamp
122
+ const now = Date.now();
123
+ const resetTimestamp = Math.ceil((now + limiter['windowMs']) / 1000);
124
+ // Set rate limit headers on ALL responses
125
+ res.setHeader('X-RateLimit-Limit', limit.toString());
126
+ res.setHeader('X-RateLimit-Remaining', Math.max(0, result.remaining).toString());
127
+ res.setHeader('X-RateLimit-Reset', resetTimestamp.toString());
128
+ // Add plan header if authenticated
129
+ if (req.auth?.tier) {
130
+ res.setHeader('X-WebPeel-Plan', req.auth.tier);
131
+ }
132
+ if (!result.allowed) {
133
+ const retryAfterSecs = result.retryAfter;
134
+ res.setHeader('Retry-After', retryAfterSecs.toString());
135
+ const tier = req.auth?.tier || 'free';
136
+ const upgradeHint = tier === 'free'
137
+ ? ' Upgrade to Pro ($9/mo) for 100/hr burst limit → https://webpeel.dev/pricing'
138
+ : tier === 'pro'
139
+ ? ' Upgrade to Max ($29/mo) for 500/hr burst limit → https://webpeel.dev/pricing'
140
+ : '';
141
+ res.status(429).json({
142
+ success: false,
143
+ error: {
144
+ type: 'rate_limited',
145
+ message: `Hourly rate limit exceeded (${limit} requests/hr on ${tier} plan). Try again in ${retryAfterSecs}s.`,
146
+ hint: `Retry after ${retryAfterSecs} seconds.${upgradeHint}`,
147
+ docs: 'https://webpeel.dev/docs/errors#rate-limited',
148
+ },
149
+ metadata: { requestId: req.requestId },
150
+ });
151
+ return; // Stop processing - rate limit exceeded
152
+ }
153
+ next();
154
+ }
155
+ catch (_error) {
156
+ res.status(500).json({
157
+ success: false,
158
+ error: {
159
+ type: 'internal_error',
160
+ message: 'Rate limiting failed',
161
+ docs: 'https://webpeel.dev/docs/errors#internal-error',
162
+ },
163
+ metadata: { requestId: req.requestId },
164
+ });
165
+ }
166
+ };
167
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * URL validation middleware to prevent SSRF attacks
3
+ * Validates URLs BEFORE any network request is made
4
+ */
5
+ /**
6
+ * Validate URL to prevent SSRF attacks
7
+ * Blocks localhost, private IPs, link-local addresses, and non-HTTP(S) protocols
8
+ */
9
+ export declare function validateUrlForSSRF(urlString: string): void;
10
+ /**
11
+ * SSRF Error class
12
+ */
13
+ export declare class SSRFError extends Error {
14
+ constructor(message: string);
15
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * URL validation middleware to prevent SSRF attacks
3
+ * Validates URLs BEFORE any network request is made
4
+ */
5
+ /**
6
+ * Validate URL to prevent SSRF attacks
7
+ * Blocks localhost, private IPs, link-local addresses, and non-HTTP(S) protocols
8
+ */
9
+ export function validateUrlForSSRF(urlString) {
10
+ // Parse URL
11
+ let url;
12
+ try {
13
+ url = new URL(urlString);
14
+ }
15
+ catch {
16
+ throw new Error('Invalid URL format');
17
+ }
18
+ // Only allow HTTP(S)
19
+ if (!['http:', 'https:'].includes(url.protocol)) {
20
+ throw new SSRFError('Cannot fetch localhost, private networks, or non-HTTP URLs');
21
+ }
22
+ const hostname = url.hostname.toLowerCase();
23
+ // Block localhost patterns
24
+ const localhostPatterns = ['localhost', '0.0.0.0'];
25
+ if (localhostPatterns.some(pattern => hostname === pattern || hostname.endsWith('.' + pattern))) {
26
+ throw new SSRFError('Cannot fetch localhost, private networks, or non-HTTP URLs');
27
+ }
28
+ // Parse and validate IP addresses
29
+ const ipv4Info = parseIPv4(hostname);
30
+ if (ipv4Info) {
31
+ validateIPv4ForSSRF(ipv4Info);
32
+ }
33
+ // Validate IPv6
34
+ if (hostname.includes(':')) {
35
+ validateIPv6ForSSRF(hostname);
36
+ }
37
+ }
38
+ /**
39
+ * SSRF Error class
40
+ */
41
+ export class SSRFError extends Error {
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = 'SSRFError';
45
+ }
46
+ }
47
+ /**
48
+ * Parse IPv4 address in any format (dotted, hex, octal, decimal)
49
+ */
50
+ function parseIPv4(hostname) {
51
+ const cleaned = hostname.replace(/^\[|\]$/g, '');
52
+ // Standard dotted notation: 192.168.1.1
53
+ const dottedRegex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
54
+ const dottedMatch = cleaned.match(dottedRegex);
55
+ if (dottedMatch) {
56
+ const octets = dottedMatch.slice(1).map(Number);
57
+ if (octets.every(o => o >= 0 && o <= 255)) {
58
+ return octets;
59
+ }
60
+ throw new SSRFError('Cannot fetch localhost, private networks, or non-HTTP URLs');
61
+ }
62
+ // Hex notation: 0x7f000001
63
+ if (/^0x[0-9a-fA-F]+$/.test(cleaned)) {
64
+ const num = parseInt(cleaned, 16);
65
+ return [
66
+ (num >>> 24) & 0xff,
67
+ (num >>> 16) & 0xff,
68
+ (num >>> 8) & 0xff,
69
+ num & 0xff,
70
+ ];
71
+ }
72
+ // Octal notation
73
+ if (/^0[0-7]/.test(cleaned)) {
74
+ if (/^0[0-7]+$/.test(cleaned)) {
75
+ const num = parseInt(cleaned, 8);
76
+ if (num <= 0xffffffff) {
77
+ return [
78
+ (num >>> 24) & 0xff,
79
+ (num >>> 16) & 0xff,
80
+ (num >>> 8) & 0xff,
81
+ num & 0xff,
82
+ ];
83
+ }
84
+ }
85
+ const parts = cleaned.split('.');
86
+ if (parts.length === 4) {
87
+ const octets = parts.map(p => parseInt(p, /^0[0-7]/.test(p) ? 8 : 10));
88
+ if (octets.every(o => o >= 0 && o <= 255)) {
89
+ return octets;
90
+ }
91
+ }
92
+ }
93
+ // Decimal notation: 2130706433
94
+ if (/^\d+$/.test(cleaned)) {
95
+ const num = parseInt(cleaned, 10);
96
+ if (num <= 0xffffffff) {
97
+ return [
98
+ (num >>> 24) & 0xff,
99
+ (num >>> 16) & 0xff,
100
+ (num >>> 8) & 0xff,
101
+ num & 0xff,
102
+ ];
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+ /**
108
+ * Validate IPv4 address against private/reserved ranges
109
+ */
110
+ function validateIPv4ForSSRF(octets) {
111
+ const [a, b, c, d] = octets;
112
+ // Loopback: 127.0.0.0/8
113
+ if (a === 127) {
114
+ throw new SSRFError('Cannot fetch localhost, private networks, or non-HTTP URLs');
115
+ }
116
+ // Private: 10.0.0.0/8
117
+ if (a === 10) {
118
+ throw new SSRFError('Cannot fetch localhost, private networks, or non-HTTP URLs');
119
+ }
120
+ // Private: 172.16.0.0/12
121
+ if (a === 172 && b >= 16 && b <= 31) {
122
+ throw new SSRFError('Cannot fetch localhost, private networks, or non-HTTP URLs');
123
+ }
124
+ // Private: 192.168.0.0/16
125
+ if (a === 192 && b === 168) {
126
+ throw new SSRFError('Cannot fetch localhost, private networks, or non-HTTP URLs');
127
+ }
128
+ // Link-local: 169.254.0.0/16 (includes AWS metadata endpoint)
129
+ if (a === 169 && b === 254) {
130
+ throw new SSRFError('Cannot fetch localhost, private networks, or non-HTTP URLs');
131
+ }
132
+ // Broadcast: 255.255.255.255
133
+ if (a === 255 && b === 255 && c === 255 && d === 255) {
134
+ throw new SSRFError('Cannot fetch localhost, private networks, or non-HTTP URLs');
135
+ }
136
+ // This network: 0.0.0.0/8
137
+ if (a === 0) {
138
+ throw new SSRFError('Cannot fetch localhost, private networks, or non-HTTP URLs');
139
+ }
140
+ }
141
+ /**
142
+ * Validate IPv6 address against private/reserved ranges
143
+ */
144
+ function validateIPv6ForSSRF(hostname) {
145
+ const addr = hostname.replace(/^\[|\]$/g, '').toLowerCase();
146
+ // Loopback: ::1
147
+ if (addr === '::1' || addr === '0:0:0:0:0:0:0:1') {
148
+ throw new SSRFError('Cannot fetch localhost, private networks, or non-HTTP URLs');
149
+ }
150
+ // IPv6 mapped IPv4: ::ffff:192.168.1.1
151
+ if (addr.startsWith('::ffff:')) {
152
+ const ipv4Part = addr.substring(7);
153
+ if (ipv4Part.includes('.')) {
154
+ const parts = ipv4Part.split('.');
155
+ if (parts.length === 4) {
156
+ const octets = parts.map(p => parseInt(p, 10));
157
+ if (octets.every(o => !isNaN(o) && o >= 0 && o <= 255)) {
158
+ validateIPv4ForSSRF(octets);
159
+ }
160
+ }
161
+ }
162
+ else {
163
+ const hexStr = ipv4Part.replace(/:/g, '');
164
+ if (/^[0-9a-f]{1,8}$/.test(hexStr)) {
165
+ const num = parseInt(hexStr, 16);
166
+ const octets = [
167
+ (num >>> 24) & 0xff,
168
+ (num >>> 16) & 0xff,
169
+ (num >>> 8) & 0xff,
170
+ num & 0xff,
171
+ ];
172
+ validateIPv4ForSSRF(octets);
173
+ }
174
+ }
175
+ throw new SSRFError('Cannot fetch localhost, private networks, or non-HTTP URLs');
176
+ }
177
+ // Unique local addresses: fc00::/7
178
+ if (addr.startsWith('fc') || addr.startsWith('fd')) {
179
+ throw new SSRFError('Cannot fetch localhost, private networks, or non-HTTP URLs');
180
+ }
181
+ // Link-local: fe80::/10
182
+ if (addr.startsWith('fe8') || addr.startsWith('fe9') ||
183
+ addr.startsWith('fea') || addr.startsWith('feb')) {
184
+ throw new SSRFError('Cannot fetch localhost, private networks, or non-HTTP URLs');
185
+ }
186
+ }