@doccov/api 0.3.7 → 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 +19 -0
- package/api/index.ts +12 -5
- package/migrations/001_initial.ts +151 -0
- package/migrations/002_polar_billing.ts +17 -0
- package/migrations/003_verification_table.ts +18 -0
- package/migrations/004_rename_tables_singular.ts +14 -0
- package/package.json +15 -4
- package/src/auth/config.ts +53 -0
- package/src/auth/hooks.ts +45 -0
- package/src/db/client.ts +15 -0
- package/src/db/migrate.ts +38 -0
- package/src/index.ts +63 -5
- package/src/middleware/anonymous-rate-limit.ts +131 -0
- package/src/middleware/api-key-auth.ts +94 -0
- package/src/middleware/org-rate-limit.ts +78 -0
- package/src/routes/ai.ts +353 -0
- package/src/routes/api-keys.ts +127 -0
- package/src/routes/auth.ts +62 -0
- package/src/routes/badge.ts +122 -32
- package/src/routes/billing.ts +267 -0
- package/src/routes/coverage.ts +288 -0
- package/src/routes/demo.ts +297 -0
- package/src/routes/github-app.ts +368 -0
- package/src/routes/invites.ts +90 -0
- package/src/routes/orgs.ts +387 -0
- package/src/routes/plan.ts +2 -72
- package/src/server.ts +22 -0
- package/src/utils/api-keys.ts +20 -0
- package/src/utils/github-app.ts +196 -0
- package/src/utils/github-checks.ts +278 -0
- package/src/utils/remote-analyzer.ts +251 -0
- package/vercel.json +1 -3
- package/src/utils/github.ts +0 -5
|
@@ -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,94 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler, Next } from 'hono';
|
|
2
|
+
import { db } from '../db/client';
|
|
3
|
+
import { hashApiKey, isValidKeyFormat } from '../utils/api-keys';
|
|
4
|
+
|
|
5
|
+
export interface ApiKeyContext {
|
|
6
|
+
apiKey: { id: string; orgId: string; name: string };
|
|
7
|
+
org: { id: string; plan: string; aiCallsUsed: number; aiCallsResetAt: Date | null };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Required API key authentication for /v1/* endpoints
|
|
12
|
+
* Rejects requests without valid API key
|
|
13
|
+
*/
|
|
14
|
+
export function requireApiKey(): MiddlewareHandler {
|
|
15
|
+
return async (c: Context, next: Next) => {
|
|
16
|
+
const authHeader = c.req.header('Authorization');
|
|
17
|
+
|
|
18
|
+
if (!authHeader) {
|
|
19
|
+
return c.json(
|
|
20
|
+
{
|
|
21
|
+
error: 'API key required',
|
|
22
|
+
docs: 'https://docs.doccov.com/api-keys',
|
|
23
|
+
},
|
|
24
|
+
401,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!authHeader.startsWith('Bearer ')) {
|
|
29
|
+
return c.json({ error: 'Invalid Authorization header. Use: Bearer <api_key>' }, 401);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const key = authHeader.slice(7);
|
|
33
|
+
|
|
34
|
+
if (!isValidKeyFormat(key)) {
|
|
35
|
+
return c.json({ error: 'Invalid API key format' }, 401);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const keyHash = hashApiKey(key);
|
|
39
|
+
|
|
40
|
+
const result = await db
|
|
41
|
+
.selectFrom('api_keys')
|
|
42
|
+
.innerJoin('organizations', 'organizations.id', 'api_keys.orgId')
|
|
43
|
+
.where('api_keys.keyHash', '=', keyHash)
|
|
44
|
+
.where((eb) =>
|
|
45
|
+
eb.or([eb('api_keys.expiresAt', 'is', null), eb('api_keys.expiresAt', '>', new Date())]),
|
|
46
|
+
)
|
|
47
|
+
.select([
|
|
48
|
+
'api_keys.id as keyId',
|
|
49
|
+
'api_keys.orgId',
|
|
50
|
+
'api_keys.name as keyName',
|
|
51
|
+
'organizations.plan',
|
|
52
|
+
'organizations.aiCallsUsed',
|
|
53
|
+
'organizations.aiCallsResetAt',
|
|
54
|
+
])
|
|
55
|
+
.executeTakeFirst();
|
|
56
|
+
|
|
57
|
+
if (!result) {
|
|
58
|
+
return c.json({ error: 'Invalid or expired API key' }, 401);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Free tier shouldn't have API keys, but guard anyway
|
|
62
|
+
if (result.plan === 'free') {
|
|
63
|
+
return c.json(
|
|
64
|
+
{
|
|
65
|
+
error: 'API access requires a paid plan',
|
|
66
|
+
upgrade: 'https://doccov.com/pricing',
|
|
67
|
+
},
|
|
68
|
+
403,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Update last used (async, don't block)
|
|
73
|
+
db.updateTable('api_keys')
|
|
74
|
+
.set({ lastUsedAt: new Date() })
|
|
75
|
+
.where('id', '=', result.keyId)
|
|
76
|
+
.execute()
|
|
77
|
+
.catch(console.error);
|
|
78
|
+
|
|
79
|
+
c.set('apiKey', {
|
|
80
|
+
id: result.keyId,
|
|
81
|
+
orgId: result.orgId,
|
|
82
|
+
name: result.keyName,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
c.set('org', {
|
|
86
|
+
id: result.orgId,
|
|
87
|
+
plan: result.plan,
|
|
88
|
+
aiCallsUsed: result.aiCallsUsed,
|
|
89
|
+
aiCallsResetAt: result.aiCallsResetAt,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await next();
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { getPlanLimits, type Plan } from '@doccov/db';
|
|
2
|
+
import type { Context, MiddlewareHandler, Next } from 'hono';
|
|
3
|
+
import { db } from '../db/client';
|
|
4
|
+
|
|
5
|
+
const usageStore = new Map<string, { count: number; resetAt: number }>();
|
|
6
|
+
|
|
7
|
+
setInterval(() => {
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
usageStore.forEach((entry, key) => {
|
|
10
|
+
if (now > entry.resetAt) usageStore.delete(key);
|
|
11
|
+
});
|
|
12
|
+
}, 60_000).unref();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Rate limit by org plan
|
|
16
|
+
* Requires apiKey middleware to run first
|
|
17
|
+
*/
|
|
18
|
+
export function orgRateLimit(): MiddlewareHandler {
|
|
19
|
+
return async (c: Context, next: Next) => {
|
|
20
|
+
const org = c.get('org');
|
|
21
|
+
if (!org) return c.json({ error: 'Auth required' }, 401);
|
|
22
|
+
|
|
23
|
+
const limits = getPlanLimits(org.plan as Plan);
|
|
24
|
+
const dailyLimit = limits.analysesPerDay;
|
|
25
|
+
|
|
26
|
+
// Unlimited for enterprise
|
|
27
|
+
if (dailyLimit === Infinity) {
|
|
28
|
+
await next();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const key = `org:${org.id}`;
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
35
|
+
|
|
36
|
+
let entry = usageStore.get(key);
|
|
37
|
+
if (!entry || now > entry.resetAt) {
|
|
38
|
+
entry = { count: 0, resetAt: now + dayMs };
|
|
39
|
+
usageStore.set(key, entry);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (entry.count >= dailyLimit) {
|
|
43
|
+
c.header('X-RateLimit-Limit', String(dailyLimit));
|
|
44
|
+
c.header('X-RateLimit-Remaining', '0');
|
|
45
|
+
c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000)));
|
|
46
|
+
|
|
47
|
+
return c.json(
|
|
48
|
+
{
|
|
49
|
+
error: 'Daily analysis limit exceeded',
|
|
50
|
+
limit: dailyLimit,
|
|
51
|
+
plan: org.plan,
|
|
52
|
+
resetAt: new Date(entry.resetAt).toISOString(),
|
|
53
|
+
upgrade: org.plan === 'team' ? 'https://doccov.com/pricing' : undefined,
|
|
54
|
+
},
|
|
55
|
+
429,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
entry.count++;
|
|
60
|
+
|
|
61
|
+
c.header('X-RateLimit-Limit', String(dailyLimit));
|
|
62
|
+
c.header('X-RateLimit-Remaining', String(dailyLimit - entry.count));
|
|
63
|
+
c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000)));
|
|
64
|
+
|
|
65
|
+
// Track in DB (async, don't block)
|
|
66
|
+
db.insertInto('usage_records')
|
|
67
|
+
.values({
|
|
68
|
+
id: crypto.randomUUID(),
|
|
69
|
+
orgId: org.id,
|
|
70
|
+
feature: 'analysis',
|
|
71
|
+
count: 1,
|
|
72
|
+
})
|
|
73
|
+
.execute()
|
|
74
|
+
.catch(console.error);
|
|
75
|
+
|
|
76
|
+
await next();
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/routes/ai.ts
ADDED
|
@@ -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 };
|