Merge branches 'feat/skills-gitops-complete' and 'feat/secrets-management-ui' into dev

This commit is contained in:
2026-05-03 11:02:25 -04:00
5 changed files with 99 additions and 33 deletions
+2 -2
View File
@@ -141,8 +141,8 @@ export function secretRoutes(db: Db) {
return;
}
assertCompanyAccess(req, existing.companyId);
const agents = await svc.usages(existing.companyId, id);
res.json({ agents });
const used = await svc.usages(existing.companyId, id);
res.json({ agents: used.agents, skills: used.skills });
});
router.delete("/secrets/:id", async (req, res) => {
+34 -9
View File
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { and, asc, eq } from "drizzle-orm";
import { and, asc, eq, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companies, companySkills } from "@paperclipai/db";
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
@@ -2372,21 +2372,27 @@ export function companySkillService(db: Db) {
const incomingOwner = asString(incomingMeta.owner);
const incomingRepo = asString(incomingMeta.repo);
const incomingKind = asString(incomingMeta.sourceKind);
// Bundled skills are sourced from the server image and re-upserted by
// ensureBundledSkills only. Never let a non-bundled import overwrite a
// bundled row, regardless of which org/repo it claims to be from.
if (
existing
&& existingMeta.sourceKind === "paperclip_bundled"
&& incomingKind === "github"
&& incomingOwner === "paperclipai"
&& incomingRepo === "paperclip"
&& incomingKind !== "paperclip_bundled"
) {
out.push(existing);
continue;
}
// Preserve sourceAuthSecretId across re-imports of the same skill. Skip
// bundled rows: they should never carry a PAT reference, and preserving
// one across a bundled re-upsert would re-attach stale data.
const metadata = {
...(skill.metadata ?? {}),
skillKey: skill.key,
...(existing?.metadata && typeof (existing.metadata as Record<string, unknown>).sourceAuthSecretId === "string"
...(existing?.metadata
&& existingMeta.sourceKind !== "paperclip_bundled"
&& typeof (existing.metadata as Record<string, unknown>).sourceAuthSecretId === "string"
? { sourceAuthSecretId: (existing.metadata as Record<string, unknown>).sourceAuthSecretId }
: {}),
};
@@ -2464,6 +2470,8 @@ export function companySkillService(db: Db) {
if (authToken && imported.length > 0) {
for (const skill of imported) {
const skillMeta = skill.metadata as Record<string, unknown> | null;
if (skillMeta?.sourceKind === "paperclip_bundled") continue;
const secretName = `skill-pat:${skill.id}`;
let secretId: string;
const existing = await secretsSvc.getByName(companyId, secretName);
@@ -2530,10 +2538,24 @@ export function companySkillService(db: Db) {
const meta = skill.metadata as Record<string, unknown> | null;
const secretId = typeof meta?.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null;
if (secretId) {
try {
await secretsSvc.remove(secretId);
} catch {
// Best-effort: don't fail the skill deletion if secret cleanup fails
// Skip cleanup if another skill in the same company still references this
// secret. The deleted row is already gone from the table, so any result
// here is a sibling skill we shouldn't orphan.
const otherSkillRefs = await db
.select({ id: companySkills.id })
.from(companySkills)
.where(and(
eq(companySkills.companyId, companyId),
sql`${companySkills.metadata} ->> 'sourceAuthSecretId' = ${secretId}`,
))
.limit(1);
if (otherSkillRefs.length === 0) {
try {
await secretsSvc.remove(secretId);
} catch {
// Best-effort: don't fail the skill deletion if secret cleanup fails
// (typically blocked by an agent env binding still referencing it).
}
}
}
@@ -2549,6 +2571,9 @@ export function companySkillService(db: Db) {
if (!skill) return null;
const meta = (skill.metadata ?? {}) as Record<string, unknown>;
if (meta.sourceKind === "paperclip_bundled") {
throw unprocessable("Cannot configure auth for bundled paperclip skills");
}
const existingSecretId = typeof meta.sourceAuthSecretId === "string" ? meta.sourceAuthSecretId : null;
if (authToken) {
+32 -10
View File
@@ -1,6 +1,6 @@
import { and, desc, eq } from "drizzle-orm";
import { and, desc, eq, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companySecrets, companySecretVersions } from "@paperclipai/db";
import { companySecrets, companySecretVersions, companySkills } from "@paperclipai/db";
import type { AgentEnvConfig, EnvBinding, SecretProvider } from "@paperclipai/shared";
import { envBindingSchema } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
@@ -46,6 +46,8 @@ export function secretService(db: Db) {
};
type SecretUsageAgent = { id: string; name: string; envKeys: string[] };
type SecretUsageSkill = { id: string; name: string; slug: string };
type SecretUsages = { agents: SecretUsageAgent[]; skills: SecretUsageSkill[] };
async function getById(id: string) {
return db
@@ -55,10 +57,10 @@ export function secretService(db: Db) {
.then((rows) => rows[0] ?? null);
}
async function usages(companyId: string, secretId: string): Promise<SecretUsageAgent[]> {
async function usages(companyId: string, secretId: string): Promise<SecretUsages> {
const agents = agentService(db);
const allAgents = await agents.list(companyId);
const out: SecretUsageAgent[] = [];
const agentRefs: SecretUsageAgent[] = [];
for (const agent of allAgents) {
const config = asRecord(agent.adapterConfig);
if (!config) continue;
@@ -73,10 +75,28 @@ export function secretService(db: Db) {
}
}
if (matchingKeys.length > 0) {
out.push({ id: agent.id, name: agent.name, envKeys: matchingKeys });
agentRefs.push({ id: agent.id, name: agent.name, envKeys: matchingKeys });
}
}
return out;
const skillRows = await db
.select({
id: companySkills.id,
name: companySkills.name,
slug: companySkills.slug,
})
.from(companySkills)
.where(and(
eq(companySkills.companyId, companyId),
sql`${companySkills.metadata} ->> 'sourceAuthSecretId' = ${secretId}`,
));
const skillRefs: SecretUsageSkill[] = skillRows.map((row) => ({
id: row.id,
name: row.name,
slug: row.slug,
}));
return { agents: agentRefs, skills: skillRefs };
}
async function getByName(companyId: string, name: string) {
@@ -315,11 +335,13 @@ export function secretService(db: Db) {
const secret = await getById(secretId);
if (!secret) return null;
const used = await usages(secret.companyId, secretId);
if (used.length > 0) {
const names = used.map((agent) => agent.name).sort((left, right) => left.localeCompare(right));
if (used.agents.length > 0 || used.skills.length > 0) {
const agentNames = used.agents.map((agent) => agent.name);
const skillNames = used.skills.map((skill) => skill.name);
const names = [...agentNames, ...skillNames].sort((left, right) => left.localeCompare(right));
throw unprocessable(
`Cannot delete secret "${secret.name}" while it is still used by ${names.join(", ")}. Detach it from those agents first.`,
{ secretId, usedByAgents: used },
`Cannot delete secret "${secret.name}" while it is still used by ${names.join(", ")}. Detach it from those references first.`,
{ secretId, usedByAgents: used.agents, usedBySkills: used.skills },
);
}
await db.delete(companySecrets).where(eq(companySecrets.id, secretId));