/** * Federation get verb (FED-M3-06). * * POST /api/federation/v1/get/:resource/:id * * Pipeline: FederationAuthGuard attaches the active grant context, then * FederationScopeService enforces grant scope + native RBAC intersection, then * the read-only query layer fetches one local row and tags it with `_source`. * Read audit-log writes are deferred to M4; this controller does not persist * request or response bodies. */ import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common'; import type { FastifyRequest } from 'fastify'; import { FederationInvalidRequestError, FederationNotFoundError, FederationScopeViolationError, FederationUnauthorizedError, SOURCE_LOCAL, type FederationGetResponse, type SourceTag, } from '@mosaicstack/types'; import { FederationAuthGuard } from '../federation-auth.guard.js'; import '../federation-context.js'; import { FederationScopeService } from '../scope.service.js'; import { FederationGetQueryService } from './get-query.service.js'; type FederatedRow = Record & SourceTag; function scopeDenyToHttpException(deny: { readonly statusCode: 400 | 403; readonly message: string; }): HttpException { const ErrorClass = deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError; return new HttpException(new ErrorClass(deny.message, deny).toEnvelope(), deny.statusCode); } @Controller('api/federation/v1/get') @UseGuards(FederationAuthGuard) export class GetController { constructor( @Inject(FederationScopeService) private readonly scope: FederationScopeService, @Inject(FederationGetQueryService) private readonly query: FederationGetQueryService, ) {} @Post(':resource/:id') async get( @Param('resource') resource: string, @Param('id') id: string, @Req() request: FastifyRequest, ): Promise> { if (!request.federationContext) { throw new HttpException( new FederationUnauthorizedError('Federation context missing').toEnvelope(), 401, ); } if (id.trim().length === 0) { throw new HttpException( new FederationInvalidRequestError('Federation get id must not be empty').toEnvelope(), 400, ); } const scopeResult = await this.scope.evaluateAccess({ context: request.federationContext, resource, requestedLimit: 1, nativeRbac: this.query, }); if (!scopeResult.allowed) { throw scopeDenyToHttpException(scopeResult.deny); } const result = await this.query.get({ filter: scopeResult.filter, id }); if (result.status === 'not_found') { throw new HttpException( new FederationNotFoundError('Requested federation resource was not found').toEnvelope(), 404, ); } if (result.status === 'denied') { throw new HttpException( new FederationScopeViolationError(result.reason, { resource, id, grantId: request.federationContext.grantId, peerId: request.federationContext.peerId, subjectUserId: request.federationContext.subjectUserId, }).toEnvelope(), 403, ); } return { item: { ...result.item, _source: SOURCE_LOCAL } }; } }