feat(workspaces): encrypted write-only credential storage

Workspaces Payload collection with the PAT as a write-only field:
access.read () => false strips it from every access-controlled read
path, and a field-level beforeChange hook encrypts on write with
AES-256-GCM (scrypt key from VEECT_SECRET, random per-value salt + IV,
v1 storage format) via node:crypto only. The real repository replaces
the phase-1 stub with payload create/findByID; toDomain never maps the
credential, and getDecryptedCredential(id) is the single server-side
decrypt path for the runner handoff (story 07). Contract suite now
covers create, write-only behaviour, and the decrypt path against both
the mock and the Payload impl (stub runs the real collection hooks).
Missing VEECT_SECRET fails production bind/boot with an actionable
message; dev-seed boots without it. Env declared in turbo.json
globalEnv + .env.example.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
2026-07-12 22:19:14 +02:00
parent 8219c1fabb
commit c990e1b871
21 changed files with 788 additions and 97 deletions

View File

@@ -11,6 +11,12 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5433/template
# Payload CMS encryption key. Any random 32+ char string in dev. # Payload CMS encryption key. Any random 32+ char string in dev.
PAYLOAD_SECRET=replace-with-a-random-32-char-string PAYLOAD_SECRET=replace-with-a-random-32-char-string
# Workspace credential encryption key (AES-256-GCM, scrypt-derived).
# Required in production mode - boot fails without it. Dev-seed mode
# (USE_DEV_SEED=true / plain `pnpm dev`) boots fine without it.
# Generate via `openssl rand -hex 32`.
VEECT_SECRET=replace-with-a-random-64-char-hex-string
# --- Optional: app URLs (defaults work in dev) --- # --- Optional: app URLs (defaults work in dev) ---
NEXT_PUBLIC_APP_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000

View File

@@ -21,3 +21,7 @@ collections:
restrictable: true restrictable: true
source: auth-default source: auth-default
slug: users slug: users
workspaces:
auth: false
piiFields: []
slug: workspaces

View File

@@ -9,3 +9,10 @@ collections:
trigger: after-deletion trigger: after-deletion
purgeSchedule: daily purgeSchedule: daily
slug: users slug: users
workspaces:
postDeletion:
action: hard-delete
duration: P30D
trigger: after-deletion
purgeSchedule: daily
slug: workspaces

View File

