@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 +13 -0
- package/package.json +3 -3
- package/src/index.ts +30 -2
- package/src/middleware/anonymous-rate-limit.ts +131 -0
- package/src/routes/ai.ts +353 -0
- package/src/routes/badge.ts +122 -32
- package/src/routes/billing.ts +65 -0
- package/src/routes/coverage.ts +34 -16
- 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 +249 -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/src/utils/github.ts +0 -5
package/src/routes/orgs.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { nanoid } from 'nanoid';
|
|
|
3
3
|
import { auth } from '../auth/config';
|
|
4
4
|
import { db } from '../db/client';
|
|
5
5
|
|
|
6
|
+
const SITE_URL = process.env.SITE_URL || 'http://localhost:3000';
|
|
7
|
+
|
|
6
8
|
type Session = Awaited<ReturnType<typeof auth.api.getSession>>;
|
|
7
9
|
|
|
8
10
|
type Env = {
|
|
@@ -136,3 +138,250 @@ orgsRoute.post('/:slug/projects', async (c) => {
|
|
|
136
138
|
|
|
137
139
|
return c.json({ project }, 201);
|
|
138
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
|
+
});
|
|
@@ -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
|
+
}
|