@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.
@@ -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
+ }
package/src/admin-boot.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  * They use non-overlapping sub-paths so Hono resolves them correctly:
24
24
  * runtime → GET /agents/:id/config, GET /agents/:id/crons
25
25
  * admin → GET /agents/:id/crons/summary (enriched with run stats)
26
- * POST/PATCH/DELETE /agents/:id/envs, /crons, /tools, /tokens, /plugins
26
+ * POST/PATCH/DELETE /agents/:id/envs, /crons, /tools, /tokens, /plugins, /marketplaces
27
27
  *
28
28
  * This file must not import any coding module — main.ts is the only file
29
29
  * that wires a concrete extension in.
@@ -80,6 +80,7 @@ import { AgentCronRunStatsService } from "./agent-cron-run-stats.ts";
80
80
  import { AgentCronRunService } from "./agent-cron-runs.ts";
81
81
  import { AgentEnvService } from "./agent-envs.ts";
82
82
  import { AgentGitHubInstallationsService } from "./agent-github-installations.ts";
83
+ import { AgentMarketplaceService } from "./agent-marketplaces.ts";
83
84
  import { AgentMemberService } from "./agent-members.ts";
84
85
  import { AgentPluginService } from "./agent-plugins.ts";
85
86
  import { AgentTokenService } from "./agent-tokens.ts";
