@app-vitals/agent-admin 1.3.0 → 1.4.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.
@@ -14,6 +14,7 @@ export type * from './models/SessionFollow.ts'
14
14
  export type * from './models/SessionAlertState.ts'
15
15
  export type * from './models/Agent.ts'
16
16
  export type * from './models/AgentEnv.ts'
17
+ export type * from './models/GitHubEnterpriseApp.ts'
17
18
  export type * from './models/AgentCronJob.ts'
18
19
  export type * from './models/AgentTool.ts'
19
20
  export type * from './models/AgentToken.ts'
@@ -34,4 +35,5 @@ export type * from './models/AgentGitHubInstallationsSnapshot.ts'
34
35
  export type * from './models/PushSubscription.ts'
35
36
  export type * from './models/ChatThreadWatch.ts'
36
37
  export type * from './models/UserNotificationPrefs.ts'
38
+ export type * from './models/AgentMarketplace.ts'
37
39
  export type * from './commonInputTypes.ts'
@@ -0,0 +1,19 @@
1
+ -- PMK-1.1: AgentMarketplace table for per-agent plugin marketplaces (additive only).
2
+ -- CreateTable
3
+ CREATE TABLE "AgentMarketplace" (
4
+ "id" TEXT NOT NULL,
5
+ "agentId" TEXT NOT NULL,
6
+ "name" TEXT NOT NULL,
7
+ "source" TEXT NOT NULL,
8
+ "ref" TEXT,
9
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10
+ "updatedAt" TIMESTAMP(3) NOT NULL,
11
+
12
+ CONSTRAINT "AgentMarketplace_pkey" PRIMARY KEY ("id")
13
+ );
14
+
15
+ -- CreateIndex
16
+ CREATE UNIQUE INDEX "AgentMarketplace_agentId_name_key" ON "AgentMarketplace"("agentId", "name");
17
+
18
+ -- AddForeignKey
19
+ ALTER TABLE "AgentMarketplace" ADD CONSTRAINT "AgentMarketplace_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
1
+ -- GHE-1.1: optional admin-level GitHub Apps (installer / lookup). Additive only: new table.
2
+ -- CreateTable
3
+ CREATE TABLE "GitHubEnterpriseApp" (
4
+ "role" TEXT NOT NULL,
5
+ "appId" TEXT NOT NULL,
6
+ "slug" TEXT NOT NULL,
7
+ "enterpriseSlug" TEXT,
8
+ "privateKeyEncrypted" TEXT NOT NULL,
9
+ "lastValidationStatus" TEXT,
10
+ "lastValidatedAt" TIMESTAMP(3),
11
+ "lastValidationError" TEXT,
12
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
13
+ "updatedAt" TIMESTAMP(3) NOT NULL,
14
+
15
+ CONSTRAINT "GitHubEnterpriseApp_pkey" PRIMARY KEY ("role")
16
+ );
@@ -1,5 +1,6 @@
1
1
  // Shipwright — Admin Service Schema
2
2
  // Owns: Agent, AgentEnv, AgentCronJob, AgentTool, AgentToken, AgentPlugin,
3
+ // AgentMarketplace,
3
4
  // plus the coding-owned models in coding.prisma (AgentWorkQueueSnapshot,
4
5
  // AgentPhaseMethodology, SessionFollow, SessionAlertState).
5
6
  // Agent is a first-class model (standalone, not embedded in a User model).
