From 8219c1fabb92e0a49655d6f175a6d3838888910f Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Sun, 12 Jul 2026 21:54:00 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK --- apps/web-next/package.json | 1 + .../src/server/bind-production.test.ts | 12 ++ apps/web-next/src/server/bind-production.ts | 15 ++ .../workspace-repository.contract.ts | 20 ++- packages/workspaces/src/__seeds__/dev.ts | 33 ++++- .../workspace.repository.interface.ts | 14 ++ .../connect-workspace.use-case.test.ts | 129 ++++++++++++++++++ .../use-cases/connect-workspace.use-case.ts | 62 +++++++++ .../use-cases/get-workspace.use-case.test.ts | 1 + .../workspaces/src/di/bind-dev-seed.test.ts | 9 +- packages/workspaces/src/di/bind-dev-seed.ts | 44 +++++- packages/workspaces/src/di/bind-production.ts | 55 +++++++- packages/workspaces/src/di/container.test.ts | 16 +++ packages/workspaces/src/di/module.ts | 28 ++++ packages/workspaces/src/di/symbols.ts | 4 + .../src/entities/models/workspace.test.ts | 65 ++++++++- .../src/entities/models/workspace.ts | 33 +++++ packages/workspaces/src/feature.manifest.ts | 9 +- packages/workspaces/src/index.ts | 10 +- .../repositories/workspace.repository.mock.ts | 50 ++++++- .../repositories/workspace.repository.test.ts | 44 ++++++ .../repositories/workspace.repository.ts | 26 +++- .../src/integrations/api/router.test.ts | 28 +++- .../workspaces/src/integrations/api/router.ts | 10 ++ .../connect-workspace.controller.test.ts | 59 ++++++++ .../connect-workspace.controller.ts | 29 ++++ pnpm-lock.yaml | 3 + 27 files changed, 783 insertions(+), 26 deletions(-) create mode 100644 packages/workspaces/src/application/use-cases/connect-workspace.use-case.test.ts create mode 100644 packages/workspaces/src/application/use-cases/connect-workspace.use-case.ts create mode 100644 packages/workspaces/src/interface-adapters/controllers/connect-workspace.controller.test.ts create mode 100644 packages/workspaces/src/interface-adapters/controllers/connect-workspace.controller.ts diff --git a/apps/web-next/package.json b/apps/web-next/package.json index c6fdc50..93dd4b7 100644 --- a/apps/web-next/package.json +++ b/apps/web-next/package.json @@ -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:^", diff --git a/apps/web-next/src/server/bind-production.test.ts b/apps/web-next/src/server/bind-production.test.ts index e0f1ee5..b7a6316 100644 --- a/apps/web-next/src/server/bind-production.test.ts +++ b/apps/web-next/src/server/bind-production.test.ts @@ -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(); diff --git a/apps/web-next/src/server/bind-production.ts b/apps/web-next/src/server/bind-production.ts index 2fd3e25..bf0f422 100644 --- a/apps/web-next/src/server/bind-production.ts +++ b/apps/web-next/src/server/bind-production.ts @@ -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 { 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 { 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(), }; diff --git a/packages/workspaces/src/__contracts__/workspace-repository.contract.ts b/packages/workspaces/src/__contracts__/workspace-repository.contract.ts index 737a8b6..58cf4dd 100644 --- a/packages/workspaces/src/__contracts__/workspace-repository.contract.ts +++ b/packages/workspaces/src/__contracts__/workspace-repository.contract.ts @@ -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", + }, + ], ]; /** diff --git a/packages/workspaces/src/__seeds__/dev.ts b/packages/workspaces/src/__seeds__/dev.ts index cd04ec3..9f53a23 100644 --- a/packages/workspaces/src/__seeds__/dev.ts +++ b/packages/workspaces/src/__seeds__/dev.ts @@ -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 { return new Map([ - ["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", + }, + ], ]); } diff --git a/packages/workspaces/src/application/repositories/workspace.repository.interface.ts b/packages/workspaces/src/application/repositories/workspace.repository.interface.ts index 460b6ac..b9878ab 100644 --- a/packages/workspaces/src/application/repositories/workspace.repository.interface.ts +++ b/packages/workspaces/src/application/repositories/workspace.repository.interface.ts @@ -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; + /** Persists a new workspace with initial status "created". */ + createWorkspace(data: CreateWorkspaceData): Promise; } diff --git a/packages/workspaces/src/application/use-cases/connect-workspace.use-case.test.ts b/packages/workspaces/src/application/use-cases/connect-workspace.use-case.test.ts new file mode 100644 index 0000000..21337c9 --- /dev/null +++ b/packages/workspaces/src/application/use-cases/connect-workspace.use-case.test.ts @@ -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); + }); +}); diff --git a/packages/workspaces/src/application/use-cases/connect-workspace.use-case.ts b/packages/workspaces/src/application/use-cases/connect-workspace.use-case.ts new file mode 100644 index 0000000..ae8c155 --- /dev/null +++ b/packages/workspaces/src/application/use-cases/connect-workspace.use-case.ts @@ -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; + +// ── 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 => { + 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); + }; diff --git a/packages/workspaces/src/application/use-cases/get-workspace.use-case.test.ts b/packages/workspaces/src/application/use-cases/get-workspace.use-case.test.ts index 983e18b..cb0b590 100644 --- a/packages/workspaces/src/application/use-cases/get-workspace.use-case.test.ts +++ b/packages/workspaces/src/application/use-cases/get-workspace.use-case.test.ts @@ -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); diff --git a/packages/workspaces/src/di/bind-dev-seed.test.ts b/packages/workspaces/src/di/bind-dev-seed.test.ts index 2bbbeca..6a69b72 100644 --- a/packages/workspaces/src/di/bind-dev-seed.test.ts +++ b/packages/workspaces/src/di/bind-dev-seed.test.ts @@ -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(() => { diff --git a/packages/workspaces/src/di/bind-dev-seed.ts b/packages/workspaces/src/di/bind-dev-seed.ts index 0cefd11..073f86c 100644 --- a/packages/workspaces/src/di/bind-dev-seed.ts +++ b/packages/workspaces/src/di/bind-dev-seed.ts @@ -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 { - 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 { .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, @@ -71,6 +87,29 @@ export async function bindDevSeedWorkspaces(ctx: BindContext): Promise { 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 { workspacesContainer, workspacesManifest, { + connectWorkspace: WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase, getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase, }, ctx, diff --git a/packages/workspaces/src/di/bind-production.ts b/packages/workspaces/src/di/bind-production.ts index 25eeb08..9290cc7 100644 --- a/packages/workspaces/src/di/bind-production.ts +++ b/packages/workspaces/src/di/bind-production.ts @@ -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, diff --git a/packages/workspaces/src/di/container.test.ts b/packages/workspaces/src/di/container.test.ts index 12820f3..80f737f 100644 --- a/packages/workspaces/src/di/container.test.ts +++ b/packages/workspaces/src/di/container.test.ts @@ -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( + WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase, + ); + expect(typeof useCase).toBe("function"); + }); + + it("resolves IConnectWorkspaceController as a function", () => { + const controller = workspacesContainer.get( + WORKSPACES_SYMBOLS.IConnectWorkspaceController, + ); + expect(typeof controller).toBe("function"); + }); + it("resolves IGetWorkspaceUseCase as a function", () => { const useCase = workspacesContainer.get( WORKSPACES_SYMBOLS.IGetWorkspaceUseCase, diff --git a/packages/workspaces/src/di/module.ts b/packages/workspaces/src/di/module.ts index 4704886..c00dd05 100644 --- a/packages/workspaces/src/di/module.ts +++ b/packages/workspaces/src/di/module.ts @@ -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( + WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase, + ).toDynamicValue((ctx) => + connectWorkspaceUseCase( + ctx.container.get( + WORKSPACES_SYMBOLS.IWorkspaceRepository, + ), + ), + ); + bind( WORKSPACES_SYMBOLS.IGetWorkspaceUseCase, ).toDynamicValue((ctx) => @@ -27,6 +45,16 @@ export const WorkspacesModule = new ContainerModule((bind: interfaces.Bind) => { ), ); + bind( + WORKSPACES_SYMBOLS.IConnectWorkspaceController, + ).toDynamicValue((ctx) => + connectWorkspaceController( + ctx.container.get( + WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase, + ), + ), + ); + bind( WORKSPACES_SYMBOLS.IGetWorkspaceController, ).toDynamicValue((ctx) => diff --git a/packages/workspaces/src/di/symbols.ts b/packages/workspaces/src/di/symbols.ts index 1d11ee7..c8e4de7 100644 --- a/packages/workspaces/src/di/symbols.ts +++ b/packages/workspaces/src/di/symbols.ts @@ -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"), // // diff --git a/packages/workspaces/src/entities/models/workspace.test.ts b/packages/workspaces/src/entities/models/workspace.test.ts index b883c77..37f77ae 100644 --- a/packages/workspaces/src/entities/models/workspace.test.ts +++ b/packages/workspaces/src/entities/models/workspace.test.ts @@ -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(); + }, + ); }); diff --git a/packages/workspaces/src/entities/models/workspace.ts b/packages/workspaces/src/entities/models/workspace.ts index 1ef1dfd..f5f4c5f 100644 --- a/packages/workspaces/src/entities/models/workspace.ts +++ b/packages/workspaces/src/entities/models/workspace.ts @@ -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; + +/** + * 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; diff --git a/packages/workspaces/src/feature.manifest.ts b/packages/workspaces/src/feature.manifest.ts index a3f8e95..061d054 100644 --- a/packages/workspaces/src/feature.manifest.ts +++ b/packages/workspaces/src/feature.manifest.ts @@ -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: [], diff --git a/packages/workspaces/src/index.ts b/packages/workspaces/src/index.ts index 752bdd5..8eddce1 100644 --- a/packages/workspaces/src/index.ts +++ b/packages/workspaces/src/index.ts @@ -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"; // diff --git a/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.ts b/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.ts index 99f4ca4..c5440ea 100644 --- a/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.ts +++ b/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.ts @@ -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([ - ["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; + /** + * 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(); private tracer: ITracer; private logger: ILogger; @@ -42,4 +68,22 @@ export class MockWorkspaceRepository implements IWorkspaceRepository { }, ); } + + async createWorkspace(data: CreateWorkspaceData): Promise { + 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; + }, + ); + } } diff --git a/packages/workspaces/src/infrastructure/repositories/workspace.repository.test.ts b/packages/workspaces/src/infrastructure/repositories/workspace.repository.test.ts index c0a4abb..bf58a83 100644 --- a/packages/workspaces/src/infrastructure/repositories/workspace.repository.test.ts +++ b/packages/workspaces/src/infrastructure/repositories/workspace.repository.test.ts @@ -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", + }); + }); }); diff --git a/packages/workspaces/src/infrastructure/repositories/workspace.repository.ts b/packages/workspaces/src/infrastructure/repositories/workspace.repository.ts index c6faabd..cfbc2ea 100644 --- a/packages/workspaces/src/infrastructure/repositories/workspace.repository.ts +++ b/packages/workspaces/src/infrastructure/repositories/workspace.repository.ts @@ -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 { + 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; + }, + ); + } } diff --git a/packages/workspaces/src/integrations/api/router.test.ts b/packages/workspaces/src/integrations/api/router.test.ts index 6f0fe49..7fb8bd4 100644 --- a/packages/workspaces/src/integrations/api/router.test.ts +++ b/packages/workspaces/src/integrations/api/router.test.ts @@ -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 { diff --git a/packages/workspaces/src/integrations/api/router.ts b/packages/workspaces/src/integrations/api/router.ts index 30f3388..b0b6e57 100644 --- a/packages/workspaces/src/integrations/api/router.ts +++ b/packages/workspaces/src/integrations/api/router.ts @@ -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( + WORKSPACES_SYMBOLS.IConnectWorkspaceController, + ); + return ctrl(input); + }), getWorkspace: workspacesProcedure .input(getWorkspaceInputSchema) .query(({ input }) => { diff --git a/packages/workspaces/src/interface-adapters/controllers/connect-workspace.controller.test.ts b/packages/workspaces/src/interface-adapters/controllers/connect-workspace.controller.test.ts new file mode 100644 index 0000000..54343ef --- /dev/null +++ b/packages/workspaces/src/interface-adapters/controllers/connect-workspace.controller.test.ts @@ -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); + }); +}); diff --git a/packages/workspaces/src/interface-adapters/controllers/connect-workspace.controller.ts b/packages/workspaces/src/interface-adapters/controllers/connect-workspace.controller.ts new file mode 100644 index 0000000..0bd6ff8 --- /dev/null +++ b/packages/workspaces/src/interface-adapters/controllers/connect-workspace.controller.ts @@ -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> => { + 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); + }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ace558f..0017e49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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