@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,387 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { nanoid } from 'nanoid';
|
|
3
|
+
import { auth } from '../auth/config';
|
|
4
|
+
import { db } from '../db/client';
|
|
5
|
+
|
|
6
|
+
const SITE_URL = process.env.SITE_URL || 'http://localhost:3000';
|
|
7
|
+
|
|
8
|
+
type Session = Awaited<ReturnType<typeof auth.api.getSession>>;
|
|
9
|
+
|
|
10
|
+
type Env = {
|
|
11
|
+
Variables: {
|
|
12
|
+
session: NonNullable<Session>;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const orgsRoute = new Hono<Env>();
|
|
17
|
+
|
|
18
|
+
// Middleware: require auth
|
|
19
|
+
orgsRoute.use('*', async (c, next) => {
|
|
20
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
21
|
+
if (!session) {
|
|
22
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
23
|
+
}
|
|
24
|
+
c.set('session', session);
|
|
25
|
+
await next();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// List user's organizations
|
|
29
|
+
orgsRoute.get('/', async (c) => {
|
|
30
|
+
const session = c.get('session');
|
|
31
|
+
|
|
32
|
+
const memberships = await db
|
|
33
|
+
.selectFrom('org_members')
|
|
34
|
+
.innerJoin('organizations', 'organizations.id', 'org_members.orgId')
|
|
35
|
+
.where('org_members.userId', '=', session.user.id)
|
|
36
|
+
.select([
|
|
37
|
+
'organizations.id',
|
|
38
|
+
'organizations.name',
|
|
39
|
+
'organizations.slug',
|
|
40
|
+
'organizations.plan',
|
|
41
|
+
'organizations.isPersonal',
|
|
42
|
+
'organizations.aiCallsUsed',
|
|
43
|
+
'org_members.role',
|
|
44
|
+
])
|
|
45
|
+
.execute();
|
|
46
|
+
|
|
47
|
+
return c.json({ organizations: memberships });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Get single org by slug
|
|
51
|
+
orgsRoute.get('/:slug', async (c) => {
|
|
52
|
+
const session = c.get('session');
|
|
53
|
+
const { slug } = c.req.param();
|
|
54
|
+
|
|
55
|
+
const org = await db
|
|
56
|
+
.selectFrom('organizations')
|
|
57
|
+
.innerJoin('org_members', 'org_members.orgId', 'organizations.id')
|
|
58
|
+
.where('organizations.slug', '=', slug)
|
|
59
|
+
.where('org_members.userId', '=', session.user.id)
|
|
60
|
+
.select([
|
|
61
|
+
'organizations.id',
|
|
62
|
+
'organizations.name',
|
|
63
|
+
'organizations.slug',
|
|
64
|
+
'organizations.plan',
|
|
65
|
+
'organizations.isPersonal',
|
|
66
|
+
'organizations.aiCallsUsed',
|
|
67
|
+
'organizations.aiCallsResetAt',
|
|
68
|
+
'org_members.role',
|
|
69
|
+
])
|
|
70
|
+
.executeTakeFirst();
|
|
71
|
+
|
|
72
|
+
if (!org) {
|
|
73
|
+
return c.json({ error: 'Organization not found' }, 404);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return c.json({ organization: org });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Get org's projects
|
|
80
|
+
orgsRoute.get('/:slug/projects', async (c) => {
|
|
81
|
+
const session = c.get('session');
|
|
82
|
+
const { slug } = c.req.param();
|
|
83
|
+
|
|
84
|
+
// Verify membership
|
|
85
|
+
const membership = await db
|
|
86
|
+
.selectFrom('org_members')
|
|
87
|
+
.innerJoin('organizations', 'organizations.id', 'org_members.orgId')
|
|
88
|
+
.where('organizations.slug', '=', slug)
|
|
89
|
+
.where('org_members.userId', '=', session.user.id)
|
|
90
|
+
.select(['org_members.orgId'])
|
|
91
|
+
.executeTakeFirst();
|
|
92
|
+
|
|
93
|
+
if (!membership) {
|
|
94
|
+
return c.json({ error: 'Organization not found' }, 404);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const projects = await db
|
|
98
|
+
.selectFrom('projects')
|
|
99
|
+
.where('orgId', '=', membership.orgId)
|
|
100
|
+
.selectAll()
|
|
101
|
+
.execute();
|
|
102
|
+
|
|
103
|
+
return c.json({ projects });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Create a project
|
|
107
|
+
orgsRoute.post('/:slug/projects', async (c) => {
|
|
108
|
+
const session = c.get('session');
|
|
109
|
+
const { slug } = c.req.param();
|
|
110
|
+
const body = await c.req.json<{ name: string; fullName: string; isPrivate?: boolean }>();
|
|
111
|
+
|
|
112
|
+
// Verify owner/admin membership
|
|
113
|
+
const membership = await db
|
|
114
|
+
.selectFrom('org_members')
|
|
115
|
+
.innerJoin('organizations', 'organizations.id', 'org_members.orgId')
|
|
116
|
+
.where('organizations.slug', '=', slug)
|
|
117
|
+
.where('org_members.userId', '=', session.user.id)
|
|
118
|
+
.where('org_members.role', 'in', ['owner', 'admin'])
|
|
119
|
+
.select(['org_members.orgId'])
|
|
120
|
+
.executeTakeFirst();
|
|
121
|
+
|
|
122
|
+
if (!membership) {
|
|
123
|
+
return c.json({ error: 'Forbidden' }, 403);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const project = await db
|
|
127
|
+
.insertInto('projects')
|
|
128
|
+
.values({
|
|
129
|
+
id: nanoid(21),
|
|
130
|
+
orgId: membership.orgId,
|
|
131
|
+
name: body.name,
|
|
132
|
+
fullName: body.fullName,
|
|
133
|
+
isPrivate: body.isPrivate ?? false,
|
|
134
|
+
defaultBranch: 'main',
|
|
135
|
+
})
|
|
136
|
+
.returningAll()
|
|
137
|
+
.executeTakeFirst();
|
|
138
|
+
|
|
139
|
+
return c.json({ project }, 201);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ============ Members ============
|
|
143
|
+
|
|
144
|
+
// List org members
|
|
145
|
+
orgsRoute.get('/:slug/members', async (c) => {
|
|
146
|
+
const session = c.get('session');
|
|
147
|
+
const { slug } = c.req.param();
|
|
148
|
+
|
|
149
|
+
// Verify membership
|
|
150
|
+
const org = await db
|
|
151
|
+
.selectFrom('organizations')
|
|
152
|
+
.innerJoin('org_members', 'org_members.orgId', 'organizations.id')
|
|
153
|
+
.where('organizations.slug', '=', slug)
|
|
154
|
+
.where('org_members.userId', '=', session.user.id)
|
|
155
|
+
.select(['organizations.id as orgId', 'org_members.role as myRole'])
|
|
156
|
+
.executeTakeFirst();
|
|
157
|
+
|
|
158
|
+
if (!org) {
|
|
159
|
+
return c.json({ error: 'Organization not found' }, 404);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const members = await db
|
|
163
|
+
.selectFrom('org_members')
|
|
164
|
+
.innerJoin('user', 'user.id', 'org_members.userId')
|
|
165
|
+
.where('org_members.orgId', '=', org.orgId)
|
|
166
|
+
.select([
|
|
167
|
+
'org_members.id',
|
|
168
|
+
'org_members.userId',
|
|
169
|
+
'org_members.role',
|
|
170
|
+
'org_members.createdAt',
|
|
171
|
+
'user.email',
|
|
172
|
+
'user.name',
|
|
173
|
+
'user.image',
|
|
174
|
+
])
|
|
175
|
+
.orderBy('org_members.createdAt', 'asc')
|
|
176
|
+
.execute();
|
|
177
|
+
|
|
178
|
+
return c.json({ members, myRole: org.myRole });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Create invite
|
|
182
|
+
orgsRoute.post('/:slug/invites', async (c) => {
|
|
183
|
+
const session = c.get('session');
|
|
184
|
+
const { slug } = c.req.param();
|
|
185
|
+
const body = await c.req.json<{ email: string; role: 'admin' | 'member' }>();
|
|
186
|
+
|
|
187
|
+
// Verify owner/admin
|
|
188
|
+
const org = await db
|
|
189
|
+
.selectFrom('organizations')
|
|
190
|
+
.innerJoin('org_members', 'org_members.orgId', 'organizations.id')
|
|
191
|
+
.where('organizations.slug', '=', slug)
|
|
192
|
+
.where('org_members.userId', '=', session.user.id)
|
|
193
|
+
.where('org_members.role', 'in', ['owner', 'admin'])
|
|
194
|
+
.select(['organizations.id as orgId', 'organizations.name'])
|
|
195
|
+
.executeTakeFirst();
|
|
196
|
+
|
|
197
|
+
if (!org) {
|
|
198
|
+
return c.json({ error: 'Forbidden' }, 403);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Check if already a member
|
|
202
|
+
const existingMember = await db
|
|
203
|
+
.selectFrom('org_members')
|
|
204
|
+
.innerJoin('user', 'user.id', 'org_members.userId')
|
|
205
|
+
.where('org_members.orgId', '=', org.orgId)
|
|
206
|
+
.where('user.email', '=', body.email)
|
|
207
|
+
.select('org_members.id')
|
|
208
|
+
.executeTakeFirst();
|
|
209
|
+
|
|
210
|
+
if (existingMember) {
|
|
211
|
+
return c.json({ error: 'User is already a member' }, 400);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const token = nanoid(32);
|
|
215
|
+
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
|
216
|
+
|
|
217
|
+
const invite = await db
|
|
218
|
+
.insertInto('org_invites')
|
|
219
|
+
.values({
|
|
220
|
+
id: nanoid(21),
|
|
221
|
+
orgId: org.orgId,
|
|
222
|
+
email: body.email,
|
|
223
|
+
role: body.role || 'member',
|
|
224
|
+
token,
|
|
225
|
+
expiresAt,
|
|
226
|
+
createdBy: session.user.id,
|
|
227
|
+
})
|
|
228
|
+
.returningAll()
|
|
229
|
+
.executeTakeFirst();
|
|
230
|
+
|
|
231
|
+
const inviteUrl = `${SITE_URL}/invite/${token}`;
|
|
232
|
+
|
|
233
|
+
return c.json({ invite, inviteUrl }, 201);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// List pending invites
|
|
237
|
+
orgsRoute.get('/:slug/invites', async (c) => {
|
|
238
|
+
const session = c.get('session');
|
|
239
|
+
const { slug } = c.req.param();
|
|
240
|
+
|
|
241
|
+
// Verify owner/admin
|
|
242
|
+
const org = await db
|
|
243
|
+
.selectFrom('organizations')
|
|
244
|
+
.innerJoin('org_members', 'org_members.orgId', 'organizations.id')
|
|
245
|
+
.where('organizations.slug', '=', slug)
|
|
246
|
+
.where('org_members.userId', '=', session.user.id)
|
|
247
|
+
.where('org_members.role', 'in', ['owner', 'admin'])
|
|
248
|
+
.select(['organizations.id as orgId'])
|
|
249
|
+
.executeTakeFirst();
|
|
250
|
+
|
|
251
|
+
if (!org) {
|
|
252
|
+
return c.json({ error: 'Forbidden' }, 403);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const invites = await db
|
|
256
|
+
.selectFrom('org_invites')
|
|
257
|
+
.where('orgId', '=', org.orgId)
|
|
258
|
+
.where('expiresAt', '>', new Date())
|
|
259
|
+
.select(['id', 'email', 'role', 'expiresAt', 'createdAt'])
|
|
260
|
+
.orderBy('createdAt', 'desc')
|
|
261
|
+
.execute();
|
|
262
|
+
|
|
263
|
+
return c.json({ invites });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Delete invite
|
|
267
|
+
orgsRoute.delete('/:slug/invites/:inviteId', async (c) => {
|
|
268
|
+
const session = c.get('session');
|
|
269
|
+
const { slug, inviteId } = c.req.param();
|
|
270
|
+
|
|
271
|
+
// Verify owner/admin
|
|
272
|
+
const org = await db
|
|
273
|
+
.selectFrom('organizations')
|
|
274
|
+
.innerJoin('org_members', 'org_members.orgId', 'organizations.id')
|
|
275
|
+
.where('organizations.slug', '=', slug)
|
|
276
|
+
.where('org_members.userId', '=', session.user.id)
|
|
277
|
+
.where('org_members.role', 'in', ['owner', 'admin'])
|
|
278
|
+
.select(['organizations.id as orgId'])
|
|
279
|
+
.executeTakeFirst();
|
|
280
|
+
|
|
281
|
+
if (!org) {
|
|
282
|
+
return c.json({ error: 'Forbidden' }, 403);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
await db
|
|
286
|
+
.deleteFrom('org_invites')
|
|
287
|
+
.where('id', '=', inviteId)
|
|
288
|
+
.where('orgId', '=', org.orgId)
|
|
289
|
+
.execute();
|
|
290
|
+
|
|
291
|
+
return c.json({ success: true });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// Update member role
|
|
295
|
+
orgsRoute.patch('/:slug/members/:userId', async (c) => {
|
|
296
|
+
const session = c.get('session');
|
|
297
|
+
const { slug, userId } = c.req.param();
|
|
298
|
+
const body = await c.req.json<{ role: 'admin' | 'member' }>();
|
|
299
|
+
|
|
300
|
+
// Verify owner
|
|
301
|
+
const org = await db
|
|
302
|
+
.selectFrom('organizations')
|
|
303
|
+
.innerJoin('org_members', 'org_members.orgId', 'organizations.id')
|
|
304
|
+
.where('organizations.slug', '=', slug)
|
|
305
|
+
.where('org_members.userId', '=', session.user.id)
|
|
306
|
+
.where('org_members.role', '=', 'owner')
|
|
307
|
+
.select(['organizations.id as orgId'])
|
|
308
|
+
.executeTakeFirst();
|
|
309
|
+
|
|
310
|
+
if (!org) {
|
|
311
|
+
return c.json({ error: 'Only owner can change roles' }, 403);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Don't allow changing owner role
|
|
315
|
+
const targetMember = await db
|
|
316
|
+
.selectFrom('org_members')
|
|
317
|
+
.where('orgId', '=', org.orgId)
|
|
318
|
+
.where('userId', '=', userId)
|
|
319
|
+
.select('role')
|
|
320
|
+
.executeTakeFirst();
|
|
321
|
+
|
|
322
|
+
if (!targetMember) {
|
|
323
|
+
return c.json({ error: 'Member not found' }, 404);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (targetMember.role === 'owner') {
|
|
327
|
+
return c.json({ error: 'Cannot change owner role' }, 400);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
await db
|
|
331
|
+
.updateTable('org_members')
|
|
332
|
+
.set({ role: body.role })
|
|
333
|
+
.where('orgId', '=', org.orgId)
|
|
334
|
+
.where('userId', '=', userId)
|
|
335
|
+
.execute();
|
|
336
|
+
|
|
337
|
+
return c.json({ success: true });
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Remove member
|
|
341
|
+
orgsRoute.delete('/:slug/members/:userId', async (c) => {
|
|
342
|
+
const session = c.get('session');
|
|
343
|
+
const { slug, userId } = c.req.param();
|
|
344
|
+
|
|
345
|
+
// Verify owner/admin
|
|
346
|
+
const org = await db
|
|
347
|
+
.selectFrom('organizations')
|
|
348
|
+
.innerJoin('org_members', 'org_members.orgId', 'organizations.id')
|
|
349
|
+
.where('organizations.slug', '=', slug)
|
|
350
|
+
.where('org_members.userId', '=', session.user.id)
|
|
351
|
+
.where('org_members.role', 'in', ['owner', 'admin'])
|
|
352
|
+
.select(['organizations.id as orgId', 'org_members.role as myRole'])
|
|
353
|
+
.executeTakeFirst();
|
|
354
|
+
|
|
355
|
+
if (!org) {
|
|
356
|
+
return c.json({ error: 'Forbidden' }, 403);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Don't allow removing owner
|
|
360
|
+
const targetMember = await db
|
|
361
|
+
.selectFrom('org_members')
|
|
362
|
+
.where('orgId', '=', org.orgId)
|
|
363
|
+
.where('userId', '=', userId)
|
|
364
|
+
.select('role')
|
|
365
|
+
.executeTakeFirst();
|
|
366
|
+
|
|
367
|
+
if (!targetMember) {
|
|
368
|
+
return c.json({ error: 'Member not found' }, 404);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (targetMember.role === 'owner') {
|
|
372
|
+
return c.json({ error: 'Cannot remove owner' }, 400);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Admins can only remove members, not other admins
|
|
376
|
+
if (org.myRole === 'admin' && targetMember.role === 'admin') {
|
|
377
|
+
return c.json({ error: 'Admins cannot remove other admins' }, 403);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
await db
|
|
381
|
+
.deleteFrom('org_members')
|
|
382
|
+
.where('orgId', '=', org.orgId)
|
|
383
|
+
.where('userId', '=', userId)
|
|
384
|
+
.execute();
|
|
385
|
+
|
|
386
|
+
return c.json({ success: true });
|
|
387
|
+
});
|
package/src/routes/plan.ts
CHANGED
|
@@ -1,81 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Plan route
|
|
3
|
-
* Does NOT use Vercel Sandbox - only GitHub API + Anthropic Claude
|
|
2
|
+
* Plan route - TODO: implement plan-agent
|
|
4
3
|
*/
|
|
5
4
|
|
|
6
|
-
import { fetchGitHubContext, parseScanGitHubUrl } from '@doccov/sdk';
|
|
7
5
|
import { Hono } from 'hono';
|
|
8
|
-
import { generateBuildPlan } from '../../lib/plan-agent';
|
|
9
6
|
|
|
10
7
|
export const planRoute = new Hono();
|
|
11
8
|
|
|
12
9
|
planRoute.post('/', async (c) => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if (!body.url) {
|
|
16
|
-
return c.json({ error: 'url is required' }, 400);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// Validate URL format
|
|
20
|
-
let repoUrl: string;
|
|
21
|
-
try {
|
|
22
|
-
const parsed = parseScanGitHubUrl(body.url);
|
|
23
|
-
if (!parsed) {
|
|
24
|
-
return c.json({ error: 'Invalid GitHub URL' }, 400);
|
|
25
|
-
}
|
|
26
|
-
repoUrl = `https://github.com/${parsed.owner}/${parsed.repo}`;
|
|
27
|
-
} catch {
|
|
28
|
-
return c.json({ error: 'Invalid GitHub URL' }, 400);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
// Fetch project context from GitHub
|
|
33
|
-
const context = await fetchGitHubContext(repoUrl, body.ref);
|
|
34
|
-
|
|
35
|
-
// Check for private repos
|
|
36
|
-
if (context.metadata.isPrivate) {
|
|
37
|
-
return c.json(
|
|
38
|
-
{
|
|
39
|
-
error: 'Private repositories are not supported',
|
|
40
|
-
hint: 'Use a public repository or run doccov locally',
|
|
41
|
-
},
|
|
42
|
-
403,
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Generate build plan using AI
|
|
47
|
-
const plan = await generateBuildPlan(context, {
|
|
48
|
-
targetPackage: body.package,
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
return c.json({
|
|
52
|
-
plan,
|
|
53
|
-
context: {
|
|
54
|
-
owner: context.metadata.owner,
|
|
55
|
-
repo: context.metadata.repo,
|
|
56
|
-
ref: context.ref,
|
|
57
|
-
packageManager: context.packageManager,
|
|
58
|
-
isMonorepo: context.workspace.isMonorepo,
|
|
59
|
-
},
|
|
60
|
-
});
|
|
61
|
-
} catch (error) {
|
|
62
|
-
console.error('Plan generation error:', error);
|
|
63
|
-
|
|
64
|
-
if (error instanceof Error) {
|
|
65
|
-
if (error.message.includes('404') || error.message.includes('not found')) {
|
|
66
|
-
return c.json({ error: 'Repository not found' }, 404);
|
|
67
|
-
}
|
|
68
|
-
if (error.message.includes('rate limit')) {
|
|
69
|
-
return c.json({ error: 'GitHub API rate limit exceeded' }, 429);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return c.json(
|
|
74
|
-
{
|
|
75
|
-
error: 'Failed to generate build plan',
|
|
76
|
-
message: error instanceof Error ? error.message : 'Unknown error',
|
|
77
|
-
},
|
|
78
|
-
500,
|
|
79
|
-
);
|
|
80
|
-
}
|
|
10
|
+
return c.json({ error: 'Plan endpoint not yet implemented' }, 501);
|
|
81
11
|
});
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { serve } from '@hono/node-server';
|
|
2
|
+
import { runMigrations } from './db/migrate';
|
|
3
|
+
import app from './index';
|
|
4
|
+
|
|
5
|
+
const port = parseInt(process.env.PORT || '3001');
|
|
6
|
+
|
|
7
|
+
async function start() {
|
|
8
|
+
// Run migrations on startup
|
|
9
|
+
console.log('Running migrations...');
|
|
10
|
+
await runMigrations();
|
|
11
|
+
|
|
12
|
+
// Start server
|
|
13
|
+
console.log(`Starting server on port ${port}`);
|
|
14
|
+
serve({
|
|
15
|
+
fetch: app.fetch,
|
|
16
|
+
port,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
console.log(`Server running at http://localhost:${port}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
start().catch(console.error);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { nanoid } from 'nanoid';
|
|
3
|
+
|
|
4
|
+
const KEY_PREFIX = 'doccov_';
|
|
5
|
+
|
|
6
|
+
export function generateApiKey(): { key: string; hash: string; prefix: string } {
|
|
7
|
+
const random = nanoid(32);
|
|
8
|
+
const key = `${KEY_PREFIX}${random}`;
|
|
9
|
+
const hash = hashApiKey(key);
|
|
10
|
+
const prefix = key.slice(0, 12);
|
|
11
|
+
return { key, hash, prefix };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function hashApiKey(key: string): string {
|
|
15
|
+
return createHash('sha256').update(key).digest('hex');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isValidKeyFormat(key: string): boolean {
|
|
19
|
+
return key.startsWith(KEY_PREFIX) && key.length === KEY_PREFIX.length + 32;
|
|
20
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub App utilities for token management and API access
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { SignJWT } from 'jose';
|
|
6
|
+
import { db } from '../db/client';
|
|
7
|
+
|
|
8
|
+
const GITHUB_APP_ID = process.env.GITHUB_APP_ID!;
|
|
9
|
+
const GITHUB_APP_PRIVATE_KEY = process.env.GITHUB_APP_PRIVATE_KEY!;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate a JWT for GitHub App authentication
|
|
13
|
+
*/
|
|
14
|
+
async function generateAppJWT(): Promise<string> {
|
|
15
|
+
const privateKey = await importPrivateKey(GITHUB_APP_PRIVATE_KEY);
|
|
16
|
+
|
|
17
|
+
const jwt = await new SignJWT({})
|
|
18
|
+
.setProtectedHeader({ alg: 'RS256' })
|
|
19
|
+
.setIssuedAt()
|
|
20
|
+
.setIssuer(GITHUB_APP_ID)
|
|
21
|
+
.setExpirationTime('10m')
|
|
22
|
+
.sign(privateKey);
|
|
23
|
+
|
|
24
|
+
return jwt;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Import PEM private key for signing
|
|
29
|
+
*/
|
|
30
|
+
async function importPrivateKey(pem: string) {
|
|
31
|
+
// Handle escaped newlines from env vars
|
|
32
|
+
const formattedPem = pem.replace(/\\n/g, '\n');
|
|
33
|
+
|
|
34
|
+
const pemContents = formattedPem
|
|
35
|
+
.replace('-----BEGIN RSA PRIVATE KEY-----', '')
|
|
36
|
+
.replace('-----END RSA PRIVATE KEY-----', '')
|
|
37
|
+
.replace(/\s/g, '');
|
|
38
|
+
|
|
39
|
+
const binaryKey = Uint8Array.from(atob(pemContents), (c) => c.charCodeAt(0));
|
|
40
|
+
|
|
41
|
+
return crypto.subtle.importKey(
|
|
42
|
+
'pkcs8',
|
|
43
|
+
binaryKey,
|
|
44
|
+
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
|
45
|
+
false,
|
|
46
|
+
['sign'],
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get or refresh installation access token
|
|
52
|
+
*/
|
|
53
|
+
export async function getInstallationToken(orgId: string): Promise<string | null> {
|
|
54
|
+
const installation = await db
|
|
55
|
+
.selectFrom('github_installations')
|
|
56
|
+
.where('orgId', '=', orgId)
|
|
57
|
+
.select(['id', 'installationId', 'accessToken', 'tokenExpiresAt'])
|
|
58
|
+
.executeTakeFirst();
|
|
59
|
+
|
|
60
|
+
if (!installation) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check if token is still valid (with 5 min buffer)
|
|
65
|
+
const now = new Date();
|
|
66
|
+
const expiresAt = installation.tokenExpiresAt;
|
|
67
|
+
const isExpired = !expiresAt || new Date(expiresAt.getTime() - 5 * 60 * 1000) <= now;
|
|
68
|
+
|
|
69
|
+
if (!isExpired && installation.accessToken) {
|
|
70
|
+
return installation.accessToken;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Refresh the token
|
|
74
|
+
try {
|
|
75
|
+
const jwt = await generateAppJWT();
|
|
76
|
+
const response = await fetch(
|
|
77
|
+
`https://api.github.com/app/installations/${installation.installationId}/access_tokens`,
|
|
78
|
+
{
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
Authorization: `Bearer ${jwt}`,
|
|
82
|
+
Accept: 'application/vnd.github+json',
|
|
83
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
console.error('Failed to get installation token:', await response.text());
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const data = (await response.json()) as { token: string; expires_at: string };
|
|
94
|
+
|
|
95
|
+
// Update token in database
|
|
96
|
+
await db
|
|
97
|
+
.updateTable('github_installations')
|
|
98
|
+
.set({
|
|
99
|
+
accessToken: data.token,
|
|
100
|
+
tokenExpiresAt: new Date(data.expires_at),
|
|
101
|
+
updatedAt: new Date(),
|
|
102
|
+
})
|
|
103
|
+
.where('id', '=', installation.id)
|
|
104
|
+
.execute();
|
|
105
|
+
|
|
106
|
+
return data.token;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.error('Error refreshing installation token:', err);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get installation token by installation ID (for webhooks)
|
|
115
|
+
*/
|
|
116
|
+
export async function getTokenByInstallationId(installationId: string): Promise<string | null> {
|
|
117
|
+
const installation = await db
|
|
118
|
+
.selectFrom('github_installations')
|
|
119
|
+
.where('installationId', '=', installationId)
|
|
120
|
+
.select(['orgId'])
|
|
121
|
+
.executeTakeFirst();
|
|
122
|
+
|
|
123
|
+
if (!installation) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return getInstallationToken(installation.orgId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* List repositories accessible to an installation
|
|
132
|
+
*/
|
|
133
|
+
export async function listInstallationRepos(
|
|
134
|
+
orgId: string,
|
|
135
|
+
): Promise<Array<{ id: number; name: string; full_name: string; private: boolean }> | null> {
|
|
136
|
+
const token = await getInstallationToken(orgId);
|
|
137
|
+
if (!token) return null;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const response = await fetch('https://api.github.com/installation/repositories', {
|
|
141
|
+
headers: {
|
|
142
|
+
Authorization: `Bearer ${token}`,
|
|
143
|
+
Accept: 'application/vnd.github+json',
|
|
144
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
console.error('Failed to list repos:', await response.text());
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const data = (await response.json()) as {
|
|
154
|
+
repositories: Array<{ id: number; name: string; full_name: string; private: boolean }>;
|
|
155
|
+
};
|
|
156
|
+
return data.repositories;
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error('Error listing repos:', err);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Fetch file content from a private repo
|
|
165
|
+
*/
|
|
166
|
+
export async function fetchRepoFile(
|
|
167
|
+
orgId: string,
|
|
168
|
+
owner: string,
|
|
169
|
+
repo: string,
|
|
170
|
+
path: string,
|
|
171
|
+
ref?: string,
|
|
172
|
+
): Promise<string | null> {
|
|
173
|
+
const token = await getInstallationToken(orgId);
|
|
174
|
+
if (!token) return null;
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const url = new URL(`https://api.github.com/repos/${owner}/${repo}/contents/${path}`);
|
|
178
|
+
if (ref) url.searchParams.set('ref', ref);
|
|
179
|
+
|
|
180
|
+
const response = await fetch(url.toString(), {
|
|
181
|
+
headers: {
|
|
182
|
+
Authorization: `Bearer ${token}`,
|
|
183
|
+
Accept: 'application/vnd.github.raw+json',
|
|
184
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (!response.ok) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return response.text();
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|