Merge upstream/master into dev (76 commits)

Resolved 5 conflicts:
- .github/workflows/docker.yml, release.yml: kept fork stubs (CI handled by build-prod/build-dev)
- server/src/routes/secrets.ts: kept fork's /usages route alongside upstream's /usage, /access-events
- server/src/services/secrets.ts: kept fork's usages() function and in-use deletion guard,
  layered before upstream's soft-delete + provider cleanup in remove()
- ui/src/api/secrets.ts: kept fork's usages() method alongside upstream's vault methods

Typechecks pass on @paperclipai/shared, @paperclipai/server, @paperclipai/ui.
This commit is contained in:
2026-05-11 18:01:34 -04:00
625 changed files with 145314 additions and 4442 deletions
+4 -2
View File
@@ -1,6 +1,7 @@
import { Router } from "express";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { normalizeIssueIdentifier } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { activityService, normalizeActivityLimit } from "../services/activity.js";
import { assertAuthenticated, assertBoard, assertCompanyAccess } from "./authz.js";
@@ -24,8 +25,9 @@ export function activityRoutes(db: Db) {
const issueSvc = issueService(db);
async function resolveIssueByRef(rawId: string) {
if (/^[A-Z]+-\d+$/i.test(rawId)) {
return issueSvc.getByIdentifier(rawId);
const identifier = normalizeIssueIdentifier(rawId);
if (identifier) {
return issueSvc.getByIdentifier(identifier);
}
return issueSvc.getById(rawId);
}
+331 -73
View File
@@ -13,6 +13,7 @@ import {
createAgentSchema,
deriveAgentUrlKey,
isUuidLike,
normalizeIssueIdentifier,
resetAgentSessionSchema,
testAdapterEnvironmentSchema,
type AgentSkillSnapshot,
@@ -55,8 +56,12 @@ import {
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { environmentService } from "../services/environments.js";
import { resolveEnvironmentExecutionTarget } from "../services/environment-execution-target.js";
import { environmentRuntimeService } from "../services/environment-runtime.js";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import type { AdapterEnvironmentCheck } from "@paperclipai/adapter-utils";
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import { secretService } from "../services/secrets.js";
import {
detectAdapterModel,
@@ -84,7 +89,8 @@ import {
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
import { requireOpenCodeModelId } from "@paperclipai/adapter-opencode-local/server";
import {
loadDefaultAgentInstructionsBundle,
resolveDefaultAgentInstructionsBundleRole,
@@ -158,6 +164,9 @@ export function agentRoutes(
const approvalsSvc = approvalService(db);
const budgets = budgetService(db);
const environmentsSvc = environmentService(db);
const environmentRuntime = environmentRuntimeService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
const heartbeat = heartbeatService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
@@ -189,9 +198,13 @@ export function agentRoutes(
* - SSH environment → builds an SSH execution target from the environment
* config so the adapter probes the remote box. No lease is required:
* the SSH spec is fully derived from the saved environment config.
* - Sandbox / plugin environments → currently fall back to local probing
* with a warning check, since lifting a temporary sandbox lease for an
* ad-hoc test invocation is out of scope for this iteration.
* - Sandbox / plugin environments → acquires an ad-hoc lease, realizes the
* workspace, and resolves a sandbox execution target wired to the runtime
* so the adapter probe runs inside the sandbox the same way a heartbeat
* would. The returned `release` callback rolls the lease back when the
* route is done.
*
* The caller MUST always invoke `release()` (typically in a `finally` block).
*/
async function resolveAdapterTestExecutionContext(input: {
companyId: string;
@@ -201,9 +214,17 @@ export function agentRoutes(
executionTarget: AdapterExecutionTarget | null;
environmentName: string | null;
fallbackChecks: AdapterEnvironmentCheck[];
release: (status?: "released" | "failed") => Promise<void>;
}> {
const noopRelease = async () => {};
if (!input.environmentId) {
return { executionTarget: null, environmentName: null, fallbackChecks: [] };
return {
executionTarget: null,
environmentName: null,
fallbackChecks: [],
release: noopRelease,
};
}
const environment = await environmentsSvc.getById(input.environmentId);
@@ -215,14 +236,20 @@ export function agentRoutes(
{
code: "environment_not_found",
level: "warn",
message: "Selected environment was not found. Falling back to a local probe.",
message: "Selected environment was not found. The test did not run.",
},
],
release: noopRelease,
};
}
if (environment.driver === "local") {
return { executionTarget: null, environmentName: environment.name, fallbackChecks: [] };
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [],
release: noopRelease,
};
}
if (environment.driver === "ssh") {
@@ -239,7 +266,12 @@ export function agentRoutes(
leaseMetadata: null,
});
if (target) {
return { executionTarget: target, environmentName: environment.name, fallbackChecks: [] };
return {
executionTarget: target,
environmentName: environment.name,
fallbackChecks: [],
release: noopRelease,
};
}
return {
executionTarget: null,
@@ -249,9 +281,10 @@ export function agentRoutes(
code: "environment_target_unavailable",
level: "warn",
message:
`Could not resolve an execution target for environment "${environment.name}". Falling back to a local probe.`,
`Could not resolve an execution target for environment "${environment.name}". The test did not run.`,
},
],
release: noopRelease,
};
} catch (err) {
return {
@@ -262,27 +295,163 @@ export function agentRoutes(
code: "environment_target_failed",
level: "warn",
message:
`Could not connect to environment "${environment.name}" to run the test. Falling back to a local probe.`,
`Could not connect to environment "${environment.name}" to run the test.`,
detail: err instanceof Error ? err.message : String(err),
},
],
release: noopRelease,
};
}
}
// sandbox / plugin / other drivers: not yet supported for ad-hoc adapter tests.
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_driver_not_supported_for_test",
level: "warn",
message:
`Adapter testing inside ${environment.driver} environments is not yet supported. Falling back to a local probe; results may not reflect runs in "${environment.name}".`,
hint: "Run a real heartbeat in the environment to verify end-to-end behavior.",
// sandbox / plugin / other remote drivers: spin up an ad-hoc lease, realize
// the workspace inside the box, and run the same probe SSH uses against
// a sandbox execution target wired to the environment runtime.
//
// We pass `heartbeatRunId: null` because there's no heartbeat run for an
// operator-initiated `Test` invocation — the leases table FKs heartbeat
// run id to heartbeat_runs.id, and we don't want to manufacture a fake
// run row. Cleanup goes through the driver's `releaseRunLease` directly
// (by lease record), since the batch helper queries by heartbeatRunId.
let leaseRecord: Awaited<ReturnType<typeof environmentRuntime.acquireRunLease>>;
try {
leaseRecord = await environmentRuntime.acquireRunLease({
companyId: input.companyId,
environment,
issueId: null,
heartbeatRunId: null,
persistedExecutionWorkspace: null,
});
} catch (err) {
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_lease_acquire_failed",
level: "error",
message: `Could not acquire a lease for environment "${environment.name}".`,
detail: err instanceof Error ? err.message : String(err),
hint: "Check the environment's provider credentials and quota.",
},
],
release: noopRelease,
};
}
const driver = environmentRuntime.getDriver(environment.driver);
const releaseLease = async (status: "released" | "failed" = "released") => {
try {
if (driver) {
await driver.releaseRunLease({
environment,
lease: leaseRecord.lease,
status,
});
} else {
await environmentsSvc.releaseLease(leaseRecord.lease.id, status);
}
} catch (err) {
// Cleanup failures must not mask the test result.
// eslint-disable-next-line no-console
console.warn(
`[adapter-test] Failed to release lease ${leaseRecord.lease.id}: ${err instanceof Error ? err.message : String(err)}`,
);
}
};
let realizedCwd: string | null = null;
try {
const realized = await environmentRuntime.realizeWorkspace({
environment,
lease: leaseRecord.lease,
// No host workspace to copy for a Test invocation; sandbox/plugin
// realize implementations use the lease metadata's remoteCwd to
// create the working directory inside the box.
workspace: {},
});
realizedCwd =
typeof realized.cwd === "string" && realized.cwd.trim().length > 0
? realized.cwd.trim()
: null;
} catch (err) {
await releaseLease("failed");
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_workspace_realize_failed",
level: "error",
message: `Could not realize a workspace inside "${environment.name}".`,
detail: err instanceof Error ? err.message : String(err),
},
],
release: noopRelease,
};
}
let target: AdapterExecutionTarget | null;
try {
// Prefer the cwd the realize step returned; fall back to lease metadata.
const leaseMetadataForTarget: Record<string, unknown> | null =
realizedCwd
? { ...(leaseRecord.lease.metadata ?? {}), remoteCwd: realizedCwd }
: (leaseRecord.lease.metadata as Record<string, unknown> | null) ?? null;
target = await resolveEnvironmentExecutionTarget({
db,
companyId: input.companyId,
adapterType: input.adapterType,
environment: {
id: environment.id,
driver: environment.driver,
config: environment.config ?? null,
},
],
leaseId: leaseRecord.lease.id,
leaseMetadata: leaseMetadataForTarget,
lease: leaseRecord.lease,
environmentRuntime,
});
} catch (err) {
await releaseLease("failed");
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_target_failed",
level: "error",
message: `Could not resolve a sandbox execution target for "${environment.name}".`,
detail: err instanceof Error ? err.message : String(err),
},
],
release: noopRelease,
};
}
if (!target) {
await releaseLease("failed");
return {
executionTarget: null,
environmentName: environment.name,
fallbackChecks: [
{
code: "environment_target_unsupported",
level: "warn",
message:
`Adapter "${input.adapterType}" is not allowed in "${environment.name}" environments.`,
},
],
release: noopRelease,
};
}
return {
executionTarget: target,
environmentName: environment.name,
fallbackChecks: [],
release: releaseLease,
};
}
@@ -767,7 +936,6 @@ export function agentRoutes(
{ strictMode: strictSecretsMode },
);
await assertAdapterConfigConstraints(
input.companyId,
input.adapterType,
input.constraintAdapterConfig
? { ...input.constraintAdapterConfig, ...normalizedAdapterConfig }
@@ -864,7 +1032,10 @@ export function agentRoutes(
next.model = DEFAULT_GEMINI_LOCAL_MODEL;
return ensureGatewayDeviceKey(adapterType, next);
}
// OpenCode requires explicit model selection — no default
if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) {
next.model = DEFAULT_OPENCODE_LOCAL_MODEL;
return ensureGatewayDeviceKey(adapterType, next);
}
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
}
@@ -872,20 +1043,12 @@ export function agentRoutes(
}
async function assertAdapterConfigConstraints(
companyId: string,
adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>,
) {
if (adapterType !== "opencode_local") return;
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig);
const runtimeEnv = asRecord(runtimeConfig.env) ?? {};
try {
await ensureOpenCodeModelConfiguredAndAvailable({
model: runtimeConfig.model,
command: runtimeConfig.command,
cwd: runtimeConfig.cwd,
env: runtimeEnv,
});
requireOpenCodeModelId(adapterConfig.model);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`);
@@ -1194,6 +1357,17 @@ export function agentRoutes(
const refresh = typeof req.query.refresh === "string"
? ["1", "true", "yes"].includes(req.query.refresh.toLowerCase())
: false;
const environmentId = asNonEmptyString(req.query.environmentId);
const environment = environmentId ? await environmentsSvc.getById(environmentId) : null;
if (environmentId && (!environment || environment.companyId !== companyId)) {
res.status(404).json({ error: "Environment not found" });
return;
}
if (type === "opencode_local" && environment && environment.driver !== "local") {
const adapter = requireServerAdapter(type);
res.json(adapter.models ?? []);
return;
}
const models = refresh
? await refreshAdapterModels(type)
: await listAdapterModels(type);
@@ -1243,33 +1417,51 @@ export function agentRoutes(
normalizedAdapterConfig,
);
const { executionTarget, environmentName, fallbackChecks } =
const { executionTarget, environmentName, fallbackChecks, release } =
await resolveAdapterTestExecutionContext({
companyId,
adapterType: type,
environmentId: requestedEnvironmentId,
});
const result = await adapter.testEnvironment({
companyId,
adapterType: type,
config: runtimeAdapterConfig,
executionTarget,
environmentName,
});
let releaseStatus: "released" | "failed" = "released";
try {
// If the caller explicitly selected an environment, never fall back to
// probing the host when we couldn't resolve that environment's
// execution target. Surface the diagnostic checks instead.
if (requestedEnvironmentId && !executionTarget && fallbackChecks.length > 0) {
const status: AdapterEnvironmentTestResult["status"] = fallbackChecks.some((c) => c.level === "error")
? "fail"
: fallbackChecks.some((c) => c.level === "warn")
? "warn"
: "pass";
if (status === "fail") releaseStatus = "failed";
const synthesized: AdapterEnvironmentTestResult = {
adapterType: type,
status,
checks: fallbackChecks,
testedAt: new Date().toISOString(),
};
res.json(synthesized);
return;
}
if (fallbackChecks.length > 0) {
const checks = [...fallbackChecks, ...result.checks];
const status: typeof result.status = checks.some((c) => c.level === "error")
? "fail"
: checks.some((c) => c.level === "warn")
? "warn"
: result.status;
res.json({ ...result, checks, status });
return;
const result = await adapter.testEnvironment({
companyId,
adapterType: type,
config: runtimeAdapterConfig,
executionTarget,
environmentName,
});
if (result.status === "fail") releaseStatus = "failed";
res.json(result);
} catch (err) {
releaseStatus = "failed";
throw err;
} finally {
await release(releaseStatus);
}
res.json(result);
},
);
@@ -1997,6 +2189,14 @@ export function agentRoutes(
lastHeartbeatAt: null,
});
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent, instructionsBundle);
const agentEnv = asRecord(agent.adapterConfig)?.env;
if (agentEnv) {
await secretsSvc.syncEnvBindingsForTarget?.(
companyId,
{ targetType: "agent", targetId: agent.id },
agentEnv,
);
}
const actor = getActorInfo(req);
await logActivity(db, {
@@ -2473,6 +2673,14 @@ export function agentRoutes(
res.status(404).json({ error: "Agent not found" });
return;
}
if (touchesAdapterConfiguration) {
const agentEnv = asRecord(agent.adapterConfig)?.env;
await secretsSvc.syncEnvBindingsForTarget?.(
agent.companyId,
{ targetType: "agent", targetId: agent.id },
agentEnv,
);
}
await logActivity(db, {
companyId: agent.companyId,
@@ -2691,7 +2899,25 @@ export function agentRoutes(
res.json({ ok: true });
});
router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => {
// Shared handler body for the wakeup-style endpoints. The two routes differ
// only in:
// - `source` — the modern /wakeup endpoint reads it from the request body
// (timer|assignment|on_demand|automation) while the legacy
// /heartbeat/invoke endpoint hardcodes "on_demand", since it has only
// ever produced on-demand invocations.
// - skipped-response shape — the modern endpoint surfaces the rich
// SkippedWakeupResponse; the legacy endpoint stays on the simpler
// { status: "skipped" } shape for backward compat.
type HeartbeatSource = "timer" | "assignment" | "on_demand" | "automation";
type WakeupRouteOpts = {
source: HeartbeatSource | undefined;
skippedResponse: (agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>) => unknown | Promise<unknown>;
};
const handleWakeupRoute = async (
req: Request,
res: Response,
opts: WakeupRouteOpts,
): Promise<void> => {
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
@@ -2710,7 +2936,7 @@ export function agentRoutes(
}
const run = await heartbeat.wakeup(id, {
source: req.body.source,
source: opts.source,
triggerDetail: req.body.triggerDetail ?? "manual",
reason: req.body.reason ?? null,
payload: req.body.payload ?? null,
@@ -2725,7 +2951,7 @@ export function agentRoutes(
});
if (!run) {
res.status(202).json(await buildSkippedWakeupResponse(agent, req.body.payload ?? null));
res.status(202).json(await opts.skippedResponse(agent));
return;
}
@@ -2743,9 +2969,23 @@ export function agentRoutes(
});
res.status(202).json(run);
};
router.post("/agents/:id/wakeup", validate(wakeAgentSchema), async (req, res) => {
await handleWakeupRoute(req, res, {
source: req.body.source,
skippedResponse: (agent) => buildSkippedWakeupResponse(agent, req.body.payload ?? null),
});
});
router.post("/agents/:id/heartbeat/invoke", async (req, res) => {
// Legacy endpoint. Hardcodes `source: "on_demand"` (the prior behavior
// before the wakeup/invoke convergence). Reads scope fields directly off
// the body without `validate(wakeAgentSchema)` because callers — including
// the e2e suite — post an empty body, and the schema rejects undefined
// / missing bodies. Only forwards fields the caller actually supplied so
// an empty body produces the original fixed-arg `heartbeat.invoke()`
// shape exactly.
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
@@ -2763,19 +3003,37 @@ export function agentRoutes(
await assertBoardCanManageAgentsForCompany(req, agent.companyId);
}
const run = await heartbeat.invoke(
id,
"on_demand",
{
triggeredBy: req.actor.type,
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
},
"manual",
{
actorType: req.actor.type === "agent" ? "agent" : "user",
actorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
},
);
const body = (req.body ?? {}) as Partial<{
reason: unknown;
payload: unknown;
idempotencyKey: unknown;
forceFreshSession: unknown;
triggerDetail: unknown;
}>;
const contextSnapshot: Record<string, unknown> = {
triggeredBy: req.actor.type,
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
};
if (body.forceFreshSession === true) {
contextSnapshot.forceFreshSession = true;
}
const wakeOpts: Parameters<typeof heartbeat.wakeup>[1] = {
source: "on_demand",
triggerDetail: typeof body.triggerDetail === "string" ? body.triggerDetail as "manual" | "system" | "ping" | "callback" : "manual",
requestedByActorType: req.actor.type === "agent" ? "agent" : "user",
requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
contextSnapshot,
};
if (typeof body.reason === "string" && body.reason.length > 0) {
wakeOpts.reason = body.reason;
}
if (body.payload && typeof body.payload === "object" && !Array.isArray(body.payload)) {
wakeOpts.payload = body.payload as Record<string, unknown>;
}
if (typeof body.idempotencyKey === "string" && body.idempotencyKey.length > 0) {
wakeOpts.idempotencyKey = body.idempotencyKey;
}
const run = await heartbeat.wakeup(id, wakeOpts);
if (!run) {
res.status(202).json({ status: "skipped" });
@@ -3082,8 +3340,8 @@ export function agentRoutes(
router.get("/issues/:issueId/live-runs", async (req, res) => {
const rawId = req.params.issueId as string;
const issueSvc = issueService(db);
const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId);
const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId);
const identifier = normalizeIssueIdentifier(rawId);
const issue = identifier ? await issueSvc.getByIdentifier(identifier) : await issueSvc.getById(rawId);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
@@ -3136,8 +3394,8 @@ export function agentRoutes(
router.get("/issues/:issueId/active-run", async (req, res) => {
const rawId = req.params.issueId as string;
const issueSvc = issueService(db);
const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId);
const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId);
const identifier = normalizeIssueIdentifier(rawId);
const issue = identifier ? await issueSvc.getByIdentifier(identifier) : await issueSvc.getById(rawId);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
+6 -3
View File
@@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db";
import {
createCostEventSchema,
createFinanceEventSchema,
normalizeIssueIdentifier,
resolveBudgetIncidentSchema,
updateBudgetSchema,
upsertBudgetPolicySchema,
@@ -62,8 +63,9 @@ export function costRoutes(
const issues = issueService(db);
async function resolveIssueByRef(rawId: string) {
if (/^[A-Z]+-\d+$/i.test(rawId)) {
return issues.getByIdentifier(rawId);
const identifier = normalizeIssueIdentifier(rawId);
if (identifier) {
return issues.getByIdentifier(identifier);
}
return issues.getById(rawId);
}
@@ -143,7 +145,8 @@ export function costRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
const summary = await costs.issueTreeSummary(issue.companyId, issue.id);
const excludeRoot = req.query.excludeRoot === "true" || req.query.excludeRoot === "1";
const summary = await costs.issueTreeSummary(issue.companyId, issue.id, { excludeRoot });
res.json(summary);
});
+16
View File
@@ -17,6 +17,7 @@ import {
projectService,
} from "../services/index.js";
import {
collectEnvironmentSecretRefs,
normalizeEnvironmentConfigForPersistence,
normalizeEnvironmentConfigForProbe,
parseEnvironmentDriverConfig,
@@ -26,6 +27,7 @@ import {
import { probeEnvironment } from "../services/environment-probe.js";
import { secretService } from "../services/secrets.js";
import { listReadyPluginEnvironmentDrivers } from "../services/plugin-environment-driver.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import { environmentService } from "../services/environments.js";
@@ -202,6 +204,7 @@ export function environmentRoutes(
companyId,
environmentName: req.body.name,
driver: req.body.driver,
secretProvider: getConfiguredSecretProvider(),
config: req.body.config,
actor: {
agentId: actor.agentId,
@@ -211,6 +214,11 @@ export function environmentRoutes(
}),
};
const environment = await svc.create(companyId, input);
await secrets.syncSecretRefsForTarget(
companyId,
{ targetType: "environment", targetId: environment.id },
await collectEnvironmentSecretRefs({ db, environment }),
);
await logActivity(db, {
companyId,
actorType: actor.actorType,
@@ -305,6 +313,7 @@ export function environmentRoutes(
companyId: existing.companyId,
environmentName: nextName,
driver: nextDriver,
secretProvider: getConfiguredSecretProvider(),
config: configSource,
actor: {
agentId: actor.agentId,
@@ -320,6 +329,13 @@ export function environmentRoutes(
res.status(404).json({ error: "Environment not found" });
return;
}
if (patch.config !== undefined || patch.driver !== undefined) {
await secrets.syncSecretRefsForTarget(
environment.companyId,
{ targetType: "environment", targetId: environment.id },
await collectEnvironmentSecretRefs({ db, environment }),
);
}
await logActivity(db, {
companyId: environment.companyId,
actorType: actor.actorType,
File diff suppressed because it is too large Load Diff
+163
View File
@@ -66,6 +66,17 @@ import {
getActorInfo,
} from "./authz.js";
import { validateInstanceConfig } from "../services/plugin-config-validator.js";
import {
findLocalFolderDeclaration,
getStoredLocalFolders,
inspectPluginLocalFolder,
requireLocalFolderDeclaration,
setStoredLocalFolder,
} from "../services/plugin-local-folders.js";
import {
extractSecretRefPathsFromConfig,
PLUGIN_SECRET_REFS_DISABLED_MESSAGE,
} from "../services/plugin-secrets-handler.js";
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
/** UI slot declaration extracted from plugin manifest */
@@ -1934,6 +1945,12 @@ export function pluginRoutes(
}
try {
const secretRefsByPath = extractSecretRefPathsFromConfig(body.configJson, schema);
if (secretRefsByPath.size > 0) {
res.status(422).json({ error: PLUGIN_SECRET_REFS_DISABLED_MESSAGE });
return;
}
const result = await registry.upsertConfig(plugin.id, {
configJson: body.configJson,
});
@@ -2379,6 +2396,152 @@ export function pluginRoutes(
}
});
// ===========================================================================
// Company-scoped trusted local folders
// ===========================================================================
router.get("/plugins/:pluginId/companies/:companyId/local-folders", async (req, res) => {
assertBoardOrgAccess(req);
const { pluginId, companyId } = req.params;
assertCompanyAccess(req, companyId);
const plugin = await resolvePlugin(registry, pluginId);
if (!plugin) {
res.status(404).json({ error: "Plugin not found" });
return;
}
const settings = await registry.getCompanySettings(plugin.id, companyId);
const storedFolders = getStoredLocalFolders(settings?.settingsJson);
const declarations = plugin.manifestJson.localFolders ?? [];
const folderKeys = declarations.map((declaration) => declaration.folderKey);
const statuses = await Promise.all(folderKeys.map((folderKey) =>
inspectPluginLocalFolder({
folderKey,
declaration: findLocalFolderDeclaration(declarations, folderKey),
storedConfig: storedFolders[folderKey] ?? null,
})));
res.json({
pluginId: plugin.id,
companyId,
declarations,
folders: statuses,
});
});
router.get("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/status", async (req, res) => {
assertBoardOrgAccess(req);
const { pluginId, companyId, folderKey } = req.params;
assertCompanyAccess(req, companyId);
const plugin = await resolvePlugin(registry, pluginId);
if (!plugin) {
res.status(404).json({ error: "Plugin not found" });
return;
}
const settings = await registry.getCompanySettings(plugin.id, companyId);
const storedFolders = getStoredLocalFolders(settings?.settingsJson);
const declarations = plugin.manifestJson.localFolders ?? [];
const declaration = requireLocalFolderDeclaration(declarations, folderKey);
const status = await inspectPluginLocalFolder({
folderKey,
declaration,
storedConfig: storedFolders[folderKey] ?? null,
});
res.json(status);
});
router.post("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/validate", async (req, res) => {
assertBoardOrgAccess(req);
const { pluginId, companyId, folderKey } = req.params;
assertCompanyAccess(req, companyId);
const plugin = await resolvePlugin(registry, pluginId);
if (!plugin) {
res.status(404).json({ error: "Plugin not found" });
return;
}
const body = req.body as {
path?: unknown;
access?: "read" | "readWrite";
requiredDirectories?: string[];
requiredFiles?: string[];
} | undefined;
if (typeof body?.path !== "string" || body.path.trim().length === 0) {
res.status(400).json({ error: '"path" is required and must be a non-empty string' });
return;
}
const declaration = requireLocalFolderDeclaration(plugin.manifestJson.localFolders ?? [], folderKey);
const status = await inspectPluginLocalFolder({
folderKey,
declaration,
overrideConfig: {
path: body.path,
},
});
res.json(status);
});
router.put("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey", async (req, res) => {
assertBoardOrgAccess(req);
const { pluginId, companyId, folderKey } = req.params;
assertCompanyAccess(req, companyId);
const plugin = await resolvePlugin(registry, pluginId);
if (!plugin) {
res.status(404).json({ error: "Plugin not found" });
return;
}
const body = req.body as {
path?: unknown;
access?: "read" | "readWrite";
requiredDirectories?: string[];
requiredFiles?: string[];
} | undefined;
if (typeof body?.path !== "string" || body.path.trim().length === 0) {
res.status(400).json({ error: '"path" is required and must be a non-empty string' });
return;
}
const existing = await registry.getCompanySettings(plugin.id, companyId);
const declaration = requireLocalFolderDeclaration(plugin.manifestJson.localFolders ?? [], folderKey);
const status = await inspectPluginLocalFolder({
folderKey,
declaration,
storedConfig: getStoredLocalFolders(existing?.settingsJson)[folderKey] ?? null,
overrideConfig: {
path: body.path,
},
});
const nextSettings = setStoredLocalFolder(existing?.settingsJson, folderKey, {
path: body.path,
access: status.access,
requiredDirectories: status.requiredDirectories,
requiredFiles: status.requiredFiles,
});
await registry.upsertCompanySettings(plugin.id, companyId, {
enabled: existing?.enabled ?? true,
settingsJson: nextSettings,
lastError: status.healthy ? null : status.problems.map((item: { message: string }) => item.message).join("; "),
});
await logPluginMutationActivity(req, "plugin.local_folder.configured", plugin.id, {
pluginId: plugin.id,
pluginKey: plugin.pluginKey,
companyId,
folderKey,
healthy: status.healthy,
});
res.json(status);
});
// ===========================================================================
// Plugin health dashboard — aggregated diagnostics for the settings page
// ===========================================================================
+14
View File
@@ -142,6 +142,13 @@ export function projectRoutes(db: Db) {
);
}
const project = await svc.create(companyId, projectData);
if (project.env) {
await secretsSvc.syncEnvBindingsForTarget?.(
companyId,
{ targetType: "project", targetId: project.id },
project.env,
);
}
let createdWorkspaceId: string | null = null;
if (workspace) {
const createdWorkspace = await svc.createWorkspace(project.id, workspace);
@@ -207,6 +214,13 @@ export function projectRoutes(db: Db) {
res.status(404).json({ error: "Project not found" });
return;
}
if (body.env !== undefined) {
await secretsSvc.syncEnvBindingsForTarget?.(
project.companyId,
{ targetType: "project", targetId: project.id },
project.env,
);
}
const actor = getActorInfo(req);
await logActivity(db, {
+137 -3
View File
@@ -57,6 +57,34 @@ export function routineRoutes(
return routine;
}
async function logRoutineRevisionCreated(req: Request, input: {
companyId: string;
routineId: string;
revisionId: string | null;
revisionNumber: number;
changeSummary?: string | null;
triggerCount?: number | null;
}) {
if (!input.revisionId) return;
const actor = getActorInfo(req);
await logActivity(db, {
companyId: input.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.revision_created",
entityType: "routine",
entityId: input.routineId,
details: {
revisionId: input.revisionId,
revisionNumber: input.revisionNumber,
changeSummary: input.changeSummary ?? null,
triggerCount: input.triggerCount ?? null,
},
});
}
router.get("/companies/:companyId/routines", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -72,6 +100,7 @@ export function routineRoutes(
const created = await svc.create(companyId, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
runId: req.actor.runId ?? null,
});
const actor = getActorInfo(req);
await logActivity(db, {
@@ -89,6 +118,14 @@ export function routineRoutes(
if (telemetryClient) {
trackRoutineCreated(telemetryClient);
}
await logRoutineRevisionCreated(req, {
companyId,
routineId: created.id,
revisionId: created.latestRevisionId,
revisionNumber: created.latestRevisionNumber,
changeSummary: "Created routine",
triggerCount: 0,
});
res.status(201).json(created);
});
@@ -102,6 +139,16 @@ export function routineRoutes(
res.json(detail);
});
router.get("/routines/:id/revisions", async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
const revisions = await svc.listRevisions(routine.id);
res.json(revisions);
});
router.patch("/routines/:id", validate(updateRoutineSchema), async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
@@ -131,6 +178,7 @@ export function routineRoutes(
const updated = await svc.update(routine.id, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
runId: req.actor.runId ?? null,
});
const actor = getActorInfo(req);
await logActivity(db, {
@@ -144,9 +192,52 @@ export function routineRoutes(
entityId: routine.id,
details: { title: updated?.title ?? routine.title },
});
if (updated && updated.latestRevisionId !== routine.latestRevisionId) {
await logRoutineRevisionCreated(req, {
companyId: routine.companyId,
routineId: routine.id,
revisionId: updated.latestRevisionId,
revisionNumber: updated.latestRevisionNumber,
changeSummary: "Updated routine",
triggerCount: null,
});
}
res.json(updated);
});
router.post("/routines/:id/revisions/:revisionId/restore", async (req, res) => {
const routine = await assertCanManageExistingRoutine(req, req.params.id as string);
if (!routine) {
res.status(404).json({ error: "Routine not found" });
return;
}
await assertBoardCanAssignTasks(req, routine.companyId);
const result = await svc.restoreRevision(routine.id, req.params.revisionId as string, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
runId: req.actor.runId ?? null,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId: routine.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "routine.revision_restored",
entityType: "routine",
entityId: routine.id,
details: {
revisionId: result.revision.id,
revisionNumber: result.revision.revisionNumber,
restoredFromRevisionId: result.restoredFromRevisionId,
restoredFromRevisionNumber: result.restoredFromRevisionNumber,
triggerCount: result.revision.snapshot.triggers.length,
},
});
res.json(result);
});
router.get("/routines/:id/runs", async (req, res) => {
const routine = await svc.get(req.params.id as string);
if (!routine) {
@@ -169,6 +260,7 @@ export function routineRoutes(
const created = await svc.createTrigger(routine.id, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
runId: req.actor.runId ?? null,
});
const actor = getActorInfo(req);
await logActivity(db, {
@@ -182,6 +274,14 @@ export function routineRoutes(
entityId: created.trigger.id,
details: { routineId: routine.id, kind: created.trigger.kind },
});
await logRoutineRevisionCreated(req, {
companyId: routine.companyId,
routineId: routine.id,
revisionId: created.revision.id,
revisionNumber: created.revision.revisionNumber,
changeSummary: created.revision.changeSummary,
triggerCount: created.revision.snapshot.triggers.length,
});
res.status(201).json(created);
});
@@ -200,6 +300,7 @@ export function routineRoutes(
const updated = await svc.updateTrigger(trigger.id, req.body, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
runId: req.actor.runId ?? null,
});
const actor = getActorInfo(req);
await logActivity(db, {
@@ -211,9 +312,19 @@ export function routineRoutes(
action: "routine.trigger_updated",
entityType: "routine_trigger",
entityId: trigger.id,
details: { routineId: routine.id, kind: updated?.kind ?? trigger.kind },
details: { routineId: routine.id, kind: updated?.trigger.kind ?? trigger.kind },
});
res.json(updated);
if (updated) {
await logRoutineRevisionCreated(req, {
companyId: routine.companyId,
routineId: routine.id,
revisionId: updated.revision.id,
revisionNumber: updated.revision.revisionNumber,
changeSummary: updated.revision.changeSummary,
triggerCount: updated.revision.snapshot.triggers.length,
});
}
res.json(updated?.trigger ?? null);
});
router.delete("/routine-triggers/:id", async (req, res) => {
@@ -227,7 +338,11 @@ export function routineRoutes(
res.status(404).json({ error: "Routine not found" });
return;
}
await svc.deleteTrigger(trigger.id);
const deleted = await svc.deleteTrigger(trigger.id, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
runId: req.actor.runId ?? null,
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId: routine.companyId,
@@ -240,6 +355,16 @@ export function routineRoutes(
entityId: trigger.id,
details: { routineId: routine.id, kind: trigger.kind },
});
if (deleted.revision) {
await logRoutineRevisionCreated(req, {
companyId: routine.companyId,
routineId: routine.id,
revisionId: deleted.revision.id,
revisionNumber: deleted.revision.revisionNumber,
changeSummary: deleted.revision.changeSummary,
triggerCount: deleted.revision.snapshot.triggers.length,
});
}
res.status(204).end();
});
@@ -260,6 +385,7 @@ export function routineRoutes(
const rotated = await svc.rotateTriggerSecret(trigger.id, {
agentId: req.actor.type === "agent" ? req.actor.agentId : null,
userId: req.actor.type === "board" ? req.actor.userId ?? "board" : null,
runId: req.actor.runId ?? null,
});
const actor = getActorInfo(req);
await logActivity(db, {
@@ -273,6 +399,14 @@ export function routineRoutes(
entityId: trigger.id,
details: { routineId: routine.id },
});
await logRoutineRevisionCreated(req, {
companyId: routine.companyId,
routineId: routine.id,
revisionId: rotated.revision.id,
revisionNumber: rotated.revision.revisionNumber,
changeSummary: rotated.revision.changeSummary,
triggerCount: rotated.revision.snapshot.triggers.length,
});
res.json(rotated);
},
);
+321 -8
View File
@@ -1,25 +1,23 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import {
SECRET_PROVIDERS,
type SecretProvider,
createSecretProviderConfigSchema,
createSecretSchema,
remoteSecretImportPreviewSchema,
remoteSecretImportSchema,
rotateSecretSchema,
updateSecretProviderConfigSchema,
updateSecretSchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { assertBoard, assertCompanyAccess } from "./authz.js";
import { logActivity, secretService } from "../services/index.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
export function secretRoutes(db: Db) {
const router = Router();
const svc = secretService(db);
const configuredDefaultProvider = process.env.PAPERCLIP_SECRETS_PROVIDER;
const defaultProvider = (
configuredDefaultProvider && SECRET_PROVIDERS.includes(configuredDefaultProvider as SecretProvider)
? configuredDefaultProvider
: "local_encrypted"
) as SecretProvider;
const defaultProvider = getConfiguredSecretProvider();
router.get("/companies/:companyId/secret-providers", (req, res) => {
assertBoard(req);
@@ -28,6 +26,205 @@ export function secretRoutes(db: Db) {
res.json(svc.listProviders());
});
router.get("/companies/:companyId/secret-providers/health", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const checks = await svc.checkProviders();
res.json({ providers: checks });
});
router.get("/companies/:companyId/secret-provider-configs", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await svc.listProviderConfigs(companyId));
});
router.post("/companies/:companyId/secret-provider-configs", validate(createSecretProviderConfigSchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const created = await svc.createProviderConfig(
companyId,
{
provider: req.body.provider,
displayName: req.body.displayName,
status: req.body.status,
isDefault: req.body.isDefault,
config: req.body.config,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.created",
entityType: "secret_provider_config",
entityId: created.id,
details: {
provider: created.provider,
displayName: created.displayName,
status: created.status,
isDefault: created.isDefault,
},
});
res.status(201).json(created);
});
router.get("/secret-provider-configs/:id", async (req, res) => {
assertBoard(req);
const existing = await svc.getProviderConfigById(req.params.id as string);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
res.json(existing);
});
router.patch("/secret-provider-configs/:id", validate(updateSecretProviderConfigSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getProviderConfigById(id);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const updated = await svc.updateProviderConfig(id, {
displayName: req.body.displayName,
status: req.body.status,
isDefault: req.body.isDefault,
config: req.body.config,
});
if (!updated) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
await logActivity(db, {
companyId: updated.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.updated",
entityType: "secret_provider_config",
entityId: updated.id,
details: {
provider: updated.provider,
displayName: updated.displayName,
status: updated.status,
isDefault: updated.isDefault,
},
});
res.json(updated);
});
router.delete("/secret-provider-configs/:id", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getProviderConfigById(id);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const disabled = await svc.disableProviderConfig(id);
if (!disabled) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
await logActivity(db, {
companyId: disabled.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.disabled",
entityType: "secret_provider_config",
entityId: disabled.id,
details: {
provider: disabled.provider,
displayName: disabled.displayName,
status: disabled.status,
},
});
res.json(disabled);
});
router.post("/secret-provider-configs/:id/default", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getProviderConfigById(id);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const updated = await svc.setDefaultProviderConfig(id);
if (!updated) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
await logActivity(db, {
companyId: updated.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.default_set",
entityType: "secret_provider_config",
entityId: updated.id,
details: {
provider: updated.provider,
displayName: updated.displayName,
isDefault: updated.isDefault,
},
});
res.json(updated);
});
router.post("/secret-provider-configs/:id/health", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getProviderConfigById(id);
if (!existing) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const health = await svc.checkProviderConfigHealth(id);
if (!health) {
res.status(404).json({ error: "Provider vault not found" });
return;
}
await logActivity(db, {
companyId: existing.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret_provider_config.health_checked",
entityType: "secret_provider_config",
entityId: existing.id,
details: {
provider: existing.provider,
status: health.status,
code: health.details.code,
},
});
res.json(health);
});
router.get("/companies/:companyId/secrets", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
@@ -45,10 +242,15 @@ export function secretRoutes(db: Db) {
companyId,
{
name: req.body.name,
key: req.body.key,
provider: req.body.provider ?? defaultProvider,
providerConfigId: req.body.providerConfigId,
managedMode: req.body.managedMode,
value: req.body.value,
description: req.body.description,
externalRef: req.body.externalRef,
providerVersionRef: req.body.providerVersionRef,
providerMetadata: req.body.providerMetadata,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
@@ -66,6 +268,77 @@ export function secretRoutes(db: Db) {
res.status(201).json(created);
});
router.post(
"/companies/:companyId/secrets/remote-import/preview",
validate(remoteSecretImportPreviewSchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const preview = await svc.previewRemoteImport(companyId, {
providerConfigId: req.body.providerConfigId,
query: req.body.query,
nextToken: req.body.nextToken,
pageSize: req.body.pageSize,
});
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret.remote_import.previewed",
entityType: "secret_provider_config",
entityId: preview.providerConfigId,
details: {
provider: preview.provider,
candidateCount: preview.candidates.length,
readyCount: preview.candidates.filter((candidate) => candidate.status === "ready").length,
duplicateCount: preview.candidates.filter((candidate) => candidate.status === "duplicate").length,
conflictCount: preview.candidates.filter((candidate) => candidate.status === "conflict").length,
},
});
res.json(preview);
},
);
router.post(
"/companies/:companyId/secrets/remote-import",
validate(remoteSecretImportSchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.importRemoteSecrets(
companyId,
{
providerConfigId: req.body.providerConfigId,
secrets: req.body.secrets,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "secret.remote_import.completed",
entityType: "secret_provider_config",
entityId: result.providerConfigId,
details: {
provider: result.provider,
importedCount: result.importedCount,
skippedCount: result.skippedCount,
errorCount: result.errorCount,
},
});
res.json(result);
},
);
router.post("/secrets/:id/rotate", validate(rotateSecretSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
@@ -75,12 +348,18 @@ export function secretRoutes(db: Db) {
return;
}
assertCompanyAccess(req, existing.companyId);
if (existing.status === "deleted") {
res.status(404).json({ error: "Secret not found" });
return;
}
const rotated = await svc.rotate(
id,
{
value: req.body.value,
externalRef: req.body.externalRef,
providerVersionRef: req.body.providerVersionRef,
providerConfigId: req.body.providerConfigId,
},
{ userId: req.actor.userId ?? "board", agentId: null },
);
@@ -107,11 +386,19 @@ export function secretRoutes(db: Db) {
return;
}
assertCompanyAccess(req, existing.companyId);
if (existing.status === "deleted") {
res.status(404).json({ error: "Secret not found" });
return;
}
const updated = await svc.update(id, {
name: req.body.name,
key: req.body.key,
status: req.body.status,
providerConfigId: req.body.providerConfigId,
description: req.body.description,
externalRef: req.body.externalRef,
providerMetadata: req.body.providerMetadata,
});
if (!updated) {
@@ -145,6 +432,32 @@ export function secretRoutes(db: Db) {
res.json({ agents: used.agents, skills: used.skills });
});
router.get("/secrets/:id/usage", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const bindings = await svc.listBindingReferences(existing.companyId, existing.id);
res.json({ secretId: existing.id, bindings });
});
router.get("/secrets/:id/access-events", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Secret not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const events = await svc.listAccessEvents(existing.companyId, existing.id);
res.json(events);
});
router.delete("/secrets/:id", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;