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

@@ -16,6 +16,7 @@
"dependencies": {
"@repo/auth": "workspace:*",
"@repo/core-api": "workspace:*",
"@repo/core-audit": "workspace:*",
"@repo/core-cms": "workspace:*",
"@repo/core-shared": "workspace:*",
"@repo/core-trpc": "workspace:^",

View File

@@ -8,6 +8,18 @@ vi.mock("@repo/auth/di/bind-production", () => ({
bindProductionAuth: vi.fn(),
}));
vi.mock("@repo/auth/di/bind-dev-seed", () => ({ bindDevSeedAuth: vi.fn() }));
vi.mock("@repo/workspaces/di/bind-production", () => ({
bindProductionWorkspaces: vi.fn(),
}));
vi.mock("@repo/workspaces/di/bind-dev-seed", () => ({
bindDevSeedWorkspaces: vi.fn(),
}));
// bindAudit enforces AUDIT_PSEUDONYM_SALT under NODE_ENV=production — mocked
// here so dispatcher-routing tests stay focused on routing. The real audit
// binding is exercised by bind-production.smoke.test.ts (dev-seed path).
vi.mock("@repo/core-audit/di", () => ({
bindAudit: vi.fn(() => ({ auditLog: { record: vi.fn() } })),
}));
vi.mock("@repo/core-shared/instrumentation", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@repo/core-shared/instrumentation")>();

View File

@@ -17,6 +17,7 @@ import {
type IJobQueue,
} from "@repo/core-shared/jobs";
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
import { bindAudit } from "@repo/core-audit/di";
import { bindProductionAuth } from "@repo/auth/di/bind-production";
import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed";
import { bindProductionWorkspaces } from "@repo/workspaces/di/bind-production";
@@ -85,11 +86,19 @@ export async function bindAllProduction(): Promise<void> {
const { queue } = await resolveJobsProduction();
const resolvedConfig = await config;
// Audit trail (core-audit): Payload hot store + stdout JSON for the log
// shipper. Entries are trace-id enriched from the active OTel span.
const { auditLog } = bindAudit(sharedContainer, {
payloadConfig: resolvedConfig,
sinks: ["payload", "stdout"],
});
const ctx: BindProductionContext = {
config: resolvedConfig,
tracer,
logger,
queue,
auditLog,
rateLimit: new NoopRateLimit(),
};
@@ -106,10 +115,16 @@ export async function bindAllDevSeed(): Promise<void> {
const { tracer, logger } = resolveInstrumentation(); // Rule 0
const { queue } = resolveJobsDevSeed();
// Dev-seed audit trail: stdout JSON only (no Payload booted). Keeps the
// __audited brand path identical to production so the boot conformance
// assertion exercises the same wiring.
const { auditLog } = bindAudit(sharedContainer, { sinks: ["stdout"] });
const ctx: BindContext = {
tracer,
logger,
queue,
auditLog,
rateLimit: new NoopRateLimit(),
};

View File

@@ -11,8 +11,24 @@ import type { Workspace } from "../entities/models/workspace";
export const CONTRACT_WORKSPACE_SEED: ReadonlyArray<
readonly [string, Workspace]
> = [
["seed-1", { id: "seed-1", name: "Seed One" }],
["seed-2", { id: "seed-2", name: "Seed Two" }],
[
"seed-1",
{
id: "seed-1",
name: "Seed One",
gitUrl: "https://github.com/acme/seed-one.git",
status: "ready",
},
],
[
"seed-2",
{
id: "seed-2",
name: "Seed Two",
gitUrl: "git@github.com:acme/seed-two.git",
status: "created",
},
],
];
/**

View File

@@ -4,13 +4,36 @@ import type { Workspace } from "../entities/models/workspace";
* Realistic dev seed for `bindDevSeedWorkspaces`.
*
* Phase-1: returns a small hand-rolled Map. Replace with a faker-driven
* `defineFactory` (see `packages/blog/src/__factories__/`) once the entity
* shape stabilises.
* `defineFactory` once the entity shape stabilises.
*/
export function buildDevWorkspaceMap(): Map<string, Workspace> {
return new Map<string, Workspace>([
["dev-1", { id: "dev-1", name: "Dev One" }],
["dev-2", { id: "dev-2", name: "Dev Two" }],
["dev-3", { id: "dev-3", name: "Dev Three" }],
[
"dev-1",
{
id: "dev-1",
name: "Dev One",
gitUrl: "https://github.com/veect-dev/dev-one.git",
status: "ready",
},
],
[
"dev-2",
{
id: "dev-2",
name: "Dev Two",
gitUrl: "git@github.com:veect-dev/dev-two.git",
status: "connecting",
},
],
[
"dev-3",
{
id: "dev-3",
name: "Dev Three",
gitUrl: "https://github.com/veect-dev/dev-three.git",
status: "error",
},
],
]);
}

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

View File

@@ -1,13 +1,20 @@
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
import { bindDevSeedWorkspaces } from "@/di/bind-dev-seed";
import { workspacesContainer } from "@/di/container";
import { WORKSPACES_SYMBOLS } from "@/di/symbols";
import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock";
import type { IWorkspaceRepository } from "@/application/repositories/workspace.repository.interface";
const noop = { tracer: new NoopTracer(), logger: new NoopLogger() };
// connectWorkspace declares audits — the binder needs an auditLog in ctx to
// attach the __audited brand the boot conformance assertion requires.
const noop = {
tracer: new NoopTracer(),
logger: new NoopLogger(),
auditLog: new RecordingAuditLog(),
};
describe("bindDevSeedWorkspaces", () => {
beforeEach(() => {

View File

@@ -15,7 +15,9 @@ import { workspacesContainer } from "./container";
import { WORKSPACES_SYMBOLS } from "./symbols";
import { MockWorkspaceRepository } from "../infrastructure/repositories/workspace.repository.mock";
import { buildDevWorkspaceMap } from "../__seeds__/dev";
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";
import { getWorkspaceController } from "../interface-adapters/controllers/get-workspace.controller";
import type { IWorkspaceRepository } from "../application/repositories/workspace.repository.interface";
@@ -30,7 +32,8 @@ import type { IWorkspaceRepository } from "../application/repositories/workspace
* populated repo and rebinds the symbol.
*/
export async function bindDevSeedWorkspaces(ctx: BindContext): Promise<void> {
const { tracer, logger, bus, queue, realtime, realtimeRegistry } = ctx;
const { tracer, logger, bus, queue, realtime, realtimeRegistry, auditLog } =
ctx;
// Bind shared instrumentation into feature container
if (workspacesContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
@@ -58,7 +61,20 @@ export async function bindDevSeedWorkspaces(ctx: BindContext): Promise<void> {
.bind<IWorkspaceRepository>(WORKSPACES_SYMBOLS.IWorkspaceRepository)
.toConstantValue(repo);
// Use case
// Use cases
const wrappedConnectWorkspace = wireUseCase({
container: workspacesContainer,
symbol: WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase,
factory: connectWorkspaceUseCase,
deps: [repo, auditLog],
feature: "workspaces",
layer: "use-case",
name: "connectWorkspace",
tracer,
logger,
auditLog,
});
const wrappedGetWorkspace = wireUseCase({
container: workspacesContainer,
symbol: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
@@ -71,6 +87,29 @@ export async function bindDevSeedWorkspaces(ctx: BindContext): Promise<void> {
logger,
});
if (
workspacesContainer.isBound(WORKSPACES_SYMBOLS.IConnectWorkspaceController)
) {
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IConnectWorkspaceController);
}
workspacesContainer
.bind(WORKSPACES_SYMBOLS.IConnectWorkspaceController)
.toConstantValue(
withSpan(
tracer,
{ name: "workspaces.connectWorkspace", op: "controller" },
withCapture(
logger,
{
feature: "workspaces",
layer: "controller",
name: "workspaces.connectWorkspace",
},
connectWorkspaceController(wrappedConnectWorkspace),
),
),
);
if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IGetWorkspaceController)) {
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IGetWorkspaceController);
}
@@ -105,6 +144,7 @@ export async function bindDevSeedWorkspaces(ctx: BindContext): Promise<void> {
workspacesContainer,
workspacesManifest,
{
connectWorkspace: WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase,
getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
},
ctx,

View File

@@ -14,12 +14,22 @@ import { workspacesContainer } from "./container";
import { WORKSPACES_SYMBOLS } from "./symbols";
import { workspacesManifest } from "../feature.manifest";
import { WorkspaceRepository } from "../infrastructure/repositories/workspace.repository";
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";
import { getWorkspaceController } from "../interface-adapters/controllers/get-workspace.controller";
export function bindProductionWorkspaces(ctx: BindProductionContext): void {
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } =
ctx;
const {
config,
tracer,
logger,
bus,
queue,
realtime,
realtimeRegistry,
auditLog,
} = ctx;
// Bind shared instrumentation into feature container
if (workspacesContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
@@ -44,7 +54,20 @@ export function bindProductionWorkspaces(ctx: BindProductionContext): void {
.bind(WORKSPACES_SYMBOLS.IWorkspaceRepository)
.toConstantValue(repo);
// Use case
// Use cases
const wrappedConnectWorkspace = wireUseCase({
container: workspacesContainer,
symbol: WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase,
factory: connectWorkspaceUseCase,
deps: [repo, auditLog],
feature: "workspaces",
layer: "use-case",
name: "connectWorkspace",
tracer,
logger,
auditLog,
});
const wrappedGetWorkspace = wireUseCase({
container: workspacesContainer,
symbol: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
@@ -57,7 +80,30 @@ export function bindProductionWorkspaces(ctx: BindProductionContext): void {
logger,
});
// Controller — wrapped with span at bind time
// Controllers — wrapped with span at bind time
if (
workspacesContainer.isBound(WORKSPACES_SYMBOLS.IConnectWorkspaceController)
) {
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IConnectWorkspaceController);
}
workspacesContainer
.bind(WORKSPACES_SYMBOLS.IConnectWorkspaceController)
.toConstantValue(
withSpan(
tracer,
{ name: "workspaces.connectWorkspace", op: "controller" },
withCapture(
logger,
{
feature: "workspaces",
layer: "controller",
name: "workspaces.connectWorkspace",
},
connectWorkspaceController(wrappedConnectWorkspace),
),
),
);
if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IGetWorkspaceController)) {
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IGetWorkspaceController);
}
@@ -93,6 +139,7 @@ export function bindProductionWorkspaces(ctx: BindProductionContext): void {
workspacesContainer,
workspacesManifest,
{
connectWorkspace: WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase,
getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
},
ctx,

View File

@@ -4,7 +4,9 @@ import { WORKSPACES_SYMBOLS } from "./symbols";
import { WorkspacesModule } from "./module";
import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock";
import type { IWorkspaceRepository } from "@/application/repositories/workspace.repository.interface";
import type { IConnectWorkspaceUseCase } from "@/application/use-cases/connect-workspace.use-case";
import type { IGetWorkspaceUseCase } from "@/application/use-cases/get-workspace.use-case";
import type { IConnectWorkspaceController } from "@/interface-adapters/controllers/connect-workspace.controller";
import type { IGetWorkspaceController } from "@/interface-adapters/controllers/get-workspace.controller";
describe("workspacesContainer", () => {
@@ -24,6 +26,20 @@ describe("workspacesContainer", () => {
expect(repo).toBeInstanceOf(MockWorkspaceRepository);
});
it("resolves IConnectWorkspaceUseCase as a function", () => {
const useCase = workspacesContainer.get<IConnectWorkspaceUseCase>(
WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase,
);
expect(typeof useCase).toBe("function");
});
it("resolves IConnectWorkspaceController as a function", () => {
const controller = workspacesContainer.get<IConnectWorkspaceController>(
WORKSPACES_SYMBOLS.IConnectWorkspaceController,
);
expect(typeof controller).toBe("function");
});
it("resolves IGetWorkspaceUseCase as a function", () => {
const useCase = workspacesContainer.get<IGetWorkspaceUseCase>(
WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,

View File

@@ -2,10 +2,18 @@ import { ContainerModule, type interfaces } from "inversify";
import type { IWorkspaceRepository } from "../application/repositories/workspace.repository.interface";
import { MockWorkspaceRepository } from "../infrastructure/repositories/workspace.repository.mock";
import {
connectWorkspaceUseCase,
type IConnectWorkspaceUseCase,
} from "../application/use-cases/connect-workspace.use-case";
import {
getWorkspaceUseCase,
type IGetWorkspaceUseCase,
} from "../application/use-cases/get-workspace.use-case";
import {
connectWorkspaceController,
type IConnectWorkspaceController,
} from "../interface-adapters/controllers/connect-workspace.controller";
import {
getWorkspaceController,
type IGetWorkspaceController,
@@ -17,6 +25,16 @@ export const WorkspacesModule = new ContainerModule((bind: interfaces.Bind) => {
MockWorkspaceRepository,
);
bind<IConnectWorkspaceUseCase>(
WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase,
).toDynamicValue((ctx) =>
connectWorkspaceUseCase(
ctx.container.get<IWorkspaceRepository>(
WORKSPACES_SYMBOLS.IWorkspaceRepository,
),
),
);
bind<IGetWorkspaceUseCase>(
WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
).toDynamicValue((ctx) =>
@@ -27,6 +45,16 @@ export const WorkspacesModule = new ContainerModule((bind: interfaces.Bind) => {
),
);
bind<IConnectWorkspaceController>(
WORKSPACES_SYMBOLS.IConnectWorkspaceController,
).toDynamicValue((ctx) =>
connectWorkspaceController(
ctx.container.get<IConnectWorkspaceUseCase>(
WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase,
),
),
);
bind<IGetWorkspaceController>(
WORKSPACES_SYMBOLS.IGetWorkspaceController,
).toDynamicValue((ctx) =>

View File

@@ -1,8 +1,12 @@
export const WORKSPACES_SYMBOLS = {
IWorkspaceRepository: Symbol.for("workspaces:IWorkspaceRepository"),
// Use cases
IConnectWorkspaceUseCase: Symbol.for("workspaces:IConnectWorkspaceUseCase"),
IGetWorkspaceUseCase: Symbol.for("workspaces:IGetWorkspaceUseCase"),
// Controllers
IConnectWorkspaceController: Symbol.for(
"workspaces:IConnectWorkspaceController",
),
IGetWorkspaceController: Symbol.for("workspaces:IGetWorkspaceController"),
// <gen:event-handler-symbols>
// <gen:job-symbols>

View File

@@ -1,24 +1,79 @@
import { describe, expect, it } from "vitest";
import { workspaceSchema } from "./workspace";
import {
gitUrlSchema,
workspaceSchema,
workspaceStatusSchema,
} from "./workspace";
const VALID = {
id: "1",
name: "Example",
gitUrl: "https://github.com/acme/example.git",
status: "created",
} as const;
describe("workspaceSchema", () => {
it("accepts a valid workspace", () => {
const result = workspaceSchema.parse({ id: "1", name: "Example" });
const result = workspaceSchema.parse(VALID);
expect(result.id).toBe("1");
expect(result.name).toBe("Example");
expect(result.gitUrl).toBe("https://github.com/acme/example.git");
expect(result.status).toBe("created");
});
it("rejects an empty id", () => {
expect(() => workspaceSchema.parse({ id: "", name: "ok" })).toThrow();
expect(() => workspaceSchema.parse({ ...VALID, id: "" })).toThrow();
});
it("rejects an empty name", () => {
expect(() => workspaceSchema.parse({ id: "1", name: "" })).toThrow();
expect(() => workspaceSchema.parse({ ...VALID, name: "" })).toThrow();
});
it("rejects a name over 128 chars", () => {
expect(() =>
workspaceSchema.parse({ id: "1", name: "x".repeat(129) }),
workspaceSchema.parse({ ...VALID, name: "x".repeat(129) }),
).toThrow();
});
it("rejects an unknown status", () => {
expect(() =>
workspaceSchema.parse({ ...VALID, status: "provisioned" }),
).toThrow();
});
it("has no credential field — a pat passed in is stripped, never parsed", () => {
const result = workspaceSchema.parse({ ...VALID, pat: "secret" });
expect(result).not.toHaveProperty("pat");
});
});
describe("workspaceStatusSchema", () => {
it.each(["created", "connecting", "ready", "error"] as const)(
"accepts %s",
(status) => {
expect(workspaceStatusSchema.parse(status)).toBe(status);
},
);
it("rejects statuses outside the persisted set", () => {
expect(() => workspaceStatusSchema.parse("deleted")).toThrow();
});
});
describe("gitUrlSchema", () => {
it.each([
"https://github.com/acme/example.git",
"http://git.internal/acme/example.git",
"ssh://git@github.com/acme/example.git",
"git@github.com:acme/example.git",
])("accepts %s", (url) => {
expect(gitUrlSchema.parse(url)).toBe(url);
});
it.each(["", "ftp://example.com/repo.git", "not-a-url", "https:// spaced"])(
"rejects %j",
(url) => {
expect(() => gitUrlSchema.parse(url)).toThrow();
},
);
});

View File

@@ -1,8 +1,41 @@
import { z } from "zod";
/**
* Persisted workspace lifecycle states. This story covers persisted state
* only — runner-driven transitions (provisioning, lifecycle events) extend
* this enum in the runner stories.
*/
export const workspaceStatusSchema = z.enum([
"created",
"connecting",
"ready",
"error",
]);
export type WorkspaceStatus = z.infer<typeof workspaceStatusSchema>;
/**
* Git remote URL — http(s), ssh://, or scp-like git@ form. Shared by the
* workspace entity and the connectWorkspace input schema.
*/
export const gitUrlSchema = z
.string()
.min(1)
.max(2048)
.regex(
/^(https?:\/\/|ssh:\/\/|git@)\S+$/,
"Must be an http(s), ssh://, or git@ git URL",
);
/**
* The workspace entity. Deliberately credential-free: the PAT lives only in
* the persistence layer (encrypted at rest, write-only) and never appears on
* the domain model or any use-case output.
*/
export const workspaceSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(128),
gitUrl: gitUrlSchema,
status: workspaceStatusSchema,
});
export type Workspace = z.infer<typeof workspaceSchema>;

View File

@@ -13,8 +13,15 @@ import { defineFeature } from "@repo/core-shared/conformance";
*/
export const workspacesManifest = defineFeature({
name: "workspaces",
requiredCores: [],
requiredCores: ["audit"],
useCases: {
connectWorkspace: {
mutates: true,
audits: ["workspace-connected"],
publishes: [],
consumes: [],
analyticsEvents: [],
},
getWorkspace: {
mutates: false,
audits: [],

View File

@@ -1,9 +1,16 @@
export type { Workspace } from "./entities/models/workspace";
export type { Workspace, WorkspaceStatus } from "./entities/models/workspace";
export type { WorkspacesRouter } from "./integrations/api/router";
export { WorkspaceNotFoundError } from "./entities/errors/workspace";
export { InputParseError } from "./entities/errors/common";
// Use case schemas + types
export {
connectWorkspaceInputSchema,
connectWorkspaceOutputSchema,
type ConnectWorkspaceInput,
type ConnectWorkspaceOutput,
type IConnectWorkspaceUseCase,
} from "./application/use-cases/connect-workspace.use-case";
export {
getWorkspaceInputSchema,
getWorkspaceOutputSchema,
@@ -13,6 +20,7 @@ export {
} from "./application/use-cases/get-workspace.use-case";
// Controller type aliases
export type { IConnectWorkspaceController } from "./interface-adapters/controllers/connect-workspace.controller";
export type { IGetWorkspaceController } from "./interface-adapters/controllers/get-workspace.controller";
// <gen:events>

View File

@@ -1,4 +1,5 @@
import "reflect-metadata";
import { randomUUID } from "node:crypto";
import { injectable } from "inversify";
import {
NoopTracer,
@@ -7,17 +8,42 @@ import {
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { IWorkspaceRepository } from "../../application/repositories/workspace.repository.interface";
import type {
CreateWorkspaceData,
IWorkspaceRepository,
} from "../../application/repositories/workspace.repository.interface";
import type { Workspace } from "../../entities/models/workspace";
const DEFAULT_DATA = new Map<string, Workspace>([
["seed-1", { id: "seed-1", name: "Seed One" }],
["seed-2", { id: "seed-2", name: "Seed Two" }],
[
"seed-1",
{
id: "seed-1",
name: "Seed One",
gitUrl: "https://github.com/acme/seed-one.git",
status: "ready",
},
],
[
"seed-2",
{
id: "seed-2",
name: "Seed Two",
gitUrl: "git@github.com:acme/seed-two.git",
status: "created",
},
],
]);
@injectable()
export class MockWorkspaceRepository implements IWorkspaceRepository {
private readonly data: Map<string, Workspace>;
/**
* Credentials live in a separate private map — mirroring the real
* repository's write-only field: no read path on the `Workspace` model
* ever carries them.
*/
private readonly credentials = new Map<string, string>();
private tracer: ITracer;
private logger: ILogger;
@@ -42,4 +68,22 @@ export class MockWorkspaceRepository implements IWorkspaceRepository {
},
);
}
async createWorkspace(data: CreateWorkspaceData): Promise<Workspace> {
return this.tracer.startSpan(
{ name: "workspace.createWorkspace", op: "repository", attributes: {} },
async (span) => {
const workspace: Workspace = {
id: `ws-${randomUUID()}`,
name: data.name,
gitUrl: data.gitUrl,
status: "created",
};
this.data.set(workspace.id, workspace);
this.credentials.set(workspace.id, data.credential);
span.setAttribute("created", true);
return workspace;
},
);
}
}

View File

@@ -1,4 +1,5 @@
import { describe, it, expect } from "vitest";
import type { ITracer } from "@repo/core-shared/instrumentation";
import {
RecordingTracer,
RecordingLogger,
@@ -34,4 +35,47 @@ describe("WorkspaceRepository (Phase-1 stub)", () => {
expect(tracer.spans[0]!.attributes.id).toBe("custom-id");
expect(tracer.spans[0]!.attributes.found).toBe(false);
});
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: () => {},
}),
};
const repo = new WorkspaceRepository(
stubPayloadConfig,
throwingTracer,
logger,
);
await expect(repo.getWorkspace("x")).rejects.toThrow("span 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",
});
});
});

View File

@@ -8,7 +8,10 @@ import {
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { IWorkspaceRepository } from "../../application/repositories/workspace.repository.interface";
import type {
CreateWorkspaceData,
IWorkspaceRepository,
} from "../../application/repositories/workspace.repository.interface";
import type { Workspace } from "../../entities/models/workspace";
const FEATURE = "workspaces" as const;
@@ -62,4 +65,25 @@ export class WorkspaceRepository implements IWorkspaceRepository {
},
);
}
async createWorkspace(data: CreateWorkspaceData): Promise<Workspace> {
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;
},
);
}
}

View File

@@ -18,9 +18,10 @@ describe("workspacesRouter", () => {
workspacesContainer.unbindAll();
});
it("exposes the getWorkspace procedure", () => {
it("exposes the getWorkspace and connectWorkspace procedures", () => {
const names = Object.keys(workspacesRouter._def.procedures);
expect(names).toContain("getWorkspace");
expect(names).toContain("connectWorkspace");
});
it("getWorkspace returns the seeded workspace", async () => {
@@ -28,6 +29,17 @@ describe("workspacesRouter", () => {
const result = await caller.getWorkspace({ id: "seed-1" });
expect(result.id).toBe("seed-1");
});
it("connectWorkspace creates a workspace and never echoes the pat", async () => {
const caller = workspacesRouter.createCaller({});
const result = await caller.connectWorkspace({
name: "Acme Web",
gitUrl: "https://github.com/acme/web.git",
pat: "ghp_super-secret-token",
});
expect(result.status).toBe("created");
expect(JSON.stringify(result)).not.toContain("ghp_super-secret-token");
});
});
describe("workspacesRouter (error mapping)", () => {
@@ -54,6 +66,20 @@ describe("workspacesRouter (error mapping)", () => {
}
});
it("translates InputParseError → BAD_REQUEST when connectWorkspace pat is missing", async () => {
const caller = workspacesRouter.createCaller({});
try {
await caller.connectWorkspace({
name: "Acme Web",
gitUrl: "https://github.com/acme/web.git",
} as unknown as { name: string; gitUrl: string; pat: string });
throw new Error("expected throw");
} catch (e) {
expect(e).toBeInstanceOf(TRPCError);
expect((e as TRPCError).code).toBe("BAD_REQUEST");
}
});
it("translates WorkspaceNotFoundError → NOT_FOUND when repository returns null", async () => {
@injectable()
class NullWorkspaceRepository {

View File

@@ -3,12 +3,22 @@ import { router } from "@repo/core-shared/trpc/init";
import { workspacesContainer } from "../../di/container";
import { WORKSPACES_SYMBOLS } from "../../di/symbols";
import { connectWorkspaceInputSchema } from "../../application/use-cases/connect-workspace.use-case";
import { getWorkspaceInputSchema } from "../../application/use-cases/get-workspace.use-case";
import type { IConnectWorkspaceController } from "../../interface-adapters/controllers/connect-workspace.controller";
import type { IGetWorkspaceController } from "../../interface-adapters/controllers/get-workspace.controller";
import { workspacesProcedure } from "./procedures";
export const workspacesRouter = router({
connectWorkspace: workspacesProcedure
.input(connectWorkspaceInputSchema)
.mutation(({ input }) => {
const ctrl = workspacesContainer.get<IConnectWorkspaceController>(
WORKSPACES_SYMBOLS.IConnectWorkspaceController,
);
return ctrl(input);
}),
getWorkspace: workspacesProcedure
.input(getWorkspaceInputSchema)
.query(({ input }) => {

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { connectWorkspaceController } from "@/interface-adapters/controllers/connect-workspace.controller";
import { connectWorkspaceUseCase } from "@/application/use-cases/connect-workspace.use-case";
import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock";
import { InputParseError } from "@/entities/errors/common";
const INPUT = {
name: "Acme Web",
gitUrl: "https://github.com/acme/web.git",
pat: "ghp_super-secret-token",
};
function buildController() {
const repo = new MockWorkspaceRepository(new Map());
return connectWorkspaceController(connectWorkspaceUseCase(repo));
}
describe("connectWorkspaceController", () => {
it("returns the created workspace for valid input", async () => {
const controller = buildController();
const result = await controller(INPUT);
expect(result.name).toBe("Acme Web");
expect(result.status).toBe("created");
});
it("never echoes the credential", async () => {
const controller = buildController();
const result = await controller(INPUT);
expect(JSON.stringify(result)).not.toContain(INPUT.pat);
});
it("throws InputParseError when the pat is missing", async () => {
const controller = buildController();
await expect(
controller({ name: "Acme Web", gitUrl: INPUT.gitUrl }),
).rejects.toBeInstanceOf(InputParseError);
});
it("throws InputParseError on unknown extra fields (strict schema)", async () => {
const controller = buildController();
await expect(
controller({ ...INPUT, unexpected: "field" }),
).rejects.toBeInstanceOf(InputParseError);
});
it("throws InputParseError on a non-git URL", async () => {
const controller = buildController();
await expect(
controller({ ...INPUT, gitUrl: "ftp://nope/repo.git" }),
).rejects.toBeInstanceOf(InputParseError);
});
});

View File

@@ -0,0 +1,29 @@
import { InputParseError } from "../../entities/errors/common";
import {
connectWorkspaceInputSchema,
type ConnectWorkspaceOutput,
type IConnectWorkspaceUseCase,
} from "../../application/use-cases/connect-workspace.use-case";
// Identity presenter: `ConnectWorkspaceOutput` is already credential-free
// (the output schema is the credential-less workspace entity).
function presenter(value: ConnectWorkspaceOutput) {
return value;
}
export type IConnectWorkspaceController = ReturnType<
typeof connectWorkspaceController
>;
export const connectWorkspaceController =
(connectWorkspaceUseCase: IConnectWorkspaceUseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = connectWorkspaceInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid connect-workspace input", {
cause: parsed.error,
});
}
const result = await connectWorkspaceUseCase(parsed.data);
return presenter(result);
};

3
pnpm-lock.yaml generated
View File

@@ -179,6 +179,9 @@ importers:
"@repo/core-api":
specifier: workspace:*
version: link:../../packages/core-api
"@repo/core-audit":
specifier: workspace:*
version: link:../../packages/core-audit
"@repo/core-cms":
specifier: workspace:*
version: link:../../packages/core-cms