@@ -88,6 +89,7 @@ model Agent {
88
89
  tools AgentTool[]
89
90
  tokens AgentToken[]
90
91
  plugins AgentPlugin[]
92
+ marketplaces AgentMarketplace[]
91
93
  members AgentMember[]
92
94
  chatTokenUsage AgentChatTokenUsageDailyByModel[]
93
95
  workQueueSnapshot AgentWorkQueueSnapshot?
@@ -114,6 +116,24 @@ model AgentEnv {
114
116
  @@unique([agentId, key])
115
117
  }
116
118
 
119
+ // ─── GitHub Enterprise Apps ───────────────────────────────────────────────────
120
+ // Optional admin-level GitHub Apps, one per role ('installer' | 'lookup'), at most
121
+ // two rows per deployment; no rows means the feature is off. Deliberately NOT in
122
+ // AgentEnv so the private key can never reach the agent config bundle.
123
+
124
+ model GitHubEnterpriseApp {
125
+ role String @id // 'installer' | 'lookup'
126
+ appId String
127
+ slug String
128
+ enterpriseSlug String? // installer only
129
+ privateKeyEncrypted String // AES-256-GCM (iv:ciphertext:authTag); never returned by any read path
130
+ lastValidationStatus String?
131
+ lastValidatedAt DateTime?
132
+ lastValidationError String?
133
+ createdAt DateTime @default(now())
134
+ updatedAt DateTime @updatedAt
135
+ }
136
+
117
137
  // ─── Agent Cron Jobs ──────────────────────────────────────────────────────────
118
138
  // Scheduled prompts per agent.
119
139
  // Delivery target is either a Slack channel ID or a Slack user ID (DM).
@@ -569,3 +589,20 @@ model UserNotificationPrefs {
569
589
  updatedAt DateTime @updatedAt
570
590
  }
571
591
 
592
+
593
+ // ─── AgentMarketplace ─────────────────────────────────────────────────────────
594
+ // Plugin marketplaces registered per agent. `name` is the marketplace name that
595
+ // AgentPlugin specs reference after the `@` (e.g. "plugin@<name>").
596
+
597
+ model AgentMarketplace {
598
+ id String @id @default(cuid())
599
+ agentId String
600
+ agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
601
+ name String
602
+ source String
603
+ ref String?
604
+ createdAt DateTime @default(now())
605
+ updatedAt DateTime @updatedAt
606
+
607
+ @@unique([agentId, name])
608
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * admin/src/agent-marketplaces.integration.test.ts
3
+ * Integration tests for the AgentMarketplace model against a real PostgreSQL DB.
4
+ *
5
+ * Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
9
+ import type { PrismaClient } from "../prisma/client/client.ts";
10
+ import { createAdminPrismaClient } from "./prisma-client.ts";
11
+
12
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
13
+
14
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
15
+
16
+ describeOrSkip("AgentMarketplace (integration)", () => {
17
+ let prisma: PrismaClient;
18
+
19
+ beforeEach(async () => {
20
+ prisma = createAdminPrismaClient(TEST_DB as string);
21
+ await prisma.agentMarketplace.deleteMany();
22
+ await prisma.agentPlugin.deleteMany();
23
+ await prisma.agentToken.deleteMany();
24
+ await prisma.agentCronJob.deleteMany();
25
+ await prisma.agentTool.deleteMany();
26
+ await prisma.agentEnv.deleteMany();
27
+ await prisma.agent.deleteMany();
28
+ });
29
+
30
+ afterEach(async () => {
31
+ await prisma.$disconnect();
32
+ });
33
+
34
+ async function createAgent(): Promise<string> {
35
+ return (await prisma.agent.create({ data: { name: "Test Agent" } })).id;
36
+ }
37
+
38
+ it("inserts a row and reads it back", async () => {
39
+ const agentId = await createAgent();
40
+ const created = await prisma.agentMarketplace.create({
41
+ data: {
42
+ agentId,
43
+ name: "shipwright",
44
+ source: "github:app-vitals/shipwright",
45
+ },
46
+ });
47
+ const found = await prisma.agentMarketplace.findUnique({
48
+ where: { id: created.id },
49
+ });
50
+ expect(found?.agentId).toBe(agentId);
51
+ expect(found?.name).toBe("shipwright");
52
+ expect(found?.source).toBe("github:app-vitals/shipwright");
53
+ expect(found?.ref).toBeNull();
54
+ expect(found?.createdAt).toBeInstanceOf(Date);
55
+ expect(found?.updatedAt).toBeInstanceOf(Date);
56
+ });
57
+
58
+ it("rejects a duplicate (agentId, name)", async () => {
59
+ const agentId = await createAgent();
60
+ await prisma.agentMarketplace.create({
61
+ data: { agentId, name: "dup", source: "a" },
62
+ });
63
+ await expect(
64
+ (async () =>
65
+ prisma.agentMarketplace.create({
66
+ data: { agentId, name: "dup", source: "b", ref: "main" },
67
+ }))(),
68
+ ).rejects.toThrow();
69
+ });
70
+
71
+ it("cascades when the agent is deleted", async () => {
72
+ const agentId = await createAgent();
73
+ await prisma.agentMarketplace.create({
74
+ data: { agentId, name: "m1", source: "a" },
75
+ });
76
+ await prisma.agentMarketplace.create({
77
+ data: { agentId, name: "m2", source: "b", ref: "v1" },
78
+ });
79
+ await prisma.agent.delete({ where: { id: agentId } });
80
+ expect(await prisma.agentMarketplace.count({ where: { agentId } })).toBe(0);
81
+ });
82
+ });
@@ -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
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Unit tests for the GitHubEnterpriseApp serializer: metadata plus a
3
+ * "key set" flag only, never the private key (plain or encrypted).
4
+ */
5
+
6
+ import { describe, expect, it } from "bun:test";
7
+ import { toGitHubEnterpriseAppMetadata } from "./github-enterprise-apps.ts";
8
+
9
+ describe("toGitHubEnterpriseAppMetadata", () => {
10
+ const row = {
11
+ role: "installer",
12
+ appId: "123",
13
+ slug: "my-app",
14
+ enterpriseSlug: "acme",
15
+ privateKeyEncrypted: "aa:bb:cc",
16
+ lastValidationStatus: "ok",
17
+ lastValidatedAt: new Date("2026-01-01T00:00:00Z"),
18
+ lastValidationError: null,
19
+ createdAt: new Date("2026-01-01T00:00:00Z"),
20
+ updatedAt: new Date("2026-01-02T00:00:00Z"),
21
+ };
22
+
23
+ it("returns metadata and keySet, with no key material", () => {
24
+ const out = toGitHubEnterpriseAppMetadata(row);
25
+ expect(out.keySet).toBe(true);
26
+ expect(out).toMatchObject({
27
+ role: "installer",
28
+ appId: "123",
29
+ slug: "my-app",
30
+ });
31
+ expect(Object.keys(out).some((k) => /key/i.test(k) && k !== "keySet")).toBe(
32
+ false,
33
+ );
34
+ expect(JSON.stringify(out)).not.toContain("aa:bb:cc");
35
+ });
36
+
37
+ it("keySet is false when no ciphertext is stored", () => {
38
+ expect(
39
+ toGitHubEnterpriseAppMetadata({ ...row, privateKeyEncrypted: "" }).keySet,
40
+ ).toBe(false);
41
+ });
42
+ });
@@ -58,3 +58,28 @@ export function makeTokenCrypto(
58
58
  },
59
59
  };
60
60
  }
