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:
@@ -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
|
||||
|
||||
@@ -21,3 +21,7 @@ collections:
|
||||
restrictable: true
|
||||
source: auth-default
|
||||
slug: users
|
||||
workspaces:
|
||||
auth: false
|
||||
piiFields: []
|
||||
slug: workspaces
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 tracer = new RecordingTracer();
|
||||
const logger = new RecordingLogger();
|
||||
const repo = new WorkspaceRepository(stubPayloadConfig, tracer, logger);
|
||||
|
||||
const result = await repo.getWorkspace("anything");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(tracer.spans).toHaveLength(1);
|
||||
expect(tracer.spans[0]).toMatchObject({
|
||||
name: "workspace.getWorkspace",
|
||||
op: "repository",
|
||||
});
|
||||
const SECRET = "test-secret-0123456789abcdef0123456789abcdef";
|
||||
vi.stubEnv("VEECT_SECRET", SECRET);
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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");
|
||||
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;
|
||||
},
|
||||
setStatus: () => {},
|
||||
),
|
||||
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();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error paths (capture-at-throw-site)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
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 { getPayload } = await import("payload");
|
||||
(getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
|
||||
const repo = new WorkspaceRepository(stubPayloadConfig, SECRET);
|
||||
|
||||
await expect(repo.getWorkspace("missing")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
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.getDecryptedCredential("missing")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
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,
|
||||
throwingTracer,
|
||||
SECRET,
|
||||
undefined,
|
||||
logger,
|
||||
);
|
||||
|
||||
await expect(repo.getWorkspace("x")).rejects.toThrow("span boom");
|
||||
await expect(call(repo)).rejects.toThrow("infra boom");
|
||||
expect(logger.captures).toHaveLength(1);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
await expect(
|
||||
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);
|
||||
expect(tracer.spans[0]).toMatchObject({
|
||||
name: "workspace.createWorkspace",
|
||||
op: "repository",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
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;
|
||||
} catch (err) {
|
||||
}
|
||||
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.",
|
||||
);
|
||||
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.message);
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user