feat(workspaces): connectWorkspace use case with audit

Manifest-first: connectWorkspace declared mutates:true with the
workspace-connected audit event, requiredCores gains audit. Workspace
entity gains gitUrl + persisted status enum (created/connecting/ready/
error). Input takes name + git URL + PAT; the output schema is the
credential-free workspace entity, so the PAT can never round-trip.
Audit emission asserted with RecordingAuditLog; binders wire the use
case through wireUseCase with the __audited brand, and web-next
bindAll now binds core-audit (payload+stdout sinks in production,
stdout in dev-seed) so boot conformance passes in both modes.

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 21:54:00 +02:00
parent 6216897680
commit 8219c1fabb
27 changed files with 783 additions and 26 deletions

View File

@@ -1,5 +1,19 @@
import type { Workspace } from "../../entities/models/workspace";
/**
* Data required to persist a new workspace. `credential` is the repository
* PAT in plaintext at this boundary — implementations own encryption at
* rest (AES-256-GCM, scrypt-derived key) and MUST never return it on any
* read path. The domain `Workspace` model is credential-free by construction.
*/
export type CreateWorkspaceData = {
name: string;
gitUrl: string;
credential: string;
};
export interface IWorkspaceRepository {
getWorkspace(id: string): Promise<Workspace | null>;
/** Persists a new workspace with initial status "created". */
createWorkspace(data: CreateWorkspaceData): Promise<Workspace>;
}

View File

@@ -0,0 +1,129 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ZodError } from "zod";
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
import { connectWorkspaceUseCase } from "@/application/use-cases/connect-workspace.use-case";
import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock";
const INPUT = {
name: "Acme Web",
gitUrl: "https://github.com/acme/web.git",
pat: "ghp_super-secret-token",
};
describe("connectWorkspaceUseCase", () => {
it("creates a workspace with initial status 'created'", async () => {
const repo = new MockWorkspaceRepository(new Map());
const useCase = connectWorkspaceUseCase(repo);
const result = await useCase(INPUT);
expect(result.id).not.toBe("");
expect(result.name).toBe("Acme Web");
expect(result.gitUrl).toBe("https://github.com/acme/web.git");
expect(result.status).toBe("created");
});
it("persists the workspace — it is readable back through the repository", async () => {
const repo = new MockWorkspaceRepository(new Map());
const useCase = connectWorkspaceUseCase(repo);
const created = await useCase(INPUT);
const found = await repo.getWorkspace(created.id);
expect(found).toEqual(created);
});
it("never includes the credential in the output", async () => {
const repo = new MockWorkspaceRepository(new Map());
const useCase = connectWorkspaceUseCase(repo);
const result = await useCase(INPUT);
expect(result).not.toHaveProperty("pat");
expect(result).not.toHaveProperty("credential");
expect(JSON.stringify(result)).not.toContain(INPUT.pat);
});
it("strips a credential leaked by a misbehaving repository", async () => {
const leakyRepo = {
getWorkspace: async () => null,
createWorkspace: async () => ({
id: "ws-1",
name: "Acme Web",
gitUrl: "https://github.com/acme/web.git",
status: "created" as const,
credential: "ghp_super-secret-token",
}),
};
const useCase = connectWorkspaceUseCase(leakyRepo);
const result = await useCase(INPUT);
expect(result).not.toHaveProperty("credential");
});
it("records the workspace-connected audit event", async () => {
const repo = new MockWorkspaceRepository(new Map());
const auditLog = new RecordingAuditLog();
const useCase = connectWorkspaceUseCase(repo, auditLog);
const created = await useCase(INPUT);
expect(auditLog.recorded).toHaveLength(1);
const entry = auditLog.recorded[0]!;
expect(entry.action).toBe("CREATE");
expect(entry.reason).toBe("workspace-connected");
expect(entry.resource).toEqual({ type: "workspaces", id: created.id });
expect(entry.outcome).toBe("success");
expect(entry.containsPii).toBe(false);
expect(entry.scope.feature).toBe("workspaces");
});
describe("audit scope environment", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("falls back to 'development' when NODE_ENV is unset", async () => {
vi.stubEnv("NODE_ENV", undefined);
const repo = new MockWorkspaceRepository(new Map());
const auditLog = new RecordingAuditLog();
const useCase = connectWorkspaceUseCase(repo, auditLog);
await useCase(INPUT);
expect(auditLog.recorded[0]!.scope.environment).toBe("development");
});
});
it("does not audit when the repository write fails", async () => {
const failingRepo = {
getWorkspace: async () => null,
createWorkspace: async () => {
throw new Error("boom");
},
};
const auditLog = new RecordingAuditLog();
const useCase = connectWorkspaceUseCase(failingRepo, auditLog);
await expect(useCase(INPUT)).rejects.toThrow("boom");
expect(auditLog.recorded).toHaveLength(0);
});
it("works without an audit log (optional dependency)", async () => {
const repo = new MockWorkspaceRepository(new Map());
const useCase = connectWorkspaceUseCase(repo, undefined);
await expect(useCase(INPUT)).resolves.toMatchObject({ status: "created" });
});
it("throws ZodError when the repository returns malformed data", async () => {
const malformedRepo = {
getWorkspace: async () => null,
createWorkspace: async () => ({ id: "", name: "x" }) as never,
};
const useCase = connectWorkspaceUseCase(malformedRepo);
await expect(useCase(INPUT)).rejects.toBeInstanceOf(ZodError);
});
});

