Compare commits

...

3 Commits

Author SHA1 Message Date
Jarvis
da387b32cb 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>
2026-04-23 21:43:57 -05:00
Jarvis
8d8e49f983 feat(types): federation v1 wire-format DTOs (FED-M3-01)
Add packages/types/src/federation/ with Zod v4 schemas and inferred TS
types for the M3 federation wire protocol. Includes verbs constant,
request schema, list/get/capabilities response factories, _source tag
helper, and typed error envelope + exception hierarchy. 46 unit tests;
typecheck, lint, and format:check all green.

Closes #462

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 21:43:57 -05:00
7342c1290d fix(federation): use real PEM certs in enrollment + ca service tests (#507)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-04-24 02:43:42 +00:00
13 changed files with 1169 additions and 40 deletions

View File

@@ -24,10 +24,11 @@
*/ */
import 'reflect-metadata'; import 'reflect-metadata';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest';
import { GoneException, NotFoundException } from '@nestjs/common'; import { GoneException, NotFoundException } from '@nestjs/common';
import type { Db } from '@mosaicstack/db'; import type { Db } from '@mosaicstack/db';
import { EnrollmentService } from '../enrollment.service.js'; import { EnrollmentService } from '../enrollment.service.js';
import { makeSelfSignedCert } from './helpers/test-cert.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Test constants // Test constants
@@ -38,10 +39,18 @@ const PEER_ID = 'p2222222-2222-2222-2222-222222222222';
const USER_ID = 'u3333333-3333-3333-3333-333333333333'; const USER_ID = 'u3333333-3333-3333-3333-333333333333';
const TOKEN = 'a'.repeat(64); // 64-char hex const TOKEN = 'a'.repeat(64); // 64-char hex
const MOCK_CERT_PEM = '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n'; // Real self-signed EC P-256 cert — populated once in beforeAll.
const MOCK_CHAIN_PEM = MOCK_CERT_PEM + MOCK_CERT_PEM; // Required because EnrollmentService.extractCertNotAfter calls new X509Certificate(certPem)
// with strict parsing (PR #501 HIGH-2: no silent fallback).
let REAL_CERT_PEM: string;
const MOCK_CHAIN_PEM = () => REAL_CERT_PEM + REAL_CERT_PEM;
const MOCK_SERIAL = 'ABCD1234'; const MOCK_SERIAL = 'ABCD1234';
beforeAll(async () => {
REAL_CERT_PEM = await makeSelfSignedCert();
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Factory helpers // Factory helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -103,11 +112,27 @@ function makeDb({
const claimUpdateMock = vi.fn().mockReturnValue({ set: setClaimMock }); const claimUpdateMock = vi.fn().mockReturnValue({ set: setClaimMock });
// transaction(cb) — cb receives txMock; txMock has update + insert // transaction(cb) — cb receives txMock; txMock has update + insert
const txInsertValues = vi.fn().mockResolvedValue(undefined); //
const txInsertMock = vi.fn().mockReturnValue({ values: txInsertValues }); // The tx mock must support two tx.update() call patterns (CRIT-2, PR #501):
const txWhereUpdate = vi.fn().mockResolvedValue(undefined); // 1. Grant activation: .update().set().where().returning() → resolves to [{ id }]
// 2. Peer update: .update().set().where() → resolves to undefined
//
// We achieve this by making txWhereUpdate return an object with BOTH a thenable
// interface (so `await tx.update().set().where()` works) AND a .returning() method.
const txGrantActivatedRow = { id: GRANT_ID };
const txReturningMock = vi.fn().mockResolvedValue([txGrantActivatedRow]);
const txWhereUpdate = vi.fn().mockReturnValue({
// .returning() for grant activation (first tx.update call)
returning: txReturningMock,
// thenables so `await tx.update().set().where()` also works for peer update
then: (resolve: (v: undefined) => void) => resolve(undefined),
catch: () => undefined,
finally: () => undefined,
});
const txSetMock = vi.fn().mockReturnValue({ where: txWhereUpdate }); const txSetMock = vi.fn().mockReturnValue({ where: txWhereUpdate });
const txUpdateMock = vi.fn().mockReturnValue({ set: txSetMock }); const txUpdateMock = vi.fn().mockReturnValue({ set: txSetMock });
const txInsertValues = vi.fn().mockResolvedValue(undefined);
const txInsertMock = vi.fn().mockReturnValue({ values: txInsertValues });
const txMock = { update: txUpdateMock, insert: txInsertMock }; const txMock = { update: txUpdateMock, insert: txInsertMock };
const transactionMock = vi const transactionMock = vi
.fn() .fn()
@@ -132,6 +157,7 @@ function makeDb({
txInsertValues, txInsertValues,
txInsertMock, txInsertMock,
txWhereUpdate, txWhereUpdate,
txReturningMock,
txSetMock, txSetMock,
txUpdateMock, txUpdateMock,
txMock, txMock,
@@ -146,11 +172,13 @@ function makeDb({
function makeCaService() { function makeCaService() {
return { return {
issueCert: vi.fn().mockResolvedValue({ // REAL_CERT_PEM is populated by beforeAll — safe to reference via closure here
certPem: MOCK_CERT_PEM, // because makeCaService() is only called after the suite's beforeAll runs.
certChainPem: MOCK_CHAIN_PEM, issueCert: vi.fn().mockImplementation(async () => ({
certPem: REAL_CERT_PEM,
certChainPem: MOCK_CHAIN_PEM(),
serialNumber: MOCK_SERIAL, serialNumber: MOCK_SERIAL,
}), })),
}; };
} }
@@ -301,29 +329,29 @@ describe('EnrollmentService.redeem — success path', () => {
}); });
caService.issueCert.mockImplementation(async () => { caService.issueCert.mockImplementation(async () => {
callOrder.push('issueCert'); callOrder.push('issueCert');
return { certPem: MOCK_CERT_PEM, certChainPem: MOCK_CHAIN_PEM, serialNumber: MOCK_SERIAL }; return { certPem: REAL_CERT_PEM, certChainPem: MOCK_CHAIN_PEM(), serialNumber: MOCK_SERIAL };
}); });
await service.redeem(TOKEN, MOCK_CERT_PEM); await service.redeem(TOKEN, '---CSR---');
expect(callOrder).toEqual(['claim', 'issueCert']); expect(callOrder).toEqual(['claim', 'issueCert']);
}); });
it('calls CaService.issueCert with grantId, subjectUserId, csrPem, ttlSeconds=300', async () => { it('calls CaService.issueCert with grantId, subjectUserId, csrPem, ttlSeconds=300', async () => {
await service.redeem(TOKEN, MOCK_CERT_PEM); await service.redeem(TOKEN, '---CSR---');
expect(caService.issueCert).toHaveBeenCalledWith( expect(caService.issueCert).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
grantId: GRANT_ID, grantId: GRANT_ID,
subjectUserId: USER_ID, subjectUserId: USER_ID,
csrPem: MOCK_CERT_PEM, csrPem: '---CSR---',
ttlSeconds: 300, ttlSeconds: 300,
}), }),
); );
}); });
it('runs activate grant + peer update + audit inside a transaction', async () => { it('runs activate grant + peer update + audit inside a transaction', async () => {
await service.redeem(TOKEN, MOCK_CERT_PEM); await service.redeem(TOKEN, '---CSR---');
expect(db._mocks.transactionMock).toHaveBeenCalledOnce(); expect(db._mocks.transactionMock).toHaveBeenCalledOnce();
// tx.update called twice: activate grant + update peer // tx.update called twice: activate grant + update peer
@@ -333,17 +361,17 @@ describe('EnrollmentService.redeem — success path', () => {
}); });
it('activates grant (sets status=active) inside the transaction', async () => { it('activates grant (sets status=active) inside the transaction', async () => {
await service.redeem(TOKEN, MOCK_CERT_PEM); await service.redeem(TOKEN, '---CSR---');
expect(db._mocks.txSetMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'active' })); expect(db._mocks.txSetMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'active' }));
}); });
it('updates the federationPeers row with certPem, certSerial, state=active inside the transaction', async () => { it('updates the federationPeers row with certPem, certSerial, state=active inside the transaction', async () => {
await service.redeem(TOKEN, MOCK_CERT_PEM); await service.redeem(TOKEN, '---CSR---');
expect(db._mocks.txSetMock).toHaveBeenCalledWith( expect(db._mocks.txSetMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
certPem: MOCK_CERT_PEM, certPem: REAL_CERT_PEM,
certSerial: MOCK_SERIAL, certSerial: MOCK_SERIAL,
state: 'active', state: 'active',
}), }),
@@ -351,7 +379,7 @@ describe('EnrollmentService.redeem — success path', () => {
}); });
it('inserts an audit log row inside the transaction', async () => { it('inserts an audit log row inside the transaction', async () => {
await service.redeem(TOKEN, MOCK_CERT_PEM); await service.redeem(TOKEN, '---CSR---');
expect(db._mocks.txInsertValues).toHaveBeenCalledWith( expect(db._mocks.txInsertValues).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -363,11 +391,11 @@ describe('EnrollmentService.redeem — success path', () => {
}); });
it('returns { certPem, certChainPem } from CaService', async () => { it('returns { certPem, certChainPem } from CaService', async () => {
const result = await service.redeem(TOKEN, MOCK_CERT_PEM); const result = await service.redeem(TOKEN, '---CSR---');
expect(result).toEqual({ expect(result).toEqual({
certPem: MOCK_CERT_PEM, certPem: REAL_CERT_PEM,
certChainPem: MOCK_CHAIN_PEM, certChainPem: MOCK_CHAIN_PEM(),
}); });
}); });
}); });

