diff --git a/apps/gateway/src/app.module.ts b/apps/gateway/src/app.module.ts index 8adfabe8..7c2388a8 100644 --- a/apps/gateway/src/app.module.ts +++ b/apps/gateway/src/app.module.ts @@ -21,6 +21,7 @@ import { AdminModule } from './admin/admin.module.js'; import { CommandsModule } from './commands/commands.module.js'; import { PreferencesModule } from './preferences/preferences.module.js'; import { GCModule } from './gc/gc.module.js'; +import { HarnessModule } from './harness/harness.module.js'; import { ReloadModule } from './reload/reload.module.js'; import { WorkspaceModule } from './workspace/workspace.module.js'; import { QueueModule } from './queue/queue.module.js'; @@ -60,6 +61,7 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder PreferencesModule, CommandsModule, GCModule, + HarnessModule, QueueModule, ReloadModule, WorkspaceModule, diff --git a/apps/gateway/src/harness/harness-selection.controller.spec.ts b/apps/gateway/src/harness/harness-selection.controller.spec.ts new file mode 100644 index 00000000..7714bb53 --- /dev/null +++ b/apps/gateway/src/harness/harness-selection.controller.spec.ts @@ -0,0 +1,164 @@ +import 'reflect-metadata'; +import { + type CanActivate, + type ExecutionContext, + type INestApplication, + ValidationPipe, +} from '@nestjs/common'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { HarnessRegistry } from './harness.registry.js'; +import { HARNESS_REGISTRY } from './harness.tokens.js'; +import { HarnessSelectionRepository } from './harness-selection.repository.js'; +import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js'; +// Import the REAL module (not a hand-listed controllers+mocks list) so an +// unresolved provider fails at app.init() — the #1145-class DI-boot guard. +import { HarnessModule } from './harness.module.js'; + +// A known-available tuple from the fake adapter's default catalog. +const VALID = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-mini' }; +// A tuple whose provider/model are not in any catalog. +const UNKNOWN = { harnessId: 'fake', providerId: 'ghost-provider', modelId: 'ghost-model' }; +// A tuple that is known in the catalog but flagged unavailable. +const UNAVAILABLE = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-legacy' }; + +const authGuard: CanActivate = { + canActivate(context: ExecutionContext): boolean { + const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>(); + requestContext.user = { id: 'user-1' }; + return true; + }, +}; + +function registryWithFake(): HarnessRegistry { + const registry = new HarnessRegistry(); + registry.register(new FakeHarnessAdapter({ id: 'fake' })); + return registry; +} + +describe('Harness selection HTTP surface', () => { + let app: INestApplication; + let repository: HarnessSelectionRepository; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [HarnessModule], + }) + .overrideGuard(AuthGuard) + .useValue(authGuard) + .overrideProvider(HARNESS_REGISTRY) + .useValue(registryWithFake()) + .compile(); + + // Real in-memory repository from the module graph — proves the module wired it. + repository = moduleRef.get(HarnessSelectionRepository); + + app = moduleRef.createNestApplication(new FastifyAdapter()); + app.useGlobalPipes( + new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), + ); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + beforeEach(() => { + // Reset owner-scoped state between tests via the public API surface. + repository.set({ userId: 'user-1', tenantId: 'user-1' }, VALID); + }); + + afterAll(async () => { + await app.close(); + }); + + it('GET selection is server-scoped and ignores caller-supplied scope in the query', async () => { + const response = await request(app.getHttpServer()) + .get('/api/chat/preferences/selection') + .query({ userId: 'attacker', tenantId: 'attacker-tenant', seatId: 'attacker-seat' }); + + expect(response.status).toBe(200); + // The returned selection is user-1's (guard-derived scope), not the query's. + expect(response.body.selection).toEqual(VALID); + }); + + it('PUT with a valid structured tuple persists and round-trips via GET', async () => { + const next = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-pro' }; + + const put = await request(app.getHttpServer()) + .put('/api/chat/preferences/selection') + .send(next) + .set('Content-Type', 'application/json'); + expect(put.status).toBe(200); + expect(put.body.selection).toEqual(next); + + const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection'); + expect(get.status).toBe(200); + expect(get.body.selection).toEqual(next); + }); + + it('PUT with FREE TEXT is rejected 400 and does not mutate the stored selection', async () => { + const response = await request(app.getHttpServer()) + .put('/api/chat/preferences/selection') + .send({ selection: 'gpt-4o' }) + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(400); + + const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection'); + expect(get.body.selection).toEqual(VALID); + }); + + it.each([ + ['seatId', { ...VALID, seatId: 'attacker-seat' }], + ['tenantId', { ...VALID, tenantId: 'attacker-tenant' }], + ['userId', { ...VALID, userId: 'attacker' }], + ['nativeSessionPath', { ...VALID, nativeSessionPath: '/var/native/x.jsonl' }], + ['executable', { ...VALID, executable: '/usr/bin/evil' }], + ['home', { ...VALID, home: '/home/attacker' }], + ['cwd', { ...VALID, cwd: '/tmp/attacker' }], + ])( + 'PUT with an extra authority-bearing field (%s) is rejected 400 and does not mutate stored selection', + async (_name, body) => { + const response = await request(app.getHttpServer()) + .put('/api/chat/preferences/selection') + .send(body) + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(400); + + const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection'); + expect(get.body.selection).toEqual(VALID); + }, + ); + + it('PUT with an UNKNOWN tuple returns selection_invalid, unchanged and echoed unchanged (no fallback)', async () => { + const response = await request(app.getHttpServer()) + .put('/api/chat/preferences/selection') + .send(UNKNOWN) + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(422); + expect(response.body.code).toBe('selection_invalid'); + // Echoed back unchanged: no first-row / first-provider substitution. + expect(response.body.selection).toEqual(UNKNOWN); + + const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection'); + expect(get.body.selection).toEqual(VALID); + }); + + it('PUT with a KNOWN-but-UNAVAILABLE tuple returns model_unavailable, unchanged (distinct from selection_invalid)', async () => { + const response = await request(app.getHttpServer()) + .put('/api/chat/preferences/selection') + .send(UNAVAILABLE) + .set('Content-Type', 'application/json'); + + expect(response.status).toBe(422); + expect(response.body.code).toBe('model_unavailable'); + expect(response.body.selection).toEqual(UNAVAILABLE); + + const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection'); + expect(get.body.selection).toEqual(VALID); + }); +}); diff --git a/apps/gateway/src/harness/harness-selection.controller.ts b/apps/gateway/src/harness/harness-selection.controller.ts new file mode 100644 index 00000000..06c4210c --- /dev/null +++ b/apps/gateway/src/harness/harness-selection.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Get, HttpException, HttpStatus, Put, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; +import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; +import { HarnessOperationError } from './harness.registry.js'; +import { HarnessSelectionService } from './harness-selection.service.js'; +import { HarnessSelectionInputDto, type SelectionResponseDto } from './harness.dto.js'; + +/** + * Chat-preferences selection surface. The scope is ALWAYS derived on the server + * from the authenticated user (`scopeFromUser(CurrentUser)`); the request body and + * query string can never name another user, tenant, or seat. A typed selection + * failure (unknown tuple → `selection_invalid`, known-but-unavailable → + * `model_unavailable`) is returned as 422 with the requested tuple echoed back + * unchanged, and never mutates the stored selection. + */ +@Controller('api/chat/preferences/selection') +@UseGuards(AuthGuard) +export class HarnessSelectionController { + constructor(private readonly selection: HarnessSelectionService) {} + + @Get() + get(@CurrentUser() user: AuthenticatedUserLike): SelectionResponseDto { + return { selection: this.selection.getSelection(scopeFromUser(user)) }; + } + + @Put() + async put( + @CurrentUser() user: AuthenticatedUserLike, + @Body() dto: HarnessSelectionInputDto, + ): Promise { + try { + const stored = await this.selection.setSelection(scopeFromUser(user), { + harnessId: dto.harnessId, + providerId: dto.providerId, + modelId: dto.modelId, + }); + return { selection: stored }; + } catch (error) { + if (error instanceof HarnessOperationError) { + throw new HttpException(error.dto, HttpStatus.UNPROCESSABLE_ENTITY); + } + throw error; + } + } +} diff --git a/apps/gateway/src/harness/harness-selection.repository.ts b/apps/gateway/src/harness/harness-selection.repository.ts new file mode 100644 index 00000000..04734d9f Binary files /dev/null and b/apps/gateway/src/harness/harness-selection.repository.ts differ diff --git a/apps/gateway/src/harness/harness-selection.service.ts b/apps/gateway/src/harness/harness-selection.service.ts new file mode 100644 index 00000000..e77b685a --- /dev/null +++ b/apps/gateway/src/harness/harness-selection.service.ts @@ -0,0 +1,90 @@ +import { randomUUID } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import type { HarnessSelection } from '@mosaicstack/types'; +import type { ActorTenantScope } from '../auth/session-scope.js'; +import { + HarnessAdapterUnavailableError, + HarnessRegistry, + operationError, +} from './harness.registry.js'; +import { HARNESS_REGISTRY } from './harness.tokens.js'; +import { readContextFromScope } from './harness.dto.js'; +import { HarnessSelectionRepository } from './harness-selection.repository.js'; + +/** + * Selection logic for the Slice-Zero chat-preferences surface. It validates the + * requested harness/provider/model tuple against the live catalog with NO + * fallback substitution, then persists it owner-scoped. The stored selection is + * only ever mutated when the tuple is valid AND available. + */ +@Injectable() +export class HarnessSelectionService { + constructor( + @Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry, + private readonly repository: HarnessSelectionRepository, + ) {} + + getSelection(scope: ActorTenantScope): HarnessSelection | null { + return this.repository.get(scope); + } + + async setSelection( + scope: ActorTenantScope, + selection: HarnessSelection, + ): Promise { + // Throws HarnessOperationError (selection_invalid / model_unavailable) with the + // requested tuple echoed back unchanged. The store is untouched on any throw. + await this.assertSelectionAvailable(scope, selection); + return this.repository.set(scope, selection); + } + + private async assertSelectionAvailable( + scope: ActorTenantScope, + selection: HarnessSelection, + ): Promise { + const correlationId = randomUUID(); + + let adapter; + try { + adapter = this.registry.get(selection.harnessId); + } catch (error) { + if (error instanceof HarnessAdapterUnavailableError) { + // An unknown harness makes the whole tuple invalid — no fallback adapter. + throw operationError( + 'selection_invalid', + 'The requested harness/provider/model tuple is not in the catalog.', + selection, + correlationId, + ); + } + throw error; + } + + const catalog = await adapter.catalog(readContextFromScope(scope)); + const entry = catalog.models.find( + (candidate) => + candidate.harnessId === selection.harnessId && + candidate.providerId === selection.providerId && + candidate.modelId === selection.modelId, + ); + + if (!entry) { + // No first-row / first-provider fallback: reject the requested tuple unchanged. + throw operationError( + 'selection_invalid', + 'The requested harness/provider/model tuple is not in the catalog.', + selection, + correlationId, + ); + } + if (entry.availability === 'unavailable') { + throw operationError( + 'model_unavailable', + 'The requested model is currently unavailable.', + selection, + correlationId, + true, + ); + } + } +} diff --git a/apps/gateway/src/harness/harness.controller.spec.ts b/apps/gateway/src/harness/harness.controller.spec.ts new file mode 100644 index 00000000..4e6f5e45 --- /dev/null +++ b/apps/gateway/src/harness/harness.controller.spec.ts @@ -0,0 +1,138 @@ +import 'reflect-metadata'; +import { + type CanActivate, + type ExecutionContext, + type INestApplication, + ValidationPipe, +} from '@nestjs/common'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { HarnessRegistry } from './harness.registry.js'; +import { HARNESS_REGISTRY } from './harness.tokens.js'; +import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js'; +// The real module under test — importing it (not a hand-listed controllers/mocks +// list) is what makes an unresolved provider fail loudly at app.init() (#1145 guard). +import { HarnessModule } from './harness.module.js'; + +// Fields that must NEVER surface on a browser-facing catalog/list response. +const FORBIDDEN_KEYS = [ + 'executable', + 'executablePath', + 'home', + 'homeDir', + 'cwd', + 'workingDir', + 'workingDirectory', + 'nativeSessionPath', + 'sessionPath', + 'env', + 'secret', + 'secrets', + 'token', + 'apiKey', +]; + +function assertNoForbiddenLeak(payload: unknown): void { + const serialized = JSON.stringify(payload).toLowerCase(); + for (const key of FORBIDDEN_KEYS) { + expect(serialized).not.toContain(key.toLowerCase()); + } +} + +const authGuard: CanActivate = { + canActivate(context: ExecutionContext): boolean { + const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>(); + requestContext.user = { id: 'user-1' }; + return true; + }, +}; + +function registryWithFake(): HarnessRegistry { + const registry = new HarnessRegistry(); + registry.register(new FakeHarnessAdapter({ id: 'fake' })); + return registry; +} + +describe('Harness catalog HTTP surface', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [HarnessModule], + }) + .overrideGuard(AuthGuard) + .useValue(authGuard) + .overrideProvider(HARNESS_REGISTRY) + .useValue(registryWithFake()) + .compile(); + + app = moduleRef.createNestApplication(new FastifyAdapter()); + app.useGlobalPipes( + new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }), + ); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('boots the real HarnessModule so all providers resolve at app.init()', () => { + // If HarnessModule failed to resolve a provider, beforeAll's app.init() would + // have thrown and this suite would never reach here. + expect(app).toBeDefined(); + }); + + it('GET /api/harnesses returns 200 with safe fields only', async () => { + const response = await request(app.getHttpServer()).get('/api/harnesses'); + + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body.length).toBeGreaterThan(0); + const summary = response.body[0]; + expect(Object.keys(summary).sort()).toEqual(['capabilities', 'displayName', 'id']); + expect(summary.id).toBe('fake'); + expect(typeof summary.displayName).toBe('string'); + expect(Array.isArray(summary.capabilities)).toBe(true); + assertNoForbiddenLeak(response.body); + }); + + it('GET /api/harnesses/:harnessId/catalog returns 200 with safe catalog fields only', async () => { + const response = await request(app.getHttpServer()).get('/api/harnesses/fake/catalog'); + + expect(response.status).toBe(200); + expect(response.body.harnessId).toBe('fake'); + expect(typeof response.body.version).toBe('string'); + expect(typeof response.body.fingerprint).toBe('string'); + expect(Array.isArray(response.body.models)).toBe(true); + expect(response.body.models.length).toBeGreaterThan(0); + const entry = response.body.models[0]; + // Whitelisted catalog-entry fields only (no executables/paths/secrets). + expect(Object.keys(entry).sort()).toEqual( + [ + 'authState', + 'availability', + 'displayName', + 'harnessId', + 'inputTypes', + 'modelId', + 'providerId', + 'reasoningCapability', + ].sort(), + ); + assertNoForbiddenLeak(response.body); + }); + + it('GET catalog for an unknown harnessId returns a typed adapter_unavailable error, never a fallback catalog', async () => { + const response = await request(app.getHttpServer()).get('/api/harnesses/ghost-harness/catalog'); + + expect(response.status).toBe(404); + expect(response.body.code).toBe('adapter_unavailable'); + // A fallback catalog would carry a models array; a typed error must not. + expect(response.body.models).toBeUndefined(); + }); +}); diff --git a/apps/gateway/src/harness/harness.controller.ts b/apps/gateway/src/harness/harness.controller.ts new file mode 100644 index 00000000..75da289d --- /dev/null +++ b/apps/gateway/src/harness/harness.controller.ts @@ -0,0 +1,65 @@ +import { + Controller, + Get, + HttpException, + HttpStatus, + Inject, + Param, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '../auth/auth.guard.js'; +import { CurrentUser } from '../auth/current-user.decorator.js'; +import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js'; +import { HarnessAdapterUnavailableError, HarnessRegistry } from './harness.registry.js'; +import { HARNESS_REGISTRY } from './harness.tokens.js'; +import { + readContextFromScope, + toHarnessSummary, + toSafeCatalog, + type HarnessCatalogDto, + type HarnessSummaryDto, +} from './harness.dto.js'; + +/** + * Generic harness catalog surface. It exposes only harness-neutral, browser-safe + * fields (identity, capabilities, provider/model catalog) — never executables, + * native paths, home/cwd, env, or secrets. There is NO provider-probe route here; + * `/api/providers` and `POST /api/providers/test` are intentionally out of scope. + */ +@Controller('api/harnesses') +@UseGuards(AuthGuard) +export class HarnessController { + constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {} + + @Get() + async list(@CurrentUser() user: AuthenticatedUserLike): Promise { + const context = readContextFromScope(scopeFromUser(user)); + const summaries: HarnessSummaryDto[] = []; + for (const adapter of this.registry.list()) { + summaries.push(toHarnessSummary(await adapter.describe(context))); + } + return summaries; + } + + @Get(':harnessId/catalog') + async catalog( + @CurrentUser() user: AuthenticatedUserLike, + @Param('harnessId') harnessId: string, + ): Promise { + const context = readContextFromScope(scopeFromUser(user)); + let adapter; + try { + adapter = this.registry.get(harnessId); + } catch (error) { + if (error instanceof HarnessAdapterUnavailableError) { + // Typed failure — NEVER a fallback catalog for an unknown harness id. + throw new HttpException( + { code: error.code, message: error.message, harnessId }, + HttpStatus.NOT_FOUND, + ); + } + throw error; + } + return toSafeCatalog(await adapter.catalog(context)); + } +} diff --git a/apps/gateway/src/harness/harness.dto.ts b/apps/gateway/src/harness/harness.dto.ts new file mode 100644 index 00000000..deee6848 --- /dev/null +++ b/apps/gateway/src/harness/harness.dto.ts @@ -0,0 +1,116 @@ +import { randomUUID } from 'node:crypto'; +import { IsNotEmpty, IsString } from 'class-validator'; +import type { + HarnessActorContext, + HarnessAuthState, + HarnessCapability, + HarnessCatalog, + HarnessCatalogEntry, + HarnessDescriptor, + HarnessInputType, + HarnessModelAvailability, + HarnessSelection, +} from '@mosaicstack/types'; +import type { ActorTenantScope } from '../auth/session-scope.js'; + +/** + * Structured selection tuple accepted on `PUT /api/chat/preferences/selection`. + * + * The body is a STRUCTURED tuple (harness + provider + model), never a free-text + * model string. With `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })` + * any extra property — including smuggled server-authority fields such as + * `seatId`, `tenantId`, `userId`, `nativeSessionPath`, `executable`, `home`, `cwd` — + * is rejected with 400. There is deliberately no field through which a caller can + * name a scope; scope is derived on the server from the authenticated session. + */ +export class HarnessSelectionInputDto { + @IsString() + @IsNotEmpty() + harnessId!: string; + + @IsString() + @IsNotEmpty() + providerId!: string; + + @IsString() + @IsNotEmpty() + modelId!: string; +} + +/** Browser-safe harness summary — identity and capabilities only. */ +export interface HarnessSummaryDto { + readonly id: string; + readonly displayName: string; + readonly capabilities: readonly HarnessCapability[]; +} + +/** Browser-safe catalog entry — no executables, paths, secrets, or env. */ +export interface HarnessCatalogEntryDto { + readonly harnessId: string; + readonly providerId: string; + readonly modelId: string; + readonly displayName: string; + readonly reasoningCapability: boolean; + readonly inputTypes: readonly HarnessInputType[]; + readonly authState: HarnessAuthState; + readonly availability: HarnessModelAvailability; +} + +/** Browser-safe catalog envelope. */ +export interface HarnessCatalogDto { + readonly harnessId: string; + readonly version: string; + readonly fingerprint: string; + readonly models: readonly HarnessCatalogEntryDto[]; +} + +/** Response envelope for the caller's current selection (null when unset). */ +export interface SelectionResponseDto { + readonly selection: HarnessSelection | null; +} + +/** + * Derive a server-trusted {@link HarnessActorContext} for read operations from the + * session-derived {@link ActorTenantScope}. All authority originates on the server; + * nothing here is caller-supplied. A fresh correlation id is minted per call. + */ +export function readContextFromScope(scope: ActorTenantScope): HarnessActorContext { + return { + actorId: scope.userId, + tenantId: scope.tenantId, + seatId: scope.userId, + correlationId: randomUUID(), + }; +} + +/** Project a descriptor onto the browser-safe summary shape (whitelist by construction). */ +export function toHarnessSummary(descriptor: HarnessDescriptor): HarnessSummaryDto { + return { + id: descriptor.id, + displayName: descriptor.displayName, + capabilities: [...descriptor.capabilities], + }; +} + +/** Project a catalog onto the browser-safe shape (whitelist by construction). */ +export function toSafeCatalog(catalog: HarnessCatalog): HarnessCatalogDto { + return { + harnessId: catalog.harnessId, + version: catalog.version, + fingerprint: catalog.fingerprint, + models: catalog.models.map(toSafeCatalogEntry), + }; +} + +function toSafeCatalogEntry(entry: HarnessCatalogEntry): HarnessCatalogEntryDto { + return { + harnessId: entry.harnessId, + providerId: entry.providerId, + modelId: entry.modelId, + displayName: entry.displayName, + reasoningCapability: entry.reasoningCapability, + inputTypes: [...entry.inputTypes], + authState: entry.authState, + availability: entry.availability, + }; +} diff --git a/apps/gateway/src/harness/harness.module.ts b/apps/gateway/src/harness/harness.module.ts new file mode 100644 index 00000000..4ecfd595 --- /dev/null +++ b/apps/gateway/src/harness/harness.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { HarnessRegistry } from './harness.registry.js'; +import { HarnessService } from './harness.service.js'; +import { HARNESS_REGISTRY, HARNESS_SERVICE } from './harness.tokens.js'; +import { HarnessController } from './harness.controller.js'; +import { HarnessSelectionController } from './harness-selection.controller.js'; +import { HarnessSelectionService } from './harness-selection.service.js'; +import { HarnessSelectionRepository } from './harness-selection.repository.js'; + +/** + * Wires the harness-neutral registry/service (Task Two) together with the + * Slice-Zero catalog and selection HTTP surfaces (Task Three). + * + * The registry is provided empty here; real harness adapters are registered in a + * later task. Because the controllers/services resolve their collaborators through + * this real module graph, an unresolved provider fails loudly at `app.init()`. + */ +@Module({ + controllers: [HarnessController, HarnessSelectionController], + providers: [ + { provide: HARNESS_REGISTRY, useFactory: () => new HarnessRegistry() }, + { provide: HARNESS_SERVICE, useClass: HarnessService }, + HarnessSelectionRepository, + HarnessSelectionService, + ], + exports: [HARNESS_REGISTRY, HARNESS_SERVICE], +}) +export class HarnessModule {}