fix(types): capabilities response — add filters + rate_limit stub + tighten supported_verbs (review remediation)
- 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:
@@ -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: [],
|
||||
|
||||
@@ -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<typeof FederationCapabilitiesResponseSchema>;
|
||||
|
||||
Reference in New Issue
Block a user