View File

@@ -0,0 +1,62 @@
import { z } from "zod";
import type { AuditLogProtocol } from "@repo/core-shared/di/bind-protocols";
import { gitUrlSchema, workspaceSchema } from "../../entities/models/workspace";
import type { IWorkspaceRepository } from "../repositories/workspace.repository.interface";
// ── Input ────────────────────────────────────────────────────────────────
export const connectWorkspaceInputSchema = z
.object({
name: z.string().min(1).max(128),
gitUrl: gitUrlSchema,
/** Repository personal access token. Write-only: encrypted at rest and never returned by any output. */
pat: z.string().min(1).max(4096),
})
.strict();
export type ConnectWorkspaceInput = z.infer<typeof connectWorkspaceInputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
// The credential is deliberately absent: `workspaceSchema` has no credential
// field, and `.parse` strips any unknown keys a repository might leak.
export const connectWorkspaceOutputSchema = workspaceSchema;
export type ConnectWorkspaceOutput = z.infer<
typeof connectWorkspaceOutputSchema
>;
// ── Use case ─────────────────────────────────────────────────────────────
export type IConnectWorkspaceUseCase = ReturnType<
typeof connectWorkspaceUseCase
>;
export const connectWorkspaceUseCase =
(workspaceRepository: IWorkspaceRepository, auditLog?: AuditLogProtocol) =>
async (input: ConnectWorkspaceInput): Promise<ConnectWorkspaceOutput> => {
const workspace = await workspaceRepository.createWorkspace({
name: input.name,
gitUrl: input.gitUrl,
credential: input.pat,
});
// Audit event "workspace-connected" — declared in feature.manifest.ts
// (useCases.connectWorkspace.audits). No session context exists at this
// layer yet (auth wiring is a later story), so the system sentinels apply.
await auditLog?.record({
actorId: "system",
actorType: "system",
actorRoles: [],
action: "CREATE",
resource: { type: "workspaces", id: workspace.id },
at: new Date(),
scope: {
feature: "workspaces",
environment: process.env.NODE_ENV ?? "development",
tenant: "default",
},
from: { ipTruncated: "system", userAgent: "control-plane" },
containsPii: false,
outcome: "success",
reason: "workspace-connected",
});
return connectWorkspaceOutputSchema.parse(workspace);
};

View File

@@ -24,6 +24,7 @@ describe("getWorkspaceUseCase", () => {
it("throws ZodError when repository returns malformed data", async () => {
const malformedRepo = {
getWorkspace: async () => ({ id: "", name: "x" }) as never,
createWorkspace: async () => ({ id: "", name: "x" }) as never,
};
const useCase = getWorkspaceUseCase(malformedRepo);
await expect(useCase({ id: "anything" })).rejects.toBeInstanceOf(ZodError);