@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.
- package/package.json +2 -2
- package/prisma/client/browser.ts +10 -0
- package/prisma/client/client.ts +10 -0
- package/prisma/client/internal/class.ts +24 -4
- package/prisma/client/internal/prismaNamespace.ts +183 -2
- package/prisma/client/internal/prismaNamespaceBrowser.ts +32 -1
- package/prisma/client/models/Agent.ts +282 -0
- package/prisma/client/models/AgentMarketplace.ts +1421 -0
- package/prisma/client/models/GitHubEnterpriseApp.ts +1289 -0
- package/prisma/client/models.ts +2 -0
- package/prisma/migrations/20261101000000_add_agent_marketplace/migration.sql +19 -0
- package/prisma/migrations/20261109000000_add_github_enterprise_app/migration.sql +16 -0
- package/prisma/schema/schema.prisma +37 -0
- package/src/agent-marketplaces.integration.test.ts +82 -0
- package/src/github-enterprise-apps.integration.test.ts +152 -0
- package/src/github-enterprise-apps.ts +204 -0
- package/src/github-enterprise-apps.unit.test.ts +42 -0
- package/src/token-crypto.ts +25 -0
- package/src/token-crypto.unit.test.ts +24 -1
package/prisma/client/models.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/token-crypto.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
});
|