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:
@@ -16,6 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@repo/auth": "workspace:*",
|
"@repo/auth": "workspace:*",
|
||||||
"@repo/core-api": "workspace:*",
|
"@repo/core-api": "workspace:*",
|
||||||
|
"@repo/core-audit": "workspace:*",
|
||||||
"@repo/core-cms": "workspace:*",
|
"@repo/core-cms": "workspace:*",
|
||||||
"@repo/core-shared": "workspace:*",
|
"@repo/core-shared": "workspace:*",
|
||||||
"@repo/core-trpc": "workspace:^",
|
"@repo/core-trpc": "workspace:^",
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ vi.mock("@repo/auth/di/bind-production", () => ({
|
|||||||
bindProductionAuth: vi.fn(),
|
bindProductionAuth: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@repo/auth/di/bind-dev-seed", () => ({ bindDevSeedAuth: 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) => {
|
vi.mock("@repo/core-shared/instrumentation", async (importOriginal) => {
|
||||||
const actual =
|
const actual =
|
||||||
await importOriginal<typeof import("@repo/core-shared/instrumentation")>();
|
await importOriginal<typeof import("@repo/core-shared/instrumentation")>();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type IJobQueue,
|
type IJobQueue,
|
||||||
} from "@repo/core-shared/jobs";
|
} from "@repo/core-shared/jobs";
|
||||||
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
|
import { NoopRateLimit } from "@repo/core-shared/rate-limit";
|
||||||
|
import { bindAudit } from "@repo/core-audit/di";
|
||||||
import { bindProductionAuth } from "@repo/auth/di/bind-production";
|
import { bindProductionAuth } from "@repo/auth/di/bind-production";
|
||||||
import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed";
|
import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed";
|
||||||
import { bindProductionWorkspaces } from "@repo/workspaces/di/bind-production";
|
import { bindProductionWorkspaces } from "@repo/workspaces/di/bind-production";
|
||||||
@@ -85,11 +86,19 @@ export async function bindAllProduction(): Promise<void> {
|
|||||||
const { queue } = await resolveJobsProduction();
|
const { queue } = await resolveJobsProduction();
|
||||||
const resolvedConfig = await config;
|
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 = {
|
const ctx: BindProductionContext = {
|
||||||
config: resolvedConfig,
|
config: resolvedConfig,
|
||||||
tracer,
|
tracer,
|
||||||
logger,
|
logger,
|
||||||
queue,
|
queue,
|
||||||
|
auditLog,
|
||||||
rateLimit: new NoopRateLimit(),
|
rateLimit: new NoopRateLimit(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,10 +115,16 @@ export async function bindAllDevSeed(): Promise<void> {
|
|||||||
const { tracer, logger } = resolveInstrumentation(); // Rule 0
|
const { tracer, logger } = resolveInstrumentation(); // Rule 0
|
||||||
const { queue } = resolveJobsDevSeed();
|
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 = {
|
const ctx: BindContext = {
|
||||||
tracer,
|
tracer,
|
||||||
logger,
|
logger,
|
||||||
queue,
|
queue,
|
||||||
|
auditLog,
|
||||||
rateLimit: new NoopRateLimit(),
|
rateLimit: new NoopRateLimit(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,24 @@ import type { Workspace } from "../entities/models/workspace";
|
|||||||
export const CONTRACT_WORKSPACE_SEED: ReadonlyArray<
|
export const CONTRACT_WORKSPACE_SEED: ReadonlyArray<
|
||||||
readonly [string, Workspace]
|
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`.
|
* Realistic dev seed for `bindDevSeedWorkspaces`.
|
||||||
*
|
*
|
||||||
* Phase-1: returns a small hand-rolled Map. Replace with a faker-driven
|
* Phase-1: returns a small hand-rolled Map. Replace with a faker-driven
|
||||||
* `defineFactory` (see `packages/blog/src/__factories__/`) once the entity
|
* `defineFactory` once the entity shape stabilises.
|
||||||
* shape stabilises.
|
|
||||||
*/
|
*/
|
||||||
export function buildDevWorkspaceMap(): Map<string, Workspace> {
|
export function buildDevWorkspaceMap(): Map<string, Workspace> {
|
||||||
return new 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-1",
|
||||||
["dev-3", { id: "dev-3", name: "Dev Three" }],
|
{
|
||||||
|
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";
|
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 {
|
export interface IWorkspaceRepository {
|
||||||
getWorkspace(id: string): Promise<Workspace | null>;
|
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 () => {
|
it("throws ZodError when repository returns malformed data", async () => {
|
||||||
const malformedRepo = {
|
const malformedRepo = {
|
||||||
getWorkspace: async () => ({ id: "", name: "x" }) as never,
|
getWorkspace: async () => ({ id: "", name: "x" }) as never,
|
||||||
|
createWorkspace: async () => ({ id: "", name: "x" }) as never,
|
||||||
};
|
};
|
||||||
const useCase = getWorkspaceUseCase(malformedRepo);
|
const useCase = getWorkspaceUseCase(malformedRepo);
|
||||||
await expect(useCase({ id: "anything" })).rejects.toBeInstanceOf(ZodError);
|
await expect(useCase({ id: "anything" })).rejects.toBeInstanceOf(ZodError);
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
import { NoopTracer, NoopLogger } from "@repo/core-shared/instrumentation";
|
||||||
|
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
|
||||||
import { bindDevSeedWorkspaces } from "@/di/bind-dev-seed";
|
import { bindDevSeedWorkspaces } from "@/di/bind-dev-seed";
|
||||||
import { workspacesContainer } from "@/di/container";
|
import { workspacesContainer } from "@/di/container";
|
||||||
import { WORKSPACES_SYMBOLS } from "@/di/symbols";
|
import { WORKSPACES_SYMBOLS } from "@/di/symbols";
|
||||||
import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock";
|
import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock";
|
||||||
import type { IWorkspaceRepository } from "@/application/repositories/workspace.repository.interface";
|
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", () => {
|
describe("bindDevSeedWorkspaces", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import { workspacesContainer } from "./container";
|
|||||||
import { WORKSPACES_SYMBOLS } from "./symbols";
|
import { WORKSPACES_SYMBOLS } from "./symbols";
|
||||||
import { MockWorkspaceRepository } from "../infrastructure/repositories/workspace.repository.mock";
|
import { MockWorkspaceRepository } from "../infrastructure/repositories/workspace.repository.mock";
|
||||||
import { buildDevWorkspaceMap } from "../__seeds__/dev";
|
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 { 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 { getWorkspaceController } from "../interface-adapters/controllers/get-workspace.controller";
|
||||||
import type { IWorkspaceRepository } from "../application/repositories/workspace.repository.interface";
|
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.
|
* populated repo and rebinds the symbol.
|
||||||
*/
|
*/
|
||||||
export async function bindDevSeedWorkspaces(ctx: BindContext): Promise<void> {
|
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
|
// Bind shared instrumentation into feature container
|
||||||
if (workspacesContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
if (workspacesContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||||
@@ -58,7 +61,20 @@ export async function bindDevSeedWorkspaces(ctx: BindContext): Promise<void> {
|
|||||||
.bind<IWorkspaceRepository>(WORKSPACES_SYMBOLS.IWorkspaceRepository)
|
.bind<IWorkspaceRepository>(WORKSPACES_SYMBOLS.IWorkspaceRepository)
|
||||||
.toConstantValue(repo);
|
.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({
|
const wrappedGetWorkspace = wireUseCase({
|
||||||
container: workspacesContainer,
|
container: workspacesContainer,
|
||||||
symbol: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
|
symbol: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
|
||||||
@@ -71,6 +87,29 @@ export async function bindDevSeedWorkspaces(ctx: BindContext): Promise<void> {
|
|||||||
logger,
|
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)) {
|
if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IGetWorkspaceController)) {
|
||||||
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IGetWorkspaceController);
|
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IGetWorkspaceController);
|
||||||
}
|
}
|
||||||
@@ -105,6 +144,7 @@ export async function bindDevSeedWorkspaces(ctx: BindContext): Promise<void> {
|
|||||||
workspacesContainer,
|
workspacesContainer,
|
||||||
workspacesManifest,
|
workspacesManifest,
|
||||||
{
|
{
|
||||||
|
connectWorkspace: WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase,
|
||||||
getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
|
getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
|
||||||
},
|
},
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -14,12 +14,22 @@ import { workspacesContainer } from "./container";
|
|||||||
import { WORKSPACES_SYMBOLS } from "./symbols";
|
import { WORKSPACES_SYMBOLS } from "./symbols";
|
||||||
import { workspacesManifest } from "../feature.manifest";
|
import { workspacesManifest } from "../feature.manifest";
|
||||||
import { WorkspaceRepository } from "../infrastructure/repositories/workspace.repository";
|
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 { 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 { getWorkspaceController } from "../interface-adapters/controllers/get-workspace.controller";
|
||||||
|
|
||||||
export function bindProductionWorkspaces(ctx: BindProductionContext): void {
|
export function bindProductionWorkspaces(ctx: BindProductionContext): void {
|
||||||
const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } =
|
const {
|
||||||
ctx;
|
config,
|
||||||
|
tracer,
|
||||||
|
logger,
|
||||||
|
bus,
|
||||||
|
queue,
|
||||||
|
realtime,
|
||||||
|
realtimeRegistry,
|
||||||
|
auditLog,
|
||||||
|
} = ctx;
|
||||||
|
|
||||||
// Bind shared instrumentation into feature container
|
// Bind shared instrumentation into feature container
|
||||||
if (workspacesContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
if (workspacesContainer.isBound(INSTRUMENTATION_SYMBOLS.TRACER)) {
|
||||||
@@ -44,7 +54,20 @@ export function bindProductionWorkspaces(ctx: BindProductionContext): void {
|
|||||||
.bind(WORKSPACES_SYMBOLS.IWorkspaceRepository)
|
.bind(WORKSPACES_SYMBOLS.IWorkspaceRepository)
|
||||||
.toConstantValue(repo);
|
.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({
|
const wrappedGetWorkspace = wireUseCase({
|
||||||
container: workspacesContainer,
|
container: workspacesContainer,
|
||||||
symbol: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
|
symbol: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
|
||||||
@@ -57,7 +80,30 @@ export function bindProductionWorkspaces(ctx: BindProductionContext): void {
|
|||||||
logger,
|
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)) {
|
if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IGetWorkspaceController)) {
|
||||||
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IGetWorkspaceController);
|
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IGetWorkspaceController);
|
||||||
}
|
}
|
||||||
@@ -93,6 +139,7 @@ export function bindProductionWorkspaces(ctx: BindProductionContext): void {
|
|||||||
workspacesContainer,
|
workspacesContainer,
|
||||||
workspacesManifest,
|
workspacesManifest,
|
||||||
{
|
{
|
||||||
|
connectWorkspace: WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase,
|
||||||
getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
|
getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
|
||||||
},
|
},
|
||||||
ctx,
|
ctx,
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { WORKSPACES_SYMBOLS } from "./symbols";
|
|||||||
import { WorkspacesModule } from "./module";
|
import { WorkspacesModule } from "./module";
|
||||||
import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock";
|
import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock";
|
||||||
import type { IWorkspaceRepository } from "@/application/repositories/workspace.repository.interface";
|
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 { 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";
|
import type { IGetWorkspaceController } from "@/interface-adapters/controllers/get-workspace.controller";
|
||||||
|
|
||||||
describe("workspacesContainer", () => {
|
describe("workspacesContainer", () => {
|
||||||
@@ -24,6 +26,20 @@ describe("workspacesContainer", () => {
|
|||||||
expect(repo).toBeInstanceOf(MockWorkspaceRepository);
|
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", () => {
|
it("resolves IGetWorkspaceUseCase as a function", () => {
|
||||||
const useCase = workspacesContainer.get<IGetWorkspaceUseCase>(
|
const useCase = workspacesContainer.get<IGetWorkspaceUseCase>(
|
||||||
WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
|
WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
|
||||||
|
|||||||
@@ -2,10 +2,18 @@ import { ContainerModule, type interfaces } from "inversify";
|
|||||||
|
|
||||||
import type { IWorkspaceRepository } from "../application/repositories/workspace.repository.interface";
|
import type { IWorkspaceRepository } from "../application/repositories/workspace.repository.interface";
|
||||||
import { MockWorkspaceRepository } from "../infrastructure/repositories/workspace.repository.mock";
|
import { MockWorkspaceRepository } from "../infrastructure/repositories/workspace.repository.mock";
|
||||||
|
import {
|
||||||
|
connectWorkspaceUseCase,
|
||||||
|
type IConnectWorkspaceUseCase,
|
||||||
|
} from "../application/use-cases/connect-workspace.use-case";
|
||||||
import {
|
import {
|
||||||
getWorkspaceUseCase,
|
getWorkspaceUseCase,
|
||||||
type IGetWorkspaceUseCase,
|
type IGetWorkspaceUseCase,
|
||||||
} from "../application/use-cases/get-workspace.use-case";
|
} from "../application/use-cases/get-workspace.use-case";
|
||||||
|
import {
|
||||||
|
connectWorkspaceController,
|
||||||
|
type IConnectWorkspaceController,
|
||||||
|
} from "../interface-adapters/controllers/connect-workspace.controller";
|
||||||
import {
|
import {
|
||||||
getWorkspaceController,
|
getWorkspaceController,
|
||||||
type IGetWorkspaceController,
|
type IGetWorkspaceController,
|
||||||
@@ -17,6 +25,16 @@ export const WorkspacesModule = new ContainerModule((bind: interfaces.Bind) => {
|
|||||||
MockWorkspaceRepository,
|
MockWorkspaceRepository,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
bind<IConnectWorkspaceUseCase>(
|
||||||
|
WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase,
|
||||||
|
).toDynamicValue((ctx) =>
|
||||||
|
connectWorkspaceUseCase(
|
||||||
|
ctx.container.get<IWorkspaceRepository>(
|
||||||
|
WORKSPACES_SYMBOLS.IWorkspaceRepository,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
bind<IGetWorkspaceUseCase>(
|
bind<IGetWorkspaceUseCase>(
|
||||||
WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
|
WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
|
||||||
).toDynamicValue((ctx) =>
|
).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>(
|
bind<IGetWorkspaceController>(
|
||||||
WORKSPACES_SYMBOLS.IGetWorkspaceController,
|
WORKSPACES_SYMBOLS.IGetWorkspaceController,
|
||||||
).toDynamicValue((ctx) =>
|
).toDynamicValue((ctx) =>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
export const WORKSPACES_SYMBOLS = {
|
export const WORKSPACES_SYMBOLS = {
|
||||||
IWorkspaceRepository: Symbol.for("workspaces:IWorkspaceRepository"),
|
IWorkspaceRepository: Symbol.for("workspaces:IWorkspaceRepository"),
|
||||||
// Use cases
|
// Use cases
|
||||||
|
IConnectWorkspaceUseCase: Symbol.for("workspaces:IConnectWorkspaceUseCase"),
|
||||||
IGetWorkspaceUseCase: Symbol.for("workspaces:IGetWorkspaceUseCase"),
|
IGetWorkspaceUseCase: Symbol.for("workspaces:IGetWorkspaceUseCase"),
|
||||||
// Controllers
|
// Controllers
|
||||||
|
IConnectWorkspaceController: Symbol.for(
|
||||||
|
"workspaces:IConnectWorkspaceController",
|
||||||
|
),
|
||||||
IGetWorkspaceController: Symbol.for("workspaces:IGetWorkspaceController"),
|
IGetWorkspaceController: Symbol.for("workspaces:IGetWorkspaceController"),
|
||||||
// <gen:event-handler-symbols>
|
// <gen:event-handler-symbols>
|
||||||
// <gen:job-symbols>
|
// <gen:job-symbols>
|
||||||
|
|||||||
@@ -1,24 +1,79 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("workspaceSchema", () => {
|
||||||
it("accepts a valid workspace", () => {
|
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.id).toBe("1");
|
||||||
expect(result.name).toBe("Example");
|
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", () => {
|
it("rejects an empty id", () => {
|
||||||
expect(() => workspaceSchema.parse({ id: "", name: "ok" })).toThrow();
|
expect(() => workspaceSchema.parse({ ...VALID, id: "" })).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an empty name", () => {
|
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", () => {
|
it("rejects a name over 128 chars", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
workspaceSchema.parse({ id: "1", name: "x".repeat(129) }),
|
workspaceSchema.parse({ ...VALID, name: "x".repeat(129) }),
|
||||||
).toThrow();
|
).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";
|
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({
|
export const workspaceSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
name: z.string().min(1).max(128),
|
name: z.string().min(1).max(128),
|
||||||
|
gitUrl: gitUrlSchema,
|
||||||
|
status: workspaceStatusSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Workspace = z.infer<typeof workspaceSchema>;
|
export type Workspace = z.infer<typeof workspaceSchema>;
|
||||||
|
|||||||
@@ -13,8 +13,15 @@ import { defineFeature } from "@repo/core-shared/conformance";
|
|||||||
*/
|
*/
|
||||||
export const workspacesManifest = defineFeature({
|
export const workspacesManifest = defineFeature({
|
||||||
name: "workspaces",
|
name: "workspaces",
|
||||||
requiredCores: [],
|
requiredCores: ["audit"],
|
||||||
useCases: {
|
useCases: {
|
||||||
|
connectWorkspace: {
|
||||||
|
mutates: true,
|
||||||
|
audits: ["workspace-connected"],
|
||||||
|
publishes: [],
|
||||||
|
consumes: [],
|
||||||
|
analyticsEvents: [],
|
||||||
|
},
|
||||||
getWorkspace: {
|
getWorkspace: {
|
||||||
mutates: false,
|
mutates: false,
|
||||||
audits: [],
|
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 type { WorkspacesRouter } from "./integrations/api/router";
|
||||||
export { WorkspaceNotFoundError } from "./entities/errors/workspace";
|
export { WorkspaceNotFoundError } from "./entities/errors/workspace";
|
||||||
export { InputParseError } from "./entities/errors/common";
|
export { InputParseError } from "./entities/errors/common";
|
||||||
|
|
||||||
// Use case schemas + types
|
// Use case schemas + types
|
||||||
|
export {
|
||||||
|
connectWorkspaceInputSchema,
|
||||||
|
connectWorkspaceOutputSchema,
|
||||||
|
type ConnectWorkspaceInput,
|
||||||
|
type ConnectWorkspaceOutput,
|
||||||
|
type IConnectWorkspaceUseCase,
|
||||||
|
} from "./application/use-cases/connect-workspace.use-case";
|
||||||
export {
|
export {
|
||||||
getWorkspaceInputSchema,
|
getWorkspaceInputSchema,
|
||||||
getWorkspaceOutputSchema,
|
getWorkspaceOutputSchema,
|
||||||
@@ -13,6 +20,7 @@ export {
|
|||||||
} from "./application/use-cases/get-workspace.use-case";
|
} from "./application/use-cases/get-workspace.use-case";
|
||||||
|
|
||||||
// Controller type aliases
|
// Controller type aliases
|
||||||
|
export type { IConnectWorkspaceController } from "./interface-adapters/controllers/connect-workspace.controller";
|
||||||
export type { IGetWorkspaceController } from "./interface-adapters/controllers/get-workspace.controller";
|
export type { IGetWorkspaceController } from "./interface-adapters/controllers/get-workspace.controller";
|
||||||
|
|
||||||
// <gen:events>
|
// <gen:events>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { injectable } from "inversify";
|
import { injectable } from "inversify";
|
||||||
import {
|
import {
|
||||||
NoopTracer,
|
NoopTracer,
|
||||||
@@ -7,17 +8,42 @@ import {
|
|||||||
type ILogger,
|
type ILogger,
|
||||||
} from "@repo/core-shared/instrumentation";
|
} 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";
|
import type { Workspace } from "../../entities/models/workspace";
|
||||||
|
|
||||||
const DEFAULT_DATA = new Map<string, 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()
|
@injectable()
|
||||||
export class MockWorkspaceRepository implements IWorkspaceRepository {
|
export class MockWorkspaceRepository implements IWorkspaceRepository {
|
||||||
private readonly data: Map<string, Workspace>;
|
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 tracer: ITracer;
|
||||||
private logger: ILogger;
|
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 { describe, it, expect } from "vitest";
|
||||||
|
import type { ITracer } from "@repo/core-shared/instrumentation";
|
||||||
import {
|
import {
|
||||||
RecordingTracer,
|
RecordingTracer,
|
||||||
RecordingLogger,
|
RecordingLogger,
|
||||||
@@ -34,4 +35,47 @@ describe("WorkspaceRepository (Phase-1 stub)", () => {
|
|||||||
expect(tracer.spans[0]!.attributes.id).toBe("custom-id");
|
expect(tracer.spans[0]!.attributes.id).toBe("custom-id");
|
||||||
expect(tracer.spans[0]!.attributes.found).toBe(false);
|
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,
|
type ILogger,
|
||||||
} from "@repo/core-shared/instrumentation";
|
} 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";
|
import type { Workspace } from "../../entities/models/workspace";
|
||||||
|
|
||||||
const FEATURE = "workspaces" as const;
|
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();
|
workspacesContainer.unbindAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exposes the getWorkspace procedure", () => {
|
it("exposes the getWorkspace and connectWorkspace procedures", () => {
|
||||||
const names = Object.keys(workspacesRouter._def.procedures);
|
const names = Object.keys(workspacesRouter._def.procedures);
|
||||||
expect(names).toContain("getWorkspace");
|
expect(names).toContain("getWorkspace");
|
||||||
|
expect(names).toContain("connectWorkspace");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("getWorkspace returns the seeded workspace", async () => {
|
it("getWorkspace returns the seeded workspace", async () => {
|
||||||
@@ -28,6 +29,17 @@ describe("workspacesRouter", () => {
|
|||||||
const result = await caller.getWorkspace({ id: "seed-1" });
|
const result = await caller.getWorkspace({ id: "seed-1" });
|
||||||
expect(result.id).toBe("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)", () => {
|
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 () => {
|
it("translates WorkspaceNotFoundError → NOT_FOUND when repository returns null", async () => {
|
||||||
@injectable()
|
@injectable()
|
||||||
class NullWorkspaceRepository {
|
class NullWorkspaceRepository {
|
||||||
|
|||||||
@@ -3,12 +3,22 @@ import { router } from "@repo/core-shared/trpc/init";
|
|||||||
import { workspacesContainer } from "../../di/container";
|
import { workspacesContainer } from "../../di/container";
|
||||||
import { WORKSPACES_SYMBOLS } from "../../di/symbols";
|
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 { 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 type { IGetWorkspaceController } from "../../interface-adapters/controllers/get-workspace.controller";
|
||||||
|
|
||||||
import { workspacesProcedure } from "./procedures";
|
import { workspacesProcedure } from "./procedures";
|
||||||
|
|
||||||
export const workspacesRouter = router({
|
export const workspacesRouter = router({
|
||||||
|
connectWorkspace: workspacesProcedure
|
||||||
|
.input(connectWorkspaceInputSchema)
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
const ctrl = workspacesContainer.get<IConnectWorkspaceController>(
|
||||||
|
WORKSPACES_SYMBOLS.IConnectWorkspaceController,
|
||||||
|
);
|
||||||
|
return ctrl(input);
|
||||||
|
}),
|
||||||
getWorkspace: workspacesProcedure
|
getWorkspace: workspacesProcedure
|
||||||
.input(getWorkspaceInputSchema)
|
.input(getWorkspaceInputSchema)
|
||||||
.query(({ input }) => {
|
.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);
|
||||||
|
};
|
||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -179,6 +179,9 @@ importers:
|
|||||||
"@repo/core-api":
|
"@repo/core-api":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/core-api
|
version: link:../../packages/core-api
|
||||||
|
"@repo/core-audit":
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/core-audit
|
||||||
"@repo/core-cms":
|
"@repo/core-cms":
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/core-cms
|
version: link:../../packages/core-cms
|
||||||
|
|||||||
Reference in New Issue
Block a user