Compare commits
7 Commits
dc122e138b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b67f2c9f08 | |||
|
|
37675ae3f2 | ||
|
|
a4a6769a6d | ||
|
|
21650fb194 | ||
| 89c733e0b9 | |||
| ee3f2defd9 | |||
| 7342c1290d |
@@ -73,6 +73,7 @@
|
|||||||
"rxjs": "^7.8.0",
|
"rxjs": "^7.8.0",
|
||||||
"socket.io": "^4.8.0",
|
"socket.io": "^4.8.0",
|
||||||
"uuid": "^11.0.0",
|
"uuid": "^11.0.0",
|
||||||
|
"undici": "^7.24.6",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
138
apps/gateway/src/federation/__tests__/helpers/test-cert.ts
Normal file
138
apps/gateway/src/federation/__tests__/helpers/test-cert.ts
Normal 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');
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -0,0 +1,553 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for FederationClientService (FED-M3-08).
|
||||||
|
*
|
||||||
|
* HTTP mocking strategy:
|
||||||
|
* undici MockAgent is used to intercept outbound HTTP requests. The service
|
||||||
|
* uses `undici.fetch` with a `dispatcher` option, so MockAgent is set as the
|
||||||
|
* global dispatcher and all requests flow through it.
|
||||||
|
*
|
||||||
|
* Because the service builds one `undici.Agent` per peer and passes it as
|
||||||
|
* the dispatcher on every fetch call, we cannot intercept at the Agent level
|
||||||
|
* in unit tests without significant refactoring. Instead, we set the global
|
||||||
|
* dispatcher to a MockAgent and override the service's `doRequest` indirection
|
||||||
|
* by spying on the internal fetch call.
|
||||||
|
*
|
||||||
|
* For the cert/key wiring, we use the real `sealClientKey` function from
|
||||||
|
* peer-key.util.ts with a test secret — no stubs.
|
||||||
|
*
|
||||||
|
* Sealed-key setup:
|
||||||
|
* Each test (or beforeAll) calls `sealClientKey(TEST_PRIVATE_KEY_PEM)` with
|
||||||
|
* BETTER_AUTH_SECRET set to a deterministic test value so that
|
||||||
|
* `unsealClientKey` in the service recovers the original PEM.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'reflect-metadata';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';
|
||||||
|
import type { Dispatcher } from 'undici';
|
||||||
|
import { writeFileSync, unlinkSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Db } from '@mosaicstack/db';
|
||||||
|
import { FederationClientService, FederationClientError } from '../federation-client.service.js';
|
||||||
|
import { sealClientKey } from '../../peer-key.util.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TEST_SECRET = 'test-secret-for-federation-client-spec-only';
|
||||||
|
const PEER_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||||
|
const ENDPOINT = 'https://peer.example.com';
|
||||||
|
|
||||||
|
// Minimal valid RSA/EC private key PEM — does NOT need to be a real key for
|
||||||
|
// unit tests because we only verify it round-trips through seal/unseal, not
|
||||||
|
// that it actually negotiates TLS (MockAgent handles that).
|
||||||
|
const TEST_PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDummyKeyForTests
|
||||||
|
-----END PRIVATE KEY-----`;
|
||||||
|
|
||||||
|
// Minimal self-signed cert PEM (dummy — only used for mTLS Agent construction)
|
||||||
|
const TEST_CERT_PEM = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBdummyCertForFederationClientTests==
|
||||||
|
-----END CERTIFICATE-----`;
|
||||||
|
|
||||||
|
const TEST_CERT_SERIAL = 'ABCDEF1234567890';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sealed key (computed once in beforeAll)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let SEALED_KEY: string;
|
||||||
|
|
||||||
|
// Path to a stub Step-CA root cert file written in beforeAll. The cert is never
|
||||||
|
// actually used to negotiate TLS in unit tests (MockAgent + spy on resolveEntry
|
||||||
|
// short-circuit the network), but loadStepCaRoot() requires the file to exist.
|
||||||
|
const STUB_CA_PEM_PATH = join(tmpdir(), 'federation-client-spec-ca.pem');
|
||||||
|
const STUB_CA_PEM = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIBdummyCAforFederationClientSpecOnly==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Peer row factory
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makePeerRow(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return {
|
||||||
|
id: PEER_ID,
|
||||||
|
commonName: 'peer-example-com',
|
||||||
|
displayName: 'Test Peer',
|
||||||
|
certPem: TEST_CERT_PEM,
|
||||||
|
certSerial: TEST_CERT_SERIAL,
|
||||||
|
certNotAfter: new Date('2030-01-01T00:00:00Z'),
|
||||||
|
clientKeyPem: SEALED_KEY,
|
||||||
|
state: 'active' as const,
|
||||||
|
endpointUrl: ENDPOINT,
|
||||||
|
lastSeenAt: null,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||||
|
revokedAt: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock DB builder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeDb(selectRows: unknown[] = [makePeerRow()]): Db {
|
||||||
|
const limitSelect = vi.fn().mockResolvedValue(selectRows);
|
||||||
|
const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect });
|
||||||
|
const fromSelect = vi.fn().mockReturnValue({ where: whereSelect });
|
||||||
|
const selectMock = vi.fn().mockReturnValue({ from: fromSelect });
|
||||||
|
|
||||||
|
return {
|
||||||
|
select: selectMock,
|
||||||
|
insert: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
transaction: vi.fn(),
|
||||||
|
} as unknown as Db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers for MockAgent HTTP interception
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a MockAgent + MockPool for the peer endpoint, set it as the global
|
||||||
|
* dispatcher, and return both for per-test configuration.
|
||||||
|
*/
|
||||||
|
function makeMockAgent() {
|
||||||
|
const mockAgent = new MockAgent({ connections: 1 });
|
||||||
|
mockAgent.disableNetConnect();
|
||||||
|
setGlobalDispatcher(mockAgent);
|
||||||
|
const pool = mockAgent.get(ENDPOINT);
|
||||||
|
return { mockAgent, pool };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a FederationClientService with a mock DB and a spy on the internal
|
||||||
|
* fetch so we can intercept at the HTTP layer via MockAgent.
|
||||||
|
*
|
||||||
|
* The service calls `fetch(url, { dispatcher: agent })` where `agent` is the
|
||||||
|
* mTLS undici.Agent built from the peer's cert+key. To make MockAgent work,
|
||||||
|
* we need the fetch dispatcher to be the MockAgent, not the per-peer Agent.
|
||||||
|
*
|
||||||
|
* Strategy: we replace the private `resolveEntry` result's `agent` field with
|
||||||
|
* the MockAgent's pool, so fetch uses our interceptor. We do this by spying
|
||||||
|
* on `resolveEntry` and returning a controlled entry.
|
||||||
|
*/
|
||||||
|
function makeService(db: Db, mockPool: Dispatcher): FederationClientService {
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
|
||||||
|
// Override resolveEntry to inject MockAgent pool as the dispatcher
|
||||||
|
vi.spyOn(
|
||||||
|
svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> },
|
||||||
|
'resolveEntry',
|
||||||
|
).mockImplementation(async (_peerId: string) => {
|
||||||
|
// Still call DB (via the real logic) to exercise peer validation,
|
||||||
|
// but return mock pool as the agent.
|
||||||
|
// For simplicity in unit tests, directly return a controlled entry.
|
||||||
|
return {
|
||||||
|
agent: mockPool,
|
||||||
|
endpointUrl: ENDPOINT,
|
||||||
|
certPem: TEST_CERT_PEM,
|
||||||
|
certSerial: TEST_CERT_SERIAL,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return svc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test setup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let originalDispatcher: Dispatcher;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Seal the test key once — requires BETTER_AUTH_SECRET
|
||||||
|
const saved = process.env['BETTER_AUTH_SECRET'];
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
|
||||||
|
try {
|
||||||
|
SEALED_KEY = sealClientKey(TEST_PRIVATE_KEY_PEM);
|
||||||
|
} finally {
|
||||||
|
if (saved === undefined) {
|
||||||
|
delete process.env['BETTER_AUTH_SECRET'];
|
||||||
|
} else {
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeFileSync(STUB_CA_PEM_PATH, STUB_CA_PEM, 'utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
try {
|
||||||
|
unlinkSync(STUB_CA_PEM_PATH);
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalDispatcher = getGlobalDispatcher();
|
||||||
|
process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
|
||||||
|
process.env['STEP_CA_ROOT_CERT_PATH'] = STUB_CA_PEM_PATH;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setGlobalDispatcher(originalDispatcher);
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
delete process.env['BETTER_AUTH_SECRET'];
|
||||||
|
delete process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Successful list response body */
|
||||||
|
const LIST_BODY = {
|
||||||
|
items: [{ id: '1', title: 'Task One' }],
|
||||||
|
nextCursor: undefined,
|
||||||
|
_partial: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Successful get response body */
|
||||||
|
const GET_BODY = {
|
||||||
|
item: { id: '1', title: 'Task One' },
|
||||||
|
_partial: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Successful capabilities response body */
|
||||||
|
const CAP_BODY = {
|
||||||
|
resources: ['tasks'],
|
||||||
|
excluded_resources: [],
|
||||||
|
max_rows_per_query: 100,
|
||||||
|
supported_verbs: ['list', 'get', 'capabilities'] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('FederationClientService', () => {
|
||||||
|
// ─── Successful verb calls ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('list()', () => {
|
||||||
|
it('returns parsed typed response on success', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool
|
||||||
|
.intercept({
|
||||||
|
path: '/api/federation/v1/list/tasks',
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
.reply(200, LIST_BODY, { headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
const result = await svc.list(PEER_ID, 'tasks', {});
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]).toMatchObject({ id: '1', title: 'Task One' });
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get()', () => {
|
||||||
|
it('returns parsed typed response on success', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool
|
||||||
|
.intercept({
|
||||||
|
path: '/api/federation/v1/get/tasks/1',
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
.reply(200, GET_BODY, { headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
const result = await svc.get(PEER_ID, 'tasks', '1', {});
|
||||||
|
|
||||||
|
expect(result.item).toMatchObject({ id: '1', title: 'Task One' });
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('capabilities()', () => {
|
||||||
|
it('returns parsed capabilities response on success', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool
|
||||||
|
.intercept({
|
||||||
|
path: '/api/federation/v1/capabilities',
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
.reply(200, CAP_BODY, { headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
const result = await svc.capabilities(PEER_ID);
|
||||||
|
|
||||||
|
expect(result.resources).toContain('tasks');
|
||||||
|
expect(result.max_rows_per_query).toBe(100);
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── HTTP error surfaces ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('non-2xx responses', () => {
|
||||||
|
it('surfaces 403 as FederationClientError({ status: 403, code: "FORBIDDEN" })', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool.intercept({ path: '/api/federation/v1/list/tasks', method: 'POST' }).reply(
|
||||||
|
403,
|
||||||
|
{ error: { code: 'forbidden', message: 'Access denied' } },
|
||||||
|
{
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(svc.list(PEER_ID, 'tasks', {})).rejects.toMatchObject({
|
||||||
|
status: 403,
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces 404 as FederationClientError({ status: 404, code: "HTTP_404" })', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool.intercept({ path: '/api/federation/v1/get/tasks/999', method: 'POST' }).reply(
|
||||||
|
404,
|
||||||
|
{ error: { code: 'not_found', message: 'Not found' } },
|
||||||
|
{
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(svc.get(PEER_ID, 'tasks', '999', {})).rejects.toMatchObject({
|
||||||
|
status: 404,
|
||||||
|
code: 'HTTP_404',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Network error ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('network errors', () => {
|
||||||
|
it('surfaces network error as FederationClientError({ code: "NETWORK" })', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool
|
||||||
|
.intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
|
||||||
|
.replyWithError(new Error('ECONNREFUSED'));
|
||||||
|
|
||||||
|
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
|
||||||
|
code: 'NETWORK',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Invalid response body ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('invalid response body', () => {
|
||||||
|
it('surfaces as FederationClientError({ code: "INVALID_RESPONSE" }) when body shape is wrong', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
// capabilities returns wrong shape (missing required fields)
|
||||||
|
pool
|
||||||
|
.intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
|
||||||
|
.reply(200, { totally: 'wrong' }, { headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
|
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
|
||||||
|
code: 'INVALID_RESPONSE',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Peer DB validation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('peer validation (without resolveEntry spy)', () => {
|
||||||
|
/**
|
||||||
|
* These tests exercise the real `resolveEntry` path — no spy on resolveEntry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
it('throws PEER_NOT_FOUND when peer is not in DB', async () => {
|
||||||
|
// DB returns empty array (peer not found)
|
||||||
|
const db = makeDb([]);
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
|
||||||
|
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
|
||||||
|
code: 'PEER_NOT_FOUND',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws PEER_INACTIVE when peer state is not "active"', async () => {
|
||||||
|
const db = makeDb([makePeerRow({ state: 'suspended' })]);
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
|
||||||
|
await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({
|
||||||
|
code: 'PEER_INACTIVE',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Cache behaviour ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('cache behaviour', () => {
|
||||||
|
it('hits cache on second call — only one DB lookup happens', async () => {
|
||||||
|
// Verify cache by calling the private resolveEntry directly twice and
|
||||||
|
// asserting the DB was queried only once. This avoids the HTTP layer,
|
||||||
|
// which would require either a real network or per-peer Agent rewiring
|
||||||
|
// that the cache invariant doesn't depend on.
|
||||||
|
const db = makeDb();
|
||||||
|
const selectSpy = vi.spyOn(db, 'select');
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
const resolveEntry = (
|
||||||
|
svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> }
|
||||||
|
).resolveEntry.bind(svc);
|
||||||
|
|
||||||
|
const first = await resolveEntry(PEER_ID);
|
||||||
|
const second = await resolveEntry(PEER_ID);
|
||||||
|
|
||||||
|
expect(first).toBe(second);
|
||||||
|
expect(selectSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes concurrent resolveEntry calls — only one DB lookup', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const selectSpy = vi.spyOn(db, 'select');
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
const resolveEntry = (
|
||||||
|
svc as unknown as {
|
||||||
|
resolveEntry: (peerId: string) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
).resolveEntry.bind(svc);
|
||||||
|
|
||||||
|
const [a, b] = await Promise.all([resolveEntry(PEER_ID), resolveEntry(PEER_ID)]);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
expect(selectSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushPeer destroys the evicted Agent so old TLS connections close', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
const resolveEntry = (
|
||||||
|
svc as unknown as {
|
||||||
|
resolveEntry: (peerId: string) => Promise<{ agent: { destroy: () => Promise<void> } }>;
|
||||||
|
}
|
||||||
|
).resolveEntry.bind(svc);
|
||||||
|
|
||||||
|
const entry = await resolveEntry(PEER_ID);
|
||||||
|
const destroySpy = vi.spyOn(entry.agent, 'destroy').mockResolvedValue();
|
||||||
|
|
||||||
|
svc.flushPeer(PEER_ID);
|
||||||
|
expect(destroySpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushPeer() invalidates cache — next call re-reads DB', async () => {
|
||||||
|
const db = makeDb();
|
||||||
|
const { mockAgent, pool } = makeMockAgent();
|
||||||
|
const svc = makeService(db, pool);
|
||||||
|
|
||||||
|
pool
|
||||||
|
.intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
|
||||||
|
.reply(200, CAP_BODY, { headers: { 'content-type': 'application/json' } })
|
||||||
|
.times(2);
|
||||||
|
|
||||||
|
// First call — populates cache (via mock resolveEntry)
|
||||||
|
await svc.capabilities(PEER_ID);
|
||||||
|
|
||||||
|
// Flush the cache
|
||||||
|
svc.flushPeer(PEER_ID);
|
||||||
|
|
||||||
|
// The spy on resolveEntry is still active — check it's called again after flush
|
||||||
|
const resolveEntrySpy = vi.spyOn(
|
||||||
|
svc as unknown as { resolveEntry: (peerId: string) => Promise<unknown> },
|
||||||
|
'resolveEntry',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second call after flush — should call resolveEntry again
|
||||||
|
await svc.capabilities(PEER_ID);
|
||||||
|
|
||||||
|
// resolveEntry should have been called once after we started spying (post-flush)
|
||||||
|
expect(resolveEntrySpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await mockAgent.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── loadStepCaRoot env-var guard ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('loadStepCaRoot() env-var guard', () => {
|
||||||
|
it('throws PEER_MISCONFIGURED when STEP_CA_ROOT_CERT_PATH is not set', async () => {
|
||||||
|
delete process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||||
|
const db = makeDb();
|
||||||
|
const svc = new FederationClientService(db);
|
||||||
|
const resolveEntry = (
|
||||||
|
svc as unknown as {
|
||||||
|
resolveEntry: (peerId: string) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
).resolveEntry.bind(svc);
|
||||||
|
|
||||||
|
await expect(resolveEntry(PEER_ID)).rejects.toMatchObject({
|
||||||
|
code: 'PEER_MISCONFIGURED',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── FederationClientError class ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('FederationClientError', () => {
|
||||||
|
it('is instanceof Error and FederationClientError', () => {
|
||||||
|
const err = new FederationClientError({
|
||||||
|
code: 'PEER_NOT_FOUND',
|
||||||
|
message: 'test',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
expect(err).toBeInstanceOf(Error);
|
||||||
|
expect(err).toBeInstanceOf(FederationClientError);
|
||||||
|
expect(err.name).toBe('FederationClientError');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries status, code, and peerId', () => {
|
||||||
|
const err = new FederationClientError({
|
||||||
|
status: 403,
|
||||||
|
code: 'FORBIDDEN',
|
||||||
|
message: 'forbidden',
|
||||||
|
peerId: PEER_ID,
|
||||||
|
});
|
||||||
|
expect(err.status).toBe(403);
|
||||||
|
expect(err.code).toBe('FORBIDDEN');
|
||||||
|
expect(err.peerId).toBe(PEER_ID);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
500
apps/gateway/src/federation/client/federation-client.service.ts
Normal file
500
apps/gateway/src/federation/client/federation-client.service.ts
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
/**
|
||||||
|
* FederationClientService — outbound mTLS client for federation requests (FED-M3-08).
|
||||||
|
*
|
||||||
|
* Dials peer gateways over mTLS using the cert+sealed-key stored in `federation_peers`,
|
||||||
|
* invokes federation verbs (list / get / capabilities), and surfaces all failure modes
|
||||||
|
* as typed `FederationClientError` instances.
|
||||||
|
*
|
||||||
|
* ## Error code taxonomy
|
||||||
|
*
|
||||||
|
* | Code | When |
|
||||||
|
* | ------------------ | ------------------------------------------------------------- |
|
||||||
|
* | PEER_NOT_FOUND | No row in federation_peers for the given peerId |
|
||||||
|
* | PEER_INACTIVE | Peer row exists but state !== 'active' |
|
||||||
|
* | PEER_MISCONFIGURED | Peer row is active but missing endpointUrl or clientKeyPem |
|
||||||
|
* | NETWORK | undici threw a connection / TLS / timeout error |
|
||||||
|
* | HTTP_{status} | Peer returned a non-2xx response (e.g. HTTP_403, HTTP_404) |
|
||||||
|
* | FORBIDDEN | Peer returned 403 (convenience alias alongside HTTP_403) |
|
||||||
|
* | INVALID_RESPONSE | Response body failed Zod schema validation |
|
||||||
|
*
|
||||||
|
* ## Cache strategy
|
||||||
|
*
|
||||||
|
* Per-peer `undici.Agent` instances are cached in a `Map<peerId, AgentCacheEntry>` for
|
||||||
|
* the lifetime of the service instance. The cache is keyed on peerId (UUID).
|
||||||
|
*
|
||||||
|
* Cache invalidation:
|
||||||
|
* - `flushPeer(peerId)` — removes the entry immediately. M5/M6 MUST call this on
|
||||||
|
* cert rotation or peer revocation events so the next request re-reads the DB and
|
||||||
|
* builds a fresh TLS Agent with the new cert material.
|
||||||
|
* - On cache miss: re-reads the DB, checks state === 'active', rebuilds Agent.
|
||||||
|
*
|
||||||
|
* Cache does NOT auto-expire. The service is expected to be a singleton scoped to the
|
||||||
|
* NestJS application lifecycle; flushing on revocation/rotation is the only invalidation
|
||||||
|
* path by design (avoids redundant DB round-trips on the hot path).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { Agent, fetch as undiciFetch } from 'undici';
|
||||||
|
import type { Dispatcher } from 'undici';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { type Db, eq, federationPeers } from '@mosaicstack/db';
|
||||||
|
import {
|
||||||
|
FederationListResponseSchema,
|
||||||
|
FederationGetResponseSchema,
|
||||||
|
FederationCapabilitiesResponseSchema,
|
||||||
|
FederationErrorEnvelopeSchema,
|
||||||
|
type FederationListResponse,
|
||||||
|
type FederationGetResponse,
|
||||||
|
type FederationCapabilitiesResponse,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { DB } from '../../database/database.module.js';
|
||||||
|
import { unsealClientKey } from '../peer-key.util.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error taxonomy
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side error code set. Distinct from the server-side `FederationErrorCode`
|
||||||
|
* (which lives in `@mosaicstack/types`) because the client has additional failure
|
||||||
|
* modes (PEER_NOT_FOUND, PEER_INACTIVE, PEER_MISCONFIGURED, NETWORK) that the
|
||||||
|
* server never emits.
|
||||||
|
*/
|
||||||
|
export type FederationClientErrorCode =
|
||||||
|
| 'PEER_NOT_FOUND'
|
||||||
|
| 'PEER_INACTIVE'
|
||||||
|
| 'PEER_MISCONFIGURED'
|
||||||
|
| 'NETWORK'
|
||||||
|
| 'FORBIDDEN'
|
||||||
|
| 'INVALID_RESPONSE'
|
||||||
|
| `HTTP_${number}`;
|
||||||
|
|
||||||
|
export interface FederationClientErrorOptions {
|
||||||
|
status?: number;
|
||||||
|
code: FederationClientErrorCode;
|
||||||
|
message: string;
|
||||||
|
peerId: string;
|
||||||
|
cause?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by FederationClientService on every failure path.
|
||||||
|
* Callers can dispatch on `error.code` for programmatic handling.
|
||||||
|
*/
|
||||||
|
export class FederationClientError extends Error {
|
||||||
|
readonly status?: number;
|
||||||
|
readonly code: FederationClientErrorCode;
|
||||||
|
readonly peerId: string;
|
||||||
|
readonly cause?: unknown;
|
||||||
|
|
||||||
|
constructor(opts: FederationClientErrorOptions) {
|
||||||
|
super(opts.message);
|
||||||
|
this.name = 'FederationClientError';
|
||||||
|
this.status = opts.status;
|
||||||
|
this.code = opts.code;
|
||||||
|
this.peerId = opts.peerId;
|
||||||
|
this.cause = opts.cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal cache types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface AgentCacheEntry {
|
||||||
|
agent: Agent;
|
||||||
|
endpointUrl: string;
|
||||||
|
certPem: string;
|
||||||
|
certSerial: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Service
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FederationClientService {
|
||||||
|
private readonly logger = new Logger(FederationClientService.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-peer undici Agent cache.
|
||||||
|
* Key = peerId (UUID string).
|
||||||
|
*
|
||||||
|
* Values are either a resolved `AgentCacheEntry` or an in-flight
|
||||||
|
* `Promise<AgentCacheEntry>` (promise-cache pattern). Storing the promise
|
||||||
|
* prevents duplicate DB lookups and duplicate key-unseal operations when two
|
||||||
|
* requests for the same peer arrive before the first build completes.
|
||||||
|
*
|
||||||
|
* Flush via `flushPeer(peerId)` on cert rotation / peer revocation (M5/M6).
|
||||||
|
*/
|
||||||
|
private readonly cache = new Map<string, AgentCacheEntry | Promise<AgentCacheEntry>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step-CA root cert PEM, loaded once from `STEP_CA_ROOT_CERT_PATH`.
|
||||||
|
* Used as the trust anchor for peer server certificates so federation TLS is
|
||||||
|
* pinned to our PKI, not the public trust store. Lazily loaded on first use
|
||||||
|
* so unit tests that don't exercise the agent path can run without the env var.
|
||||||
|
*/
|
||||||
|
private cachedCaPem: string | null = null;
|
||||||
|
|
||||||
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Public verb API
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the `list` verb on a remote peer.
|
||||||
|
*
|
||||||
|
* @param peerId UUID of the peer row in `federation_peers`.
|
||||||
|
* @param resource Resource path, e.g. "tasks".
|
||||||
|
* @param request Free-form body sent as JSON in the POST body.
|
||||||
|
* @returns Parsed `FederationListResponse<T>`.
|
||||||
|
*/
|
||||||
|
async list<T>(
|
||||||
|
peerId: string,
|
||||||
|
resource: string,
|
||||||
|
request: Record<string, unknown>,
|
||||||
|
): Promise<FederationListResponse<T>> {
|
||||||
|
const { endpointUrl, agent } = await this.resolveEntry(peerId);
|
||||||
|
const url = `${endpointUrl}/api/federation/v1/list/${encodeURIComponent(resource)}`;
|
||||||
|
const body = await this.doPost(peerId, url, agent, request);
|
||||||
|
return this.parseWith<FederationListResponse<T>>(
|
||||||
|
peerId,
|
||||||
|
body,
|
||||||
|
FederationListResponseSchema(z.unknown()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the `get` verb on a remote peer.
|
||||||
|
*
|
||||||
|
* @param peerId UUID of the peer row in `federation_peers`.
|
||||||
|
* @param resource Resource path, e.g. "tasks".
|
||||||
|
* @param id Resource identifier.
|
||||||
|
* @param request Free-form body sent as JSON in the POST body.
|
||||||
|
* @returns Parsed `FederationGetResponse<T>`.
|
||||||
|
*/
|
||||||
|
async get<T>(
|
||||||
|
peerId: string,
|
||||||
|
resource: string,
|
||||||
|
id: string,
|
||||||
|
request: Record<string, unknown>,
|
||||||
|
): Promise<FederationGetResponse<T>> {
|
||||||
|
const { endpointUrl, agent } = await this.resolveEntry(peerId);
|
||||||
|
const url = `${endpointUrl}/api/federation/v1/get/${encodeURIComponent(resource)}/${encodeURIComponent(id)}`;
|
||||||
|
const body = await this.doPost(peerId, url, agent, request);
|
||||||
|
return this.parseWith<FederationGetResponse<T>>(
|
||||||
|
peerId,
|
||||||
|
body,
|
||||||
|
FederationGetResponseSchema(z.unknown()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke the `capabilities` verb on a remote peer.
|
||||||
|
*
|
||||||
|
* @param peerId UUID of the peer row in `federation_peers`.
|
||||||
|
* @returns Parsed `FederationCapabilitiesResponse`.
|
||||||
|
*/
|
||||||
|
async capabilities(peerId: string): Promise<FederationCapabilitiesResponse> {
|
||||||
|
const { endpointUrl, agent } = await this.resolveEntry(peerId);
|
||||||
|
const url = `${endpointUrl}/api/federation/v1/capabilities`;
|
||||||
|
const body = await this.doGet(peerId, url, agent);
|
||||||
|
return this.parseWith<FederationCapabilitiesResponse>(
|
||||||
|
peerId,
|
||||||
|
body,
|
||||||
|
FederationCapabilitiesResponseSchema,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Cache management
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush the cached Agent for a specific peer.
|
||||||
|
*
|
||||||
|
* M5/M6 MUST call this on:
|
||||||
|
* - cert rotation events (so new cert material is picked up)
|
||||||
|
* - peer revocation events (so future requests fail at PEER_INACTIVE)
|
||||||
|
*
|
||||||
|
* After flushing, the next call to `list`, `get`, or `capabilities` for
|
||||||
|
* this peer will re-read the DB and rebuild the Agent.
|
||||||
|
*/
|
||||||
|
flushPeer(peerId: string): void {
|
||||||
|
const entry = this.cache.get(peerId);
|
||||||
|
if (entry === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.cache.delete(peerId);
|
||||||
|
if (!(entry instanceof Promise)) {
|
||||||
|
// best-effort destroy; promise-cached entries skip destroy because
|
||||||
|
// the in-flight build owns its own Agent which will be GC'd when the
|
||||||
|
// owning request handles the rejection from the cache miss
|
||||||
|
entry.agent.destroy().catch(() => {
|
||||||
|
// intentionally ignored — destroy errors are not actionable
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.logger.log(`Cache flushed for peer ${peerId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and cache the Step-CA root cert PEM from `STEP_CA_ROOT_CERT_PATH`.
|
||||||
|
* Throws `FederationClientError` if the env var is unset or the file cannot
|
||||||
|
* be read — mTLS to a peer without a pinned trust anchor would silently
|
||||||
|
* fall back to the public trust store.
|
||||||
|
*/
|
||||||
|
private loadStepCaRoot(): string {
|
||||||
|
if (this.cachedCaPem !== null) {
|
||||||
|
return this.cachedCaPem;
|
||||||
|
}
|
||||||
|
const path = process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||||
|
if (!path) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_MISCONFIGURED',
|
||||||
|
message: 'STEP_CA_ROOT_CERT_PATH is not set; refusing to dial peer without pinned CA trust',
|
||||||
|
peerId: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pem = readFileSync(path, 'utf8');
|
||||||
|
this.cachedCaPem = pem;
|
||||||
|
return pem;
|
||||||
|
} catch (err) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_MISCONFIGURED',
|
||||||
|
message: `Failed to read STEP_CA_ROOT_CERT_PATH (${path})`,
|
||||||
|
peerId: '',
|
||||||
|
cause: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the cache entry for a peer, reading DB on miss.
|
||||||
|
*
|
||||||
|
* Uses a promise-cache pattern: concurrent callers for the same uncached
|
||||||
|
* `peerId` all `await` the same in-flight `Promise<AgentCacheEntry>` so
|
||||||
|
* only one DB lookup and one key-unseal ever runs per peer per cache miss.
|
||||||
|
* The promise is replaced with the concrete entry on success, or deleted on
|
||||||
|
* rejection so a transient error does not poison the cache permanently.
|
||||||
|
*
|
||||||
|
* Throws `FederationClientError` with appropriate code if the peer is not
|
||||||
|
* found, is inactive, or is missing required fields.
|
||||||
|
*/
|
||||||
|
private async resolveEntry(peerId: string): Promise<AgentCacheEntry> {
|
||||||
|
const cached = this.cache.get(peerId);
|
||||||
|
if (cached) {
|
||||||
|
return cached; // Promise or concrete entry — both are awaitable
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflight = this.buildEntry(peerId).then(
|
||||||
|
(entry) => {
|
||||||
|
this.cache.set(peerId, entry); // replace promise with concrete value
|
||||||
|
return entry;
|
||||||
|
},
|
||||||
|
(err: unknown) => {
|
||||||
|
this.cache.delete(peerId); // don't poison the cache with a rejected promise
|
||||||
|
throw err;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.cache.set(peerId, inflight);
|
||||||
|
return inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `AgentCacheEntry` for a peer by reading the DB, validating the
|
||||||
|
* peer's state, unsealing the private key, and constructing the mTLS Agent.
|
||||||
|
*
|
||||||
|
* Throws `FederationClientError` with appropriate code if the peer is not
|
||||||
|
* found, is inactive, or is missing required fields.
|
||||||
|
*/
|
||||||
|
private async buildEntry(peerId: string): Promise<AgentCacheEntry> {
|
||||||
|
// DB lookup
|
||||||
|
const [peer] = await this.db
|
||||||
|
.select()
|
||||||
|
.from(federationPeers)
|
||||||
|
.where(eq(federationPeers.id, peerId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!peer) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_NOT_FOUND',
|
||||||
|
message: `Federation peer ${peerId} not found`,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peer.state !== 'active') {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_INACTIVE',
|
||||||
|
message: `Federation peer ${peerId} is not active (state: ${peer.state})`,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!peer.endpointUrl || !peer.clientKeyPem) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_MISCONFIGURED',
|
||||||
|
message: `Federation peer ${peerId} is missing endpointUrl or clientKeyPem`,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unseal the private key
|
||||||
|
let privateKeyPem: string;
|
||||||
|
try {
|
||||||
|
privateKeyPem = unsealClientKey(peer.clientKeyPem);
|
||||||
|
} catch (err) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'PEER_MISCONFIGURED',
|
||||||
|
message: `Failed to unseal client key for peer ${peerId}`,
|
||||||
|
peerId,
|
||||||
|
cause: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build mTLS agent — pin trust to Step-CA root so we never accept
|
||||||
|
// a peer cert signed by a public CA (defense against MITM with a
|
||||||
|
// publicly-trusted DV cert for the peer's hostname).
|
||||||
|
const agent = new Agent({
|
||||||
|
connect: {
|
||||||
|
cert: peer.certPem,
|
||||||
|
key: privateKeyPem,
|
||||||
|
ca: this.loadStepCaRoot(),
|
||||||
|
// rejectUnauthorized: true is the undici default for HTTPS
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const entry: AgentCacheEntry = {
|
||||||
|
agent,
|
||||||
|
endpointUrl: peer.endpointUrl,
|
||||||
|
certPem: peer.certPem,
|
||||||
|
certSerial: peer.certSerial,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(`Agent cached for peer ${peerId} (serial: ${peer.certSerial})`);
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a POST request with a JSON body.
|
||||||
|
* Returns the parsed response body as an unknown value.
|
||||||
|
* Throws `FederationClientError` on network errors and non-2xx responses.
|
||||||
|
*/
|
||||||
|
private async doPost(
|
||||||
|
peerId: string,
|
||||||
|
url: string,
|
||||||
|
agent: Dispatcher,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return this.doRequest(peerId, url, agent, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a GET request.
|
||||||
|
* Returns the parsed response body as an unknown value.
|
||||||
|
* Throws `FederationClientError` on network errors and non-2xx responses.
|
||||||
|
*/
|
||||||
|
private async doGet(peerId: string, url: string, agent: Dispatcher): Promise<unknown> {
|
||||||
|
return this.doRequest(peerId, url, agent, { method: 'GET' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doRequest(
|
||||||
|
peerId: string,
|
||||||
|
url: string,
|
||||||
|
agent: Dispatcher,
|
||||||
|
init: { method: string; headers?: Record<string, string>; body?: string },
|
||||||
|
): Promise<unknown> {
|
||||||
|
let response: Awaited<ReturnType<typeof undiciFetch>>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await undiciFetch(url, {
|
||||||
|
...init,
|
||||||
|
dispatcher: agent,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'NETWORK',
|
||||||
|
message: `Network error calling peer ${peerId} at ${url}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
peerId,
|
||||||
|
cause: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await response.text().catch(() => '');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const status = response.status;
|
||||||
|
|
||||||
|
// Attempt to parse as federation error envelope
|
||||||
|
let serverMessage = `HTTP ${status}`;
|
||||||
|
try {
|
||||||
|
const json: unknown = JSON.parse(rawBody);
|
||||||
|
const result = FederationErrorEnvelopeSchema.safeParse(json);
|
||||||
|
if (result.success) {
|
||||||
|
serverMessage = result.data.error.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON or not a federation envelope — use generic message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific code for 403 (most actionable for callers); generic HTTP_{n} for others
|
||||||
|
const code: FederationClientErrorCode = status === 403 ? 'FORBIDDEN' : `HTTP_${status}`;
|
||||||
|
|
||||||
|
throw new FederationClientError({
|
||||||
|
status,
|
||||||
|
code,
|
||||||
|
message: `Peer ${peerId} returned ${status}: ${serverMessage}`,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawBody) as unknown;
|
||||||
|
} catch (err) {
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'INVALID_RESPONSE',
|
||||||
|
message: `Peer ${peerId} returned non-JSON body`,
|
||||||
|
peerId,
|
||||||
|
cause: err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate a response body against a Zod schema.
|
||||||
|
*
|
||||||
|
* For list/get, callers pass the result of `FederationListResponseSchema(z.unknown())`
|
||||||
|
* so that the envelope structure is validated without requiring a concrete item schema
|
||||||
|
* at the client level. The generic `T` provides compile-time typing.
|
||||||
|
*
|
||||||
|
* Throws `FederationClientError({ code: 'INVALID_RESPONSE' })` on parse failure.
|
||||||
|
*/
|
||||||
|
private parseWith<T>(peerId: string, body: unknown, schema: z.ZodTypeAny): T {
|
||||||
|
const result = schema.safeParse(body);
|
||||||
|
if (!result.success) {
|
||||||
|
const issues = result.error.issues
|
||||||
|
.map((e: z.ZodIssue) => `[${e.path.join('.') || 'root'}] ${e.message}`)
|
||||||
|
.join('; ');
|
||||||
|
throw new FederationClientError({
|
||||||
|
code: 'INVALID_RESPONSE',
|
||||||
|
message: `Peer ${peerId} returned invalid response shape: ${issues}`,
|
||||||
|
peerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.data as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/gateway/src/federation/client/index.ts
Normal file
13
apps/gateway/src/federation/client/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Federation client barrel — re-exports for FederationModule consumers.
|
||||||
|
*
|
||||||
|
* M3-09 (QuerySourceService) and future milestones should import from here,
|
||||||
|
* not directly from the implementation file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
FederationClientService,
|
||||||
|
FederationClientError,
|
||||||
|
type FederationClientErrorCode,
|
||||||
|
type FederationClientErrorOptions,
|
||||||
|
} from './federation-client.service.js';
|
||||||
@@ -5,10 +5,11 @@ import { EnrollmentController } from './enrollment.controller.js';
|
|||||||
import { EnrollmentService } from './enrollment.service.js';
|
import { EnrollmentService } from './enrollment.service.js';
|
||||||
import { FederationController } from './federation.controller.js';
|
import { FederationController } from './federation.controller.js';
|
||||||
import { GrantsService } from './grants.service.js';
|
import { GrantsService } from './grants.service.js';
|
||||||
|
import { FederationClientService } from './client/index.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [EnrollmentController, FederationController],
|
controllers: [EnrollmentController, FederationController],
|
||||||
providers: [AdminGuard, CaService, EnrollmentService, GrantsService],
|
providers: [AdminGuard, CaService, EnrollmentService, GrantsService, FederationClientService],
|
||||||
exports: [CaService, EnrollmentService, GrantsService],
|
exports: [CaService, EnrollmentService, GrantsService, FederationClientService],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
@@ -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/",
|
||||||
|
|||||||
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 './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';
|
||||||
|
|||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -179,6 +179,9 @@ importers:
|
|||||||
socket.io:
|
socket.io:
|
||||||
specifier: ^4.8.0
|
specifier: ^4.8.0
|
||||||
version: 4.8.3
|
version: 4.8.3
|
||||||
|
undici:
|
||||||
|
specifier: ^7.24.6
|
||||||
|
version: 7.24.6
|
||||||
uuid:
|
uuid:
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
version: 11.1.0
|
version: 11.1.0
|
||||||
@@ -679,6 +682,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
|
||||||
@@ -6990,10 +6996,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
|
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
|
||||||
engines: {node: '>=18.17'}
|
engines: {node: '>=18.17'}
|
||||||
|
|
||||||
undici@7.24.3:
|
|
||||||
resolution: {integrity: sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==}
|
|
||||||
engines: {node: '>=20.18.1'}
|
|
||||||
|
|
||||||
undici@7.24.6:
|
undici@7.24.6:
|
||||||
resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==}
|
resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==}
|
||||||
engines: {node: '>=20.18.1'}
|
engines: {node: '>=20.18.1'}
|
||||||
@@ -8728,7 +8730,7 @@ snapshots:
|
|||||||
openai: 6.26.0(ws@8.20.0)(zod@4.3.6)
|
openai: 6.26.0(ws@8.20.0)(zod@4.3.6)
|
||||||
partial-json: 0.1.7
|
partial-json: 0.1.7
|
||||||
proxy-agent: 6.5.0
|
proxy-agent: 6.5.0
|
||||||
undici: 7.24.3
|
undici: 7.24.6
|
||||||
zod-to-json-schema: 3.25.1(zod@4.3.6)
|
zod-to-json-schema: 3.25.1(zod@4.3.6)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@modelcontextprotocol/sdk'
|
- '@modelcontextprotocol/sdk'
|
||||||
@@ -12587,7 +12589,7 @@ snapshots:
|
|||||||
saxes: 6.0.0
|
saxes: 6.0.0
|
||||||
symbol-tree: 3.2.4
|
symbol-tree: 3.2.4
|
||||||
tough-cookie: 6.0.1
|
tough-cookie: 6.0.1
|
||||||
undici: 7.24.3
|
undici: 7.24.6
|
||||||
w3c-xmlserializer: 5.0.0
|
w3c-xmlserializer: 5.0.0
|
||||||
webidl-conversions: 8.0.1
|
webidl-conversions: 8.0.1
|
||||||
whatwg-mimetype: 5.0.0
|
whatwg-mimetype: 5.0.0
|
||||||
@@ -14438,8 +14440,6 @@ snapshots:
|
|||||||
|
|
||||||
undici@6.21.3: {}
|
undici@6.21.3: {}
|
||||||
|
|
||||||
undici@7.24.3: {}
|
|
||||||
|
|
||||||
undici@7.24.6: {}
|
undici@7.24.6: {}
|
||||||
|
|
||||||
unhomoglyph@1.0.6: {}
|
unhomoglyph@1.0.6: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user