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