import { and, desc, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { companySecrets, companySecretVersions } from "@paperclipai/db"; import type { AgentEnvConfig, EnvBinding, SecretProvider } from "@paperclipai/shared"; import { envBindingSchema } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import { getSecretProvider, listSecretProviders } from "../secrets/provider-registry.js"; const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const SENSITIVE_ENV_KEY_RE = /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; const REDACTED_SENTINEL = "***REDACTED***"; type CanonicalEnvBinding = | { type: "plain"; value: string } | { type: "secret_ref"; secretId: string; version: number | "latest" }; function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; } function isSensitiveEnvKey(key: string) { return SENSITIVE_ENV_KEY_RE.test(key); } function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding { if (typeof binding === "string") { return { type: "plain", value: binding }; } if (binding.type === "plain") { return { type: "plain", value: String(binding.value) }; } return { type: "secret_ref", secretId: binding.secretId, version: binding.version ?? "latest", }; } export function secretService(db: Db) { async function getById(id: string) { return db .select() .from(companySecrets) .where(eq(companySecrets.id, id)) .then((rows) => rows[0] ?? null); } async function getByName(companyId: string, name: string) { return db .select() .from(companySecrets) .where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.name, name))) .then((rows) => rows[0] ?? null); } async function getSecretVersion(secretId: string, version: number) { return db .select() .from(companySecretVersions) .where( and( eq(companySecretVersions.secretId, secretId), eq(companySecretVersions.version, version), ), ) .then((rows) => rows[0] ?? null); } async function assertSecretInCompany(companyId: string, secretId: string) { const secret = await getById(secretId); if (!secret) throw notFound("Secret not found"); if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); return secret; } async function resolveSecretValue( companyId: string, secretId: string, version: number | "latest", ): Promise { const secret = await assertSecretInCompany(companyId, secretId); const resolvedVersion = version === "latest" ? secret.latestVersion : version; const versionRow = await getSecretVersion(secret.id, resolvedVersion); if (!versionRow) throw notFound("Secret version not found"); const provider = getSecretProvider(secret.provider as SecretProvider); return provider.resolveVersion({ material: versionRow.material as Record, externalRef: secret.externalRef, }); } async function normalizeEnvConfig( companyId: string, envValue: unknown, opts?: { strictMode?: boolean }, ): Promise { const record = asRecord(envValue); if (!record) throw unprocessable("adapterConfig.env must be an object"); const normalized: AgentEnvConfig = {}; for (const [key, rawBinding] of Object.entries(record)) { if (!ENV_KEY_RE.test(key)) { throw unprocessable(`Invalid environment variable name: ${key}`); } const parsed = envBindingSchema.safeParse(rawBinding); if (!parsed.success) { throw unprocessable(`Invalid environment binding for key: ${key}`); } const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type === "plain") { if (opts?.strictMode && isSensitiveEnvKey(key) && binding.value.trim().length > 0) { throw unprocessable( `Strict secret mode requires secret references for sensitive key: ${key}`, ); } if (binding.value === REDACTED_SENTINEL) { throw unprocessable(`Refusing to persist redacted placeholder for key: ${key}`); } normalized[key] = binding; continue; } await assertSecretInCompany(companyId, binding.secretId); normalized[key] = { type: "secret_ref", secretId: binding.secretId, version: binding.version, }; } return normalized; } async function normalizeAdapterConfigForPersistenceInternal( companyId: string, adapterConfig: Record, opts?: { strictMode?: boolean }, ) { const normalized = { ...adapterConfig }; if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) { return normalized; } normalized.env = await normalizeEnvConfig(companyId, adapterConfig.env, opts); return normalized; } return { listProviders: () => listSecretProviders(), list: (companyId: string) => db .select() .from(companySecrets) .where(eq(companySecrets.companyId, companyId)) .orderBy(desc(companySecrets.createdAt)), getById, getByName, create: async ( companyId: string, input: { name: string; provider: SecretProvider; value: string; description?: string | null; externalRef?: string | null; }, actor?: { userId?: string | null; agentId?: string | null }, ) => { const existing = await getByName(companyId, input.name); if (existing) throw conflict(`Secret already exists: ${input.name}`); const provider = getSecretProvider(input.provider); const prepared = await provider.createVersion({ value: input.value, externalRef: input.externalRef ?? null, }); return db.transaction(async (tx) => { const secret = await tx .insert(companySecrets) .values({ companyId, name: input.name, provider: input.provider, externalRef: prepared.externalRef, latestVersion: 1, description: input.description ?? null, createdByAgentId: actor?.agentId ?? null, createdByUserId: actor?.userId ?? null, }) .returning() .then((rows) => rows[0]); await tx.insert(companySecretVersions).values({ secretId: secret.id, version: 1, material: prepared.material, valueSha256: prepared.valueSha256, createdByAgentId: actor?.agentId ?? null, createdByUserId: actor?.userId ?? null, }); return secret; }); }, rotate: async ( secretId: string, input: { value: string; externalRef?: string | null }, actor?: { userId?: string | null; agentId?: string | null }, ) => { const secret = await getById(secretId); if (!secret) throw notFound("Secret not found"); const provider = getSecretProvider(secret.provider as SecretProvider); const nextVersion = secret.latestVersion + 1; const prepared = await provider.createVersion({ value: input.value, externalRef: input.externalRef ?? secret.externalRef ?? null, }); return db.transaction(async (tx) => { await tx.insert(companySecretVersions).values({ secretId: secret.id, version: nextVersion, material: prepared.material, valueSha256: prepared.valueSha256, createdByAgentId: actor?.agentId ?? null, createdByUserId: actor?.userId ?? null, }); const updated = await tx .update(companySecrets) .set({ latestVersion: nextVersion, externalRef: prepared.externalRef, updatedAt: new Date(), }) .where(eq(companySecrets.id, secret.id)) .returning() .then((rows) => rows[0] ?? null); if (!updated) throw notFound("Secret not found"); return updated; }); }, update: async ( secretId: string, patch: { name?: string; description?: string | null; externalRef?: string | null }, ) => { const secret = await getById(secretId); if (!secret) throw notFound("Secret not found"); if (patch.name && patch.name !== secret.name) { const duplicate = await getByName(secret.companyId, patch.name); if (duplicate && duplicate.id !== secret.id) { throw conflict(`Secret already exists: ${patch.name}`); } } return db .update(companySecrets) .set({ name: patch.name ?? secret.name, description: patch.description === undefined ? secret.description : patch.description, externalRef: patch.externalRef === undefined ? secret.externalRef : patch.externalRef, updatedAt: new Date(), }) .where(eq(companySecrets.id, secret.id)) .returning() .then((rows) => rows[0] ?? null); }, remove: async (secretId: string) => { const secret = await getById(secretId); if (!secret) return null; await db.delete(companySecrets).where(eq(companySecrets.id, secretId)); return secret; }, normalizeAdapterConfigForPersistence: async ( companyId: string, adapterConfig: Record, opts?: { strictMode?: boolean }, ) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts), normalizeHireApprovalPayloadForPersistence: async ( companyId: string, payload: Record, opts?: { strictMode?: boolean }, ) => { const normalized = { ...payload }; const adapterConfig = asRecord(payload.adapterConfig); if (adapterConfig) { normalized.adapterConfig = await normalizeAdapterConfigForPersistenceInternal( companyId, adapterConfig, opts, ); } return normalized; }, resolveEnvBindings: async (companyId: string, envValue: unknown): Promise<{ env: Record; secretKeys: Set }> => { const record = asRecord(envValue); if (!record) return { env: {} as Record, secretKeys: new Set() }; const resolved: Record = {}; const secretKeys = new Set(); for (const [key, rawBinding] of Object.entries(record)) { if (!ENV_KEY_RE.test(key)) { throw unprocessable(`Invalid environment variable name: ${key}`); } const parsed = envBindingSchema.safeParse(rawBinding); if (!parsed.success) { throw unprocessable(`Invalid environment binding for key: ${key}`); } const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type === "plain") { resolved[key] = binding.value; } else { resolved[key] = await resolveSecretValue(companyId, binding.secretId, binding.version); secretKeys.add(key); } } return { env: resolved, secretKeys }; }, resolveAdapterConfigForRuntime: async (companyId: string, adapterConfig: Record): Promise<{ config: Record; secretKeys: Set }> => { const resolved = { ...adapterConfig }; const secretKeys = new Set(); if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) { return { config: resolved, secretKeys }; } const record = asRecord(adapterConfig.env); if (!record) { resolved.env = {}; return { config: resolved, secretKeys }; } const env: Record = {}; for (const [key, rawBinding] of Object.entries(record)) { if (!ENV_KEY_RE.test(key)) { throw unprocessable(`Invalid environment variable name: ${key}`); } const parsed = envBindingSchema.safeParse(rawBinding); if (!parsed.success) { throw unprocessable(`Invalid environment binding for key: ${key}`); } const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type === "plain") { env[key] = binding.value; } else { env[key] = await resolveSecretValue(companyId, binding.secretId, binding.version); secretKeys.add(key); } } resolved.env = env; return { config: resolved, secretKeys }; }, }; }