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

@@ -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": {

View File

@@ -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 () => {

View File

@@ -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({

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.
*
* 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<IWorkspaceRepository>(
@@ -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");
});
});
},
);

View File

@@ -16,4 +16,11 @@ export interface IWorkspaceRepository {
getWorkspace(id: string): Promise<Workspace | null>;
/** Persists a new workspace with initial status "created". */
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 () => {
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);

View File

@@ -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);

View File

@@ -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<ILogger>(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);

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 {
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<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 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<typeof vi.fn>).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<typeof vi.fn>).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<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 { 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<Workspace | null> {
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<string, unknown>) : 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<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.
// 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 `<gen:job-tasks>` anchor below is required by
// `pnpm turbo gen job` and `pnpm turbo gen event` — do not remove it.
export {};
// The `<gen:job-tasks>` anchor below is required by `pnpm turbo gen job` and
// `pnpm turbo gen event` — do not remove it.
export { workspaces } from "./collections/workspaces";
// <gen:job-tasks>