diff --git a/packages/types/package.json b/packages/types/package.json index 5677a66..5e9b512 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -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/", diff --git a/packages/types/src/federation/__tests__/federation.spec.ts b/packages/types/src/federation/__tests__/federation.spec.ts new file mode 100644 index 0000000..2fd8bdb --- /dev/null +++ b/packages/types/src/federation/__tests__/federation.spec.ts @@ -0,0 +1,336 @@ +/** + * 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('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); + }); +}); diff --git a/packages/types/src/federation/error.ts b/packages/types/src/federation/error.ts new file mode 100644 index 0000000..9416fd0 --- /dev/null +++ b/packages/types/src/federation/error.ts @@ -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; + +// --------------------------------------------------------------------------- +// 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; +} diff --git a/packages/types/src/federation/index.ts b/packages/types/src/federation/index.ts new file mode 100644 index 0000000..1bef7fc --- /dev/null +++ b/packages/types/src/federation/index.ts @@ -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'; diff --git a/packages/types/src/federation/request.ts b/packages/types/src/federation/request.ts new file mode 100644 index 0000000..1e7eac5 --- /dev/null +++ b/packages/types/src/federation/request.ts @@ -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; diff --git a/packages/types/src/federation/response.ts b/packages/types/src/federation/response.ts new file mode 100644 index 0000000..c661716 --- /dev/null +++ b/packages/types/src/federation/response.ts @@ -0,0 +1,132 @@ +/** + * 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; + * // => { 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'; + +// --------------------------------------------------------------------------- +// 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 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; + * ``` + */ +export function FederationListResponseSchema(itemSchema: T) { + return z.object({ + items: z.array(itemSchema), + nextCursor: z.string().optional(), + _partial: PartialFlag, + _truncated: TruncatedFlag, + }); +} + +export type FederationListResponse = { + items: T[]; + nextCursor?: string; + _partial?: boolean; + _truncated?: boolean; +}; + +// --------------------------------------------------------------------------- +// FederationGetResponseSchema 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; + * ``` + */ +export function FederationGetResponseSchema(itemSchema: T) { + return z.object({ + item: itemSchema.nullable(), + _partial: PartialFlag, + }); +} + +export type FederationGetResponse = { + 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()), + + /** + * 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). + */ + supported_verbs: z.array(z.string()).nonempty(), +}); + +export type FederationCapabilitiesResponse = z.infer; diff --git a/packages/types/src/federation/source-tag.ts b/packages/types/src/federation/source-tag.ts new file mode 100644 index 0000000..2268884 --- /dev/null +++ b/packages/types/src/federation/source-tag.ts @@ -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; + +/** + * 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(items: T[], source: string): Array { + return items.map((item) => ({ ...item, _source: source })); +} diff --git a/packages/types/src/federation/verbs.ts b/packages/types/src/federation/verbs.ts new file mode 100644 index 0000000..5664069 --- /dev/null +++ b/packages/types/src/federation/verbs.ts @@ -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]; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3c9e2b4..49ae520 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a1e638..793f3ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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