mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 18:56:05 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents can propose binding a company secret to another agent; a person approves it with "Create binding" on a chat card > - Approval refuses when the target config key already holds a different value, so it never overwrites a working credential > - An empty string or empty plain value also counted as "already exists" > - So an agent whose key was cleared could not be repaired through the governed binding card: approval returned 409 > - This pull request treats a blank value like an unset key; non-empty values still conflict > - The benefit is that the supported binding approval repairs a member with a blank credential ## Linked Issues or Issue Description Refs #20879 **What happened** In the `teams.configure-team` chat eval, the CEO proposed binding the company OpenRouter secret to the QA member as `env.OPENROUTER_API_KEY`. QA's saved value was an empty string. Approving that proposal fails with 409 "Agent config path already exists: env.OPENROUTER_API_KEY". **Expected behavior** Approval fills a blank value (empty string or empty plain value) with the secret reference. A non-empty value still blocks the approval. **Steps to reproduce** 1. Save an agent with `adapterConfig.env.TOKEN = ""`. 2. Have an agent propose a binding of a company secret to that agent as `env.TOKEN`. 3. Approve the proposal as the board user. 4. The approval returns 409 and the binding is not created. ## What Changed - `secret-proposals.ts`: `applyBindingApproval` treats `undefined`, `""` and `{ type: "plain", value: "" }` as an unset key for both `env.<KEY>` and other config paths. - `secret-proposals-routes.test.ts`: regression tests for an empty string and an empty plain value. The existing test that a non-empty value conflicts still passes. ## Verification - `pnpm exec vitest run src/__tests__/secret-proposals-routes.test.ts` (in `server/`): 49 passed. - Red check: without the service change, the two new tests fail. ## Risks - Low risk. Only a blank value changes behavior. A blank value carries no credential, so nothing working is replaced. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
963 lines
41 KiB
TypeScript
963 lines
41 KiB
TypeScript
import { notifyChatRunMilestones } from "./chat-run-milestone-notifications.js";
|
|
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
|
import { updateAgentConfigurationInTransaction } from "./agent-configuration-transaction.js";
|
|
import { withAgentAppearance } from "@paperclipai/shared";
|
|
import { and, count, desc, eq, gte, inArray, lte, or, sql } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
agents,
|
|
companySecretBindings,
|
|
companySecretProposals,
|
|
companySecrets,
|
|
heartbeatRuns,
|
|
issueThreadInteractions,
|
|
issues,
|
|
userSecretDeclarations,
|
|
userSecretDefinitions,
|
|
} from "@paperclipai/db";
|
|
import type { SecretProvider } from "@paperclipai/shared";
|
|
import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
|
|
import { getSecretProvider } from "../secrets/provider-registry.js";
|
|
import { agentService } from "./agents.js";
|
|
import { logActivity } from "./activity-log.js";
|
|
import { normalizeSecretKey, secretService } from "./secrets.js";
|
|
|
|
const CONFIG_PATH_RE = /^(?:env\.[A-Za-z_][A-Za-z0-9_]*|access\.[A-Za-z_][A-Za-z0-9_]*)$/;
|
|
const SECRET_NAME_RE = /^[^/\s]+(?:\/[^/\s]+)*$/;
|
|
const MAX_PENDING_PROPOSALS_PER_AGENT = 20;
|
|
const MAX_PROPOSALS_PER_MINUTE = 20;
|
|
const MAX_SECRET_VALUE_BYTES = 64 * 1024;
|
|
const PENDING_EXPIRY_MS = 14 * 24 * 60 * 60 * 1000;
|
|
const DEFAULT_PROPOSAL_LIST_LIMIT = 100;
|
|
const DEFAULT_EXPIRY_SWEEP_LIMIT = 100;
|
|
|
|
export type SecretProposalTerminalStatus = "approved" | "rejected" | "withdrawn" | "expired";
|
|
|
|
export type ProposalRunContext = {
|
|
companyId: string;
|
|
heartbeatRunId: string;
|
|
registerForRedaction: (value: string) => void | Promise<void>;
|
|
};
|
|
|
|
type Proposal = typeof companySecretProposals.$inferSelect;
|
|
|
|
async function loadRunContext(db: Db, context: Pick<ProposalRunContext, "companyId" | "heartbeatRunId">) {
|
|
const run = await db.select().from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.id, context.heartbeatRunId),
|
|
eq(heartbeatRuns.companyId, context.companyId),
|
|
)).then((rows) => rows[0] ?? null);
|
|
if (!run) throw forbidden("Secret proposals require a verified run-bound agent token");
|
|
const issue = await db.select({ id: issues.id }).from(issues).where(and(
|
|
eq(issues.companyId, context.companyId),
|
|
or(eq(issues.executionRunId, run.id), eq(issues.checkoutRunId, run.id)),
|
|
)).then((rows) => rows[0] ?? null);
|
|
return { run, originIssueId: issue?.id ?? null };
|
|
}
|
|
|
|
async function ancestorIds(db: Db, companyId: string, agentId: string, lockForUpdate = false) {
|
|
const query = db.select({ id: agents.id, reportsTo: agents.reportsTo }).from(agents)
|
|
.where(eq(agents.companyId, companyId));
|
|
const rows = lockForUpdate ? await query.for("update") : await query;
|
|
const byId = new Map(rows.map((row) => [row.id, row.reportsTo]));
|
|
if (!byId.has(agentId)) throw notFound("Agent not found");
|
|
const result: string[] = [];
|
|
const seen = new Set<string>([agentId]);
|
|
let current = byId.get(agentId) ?? null;
|
|
while (current && !seen.has(current)) {
|
|
result.push(current);
|
|
seen.add(current);
|
|
current = byId.get(current) ?? null;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function bindingTargetAllowed(proposerAgentId: string, targetAgentId: string, targetAncestorIds: string[]) {
|
|
return proposerAgentId === targetAgentId || targetAncestorIds.includes(proposerAgentId);
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: {};
|
|
}
|
|
|
|
export function createSecretProposalsService(db: Db) {
|
|
async function getById(companyId: string, proposalId: string, dbClient: Db = db, lockForUpdate = false) {
|
|
const query = dbClient.select().from(companySecretProposals).where(and(
|
|
eq(companySecretProposals.id, proposalId),
|
|
eq(companySecretProposals.companyId, companyId),
|
|
));
|
|
if (lockForUpdate) return query.for("update").then((rows) => rows[0] ?? null);
|
|
return query.then((rows) => rows[0] ?? null);
|
|
}
|
|
|
|
async function requirePending(companyId: string, proposalId: string, dbClient: Db = db, lockForUpdate = false) {
|
|
const proposal = await getById(companyId, proposalId, dbClient, lockForUpdate);
|
|
if (!proposal) throw notFound("Secret proposal not found");
|
|
if (proposal.status !== "pending") throw conflict("Only pending proposals can be resolved");
|
|
return proposal;
|
|
}
|
|
|
|
function assertNotExpired(proposal: Proposal) {
|
|
if (proposal.expiresAt.getTime() <= Date.now()) {
|
|
throw conflict("Expired proposals cannot be approved");
|
|
}
|
|
}
|
|
|
|
type CreationQuotaInput = { companyId: string; agentId: string; runId: string; issueId: string | null };
|
|
|
|
async function creationQuotaDenial(dbClient: Db, input: CreationQuotaInput) {
|
|
const [pending, recent] = await Promise.all([
|
|
dbClient.select({ value: count() }).from(companySecretProposals).where(and(
|
|
eq(companySecretProposals.companyId, input.companyId),
|
|
eq(companySecretProposals.proposedByAgentId, input.agentId),
|
|
eq(companySecretProposals.status, "pending"),
|
|
)).then((rows) => Number(rows[0]?.value ?? 0)),
|
|
dbClient.select({ value: count() }).from(companySecretProposals).where(and(
|
|
eq(companySecretProposals.companyId, input.companyId),
|
|
eq(companySecretProposals.proposedByAgentId, input.agentId),
|
|
gte(companySecretProposals.createdAt, new Date(Date.now() - 60_000)),
|
|
)).then((rows) => Number(rows[0]?.value ?? 0)),
|
|
]);
|
|
const denial = pending >= MAX_PENDING_PROPOSALS_PER_AGENT
|
|
? { code: "pending_cap", message: `Agents may have at most ${MAX_PENDING_PROPOSALS_PER_AGENT} pending secret proposals` }
|
|
: recent >= MAX_PROPOSALS_PER_MINUTE
|
|
? { code: "rate_limit", message: `Agents may create at most ${MAX_PROPOSALS_PER_MINUTE} secret proposals per minute` }
|
|
: null;
|
|
return denial ? { ...denial, pending, recent } : null;
|
|
}
|
|
|
|
async function createWithinQuota<T>(input: CreationQuotaInput, create: (txDb: Db) => Promise<T>) {
|
|
const result = await db.transaction(async (tx) => {
|
|
const txDb = tx as unknown as Db;
|
|
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${input.companyId}), hashtext(${input.agentId}))`);
|
|
const denial = await creationQuotaDenial(txDb, input);
|
|
if (denial) {
|
|
await logActivity(txDb, {
|
|
companyId: input.companyId,
|
|
actorType: "agent",
|
|
actorId: input.agentId,
|
|
action: "secret.proposal.denied",
|
|
entityType: "agent",
|
|
entityId: input.agentId,
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
details: { code: denial.code, issueId: input.issueId, pending: denial.pending, recent: denial.recent },
|
|
});
|
|
return { denial, value: null };
|
|
}
|
|
await notifyDeliveryWork(txDb, DELIVERY_QUEUES.secretProposalExpiry, input.companyId);
|
|
return { denial: null, value: await create(txDb) };
|
|
});
|
|
if (result.denial) throw unprocessable(result.denial.message);
|
|
return result.value as T;
|
|
}
|
|
|
|
async function recordCreated(proposal: Proposal, dbClient: Db = db) {
|
|
await logActivity(dbClient, {
|
|
companyId: proposal.companyId,
|
|
actorType: "agent",
|
|
actorId: proposal.proposedByAgentId,
|
|
action: "secret.proposal.created",
|
|
entityType: "company_secret_proposal",
|
|
entityId: proposal.id,
|
|
agentId: proposal.proposedByAgentId,
|
|
runId: proposal.originRunId,
|
|
details: {
|
|
kind: proposal.kind,
|
|
issueId: proposal.originIssueId,
|
|
targetAgentId: proposal.targetId,
|
|
configPath: proposal.configPath,
|
|
valueFingerprintSha256: proposal.valueFingerprintSha256,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function createBindingInteraction(
|
|
txDb: Db,
|
|
proposal: Proposal,
|
|
sourceSecretLabel: string,
|
|
) {
|
|
if (!proposal.originIssueId || !proposal.targetId || !proposal.configPath) return proposal;
|
|
const target = await txDb
|
|
.select({ name: agents.name })
|
|
.from(agents)
|
|
.where(and(eq(agents.id, proposal.targetId), eq(agents.companyId, proposal.companyId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!target) throw notFound("Target agent not found");
|
|
|
|
const [interaction] = await txDb
|
|
.insert(issueThreadInteractions)
|
|
.values({
|
|
companyId: proposal.companyId,
|
|
issueId: proposal.originIssueId,
|
|
kind: "request_confirmation",
|
|
status: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
requestedResolverPolicy: "human_only",
|
|
effectiveResolverPolicy: "human_only",
|
|
resolverPolicyProvenance: "explicit",
|
|
effectiveResolverPolicySource: "governed_action",
|
|
idempotencyKey: `secret-proposal:${proposal.id}`,
|
|
sourceRunId: proposal.originRunId,
|
|
title: "Confirm secret binding",
|
|
summary: `Bind ${sourceSecretLabel} to ${target.name} as ${proposal.configPath}`,
|
|
createdByAgentId: proposal.proposedByAgentId,
|
|
addresseeAgentId: null,
|
|
payload: {
|
|
version: 1,
|
|
prompt: `Bind secret ${sourceSecretLabel} to ${target.name} as ${proposal.configPath}?`,
|
|
acceptLabel: "Create binding",
|
|
rejectLabel: "Reject",
|
|
rejectRequiresReason: true,
|
|
rejectReasonLabel: "Why should this binding not be created?",
|
|
allowDeclineReason: true,
|
|
supersedeOnUserComment: false,
|
|
secretProposal: {
|
|
version: 1,
|
|
proposalId: proposal.id,
|
|
sourceSecretLabel,
|
|
configPath: proposal.configPath,
|
|
targetAgentId: proposal.targetId,
|
|
targetAgentName: target.name,
|
|
justification: proposal.justification,
|
|
expiresAt: proposal.expiresAt.toISOString(),
|
|
},
|
|
},
|
|
})
|
|
.returning();
|
|
|
|
const [linked] = await txDb
|
|
.update(companySecretProposals)
|
|
.set({ interactionId: interaction.id, updatedAt: new Date() })
|
|
.where(eq(companySecretProposals.id, proposal.id))
|
|
.returning();
|
|
await txDb.update(issues).set({ updatedAt: new Date() }).where(eq(issues.id, proposal.originIssueId));
|
|
await logActivity(txDb, {
|
|
companyId: proposal.companyId,
|
|
actorType: "agent",
|
|
actorId: proposal.proposedByAgentId,
|
|
action: "issue.thread_interaction_created",
|
|
entityType: "issue",
|
|
entityId: proposal.originIssueId,
|
|
agentId: proposal.proposedByAgentId,
|
|
runId: proposal.originRunId,
|
|
details: {
|
|
interactionId: interaction.id,
|
|
interactionKind: "request_confirmation",
|
|
interactionStatus: "pending",
|
|
continuationPolicy: "wake_assignee",
|
|
addresseeAgentId: null,
|
|
requestedResolverPolicy: "board_only",
|
|
effectiveResolverPolicy: "board_only",
|
|
serverOwnedPayload: "secretProposal",
|
|
},
|
|
});
|
|
return linked;
|
|
}
|
|
|
|
async function reflectProposalLifecycleOnInteraction(
|
|
txDb: Db,
|
|
proposal: Proposal,
|
|
outcome: "approved" | "rejected" | "withdrawn" | "expired",
|
|
input: { resolvedByUserId?: string | null; reason?: string | null } = {},
|
|
) {
|
|
if (!proposal.interactionId) return;
|
|
const current = await txDb
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, proposal.interactionId),
|
|
eq(issueThreadInteractions.companyId, proposal.companyId),
|
|
))
|
|
.for("update")
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!current || (current.status !== "pending" && !(outcome === "approved" && current.status === "accepted"))) return;
|
|
const payload = asRecord(current.payload);
|
|
const linked = asRecord(payload.secretProposal);
|
|
if (linked.proposalId !== proposal.id) return;
|
|
|
|
const now = new Date();
|
|
const currentResult = asRecord(current.result);
|
|
const status = outcome === "approved"
|
|
? "accepted"
|
|
: outcome === "rejected"
|
|
? "rejected"
|
|
: outcome === "withdrawn"
|
|
? "cancelled"
|
|
: "expired";
|
|
const resultOutcome = outcome === "approved"
|
|
? "accepted"
|
|
: outcome === "rejected"
|
|
? "rejected"
|
|
: "withdrawn";
|
|
if (current.status !== status) await notifyChatRunMilestones(txDb, current);
|
|
await txDb
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status,
|
|
result: {
|
|
...currentResult,
|
|
version: 1,
|
|
outcome: resultOutcome,
|
|
...(input.reason ? { reason: input.reason } : {}),
|
|
secretProposal: {
|
|
version: 1,
|
|
status: outcome === "approved" ? "executed" : outcome,
|
|
updatedAt: now.toISOString(),
|
|
},
|
|
},
|
|
resolvedByUserId: input.resolvedByUserId ?? current.resolvedByUserId ?? null,
|
|
resolvedAt: current.resolvedAt ?? now,
|
|
updatedAt: now,
|
|
})
|
|
.where(and(
|
|
eq(issueThreadInteractions.id, current.id),
|
|
inArray(issueThreadInteractions.status, outcome === "approved" ? ["pending", "accepted"] : ["pending"]),
|
|
));
|
|
}
|
|
|
|
async function createSecret(context: ProposalRunContext, input: {
|
|
name: string;
|
|
key?: string | null;
|
|
description?: string | null;
|
|
value: string;
|
|
justification: string;
|
|
}) {
|
|
const name = input.name.trim();
|
|
const justification = input.justification.trim();
|
|
if (!SECRET_NAME_RE.test(name)) throw unprocessable("Secret name must be a slash-separated path without empty segments");
|
|
if (!justification) throw unprocessable("Justification is required");
|
|
if (!input.value) throw unprocessable("Secret value is required");
|
|
if (Buffer.byteLength(input.value, "utf8") > MAX_SECRET_VALUE_BYTES) {
|
|
throw unprocessable(`Secret value must be at most ${MAX_SECRET_VALUE_BYTES} bytes`);
|
|
}
|
|
const proposedKey = normalizeSecretKey(input.key?.trim() || name.split("/").at(-1) || "");
|
|
if (!proposedKey) throw unprocessable("Secret key is required");
|
|
const { run, originIssueId } = await loadRunContext(db, context);
|
|
const prepared = await getSecretProvider("local_encrypted").createSecret({ value: input.value });
|
|
await context.registerForRedaction(input.value);
|
|
return createWithinQuota(
|
|
{ companyId: context.companyId, agentId: run.agentId, runId: run.id, issueId: originIssueId },
|
|
async (txDb) => {
|
|
const proposal = await txDb.insert(companySecretProposals).values({
|
|
companyId: context.companyId,
|
|
kind: "secret",
|
|
proposedName: name,
|
|
proposedKey,
|
|
proposedDescription: input.description?.trim() || null,
|
|
justification,
|
|
valueCiphertext: prepared.material,
|
|
valueFingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256,
|
|
valueLength: Buffer.byteLength(input.value, "utf8"),
|
|
proposedByAgentId: run.agentId,
|
|
originIssueId,
|
|
originRunId: run.id,
|
|
expiresAt: new Date(Date.now() + PENDING_EXPIRY_MS),
|
|
}).returning().then((rows) => rows[0]);
|
|
await recordCreated(proposal, txDb);
|
|
return proposal;
|
|
},
|
|
);
|
|
}
|
|
|
|
async function createBinding(context: Pick<ProposalRunContext, "companyId" | "heartbeatRunId">, input: {
|
|
secretId?: string | null;
|
|
sourceConfigPath?: string | null;
|
|
secretProposalId?: string | null;
|
|
targetAgentId?: string | null;
|
|
configPath: string;
|
|
justification: string;
|
|
bindingTargetPolicy: "self_and_reports";
|
|
}) {
|
|
const referenceCount = [input.secretId, input.sourceConfigPath, input.secretProposalId]
|
|
.filter((value) => Boolean(value)).length;
|
|
if (referenceCount !== 1) {
|
|
throw badRequest(
|
|
"Binding proposals require exactly one of secretId, sourceConfigPath, or secretProposalId",
|
|
);
|
|
}
|
|
if (!CONFIG_PATH_RE.test(input.configPath)) throw unprocessable("configPath must use env.<KEY> or access.<ALIAS>");
|
|
if (input.sourceConfigPath && !CONFIG_PATH_RE.test(input.sourceConfigPath)) {
|
|
throw unprocessable("sourceConfigPath must use env.<KEY> or access.<ALIAS>");
|
|
}
|
|
if (!input.justification.trim()) throw unprocessable("Justification is required");
|
|
if (input.justification.trim().length > 20_000) throw unprocessable("Justification must be at most 20000 characters");
|
|
const { run, originIssueId } = await loadRunContext(db, context);
|
|
const targetAgentId = input.targetAgentId ?? run.agentId;
|
|
const [proposerAncestors, targetAncestors] = await Promise.all([
|
|
ancestorIds(db, context.companyId, run.agentId),
|
|
ancestorIds(db, context.companyId, targetAgentId),
|
|
]);
|
|
if (!bindingTargetAllowed(run.agentId, targetAgentId, targetAncestors)) {
|
|
throw forbidden("Binding proposals may target only the proposing agent or its reports");
|
|
}
|
|
let resolvedSecretId = input.secretId ?? null;
|
|
let sourceSecretLabel: string | null = null;
|
|
if (input.sourceConfigPath) {
|
|
const sourceBinding = await db.select({ secretId: companySecretBindings.secretId })
|
|
.from(companySecretBindings)
|
|
.where(and(
|
|
eq(companySecretBindings.companyId, context.companyId),
|
|
eq(companySecretBindings.targetType, "agent"),
|
|
eq(companySecretBindings.targetId, run.agentId),
|
|
eq(companySecretBindings.configPath, input.sourceConfigPath),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (sourceBinding) {
|
|
resolvedSecretId = sourceBinding.secretId;
|
|
} else if (run.responsibleUserId) {
|
|
const sourceDeclaration = await db
|
|
.select({ secretId: companySecrets.id })
|
|
.from(userSecretDeclarations)
|
|
.innerJoin(companySecrets, and(
|
|
eq(companySecrets.companyId, context.companyId),
|
|
eq(companySecrets.scope, "user"),
|
|
eq(companySecrets.ownerUserId, run.responsibleUserId),
|
|
eq(companySecrets.userSecretDefinitionId, userSecretDeclarations.userSecretDefinitionId),
|
|
eq(companySecrets.status, "active"),
|
|
))
|
|
.where(and(
|
|
eq(userSecretDeclarations.companyId, context.companyId),
|
|
eq(userSecretDeclarations.targetType, "agent"),
|
|
eq(userSecretDeclarations.targetId, run.agentId),
|
|
eq(userSecretDeclarations.configPath, input.sourceConfigPath),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
resolvedSecretId = sourceDeclaration?.secretId ?? null;
|
|
}
|
|
if (!resolvedSecretId) throw notFound("Source secret binding not found");
|
|
}
|
|
if (resolvedSecretId) {
|
|
const secret = await db.select().from(companySecrets).where(and(
|
|
eq(companySecrets.id, resolvedSecretId),
|
|
eq(companySecrets.companyId, context.companyId),
|
|
)).then((rows) => rows[0] ?? null);
|
|
const sourceBindingAllowsUserSecret = Boolean(input.sourceConfigPath) && secret?.scope === "user";
|
|
if (
|
|
!secret
|
|
|| secret.status === "deleted"
|
|
|| (secret.scope !== "company" && !sourceBindingAllowsUserSecret)
|
|
) {
|
|
throw notFound("Secret not found");
|
|
}
|
|
sourceSecretLabel = secret.name;
|
|
}
|
|
return createWithinQuota(
|
|
{ companyId: context.companyId, agentId: run.agentId, runId: run.id, issueId: originIssueId },
|
|
async (txDb) => {
|
|
if (resolvedSecretId) {
|
|
// Hold the secret against a concurrent delete: the delete marks it
|
|
// deleted first, then settles the proposals that reference it.
|
|
const live = await txDb.select({ status: companySecrets.status }).from(companySecrets)
|
|
.where(eq(companySecrets.id, resolvedSecretId)).for("share").then((rows) => rows[0] ?? null);
|
|
if (!live || live.status === "deleted") throw notFound("Secret not found");
|
|
}
|
|
if (input.secretProposalId) {
|
|
const dependency = await txDb.select().from(companySecretProposals).where(and(
|
|
eq(companySecretProposals.id, input.secretProposalId),
|
|
eq(companySecretProposals.companyId, context.companyId),
|
|
)).for("update").then((rows) => rows[0] ?? null);
|
|
if (!dependency || dependency.kind !== "secret") throw notFound("Secret proposal not found");
|
|
if (dependency.status !== "pending") {
|
|
throw unprocessable(
|
|
"Prerequisite secret proposal is no longer pending; use secretId to reference an approved secret",
|
|
);
|
|
}
|
|
sourceSecretLabel = dependency.proposedName;
|
|
}
|
|
const proposal = await txDb.insert(companySecretProposals).values({
|
|
companyId: context.companyId,
|
|
kind: "binding",
|
|
justification: input.justification.trim(),
|
|
secretId: resolvedSecretId,
|
|
secretProposalId: input.secretProposalId ?? null,
|
|
targetType: "agent",
|
|
targetId: targetAgentId,
|
|
configPath: input.configPath,
|
|
bindingTargetPolicySnapshot: input.bindingTargetPolicy,
|
|
proposerAncestorIdsSnapshot: proposerAncestors,
|
|
targetAncestorIdsSnapshot: targetAncestors,
|
|
proposedByAgentId: run.agentId,
|
|
originIssueId,
|
|
originRunId: run.id,
|
|
expiresAt: new Date(Date.now() + PENDING_EXPIRY_MS),
|
|
}).returning().then((rows) => rows[0]);
|
|
await recordCreated(proposal, txDb);
|
|
if (!sourceSecretLabel) throw conflict("Binding proposal source secret label is unavailable");
|
|
return createBindingInteraction(txDb, proposal, sourceSecretLabel);
|
|
},
|
|
);
|
|
}
|
|
|
|
async function enrich(proposal: Proposal) {
|
|
const [proposer, target, originIssue, secret, secretProposal] = await Promise.all([
|
|
db.select({ id: agents.id, name: agents.name, icon: agents.icon, appearance: agents.appearance }).from(agents)
|
|
.where(eq(agents.id, proposal.proposedByAgentId)).then((rows) => rows[0] ?? null),
|
|
proposal.targetId
|
|
? db.select({ id: agents.id, name: agents.name, icon: agents.icon, appearance: agents.appearance }).from(agents)
|
|
.where(eq(agents.id, proposal.targetId)).then((rows) => rows[0] ?? null)
|
|
: Promise.resolve(null),
|
|
proposal.originIssueId
|
|
? db.select({ id: issues.id, key: issues.identifier, title: issues.title }).from(issues)
|
|
.where(eq(issues.id, proposal.originIssueId)).then((rows) => rows[0] ?? null)
|
|
: Promise.resolve(null),
|
|
proposal.secretId
|
|
? db.select({ name: companySecrets.name }).from(companySecrets)
|
|
.where(eq(companySecrets.id, proposal.secretId)).then((rows) => rows[0] ?? null)
|
|
: Promise.resolve(null),
|
|
proposal.secretProposalId
|
|
? db.select({ proposedName: companySecretProposals.proposedName }).from(companySecretProposals)
|
|
.where(eq(companySecretProposals.id, proposal.secretProposalId)).then((rows) => rows[0] ?? null)
|
|
: Promise.resolve(null),
|
|
]);
|
|
if (!proposer) throw notFound("Proposal agent not found");
|
|
const {
|
|
valueCiphertext: _ciphertext,
|
|
bindingTargetPolicySnapshot: _policy,
|
|
proposerAncestorIdsSnapshot: _proposerAncestors,
|
|
targetAncestorIdsSnapshot: _targetAncestors,
|
|
...safe
|
|
} = proposal;
|
|
return {
|
|
...safe,
|
|
secretName: secret?.name ?? null,
|
|
secretProposalName: secretProposal?.proposedName ?? null,
|
|
proposedBy: withAgentAppearance(proposer),
|
|
target: target ? withAgentAppearance(target) : null,
|
|
originIssue,
|
|
};
|
|
}
|
|
|
|
async function listForAgent(
|
|
companyId: string,
|
|
agentId: string,
|
|
options: { limit?: number; offset?: number } = {},
|
|
) {
|
|
const rows = await db.select().from(companySecretProposals).where(and(
|
|
eq(companySecretProposals.companyId, companyId),
|
|
or(
|
|
eq(companySecretProposals.proposedByAgentId, agentId),
|
|
and(eq(companySecretProposals.kind, "binding"), eq(companySecretProposals.targetId, agentId)),
|
|
),
|
|
)).orderBy(desc(companySecretProposals.createdAt))
|
|
.limit(options.limit ?? DEFAULT_PROPOSAL_LIST_LIMIT)
|
|
.offset(options.offset ?? 0);
|
|
return Promise.all(rows.map(enrich));
|
|
}
|
|
|
|
async function listForBoard(
|
|
companyId: string,
|
|
status?: string | null,
|
|
options: { limit?: number; offset?: number } = {},
|
|
) {
|
|
const rows = await db.select().from(companySecretProposals).where(and(
|
|
eq(companySecretProposals.companyId, companyId),
|
|
status ? eq(companySecretProposals.status, status) : undefined,
|
|
)).orderBy(desc(companySecretProposals.createdAt))
|
|
.limit(options.limit ?? DEFAULT_PROPOSAL_LIST_LIMIT)
|
|
.offset(options.offset ?? 0);
|
|
return Promise.all(rows.map(enrich));
|
|
}
|
|
|
|
async function assertBindingSnapshotCurrent(proposal: Proposal, dbClient: Db = db, lockForUpdate = false) {
|
|
if (proposal.kind !== "binding" || !proposal.targetId) return;
|
|
const snapshotAllowed = proposal.bindingTargetPolicySnapshot === "self_and_reports"
|
|
&& bindingTargetAllowed(proposal.proposedByAgentId, proposal.targetId, proposal.targetAncestorIdsSnapshot ?? []);
|
|
const currentTargetAncestors = await ancestorIds(
|
|
dbClient,
|
|
proposal.companyId,
|
|
proposal.targetId,
|
|
lockForUpdate,
|
|
);
|
|
const currentAllowed = bindingTargetAllowed(proposal.proposedByAgentId, proposal.targetId, currentTargetAncestors);
|
|
if (!snapshotAllowed || !currentAllowed) {
|
|
throw conflict("Binding proposal target is no longer allowed by its proposal-time and current chain-of-command policy");
|
|
}
|
|
}
|
|
|
|
async function applySecretApproval(txDb: Db, proposal: Proposal, input: {
|
|
resolvedByUserId: string;
|
|
overrides?: { name?: string; description?: string | null; providerConfigId?: string | null };
|
|
}) {
|
|
if (!proposal.valueCiphertext) throw conflict("Proposed secret value is no longer available");
|
|
const name = input.overrides?.name?.trim() || proposal.proposedName || "";
|
|
if (!SECRET_NAME_RE.test(name)) throw unprocessable("Secret name must be a slash-separated path without empty segments");
|
|
const value = await getSecretProvider("local_encrypted").resolveVersion({
|
|
material: proposal.valueCiphertext,
|
|
externalRef: null,
|
|
});
|
|
const secrets = secretService(txDb);
|
|
const providerConfigId = input.overrides?.providerConfigId ?? null;
|
|
const providerConfig = providerConfigId
|
|
? await secrets.getProviderConfigById(providerConfigId)
|
|
: null;
|
|
if (providerConfigId && (!providerConfig || providerConfig.companyId !== proposal.companyId)) {
|
|
throw notFound("Provider vault not found");
|
|
}
|
|
const provider = (providerConfig?.provider ?? "local_encrypted") as SecretProvider;
|
|
const created = await secrets.create(
|
|
proposal.companyId,
|
|
{
|
|
name,
|
|
key: proposal.proposedKey || normalizeSecretKey(name.split("/").at(-1) || ""),
|
|
provider,
|
|
providerConfigId,
|
|
value,
|
|
description: input.overrides?.description === undefined
|
|
? proposal.proposedDescription
|
|
: input.overrides.description,
|
|
},
|
|
{ userId: input.resolvedByUserId, agentId: proposal.proposedByAgentId },
|
|
);
|
|
await logActivity(txDb, {
|
|
companyId: proposal.companyId,
|
|
actorType: "user",
|
|
actorId: input.resolvedByUserId,
|
|
action: "secret.created",
|
|
entityType: "secret",
|
|
entityId: created.id,
|
|
agentId: proposal.proposedByAgentId,
|
|
runId: proposal.originRunId,
|
|
details: { name: created.name, provider: created.provider, proposalId: proposal.id },
|
|
});
|
|
return created;
|
|
}
|
|
|
|
async function markApproved(txDb: Db, proposal: Proposal, input: {
|
|
resolvedByUserId: string;
|
|
createdSecretId?: string | null;
|
|
appliedBindingConfigPath?: string | null;
|
|
}) {
|
|
const now = new Date();
|
|
await notifyDeliveryWork(txDb, DELIVERY_QUEUES.secretProposalExpiry, proposal.companyId);
|
|
const updated = await txDb.update(companySecretProposals).set({
|
|
status: "approved",
|
|
resolvedByUserId: input.resolvedByUserId,
|
|
resolvedAt: now,
|
|
createdSecretId: input.createdSecretId ?? null,
|
|
appliedBindingConfigPath: input.appliedBindingConfigPath ?? null,
|
|
valueCiphertext: null,
|
|
ciphertextScrubbedAt: now,
|
|
updatedAt: now,
|
|
}).where(and(
|
|
eq(companySecretProposals.id, proposal.id),
|
|
eq(companySecretProposals.status, "pending"),
|
|
)).returning().then((rows) => rows[0] ?? null);
|
|
if (!updated) throw conflict("Proposal is no longer pending");
|
|
await logActivity(txDb, {
|
|
companyId: proposal.companyId,
|
|
actorType: "user",
|
|
actorId: input.resolvedByUserId,
|
|
action: "secret.proposal.approved",
|
|
entityType: "company_secret_proposal",
|
|
entityId: proposal.id,
|
|
agentId: proposal.proposedByAgentId,
|
|
runId: proposal.originRunId,
|
|
details: {
|
|
issueId: proposal.originIssueId,
|
|
createdSecretId: input.createdSecretId ?? null,
|
|
appliedBindingConfigPath: input.appliedBindingConfigPath ?? null,
|
|
ciphertextScrubbed: true,
|
|
},
|
|
});
|
|
await reflectProposalLifecycleOnInteraction(txDb, proposal, "approved", {
|
|
resolvedByUserId: input.resolvedByUserId,
|
|
});
|
|
return updated;
|
|
}
|
|
|
|
function isBlankConfigValue(value: unknown) {
|
|
if (value === undefined || value === "") return true;
|
|
const record = asRecord(value);
|
|
return record.type === "plain" && record.value === "";
|
|
}
|
|
|
|
async function applyBindingApproval(
|
|
txDb: Db,
|
|
proposal: Proposal,
|
|
secret: typeof companySecrets.$inferSelect,
|
|
resolvedByUserId: string,
|
|
) {
|
|
if (!proposal.targetId || !proposal.configPath) throw conflict("Binding proposal is incomplete");
|
|
const agentSvc = agentService(txDb);
|
|
const target = await agentSvc.getById(proposal.targetId);
|
|
if (!target || target.companyId !== proposal.companyId) throw notFound("Target agent not found");
|
|
const adapterConfig = { ...asRecord(target.adapterConfig) };
|
|
const [namespace, key] = proposal.configPath.split(".", 2);
|
|
const userSecretDefinition = secret.scope === "user" && secret.userSecretDefinitionId
|
|
? await txDb.select({ key: userSecretDefinitions.key }).from(userSecretDefinitions).where(and(
|
|
eq(userSecretDefinitions.id, secret.userSecretDefinitionId),
|
|
eq(userSecretDefinitions.companyId, proposal.companyId),
|
|
eq(userSecretDefinitions.status, "active"),
|
|
)).then((rows) => rows[0] ?? null)
|
|
: null;
|
|
if (secret.scope === "user" && !userSecretDefinition) {
|
|
throw conflict("Binding proposal user secret definition is not active");
|
|
}
|
|
const binding = userSecretDefinition
|
|
? {
|
|
type: "user_secret_ref",
|
|
key: userSecretDefinition.key,
|
|
version: "latest",
|
|
required: true,
|
|
allowMissingOverride: false,
|
|
}
|
|
: { type: "secret_ref", secretId: secret.id, version: "latest" };
|
|
if (namespace === "env") {
|
|
const env = { ...asRecord(adapterConfig.env) };
|
|
const existing = env[key];
|
|
if (!isBlankConfigValue(existing) && JSON.stringify(existing) !== JSON.stringify(binding)) {
|
|
throw conflict(`Agent config path already exists: ${proposal.configPath}`);
|
|
}
|
|
adapterConfig.env = { ...env, [key]: binding };
|
|
} else {
|
|
const existing = adapterConfig[proposal.configPath];
|
|
if (!isBlankConfigValue(existing) && JSON.stringify(existing) !== JSON.stringify(binding)) {
|
|
throw conflict(`Agent config path already exists: ${proposal.configPath}`);
|
|
}
|
|
adapterConfig[proposal.configPath] = binding;
|
|
}
|
|
const updated = await updateAgentConfigurationInTransaction(txDb, target.id, { adapterConfig }, {
|
|
recordRevision: { createdByUserId: resolvedByUserId, source: "patch" },
|
|
});
|
|
if (!updated) throw notFound("Target agent not found");
|
|
await logActivity(txDb, {
|
|
companyId: proposal.companyId,
|
|
actorType: "user",
|
|
actorId: resolvedByUserId,
|
|
action: "agent.updated",
|
|
entityType: "agent",
|
|
entityId: target.id,
|
|
details: { adapterConfig: true, proposalId: proposal.id, configPath: proposal.configPath },
|
|
});
|
|
}
|
|
|
|
async function approve(companyId: string, proposalId: string, input: {
|
|
resolvedByUserId: string;
|
|
cascade?: boolean;
|
|
overrides?: { name?: string; description?: string | null; providerConfigId?: string | null };
|
|
assertCanResolve?: (proposal: Proposal, txDb: Db) => Promise<void>;
|
|
}) {
|
|
const loadForApproval = async (txDb: Db) => {
|
|
const proposal = await requirePending(companyId, proposalId, txDb, true);
|
|
assertNotExpired(proposal);
|
|
await input.assertCanResolve?.(proposal, txDb);
|
|
await assertBindingSnapshotCurrent(proposal, txDb, true);
|
|
return proposal;
|
|
};
|
|
const saved = await db.transaction(async (tx) => {
|
|
const txDb = tx as unknown as Db;
|
|
const proposal = await loadForApproval(txDb);
|
|
if (proposal.kind === "secret") {
|
|
const created = await applySecretApproval(txDb, proposal, input);
|
|
return { kind: "secret" as const, proposal: await markApproved(txDb, proposal, {
|
|
resolvedByUserId: input.resolvedByUserId,
|
|
createdSecretId: created.id,
|
|
}) };
|
|
}
|
|
|
|
let secretId = proposal.secretId;
|
|
if (proposal.secretProposalId) {
|
|
const dependency = await getById(companyId, proposal.secretProposalId, txDb, true);
|
|
if (!dependency || dependency.kind !== "secret") throw notFound("Prerequisite secret proposal not found");
|
|
if (dependency.status !== "pending") {
|
|
if (dependency.status !== "approved" || !dependency.createdSecretId) {
|
|
throw conflict(`Prerequisite secret proposal ${dependency.id} is not approvable`);
|
|
}
|
|
secretId = dependency.createdSecretId;
|
|
} else {
|
|
if (!input.cascade) {
|
|
throw conflict(`Binding proposal requires pending secret proposal ${dependency.id}; retry with cascade=true`);
|
|
}
|
|
assertNotExpired(dependency);
|
|
const created = await applySecretApproval(txDb, dependency, input);
|
|
await markApproved(txDb, dependency, {
|
|
resolvedByUserId: input.resolvedByUserId,
|
|
createdSecretId: created.id,
|
|
});
|
|
secretId = created.id;
|
|
}
|
|
}
|
|
if (!secretId) throw conflict("Binding proposal has no approved secret");
|
|
return { kind: "binding" as const, secretId };
|
|
});
|
|
if (saved.kind === "secret") return saved.proposal;
|
|
|
|
// Keep the approved secret if applying its reference fails.
|
|
return db.transaction(async tx => {
|
|
const txDb = tx as unknown as Db;
|
|
const proposal = await loadForApproval(txDb);
|
|
const liveSecret = await secretService(txDb).getById(saved.secretId);
|
|
if (!liveSecret || liveSecret.companyId !== companyId || liveSecret.status !== "active") {
|
|
throw conflict("Binding proposal secret is not active");
|
|
}
|
|
await applyBindingApproval(txDb, proposal, liveSecret, input.resolvedByUserId);
|
|
return markApproved(txDb, proposal, {
|
|
resolvedByUserId: input.resolvedByUserId,
|
|
appliedBindingConfigPath: proposal.configPath,
|
|
});
|
|
});
|
|
}
|
|
|
|
async function transition(companyId: string, proposalId: string, status: Exclude<SecretProposalTerminalStatus, "approved">, input: {
|
|
resolvedByUserId?: string | null;
|
|
reason?: string | null;
|
|
proposerAgentId?: string | null;
|
|
} = {}) {
|
|
const proposal = await requirePending(companyId, proposalId);
|
|
if (status === "withdrawn" && proposal.proposedByAgentId !== input.proposerAgentId) {
|
|
throw forbidden("Only the proposer can withdraw this proposal");
|
|
}
|
|
const now = new Date();
|
|
return db.transaction(async (tx) => {
|
|
const txDb = tx as unknown as Db;
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.secretProposalExpiry, companyId);
|
|
const updated = await tx.update(companySecretProposals).set({
|
|
status,
|
|
resolvedByUserId: input.resolvedByUserId ?? null,
|
|
resolvedAt: now,
|
|
resolutionReason: input.reason ?? null,
|
|
valueCiphertext: null,
|
|
ciphertextScrubbedAt: now,
|
|
updatedAt: now,
|
|
}).where(and(eq(companySecretProposals.id, proposalId), eq(companySecretProposals.status, "pending")))
|
|
.returning().then((rows) => rows[0] ?? null);
|
|
if (!updated) throw conflict("Proposal is no longer pending");
|
|
const dependents = proposal.kind === "secret" && (status === "rejected" || status === "expired" || status === "withdrawn")
|
|
? await tx.update(companySecretProposals).set({
|
|
status: "rejected",
|
|
resolvedByUserId: input.resolvedByUserId ?? null,
|
|
resolvedAt: now,
|
|
resolutionReason: `Dependent secret proposal ${proposal.id} was ${status}`,
|
|
valueCiphertext: null,
|
|
ciphertextScrubbedAt: now,
|
|
updatedAt: now,
|
|
}).where(and(
|
|
eq(companySecretProposals.companyId, companyId),
|
|
eq(companySecretProposals.status, "pending"),
|
|
eq(companySecretProposals.secretProposalId, proposal.id),
|
|
)).returning()
|
|
: [];
|
|
const actorType = input.resolvedByUserId ? "user" as const : status === "withdrawn" ? "agent" as const : "system" as const;
|
|
const actorId = input.resolvedByUserId ?? input.proposerAgentId ?? "system";
|
|
await logActivity(txDb, {
|
|
companyId,
|
|
actorType,
|
|
actorId,
|
|
action: `secret.proposal.${status}`,
|
|
entityType: "company_secret_proposal",
|
|
entityId: proposal.id,
|
|
agentId: proposal.proposedByAgentId,
|
|
runId: proposal.originRunId,
|
|
details: { ciphertextScrubbed: true, issueId: proposal.originIssueId, reason: input.reason ?? null },
|
|
});
|
|
for (const dependent of dependents) {
|
|
await logActivity(txDb, {
|
|
companyId,
|
|
actorType,
|
|
actorId,
|
|
action: "secret.proposal.rejected",
|
|
entityType: "company_secret_proposal",
|
|
entityId: dependent.id,
|
|
agentId: dependent.proposedByAgentId,
|
|
runId: dependent.originRunId,
|
|
details: {
|
|
ciphertextScrubbed: true,
|
|
issueId: dependent.originIssueId,
|
|
reason: dependent.resolutionReason,
|
|
cascadeFromProposalId: proposal.id,
|
|
},
|
|
});
|
|
await reflectProposalLifecycleOnInteraction(txDb, dependent, "rejected", {
|
|
resolvedByUserId: input.resolvedByUserId,
|
|
reason: dependent.resolutionReason,
|
|
});
|
|
}
|
|
await reflectProposalLifecycleOnInteraction(txDb, proposal, status, {
|
|
resolvedByUserId: input.resolvedByUserId,
|
|
reason: input.reason ?? (status === "expired" ? "Pending proposal expired" : null),
|
|
});
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
async function sweepExpired(
|
|
now = new Date(),
|
|
limit = DEFAULT_EXPIRY_SWEEP_LIMIT,
|
|
expireProposal: (companyId: string, proposalId: string) => Promise<unknown> =
|
|
(companyId, proposalId) => transition(companyId, proposalId, "expired", { reason: "Pending proposal expired" }),
|
|
companyId?: string,
|
|
) {
|
|
const expired = await db.select({ id: companySecretProposals.id, companyId: companySecretProposals.companyId })
|
|
.from(companySecretProposals)
|
|
.where(and(eq(companySecretProposals.status, "pending"), lte(companySecretProposals.expiresAt, now),
|
|
companyId === undefined ? undefined : eq(companySecretProposals.companyId, companyId)))
|
|
.orderBy(companySecretProposals.expiresAt)
|
|
.limit(limit);
|
|
let expiredCount = 0;
|
|
for (const proposal of expired) {
|
|
try {
|
|
await expireProposal(proposal.companyId, proposal.id);
|
|
expiredCount += 1;
|
|
} catch (error) {
|
|
if (error instanceof HttpError && error.status === 409) continue;
|
|
throw error;
|
|
}
|
|
}
|
|
return expiredCount;
|
|
}
|
|
|
|
async function sweepExpiredForCompany(companyId: string) {
|
|
return sweepExpired(new Date(), DEFAULT_EXPIRY_SWEEP_LIMIT, undefined, companyId);
|
|
}
|
|
|
|
async function expiryDeadlines(companyId?: string) {
|
|
const rows = await db.select({ companyId: companySecretProposals.companyId,
|
|
at: sql<string | Date>`min(${companySecretProposals.expiresAt})` })
|
|
.from(companySecretProposals)
|
|
.where(and(eq(companySecretProposals.status, "pending"),
|
|
companyId === undefined ? undefined : eq(companySecretProposals.companyId, companyId)))
|
|
.groupBy(companySecretProposals.companyId);
|
|
return rows.map(row => ({ companyId: row.companyId, dueAt: new Date(row.at).getTime() }));
|
|
}
|
|
|
|
async function nextExpiryAt(): Promise<number | null> {
|
|
const [row] = await db.select({ at: sql<string | Date | null>`min(${companySecretProposals.expiresAt})` })
|
|
.from(companySecretProposals).where(eq(companySecretProposals.status, "pending"));
|
|
return row?.at == null ? null : new Date(row.at).getTime();
|
|
}
|
|
|
|
/**
|
|
* Deleting a secret also deletes the binding proposals that reference it.
|
|
* Settle pending ones first, so their review cards close as rejected with
|
|
* the reason instead of pointing at a proposal that no longer exists. Call
|
|
* it after the secret is marked deleted: createBinding holds the secret row
|
|
* and refuses a deleted one, so no new pending proposal can follow.
|
|
*/
|
|
async function rejectPendingBindingsForSecret(companyId: string, secretId: string, input: { resolvedByUserId?: string | null; reason: string }) {
|
|
const pending = await db.select().from(companySecretProposals).where(and(
|
|
eq(companySecretProposals.companyId, companyId),
|
|
eq(companySecretProposals.secretId, secretId),
|
|
eq(companySecretProposals.kind, "binding"),
|
|
eq(companySecretProposals.status, "pending"),
|
|
));
|
|
const rejected: Proposal[] = [];
|
|
for (const row of pending) {
|
|
try {
|
|
await transition(companyId, row.id, "rejected", {
|
|
resolvedByUserId: input.resolvedByUserId ?? null,
|
|
reason: input.reason,
|
|
});
|
|
rejected.push(row);
|
|
} catch (error) {
|
|
// Resolved concurrently; the secret deletion still removes the row.
|
|
if (!(error instanceof HttpError) || error.status !== 409) throw error;
|
|
}
|
|
}
|
|
return rejected;
|
|
}
|
|
|
|
return { getById, view: enrich, createSecret, createBinding, listForAgent, listForBoard, assertBindingSnapshotCurrent, approve, transition, rejectPendingBindingsForSecret, sweepExpired, sweepExpiredForCompany, expiryDeadlines, nextExpiryAt };
|
|
}
|