@app-vitals/agent-admin 1.12.1 → 1.14.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,750 @@
1
+ /**
2
+ * admin/src/github-enterprise-api.ts
3
+ * Admin-only /github-enterprise API (GHE-1.3): configure the two optional
4
+ * admin-level GitHub Apps ('installer' and 'lookup'), validate them, list the
5
+ * enterprise's installable organizations, and install an agent's App on one.
6
+ *
7
+ * Isolation exception: this module (with github-enterprise-apps.ts and the
8
+ * client files) is the only admin code that calls GitHub with App credentials.
9
+ * No route here returns key material, and nothing here is reachable from the
10
+ * agent config bundle, an agent-bearer route or env serialization (asserted by
11
+ * github-enterprise-isolation.*.test.ts).
12
+ *
13
+ * The whole feature is optional: with no rows configured, GET /apps and
14
+ * GET /orgs report an unconfigured status and nothing else happens.
15
+ *
16
+ * Auth: admin key or admin session (every other caller class gets 403).
17
+ */
18
+
19
+ import { createPrivateKey } from "node:crypto";
20
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
21
+ import { HTTPException } from "hono/http-exception";
22
+ import type { AgentEnvService } from "./agent-envs.ts";
23
+ import {
24
+ type AdminApiKey,
25
+ type AdminAuthEnv,
26
+ createAdminAuthMiddleware,
27
+ } from "./api-auth.ts";
28
+ import type { AgentTokenService } from "./agent-tokens.ts";
29
+ import type { AgentService } from "./agents.ts";
30
+ import {
31
+ ApiError,
32
+ ConflictError,
33
+ ForbiddenError,
34
+ NotFoundError,
35
+ UnprocessableEntityError,
36
+ } from "./errors.ts";
37
+ import { fetchAuthenticatedApp, fetchPublicApp } from "./github-app-api.ts";
38
+ import {
39
+ GithubEnterpriseClient,
40
+ GithubEnterpriseError,
41
+ type GithubEnterpriseErrorKind,
42
+ type AppInstallation,
43
+ } from "./github-enterprise-client.ts";
44
+ import {
45
+ GITHUB_ENTERPRISE_APP_ROLES,
46
+ type GitHubEnterpriseAppMetadata,
47
+ type GitHubEnterpriseAppRole,
48
+ type GitHubEnterpriseAppService,
49
+ } from "./github-enterprise-apps.ts";
50
+ import { ErrorSchema } from "./openapi-schemas.ts";
51
+
52
+ // ─── Deps ─────────────────────────────────────────────────────────────────────
53
+
54
+ export interface GithubEnterpriseApiDeps {
55
+ appService: Pick<
56
+ GitHubEnterpriseAppService,
57
+ "list" | "get" | "save" | "delete" | "getPrivateKey" | "recordValidation"
58
+ >;
59
+ agentService: Pick<AgentService, "getSummary">;
60
+ agentEnvService: Pick<AgentEnvService, "getByAgentId">;
61
+ sessionSecret: string;
62
+ adminApiKeys?: Map<string, AdminApiKey>;
63
+ agentTokenService: Pick<AgentTokenService, "validate">;
64
+ /** Test seams; default to the real GitHub API. */
65
+ fetchFn?: typeof fetch;
66
+ apiBase?: string;
67
+ now?: () => Date;
68
+ }
69
+
70
+ /** Permission each role's App must hold, and the levels that satisfy it. */
71
+ const REQUIRED_PERMISSION: Record<
72
+ GitHubEnterpriseAppRole,
73
+ { name: string; levels: readonly string[] }
74
+ > = {
75
+ installer: {
76
+ name: "enterprise_organization_installations",
77
+ levels: ["write"],
78
+ },
79
+ lookup: { name: "metadata", levels: ["read", "write"] },
80
+ };
81
+
82
+ const SLUG_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
83
+ const ORG_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
84
+
85
+ // ─── Schemas ──────────────────────────────────────────────────────────────────
86
+
87
+ const RoleSchema = z.enum(GITHUB_ENTERPRISE_APP_ROLES);
88
+
89
+ const RoleParamSchema = z.object({
90
+ role: RoleSchema.openapi({
91
+ param: { name: "role", in: "path" },
92
+ example: "installer",
93
+ }),
94
+ });
95
+
96
+ const OrgParamSchema = z.object({
97
+ org: z
98
+ .string()
99
+ .regex(ORG_RE)
100
+ .openapi({ param: { name: "org", in: "path" }, example: "example-org" }),
101
+ });
102
+
103
+ const AppMetadataSchema = z
104
+ .object({
105
+ role: z.string().openapi({ example: "installer" }),
106
+ appId: z.string().openapi({ example: "12345" }),
107
+ slug: z.string().openapi({ example: "example-installer" }),
108
+ enterpriseSlug: z.string().nullable().openapi({ example: "acme" }),
109
+ keySet: z
110
+ .boolean()
111
+ .openapi({
112
+ description:
113
+ "True when a private key is stored. The key is never returned.",
114
+ }),
115
+ lastValidationStatus: z.string().nullable().openapi({ example: "valid" }),
116
+ lastValidatedAt: z.string().nullable(),
117
+ lastValidationError: z.string().nullable(),
118
+ createdAt: z.string(),
119
+ updatedAt: z.string(),
120
+ })
121
+ .openapi("GithubEnterpriseApp");
122
+
123
+ const StatusSchema = z
124
+ .object({
125
+ configured: z.boolean(),
126
+ installer: AppMetadataSchema.nullable(),
127
+ lookup: AppMetadataSchema.nullable(),
128
+ })
129
+ .openapi("GithubEnterpriseStatus");
130
+
131
+ const PutAppBodySchema = z
132
+ .object({
133
+ appId: z.string().regex(/^\d+$/).openapi({ example: "12345" }),
134
+ slug: z.string().regex(SLUG_RE).openapi({ example: "example-installer" }),
135
+ enterpriseSlug: z
136
+ .string()
137
+ .regex(SLUG_RE)
138
+ .nullish()
139
+ .openapi({
140
+ description: "Required for the installer role.",
141
+ example: "acme",
142
+ }),
143
+ privateKey: z
144
+ .string()
145
+ .min(1)
146
+ .openapi({
147
+ description:
148
+ "PEM private key. Write-only: encrypted at rest, never returned.",
149
+ }),
150
+ })
151
+ .openapi("PutGithubEnterpriseAppBody");
152
+
153
+ const ValidateResultSchema = z
154
+ .object({
155
+ role: z.string(),
156
+ ok: z.boolean(),
157
+ status: z.enum(["valid", "missing_permission", "error"]),
158
+ requiredPermission: z.string(),
159
+ grantedPermissions: z.record(z.string(), z.string()),
160
+ error: z.string().nullable(),
161
+ app: AppMetadataSchema.nullable(),
162
+ })
163
+ .openapi("GithubEnterpriseValidateResult");
164
+
165
+ const OrgsQuerySchema = z.object({
166
+ agentId: z
167
+ .string()
168
+ .min(1)
169
+ .optional()
170
+ .openapi({ param: { name: "agentId", in: "query" } }),
171
+ });
172
+
173
+ const OrgsResultSchema = z
174
+ .object({
175
+ configured: z.boolean(),
176
+ agentAppSlug: z.string().nullable().optional(),
177
+ orgs: z.array(
178
+ z.object({
179
+ id: z.number(),
180
+ login: z.string(),
181
+ lookupAppInstalled: z
182
+ .boolean()
183
+ .nullable()
184
+ .openapi({ description: "Null when no lookup App is configured." }),
185
+ agentAppInstalled: z
186
+ .boolean()
187
+ .nullable()
188
+ .openapi({
189
+ description: "Null when no agentId (or no slug) was given.",
190
+ }),
191
+ }),
192
+ ),
193
+ })
194
+ .openapi("GithubEnterpriseOrgs");
195
+
196
+ const InstallBodySchema = z
197
+ .object({
198
+ agentId: z.string().min(1).openapi({ example: "agent-id" }),
199
+ repositorySelection: z.enum(["all", "selected", "none"]).default("all"),
200
+ repositories: z.array(z.string().min(1)).max(50).optional(),
201
+ })
202
+ .openapi("GithubEnterpriseInstallBody");
203
+
204
+ const InstallResultSchema = z
205
+ .object({
206
+ org: z.string(),
207
+ created: z
208
+ .boolean()
209
+ .openapi({ description: "False when the App was already installed." }),
210
+ installation: z.object({
211
+ id: z.number(),
212
+ appSlug: z.string(),
213
+ accountLogin: z.string(),
214
+ repositorySelection: z.enum(["all", "selected", "none"]),
215
+ }),
216
+ })
217
+ .openapi("GithubEnterpriseInstallResult");
218
+
219
+ // ─── Routes ───────────────────────────────────────────────────────────────────
220
+
221
+ const jsonError = { content: { "application/json": { schema: ErrorSchema } } };
222
+ const errorResponses = {
223
+ 401: { description: "Unauthorized", ...jsonError },
224
+ 403: { description: "Forbidden (non-admin caller)", ...jsonError },
225
+ };
226
+ const upstreamErrors = {
227
+ 404: { description: "Not found (or not found at GitHub)", ...jsonError },
228
+ 422: { description: "Refused or rejected by GitHub", ...jsonError },
229
+ 424: {
230
+ description: "GitHub refused the configured App credentials or permission",
231
+ ...jsonError,
232
+ },
233
+ 429: { description: "GitHub rate limit", ...jsonError },
234
+ 502: {
235
+ description: "GitHub unavailable or returned an unexpected response",
236
+ ...jsonError,
237
+ },
238
+ 504: { description: "GitHub unreachable", ...jsonError },
239
+ };
240
+ const tags = ["github-enterprise"];
241
+
242
+ const statusRoute = createRoute({
243
+ method: "get",
244
+ path: "/github-enterprise/apps",
245
+ tags,
246
+ summary: "GitHub Enterprise App status",
247
+ description:
248
+ "Admin-only. Metadata and key-set flag per role; never the key. Returns configured=false with null roles when nothing is set.",
249
+ responses: {
250
+ 200: {
251
+ description: "Status",
252
+ content: { "application/json": { schema: StatusSchema } },
253
+ },
254
+ ...errorResponses,
255
+ },
256
+ });
257
+
258
+ const putRoute = createRoute({
259
+ method: "put",
260
+ path: "/github-enterprise/apps/{role}",
261
+ tags,
262
+ summary: "Create or replace the App for a role",
263
+ description:
264
+ "Admin-only. Stores the App id, slug, enterprise slug (installer) and the encrypted private key, and resets validation state. 422 when SHIPWRIGHT_ENCRYPTION_KEY is not configured.",
265
+ request: {
266
+ params: RoleParamSchema,
267
+ body: { content: { "application/json": { schema: PutAppBodySchema } } },
268
+ },
269
+ responses: {
270
+ 200: {
271
+ description: "Saved (metadata only)",
272
+ content: { "application/json": { schema: AppMetadataSchema } },
273
+ },
274
+ 400: { description: "Bad request", ...jsonError },
275
+ 422: {
276
+ description: "Invalid key, or encryption unavailable",
277
+ ...jsonError,
278
+ },
279
+ ...errorResponses,
280
+ },
281
+ });
282
+
283
+ const deleteRoute = createRoute({
284
+ method: "delete",
285
+ path: "/github-enterprise/apps/{role}",
286
+ tags,
287
+ summary: "Remove the App for a role",
288
+ description: "Admin-only. No-op when the role is not configured.",
289
+ request: { params: RoleParamSchema },
290
+ responses: {
291
+ 204: { description: "Removed" },
292
+ ...errorResponses,
293
+ },
294
+ });
295
+
296
+ const validateRoute = createRoute({
297
+ method: "post",
298
+ path: "/github-enterprise/apps/{role}/validate",
299
+ tags,
300
+ summary: "Validate the App for a role",
301
+ description:
302
+ "Admin-only. Authenticates as the App and checks the required permission (installer: enterprise_organization_installations write; lookup: metadata read). The outcome is persisted and returned; GitHub failures are reported as status=error with a generic message.",
303
+ request: { params: RoleParamSchema },
304
+ responses: {
305
+ 200: {
306
+ description: "Validation outcome",
307
+ content: { "application/json": { schema: ValidateResultSchema } },
308
+ },
309
+ 404: { description: "Role not configured", ...jsonError },
310
+ ...errorResponses,
311
+ },
312
+ });
313
+
314
+ const orgsRoute = createRoute({
315
+ method: "get",
316
+ path: "/github-enterprise/orgs",
317
+ tags,
318
+ summary: "List installable organizations",
319
+ description:
320
+ "Admin-only. Lists the enterprise's installable organizations, whether each has the lookup App installed, and (with agentId) whether each has that agent's App installed. Returns configured=false and no orgs when the installer App is not set.",
321
+ request: { query: OrgsQuerySchema },
322
+ responses: {
323
+ 200: {
324
+ description: "Organizations",
325
+ content: { "application/json": { schema: OrgsResultSchema } },
326
+ },
327
+ ...upstreamErrors,
328
+ ...errorResponses,
329
+ },
330
+ });
331
+
332
+ const installRoute = createRoute({
333
+ method: "post",
334
+ path: "/github-enterprise/orgs/{org}/install",
335
+ tags,
336
+ summary: "Install an agent's App on an organization",
337
+ description:
338
+ "Admin-only. Resolves the agent's stored GH_APP_SLUG, looks the App up through the public GET /apps/{slug} to get its client id, and installs it on the org with the installer App. 422 for an agent with no slug and for a private App (not visible to the public lookup); 409 when the installer App is not configured.",
339
+ request: {
340
+ params: OrgParamSchema,
341
+ body: { content: { "application/json": { schema: InstallBodySchema } } },
342
+ },
343
+ responses: {
344
+ 200: {
345
+ description: "Installed (or already installed)",
346
+ content: { "application/json": { schema: InstallResultSchema } },
347
+ },
348
+ 400: { description: "Bad request", ...jsonError },
349
+ 409: { description: "Installer App not configured", ...jsonError },
350
+ ...upstreamErrors,
351
+ ...errorResponses,
352
+ },
353
+ });
354
+
355
+ // ─── Error mapping ────────────────────────────────────────────────────────────
356
+
357
+ interface Mapped {
358
+ status: 404 | 422 | 424 | 429 | 502 | 504;
359
+ message: string;
360
+ }
361
+
362
+ /**
363
+ * Distinct, non-leaky mapping of client failures. Never forwards GitHub's
364
+ * message text or the client's (redacted) message.
365
+ */
366
+ export function mapEnterpriseFailure(kind: GithubEnterpriseErrorKind): Mapped {
367
+ switch (kind) {
368
+ case "unauthorized":
369
+ return {
370
+ status: 424,
371
+ message:
372
+ "GitHub rejected the configured App credentials; re-validate the App and re-save its private key.",
373
+ };
374
+ case "permission":
375
+ return {
376
+ status: 424,
377
+ message:
378
+ "GitHub denied the request: the App lacks the required permission or is not installed on the enterprise.",
379
+ };
380
+ case "not_found":
381
+ return {
382
+ status: 404,
383
+ message:
384
+ "GitHub could not find the enterprise, organization or App for this request.",
385
+ };
386
+ case "validation":
387
+ return {
388
+ status: 422,
389
+ message: "GitHub rejected the request as invalid.",
390
+ };
391
+ case "rate_limit":
392
+ return {
393
+ status: 429,
394
+ message: "GitHub rate limit reached; retry later.",
395
+ };
396
+ case "server":
397
+ return {
398
+ status: 502,
399
+ message: "GitHub is unavailable (server error); retry later.",
400
+ };
401
+ case "network":
402
+ return { status: 504, message: "GitHub could not be reached." };
403
+ default:
404
+ return {
405
+ status: 502,
406
+ message: "GitHub returned an unexpected response.",
407
+ };
408
+ }
409
+ }
410
+
411
+ function serializeApp(m: GitHubEnterpriseAppMetadata) {
412
+ return {
413
+ role: m.role,
414
+ appId: m.appId,
415
+ slug: m.slug,
416
+ enterpriseSlug: m.enterpriseSlug,
417
+ keySet: m.keySet,
418
+ lastValidationStatus: m.lastValidationStatus,
419
+ lastValidatedAt: m.lastValidatedAt?.toISOString() ?? null,
420
+ lastValidationError: m.lastValidationError,
421
+ createdAt: m.createdAt.toISOString(),
422
+ updatedAt: m.updatedAt.toISOString(),
423
+ };
424
+ }
425
+
426
+ // ─── App ──────────────────────────────────────────────────────────────────────
427
+
428
+ export function createGithubEnterpriseApp(
429
+ deps: GithubEnterpriseApiDeps,
430
+ ): OpenAPIHono<AdminAuthEnv> {
431
+ const { appService } = deps;
432
+
433
+ const app = new OpenAPIHono<AdminAuthEnv>({
434
+ defaultHook: (result, c) => {
435
+ if (!result.success) {
436
+ const message = result.error.issues
437
+ .map((i) => `${i.path.join(".")}: ${i.message}`)
438
+ .join(", ");
439
+ return c.json({ error: message }, 400);
440
+ }
441
+ },
442
+ });
443
+
444
+ app.onError((err, c) => {
445
+ if (err instanceof GithubEnterpriseError) {
446
+ const mapped = mapEnterpriseFailure(err.kind);
447
+ if (mapped.status === 429 && err.retryAfterSeconds) {
448
+ c.header("Retry-After", String(Math.ceil(err.retryAfterSeconds)));
449
+ }
450
+ return c.json({ error: mapped.message }, mapped.status);
451
+ }
452
+ if (err instanceof ApiError) {
453
+ // ApiError messages are authored in this repo and never carry key material.
454
+ return c.json(
455
+ { error: err.message },
456
+ err.statusCode as 400 | 401 | 403 | 404 | 409 | 422 | 500,
457
+ );
458
+ }
459
+ if (err instanceof HTTPException && err.status < 500) {
460
+ return c.json({ error: err.message }, err.status as 400 | 415);
461
+ }
462
+ // Deliberately log only the class: an unexpected error must not echo key material.
463
+ console.error(
464
+ "[github-enterprise-api] unhandled error:",
465
+ err instanceof Error ? err.name : typeof err,
466
+ );
467
+ return c.json({ error: "Internal server error" }, 500);
468
+ });
469
+
470
+ app.use(
471
+ "/github-enterprise/*",
472
+ createAdminAuthMiddleware({
473
+ sessionSecret: deps.sessionSecret,
474
+ agentTokenService: deps.agentTokenService,
475
+ adminApiKeys: deps.adminApiKeys,
476
+ }),
477
+ );
478
+
479
+ const requireAdmin = (c: { get: (k: "isAdmin") => unknown }) => {
480
+ if (c.get("isAdmin") !== true) {
481
+ throw new ForbiddenError("Admin access required");
482
+ }
483
+ };
484
+
485
+ const clientFor = async (
486
+ meta: GitHubEnterpriseAppMetadata,
487
+ ): Promise<GithubEnterpriseClient> => {
488
+ const privateKey = await appService.getPrivateKey(
489
+ meta.role as GitHubEnterpriseAppRole,
490
+ );
491
+ if (!privateKey || !meta.enterpriseSlug) {
492
+ throw new UnprocessableEntityError(
493
+ "the installer App is missing its key or enterprise slug; re-save it",
494
+ );
495
+ }
496
+ return new GithubEnterpriseClient({
497
+ appId: meta.appId,
498
+ enterprise: meta.enterpriseSlug,
499
+ privateKey,
500
+ apiBase: deps.apiBase,
501
+ fetchFn: deps.fetchFn,
502
+ now: deps.now,
503
+ });
504
+ };
505
+
506
+ /** Agent's stored App slug: null when absent/invalid. 404 when the agent is unknown. */
507
+ const agentSlug = async (
508
+ agentId: string,
509
+ ): Promise<{ slug: string | null; invalid: boolean }> => {
510
+ if (!(await deps.agentService.getSummary(agentId))) {
511
+ throw new NotFoundError(`agent ${agentId} not found`);
512
+ }
513
+ const slug = (await deps.agentEnvService.getByAgentId(agentId))?.env
514
+ .GH_APP_SLUG;
515
+ if (!slug) return { slug: null, invalid: false };
516
+ return SLUG_RE.test(slug)
517
+ ? { slug, invalid: false }
518
+ : { slug: null, invalid: true };
519
+ };
520
+
521
+ app.openapi(statusRoute, async (c) => {
522
+ requireAdmin(c);
523
+ const rows = await appService.list();
524
+ const byRole = (role: string) => {
525
+ const r = rows.find((x) => x.role === role);
526
+ return r ? serializeApp(r) : null;
527
+ };
528
+ return c.json(
529
+ {
530
+ configured: rows.length > 0,
531
+ installer: byRole("installer"),
532
+ lookup: byRole("lookup"),
533
+ },
534
+ 200,
535
+ );
536
+ });
537
+
538
+ app.openapi(putRoute, async (c) => {
539
+ requireAdmin(c);
540
+ const { role } = c.req.valid("param");
541
+ const body = c.req.valid("json");
542
+ if (role === "installer" && !body.enterpriseSlug) {
543
+ throw new UnprocessableEntityError(
544
+ "enterpriseSlug is required for the installer role",
545
+ );
546
+ }
547
+ try {
548
+ createPrivateKey(body.privateKey);
549
+ } catch {
550
+ // Never echo the submitted value.
551
+ throw new UnprocessableEntityError(
552
+ "privateKey is not a valid PEM private key",
553
+ );
554
+ }
555
+ const saved = await appService.save({
556
+ role,
557
+ appId: body.appId,
558
+ slug: body.slug,
559
+ enterpriseSlug: body.enterpriseSlug ?? null,
560
+ privateKey: body.privateKey,
561
+ });
562
+ return c.json(serializeApp(saved), 200);
563
+ });
564
+
565
+ app.openapi(deleteRoute, async (c) => {
566
+ requireAdmin(c);
567
+ await appService.delete(c.req.valid("param").role);
568
+ return c.body(null, 204);
569
+ });
570
+
571
+ app.openapi(validateRoute, async (c) => {
572
+ requireAdmin(c);
573
+ const { role } = c.req.valid("param");
574
+ const meta = await appService.get(role);
575
+ if (!meta) throw new NotFoundError(`no '${role}' App is configured`);
576
+ const privateKey = await appService.getPrivateKey(role);
577
+ if (!privateKey) throw new NotFoundError(`no '${role}' App is configured`);
578
+
579
+ const required = REQUIRED_PERMISSION[role];
580
+ let granted: Record<string, string> = {};
581
+ let status: "valid" | "missing_permission" | "error";
582
+ let error: string | null = null;
583
+ try {
584
+ if (role === "installer") {
585
+ if (!meta.enterpriseSlug) {
586
+ throw new UnprocessableEntityError(
587
+ "the installer App has no enterprise slug; re-save it",
588
+ );
589
+ }
590
+ const client = new GithubEnterpriseClient({
591
+ appId: meta.appId,
592
+ enterprise: meta.enterpriseSlug,
593
+ privateKey,
594
+ apiBase: deps.apiBase,
595
+ fetchFn: deps.fetchFn,
596
+ now: deps.now,
597
+ });
598
+ granted = (await client.getInstallationToken()).permissions ?? {};
599
+ } else {
600
+ granted = (
601
+ await fetchAuthenticatedApp({
602
+ appId: meta.appId,
603
+ privateKey,
604
+ apiBase: deps.apiBase,
605
+ fetchFn: deps.fetchFn,
606
+ now: deps.now,
607
+ })
608
+ ).permissions;
609
+ }
610
+ status = required.levels.includes(granted[required.name] ?? "")
611
+ ? "valid"
612
+ : "missing_permission";
613
+ if (status === "missing_permission") {
614
+ error = `the App does not hold ${required.name} (${required.levels.join(" or ")})`;
615
+ }
616
+ } catch (err) {
617
+ status = "error";
618
+ if (err instanceof GithubEnterpriseError) {
619
+ error = mapEnterpriseFailure(err.kind).message;
620
+ } else if (err instanceof ApiError) {
621
+ error = err.message;
622
+ } else {
623
+ // JWT signing failure and anything else: generic, no detail.
624
+ error = "the stored private key could not be used to sign a request";
625
+ }
626
+ }
627
+
628
+ await appService.recordValidation(role, { status, error });
629
+ const updated = await appService.get(role);
630
+ return c.json(
631
+ {
632
+ role,
633
+ ok: status === "valid",
634
+ status,
635
+ requiredPermission: required.name,
636
+ grantedPermissions: granted,
637
+ error,
638
+ app: updated ? serializeApp(updated) : null,
639
+ },
640
+ 200,
641
+ );
642
+ });
643
+
644
+ app.openapi(orgsRoute, async (c) => {
645
+ requireAdmin(c);
646
+ const { agentId } = c.req.valid("query");
647
+ const installer = await appService.get("installer");
648
+ if (!installer) return c.json({ configured: false, orgs: [] }, 200);
649
+
650
+ let agentAppSlug: string | null = null;
651
+ if (agentId) agentAppSlug = (await agentSlug(agentId)).slug;
652
+ const lookup = await appService.get("lookup");
653
+
654
+ const client = await clientFor(installer);
655
+ const orgs = await client.listInstallableOrganizations();
656
+ const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase();
657
+ const needInstallations = lookup !== null || agentAppSlug !== null;
658
+
659
+ const result = [];
660
+ for (const o of orgs) {
661
+ let installs: AppInstallation[] = [];
662
+ if (needInstallations)
663
+ installs = await client.listOrgInstallations(o.login);
664
+ result.push({
665
+ id: o.id,
666
+ login: o.login,
667
+ lookupAppInstalled: lookup
668
+ ? installs.some((i) => same(i.appSlug, lookup.slug))
669
+ : null,
670
+ agentAppInstalled: agentAppSlug
671
+ ? installs.some((i) => same(i.appSlug, agentAppSlug as string))
672
+ : null,
673
+ });
674
+ }
675
+ return c.json(
676
+ {
677
+ configured: true,
678
+ ...(agentId ? { agentAppSlug } : {}),
679
+ orgs: result,
680
+ },
681
+ 200,
682
+ );
683
+ });
684
+
685
+ app.openapi(installRoute, async (c) => {
686
+ requireAdmin(c);
687
+ const { org } = c.req.valid("param");
688
+ const body = c.req.valid("json");
689
+
690
+ const installer = await appService.get("installer");
691
+ if (!installer) {
692
+ throw new ConflictError("the installer App is not configured");
693
+ }
694
+ const { slug, invalid } = await agentSlug(body.agentId);
695
+ if (invalid) {
696
+ throw new UnprocessableEntityError(
697
+ "the agent's stored GH_APP_SLUG is not a valid GitHub App slug",
698
+ );
699
+ }
700
+ if (!slug) {
701
+ throw new UnprocessableEntityError(
702
+ "the agent has no GH_APP_SLUG; it has no GitHub App to install",
703
+ );
704
+ }
705
+
706
+ let clientId: string | null;
707
+ try {
708
+ clientId = (
709
+ await fetchPublicApp(slug, {
710
+ apiBase: deps.apiBase,
711
+ fetchFn: deps.fetchFn,
712
+ })
713
+ ).clientId;
714
+ } catch (err) {
715
+ if (err instanceof GithubEnterpriseError && err.kind === "not_found") {
716
+ throw new UnprocessableEntityError(
717
+ `the agent's App '${slug}' is private (not visible to the public lookup) and cannot be installed through the enterprise API`,
718
+ );
719
+ }
720
+ throw err;
721
+ }
722
+ if (!clientId) {
723
+ throw new UnprocessableEntityError(
724
+ `the agent's App '${slug}' did not expose a client id and cannot be installed`,
725
+ );
726
+ }
727
+
728
+ const client = await clientFor(installer);
729
+ const res = await client.installOnOrg(org, {
730
+ clientId,
731
+ repositorySelection: body.repositorySelection,
732
+ ...(body.repositories ? { repositories: body.repositories } : {}),
733
+ });
734
+ return c.json(
735
+ {
736
+ org,
737
+ created: res.created,
738
+ installation: {
739
+ id: res.installation.id,
740
+ appSlug: res.installation.appSlug,
741
+ accountLogin: res.installation.accountLogin,
742
+ repositorySelection: res.installation.repositorySelection,
743
+ },
744
+ },
745
+ 200,
746
+ );
747
+ });
748
+
749
+ return app;
750
+ }