@app-vitals/agent-admin 1.3.0 → 1.5.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,72 @@
1
+ /**
2
+ * admin/src/agent-marketplaces.unit.test.ts
3
+ * Unit tests for marketplace input validation (source, name, ref).
4
+ */
5
+
6
+ import { describe, expect, it } from "bun:test";
7
+ import {
8
+ validateMarketplaceName,
9
+ validateMarketplaceRef,
10
+ validateMarketplaceSource,
11
+ } from "./agent-marketplaces.ts";
12
+ import { UnprocessableEntityError } from "./errors.ts";
13
+
14
+ describe("validateMarketplaceSource", () => {
15
+ it.each([
16
+ "app-vitals/shipwright",
17
+ "owner/repo.name",
18
+ "Org_1/repo-2",
19
+ "https://github.com/app-vitals/shipwright.git",
20
+ "https://gitlab.example.com/group/sub/repo",
21
+ ])("accepts %s", (source) => {
22
+ expect(() => validateMarketplaceSource(source)).not.toThrow();
23
+ });
24
+
25
+ it.each([
26
+ "",
27
+ " ",
28
+ "repo",
29
+ "owner/",
30
+ "/repo",
31
+ "owner/repo/extra",
32
+ "owner/re po",
33
+ "http://github.com/o/r",
34
+ "git@github.com:o/r.git",
35
+ "ssh://git@github.com/o/r",
36
+ "file:///etc/passwd",
37
+ "../etc",
38
+ "./local/path",
39
+ "-owner/repo",
40
+ "https://",
41
+ "https://user:pass@github.com/o/r",
42
+ "https://github.com/o/r with space",
43
+ "https://-evil.com/x",
44
+ ])("rejects %p with a 422", (source) => {
45
+ expect(() => validateMarketplaceSource(source)).toThrow(
46
+ UnprocessableEntityError,
47
+ );
48
+ });
49
+ });
50
+
51
+ describe("validateMarketplaceName", () => {
52
+ it("accepts simple names", () => {
53
+ expect(() => validateMarketplaceName("shipwright")).not.toThrow();
54
+ expect(() => validateMarketplaceName("my.market_place-2")).not.toThrow();
55
+ });
56
+ it.each(["", "a b", "a@b", "a/b", "-x"])("rejects %p", (name) => {
57
+ expect(() => validateMarketplaceName(name)).toThrow(
58
+ UnprocessableEntityError,
59
+ );
60
+ });
61
+ });
62
+
63
+ describe("validateMarketplaceRef", () => {
64
+ it("accepts branches, tags, shas and absent refs", () => {
65
+ for (const ref of [undefined, null, "main", "v1.2.3", "feat/x", "abc123"]) {
66
+ expect(() => validateMarketplaceRef(ref)).not.toThrow();
67
+ }
68
+ });
69
+ it.each(["", "-x", "a b", "a..b", "x;rm"])("rejects %p", (ref) => {
70
+ expect(() => validateMarketplaceRef(ref)).toThrow(UnprocessableEntityError);
71
+ });
72
+ });
package/src/agents-api.ts CHANGED
@@ -41,8 +41,8 @@ import {
41
41
  } from "./account-agent-create.ts";
42
42
  import {
43
43
  reconcileAgentAfterAccountChange,
44
- reconcileAgentAfterSpecialtyChange,
45
44
  reconcileAgentAfterPodServiceAccountChange,
45
+ reconcileAgentAfterSpecialtyChange,
46
46
  } from "./agent-account-assignment.ts";
