fix(federation/client): pin Step-CA root, fix lockfile, harden cache test
CRIT-1: regenerate pnpm-lock.yaml so apps/gateway resolves undici@7.24.6 (prior PR pushed package.json without lockfile update; CI failed with ERR_PNPM_OUTDATED_LOCKFILE). Incidentally cleans 57 lines of stale peer-dep entries. CRIT-2: cache-hit test no longer swallows resolveEntry errors. Calls the private method directly twice and asserts identity equality plus a single DB select, removing the silent-failure path the prior assertion allowed. HIGH-1: mTLS Agent now pins Step-CA root via STEP_CA_ROOT_CERT_PATH. Without the env var resolveEntry throws PEER_MISCONFIGURED, refusing to dial peers against the public trust store. PEM is read once and cached on the service instance. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -22,9 +22,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
|
||||||
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';
|
import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici';
|
||||||
import type { Dispatcher } 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 type { Db } from '@mosaicstack/db';
|
||||||
import { FederationClientService, FederationClientError } from '../federation-client.service.js';
|
import { FederationClientService, FederationClientError } from '../federation-client.service.js';
|
||||||
import { sealClientKey } from '../../peer-key.util.js';
|
import { sealClientKey } from '../../peer-key.util.js';
|
||||||
@@ -57,6 +60,15 @@ const TEST_CERT_SERIAL = 'ABCDEF1234567890';
|
|||||||
|
|
||||||
let SEALED_KEY: string;
|
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
|
// Peer row factory
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -167,17 +179,28 @@ beforeAll(() => {
|
|||||||
process.env['BETTER_AUTH_SECRET'] = saved;
|
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(() => {
|
beforeEach(() => {
|
||||||
originalDispatcher = getGlobalDispatcher();
|
originalDispatcher = getGlobalDispatcher();
|
||||||
process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
|
process.env['BETTER_AUTH_SECRET'] = TEST_SECRET;
|
||||||
|
process.env['STEP_CA_ROOT_CERT_PATH'] = STUB_CA_PEM_PATH;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setGlobalDispatcher(originalDispatcher);
|
setGlobalDispatcher(originalDispatcher);
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
delete process.env['BETTER_AUTH_SECRET'];
|
delete process.env['BETTER_AUTH_SECRET'];
|
||||||
|
delete process.env['STEP_CA_ROOT_CERT_PATH'];
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -401,34 +424,22 @@ describe('FederationClientService', () => {
|
|||||||
|
|
||||||
describe('cache behaviour', () => {
|
describe('cache behaviour', () => {
|
||||||
it('hits cache on second call — only one DB lookup happens', async () => {
|
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 db = makeDb();
|
||||||
const { mockAgent, pool } = makeMockAgent();
|
|
||||||
|
|
||||||
// Use real resolveEntry — let it build cache from DB
|
|
||||||
const svc = new FederationClientService(db);
|
|
||||||
|
|
||||||
// First capabilities call — will build + cache entry
|
|
||||||
pool
|
|
||||||
.intercept({ path: '/api/federation/v1/capabilities', method: 'GET' })
|
|
||||||
.reply(200, CAP_BODY, { headers: { 'content-type': 'application/json' } })
|
|
||||||
.times(2); // allow two HTTP calls (one per call below)
|
|
||||||
|
|
||||||
// Spy on the private resolveEntry to count DB calls via the DB select spy
|
|
||||||
const selectSpy = vi.spyOn(db, 'select');
|
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);
|
||||||
|
|
||||||
// First call
|
const first = await resolveEntry(PEER_ID);
|
||||||
await svc.capabilities(PEER_ID).catch(() => {
|
const second = await resolveEntry(PEER_ID);
|
||||||
// May fail with PEER_MISCONFIGURED if key unseal fails in test — that's OK
|
|
||||||
// for this specific test which only cares about select spy count
|
|
||||||
});
|
|
||||||
|
|
||||||
// Second call — should use cache
|
expect(first).toBe(second);
|
||||||
await svc.capabilities(PEER_ID).catch(() => {});
|
|
||||||
|
|
||||||
// DB select should have been called at most once (cache hit on second call)
|
|
||||||
expect(selectSpy).toHaveBeenCalledTimes(1);
|
expect(selectSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
await mockAgent.close();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('flushPeer() invalidates cache — next call re-reads DB', async () => {
|
it('flushPeer() invalidates cache — next call re-reads DB', async () => {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import { Agent, fetch as undiciFetch } from 'undici';
|
import { Agent, fetch as undiciFetch } from 'undici';
|
||||||
import type { Dispatcher } from 'undici';
|
import type { Dispatcher } from 'undici';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -123,6 +124,14 @@ export class FederationClientService {
|
|||||||
*/
|
*/
|
||||||
private readonly cache = new Map<string, AgentCacheEntry>();
|
private readonly cache = new Map<string, 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) {}
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -219,6 +228,38 @@ export class FederationClientService {
|
|||||||
// Internal helpers
|
// 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.
|
* Resolve the cache entry for a peer, reading DB on miss.
|
||||||
*
|
*
|
||||||
@@ -275,11 +316,14 @@ export class FederationClientService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build mTLS agent
|
// 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({
|
const agent = new Agent({
|
||||||
connect: {
|
connect: {
|
||||||
cert: peer.certPem,
|
cert: peer.certPem,
|
||||||
key: privateKeyPem,
|
key: privateKeyPem,
|
||||||
|
ca: this.loadStepCaRoot(),
|
||||||
// rejectUnauthorized: true is the undici default for HTTPS
|
// rejectUnauthorized: true is the undici default for HTTPS
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
64
pnpm-lock.yaml
generated
64
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
|
||||||
@@ -713,10 +716,10 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@mariozechner/pi-agent-core':
|
'@mariozechner/pi-agent-core':
|
||||||
specifier: ^0.63.1
|
specifier: ^0.63.1
|
||||||
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
|
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||||
'@mariozechner/pi-ai':
|
'@mariozechner/pi-ai':
|
||||||
specifier: ^0.63.1
|
specifier: ^0.63.1
|
||||||
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
|
version: 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
specifier: ^0.34.41
|
specifier: ^0.34.41
|
||||||
version: 0.34.48
|
version: 0.34.48
|
||||||
@@ -6993,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'}
|
||||||
@@ -7329,12 +7328,6 @@ snapshots:
|
|||||||
'@jridgewell/gen-mapping': 0.3.13
|
'@jridgewell/gen-mapping': 0.3.13
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
|
||||||
'@anthropic-ai/sdk@0.73.0(zod@3.25.76)':
|
|
||||||
dependencies:
|
|
||||||
json-schema-to-ts: 3.1.1
|
|
||||||
optionalDependencies:
|
|
||||||
zod: 3.25.76
|
|
||||||
|
|
||||||
'@anthropic-ai/sdk@0.73.0(zod@4.3.6)':
|
'@anthropic-ai/sdk@0.73.0(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
json-schema-to-ts: 3.1.1
|
json-schema-to-ts: 3.1.1
|
||||||
@@ -8676,18 +8669,6 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
'@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)':
|
|
||||||
dependencies:
|
|
||||||
'@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@modelcontextprotocol/sdk'
|
|
||||||
- aws-crt
|
|
||||||
- bufferutil
|
|
||||||
- supports-color
|
|
||||||
- utf-8-validate
|
|
||||||
- ws
|
|
||||||
- zod
|
|
||||||
|
|
||||||
'@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
'@mariozechner/pi-agent-core@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
'@mariozechner/pi-ai': 0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)
|
||||||
@@ -8736,30 +8717,6 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
'@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@3.25.76)':
|
|
||||||
dependencies:
|
|
||||||
'@anthropic-ai/sdk': 0.73.0(zod@3.25.76)
|
|
||||||
'@aws-sdk/client-bedrock-runtime': 3.1008.0
|
|
||||||
'@google/genai': 1.45.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))
|
|
||||||
'@mistralai/mistralai': 1.14.1
|
|
||||||
'@sinclair/typebox': 0.34.48
|
|
||||||
ajv: 8.18.0
|
|
||||||
ajv-formats: 3.0.1(ajv@8.18.0)
|
|
||||||
chalk: 5.6.2
|
|
||||||
openai: 6.26.0(ws@8.20.0)(zod@3.25.76)
|
|
||||||
partial-json: 0.1.7
|
|
||||||
proxy-agent: 6.5.0
|
|
||||||
undici: 7.24.3
|
|
||||||
zod-to-json-schema: 3.25.1(zod@3.25.76)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@modelcontextprotocol/sdk'
|
|
||||||
- aws-crt
|
|
||||||
- bufferutil
|
|
||||||
- supports-color
|
|
||||||
- utf-8-validate
|
|
||||||
- ws
|
|
||||||
- zod
|
|
||||||
|
|
||||||
'@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
'@mariozechner/pi-ai@0.63.2(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
|
||||||
@@ -8773,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'
|
||||||
@@ -12632,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
|
||||||
@@ -13352,11 +13309,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mimic-function: 5.0.1
|
mimic-function: 5.0.1
|
||||||
|
|
||||||
openai@6.26.0(ws@8.20.0)(zod@3.25.76):
|
|
||||||
optionalDependencies:
|
|
||||||
ws: 8.20.0
|
|
||||||
zod: 3.25.76
|
|
||||||
|
|
||||||
openai@6.26.0(ws@8.20.0)(zod@4.3.6):
|
openai@6.26.0(ws@8.20.0)(zod@4.3.6):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
ws: 8.20.0
|
ws: 8.20.0
|
||||||
@@ -14488,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