fix(types): capabilities response — add filters + rate_limit stub + tighten supported_verbs (review remediation)
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful

- 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 <noreply@anthropic.com>
This commit is contained in:
Jarvis
2026-04-23 20:34:47 -05:00
parent 8d8e49f983
commit da387b32cb
2 changed files with 130 additions and 1 deletions

View File

@@ -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', () => { it('rejects empty resources array', () => {
const result = FederationCapabilitiesResponseSchema.safeParse({ const result = FederationCapabilitiesResponseSchema.safeParse({
resources: [], resources: [],

View File

@@ -22,6 +22,8 @@
import { z } from 'zod'; import { z } from 'zod';
import { FEDERATION_VERBS } from './verbs.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared envelope flags // Shared envelope flags
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -118,6 +120,20 @@ export const FederationCapabilitiesResponseSchema = z.object({
*/ */
excluded_resources: z.array(z.string()), 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. * 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). * 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<typeof FederationCapabilitiesResponseSchema>; export type FederationCapabilitiesResponse = z.infer<typeof FederationCapabilitiesResponseSchema>;