diff --git a/apps/gateway/src/federation/federation.module.ts b/apps/gateway/src/federation/federation.module.ts index a2e01ef..eda410a 100644 --- a/apps/gateway/src/federation/federation.module.ts +++ b/apps/gateway/src/federation/federation.module.ts @@ -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 } from './client/index.js'; import { FederationAuthGuard } from './server/index.js'; @Module({ - controllers: [EnrollmentController, FederationController], + controllers: [EnrollmentController, FederationController, CapabilitiesController], providers: [ AdminGuard, CaService, diff --git a/apps/gateway/src/federation/server/verbs/__tests__/capabilities.controller.spec.ts b/apps/gateway/src/federation/server/verbs/__tests__/capabilities.controller.spec.ts new file mode 100644 index 0000000..0a1157c --- /dev/null +++ b/apps/gateway/src/federation/server/verbs/__tests__/capabilities.controller.spec.ts @@ -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): 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); + }); +}); diff --git a/apps/gateway/src/federation/server/verbs/capabilities.controller.ts b/apps/gateway/src/federation/server/verbs/capabilities.controller.ts new file mode 100644 index 0000000..539b0d1 --- /dev/null +++ b/apps/gateway/src/federation/server/verbs/capabilities.controller.ts @@ -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[], + }; + } +} diff --git a/docs/scratchpads/FED-M3-07-capabilities.md b/docs/scratchpads/FED-M3-07-capabilities.md new file mode 100644 index 0000000..58f8dfa --- /dev/null +++ b/docs/scratchpads/FED-M3-07-capabilities.md @@ -0,0 +1,65 @@ +# FED-M3-07 — Capabilities Verb Scratchpad + +## Objective + +Implement `GET /api/federation/v1/capabilities` in `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`. + +## Scope + +- Add read-only capabilities controller under federation server verbs. +- Use `FederationAuthGuard` only; active grant is sufficient and no native RBAC/scope-service eval runs. +- Response shape: `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope. +- Register controller in `FederationModule`. +- Unit-test happy path, defaults, no-context guard seam, and invalid scope handling. + +## Constraints / assumptions + +- Issue: #462. +- Branch: `feat/federation-m3-verb-capabilities` from `origin/main` (`3eeed04e`). +- Depends on M3-03 auth guard; guard attaches `request.federationContext.scope` after active-grant validation. +- ASSUMPTION: `supported_verbs` is the M3 verb set from `@mosaicstack/types` (`list`, `get`, `capabilities`). +- ASSUMPTION: `filters`/`rate_limit` are intentionally omitted for FED-M3-07 because the card’s response shape lists only the four required fields. +- Budget: no explicit hard cap from orchestrator; working cap ~4K-8K tokens for card implementation + tests + PR cycle. + +## Plan + +1. Write controller unit tests first. +2. Implement controller and module registration. +3. Run scoped tests + typecheck/lint/format. +4. Run Codex code/security review and remediate. +5. Commit, queue guard, push, PR via wrapper. + +## Progress + +- 2026-06-24: Intake complete; fresh worktree created from origin/main. +- 2026-06-24: Added `CapabilitiesController`, registered it in `FederationModule`, and added 5 unit tests. +- 2026-06-24: Code/security reviews passed with no findings. + +## Tests run + +- `pnpm --filter @mosaicstack/gateway test -- capabilities.controller.spec.ts` — PASS (5 tests). +- `pnpm --filter @mosaicstack/gateway typecheck` — PASS. +- `pnpm --filter @mosaicstack/gateway lint` — PASS. +- `pnpm format:check` — PASS. +- `pnpm typecheck` — PASS (41/41 turbo tasks). +- `pnpm lint` — PASS (23/23 turbo tasks). +- `pnpm test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup hit PostgreSQL connection/schema state for the `messages` table. Changed capabilities tests passed; failure is outside FED-M3-07 surface. No `fleet-personas.spec` flake encountered. + +## Review evidence + +- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS/approve, no findings. +- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS, risk level none, no findings. + +## Risks / blockers + +- Full repo `pnpm test` may hit known `fleet-personas.spec` flake per orchestrator; ignore that specific flake if encountered. +- Previous card saw local DB schema issue in `cross-user-isolation.test.ts`; scoped capabilities tests should be authoritative for this surface. + +## Acceptance evidence mapping + +| Acceptance criterion | Evidence | +| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| GET `/api/federation/v1/capabilities` exists | Route metadata test in `capabilities.controller.spec.ts`; scoped test PASS | +| Uses active-grant auth guard and no RBAC eval | Guard metadata test confirms only `FederationAuthGuard`; controller has no service injections/RBAC calls; scoped test PASS | +| Response enumerates resources/excluded/max rows/supported verbs from scope | Happy-path/default scope tests + response schema parse; scoped test PASS | +| Read-only/no persistence side effects | Controller only parses request `federationContext.scope` and returns a DTO; no DB/service dependency; code review PASS |