@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.
- package/openapi.json +787 -0
- package/openapi.platform.json +787 -0
- package/package.json +2 -2
- package/src/admin-boot.ts +21 -1
- package/src/admin-spec.ts +19 -0
- package/src/fixtures/github-enterprise-cassette.json +675 -0
- package/src/github-app-api.ts +111 -0
- package/src/github-app-jwt.ts +44 -0
- package/src/github-app-jwt.unit.test.ts +95 -0
- package/src/github-enterprise-api.integration.test.ts +264 -0
- package/src/github-enterprise-api.smoke.test.ts +595 -0
- package/src/github-enterprise-api.ts +750 -0
- package/src/github-enterprise-client.integration.test.ts +318 -0
- package/src/github-enterprise-client.ts +384 -0
- package/src/github-enterprise-client.unit.test.ts +97 -0
- package/src/github-enterprise-isolation.unit.test.ts +84 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/github-enterprise-client.integration.test.ts
|
|
3
|
+
* Integration tests for GithubEnterpriseClient against hand-authored cassettes
|
|
4
|
+
* (fixtures/github-enterprise-cassette.json, derived from GitHub's ghec REST
|
|
5
|
+
* OpenAPI description; nothing was recorded live).
|
|
6
|
+
*
|
|
7
|
+
* Replays through an INJECTED fetchFn: no global.fetch override, no
|
|
8
|
+
* mock.module(). The RSA key is generated in-test.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
14
|
+
import {
|
|
15
|
+
GithubEnterpriseClient,
|
|
16
|
+
type GithubEnterpriseErrorKind,
|
|
17
|
+
} from "./github-enterprise-client.ts";
|
|
18
|
+
|
|
19
|
+
interface CassetteEntry {
|
|
20
|
+
status: number;
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
body: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const cassette: Record<string, CassetteEntry> = JSON.parse(
|
|
26
|
+
readFileSync(
|
|
27
|
+
new URL("./fixtures/github-enterprise-cassette.json", import.meta.url).pathname,
|
|
28
|
+
"utf-8",
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const { privateKey } = generateKeyPairSync("rsa", {
|
|
33
|
+
modulusLength: 2048,
|
|
34
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
35
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const NOW = new Date("2026-11-09T12:00:00Z");
|
|
39
|
+
const API = "https://ghe.test";
|
|
40
|
+
const ENT = "GET /enterprises/acme/installation";
|
|
41
|
+
const MINT = "POST /app/installations/4242/access_tokens";
|
|
42
|
+
const ORGS = "GET /enterprises/acme/apps/installable_organizations";
|
|
43
|
+
const INSTS = "GET /enterprises/acme/apps/organizations/acme-eng/installations";
|
|
44
|
+
const INSTALL = "POST /enterprises/acme/apps/organizations/acme-eng/installations";
|
|
45
|
+
|
|
46
|
+
interface Recorded {
|
|
47
|
+
method: string;
|
|
48
|
+
path: string;
|
|
49
|
+
headers: Headers;
|
|
50
|
+
body?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** routes: "METHOD /path[?page=N]" or "METHOD /path" -> cassette key. */
|
|
54
|
+
function replay(routes: Record<string, string>) {
|
|
55
|
+
const calls: Recorded[] = [];
|
|
56
|
+
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
57
|
+
const u = new URL(input.toString());
|
|
58
|
+
const method = init?.method ?? "GET";
|
|
59
|
+
const page = u.searchParams.get("page");
|
|
60
|
+
const base = `${method} ${u.pathname}`;
|
|
61
|
+
calls.push({
|
|
62
|
+
method,
|
|
63
|
+
path: `${u.pathname}${u.search}`,
|
|
64
|
+
headers: new Headers(init?.headers),
|
|
65
|
+
body: typeof init?.body === "string" ? init.body : undefined,
|
|
66
|
+
});
|
|
67
|
+
const key = (page && routes[`${base}?page=${page}`]) || routes[base];
|
|
68
|
+
if (!key) throw new Error(`no route for ${base} page=${page}`);
|
|
69
|
+
const entry = cassette[key];
|
|
70
|
+
if (!entry) throw new Error(`cassette key not found: ${key}`);
|
|
71
|
+
return new Response(JSON.stringify(entry.body), {
|
|
72
|
+
status: entry.status,
|
|
73
|
+
headers: { "Content-Type": "application/json", ...(entry.headers ?? {}) },
|
|
74
|
+
});
|
|
75
|
+
}) as typeof fetch;
|
|
76
|
+
return { fetchFn, calls };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const OK = { [ENT]: "enterprise_installation_success", [MINT]: "access_token_success" };
|
|
80
|
+
|
|
81
|
+
function makeClient(routes: Record<string, string>) {
|
|
82
|
+
const { fetchFn, calls } = replay({ ...OK, ...routes });
|
|
83
|
+
const client = new GithubEnterpriseClient({
|
|
84
|
+
appId: "12345",
|
|
85
|
+
enterprise: "acme",
|
|
86
|
+
privateKey,
|
|
87
|
+
apiBase: API,
|
|
88
|
+
fetchFn,
|
|
89
|
+
now: () => NOW,
|
|
90
|
+
});
|
|
91
|
+
return { client, calls };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ERRORS: Array<[string, GithubEnterpriseErrorKind, number]> = [
|
|
95
|
+
["403", "permission", 403],
|
|
96
|
+
["403_secondary_rate_limit", "rate_limit", 403],
|
|
97
|
+
["404", "not_found", 404],
|
|
98
|
+
["422", "validation", 422],
|
|
99
|
+
["429", "rate_limit", 429],
|
|
100
|
+
["500", "server", 500],
|
|
101
|
+
["503", "server", 503],
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
async function errOf(p: Promise<unknown>) {
|
|
105
|
+
try {
|
|
106
|
+
await p;
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return e as Error & { kind: GithubEnterpriseErrorKind; status?: number };
|
|
109
|
+
}
|
|
110
|
+
throw new Error("expected rejection");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function expectNoSecrets(e: Error) {
|
|
114
|
+
expect(e.message).not.toContain("PRIVATE KEY");
|
|
115
|
+
expect(e.message).not.toContain("ghs_cassette");
|
|
116
|
+
expect(e.message).not.toContain("Bearer");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe("getInstallationToken", () => {
|
|
120
|
+
it("looks up the enterprise installation with a JWT then mints a token", async () => {
|
|
121
|
+
const { client, calls } = makeClient({});
|
|
122
|
+
const t = await client.getInstallationToken();
|
|
123
|
+
expect(t).toEqual({
|
|
124
|
+
token: "ghs_cassetteInstallationToken0000000000",
|
|
125
|
+
expiresAt: "2026-11-09T13:00:00Z",
|
|
126
|
+
installationId: 4242,
|
|
127
|
+
permissions: { enterprise_organization_installations: "write", metadata: "read" },
|
|
128
|
+
});
|
|
129
|
+
expect(calls.map((c) => `${c.method} ${c.path}`)).toEqual([ENT, MINT]);
|
|
130
|
+
for (const c of calls) {
|
|
131
|
+
expect(c.headers.get("authorization")).toMatch(/^Bearer [\w-]+\.[\w-]+\.[\w-]+$/);
|
|
132
|
+
expect(c.headers.get("accept")).toBe("application/vnd.github+json");
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("caches the token until near expiry", async () => {
|
|
137
|
+
const { client, calls } = makeClient({});
|
|
138
|
+
await client.getInstallationToken();
|
|
139
|
+
await client.getInstallationToken();
|
|
140
|
+
expect(calls).toHaveLength(2);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
for (const [suffix, kind, status] of ERRORS) {
|
|
144
|
+
it(`lookup ${suffix} -> ${kind}`, async () => {
|
|
145
|
+
const { client } = makeClient({ [ENT]: `enterprise_installation_${suffix}` });
|
|
146
|
+
const e = await errOf(client.getInstallationToken());
|
|
147
|
+
expect(e.kind).toBe(kind);
|
|
148
|
+
expect(e.status).toBe(status);
|
|
149
|
+
expectNoSecrets(e);
|
|
150
|
+
});
|
|
151
|
+
it(`mint ${suffix} -> ${kind}`, async () => {
|
|
152
|
+
const { client } = makeClient({ [MINT]: `access_token_${suffix}` });
|
|
153
|
+
const e = await errOf(client.getInstallationToken());
|
|
154
|
+
expect(e.kind).toBe(kind);
|
|
155
|
+
expect(e.status).toBe(status);
|
|
156
|
+
expectNoSecrets(e);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
it("maps a thrown fetch to a network error without echoing the cause", async () => {
|
|
161
|
+
const client = new GithubEnterpriseClient({
|
|
162
|
+
appId: "1",
|
|
163
|
+
enterprise: "acme",
|
|
164
|
+
privateKey,
|
|
165
|
+
apiBase: API,
|
|
166
|
+
fetchFn: (async () => {
|
|
167
|
+
throw new Error("connect ECONNREFUSED Bearer abc");
|
|
168
|
+
}) as unknown as typeof fetch,
|
|
169
|
+
});
|
|
170
|
+
const e = await errOf(client.getInstallationToken());
|
|
171
|
+
expect(e.kind).toBe("network");
|
|
172
|
+
expect(e.message).not.toContain("abc");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("listInstallableOrganizations", () => {
|
|
177
|
+
it("returns orgs using the installation token", async () => {
|
|
178
|
+
const { client, calls } = makeClient({ [ORGS]: "installable_orgs_success" });
|
|
179
|
+
expect(await client.listInstallableOrganizations()).toEqual([
|
|
180
|
+
{ id: 1, login: "org-1" },
|
|
181
|
+
{ id: 2, login: "org-2" },
|
|
182
|
+
]);
|
|
183
|
+
const last = calls[calls.length - 1] as Recorded;
|
|
184
|
+
expect(last.path).toBe(
|
|
185
|
+
"/enterprises/acme/apps/installable_organizations?per_page=100&page=1",
|
|
186
|
+
);
|
|
187
|
+
expect(last.headers.get("authorization")).toBe(
|
|
188
|
+
"Bearer ghs_cassetteInstallationToken0000000000",
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("follows Link rel=next across pages", async () => {
|
|
193
|
+
const { client, calls } = makeClient({
|
|
194
|
+
[`${ORGS}?page=1`]: "installable_orgs_page_1",
|
|
195
|
+
[`${ORGS}?page=2`]: "installable_orgs_page_2",
|
|
196
|
+
});
|
|
197
|
+
const orgs = await client.listInstallableOrganizations();
|
|
198
|
+
expect(orgs.map((o) => o.login)).toEqual(["org-1", "org-2", "org-3"]);
|
|
199
|
+
expect(calls.filter((c) => c.path.includes("installable_organizations"))).toHaveLength(2);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("returns an empty list", async () => {
|
|
203
|
+
const { client } = makeClient({ [ORGS]: "installable_orgs_empty" });
|
|
204
|
+
expect(await client.listInstallableOrganizations()).toEqual([]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
for (const [suffix, kind, status] of ERRORS) {
|
|
208
|
+
it(`${suffix} -> ${kind}`, async () => {
|
|
209
|
+
const { client } = makeClient({ [ORGS]: `installable_orgs_${suffix}` });
|
|
210
|
+
const e = await errOf(client.listInstallableOrganizations());
|
|
211
|
+
expect(e.kind).toBe(kind);
|
|
212
|
+
expect(e.status).toBe(status);
|
|
213
|
+
expectNoSecrets(e);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("listOrgInstallations", () => {
|
|
219
|
+
it("maps installations", async () => {
|
|
220
|
+
const { client } = makeClient({ [INSTS]: "org_installations_success" });
|
|
221
|
+
const list = await client.listOrgInstallations("acme-eng");
|
|
222
|
+
expect(list).toHaveLength(2);
|
|
223
|
+
expect(list[0]).toEqual({
|
|
224
|
+
id: 501,
|
|
225
|
+
appId: 777,
|
|
226
|
+
appSlug: "target-app",
|
|
227
|
+
clientId: "Iv1.targetapp0001",
|
|
228
|
+
accountLogin: "acme-eng",
|
|
229
|
+
repositorySelection: "all",
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("paginates", async () => {
|
|
234
|
+
const { client } = makeClient({
|
|
235
|
+
[`${INSTS}?page=1`]: "org_installations_page_1",
|
|
236
|
+
[`${INSTS}?page=2`]: "org_installations_page_2",
|
|
237
|
+
});
|
|
238
|
+
const list = await client.listOrgInstallations("acme-eng");
|
|
239
|
+
expect(list.map((i) => i.id)).toEqual([501, 502]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
for (const [suffix, kind, status] of ERRORS) {
|
|
243
|
+
it(`${suffix} -> ${kind}`, async () => {
|
|
244
|
+
const { client } = makeClient({ [INSTS]: `org_installations_${suffix}` });
|
|
245
|
+
const e = await errOf(client.listOrgInstallations("acme-eng"));
|
|
246
|
+
expect(e.kind).toBe(kind);
|
|
247
|
+
expect(e.status).toBe(status);
|
|
248
|
+
expectNoSecrets(e);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("installOnOrg", () => {
|
|
254
|
+
it("posts client_id and repository_selection; 201 means created", async () => {
|
|
255
|
+
const { client, calls } = makeClient({ [INSTALL]: "install_created" });
|
|
256
|
+
const r = await client.installOnOrg("acme-eng", {
|
|
257
|
+
clientId: "Iv1.targetapp0001",
|
|
258
|
+
repositorySelection: "all",
|
|
259
|
+
});
|
|
260
|
+
expect(r.created).toBe(true);
|
|
261
|
+
expect(r.installation.id).toBe(601);
|
|
262
|
+
const last = calls[calls.length - 1] as Recorded;
|
|
263
|
+
expect(last.method).toBe("POST");
|
|
264
|
+
expect(JSON.parse(last.body ?? "")).toEqual({
|
|
265
|
+
client_id: "Iv1.targetapp0001",
|
|
266
|
+
repository_selection: "all",
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("200 means already installed", async () => {
|
|
271
|
+
const { client } = makeClient({ [INSTALL]: "install_already_installed" });
|
|
272
|
+
const r = await client.installOnOrg("acme-eng", {
|
|
273
|
+
clientId: "Iv1.targetapp0001",
|
|
274
|
+
repositorySelection: "all",
|
|
275
|
+
});
|
|
276
|
+
expect(r.created).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("sends repositories for selected", async () => {
|
|
280
|
+
const { client, calls } = makeClient({ [INSTALL]: "install_selected_created" });
|
|
281
|
+
const r = await client.installOnOrg("acme-eng", {
|
|
282
|
+
clientId: "Iv1.targetapp0001",
|
|
283
|
+
repositorySelection: "selected",
|
|
284
|
+
repositories: ["hello-world"],
|
|
285
|
+
});
|
|
286
|
+
expect(r.installation.repositorySelection).toBe("selected");
|
|
287
|
+
expect(JSON.parse((calls[calls.length - 1] as Recorded).body ?? "")).toEqual({
|
|
288
|
+
client_id: "Iv1.targetapp0001",
|
|
289
|
+
repository_selection: "selected",
|
|
290
|
+
repositories: ["hello-world"],
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("sends none without repositories", async () => {
|
|
295
|
+
const { client, calls } = makeClient({ [INSTALL]: "install_created" });
|
|
296
|
+
await client.installOnOrg("acme-eng", {
|
|
297
|
+
clientId: "Iv1.x",
|
|
298
|
+
repositorySelection: "none",
|
|
299
|
+
repositories: ["ignored"],
|
|
300
|
+
});
|
|
301
|
+
expect(JSON.parse((calls[calls.length - 1] as Recorded).body ?? "")).toEqual({
|
|
302
|
+
client_id: "Iv1.x",
|
|
303
|
+
repository_selection: "none",
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
for (const [suffix, kind, status] of ERRORS) {
|
|
308
|
+
it(`${suffix} -> ${kind}`, async () => {
|
|
309
|
+
const { client } = makeClient({ [INSTALL]: `install_${suffix}` });
|
|
310
|
+
const e = await errOf(
|
|
311
|
+
client.installOnOrg("acme-eng", { clientId: "Iv1.x", repositorySelection: "all" }),
|
|
312
|
+
);
|
|
313
|
+
expect(e.kind).toBe(kind);
|
|
314
|
+
expect(e.status).toBe(status);
|
|
315
|
+
expectNoSecrets(e);
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
});
|
|
@@ -0,0 +1,384 @@
|
|
|
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
|
+
/** Permissions granted to the minted token, as reported by GitHub (when present). */
|
|
161
|
+
permissions?: Record<string, string>;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface GithubEnterpriseClientOptions {
|
|
165
|
+
appId: string;
|
|
166
|
+
enterprise: string;
|
|
167
|
+
privateKey: string;
|
|
168
|
+
apiBase?: string;
|
|
169
|
+
fetchFn?: typeof fetch;
|
|
170
|
+
now?: () => Date;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const PER_PAGE = 100;
|
|
174
|
+
const MAX_PAGES = 100;
|
|
175
|
+
const MAX_REPOSITORIES = 50;
|
|
176
|
+
/** Refresh a cached installation token this long before it expires. */
|
|
177
|
+
const TOKEN_REFRESH_SKEW_MS = 60_000;
|
|
178
|
+
|
|
179
|
+
// ─── Client ───────────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
export class GithubEnterpriseClient {
|
|
182
|
+
private readonly appId: string;
|
|
183
|
+
private readonly enterprise: string;
|
|
184
|
+
private readonly privateKey: string;
|
|
185
|
+
private readonly apiBase: string;
|
|
186
|
+
private readonly fetchFn: typeof fetch;
|
|
187
|
+
private readonly now: () => Date;
|
|
188
|
+
private cached?: EnterpriseInstallationToken;
|
|
189
|
+
|
|
190
|
+
constructor(opts: GithubEnterpriseClientOptions) {
|
|
191
|
+
this.appId = opts.appId;
|
|
192
|
+
this.enterprise = opts.enterprise;
|
|
193
|
+
this.privateKey = opts.privateKey;
|
|
194
|
+
this.apiBase = opts.apiBase ?? "https://api.github.com";
|
|
195
|
+
this.fetchFn = opts.fetchFn ?? fetch;
|
|
196
|
+
this.now = opts.now ?? (() => new Date());
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Mint (or reuse a still-fresh) enterprise installation token. */
|
|
200
|
+
async getInstallationToken(): Promise<EnterpriseInstallationToken> {
|
|
201
|
+
if (
|
|
202
|
+
this.cached &&
|
|
203
|
+
Date.parse(this.cached.expiresAt) - this.now().getTime() > TOKEN_REFRESH_SKEW_MS
|
|
204
|
+
) {
|
|
205
|
+
return this.cached;
|
|
206
|
+
}
|
|
207
|
+
const jwt = signGithubAppJwt({
|
|
208
|
+
appId: this.appId,
|
|
209
|
+
privateKey: this.privateKey,
|
|
210
|
+
now: this.now(),
|
|
211
|
+
});
|
|
212
|
+
const ent = encodeURIComponent(this.enterprise);
|
|
213
|
+
const inst = await this.request<{ id?: number }>(
|
|
214
|
+
"GET",
|
|
215
|
+
`/enterprises/${ent}/installation`,
|
|
216
|
+
jwt,
|
|
217
|
+
"GitHub enterprise installation lookup",
|
|
218
|
+
);
|
|
219
|
+
if (typeof inst.data?.id !== "number") {
|
|
220
|
+
throw new GithubEnterpriseError(
|
|
221
|
+
"unexpected",
|
|
222
|
+
"GitHub enterprise installation lookup: response missing id",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
const minted = await this.request<{
|
|
226
|
+
token?: string;
|
|
227
|
+
expires_at?: string;
|
|
228
|
+
permissions?: Record<string, string>;
|
|
229
|
+
}>(
|
|
230
|
+
"POST",
|
|
231
|
+
`/app/installations/${inst.data.id}/access_tokens`,
|
|
232
|
+
jwt,
|
|
233
|
+
"GitHub installation access token",
|
|
234
|
+
);
|
|
235
|
+
if (!minted.data?.token || !minted.data.expires_at) {
|
|
236
|
+
throw new GithubEnterpriseError(
|
|
237
|
+
"unexpected",
|
|
238
|
+
"GitHub installation access token: response missing token or expires_at",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
this.cached = {
|
|
242
|
+
token: minted.data.token,
|
|
243
|
+
expiresAt: minted.data.expires_at,
|
|
244
|
+
installationId: inst.data.id,
|
|
245
|
+
...(minted.data.permissions && typeof minted.data.permissions === "object"
|
|
246
|
+
? { permissions: minted.data.permissions }
|
|
247
|
+
: {}),
|
|
248
|
+
};
|
|
249
|
+
return this.cached;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** List all organizations the installer App can install Apps on (paginated). */
|
|
253
|
+
async listInstallableOrganizations(): Promise<InstallableOrganization[]> {
|
|
254
|
+
const ent = encodeURIComponent(this.enterprise);
|
|
255
|
+
const rows = await this.paginate<{ id: number; login: string }>(
|
|
256
|
+
`/enterprises/${ent}/apps/installable_organizations`,
|
|
257
|
+
"GitHub installable organizations",
|
|
258
|
+
);
|
|
259
|
+
return rows.map((r) => ({ id: r.id, login: r.login }));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** List all App installations on an enterprise-owned org (paginated). */
|
|
263
|
+
async listOrgInstallations(org: string): Promise<AppInstallation[]> {
|
|
264
|
+
const ent = encodeURIComponent(this.enterprise);
|
|
265
|
+
const rows = await this.paginate<RawInstallation>(
|
|
266
|
+
`/enterprises/${ent}/apps/organizations/${encodeURIComponent(org)}/installations`,
|
|
267
|
+
"GitHub org installations",
|
|
268
|
+
);
|
|
269
|
+
return rows.map(toInstallation);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Install an App on an enterprise-owned org. */
|
|
273
|
+
async installOnOrg(org: string, input: InstallOnOrgInput): Promise<InstallOnOrgResult> {
|
|
274
|
+
if (!input.clientId) {
|
|
275
|
+
throw new GithubEnterpriseError("validation", "installOnOrg: clientId is required");
|
|
276
|
+
}
|
|
277
|
+
const repos = input.repositories ?? [];
|
|
278
|
+
if (input.repositorySelection === "selected" && (repos.length === 0 || repos.length > MAX_REPOSITORIES)) {
|
|
279
|
+
throw new GithubEnterpriseError(
|
|
280
|
+
"validation",
|
|
281
|
+
`installOnOrg: repositories must list 1-${MAX_REPOSITORIES} names when repositorySelection is "selected"`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
const body: Record<string, unknown> = {
|
|
285
|
+
client_id: input.clientId,
|
|
286
|
+
repository_selection: input.repositorySelection,
|
|
287
|
+
};
|
|
288
|
+
if (input.repositorySelection === "selected") body.repositories = repos;
|
|
289
|
+
const { token } = await this.getInstallationToken();
|
|
290
|
+
const ent = encodeURIComponent(this.enterprise);
|
|
291
|
+
const res = await this.request<RawInstallation>(
|
|
292
|
+
"POST",
|
|
293
|
+
`/enterprises/${ent}/apps/organizations/${encodeURIComponent(org)}/installations`,
|
|
294
|
+
token,
|
|
295
|
+
"GitHub install App on org",
|
|
296
|
+
body,
|
|
297
|
+
);
|
|
298
|
+
if (typeof res.data?.id !== "number") {
|
|
299
|
+
throw new GithubEnterpriseError("unexpected", "GitHub install App on org: response missing id");
|
|
300
|
+
}
|
|
301
|
+
return { installation: toInstallation(res.data), created: res.status === 201 };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ─── internals ──────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
private async paginate<T>(path: string, context: string): Promise<T[]> {
|
|
307
|
+
const { token } = await this.getInstallationToken();
|
|
308
|
+
const all: T[] = [];
|
|
309
|
+
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
310
|
+
const res = await this.request<T[]>(
|
|
311
|
+
"GET",
|
|
312
|
+
`${path}?per_page=${PER_PAGE}&page=${page}`,
|
|
313
|
+
token,
|
|
314
|
+
context,
|
|
315
|
+
);
|
|
316
|
+
if (!Array.isArray(res.data)) {
|
|
317
|
+
throw new GithubEnterpriseError("unexpected", `${context}: response is not a list`);
|
|
318
|
+
}
|
|
319
|
+
all.push(...res.data);
|
|
320
|
+
const link = res.headers.get("link");
|
|
321
|
+
const hasNext = link !== null ? /rel="next"/.test(link) : res.data.length >= PER_PAGE;
|
|
322
|
+
if (!hasNext) break;
|
|
323
|
+
}
|
|
324
|
+
return all;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private async request<T>(
|
|
328
|
+
method: "GET" | "POST",
|
|
329
|
+
path: string,
|
|
330
|
+
bearer: string,
|
|
331
|
+
context: string,
|
|
332
|
+
body?: unknown,
|
|
333
|
+
): Promise<{ status: number; headers: Headers; data: T }> {
|
|
334
|
+
let resp: Response;
|
|
335
|
+
try {
|
|
336
|
+
resp = await this.fetchFn(`${this.apiBase}${path}`, {
|
|
337
|
+
method,
|
|
338
|
+
headers: {
|
|
339
|
+
Accept: "application/vnd.github+json",
|
|
340
|
+
Authorization: `Bearer ${bearer}`,
|
|
341
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
342
|
+
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
343
|
+
},
|
|
344
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
345
|
+
});
|
|
346
|
+
} catch {
|
|
347
|
+
// The underlying error is dropped on purpose: it must never echo headers.
|
|
348
|
+
throw new GithubEnterpriseError("network", `${context}: network error`);
|
|
349
|
+
}
|
|
350
|
+
let data: unknown;
|
|
351
|
+
try {
|
|
352
|
+
data = await resp.json();
|
|
353
|
+
} catch {
|
|
354
|
+
data = undefined;
|
|
355
|
+
}
|
|
356
|
+
if (!resp.ok) {
|
|
357
|
+
throw mapGithubEnterpriseError(resp.status, resp.headers, data, context, [
|
|
358
|
+
bearer,
|
|
359
|
+
this.privateKey,
|
|
360
|
+
]);
|
|
361
|
+
}
|
|
362
|
+
return { status: resp.status, headers: resp.headers, data: data as T };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
interface RawInstallation {
|
|
367
|
+
id: number;
|
|
368
|
+
app_id: number;
|
|
369
|
+
app_slug: string;
|
|
370
|
+
client_id?: string;
|
|
371
|
+
account?: { login?: string } | null;
|
|
372
|
+
repository_selection: RepositorySelection;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function toInstallation(r: RawInstallation): AppInstallation {
|
|
376
|
+
return {
|
|
377
|
+
id: r.id,
|
|
378
|
+
appId: r.app_id,
|
|
379
|
+
appSlug: r.app_slug,
|
|
380
|
+
...(r.client_id ? { clientId: r.client_id } : {}),
|
|
381
|
+
accountLogin: r.account?.login ?? "",
|
|
382
|
+
repositorySelection: r.repository_selection,
|
|
383
|
+
};
|
|
384
|
+
}
|