@app-vitals/agent-admin 1.14.0 → 1.16.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,323 @@
1
+ /**
2
+ * admin/src/admin-ui-github-enterprise.smoke.test.ts
3
+ * Smoke tests for /admin/settings/github-enterprise (GHE-1.4) via
4
+ * app.request(), backed by the real GHE-1.3 API app with an in-memory app
5
+ * service and a stubbed fetch. No DB, no network.
6
+ */
7
+
8
+ import { describe, expect, it } from "bun:test";
9
+ import { generateKeyPairSync } from "node:crypto";
10
+ import type { MiddlewareHandler } from "hono";
11
+ import { Hono } from "hono";
12
+ import { type AdminUIEnv, createAdminUIApp } from "./admin-ui.ts";
13
+ import { makeMockDeps, makeSessionCookie } from "./admin-ui.fixtures.ts";
14
+ import { registerAdminGithubEnterpriseRoutes } from "./admin-ui-github-enterprise.ts";
15
+ import { parseAdminApiKeys } from "./api-auth.ts";
16
+ import { createGithubEnterpriseApp } from "./github-enterprise-api.ts";
17
+ import type {
18
+ GitHubEnterpriseAppMetadata,
19
+ GitHubEnterpriseAppRole,
20
+ SaveGitHubEnterpriseAppInput,
21
+ } from "./github-enterprise-apps.ts";
22
+
23
+ const { privateKey: PEM } = generateKeyPairSync("rsa", {
24
+ modulusLength: 2048,
25
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
26
+ publicKeyEncoding: { type: "spki", format: "pem" },
27
+ });
28
+
29
+ function fakeAppService() {
30
+ const rows = new Map<string, GitHubEnterpriseAppMetadata & { pem: string }>();
31
+ const now = new Date("2026-11-09T12:00:00Z");
32
+ const strip = ({
33
+ pem: _p,
34
+ ...m
35
+ }: GitHubEnterpriseAppMetadata & { pem: string }) => m;
36
+ return {
37
+ async list() {
38
+ return [...rows.values()].map(strip);
39
+ },
40
+ async get(role: GitHubEnterpriseAppRole) {
41
+ const r = rows.get(role);
42
+ return r ? strip(r) : null;
43
+ },
44
+ async save(input: SaveGitHubEnterpriseAppInput) {
45
+ const row = {
46
+ role: input.role,
47
+ appId: input.appId,
48
+ slug: input.slug,
49
+ enterpriseSlug: input.enterpriseSlug ?? null,
50
+ keySet: true,
51
+ lastValidationStatus: null,
52
+ lastValidatedAt: null,
53
+ lastValidationError: null,
54
+ createdAt: now,
55
+ updatedAt: now,
56
+ pem: input.privateKey,
57
+ } as GitHubEnterpriseAppMetadata & { pem: string };
58
+ rows.set(input.role, row);
59
+ return strip(row);
60
+ },
61
+ async delete(role: GitHubEnterpriseAppRole) {
62
+ rows.delete(role);
63
+ },
64
+ async getPrivateKey(role: GitHubEnterpriseAppRole) {
65
+ return rows.get(role)?.pem ?? null;
66
+ },
67
+ async recordValidation(
68
+ role: GitHubEnterpriseAppRole,
69
+ r: { status: string; error: string | null },
70
+ ) {
71
+ const row = rows.get(role);
72
+ if (row) {
73
+ row.lastValidationStatus = r.status;
74
+ row.lastValidationError = r.error;
75
+ row.lastValidatedAt = now;
76
+ }
77
+ },
78
+ };
79
+ }
80
+
81
+ function setup(opts: { isAdmin?: boolean; fetchStatus?: number } = {}) {
82
+ const appService = fakeAppService();
83
+ const fetchCalls: string[] = [];
84
+ const fetchFn = (async (input: RequestInfo | URL) => {
85
+ const url = String(input);
86
+ fetchCalls.push(url);
87
+ const fail = opts.fetchStatus;
88
+ if (fail) return new Response("{}", { status: fail });
89
+ const json = (b: unknown) =>
90
+ new Response(JSON.stringify(b), {
91
+ status: 200,
92
+ headers: { "Content-Type": "application/json" },
93
+ });
94
+ if (url.includes("/access_tokens"))
95
+ return json({
96
+ token: "t",
97
+ expires_at: "2099-01-01T00:00:00Z",
98
+ permissions: { enterprise_organization_installations: "write" },
99
+ });
100
+ return json({});
101
+ }) as unknown as typeof fetch;
102
+ const api = createGithubEnterpriseApp({
103
+ appService,
104
+ agentService: { getSummary: async () => null } as never,
105
+ agentEnvService: { getByAgentId: async () => null } as never,
106
+ sessionSecret: "test-secret-32chars-for-ghe-api!!",
107
+ adminApiKeys: parseAdminApiKeys("root:tok-admin:*"),
108
+ agentTokenService: { validate: async () => null } as never,
109
+ fetchFn,
110
+ });
111
+ const app = new Hono<AdminUIEnv>();
112
+ registerAdminGithubEnterpriseRoutes(app, {
113
+ requireAuth: (async (c, next) => {
114
+ c.set("userEmail", "admin@x.com");
115
+ c.set("isAdmin", opts.isAdmin ?? true);
116
+ await next();
117
+ }) as MiddlewareHandler<AdminUIEnv>,
118
+ html: (content, o) =>
119
+ new Response(content, {
120
+ status: o?.status ?? 200,
121
+ headers: { "Content-Type": "text/html" },
122
+ }),
123
+ // The UI calls the GHE-1.3 API with a server-held admin bearer.
124
+ api: (path, init) =>
125
+ api.request(path, {
126
+ ...init,
127
+ headers: {
128
+ ...(init?.headers ?? {}),
129
+ Authorization: "Bearer tok-admin",
130
+ },
131
+ }),
132
+ });
133
+ return { app, appService, fetchCalls };
134
+ }
135
+
136
+ const BASE = "/admin/settings/github-enterprise";
137
+ const form = (fields: Record<string, string>) => ({
138
+ method: "POST",
139
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
140
+ body: new URLSearchParams(fields).toString(),
141
+ });
142
+
143
+ describe("GET /admin/settings/github-enterprise", () => {
144
+ it("403s for a non-admin", async () => {
145
+ const { app } = setup({ isAdmin: false });
146
+ expect((await app.request(BASE)).status).toBe(403);
147
+ expect((await app.request(`${BASE}/apps/lookup`, form({}))).status).toBe(
148
+ 403,
149
+ );
150
+ });
151
+
152
+ it("renders the empty state when nothing is configured", async () => {
153
+ const { app } = setup();
154
+ const res = await app.request(BASE);
155
+ expect(res.status).toBe(200);
156
+ const html = await res.text();
157
+ expect(html).toContain("optional");
158
+ expect(html).toContain("Installer app");
159
+ });
160
+ });
161
+
162
+ describe("form posts", () => {
163
+ it("saves an app, then shows status without the key", async () => {
164
+ const { app, appService } = setup();
165
+ const res = await app.request(
166
+ `${BASE}/apps/lookup`,
167
+ form({ appId: "42", slug: "acme-lookup", privateKey: PEM }),
168
+ );
169
+ expect(res.status).toBe(302);
170
+ expect(res.headers.get("location")).toContain(BASE);
171
+ expect((await appService.get("lookup"))?.slug).toBe("acme-lookup");
172
+ const html = await (await app.request(BASE)).text();
173
+ expect(html).toContain("acme-lookup");
174
+ expect(html).toContain("Key set");
175
+ expect(html).not.toContain("PRIVATE KEY");
176
+ });
177
+
178
+ it("surfaces a typed error for an invalid key and does not echo it", async () => {
179
+ const { app } = setup();
180
+ const res = await app.request(
181
+ `${BASE}/apps/lookup`,
182
+ form({
183
+ appId: "42",
184
+ slug: "acme-lookup",
185
+ privateKey: "not-a-pem-SECRET",
186
+ }),
187
+ );
188
+ expect(res.status).toBe(422);
189
+ const html = await res.text();
190
+ expect(html).toContain("privateKey is not a valid PEM private key");
191
+ expect(html).not.toContain("not-a-pem-SECRET");
192
+ });
193
+
194
+ it("surfaces validation errors for a missing installer enterprise slug", async () => {
195
+ const { app } = setup();
196
+ const res = await app.request(
197
+ `${BASE}/apps/installer`,
198
+ form({ appId: "42", slug: "acme-inst", privateKey: PEM }),
199
+ );
200
+ expect(res.status).toBe(422);
201
+ expect(await res.text()).toContain("enterpriseSlug is required");
202
+ });
203
+
204
+ it("validates the installer and shows the persisted result", async () => {
205
+ const { app } = setup();
206
+ await app.request(
207
+ `${BASE}/apps/installer`,
208
+ form({
209
+ appId: "42",
210
+ slug: "acme-inst",
211
+ enterpriseSlug: "acme",
212
+ privateKey: PEM,
213
+ }),
214
+ );
215
+ const res = await app.request(`${BASE}/apps/installer/validate`, form({}));
216
+ expect(res.status).toBe(302);
217
+ const html = await (await app.request(BASE)).text();
218
+ expect(html).toContain("valid");
219
+ expect(html).not.toContain("Not validated yet");
220
+ });
221
+
222
+ it("validate on an unconfigured role is a readable 404 message", async () => {
223
+ const { app } = setup();
224
+ const res = await app.request(`${BASE}/apps/lookup/validate`, form({}));
225
+ expect(res.status).toBe(404);
226
+ expect(await res.text()).toContain("no &#39;lookup&#39; App is configured");
227
+ });
228
+
229
+ it("deletes an app", async () => {
230
+ const { app, appService } = setup();
231
+ await app.request(
232
+ `${BASE}/apps/lookup`,
233
+ form({ appId: "42", slug: "acme-lookup", privateKey: PEM }),
234
+ );
235
+ const res = await app.request(`${BASE}/apps/lookup/delete`, form({}));
236
+ expect(res.status).toBe(302);
237
+ expect(await appService.get("lookup")).toBeNull();
238
+ });
239
+
240
+ it("rejects an unknown role", async () => {
241
+ const { app } = setup();
242
+ expect((await app.request(`${BASE}/apps/bogus`, form({}))).status).toBe(
243
+ 404,
244
+ );
245
+ });
246
+
247
+ it("install without a configured installer surfaces the 409 message", async () => {
248
+ const { app } = setup();
249
+ const res = await app.request(
250
+ `${BASE}/orgs/org-a/install`,
251
+ form({ agentId: "agent-1" }),
252
+ );
253
+ expect(res.status).toBe(409);
254
+ expect(await res.text()).toContain("the installer App is not configured");
255
+ });
256
+
257
+ it("install with an empty agent id is a readable 400", async () => {
258
+ const { app } = setup();
259
+ const res = await app.request(`${BASE}/orgs/org-a/install`, form({}));
260
+ expect(res.status).toBe(400);
261
+ expect(await res.text()).toContain("Agent ID is required");
262
+ });
263
+
264
+ it("org list failure from GitHub renders a message, not a 500", async () => {
265
+ const { app } = setup({ fetchStatus: 401 });
266
+ await app.request(
267
+ `${BASE}/apps/installer`,
268
+ form({
269
+ appId: "42",
270
+ slug: "acme-inst",
271
+ enterpriseSlug: "acme",
272
+ privateKey: PEM,
273
+ }),
274
+ );
275
+ const res = await app.request(BASE);
276
+ expect(res.status).toBe(200);
277
+ expect(await res.text()).toContain(
278
+ "GitHub rejected the configured App credentials",
279
+ );
280
+ });
281
+ });
282
+
283
+ describe("nav entry in the full admin UI", () => {
284
+ const api = async () =>
285
+ new Response(
286
+ JSON.stringify({ configured: false, installer: null, lookup: null }),
287
+ {
288
+ status: 200,
289
+ headers: { "Content-Type": "application/json" },
290
+ },
291
+ );
292
+ const NAV = 'href="/admin/settings/github-enterprise"';
293
+
294
+ it("shows Settings to admins and serves the page", async () => {
295
+ const app = createAdminUIApp(makeMockDeps({ githubEnterpriseApi: api }));
296
+ const cookie = `admin_session=${await makeSessionCookie()}`;
297
+ const nav = await (
298
+ await app.request("/admin/agents", { headers: { cookie } })
299
+ ).text();
300
+ expect(nav).toContain(NAV);
301
+ const page = await app.request(BASE, { headers: { cookie } });
302
+ expect(page.status).toBe(200);
303
+ expect(await page.text()).toContain("optional");
304
+ });
305
+
306
+ it("hides Settings and 403s the page for non-admins", async () => {
307
+ const app = createAdminUIApp(makeMockDeps({ githubEnterpriseApi: api }));
308
+ const cookie = `admin_session=${await makeSessionCookie(undefined, "u2", "other@example.com", false)}`;
309
+ const nav = await app.request("/admin/agents", { headers: { cookie } });
310
+ expect(await nav.text()).not.toContain(NAV);
311
+ expect((await app.request(BASE, { headers: { cookie } })).status).toBe(403);
312
+ });
313
+
314
+ it("leaves nav and routes untouched when the API is not wired", async () => {
315
+ const app = createAdminUIApp(makeMockDeps());
316
+ const cookie = `admin_session=${await makeSessionCookie()}`;
317
+ const nav = await (
318
+ await app.request("/admin/agents", { headers: { cookie } })
319
+ ).text();
320
+ expect(nav).not.toContain(NAV);
321
+ expect((await app.request(BASE, { headers: { cookie } })).status).toBe(404);
322
+ });
323
+ });
@@ -0,0 +1,221 @@
1
+ /**
2
+ * admin/src/admin-ui-github-enterprise.ts
3
+ * GET/POST /admin/settings/github-enterprise — admin-only settings page for
4
+ * the optional enterprise GitHub Apps (GHE-1.4). A thin UI over the GHE-1.3
5
+ * /github-enterprise API: every action calls that API (with the caller's
6
+ * session cookie, so its own admin check applies) and surfaces its typed
7
+ * `{ error }` bodies as readable messages. No key is ever read back.
8
+ */
9
+
10
+ import type { Context, Hono, MiddlewareHandler } from "hono";
11
+ import type { AdminUIEnv } from "./admin-ui.ts";
12
+ import {
13
+ ADMIN_GITHUB_ENTERPRISE_PATH as BASE,
14
+ type GheOrgs,
15
+ type GheStatus,
16
+ renderGithubEnterpriseSettingsPage,
17
+ } from "./admin-ui-github-enterprise-pages.ts";
18
+
19
+ export interface AdminGithubEnterpriseRouteDeps {
20
+ requireAuth: MiddlewareHandler<AdminUIEnv>;
21
+ /** Calls the GHE-1.3 API app (its `.request`), e.g. `/github-enterprise/apps`. */
22
+ api: (path: string, init?: RequestInit) => Response | Promise<Response>;
23
+ html: (content: string, opts?: { status?: number }) => Response;
24
+ }
25
+
26
+ const NOTICES: Record<string, string> = {
27
+ saved: "Credentials saved. Validate the app to check its permissions.",
28
+ deleted: "App removed.",
29
+ validated: "Validation finished; see the status below.",
30
+ installed: "Agent app installed.",
31
+ already: "The agent app was already installed on that organization.",
32
+ };
33
+
34
+ // Deliberately not imported from the enterprise credential service: that
35
+ // key-bearing module stays out of the UI layer (see the isolation test).
36
+ const ROLES = ["installer", "lookup"] as const;
37
+ const isRole = (v: string): v is (typeof ROLES)[number] =>
38
+ (ROLES as readonly string[]).includes(v);
39
+
40
+ interface ApiResult<T> {
41
+ ok: boolean;
42
+ status: number;
43
+ data?: T;
44
+ error?: string;
45
+ }
46
+
47
+ export function registerAdminGithubEnterpriseRoutes(
48
+ app: Hono<AdminUIEnv>,
49
+ deps: AdminGithubEnterpriseRouteDeps,
50
+ ): void {
51
+ const requireAdmin: MiddlewareHandler<AdminUIEnv> = async (c, next) =>
52
+ c.var.isAdmin === true
53
+ ? next()
54
+ : new Response("Forbidden", { status: 403 });
55
+
56
+ async function call<T>(
57
+ c: Context<AdminUIEnv>,
58
+ method: string,
59
+ path: string,
60
+ body?: unknown,
61
+ ): Promise<ApiResult<T>> {
62
+ const headers: Record<string, string> = {};
63
+ const cookie = c.req.header("cookie");
64
+ if (cookie) headers.cookie = cookie;
65
+ if (body !== undefined) headers["content-type"] = "application/json";
66
+ let res: Response;
67
+ try {
68
+ res = await deps.api(path, {
69
+ method,
70
+ headers,
71
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
72
+ });
73
+ } catch {
74
+ return { ok: false, status: 500, error: "The request failed." };
75
+ }
76
+ if (res.ok) {
77
+ const data = res.status === 204 ? undefined : ((await res.json()) as T);
78
+ return { ok: true, status: res.status, data };
79
+ }
80
+ let error = `The request failed (HTTP ${res.status}).`;
81
+ try {
82
+ const j = (await res.json()) as { error?: unknown };
83
+ if (typeof j.error === "string" && j.error) error = j.error;
84
+ } catch {
85
+ // keep the generic message
86
+ }
87
+ return { ok: false, status: res.status, error };
88
+ }
89
+
90
+ async function renderPage(
91
+ c: Context<AdminUIEnv>,
92
+ flash: { error?: string; notice?: string; status?: number } = {},
93
+ ): Promise<Response> {
94
+ const agentId = c.req.query("agentId")?.trim() || undefined;
95
+ const statusRes = await call<GheStatus>(
96
+ c,
97
+ "GET",
98
+ "/github-enterprise/apps",
99
+ );
100
+ if (!statusRes.ok || !statusRes.data) {
101
+ return deps.html(
102
+ renderGithubEnterpriseSettingsPage({
103
+ userEmail: c.var.userEmail,
104
+ status: { configured: false, installer: null, lookup: null },
105
+ error: flash.error ?? statusRes.error,
106
+ }),
107
+ { status: statusRes.status >= 400 ? statusRes.status : 500 },
108
+ );
109
+ }
110
+ const status = statusRes.data;
111
+ let orgs: GheOrgs | undefined;
112
+ let orgsError: string | undefined;
113
+ if (status.installer) {
114
+ const qs = agentId ? `?agentId=${encodeURIComponent(agentId)}` : "";
115
+ const r = await call<GheOrgs>(c, "GET", `/github-enterprise/orgs${qs}`);
116
+ if (r.ok) orgs = r.data;
117
+ else orgsError = r.error;
118
+ }
119
+ const noticeKey = c.req.query("notice");
120
+ const notice = flash.notice ?? (noticeKey ? NOTICES[noticeKey] : undefined);
121
+ return deps.html(
122
+ renderGithubEnterpriseSettingsPage({
123
+ userEmail: c.var.userEmail,
124
+ status,
125
+ orgs,
126
+ orgsError,
127
+ agentId,
128
+ error: flash.error,
129
+ notice,
130
+ }),
131
+ { status: flash.status ?? 200 },
132
+ );
133
+ }
134
+
135
+ const field = (form: FormData, name: string) =>
136
+ (form.get(name)?.toString() ?? "").trim();
137
+ async function readForm(c: Context<AdminUIEnv>): Promise<FormData> {
138
+ try {
139
+ return await c.req.formData();
140
+ } catch {
141
+ return new FormData();
142
+ }
143
+ }
144
+ const back = (c: Context<AdminUIEnv>, notice: string, agentId?: string) =>
145
+ c.redirect(
146
+ `${BASE}?notice=${notice}${agentId ? `&agentId=${encodeURIComponent(agentId)}` : ""}`,
147
+ 302,
148
+ );
149
+ const fail = (c: Context<AdminUIEnv>, r: ApiResult<unknown>) =>
150
+ renderPage(c, { error: r.error, status: r.status >= 400 ? r.status : 400 });
151
+
152
+ app.get(BASE, deps.requireAuth, requireAdmin, (c) => renderPage(c));
153
+
154
+ app.post(`${BASE}/apps/:role`, deps.requireAuth, requireAdmin, async (c) => {
155
+ const role = c.req.param("role");
156
+ if (!isRole(role)) return new Response("Not Found", { status: 404 });
157
+ const form = await readForm(c);
158
+ const enterpriseSlug = field(form, "enterpriseSlug");
159
+ const r = await call(c, "PUT", `/github-enterprise/apps/${role}`, {
160
+ appId: field(form, "appId"),
161
+ slug: field(form, "slug"),
162
+ ...(enterpriseSlug ? { enterpriseSlug } : {}),
163
+ // Pasted as-is; never trimmed away from or echoed back to the page.
164
+ privateKey: form.get("privateKey")?.toString() ?? "",
165
+ });
166
+ return r.ok ? back(c, "saved") : fail(c, r);
167
+ });
168
+
169
+ app.post(
170
+ `${BASE}/apps/:role/validate`,
171
+ deps.requireAuth,
172
+ requireAdmin,
173
+ async (c) => {
174
+ const role = c.req.param("role");
175
+ if (!isRole(role)) return new Response("Not Found", { status: 404 });
176
+ const r = await call(
177
+ c,
178
+ "POST",
179
+ `/github-enterprise/apps/${role}/validate`,
180
+ );
181
+ return r.ok ? back(c, "validated") : fail(c, r);
182
+ },
183
+ );
184
+
185
+ app.post(
186
+ `${BASE}/apps/:role/delete`,
187
+ deps.requireAuth,
188
+ requireAdmin,
189
+ async (c) => {
190
+ const role = c.req.param("role");
191
+ if (!isRole(role)) return new Response("Not Found", { status: 404 });
192
+ const r = await call(c, "DELETE", `/github-enterprise/apps/${role}`);
193
+ return r.ok ? back(c, "deleted") : fail(c, r);
194
+ },
195
+ );
196
+
197
+ app.post(
198
+ `${BASE}/orgs/:org/install`,
199
+ deps.requireAuth,
200
+ requireAdmin,
201
+ async (c) => {
202
+ const org = c.req.param("org");
203
+ const agentId = field(await readForm(c), "agentId");
204
+ if (!agentId) {
205
+ return renderPage(c, { error: "Agent ID is required.", status: 400 });
206
+ }
207
+ const r = await call<{ created: boolean }>(
208
+ c,
209
+ "POST",
210
+ `/github-enterprise/orgs/${encodeURIComponent(org)}/install`,
211
+ { agentId },
212
+ );
213
+ if (!r.ok) return fail(c, r);
214
+ return back(
215
+ c,
216
+ r.data?.created === false ? "already" : "installed",
217
+ agentId,
218
+ );
219
+ },
220
+ );
221
+ }
@@ -3,7 +3,10 @@ import {
3
3
  renderShipwrightToolbar,
4
4
  baseStyles as toolbarBaseStyles,
5
5
  } from "@app-vitals/agent-lib/web/toolbar.ts";
