From c990e1b871ad1a3f314b4caad67f4fddf076fe46 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Sun, 12 Jul 2026 22:19:14 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK --- .env.example | 6 + compliance/data-map.yml | 4 + compliance/retention-policy.yml | 7 + packages/core-cms/package.json | 1 + packages/core-cms/src/payload.config.test.ts | 2 +- packages/core-cms/src/payload.config.ts | 3 +- .../workspace-repository.contract.ts | 69 ++++- .../workspace.repository.interface.ts | 7 + .../connect-workspace.use-case.test.ts | 3 + .../use-cases/get-workspace.use-case.test.ts | 1 + packages/workspaces/src/di/bind-production.ts | 13 +- .../crypto/credential-cipher.test.ts | 99 ++++++++ .../crypto/credential-cipher.ts | 86 +++++++ .../repositories/workspace.repository.mock.ts | 15 ++ .../repositories/workspace.repository.test.ts | 240 +++++++++++++----- .../repositories/workspace.repository.ts | 138 ++++++++-- .../cms/collections/workspaces.test.ts | 95 +++++++ .../cms/collections/workspaces.ts | 82 ++++++ .../workspaces/src/integrations/cms/index.ts | 8 +- pnpm-lock.yaml | 3 + turbo.json | 3 +- 21 files changed, 788 insertions(+), 97 deletions(-) create mode 100644 packages/workspaces/src/infrastructure/crypto/credential-cipher.test.ts create mode 100644 packages/workspaces/src/infrastructure/crypto/credential-cipher.ts create mode 100644 packages/workspaces/src/integrations/cms/collections/workspaces.test.ts create mode 100644 packages/workspaces/src/integrations/cms/collections/workspaces.ts diff --git a/.env.example b/.env.example index e564199..f19d288 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,12 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5433/template # Payload CMS encryption key. Any random 32+ char string in dev. 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) --- NEXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/compliance/data-map.yml b/compliance/data-map.yml index e197c30..a0edc15 100644 --- a/compliance/data-map.yml +++ b/compliance/data-map.yml @@ -21,3 +21,7 @@ collections: restrictable: true source: auth-default slug: users + workspaces: + auth: false + piiFields: [] + slug: workspaces diff --git a/compliance/retention-policy.yml b/compliance/retention-policy.yml index 907e509..29cb2f1 100644 --- a/compliance/retention-policy.yml +++ b/compliance/retention-policy.yml @@ -9,3 +9,10 @@ collections: trigger: after-deletion purgeSchedule: daily slug: users + workspaces: + postDeletion: + action: hard-delete + duration: P30D + trigger: after-deletion + purgeSchedule: daily + slug: workspaces diff --git a/packages/core-cms/package.json b/packages/core-cms/package.json index 6c79204..74ac799 100644 --- a/packages/core-cms/package.json +++ b/packages/core-cms/package.json @@ -17,6 +17,7 @@ "@payloadcms/db-postgres": "^3.14.0", "@payloadcms/richtext-lexical": "^3.14.0", "@repo/auth": "workspace:*", + "@repo/workspaces": "workspace:*", "payload": "^3.14.0" }, "devDependencies": { diff --git a/packages/core-cms/src/payload.config.test.ts b/packages/core-cms/src/payload.config.test.ts index 76d1d92..65d8821 100644 --- a/packages/core-cms/src/payload.config.test.ts +++ b/packages/core-cms/src/payload.config.test.ts @@ -5,7 +5,7 @@ describe("payloadConfig composition", () => { it("registers all feature collections", async () => { const resolved = await config; 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 () => { diff --git a/packages/core-cms/src/payload.config.ts b/packages/core-cms/src/payload.config.ts index bdbef54..302e342 100644 --- a/packages/core-cms/src/payload.config.ts +++ b/packages/core-cms/src/payload.config.ts @@ -5,13 +5,14 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { users } from "@repo/auth/cms"; +import { workspaces } from "@repo/workspaces/cms"; const filename = fileURLToPath(import.meta.url); const dirname = path.dirname(filename); export default buildConfig({ editor: lexicalEditor(), - collections: [users], + collections: [users, workspaces], globals: [], secret: process.env.PAYLOAD_SECRET || "default-secret-change-me", db: postgresAdapter({ diff --git a/packages/workspaces/src/__contracts__/workspace-repository.contract.ts b/packages/workspaces/src/__contracts__/workspace-repository.contract.ts index 58cf4dd..68682fe 100644 --- a/packages/workspaces/src/__contracts__/workspace-repository.contract.ts +++ b/packages/workspaces/src/__contracts__/workspace-repository.contract.ts @@ -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. - * - * The interface exposes only `getWorkspace(id)`. The contract verifies - * found vs missing behaviour and span emission. + * Contract for IWorkspaceRepository — runs against both the mock and the + * Payload implementation. Covers read/create behaviour, span emission, and + * the write-only credential guarantee: no read path returns the PAT; the + * narrow `getDecryptedCredential` path returns the plaintext for the runner + * handoff (story 07). */ export const workspaceRepositoryContract = defineContractSuite( @@ -59,6 +69,47 @@ export const workspaceRepositoryContract = 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", () => { it("getWorkspace emits span 'workspace.getWorkspace' with op=repository", async () => { if (!getTracer) return; @@ -69,6 +120,16 @@ export const workspaceRepositoryContract = expect(span).toBeDefined(); 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"); + }); }); }, ); diff --git a/packages/workspaces/src/application/repositories/workspace.repository.interface.ts b/packages/workspaces/src/application/repositories/workspace.repository.interface.ts index b9878ab..a7dff7f 100644 --- a/packages/workspaces/src/application/repositories/workspace.repository.interface.ts +++ b/packages/workspaces/src/application/repositories/workspace.repository.interface.ts @@ -16,4 +16,11 @@ export interface IWorkspaceRepository { getWorkspace(id: string): Promise; /** Persists a new workspace with initial status "created". */ createWorkspace(data: CreateWorkspaceData): Promise; + /** + * 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; } diff --git a/packages/workspaces/src/application/use-cases/connect-workspace.use-case.test.ts b/packages/workspaces/src/application/use-cases/connect-workspace.use-case.test.ts index 21337c9..20166c1 100644 --- a/packages/workspaces/src/application/use-cases/connect-workspace.use-case.test.ts +++ b/packages/workspaces/src/application/use-cases/connect-workspace.use-case.test.ts @@ -47,6 +47,7 @@ describe("connectWorkspaceUseCase", () => { it("strips a credential leaked by a misbehaving repository", async () => { const leakyRepo = { getWorkspace: async () => null, + getDecryptedCredential: async () => null, createWorkspace: async () => ({ id: "ws-1", name: "Acme Web", @@ -99,6 +100,7 @@ describe("connectWorkspaceUseCase", () => { it("does not audit when the repository write fails", async () => { const failingRepo = { getWorkspace: async () => null, + getDecryptedCredential: async () => null, createWorkspace: async () => { throw new Error("boom"); }, @@ -120,6 +122,7 @@ describe("connectWorkspaceUseCase", () => { it("throws ZodError when the repository returns malformed data", async () => { const malformedRepo = { getWorkspace: async () => null, + getDecryptedCredential: async () => null, createWorkspace: async () => ({ id: "", name: "x" }) as never, }; const useCase = connectWorkspaceUseCase(malformedRepo); diff --git a/packages/workspaces/src/application/use-cases/get-workspace.use-case.test.ts b/packages/workspaces/src/application/use-cases/get-workspace.use-case.test.ts index cb0b590..3d748ce 100644 --- a/packages/workspaces/src/application/use-cases/get-workspace.use-case.test.ts +++ b/packages/workspaces/src/application/use-cases/get-workspace.use-case.test.ts @@ -25,6 +25,7 @@ describe("getWorkspaceUseCase", () => { const malformedRepo = { getWorkspace: async () => ({ id: "", name: "x" }) as never, createWorkspace: async () => ({ id: "", name: "x" }) as never, + getDecryptedCredential: async () => null, }; const useCase = getWorkspaceUseCase(malformedRepo); await expect(useCase({ id: "anything" })).rejects.toBeInstanceOf(ZodError); diff --git a/packages/workspaces/src/di/bind-production.ts b/packages/workspaces/src/di/bind-production.ts index 9290cc7..654d37e 100644 --- a/packages/workspaces/src/di/bind-production.ts +++ b/packages/workspaces/src/di/bind-production.ts @@ -14,6 +14,7 @@ import { workspacesContainer } from "./container"; import { WORKSPACES_SYMBOLS } from "./symbols"; import { workspacesManifest } from "../feature.manifest"; 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 { getWorkspaceUseCase } from "../application/use-cases/get-workspace.use-case"; import { connectWorkspaceController } from "../interface-adapters/controllers/connect-workspace.controller"; @@ -45,11 +46,19 @@ export function bindProductionWorkspaces(ctx: BindProductionContext): void { .bind(INSTRUMENTATION_SYMBOLS.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)) { workspacesContainer.unbind(WORKSPACES_SYMBOLS.IWorkspaceRepository); } - const repo = new WorkspaceRepository(config, tracer, logger); + const repo = new WorkspaceRepository( + config, + credentialSecret, + tracer, + logger, + ); workspacesContainer .bind(WORKSPACES_SYMBOLS.IWorkspaceRepository) .toConstantValue(repo); diff --git a/packages/workspaces/src/infrastructure/crypto/credential-cipher.test.ts b/packages/workspaces/src/infrastructure/crypto/credential-cipher.test.ts new file mode 100644 index 0000000..8da2681 --- /dev/null +++ b/packages/workspaces/src/infrastructure/crypto/credential-cipher.test.ts @@ -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:::: 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/); + }); +}); diff --git a/packages/workspaces/src/infrastructure/crypto/credential-cipher.ts b/packages/workspaces/src/infrastructure/crypto/credential-cipher.ts new file mode 100644 index 0000000..4814587 --- /dev/null +++ b/packages/workspaces/src/infrastructure/crypto/credential-cipher.ts @@ -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::::` (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::::", + ); + } + 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"); +} diff --git a/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.ts b/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.ts index c5440ea..021757e 100644 --- a/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.ts +++ b/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.ts @@ -86,4 +86,19 @@ export class MockWorkspaceRepository implements IWorkspaceRepository { }, ); } + + async getDecryptedCredential(id: string): Promise { + 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; + }, + ); + } } diff --git a/packages/workspaces/src/infrastructure/repositories/workspace.repository.test.ts b/packages/workspaces/src/infrastructure/repositories/workspace.repository.test.ts index bf58a83..d306aa8 100644 --- a/packages/workspaces/src/infrastructure/repositories/workspace.repository.test.ts +++ b/packages/workspaces/src/infrastructure/repositories/workspace.repository.test.ts @@ -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 { RecordingTracer, @@ -6,76 +7,201 @@ import { } from "@repo/core-testing/instrumentation"; import { stubPayloadConfig } from "@repo/core-testing/payload/stub-config"; 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 -// 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. +vi.mock("payload", () => ({ getPayload: vi.fn() })); -describe("WorkspaceRepository (Phase-1 stub)", () => { - it("returns null and emits a span with op='repository'", async () => { +const SECRET = "test-secret-0123456789abcdef0123456789abcdef"; +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]> = [], +) { + const store = new Map>( + seed.map(([id, doc]) => [id, { ...doc }]), + ); + let nextId = 1; + + async function applyBeforeChangeHooks( + data: Record, + ): Promise> { + const doc: Record = { ...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[0]); + } + } + return doc; + } + + return { + create: vi.fn( + async ({ + data, + }: { + collection: string; + data: Record; + }) => { + 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).mockResolvedValue(stub); + return { + stub, + repo: new WorkspaceRepository(stubPayloadConfig, SECRET, tracer), + }; +} + +describe("WorkspaceRepository", () => { + describe("contract", () => { 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(); - expect(tracer.spans).toHaveLength(1); - expect(tracer.spans[0]).toMatchObject({ - name: "workspace.getWorkspace", - op: "repository", + workspaceRepositoryContract.run( + async () => (await buildRepo(tracer)).repo, + { tracer: () => tracer }, + ); + }); + + 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 () => { - const tracer = new RecordingTracer(); - const repo = new WorkspaceRepository(stubPayloadConfig, tracer); - await repo.getWorkspace("custom-id"); - expect(tracer.spans[0]!.attributes.id).toBe("custom-id"); - expect(tracer.spans[0]!.attributes.found).toBe(false); - }); + describe("error paths (capture-at-throw-site)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - it("getWorkspace captures and rethrows infra errors (capture-at-throw-site)", async () => { - const logger = new RecordingLogger(); - // Span whose setAttribute throws — drives the repository's catch path - // that the real payload-backed body will rely on. - const throwingTracer: ITracer = { - startSpan: (_opts, fn) => - fn({ - setAttribute: () => { - throw new Error("span boom"); - }, - setStatus: () => {}, + it("getWorkspace treats a Payload 404 error as null", async () => { + const stub = { + findByID: vi.fn(async () => { + throw Object.assign(new Error("Not Found"), { status: 404 }); }), - }; - const repo = new WorkspaceRepository( - stubPayloadConfig, - throwingTracer, - logger, - ); + }; + const { getPayload } = await import("payload"); + (getPayload as ReturnType).mockResolvedValue(stub); + const repo = new WorkspaceRepository(stubPayloadConfig, SECRET); - await expect(repo.getWorkspace("x")).rejects.toThrow("span boom"); - expect(logger.captures).toHaveLength(1); - }); + await expect(repo.getWorkspace("missing")).resolves.toBeNull(); + }); - it("createWorkspace fails loudly until the Payload collection is registered", async () => { - const tracer = new RecordingTracer(); - const logger = new RecordingLogger(); - const repo = new WorkspaceRepository(stubPayloadConfig, tracer, logger); + it("getDecryptedCredential treats a Payload 404 error as null", async () => { + const stub = { + findByID: vi.fn(async () => { + throw Object.assign(new Error("Not Found"), { status: 404 }); + }), + }; + const { getPayload } = await import("payload"); + (getPayload as ReturnType).mockResolvedValue(stub); + const repo = new WorkspaceRepository(stubPayloadConfig, SECRET); - await expect( - repo.createWorkspace({ - name: "Acme Web", - gitUrl: "https://github.com/acme/web.git", - credential: "ghp_token", - }), - ).rejects.toThrow(/not registered yet/); + await expect(repo.getDecryptedCredential("missing")).resolves.toBeNull(); + }); - expect(logger.captures).toHaveLength(1); - expect(tracer.spans[0]).toMatchObject({ - name: "workspace.createWorkspace", - op: "repository", + it.each([ + ["getWorkspace", (r: WorkspaceRepository) => r.getWorkspace("x")], + [ + "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).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); }); }); }); diff --git a/packages/workspaces/src/infrastructure/repositories/workspace.repository.ts b/packages/workspaces/src/infrastructure/repositories/workspace.repository.ts index cfbc2ea..523c67a 100644 --- a/packages/workspaces/src/infrastructure/repositories/workspace.repository.ts +++ b/packages/workspaces/src/infrastructure/repositories/workspace.repository.ts @@ -1,5 +1,6 @@ import "reflect-metadata"; import { injectable } from "inversify"; +import { getPayload } from "payload"; import type { SanitizedConfig } from "payload"; import { NoopTracer, @@ -13,46 +14,56 @@ import type { IWorkspaceRepository, } from "../../application/repositories/workspace.repository.interface"; import type { Workspace } from "../../entities/models/workspace"; +import { decryptCredential } from "../crypto/credential-cipher"; const FEATURE = "workspaces" as const; const REPO = "workspace" as const; /** - * Phase-1 scaffold — the Payload collection has not been added yet, so this - * repository emits a span but always resolves to `null`. Once you add the - * collection at `integrations/cms/collections/workspace.ts` and - * register it with Payload, replace the stub below with a real `payload.find` - * call (see `packages/blog/src/infrastructure/repositories/articles.repository.ts` - * for the canonical pattern). + * Payload-backed workspace repository. The `credential` column is encrypted + * at rest by the collection's field-level `beforeChange` hook and is + * write-only through every access-controlled API path; this repository's + * `getDecryptedCredential` is the single server-side decrypt path (runner + * handoff, story 07). `toDomain` never maps the credential, so ordinary + * reads are credential-free by construction. */ @injectable() export class WorkspaceRepository implements IWorkspaceRepository { private config: SanitizedConfig; + private credentialSecret: string; private tracer: ITracer; private logger: ILogger; constructor( config: SanitizedConfig, + credentialSecret: string, tracer: ITracer = new NoopTracer(), logger: ILogger = new NoopLogger(), ) { this.config = config; + this.credentialSecret = credentialSecret; this.tracer = tracer; this.logger = logger; - void this.config; } async getWorkspace(id: string): Promise { return this.tracer.startSpan( - { name: "workspace.getWorkspace", op: "repository", attributes: {} }, + { name: "workspace.getWorkspace", op: "repository", attributes: { id } }, async (span) => { try { - // TODO: replace with `payload.find({ collection: "workspaces", where: { id: { equals: id } } })` - // once the Payload collection is registered. - span.setAttribute("id", id); - span.setAttribute("found", false); - return null; + const payload = await getPayload({ config: this.config }); + const doc = await payload.findByID({ + collection: "workspaces", + id, + overrideAccess: true, + }); + span.setAttribute("found", Boolean(doc)); + return doc ? this.toDomain(doc as Record) : null; } catch (err) { + if (isNotFound(err)) { + span.setAttribute("found", false); + return null; + } this.logger.captureException(err, { tags: { feature: FEATURE, repo: REPO, method: "getWorkspace" }, }); @@ -70,20 +81,95 @@ export class WorkspaceRepository implements IWorkspaceRepository { return this.tracer.startSpan( { name: "workspace.createWorkspace", op: "repository", attributes: {} }, async (span) => { - // Placeholder until the encrypted-credential storage slice registers - // the Payload collection: fail loudly rather than silently dropping - // the write. Replaced by a real `payload.create` with AES-256-GCM - // credential encryption in the same story. - void data; - const err = new Error( - "Workspaces Payload collection is not registered yet — createWorkspace is unavailable in production mode.", - ); - this.logger.captureException(err, { - tags: { feature: FEATURE, repo: REPO, method: "createWorkspace" }, - }); - span.setStatus("error", err.message); - throw err; + try { + const payload = await getPayload({ config: this.config }); + // The collection's beforeChange field hook encrypts `credential` + // before it reaches the database. + const created = await payload.create({ + collection: "workspaces", + data: { + name: data.name, + gitUrl: data.gitUrl, + status: "created", + credential: data.credential, + }, + overrideAccess: true, + }); + span.setAttribute("created", true); + return this.toDomain(created as Record); + } 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 { + 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 | 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): 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 + ); } diff --git a/packages/workspaces/src/integrations/cms/collections/workspaces.test.ts b/packages/workspaces/src/integrations/cms/collections/workspaces.test.ts new file mode 100644 index 0000000..c606a37 --- /dev/null +++ b/packages/workspaces/src/integrations/cms/collections/workspaces.test.ts @@ -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[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>[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/); + }); + }); +}); diff --git a/packages/workspaces/src/integrations/cms/collections/workspaces.ts b/packages/workspaces/src/integrations/cms/collections/workspaces.ts new file mode 100644 index 0000000..b1990f6 --- /dev/null +++ b/packages/workspaces/src/integrations/cms/collections/workspaces.ts @@ -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); + }, + ], + }, + }, + ], +}; diff --git a/packages/workspaces/src/integrations/cms/index.ts b/packages/workspaces/src/integrations/cms/index.ts index 14ff78c..3f39277 100644 --- a/packages/workspaces/src/integrations/cms/index.ts +++ b/packages/workspaces/src/integrations/cms/index.ts @@ -1,7 +1,5 @@ // Payload CMS integration barrel for @repo/workspaces. -// Re-export this feature's collections and globals here as you add them -// under ./collections and ./globals. See packages/auth/src/integrations/cms -// for the canonical shape. The `` anchor below is required by -// `pnpm turbo gen job` and `pnpm turbo gen event` — do not remove it. -export {}; +// The `` anchor below is required by `pnpm turbo gen job` and +// `pnpm turbo gen event` — do not remove it. +export { workspaces } from "./collections/workspaces"; // diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0017e49..dc87227 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -454,6 +454,9 @@ importers: "@repo/auth": specifier: workspace:* version: link:../auth + "@repo/workspaces": + specifier: workspace:* + version: link:../workspaces payload: specifier: ^3.14.0 version: 3.81.0(graphql@16.13.2)(typescript@5.9.3) diff --git a/turbo.json b/turbo.json index 96d76f5..aa3a977 100644 --- a/turbo.json +++ b/turbo.json @@ -19,7 +19,8 @@ "VERCEL_GIT_COMMIT_SHA", "NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA", "VERCEL_ENV", - "AUDIT_PSEUDONYM_SALT" + "AUDIT_PSEUDONYM_SALT", + "VEECT_SECRET" ], "boundaries": { "tags": {