@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.
package/src/admin-boot.ts CHANGED
@@ -535,20 +535,15 @@ export async function startAdmin(
535
535
  // 3b'. GitHub Enterprise Apps API — /github-enterprise/* — admin-only,
536
536
  // optional (GHE-1.3). With no rows configured it reports unconfigured
537
537
  // and does nothing else. Strict crypto: no plain-text key storage.
538
- root.route(
539
- "/",
540
- createGithubEnterpriseApp({
541
- appService: new GitHubEnterpriseAppService(
542
- prisma,
543
- makeStrictTokenCrypto(),
544
- ),
545
- agentService,
546
- agentEnvService,
547
- sessionSecret,
548
- adminApiKeys,
549
- agentTokenService,
550
- }),
551
- );
538
+ const githubEnterpriseApp = createGithubEnterpriseApp({
539
+ appService: new GitHubEnterpriseAppService(prisma, makeStrictTokenCrypto()),
540
+ agentService,
541
+ agentEnvService,
542
+ sessionSecret,
543
+ adminApiKeys,
544
+ agentTokenService,
545
+ });
546
+ root.route("/", githubEnterpriseApp);
552
547
 
553
548
  // 3c. Extension API routes — after every platform API, before the admin UI
554
549
  // (same no-shadowing rule as the admin CRUD API above).
@@ -614,6 +609,8 @@ export async function startAdmin(
614
609
  });
615
610
  return accountCreatedNotifier ? { accountCreatedNotifier } : {};
616
611
  })(),