@@ -17,6 +17,7 @@
"@payloadcms/db-postgres": "^3.14.0", "@payloadcms/db-postgres": "^3.14.0",
"@payloadcms/richtext-lexical": "^3.14.0", "@payloadcms/richtext-lexical": "^3.14.0",
"@repo/auth": "workspace:*", "@repo/auth": "workspace:*",
"@repo/workspaces": "workspace:*",
"payload": "^3.14.0" "payload": "^3.14.0"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -5,7 +5,7 @@ describe("payloadConfig composition", () => {
it("registers all feature collections", async () => { it("registers all feature collections", async () => {
const resolved = await config; const resolved = await config;
const slugs = resolved.collections?.map((c) => c.slug) ?? []; const slugs = resolved.collections?.map((c) => c.slug) ?? [];
expect(slugs).toEqual(expect.arrayContaining(["users"])); expect(slugs).toEqual(expect.arrayContaining(["users", "workspaces"]));
}); });
it("registers no feature globals (none remain)", async () => { it("registers no feature globals (none remain)", async () => {

View File

@@ -5,13 +5,14 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { users } from "@repo/auth/cms"; import { users } from "@repo/auth/cms";
import { workspaces } from "@repo/workspaces/cms";
const filename = fileURLToPath(import.meta.url); const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename); const dirname = path.dirname(filename);
export default buildConfig({ export default buildConfig({
editor: lexicalEditor(), editor: lexicalEditor(),
collections: [users], collections: [users, workspaces],
globals: [], globals: [],
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me", secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
db: postgresAdapter({ db: postgresAdapter({

View File

@@ -31,11 +31,21 @@ export const CONTRACT_WORKSPACE_SEED: ReadonlyArray<
], ],
]; ];
/** Fixture PAT used by the write-only credential contract tests. */
export const CONTRACT_WORKSPACE_PAT = "ghp_contract-secret-token";
const CREATE_DATA = {
name: "Contract Workspace",
gitUrl: "https://github.com/acme/contract.git",
credential: CONTRACT_WORKSPACE_PAT,
};
/** /**
* Contract for IWorkspaceRepository. * Contract for IWorkspaceRepository — runs against both the mock and the
* * Payload implementation. Covers read/create behaviour, span emission, and
* The interface exposes only `getWorkspace(id)`. The contract verifies * the write-only credential guarantee: no read path returns the PAT; the
* found vs missing behaviour and span emission. * narrow `getDecryptedCredential` path returns the plaintext for the runner
* handoff (story 07).
*/ */
export const workspaceRepositoryContract = export const workspaceRepositoryContract =
defineContractSuite<IWorkspaceRepository>( defineContractSuite<IWorkspaceRepository>(
@@ -59,6 +69,47 @@ export const workspaceRepositoryContract =
expect(result).toBeNull(); expect(result).toBeNull();
}); });
describe("createWorkspace", () => {
it("creates a workspace with initial status 'created'", async () => {
const created = await repo.createWorkspace(CREATE_DATA);
expect(created.id).not.toBe("");
expect(created.name).toBe(CREATE_DATA.name);
expect(created.gitUrl).toBe(CREATE_DATA.gitUrl);
expect(created.status).toBe("created");
});
it("persists the workspace — readable back via getWorkspace", async () => {
const created = await repo.createWorkspace(CREATE_DATA);
const found = await repo.getWorkspace(created.id);
expect(found).toEqual(created);
});
});
describe("write-only credential behaviour", () => {
it("create → read back: the credential is absent from every read path", async () => {
const created = await repo.createWorkspace(CREATE_DATA);
const found = await repo.getWorkspace(created.id);
expect(created).not.toHaveProperty("credential");
expect(found).not.toHaveProperty("credential");
expect(JSON.stringify(created)).not.toContain(CONTRACT_WORKSPACE_PAT);
expect(JSON.stringify(found)).not.toContain(CONTRACT_WORKSPACE_PAT);
});
it("getDecryptedCredential returns the plaintext for the runner handoff", async () => {
const created = await repo.createWorkspace(CREATE_DATA);
await expect(repo.getDecryptedCredential(created.id)).resolves.toBe(
CONTRACT_WORKSPACE_PAT,
);
});
it("getDecryptedCredential returns null for an unknown id", async () => {
await expect(
repo.getDecryptedCredential("does-not-exist"),
).resolves.toBeNull();
});
});
describe("span emission", () => { describe("span emission", () => {
it("getWorkspace emits span 'workspace.getWorkspace' with op=repository", async () => { it("getWorkspace emits span 'workspace.getWorkspace' with op=repository", async () => {
if (!getTracer) return; if (!getTracer) return;
@@ -69,6 +120,16 @@ export const workspaceRepositoryContract =
expect(span).toBeDefined(); expect(span).toBeDefined();
expect(span!.op).toBe("repository"); expect(span!.op).toBe("repository");
}); });
it("createWorkspace emits span 'workspace.createWorkspace' with op=repository", async () => {
if (!getTracer) return;
const tracer = getTracer();
tracer.reset();
await repo.createWorkspace(CREATE_DATA);
const span = tracer.findSpan("workspace.createWorkspace");
expect(span).toBeDefined();
expect(span!.op).toBe("repository");
});
}); });
}, },
); );

View File

@@ -16,4 +16,11 @@ export interface IWorkspaceRepository {
getWorkspace(id: string): Promise<Workspace | null>; getWorkspace(id: string): Promise<Workspace | null>;
/** Persists a new workspace with initial status "created". */ /** Persists a new workspace with initial status "created". */
createWorkspace(data: CreateWorkspaceData): Promise<Workspace>; createWorkspace(data: CreateWorkspaceData): Promise<Workspace>;
/**
* INTERNAL server-side decrypt path for the runner handoff (story 07):
* returns the plaintext PAT, or null when the workspace does not exist.
* MUST never be surfaced through a use-case output — the credential is
* write-only on every API read path.
*/
getDecryptedCredential(id: string): Promise<string | null>;
} }

View File

@@ -47,6 +47,7 @@ describe("connectWorkspaceUseCase", () => {
it("strips a credential leaked by a misbehaving repository", async () => { it("strips a credential leaked by a misbehaving repository", async () => {
const leakyRepo = { const leakyRepo = {
getWorkspace: async () => null, getWorkspace: async () => null,
getDecryptedCredential: async () => null,
createWorkspace: async () => ({ createWorkspace: async () => ({
id: "ws-1", id: "ws-1",
name: "Acme Web", name: "Acme Web",
@@ -99,6 +100,7 @@ describe("connectWorkspaceUseCase", () => {
it("does not audit when the repository write fails", async () => { it("does not audit when the repository write fails", async () => {
const failingRepo = { const failingRepo = {
getWorkspace: async () => null, getWorkspace: async () => null,
getDecryptedCredential: async () => null,
createWorkspace: async () => { createWorkspace: async () => {
throw new Error("boom"); throw new Error("boom");
}, },
@@ -120,6 +122,7 @@ describe("connectWorkspaceUseCase", () => {
it("throws ZodError when the repository returns malformed data", async () => { it("throws ZodError when the repository returns malformed data", async () => {
const malformedRepo = { const malformedRepo = {
getWorkspace: async () => null, getWorkspace: async () => null,
getDecryptedCredential: async () => null,
createWorkspace: async () => ({ id: "", name: "x" }) as never, createWorkspace: async () => ({ id: "", name: "x" }) as never,
}; };
const useCase = connectWorkspaceUseCase(malformedRepo); const useCase = connectWorkspaceUseCase(malformedRepo);

View File

@@ -25,6 +25,7 @@ describe("getWorkspaceUseCase", () => {
const malformedRepo = { const malformedRepo = {
getWorkspace: async () => ({ id: "", name: "x" }) as never, getWorkspace: async () => ({ id: "", name: "x" }) as never,
createWorkspace: async () => ({ id: "", name: "x" }) as never, createWorkspace: async () => ({ id: "", name: "x" }) as never,
getDecryptedCredential: async () => null,
}; };
const useCase = getWorkspaceUseCase(malformedRepo); const useCase = getWorkspaceUseCase(malformedRepo);
await expect(useCase({ id: "anything" })).rejects.toBeInstanceOf(ZodError); await expect(useCase({ id: "anything" })).rejects.toBeInstanceOf(ZodError);

View File

@@ -14,6 +14,7 @@ import { workspacesContainer } from "./container";
import { WORKSPACES_SYMBOLS } from "./symbols"; import { WORKSPACES_SYMBOLS } from "./symbols";
import { workspacesManifest } from "../feature.manifest"; import { workspacesManifest } from "../feature.manifest";
import { WorkspaceRepository } from "../infrastructure/repositories/workspace.repository"; import { WorkspaceRepository } from "../infrastructure/repositories/workspace.repository";
import { requireVeectSecret } from "../infrastructure/crypto/credential-cipher";
import { connectWorkspaceUseCase } from "../application/use-cases/connect-workspace.use-case"; import { connectWorkspaceUseCase } from "../application/use-cases/connect-workspace.use-case";
import { getWorkspaceUseCase } from "../application/use-cases/get-workspace.use-case"; import { getWorkspaceUseCase } from "../application/use-cases/get-workspace.use-case";
import { connectWorkspaceController } from "../interface-adapters/controllers/connect-workspace.controller"; import { connectWorkspaceController } from "../interface-adapters/controllers/connect-workspace.controller";
@@ -45,11 +46,19 @@ export function bindProductionWorkspaces(ctx: BindProductionContext): void {
.bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER) .bind<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER)
.toConstantValue(logger); .toConstantValue(logger);
// Real repository // Real repository. `requireVeectSecret()` fails the boot fast (clear
// message) when VEECT_SECRET is missing — production cannot encrypt or
// decrypt workspace credentials without it. Dev-seed mode never runs this.
const credentialSecret = requireVeectSecret();
if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IWorkspaceRepository)) { if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IWorkspaceRepository)) {
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IWorkspaceRepository); workspacesContainer.unbind(WORKSPACES_SYMBOLS.IWorkspaceRepository);
} }
const repo = new WorkspaceRepository(config, tracer, logger); const repo = new WorkspaceRepository(
config,
credentialSecret,
tracer,
logger,
);
workspacesContainer workspacesContainer
.bind(WORKSPACES_SYMBOLS.IWorkspaceRepository) .bind(WORKSPACES_SYMBOLS.IWorkspaceRepository)
.toConstantValue(repo); .toConstantValue(repo);

View File

@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
decryptCredential,
encryptCredential,
isEncryptedCredential,
requireVeectSecret,
} from "@/infrastructure/crypto/credential-cipher";
const SECRET = "test-secret-0123456789abcdef0123456789abcdef";
const PAT = "ghp_super-secret-token";
describe("encryptCredential / decryptCredential", () => {
it("round-trips a credential", () => {
const stored = encryptCredential(SECRET, PAT);
expect(decryptCredential(SECRET, stored)).toBe(PAT);
});
it("emits the v1:<salt>:<iv>:<authTag>:<ciphertext> format", () => {
const stored = encryptCredential(SECRET, PAT);
const parts = stored.split(":");
expect(parts).toHaveLength(5);
expect(parts[0]).toBe("v1");
// salt 16 bytes, iv 12 bytes, GCM tag 16 bytes
expect(Buffer.from(parts[1]!, "base64")).toHaveLength(16);
expect(Buffer.from(parts[2]!, "base64")).toHaveLength(12);
expect(Buffer.from(parts[3]!, "base64")).toHaveLength(16);
});
it("never stores the plaintext", () => {
const stored = encryptCredential(SECRET, PAT);
expect(stored).not.toContain(PAT);
expect(
Buffer.from(stored.split(":")[4]!, "base64").toString("utf8"),
).not.toBe(PAT);
});
it("uses a fresh salt + iv per call — two encryptions of the same value differ", () => {
expect(encryptCredential(SECRET, PAT)).not.toBe(
encryptCredential(SECRET, PAT),
);
});
it("rejects decryption with the wrong secret", () => {
const stored = encryptCredential(SECRET, PAT);
expect(() => decryptCredential("wrong-secret", stored)).toThrow();
});
it("rejects tampered ciphertext (GCM auth tag)", () => {
const stored = encryptCredential(SECRET, PAT);
const parts = stored.split(":");
const tampered = Buffer.from(parts[4]!, "base64");
tampered[0] = tampered[0]! ^ 0xff;
parts[4] = tampered.toString("base64");
expect(() => decryptCredential(SECRET, parts.join(":"))).toThrow();
});
it("rejects an unrecognized storage format", () => {
expect(() => decryptCredential(SECRET, "plaintext-not-encrypted")).toThrow(
/Unrecognized encrypted credential format/,
);
expect(() => decryptCredential(SECRET, "v2:a:b:c:d")).toThrow(
/Unrecognized encrypted credential format/,
);
});
});
describe("isEncryptedCredential", () => {
it("recognizes encrypted values", () => {
expect(isEncryptedCredential(encryptCredential(SECRET, PAT))).toBe(true);
});
it("rejects plaintext values", () => {
expect(isEncryptedCredential(PAT)).toBe(false);
expect(isEncryptedCredential("v1-but-not-really")).toBe(false);
});
});
describe("requireVeectSecret", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("returns the secret when set", () => {
vi.stubEnv("VEECT_SECRET", SECRET);
expect(requireVeectSecret()).toBe(SECRET);
});
it("throws a clear, actionable error when unset", () => {
vi.stubEnv("VEECT_SECRET", undefined);
expect(() => requireVeectSecret()).toThrow(
/VEECT_SECRET environment variable is required/,
);
});
it("throws when set to an empty string", () => {
vi.stubEnv("VEECT_SECRET", "");
expect(() => requireVeectSecret()).toThrow(/VEECT_SECRET/);
});
});

View File

@@ -0,0 +1,86 @@
import {
createCipheriv,
createDecipheriv,
randomBytes,
scryptSync,
} from "node:crypto";
/**
* AES-256-GCM credential encryption for workspace PATs (tech spec §13).
*
* - Key derived from `VEECT_SECRET` via scrypt with a random per-value salt.
* - Random 12-byte IV per encryption; GCM auth tag guards integrity.
* - Storage format: `v1:<salt>:<iv>:<authTag>:<ciphertext>` (base64 parts).
*
* node:crypto only — no runtime dependencies.
*/
const ALGORITHM = "aes-256-gcm";
const KEY_LENGTH = 32;
const IV_LENGTH = 12;
const SALT_LENGTH = 16;
const FORMAT_VERSION = "v1";
/**
* Reads `VEECT_SECRET`, failing fast with an actionable message. Called at
* production bind time (boot-time error when missing) and by the collection's
* encrypt-on-write hook. Dev-seed mode never calls this — it must boot
* without the secret.
*/
export function requireVeectSecret(): string {
const secret = process.env.VEECT_SECRET;
if (!secret) {
throw new Error(
"VEECT_SECRET environment variable is required to encrypt/decrypt workspace credentials. " +
"Generate one via `openssl rand -hex 32` and set it in .env (development) " +
"or your secrets manager (production).",
);
}
return secret;
}
/** True when `value` is already in this module's encrypted storage format. */
export function isEncryptedCredential(value: string): boolean {
return (
value.startsWith(`${FORMAT_VERSION}:`) && value.split(":").length === 5
);
}
export function encryptCredential(secret: string, plaintext: string): string {
const salt = randomBytes(SALT_LENGTH);
const key = scryptSync(secret, salt, KEY_LENGTH);
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const ciphertext = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
return [
FORMAT_VERSION,
salt.toString("base64"),
iv.toString("base64"),
authTag.toString("base64"),
ciphertext.toString("base64"),
].join(":");
}
export function decryptCredential(secret: string, stored: string): string {
const [version, saltB64, ivB64, tagB64, dataB64] = stored.split(":");
if (version !== FORMAT_VERSION || !saltB64 || !ivB64 || !tagB64 || !dataB64) {
throw new Error(
"Unrecognized encrypted credential format — expected v1:<salt>:<iv>:<authTag>:<ciphertext>",
);
}
const key = scryptSync(secret, Buffer.from(saltB64, "base64"), KEY_LENGTH);
const decipher = createDecipheriv(
ALGORITHM,
key,
Buffer.from(ivB64, "base64"),
);
decipher.setAuthTag(Buffer.from(tagB64, "base64"));
return Buffer.concat([
decipher.update(Buffer.from(dataB64, "base64")),
decipher.final(),
]).toString("utf8");
}

View File

@@ -86,4 +86,19 @@ export class MockWorkspaceRepository implements IWorkspaceRepository {
}, },
); );
} }
async getDecryptedCredential(id: string): Promise<string | null> {
return this.tracer.startSpan(
{
name: "workspace.getDecryptedCredential",
op: "repository",
attributes: { id },
},
async (span) => {
const found = this.credentials.get(id) ?? null;
span.setAttribute("found", found !== null);
return found;
},
);
}
} }

View File

@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi, beforeEach, afterAll } from "vitest";
import type { FieldHook } from "payload";
import type { ITracer } from "@repo/core-shared/instrumentation"; import type { ITracer } from "@repo/core-shared/instrumentation";
import { import {
RecordingTracer, RecordingTracer,
@@ -6,76 +7,201 @@ import {
} from "@repo/core-testing/instrumentation"; } from "@repo/core-testing/instrumentation";
import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config"; import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config";
import { WorkspaceRepository } from "@/infrastructure/repositories/workspace.repository"; import { WorkspaceRepository } from "@/infrastructure/repositories/workspace.repository";
import { workspaces } from "@/integrations/cms/collections/workspaces";
import { isEncryptedCredential } from "@/infrastructure/crypto/credential-cipher";
import {
workspaceRepositoryContract,
CONTRACT_WORKSPACE_SEED,
CONTRACT_WORKSPACE_PAT,
} from "@/__contracts__/workspace-repository.contract";
// Phase-1 scaffold: the real repository returns null until the Payload vi.mock("payload", () => ({ getPayload: vi.fn() }));
// collection is wired. These tests pin the span shape and the stub return
// value so that callers (use case + DI tests) keep working when the body is
// later replaced with a real `payload.find()` call.
describe("WorkspaceRepository (Phase-1 stub)", () => { const SECRET = "test-secret-0123456789abcdef0123456789abcdef";
it("returns null and emits a span with op='repository'", async () => { vi.stubEnv("VEECT_SECRET", SECRET);
afterAll(() => {
vi.unstubAllEnvs();
});
/**
* In-memory Payload stub that honours the REAL workspaces collection config:
* `create` runs each field's `beforeChange` hooks (so the credential is
* encrypted exactly as Payload would at runtime) and applies select
* defaults. Reads return the stored doc — the repository always reads with
* `overrideAccess: true`, and `toDomain` owns credential stripping.
*/
function buildPayloadStub(
seed: ReadonlyArray<readonly [string, Record<string, unknown>]> = [],
) {
const store = new Map<string, Record<string, unknown>>(
seed.map(([id, doc]) => [id, { ...doc }]),
);
let nextId = 1;
async function applyBeforeChangeHooks(
data: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const doc: Record<string, unknown> = { ...data };
for (const field of workspaces.fields) {
if (!("name" in field)) continue;
const hooks = ("hooks" in field && field.hooks?.beforeChange) || [];
for (const hook of hooks as FieldHook[]) {
doc[field.name] = await hook({
value: doc[field.name],
data: doc,
} as Parameters<FieldHook>[0]);
}
}
return doc;
}
return {
create: vi.fn(
async ({
data,
}: {
collection: string;
data: Record<string, unknown>;
}) => {
const doc = await applyBeforeChangeHooks(data);
doc.id = `pw-${nextId++}`;
store.set(String(doc.id), doc);
return doc;
},
),
findByID: vi.fn(
async ({ id }: { collection: string; id: string }) =>
store.get(String(id)) ?? null,
),
__store: store,
};
}
async function buildRepo(tracer?: ITracer) {
const stub = buildPayloadStub(CONTRACT_WORKSPACE_SEED);
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
return {
stub,
repo: new WorkspaceRepository(stubPayloadConfig, SECRET, tracer),
};
}
describe("WorkspaceRepository", () => {
describe("contract", () => {
const tracer = new RecordingTracer(); const tracer = new RecordingTracer();
const logger = new RecordingLogger();
const repo = new WorkspaceRepository(stubPayloadConfig, tracer, logger);
const result = await repo.getWorkspace("anything"); beforeEach(() => {
vi.clearAllMocks();
});
expect(result).toBeNull(); workspaceRepositoryContract.run(
expect(tracer.spans).toHaveLength(1); async () => (await buildRepo(tracer)).repo,
expect(tracer.spans[0]).toMatchObject({ { tracer: () => tracer },
name: "workspace.getWorkspace", );
op: "repository", });
describe("encryption at rest", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("stores the credential encrypted — the plaintext never reaches the database", async () => {
const { stub, repo } = await buildRepo();
const created = await repo.createWorkspace({
name: "Enc",
gitUrl: "https://github.com/acme/enc.git",
credential: CONTRACT_WORKSPACE_PAT,
});
const storedDoc = stub.__store.get(created.id)!;
expect(typeof storedDoc.credential).toBe("string");
expect(storedDoc.credential).not.toBe(CONTRACT_WORKSPACE_PAT);
expect(isEncryptedCredential(storedDoc.credential as string)).toBe(true);
expect(JSON.stringify(storedDoc)).not.toContain(CONTRACT_WORKSPACE_PAT);
});
it("getDecryptedCredential decrypts the stored value back to the plaintext", async () => {
const { repo } = await buildRepo();
const created = await repo.createWorkspace({
name: "Enc",
gitUrl: "https://github.com/acme/enc.git",
credential: CONTRACT_WORKSPACE_PAT,
});
await expect(repo.getDecryptedCredential(created.id)).resolves.toBe(
CONTRACT_WORKSPACE_PAT,
);
}); });
}); });
it("records the requested id as a span attribute", async () => { describe("error paths (capture-at-throw-site)", () => {
const tracer = new RecordingTracer(); beforeEach(() => {
const repo = new WorkspaceRepository(stubPayloadConfig, tracer); vi.clearAllMocks();
await repo.getWorkspace("custom-id"); });
expect(tracer.spans[0]!.attributes.id).toBe("custom-id");
expect(tracer.spans[0]!.attributes.found).toBe(false);
});
it("getWorkspace captures and rethrows infra errors (capture-at-throw-site)", async () => { it("getWorkspace treats a Payload 404 error as null", async () => {
const logger = new RecordingLogger(); const stub = {
// Span whose setAttribute throws — drives the repository's catch path findByID: vi.fn(async () => {
// that the real payload-backed body will rely on. throw Object.assign(new Error("Not Found"), { status: 404 });
const throwingTracer: ITracer = {
startSpan: (_opts, fn) =>
fn({
setAttribute: () => {
throw new Error("span boom");
},
setStatus: () => {},
}), }),
}; };
const repo = new WorkspaceRepository( const { getPayload } = await import("payload");
stubPayloadConfig, (getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
throwingTracer, const repo = new WorkspaceRepository(stubPayloadConfig, SECRET);
logger,
);
await expect(repo.getWorkspace("x")).rejects.toThrow("span boom"); await expect(repo.getWorkspace("missing")).resolves.toBeNull();
expect(logger.captures).toHaveLength(1); });
});
it("createWorkspace fails loudly until the Payload collection is registered", async () => { it("getDecryptedCredential treats a Payload 404 error as null", async () => {
const tracer = new RecordingTracer(); const stub = {
const logger = new RecordingLogger(); findByID: vi.fn(async () => {
const repo = new WorkspaceRepository(stubPayloadConfig, tracer, logger); throw Object.assign(new Error("Not Found"), { status: 404 });
}),
};
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
const repo = new WorkspaceRepository(stubPayloadConfig, SECRET);
await expect( await expect(repo.getDecryptedCredential("missing")).resolves.toBeNull();
repo.createWorkspace({ });
name: "Acme Web",
gitUrl: "https://github.com/acme/web.git",
credential: "ghp_token",
}),
).rejects.toThrow(/not registered yet/);
expect(logger.captures).toHaveLength(1); it.each([
expect(tracer.spans[0]).toMatchObject({ ["getWorkspace", (r: WorkspaceRepository) => r.getWorkspace("x")],
name: "workspace.createWorkspace", [
op: "repository", "createWorkspace",
(r: WorkspaceRepository) =>
r.createWorkspace({
name: "X",
gitUrl: "https://github.com/acme/x.git",
credential: "pat",
}),
],
[
"getDecryptedCredential",
(r: WorkspaceRepository) => r.getDecryptedCredential("x"),
],
])("%s captures and rethrows infra errors", async (_name, call) => {
const stub = {
findByID: vi.fn(async () => {
throw new Error("infra boom");
}),
create: vi.fn(async () => {
throw new Error("infra boom");
}),
};
const { getPayload } = await import("payload");
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
const logger = new RecordingLogger();
const repo = new WorkspaceRepository(
stubPayloadConfig,
SECRET,
undefined,
logger,
);
await expect(call(repo)).rejects.toThrow("infra boom");
expect(logger.captures).toHaveLength(1);
}); });
}); });
}); });

View File

@@ -1,5 +1,6 @@
import "reflect-metadata"; import "reflect-metadata";
import { injectable } from "inversify"; import { injectable } from "inversify";
import { getPayload } from "payload";
import type { SanitizedConfig } from "payload"; import type { SanitizedConfig } from "payload";
import { import {
NoopTracer, NoopTracer,
@@ -13,46 +14,56 @@ import type {
IWorkspaceRepository, IWorkspaceRepository,
} from "../../application/repositories/workspace.repository.interface"; } from "../../application/repositories/workspace.repository.interface";
import type { Workspace } from "../../entities/models/workspace"; import type { Workspace } from "../../entities/models/workspace";
import { decryptCredential } from "../crypto/credential-cipher";
const FEATURE = "workspaces" as const; const FEATURE = "workspaces" as const;
const REPO = "workspace" as const; const REPO = "workspace" as const;
/** /**
* Phase-1 scaffold — the Payload collection has not been added yet, so this * Payload-backed workspace repository. The `credential` column is encrypted
* repository emits a span but always resolves to `null`. Once you add the * at rest by the collection's field-level `beforeChange` hook and is
* collection at `integrations/cms/collections/workspace.ts` and * write-only through every access-controlled API path; this repository's
* register it with Payload, replace the stub below with a real `payload.find` * `getDecryptedCredential` is the single server-side decrypt path (runner
* call (see `packages/blog/src/infrastructure/repositories/articles.repository.ts` * handoff, story 07). `toDomain` never maps the credential, so ordinary
* for the canonical pattern). * reads are credential-free by construction.
*/ */
@injectable() @injectable()
export class WorkspaceRepository implements IWorkspaceRepository { export class WorkspaceRepository implements IWorkspaceRepository {
private config: SanitizedConfig; private config: SanitizedConfig;
private credentialSecret: string;
private tracer: ITracer; private tracer: ITracer;
private logger: ILogger; private logger: ILogger;
constructor( constructor(
config: SanitizedConfig, config: SanitizedConfig,
credentialSecret: string,
tracer: ITracer = new NoopTracer(), tracer: ITracer = new NoopTracer(),
logger: ILogger = new NoopLogger(), logger: ILogger = new NoopLogger(),
) { ) {
this.config = config; this.config = config;
this.credentialSecret = credentialSecret;
this.tracer = tracer; this.tracer = tracer;
this.logger = logger; this.logger = logger;
void this.config;
} }
async getWorkspace(id: string): Promise<Workspace | null> { async getWorkspace(id: string): Promise<Workspace | null> {
return this.tracer.startSpan( return this.tracer.startSpan(
{ name: "workspace.getWorkspace", op: "repository", attributes: {} }, { name: "workspace.getWorkspace", op: "repository", attributes: { id } },
async (span) => { async (span) => {
try { try {
// TODO: replace with `payload.find({ collection: "workspaces", where: { id: { equals: id } } })` const payload = await getPayload({ config: this.config });
// once the Payload collection is registered. const doc = await payload.findByID({
span.setAttribute("id", id); collection: "workspaces",
span.setAttribute("found", false); id,
return null; overrideAccess: true,
});
span.setAttribute("found", Boolean(doc));
return doc ? this.toDomain(doc as Record<string, unknown>) : null;
} catch (err) { } catch (err) {
if (isNotFound(err)) {
span.setAttribute("found", false);
return null;
}
this.logger.captureException(err, { this.logger.captureException(err, {
tags: { feature: FEATURE, repo: REPO, method: "getWorkspace" }, tags: { feature: FEATURE, repo: REPO, method: "getWorkspace" },
}); });
@@ -70,20 +81,95 @@ export class WorkspaceRepository implements IWorkspaceRepository {
return this.tracer.startSpan( return this.tracer.startSpan(
{ name: "workspace.createWorkspace", op: "repository", attributes: {} }, { name: "workspace.createWorkspace", op: "repository", attributes: {} },
async (span) => { async (span) => {
// Placeholder until the encrypted-credential storage slice registers try {
// the Payload collection: fail loudly rather than silently dropping const payload = await getPayload({ config: this.config });
// the write. Replaced by a real `payload.create` with AES-256-GCM // The collection's beforeChange field hook encrypts `credential`
// credential encryption in the same story. // before it reaches the database.
void data; const created = await payload.create({
const err = new Error( collection: "workspaces",
"Workspaces Payload collection is not registered yet — createWorkspace is unavailable in production mode.", data: {
); name: data.name,
this.logger.captureException(err, { gitUrl: data.gitUrl,
tags: { feature: FEATURE, repo: REPO, method: "createWorkspace" }, status: "created",
}); credential: data.credential,
span.setStatus("error", err.message); },
throw err; overrideAccess: true,
});
span.setAttribute("created", true);
return this.toDomain(created as Record<string, unknown>);
} catch (err) {
this.logger.captureException(err, {
tags: { feature: FEATURE, repo: REPO, method: "createWorkspace" },
});
span.setStatus(
"error",
err instanceof Error ? err.message : String(err),
);
throw err;
}
}, },
); );
} }
async getDecryptedCredential(id: string): Promise<string | null> {
return this.tracer.startSpan(
{
name: "workspace.getDecryptedCredential",
op: "repository",
attributes: { id },
},
async (span) => {
try {
const payload = await getPayload({ config: this.config });
// overrideAccess bypasses the field's read: () => false — this is
// the single sanctioned decrypt path (server-side runner handoff).
const doc = await payload.findByID({
collection: "workspaces",
id,
overrideAccess: true,
});
const stored = (doc as Record<string, unknown> | null)?.credential;
span.setAttribute("found", typeof stored === "string");
if (typeof stored !== "string") return null;
return decryptCredential(this.credentialSecret, stored);
} catch (err) {
if (isNotFound(err)) {
span.setAttribute("found", false);
return null;
}
this.logger.captureException(err, {
tags: {
feature: FEATURE,
repo: REPO,
method: "getDecryptedCredential",
},
});
span.setStatus(
"error",
err instanceof Error ? err.message : String(err),
);
throw err;
}
},
);
}
private toDomain(doc: Record<string, unknown>): Workspace {
// Deliberately never maps `credential`.
return {
id: String(doc.id),
name: doc.name as string,
gitUrl: doc.gitUrl as string,
status: doc.status as Workspace["status"],
};
}
}
function isNotFound(err: unknown): boolean {
return (
err !== null &&
typeof err === "object" &&
"status" in err &&
(err as { status: unknown }).status === 404
);
} }

View File

@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FieldHook, TextField } from "payload";
import { workspaces } from "@/integrations/cms/collections/workspaces";
import {
decryptCredential,
isEncryptedCredential,
} from "@/infrastructure/crypto/credential-cipher";
const SECRET = "test-secret-0123456789abcdef0123456789abcdef";
const PAT = "ghp_super-secret-token";
function credentialField(): TextField {
const field = workspaces.fields.find(
(f) => "name" in f && f.name === "credential",
);
if (!field) throw new Error("credential field missing");
return field as TextField;
}
function encryptHook(): FieldHook {
const hook = credentialField().hooks?.beforeChange?.[0];
if (!hook) throw new Error("credential beforeChange hook missing");
return hook;
}
type HookArgs = Parameters<FieldHook>[0];
describe("workspaces collection", () => {
it("uses the workspaces slug with name/gitUrl/status/credential fields", () => {
expect(workspaces.slug).toBe("workspaces");
const names = workspaces.fields.map((f) => ("name" in f ? f.name : ""));
expect(names).toEqual(["name", "gitUrl", "status", "credential"]);
});
it("declares a retention purge schedule (compliance requirement)", () => {
expect(
(workspaces.custom as { retention: { purgeSchedule: string } }).retention
.purgeSchedule,
).toBe("daily");
});
it("status options match the persisted status enum", () => {
const status = workspaces.fields.find(
(f) => "name" in f && f.name === "status",
) as { options: { value: string }[]; defaultValue: string };
expect(status.options.map((o) => o.value)).toEqual([
"created",
"connecting",
"ready",
"error",
]);
expect(status.defaultValue).toBe("created");
});
describe("credential field (write-only, encrypted at rest)", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("denies read access unconditionally", () => {
const read = credentialField().access?.read;
expect(read).toBeDefined();
expect(read!({} as Parameters<NonNullable<typeof read>>[0])).toBe(false);
});
it("encrypts the plaintext PAT on write", async () => {
vi.stubEnv("VEECT_SECRET", SECRET);
const stored = await encryptHook()({ value: PAT } as HookArgs);
expect(stored).not.toBe(PAT);
expect(isEncryptedCredential(stored as string)).toBe(true);
expect(decryptCredential(SECRET, stored as string)).toBe(PAT);
});
it("passes an already-encrypted value through untouched (update flows)", async () => {
vi.stubEnv("VEECT_SECRET", SECRET);
const first = await encryptHook()({ value: PAT } as HookArgs);
const second = await encryptHook()({ value: first } as HookArgs);
expect(second).toBe(first);
});
it("passes undefined through (partial updates without the field)", async () => {
const result = await encryptHook()({ value: undefined } as HookArgs);
expect(result).toBeUndefined();
});
it("fails loudly when VEECT_SECRET is missing at write time", async () => {
vi.stubEnv("VEECT_SECRET", undefined);
await expect(async () =>
encryptHook()({ value: PAT } as HookArgs),
).rejects.toThrow(/VEECT_SECRET/);
});
});
});

View File

@@ -0,0 +1,82 @@
import type { CollectionConfig } from "payload";
import {
encryptCredential,
isEncryptedCredential,
requireVeectSecret,
} from "../../../infrastructure/crypto/credential-cipher";
/**
* Workspaces collection — the root entity of the control plane.
*
* The `credential` field (repository PAT) is write-only and encrypted at
* rest:
* - `access.read: () => false` strips it from every access-controlled
* read path (REST, GraphQL, admin UI). Only local-API calls that pass
* `overrideAccess: true` — the repository's internal decrypt path —
* can see the (still encrypted) stored value.
* - The field-level `beforeChange` hook encrypts on write with
* AES-256-GCM (scrypt key from `VEECT_SECRET`), so the plaintext never
* reaches the database. Already-encrypted values pass through untouched
* (Payload feeds existing values back through update flows).
* - There is deliberately NO `afterRead` decrypt hook — decryption happens
* only in `WorkspaceRepository.getDecryptedCredential`, the narrow
* server-side path for the runner handoff.
*/
export const workspaces: CollectionConfig = {
slug: "workspaces",
admin: {
useAsTitle: "name",
},
custom: {
retention: {
purgeSchedule: "daily",
postDeletion: {
duration: "P30D",
trigger: "after-deletion",
action: "hard-delete",
},
},
},
fields: [
{
name: "name",
type: "text",
required: true,
},
{
name: "gitUrl",
type: "text",
required: true,
},
{
name: "status",
type: "select",
options: [
{ label: "Created", value: "created" },
{ label: "Connecting", value: "connecting" },
{ label: "Ready", value: "ready" },
{ label: "Error", value: "error" },
],
defaultValue: "created",
required: true,
},
{
name: "credential",
type: "text",
required: true,
access: {
// Write-only: no API read path ever returns this field.
read: () => false,
},
hooks: {
beforeChange: [
({ value }) => {
if (typeof value !== "string" || value === "") return value;
if (isEncryptedCredential(value)) return value;
return encryptCredential(requireVeectSecret(), value);
},
],
},
},
],
};

View File

@@ -1,7 +1,5 @@
// Payload CMS integration barrel for @repo/workspaces. // Payload CMS integration barrel for @repo/workspaces.
// Re-export this feature's collections and globals here as you add them // The `<gen:job-tasks>` anchor below is required by `pnpm turbo gen job` and
// under ./collections and ./globals. See packages/auth/src/integrations/cms // `pnpm turbo gen event` — do not remove it.
// for the canonical shape. The `<gen:job-tasks>` anchor below is required by export { workspaces } from "./collections/workspaces";
// `pnpm turbo gen job` and `pnpm turbo gen event` — do not remove it.
export {};
// <gen:job-tasks> // <gen:job-tasks>

3
pnpm-lock.yaml generated
View File

@@ -454,6 +454,9 @@ importers:
"@repo/auth": "@repo/auth":
specifier: workspace:* specifier: workspace:*
version: link:../auth version: link:../auth
"@repo/workspaces":
specifier: workspace:*
version: link:../workspaces
payload: payload:
specifier: ^3.14.0 specifier: ^3.14.0
version: 3.81.0(graphql@16.13.2)(typescript@5.9.3) version: 3.81.0(graphql@16.13.2)(typescript@5.9.3)

View File

@@ -19,7 +19,8 @@
"VERCEL_GIT_COMMIT_SHA", "VERCEL_GIT_COMMIT_SHA",
"NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA", "NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA",
"VERCEL_ENV", "VERCEL_ENV",
"AUDIT_PSEUDONYM_SALT" "AUDIT_PSEUDONYM_SALT",
"VEECT_SECRET"
], ],
"boundaries": { "boundaries": {
"tags": { "tags": {