@doccov/api 0.4.0 → 0.6.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,165 @@
1
+ /**
2
+ * Spec routes (v1) - API key authenticated endpoints for programmatic access
3
+ *
4
+ * POST /v1/spec/diff - Compare two specs
5
+ */
6
+
7
+ import { Hono } from 'hono';
8
+ import { z } from 'zod';
9
+ import { db } from '../db/client';
10
+ import type { ApiKeyContext } from '../middleware/api-key-auth';
11
+ import {
12
+ computeFullDiff,
13
+ type DiffOptions,
14
+ diffSpecs,
15
+ formatDiffResponse,
16
+ } from '../utils/spec-diff-core';
17
+
18
+ type Env = {
19
+ Variables: ApiKeyContext;
20
+ };
21
+
22
+ export const specV1Route = new Hono<Env>();
23
+
24
+ // Request schemas
25
+ const GitHubDiffSchema = z.object({
26
+ mode: z.literal('github'),
27
+ owner: z.string().min(1),
28
+ repo: z.string().min(1),
29
+ base: z.string().min(1),
30
+ head: z.string().min(1),
31
+ includeDocsImpact: z.boolean().optional(),
32
+ });
33
+
34
+ const SpecsDiffSchema = z.object({
35
+ mode: z.literal('specs'),
36
+ baseSpec: z.object({}).passthrough(),
37
+ headSpec: z.object({}).passthrough(),
38
+ markdownFiles: z
39
+ .array(
40
+ z.object({
41
+ path: z.string(),
42
+ content: z.string(),
43
+ }),
44
+ )
45
+ .optional(),
46
+ });
47
+
48
+ const DiffRequestSchema = z.discriminatedUnion('mode', [GitHubDiffSchema, SpecsDiffSchema]);
49
+
50
+ /**
51
+ * POST /v1/spec/diff - Compare two specs
52
+ *
53
+ * Supports two modes:
54
+ * 1. GitHub refs: Clone and compare specs from GitHub refs
55
+ * 2. Direct specs: Compare uploaded spec objects
56
+ */
57
+ specV1Route.post('/diff', async (c) => {
58
+ const org = c.get('org');
59
+
60
+ // Parse and validate request body
61
+ let body: z.infer<typeof DiffRequestSchema>;
62
+ try {
63
+ const rawBody = await c.req.json();
64
+ body = DiffRequestSchema.parse(rawBody);
65
+ } catch (err) {
66
+ if (err instanceof z.ZodError) {
67
+ return c.json(
68
+ {
69
+ error: 'Invalid request',
70
+ details: err.errors,
71
+ },
72
+ 400,
73
+ );
74
+ }
75
+ return c.json({ error: 'Invalid JSON body' }, 400);
76
+ }
77
+
78
+ try {
79
+ if (body.mode === 'github') {
80
+ // GitHub mode: need to find installation for this org
81
+ const { owner, repo, base, head, includeDocsImpact } = body;
82
+
83
+ // Look up installation from org
84
+ const installation = await db
85
+ .selectFrom('github_installations')
86
+ .where('orgId', '=', org.id)
87
+ .select(['installationId'])
88
+ .executeTakeFirst();
89
+
90
+ if (!installation) {
91
+ return c.json(
92
+ {
93
+ error: 'No GitHub App installation found for this repository',
94
+ hint: 'Install the DocCov GitHub App to compare repos',
95
+ },
96
+ 403,
97
+ );
98
+ }
99
+
100
+ // Compute diff with timeout
101
+ const diffOptions: DiffOptions = {
102
+ includeDocsImpact,
103
+ };
104
+
105
+ const result = await Promise.race([
106
+ computeFullDiff(
107
+ { owner, repo, ref: base, installationId: installation.installationId },
108
+ { owner, repo, ref: head, installationId: installation.installationId },
109
+ diffOptions,
110
+ ),
111
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error('TIMEOUT')), 60_000)),
112
+ ]);
113
+
114
+ return c.json(formatDiffResponse(result));
115
+ }
116
+
117
+ // Specs mode: direct comparison
118
+ const { baseSpec, headSpec, markdownFiles } = body;
119
+
120
+ const diff = diffSpecs(
121
+ baseSpec as Parameters<typeof diffSpecs>[0],
122
+ headSpec as Parameters<typeof diffSpecs>[1],
123
+ markdownFiles,
124
+ );
125
+
126
+ return c.json({
127
+ // Core diff fields
128
+ breaking: diff.breaking,
129
+ nonBreaking: diff.nonBreaking,
130
+ docsOnly: diff.docsOnly,
131
+ coverageDelta: diff.coverageDelta,
132
+ oldCoverage: diff.oldCoverage,
133
+ newCoverage: diff.newCoverage,
134
+ driftIntroduced: diff.driftIntroduced,
135
+ driftResolved: diff.driftResolved,
136
+ newUndocumented: diff.newUndocumented,
137
+ improvedExports: diff.improvedExports,
138
+ regressedExports: diff.regressedExports,
139
+
140
+ // Extended fields
141
+ memberChanges: diff.memberChanges,
142
+ categorizedBreaking: diff.categorizedBreaking,
143
+ docsImpact: diff.docsImpact,
144
+
145
+ // Metadata
146
+ generatedAt: new Date().toISOString(),
147
+ cached: false,
148
+ });
149
+ } catch (err) {
150
+ if (err instanceof Error) {
151
+ if (err.message === 'TIMEOUT') {
152
+ return c.json({ error: 'Spec generation timed out' }, 408);
153
+ }
154
+ if (err.message.includes('not found') || err.message.includes('404')) {
155
+ return c.json({ error: 'Repository or ref not found' }, 404);
156
+ }
157
+ if (err.message.includes('No token')) {
158
+ return c.json({ error: 'GitHub App access required' }, 403);
159
+ }
160
+ }
161
+
162
+ console.error('Spec diff error:', err);
163
+ return c.json({ error: 'Failed to compute diff' }, 500);
164
+ }
165
+ });
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Spec routes - session authenticated endpoints for dashboard
3
+ */
4
+
5
+ import { Hono } from 'hono';
6
+ import { z } from 'zod';
7
+ import { auth } from '../auth/config';
8
+ import { db } from '../db/client';
9
+ import {
10
+ computeFullDiff,
11
+ type DiffOptions,
12
+ diffSpecs,
13
+ formatDiffResponse,
14
+ } from '../utils/spec-diff-core';
15
+
16
+ type Session = Awaited<ReturnType<typeof auth.api.getSession>>;
17
+
18
+ type Env = {
19
+ Variables: {
20
+ session: NonNullable<Session>;
21
+ };
22
+ };
23
+
24
+ export const specRoute = new Hono<Env>();
25
+
26
+ // Middleware: require session auth
27
+ specRoute.use('*', async (c, next) => {
28
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
29
+ if (!session) {
30
+ return c.json({ error: 'Unauthorized' }, 401);
31
+ }
32
+ c.set('session', session);
33
+ await next();
34
+ });
35
+
36
+ // Request schemas
37
+ const GitHubDiffSchema = z.object({
38
+ mode: z.literal('github'),
39
+ owner: z.string().min(1),
40
+ repo: z.string().min(1),
41
+ base: z.string().min(1),
42
+ head: z.string().min(1),
43
+ installationId: z.string().optional(),
44
+ includeDocsImpact: z.boolean().optional(),
45
+ });
46
+
47
+ const SpecsDiffSchema = z.object({
48
+ mode: z.literal('specs'),
49
+ baseSpec: z.object({}).passthrough(),
50
+ headSpec: z.object({}).passthrough(),
51
+ markdownFiles: z
52
+ .array(
53
+ z.object({
54
+ path: z.string(),
55
+ content: z.string(),
56
+ }),
57
+ )
58
+ .optional(),
59
+ });
60
+
61
+ const DiffRequestSchema = z.discriminatedUnion('mode', [GitHubDiffSchema, SpecsDiffSchema]);
62
+
63
+ /**
64
+ * POST /spec/diff - Compare two specs
65
+ *
66
+ * Supports two modes:
67
+ * 1. GitHub refs: Clone and compare specs from GitHub refs
68
+ * 2. Direct specs: Compare uploaded spec objects
69
+ */
70
+ specRoute.post('/diff', async (c) => {
71
+ const session = c.get('session');
72
+
73
+ // Parse and validate request body
74
+ let body: z.infer<typeof DiffRequestSchema>;
75
+ try {
76
+ const rawBody = await c.req.json();
77
+ body = DiffRequestSchema.parse(rawBody);
78
+ } catch (err) {
79
+ if (err instanceof z.ZodError) {
80
+ return c.json(
81
+ {
82
+ error: 'Invalid request',
83
+ details: err.errors,
84
+ },
85
+ 400,
86
+ );
87
+ }
88
+ return c.json({ error: 'Invalid JSON body' }, 400);
89
+ }
90
+
91
+ try {
92
+ if (body.mode === 'github') {
93
+ // GitHub mode: need to verify access and get installation
94
+ const { owner, repo, base, head, installationId, includeDocsImpact } = body;
95
+
96
+ // Find installation ID if not provided
97
+ let resolvedInstallationId = installationId;
98
+
99
+ if (!resolvedInstallationId) {
100
+ // Look up installation from user's orgs
101
+ const installation = await db
102
+ .selectFrom('github_installations')
103
+ .innerJoin('org_members', 'org_members.orgId', 'github_installations.orgId')
104
+ .where('org_members.userId', '=', session.user.id)
105
+ .select(['github_installations.installationId'])
106
+ .executeTakeFirst();
107
+
108
+ if (!installation) {
109
+ return c.json(
110
+ {
111
+ error: 'No GitHub App installation found for this repository',
112
+ hint: 'Install the DocCov GitHub App to compare repos',
113
+ },
114
+ 403,
115
+ );
116
+ }
117
+
118
+ resolvedInstallationId = installation.installationId;
119
+ }
120
+
121
+ // Compute diff with timeout
122
+ const diffOptions: DiffOptions = {
123
+ includeDocsImpact,
124
+ };
125
+
126
+ const result = await Promise.race([
127
+ computeFullDiff(
128
+ { owner, repo, ref: base, installationId: resolvedInstallationId },
129
+ { owner, repo, ref: head, installationId: resolvedInstallationId },
130
+ diffOptions,
131
+ ),
132
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error('TIMEOUT')), 60_000)),
133
+ ]);
134
+
135
+ return c.json(formatDiffResponse(result));
136
+ }
137
+
138
+ // Specs mode: direct comparison
139
+ const { baseSpec, headSpec, markdownFiles } = body;
140
+
141
+ const diff = diffSpecs(
142
+ baseSpec as Parameters<typeof diffSpecs>[0],
143
+ headSpec as Parameters<typeof diffSpecs>[1],
144
+ markdownFiles,
145
+ );
146
+
147
+ return c.json({
148
+ // Core diff fields
149
+ breaking: diff.breaking,
150
+ nonBreaking: diff.nonBreaking,
151
+ docsOnly: diff.docsOnly,
152
+ coverageDelta: diff.coverageDelta,
153
+ oldCoverage: diff.oldCoverage,
154
+ newCoverage: diff.newCoverage,
155
+ driftIntroduced: diff.driftIntroduced,
156
+ driftResolved: diff.driftResolved,
157
+ newUndocumented: diff.newUndocumented,
158
+ improvedExports: diff.improvedExports,
159
+ regressedExports: diff.regressedExports,
160
+
161
+ // Extended fields
162
+ memberChanges: diff.memberChanges,
163
+ categorizedBreaking: diff.categorizedBreaking,
164
+ docsImpact: diff.docsImpact,
165
+
166
+ // Metadata
167
+ generatedAt: new Date().toISOString(),
168
+ cached: false,
169
+ });
170
+ } catch (err) {
171
+ if (err instanceof Error) {
172
+ if (err.message === 'TIMEOUT') {
173
+ return c.json({ error: 'Spec generation timed out' }, 408);
174
+ }
175
+ if (err.message.includes('not found') || err.message.includes('404')) {
176
+ return c.json({ error: 'Repository or ref not found' }, 404);
177
+ }
178
+ if (err.message.includes('No token')) {
179
+ return c.json({ error: 'GitHub App access required' }, 403);
180
+ }
181
+ }
182
+
183
+ console.error('Spec diff error:', err);
184
+ return c.json({ error: 'Failed to compute diff' }, 500);
185
+ }
186
+ });