Compare commits
2 Commits
feat/feder
...
da387b32cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da387b32cb | ||
|
|
8d8e49f983 |
@@ -26,7 +26,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1"
|
||||
"class-validator": "^0.15.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
|
||||
|
||||
435
packages/types/src/federation/__tests__/federation.spec.ts
Normal file
435
packages/types/src/federation/__tests__/federation.spec.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Unit tests for federation wire-format DTOs.
|
||||
*
|
||||
* Coverage:
|
||||
* - FederationRequestSchema (valid + invalid)
|
||||
* - FederationListResponseSchema factory
|
||||
* - FederationGetResponseSchema factory
|
||||
* - FederationCapabilitiesResponseSchema
|
||||
* - FederationErrorEnvelopeSchema + error code exhaustiveness
|
||||
* - FederationError exception hierarchy
|
||||
* - tagWithSource helper round-trip
|
||||
* - SourceTagSchema
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
FEDERATION_ERROR_CODES,
|
||||
FEDERATION_VERBS,
|
||||
FederationCapabilitiesResponseSchema,
|
||||
FederationError,
|
||||
FederationErrorEnvelopeSchema,
|
||||
FederationForbiddenError,
|
||||
FederationInternalError,
|
||||
FederationInvalidRequestError,
|
||||
FederationNotFoundError,
|
||||
FederationRateLimitedError,
|
||||
FederationRequestSchema,
|
||||
FederationScopeViolationError,
|
||||
FederationUnauthorizedError,
|
||||
FederationGetResponseSchema,
|
||||
FederationListResponseSchema,
|
||||
SOURCE_LOCAL,
|
||||
SourceTagSchema,
|
||||
parseFederationErrorEnvelope,
|
||||
tagWithSource,
|
||||
} from '../index.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verbs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FEDERATION_VERBS', () => {
|
||||
it('contains exactly list, get, capabilities', () => {
|
||||
expect(FEDERATION_VERBS).toEqual(['list', 'get', 'capabilities']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationRequestSchema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FederationRequestSchema', () => {
|
||||
it('accepts a minimal valid list request', () => {
|
||||
const result = FederationRequestSchema.safeParse({ verb: 'list', resource: 'tasks' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a get request with cursor and params', () => {
|
||||
const result = FederationRequestSchema.safeParse({
|
||||
verb: 'get',
|
||||
resource: 'notes',
|
||||
cursor: 'abc123',
|
||||
params: { filter: 'mine' },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.cursor).toBe('abc123');
|
||||
expect(result.data.params?.['filter']).toBe('mine');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a capabilities request', () => {
|
||||
const result = FederationRequestSchema.safeParse({ verb: 'capabilities', resource: 'tasks' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unknown verb', () => {
|
||||
const result = FederationRequestSchema.safeParse({ verb: 'search', resource: 'tasks' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an empty resource string', () => {
|
||||
const result = FederationRequestSchema.safeParse({ verb: 'list', resource: '' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a missing verb', () => {
|
||||
const result = FederationRequestSchema.safeParse({ resource: 'tasks' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationListResponseSchema factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FederationListResponseSchema', () => {
|
||||
const ItemSchema = z.object({ id: z.string(), name: z.string() });
|
||||
const ListSchema = FederationListResponseSchema(ItemSchema);
|
||||
|
||||
it('accepts a valid list envelope', () => {
|
||||
const result = ListSchema.safeParse({
|
||||
items: [{ id: '1', name: 'Task A' }],
|
||||
nextCursor: 'page2',
|
||||
_partial: false,
|
||||
_truncated: false,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.items).toHaveLength(1);
|
||||
expect(result.data.nextCursor).toBe('page2');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a minimal envelope with empty items', () => {
|
||||
const result = ListSchema.safeParse({ items: [] });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when items is missing', () => {
|
||||
const result = ListSchema.safeParse({ nextCursor: 'x' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when an item fails validation', () => {
|
||||
const result = ListSchema.safeParse({ items: [{ id: 1, name: 'bad' }] });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationGetResponseSchema factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FederationGetResponseSchema', () => {
|
||||
const ItemSchema = z.object({ id: z.string() });
|
||||
const GetSchema = FederationGetResponseSchema(ItemSchema);
|
||||
|
||||
it('accepts a found item', () => {
|
||||
const result = GetSchema.safeParse({ item: { id: 'abc' } });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.item).toEqual({ id: 'abc' });
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts null item (not found)', () => {
|
||||
const result = GetSchema.safeParse({ item: null });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.item).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects when item is missing', () => {
|
||||
const result = GetSchema.safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationCapabilitiesResponseSchema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FederationCapabilitiesResponseSchema', () => {
|
||||
it('accepts a valid capabilities response', () => {
|
||||
const result = FederationCapabilitiesResponseSchema.safeParse({
|
||||
resources: ['tasks', 'notes'],
|
||||
excluded_resources: ['credentials'],
|
||||
max_rows_per_query: 500,
|
||||
supported_verbs: ['list', 'get', 'capabilities'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.max_rows_per_query).toBe(500);
|
||||
}
|
||||
});
|
||||
|
||||
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: [],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 100,
|
||||
supported_verbs: ['list'],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-integer max_rows_per_query', () => {
|
||||
const result = FederationCapabilitiesResponseSchema.safeParse({
|
||||
resources: ['tasks'],
|
||||
excluded_resources: [],
|
||||
max_rows_per_query: 1.5,
|
||||
supported_verbs: ['list'],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationErrorEnvelopeSchema + error code exhaustiveness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FederationErrorEnvelopeSchema', () => {
|
||||
it('accepts each valid error code', () => {
|
||||
for (const code of FEDERATION_ERROR_CODES) {
|
||||
const result = FederationErrorEnvelopeSchema.safeParse({
|
||||
error: { code, message: 'test' },
|
||||
});
|
||||
expect(result.success, `code ${code} should be valid`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an unknown error code', () => {
|
||||
const result = FederationErrorEnvelopeSchema.safeParse({
|
||||
error: { code: 'unknown_code', message: 'test' },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts optional details field', () => {
|
||||
const result = FederationErrorEnvelopeSchema.safeParse({
|
||||
error: { code: 'forbidden', message: 'nope', details: { grantId: 'xyz' } },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when message is missing', () => {
|
||||
const result = FederationErrorEnvelopeSchema.safeParse({ error: { code: 'not_found' } });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFederationErrorEnvelope', () => {
|
||||
it('returns a typed envelope for valid input', () => {
|
||||
const env = parseFederationErrorEnvelope({ error: { code: 'not_found', message: 'gone' } });
|
||||
expect(env.error.code).toBe('not_found');
|
||||
});
|
||||
|
||||
it('throws for invalid input', () => {
|
||||
expect(() => parseFederationErrorEnvelope({ bad: 'shape' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationError exception hierarchy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('FederationError hierarchy', () => {
|
||||
const cases: Array<[string, FederationError]> = [
|
||||
['unauthorized', new FederationUnauthorizedError()],
|
||||
['forbidden', new FederationForbiddenError()],
|
||||
['not_found', new FederationNotFoundError()],
|
||||
['rate_limited', new FederationRateLimitedError()],
|
||||
['scope_violation', new FederationScopeViolationError()],
|
||||
['invalid_request', new FederationInvalidRequestError()],
|
||||
['internal_error', new FederationInternalError()],
|
||||
];
|
||||
|
||||
it.each(cases)('code %s is an instance of FederationError', (_code, err) => {
|
||||
expect(err).toBeInstanceOf(FederationError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it.each(cases)('code %s has correct code property', (code, err) => {
|
||||
expect(err.code).toBe(code);
|
||||
});
|
||||
|
||||
it('toEnvelope serialises to wire format', () => {
|
||||
const err = new FederationForbiddenError('Access denied', { grantId: 'g1' });
|
||||
const env = err.toEnvelope();
|
||||
expect(env.error.code).toBe('forbidden');
|
||||
expect(env.error.message).toBe('Access denied');
|
||||
expect(env.error.details).toEqual({ grantId: 'g1' });
|
||||
});
|
||||
|
||||
it('toEnvelope omits details when not provided', () => {
|
||||
const err = new FederationNotFoundError();
|
||||
const env = err.toEnvelope();
|
||||
expect(Object.prototype.hasOwnProperty.call(env.error, 'details')).toBe(false);
|
||||
});
|
||||
|
||||
it('error codes tuple covers all subclasses (exhaustiveness check)', () => {
|
||||
// If a new subclass is added without a code, this test fails at compile time.
|
||||
const allCodes = new Set(FEDERATION_ERROR_CODES);
|
||||
for (const [code] of cases) {
|
||||
expect(allCodes.has(code as (typeof FEDERATION_ERROR_CODES)[number])).toBe(true);
|
||||
}
|
||||
// All codes are covered by at least one case
|
||||
expect(cases).toHaveLength(FEDERATION_ERROR_CODES.length);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source tag + tagWithSource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('SourceTagSchema', () => {
|
||||
it('accepts a non-empty _source string', () => {
|
||||
expect(SourceTagSchema.safeParse({ _source: 'local' }).success).toBe(true);
|
||||
expect(SourceTagSchema.safeParse({ _source: 'mosaic.uscllc.com' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty _source string', () => {
|
||||
expect(SourceTagSchema.safeParse({ _source: '' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tagWithSource', () => {
|
||||
it('stamps each item with the given source', () => {
|
||||
const items = [{ id: '1' }, { id: '2' }];
|
||||
const tagged = tagWithSource(items, SOURCE_LOCAL);
|
||||
expect(tagged).toEqual([
|
||||
{ id: '1', _source: 'local' },
|
||||
{ id: '2', _source: 'local' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves original item fields', () => {
|
||||
const items = [{ id: 'x', name: 'Task', done: false }];
|
||||
const tagged = tagWithSource(items, 'mosaic.uscllc.com');
|
||||
expect(tagged[0]).toMatchObject({ id: 'x', name: 'Task', done: false });
|
||||
expect(tagged[0]?._source).toBe('mosaic.uscllc.com');
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(tagWithSource([], 'local')).toEqual([]);
|
||||
});
|
||||
|
||||
it('round-trip: tagWithSource output passes SourceTagSchema', () => {
|
||||
const tagged = tagWithSource([{ id: '1' }], 'local');
|
||||
expect(SourceTagSchema.safeParse(tagged[0]).success).toBe(true);
|
||||
});
|
||||
});
|
||||
164
packages/types/src/federation/error.ts
Normal file
164
packages/types/src/federation/error.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Federation wire-format error envelope and exception hierarchy.
|
||||
*
|
||||
* Source of truth: docs/federation/PRD.md §6, §8.
|
||||
*
|
||||
* DESIGN: Typed error classes rather than discriminated union values
|
||||
* ──────────────────────────────────────────────────────────────────
|
||||
* We expose:
|
||||
* 1. `FEDERATION_ERROR_CODES` — closed string-enum tuple (exhaustiveness-checkable).
|
||||
* 2. `FederationErrorCode` — union type inferred from the tuple.
|
||||
* 3. `FederationErrorEnvelopeSchema` — Zod schema for the wire format.
|
||||
* 4. `FederationError` — base Error subclass with a typed `code` property.
|
||||
* One concrete subclass per code (e.g. `FederationUnauthorizedError`),
|
||||
* which enables `instanceof` dispatch in handlers without a switch.
|
||||
*
|
||||
* Rationale: subclasses give gateway handlers and the client a clean dispatch
|
||||
* point (catch + instanceof) without re-parsing or switch tables. All classes
|
||||
* carry `code` so a generic logger can act on any FederationError uniformly.
|
||||
*
|
||||
* Pure — no NestJS, no DB, no Node-only APIs. Safe for browser/edge.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error code enum (closed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FEDERATION_ERROR_CODES = [
|
||||
'unauthorized',
|
||||
'forbidden',
|
||||
'not_found',
|
||||
'rate_limited',
|
||||
'scope_violation',
|
||||
'invalid_request',
|
||||
'internal_error',
|
||||
] as const;
|
||||
|
||||
export type FederationErrorCode = (typeof FEDERATION_ERROR_CODES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire-format schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FederationErrorEnvelopeSchema = z.object({
|
||||
error: z.object({
|
||||
code: z.enum(FEDERATION_ERROR_CODES),
|
||||
message: z.string(),
|
||||
details: z.unknown().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type FederationErrorEnvelope = z.infer<typeof FederationErrorEnvelopeSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exception class hierarchy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Base class for all federation errors.
|
||||
* Carries a typed `code` so handlers can act uniformly on any FederationError.
|
||||
*/
|
||||
export class FederationError extends Error {
|
||||
readonly code: FederationErrorCode;
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(code: FederationErrorCode, message: string, details?: unknown) {
|
||||
super(message);
|
||||
this.name = 'FederationError';
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
/** Serialise to the wire-format error envelope. */
|
||||
toEnvelope(): FederationErrorEnvelope {
|
||||
return {
|
||||
error: {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
...(this.details !== undefined ? { details: this.details } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Client cert is missing, invalid, or signed by an untrusted CA. */
|
||||
export class FederationUnauthorizedError extends FederationError {
|
||||
constructor(message = 'Unauthorized', details?: unknown) {
|
||||
super('unauthorized', message, details);
|
||||
this.name = 'FederationUnauthorizedError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Grant is inactive, revoked, or the subject user lacks access to the resource. */
|
||||
export class FederationForbiddenError extends FederationError {
|
||||
constructor(message = 'Forbidden', details?: unknown) {
|
||||
super('forbidden', message, details);
|
||||
this.name = 'FederationForbiddenError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Requested resource does not exist. */
|
||||
export class FederationNotFoundError extends FederationError {
|
||||
constructor(message = 'Not found', details?: unknown) {
|
||||
super('not_found', message, details);
|
||||
this.name = 'FederationNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Grant has exceeded its rate limit; Retry-After should accompany this. */
|
||||
export class FederationRateLimitedError extends FederationError {
|
||||
constructor(message = 'Rate limit exceeded', details?: unknown) {
|
||||
super('rate_limited', message, details);
|
||||
this.name = 'FederationRateLimitedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The request targets a resource or performs an action that the grant's
|
||||
* scope explicitly disallows (distinct from generic 403 — scope_violation
|
||||
* means the scope configuration itself blocked the request).
|
||||
*/
|
||||
export class FederationScopeViolationError extends FederationError {
|
||||
constructor(message = 'Scope violation', details?: unknown) {
|
||||
super('scope_violation', message, details);
|
||||
this.name = 'FederationScopeViolationError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Malformed request — missing fields, invalid cursor, unknown verb, etc. */
|
||||
export class FederationInvalidRequestError extends FederationError {
|
||||
constructor(message = 'Invalid request', details?: unknown) {
|
||||
super('invalid_request', message, details);
|
||||
this.name = 'FederationInvalidRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Unexpected server-side failure. */
|
||||
export class FederationInternalError extends FederationError {
|
||||
constructor(message = 'Internal error', details?: unknown) {
|
||||
super('internal_error', message, details);
|
||||
this.name = 'FederationInternalError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typed parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse an unknown value as a FederationErrorEnvelope.
|
||||
* Throws a plain Error (not FederationError) when parsing fails — this means
|
||||
* the payload wasn't even a valid error envelope.
|
||||
*/
|
||||
export function parseFederationErrorEnvelope(input: unknown): FederationErrorEnvelope {
|
||||
const result = FederationErrorEnvelopeSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map((e) => ` - [${e.path.join('.') || 'root'}] ${e.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`Invalid federation error envelope:\n${issues}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
16
packages/types/src/federation/index.ts
Normal file
16
packages/types/src/federation/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Federation wire-format DTOs — public barrel.
|
||||
*
|
||||
* Exports everything downstream M3 tasks need:
|
||||
* verbs.ts — FEDERATION_VERBS constant + FederationVerb type
|
||||
* request.ts — FederationRequestSchema + FederationRequest
|
||||
* response.ts — list/get/capabilities schema factories + types
|
||||
* source-tag.ts — SourceTagSchema, tagWithSource helper
|
||||
* error.ts — error envelope schema + typed exception hierarchy
|
||||
*/
|
||||
|
||||
export * from './verbs.js';
|
||||
export * from './request.js';
|
||||
export * from './response.js';
|
||||
export * from './source-tag.js';
|
||||
export * from './error.js';
|
||||
47
packages/types/src/federation/request.ts
Normal file
47
packages/types/src/federation/request.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Federation wire-format request schema.
|
||||
*
|
||||
* Source of truth: docs/federation/PRD.md §9 (query model).
|
||||
*
|
||||
* Pure — no NestJS, no DB, no Node-only APIs. Safe for browser/edge.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { FEDERATION_VERBS } from './verbs.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query params — free-form key/value pairs passed alongside the request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const QueryParamsSchema = z.record(z.string(), z.string()).optional();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-level request schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const FederationRequestSchema = z.object({
|
||||
/**
|
||||
* Verb being invoked. One of the M3 federation verbs.
|
||||
*/
|
||||
verb: z.enum(FEDERATION_VERBS),
|
||||
|
||||
/**
|
||||
* Resource path being queried, e.g. "tasks", "notes", "memory".
|
||||
* Forward-slash-separated for sub-resources (e.g. "teams/abc/tasks").
|
||||
*/
|
||||
resource: z.string().min(1, { message: 'resource must not be empty' }),
|
||||
|
||||
/**
|
||||
* Optional free-form query params (filters, sort, etc.).
|
||||
* Values are always strings; consumers parse as needed.
|
||||
*/
|
||||
params: QueryParamsSchema,
|
||||
|
||||
/**
|
||||
* Opaque pagination cursor returned by a previous list response.
|
||||
* Absent on first page.
|
||||
*/
|
||||
cursor: z.string().optional(),
|
||||
});
|
||||
|
||||
export type FederationRequest = z.infer<typeof FederationRequestSchema>;
|
||||
162
packages/types/src/federation/response.ts
Normal file
162
packages/types/src/federation/response.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Federation wire-format response schemas.
|
||||
*
|
||||
* Source of truth: docs/federation/PRD.md §9 and MILESTONES.md §M3.
|
||||
*
|
||||
* DESIGN: Generic factory functions rather than z.lazy
|
||||
* ─────────────────────────────────────────────────────
|
||||
* Zod generic schemas cannot be expressed as a single re-usable `z.ZodType`
|
||||
* value because TypeScript's type system erases the generic at the call site.
|
||||
* The idiomatic Zod v4 pattern is factory functions that take an item schema
|
||||
* and return a fully-typed schema.
|
||||
*
|
||||
* const MyListSchema = FederationListResponseSchema(z.string());
|
||||
* type MyList = z.infer<typeof MyListSchema>;
|
||||
* // => { items: string[]; nextCursor?: string; _partial?: boolean; _truncated?: boolean }
|
||||
*
|
||||
* Downstream consumers (M3-03..M3-07, M3-08, M3-09) should call these
|
||||
* factories once per resource type and cache the result.
|
||||
*
|
||||
* Pure — no NestJS, no DB, no Node-only APIs. Safe for browser/edge.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FEDERATION_VERBS } from './verbs.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared envelope flags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `_partial`: true when the response is a subset of available data (e.g. due
|
||||
* to scope intersection reducing the result set).
|
||||
*/
|
||||
const PartialFlag = z.boolean().optional();
|
||||
|
||||
/**
|
||||
* `_truncated`: true when the response was capped by max_rows_per_query and
|
||||
* additional pages exist beyond the current cursor.
|
||||
*/
|
||||
const TruncatedFlag = z.boolean().optional();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationListResponseSchema<T> factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a Zod schema for a paginated federation list envelope.
|
||||
*
|
||||
* @param itemSchema - Zod schema for a single item in the list.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const TaskListSchema = FederationListResponseSchema(TaskSchema);
|
||||
* type TaskList = z.infer<typeof TaskListSchema>;
|
||||
* ```
|
||||
*/
|
||||
export function FederationListResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
|
||||
return z.object({
|
||||
items: z.array(itemSchema),
|
||||
nextCursor: z.string().optional(),
|
||||
_partial: PartialFlag,
|
||||
_truncated: TruncatedFlag,
|
||||
});
|
||||
}
|
||||
|
||||
export type FederationListResponse<T> = {
|
||||
items: T[];
|
||||
nextCursor?: string;
|
||||
_partial?: boolean;
|
||||
_truncated?: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationGetResponseSchema<T> factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a Zod schema for a single-item federation get envelope.
|
||||
*
|
||||
* `item` is null when the resource was not found (404 equivalent on the wire).
|
||||
*
|
||||
* @param itemSchema - Zod schema for the item (nullable is applied internally).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const TaskGetSchema = FederationGetResponseSchema(TaskSchema);
|
||||
* type TaskGet = z.infer<typeof TaskGetSchema>;
|
||||
* ```
|
||||
*/
|
||||
export function FederationGetResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
|
||||
return z.object({
|
||||
item: itemSchema.nullable(),
|
||||
_partial: PartialFlag,
|
||||
});
|
||||
}
|
||||
|
||||
export type FederationGetResponse<T> = {
|
||||
item: T | null;
|
||||
_partial?: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FederationCapabilitiesResponseSchema (fixed shape)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Shape mirrors FederationScope (apps/gateway/src/federation/scope-schema.ts)
|
||||
* but is kept separate to avoid coupling packages/types to the gateway module.
|
||||
* The serving side populates this from the resolved grant scope at request time.
|
||||
*/
|
||||
export const FederationCapabilitiesResponseSchema = z.object({
|
||||
/**
|
||||
* Resources this grant is allowed to query.
|
||||
*/
|
||||
resources: z.array(z.string()).nonempty(),
|
||||
|
||||
/**
|
||||
* Resources explicitly blocked for this grant even if they exist.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
max_rows_per_query: z.number().int().positive(),
|
||||
|
||||
/**
|
||||
* Verbs currently available. Will expand in M4+ (search).
|
||||
* Closed enum — only values from FEDERATION_VERBS are accepted.
|
||||
*/
|
||||
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>;
|
||||
61
packages/types/src/federation/source-tag.ts
Normal file
61
packages/types/src/federation/source-tag.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* _source tag for federation fan-out results.
|
||||
*
|
||||
* Source of truth: docs/federation/PRD.md §9.3 and MILESTONES.md §M3 acceptance test #8.
|
||||
*
|
||||
* When source: "all" is requested, the gateway fans out to local + all active
|
||||
* federated peers, merges results, and tags each item with _source so the
|
||||
* caller knows the provenance.
|
||||
*
|
||||
* Pure — no NestJS, no DB, no Node-only APIs. Safe for browser/edge.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source tag schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `_source` is either:
|
||||
* - `"local"` — the item came from this gateway's own storage.
|
||||
* - a peer common name (e.g. `"mosaic.uscllc.com"`) — the item came from
|
||||
* that federated peer.
|
||||
*/
|
||||
export const SourceTagSchema = z.object({
|
||||
_source: z.string().min(1, { message: '_source must not be empty' }),
|
||||
});
|
||||
|
||||
export type SourceTag = z.infer<typeof SourceTagSchema>;
|
||||
|
||||
/**
|
||||
* Literal union for the well-known local source value.
|
||||
* Peers are identified by hostname strings, so there is no closed enum.
|
||||
*/
|
||||
export const SOURCE_LOCAL = 'local' as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: tagWithSource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stamps each item in `items` with `{ _source: source }`.
|
||||
*
|
||||
* The return type merges the item type with SourceTag so callers get full
|
||||
* type-safety on both the original fields and `_source`.
|
||||
*
|
||||
* @param items - Array of items to tag.
|
||||
* @param source - Either `"local"` or a peer hostname (common name from the
|
||||
* client cert's CN or O field).
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const local = tagWithSource([{ id: '1', title: 'Task' }], 'local');
|
||||
* // => [{ id: '1', title: 'Task', _source: 'local' }]
|
||||
*
|
||||
* const remote = tagWithSource(peerItems, 'mosaic.uscllc.com');
|
||||
* ```
|
||||
*/
|
||||
export function tagWithSource<T extends object>(items: T[], source: string): Array<T & SourceTag> {
|
||||
return items.map((item) => ({ ...item, _source: source }));
|
||||
}
|
||||
11
packages/types/src/federation/verbs.ts
Normal file
11
packages/types/src/federation/verbs.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Federation verb constants and types.
|
||||
*
|
||||
* Source of truth: docs/federation/PRD.md §9.1
|
||||
*
|
||||
* M3 ships list, get, capabilities. search lives in M4.
|
||||
*/
|
||||
|
||||
export const FEDERATION_VERBS = ['list', 'get', 'capabilities'] as const;
|
||||
|
||||
export type FederationVerb = (typeof FEDERATION_VERBS)[number];
|
||||
@@ -5,3 +5,4 @@ export * from './agent/index.js';
|
||||
export * from './provider/index.js';
|
||||
export * from './routing/index.js';
|
||||
export * from './commands/index.js';
|
||||
export * from './federation/index.js';
|
||||
|
||||
54
pnpm-lock.yaml
generated
54
pnpm-lock.yaml
generated
@@ -679,6 +679,9 @@ importers:
|
||||
class-validator:
|
||||
specifier: ^0.15.1
|
||||
version: 0.15.1
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.8.0
|
||||
@@ -710,10 +713,10 @@ importers:
|
||||
dependencies:
|
||||
'@mariozechner/pi-agent-core':
|
||||
specifier: ^0.63.1
|
||||
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: ^0.63.1
|
||||
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
|
||||
'@sinclair/typebox':
|
||||
specifier: ^0.34.41
|
||||
version: 0.34.48
|
||||
@@ -7326,6 +7329,12 @@ snapshots:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@anthropic-ai/sdk@0.73.0(zod@3.25.76)':
|
||||
dependencies:
|
||||
json-schema-to-ts: 3.1.1
|
||||
optionalDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@anthropic-ai/sdk@0.73.0(zod@4.3.6)':
|
||||
dependencies:
|
||||
json-schema-to-ts: 3.1.1
|
||||
@@ -8667,6 +8676,18 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||
@@ -8715,6 +8736,30 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.73.0(zod@3.25.76)
|
||||
'@aws-sdk/client-bedrock-runtime': 3.1008.0
|
||||
'@google/genai': 1.45.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))
|
||||
'@mistralai/mistralai': 1.14.1
|
||||
'@sinclair/typebox': 0.34.48
|
||||
ajv: 8.18.0
|
||||
ajv-formats: 3.0.1(ajv@8.18.0)
|
||||
chalk: 5.6.2
|
||||
openai: 6.26.0(ws@8.20.0)(zod@3.25.76)
|
||||
partial-json: 0.1.7
|
||||
proxy-agent: 6.5.0
|
||||
undici: 7.24.3
|
||||
zod-to-json-schema: 3.25.1(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
||||
@@ -13307,6 +13352,11 @@ snapshots:
|
||||
dependencies:
|
||||
mimic-function: 5.0.1
|
||||
|
||||
openai@6.26.0(ws@8.20.0)(zod@3.25.76):
|
||||
optionalDependencies:
|
||||
ws: 8.20.0
|
||||
zod: 3.25.76
|
||||
|
||||
openai@6.26.0(ws@8.20.0)(zod@4.3.6):
|
||||
optionalDependencies:
|
||||
ws: 8.20.0
|
||||
|
||||
Reference in New Issue
Block a user