@app-vitals/agent-admin 1.2.7 → 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/src/agents-api.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  } from "./account-agent-create.ts";
42
42
  import {
43
43
  reconcileAgentAfterAccountChange,
44
+ reconcileAgentAfterSpecialtyChange,
44
45
  reconcileAgentAfterPodServiceAccountChange,
45
46
  } from "./agent-account-assignment.ts";
46
47
  import {
@@ -623,7 +624,7 @@ const patchAgentRoute = createRoute({
623
624
  path: "/agents/{id}",
624
625
  summary: "Update an agent",
625
626
  description:
626
- "Admin-only. Updates `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `slackId`, `trialExpiresAt`, `accountId` (platform admin only — 403 for session members; `null` clears it; reconciles the workload of in-cluster agents; does not move historical task-store rows), and/or `podServiceAccountName` (platform admin only — 403 otherwise; `null` clears it; 409 unless SHIPWRIGHT_K8S_AGENT_POD_SERVICE_ACCOUNT_ENABLED is on; 422 for agents with a per-agent federated identity; reconciles the workload so the pod is recreated under the new ServiceAccount). `specialty` and `trialExpiryWarnedAt` are not updatable via this route. `repos` here REPLACES the whole list; for incremental changes use `POST /agents/{id}/repos` (add) and `DELETE /agents/{id}/repos/{owner}/{name}` (remove). Returns the updated agent, including a `warning` field when `restrictSlackToMembers` is set true on an agent with zero members.",
627
+ "Admin-only. Updates `selfHosted`, `repos`, `reviewAuthorAllowlist`, `patchAuthorAllowlist`, `restrictSlackToMembers`, `slackId`, `trialExpiresAt`, `accountId` (platform admin only — 403 for session members; `null` clears it; reconciles the workload of in-cluster agents; does not move historical task-store rows), and/or `podServiceAccountName` (platform admin only — 403 otherwise; `null` clears it; 409 unless SHIPWRIGHT_K8S_AGENT_POD_SERVICE_ACCOUNT_ENABLED is on; 422 for agents with a per-agent federated identity; reconciles the workload so the pod is recreated under the new ServiceAccount). `specialty` (platform admin only — 403 otherwise; 422 when the type is unknown; a no-op when unchanged; otherwise persists the new type, reconciles the agent's system crons against it — system crons the new type does not declare are deleted, user crons survive — and reconciles the workload of in-cluster agents so the pod runs the image mapped to the new specialty). `trialExpiryWarnedAt` is not updatable via this route. `repos` here REPLACES the whole list; for incremental changes use `POST /agents/{id}/repos` (add) and `DELETE /agents/{id}/repos/{owner}/{name}` (remove). Returns the updated agent, including a `warning` field when `restrictSlackToMembers` is set true on an agent with zero members.",
627
628
  request: {
628
629
  params: AgentIdParamSchema,
629
630
  body: {
@@ -645,7 +646,7 @@ const patchAgentRoute = createRoute({
645
646
  },
646
647
  422: {
647
648
  description:
648
- "podServiceAccountName supplied for an agent with a per-agent federated identity",
649
+ "podServiceAccountName supplied for an agent with a per-agent federated identity, or `specialty` naming an unknown type",
649
650
  ...jsonError,
650
651
  },
651
652
  },
@@ -1688,6 +1689,25 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1688
1689
  throw new ForbiddenError("Admin access required to change accountId");
1689
1690
  }
1690
1691
  }
1692
+ // SAL-1.2: specialty change — platform-admin only, known type only.
1693
+ let previousSpecialty: string | undefined;
1694
+ if (body.specialty !== undefined) {
1695
+ if (c.get("isAdmin") !== true) {
1696
+ throw new ForbiddenError("Admin access required to change specialty");
1697
+ }
1698
+ if (!specialtyRegistry.tryGetType(body.specialty)) {
1699
+ throw new UnprocessableEntityError(
1700
+ `unknown specialty "${body.specialty}"`,
1701
+ );
1702
+ }
1703
+ const current = await agentService.getDetail(agentId);
1704
+ if (!current) {
1705
+ throw new NotFoundError(`agent ${agentId} not found`);
1706
+ }
1707
+ if (current.specialty !== body.specialty) {
1708
+ previousSpecialty = current.specialty;
1709
+ }
1710
+ }
1691
1711
  // COS-49.1: pod ServiceAccount — platform-admin only, flag-gated, and not
1692
1712
  // for agents whose federated identity owns the ServiceAccount.
1693
1713
  let previousPodServiceAccountName: string | null = null;
@@ -1733,6 +1753,9 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1733
1753
  ? { podServiceAccountName: body.podServiceAccountName }
1734
1754
  : {}),
1735
1755
  ...(body.accountId !== undefined ? { accountId: body.accountId } : {}),
1756
+ ...(previousSpecialty !== undefined
1757
+ ? { specialty: body.specialty }
1758
+ : {}),
1736
1759
  ...(body.repos !== undefined ? { repos: body.repos } : {}),
1737
1760
  ...(body.reviewAuthorAllowlist !== undefined
1738
1761
  ? { reviewAuthorAllowlist: body.reviewAuthorAllowlist }
@@ -1786,6 +1809,28 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1786
1809
  body.accountId !== undefined
1787
1810
  ? await reconcileAgentAfterAccountChange(provisioner, agent)
1788
1811
  : undefined;
1812
+ let specialtyWarning: string | undefined;
1813
+ if (previousSpecialty !== undefined) {
1814
+ // Audit: ids and names only — never the caller's key.
1815
+ console.info(
1816
+ `[agents-api] specialty changed: agent=${agentId} old=${previousSpecialty} new=${agent.specialty} caller=${c.get("caller")?.name ?? "anonymous"}`,
1817
+ );
1818
+ const warnings: string[] = [];
1819
+ try {
1820
+ await agentCronJobService.reconcileSystemCrons(agentId);
1821
+ } catch (err) {
1822
+ const message = err instanceof Error ? err.message : String(err);
1823
+ warnings.push(
1824
+ `specialty updated, but reconciling system crons failed: ${message}`,
1825
+ );
1826
+ }
1827
+ const workloadWarning = await reconcileAgentAfterSpecialtyChange(
1828
+ provisioner,
1829
+ agent,
1830
+ );
1831
+ if (workloadWarning) warnings.push(workloadWarning);
1832
+ specialtyWarning = warnings.join("; ") || undefined;
1833
+ }
1789
1834
  let podSaWarning: string | undefined;
1790
1835
  if (body.podServiceAccountName !== undefined) {
1791
1836
  // Audit: ids and names only — never the caller's key.
@@ -1801,7 +1846,7 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1801
1846
  }
1802
1847
  }
1803
1848
  const warning =
1804
- [slackWarning, reconcileWarning, podSaWarning]
1849
+ [slackWarning, reconcileWarning, specialtyWarning, podSaWarning]
1805
1850
  .filter(Boolean)
1806
1851
  .join("; ") || undefined;
1807
1852
  return c.json(serializeAgent(agent, warning), 200);
package/src/agents.ts CHANGED
@@ -140,6 +140,8 @@ export interface AgentDetail extends Partial<AgentAnthropicIdentity> {
140
140
  }
141
141
 
142
142
  interface UpdateSelfHostedInput {
143
+ /** SAL-1.2: the agent's specialty; callers validate it against the registry. */
144
+ specialty?: string;
143
145
  /** COS-49.1: pod ServiceAccount; null clears it. */
144
146
  podServiceAccountName?: string | null;
145
147
  /** SSP-5.3: owning account; null clears it (unassigned/platform agent). */
@@ -475,6 +477,9 @@ export class AgentService {
475
477
  where: { id },
476
478
  data: {
477
479
  selfHosted: input.selfHosted,
480
+ ...(input.specialty !== undefined
481
+ ? { specialty: input.specialty }
482
+ : {}),
478
483
  ...(input.accountId !== undefined
479
484
  ? { accountId: input.accountId }
480
485
  : {}),
@@ -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
+ });
@@ -52,6 +52,10 @@ export const PatchAgentBodySchema = z
52
52
  .optional()
53
53
  .openapi({ example: "U0AALR8M69X" }),
54
54
  selfHosted: z.boolean().optional().openapi({ example: false }),
55
+ /**
56
+ * SAL-1.2: platform-admin only. Must name a known specialty type.
57
+ */
58
+ specialty: z.string().min(1).optional().openapi({ example: "specialist" }),
55
59
  repos: z
56
60
  .array(
57
61
  z.string().refine(isOrgRepo, {
@@ -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
+ });