@app-vitals/agent-admin 1.16.0 → 1.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-admin",
3
- "version": "1.16.0",
3
+ "version": "1.17.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "@anthropic-ai/sdk": "0.135.0",
45
- "@app-vitals/agent-lib": "1.16.0",
45
+ "@app-vitals/agent-lib": "1.17.0",
46
46
  "@hono/zod-openapi": "^1.0.0",
47
47
  "@prisma/adapter-pg": "^7.10.0",
48
48
  "@prisma/client": "^7.10.0",
@@ -0,0 +1,499 @@
1
+ {
2
+ "org_installation_success": {
3
+ "status": 200,
4
+ "body": {
5
+ "id": 7001,
6
+ "app_id": 555,
7
+ "app_slug": "example-lookup",
8
+ "target_type": "Organization",
9
+ "account": {
10
+ "login": "acme-eng",
11
+ "id": 11,
12
+ "type": "Organization"
13
+ },
14
+ "repository_selection": "all",
15
+ "permissions": {
16
+ "metadata": "read"
17
+ }
18
+ }
19
+ },
20
+ "org_installation_403": {
21
+ "status": 403,
22
+ "body": {
23
+ "message": "Resource not accessible by integration",
24
+ "documentation_url": "https://docs.github.com/rest"
25
+ }
26
+ },
27
+ "org_installation_404": {
28
+ "status": 404,
29
+ "body": {
30
+ "message": "Not Found",
31
+ "documentation_url": "https://docs.github.com/rest"
32
+ }
33
+ },
34
+ "org_installation_422": {
35
+ "status": 422,
36
+ "body": {
37
+ "message": "Validation Failed",
38
+ "documentation_url": "https://docs.github.com/rest"
39
+ }
40
+ },
41
+ "org_installation_429": {
42
+ "status": 429,
43
+ "headers": {
44
+ "retry-after": "30"
45
+ },
46
+ "body": {
47
+ "message": "API rate limit exceeded",
48
+ "documentation_url": "https://docs.github.com/rest"
49
+ }
50
+ },
51
+ "org_installation_500": {
52
+ "status": 500,
53
+ "body": {
54
+ "message": "Server Error",
55
+ "documentation_url": "https://docs.github.com/rest"
56
+ }
57
+ },
58
+ "org_installation_503": {
59
+ "status": 503,
60
+ "body": {
61
+ "message": "Service Unavailable",
62
+ "documentation_url": "https://docs.github.com/rest"
63
+ }
64
+ },
65
+ "lookup_token_success": {
66
+ "status": 201,
67
+ "body": {
68
+ "token": "ghs_lookupInstallationTokenSecret",
69
+ "expires_at": "2026-11-09T13:00:00Z",
70
+ "permissions": {
71
+ "metadata": "read"
72
+ },
73
+ "repository_selection": "all"
74
+ }
75
+ },
76
+ "lookup_token_403": {
77
+ "status": 403,
78
+ "body": {
79
+ "message": "Resource not accessible by integration",
80
+ "documentation_url": "https://docs.github.com/rest"
81
+ }
82
+ },
83
+ "lookup_token_404": {
84
+ "status": 404,
85
+ "body": {
86
+ "message": "Not Found",
87
+ "documentation_url": "https://docs.github.com/rest"
88
+ }
89
+ },
90
+ "lookup_token_422": {
91
+ "status": 422,
92
+ "body": {
93
+ "message": "Validation Failed",
94
+ "documentation_url": "https://docs.github.com/rest"
95
+ }
96
+ },
97
+ "lookup_token_429": {
98
+ "status": 429,
99
+ "headers": {
100
+ "retry-after": "30"
101
+ },
102
+ "body": {
103
+ "message": "API rate limit exceeded",
104
+ "documentation_url": "https://docs.github.com/rest"
105
+ }
106
+ },
107
+ "lookup_token_500": {
108
+ "status": 500,
109
+ "body": {
110
+ "message": "Server Error",
111
+ "documentation_url": "https://docs.github.com/rest"
112
+ }
113
+ },
114
+ "lookup_token_503": {
115
+ "status": 503,
116
+ "body": {
117
+ "message": "Service Unavailable",
118
+ "documentation_url": "https://docs.github.com/rest"
119
+ }
120
+ },
121
+ "repos_success": {
122
+ "status": 200,
123
+ "body": {
124
+ "total_count": 2,
125
+ "repositories": [
126
+ {
127
+ "id": 1,
128
+ "name": "api",
129
+ "full_name": "acme-eng/api",
130
+ "private": false,
131
+ "archived": false
132
+ },
133
+ {
134
+ "id": 2,
135
+ "name": "web",
136
+ "full_name": "acme-eng/web",
137
+ "private": true,
138
+ "archived": false
139
+ }
140
+ ]
141
+ }
142
+ },
143
+ "repos_page_1": {
144
+ "status": 200,
145
+ "headers": {
146
+ "link": "<https://ghe.test/installation/repositories?per_page=100&page=2>; rel=\"next\""
147
+ },
148
+ "body": {
149
+ "total_count": 3,
150
+ "repositories": [
151
+ {
152
+ "id": 1,
153
+ "name": "api",
154
+ "full_name": "acme-eng/api",
155
+ "private": false,
156
+ "archived": false
157
+ },
158
+ {
159
+ "id": 2,
160
+ "name": "web",
161
+ "full_name": "acme-eng/web",
162
+ "private": true,
163
+ "archived": false
164
+ }
165
+ ]
166
+ }
167
+ },
168
+ "repos_page_2": {
169
+ "status": 200,
170
+ "body": {
171
+ "total_count": 3,
172
+ "repositories": [
173
+ {
174
+ "id": 3,
175
+ "name": "infra",
176
+ "full_name": "acme-eng/infra",
177
+ "private": false,
178
+ "archived": false
179
+ }
180
+ ]
181
+ }
182
+ },
183
+ "repos_empty": {
184
+ "status": 200,
185
+ "body": {
186
+ "total_count": 0,
187
+ "repositories": []
188
+ }
189
+ },
190
+ "repos_403": {
191
+ "status": 403,
192
+ "body": {
193
+ "message": "Resource not accessible by integration",
194
+ "documentation_url": "https://docs.github.com/rest"
195
+ }
196
+ },
197
+ "repos_404": {
198
+ "status": 404,
199
+ "body": {
200
+ "message": "Not Found",
201
+ "documentation_url": "https://docs.github.com/rest"
202
+ }
203
+ },
204
+ "repos_422": {
205
+ "status": 422,
206
+ "body": {
207
+ "message": "Validation Failed",
208
+ "documentation_url": "https://docs.github.com/rest"
209
+ }
210
+ },
211
+ "repos_429": {
212
+ "status": 429,
213
+ "headers": {
214
+ "retry-after": "30"
215
+ },
216
+ "body": {
217
+ "message": "API rate limit exceeded",
218
+ "documentation_url": "https://docs.github.com/rest"
219
+ }
220
+ },
221
+ "repos_500": {
222
+ "status": 500,
223
+ "body": {
224
+ "message": "Server Error",
225
+ "documentation_url": "https://docs.github.com/rest"
226
+ }
227
+ },
228
+ "repos_503": {
229
+ "status": 503,
230
+ "body": {
231
+ "message": "Service Unavailable",
232
+ "documentation_url": "https://docs.github.com/rest"
233
+ }
234
+ },
235
+ "permission_admin": {
236
+ "status": 200,
237
+ "body": {
238
+ "permission": "admin",
239
+ "role_name": "admin",
240
+ "user": {
241
+ "login": "octocat",
242
+ "id": 583231,
243
+ "type": "User"
244
+ }
245
+ }
246
+ },
247
+ "permission_maintain": {
248
+ "status": 200,
249
+ "body": {
250
+ "permission": "write",
251
+ "role_name": "maintain",
252
+ "user": {
253
+ "login": "octocat",
254
+ "id": 583231,
255
+ "type": "User"
256
+ }
257
+ }
258
+ },
259
+ "permission_write": {
260
+ "status": 200,
261
+ "body": {
262
+ "permission": "write",
263
+ "role_name": "write",
264
+ "user": {
265
+ "login": "octocat",
266
+ "id": 583231,
267
+ "type": "User"
268
+ }
269
+ }
270
+ },
271
+ "permission_triage": {
272
+ "status": 200,
273
+ "body": {
274
+ "permission": "read",
275
+ "role_name": "triage",
276
+ "user": {
277
+ "login": "octocat",
278
+ "id": 583231,
279
+ "type": "User"
280
+ }
281
+ }
282
+ },
283
+ "permission_read": {
284
+ "status": 200,
285
+ "body": {
286
+ "permission": "read",
287
+ "role_name": "read",
288
+ "user": {
289
+ "login": "octocat",
290
+ "id": 583231,
291
+ "type": "User"
292
+ }
293
+ }
294
+ },
295
+ "permission_none": {
296
+ "status": 200,
297
+ "body": {
298
+ "permission": "none",
299
+ "role_name": "none",
300
+ "user": {
301
+ "login": "octocat",
302
+ "id": 583231,
303
+ "type": "User"
304
+ }
305
+ }
306
+ },
307
+ "permission_legacy_only": {
308
+ "status": 200,
309
+ "body": {
310
+ "permission": "write",
311
+ "user": {
312
+ "login": "octocat",
313
+ "id": 583231,
314
+ "type": "User"
315
+ }
316
+ }
317
+ },
318
+ "permission_unknown_role": {
319
+ "status": 200,
320
+ "body": {
321
+ "permission": "write",
322
+ "role_name": "custom-release-manager",
323
+ "user": {
324
+ "login": "octocat",
325
+ "id": 583231,
326
+ "type": "User"
327
+ }
328
+ }
329
+ },
330
+ "permission_403": {
331
+ "status": 403,
332
+ "body": {
333
+ "message": "Resource not accessible by integration",
334
+ "documentation_url": "https://docs.github.com/rest"
335
+ }
336
+ },
337
+ "permission_404": {
338
+ "status": 404,
339
+ "body": {
340
+ "message": "Not Found",
341
+ "documentation_url": "https://docs.github.com/rest"
342
+ }
343
+ },
344
+ "permission_422": {
345
+ "status": 422,
346
+ "body": {
347
+ "message": "Validation Failed",
348
+ "documentation_url": "https://docs.github.com/rest"
349
+ }
350
+ },
351
+ "permission_429": {
352
+ "status": 429,
353
+ "headers": {
354
+ "retry-after": "30"
355
+ },
356
+ "body": {
357
+ "message": "API rate limit exceeded",
358
+ "documentation_url": "https://docs.github.com/rest"
359
+ }
360
+ },
361
+ "permission_500": {
362
+ "status": 500,
363
+ "body": {
364
+ "message": "Server Error",
365
+ "documentation_url": "https://docs.github.com/rest"
366
+ }
367
+ },
368
+ "permission_503": {
369
+ "status": 503,
370
+ "body": {
371
+ "message": "Service Unavailable",
372
+ "documentation_url": "https://docs.github.com/rest"
373
+ }
374
+ },
375
+ "user_success": {
376
+ "status": 200,
377
+ "body": {
378
+ "login": "octocat",
379
+ "id": 583231,
380
+ "type": "User",
381
+ "name": "The Octocat"
382
+ }
383
+ },
384
+ "user_nonexistent": {
385
+ "status": 404,
386
+ "body": {
387
+ "message": "Not Found",
388
+ "documentation_url": "https://docs.github.com/rest"
389
+ }
390
+ },
391
+ "user_403": {
392
+ "status": 403,
393
+ "body": {
394
+ "message": "Resource not accessible by integration",
395
+ "documentation_url": "https://docs.github.com/rest"
396
+ }
397
+ },
398
+ "user_404": {
399
+ "status": 404,
400
+ "body": {
401
+ "message": "Not Found",
402
+ "documentation_url": "https://docs.github.com/rest"
403
+ }
404
+ },
405
+ "user_422": {
406
+ "status": 422,
407
+ "body": {
408
+ "message": "Validation Failed",
409
+ "documentation_url": "https://docs.github.com/rest"
410
+ }
411
+ },
412
+ "user_429": {
413
+ "status": 429,
414
+ "headers": {
415
+ "retry-after": "30"
416
+ },
417
+ "body": {
418
+ "message": "API rate limit exceeded",
419
+ "documentation_url": "https://docs.github.com/rest"
420
+ }
421
+ },
422
+ "user_500": {
423
+ "status": 500,
424
+ "body": {
425
+ "message": "Server Error",
426
+ "documentation_url": "https://docs.github.com/rest"
427
+ }
428
+ },
429
+ "user_503": {
430
+ "status": 503,
431
+ "body": {
432
+ "message": "Service Unavailable",
433
+ "documentation_url": "https://docs.github.com/rest"
434
+ }
435
+ },
436
+ "member_yes": {
437
+ "status": 204,
438
+ "body": null
439
+ },
440
+ "member_no": {
441
+ "status": 404,
442
+ "body": {
443
+ "message": "Not Found",
444
+ "documentation_url": "https://docs.github.com/rest"
445
+ }
446
+ },
447
+ "member_redirect": {
448
+ "status": 302,
449
+ "headers": {
450
+ "location": "https://api.github.com/orgs/acme-eng/public_members/octocat"
451
+ },
452
+ "body": null
453
+ },
454
+ "member_403": {
455
+ "status": 403,
456
+ "body": {
457
+ "message": "Resource not accessible by integration",
458
+ "documentation_url": "https://docs.github.com/rest"
459
+ }
460
+ },
461
+ "member_404": {
462
+ "status": 404,
463
+ "body": {
464
+ "message": "Not Found",
465
+ "documentation_url": "https://docs.github.com/rest"
466
+ }
467
+ },
468
+ "member_422": {
469
+ "status": 422,
470
+ "body": {
471
+ "message": "Validation Failed",
472
+ "documentation_url": "https://docs.github.com/rest"
473
+ }
474
+ },
475
+ "member_429": {
476
+ "status": 429,
477
+ "headers": {
478
+ "retry-after": "30"
479
+ },
480
+ "body": {
481
+ "message": "API rate limit exceeded",
482
+ "documentation_url": "https://docs.github.com/rest"
483
+ }
484
+ },
485
+ "member_500": {
486
+ "status": 500,
487
+ "body": {
488
+ "message": "Server Error",
489
+ "documentation_url": "https://docs.github.com/rest"
490
+ }
491
+ },
492
+ "member_503": {
493
+ "status": 503,
494
+ "body": {
495
+ "message": "Service Unavailable",
496
+ "documentation_url": "https://docs.github.com/rest"
497
+ }
498
+ }
499
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * admin/src/github-lookup-client.integration.test.ts
3
+ * Integration tests for GithubLookupClient against hand-authored cassettes
4
+ * (fixtures/github-lookup-cassette.json, derived from GitHub's REST OpenAPI
5
+ * 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 { generateKeyPairSync } from "node:crypto";
13
+ import { readFileSync } from "node:fs";
14
+ import type { GithubEnterpriseErrorKind } from "./github-enterprise-client.ts";
15
+ import { GithubLookupClient, type RepoRole } from "./github-lookup-client.ts";
16
+
17
+ interface CassetteEntry {
18
+ status: number;
19
+ headers?: Record<string, string>;
20
+ body: unknown;
21
+ }
22
+
23
+ const cassette: Record<string, CassetteEntry> = JSON.parse(
24
+ readFileSync(
25
+ new URL("./fixtures/github-lookup-cassette.json", import.meta.url).pathname,
26
+ "utf-8",
27
+ ),
28
+ );
29
+
30
+ const { privateKey } = generateKeyPairSync("rsa", {
31
+ modulusLength: 2048,
32
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
33
+ publicKeyEncoding: { type: "spki", format: "pem" },
34
+ });
35
+
36
+ const NOW = new Date("2026-11-09T12:00:00Z");
37
+ const API = "https://ghe.test";
38
+ const INST = "GET /orgs/acme-eng/installation";
39
+ const MINT = "POST /app/installations/7001/access_tokens";
40
+ const REPOS = "GET /installation/repositories";
41
+ const PERM = "GET /repos/acme-eng/api/collaborators/octocat/permission";
42
+ const USER = "GET /users/octocat";
43
+ const MEMBER = "GET /orgs/acme-eng/members/octocat";
44
+
45
+ interface Recorded {
46
+ method: string;
47
+ path: string;
48
+ headers: Headers;
49
+ }
50
+
51
+ /** routes: "METHOD /path[?page=N]" or "METHOD /path" -> cassette key. */
52
+ function replay(routes: Record<string, string>) {
53
+ const calls: Recorded[] = [];
54
+ const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
55
+ const u = new URL(input.toString());
56
+ const method = init?.method ?? "GET";
57
+ const page = u.searchParams.get("page");
58
+ const base = `${method} ${u.pathname}`;
59
+ calls.push({
60
+ method,
61
+ path: `${u.pathname}${u.search}`,
62
+ headers: new Headers(init?.headers),
63
+ });
64
+ const key = (page && routes[`${base}?page=${page}`]) || routes[base];
65
+ if (!key) throw new Error(`no route for ${base} page=${page}`);
66
+ const entry = cassette[key];
67
+ if (!entry) throw new Error(`cassette key not found: ${key}`);
68
+ const bodyless = entry.status === 204 || entry.status === 302;
69
+ return new Response(bodyless ? null : JSON.stringify(entry.body), {
70
+ status: entry.status,
71
+ headers: { "Content-Type": "application/json", ...(entry.headers ?? {}) },
72
+ });
73
+ }) as typeof fetch;
74
+ return { fetchFn, calls };
75
+ }
76
+
77
+ const OK = {
78
+ [INST]: "org_installation_success",
79
+ [MINT]: "lookup_token_success",
80
+ };
81
+
82
+ function makeClient(routes: Record<string, string>) {
83
+ const { fetchFn, calls } = replay({ ...OK, ...routes });
84
+ const client = new GithubLookupClient({
85
+ appId: "555",
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
+ ["404", "not_found", 404],
97
+ ["422", "validation", 422],
98
+ ["429", "rate_limit", 429],
99
+ ["500", "server", 500],
100
+ ["503", "server", 503],
101
+ ];
102
+
103
+ async function errOf(p: Promise<unknown>) {
104
+ try {
105
+ await p;
106
+ } catch (e) {
107
+ return e as Error & {
108
+ kind: GithubEnterpriseErrorKind;
109
+ status?: number;
110
+ retryAfterSeconds?: number;
111
+ };
112
+ }
113
+ throw new Error("expected rejection");
114
+ }
115
+
116
+ function expectNoSecrets(e: Error) {
117
+ expect(e.message).not.toContain("PRIVATE KEY");
118
+ expect(e.message).not.toContain("ghs_lookup");
119
+ expect(e.message).not.toContain("Bearer");
120
+ }
121
+
122
+ describe("getOrgToken", () => {
123
+ it("looks up the org installation with a JWT then mints a token", async () => {
124
+ const { client, calls } = makeClient({});
125
+ expect(await client.getOrgToken("acme-eng")).toEqual({
126
+ token: "ghs_lookupInstallationTokenSecret",
127
+ expiresAt: "2026-11-09T13:00:00Z",
128
+ installationId: 7001,
129
+ });
130
+ expect(calls.map((c) => `${c.method} ${c.path}`)).toEqual([INST, MINT]);
131
+ for (const c of calls) {
132
+ expect(c.headers.get("authorization")).toMatch(
133
+ /^Bearer [\w-]+\.[\w-]+\.[\w-]+$/,
134
+ );
135
+ }
136
+ });
137
+
138
+ it("caches per org until near expiry", async () => {
139
+ const { client, calls } = makeClient({});
140
+ await client.getOrgToken("acme-eng");
141
+ await client.getOrgToken("ACME-ENG");
142
+ expect(calls).toHaveLength(2);
143
+ });
144
+
145
+ for (const [suffix, kind, status] of ERRORS) {
146
+ it(`maps installation lookup ${suffix} to ${kind}`, async () => {
147
+ const { client } = makeClient({ [INST]: `org_installation_${suffix}` });
148
+ const e = await errOf(client.getOrgToken("acme-eng"));
149
+ expect(e.kind).toBe(kind);
150
+ expect(e.status).toBe(status);
151
+ expectNoSecrets(e);
152
+ });
153
+
154
+ it(`maps token mint ${suffix} to ${kind}`, async () => {
155
+ const { client } = makeClient({ [MINT]: `lookup_token_${suffix}` });
156
+ const e = await errOf(client.getOrgToken("acme-eng"));
157
+ expect(e.kind).toBe(kind);
158
+ expect(e.status).toBe(status);
159
+ expectNoSecrets(e);
160
+ });
161
+ }
162
+
163
+ it("surfaces retry-after on 429", async () => {
164
+ const { client } = makeClient({ [MINT]: "lookup_token_429" });
165
+ expect(
166
+ (await errOf(client.getOrgToken("acme-eng"))).retryAfterSeconds,
167
+ ).toBe(30);
168
+ });
169
+ });
170
+
171
+ describe("listInstallationRepositories", () => {
172
+ it("lists a single page with the installation token", async () => {
173
+ const { client, calls } = makeClient({ [REPOS]: "repos_success" });
174
+ const repos = await client.listInstallationRepositories("acme-eng");
175
+ expect(repos).toEqual([
176
+ {
177
+ id: 1,
178
+ name: "api",
179
+ fullName: "acme-eng/api",
180
+ private: false,
181
+ archived: false,
182
+ },
183
+ {
184
+ id: 2,
185
+ name: "web",
186
+ fullName: "acme-eng/web",
187
+ private: true,
188
+ archived: false,
189
+ },
190
+ ]);
191
+ expect(calls.at(-1)?.headers.get("authorization")).toBe(
192
+ "Bearer ghs_lookupInstallationTokenSecret",
193
+ );
194
+ });
195
+
196
+ it("follows the Link header across pages", async () => {
197
+ const { client, calls } = makeClient({
198
+ [`${REPOS}?page=1`]: "repos_page_1",
199
+ [`${REPOS}?page=2`]: "repos_page_2",
200
+ });
201
+ const repos = await client.listInstallationRepositories("acme-eng");
202
+ expect(repos.map((r) => r.name)).toEqual(["api", "web", "infra"]);
203
+ expect(
204
+ calls.filter((c) => c.path.startsWith("/installation/repositories")),
205
+ ).toHaveLength(2);
206
+ });
207
+
208
+ it("returns an empty list", async () => {
209
+ const { client } = makeClient({ [REPOS]: "repos_empty" });
210
+ expect(await client.listInstallationRepositories("acme-eng")).toEqual([]);
211
+ });
212
+
213
+ for (const [suffix, kind] of ERRORS) {
214
+ it(`maps ${suffix} to ${kind}`, async () => {
215
+ const { client } = makeClient({ [REPOS]: `repos_${suffix}` });
216
+ const e = await errOf(client.listInstallationRepositories("acme-eng"));
217
+ expect(e.kind).toBe(kind);
218
+ expectNoSecrets(e);
219
+ });
220
+ }
221
+ });
222
+
223
+ describe("getCollaboratorPermission", () => {
224
+ const roles: Array<[string, RepoRole]> = [
225
+ ["permission_admin", "admin"],
226
+ ["permission_maintain", "maintain"],
227
+ ["permission_write", "write"],
228
+ ["permission_triage", "triage"],
229
+ ["permission_read", "read"],
230
+ ["permission_none", "none"],
231
+ ["permission_legacy_only", "write"],
232
+ ];
233
+ for (const [key, role] of roles) {
234
+ it(`maps ${key} to ${role}`, async () => {
235
+ const { client } = makeClient({ [PERM]: key });
236
+ expect(
237
+ await client.getCollaboratorPermission("acme-eng", "api", "octocat"),
238
+ ).toBe(role);
239
+ });
240
+ }
241
+
242
+ it("rejects an unknown role as an error, not none", async () => {
243
+ const { client } = makeClient({ [PERM]: "permission_unknown_role" });
244
+ const e = await errOf(
245
+ client.getCollaboratorPermission("acme-eng", "api", "octocat"),
246
+ );
247
+ expect(e.kind).toBe("unexpected");
248
+ });
249
+
250
+ for (const [suffix, kind] of ERRORS) {
251
+ it(`maps ${suffix} to ${kind}`, async () => {
252
+ const { client } = makeClient({ [PERM]: `permission_${suffix}` });
253
+ const e = await errOf(
254
+ client.getCollaboratorPermission("acme-eng", "api", "octocat"),
255
+ );
256
+ expect(e.kind).toBe(kind);
257
+ expectNoSecrets(e);
258
+ });
259
+ }
260
+ });
261
+
262
+ describe("getUser", () => {
263
+ it("returns the user", async () => {
264
+ const { client } = makeClient({ [USER]: "user_success" });
265
+ expect(await client.getUser("acme-eng", "octocat")).toEqual({
266
+ id: 583231,
267
+ login: "octocat",
268
+ type: "User",
269
+ });
270
+ });
271
+
272
+ it("reports a nonexistent user as not_found", async () => {
273
+ const { client } = makeClient({ [USER]: "user_nonexistent" });
274
+ const e = await errOf(client.getUser("acme-eng", "octocat"));
275
+ expect(e.kind).toBe("not_found");
276
+ });
277
+
278
+ for (const [suffix, kind] of ERRORS) {
279
+ it(`maps ${suffix} to ${kind}`, async () => {
280
+ const { client } = makeClient({ [USER]: `user_${suffix}` });
281
+ const e = await errOf(client.getUser("acme-eng", "octocat"));
282
+ expect(e.kind).toBe(kind);
283
+ expectNoSecrets(e);
284
+ });
285
+ }
286
+ });
287
+
288
+ describe("isOrgMember", () => {
289
+ it("is true on 204", async () => {
290
+ const { client } = makeClient({ [MEMBER]: "member_yes" });
291
+ expect(await client.isOrgMember("acme-eng", "octocat")).toBe(true);
292
+ });
293
+
294
+ it("is false on 404", async () => {
295
+ const { client } = makeClient({ [MEMBER]: "member_no" });
296
+ expect(await client.isOrgMember("acme-eng", "octocat")).toBe(false);
297
+ });
298
+
299
+ it("treats a 302 as an error, not as non-membership", async () => {
300
+ const { client } = makeClient({ [MEMBER]: "member_redirect" });
301
+ const e = await errOf(client.isOrgMember("acme-eng", "octocat"));
302
+ expect(e.kind).toBe("unexpected");
303
+ });
304
+
305
+ for (const [suffix, kind] of ERRORS.filter(([s]) => s !== "404")) {
306
+ it(`maps ${suffix} to ${kind}`, async () => {
307
+ const { client } = makeClient({ [MEMBER]: `member_${suffix}` });
308
+ const e = await errOf(client.isOrgMember("acme-eng", "octocat"));
309
+ expect(e.kind).toBe(kind);
310
+ expectNoSecrets(e);
311
+ });
312
+ }
313
+ });
@@ -0,0 +1,308 @@
1
+ /**
2
+ * admin/src/github-lookup-client.ts
3
+ * GithubLookupClient: read-only lookups through the admin-level "lookup"
4
+ * GitHub App (GHE-1.1), installed on each organization it needs to read.
5
+ *
6
+ * Flow per org: sign app JWT -> GET /orgs/{org}/installation (JWT) ->
7
+ * POST /app/installations/{id}/access_tokens (JWT) -> read calls with the
8
+ * installation token. Sibling of GithubEnterpriseClient: it shares the JWT
9
+ * signer and the typed error (kinds keep rate limits and server errors
10
+ * distinguishable from not-found), and never puts a token or key in a message.
11
+ *
12
+ * Endpoints follow GitHub's published REST OpenAPI description
13
+ * (api.github.com.json); nothing here was verified live.
14
+ */
15
+
16
+ import { signGithubAppJwt } from "./github-app-jwt.ts";
17
+ import {
18
+ GithubEnterpriseError,
19
+ mapGithubEnterpriseError,
20
+ } from "./github-enterprise-client.ts";
21
+
22
+ export type RepoRole =
23
+ | "admin"
24
+ | "maintain"
25
+ | "write"
26
+ | "triage"
27
+ | "read"
28
+ | "none";
29
+
30
+ export const REPO_ROLES: readonly RepoRole[] = [
31
+ "admin",
32
+ "maintain",
33
+ "write",
34
+ "triage",
35
+ "read",
36
+ "none",
37
+ ];
38
+
39
+ /**
40
+ * Map GitHub's role string to a RepoRole. Exhaustive: an unknown role (for
41
+ * example a custom repository role) is an error, never silently "none".
42
+ */
43
+ export function mapRepoRole(role: unknown): RepoRole {
44
+ if (
45
+ typeof role === "string" &&
46
+ (REPO_ROLES as readonly string[]).includes(role)
47
+ ) {
48
+ return role as RepoRole;
49
+ }
50
+ throw new GithubEnterpriseError(
51
+ "unexpected",
52
+ `GitHub collaborator permission: unknown role ${
53
+ typeof role === "string" ? JSON.stringify(role.slice(0, 40)) : typeof role
54
+ }`,
55
+ );
56
+ }
57
+
58
+ export interface LookupRepository {
59
+ id: number;
60
+ name: string;
61
+ fullName: string;
62
+ private: boolean;
63
+ archived: boolean;
64
+ }
65
+
66
+ export interface LookupUser {
67
+ id: number;
68
+ login: string;
69
+ type: string;
70
+ }
71
+
72
+ export interface LookupToken {
73
+ token: string;
74
+ expiresAt: string;
75
+ installationId: number;
76
+ }
77
+
78
+ export interface GithubLookupClientOptions {
79
+ appId: string;
80
+ privateKey: string;
81
+ apiBase?: string;
82
+ fetchFn?: typeof fetch;
83
+ now?: () => Date;
84
+ }
85
+
86
+ const PER_PAGE = 100;
87
+ const MAX_PAGES = 100;
88
+ /** Refresh a cached installation token this long before it expires. */
89
+ const TOKEN_REFRESH_SKEW_MS = 60_000;
90
+
91
+ export class GithubLookupClient {
92
+ private readonly appId: string;
93
+ private readonly privateKey: string;
94
+ private readonly apiBase: string;
95
+ private readonly fetchFn: typeof fetch;
96
+ private readonly now: () => Date;
97
+ private readonly cached = new Map<string, LookupToken>();
98
+
99
+ constructor(opts: GithubLookupClientOptions) {
100
+ this.appId = opts.appId;
101
+ this.privateKey = opts.privateKey;
102
+ this.apiBase = opts.apiBase ?? "https://api.github.com";
103
+ this.fetchFn = opts.fetchFn ?? fetch;
104
+ this.now = opts.now ?? (() => new Date());
105
+ }
106
+
107
+ /** Mint (or reuse a still-fresh) installation token for the lookup App on `org`. */
108
+ async getOrgToken(org: string): Promise<LookupToken> {
109
+ const key = org.toLowerCase();
110
+ const hit = this.cached.get(key);
111
+ if (
112
+ hit &&
113
+ Date.parse(hit.expiresAt) - this.now().getTime() > TOKEN_REFRESH_SKEW_MS
114
+ ) {
115
+ return hit;
116
+ }
117
+ const jwt = signGithubAppJwt({
118
+ appId: this.appId,
119
+ privateKey: this.privateKey,
120
+ now: this.now(),
121
+ });
122
+ const inst = await this.request<{ id?: number }>(
123
+ "GET",
124
+ `/orgs/${encodeURIComponent(org)}/installation`,
125
+ jwt,
126
+ "GitHub org installation lookup",
127
+ );
128
+ if (typeof inst.data?.id !== "number") {
129
+ throw new GithubEnterpriseError(
130
+ "unexpected",
131
+ "GitHub org installation lookup: response missing id",
132
+ );
133
+ }
134
+ const minted = await this.request<{ token?: string; expires_at?: string }>(
135
+ "POST",
136
+ `/app/installations/${inst.data.id}/access_tokens`,
137
+ jwt,
138
+ "GitHub installation access token",
139
+ );
140
+ if (!minted.data?.token || !minted.data.expires_at) {
141
+ throw new GithubEnterpriseError(
142
+ "unexpected",
143
+ "GitHub installation access token: response missing token or expires_at",
144
+ );
145
+ }
146
+ const token: LookupToken = {
147
+ token: minted.data.token,
148
+ expiresAt: minted.data.expires_at,
149
+ installationId: inst.data.id,
150
+ };
151
+ this.cached.set(key, token);
152
+ return token;
153
+ }
154
+
155
+ /** List every repository the lookup App can see on `org` (paginated). */
156
+ async listInstallationRepositories(org: string): Promise<LookupRepository[]> {
157
+ const { token } = await this.getOrgToken(org);
158
+ const all: LookupRepository[] = [];
159
+ for (let page = 1; page <= MAX_PAGES; page++) {
160
+ const res = await this.request<{ repositories?: RawRepository[] }>(
161
+ "GET",
162
+ `/installation/repositories?per_page=${PER_PAGE}&page=${page}`,
163
+ token,
164
+ "GitHub installation repositories",
165
+ );
166
+ const rows = res.data?.repositories;
167
+ if (!Array.isArray(rows)) {
168
+ throw new GithubEnterpriseError(
169
+ "unexpected",
170
+ "GitHub installation repositories: response is not a repository list",
171
+ );
172
+ }
173
+ for (const r of rows) all.push(toRepository(r));
174
+ const link = res.headers.get("link");
175
+ const hasNext =
176
+ link !== null ? /rel="next"/.test(link) : rows.length >= PER_PAGE;
177
+ if (!hasNext) break;
178
+ }
179
+ return all;
180
+ }
181
+
182
+ /**
183
+ * A user's role on `owner/repo`. Prefers `role_name` (carries maintain and
184
+ * triage), falling back to the legacy `permission` field.
185
+ */
186
+ async getCollaboratorPermission(
187
+ owner: string,
188
+ repo: string,
189
+ username: string,
190
+ ): Promise<RepoRole> {
191
+ const { token } = await this.getOrgToken(owner);
192
+ const res = await this.request<{
193
+ permission?: unknown;
194
+ role_name?: unknown;
195
+ }>(
196
+ "GET",
197
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}/permission`,
198
+ token,
199
+ "GitHub collaborator permission",
200
+ );
201
+ return mapRepoRole(res.data?.role_name ?? res.data?.permission);
202
+ }
203
+
204
+ /** Look up a user. A nonexistent login throws kind "not_found". */
205
+ async getUser(org: string, login: string): Promise<LookupUser> {
206
+ const { token } = await this.getOrgToken(org);
207
+ const res = await this.request<{
208
+ id?: number;
209
+ login?: string;
210
+ type?: string;
211
+ }>(
212
+ "GET",
213
+ `/users/${encodeURIComponent(login)}`,
214
+ token,
215
+ "GitHub user lookup",
216
+ );
217
+ if (
218
+ typeof res.data?.id !== "number" ||
219
+ typeof res.data.login !== "string"
220
+ ) {
221
+ throw new GithubEnterpriseError(
222
+ "unexpected",
223
+ "GitHub user lookup: response missing id or login",
224
+ );
225
+ }
226
+ return {
227
+ id: res.data.id,
228
+ login: res.data.login,
229
+ type: res.data.type ?? "User",
230
+ };
231
+ }
232
+
233
+ /**
234
+ * Whether `username` is a member of `org`. GitHub answers 204 (member) or
235
+ * 404 (not a member, or no such org); a 302 means the requester cannot see
236
+ * the member list and is reported as an error, not as "not a member".
237
+ */
238
+ async isOrgMember(org: string, username: string): Promise<boolean> {
239
+ const { token } = await this.getOrgToken(org);
240
+ try {
241
+ await this.request(
242
+ "GET",
243
+ `/orgs/${encodeURIComponent(org)}/members/${encodeURIComponent(username)}`,
244
+ token,
245
+ "GitHub org membership",
246
+ );
247
+ return true;
248
+ } catch (err) {
249
+ if (err instanceof GithubEnterpriseError && err.kind === "not_found")
250
+ return false;
251
+ throw err;
252
+ }
253
+ }
254
+
255
+ private async request<T>(
256
+ method: "GET" | "POST",
257
+ path: string,
258
+ bearer: string,
259
+ context: string,
260
+ ): Promise<{ status: number; headers: Headers; data: T }> {
261
+ let resp: Response;
262
+ try {
263
+ resp = await this.fetchFn(`${this.apiBase}${path}`, {
264
+ method,
265
+ redirect: "manual",
266
+ headers: {
267
+ Accept: "application/vnd.github+json",
268
+ Authorization: `Bearer ${bearer}`,
269
+ "X-GitHub-Api-Version": "2022-11-28",
270
+ },
271
+ });
272
+ } catch {
273
+ // The underlying error is dropped on purpose: it must never echo headers.
274
+ throw new GithubEnterpriseError("network", `${context}: network error`);
275
+ }
276
+ let data: unknown;
277
+ try {
278
+ data = await resp.json();
279
+ } catch {
280
+ data = undefined;
281
+ }
282
+ if (!resp.ok) {
283
+ throw mapGithubEnterpriseError(resp.status, resp.headers, data, context, [
284
+ bearer,
285
+ this.privateKey,
286
+ ]);
287
+ }
288
+ return { status: resp.status, headers: resp.headers, data: data as T };
289
+ }
290
+ }
291
+
292
+ interface RawRepository {
293
+ id: number;
294
+ name: string;
295
+ full_name: string;
296
+ private?: boolean;
297
+ archived?: boolean;
298
+ }
299
+
300
+ function toRepository(r: RawRepository): LookupRepository {
301
+ return {
302
+ id: r.id,
303
+ name: r.name,
304
+ fullName: r.full_name,
305
+ private: r.private === true,
306
+ archived: r.archived === true,
307
+ };
308
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * admin/src/github-lookup-client.unit.test.ts
3
+ * Unit tests for role mapping and error handling of GithubLookupClient.
4
+ */
5
+
6
+ import { describe, expect, it } from "bun:test";
7
+ import { generateKeyPairSync } from "node:crypto";
8
+ import { GithubEnterpriseError } from "./github-enterprise-client.ts";
9
+ import {
10
+ GithubLookupClient,
11
+ mapRepoRole,
12
+ REPO_ROLES,
13
+ } from "./github-lookup-client.ts";
14
+
15
+ describe("mapRepoRole", () => {
16
+ it.each([...REPO_ROLES])("maps %s to itself", (role) => {
17
+ expect(mapRepoRole(role)).toBe(role);
18
+ });
19
+
20
+ it.each(["custom-role", "ADMIN", "", "owner"])(
21
+ "rejects unknown role %p instead of treating it as none",
22
+ (role) => {
23
+ try {
24
+ mapRepoRole(role);
25
+ } catch (e) {
26
+ expect(e).toBeInstanceOf(GithubEnterpriseError);
27
+ expect((e as GithubEnterpriseError).kind).toBe("unexpected");
28
+ return;
29
+ }
30
+ throw new Error("expected rejection");
31
+ },
32
+ );
33
+
34
+ it.each([undefined, null, 5, {}])("rejects non-string role %p", (role) => {
35
+ expect(() => mapRepoRole(role)).toThrow(GithubEnterpriseError);
36
+ });
37
+ });
38
+
39
+ describe("GithubLookupClient errors", () => {
40
+ const { privateKey } = generateKeyPairSync("rsa", {
41
+ modulusLength: 2048,
42
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
43
+ publicKeyEncoding: { type: "spki", format: "pem" },
44
+ });
45
+
46
+ it("reports a network failure as kind network without the underlying message", async () => {
47
+ const client = new GithubLookupClient({
48
+ appId: "555",
49
+ privateKey,
50
+ fetchFn: (async () => {
51
+ throw new Error("connect failed Authorization: Bearer ghs_leak");
52
+ }) as unknown as typeof fetch,
53
+ });
54
+ try {
55
+ await client.getOrgToken("acme-eng");
56
+ } catch (e) {
57
+ const err = e as GithubEnterpriseError;
58
+ expect(err.kind).toBe("network");
59
+ expect(err.message).not.toContain("ghs_leak");
60
+ return;
61
+ }
62
+ throw new Error("expected rejection");
63
+ });
64
+
65
+ it("redacts an echoed token from an error message", async () => {
66
+ let n = 0;
67
+ const client = new GithubLookupClient({
68
+ appId: "555",
69
+ privateKey,
70
+ fetchFn: (async (_u: unknown, init?: RequestInit) => {
71
+ n++;
72
+ if (n === 1) return Response.json({ id: 1 });
73
+ const bearer = String(
74
+ new Headers(init?.headers).get("authorization"),
75
+ ).slice(7);
76
+ return Response.json(
77
+ { message: `bad credentials ${bearer}` },
78
+ { status: 401 },
79
+ );
80
+ }) as unknown as typeof fetch,
81
+ });
82
+ try {
83
+ await client.getOrgToken("acme-eng");
84
+ } catch (e) {
85
+ const err = e as GithubEnterpriseError;
86
+ expect(err.kind).toBe("unauthorized");
87
+ expect(err.message).toContain("[redacted]");
88
+ expect(err.message).not.toMatch(/eyJ/);
89
+ return;
90
+ }
91
+ throw new Error("expected rejection");
92
+ });
93
+ });