@app-vitals/agent-admin 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,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
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * admin/src/agent-marketplaces.unit.test.ts
3
+ * Unit tests for marketplace input validation (source, name, ref).
4
+ */
5
+
6
+ import { describe, expect, it } from "bun:test";
7
+ import {
8
+ validateMarketplaceName,
9
+ validateMarketplaceRef,
10
+ validateMarketplaceSource,
11
+ } from "./agent-marketplaces.ts";
12
+ import { UnprocessableEntityError } from "./errors.ts";
13
+
14
+ describe("validateMarketplaceSource", () => {
15
+ it.each([
16
+ "app-vitals/shipwright",
17
+ "owner/repo.name",
18
+ "Org_1/repo-2",
19
+ "https://github.com/app-vitals/shipwright.git",
20
+ "https://gitlab.example.com/group/sub/repo",
21
+ ])("accepts %s", (source) => {
22
+ expect(() => validateMarketplaceSource(source)).not.toThrow();
23
+ });
24
+
25
+ it.each([
26
+ "",
27
+ " ",
28
+ "repo",
29
+ "owner/",
30
+ "/repo",
31
+ "owner/repo/extra",
32
+ "owner/re po",
33
+ "http://github.com/o/r",
34
+ "git@github.com:o/r.git",
35
+ "ssh://git@github.com/o/r",
36
+ "file:///etc/passwd",
37
+ "../etc",
38
+ "./local/path",
39
+ "-owner/repo",
40
+ "https://",
41
+ "https://user:pass@github.com/o/r",
42
+ "https://github.com/o/r with space",
43
+ "https://-evil.com/x",
44
+ ])("rejects %p with a 422", (source) => {
45
+ expect(() => validateMarketplaceSource(source)).toThrow(
46
+ UnprocessableEntityError,
47
+ );
48
+ });
49
+ });
50
+
51
+ describe("validateMarketplaceName", () => {
52
+ it("accepts simple names", () => {
53
+ expect(() => validateMarketplaceName("shipwright")).not.toThrow();
54
+ expect(() => validateMarketplaceName("my.market_place-2")).not.toThrow();
55
+ });
56
+ it.each(["", "a b", "a@b", "a/b", "-x"])("rejects %p", (name) => {
57
+ expect(() => validateMarketplaceName(name)).toThrow(
58
+ UnprocessableEntityError,
59
+ );
60
+ });
61
+ });
62
+
63
+ describe("validateMarketplaceRef", () => {
64
+ it("accepts branches, tags, shas and absent refs", () => {
65
+ for (const ref of [undefined, null, "main", "v1.2.3", "feat/x", "abc123"]) {
66
+ expect(() => validateMarketplaceRef(ref)).not.toThrow();
67
+ }
68
+ });
69
+ it.each(["", "-x", "a b", "a..b", "x;rm"])("rejects %p", (ref) => {
70
+ expect(() => validateMarketplaceRef(ref)).toThrow(UnprocessableEntityError);
71
+ });
72
+ });
package/src/agents-api.ts CHANGED
@@ -41,8 +41,8 @@ import {
41
41
  } from "./account-agent-create.ts";
42
42
  import {
43
43
  reconcileAgentAfterAccountChange,
44
- reconcileAgentAfterSpecialtyChange,
45
44
  reconcileAgentAfterPodServiceAccountChange,
45
+ reconcileAgentAfterSpecialtyChange,
46
46
  } from "./agent-account-assignment.ts";
