feat(09-04): implement skill registry REST routes and mount in app.ts (GREEN)

- Create skillRegistryRoutes() factory with 6 endpoints
- GET /api/skill-registry/skills (list, includeRemoved support)
- GET /api/skill-registry/skills/:sourceId/:slug (single skill, 404 on missing)
- GET /api/skill-registry/skills/:sourceId/:slug/versions (version history)
- POST /api/skill-registry/fetch (trigger fetchAllSources)
- POST /api/skill-registry/skills/:sourceId/:slug/install (copy to agent dir)
- POST /api/skill-registry/skills/:sourceId/:slug/rollback (restore prior version)
- DELETE /api/skill-registry/skills/:sourceId/:slug (soft-delete)
- assertBoard auth guard on every route
- Mount skillRegistryRoutes() in app.ts after companySkillRoutes
- Update tests to use two-segment path params (sourceId/slug) for Express 5 compatibility
- All 12 route tests pass
This commit is contained in:
Mikkel Georgsen 2026-04-01 01:31:22 +02:00 committed by Nexus Dev
parent 2137e1cf78
commit 4f6f85bb5a
3 changed files with 102 additions and 19 deletions

View file

@ -102,38 +102,40 @@ describe("skill registry routes", () => {
});
});
// ---- Test 2: GET /api/skill-registry/skills/:id ----
// ---- Test 2: GET /api/skill-registry/skills/:sourceId/:slug ----
describe("GET /api/skill-registry/skills/:id", () => {
describe("GET /api/skill-registry/skills/:sourceId/:slug", () => {
it("returns 200 with skill object when found", async () => {
mockSkillRegistryService.getById.mockResolvedValue(skill1);
const res = await request(app).get("/api/skill-registry/skills/anthropic-official%2Fbash");
const res = await request(app).get("/api/skill-registry/skills/anthropic-official/bash");
expect(res.status).toBe(200);
expect(res.body).toEqual(skill1);
expect(mockSkillRegistryService.getById).toHaveBeenCalledWith("anthropic-official/bash");
});
it("returns 404 when skill not found", async () => {
mockSkillRegistryService.getById.mockResolvedValue(undefined);
const res = await request(app).get("/api/skill-registry/skills/unknown%2Fskill");
const res = await request(app).get("/api/skill-registry/skills/unknown/skill");
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: "Skill not found" });
});
});
// ---- Test 3: GET /api/skill-registry/skills/:id/versions ----
// ---- Test 3: GET /api/skill-registry/skills/:sourceId/:slug/versions ----
describe("GET /api/skill-registry/skills/:id/versions", () => {
describe("GET /api/skill-registry/skills/:sourceId/:slug/versions", () => {
it("returns 200 with version array", async () => {
mockSkillRegistryService.getVersions.mockResolvedValue([version1]);
const res = await request(app).get("/api/skill-registry/skills/anthropic-official%2Fbash/versions");
const res = await request(app).get("/api/skill-registry/skills/anthropic-official/bash/versions");
expect(res.status).toBe(200);
expect(res.body).toEqual([version1]);
expect(mockSkillRegistryService.getVersions).toHaveBeenCalledWith("anthropic-official/bash");
});
});
@ -150,9 +152,9 @@ describe("skill registry routes", () => {
});
});
// ---- Test 5: POST /api/skill-registry/skills/:id/install ----
// ---- Test 5: POST /api/skill-registry/skills/:sourceId/:slug/install ----
describe("POST /api/skill-registry/skills/:id/install", () => {
describe("POST /api/skill-registry/skills/:sourceId/:slug/install", () => {
it("returns 200 with install result when agentSkillsDir provided", async () => {
const installResult = {
type: "installed",
@ -163,7 +165,7 @@ describe("skill registry routes", () => {
mockSkillRegistryService.install.mockResolvedValue(installResult);
const res = await request(app)
.post("/api/skill-registry/skills/anthropic-official%2Fbash/install")
.post("/api/skill-registry/skills/anthropic-official/bash/install")
.send({ agentSkillsDir: "/agent/skills" });
expect(res.status).toBe(200);
@ -176,7 +178,7 @@ describe("skill registry routes", () => {
it("returns 400 when agentSkillsDir is missing", async () => {
const res = await request(app)
.post("/api/skill-registry/skills/anthropic-official%2Fbash/install")
.post("/api/skill-registry/skills/anthropic-official/bash/install")
.send({});
expect(res.status).toBe(400);
@ -184,14 +186,14 @@ describe("skill registry routes", () => {
});
});
// ---- Test 6: POST /api/skill-registry/skills/:id/rollback ----
// ---- Test 6: POST /api/skill-registry/skills/:sourceId/:slug/rollback ----
describe("POST /api/skill-registry/skills/:id/rollback", () => {
describe("POST /api/skill-registry/skills/:sourceId/:slug/rollback", () => {
it("returns 200 when versionId and agentSkillsDir provided", async () => {
mockSkillRegistryService.rollback.mockResolvedValue(undefined);
const res = await request(app)
.post("/api/skill-registry/skills/anthropic-official%2Fbash/rollback")
.post("/api/skill-registry/skills/anthropic-official/bash/rollback")
.send({ versionId: "anthropic-official/bash@abc123", agentSkillsDir: "/agent/skills" });
expect(res.status).toBe(200);
@ -205,7 +207,7 @@ describe("skill registry routes", () => {
it("returns 400 when versionId is missing", async () => {
const res = await request(app)
.post("/api/skill-registry/skills/anthropic-official%2Fbash/rollback")
.post("/api/skill-registry/skills/anthropic-official/bash/rollback")
.send({ agentSkillsDir: "/agent/skills" });
expect(res.status).toBe(400);
@ -214,7 +216,7 @@ describe("skill registry routes", () => {
it("returns 400 when agentSkillsDir is missing", async () => {
const res = await request(app)
.post("/api/skill-registry/skills/anthropic-official%2Fbash/rollback")
.post("/api/skill-registry/skills/anthropic-official/bash/rollback")
.send({ versionId: "anthropic-official/bash@abc123" });
expect(res.status).toBe(400);
@ -222,13 +224,13 @@ describe("skill registry routes", () => {
});
});
// ---- Test 7: DELETE /api/skill-registry/skills/:id ----
// ---- Test 7: DELETE /api/skill-registry/skills/:sourceId/:slug ----
describe("DELETE /api/skill-registry/skills/:id", () => {
describe("DELETE /api/skill-registry/skills/:sourceId/:slug", () => {
it("returns 200 after soft-delete", async () => {
mockSkillRegistryService.uninstall.mockResolvedValue(undefined);
const res = await request(app).delete("/api/skill-registry/skills/anthropic-official%2Fbash");
const res = await request(app).delete("/api/skill-registry/skills/anthropic-official/bash");
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });

View file

@ -12,6 +12,7 @@ import { privateHostnameGuard, resolvePrivateHostnameAllowSet } from "./middlewa
import { healthRoutes } from "./routes/health.js";
import { companyRoutes } from "./routes/companies.js";
import { companySkillRoutes } from "./routes/company-skills.js";
import { skillRegistryRoutes } from "./routes/skill-registry.js";
import { agentRoutes } from "./routes/agents.js";
import { projectRoutes } from "./routes/projects.js";
import { issueRoutes } from "./routes/issues.js";
@ -150,6 +151,7 @@ export async function createApp(
);
api.use("/companies", companyRoutes(db, opts.storageService));
api.use(companySkillRoutes(db));
api.use(skillRegistryRoutes());
api.use(agentRoutes(db));
api.use(assetRoutes(db, opts.storageService));
api.use(projectRoutes(db));

View file

@ -0,0 +1,79 @@
import { Router } from "express";
import { skillRegistryService } from "../services/skill-registry.js";
import { assertBoard } from "./authz.js";
/**
* REST routes for the skill registry.
*
* Note: does NOT take a db param the skill registry manages its own libSQL database.
* All route handlers assert `board` access before delegating to skillRegistryService.
*/
export function skillRegistryRoutes(): Router {
const router = Router();
const svc = skillRegistryService();
// List all skills (soft-deleted excluded by default)
router.get("/skill-registry/skills", async (req, res) => {
assertBoard(req);
const includeRemoved = req.query.includeRemoved === "true";
const list = await svc.list({ includeRemoved });
res.json(list);
});
// Get versions for a skill — must be registered before the single-skill route
// to avoid /:id matching "versions" as the id segment
router.get("/skill-registry/skills/:sourceId/:slug/versions", async (req, res) => {
assertBoard(req);
const skillId = `${req.params.sourceId}/${req.params.slug}`;
const versions = await svc.getVersions(skillId);
res.json(versions);
});
// Install skill to agent directory
router.post("/skill-registry/skills/:sourceId/:slug/install", async (req, res) => {
assertBoard(req);
const skillId = `${req.params.sourceId}/${req.params.slug}`;
const { agentSkillsDir } = req.body as { agentSkillsDir: string };
if (!agentSkillsDir) return res.status(400).json({ error: "agentSkillsDir required" });
const result = await svc.install(skillId, agentSkillsDir);
res.json(result);
});
// Rollback to a specific version
router.post("/skill-registry/skills/:sourceId/:slug/rollback", async (req, res) => {
assertBoard(req);
const skillId = `${req.params.sourceId}/${req.params.slug}`;
const { versionId, agentSkillsDir } = req.body as { versionId: string; agentSkillsDir: string };
if (!versionId || !agentSkillsDir) {
return res.status(400).json({ error: "versionId and agentSkillsDir required" });
}
await svc.rollback(skillId, versionId, agentSkillsDir);
res.json({ ok: true });
});
// Soft-delete a skill
router.delete("/skill-registry/skills/:sourceId/:slug", async (req, res) => {
assertBoard(req);
const skillId = `${req.params.sourceId}/${req.params.slug}`;
await svc.uninstall(skillId);
res.json({ ok: true });
});
// Get a single skill by id
router.get("/skill-registry/skills/:sourceId/:slug", async (req, res) => {
assertBoard(req);
const skillId = `${req.params.sourceId}/${req.params.slug}`;
const skill = await svc.getById(skillId);
if (!skill) return res.status(404).json({ error: "Skill not found" });
res.json(skill);
});
// Trigger fetch from all configured sources
router.post("/skill-registry/fetch", async (req, res) => {
assertBoard(req);
const result = await svc.fetchAll();
res.json(result);
});
return router;
}