@@ -197,6 +198,7 @@ export async function startAdmin(
197
198
  const agentToolService = new AgentToolService(prisma);
198
199
  const agentTokenService = new AgentTokenService(prisma);
199
200
  const agentPluginService = new AgentPluginService(prisma);
201
+ const agentMarketplaceService = new AgentMarketplaceService(prisma);
200
202
  const agentMemberService = new AgentMemberService(prisma);
201
203
  // Account-aware caller scope (SSP-2.1). Account membership contributes
202
204
  // agents only when the self-serve flag is on; off → AgentMember-only.
@@ -433,6 +435,7 @@ export async function startAdmin(
433
435
  agentCronJobService,
434
436
  agentService,
435
437
  agentPluginService,
438
+ agentMarketplaceService,
436
439
  ...(resolvedExtensions.agentPhaseMethodologyService
437
440
  ? {
438
441
  agentPhaseMethodologyService:
@@ -461,6 +464,7 @@ export async function startAdmin(
461
464
  agentToolService,
462
465
  agentTokenService,
463
466
  agentPluginService,
467
+ agentMarketplaceService,
464
468
  agentMemberService,
465
469
  callerScopeResolver,
466
470
  specialtyRegistry,
@@ -0,0 +1,263 @@
1
+ /**
2
+ * admin/src/agent-marketplaces-api.integration.test.ts
3
+ * Integration tests for AgentMarketplaceService, the /agents/{id}/marketplaces
4
+ * routes and the `marketplaces` field of GET /agents/{id}/config, against a
5
+ * real PostgreSQL DB.
6
+ *
7
+ * Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
11
+ import type { PrismaClient } from "../prisma/client/client.ts";
12
+ import { AgentMarketplaceService } from "./agent-marketplaces.ts";
13
+ import { AgentTokenService } from "./agent-tokens.ts";
14
+ import { AgentService } from "./agents.ts";
15
+ import {
16
+ type AdminDeps,
17
+ createAdminApp,
18
+ parseAdminApiKeys,
19
+ } from "./agents-api.ts";
20
+ import { createAgentRuntimeApp } from "./api.ts";
21
+ import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
22
+ import { createAdminPrismaClient } from "./prisma-client.ts";
23
+
24
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
25
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
26
+
27
+ const SESSION_SECRET = "test-admin-session-secret-32-bytes!";
28
+ const ADMIN_KEY = "admin-key-token";
29
+
30
+ describeOrSkip("AgentMarketplaceService + routes (integration)", () => {
31
+ let prisma: PrismaClient;
32
+ let service: AgentMarketplaceService;
33
+ let tokens: AgentTokenService;
34
+ let adminApp: ReturnType<typeof createAdminApp>;
35
+ let runtimeApp: ReturnType<typeof createAgentRuntimeApp>;
36
+ let agentA: string;
37
+ let agentB: string;
38
+ let tokenA: string;
39
+ let tokenB: string;
40
+
41
+ const adminKeys = parseAdminApiKeys(`root:${ADMIN_KEY}:*`);
42
+ const json = (token: string, body?: unknown, method = "POST") => ({
43
+ method,
44
+ headers: {
45
+ Authorization: `Bearer ${token}`,
46
+ "Content-Type": "application/json",
47
+ },
48
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
49
+ });
50
+ const get = (token: string) => ({
51
+ headers: { Authorization: `Bearer ${token}` },
52
+ });
53
+
54
+ beforeEach(async () => {
55
+ prisma = createAdminPrismaClient(TEST_DB as string);
56
+ await prisma.agentMarketplace.deleteMany();
57
+ await prisma.agentPlugin.deleteMany();
58
+ await prisma.agentToken.deleteMany();
59
+ await prisma.agentCronJob.deleteMany();
60
+ await prisma.agentTool.deleteMany();
61
+ await prisma.agentEnv.deleteMany();
62
+ await prisma.agentMember.deleteMany();
63
+ await prisma.agent.deleteMany();
64
+
65
+ agentA = (await prisma.agent.create({ data: { name: "A" } })).id;
66
+ agentB = (await prisma.agent.create({ data: { name: "B" } })).id;
67
+ service = new AgentMarketplaceService(prisma);
68
+ tokens = new AgentTokenService(prisma);
69
+ tokenA = (await tokens.create(agentA)).rawToken;
70
+ tokenB = (await tokens.create(agentB)).rawToken;
71
+
72
+ adminApp = createAdminApp({
73
+ agentTokenService: tokens,
74
+ agentMemberService: { exists: async () => false },
75
+ agentMarketplaceService: service,
76
+ prisma,
77
+ sessionSecret: SESSION_SECRET,
78
+ adminApiKeys: adminKeys,
79
+ } as unknown as AdminDeps);
80
+
81
+ runtimeApp = createAgentRuntimeApp({
82
+ agentEnvService: { getConfigBundle: async () => null },
83
+ agentCronJobService: { list: async () => [] },
84
+ agentService: new AgentService(prisma),
85
+ agentPluginService: { listEnabled: async () => [] },
86
+ agentMarketplaceService: service,
87
+ sessionSecret: SESSION_SECRET,
88
+ adminApiKeys: adminKeys,
89
+ agentTokenService: tokens,
90
+ } as never);
91
+ });
92
+
93
+ afterEach(async () => {
94
+ await prisma.$disconnect();
95
+ });
96
+
97
+ describe("service", () => {
98
+ it("upserts, lists in creation order, and re-points an existing name", async () => {
99
+ const first = await service.upsert(agentA, {
100
+ name: "shipwright",
101
+ source: "app-vitals/shipwright",
102
+ });
103
+ expect(first.ref).toBeNull();
104
+ await service.upsert(agentA, {
105
+ name: "other",
106
+ source: "https://github.com/o/r.git",
107
+ ref: "v1",
108
+ });
109
+ const again = await service.upsert(agentA, {
110
+ name: "shipwright",
111
+ source: "app-vitals/shipwright-fork",
112
+ ref: "main",
113
+ });
114
+ expect(again.id).toBe(first.id);
115
+
116
+ const rows = await service.list(agentA);
117
+ expect(rows.map((r) => r.name)).toEqual(["shipwright", "other"]);
118
+ expect(rows[0]?.source).toBe("app-vitals/shipwright-fork");
119
+ expect(rows[0]?.ref).toBe("main");
120
+ expect(await service.list(agentB)).toEqual([]);
121
+ });
122
+
123
+ it("same name on two agents are independent rows", async () => {
124
+ await service.upsert(agentA, { name: "m", source: "o/a" });
125
+ await service.upsert(agentB, { name: "m", source: "o/b" });
126
+ expect((await service.list(agentA))[0]?.source).toBe("o/a");
127
+ expect((await service.list(agentB))[0]?.source).toBe("o/b");
128
+ });
129
+
130
+ it("rejects an invalid source without writing", async () => {
131
+ await expect(
132
+ service.upsert(agentA, { name: "m", source: "http://x.com/o/r" }),
133
+ ).rejects.toBeInstanceOf(UnprocessableEntityError);
134
+ expect(await service.list(agentA)).toEqual([]);
135
+ });
136
+
137
+ it("remove deletes an owned row and 404s on another agent's row", async () => {
138
+ const row = await service.upsert(agentA, { name: "m", source: "o/r" });
139
+ await expect(service.remove(agentB, row.id)).rejects.toBeInstanceOf(
140
+ NotFoundError,
141
+ );
142
+ expect(await service.list(agentA)).toHaveLength(1);
143
+ await service.remove(agentA, row.id);
144
+ expect(await service.list(agentA)).toEqual([]);
145
+ await expect(service.remove(agentA, row.id)).rejects.toBeInstanceOf(
146
+ NotFoundError,
147
+ );
148
+ });
149
+ });
150
+
151
+ describe("routes", () => {
152
+ it("POST upserts (201), GET lists, DELETE removes (204)", async () => {
153
+ const created = await adminApp.request(
154
+ `/agents/${agentA}/marketplaces`,
155
+ json(tokenA, {
156
+ name: "shipwright",
157
+ source: "app-vitals/shipwright",
158
+ ref: "main",
159
+ }),
160
+ );
161
+ expect(created.status).toBe(201);
162
+ const { marketplace } = await created.json();
163
+ expect(marketplace).toMatchObject({
164
+ agentId: agentA,
165
+ name: "shipwright",
166
+ source: "app-vitals/shipwright",
167
+ ref: "main",
168
+ });
169
+
170
+ const listed = await adminApp.request(
171
+ `/agents/${agentA}/marketplaces`,
172
+ get(ADMIN_KEY),
173
+ );
174
+ expect(listed.status).toBe(200);
175
+ expect((await listed.json()).marketplaces).toHaveLength(1);
176
+
177
+ const del = await adminApp.request(
178
+ `/agents/${agentA}/marketplaces/${marketplace.id}`,
179
+ json(tokenA, undefined, "DELETE"),
180
+ );
181
+ expect(del.status).toBe(204);
182
+ expect(await service.list(agentA)).toEqual([]);
183
+ });
184
+
185
+ it("platform admin key can write and read any agent", async () => {
186
+ const res = await adminApp.request(
187
+ `/agents/${agentB}/marketplaces`,
188
+ json(ADMIN_KEY, { name: "m", source: "o/r" }),
189
+ );
190
+ expect(res.status).toBe(201);
191
+ });
192
+
193
+ it("invalid source returns 422 and writes nothing", async () => {
194
+ for (const source of [
195
+ "http://x.com/o/r",
196
+ "not-a-source",
197
+ "git@github.com:o/r.git",
198
+ "",
199
+ ]) {
200
+ const res = await adminApp.request(
201
+ `/agents/${agentA}/marketplaces`,
202
+ json(tokenA, { name: "m", source }),
203
+ );
204
+ expect(res.status).toBe(422);
205
+ }
206
+ expect(await service.list(agentA)).toEqual([]);
207
+ });
208
+
209
+ it("deleting another agent's row returns 404 and leaves it", async () => {
210
+ const row = await service.upsert(agentB, { name: "m", source: "o/r" });
211
+ const res = await adminApp.request(
212
+ `/agents/${agentA}/marketplaces/${row.id}`,
213
+ json(tokenA, undefined, "DELETE"),
214
+ );
215
+ expect(res.status).toBe(404);
216
+ expect(await service.list(agentB)).toHaveLength(1);
217
+ });
218
+
219
+ it("another agent's key is 403 on every method; no credential is 401", async () => {
220
+ const row = await service.upsert(agentA, { name: "m", source: "o/r" });
221
+ const post = await adminApp.request(
222
+ `/agents/${agentA}/marketplaces`,
223
+ json(tokenB, { name: "x", source: "o/r" }),
224
+ );
225
+ const list = await adminApp.request(
226
+ `/agents/${agentA}/marketplaces`,
227
+ get(tokenB),
228
+ );
229
+ const del = await adminApp.request(
230
+ `/agents/${agentA}/marketplaces/${row.id}`,
231
+ json(tokenB, undefined, "DELETE"),
232
+ );
233
+ expect([post.status, list.status, del.status]).toEqual([403, 403, 403]);
234
+ expect(
235
+ (await adminApp.request(`/agents/${agentA}/marketplaces`)).status,
236
+ ).toBe(401);
237
+ expect(await service.list(agentA)).toHaveLength(1);
238
+ });
239
+ });
240
+
241
+ describe("GET /agents/:id/config", () => {
242
+ it("includes marketplaces: [] when none", async () => {
243
+ const res = await runtimeApp.request(`/${agentA}/config`, get(tokenA));
244
+ expect(res.status).toBe(200);
245
+ expect((await res.json()).marketplaces).toEqual([]);
246
+ });
247
+
248
+ it("includes name/source/ref for the agent's own rows only", async () => {
249
+ await service.upsert(agentA, { name: "one", source: "o/r", ref: "v1" });
250
+ await service.upsert(agentA, {
251
+ name: "two",
252
+ source: "https://h.example/o/r",
253
+ });
254
+ await service.upsert(agentB, { name: "theirs", source: "o/x" });
255
+ const res = await runtimeApp.request(`/${agentA}/config`, get(tokenA));
256
+ expect(res.status).toBe(200);
257
+ expect((await res.json()).marketplaces).toEqual([
258
+ { name: "one", source: "o/r", ref: "v1" },
259
+ { name: "two", source: "https://h.example/o/r", ref: null },
260
+ ]);
261
+ });
262
+ });
263
+ });
@@ -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,120 @@
1
+ /**
2
+ * admin/src/agent-marketplaces.ts
3
+ * AgentMarketplaceService — CRUD for remote Claude Code plugin marketplaces
4
+ * per agent.
5
+ *
6
+ * A marketplace is identified by `name` (the name plugins reference after the
7
+ * "@" in "<plugin>@<marketplace>") and points at a `source`: a GitHub
8
+ * "owner/repo" or an https git URL, optionally pinned to a `ref`. The unique
9
+ * constraint on [agentId, name] makes two versions of one marketplace
10
+ * impossible; upsert() re-points an existing name.
11
+ *
12
+ * Input is validated here (422) rather than in the Zod request schema, which
13
+ * would answer 400: `source` and `ref` end up as arguments to `git`/`claude`
14
+ * in the runtime, so anything that could be read as an option, a local path,
15
+ * or a credential-bearing URL is refused.
16
+ */
17
+
18
+ import type {
19
+ AgentMarketplace,
20
+ PrismaClient,
21
+ } from "../prisma/client/client.ts";
22
+ import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
23
+
24
+ export type { AgentMarketplace };
25
+
26
+ const GITHUB_SHORTHAND =
27
+ /^[A-Za-z0-9_][A-Za-z0-9_.-]*\/[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
28
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
29
+ const REF = /^[A-Za-z0-9_][A-Za-z0-9_./-]*$/;
30
+ const HOST = /^[A-Za-z0-9][A-Za-z0-9.-]*$/;
31
+
32
+ /** Throws UnprocessableEntityError unless `source` is `owner/repo` or an https git URL. */
33
+ export function validateMarketplaceSource(source: string): void {
34
+ if (GITHUB_SHORTHAND.test(source)) return;
35
+ if (source.startsWith("https://") && !/\s/.test(source)) {
36
+ try {
37
+ const url = new URL(source);
38
+ if (
39
+ url.protocol === "https:" &&
40
+ HOST.test(url.hostname) &&
41
+ url.username === "" &&
42
+ url.password === "" &&
43
+ url.pathname.length > 1
44
+ ) {
45
+ return;
46
+ }
47
+ } catch {
48
+ // fall through to the 422 below
49
+ }
50
+ }
51
+ throw new UnprocessableEntityError(
52
+ "source must be a GitHub 'owner/repo' or an https git URL without credentials",
53
+ );
54
+ }
55
+
56
+ /** Throws UnprocessableEntityError unless `name` is a plain marketplace name. */
57
+ export function validateMarketplaceName(name: string): void {
58
+ if (!NAME.test(name)) {
59
+ throw new UnprocessableEntityError(
60
+ "name must start with a letter or digit and contain only letters, digits, '.', '_' and '-'",
61
+ );
62
+ }
63
+ }
64
+
65
+ /** Throws UnprocessableEntityError for a ref that is not a plain branch, tag or sha. */
66
+ export function validateMarketplaceRef(ref: string | null | undefined): void {
67
+ if (ref === undefined || ref === null) return;
68
+ if (!REF.test(ref) || ref.includes("..")) {
69
+ throw new UnprocessableEntityError(
70
+ "ref must be a plain branch, tag or sha",
71
+ );
72
+ }
73
+ }
74
+
75
+ export class AgentMarketplaceService {
76
+ constructor(private prisma: PrismaClient) {}
77
+
78
+ /** List all marketplaces for a given agent, ordered by createdAt. */
79
+ async list(agentId: string): Promise<AgentMarketplace[]> {
80
+ return this.prisma.agentMarketplace.findMany({
81
+ where: { agentId },
82
+ orderBy: { createdAt: "asc" },
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Add a marketplace for the agent, or re-point the existing one with the
88
+ * same name at a new source/ref. Throws UnprocessableEntityError on invalid
89
+ * input.
90
+ */
91
+ async upsert(
92
+ agentId: string,
93
+ input: { name: string; source: string; ref?: string | null },
94
+ ): Promise<AgentMarketplace> {
95
+ const { name, source } = input;
96
+ const ref = input.ref ?? null;
97
+ validateMarketplaceName(name);
98
+ validateMarketplaceSource(source);
99
+ validateMarketplaceRef(ref);
100
+ return this.prisma.agentMarketplace.upsert({
101
+ where: { agentId_name: { agentId, name } },
102
+ create: { agentId, name, source, ref },
103
+ update: { source, ref },
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Remove a marketplace by ID. Verifies agentId ownership before deleting.
109
+ * Throws NotFoundError if the id doesn't exist or belongs to another agent.
110
+ */
111
+ async remove(agentId: string, marketplaceId: string): Promise<void> {
112
+ const existing = await this.prisma.agentMarketplace.findUnique({
113
+ where: { id: marketplaceId },
114
+ });
115
+ if (!existing || existing.agentId !== agentId) {
116
+ throw new NotFoundError(`marketplace ${marketplaceId} not found`);
117
+ }
118
+ await this.prisma.agentMarketplace.delete({ where: { id: marketplaceId } });
119
+ }
120
+ }