@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.
@@ -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
+ });
@@ -1,81 +1,11 @@
1
1
  /**
2
- * Plan route for local development (mirrors api/plan.ts)
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
- const body = await c.req.json<{ url: string; ref?: string; package?: string }>();
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
+ }