61
+
62
+ /**
63
+ * Strict TokenCrypto for credentials that must never be stored or passed on as
64
+ * plain text (e.g. GitHub Enterprise App private keys).
65
+ *
66
+ * Differences from makeTokenCrypto(): returns null when no key is configured
67
+ * (callers must refuse to save, not fall back to identity), and decrypt()
68
+ * throws on any failure instead of returning the stored value as legacy
69
+ * plain text. A malformed key still throws at construction. Error messages
70
+ * never include the input value.
71
+ */
72
+ export function makeStrictTokenCrypto(
73
+ key = process.env.SHIPWRIGHT_ENCRYPTION_KEY,
74
+ ): TokenCrypto | null {
75
+ if (!key) return null;
76
+ if (!isValidKeyHex(key)) {
77
+ throw new Error(
78
+ `SHIPWRIGHT_ENCRYPTION_KEY must be a 64-character hex string (32 bytes for AES-256-GCM), but got ${key.length} character(s). Generate one with: openssl rand -hex 32`,
79
+ );
80
+ }
81
+ return {
82
+ encrypt: (token) => encrypt(token, key),
83
+ decrypt: (encrypted) => decrypt(encrypted, key),
84
+ };
85
+ }
@@ -6,7 +6,11 @@
6
6
 
7
7
  import { afterEach, describe, expect, it } from "bun:test";
8
8
  import { decrypt, encrypt, isValidKeyHex } from "./crypto.ts";
9
- import { identityCrypto, makeTokenCrypto } from "./token-crypto.ts";
9
+ import {
10
+ identityCrypto,
11
+ makeStrictTokenCrypto,
12
+ makeTokenCrypto,
13
+ } from "./token-crypto.ts";
10
14
 
11
15
  // ─── Test key ─────────────────────────────────────────────────────────────────
12
16
  // A known 64-char hex key (32 bytes) for deterministic tests.
@@ -180,3 +184,22 @@ describe("identityCrypto", () => {
180
184
  expect(identityCrypto.decrypt("hello")).toBe("hello");
181
185
  });
182
186
  });
187
+
188
+ describe("makeStrictTokenCrypto", () => {
189
+ const KEY = "c".repeat(64);
190
+
191
+ it("returns null when no key is configured (no identity fallback)", () => {
192
+ expect(makeStrictTokenCrypto(undefined)).toBeNull();
193
+ expect(makeStrictTokenCrypto("")).toBeNull();
194
+ });
195
+
196
+ it("throws on a malformed key", () => {
197
+ expect(() => makeStrictTokenCrypto("short")).toThrow(/64-character/);
198
+ });
199
+
200
+ it("round-trips and throws on non-ciphertext instead of passing it through", () => {
201
+ const c = makeStrictTokenCrypto(KEY);
202
+ expect(c?.decrypt(c.encrypt("secret"))).toBe("secret");
203
+ expect(() => c?.decrypt("legacy-plain-text")).toThrow();
204
+ });
205
+ });