import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { catalogSkillListQuerySchema, companySkillCreateSchema, companySkillFileUpdateSchema, companySkillImportSchema, companySkillInstallCatalogSchema, companySkillInstallUpdateSchema, companySkillProjectScanRequestSchema, companySkillResetSchema, } from "@paperclipai/shared"; import { trackSkillImported } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { accessService, agentService, companySkillService, logActivity } from "../services/index.js"; import { getCatalogSkillOrThrow, listCatalogSkills, readCatalogSkillFile } from "../services/skills-catalog.js"; import { forbidden } from "../errors.js"; import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js"; import { getTelemetryClient } from "../telemetry.js"; type SkillTelemetryInput = { key: string; slug: string; sourceType: string; sourceLocator: string | null; metadata: Record | null; }; export function companySkillRoutes(db: Db) { const router = Router(); const agents = agentService(db); const access = accessService(db); const svc = companySkillService(db); function canCreateAgents(agent: { permissions: Record | null | undefined }) { if (!agent.permissions || typeof agent.permissions !== "object") return false; return Boolean((agent.permissions as Record).canCreateAgents); } function asString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function deriveTrackedSkillRef(skill: SkillTelemetryInput): string | null { if (skill.sourceType === "skills_sh") { return skill.key; } if (skill.sourceType !== "github") { return null; } const hostname = asString(skill.metadata?.hostname); if (hostname !== "github.com") { return null; } return skill.key; } function firstQueryString(value: unknown): string | undefined { if (typeof value === "string") return value; if (Array.isArray(value) && typeof value[0] === "string") return value[0]; return undefined; } async function assertCanMutateCompanySkills(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") { if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); if (!allowed) { throw forbidden("Missing permission: agents:create"); } return; } if (!req.actor.agentId) { throw forbidden("Agent authentication required"); } const actorAgent = await agents.getById(req.actor.agentId); if (!actorAgent || actorAgent.companyId !== companyId) { throw forbidden("Agent key cannot access another company"); } const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "agents:create"); if (allowedByGrant || canCreateAgents(actorAgent)) { return; } throw forbidden("Missing permission: can create agents"); } router.get("/skills/catalog", async (req, res) => { assertAuthenticated(req); const query = catalogSkillListQuerySchema.parse({ kind: firstQueryString(req.query.kind), category: firstQueryString(req.query.category), q: firstQueryString(req.query.q), }); res.json(listCatalogSkills(query)); }); router.get("/skills/catalog/:catalogId/files", async (req, res) => { assertAuthenticated(req); const catalogRef = firstQueryString(req.query.ref) ?? (req.params.catalogId as string); const relativePath = firstQueryString(req.query.path) ?? "SKILL.md"; res.json(await readCatalogSkillFile(catalogRef, relativePath)); }); router.get("/skills/catalog/:catalogId", async (req, res) => { assertAuthenticated(req); const catalogRef = firstQueryString(req.query.ref) ?? (req.params.catalogId as string); res.json(getCatalogSkillOrThrow(catalogRef)); }); router.get("/companies/:companyId/skills", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const result = await svc.list(companyId); res.json(result); }); router.get("/companies/:companyId/skills/:skillId", async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; assertCompanyAccess(req, companyId); const result = await svc.detail(companyId, skillId); if (!result) { res.status(404).json({ error: "Skill not found" }); return; } res.json(result); }); router.get("/companies/:companyId/skills/:skillId/update-status", async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; assertCompanyAccess(req, companyId); const result = await svc.updateStatus(companyId, skillId); if (!result) { res.status(404).json({ error: "Skill not found" }); return; } res.json(result); }); router.get("/companies/:companyId/skills/:skillId/files", async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; const relativePath = String(req.query.path ?? "SKILL.md"); assertCompanyAccess(req, companyId); const result = await svc.readFile(companyId, skillId, relativePath); if (!result) { res.status(404).json({ error: "Skill not found" }); return; } res.json(result); }); router.post( "/companies/:companyId/skills", validate(companySkillCreateSchema), async (req, res) => { const companyId = req.params.companyId as string; await assertCanMutateCompanySkills(req, companyId); const result = await svc.createLocalSkill(companyId, req.body); const actor = getActorInfo(req); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "company.skill_created", entityType: "company_skill", entityId: result.id, details: { slug: result.slug, name: result.name, }, }); res.status(201).json(result); }, ); router.patch( "/companies/:companyId/skills/:skillId/files", validate(companySkillFileUpdateSchema), async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; await assertCanMutateCompanySkills(req, companyId); const result = await svc.updateFile( companyId, skillId, String(req.body.path ?? ""), String(req.body.content ?? ""), ); const actor = getActorInfo(req); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "company.skill_file_updated", entityType: "company_skill", entityId: skillId, details: { path: result.path, markdown: result.markdown, }, }); res.json(result); }, ); router.post( "/companies/:companyId/skills/import", validate(companySkillImportSchema), async (req, res) => { const companyId = req.params.companyId as string; await assertCanMutateCompanySkills(req, companyId); const source = String(req.body.source ?? ""); const result = await svc.importFromSource(companyId, source); const actor = getActorInfo(req); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "company.skills_imported", entityType: "company", entityId: companyId, details: { source, importedCount: result.imported.length, importedSlugs: result.imported.map((skill) => skill.slug), warningCount: result.warnings.length, }, }); const telemetryClient = getTelemetryClient(); if (telemetryClient) { for (const skill of result.imported) { trackSkillImported(telemetryClient, { sourceType: skill.sourceType, skillRef: deriveTrackedSkillRef(skill), }); } } res.status(201).json(result); }, ); router.post( "/companies/:companyId/skills/install-catalog", validate(companySkillInstallCatalogSchema), async (req, res) => { const companyId = req.params.companyId as string; await assertCanMutateCompanySkills(req, companyId); const result = await svc.installFromCatalog(companyId, req.body); const actor = getActorInfo(req); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: result.action === "created" ? "company.skill_catalog_installed" : "company.skill_catalog_updated", entityType: "company_skill", entityId: result.skill.id, details: { action: result.action, catalogId: result.catalogSkill.id, catalogKey: result.catalogSkill.key, slug: result.skill.slug, originHash: result.catalogSkill.contentHash, warningCount: result.warnings.length, }, }); res.status(result.action === "created" ? 201 : 200).json(result); }, ); router.post( "/companies/:companyId/skills/scan-projects", validate(companySkillProjectScanRequestSchema), async (req, res) => { const companyId = req.params.companyId as string; await assertCanMutateCompanySkills(req, companyId); const result = await svc.scanProjectWorkspaces(companyId, req.body); const actor = getActorInfo(req); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "company.skills_scanned", entityType: "company", entityId: companyId, details: { scannedProjects: result.scannedProjects, scannedWorkspaces: result.scannedWorkspaces, discovered: result.discovered, importedCount: result.imported.length, updatedCount: result.updated.length, conflictCount: result.conflicts.length, warningCount: result.warnings.length, }, }); res.json(result); }, ); router.delete("/companies/:companyId/skills/:skillId", async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; await assertCanMutateCompanySkills(req, companyId); const result = await svc.deleteSkill(companyId, skillId); if (!result) { res.status(404).json({ error: "Skill not found" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "company.skill_deleted", entityType: "company_skill", entityId: result.id, details: { slug: result.slug, name: result.name, }, }); res.json(result); }); router.post( "/companies/:companyId/skills/:skillId/audit", async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; await assertCanMutateCompanySkills(req, companyId); const result = await svc.auditSkill(companyId, skillId); if (!result) { res.status(404).json({ error: "Skill not found" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "company.skill_audited", entityType: "company_skill", entityId: skillId, details: { verdict: result.verdict, codes: result.codes, installedHash: result.installedHash, originHash: result.originHash, scanVersion: result.scanVersion, }, }); res.json(result); }, ); router.post( "/companies/:companyId/skills/:skillId/install-update", validate(companySkillInstallUpdateSchema), async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; await assertCanMutateCompanySkills(req, companyId); const before = await svc.getById(companyId, skillId); const result = await svc.installUpdate(companyId, skillId, req.body); if (!result) { res.status(404).json({ error: "Skill not found" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "company.skill_update_installed", entityType: "company_skill", entityId: result.id, details: { slug: result.slug, previousOriginHash: before?.metadata?.originHash ?? before?.sourceRef ?? null, previousOriginVersion: before?.metadata?.originVersion ?? null, newOriginHash: result.metadata?.originHash ?? result.sourceRef, newOriginVersion: result.metadata?.originVersion ?? null, driftDetected: Boolean(before?.metadata?.userModifiedAt), force: Boolean(req.body.force), auditVerdict: result.metadata?.auditVerdict ?? null, }, }); res.json(result); }, ); router.post( "/companies/:companyId/skills/:skillId/reset", validate(companySkillResetSchema), async (req, res) => { const companyId = req.params.companyId as string; const skillId = req.params.skillId as string; await assertCanMutateCompanySkills(req, companyId); const before = await svc.getById(companyId, skillId); const result = await svc.resetSkill(companyId, skillId, req.body); if (!result) { res.status(404).json({ error: "Skill not found" }); return; } const actor = getActorInfo(req); await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action: "company.skill_reset", entityType: "company_skill", entityId: result.id, details: { slug: result.slug, previousOriginHash: before?.metadata?.originHash ?? before?.sourceRef ?? null, previousOriginVersion: before?.metadata?.originVersion ?? null, newOriginHash: result.metadata?.originHash ?? result.sourceRef, newOriginVersion: result.metadata?.originVersion ?? null, driftDetected: Boolean(before?.metadata?.userModifiedAt), force: Boolean(req.body.force), auditVerdict: result.metadata?.auditVerdict ?? null, }, }); res.json(result); }, ); return router; }