View File

@@ -0,0 +1,138 @@
/**
* Test helpers for generating real X.509 PEM certificates in unit tests.
*
* PR #501 (FED-M2-11) introduced strict `new X509Certificate(certPem)` parsing
* in both EnrollmentService.extractCertNotAfter and CaService.issueCert — dummy
* cert strings now throw `error:0680007B:asn1 encoding routines::header too long`.
*
* These helpers produce minimal but cryptographically valid self-signed EC P-256
* certificates via @peculiar/x509 + Node.js webcrypto, suitable for test mocks.
*
* Two variants:
* - makeSelfSignedCert() Plain cert — satisfies node:crypto X509Certificate parse.
* - makeMosaicIssuedCert(opts) Cert with custom Mosaic OID extensions — satisfies the
* CRIT-1 OID presence + value checks in CaService.issueCert.
*/
import { webcrypto } from 'node:crypto';
import {
X509CertificateGenerator,
Extension,
KeyUsagesExtension,
KeyUsageFlags,
BasicConstraintsExtension,
cryptoProvider,
} from '@peculiar/x509';
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Encode a string as an ASN.1 UTF8String TLV:
* 0x0C (tag) + 1-byte length (for strings ≤ 127 bytes) + UTF-8 bytes.
*
* CaService.issueCert reads the extension value as:
* decoder.decode(grantIdExt.value.slice(2))
* i.e. it skips the tag + length byte and decodes the remainder as UTF-8.
* So we must produce exactly this encoding as the OCTET STRING content.
*/
function encodeUtf8String(value: string): Uint8Array {
const utf8 = new TextEncoder().encode(value);
if (utf8.length > 127) {
throw new Error('encodeUtf8String: value too long for single-byte length encoding');
}
const buf = new Uint8Array(2 + utf8.length);
buf[0] = 0x0c; // ASN.1 UTF8String tag
buf[1] = utf8.length;
buf.set(utf8, 2);
return buf;
}
// ---------------------------------------------------------------------------
// Mosaic OID constants (must match production CaService)
// ---------------------------------------------------------------------------
const OID_MOSAIC_GRANT_ID = '1.3.6.1.4.1.99999.1';
const OID_MOSAIC_SUBJECT_USER_ID = '1.3.6.1.4.1.99999.2';
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Generate a minimal self-signed EC P-256 certificate valid for 1 day.
* CN=harness-test, no custom extensions.
*
* Suitable for:
* - EnrollmentService.extractCertNotAfter (just needs parseable PEM)
* - Any mock that returns certPem / certChainPem without OID checks
*/
export async function makeSelfSignedCert(): Promise<string> {
// Ensure @peculiar/x509 uses Node.js webcrypto (available as globalThis.crypto in Node 19+,
// but we set it explicitly here to be safe on all Node 18+ versions).
cryptoProvider.set(webcrypto as unknown as Parameters<typeof cryptoProvider.set>[0]);
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const;
const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']);
const now = new Date();
const tomorrow = new Date(now.getTime() + 86_400_000);
const cert = await X509CertificateGenerator.createSelfSigned({
serialNumber: '01',
name: 'CN=harness-test',
notBefore: now,
notAfter: tomorrow,
signingAlgorithm: alg,
keys,
extensions: [
new BasicConstraintsExtension(false),
new KeyUsagesExtension(KeyUsageFlags.digitalSignature),
],
});
return cert.toString('pem');
}
/**
* Generate a self-signed EC P-256 certificate that contains the two custom
* Mosaic OID extensions required by CaService.issueCert's CRIT-1 check:
* OID 1.3.6.1.4.1.99999.1 → mosaic_grant_id (value = grantId)
* OID 1.3.6.1.4.1.99999.2 → mosaic_subject_user_id (value = subjectUserId)
*
* The extension value encoding matches the production parser's `.slice(2)` assumption:
* each extension value is an OCTET STRING wrapping an ASN.1 UTF8String TLV.
*/
export async function makeMosaicIssuedCert(opts: {
grantId: string;
subjectUserId: string;
}): Promise<string> {
// Ensure @peculiar/x509 uses Node.js webcrypto.
cryptoProvider.set(webcrypto as unknown as Parameters<typeof cryptoProvider.set>[0]);
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const;
const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']);
const now = new Date();
const tomorrow = new Date(now.getTime() + 86_400_000);
const cert = await X509CertificateGenerator.createSelfSigned({
serialNumber: '01',
name: 'CN=mosaic-issued-test',
notBefore: now,
notAfter: tomorrow,
signingAlgorithm: alg,
keys,
extensions: [
new BasicConstraintsExtension(false),
new KeyUsagesExtension(KeyUsageFlags.digitalSignature),
// mosaic_grant_id — OID 1.3.6.1.4.1.99999.1
new Extension(OID_MOSAIC_GRANT_ID, false, encodeUtf8String(opts.grantId)),
// mosaic_subject_user_id — OID 1.3.6.1.4.1.99999.2
new Extension(OID_MOSAIC_SUBJECT_USER_ID, false, encodeUtf8String(opts.subjectUserId)),
],
});
return cert.toString('pem');
}

