148 lines
4.1 KiB
TypeScript
148 lines
4.1 KiB
TypeScript
/**
|
|
* 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,
|
|
FederationUnauthorizedError,
|
|
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<string, unknown> & 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<FederationListResponse<FederatedRow>> {
|
|
if (!request.federationContext) {
|
|
throw new HttpException(
|
|
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
|
|
401,
|
|
);
|
|
}
|
|
|
|
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<ReturnType<FederationListQueryService['list']>>;
|
|
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<FederatedRow> = {
|
|
items: tagWithSource(result.items, SOURCE_LOCAL),
|
|
};
|
|
if (result.nextCursor !== undefined) {
|
|
response.nextCursor = result.nextCursor;
|
|
}
|
|
if (result.truncated) {
|
|
response._truncated = true;
|
|
}
|
|
return response;
|
|
}
|
|
}
|