From 7e111f88bf2253433e07f1b4818b18bc3267ee58 Mon Sep 17 00:00:00 2001 From: rmlearney-digicatapult Date: Mon, 24 Aug 2026 12:52:06 +0100 Subject: [PATCH 01/14] agent startup bootstrap for lifecycle management Signed-off-by: rmlearney-digicatapult --- src/agent.ts | 25 ++-- src/bootstrap.ts | 283 +++++++++++++++++++++++++++++++++++++++++++ src/didweb/server.ts | 27 +++-- src/index.ts | 145 +++++----------------- 4 files changed, 344 insertions(+), 136 deletions(-) create mode 100644 src/bootstrap.ts diff --git a/src/agent.ts b/src/agent.ts index 1dde5865..22486110 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -32,6 +32,7 @@ import { DrpcModule } from '@credo-ts/drpc' import { agentDependencies, DidCommHttpInboundTransport, DidCommWsInboundTransport } from '@credo-ts/node' import { askarNodeJS } from '@openwallet-foundation/askar-nodejs' import { container } from 'tsyringe' +import type { WebSocketServer } from 'ws' import { AskarModule, type AskarModuleConfigStoreOptions } from '@credo-ts/askar' import VeritableAnonCredsRegistry from './anoncreds/index.js' @@ -51,11 +52,6 @@ type AgentProofProtocols = [ DidCommProofV2Protocol<[AnonCredsDidCommProofFormatService, DidCommDifPresentationExchangeProofFormatService]>, ] -const inboundTransportMapping = { - http: DidCommHttpInboundTransport, - ws: DidCommWsInboundTransport, -} as const - const outboundTransportMapping = { http: DidCommHttpOutboundTransport, ws: DidCommWsOutboundTransport, @@ -81,6 +77,7 @@ export type AriesRestConfig = { ipfsTimeoutMs: number verifiedDrpcOptions: VerifiedDrpcModuleConfigOptions + didcommWsSocketServer?: WebSocketServer logger: PinoLogger } @@ -212,6 +209,7 @@ export async function setupAgent(restConfig: AriesRestConfig) { ipfsOrigin, ipfsTimeoutMs, verifiedDrpcOptions, + didcommWsSocketServer, agentConfig, askarStoreConfig, @@ -247,10 +245,19 @@ export async function setupAgent(restConfig: AriesRestConfig) { // Register inbound transports for (const inboundTransport of inboundTransports) { - const InboundTransport = inboundTransportMapping[inboundTransport.transport] - agent.didcomm.registerInboundTransport( - new InboundTransport({ port: inboundTransport.port, processedMessageListenerTimeoutMs: 30000 }) - ) + if (inboundTransport.transport === 'http') { + agent.didcomm.registerInboundTransport( + new DidCommHttpInboundTransport({ port: inboundTransport.port, processedMessageListenerTimeoutMs: 30000 }) + ) + continue + } + + if (didcommWsSocketServer) { + agent.didcomm.registerInboundTransport(new DidCommWsInboundTransport({ server: didcommWsSocketServer })) + continue + } + + agent.didcomm.registerInboundTransport(new DidCommWsInboundTransport({ port: inboundTransport.port })) } await agent.initialize() diff --git a/src/bootstrap.ts b/src/bootstrap.ts new file mode 100644 index 00000000..0ea116c9 --- /dev/null +++ b/src/bootstrap.ts @@ -0,0 +1,283 @@ +import type { Server as HttpServer } from 'http' +import type { Socket } from 'node:net' +import { clearInterval } from 'node:timers' + +import WebSocket, { WebSocketServer } from 'ws' + +import { DidCommAutoAcceptCredential, DidCommAutoAcceptProof } from '@credo-ts/didcomm' +import { container } from 'tsyringe' + +import { setupAgent, type InboundTransport, type RestAgent } from './agent.js' +import Database from './didweb/db.js' +import { DidWebServer } from './didweb/server.js' +import type { Env } from './env.js' +import { setupServer } from './server.js' +import { DidWebDocGenerator } from './utils/didWebGenerator.js' +import PinoLogger from './utils/logger.js' + +const SHUTDOWN_TIMEOUT_MS = 15000 + +export interface CloudagentHandle { + agent: RestAgent + adminServer: HttpServer + didWebServer: DidWebServer + shutdown(): Promise +} + +const withTimeout = async (promise: Promise, timeoutMs: number, name: string): Promise => { + let timeoutHandle: NodeJS.Timeout | undefined + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error(`${name} timed out after ${timeoutMs}ms`)), timeoutMs) + }) + + try { + return await Promise.race([promise, timeoutPromise]) + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle) + } + } +} + +const closeServer = async (server?: HttpServer) => { + if (!server) { + return + } + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) +} + +const closeWebSocketServer = async (server?: WebSocketServer, terminateClients = false) => { + if (!server) { + return + } + + if (terminateClients) { + for (const client of server.clients) { + client.terminate() + } + } + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + if (error.message === 'The server is not running') { + resolve() + return + } + + reject(error) + return + } + resolve() + }) + }) +} + +const listen = async (server: HttpServer) => { + await new Promise((resolve, reject) => { + server.once('listening', () => resolve()) + server.once('error', (error) => reject(error)) + }) +} + +export async function startCloudagent(env: Env, logger: PinoLogger): Promise { + container.register(PinoLogger, { + useValue: logger, + }) + + const inboundTransports = env.get('INBOUND_TRANSPORT') as InboundTransport[] + + const didcommWsEntry = inboundTransports.find( + (transport) => transport.transport === 'ws' && typeof transport.port === 'number' + ) + const didcommSocketServer = didcommWsEntry ? new WebSocketServer({ port: didcommWsEntry.port }) : undefined + + let agent: RestAgent | undefined + let didWebServer: DidWebServer | undefined + let adminServer: HttpServer | undefined + let adminSocketServer: WebSocketServer | undefined + let shuttingDownPromise: Promise | undefined + + try { + agent = await setupAgent({ + agentConfig: { + logger: logger.child({ component: 'credo-ts-agent' }), + endpoints: env.get('ENDPOINT'), + autoUpdateStorageOnStartup: env.get('AUTO_UPDATE_STORAGE_ON_STARTUP'), + useDidKeyInProtocols: env.get('USE_DID_KEY_IN_PROTOCOLS'), + useDidSovPrefixWhereAllowed: env.get('USE_DID_SOV_PREFIX_WHERE_ALLOWED'), + }, + + askarStoreConfig: { + id: env.get('WALLET_ID'), + key: env.get('WALLET_KEY'), + database: + env.get('STORAGE_TYPE') === 'sqlite' + ? { + type: 'sqlite', + } + : { + type: 'postgres', + config: { + host: `${env.get('POSTGRES_HOST') as string}:${String(env.get('POSTGRES_PORT'))}`, + }, + credentials: { + account: env.get('POSTGRES_USERNAME') as string, + password: env.get('POSTGRES_PASSWORD') as string, + }, + }, + }, + + inboundTransports, + outboundTransports: env.get('OUTBOUND_TRANSPORT'), + + autoAcceptConnections: env.get('AUTO_ACCEPT_CONNECTIONS'), + autoAcceptCredentials: env.get('AUTO_ACCEPT_CREDENTIALS') as DidCommAutoAcceptCredential, + autoAcceptProofs: env.get('AUTO_ACCEPT_PROOFS') as DidCommAutoAcceptProof, + autoAcceptMediationRequests: env.get('AUTO_ACCEPT_MEDIATION_REQUESTS'), + ipfsOrigin: env.get('IPFS_ORIGIN'), + ipfsTimeoutMs: env.get('IPFS_TIMEOUT_MS'), + + verifiedDrpcOptions: { + proofTimeoutMs: env.get('VERIFIED_DRPC_OPTIONS_PROOF_TIMEOUT_MS'), + requestTimeoutMs: env.get('VERIFIED_DRPC_OPTIONS_REQUEST_TIMEOUT_MS'), + proofRequestOptions: env.get('VERIFIED_DRPC_OPTIONS_PROOF_REQUEST_OPTIONS'), + }, + + didcommWsSocketServer: didcommSocketServer, + logger, + }) + + const database = new Database({ + host: env.get('POSTGRES_HOST'), + database: env.get('DID_WEB_DB_NAME'), + user: env.get('POSTGRES_USERNAME'), + password: env.get('POSTGRES_PASSWORD'), + port: env.get('POSTGRES_PORT'), + }) + + didWebServer = new DidWebServer(logger.logger, database, { + enabled: env.get('DID_WEB_ENABLED'), + port: env.get('DID_WEB_PORT'), + useDevCert: env.get('DID_WEB_USE_DEV_CERT'), + certPath: env.get('DID_WEB_DEV_CERT_PATH'), + keyPath: env.get('DID_WEB_DEV_KEY_PATH'), + didWebDomain: env.get('DID_WEB_DOMAIN'), + }) + await didWebServer.start() + + const didWebGenerator = new DidWebDocGenerator(agent, logger.logger) + await didWebGenerator.generateAndRegister( + env.get('DID_WEB_DOMAIN'), + env.get('DID_WEB_SERVICE_ENDPOINT'), + env.get('DID_WEB_ENABLED'), + (document) => didWebServer!.upsertDid(document) + ) + + adminSocketServer = new WebSocketServer({ noServer: true }) + const zombieSockets = new WeakSet() + const interval = setInterval(() => { + logger.trace(`WebSocket PING (socket count = ${adminSocketServer!.clients.size})`) + adminSocketServer!.clients.forEach((ws: WebSocket) => { + ws.once('pong', () => { + logger.debug('WebSocket PONG') + zombieSockets.delete(ws) + }) + + if (zombieSockets.has(ws)) { + logger.warn('Terminating dead WebSocket') + ws.terminate() + return + } + + zombieSockets.add(ws) + ws.ping() + }) + }, env.get('ADMIN_PING_INTERVAL_MS')) + + adminSocketServer.on('close', () => { + clearInterval(interval) + }) + + const app = await setupServer(agent, logger, { + webhookUrl: env.get('WEBHOOK_URL'), + personaTitle: env.get('PERSONA_TITLE'), + personaColor: env.get('PERSONA_COLOR'), + socketServer: adminSocketServer, + }) + + const adminPort = env.get('ADMIN_PORT') + adminServer = app.listen(adminPort) + await listen(adminServer) + + logger.info(`Successfully started server on port ${adminPort}`) + + adminServer.on('upgrade', (request, socket, head) => { + adminSocketServer!.handleUpgrade(request, socket as Socket, head, () => { + // incoming messages aren't expected so ignore + return + }) + }) + + const shutdown = async () => { + if (!shuttingDownPromise) { + shuttingDownPromise = (async () => { + await closeServer(adminServer) + await closeWebSocketServer(adminSocketServer, true) + await didWebServer!.stop() + + if (didcommSocketServer) { + // Remove once upstream websocket transport closes connected clients during stop. + for (const client of didcommSocketServer.clients) { + client.terminate() + } + } + + // Keep timeout as a backstop to avoid indefinite shutdown hangs. + await withTimeout(agent!.shutdown(), SHUTDOWN_TIMEOUT_MS, 'agent.shutdown') + await closeWebSocketServer(didcommSocketServer) + })() + } + + return shuttingDownPromise + } + + return { + agent, + adminServer, + didWebServer, + shutdown, + } + } catch (error) { + try { + await closeServer(adminServer) + await closeWebSocketServer(adminSocketServer, true) + await didWebServer?.stop() + + if (didcommSocketServer) { + for (const client of didcommSocketServer.clients) { + client.terminate() + } + } + + if (agent) { + await withTimeout(agent.shutdown(), SHUTDOWN_TIMEOUT_MS, 'agent.shutdown') + } + + await closeWebSocketServer(didcommSocketServer) + } catch (cleanupError) { + logger.error('Error during startup cleanup', { cleanupError }) + } + + throw error + } +} diff --git a/src/didweb/server.ts b/src/didweb/server.ts index f48ef75d..4d00605d 100644 --- a/src/didweb/server.ts +++ b/src/didweb/server.ts @@ -78,13 +78,6 @@ export class DidWebServer { this.logger.info('DID:web server disabled') return } - const setupGracefulExit = (sigName: NodeJS.Signals, server: Server, exitCode: number) => { - process.on(sigName, async () => { - server.close(() => { - process.exit(exitCode) - }) - }) - } if (this.config.useDevCert) { let httpsCredentials @@ -107,8 +100,24 @@ export class DidWebServer { this.logger.info(`DID:web server started on http port ${this.config.port}`) }) } + } - setupGracefulExit('SIGINT', this.server, 0) - setupGracefulExit('SIGTERM', this.server, 143) + async stop(): Promise { + if (!this.server) { + return + } + + const server = this.server + this.server = undefined + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) } } diff --git a/src/index.ts b/src/index.ts index a21d9429..f897d402 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,9 @@ #!/usr/bin/env node import 'reflect-metadata' -import type { Socket } from 'node:net' - -import WebSocket, { WebSocketServer } from 'ws' - -import { DidCommAutoAcceptCredential, DidCommAutoAcceptProof } from '@credo-ts/didcomm' -import { clearInterval } from 'node:timers' import { container } from 'tsyringe' -import { setupAgent } from './agent.js' -import Database from './didweb/db.js' -import { DidWebServer } from './didweb/server.js' +import { startCloudagent } from './bootstrap.js' import { Env } from './env.js' -import { setupServer } from './server.js' -import { DidWebDocGenerator } from './utils/didWebGenerator.js' import PinoLogger from './utils/logger.js' const env = container.resolve(Env) @@ -22,117 +12,36 @@ container.register(PinoLogger, { useValue: logger, }) -const agent = await setupAgent({ - agentConfig: { - logger: logger.child({ component: 'credo-ts-agent' }), - endpoints: env.get('ENDPOINT'), - autoUpdateStorageOnStartup: env.get('AUTO_UPDATE_STORAGE_ON_STARTUP'), - useDidKeyInProtocols: env.get('USE_DID_KEY_IN_PROTOCOLS'), - useDidSovPrefixWhereAllowed: env.get('USE_DID_SOV_PREFIX_WHERE_ALLOWED'), - }, - - askarStoreConfig: { - id: env.get('WALLET_ID'), - key: env.get('WALLET_KEY'), - database: - env.get('STORAGE_TYPE') === 'sqlite' - ? { - type: 'sqlite', - } - : { - type: 'postgres', - config: { - host: `${env.get('POSTGRES_HOST') as string}:${String(env.get('POSTGRES_PORT'))}`, - }, - credentials: { - account: env.get('POSTGRES_USERNAME') as string, - password: env.get('POSTGRES_PASSWORD') as string, - }, - }, - }, - - inboundTransports: env.get('INBOUND_TRANSPORT'), - outboundTransports: env.get('OUTBOUND_TRANSPORT'), - - autoAcceptConnections: env.get('AUTO_ACCEPT_CONNECTIONS'), - autoAcceptCredentials: env.get('AUTO_ACCEPT_CREDENTIALS') as DidCommAutoAcceptCredential, - autoAcceptProofs: env.get('AUTO_ACCEPT_PROOFS') as DidCommAutoAcceptProof, - autoAcceptMediationRequests: env.get('AUTO_ACCEPT_MEDIATION_REQUESTS'), - ipfsOrigin: env.get('IPFS_ORIGIN'), - ipfsTimeoutMs: env.get('IPFS_TIMEOUT_MS'), +let cloudagent +try { + cloudagent = await startCloudagent(env, logger) +} catch (error) { + logger.error('Startup failed', { error }) + process.exit(1) +} - verifiedDrpcOptions: { - proofTimeoutMs: env.get('VERIFIED_DRPC_OPTIONS_PROOF_TIMEOUT_MS'), - requestTimeoutMs: env.get('VERIFIED_DRPC_OPTIONS_REQUEST_TIMEOUT_MS'), - proofRequestOptions: env.get('VERIFIED_DRPC_OPTIONS_PROOF_REQUEST_OPTIONS'), - }, - - logger, -}) - -const database = new Database({ - host: env.get('POSTGRES_HOST'), - database: env.get('DID_WEB_DB_NAME'), - user: env.get('POSTGRES_USERNAME'), - password: env.get('POSTGRES_PASSWORD'), - port: env.get('POSTGRES_PORT'), -}) -const didWebServer = new DidWebServer(logger.logger, database, { - enabled: env.get('DID_WEB_ENABLED'), - port: env.get('DID_WEB_PORT'), - useDevCert: env.get('DID_WEB_USE_DEV_CERT'), - certPath: env.get('DID_WEB_DEV_CERT_PATH'), - keyPath: env.get('DID_WEB_DEV_KEY_PATH'), - didWebDomain: env.get('DID_WEB_DOMAIN'), -}) -await didWebServer.start() - -const didWebGenerator = new DidWebDocGenerator(agent, logger.logger) -await didWebGenerator.generateAndRegister( - env.get('DID_WEB_DOMAIN'), - env.get('DID_WEB_SERVICE_ENDPOINT'), - env.get('DID_WEB_ENABLED'), - (document) => didWebServer.upsertDid(document) -) - -const socketServer = new WebSocketServer({ noServer: true }) -const zombieSockets = new WeakSet() -const interval = setInterval(() => { - logger.trace(`WebSocket PING (socket count = ${socketServer.clients.size})`) - socketServer.clients.forEach((ws: WebSocket) => { - ws.once('pong', () => { - logger.debug('WebSocket PONG') - zombieSockets.delete(ws) - }) - - if (zombieSockets.has(ws)) { - logger.warn(`Terminating dead WebSocket`) - return ws.terminate() - } +let shuttingDown = false +const shutdown = async (signal: NodeJS.Signals, exitCode: number) => { + if (shuttingDown) { + return + } - zombieSockets.add(ws) - ws.ping() - }) -}, env.get('ADMIN_PING_INTERVAL_MS')) -socketServer.on('close', () => { - clearInterval(interval) -}) + shuttingDown = true + logger.info(`Received ${signal}. Shutting down`) -const app = await setupServer(agent, logger, { - webhookUrl: env.get('WEBHOOK_URL'), - personaTitle: env.get('PERSONA_TITLE'), - personaColor: env.get('PERSONA_COLOR'), - socketServer, -}) + try { + await cloudagent!.shutdown() + process.exit(exitCode) + } catch (error) { + logger.error('Shutdown failed', { error }) + process.exit(1) + } +} -const adminPort = env.get('ADMIN_PORT') -const server = app.listen(adminPort, () => { - logger.info(`Successfully started server on port ${adminPort}`) +process.on('SIGINT', () => { + void shutdown('SIGINT', 0) }) -server.on('upgrade', (request, socket, head) => { - socketServer.handleUpgrade(request, socket as Socket, head, () => { - // incoming messages aren't expected so ignore - return - }) +process.on('SIGTERM', () => { + void shutdown('SIGTERM', 143) }) From c7ae7a7f7164586512c751c7c42b3a99602f370a Mon Sep 17 00:00:00 2001 From: rmlearney-digicatapult Date: Mon, 24 Aug 2026 12:58:06 +0100 Subject: [PATCH 02/14] bootstrap tests Signed-off-by: rmlearney-digicatapult --- tests/unit/bootstrap.test.ts | 210 +++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 tests/unit/bootstrap.test.ts diff --git a/tests/unit/bootstrap.test.ts b/tests/unit/bootstrap.test.ts new file mode 100644 index 00000000..48e331ea --- /dev/null +++ b/tests/unit/bootstrap.test.ts @@ -0,0 +1,210 @@ +import { expect } from 'chai' +import { afterEach, describe, test } from 'mocha' +import { randomUUID } from 'node:crypto' +import { createServer } from 'node:net' +import WebSocket from 'ws' + +import { startCloudagent } from '../../src/bootstrap.js' +import type { Env } from '../../src/env.js' +import PinoLogger from '../../src/utils/logger.js' +import { deleteAgentStore } from './utils/helpers.js' + +const getAvailablePort = async () => { + const server = createServer() + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => resolve()) + }) + + const address = server.address() + const port = typeof address === 'object' && address ? address.port : undefined + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + + if (!port) { + throw new Error('Unable to allocate test port') + } + + return port +} + +const createTestEnv = async (walletId?: string) => { + const adminPort = await getAvailablePort() + const didcommHttpPort = await getAvailablePort() + const didcommWsPort = await getAvailablePort() + const didWebPort = await getAvailablePort() + + const values: Record = { + LABEL: 'Test Agent', + WALLET_ID: walletId ?? randomUUID(), + WALLET_KEY: 'DZ9hPqFWTPxemcGea72C1X1nusqk5wFNLq6QPjwXGqAa', + ENDPOINT: [`http://localhost:${didcommHttpPort}`, `ws://localhost:${didcommWsPort}`], + LOG_LEVEL: 'silent', + USE_DID_SOV_PREFIX_WHERE_ALLOWED: true, + USE_DID_KEY_IN_PROTOCOLS: true, + OUTBOUND_TRANSPORT: ['http', 'ws'], + INBOUND_TRANSPORT: [ + { transport: 'http', port: didcommHttpPort }, + { transport: 'ws', port: didcommWsPort }, + ], + AUTO_ACCEPT_CONNECTIONS: true, + AUTO_ACCEPT_CREDENTIALS: 'always', + AUTO_ACCEPT_MEDIATION_REQUESTS: false, + AUTO_ACCEPT_PROOFS: 'always', + AUTO_UPDATE_STORAGE_ON_STARTUP: true, + BACKUP_BEFORE_STORAGE_UPDATE: false, + CONNECTION_IMAGE_URL: 'https://image.com/image.png', + WEBHOOK_URL: [], + ADMIN_PORT: adminPort, + ADMIN_PING_INTERVAL_MS: 1000, + IPFS_ORIGIN: 'http://localhost:5001', + IPFS_TIMEOUT_MS: 15000, + PERSONA_TITLE: 'Test Persona', + PERSONA_COLOR: 'white', + STORAGE_TYPE: 'sqlite', + POSTGRES_HOST: 'localhost', + POSTGRES_PORT: 5432, + POSTGRES_USERNAME: 'postgres', + POSTGRES_PASSWORD: 'postgres', + VERIFIED_DRPC_OPTIONS_PROOF_TIMEOUT_MS: 500, + VERIFIED_DRPC_OPTIONS_REQUEST_TIMEOUT_MS: 500, + VERIFIED_DRPC_OPTIONS_PROOF_REQUEST_OPTIONS: { + protocolVersion: 'v2', + proofFormats: { + anoncreds: { + name: 'drpc-proof-request', + version: '1.0', + requested_attributes: { + companiesHouseNumberExists: { + name: 'companiesHouseNumber', + }, + }, + }, + }, + }, + DID_WEB_SERVICE_ENDPOINT: '', + DID_WEB_ENABLED: false, + DID_WEB_PORT: didWebPort, + DID_WEB_USE_DEV_CERT: false, + DID_WEB_DEV_CERT_PATH: '', + DID_WEB_DEV_KEY_PATH: '', + DID_WEB_DB_NAME: 'did-web-server', + DID_WEB_DOMAIN: 'localhost%3A8443', + } + + const env = { + get: (key: T) => values[key], + } + + return { + env: env as unknown as Env, + ports: { + adminPort, + didcommHttpPort, + didcommWsPort, + didWebPort, + }, + } +} + +describe('startCloudagent lifecycle', () => { + const handles: Awaited>[] = [] + + afterEach(async () => { + while (handles.length > 0) { + const handle = handles.pop()! + try { + await handle.shutdown() + } catch { + // ignore errors during test cleanup + } + await deleteAgentStore(handle.agent) + } + }) + + test('should start and shutdown idempotently', async () => { + const { env } = await createTestEnv() + const handle = await startCloudagent(env, new PinoLogger('silent')) + handles.push(handle) + + expect(handle.adminServer.listening).to.equal(true) + + await handle.shutdown() + expect(handle.adminServer.listening).to.equal(false) + + // shutdown must be idempotent + await handle.shutdown() + + await deleteAgentStore(handle.agent) + handles.pop() + }) + + test('should shutdown with active didcomm websocket client', async function () { + this.timeout(15000) + + const { env, ports } = await createTestEnv() + const handle = await startCloudagent(env, new PinoLogger('silent')) + handles.push(handle) + + const client = await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${ports.didcommWsPort}`) + ws.once('open', () => resolve(ws)) + ws.once('error', (error) => reject(error)) + }) + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('shutdown timed out with connected ws client')), 5000) + }) + + await Promise.race([handle.shutdown(), timeoutPromise]) + + if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) { + client.terminate() + } + + await deleteAgentStore(handle.agent) + handles.pop() + }) + + test('should restart on the same wallet store', async () => { + const { env: env1 } = await createTestEnv() + const walletId = env1.get('WALLET_ID') + + // First start + const handle1 = await startCloudagent(env1, new PinoLogger('silent')) + handles.push(handle1) + + expect(handle1.adminServer.listening).to.equal(true) + const firstLinkSecrets = await handle1.agent.modules.anoncreds.getLinkSecretIds() + + await handle1.shutdown() + expect(handle1.adminServer.listening).to.equal(false) + + // Second start with same wallet ID, different ports + const { env: env2 } = await createTestEnv(walletId as string) + const handle2 = await startCloudagent(env2, new PinoLogger('silent')) + handles.push(handle2) + + expect(handle2.adminServer.listening).to.equal(true) + const secondLinkSecrets = await handle2.agent.modules.anoncreds.getLinkSecretIds() + + // Verify wallet data persisted across restart + expect(secondLinkSecrets.length).to.equal(firstLinkSecrets.length) + if (firstLinkSecrets.length > 0) { + expect(secondLinkSecrets[0]).to.equal(firstLinkSecrets[0]) + } + + await handle2.shutdown() + await deleteAgentStore(handle2.agent) + handles.pop() + }) +}) From 69ed5b7fa3bbbfad0d35b9482fb48afaba329211 Mon Sep 17 00:00:00 2001 From: rmlearney-digicatapult Date: Mon, 24 Aug 2026 13:00:41 +0100 Subject: [PATCH 03/14] patch version bump Signed-off-by: rmlearney-digicatapult --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index cacff96d..754640df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.35", + "version": "0.20.36", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.35", + "version": "0.20.36", "license": "Apache-2.0", "dependencies": { "@credo-ts/anoncreds": "^0.7.0", diff --git a/package.json b/package.json index 525f066c..6cf6a886 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.35", + "version": "0.20.36", "main": "build/index", "type": "module", "types": "build/index", From 2211563b2700bc67f1d01edcd8c172dbf9094caa Mon Sep 17 00:00:00 2001 From: rmlearney-digicatapult Date: Mon, 24 Aug 2026 15:22:27 +0100 Subject: [PATCH 04/14] address review comments Signed-off-by: rmlearney-digicatapult --- src/agent.ts | 30 +++++++++++++++++++----------- src/bootstrap.ts | 16 ++++++++++++++-- src/didweb/server.ts | 19 +++++++++++++------ 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/src/agent.ts b/src/agent.ts index 22486110..e2d414d9 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -244,6 +244,7 @@ export async function setupAgent(restConfig: AriesRestConfig) { } // Register inbound transports + let externalWsServerAssigned = false for (const inboundTransport of inboundTransports) { if (inboundTransport.transport === 'http') { agent.didcomm.registerInboundTransport( @@ -252,8 +253,9 @@ export async function setupAgent(restConfig: AriesRestConfig) { continue } - if (didcommWsSocketServer) { + if (didcommWsSocketServer && !externalWsServerAssigned) { agent.didcomm.registerInboundTransport(new DidCommWsInboundTransport({ server: didcommWsSocketServer })) + externalWsServerAssigned = true continue } @@ -262,19 +264,25 @@ export async function setupAgent(restConfig: AriesRestConfig) { await agent.initialize() - container.register(Agent, { useValue: agent as Agent }) + try { + container.register(Agent, { useValue: agent as Agent }) - const existingSecrets = await agent.modules.anoncreds.getLinkSecretIds() - if (existingSecrets.length === 0) { - await agent.modules.anoncreds.createLinkSecret({ - setAsDefault: true, - }) - } + const existingSecrets = await agent.modules.anoncreds.getLinkSecretIds() + if (existingSecrets.length === 0) { + await agent.modules.anoncreds.createLinkSecret({ + setAsDefault: true, + }) + } - agent.modules.verifiedDrpc.addRequestListener(verifiedDrpcRequestHandler) + agent.modules.verifiedDrpc.addRequestListener(verifiedDrpcRequestHandler) - const drpcReceiveHandler = container.resolve(DrpcReceiveHandler) - drpcReceiveHandler.start() + const drpcReceiveHandler = container.resolve(DrpcReceiveHandler) + drpcReceiveHandler.start() + } catch (error) { + // Agent is already initialized at this point; shut it down so the caller's cleanup isn't skipped. + await agent.shutdown() + throw error + } return agent } diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 0ea116c9..683bf038 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -40,13 +40,17 @@ const withTimeout = async (promise: Promise, timeoutMs: number, name: stri } const closeServer = async (server?: HttpServer) => { - if (!server) { + if (!server || !server.listening) { return } await new Promise((resolve, reject) => { server.close((error) => { if (error) { + if ((error as NodeJS.ErrnoException).code === 'ERR_SERVER_NOT_RUNNING') { + resolve() + return + } reject(error) return } @@ -99,15 +103,23 @@ export async function startCloudagent(env: Env, logger: PinoLogger): Promise transport.transport === 'ws' && typeof transport.port === 'number' ) - const didcommSocketServer = didcommWsEntry ? new WebSocketServer({ port: didcommWsEntry.port }) : undefined let agent: RestAgent | undefined let didWebServer: DidWebServer | undefined let adminServer: HttpServer | undefined let adminSocketServer: WebSocketServer | undefined + let didcommSocketServer: WebSocketServer | undefined let shuttingDownPromise: Promise | undefined try { + if (didcommWsEntry) { + didcommSocketServer = new WebSocketServer({ port: didcommWsEntry.port }) + await new Promise((resolve, reject) => { + didcommSocketServer!.once('listening', () => resolve()) + didcommSocketServer!.once('error', (error) => reject(error)) + }) + } + agent = await setupAgent({ agentConfig: { logger: logger.child({ component: 'credo-ts-agent' }), diff --git a/src/didweb/server.ts b/src/didweb/server.ts index 4d00605d..c7313478 100644 --- a/src/didweb/server.ts +++ b/src/didweb/server.ts @@ -3,6 +3,7 @@ import cors from 'cors' import express from 'express' import fs from 'fs' import type { Server } from 'http' +import * as http from 'http' import https from 'https' import { Logger } from 'pino' import { createRequestLogger } from '../utils/logger.js' @@ -92,14 +93,20 @@ export class DidWebServer { ) } this.server = https.createServer(httpsCredentials, this.app) - this.server.listen(this.config.port, () => { - this.logger.info(`DID:web server started on https port ${this.config.port}`) - }) } else { - this.server = this.app.listen(this.config.port, () => { - this.logger.info(`DID:web server started on http port ${this.config.port}`) - }) + this.server = http.createServer(this.app) } + + const server = this.server + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(this.config.port, () => { + server.removeListener('error', reject) + resolve() + }) + }) + + this.logger.info(`DID:web server started on ${this.config.useDevCert ? 'https' : 'http'} port ${this.config.port}`) } async stop(): Promise { From e2f224537edaf37698bdfc38f9088fa4334b1ca3 Mon Sep 17 00:00:00 2001 From: rmlearney-digicatapult Date: Mon, 24 Aug 2026 15:22:36 +0100 Subject: [PATCH 05/14] extend test suite Signed-off-by: rmlearney-digicatapult --- tests/unit/bootstrap.test.ts | 201 ++++++++++++++++++++++++++++++++--- 1 file changed, 189 insertions(+), 12 deletions(-) diff --git a/tests/unit/bootstrap.test.ts b/tests/unit/bootstrap.test.ts index 48e331ea..24ca7473 100644 --- a/tests/unit/bootstrap.test.ts +++ b/tests/unit/bootstrap.test.ts @@ -1,10 +1,12 @@ import { expect } from 'chai' -import { afterEach, describe, test } from 'mocha' +import { afterEach, before, describe, test } from 'mocha' import { randomUUID } from 'node:crypto' import { createServer } from 'node:net' +import { restore as sinonRestore, stub as sinonStub } from 'sinon' import WebSocket from 'ws' import { startCloudagent } from '../../src/bootstrap.js' +import DrpcReceiveHandler from '../../src/drpc-handler/index.js' import type { Env } from '../../src/env.js' import PinoLogger from '../../src/utils/logger.js' import { deleteAgentStore } from './utils/helpers.js' @@ -37,25 +39,65 @@ const getAvailablePort = async () => { return port } -const createTestEnv = async (walletId?: string) => { +const occupyPort = async (port: number) => { + const server = createServer() + + await new Promise((resolve, reject) => { + server.once('error', reject) + // Bind without a host, matching how adminServer/didcommSocketServer bind, so the conflict is real. + server.listen(port, () => resolve()) + }) + + return server +} + +const closeServer = async (server: ReturnType) => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) +} + +const connectWebSocket = async (port: number) => { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`) + ws.once('open', () => resolve(ws)) + ws.once('error', (error) => reject(error)) + }) +} + +const createTestEnv = async (walletId?: string, options?: { didWebEnabled?: boolean; secondWsPort?: number }) => { const adminPort = await getAvailablePort() const didcommHttpPort = await getAvailablePort() const didcommWsPort = await getAvailablePort() const didWebPort = await getAvailablePort() + const endpoints = [`http://localhost:${didcommHttpPort}`, `ws://localhost:${didcommWsPort}`] + const inboundTransport: Array<{ transport: string; port: number }> = [ + { transport: 'http', port: didcommHttpPort }, + { transport: 'ws', port: didcommWsPort }, + ] + + if (options?.secondWsPort) { + endpoints.push(`ws://localhost:${options.secondWsPort}`) + inboundTransport.push({ transport: 'ws', port: options.secondWsPort }) + } + const values: Record = { LABEL: 'Test Agent', WALLET_ID: walletId ?? randomUUID(), WALLET_KEY: 'DZ9hPqFWTPxemcGea72C1X1nusqk5wFNLq6QPjwXGqAa', - ENDPOINT: [`http://localhost:${didcommHttpPort}`, `ws://localhost:${didcommWsPort}`], + ENDPOINT: endpoints, LOG_LEVEL: 'silent', USE_DID_SOV_PREFIX_WHERE_ALLOWED: true, USE_DID_KEY_IN_PROTOCOLS: true, OUTBOUND_TRANSPORT: ['http', 'ws'], - INBOUND_TRANSPORT: [ - { transport: 'http', port: didcommHttpPort }, - { transport: 'ws', port: didcommWsPort }, - ], + INBOUND_TRANSPORT: inboundTransport, AUTO_ACCEPT_CONNECTIONS: true, AUTO_ACCEPT_CREDENTIALS: 'always', AUTO_ACCEPT_MEDIATION_REQUESTS: false, @@ -92,7 +134,7 @@ const createTestEnv = async (walletId?: string) => { }, }, DID_WEB_SERVICE_ENDPOINT: '', - DID_WEB_ENABLED: false, + DID_WEB_ENABLED: options?.didWebEnabled ?? false, DID_WEB_PORT: didWebPort, DID_WEB_USE_DEV_CERT: false, DID_WEB_DEV_CERT_PATH: '', @@ -117,7 +159,14 @@ const createTestEnv = async (walletId?: string) => { } describe('startCloudagent lifecycle', () => { + let logger: PinoLogger const handles: Awaited>[] = [] + // Wallets created by a startup that failed later in the sequence, reopened here for deletion. + const walletIdsToClean: string[] = [] + + before(() => { + logger = new PinoLogger('silent') + }) afterEach(async () => { while (handles.length > 0) { @@ -129,11 +178,21 @@ describe('startCloudagent lifecycle', () => { } await deleteAgentStore(handle.agent) } + + while (walletIdsToClean.length > 0) { + const walletId = walletIdsToClean.pop()! + const { env } = await createTestEnv(walletId) + const handle = await startCloudagent(env, logger) + expect(handle.adminServer.listening).to.equal(true) + await handle.shutdown() + expect(handle.adminServer.listening).to.equal(false) + await deleteAgentStore(handle.agent) + } }) test('should start and shutdown idempotently', async () => { const { env } = await createTestEnv() - const handle = await startCloudagent(env, new PinoLogger('silent')) + const handle = await startCloudagent(env, logger) handles.push(handle) expect(handle.adminServer.listening).to.equal(true) @@ -152,7 +211,7 @@ describe('startCloudagent lifecycle', () => { this.timeout(15000) const { env, ports } = await createTestEnv() - const handle = await startCloudagent(env, new PinoLogger('silent')) + const handle = await startCloudagent(env, logger) handles.push(handle) const client = await new Promise((resolve, reject) => { @@ -180,7 +239,7 @@ describe('startCloudagent lifecycle', () => { const walletId = env1.get('WALLET_ID') // First start - const handle1 = await startCloudagent(env1, new PinoLogger('silent')) + const handle1 = await startCloudagent(env1, logger) handles.push(handle1) expect(handle1.adminServer.listening).to.equal(true) @@ -188,10 +247,11 @@ describe('startCloudagent lifecycle', () => { await handle1.shutdown() expect(handle1.adminServer.listening).to.equal(false) + handles.pop() // Second start with same wallet ID, different ports const { env: env2 } = await createTestEnv(walletId as string) - const handle2 = await startCloudagent(env2, new PinoLogger('silent')) + const handle2 = await startCloudagent(env2, logger) handles.push(handle2) expect(handle2.adminServer.listening).to.equal(true) @@ -207,4 +267,121 @@ describe('startCloudagent lifecycle', () => { await deleteAgentStore(handle2.agent) handles.pop() }) + + test('should reject startup and free ports when the admin port is already in use', async function () { + this.timeout(15000) + + const { env, ports } = await createTestEnv() + const walletId = env.get('WALLET_ID') as string + const occupyingServer = await occupyPort(ports.adminPort) + + let thrownError: unknown + try { + await startCloudagent(env, logger) + } catch (error) { + thrownError = error + } finally { + await closeServer(occupyingServer) + } + + expect(thrownError).to.be.instanceOf(Error) + + // The DIDComm ws port opened during the failed attempt must have been released. + const verifyServer = await occupyPort(ports.didcommWsPort) + await closeServer(verifyServer) + + walletIdsToClean.push(walletId) + }) + + test('should reject startup and free ports when the DID:web port is already in use', async function () { + this.timeout(15000) + + const { env, ports } = await createTestEnv(undefined, { didWebEnabled: true }) + const walletId = env.get('WALLET_ID') as string + const occupyingServer = await occupyPort(ports.didWebPort) + + let thrownError: unknown + try { + await startCloudagent(env, logger) + } catch (error) { + thrownError = error + } finally { + await closeServer(occupyingServer) + } + + expect(thrownError).to.be.instanceOf(Error) + + // The admin port opened during the failed attempt must have been released. + const verifyServer = await occupyPort(ports.adminPort) + await closeServer(verifyServer) + + walletIdsToClean.push(walletId) + }) + + test('should reject startup and free ports when the DIDComm ws port is already in use', async function () { + this.timeout(15000) + + const { env, ports } = await createTestEnv() + const occupyingServer = await occupyPort(ports.didcommWsPort) + + let thrownError: unknown + try { + await startCloudagent(env, logger) + } catch (error) { + thrownError = error + } finally { + await closeServer(occupyingServer) + } + + expect(thrownError).to.be.instanceOf(Error) + + // No wallet is created when the DIDComm ws server fails to bind before setupAgent() runs. + const verifyServer = await occupyPort(ports.adminPort) + await closeServer(verifyServer) + }) + + test('should shut down the agent when setupAgent() fails after agent initialisation', async function () { + this.timeout(15000) + + const { env } = await createTestEnv() + const walletId = env.get('WALLET_ID') as string + const startStub = sinonStub(DrpcReceiveHandler.prototype, 'start').throws(new Error('drpc handler start failed')) + + let thrownError: unknown + try { + await startCloudagent(env, logger) + } catch (error) { + thrownError = error + } finally { + startStub.restore() + sinonRestore() + } + + expect(thrownError).to.be.instanceOf(Error) + expect((thrownError as Error).message).to.equal('drpc handler start failed') + + walletIdsToClean.push(walletId) + }) + + test('should register independent servers for multiple ws inbound transport entries', async function () { + this.timeout(15000) + + const secondWsPort = await getAvailablePort() + const { env, ports } = await createTestEnv(undefined, { secondWsPort }) + const handle = await startCloudagent(env, logger) + handles.push(handle) + + const firstClient = await connectWebSocket(ports.didcommWsPort) + const secondClient = await connectWebSocket(secondWsPort) + + expect(firstClient.readyState).to.equal(WebSocket.OPEN) + expect(secondClient.readyState).to.equal(WebSocket.OPEN) + + firstClient.terminate() + secondClient.terminate() + + await handle.shutdown() + await deleteAgentStore(handle.agent) + handles.pop() + }) }) From 55efbf18aa323af4167f50d1a797c2d005db02dc Mon Sep 17 00:00:00 2001 From: rmlearney-digicatapult Date: Mon, 24 Aug 2026 20:11:51 +0100 Subject: [PATCH 06/14] safer did:web server bind failure handling and tests Signed-off-by: rmlearney-digicatapult --- src/didweb/__tests__/server.test.ts | 31 ++++++++++++++ src/didweb/server.ts | 24 +++++++---- tests/unit/bootstrap.test.ts | 64 +---------------------------- tests/unit/utils/helpers.ts | 62 +++++++++++++++++++++++++++- 4 files changed, 111 insertions(+), 70 deletions(-) diff --git a/src/didweb/__tests__/server.test.ts b/src/didweb/__tests__/server.test.ts index d63afd2c..84c44e67 100644 --- a/src/didweb/__tests__/server.test.ts +++ b/src/didweb/__tests__/server.test.ts @@ -1,6 +1,7 @@ import { DidDocument } from '@credo-ts/core' import { expect } from 'chai' import sinon from 'sinon' +import { closeServer, getAvailablePort, occupyPort } from '../../../tests/unit/utils/helpers.js' import { DidWebServer, DidWebServerConfig } from '../../didweb/server.js' import PinoLogger from '../../utils/logger.js' import Database from '../db.js' @@ -58,4 +59,34 @@ describe('did:web server', () => { expect(() => server.reqPathToDid('/users/alice/did')).to.throw('Invalid DID URL path: /users/alice/did') }) }) + + describe('start/stop when the port is already in use', () => { + it('rejects start() and leaves stop() a safe no-op when the port bind fails', async () => { + const port = await getAvailablePort() + const occupyingServer = await occupyPort(port) + + const server = new DidWebServer(logger, dbMockDep, { + ...config, + enabled: true, + port, + useDevCert: false, + certPath: '', + keyPath: '', + }) + + let thrownError: unknown + try { + await server.start() + } catch (error) { + thrownError = error + } + + expect(thrownError).to.be.instanceOf(Error) + + // Must not throw ERR_SERVER_NOT_RUNNING; start() clears the server reference on bind failure. + await server.stop() + + await closeServer(occupyingServer) + }) + }) }) diff --git a/src/didweb/server.ts b/src/didweb/server.ts index c7313478..86836b0b 100644 --- a/src/didweb/server.ts +++ b/src/didweb/server.ts @@ -98,19 +98,25 @@ export class DidWebServer { } const server = this.server - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(this.config.port, () => { - server.removeListener('error', reject) - resolve() + try { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(this.config.port, () => { + server.removeListener('error', reject) + resolve() + }) }) - }) + } catch (error) { + // Bind failed before the server ever started; clear it so stop() treats this as never-started. + this.server = undefined + throw error + } this.logger.info(`DID:web server started on ${this.config.useDevCert ? 'https' : 'http'} port ${this.config.port}`) } async stop(): Promise { - if (!this.server) { + if (!this.server || !this.server.listening) { return } @@ -120,6 +126,10 @@ export class DidWebServer { await new Promise((resolve, reject) => { server.close((error) => { if (error) { + if ((error as NodeJS.ErrnoException).code === 'ERR_SERVER_NOT_RUNNING') { + resolve() + return + } reject(error) return } diff --git a/tests/unit/bootstrap.test.ts b/tests/unit/bootstrap.test.ts index 24ca7473..a68d4206 100644 --- a/tests/unit/bootstrap.test.ts +++ b/tests/unit/bootstrap.test.ts @@ -1,7 +1,6 @@ import { expect } from 'chai' import { afterEach, before, describe, test } from 'mocha' import { randomUUID } from 'node:crypto' -import { createServer } from 'node:net' import { restore as sinonRestore, stub as sinonStub } from 'sinon' import WebSocket from 'ws' @@ -9,67 +8,7 @@ import { startCloudagent } from '../../src/bootstrap.js' import DrpcReceiveHandler from '../../src/drpc-handler/index.js' import type { Env } from '../../src/env.js' import PinoLogger from '../../src/utils/logger.js' -import { deleteAgentStore } from './utils/helpers.js' - -const getAvailablePort = async () => { - const server = createServer() - - await new Promise((resolve, reject) => { - server.once('error', reject) - server.listen(0, '127.0.0.1', () => resolve()) - }) - - const address = server.address() - const port = typeof address === 'object' && address ? address.port : undefined - - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error) - return - } - resolve() - }) - }) - - if (!port) { - throw new Error('Unable to allocate test port') - } - - return port -} - -const occupyPort = async (port: number) => { - const server = createServer() - - await new Promise((resolve, reject) => { - server.once('error', reject) - // Bind without a host, matching how adminServer/didcommSocketServer bind, so the conflict is real. - server.listen(port, () => resolve()) - }) - - return server -} - -const closeServer = async (server: ReturnType) => { - await new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error) - return - } - resolve() - }) - }) -} - -const connectWebSocket = async (port: number) => { - return new Promise((resolve, reject) => { - const ws = new WebSocket(`ws://127.0.0.1:${port}`) - ws.once('open', () => resolve(ws)) - ws.once('error', (error) => reject(error)) - }) -} +import { closeServer, connectWebSocket, deleteAgentStore, getAvailablePort, occupyPort } from './utils/helpers.js' const createTestEnv = async (walletId?: string, options?: { didWebEnabled?: boolean; secondWsPort?: number }) => { const adminPort = await getAvailablePort() @@ -202,6 +141,7 @@ describe('startCloudagent lifecycle', () => { // shutdown must be idempotent await handle.shutdown() + expect(handle.adminServer.listening).to.equal(false) await deleteAgentStore(handle.agent) handles.pop() diff --git a/tests/unit/utils/helpers.ts b/tests/unit/utils/helpers.ts index 5952d125..9d3047d1 100644 --- a/tests/unit/utils/helpers.ts +++ b/tests/unit/utils/helpers.ts @@ -13,7 +13,7 @@ import { DidCommTrustPingMessage, type DidCommConnectionRecordProps, } from '@credo-ts/didcomm' -import type { Socket } from 'node:net' +import { createServer, type Socket } from 'node:net' import { DidDocument, JsonEncoder, JsonTransformer, type DidCreateResult } from '@credo-ts/core' import { randomUUID } from 'crypto' @@ -26,6 +26,66 @@ import PinoLogger from '../../../src/utils/logger.js' export type TestAgent = RestAgent +export const getAvailablePort = async () => { + const server = createServer() + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => resolve()) + }) + + const address = server.address() + const port = typeof address === 'object' && address ? address.port : undefined + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + + if (!port) { + throw new Error('Unable to allocate test port') + } + + return port +} + +export const occupyPort = async (port: number) => { + const server = createServer() + + await new Promise((resolve, reject) => { + server.once('error', reject) + // Bind without a host, matching how adminServer/didcommSocketServer bind, so the conflict is real. + server.listen(port, () => resolve()) + }) + + return server +} + +export const closeServer = async (server: ReturnType) => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + }) +} + +export const connectWebSocket = async (port: number) => { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`) + ws.once('open', () => resolve(ws)) + ws.once('error', (error) => reject(error)) + }) +} + export async function deleteAgentStore(agent: RestAgent): Promise { await agent.dependencyManager.resolve(AskarStoreManager).deleteStore(agent.context) } From 2d854e73a3d07dc2e3428e1c752c17ab0046785f Mon Sep 17 00:00:00 2001 From: rmlearney-digicatapult Date: Mon, 24 Aug 2026 21:01:25 +0100 Subject: [PATCH 07/14] drpc registration Signed-off-by: rmlearney-digicatapult --- src/agent.ts | 5 ++++- src/bootstrap.ts | 5 +++++ src/drpc-handler/index.ts | 5 ++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/agent.ts b/src/agent.ts index e2d414d9..9ad050cd 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -210,6 +210,7 @@ export async function setupAgent(restConfig: AriesRestConfig) { ipfsTimeoutMs, verifiedDrpcOptions, didcommWsSocketServer, + logger, agentConfig, askarStoreConfig, @@ -276,7 +277,9 @@ export async function setupAgent(restConfig: AriesRestConfig) { agent.modules.verifiedDrpc.addRequestListener(verifiedDrpcRequestHandler) - const drpcReceiveHandler = container.resolve(DrpcReceiveHandler) + // Construct and register explicitly to avoid resolving a stale singleton bound to a previous agent. + const drpcReceiveHandler = new DrpcReceiveHandler(agent, logger) + container.register(DrpcReceiveHandler, { useValue: drpcReceiveHandler }) drpcReceiveHandler.start() } catch (error) { // Agent is already initialized at this point; shut it down so the caller's cleanup isn't skipped. diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 683bf038..afa9a2cc 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -10,6 +10,7 @@ import { container } from 'tsyringe' import { setupAgent, type InboundTransport, type RestAgent } from './agent.js' import Database from './didweb/db.js' import { DidWebServer } from './didweb/server.js' +import DrpcReceiveHandler from './drpc-handler/index.js' import type { Env } from './env.js' import { setupServer } from './server.js' import { DidWebDocGenerator } from './utils/didWebGenerator.js' @@ -254,6 +255,9 @@ export async function startCloudagent(env: Env, logger: PinoLogger): Promise Promise } +const RECV_REQUEST_POLL_TIMEOUT_MS = 1000 + @injectable() @singleton() export default class DrpcReceiveHandler { @@ -65,7 +67,8 @@ export default class DrpcReceiveHandler { private async loop() { while (!this.stopped) { - const maybeRequest = await this.agent.modules.drpc.recvRequest() + // Timeout only bounds idle re-checking of stopped, real requests still resolve immediately + const maybeRequest = await this.agent.modules.drpc.recvRequest(RECV_REQUEST_POLL_TIMEOUT_MS) if (!maybeRequest) { continue } From 79f14f2e146a028f29370063eb559b5d88b41ba2 Mon Sep 17 00:00:00 2001 From: rmlearney-digicatapult Date: Mon, 24 Aug 2026 21:57:30 +0100 Subject: [PATCH 08/14] drpc controller singleton Signed-off-by: rmlearney-digicatapult --- src/controllers/v1/drpc/DrpcController.ts | 3 +-- tests/unit/bootstrap.test.ts | 25 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/controllers/v1/drpc/DrpcController.ts b/src/controllers/v1/drpc/DrpcController.ts index 2a0dce89..199e6b05 100644 --- a/src/controllers/v1/drpc/DrpcController.ts +++ b/src/controllers/v1/drpc/DrpcController.ts @@ -2,7 +2,7 @@ import { Agent, RecordNotFoundError, utils } from '@credo-ts/core' import type { DrpcResponseObject } from '@credo-ts/drpc' import { Body, Controller, Path, Post, Query, Request, Response, Route, Tags } from '@tsoa/runtime' import express from 'express' -import { injectable, singleton } from 'tsyringe' +import { injectable } from 'tsyringe' import { z } from 'zod' import { BadGatewayError, GatewayTimeout, NotFoundError } from '../../../error.js' @@ -34,7 +34,6 @@ const rpcResponseParser = z.object({ @Tags('Didcomm RPC') @Route('/v1/drpc') @injectable() -@singleton() export class DrpcController extends Controller { private agent: RestAgent diff --git a/tests/unit/bootstrap.test.ts b/tests/unit/bootstrap.test.ts index a68d4206..83fb9bb8 100644 --- a/tests/unit/bootstrap.test.ts +++ b/tests/unit/bootstrap.test.ts @@ -2,6 +2,7 @@ import { expect } from 'chai' import { afterEach, before, describe, test } from 'mocha' import { randomUUID } from 'node:crypto' import { restore as sinonRestore, stub as sinonStub } from 'sinon' +import { container } from 'tsyringe' import WebSocket from 'ws' import { startCloudagent } from '../../src/bootstrap.js' @@ -208,6 +209,30 @@ describe('startCloudagent lifecycle', () => { handles.pop() }) + test('should register a fresh DrpcReceiveHandler bound to the new agent on restart', async () => { + const { env: env1 } = await createTestEnv() + const walletId = env1.get('WALLET_ID') + + const handle1 = await startCloudagent(env1, logger) + handles.push(handle1) + const handler1 = container.resolve(DrpcReceiveHandler) + + await handle1.shutdown() + handles.pop() + + const { env: env2 } = await createTestEnv(walletId as string) + const handle2 = await startCloudagent(env2, logger) + handles.push(handle2) + const handler2 = container.resolve(DrpcReceiveHandler) + + // Must be a distinct instance bound to handle2's agent, not the stale singleton from handle1. + expect(handler2).to.not.equal(handler1) + + await handle2.shutdown() + await deleteAgentStore(handle2.agent) + handles.pop() + }) + test('should reject startup and free ports when the admin port is already in use', async function () { this.timeout(15000) From 08286808f2d2d373287d2bd9ec1b5f594c0049d6 Mon Sep 17 00:00:00 2001 From: "dc-autobot[bot]" <181364585+dc-autobot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:34:45 +0000 Subject: [PATCH 09/14] Updating version to 0.20.42 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 754640df..1348fceb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.36", + "version": "0.20.42", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.36", + "version": "0.20.42", "license": "Apache-2.0", "dependencies": { "@credo-ts/anoncreds": "^0.7.0", diff --git a/package.json b/package.json index 6cf6a886..7b246f85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.36", + "version": "0.20.42", "main": "build/index", "type": "module", "types": "build/index", From 440259988ec1c2f591c7e7fc1d8de453eafab2f3 Mon Sep 17 00:00:00 2001 From: "dc-autobot[bot]" <181364585+dc-autobot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:43:15 +0000 Subject: [PATCH 10/14] Updating version to 0.20.43 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1348fceb..074d31e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.42", + "version": "0.20.43", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.42", + "version": "0.20.43", "license": "Apache-2.0", "dependencies": { "@credo-ts/anoncreds": "^0.7.0", diff --git a/package.json b/package.json index 7b246f85..187d4db7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.42", + "version": "0.20.43", "main": "build/index", "type": "module", "types": "build/index", From 9f5d67d00dab2db6c633a79ef7728ad32907658e Mon Sep 17 00:00:00 2001 From: "dc-autobot[bot]" <181364585+dc-autobot[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:23:53 +0000 Subject: [PATCH 11/14] Updating version to 0.20.44 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 074d31e7..8bffc0c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.43", + "version": "0.20.44", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.43", + "version": "0.20.44", "license": "Apache-2.0", "dependencies": { "@credo-ts/anoncreds": "^0.7.0", diff --git a/package.json b/package.json index 187d4db7..f9adde49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.43", + "version": "0.20.44", "main": "build/index", "type": "module", "types": "build/index", From a5b2aaa47812924a0ff7cfe1b06569e02a357a0d Mon Sep 17 00:00:00 2001 From: "dc-autobot[bot]" <181364585+dc-autobot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:48:29 +0000 Subject: [PATCH 12/14] Updating version to 0.20.45 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8bffc0c5..398ecef3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.44", + "version": "0.20.45", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.44", + "version": "0.20.45", "license": "Apache-2.0", "dependencies": { "@credo-ts/anoncreds": "^0.7.0", diff --git a/package.json b/package.json index f9adde49..80b47560 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.44", + "version": "0.20.45", "main": "build/index", "type": "module", "types": "build/index", From 75eaa71b4709d055e74085da42bc53fdd7bfa257 Mon Sep 17 00:00:00 2001 From: "dc-autobot[bot]" <181364585+dc-autobot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:05:14 +0000 Subject: [PATCH 13/14] Updating version to 0.20.46 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 398ecef3..d3c83046 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.45", + "version": "0.20.46", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.45", + "version": "0.20.46", "license": "Apache-2.0", "dependencies": { "@credo-ts/anoncreds": "^0.7.0", diff --git a/package.json b/package.json index 80b47560..e9a0a9f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.45", + "version": "0.20.46", "main": "build/index", "type": "module", "types": "build/index", From 35c0154da05393980460fb6249c14462ad3f5945 Mon Sep 17 00:00:00 2001 From: "dc-autobot[bot]" <181364585+dc-autobot[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:53:00 +0000 Subject: [PATCH 14/14] Updating version to 0.20.47 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d3c83046..e680a5b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.46", + "version": "0.20.47", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.46", + "version": "0.20.47", "license": "Apache-2.0", "dependencies": { "@credo-ts/anoncreds": "^0.7.0", diff --git a/package.json b/package.json index e9a0a9f5..e4c27b4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@digicatapult/veritable-cloudagent", - "version": "0.20.46", + "version": "0.20.47", "main": "build/index", "type": "module", "types": "build/index",