6
- import { shouldShowAccountNav } from "./admin-ui-account-nav.ts";
6
+ import {
7
+ shouldShowAccountNav,
8
+ shouldShowSettingsNav,
9
+ } from "./admin-ui-account-nav.ts";
7
10
 
8
11
  export { escapeHtml };
9
12
 
@@ -652,5 +655,6 @@ export function renderAdminToolbar(userName: string, activePath = ""): string {
652
655
  logoutAction: "/admin/logout",
653
656
  metricsUrl: process.env.METRICS_DASHBOARD_URL ?? "/dashboard",
654
657
  showAccount: shouldShowAccountNav(),
658
+ showSettings: shouldShowSettingsNav(),
655
659
  });
656
660
  }
package/src/admin-ui.ts CHANGED
@@ -45,9 +45,13 @@ import {
45
45
  renderAgentDetailCardSlots,
46
46
  } from "./admin-extension.ts";
47
47
  import { registerAccountRoutes } from "./admin-ui-account.ts";
48
- import { runWithAccountNav } from "./admin-ui-account-nav.ts";
48
+ import {
49
+ runWithAccountNav,
50
+ runWithSettingsNav,
51
+ } from "./admin-ui-account-nav.ts";
49
52
  import { renderZeroQuotaNotice } from "./admin-ui-account-pages.ts";
