/** * Federation list verb (FED-M3-05). * * POST /api/federation/v1/list/:resource * * Pipeline: FederationAuthGuard attaches the active grant context, then * FederationScopeService enforces grant scope + native RBAC intersection, then * the read-only query layer returns capped rows tagged with `_source`. Read * audit-log writes are deferred to M4; this controller does not persist request * or response bodies. */ import { Body, Controller, HttpException, Inject, Param, Post, Req, UseGuards, } from '@nestjs/common'; import type { FastifyRequest } from 'fastify'; import { FederationInvalidRequestError, FederationScopeViolationError, SOURCE_LOCAL, tagWithSource, type FederationListResponse, type SourceTag, } from '@mosaicstack/types'; import { FederationAuthGuard } from '../federation-auth.guard.js'; import '../federation-context.js'; import { FederationScopeService } from '../scope.service.js'; import { FederationListQueryService } from './list-query.service.js'; interface FederationListRequestBody { readonly limit?: unknown; readonly cursor?: unknown; } type FederatedRow = Record & SourceTag; function parseLimit(body: FederationListRequestBody | undefined): number | undefined { if (body?.limit === undefined) { return undefined; } const parsed = typeof body.limit === 'number' ? body.limit : typeof body.limit === 'string' && body.limit.trim().length > 0 ? Number(body.limit) : Number.NaN; if (!Number.isSafeInteger(parsed) || parsed < 1) { throw new HttpException( new FederationInvalidRequestError( 'Federation list limit must be a positive integer', ).toEnvelope(), 400, ); } return parsed; } function parseCursor(body: FederationListRequestBody | undefined): string | undefined { if (body?.cursor === undefined) { return undefined; } if (typeof body.cursor === 'string') { return body.cursor; } throw new HttpException( new FederationInvalidRequestError('Federation list cursor must be a string').toEnvelope(), 400, ); } @Controller('api/federation/v1/list') @UseGuards(FederationAuthGuard) export class ListController { constructor( @Inject(FederationScopeService) private readonly scope: FederationScopeService, @Inject(FederationListQueryService) private readonly query: FederationListQueryService, ) {} @Post(':resource') async list( @Param('resource') resource: string, @Req() request: FastifyRequest, @Body() body?: FederationListRequestBody, ): Promise> { if (!request.federationContext) { throw new Error('Federation context missing after auth guard'); } const requestedLimit = parseLimit(body); const cursor = parseCursor(body); const scopeResult = await this.scope.evaluateAccess({ context: request.federationContext, resource, requestedLimit, nativeRbac: this.query, }); if (!scopeResult.allowed) { const ErrorClass = scopeResult.deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError; throw new HttpException( new ErrorClass(scopeResult.deny.message, scopeResult.deny).toEnvelope(), scopeResult.deny.statusCode, ); } let result: Awaited>; try { result = await this.query.list({ filter: scopeResult.filter, cursor }); } catch (error: unknown) { if (error instanceof Error && error.message === 'Invalid federation list cursor') { throw new HttpException( new FederationInvalidRequestError('Federation list cursor is invalid').toEnvelope(), 400, ); } throw error; } const response: FederationListResponse = { items: tagWithSource(result.items, SOURCE_LOCAL), }; if (result.nextCursor !== undefined) { response.nextCursor = result.nextCursor; } if (result.truncated) { response._truncated = true; } return response; } }