From 9ec1747734e443fefecfb15a64652c2b70c6d841 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 31 Jul 2026 11:05:38 -0700 Subject: [PATCH 1/9] fix(auth): contain untrusted transport failures --- docs/packages/messaging/authsocket-client.md | 25 +- docs/packages/messaging/authsocket.md | 25 +- .../middleware/auth-express-middleware.md | 6 +- docs/packages/sdk/bsv-sdk.md | 6 +- docs/reference/package-api-migrations.md | 86 +++--- docs/reference/stack-facts.md | 8 +- governance/mutation-testing/policy.json | 24 +- governance/mutation-testing/targets.mjs | 40 +++ governance/package-release-notes.json | 18 +- governance/repository-health/baselines.json | 8 +- governance/test-quality/policy.json | 44 ++- packages/messaging/authsocket-client/API.md | 237 ++++++++++++++ .../messaging/authsocket-client/README.md | 24 +- .../messaging/authsocket-client/package.json | 4 +- .../authsocket-client/src/AuthSocketClient.ts | 147 +++++++-- .../src/SocketClientTransport.ts | 61 +++- .../src/__tests__/AuthSocketClient.test.ts | 61 +++- .../__tests__/SocketClientTransport.test.ts | 89 ++++++ .../__tests__/eventPayload.property.test.ts | 99 ++++++ packages/messaging/authsocket/API.md | 292 +++++++++++++----- packages/messaging/authsocket/README.md | 32 +- packages/messaging/authsocket/package.json | 5 +- .../authsocket/src/AuthSocketServer.ts | 194 +++++++++--- .../authsocket/src/SocketServerTransport.ts | 63 +++- .../src/__tests__/AuthSocket.test.ts | 51 ++- .../AuthSocketServer.lifecycle.test.ts | 7 +- .../__tests__/SocketServerTransport.test.ts | 89 ++++++ .../__tests__/eventPayload.property.test.ts | 98 ++++++ .../test/AuthSocketServer.routing.test.ts | 9 +- .../SocketServerTransport.integration.test.ts | 79 +++++ .../auth-express-middleware/package.json | 2 +- .../__tests/ExpressTransportHardening.test.ts | 19 ++ .../auth-express-middleware/src/index.ts | 52 ++-- packages/sdk/CHANGELOG.md | 5 + packages/sdk/package.json | 2 +- packages/sdk/src/auth/Peer.ts | 66 ++-- packages/sdk/src/auth/__tests/Peer.test.ts | 60 ++++ .../transports/SimplifiedFetchTransport.ts | 10 +- ...implifiedFetchTransport.additional.test.ts | 21 ++ pnpm-lock.yaml | 9 + scripts/test-governance.test.mjs | 14 +- 41 files changed, 1843 insertions(+), 348 deletions(-) create mode 100644 packages/messaging/authsocket-client/API.md create mode 100644 packages/messaging/authsocket-client/src/__tests__/eventPayload.property.test.ts create mode 100644 packages/messaging/authsocket/src/__tests__/eventPayload.property.test.ts create mode 100644 packages/messaging/authsocket/test/SocketServerTransport.integration.test.ts diff --git a/docs/packages/messaging/authsocket-client.md b/docs/packages/messaging/authsocket-client.md index 36b43eebb..2c289b210 100644 --- a/docs/packages/messaging/authsocket-client.md +++ b/docs/packages/messaging/authsocket-client.md @@ -3,10 +3,10 @@ id: pkg-authsocket-client title: '@bsv/authsocket-client' kind: package domain: messaging -version: '2.1.3' +version: '2.1.4' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-07-30' -last_verified: '2026-07-30' +last_updated: '2026-07-31' +last_verified: '2026-07-31' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/authsocket-client' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client' @@ -58,6 +58,8 @@ socket.on('disconnect', () => { - **Transparent proxying** — User code sees normal Socket.IO API; BRC-103 hidden - **Certificate exchange** — Supports verifiable certificates during handshake (optional) - **Standard Socket.IO interface** — `.on()`, `.emit()`, `.id`, `.connect()`, `.disconnect()` +- **Failure isolation** — Authentication and callback failures disconnect the affected connection +- **Bounded ingress** — Authentication concurrency defaults to 32 and is configurable ## Common patterns @@ -96,6 +98,23 @@ const socket = AuthSocketClient('http://localhost:3000', { }) ``` +### Failure reporting and authentication bounds + +```typescript +const socket = AuthSocketClient('http://localhost:3000', { + wallet, + maxPendingAuthMessages: 32, + onError: (error, context) => { + console.error(context.phase, context.eventName, error) + } +}) +``` + +Malformed server authentication traffic and application callback failures are +contained before they can become unhandled rejections. The error context does +not include remote payloads or wallet material, and an `onError` handler that +throws or rejects is also contained. + ## Key concepts - **BRC-103 mutual authentication** — Nonce-based challenge-response protocol diff --git a/docs/packages/messaging/authsocket.md b/docs/packages/messaging/authsocket.md index 8d66ad6ee..56900549b 100644 --- a/docs/packages/messaging/authsocket.md +++ b/docs/packages/messaging/authsocket.md @@ -3,10 +3,10 @@ id: pkg-authsocket title: '@bsv/authsocket' kind: package domain: messaging -version: '2.1.4' +version: '2.1.5' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-07-30' -last_verified: '2026-07-30' +last_updated: '2026-07-31' +last_verified: '2026-07-31' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/authsocket' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket' @@ -60,6 +60,8 @@ server.listen(3000) - **Message signing** — Every message auto-signed with server wallet; every inbound message verified - **Automatic re-dispatch** — Special `'authMessage'` channel for BRC-103 frames; user code sees normal Socket.IO events - **Graceful lifecycle** — Idempotent `close()` disconnects clients and closes the attached HTTP server +- **Failure isolation** — Authentication and callback failures disconnect only the offending socket +- **Bounded ingress** — Per-socket authentication concurrency defaults to 32 and is configurable ## Common patterns @@ -100,6 +102,23 @@ process.once('SIGTERM', () => { `close()` is idempotent. Socket.IO disconnects active clients before closing the HTTP server supplied to `AuthSocketServer`. +### Failure reporting and authentication bounds + +```typescript +const io = new AuthSocketServer(server, { + wallet, + maxPendingAuthMessages: 32, + onError: (error, context) => { + console.error(context.phase, context.socketId, error) + } +}) +``` + +Authentication, connection, and application callback failures are contained +and disconnect only the affected socket. The error context identifies the +phase and socket without including remote payloads or wallet material. An +`onError` handler that throws or rejects is also contained. + ### Receiving authenticated messages ```typescript diff --git a/docs/packages/middleware/auth-express-middleware.md b/docs/packages/middleware/auth-express-middleware.md index aa806f445..b5ef28782 100644 --- a/docs/packages/middleware/auth-express-middleware.md +++ b/docs/packages/middleware/auth-express-middleware.md @@ -3,10 +3,10 @@ id: pkg-auth-express-middleware title: '@bsv/auth-express-middleware' kind: package domain: middleware -version: '2.1.5' +version: '2.1.6' source_repo: 'bsv-blockchain/ts-stack' -last_updated: '2026-07-30' -last_verified: '2026-07-30' +last_updated: '2026-07-31' +last_verified: '2026-07-31' review_cadence_days: 30 npm: 'https://www.npmjs.com/package/@bsv/auth-express-middleware' repo: 'https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware' diff --git a/docs/packages/sdk/bsv-sdk.md b/docs/packages/sdk/bsv-sdk.md index a21adbca5..d67385b3c 100644 --- a/docs/packages/sdk/bsv-sdk.md +++ b/docs/packages/sdk/bsv-sdk.md @@ -3,10 +3,10 @@ id: bsv-sdk title: '@bsv/sdk' kind: package domain: sdk -version: '2.2.15' +version: '2.2.16' npm: '@bsv/sdk' -last_updated: '2026-07-30' -last_verified: '2026-07-30' +last_updated: '2026-07-31' +last_verified: '2026-07-31' review_cadence_days: 30 status: stable tags: ['sdk', 'crypto', 'transactions'] diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 2a090f29e..3ab75e2d6 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -3,8 +3,8 @@ id: package-api-migrations title: 'Package API, Declarations, and Migration Ledger' kind: reference version: '1.0.0' -last_updated: '2026-07-30' -last_verified: '2026-07-30' +last_updated: '2026-07-31' +last_verified: '2026-07-31' review_cadence_days: 30 status: stable tags: [reference, packages, api, declarations, migrations, release-notes] @@ -23,39 +23,39 @@ and clean-consumer tests remain the executable type authority. ## Current release boundary -| Package | npm baseline | Source | Candidate | API | Migration | -| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `@bsv/402-pay` | `0.2.1` | `0.2.4` | patch | [API and usage](../packages/middleware/402-pay.md) | No consumer migration is required; client and server exports, payment protocol behavior, and runtime defaults are unchanged. | -| `@bsv/air-gap` | `0.0.0` | `0.1.1` | minor | [API and usage](../packages/helpers/air-gap.md) | No consumer migration is required; this is the first published release of a new package with no prior public API. The experimental pre-release framing that circulated on the unmerged feature branch is not accepted by the v1 decoder. | -| `@bsv/amountinator` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/helpers/amountinator.md) | No consumer migration is required; this is a backward-compatible patch candidate. | -| `@bsv/auth` | `0.1.1` | `0.1.3` | patch | [API and usage](../packages/middleware/auth.md) | No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. | -| `@bsv/auth-express-middleware` | `2.1.2` | `2.1.5` | patch | [API and usage](../packages/middleware/auth-express-middleware.md) | No consumer migration is required; existing public CORS defaults and middleware APIs are retained. | -| `@bsv/authsocket` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/messaging/authsocket.md) | No existing behavior changes automatically; service owners can call await server.close() during graceful shutdown. | -| `@bsv/authsocket-client` | `2.1.1` | `2.1.3` | patch | [API and usage](../packages/messaging/authsocket-client.md) | No consumer migration is required; client APIs, authenticated socket behavior, and supported imports are unchanged. | -| `@bsv/btms` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/wallet/btms.md) | No consumer migration is required; token and lookup wire contracts are unchanged. | -| `@bsv/btms-permission-module` | `1.1.1` | `1.1.3` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | No consumer migration is required; permission-module APIs and token semantics are unchanged. | -| `@bsv/did` | `0.2.1` | `0.2.4` | patch | [API and usage](../packages/helpers/did.md) | No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. | -| `@bsv/did-client` | `1.2.1` | `1.2.3` | patch | [API and usage](../packages/helpers/did-client.md) | No consumer migration is required; DID client APIs, encodings, and supported imports are unchanged. | -| `@bsv/fund-wallet` | `1.4.1` | `1.4.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No consumer migration is required; wallet funding APIs and transaction behavior are unchanged. | -| `@bsv/gasp` | `1.3.1` | `1.3.5` | patch | [API and usage](../packages/overlays/gasp.md) | No consumer migration is required; existing constructor calls, imports, synchronization behavior, and wire semantics are unchanged. | -| `@bsv/message-box-client` | `2.2.2` | `2.2.6` | patch | [API and usage](../packages/messaging/message-box-client.md) | No consumer migration is required; Message Box protocol and client entry points are unchanged. | -| `@bsv/overlay` | `2.2.1` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay.md) | No consumer migration is required; existing imports, submission results, notification contracts, storage order, and network behavior remain unchanged. | -| `@bsv/overlay-discovery-services` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No consumer migration is required; discovery records and public network behavior are unchanged. | -| `@bsv/overlay-express` | `2.4.2` | `2.4.9` | patch | [API and usage](../packages/overlays/overlay-express.md) | No consumer migration is required; wildcard credential-free public access remains the default and runtimes may opt into the new close method. | -| `@bsv/overlay-topics` | `1.6.1` | `1.6.8` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No consumer migration is required; topic IDs, lookup contracts, and persisted formats are unchanged. | -| `@bsv/paymail` | `2.4.2` | `2.4.5` | patch | [API and usage](../packages/messaging/paymail.md) | No consumer migration is required; existing Paymail client APIs and protocol semantics are retained. | -| `@bsv/payment-express-middleware` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No consumer migration is required; legacy x-bsv-payment JSON behavior remains supported. | -| `@bsv/sdk` | `2.2.0` | `2.2.15` | patch | [API and usage](../packages/sdk/bsv-sdk.md) | No consumer migration is required; the source candidate preserves the 2.x public API, wire encodings, script semantics, errors, and supported import forms. | -| `@bsv/simple` | `0.4.1` | `0.4.8` | patch | [API and usage](../packages/helpers/simple.md) | No consumer migration is required; the browser and server entry points remain compatible. | -| `@bsv/templates` | `1.9.1` | `1.9.6` | patch | [API and usage](../packages/helpers/templates.md) | No consumer migration is required; template APIs, supported imports, and generated script semantics are unchanged. | -| `@bsv/teranode-listener` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. | -| `@bsv/verifast` | `0.3.0` | `0.3.4` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. | -| `@bsv/wallet-helper` | `0.1.1` | `0.1.6` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. | -| `@bsv/wallet-relay` | `0.2.2` | `0.3.3` | minor | [API and usage](../packages/wallet/wallet-relay.md) | QRPairingCode now renders a native button and accepts button wrapper attributes. Existing className, style, data, and ARIA props continue to work; update div-specific wrapper selectors or explicitly typed div event handlers. | -| `@bsv/wallet-toolbox` | `2.4.4` | `2.4.20` | patch | [API and usage](../packages/wallet/wallet-toolbox.md) | No consumer migration is required; persisted schemas, provider behavior, transaction construction, monitor behavior, and the 2.x wallet and storage interfaces remain compatible. | -| `@bsv/wallet-toolbox-client` | `2.4.4` | `2.4.20` | patch | [API and usage](../packages/wallet/wallet-toolbox-client.md) | No consumer migration is required; client entry points and remote storage contracts remain compatible. | -| `@bsv/wallet-toolbox-mobile` | `2.4.4` | `2.4.20` | patch | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | No consumer migration is required; React Native and mobile bridge contracts remain compatible. | -| `create-bsv-app` | `1.0.2` | `1.0.4` | patch | [API and usage](../packages/helpers/create-bsv-app.md) | No consumer migration is required; generated application structure and CLI behavior are unchanged. | +| Package | npm baseline | Source | Candidate | API | Migration | +| --------------------------------- | ------------ | -------- | --------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@bsv/402-pay` | `0.2.1` | `0.2.4` | patch | [API and usage](../packages/middleware/402-pay.md) | No consumer migration is required; client and server exports, payment protocol behavior, and runtime defaults are unchanged. | +| `@bsv/air-gap` | `0.0.0` | `0.1.1` | minor | [API and usage](../packages/helpers/air-gap.md) | No consumer migration is required; this is the first published release of a new package with no prior public API. The experimental pre-release framing that circulated on the unmerged feature branch is not accepted by the v1 decoder. | +| `@bsv/amountinator` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/helpers/amountinator.md) | No consumer migration is required; this is a backward-compatible patch candidate. | +| `@bsv/auth` | `0.1.1` | `0.1.3` | patch | [API and usage](../packages/middleware/auth.md) | No consumer migration is required; authentication APIs, wire behavior, and runtime defaults are unchanged. | +| `@bsv/auth-express-middleware` | `2.1.2` | `2.1.6` | patch | [API and usage](../packages/middleware/auth-express-middleware.md) | No consumer migration is required; existing public CORS defaults and middleware APIs are retained, while authentication callback failures now produce a controlled HTTP error. | +| `@bsv/authsocket` | `2.1.1` | `2.1.5` | patch | [API and usage](../packages/messaging/authsocket.md) | Valid traffic and the wire contract are unchanged. A socket is now disconnected when its authentication processing exceeds the concurrency limit or a callback fails; use onError for diagnostics and maxPendingAuthMessages to tune the default limit of 32. | +| `@bsv/authsocket-client` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/messaging/authsocket-client.md) | Valid traffic and supported imports are unchanged. The client now disconnects from a server that causes authentication failure or exceeds the concurrency limit; use onError for diagnostics and maxPendingAuthMessages to tune the default limit of 32. | +| `@bsv/btms` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/wallet/btms.md) | No consumer migration is required; token and lookup wire contracts are unchanged. | +| `@bsv/btms-permission-module` | `1.1.1` | `1.1.3` | patch | [API and usage](../packages/wallet/btms-permission-module.md) | No consumer migration is required; permission-module APIs and token semantics are unchanged. | +| `@bsv/did` | `0.2.1` | `0.2.4` | patch | [API and usage](../packages/helpers/did.md) | No consumer migration is required; DID APIs, encodings, credential behavior, and supported import forms are unchanged. | +| `@bsv/did-client` | `1.2.1` | `1.2.3` | patch | [API and usage](../packages/helpers/did-client.md) | No consumer migration is required; DID client APIs, encodings, and supported imports are unchanged. | +| `@bsv/fund-wallet` | `1.4.1` | `1.4.3` | patch | [API and usage](../packages/helpers/fund-wallet.md) | No consumer migration is required; wallet funding APIs and transaction behavior are unchanged. | +| `@bsv/gasp` | `1.3.1` | `1.3.5` | patch | [API and usage](../packages/overlays/gasp.md) | No consumer migration is required; existing constructor calls, imports, synchronization behavior, and wire semantics are unchanged. | +| `@bsv/message-box-client` | `2.2.2` | `2.2.6` | patch | [API and usage](../packages/messaging/message-box-client.md) | No consumer migration is required; Message Box protocol and client entry points are unchanged. | +| `@bsv/overlay` | `2.2.1` | `2.2.7` | patch | [API and usage](../packages/overlays/overlay.md) | No consumer migration is required; existing imports, submission results, notification contracts, storage order, and network behavior remain unchanged. | +| `@bsv/overlay-discovery-services` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/overlays/overlay-discovery-services.md) | No consumer migration is required; discovery records and public network behavior are unchanged. | +| `@bsv/overlay-express` | `2.4.2` | `2.4.9` | patch | [API and usage](../packages/overlays/overlay-express.md) | No consumer migration is required; wildcard credential-free public access remains the default and runtimes may opt into the new close method. | +| `@bsv/overlay-topics` | `1.6.1` | `1.6.8` | patch | [API and usage](../packages/overlays/overlay-topics.md) | No consumer migration is required; topic IDs, lookup contracts, and persisted formats are unchanged. | +| `@bsv/paymail` | `2.4.2` | `2.4.5` | patch | [API and usage](../packages/messaging/paymail.md) | No consumer migration is required; existing Paymail client APIs and protocol semantics are retained. | +| `@bsv/payment-express-middleware` | `2.1.1` | `2.1.4` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No consumer migration is required; legacy x-bsv-payment JSON behavior remains supported. | +| `@bsv/sdk` | `2.2.0` | `2.2.16` | patch | [API and usage](../packages/sdk/bsv-sdk.md) | The 2.x wire encodings and supported imports are unchanged. Peer listeners may continue returning void and may now return Promise; rejected listeners propagate to the owning transport for containment. | +| `@bsv/simple` | `0.4.1` | `0.4.8` | patch | [API and usage](../packages/helpers/simple.md) | No consumer migration is required; the browser and server entry points remain compatible. | +| `@bsv/templates` | `1.9.1` | `1.9.6` | patch | [API and usage](../packages/helpers/templates.md) | No consumer migration is required; template APIs, supported imports, and generated script semantics are unchanged. | +| `@bsv/teranode-listener` | `1.1.1` | `1.1.4` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. | +| `@bsv/verifast` | `0.3.0` | `0.3.4` | patch | [API and usage](../packages/sdk/verifast.md) | No consumer migration is required; exports, verification behavior, worker protocols, package paths, and runtime defaults are unchanged. | +| `@bsv/wallet-helper` | `0.1.1` | `0.1.6` | patch | [API and usage](../packages/helpers/wallet-helper.md) | No consumer migration is required; fluent builder APIs and transaction semantics are unchanged. | +| `@bsv/wallet-relay` | `0.2.2` | `0.3.3` | minor | [API and usage](../packages/wallet/wallet-relay.md) | QRPairingCode now renders a native button and accepts button wrapper attributes. Existing className, style, data, and ARIA props continue to work; update div-specific wrapper selectors or explicitly typed div event handlers. | +| `@bsv/wallet-toolbox` | `2.4.4` | `2.4.20` | patch | [API and usage](../packages/wallet/wallet-toolbox.md) | No consumer migration is required; persisted schemas, provider behavior, transaction construction, monitor behavior, and the 2.x wallet and storage interfaces remain compatible. | +| `@bsv/wallet-toolbox-client` | `2.4.4` | `2.4.20` | patch | [API and usage](../packages/wallet/wallet-toolbox-client.md) | No consumer migration is required; client entry points and remote storage contracts remain compatible. | +| `@bsv/wallet-toolbox-mobile` | `2.4.4` | `2.4.20` | patch | [API and usage](../packages/wallet/wallet-toolbox-mobile.md) | No consumer migration is required; React Native and mobile bridge contracts remain compatible. | +| `create-bsv-app` | `1.0.2` | `1.0.4` | patch | [API and usage](../packages/helpers/create-bsv-app.md) | No consumer migration is required; generated application structure and CLI behavior are unchanged. | `none` means the source manifest matches the recorded npm baseline. Any other value is an unpublished candidate. Publication, tags, releases, registry @@ -114,8 +114,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/middleware/auth-express-middleware.md](../packages/middleware/auth-express-middleware.md) - Source: [packages/middleware/auth-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware) -- Release note: Standardizes package quality and strengthens authenticated Express session, edge-policy, and error-handling boundaries. -- Migration: No consumer migration is required; existing public CORS defaults and middleware APIs are retained. +- Release note: Standardizes package quality and strengthens authenticated Express session, edge-policy, and error-handling boundaries, including containment of both synchronous and asynchronous BRC-103 callback failures. +- Migration: No consumer migration is required; existing public CORS defaults and middleware APIs are retained, while authentication callback failures now produce a controlled HTTP error. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ------------------------------------ | ---------------------------------------- | @@ -126,8 +126,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/messaging/authsocket.md](../packages/messaging/authsocket.md) - Source: [packages/messaging/authsocket](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket) -- Release note: Adds an idempotent server shutdown API that closes authenticated sockets and the attached HTTP listener. -- Migration: No existing behavior changes automatically; service owners can call await server.close() during graceful shutdown. +- Release note: Contains authentication and application callback failures to the offending socket, caps per-socket authentication concurrency, validates authenticated event envelopes, adds safe error reporting, and adds randomized and real-Socket.IO process-survival regression coverage. +- Migration: Valid traffic and the wire contract are unchanged. A socket is now disconnected when its authentication processing exceeds the concurrency limit or a callback fails; use onError for diagnostics and maxPendingAuthMessages to tune the default limit of 32. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ------------------------------------ | ---------------------------------------- | @@ -138,8 +138,8 @@ explicitly authorized operations. - Package documentation: [docs/packages/messaging/authsocket-client.md](../packages/messaging/authsocket-client.md) - Source: [packages/messaging/authsocket-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client) -- Release note: Adopts the governed strict TypeScript profile and repository-wide zero-warning lint and formatting contract. -- Migration: No consumer migration is required; client APIs, authenticated socket behavior, and supported imports are unchanged. +- Release note: Contains authentication and application callback failures, caps authentication concurrency, validates authenticated event envelopes, adds safe error reporting, and registers the remote payload boundary for randomized and mutation testing. +- Migration: Valid traffic and supported imports are unchanged. The client now disconnects from a server that causes authentication failure or exceeds the concurrency limit; use onError for diagnostics and maxPendingAuthMessages to tune the default limit of 32. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ----------------------------------- | --------------------------------------- | @@ -316,8 +316,8 @@ CLI entry points: `{"fund-metanet":"./dist/index.mjs"}`. - Package documentation: [docs/packages/sdk/bsv-sdk.md](../packages/sdk/bsv-sdk.md) - Source: [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) -- Release note: Accumulates security and correctness hardening, transaction and action-batch performance work, strict package contracts, safer text and telemetry handling, behavior-preserving maintainability remediation across cryptographic, transaction, remittance, lookup, script-interpreter, broadcaster, and wallet-wire paths, and canonical root contribution guidance. -- Migration: No consumer migration is required; the source candidate preserves the 2.x public API, wire encodings, script semantics, errors, and supported import forms. +- Release note: Accumulates security and correctness hardening, transaction and action-batch performance work, strict package contracts, safer text and telemetry handling, behavior-preserving maintainability remediation across cryptographic, transaction, remittance, lookup, script-interpreter, broadcaster, and wallet-wire paths, and hardens BRC-103 message and async listener boundaries. +- Migration: The 2.x wire encodings and supported imports are unchanged. Peer listeners may continue returning void and may now return Promise; rejected listeners propagate to the owning transport for containment. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index dee2f074e..46188582d 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -46,13 +46,13 @@ authorized release action. | helpers | `@bsv/templates` | `1.9.6` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/helpers/ts-templates](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/ts-templates) | | helpers | `@bsv/wallet-helper` | `0.1.6` | node-library | node-cjs, node-esm | node | `>=22` | [packages/helpers/bsv-wallet-helper](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/bsv-wallet-helper) | | helpers | `create-bsv-app` | `1.0.4` | cli | cli | node | `>=22` | [packages/helpers/create-bsv-app](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/helpers/create-bsv-app) | -| messaging | `@bsv/authsocket` | `2.1.4` | node-library | node-cjs, node-esm | node | `>=22` | [packages/messaging/authsocket](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket) | -| messaging | `@bsv/authsocket-client` | `2.1.3` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/authsocket-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client) | +| messaging | `@bsv/authsocket` | `2.1.5` | node-library | node-cjs, node-esm | node | `>=22` | [packages/messaging/authsocket](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket) | +| messaging | `@bsv/authsocket-client` | `2.1.4` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/authsocket-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/authsocket-client) | | messaging | `@bsv/message-box-client` | `2.2.6` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/messaging/message-box-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client) | | messaging | `@bsv/paymail` | `2.4.5` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/messaging/ts-paymail](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/ts-paymail) | | middleware | `@bsv/402-pay` | `0.2.4` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/middleware/402-pay](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/402-pay) | | middleware | `@bsv/auth` | `0.1.3` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/auth](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth) | -| middleware | `@bsv/auth-express-middleware` | `2.1.5` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/auth-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware) | +| middleware | `@bsv/auth-express-middleware` | `2.1.6` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/auth-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth-express-middleware) | | middleware | `@bsv/payment-express-middleware` | `2.1.4` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/payment-express-middleware](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/payment-express-middleware) | | network | `@bsv/teranode-listener` | `1.1.4` | node-library | node-esm | node | `>=22` | [packages/network/ts-p2p](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/network/ts-p2p) | | overlays | `@bsv/gasp` | `1.3.5` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm | browser, node | `>=22` | [packages/overlays/gasp-core](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/gasp-core) | @@ -60,7 +60,7 @@ authorized release action. | overlays | `@bsv/overlay-discovery-services` | `2.1.6` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-discovery-services](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-discovery-services) | | overlays | `@bsv/overlay-express` | `2.4.9` | node-library | node-cjs, node-esm | node | `>=22` | [packages/overlays/overlay-express](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/overlay-express) | | overlays | `@bsv/overlay-topics` | `1.6.8` | node-library | node-esm | node | `>=22` | [packages/overlays/topics](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/overlays/topics) | -| sdk | `@bsv/sdk` | `2.2.15` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) | +| sdk | `@bsv/sdk` | `2.2.16` | browser-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global | browser, node, umd | `>=22` | [packages/sdk](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk) | | sdk | `@bsv/verifast` | `0.3.4` | wasm-library | browser-bundler, browser-esm, node-cjs, node-esm, umd-global, wasm-worker | browser, node, umd, wasm, worker | `>=22` | [packages/verifast](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/verifast) | | wallet | `@bsv/btms` | `1.1.4` | node-library | node-cjs, node-esm | node | `>=22` | [packages/wallet/btms](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms) | | wallet | `@bsv/btms-permission-module` | `1.1.3` | node-library | node-esm | node | `>=22` | [packages/wallet/btms-permission-module](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/wallet/btms-permission-module) | diff --git a/governance/mutation-testing/policy.json b/governance/mutation-testing/policy.json index 0e83538ef..26cf14b97 100644 --- a/governance/mutation-testing/policy.json +++ b/governance/mutation-testing/policy.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "lastReviewed": "2026-07-26", - "reviewBy": "2026-08-26", + "lastReviewed": "2026-07-31", + "reviewBy": "2026-08-31", "owner": "ts-stack-maintainers", "tool": { "package": "@stryker-mutator/core", @@ -117,6 +117,26 @@ "maximumNoCoverage": 0, "maximumInvalid": 0 }, + { + "id": "authsocket-server-boundary", + "manifest": "packages/messaging/authsocket/package.json", + "propertyTest": "packages/messaging/authsocket/src/__tests__/eventPayload.property.test.ts", + "risk": "critical", + "boundary": "Server-side BRC-103 Socket.IO authentication and authenticated event payload ingress", + "minimumScore": 89, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, + { + "id": "authsocket-client-boundary", + "manifest": "packages/messaging/authsocket-client/package.json", + "propertyTest": "packages/messaging/authsocket-client/src/__tests__/eventPayload.property.test.ts", + "risk": "critical", + "boundary": "Client-side BRC-103 Socket.IO authentication and authenticated event payload ingress", + "minimumScore": 87, + "maximumNoCoverage": 0, + "maximumInvalid": 0 + }, { "id": "wallet-pairing", "manifest": "packages/wallet/ts-wallet-relay/package.json", diff --git a/governance/mutation-testing/targets.mjs b/governance/mutation-testing/targets.mjs index 54eb4eb86..c8637b105 100644 --- a/governance/mutation-testing/targets.mjs +++ b/governance/mutation-testing/targets.mjs @@ -168,6 +168,46 @@ export function buildMutationTargets(repositoryRoot) { mutate: ['src/host.ts'], ...jestTarget('jest.config.ts', ['/src/__tests/host*.test.ts'], { esm: true }) }, + 'authsocket-server-boundary': { + packageDirectory: 'packages/messaging/authsocket', + manifest: 'packages/messaging/authsocket/package.json', + propertyTest: 'packages/messaging/authsocket/src/__tests__/eventPayload.property.test.ts', + mutate: [ + 'src/SocketServerTransport.ts', + sourceLineRange( + repositoryRoot, + 'packages/messaging/authsocket', + 'src/AuthSocketServer.ts', + 'export function decodeAuthSocketEventPayload(', + 'export interface AuthSocketServerOptions' + ) + ], + ...jestTarget('jest.config.js', [ + '/src/__tests__/eventPayload.property.test.ts', + '/src/__tests__/SocketServerTransport.test.ts', + '/test/SocketServerTransport.integration.test.ts' + ]) + }, + 'authsocket-client-boundary': { + packageDirectory: 'packages/messaging/authsocket-client', + manifest: 'packages/messaging/authsocket-client/package.json', + propertyTest: + 'packages/messaging/authsocket-client/src/__tests__/eventPayload.property.test.ts', + mutate: [ + 'src/SocketClientTransport.ts', + sourceLineRange( + repositoryRoot, + 'packages/messaging/authsocket-client', + 'src/AuthSocketClient.ts', + 'export function decodeAuthSocketEventPayload(', + 'export interface AuthSocketClientOptions' + ) + ], + ...jestTarget('jest.config.js', [ + '/src/__tests__/eventPayload.property.test.ts', + '/src/__tests__/SocketClientTransport.test.ts' + ]) + }, 'wallet-pairing': { packageDirectory: 'packages/wallet/ts-wallet-relay', manifest: 'packages/wallet/ts-wallet-relay/package.json', diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index 2bce453c8..55fd7d27a 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "lastReviewed": "2026-07-30", + "lastReviewed": "2026-07-31", "owner": "ts-stack-maintainers", "entries": [ { @@ -35,22 +35,22 @@ "name": "@bsv/auth-express-middleware", "publishedVersion": "2.1.2", "releaseType": "patch", - "summary": "Standardizes package quality and strengthens authenticated Express session, edge-policy, and error-handling boundaries.", - "migration": "No consumer migration is required; existing public CORS defaults and middleware APIs are retained." + "summary": "Standardizes package quality and strengthens authenticated Express session, edge-policy, and error-handling boundaries, including containment of both synchronous and asynchronous BRC-103 callback failures.", + "migration": "No consumer migration is required; existing public CORS defaults and middleware APIs are retained, while authentication callback failures now produce a controlled HTTP error." }, { "name": "@bsv/authsocket", "publishedVersion": "2.1.1", "releaseType": "patch", - "summary": "Adds an idempotent server shutdown API that closes authenticated sockets and the attached HTTP listener.", - "migration": "No existing behavior changes automatically; service owners can call await server.close() during graceful shutdown." + "summary": "Contains authentication and application callback failures to the offending socket, caps per-socket authentication concurrency, validates authenticated event envelopes, adds safe error reporting, and adds randomized and real-Socket.IO process-survival regression coverage.", + "migration": "Valid traffic and the wire contract are unchanged. A socket is now disconnected when its authentication processing exceeds the concurrency limit or a callback fails; use onError for diagnostics and maxPendingAuthMessages to tune the default limit of 32." }, { "name": "@bsv/authsocket-client", "publishedVersion": "2.1.1", "releaseType": "patch", - "summary": "Adopts the governed strict TypeScript profile and repository-wide zero-warning lint and formatting contract.", - "migration": "No consumer migration is required; client APIs, authenticated socket behavior, and supported imports are unchanged." + "summary": "Contains authentication and application callback failures, caps authentication concurrency, validates authenticated event envelopes, adds safe error reporting, and registers the remote payload boundary for randomized and mutation testing.", + "migration": "Valid traffic and supported imports are unchanged. The client now disconnects from a server that causes authentication failure or exceeds the concurrency limit; use onError for diagnostics and maxPendingAuthMessages to tune the default limit of 32." }, { "name": "@bsv/btms", @@ -147,8 +147,8 @@ "name": "@bsv/sdk", "publishedVersion": "2.2.0", "releaseType": "patch", - "summary": "Accumulates security and correctness hardening, transaction and action-batch performance work, strict package contracts, safer text and telemetry handling, behavior-preserving maintainability remediation across cryptographic, transaction, remittance, lookup, script-interpreter, broadcaster, and wallet-wire paths, and canonical root contribution guidance.", - "migration": "No consumer migration is required; the source candidate preserves the 2.x public API, wire encodings, script semantics, errors, and supported import forms." + "summary": "Accumulates security and correctness hardening, transaction and action-batch performance work, strict package contracts, safer text and telemetry handling, behavior-preserving maintainability remediation across cryptographic, transaction, remittance, lookup, script-interpreter, broadcaster, and wallet-wire paths, and hardens BRC-103 message and async listener boundaries.", + "migration": "The 2.x wire encodings and supported imports are unchanged. Peer listeners may continue returning void and may now return Promise; rejected listeners propagate to the owning transport for containment." }, { "name": "@bsv/simple", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index ca3001428..c4499ccfb 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -306,13 +306,13 @@ "@bsv/fund-wallet": "1.4.3", "@bsv/simple": "0.4.8", "@bsv/templates": "1.9.6", - "@bsv/authsocket": "2.1.4", - "@bsv/authsocket-client": "2.1.3", + "@bsv/authsocket": "2.1.5", + "@bsv/authsocket-client": "2.1.4", "@bsv/message-box-client": "2.2.6", "@bsv/paymail": "2.4.5", "@bsv/402-pay": "0.2.4", "@bsv/auth": "0.1.3", - "@bsv/auth-express-middleware": "2.1.5", + "@bsv/auth-express-middleware": "2.1.6", "@bsv/payment-express-middleware": "2.1.4", "@bsv/teranode-listener": "1.1.4", "@bsv/gasp": "1.3.5", @@ -320,7 +320,7 @@ "@bsv/overlay-discovery-services": "2.1.6", "@bsv/overlay-express": "2.4.9", "@bsv/overlay-topics": "1.6.8", - "@bsv/sdk": "2.2.15", + "@bsv/sdk": "2.2.16", "@bsv/verifast": "0.3.4", "@bsv/btms": "1.1.4", "@bsv/btms-permission-module": "1.1.3", diff --git a/governance/test-quality/policy.json b/governance/test-quality/policy.json index 93e89448d..db1540933 100644 --- a/governance/test-quality/policy.json +++ b/governance/test-quality/policy.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "lastReviewed": "2026-07-26", + "lastReviewed": "2026-07-31", "ownerDefinitions": ["ts-stack-maintainers"], "propertyTesting": { "library": "fast-check", @@ -23,6 +23,8 @@ "packages/middleware/auth/package.json", "packages/overlays/overlay-express/package.json", "packages/messaging/message-box-client/package.json", + "packages/messaging/authsocket/package.json", + "packages/messaging/authsocket-client/package.json", "packages/wallet/ts-wallet-relay/package.json", "packages/overlays/overlay-discovery-services/package.json", "packages/overlays/overlay/package.json", @@ -170,6 +172,32 @@ "Arbitrary advertisements never throw at the tolerant boundary." ] }, + { + "path": "packages/messaging/authsocket/src/__tests__/eventPayload.property.test.ts", + "manifest": "packages/messaging/authsocket/package.json", + "risk": "critical", + "boundary": "Server-side BRC-103 Socket.IO authentication and authenticated event payload ingress", + "target": "Arbitrary-value callback containment, arbitrary-byte parser totality, JSON envelope shape validation, and event data round trips", + "invariants": [ + "Rejected authentication processing for arbitrary remote values is contained and disconnects only that socket.", + "Arbitrary remote bytes never make event decoding throw.", + "Only object envelopes with string event names are dispatched as named events.", + "Arbitrary JSON event data retains its canonical JSON semantics." + ] + }, + { + "path": "packages/messaging/authsocket-client/src/__tests__/eventPayload.property.test.ts", + "manifest": "packages/messaging/authsocket-client/package.json", + "risk": "critical", + "boundary": "Client-side BRC-103 Socket.IO authentication and authenticated event payload ingress", + "target": "Arbitrary-value callback containment, arbitrary-byte parser totality, JSON envelope shape validation, and event data round trips", + "invariants": [ + "Rejected authentication processing for arbitrary server values is contained and disconnects that connection.", + "Arbitrary server bytes never make event decoding throw.", + "Only object envelopes with string event names are dispatched as named events.", + "Arbitrary JSON event data retains its canonical JSON semantics." + ] + }, { "path": "packages/wallet/ts-wallet-relay/tests/pairingUri.property.test.ts", "manifest": "packages/wallet/ts-wallet-relay/package.json", @@ -388,20 +416,6 @@ "owner": "ts-stack-maintainers", "reviewBy": "2026-08-26" }, - { - "manifest": "packages/messaging/authsocket/package.json", - "kind": "adapter-or-composition", - "rationale": "The server package is a Socket.IO transport for SDK BRC-103 AuthMessage values; authentication byte binding is governed by @bsv/auth and the SDK.", - "owner": "ts-stack-maintainers", - "reviewBy": "2026-08-26" - }, - { - "manifest": "packages/messaging/authsocket-client/package.json", - "kind": "adapter-or-composition", - "rationale": "The client package forwards SDK AuthMessage values through Socket.IO and owns no independent parser, canonicalizer, or authorization decision.", - "owner": "ts-stack-maintainers", - "reviewBy": "2026-08-26" - }, { "manifest": "packages/messaging/ts-paymail/docs/examples/package.json", "kind": "example-or-platform", diff --git a/packages/messaging/authsocket-client/API.md b/packages/messaging/authsocket-client/API.md new file mode 100644 index 000000000..8cafb26e0 --- /dev/null +++ b/packages/messaging/authsocket-client/API.md @@ -0,0 +1,237 @@ +# API + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +## Interfaces + +| | +| ----------------------------------------------------------------------- | +| [AuthSocketClientErrorContext](#interface-authsocketclienterrorcontext) | +| [AuthSocketClientOptions](#interface-authsocketclientoptions) | +| [SocketClientTransportOptions](#interface-socketclienttransportoptions) | + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +### Interface: AuthSocketClientErrorContext + +```ts +export interface AuthSocketClientErrorContext { + phase: AuthSocketClientErrorPhase + socketId?: string + eventName?: string +} +``` + +See also: [AuthSocketClientErrorPhase](#type-authsocketclienterrorphase) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +### Interface: AuthSocketClientOptions + +```ts +export interface AuthSocketClientOptions { + wallet: WalletInterface + requestedCertificates?: RequestedCertificateSet + sessionManager?: SessionManager | AsyncSessionManager + managerOptions?: Partial + originator?: OriginatorDomainNameStringUnder250Bytes + maxPendingAuthMessages?: number + onError?: AuthSocketClientErrorHandler +} +``` + +See also: [AuthSocketClientErrorHandler](#type-authsocketclienterrorhandler) + +
+ +Interface AuthSocketClientOptions Details + +#### Property maxPendingAuthMessages + +Maximum authentication messages processed concurrently. Defaults to 32. + +```ts +maxPendingAuthMessages?: number +``` + +#### Property onError + +Receives contained transport and application errors without exposing remote payloads. + +```ts +onError?: AuthSocketClientErrorHandler +``` + +See also: [AuthSocketClientErrorHandler](#type-authsocketclienterrorhandler) + +
+ +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +### Interface: SocketClientTransportOptions + +```ts +export interface SocketClientTransportOptions { + maxPendingMessages?: number + onError?: (error: unknown) => void | Promise +} +``` + +
+ +Interface SocketClientTransportOptions Details + +#### Property maxPendingMessages + +Maximum authentication messages that may be processed concurrently per socket. + +```ts +maxPendingMessages?: number +``` + +#### Property onError + +Receives contained authentication failures. The hook is never allowed to throw outward. + +```ts +onError?: (error: unknown) => void | Promise +``` + +
+ +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +## Classes + +### Class: SocketClientTransport + +```ts +export class SocketClientTransport implements Transport { + constructor(private readonly socket: IoClientSocket, options: SocketClientTransportOptions = {}) + async send(message: AuthMessage): Promise + async onData(callback: (message: AuthMessage) => Promise): Promise +} +``` + +See also: [SocketClientTransportOptions](#interface-socketclienttransportoptions) + +
+ +Class SocketClientTransport Details + +#### Method onData + +Register a callback to handle incoming AuthMessages. + +```ts +async onData(callback: (message: AuthMessage) => Promise): Promise +``` + +#### Method send + +Send an AuthMessage to the server. + +```ts +async send(message: AuthMessage): Promise +``` + +
+ +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +## Functions + +| | +| ---------------------------------------------------------------------- | +| [AuthSocketClient](#function-authsocketclient) | +| [decodeAuthSocketEventPayload](#function-decodeauthsocketeventpayload) | + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +### Function: AuthSocketClient + +Factory function for creating a new AuthSocketClientImpl instance. + +```ts +export function AuthSocketClient(url: string, opts: AuthSocketClientOptions): AuthSocketClientImpl +``` + +See also: [AuthSocketClientOptions](#interface-authsocketclientoptions) + +
+ +Function AuthSocketClient Details + +Argument Details + +- **url** + - The server URL +- **opts** + - Contains wallet, requested certificates, and other optional settings + +
+ +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +### Function: decodeAuthSocketEventPayload + +```ts +export function decodeAuthSocketEventPayload(payload: number[]): { + eventName: string + data: any +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +## Types + +| | +| ------------------------------------------------------------------ | +| [AuthSocketClientErrorHandler](#type-authsocketclienterrorhandler) | +| [AuthSocketClientErrorPhase](#type-authsocketclienterrorphase) | + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +### Type: AuthSocketClientErrorHandler + +```ts +export type AuthSocketClientErrorHandler = ( + error: unknown, + context: AuthSocketClientErrorContext +) => void | Promise +``` + +See also: [AuthSocketClientErrorContext](#interface-authsocketclienterrorcontext) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +### Type: AuthSocketClientErrorPhase + +```ts +export type AuthSocketClientErrorPhase = 'authentication' | 'application' | 'send' +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- diff --git a/packages/messaging/authsocket-client/README.md b/packages/messaging/authsocket-client/README.md index 800a689b0..e1a7b3905 100644 --- a/packages/messaging/authsocket-client/README.md +++ b/packages/messaging/authsocket-client/README.md @@ -34,7 +34,11 @@ const clientWallet = new ProtoWallet('client-private-key-hex') // Wrap the normal Socket.IO client with AuthSocketClient const socket = AuthSocketClient('http://localhost:3000', { - wallet: clientWallet + wallet: clientWallet, + onError: (error, context) => { + // Context identifies the phase and event without copying the remote payload. + console.error(context.phase, context.eventName, error) + } }) // Standard Socket.IO usage @@ -47,7 +51,7 @@ socket.on('connect', () => { }) }) -socket.on('chatMessage', (msg) => { +socket.on('chatMessage', msg => { console.log('Server says:', msg) }) @@ -60,6 +64,19 @@ socket.on('disconnect', () => { 2. Interact with `.on(...)`, `.emit(...)` as normal. 3. Behind the scenes, each message is signed with your client wallet key and verified by the server. Inbound messages are also verified. +### Failure isolation and resource limits + +Authentication frames and application callbacks are contained inside the +client connection. If a server sends a frame that fails BRC-103 processing, or +an event callback throws or rejects, the client disconnects without creating +an unhandled promise rejection. The optional `onError(error, context)` hook is +also isolated if it throws or rejects, and its context does not include remote +payloads or wallet material. + +The client processes at most 32 authentication messages concurrently by +default. Set `maxPendingAuthMessages` to a positive safe integer to choose a +different bound; a server that exceeds it is disconnected. + ### How It Works (Briefly) - `AuthSocketClient` creates an internal BRC-103 `Peer` that handles: @@ -70,11 +87,14 @@ socket.on('disconnect', () => { ## Detailed Explanations ### SocketClientTransport + - Implements the **BRC-103** `Transport` interface on the client side. - Relies on the underlying `socket.io-client` for raw message passing via the `'authMessage'` channel. - The BRC-103 `Peer` calls this transport to send and receive raw BRC-103 frames. +- Rejected or synchronous authentication failures are contained before they can become unhandled rejections. ### AuthSocketClient + - A function that returns a proxy-like client socket. - Inside, it: 1. Creates a real `io(url, managerOptions)` from `socket.io-client`. diff --git a/packages/messaging/authsocket-client/package.json b/packages/messaging/authsocket-client/package.json index b8adc6780..7c25484d0 100644 --- a/packages/messaging/authsocket-client/package.json +++ b/packages/messaging/authsocket-client/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/authsocket-client", - "version": "2.1.3", + "version": "2.1.4", "sideEffects": false, "engines": { "node": ">=22" @@ -44,6 +44,7 @@ "test": "jest", "test:browser": "pnpm build && node ../../../scripts/check-browser-package.mjs .", "test:coverage": "jest --coverage", + "test:property": "jest --runInBand --runTestsByPath src/__tests__/eventPayload.property.test.ts", "test:watch": "jest --watch", "typecheck": "tsc --project tsconfig.typecheck.json" }, @@ -73,6 +74,7 @@ "@types/jest": "^30.0.0", "@types/node": "^26.1.2", "@typescript/native": "npm:typescript@7.0.2", + "fast-check": "^4.9.0", "jest": "^30.4.2", "oxlint": "^1.76.0", "ts-jest": "^29.4.12", diff --git a/packages/messaging/authsocket-client/src/AuthSocketClient.ts b/packages/messaging/authsocket-client/src/AuthSocketClient.ts index 2d85c5b89..738acdf0f 100644 --- a/packages/messaging/authsocket-client/src/AuthSocketClient.ts +++ b/packages/messaging/authsocket-client/src/AuthSocketClient.ts @@ -15,6 +15,52 @@ import { } from '@bsv/sdk' import { SocketClientTransport } from './SocketClientTransport.js' +export type AuthSocketClientErrorPhase = 'authentication' | 'application' | 'send' + +export interface AuthSocketClientErrorContext { + phase: AuthSocketClientErrorPhase + socketId?: string + eventName?: string +} + +export type AuthSocketClientErrorHandler = ( + error: unknown, + context: AuthSocketClientErrorContext +) => void | Promise + +export function decodeAuthSocketEventPayload(payload: number[]): { eventName: string; data: any } { + try { + const str = Utils.toUTF8(payload) + const decoded: unknown = JSON.parse(str) + if ( + decoded === null || + typeof decoded !== 'object' || + Array.isArray(decoded) || + typeof (decoded as { eventName?: unknown }).eventName !== 'string' + ) { + return { eventName: '_unknown', data: undefined } + } + return { + eventName: (decoded as { eventName: string }).eventName, + data: (decoded as { data?: unknown }).data + } + } catch { + return { eventName: '_unknown', data: undefined } + } +} + +export interface AuthSocketClientOptions { + wallet: WalletInterface + requestedCertificates?: RequestedCertificateSet + sessionManager?: SessionManager | AsyncSessionManager + managerOptions?: Partial + originator?: OriginatorDomainNameStringUnder250Bytes + /** Maximum authentication messages processed concurrently. Defaults to 32. */ + maxPendingAuthMessages?: number + /** Receives contained transport and application errors without exposing remote payloads. */ + onError?: AuthSocketClientErrorHandler +} + /** * Internal class that wraps a Socket.IO client connection with BRC-103 mutual authentication, * enabling secure and identity-aware communication with a server. @@ -23,7 +69,7 @@ class AuthSocketClientImpl { public connected = false public id: string = '' public serverIdentityKey: string | undefined - private readonly eventCallbacks = new Map void>>() + private readonly eventCallbacks = new Map void | Promise>>() /** * Creates an instance of AuthSocketClient. @@ -34,32 +80,33 @@ class AuthSocketClientImpl { */ constructor( private readonly ioSocket: IoClientSocket, - private readonly peer: Peer + private readonly peer: Peer, + private readonly onError: AuthSocketClientErrorHandler = () => {} ) { // Listen for 'connect' and 'disconnect' from underlying Socket.IO this.ioSocket.on('connect', () => { this.connected = true this.id = this.ioSocket.id ?? '' // Re-dispatch to dev if they've called "socket.on('connect', ...)" - this.fireEventCallbacks('connect') + void this.fireEventCallbacks('connect') }) this.ioSocket.on('disconnect', reason => { this.connected = false // Re-dispatch - this.fireEventCallbacks('disconnect', reason) + void this.fireEventCallbacks('disconnect', reason) }) // Also listen for BRC-103 "general" messages // We'll rely on peer.listenForGeneralMessages - this.peer.listenForGeneralMessages((senderKey, payload) => { + this.peer.listenForGeneralMessages(async (senderKey, payload) => { this.serverIdentityKey = senderKey const { eventName, data } = this.decodeEventPayload(payload) - this.fireEventCallbacks(eventName, data) + await this.fireEventCallbacks(eventName, data) }) } - on(eventName: string, callback: (data?: any) => void): this { + on(eventName: string, callback: (data?: any) => void | Promise): this { let arr = this.eventCallbacks.get(eventName) if (arr === undefined) { arr = [] @@ -72,9 +119,23 @@ class AuthSocketClientImpl { emit(eventName: string, data: any): this { // We sign a BRC-103 "general" message and send to the server // via peer.toPeer - const encoded = this.encodeEventPayload(eventName, data) + let encoded: number[] + try { + encoded = this.encodeEventPayload(eventName, data) + } catch (error) { + this.reportError(error, { + phase: 'send', + socketId: this.ioSocket.id ?? this.id, + eventName + }) + return this + } this.peer.toPeer(encoded, this.serverIdentityKey).catch(err => { - console.error(`BRC103IoClientSocket emit error for event "${eventName}":`, err) + this.reportError(err, { + phase: 'send', + socketId: this.ioSocket.id ?? this.id, + eventName + }) }) return this } @@ -84,11 +145,23 @@ class AuthSocketClientImpl { this.ioSocket.disconnect() } - private fireEventCallbacks(eventName: string, data?: any): void { + private async fireEventCallbacks(eventName: string, data?: any): Promise { const cbs = this.eventCallbacks.get(eventName) if (cbs === undefined) return - for (const cb of cbs) { - cb(data) + try { + for (const cb of cbs) { + const result = cb(data) + if (result != null && typeof (result as PromiseLike).then === 'function') { + await result + } + } + } catch (error) { + this.reportError(error, { + phase: 'application', + socketId: this.ioSocket.id ?? this.id, + eventName + }) + if (eventName !== 'disconnect') this.disconnectSafely() } } @@ -98,11 +171,20 @@ class AuthSocketClientImpl { } private decodeEventPayload(payload: number[]): { eventName: string; data: any } { + return decodeAuthSocketEventPayload(payload) + } + + private reportError(error: unknown, context: AuthSocketClientErrorContext): void { + void Promise.resolve() + .then(async () => await this.onError(error, context)) + .catch(() => {}) + } + + private disconnectSafely(): void { try { - const str = Utils.toUTF8(payload) - return JSON.parse(str) + this.ioSocket.disconnect() } catch { - return { eventName: '_unknown', data: undefined } + // The original failure is already contained and reported. } } } @@ -113,21 +195,20 @@ class AuthSocketClientImpl { * @param url - The server URL * @param opts - Contains wallet, requested certificates, and other optional settings */ -export function AuthSocketClient( - url: string, - opts: { - wallet: WalletInterface - requestedCertificates?: RequestedCertificateSet - sessionManager?: SessionManager | AsyncSessionManager - managerOptions?: Partial - originator?: OriginatorDomainNameStringUnder250Bytes - } -): AuthSocketClientImpl { +export function AuthSocketClient(url: string, opts: AuthSocketClientOptions): AuthSocketClientImpl { // 1) Create real socket.io-client connection const socket = realIo(url, opts.managerOptions) // 2) Create a BRC-103 transport for the new socket - const transport = new SocketClientTransport(socket) + const transport = new SocketClientTransport(socket, { + maxPendingMessages: opts.maxPendingAuthMessages, + onError: error => { + reportErrorSafely(opts.onError, error, { + phase: 'authentication', + socketId: socket.id + }) + } + }) // 3) Create a Peer const peer = new Peer( @@ -140,5 +221,17 @@ export function AuthSocketClient( ) // 4) Return our new AuthSocketClientImpl - return new AuthSocketClientImpl(socket, peer) + return new AuthSocketClientImpl(socket, peer, (error, context) => { + reportErrorSafely(opts.onError, error, context) + }) +} + +function reportErrorSafely( + handler: AuthSocketClientErrorHandler | undefined, + error: unknown, + context: AuthSocketClientErrorContext +): void { + void Promise.resolve() + .then(async () => await handler?.(error, context)) + .catch(() => {}) } diff --git a/packages/messaging/authsocket-client/src/SocketClientTransport.ts b/packages/messaging/authsocket-client/src/SocketClientTransport.ts index 92a21730d..f52b27327 100644 --- a/packages/messaging/authsocket-client/src/SocketClientTransport.ts +++ b/packages/messaging/authsocket-client/src/SocketClientTransport.ts @@ -9,15 +9,36 @@ import { Socket as IoClientSocket } from 'socket.io-client' import { AuthMessage, Transport } from '@bsv/sdk' +const DEFAULT_MAX_PENDING_MESSAGES = 32 + +export interface SocketClientTransportOptions { + /** Maximum authentication messages that may be processed concurrently per socket. */ + maxPendingMessages?: number + /** Receives contained authentication failures. The hook is never allowed to throw outward. */ + onError?: (error: unknown) => void | Promise +} + export class SocketClientTransport implements Transport { private onDataCallback?: (message: AuthMessage) => Promise + private readonly maxPendingMessages: number + private readonly onError?: (error: unknown) => void | Promise + private pendingMessages = 0 + private failed = false + + constructor( + private readonly socket: IoClientSocket, + options: SocketClientTransportOptions = {} + ) { + const maxPendingMessages = options.maxPendingMessages ?? DEFAULT_MAX_PENDING_MESSAGES + if (!Number.isSafeInteger(maxPendingMessages) || maxPendingMessages < 1) { + throw new RangeError('maxPendingMessages must be a positive safe integer') + } + this.maxPendingMessages = maxPendingMessages + this.onError = options.onError - constructor(private readonly socket: IoClientSocket) { // Subscribe to the 'authMessage' event from the server - this.socket.on('authMessage', async (msg: AuthMessage) => { - if (this.onDataCallback !== undefined) { - await this.onDataCallback(msg) - } + this.socket.on('authMessage', (msg: AuthMessage) => { + return this.processMessage(msg) }) } @@ -34,4 +55,34 @@ export class SocketClientTransport implements Transport { async onData(callback: (message: AuthMessage) => Promise): Promise { this.onDataCallback = callback } + + private async processMessage(message: AuthMessage): Promise { + if (this.failed || this.onDataCallback === undefined) return + if (this.pendingMessages >= this.maxPendingMessages) { + this.fail(new Error('Authentication message concurrency limit exceeded')) + return + } + + this.pendingMessages += 1 + try { + await this.onDataCallback(message) + } catch (error) { + this.fail(error) + } finally { + this.pendingMessages -= 1 + } + } + + private fail(error: unknown): void { + if (this.failed) return + this.failed = true + void Promise.resolve() + .then(async () => await this.onError?.(error)) + .catch(() => {}) + try { + this.socket.disconnect() + } catch { + // A transport failure is already contained; disconnect errors are non-actionable here. + } + } } diff --git a/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts b/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts index 245a857b9..a0308e312 100644 --- a/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts +++ b/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts @@ -31,7 +31,8 @@ import { SocketClientTransport } from '../SocketClientTransport.js' describe('AuthSocketClient', () => { let socketListeners: Map any> - let generalMessageListener: ((senderPublicKey: string, payload: number[]) => void) | undefined + let generalMessageListener: + ((senderPublicKey: string, payload: number[]) => void | Promise) | undefined beforeEach(() => { jest.clearAllMocks() @@ -43,14 +44,14 @@ describe('AuthSocketClient', () => { } ) mockPeer.listenForGeneralMessages.mockImplementation( - (callback: (senderPublicKey: string, payload: number[]) => void) => { + (callback: (senderPublicKey: string, payload: number[]) => void | Promise) => { generalMessageListener = callback } ) mockPeer.toPeer.mockResolvedValue(undefined) }) - function createClient() { + function createClient(onError?: jest.Mock) { const wallet = { id: 'wallet' } const requestedCertificates = { certifiers: [], types: {} } const sessionManager = { id: 'sessions' } @@ -60,7 +61,8 @@ describe('AuthSocketClient', () => { requestedCertificates, sessionManager: sessionManager as never, managerOptions, - originator: 'example.test' as never + originator: 'example.test' as never, + onError }) return { client, @@ -141,18 +143,18 @@ describe('AuthSocketClient', () => { it('reports asynchronous send failures with the event name', async () => { const error = new Error('send failed') - const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}) + const onError = jest.fn() mockPeer.toPeer.mockRejectedValue(error) - const { client } = createClient() + const { client } = createClient(onError) client.emit('failing-event', true) - await Promise.resolve() + await new Promise(resolve => setImmediate(resolve)) - expect(consoleError).toHaveBeenCalledWith( - 'BRC103IoClientSocket emit error for event "failing-event":', - error - ) - consoleError.mockRestore() + expect(onError).toHaveBeenCalledWith(error, { + phase: 'send', + socketId: 'socket-id', + eventName: 'failing-event' + }) }) it('handles malformed general messages and clears identity on disconnect', () => { @@ -170,6 +172,41 @@ describe('AuthSocketClient', () => { expect(mockSocket.disconnect).toHaveBeenCalledTimes(1) }) + it.each([null, [], 7, 'event', {}, { eventName: 7 }])( + 'routes a valid JSON non-envelope (%p) to the explicit unknown event', + value => { + const { client } = createClient() + const unknown = jest.fn() + client.on('_unknown', unknown) + + generalMessageListener?.('server-key', Array.from(Buffer.from(JSON.stringify(value)))) + + expect(unknown).toHaveBeenCalledWith(undefined) + } + ) + + it('contains rejected application handlers and an observer that also rejects', async () => { + const onError = jest.fn().mockRejectedValue(new Error('observer failed')) + const { client } = createClient(onError) + const applicationFailure = new Error('application failed') + client.on('message', async () => await Promise.reject(applicationFailure)) + + await expect( + generalMessageListener?.( + 'server-key', + Array.from(Buffer.from(JSON.stringify({ eventName: 'message', data: true }))) + ) + ).resolves.toBeUndefined() + await Promise.resolve() + + expect(onError).toHaveBeenCalledWith(applicationFailure, { + phase: 'application', + socketId: 'socket-id', + eventName: 'message' + }) + expect(mockSocket.disconnect).toHaveBeenCalledTimes(1) + }) + it('ignores events without callbacks', () => { const { client } = createClient() diff --git a/packages/messaging/authsocket-client/src/__tests__/SocketClientTransport.test.ts b/packages/messaging/authsocket-client/src/__tests__/SocketClientTransport.test.ts index c07874b4f..6987b7b8b 100644 --- a/packages/messaging/authsocket-client/src/__tests__/SocketClientTransport.test.ts +++ b/packages/messaging/authsocket-client/src/__tests__/SocketClientTransport.test.ts @@ -5,6 +5,7 @@ describe('SocketClientTransport', () => { const listeners: Record unknown> = {} return { emit: jest.fn(), + disconnect: jest.fn(), on: jest.fn((event: string, callback: (data: unknown) => unknown) => { listeners[event] = callback }), @@ -48,4 +49,92 @@ describe('SocketClientTransport', () => { await expect(socket.fire('authMessage', { type: 'test' })).resolves.toBeUndefined() }) + + test.each([ + [ + 'a synchronous throw', + () => { + throw new Error('invalid auth message') + } + ], + ['a rejected promise', async () => await Promise.reject(new Error('invalid signature'))] + ])('contains %s from a malicious server and disconnects', async (_label, failure) => { + const socket = createMockSocket() + const onError = jest.fn().mockRejectedValue(new Error('observer failed')) + const transport = new SocketClientTransport(socket as never, { onError }) + const callback = jest.fn(failure) + + await transport.onData(callback as never) + await expect( + socket.fire('authMessage', { messageType: 'initialResponse' }) + ).resolves.toBeUndefined() + await Promise.resolve() + + expect(onError).toHaveBeenCalledWith(expect.any(Error)) + expect(socket.disconnect).toHaveBeenCalledTimes(1) + + await socket.fire('authMessage', { messageType: 'initialRequest' }) + expect(callback).toHaveBeenCalledTimes(1) + }) + + test('disconnects when a server exceeds the authentication concurrency limit', async () => { + const socket = createMockSocket() + const onError = jest.fn() + const transport = new SocketClientTransport(socket as never, { + maxPendingMessages: 1, + onError + }) + let release: (() => void) | undefined + const pending = new Promise(resolve => { + release = resolve + }) + + await transport.onData(async () => await pending) + const first = socket.fire('authMessage', { sequence: 1 }) + await socket.fire('authMessage', { sequence: 2 }) + + expect(socket.disconnect).toHaveBeenCalledTimes(1) + await Promise.resolve() + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Authentication message concurrency limit exceeded' }) + ) + release?.() + await first + }) + + test('releases authentication capacity after each successful message', async () => { + const socket = createMockSocket() + const transport = new SocketClientTransport(socket as never, { maxPendingMessages: 1 }) + const callback = jest.fn().mockResolvedValue(undefined) + await transport.onData(callback) + + await socket.fire('authMessage', { sequence: 1 }) + await socket.fire('authMessage', { sequence: 2 }) + + expect(callback).toHaveBeenCalledTimes(2) + expect(socket.disconnect).not.toHaveBeenCalled() + }) + + test('reports and disconnects once when concurrent callbacks both fail', async () => { + const socket = createMockSocket() + const onError = jest.fn() + const transport = new SocketClientTransport(socket as never, { onError }) + await transport.onData(async () => await Promise.reject(new Error('failed'))) + + await Promise.all([ + socket.fire('authMessage', { sequence: 1 }), + socket.fire('authMessage', { sequence: 2 }) + ]) + await Promise.resolve() + + expect(onError).toHaveBeenCalledTimes(1) + expect(socket.disconnect).toHaveBeenCalledTimes(1) + }) + + test('rejects invalid concurrency limits', () => { + const socket = createMockSocket() + expect(() => new SocketClientTransport(socket as never, { maxPendingMessages: 0 })).toThrow( + new RangeError('maxPendingMessages must be a positive safe integer') + ) + }) }) diff --git a/packages/messaging/authsocket-client/src/__tests__/eventPayload.property.test.ts b/packages/messaging/authsocket-client/src/__tests__/eventPayload.property.test.ts new file mode 100644 index 000000000..9044fcda5 --- /dev/null +++ b/packages/messaging/authsocket-client/src/__tests__/eventPayload.property.test.ts @@ -0,0 +1,99 @@ +import { Buffer } from 'node:buffer' +import fc from 'fast-check' + +import { decodeAuthSocketEventPayload } from '../AuthSocketClient.js' +import { SocketClientTransport } from '../SocketClientTransport.js' + +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH + +fc.configureGlobal({ + numRuns: Number.isSafeInteger(requestedRuns) + ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) + : MIN_PROPERTY_RUNS, + ...(Number.isSafeInteger(requestedSeed) ? { seed: requestedSeed } : {}), + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +}) + +describe('AuthSocket client event payload boundary properties', () => { + test.each([null, [], 0, 'text', {}, { eventName: 1 }])( + 'rejects the deterministic non-envelope value %p', + value => { + const payload = Array.from(Buffer.from(JSON.stringify(value), 'utf8')) + expect(decodeAuthSocketEventPayload(payload)).toEqual({ + eventName: '_unknown', + data: undefined + }) + } + ) + + test('maps malformed JSON to the explicit unknown event', () => { + expect(decodeAuthSocketEventPayload(Array.from(Buffer.from('{not-json')))).toEqual({ + eventName: '_unknown', + data: undefined + }) + }) + + test('contains callback rejection for arbitrary server values', async () => { + await fc.assert( + fc.asyncProperty(fc.anything(), async remoteValue => { + let listener: ((value: unknown) => Promise) | undefined + const socket = { + emit() {}, + disconnect: jest.fn(), + on(_eventName: string, callback: (value: unknown) => Promise) { + listener = callback + } + } + const transport = new SocketClientTransport(socket as never) + await transport.onData(async () => await Promise.reject(new Error('rejected'))) + + await expect(listener?.(remoteValue)).resolves.toBeUndefined() + expect(socket.disconnect).toHaveBeenCalledTimes(1) + }) + ) + }) + + test('is total for arbitrary wire bytes', () => { + fc.assert( + fc.property(fc.uint8Array({ maxLength: 4096 }), bytes => { + const result = decodeAuthSocketEventPayload(Array.from(bytes)) + expect(typeof result.eventName).toBe('string') + }) + ) + }) + + test('round-trips arbitrary JSON event data', () => { + fc.assert( + fc.property(fc.string(), fc.jsonValue(), (eventName, data) => { + const payload = Array.from(Buffer.from(JSON.stringify({ eventName, data }), 'utf8')) + const canonicalData = JSON.parse(JSON.stringify(data)) + expect(decodeAuthSocketEventPayload(payload)).toEqual({ eventName, data: canonicalData }) + }) + ) + }) + + test('maps arbitrary non-envelope JSON values to the unknown event', () => { + fc.assert( + fc.property( + fc.jsonValue().filter(value => { + return !( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + typeof (value as { eventName?: unknown }).eventName === 'string' + ) + }), + value => { + const payload = Array.from(Buffer.from(JSON.stringify(value), 'utf8')) + expect(decodeAuthSocketEventPayload(payload)).toEqual({ + eventName: '_unknown', + data: undefined + }) + } + ) + ) + }) +}) diff --git a/packages/messaging/authsocket/API.md b/packages/messaging/authsocket/API.md index cfe0b0af7..4f2596fef 100644 --- a/packages/messaging/authsocket/API.md +++ b/packages/messaging/authsocket/API.md @@ -1,32 +1,130 @@ # API -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions) +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) ## Interfaces +| | +| ----------------------------------------------------------------------- | +| [AuthSocketErrorContext](#interface-authsocketerrorcontext) | +| [AuthSocketServerOptions](#interface-authsocketserveroptions) | +| [SocketServerTransportOptions](#interface-socketservertransportoptions) | + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +### Interface: AuthSocketErrorContext + +```ts +export interface AuthSocketErrorContext { + phase: AuthSocketErrorPhase + socketId?: string + eventName?: string +} +``` + +See also: [AuthSocketErrorPhase](#type-authsocketerrorphase) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + ### Interface: AuthSocketServerOptions ```ts export interface AuthSocketServerOptions extends Partial { - wallet: Wallet; - requestedCertificates?: any; - sessionManager?: SessionManager; + wallet: WalletInterface + requestedCertificates?: any + sessionManager?: SessionManager | AsyncSessionManager + maxPendingAuthMessages?: number + onError?: AuthSocketErrorHandler } ``` -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions) +See also: [AuthSocketErrorHandler](#type-authsocketerrorhandler) + +
+ +Interface AuthSocketServerOptions Details + +#### Property maxPendingAuthMessages + +Maximum authentication messages processed concurrently by each socket. Defaults to 32. + +```ts +maxPendingAuthMessages?: number +``` + +#### Property onError + +Receives contained transport and application errors without exposing remote payloads. + +```ts +onError?: AuthSocketErrorHandler +``` + +See also: [AuthSocketErrorHandler](#type-authsocketerrorhandler) + +#### Property sessionManager + +Optional shared BRC-103 session store. Use an AsyncSessionManager backed by +a shared database when more than one server replica handles connections. + +```ts +sessionManager?: SessionManager | AsyncSessionManager +``` + +
+ +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) --- + +### Interface: SocketServerTransportOptions + +```ts +export interface SocketServerTransportOptions { + maxPendingMessages?: number + onError?: (error: unknown) => void | Promise +} +``` + +
+ +Interface SocketServerTransportOptions Details + +#### Property maxPendingMessages + +Maximum authentication messages that may be processed concurrently per socket. + +```ts +maxPendingMessages?: number +``` + +#### Property onError + +Receives contained authentication failures. The hook is never allowed to throw outward. + +```ts +onError?: (error: unknown) => void | Promise +``` + +
+ +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + ## Classes -| | -| --- | -| [AuthSocket](#class-authsocket) | -| [AuthSocketServer](#class-authsocketserver) | -| [SocketClientTransport](#class-socketclienttransport) | +| | +| ----------------------------------------------------- | +| [AuthSocket](#class-authsocket) | +| [AuthSocketServer](#class-authsocketserver) | | [SocketServerTransport](#class-socketservertransport) | -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions) +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) --- @@ -37,14 +135,16 @@ signing and verification via the Peer class. ```ts export class AuthSocket { - constructor(public readonly ioSocket: IoSocket, private peer: Peer, private onIdentityKeyDiscovered: (socketId: string, identityKey: string) => void) - public on(eventName: string, callback: (data: any) => void) - public async emit(eventName: string, data: any): Promise - get id(): string - get identityKey(): string | undefined + constructor(public readonly ioSocket: IoSocket, private readonly peer: Peer, private readonly onIdentityKeyDiscovered: (socketId: string, identityKey: string) => void, private readonly onError: AuthSocketErrorHandler = () => { }) + public on(eventName: string, callback: (data: any) => void | Promise) + public async emit(eventName: string, data: any): Promise + get id(): string + get identityKey(): string | undefined } ``` +See also: [AuthSocketErrorHandler](#type-authsocketerrorhandler) +
Class AuthSocket Details @@ -52,15 +152,15 @@ export class AuthSocket { #### Method emit Emulate `socket.emit(eventName, data)`. -We'll sign a BRC-103 `general` message via Peer, +We'll sign a BRC-103 `general` message via Peer, embedding the event name & data in the payload. -If we do not yet have the peer's identity key (handshake not done?), -the Peer will attempt the handshake. Once known, subsequent calls +If we do not yet have the peer's identity key (handshake not done?), +the Peer will attempt the handshake. Once known, subsequent calls will pass identityKey to skip the initial handshake. ```ts -public async emit(eventName: string, data: any): Promise +public async emit(eventName: string, data: any): Promise ``` #### Method on @@ -68,14 +168,15 @@ public async emit(eventName: string, data: any): Promise Register a callback for an event name, just like `socket.on(...)`. ```ts -public on(eventName: string, callback: (data: any) => void) +public on(eventName: string, callback: (data: any) => void | Promise) ```
-Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions) +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) --- + ### Class: AuthSocketServer A server-side wrapper for Socket.IO that integrates BRC-103 mutual authentication @@ -83,21 +184,26 @@ to ensure secure, identity-aware communication between clients and the server. This class functions as a drop-in replacement for the `Server` class from Socket.IO, with added support for: + - Automatic BRC-103 handshake for secure client authentication. - Management of authenticated client sessions, avoiding redundant handshakes. - Event-based communication through signed and verified BRC-103 messages. Features: + - Tracks client connections and their associated `Peer` and `AuthSocket` instances. - Allows broadcasting messages to all authenticated clients. - Provides a seamless API for developers by wrapping Socket.IO functionality. ```ts export class AuthSocketServer { - constructor(httpServer: HttpServer, private options: AuthSocketServerOptions) - public on(eventName: "connection", callback: (socket: AuthSocket) => void): void; - public on(eventName: string, callback: (data: any) => void): void - public emit(eventName: string, data: any) + constructor(httpServer: HttpServer, private readonly options: AuthSocketServerOptions) + public on(eventName: "connection", callback: (socket: AuthSocket) => void | Promise): void; + public on(eventName: string, callback: (data: any) => void | Promise): void; + public on(eventName: string, callback: (data: any) => void | Promise): void + public emit(eventName: string, data: any) + public emitToIdentity(identityKey: string, eventName: string, data: any): number + public close(): Promise } ``` @@ -110,78 +216,72 @@ See also: [AuthSocket](#class-authsocket), [AuthSocketServerOptions](#interface- #### Constructor ```ts -constructor(httpServer: HttpServer, private options: AuthSocketServerOptions) +constructor(httpServer: HttpServer, private readonly options: AuthSocketServerOptions) ``` + See also: [AuthSocketServerOptions](#interface-authsocketserveroptions) Argument Details -+ **httpServer** - + The underlying HTTP server -+ **options** - + Contains both standard Socket.IO server config and BRC-103 config. +- **httpServer** + - The underlying HTTP server +- **options** + - Contains both standard Socket.IO server config and BRC-103 config. -#### Method emit +#### Method close -Provide a classic pass-through to `io.emit(...)`. - -Under the hood, we sign a separate BRC-103 AuthMessage for each -authenticated peer. We'll embed eventName + data in the payload. +Stops accepting connections, disconnects active sockets, and closes the +attached HTTP server. Repeated calls share the same shutdown operation. ```ts -public emit(eventName: string, data: any) +public close(): Promise ``` -#### Method on +#### Method emit -A direct pass-through to `io.on('connection', cb)`, -but the callback is invoked with an AuthSocket instead. +Provide a classic pass-through to `io.emit(...)`. + +Under the hood, we sign a separate BRC-103 AuthMessage for each +authenticated peer. We'll embed eventName + data in the payload. ```ts -public on(eventName: "connection", callback: (socket: AuthSocket) => void): void +public emit(eventName: string, data: any) ``` -See also: [AuthSocket](#class-authsocket) - +#### Method emitToIdentity -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions) +Emit only to connections whose cryptographically authenticated peer +identity matches the requested identity key. ---- -### Class: SocketClientTransport +This is safer than application-level "room" names for private delivery: +a client cannot subscribe itself to another identity because the routing +decision uses the key discovered by the BRC-103 handshake. ```ts -export class SocketClientTransport implements Transport { - constructor(private socket: IoClientSocket) - async send(message: AuthMessage): Promise - async onData(callback: (message: AuthMessage) => Promise): Promise -} +public emitToIdentity(identityKey: string, eventName: string, data: any): number ``` -
+Returns -Class SocketClientTransport Details +the number of authenticated connections selected for delivery -#### Method onData +#### Method on -Register a callback to handle incoming AuthMessages. +A direct pass-through to `io.on('connection', cb)`, +but the callback is invoked with an AuthSocket instead. ```ts -async onData(callback: (message: AuthMessage) => Promise): Promise +public on(eventName: "connection", callback: (socket: AuthSocket) => void | Promise): void ``` -#### Method send - -Send an AuthMessage to the server. - -```ts -async send(message: AuthMessage): Promise -``` +See also: [AuthSocket](#class-authsocket)
-Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions) +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) --- + ### Class: SocketServerTransport Implements the Transport interface for a specific client socket. @@ -191,43 +291,65 @@ in the underlying Socket.IO connection. ```ts export class SocketServerTransport implements Transport { - constructor(private socket: IoSocket) - async send(message: AuthMessage): Promise - async onData(callback: (message: AuthMessage) => Promise): Promise + constructor(private readonly socket: IoSocket, options: SocketServerTransportOptions = {}) + async send(message: AuthMessage): Promise + async onData(callback: (message: AuthMessage) => Promise): Promise } ``` -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions) +See also: [SocketServerTransportOptions](#interface-socketservertransportoptions) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) --- + ## Functions -### Function: AuthSocketClient +### Function: decodeAuthSocketEventPayload + +```ts +export function decodeAuthSocketEventPayload(payload: number[]): { + eventName: string + data: any +} +``` + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) -Factory function for creating a new AuthSocketClientImpl instance. +--- + +## Types + +| | +| ------------------------------------------------------ | +| [AuthSocketErrorHandler](#type-authsocketerrorhandler) | +| [AuthSocketErrorPhase](#type-authsocketerrorphase) | + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) + +--- + +### Type: AuthSocketErrorHandler ```ts -export function AuthSocketClient(url: string, opts: { - wallet: Wallet; - requestedCertificates?: RequestedCertificateSet; - sessionManager?: SessionManager; - managerOptions?: Partial; -}): AuthSocketClientImpl +export type AuthSocketErrorHandler = ( + error: unknown, + context: AuthSocketErrorContext +) => void | Promise ``` -
+See also: [AuthSocketErrorContext](#interface-authsocketerrorcontext) -Function AuthSocketClient Details +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) -Argument Details +--- -+ **url** - + The server URL -+ **opts** - + Contains wallet, requested certificates, and other optional settings +### Type: AuthSocketErrorPhase -
+```ts +export type AuthSocketErrorPhase = 'authentication' | 'application' | 'connection' | 'send' +``` -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions) +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types) --- diff --git a/packages/messaging/authsocket/README.md b/packages/messaging/authsocket/README.md index 3c513a878..32dd9b173 100644 --- a/packages/messaging/authsocket/README.md +++ b/packages/messaging/authsocket/README.md @@ -45,17 +45,22 @@ const serverWallet = new ProtoWallet('my-private-key-hex') // which internally wraps the Socket.IO server. const io = new AuthSocketServer(server, { wallet: serverWallet, + onError: (error, context) => { + // Send the error to your private telemetry sink. Context never includes + // the remote payload or wallet material. + console.error(context.phase, context.socketId, error) + }, cors: { origin: '*' } }) // Use it like standard Socket.IO -io.on('connection', (socket) => { +io.on('connection', socket => { console.log('New Authenticated Connection -> socket ID:', socket.id) // Listen for chat messages - socket.on('chatMessage', (msg) => { + socket.on('chatMessage', msg => { console.log('Received message from client:', msg) // Reply to the client socket.emit('chatMessage', { from: socket.id, text: 'Hello from server!' }) @@ -89,16 +94,27 @@ cross-origin default of its own. Call `await io.close()` during shutdown. It is idempotent and disconnects active Socket.IO clients before closing the attached HTTP server. +### Failure isolation and resource limits + +Authentication frames and application callbacks are isolated per connection. +If signature verification, certificate handling, a connection callback, or an +event callback throws or rejects, AuthSocket contains the failure and +disconnects only that socket. The optional `onError(error, context)` hook +receives a phase, socket ID, and event name where applicable; a hook that +throws or rejects is also contained. Raw payloads and wallet data are not added +to the context. + +At most 32 authentication messages are processed concurrently per socket by +default. Set `maxPendingAuthMessages` to a positive safe integer when a +deployment needs a different per-connection bound. A client that exceeds the +bound is disconnected while the server continues accepting other clients. + ### Targeted authenticated delivery Use `emitToIdentity` when a message is private to one BRC-103 identity: ```ts -const selectedConnections = io.emitToIdentity( - recipientIdentityKey, - 'message', - encryptedPayload -) +const selectedConnections = io.emitToIdentity(recipientIdentityKey, 'message', encryptedPayload) ``` The routing decision uses the peer identity discovered by the signed @@ -129,9 +145,11 @@ operation and should be reserved for intentionally public events. - Internally, it uses the BRC-103 `Peer` to sign outbound messages and verify inbound ones. ### SocketServerTransport + - Implements the **BRC-103** `Transport` interface for server-side usage. - Receives messages via `socket.on('authMessage', ...)` from the Socket.IO layer. - Passes them to the `Peer` for handshake steps (signature verification, certificate exchange, etc.). +- Contains rejected handshake processing and disconnects the offending socket. - Sends BRC-103 messages back to the client via `socket.emit('authMessage', ...)`. ## License diff --git a/packages/messaging/authsocket/package.json b/packages/messaging/authsocket/package.json index ad027ead8..86d05af00 100644 --- a/packages/messaging/authsocket/package.json +++ b/packages/messaging/authsocket/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/authsocket", - "version": "2.1.4", + "version": "2.1.5", "sideEffects": false, "engines": { "node": ">=22" @@ -41,6 +41,7 @@ "prepublishOnly": "pnpm build", "test": "jest", "test:coverage": "jest --coverage", + "test:property": "jest --runInBand --runTestsByPath src/__tests__/eventPayload.property.test.ts", "test:watch": "jest --watch", "typecheck": "tsc --project tsconfig.typecheck.json" }, @@ -70,8 +71,10 @@ "@types/jest": "^30.0.0", "@types/node": "^26.1.2", "@typescript/native": "npm:typescript@7.0.2", + "fast-check": "^4.9.0", "jest": "^30.4.2", "oxlint": "^1.76.0", + "socket.io-client": "^4.8.3", "ts-jest": "^29.4.12", "ts2md": "^0.2.8", "tsdown": "0.22.14", diff --git a/packages/messaging/authsocket/src/AuthSocketServer.ts b/packages/messaging/authsocket/src/AuthSocketServer.ts index 030102682..8a6cb11b1 100644 --- a/packages/messaging/authsocket/src/AuthSocketServer.ts +++ b/packages/messaging/authsocket/src/AuthSocketServer.ts @@ -3,6 +3,40 @@ import { ServerOptions, Server as IoServer, Socket as IoSocket } from 'socket.io import { WalletInterface, Peer, SessionManager, AsyncSessionManager } from '@bsv/sdk' import { SocketServerTransport } from './SocketServerTransport.js' +export type AuthSocketErrorPhase = 'authentication' | 'application' | 'connection' | 'send' + +export interface AuthSocketErrorContext { + phase: AuthSocketErrorPhase + socketId?: string + eventName?: string +} + +export type AuthSocketErrorHandler = ( + error: unknown, + context: AuthSocketErrorContext +) => void | Promise + +export function decodeAuthSocketEventPayload(payload: number[]): { eventName: string; data: any } { + try { + const str = Buffer.from(payload).toString('utf8') + const decoded: unknown = JSON.parse(str) + if ( + decoded === null || + typeof decoded !== 'object' || + Array.isArray(decoded) || + typeof (decoded as { eventName?: unknown }).eventName !== 'string' + ) { + return { eventName: '_unknown', data: null } + } + return { + eventName: (decoded as { eventName: string }).eventName, + data: (decoded as { data?: unknown }).data + } + } catch { + return { eventName: '_unknown', data: null } + } +} + export interface AuthSocketServerOptions extends Partial { wallet: WalletInterface // The server's wallet for signing requestedCertificates?: any // e.g. RequestedCertificateSet @@ -11,6 +45,10 @@ export interface AuthSocketServerOptions extends Partial { * a shared database when more than one server replica handles connections. */ sessionManager?: SessionManager | AsyncSessionManager + /** Maximum authentication messages processed concurrently by each socket. Defaults to 32. */ + maxPendingAuthMessages?: number + /** Receives contained transport and application errors without exposing remote payloads. */ + onError?: AuthSocketErrorHandler } interface PeerInfo { @@ -45,7 +83,7 @@ export class AuthSocketServer { * for that connection to skip re-handshaking. */ private readonly peers = new Map() - private readonly connectionCallbacks: Array<(socket: AuthSocket) => void> = [] + private readonly connectionCallbacks: Array<(socket: AuthSocket) => void | Promise> = [] private closePromise?: Promise /** @@ -56,11 +94,24 @@ export class AuthSocketServer { httpServer: HttpServer, private readonly options: AuthSocketServerOptions ) { - this.realIo = new IoServer(httpServer, options) + const { + wallet: _wallet, + requestedCertificates: _requestedCertificates, + sessionManager: _sessionManager, + maxPendingAuthMessages: _maxPendingAuthMessages, + onError: _onError, + ...serverOptions + } = options + this.realIo = new IoServer(httpServer, serverOptions) // Listen for new connections this.realIo.on('connection', (socket: IoSocket) => { - this.handleNewConnection(socket) + try { + this.handleNewConnection(socket) + } catch (error) { + this.reportError(error, { phase: 'connection', socketId: socket.id }) + this.disconnectSafely(socket) + } }) } @@ -68,12 +119,12 @@ export class AuthSocketServer { * A direct pass-through to `io.on('connection', cb)`, * but the callback is invoked with an AuthSocket instead. */ - public on(eventName: 'connection', callback: (socket: AuthSocket) => void): void - public on(eventName: string, callback: (data: any) => void): void - public on(eventName: string, callback: (data: any) => void): void { + public on(eventName: 'connection', callback: (socket: AuthSocket) => void | Promise): void + public on(eventName: string, callback: (data: any) => void | Promise): void + public on(eventName: string, callback: (data: any) => void | Promise): void { // We only override the 'connection' event. For other events, pass them through if (eventName === 'connection') { - this.connectionCallbacks.push(callback as (socket: AuthSocket) => void) + this.connectionCallbacks.push(callback as (socket: AuthSocket) => void | Promise) } else { this.realIo.on(eventName, callback) } @@ -86,11 +137,16 @@ export class AuthSocketServer { * authenticated peer. We'll embed eventName + data in the payload. */ public emit(eventName: string, data: any) { + let payload: number[] + try { + payload = this.encodeEventPayload(eventName, data) + } catch (error) { + this.reportError(error, { phase: 'send', eventName }) + return + } this.peers.forEach(({ peer, identityKey }) => { - const payload = this.encodeEventPayload(eventName, data) peer.toPeer(payload, identityKey).catch(err => { - // log or handle error - console.error(err) + this.reportError(err, { phase: 'send', eventName }) }) }) } @@ -107,12 +163,18 @@ export class AuthSocketServer { */ public emitToIdentity(identityKey: string, eventName: string, data: any): number { let selected = 0 + let payload: number[] + try { + payload = this.encodeEventPayload(eventName, data) + } catch (error) { + this.reportError(error, { phase: 'send', eventName }) + return selected + } this.peers.forEach(({ peer, identityKey: authenticatedIdentityKey }) => { if (authenticatedIdentityKey !== identityKey) return selected += 1 - const payload = this.encodeEventPayload(eventName, data) peer.toPeer(payload, authenticatedIdentityKey).catch(err => { - console.error(err) + this.reportError(err, { phase: 'send', eventName }) }) }) return selected @@ -138,8 +200,13 @@ export class AuthSocketServer { // return this.realIo // } - private async handleNewConnection(socket: IoSocket) { - const transport = new SocketServerTransport(socket) + private handleNewConnection(socket: IoSocket): void { + const transport = new SocketServerTransport(socket, { + maxPendingMessages: this.options.maxPendingAuthMessages, + onError: error => { + this.reportError(error, { phase: 'authentication', socketId: socket.id }) + } + }) // Create a new Peer for this client const peer = new Peer( @@ -149,13 +216,20 @@ export class AuthSocketServer { this.options.sessionManager ) - const authSocket = new AuthSocket(socket, peer, (sockId, identityKey) => { - // Callback: once the AuthSocket learns identityKey from a 'general' message, store it - const info = this.peers.get(sockId) - if (info) { - info.identityKey = identityKey + const authSocket = new AuthSocket( + socket, + peer, + (sockId, identityKey) => { + // Callback: once the AuthSocket learns identityKey from a 'general' message, store it + const info = this.peers.get(sockId) + if (info) { + info.identityKey = identityKey + } + }, + (error, context) => { + this.reportError(error, context) } - }) + ) this.peers.set(socket.id, { peer, authSocket, identityKey: undefined }) @@ -165,13 +239,34 @@ export class AuthSocketServer { }) // Fire any onConnection callbacks - this.connectionCallbacks.forEach(cb => cb(authSocket)) + void (async () => { + for (const callback of this.connectionCallbacks) { + await callback(authSocket) + } + })().catch(error => { + this.reportError(error, { phase: 'connection', socketId: socket.id }) + this.disconnectSafely(socket) + }) } private encodeEventPayload(eventName: string, data: any): number[] { const obj = { eventName, data } return Array.from(Buffer.from(JSON.stringify(obj), 'utf8')) } + + private reportError(error: unknown, context: AuthSocketErrorContext): void { + void Promise.resolve() + .then(async () => await this.options.onError?.(error, context)) + .catch(() => {}) + } + + private disconnectSafely(socket: IoSocket): void { + try { + socket.disconnect(true) + } catch { + // The original failure is already contained and reported. + } + } } /** @@ -180,7 +275,8 @@ export class AuthSocketServer { */ export class AuthSocket { // We store event callbacks for re-dispatch - private readonly eventCallbacks: Map void>> = new Map() + private readonly eventCallbacks: Map void | Promise>> = + new Map() /** * Current known identity key of the server, if discovered @@ -196,22 +292,33 @@ export class AuthSocket { * A function the server passes in so we can * notify it once we discover the peer's identity key. */ - private readonly onIdentityKeyDiscovered: (socketId: string, identityKey: string) => void + private readonly onIdentityKeyDiscovered: (socketId: string, identityKey: string) => void, + private readonly onError: AuthSocketErrorHandler = () => {} ) { // Listen for 'general' messages from the Peer - this.peer.listenForGeneralMessages((senderPublicKey, payload) => { - // Capture the newly discovered identity key if not known yet - if (!this.peerIdentityKey) { - this.peerIdentityKey = senderPublicKey - this.onIdentityKeyDiscovered(this.ioSocket.id, senderPublicKey) - } + this.peer.listenForGeneralMessages(async (senderPublicKey, payload) => { + let eventName: string | undefined + try { + // Capture the newly discovered identity key if not known yet + if (!this.peerIdentityKey) { + this.peerIdentityKey = senderPublicKey + this.onIdentityKeyDiscovered(this.ioSocket.id, senderPublicKey) + } - // The payload is a number[] representing JSON for { eventName, data } - const { eventName, data } = this.decodeEventPayload(payload) - const cbs = this.eventCallbacks.get(eventName) - if (!cbs) return - for (const cb of cbs) { - cb(data) + // The payload is a number[] representing JSON for { eventName, data } + const decoded = this.decodeEventPayload(payload) + eventName = decoded.eventName + const cbs = this.eventCallbacks.get(eventName) + if (!cbs) return + for (const cb of cbs) { + const result = cb(decoded.data) + if (result != null && typeof (result as PromiseLike).then === 'function') { + await result + } + } + } catch (error) { + this.reportError(error, { phase: 'application', socketId: this.id, eventName }) + this.disconnectSafely() } }) } @@ -219,7 +326,7 @@ export class AuthSocket { /** * Register a callback for an event name, just like `socket.on(...)`. */ - public on(eventName: string, callback: (data: any) => void) { + public on(eventName: string, callback: (data: any) => void | Promise) { const arr = this.eventCallbacks.get(eventName) || [] arr.push(callback) this.eventCallbacks.set(eventName, arr) @@ -263,11 +370,20 @@ export class AuthSocket { } private decodeEventPayload(payload: number[]): { eventName: string; data: any } { + return decodeAuthSocketEventPayload(payload) + } + + private reportError(error: unknown, context: AuthSocketErrorContext): void { + void Promise.resolve() + .then(async () => await this.onError(error, context)) + .catch(() => {}) + } + + private disconnectSafely(): void { try { - const str = Buffer.from(payload).toString('utf8') - return JSON.parse(str) + this.ioSocket.disconnect(true) } catch { - return { eventName: '_unknown', data: null } + // The original failure is already contained and reported. } } } diff --git a/packages/messaging/authsocket/src/SocketServerTransport.ts b/packages/messaging/authsocket/src/SocketServerTransport.ts index 14955e98c..de37323eb 100644 --- a/packages/messaging/authsocket/src/SocketServerTransport.ts +++ b/packages/messaging/authsocket/src/SocketServerTransport.ts @@ -1,6 +1,15 @@ import { Socket as IoSocket } from 'socket.io' import { Transport, AuthMessage } from '@bsv/sdk' +const DEFAULT_MAX_PENDING_MESSAGES = 32 + +export interface SocketServerTransportOptions { + /** Maximum authentication messages that may be processed concurrently per socket. */ + maxPendingMessages?: number + /** Receives contained authentication failures. The hook is never allowed to throw outward. */ + onError?: (error: unknown) => void | Promise +} + /** * Implements the Transport interface for a specific client socket. * @@ -8,7 +17,22 @@ import { Transport, AuthMessage } from '@bsv/sdk' * in the underlying Socket.IO connection. */ export class SocketServerTransport implements Transport { - constructor(private readonly socket: IoSocket) {} + private readonly maxPendingMessages: number + private readonly onError?: (error: unknown) => void | Promise + private pendingMessages = 0 + private failed = false + + constructor( + private readonly socket: IoSocket, + options: SocketServerTransportOptions = {} + ) { + const maxPendingMessages = options.maxPendingMessages ?? DEFAULT_MAX_PENDING_MESSAGES + if (!Number.isSafeInteger(maxPendingMessages) || maxPendingMessages < 1) { + throw new RangeError('maxPendingMessages must be a positive safe integer') + } + this.maxPendingMessages = maxPendingMessages + this.onError = options.onError + } async send(message: AuthMessage): Promise { // We'll emit with a special low-level event named: 'authMessage' @@ -17,8 +41,41 @@ export class SocketServerTransport implements Transport { async onData(callback: (message: AuthMessage) => Promise): Promise { // Listen for 'authMessage' from the client - this.socket.on('authMessage', async (msg: AuthMessage) => { - await callback(msg) + this.socket.on('authMessage', (msg: AuthMessage) => { + return this.processMessage(msg, callback) }) } + + private async processMessage( + message: AuthMessage, + callback: (message: AuthMessage) => Promise + ): Promise { + if (this.failed) return + if (this.pendingMessages >= this.maxPendingMessages) { + this.fail(new Error('Authentication message concurrency limit exceeded')) + return + } + + this.pendingMessages += 1 + try { + await callback(message) + } catch (error) { + this.fail(error) + } finally { + this.pendingMessages -= 1 + } + } + + private fail(error: unknown): void { + if (this.failed) return + this.failed = true + void Promise.resolve() + .then(async () => await this.onError?.(error)) + .catch(() => {}) + try { + this.socket.disconnect(true) + } catch { + // A transport failure is already contained; disconnect errors are non-actionable here. + } + } } diff --git a/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts b/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts index 223328f00..17fb1b63d 100644 --- a/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts +++ b/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts @@ -1,25 +1,27 @@ import { AuthSocket } from '../AuthSocketServer.js' describe('AuthSocket', () => { - function createHarness() { - let generalMessageListener: ((senderPublicKey: string, payload: number[]) => void) | undefined + function createHarness(onError = jest.fn()) { + let generalMessageListener: + ((senderPublicKey: string, payload: number[]) => void | Promise) | undefined const peer = { listenForGeneralMessages: jest.fn( - (callback: (senderPublicKey: string, payload: number[]) => void) => { + (callback: (senderPublicKey: string, payload: number[]) => void | Promise) => { generalMessageListener = callback } ), toPeer: jest.fn().mockResolvedValue(undefined) } const socket = { - id: 'socket-2' + id: 'socket-2', + disconnect: jest.fn() } const identityDiscovered = jest.fn() - const authSocket = new AuthSocket(socket as never, peer as never, identityDiscovered) + const authSocket = new AuthSocket(socket as never, peer as never, identityDiscovered, onError) return { authSocket, generalMessage(payload: unknown, sender = 'peer-key') { - generalMessageListener?.( + return generalMessageListener?.( sender, typeof payload === 'string' ? Array.from(Buffer.from(payload)) @@ -27,7 +29,9 @@ describe('AuthSocket', () => { ) }, identityDiscovered, - peer + onError, + peer, + socket } } @@ -69,6 +73,39 @@ describe('AuthSocket', () => { expect(unknown).toHaveBeenCalledWith(null) }) + it.each([null, [], 7, 'event', {}, { eventName: 7 }])( + 'routes a valid JSON non-envelope (%p) to the explicit unknown event', + value => { + const { authSocket, generalMessage } = createHarness() + const unknown = jest.fn() + authSocket.on('_unknown', unknown) + + generalMessage(value) + + expect(unknown).toHaveBeenCalledWith(null) + } + ) + + it('contains rejected application handlers and disconnects the offending socket', async () => { + const observerFailure = new Error('observer failed') + const onError = jest.fn().mockRejectedValue(observerFailure) + const { authSocket, generalMessage, socket } = createHarness(onError) + const applicationFailure = new Error('application failed') + authSocket.on('message', async () => await Promise.reject(applicationFailure)) + + await expect( + generalMessage({ eventName: 'message', data: { untrusted: true } }) + ).resolves.toBeUndefined() + await Promise.resolve() + + expect(onError).toHaveBeenCalledWith(applicationFailure, { + phase: 'application', + socketId: 'socket-2', + eventName: 'message' + }) + expect(socket.disconnect).toHaveBeenCalledWith(true) + }) + it('ignores valid events without registered callbacks', () => { const { generalMessage } = createHarness() diff --git a/packages/messaging/authsocket/src/__tests__/AuthSocketServer.lifecycle.test.ts b/packages/messaging/authsocket/src/__tests__/AuthSocketServer.lifecycle.test.ts index 60049bd90..0c08d510b 100644 --- a/packages/messaging/authsocket/src/__tests__/AuthSocketServer.lifecycle.test.ts +++ b/packages/messaging/authsocket/src/__tests__/AuthSocketServer.lifecycle.test.ts @@ -42,10 +42,9 @@ describe('AuthSocketServer lifecycle', () => { const connectionCallback = jest.fn() server.on('connection', connectionCallback) - expect(mockIoServerConstructor).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ wallet, requestedCertificates, sessionManager }) - ) + expect(mockIoServerConstructor).toHaveBeenCalledWith(expect.anything(), { + cors: { origin: '*' } + }) const rawListeners = new Map any>() const rawSocket = { diff --git a/packages/messaging/authsocket/src/__tests__/SocketServerTransport.test.ts b/packages/messaging/authsocket/src/__tests__/SocketServerTransport.test.ts index 6beebcabc..589fc13af 100644 --- a/packages/messaging/authsocket/src/__tests__/SocketServerTransport.test.ts +++ b/packages/messaging/authsocket/src/__tests__/SocketServerTransport.test.ts @@ -5,6 +5,7 @@ describe('SocketServerTransport', () => { const listeners: Record unknown> = {} return { emit: jest.fn(), + disconnect: jest.fn(), on: jest.fn((event: string, callback: (data: unknown) => unknown) => { listeners[event] = callback }), @@ -36,4 +37,92 @@ describe('SocketServerTransport', () => { expect(callback).toHaveBeenCalledWith(message) }) + + test.each([ + [ + 'a synchronous throw', + () => { + throw new Error('invalid auth message') + } + ], + ['a rejected promise', async () => await Promise.reject(new Error('invalid signature'))] + ])('contains %s and disconnects only the offending socket', async (_label, failure) => { + const socket = createMockSocket() + const onError = jest.fn().mockRejectedValue(new Error('observer failed')) + const transport = new SocketServerTransport(socket as never, { onError }) + const callback = jest.fn(failure) + + await transport.onData(callback as never) + await expect( + socket.fire('authMessage', { messageType: 'initialResponse' }) + ).resolves.toBeUndefined() + await Promise.resolve() + + expect(onError).toHaveBeenCalledWith(expect.any(Error)) + expect(socket.disconnect).toHaveBeenCalledWith(true) + + await socket.fire('authMessage', { messageType: 'initialRequest' }) + expect(callback).toHaveBeenCalledTimes(1) + }) + + test('disconnects a socket that exceeds its authentication concurrency limit', async () => { + const socket = createMockSocket() + const onError = jest.fn() + const transport = new SocketServerTransport(socket as never, { + maxPendingMessages: 1, + onError + }) + let release: (() => void) | undefined + const pending = new Promise(resolve => { + release = resolve + }) + + await transport.onData(async () => await pending) + const first = socket.fire('authMessage', { sequence: 1 }) + await socket.fire('authMessage', { sequence: 2 }) + + expect(socket.disconnect).toHaveBeenCalledWith(true) + await Promise.resolve() + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Authentication message concurrency limit exceeded' }) + ) + release?.() + await first + }) + + test('releases authentication capacity after each successful message', async () => { + const socket = createMockSocket() + const transport = new SocketServerTransport(socket as never, { maxPendingMessages: 1 }) + const callback = jest.fn().mockResolvedValue(undefined) + await transport.onData(callback) + + await socket.fire('authMessage', { sequence: 1 }) + await socket.fire('authMessage', { sequence: 2 }) + + expect(callback).toHaveBeenCalledTimes(2) + expect(socket.disconnect).not.toHaveBeenCalled() + }) + + test('reports and disconnects once when concurrent callbacks both fail', async () => { + const socket = createMockSocket() + const onError = jest.fn() + const transport = new SocketServerTransport(socket as never, { onError }) + await transport.onData(async () => await Promise.reject(new Error('failed'))) + + await Promise.all([ + socket.fire('authMessage', { sequence: 1 }), + socket.fire('authMessage', { sequence: 2 }) + ]) + await Promise.resolve() + + expect(onError).toHaveBeenCalledTimes(1) + expect(socket.disconnect).toHaveBeenCalledTimes(1) + }) + + test('rejects invalid concurrency limits', () => { + const socket = createMockSocket() + expect(() => new SocketServerTransport(socket as never, { maxPendingMessages: 0 })).toThrow( + new RangeError('maxPendingMessages must be a positive safe integer') + ) + }) }) diff --git a/packages/messaging/authsocket/src/__tests__/eventPayload.property.test.ts b/packages/messaging/authsocket/src/__tests__/eventPayload.property.test.ts new file mode 100644 index 000000000..e6025f527 --- /dev/null +++ b/packages/messaging/authsocket/src/__tests__/eventPayload.property.test.ts @@ -0,0 +1,98 @@ +import fc from 'fast-check' + +import { decodeAuthSocketEventPayload } from '../AuthSocketServer.js' +import { SocketServerTransport } from '../SocketServerTransport.js' + +const MIN_PROPERTY_RUNS = 300 +const requestedRuns = Number.parseInt(process.env.FAST_CHECK_NUM_RUNS ?? '', 10) +const requestedSeed = Number.parseInt(process.env.FAST_CHECK_SEED ?? '', 10) +const replayPath = process.env.FAST_CHECK_PATH + +fc.configureGlobal({ + numRuns: Number.isSafeInteger(requestedRuns) + ? Math.max(MIN_PROPERTY_RUNS, requestedRuns) + : MIN_PROPERTY_RUNS, + ...(Number.isSafeInteger(requestedSeed) ? { seed: requestedSeed } : {}), + ...(replayPath !== undefined && replayPath !== '' ? { path: replayPath } : {}) +}) + +describe('AuthSocket server event payload boundary properties', () => { + test.each([null, [], 0, 'text', {}, { eventName: 1 }])( + 'rejects the deterministic non-envelope value %p', + value => { + const payload = Array.from(Buffer.from(JSON.stringify(value), 'utf8')) + expect(decodeAuthSocketEventPayload(payload)).toEqual({ + eventName: '_unknown', + data: null + }) + } + ) + + test('maps malformed JSON to the explicit unknown event', () => { + expect(decodeAuthSocketEventPayload(Array.from(Buffer.from('{not-json')))).toEqual({ + eventName: '_unknown', + data: null + }) + }) + + test('contains callback rejection for arbitrary remote values', async () => { + await fc.assert( + fc.asyncProperty(fc.anything(), async remoteValue => { + let listener: ((value: unknown) => Promise) | undefined + const socket = { + emit() {}, + disconnect: jest.fn(), + on(_eventName: string, callback: (value: unknown) => Promise) { + listener = callback + } + } + const transport = new SocketServerTransport(socket as never) + await transport.onData(async () => await Promise.reject(new Error('rejected'))) + + await expect(listener?.(remoteValue)).resolves.toBeUndefined() + expect(socket.disconnect).toHaveBeenCalledWith(true) + }) + ) + }) + + test('is total for arbitrary wire bytes', () => { + fc.assert( + fc.property(fc.uint8Array({ maxLength: 4096 }), bytes => { + const result = decodeAuthSocketEventPayload(Array.from(bytes)) + expect(typeof result.eventName).toBe('string') + }) + ) + }) + + test('round-trips arbitrary JSON event data', () => { + fc.assert( + fc.property(fc.string(), fc.jsonValue(), (eventName, data) => { + const payload = Array.from(Buffer.from(JSON.stringify({ eventName, data }), 'utf8')) + const canonicalData = JSON.parse(JSON.stringify(data)) + expect(decodeAuthSocketEventPayload(payload)).toEqual({ eventName, data: canonicalData }) + }) + ) + }) + + test('maps arbitrary non-envelope JSON values to the unknown event', () => { + fc.assert( + fc.property( + fc.jsonValue().filter(value => { + return !( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + typeof (value as { eventName?: unknown }).eventName === 'string' + ) + }), + value => { + const payload = Array.from(Buffer.from(JSON.stringify(value), 'utf8')) + expect(decodeAuthSocketEventPayload(payload)).toEqual({ + eventName: '_unknown', + data: null + }) + } + ) + ) + }) +}) diff --git a/packages/messaging/authsocket/test/AuthSocketServer.routing.test.ts b/packages/messaging/authsocket/test/AuthSocketServer.routing.test.ts index 9021f7e54..4f73725ef 100644 --- a/packages/messaging/authsocket/test/AuthSocketServer.routing.test.ts +++ b/packages/messaging/authsocket/test/AuthSocketServer.routing.test.ts @@ -26,8 +26,9 @@ describe('AuthSocketServer identity routing', () => { const error = new Error('send failed') const authenticatedPeer = { toPeer: jest.fn().mockResolvedValue(undefined) } const failingPeer = { toPeer: jest.fn().mockRejectedValue(error) } - const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}) + const onError = jest.fn() const server = Object.create(AuthSocketServer.prototype) as any + server.options = { onError } server.peers = new Map([ ['authenticated', { peer: authenticatedPeer, identityKey: 'recipient' }], ['failing', { peer: failingPeer, identityKey: 'recipient' }] @@ -38,7 +39,9 @@ describe('AuthSocketServer identity routing', () => { await new Promise(resolve => setImmediate(resolve)) expect(authenticatedPeer.toPeer).toHaveBeenCalledWith(expect.any(Array), 'recipient') - expect(consoleError).toHaveBeenCalledWith(error) - consoleError.mockRestore() + expect(onError).toHaveBeenCalledWith(error, { + phase: 'send', + eventName: expect.any(String) + }) }) }) diff --git a/packages/messaging/authsocket/test/SocketServerTransport.integration.test.ts b/packages/messaging/authsocket/test/SocketServerTransport.integration.test.ts new file mode 100644 index 000000000..9fd2f3d29 --- /dev/null +++ b/packages/messaging/authsocket/test/SocketServerTransport.integration.test.ts @@ -0,0 +1,79 @@ +import { createServer } from 'node:http' +import { AddressInfo } from 'node:net' +import { Server as IoServer } from 'socket.io' +import { io as createClient, Socket as ClientSocket } from 'socket.io-client' + +import { SocketServerTransport } from '../src/SocketServerTransport.js' + +function waitForEvent(socket: ClientSocket, eventName: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${eventName}`)), 5000) + socket.once(eventName, (...args: unknown[]) => { + clearTimeout(timeout) + resolve(args) + }) + }) +} + +describe('SocketServerTransport process survival', () => { + test('isolates a malicious peer and continues serving a subsequent connection', async () => { + const httpServer = createServer() + const ioServer = new IoServer(httpServer, { transports: ['websocket'] }) + const containedErrors: unknown[] = [] + let connectionCount = 0 + let acceptSecondMessage: (() => void) | undefined + const secondMessage = new Promise(resolve => { + acceptSecondMessage = resolve + }) + + ioServer.on('connection', socket => { + connectionCount += 1 + const connectionNumber = connectionCount + const transport = new SocketServerTransport(socket, { + onError: error => { + containedErrors.push(error) + } + }) + void transport.onData(async () => { + if (connectionNumber === 1) throw new Error('invalid signature') + acceptSecondMessage?.() + }) + }) + + await new Promise(resolve => httpServer.listen(0, '127.0.0.1', resolve)) + const { port } = httpServer.address() as AddressInfo + const url = `http://127.0.0.1:${port}` + const firstClient = createClient(url, { + transports: ['websocket'], + forceNew: true, + reconnection: false + }) + let secondClient: ClientSocket | undefined + + try { + await waitForEvent(firstClient, 'connect') + const disconnected = waitForEvent(firstClient, 'disconnect') + firstClient.emit('authMessage', { messageType: 'initialResponse' }) + await disconnected + + expect(containedErrors).toEqual([expect.objectContaining({ message: 'invalid signature' })]) + expect(httpServer.listening).toBe(true) + + secondClient = createClient(url, { + transports: ['websocket'], + forceNew: true, + reconnection: false + }) + await waitForEvent(secondClient, 'connect') + secondClient.emit('authMessage', { messageType: 'initialRequest' }) + await secondMessage + + expect(connectionCount).toBe(2) + expect(httpServer.listening).toBe(true) + } finally { + firstClient.disconnect() + secondClient?.disconnect() + await new Promise(resolve => ioServer.close(() => resolve())) + } + }) +}) diff --git a/packages/middleware/auth-express-middleware/package.json b/packages/middleware/auth-express-middleware/package.json index 9224d515f..2c68c62f4 100644 --- a/packages/middleware/auth-express-middleware/package.json +++ b/packages/middleware/auth-express-middleware/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/auth-express-middleware", - "version": "2.1.5", + "version": "2.1.6", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts b/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts index 82bab0a26..1c59f1bce 100644 --- a/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts +++ b/packages/middleware/auth-express-middleware/src/__tests/ExpressTransportHardening.test.ts @@ -300,6 +300,25 @@ describe('ExpressTransport hardening', () => { code: 'ERR_INTERNAL_SERVER_ERROR', description: 'Authentication processing failed.' }) + + const synchronousFailure = new ExpressTransport() + synchronousFailure.peer = peerMock() + await synchronousFailure.onData((() => { + throw new Error('synchronous private detail') + }) as never) + const synchronousResponse = responseMock() + await synchronousFailure.handleIncomingRequest( + validHandshakeRequest(), + synchronousResponse, + jest.fn() + ) + await flushPromises() + expect(synchronousResponse.status).toHaveBeenCalledWith(500) + expect(synchronousResponse.json).toHaveBeenCalledWith({ + status: 'error', + code: 'ERR_INTERNAL_SERVER_ERROR', + description: 'Authentication processing failed.' + }) }) it('clears a certificate listener before awaiting its callback and continues once', async () => { diff --git a/packages/middleware/auth-express-middleware/src/index.ts b/packages/middleware/auth-express-middleware/src/index.ts index 5ef6f8162..366b036c1 100644 --- a/packages/middleware/auth-express-middleware/src/index.ts +++ b/packages/middleware/auth-express-middleware/src/index.ts @@ -741,16 +741,19 @@ export class ExpressTransport implements Transport { if (this.messageCallback) { this.log('debug', 'Invoking stored messageCallback for non-general message') - this.messageCallback(message).catch(err => { - this.log('error', 'Error in messageCallback', safeErrorDetails(err)) - this.removeNonGeneralHandle(requestId) - this.clearActiveCertificateRequest(requestId) - return res.status(500).json({ - status: 'error', - code: 'ERR_INTERNAL_SERVER_ERROR', - description: 'Authentication processing failed.' + const messageCallback = this.messageCallback + void Promise.resolve() + .then(async () => await messageCallback(message)) + .catch(err => { + this.log('error', 'Error in messageCallback', safeErrorDetails(err)) + this.removeNonGeneralHandle(requestId) + this.clearActiveCertificateRequest(requestId) + return res.status(500).json({ + status: 'error', + code: 'ERR_INTERNAL_SERVER_ERROR', + description: 'Authentication processing failed.' + }) }) - }) } } @@ -921,21 +924,24 @@ export class ExpressTransport implements Transport { if (this.messageCallback) { this.log('debug', 'Invoking stored messageCallback for general message') - this.messageCallback(message).catch(err => { - this.clearActiveGeneralRequest(expectedRequestId) - const msg = err instanceof Error ? err.message : String(err) - const isAuthError = /nonce|signature|session|auth version/i.test(msg) - this.log('error', 'Error in messageCallback (general message)', { - ...safeErrorDetails(err), - isAuthError + const messageCallback = this.messageCallback + void Promise.resolve() + .then(async () => await messageCallback(message)) + .catch(err => { + this.clearActiveGeneralRequest(expectedRequestId) + const msg = err instanceof Error ? err.message : String(err) + const isAuthError = /nonce|signature|session|auth version/i.test(msg) + this.log('error', 'Error in messageCallback (general message)', { + ...safeErrorDetails(err), + isAuthError + }) + const statusCode = isAuthError ? 401 : 500 + const code = isAuthError ? 'ERR_AUTH_FAILED' : 'ERR_INTERNAL_SERVER_ERROR' + const description = isAuthError + ? 'Authentication failed.' + : 'Authentication processing failed.' + return res.status(statusCode).json({ status: 'error', code, description }) }) - const statusCode = isAuthError ? 401 : 500 - const code = isAuthError ? 'ERR_AUTH_FAILED' : 'ERR_INTERNAL_SERVER_ERROR' - const description = isAuthError - ? 'Authentication failed.' - : 'Authentication processing failed.' - return res.status(statusCode).json({ status: 'error', code, description }) - }) } } diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index e495bc0ce..8881b2507 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -295,6 +295,11 @@ All notable changes to this project will be documented in this file. The format ### Security +- Reject non-object BRC-103 messages before field access, await asynchronous + general-message and certificate listeners so their failures reach the owning + transport, and contain synchronous callback failures in the simplified HTTP + transport. + --- ## [2.1.4] - 2026-05-26 diff --git a/packages/sdk/package.json b/packages/sdk/package.json index e1d1f99da..f9badda76 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/sdk", - "version": "2.2.15", + "version": "2.2.16", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/sdk/src/auth/Peer.ts b/packages/sdk/src/auth/Peer.ts index 69255987d..40c8fe7c7 100644 --- a/packages/sdk/src/auth/Peer.ts +++ b/packages/sdk/src/auth/Peer.ts @@ -41,12 +41,12 @@ export class Peer { certificatesToRequest: RequestedCertificateSet private readonly onGeneralMessageReceivedCallbacks: Map< number, - (senderPublicKey: string, payload: number[]) => void + (senderPublicKey: string, payload: number[]) => void | Promise > = new Map() private readonly onCertificatesReceivedCallbacks: Map< number, - (senderPublicKey: string, certs: VerifiableCertificate[]) => void + (senderPublicKey: string, certs: VerifiableCertificate[]) => void | Promise > = new Map() private readonly onCertificateRequestReceivedCallbacks: Map< @@ -54,7 +54,7 @@ export class Peer { ( senderPublicKey: string, requestedCertificates: RequestedCertificateSet - ) => void + ) => void | Promise > = new Map() private readonly onInitialResponseReceivedCallbacks: Map< @@ -292,11 +292,11 @@ export class Peer { /** * Registers a callback to listen for general messages from peers. * - * @param {(senderPublicKey: string, payload: number[]) => void} callback - The function to call when a general message is received. + * @param {(senderPublicKey: string, payload: number[]) => void | Promise} callback - The function to call when a general message is received. * @returns {number} The ID of the callback listener. */ listenForGeneralMessages ( - callback: (senderPublicKey: string, payload: number[]) => void + callback: (senderPublicKey: string, payload: number[]) => void | Promise ): number { const callbackID = this.callbackIdCounter++ this.onGeneralMessageReceivedCallbacks.set(callbackID, callback) @@ -315,11 +315,11 @@ export class Peer { /** * Registers a callback to listen for certificates received from peers. * - * @param {(senderPublicKey: string, certs: VerifiableCertificate[]) => void} callback - The function to call when certificates are received. + * @param {(senderPublicKey: string, certs: VerifiableCertificate[]) => void | Promise} callback - The function to call when certificates are received. * @returns {number} The ID of the callback listener. */ listenForCertificatesReceived ( - callback: (senderPublicKey: string, certs: VerifiableCertificate[]) => void + callback: (senderPublicKey: string, certs: VerifiableCertificate[]) => void | Promise ): number { const callbackID = this.callbackIdCounter++ this.onCertificatesReceivedCallbacks.set(callbackID, callback) @@ -338,14 +338,14 @@ export class Peer { /** * Registers a callback to listen for certificates requested from peers. * - * @param {(requestedCertificates: RequestedCertificateSet) => void} callback - The function to call when a certificate request is received + * @param {(senderPublicKey: string, requestedCertificates: RequestedCertificateSet) => void | Promise} callback - The function to call when a certificate request is received * @returns {number} The ID of the callback listener. */ listenForCertificatesRequested ( callback: ( senderPublicKey: string, requestedCertificates: RequestedCertificateSet - ) => void + ) => void | Promise ): number { const callbackID = this.callbackIdCounter++ this.onCertificateRequestReceivedCallbacks.set(callbackID, callback) @@ -469,6 +469,9 @@ export class Peer { * @returns {Promise} */ private async handleIncomingMessage (message: AuthMessage): Promise { + if (message == null || typeof message !== 'object' || Array.isArray(message)) { + throw new Error('Invalid authentication message.') + } if (typeof message.version !== 'string' || message.version !== AUTH_VERSION) { throw new Error( `Invalid or unsupported message auth version! Received: ${message.version}, expected: ${AUTH_VERSION}` @@ -539,9 +542,12 @@ export class Peer { message.requestedCertificates.certifiers.length > 0 ) { if (this.onCertificateRequestReceivedCallbacks.size > 0) { - this.onCertificateRequestReceivedCallbacks.forEach(cb => { - cb(message.identityKey, message.requestedCertificates as RequestedCertificateSet) - }) + for (const callback of this.onCertificateRequestReceivedCallbacks.values()) { + await callback( + message.identityKey, + message.requestedCertificates as RequestedCertificateSet + ) + } } else { certificatesToInclude = await getVerifiableCertificates( this.wallet, @@ -659,9 +665,9 @@ export class Peer { this.resolveCertificateValidation(peerSession.sessionNonce) } - this.onCertificatesReceivedCallbacks.forEach(cb => - cb(message.identityKey, message.certificates as VerifiableCertificate[]) - ) + for (const callback of this.onCertificatesReceivedCallbacks.values()) { + await callback(message.identityKey, message.certificates as VerifiableCertificate[]) + } } private releaseInitialResponseWaiters(peerSession: PeerSession): void { @@ -681,9 +687,12 @@ export class Peer { return } if (this.onCertificateRequestReceivedCallbacks.size > 0) { - this.onCertificateRequestReceivedCallbacks.forEach(cb => { - cb(message.identityKey, message.requestedCertificates as RequestedCertificateSet) - }) + for (const callback of this.onCertificateRequestReceivedCallbacks.values()) { + await callback( + message.identityKey, + message.requestedCertificates as RequestedCertificateSet + ) + } return } const verifiableCertificates = await getVerifiableCertificates( @@ -750,9 +759,12 @@ export class Peer { ) { if (this.onCertificateRequestReceivedCallbacks.size > 0) { // Let the application handle it - this.onCertificateRequestReceivedCallbacks.forEach(cb => { - cb(message.identityKey, message.requestedCertificates as RequestedCertificateSet) - }) + for (const callback of this.onCertificateRequestReceivedCallbacks.values()) { + await callback( + message.identityKey, + message.requestedCertificates as RequestedCertificateSet + ) + } } else { // Attempt auto const verifiableCertificates = await getVerifiableCertificates( @@ -862,9 +874,9 @@ export class Peer { } // Notify any listeners - this.onCertificatesReceivedCallbacks.forEach(cb => { - cb(message.identityKey, message.certificates ?? []) - }) + for (const callback of this.onCertificatesReceivedCallbacks.values()) { + await callback(message.identityKey, message.certificates ?? []) + } } /** @@ -958,9 +970,9 @@ export class Peer { this.lastInteractedWithPeer = message.identityKey // Dispatch callbacks - this.onGeneralMessageReceivedCallbacks.forEach(cb => { - cb(message.identityKey, message.payload ?? []) - }) + for (const callback of this.onGeneralMessageReceivedCallbacks.values()) { + await callback(message.identityKey, message.payload ?? []) + } } /** diff --git a/packages/sdk/src/auth/__tests/Peer.test.ts b/packages/sdk/src/auth/__tests/Peer.test.ts index df63f686d..a68fab56b 100644 --- a/packages/sdk/src/auth/__tests/Peer.test.ts +++ b/packages/sdk/src/auth/__tests/Peer.test.ts @@ -1248,3 +1248,63 @@ describe('Peer class mutual authentication and certificate exchange', () => { }) }) }) + +describe('Peer untrusted callback boundaries', () => { + test('rejects null and non-object authentication messages without property access failures', async () => { + let receive: ((message: AuthMessage) => Promise) | undefined + const transport: Transport = { + async send() {}, + async onData(callback) { + receive = callback + } + } + const peer = new Peer(new CompletedProtoWallet(new PrivateKey(31)), transport) + await peer.ready + + await expect(receive?.(null as never)).rejects.toThrow('Invalid authentication message.') + await expect(receive?.([] as never)).rejects.toThrow('Invalid authentication message.') + await expect(receive?.('message' as never)).rejects.toThrow('Invalid authentication message.') + }) + + test('propagates an asynchronous general-message listener failure to its transport', async () => { + class CallbackAwareTransport implements Transport { + peer?: CallbackAwareTransport + callback?: (message: AuthMessage) => Promise + + connect(peer: CallbackAwareTransport): void { + this.peer = peer + peer.peer = this + } + + async send(message: AuthMessage): Promise { + const callback = this.peer?.callback + if (callback === undefined) throw new Error('Transport is not connected') + if (message.messageType === 'initialRequest' || message.messageType === 'initialResponse') { + setTimeout(() => { + void callback(message).catch(() => {}) + }, 0) + return + } + await callback(message) + } + + async onData(callback: (message: AuthMessage) => Promise): Promise { + this.callback = callback + } + } + + const aliceTransport = new CallbackAwareTransport() + const bobTransport = new CallbackAwareTransport() + aliceTransport.connect(bobTransport) + const aliceWallet = new CompletedProtoWallet(new PrivateKey(32)) + const bobWallet = new CompletedProtoWallet(new PrivateKey(33)) + const alice = new Peer(aliceWallet, aliceTransport) + const bob = new Peer(bobWallet, bobTransport) + await Promise.all([alice.ready, bob.ready]) + const listenerFailure = new Error('listener failed') + bob.listenForGeneralMessages(async () => await Promise.reject(listenerFailure)) + const bobIdentity = (await bobWallet.getPublicKey({ identityKey: true })).publicKey + + await expect(alice.toPeer([1, 2, 3], bobIdentity)).rejects.toThrow('listener failed') + }) +}) diff --git a/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts b/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts index ef849ffc9..49f63dd8f 100644 --- a/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts +++ b/packages/sdk/src/auth/transports/SimplifiedFetchTransport.ts @@ -235,10 +235,12 @@ export class SimplifiedFetchTransport implements Transport { */ async onData(callback: (message: AuthMessage) => Promise): Promise { this.onDataCallback = m => { - void callback(m).catch(() => { - // Errors from handleIncomingMessage on the client side are not - // actionable here — prevent unhandled promise rejections. - }) + void Promise.resolve() + .then(async () => await callback(m)) + .catch(() => { + // Errors from handleIncomingMessage on the client side are not + // actionable here — prevent unhandled promise rejections. + }) } } diff --git a/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts b/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts index e94d27599..3e435d75e 100644 --- a/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts +++ b/packages/sdk/src/auth/transports/__tests__/SimplifiedFetchTransport.additional.test.ts @@ -847,3 +847,24 @@ describe('SimplifiedFetchTransport — isTextualContent heuristics (via send res expect(err.message).toContain('0x') }) }) + +describe('SimplifiedFetchTransport callback containment', () => { + test('contains a synchronous callback throw from an untrusted response', async () => { + const mockFetch: any = jest.fn().mockResolvedValue( + new Response('', { + status: 200, + headers: { + 'x-bsv-auth-version': '0.1', + 'x-bsv-auth-identity-key': 'server-key', + 'x-bsv-auth-signature': 'aabbcc' + } + }) + ) + const transport = new SimplifiedFetchTransport('https://api.example.com', mockFetch) + await transport.onData((() => { + throw new Error('invalid response') + }) as never) + + await expect(transport.send(makeGeneralMessage())).resolves.toBeUndefined() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ccb5033a..08528eb38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -547,12 +547,18 @@ importers: '@typescript/native': specifier: npm:typescript@7.0.2 version: typescript@7.0.2 + fast-check: + specifier: ^4.9.0 + version: 4.9.0 jest: specifier: ^30.4.2 version: 30.4.2(@types/node@26.1.2)(ts-node@10.9.2(@types/node@26.1.2)(@typescript/typescript6@6.0.2)) oxlint: specifier: ^1.76.0 version: 1.76.0 + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 ts-jest: specifier: ^29.4.12 version: 29.4.12(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(@typescript/typescript6@6.0.2)(babel-jest@30.4.1(@babel/core@7.29.7))(esbuild@0.28.1)(jest-util@30.4.1)(jest@30.4.2(@types/node@26.1.2)(ts-node@10.9.2(@types/node@26.1.2)(@typescript/typescript6@6.0.2))) @@ -584,6 +590,9 @@ importers: '@typescript/native': specifier: npm:typescript@7.0.2 version: typescript@7.0.2 + fast-check: + specifier: ^4.9.0 + version: 4.9.0 jest: specifier: ^30.4.2 version: 30.4.2(@types/node@26.1.2)(ts-node@10.9.2(@types/node@26.1.2)(@typescript/typescript6@6.0.2)) diff --git a/scripts/test-governance.test.mjs b/scripts/test-governance.test.mjs index 4c958538e..e74af3f63 100644 --- a/scripts/test-governance.test.mjs +++ b/scripts/test-governance.test.mjs @@ -27,16 +27,16 @@ const walletManualSuiteInventory = JSON.parse( test('current required, manual, live, resource, and conformance tests are governed', () => { const result = evaluateTestGovernance({ policy, - today: '2026-07-26' + today: '2026-07-31' }) assert.deepEqual(result.errors, []) assert.equal(result.summary.requiredDirectSkips, 2) - assert.equal(result.summary.propertySuites, 26) - assert.equal(result.summary.propertyPackages, 26) - assert.equal(result.summary.propertyExcludedPackages, 8) + assert.equal(result.summary.propertySuites, 28) + assert.equal(result.summary.propertyPackages, 28) + assert.equal(result.summary.propertyExcludedPackages, 6) assert.equal(result.summary.propertyClassifiedPackages, 34) - assert.equal(result.summary.mutationTargets, 26) + assert.equal(result.summary.mutationTargets, 28) assert.equal(result.summary.manualAndLiveFiles, 32) assert.equal(result.summary.walletManualSuites, 30) assert.equal(result.summary.conformanceSkipFiles, 19) @@ -50,7 +50,7 @@ test('every property suite must retain an exact mutation-quality target', () => const result = evaluateTestGovernance({ policy, mutationPolicy, - today: '2026-07-26' + today: '2026-07-31' }) assert.match(result.errors.join('\n'), /lacks mutation validation/) @@ -62,7 +62,7 @@ test('an unregistered required skip fails the exact inventory', () => { changedPolicy.requiredSkips.pop() const result = evaluateTestGovernance({ policy: changedPolicy, - today: '2026-07-26' + today: '2026-07-31' }) assert.match(result.errors.join('\n'), /has unregistered skip/) From 9036b1aedf9ca951b086bd9ff65f11eba8ee7263 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 31 Jul 2026 11:37:27 -0700 Subject: [PATCH 2/9] ci: re-evaluate dependency evidence From 4f94be6630ba076dc7a6aa9aad365e7b0f00840b Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 31 Jul 2026 12:20:12 -0700 Subject: [PATCH 3/9] ci: scope expensive validation to affected graph --- .github/workflows/ci.yml | 354 ++++++++++++------ .../workflows/container-runtime-contract.yml | 73 ++-- docs/reference/ci-performance.md | 39 +- docs/reference/container-supply-chain.md | 14 +- docs/reference/repository-health.md | 15 +- docs/reference/test-quality-governance.md | 23 +- scripts/ci-affected-scope.mjs | 339 +++++++++++++++++ scripts/ci-affected-scope.test.mjs | 125 +++++++ scripts/ci-orchestration.test.mjs | 6 +- scripts/container-supply-chain.test.mjs | 11 +- scripts/mutation-testing.mjs | 153 +++++++- scripts/mutation-testing.test.mjs | 51 ++- scripts/repository-health.test.mjs | 7 +- scripts/run-ci-tests.mjs | 5 +- scripts/run-ci-tests.test.mjs | 7 + 15 files changed, 1015 insertions(+), 207 deletions(-) create mode 100644 scripts/ci-affected-scope.mjs create mode 100644 scripts/ci-affected-scope.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index faabfafe4..b7eb0ad19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,70 @@ jobs: --format text --summary-file "$GITHUB_STEP_SUMMARY" + scope: + name: Select affected work + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + direct-projects: ${{ steps.scope.outputs.direct-projects }} + affected-projects: ${{ steps.scope.outputs.affected-projects }} + build-projects: ${{ steps.scope.outputs.build-projects }} + mutation-targets: ${{ steps.scope.outputs.mutation-targets }} + infra-matrix: ${{ steps.scope.outputs.infra-matrix }} + docs: ${{ steps.scope.outputs.docs }} + conformance: ${{ steps.scope.outputs.conformance }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Use Node.js 24.x + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.x + + - name: Resolve package, mutation, documentation, conformance, and image scope + id: scope + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BASE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + BASE_SHA="$PR_BASE_SHA" + if [ -z "$BASE_SHA" ]; then BASE_SHA="$PUSH_BASE_SHA"; fi + if [ -n "$BASE_SHA" ] && + [ "$BASE_SHA" != "0000000000000000000000000000000000000000" ] && + git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then + SCOPE=$(node scripts/ci-affected-scope.mjs --base "$BASE_SHA" --head "$HEAD_SHA") + git diff --name-only "$BASE_SHA"..."$HEAD_SHA" > .ci-changed-files.txt + MUTATION_TARGETS=$(node scripts/mutation-testing.mjs \ + --affected-file .ci-changed-files.txt \ + --base "$BASE_SHA") + else + SCOPE=$(node scripts/ci-affected-scope.mjs --all) + MUTATION_TARGETS=$(node scripts/mutation-testing.mjs --list | \ + jq -R -s -c 'split("\n") | map(select(length > 0))') + fi + + echo "direct-projects=$(jq -c '.directProjects' <<<"$SCOPE")" >> "$GITHUB_OUTPUT" + echo "affected-projects=$(jq -c '.affectedProjects' <<<"$SCOPE")" >> "$GITHUB_OUTPUT" + echo "build-projects=$(jq -c '.buildProjects' <<<"$SCOPE")" >> "$GITHUB_OUTPUT" + echo "infra-matrix=$(jq -c '.infraMatrix' <<<"$SCOPE")" >> "$GITHUB_OUTPUT" + echo "docs=$(jq -r '.docs' <<<"$SCOPE")" >> "$GITHUB_OUTPUT" + echo "conformance=$(jq -r '.conformance' <<<"$SCOPE")" >> "$GITHUB_OUTPUT" + echo "mutation-targets=$MUTATION_TARGETS" >> "$GITHUB_OUTPUT" + + { + echo '### Affected scope' + echo + echo "- Direct test packages: $(jq -c '[.directProjects[].name]' <<<"$SCOPE")" + echo "- Compatibility packages: $(jq -c '[.affectedProjects[].name]' <<<"$SCOPE")" + echo "- Mutation targets: $MUTATION_TARGETS" + echo "- Infrastructure: $(jq -c '[.infraMatrix.include[].display]' <<<"$SCOPE")" + } >> "$GITHUB_STEP_SUMMARY" + sonar-zero-findings: name: Quality gate — zero new Sonar findings if: github.event_name == 'pull_request' @@ -90,19 +154,26 @@ jobs: prepare: name: Build, lint, and policy + needs: + - repository-health + - scope runs-on: ubuntu-latest permissions: contents: read pull-requests: read outputs: - filter: ${{ steps.scope.outputs.filter }} + direct-projects: ${{ needs.scope.outputs.direct-projects }} + affected-projects: ${{ needs.scope.outputs.affected-projects }} + build-projects: ${{ needs.scope.outputs.build-projects }} standard-packages: ${{ steps.scope.outputs.standard-packages }} + dependent-test-packages: ${{ steps.scope.outputs.dependent-test-packages }} + dependent-test-matrix: ${{ steps.scope.outputs.dependent-test-matrix }} coverage-other-packages: ${{ steps.scope.outputs.coverage-other-packages }} coverage-other-matrix: ${{ steps.scope.outputs.coverage-other-matrix }} browser-packages: ${{ steps.scope.outputs.browser-packages }} browser-matrix: ${{ steps.scope.outputs.browser-matrix }} coverage-required: ${{ steps.scope.outputs.coverage-required }} - mutation-targets: ${{ steps.scope.outputs.mutation-targets }} + mutation-targets: ${{ needs.scope.outputs.mutation-targets }} sdk: ${{ steps.scope.outputs.sdk }} did: ${{ steps.scope.outputs.did }} wallet: ${{ steps.scope.outputs.wallet }} @@ -148,48 +219,39 @@ jobs: run: node scripts/check-sdk-peer.mjs - name: Determine changed scope - # PRs normally test changed packages plus dependents. Changes to the CI - # orchestration, root toolchain, or coverage configuration intentionally - # exercise the complete workspace so those changes validate themselves. + # Coverage suites run only for directly changed package importers. + # Regression/type compatibility expands through dependents; builds also + # include their prerequisites. + # The preceding repository-health dependency prevents expensive work + # from starting behind a predictable policy or PR-evidence failure. id: scope + env: + DIRECT_PROJECTS: ${{ needs.scope.outputs.direct-projects }} + AFFECTED_PROJECTS: ${{ needs.scope.outputs.affected-projects }} run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - BASE_SHA="${{ github.event.pull_request.base.sha }}" - CHANGED=$(git diff --name-only "${BASE_SHA}"...HEAD) - if echo "$CHANGED" | grep -Eq '^(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig.base.json|codecov.yml|sonar-project.properties|\.sonarcloud\.properties|\.github/workflows/(ci|ci-performance|property-tests|mutation-tests|release)\.(yml|yaml)|governance/(npm-package-supply-chain\.json|repository-health|test-quality|mutation-testing)/|scripts/(check-browser-package|check-package-artifact|check-wallet-toolbox-platform|ci-performance|package-release-artifacts|repository-health|run-ci-tests|run-prebuilt-package-script|normalize-lcov-paths|run-governed-test|sonar-pr-gate|test-governance|mutation-testing|typescript-toolchain|sync-service-(rate-limit|edge)-policy|lib/workspace-packages)\.(mjs|test\.mjs)$)'; then - FILTER="" - else - FILTER="...[${BASE_SHA}]" - fi - else - FILTER="" - BEFORE_SHA="${{ github.event.before }}" - if [ -z "$BEFORE_SHA" ] || - [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ] || - ! git cat-file -e "$BEFORE_SHA^{commit}" 2>/dev/null; then - CHANGED=$(git ls-files) - else - CHANGED=$(git diff --name-only "$BEFORE_SHA"...HEAD) - fi - fi + DIRECT=$(jq -r '.[].name' <<<"$DIRECT_PROJECTS") + printf '%s\n' "$DIRECT_PROJECTS" > .ci-direct-packages.json + printf '%s\n' "$AFFECTED_PROJECTS" > .ci-affected-packages.json + DEPENDENT_PROJECTS=$(jq -n -c \ + --argjson affected "$AFFECTED_PROJECTS" \ + --argjson direct "$DIRECT_PROJECTS" \ + '$direct | map(.name) as $directNames | $affected | map(select(.name as $name | ($directNames | index($name) | not)))') + printf '%s\n' "$DEPENDENT_PROJECTS" > .ci-dependent-packages.json + + DEPENDENT_TEST_PACKAGES=$(node scripts/run-ci-tests.mjs \ + --projects-json .ci-dependent-packages.json \ + --mode test) + echo "dependent-test-packages=$DEPENDENT_TEST_PACKAGES" >> "$GITHUB_OUTPUT" + DEPENDENT_TEST_MATRIX=$(jq -c \ + 'if length > 12 then {include:[{shard:1,total:4},{shard:2,total:4},{shard:3,total:4},{shard:4,total:4}]} elif length > 1 then {include:[{shard:1,total:2},{shard:2,total:2}]} else {include:[{shard:1,total:1}]} end' \ + <<<"$DEPENDENT_TEST_PACKAGES") + echo "dependent-test-matrix=$DEPENDENT_TEST_MATRIX" >> "$GITHUB_OUTPUT" - printf '%s\n' "$CHANGED" > .ci-changed-files.txt - echo "filter=$FILTER" >> "$GITHUB_OUTPUT" - MUTATION_TARGETS=$(node scripts/mutation-testing.mjs \ - --affected-file .ci-changed-files.txt) - echo "mutation-targets=$MUTATION_TARGETS" >> "$GITHUB_OUTPUT" - if [ -n "$FILTER" ]; then - PROJECTS=$(pnpm -r --filter "$FILTER" list --depth -1 --json) - else - PROJECTS=$(pnpm -r list --depth -1 --json) - fi - AFFECTED=$(jq -r '.[].name' <<<"$PROJECTS") - printf '%s\n' "$PROJECTS" > .ci-affected-packages.json STANDARD_PACKAGES=$(node scripts/run-ci-tests.mjs \ - --projects-json .ci-affected-packages.json) + --projects-json .ci-direct-packages.json) echo "standard-packages=$STANDARD_PACKAGES" >> "$GITHUB_OUTPUT" COVERAGE_OTHER_PACKAGES=$(node scripts/run-ci-tests.mjs \ - --projects-json .ci-affected-packages.json \ + --projects-json .ci-direct-packages.json \ --mode coverage-other) echo "coverage-other-packages=$COVERAGE_OTHER_PACKAGES" >> "$GITHUB_OUTPUT" COVERAGE_OTHER_MATRIX=$(jq -c \ @@ -207,7 +269,7 @@ jobs: COVERAGE_REQUIRED=false if [ "$COVERAGE_OTHER_PACKAGES" != "[]" ] || - grep -Eq '^(@bsv/did|@bsv/sdk|@bsv/verifast|@bsv/wallet-toolbox)$' <<<"$AFFECTED"; then + grep -Eq '^(@bsv/did|@bsv/sdk|@bsv/verifast|@bsv/wallet-toolbox)$' <<<"$DIRECT"; then COVERAGE_REQUIRED=true fi echo "coverage-required=$COVERAGE_REQUIRED" >> "$GITHUB_OUTPUT" @@ -221,7 +283,7 @@ jobs: "verifast:@bsv/verifast"; do key="${entry%%:*}" package="${entry#*:}" - if grep -Fxq "$package" <<<"$AFFECTED"; then + if grep -Fxq "$package" <<<"$DIRECT"; then echo "$key=true" >> "$GITHUB_OUTPUT" else echo "$key=false" >> "$GITHUB_OUTPUT" @@ -229,34 +291,50 @@ jobs: done - name: Build workspace - # Build the full graph once. Parallel test jobs restore these outputs, - # avoiding repeated prerequisite builds while retaining a clean, - # frozen install in every isolation boundary. - # The docs site is built by its parallel validation job. - # Exclude: workspace root, docs site, and example-paymail. + env: + BUILD_PROJECTS: ${{ needs.scope.outputs.build-projects }} run: | - pnpm -r \ - --filter '!@bsv/ts-stack' \ - --filter '!docs-site' \ - --filter '!example-paymail' \ - run build + filters=() + while IFS= read -r package; do filters+=(--filter "$package"); done < <( + jq -r '.[].name | select(. != "docs-site" and . != "example-paymail")' <<<"$BUILD_PROJECTS" + ) + if [ "${#filters[@]}" -eq 0 ]; then + echo "No package build is affected." + else + pnpm -r --if-present "${filters[@]}" run build + fi - name: Typecheck workspace - # Build outputs exist before this step, so package declarations resolve - # exactly as they do for downstream consumers. The recursive contract - # catches cross-package nominal-type drift that isolated builds can miss. - run: pnpm typecheck + env: + AFFECTED_PROJECTS: ${{ needs.scope.outputs.affected-projects }} + run: | + filters=() + while IFS= read -r package; do filters+=(--filter "$package"); done < <( + jq -r '.[].name | select(. != "docs-site" and . != "example-paymail")' <<<"$AFFECTED_PROJECTS" + ) + if [ "${#filters[@]}" -eq 0 ]; then + echo "No package typecheck is affected." + else + pnpm -r --if-present "${filters[@]}" run typecheck + fi - name: Compile documentation examples against exact package tarballs + if: needs.scope.outputs.direct-projects != '[]' run: pnpm docs:examples - name: Verify changed package artifacts env: - SCOPE_FILTER: ${{ steps.scope.outputs.filter }} + DIRECT_PROJECTS: ${{ needs.scope.outputs.direct-projects }} run: | - EXTRA="" - if [ -n "$SCOPE_FILTER" ]; then EXTRA="--filter $SCOPE_FILTER"; fi - pnpm -r --if-present --filter '!@bsv/ts-stack' $EXTRA run pack:check + filters=() + while IFS= read -r package; do filters+=(--filter "$package"); done < <( + jq -r '.[].name' <<<"$DIRECT_PROJECTS" + ) + if [ "${#filters[@]}" -eq 0 ]; then + echo "No changed package artifact requires verification." + else + pnpm -r --if-present "${filters[@]}" run pack:check + fi - name: Dry-run the npm artifact supply-chain boundary run: | @@ -275,17 +353,16 @@ jobs: - name: Check repository and changed-package formatting env: - SCOPE_FILTER: ${{ steps.scope.outputs.filter }} + DIRECT_PROJECTS: ${{ needs.scope.outputs.direct-projects }} run: | - EXTRA="" - if [ -n "$SCOPE_FILTER" ]; then EXTRA="--filter $SCOPE_FILTER"; fi pnpm format:root - pnpm -r --if-present \ - --filter '!@bsv/ts-stack' \ - --filter '!docs-site' \ - --filter '!@bsv/conformance-runner' \ - --filter '!@bsv/conformance-runner-ts' \ - $EXTRA run format:check + filters=() + while IFS= read -r package; do filters+=(--filter "$package"); done < <( + jq -r '.[].name | select(. != "docs-site" and . != "@bsv/conformance-runner" and . != "@bsv/conformance-runner-ts")' <<<"$DIRECT_PROJECTS" + ) + if [ "${#filters[@]}" -gt 0 ]; then + pnpm -r --if-present "${filters[@]}" run format:check + fi - name: Archive build outputs run: | @@ -313,7 +390,7 @@ jobs: permissions: contents: read strategy: - fail-fast: false + fail-fast: true max-parallel: 6 matrix: target: ${{ fromJSON(needs.prepare.outputs.mutation-targets) }} @@ -405,6 +482,69 @@ jobs: done pnpm -r "${filters[@]}" run test + dependent-tests: + name: Tests / affected dependents (${{ matrix.shard }}/${{ matrix.total }}) + if: needs.prepare.outputs.dependent-test-packages != '[]' + needs: prepare + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: true + matrix: ${{ fromJSON(needs.prepare.outputs.dependent-test-matrix) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile --ignore-scripts + - name: Restore the immutable MongoDB test binary + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/mongodb-binaries + key: mongodb-memory-server-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: build-outputs + path: .ci-artifacts + - run: tar --extract --gzip --file .ci-artifacts/build-outputs.tar.gz + - name: Run affected dependent regressions without regenerating coverage + env: + DEPENDENT_TEST_PACKAGES: ${{ needs.prepare.outputs.dependent-test-packages }} + MONGOMS_DOWNLOAD_DIR: ${{ runner.temp }}/mongodb-binaries + PREBUILT_PACKAGE_OUTPUTS: '1' + run: | + packages=() + index=0 + while IFS= read -r package; do + if [ $((index % ${{ matrix.total }} + 1)) -eq "${{ matrix.shard }}" ]; then + packages+=("$package") + fi + index=$((index + 1)) + done < <(jq -r '.[]' <<<"$DEPENDENT_TEST_PACKAGES") + + if printf '%s\n' "${packages[@]}" | grep -Fxq '@bsv/wallet-toolbox'; then + pnpm --filter @bsv/wallet-toolbox rebuild better-sqlite3 + fi + if printf '%s\n' "${packages[@]}" | grep -Fxq '@bsv/overlay-topics'; then + pnpm --filter @bsv/overlay-topics exec node --input-type=module -e \ + "import { MongoMemoryServer } from 'mongodb-memory-server'; const s = await MongoMemoryServer.create({ instance: { launchTimeout: 120000 } }); await s.stop(); console.log('mongodb-memory-server binary cache warmed');" + fi + + filters=() + echo "Affected dependent test shard ${{ matrix.shard }}/${{ matrix.total }}:" + for package in "${packages[@]}"; do + echo " - $package" + filters+=(--filter "$package") + done + if [ "${#filters[@]}" -gt 0 ]; then + pnpm -r --no-sort "${filters[@]}" exec node \ + "$GITHUB_WORKSPACE/scripts/run-prebuilt-package-script.mjs" \ + --script test + fi + browser-packages: name: Platform / browser packages (${{ matrix.shard }}/${{ matrix.total }}) if: needs.prepare.outputs.browser-packages != '[]' @@ -413,7 +553,7 @@ jobs: permissions: contents: read strategy: - fail-fast: false + fail-fast: true matrix: ${{ fromJSON(needs.prepare.outputs.browser-matrix) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -616,7 +756,7 @@ jobs: permissions: contents: read strategy: - fail-fast: false + fail-fast: true matrix: shard: [1, 2, 3, 4] steps: @@ -759,7 +899,7 @@ jobs: permissions: contents: read strategy: - fail-fast: false + fail-fast: true matrix: ${{ fromJSON(needs.prepare.outputs.coverage-other-matrix) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -913,6 +1053,7 @@ jobs: needs: - prepare - standard-tests + - dependent-tests - browser-packages - coverage-upload - wallet-browser-platform @@ -924,6 +1065,7 @@ jobs: env: PREPARE_RESULT: ${{ needs.prepare.result }} TEST_RESULT: ${{ needs.standard-tests.result }} + DEPENDENT_TEST_RESULT: ${{ needs.dependent-tests.result }} PACKAGE_BROWSER_RESULT: ${{ needs.browser-packages.result }} COVERAGE_RESULT: ${{ needs.coverage-upload.result }} BROWSER_RESULT: ${{ needs.wallet-browser-platform.result }} @@ -931,62 +1073,31 @@ jobs: run: | if [[ "$PREPARE_RESULT" != "success" || ( "$TEST_RESULT" != "success" && "$TEST_RESULT" != "skipped" ) || + ( "$DEPENDENT_TEST_RESULT" != "success" && "$DEPENDENT_TEST_RESULT" != "skipped" ) || ( "$PACKAGE_BROWSER_RESULT" != "success" && "$PACKAGE_BROWSER_RESULT" != "skipped" ) || ( "$COVERAGE_RESULT" != "success" && "$COVERAGE_RESULT" != "skipped" ) || ( "$BROWSER_RESULT" != "success" && "$BROWSER_RESULT" != "skipped" ) || ( "$MOBILE_RESULT" != "success" && "$MOBILE_RESULT" != "skipped" ) ]]; then - echo "::error::Build/test lanes failed: prepare=$PREPARE_RESULT tests=$TEST_RESULT package-browser=$PACKAGE_BROWSER_RESULT coverage=$COVERAGE_RESULT wallet-browser=$BROWSER_RESULT mobile=$MOBILE_RESULT" + echo "::error::Build/test lanes failed: prepare=$PREPARE_RESULT direct-tests=$TEST_RESULT dependent-tests=$DEPENDENT_TEST_RESULT package-browser=$PACKAGE_BROWSER_RESULT coverage=$COVERAGE_RESULT wallet-browser=$BROWSER_RESULT mobile=$MOBILE_RESULT" exit 1 fi infra-scope: name: Detect affected infrastructure + needs: + - repository-health + - scope runs-on: ubuntu-latest - permissions: - contents: read + permissions: {} outputs: - matrix: ${{ steps.matrix.outputs.matrix }} + matrix: ${{ needs.scope.outputs.infra-matrix }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - name: Build affected component matrix - id: matrix + - name: Confirm dependency-aware component selection + env: + INFRA_MATRIX: ${{ needs.scope.outputs.infra-matrix }} run: | - COMPONENTS='[ - {"component":"chaintracks-server","native-modules":"better-sqlite3"}, - {"component":"message-box-server","native-modules":"better-sqlite3"}, - {"component":"overlay-server","native-modules":""}, - {"component":"uhrp-server-basic","native-modules":""}, - {"component":"uhrp-server-cloud-bucket","native-modules":"better-sqlite3"}, - {"component":"uhrp-server-cloud-bucket/notifier","native-modules":""}, - {"component":"wab","native-modules":"better-sqlite3 sqlite3"}, - {"component":"wallet-infra","native-modules":"better-sqlite3"} - ]' - - if [ "${{ github.event_name }}" = "push" ]; then - SELECTED=$(jq -c 'map(. + {run:true, display:.component})' <<<"$COMPONENTS") - else - BASE_SHA="${{ github.event.pull_request.base.sha }}" - if git diff --quiet "$BASE_SHA"...HEAD -- .github/workflows/ci.yml; then - SELECTED='[]' - while IFS= read -r row; do - component=$(jq -r '.component' <<<"$row") - if ! git diff --quiet "$BASE_SHA"...HEAD -- "infra/$component/"; then - SELECTED=$(jq -c --argjson row "$row" '. + [$row + {run:true, display:$row.component}]' <<<"$SELECTED") - fi - done < <(jq -c '.[]' <<<"$COMPONENTS") - else - SELECTED=$(jq -c 'map(. + {run:true, display:.component})' <<<"$COMPONENTS") - fi - fi - - if [ "$(jq 'length' <<<"$SELECTED")" -eq 0 ]; then - SELECTED='[{"component":"_none","native-modules":"","run":false,"display":"no changes"}]' - fi - - jq -cn --argjson include "$SELECTED" '{include:$include}' | - sed 's/^/matrix=/' >> "$GITHUB_OUTPUT" + jq -e '.include | length > 0' <<<"$INFRA_MATRIX" >/dev/null + echo "Selected infrastructure: $(jq -c '[.include[].display]' <<<"$INFRA_MATRIX")" infra: name: Infra / ${{ matrix.display }} @@ -995,7 +1106,7 @@ jobs: permissions: contents: read strategy: - fail-fast: false + fail-fast: true matrix: ${{ fromJson(needs.infra-scope.outputs.matrix) }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -1071,10 +1182,13 @@ jobs: docs-validate: name: Docs Site Validation + needs: + - repository-health + - scope runs-on: ubuntu-latest # Run on every PR (cheap and protects the docs site) and on pushes that touch docs # The build step inside will fail fast on frontmatter or link problems before they reach production - if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || github.event_name == 'push' + if: needs.scope.outputs.docs == 'true' permissions: contents: read steps: @@ -1113,6 +1227,10 @@ jobs: conformance: name: Conformance Vectors + needs: + - repository-health + - scope + if: needs.scope.outputs.conformance == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -1201,8 +1319,8 @@ jobs: "$BUILD_RESULT" != "success" || "$MUTATION_RESULT" != "success" || "$INFRA_RESULT" != "success" || - "$DOCS_RESULT" != "success" || - "$CONFORMANCE_RESULT" != "success" ]]; then + ( "$DOCS_RESULT" != "success" && "$DOCS_RESULT" != "skipped" ) || + ( "$CONFORMANCE_RESULT" != "success" && "$CONFORMANCE_RESULT" != "skipped" ) ]]; then echo "::error::Required CI failed: health=$HEALTH_RESULT build=$BUILD_RESULT mutation=$MUTATION_RESULT infra=$INFRA_RESULT docs=$DOCS_RESULT conformance=$CONFORMANCE_RESULT" exit 1 fi diff --git a/.github/workflows/container-runtime-contract.yml b/.github/workflows/container-runtime-contract.yml index d3bba20c0..61916ee12 100644 --- a/.github/workflows/container-runtime-contract.yml +++ b/.github/workflows/container-runtime-contract.yml @@ -8,9 +8,10 @@ on: - 'governance/container-images.json' - 'governance/service-operations.json' - 'infra/**' - - 'packages/wallet/wallet-toolbox/**' - 'scripts/container-runtime-contract.mjs' - 'scripts/container-runtime-contract.test.mjs' + - 'scripts/ci-affected-scope.mjs' + - 'scripts/ci-affected-scope.test.mjs' - 'scripts/service-operations.mjs' push: branches: [main] @@ -19,9 +20,10 @@ on: - 'governance/container-images.json' - 'governance/service-operations.json' - 'infra/**' - - 'packages/wallet/wallet-toolbox/**' - 'scripts/container-runtime-contract.mjs' - 'scripts/container-runtime-contract.test.mjs' + - 'scripts/ci-affected-scope.mjs' + - 'scripts/ci-affected-scope.test.mjs' - 'scripts/service-operations.mjs' permissions: {} @@ -31,38 +33,57 @@ concurrency: cancel-in-progress: true jobs: + scope: + name: Select affected runtime images + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + matrix: ${{ steps.scope.outputs.matrix }} + has-runtime: ${{ steps.scope.outputs.has-runtime }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.0 + + - name: Resolve runtime contexts from the changed-file graph + id: scope + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BASE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + BASE_SHA="$PR_BASE_SHA" + if [ -z "$BASE_SHA" ]; then BASE_SHA="$PUSH_BASE_SHA"; fi + if [ -z "$BASE_SHA" ] || + [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ] || + ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then + SCOPE=$(node scripts/ci-affected-scope.mjs --all) + else + SCOPE=$(node scripts/ci-affected-scope.mjs --base "$BASE_SHA" --head "$HEAD_SHA") + fi + MATRIX=$(jq -c '.runtimeMatrix' <<<"$SCOPE") + echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" + echo "has-runtime=$(jq -r '.include | length > 0' <<<"$MATRIX")" >> "$GITHUB_OUTPUT" + echo "Runtime images: $(jq -c '[.include[].component.name]' <<<"$MATRIX")" >> "$GITHUB_STEP_SUMMARY" + runtime: name: Runtime / ${{ matrix.component.name }} + if: needs.scope.outputs.has-runtime == 'true' + needs: scope runs-on: ubuntu-24.04 timeout-minutes: 35 permissions: contents: read strategy: - fail-fast: false + fail-fast: true max-parallel: 7 - matrix: - component: - - name: chaintracks-server - path: infra/chaintracks-server - wallet: false - - name: message-box-server - path: infra/message-box-server - wallet: true - - name: overlay-server - path: infra/overlay-server - wallet: true - - name: uhrp-server-basic - path: infra/uhrp-server-basic - wallet: true - - name: uhrp-server-cloud-bucket - path: infra/uhrp-server-cloud-bucket - wallet: true - - name: wab - path: infra/wab - wallet: false - - name: wallet-infra - path: infra/wallet-infra - wallet: false + matrix: ${{ fromJSON(needs.scope.outputs.matrix) }} services: mysql: image: mysql@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb diff --git a/docs/reference/ci-performance.md b/docs/reference/ci-performance.md index e83e8558a..e7aa3f963 100644 --- a/docs/reference/ci-performance.md +++ b/docs/reference/ci-performance.md @@ -2,9 +2,9 @@ id: ci-performance title: 'CI Performance Governance' kind: reference -version: '1.0.0' -last_updated: '2026-07-30' -last_verified: '2026-07-30' +version: '1.1.0' +last_updated: '2026-07-31' +last_verified: '2026-07-31' review_cadence_days: 30 status: stable tags: [reference, ci, performance, github-actions] @@ -23,13 +23,32 @@ prepare-job duration; artifact upload/download duration; and variance. This separates targeted feedback from the complete merge gate so a changing PR mix cannot make the trend appear faster or slower by accident. -The main CI workflow builds the workspace once and shares immutable outputs -with isolated test lanes, skips empty affected-package lanes, installs through -the setup-node pnpm cache, caches the immutable MongoDB test binary, and -rebuilds native/build tools only in jobs that execute them. Browser lanes -retain exact package-composition reports without rebuilding the workspace. -These optimizations reduce repeated CPU, network, and setup work without -removing coverage, mutation, platform, security, or package-consumer checks. +The zero-install scope job resolves three distinct package sets from the +workspace dependency graph. Directly changed package importers own coverage +suites; their reverse-dependency closure owns non-instrumented regression, +compatibility, and browser checks; and the forward closure supplies every build +prerequisite. This preserves behavioral coverage of possible consumers without +paying to regenerate unchanged packages' coverage reports. +A lockfile-only change selects the importers whose lock snapshots actually +changed instead of treating the root lockfile as a global invalidation. Root +compiler and workspace controls still select the complete graph deliberately. + +Mutation selection follows each target's exact implementation, property, +regression, configuration, and policy inputs. Package-wide mutation suites +expand only where their configuration really covers the whole package. Image +jobs follow changed build contexts: a CI-workflow-only change selects no +application image, while shared image/runtime contract inputs deliberately fan +out to the registered consumers. + +The main CI workflow builds the selected graph once and shares immutable +outputs with isolated test lanes, skips empty lanes, installs through the +setup-node pnpm cache, caches the immutable MongoDB test binary, and rebuilds +native/build tools only in jobs that execute them. Browser lanes retain exact +package-composition reports without rebuilding the workspace. The cheap +repository-health and scope gates complete before dependency installation and +all expensive matrices cancel unfinished siblings after the first failure. +These controls reduce repeated CPU, network, and setup work without weakening +the tests selected by the dependency or registered trust-boundary graph. To refresh the evidence without changing the baseline: diff --git a/docs/reference/container-supply-chain.md b/docs/reference/container-supply-chain.md index 5003af7a8..d48c3389d 100644 --- a/docs/reference/container-supply-chain.md +++ b/docs/reference/container-supply-chain.md @@ -59,12 +59,14 @@ dependency graph. ## Pull request gates -The infrastructure CI matrix builds all registered images on GitHub's -Linux/amd64 runners and scans each resulting image with Trivy. Any high or -critical OS or library vulnerability, including one without an upstream fix, -blocks the merge. A finding must be fixed or entered in the repository's -time-bounded exception registry with an owner, evidence, review date, and -objective removal condition. +The infrastructure CI matrix builds and scans only images whose build contexts +changed. Shared image-governance or runtime-contract changes expand to every +registered consumer, while application packages and CI orchestration alone do +not build unrelated images. GitHub's Linux/amd64 runners scan every selected +image with Trivy. Any high or critical OS or library vulnerability, including +one without an upstream fix, blocks the merge. A finding must be fixed or +entered in the repository's time-bounded exception registry with an owner, +evidence, review date, and objective removal condition. All base-image and deployment references use a readable version tag plus a content digest. Dependabot proposes reviewed Docker refreshes alongside the diff --git a/docs/reference/repository-health.md b/docs/reference/repository-health.md index 7c68152e8..9ebb08770 100644 --- a/docs/reference/repository-health.md +++ b/docs/reference/repository-health.md @@ -215,12 +215,15 @@ checks: unreviewed security hotspots, including findings reclassified as accepted or false-positive. -Affected package changes also select their matching mutation targets. The -parallel mutation lane restores the shared workspace build, executes only the -focused property and regression tests for each selected implementation -boundary, retains its JSON report, and feeds the required merge gate. SDK, -toolchain, CI, and governance changes deliberately fan out to the full target -registry. A separate scheduled workflow validates all targets weekly. +Changed implementation, property, regression, configuration, and policy inputs +select their exact mutation targets. The parallel mutation lane restores the +shared workspace build, executes only the focused property and regression tests +for each selected implementation boundary, retains its JSON report, and feeds +the required merge gate. Only root mutation tooling changes deliberately fan +out to the full target registry. Selector and scoring changes are exercised by +the zero-install repository contract instead of spending the same mutation +matrix merely to test which matrix was selected. A separate scheduled workflow +validates all targets weekly. The job writes a rule-by-rule and project-by-project report to the GitHub Actions step summary and feeds the required `merge-gate`. Known findings stay visible diff --git a/docs/reference/test-quality-governance.md b/docs/reference/test-quality-governance.md index 4d60ad38d..0ee5ee325 100644 --- a/docs/reference/test-quality-governance.md +++ b/docs/reference/test-quality-governance.md @@ -172,15 +172,20 @@ property when a survivor identifies a concrete boundary. Narrow a mutation scope only to the implementation contract described by the registered property; never exclude product code merely to raise the score. -Pull requests run the targets owned by changed packages. Changes to the SDK, -root toolchain, mutation governance, or CI fan out to all 25 targets. The -independent `Mutation quality` workflow runs the full matrix every Sunday and -can run one exact target manually. Targets execute in parallel, reuse one -workspace build, and preserve machine-readable reports for 30 days. Mutation -runs use the policy's fixed 300-case fast-check seed by default so the dry run -and every mutant see the same generated campaign. `FAST_CHECK_NUM_RUNS`, -`FAST_CHECK_SEED`, and `FAST_CHECK_PATH` remain available for an explicit -replay or deeper local investigation. +Pull requests run targets whose registered implementation, property, +regression, configuration, or policy inputs changed. Lockfile changes select +only targets owned by importers whose dependency snapshots changed. Root +mutation engine, workspace toolchain, or scheduled full-matrix workflow changes +fan out to all targets; selector, scoring, unrelated SDK, CI, or governance +edits do not. Selector and score evaluation are covered by the zero-install +repository contract. The independent `Mutation quality` workflow runs the full +matrix every Sunday and can run one exact target manually. Targets execute +in parallel, reuse one workspace build, cancel unfinished siblings after a +failure, and preserve machine-readable reports for 30 days. Mutation runs use +the policy's fixed 300-case fast-check seed by default so the dry run and every +mutant see the same generated campaign. `FAST_CHECK_NUM_RUNS`, +`FAST_CHECK_SEED`, and `FAST_CHECK_PATH` remain available for an explicit replay +or deeper local investigation. List and run targets locally: diff --git a/scripts/ci-affected-scope.mjs b/scripts/ci-affected-scope.mjs new file mode 100644 index 000000000..518625536 --- /dev/null +++ b/scripts/ci-affected-scope.mjs @@ -0,0 +1,339 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const DEPENDENCY_FIELDS = [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies' +] + +const FULL_PACKAGE_CONTROL_PATHS = new Set([ + 'package.json', + 'pnpm-workspace.yaml', + 'tsconfig.base.json', + 'governance/repository-health/projects.json', + 'scripts/check-browser-package.mjs', + 'scripts/check-package-artifact.mjs', + 'scripts/run-prebuilt-package-script.mjs', + 'scripts/typescript-toolchain.mjs' +]) + +export const INFRA_COMPONENTS = [ + { component: 'chaintracks-server', 'native-modules': 'better-sqlite3' }, + { component: 'message-box-server', 'native-modules': 'better-sqlite3' }, + { component: 'overlay-server', 'native-modules': '' }, + { component: 'uhrp-server-basic', 'native-modules': '' }, + { component: 'uhrp-server-cloud-bucket', 'native-modules': 'better-sqlite3' }, + { component: 'uhrp-server-cloud-bucket/notifier', 'native-modules': '' }, + { component: 'wab', 'native-modules': 'better-sqlite3 sqlite3' }, + { component: 'wallet-infra', 'native-modules': 'better-sqlite3' } +] + +export const RUNTIME_COMPONENTS = [ + { name: 'chaintracks-server', path: 'infra/chaintracks-server', wallet: false }, + { name: 'message-box-server', path: 'infra/message-box-server', wallet: true }, + { name: 'overlay-server', path: 'infra/overlay-server', wallet: true }, + { name: 'uhrp-server-basic', path: 'infra/uhrp-server-basic', wallet: true }, + { + name: 'uhrp-server-cloud-bucket', + path: 'infra/uhrp-server-cloud-bucket', + wallet: true + }, + { name: 'wab', path: 'infra/wab', wallet: false }, + { name: 'wallet-infra', path: 'infra/wallet-infra', wallet: false } +] + +const FULL_INFRA_CONTROL_PATHS = new Set([ + 'governance/Dockerfile.container-bases', + 'governance/container-images.json' +]) + +const FULL_RUNTIME_CONTROL_PATHS = new Set([ + 'governance/service-operations.json', + 'scripts/service-operations.mjs', + 'scripts/container-runtime-contract.mjs', + 'scripts/container-runtime-contract.test.mjs' +]) + +function normalized(value) { + return value.split(path.sep).join('/').replace(/^\.\//, '') +} + +function unquote(value) { + const trimmed = value.trim() + if ( + (trimmed.startsWith("'") && trimmed.endsWith("'")) || + (trimmed.startsWith('"') && trimmed.endsWith('"')) + ) { + return trimmed.slice(1, -1) + } + return trimmed +} + +export function lockfileImporterSections(source) { + const sections = new Map() + const lines = source.split(/\r?\n/) + const importersIndex = lines.findIndex(line => line === 'importers:') + if (importersIndex === -1) return sections + + let importer + let body = [] + const flush = () => { + if (importer !== undefined) sections.set(importer, body.join('\n')) + } + for (let index = importersIndex + 1; index < lines.length; index += 1) { + const line = lines[index] + if (/^[^\s]/.test(line) && line !== '') break + const match = /^ (\S.*):$/.exec(line) + if (match !== null) { + flush() + importer = unquote(match[1]) + body = [] + } else if (importer !== undefined) { + body.push(line) + } + } + flush() + return sections +} + +export function changedLockfileImporters(baseSource, headSource) { + const base = lockfileImporterSections(baseSource) + const head = lockfileImporterSections(headSource) + const importers = new Set([...base.keys(), ...head.keys()]) + return [...importers] + .filter(importer => base.get(importer) !== head.get(importer)) + .sort((left, right) => left.localeCompare(right)) +} + +function projectOwnsFile(projectPath, file) { + return projectPath !== '.' && (file === projectPath || file.startsWith(`${projectPath}/`)) +} + +function documentationOnlyProjectFile(projectPath, file) { + const relative = file.slice(projectPath.length + 1) + return ( + relative.endsWith('.md') || + relative === 'LICENSE' || + relative === 'LICENSE.txt' || + relative.startsWith('docs/') + ) +} + +function internalDependencies(project, names) { + const dependencies = new Set() + for (const field of DEPENDENCY_FIELDS) { + for (const name of Object.keys(project.manifest[field] ?? {})) { + if (names.has(name)) dependencies.add(name) + } + } + return dependencies +} + +function closure(seed, neighbors) { + const selected = new Set(seed) + const queue = [...seed] + while (queue.length > 0) { + const name = queue.shift() + for (const neighbor of neighbors.get(name) ?? []) { + if (selected.has(neighbor)) continue + selected.add(neighbor) + queue.push(neighbor) + } + } + return selected +} + +export function selectWorkspaceScope(projects, changedFiles, changedImporters = []) { + const files = changedFiles.map(normalized).filter(Boolean) + const projectNames = new Set(projects.map(project => project.name)) + const nonRootProjects = projects.filter(project => project.path !== '.') + const full = + files.some(file => FULL_PACKAGE_CONTROL_PATHS.has(file)) || changedImporters.includes('.') + + const direct = new Set() + if (full) { + for (const project of nonRootProjects) direct.add(project.name) + } else { + for (const project of nonRootProjects) { + if ( + files.some( + file => + projectOwnsFile(project.path, file) && !documentationOnlyProjectFile(project.path, file) + ) || + changedImporters.includes(project.path) + ) { + direct.add(project.name) + } + } + } + + const forward = new Map() + const reverse = new Map() + for (const project of projects) { + const dependencies = internalDependencies(project, projectNames) + forward.set(project.name, dependencies) + for (const dependency of dependencies) { + const dependents = reverse.get(dependency) ?? new Set() + dependents.add(project.name) + reverse.set(dependency, dependents) + } + } + + const affected = closure(direct, reverse) + affected.delete('@bsv/ts-stack') + const build = closure(affected, forward) + build.delete('@bsv/ts-stack') + + const sorted = values => [...values].sort((left, right) => left.localeCompare(right)) + return { + direct: sorted(direct), + affected: sorted(affected), + build: sorted(build) + } +} + +export function selectInfraComponents(changedFiles) { + const files = changedFiles.map(normalized).filter(Boolean) + if (files.some(file => FULL_INFRA_CONTROL_PATHS.has(file))) return INFRA_COMPONENTS + return INFRA_COMPONENTS.filter(entry => + files.some(file => file.startsWith(`infra/${entry.component}/`)) + ) +} + +export function selectRuntimeComponents(changedFiles) { + const files = changedFiles.map(normalized).filter(Boolean) + const full = files.some(file => FULL_RUNTIME_CONTROL_PATHS.has(file)) + if (full) return RUNTIME_COMPONENTS + + const selected = new Set() + for (const component of RUNTIME_COMPONENTS) { + if (files.some(file => file.startsWith(`${component.path}/`))) selected.add(component.name) + } + if (selected.has('wallet-infra')) { + for (const component of RUNTIME_COMPONENTS) { + if (component.wallet) selected.add(component.name) + } + } + return RUNTIME_COMPONENTS.filter(component => selected.has(component.name)) +} + +export function docsAreAffected(changedFiles) { + return changedFiles.some(file => { + const normalizedFile = normalized(file) + return ( + normalizedFile.startsWith('docs/') || + normalizedFile.startsWith('docs-site/') || + /(?:^|\/)(?:README|API|CHANGELOG)\.md$/.test(normalizedFile) || + normalizedFile === 'scripts/package-documentation.mjs' || + normalizedFile === 'scripts/documentation-policy.mjs' + ) + }) +} + +export function conformanceIsAffected(changedFiles) { + return changedFiles.some(file => { + const normalizedFile = normalized(file) + return ( + normalizedFile.startsWith('conformance/') || + normalizedFile.startsWith('specs/') || + normalizedFile === 'scripts/generate-openapi-types.mjs' + ) + }) +} + +function parseArguments(arguments_) { + const result = { all: false, base: '', head: 'HEAD' } + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index] + if (argument === '--all') result.all = true + else if (argument === '--base') result.base = arguments_[++index] ?? '' + else if (argument === '--head') result.head = arguments_[++index] ?? '' + else throw new Error(`Unknown argument: ${argument}`) + } + if (result.base === '' && !result.all) throw new Error('--base or --all is required') + if (result.base !== '' && result.all) throw new Error('--base and --all are mutually exclusive') + if (result.head === '') throw new Error('--head requires a revision') + return result +} + +function gitText(arguments_) { + return execFileSync('git', arguments_, { cwd: REPOSITORY_ROOT, encoding: 'utf8' }) +} + +function loadProjects() { + const registry = JSON.parse( + readFileSync(path.join(REPOSITORY_ROOT, 'governance/repository-health/projects.json'), 'utf8') + ) + return registry.projects.map(project => { + const manifest = JSON.parse( + readFileSync(path.join(REPOSITORY_ROOT, project.path, 'package.json'), 'utf8') + ) + return { name: manifest.name, path: normalized(project.path), manifest } + }) +} + +function projectRecords(projects, names) { + const selected = new Set(names) + return projects + .filter(project => selected.has(project.name)) + .map(project => ({ name: project.name, path: path.join(REPOSITORY_ROOT, project.path) })) + .sort((left, right) => left.name.localeCompare(right.name)) +} + +function main(arguments_) { + const { all, base, head } = parseArguments(arguments_) + const changedFiles = all + ? gitText(['ls-files']).split(/\r?\n/).filter(Boolean) + : gitText(['diff', '--name-only', `${base}...${head}`]) + .split(/\r?\n/) + .filter(Boolean) + let importers = [] + if (!all && changedFiles.includes('pnpm-lock.yaml')) { + importers = changedLockfileImporters( + gitText(['show', `${base}:pnpm-lock.yaml`]), + readFileSync(path.join(REPOSITORY_ROOT, 'pnpm-lock.yaml'), 'utf8') + ) + } + const projects = loadProjects() + const workspace = all + ? selectWorkspaceScope(projects, ['tsconfig.base.json']) + : selectWorkspaceScope(projects, changedFiles, importers) + const infrastructure = all ? INFRA_COMPONENTS : selectInfraComponents(changedFiles) + const runtimeComponents = all ? RUNTIME_COMPONENTS : selectRuntimeComponents(changedFiles) + const infraEntries = + infrastructure.length === 0 + ? [{ component: '_none', 'native-modules': '', run: false, display: 'no changes' }] + : infrastructure.map(entry => ({ ...entry, run: true, display: entry.component })) + + process.stdout.write( + JSON.stringify({ + changedFiles, + changedImporters: importers, + directProjects: projectRecords(projects, workspace.direct), + affectedProjects: projectRecords(projects, workspace.affected), + buildProjects: projectRecords(projects, workspace.build), + infraMatrix: { include: infraEntries }, + runtimeMatrix: { + include: runtimeComponents.map(component => ({ component })) + }, + docs: docsAreAffected(changedFiles), + conformance: conformanceIsAffected(changedFiles) + }) + ) +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + try { + main(process.argv.slice(2)) + } catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + } +} diff --git a/scripts/ci-affected-scope.test.mjs b/scripts/ci-affected-scope.test.mjs new file mode 100644 index 000000000..619b8212a --- /dev/null +++ b/scripts/ci-affected-scope.test.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + changedLockfileImporters, + conformanceIsAffected, + docsAreAffected, + lockfileImporterSections, + selectInfraComponents, + selectRuntimeComponents, + selectWorkspaceScope +} from './ci-affected-scope.mjs' + +const projects = [ + { + name: '@bsv/ts-stack', + path: '.', + manifest: { name: '@bsv/ts-stack' } + }, + { + name: '@bsv/base', + path: 'packages/base', + manifest: { name: '@bsv/base' } + }, + { + name: '@bsv/direct', + path: 'packages/direct', + manifest: { name: '@bsv/direct', dependencies: { '@bsv/base': 'workspace:^' } } + }, + { + name: '@bsv/consumer', + path: 'packages/consumer', + manifest: { name: '@bsv/consumer', peerDependencies: { '@bsv/direct': '^1.0.0' } } + }, + { + name: '@bsv/unrelated', + path: 'packages/unrelated', + manifest: { name: '@bsv/unrelated' } + } +] + +test('lockfile importer parsing identifies only changed dependency snapshots', () => { + const base = `lockfileVersion: '9.0'\n\nimporters:\n\n .:\n devDependencies:\n tool: 1\n\n packages/direct:\n devDependencies:\n fast-check: 1\n\npackages:\n` + const head = base.replace('fast-check: 1', 'fast-check: 2') + assert.deepEqual([...lockfileImporterSections(base).keys()], ['.', 'packages/direct']) + assert.deepEqual(changedLockfileImporters(base, head), ['packages/direct']) +}) + +test('workspace scope tests direct changes, typechecks dependents, and builds dependencies', () => { + const scope = selectWorkspaceScope(projects, ['packages/direct/src/index.ts']) + assert.deepEqual(scope.direct, ['@bsv/direct']) + assert.deepEqual(scope.affected, ['@bsv/consumer', '@bsv/direct']) + assert.deepEqual(scope.build, ['@bsv/base', '@bsv/consumer', '@bsv/direct']) +}) + +test('documentation and QA policy changes do not fan out package tests', () => { + assert.deepEqual( + selectWorkspaceScope(projects, [ + 'packages/direct/README.md', + 'governance/mutation-testing/policy.json', + '.github/workflows/ci.yml' + ]), + { direct: [], affected: [], build: [] } + ) +}) + +test('lock-only package changes select that importer and its dependents', () => { + const scope = selectWorkspaceScope(projects, ['pnpm-lock.yaml'], ['packages/direct']) + assert.deepEqual(scope.direct, ['@bsv/direct']) + assert.deepEqual(scope.affected, ['@bsv/consumer', '@bsv/direct']) +}) + +test('root toolchain changes deliberately retain full workspace coverage', () => { + assert.deepEqual(selectWorkspaceScope(projects, ['tsconfig.base.json']).direct, [ + '@bsv/base', + '@bsv/consumer', + '@bsv/direct', + '@bsv/unrelated' + ]) + assert.equal( + selectWorkspaceScope(projects, ['governance/repository-health/projects.json']).direct.length, + 4 + ) +}) + +test('infrastructure scope never rebuilds unrelated images for workflow-only changes', () => { + assert.deepEqual(selectInfraComponents(['.github/workflows/ci.yml']), []) + assert.deepEqual( + selectInfraComponents(['infra/message-box-server/src/index.ts']).map(entry => entry.component), + ['message-box-server'] + ) + assert.equal(selectInfraComponents(['governance/container-images.json']).length, 8) +}) + +test('runtime image scope follows component contexts and wallet-image consumers', () => { + assert.deepEqual( + selectRuntimeComponents(['.github/workflows/container-runtime-contract.yml']), + [] + ) + assert.deepEqual( + selectRuntimeComponents(['infra/chaintracks-server/src/index.ts']).map(entry => entry.name), + ['chaintracks-server'] + ) + assert.deepEqual( + selectRuntimeComponents(['infra/wallet-infra/Dockerfile']).map(entry => entry.name), + [ + 'message-box-server', + 'overlay-server', + 'uhrp-server-basic', + 'uhrp-server-cloud-bucket', + 'wallet-infra' + ] + ) + assert.equal(selectRuntimeComponents(['scripts/container-runtime-contract.mjs']).length, 7) + assert.deepEqual(selectInfraComponents(['scripts/container-runtime-contract.mjs']), []) + assert.deepEqual(selectRuntimeComponents(['governance/container-images.json']), []) + assert.deepEqual(selectRuntimeComponents(['packages/wallet/wallet-toolbox/src/index.ts']), []) +}) + +test('docs and conformance work are selected from their actual inputs', () => { + assert.equal(docsAreAffected(['packages/direct/README.md']), true) + assert.equal(docsAreAffected(['packages/direct/src/index.ts']), false) + assert.equal(conformanceIsAffected(['conformance/vectors/example.json']), true) + assert.equal(conformanceIsAffected(['packages/direct/src/index.ts']), false) +}) diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index 0b5f51a20..a39402881 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -28,10 +28,13 @@ test('CI shares one audited build across coverage and browser consumer lanes', ( assert.match(workflow, /name: browser-composition-wallet/) assert.match(workflow, /run-prebuilt-package-script\.mjs" \\\n\s+--script test:browser/) assert.match(workflow, /run-prebuilt-package-script\.mjs" \\\n\s+--script test:coverage/) + assert.match(workflow, /^ dependent-tests:$/m) + assert.match(workflow, /run-prebuilt-package-script\.mjs" \\\n\s+--script test/) assert.doesNotMatch(workflow, /@bsv\/sdk run test:coverage/) assert.doesNotMatch(workflow, /@bsv\/verifast run test:coverage/) assert.doesNotMatch(workflow, /pnpm -r --no-sort "\$\{filters\[@\]\}" run test:coverage/) assert.match(workflow, /^ - browser-packages$/m) + assert.match(workflow, /^ - dependent-tests$/m) assert.match( workflow, /\( "\$PACKAGE_BROWSER_RESULT" != "success" && "\$PACKAGE_BROWSER_RESULT" != "skipped" \)/ @@ -42,6 +45,7 @@ test('CI skips empty duplicate lanes without weakening the aggregate gate', () = const workflow = readFileSync(CI_PATH, 'utf8') assert.match(workflow, /^ if: needs\.prepare\.outputs\.standard-packages != '\[\]'$/m) + assert.match(workflow, /^ if: needs\.prepare\.outputs\.dependent-test-packages != '\[\]'$/m) assert.match(workflow, /^ if: needs\.prepare\.outputs\.coverage-other-packages != '\[\]'$/m) assert.match( workflow, @@ -51,5 +55,5 @@ test('CI skips empty duplicate lanes without weakening the aggregate gate', () = assert.match(workflow, /needs\.prepare\.outputs\.coverage-required == 'true'/) assert.match(workflow, /\( "\$TEST_RESULT" != "success" && "\$TEST_RESULT" != "skipped" \)/) assert.match(workflow, /grep -Fxq '@bsv\/overlay-topics'/) - assert.equal(workflow.match(/mongodb-memory-server binary cache warmed/g)?.length, 1) + assert.equal(workflow.match(/mongodb-memory-server binary cache warmed/g)?.length, 2) }) diff --git a/scripts/container-supply-chain.test.mjs b/scripts/container-supply-chain.test.mjs index 4c04a6191..26419828c 100644 --- a/scripts/container-supply-chain.test.mjs +++ b/scripts/container-supply-chain.test.mjs @@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import test from 'node:test' +import { RUNTIME_COMPONENTS } from './ci-affected-scope.mjs' import { OCI_LICENSE_REFERENCE } from './package-license-policy.mjs' import { REPOSITORY_ROOT } from './repository-health.mjs' @@ -220,9 +221,13 @@ test('hosted CI exercises every governed image through the complete runtime cont assert.match(runtimeContract, /minimal-transaction/) assert.match(runtimeContract, /graceful-shutdown/) assert.match(runtimeContract, /access-control-allow-origin/) - for (const component of registry.components) { - assert.match(workflow, new RegExp(`name: ${component.name}(?:\\s|$)`)) - } + assert.match(workflow, /ci-affected-scope\.mjs/) + assert.match(workflow, /if: needs\.scope\.outputs\.has-runtime == 'true'/) + assert.match(workflow, /matrix: \$\{\{ fromJSON\(needs\.scope\.outputs\.matrix\) \}\}/) + assert.deepEqual( + RUNTIME_COMPONENTS.map(component => component.name).sort(), + registry.components.map(component => component.name).sort() + ) }) test('Docker refreshes and OpenSSF posture checks remain automated', () => { diff --git a/scripts/mutation-testing.mjs b/scripts/mutation-testing.mjs index e1189c9fd..9ec6a560c 100644 --- a/scripts/mutation-testing.mjs +++ b/scripts/mutation-testing.mjs @@ -1,29 +1,22 @@ #!/usr/bin/env node import { spawn } from 'node:child_process' +import { execFileSync } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { buildMutationTargets } from '../governance/mutation-testing/targets.mjs' +import { changedLockfileImporters } from './ci-affected-scope.mjs' export const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const CONFIG_PATH = path.join(REPOSITORY_ROOT, 'governance/mutation-testing/stryker.config.mjs') const POLICY_PATH = path.join(REPOSITORY_ROOT, 'governance/mutation-testing/policy.json') const CONTROL_PATH_PREFIXES = [ - '.github/workflows/ci.yml', '.github/workflows/mutation-tests.yml', - 'governance/mutation-testing/', - 'governance/test-quality/', - 'scripts/mutation-testing.mjs', - 'scripts/mutation-testing.test.mjs' + 'governance/mutation-testing/stryker.config.mjs' ] -const CONTROL_PATHS = new Set([ - 'package.json', - 'pnpm-lock.yaml', - 'pnpm-workspace.yaml', - 'tsconfig.base.json' -]) +const CONTROL_PATHS = new Set(['package.json', 'pnpm-workspace.yaml', 'tsconfig.base.json']) function normalized(value) { return value.split(path.sep).join('/').replace(/^\.\//, '') @@ -44,25 +37,112 @@ export function calculateMutationMetrics(mutants) { } } -export function selectAffectedMutationTargets(targets, changedFiles) { +function globPattern(pattern) { + let expression = '^' + for (let index = 0; index < pattern.length; index += 1) { + const character = pattern[index] + if (character === '*' && pattern[index + 1] === '*') { + expression += '.*' + index += 1 + } else if (character === '*') expression += '[^/]*' + else if (character === '?') expression += '[^/]' + else expression += character.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + } + return new RegExp(`${expression}$`) +} + +function targetInputPatterns(target) { + const packageDirectory = normalized(target.packageDirectory) + const patterns = [] + for (const mutate of target.mutate ?? []) { + patterns.push(`${packageDirectory}/${mutate.replace(/:\d+(?:-\d+)?$/, '')}`) + } + if (typeof target.propertyTest === 'string') patterns.push(normalized(target.propertyTest)) + const jest = target.runnerOptions?.jest + const vitest = target.runnerOptions?.vitest + const configFile = jest?.configFile ?? vitest?.configFile + if (typeof configFile === 'string') patterns.push(`${packageDirectory}/${configFile}`) + for (const match of jest?.config?.testMatch ?? []) { + patterns.push(`${packageDirectory}/${match.replace(/^\//, '')}`) + } + return patterns.map(globPattern) +} + +function packageWideTarget(target) { + return ( + target.runnerOptions?.jest?.config?.testMatch === undefined && + target.runnerOptions?.vitest?.related !== true + ) +} + +export function selectAffectedMutationTargets( + targets, + changedFiles, + { changedTargetIds = [], changedImporters = [] } = {} +) { const files = changedFiles.map(normalized).filter(Boolean) const globalChange = files.some( file => CONTROL_PATHS.has(file) || CONTROL_PATH_PREFIXES.some(prefix => prefix.endsWith('/') ? file.startsWith(prefix) : file === prefix - ) || - file.startsWith('packages/sdk/') + ) ) if (globalChange) return Object.keys(targets) return Object.entries(targets) - .filter(([, target]) => - files.some(file => file.startsWith(`${normalized(target.packageDirectory)}/`)) - ) + .filter(([id, target]) => { + if ( + changedTargetIds.includes(id) || + changedImporters.includes(normalized(target.packageDirectory)) + ) { + return true + } + const patterns = targetInputPatterns(target) + if (files.some(file => patterns.some(pattern => pattern.test(file)))) return true + if (!packageWideTarget(target)) return false + const prefix = `${normalized(target.packageDirectory)}/` + return files.some( + file => + file.startsWith(prefix) && + file !== normalized(target.manifest) && + !file.endsWith('.md') && + !file.includes('/docs/') + ) + }) .map(([id]) => id) } +function changedPolicyTargets(base) { + const currentPolicy = readPolicy() + if (currentPolicy === undefined) return [] + const basePolicy = JSON.parse( + execFileSync('git', ['show', `${base}:governance/mutation-testing/policy.json`], { + cwd: REPOSITORY_ROOT, + encoding: 'utf8' + }) + ) + const currentById = new Map(currentPolicy.targets.map(target => [target.id, target])) + const baseById = new Map(basePolicy.targets.map(target => [target.id, target])) + return [...new Set([...currentById.keys(), ...baseById.keys()])].filter( + id => JSON.stringify(currentById.get(id)) !== JSON.stringify(baseById.get(id)) + ) +} + +async function changedConfiguredTargets(base, currentTargets) { + const source = execFileSync('git', ['show', `${base}:governance/mutation-testing/targets.mjs`], { + cwd: REPOSITORY_ROOT, + encoding: 'utf8' + }) + const module = await import( + `data:text/javascript;base64,${Buffer.from(source).toString('base64')}` + ) + const baseTargets = module.buildMutationTargets(REPOSITORY_ROOT) + return [...new Set([...Object.keys(currentTargets), ...Object.keys(baseTargets)])].filter( + id => JSON.stringify(currentTargets[id]) !== JSON.stringify(baseTargets[id]) + ) +} + function readPolicy() { if (!fs.existsSync(POLICY_PATH)) return undefined return JSON.parse(fs.readFileSync(POLICY_PATH, 'utf8')) @@ -144,7 +224,7 @@ async function runTarget(targetName, target, policy) { } export function parseArguments(arguments_) { - const result = { all: false, list: false, targets: [], affectedFile: undefined } + const result = { all: false, list: false, targets: [], affectedFile: undefined, base: undefined } for (let index = 0; index < arguments_.length; index++) { const argument = arguments_[index] if (argument === '--all') result.all = true @@ -161,6 +241,12 @@ export function parseArguments(arguments_) { throw new Error('--affected-file requires a path') } result.affectedFile = affectedFile + } else if (argument === '--base') { + const base = arguments_[++index] + if (base === undefined || base.startsWith('--')) { + throw new Error('--base requires an exact revision') + } + result.base = base } else throw new Error(`Unknown argument ${argument}`) } const modes = [ @@ -170,6 +256,9 @@ export function parseArguments(arguments_) { result.affectedFile !== undefined ].filter(Boolean).length if (modes > 1) throw new Error('Select exactly one mutation command mode') + if (result.base !== undefined && result.affectedFile === undefined) { + throw new Error('--base is valid only with --affected-file') + } return result } @@ -183,7 +272,33 @@ async function main() { } if (options.affectedFile !== undefined) { const changedFiles = fs.readFileSync(options.affectedFile, 'utf8').split(/\r?\n/) - console.log(JSON.stringify(selectAffectedMutationTargets(targets, changedFiles))) + let changedTargetIds = [] + let changedImporters = [] + if (options.base !== undefined) { + if (changedFiles.includes('governance/mutation-testing/policy.json')) { + changedTargetIds.push(...changedPolicyTargets(options.base)) + } + if (changedFiles.includes('governance/mutation-testing/targets.mjs')) { + changedTargetIds.push(...(await changedConfiguredTargets(options.base, targets))) + } + if (changedFiles.includes('pnpm-lock.yaml')) { + changedImporters = changedLockfileImporters( + execFileSync('git', ['show', `${options.base}:pnpm-lock.yaml`], { + cwd: REPOSITORY_ROOT, + encoding: 'utf8' + }), + fs.readFileSync(path.join(REPOSITORY_ROOT, 'pnpm-lock.yaml'), 'utf8') + ) + } + } + console.log( + JSON.stringify( + selectAffectedMutationTargets(targets, changedFiles, { + changedTargetIds: [...new Set(changedTargetIds)], + changedImporters + }) + ) + ) return } diff --git a/scripts/mutation-testing.test.mjs b/scripts/mutation-testing.test.mjs index 8961b95ee..037ee07af 100644 --- a/scripts/mutation-testing.test.mjs +++ b/scripts/mutation-testing.test.mjs @@ -35,21 +35,58 @@ test('mutation command parsing rejects missing values and conflicting modes', () all: false, list: false, targets: ['one', 'two'], - affectedFile: undefined + affectedFile: undefined, + base: undefined }) assert.throws(() => parseArguments(['--target']), /requires an exact target ID/) assert.throws(() => parseArguments(['--affected-file']), /requires a path/) assert.throws(() => parseArguments(['--all', '--target', 'one']), /exactly one/) }) -test('affected mutation selection is package-scoped with global control fan-out', () => { - assert.deepEqual(selectAffectedMutationTargets(targets, ['packages/one/src/index.ts']), ['one']) +test('affected mutation selection follows exact target inputs and changed governance entries', () => { + const preciseTargets = { + one: { + packageDirectory: 'packages/one', + manifest: 'packages/one/package.json', + propertyTest: 'packages/one/test/value.property.test.ts', + mutate: ['src/value.ts'], + runnerOptions: { + jest: { + configFile: 'jest.config.js', + config: { testMatch: ['/test/value*.test.ts'] } + } + } + }, + two: { + packageDirectory: 'packages/two', + manifest: 'packages/two/package.json', + mutate: ['src/other.ts'], + runnerOptions: { jest: { configFile: 'jest.config.js' } } + } + } + assert.deepEqual(selectAffectedMutationTargets(preciseTargets, ['packages/one/src/value.ts']), [ + 'one' + ]) + assert.deepEqual( + selectAffectedMutationTargets(preciseTargets, ['packages/one/src/unrelated.ts']), + [] + ) + assert.deepEqual( + selectAffectedMutationTargets(preciseTargets, ['governance/mutation-testing/policy.json'], { + changedTargetIds: ['two'] + }), + ['two'] + ) + assert.deepEqual( + selectAffectedMutationTargets(preciseTargets, ['pnpm-lock.yaml'], { + changedImporters: ['packages/one'] + }), + ['one'] + ) assert.deepEqual(selectAffectedMutationTargets(targets, ['docs/about/contributing.md']), []) assert.deepEqual(selectAffectedMutationTargets(targets, ['package.json']), ['one', 'two']) - assert.deepEqual(selectAffectedMutationTargets(targets, ['packages/sdk/src/index.ts']), [ - 'one', - 'two' - ]) + assert.deepEqual(selectAffectedMutationTargets(targets, ['.github/workflows/ci.yml']), []) + assert.deepEqual(selectAffectedMutationTargets(targets, ['scripts/mutation-testing.mjs']), []) }) test('mutation report evaluation ratchets score, coverage, and invalid outcomes', () => { diff --git a/scripts/repository-health.test.mjs b/scripts/repository-health.test.mjs index 110700171..4bfb1ba2f 100644 --- a/scripts/repository-health.test.mjs +++ b/scripts/repository-health.test.mjs @@ -436,6 +436,11 @@ test('CI and release typecheck the built cross-package declaration graph', () => assert.notEqual(buildIndex, -1, `${file} must retain its workspace build`) assert.ok(typecheckIndex > buildIndex, `${file} must typecheck after building package outputs`) - assert.match(source.slice(typecheckIndex), /run: pnpm typecheck/) + if (file === 'ci.yml') { + assert.match(source.slice(typecheckIndex), /AFFECTED_PROJECTS:/) + assert.match(source.slice(typecheckIndex), /pnpm -r --if-present.*run typecheck/) + } else { + assert.match(source.slice(typecheckIndex), /run: pnpm typecheck/) + } } }) diff --git a/scripts/run-ci-tests.mjs b/scripts/run-ci-tests.mjs index e3a5aa2ce..4cc42cd3b 100644 --- a/scripts/run-ci-tests.mjs +++ b/scripts/run-ci-tests.mjs @@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' -const MODES = ['standard', 'coverage-other', 'browser'] +const MODES = ['test', 'standard', 'coverage-other', 'browser'] const dedicatedSuites = new Set([ '@bsv/conformance-runner', @@ -47,6 +47,9 @@ export function selectCiPackageNames(projects, mode) { if (project.name === '@bsv/ts-stack' || project.name === 'example-paymail') { return false } + if (mode === 'test') { + return project.name !== 'docs-site' && typeof project.scripts.test === 'string' + } if (mode === 'browser') { return ( !dedicatedBrowserSuites.has(project.name) && diff --git a/scripts/run-ci-tests.test.mjs b/scripts/run-ci-tests.test.mjs index 9dd84f6cf..cbddb7a79 100644 --- a/scripts/run-ci-tests.test.mjs +++ b/scripts/run-ci-tests.test.mjs @@ -38,6 +38,13 @@ const projects = [ ] test('CI package selection partitions standard and coverage suites without duplication', () => { + assert.deepEqual(selectCiPackageNames(projects, 'test'), [ + '@bsv/did', + '@bsv/example-browser', + '@bsv/example-covered', + '@bsv/example-standard', + '@bsv/sdk' + ]) assert.deepEqual(selectCiPackageNames(projects, 'standard'), ['@bsv/example-standard']) assert.deepEqual(selectCiPackageNames(projects, 'coverage-other'), [ '@bsv/example-browser', From 5f881ad7c72471027b309f2b21529a2bba62a618 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 31 Jul 2026 12:26:42 -0700 Subject: [PATCH 4/9] ci: gate builds on cheap analysis --- .github/workflows/ci.yml | 49 ++++++++++-- docs/reference/ci-performance.md | 5 +- scripts/ci-affected-scope.mjs | 6 +- scripts/ci-orchestration.test.mjs | 4 + scripts/mutation-testing.mjs | 122 +++++++++++++++--------------- 5 files changed, 113 insertions(+), 73 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7eb0ad19..346558ae3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,10 +152,36 @@ jobs: --pull-request "${{ github.event.pull_request.number }}" --revision "${{ github.event.pull_request.head.sha }}" + early-gates: + name: Early policy gates + if: always() + needs: + - repository-health + - scope + - sonar-zero-findings + - dependency-review + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Stop before installing or building when a cheap gate failed + env: + HEALTH_RESULT: ${{ needs.repository-health.result }} + SCOPE_RESULT: ${{ needs.scope.result }} + SONAR_RESULT: ${{ needs.sonar-zero-findings.result }} + DEPENDENCY_RESULT: ${{ needs.dependency-review.result }} + run: | + if [[ "$HEALTH_RESULT" != "success" || + "$SCOPE_RESULT" != "success" || + ( "$SONAR_RESULT" != "success" && "$SONAR_RESULT" != "skipped" ) || + ( "$DEPENDENCY_RESULT" != "success" && "$DEPENDENCY_RESULT" != "skipped" ) ]]; then + echo "::error::Early policy gate failed: health=$HEALTH_RESULT scope=$SCOPE_RESULT sonar=$SONAR_RESULT dependency=$DEPENDENCY_RESULT" + exit 1 + fi + prepare: name: Build, lint, and policy needs: - - repository-health + - early-gates - scope runs-on: ubuntu-latest permissions: @@ -230,6 +256,7 @@ jobs: AFFECTED_PROJECTS: ${{ needs.scope.outputs.affected-projects }} run: | DIRECT=$(jq -r '.[].name' <<<"$DIRECT_PROJECTS") + AFFECTED=$(jq -r '.[].name' <<<"$AFFECTED_PROJECTS") printf '%s\n' "$DIRECT_PROJECTS" > .ci-direct-packages.json printf '%s\n' "$AFFECTED_PROJECTS" > .ci-affected-packages.json DEPENDENT_PROJECTS=$(jq -n -c \ @@ -278,8 +305,6 @@ jobs: "sdk:@bsv/sdk" \ "did:@bsv/did" \ "wallet:@bsv/wallet-toolbox" \ - "wallet_client:@bsv/wallet-toolbox-client" \ - "wallet_mobile:@bsv/wallet-toolbox-mobile" \ "verifast:@bsv/verifast"; do key="${entry%%:*}" package="${entry#*:}" @@ -290,6 +315,18 @@ jobs: fi done + for entry in \ + "wallet_client:@bsv/wallet-toolbox-client" \ + "wallet_mobile:@bsv/wallet-toolbox-mobile"; do + key="${entry%%:*}" + package="${entry#*:}" + if grep -Fxq "$package" <<<"$AFFECTED"; then + echo "$key=true" >> "$GITHUB_OUTPUT" + else + echo "$key=false" >> "$GITHUB_OUTPUT" + fi + done + - name: Build workspace env: BUILD_PROJECTS: ${{ needs.scope.outputs.build-projects }} @@ -1085,7 +1122,7 @@ jobs: infra-scope: name: Detect affected infrastructure needs: - - repository-health + - early-gates - scope runs-on: ubuntu-latest permissions: {} @@ -1183,7 +1220,7 @@ jobs: docs-validate: name: Docs Site Validation needs: - - repository-health + - early-gates - scope runs-on: ubuntu-latest # Run on every PR (cheap and protects the docs site) and on pushes that touch docs @@ -1228,7 +1265,7 @@ jobs: conformance: name: Conformance Vectors needs: - - repository-health + - early-gates - scope if: needs.scope.outputs.conformance == 'true' runs-on: ubuntu-latest diff --git a/docs/reference/ci-performance.md b/docs/reference/ci-performance.md index e7aa3f963..676258af2 100644 --- a/docs/reference/ci-performance.md +++ b/docs/reference/ci-performance.md @@ -45,8 +45,9 @@ outputs with isolated test lanes, skips empty lanes, installs through the setup-node pnpm cache, caches the immutable MongoDB test binary, and rebuilds native/build tools only in jobs that execute them. Browser lanes retain exact package-composition reports without rebuilding the workspace. The cheap -repository-health and scope gates complete before dependency installation and -all expensive matrices cancel unfinished siblings after the first failure. +repository-health, scope, Sonar, and dependency-review gates complete before +dependency installation, and all expensive matrices cancel unfinished siblings +after the first failure. These controls reduce repeated CPU, network, and setup work without weakening the tests selected by the dependency or registered trust-boundary graph. diff --git a/scripts/ci-affected-scope.mjs b/scripts/ci-affected-scope.mjs index 518625536..5aebc5380 100644 --- a/scripts/ci-affected-scope.mjs +++ b/scripts/ci-affected-scope.mjs @@ -79,7 +79,7 @@ function unquote(value) { export function lockfileImporterSections(source) { const sections = new Map() const lines = source.split(/\r?\n/) - const importersIndex = lines.findIndex(line => line === 'importers:') + const importersIndex = lines.indexOf('importers:') if (importersIndex === -1) return sections let importer @@ -90,7 +90,7 @@ export function lockfileImporterSections(source) { for (let index = importersIndex + 1; index < lines.length; index += 1) { const line = lines[index] if (/^[^\s]/.test(line) && line !== '') break - const match = /^ (\S.*):$/.exec(line) + const match = /^ {2}(\S.*):$/.exec(line) if (match !== null) { flush() importer = unquote(match[1]) @@ -264,7 +264,7 @@ function parseArguments(arguments_) { } function gitText(arguments_) { - return execFileSync('git', arguments_, { cwd: REPOSITORY_ROOT, encoding: 'utf8' }) + return execFileSync('/usr/bin/git', arguments_, { cwd: REPOSITORY_ROOT, encoding: 'utf8' }) } function loadProjects() { diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index a39402881..b1d6968e3 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -21,6 +21,10 @@ test('CI shares one audited build across coverage and browser consumer lanes', ( /^ matrix: \$\{\{ fromJSON\(needs\.prepare\.outputs\.browser-matrix\) \}\}$/m ) assert.match(workflow, /PREBUILT_PACKAGE_OUTPUTS: '1'/) + assert.match(workflow, /^ early-gates:$/m) + assert.match(workflow, /Stop before installing or building when a cheap gate failed/) + assert.match(workflow, /AFFECTED=\$\(jq -r '\.\[\]\.name' <<<"\$AFFECTED_PROJECTS"\)/) + assert.match(workflow, /"wallet_client:@bsv\/wallet-toolbox-client"/) assert.match(workflow, /BROWSER_COMPOSITION_DIRECTORY:/) assert.match(workflow, /name: browser-composition-\$\{\{ matrix\.shard \}\}/) assert.match(workflow, /name: browser-composition-sdk/) diff --git a/scripts/mutation-testing.mjs b/scripts/mutation-testing.mjs index 9ec6a560c..86b55c8f5 100644 --- a/scripts/mutation-testing.mjs +++ b/scripts/mutation-testing.mjs @@ -1,7 +1,6 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process' -import { execFileSync } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -17,6 +16,12 @@ const CONTROL_PATH_PREFIXES = [ 'governance/mutation-testing/stryker.config.mjs' ] const CONTROL_PATHS = new Set(['package.json', 'pnpm-workspace.yaml', 'tsconfig.base.json']) +const REGEXP_META = new RegExp(String.raw`[.*+?^$(){}|[\]\\]`, 'g') +const OPTION_REQUIREMENTS = new Map([ + ['--target', 'an exact target ID'], + ['--base', 'an exact revision'], + ['--affected-file', 'a path'] +]) function normalized(value) { return value.split(path.sep).join('/').replace(/^\.\//, '') @@ -46,7 +51,7 @@ function globPattern(pattern) { index += 1 } else if (character === '*') expression += '[^/]*' else if (character === '?') expression += '[^/]' - else expression += character.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + else expression += character.replace(REGEXP_META, '\\$&') } return new RegExp(`${expression}$`) } @@ -116,12 +121,7 @@ export function selectAffectedMutationTargets( function changedPolicyTargets(base) { const currentPolicy = readPolicy() if (currentPolicy === undefined) return [] - const basePolicy = JSON.parse( - execFileSync('git', ['show', `${base}:governance/mutation-testing/policy.json`], { - cwd: REPOSITORY_ROOT, - encoding: 'utf8' - }) - ) + const basePolicy = JSON.parse(gitShow(base, 'governance/mutation-testing/policy.json')) const currentById = new Map(currentPolicy.targets.map(target => [target.id, target])) const baseById = new Map(basePolicy.targets.map(target => [target.id, target])) return [...new Set([...currentById.keys(), ...baseById.keys()])].filter( @@ -130,10 +130,7 @@ function changedPolicyTargets(base) { } async function changedConfiguredTargets(base, currentTargets) { - const source = execFileSync('git', ['show', `${base}:governance/mutation-testing/targets.mjs`], { - cwd: REPOSITORY_ROOT, - encoding: 'utf8' - }) + const source = gitShow(base, 'governance/mutation-testing/targets.mjs') const module = await import( `data:text/javascript;base64,${Buffer.from(source).toString('base64')}` ) @@ -148,6 +145,13 @@ function readPolicy() { return JSON.parse(fs.readFileSync(POLICY_PATH, 'utf8')) } +function gitShow(revision, file) { + return execFileSync('/usr/bin/git', ['show', `${revision}:${file}`], { + cwd: REPOSITORY_ROOT, + encoding: 'utf8' + }) +} + function readReport(targetName) { const reportPath = path.join(REPOSITORY_ROOT, 'artifacts/mutation', targetName, 'mutation.json') const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')) @@ -223,31 +227,29 @@ async function runTarget(targetName, target, policy) { if (errors.length > 0) throw new Error(errors.join('\n')) } +function requiredArgument(arguments_, index, option) { + const value = arguments_[index + 1] + if (value === undefined || value.startsWith('--')) { + throw new Error(`${option} requires ${OPTION_REQUIREMENTS.get(option)}`) + } + return value +} + export function parseArguments(arguments_) { const result = { all: false, list: false, targets: [], affectedFile: undefined, base: undefined } for (let index = 0; index < arguments_.length; index++) { const argument = arguments_[index] - if (argument === '--all') result.all = true - else if (argument === '--list') result.list = true - else if (argument === '--target') { - const target = arguments_[++index] - if (target === undefined || target.startsWith('--')) { - throw new Error('--target requires an exact target ID') - } - result.targets.push(target) - } else if (argument === '--affected-file') { - const affectedFile = arguments_[++index] - if (affectedFile === undefined || affectedFile.startsWith('--')) { - throw new Error('--affected-file requires a path') - } - result.affectedFile = affectedFile - } else if (argument === '--base') { - const base = arguments_[++index] - if (base === undefined || base.startsWith('--')) { - throw new Error('--base requires an exact revision') - } - result.base = base - } else throw new Error(`Unknown argument ${argument}`) + if (argument === '--all' || argument === '--list') { + result[argument.slice(2)] = true + continue + } + if (!['--target', '--affected-file', '--base'].includes(argument)) { + throw new Error(`Unknown argument ${argument}`) + } + const value = requiredArgument(arguments_, index, argument) + index += 1 + if (argument === '--target') result.targets.push(value) + else result[argument === '--base' ? 'base' : 'affectedFile'] = value } const modes = [ result.all, @@ -262,6 +264,29 @@ export function parseArguments(arguments_) { return result } +async function affectedTargets(options, targets) { + const changedFiles = fs.readFileSync(options.affectedFile, 'utf8').split(/\r?\n/) + if (options.base === undefined) return selectAffectedMutationTargets(targets, changedFiles) + + const changedTargetIds = [] + if (changedFiles.includes('governance/mutation-testing/policy.json')) { + changedTargetIds.push(...changedPolicyTargets(options.base)) + } + if (changedFiles.includes('governance/mutation-testing/targets.mjs')) { + changedTargetIds.push(...(await changedConfiguredTargets(options.base, targets))) + } + const changedImporters = changedFiles.includes('pnpm-lock.yaml') + ? changedLockfileImporters( + gitShow(options.base, 'pnpm-lock.yaml'), + fs.readFileSync(path.join(REPOSITORY_ROOT, 'pnpm-lock.yaml'), 'utf8') + ) + : [] + return selectAffectedMutationTargets(targets, changedFiles, { + changedTargetIds: [...new Set(changedTargetIds)], + changedImporters + }) +} + async function main() { const options = parseArguments(process.argv.slice(2)) const targets = buildMutationTargets(REPOSITORY_ROOT) @@ -271,34 +296,7 @@ async function main() { return } if (options.affectedFile !== undefined) { - const changedFiles = fs.readFileSync(options.affectedFile, 'utf8').split(/\r?\n/) - let changedTargetIds = [] - let changedImporters = [] - if (options.base !== undefined) { - if (changedFiles.includes('governance/mutation-testing/policy.json')) { - changedTargetIds.push(...changedPolicyTargets(options.base)) - } - if (changedFiles.includes('governance/mutation-testing/targets.mjs')) { - changedTargetIds.push(...(await changedConfiguredTargets(options.base, targets))) - } - if (changedFiles.includes('pnpm-lock.yaml')) { - changedImporters = changedLockfileImporters( - execFileSync('git', ['show', `${options.base}:pnpm-lock.yaml`], { - cwd: REPOSITORY_ROOT, - encoding: 'utf8' - }), - fs.readFileSync(path.join(REPOSITORY_ROOT, 'pnpm-lock.yaml'), 'utf8') - ) - } - } - console.log( - JSON.stringify( - selectAffectedMutationTargets(targets, changedFiles, { - changedTargetIds: [...new Set(changedTargetIds)], - changedImporters - }) - ) - ) + console.log(JSON.stringify(await affectedTargets(options, targets))) return } From 4b5414ea50e871fb5e8a485b1ed452b6bd57e1a7 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 31 Jul 2026 12:29:01 -0700 Subject: [PATCH 5/9] ci: clear exact-head static analysis --- scripts/mutation-testing.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/mutation-testing.mjs b/scripts/mutation-testing.mjs index 86b55c8f5..3b9a89dfb 100644 --- a/scripts/mutation-testing.mjs +++ b/scripts/mutation-testing.mjs @@ -16,7 +16,7 @@ const CONTROL_PATH_PREFIXES = [ 'governance/mutation-testing/stryker.config.mjs' ] const CONTROL_PATHS = new Set(['package.json', 'pnpm-workspace.yaml', 'tsconfig.base.json']) -const REGEXP_META = new RegExp(String.raw`[.*+?^$(){}|[\]\\]`, 'g') +const REGEXP_META = new Set('.*+?^$(){}|[]\\') const OPTION_REQUIREMENTS = new Map([ ['--target', 'an exact target ID'], ['--base', 'an exact revision'], @@ -51,7 +51,7 @@ function globPattern(pattern) { index += 1 } else if (character === '*') expression += '[^/]*' else if (character === '?') expression += '[^/]' - else expression += character.replace(REGEXP_META, '\\$&') + else expression += REGEXP_META.has(character) ? `\\${character}` : character } return new RegExp(`${expression}$`) } From 16ca542f143503013180dd551ef8f1cf2eb0dabc Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 31 Jul 2026 12:45:38 -0700 Subject: [PATCH 6/9] test(auth): cover contained error fallbacks --- .../src/__tests__/AuthSocketClient.test.ts | 90 ++++++++++++ .../src/__tests__/AuthSocket.test.ts | 19 ++- .../AuthSocketServer.lifecycle.test.ts | 130 ++++++++++++++++++ 3 files changed, 237 insertions(+), 2 deletions(-) diff --git a/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts b/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts index a0308e312..d09d2f835 100644 --- a/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts +++ b/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts @@ -157,6 +157,61 @@ describe('AuthSocketClient', () => { }) }) + it('contains serialization failures without sending untrusted data', async () => { + const onError = jest.fn() + mockSocket.id = undefined + const { client } = createClient(onError) + const circular: Record = {} + circular.self = circular + + expect(client.emit('circular', circular)).toBe(client) + await new Promise(resolve => setImmediate(resolve)) + + expect(mockPeer.toPeer).not.toHaveBeenCalled() + expect(onError).toHaveBeenCalledWith(expect.any(TypeError), { + phase: 'send', + socketId: '', + eventName: 'circular' + }) + mockSocket.id = 'socket-id' + }) + + it('uses the remembered socket ID when an asynchronous send fails after disconnect', async () => { + const error = new Error('send failed') + const onError = jest.fn() + mockSocket.id = undefined + mockPeer.toPeer.mockRejectedValue(error) + const { client } = createClient(onError) + + socketListeners.get('connect')?.() + client.emit('failing-event', true) + await new Promise(resolve => setImmediate(resolve)) + + expect(onError).toHaveBeenCalledWith(error, { + phase: 'send', + socketId: '', + eventName: 'failing-event' + }) + mockSocket.id = 'socket-id' + }) + + it('reports contained authentication failures through the client observer', async () => { + const authenticationFailure = new Error('authentication failed') + const onError = jest.fn() + createClient(onError) + const transport = mockPeerConstructor.mock.calls[0][1] as unknown as SocketClientTransport + await transport.onData(async () => await Promise.reject(authenticationFailure)) + + await socketListeners.get('authMessage')?.({ messageType: 'general' }) + await new Promise(resolve => setImmediate(resolve)) + + expect(onError).toHaveBeenCalledWith(authenticationFailure, { + phase: 'authentication', + socketId: 'socket-id' + }) + expect(mockSocket.disconnect).toHaveBeenCalledTimes(1) + }) + it('handles malformed general messages and clears identity on disconnect', () => { const { client } = createClient() const unknown = jest.fn() @@ -207,6 +262,41 @@ describe('AuthSocketClient', () => { expect(mockSocket.disconnect).toHaveBeenCalledTimes(1) }) + it('reports rejected disconnect handlers without disconnecting twice', async () => { + const onError = jest.fn() + mockSocket.id = undefined + const { client } = createClient(onError) + const applicationFailure = new Error('disconnect handler failed') + client.on('disconnect', async () => await Promise.reject(applicationFailure)) + + await socketListeners.get('disconnect')?.('transport close') + await new Promise(resolve => setImmediate(resolve)) + + expect(onError).toHaveBeenCalledWith(applicationFailure, { + phase: 'application', + socketId: '', + eventName: 'disconnect' + }) + expect(mockSocket.disconnect).not.toHaveBeenCalled() + mockSocket.id = 'socket-id' + }) + + it('contains application failures when no error observer is configured', async () => { + const { client } = createClient() + client.on('message', () => { + throw new Error('application failed') + }) + + await expect( + generalMessageListener?.( + 'server-key', + Array.from(Buffer.from(JSON.stringify({ eventName: 'message', data: true }))) + ) + ).resolves.toBeUndefined() + + expect(mockSocket.disconnect).toHaveBeenCalledTimes(1) + }) + it('ignores events without callbacks', () => { const { client } = createClient() diff --git a/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts b/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts index 17fb1b63d..a8cc30cfe 100644 --- a/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts +++ b/packages/messaging/authsocket/src/__tests__/AuthSocket.test.ts @@ -1,7 +1,7 @@ import { AuthSocket } from '../AuthSocketServer.js' describe('AuthSocket', () => { - function createHarness(onError = jest.fn()) { + function createHarness(onError = jest.fn(), useDefaultObserver = false) { let generalMessageListener: ((senderPublicKey: string, payload: number[]) => void | Promise) | undefined const peer = { @@ -17,7 +17,9 @@ describe('AuthSocket', () => { disconnect: jest.fn() } const identityDiscovered = jest.fn() - const authSocket = new AuthSocket(socket as never, peer as never, identityDiscovered, onError) + const authSocket = useDefaultObserver + ? new AuthSocket(socket as never, peer as never, identityDiscovered) + : new AuthSocket(socket as never, peer as never, identityDiscovered, onError) return { authSocket, generalMessage(payload: unknown, sender = 'peer-key') { @@ -106,6 +108,19 @@ describe('AuthSocket', () => { expect(socket.disconnect).toHaveBeenCalledWith(true) }) + it('contains application failures when no error observer is configured', async () => { + const { authSocket, generalMessage, socket } = createHarness(jest.fn(), true) + authSocket.on('message', () => { + throw new Error('application failed') + }) + + await expect( + generalMessage({ eventName: 'message', data: { untrusted: true } }) + ).resolves.toBeUndefined() + + expect(socket.disconnect).toHaveBeenCalledWith(true) + }) + it('ignores valid events without registered callbacks', () => { const { generalMessage } = createHarness() diff --git a/packages/messaging/authsocket/src/__tests__/AuthSocketServer.lifecycle.test.ts b/packages/messaging/authsocket/src/__tests__/AuthSocketServer.lifecycle.test.ts index 0c08d510b..32542c189 100644 --- a/packages/messaging/authsocket/src/__tests__/AuthSocketServer.lifecycle.test.ts +++ b/packages/messaging/authsocket/src/__tests__/AuthSocketServer.lifecycle.test.ts @@ -49,6 +49,7 @@ describe('AuthSocketServer lifecycle', () => { const rawListeners = new Map any>() const rawSocket = { id: 'socket-1', + disconnect: jest.fn(), emit: jest.fn(), on: jest.fn((eventName: string, callback: (...arguments_: any[]) => any) => { rawListeners.set(eventName, callback) @@ -107,6 +108,135 @@ describe('AuthSocketServer lifecycle', () => { expect(mockIoServer.on).toHaveBeenCalledWith('maintenance', callback) }) + it('contains connection construction and asynchronous application failures', async () => { + const onError = jest.fn() + const server = new AuthSocketServer({} as never, { wallet: {} as never, onError }) + const connectionListener = mockIoServer.on.mock.calls.find( + ([eventName]) => eventName === 'connection' + )?.[1] + const constructionFailure = new Error('construction failed') + const firstSocket = { + id: 'broken-construction', + disconnect: jest.fn(() => { + throw new Error('disconnect failed') + }), + emit: jest.fn(), + on: jest.fn() + } + mockPeerConstructor.mockImplementationOnce(() => { + throw constructionFailure + }) + + expect(() => connectionListener(firstSocket)).not.toThrow() + await Promise.resolve() + expect(onError).toHaveBeenCalledWith(constructionFailure, { + phase: 'connection', + socketId: 'broken-construction' + }) + expect(firstSocket.disconnect).toHaveBeenCalledWith(true) + + const callbackFailure = new Error('connection callback failed') + server.on('connection', async () => await Promise.reject(callbackFailure)) + const secondSocket = { + id: 'broken-callback', + disconnect: jest.fn(), + emit: jest.fn(), + on: jest.fn() + } + connectionListener(secondSocket) + await new Promise(resolve => setImmediate(resolve)) + + expect(onError).toHaveBeenCalledWith(callbackFailure, { + phase: 'connection', + socketId: 'broken-callback' + }) + expect(secondSocket.disconnect).toHaveBeenCalledWith(true) + }) + + it('contains server-side serialization failures before any peer send', async () => { + const onError = jest.fn() + const server = new AuthSocketServer({} as never, { wallet: {} as never, onError }) + + expect(() => server.emit('circular', 1n)).not.toThrow() + expect(server.emitToIdentity('identity', 'circular', 1n)).toBe(0) + await Promise.resolve() + + expect(mockPeer.toPeer).not.toHaveBeenCalled() + expect(onError).toHaveBeenNthCalledWith(1, expect.any(TypeError), { + phase: 'send', + eventName: 'circular' + }) + expect(onError).toHaveBeenNthCalledWith(2, expect.any(TypeError), { + phase: 'send', + eventName: 'circular' + }) + }) + + it('routes authenticated application failures through the server observer', async () => { + const applicationFailure = new Error('application failed') + const onError = jest.fn() + const server = new AuthSocketServer({} as never, { wallet: {} as never, onError }) + const connectionListener = mockIoServer.on.mock.calls.find( + ([eventName]) => eventName === 'connection' + )?.[1] + const rawSocket = { + id: 'socket-application', + disconnect: jest.fn(), + emit: jest.fn(), + on: jest.fn() + } + const connectionCallback = jest.fn() + server.on('connection', connectionCallback) + connectionListener(rawSocket) + await new Promise(resolve => setImmediate(resolve)) + const authenticatedSocket = connectionCallback.mock.calls[0][0] + authenticatedSocket.on('message', async () => await Promise.reject(applicationFailure)) + const generalMessageListener = mockPeer.listenForGeneralMessages.mock.calls.at(-1)[0] + + await generalMessageListener( + 'identity-key', + Array.from(Buffer.from(JSON.stringify({ eventName: 'message', data: true }))) + ) + await Promise.resolve() + + expect(onError).toHaveBeenCalledWith(applicationFailure, { + phase: 'application', + socketId: 'socket-application', + eventName: 'message' + }) + expect(rawSocket.disconnect).toHaveBeenCalledWith(true) + }) + + it('reports contained authentication failures through the server observer', async () => { + const authenticationFailure = new Error('authentication failed') + const onError = jest.fn() + new AuthSocketServer({} as never, { wallet: {} as never, onError }) + const connectionListener = mockIoServer.on.mock.calls.find( + ([eventName]) => eventName === 'connection' + )?.[1] + const rawListeners = new Map any>() + const rawSocket = { + id: 'socket-authentication', + disconnect: jest.fn(), + emit: jest.fn(), + on: jest.fn((eventName: string, callback: (...arguments_: any[]) => any) => { + rawListeners.set(eventName, callback) + }) + } + connectionListener(rawSocket) + const transport = mockPeerConstructor.mock.calls.at(-1)[1] as unknown as SocketServerTransport + await transport.onData(async () => await Promise.reject(authenticationFailure)) + + await rawListeners.get('authMessage')?.({ messageType: 'general' }) + await new Promise(resolve => setImmediate(resolve)) + + expect(onError).toHaveBeenCalledWith(authenticationFailure, { + phase: 'authentication', + socketId: 'socket-authentication' + }) + expect(rawSocket.disconnect).toHaveBeenCalledWith(true) + }) + it('closes Socket.IO once when shutdown is requested repeatedly', async () => { const server = new AuthSocketServer({} as never, { wallet: {} as never }) From c2e4b9cd9b7fc9c103615a43fc0efb6d0c1eb6c2 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 31 Jul 2026 12:54:32 -0700 Subject: [PATCH 7/9] ci: bound and skip unaffected workflow lanes --- .github/workflows/ci.yml | 31 ++++++++++++++-- .github/workflows/codegen.yml | 1 + .github/workflows/conformance.yml | 11 ++++++ .../workflows/container-runtime-contract.yml | 1 + docs/reference/test-quality-governance.md | 11 ++++++ scripts/ci-affected-scope.mjs | 9 ++--- scripts/ci-orchestration.test.mjs | 35 +++++++++++++++++++ 7 files changed, 93 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 346558ae3..5e5114a4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ jobs: repository-health: name: Repository health contract runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read steps: @@ -69,6 +70,7 @@ jobs: scope: name: Select affected work runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read outputs: @@ -77,6 +79,7 @@ jobs: build-projects: ${{ steps.scope.outputs.build-projects }} mutation-targets: ${{ steps.scope.outputs.mutation-targets }} infra-matrix: ${{ steps.scope.outputs.infra-matrix }} + has-infra: ${{ steps.scope.outputs.has-infra }} docs: ${{ steps.scope.outputs.docs }} conformance: ${{ steps.scope.outputs.conformance }} steps: @@ -120,6 +123,7 @@ jobs: echo "docs=$(jq -r '.docs' <<<"$SCOPE")" >> "$GITHUB_OUTPUT" echo "conformance=$(jq -r '.conformance' <<<"$SCOPE")" >> "$GITHUB_OUTPUT" echo "mutation-targets=$MUTATION_TARGETS" >> "$GITHUB_OUTPUT" + echo "has-infra=$(jq -r '.infraMatrix.include | length > 0' <<<"$SCOPE")" >> "$GITHUB_OUTPUT" { echo '### Affected scope' @@ -161,6 +165,7 @@ jobs: - sonar-zero-findings - dependency-review runs-on: ubuntu-latest + timeout-minutes: 5 permissions: {} steps: - name: Stop before installing or building when a cheap gate failed @@ -184,6 +189,7 @@ jobs: - early-gates - scope runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: read pull-requests: read @@ -464,6 +470,7 @@ jobs: - prepare - mutation-tests runs-on: ubuntu-latest + timeout-minutes: 5 permissions: {} steps: - name: Verify the affected mutation targets @@ -482,6 +489,7 @@ jobs: if: needs.prepare.outputs.standard-packages != '[]' needs: prepare runs-on: ubuntu-latest + timeout-minutes: 25 permissions: contents: read steps: @@ -524,6 +532,7 @@ jobs: if: needs.prepare.outputs.dependent-test-packages != '[]' needs: prepare runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: read strategy: @@ -587,6 +596,7 @@ jobs: if: needs.prepare.outputs.browser-packages != '[]' needs: prepare runs-on: ubuntu-latest + timeout-minutes: 25 permissions: contents: read strategy: @@ -650,6 +660,7 @@ jobs: if: needs.prepare.outputs.wallet_client == 'true' needs: prepare runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read steps: @@ -683,6 +694,7 @@ jobs: if: needs.prepare.outputs.wallet_mobile == 'true' needs: prepare runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read steps: @@ -706,6 +718,7 @@ jobs: if: needs.prepare.outputs.sdk == 'true' needs: prepare runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: read steps: @@ -756,6 +769,7 @@ jobs: if: needs.prepare.outputs.did == 'true' needs: prepare runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read steps: @@ -790,6 +804,7 @@ jobs: if: needs.prepare.outputs.wallet == 'true' needs: prepare runs-on: ubuntu-latest + timeout-minutes: 40 permissions: contents: read strategy: @@ -838,6 +853,7 @@ jobs: if: needs.prepare.outputs.wallet == 'true' needs: prepare runs-on: ubuntu-latest + timeout-minutes: 25 permissions: contents: read steps: @@ -882,6 +898,7 @@ jobs: if: needs.prepare.outputs.verifast == 'true' needs: prepare runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: read steps: @@ -933,6 +950,7 @@ jobs: if: needs.prepare.outputs.coverage-other-packages != '[]' needs: prepare runs-on: ubuntu-latest + timeout-minutes: 35 permissions: contents: read strategy: @@ -1033,6 +1051,7 @@ jobs: - coverage-verifast - coverage-other runs-on: ubuntu-latest + timeout-minutes: 25 permissions: contents: read steps: @@ -1096,6 +1115,7 @@ jobs: - wallet-browser-platform - wallet-mobile-platform runs-on: ubuntu-latest + timeout-minutes: 5 permissions: {} steps: - name: Verify parallel build, test, and coverage lanes @@ -1125,21 +1145,24 @@ jobs: - early-gates - scope runs-on: ubuntu-latest + timeout-minutes: 5 permissions: {} outputs: matrix: ${{ needs.scope.outputs.infra-matrix }} + has-infra: ${{ needs.scope.outputs.has-infra }} steps: - name: Confirm dependency-aware component selection env: INFRA_MATRIX: ${{ needs.scope.outputs.infra-matrix }} run: | - jq -e '.include | length > 0' <<<"$INFRA_MATRIX" >/dev/null echo "Selected infrastructure: $(jq -c '[.include[].display]' <<<"$INFRA_MATRIX")" infra: name: Infra / ${{ matrix.display }} + if: needs.infra-scope.outputs.has-infra == 'true' needs: infra-scope runs-on: ubuntu-latest + timeout-minutes: 35 permissions: contents: read strategy: @@ -1223,6 +1246,7 @@ jobs: - early-gates - scope runs-on: ubuntu-latest + timeout-minutes: 20 # Run on every PR (cheap and protects the docs site) and on pushes that touch docs # The build step inside will fail fast on frontmatter or link problems before they reach production if: needs.scope.outputs.docs == 'true' @@ -1269,6 +1293,7 @@ jobs: - scope if: needs.scope.outputs.conformance == 'true' runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read steps: @@ -1316,6 +1341,7 @@ jobs: name: Dependency Review if: github.event_name == 'pull_request' runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read steps: @@ -1339,6 +1365,7 @@ jobs: - conformance - dependency-review runs-on: ubuntu-latest + timeout-minutes: 5 permissions: {} steps: - name: Verify every required CI result @@ -1355,7 +1382,7 @@ jobs: if [[ "$HEALTH_RESULT" != "success" || "$BUILD_RESULT" != "success" || "$MUTATION_RESULT" != "success" || - "$INFRA_RESULT" != "success" || + ( "$INFRA_RESULT" != "success" && "$INFRA_RESULT" != "skipped" ) || ( "$DOCS_RESULT" != "success" && "$DOCS_RESULT" != "skipped" ) || ( "$CONFORMANCE_RESULT" != "success" && "$CONFORMANCE_RESULT" != "skipped" ) ]]; then echo "::error::Required CI failed: health=$HEALTH_RESULT build=$BUILD_RESULT mutation=$MUTATION_RESULT infra=$INFRA_RESULT docs=$DOCS_RESULT conformance=$CONFORMANCE_RESULT" diff --git a/.github/workflows/codegen.yml b/.github/workflows/codegen.yml index 4f6383198..32bd95d17 100644 --- a/.github/workflows/codegen.yml +++ b/.github/workflows/codegen.yml @@ -31,6 +31,7 @@ jobs: verify: name: Verify committed generated types runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read steps: diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 1725bdccb..0ef7c03a6 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -3,8 +3,18 @@ name: Conformance on: push: branches: [main, phase2/boundary-specs] + paths: + - '.github/workflows/conformance.yml' + - 'conformance/**' + - 'specs/**' + - 'scripts/generate-openapi-types.mjs' pull_request: branches: [main] + paths: + - '.github/workflows/conformance.yml' + - 'conformance/**' + - 'specs/**' + - 'scripts/generate-openapi-types.mjs' concurrency: group: conformance-${{ github.event.pull_request.number || github.ref }} @@ -16,6 +26,7 @@ permissions: jobs: ts-runner: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 diff --git a/.github/workflows/container-runtime-contract.yml b/.github/workflows/container-runtime-contract.yml index 61916ee12..8389d6441 100644 --- a/.github/workflows/container-runtime-contract.yml +++ b/.github/workflows/container-runtime-contract.yml @@ -36,6 +36,7 @@ jobs: scope: name: Select affected runtime images runs-on: ubuntu-24.04 + timeout-minutes: 10 permissions: contents: read outputs: diff --git a/docs/reference/test-quality-governance.md b/docs/reference/test-quality-governance.md index 0ee5ee325..54309225a 100644 --- a/docs/reference/test-quality-governance.md +++ b/docs/reference/test-quality-governance.md @@ -187,6 +187,17 @@ mutant see the same generated campaign. `FAST_CHECK_NUM_RUNS`, `FAST_CHECK_SEED`, and `FAST_CHECK_PATH` remain available for an explicit replay or deeper local investigation. +Pull-request CI applies the same dependency graph to package regressions, +browser/mobile consumers, infrastructure, and runtime images. Empty image and +infrastructure matrices do not allocate build runners. The standalone +TypeScript conformance workflow runs only when its vectors, specifications, +generator, or workflow change; SDK-dependent conformance behavior remains an +affected workspace regression. Cheap repository, dependency, scope, and Sonar +checks gate installation and compilation, matrix lanes cancel siblings on a +failure, and every CI job has a reviewed timeout instead of GitHub's six-hour +default. The zero-install orchestration tests enforce these resource and +fail-fast controls. + List and run targets locally: ```sh diff --git a/scripts/ci-affected-scope.mjs b/scripts/ci-affected-scope.mjs index 5aebc5380..a6944ffd2 100644 --- a/scripts/ci-affected-scope.mjs +++ b/scripts/ci-affected-scope.mjs @@ -307,10 +307,11 @@ function main(arguments_) { : selectWorkspaceScope(projects, changedFiles, importers) const infrastructure = all ? INFRA_COMPONENTS : selectInfraComponents(changedFiles) const runtimeComponents = all ? RUNTIME_COMPONENTS : selectRuntimeComponents(changedFiles) - const infraEntries = - infrastructure.length === 0 - ? [{ component: '_none', 'native-modules': '', run: false, display: 'no changes' }] - : infrastructure.map(entry => ({ ...entry, run: true, display: entry.component })) + const infraEntries = infrastructure.map(entry => ({ + ...entry, + run: true, + display: entry.component + })) process.stdout.write( JSON.stringify({ diff --git a/scripts/ci-orchestration.test.mjs b/scripts/ci-orchestration.test.mjs index b1d6968e3..5cf40e0dc 100644 --- a/scripts/ci-orchestration.test.mjs +++ b/scripts/ci-orchestration.test.mjs @@ -6,6 +6,18 @@ import test from 'node:test' import { REPOSITORY_ROOT } from './repository-health.mjs' const CI_PATH = join(REPOSITORY_ROOT, '.github/workflows/ci.yml') +const CONFORMANCE_PATH = join(REPOSITORY_ROOT, '.github/workflows/conformance.yml') +const RUNTIME_PATH = join(REPOSITORY_ROOT, '.github/workflows/container-runtime-contract.yml') + +function workflowJobBlocks(workflow) { + const jobsMarker = '\njobs:\n' + const jobs = workflow.slice(workflow.indexOf(jobsMarker) + jobsMarker.length) + const matches = [...jobs.matchAll(/^ ([a-z][a-z0-9-]+):$/gm)] + return matches.map((match, index) => ({ + name: match[1], + source: jobs.slice(match.index, matches[index + 1]?.index ?? jobs.length) + })) +} test('CI shares one audited build across coverage and browser consumer lanes', () => { const workflow = readFileSync(CI_PATH, 'utf8') @@ -61,3 +73,26 @@ test('CI skips empty duplicate lanes without weakening the aggregate gate', () = assert.match(workflow, /grep -Fxq '@bsv\/overlay-topics'/) assert.equal(workflow.match(/mongodb-memory-server binary cache warmed/g)?.length, 2) }) + +test('CI bounds every job and allocates no runner for an empty infrastructure matrix', () => { + const workflow = readFileSync(CI_PATH, 'utf8') + const jobs = workflowJobBlocks(workflow) + + assert.ok(jobs.length > 0) + for (const job of jobs) { + assert.match(job.source, /^ timeout-minutes: \d+$/m, `${job.name} must have a timeout`) + } + assert.match(workflow, /^ has-infra: \$\{\{ steps\.scope\.outputs\.has-infra \}\}$/m) + assert.match(workflow, /^ if: needs\.infra-scope\.outputs\.has-infra == 'true'$/m) + assert.match(workflow, /\( "\$INFRA_RESULT" != "success" && "\$INFRA_RESULT" != "skipped" \)/) +}) + +test('specialized workflows are scoped and bounded', () => { + const conformance = readFileSync(CONFORMANCE_PATH, 'utf8') + const runtime = readFileSync(RUNTIME_PATH, 'utf8') + + assert.equal(conformance.match(/- 'conformance\/\*\*'/g)?.length, 2) + assert.match(conformance, /^ timeout-minutes: 30$/m) + assert.match(runtime, /^ timeout-minutes: 10$/m) + assert.match(runtime, /^ if: needs\.scope\.outputs\.has-runtime == 'true'$/m) +}) From daa279765b6ffcb6d197b4575a262f495a7278f2 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 31 Jul 2026 12:55:35 -0700 Subject: [PATCH 8/9] test(auth): type captured transport explicitly --- .../src/__tests__/AuthSocketServer.lifecycle.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/messaging/authsocket/src/__tests__/AuthSocketServer.lifecycle.test.ts b/packages/messaging/authsocket/src/__tests__/AuthSocketServer.lifecycle.test.ts index 32542c189..f1b8ff23d 100644 --- a/packages/messaging/authsocket/src/__tests__/AuthSocketServer.lifecycle.test.ts +++ b/packages/messaging/authsocket/src/__tests__/AuthSocketServer.lifecycle.test.ts @@ -224,7 +224,9 @@ describe('AuthSocketServer lifecycle', () => { }) } connectionListener(rawSocket) - const transport = mockPeerConstructor.mock.calls.at(-1)[1] as unknown as SocketServerTransport + const transport = (mockPeerConstructor as jest.Mock).mock.calls.at( + -1 + )?.[1] as SocketServerTransport await transport.onData(async () => await Promise.reject(authenticationFailure)) await rawListeners.get('authMessage')?.({ messageType: 'general' }) From 92cc2e44ee2fd10f5d5f3b3696924081e3b53ef2 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Fri, 31 Jul 2026 12:57:01 -0700 Subject: [PATCH 9/9] test(auth): type client transport capture --- .../authsocket-client/src/__tests__/AuthSocketClient.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts b/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts index d09d2f835..cd15f886f 100644 --- a/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts +++ b/packages/messaging/authsocket-client/src/__tests__/AuthSocketClient.test.ts @@ -199,7 +199,7 @@ describe('AuthSocketClient', () => { const authenticationFailure = new Error('authentication failed') const onError = jest.fn() createClient(onError) - const transport = mockPeerConstructor.mock.calls[0][1] as unknown as SocketClientTransport + const transport = (mockPeerConstructor as jest.Mock).mock.calls[0][1] as SocketClientTransport await transport.onData(async () => await Promise.reject(authenticationFailure)) await socketListeners.get('authMessage')?.({ messageType: 'general' })