feat(#462): add federation capabilities verb (#674)
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 #674.
This commit is contained in:
2026-06-24 21:39:56 +00:00
parent 838c44086c
commit 6b94d014a8
4 changed files with 193 additions and 1 deletions

View File

@@ -4,12 +4,13 @@ import { CaService } from './ca.service.js';
import { EnrollmentController } from './enrollment.controller.js';
import { EnrollmentService } from './enrollment.service.js';
import { FederationController } from './federation.controller.js';
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
import { GrantsService } from './grants.service.js';
import { FederationClientService, QuerySourceService } from './client/index.js';
import { FederationAuthGuard } from './server/index.js';
@Module({
controllers: [EnrollmentController, FederationController],
controllers: [EnrollmentController, FederationController, CapabilitiesController],
providers: [
AdminGuard,
CaService,

View File

@@ -0,0 +1,88 @@
import 'reflect-metadata';
import { RequestMethod } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import type { FastifyRequest } from 'fastify';
import { FederationCapabilitiesResponseSchema, FEDERATION_VERBS } from '@mosaicstack/types';
import { FederationScopeError } from '../../../scope-schema.js';
import { FederationAuthGuard } from '../../federation-auth.guard.js';
import { CapabilitiesController } from '../capabilities.controller.js';
const VALID_SCOPE = {
resources: ['tasks', 'notes'],
excluded_resources: ['credentials'],
max_rows_per_query: 250,
} as const;
const DEFAULTED_SCOPE = {
resources: ['memory'],
max_rows_per_query: 10,
} as const;
function makeRequest(scope: Record<string, unknown>): FastifyRequest {
return {
federationContext: {
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
scope,
},
} as FastifyRequest;
}
describe('CapabilitiesController', () => {
it('declares GET /api/federation/v1/capabilities', () => {
expect(Reflect.getMetadata('path', CapabilitiesController)).toBe(
'api/federation/v1/capabilities',
);
expect(Reflect.getMetadata('path', CapabilitiesController.prototype.getCapabilities)).toBe('/');
expect(Reflect.getMetadata('method', CapabilitiesController.prototype.getCapabilities)).toBe(
RequestMethod.GET,
);
});
it('is protected only by FederationAuthGuard', () => {
const guards = Reflect.getMetadata('__guards__', CapabilitiesController) as unknown[];
expect(guards).toEqual([FederationAuthGuard]);
});
it('returns resources, excluded resources, max rows, and M3 supported verbs from the active grant scope', () => {
const controller = new CapabilitiesController();
const response = controller.getCapabilities(makeRequest(VALID_SCOPE));
expect(response).toEqual({
resources: ['tasks', 'notes'],
excluded_resources: ['credentials'],
max_rows_per_query: 250,
supported_verbs: [...FEDERATION_VERBS],
});
expect(FederationCapabilitiesResponseSchema.safeParse(response).success).toBe(true);
});
it('applies scope defaults without RBAC or resource filtering', () => {
const controller = new CapabilitiesController();
const response = controller.getCapabilities(makeRequest(DEFAULTED_SCOPE));
expect(response).toEqual({
resources: ['memory'],
excluded_resources: [],
max_rows_per_query: 10,
supported_verbs: ['list', 'get', 'capabilities'],
});
});
it('rejects invalid scope state instead of returning an invalid capabilities contract', () => {
const controller = new CapabilitiesController();
expect(() =>
controller.getCapabilities(
makeRequest({
resources: [],
max_rows_per_query: 0,
}),
),
).toThrow(FederationScopeError);
});
});

View File

@@ -0,0 +1,38 @@
/**
* Federation capabilities verb (FED-M3-07).
*
* Returns the read-only capability envelope for the active grant attached by
* FederationAuthGuard. This endpoint intentionally does not invoke native RBAC
* or ScopeService: an active grant is enough to ask what the grant allows.
*/
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import {
FEDERATION_VERBS,
type FederationCapabilitiesResponse,
type FederationVerb,
} from '@mosaicstack/types';
import { parseFederationScope } from '../../scope-schema.js';
import { FederationAuthGuard } from '../federation-auth.guard.js';
import '../federation-context.js';
@Controller('api/federation/v1/capabilities')
@UseGuards(FederationAuthGuard)
export class CapabilitiesController {
@Get()
getCapabilities(@Req() request: FastifyRequest): FederationCapabilitiesResponse {
if (!request.federationContext) {
throw new Error('Federation context missing after auth guard');
}
const scope = parseFederationScope(request.federationContext.scope);
return {
resources: [...scope.resources],
excluded_resources: [...scope.excluded_resources],
max_rows_per_query: scope.max_rows_per_query,
supported_verbs: [...FEDERATION_VERBS] satisfies FederationVerb[],
};
}
}