@doccov/api 0.2.2 → 0.3.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @doccov/api
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - add rate limiting middleware and job store abstractions
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies
12
+ - @openpkg-ts/spec@0.4.1
13
+
14
+ ## 0.2.3
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies
19
+ - @openpkg-ts/spec@0.4.0
20
+
3
21
  ## 0.2.2
4
22
 
5
23
  ### Patch Changes
@@ -1,14 +1,14 @@
1
1
  import { Writable } from 'node:stream';
2
- import type { VercelRequest, VercelResponse } from '@vercel/node';
3
- import { Sandbox } from '@vercel/sandbox';
4
2
  import {
5
- SandboxFileSystem,
6
3
  detectBuildInfo,
7
4
  detectMonorepo,
8
5
  detectPackageManager,
9
6
  getInstallCommand,
10
7
  getPrimaryBuildScript,
8
+ SandboxFileSystem,
11
9
  } from '@doccov/sdk';
10
+ import type { VercelRequest, VercelResponse } from '@vercel/node';
11
+ import { Sandbox } from '@vercel/sandbox';
12
12
 
13
13
  export const config = {
14
14
  runtime: 'nodejs',
@@ -153,7 +153,13 @@ async function runScanWithProgress(
153
153
  // Try fetching the ref first (might be a tag not fetched by shallow clone)
154
154
  await sandbox.runCommand({
155
155
  cmd: 'git',
156
- args: ['fetch', '--depth', '1', 'origin', `refs/tags/${options.ref}:refs/tags/${options.ref}`],
156
+ args: [
157
+ 'fetch',
158
+ '--depth',
159
+ '1',
160
+ 'origin',
161
+ `refs/tags/${options.ref}:refs/tags/${options.ref}`,
162
+ ],
157
163
  });
158
164
  const retryResult = await sandbox.runCommand({
159
165
  cmd: 'git',
@@ -174,7 +180,9 @@ async function runScanWithProgress(
174
180
 
175
181
  // Detect package manager using SDK
176
182
  const pmInfo = await detectPackageManager(fs);
177
- const pmMessage = pmInfo.lockfile ? `Detected ${pmInfo.name} project` : 'No lockfile detected';
183
+ const pmMessage = pmInfo.lockfile
184
+ ? `Detected ${pmInfo.name} project`
185
+ : 'No lockfile detected';
178
186
  sendEvent({ type: 'progress', stage: 'detecting', message: pmMessage, progress: 15 });
179
187
 
180
188
  // Early monorepo detection - fail fast if monorepo without package param
@@ -189,9 +197,7 @@ async function runScanWithProgress(
189
197
  progress: 17,
190
198
  });
191
199
 
192
- const availablePackages = mono.packages
193
- .filter((p) => !p.private)
194
- .map((p) => p.name);
200
+ const availablePackages = mono.packages.filter((p) => !p.private).map((p) => p.name);
195
201
 
196
202
  await sandbox.stop();
197
203
  sendEvent({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doccov/api",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "DocCov API - Badge endpoint and coverage services",
5
5
  "keywords": [
6
6
  "doccov",
@@ -27,10 +27,11 @@
27
27
  "format": "biome format --write src/"
28
28
  },
29
29
  "dependencies": {
30
- "@openpkg-ts/spec": "^0.3.0",
30
+ "@openpkg-ts/spec": "^0.4.1",
31
31
  "@vercel/sandbox": "^1.0.3",
32
32
  "hono": "^4.0.0",
33
- "ms": "^2.1.3"
33
+ "ms": "^2.1.3",
34
+ "zod": "^3.25.0"
34
35
  },
35
36
  "devDependencies": {
36
37
  "@types/bun": "latest",
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Hono } from 'hono';
2
2
  import { cors } from 'hono/cors';
3
+ import { rateLimit } from './middleware/rate-limit';
3
4
  import { badgeRoute } from './routes/badge';
4
5
  import { leaderboardRoute } from './routes/leaderboard';
5
6
  import { scanRoute } from './routes/scan';
@@ -10,6 +11,16 @@ const app = new Hono();
10
11
  // Middleware
11
12
  app.use('*', cors());
12
13
 
14
+ // Rate limit /scan endpoint: 10 requests per minute per IP
15
+ app.use(
16
+ '/scan/*',
17
+ rateLimit({
18
+ windowMs: 60 * 1000,
19
+ max: 10,
20
+ message: 'Too many scan requests. Please try again in a minute.',
21
+ }),
22
+ );
23
+
13
24
  // Health check
14
25
  app.get('/', (c) => {
15
26
  return c.json({
@@ -0,0 +1,112 @@
1
+ import type { Context, MiddlewareHandler, Next } from 'hono';
2
+
3
+ interface RateLimitOptions {
4
+ /** Time window in milliseconds */
5
+ windowMs: number;
6
+ /** Max requests per window */
7
+ max: number;
8
+ /** Message to return when rate limited */
9
+ message?: string;
10
+ }
11
+
12
+ interface RateLimitEntry {
13
+ count: number;
14
+ resetAt: number;
15
+ }
16
+
17
+ /**
18
+ * In-memory rate limiter store
19
+ * Can be swapped for Redis in production
20
+ */
21
+ class RateLimitStore {
22
+ private store = new Map<string, RateLimitEntry>();
23
+
24
+ get(key: string): RateLimitEntry | undefined {
25
+ const entry = this.store.get(key);
26
+ if (entry && Date.now() > entry.resetAt) {
27
+ this.store.delete(key);
28
+ return undefined;
29
+ }
30
+ return entry;
31
+ }
32
+
33
+ increment(key: string, windowMs: number): RateLimitEntry {
34
+ const now = Date.now();
35
+ const existing = this.get(key);
36
+
37
+ if (existing) {
38
+ existing.count++;
39
+ return existing;
40
+ }
41
+
42
+ const entry: RateLimitEntry = {
43
+ count: 1,
44
+ resetAt: now + windowMs,
45
+ };
46
+ this.store.set(key, entry);
47
+ return entry;
48
+ }
49
+
50
+ cleanup(): void {
51
+ const now = Date.now();
52
+ for (const [key, entry] of this.store.entries()) {
53
+ if (now > entry.resetAt) {
54
+ this.store.delete(key);
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ const store = new RateLimitStore();
61
+
62
+ // Cleanup expired entries every minute
63
+ setInterval(() => store.cleanup(), 60 * 1000).unref();
64
+
65
+ /**
66
+ * Get client IP from request
67
+ */
68
+ function getClientIp(c: Context): string {
69
+ // Check common proxy headers
70
+ const forwarded = c.req.header('x-forwarded-for');
71
+ if (forwarded) {
72
+ return forwarded.split(',')[0].trim();
73
+ }
74
+
75
+ const realIp = c.req.header('x-real-ip');
76
+ if (realIp) {
77
+ return realIp;
78
+ }
79
+
80
+ // Vercel-specific
81
+ const vercelIp = c.req.header('x-vercel-forwarded-for');
82
+ if (vercelIp) {
83
+ return vercelIp.split(',')[0].trim();
84
+ }
85
+
86
+ return 'unknown';
87
+ }
88
+
89
+ /**
90
+ * Rate limiting middleware for Hono
91
+ */
92
+ export function rateLimit(options: RateLimitOptions): MiddlewareHandler {
93
+ const { windowMs, max, message = 'Too many requests, please try again later' } = options;
94
+
95
+ return async (c: Context, next: Next) => {
96
+ const ip = getClientIp(c);
97
+ const key = `ratelimit:${ip}`;
98
+
99
+ const entry = store.increment(key, windowMs);
100
+
101
+ // Set rate limit headers
102
+ c.header('X-RateLimit-Limit', String(max));
103
+ c.header('X-RateLimit-Remaining', String(Math.max(0, max - entry.count)));
104
+ c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000)));
105
+
106
+ if (entry.count > max) {
107
+ return c.json({ error: message }, 429);
108
+ }
109
+
110
+ await next();
111
+ };
112
+ }
@@ -3,26 +3,37 @@ import * as path from 'node:path';
3
3
  import { Hono } from 'hono';
4
4
  import { isSandboxAvailable, runScanInSandbox } from '../sandbox-runner';
5
5
  import { type ScanJob, type ScanResult, scanJobStore } from '../scan-worker';
6
+ import { scanRequestSchema } from '../schemas/scan';
6
7
 
7
8
  export const scanRoute = new Hono();
8
9
 
9
- interface ScanRequestBody {
10
- url: string;
11
- ref?: string;
12
- package?: string;
13
- }
14
-
15
10
  /**
16
11
  * POST /scan
17
12
  * Start a new scan job
18
13
  */
19
14
  scanRoute.post('/', async (c) => {
20
- const body = await c.req.json<ScanRequestBody>();
15
+ // Parse JSON with error handling
16
+ let rawBody: unknown;
17
+ try {
18
+ rawBody = await c.req.json();
19
+ } catch {
20
+ return c.json({ error: 'Invalid JSON' }, 400);
21
+ }
21
22
 
22
- if (!body.url) {
23
- return c.json({ error: 'url is required' }, 400);
23
+ // Validate request body with Zod schema
24
+ const parsed = scanRequestSchema.safeParse(rawBody);
25
+ if (!parsed.success) {
26
+ return c.json(
27
+ {
28
+ error: 'Validation failed',
29
+ details: parsed.error.issues.map((i) => i.message),
30
+ },
31
+ 400,
32
+ );
24
33
  }
25
34
 
35
+ const body = parsed.data;
36
+
26
37
  // Generate job ID
27
38
  const jobId = `scan-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
28
39
 
@@ -2,6 +2,8 @@
2
2
  * Scan job store and caching layer
3
3
  */
4
4
 
5
+ import type { JobStore } from './stores/job-store.interface';
6
+
5
7
  export interface ScanResult {
6
8
  owner: string;
7
9
  repo: string;
@@ -42,8 +44,9 @@ const JOB_TTL_MS = 10 * 60 * 1000; // 10 minutes
42
44
 
43
45
  /**
44
46
  * In-memory job store with caching
47
+ * Implements JobStore interface for easy swap to Redis/KV
45
48
  */
46
- class ScanJobStore {
49
+ class ScanJobStore implements JobStore {
47
50
  private jobs = new Map<string, ScanJob>();
48
51
  private cache = new Map<string, CacheEntry>();
49
52
 
@@ -123,9 +126,10 @@ class ScanJobStore {
123
126
  export const scanJobStore = new ScanJobStore();
124
127
 
125
128
  // Periodic cleanup every 5 minutes
129
+ // Use .unref() to prevent keeping the process alive
126
130
  setInterval(
127
131
  () => {
128
132
  scanJobStore.cleanup();
129
133
  },
130
134
  5 * 60 * 1000,
131
- );
135
+ ).unref();
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Schema for POST /scan request body
5
+ * Validates and restricts URLs to GitHub only
6
+ */
7
+ export const scanRequestSchema = z.object({
8
+ url: z
9
+ .string()
10
+ .url('Invalid URL format')
11
+ .regex(/^https:\/\/github\.com\//, 'Only GitHub URLs are allowed'),
12
+ ref: z.string().optional(),
13
+ package: z.string().optional(),
14
+ });
15
+
16
+ export type ScanRequestBody = z.infer<typeof scanRequestSchema>;
@@ -0,0 +1,25 @@
1
+ import type { ScanJob, ScanResult } from '../scan-worker';
2
+
3
+ /**
4
+ * Interface for scan job storage
5
+ * Allows swapping in-memory store for Redis/KV in production
6
+ */
7
+ export interface JobStore {
8
+ /** Get a job by ID */
9
+ get(jobId: string): ScanJob | undefined | Promise<ScanJob | undefined>;
10
+
11
+ /** Set/update a job */
12
+ set(jobId: string, job: ScanJob): void | Promise<void>;
13
+
14
+ /** Get cached scan result */
15
+ getFromCache(key: string): ScanResult | undefined | Promise<ScanResult | undefined>;
16
+
17
+ /** Cache a scan result */
18
+ setCache(key: string, result: ScanResult): void | Promise<void>;
19
+
20
+ /** Cleanup expired entries */
21
+ cleanup(): void | Promise<void>;
22
+
23
+ /** Get store statistics */
24
+ stats(): { jobs: number; cache: number } | Promise<{ jobs: number; cache: number }>;
25
+ }