feat(types): federation v1 DTOs (FED-M3-01) #506

Merged
jason.woltje merged 2 commits from feat/federation-m3-types into main 2026-04-24 02:54:41 +00:00
2 changed files with 130 additions and 1 deletions
Showing only changes of commit da387b32cb - Show all commits

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

View File

@@ -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>;