612
+ githubEnterpriseApi: (path, init) =>
613
+ githubEnterpriseApp.request(path, init),
617
614
  accountServices: {
618
615
  accounts: new AccountService(prisma),
619
616
  members: new AccountMemberService(prisma),
@@ -9,12 +9,29 @@
9
9
 
10
10
  import { AsyncLocalStorage } from "node:async_hooks";
11
11
 
12
- const storage = new AsyncLocalStorage<{ showAccount: boolean }>();
12
+ interface NavFlags {
13
+ showAccount: boolean;
14
+ /** GHE-1.4: the admin-only "Settings" entry. */
15
+ showSettings: boolean;
16
+ }
17
+
18
+ const storage = new AsyncLocalStorage<NavFlags>();
13
19
 
14
20
  export function runWithAccountNav<T>(showAccount: boolean, fn: () => T): T {
15
- return storage.run({ showAccount }, fn);
21
+ const showSettings = storage.getStore()?.showSettings === true;
22
+ return storage.run({ showAccount, showSettings }, fn);
16
23
  }
17
24
 
18
25
  export function shouldShowAccountNav(): boolean {
19
26
  return storage.getStore()?.showAccount === true;
20
27
  }
28
+
29
+ /** Wrap a request so the toolbar shows (or hides) the admin Settings entry. */
30
+ export function runWithSettingsNav<T>(showSettings: boolean, fn: () => T): T {
31
+ const showAccount = storage.getStore()?.showAccount === true;
32
+ return storage.run({ showAccount, showSettings }, fn);
33
+ }
34
+
35
+ export function shouldShowSettingsNav(): boolean {
36
+ return storage.getStore()?.showSettings === true;
37
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * admin/src/admin-ui-github-enterprise-pages.ts
3
+ * Render function for the enterprise GitHub apps settings page (GHE-1.4).
4
+ * Pure string -> string; routes live in admin-ui-github-enterprise.ts.
5
+ *
6
+ * Credentials are paste-only: the forms accept the private key but the page
7
+ * never renders a key (the status API only reports `keySet`).
8
+ */
9
+
10
+ import { renderAdminPage } from "./admin-ui-layout.ts";
11
+ import { escapeHtml, renderAdminToolbar } from "./admin-ui-styles.ts";
12
+
13
+ export const ADMIN_GITHUB_ENTERPRISE_PATH = "/admin/settings/github-enterprise";
14
+
15
+ /** GithubEnterpriseApp shape returned by GET /github-enterprise/apps. */
16
+ export interface GheApp {
17
+ role: string;
18
+ appId: string;
19
+ slug: string;
20
+ enterpriseSlug: string | null;
21
+ keySet: boolean;
22
+ lastValidationStatus: string | null;
23
+ lastValidatedAt: string | null;
24
+ lastValidationError: string | null;
25
+ createdAt: string;
26
+ updatedAt: string;
27
+ }
28
+
29
+ export interface GheStatus {
30
+ configured: boolean;
31
+ installer: GheApp | null;
32
+ lookup: GheApp | null;
33
+ }
34
+
35
+ export interface GheOrg {
36
+ id: number;
37
+ login: string;
38
+ lookupAppInstalled: boolean | null;
39
+ agentAppInstalled: boolean | null;
40
+ }
41
+
42
+ export interface GheOrgs {
43
+ configured: boolean;
44
+ agentAppSlug?: string | null;
45
+ orgs: GheOrg[];
46
+ }
47
+
48
+ export interface GithubEnterpriseSettingsPageOpts {
49
+ userEmail: string;
50
+ status: GheStatus;
51
+ /** Present when the installer App is configured and the org list loaded. */
52
+ orgs?: GheOrgs;
53
+ /** Readable message when the org list could not be loaded. */
54
+ orgsError?: string;
55
+ /** Agent whose App the install actions target (carried via ?agentId=). */
56
+ agentId?: string;
57
+ error?: string;
58
+ notice?: string;
59
+ }
60
+
61
+ const BASE = ADMIN_GITHUB_ENTERPRISE_PATH;
62
+
63
+ /** "2026-11-09T12:00:00.000Z" -> "2026-11-09 12:00 UTC". */
64
+ function formatTime(iso: string): string {
65
+ return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
66
+ }
67
+
68
+ function validationBadge(status: string | null): string {
69
+ if (!status) return `<span class="badge badge-gray">Not validated yet</span>`;
70
+ const cls = status === "valid" ? "badge-green" : "badge-warning";
71
+ return `<span class="badge ${cls}">${escapeHtml(status)}</span>`;
72
+ }
73
+
74
+ const ROLE_COPY = {
75
+ installer: {
76
+ title: "Installer app",
77
+ help: "Installs agents' GitHub Apps on the enterprise's organizations. Needs the enterprise_organization_installations (write) permission.",
78
+ },
79
+ lookup: {
80
+ title: "Lookup app",
81
+ help: "Read-only App used to see which organizations the platform can look up. Needs the metadata (read) permission.",
82
+ },
83
+ } as const;
84
+
85
+ function statusLine(app: GheApp): string {
86
+ const validated = app.lastValidatedAt
87
+ ? ` &middot; ${escapeHtml(formatTime(app.lastValidatedAt))}`
88
+ : "";
89
+ const error = app.lastValidationError
90
+ ? `<div class="help-text" style="font-size:12px;margin-top:4px">${escapeHtml(app.lastValidationError)}</div>`
91
+ : "";
92
+ return `<div class="gh-ent-status">
93
+ <span class="badge badge-green">Configured</span>
94
+ <span class="badge ${app.keySet ? "badge-green" : "badge-warning"}">${app.keySet ? "Key set" : "Key not set"}</span>
95
+ ${validationBadge(app.lastValidationStatus)}${validated}
96
+ ${error}
97
+ </div>`;
98
+ }
99
+
100
+ function postButton(
101
+ action: string,
102
+ label: string,
103
+ cls = "btn-secondary",
104
+ ): string {
105
+ return `<form method="POST" action="${action}" style="display:inline;margin:0"><button type="submit" class="btn ${cls}" style="font-size:12px;padding:4px 10px">${label}</button></form>`;
106
+ }
107
+
108
+ function appCard(role: "installer" | "lookup", app: GheApp | null): string {
109
+ const copy = ROLE_COPY[role];
110
+ const action = `${BASE}/apps/${role}`;
111
+ const summary = app
112
+ ? `<p style="margin:8px 0">App ID <strong>${escapeHtml(app.appId)}</strong> &middot; slug <strong>${escapeHtml(app.slug)}</strong>${app.enterpriseSlug ? ` &middot; enterprise <strong>${escapeHtml(app.enterpriseSlug)}</strong>` : ""}</p>
113
+ ${statusLine(app)}
114
+ <div style="display:flex;gap:8px;margin:12px 0">${postButton(`${action}/validate`, "Validate")}${postButton(`${action}/delete`, "Delete", "btn-danger")}</div>`
115
+ : `<p class="help-text" style="font-size:12px;margin:8px 0">Not configured.</p>`;
116
+ const enterprise =
117
+ role === "installer"
118
+ ? `<label>Enterprise slug <input name="enterpriseSlug" class="form-input" required placeholder="my-enterprise" /></label>`
119
+ : "";
120
+ return `<div class="card">
121
+ <div class="card-title">${copy.title}</div>
122
+ <p class="help-text" style="font-size:12px">${copy.help}</p>
123
+ ${summary}
124
+ <form method="POST" action="${action}" style="display:grid;gap:8px;max-width:480px">
125
+ <label>App ID <input name="appId" class="form-input" required inputmode="numeric" pattern="[0-9]+" /></label>
126
+ <label>App slug <input name="slug" class="form-input" required /></label>
127
+ ${enterprise}
128
+ <label>Private key (PEM) <textarea name="privateKey" class="form-input" rows="4" required autocomplete="off" spellcheck="false" placeholder="Paste the private key. It is stored encrypted and never shown again."></textarea></label>
129
+ <div><button type="submit" class="btn btn-primary">${app ? "Replace credentials" : "Save credentials"}</button></div>
130
+ </form>
131
+ </div>`;
132
+ }
133
+
134
+ function installedCell(v: boolean | null): string {
135
+ if (v === null) return "&mdash;";
136
+ return v
137
+ ? `<span class="badge badge-green">Installed</span>`
138
+ : `<span class="badge badge-gray">Not installed</span>`;
139
+ }
140
+
141
+ function orgsCard(opts: GithubEnterpriseSettingsPageOpts): string {
142
+ const { status } = opts;
143
+ if (!status.installer) return "";
144
+ const agentId = opts.agentId ?? "";
145
+ const lookupSlug = status.lookup?.slug;
146
+
147
+ const filter = `<form method="GET" action="${BASE}" style="display:flex;gap:8px;margin:8px 0">
148
+ <input name="agentId" class="form-input" placeholder="Agent ID (to check its App)" value="${escapeHtml(agentId)}" style="max-width:280px" />
149
+ <button type="submit" class="btn btn-secondary">Check agent</button>
150
+ </form>`;
151
+
152
+ let body: string;
153
+ if (opts.orgsError) {
154
+ body = `<div class="alert alert-error">${escapeHtml(opts.orgsError)}</div>`;
155
+ } else {
156
+ const orgs = opts.orgs?.orgs ?? [];
157
+ const rows =
158
+ orgs.length === 0
159
+ ? `<tr><td colspan="4" class="empty-state">No installable organizations.</td></tr>`
160
+ : orgs
161
+ .map((o) => {
162
+ const login = escapeHtml(o.login);
163
+ const lookupAction = !lookupSlug
164
+ ? ""
165
+ : o.lookupAppInstalled
166
+ ? ""
167
+ : `<a class="btn btn-secondary" style="font-size:12px;padding:4px 10px" href="https://github.com/apps/${escapeHtml(lookupSlug)}/installations/new" target="_blank" rel="noopener noreferrer">Install lookup app</a>`;
168
+ const agentAction = `<form method="POST" action="${BASE}/orgs/${encodeURIComponent(o.login)}/install" style="display:inline-flex;gap:4px;margin:0">
169
+ <input name="agentId" class="form-input" required placeholder="Agent ID" value="${escapeHtml(agentId)}" style="max-width:160px;padding:4px 8px" />
170
+ <button type="submit" class="btn btn-primary" style="font-size:12px;padding:4px 10px">Install agent app</button>
171
+ </form>`;
172
+ return `<tr>
173
+ <td>${login}</td>
174
+ <td>${installedCell(o.lookupAppInstalled)}</td>
175
+ <td>${installedCell(o.agentAppInstalled)}</td>
176
+ <td style="text-align:right">${lookupAction} ${agentAction}</td>
177
+ </tr>`;
178
+ })
179
+ .join("\n");
180
+ body = `<table class="data-table">
181
+ <thead><tr><th>Organization</th><th>Lookup app</th><th>Agent app</th><th></th></tr></thead>
182
+ <tbody>${rows}</tbody>
183
+ </table>`;
184
+ }
185
+ return `<div class="card">
186
+ <div class="card-title">Organizations</div>
187
+ ${filter}
188
+ ${body}
189
+ </div>`;
190
+ }
191
+
192
+ export function renderGithubEnterpriseSettingsPage(
193
+ opts: GithubEnterpriseSettingsPageOpts,
194
+ ): string {
195
+ const { status } = opts;
196
+ const error = opts.error
197
+ ? `<div class="alert alert-error">${escapeHtml(opts.error)}</div>`
198
+ : "";
199
+ const notice = opts.notice
200
+ ? `<div class="alert alert-success">${escapeHtml(opts.notice)}</div>`
201
+ : "";
202
+ const emptyState = status.configured
203
+ ? ""
204
+ : `<div class="card"><div class="card-title">Not configured</div>
205
+ <p>This feature is optional. Without it, agents are provisioned exactly as before. Configure an installer app to install agents' GitHub Apps on your enterprise's organizations from here, and a lookup app to see which organizations are reachable. Paste the credentials of Apps you created in GitHub below.</p>
206
+ </div>`;
207
+
208
+ return renderAdminPage({
209
+ title: "GitHub Enterprise — Settings — Shipwright Admin",
210
+ body: `${renderAdminToolbar(opts.userEmail, BASE)}
211
+ <div class="vos-page">
212
+ <div class="page-header"><h1 class="page-title">GitHub Enterprise apps</h1></div>
213
+ ${error}${notice}${emptyState}
214
+ ${appCard("installer", status.installer)}
215
+ ${appCard("lookup", status.lookup)}
216
+ ${orgsCard(opts)}
217
+ </div>`,
218
+ });
219
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * admin/src/admin-ui-github-enterprise-pages.unit.test.ts
3
+ * Pure render tests for the enterprise GitHub apps settings page (GHE-1.4):
4
+ * empty, configured, validation-failed, and the never-render-the-key rule.
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { runWithSettingsNav } from "./admin-ui-account-nav.ts";
9
+ import {
10
+ type GheApp,
11
+ renderGithubEnterpriseSettingsPage,
12
+ } from "./admin-ui-github-enterprise-pages.ts";
13
+ import { renderAdminToolbar } from "./admin-ui-styles.ts";
14
+
15
+ const PEM_MARKER = "-----BEGIN PRIVATE KEY-----";
16
+
17
+ function app(over: Partial<GheApp> = {}): GheApp {
18
+ return {
19
+ role: "installer",
20
+ appId: "12345",
21
+ slug: "acme-installer",
22
+ enterpriseSlug: "acme",
23
+ keySet: true,
24
+ lastValidationStatus: "valid",
25
+ lastValidatedAt: "2026-11-09T12:00:00.000Z",
26
+ lastValidationError: null,
27
+ createdAt: "2026-11-09T11:00:00.000Z",
28
+ updatedAt: "2026-11-09T11:00:00.000Z",
29
+ ...over,
30
+ };
31
+ }
32
+
33
+ const base = { userEmail: "admin@x.com" };
34
+ const empty = { configured: false, installer: null, lookup: null };
35
+
36
+ describe("renderGithubEnterpriseSettingsPage", () => {
37
+ test("empty state explains the feature is optional and shows both paste forms", () => {
38
+ const html = renderGithubEnterpriseSettingsPage({ ...base, status: empty });
39
+ expect(html).toContain("optional");
40
+ expect(html).toContain("Installer app");
41
+ expect(html).toContain("Lookup app");
42
+ expect(html).toContain(
43
+ 'action="/admin/settings/github-enterprise/apps/installer"',
44
+ );
45
+ expect(html).toContain(
46
+ 'action="/admin/settings/github-enterprise/apps/lookup"',
47
+ );
48
+ expect(html).toContain('name="enterpriseSlug"');
49
+ expect(html).toContain('name="privateKey"');
50
+ expect(html).not.toContain("/validate");
51
+ expect(html).not.toContain("Organizations</div>");
52
+ });
53
+
54
+ test("only the installer form asks for an enterprise slug", () => {
55
+ const html = renderGithubEnterpriseSettingsPage({ ...base, status: empty });
56
+ expect(html.match(/name="enterpriseSlug"/g)?.length).toBe(1);
57
+ });
58
+
59
+ test("configured card shows status only: key set, result, time, no key", () => {
60
+ const html = renderGithubEnterpriseSettingsPage({
61
+ ...base,
62
+ status: { configured: true, installer: app(), lookup: null },
63
+ });
64
+ expect(html).toContain("acme-installer");
65
+ expect(html).toContain("12345");
66
+ expect(html).toContain("Key set");
67
+ expect(html).toContain("valid");
68
+ expect(html).toContain("2026-11-09 12:00");
69
+ expect(html).toContain("/apps/installer/validate");
70
+ expect(html).toContain("/apps/installer/delete");
71
+ expect(html).not.toContain(PEM_MARKER);
72
+ expect(html).not.toContain('type="password" value=');
73
+ expect(html).not.toMatch(/<textarea[^>]*>[^<]+<\/textarea>/);
74
+ });
75
+
76
+ test("never-validated app says so", () => {
77
+ const html = renderGithubEnterpriseSettingsPage({
78
+ ...base,
79
+ status: {
80
+ configured: true,
81
+ installer: app({ lastValidationStatus: null, lastValidatedAt: null }),
82
+ lookup: null,
83
+ },
84
+ });
85
+ expect(html).toContain("Not validated yet");
86
+ });
87
+
88
+ test("validation failure renders the stored error, escaped", () => {
89
+ const html = renderGithubEnterpriseSettingsPage({
90
+ ...base,
91
+ status: {
92
+ configured: true,
93
+ installer: app({
94
+ lastValidationStatus: "missing_permission",
95
+ lastValidationError: "the App does not hold <b>x</b>",
96
+ }),
97
+ lookup: null,
98
+ },
99
+ });
100
+ expect(html).toContain("missing_permission");
101
+ expect(html).toContain("the App does not hold &lt;b&gt;x&lt;/b&gt;");
102
+ expect(html).not.toContain("<b>x</b>");
103
+ });
104
+
105
+ test("key not set shows 'Key not set'", () => {
106
+ const html = renderGithubEnterpriseSettingsPage({
107
+ ...base,
108
+ status: {
109
+ configured: true,
110
+ installer: app({ keySet: false }),
111
+ lookup: null,
112
+ },
113
+ });
114
+ expect(html).toContain("Key not set");
115
+ });
116
+
117
+ test("org list renders install actions and lookup state", () => {
118
+ const html = renderGithubEnterpriseSettingsPage({
119
+ ...base,
120
+ agentId: "agent-1",
121
+ status: {
122
+ configured: true,
123
+ installer: app(),
124
+ lookup: app({
125
+ role: "lookup",
126
+ slug: "acme-lookup",
127
+ enterpriseSlug: null,
128
+ }),
129
+ },
130
+ orgs: {
131
+ configured: true,
132
+ agentAppSlug: "agent-app",
133
+ orgs: [
134
+ {
135
+ id: 1,
136
+ login: "org-a",
137
+ lookupAppInstalled: false,
138
+ agentAppInstalled: false,
139
+ },
140
+ {
141
+ id: 2,
142
+ login: "org-b",
143
+ lookupAppInstalled: true,
144
+ agentAppInstalled: true,
145
+ },
146
+ ],
147
+ },
148
+ });
149
+ expect(html).toContain("org-a");
150
+ expect(html).toContain("org-b");
151
+ expect(html).toContain(
152
+ "https://github.com/apps/acme-lookup/installations/new",
153
+ );
154
+ expect(html).toContain(
155
+ 'action="/admin/settings/github-enterprise/orgs/org-a/install"',
156
+ );
157
+ expect(html).toContain('value="agent-1"');
158
+ expect(html).toContain("Install agent app");
159
+ expect(html).toContain("Installed");
160
+ });
161
+
162
+ test("org list failure and top-level error/notice are escaped messages", () => {
163
+ const html = renderGithubEnterpriseSettingsPage({
164
+ ...base,
165
+ status: { configured: true, installer: app(), lookup: null },
166
+ orgsError: "GitHub could not be reached <x>",
167
+ error: "bad <script>",
168
+ notice: "Saved.",
169
+ });
170
+ expect(html).toContain("GitHub could not be reached &lt;x&gt;");
171
+ expect(html).toContain("bad &lt;script&gt;");
172
+ expect(html).toContain("Saved.");
173
+ expect(html).not.toContain("<script>alert");
174
+ });
175
+ });
176
+
177
+ describe("toolbar Settings entry", () => {
178
+ test("hidden outside a settings-nav context, shown inside one", () => {
179
+ expect(renderAdminToolbar("a@x.com")).not.toContain("/admin/settings");
180
+ const html = runWithSettingsNav(true, () => renderAdminToolbar("a@x.com"));
181
+ expect(html).toContain('href="/admin/settings/github-enterprise"');
182
+ });
183
+ });