View File

@@ -20,9 +20,10 @@
*/ */
import 'reflect-metadata'; import 'reflect-metadata';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { describe, it, expect, vi, beforeEach, beforeAll, type Mock } from 'vitest';
import { jwtVerify, exportJWK, generateKeyPair } from 'jose'; import { jwtVerify, exportJWK, generateKeyPair } from 'jose';
import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509'; import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509';
import { makeMosaicIssuedCert } from './__tests__/helpers/test-cert.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mock node:https BEFORE importing CaService so the mock is in place when // Mock node:https BEFORE importing CaService so the mock is in place when
@@ -74,6 +75,11 @@ const FAKE_CA_PEM = FAKE_CERT_PEM;
const GRANT_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'; const GRANT_ID = 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11';
const SUBJECT_USER_ID = 'b1ffcd00-0d1c-5f09-cc7e-7cc0ce491b22'; const SUBJECT_USER_ID = 'b1ffcd00-0d1c-5f09-cc7e-7cc0ce491b22';
// Real self-signed cert containing both Mosaic OID extensions — populated in beforeAll.
// Required because CaService.issueCert performs CRIT-1 OID presence/value checks on the
// response cert (PR #501 — strict parsing, no silent fallback).
let realIssuedCertPem: string;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Generate a real EC P-256 key pair and CSR for integration-style tests // Generate a real EC P-256 key pair and CSR for integration-style tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -194,6 +200,15 @@ function makeHttpsMock(statusCode: number, body: unknown, errorMsg?: string): vo
describe('CaService', () => { describe('CaService', () => {
let service: CaService; let service: CaService;
beforeAll(async () => {
// Generate a cert with the two Mosaic OIDs so that CaService.issueCert's
// CRIT-1 OID checks pass when mock step-ca returns it as `crt`.
realIssuedCertPem = await makeMosaicIssuedCert({
grantId: GRANT_ID,
subjectUserId: SUBJECT_USER_ID,
});
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
service = new CaService(); service = new CaService();
@@ -226,9 +241,9 @@ describe('CaService', () => {
// Now test that the service's validateCsr accepts it. // Now test that the service's validateCsr accepts it.
// We call it indirectly via issueCert with a successful mock. // We call it indirectly via issueCert with a successful mock.
makeHttpsMock(200, { crt: FAKE_CERT_PEM, certChain: [FAKE_CERT_PEM, FAKE_CA_PEM] }); makeHttpsMock(200, { crt: realIssuedCertPem, certChain: [realIssuedCertPem, FAKE_CA_PEM] });
const result = await service.issueCert(makeReq({ csrPem: realCsrPem })); const result = await service.issueCert(makeReq({ csrPem: realCsrPem }));
expect(result.certPem).toBe(FAKE_CERT_PEM); expect(result.certPem).toBe(realIssuedCertPem);
}); });
it('throws INVALID_CSR for a malformed PEM-shaped CSR', async () => { it('throws INVALID_CSR for a malformed PEM-shaped CSR', async () => {
@@ -251,14 +266,14 @@ describe('CaService', () => {
it('returns IssuedCertDto on success (certChain present)', async () => { it('returns IssuedCertDto on success (certChain present)', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr(); if (!realCsrPem) realCsrPem = await generateRealCsr();
makeHttpsMock(200, { makeHttpsMock(200, {
crt: FAKE_CERT_PEM, crt: realIssuedCertPem,
certChain: [FAKE_CERT_PEM, FAKE_CA_PEM], certChain: [realIssuedCertPem, FAKE_CA_PEM],
}); });
const result = await service.issueCert(makeReq()); const result = await service.issueCert(makeReq());
expect(result.certPem).toBe(FAKE_CERT_PEM); expect(result.certPem).toBe(realIssuedCertPem);
expect(result.certChainPem).toContain(FAKE_CERT_PEM); expect(result.certChainPem).toContain(realIssuedCertPem);
expect(result.certChainPem).toContain(FAKE_CA_PEM); expect(result.certChainPem).toContain(FAKE_CA_PEM);
expect(typeof result.serialNumber).toBe('string'); expect(typeof result.serialNumber).toBe('string');
}); });
@@ -270,14 +285,14 @@ describe('CaService', () => {
it('builds certChainPem from crt+ca when certChain is absent', async () => { it('builds certChainPem from crt+ca when certChain is absent', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr(); if (!realCsrPem) realCsrPem = await generateRealCsr();
makeHttpsMock(200, { makeHttpsMock(200, {
crt: FAKE_CERT_PEM, crt: realIssuedCertPem,
ca: FAKE_CA_PEM, ca: FAKE_CA_PEM,
}); });
const result = await service.issueCert(makeReq()); const result = await service.issueCert(makeReq());
expect(result.certPem).toBe(FAKE_CERT_PEM); expect(result.certPem).toBe(realIssuedCertPem);
expect(result.certChainPem).toContain(FAKE_CERT_PEM); expect(result.certChainPem).toContain(realIssuedCertPem);
expect(result.certChainPem).toContain(FAKE_CA_PEM); expect(result.certChainPem).toContain(FAKE_CA_PEM);
}); });
@@ -287,12 +302,12 @@ describe('CaService', () => {
it('falls back to certPem alone when certChain and ca are absent', async () => { it('falls back to certPem alone when certChain and ca are absent', async () => {
if (!realCsrPem) realCsrPem = await generateRealCsr(); if (!realCsrPem) realCsrPem = await generateRealCsr();
makeHttpsMock(200, { crt: FAKE_CERT_PEM }); makeHttpsMock(200, { crt: realIssuedCertPem });
const result = await service.issueCert(makeReq()); const result = await service.issueCert(makeReq());
expect(result.certPem).toBe(FAKE_CERT_PEM); expect(result.certPem).toBe(realIssuedCertPem);
expect(result.certChainPem).toBe(FAKE_CERT_PEM); expect(result.certChainPem).toBe(realIssuedCertPem);
}); });
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -398,7 +413,7 @@ describe('CaService', () => {
statusCode: 200, statusCode: 200,
on: (event: string, cb: (chunk?: Buffer) => void) => { on: (event: string, cb: (chunk?: Buffer) => void) => {
if (event === 'data') { if (event === 'data') {
cb(Buffer.from(JSON.stringify({ crt: FAKE_CERT_PEM }))); cb(Buffer.from(JSON.stringify({ crt: realIssuedCertPem })));
} }
if (event === 'end') { if (event === 'end') {
cb(); cb();
@@ -555,7 +570,7 @@ describe('CaService', () => {
statusCode: 200, statusCode: 200,
on: (event: string, cb: (chunk?: Buffer) => void) => { on: (event: string, cb: (chunk?: Buffer) => void) => {
if (event === 'data') { if (event === 'data') {
cb(Buffer.from(JSON.stringify({ crt: FAKE_CERT_PEM }))); cb(Buffer.from(JSON.stringify({ crt: realIssuedCertPem })));
} }
if (event === 'end') { if (event === 'end') {
cb(); cb();

View File

@@ -26,7 +26,8 @@
}, },
"dependencies": { "dependencies": {
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1" "class-validator": "^0.15.1",
"zod": "^4.3.6"
}, },
"publishConfig": { "publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/", "registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",

View 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);
});
});

View 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;
}

View 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';

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

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

View 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 }));
}

View 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];

View File

@@ -5,3 +5,4 @@ export * from './agent/index.js';
export * from './provider/index.js'; export * from './provider/index.js';
export * from './routing/index.js'; export * from './routing/index.js';
export * from './commands/index.js'; export * from './commands/index.js';
export * from './federation/index.js';

54
pnpm-lock.yaml generated
View File

@@ -679,6 +679,9 @@ importers:
class-validator: class-validator:
specifier: ^0.15.1 specifier: ^0.15.1
version: 0.15.1 version: 0.15.1
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies: devDependencies:
typescript: typescript:
specifier: ^5.8.0 specifier: ^5.8.0
@@ -710,10 +713,10 @@ importers:
dependencies: dependencies:
'@mariozechner/pi-agent-core': '@mariozechner/pi-agent-core':
specifier: ^0.63.1 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': '@mariozechner/pi-ai':
specifier: ^0.63.1 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': '@sinclair/typebox':
specifier: ^0.34.41 specifier: ^0.34.41
version: 0.34.48 version: 0.34.48
@@ -7326,6 +7329,12 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.13 '@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31 '@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)': '@anthropic-ai/sdk@0.73.0(zod@4.3.6)':
dependencies: dependencies:
json-schema-to-ts: 3.1.1 json-schema-to-ts: 3.1.1
@@ -8667,6 +8676,18 @@ snapshots:
- ws - ws
- zod - 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)': '@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: dependencies:
'@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@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 - ws
- zod - 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)': '@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
dependencies: dependencies:
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6) '@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
@@ -13307,6 +13352,11 @@ snapshots:
dependencies: dependencies:
mimic-function: 5.0.1 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): openai@6.26.0(ws@8.20.0)(zod@4.3.6):
optionalDependencies: optionalDependencies:
ws: 8.20.0 ws: 8.20.0