Add secrets infrastructure: DB tables, shared types, env binding model, and migration improvements

Introduce company_secrets and company_secret_versions tables for
encrypted secret storage. Add EnvBinding discriminated union (plain vs
secret_ref) to replace raw string env values in adapter configs. Add
hiddenAt column to issues for soft-hiding. Improve migration system
with journal-ordered application and manual fallback when Drizzle
migrator can't reconcile history.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-19 15:43:43 -06:00
parent 3b81557f7c
commit d26b67ebc3
23 changed files with 7348 additions and 14 deletions

View File

@@ -8,6 +8,7 @@ export const API = {
issues: `${API_PREFIX}/issues`,
goals: `${API_PREFIX}/goals`,
approvals: `${API_PREFIX}/approvals`,
secrets: `${API_PREFIX}/secrets`,
costs: `${API_PREFIX}/costs`,
activity: `${API_PREFIX}/activity`,
dashboard: `${API_PREFIX}/dashboard`,

View File

@@ -1,4 +1,5 @@
import { z } from "zod";
import { SECRET_PROVIDERS } from "./constants.js";
export const configMetaSchema = z.object({
version: z.literal(1),
@@ -28,12 +29,31 @@ export const serverConfigSchema = z.object({
serveUi: z.boolean().default(true),
});
export const secretsLocalEncryptedConfigSchema = z.object({
keyFilePath: z.string().default("./data/secrets/master.key"),
});
export const secretsConfigSchema = z.object({
provider: z.enum(SECRET_PROVIDERS).default("local_encrypted"),
strictMode: z.boolean().default(false),
localEncrypted: secretsLocalEncryptedConfigSchema.default({
keyFilePath: "./data/secrets/master.key",
}),
});
export const paperclipConfigSchema = z.object({
$meta: configMetaSchema,
llm: llmConfigSchema.optional(),
database: databaseConfigSchema,
logging: loggingConfigSchema,
server: serverConfigSchema,
secrets: secretsConfigSchema.default({
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: "./data/secrets/master.key",
},
}),
});
export type PaperclipConfig = z.infer<typeof paperclipConfigSchema>;
@@ -41,4 +61,6 @@ export type LlmConfig = z.infer<typeof llmConfigSchema>;
export type DatabaseConfig = z.infer<typeof databaseConfigSchema>;
export type LoggingConfig = z.infer<typeof loggingConfigSchema>;
export type ServerConfig = z.infer<typeof serverConfigSchema>;
export type SecretsConfig = z.infer<typeof secretsConfigSchema>;
export type SecretsLocalEncryptedConfig = z.infer<typeof secretsLocalEncryptedConfigSchema>;
export type ConfigMeta = z.infer<typeof configMetaSchema>;

View File

@@ -71,6 +71,14 @@ export const APPROVAL_STATUSES = [
] as const;
export type ApprovalStatus = (typeof APPROVAL_STATUSES)[number];
export const SECRET_PROVIDERS = [
"local_encrypted",
"aws_secrets_manager",
"gcp_secret_manager",
"vault",
] as const;
export type SecretProvider = (typeof SECRET_PROVIDERS)[number];
export const HEARTBEAT_INVOCATION_SOURCES = [
"timer",
"assignment",

View File

@@ -10,6 +10,7 @@ export {
PROJECT_STATUSES,
APPROVAL_TYPES,
APPROVAL_STATUSES,
SECRET_PROVIDERS,
HEARTBEAT_INVOCATION_SOURCES,
HEARTBEAT_RUN_STATUSES,
WAKEUP_TRIGGER_DETAILS,
@@ -26,6 +27,7 @@ export {
type ProjectStatus,
type ApprovalType,
type ApprovalStatus,
type SecretProvider,
type HeartbeatInvocationSource,
type HeartbeatRunStatus,
type WakeupTriggerDetail,
@@ -57,6 +59,10 @@ export type {
DashboardSummary,
ActivityEvent,
SidebarBadges,
EnvBinding,
AgentEnvConfig,
CompanySecret,
SecretProviderDescriptor,
} from "./types/index.js";
export {
@@ -107,6 +113,16 @@ export {
type RequestApprovalRevision,
type ResubmitApproval,
type AddApprovalComment,
envBindingPlainSchema,
envBindingSecretRefSchema,
envBindingSchema,
envConfigSchema,
createSecretSchema,
rotateSecretSchema,
updateSecretSchema,
type CreateSecret,
type RotateSecret,
type UpdateSecret,
createCostEventSchema,
updateBudgetSchema,
type CreateCostEvent,
@@ -122,10 +138,14 @@ export {
databaseConfigSchema,
loggingConfigSchema,
serverConfigSchema,
secretsConfigSchema,
secretsLocalEncryptedConfigSchema,
type PaperclipConfig,
type LlmConfig,
type DatabaseConfig,
type LoggingConfig,
type ServerConfig,
type SecretsConfig,
type SecretsLocalEncryptedConfig,
type ConfigMeta,
} from "./config-schema.js";

View File

@@ -4,6 +4,16 @@ export type { Project } from "./project.js";
export type { Issue, IssueComment, IssueAncestor } from "./issue.js";
export type { Goal } from "./goal.js";
export type { Approval, ApprovalComment } from "./approval.js";
export type {
SecretProvider,
SecretVersionSelector,
EnvPlainBinding,
EnvSecretRefBinding,
EnvBinding,
AgentEnvConfig,
CompanySecret,
SecretProviderDescriptor,
} from "./secrets.js";
export type { CostEvent, CostSummary, CostByAgent } from "./cost.js";
export type {
HeartbeatRun,

View File

@@ -32,6 +32,7 @@ export interface Issue {
startedAt: Date | null;
completedAt: Date | null;
cancelledAt: Date | null;
hiddenAt: Date | null;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -0,0 +1,43 @@
export type SecretProvider =
| "local_encrypted"
| "aws_secrets_manager"
| "gcp_secret_manager"
| "vault";
export type SecretVersionSelector = number | "latest";
export interface EnvPlainBinding {
type: "plain";
value: string;
}
export interface EnvSecretRefBinding {
type: "secret_ref";
secretId: string;
version?: SecretVersionSelector;
}
// Backward-compatible: legacy plaintext string values are still accepted.
export type EnvBinding = string | EnvPlainBinding | EnvSecretRefBinding;
export type AgentEnvConfig = Record<string, EnvBinding>;
export interface CompanySecret {
id: string;
companyId: string;
name: string;
provider: SecretProvider;
externalRef: string | null;
latestVersion: number;
description: string | null;
createdByAgentId: string | null;
createdByUserId: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface SecretProviderDescriptor {
id: SecretProvider;
label: string;
requiresExternalRef: boolean;
}

View File

@@ -4,11 +4,25 @@ import {
AGENT_ROLES,
AGENT_STATUSES,
} from "../constants.js";
import { envConfigSchema } from "./secret.js";
export const agentPermissionsSchema = z.object({
canCreateAgents: z.boolean().optional().default(false),
});
const adapterConfigSchema = z.record(z.unknown()).superRefine((value, ctx) => {
const envValue = value.env;
if (envValue === undefined) return;
const parsed = envConfigSchema.safeParse(envValue);
if (!parsed.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "adapterConfig.env must be a map of valid env bindings",
path: ["env"],
});
}
});
export const createAgentSchema = z.object({
name: z.string().min(1),
role: z.enum(AGENT_ROLES).optional().default("general"),
@@ -16,7 +30,7 @@ export const createAgentSchema = z.object({
reportsTo: z.string().uuid().optional().nullable(),
capabilities: z.string().optional().nullable(),
adapterType: z.enum(AGENT_ADAPTER_TYPES).optional().default("process"),
adapterConfig: z.record(z.unknown()).optional().default({}),
adapterConfig: adapterConfigSchema.optional().default({}),
runtimeConfig: z.record(z.unknown()).optional().default({}),
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
permissions: agentPermissionsSchema.optional(),

View File

@@ -63,6 +63,19 @@ export {
type AddApprovalComment,
} from "./approval.js";
export {
envBindingPlainSchema,
envBindingSecretRefSchema,
envBindingSchema,
envConfigSchema,
createSecretSchema,
rotateSecretSchema,
updateSecretSchema,
type CreateSecret,
type RotateSecret,
type UpdateSecret,
} from "./secret.js";
export {
createCostEventSchema,
updateBudgetSchema,

View File

@@ -18,6 +18,7 @@ export type CreateIssue = z.infer<typeof createIssueSchema>;
export const updateIssueSchema = createIssueSchema.partial().extend({
comment: z.string().min(1).optional(),
hiddenAt: z.string().datetime().nullable().optional(),
});
export type UpdateIssue = z.infer<typeof updateIssueSchema>;

View File

@@ -0,0 +1,47 @@
import { z } from "zod";
import { SECRET_PROVIDERS } from "../constants.js";
export const envBindingPlainSchema = z.object({
type: z.literal("plain"),
value: z.string(),
});
export const envBindingSecretRefSchema = z.object({
type: z.literal("secret_ref"),
secretId: z.string().uuid(),
version: z.union([z.literal("latest"), z.number().int().positive()]).optional(),
});
// Backward-compatible union that accepts legacy inline values.
export const envBindingSchema = z.union([
z.string(),
envBindingPlainSchema,
envBindingSecretRefSchema,
]);
export const envConfigSchema = z.record(envBindingSchema);
export const createSecretSchema = z.object({
name: z.string().min(1),
provider: z.enum(SECRET_PROVIDERS).optional(),
value: z.string().min(1),
description: z.string().optional().nullable(),
externalRef: z.string().optional().nullable(),
});
export type CreateSecret = z.infer<typeof createSecretSchema>;
export const rotateSecretSchema = z.object({
value: z.string().min(1),
externalRef: z.string().optional().nullable(),
});
export type RotateSecret = z.infer<typeof rotateSecretSchema>;
export const updateSecretSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
externalRef: z.string().optional().nullable(),
});
export type UpdateSecret = z.infer<typeof updateSecretSchema>;