@doccov/api 0.4.0 → 0.5.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,18 @@
1
1
  # @doccov/api
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Enhanced quality rules, filtering, github context, analysis reports, new API routes (ai, billing, demo, github-app, invites, orgs), trends command, diff capabilities
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies
12
+ - @doccov/sdk@0.18.0
13
+ - @openpkg-ts/spec@0.10.0
14
+ - @doccov/db@0.1.0
15
+
3
16
  ## 0.4.0
4
17
 
5
18
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doccov/api",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "DocCov API - Badge endpoint and coverage services",
5
5
  "keywords": [
6
6
  "doccov",
@@ -30,9 +30,9 @@
30
30
  "dependencies": {
31
31
  "@ai-sdk/anthropic": "^2.0.55",
32
32
  "@doccov/db": "workspace:*",
33
- "@doccov/sdk": "^0.15.0",
33
+ "@doccov/sdk": "^0.18.0",
34
34
  "@hono/node-server": "^1.14.3",
35
- "@openpkg-ts/spec": "^0.9.0",
35
+ "@openpkg-ts/spec": "^0.10.0",
36
36
  "@polar-sh/hono": "^0.5.3",
37
37
  "@vercel/sandbox": "^1.0.3",
38
38
  "ai": "^5.0.111",
package/src/index.ts CHANGED
@@ -1,14 +1,19 @@
1
1
  import { Hono } from 'hono';
2
2
  import { cors } from 'hono/cors';
3
3
  import { logger } from 'hono/logger';
4
+ import { anonymousRateLimit } from './middleware/anonymous-rate-limit';
4
5
  import { requireApiKey } from './middleware/api-key-auth';
5
6
  import { orgRateLimit } from './middleware/org-rate-limit';
6
7
  import { rateLimit } from './middleware/rate-limit';
8
+ import { aiRoute } from './routes/ai';
7
9
  import { apiKeysRoute } from './routes/api-keys';
8
10
  import { authRoute } from './routes/auth';
9
11
  import { badgeRoute } from './routes/badge';
10
12
  import { billingRoute } from './routes/billing';
11
13
  import { coverageRoute } from './routes/coverage';
14
+ import { demoRoute } from './routes/demo';
15
+ import { githubAppRoute } from './routes/github-app';
16
+ import { invitesRoute } from './routes/invites';
12
17
  import { orgsRoute } from './routes/orgs';
13
18
  import { planRoute } from './routes/plan';
14
19
 
@@ -45,9 +50,13 @@ app.get('/', (c) => {
45
50
  badge: '/badge/:owner/:repo',
46
51
  billing: '/billing/*',
47
52
  coverage: '/coverage/*',
53
+ github: '/github/* (App install, webhooks)',
54
+ invites: '/invites/:token',
48
55
  orgs: '/orgs/*',
49
56
  plan: '/plan',
50
- v1: '/v1/* (API key required)',
57
+ v1: {
58
+ ai: '/v1/ai/generate (POST), /v1/ai/quota (GET)',
59
+ },
51
60
  health: '/health',
52
61
  },
53
62
  });
@@ -58,8 +67,27 @@ app.get('/health', (c) => {
58
67
  });
59
68
 
60
69
  // Public endpoints (no auth)
70
+ // Anonymous rate limit: 10 requests per day per IP
71
+ app.use(
72
+ '/badge/*',
73
+ anonymousRateLimit({
74
+ windowMs: 24 * 60 * 60 * 1000, // 24 hours
75
+ max: 10,
76
+ message: 'Rate limit reached. Sign up free for 100/day.',
77
+ upgradeUrl: 'https://doccov.com/signup',
78
+ }),
79
+ );
61
80
  app.route('/badge', badgeRoute);
62
81
 
82
+ // Semi-public endpoints (invite info is public, acceptance requires auth)
83
+ app.route('/invites', invitesRoute);
84
+
85
+ // GitHub App (install/callback need auth, webhook is public)
86
+ app.route('/github', githubAppRoute);
87
+
88
+ // Demo endpoint (public, rate-limited)
89
+ app.route('/demo', demoRoute);
90
+
63
91
  // Dashboard endpoints (session auth)
64
92
  app.route('/auth', authRoute);
65
93
  app.route('/api-keys', apiKeysRoute);
@@ -70,7 +98,7 @@ app.route('/plan', planRoute);
70
98
 
71
99
  // API endpoints (API key required)
72
100
  app.use('/v1/*', requireApiKey(), orgRateLimit());
73
- // TODO: app.route('/v1/analyze', analyzeRoute);
101
+ app.route('/v1/ai', aiRoute);
74
102
 
75
103
  // Vercel serverless handler + Bun auto-serves this export
76
104
  export default app;
@@ -0,0 +1,131 @@
1
+ import type { Context, MiddlewareHandler, Next } from 'hono';
2
+
3
+ interface AnonymousRateLimitOptions {
4
+ /** Time window in milliseconds (default: 24 hours) */
5
+ windowMs: number;
6
+ /** Max requests per window */
7
+ max: number;
8
+ /** Message to return when rate limited */
9
+ message?: string;
10
+ /** URL for upgrade CTA */
11
+ upgradeUrl?: string;
12
+ }
13
+
14
+ interface RateLimitEntry {
15
+ count: number;
16
+ resetAt: number;
17
+ }
18
+
19
+ /**
20
+ * In-memory store for anonymous rate limiting
21
+ */
22
+ class AnonymousRateLimitStore {
23
+ private store = new Map<string, RateLimitEntry>();
24
+
25
+ get(key: string): RateLimitEntry | undefined {
26
+ const entry = this.store.get(key);
27
+ if (entry && Date.now() > entry.resetAt) {
28
+ this.store.delete(key);
29
+ return undefined;
30
+ }
31
+ return entry;
32
+ }
33
+
34
+ increment(key: string, windowMs: number): RateLimitEntry {
35
+ const now = Date.now();
36
+ const existing = this.get(key);
37
+
38
+ if (existing) {
39
+ existing.count++;
40
+ return existing;
41
+ }
42
+
43
+ const entry: RateLimitEntry = {
44
+ count: 1,
45
+ resetAt: now + windowMs,
46
+ };
47
+ this.store.set(key, entry);
48
+ return entry;
49
+ }
50
+
51
+ cleanup(): void {
52
+ const now = Date.now();
53
+ for (const [key, entry] of this.store.entries()) {
54
+ if (now > entry.resetAt) {
55
+ this.store.delete(key);
56
+ }
57
+ }
58
+ }
59
+ }
60
+
61
+ const store = new AnonymousRateLimitStore();
62
+
63
+ // Cleanup expired entries every minute
64
+ setInterval(() => store.cleanup(), 60 * 1000).unref();
65
+
66
+ /**
67
+ * Get client IP from request headers
68
+ */
69
+ function getClientIp(c: Context): string {
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
+ const vercelIp = c.req.header('x-vercel-forwarded-for');
81
+ if (vercelIp) {
82
+ return vercelIp.split(',')[0].trim();
83
+ }
84
+
85
+ return 'unknown';
86
+ }
87
+
88
+ /**
89
+ * Anonymous IP-based rate limiting middleware
90
+ * Skips authenticated requests (with API key)
91
+ * Returns upgrade CTA when limit reached
92
+ */
93
+ export function anonymousRateLimit(options: AnonymousRateLimitOptions): MiddlewareHandler {
94
+ const {
95
+ windowMs,
96
+ max,
97
+ message = 'Rate limit reached. Sign up free for 100/day.',
98
+ upgradeUrl = 'https://doccov.com/signup',
99
+ } = options;
100
+
101
+ return async (c: Context, next: Next) => {
102
+ // Skip if authenticated (has API key)
103
+ if (c.get('apiKey')) {
104
+ return next();
105
+ }
106
+
107
+ const ip = getClientIp(c);
108
+ const key = `anon:${ip}`;
109
+
110
+ const entry = store.increment(key, windowMs);
111
+
112
+ // Set rate limit headers
113
+ c.header('X-RateLimit-Limit', String(max));
114
+ c.header('X-RateLimit-Remaining', String(Math.max(0, max - entry.count)));
115
+ c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000)));
116
+
117
+ if (entry.count > max) {
118
+ return c.json(
119
+ {
120
+ error: message,
121
+ limit: max,
122
+ resetAt: new Date(entry.resetAt).toISOString(),
123
+ upgrade: upgradeUrl,
124
+ },
125
+ 429,
126
+ );
127
+ }
128
+
129
+ await next();
130
+ };
131
+ }
@@ -0,0 +1,353 @@
1
+ /**
2
+ * AI generation endpoint
3
+ *
4
+ * POST /v1/ai/generate - Generate JSDoc for undocumented exports
5
+ */
6
+
7
+ import { createAnthropic } from '@ai-sdk/anthropic';
8
+ import { createOpenAI } from '@ai-sdk/openai';
9
+ import { getPlanLimits, type Plan } from '@doccov/db';
10
+ import { generateObject } from 'ai';
11
+ import { Hono } from 'hono';
12
+ import { z } from 'zod';
13
+ import { db } from '../db/client';
14
+ import type { ApiKeyContext } from '../middleware/api-key-auth';
15
+
16
+ type AiVariables = {
17
+ Variables: ApiKeyContext;
18
+ };
19
+
20
+ const aiRoute = new Hono<AiVariables>();
21
+
22
+ // Schema for request body
23
+ const GenerateRequestSchema = z.object({
24
+ exports: z.array(
25
+ z.object({
26
+ name: z.string(),
27
+ kind: z.string(),
28
+ signature: z.string().optional(),
29
+ members: z
30
+ .array(
31
+ z.object({
32
+ name: z.string(),
33
+ type: z.string().optional(),
34
+ }),
35
+ )
36
+ .optional(),
37
+ }),
38
+ ),
39
+ packageName: z.string().optional(),
40
+ });
41
+
42
+ // Schema for AI-generated JSDoc
43
+ const JSDocGenerationSchema = z.object({
44
+ description: z.string().describe('1-2 sentence description of what this does'),
45
+ params: z
46
+ .array(
47
+ z.object({
48
+ name: z.string(),
49
+ type: z.string().optional(),
50
+ description: z.string(),
51
+ }),
52
+ )
53
+ .optional()
54
+ .describe('Parameter descriptions for functions'),
55
+ returns: z
56
+ .object({
57
+ type: z.string().optional(),
58
+ description: z.string(),
59
+ })
60
+ .optional()
61
+ .describe('Return value description for functions'),
62
+ example: z.string().optional().describe('Working code example showing typical usage'),
63
+ typeParams: z
64
+ .array(
65
+ z.object({
66
+ name: z.string(),
67
+ description: z.string(),
68
+ }),
69
+ )
70
+ .optional()
71
+ .describe('Type parameter descriptions for generics'),
72
+ });
73
+
74
+ type JSDocGenerationResult = z.infer<typeof JSDocGenerationSchema>;
75
+
76
+ /**
77
+ * Get AI model - uses server-side API keys
78
+ */
79
+ function getModel() {
80
+ if (process.env.ANTHROPIC_API_KEY) {
81
+ const anthropic = createAnthropic();
82
+ return anthropic('claude-sonnet-4-20250514');
83
+ }
84
+
85
+ if (process.env.OPENAI_API_KEY) {
86
+ const openai = createOpenAI();
87
+ return openai('gpt-4o-mini');
88
+ }
89
+
90
+ throw new Error('No AI provider configured');
91
+ }
92
+
93
+ /**
94
+ * Check and update AI quota for an organization
95
+ * Returns remaining quota or error
96
+ */
97
+ async function checkAndUpdateQuota(
98
+ orgId: string,
99
+ plan: string,
100
+ currentUsed: number,
101
+ resetAt: Date | null,
102
+ callCount: number,
103
+ ): Promise<{ allowed: boolean; remaining: number; resetAt: Date; error?: string }> {
104
+ const limits = getPlanLimits(plan as Plan);
105
+ const monthlyLimit = limits.aiCallsPerMonth;
106
+
107
+ // Check if unlimited
108
+ if (monthlyLimit === Infinity) {
109
+ return { allowed: true, remaining: Infinity, resetAt: new Date() };
110
+ }
111
+
112
+ // Check if we need to reset the counter (monthly)
113
+ const now = new Date();
114
+ const shouldReset = !resetAt || now >= resetAt;
115
+
116
+ let used = currentUsed;
117
+ let nextReset = resetAt || getNextMonthReset();
118
+
119
+ if (shouldReset) {
120
+ // Reset the counter
121
+ used = 0;
122
+ nextReset = getNextMonthReset();
123
+
124
+ await db
125
+ .updateTable('organizations')
126
+ .set({
127
+ aiCallsUsed: 0,
128
+ aiCallsResetAt: nextReset,
129
+ })
130
+ .where('id', '=', orgId)
131
+ .execute();
132
+ }
133
+
134
+ // Check if over limit
135
+ if (used + callCount > monthlyLimit) {
136
+ return {
137
+ allowed: false,
138
+ remaining: Math.max(0, monthlyLimit - used),
139
+ resetAt: nextReset,
140
+ error: `Monthly AI limit reached (${used}/${monthlyLimit} calls used). Set OPENAI_API_KEY or ANTHROPIC_API_KEY for unlimited.`,
141
+ };
142
+ }
143
+
144
+ // Increment usage
145
+ await db
146
+ .updateTable('organizations')
147
+ .set({
148
+ aiCallsUsed: used + callCount,
149
+ })
150
+ .where('id', '=', orgId)
151
+ .execute();
152
+
153
+ // Track in usage_records (async)
154
+ db.insertInto('usage_records')
155
+ .values({
156
+ id: crypto.randomUUID(),
157
+ orgId,
158
+ feature: 'ai_generate',
159
+ count: callCount,
160
+ })
161
+ .execute()
162
+ .catch(console.error);
163
+
164
+ return {
165
+ allowed: true,
166
+ remaining: monthlyLimit - (used + callCount),
167
+ resetAt: nextReset,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Get the first day of next month
173
+ */
174
+ function getNextMonthReset(): Date {
175
+ const now = new Date();
176
+ return new Date(now.getFullYear(), now.getMonth() + 1, 1);
177
+ }
178
+
179
+ /**
180
+ * Generate JSDoc for a single export
181
+ */
182
+ async function generateJSDocForExport(
183
+ exp: {
184
+ name: string;
185
+ kind: string;
186
+ signature?: string;
187
+ members?: { name: string; type?: string }[];
188
+ },
189
+ packageName?: string,
190
+ ): Promise<JSDocGenerationResult> {
191
+ const membersContext =
192
+ exp.members && exp.members.length > 0
193
+ ? `\n\nMembers:\n${exp.members
194
+ .slice(0, 10)
195
+ .map((m) => ` - ${m.name}${m.type ? `: ${m.type}` : ''}`)
196
+ .join('\n')}`
197
+ : '';
198
+
199
+ const prompt = `Generate JSDoc documentation for this TypeScript export.
200
+
201
+ Name: ${exp.name}
202
+ Kind: ${exp.kind}
203
+ ${exp.signature ? `Signature: ${exp.signature}` : ''}${membersContext}
204
+ ${packageName ? `Package: ${packageName}` : ''}
205
+
206
+ Requirements:
207
+ - Description: 1-2 sentences explaining what this does and when to use it
208
+ - For functions: describe each parameter and return value
209
+ - Example: provide a working code snippet showing typical usage
210
+ - Be concise but informative`;
211
+
212
+ // biome-ignore lint/suspicious/noExplicitAny: AI SDK type mismatch between LanguageModelV1/V2
213
+ const { object } = await generateObject({
214
+ model: getModel() as any,
215
+ schema: JSDocGenerationSchema,
216
+ prompt,
217
+ });
218
+
219
+ return object;
220
+ }
221
+
222
+ // POST /v1/ai/generate
223
+ aiRoute.post('/generate', async (c) => {
224
+ const org = c.get('org') as {
225
+ id: string;
226
+ plan: string;
227
+ aiCallsUsed: number;
228
+ aiCallsResetAt: Date | null;
229
+ };
230
+
231
+ if (!org) {
232
+ return c.json({ error: 'Unauthorized' }, 401);
233
+ }
234
+
235
+ // Parse request body
236
+ let body: z.infer<typeof GenerateRequestSchema>;
237
+ try {
238
+ const raw = await c.req.json();
239
+ body = GenerateRequestSchema.parse(raw);
240
+ } catch {
241
+ return c.json({ error: 'Invalid request body' }, 400);
242
+ }
243
+
244
+ if (body.exports.length === 0) {
245
+ return c.json({ error: 'No exports provided' }, 400);
246
+ }
247
+
248
+ // Limit batch size
249
+ if (body.exports.length > 20) {
250
+ return c.json({ error: 'Maximum 20 exports per request' }, 400);
251
+ }
252
+
253
+ // Check quota before processing
254
+ const quotaCheck = await checkAndUpdateQuota(
255
+ org.id,
256
+ org.plan,
257
+ org.aiCallsUsed,
258
+ org.aiCallsResetAt,
259
+ body.exports.length,
260
+ );
261
+
262
+ if (!quotaCheck.allowed) {
263
+ return c.json(
264
+ {
265
+ error: quotaCheck.error,
266
+ remaining: quotaCheck.remaining,
267
+ resetAt: quotaCheck.resetAt.toISOString(),
268
+ byok: 'Set OPENAI_API_KEY or ANTHROPIC_API_KEY for unlimited generation',
269
+ },
270
+ 429,
271
+ );
272
+ }
273
+
274
+ // Generate JSDoc for each export
275
+ const results: Array<{
276
+ name: string;
277
+ patch: JSDocGenerationResult | null;
278
+ error?: string;
279
+ }> = [];
280
+
281
+ // Process in batches of 3 to avoid rate limits
282
+ for (let i = 0; i < body.exports.length; i += 3) {
283
+ const batch = body.exports.slice(i, i + 3);
284
+ const promises = batch.map(async (exp) => {
285
+ try {
286
+ const patch = await generateJSDocForExport(exp, body.packageName);
287
+ return { name: exp.name, patch };
288
+ } catch (err) {
289
+ return {
290
+ name: exp.name,
291
+ patch: null,
292
+ error: err instanceof Error ? err.message : 'Generation failed',
293
+ };
294
+ }
295
+ });
296
+
297
+ const batchResults = await Promise.all(promises);
298
+ results.push(...batchResults);
299
+ }
300
+
301
+ const successful = results.filter((r) => r.patch !== null).length;
302
+ const failed = results.filter((r) => r.patch === null).length;
303
+
304
+ return c.json({
305
+ success: true,
306
+ generated: successful,
307
+ failed,
308
+ results,
309
+ quota: {
310
+ remaining: quotaCheck.remaining,
311
+ resetAt: quotaCheck.resetAt.toISOString(),
312
+ },
313
+ });
314
+ });
315
+
316
+ // GET /v1/ai/quota - Check remaining AI quota
317
+ aiRoute.get('/quota', async (c) => {
318
+ const org = c.get('org') as {
319
+ id: string;
320
+ plan: string;
321
+ aiCallsUsed: number;
322
+ aiCallsResetAt: Date | null;
323
+ };
324
+
325
+ if (!org) {
326
+ return c.json({ error: 'Unauthorized' }, 401);
327
+ }
328
+
329
+ const limits = getPlanLimits(org.plan as Plan);
330
+ const monthlyLimit = limits.aiCallsPerMonth;
331
+
332
+ // Check if reset is needed
333
+ const now = new Date();
334
+ const shouldReset = !org.aiCallsResetAt || now >= org.aiCallsResetAt;
335
+
336
+ let used = org.aiCallsUsed;
337
+ let resetAt = org.aiCallsResetAt || getNextMonthReset();
338
+
339
+ if (shouldReset) {
340
+ used = 0;
341
+ resetAt = getNextMonthReset();
342
+ }
343
+
344
+ return c.json({
345
+ plan: org.plan,
346
+ used,
347
+ limit: monthlyLimit === Infinity ? 'unlimited' : monthlyLimit,
348
+ remaining: monthlyLimit === Infinity ? 'unlimited' : Math.max(0, monthlyLimit - used),
349
+ resetAt: resetAt.toISOString(),
350
+ });
351
+ });
352
+
353
+ export { aiRoute };