50
53
  import { registerAdminAccountsRoutes } from "./admin-ui-accounts.ts";
54
+ import { registerAdminGithubEnterpriseRoutes } from "./admin-ui-github-enterprise.ts";
51
55
  import {
52
56
  type AgentDetail,
53
57
  type AgentOption,
@@ -407,6 +411,15 @@ export interface AdminUIDeps {
407
411
  * callback only, and only when selfServe.enabled; absent → no auto-create.
408
412
  */
409
413
  accountOnboarding?: Pick<AccountOnboardingService, "provisionForEmail">;
414
+ /**
415
+ * GHE-1.4: the GHE-1.3 /github-enterprise API app's `request`. Optional
416
+ * (additive): when absent the Settings page and its nav entry are not
417
+ * registered.
418
+ */
419
+ githubEnterpriseApi?: (
420
+ path: string,
421
+ init?: RequestInit,
422
+ ) => Response | Promise<Response>;
410
423
  /** Best-effort operator push on auto-created accounts (SSP-3.3). */
411
424
  accountCreatedNotifier?: AccountCreatedNotifier;
412
425
  /**
@@ -795,6 +808,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
795
808
  accountOnboarding,
796
809
  accountCreatedNotifier,
797
810
  accountServices,
811
+ githubEnterpriseApi,
798
812
  specialtyRegistry = new SpecialtyTypes(),
799
813
  specialtyDefaults,
800
814
  callerScopeResolver = memberOnlyCallerScopeResolver(agentMemberService),
@@ -837,6 +851,16 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
837
851
  devAuthEnabled,
838
852
  });
839
853
 
854
+ // GHE-1.4: the admin-only "Settings" toolbar entry. Registered before the
855
+ // Account middleware; the two merge their flags in the shared store.
856
+ if (githubEnterpriseApi) {
857
+ app.use("/admin/*", async (c, next) => {
858
+ const token = getCookie(c, SESSION_COOKIE);
859
+ const user = token ? await getSessionUser(token, sessionSecret) : null;
860
+ return runWithSettingsNav(user?.isAdmin === true, () => next());
861
+ });
862
+ }
863
+
840
864
  // SSP-3.2: show the toolbar's "Account" entry only for signed-in users who
841
865
  // belong to an account, and only while self-serve is on. The flag is carried
842
866
  // in AsyncLocalStorage so renderAdminToolbar() needs no extra parameter.
@@ -3182,6 +3206,16 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
3182
3206
  return c.redirect(`/admin/agents/${agentId}`, 302);
3183
3207
  });
3184
3208
 
3209
+ // ─── Enterprise GitHub apps settings (GHE-1.4) ───────────────────────────
3210
+
3211
+ if (githubEnterpriseApi) {
3212
+ registerAdminGithubEnterpriseRoutes(app, {
3213
+ requireAuth,
3214
+ api: githubEnterpriseApi,
3215
+ html,
3216
+ });
3217
+ }
3218
+
3185
3219
  // ─── Account page (SSP-3.2) ───────────────────────────────────────────────
3186
3220
 
3187
3221
  if (accountServices) {