fix(secrets): encrypt service configuration and frozen snapshots (#844)

Reuse one explicit-key AES-GCM implementation in the shared repository layer.
Protect live env/build args, advanced configuration, drift baselines, and frozen
service snapshots; decode legacy rows and convert them in bounded, guarded
startup batches. Re-encrypt transfers for the receiving installation and redact
protected configuration from transfers that omit secrets.

Verify repository semantics, native/HTTP masking, cross-key HTTP transfers,
PostgreSQL update races, and real Docker rollback with the original secret.
This commit is contained in:
Hydra
2026-09-16 00:58:29 +03:00
parent 760bdbed7f
commit 2763b0cdff
40 changed files with 1021 additions and 263 deletions
+1
View File
@@ -66,6 +66,7 @@ import { repos } from "@repo/db";
/* ---------- Initialize platform (runtime + infra + system) ---------- */
await initPlatform(resolvePlatformConfig());
await repos.configurationSecrets.backfillLegacy();
export const app = new Hono();
@@ -11,11 +11,28 @@
*/
import { encrypt, decrypt, decryptEnvMap } from "@repo/platform/engine/lib/encryption";
import { encryptSecretField, decryptSecretField } from "@repo/platform/engine/lib/credential-encryption";
import {
encryptSecretField,
decryptSecretField,
} from "@repo/platform/engine/lib/credential-encryption";
import { createConfigurationSecrets, SERVICE_SECRET_FIELDS } from "@repo/db/configuration-secrets";
import type { SecretColumn } from "./secret-registry";
import type { SecretEntry } from "./types";
const configuration = createConfigurationSecrets({ encrypt, decrypt });
function configurationCell(spec: SecretColumn, cell: unknown, direction: "open" | "seal"): unknown {
if (spec.sqlName === "deployment" && spec.column === "meta") {
return direction === "open"
? configuration.openDeploymentMeta(cell)
: configuration.sealDeploymentMeta(cell);
}
if (spec.sqlName === "service" && SERVICE_SECRET_FIELDS.some((key) => key === spec.column)) {
return direction === "open" ? configuration.openJson(cell) : configuration.sealJson(cell);
}
return structuredClone(cell);
}
/** Decrypt one stored cell → plaintext entry, or null if empty/absent. */
export function extractPlaintext(
spec: SecretColumn,
@@ -27,7 +44,7 @@ export function extractPlaintext(
switch (spec.scheme) {
case "json":
return { ...base, scheme: "json", json: structuredClone(cell) };
return { ...base, scheme: "json", json: configurationCell(spec, cell, "open") };
case "scalar": {
if (typeof cell !== "string" || cell === "") return null;
return { ...base, scheme: "scalar", value: decrypt(cell) };
@@ -73,7 +90,7 @@ export function sealForInstance(
): unknown {
switch (spec.scheme) {
case "json":
return structuredClone(entry.json);
return configurationCell(spec, entry.json, "seal");
case "scalar":
return entry.value != null ? encrypt(entry.value) : null;
case "enc1":
@@ -1,8 +1,8 @@
/**
* Maps every encrypted column (the single source of truth in @repo/db
* `ENCRYPTED_COLUMNS`) to the crypto scheme used to seal it at rest. This
* drives the export decrypt and the import re-encrypt. Kept in `apps/api`
* because the crypto helpers live here and `packages/db` cannot import them.
* drives export decryption and import re-encryption. The transfer coordinator
* binds the shared codecs to the source or destination installation's key.
*
* A build-time assertion below fails fast if `ENCRYPTED_COLUMNS` gains an
* entry this registry doesn't know how to (de)crypt.
@@ -89,10 +89,9 @@ const SCHEME_BY_KEY: Record<string, { table: AnyTable; scheme: SecretScheme }> =
},
};
// Kept separate from ENCRYPTED_COLUMNS: tenant/cloud promotion still transports
// ordinary Compose configuration directly. File/direct control-plane transfers
// seal it because inline env values and mounted files may contain credentials.
const PLAINTEXT_CONFIG_COLUMNS = [
// File/direct transfers also remove non-encrypted sensitive configuration. The
// service fields are included here to keep legacy export redaction identical.
const TRANSFER_CONFIG_COLUMNS = [
{ table: "deployment", column: "meta" },
{ table: "service", column: "environment" },
{ table: "service", column: "buildArgs" },
@@ -109,7 +108,12 @@ const PLAINTEXT_CONFIG_COLUMNS = [
export const SECRET_COLUMNS: readonly SecretColumn[] = [
...ENCRYPTED_COLUMNS,
...PLAINTEXT_CONFIG_COLUMNS,
...TRANSFER_CONFIG_COLUMNS.filter(
(spec) =>
!ENCRYPTED_COLUMNS.some(
(encrypted) => encrypted.table === spec.table && encrypted.column === spec.column,
),
),
].map((spec) => {
const key = `${spec.table}.${spec.column}`;
const meta = SCHEME_BY_KEY[key];
@@ -129,7 +133,7 @@ export const SECRET_COLUMNS: readonly SecretColumn[] = [
export function stripTransferSecrets(tables: DatabaseDump["tables"]): void {
stripEncryptedInPlace(tables);
for (const spec of PLAINTEXT_CONFIG_COLUMNS) {
for (const spec of TRANSFER_CONFIG_COLUMNS) {
for (const row of tables[spec.table] ?? []) {
if (spec.table === "deployment" && spec.column === "meta") {
// Frozen Compose services can contain inline credentials. Keep only
@@ -0,0 +1,162 @@
/** Real PostgreSQL lock races: startup conversion must not replace a newer edit. */
import { afterAll, beforeAll, expect, it, vi } from "vitest";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { resolve } from "node:path";
import {
createDatabase,
createRepositories,
schema,
type DatabaseConnection,
} from "@repo/db/factory";
import { createEncryption } from "@repo/db/encryption";
import { createConfigurationSecrets } from "@repo/db/configuration-secrets";
import { describeDockerE2E, requireDocker } from "../helpers/docker-e2e";
const exec = promisify(execFile);
const container = `openship-e2e-config-secrets-${process.pid}`;
const encryption = createEncryption("configuration-e2e-key-844");
const codec = createConfigurationSecrets(encryption);
let connection: DatabaseConnection;
let repos: ReturnType<typeof createRepositories>;
describeDockerE2E("encrypted configuration conversion (GH-844, real PostgreSQL)", () => {
beforeAll(async () => {
await requireDocker();
await exec(
"docker",
[
"run",
"-d",
"--name",
container,
"-e",
"POSTGRES_PASSWORD=config-test-password",
"-p",
"127.0.0.1::5432",
"postgres:16-alpine",
],
{ timeout: 120_000 },
);
const port = (await exec("docker", ["port", container, "5432/tcp"])).stdout
.trim()
.split(":")
.at(-1);
connection = await createDatabase({
driver: "pg",
url: `postgresql://postgres:config-test-password@127.0.0.1:${port}/postgres`,
migrationsDir: resolve(import.meta.dirname, "../../../../packages/db/drizzle"),
poolMax: 4,
registerExitHook: false,
});
repos = createRepositories(connection.db, encryption);
await connection.db.insert(schema.organization).values({ id: "org", name: "Config test" });
await connection.db
.insert(schema.projectGroup)
.values({ id: "group", organizationId: "org", name: "Test", slug: "test" });
await connection.db
.insert(schema.project)
.values({
id: "project",
groupId: "group",
organizationId: "org",
name: "Test",
slug: "test",
});
});
afterAll(async () => {
try {
await connection?.close();
} finally {
encryption.close();
await exec("docker", ["rm", "-fv", container]).catch(() => {});
}
});
it.each(["service", "deployment"] as const)(
"does not overwrite a concurrent %s edit",
async (table) => {
const id = `legacy-${table}`;
if (table === "service") {
await connection.db
.insert(schema.service)
.values({
id,
projectId: "project",
name: "web",
environment: { PASSWORD: "legacy-844" },
});
} else {
await connection.db
.insert(schema.deployment)
.values({
id,
projectId: "project",
organizationId: "org",
branch: "main",
status: "ready",
meta: {
serverId: "before",
composeServices: [{ name: "web", environment: { PASSWORD: "legacy-844" } }],
},
});
}
const locker = await connection.pool!.connect();
let pending: ReturnType<typeof repos.configurationSecrets.backfillLegacy> | undefined;
try {
await locker.query("BEGIN");
// Identifiers are a fixed test union; only the id/value is user-shaped.
await locker.query(`SELECT id FROM "${table}" WHERE id = $1 FOR UPDATE`, [id]);
pending = repos.configurationSecrets.backfillLegacy();
await vi.waitFor(
async () => {
const blocked = await connection.pool!.query(
"SELECT 1 FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND query LIKE $1",
[`update "${table}"%`],
);
expect(blocked.rowCount).toBeGreaterThan(0);
},
{ timeout: 15_000, interval: 50 },
);
if (table === "service") {
await locker.query('UPDATE "service" SET environment = $1::jsonb WHERE id = $2', [
JSON.stringify(codec.sealJson({ PASSWORD: "rotated-844" })),
id,
]);
} else {
const updated = codec.sealDeploymentMeta({
serverId: "after",
composeServices: [{ name: "web", environment: { PASSWORD: "rotated-844" } }],
});
await locker.query('UPDATE "deployment" SET meta = $1::jsonb WHERE id = $2', [
JSON.stringify(updated),
id,
]);
}
await locker.query("COMMIT");
expect(await pending).toEqual({ services: 0, deployments: 0 });
const raw = (await connection.pool!.query(`SELECT * FROM "${table}" WHERE id = $1`, [id]))
.rows[0];
expect(JSON.stringify(raw)).not.toContain("rotated-844");
if (table === "service") {
expect((await repos.service.findById(id))?.environment).toEqual({
PASSWORD: "rotated-844",
});
} else {
expect((await repos.deployment.findById(id))?.meta).toEqual({
serverId: "after",
composeServices: [{ name: "web", environment: { PASSWORD: "rotated-844" } }],
});
}
expect(await repos.configurationSecrets.backfillLegacy()).toEqual({
services: 0,
deployments: 0,
});
} finally {
await locker.query("ROLLBACK").catch(() => {});
locker.release();
await pending?.catch(() => {});
}
},
);
});
@@ -9,7 +9,8 @@
*/
import { createHash } from "node:crypto";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { spawn, type ChildProcessByStdio } from "node:child_process";
import type { Readable } from "node:stream";
import { mkdtemp, rm } from "node:fs/promises";
import { createServer, request as httpRequest, type Server } from "node:http";
import { tmpdir } from "node:os";
@@ -35,7 +36,7 @@ const FILE_SECRET_VALUE = "file-resume-secret-816-✓";
const FILE_PASSPHRASE = "issue-816-http-e2e-passphrase";
interface RunningApi {
child: ChildProcessWithoutNullStreams;
child: ChildProcessByStdio<null, Readable, Readable>;
baseUrl: string;
dbDir: string;
port: number;
@@ -367,6 +368,26 @@ it("transfers multiple HTTP chunks atomically and resumes a file import after re
await mergeEnv(source.baseUrl, project.id, [
{ key: "E2E_SECRET", value: SECRET_VALUE, isSecret: true },
]);
const inlineSecret = "inline-transfer-844-✓";
const buildSecret = "build-transfer-844-✓";
const fileSecret = "mounted-transfer-844-✓";
const created = await jsonRequest<{ service: { id: string; buildArgs: Record<string, string> } }>(
source.baseUrl,
`/api/projects/${project.id}/services`,
{
method: "POST",
body: JSON.stringify({
name: "web",
kind: "compose",
image: "busybox:1.37.0",
exposed: false,
environment: { PASSWORD: inlineSecret },
buildArgs: { TOKEN: buildSecret },
advanced: { files: [{ path: "/run/config", content: fileSecret }] },
}),
},
);
expect(created.service.buildArgs.TOKEN).toBe("••••••••");
const retryProxy = await startRetryProxy(destination.port);
const receive = await jsonRequest<{ code: string }>(
@@ -404,6 +425,21 @@ it("transfers multiple HTTP chunks atomically and resumes a file import after re
expect(masked.data).toContainEqual(
expect.objectContaining({ key: "E2E_SECRET", value: "••••••••", isSecret: true }),
);
const servicePath = `/api/projects/${project.id}/services/${created.service.id}`;
const serviceRead = await jsonRequest<{
service: { environment: Record<string, string>; buildArgs: Record<string, string> };
}>(destination.baseUrl, servicePath);
expect(serviceRead.service.environment.PASSWORD).toBe("••••••••");
expect(serviceRead.service.buildArgs.TOKEN).toBe("••••••••");
const revealed = await jsonRequest<{ environment: Record<string, string> }>(
destination.baseUrl,
`${servicePath}/env-reveal`,
{
method: "POST",
body: JSON.stringify({ keys: ["PASSWORD"] }),
},
);
expect(revealed.environment.PASSWORD).toBe(inlineSecret);
const destinationExport = await jsonRequest<DataTransferFile>(
destination.baseUrl,
@@ -416,6 +452,19 @@ it("transfers multiple HTTP chunks atomically and resumes a file import after re
expect(
findSecret(openTransferSecrets(destinationExport.secrets, FILE_PASSPHRASE), SECRET_VALUE),
).toBe(true);
const transferredConfig = openTransferSecrets(destinationExport.secrets, FILE_PASSPHRASE)!;
for (const secret of [inlineSecret, buildSecret, fileSecret]) {
expect(JSON.stringify(destinationExport)).not.toContain(secret);
expect(JSON.stringify(transferredConfig)).toContain(secret);
}
expect(transferredConfig.entries).toContainEqual(
expect.objectContaining({
table: "service",
id: created.service.id,
column: "buildArgs",
json: { TOKEN: buildSecret },
}),
);
// File upload uses the same import boundary. Upload one chunk, restart the
// destination process against the same DB, then resume and finalize.
@@ -35,7 +35,7 @@ import {
createHostExecutor,
type CommandExecutor,
} from "@repo/adapters";
import { repos } from "@repo/db";
import { db, eq, repos, schema } from "@repo/db";
import { LOCAL_HOST_PORT_TARGET } from "@repo/platform/engine/lib/host-port-target";
import { describeDockerE2E, requireDocker } from "../helpers/docker-e2e";
import {
@@ -46,7 +46,7 @@ import {
seedServiceDeployment,
} from "../helpers/seed";
const BASE_IMAGE = "busybox:latest";
const BASE_IMAGE = "busybox:1.37.0";
const WEB_V1 = "openship/e2e-compose-web:v1";
const WEB_V2 = "openship/e2e-compose-web:v2";
const API_V1 = "openship/e2e-compose-api:v1";
@@ -88,7 +88,7 @@ describeDockerE2E("compose rollback cycle through the real entry point", () => {
let apiPort = 0;
let contextDir = "";
let rollbackMod: typeof import("../../src/modules/deployments/rollback");
let rollbackMod: typeof import("@repo/platform/engine/modules/deployments/rollback/index");
const buildImage = async (tag: string, body: string) => {
await writeFile(
@@ -185,6 +185,13 @@ describeDockerE2E("compose rollback cycle through the real entry point", () => {
}),
};
});
// These factories close over this fixture's real runtime, after it exists.
for (const module of ["@repo/platform/engine/lib/platform-config", "@repo/platform/engine/lib/resource-access"]) {
vi.doMock(module, async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return { ...actual, platform: () => localPlatform.platform };
});
}
vi.doMock("../../src/lib/controller-helpers", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return { ...actual, platform: () => localPlatform.platform };
@@ -196,6 +203,7 @@ describeDockerE2E("compose rollback cycle through the real entry point", () => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
...actual,
getPlatform: () => localPlatform.platform,
edgeProxyFor: () => ({
listLoopbackUpstreamPortsStrict: async () => new Set<number>(),
}),
@@ -236,8 +244,8 @@ describeDockerE2E("compose rollback cycle through the real entry point", () => {
runtimeMode: "docker" as const,
serviceDeploymentMode: "services" as const,
composeServices: [
{ id: web.id, name: "web", kind: "compose", image: webImage, enabled: true, ports: [`${webPort}:${SVC_PORT}`] },
{ id: api.id, name: "api", kind: "compose", image: API_V1, enabled: true, ports: [`${apiPort}:${SVC_PORT}`] },
{ id: web.id, name: "web", kind: "compose", image: webImage, environment: { ROLLBACK_SECRET: webImage === WEB_V1 ? "original-844" : "rotated-844" }, enabled: true, ports: [`${webPort}:${SVC_PORT}`] },
{ id: api.id, name: "api", kind: "compose", image: API_V1, environment: { API_SECRET: "api-secret-844" }, enabled: true, ports: [`${apiPort}:${SVC_PORT}`] },
],
});
@@ -339,6 +347,12 @@ describeDockerE2E("compose rollback cycle through the real entry point", () => {
// a null name here would silently rebuild the whole stack next time.
expect(byName.get("web")).toBe(WEB_V1);
expect(byName.get("api")).toBe(API_V1);
const webRow = rows.find(row => row.serviceName === "web")!;
const live = await runtime.docker.getContainer(webRow.containerId!).inspect();
expect(live.Config.Env).toContain("ROLLBACK_SECRET=original-844");
const stored = await db.query.deployment.findFirst({ where: eq(schema.deployment.id, restore.id) });
expect((stored!.meta as Record<string, unknown>).composeServices).toEqual(expect.stringMatching(/^openship:config:v1:/));
expect(JSON.stringify(stored!.meta)).not.toContain("original-844");
}, 600_000);
it("cancels a lost pre-activation host callback, preserves v1, and lets the next real rollout finish", async () => {
@@ -518,14 +532,3 @@ describeDockerE2E("compose rollback cycle through the real entry point", () => {
throw new Error(`deploy never finished (last status: ${last || "no new row"})`);
}
});
// The application seams moved with the shared engine.
vi.doMock("@repo/platform/engine/lib/platform-config", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return { ...actual, platform: () => localPlatform.platform };
});
vi.doMock("@repo/platform/engine/lib/resource-access", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return { ...actual, platform: () => localPlatform.platform };
});
@@ -1,3 +1,4 @@
import { createEncryption } from "@repo/db/encryption";
import { afterAll, beforeAll, beforeEach, expect, it } from "vitest";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
@@ -52,7 +53,7 @@ describeDockerE2E("update cache lock deadlines (GH-880, real PostgreSQL)", () =>
poolMax: 3,
registerExitHook: false,
});
repos = createRepositories(connection.db);
repos = createRepositories(connection.db, createEncryption("repository-test-secret"));
await connection.db.insert(schema.organization).values({ id: "org", name: "Cache test" });
await connection.db
.insert(schema.projectGroup)
+2 -2
View File
@@ -11,8 +11,8 @@ import { db, eq, schema } from "@repo/db";
// Skip the full zod-validated env (which refuses to load outside desktop mode
// without INTERNAL_TOKEN); the crypto helpers only need BETTER_AUTH_SECRET.
vi.mock("@repo/platform/engine/config/env", () => ({
env: { BETTER_AUTH_SECRET: "test-secret-for-data-transfer-unit-tests", CLOUD_MODE: false },
vi.mock("@repo/platform/engine/config/env", async () => ({
env: { BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? (await import("@repo/db/encryption")).DEFAULT_ENCRYPTION_SECRET, CLOUD_MODE: false },
}));
import { decrypt, encrypt, encryptBytesWithKey, encryptWithKey } from "@repo/platform/engine/lib/encryption";
@@ -1,3 +1,5 @@
import { createConfigurationSecrets } from "@repo/db/configuration-secrets";
import { createEncryption } from "@repo/db/encryption";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -208,6 +210,9 @@ const composeServices = [
* seam. The rest of a deployment stays mocked (no clone/build/Docker), while
* service writes are stateful and observable across the full trigger boundary.
*/
const testEncryption = createEncryption("repository-test-secret");
const configuration = createConfigurationSecrets(testEncryption);
function installStatefulComposeRepo<T extends Record<string, unknown>>(initial: T) {
let stored = structuredClone(initial);
const writes: Array<Record<string, unknown>> = [];
@@ -216,16 +221,16 @@ function installStatefulComposeRepo<T extends Record<string, unknown>>(initial:
update: () => ({
set: (data: Record<string, unknown>) => ({
where: async () => {
writes.push(data);
writes.push(configuration.openService(data));
stored = { ...stored, ...data } as T;
},
}),
}),
} as unknown as Database;
const real = createServiceRepo(db);
const real = createServiceRepo(db, testEncryption);
repos.service.listByProject.mockImplementation(real.listByProject.bind(real));
repos.service.reconcileFromCompose.mockImplementation(real.reconcileFromCompose.bind(real));
return { stored: () => stored, writes };
return { stored: () => configuration.openService(stored), writes };
}
const criticalApiEnvironment = {
@@ -1,3 +1,4 @@
import { createEncryption } from "@repo/db/encryption";
import { describe, expect, it, beforeEach, vi } from "vitest";
import { createDeploymentRepo } from "../../../../../packages/db/src/repos/deployment.repo";
@@ -437,7 +438,7 @@ describe("repo: finishBuildSession sheds the payload, never the status", () => {
}),
}),
};
return { writes, repo: createDeploymentRepo(db as never) };
return { writes, repo: createDeploymentRepo(db as never, createEncryption("repository-test-secret")) };
}
it("retries with a marker payload and keeps status + duration", async () => {
@@ -1,11 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { db, eq, schema, sql } from "@repo/db";
import { db, eq, schema, sql, repos } from "@repo/db";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
vi.mock("@repo/platform/engine/config/env", () => ({
env: { BETTER_AUTH_SECRET: "project-transfer-test-key", CLOUD_MODE: false },
vi.mock("@repo/platform/engine/config/env", async () => ({
env: { BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? (await import("@repo/db/encryption")).DEFAULT_ENCRYPTION_SECRET, CLOUD_MODE: false },
}));
vi.mock("../../src/lib/database-runtime-state", () => ({
reconcileRuntimeStateAfterImport: vi.fn(),
@@ -434,12 +434,15 @@ describe("project control-plane export and import", () => {
.where(eq(schema.envVar.id, "env_service"));
expect(environment!.serviceId).toBe("service_web");
expect(decrypt(environment!.value)).toBe("service-secret");
const [service] = await db.select().from(schema.service);
const [storedService] = await db.select().from(schema.service);
expect(typeof storedService!.environment).toBe("string");
const service = await repos.service.findById(storedService!.id);
expect(service!.environment).toEqual({ INLINE_PASSWORD: "inline-secret" });
expect(service!.advanced).toEqual({
files: [{ path: "/run/secrets/key", content: "private-file-contents" }],
});
const [deployment] = await db.select().from(schema.deployment);
const [storedDeployment] = await db.select().from(schema.deployment);
const deployment = await repos.deployment.findById(storedDeployment!.id);
expect(deployment!.meta).toMatchObject({
organizationId: context.organizationId,
serverId: "target_server",
@@ -590,7 +593,8 @@ describe("project control-plane export and import", () => {
serviceId: "existing_service",
serviceIds: ["existing_service"],
});
const [deployment] = await db.select().from(schema.deployment);
const [storedDeployment] = await db.select().from(schema.deployment);
const deployment = await repos.deployment.findById(storedDeployment!.id);
expect(deployment!.meta).toMatchObject({
targetServiceIds: ["existing_service"],
composeServices: [
@@ -735,7 +739,8 @@ describe("project control-plane export and import", () => {
const [project] = await db.select().from(schema.project).where(eq(schema.project.id, "web"));
expect(project!.activeDeploymentId).toBeNull();
expect(project!.disabledAt).toBeInstanceOf(Date);
const [deployment] = await db.select().from(schema.deployment);
const [storedDeployment] = await db.select().from(schema.deployment);
const deployment = await repos.deployment.findById(storedDeployment!.id);
expect(deployment!.containerId).toBeNull();
expect(deployment!.meta).toBeNull();
const [domain] = await db.select().from(schema.domain);
@@ -52,12 +52,23 @@ Some values are sensitive — passwords, API keys, tokens. For each of those, pr
row to **mark it as secret**. A secret's value is stored the same way, but from then on it shows as dots
(`••••••••`) instead of plain text, so it stays hidden whenever you reopen the editor.
<Callout title="Everything is encrypted at rest" type="info">
<Callout title="Environment values are encrypted at rest" type="info">
Whether or not you mark a variable secret, Openship **encrypts every value before saving it**.
Marking a value secret adds the on-screen masking on top — it controls what *you* can see later,
not whether it's protected on disk.
</Callout>
Inline service environment values and build arguments are also encrypted, including their copies in
saved deployment snapshots and Compose drift baselines. Existing plaintext service configuration is
converted when the API or native engine starts after upgrading. Keep your `BETTER_AUTH_SECRET` with
your database backup: it is needed to read these values. To downgrade to a version from before this
protection, restore its matching database backup as well.
When moving to an installation with a different key, use **Settings → Data Transfer** with secrets
included. It re-encrypts the configuration for the destination. Transfers or recovery manifests that
omit secrets also omit inline environment, build arguments and protected service configuration; set
those values again before deploying.
</Step>
<Step>
+8
View File
@@ -24,6 +24,14 @@
"./lock": {
"types": "./src/pglite-lock.ts",
"import": "./src/pglite-lock.ts"
},
"./encryption": {
"types": "./src/encryption.ts",
"import": "./src/encryption.ts"
},
"./configuration-secrets": {
"types": "./src/configuration-secrets.ts",
"import": "./src/configuration-secrets.ts"
}
},
"scripts": {
+10 -2
View File
@@ -1,6 +1,7 @@
/** HTTP/CLI process composition. Native embedders import @repo/db/factory. */
import { createDatabase, PG_POOL_MAX, type DatabaseOptions } from "./connection";
import { resolve } from "node:path";
import { createEncryption, DEFAULT_ENCRYPTION_SECRET } from "./encryption";
export { PG_POOL_MAX, type Database, type DatabaseTransaction, type Driver } from "./connection";
export { awaitPgReady } from "./pg-ready";
@@ -84,9 +85,16 @@ const options: DatabaseOptions = {
lockWaitMs: process.env.OPENSHIP_NATIVE === "true" ? 0 : undefined,
takeover: process.env.OPENSHIP_NATIVE !== "true" && (process.execArgv.includes("--watch") || process.env.OPENSHIP_DEV_LOCK_TAKEOVER === "true"),
};
const connection = await createDatabase(options);
export const storageEncryption = createEncryption(process.env.BETTER_AUTH_SECRET ?? DEFAULT_ENCRYPTION_SECRET);
const connection = await createDatabase(options).catch(error => {
storageEncryption.close();
throw error;
});
export const db = connection.db;
export const closeDb = connection.close;
export async function closeDb(): Promise<void> {
try { await connection.close(); }
finally { storageEncryption.close(); }
}
export const getDriver = () => connection.driver;
export function getPgPool() {
if (!connection.pool) throw new Error("Postgres pool is unavailable (active driver is not 'pg')");
+89
View File
@@ -0,0 +1,89 @@
/**
* Storage codec for JSON configuration containing inline secrets. Repositories
* expose ordinary domain objects; only the persisted JSONB cell is a string.
* Legacy objects remain readable until the bounded startup backfill seals them.
* No environment access, connection, or process-global key lives in this module.
*/
import type { Encryption } from "./encryption";
export type ConfigurationEncryption = Pick<Encryption, "encrypt" | "decrypt">;
export const CONFIGURATION_PREFIX = "openship:config:v1:";
export const SERVICE_SECRET_FIELDS = [
"environment",
"buildArgs",
"advanced",
"importedSpec",
"driftSpec",
] as const;
// Keep routing identity and composeDeployment.decision queryable in SQL. The
// service snapshot includes env, build args, advanced inline files and baselines.
export const DEPLOYMENT_SECRET_FIELDS = ["composeServices"] as const;
export function isPlainConfiguration(value: unknown): value is Record<string, unknown> | unknown[] {
return value !== null && typeof value === "object" && Object.keys(value).length > 0;
}
export function createConfigurationSecrets(encryption: ConfigurationEncryption) {
function sealJson(value: unknown): unknown {
if (value === undefined || value === null) return value;
if (typeof value !== "object") throw new Error("Expected JSON configuration before encryption");
if (!isPlainConfiguration(value)) return value;
return CONFIGURATION_PREFIX + encryption.encrypt(JSON.stringify(value));
}
function openJson(value: unknown): unknown {
if (value === undefined || value === null || typeof value === "object") return value;
try {
if (typeof value !== "string" || !value.startsWith(CONFIGURATION_PREFIX)) throw new Error();
const plain: unknown = JSON.parse(
encryption.decrypt(value.slice(CONFIGURATION_PREFIX.length)),
);
if (plain === null || typeof plain !== "object") throw new Error();
return plain;
} catch {
// Never pass ciphertext to a runtime, expose it in errors, or turn a wrong
// key into an empty configuration that could overwrite the stored secret.
throw new Error("Unable to decrypt stored configuration with this installation's key");
}
}
// This is the storage/domain boundary: schema types describe the plaintext
// object callers use, while JSONB also accepts the sealed string on disk.
function fields<T extends object>(
row: T,
keys: readonly string[],
map: (value: unknown) => unknown,
): T {
const copy = { ...row } as Record<string, unknown>;
for (const key of keys) if (copy[key] !== undefined) copy[key] = map(copy[key]);
return copy as T;
}
function sealService<T extends object>(row: T): T {
return fields(row, SERVICE_SECRET_FIELDS, sealJson);
}
function openService<T extends object | undefined>(row: T): T {
return row === undefined ? row : (fields(row, SERVICE_SECRET_FIELDS, openJson) as T);
}
function meta(value: unknown, map: (value: unknown) => unknown): unknown {
if (value === null || value === undefined) return value;
if (typeof value !== "object" || Array.isArray(value))
throw new Error("Invalid deployment configuration");
return fields(value, DEPLOYMENT_SECRET_FIELDS, map);
}
function sealDeployment<T extends object>(row: T): T {
return fields(row, ["meta"], (value) => meta(value, sealJson));
}
function openDeployment<T extends object | undefined>(row: T): T {
return row === undefined ? row : (fields(row, ["meta"], (value) => meta(value, openJson)) as T);
}
return {
sealJson,
openJson,
sealService,
openService,
sealDeployment,
openDeployment,
sealDeploymentMeta: (value: unknown) => meta(value, sealJson),
openDeploymentMeta: (value: unknown) => meta(value, openJson),
};
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { createEncryption } from "./encryption";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createDatabase, createRepositories, schema, type DatabaseConnection } from "./factory";
import { createPgliteLock } from "./pglite-lock";
@@ -21,8 +22,8 @@ describe("instance-owned database composition", () => {
it("keeps repositories with the same identifiers isolated and closes only owned resources", async () => {
await first.db.insert(schema.user).values({ id: "same-user", name: "First", email: "first@example.test" });
await second.db.insert(schema.user).values({ id: "same-user", name: "Second", email: "second@example.test" });
const a = createRepositories(first.db);
const b = createRepositories(second.db);
const a = createRepositories(first.db, createEncryption("repository-test-secret"));
const b = createRepositories(second.db, createEncryption("repository-test-secret"));
expect((await a.user.findById("same-user"))?.name).toBe("First");
expect((await b.user.findById("same-user"))?.name).toBe("Second");
await Promise.all([first.close(), first.close()]);
+3
View File
@@ -28,6 +28,7 @@ import { sql, eq, inArray, count, getTableColumns } from "drizzle-orm";
import { getTableConfig, type PgTable } from "drizzle-orm/pg-core";
import { db, getDriver, type DatabaseTransaction } from "./client";
import * as schema from "./schema";
import { SERVICE_SECRET_FIELDS, DEPLOYMENT_SECRET_FIELDS } from "./configuration-secrets";
export const DUMP_FORMAT_VERSION = 1;
@@ -948,6 +949,8 @@ export const ENCRYPTED_COLUMNS: ReadonlyArray<EncryptedColumnSpec> = [
{ table: "instance_settings", column: "tunnelToken" },
{ table: "instance_settings", column: "ghDeviceTokenEncrypted" },
{ table: "deployment", column: "envVars" },
...SERVICE_SECRET_FIELDS.map(column => ({ table: "service", column })),
{ table: "deployment", column: "meta", secretPaths: [...DEPLOYMENT_SECRET_FIELDS] },
{ table: "notification_channel", column: "config", secretPaths: ["hmacSecret", "webhookUrl", "botToken"] },
];
+129
View File
@@ -0,0 +1,129 @@
/**
* Application-level encryption for sensitive data (env vars, secrets).
*
* Uses AES-256-GCM (authenticated encryption) via Node.js built-in crypto.
* The encryption key is derived from BETTER_AUTH_SECRET (which every install
* already has) - no extra env var needed.
*
* Format: base64( iv:16 || authTag:16 || ciphertext )
*
* Usage:
* import { encrypt, decrypt } from "../lib/encryption";
* const sealed = encrypt("my secret");
* const plain = decrypt(sealed); // "my secret"
*/
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
export const DEFAULT_ENCRYPTION_SECRET = "change-me-in-production";
// ─── Key derivation ──────────────────────────────────────────────────────────
const ALGORITHM = "aes-256-gcm" as const;
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Seal a plaintext under an EXPLICIT 32-byte key. The single AES-256-GCM
* implementation for the whole app — `encrypt()` uses the instance key,
* while callers with a different key source (e.g. a passphrase-derived key
* for data export) reuse this so the cipher/format never diverges.
* Returns base64( iv:16 || authTag:16 || ciphertext ).
*/
export function encryptWithKey(key: Buffer, plaintext: string): string {
return encryptBytesWithKey(key, Buffer.from(plaintext, "utf8")).toString("base64");
}
/** Binary twin used by bounded transfer chunks; keeps bytes binary on the wire. */
export function encryptBytesWithKey(key: Buffer, plaintext: Uint8Array): Buffer {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, encrypted]);
}
/**
* Open a value produced by `encryptWithKey()` (or `encrypt()`), using an
* explicit key. Throws if the data is tampered with or the key is wrong.
*/
export function decryptWithKey(key: Buffer, sealed: string): string {
return decryptBytesWithKey(key, Buffer.from(sealed, "base64")).toString("utf8");
}
/** Open one binary AES-GCM chunk without converting its payload through UTF-8. */
export function decryptBytesWithKey(key: Buffer, sealed: Uint8Array): Buffer {
const packed = Buffer.from(sealed);
if (packed.length < IV_LENGTH + AUTH_TAG_LENGTH) {
throw new Error("Invalid encrypted data: too short");
}
const iv = packed.subarray(0, IV_LENGTH);
const authTag = packed.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
const ciphertext = packed.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
/** One installation's secrets. The existing ciphertext format and key derivation are unchanged. */
export function createEncryption(secret: string) {
if (typeof secret !== "string" || !secret)
throw new TypeError("An explicit encryption secret is required");
const key = createHash("sha256").update(secret).digest();
let closed = false;
function getKey(): Buffer {
if (closed) throw new Error("Encryption context is closed");
return key;
}
/**
* Encrypt a plaintext string under the instance key.
* Returns a base64-encoded string containing IV + auth tag + ciphertext.
*/
function encrypt(plaintext: string): string {
return encryptWithKey(getKey(), plaintext);
}
/**
* Decrypt a value produced by `encrypt()`.
* Throws if the data is tampered with or the key is wrong.
*/
function decrypt(sealed: string): string {
return decryptWithKey(getKey(), sealed);
}
/**
* Decrypt a Record<string, encryptedValue> → Record<string, plaintext>.
* On decryption failure for a key, that key is omitted (not silently passed through).
*/
function decryptEnvMap(
encrypted: Record<string, string>,
onError?: (key: string, err: unknown) => void,
): Record<string, string> {
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(encrypted)) {
try {
result[k] = decrypt(v);
} catch (err) {
onError?.(k, err);
// Omit keys that fail decryption - never leak ciphertext into containers
}
}
return result;
}
return Object.freeze({
encrypt,
decrypt,
decryptEnvMap,
close() {
if (!closed) {
closed = true;
key.fill(0);
}
},
});
}
export type Encryption = ReturnType<typeof createEncryption>;
@@ -1,3 +1,5 @@
import { createConfigurationSecrets } from "../configuration-secrets";
import { createEncryption } from "../encryption";
import { describe, expect, it } from "vitest";
import type { Database } from "../client";
import {
@@ -7,6 +9,9 @@ import {
type ParsedComposeService,
} from "./service.repo";
const testEncryption = createEncryption("repository-test-secret");
const configuration = createConfigurationSecrets(testEncryption);
const fullEnvironment = {
NODE_ENV: "production",
PORT: "4000",
@@ -56,16 +61,16 @@ function harness(initial = existingService()) {
update: () => ({
set: (data: Record<string, unknown>) => ({
where: async () => {
writes.push(data);
writes.push(configuration.openService(data));
stored = { ...stored, ...data };
},
}),
}),
} as unknown as Database;
return {
repo: createServiceRepo(db),
repo: createServiceRepo(db, testEncryption),
writes,
stored: () => stored,
stored: () => configuration.openService(stored),
};
}
@@ -0,0 +1,252 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { createDatabase, createRepositories, schema, type DatabaseConnection } from "../factory";
import { createEncryption } from "../encryption";
import {
CONFIGURATION_PREFIX,
createConfigurationSecrets,
SERVICE_SECRET_FIELDS,
} from "../configuration-secrets";
import { toComposeSpec } from "./service.repo";
describe("service configuration at rest (GH-844)", () => {
const encryption = createEncryption("configuration-test-installation-a");
const otherEncryption = createEncryption("configuration-test-installation-b");
const codec = createConfigurationSecrets(encryption);
let connection: DatabaseConnection;
let repos: ReturnType<typeof createRepositories>;
let sequence = 0;
beforeAll(async () => {
connection = await createDatabase({ driver: "pglite", dataDir: "memory://" });
repos = createRepositories(connection.db, encryption);
await connection.db.insert(schema.organization).values({ id: "org", name: "Test" });
});
afterAll(async () => {
await connection?.close();
encryption.close();
otherEncryption.close();
});
async function project() {
const slug = `app-${++sequence}`;
const group = await repos.projectGroup.create({ organizationId: "org", name: slug, slug });
return repos.project.create({ groupId: group.id, organizationId: "org", name: slug, slug });
}
const config = {
environment: {
PASSWORD: "inline-secret-844",
LITERAL: `${CONFIGURATION_PREFIX}literal-user-value`,
},
buildArgs: { TOKEN: "build-secret-844", INHERIT: null, EMPTY: "" },
advanced: { files: [{ path: "/run/config", content: "mounted-secret-844" }] },
};
async function storedService(id: string) {
return connection.db.query.service.findFirst({ where: eq(schema.service.id, id) });
}
it("seals live config and both drift baselines while every repository read returns usable values", async () => {
const app = await project();
const baseline = toComposeSpec(config);
const created = await repos.service.create({
projectId: app.id,
name: "web",
...config,
importedSpec: baseline,
driftSpec: baseline,
});
expect(created).toMatchObject(config);
const stored = await storedService(created.id);
for (const field of SERVICE_SECRET_FIELDS)
expect(stored![field]).toEqual(expect.stringMatching(/^openship:config:v1:/));
for (const secret of ["inline-secret-844", "build-secret-844", "mounted-secret-844"])
expect(JSON.stringify(stored)).not.toContain(secret);
const reads = [
await repos.service.findById(created.id),
await repos.service.findByName(app.id, "web"),
...(await repos.service.listByProject(app.id)),
...(await repos.service.listByProjectKind(app.id, "compose")),
...(await repos.service.listByProjects([app.id])).get(app.id)!,
];
for (const read of reads)
expect(read).toMatchObject({ ...config, importedSpec: baseline, driftSpec: baseline });
await repos.service.update(created.id, { environment: { PASSWORD: "rotated-844" } });
expect((await repos.service.findById(created.id))?.environment).toEqual({
PASSWORD: "rotated-844",
});
expect((await repos.service.findById(created.id))?.buildArgs).toEqual(config.buildArgs);
expect(JSON.stringify(await storedService(created.id))).not.toContain("rotated-844");
});
it("compares decrypted baselines when Compose changes and preserves the original frozen release", async () => {
const app = await project();
await repos.service.syncFromCompose(app.id, [{ name: "web", image: "app:1", ...config }], {
composeAuthoritative: true,
});
const before = (await repos.service.listByProject(app.id))[0]!;
const release = await repos.deployment.create({
projectId: app.id,
organizationId: "org",
branch: "main",
status: "ready",
meta: {
serverId: "server-844",
composeServices: [before],
composeDeployment: { decision: "pending" },
},
});
expect(release).toBeDefined();
const updated = await repos.service.reconcileFromCompose(app.id, [
{
name: "web",
image: "app:2",
...config,
environment: { ...config.environment, PASSWORD: "new-repo-secret-844" },
},
]);
expect(updated.driftedNames).toEqual([]);
expect((await repos.service.findById(before.id))?.environment?.PASSWORD).toBe(
"new-repo-secret-844",
);
const stored = await connection.db.query.deployment.findFirst({
where: eq(schema.deployment.id, release!.id),
});
expect(stored!.meta).toMatchObject({
serverId: "server-844",
composeServices: expect.stringMatching(/^openship:config:v1:/),
});
expect(JSON.stringify(stored!.meta)).not.toContain("inline-secret-844");
const historical = { meta: { composeServices: [expect.objectContaining(config)] } };
const reads = [
await repos.deployment.findById(release!.id),
await repos.deployment.findLatestReady(app.id, "production"),
await repos.deployment.findLatestByProject(app.id),
await repos.deployment.getLatestSuccessfulForBranch(app.id, "main"),
...(await repos.deployment.listByProject(app.id)).rows,
...(await repos.deployment.listReadyOrderedDesc(app.id)),
(await repos.deployment.findManyById([release!.id])).get(release!.id),
(await repos.deployment.findLatestByProjects([app.id])).get(app.id),
];
for (const read of reads) expect(read).toMatchObject(historical);
await repos.deployment.supersedePendingDecisions(app.id, "a-new-release");
expect((await repos.deployment.findById(release!.id))?.meta).toMatchObject({
composeDeployment: { decision: "superseded" },
composeServices: [expect.objectContaining(config)],
});
});
it("seals replacement deployment metadata without changing target identity or status rules", async () => {
const app = await project();
const dep = await repos.deployment.create({
projectId: app.id,
organizationId: "org",
branch: "main",
meta: { composeServices: [{ name: "web", ...config }] },
});
const meta = {
serverId: "remote",
composeServices: [{ name: "web", environment: { PASSWORD: "replacement-secret-844" } }],
};
expect(await repos.deployment.updateStatus(dep!.id, "building", { meta })).toBe(true);
expect((await repos.deployment.listInFlightByProject(app.id))[0]?.meta).toEqual(meta);
expect(
await repos.deployment.findInProgressByReleaseVersion(app.id, undefined),
).toBeUndefined();
expect(
(await repos.deployment.listByStatus("building")).find((row) => row.id === dep!.id)?.meta,
).toEqual(meta);
expect(
(await repos.deployment.listByOrganization("org")).rows.find((row) => row.id === dep!.id)
?.meta,
).toEqual(meta);
expect(await repos.deployment.cancelInFlight(dep!.id, { meta })).toBe(true);
expect(await repos.deployment.updateStatus(dep!.id, "ready", { meta })).toBe(false);
const raw = await connection.db.query.deployment.findFirst({
where: eq(schema.deployment.id, dep!.id),
});
expect(JSON.stringify(raw!.meta)).not.toContain("replacement-secret-844");
expect((await repos.deployment.findById(dep!.id))?.meta).toEqual(meta);
});
it("encrypts services created by atomic project cloning", async () => {
const cloned = await repos.project.createProjectWithRecords({
group: { organizationId: "org", name: "Cloned", slug: "cloned" },
project: { organizationId: "org", name: "Cloned", slug: "cloned" },
services: [{ sourceId: "source", row: { name: "web", ...config } }],
envVars: [],
});
const id = cloned.serviceIdBySourceId.source!;
expect(await repos.service.findById(id)).toMatchObject(config);
expect(JSON.stringify(await storedService(id))).not.toContain("inline-secret-844");
});
it("backfills multiple pages of legacy rows, keeps timestamps and is idempotent", async () => {
const app = await project();
const baseline = toComposeSpec(config);
await connection.db.insert(schema.service).values(
Array.from({ length: 103 }, (_, i) => ({
id: `legacy-${i}`,
name: `legacy-${i}`,
projectId: app.id,
...config,
importedSpec: baseline,
driftSpec: baseline,
})),
);
await connection.db
.insert(schema.deployment)
.values({
id: "legacy-release",
projectId: app.id,
organizationId: "org",
branch: "main",
status: "ready",
meta: { serverId: "old-server", composeServices: [{ name: "web", ...config }] },
});
const original = await storedService("legacy-0");
expect(await repos.service.findById("legacy-0")).toMatchObject(config);
expect(await repos.configurationSecrets.backfillLegacy()).toEqual({
services: 103,
deployments: 1,
});
const sealed = await storedService("legacy-0");
expect(sealed!.updatedAt).toEqual(original!.updatedAt);
expect(JSON.stringify(sealed)).not.toContain("inline-secret-844");
expect(await repos.service.findById("legacy-102")).toMatchObject(config);
expect((await repos.deployment.findById("legacy-release"))?.meta).toMatchObject({
serverId: "old-server",
composeServices: [expect.objectContaining(config)],
});
expect(await repos.configurationSecrets.backfillLegacy()).toEqual({
services: 0,
deployments: 0,
});
expect((await storedService("legacy-0"))!.environment).toBe(sealed!.environment);
});
it("fails closed on a wrong key or damaged ciphertext without altering saved values", async () => {
const app = await project();
const service = await repos.service.create({ projectId: app.id, name: "web", ...config });
const wrongRepos = createRepositories(connection.db, otherEncryption);
await expect(wrongRepos.service.findById(service.id)).rejects.toThrow(
"Unable to decrypt stored configuration",
);
const raw = await storedService(service.id);
const broken = String(raw!.environment).slice(0, -8) + "tampered";
await connection.db
.update(schema.service)
.set({ environment: broken as never })
.where(eq(schema.service.id, service.id));
await expect(repos.service.findById(service.id)).rejects.toThrow(
"Unable to decrypt stored configuration",
);
expect((await storedService(service.id))!.environment).toBe(broken);
});
it("never accepts a public string as trusted ciphertext and preserves empty defaults", () => {
const sealed = codec.sealJson(config.environment);
expect(() => codec.sealJson(sealed)).toThrow("Expected JSON configuration");
expect(codec.openJson(sealed)).toEqual(config.environment);
for (const value of [undefined, null, {}, []])
expect(codec.openJson(codec.sealJson(value))).toEqual(value);
expect(() => codec.openJson("openship:config:v99:unknown")).toThrow("Unable to decrypt");
});
});
@@ -0,0 +1,97 @@
import { and, asc, eq, gt, or, sql, type SQL } from "drizzle-orm";
import type { Database } from "../connection";
import { deployment, service } from "../schema";
import {
createConfigurationSecrets,
isPlainConfiguration,
SERVICE_SECRET_FIELDS,
DEPLOYMENT_SECRET_FIELDS,
type ConfigurationEncryption,
} from "../configuration-secrets";
/** No schema rewrite or unbounded transaction. New writers always seal; the
* compare-and-swap guards keep a boot-time backfill from losing concurrent edits. */
export function createConfigurationSecretsRepo(db: Database, encryption: ConfigurationEncryption) {
const codec = createConfigurationSecrets(encryption);
const nonemptyJson = (cell: SQL) =>
sql`jsonb_typeof(${cell}) in ('object', 'array') and ${cell} <> '{}'::jsonb and ${cell} <> '[]'::jsonb`;
return {
async backfillLegacy(): Promise<{ services: number; deployments: number }> {
const counts = { services: 0, deployments: 0 };
let after: string | undefined;
for (;;) {
const rows = await db
.select()
.from(service)
.where(
and(
after ? gt(service.id, after) : undefined,
or(...SERVICE_SECRET_FIELDS.map((key) => nonemptyJson(sql`${service[key]}`))),
),
)
.orderBy(asc(service.id))
.limit(100);
if (!rows.length) break;
for (const row of rows) {
const patch: Record<string, unknown> = {};
const unchanged: SQL[] = [eq(service.id, row.id)];
for (const key of SERVICE_SECRET_FIELDS) {
if (!isPlainConfiguration(row[key])) continue;
patch[key] = codec.sealJson(row[key]);
unchanged.push(
sql`${service[key]} is not distinct from ${JSON.stringify(row[key])}::jsonb`,
);
}
if (Object.keys(patch).length) {
const changed = await db
.update(service)
.set(patch)
.where(and(...unchanged))
.returning();
counts.services += changed.length;
}
}
after = rows.at(-1)!.id;
}
after = undefined;
for (;;) {
const rows = await db
.select({ id: deployment.id, meta: deployment.meta })
.from(deployment)
.where(
and(
after ? gt(deployment.id, after) : undefined,
or(
...DEPLOYMENT_SECRET_FIELDS.map((key) =>
nonemptyJson(sql`${deployment.meta}->${key}`),
),
),
),
)
.orderBy(asc(deployment.id))
.limit(100);
if (!rows.length) break;
for (const row of rows) {
// Leave fields already sealed by another code path untouched.
const next = { ...(row.meta as Record<string, unknown>) };
for (const key of DEPLOYMENT_SECRET_FIELDS) {
if (isPlainConfiguration(next[key])) next[key] = codec.sealJson(next[key]);
}
const changed = await db
.update(deployment)
.set({ meta: next })
.where(
and(
eq(deployment.id, row.id),
sql`${deployment.meta} is not distinct from ${JSON.stringify(row.meta)}::jsonb`,
),
)
.returning();
counts.deployments += changed.length;
}
after = rows.at(-1)!.id;
}
return counts;
},
};
}
@@ -1,3 +1,4 @@
import { createEncryption } from "../encryption";
import { describe, it, expect, beforeEach } from "vitest";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
@@ -25,8 +26,8 @@ async function freshDb() {
await client.exec("SET session_replication_role = replica;"); // skip FK seeding
return {
db,
projectRepo: createProjectRepo(db),
deploymentRepo: createDeploymentRepo(db),
projectRepo: createProjectRepo(db, createEncryption("repository-test-secret")),
deploymentRepo: createDeploymentRepo(db, createEncryption("repository-test-secret")),
};
}
@@ -1,3 +1,4 @@
import { createEncryption } from "../encryption";
import { describe, it, expect } from "vitest";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
@@ -25,7 +26,7 @@ async function freshRepo() {
const db = drizzle(client, { schema });
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
await client.exec("SET session_replication_role = replica;"); // skip FK seeding
const repo = createDeploymentRepo(db);
const repo = createDeploymentRepo(db, createEncryption("repository-test-secret"));
await db.insert(deployment).values({
id: "d1",
projectId: "p1",
@@ -1,3 +1,4 @@
import { createEncryption } from "../encryption";
import { describe, expect, it } from "vitest";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
@@ -23,7 +24,7 @@ async function freshRepo(deletionInProgress: boolean) {
slug: "app",
deletionInProgress,
});
return { db, repo: createDeploymentRepo(db) };
return { db, repo: createDeploymentRepo(db, createEncryption("repository-test-secret")) };
}
describe("deployment creation vs. project deletion", () => {
+33 -30
View File
@@ -1,6 +1,7 @@
import { eq, and, desc, gte, lte, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm";
import { generateId } from "@repo/core";
import type { Database } from "../client";
import type { Database } from "../connection";
import { createConfigurationSecrets, type ConfigurationEncryption } from "../configuration-secrets";
import { deployment, buildSession, project } from "../schema";
import { detailOf } from "./storable-detail";
import { withProjectWorkAdmission } from "./project-work-admission";
@@ -14,25 +15,27 @@ export type NewBuildSession = typeof buildSession.$inferInsert;
// ─── Repository ──────────────────────────────────────────────────────────────
export function createDeploymentRepo(db: Database) {
export function createDeploymentRepo(db: Database, encryption: ConfigurationEncryption) {
const codec = createConfigurationSecrets(encryption);
return {
// ── Deployments ────────────────────────────────────────────────────
async findById(id: string) {
return db.query.deployment.findFirst({
return codec.openDeployment(await db.query.deployment.findFirst({
where: eq(deployment.id, id),
});
}));
},
/** All deployments in a given status (e.g. "reconciling") — drives the
* reconcile sweep. Bounded to avoid pulling an unbounded history. */
async listByStatus(status: string, limit = 200) {
return db
const rows = await db
.select()
.from(deployment)
.where(eq(deployment.status, status))
.orderBy(desc(deployment.createdAt))
.limit(limit);
return rows.map(codec.openDeployment);
},
async listByProject(
@@ -48,12 +51,12 @@ export function createDeploymentRepo(db: Database) {
conditions.push(eq(deployment.environment, opts.environment));
}
const rows = await db.query.deployment.findMany({
const rows = (await db.query.deployment.findMany({
where: and(...conditions),
orderBy: [desc(deployment.createdAt)],
limit: perPage,
offset,
});
})).map(codec.openDeployment);
const [{ value: total }] = await db
.select({ value: sql<number>`count(*)` })
@@ -76,7 +79,7 @@ export function createDeploymentRepo(db: Database) {
* status side at one; the EXISTS side covers its still-running cancelled
* worker without trusting that terminal-looking status. */
async listInFlightByProject(projectId: string): Promise<Deployment[]> {
return db.query.deployment.findMany({
return (await db.query.deployment.findMany({
where: and(
eq(deployment.projectId, projectId),
or(
@@ -91,7 +94,7 @@ export function createDeploymentRepo(db: Database) {
)`,
),
),
}) as Promise<Deployment[]>;
})).map(codec.openDeployment);
},
async hasLiveBuildExecution(deploymentId: string, projectId: string): Promise<boolean> {
@@ -119,12 +122,12 @@ export function createDeploymentRepo(db: Database) {
const perPage = opts?.perPage ?? 50;
const offset = (page - 1) * perPage;
const rows = await db.query.deployment.findMany({
const rows = (await db.query.deployment.findMany({
where: eq(deployment.organizationId, organizationId),
orderBy: [desc(deployment.createdAt)],
limit: perPage,
offset,
});
})).map(codec.openDeployment);
const [{ value: total }] = await db
.select({ value: sql<number>`count(*)` })
@@ -200,10 +203,10 @@ export function createDeploymentRepo(db: Database) {
const [inserted] = await tx
.insert(deployment)
.values({ id, ...rest })
.values(codec.sealDeployment({ id, ...rest }))
.onConflictDoNothing()
.returning();
return inserted as Deployment | undefined;
return codec.openDeployment(inserted as Deployment | undefined);
});
},
@@ -371,14 +374,14 @@ export function createDeploymentRepo(db: Database) {
*/
async findInProgressByCommit(projectId: string, commitSha: string | null | undefined) {
if (!commitSha) return undefined;
return db.query.deployment.findFirst({
return codec.openDeployment(await db.query.deployment.findFirst({
where: and(
eq(deployment.projectId, projectId),
eq(deployment.commitSha, commitSha),
inArray(deployment.status, ["queued", "building", "deploying"]),
),
orderBy: [desc(deployment.createdAt)],
});
}));
},
/**
@@ -393,14 +396,14 @@ export function createDeploymentRepo(db: Database) {
releaseVersion: string | null | undefined,
) {
if (!releaseVersion) return undefined;
return db.query.deployment.findFirst({
return codec.openDeployment(await db.query.deployment.findFirst({
where: and(
eq(deployment.projectId, projectId),
eq(deployment.releaseVersion, releaseVersion),
inArray(deployment.status, ["queued", "building", "deploying"]),
),
orderBy: [desc(deployment.createdAt)],
});
}));
},
/**
@@ -426,7 +429,7 @@ export function createDeploymentRepo(db: Database) {
): Promise<boolean> {
const rows = await db
.update(deployment)
.set({ status, ...extra, updatedAt: new Date() })
.set(codec.sealDeployment({ status, ...extra, updatedAt: new Date() }))
.where(and(eq(deployment.id, id), ne(deployment.status, "cancelled")))
.returning();
return rows.length > 0;
@@ -443,7 +446,7 @@ export function createDeploymentRepo(db: Database) {
async cancelInFlight(id: string, extra?: Partial<NewDeployment>): Promise<boolean> {
const rows = await db
.update(deployment)
.set({ ...extra, status: "cancelled", updatedAt: new Date() })
.set(codec.sealDeployment({ ...extra, status: "cancelled", updatedAt: new Date() }))
.where(
and(
eq(deployment.id, id),
@@ -580,10 +583,10 @@ export function createDeploymentRepo(db: Database) {
/** Find the most recent deployment for a project (any status) */
async findLatestByProject(projectId: string) {
return db.query.deployment.findFirst({
return codec.openDeployment(await db.query.deployment.findFirst({
where: eq(deployment.projectId, projectId),
orderBy: [desc(deployment.createdAt)],
});
}));
},
/**
@@ -597,10 +600,10 @@ export function createDeploymentRepo(db: Database) {
*/
async findLatestByProjects(projectIds: string[]): Promise<Map<string, Deployment>> {
if (projectIds.length === 0) return new Map();
const rows = await db.query.deployment.findMany({
const rows = (await db.query.deployment.findMany({
where: inArray(deployment.projectId, projectIds),
orderBy: [desc(deployment.createdAt)],
});
})).map(codec.openDeployment);
const out = new Map<string, Deployment>();
for (const row of rows) {
if (!out.has(row.projectId)) out.set(row.projectId, row);
@@ -630,20 +633,20 @@ export function createDeploymentRepo(db: Database) {
if (ids.length === 0) return new Map();
const rows = await db.select().from(deployment).where(inArray(deployment.id, ids));
const out = new Map<string, Deployment>();
for (const row of rows) out.set(row.id, row);
for (const row of rows) out.set(row.id, codec.openDeployment(row));
return out;
},
/** Find the most recent successful deployment for rollback */
async findLatestReady(projectId: string, environment: string) {
return db.query.deployment.findFirst({
return codec.openDeployment(await db.query.deployment.findFirst({
where: and(
eq(deployment.projectId, projectId),
eq(deployment.environment, environment),
eq(deployment.status, "ready"),
),
orderBy: [desc(deployment.createdAt)],
});
}));
},
/**
@@ -656,14 +659,14 @@ export function createDeploymentRepo(db: Database) {
* that did come up.
*/
async getLatestSuccessfulForBranch(projectId: string, branch: string) {
return db.query.deployment.findFirst({
return codec.openDeployment(await db.query.deployment.findFirst({
where: and(
eq(deployment.projectId, projectId),
eq(deployment.branch, branch),
inArray(deployment.status, ["ready", "partial_failure"]),
),
orderBy: [desc(deployment.createdAt)],
});
}));
},
// ── Rollback / retention ───────────────────────────────────────────
@@ -708,10 +711,10 @@ export function createDeploymentRepo(db: Database) {
if (environment) {
conditions.push(eq(deployment.environment, environment));
}
return db.query.deployment.findMany({
return (await db.query.deployment.findMany({
where: and(...conditions),
orderBy: [desc(deployment.createdAt)],
});
})).map(codec.openDeployment);
},
// ── Build sessions ─────────────────────────────────────────────────
+9 -4
View File
@@ -337,9 +337,14 @@ import { createStripeTopupGrantRepo } from "./stripe-topup-grant.repo";
import { createBillingAnniversaryGrantRepo } from "./billing-anniversary-grant.repo";
import { createBillingUsageSnapshotRepo } from "./billing-usage-snapshot.repo";
export function createRepositories(db: Database) {
import type { ConfigurationEncryption } from "../configuration-secrets";
import { createConfigurationSecretsRepo } from "./configuration-secrets.repo";
/** Passive composition: the caller owns both the connection and encryption key. */
export function createRepositories(db: Database, encryption: ConfigurationEncryption) {
const auditSettingsRepo = createAuditSettingsRepo(db);
return {
configurationSecrets: createConfigurationSecretsRepo(db, encryption),
user: createUserRepo(db),
session: createSessionRepo(db),
account: createAccountRepo(db),
@@ -347,8 +352,8 @@ export function createRepositories(db: Database) {
githubInstallState: createGithubInstallStateRepo(db),
gitSource: createGitSourceRepo(db),
projectGroup: createProjectGroupRepo(db),
project: createProjectRepo(db),
deployment: createDeploymentRepo(db),
project: createProjectRepo(db, encryption),
deployment: createDeploymentRepo(db, encryption),
domain: createDomainRepo(db),
dnsCredential: createDnsCredentialRepo(db),
credential: createCredentialRepo(db),
@@ -365,7 +370,7 @@ export function createRepositories(db: Database) {
projectConnection: createProjectConnectionRepo(db),
customAppTemplate: createCustomAppTemplateRepo(db),
webhookDelivery: createWebhookDeliveryRepo(db),
service: createServiceRepo(db),
service: createServiceRepo(db, encryption),
serviceDeployment: createServiceDeploymentRepo(db),
settings: createSettingsRepo(db),
instanceSettings: createInstanceSettingsRepo(db),
+2 -2
View File
@@ -1,5 +1,5 @@
export * from "./factory";
import { createRepositories } from "./factory";
import { db } from "../client";
import { db, storageEncryption } from "../client";
export const repos = createRepositories(db);
export const repos = createRepositories(db, storageEncryption);
@@ -1,3 +1,4 @@
import { createEncryption } from "../encryption";
import { describe, it, expect, beforeEach } from "vitest";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
@@ -30,7 +31,7 @@ async function freshDb() {
const db = drizzle(client, { schema });
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
await client.exec("SET session_replication_role = replica;");
return { db, projectRepo: createProjectRepo(db) };
return { db, projectRepo: createProjectRepo(db, createEncryption("repository-test-secret")) };
}
type Db = Awaited<ReturnType<typeof freshDb>>["db"];
@@ -1,3 +1,4 @@
import { createEncryption } from "../encryption";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createDatabase, createRepositories, schema, type DatabaseConnection } from "../factory";
@@ -6,7 +7,7 @@ describe("atomic project creation and credential access", () => {
let repos: ReturnType<typeof createRepositories>;
beforeAll(async () => {
connection = await createDatabase({ driver: "pglite", dataDir: "memory://" });
repos = createRepositories(connection.db);
repos = createRepositories(connection.db, createEncryption("repository-test-secret"));
await connection.db.insert(schema.organization).values({ id: "org", name: "Test" });
}, 30_000);
afterAll(async () => { await connection?.close(); });
@@ -1,3 +1,4 @@
import { createEncryption } from "../encryption";
import { describe, it, expect, beforeEach } from "vitest";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
@@ -22,7 +23,7 @@ async function freshRepo() {
const db = drizzle(client, { schema });
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
await client.exec("SET session_replication_role = replica;"); // skip FK seeding
return { db, repo: createProjectRepo(db) };
return { db, repo: createProjectRepo(db, createEncryption("repository-test-secret")) };
}
async function seed(db: Awaited<ReturnType<typeof freshRepo>>["db"]) {
+5 -3
View File
@@ -1,6 +1,7 @@
import { eq, and, isNull, isNotNull, inArray, desc, sql, type SQL } from "drizzle-orm";
import { generateId, ForbiddenError, UnauthorizedError } from "@repo/core";
import type { Database } from "../client";
import type { Database } from "../connection";
import { createConfigurationSecrets, type ConfigurationEncryption } from "../configuration-secrets";
import { project, projectGroup, envVar, deployment, service } from "../schema";
import { member } from "../schema/organization";
// Cloning a project writes its group and service rows in the same transaction, so this repo
@@ -93,7 +94,8 @@ export async function rebindGitHubInstallationRows(
// ─── Repository ──────────────────────────────────────────────────────────────
export function createProjectRepo(db: Database) {
export function createProjectRepo(db: Database, encryption: ConfigurationEncryption) {
const codec = createConfigurationSecrets(encryption);
return {
// ── Projects ───────────────────────────────────────────────────────
@@ -437,7 +439,7 @@ export function createProjectRepo(db: Database) {
input.services.map((svc) => ({
id: serviceIdBySourceId[svc.sourceId]!,
projectId,
...svc.row,
...codec.sealService(svc.row),
})),
);
}
@@ -1,3 +1,4 @@
import { createEncryption } from "../encryption";
import { describe, it, expect, beforeEach } from "vitest";
import { PGlite } from "@electric-sql/pglite";
import { drizzle } from "drizzle-orm/pglite";
@@ -33,7 +34,7 @@ async function fresh() {
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
// Seed rows without the full org→project→service FK chain.
await client.exec("SET session_replication_role = replica;");
return { db, repo: createServiceRepo(db) };
return { db, repo: createServiceRepo(db, createEncryption("repository-test-secret")) };
}
const DEP = "dep_1";
@@ -1,3 +1,4 @@
import { createEncryption } from "../encryption";
import { describe, it, expect, beforeEach } from "vitest";
import { PGlite } from "@electric-sql/pglite";
import { drizzle } from "drizzle-orm/pglite";
@@ -30,7 +31,7 @@ async function fresh() {
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
// Seed rows without the full org→project→service FK chain.
await client.exec("SET session_replication_role = replica;");
return { db, repo: createServiceRepo(db) };
return { db, repo: createServiceRepo(db, createEncryption("repository-test-secret")) };
}
const DEP = "dep_1";
@@ -1,3 +1,5 @@
import { createConfigurationSecrets } from "../configuration-secrets";
import { createEncryption } from "../encryption";
import { describe, expect, it } from "vitest";
import {
composeWritePatch,
@@ -8,6 +10,9 @@ import {
} from "./service.repo";
import type { Database } from "../client";
const testEncryption = createEncryption("repository-test-secret");
const configuration = createConfigurationSecrets(testEncryption);
const multiRoute = [
{ port: 3210, domainType: "free" as const, domain: "acme-backend" },
{ port: 3211, domainType: "free" as const, domain: "acme-backend-http" },
@@ -81,13 +86,13 @@ describe("reconcileFromCompose keeps the route set", () => {
query: { service: { findMany: async () => [{ ...existing, importedSpec }] } },
update: () => ({
set: (data: Record<string, unknown>) => {
writes.push(data);
writes.push(configuration.openService(data));
return { where: async () => undefined };
},
}),
} as unknown as Database;
await createServiceRepo(db).reconcileFromCompose("proj_1", [
await createServiceRepo(db, testEncryption).reconcileFromCompose("proj_1", [
{ name: "backend", image: "convex:2" },
]);
@@ -123,13 +128,13 @@ describe("reconcileFromCompose bootstraps dynamic env provenance (#673)", () =>
},
update: () => ({
set: (data: Record<string, unknown>) => {
writes.push(data);
writes.push(configuration.openService(data));
return { where: async () => undefined };
},
}),
} as unknown as Database;
await createServiceRepo(db).reconcileFromCompose("proj_1", [
await createServiceRepo(db, testEncryption).reconcileFromCompose("proj_1", [
{
name: "api",
environment: {
@@ -207,13 +212,13 @@ describe("legacy compose provenance baselines", () => {
},
update: () => ({
set: (data: Record<string, unknown>) => {
writes.push(data);
writes.push(configuration.openService(data));
return { where: async () => undefined };
},
}),
} as unknown as Database;
const result = await createServiceRepo(db).reconcileFromCompose("proj_1", [parsedNow]);
const result = await createServiceRepo(db, testEncryption).reconcileFromCompose("proj_1", [parsedNow]);
expect(result.driftedNames).toEqual([]);
expect(writes).toHaveLength(1);
@@ -251,13 +256,13 @@ describe("legacy compose provenance baselines", () => {
},
update: () => ({
set: (data: Record<string, unknown>) => {
writes.push(data);
writes.push(configuration.openService(data));
return { where: async () => undefined };
},
}),
} as unknown as Database;
await createServiceRepo(db).reconcileFromCompose("proj_1", [parsedWithImageTemplate]);
await createServiceRepo(db, testEncryption).reconcileFromCompose("proj_1", [parsedWithImageTemplate]);
expect(writes).toHaveLength(1);
expect(writes[0].advanced).toBeUndefined();
@@ -335,7 +340,7 @@ describe("Compose image provenance (#809)", () => {
query: { service: { findMany: async () => [row] } },
update: () => ({
set: (data: Record<string, unknown>) => ({
where: async () => writes.push(data),
where: async () => writes.push(configuration.openService(data)),
}),
}),
} as unknown as Database;
@@ -345,7 +350,7 @@ describe("Compose image provenance (#809)", () => {
advanced: stored.advanced,
};
await createServiceRepo(db).syncFromCompose("proj_1", [parsed], {
await createServiceRepo(db, testEncryption).syncFromCompose("proj_1", [parsed], {
removeMissing: false,
composeAuthoritative: true,
});
@@ -376,7 +381,7 @@ describe("Compose image provenance (#809)", () => {
query: { service: { findMany: async () => [row] } },
update: () => ({
set: (data: Record<string, unknown>) => ({
where: async () => writes.push(data),
where: async () => writes.push(configuration.openService(data)),
}),
}),
} as unknown as Database;
@@ -392,7 +397,7 @@ describe("Compose image provenance (#809)", () => {
},
};
await createServiceRepo(db).reconcileFromCompose("proj_1", [parsed]);
await createServiceRepo(db, testEncryption).reconcileFromCompose("proj_1", [parsed]);
return writes[0];
};
@@ -430,13 +435,13 @@ describe("reconcileFromCompose bootstraps legacy build args (#689)", () => {
query: { service: { findMany: async () => [{ ...row, importedSpec: oldBaseline }] } },
update: () => ({
set: (data: Record<string, unknown>) => {
writes.push(data);
writes.push(configuration.openService(data));
return { where: async () => undefined };
},
}),
} as unknown as Database;
await createServiceRepo(db).reconcileFromCompose("proj_1", [
await createServiceRepo(db, testEncryption).reconcileFromCompose("proj_1", [
{ name: "api", image: "example/api:1" },
]);
@@ -484,13 +489,13 @@ describe("reconcileFromCompose bootstraps legacy build args (#689)", () => {
},
update: () => ({
set: (data: Record<string, unknown>) => {
writes.push(data);
writes.push(configuration.openService(data));
return { where: async () => undefined };
},
}),
} as unknown as Database;
await createServiceRepo(db).reconcileFromCompose("proj_1", [
await createServiceRepo(db, testEncryption).reconcileFromCompose("proj_1", [
{
name: "api",
build: ".",
+17 -15
View File
@@ -7,7 +7,8 @@ import {
resolveCommandArgv,
type ComposeAdvanced,
} from "@repo/core";
import type { Database } from "../client";
import type { Database } from "../connection";
import { createConfigurationSecrets, type ConfigurationEncryption } from "../configuration-secrets";
import { project, service, serviceDeployment } from "../schema";
import type { ComposeServiceSpec, ServicePublicEndpoint } from "../schema/service";
@@ -468,14 +469,15 @@ export function normalizeRoutingFields(input: {
// ─── Repository ──────────────────────────────────────────────────────────────
export function createServiceRepo(db: Database) {
export function createServiceRepo(db: Database, encryption: ConfigurationEncryption) {
const codec = createConfigurationSecrets(encryption);
return {
// ── Services ───────────────────────────────────────────────────────
async findById(id: string) {
return db.query.service.findFirst({
return codec.openService(await db.query.service.findFirst({
where: eq(service.id, id),
});
}));
},
/** Batch id → display name, for naming services in list responses. */
@@ -488,16 +490,16 @@ export function createServiceRepo(db: Database) {
},
async findByName(projectId: string, name: string) {
return db.query.service.findFirst({
return codec.openService(await db.query.service.findFirst({
where: and(eq(service.projectId, projectId), eq(service.name, name)),
});
}));
},
async listByProject(projectId: string) {
return db.query.service.findMany({
return (await db.query.service.findMany({
where: eq(service.projectId, projectId),
orderBy: [asc(service.sortOrder), asc(service.name)],
});
})).map(codec.openService);
},
/**
@@ -535,10 +537,10 @@ export function createServiceRepo(db: Database) {
*/
async listByProjects(projectIds: string[]): Promise<Map<string, Service[]>> {
if (projectIds.length === 0) return new Map();
const rows = await db.query.service.findMany({
const rows = (await db.query.service.findMany({
where: inArray(service.projectId, projectIds),
orderBy: [asc(service.sortOrder), asc(service.name)],
});
})).map(codec.openService);
const out = new Map<string, Service[]>();
for (const id of projectIds) out.set(id, []);
for (const row of rows) {
@@ -553,14 +555,14 @@ export function createServiceRepo(db: Database) {
// Return the persisted defaults and timestamps. Synthesizing a Service
// from the input omitted fields such as namespaceVolumes and made create
// disagree with the next read of the same row.
const [row] = await db.insert(service).values({ id, ...data }).returning();
return row!;
const [row] = await db.insert(service).values(codec.sealService({ id, ...data })).returning();
return codec.openService(row!);
},
async update(id: string, data: Partial<NewService>) {
await db
.update(service)
.set({ ...data, updatedAt: new Date() })
.set(codec.sealService({ ...data, updatedAt: new Date() }))
.where(eq(service.id, id));
},
@@ -582,10 +584,10 @@ export function createServiceRepo(db: Database) {
/** List only the rows of one kind under a project. */
async listByProjectKind(projectId: string, kind: "compose" | "monorepo") {
return db.query.service.findMany({
return (await db.query.service.findMany({
where: and(eq(service.projectId, projectId), eq(service.kind, kind)),
orderBy: [asc(service.sortOrder), asc(service.name)],
});
})).map(codec.openService);
},
/**
+1 -2
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { createPrivateKey } from "crypto";
import { DEFAULT_ENCRYPTION_SECRET as DEFAULT_BETTER_AUTH_SECRET } from "@repo/db/encryption";
import {
runtimeTarget,
runtimeTargetId,
@@ -11,8 +12,6 @@ import {
export { runtimeTarget, runtimeTargetId, cloudRuntimeTarget, cloudRuntimeTargetId };
const DEFAULT_BETTER_AUTH_SECRET = "change-me-in-production";
/**
* Parse a string env var as boolean. Accepts "true"/"1" → true,
* "false"/"0"/"" → false. Defaults match the surrounding semantics.
@@ -334,7 +334,7 @@ export function maskScanService<T extends Parameters<typeof publicScanService>[0
* (`meta.composeServices[].environment` and `buildArgs`). Returns a copy — the stored row/meta
* is untouched (rollback/redeploy read the real values back). Apply at the
* shared presentation boundary: `getDeployment` is also used internally and must
* keep plaintext. No-op when there's no `meta.composeServices`.
* use the decrypted repository values. No-op when there's no `meta.composeServices`.
*/
export function maskDeploymentEnv<T extends { meta?: unknown } | null | undefined>(dep: T): T {
if (
@@ -83,9 +83,9 @@ export async function parseRepoCompose(
// NB: we deliberately do NOT read the repo's `.env` for `${VAR}` interpolation.
// Secrets live in Openship's ENCRYPTED env store — captured from the running
// container for adopted services, or set via the wizard/env UI for new ones —
// never a committed repo file. Pulling a `.env` here would drop those values
// into the PLAINTEXT service.environment column. So a bare `${VAR}` with no
// inline default resolves to "" and the real value comes from the env store.
// never a committed repo file. Pulling a `.env` here would pin those values
// as service overrides, hiding later rotations in the project store. A bare
// `${VAR}` without an inline default resolves to "" and defers to that store.
for (const file of REPO_COMPOSE_FILES) {
let content: string | null = null;
try {
+1
View File
@@ -60,6 +60,7 @@ try {
const { configureNativeSourceRoots } = await import("./engine/native/source-policy");
configureNativeSourceRoots(options.policy?.sourceRoots ?? []);
await identities.bindInstallation(options.instanceId, options.encryptionKey);
await database.repos.configurationSecrets.backfillLegacy();
await adapters.initPlatform(resolvePlatformConfig());
const kernel = getPlatformKernel();
let started = false, draining = false, finalizing = false;
+9 -127
View File
@@ -1,127 +1,9 @@
/**
* Application-level encryption for sensitive data (env vars, secrets).
*
* Uses AES-256-GCM (authenticated encryption) via Node.js built-in crypto.
* The encryption key is derived from BETTER_AUTH_SECRET (which every install
* already has) - no extra env var needed.
*
* Format: base64( iv:16 || authTag:16 || ciphertext )
*
* Usage:
* import { encrypt, decrypt } from "../lib/encryption";
* const sealed = encrypt("my secret");
* const plain = decrypt(sealed); // "my secret"
*/
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
// ─── Key derivation ──────────────────────────────────────────────────────────
const ALGORITHM = "aes-256-gcm" as const;
const IV_LENGTH = 16;
const AUTH_TAG_LENGTH = 16;
// ─── Public API ──────────────────────────────────────────────────────────────
/**
* Seal a plaintext under an EXPLICIT 32-byte key. The single AES-256-GCM
* implementation for the whole app — `encrypt()` uses the instance key,
* while callers with a different key source (e.g. a passphrase-derived key
* for data export) reuse this so the cipher/format never diverges.
* Returns base64( iv:16 || authTag:16 || ciphertext ).
*/
export function encryptWithKey(key: Buffer, plaintext: string): string {
return encryptBytesWithKey(key, Buffer.from(plaintext, "utf8")).toString("base64");
}
/** Binary twin used by bounded transfer chunks; keeps bytes binary on the wire. */
export function encryptBytesWithKey(key: Buffer, plaintext: Uint8Array): Buffer {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, encrypted]);
}
/**
* Open a value produced by `encryptWithKey()` (or `encrypt()`), using an
* explicit key. Throws if the data is tampered with or the key is wrong.
*/
export function decryptWithKey(key: Buffer, sealed: string): string {
return decryptBytesWithKey(key, Buffer.from(sealed, "base64")).toString("utf8");
}
/** Open one binary AES-GCM chunk without converting its payload through UTF-8. */
export function decryptBytesWithKey(key: Buffer, sealed: Uint8Array): Buffer {
const packed = Buffer.from(sealed);
if (packed.length < IV_LENGTH + AUTH_TAG_LENGTH) {
throw new Error("Invalid encrypted data: too short");
}
const iv = packed.subarray(0, IV_LENGTH);
const authTag = packed.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
const ciphertext = packed.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
/** One installation's secrets. The existing ciphertext format and key derivation are unchanged. */
export function createEncryption(secret: string) {
if (typeof secret !== "string" || !secret)
throw new TypeError("An explicit encryption secret is required");
const key = createHash("sha256").update(secret).digest();
let closed = false;
function getKey(): Buffer {
if (closed) throw new Error("Encryption context is closed");
return key;
}
/**
* Encrypt a plaintext string under the instance key.
* Returns a base64-encoded string containing IV + auth tag + ciphertext.
*/
function encrypt(plaintext: string): string {
return encryptWithKey(getKey(), plaintext);
}
/**
* Decrypt a value produced by `encrypt()`.
* Throws if the data is tampered with or the key is wrong.
*/
function decrypt(sealed: string): string {
return decryptWithKey(getKey(), sealed);
}
/**
* Decrypt a Record<string, encryptedValue> → Record<string, plaintext>.
* On decryption failure for a key, that key is omitted (not silently passed through).
*/
function decryptEnvMap(
encrypted: Record<string, string>,
onError?: (key: string, err: unknown) => void,
): Record<string, string> {
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(encrypted)) {
try {
result[k] = decrypt(v);
} catch (err) {
onError?.(k, err);
// Omit keys that fail decryption - never leak ciphertext into containers
}
}
return result;
}
return Object.freeze({
encrypt,
decrypt,
decryptEnvMap,
close() {
if (!closed) {
closed = true;
key.fill(0);
}
},
});
}
export type Encryption = ReturnType<typeof createEncryption>;
/** Shared passive cipher; importing it never opens a database or reads process env. */
export {
createEncryption,
encryptWithKey,
decryptWithKey,
encryptBytesWithKey,
decryptBytesWithKey,
type Encryption,
} from "@repo/db/encryption";