mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:12:18 +01:00
Harden secrets provider abstraction
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
c7fcfee836
commit
c26273328f
19 files changed
+713
-90
No files matched your search
@@ -135,11 +135,20 @@ export function secretsCheck(config: PaperclipConfig, configPath?: string): Chec
|
||||
};
|
||||
}
|
||||
|
||||
const keyMode = fs.statSync(keyFilePath).mode & 0o777;
|
||||
const permissionWarning =
|
||||
(keyMode & 0o077) !== 0
|
||||
? `; key file permissions are ${keyMode.toString(8)} (run chmod 600 ${keyFilePath})`
|
||||
: "";
|
||||
|
||||
return withStrictModeNote(
|
||||
{
|
||||
name: "Secrets adapter",
|
||||
status: "pass",
|
||||
message: `Local encrypted provider configured with key file ${keyFilePath}`,
|
||||
status: permissionWarning ? "warn" : "pass",
|
||||
message: `Local encrypted provider configured with key file ${keyFilePath}${permissionWarning}`,
|
||||
repairHint: permissionWarning
|
||||
? "Restrict the local encrypted secrets key file to owner read/write permissions"
|
||||
: undefined,
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
@@ -171,6 +171,8 @@ For local/default installs, the active provider is `local_encrypted`:
|
||||
- Secret material is encrypted at rest with a local master key.
|
||||
- Default key file: `~/.paperclip/instances/default/secrets/master.key` (auto-created if missing).
|
||||
- CLI config location: `~/.paperclip/instances/default/config.json` under `secrets.localEncrypted.keyFilePath`.
|
||||
- Backup/restore requires both the database metadata and the local master key file; either artifact alone is insufficient.
|
||||
- The server best-effort enforces `0600` key file permissions and provider health reports permission warnings.
|
||||
|
||||
Optional overrides:
|
||||
|
||||
|
||||
@@ -462,6 +462,7 @@ Agent env vars now support secret references. By default, secret values are stor
|
||||
- Default local key path: `~/.paperclip/instances/default/secrets/master.key`
|
||||
- Override key material directly: `PAPERCLIP_SECRETS_MASTER_KEY`
|
||||
- Override key file path: `PAPERCLIP_SECRETS_MASTER_KEY_FILE`
|
||||
- Back up the key file and database together; either one alone is not enough to restore local encrypted secrets.
|
||||
|
||||
Strict mode (recommended outside local trusted machines):
|
||||
|
||||
@@ -470,12 +471,14 @@ PAPERCLIP_SECRETS_STRICT_MODE=true
|
||||
```
|
||||
|
||||
When strict mode is enabled, sensitive env keys (for example `*_API_KEY`, `*_TOKEN`, `*_SECRET`) must use secret references instead of inline plain values.
|
||||
Authenticated deployments default strict mode on unless explicitly overridden.
|
||||
|
||||
CLI configuration support:
|
||||
|
||||
- `pnpm paperclipai onboard` writes a default `secrets` config section (`local_encrypted`, strict mode off, key file path set) and creates a local key file when needed.
|
||||
- `pnpm paperclipai configure --section secrets` lets you update provider/strict mode/key path and creates the local key file when needed.
|
||||
- `pnpm paperclipai doctor` validates secrets adapter configuration and can create a missing local key file with `--repair`.
|
||||
- Provider health is available at `GET /api/companies/:companyId/secret-providers/health` and reports local key permission warnings plus backup guidance.
|
||||
|
||||
Migration helper for existing inline env secrets:
|
||||
|
||||
|
||||
+27
-1
@@ -25,10 +25,36 @@ POST /api/companies/{companyId}/secrets
|
||||
|
||||
The value is encrypted at rest. Only the secret ID and metadata are returned.
|
||||
|
||||
To link a provider-owned secret without copying the value into Paperclip, create
|
||||
an external-reference secret:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "prod-stripe-key",
|
||||
"provider": "aws_secrets_manager",
|
||||
"managedMode": "external_reference",
|
||||
"externalRef": "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/stripe",
|
||||
"providerVersionRef": "version-id-or-label"
|
||||
}
|
||||
```
|
||||
|
||||
Paperclip stores the provider reference and a non-sensitive fingerprint only.
|
||||
The value is resolved, when the provider is configured, through the server
|
||||
runtime path that enforces binding context and records access events.
|
||||
|
||||
## Provider Health
|
||||
|
||||
```
|
||||
GET /api/companies/{companyId}/secret-providers/health
|
||||
```
|
||||
|
||||
Returns provider setup diagnostics, warnings, and local backup guidance. Health
|
||||
responses must not include secret values or provider credentials.
|
||||
|
||||
## Update Secret
|
||||
|
||||
```
|
||||
PATCH /api/secrets/{secretId}
|
||||
POST /api/secrets/{secretId}/rotate
|
||||
{
|
||||
"value": "sk-ant-new-value..."
|
||||
}
|
||||
|
||||
@@ -14,6 +14,13 @@ Secrets are encrypted with a local master key stored at:
|
||||
```
|
||||
|
||||
This key is auto-created during onboarding. The key never leaves your machine.
|
||||
Paperclip best-effort enforces `0600` permissions when it creates or loads the
|
||||
key file. `paperclipai doctor` and the provider health API warn when the file is
|
||||
readable by group or other users.
|
||||
|
||||
Back up the key file together with database backups. A database backup without
|
||||
the key cannot decrypt local secrets, and a key backup without the database
|
||||
metadata is not enough to restore named secret versions.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -55,6 +62,21 @@ PAPERCLIP_SECRETS_STRICT_MODE=true
|
||||
|
||||
Recommended for any deployment beyond local trusted.
|
||||
|
||||
Authenticated deployments default strict mode on unless explicitly overridden by
|
||||
configuration or `PAPERCLIP_SECRETS_STRICT_MODE=false`.
|
||||
|
||||
## External References
|
||||
|
||||
Provider-owned secrets can be linked without copying values into Paperclip by
|
||||
using `managedMode: "external_reference"` plus a provider `externalRef`.
|
||||
Paperclip stores metadata and a non-sensitive fingerprint, never the value.
|
||||
Runtime resolution remains server-side and binding-enforced.
|
||||
|
||||
The built-in AWS, GCP, and Vault provider IDs currently accept external
|
||||
reference metadata, but runtime resolution requires provider configuration in the
|
||||
deployment. Their provider health check reports this as a warning until
|
||||
configured.
|
||||
|
||||
## Migrating Inline Secrets
|
||||
|
||||
If you have existing agents with inline API keys in their config, migrate them to encrypted secret refs:
|
||||
|
||||
@@ -53,6 +53,9 @@ export interface SecretProviderDescriptor {
|
||||
id: SecretProvider;
|
||||
label: string;
|
||||
requiresExternalRef: boolean;
|
||||
supportsManagedValues?: boolean;
|
||||
supportsExternalReferences?: boolean;
|
||||
configured?: boolean;
|
||||
}
|
||||
|
||||
export interface CompanySecretVersion {
|
||||
|
||||
@@ -31,16 +31,35 @@ export const createSecretSchema = z.object({
|
||||
key: z.string().min(1).regex(/^[a-zA-Z0-9_.-]+$/).optional(),
|
||||
provider: z.enum(SECRET_PROVIDERS).optional(),
|
||||
managedMode: z.enum(SECRET_MANAGED_MODES).optional(),
|
||||
value: z.string().min(1),
|
||||
value: z.string().min(1).optional().nullable(),
|
||||
description: z.string().optional().nullable(),
|
||||
externalRef: z.string().optional().nullable(),
|
||||
providerMetadata: z.record(z.unknown()).optional().nullable(),
|
||||
providerVersionRef: z.string().optional().nullable(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if ((value.managedMode ?? "paperclip_managed") === "external_reference") {
|
||||
if (!value.externalRef?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["externalRef"],
|
||||
message: "External reference secrets require externalRef",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!value.value?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["value"],
|
||||
message: "Managed secrets require value",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type CreateSecret = z.infer<typeof createSecretSchema>;
|
||||
|
||||
export const rotateSecretSchema = z.object({
|
||||
value: z.string().min(1),
|
||||
value: z.string().min(1).optional().nullable(),
|
||||
externalRef: z.string().optional().nullable(),
|
||||
providerVersionRef: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { checkSecretProviders, listSecretProviders } from "../secrets/provider-registry.js";
|
||||
|
||||
describe("secret provider registry", () => {
|
||||
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
const previousMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
if (previousKeyFile === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
|
||||
}
|
||||
if (previousMasterKey === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY = previousMasterKey;
|
||||
}
|
||||
for (const dir of tmpDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("describes managed and external-reference provider capabilities", () => {
|
||||
const descriptors = listSecretProviders();
|
||||
|
||||
expect(descriptors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "local_encrypted",
|
||||
supportsManagedValues: true,
|
||||
supportsExternalReferences: false,
|
||||
configured: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "aws_secrets_manager",
|
||||
supportsManagedValues: false,
|
||||
supportsExternalReferences: true,
|
||||
configured: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when the local encrypted key file is readable by group or others", async () => {
|
||||
const dir = path.join(os.tmpdir(), `paperclip-secret-provider-${randomBytes(6).toString("hex")}`);
|
||||
tmpDirs.push(dir);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const keyFile = path.join(dir, "master.key");
|
||||
writeFileSync(keyFile, randomBytes(32).toString("base64"), { encoding: "utf8", mode: 0o644 });
|
||||
chmodSync(keyFile, 0o644);
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = keyFile;
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
|
||||
const checks = await checkSecretProviders();
|
||||
const local = checks.find((check) => check.provider === "local_encrypted");
|
||||
|
||||
expect(local).toMatchObject({
|
||||
status: "warn",
|
||||
details: { keyFilePath: keyFile },
|
||||
});
|
||||
expect(local?.warnings?.join("\n")).toContain("chmod 600");
|
||||
expect(local?.backupGuidance?.join("\n")).toContain("database");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { secretRoutes } from "../routes/secrets.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
listProviders: vi.fn(),
|
||||
checkProviders: vi.fn(),
|
||||
}));
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
secretService: () => mockSecretService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
companyIds: ["company-1"],
|
||||
memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", secretRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("secret routes", () => {
|
||||
beforeEach(() => {
|
||||
mockSecretService.listProviders.mockReset();
|
||||
mockSecretService.checkProviders.mockReset();
|
||||
mockLogActivity.mockReset();
|
||||
});
|
||||
|
||||
it("returns provider health checks for board callers with company access", async () => {
|
||||
mockSecretService.checkProviders.mockResolvedValue([
|
||||
{
|
||||
provider: "local_encrypted",
|
||||
status: "ok",
|
||||
message: "Local encrypted provider configured",
|
||||
backupGuidance: ["Back up the key file together with database backups."],
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await request(createApp()).get("/api/companies/company-1/secret-providers/health");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
providers: [
|
||||
{
|
||||
provider: "local_encrypted",
|
||||
status: "ok",
|
||||
message: "Local encrypted provider configured",
|
||||
backupGuidance: ["Back up the key file together with database backups."],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
companies,
|
||||
companySecretBindings,
|
||||
@@ -23,8 +27,12 @@ if (!embeddedPostgresSupport.supported) {
|
||||
describeEmbeddedPostgres("secretService", () => {
|
||||
let stopDb: (() => Promise<void>) | null = null;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
const secretsTmpDir = path.join(os.tmpdir(), `paperclip-secrets-service-${randomUUID()}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
mkdirSync(secretsTmpDir, { recursive: true });
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
|
||||
const started = await startEmbeddedPostgresTestDatabase("secrets-service");
|
||||
stopDb = started.stop;
|
||||
db = createDb(started.connectionString);
|
||||
@@ -40,6 +48,12 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await stopDb?.();
|
||||
if (previousKeyFile === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
|
||||
}
|
||||
rmSync(secretsTmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function seedCompany(name = "Acme") {
|
||||
@@ -141,4 +155,37 @@ describeEmbeddedPostgres("secretService", () => {
|
||||
expect(events.map((event) => event.outcome).sort()).toEqual(["failure", "success"]);
|
||||
expect(JSON.stringify(events)).not.toContain("runtime-secret");
|
||||
});
|
||||
|
||||
it("stores external references without requiring or persisting secret values", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
|
||||
const secret = await svc.create(companyId, {
|
||||
name: `external-${randomUUID()}`,
|
||||
provider: "aws_secrets_manager",
|
||||
managedMode: "external_reference",
|
||||
externalRef: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/test",
|
||||
providerVersionRef: "version-1",
|
||||
});
|
||||
|
||||
expect(secret.managedMode).toBe("external_reference");
|
||||
expect(secret.externalRef).toBe("arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/test");
|
||||
|
||||
const versions = await db
|
||||
.select()
|
||||
.from(companySecretVersions)
|
||||
.where(eq(companySecretVersions.secretId, secret.id));
|
||||
expect(versions).toHaveLength(1);
|
||||
expect(versions[0]?.providerVersionRef).toBe("version-1");
|
||||
expect(JSON.stringify(versions[0])).not.toContain("runtime-secret");
|
||||
expect(JSON.stringify(versions[0])).not.toContain("sk-");
|
||||
|
||||
await expect(
|
||||
svc.resolveSecretValue(companyId, secret.id, "latest", {
|
||||
consumerType: "system",
|
||||
consumerId: "system",
|
||||
configPath: "env.EXTERNAL_SECRET",
|
||||
}),
|
||||
).rejects.toThrow(/not bound/i);
|
||||
});
|
||||
});
|
||||
@@ -120,11 +120,6 @@ export function loadConfig(): Config {
|
||||
const fileDatabaseBackup = fileConfig?.database.backup;
|
||||
const fileSecrets = fileConfig?.secrets;
|
||||
const fileStorage = fileConfig?.storage;
|
||||
const strictModeFromEnv = process.env.PAPERCLIP_SECRETS_STRICT_MODE;
|
||||
const secretsStrictMode =
|
||||
strictModeFromEnv !== undefined
|
||||
? strictModeFromEnv === "true"
|
||||
: (fileSecrets?.strictMode ?? false);
|
||||
|
||||
const providerFromEnvRaw = process.env.PAPERCLIP_SECRETS_PROVIDER;
|
||||
const providerFromEnv =
|
||||
@@ -168,6 +163,11 @@ export function loadConfig(): Config {
|
||||
? (deploymentModeFromEnvRaw as DeploymentMode)
|
||||
: null;
|
||||
const deploymentMode: DeploymentMode = deploymentModeFromEnv ?? fileConfig?.server.deploymentMode ?? "local_trusted";
|
||||
const strictModeFromEnv = process.env.PAPERCLIP_SECRETS_STRICT_MODE;
|
||||
const secretsStrictMode =
|
||||
strictModeFromEnv !== undefined
|
||||
? strictModeFromEnv === "true"
|
||||
: (fileSecrets?.strictMode ?? deploymentMode === "authenticated");
|
||||
const deploymentExposureFromEnvRaw = process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE;
|
||||
const deploymentExposureFromEnv =
|
||||
deploymentExposureFromEnvRaw &&
|
||||
|
||||
@@ -28,6 +28,14 @@ export function secretRoutes(db: Db) {
|
||||
res.json(svc.listProviders());
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/secret-providers/health", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const checks = await svc.checkProviders();
|
||||
res.json({ providers: checks });
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/secrets", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
@@ -51,6 +59,7 @@ export function secretRoutes(db: Db) {
|
||||
value: req.body.value,
|
||||
description: req.body.description,
|
||||
externalRef: req.body.externalRef,
|
||||
providerVersionRef: req.body.providerVersionRef,
|
||||
providerMetadata: req.body.providerMetadata,
|
||||
},
|
||||
{ userId: req.actor.userId ?? "board", agentId: null },
|
||||
@@ -84,6 +93,7 @@ export function secretRoutes(db: Db) {
|
||||
{
|
||||
value: req.body.value,
|
||||
externalRef: req.body.externalRef,
|
||||
providerVersionRef: req.body.providerVersionRef,
|
||||
},
|
||||
{ userId: req.actor.userId ?? "board", agentId: null },
|
||||
);
|
||||
|
||||
@@ -1,23 +1,78 @@
|
||||
import { unprocessable } from "../errors.js";
|
||||
import type { SecretProviderModule } from "./types.js";
|
||||
import type { PreparedSecretVersion, SecretProviderModule } from "./types.js";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
function unavailableProvider(
|
||||
id: "aws_secrets_manager" | "gcp_secret_manager" | "vault",
|
||||
label: string,
|
||||
): SecretProviderModule {
|
||||
function externalFingerprint(externalRef: string, providerVersionRef: string | null): string {
|
||||
return createHash("sha256")
|
||||
.update(`${id}:${externalRef}:${providerVersionRef ?? ""}`)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function prepareExternalReference(input: {
|
||||
externalRef: string;
|
||||
providerVersionRef?: string | null;
|
||||
}): PreparedSecretVersion {
|
||||
const externalRef = input.externalRef.trim();
|
||||
const providerVersionRef = input.providerVersionRef?.trim() || null;
|
||||
const fingerprint = externalFingerprint(externalRef, providerVersionRef);
|
||||
return {
|
||||
material: {
|
||||
scheme: "external_reference_v1",
|
||||
provider: id,
|
||||
externalRef,
|
||||
providerVersionRef,
|
||||
},
|
||||
valueSha256: fingerprint,
|
||||
fingerprintSha256: fingerprint,
|
||||
externalRef,
|
||||
providerVersionRef,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
descriptor: {
|
||||
id,
|
||||
label,
|
||||
requiresExternalRef: true,
|
||||
descriptor() {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
requiresExternalRef: true,
|
||||
supportsManagedValues: false,
|
||||
supportsExternalReferences: true,
|
||||
configured: false,
|
||||
};
|
||||
},
|
||||
async validateConfig() {
|
||||
return { ok: false, warnings: [`${id} provider is not configured in this deployment`] };
|
||||
},
|
||||
async createSecret() {
|
||||
throw unprocessable(`${id} provider is not configured for Paperclip-managed values`);
|
||||
},
|
||||
async createVersion() {
|
||||
throw unprocessable(`${id} provider is not configured in this deployment`);
|
||||
throw unprocessable(`${id} provider is not configured for Paperclip-managed values`);
|
||||
},
|
||||
async linkExternalSecret(input) {
|
||||
return prepareExternalReference(input);
|
||||
},
|
||||
async resolveVersion() {
|
||||
throw unprocessable(`${id} provider is not configured in this deployment`);
|
||||
},
|
||||
async deleteOrArchive() {
|
||||
// External references are metadata-only in Paperclip for unconfigured providers.
|
||||
},
|
||||
async healthCheck() {
|
||||
return {
|
||||
provider: id,
|
||||
status: "warn",
|
||||
message: `${id} provider is available for external references but not configured for runtime resolution`,
|
||||
warnings: [
|
||||
"Linked external references can be stored as metadata, but runtime resolution will fail until this provider is configured.",
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { SecretProviderModule, StoredSecretVersionMaterial } from "./types.js";
|
||||
import { resolveDefaultSecretsKeyFilePath } from "../home-paths.js";
|
||||
import type {
|
||||
PreparedSecretVersion,
|
||||
SecretProviderHealthCheck,
|
||||
SecretProviderModule,
|
||||
SecretProviderValidationResult,
|
||||
StoredSecretVersionMaterial,
|
||||
} from "./types.js";
|
||||
import { badRequest } from "../errors.js";
|
||||
|
||||
interface LocalEncryptedMaterial extends StoredSecretVersionMaterial {
|
||||
@@ -14,7 +21,7 @@ interface LocalEncryptedMaterial extends StoredSecretVersionMaterial {
|
||||
function resolveMasterKeyFilePath() {
|
||||
const fromEnv = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
if (fromEnv && fromEnv.trim().length > 0) return path.resolve(fromEnv.trim());
|
||||
return path.resolve(process.cwd(), "data/secrets/master.key");
|
||||
return resolveDefaultSecretsKeyFilePath();
|
||||
}
|
||||
|
||||
function decodeMasterKey(raw: string): Buffer | null {
|
||||
@@ -52,6 +59,7 @@ function loadOrCreateMasterKey(): Buffer {
|
||||
|
||||
const keyPath = resolveMasterKeyFilePath();
|
||||
if (existsSync(keyPath)) {
|
||||
enforceKeyFilePermissionsBestEffort(keyPath);
|
||||
const raw = readFileSync(keyPath, "utf8");
|
||||
const decoded = decodeMasterKey(raw);
|
||||
if (!decoded) {
|
||||
@@ -72,10 +80,118 @@ function loadOrCreateMasterKey(): Buffer {
|
||||
return generated;
|
||||
}
|
||||
|
||||
function enforceKeyFilePermissionsBestEffort(keyPath: string) {
|
||||
try {
|
||||
const mode = statSync(keyPath).mode & 0o777;
|
||||
if ((mode & 0o077) !== 0) {
|
||||
chmodSync(keyPath, 0o600);
|
||||
}
|
||||
} catch {
|
||||
// best effort only; health checks surface persistent permission problems.
|
||||
}
|
||||
}
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function prepareManagedVersion(value: string): PreparedSecretVersion {
|
||||
const masterKey = loadOrCreateMasterKey();
|
||||
const valueSha256 = sha256Hex(value);
|
||||
return {
|
||||
material: encryptValue(masterKey, value),
|
||||
valueSha256,
|
||||
fingerprintSha256: valueSha256,
|
||||
externalRef: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectLocalEncryptedHealth(): Promise<SecretProviderHealthCheck> {
|
||||
const envKeyRaw = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
|
||||
if (envKeyRaw && envKeyRaw.trim().length > 0) {
|
||||
if (!decodeMasterKey(envKeyRaw)) {
|
||||
return {
|
||||
provider: "local_encrypted",
|
||||
status: "error",
|
||||
message:
|
||||
"PAPERCLIP_SECRETS_MASTER_KEY is invalid; expected 32-byte base64, 64-char hex, or raw 32-char string",
|
||||
};
|
||||
}
|
||||
return {
|
||||
provider: "local_encrypted",
|
||||
status: "ok",
|
||||
message: "Local encrypted provider is using PAPERCLIP_SECRETS_MASTER_KEY",
|
||||
backupGuidance: [
|
||||
"Back up the configured master key separately from the database.",
|
||||
"A restore needs both the database metadata and the same master key.",
|
||||
],
|
||||
details: { keySource: "env" },
|
||||
};
|
||||
}
|
||||
|
||||
const keyPath = resolveMasterKeyFilePath();
|
||||
if (!existsSync(keyPath)) {
|
||||
return {
|
||||
provider: "local_encrypted",
|
||||
status: "warn",
|
||||
message: `Secrets key file does not exist yet: ${keyPath}`,
|
||||
warnings: ["The first managed secret write will create this key file with 0600 permissions."],
|
||||
backupGuidance: [
|
||||
"Back up the key file together with database backups.",
|
||||
"The database alone cannot restore local encrypted secret values.",
|
||||
],
|
||||
details: { keySource: "file", keyFilePath: keyPath },
|
||||
};
|
||||
}
|
||||
|
||||
let mode: number | null = null;
|
||||
try {
|
||||
mode = statSync(keyPath).mode & 0o777;
|
||||
} catch (err) {
|
||||
return {
|
||||
provider: "local_encrypted",
|
||||
status: "error",
|
||||
message: `Could not stat secrets key file: ${err instanceof Error ? err.message : String(err)}`,
|
||||
details: { keySource: "file", keyFilePath: keyPath },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(keyPath, "utf8");
|
||||
if (!decodeMasterKey(raw)) {
|
||||
return {
|
||||
provider: "local_encrypted",
|
||||
status: "error",
|
||||
message: `Invalid key material in ${keyPath}`,
|
||||
details: { keySource: "file", keyFilePath: keyPath },
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
provider: "local_encrypted",
|
||||
status: "error",
|
||||
message: `Could not read secrets key file: ${err instanceof Error ? err.message : String(err)}`,
|
||||
details: { keySource: "file", keyFilePath: keyPath },
|
||||
};
|
||||
}
|
||||
|
||||
const warnings =
|
||||
mode !== null && (mode & 0o077) !== 0
|
||||
? [`Secrets key file permissions are ${mode.toString(8)}; run chmod 600 ${keyPath}`]
|
||||
: [];
|
||||
return {
|
||||
provider: "local_encrypted",
|
||||
status: warnings.length > 0 ? "warn" : "ok",
|
||||
message: `Local encrypted provider configured with key file ${keyPath}`,
|
||||
warnings,
|
||||
backupGuidance: [
|
||||
"Back up the key file together with database backups.",
|
||||
"The database alone cannot restore local encrypted secret values.",
|
||||
],
|
||||
details: { keySource: "file", keyFilePath: keyPath },
|
||||
};
|
||||
}
|
||||
|
||||
function encryptValue(masterKey: Buffer, value: string): LocalEncryptedMaterial {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", masterKey, iv);
|
||||
@@ -115,21 +231,45 @@ function asLocalEncryptedMaterial(value: StoredSecretVersionMaterial): LocalEncr
|
||||
|
||||
export const localEncryptedProvider: SecretProviderModule = {
|
||||
id: "local_encrypted",
|
||||
descriptor: {
|
||||
id: "local_encrypted",
|
||||
label: "Local encrypted (default)",
|
||||
requiresExternalRef: false,
|
||||
descriptor() {
|
||||
return {
|
||||
id: "local_encrypted",
|
||||
label: "Local encrypted (default)",
|
||||
requiresExternalRef: false,
|
||||
supportsManagedValues: true,
|
||||
supportsExternalReferences: false,
|
||||
configured: true,
|
||||
};
|
||||
},
|
||||
async validateConfig(input): Promise<SecretProviderValidationResult> {
|
||||
const warnings: string[] = [];
|
||||
if (input?.deploymentMode === "authenticated" && input.strictMode !== true) {
|
||||
warnings.push("Strict secret mode should be enabled for authenticated deployments");
|
||||
}
|
||||
const health = await inspectLocalEncryptedHealth();
|
||||
if (health.status === "error") {
|
||||
throw badRequest(health.message);
|
||||
}
|
||||
warnings.push(...(health.warnings ?? []));
|
||||
return { ok: true, warnings };
|
||||
},
|
||||
async createSecret(input) {
|
||||
return prepareManagedVersion(input.value);
|
||||
},
|
||||
async createVersion(input) {
|
||||
const masterKey = loadOrCreateMasterKey();
|
||||
return {
|
||||
material: encryptValue(masterKey, input.value),
|
||||
valueSha256: sha256Hex(input.value),
|
||||
externalRef: null,
|
||||
};
|
||||
return prepareManagedVersion(input.value);
|
||||
},
|
||||
async linkExternalSecret() {
|
||||
throw badRequest("local_encrypted does not support external reference secrets");
|
||||
},
|
||||
async resolveVersion(input) {
|
||||
const masterKey = loadOrCreateMasterKey();
|
||||
return decryptValue(masterKey, asLocalEncryptedMaterial(input.material));
|
||||
},
|
||||
async deleteOrArchive() {
|
||||
// Secret metadata deletion is handled in Paperclip DB; the local key is shared and must remain.
|
||||
},
|
||||
async healthCheck() {
|
||||
return inspectLocalEncryptedHealth();
|
||||
},
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
gcpSecretManagerProvider,
|
||||
vaultProvider,
|
||||
} from "./external-stub-providers.js";
|
||||
import type { SecretProviderModule } from "./types.js";
|
||||
import type { SecretProviderHealthCheck, SecretProviderModule } from "./types.js";
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
const providers: SecretProviderModule[] = [
|
||||
@@ -26,5 +26,9 @@ export function getSecretProvider(id: SecretProvider): SecretProviderModule {
|
||||
}
|
||||
|
||||
export function listSecretProviders(): SecretProviderDescriptor[] {
|
||||
return providers.map((provider) => provider.descriptor);
|
||||
return providers.map((provider) => provider.descriptor());
|
||||
}
|
||||
|
||||
export async function checkSecretProviders(): Promise<SecretProviderHealthCheck[]> {
|
||||
return Promise.all(providers.map((provider) => provider.healthCheck()));
|
||||
}
|
||||
@@ -1,22 +1,88 @@
|
||||
import type { SecretProvider, SecretProviderDescriptor } from "@paperclipai/shared";
|
||||
import type { DeploymentMode } from "@paperclipai/shared";
|
||||
|
||||
export interface StoredSecretVersionMaterial {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type SecretProviderHealthStatus = "ok" | "warn" | "error";
|
||||
|
||||
export interface SecretProviderHealthCheck {
|
||||
provider: SecretProvider;
|
||||
status: SecretProviderHealthStatus;
|
||||
message: string;
|
||||
warnings?: string[];
|
||||
backupGuidance?: string[];
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SecretProviderValidationResult {
|
||||
ok: boolean;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface PreparedSecretVersion {
|
||||
material: StoredSecretVersionMaterial;
|
||||
valueSha256: string;
|
||||
fingerprintSha256?: string;
|
||||
externalRef: string | null;
|
||||
providerVersionRef?: string | null;
|
||||
}
|
||||
|
||||
export interface SecretProviderRuntimeContext {
|
||||
companyId: string;
|
||||
secretId: string;
|
||||
secretKey: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface SecretProviderWriteContext {
|
||||
companyId: string;
|
||||
secretKey: string;
|
||||
secretName: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface SecretProviderModule {
|
||||
id: SecretProvider;
|
||||
descriptor: SecretProviderDescriptor;
|
||||
descriptor(): SecretProviderDescriptor;
|
||||
validateConfig(input?: {
|
||||
deploymentMode?: DeploymentMode;
|
||||
strictMode?: boolean;
|
||||
}): Promise<SecretProviderValidationResult>;
|
||||
createSecret(input: {
|
||||
value: string;
|
||||
externalRef?: string | null;
|
||||
context?: SecretProviderWriteContext;
|
||||
}): Promise<PreparedSecretVersion>;
|
||||
createVersion(input: {
|
||||
value: string;
|
||||
externalRef: string | null;
|
||||
}): Promise<{
|
||||
material: StoredSecretVersionMaterial;
|
||||
valueSha256: string;
|
||||
externalRef: string | null;
|
||||
}>;
|
||||
externalRef?: string | null;
|
||||
context?: SecretProviderWriteContext;
|
||||
}): Promise<PreparedSecretVersion>;
|
||||
linkExternalSecret(input: {
|
||||
externalRef: string;
|
||||
providerVersionRef?: string | null;
|
||||
context?: SecretProviderWriteContext;
|
||||
}): Promise<PreparedSecretVersion>;
|
||||
resolveVersion(input: {
|
||||
material: StoredSecretVersionMaterial;
|
||||
externalRef: string | null;
|
||||
providerVersionRef?: string | null;
|
||||
context?: SecretProviderRuntimeContext;
|
||||
}): Promise<string>;
|
||||
rotate?(input: {
|
||||
material: StoredSecretVersionMaterial;
|
||||
externalRef: string | null;
|
||||
providerVersionRef?: string | null;
|
||||
}): Promise<PreparedSecretVersion>;
|
||||
deleteOrArchive(input: {
|
||||
material?: StoredSecretVersionMaterial | null;
|
||||
externalRef: string | null;
|
||||
mode: "archive" | "delete";
|
||||
}): Promise<void>;
|
||||
healthCheck(input?: {
|
||||
deploymentMode?: DeploymentMode;
|
||||
strictMode?: boolean;
|
||||
}): Promise<SecretProviderHealthCheck>;
|
||||
}
|
||||
@@ -15,7 +15,11 @@ import type {
|
||||
} from "@paperclipai/shared";
|
||||
import { envBindingSchema } from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { getSecretProvider, listSecretProviders } from "../secrets/provider-registry.js";
|
||||
import {
|
||||
checkSecretProviders,
|
||||
getSecretProvider,
|
||||
listSecretProviders,
|
||||
} from "../secrets/provider-registry.js";
|
||||
|
||||
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const SENSITIVE_ENV_KEY_RE =
|
||||
@@ -207,6 +211,13 @@ export function secretService(db: Db) {
|
||||
const value = await provider.resolveVersion({
|
||||
material: versionRow.material as Record<string, unknown>,
|
||||
externalRef: secret.externalRef,
|
||||
providerVersionRef: versionRow.providerVersionRef,
|
||||
context: {
|
||||
companyId,
|
||||
secretId: secret.id,
|
||||
secretKey: secret.key,
|
||||
version: resolvedVersion,
|
||||
},
|
||||
});
|
||||
await Promise.all([
|
||||
db
|
||||
@@ -296,6 +307,8 @@ export function secretService(db: Db) {
|
||||
return {
|
||||
listProviders: () => listSecretProviders(),
|
||||
|
||||
checkProviders: () => checkSecretProviders(),
|
||||
|
||||
list: (companyId: string) =>
|
||||
db
|
||||
.select()
|
||||
@@ -330,11 +343,12 @@ export function secretService(db: Db) {
|
||||
input: {
|
||||
name: string;
|
||||
provider: SecretProvider;
|
||||
value: string;
|
||||
value?: string | null;
|
||||
key?: string | null;
|
||||
managedMode?: "paperclip_managed" | "external_reference";
|
||||
description?: string | null;
|
||||
externalRef?: string | null;
|
||||
providerVersionRef?: string | null;
|
||||
providerMetadata?: Record<string, unknown> | null;
|
||||
},
|
||||
actor?: { userId?: string | null; agentId?: string | null },
|
||||
@@ -350,11 +364,24 @@ export function secretService(db: Db) {
|
||||
.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 prepared = await provider.createVersion({
|
||||
value: input.value,
|
||||
externalRef: input.externalRef ?? null,
|
||||
});
|
||||
if (managedMode === "external_reference" && !input.externalRef?.trim()) {
|
||||
throw unprocessable("External reference secrets require externalRef");
|
||||
}
|
||||
if (managedMode === "paperclip_managed" && !input.value?.trim()) {
|
||||
throw unprocessable("Managed secrets require value");
|
||||
}
|
||||
const prepared =
|
||||
managedMode === "external_reference"
|
||||
? await provider.linkExternalSecret({
|
||||
externalRef: input.externalRef ?? "",
|
||||
providerVersionRef: input.providerVersionRef ?? null,
|
||||
})
|
||||
: await provider.createSecret({
|
||||
value: input.value ?? "",
|
||||
externalRef: input.externalRef ?? null,
|
||||
});
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const secret = await tx
|
||||
@@ -365,7 +392,7 @@ export function secretService(db: Db) {
|
||||
name: input.name,
|
||||
provider: input.provider,
|
||||
status: "active",
|
||||
managedMode: input.managedMode ?? "paperclip_managed",
|
||||
managedMode,
|
||||
externalRef: prepared.externalRef,
|
||||
providerMetadata: input.providerMetadata ?? null,
|
||||
latestVersion: 1,
|
||||
@@ -382,7 +409,8 @@ export function secretService(db: Db) {
|
||||
version: 1,
|
||||
material: prepared.material,
|
||||
valueSha256: prepared.valueSha256,
|
||||
fingerprintSha256: prepared.valueSha256,
|
||||
fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256,
|
||||
providerVersionRef: prepared.providerVersionRef ?? null,
|
||||
status: "current",
|
||||
createdByAgentId: actor?.agentId ?? null,
|
||||
createdByUserId: actor?.userId ?? null,
|
||||
@@ -394,17 +422,29 @@ export function secretService(db: Db) {
|
||||
|
||||
rotate: async (
|
||||
secretId: string,
|
||||
input: { value: string; externalRef?: string | null },
|
||||
input: { value?: string | null; externalRef?: string | null; providerVersionRef?: string | null },
|
||||
actor?: { userId?: string | null; agentId?: string | null },
|
||||
) => {
|
||||
const secret = await getById(secretId);
|
||||
if (!secret) throw notFound("Secret not found");
|
||||
const provider = getSecretProvider(secret.provider as SecretProvider);
|
||||
const nextVersion = secret.latestVersion + 1;
|
||||
const prepared = await provider.createVersion({
|
||||
value: input.value,
|
||||
externalRef: input.externalRef ?? secret.externalRef ?? null,
|
||||
});
|
||||
if (secret.managedMode === "external_reference" && !(input.externalRef ?? secret.externalRef)?.trim()) {
|
||||
throw unprocessable("External reference secrets require externalRef");
|
||||
}
|
||||
if (secret.managedMode !== "external_reference" && !input.value?.trim()) {
|
||||
throw unprocessable("Managed secrets require value");
|
||||
}
|
||||
const prepared =
|
||||
secret.managedMode === "external_reference"
|
||||
? await provider.linkExternalSecret({
|
||||
externalRef: input.externalRef ?? secret.externalRef ?? "",
|
||||
providerVersionRef: input.providerVersionRef ?? null,
|
||||
})
|
||||
: await provider.createVersion({
|
||||
value: input.value ?? "",
|
||||
externalRef: input.externalRef ?? secret.externalRef ?? null,
|
||||
});
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
await tx
|
||||
@@ -416,7 +456,8 @@ export function secretService(db: Db) {
|
||||
version: nextVersion,
|
||||
material: prepared.material,
|
||||
valueSha256: prepared.valueSha256,
|
||||
fingerprintSha256: prepared.valueSha256,
|
||||
fingerprintSha256: prepared.fingerprintSha256 ?? prepared.valueSha256,
|
||||
providerVersionRef: prepared.providerVersionRef ?? null,
|
||||
status: "current",
|
||||
createdByAgentId: actor?.agentId ?? null,
|
||||
createdByUserId: actor?.userId ?? null,
|
||||
@@ -587,6 +628,13 @@ export function secretService(db: Db) {
|
||||
remove: async (secretId: string) => {
|
||||
const secret = await getById(secretId);
|
||||
if (!secret) return null;
|
||||
const versionRow = await getSecretVersion(secret.id, secret.latestVersion);
|
||||
const provider = getSecretProvider(secret.provider as SecretProvider);
|
||||
await provider.deleteOrArchive({
|
||||
material: versionRow?.material as Record<string, unknown> | undefined,
|
||||
externalRef: secret.externalRef,
|
||||
mode: "delete",
|
||||
});
|
||||
await db.delete(companySecrets).where(eq(companySecrets.id, secretId));
|
||||
return secret;
|
||||
},
|
||||
|
||||
@@ -250,28 +250,44 @@ const runtimeState: AgentRuntimeState = {
|
||||
};
|
||||
|
||||
const storybookSecrets: CompanySecret[] = [
|
||||
{
|
||||
id: "secret-openai",
|
||||
companyId: COMPANY_ID,
|
||||
name: "OPENAI_API_KEY",
|
||||
provider: "local_encrypted",
|
||||
externalRef: null,
|
||||
latestVersion: 5,
|
||||
description: "Primary coding model key for local Codex agents.",
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-board",
|
||||
createdAt: recent(21_000),
|
||||
{
|
||||
id: "secret-openai",
|
||||
companyId: COMPANY_ID,
|
||||
key: "openai-api-key",
|
||||
name: "OPENAI_API_KEY",
|
||||
provider: "local_encrypted",
|
||||
status: "active",
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: null,
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
latestVersion: 5,
|
||||
description: "Primary coding model key for local Codex agents.",
|
||||
lastResolvedAt: recent(600),
|
||||
lastRotatedAt: recent(21_000),
|
||||
deletedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-board",
|
||||
createdAt: recent(21_000),
|
||||
updatedAt: recent(400),
|
||||
},
|
||||
{
|
||||
id: "secret-ops-webhook",
|
||||
companyId: COMPANY_ID,
|
||||
name: "OPS_WEBHOOK_TOKEN",
|
||||
provider: "local_encrypted",
|
||||
externalRef: null,
|
||||
latestVersion: 3,
|
||||
description: "Webhook token for runtime observability callbacks.",
|
||||
createdByAgentId: "agent-cto",
|
||||
{
|
||||
id: "secret-ops-webhook",
|
||||
companyId: COMPANY_ID,
|
||||
key: "ops-webhook-token",
|
||||
name: "OPS_WEBHOOK_TOKEN",
|
||||
provider: "local_encrypted",
|
||||
status: "active",
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: null,
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
latestVersion: 3,
|
||||
description: "Webhook token for runtime observability callbacks.",
|
||||
lastResolvedAt: recent(120),
|
||||
lastRotatedAt: recent(12_000),
|
||||
deletedAt: null,
|
||||
createdByAgentId: "agent-cto",
|
||||
createdByUserId: null,
|
||||
createdAt: recent(12_000),
|
||||
updatedAt: recent(80),
|
||||
|
||||
@@ -199,28 +199,44 @@ const adapterErrors = {
|
||||
};
|
||||
|
||||
const storybookSecrets: CompanySecret[] = [
|
||||
{
|
||||
id: "secret-openai",
|
||||
companyId: "company-storybook",
|
||||
name: "OPENAI_API_KEY",
|
||||
provider: "local_encrypted",
|
||||
externalRef: null,
|
||||
latestVersion: 3,
|
||||
description: null,
|
||||
createdByAgentId: null,
|
||||
{
|
||||
id: "secret-openai",
|
||||
companyId: "company-storybook",
|
||||
key: "openai-api-key",
|
||||
name: "OPENAI_API_KEY",
|
||||
provider: "local_encrypted",
|
||||
status: "active",
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: null,
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
latestVersion: 3,
|
||||
description: null,
|
||||
lastResolvedAt: new Date("2026-04-20T09:00:00.000Z"),
|
||||
lastRotatedAt: new Date("2026-04-18T10:00:00.000Z"),
|
||||
deletedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-board",
|
||||
createdAt: new Date("2026-04-18T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-20T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "secret-github",
|
||||
companyId: "company-storybook",
|
||||
name: "GITHUB_TOKEN",
|
||||
provider: "local_encrypted",
|
||||
externalRef: null,
|
||||
latestVersion: 1,
|
||||
description: null,
|
||||
createdByAgentId: null,
|
||||
{
|
||||
id: "secret-github",
|
||||
companyId: "company-storybook",
|
||||
key: "github-token",
|
||||
name: "GITHUB_TOKEN",
|
||||
provider: "local_encrypted",
|
||||
status: "active",
|
||||
managedMode: "paperclip_managed",
|
||||
externalRef: null,
|
||||
providerConfigId: null,
|
||||
providerMetadata: null,
|
||||
latestVersion: 1,
|
||||
description: null,
|
||||
lastResolvedAt: null,
|
||||
lastRotatedAt: new Date("2026-04-19T10:00:00.000Z"),
|
||||
deletedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-board",
|
||||
createdAt: new Date("2026-04-19T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-19T10:00:00.000Z"),
|
||||
|
||||
Reference in new issue
Block a user