47
47
  import {
48
48
  type AgentAskDeps,
@@ -60,6 +60,7 @@ import type { DeleteAgentFullyDeps } from "./agent-deletion.ts";
60
60
  import { deleteAgentFully } from "./agent-deletion.ts";
61
61
  import type { AgentEnvService } from "./agent-envs.ts";
62
62
  import type { AgentGitHubInstallationsService } from "./agent-github-installations.ts";
63
+ import { AgentMarketplaceService } from "./agent-marketplaces.ts";
63
64
  import type { AgentMemberService } from "./agent-members.ts";
64
65
  import type { AgentPluginService } from "./agent-plugins.ts";
65
66
  import {
@@ -116,6 +117,7 @@ import {
116
117
  AgentEnvResponseSchema,
117
118
  AgentGitHubInstallationsSnapshotSchema,
118
119
  AgentIdParamSchema,
120
+ AgentMarketplaceSchema,
119
121
  AgentPluginSchema,
120
122
  AgentRepoParamSchema,
121
123
  AgentSummarySchema,
@@ -143,6 +145,7 @@ import {
143
145
  EnvKeyParamSchema,
144
146
  ErrorSchema,
145
147
  ListCronRunsQuerySchema,
148
+ MarketplaceIdParamSchema,
146
149
  type ModelBreakdownEntry,
147
150
  OkSchema,
148
151
  PatchAgentBodySchema,
@@ -154,6 +157,7 @@ import {
154
157
  PutGitHubInstallationsBodySchema,
155
158
  TokenIdParamSchema,
156
159
  ToolIdParamSchema,
160
+ UpsertAgentMarketplaceBodySchema,
157
161
  UpsertChatTokenDailyBodySchema,
158
162
  } from "./openapi-schemas.ts";
159
163
  import type { SelfServeConfig } from "./self-serve-config.ts";
@@ -212,6 +216,14 @@ export interface AdminDeps {
212
216
  AgentPluginService,
213
217
  "list" | "add" | "remove" | "removeByName"
214
218
  >;
219
+ /**
220
+ * PMK-1.2: remote plugin marketplaces. Optional (additive to the exported
221
+ * AdminDeps): defaults to an AgentMarketplaceService over `prisma`.
222
+ */
223
+ agentMarketplaceService?: Pick<
224
+ AgentMarketplaceService,
225
+ "list" | "upsert" | "remove"
226
+ >;
215
227
  agentMemberService: Pick<
216
228
  AgentMemberService,
217
229
  "add" | "listByAgentId" | "exists" | "listByEmail"
@@ -492,6 +504,14 @@ const PluginWrapperSchema = z
492
504
  .object({ plugin: AgentPluginSchema })
493
505
  .openapi("PluginWrapper");
494
506
 
507
+ const MarketplaceWrapperSchema = z
508
+ .object({ marketplace: AgentMarketplaceSchema })
509
+ .openapi("MarketplaceWrapper");
510
+
511
+ const MarketplacesWrapperSchema = z
512
+ .object({ marketplaces: z.array(AgentMarketplaceSchema) })
513
+ .openapi("MarketplacesWrapper");
514
+
495
515
  const PluginsWrapperSchema = z
496
516
  .object({ plugins: z.array(AgentPluginSchema) })
497
517
  .openapi("PluginsWrapper");
@@ -1211,6 +1231,58 @@ const deletePluginRoute = createRoute({
1211
1231
  },
1212
1232
  });
1213
1233
 
1234
+ const listMarketplacesRoute = createRoute({
1235
+ method: "get",
1236
+ path: "/agents/{id}/marketplaces",
1237
+ summary: "List marketplaces",
1238
+ description:
1239
+ "Returns every remote plugin marketplace configured for the agent.",
1240
+ request: { params: AgentIdParamSchema },
1241
+ responses: {
1242
+ 200: {
1243
+ description: "List of marketplaces",
1244
+ content: { "application/json": { schema: MarketplacesWrapperSchema } },
1245
+ },
1246
+ },
1247
+ });
1248
+
1249
+ const upsertMarketplaceRoute = createRoute({
1250
+ method: "post",
1251
+ path: "/agents/{id}/marketplaces",
1252
+ summary: "Add or update a marketplace",
1253
+ description:
1254
+ "Upserts a remote plugin marketplace by `name`. `source` must be a GitHub `owner/repo` or an https git URL (no credentials); `ref` optionally pins a branch, tag or sha. 422 on an invalid `name`, `source` or `ref`.",
1255
+ request: {
1256
+ params: AgentIdParamSchema,
1257
+ body: {
1258
+ content: {
1259
+ "application/json": { schema: UpsertAgentMarketplaceBodySchema },
1260
+ },
1261
+ },
1262
+ },
1263
+ responses: {
1264
+ 201: {
1265
+ description: "Marketplace added or updated",
1266
+ content: { "application/json": { schema: MarketplaceWrapperSchema } },
1267
+ },
1268
+ 400: { description: "Bad request", ...jsonError },
1269
+ 422: { description: "Invalid name, source or ref", ...jsonError },
1270
+ },
1271
+ });
1272
+
1273
+ const deleteMarketplaceRoute = createRoute({
1274
+ method: "delete",
1275
+ path: "/agents/{id}/marketplaces/{marketplaceId}",
1276
+ summary: "Remove a marketplace",
1277
+ description:
1278
+ "Removes the marketplace row by id. 404 when the id does not exist or belongs to a different agent.",
1279
+ request: { params: MarketplaceIdParamSchema },
1280
+ responses: {
1281
+ 204: { description: "Marketplace removed" },
1282
+ 404: { description: "Marketplace not found", ...jsonError },
1283
+ },
1284
+ });
1285
+
1214
1286
  const upsertChatTokenDailyRoute = createRoute({
1215
1287
  method: "post",
1216
1288
  path: "/agents/{id}/chat-tokens/daily",
@@ -2532,6 +2604,34 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
2532
2604
  return c.body(null, 204);
2533
2605
  });
2534
2606
 
2607
+ // ─── Marketplaces (PMK-1.2) ────────────────────────────────────────────────
2608
+ // Auth is the shared /agents/* middleware: the agent's own key, a platform
2609
+ // admin and authorised session members pass; other agents' keys get 403.
2610
+ // No route here writes the runtime-reported baked-marketplace list (PMK-1.4).
2611
+
2612
+ const agentMarketplaceService =
2613
+ deps.agentMarketplaceService ??
2614
+ new AgentMarketplaceService(prisma as PrismaClient);
2615
+
2616
+ app.openapi(listMarketplacesRoute, async (c) => {
2617
+ const { id: agentId } = c.req.valid("param");
2618
+ const rows = await agentMarketplaceService.list(agentId);
2619
+ return c.json({ marketplaces: rows.map(serializeMarketplace) }, 200);
2620
+ });
2621
+
2622
+ app.openapi(upsertMarketplaceRoute, async (c) => {
2623
+ const { id: agentId } = c.req.valid("param");
2624
+ const body = c.req.valid("json");
2625
+ const row = await agentMarketplaceService.upsert(agentId, body);
2626
+ return c.json({ marketplace: serializeMarketplace(row) }, 201);
2627
+ });
2628
+
2629
+ app.openapi(deleteMarketplaceRoute, async (c) => {
2630
+ const { id: agentId, marketplaceId } = c.req.valid("param");
2631
+ await agentMarketplaceService.remove(agentId, marketplaceId);
2632
+ return c.body(null, 204);
2633
+ });
2634
+
2535
2635
  // ─── Cron-run token stats ──────────────────────────────────────────────────
2536
2636
 
2537
2637
  // GET /agents/all/cron-runs/stats — aggregated token stats across all agents
@@ -2844,6 +2944,22 @@ function serializeTool(tool: {
2844
2944
  } as z.infer<typeof AgentToolSchema>;
2845
2945
  }
2846
2946
 
2947
+ function serializeMarketplace(row: {
2948
+ id: string;
2949
+ agentId: string;
2950
+ name: string;
2951
+ source: string;
2952
+ ref: string | null;
2953
+ createdAt: Date;
2954
+ updatedAt: Date;
2955
+ }): z.infer<typeof AgentMarketplaceSchema> {
2956
+ return {
2957
+ ...row,
2958
+ createdAt: row.createdAt.toISOString(),
2959
+ updatedAt: row.updatedAt.toISOString(),
2960
+ };
2961
+ }
2962
+
2847
2963
  function serializePlugin(plugin: {
2848
2964
  createdAt: Date;
2849
2965
  updatedAt: Date;