import { randomUUID } from "node:crypto"; import { and, desc, eq, inArray, like, ne, notInArray, notLike, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, companies, companySecretBindings, companySecretProviderConfigs, companySecrets, companySecretVersions, companyMemberships, environments, heartbeatRuns, issues, projects, routines, secretAccessEvents, userSecretDeclarations, userSecretDefinitions, } from "@paperclipai/db"; import type { AgentApiKeyScope, AgentEnvConfig, CompanySecretBindingTarget, EnvBinding, RemoteSecretImportCandidate, RemoteSecretImportConflict, RemoteSecretImportRowResult, SecretProviderConfigDiscoveryPreviewResult, SecretBindingTargetType, SecretProjectionClass, SecretProvider, SecretProviderConfigHealthResponse, SecretProviderConfigHealthStatus, SecretProviderConfigStatus, SecretVersionSelector, } from "@paperclipai/shared"; import { CLASS3_STATIC_LEASE_ALLOWLIST, createSecretProviderConfigSchema, deriveProjectUrlKey, envBindingSchema, isUuidLike, normalizeAgentUrlKey, secretProviderConfigPayloadSchema, secretProviderConfigDiscoveryPreviewSchema, updateSecretProviderConfigSchema, } from "@paperclipai/shared"; import { conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; import { logger } from "../middleware/logger.js"; import { checkSecretProviders, getSecretProvider, listSecretProviders, } from "../secrets/provider-registry.js"; import type { PreparedSecretVersion, RemoteSecretListResult, SecretProviderHealthCheck, SecretProviderModule, SecretProviderVaultRuntimeConfig, SecretProviderWriteContext, } from "../secrets/types.js"; import { isSecretProviderClientError } from "../secrets/types.js"; import { authorizationDeniedDetails, authorizationService } from "./authorization.js"; import { findActiveServerAdapter } from "../adapters/index.js"; import { logActivity } from "./activity-log.js"; // Only a `local_encrypted` secret can hold a literal directory path, so only a // `local_encrypted` secret can ever name a Codex account-home directory. A // create or a rotate that writes a new `local_encrypted` value runs inside // this lock for its whole call, so it can never commit a value in the exact // window an account-home cleanup's claimant scan already decided "no // claimant" but has not yet deleted the directory. See // `withAccountHomeSecretMutationLock`'s own comment in the codex-local // adapter for the full race this closes. // // The lock alone still lets a queued write commit a directory the cleanup // already removed, once the cleanup's own lock-holding section runs first and // frees the lock. `assertAccountHomeCacheDirStillValid` closes that window: a // create or a rotate calls it inside the same lock, right before it commits, // so a value that named a directory the cleanup just deleted fails instead of // writing a secret that points at nothing. import { assertAccountHomeCacheDirStillValid, withAccountHomeSecretMutationLock, } from "@paperclipai/adapter-codex-local/server"; const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; const AGENT_ACCESS_CONFIG_PATH_PREFIX = "access."; // System consumer id for a durable orphan-sandbox teardown. The cleanup sweep // resolves the recorded connection secret under this id so the audit trail // marks the read as an orphan-sandbox teardown, not a normal environment read. export const SANDBOX_CLEANUP_CONSUMER_ID = "environment-sandbox-cleanup"; // System consumer id for a device-login account-home secret check. A device // login resolves a pre-existing secret's value under this id, so the audit // trail marks the read as a same-account idempotency check, not a normal // runtime bind. export const DEVICE_LOGIN_SECRET_CHECK_CONSUMER_ID = "device-login-secret-check"; const SENSITIVE_ENV_KEY_RE = /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; const REDACTED_SENTINEL = "***REDACTED***"; const COMING_SOON_SECRET_PROVIDERS: ReadonlySet = new Set([ "gcp_secret_manager", "vault", ]); const FALLBACK_ADAPTER_SCHEMA_SECRET_FIELDS: Readonly> = { hermes_gateway: ["apiKey"], }; const USER_SECRET_DEFINITION_KEY_UNIQUE_CONSTRAINT = "user_secret_definitions_company_key_uq"; const USER_SECRET_VALUE_UNIQUE_CONSTRAINT = "company_secrets_user_definition_owner_uq"; // The unique index on (secretId, version). A concurrent rotation that inserts the // same next version first makes the loser's insert fail this constraint. const COMPANY_SECRET_VERSION_UNIQUE_CONSTRAINT = "company_secret_versions_secret_version_uq"; // The one stale-rotation conflict text. The rotate function returns it for every // stale race, so the caller sees one fixed 409 and no owner-value state. const SECRET_VERSION_STALE_CONFLICT = "The secret version is stale. Reload and confirm the rotation again."; // The fixed Claude Code OAuth user-secret definition. The Claude login flow owns // only this compile-time key and these fixed properties. A caller never selects // the key, the name, the provider, the mode, or the status. const CLAUDE_CODE_OAUTH_TOKEN_KEY = "CLAUDE_CODE_OAUTH_TOKEN"; const CLAUDE_CODE_OAUTH_DEFINITION = { key: CLAUDE_CODE_OAUTH_TOKEN_KEY, name: "Claude Code OAuth token", provider: "local_encrypted", managedMode: "paperclip_managed", status: "active", } as const; // The fixed, non-secret conflict text. The helper returns it when a stored // definition for the fixed key does not match the fixed shape. The text echoes // no caller input. const CLAUDE_OAUTH_DEFINITION_CONFLICT = "A conflicting Claude Code OAuth token definition already exists."; // The fixed, non-secret text for a stale confirmed rotation. The text is the // same for every stale reason, so it discloses no owner-value state. const CLAUDE_OAUTH_STALE_CONFIRMATION = "The Claude login confirmation is stale. Reload the page and confirm again."; // The fixed, non-secret text for a first write that finds an existing value. The // caller must confirm a replacement to rotate it. const CLAUDE_OAUTH_VALUE_EXISTS = "A Claude login value already exists. Confirm a replacement to rotate it."; // The metadata field that records the setup-token session id on the owner value. // It is the idempotency key for one completion. It is not a secret. const CLAUDE_OAUTH_SESSION_METADATA_FIELD = "claudeSetupTokenSessionId"; /** The stored result of one owner-bound Claude OAuth completion. It holds no secret. */ export interface ClaudeOAuthUserSecretResult { secretId: string; latestVersion: number; definitionId: string; } // --- The server-enforced Claude OAuth binding invariant ------------ /** The adapter that owns the fixed Claude Code OAuth token binding. */ export const CLAUDE_LOCAL_ADAPTER_TYPE = "claude_local"; // The one fixed, non-secret error for every rejected stored-session claim. The // text is byte-identical for a missing, foreign, cross-company, cross-owner, // cross-adapter, cross-environment, expired, non-stored, or already-consumed // claim, so a caller cannot tell the reasons apart. export const CLAUDE_OAUTH_CLAIM_REJECTED = "The Claude login binding requires a valid stored-session claim."; // The generic credential-conflict text. It names no token value and no owner // configuration. It tells the caller only that a higher-priority credential is // configured together with the Claude login token. export const CLAUDE_OAUTH_CREDENTIAL_CONFLICT = "A higher-priority Claude credential is configured. Remove it to use the Claude login token."; const ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY"; /** Reads the `env` record of an adapter config, or an empty record. */ function readAdapterEnvRecord(config: unknown): Record { if (typeof config !== "object" || config === null || Array.isArray(config)) return {}; const env = (config as Record).env; if (typeof env !== "object" || env === null || Array.isArray(env)) return {}; return env as Record; } /** * Returns true when a binding is the exact fixed Claude Code OAuth user-secret * reference. The fixed binding is a `user_secret_ref` whose key is the fixed * key. Any other shape (a plain value, a company secret reference, or a * different user-secret key) is a replacement or a weaker binding. */ export function isFixedClaudeOAuthBinding(binding: unknown): boolean { if (typeof binding !== "object" || binding === null) return false; const record = binding as Record; return record.type === "user_secret_ref" && record.key === CLAUDE_CODE_OAUTH_TOKEN_KEY; } /** * Reads the `CLAUDE_CODE_OAUTH_TOKEN` binding from an adapter config, or * `null` when the config carries no such key. */ export function readClaudeOAuthBinding(config: unknown): unknown { const value = readAdapterEnvRecord(config)[CLAUDE_CODE_OAUTH_TOKEN_KEY]; return value === undefined ? null : value; } /** * Returns true when both bindings are the exact fixed Claude Code OAuth * reference and select the exact same secret version. The hire-inheritance * gate compares the parent's current reference, re-read inside the write * transaction, against the reference already copied onto the child before the * transaction started. A concurrent version change on the parent must fail * this check, so the child never keeps a stale version under a claim the gate * treats as current. */ export function claudeOAuthBindingsMatchExactly(parentBinding: unknown, childBinding: unknown): boolean { if (!isFixedClaudeOAuthBinding(parentBinding) || !isFixedClaudeOAuthBinding(childBinding)) return false; const parentVersion = (parentBinding as Record).version; const childVersion = (childBinding as Record).version; return parentVersion === childVersion; } /** True when the config carries the exact fixed OAuth binding. */ function hasFixedClaudeOAuthBinding(config: unknown): boolean { return isFixedClaudeOAuthBinding(readAdapterEnvRecord(config)[CLAUDE_CODE_OAUTH_TOKEN_KEY]); } /** * Returns true when the config delivers a non-empty ANTHROPIC_API_KEY. A plain * binding counts only when it has a non-empty value. A company or user secret * reference always counts, because it resolves to a value at runtime. */ function hasAnthropicApiKeyCredential(config: unknown): boolean { const binding = readAdapterEnvRecord(config)[ANTHROPIC_API_KEY_ENV]; if (typeof binding === "string") return binding.trim().length > 0; if (typeof binding !== "object" || binding === null) return false; const record = binding as Record; if (record.type === "plain") { return typeof record.value === "string" && record.value.trim().length > 0; } return record.type === "secret_ref" || record.type === "user_secret_ref"; } export interface ClaudeOAuthBindingInvariantInput { /** The effective adapter type of the write. */ adapterType: string | null | undefined; /** The normalized adapter config the write persists. */ nextConfig: unknown; /** The stored adapter config before the write. Null on a create. */ priorConfig?: unknown; } export interface ClaudeOAuthBindingInvariantDecision { /** True when the write adds the fixed binding that the prior config lacked. */ introducesBinding: boolean; /** True when the write keeps an existing fixed binding unchanged. */ keepsBinding: boolean; } /** * The Claude OAuth binding check. It runs after generic normalization and * before every database write on a `claude_local` create, hire, update, * approval activation, and configuration rollback path. * * The `CLAUDE_CODE_OAUTH_TOKEN` binding behaves like a normal environment * variable. A normal write can remove the fixed binding, or re-point it to a * plain value, a company-secret reference, or a different user-secret key. The * function no longer locks a prior fixed binding against removal or replacement. * * A write to a non-claude_local adapter that has no prior fixed binding stays * outside the Claude login flow. A prior fixed binding keeps the function active * for the write, so the function still reports whether the write keeps that * binding, independently of the destination adapter type. * * The precedence policy runs on every path: the fixed binding together with a * non-empty ANTHROPIC_API_KEY is a conflict. The function rejects that conflict * with a generic message that names no token value and no owner configuration. * * The function returns whether the write introduces the fixed binding or keeps * an existing one. The create and hire paths consume a stored-session claim when * the write introduces the binding. The update, approval, and rollback paths * reject a newly introduced binding, because they carry no claim. */ export function assertClaudeOAuthBindingInvariant( input: ClaudeOAuthBindingInvariantInput, ): ClaudeOAuthBindingInvariantDecision { const isClaudeLocal = input.adapterType === CLAUDE_LOCAL_ADAPTER_TYPE; const nextIsFixed = hasFixedClaudeOAuthBinding(input.nextConfig); const priorIsFixed = hasFixedClaudeOAuthBinding(input.priorConfig); // A write to a non-claude_local adapter that has no prior fixed binding is a // normal non-Claude configuration. It stays outside the Claude login flow. A // prior fixed binding always keeps the function active, so a write still // reports the binding state after a move to another adapter type. if (!isClaudeLocal && !priorIsFixed) { return { introducesBinding: false, keepsBinding: false }; } if (nextIsFixed && hasAnthropicApiKeyCredential(input.nextConfig)) { throw new HttpError(409, CLAUDE_OAUTH_CREDENTIAL_CONFLICT, { code: "claude_oauth_credential_conflict", }); } return { introducesBinding: nextIsFixed && !priorIsFixed, keepsBinding: nextIsFixed && priorIsFixed, }; } /** The fixed error the create and hire paths raise for a rejected claim. */ export function claudeOAuthClaimRejectedError(): HttpError { return new HttpError(409, CLAUDE_OAUTH_CLAIM_REJECTED, { code: "claude_oauth_claim_rejected" }); } type DbTransaction = Parameters[0]>[0]; type SecretBindingDb = Pick; function isUniqueConstraintViolation(error: unknown, constraintName: string) { const seen = new Set(); let current = error; while (typeof current === "object" && current !== null && !seen.has(current)) { seen.add(current); const maybe = current as { code?: string; constraint?: string; constraint_name?: string; cause?: unknown; }; const constraint = maybe.constraint ?? maybe.constraint_name; if (maybe.code === "23505" && constraint === constraintName) return true; current = maybe.cause; } return false; } function remoteProviderHttpError(error: unknown, context: { companyId: string; provider: SecretProvider; providerConfigId: string; operation: string; providerConfig?: Record | null; }): HttpError { if (isSecretProviderClientError(error)) { logger.warn( { companyId: context.companyId, provider: context.provider, providerConfigId: context.providerConfigId, operation: context.operation, providerErrorCode: error.code, }, "remote secret provider request failed", ); return new HttpError(error.status, error.message, safeRemoteProviderErrorDetails(error, context)); } if (error instanceof HttpError) return error; logger.warn( { companyId: context.companyId, provider: context.provider, providerConfigId: context.providerConfigId, operation: context.operation, providerErrorCode: "provider_error", }, "remote secret provider request failed", ); return new HttpError(502, "Remote secret provider request failed.", safeRemoteProviderErrorDetails(null, context)); } function remoteProviderWriteHttpError(error: unknown, context: { companyId: string; provider: SecretProvider; providerConfigId?: string | null; providerConfig: SecretProviderVaultRuntimeConfig | null; operation: string; }): HttpError { return remoteProviderHttpError(error, { companyId: context.companyId, provider: context.provider, providerConfigId: context.providerConfig?.id ?? context.providerConfigId ?? "deployment-default", operation: context.operation, providerConfig: context.providerConfig?.config ?? null, }); } async function throwProviderWriteOrReservedRowRollbackError(input: { error: unknown; rollbackReservedRow: () => Promise; companyId: string; provider: SecretProvider; providerConfigId?: string | null; providerConfig: SecretProviderVaultRuntimeConfig | null; operation: string; }): Promise { const providerError = remoteProviderWriteHttpError(input.error, input); try { await input.rollbackReservedRow(); } catch (rollbackError) { const providerConfigId = input.providerConfig?.id ?? input.providerConfigId ?? "deployment-default"; logger.warn( { err: rollbackError, providerErr: providerError, companyId: input.companyId, provider: input.provider, providerConfigId, operation: input.operation, }, "remote secret provider write failed and reserved secret rollback failed", ); throw new HttpError(500, "Secret create failed and Paperclip could not roll back the local secret reservation.", { code: "secret_create_rollback_failed", provider: input.provider, operation: input.operation, providerConfigId, providerError: { status: providerError.status, message: providerError.message, details: providerError.details ?? null, }, }); } throw providerError; } function providerConfigIdentifier(input: { providerConfigId?: string | null; providerConfig: SecretProviderVaultRuntimeConfig | null; }) { return input.providerConfig?.id ?? input.providerConfigId ?? "deployment-default"; } async function deleteLocalSecretCreateReservationOrThrow(input: { db: Pick; secretId: string; companyId: string; provider: SecretProvider; providerConfigId?: string | null; providerConfig: SecretProviderVaultRuntimeConfig | null; operation: string; }) { try { await input.db.delete(companySecretVersions).where(eq(companySecretVersions.secretId, input.secretId)); await input.db.delete(companySecrets).where(eq(companySecrets.id, input.secretId)); } catch (rollbackError) { const providerConfigId = providerConfigIdentifier(input); logger.warn( { err: rollbackError, companyId: input.companyId, provider: input.provider, providerConfigId, operation: input.operation, }, "secret create failed and local reserved secret rollback failed", ); throw new HttpError(500, "Secret create failed and Paperclip could not roll back the local secret reservation.", { code: "secret_create_rollback_failed", provider: input.provider, operation: input.operation, providerConfigId, }); } } function throwProviderCleanupFailedAfterCreateRollback(input: { companyId: string; provider: SecretProvider; providerConfigId?: string | null; providerConfig: SecretProviderVaultRuntimeConfig | null; operation: string; }): never { const providerConfigId = providerConfigIdentifier(input); throw new HttpError(500, "Secret create failed and Paperclip could not clean up the remote provider secret.", { code: "secret_create_provider_cleanup_failed", provider: input.provider, operation: input.operation, providerConfigId, localCleanupHandle: true, }); } function safeRemoteProviderErrorDetails( error: { code: string } | null, context: { provider: SecretProvider; providerConfigId: string; operation: string; providerConfig?: Record | null; }, ): Record { if ( context.provider !== "aws_secrets_manager" || context.operation !== "secret_provider_config.discovery.preview" ) { if (context.provider !== "aws_secrets_manager") { return { code: error?.code ?? "provider_error" }; } const details: Record = { code: error?.code ?? "provider_error", provider: context.provider, operation: context.operation, providerConfigId: context.providerConfigId, }; const region = safeString(context.providerConfig?.region); if (region) details.region = region; details.credentialPath = "Paperclip server runtime/provider credential path"; if (error?.code === "access_denied") { if (context.operation === "secret.create") { details.requiredCapability = "secretsmanager:CreateSecret"; details.actionableMessage = "AWS managed secret creation needs secretsmanager:CreateSecret in the selected region for this provider vault. If the vault config uses a KMS key, the runtime credentials also need KMS write permissions for that key."; details.safeAlternative = "If the secret already exists in AWS, link it as an external reference instead of creating a Paperclip-managed value."; } else if (context.operation === "secret.rotate") { details.requiredCapability = "secretsmanager:PutSecretValue"; details.actionableMessage = "AWS managed secret rotation needs secretsmanager:PutSecretValue for the selected provider vault and managed secret path."; } } return details; } const details: Record = { code: error?.code ?? "provider_error", provider: context.provider, operation: context.operation, providerConfigId: context.providerConfigId, }; const region = safeString(context.providerConfig?.region); if (region) details.region = region; details.providerVaultContext = context.providerConfigId === "discovery-preview" ? "draft_config" : "provider_config"; details.credentialPath = "Paperclip server runtime/provider credential path"; if (error?.code === "access_denied") { details.requiredCapability = "secretsmanager:ListSecrets"; details.actionableMessage = "AWS discovery preview needs secretsmanager:ListSecrets in the selected region for the Paperclip server runtime/provider credential path."; details.safeAlternative = "If the operator already knows the exact AWS Secrets Manager ARN, paste/link that ARN instead of using discovery. Exact-resource DescribeSecret and runtime read permissions are still required."; } return details; } function safeString(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } function remoteImportRowFailureReason(error: unknown, fallback: string, context: { companyId: string; provider: SecretProvider; providerConfigId: string; operation: string; }): string { if (isSecretProviderClientError(error)) { logger.warn( { err: error, companyId: context.companyId, provider: context.provider, providerConfigId: context.providerConfigId, operation: context.operation, providerErrorCode: error.code, }, "remote secret import row provider failure", ); return error.message; } if (error instanceof HttpError && error.status < 500) return error.message; logger.warn( { err: error, companyId: context.companyId, provider: context.provider, providerConfigId: context.providerConfigId, operation: context.operation, providerErrorCode: "provider_error", }, "remote secret import row failed", ); return fallback; } async function cleanupPreparedProviderWrite(input: { provider: SecretProviderModule; prepared: PreparedSecretVersion; providerConfig: SecretProviderVaultRuntimeConfig | null; context: SecretProviderWriteContext; mode: "archive" | "delete"; operation: string; }): Promise { try { await input.provider.deleteOrArchive({ material: input.prepared.material, externalRef: input.prepared.externalRef, providerConfig: input.providerConfig, context: input.context, mode: input.mode, }); return true; } catch (cleanupError) { logger.warn( { err: cleanupError, companyId: input.context.companyId, provider: input.provider.id, providerConfigId: input.providerConfig?.id ?? null, operation: input.operation, }, "remote secret provider cleanup failed after db write failure", ); return false; } } type CanonicalEnvBinding = | { type: "plain"; value: string } | { type: "secret_ref"; secretId: string; version: number | "latest"; projectionClass: SecretProjectionClass; projectionAllowlistKey: string | null; } | { type: "user_secret_ref"; key: string; version: number | "latest"; required: boolean; allowMissingOverride: boolean; }; type SecretAccessConsumerType = SecretBindingTargetType | "agent_api" | "plugin_worker"; type SecretConsumerContext = { consumerType: SecretAccessConsumerType; consumerId: string; configPath?: string | null; responsibleUserId?: string | null; actorType?: "agent" | "user" | "system" | "plugin"; actorId?: string | null; actorSource?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "mcp_oauth" | "cloud_tenant"; issueId?: string | null; heartbeatRunId?: string | null; pluginId?: string | null; allowedBindingIds?: string[] | null; }; type SecretBindingContext = Omit & { consumerType: SecretBindingTargetType; }; type SecretResolutionOptions = { bindingContext?: SecretBindingContext; accessContext?: SecretConsumerContext; allowUserSecretScope?: boolean; instanceOwner?: { agentId: string; pluginId: string }; }; export type AgentSecretReadContext = { agentId: string; configPath: string; bindingId?: string | null; actorSource: "agent_jwt" | "agent_key"; keyId?: string | null; keyScope?: AgentApiKeyScope | null; heartbeatRunId: string; issueId?: string | null; responsibleUserId?: string | null; registerForRedaction: (value: string) => void | Promise; }; export type AgentSecretAccessEntry = { secretId: string; bindingId: string; configPath: string; key: string; name: string; description: string | null; delivery: "env" | "api" | "both"; projectionClass: SecretProjectionClass; latestVersion: number; versionSelector: SecretVersionSelector; resolvedVersion: number; }; type ResolveAdapterConfigForRuntimeOptions = { adapterType?: string | null; skipUserSecrets?: boolean; /** * Selects how user-scoped secrets are mediated for this resolution. * * - `"declared"` (default): the resolver injects a `configPath`, activating * `resolveUserSecretValue`'s declaration guard. A persisted consumer's real * declaration rows satisfy it; an undeclared required ref → `binding_missing`. * - `"owner_scoped"`: for a prospective, non-persisted config (e.g. adapter * test-environment). The user-secret call omits `configPath` so the * declaration lookup is skipped and the value resolves by definition + owner * boundary; the company `secret_ref` call routes through `bindingContext: * undefined` (audit-only `accessContext`) to preserve today's zero-enforcement * company-secret behavior while gaining actor attribution. Opt-in per call. */ userSecretMediation?: "declared" | "owner_scoped"; }; export type RuntimeSecretManifestEntry = { configPath: string; envKey: string | null; secretId: string; bindingId?: string | null; secretKey: string; version: number; provider: SecretProvider; providerVersionRef?: string | null; outcome: "success" | "failure"; errorCode?: string | null; }; export type MissingRuntimeBinding = { consumerType: SecretBindingTargetType; consumerId: string; configPath: string; envKey: string; bindingType?: "secret_ref" | "user_secret_ref"; secretId: string | null; secretName: string | null; userSecretDefinitionId?: string | null; userSecretDefinitionKey?: string | null; userSecretDefinitionName?: string | null; responsibleUserId?: string | null; errorCode?: SecretResolutionErrorCode; }; function missingRuntimeConsumerType(consumerType: SecretAccessConsumerType): SecretBindingTargetType { if (consumerType === "plugin_worker") return "plugin"; if (consumerType === "agent_api") return "agent"; return consumerType; } type RuntimeSecretResolution = { value: string; manifestEntry: RuntimeSecretManifestEntry; }; type SecretResolutionErrorCode = | "binding_missing" | "secret_deleted" | "secret_inactive" | "secret_scope_invalid" | "responsible_user_missing" | "user_secret_definition_missing" | "user_secret_definition_inactive" | "user_secret_missing" | "version_missing" | "version_inactive" | "provider_error"; function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; } function isSensitiveEnvKey(key: string) { return SENSITIVE_ENV_KEY_RE.test(key); } export function normalizeSecretKey(input: string) { return input .trim() .toLowerCase() .replace(/[^a-z0-9_.-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 120); } function deriveSecretNameFromExternalRef(externalRef: string) { const trimmed = externalRef.trim(); const arnMatch = /^arn:[^:]+:secretsmanager:[^:]*:[^:]*:secret:(.+)$/i.exec(trimmed); const name = arnMatch?.[1] ?? trimmed; return name.split("/").filter(Boolean).at(-1) ?? name; } function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding { if (typeof binding === "string") { return { type: "plain", value: binding }; } if (binding.type === "plain") { return { type: "plain", value: String(binding.value) }; } if (binding.type === "user_secret_ref") { return { type: "user_secret_ref", key: binding.key, version: binding.version ?? "latest", required: binding.required ?? true, allowMissingOverride: binding.allowMissingOverride ?? false, }; } return { type: "secret_ref", secretId: binding.secretId, version: binding.version ?? "latest", projectionClass: binding.projectionClass ?? "unclassified", projectionAllowlistKey: binding.projectionAllowlistKey ?? null, }; } function assertClass3StaticLeaseAllowed(input: { targetType: SecretBindingTargetType; configPath: string; projectionClass?: string | null; projectionAllowlistKey?: string | null; }) { const projectionClass = input.projectionClass ?? "unclassified"; if (projectionClass !== "class_3_static_lease") return; if (!input.projectionAllowlistKey?.trim()) { throw unprocessable("Class-3 static lease bindings require an allowlist key", { code: "class_3_static_lease_allowlist_required", targetType: input.targetType, configPath: input.configPath, }); } const allowed = CLASS3_STATIC_LEASE_ALLOWLIST.some((entry) => entry.key === input.projectionAllowlistKey && entry.targetType === input.targetType && entry.configPath === input.configPath ); if (!allowed) { throw unprocessable("Class-3 static lease binding is outside the approved allowlist", { code: "class_3_static_lease_not_allowed", allowlistKey: input.projectionAllowlistKey, targetType: input.targetType, configPath: input.configPath, }); } } function defaultProviderConfigStatus(provider: SecretProvider): SecretProviderConfigStatus { return COMING_SOON_SECRET_PROVIDERS.has(provider) ? "coming_soon" : "ready"; } function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode { if (isSecretProviderClientError(error)) return "provider_error"; if (error instanceof HttpError) { const details = asRecord(error.details); switch (details?.code) { case "binding_missing": case "secret_deleted": case "secret_inactive": case "version_missing": case "version_inactive": case "provider_error": return details.code; } if (error.message === "Secret is not active") return "secret_inactive"; if (error.message === "User secret value is not configured") return "user_secret_missing"; if (error.message === "Responsible user is required for user secret resolution") { return "responsible_user_missing"; } if (error.message.startsWith("User secret definition not found")) { return "user_secret_definition_missing"; } if (error.message === "User secret definition is not active") return "user_secret_definition_inactive"; if (error.message === "User-scoped secrets must be resolved through user secret declarations") { return "secret_scope_invalid"; } if (error.message === "Secret version not found") return "version_missing"; if (error.message === "Secret version is not active") return "version_inactive"; if ( error.message === "Secret resolution requires a binding config path" || error.message.startsWith("Secret is not bound to ") ) { return "binding_missing"; } if (error.status >= 500) return "provider_error"; } return "provider_error"; } function assertSecretBindingConfigPath(input: { targetType: SecretBindingTargetType; configPath: string; }) { if (!input.configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) return; if (input.targetType !== "agent") { throw unprocessable("API-only secret access bindings must target an agent"); } const alias = input.configPath.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length); if (!ENV_KEY_RE.test(alias)) { throw unprocessable(`Invalid agent secret access alias: ${alias || "(empty)"}`); } } function missingUserSecretDefinitionRuntimeBinding( entry: { key: string; configPath: string; binding: Extract; }, context: Omit, definition: typeof userSecretDefinitions.$inferSelect | null, errorCode: "user_secret_definition_missing" | "user_secret_definition_inactive", ): MissingRuntimeBinding { return { consumerType: missingRuntimeConsumerType(context.consumerType), consumerId: context.consumerId, configPath: entry.configPath, envKey: entry.key, bindingType: "user_secret_ref", secretId: null, secretName: null, userSecretDefinitionId: definition?.id ?? null, userSecretDefinitionKey: definition?.key ?? entry.binding.key, userSecretDefinitionName: definition?.name ?? null, responsibleUserId: context.responsibleUserId ?? null, errorCode, }; } // A direct-resolution path (Test or save) resolves one user-secret binding at a // time. When the definition is gone, this builder names the environment // variable, the consumer, and the unresolved definition so the actor can find // the dangling binding. The message keeps the exact "User secret definition not // found" prefix so `secretResolutionErrorCode` still maps it to // `user_secret_definition_missing`. It never includes a secret value. type UserSecretDefinitionResolutionContext = { envKey?: string | null; configPath?: string | null; consumerType?: string | null; consumerId?: string | null; }; function envKeyFromConfigPath(configPath?: string | null): string | null { if (!configPath) return null; const separatorIndex = configPath.lastIndexOf("."); const key = separatorIndex >= 0 ? configPath.slice(separatorIndex + 1) : configPath; return key.length > 0 ? key : null; } const BASE_USER_SECRET_DEFINITION_NOT_FOUND_MESSAGE = "User secret definition not found"; function userSecretDefinitionNotFoundMessage( input: { definitionId?: string | null; definitionKey?: string | null }, context?: UserSecretDefinitionResolutionContext, ): string { const parts: string[] = []; const envKey = context?.envKey ?? envKeyFromConfigPath(context?.configPath); if (envKey) parts.push(`environment variable "${envKey}"`); if (context?.consumerId) { parts.push(`${context.consumerType ?? "consumer"} ${context.consumerId}`); } if (input.definitionKey) { parts.push(`definition key "${input.definitionKey}"`); } else if (input.definitionId) { parts.push(`definition id "${input.definitionId}"`); } if (parts.length === 0) return BASE_USER_SECRET_DEFINITION_NOT_FOUND_MESSAGE; return `${BASE_USER_SECRET_DEFINITION_NOT_FOUND_MESSAGE} for ${parts.join(", ")}`; } function assertSelectableProviderConfig(config: { provider: string; status: string; companyId: string; }, companyId: string, provider: SecretProvider) { if (config.companyId !== companyId) throw unprocessable("Provider vault must belong to same company"); if (config.provider !== provider) throw unprocessable("Provider vault must match the secret provider"); if (config.status === "coming_soon") { throw unprocessable("Provider vault is locked while coming soon"); } if (config.status === "disabled") { throw unprocessable("Provider vault is disabled"); } } export function secretService(db: Db | DbTransaction) { const authorization = authorizationService(db); type NormalizeEnvOptions = { strictMode?: boolean; fieldPath?: string; }; type NormalizeAdapterConfigOptions = { strictMode?: boolean; adapterType?: string | null; actor?: { userId?: string | null; agentId?: string | null }; }; async function getById(id: string, source: Pick = db) { return source .select() .from(companySecrets) .where(eq(companySecrets.id, id)) .then((rows) => rows[0] ?? null); } async function getByName(companyId: string, name: string) { return db .select() .from(companySecrets) .where(and( eq(companySecrets.companyId, companyId), eq(companySecrets.scope, "company"), eq(companySecrets.name, name), ne(companySecrets.status, "deleted"), )) .then((rows) => rows[0] ?? null); } async function getByKey(companyId: string, key: string) { return db .select() .from(companySecrets) .where(and( eq(companySecrets.companyId, companyId), eq(companySecrets.key, key), eq(companySecrets.scope, "company"), ne(companySecrets.status, "deleted"), )) .then((rows) => rows[0] ?? null); } async function getUserSecretDefinitionById( companyId: string, definitionId: string, source: Pick = db, ) { return source .select() .from(userSecretDefinitions) .where(and( eq(userSecretDefinitions.companyId, companyId), eq(userSecretDefinitions.id, definitionId), )) .then((rows) => rows[0] ?? null); } async function getUserSecretDefinitionByKey( companyId: string, key: string, source: Pick = db, ) { return source .select() .from(userSecretDefinitions) .where(and( eq(userSecretDefinitions.companyId, companyId), eq(userSecretDefinitions.key, key), ne(userSecretDefinitions.status, "deleted"), )) .then((rows) => rows[0] ?? null); } async function resolveUserSecretDefinition( companyId: string, input: { definitionId?: string | null; definitionKey?: string | null }, source: Pick = db, context?: UserSecretDefinitionResolutionContext, ) { const definition = input.definitionId ? await getUserSecretDefinitionById(companyId, input.definitionId, source) : input.definitionKey ? await getUserSecretDefinitionByKey(companyId, input.definitionKey, source) : null; if (!definition || definition.deletedAt || definition.status === "deleted") { throw notFound(userSecretDefinitionNotFoundMessage(input, context)); } if (definition.companyId !== companyId) { throw unprocessable("User secret definition must belong to same company"); } return definition; } async function getUserSecretValue(input: { companyId: string; ownerUserId: string; definitionId: string; }) { return db .select() .from(companySecrets) .where(and( eq(companySecrets.companyId, input.companyId), eq(companySecrets.scope, "user"), eq(companySecrets.ownerUserId, input.ownerUserId), eq(companySecrets.userSecretDefinitionId, input.definitionId), ne(companySecrets.status, "deleted"), )) .then((rows) => rows[0] ?? null); } async function getUserSecretValueById(companyId: string, ownerUserId: string, secretId: string) { const secret = await getById(secretId); if (!secret || secret.status === "deleted" || secret.scope !== "user") { throw notFound("User secret value not found"); } if (secret.companyId !== companyId || secret.ownerUserId !== ownerUserId) { throw notFound("User secret value not found"); } return secret; } async function getSecretVersion(secretId: string, version: number) { return db .select() .from(companySecretVersions) .where( and( eq(companySecretVersions.secretId, secretId), eq(companySecretVersions.version, version), ), ) .then((rows) => rows[0] ?? null); } async function getBinding(input: { companyId: string; secretId: string; consumerType: SecretBindingTargetType; consumerId: string; configPath: string; }) { return db .select() .from(companySecretBindings) .where( and( eq(companySecretBindings.companyId, input.companyId), eq(companySecretBindings.secretId, input.secretId), eq(companySecretBindings.targetType, input.consumerType), eq(companySecretBindings.targetId, input.consumerId), eq(companySecretBindings.configPath, input.configPath), ), ) .then((rows) => rows[0] ?? null); } async function assertBindingContext( companyId: string, secretId: string, context: SecretBindingContext | undefined, ) { if (!context) return null; if (!context.configPath) { throw unprocessable("Secret resolution requires a binding config path", { code: "binding_missing" }); } const binding = await getBinding({ companyId, secretId, consumerType: context.consumerType, consumerId: context.consumerId, configPath: context.configPath, }); if (!binding) { throw unprocessable( `Secret is not bound to ${context.consumerType}:${context.consumerId} at ${context.configPath}`, { code: "binding_missing" }, ); } if ( Array.isArray(context.allowedBindingIds) && !context.allowedBindingIds.includes(binding.id) ) { throw unprocessable( "Secret binding is outside the active low-trust boundary", { code: "binding_not_allowed" }, ); } assertClass3StaticLeaseAllowed({ targetType: binding.targetType as SecretBindingTargetType, configPath: binding.configPath, projectionClass: binding.projectionClass, projectionAllowlistKey: binding.projectionAllowlistKey, }); return binding; } async function recordAccessEvent(input: { companyId: string; secretId: string; userSecretDefinitionId?: string | null; secretScope?: string | null; version: number | null; provider: SecretProvider; context: SecretConsumerContext | undefined; credentialOwnerUserId?: string | null; credentialSubjectType?: string | null; credentialSubjectId?: string | null; outcome: "success" | "failure"; errorCode?: string | null; }) { if (!input.context) return; await db.insert(secretAccessEvents).values({ companyId: input.companyId, secretId: input.secretId, userSecretDefinitionId: input.userSecretDefinitionId ?? null, secretScope: input.secretScope ?? "company", version: input.version, provider: input.provider, responsibleUserId: input.context.responsibleUserId ?? null, credentialOwnerUserId: input.credentialOwnerUserId ?? null, credentialSubjectType: input.credentialSubjectType ?? null, credentialSubjectId: input.credentialSubjectId ?? null, actorType: input.context.actorType ?? "system", actorId: input.context.actorId ?? null, consumerType: input.context.consumerType, consumerId: input.context.consumerId, configPath: input.context.configPath ?? null, issueId: input.context.issueId ?? null, heartbeatRunId: input.context.heartbeatRunId ?? null, pluginId: input.context.pluginId ?? null, outcome: input.outcome, errorCode: input.errorCode ?? null, }); } async function assertSecretInCompany( companyId: string, secretId: string, source: Pick = db, ) { const secret = await getById(secretId, source); if (!secret) throw notFound("Secret not found"); if (secret.status === "deleted") throw notFound("Secret not found"); if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); if (secret.scope !== "company") throw unprocessable("Secret references require company-scoped secrets"); return secret; } async function getProviderConfigById(id: string) { return db .select() .from(companySecretProviderConfigs) .where(eq(companySecretProviderConfigs.id, id)) .then((rows) => rows[0] ?? null); } async function assertProviderConfigForSecret( companyId: string, provider: SecretProvider, providerConfigId: string | null | undefined, ) { if (!providerConfigId) return null; const providerConfig = await getProviderConfigById(providerConfigId); if (!providerConfig) throw notFound("Provider vault not found"); assertSelectableProviderConfig(providerConfig, companyId, provider); return providerConfig; } function toProviderVaultRuntimeConfig( providerConfig: Awaited> | null, ): SecretProviderVaultRuntimeConfig | null { if (!providerConfig) return null; return { id: providerConfig.id, provider: providerConfig.provider as SecretProvider, status: providerConfig.status, config: providerConfig.config ?? {}, }; } async function getSelectableRuntimeProviderConfig(input: { companyId: string; provider: SecretProvider; providerConfigId: string | null | undefined; }) { const providerConfig = await assertProviderConfigForSecret( input.companyId, input.provider, input.providerConfigId, ); return toProviderVaultRuntimeConfig(providerConfig); } function validateProviderConfigPayload( provider: SecretProvider, config: Record, ): Record { const parsed = secretProviderConfigPayloadSchema.safeParse({ provider, config }); if (!parsed.success) { throw unprocessable("Invalid provider vault config", parsed.error.flatten()); } return parsed.data.config; } function toDraftProviderVaultRuntimeConfig(input: { companyId: string; provider: SecretProvider; config: Record; }): SecretProviderVaultRuntimeConfig { return { id: `discovery-preview-${input.companyId}`, provider: input.provider, status: "ready", config: validateProviderConfigPayload(input.provider, input.config), }; } function providerConfigHealth(input: { id: string; provider: SecretProvider; status: SecretProviderConfigStatus; config: Record; }): Omit | null { if (input.status === "disabled") { return { configId: input.id, provider: input.provider, status: "disabled", message: "Provider vault is disabled.", details: { code: "disabled", message: "Provider vault is disabled." }, }; } if (input.status === "coming_soon" || COMING_SOON_SECRET_PROVIDERS.has(input.provider)) { return { configId: input.id, provider: input.provider, status: "coming_soon", message: "Provider vault runtime is locked while coming soon.", details: { code: "runtime_locked", message: "Provider vault runtime is locked while coming soon.", guidance: ["Draft metadata may be saved, but create, rotate, and resolve stay unavailable."], }, }; } return null; } function mapProviderModuleHealth(input: { configId: string; provider: SecretProvider; providerStatus: SecretProviderConfigStatus; health: SecretProviderHealthCheck; }): Omit { const status: SecretProviderConfigHealthStatus = input.health.status === "ok" ? input.providerStatus === "warning" ? "warning" : "ready" : input.health.status === "error" ? "error" : "warning"; const guidance = [ ...(input.health.warnings ?? []), ...(input.health.backupGuidance ?? []), ]; return { configId: input.configId, provider: input.provider, status, message: input.health.message, details: { code: input.health.status === "ok" ? "provider_ready" : "provider_needs_attention", message: input.health.message, guidance: guidance.length > 0 ? guidance : undefined, }, }; } async function resolveSecretValueInternal( companyId: string, secretId: string, version: number | "latest", options?: SecretResolutionOptions, ): Promise { const bindingContext = options?.bindingContext; const accessContext = options?.accessContext ?? bindingContext; const secret = await getById(secretId); if (!secret) throw notFound("Secret not found"); if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); const instanceOwned = secret.scope === "instance" && options?.instanceOwner && secret.ownerAgentId === options.instanceOwner.agentId && secret.ownerPluginId === options.instanceOwner.pluginId; if (secret.scope !== "company" && !(secret.scope === "user" && options?.allowUserSecretScope) && !instanceOwned) { throw unprocessable("User-scoped secrets must be resolved through user secret declarations", { code: "secret_scope_invalid", }); } const resolvedVersion = version === "latest" ? secret.latestVersion : version; const providerId = secret.provider as SecretProvider; const configPath = accessContext?.configPath ?? null; try { if (secret.status === "deleted") { throw new HttpError(404, "Secret not found", { code: "secret_deleted" }); } if (secret.status !== "active") { throw unprocessable("Secret is not active", { code: "secret_inactive" }); } const binding = await assertBindingContext(companyId, secret.id, bindingContext); const versionRow = await getSecretVersion(secret.id, resolvedVersion); if (!versionRow) throw new HttpError(404, "Secret version not found", { code: "version_missing" }); if (versionRow.status === "disabled" || versionRow.status === "destroyed" || versionRow.revokedAt) { throw unprocessable("Secret version is not active", { code: "version_inactive" }); } const provider = getSecretProvider(providerId); const providerConfig = await getSelectableRuntimeProviderConfig({ companyId, provider: providerId, providerConfigId: secret.providerConfigId, }); const value = await provider.resolveVersion({ material: versionRow.material as Record, externalRef: secret.externalRef, providerVersionRef: versionRow.providerVersionRef, providerConfig, context: { companyId, secretId: secret.id, secretKey: secret.key, version: resolvedVersion, }, }); await Promise.all([ db .update(companySecrets) .set({ lastResolvedAt: new Date(), updatedAt: new Date() }) .where(eq(companySecrets.id, secret.id)) .catch(() => undefined), recordAccessEvent({ companyId, secretId: secret.id, userSecretDefinitionId: secret.userSecretDefinitionId ?? null, secretScope: secret.scope, version: resolvedVersion, provider: providerId, context: accessContext, credentialOwnerUserId: secret.ownerUserId ?? null, credentialSubjectType: secret.scope === "user" ? "user" : null, credentialSubjectId: secret.ownerUserId ?? null, outcome: "success", }).catch(() => undefined), ]); return { value, manifestEntry: { configPath: configPath ?? "", envKey: configPath?.startsWith("env.") ? configPath.slice("env.".length) : null, secretId: secret.id, bindingId: binding?.id ?? null, secretKey: secret.key, version: resolvedVersion, provider: providerId, providerVersionRef: versionRow.providerVersionRef, outcome: "success", }, }; } catch (err) { const errorCode = secretResolutionErrorCode(err); await recordAccessEvent({ companyId, secretId: secret.id, userSecretDefinitionId: secret.userSecretDefinitionId ?? null, secretScope: secret.scope, version: resolvedVersion, provider: providerId, context: accessContext, credentialOwnerUserId: secret.ownerUserId ?? null, credentialSubjectType: secret.scope === "user" ? "user" : null, credentialSubjectId: secret.ownerUserId ?? null, outcome: "failure", errorCode, }).catch(() => undefined); throw err; } } function isSecretResolutionOptions( value: SecretBindingContext | SecretResolutionOptions | undefined, ): value is SecretResolutionOptions { return Boolean(value && ("bindingContext" in value || "accessContext" in value)); } async function resolveSecretValue( companyId: string, secretId: string, version: number | "latest", contextOrOptions?: SecretBindingContext | SecretResolutionOptions, ): Promise { const options = isSecretResolutionOptions(contextOrOptions) ? contextOrOptions : { bindingContext: contextOrOptions, accessContext: contextOrOptions }; return (await resolveSecretValueInternal(companyId, secretId, version, options)).value; } async function resolveSecretValueForEphemeralAccess( companyId: string, secretId: string, version: number | "latest", context: SecretConsumerContext, ): Promise { if (context.consumerType !== "system" || context.consumerId !== "environment-probe-config") { throw forbidden("Ephemeral secret resolution is limited to draft environment probes"); } if ( (context.actorType !== "agent" && context.actorType !== "user") || !context.actorId?.trim() ) { throw forbidden("Ephemeral secret resolution requires an authenticated actor"); } const actor = context.actorType === "agent" ? { type: "agent" as const, agentId: context.actorId, companyId, source: context.actorSource === "agent_jwt" ? "agent_jwt" as const : "agent_key" as const, } : { type: "board" as const, userId: context.actorId, source: context.actorSource === "local_implicit" ? "local_implicit" as const : context.actorSource === "board_key" ? "board_key" as const : context.actorSource === "mcp_oauth" ? "mcp_oauth" as const : context.actorSource === "cloud_tenant" ? "cloud_tenant" as const : "session" as const, }; const decision = await authorization.decide({ actor, action: "secrets:read", resource: { type: "company", companyId }, }); if (!decision.allowed) { throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } return (await resolveSecretValueInternal(companyId, secretId, version, { accessContext: context, })).value; } // Resolve a connection secret for a durable orphan-sandbox teardown. // // The retry destroys a remote sandbox that a failed acquire left allocated. // The teardown needs the same connection secret the acquire used. But the // environment binding may be gone: a delete removed the environment, or a // provider change replaced the binding. So this path authorizes the read from // the durable `pending_cleanup` lease row, not from the environment binding, // and it never checks the binding. The caller passes only a secret id that the // durable row recorded, so the scope stays narrow. The resolution records an // access event for audit, and the value never enters lease metadata. async function resolveSecretValueForSandboxCleanup( companyId: string, secretId: string, version: number | "latest", context: { configPath: string; issueId?: string | null; heartbeatRunId?: string | null; }, ): Promise { return ( await resolveSecretValueInternal(companyId, secretId, version, { // Audit-only access context. No `bindingContext`, so the resolver never // asserts the environment binding that a delete or a provider change may // have removed. accessContext: { consumerType: "system", consumerId: SANDBOX_CLEANUP_CONSUMER_ID, actorType: "system", actorId: null, configPath: context.configPath, issueId: context.issueId ?? null, heartbeatRunId: context.heartbeatRunId ?? null, }, }) ).value; } // Resolve a pre-existing `CODEX_HOME_` (or equivalent) secret's // current value for a device-login idempotency check. A device login that // finds a secret already at its expected name must compare the stored // value against the account home it just resolved, not trust the name // alone: a stale or foreign secret at that name would otherwise let the // login report success while a bound agent reads the wrong credential // home. The read is audit-only (no `bindingContext`), because the secret // may carry no environment binding yet. async function resolveSecretValueForDeviceLoginCheck( companyId: string, secretId: string, context: { configPath: string }, ): Promise { return ( await resolveSecretValueInternal(companyId, secretId, "latest", { accessContext: { consumerType: "system", consumerId: DEVICE_LOGIN_SECRET_CHECK_CONSUMER_ID, actorType: "system", actorId: null, configPath: context.configPath, }, }) ).value; } async function resolveSecretValueForAgentAccess( companyId: string, secretId: string, version: number | "latest", context: AgentSecretReadContext, ): Promise<{ value: string; version: number }> { if (context.actorSource !== "agent_jwt") { throw forbidden("Agent secret access requires a run-bound agent token"); } if (!isUuidLike(context.heartbeatRunId)) { throw forbidden("Agent secret access requires a verified heartbeat run"); } if (!context.configPath.startsWith("env.") && !context.configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) { throw forbidden("Secret access is not granted for this binding path"); } assertSecretBindingConfigPath({ targetType: "agent", configPath: context.configPath }); const run = await db .select({ id: heartbeatRuns.id, contextSnapshot: heartbeatRuns.contextSnapshot }) .from(heartbeatRuns) .where(and( eq(heartbeatRuns.id, context.heartbeatRunId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, context.agentId), eq(heartbeatRuns.status, "running"), )) .then((rows) => rows[0] ?? null); if (!run) { throw forbidden("Agent secret access requires a verified heartbeat run"); } const decision = await authorization.decide({ actor: { type: "agent", agentId: context.agentId, companyId, source: "agent_jwt", keyId: context.keyId ?? null, keyScope: context.keyScope ?? null, runId: context.heartbeatRunId, }, action: "secrets:read", resource: { type: "company", companyId }, }); if (!decision.allowed) { throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); } let bindingContext: SecretBindingContext = { consumerType: "agent", consumerId: context.agentId, configPath: context.configPath, responsibleUserId: context.responsibleUserId ?? null, actorType: "agent", actorId: context.agentId, actorSource: context.actorSource, issueId: context.issueId ?? null, heartbeatRunId: context.heartbeatRunId, }; if (context.bindingId) { const binding = await db .select() .from(companySecretBindings) .where(and( eq(companySecretBindings.id, context.bindingId), eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.secretId, secretId), eq(companySecretBindings.configPath, context.configPath), )) .then((rows) => rows[0] ?? null); if (!binding) throw forbidden("Secret access is not granted for this agent"); const runContext = asRecord(run.contextSnapshot) ?? {}; const manifest = (asRecord(runContext.paperclipSecrets) ?? {}).manifest; const manifestBindingIds = new Set( Array.isArray(manifest) ? manifest.flatMap((entry) => { const record = asRecord(entry) ?? {}; return typeof record.bindingId === "string" ? [record.bindingId] : []; }) : [], ); const isDirectAgentBinding = binding.targetType === "agent" && binding.targetId === context.agentId; if (!isDirectAgentBinding && !manifestBindingIds.has(binding.id)) { throw forbidden("Secret access is not granted for this agent run"); } bindingContext = { ...bindingContext, consumerType: binding.targetType as SecretBindingTargetType, consumerId: binding.targetId, }; } const runContext = asRecord(run.contextSnapshot) ?? {}; const effectiveIssueId = context.issueId ?? ( typeof runContext.issueId === "string" ? runContext.issueId : typeof (asRecord(runContext.paperclipIssue) ?? {}).id === "string" ? String((asRecord(runContext.paperclipIssue) ?? {}).id) : null ); bindingContext.issueId = effectiveIssueId; const accessContext: SecretConsumerContext = { consumerType: "agent_api", consumerId: context.agentId, configPath: context.configPath, responsibleUserId: context.responsibleUserId ?? null, actorType: "agent", actorId: context.agentId, actorSource: context.actorSource, issueId: effectiveIssueId, heartbeatRunId: context.heartbeatRunId, }; try { const resolution = await resolveSecretValueInternal(companyId, secretId, version, { bindingContext, accessContext, }); await context.registerForRedaction(resolution.value); await logActivity(db as Db, { companyId, actorType: "agent", actorId: context.agentId, action: "secret.value.read", entityType: "secret", entityId: secretId, agentId: context.agentId, runId: context.heartbeatRunId, issueId: effectiveIssueId, details: { configPath: context.configPath, outcome: "success", version: resolution.manifestEntry.version, }, }); return { value: resolution.value, version: resolution.manifestEntry.version, }; } catch (error) { const errorCode = secretResolutionErrorCode(error); await logActivity(db as Db, { companyId, actorType: "agent", actorId: context.agentId, action: "secret.value.read", entityType: "secret", entityId: secretId, agentId: context.agentId, runId: context.heartbeatRunId, issueId: effectiveIssueId, details: { configPath: context.configPath, outcome: "failure", errorCode, }, }).catch(() => undefined); if (errorCode === "binding_missing" || errorCode === "secret_scope_invalid") { throw forbidden("Secret access is not granted for this agent"); } throw error; } } async function listAgentSecretAccess( companyId: string, context: Omit, ): Promise { if (context.actorSource !== "agent_jwt" || !isUuidLike(context.heartbeatRunId)) { throw forbidden("Agent secret access requires a run-bound agent token"); } const run = await db .select({ contextSnapshot: heartbeatRuns.contextSnapshot }) .from(heartbeatRuns) .where(and( eq(heartbeatRuns.id, context.heartbeatRunId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, context.agentId), eq(heartbeatRuns.status, "running"), )) .then((rows) => rows[0] ?? null); if (!run) throw forbidden("Agent secret access requires a verified heartbeat run"); const decision = await authorization.decide({ actor: { type: "agent", agentId: context.agentId, companyId, source: "agent_jwt", keyId: context.keyId ?? null, keyScope: context.keyScope ?? null, runId: context.heartbeatRunId, }, action: "secrets:read", resource: { type: "company", companyId }, }); if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); const runContext = asRecord(run.contextSnapshot) ?? {}; const manifest = (asRecord(runContext.paperclipSecrets) ?? {}).manifest; const manifestBindingIds = Array.isArray(manifest) ? manifest.flatMap((entry) => { const bindingId = (asRecord(entry) ?? {}).bindingId; return typeof bindingId === "string" ? [bindingId] : []; }) : []; const [directBindings, runtimeBindings] = await Promise.all([ db.select().from(companySecretBindings).where(and( eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.targetType, "agent"), eq(companySecretBindings.targetId, context.agentId), or( like(companySecretBindings.configPath, "env.%"), like(companySecretBindings.configPath, `${AGENT_ACCESS_CONFIG_PATH_PREFIX}%`), ), )), manifestBindingIds.length > 0 ? db.select().from(companySecretBindings).where(and( eq(companySecretBindings.companyId, companyId), inArray(companySecretBindings.id, manifestBindingIds), )) : Promise.resolve([]), ]); const bindings = [...new Map([...directBindings, ...runtimeBindings].map((binding) => [binding.id, binding])).values()]; if (bindings.length === 0) return []; const secrets = await db .select() .from(companySecrets) .where(and( eq(companySecrets.companyId, companyId), eq(companySecrets.scope, "company"), eq(companySecrets.status, "active"), inArray(companySecrets.id, [...new Set(bindings.map((binding) => binding.secretId))]), )); const secretsById = new Map(secrets.map((secret) => [secret.id, secret])); const bindingsBySecret = new Map(); for (const binding of bindings) { const current = bindingsBySecret.get(binding.secretId) ?? []; current.push(binding); bindingsBySecret.set(binding.secretId, current); } return [...bindingsBySecret.entries()].flatMap(([secretId, secretBindings]) => { const secret = secretsById.get(secretId); if (!secret) return []; const accessBinding = secretBindings.find((binding) => binding.configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)); const selectedBinding = accessBinding ?? secretBindings[0]; const hasEnv = secretBindings.some((binding) => binding.configPath.startsWith("env.")); const hasApi = Boolean(accessBinding); const versionSelector: SecretVersionSelector = selectedBinding.versionSelector === "latest" ? "latest" : Number(selectedBinding.versionSelector); const delivery: AgentSecretAccessEntry["delivery"] = hasEnv && hasApi ? "both" : hasEnv ? "env" : "api"; return [{ secretId, bindingId: selectedBinding.id, configPath: selectedBinding.configPath, key: secret.key, name: secret.name, description: secret.description ?? null, delivery, projectionClass: (selectedBinding.projectionClass ?? "unclassified") as SecretProjectionClass, latestVersion: secret.latestVersion, versionSelector, resolvedVersion: versionSelector === "latest" ? secret.latestVersion : versionSelector, }]; }).sort((left, right) => left.key.localeCompare(right.key)); } async function resolveSecretVersion( companyId: string, secretId: string, version: number | "latest", context?: SecretBindingContext, ): Promise { const secret = await getById(secretId); if (!secret) throw notFound("Secret not found"); if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); const resolvedVersion = version === "latest" ? secret.latestVersion : version; if (secret.status === "deleted") { throw new HttpError(404, "Secret not found", { code: "secret_deleted" }); } if (secret.status !== "active") { throw unprocessable("Secret is not active", { code: "secret_inactive" }); } await assertBindingContext(companyId, secret.id, context); const versionRow = await getSecretVersion(secret.id, resolvedVersion); if (!versionRow) throw new HttpError(404, "Secret version not found", { code: "version_missing" }); if (versionRow.status === "disabled" || versionRow.status === "destroyed" || versionRow.revokedAt) { throw unprocessable("Secret version is not active", { code: "version_inactive" }); } return resolvedVersion; } async function normalizeEnvConfig( companyId: string, envValue: unknown, opts?: NormalizeEnvOptions, ): Promise { const record = asRecord(envValue); if (!record) throw unprocessable(`${opts?.fieldPath ?? "env"} must be an object`); const normalized: AgentEnvConfig = {}; for (const [key, rawBinding] of Object.entries(record)) { if (!ENV_KEY_RE.test(key)) { throw unprocessable(`Invalid environment variable name: ${key}`); } const parsed = envBindingSchema.safeParse(rawBinding); if (!parsed.success) { throw unprocessable(`Invalid environment binding for key: ${key}`); } const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type === "plain") { if (opts?.strictMode && isSensitiveEnvKey(key) && binding.value.trim().length > 0) { throw unprocessable( `Strict secret mode requires secret references for sensitive key: ${key}`, ); } if (binding.value === REDACTED_SENTINEL) { throw unprocessable(`Refusing to persist redacted placeholder for key: ${key}`); } normalized[key] = binding; continue; } if (binding.type === "user_secret_ref") { normalized[key] = binding; continue; } await assertSecretInCompany(companyId, binding.secretId); normalized[key] = { type: "secret_ref", secretId: binding.secretId, version: binding.version, projectionClass: binding.projectionClass, projectionAllowlistKey: binding.projectionAllowlistKey, }; } return normalized; } async function normalizeAdapterConfigForPersistenceInternal( companyId: string, adapterConfig: Record, opts?: NormalizeAdapterConfigOptions, ) { const normalized = { ...adapterConfig }; if (Object.prototype.hasOwnProperty.call(adapterConfig, "env")) { normalized.env = await normalizeEnvConfig(companyId, adapterConfig.env, opts); } const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(opts?.adapterType); for (const key of secretFieldKeys) { if (!Object.prototype.hasOwnProperty.call(adapterConfig, key)) continue; const value = await normalizeSchemaSecretFieldForPersistence(companyId, { adapterType: opts?.adapterType ?? null, key, rawValue: adapterConfig[key], actor: opts?.actor, }); if (value === undefined) { delete normalized[key]; } else { normalized[key] = value; } } return normalized; } async function listAdapterSchemaSecretFieldKeys(adapterType: string | null | undefined): Promise { if (!adapterType) return []; const adapter = findActiveServerAdapter(adapterType); const fallback = [...(FALLBACK_ADAPTER_SCHEMA_SECRET_FIELDS[adapterType] ?? [])]; if (!adapter?.getConfigSchema) return fallback; try { const schema = await adapter.getConfigSchema(); return [...new Set([ ...fallback, ...schema.fields .filter((field) => field.meta?.secret === true) .map((field) => field.key), ])]; } catch (err) { logger.warn({ err, adapterType }, "adapter config schema unavailable while normalizing secret fields"); return fallback; } } async function normalizeSchemaSecretFieldForPersistence( companyId: string, input: { adapterType: string | null; key: string; rawValue: unknown; actor?: { userId?: string | null; agentId?: string | null }; }, ): Promise { if (input.rawValue === null || input.rawValue === undefined) return undefined; const parsed = envBindingSchema.safeParse(input.rawValue); if (!parsed.success) { throw unprocessable(`${input.key} must be a string, plain binding, or secret reference`); } const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type === "secret_ref") { await assertSecretInCompany(companyId, binding.secretId); return { type: "secret_ref", secretId: binding.secretId, version: binding.version, projectionClass: binding.projectionClass, projectionAllowlistKey: binding.projectionAllowlistKey, }; } if (binding.type === "user_secret_ref") { throw unprocessable(`${input.key} must be a string, plain binding, or company secret reference`); } const value = binding.value.trim(); if (!value) return undefined; if (value === REDACTED_SENTINEL) { throw unprocessable(`Refusing to persist redacted placeholder for key: ${input.key}`); } const id = randomUUID(); const adapterPart = normalizeSecretKey(input.adapterType ?? "adapter"); const fieldPart = normalizeSecretKey(input.key); const secret = await createManagedLocalSecret(companyId, { name: `${adapterPart}.${fieldPart}.${id}`, key: `${adapterPart}.${fieldPart}.${id}`, value, description: `Adapter config secret for ${input.adapterType ?? "adapter"}.${input.key}`, }, input.actor); return { type: "secret_ref", secretId: secret.id, version: "latest", }; } // Every `local_encrypted` secret this function creates runs under // `withAccountHomeSecretMutationLock`, so its write can never commit inside // the window a Codex account-home cleanup already used to decide no secret // claims the directory it is about to delete. A create queued behind the // lock can still win it after the cleanup already removed that directory, // so check the directory's existence inside the same lock, right before // the write, the same way `create:` and `rotate:` below do. async function createManagedLocalSecret( companyId: string, input: { name: string; key: string; value: string; description?: string | null; }, actor?: { userId?: string | null; agentId?: string | null }, ) { return withAccountHomeSecretMutationLock(undefined, companyId, async () => { await assertAccountHomeCacheDirStillValid(undefined, companyId, input.value); return createManagedLocalSecretUnlocked(companyId, input, actor); }); } async function createManagedLocalSecretUnlocked( companyId: string, input: { name: string; key: string; value: string; description?: string | null; }, actor?: { userId?: string | null; agentId?: string | null }, ) { const existing = await getByName(companyId, input.name); if (existing) throw conflict(`Secret already exists: ${input.name}`); const key = normalizeSecretKey(input.key); if (!key) throw unprocessable("Secret key is required"); const duplicateKey = await db .select() .from(companySecrets) .where(and( eq(companySecrets.companyId, companyId), eq(companySecrets.scope, "company"), eq(companySecrets.key, key), ne(companySecrets.status, "deleted"), )) .then((rows) => rows[0] ?? null); if (duplicateKey) throw conflict(`Secret key already exists: ${key}`); const provider = getSecretProvider("local_encrypted"); const providerConfig = await getSelectableRuntimeProviderConfig({ companyId, provider: "local_encrypted", providerConfigId: null, }); const providerWriteContext = { companyId, secretKey: key, secretName: input.name, version: 1, }; const reservedSecret = await db .insert(companySecrets) .values({ companyId, key, name: input.name, provider: "local_encrypted", providerConfigId: null, status: "archived", managedMode: "paperclip_managed", externalRef: null, providerMetadata: null, latestVersion: 0, description: input.description ?? null, createdByAgentId: actor?.agentId ?? null, createdByUserId: actor?.userId ?? null, }) .returning() .then((rows) => rows[0]); let prepared: PreparedSecretVersion | null = null; try { prepared = await provider.createSecret({ value: input.value, externalRef: null, providerConfig, context: providerWriteContext, }); const preparedSecret = prepared; await db.insert(companySecretVersions).values({ secretId: reservedSecret.id, version: 1, material: preparedSecret.material, valueSha256: preparedSecret.valueSha256, fingerprintSha256: preparedSecret.fingerprintSha256 ?? preparedSecret.valueSha256, providerVersionRef: preparedSecret.providerVersionRef ?? null, status: "disabled", createdByAgentId: actor?.agentId ?? null, createdByUserId: actor?.userId ?? null, }); return await db.transaction(async (tx) => { await tx .update(companySecretVersions) .set({ status: "current" }) .where(and( eq(companySecretVersions.secretId, reservedSecret.id), eq(companySecretVersions.version, 1), )); const secret = await tx .update(companySecrets) .set({ status: "active", externalRef: preparedSecret.externalRef, latestVersion: 1, lastRotatedAt: new Date(), updatedAt: new Date(), }) .where(eq(companySecrets.id, reservedSecret.id)) .returning() .then((rows) => rows[0]); if (!secret) throw notFound("Secret not found"); return secret; }); } catch (error) { if (prepared) { await cleanupPreparedProviderWrite({ provider, prepared, providerConfig, context: providerWriteContext, mode: "delete", operation: "adapter_config_secret.create_rollback", }).catch(() => false); } await db.delete(companySecretVersions).where(eq(companySecretVersions.secretId, reservedSecret.id)).catch(() => undefined); await db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)).catch(() => undefined); throw error; } } // The body `create:` below runs, unchanged. Extracted to a named function so // `create:` can wrap it in `withAccountHomeSecretMutationLock` only for the // one provider (`local_encrypted`) that can hold a literal directory path, // without duplicating this whole write sequence. async function createSecretUnlocked( companyId: string, input: { name: string; provider: SecretProvider; providerConfigId?: string | null; value?: string | null; key?: string | null; managedMode?: "paperclip_managed" | "external_reference"; description?: string | null; externalRef?: string | null; providerVersionRef?: string | null; providerMetadata?: Record | null; }, actor?: { userId?: string | null; agentId?: string | null }, instanceOwner?: { agentId: string; pluginId: string }, ) { const existing = instanceOwner ? null : await getByName(companyId, input.name); if (existing) throw conflict(`Secret already exists: ${input.name}`); const key = normalizeSecretKey(input.key ?? input.name); if (!key) throw unprocessable("Secret key is required"); const duplicateKey = instanceOwner ? null : await db .select() .from(companySecrets) .where(and( eq(companySecrets.companyId, companyId), eq(companySecrets.scope, "company"), eq(companySecrets.key, key), ne(companySecrets.status, "deleted"), )) .then((rows) => rows[0] ?? null); if (duplicateKey) throw conflict(`Secret key already exists: ${key}`); const managedMode = input.managedMode ?? "paperclip_managed"; const provider = getSecretProvider(input.provider); const providerConfig = await getSelectableRuntimeProviderConfig({ companyId, provider: input.provider, providerConfigId: input.providerConfigId, }); if (managedMode === "external_reference" && !input.externalRef?.trim()) { throw unprocessable("External reference secrets require externalRef"); } if (managedMode === "paperclip_managed" && input.externalRef?.trim()) { throw unprocessable("Managed secrets cannot override externalRef"); } if (managedMode === "paperclip_managed" && !input.value?.trim()) { throw unprocessable("Managed secrets require value"); } const providerWriteContext = { companyId, secretKey: key, secretName: input.name, version: 1, }; const reservedSecret = await db .insert(companySecrets) .values({ companyId, scope: instanceOwner ? "instance" : "company", ownerAgentId: instanceOwner?.agentId ?? null, ownerPluginId: instanceOwner?.pluginId ?? null, key, name: input.name, provider: input.provider, providerConfigId: input.providerConfigId ?? null, status: "archived", managedMode, externalRef: null, providerMetadata: input.providerMetadata ?? null, latestVersion: 0, description: input.description ?? null, createdByAgentId: actor?.agentId ?? null, createdByUserId: actor?.userId ?? null, }) .returning() .then((rows) => rows[0]); let prepared: PreparedSecretVersion; try { prepared = managedMode === "external_reference" ? await provider.linkExternalSecret({ externalRef: input.externalRef ?? "", providerVersionRef: input.providerVersionRef ?? null, providerConfig, context: providerWriteContext, }) : await provider.createSecret({ value: input.value ?? "", externalRef: null, providerConfig, context: providerWriteContext, }); } catch (error) { throw await throwProviderWriteOrReservedRowRollbackError({ error, rollbackReservedRow: () => db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)), companyId, provider: provider.id, providerConfigId: input.providerConfigId ?? null, providerConfig, operation: "secret.create", }); } try { await db .update(companySecrets) .set({ externalRef: prepared.externalRef, latestVersion: 1, updatedAt: new Date(), }) .where(eq(companySecrets.id, reservedSecret.id)); await db.insert(companySecretVersions).values({ secretId: reservedSecret.id, version: 1, material: prepared.material, valueSha256: prepared.valueSha256, fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256, providerVersionRef: prepared.providerVersionRef ?? null, status: "disabled", createdByAgentId: actor?.agentId ?? null, createdByUserId: actor?.userId ?? null, }); } catch (error) { if (managedMode === "paperclip_managed") { const cleaned = await cleanupPreparedProviderWrite({ provider, prepared, providerConfig, context: providerWriteContext, mode: "delete", operation: "create.prepare_rollback", }); if (!cleaned) { throwProviderCleanupFailedAfterCreateRollback({ companyId, provider: provider.id, providerConfigId: input.providerConfigId ?? null, providerConfig, operation: "create.prepare_rollback", }); } } await deleteLocalSecretCreateReservationOrThrow({ db, secretId: reservedSecret.id, companyId, provider: provider.id, providerConfigId: input.providerConfigId ?? null, providerConfig, operation: "create.prepare_rollback", }); throw error; } try { return await db.transaction(async (tx) => { await tx .update(companySecretVersions) .set({ status: "current" }) .where(and( eq(companySecretVersions.secretId, reservedSecret.id), eq(companySecretVersions.version, 1), )); const secret = await tx .update(companySecrets) .set({ status: "active", externalRef: prepared.externalRef, latestVersion: 1, lastRotatedAt: new Date(), updatedAt: new Date(), }) .where(eq(companySecrets.id, reservedSecret.id)) .returning() .then((rows) => rows[0]); if (!secret) throw notFound("Secret not found"); return secret; }); } catch (error) { if (managedMode === "paperclip_managed") { const cleaned = await cleanupPreparedProviderWrite({ provider, prepared, providerConfig, context: providerWriteContext, mode: "delete", operation: "create.rollback", }); if (!cleaned) { throwProviderCleanupFailedAfterCreateRollback({ companyId, provider: provider.id, providerConfigId: input.providerConfigId ?? null, providerConfig, operation: "create.rollback", }); } } await deleteLocalSecretCreateReservationOrThrow({ db, secretId: reservedSecret.id, companyId, provider: provider.id, providerConfigId: input.providerConfigId ?? null, providerConfig, operation: "create.rollback", }); throw error; } } // The body of `rotate:` below, unchanged. Extracted to a named function so // `rotate:` can wrap it in `withAccountHomeSecretMutationLock` only when the // secret being rotated is `local_encrypted`, without duplicating this whole // write sequence. async function rotateUnlocked( secretId: string, input: { value?: string | null; externalRef?: string | null; providerVersionRef?: string | null; providerConfigId?: string | null; // The optional owner-bound compare-and-set guard. When set, the final // update matches the latest version, so a concurrent rotation between the // read and the write cannot pass. A mismatch throws a 409 conflict. expectedLatestVersion?: number; /** Internal authenticated refresh write-back only; never accepted by HTTP schemas. */ preserveAiSessionEpoch?: boolean; }, actor?: { userId?: string | null; agentId?: string | null }, ) { const secret = await getById(secretId); if (!secret) throw notFound("Secret not found"); if (secret.status !== "active") throw unprocessable("Cannot rotate a non-active secret"); if (input.expectedLatestVersion !== undefined && secret.latestVersion !== input.expectedLatestVersion) { throw conflict(SECRET_VERSION_STALE_CONFLICT); } const providerId = secret.provider as SecretProvider; const provider = getSecretProvider(providerId); const providerConfigId = input.providerConfigId === undefined ? secret.providerConfigId : input.providerConfigId; const providerConfig = await getSelectableRuntimeProviderConfig({ companyId: secret.companyId, provider: providerId, providerConfigId, }); const nextVersion = secret.latestVersion + 1; const externalValueWrite = secret.managedMode === "external_reference" && Boolean(input.value?.trim()); if (externalValueWrite) { const currentRef = secret.externalRef?.trim(); if (!currentRef) { throw unprocessable("External reference secrets require externalRef"); } if (input.externalRef?.trim() && input.externalRef.trim() !== currentRef) { throw unprocessable( "Provide either a new value or a new external reference, not both", ); } if (input.providerVersionRef?.trim()) { throw unprocessable("Value updates cannot pin providerVersionRef"); } if (!provider.updateExternalSecretValue) { throw unprocessable( `${provider.descriptor().label} does not support writing values to external reference secrets`, ); } } if (secret.managedMode === "external_reference" && !(input.externalRef ?? secret.externalRef)?.trim()) { throw unprocessable("External reference secrets require externalRef"); } if (secret.managedMode !== "external_reference" && input.externalRef?.trim()) { throw unprocessable("Managed secrets cannot override externalRef"); } if (secret.managedMode !== "external_reference" && !input.value?.trim()) { throw unprocessable("Managed secrets require value"); } const providerWriteContext = { companyId: secret.companyId, secretKey: secret.key, secretName: secret.name, version: nextVersion, }; let prepared: PreparedSecretVersion; try { prepared = externalValueWrite ? await provider.updateExternalSecretValue!({ externalRef: secret.externalRef ?? "", value: input.value ?? "", providerConfig, context: providerWriteContext, }) : secret.managedMode === "external_reference" ? await provider.linkExternalSecret({ externalRef: input.externalRef ?? secret.externalRef ?? "", providerVersionRef: input.providerVersionRef ?? null, providerConfig, context: providerWriteContext, }) : await provider.createVersion({ value: input.value ?? "", externalRef: secret.externalRef ?? null, providerConfig, context: providerWriteContext, }); } catch (error) { throw remoteProviderWriteHttpError(error, { companyId: secret.companyId, provider: provider.id, providerConfigId, providerConfig, operation: "secret.rotate", }); } try { await db.insert(companySecretVersions).values({ secretId: secret.id, version: nextVersion, material: prepared.material, valueSha256: prepared.valueSha256, fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256, providerVersionRef: prepared.providerVersionRef ?? null, status: "disabled", createdByAgentId: actor?.agentId ?? null, createdByUserId: actor?.userId ?? null, }); } catch (error) { if (secret.managedMode !== "external_reference" || externalValueWrite) { await cleanupPreparedProviderWrite({ provider, prepared, providerConfig, context: providerWriteContext, mode: "archive", operation: "rotate.prepare_rollback", }); } // A guarded concurrent rotation inserted the same next version first, so // this insert fails the (secretId, version) unique index. The loser never // reaches the compare-and-set guard below. Normalize only that one // collision to the same stale conflict, so a guarded rotation always // returns one fixed 409. Re-throw every other error unchanged. if ( input.expectedLatestVersion !== undefined && isUniqueConstraintViolation(error, COMPANY_SECRET_VERSION_UNIQUE_CONSTRAINT) ) { throw conflict(SECRET_VERSION_STALE_CONFLICT); } throw error; } try { // Lock and update the parent secret row before touching its version // rows. A credential write-back locks the parent secret row first, then // calls this function. If this transaction updated the version rows // first instead, the two transactions would take their two row locks // in opposite order and could deadlock. Matching the order here removes // that risk: every caller now locks the parent row before the version // rows, so a lock cycle between the two tables cannot form. return await db.transaction(async (tx) => { const updated = await tx .update(companySecrets) .set({ latestVersion: nextVersion, aiSessionEpoch: input.preserveAiSessionEpoch ? secret.aiSessionEpoch : sql`${companySecrets.aiSessionEpoch} + 1`, externalRef: prepared.externalRef, providerConfigId, lastRotatedAt: new Date(), updatedAt: new Date(), }) .where(and( eq(companySecrets.id, secret.id), // The compare-and-set guard. It matches the latest version, so a // concurrent rotation that already bumped it fails this predicate. input.expectedLatestVersion === undefined ? undefined : eq(companySecrets.latestVersion, input.expectedLatestVersion), )) .returning() .then((rows) => rows[0] ?? null); if (!updated) { // The predicate matched no row. A supplied expected version means a // concurrent rotation won the race; return the stale conflict. An // unguarded rotation means the secret is gone. Neither version row // has been touched yet, so there is nothing to undo here. throw input.expectedLatestVersion === undefined ? notFound("Secret not found") : conflict(SECRET_VERSION_STALE_CONFLICT); } await tx .update(companySecretVersions) .set({ status: "previous" }) .where(and( eq(companySecretVersions.secretId, secret.id), ne(companySecretVersions.version, nextVersion), )); await tx .update(companySecretVersions) .set({ status: "current" }) .where(and( eq(companySecretVersions.secretId, secret.id), eq(companySecretVersions.version, nextVersion), )); return updated; }); } catch (error) { if (secret.managedMode !== "external_reference" || externalValueWrite) { const cleaned = await cleanupPreparedProviderWrite({ provider, prepared, providerConfig, context: providerWriteContext, mode: "archive", operation: "rotate.rollback", }); if (cleaned) { await db .delete(companySecretVersions) .where(and( eq(companySecretVersions.secretId, secret.id), eq(companySecretVersions.version, nextVersion), )) .catch(() => undefined); } } throw error; } } // The body of `update:` below, unchanged. Extracted to a named function so // `update:` can wrap it in `withAccountHomeSecretMutationLock`, keyed by the // company the pre-check read, without duplicating this whole patch // sequence. async function updateUnlocked( secretId: string, patch: { name?: string; key?: string; status?: "active" | "disabled" | "archived" | "deleted"; providerConfigId?: string | null; description?: string | null; externalRef?: string | null; providerMetadata?: Record | null; }, ) { const secret = await getById(secretId); if (!secret) throw notFound("Secret not found"); if (secret.status === "deleted") throw notFound("Secret not found"); if (patch.name && patch.name !== secret.name) { const duplicate = await getByName(secret.companyId, patch.name); if (duplicate && duplicate.id !== secret.id) { throw conflict(`Secret already exists: ${patch.name}`); } } const nextKey = patch.key ? normalizeSecretKey(patch.key) : secret.key; if (!nextKey) throw unprocessable("Secret key is required"); if (nextKey !== secret.key) { const duplicateKey = await db .select() .from(companySecrets) .where(and( eq(companySecrets.companyId, secret.companyId), eq(companySecrets.scope, "company"), eq(companySecrets.key, nextKey), ne(companySecrets.status, "deleted"), )) .then((rows) => rows[0] ?? null); if (duplicateKey && duplicateKey.id !== secret.id) { throw conflict(`Secret key already exists: ${nextKey}`); } } const deleting = patch.status === "deleted"; if (deleting && secret.managedMode === "paperclip_managed") { throw unprocessable("Managed secrets must be deleted through DELETE /secrets/:id"); } if (secret.managedMode !== "external_reference" && patch.externalRef !== undefined) { throw unprocessable("Managed secrets cannot override externalRef"); } if ( secret.managedMode === "external_reference" && patch.externalRef !== undefined && patch.externalRef !== secret.externalRef ) { throw unprocessable( "External reference secrets cannot be retargeted through generic update", ); } if ( secret.managedMode === "external_reference" && patch.providerConfigId !== undefined && patch.providerConfigId !== secret.providerConfigId ) { throw unprocessable( "External reference secrets cannot change provider vault through generic update", ); } if ( secret.managedMode === "paperclip_managed" && patch.providerConfigId !== undefined && patch.providerConfigId !== secret.providerConfigId ) { throw unprocessable( "Managed secrets cannot change provider vault through PATCH; use rotate() to migrate to a new vault", ); } if (patch.providerConfigId !== undefined) { await assertProviderConfigForSecret( secret.companyId, secret.provider as SecretProvider, patch.providerConfigId, ); } return db .update(companySecrets) .set({ key: deleting ? `${secret.key}__deleted__${secret.id}` : nextKey, name: deleting ? `${secret.name}__deleted__${secret.id}` : patch.name ?? secret.name, status: patch.status ?? secret.status, providerConfigId: patch.providerConfigId === undefined ? secret.providerConfigId : patch.providerConfigId, description: patch.description === undefined ? secret.description : patch.description, externalRef: patch.externalRef === undefined ? secret.externalRef : patch.externalRef, providerMetadata: patch.providerMetadata === undefined ? secret.providerMetadata : patch.providerMetadata, deletedAt: deleting ? new Date() : secret.deletedAt, updatedAt: new Date(), }) .where(eq(companySecrets.id, secret.id)) .returning() .then((rows) => rows[0] ?? null); } function collectTargetIds( bindings: Array, targetType: SecretBindingTargetType, opts?: { uuidOnly?: boolean }, ) { return [ ...new Set( bindings .filter((binding) => binding.targetType === targetType) .map((binding) => binding.targetId) .filter((id) => !opts?.uuidOnly || isUuidLike(id)), ), ]; } function fallbackBindingTarget(binding: typeof companySecretBindings.$inferSelect): CompanySecretBindingTarget { return { type: binding.targetType as SecretBindingTargetType, id: binding.targetId, label: binding.targetId, href: null, status: null, }; } async function buildBindingTargetMap( companyId: string, bindings: Array, ) { const targetMap = new Map(); const setTarget = (target: CompanySecretBindingTarget) => { targetMap.set(`${target.type}:${target.id}`, target); }; const agentIds = collectTargetIds(bindings, "agent", { uuidOnly: true }); if (agentIds.length > 0) { const rows = await db .select({ id: agents.id, name: agents.name, title: agents.title, status: agents.status, }) .from(agents) .where(and(eq(agents.companyId, companyId), inArray(agents.id, agentIds))); for (const row of rows) { setTarget({ type: "agent", id: row.id, label: row.title ? `${row.name} (${row.title})` : row.name, href: `/agents/${normalizeAgentUrlKey(row.name) ?? row.id}`, status: row.status, }); } } const projectIds = collectTargetIds(bindings, "project", { uuidOnly: true }); if (projectIds.length > 0) { const rows = await db .select({ id: projects.id, name: projects.name, status: projects.status, }) .from(projects) .where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds))); for (const row of rows) { setTarget({ type: "project", id: row.id, label: row.name, href: `/projects/${deriveProjectUrlKey(row.name, row.id)}`, status: row.status, }); } } const environmentIds = collectTargetIds(bindings, "environment", { uuidOnly: true }); if (environmentIds.length > 0) { const rows = await db .select({ id: environments.id, name: environments.name, status: environments.status, }) .from(environments) .where(inArray(environments.id, environmentIds)); for (const row of rows) { setTarget({ type: "environment", id: row.id, label: row.name, href: "/company/settings/instance/environments", status: row.status, }); } } const routineIds = collectTargetIds(bindings, "routine", { uuidOnly: true }); if (routineIds.length > 0) { const rows = await db .select({ id: routines.id, title: routines.title, status: routines.status, }) .from(routines) .where(and(eq(routines.companyId, companyId), inArray(routines.id, routineIds))); for (const row of rows) { setTarget({ type: "routine", id: row.id, label: row.title, href: `/routines/${row.id}`, status: row.status, }); } } const issueIds = collectTargetIds(bindings, "issue", { uuidOnly: true }); if (issueIds.length > 0) { const rows = await db .select({ id: issues.id, identifier: issues.identifier, title: issues.title, status: issues.status, }) .from(issues) .where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds))); for (const row of rows) { setTarget({ type: "issue", id: row.id, label: row.identifier ? `${row.identifier} ${row.title}` : row.title, href: `/issues/${row.identifier ?? row.id}`, status: row.status, }); } } const runIds = collectTargetIds(bindings, "run", { uuidOnly: true }); if (runIds.length > 0) { const rows = await db .select({ id: heartbeatRuns.id, agentId: heartbeatRuns.agentId, status: heartbeatRuns.status, }) .from(heartbeatRuns) .where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, runIds))); for (const row of rows) { setTarget({ type: "run", id: row.id, label: `Run ${row.id.slice(0, 8)}`, href: `/agents/${row.agentId}/runs/${row.id}`, status: row.status, }); } } return targetMap; } async function buildRemoteImportConflictMaps(companyId: string, provider: SecretProvider) { const activeSecrets = await db .select({ id: companySecrets.id, name: companySecrets.name, key: companySecrets.key, provider: companySecrets.provider, providerConfigId: companySecrets.providerConfigId, externalRef: companySecrets.externalRef, status: companySecrets.status, }) .from(companySecrets) .where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.scope, "company"), ne(companySecrets.status, "deleted"))); return { byProviderConfigExternalRef: new Map( activeSecrets .filter((secret) => secret.provider === provider && typeof secret.externalRef === "string" && secret.externalRef.trim() ) .map((secret) => [ remoteImportExternalRefKey(secret.providerConfigId, secret.externalRef!), secret, ]), ), byName: new Map(activeSecrets.map((secret) => [secret.name, secret])), byKey: new Map(activeSecrets.map((secret) => [secret.key, secret])), }; } function remoteImportExternalRefKey(providerConfigId: string | null | undefined, externalRef: string) { return `${providerConfigId ?? "default"}\0${externalRef.trim()}`; } function sanitizeRemoteProviderMetadata( provider: SecretProvider, metadata: Record | null | undefined, ): Record | null { if (!metadata || provider !== "aws_secrets_manager") return null; const safe: Record = {}; for (const key of ["createdDate", "lastAccessedDate", "lastChangedDate", "deletedDate"]) { const value = metadata[key]; if (typeof value === "string" || value === null) safe[key] = value; } for (const key of ["hasDescription", "hasKmsKey", "tagCount"]) { const value = metadata[key]; if (typeof value === "boolean" || typeof value === "number") safe[key] = value; } return Object.keys(safe).length > 0 ? safe : null; } function remoteImportConflictsFor(input: { providerConfigId: string | null; externalRef: string; name: string; key: string; maps: Awaited>; }): RemoteSecretImportConflict[] { const conflicts: RemoteSecretImportConflict[] = []; const duplicate = input.maps.byProviderConfigExternalRef.get( remoteImportExternalRefKey(input.providerConfigId, input.externalRef), ); if (duplicate) { conflicts.push({ type: "exact_reference", existingSecretId: duplicate.id, message: "An existing secret already links this exact provider reference.", }); return conflicts; } const nameConflict = input.maps.byName.get(input.name); if (nameConflict) { conflicts.push({ type: "name", existingSecretId: nameConflict.id, message: `Secret name already exists: ${input.name}`, }); } const keyConflict = input.maps.byKey.get(input.key); if (keyConflict) { conflicts.push({ type: "key", existingSecretId: keyConflict.id, message: `Secret key already exists: ${input.key}`, }); } return conflicts; } async function getRemoteImportProviderConfig(companyId: string, providerConfigId: string) { const providerConfig = await getProviderConfigById(providerConfigId); if (!providerConfig) throw notFound("Provider vault not found"); const provider = providerConfig.provider as SecretProvider; assertSelectableProviderConfig(providerConfig, companyId, provider); return { providerConfig, provider, runtimeConfig: toProviderVaultRuntimeConfig(providerConfig) }; } async function createUserSecretValueInternal( companyId: string, ownerUserId: string, input: { definitionId?: string | null; definitionKey?: string | null; value?: string | null; externalRef?: string | null; providerVersionRef?: string | null; providerConfigId?: string | null; }, actor?: { userId?: string | null; agentId?: string | null }, ) { const definition = await resolveUserSecretDefinition(companyId, input); if (definition.status !== "active") { throw unprocessable("User secret definition is not active"); } const existing = await getUserSecretValue({ companyId, ownerUserId, definitionId: definition.id, }); if (existing) throw conflict("User secret value already exists"); const providerId = definition.provider as SecretProvider; const managedMode = definition.managedMode as "paperclip_managed" | "external_reference"; if (managedMode === "external_reference" && !input.externalRef?.trim()) { throw unprocessable("External reference user secrets require externalRef"); } if (managedMode === "paperclip_managed" && input.externalRef?.trim()) { throw unprocessable("Managed user secrets cannot override externalRef"); } if (managedMode === "paperclip_managed" && !input.value?.trim()) { throw unprocessable("Managed user secrets require value"); } const providerConfigId = input.providerConfigId === undefined ? definition.providerConfigId : input.providerConfigId; const provider = getSecretProvider(providerId); const providerConfig = await getSelectableRuntimeProviderConfig({ companyId, provider: providerId, providerConfigId, }); const idSuffix = randomUUID(); const key = normalizeSecretKey(`user.${definition.key}.${idSuffix}`); const name = `${definition.name} (${ownerUserId})`; const providerWriteContext = { companyId, secretKey: key, secretName: definition.name, version: 1, }; let reservedSecret: typeof companySecrets.$inferSelect; try { reservedSecret = await db .insert(companySecrets) .values({ companyId, scope: "user", ownerUserId, userSecretDefinitionId: definition.id, key, name, provider: providerId, providerConfigId: providerConfigId ?? null, status: "archived", managedMode, externalRef: null, providerMetadata: definition.providerMetadata ?? null, latestVersion: 0, description: definition.description ?? null, createdByAgentId: actor?.agentId ?? null, createdByUserId: actor?.userId ?? null, }) .returning() .then((rows) => rows[0]); } catch (error) { if (isUniqueConstraintViolation(error, USER_SECRET_VALUE_UNIQUE_CONSTRAINT)) { throw conflict("User secret value already exists"); } throw error; } let prepared: PreparedSecretVersion; try { prepared = managedMode === "external_reference" ? await provider.linkExternalSecret({ externalRef: input.externalRef ?? "", providerVersionRef: input.providerVersionRef ?? null, providerConfig, context: providerWriteContext, }) : await provider.createSecret({ value: input.value ?? "", externalRef: null, providerConfig, context: providerWriteContext, }); } catch (error) { throw await throwProviderWriteOrReservedRowRollbackError({ error, rollbackReservedRow: () => db.delete(companySecrets).where(eq(companySecrets.id, reservedSecret.id)), companyId, provider: provider.id, providerConfigId, providerConfig, operation: "secret.create", }); } try { return await db.transaction(async (tx) => { await tx.insert(companySecretVersions).values({ secretId: reservedSecret.id, version: 1, material: prepared.material, valueSha256: prepared.valueSha256, fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256, providerVersionRef: prepared.providerVersionRef ?? null, status: "current", createdByAgentId: actor?.agentId ?? null, createdByUserId: actor?.userId ?? null, }); const secret = await tx .update(companySecrets) .set({ status: "active", externalRef: prepared.externalRef, latestVersion: 1, lastRotatedAt: new Date(), updatedAt: new Date(), }) .where(eq(companySecrets.id, reservedSecret.id)) .returning() .then((rows) => rows[0]); if (!secret) throw notFound("User secret value not found"); return secret; }); } catch (error) { if (managedMode === "paperclip_managed") { const cleaned = await cleanupPreparedProviderWrite({ provider, prepared, providerConfig, context: providerWriteContext, mode: "delete", operation: "user_secret_value.create_rollback", }); if (!cleaned) { throwProviderCleanupFailedAfterCreateRollback({ companyId, provider: provider.id, providerConfigId, providerConfig, operation: "user_secret_value.create_rollback", }); } } await deleteLocalSecretCreateReservationOrThrow({ db, secretId: reservedSecret.id, companyId, provider: provider.id, providerConfigId, providerConfig, operation: "user_secret_value.create_rollback", }); throw error; } } // --- Claude Code OAuth login: narrow definition and owner-bound write ------- type UserSecretDefinitionRow = typeof userSecretDefinitions.$inferSelect; type CompanySecretRow = typeof companySecrets.$inferSelect; /** True only when a stored definition matches the fixed Claude OAuth shape. */ function isCompatibleClaudeOAuthDefinition(definition: UserSecretDefinitionRow) { return ( definition.key === CLAUDE_CODE_OAUTH_DEFINITION.key && definition.name === CLAUDE_CODE_OAUTH_DEFINITION.name && definition.provider === CLAUDE_CODE_OAUTH_DEFINITION.provider && definition.managedMode === CLAUDE_CODE_OAUTH_DEFINITION.managedMode && definition.status === CLAUDE_CODE_OAUTH_DEFINITION.status ); } /** Reads the recorded setup-token session id from an owner value, or null. */ function readClaudeOAuthSessionId(secret: CompanySecretRow): string | null { const metadata = secret.providerMetadata; if (!metadata || typeof metadata !== "object") return null; const value = (metadata as Record)[CLAUDE_OAUTH_SESSION_METADATA_FIELD]; return typeof value === "string" ? value : null; } /** Records the setup-token session id on the owner value. Not a secret. */ async function stampClaudeOAuthSessionId( secret: CompanySecretRow, sessionId: string, ): Promise { const nextMetadata: Record = { ...(secret.providerMetadata ?? {}), [CLAUDE_OAUTH_SESSION_METADATA_FIELD]: sessionId, }; return db .update(companySecrets) .set({ providerMetadata: nextMetadata, updatedAt: new Date() }) .where(eq(companySecrets.id, secret.id)) .returning() .then((rows) => rows[0] ?? secret); } function toClaudeOAuthResult(secret: CompanySecretRow): ClaudeOAuthUserSecretResult { return { secretId: secret.id, latestVersion: secret.latestVersion, definitionId: secret.userSecretDefinitionId ?? "", }; } /** * Ensures the fixed Claude Code OAuth user-secret definition for a company. The * helper accepts no key, name, provider, mode, or status from a caller. It * reads the existing definition by the company and the fixed key. It returns an * exact compatible definition. It rejects a conflicting definition with 409 and * does not mutate it. After a uniqueness conflict it re-reads the row and * compares the fixed fields before it returns. */ async function ensureClaudeOAuthUserSecretDefinitionInternal( companyId: string, actor?: { userId?: string | null; agentId?: string | null }, ): Promise { const existing = await getUserSecretDefinitionByKey(companyId, CLAUDE_CODE_OAUTH_DEFINITION.key); if (existing) { if (!isCompatibleClaudeOAuthDefinition(existing)) { throw conflict(CLAUDE_OAUTH_DEFINITION_CONFLICT); } return existing; } try { return await db .insert(userSecretDefinitions) .values({ companyId, key: CLAUDE_CODE_OAUTH_DEFINITION.key, name: CLAUDE_CODE_OAUTH_DEFINITION.name, description: null, status: CLAUDE_CODE_OAUTH_DEFINITION.status, provider: CLAUDE_CODE_OAUTH_DEFINITION.provider, providerConfigId: null, managedMode: CLAUDE_CODE_OAUTH_DEFINITION.managedMode, providerMetadata: null, usageGuidance: null, createdByAgentId: actor?.agentId ?? null, createdByUserId: actor?.userId ?? null, updatedByAgentId: actor?.agentId ?? null, updatedByUserId: actor?.userId ?? null, }) .returning() .then((rows) => rows[0]); } catch (error) { if (isUniqueConstraintViolation(error, USER_SECRET_DEFINITION_KEY_UNIQUE_CONSTRAINT)) { // A concurrent create won the race. Re-read and compare the fixed fields. const raced = await getUserSecretDefinitionByKey(companyId, CLAUDE_CODE_OAUTH_DEFINITION.key); if (raced && isCompatibleClaudeOAuthDefinition(raced)) return raced; throw conflict(CLAUDE_OAUTH_DEFINITION_CONFLICT); } throw error; } } /** * The owner-bound compare-and-set for the Claude Code OAuth value. It has two * modes. `first_write` creates a value only when no owner value exists. * `confirmed_rotation` rotates only after confirmation with the expected secret * id and the expected latest version. The session id is the idempotency key: a * repeated successful completion returns the stored result and creates no new * version. */ async function completeClaudeOAuthUserSecretInternal( companyId: string, ownerUserId: string, input: { sessionId: string; mode: "first_write" | "confirmed_rotation"; value: string; expectedSecretId?: string | null; expectedLatestVersion?: number | null; }, actor?: { userId?: string | null; agentId?: string | null }, ): Promise { const sessionId = input.sessionId?.trim(); if (!sessionId) throw unprocessable("A Claude login session id is required"); const value = input.value?.trim(); if (!value) throw unprocessable("A Claude login token value is required"); const definition = await ensureClaudeOAuthUserSecretDefinitionInternal(companyId, actor); // Idempotency: a prior successful completion for this session returns the // stored result and creates no new version. const existing = await getUserSecretValue({ companyId, ownerUserId, definitionId: definition.id }); if (existing && readClaudeOAuthSessionId(existing) === sessionId) { return toClaudeOAuthResult(existing); } if (input.mode === "first_write") { if (existing) throw conflict(CLAUDE_OAUTH_VALUE_EXISTS); let created: CompanySecretRow; try { created = await createUserSecretValueInternal( companyId, ownerUserId, { definitionId: definition.id, value }, actor, ); } catch (error) { // Two concurrent first writes race the partial unique index. Re-read and // return the stored result only when this session already won. if (error instanceof HttpError && error.status === 409) { const raced = await getUserSecretValue({ companyId, ownerUserId, definitionId: definition.id }); if (raced && readClaudeOAuthSessionId(raced) === sessionId) { return toClaudeOAuthResult(raced); } } throw error; } const stamped = await stampClaudeOAuthSessionId(created, sessionId); return toClaudeOAuthResult(stamped); } // confirmed_rotation. if (!input.expectedSecretId || input.expectedLatestVersion == null) { throw unprocessable("A confirmed rotation requires expectedSecretId and expectedLatestVersion"); } // The owner-scoped lookup fails closed for a cross-owner or cross-company id. const current = await getUserSecretValueById(companyId, ownerUserId, input.expectedSecretId); if (current.userSecretDefinitionId !== definition.id) { throw notFound("User secret value not found"); } if (current.latestVersion !== input.expectedLatestVersion) { throw conflict(CLAUDE_OAUTH_STALE_CONFIRMATION); } let rotated: CompanySecretRow; try { rotated = await secretService(db).rotate( current.id, { value, expectedLatestVersion: input.expectedLatestVersion }, actor, ); } catch (error) { if (error instanceof HttpError && error.status === 409) { // A concurrent rotation bumped the version between the read and the // predicate. Return the same fixed stale-confirmation conflict. throw conflict(CLAUDE_OAUTH_STALE_CONFIRMATION); } throw error; } const stamped = await stampClaudeOAuthSessionId(rotated, sessionId); return toClaudeOAuthResult(stamped); } /** * Reads the stored Claude Code OAuth value metadata for one owner. It returns * only the secret id and the latest version, never the token. The client uses * the version as the expected version of a later confirmed rotation. * * The reader derives no definition, no owner, and no secret id from a caller. * It reads the fixed definition by the company and the fixed key. It reads the * value by the company, the owner, and that definition. It returns null when no * definition or no owner value exists, so a foreign value and a missing value * look the same to the caller. It never creates the definition. */ async function readClaudeOAuthUserSecretStatusInternal( companyId: string, ownerUserId: string, ): Promise<{ secretId: string; latestVersion: number } | null> { const definition = await getUserSecretDefinitionByKey(companyId, CLAUDE_CODE_OAUTH_DEFINITION.key); if (!definition || !isCompatibleClaudeOAuthDefinition(definition)) return null; const existing = await getUserSecretValue({ companyId, ownerUserId, definitionId: definition.id }); if (!existing) return null; return { secretId: existing.id, latestVersion: existing.latestVersion }; } // A delete can stop the generated account-home secret from resolving to // the value a device-login promotion already validated, the same way a // rename, an archive, or a disable can (see `update:`'s comment). Run every // delete inside `withAccountHomeSecretMutationLock`, keyed by the company a // pre-check read, so a delete can never land in the gap between the // promotion's terminal-commit re-check and the commit it guards. // `removeSecretUnlocked` re-reads the secret under the lock, so a delete // that lost a race between the pre-check and the lock still sees current // state, not the pre-check's stale read. async function removeSecretInternal(secretId: string) { const preCheckSecret = await getById(secretId); if (!preCheckSecret) return null; return withAccountHomeSecretMutationLock(undefined, preCheckSecret.companyId, () => removeSecretUnlocked(secretId), ); } // The body of `removeSecretInternal` above, unchanged. Extracted to a named // function so that wrapper can hold the lock across this whole delete // sequence without duplicating it. async function removeSecretUnlocked(secretId: string) { const secret = await getById(secretId); if (!secret) return null; const versionRow = await getSecretVersion(secret.id, secret.latestVersion); const providerId = secret.provider as SecretProvider; const provider = getSecretProvider(providerId); if (secret.status !== "deleted") { await db .update(companySecrets) .set({ key: `${secret.key}__deleted__${secret.id}`, name: `${secret.name}__deleted__${secret.id}`, status: "deleted", deletedAt: secret.deletedAt ?? new Date(), updatedAt: new Date(), }) .where(eq(companySecrets.id, secretId)); } const providerConfig = secret.providerConfigId ? await getProviderConfigById(secret.providerConfigId) : null; const providerRuntimeConfig = providerConfig && providerConfig.status !== "disabled" && providerConfig.status !== "coming_soon" ? toProviderVaultRuntimeConfig(providerConfig) : null; if (!secret.providerConfigId || providerRuntimeConfig) { try { await provider.deleteOrArchive({ material: versionRow?.material as Record | undefined, externalRef: secret.externalRef, providerConfig: providerRuntimeConfig, context: { companyId: secret.companyId, secretKey: secret.key, secretName: secret.name, version: secret.latestVersion, }, mode: "delete", }); } catch (error) { if (!isSecretProviderClientError(error) || error.code !== "not_found") { throw error; } } } await db.delete(companySecrets).where(eq(companySecrets.id, secretId)); return secret; } async function removeUserSecretDefinitionInternal( companyId: string, definitionId: string, actor?: { userId?: string | null; agentId?: string | null }, ) { const existing = await resolveUserSecretDefinition(companyId, { definitionId }); const values = await db .select({ id: companySecrets.id }) .from(companySecrets) .where(and( eq(companySecrets.companyId, companyId), eq(companySecrets.scope, "user"), eq(companySecrets.userSecretDefinitionId, definitionId), )); for (const value of values) { await removeSecretInternal(value.id); } return db .update(userSecretDefinitions) .set({ key: `${existing.key}__deleted__${existing.id}`, status: "deleted", deletedAt: existing.deletedAt ?? new Date(), updatedByAgentId: actor?.agentId ?? null, updatedByUserId: actor?.userId ?? null, updatedAt: new Date(), }) .where(and( eq(userSecretDefinitions.companyId, companyId), eq(userSecretDefinitions.id, definitionId), )) .returning() .then((rows) => rows[0] ?? null); } return { listProviders: () => listSecretProviders(), async hasInstanceAccountHomeClaim(companyId: string, accountHomeDir: string): Promise { try { const rows = await db.select().from(companySecrets).where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.scope, "instance"), ne(companySecrets.status, "deleted"))); for (const row of rows) { if (!row.ownerAgentId || !row.ownerPluginId) return true; const resolved = await resolveSecretValueInternal(companyId, row.id, "latest", { instanceOwner: { agentId: row.ownerAgentId, pluginId: row.ownerPluginId }, accessContext: { consumerType: "system", consumerId: DEVICE_LOGIN_SECRET_CHECK_CONSUMER_ID, actorType: "system", actorId: null }, }); if (resolved.value === accountHomeDir) return true; } return false; } catch { return true; } }, agentInstance: { async read(scope: { companyId: string; agentId: string; pluginId: string }): Promise> { const rows = await db.select().from(companySecrets).where(and(eq(companySecrets.companyId, scope.companyId), eq(companySecrets.scope, "instance"), eq(companySecrets.ownerAgentId, scope.agentId), eq(companySecrets.ownerPluginId, scope.pluginId), ne(companySecrets.status, "deleted"))); const values: Record = Object.create(null); for (const row of rows) { const resolved = await resolveSecretValueInternal(scope.companyId, row.id, "latest", { instanceOwner: scope, accessContext: { consumerType: "plugin_worker", consumerId: scope.pluginId, actorType: "plugin", actorId: scope.pluginId, pluginId: scope.pluginId }, }); values[row.name] = resolved.value; } return values; }, async set(scope: { companyId: string; agentId: string; pluginId: string }, key: string, value: string | null) { return withAccountHomeSecretMutationLock(undefined, scope.companyId, async () => { if (value) await assertAccountHomeCacheDirStillValid(undefined, scope.companyId, value); const normalizedKey = normalizeSecretKey(key); const [existing] = await db.select().from(companySecrets).where(and(eq(companySecrets.companyId, scope.companyId), eq(companySecrets.scope, "instance"), eq(companySecrets.ownerAgentId, scope.agentId), eq(companySecrets.ownerPluginId, scope.pluginId), eq(companySecrets.key, normalizedKey), ne(companySecrets.status, "deleted"))); if (value === null) { if (existing) await removeSecretUnlocked(existing.id); } else if (existing) { await rotateUnlocked(existing.id, { value, expectedLatestVersion: existing.latestVersion }); } else { await createSecretUnlocked(scope.companyId, { name: key, key, provider: "local_encrypted", value }, undefined, scope); } }); }, }, checkProviders: () => checkSecretProviders(), previewProviderConfigDiscovery: async ( companyId: string, input: { provider: SecretProvider; config?: Record; query?: string | null; nextToken?: string | null; pageSize?: number; }, ): Promise => { const parsed = secretProviderConfigDiscoveryPreviewSchema.safeParse({ provider: input.provider, config: input.config ?? {}, query: input.query, nextToken: input.nextToken, pageSize: input.pageSize, }); if (!parsed.success) { throw unprocessable("Invalid provider vault discovery config", parsed.error.flatten()); } const providerId = parsed.data.provider as SecretProvider; const provider = getSecretProvider(providerId); if (!provider.discoverProviderConfigs) { throw unprocessable(`${providerId} provider does not support provider vault discovery`); } const runtimeConfig = toDraftProviderVaultRuntimeConfig({ companyId, provider: providerId, config: parsed.data.config, }); try { return await provider.discoverProviderConfigs({ companyId, providerConfig: runtimeConfig, query: parsed.data.query, nextToken: parsed.data.nextToken, pageSize: parsed.data.pageSize, }); } catch (error) { throw remoteProviderHttpError(error, { companyId, provider: providerId, providerConfigId: "discovery-preview", operation: "secret_provider_config.discovery.preview", providerConfig: parsed.data.config, }); } }, listProviderConfigs: (companyId: string) => db .select() .from(companySecretProviderConfigs) .where(eq(companySecretProviderConfigs.companyId, companyId)) .orderBy(desc(companySecretProviderConfigs.createdAt)), getProviderConfigById, createProviderConfig: async ( companyId: string, input: { provider: SecretProvider; displayName: string; status?: SecretProviderConfigStatus; isDefault?: boolean; config?: Record; }, actor?: { userId?: string | null; agentId?: string | null }, ) => { const parsed = createSecretProviderConfigSchema.safeParse(input); if (!parsed.success) throw unprocessable("Invalid provider vault config", parsed.error.flatten()); const status = input.status ?? defaultProviderConfigStatus(input.provider); if ((status === "coming_soon" || status === "disabled") && input.isDefault) { throw unprocessable("Only ready or warning provider vaults can be default"); } const normalizedConfig = validateProviderConfigPayload(input.provider, input.config ?? {}); return db.transaction(async (tx) => { if (input.isDefault) { await tx .update(companySecretProviderConfigs) .set({ isDefault: false, updatedAt: new Date() }) .where(and( eq(companySecretProviderConfigs.companyId, companyId), eq(companySecretProviderConfigs.provider, input.provider), )); } return tx .insert(companySecretProviderConfigs) .values({ companyId, provider: input.provider, displayName: input.displayName.trim(), status, isDefault: input.isDefault ?? false, config: normalizedConfig, disabledAt: status === "disabled" ? new Date() : null, createdByAgentId: actor?.agentId ?? null, createdByUserId: actor?.userId ?? null, }) .returning() .then((rows) => rows[0]); }); }, updateProviderConfig: async ( id: string, patch: { displayName?: string; status?: SecretProviderConfigStatus; isDefault?: boolean; config?: Record; }, ) => { const existing = await getProviderConfigById(id); if (!existing) return null; const parsed = updateSecretProviderConfigSchema.safeParse(patch); if (!parsed.success) throw unprocessable("Invalid provider vault config", parsed.error.flatten()); const provider = existing.provider as SecretProvider; const status = patch.status ?? (existing.status as SecretProviderConfigStatus); if (COMING_SOON_SECRET_PROVIDERS.has(provider) && status !== "coming_soon" && status !== "disabled") { throw unprocessable(`${provider} provider vaults are locked while coming soon`); } if ((status === "coming_soon" || status === "disabled") && patch.isDefault) { throw unprocessable("Only ready or warning provider vaults can be default"); } const normalizedConfig = patch.config === undefined ? existing.config : validateProviderConfigPayload(provider, patch.config); return db.transaction(async (tx) => { if (patch.isDefault) { await tx .update(companySecretProviderConfigs) .set({ isDefault: false, updatedAt: new Date() }) .where(and( eq(companySecretProviderConfigs.companyId, existing.companyId), eq(companySecretProviderConfigs.provider, existing.provider), )); } return tx .update(companySecretProviderConfigs) .set({ displayName: patch.displayName?.trim() ?? existing.displayName, status, isDefault: status === "disabled" || status === "coming_soon" ? false : patch.isDefault ?? existing.isDefault, config: normalizedConfig, disabledAt: status === "disabled" ? existing.disabledAt ?? new Date() : null, updatedAt: new Date(), }) .where(eq(companySecretProviderConfigs.id, id)) .returning() .then((rows) => rows[0] ?? null); }); }, disableProviderConfig: async (id: string) => { const existing = await getProviderConfigById(id); if (!existing) return null; return db .update(companySecretProviderConfigs) .set({ status: "disabled", isDefault: false, disabledAt: existing.disabledAt ?? new Date(), updatedAt: new Date(), }) .where(eq(companySecretProviderConfigs.id, id)) .returning() .then((rows) => rows[0] ?? null); }, removeProviderConfig: async (id: string) => db .delete(companySecretProviderConfigs) .where(eq(companySecretProviderConfigs.id, id)) .returning() .then((rows) => rows[0] ?? null), setDefaultProviderConfig: async (id: string) => { const existing = await getProviderConfigById(id); if (!existing) return null; if (existing.status === "coming_soon" || existing.status === "disabled") { throw unprocessable("Only ready or warning provider vaults can be default"); } return db.transaction(async (tx) => { const current = await tx .select() .from(companySecretProviderConfigs) .where(eq(companySecretProviderConfigs.id, id)) .then((rows) => rows[0] ?? null); if (!current) return null; if (current.status === "coming_soon" || current.status === "disabled") { throw unprocessable("Only ready or warning provider vaults can be default"); } await tx .update(companySecretProviderConfigs) .set({ isDefault: false, updatedAt: new Date() }) .where(and( eq(companySecretProviderConfigs.companyId, current.companyId), eq(companySecretProviderConfigs.provider, current.provider), )); const updated = await tx .update(companySecretProviderConfigs) .set({ isDefault: true, updatedAt: new Date() }) .where(and( eq(companySecretProviderConfigs.id, id), notInArray(companySecretProviderConfigs.status, ["coming_soon", "disabled"]), )) .returning() .then((rows) => rows[0] ?? null); if (!updated) throw unprocessable("Only ready or warning provider vaults can be default"); return updated; }); }, checkProviderConfigHealth: async (id: string) => { const existing = await getProviderConfigById(id); if (!existing) return null; const checkedAt = new Date(); const staticHealth = providerConfigHealth({ id: existing.id, provider: existing.provider as SecretProvider, status: existing.status as SecretProviderConfigStatus, config: existing.config ?? {}, }); const provider = getSecretProvider(existing.provider as SecretProvider); const health = staticHealth ?? mapProviderModuleHealth({ configId: existing.id, provider: existing.provider as SecretProvider, providerStatus: existing.status as SecretProviderConfigStatus, health: await provider.healthCheck({ providerConfig: toProviderVaultRuntimeConfig(existing), }), }); await db .update(companySecretProviderConfigs) .set({ healthStatus: health.status, healthCheckedAt: checkedAt, healthMessage: health.message, healthDetails: health.details as unknown as Record, updatedAt: new Date(), }) .where(eq(companySecretProviderConfigs.id, id)); return { ...health, checkedAt }; }, list: async (companyId: string) => { const [secrets, referenceCounts] = await Promise.all([ db .select() .from(companySecrets) .where(and( eq(companySecrets.companyId, companyId), eq(companySecrets.scope, "company"), ne(companySecrets.status, "deleted"), )) .orderBy(desc(companySecrets.createdAt)), db .select({ secretId: companySecretBindings.secretId, count: sql`count(*)::int`, }) .from(companySecretBindings) .where(eq(companySecretBindings.companyId, companyId)) .groupBy(companySecretBindings.secretId), ]); const countsBySecretId = new Map(referenceCounts.map((row) => [row.secretId, row.count])); return secrets.map((secret) => ({ ...secret, referenceCount: countsBySecretId.get(secret.id) ?? 0, })); }, listBindings: (companyId: string, secretId?: string) => db .select() .from(companySecretBindings) .where( secretId ? and(eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.secretId, secretId)) : eq(companySecretBindings.companyId, companyId), ) .orderBy(desc(companySecretBindings.createdAt)), listBindingReferences: async (companyId: string, secretId: string) => { const bindings = await db .select() .from(companySecretBindings) .where(and(eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.secretId, secretId))) .orderBy(desc(companySecretBindings.createdAt)); const targetMap = await buildBindingTargetMap(companyId, bindings); return bindings.map((binding) => ({ ...binding, target: targetMap.get(`${binding.targetType}:${binding.targetId}`) ?? fallbackBindingTarget(binding), })); }, listAccessEvents: (companyId: string, secretId: string) => db .select() .from(secretAccessEvents) .where(and(eq(secretAccessEvents.companyId, companyId), eq(secretAccessEvents.secretId, secretId))) .orderBy(desc(secretAccessEvents.createdAt)), listUserSecretDefinitions: (companyId: string) => db .select() .from(userSecretDefinitions) .where(and(eq(userSecretDefinitions.companyId, companyId), ne(userSecretDefinitions.status, "deleted"))) .orderBy(desc(userSecretDefinitions.createdAt)), getUserSecretDefinitionById: (companyId: string, definitionId: string) => getUserSecretDefinitionById(companyId, definitionId), createUserSecretDefinition: async ( companyId: string, input: { key: string; name: string; description?: string | null; status?: string; provider: SecretProvider; providerConfigId?: string | null; managedMode?: "paperclip_managed" | "external_reference"; providerMetadata?: Record | null; usageGuidance?: string | null; }, actor?: { userId?: string | null; agentId?: string | null }, ) => { const key = input.key.trim(); const duplicate = await getUserSecretDefinitionByKey(companyId, key); if (duplicate) throw conflict(`User secret definition already exists: ${key}`); await assertProviderConfigForSecret(companyId, input.provider, input.providerConfigId); try { return await db .insert(userSecretDefinitions) .values({ companyId, key, name: input.name.trim(), description: input.description ?? null, status: input.status ?? "active", provider: input.provider, providerConfigId: input.providerConfigId ?? null, managedMode: input.managedMode ?? "paperclip_managed", providerMetadata: input.providerMetadata ?? null, usageGuidance: input.usageGuidance ?? null, createdByAgentId: actor?.agentId ?? null, createdByUserId: actor?.userId ?? null, updatedByAgentId: actor?.agentId ?? null, updatedByUserId: actor?.userId ?? null, }) .returning() .then((rows) => rows[0]); } catch (error) { if (isUniqueConstraintViolation(error, USER_SECRET_DEFINITION_KEY_UNIQUE_CONSTRAINT)) { throw conflict(`User secret definition already exists: ${key}`); } throw error; } }, updateUserSecretDefinition: async ( companyId: string, definitionId: string, patch: { key?: string; name?: string; description?: string | null; status?: string; providerConfigId?: string | null; providerMetadata?: Record | null; usageGuidance?: string | null; }, actor?: { userId?: string | null; agentId?: string | null }, ) => { const existing = await resolveUserSecretDefinition(companyId, { definitionId }); if (patch.status === "deleted") { return removeUserSecretDefinitionInternal(companyId, existing.id, actor); } const nextKey = patch.key?.trim() ?? existing.key; if (nextKey !== existing.key) { const duplicate = await getUserSecretDefinitionByKey(companyId, nextKey); if (duplicate && duplicate.id !== existing.id) { throw conflict(`User secret definition already exists: ${nextKey}`); } } if (patch.providerConfigId !== undefined) { await assertProviderConfigForSecret( companyId, existing.provider as SecretProvider, patch.providerConfigId, ); } return db .update(userSecretDefinitions) .set({ key: nextKey, name: patch.name?.trim() ?? existing.name, description: patch.description === undefined ? existing.description : patch.description, status: patch.status ?? existing.status, providerConfigId: patch.providerConfigId === undefined ? existing.providerConfigId : patch.providerConfigId, providerMetadata: patch.providerMetadata === undefined ? existing.providerMetadata : patch.providerMetadata, usageGuidance: patch.usageGuidance === undefined ? existing.usageGuidance : patch.usageGuidance, updatedByAgentId: actor?.agentId ?? null, updatedByUserId: actor?.userId ?? null, deletedAt: existing.deletedAt, updatedAt: new Date(), }) .where(and( eq(userSecretDefinitions.companyId, companyId), eq(userSecretDefinitions.id, definitionId), )) .returning() .then((rows) => rows[0] ?? null); }, removeUserSecretDefinition: async ( companyId: string, definitionId: string, actor?: { userId?: string | null; agentId?: string | null }, ) => removeUserSecretDefinitionInternal(companyId, definitionId, actor), getUserSecretDefinitionCoverage: async (companyId: string, definitionId: string) => { await resolveUserSecretDefinition(companyId, { definitionId }); const [members, values] = await Promise.all([ db .select({ principalId: companyMemberships.principalId }) .from(companyMemberships) .where(and( eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.status, "active"), )), db .select({ status: companySecrets.status, ownerUserId: companySecrets.ownerUserId }) .from(companySecrets) .where(and( eq(companySecrets.companyId, companyId), eq(companySecrets.scope, "user"), eq(companySecrets.userSecretDefinitionId, definitionId), ne(companySecrets.status, "deleted"), )), ]); const memberIds = new Set(members.map((member) => member.principalId)); const configuredCount = values.filter((value) => value.status === "active" && value.ownerUserId && memberIds.has(value.ownerUserId) ).length; const inactiveCount = values.filter((value) => value.status !== "active" && value.ownerUserId && memberIds.has(value.ownerUserId) ).length; return { definitionId, configuredCount, inactiveCount, missingCount: Math.max(0, memberIds.size - configuredCount - inactiveCount), }; }, listCurrentUserSecretValues: async (companyId: string, ownerUserId: string) => { const definitions = await db .select() .from(userSecretDefinitions) .where(and(eq(userSecretDefinitions.companyId, companyId), ne(userSecretDefinitions.status, "deleted"))) .orderBy(desc(userSecretDefinitions.createdAt)); const values = await db .select() .from(companySecrets) .where(and( eq(companySecrets.companyId, companyId), eq(companySecrets.scope, "user"), eq(companySecrets.ownerUserId, ownerUserId), ne(companySecrets.status, "deleted"), )); const valuesByDefinitionId = new Map(values.map((value) => [value.userSecretDefinitionId, value])); return definitions.map((definition) => ({ definition, secret: valuesByDefinitionId.get(definition.id) ?? null, })); }, createCurrentUserSecretValue: createUserSecretValueInternal, // The narrow Claude Code OAuth definition helper. A caller passes // no key, name, provider, mode, or status. The route calls it only after the // authenticated board-user, company, and sandbox checks pass. ensureClaudeOAuthUserSecretDefinition: ( companyId: string, actor?: { userId?: string | null; agentId?: string | null }, ) => ensureClaudeOAuthUserSecretDefinitionInternal(companyId, actor), // The owner-bound Claude Code OAuth compare-and-set. It creates a // first value or rotates after a confirmed expected version. The session id // is the idempotency key for one completion. completeClaudeOAuthUserSecret: completeClaudeOAuthUserSecretInternal, // The owner-bound Claude Code OAuth status read. It returns only // the secret id and the latest version for the owner value, or null. It never // returns the token and never creates the definition. The status route reads // the expected version from it before it captures the confirmed rotation. readClaudeOAuthUserSecretStatus: readClaudeOAuthUserSecretStatusInternal, rotateCurrentUserSecretValue: async ( companyId: string, ownerUserId: string, secretId: string, input: { value?: string | null; externalRef?: string | null; providerVersionRef?: string | null; providerConfigId?: string | null; }, actor?: { userId?: string | null; agentId?: string | null }, ) => { const secret = await getUserSecretValueById(companyId, ownerUserId, secretId); return await (async () => { await resolveUserSecretDefinition(companyId, { definitionId: secret.userSecretDefinitionId }); return (await secretService(db).rotate(secret.id, input, actor)); })(); }, updateCurrentUserSecretValue: async ( companyId: string, ownerUserId: string, secretId: string, patch: { status?: "active" | "disabled" | "archived" | "deleted"; value?: string | null; externalRef?: string | null; providerVersionRef?: string | null; providerConfigId?: string | null; }, actor?: { userId?: string | null; agentId?: string | null }, ) => { const secret = await getUserSecretValueById(companyId, ownerUserId, secretId); if ( patch.value != null || patch.externalRef != null || patch.providerVersionRef != null || patch.providerConfigId != null ) { return await secretService(db).rotateCurrentUserSecretValue( companyId, ownerUserId, secret.id, patch, actor, ); } if (patch.status === "deleted") { return await secretService(db).removeCurrentUserSecretValue(companyId, ownerUserId, secret.id); } return db .update(companySecrets) .set({ status: patch.status ?? secret.status, updatedAt: new Date(), }) .where(eq(companySecrets.id, secret.id)) .returning() .then((rows) => rows[0] ?? null); }, removeCurrentUserSecretValue: async (companyId: string, ownerUserId: string, secretId: string) => { const secret = await getUserSecretValueById(companyId, ownerUserId, secretId); return await secretService(db).remove(secret.id); }, syncUserSecretDeclarationsForTarget: async ( companyId: string, target: { targetType: SecretBindingTargetType; targetId: string; pathPrefix?: string }, refs: Array<{ definitionKey: string; configPath: string; envKey: string; versionSelector?: SecretVersionSelector; required?: boolean; allowMissingOverride?: boolean; label?: string | null; }>, options?: { db?: SecretBindingDb; replaceAll?: boolean }, ) => { const targetDb = options?.db ?? db; const normalizedRefs: Array<{ definitionId: string; configPath: string; envKey: string; versionSelector: SecretVersionSelector; required: boolean; allowMissingOverride: boolean; label: string | null; }> = []; for (const ref of refs) { const definition = await resolveUserSecretDefinition( companyId, { definitionKey: ref.definitionKey }, targetDb, { envKey: ref.envKey, configPath: ref.configPath, consumerType: target.targetType, consumerId: target.targetId, }, ); normalizedRefs.push({ definitionId: definition.id, configPath: ref.configPath, envKey: ref.envKey, versionSelector: ref.versionSelector ?? "latest", required: ref.required ?? true, allowMissingOverride: ref.allowMissingOverride ?? false, label: ref.label ?? null, }); } const pathPrefix = target.pathPrefix ?? "env"; const writeDeclarations = async (executor: SecretBindingDb) => { if (options?.replaceAll) { await executor .delete(userSecretDeclarations) .where(and( eq(userSecretDeclarations.companyId, companyId), eq(userSecretDeclarations.targetType, target.targetType), eq(userSecretDeclarations.targetId, target.targetId), )); } else { await executor .delete(userSecretDeclarations) .where(and( eq(userSecretDeclarations.companyId, companyId), eq(userSecretDeclarations.targetType, target.targetType), eq(userSecretDeclarations.targetId, target.targetId), like(userSecretDeclarations.configPath, `${pathPrefix}.%`), )); } if (normalizedRefs.length === 0) return; await executor.insert(userSecretDeclarations).values( normalizedRefs.map((ref) => ({ companyId, userSecretDefinitionId: ref.definitionId, targetType: target.targetType, targetId: target.targetId, configPath: ref.configPath, envKey: ref.envKey, versionSelector: String(ref.versionSelector), required: ref.required, allowMissingOverride: ref.allowMissingOverride, label: ref.label, })), ); }; if (options?.db) { await writeDeclarations(targetDb); } else { await db.transaction(async (tx) => writeDeclarations(tx)); } return normalizedRefs; }, resolveUserSecretValue: async ( companyId: string, input: { definitionKey?: string | null; definitionId?: string | null; responsibleUserId?: string | null; version?: SecretVersionSelector; required?: boolean; allowMissingOverride?: boolean; }, context?: SecretConsumerContext, ): Promise => { const responsibleUserId = input.responsibleUserId ?? context?.responsibleUserId ?? null; const optionalBinding = input.allowMissingOverride || input.required === false; let definition: typeof userSecretDefinitions.$inferSelect; try { definition = await resolveUserSecretDefinition(companyId, input, db, { configPath: context?.configPath ?? null, consumerType: context?.consumerType ?? null, consumerId: context?.consumerId ?? null, }); } catch (error) { if (optionalBinding && error instanceof HttpError && error.status === 404) return null; throw error; } if (definition.status !== "active") { if (optionalBinding) return null; throw unprocessable("User secret definition is not active"); } if (!responsibleUserId?.trim()) { if (optionalBinding) return null; throw unprocessable("Responsible user is required for user secret resolution", { code: "responsible_user_missing", }); } let declaration: typeof userSecretDeclarations.$inferSelect | null = null; if (context?.configPath) { declaration = await db .select() .from(userSecretDeclarations) .where(and( eq(userSecretDeclarations.companyId, companyId), eq(userSecretDeclarations.userSecretDefinitionId, definition.id), eq(userSecretDeclarations.targetType, context.consumerType), eq(userSecretDeclarations.targetId, context.consumerId), eq(userSecretDeclarations.configPath, context.configPath), )) .then((rows) => rows[0] ?? null); if (!declaration) { if (optionalBinding) return null; throw unprocessable( `User secret is not declared for ${context.consumerType}:${context.consumerId} at ${context.configPath}`, { code: "binding_missing" }, ); } } if ( Array.isArray(context?.allowedBindingIds) && (!declaration || !context.allowedBindingIds.includes(declaration.id)) ) { throw unprocessable( "User secret declaration is outside the active low-trust boundary", { code: "binding_not_allowed" }, ); } const secret = await getUserSecretValue({ companyId, ownerUserId: responsibleUserId, definitionId: definition.id, }); if (!secret) { if (optionalBinding) return null; throw unprocessable("User secret value is not configured", { code: "user_secret_missing", definitionId: definition.id, responsibleUserId, }); } const resolution = await resolveSecretValueInternal( companyId, secret.id, input.version ?? "latest", { accessContext: context ? { ...context, responsibleUserId } : undefined, allowUserSecretScope: true, }, ); return { ...resolution, manifestEntry: { ...resolution.manifestEntry, bindingId: declaration?.id ?? resolution.manifestEntry.bindingId ?? null, }, }; }, previewRemoteImport: async ( companyId: string, input: { providerConfigId: string; query?: string | null; nextToken?: string | null; pageSize?: number; }, ) => { const { providerConfig, provider: providerId, runtimeConfig } = await getRemoteImportProviderConfig( companyId, input.providerConfigId, ); const provider = getSecretProvider(providerId); if (!provider.listRemoteSecrets) { throw unprocessable(`${providerId} provider does not support remote import listing`); } let listed: RemoteSecretListResult; try { listed = await provider.listRemoteSecrets({ providerConfig: runtimeConfig, query: input.query, nextToken: input.nextToken, pageSize: input.pageSize, }); } catch (error) { throw remoteProviderHttpError(error, { companyId, provider: providerId, providerConfigId: providerConfig.id, operation: "remote_import.preview", }); } const maps = await buildRemoteImportConflictMaps(companyId, providerId); const candidates: RemoteSecretImportCandidate[] = []; for (const remote of listed.secrets) { const externalRef = remote.externalRef.trim(); const remoteName = remote.name.trim() || deriveSecretNameFromExternalRef(externalRef); const name = remoteName || deriveSecretNameFromExternalRef(externalRef); const key = normalizeSecretKey(name); let canonicalExternalRef = externalRef; const conflicts: RemoteSecretImportConflict[] = []; try { const prepared = await provider.linkExternalSecret({ externalRef, providerVersionRef: remote.providerVersionRef ?? null, providerConfig: runtimeConfig, context: { companyId, secretKey: key || "remote-import-preview", secretName: name, version: 1, }, }); canonicalExternalRef = prepared.externalRef ?? externalRef; } catch (error) { conflicts.push({ type: "provider_guardrail", message: remoteImportRowFailureReason(error, "Provider rejected this external reference", { companyId, provider: providerId, providerConfigId: providerConfig.id, operation: "remote_import.preview.link_external_reference", }), }); } conflicts.push(...remoteImportConflictsFor({ providerConfigId: providerConfig.id, externalRef: canonicalExternalRef, name, key, maps, })); const hasDuplicate = conflicts.some((conflict) => conflict.type === "exact_reference"); const hasConflict = conflicts.length > 0; candidates.push({ externalRef, remoteName, name, key, providerVersionRef: remote.providerVersionRef ?? null, providerMetadata: sanitizeRemoteProviderMetadata(providerId, remote.metadata), status: hasDuplicate ? "duplicate" : hasConflict ? "conflict" : "ready", importable: !hasConflict, conflicts, }); } return { providerConfigId: providerConfig.id, provider: providerId, nextToken: listed.nextToken ?? null, candidates, }; }, importRemoteSecrets: async ( companyId: string, input: { providerConfigId: string; secrets: Array<{ externalRef: string; name?: string | null; key?: string | null; description?: string | null; providerVersionRef?: string | null; providerMetadata?: Record | null; }>; }, actor?: { userId?: string | null; agentId?: string | null }, ) => { const { providerConfig, provider: providerId, runtimeConfig } = await getRemoteImportProviderConfig( companyId, input.providerConfigId, ); const provider = getSecretProvider(providerId); if (provider.descriptor().supportsExternalReferences === false) { throw unprocessable(`${providerId} provider does not support linked external references`); } const maps = await buildRemoteImportConflictMaps(companyId, providerId); const results: RemoteSecretImportRowResult[] = []; for (const selection of input.secrets) { const externalRef = selection.externalRef.trim(); const name = selection.name?.trim() || deriveSecretNameFromExternalRef(externalRef); const key = normalizeSecretKey(selection.key?.trim() || name); const description = selection.description?.trim() || null; let prepared: PreparedSecretVersion | undefined; const conflicts = remoteImportConflictsFor({ providerConfigId: providerConfig.id, externalRef, name, key, maps, }); if (!key) { results.push({ externalRef, name, key, status: "error", reason: "Secret key is required", secretId: null, conflicts, }); continue; } if (conflicts.length === 0) { try { prepared = await provider.linkExternalSecret({ externalRef, providerVersionRef: selection.providerVersionRef ?? null, providerConfig: runtimeConfig, context: { companyId, secretKey: key, secretName: name, version: 1, }, }); const canonicalDuplicate = maps.byProviderConfigExternalRef.get( remoteImportExternalRefKey(providerConfig.id, prepared.externalRef ?? externalRef), ); if (canonicalDuplicate) { conflicts.push({ type: "exact_reference", existingSecretId: canonicalDuplicate.id, message: "An existing secret already links this exact provider reference.", }); } } catch (error) { results.push({ externalRef, name, key, status: "error", reason: remoteImportRowFailureReason(error, "Provider rejected this external reference", { companyId, provider: providerId, providerConfigId: providerConfig.id, operation: "remote_import.prepare_external_reference", }), secretId: null, conflicts: [], }); continue; } } if (conflicts.length > 0) { results.push({ externalRef, name, key, status: "skipped", reason: conflicts.some((conflict) => conflict.type === "exact_reference") ? "exact_reference_duplicate" : "name_or_key_conflict", secretId: null, conflicts, }); continue; } try { if (!prepared) { prepared = await provider.linkExternalSecret({ externalRef, providerVersionRef: selection.providerVersionRef ?? null, providerConfig: runtimeConfig, context: { companyId, secretKey: key, secretName: name, version: 1, }, }); } if (!prepared) { throw unprocessable("Provider rejected this external reference"); } const preparedSecret = prepared; const secret = await db.transaction(async (tx) => { const inserted = await tx .insert(companySecrets) .values({ companyId, key, name, provider: providerId, providerConfigId: providerConfig.id, status: "active", managedMode: "external_reference", externalRef: preparedSecret.externalRef, providerMetadata: null, latestVersion: 1, description, lastRotatedAt: new Date(), createdByAgentId: actor?.agentId ?? null, createdByUserId: actor?.userId ?? null, }) .returning() .then((rows) => rows[0]); await tx.insert(companySecretVersions).values({ secretId: inserted.id, version: 1, material: preparedSecret.material, valueSha256: preparedSecret.valueSha256, fingerprintSha256: preparedSecret.fingerprintSha256 ?? preparedSecret.valueSha256, providerVersionRef: preparedSecret.providerVersionRef ?? null, status: "current", createdByAgentId: actor?.agentId ?? null, createdByUserId: actor?.userId ?? null, }); return inserted; }); maps.byProviderConfigExternalRef.set( remoteImportExternalRefKey(providerConfig.id, preparedSecret.externalRef ?? externalRef), secret, ); maps.byName.set(name, secret); maps.byKey.set(key, secret); results.push({ externalRef, name, key, status: "imported", reason: null, secretId: secret.id, conflicts: [], }); } catch (error) { results.push({ externalRef, name, key, status: "error", reason: remoteImportRowFailureReason(error, "Import failed", { companyId, provider: providerId, providerConfigId: providerConfig.id, operation: "remote_import.commit", }), secretId: null, conflicts: [], }); } } return { providerConfigId: providerConfig.id, provider: providerId, importedCount: results.filter((result) => result.status === "imported").length, skippedCount: results.filter((result) => result.status === "skipped").length, errorCount: results.filter((result) => result.status === "error").length, results, }; }, getById, getByName, getByKey, resolveSecretValue, resolveSecretVersion, resolveSecretValueForAgentAccess, listAgentSecretAccess, resolveSecretValueForEphemeralAccess, resolveSecretValueForSandboxCleanup, resolveSecretValueForDeviceLoginCheck, // A plain string value can equal a Codex account-home path regardless of // which provider stores it: an AWS Secrets Manager-backed secret (or any // other provider) resolves to the same literal string a `local_encrypted` // secret would. So every provider's create runs inside // `withAccountHomeSecretMutationLock`, not only `local_encrypted`'s. This // closes the account-home cleanup race for every provider: a create can no // longer commit a value between the cleanup's final claimant scan and its // delete just because it named a provider the old check treated as safe. create: async ( companyId: string, input: { name: string; provider: SecretProvider; providerConfigId?: string | null; value?: string | null; key?: string | null; managedMode?: "paperclip_managed" | "external_reference"; description?: string | null; externalRef?: string | null; providerVersionRef?: string | null; providerMetadata?: Record | null; }, actor?: { userId?: string | null; agentId?: string | null }, ) => { return withAccountHomeSecretMutationLock(undefined, companyId, async () => { // A queued create can win the lock after an account-home cleanup // already removed the directory this value names. Check the // directory's existence inside the same lock, right before the // write, so that order fails instead of committing a dangling path. // `assertAccountHomeCacheDirStillValid` is a no-op for a value that is // not under the account-home cache root, so this never rejects an // unrelated secret write. if (input.value) { await assertAccountHomeCacheDirStillValid(undefined, companyId, input.value); } return createSecretUnlocked(companyId, input, actor); }); }, // Same reasoning as `create:` above: a rotate's new value can equal a // Codex account-home path under any provider, so every provider's rotate // runs inside `withAccountHomeSecretMutationLock`, not only // `local_encrypted`'s. The pre-check reads the secret only to find its // company for the lock; `rotateUnlocked` re-reads the secret under the // lock, so a rotation that lost a race between the pre-check and the lock // still sees current state, not the pre-check's stale read. rotate: async ( secretId: string, input: { value?: string | null; externalRef?: string | null; providerVersionRef?: string | null; providerConfigId?: string | null; expectedLatestVersion?: number; /** Internal authenticated refresh write-back only; never accepted by HTTP schemas. */ preserveAiSessionEpoch?: boolean; }, actor?: { userId?: string | null; agentId?: string | null }, ) => { const preCheckSecret = await getById(secretId); if (!preCheckSecret) throw notFound("Secret not found"); return withAccountHomeSecretMutationLock(undefined, preCheckSecret.companyId, async () => { // Same reasoning as `create:` above: check the new value's directory // inside the same lock the write commits under, so a rotate queued // behind an account-home cleanup cannot commit a directory the // cleanup already removed. if (input.value) { await assertAccountHomeCacheDirStillValid(undefined, preCheckSecret.companyId, input.value); } return rotateUnlocked(secretId, input, actor); }); }, // A patch can rename a secret (`patch.name`) or move it out of the // `active` status (archive, disable, or a soft delete for an // `external_reference` secret) — any of which can stop the generated // account-home secret from resolving to the value a device-login // promotion already validated. Run every update inside // `withAccountHomeSecretMutationLock`, the same lock the promotion's // terminal-commit re-check holds across its own resolve-and-commit // section, so a rename, an archive, a disable, or a soft delete can never // land in the gap between that re-check and the commit it guards: it // either finishes (and the re-check observes it and rejects) before that // section acquires the lock, or it waits for that section to finish // first. update: async ( secretId: string, patch: { name?: string; key?: string; status?: "active" | "disabled" | "archived" | "deleted"; providerConfigId?: string | null; description?: string | null; externalRef?: string | null; providerMetadata?: Record | null; }, ) => { const preCheckSecret = await getById(secretId); if (!preCheckSecret) throw notFound("Secret not found"); return withAccountHomeSecretMutationLock(undefined, preCheckSecret.companyId, () => updateUnlocked(secretId, patch), ); }, createBinding: async (input: { companyId: string; secretId: string; targetType: SecretBindingTargetType; targetId: string; configPath: string; versionSelector?: SecretVersionSelector; required?: boolean; label?: string | null; projectionClass?: SecretProjectionClass; projectionAllowlistKey?: string | null; }) => { await assertSecretInCompany(input.companyId, input.secretId); assertSecretBindingConfigPath(input); assertClass3StaticLeaseAllowed({ targetType: input.targetType, configPath: input.configPath, projectionClass: input.projectionClass, projectionAllowlistKey: input.projectionAllowlistKey, }); const existing = await db .select() .from(companySecretBindings) .where( and( eq(companySecretBindings.companyId, input.companyId), eq(companySecretBindings.targetType, input.targetType), eq(companySecretBindings.targetId, input.targetId), eq(companySecretBindings.configPath, input.configPath), ), ) .then((rows) => rows[0] ?? null); if (existing) throw conflict(`Secret binding already exists at ${input.configPath}`); return db .insert(companySecretBindings) .values({ companyId: input.companyId, secretId: input.secretId, targetType: input.targetType, targetId: input.targetId, configPath: input.configPath, versionSelector: String(input.versionSelector ?? "latest"), required: input.required ?? true, label: input.label ?? null, projectionClass: input.projectionClass ?? "unclassified", projectionAllowlistKey: input.projectionAllowlistKey ?? null, }) .returning() .then((rows) => rows[0]); }, syncSecretRefsForTarget: async ( companyId: string, target: { targetType: SecretBindingTargetType; targetId: string }, refs: Array<{ secretId: string; configPath: string; versionSelector?: SecretVersionSelector; required?: boolean; label?: string | null; projectionClass?: SecretProjectionClass; projectionAllowlistKey?: string | null; }>, options?: { replaceAll?: boolean }, ) => { const normalizedRefs: Array<{ secretId: string; configPath: string; versionSelector: SecretVersionSelector; required: boolean; label: string | null; projectionClass: SecretProjectionClass; projectionAllowlistKey: string | null; }> = []; for (const ref of refs) { await assertSecretInCompany(companyId, ref.secretId); assertSecretBindingConfigPath({ targetType: target.targetType, configPath: ref.configPath }); const projectionClass = ref.projectionClass ?? "unclassified"; const projectionAllowlistKey = ref.projectionAllowlistKey ?? null; assertClass3StaticLeaseAllowed({ targetType: target.targetType, configPath: ref.configPath, projectionClass, projectionAllowlistKey, }); normalizedRefs.push({ secretId: ref.secretId, configPath: ref.configPath, versionSelector: ref.versionSelector ?? "latest", required: ref.required ?? true, label: ref.label ?? null, projectionClass, projectionAllowlistKey, }); } const pathPrefixes = [...new Set(normalizedRefs.map((ref) => ref.configPath.split(".")[0]))]; await db.transaction(async (tx) => { if (options?.replaceAll) { await tx .delete(companySecretBindings) .where( and( eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.targetType, target.targetType), eq(companySecretBindings.targetId, target.targetId), ), ); } else if (pathPrefixes.length > 0) { for (const pathPrefix of pathPrefixes) { await tx .delete(companySecretBindings) .where( and( eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.targetType, target.targetType), eq(companySecretBindings.targetId, target.targetId), or( eq(companySecretBindings.configPath, pathPrefix), like(companySecretBindings.configPath, `${pathPrefix}.%`), ), ), ); } } else { await tx .delete(companySecretBindings) .where( and( eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.targetType, target.targetType), eq(companySecretBindings.targetId, target.targetId), ), ); } if (normalizedRefs.length === 0) return; await tx.insert(companySecretBindings).values( normalizedRefs.map((ref) => ({ companyId, secretId: ref.secretId, targetType: target.targetType, targetId: target.targetId, configPath: ref.configPath, versionSelector: String(ref.versionSelector), required: ref.required, label: ref.label, projectionClass: ref.projectionClass, projectionAllowlistKey: ref.projectionAllowlistKey, })), ); }); return normalizedRefs; }, /** * Replace the config-derived secret bindings of an instance-scoped target * (an environment). Instance-scoped targets are shared across companies, * so each binding is written under the company that owns the referenced * secret rather than a single caller-supplied company context — a * re-point to a secret owned by another company moves the binding with * it. All non-`env.*` bindings of the target are replaced across every * company (env-var bindings stay company-scoped and are managed by * `syncEnvBindingsForTarget`). * * Every referenced secret is loaded and validated before any row is * written, and the delete + insert run on one executor, so an invalid * ref (deleted or unknown secret) fails the whole call without leaving * the target half-bound. */ replaceSecretRefsForInstanceTarget: async ( target: { targetType: SecretBindingTargetType; targetId: string }, refs: Array<{ secretId: string; configPath: string; versionSelector?: SecretVersionSelector; required?: boolean; label?: string | null; projectionClass?: SecretProjectionClass; projectionAllowlistKey?: string | null; }>, options?: { db?: SecretBindingDb }, ) => { const normalizedRefs: Array<{ companyId: string; secretId: string; configPath: string; versionSelector: SecretVersionSelector; required: boolean; label: string | null; projectionClass: SecretProjectionClass; projectionAllowlistKey: string | null; }> = []; const readDb = options?.db ?? db; for (const ref of refs) { const secret = await getById(ref.secretId, readDb); if (!secret || secret.status === "deleted") { throw unprocessable( `Secret referenced at ${ref.configPath} was not found`, { code: "secret_missing", configPath: ref.configPath }, ); } assertSecretBindingConfigPath({ targetType: target.targetType, configPath: ref.configPath }); const projectionClass = ref.projectionClass ?? "unclassified"; const projectionAllowlistKey = ref.projectionAllowlistKey ?? null; assertClass3StaticLeaseAllowed({ targetType: target.targetType, configPath: ref.configPath, projectionClass, projectionAllowlistKey, }); normalizedRefs.push({ companyId: secret.companyId, secretId: ref.secretId, configPath: ref.configPath, versionSelector: ref.versionSelector ?? "latest", required: ref.required ?? true, label: ref.label ?? null, projectionClass, projectionAllowlistKey, }); } const writeBindings = async (executor: SecretBindingDb) => { await executor .delete(companySecretBindings) .where( and( eq(companySecretBindings.targetType, target.targetType), eq(companySecretBindings.targetId, target.targetId), notLike(companySecretBindings.configPath, "env.%"), ), ); if (normalizedRefs.length === 0) return; await executor.insert(companySecretBindings).values( normalizedRefs.map((ref) => ({ companyId: ref.companyId, secretId: ref.secretId, targetType: target.targetType, targetId: target.targetId, configPath: ref.configPath, versionSelector: String(ref.versionSelector), required: ref.required, label: ref.label, projectionClass: ref.projectionClass, projectionAllowlistKey: ref.projectionAllowlistKey, })), ); }; if (options?.db) { await writeBindings(options.db); } else { await db.transaction(async (tx) => { await writeBindings(tx); }); } return normalizedRefs; }, /** * Describe secret refs (id + config path) with the referenced secret's * name, status, and owning company. Environments are instance-scoped * while secrets are company-scoped, so an environment can legitimately * reference a secret a given company's picker cannot list; this gives * instance-level readers enough metadata to present such refs honestly. * Returns names across companies — callers must sit behind an * instance-level authorization gate. Never returns secret values. */ describeSecretRefs: async ( refs: Array<{ secretId: string; configPath: string }>, ): Promise> => { if (refs.length === 0) return []; const secretIds = [...new Set(refs.map((ref) => ref.secretId))]; const secretRows = await db .select() .from(companySecrets) .where(inArray(companySecrets.id, secretIds)); const secretsById = new Map(secretRows.map((row) => [row.id, row])); const companyIds = [...new Set(secretRows.map((row) => row.companyId))]; const companyRows = companyIds.length > 0 ? await db.select().from(companies).where(inArray(companies.id, companyIds)) : []; const companyNamesById = new Map(companyRows.map((row) => [row.id, row.name])); return refs.flatMap((ref) => { const secret = secretsById.get(ref.secretId); if (!secret) return []; return [{ configPath: ref.configPath, secretId: secret.id, name: secret.name, status: secret.status, companyId: secret.companyId, companyName: companyNamesById.get(secret.companyId) ?? null, }]; }); }, listBindingCompanyIdsForTarget: async ( target: { targetType: SecretBindingTargetType; targetId: string }, ): Promise => db .select({ companyId: companySecretBindings.companyId }) .from(companySecretBindings) .where( and( eq(companySecretBindings.targetType, target.targetType), eq(companySecretBindings.targetId, target.targetId), ), ) .then((rows) => [...new Set(rows.map((row) => row.companyId))]), syncEnvBindingsForTarget: async ( companyId: string, target: { targetType: SecretBindingTargetType; targetId: string; pathPrefix?: string }, envValue: unknown, options?: { db?: SecretBindingDb }, ) => { const record = asRecord(envValue) ?? {}; const refs: Array<{ secretId: string; configPath: string; versionSelector: SecretVersionSelector; projectionClass: SecretProjectionClass; projectionAllowlistKey: string | null; }> = []; const userRefs: Array<{ definitionKey: string; configPath: string; envKey: string; versionSelector: SecretVersionSelector; required: boolean; allowMissingOverride: boolean; }> = []; const pathPrefix = target.pathPrefix ?? "env"; const bindingDb = options?.db ?? db; for (const [key, rawBinding] of Object.entries(record)) { const parsed = envBindingSchema.safeParse(rawBinding); if (!parsed.success) continue; const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type === "user_secret_ref") { await resolveUserSecretDefinition(companyId, { definitionKey: binding.key }, bindingDb, { envKey: key, configPath: `${pathPrefix}.${key}`, consumerType: target.targetType, consumerId: target.targetId, }); userRefs.push({ definitionKey: binding.key, configPath: `${pathPrefix}.${key}`, envKey: key, versionSelector: binding.version, required: binding.required, allowMissingOverride: binding.allowMissingOverride, }); continue; } if (binding.type !== "secret_ref") continue; await assertSecretInCompany(companyId, binding.secretId, bindingDb); const configPath = `${pathPrefix}.${key}`; assertClass3StaticLeaseAllowed({ targetType: target.targetType, configPath, projectionClass: binding.projectionClass, projectionAllowlistKey: binding.projectionAllowlistKey, }); refs.push({ secretId: binding.secretId, configPath, versionSelector: binding.version, projectionClass: binding.projectionClass, projectionAllowlistKey: binding.projectionAllowlistKey, }); } const writeBindings = async (targetDb: SecretBindingDb) => { await targetDb .delete(companySecretBindings) .where( and( eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.targetType, target.targetType), eq(companySecretBindings.targetId, target.targetId), like(companySecretBindings.configPath, `${pathPrefix}.%`), ), ); if (refs.length === 0) return; await targetDb.insert(companySecretBindings).values( refs.map((ref) => ({ companyId, secretId: ref.secretId, targetType: target.targetType, targetId: target.targetId, configPath: ref.configPath, versionSelector: String(ref.versionSelector), required: true, projectionClass: ref.projectionClass, projectionAllowlistKey: ref.projectionAllowlistKey, })), ); }; const writeUserDeclarations = async (targetDb: SecretBindingDb) => { await targetDb .delete(userSecretDeclarations) .where( and( eq(userSecretDeclarations.companyId, companyId), eq(userSecretDeclarations.targetType, target.targetType), eq(userSecretDeclarations.targetId, target.targetId), like(userSecretDeclarations.configPath, `${pathPrefix}.%`), ), ); if (userRefs.length === 0) return; const definitions = new Map(); for (const ref of userRefs) { const definition = await resolveUserSecretDefinition( companyId, { definitionKey: ref.definitionKey }, targetDb, { envKey: ref.envKey, configPath: ref.configPath, consumerType: target.targetType, consumerId: target.targetId, }, ); definitions.set(ref.definitionKey, definition.id); } await targetDb.insert(userSecretDeclarations).values( userRefs.map((ref) => ({ companyId, userSecretDefinitionId: definitions.get(ref.definitionKey)!, targetType: target.targetType, targetId: target.targetId, configPath: ref.configPath, envKey: ref.envKey, versionSelector: String(ref.versionSelector), required: ref.required, allowMissingOverride: ref.allowMissingOverride, })), ); }; if (options?.db) { await writeBindings(options.db); await writeUserDeclarations(options.db); } else { await db.transaction(async (tx) => { await writeBindings(tx); await writeUserDeclarations(tx); }); } return refs; }, remove: removeSecretInternal, normalizeAdapterConfigForPersistence: async ( companyId: string, adapterConfig: Record, opts?: NormalizeAdapterConfigOptions, ) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts), normalizeEnvBindingsForPersistence: async ( companyId: string, envValue: unknown, opts?: NormalizeEnvOptions, ) => normalizeEnvConfig(companyId, envValue, opts), normalizeHireApprovalPayloadForPersistence: async ( companyId: string, payload: Record, opts?: NormalizeAdapterConfigOptions, ) => { const normalized = { ...payload }; const adapterConfig = asRecord(payload.adapterConfig); if (adapterConfig) { normalized.adapterConfig = await normalizeAdapterConfigForPersistenceInternal( companyId, adapterConfig, opts, ); } return normalized; }, resolveEnvBindings: async ( companyId: string, envValue: unknown, context?: Omit, ): Promise<{ env: Record; secretKeys: Set; manifest: RuntimeSecretManifestEntry[] }> => { const record = asRecord(envValue); if (!record) return { env: {} as Record, secretKeys: new Set(), manifest: [] }; const resolved: Record = {}; const secretKeys = new Set(); const manifest: RuntimeSecretManifestEntry[] = []; for (const [key, rawBinding] of Object.entries(record)) { if (!ENV_KEY_RE.test(key)) { throw unprocessable(`Invalid environment variable name: ${key}`); } const parsed = envBindingSchema.safeParse(rawBinding); if (!parsed.success) { throw unprocessable(`Invalid environment binding for key: ${key}`); } const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type === "plain") { resolved[key] = binding.value; } else if (binding.type === "secret_ref") { const secretResolution = await resolveSecretValueInternal( companyId, binding.secretId, binding.version, context ? { bindingContext: { ...context, configPath: `env.${key}` }, accessContext: { ...context, configPath: `env.${key}` }, } : undefined, ); resolved[key] = secretResolution.value; manifest.push(secretResolution.manifestEntry); secretKeys.add(key); } else { const secretResolution = await secretService(db).resolveUserSecretValue( companyId, { definitionKey: binding.key, version: binding.version, required: binding.required, allowMissingOverride: binding.allowMissingOverride, }, context ? { ...context, configPath: `env.${key}`, responsibleUserId: context.responsibleUserId ?? null, } : undefined, ); if (secretResolution) { resolved[key] = secretResolution.value; manifest.push(secretResolution.manifestEntry); secretKeys.add(key); } } } return { env: resolved, secretKeys, manifest }; }, // Pre-dispatch validation: list declared secret refs in an env-like config // that have no binding for the given consumer, WITHOUT resolving any secret // values. Callers use this to surface a configuration-incomplete blocker // before a run is dispatched instead of letting resolution throw mid-setup. collectMissingRuntimeBindings: async ( companyId: string, envValue: unknown, context: Omit, ): Promise => { const record = asRecord(envValue); if (!record) return []; const secretRefs = Object.entries(record).flatMap(([key, rawBinding]) => { if (!ENV_KEY_RE.test(key)) return []; const parsed = envBindingSchema.safeParse(rawBinding); if (!parsed.success) return []; const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type !== "secret_ref") return []; return [{ key, configPath: `env.${key}`, secretId: binding.secretId }]; }); const userSecretRefs = Object.entries(record).flatMap(([key, rawBinding]) => { if (!ENV_KEY_RE.test(key)) return []; const parsed = envBindingSchema.safeParse(rawBinding); if (!parsed.success) return []; const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type !== "user_secret_ref") return []; if (!binding.required || binding.allowMissingOverride) return []; return [{ key, configPath: `env.${key}`, binding }]; }); if (secretRefs.length === 0 && userSecretRefs.length === 0) return []; const bindingChecks = await Promise.all(secretRefs.map(async (entry) => ({ entry, found: await getBinding({ companyId, secretId: entry.secretId, consumerType: context.consumerType, consumerId: context.consumerId, configPath: entry.configPath, }), }))); const missingEntries = bindingChecks .filter((check) => !check.found) .map((check) => check.entry); const secretRows = await Promise.all( [...new Set(missingEntries.map((entry) => entry.secretId))].map(async (secretId) => [ secretId, await getById(secretId).catch(() => null), ] as const), ); const secretsById = new Map(secretRows); const missingSecretBindings: MissingRuntimeBinding[] = missingEntries.map((entry) => ({ consumerType: context.consumerType, consumerId: context.consumerId, configPath: entry.configPath, envKey: entry.key, bindingType: "secret_ref", secretId: entry.secretId, secretName: secretsById.get(entry.secretId)?.name ?? null, })); const missingUserSecretBindings: MissingRuntimeBinding[] = []; for (const entry of userSecretRefs) { let definition: typeof userSecretDefinitions.$inferSelect | null = null; try { definition = await resolveUserSecretDefinition(companyId, { definitionKey: entry.binding.key }); } catch { missingUserSecretBindings.push( missingUserSecretDefinitionRuntimeBinding( entry, context, null, "user_secret_definition_missing", ), ); continue; } if (definition.status !== "active") { missingUserSecretBindings.push( missingUserSecretDefinitionRuntimeBinding( entry, context, definition, "user_secret_definition_inactive", ), ); continue; } const declaration = await db .select() .from(userSecretDeclarations) .where(and( eq(userSecretDeclarations.companyId, companyId), eq(userSecretDeclarations.userSecretDefinitionId, definition.id), eq(userSecretDeclarations.targetType, context.consumerType), eq(userSecretDeclarations.targetId, context.consumerId), eq(userSecretDeclarations.configPath, entry.configPath), )) .then((rows) => rows[0] ?? null); if (!declaration) { missingUserSecretBindings.push({ consumerType: context.consumerType, consumerId: context.consumerId, configPath: entry.configPath, envKey: entry.key, bindingType: "user_secret_ref", secretId: null, secretName: null, userSecretDefinitionId: definition.id, userSecretDefinitionKey: definition.key, userSecretDefinitionName: definition.name, responsibleUserId: context.responsibleUserId ?? null, errorCode: "binding_missing", }); continue; } if (!context.responsibleUserId?.trim()) { missingUserSecretBindings.push({ consumerType: context.consumerType, consumerId: context.consumerId, configPath: entry.configPath, envKey: entry.key, bindingType: "user_secret_ref", secretId: null, secretName: null, userSecretDefinitionId: definition.id, userSecretDefinitionKey: definition.key, userSecretDefinitionName: definition.name, responsibleUserId: null, errorCode: "responsible_user_missing", }); continue; } const secret = await getUserSecretValue({ companyId, ownerUserId: context.responsibleUserId, definitionId: definition.id, }); if (!secret || secret.status !== "active") { missingUserSecretBindings.push({ consumerType: context.consumerType, consumerId: context.consumerId, configPath: entry.configPath, envKey: entry.key, bindingType: "user_secret_ref", secretId: secret?.id ?? null, secretName: null, userSecretDefinitionId: definition.id, userSecretDefinitionKey: definition.key, userSecretDefinitionName: definition.name, responsibleUserId: context.responsibleUserId, errorCode: secret ? "secret_inactive" : "user_secret_missing", }); } } return [...missingSecretBindings, ...missingUserSecretBindings]; }, collectMissingAdapterConfigRuntimeBindings: async ( companyId: string, adapterConfig: Record, adapterType: string | null | undefined, context: Omit, ): Promise => { const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(adapterType); const secretRefs = secretFieldKeys.flatMap((key) => { const parsed = envBindingSchema.safeParse(adapterConfig[key]); if (!parsed.success) return []; const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type !== "secret_ref") return []; return [{ key, configPath: key, secretId: binding.secretId }]; }); const userSecretRefs = secretFieldKeys.flatMap((key) => { const parsed = envBindingSchema.safeParse(adapterConfig[key]); if (!parsed.success) return []; const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type !== "user_secret_ref") return []; if (!binding.required || binding.allowMissingOverride) return []; return [{ key, configPath: key, binding }]; }); if (secretRefs.length === 0 && userSecretRefs.length === 0) return []; const bindingChecks = await Promise.all(secretRefs.map(async (entry) => ({ entry, found: await getBinding({ companyId, secretId: entry.secretId, consumerType: context.consumerType, consumerId: context.consumerId, configPath: entry.configPath, }), }))); const missingEntries = bindingChecks .filter((check) => !check.found) .map((check) => check.entry); const secretRows = await Promise.all( [...new Set(missingEntries.map((entry) => entry.secretId))].map(async (secretId) => [ secretId, await getById(secretId).catch(() => null), ] as const), ); const secretsById = new Map(secretRows); const missingSecretBindings: MissingRuntimeBinding[] = missingEntries.map((entry) => ({ consumerType: context.consumerType, consumerId: context.consumerId, configPath: entry.configPath, envKey: entry.key, bindingType: "secret_ref", secretId: entry.secretId, secretName: secretsById.get(entry.secretId)?.name ?? null, })); const missingUserSecretBindings: MissingRuntimeBinding[] = []; for (const entry of userSecretRefs) { let definition: typeof userSecretDefinitions.$inferSelect | null = null; try { definition = await resolveUserSecretDefinition(companyId, { definitionKey: entry.binding.key }); } catch { missingUserSecretBindings.push( missingUserSecretDefinitionRuntimeBinding( entry, context, null, "user_secret_definition_missing", ), ); continue; } if (definition.status !== "active") { missingUserSecretBindings.push( missingUserSecretDefinitionRuntimeBinding( entry, context, definition, "user_secret_definition_inactive", ), ); continue; } const declaration = await db .select() .from(userSecretDeclarations) .where(and( eq(userSecretDeclarations.companyId, companyId), eq(userSecretDeclarations.userSecretDefinitionId, definition.id), eq(userSecretDeclarations.targetType, context.consumerType), eq(userSecretDeclarations.targetId, context.consumerId), eq(userSecretDeclarations.configPath, entry.configPath), )) .then((rows) => rows[0] ?? null); if (!declaration) { missingUserSecretBindings.push({ consumerType: context.consumerType, consumerId: context.consumerId, configPath: entry.configPath, envKey: entry.key, bindingType: "user_secret_ref", secretId: null, secretName: null, userSecretDefinitionId: definition.id, userSecretDefinitionKey: definition.key, userSecretDefinitionName: definition.name, responsibleUserId: context.responsibleUserId ?? null, errorCode: "binding_missing", }); continue; } if (!context.responsibleUserId?.trim()) { missingUserSecretBindings.push({ consumerType: context.consumerType, consumerId: context.consumerId, configPath: entry.configPath, envKey: entry.key, bindingType: "user_secret_ref", secretId: null, secretName: null, userSecretDefinitionId: definition.id, userSecretDefinitionKey: definition.key, userSecretDefinitionName: definition.name, responsibleUserId: null, errorCode: "responsible_user_missing", }); continue; } const secret = await getUserSecretValue({ companyId, ownerUserId: context.responsibleUserId, definitionId: definition.id, }); if (!secret || secret.status !== "active") { missingUserSecretBindings.push({ consumerType: context.consumerType, consumerId: context.consumerId, configPath: entry.configPath, envKey: entry.key, bindingType: "user_secret_ref", secretId: secret?.id ?? null, secretName: null, userSecretDefinitionId: definition.id, userSecretDefinitionKey: definition.key, userSecretDefinitionName: definition.name, responsibleUserId: context.responsibleUserId, errorCode: secret ? "secret_inactive" : "user_secret_missing", }); } } return [...missingSecretBindings, ...missingUserSecretBindings]; }, resolveAdapterConfigForRuntime: async ( companyId: string, adapterConfig: Record, context?: Omit, opts?: ResolveAdapterConfigForRuntimeOptions, ): Promise<{ config: Record; secretKeys: Set; manifest: RuntimeSecretManifestEntry[] }> => { const ownerScoped = opts?.userSecretMediation === "owner_scoped"; // Fail closed: owner_scoped skips declaration mediation, so an // allowedBindingIds allowlist has no declaration to enforce against. // Rejecting (rather than silently stripping) prevents a future low-trust // owner_scoped caller from bypassing an allowlist by choosing this mode. // Any supplied array — including an empty one, which requests "allow // nothing" — is rejected: owner_scoped cannot honor either intent, and // letting `[]` slip through would resolve every owner secret, the exact // opposite of what an empty allowlist asks for. if (ownerScoped && Array.isArray(context?.allowedBindingIds)) { throw unprocessable( "allowedBindingIds is not supported with owner_scoped user-secret mediation", { code: "owner_scoped_allowed_bindings_unsupported" }, ); } const resolved = { ...adapterConfig }; const secretKeys = new Set(); const manifest: RuntimeSecretManifestEntry[] = []; if (Object.prototype.hasOwnProperty.call(adapterConfig, "env")) { const record = asRecord(adapterConfig.env); if (!record) { resolved.env = {}; } else { const env: Record = {}; for (const [key, rawBinding] of Object.entries(record)) { if (!ENV_KEY_RE.test(key)) { throw unprocessable(`Invalid environment variable name: ${key}`); } const parsed = envBindingSchema.safeParse(rawBinding); if (!parsed.success) { throw unprocessable(`Invalid environment binding for key: ${key}`); } const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type === "plain") { env[key] = binding.value; } else if (binding.type === "secret_ref") { const secretResolution = await resolveSecretValueInternal( companyId, binding.secretId, binding.version, context ? ownerScoped ? { // owner_scoped: omit bindingContext so assertBindingContext // returns null (no binding enforcement) — preserves today's // undefined-context behavior for a prospective config — // while still carrying the actor via accessContext for audit. accessContext: { ...context, configPath: `env.${key}` }, } : { bindingContext: { ...context, configPath: `env.${key}` }, accessContext: { ...context, configPath: `env.${key}` }, } : undefined, ); env[key] = secretResolution.value; manifest.push(secretResolution.manifestEntry); secretKeys.add(key); } else { if (opts?.skipUserSecrets) continue; const secretResolution = await secretService(db).resolveUserSecretValue( companyId, { definitionKey: binding.key, version: binding.version, required: binding.required, allowMissingOverride: binding.allowMissingOverride, }, context ? ownerScoped ? { // owner_scoped: omit configPath so resolveUserSecretValue's // `if (context?.configPath)` declaration guard stays false — // resolution proceeds by definition + owner boundary, with no // declaration row required for a prospective config. ...context, responsibleUserId: context.responsibleUserId ?? null, } : { ...context, configPath: `env.${key}`, responsibleUserId: context.responsibleUserId ?? null, } : undefined, ); if (secretResolution) { env[key] = secretResolution.value; manifest.push(secretResolution.manifestEntry); secretKeys.add(key); } } } resolved.env = env; } } const secretFieldKeys = await listAdapterSchemaSecretFieldKeys(opts?.adapterType); for (const key of secretFieldKeys) { const parsed = envBindingSchema.safeParse(adapterConfig[key]); if (!parsed.success) continue; const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type === "plain") continue; if (binding.type === "user_secret_ref") { if (opts?.skipUserSecrets) { delete resolved[key]; continue; } const secretResolution = await secretService(db).resolveUserSecretValue( companyId, { definitionKey: binding.key, version: binding.version, required: binding.required, allowMissingOverride: binding.allowMissingOverride, }, context ? ownerScoped ? { // owner_scoped: omit configPath so the declaration guard stays // false — resolve by definition + owner boundary. ...context, responsibleUserId: context.responsibleUserId ?? null, } : { ...context, configPath: key, responsibleUserId: context.responsibleUserId ?? null, } : undefined, ); if (secretResolution) { resolved[key] = secretResolution.value; manifest.push(secretResolution.manifestEntry); secretKeys.add(key); } continue; } const secretResolution = await resolveSecretValueInternal( companyId, binding.secretId, binding.version, context ? ownerScoped ? { // owner_scoped: omit bindingContext (no binding enforcement), // carry the actor via accessContext for audit only. accessContext: { ...context, configPath: key }, } : { bindingContext: { ...context, configPath: key }, accessContext: { ...context, configPath: key }, } : undefined, ); resolved[key] = secretResolution.value; manifest.push(secretResolution.manifestEntry); secretKeys.add(key); } return { config: resolved, secretKeys, manifest }; }, }; }