From da387b32cbceb3d98d21e1878d629e7a2e1649a5 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 23 Apr 2026 20:34:47 -0500 Subject: [PATCH] =?UTF-8?q?fix(types):=20capabilities=20response=20?= =?UTF-8?q?=E2=80=94=20add=20filters=20+=20rate=5Flimit=20stub=20+=20tight?= =?UTF-8?q?en=20supported=5Fverbs=20(review=20remediation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HIGH-1: add `filters` field (mirrors FederationScope PRD §8.1) and `rate_limit` stub (PRD §9.1) to FederationCapabilitiesResponseSchema; M4 populates remaining/reset_at - MED-3: tighten supported_verbs from z.array(z.string()) to z.array(z.enum(FEDERATION_VERBS)) closed enum - Add 10 new test cases covering filters shape, rate_limit full/minimal/absent/invalid, and invalid verb rejection Co-Authored-By: Claude Sonnet 4.6 --- .../federation/__tests__/federation.spec.ts | 99 +++++++++++++++++++ packages/types/src/federation/response.ts | 32 +++++- 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/packages/types/src/federation/__tests__/federation.spec.ts b/packages/types/src/federation/__tests__/federation.spec.ts index 2fd8bdb..c43240e 100644 --- a/packages/types/src/federation/__tests__/federation.spec.ts +++ b/packages/types/src/federation/__tests__/federation.spec.ts @@ -178,6 +178,105 @@ describe('FederationCapabilitiesResponseSchema', () => { } }); + it('accepts a response with filters field', () => { + const result = FederationCapabilitiesResponseSchema.safeParse({ + resources: ['tasks', 'notes'], + excluded_resources: [], + max_rows_per_query: 100, + supported_verbs: ['list'], + filters: { + tasks: { include_teams: ['team-a'], include_personal: true }, + notes: { include_personal: false }, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.filters?.['tasks']?.include_teams).toEqual(['team-a']); + } + }); + + it('accepts a response with partial filters (only include_teams)', () => { + const result = FederationCapabilitiesResponseSchema.safeParse({ + resources: ['tasks'], + excluded_resources: [], + max_rows_per_query: 50, + supported_verbs: ['list'], + filters: { tasks: { include_teams: ['eng'] } }, + }); + expect(result.success).toBe(true); + }); + + it('accepts a response with rate_limit (M4 full shape)', () => { + const result = FederationCapabilitiesResponseSchema.safeParse({ + resources: ['tasks'], + excluded_resources: [], + max_rows_per_query: 100, + supported_verbs: ['list'], + rate_limit: { limit_per_minute: 60, remaining: 55, reset_at: '2026-04-23T12:00:00Z' }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.rate_limit?.limit_per_minute).toBe(60); + expect(result.data.rate_limit?.remaining).toBe(55); + } + }); + + it('accepts a response with rate_limit (M3 minimal — limit_per_minute only)', () => { + const result = FederationCapabilitiesResponseSchema.safeParse({ + resources: ['tasks'], + excluded_resources: [], + max_rows_per_query: 100, + supported_verbs: ['list'], + rate_limit: { limit_per_minute: 120 }, + }); + expect(result.success).toBe(true); + }); + + it('accepts a response without rate_limit (field is optional)', () => { + const result = FederationCapabilitiesResponseSchema.safeParse({ + resources: ['tasks'], + excluded_resources: [], + max_rows_per_query: 100, + supported_verbs: ['list'], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.rate_limit).toBeUndefined(); + } + }); + + it('rejects rate_limit with non-positive limit_per_minute', () => { + const result = FederationCapabilitiesResponseSchema.safeParse({ + resources: ['tasks'], + excluded_resources: [], + max_rows_per_query: 100, + supported_verbs: ['list'], + rate_limit: { limit_per_minute: 0 }, + }); + expect(result.success).toBe(false); + }); + + it('rejects rate_limit with invalid reset_at datetime', () => { + const result = FederationCapabilitiesResponseSchema.safeParse({ + resources: ['tasks'], + excluded_resources: [], + max_rows_per_query: 100, + supported_verbs: ['list'], + rate_limit: { limit_per_minute: 60, reset_at: 'not-a-datetime' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects supported_verbs with an invalid verb (MED-3 enum guard)', () => { + const result = FederationCapabilitiesResponseSchema.safeParse({ + resources: ['tasks'], + excluded_resources: [], + max_rows_per_query: 100, + supported_verbs: ['invalid_verb'], + }); + expect(result.success).toBe(false); + }); + it('rejects empty resources array', () => { const result = FederationCapabilitiesResponseSchema.safeParse({ resources: [], diff --git a/packages/types/src/federation/response.ts b/packages/types/src/federation/response.ts index c661716..ab51dd0 100644 --- a/packages/types/src/federation/response.ts +++ b/packages/types/src/federation/response.ts @@ -22,6 +22,8 @@ import { z } from 'zod'; +import { FEDERATION_VERBS } from './verbs.js'; + // --------------------------------------------------------------------------- // Shared envelope flags // --------------------------------------------------------------------------- @@ -118,6 +120,20 @@ export const FederationCapabilitiesResponseSchema = z.object({ */ excluded_resources: z.array(z.string()), + /** + * Per-resource filters (mirrors FederationScope.filters from PRD §8.1). + * Keys are resource names; values control team/personal visibility. + */ + filters: z + .record( + z.string(), + z.object({ + include_teams: z.array(z.string()).optional(), + include_personal: z.boolean().optional(), + }), + ) + .optional(), + /** * Hard cap on rows returned per query for this grant. */ @@ -125,8 +141,22 @@ export const FederationCapabilitiesResponseSchema = z.object({ /** * Verbs currently available. Will expand in M4+ (search). + * Closed enum — only values from FEDERATION_VERBS are accepted. */ - supported_verbs: z.array(z.string()).nonempty(), + supported_verbs: z.array(z.enum(FEDERATION_VERBS)).nonempty(), + + /** + * Rate-limit state for this grant (PRD §9.1). + * M4 populates `remaining` and `reset_at`; M3 servers may return only + * `limit_per_minute` or omit the field entirely. + */ + rate_limit: z + .object({ + limit_per_minute: z.number().int().positive(), + remaining: z.number().int().nonnegative().optional(), + reset_at: z.string().datetime().optional(), + }) + .optional(), }); export type FederationCapabilitiesResponse = z.infer;