@app-vitals/agent-admin 1.12.0 → 1.13.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,375 @@
1
+ /**
2
+ * admin/src/github-enterprise-client.ts
3
+ * GithubEnterpriseClient: manages GitHub App installations on enterprise-owned
4
+ * organizations, authenticated as a "installer" GitHub App that is itself
5
+ * installed on the enterprise.
6
+ *
7
+ * Flow: sign app JWT -> GET /enterprises/{enterprise}/installation (JWT) ->
8
+ * POST /app/installations/{id}/access_tokens (JWT) -> call the
9
+ * /enterprises/{enterprise}/apps/... endpoints with the installation token.
10
+ *
11
+ * Repo add/remove on an installation is Phase 3 and intentionally not here.
12
+ *
13
+ * ── Verified findings (feed GHE-1.5 docs) ─────────────────────────────────
14
+ * Source: github/rest-api-description descriptions/ghec/ghec.json (fetched
15
+ * 2026-11-09) and docs.github.com "permissions required for GitHub Apps".
16
+ * The enterprise endpoints exist ONLY in the ghec (and GHES) descriptions,
17
+ * not in api.github.com.json; they are githubCloudOnly.
18
+ *
19
+ * - Install POST: POST /enterprises/{enterprise}/apps/organizations/{org}/installations
20
+ * body { client_id (required), repository_selection (required: all |
21
+ * selected | none), repositories (string[] of simple repo names, max 50,
22
+ * only for "selected") }. So YES it needs the TARGET App's client_id (not
23
+ * its numeric id). Returns 201 for a new installation and 200 when the app
24
+ * was already installed (suspended -> unsuspended, pending requests
25
+ * approved, pending update applied). Response is an `installation` object.
26
+ * "none" is only for apps that request no repository permissions.
27
+ * - Enterprise installation token: GET /enterprises/{enterprise}/installation
28
+ * with the app JWT ("Get an enterprise installation for the authenticated
29
+ * app") returns the installation whose `id` is then used in the normal
30
+ * POST /app/installations/{installation_id}/access_tokens (JWT) call, which
31
+ * returns { token, expires_at (1h) }. The OpenAPI description documents the
32
+ * two calls separately; the combination is the documented path, but it was
33
+ * not exercised live (no live recording in this task).
34
+ * - Installer app permissions (enterprise-level): `enterprise_organization_installations`
35
+ * (read | write). Docs page: read for GET installable_organizations and GET
36
+ * organizations/{org}/installations, write for the POST install (and DELETE).
37
+ * `enterprise_organization_installation_repositories` (read | write) is for
38
+ * the repositories endpoints, i.e. Phase 3, not needed here.
39
+ * - Caller restriction: all enterprise endpoints "can only be called by a
40
+ * GitHub App installed on the enterprise that owns the organization".
41
+ * - Collaborator-permission lookup (GET /repos/{owner}/{repo}/collaborators/
42
+ * {username}/permission, usable by GitHub Apps): the docs list it under
43
+ * repository permission "Metadata" read, NOT Administration. The returned
44
+ * `permission` reflects the highest grant across repo, team, org and
45
+ * enterprise. Whether Metadata read alone suffices at runtime was not
46
+ * verified live.
47
+ * - Pagination: per_page (max 100, default 30) and page query params.
48
+ * - NOT verified: secondary-rate-limit specifics for these endpoints (the
49
+ * docs page has no rate limit information); the 403/429 classification
50
+ * below follows GitHub's general rate-limit documentation.
51
+ * ──────────────────────────────────────────────────────────────────────────
52
+ */
53
+
54
+ import { signGithubAppJwt } from "./github-app-jwt.ts";
55
+
56
+ // ─── Errors ───────────────────────────────────────────────────────────────────
57
+
58
+ export type GithubEnterpriseErrorKind =
59
+ | "unauthorized" // 401
60
+ | "permission" // 403, missing permission
61
+ | "not_found" // 404
62
+ | "validation" // 422
63
+ | "rate_limit" // 429, or 403 primary/secondary rate limit
64
+ | "server" // 5xx
65
+ | "network" // fetch threw
66
+ | "unexpected"; // other statuses, malformed body
67
+
68
+ export class GithubEnterpriseError extends Error {
69
+ readonly kind: GithubEnterpriseErrorKind;
70
+ readonly status?: number;
71
+ readonly retryAfterSeconds?: number;
72
+
73
+ constructor(
74
+ kind: GithubEnterpriseErrorKind,
75
+ message: string,
76
+ opts?: { status?: number; retryAfterSeconds?: number },
77
+ ) {
78
+ super(message);
79
+ this.name = "GithubEnterpriseError";
80
+ this.kind = kind;
81
+ this.status = opts?.status;
82
+ this.retryAfterSeconds = opts?.retryAfterSeconds;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Map an HTTP error response to a typed error. `secrets` are redacted from
88
+ * the message so a token or key echoed by a server never propagates.
89
+ */
90
+ export function mapGithubEnterpriseError(
91
+ status: number,
92
+ headers: Headers,
93
+ body: unknown,
94
+ context: string,
95
+ secrets: readonly string[] = [],
96
+ ): GithubEnterpriseError {
97
+ let detail =
98
+ body && typeof body === "object" && typeof (body as { message?: unknown }).message === "string"
99
+ ? (body as { message: string }).message
100
+ : "";
101
+ for (const s of secrets) if (s) detail = detail.split(s).join("[redacted]");
102
+ detail = detail.slice(0, 200);
103
+ const message = `${context}: HTTP ${status}${detail ? ` ${detail}` : ""}`;
104
+
105
+ const ra = Number(headers.get("retry-after"));
106
+ const retryAfterSeconds = Number.isFinite(ra) && ra > 0 ? ra : undefined;
107
+ const opts = { status, retryAfterSeconds };
108
+
109
+ if (status === 429) return new GithubEnterpriseError("rate_limit", message, opts);
110
+ if (status === 403) {
111
+ const limited =
112
+ headers.get("retry-after") !== null ||
113
+ headers.get("x-ratelimit-remaining") === "0" ||
114
+ /rate limit/i.test(detail);
115
+ return new GithubEnterpriseError(limited ? "rate_limit" : "permission", message, opts);
116
+ }
117
+ if (status === 401) return new GithubEnterpriseError("unauthorized", message, opts);
118
+ if (status === 404) return new GithubEnterpriseError("not_found", message, opts);
119
+ if (status === 422) return new GithubEnterpriseError("validation", message, opts);
120
+ if (status >= 500) return new GithubEnterpriseError("server", message, opts);
121
+ return new GithubEnterpriseError("unexpected", message, opts);
122
+ }
123
+
124
+ // ─── Types ────────────────────────────────────────────────────────────────────
125
+
126
+ export type RepositorySelection = "all" | "selected" | "none";
127
+
128
+ export interface InstallableOrganization {
129
+ id: number;
130
+ login: string;
131
+ }
132
+
133
+ export interface AppInstallation {
134
+ id: number;
135
+ appId: number;
136
+ appSlug: string;
137
+ clientId?: string;
138
+ accountLogin: string;
139
+ repositorySelection: RepositorySelection;
140
+ }
141
+
142
+ export interface InstallOnOrgInput {
143
+ /** Client ID of the App to install (not the numeric app id). */
144
+ clientId: string;
145
+ repositorySelection: RepositorySelection;
146
+ /** Simple repo names (max 50); required when repositorySelection is "selected". */
147
+ repositories?: string[];
148
+ }
149
+
150
+ export interface InstallOnOrgResult {
151
+ installation: AppInstallation;
152
+ /** true when GitHub returned 201; false (200) when already installed. */
153
+ created: boolean;
154
+ }
155
+
156
+ export interface EnterpriseInstallationToken {
157
+ token: string;
158
+ expiresAt: string;
159
+ installationId: number;
160
+ }
161
+
162
+ export interface GithubEnterpriseClientOptions {
163
+ appId: string;
164
+ enterprise: string;
165
+ privateKey: string;
166
+ apiBase?: string;
167
+ fetchFn?: typeof fetch;
168
+ now?: () => Date;
169
+ }
170
+
171
+ const PER_PAGE = 100;
172
+ const MAX_PAGES = 100;
173
+ const MAX_REPOSITORIES = 50;
174
+ /** Refresh a cached installation token this long before it expires. */
175
+ const TOKEN_REFRESH_SKEW_MS = 60_000;
176
+
177
+ // ─── Client ───────────────────────────────────────────────────────────────────
178
+
179
+ export class GithubEnterpriseClient {
180
+ private readonly appId: string;
181
+ private readonly enterprise: string;
182
+ private readonly privateKey: string;
183
+ private readonly apiBase: string;
184
+ private readonly fetchFn: typeof fetch;
185
+ private readonly now: () => Date;
186
+ private cached?: EnterpriseInstallationToken;
187
+
188
+ constructor(opts: GithubEnterpriseClientOptions) {
189
+ this.appId = opts.appId;
190
+ this.enterprise = opts.enterprise;
191
+ this.privateKey = opts.privateKey;
192
+ this.apiBase = opts.apiBase ?? "https://api.github.com";
193
+ this.fetchFn = opts.fetchFn ?? fetch;
194
+ this.now = opts.now ?? (() => new Date());
195
+ }
196
+
197
+ /** Mint (or reuse a still-fresh) enterprise installation token. */
198
+ async getInstallationToken(): Promise<EnterpriseInstallationToken> {
199
+ if (
200
+ this.cached &&
201
+ Date.parse(this.cached.expiresAt) - this.now().getTime() > TOKEN_REFRESH_SKEW_MS
202
+ ) {
203
+ return this.cached;
204
+ }
205
+ const jwt = signGithubAppJwt({
206
+ appId: this.appId,
207
+ privateKey: this.privateKey,
208
+ now: this.now(),
209
+ });
210
+ const ent = encodeURIComponent(this.enterprise);
211
+ const inst = await this.request<{ id?: number }>(
212
+ "GET",
213
+ `/enterprises/${ent}/installation`,
214
+ jwt,
215
+ "GitHub enterprise installation lookup",
216
+ );
217
+ if (typeof inst.data?.id !== "number") {
218
+ throw new GithubEnterpriseError(
219
+ "unexpected",
220
+ "GitHub enterprise installation lookup: response missing id",
221
+ );
222
+ }
223
+ const minted = await this.request<{ token?: string; expires_at?: string }>(
224
+ "POST",
225
+ `/app/installations/${inst.data.id}/access_tokens`,
226
+ jwt,
227
+ "GitHub installation access token",
228
+ );
229
+ if (!minted.data?.token || !minted.data.expires_at) {
230
+ throw new GithubEnterpriseError(
231
+ "unexpected",
232
+ "GitHub installation access token: response missing token or expires_at",
233
+ );
234
+ }
235
+ this.cached = {
236
+ token: minted.data.token,
237
+ expiresAt: minted.data.expires_at,
238
+ installationId: inst.data.id,
239
+ };
240
+ return this.cached;
241
+ }
242
+
243
+ /** List all organizations the installer App can install Apps on (paginated). */
244
+ async listInstallableOrganizations(): Promise<InstallableOrganization[]> {
245
+ const ent = encodeURIComponent(this.enterprise);
246
+ const rows = await this.paginate<{ id: number; login: string }>(
247
+ `/enterprises/${ent}/apps/installable_organizations`,
248
+ "GitHub installable organizations",
249
+ );
250
+ return rows.map((r) => ({ id: r.id, login: r.login }));
251
+ }
252
+
253
+ /** List all App installations on an enterprise-owned org (paginated). */
254
+ async listOrgInstallations(org: string): Promise<AppInstallation[]> {
255
+ const ent = encodeURIComponent(this.enterprise);
256
+ const rows = await this.paginate<RawInstallation>(
257
+ `/enterprises/${ent}/apps/organizations/${encodeURIComponent(org)}/installations`,
258
+ "GitHub org installations",
259
+ );
260
+ return rows.map(toInstallation);
261
+ }
262
+
263
+ /** Install an App on an enterprise-owned org. */
264
+ async installOnOrg(org: string, input: InstallOnOrgInput): Promise<InstallOnOrgResult> {
265
+ if (!input.clientId) {
266
+ throw new GithubEnterpriseError("validation", "installOnOrg: clientId is required");
267
+ }
268
+ const repos = input.repositories ?? [];
269
+ if (input.repositorySelection === "selected" && (repos.length === 0 || repos.length > MAX_REPOSITORIES)) {
270
+ throw new GithubEnterpriseError(
271
+ "validation",
272
+ `installOnOrg: repositories must list 1-${MAX_REPOSITORIES} names when repositorySelection is "selected"`,
273
+ );
274
+ }
275
+ const body: Record<string, unknown> = {
276
+ client_id: input.clientId,
277
+ repository_selection: input.repositorySelection,
278
+ };
279
+ if (input.repositorySelection === "selected") body.repositories = repos;
280
+ const { token } = await this.getInstallationToken();
281
+ const ent = encodeURIComponent(this.enterprise);
282
+ const res = await this.request<RawInstallation>(
283
+ "POST",
284
+ `/enterprises/${ent}/apps/organizations/${encodeURIComponent(org)}/installations`,
285
+ token,
286
+ "GitHub install App on org",
287
+ body,
288
+ );
289
+ if (typeof res.data?.id !== "number") {
290
+ throw new GithubEnterpriseError("unexpected", "GitHub install App on org: response missing id");
291
+ }
292
+ return { installation: toInstallation(res.data), created: res.status === 201 };
293
+ }
294
+
295
+ // ─── internals ──────────────────────────────────────────────────────────────
296
+
297
+ private async paginate<T>(path: string, context: string): Promise<T[]> {
298
+ const { token } = await this.getInstallationToken();
299
+ const all: T[] = [];
300
+ for (let page = 1; page <= MAX_PAGES; page++) {
301
+ const res = await this.request<T[]>(
302
+ "GET",
303
+ `${path}?per_page=${PER_PAGE}&page=${page}`,
304
+ token,
305
+ context,
306
+ );
307
+ if (!Array.isArray(res.data)) {
308
+ throw new GithubEnterpriseError("unexpected", `${context}: response is not a list`);
309
+ }
310
+ all.push(...res.data);
311
+ const link = res.headers.get("link");
312
+ const hasNext = link !== null ? /rel="next"/.test(link) : res.data.length >= PER_PAGE;
313
+ if (!hasNext) break;
314
+ }
315
+ return all;
316
+ }
317
+
318
+ private async request<T>(
319
+ method: "GET" | "POST",
320
+ path: string,
321
+ bearer: string,
322
+ context: string,
323
+ body?: unknown,
324
+ ): Promise<{ status: number; headers: Headers; data: T }> {
325
+ let resp: Response;
326
+ try {
327
+ resp = await this.fetchFn(`${this.apiBase}${path}`, {
328
+ method,
329
+ headers: {
330
+ Accept: "application/vnd.github+json",
331
+ Authorization: `Bearer ${bearer}`,
332
+ "X-GitHub-Api-Version": "2022-11-28",
333
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
334
+ },
335
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
336
+ });
337
+ } catch {
338
+ // The underlying error is dropped on purpose: it must never echo headers.
339
+ throw new GithubEnterpriseError("network", `${context}: network error`);
340
+ }
341
+ let data: unknown;
342
+ try {
343
+ data = await resp.json();
344
+ } catch {
345
+ data = undefined;
346
+ }
347
+ if (!resp.ok) {
348
+ throw mapGithubEnterpriseError(resp.status, resp.headers, data, context, [
349
+ bearer,
350
+ this.privateKey,
351
+ ]);
352
+ }
353
+ return { status: resp.status, headers: resp.headers, data: data as T };
354
+ }
355
+ }
356
+
357
+ interface RawInstallation {
358
+ id: number;
359
+ app_id: number;
360
+ app_slug: string;
361
+ client_id?: string;
362
+ account?: { login?: string } | null;
363
+ repository_selection: RepositorySelection;
364
+ }
365
+
366
+ function toInstallation(r: RawInstallation): AppInstallation {
367
+ return {
368
+ id: r.id,
369
+ appId: r.app_id,
370
+ appSlug: r.app_slug,
371
+ ...(r.client_id ? { clientId: r.client_id } : {}),
372
+ accountLogin: r.account?.login ?? "",
373
+ repositorySelection: r.repository_selection,
374
+ };
375
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * admin/src/github-enterprise-client.unit.test.ts
3
+ * Unit tests for GitHub enterprise error mapping and client-side validation.
4
+ */
5
+
6
+ import { describe, expect, it } from "bun:test";
7
+ import {
8
+ GithubEnterpriseClient,
9
+ GithubEnterpriseError,
10
+ type GithubEnterpriseErrorKind,
11
+ mapGithubEnterpriseError,
12
+ } from "./github-enterprise-client.ts";
13
+
14
+ const h = (init?: Record<string, string>) => new Headers(init);
15
+
16
+ describe("mapGithubEnterpriseError", () => {
17
+ const cases: Array<
18
+ [number, Record<string, string>, unknown, GithubEnterpriseErrorKind]
19
+ > = [
20
+ [401, {}, { message: "Bad credentials" }, "unauthorized"],
21
+ [
22
+ 403,
23
+ {},
24
+ { message: "Resource not accessible by integration" },
25
+ "permission",
26
+ ],
27
+ [404, {}, { message: "Not Found" }, "not_found"],
28
+ [422, {}, { message: "Validation Failed" }, "validation"],
29
+ [429, {}, { message: "API rate limit exceeded" }, "rate_limit"],
30
+ [403, { "retry-after": "60" }, { message: "x" }, "rate_limit"],
31
+ [403, { "x-ratelimit-remaining": "0" }, { message: "x" }, "rate_limit"],
32
+ [
33
+ 403,
34
+ {},
35
+ { message: "You have exceeded a secondary rate limit." },
36
+ "rate_limit",
37
+ ],
38
+ [500, {}, { message: "Server Error" }, "server"],
39
+ [503, {}, undefined, "server"],
40
+ [418, {}, undefined, "unexpected"],
41
+ ];
42
+ for (const [status, headers, body, kind] of cases) {
43
+ it(`maps ${status} ${JSON.stringify(headers)} to ${kind}`, () => {
44
+ const e = mapGithubEnterpriseError(status, h(headers), body, "ctx");
45
+ expect(e).toBeInstanceOf(GithubEnterpriseError);
46
+ expect(e.kind).toBe(kind);
47
+ expect(e.status).toBe(status);
48
+ });
49
+ }
50
+
51
+ it("exposes retry-after seconds", () => {
52
+ const e = mapGithubEnterpriseError(
53
+ 429,
54
+ h({ "retry-after": "30" }),
55
+ {},
56
+ "ctx",
57
+ );
58
+ expect(e.retryAfterSeconds).toBe(30);
59
+ });
60
+
61
+ it("redacts secrets echoed in the message", () => {
62
+ const e = mapGithubEnterpriseError(
63
+ 403,
64
+ h(),
65
+ { message: "token ghs_secret123 denied" },
66
+ "ctx",
67
+ ["ghs_secret123"],
68
+ );
69
+ expect(e.message).not.toContain("ghs_secret123");
70
+ });
71
+ });
72
+
73
+ describe("GithubEnterpriseClient validation", () => {
74
+ const neverFetch = (async () => {
75
+ throw new Error("fetch must not be called");
76
+ }) as unknown as typeof fetch;
77
+ const client = new GithubEnterpriseClient({
78
+ appId: "1",
79
+ enterprise: "acme",
80
+ privateKey: "unused",
81
+ fetchFn: neverFetch,
82
+ });
83
+
84
+ it("rejects selected without repositories before any network call", async () => {
85
+ const err = await client
86
+ .installOnOrg("o", { clientId: "Iv1.x", repositorySelection: "selected" })
87
+ .catch((e) => e);
88
+ expect(err.kind).toBe("validation");
89
+ });
90
+
91
+ it("rejects a missing clientId", async () => {
92
+ const err = await client
93
+ .installOnOrg("o", { clientId: "", repositorySelection: "all" })
94
+ .catch((e) => e);
95
+ expect(err.kind).toBe("validation");
96
+ });
97
+ });
@@ -1087,14 +1087,14 @@ export const PutBakedMarketplacesBodySchema = z
1087
1087
  names: z
1088
1088
  .array(z.string().min(1).max(255))
1089
1089
  .max(100)
1090
- .openapi({ example: ["vitals-os", "shipwright"] }),
1090
+ .openapi({ example: ["example-marketplace", "shipwright"] }),
1091
1091
  })
1092
1092
  .strict()
1093
1093
  .openapi("PutBakedMarketplacesBody");
1094
1094
 
1095
1095
  export const BakedMarketplacesResultSchema = z
1096
1096
  .object({
1097
- bakedMarketplaces: z.array(z.string()).openapi({ example: ["vitals-os"] }),
1097
+ bakedMarketplaces: z.array(z.string()).openapi({ example: ["example-marketplace"] }),
1098
1098
  })
1099
1099
  .openapi("BakedMarketplacesResult");
1100
1100