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:
@@ -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",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user