@chappibunny/repolens 0.4.3 → 0.6.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.
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Rate Limiting for External APIs
3
+ *
4
+ * Implements rate limiting and request throttling for Notion API and other services
5
+ * to prevent abuse and respect API limits.
6
+ */
7
+
8
+ /**
9
+ * Rate limiter class using token bucket algorithm
10
+ */
11
+ class RateLimiter {
12
+ constructor(options = {}) {
13
+ this.maxRequests = options.maxRequests || 3; // Notion: 3 requests per second
14
+ this.timeWindow = options.timeWindow || 1000; // 1 second
15
+ this.tokens = this.maxRequests;
16
+ this.lastRefill = Date.now();
17
+ this.queue = [];
18
+ }
19
+
20
+ /**
21
+ * Refill tokens based on elapsed time
22
+ */
23
+ refill() {
24
+ const now = Date.now();
25
+ const elapsed = now - this.lastRefill;
26
+
27
+ if (elapsed >= this.timeWindow) {
28
+ const tokensToAdd = Math.floor(elapsed / this.timeWindow) * this.maxRequests;
29
+ this.tokens = Math.min(this.maxRequests, this.tokens + tokensToAdd);
30
+ this.lastRefill = now;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Attempt to consume a token
36
+ * @returns {boolean} True if token available, false otherwise
37
+ */
38
+ tryConsume() {
39
+ this.refill();
40
+
41
+ if (this.tokens > 0) {
42
+ this.tokens--;
43
+ return true;
44
+ }
45
+
46
+ return false;
47
+ }
48
+
49
+ /**
50
+ * Wait for a token to become available
51
+ * @returns {Promise<void>}
52
+ */
53
+ async waitForToken() {
54
+ return new Promise((resolve) => {
55
+ const attemptConsume = () => {
56
+ if (this.tryConsume()) {
57
+ resolve();
58
+ } else {
59
+ // Wait a bit and try again
60
+ const waitTime = Math.max(50, (this.timeWindow / this.maxRequests) / 2);
61
+ setTimeout(attemptConsume, waitTime);
62
+ }
63
+ };
64
+
65
+ attemptConsume();
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Execute a function with rate limiting
71
+ * @param {Function} fn - Async function to execute
72
+ * @returns {Promise<any>}
73
+ */
74
+ async execute(fn) {
75
+ await this.waitForToken();
76
+ return await fn();
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Retry logic with exponential backoff
82
+ */
83
+ class RetryPolicy {
84
+ constructor(options = {}) {
85
+ this.maxRetries = options.maxRetries || 3;
86
+ this.initialDelay = options.initialDelay || 1000; // 1 second
87
+ this.maxDelay = options.maxDelay || 30000; // 30 seconds
88
+ this.backoffMultiplier = options.backoffMultiplier || 2;
89
+ this.retryableStatuses = options.retryableStatuses || [429, 500, 502, 503, 504];
90
+ }
91
+
92
+ /**
93
+ * Execute function with retry logic
94
+ * @param {Function} fn - Async function to execute
95
+ * @param {object} context - Context for logging
96
+ * @returns {Promise<any>}
97
+ */
98
+ async execute(fn, context = {}) {
99
+ let lastError;
100
+ let delay = this.initialDelay;
101
+
102
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
103
+ try {
104
+ return await fn();
105
+ } catch (error) {
106
+ lastError = error;
107
+
108
+ // Check if error is retryable
109
+ if (!this.shouldRetry(error, attempt)) {
110
+ throw error;
111
+ }
112
+
113
+ // Log retry attempt
114
+ if (context.onRetry) {
115
+ context.onRetry(attempt + 1, this.maxRetries, delay, error);
116
+ }
117
+
118
+ // Wait before retrying
119
+ await this.sleep(delay);
120
+
121
+ // Increase delay for next retry (exponential backoff)
122
+ delay = Math.min(delay * this.backoffMultiplier, this.maxDelay);
123
+
124
+ // Add jitter to prevent thundering herd
125
+ delay += Math.random() * 1000;
126
+ }
127
+ }
128
+
129
+ throw lastError;
130
+ }
131
+
132
+ /**
133
+ * Check if error should trigger a retry
134
+ */
135
+ shouldRetry(error, attempt) {
136
+ // Don't retry if max attempts reached
137
+ if (attempt >= this.maxRetries) {
138
+ return false;
139
+ }
140
+
141
+ // Retry on rate limit errors
142
+ if (error.status === 429 || error.code === "rate_limited") {
143
+ return true;
144
+ }
145
+
146
+ // Retry on retryable HTTP statuses
147
+ if (error.status && this.retryableStatuses.includes(error.status)) {
148
+ return true;
149
+ }
150
+
151
+ // Retry on network errors
152
+ if (error.code === "ECONNRESET" || error.code === "ETIMEDOUT") {
153
+ return true;
154
+ }
155
+
156
+ return false;
157
+ }
158
+
159
+ /**
160
+ * Sleep for specified milliseconds
161
+ */
162
+ sleep(ms) {
163
+ return new Promise(resolve => setTimeout(resolve, ms));
164
+ }
165
+ }
166
+
167
+ // Global rate limiters for different services
168
+ const notionRateLimiter = new RateLimiter({
169
+ maxRequests: 3, // Notion: 3 requests per second
170
+ timeWindow: 1000,
171
+ });
172
+
173
+ const openAIRateLimiter = new RateLimiter({
174
+ maxRequests: 3, // Conservative: 3 requests per second
175
+ timeWindow: 1000,
176
+ });
177
+
178
+ /**
179
+ * Execute Notion API request with rate limiting and retries
180
+ * @param {Function} fn - Async function that makes Notion API call
181
+ * @param {object} options - Options for rate limiting and retries
182
+ * @returns {Promise<any>}
183
+ */
184
+ export async function executeNotionRequest(fn, options = {}) {
185
+ const retryPolicy = new RetryPolicy({
186
+ maxRetries: options.maxRetries || 3,
187
+ initialDelay: options.initialDelay || 1000,
188
+ });
189
+
190
+ return await notionRateLimiter.execute(async () => {
191
+ return await retryPolicy.execute(fn, {
192
+ onRetry: (attempt, maxRetries, delay, error) => {
193
+ if (options.onRetry) {
194
+ options.onRetry(attempt, maxRetries, delay, error);
195
+ } else {
196
+ console.warn(
197
+ `Notion API retry ${attempt}/${maxRetries} after ${Math.round(delay)}ms (${error.message})`
198
+ );
199
+ }
200
+ },
201
+ });
202
+ });
203
+ }
204
+
205
+ /**
206
+ * Execute OpenAI/AI API request with rate limiting and retries
207
+ * @param {Function} fn - Async function that makes AI API call
208
+ * @param {object} options - Options for rate limiting and retries
209
+ * @returns {Promise<any>}
210
+ */
211
+ export async function executeAIRequest(fn, options = {}) {
212
+ const retryPolicy = new RetryPolicy({
213
+ maxRetries: options.maxRetries || 2, // Fewer retries for AI (can be expensive)
214
+ initialDelay: options.initialDelay || 2000,
215
+ maxDelay: options.maxDelay || 60000, // AI can take longer
216
+ });
217
+
218
+ return await openAIRateLimiter.execute(async () => {
219
+ return await retryPolicy.execute(fn, {
220
+ onRetry: (attempt, maxRetries, delay, error) => {
221
+ if (options.onRetry) {
222
+ options.onRetry(attempt, maxRetries, delay, error);
223
+ } else {
224
+ console.warn(
225
+ `AI API retry ${attempt}/${maxRetries} after ${Math.round(delay)}ms (${error.message})`
226
+ );
227
+ }
228
+ },
229
+ });
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Batch API requests to respect rate limits
235
+ * @param {Array<Function>} requests - Array of async functions
236
+ * @param {object} options - Batching options
237
+ * @returns {Promise<Array<any>>}
238
+ */
239
+ export async function batchRequests(requests, options = {}) {
240
+ const batchSize = options.batchSize || 3;
241
+ const results = [];
242
+
243
+ for (let i = 0; i < requests.length; i += batchSize) {
244
+ const batch = requests.slice(i, i + batchSize);
245
+ const batchResults = await Promise.all(
246
+ batch.map(fn => fn().catch(err => ({ error: err })))
247
+ );
248
+ results.push(...batchResults);
249
+
250
+ // Progress callback
251
+ if (options.onProgress) {
252
+ options.onProgress(Math.min(i + batchSize, requests.length), requests.length);
253
+ }
254
+ }
255
+
256
+ return results;
257
+ }
258
+
259
+ /**
260
+ * Create a rate-limited version of a function
261
+ * @param {Function} fn - Function to rate limit
262
+ * @param {object} options - Rate limiter options
263
+ * @returns {Function} Rate-limited function
264
+ */
265
+ export function rateLimit(fn, options = {}) {
266
+ const limiter = new RateLimiter(options);
267
+
268
+ return async function(...args) {
269
+ return await limiter.execute(() => fn(...args));
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Get rate limiter stats for monitoring
275
+ */
276
+ export function getRateLimiterStats() {
277
+ return {
278
+ notion: {
279
+ availableTokens: notionRateLimiter.tokens,
280
+ maxTokens: notionRateLimiter.maxRequests,
281
+ queueLength: notionRateLimiter.queue.length,
282
+ },
283
+ openai: {
284
+ availableTokens: openAIRateLimiter.tokens,
285
+ maxTokens: openAIRateLimiter.maxRequests,
286
+ queueLength: openAIRateLimiter.queue.length,
287
+ },
288
+ };
289
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Secrets Detection & Sanitization
3
+ *
4
+ * Prevents accidental exposure of sensitive information in documentation,
5
+ * logs, and telemetry.
6
+ */
7
+
8
+ /**
9
+ * Patterns for detecting common secret formats
10
+ */
11
+ const SECRET_PATTERNS = [
12
+ // API Keys
13
+ { name: "OpenAI API Key", pattern: /sk-[a-zA-Z0-9]{20,}/, severity: "high" },
14
+ { name: "Anthropic API Key", pattern: /sk-ant-[a-zA-Z0-9_-]{95,}/, severity: "high" },
15
+ { name: "GitHub Token", pattern: /gh[ps]_[a-zA-Z0-9]{36,}/, severity: "critical" },
16
+ { name: "Generic API Key", pattern: /api[_-]?key[_-]?[a-zA-Z0-9]{20,}/i, severity: "high" },
17
+
18
+ // OAuth Tokens
19
+ { name: "Slack Token", pattern: /xox[baprs]-[a-zA-Z0-9-]{10,}/, severity: "critical" },
20
+ { name: "Bearer Token", pattern: /Bearer\s+[a-zA-Z0-9_-]{20,}/, severity: "high" },
21
+
22
+ // Cloud Provider Keys
23
+ { name: "AWS Access Key", pattern: /AKIA[0-9A-Z]{16}/, severity: "critical" },
24
+ { name: "Google API Key", pattern: /AIzaSy[a-zA-Z0-9_-]{33}/, severity: "critical" },
25
+
26
+ // Database Connection Strings
27
+ { name: "MongoDB URI", pattern: /mongodb(\+srv)?:\/\/[^\s]+/, severity: "high" },
28
+ { name: "PostgreSQL URI", pattern: /postgres(ql)?:\/\/[^\s]+/, severity: "high" },
29
+
30
+ // Generic Secrets
31
+ { name: "Private Key", pattern: /-----BEGIN (RSA|OPENSSH|DSA|EC|PGP) PRIVATE KEY-----/, severity: "critical" },
32
+ { name: "Password in URL", pattern: /[a-zA-Z]{3,10}:\/\/[^:\/]+:[^@\/]+@[^\s]+/, severity: "high" },
33
+
34
+ // Notion Specific
35
+ { name: "Notion Token", pattern: /secret_[a-zA-Z0-9]{30,}/, severity: "high" },
36
+ { name: "Notion Integration Token", pattern: /ntn_[a-zA-Z0-9]{50,}/, severity: "high" },
37
+ ];
38
+
39
+ /**
40
+ * Scan text for potential secrets
41
+ * @param {string} text - Text to scan
42
+ * @returns {Array<{type: string, severity: string, match: string, position: number}>}
43
+ */
44
+ export function detectSecrets(text) {
45
+ if (!text || typeof text !== "string") return [];
46
+
47
+ const findings = [];
48
+
49
+ for (const { name, pattern, severity } of SECRET_PATTERNS) {
50
+ const matches = text.matchAll(new RegExp(pattern, "g"));
51
+
52
+ for (const match of matches) {
53
+ findings.push({
54
+ type: name,
55
+ severity,
56
+ match: match[0],
57
+ position: match.index,
58
+ // Redacted version for safe logging
59
+ redacted: redactSecret(match[0]),
60
+ });
61
+ }
62
+ }
63
+
64
+ return findings;
65
+ }
66
+
67
+ /**
68
+ * Redact a secret for safe display
69
+ * @param {string} secret - Secret to redact
70
+ * @returns {string} Redacted version
71
+ */
72
+ function redactSecret(secret) {
73
+ if (secret.length <= 8) {
74
+ return "***";
75
+ }
76
+
77
+ const visibleChars = 4;
78
+ const start = secret.substring(0, visibleChars);
79
+ const end = secret.substring(secret.length - visibleChars);
80
+
81
+ return `${start}***${end}`;
82
+ }
83
+
84
+ /**
85
+ * Sanitize text by replacing secrets with redacted versions
86
+ * @param {string} text - Text to sanitize
87
+ * @returns {string} Sanitized text
88
+ */
89
+ export function sanitizeSecrets(text) {
90
+ if (!text || typeof text !== "string") return text;
91
+
92
+ let sanitized = text;
93
+
94
+ for (const { pattern } of SECRET_PATTERNS) {
95
+ sanitized = sanitized.replace(new RegExp(pattern, "g"), (match) => {
96
+ return redactSecret(match);
97
+ });
98
+ }
99
+
100
+ return sanitized;
101
+ }
102
+
103
+ /**
104
+ * Check if a string looks like a secret
105
+ * @param {string} value - Value to check
106
+ * @returns {boolean}
107
+ */
108
+ export function isLikelySecret(value) {
109
+ if (!value || typeof value !== "string") return false;
110
+
111
+ // Check against known patterns
112
+ for (const { pattern } of SECRET_PATTERNS) {
113
+ if (pattern.test(value)) {
114
+ return true;
115
+ }
116
+ }
117
+
118
+ // Heuristic: high entropy strings might be secrets
119
+ const entropy = calculateEntropy(value);
120
+ const hasHighEntropy = entropy > 4.5 && value.length >= 20;
121
+
122
+ // Heuristic: looks like base64-encoded data
123
+ const isBase64Like = /^[A-Za-z0-9+/]{20,}={0,2}$/.test(value);
124
+
125
+ return hasHighEntropy || isBase64Like;
126
+ }
127
+
128
+ /**
129
+ * Calculate Shannon entropy of a string
130
+ * @param {string} str - String to analyze
131
+ * @returns {number} Entropy value
132
+ */
133
+ function calculateEntropy(str) {
134
+ const freq = {};
135
+ for (const char of str) {
136
+ freq[char] = (freq[char] || 0) + 1;
137
+ }
138
+
139
+ let entropy = 0;
140
+ const len = str.length;
141
+
142
+ for (const count of Object.values(freq)) {
143
+ const p = count / len;
144
+ entropy -= p * Math.log2(p);
145
+ }
146
+
147
+ return entropy;
148
+ }
149
+
150
+ /**
151
+ * Validate environment variables don't contain unexpected secrets
152
+ * @param {object} env - Environment variables object
153
+ * @returns {Array<{key: string, issue: string}>}
154
+ */
155
+ export function validateEnvironment(env = process.env) {
156
+ const issues = [];
157
+
158
+ // List of keys that SHOULD contain secrets (expected)
159
+ const expectedSecretKeys = [
160
+ "NOTION_TOKEN",
161
+ "REPOLENS_AI_API_KEY",
162
+ "OPENAI_API_KEY",
163
+ "ANTHROPIC_API_KEY",
164
+ "AZURE_OPENAI_API_KEY",
165
+ ];
166
+
167
+ // Check each environment variable
168
+ for (const [key, value] of Object.entries(env)) {
169
+ if (!value || typeof value !== "string") continue;
170
+
171
+ // Skip expected secret keys
172
+ if (expectedSecretKeys.includes(key)) continue;
173
+
174
+ // Check if value looks like a secret but key doesn't indicate it should be
175
+ if (isLikelySecret(value)) {
176
+ issues.push({
177
+ key,
178
+ issue: `Environment variable "${key}" contains what looks like a secret`,
179
+ severity: "warning",
180
+ });
181
+ }
182
+
183
+ // Check for secrets in the value
184
+ const findings = detectSecrets(value);
185
+ if (findings.length > 0) {
186
+ issues.push({
187
+ key,
188
+ issue: `Environment variable "${key}" contains detected secret: ${findings[0].type}`,
189
+ severity: findings[0].severity,
190
+ redacted: findings[0].redacted,
191
+ });
192
+ }
193
+ }
194
+
195
+ return issues;
196
+ }
197
+
198
+ /**
199
+ * Sanitize an object for logging/telemetry
200
+ * Recursively redacts any values that look like secrets
201
+ * @param {any} obj - Object to sanitize
202
+ * @param {number} depth - Current recursion depth
203
+ * @returns {any} Sanitized copy
204
+ */
205
+ export function sanitizeObject(obj, depth = 0) {
206
+ if (depth > 10) return "[Max depth exceeded]";
207
+ if (obj === null || obj === undefined) return obj;
208
+
209
+ // Handle strings
210
+ if (typeof obj === "string") {
211
+ return sanitizeSecrets(obj);
212
+ }
213
+
214
+ // Handle arrays
215
+ if (Array.isArray(obj)) {
216
+ return obj.map(item => sanitizeObject(item, depth + 1));
217
+ }
218
+
219
+ // Handle objects
220
+ if (typeof obj === "object") {
221
+ const sanitized = {};
222
+
223
+ for (const [key, value] of Object.entries(obj)) {
224
+ // Known secret keys - always redact
225
+ const secretKeys = ["token", "key", "secret", "password", "auth", "apikey"];
226
+ const isSecretKey = secretKeys.some(sk => key.toLowerCase().includes(sk));
227
+
228
+ if (isSecretKey && typeof value === "string") {
229
+ sanitized[key] = redactSecret(value);
230
+ } else {
231
+ sanitized[key] = sanitizeObject(value, depth + 1);
232
+ }
233
+ }
234
+
235
+ return sanitized;
236
+ }
237
+
238
+ // Return primitives as-is
239
+ return obj;
240
+ }