@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.
@@ -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
+ }