feat(gateway): expose Mos coordination boundary
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
This commit is contained in:
@@ -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 {}
|
||||||
|
|||||||
36
apps/gateway/src/coord/mos-coordination.controller.test.ts
Normal file
36
apps/gateway/src/coord/mos-coordination.controller.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
73
apps/gateway/src/coord/mos-coordination.controller.ts
Normal file
73
apps/gateway/src/coord/mos-coordination.controller.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user