47
47
  import {
48
48
  type AgentAskDeps,
@@ -60,6 +60,7 @@ import type { DeleteAgentFullyDeps } from "./agent-deletion.ts";
60
60
  import { deleteAgentFully } from "./agent-deletion.ts";
61
61
  import type { AgentEnvService } from "./agent-envs.ts";
62
62
  import type { AgentGitHubInstallationsService } from "./agent-github-installations.ts";
63
+ import { AgentMarketplaceService } from "./agent-marketplaces.ts";
63
64
  import type { AgentMemberService } from "./agent-members.ts";
64
65
  import type { AgentPluginService } from "./agent-plugins.ts";
65
66
  import {
@@ -116,6 +117,7 @@ import {
116
117
  AgentEnvResponseSchema,
117
118
  AgentGitHubInstallationsSnapshotSchema,
118
119
  AgentIdParamSchema,
120
+ AgentMarketplaceSchema,
119
121
  AgentPluginSchema,
120
122
  AgentRepoParamSchema,
121
123
  AgentSummarySchema,
@@ -143,6 +145,7 @@ import {
143
145
  EnvKeyParamSchema,
144
146
  ErrorSchema,
145
147
  ListCronRunsQuerySchema,
148
+ MarketplaceIdParamSchema,
146
149
  type ModelBreakdownEntry,
147
150
  OkSchema,
148
151
  PatchAgentBodySchema,
@@ -154,6 +157,7 @@ import {
154
157
  PutGitHubInstallationsBodySchema,
155
158
  TokenIdParamSchema,
156
159
  ToolIdParamSchema,
160
+ UpsertAgentMarketplaceBodySchema,
157
161
  UpsertChatTokenDailyBodySchema,
158
162
  } from "./openapi-schemas.ts";
159
163
  import type { SelfServeConfig } from "./self-serve-config.ts";
@@ -212,6 +216,14 @@ export interface AdminDeps {
212
216
  AgentPluginService,
213
217
  "list" | "add" | "remove" | "removeByName"
214
218
  >;
219
+ /**
220
+ * PMK-1.2: remote plugin marketplaces. Optional (additive to the exported
221
+ * AdminDeps): defaults to an AgentMarketplaceService over `prisma`.
222
+ */
223
+ agentMarketplaceService?: Pick<
224
+ AgentMarketplaceService,
225
+ "list" | "upsert" | "remove"
226
+ >;
215
227
  agentMemberService: Pick<
216
228
  AgentMemberService,
217
229
  "add" | "listByAgentId" | "exists" | "listByEmail"
@@ -492,6 +504,14 @@ const PluginWrapperSchema = z
492
504
  .object({ plugin: AgentPluginSchema })
493
505
  .openapi("PluginWrapper");
494
506
 
507
+ const MarketplaceWrapperSchema = z
508
+ .object({ marketplace: AgentMarketplaceSchema })
509
+ .openapi("MarketplaceWrapper");
510
+
511
+ const MarketplacesWrapperSchema = z
512
+ .object({ marketplaces: z.array(AgentMarketplaceSchema) })
513
+ .openapi("MarketplacesWrapper");
514
+
495
515
  const PluginsWrapperSchema = z
496
516
  .object({ plugins: z.array(AgentPluginSchema) })
497
517
  .openapi("PluginsWrapper");
@@ -1211,6 +1231,58 @@ const deletePluginRoute = createRoute({
1211
1231
  },
1212
1232
  });
1213
1233
 
1234
+ const listMarketplacesRoute = createRoute({
1235
+ method: "get",
1236
+ path: "/agents/{id}/marketplaces",
1237
+ summary: "List marketplaces",
1238
+ description:
1239
+ "Returns every remote plugin marketplace configured for the agent.",
1240
+ request: { params: AgentIdParamSchema },
1241
+ responses: {
1242
+ 200: {
1243
+ description: "List of marketplaces",
1244
+ content: { "application/json": { schema: MarketplacesWrapperSchema } },
1245
+ },
1246
+ },
1247
+ });
1248
+
1249
+ const upsertMarketplaceRoute = createRoute({
1250
+ method: "post",
1251
+ path: "/agents/{id}/marketplaces",
1252
+ summary: "Add or update a marketplace",
1253
+ description:
1254
+ "Upserts a remote plugin marketplace by `name`. `source` must be a GitHub `owner/repo` or an https git URL (no credentials); `ref` optionally pins a branch, tag or sha. 422 on an invalid `name`, `source` or `ref`.",
1255
+ request: {
1256
+ params: AgentIdParamSchema,
1257
+ body: {
1258
+ content: {
1259
+ "application/json": { schema: UpsertAgentMarketplaceBodySchema },
1260
+ },
1261
+ },
1262
+ },
1263
+ responses: {
1264
+ 201: {
1265
+ description: "Marketplace added or updated",
1266
+ content: { "application/json": { schema: MarketplaceWrapperSchema } },
1267
+ },
1268
+ 400: { description: "Bad request", ...jsonError },
1269
+ 422: { description: "Invalid name, source or ref", ...jsonError },
1270
+ },
1271
+ });
1272
+
1273
+ const deleteMarketplaceRoute = createRoute({
1274
+ method: "delete",
1275
+ path: "/agents/{id}/marketplaces/{marketplaceId}",
1276
+ summary: "Remove a marketplace",
1277
+ description:
1278
+ "Removes the marketplace row by id. 404 when the id does not exist or belongs to a different agent.",
1279
+ request: { params: MarketplaceIdParamSchema },
1280
+ responses: {
1281
+ 204: { description: "Marketplace removed" },
1282
+ 404: { description: "Marketplace not found", ...jsonError },
1283
+ },
1284
+ });
1285
+
1214
1286
  const upsertChatTokenDailyRoute = createRoute({
1215
1287
  method: "post",
1216
1288
  path: "/agents/{id}/chat-tokens/daily",
@@ -2532,6 +2604,34 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
2532
2604
  return c.body(null, 204);
2533
2605
  });
2534
2606
 
2607
+ // ─── Marketplaces (PMK-1.2) ────────────────────────────────────────────────
2608
+ // Auth is the shared /agents/* middleware: the agent's own key, a platform
2609
+ // admin and authorised session members pass; other agents' keys get 403.
2610
+ // No route here writes the runtime-reported baked-marketplace list (PMK-1.4).
2611
+
2612
+ const agentMarketplaceService =
2613
+ deps.agentMarketplaceService ??
2614
+ new AgentMarketplaceService(prisma as PrismaClient);
2615
+
2616
+ app.openapi(listMarketplacesRoute, async (c) => {
2617
+ const { id: agentId } = c.req.valid("param");
2618
+ const rows = await agentMarketplaceService.list(agentId);
2619
+ return c.json({ marketplaces: rows.map(serializeMarketplace) }, 200);
2620
+ });
2621
+
2622
+ app.openapi(upsertMarketplaceRoute, async (c) => {
2623
+ const { id: agentId } = c.req.valid("param");
2624
+ const body = c.req.valid("json");
2625
+ const row = await agentMarketplaceService.upsert(agentId, body);
2626
+ return c.json({ marketplace: serializeMarketplace(row) }, 201);
2627
+ });
2628
+
2629
+ app.openapi(deleteMarketplaceRoute, async (c) => {
2630
+ const { id: agentId, marketplaceId } = c.req.valid("param");
2631
+ await agentMarketplaceService.remove(agentId, marketplaceId);
2632
+ return c.body(null, 204);
2633
+ });
2634
+
2535
2635
  // ─── Cron-run token stats ──────────────────────────────────────────────────
2536
2636
 
2537
2637
  // GET /agents/all/cron-runs/stats — aggregated token stats across all agents
@@ -2844,6 +2944,22 @@ function serializeTool(tool: {
2844
2944
  } as z.infer<typeof AgentToolSchema>;
2845
2945
  }
2846
2946
 
2947
+ function serializeMarketplace(row: {
2948
+ id: string;
2949
+ agentId: string;
2950
+ name: string;
2951
+ source: string;
2952
+ ref: string | null;
2953
+ createdAt: Date;
2954
+ updatedAt: Date;
2955
+ }): z.infer<typeof AgentMarketplaceSchema> {
2956
+ return {
2957
+ ...row,
2958
+ createdAt: row.createdAt.toISOString(),
2959
+ updatedAt: row.updatedAt.toISOString(),
2960
+ };
2961
+ }
2962
+
2847
2963
  function serializePlugin(plugin: {
2848
2964
  createdAt: Date;
2849
2965
  updatedAt: Date;
package/src/api.ts CHANGED
@@ -44,6 +44,12 @@ export interface AgentConfigResponse {
44
44
  env: Record<string, string>;
45
45
  allowedTools: string[];
46
46
  plugins: AgentPlugin[];
47
+ /**
48
+ * PMK-1.2: remote plugin marketplaces to register before plugin install.
49
+ * Optional so existing fixtures/doubles built against this interface keep
50
+ * compiling; the live handler always populates it (empty when none).
51
+ */
52
+ marketplaces?: Array<{ name: string; source: string; ref: string | null }>;
47
53
  repos: string[];
48
54
  reviewAuthorAllowlist: string[];
49
55
  patchAuthorAllowlist: string[];
@@ -155,6 +161,12 @@ interface AgentPluginServiceLike {
155
161
  listEnabled(agentId: string): Promise<Array<{ name: string }>>;
156
162
  }
157
163
 
164
+ interface AgentMarketplaceServiceLike {
165
+ list(
166
+ agentId: string,
167
+ ): Promise<Array<{ name: string; source: string; ref: string | null }>>;
168
+ }
169
+
158
170
  export interface AgentPhaseMethodologyServiceLike {
159
171
  list(
160
172
  agentId: string,
@@ -166,6 +178,11 @@ export interface AgentRuntimeDeps {
166
178
  agentCronJobService: AgentCronJobServiceLike;
167
179
  agentService: AgentServiceLike;
168
180
  agentPluginService: AgentPluginServiceLike;
181
+ /**
182
+ * PMK-1.2: optional. Absent → GET /agents/:id/config reports
183
+ * `marketplaces: []`.
184
+ */
185
+ agentMarketplaceService?: AgentMarketplaceServiceLike;
169
186
  /**
170
187
  * Optional: supplied by an AdminExtension (the coding extension). Absent →
171
188
  * GET /agents/:id/config omits `phaseMethodology`.
@@ -290,11 +307,13 @@ export function createAgentRuntimeApp(deps: AgentRuntimeDeps): OpenAPIHono {
290
307
  }
291
308
 
292
309
  // Fetch env bundle, plugins, and phase-methodology overrides in parallel
293
- const [bundle, plugins, phaseMethodologyRows] = await Promise.all([
294
- agentEnvService.getConfigBundle(id),
295
- agentPluginService.listEnabled(id),
296
- agentPhaseMethodologyService?.list(id),
297
- ]);
310
+ const [bundle, plugins, marketplaceRows, phaseMethodologyRows] =
311
+ await Promise.all([
312
+ agentEnvService.getConfigBundle(id),
313
+ agentPluginService.listEnabled(id),
314
+ deps.agentMarketplaceService?.list(id),
315
+ agentPhaseMethodologyService?.list(id),
316
+ ]);
298
317
 
299
318
  // WIF-2.1: with federation enabled, a non-self-hosted agent must never be
300
319
  // handed Anthropic credentials, even ones already stored in its bundle
@@ -341,6 +360,11 @@ export function createAgentRuntimeApp(deps: AgentRuntimeDeps): OpenAPIHono {
341
360
  ? { marketplace: "shipwright", plugin: p.name }
342
361
  : { plugin: p.name.slice(0, at), marketplace: p.name.slice(at + 1) };
343
362
  }),
363
+ marketplaces: (marketplaceRows ?? []).map((m) => ({
364
+ name: m.name,
365
+ source: m.source,
366
+ ref: m.ref,
367
+ })),
344
368
  repos: agent.repos,
345
369
  reviewAuthorAllowlist: agent.reviewAuthorAllowlist,
346
370
  patchAuthorAllowlist: agent.patchAuthorAllowlist,
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Integration tests for GitHubEnterpriseAppService against a real PostgreSQL DB.
3
+ *
4
+ * Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
8
+ import type { PrismaClient } from "../prisma/client/client.ts";
9
+ import {
10
+ GitHubEnterpriseAppService,
11
+ PrivateKeyDecryptError,
12
+ PrivateKeyEncryptionUnavailableError,
13
+ } from "./github-enterprise-apps.ts";
14
+ import { createAdminPrismaClient } from "./prisma-client.ts";
15
+ import { makeStrictTokenCrypto } from "./token-crypto.ts";
16
+
17
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
18
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
19
+
20
+ const KEY_A = "a".repeat(64);
21
+ const KEY_B = "b".repeat(64);
22
+ const PEM =
23
+ "-----BEGIN RSA PRIVATE KEY-----\nSUPERSECRETPEMBODY0123456789\n-----END RSA PRIVATE KEY-----";
24
+
25
+ const installer = {
26
+ role: "installer" as const,
27
+ appId: "111",
28
+ slug: "inst-app",
29
+ enterpriseSlug: "acme",
30
+ privateKey: PEM,
31
+ };
32
+
33
+ describeOrSkip("GitHubEnterpriseAppService (integration)", () => {
34
+ let prisma: PrismaClient;
35
+ let service: GitHubEnterpriseAppService;
36
+
37
+ beforeEach(async () => {
38
+ prisma = createAdminPrismaClient(TEST_DB as string);
39
+ await prisma.gitHubEnterpriseApp.deleteMany();
40
+ service = new GitHubEnterpriseAppService(
41
+ prisma,
42
+ makeStrictTokenCrypto(KEY_A),
43
+ );
44
+ });
45
+
46
+ afterEach(async () => {
47
+ await prisma.$disconnect();
48
+ });
49
+
50
+ it("returns null when nothing is saved (feature off)", async () => {
51
+ expect(await service.get("installer")).toBeNull();
52
+ expect(await service.list()).toEqual([]);
53
+ });
54
+
55
+ it("saves and reads per role; metadata plus keySet only", async () => {
56
+ const saved = await service.save(installer);
57
+ expect(saved.keySet).toBe(true);
58
+ await service.save({
59
+ role: "lookup",
60
+ appId: "222",
61
+ slug: "look-app",
62
+ privateKey: PEM,
63
+ });
64
+ const got = await service.get("installer");
65
+ expect(got).toMatchObject({
66
+ role: "installer",
67
+ appId: "111",
68
+ slug: "inst-app",
69
+ enterpriseSlug: "acme",
70
+ keySet: true,
71
+ });
72
+ expect(JSON.stringify(got)).not.toContain("SUPERSECRETPEMBODY");
73
+ expect(JSON.stringify(await service.list())).not.toContain(
74
+ "SUPERSECRETPEMBODY",
75
+ );
76
+ expect((await service.list()).map((a) => a.role).sort()).toEqual([
77
+ "installer",
78
+ "lookup",
79
+ ]);
80
+ });
81
+
82
+ it("save again for the same role replaces the row (at most two rows)", async () => {
83
+ await service.save(installer);
84
+ await service.save({ ...installer, appId: "999" });
85
+ expect(await prisma.gitHubEnterpriseApp.count()).toBe(1);
86
+ expect((await service.get("installer"))?.appId).toBe("999");
87
+ });
88
+
89
+ it("stores the key encrypted and round-trips it via getPrivateKey", async () => {
90
+ await service.save(installer);
91
+ const row = await prisma.gitHubEnterpriseApp.findUnique({
92
+ where: { role: "installer" },
93
+ });
94
+ expect(row?.privateKeyEncrypted).not.toContain("SUPERSECRETPEMBODY");
95
+ expect(row?.privateKeyEncrypted.split(":")).toHaveLength(3);
96
+ expect(await service.getPrivateKey("installer")).toBe(PEM);
97
+ });
98
+
99
+ it("deletes per role", async () => {
100
+ await service.save(installer);
101
+ await service.delete("installer");
102
+ expect(await service.get("installer")).toBeNull();
103
+ await service.delete("installer"); // no-op
104
+ });
105
+
106
+ it("rejects an unknown role", async () => {
107
+ await expect(
108
+ service.save({ ...installer, role: "bogus" as never }),
109
+ ).rejects.toThrow(/role/);
110
+ });
111
+
112
+ it("refuses to save when no encryption key is configured, without echoing the PEM", async () => {
113
+ const noKey = new GitHubEnterpriseAppService(
114
+ prisma,
115
+ makeStrictTokenCrypto(undefined),
116
+ );
117
+ const err = await noKey.save(installer).catch((e) => e);
118
+ expect(err).toBeInstanceOf(PrivateKeyEncryptionUnavailableError);
119
+ expect(err.message).toContain("SHIPWRIGHT_ENCRYPTION_KEY");
120
+ expect(err.message).not.toContain("SUPERSECRETPEMBODY");
121
+ expect(await prisma.gitHubEnterpriseApp.count()).toBe(0);
122
+ });
123
+
124
+ it("strict decrypt: wrong key throws, never returning ciphertext or PEM", async () => {
125
+ await service.save(installer);
126
+ const wrong = new GitHubEnterpriseAppService(
127
+ prisma,
128
+ makeStrictTokenCrypto(KEY_B),
129
+ );
130
+ const err = await wrong.getPrivateKey("installer").catch((e) => e);
131
+ expect(err).toBeInstanceOf(PrivateKeyDecryptError);
132
+ expect(err.message).not.toContain("SUPERSECRETPEMBODY");
133
+ });
134
+
135
+ it("strict decrypt: tampered and legacy plain-text values throw", async () => {
136
+ await service.save(installer);
137
+ const row = await prisma.gitHubEnterpriseApp.findUnique({
138
+ where: { role: "installer" },
139
+ });
140
+ const [iv, ct, tag] = (row?.privateKeyEncrypted ?? "").split(":");
141
+ const flipped = (ct[0] === "0" ? "1" : "0") + ct.slice(1);
142
+ for (const bad of [`${iv}:${flipped}:${tag}`, PEM]) {
143
+ await prisma.gitHubEnterpriseApp.update({
144
+ where: { role: "installer" },
145
+ data: { privateKeyEncrypted: bad },
146
+ });
147
+ const err = await service.getPrivateKey("installer").catch((e) => e);
148
+ expect(err).toBeInstanceOf(PrivateKeyDecryptError);
149
+ expect(err.message).not.toContain("SUPERSECRETPEMBODY");
150
+ }
151
+ });
152
+ });
@@ -0,0 +1,204 @@
1
+ /**
2
+ * admin/src/github-enterprise-apps.ts
3
+ * GitHubEnterpriseAppService — storage for the two optional admin-level GitHub
4
+ * Apps ('installer' and 'lookup'), at most one row per role. No rows means the
5
+ * feature is off.
6
+ *
7
+ * The private key is encrypted on write (AES-256-GCM via TokenCrypto) and is
8
+ * never returned by any read path: reads return metadata plus a `keySet` flag.
9
+ * Only getPrivateKey() decrypts, for server-side use by the app-token minter.
10
+ *
11
+ * Deliberate differences from AgentEnvService:
12
+ * - save() refuses when no encryption key is configured (no plain-text fallback);
13
+ * - decrypt is strict (failure throws; ciphertext is never passed on as a PEM);
14
+ * - separate table, so the key can never reach the agent config bundle;
15
+ * - no key rotation.
16
+ * Errors never include the PEM.
17
+ */
18
+
19
+ import type { PrismaClient } from "../prisma/client/client.ts";
20
+ import { type Clock, SystemClock } from "./clock.ts";
21
+ import { ApiError, UnprocessableEntityError } from "./errors.ts";
22
+ import type { TokenCrypto } from "./token-crypto.ts";
23
+
24
+ export const GITHUB_ENTERPRISE_APP_ROLES = ["installer", "lookup"] as const;
25
+ export type GitHubEnterpriseAppRole =
26
+ (typeof GITHUB_ENTERPRISE_APP_ROLES)[number];
27
+
28
+ export interface GitHubEnterpriseAppMetadata {
29
+ role: string;
30
+ appId: string;
31
+ slug: string;
32
+ enterpriseSlug: string | null;
33
+ /** True when an encrypted private key is stored. The key itself is never returned. */
34
+ keySet: boolean;
35
+ lastValidationStatus: string | null;
36
+ lastValidatedAt: Date | null;
37
+ lastValidationError: string | null;
38
+ createdAt: Date;
39
+ updatedAt: Date;
40
+ }
41
+
42
+ export interface SaveGitHubEnterpriseAppInput {
43
+ role: GitHubEnterpriseAppRole;
44
+ appId: string;
45
+ slug: string;
46
+ /** installer only */
47
+ enterpriseSlug?: string | null;
48
+ /** PEM; encrypted before it is stored. */
49
+ privateKey: string;
50
+ }
51
+
52
+ interface GitHubEnterpriseAppRow {
53
+ role: string;
54
+ appId: string;
55
+ slug: string;
56
+ enterpriseSlug: string | null;
57
+ privateKeyEncrypted: string;
58
+ lastValidationStatus: string | null;
59
+ lastValidatedAt: Date | null;
60
+ lastValidationError: string | null;
61
+ createdAt: Date;
62
+ updatedAt: Date;
63
+ }
64
+
65
+ export class PrivateKeyEncryptionUnavailableError extends UnprocessableEntityError {
66
+ constructor() {
67
+ super(
68
+ "SHIPWRIGHT_ENCRYPTION_KEY is not configured; refusing to store a GitHub App private key unencrypted. Set it to a 64-character hex string (openssl rand -hex 32) and retry.",
69
+ );
70
+ this.name = "PrivateKeyEncryptionUnavailableError";
71
+ }
72
+ }
73
+
74
+ export class PrivateKeyDecryptError extends ApiError {
75
+ constructor(role: string) {
76
+ super(
77
+ 500,
78
+ `failed to decrypt the stored private key for GitHub Enterprise App '${role}': the value is corrupt or SHIPWRIGHT_ENCRYPTION_KEY changed; re-save the key`,
79
+ );
80
+ this.name = "PrivateKeyDecryptError";
81
+ }
82
+ }
83
+
84
+ /** Serializer: metadata plus a keySet flag. Never includes key material. */
85
+ export function toGitHubEnterpriseAppMetadata(
86
+ row: GitHubEnterpriseAppRow,
87
+ ): GitHubEnterpriseAppMetadata {
88
+ return {
89
+ role: row.role,
90
+ appId: row.appId,
91
+ slug: row.slug,
92
+ enterpriseSlug: row.enterpriseSlug,
93
+ keySet: row.privateKeyEncrypted.length > 0,
94
+ lastValidationStatus: row.lastValidationStatus,
95
+ lastValidatedAt: row.lastValidatedAt,
96
+ lastValidationError: row.lastValidationError,
97
+ createdAt: row.createdAt,
98
+ updatedAt: row.updatedAt,
99
+ };
100
+ }
101
+
102
+ function assertRole(role: string): asserts role is GitHubEnterpriseAppRole {
103
+ if (!(GITHUB_ENTERPRISE_APP_ROLES as readonly string[]).includes(role)) {
104
+ throw new UnprocessableEntityError(
105
+ `role must be one of: ${GITHUB_ENTERPRISE_APP_ROLES.join(", ")}`,
106
+ );
107
+ }
108
+ }
109
+
110
+ export class GitHubEnterpriseAppService {
111
+ constructor(
112
+ private prisma: PrismaClient,
113
+ /** Strict crypto (see makeStrictTokenCrypto); null when no encryption key is configured. */
114
+ private crypto: TokenCrypto | null,
115
+ private clock: Clock = SystemClock(),
116
+ ) {}
117
+
118
+ /** Create or replace the app for a role. Resets validation state. */
119
+ async save(
120
+ input: SaveGitHubEnterpriseAppInput,
121
+ ): Promise<GitHubEnterpriseAppMetadata> {
122
+ assertRole(input.role);
123
+ if (!this.crypto) throw new PrivateKeyEncryptionUnavailableError();
124
+ if (!input.privateKey) {
125
+ throw new UnprocessableEntityError("privateKey is required");
126
+ }
127
+ const data = {
128
+ appId: input.appId,
129
+ slug: input.slug,
130
+ enterpriseSlug:
131
+ input.role === "installer" ? (input.enterpriseSlug ?? null) : null,
132
+ privateKeyEncrypted: this.crypto.encrypt(input.privateKey),
133
+ lastValidationStatus: null,
134
+ lastValidatedAt: null,
135
+ lastValidationError: null,
136
+ updatedAt: this.clock.now(),
137
+ };
138
+ const row = await this.prisma.gitHubEnterpriseApp.upsert({
139
+ where: { role: input.role },
140
+ create: { role: input.role, ...data },
141
+ update: data,
142
+ });
143
+ return toGitHubEnterpriseAppMetadata(row);
144
+ }
145
+
146
+ async get(
147
+ role: GitHubEnterpriseAppRole,
148
+ ): Promise<GitHubEnterpriseAppMetadata | null> {
149
+ assertRole(role);
150
+ const row = await this.prisma.gitHubEnterpriseApp.findUnique({
151
+ where: { role },
152
+ });
153
+ return row ? toGitHubEnterpriseAppMetadata(row) : null;
154
+ }
155
+
156
+ async list(): Promise<GitHubEnterpriseAppMetadata[]> {
157
+ const rows = await this.prisma.gitHubEnterpriseApp.findMany({
158
+ orderBy: { role: "asc" },
159
+ });
160
+ return rows.map(toGitHubEnterpriseAppMetadata);
161
+ }
162
+
163
+ /** Delete the app for a role. No-ops if absent. */
164
+ async delete(role: GitHubEnterpriseAppRole): Promise<void> {
165
+ assertRole(role);
166
+ await this.prisma.gitHubEnterpriseApp.deleteMany({ where: { role } });
167
+ }
168
+
169
+ /**
170
+ * Server-side only: decrypt the stored PEM. Strict — any failure throws
171
+ * PrivateKeyDecryptError (never includes the PEM or ciphertext). Returns
172
+ * null when the role has no row.
173
+ */
174
+ async getPrivateKey(role: GitHubEnterpriseAppRole): Promise<string | null> {
175
+ assertRole(role);
176
+ if (!this.crypto) throw new PrivateKeyEncryptionUnavailableError();
177
+ const row = await this.prisma.gitHubEnterpriseApp.findUnique({
178
+ where: { role },
179
+ });
180
+ if (!row) return null;
181
+ try {
182
+ return this.crypto.decrypt(row.privateKeyEncrypted);
183
+ } catch {
184
+ throw new PrivateKeyDecryptError(role);
185
+ }
186
+ }
187
+
188
+ /** Record the outcome of a validation attempt. The error text must not contain key material. */
189
+ async recordValidation(
190
+ role: GitHubEnterpriseAppRole,
191
+ result: { status: string; error?: string | null },
192
+ ): Promise<void> {
193
+ assertRole(role);
194
+ await this.prisma.gitHubEnterpriseApp.updateMany({
195
+ where: { role },
196
+ data: {
197
+ lastValidationStatus: result.status,
198
+ lastValidatedAt: this.clock.now(),
199
+ lastValidationError: result.error ?? null,
200
+ updatedAt: this.clock.now(),
201
+ },
202
+ });
203
+ }
204
+ }