feat(gateway): expose Mos coordination boundary (#737)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending

This commit was merged in pull request #737.
This commit is contained in:
2026-07-13 13:14:44 +00:00
parent cca6aaf947
commit e2376190e5
3 changed files with 111 additions and 1 deletions

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { InMemoryMosCoordinationPort } from '@mosaicstack/coord'; import { InMemoryMosCoordinationPort } from '@mosaicstack/coord';
import { CoordService } from './coord.service.js'; import { CoordService } from './coord.service.js';
import { CoordController } from './coord.controller.js'; import { CoordController } from './coord.controller.js';
import { MosCoordinationController } from './mos-coordination.controller.js';
import { import {
MOS_COORDINATION_CONFIG, MOS_COORDINATION_CONFIG,
MOS_COORDINATION_PORT, MOS_COORDINATION_PORT,
@@ -24,7 +25,7 @@ import {
}, },
MosCoordinationService, MosCoordinationService,
], ],
controllers: [CoordController], controllers: [CoordController, MosCoordinationController],
exports: [CoordService, MosCoordinationService], exports: [CoordService, MosCoordinationService],
}) })
export class CoordModule {} export class CoordModule {}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest';
import { MosCoordinationController } from './mos-coordination.controller.js';
const user = { id: 'operator-1', tenantId: 'tenant-a' };
describe('MosCoordinationController', () => {
it('derives actor and tenant from the authenticated user rather than handoff input', async () => {
const coordination = {
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
observe: vi.fn(),
result: vi.fn(),
};
const controller = new MosCoordinationController(coordination as never);
await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1');
expect(coordination.handoff).toHaveBeenCalledWith(
{ idempotencyKey: 'request-1', summary: 'Implement' },
expect.objectContaining({
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
channelId: 'cli',
correlationId: 'corr-1',
}),
);
});
it('requires a correlation header before invoking the coordination service', async () => {
const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() };
const controller = new MosCoordinationController(coordination as never);
await expect(
controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined),
).rejects.toThrow('X-Correlation-Id is required');
expect(coordination.handoff).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,73 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Headers,
Inject,
Param,
Post,
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 type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
import type {
MosCoordinationObservationDto,
MosCoordinationResponseDto,
MosCoordinationResultDto,
CreateMosHandoffDto,
} from './mos-coordination.dto.js';
import { MosCoordinationService } from './mos-coordination.service.js';
/** Authenticated interaction-plane boundary for the handoff/observe/result-only Mos contract. */
@Controller('api/coord/mos')
@UseGuards(AuthGuard)
export class MosCoordinationController {
constructor(
@Inject(MosCoordinationService) private readonly coordination: MosCoordinationService,
) {}
@Post('handoff')
async handoff(
@Body() request: CreateMosHandoffDto,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<MosCoordinationResponseDto> {
return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) };
}
@Get(':handoffId/observe')
async observe(
@Param('handoffId') handoffId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<MosCoordinationObservationDto> {
return {
observation: await this.coordination.observe(handoffId, this.context(user, correlationId)),
};
}
@Get(':handoffId/result')
async result(
@Param('handoffId') handoffId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<MosCoordinationResultDto> {
return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) };
}
private context(
user: AuthenticatedUserLike,
correlationId?: string,
): RuntimeProviderRequestContext {
const requestCorrelationId = correlationId?.trim();
if (!requestCorrelationId) throw new ForbiddenException('X-Correlation-Id is required');
return {
actorScope: scopeFromUser(user),
channelId: 'cli',
correlationId: requestCorrelationId,
};
}
}