diff --git a/.sonarcloud.properties b/.sonarcloud.properties index 2016da2be..cc92966e5 100644 --- a/.sonarcloud.properties +++ b/.sonarcloud.properties @@ -10,7 +10,7 @@ sonar.exclusions=packages/verifast/src/wasm/bdk-core.*,conformance/generated/**, # into self-contained Docker/package build contexts and checked byte-for-byte # in CI. Analyze the code for issues, but do not report intentional generated # copies as source duplication. -sonar.cpd.exclusions=**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/*.man.test.ts,**/__test__/**,**/__tests__/**,**/test/**,**/tests/**,**/*.vectors.ts,**/eslint.config.js,infra/wab/src/security/rateLimitPolicy.ts,infra/uhrp-server-basic/src/security/rateLimitPolicy.ts,infra/uhrp-server-cloud-bucket/src/security/rateLimitPolicy.ts,infra/message-box-server/src/security/rateLimitPolicy.ts,infra/uhrp-server-basic/src/security/edgePolicy.ts,infra/uhrp-server-cloud-bucket/src/security/edgePolicy.ts,infra/message-box-server/src/security/edgePolicy.ts,infra/chaintracks-server/src/security/edgePolicy.ts,packages/overlays/overlay-express/src/security/edgePolicy.ts,packages/wallet/wallet-toolbox/src/storage/remoting/edgePolicy.ts,infra/uhrp-server-cloud-bucket/src/resourceLimits.ts,infra/uhrp-server-cloud-bucket/src/utils/network.ts,infra/wallet-infra/src/KnexPaymentReplayStore.ts,infra/uhrp-server-basic/src/chirp/core/**,infra/uhrp-server-cloud-bucket/src/chirp/core/**,infra/uhrp-server-basic/src/chirp/openapi.ts,infra/uhrp-server-cloud-bucket/src/chirp/openapi.ts,infra/uhrp-server-cloud-bucket/src/chirp/contracts.ts,infra/uhrp-server-cloud-bucket/src/chirp/commitIndex.ts,infra/uhrp-server-cloud-bucket/src/chirp/routes.ts +sonar.cpd.exclusions=**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/*.man.test.ts,**/__test__/**,**/__tests__/**,**/test/**,**/tests/**,**/*.vectors.ts,**/eslint.config.js,infra/wab/src/security/rateLimitPolicy.ts,infra/uhrp-server-basic/src/security/rateLimitPolicy.ts,infra/uhrp-server-cloud-bucket/src/security/rateLimitPolicy.ts,infra/message-box-server/src/security/rateLimitPolicy.ts,infra/uhrp-server-basic/src/security/edgePolicy.ts,infra/uhrp-server-cloud-bucket/src/security/edgePolicy.ts,infra/message-box-server/src/security/edgePolicy.ts,infra/chaintracks-server/src/security/edgePolicy.ts,packages/overlays/overlay-express/src/security/edgePolicy.ts,packages/wallet/wallet-toolbox/src/storage/remoting/edgePolicy.ts,infra/uhrp-server-cloud-bucket/src/resourceLimits.ts,infra/uhrp-server-cloud-bucket/src/utils/network.ts,infra/wallet-infra/src/KnexPaymentReplayStore.ts,infra/uhrp-server-basic/src/chirp/core/**,infra/uhrp-server-cloud-bucket/src/chirp/core/**,infra/uhrp-server-basic/src/chirp/openapi.ts,infra/uhrp-server-cloud-bucket/src/chirp/openapi.ts,infra/uhrp-server-cloud-bucket/src/chirp/contracts.ts,infra/uhrp-server-cloud-bucket/src/chirp/commitIndex.ts,infra/uhrp-server-cloud-bucket/src/chirp/routes.ts,infra/chaintracks-server/src/telemetry.ts,infra/wab/src/telemetry.ts,infra/uhrp-server-basic/src/telemetry.ts,infra/uhrp-server-cloud-bucket/src/telemetry.ts,infra/wallet-infra/src/telemetry.ts,infra/message-box-server/src/telemetry.ts # Narrow compatibility exceptions are registered with owner, evidence, review # dates, and objective removal conditions in repository-health/exceptions.json. sonar.issue.ignore.multicriteria=werrProtocolNames,curveSingletonAlias,curveSingletonReturn,scriptOpcodeDispatch diff --git a/conformance/META.json b/conformance/META.json index 1d0ceab2c..4318fb369 100644 --- a/conformance/META.json +++ b/conformance/META.json @@ -20,22 +20,10 @@ "sdk.keys.publickey", "sdk.crypto.signature" ], - "BRC-74": [ - "sdk.transactions.merklepath", - "broadcast.merklepath" - ], - "BRC-77": [ - "sdk.compat.bsm" - ], - "BRC-31": [ - "messaging.brc31.authrite-signature", - "auth.brc31-handshake", - "messaging.authsocket" - ], - "BRC-29": [ - "wallet.brc29.payment-derivation", - "payments.brc29-payment-protocol" - ], + "BRC-74": ["sdk.transactions.merklepath", "broadcast.merklepath"], + "BRC-77": ["sdk.compat.bsm"], + "BRC-31": ["messaging.brc31.authrite-signature"], + "BRC-29": ["wallet.brc29.payment-derivation", "payments.brc29-payment-protocol"], "BRC-100": [ "wallet.brc100.getpublickey", "wallet.brc100.createhmac", @@ -67,47 +55,21 @@ "wallet.brc100.getversion", "wallet.storage.adapterconformance" ], - "BRC-121": [ - "payments.brc121" - ], - "BRC-26": [ - "storage.uhrp-http" - ], - "BRC-167": [ - "storage.chirp-v1" - ], - "BRC-62": [ - "overlay.submit" - ], - "BRC-22": [ - "overlay.lookup", - "overlay.topicmanagement" - ], - "BRC-20": [ - "broadcast.arcsubmit", - "broadcast.merklepath" - ], - "BRC-21": [ - "sync.gasprotocol" - ], - "BRC-40": [ - "sync.brc40" - ], - "BRC-14": [ - "sdk.scripts.evaluation" - ], - "merkle-service": [ - "broadcast.merkle-service" - ], - "message-box": [ - "messaging.messagebox-http" - ], - "chaintracks-v2": [ - "sync.chaintracks-v2-http" - ], - "BRC-141": [ - "transport.air-gap-optical" - ] + "BRC-121": ["payments.brc121"], + "BRC-26": ["storage.uhrp-http"], + "BRC-167": ["storage.chirp-v1"], + "BRC-62": ["overlay.submit"], + "BRC-22": ["overlay.lookup", "overlay.topicmanagement"], + "BRC-20": ["broadcast.arcsubmit", "broadcast.merklepath"], + "BRC-21": ["sync.gasprotocol"], + "BRC-40": ["sync.brc40"], + "BRC-14": ["sdk.scripts.evaluation"], + "merkle-service": ["broadcast.merkle-service"], + "message-box": ["messaging.messagebox-http"], + "chaintracks-v2": ["sync.chaintracks-v2-http"], + "BRC-141": ["transport.air-gap-optical"], + "BRC-103": ["auth.brc31-handshake", "messaging.authsocket"], + "BRC-104": ["auth.brc31-handshake"] }, "stats": { "total_files": 77, diff --git a/conformance/PORTING_GUIDE.md b/conformance/PORTING_GUIDE.md index 77ee7ddaa..4632f53dd 100644 --- a/conformance/PORTING_GUIDE.md +++ b/conformance/PORTING_GUIDE.md @@ -8,16 +8,17 @@ ## 1. Current Corpus Status (as of 2026-05-14) -| Metric | Value | Notes | -|---------------------|-----------|-------| -| Vector files | 72 | All load cleanly | -| Total vectors | 6,625 | | -| Active (required) | ~6,414 | Passing in TypeScript reference | -| Intentionally skipped | 187 | See "Intended / Demoted Vectors" below | -| Structural runner | `conformance/runner/src/runner.js` | Validates format + writes reports | -| TS behavior runner | `pnpm --filter @bsv/conformance-runner-ts test` | Dispatches into `@bsv/sdk` | +| Metric | Value | Notes | +| --------------------- | ----------------------------------------------- | -------------------------------------- | +| Vector files | 72 | All load cleanly | +| Total vectors | 6,625 | | +| Active (required) | ~6,414 | Passing in TypeScript reference | +| Intentionally skipped | 187 | See "Intended / Demoted Vectors" below | +| Structural runner | `conformance/runner/src/runner.js` | Validates format + writes reports | +| TS behavior runner | `pnpm --filter @bsv/conformance-runner-ts test` | Dispatches into `@bsv/sdk` | **Important recent changes (May 2026)**: + - 3 legacy SDK vector files (`key-derivation`, `ecdsa`, `serialization`) were normalized to the modern schema-compliant format. - The structural runner was updated to cleanly support the special regression vector format (no more 72 noisy warnings). - The corpus is now in a robust, portable state. @@ -31,6 +32,7 @@ Other language repositories should consume the corpus in one of these ways: 1. **Git submodule** (recommended for long-term conformance) + ```bash git submodule add https://github.com/bsv-blockchain/ts-stack conformance git submodule update --init --recursive @@ -53,6 +55,7 @@ This must pass with zero fatal errors before you begin writing your runner. ## 3. Recommended Porting Order ### Phase 1: Deterministic Core (Highest Priority) + Start here. These have no external dependencies and should be 100% passable. - `sdk/crypto/` (8 files: aes, ecdsa, ecies, hash160, hmac, ripemd160, sha256, signature) @@ -64,6 +67,7 @@ Start here. These have no external dependencies and should be 100% passable. These exercise the majority of the cryptographic and script primitives that every implementation must get right. ### Phase 2: Protocol Domains + - `auth/` (BRC-31 handshake) - `broadcast/` (ARC submit + Merkle service) - `messaging/` (authsocket + message-box-http + BRC-31 authrite signatures) @@ -75,9 +79,11 @@ These exercise the majority of the cryptographic and script primitives that ever Most of these are shape + protocol compliance tests rather than heavy state. ### Phase 3: Wallet BRC-100 (with Caveats) + `wallet/brc100/` contains ~950 vectors across 27 method files. **Warning**: Many success paths are currently marked `parity_class: "intended"` because the TypeScript reference uses a minimal `ProtoWallet` + in-memory stub that cannot satisfy full stateful behavior without: + - A funded UTXO set + realistic fee model (for `createAction` / `signAction`) - Live overlay services (for `discoverBy*`) - Pre-existing certificates / actions in storage (for `acquireCertificate`, `proveCertificate`, `relinquish*`, etc.) @@ -85,10 +91,12 @@ Most of these are shape + protocol compliance tests rather than heavy state. See Section 6 for the full list of intentionally demoted vectors. You have two choices: + - Implement a comparable funded mock harness and promote the vectors to `required`. - Accept the same `intended` / skipped set and document the gap (preferred for initial conformance claims). ### Phase 4: Regressions + `regressions/` (12 files, 36 vectors) reproduce historical bugs found in the Go SDK or TypeScript SDK. These use a **different file format** (see `VECTOR-FORMAT.md` → "Regression Vectors"). @@ -102,6 +110,7 @@ They are extremely valuable for preventing re-introduction of past mistakes. Mos All standard vectors must conform to `conformance/schema/vector.schema.json`. Key requirements: + - `brc` must be an **array** (e.g. `["BRC-42"]`), never a string. - `parity_class` must be one of: `"required"`, `"intended"`, `"best-effort"`, `"unsupported"`. - Use lowercase hex for all binary data. @@ -109,6 +118,7 @@ Key requirements: - Never modify the `expected` values of an existing vector. If the reference implementation changes behavior, deprecate the old vector (`skip: true` + `skip_reason`) and add a new one. **Regression vectors** follow their own richer format (see `VECTOR-FORMAT.md`). Your runner must: + - Recognize files under `regressions/` - Parse the top-level `regression` object (especially `issue`) - Honor per-vector `parity_class` and `skip_reason` @@ -119,14 +129,15 @@ Key requirements: Your language runner must support the CLI contract defined in `VECTOR-FORMAT.md`: -| Flag | Behavior | -|-------------------------|----------| -| `--validate-only` | Parse + schema-validate only, no execution | -| `--filter ` | Run subset (e.g. `sdk.crypto.*` or `wallet/brc100/getpublickey`) | -| `--report ` | Write JSON + JUnit XML reports | -| `--verbose` | Per-vector pass/fail output | +| Flag | Behavior | +| ----------------- | ---------------------------------------------------------------- | +| `--validate-only` | Parse + schema-validate only, no execution | +| `--filter ` | Run subset (e.g. `sdk.crypto.*` or `wallet/brc100/getpublickey`) | +| `--report ` | Write JSON + JUnit XML reports | +| `--verbose` | Per-vector pass/fail output | Exit codes: + - `0` = all executed vectors passed (or validate-only succeeded) - `1` = one or more failures - `2` = schema / parse error @@ -140,6 +151,7 @@ You should produce a `report.json` compatible with the one emitted by the Node r As of the latest run, **187 vectors** are not executed as `required`: ### A. Wallet State-Dependent Vectors (~139) + These require infrastructure the current ProtoWallet harness does not provide: - `createAction` (90 vectors) + `signAction` (8) @@ -149,9 +161,11 @@ These require infrastructure the current ProtoWallet harness does not provide: **Recommendation for other languages**: Start by implementing the same demotion logic (or build an equivalent funded mock harness). Document the gap clearly. ### B. Regressions Marked `intended` (6) + Mostly Go-SDK-specific historical issues or known behavioral differences (e.g. MINIMALDATA / OP_VER handling in script evaluation, certain BIP276 edge cases). ### C. Vacuous Paths (7 documented cases) + A few wallet lifecycle methods (`isAuthenticated`, `waitForAuthentication`, `getHeight`, `getHeaderForHeight`, `getNetwork`, `getVersion`) have vectors that hit stub paths in the TS reference without performing a real assertion. These are explicitly called out in `COVERAGE.md` under "Vacuous Paths". --- @@ -160,12 +174,12 @@ A few wallet lifecycle methods (`isAuthenticated`, `waitForAuthentication`, `get Because not every vector can be `required` without heavy infrastructure, we recommend the following tiers for other language implementations: -| Tier | Requirement | What You Must Pass | -|-------------------|-------------|--------------------| -| **Crypto Core** | All deterministic SDK vectors | `sdk/crypto/*`, `sdk/keys/*`, `sdk/transactions/*`, `sdk/scripts/*`, `sdk/compat/*` | -| **Protocol Core** | Crypto Core + all protocol domains | + `auth/`, `broadcast/`, `messaging/`, `overlay/`, `payments/`, `storage/`, `sync/` | -| **Full Conformance** | Protocol Core + all `required` wallet vectors | + the non-demoted `wallet/brc100/*` vectors | -| **Wallet Complete** | Full Conformance + funded harness | All 995 wallet vectors (including the currently `intended` ones) | +| Tier | Requirement | What You Must Pass | +| -------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------- | +| **Crypto Core** | All deterministic SDK vectors | `sdk/crypto/*`, `sdk/keys/*`, `sdk/transactions/*`, `sdk/scripts/*`, `sdk/compat/*` | +| **Protocol Core** | Crypto Core + all protocol domains | + `auth/`, `broadcast/`, `messaging/`, `overlay/`, `payments/`, `storage/`, `sync/` | +| **Full Conformance** | Protocol Core + all `required` wallet vectors | + the non-demoted `wallet/brc100/*` vectors | +| **Wallet Complete** | Full Conformance + funded harness | All 995 wallet vectors (including the currently `intended` ones) | Publish your achieved tier + any justified deviations. @@ -212,6 +226,7 @@ pnpm --filter @bsv/conformance-runner-ts test ``` Key files for port authors: + - `conformance/META.json` — authoritative index - `conformance/PARITY_MATRIX.json` — **machine-readable** parity status (recommended for Go/Rust/Python teams) - `conformance/COVERAGE.md` — detailed human-readable status, intended vectors, vacuous paths @@ -244,4 +259,24 @@ Key files for port authors: **Maintained by the ts-stack team.** Last updated: 2026-05-14 (after legacy file normalization and runner regression improvements). -If you are actively aligning a Go, Rust, or Python implementation and find gaps in this guide, please open an issue with the title prefix `[Porting Guide]`. We want this document to be the single best resource for cross-language conformance. \ No newline at end of file +If you are actively aligning a Go, Rust, or Python implementation and find gaps in this guide, please open an issue with the title prefix `[Porting Guide]`. We want this document to be the single best resource for cross-language conformance. + +## Authentication identifiers and emitted handshake + +`auth/brc31-handshake.json` and its vector IDs retain their historical spelling +for consumers that store corpus IDs. Their protocol metadata is BRC-103/BRC-104; +`messaging/authsocket.json` is BRC-103. BRC-31 remains the separate Authrite +protocol in `messaging/brc31/authrite-signature.json`. Select by corrected `brc` +metadata, not by the historical filename. + +The recorded initialRequest was captured from the real SDK Peer through +SimplifiedFetchTransport. Its fields are `version`, `messageType`, `identityKey`, +`initialNonce` and `requestedCertificates`; the HTTP request adds only JSON +content type. There is no initialRequest nonce, payload or signature member. +General application messages use the separate BRC-104 auth-header path. The +executable wire test rechecks the emitted shape and normalizes only the random +nonce value. HTTP error/scenario rows remain structural scenario documentation. + +Certificate responses retain the v0.1 wire shape. Requesters enforce their own +recorded certificate sets, including dynamic requests, and certificate listeners +observe completed validation rather than vetoing it. diff --git a/conformance/runner/ts/auth-wire.test.ts b/conformance/runner/ts/auth-wire.test.ts new file mode 100644 index 000000000..dca8e1749 --- /dev/null +++ b/conformance/runner/ts/auth-wire.test.ts @@ -0,0 +1,61 @@ +import { readFileSync } from 'node:fs' +import { expect, test } from '@jest/globals' +import { + Peer, + PrivateKey, + ProtoWallet, + SimplifiedFetchTransport, + type WalletInterface +} from '@bsv/sdk' + +const http = JSON.parse( + readFileSync(new URL('../../vectors/auth/brc31-handshake.json', import.meta.url), 'utf8') +) +const socket = JSON.parse( + readFileSync(new URL('../../vectors/messaging/authsocket.json', import.meta.url), 'utf8') +) +const authrite = JSON.parse( + readFileSync( + new URL('../../vectors/messaging/brc31/authrite-signature.json', import.meta.url), + 'utf8' + ) +) + +test('the recorded initialRequest matches real Peer and HTTP transport emission', async () => { + const requests: Array<{ url: string; request: RequestInit }> = [] + const capture: typeof fetch = async (url, request) => { + requests.push({ url: String(url), request: request ?? {} }) + throw new Error('fixture capture complete') + } + const transport = new SimplifiedFetchTransport('https://fixture.invalid', capture) + const peer = new Peer(new ProtoWallet(new PrivateKey(1)) as WalletInterface, transport) + await peer.ready + await expect(peer.getAuthenticatedSession()).rejects.toThrow('fixture capture complete') + expect(requests).toHaveLength(1) + const { url, request } = requests[0] + const fixture = http.vectors[0].input + expect(new URL(url).pathname).toBe(fixture.path) + expect(request.method).toBe(fixture.method) + expect(request.headers).toEqual(fixture.headers) + const body = JSON.parse(String(request.body)) + // Nonces are random per handshake; only their value is normalized for this comparison. + expect(Buffer.from(body.initialNonce, 'base64')).toHaveLength(48) + expect(Buffer.from(fixture.body.initialNonce, 'base64')).toHaveLength(48) + expect({ ...body, initialNonce: fixture.body.initialNonce }).toEqual(fixture.body) + expect(socket.vectors[0].input.payload).toEqual(fixture.body) + expect(Object.keys(body).sort()).toEqual([ + 'identityKey', + 'initialNonce', + 'messageType', + 'requestedCertificates', + 'version' + ]) +}) + +test('BRC metadata separates mutual authentication from Authrite while preserving stable vector IDs', () => { + expect(http.id).toBe('auth.brc31-handshake') + expect(http.brc).toEqual(['BRC-103', 'BRC-104']) + expect(socket.brc).toEqual(['BRC-103']) + expect(authrite.brc).toContain('BRC-31') + expect(authrite.brc).not.toContain('BRC-103') +}) diff --git a/conformance/runner/ts/dispatchers/auth.ts b/conformance/runner/ts/dispatchers/auth.ts index 6e608fa88..a50e48971 100644 --- a/conformance/runner/ts/dispatchers/auth.ts +++ b/conformance/runner/ts/dispatchers/auth.ts @@ -1,21 +1,10 @@ /** - * Auth dispatcher — Wave 1. - * - * Categories: - * brc31-handshake - * - * Implementation notes: - * -------------------- - * BRC-31 is a server-side mutual-authentication protocol implemented in - * `packages/middleware/auth-express-middleware`. The conformance vectors - * describe HTTP request/response pairs that require a running Express server. - * - * Vectors that are purely structural (AuthMessage shape, pubkey format, nonce - * encoding, requestId encoding) are exercised here against the SDK's `Peer` / - * `AuthMessage` types and helper utilities. Vectors that require server-side - * behaviour (middleware error responses, certificate timeouts, replay detection, - * response signing) are demoted to `best-effort` in the vector file with an - * explanation, so the runner skips them without failing the CI gate. + * BRC-103 authentication and BRC-104 HTTP conformance shapes. + * The historical brc31-handshake category remains stable for corpus consumers; + * protocol selection must use the corrected BRC metadata. BRC-31 Authrite is + * a separate protocol. HTTP scenario rows check documented response shapes; + * auth-wire.test.ts exercises the real SDK handshake emission with an injected + * fetch implementation and makes no external network requests. */ import { expect } from '@jest/globals' @@ -33,7 +22,7 @@ function getBool(m: Record, key: string): boolean { return m[key] === true } -// Regex patterns from the OpenAPI spec (brc31-handshake.yaml components/schemas) +// Patterns shared with specs/auth/brc103-mutual-auth.yaml. const PUBKEY_HEX_PATTERN = /^0[23][0-9a-fA-F]{64}$/ const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ @@ -43,7 +32,7 @@ const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ * * The AuthMessage for `initialRequest` must have: * messageType, version, identityKey (as required fields) - * Optional: nonce, initialNonce, payload (array), signature (array) + * Also emitted: initialNonce and requestedCertificates. No nonce, payload or signature. */ function dispatchInitialRequest( input: Record, @@ -59,74 +48,41 @@ function dispatchInitialRequest( for (const [k, v] of Object.entries(headers)) { lowerHeaders[k.toLowerCase()] = `${v}` } - expect(lowerHeaders['x-bsv-auth-version']).toBeDefined() - expect(lowerHeaders['x-bsv-auth-identity-key']).toBeDefined() - expect(lowerHeaders['x-bsv-auth-nonce']).toBeDefined() - - // Validate the identity key is a valid compressed pubkey - const identityKey = lowerHeaders['x-bsv-auth-identity-key'] ?? '' - expect(identityKey).toMatch(PUBKEY_HEX_PATTERN) - - // Validate the nonce is base64 - const nonce = lowerHeaders['x-bsv-auth-nonce'] ?? '' - expect(BASE64_PATTERN.test(nonce)).toBe(true) + expect(lowerHeaders).toEqual({ 'content-type': 'application/json' }) // Validate body AuthMessage shape const body = (input['body'] ?? {}) as Record expect(getString(body, 'messageType')).toBe('initialRequest') expect(typeof body['version']).toBe('string') expect(getString(body, 'identityKey')).toMatch(PUBKEY_HEX_PATTERN) - expect(Array.isArray(body['payload'])).toBe(true) - expect(Array.isArray(body['signature'])).toBe(true) + expect(Object.keys(body).sort((left, right) => left.localeCompare(right))).toEqual([ + 'identityKey', + 'initialNonce', + 'messageType', + 'requestedCertificates', + 'version' + ]) + expect(getString(body, 'initialNonce')).toMatch(BASE64_PATTERN) + expect(Buffer.from(getString(body, 'initialNonce'), 'base64')).toHaveLength(48) + expect(body['requestedCertificates']).toEqual({ certifiers: [], types: {} }) // Validate expected response body shape const bodyShape = (expected['body_shape'] ?? {}) as Record expect(getString(bodyShape, 'messageType')).toBe('initialResponse') expect(getString(bodyShape, 'version')).toBe('0.1') expect(getString(bodyShape, 'identityKey')).toBe('string') - expect(getString(bodyShape, 'nonce')).toBe('string') + expect(getString(bodyShape, 'initialNonce')).toBe('string') expect(getString(bodyShape, 'yourNonce')).toBe('string') expect(getString(bodyShape, 'signature')).toBe('array') } -/** - * auth.brc31-handshake.2 - * Phase 1 step 2: server initialResponse — required headers list check. - * This is a structural check of the *expected* header list from the spec. - */ -function dispatchInitialResponseHeaders( - input: Record, - expected: Record -): void { - // The input describes what the client sends (same shape as vector 1) - expect(getString(input, 'method')).toBe('POST') - expect(getString(input, 'path')).toBe('/.well-known/auth') - - // Verify the spec-mandated response headers are all listed in the vector - const requiredHeaders = expected['response_headers_required'] as string[] - expect(Array.isArray(requiredHeaders)).toBe(true) - - const SPEC_REQUIRED = [ - 'x-bsv-auth-version', - 'x-bsv-auth-message-type', - 'x-bsv-auth-identity-key', - 'x-bsv-auth-nonce', - 'x-bsv-auth-your-nonce', - 'x-bsv-auth-signature' - ] - - for (const h of SPEC_REQUIRED) { - expect(requiredHeaders.map(s => s.toLowerCase())).toContain(h) - } -} - /** * auth.brc31-handshake.3, .4 - * Error case: missing required header → expected 401. + * Error case: missing required body field → expected 401. * Server-only behaviour, demoted to best-effort. * This function is called only if the vector was NOT demoted (shouldn't happen). */ -function dispatchMissingHeaderError( +function dispatchMissingFieldError( input: Record, expected: Record ): void { @@ -206,14 +162,14 @@ function dispatchCertificateTimeout( /** * auth.brc31-handshake.11 - * requestedCertificates header present. Server-only, demoted to best-effort. + * requestedCertificates body field present. Server-only, demoted to best-effort. */ -function dispatchRequestedCertificatesHeader( +function dispatchRequestedCertificatesBody( _input: Record, expected: Record ): void { - const includes = (expected['response_headers_includes'] ?? {}) as Record - expect(includes['x-bsv-auth-requested-certificates']).toBe('present') + const includes = (expected['response_body_includes'] ?? {}) as Record + expect(includes['requestedCertificates']).toBe('present') } /** @@ -232,7 +188,7 @@ function dispatchAuthMessageSchema( expect(getString(input, 'identityKey')).toMatch(PUBKEY_HEX_PATTERN) // The nonce must be base64 when present - const nonce = getString(input, 'nonce') + const nonce = getString(input, 'initialNonce') if (nonce !== '') { expect(BASE64_PATTERN.test(nonce)).toBe(true) } @@ -259,14 +215,6 @@ function dispatchAuthMessageSchema( * requestId is 32 bytes, base64-encoded (44 chars with padding). * Pure math / encoding check — fully exercisable client-side. * - * NOTE (human review): The vector's `requestId_example` field - * ("cmVxdWVzdElkMzJCeXRlc1JhbmRvbVZhbHVlQQ==") decodes to 28 bytes, - * not the 32 bytes required by the spec (`requestId_length_bytes: 32`). - * The correct 32-byte example would be 44 base64 chars with padding. - * The expected.requestId_base64_length of 44 is correct for 32 bytes. - * The example in the vector is inconsistent with the stated length spec. - * Do NOT change expected.requestId_base64_length — it is correct. - * The example string should be updated to encode exactly 32 bytes. */ function dispatchRequestIdFormat( input: Record, @@ -284,15 +232,10 @@ function dispatchRequestIdFormat( const computedBase64Length = Math.ceil(lengthBytes / 3) * 4 expect(computedBase64Length).toBe(expectedBase64Length) - // Verify the example is valid base64 (even if the decoded length mismatches - // the spec — see NOTE above; we do not change expected values) const example = getString(input, 'requestId_example') - if (example !== '') { - expect(BASE64_PATTERN.test(example)).toBe(true) - // NOTE: The example decodes to 28 bytes instead of the spec-required 32. - // We assert the encoding math separately above rather than checking - // the example length to avoid a spurious failure until the vector is corrected. - } + expect(BASE64_PATTERN.test(example)).toBe(true) + expect(Buffer.from(example, 'base64')).toHaveLength(lengthBytes) + expect(example).toHaveLength(expectedBase64Length) } /** @@ -360,22 +303,18 @@ function dispatchWellKnownAuth( const expectedStatus = expected['status'] as number | undefined if (expectedStatus === 401 && messageType === 'initialRequest') { - dispatchMissingHeaderError(input, expected) + dispatchMissingFieldError(input, expected) return true } if (expectedStatus === 408) { dispatchCertificateTimeout(input, expected) return true } - if ('response_headers_includes' in expected) { - dispatchRequestedCertificatesHeader(input, expected) - return true - } - if ('response_headers_required' in expected && messageType === 'initialRequest') { - if ('body_shape' in expected) dispatchInitialRequest(input, expected) - else dispatchInitialResponseHeaders(input, expected) + if ('response_body_includes' in expected) { + dispatchRequestedCertificatesBody(input, expected) return true } + if ('body_shape' in expected && messageType === 'initialRequest') { dispatchInitialRequest(input, expected) return true diff --git a/conformance/runner/ts/dispatchers/messaging.ts b/conformance/runner/ts/dispatchers/messaging.ts index f918dee4e..f0b210acf 100644 --- a/conformance/runner/ts/dispatchers/messaging.ts +++ b/conformance/runner/ts/dispatchers/messaging.ts @@ -155,7 +155,7 @@ function dispatchInitialRequestShape(expected: Record): void { expect(responseShape).toHaveProperty('messageType') expect(responseShape.messageType).toBe('initialResponse') expect(responseShape).toHaveProperty('identityKey') - expect(responseShape).toHaveProperty('nonce') + expect(responseShape).toHaveProperty('initialNonce') expect(responseShape).toHaveProperty('signature') } if ('response_shape_includes' in expected) { diff --git a/conformance/vectors/auth/brc31-handshake.json b/conformance/vectors/auth/brc31-handshake.json index 3fd769044..a0e016be9 100644 --- a/conformance/vectors/auth/brc31-handshake.json +++ b/conformance/vectors/auth/brc31-handshake.json @@ -1,32 +1,30 @@ { "$schema": "../../../schema/vector.schema.json", "id": "auth.brc31-handshake", - "name": "BRC-31 Mutual Authentication HTTP Handshake", - "brc": ["BRC-31"], - "version": "1.0.0", - "reference_impl": "packages/middleware/auth-express-middleware", + "name": "BRC-103 Mutual Authentication and BRC-104 HTTP Transport", + "brc": ["BRC-103", "BRC-104"], + "version": "1.1.0", + "reference_impl": "packages/sdk", "parity_class": "required", "vectors": [ { "id": "auth.brc31-handshake.1", - "description": "Phase 1 step 1: client sends initialRequest to /.well-known/auth — shape check", + "description": "Real SDK initialRequest emitted by Peer through SimplifiedFetchTransport; nonce is a captured random example", "input": { "method": "POST", "path": "/.well-known/auth", "headers": { - "x-bsv-auth-version": "0.1", - "x-bsv-auth-identity-key": "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1", - "x-bsv-auth-nonce": "dGVzdE5vbmNlMTIzNA==", - "content-type": "application/json" + "Content-Type": "application/json" }, "body": { - "messageType": "initialRequest", "version": "0.1", - "identityKey": "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1", - "nonce": "dGVzdE5vbmNlMTIzNA==", - "initialNonce": "dGVzdE5vbmNlMTIzNA==", - "payload": [], - "signature": [] + "messageType": "initialRequest", + "identityKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "initialNonce": "Z9+3SZ9qiJvtpQN1RNmmGue247vPuQLeqfIV2eFIMtvRRqFb0FpYckzLbawd0bdR", + "requestedCertificates": { + "certifiers": [], + "types": {} + } } }, "expected": { @@ -35,62 +33,65 @@ "messageType": "initialResponse", "version": "0.1", "identityKey": "string", - "nonce": "string", "yourNonce": "string", - "signature": "array" + "signature": "array", + "initialNonce": "string", + "requestedCertificates": "object" } }, - "tags": ["brc-31", "phase-1", "initial-request", "happy-path"] + "tags": ["brc-103", "phase-1", "initial-request", "happy-path"] }, { "id": "auth.brc31-handshake.2", - "description": "Phase 1 step 2: server initialResponse — required headers present", + "description": "Phase 1 step 2: initialResponse is a JSON AuthMessage, not a general-message header envelope", "input": { "method": "POST", "path": "/.well-known/auth", "headers": { - "x-bsv-auth-version": "0.1", - "x-bsv-auth-identity-key": "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1", - "x-bsv-auth-nonce": "dGVzdE5vbmNlMTIzNA==" + "Content-Type": "application/json" }, "body": { - "messageType": "initialRequest", "version": "0.1", - "identityKey": "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1", - "nonce": "dGVzdE5vbmNlMTIzNA==", - "initialNonce": "dGVzdE5vbmNlMTIzNA==", - "payload": [], - "signature": [] + "messageType": "initialRequest", + "identityKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "initialNonce": "Z9+3SZ9qiJvtpQN1RNmmGue247vPuQLeqfIV2eFIMtvRRqFb0FpYckzLbawd0bdR", + "requestedCertificates": { + "certifiers": [], + "types": {} + } } }, "expected": { "status": 200, - "response_headers_required": [ - "x-bsv-auth-version", - "x-bsv-auth-message-type", - "x-bsv-auth-identity-key", - "x-bsv-auth-nonce", - "x-bsv-auth-your-nonce", - "x-bsv-auth-signature" - ] + "body_shape": { + "messageType": "initialResponse", + "version": "0.1", + "identityKey": "string", + "yourNonce": "string", + "signature": "array", + "initialNonce": "string", + "requestedCertificates": "object" + } }, - "tags": ["brc-31", "phase-1", "initial-response", "schema"] + "tags": ["brc-103", "phase-1", "initial-response", "schema"] }, { "id": "auth.brc31-handshake.3", - "description": "Phase 1: missing x-bsv-auth-identity-key header — 401", + "description": "Phase 1: absent identityKey in the JSON initialRequest body", "input": { "method": "POST", "path": "/.well-known/auth", "headers": { - "x-bsv-auth-version": "0.1", - "x-bsv-auth-nonce": "dGVzdE5vbmNlMTIzNA==" + "Content-Type": "application/json" }, "body": { - "messageType": "initialRequest", "version": "0.1", - "identityKey": "", - "nonce": "dGVzdE5vbmNlMTIzNA==" + "messageType": "initialRequest", + "initialNonce": "Z9+3SZ9qiJvtpQN1RNmmGue247vPuQLeqfIV2eFIMtvRRqFb0FpYckzLbawd0bdR", + "requestedCertificates": { + "certifiers": [], + "types": {} + } } }, "expected": { @@ -100,22 +101,25 @@ "code": "UNAUTHORIZED" } }, - "tags": ["brc-31", "phase-1", "error", "missing-identity-key"] + "tags": ["brc-103", "phase-1", "error", "missing-identity-key"] }, { "id": "auth.brc31-handshake.4", - "description": "Phase 1: missing x-bsv-auth-nonce header — 401", + "description": "Phase 1: absent initialNonce in the JSON initialRequest body", "input": { "method": "POST", "path": "/.well-known/auth", "headers": { - "x-bsv-auth-version": "0.1", - "x-bsv-auth-identity-key": "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1" + "Content-Type": "application/json" }, "body": { - "messageType": "initialRequest", "version": "0.1", - "identityKey": "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1" + "messageType": "initialRequest", + "identityKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "requestedCertificates": { + "certifiers": [], + "types": {} + } } }, "expected": { @@ -125,11 +129,11 @@ "code": "UNAUTHORIZED" } }, - "tags": ["brc-31", "phase-1", "error", "missing-nonce"] + "tags": ["brc-103", "phase-1", "error", "missing-nonce"] }, { "id": "auth.brc31-handshake.5", - "description": "Phase 2: authenticated GET request — general request headers shape", + "description": "Phase 2: authenticated GET request \u2014 general request headers shape", "input": { "method": "GET", "path": "/api/resource", @@ -153,7 +157,7 @@ "x-bsv-auth-signature" ] }, - "tags": ["brc-31", "phase-2", "general-request", "schema"] + "tags": ["brc-103", "phase-2", "general-request", "schema"] }, { "id": "auth.brc31-handshake.6", @@ -178,15 +182,13 @@ }, "expected": { "status": 200, - "response_headers_required": [ - "x-bsv-auth-signature" - ] + "response_headers_required": ["x-bsv-auth-signature"] }, - "tags": ["brc-31", "phase-2", "general-request", "post-with-body"] + "tags": ["brc-103", "phase-2", "general-request", "post-with-body"] }, { "id": "auth.brc31-handshake.7", - "description": "Phase 2: missing general request signature header — 401", + "description": "Phase 2: missing general request signature header \u2014 401", "input": { "method": "GET", "path": "/api/resource", @@ -203,11 +205,11 @@ "code": "UNAUTHORIZED" } }, - "tags": ["brc-31", "phase-2", "error", "missing-signature"] + "tags": ["brc-103", "phase-2", "error", "missing-signature"] }, { "id": "auth.brc31-handshake.8", - "description": "Phase 2: bad signature — 401", + "description": "Phase 2: bad signature \u2014 401", "input": { "method": "GET", "path": "/api/resource", @@ -227,7 +229,7 @@ "code": "ERR_AUTH_FAILED" } }, - "tags": ["brc-31", "phase-2", "error", "bad-signature"] + "tags": ["brc-103", "phase-2", "error", "bad-signature"] }, { "id": "auth.brc31-handshake.9", @@ -244,26 +246,26 @@ "status": 200, "req_auth_identity_key": "unknown" }, - "tags": ["brc-31", "unauthenticated-passthrough", "allow-unauthenticated"] + "tags": ["brc-103", "unauthenticated-passthrough", "allow-unauthenticated"] }, { "id": "auth.brc31-handshake.10", - "description": "Certificate timeout: server waits 30s for client certs — 408", + "description": "Certificate timeout: server waits 30s for client certs \u2014 408", "input": { "method": "POST", "path": "/.well-known/auth", "headers": { - "x-bsv-auth-version": "0.1", - "x-bsv-auth-identity-key": "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1", - "x-bsv-auth-nonce": "dGVzdE5vbmNlMTIzNA==" + "Content-Type": "application/json" }, "body": { - "messageType": "initialRequest", "version": "0.1", - "identityKey": "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1", - "nonce": "dGVzdE5vbmNlMTIzNA==", - "payload": [], - "signature": [] + "messageType": "initialRequest", + "identityKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "initialNonce": "Z9+3SZ9qiJvtpQN1RNmmGue247vPuQLeqfIV2eFIMtvRRqFb0FpYckzLbawd0bdR", + "requestedCertificates": { + "certifiers": [], + "types": {} + } }, "_scenario": "server requires certificates (certificatesToRequest non-empty) and client never provides them within 30s" }, @@ -274,68 +276,81 @@ "code": "CERTIFICATE_TIMEOUT" } }, - "tags": ["brc-31", "phase-1", "certificate-timeout", "error"] + "tags": ["brc-103", "phase-1", "certificate-timeout", "error"] }, { "id": "auth.brc31-handshake.11", - "description": "requestedCertificates returned in initialResponse headers when server requires certs", + "description": "requestedCertificates is carried in the initialResponse JSON body when the server requests certificates", "input": { "method": "POST", "path": "/.well-known/auth", "headers": { - "x-bsv-auth-version": "0.1", - "x-bsv-auth-identity-key": "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1", - "x-bsv-auth-nonce": "dGVzdE5vbmNlMTIzNA==" + "Content-Type": "application/json" }, "body": { - "messageType": "initialRequest", "version": "0.1", - "identityKey": "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1", - "nonce": "dGVzdE5vbmNlMTIzNA==", - "payload": [], - "signature": [] + "messageType": "initialRequest", + "identityKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "initialNonce": "Z9+3SZ9qiJvtpQN1RNmmGue247vPuQLeqfIV2eFIMtvRRqFb0FpYckzLbawd0bdR", + "requestedCertificates": { + "certifiers": [], + "types": {} + } }, "_scenario": "server configured with certificatesToRequest" }, "expected": { "status": 200, - "response_headers_includes": { - "x-bsv-auth-requested-certificates": "present" + "response_body_includes": { + "requestedCertificates": "present" } }, - "tags": ["brc-31", "phase-1", "certificate-request", "schema"] + "tags": ["brc-103", "phase-1", "certificate-request", "schema"] }, { "id": "auth.brc31-handshake.12", "description": "AuthMessage shape: initialRequest schema check", "input": { "_schema_check": true, - "messageType": "initialRequest", "version": "0.1", - "identityKey": "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1", - "nonce": "dGVzdE5vbmNlMTIzNA==", - "initialNonce": "dGVzdE5vbmNlMTIzNA==", - "payload": [], - "signature": [] + "messageType": "initialRequest", + "identityKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "initialNonce": "Z9+3SZ9qiJvtpQN1RNmmGue247vPuQLeqfIV2eFIMtvRRqFb0FpYckzLbawd0bdR", + "requestedCertificates": { + "certifiers": [], + "types": {} + } }, "expected": { - "valid_message_types": ["initialRequest", "initialResponse", "general"], - "required_fields": ["messageType", "version", "identityKey"] + "valid_message_types": [ + "initialRequest", + "initialResponse", + "certificateRequest", + "certificateResponse", + "general" + ], + "required_fields": [ + "messageType", + "version", + "identityKey", + "initialNonce", + "requestedCertificates" + ] }, - "tags": ["brc-31", "schema", "auth-message"] + "tags": ["brc-103", "schema", "auth-message"] }, { "id": "auth.brc31-handshake.13", "description": "Phase 2: requestId is 32 bytes base64-encoded", "input": { "_schema_note": "x-bsv-auth-request-id is always a 32-byte random value, base64-encoded (44 chars with padding)", - "requestId_example": "cmVxdWVzdElkMzJCeXRlc1JhbmRvbVZhbHVlQQ==", + "requestId_example": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", "requestId_length_bytes": 32 }, "expected": { "requestId_base64_length": 44 }, - "tags": ["brc-31", "schema", "request-id"] + "tags": ["brc-103", "schema", "request-id"] }, { "id": "auth.brc31-handshake.14", @@ -344,17 +359,17 @@ "method": "POST", "path": "/.well-known/auth", "headers": { - "x-bsv-auth-version": "0.1", - "x-bsv-auth-identity-key": "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1", - "x-bsv-auth-nonce": "dGVzdE5vbmNlMTIzNA==" + "Content-Type": "application/json" }, "body": { - "messageType": "initialRequest", "version": "0.1", - "identityKey": "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1", - "nonce": "dGVzdE5vbmNlMTIzNA==", - "payload": [], - "signature": [] + "messageType": "initialRequest", + "identityKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "initialNonce": "Z9+3SZ9qiJvtpQN1RNmmGue247vPuQLeqfIV2eFIMtvRRqFb0FpYckzLbawd0bdR", + "requestedCertificates": { + "certifiers": [], + "types": {} + } }, "_scenario": "this exact nonce was already used in a previous request; SessionManager has seen it" }, @@ -365,7 +380,7 @@ "code": "ERR_AUTH_FAILED" } }, - "tags": ["brc-31", "replay-protection", "error"] + "tags": ["brc-103", "replay-protection", "error"] }, { "id": "auth.brc31-handshake.15", @@ -377,7 +392,7 @@ "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" ], "invalid_examples": [ - "04" , + "04", "01deadbeef", "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc" ] @@ -385,11 +400,11 @@ "expected": { "pattern": "^0[23][0-9a-fA-F]{64}$" }, - "tags": ["brc-31", "schema", "pubkey-format"] + "tags": ["brc-103", "schema", "pubkey-format"] }, { "id": "auth.brc31-handshake.16", - "description": "Response signing failure — 500", + "description": "Response signing failure \u2014 500", "input": { "method": "GET", "path": "/api/resource", @@ -410,7 +425,7 @@ "code": "ERR_RESPONSE_SIGNING_FAILED" } }, - "tags": ["brc-31", "phase-2", "error", "signing-failure"] + "tags": ["brc-103", "phase-2", "error", "signing-failure"] } ] } diff --git a/conformance/vectors/messaging/authsocket.json b/conformance/vectors/messaging/authsocket.json index 19d86082d..558d6f199 100644 --- a/conformance/vectors/messaging/authsocket.json +++ b/conformance/vectors/messaging/authsocket.json @@ -2,8 +2,8 @@ "$schema": "../../../schema/vector.schema.json", "id": "messaging.authsocket", "name": "AuthSocket WebSocket Protocol", - "brc": ["BRC-31"], - "version": "1.0.0", + "brc": ["BRC-103"], + "version": "1.1.0", "reference_impl": "packages/messaging/authsocket", "parity_class": "required", "vectors": [ @@ -13,13 +13,14 @@ "input": { "socketio_event": "authMessage", "payload": { - "messageType": "initialRequest", "version": "0.1", - "identityKey": "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1", - "nonce": "dGVzdE5vbmNlMTIzNA==", - "initialNonce": "dGVzdE5vbmNlMTIzNA==", - "payload": [], - "signature": [] + "messageType": "initialRequest", + "identityKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "initialNonce": "Z9+3SZ9qiJvtpQN1RNmmGue247vPuQLeqfIV2eFIMtvRRqFb0FpYckzLbawd0bdR", + "requestedCertificates": { + "certifiers": [], + "types": {} + } } }, "expected": { @@ -28,9 +29,10 @@ "messageType": "initialResponse", "version": "0.1", "identityKey": "string", - "nonce": "string", "yourNonce": "string", - "signature": "array" + "signature": "array", + "initialNonce": "string", + "requestedCertificates": "object" } }, "tags": ["authsocket", "brc-103", "handshake", "initial-request"] @@ -42,7 +44,9 @@ "_schema_check": true, "envelope": { "eventName": "message", - "data": { "body": "hello world" } + "data": { + "body": "hello world" + } } }, "expected": { @@ -93,7 +97,10 @@ "identityKey": "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1", "nonce": "bmV3Tm9uY2U=", "yourNonce": "c2VydmVyTm9uY2U=", - "payload": [123, 34, 101, 118, 101, 110, 116, 78, 97, 109, 101, 34, 58, 34, 116, 101, 115, 116, 34, 125], + "payload": [ + 123, 34, 101, 118, 101, 110, 116, 78, 97, 109, 101, 34, 58, 34, 116, 101, 115, 116, 34, + 125 + ], "signature": [48, 69, 2, 33, 0] } }, @@ -172,7 +179,10 @@ "_note": "SocketServerTransport decodes the BRC-103 general message payload to extract the EventEnvelope" }, "expected": { - "application_sees": { "eventName": "string", "data": "any" }, + "application_sees": { + "eventName": "string", + "data": "any" + }, "transport_handles": "BRC-103 signing/verification" }, "tags": ["authsocket", "schema", "transport-abstraction"] diff --git a/docs/packages/messaging/message-box-client.md b/docs/packages/messaging/message-box-client.md index 4c1a092e3..21c897f55 100644 --- a/docs/packages/messaging/message-box-client.md +++ b/docs/packages/messaging/message-box-client.md @@ -3,7 +3,7 @@ id: pkg-message-box-client title: '@bsv/message-box-client' kind: package domain: messaging -version: '2.5.0' +version: '2.5.1' source_repo: 'bsv-blockchain/ts-stack' last_updated: '2026-09-08' last_verified: '2026-09-08' @@ -98,3 +98,26 @@ Vite and esbuild, and bundle budgets. Source and tests are not published. - [Peer-to-peer messaging guide](../../guides/peer-to-peer-messaging.md) - [Message Box Server](../../infrastructure/message-box-server.md) - [npm](https://www.npmjs.com/package/@bsv/message-box-client) + +## Payment acceptance and acknowledgment ordering + +`acknowledgeNotification` checks the original notification envelope, internalizes +a present recipient payment with the configured originator, and acknowledges +only after the wallet returns `accepted: true`. A notification without a +payment can be acknowledged immediately. Failures, incomplete envelopes and payments with no wallet-payment outputs +remain queued. Its boolean return contract is unchanged: `false` can +mean no payment or a retained failed payment. + +`acceptPayment` also requires affirmative wallet acceptance before acknowledgment. +For refundable amounts, `rejectPayment` internalizes first, sends the refund, +and then acknowledges. A failed internalization prevents both refund and +acknowledgment; a failed refund send leaves the message queued. The existing +small-payment policy is unchanged. + +This is the initial ordering remediation for [issue #503](https://github.com/bsv-blockchain/ts-stack/issues/503). +It does not provide a durable refund journal or exactly-once delivery. Reconcile +uncertain refund-send outcomes before retrying, since a send may have completed +before its response was lost. Payment-envelope retention and outcome reporting +through `listMessages`/`listMessagesLite`, basket-insertion policy, and resumable +refund semantics remain open. Use the original envelope for notification payment +processing; a plain acknowledgment is not evidence that a payment was accepted. diff --git a/docs/packages/sdk/bsv-sdk.md b/docs/packages/sdk/bsv-sdk.md index 06e370ddf..594226aa3 100644 --- a/docs/packages/sdk/bsv-sdk.md +++ b/docs/packages/sdk/bsv-sdk.md @@ -3,7 +3,7 @@ id: bsv-sdk title: '@bsv/sdk' kind: package domain: sdk -version: '2.6.0' +version: '2.7.0' npm: '@bsv/sdk' last_updated: '2026-09-10' last_verified: '2026-09-10' @@ -289,3 +289,32 @@ const output = { lockingScript: lockingScript.toHex(), satoshis: 1 } - [API reference (TypeDoc)](https://bsv-blockchain.github.io/ts-stack/api/sdk/) - [Source on GitHub](https://github.com/bsv-blockchain/ts-stack) - [npm](https://www.npmjs.com/package/@bsv/sdk) + +## Certificate policy and observer callbacks + +SDK 2.7 records the locally requested certificate policy for each BRC-103 +session. Standalone responses must match one complete outstanding dynamic +request or the session's handshake policy. Policies are copied before sending, +so a later edit of the caller's object does not change validation. Responses +never select their own validation policy. A different dynamic request cannot +satisfy an unmet handshake requirement. + +The v0.1 AuthMessage fields, signatures and encodings are unchanged. Because a +certificateResponse does not echo the request nonce, concurrent responses are +matched against complete local requested sets, not individual request IDs. A +successful dynamic response consumes one matching request; failed validation +keeps it available for retry. Request the intended set explicitly instead of +relying on unrequested certificates. + +`listenForCertificatesReceived` is an observer. The SDK commits certificate +validation and releases its waiters before invoking listeners. A throwing or +rejecting listener stops later listeners and rejects message handling, but does +not roll back validation. Apply the requested certificate policy and explicit +application authorization before performing protected work. + +Custom `AsyncSessionManager` implementations must retain the complete +`PeerSession`, including the optional local `certificatePolicy` and +`pendingCertificateRequests` fields. They are never serialized into AuthMessage. +Peer serializes its own certificate read-modify-write operations; shared stores +must also coordinate writers across instances. Older stored sessions without +these fields use the configured handshake policy. diff --git a/docs/reference/package-api-migrations.md b/docs/reference/package-api-migrations.md index 3e5d450fc..08c6fbd6a 100644 --- a/docs/reference/package-api-migrations.md +++ b/docs/reference/package-api-migrations.md @@ -41,14 +41,14 @@ and clean-consumer tests remain the executable type authority. | `@bsv/fund-wallet` | `1.4.4` | `1.5.1` | minor | [API and usage](../packages/helpers/fund-wallet.md) | Replace --private-key/--privateKey with the interactive secret prompt. Automation must provide exactly one private-key line on standard input from a protected file descriptor; funding APIs and transaction behavior are otherwise unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/gasp` | `1.3.1` | `1.3.6` | patch | [API and usage](../packages/overlays/gasp.md) | No consumer migration is required; existing constructor calls, imports, synchronization behavior, and wire semantics are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/lch` | `0.0.0` | `0.1.0` | minor | [API and usage](../packages/content/lch.md) | No consumer migration is required; this is the first release of a new additive package. Applications must give quote() the verified Asset encryption descriptor and selected Offer key-delivery mechanism, keep createPayment behind explicit wallet authorization, persist the funded transaction and every partial settlement proof through recovery, retry with that same transaction, distinguish finalized from accepted evidence, fail closed on unknown settlement or evidence profiles, and supply an address-pinning connector for DNS endpoints. Payees should select receipt-complete unless they explicitly accept authorized-output provider, privacy, and pre-internalization key-release tradeoffs. Distributors must retain THIRD_PARTY_NOTICES.md with the package; the new profile adds no dependency. Published BRC-170 remains authoritative if the implementation and standard differ. | -| `@bsv/message-box-client` | `2.4.0` | `2.5.0` | minor | [API and usage](../packages/messaging/message-box-client.md) | No API migration is required. socketOptions is optional and nothing is forwarded by default, so socket transport negotiation and every HTTP code path are unchanged for existing callers; wallet and originator stay client-owned and cannot be overridden through it. managerOptions excludes autoConnect and retries: JavaScript callers receive an immediate error for autoConnect: false or nonzero message retries because the live socket starts immediately and AuthSocket does not send Socket.IO acknowledgements. Connection reconnection options remain supported. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. No migration is required for the bundled payment optimization. Module consumers can use SDK 2.5.0 or later to enable the same optional extension; existing compatible SDK peer versions remain supported. Recipients must advertise already-validated transaction IDs through x-bsv-payment-known-txids, an optional SDK extension rather than a standardized BRC-105 header; services that omit it retain existing payment behavior. | +| `@bsv/message-box-client` | `2.4.0` | `2.5.1` | minor | [API and usage](../packages/messaging/message-box-client.md) | No API migration is required. socketOptions is optional and nothing is forwarded by default, so socket transport negotiation and every HTTP code path are unchanged for existing callers; wallet and originator stay client-owned and cannot be overridden through it. managerOptions excludes autoConnect and retries: JavaScript callers receive an immediate error for autoConnect: false or nonzero message retries because the live socket starts immediately and AuthSocket does not send Socket.IO acknowledgements. Connection reconnection options remain supported. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. No migration is required for the bundled payment optimization. Module consumers can use SDK 2.5.0 or later to enable the same optional extension; existing compatible SDK peer versions remain supported. Recipients must advertise already-validated transaction IDs through x-bsv-payment-known-txids, an optional SDK extension rather than a standardized BRC-105 header; services that omit it retain existing payment behavior. Existing return shapes and payment envelopes remain unchanged. Failed or incomplete notification payments stay queued. Resolve uncertain refund-send outcomes before retrying; this patch adds ordering checks, not an exactly-once refund journal. listMessages/listMessagesLite envelope behavior and basket-insertion semantics remain tracked in issue #503. | | `@bsv/overlay` | `2.2.1` | `2.3.1` | minor | [API and usage](../packages/overlays/overlay.md) | Existing Engine and TopicManager implementations remain valid. Lookup results default to 1,000 formulas; pass -1 only when an equivalent deployment bound exists. Topic managers whose validation creates provisional external state should implement abortAdmissibleOutputs, while read-only managers require no change. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/overlay-discovery-services` | `2.1.1` | `2.2.1` | minor | [API and usage](../packages/overlays/overlay-discovery-services.md) | Existing mainnet and testnet advertisers are unchanged. TTN operators pass chain ttn and provision the staging storage and overlay endpoints before advertising. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/overlay-express` | `2.5.0` | `2.6.1` | minor | [API and usage](../packages/overlays/overlay-express.md) | Existing mainnet and testnet servers are unchanged. TTN servers call configureNetwork('ttn'), configureArcade with the TTN endpoint, and configureChaintracks or configureChainTracker before engine initialization. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/overlay-topics` | `1.6.10` | `1.7.2` | minor | [API and usage](../packages/overlays/overlay-topics.md) | Existing topic and lookup identifiers remain unchanged. Production UMP overlays must give UMPTopicManager and the UMP lookup service Mongo-backed stores that use the same database, then roll out before updated wallet clients; the no-argument manager is bounded but intended only for isolated single-process use. The reservation and bootstrap-marker collections are additive and initialize from currently indexed UMP UTXOs; take a MongoDB backup before rollout. Legacy ambiguous rows remain visible and can be resolved with WAB pinning rather than deleted. Valid uora-anchor-v3 outputs retain their bytes, signatures, and admission result. Readers now reject nonconforming key, tail, and text shapes. Coordinate reader upgrades across nodes serving tm_uora_dpp and audit any previously indexed nonconforming outputs before rebuilding that topic. No wire prefix, export, or persisted schema changes are required. | | `@bsv/paymail` | `2.4.2` | `2.4.8` | patch | [API and usage](../packages/messaging/paymail.md) | Existing Paymail client APIs and protocol semantics are retained. Consumers provide one Express 4.18 or 5 runtime and matching type graph; browser bundles continue to exclude the server router implementation. Consumers of the former bundled Money Button or Tokenized specification documents must follow the authoritative links in docs/specs/README.md. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/payment-express-middleware` | `2.1.1` | `2.1.6` | patch | [API and usage](../packages/middleware/payment-express-middleware.md) | No consumer migration is required; legacy x-bsv-payment JSON behavior remains supported, and Express 4 and 5 applications use their own peer-provided Express installation. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | -| `@bsv/sdk` | `2.5.0` | `2.6.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | No API migration is required. Historical number-array fast paths and React Native behavior remain compatible. Documentation users should load docs/swagger/swagger.yaml into their preferred viewer instead of using the removed static Swagger UI scaffold. Distributors must keep THIRD_PARTY_NOTICES.md and LICENSES/ with source and browser bundles. The optional x-bsv-payment-known-txids response header requires no consumer migration: absent or invalid-only values preserve existing payment creation. Recipients may advertise only transaction IDs they already possess and have validated, as comma-separated 64-character hexadecimal values; AuthFetch forwards at most 256 unique lowercase IDs through createAction options. Browser services must expose the optional response header through their existing CORS policy to enable this optimization. Invalid controller-signed overlay values and invalid identity certificates now fail closed instead of being returned or published; valid data and public method signatures are unchanged. | +| `@bsv/sdk` | `2.5.0` | `2.7.0` | minor | [API and usage](../packages/sdk/bsv-sdk.md) | No API migration is required. Historical number-array fast paths and React Native behavior remain compatible. Documentation users should load docs/swagger/swagger.yaml into their preferred viewer instead of using the removed static Swagger UI scaffold. Distributors must keep THIRD_PARTY_NOTICES.md and LICENSES/ with source and browser bundles. The optional x-bsv-payment-known-txids response header requires no consumer migration: absent or invalid-only values preserve existing payment creation. Recipients may advertise only transaction IDs they already possess and have validated, as comma-separated 64-character hexadecimal values; AuthFetch forwards at most 256 unique lowercase IDs through createAction options. Browser services must expose the optional response header through their existing CORS policy to enable this optimization. Invalid controller-signed overlay values and invalid identity certificates now fail closed instead of being returned or published; valid data and public method signatures are unchanged. Certificate responses must match a locally requested set; applications relying on unsolicited or unrequested certificates must request their intended set explicitly. Custom session stores must retain certificatePolicy and pendingCertificateRequests and coordinate concurrent writers. Existing sessions fall back to the configured handshake policy. Certificate callbacks remain post-validation observers, not veto hooks. BRC-29 wallets must return accepted: true before settlement is accepted; wire encodings and existing error identities remain unchanged. | | `@bsv/simple` | `0.4.1` | `0.5.3` | minor | [API and usage](../packages/helpers/simple.md) | Existing overlay configurations and number-array behavior are unchanged. TTN consumers select network teratestnet; all consumers should upgrade to @bsv/sdk 2.4.2 or later. The Message Box Client dependency advance requires no helper API migration and preserves the default HTTP and live-socket behavior. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/. | | `@bsv/templates` | `1.9.1` | `1.10.1` | minor | [API and usage](../packages/helpers/templates.md) | No existing consumer migration is required; existing template APIs and generated scripts are unchanged. New R1K1Wallet consumers await lock(), retain each private 32-byte salt, and provide a PIV signer that signs the supplied digest directly without hashing it again. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | | `@bsv/teranode-listener` | `1.1.1` | `1.1.5` | patch | [API and usage](../packages/network/teranode-listener.md) | No consumer migration is required; listener APIs, topics, and network configuration are unchanged. Distributors must retain THIRD_PARTY_NOTICES.md and LICENSES/ with the package. | @@ -265,8 +265,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - Package documentation: [docs/packages/messaging/message-box-client.md](../packages/messaging/message-box-client.md) - Source: [packages/messaging/message-box-client](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/messaging/message-box-client) -- Release note: Adds an optional socketOptions client option that forwards the AuthSocketClient options other than wallet and originator, letting callers select Socket.IO transports such as websocket-only against deployments that do not carry Engine.IO HTTP polling, and reach certificate requests, session management, auth-message concurrency, and error reporting. Also preserves BRC-29 payments, paid messages, remittances, and peer tokens across binary Wallet Wire results and historical numeric-key JSON payloads, rejects sparse or invalid byte records, and ships the complete SDK incorporated-material notice archive with a retained UMD notice banner. Standardizes first-party author metadata on the current BSV Association name. Rebuilds the UMD bundle with SDK 2.5.0 support for optional recipient-declared known payment ancestors, allowing compatible wallets to omit those ancestors from payment BEEF. -- Migration: No API migration is required. socketOptions is optional and nothing is forwarded by default, so socket transport negotiation and every HTTP code path are unchanged for existing callers; wallet and originator stay client-owned and cannot be overridden through it. managerOptions excludes autoConnect and retries: JavaScript callers receive an immediate error for autoConnect: false or nonzero message retries because the live socket starts immediately and AuthSocket does not send Socket.IO acknowledgements. Connection reconnection options remain supported. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. No migration is required for the bundled payment optimization. Module consumers can use SDK 2.5.0 or later to enable the same optional extension; existing compatible SDK peer versions remain supported. Recipients must advertise already-validated transaction IDs through x-bsv-payment-known-txids, an optional SDK extension rather than a standardized BRC-105 header; services that omit it retain existing payment behavior. +- Release note: Adds an optional socketOptions client option that forwards the AuthSocketClient options other than wallet and originator, letting callers select Socket.IO transports such as websocket-only against deployments that do not carry Engine.IO HTTP polling, and reach certificate requests, session management, auth-message concurrency, and error reporting. Also preserves BRC-29 payments, paid messages, remittances, and peer tokens across binary Wallet Wire results and historical numeric-key JSON payloads, rejects sparse or invalid byte records, and ships the complete SDK incorporated-material notice archive with a retained UMD notice banner. Standardizes first-party author metadata on the current BSV Association name. Rebuilds the UMD bundle with SDK 2.5.0 support for optional recipient-declared known payment ancestors, allowing compatible wallets to omit those ancestors from payment BEEF. Internalizes notification payments before acknowledgment, passes the configured originator, requires affirmative wallet acceptance, and orders refundable PeerPay processing as internalize, refund send, then acknowledgment. +- Migration: No API migration is required. socketOptions is optional and nothing is forwarded by default, so socket transport negotiation and every HTTP code path are unchanged for existing callers; wallet and originator stay client-owned and cannot be overridden through it. managerOptions excludes autoConnect and retries: JavaScript callers receive an immediate error for autoConnect: false or nonzero message retries because the live socket starts immediately and AuthSocket does not send Socket.IO acknowledgements. Connection reconnection options remain supported. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. No migration is required for the bundled payment optimization. Module consumers can use SDK 2.5.0 or later to enable the same optional extension; existing compatible SDK peer versions remain supported. Recipients must advertise already-validated transaction IDs through x-bsv-payment-known-txids, an optional SDK extension rather than a standardized BRC-105 header; services that omit it retain existing payment behavior. Existing return shapes and payment envelopes remain unchanged. Failed or incomplete notification payments stay queued. Resolve uncertain refund-send outcomes before retrying; this patch adds ordering checks, not an exactly-once refund journal. listMessages/listMessagesLite envelope behavior and basket-insertion semantics remain tracked in issue #503. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------- | ----------------------------------- | --------------------------------------- | @@ -359,8 +359,8 @@ CLI entry points: `{"lch":"./dist/cli.js"}`. - 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: Uses asynchronous platform SHA-256 for large ProtoWallet signature payloads with an input-snapshot-preserving portable fallback, retaining signature and verification compatibility. Prevents late certificate dispatch and session recovery after an AuthFetch deadline while preserving gateway errors without write replay. Adds one strict BRC-100 byte-boundary contract and secure React Native bridge validation, and completes incorporated-code compliance with hash-pinned provenance, exact MIT/ISC/BSD/Apache notices, source lineage markers, package payloads, a retained UMD banner, SBOM components, and release enforcement. Removes a broken copied Swagger UI scaffold and screenshot; the first-party OpenAPI source remains available for use with any viewer. Standardizes first-party author metadata on the current BSV Association name. Adds an optional SDK extension for recipient-declared known transaction IDs to AuthFetch payment creation and repricing so compatible wallets can omit already-known ancestors from payment BEEF. Rejects GlobalKVStore overlay values when controller-signature verification returns false and refuses to publish identity certificates whose certifier signature is invalid. -- Migration: No API migration is required. Historical number-array fast paths and React Native behavior remain compatible. Documentation users should load docs/swagger/swagger.yaml into their preferred viewer instead of using the removed static Swagger UI scaffold. Distributors must keep THIRD_PARTY_NOTICES.md and LICENSES/ with source and browser bundles. The optional x-bsv-payment-known-txids response header requires no consumer migration: absent or invalid-only values preserve existing payment creation. Recipients may advertise only transaction IDs they already possess and have validated, as comma-separated 64-character hexadecimal values; AuthFetch forwards at most 256 unique lowercase IDs through createAction options. Browser services must expose the optional response header through their existing CORS policy to enable this optimization. Invalid controller-signed overlay values and invalid identity certificates now fail closed instead of being returned or published; valid data and public method signatures are unchanged. +- Release note: Uses asynchronous platform SHA-256 for large ProtoWallet signature payloads with an input-snapshot-preserving portable fallback, retaining signature and verification compatibility. Prevents late certificate dispatch and session recovery after an AuthFetch deadline while preserving gateway errors without write replay. Adds one strict BRC-100 byte-boundary contract and secure React Native bridge validation, and completes incorporated-code compliance with hash-pinned provenance, exact MIT/ISC/BSD/Apache notices, source lineage markers, package payloads, a retained UMD banner, SBOM components, and release enforcement. Removes a broken copied Swagger UI scaffold and screenshot; the first-party OpenAPI source remains available for use with any viewer. Standardizes first-party author metadata on the current BSV Association name. Adds an optional SDK extension for recipient-declared known transaction IDs to AuthFetch payment creation and repricing so compatible wallets can omit already-known ancestors from payment BEEF. Rejects GlobalKVStore overlay values when controller-signature verification returns false and refuses to publish identity certificates whose certifier signature is invalid. Records local certificate policies per auth session, enforces dynamic certificate requests without changing the v0.1 wire envelope, documents certificate observers, and requires affirmative wallet acceptance for BRC-29 settlement. +- Migration: No API migration is required. Historical number-array fast paths and React Native behavior remain compatible. Documentation users should load docs/swagger/swagger.yaml into their preferred viewer instead of using the removed static Swagger UI scaffold. Distributors must keep THIRD_PARTY_NOTICES.md and LICENSES/ with source and browser bundles. The optional x-bsv-payment-known-txids response header requires no consumer migration: absent or invalid-only values preserve existing payment creation. Recipients may advertise only transaction IDs they already possess and have validated, as comma-separated 64-character hexadecimal values; AuthFetch forwards at most 256 unique lowercase IDs through createAction options. Browser services must expose the optional response header through their existing CORS policy to enable this optimization. Invalid controller-signed overlay values and invalid identity certificates now fail closed instead of being returned or published; valid data and public method signatures are unchanged. Certificate responses must match a locally requested set; applications relying on unsolicited or unrequested certificates must request their intended set explicitly. Custom session stores must retain certificatePolicy and pendingCertificateRequests and coordinate concurrent writers. Existing sessions fall back to the configured handshake policy. Certificate callbacks remain post-validation observers, not veto hooks. BRC-29 wallets must return accepted: true before settlement is accepted; wire encodings and existing error identities remain unchanged. | Public subpath | Runtime target(s) | Declaration target(s) | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | diff --git a/docs/reference/service-operations.md b/docs/reference/service-operations.md index 60e4bd684..6c4f10f95 100644 --- a/docs/reference/service-operations.md +++ b/docs/reference/service-operations.md @@ -42,13 +42,13 @@ critical-journey monitoring. ## Observability contract -Each standalone service retains a self-contained bootstrap because infrastructure build contexts cannot safely depend on an unpublished shared runtime package. CI enforces one behavioral and dependency contract across those bootstraps. +Each standalone service retains a self-contained bootstrap because infrastructure build contexts cannot safely depend on an unpublished shared runtime package. CI enforces one behavioral and dependency contract across those bootstraps. Without a collector endpoint, telemetry is disabled by default; OTEL_CONSOLE_EXPORTERS=true explicitly enables local diagnostics. Application logging remains active, and console calls are bridged only in OTLP mode. -Every service preloads telemetry before application imports and emits +Every service preloads telemetry before application imports and supports traces, metrics, logs, runtime-metrics. Structured logs use `service`, `env`, `operation`, `outcome`, `duration_ms`, `err` and correlate through `trace_id`, `span_id`. Every environment -example documents `DEPLOY_ENV`, `LOG_LEVEL`, `OTEL_DIAG`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`, `OTEL_METRIC_EXPORT_INTERVAL`, `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_SERVICE_NAME`; +example documents `DEPLOY_ENV`, `LOG_LEVEL`, `OTEL_CONSOLE_EXPORTERS`, `OTEL_DIAG`, `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`, `OTEL_METRIC_EXPORT_INTERVAL`, `OTEL_RESOURCE_ATTRIBUTES`, `OTEL_SERVICE_NAME`; `OTEL_EXPORTER_OTLP_HEADERS` is secret-bearing. | Dependency | Aligned direct range | diff --git a/docs/reference/stack-facts.md b/docs/reference/stack-facts.md index e4a174597..7c9837e34 100644 --- a/docs/reference/stack-facts.md +++ b/docs/reference/stack-facts.md @@ -49,7 +49,7 @@ authorized release action. | helpers | `create-bsv-app` | `1.1.1` | 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.7` | 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.6` | 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.5.0` | 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/message-box-client` | `2.5.1` | 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.8` | 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.3.1` | 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.4` | node-library | node-cjs, node-esm | node | `>=22` | [packages/middleware/auth](https://github.com/bsv-blockchain/ts-stack/tree/main/packages/middleware/auth) | @@ -62,7 +62,7 @@ authorized release action. | overlays | `@bsv/overlay-discovery-services` | `2.2.1` | 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.6.1` | 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.7.2` | 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.6.0` | 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.7.0` | 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.5` | 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.2.2` | 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.2.0` | 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/dependency-release-policy.json b/governance/dependency-release-policy.json index 518e0bc56..11f8a279d 100644 --- a/governance/dependency-release-policy.json +++ b/governance/dependency-release-policy.json @@ -185,7 +185,7 @@ "source": "tools/codegen/node/package.json", "selector": "@redocly/openapi-core@1.34.17", "value": { - "js-yaml": "4.3.1", + "js-yaml": "4.3.2", "minimatch": "10.2.5" }, "exceptionId": "openapi-typescript-redocly-overrides" diff --git a/governance/package-release-notes.json b/governance/package-release-notes.json index a0ca7bbf1..9a9438e42 100644 --- a/governance/package-release-notes.json +++ b/governance/package-release-notes.json @@ -119,8 +119,8 @@ "name": "@bsv/message-box-client", "publishedVersion": "2.4.0", "releaseType": "minor", - "summary": "Adds an optional socketOptions client option that forwards the AuthSocketClient options other than wallet and originator, letting callers select Socket.IO transports such as websocket-only against deployments that do not carry Engine.IO HTTP polling, and reach certificate requests, session management, auth-message concurrency, and error reporting. Also preserves BRC-29 payments, paid messages, remittances, and peer tokens across binary Wallet Wire results and historical numeric-key JSON payloads, rejects sparse or invalid byte records, and ships the complete SDK incorporated-material notice archive with a retained UMD notice banner. Standardizes first-party author metadata on the current BSV Association name. Rebuilds the UMD bundle with SDK 2.5.0 support for optional recipient-declared known payment ancestors, allowing compatible wallets to omit those ancestors from payment BEEF.", - "migration": "No API migration is required. socketOptions is optional and nothing is forwarded by default, so socket transport negotiation and every HTTP code path are unchanged for existing callers; wallet and originator stay client-owned and cannot be overridden through it. managerOptions excludes autoConnect and retries: JavaScript callers receive an immediate error for autoConnect: false or nonzero message retries because the live socket starts immediately and AuthSocket does not send Socket.IO acknowledgements. Connection reconnection options remain supported. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. No migration is required for the bundled payment optimization. Module consumers can use SDK 2.5.0 or later to enable the same optional extension; existing compatible SDK peer versions remain supported. Recipients must advertise already-validated transaction IDs through x-bsv-payment-known-txids, an optional SDK extension rather than a standardized BRC-105 header; services that omit it retain existing payment behavior." + "summary": "Adds an optional socketOptions client option that forwards the AuthSocketClient options other than wallet and originator, letting callers select Socket.IO transports such as websocket-only against deployments that do not carry Engine.IO HTTP polling, and reach certificate requests, session management, auth-message concurrency, and error reporting. Also preserves BRC-29 payments, paid messages, remittances, and peer tokens across binary Wallet Wire results and historical numeric-key JSON payloads, rejects sparse or invalid byte records, and ships the complete SDK incorporated-material notice archive with a retained UMD notice banner. Standardizes first-party author metadata on the current BSV Association name. Rebuilds the UMD bundle with SDK 2.5.0 support for optional recipient-declared known payment ancestors, allowing compatible wallets to omit those ancestors from payment BEEF. Internalizes notification payments before acknowledgment, passes the configured originator, requires affirmative wallet acceptance, and orders refundable PeerPay processing as internalize, refund send, then acknowledgment.", + "migration": "No API migration is required. socketOptions is optional and nothing is forwarded by default, so socket transport negotiation and every HTTP code path are unchanged for existing callers; wallet and originator stay client-owned and cannot be overridden through it. managerOptions excludes autoConnect and retries: JavaScript callers receive an immediate error for autoConnect: false or nonzero message retries because the live socket starts immediately and AuthSocket does not send Socket.IO acknowledgements. Connection reconnection options remain supported. Upgrade @bsv/sdk and @bsv/message-box-client together; historical number-array wallets, current Uint8Array substrates, and already-pending numeric-key messages interoperate through the same portable transaction form. Distributors who copy the UMD file must keep THIRD_PARTY_NOTICES.md and LICENSES/ with it. No migration is required for the bundled payment optimization. Module consumers can use SDK 2.5.0 or later to enable the same optional extension; existing compatible SDK peer versions remain supported. Recipients must advertise already-validated transaction IDs through x-bsv-payment-known-txids, an optional SDK extension rather than a standardized BRC-105 header; services that omit it retain existing payment behavior. Existing return shapes and payment envelopes remain unchanged. Failed or incomplete notification payments stay queued. Resolve uncertain refund-send outcomes before retrying; this patch adds ordering checks, not an exactly-once refund journal. listMessages/listMessagesLite envelope behavior and basket-insertion semantics remain tracked in issue #503." }, { "name": "@bsv/overlay", @@ -168,8 +168,8 @@ "name": "@bsv/sdk", "publishedVersion": "2.5.0", "releaseType": "minor", - "summary": "Uses asynchronous platform SHA-256 for large ProtoWallet signature payloads with an input-snapshot-preserving portable fallback, retaining signature and verification compatibility. Prevents late certificate dispatch and session recovery after an AuthFetch deadline while preserving gateway errors without write replay. Adds one strict BRC-100 byte-boundary contract and secure React Native bridge validation, and completes incorporated-code compliance with hash-pinned provenance, exact MIT/ISC/BSD/Apache notices, source lineage markers, package payloads, a retained UMD banner, SBOM components, and release enforcement. Removes a broken copied Swagger UI scaffold and screenshot; the first-party OpenAPI source remains available for use with any viewer. Standardizes first-party author metadata on the current BSV Association name. Adds an optional SDK extension for recipient-declared known transaction IDs to AuthFetch payment creation and repricing so compatible wallets can omit already-known ancestors from payment BEEF. Rejects GlobalKVStore overlay values when controller-signature verification returns false and refuses to publish identity certificates whose certifier signature is invalid.", - "migration": "No API migration is required. Historical number-array fast paths and React Native behavior remain compatible. Documentation users should load docs/swagger/swagger.yaml into their preferred viewer instead of using the removed static Swagger UI scaffold. Distributors must keep THIRD_PARTY_NOTICES.md and LICENSES/ with source and browser bundles. The optional x-bsv-payment-known-txids response header requires no consumer migration: absent or invalid-only values preserve existing payment creation. Recipients may advertise only transaction IDs they already possess and have validated, as comma-separated 64-character hexadecimal values; AuthFetch forwards at most 256 unique lowercase IDs through createAction options. Browser services must expose the optional response header through their existing CORS policy to enable this optimization. Invalid controller-signed overlay values and invalid identity certificates now fail closed instead of being returned or published; valid data and public method signatures are unchanged." + "summary": "Uses asynchronous platform SHA-256 for large ProtoWallet signature payloads with an input-snapshot-preserving portable fallback, retaining signature and verification compatibility. Prevents late certificate dispatch and session recovery after an AuthFetch deadline while preserving gateway errors without write replay. Adds one strict BRC-100 byte-boundary contract and secure React Native bridge validation, and completes incorporated-code compliance with hash-pinned provenance, exact MIT/ISC/BSD/Apache notices, source lineage markers, package payloads, a retained UMD banner, SBOM components, and release enforcement. Removes a broken copied Swagger UI scaffold and screenshot; the first-party OpenAPI source remains available for use with any viewer. Standardizes first-party author metadata on the current BSV Association name. Adds an optional SDK extension for recipient-declared known transaction IDs to AuthFetch payment creation and repricing so compatible wallets can omit already-known ancestors from payment BEEF. Rejects GlobalKVStore overlay values when controller-signature verification returns false and refuses to publish identity certificates whose certifier signature is invalid. Records local certificate policies per auth session, enforces dynamic certificate requests without changing the v0.1 wire envelope, documents certificate observers, and requires affirmative wallet acceptance for BRC-29 settlement.", + "migration": "No API migration is required. Historical number-array fast paths and React Native behavior remain compatible. Documentation users should load docs/swagger/swagger.yaml into their preferred viewer instead of using the removed static Swagger UI scaffold. Distributors must keep THIRD_PARTY_NOTICES.md and LICENSES/ with source and browser bundles. The optional x-bsv-payment-known-txids response header requires no consumer migration: absent or invalid-only values preserve existing payment creation. Recipients may advertise only transaction IDs they already possess and have validated, as comma-separated 64-character hexadecimal values; AuthFetch forwards at most 256 unique lowercase IDs through createAction options. Browser services must expose the optional response header through their existing CORS policy to enable this optimization. Invalid controller-signed overlay values and invalid identity certificates now fail closed instead of being returned or published; valid data and public method signatures are unchanged. Certificate responses must match a locally requested set; applications relying on unsolicited or unrequested certificates must request their intended set explicitly. Custom session stores must retain certificatePolicy and pendingCertificateRequests and coordinate concurrent writers. Existing sessions fall back to the configured handshake policy. Certificate callbacks remain post-validation observers, not veto hooks. BRC-29 wallets must return accepted: true before settlement is accepted; wire encodings and existing error identities remain unchanged." }, { "name": "@bsv/simple", diff --git a/governance/repository-health/baselines.json b/governance/repository-health/baselines.json index 0664c781a..a55c3881a 100644 --- a/governance/repository-health/baselines.json +++ b/governance/repository-health/baselines.json @@ -310,7 +310,7 @@ "@bsv/templates": "1.10.1", "@bsv/authsocket": "2.1.7", "@bsv/authsocket-client": "2.1.6", - "@bsv/message-box-client": "2.5.0", + "@bsv/message-box-client": "2.5.1", "@bsv/paymail": "2.4.8", "@bsv/402-pay": "0.3.1", "@bsv/auth": "0.1.4", @@ -322,7 +322,7 @@ "@bsv/overlay-discovery-services": "2.2.1", "@bsv/overlay-express": "2.6.1", "@bsv/overlay-topics": "1.7.2", - "@bsv/sdk": "2.6.0", + "@bsv/sdk": "2.7.0", "@bsv/verifast": "0.3.5", "@bsv/btms": "1.2.2", "@bsv/btms-permission-module": "1.2.0", diff --git a/governance/repository-health/exceptions.json b/governance/repository-health/exceptions.json index 648b341fc..050893d38 100644 --- a/governance/repository-health/exceptions.json +++ b/governance/repository-health/exceptions.json @@ -219,7 +219,8 @@ "https://github.com/advisories/GHSA-52cp-r559-cp3m", "https://github.com/advisories/GHSA-mh99-v99m-4gvg", "https://github.com/advisories/GHSA-rgw5-rvv9-x895", - "https://github.com/openapi-ts/openapi-typescript/blob/openapi-typescript%407.13.0/packages/openapi-typescript/package.json" + "https://github.com/openapi-ts/openapi-typescript/blob/openapi-typescript%407.13.0/packages/openapi-typescript/package.json", + "https://github.com/advisories/GHSA-2883-xcg3-v3hh" ], "created": "2026-07-25", "reviewBy": "2026-09-25", diff --git a/governance/service-operations.json b/governance/service-operations.json index f881483c8..115e4ff55 100644 --- a/governance/service-operations.json +++ b/governance/service-operations.json @@ -11,10 +11,11 @@ "cspIsSeparate": true }, "telemetry": { - "implementation": "Each standalone service retains a self-contained bootstrap because infrastructure build contexts cannot safely depend on an unpublished shared runtime package. CI enforces one behavioral and dependency contract across those bootstraps.", + "implementation": "Each standalone service retains a self-contained bootstrap because infrastructure build contexts cannot safely depend on an unpublished shared runtime package. CI enforces one behavioral and dependency contract across those bootstraps. Without a collector endpoint, telemetry is disabled by default; OTEL_CONSOLE_EXPORTERS=true explicitly enables local diagnostics. Application logging remains active, and console calls are bridged only in OTLP mode.", "environment": [ "DEPLOY_ENV", "LOG_LEVEL", + "OTEL_CONSOLE_EXPORTERS", "OTEL_DIAG", "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_HEADERS", diff --git a/governance/service-runtime-copy-policy.json b/governance/service-runtime-copy-policy.json index ae33b3ee8..83b36eef1 100644 --- a/governance/service-runtime-copy-policy.json +++ b/governance/service-runtime-copy-policy.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "lastReviewed": "2026-08-24", + "lastReviewed": "2026-09-15", "owner": "ts-stack-maintainers", "rationale": "Standalone image build contexts retain a small number of runtime sources that are canonically owned elsewhere. These copies are synchronized byte-for-byte so published packages and official images cannot drift.", "copies": [ @@ -97,6 +97,17 @@ { "canonicalSource": "packages/wallet/wallet-toolbox/src/storage/remoting/KnexPaymentReplayStore.ts", "synchronizedSources": ["infra/wallet-infra/src/KnexPaymentReplayStore.ts"] + }, + { + "canonicalSource": "infra/overlay-server/src/telemetry.ts", + "synchronizedSources": [ + "infra/chaintracks-server/src/telemetry.ts", + "infra/wab/src/telemetry.ts", + "infra/uhrp-server-basic/src/telemetry.ts", + "infra/uhrp-server-cloud-bucket/src/telemetry.ts", + "infra/wallet-infra/src/telemetry.ts", + "infra/message-box-server/src/telemetry.ts" + ] } ] } diff --git a/infra/OBSERVABILITY.md b/infra/OBSERVABILITY.md index 54c991a64..b11b64699 100644 --- a/infra/OBSERVABILITY.md +++ b/infra/OBSERVABILITY.md @@ -1,21 +1,21 @@ # Infra Observability (OpenTelemetry) -Every infra component emits OpenTelemetry **traces, metrics and logs**. Each +Every infra component supports OpenTelemetry **traces, metrics and logs**. Each component has a self-contained bootstrap (`src/telemetry.ts`) that is preloaded before application code so auto-instrumentation can patch modules before they are imported. ## Components -| Component | Module | Preload | -|---|---|---| -| overlay-server | ESM | `node --import ./dist/telemetry.js dist/index.js` | -| chaintracks-server | CJS | `node --require ./dist/telemetry.js dist/server.js` | -| wab | CJS | `node --require ./dist/telemetry.js dist/server.js` | -| uhrp-server-cloud-bucket | CJS | `node --require ./out/src/telemetry.js … out/src/index.js` | -| uhrp-server-basic | CJS | `ts-node -r ./src/telemetry.ts src/index.ts` / `start:prod` | -| wallet-infra | ESM | `node --import ./out/src/telemetry.js out/src/index.js` | -| message-box-server | ESM | `node --import ./out/src/telemetry.js out/src/index.js` | +| Component | Module | Preload | +| ------------------------ | ------ | ----------------------------------------------------------- | +| overlay-server | ESM | `node --import ./dist/telemetry.js dist/index.js` | +| chaintracks-server | CJS | `node --require ./dist/telemetry.js dist/server.js` | +| wab | CJS | `node --require ./dist/telemetry.js dist/server.js` | +| uhrp-server-cloud-bucket | CJS | `node --require ./out/src/telemetry.js … out/src/index.js` | +| uhrp-server-basic | CJS | `ts-node -r ./src/telemetry.ts src/index.ts` / `start:prod` | +| wallet-infra | ESM | `node --import ./out/src/telemetry.js out/src/index.js` | +| message-box-server | ESM | `node --import ./out/src/telemetry.js out/src/index.js` | ESM components (overlay-server, wallet-infra, message-box-server) deliberately do **not** register the `import-in-the-middle` loader hook. That hook rebuilds the @@ -30,16 +30,17 @@ by `require-in-the-middle`, so auto-instrumentation coverage is retained. All wiring is driven by standard `OTEL_*` environment variables. The Dockerfiles and `docker-compose.yml` files pass these through. -| Variable | Purpose | Default | -|---|---|---| -| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP/HTTP collector base URL. **Unset → console exporters** (dev-safe). | — | -| `OTEL_EXPORTER_OTLP_HEADERS` | Comma-separated headers, e.g. auth for Coralogix. | — | -| `OTEL_SERVICE_NAME` | Overrides `service.name` (defaults to the package name). | package name | -| `OTEL_RESOURCE_ATTRIBUTES` | Extra resource attributes. | — | -| `DEPLOY_ENV` / `NODE_ENV` | Becomes `deployment.environment`. | `development` | -| `OTEL_METRIC_EXPORT_INTERVAL` | Metric export interval (ms). | `60000` | -| `OTEL_DIAG` | `true` enables OTel internal diagnostic logging. | off | -| `LOG_LEVEL` | pino log level. | `info` | +| Variable | Purpose | Default | +| ----------------------------- | ------------------------------------------------------------------------------------------------ | ------------- | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP/HTTP collector base URL. **Unset → telemetry off**, application logs stay active. | — | +| `OTEL_CONSOLE_EXPORTERS` | Set exactly `true` to explicitly enable local console telemetry when no collector is configured. | `false` | +| `OTEL_EXPORTER_OTLP_HEADERS` | Comma-separated headers, e.g. auth for Coralogix. | — | +| `OTEL_SERVICE_NAME` | Overrides `service.name` (defaults to the package name). | package name | +| `OTEL_RESOURCE_ATTRIBUTES` | Extra resource attributes. | — | +| `DEPLOY_ENV` / `NODE_ENV` | Becomes `deployment.environment`. | `development` | +| `OTEL_METRIC_EXPORT_INTERVAL` | Metric export interval (ms). | `60000` | +| `OTEL_DIAG` | `true` enables OTel internal diagnostic logging. | off | +| `LOG_LEVEL` | pino log level. | `info` | Point the whole stack at a collector by exporting once, e.g.: @@ -49,8 +50,21 @@ export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer " docker compose up ``` -With the endpoint **unset**, each service prints spans/metrics/logs to the -console — useful for verifying instrumentation locally without a backend. +With the endpoint **unset**, the bootstrap starts no telemetry SDK, auto-instrumentation, +export timers, console bridge or telemetry signal handlers. Application `console.*` +and structured pino logging continue normally. This is the quiet default in both +containers and development; an absent collector never silently enables console exporters. + +For temporary local diagnostics, set `OTEL_CONSOLE_EXPORTERS=true`. An OTLP endpoint +takes precedence when both are configured. Console mode leaves application console +methods untouched so their messages are not duplicated through the OTel Logs API. +Remove the flag after debugging: auto-instrumentation can produce substantial output. + +For production observability, configure an OTLP collector and use its filtering and +sampling controls together with `LOG_LEVEL`. To adopt the new default, configure +the collector explicitly if you relied on implicit console telemetry. Deploy the +rebuilt service images through the normal release process; merging this source +change does not change running containers. ## Signals @@ -62,7 +76,7 @@ console — useful for verifying instrumentation locally without a backend. **memory-leak and event-loop diagnosis**. - **Logs** — structured JSON via **pino** (`src/logger.ts`), with `trace_id` / `span_id` injected by `@opentelemetry/instrumentation-pino` so logs correlate to - traces, shipped over OTLP. Stray `console.*` calls are also bridged to OTel logs + traces, shipped over OTLP. Stray `console.*` calls are bridged to OTel logs only in OTLP mode during the migration to structured logging. ### Structured logging conventions @@ -81,13 +95,13 @@ log.info({ operation: 'listen', outcome: 'ok', port }, 'server listening') Overlay deployments should preserve these `operation` names because they map to operator alerts and dashboards: -| Operation | Emitted by | Notes | -|---|---|---| -| `overlay.provider_callback` | Overlay Express `/arc-ingest` | Provider callback accepted, rejected, or classified as terminal/double-spend. Alert on repeated `outcome=error`. | -| `overlay.unproven_proof_refresh` | `/admin/refreshUnprovenProofs` | Manual or monitor-triggered proof refresh for old unproven rows. | -| `overlay.unproven_eviction` | `/admin/evictUnproven` | Eviction-only cleanup for stale unproven rows. | -| `overlay.unproven_maintenance` | `/admin/maintainUnproven` | Refresh-before-evict maintenance. This is the preferred operational path. | -| `overlay.health` / HTTP health spans | health routes | Use readiness failures and provider context to detect bad deployment wiring. | +| Operation | Emitted by | Notes | +| ------------------------------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| `overlay.provider_callback` | Overlay Express `/arc-ingest` | Provider callback accepted, rejected, or classified as terminal/double-spend. Alert on repeated `outcome=error`. | +| `overlay.unproven_proof_refresh` | `/admin/refreshUnprovenProofs` | Manual or monitor-triggered proof refresh for old unproven rows. | +| `overlay.unproven_eviction` | `/admin/evictUnproven` | Eviction-only cleanup for stale unproven rows. | +| `overlay.unproven_maintenance` | `/admin/maintainUnproven` | Refresh-before-evict maintenance. This is the preferred operational path. | +| `overlay.health` / HTTP health spans | health routes | Use readiness failures and provider context to detect bad deployment wiring. | Alerting should distinguish transient provider failures from terminal provider classification. Terminal double-spend or invalid callbacks are expected to evict @@ -111,3 +125,13 @@ monitor events in storage. `infra/DEPENDENCY_POLICY.md`. See the design spec: `docs/superpowers/specs/2026-06-22-infra-opentelemetry-design.md`. + +## Bootstrap source ownership + +`infra/overlay-server/src/telemetry.ts` is the canonical bootstrap. The other six +standalone service build contexts receive identical copies through +`pnpm sync:service-runtime-copies`; repository health rejects drift. The same +source compiles as ESM or CommonJS and reads package metadata from the service +working directory. Only the six synchronized copies are excluded from Sonar +duplication scoring; code analysis and the shared bootstrap behavior tests remain +active. Edit the canonical source, synchronize, and validate all service builds. diff --git a/infra/chaintracks-server/.env.example b/infra/chaintracks-server/.env.example index 9211f012a..8c731a8dd 100644 --- a/infra/chaintracks-server/.env.example +++ b/infra/chaintracks-server/.env.example @@ -32,9 +32,11 @@ TSTN_CHAINTRACKS_URL= # Logging level LOG_LEVEL=info -# OpenTelemetry. Leave the endpoint unset for local console exporters. Supply +# OpenTelemetry. An unset endpoint disables telemetry by default. Supply # OTLP headers through secret management, never a committed environment file. OTEL_SERVICE_NAME=chaintracks-server +# No collector: telemetry is off. Opt in below for local console diagnostics. +OTEL_CONSOLE_EXPORTERS=false OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_HEADERS= OTEL_RESOURCE_ATTRIBUTES= diff --git a/infra/chaintracks-server/docker-compose.yml b/infra/chaintracks-server/docker-compose.yml index 96c01ec5d..508436d3d 100644 --- a/infra/chaintracks-server/docker-compose.yml +++ b/infra/chaintracks-server/docker-compose.yml @@ -91,6 +91,7 @@ services: # OpenTelemetry — point at any OTLP/HTTP collector. Unset => console exporters. - OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-} + - OTEL_CONSOLE_EXPORTERS=${OTEL_CONSOLE_EXPORTERS:-false} - OTEL_EXPORTER_OTLP_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS:-} - OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-chaintracks-server} - OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-} diff --git a/infra/chaintracks-server/src/telemetry.ts b/infra/chaintracks-server/src/telemetry.ts index b60e0f1e1..a40bc2eba 100644 --- a/infra/chaintracks-server/src/telemetry.ts +++ b/infra/chaintracks-server/src/telemetry.ts @@ -1,14 +1,27 @@ /** - * OpenTelemetry bootstrap (CommonJS) — preloaded before app code via - * `node --require ./dist/telemetry.js`. + * OpenTelemetry bootstrap — preloaded before application code. + * Canonical source: infra/overlay-server/src/telemetry.ts. + * Run pnpm sync:service-runtime-copies after editing; six standalone contexts + * receive byte-for-byte copies governed by service-runtime-copy-policy.json. * * Emits traces, metrics and logs. All wiring is driven by OTEL_* env vars; when - * OTEL_EXPORTER_OTLP_ENDPOINT is unset we fall back to console exporters so the - * process always boots (dev-safe). Runtime (heap/GC/event-loop) metrics are - * enabled to support memory-leak diagnosis. + * OTEL_EXPORTER_OTLP_ENDPOINT is unset telemetry stays off unless + * OTEL_CONSOLE_EXPORTERS=true explicitly enables local console diagnostics. + * Application logging remains available in every mode. + * + * ESM note: we deliberately do NOT register the import-in-the-middle loader + * hook (@opentelemetry/instrumentation/hook.mjs). That hook rebuilds the named + * exports of CJS packages imported as ESM and drops some of them (e.g. + * @bsv/sdk's PushDrop), which crashes the app at import time. The libraries we + * actually instrument (http, express, mongodb, mysql2, pino) are pulled in + * through CJS dependency chains (overlay-express, wallet-toolbox, authsocket) + * and are still patched by require-in-the-middle, so coverage is retained. * * See docs/superpowers/specs/2026-06-22-infra-opentelemetry-design.md. */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + import { NodeSDK } from '@opentelemetry/sdk-node' import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' import { RuntimeNodeInstrumentation } from '@opentelemetry/instrumentation-runtime-node' @@ -16,133 +29,139 @@ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http' import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' import { - ConsoleSpanExporter, - BatchSpanProcessor, - SimpleSpanProcessor, - type SpanExporter, + ConsoleSpanExporter, + BatchSpanProcessor, + SimpleSpanProcessor, + type SpanExporter } from '@opentelemetry/sdk-trace-base' import { - PeriodicExportingMetricReader, - ConsoleMetricExporter, - type PushMetricExporter, + PeriodicExportingMetricReader, + ConsoleMetricExporter, + type PushMetricExporter } from '@opentelemetry/sdk-metrics' import { - BatchLogRecordProcessor, - SimpleLogRecordProcessor, - ConsoleLogRecordExporter, - type LogRecordExporter, + BatchLogRecordProcessor, + SimpleLogRecordProcessor, + ConsoleLogRecordExporter, + type LogRecordExporter } from '@opentelemetry/sdk-logs' import { resourceFromAttributes } from '@opentelemetry/resources' -import { - ATTR_SERVICE_NAME, - ATTR_SERVICE_VERSION, -} from '@opentelemetry/semantic-conventions' +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions' import { logs, SeverityNumber } from '@opentelemetry/api-logs' import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api' -import * as path from 'node:path' // Resolve the component's package.json relative to the working directory (the // app root in every Dockerfile and local run) — robust regardless of build layout. -// eslint-disable-next-line @typescript-eslint/no-var-requires -const pkg = require(path.join(process.cwd(), 'package.json')) as { name: string; version: string } +const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) as { + name: string + version: string +} const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT -const useOtlp = typeof otlpEndpoint === 'string' && otlpEndpoint.length > 0 +const useOtlp = typeof otlpEndpoint === 'string' && otlpEndpoint.trim().length > 0 const env = process.env.DEPLOY_ENV ?? process.env.NODE_ENV ?? 'development' if (process.env.OTEL_DIAG === 'true') { - diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO) + diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO) } -const resource = resourceFromAttributes({ +// No SDK, instrumentation, console bridge or shutdown hooks without an exporter. +if (useOtlp || process.env.OTEL_CONSOLE_EXPORTERS === 'true') { + const resource = resourceFromAttributes({ [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? pkg.name, [ATTR_SERVICE_VERSION]: pkg.version, - 'deployment.environment': env, -}) + 'deployment.environment': env + }) -const traceExporter: SpanExporter = useOtlp - ? new OTLPTraceExporter() - : new ConsoleSpanExporter() -const metricExporter: PushMetricExporter = useOtlp + const traceExporter: SpanExporter = useOtlp ? new OTLPTraceExporter() : new ConsoleSpanExporter() + const metricExporter: PushMetricExporter = useOtlp ? new OTLPMetricExporter() : new ConsoleMetricExporter() -const logExporter: LogRecordExporter = useOtlp + const logExporter: LogRecordExporter = useOtlp ? new OTLPLogExporter() : new ConsoleLogRecordExporter() -const logRecordProcessor = useOtlp + const logRecordProcessor = useOtlp ? new BatchLogRecordProcessor({ exporter: logExporter }) : new SimpleLogRecordProcessor({ exporter: logExporter }) -const sdk = new NodeSDK({ + const sdk = new NodeSDK({ resource, spanProcessors: [ - useOtlp - ? new BatchSpanProcessor(traceExporter) - : new SimpleSpanProcessor(traceExporter), + useOtlp ? new BatchSpanProcessor(traceExporter) : new SimpleSpanProcessor(traceExporter) ], metricReader: new PeriodicExportingMetricReader({ - exporter: metricExporter, - exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL ?? 60000), + exporter: metricExporter, + exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL ?? 60000) }), logRecordProcessors: [logRecordProcessor], instrumentations: [ - getNodeAutoInstrumentations({ - '@opentelemetry/instrumentation-fs': { enabled: false }, - }), - new RuntimeNodeInstrumentation(), - ], -}) + getNodeAutoInstrumentations({ + '@opentelemetry/instrumentation-fs': { enabled: false } + }), + new RuntimeNodeInstrumentation() + ] + }) -sdk.start() + sdk.start() -// Capture clean console refs before patching, for telemetry's own messages. -const rawInfo = console.info.bind(console) -const rawError = console.error.bind(console) + // Capture clean console refs before patching, for telemetry's own messages. + const rawInfo = console.info.bind(console) + const rawError = console.error.bind(console) -// Bridge stray console.* calls into OTel logs so nothing is lost while code is -// migrated to the structured (pino) logger. -const logger = logs.getLogger(pkg.name, pkg.version) -const SEVERITY: Record = { - debug: SeverityNumber.DEBUG, - info: SeverityNumber.INFO, - log: SeverityNumber.INFO, - warn: SeverityNumber.WARN, - error: SeverityNumber.ERROR, -} -for (const method of ['debug', 'info', 'log', 'warn', 'error'] as const) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const original = (console as any)[method].bind(console) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(console as any)[method] = (...args: any[]) => { + // Bridge stray console.* calls into OTel logs so nothing is lost while code is + // migrated to the structured (pino) logger. + if (useOtlp) { + const logger = logs.getLogger(pkg.name, pkg.version) + const SEVERITY: Record = { + debug: SeverityNumber.DEBUG, + info: SeverityNumber.INFO, + log: SeverityNumber.INFO, + warn: SeverityNumber.WARN, + error: SeverityNumber.ERROR + } + for (const method of ['debug', 'info', 'log', 'warn', 'error'] as const) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const original = (console as any)[method].bind(console) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(console as any)[method] = (...args: any[]) => { try { - logger.emit({ - severityNumber: SEVERITY[method], - severityText: method.toUpperCase(), - body: args - .map((a) => - typeof a === 'string' ? a : (() => { - try { return JSON.stringify(a) } catch { return String(a) } - })() - ) - .join(' '), - }) + logger.emit({ + severityNumber: SEVERITY[method], + severityText: method.toUpperCase(), + body: args + .map(a => + typeof a === 'string' + ? a + : (() => { + try { + return JSON.stringify(a) + } catch { + return String(a) + } + })() + ) + .join(' ') + }) } catch { - /* never let telemetry break logging */ + /* never let telemetry break logging */ } original(...args) + } } -} + } -// Flush telemetry on shutdown. Only force-exit if the app registered no handler -// of its own for this signal — otherwise we let the app's shutdown drive exit. -const shutdown = (signal: NodeJS.Signals) => { - sdk.shutdown() - .then(() => rawInfo(`[otel] flushed (${signal})`)) - .catch((err: unknown) => rawError('[otel] shutdown error', err)) - .finally(() => { - if (process.listeners(signal).length <= 1) process.exit(0) - }) + // Flush telemetry on shutdown. Only force-exit if the app registered no handler + // of its own for this signal — otherwise we let the app's shutdown drive exit. + const shutdown = (signal: NodeJS.Signals) => { + sdk + .shutdown() + .then(() => rawInfo(`[otel] flushed (${signal})`)) + .catch((err: unknown) => rawError('[otel] shutdown error', err)) + .finally(() => { + if (process.listeners(signal).length <= 1) process.exit(0) + }) + } + process.on('SIGTERM', () => shutdown('SIGTERM')) + process.on('SIGINT', () => shutdown('SIGINT')) } -process.on('SIGTERM', () => shutdown('SIGTERM')) -process.on('SIGINT', () => shutdown('SIGINT')) diff --git a/infra/docker-compose.yaml b/infra/docker-compose.yaml index be27b5fee..aa970c012 100644 --- a/infra/docker-compose.yaml +++ b/infra/docker-compose.yaml @@ -19,10 +19,11 @@ # MESSAGE_BOX_SERVER_PRIVATE_KEY, WAB_SERVER_PRIVATE_KEY, and # UHRP_SERVER_PRIVATE_KEY values plus an OVERLAY_ADMIN_TOKEN before startup. # No private key or administrative token is checked in. Point telemetry at -# your collector with OTEL_EXPORTER_OTLP_ENDPOINT (unset => console exporters). +# your collector with OTEL_EXPORTER_OTLP_ENDPOINT (unset => telemetry off). x-otel-env: &otel-env OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} + OTEL_CONSOLE_EXPORTERS: ${OTEL_CONSOLE_EXPORTERS:-false} OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-} DEPLOY_ENV: ${DEPLOY_ENV:-development} diff --git a/infra/message-box-server/.env.example b/infra/message-box-server/.env.example index 7e02ff245..a10407ec5 100644 --- a/infra/message-box-server/.env.example +++ b/infra/message-box-server/.env.example @@ -10,6 +10,8 @@ SERVER_PRIVATE_KEY='replace-with-a-dedicated-private-key' # OpenTelemetry. OTLP headers may contain credentials and must come from the # deployment secret manager. OTEL_SERVICE_NAME=message-box-server +# No collector: telemetry is off. Opt in below for local console diagnostics. +OTEL_CONSOLE_EXPORTERS=false OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_HEADERS= OTEL_RESOURCE_ATTRIBUTES= diff --git a/infra/message-box-server/docker-compose.yml b/infra/message-box-server/docker-compose.yml index c8511cc04..c9e536c52 100644 --- a/infra/message-box-server/docker-compose.yml +++ b/infra/message-box-server/docker-compose.yml @@ -29,6 +29,7 @@ services: WALLET_STORAGE_URL: 'https://storage.babbage.systems' # OpenTelemetry — point at any OTLP/HTTP collector. Unset => console exporters. OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} + OTEL_CONSOLE_EXPORTERS: ${OTEL_CONSOLE_EXPORTERS:-false} OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-message-box-server} OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-} diff --git a/infra/message-box-server/src/telemetry.ts b/infra/message-box-server/src/telemetry.ts index a7b685985..a40bc2eba 100644 --- a/infra/message-box-server/src/telemetry.ts +++ b/infra/message-box-server/src/telemetry.ts @@ -1,11 +1,13 @@ /** - * OpenTelemetry bootstrap (ESM) — preloaded before app code via - * `node --import ./out/src/telemetry.js`. + * OpenTelemetry bootstrap — preloaded before application code. + * Canonical source: infra/overlay-server/src/telemetry.ts. + * Run pnpm sync:service-runtime-copies after editing; six standalone contexts + * receive byte-for-byte copies governed by service-runtime-copy-policy.json. * * Emits traces, metrics and logs. All wiring is driven by OTEL_* env vars; when - * OTEL_EXPORTER_OTLP_ENDPOINT is unset we fall back to console exporters so the - * process always boots (dev-safe). Runtime (heap/GC/event-loop) metrics are - * enabled to support memory-leak diagnosis. + * OTEL_EXPORTER_OTLP_ENDPOINT is unset telemetry stays off unless + * OTEL_CONSOLE_EXPORTERS=true explicitly enables local console diagnostics. + * Application logging remains available in every mode. * * ESM note: we deliberately do NOT register the import-in-the-middle loader * hook (@opentelemetry/instrumentation/hook.mjs). That hook rebuilds the named @@ -17,7 +19,7 @@ * * See docs/superpowers/specs/2026-06-22-infra-opentelemetry-design.md. */ -import { createRequire } from 'node:module' +import { readFileSync } from 'node:fs' import { join } from 'node:path' import { NodeSDK } from '@opentelemetry/sdk-node' @@ -50,109 +52,116 @@ import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api' // Resolve the component's package.json relative to the working directory (the // app root in every Dockerfile and local run) — robust regardless of build layout. -const require = createRequire(import.meta.url) -const pkg = require(join(process.cwd(), 'package.json')) as { name: string; version: string } +const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) as { + name: string + version: string +} const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT -const useOtlp = typeof otlpEndpoint === 'string' && otlpEndpoint.length > 0 +const useOtlp = typeof otlpEndpoint === 'string' && otlpEndpoint.trim().length > 0 const env = process.env.DEPLOY_ENV ?? process.env.NODE_ENV ?? 'development' if (process.env.OTEL_DIAG === 'true') { diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO) } -const resource = resourceFromAttributes({ - [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? pkg.name, - [ATTR_SERVICE_VERSION]: pkg.version, - 'deployment.environment': env -}) +// No SDK, instrumentation, console bridge or shutdown hooks without an exporter. +if (useOtlp || process.env.OTEL_CONSOLE_EXPORTERS === 'true') { + const resource = resourceFromAttributes({ + [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? pkg.name, + [ATTR_SERVICE_VERSION]: pkg.version, + 'deployment.environment': env + }) -const traceExporter: SpanExporter = useOtlp ? new OTLPTraceExporter() : new ConsoleSpanExporter() -const metricExporter: PushMetricExporter = useOtlp - ? new OTLPMetricExporter() - : new ConsoleMetricExporter() -const logExporter: LogRecordExporter = useOtlp - ? new OTLPLogExporter() - : new ConsoleLogRecordExporter() + const traceExporter: SpanExporter = useOtlp ? new OTLPTraceExporter() : new ConsoleSpanExporter() + const metricExporter: PushMetricExporter = useOtlp + ? new OTLPMetricExporter() + : new ConsoleMetricExporter() + const logExporter: LogRecordExporter = useOtlp + ? new OTLPLogExporter() + : new ConsoleLogRecordExporter() -const logRecordProcessor = useOtlp - ? new BatchLogRecordProcessor({ exporter: logExporter }) - : new SimpleLogRecordProcessor({ exporter: logExporter }) + const logRecordProcessor = useOtlp + ? new BatchLogRecordProcessor({ exporter: logExporter }) + : new SimpleLogRecordProcessor({ exporter: logExporter }) -const sdk = new NodeSDK({ - resource, - spanProcessors: [ - useOtlp ? new BatchSpanProcessor(traceExporter) : new SimpleSpanProcessor(traceExporter) - ], - metricReader: new PeriodicExportingMetricReader({ - exporter: metricExporter, - exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL ?? 60000) - }), - logRecordProcessors: [logRecordProcessor], - instrumentations: [ - getNodeAutoInstrumentations({ - '@opentelemetry/instrumentation-fs': { enabled: false } + const sdk = new NodeSDK({ + resource, + spanProcessors: [ + useOtlp ? new BatchSpanProcessor(traceExporter) : new SimpleSpanProcessor(traceExporter) + ], + metricReader: new PeriodicExportingMetricReader({ + exporter: metricExporter, + exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL ?? 60000) }), - new RuntimeNodeInstrumentation() - ] -}) + logRecordProcessors: [logRecordProcessor], + instrumentations: [ + getNodeAutoInstrumentations({ + '@opentelemetry/instrumentation-fs': { enabled: false } + }), + new RuntimeNodeInstrumentation() + ] + }) -sdk.start() + sdk.start() -// Capture clean console refs before patching, for telemetry's own messages. -const rawInfo = console.info.bind(console) -const rawError = console.error.bind(console) + // Capture clean console refs before patching, for telemetry's own messages. + const rawInfo = console.info.bind(console) + const rawError = console.error.bind(console) -// Bridge stray console.* calls into OTel logs so nothing is lost while code is -// migrated to the structured (pino) logger. -const logger = logs.getLogger(pkg.name, pkg.version) -const SEVERITY: Record = { - debug: SeverityNumber.DEBUG, - info: SeverityNumber.INFO, - log: SeverityNumber.INFO, - warn: SeverityNumber.WARN, - error: SeverityNumber.ERROR -} -for (const method of ['debug', 'info', 'log', 'warn', 'error'] as const) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const original = (console as any)[method].bind(console) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(console as any)[method] = (...args: any[]) => { - try { - logger.emit({ - severityNumber: SEVERITY[method], - severityText: method.toUpperCase(), - body: args - .map(a => - typeof a === 'string' - ? a - : (() => { - try { - return JSON.stringify(a) - } catch { - return String(a) - } - })() - ) - .join(' ') - }) - } catch { - /* never let telemetry break logging */ + // Bridge stray console.* calls into OTel logs so nothing is lost while code is + // migrated to the structured (pino) logger. + if (useOtlp) { + const logger = logs.getLogger(pkg.name, pkg.version) + const SEVERITY: Record = { + debug: SeverityNumber.DEBUG, + info: SeverityNumber.INFO, + log: SeverityNumber.INFO, + warn: SeverityNumber.WARN, + error: SeverityNumber.ERROR + } + for (const method of ['debug', 'info', 'log', 'warn', 'error'] as const) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const original = (console as any)[method].bind(console) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(console as any)[method] = (...args: any[]) => { + try { + logger.emit({ + severityNumber: SEVERITY[method], + severityText: method.toUpperCase(), + body: args + .map(a => + typeof a === 'string' + ? a + : (() => { + try { + return JSON.stringify(a) + } catch { + return String(a) + } + })() + ) + .join(' ') + }) + } catch { + /* never let telemetry break logging */ + } + original(...args) + } } - original(...args) } -} -// Flush telemetry on shutdown. Only force-exit if the app registered no handler -// of its own for this signal — otherwise we let the app's shutdown drive exit. -const shutdown = (signal: NodeJS.Signals) => { - sdk - .shutdown() - .then(() => rawInfo(`[otel] flushed (${signal})`)) - .catch((err: unknown) => rawError('[otel] shutdown error', err)) - .finally(() => { - if (process.listeners(signal).length <= 1) process.exit(0) - }) + // Flush telemetry on shutdown. Only force-exit if the app registered no handler + // of its own for this signal — otherwise we let the app's shutdown drive exit. + const shutdown = (signal: NodeJS.Signals) => { + sdk + .shutdown() + .then(() => rawInfo(`[otel] flushed (${signal})`)) + .catch((err: unknown) => rawError('[otel] shutdown error', err)) + .finally(() => { + if (process.listeners(signal).length <= 1) process.exit(0) + }) + } + process.on('SIGTERM', () => shutdown('SIGTERM')) + process.on('SIGINT', () => shutdown('SIGINT')) } -process.on('SIGTERM', () => shutdown('SIGTERM')) -process.on('SIGINT', () => shutdown('SIGINT')) diff --git a/infra/overlay-server/.env.example b/infra/overlay-server/.env.example index 81d6d5626..956f5ccba 100644 --- a/infra/overlay-server/.env.example +++ b/infra/overlay-server/.env.example @@ -26,6 +26,8 @@ UNPROVEN_MAINTENANCE_INTERVAL_MS=3600000 # OpenTelemetry. OTLP headers may contain credentials and must come from the # deployment secret manager. OTEL_SERVICE_NAME=overlay-server +# No collector: telemetry is off. Opt in below for local console diagnostics. +OTEL_CONSOLE_EXPORTERS=false OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_HEADERS= OTEL_RESOURCE_ATTRIBUTES= diff --git a/infra/overlay-server/docker-compose.yml b/infra/overlay-server/docker-compose.yml index c13c7fb46..985d34973 100644 --- a/infra/overlay-server/docker-compose.yml +++ b/infra/overlay-server/docker-compose.yml @@ -56,6 +56,7 @@ services: OVERLAY_STRICT_TRANSPORT_SECURITY: ${OVERLAY_STRICT_TRANSPORT_SECURITY:-} # OpenTelemetry — point at any OTLP/HTTP collector. Unset => console exporters. OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} + OTEL_CONSOLE_EXPORTERS: ${OTEL_CONSOLE_EXPORTERS:-false} OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-overlay-server} OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-} diff --git a/infra/overlay-server/src/telemetry.ts b/infra/overlay-server/src/telemetry.ts index 8a627dbfc..a40bc2eba 100644 --- a/infra/overlay-server/src/telemetry.ts +++ b/infra/overlay-server/src/telemetry.ts @@ -1,11 +1,13 @@ /** - * OpenTelemetry bootstrap (ESM) — preloaded before app code via - * `node --import ./out/src/telemetry.js`. + * OpenTelemetry bootstrap — preloaded before application code. + * Canonical source: infra/overlay-server/src/telemetry.ts. + * Run pnpm sync:service-runtime-copies after editing; six standalone contexts + * receive byte-for-byte copies governed by service-runtime-copy-policy.json. * * Emits traces, metrics and logs. All wiring is driven by OTEL_* env vars; when - * OTEL_EXPORTER_OTLP_ENDPOINT is unset we fall back to console exporters so the - * process always boots (dev-safe). Runtime (heap/GC/event-loop) metrics are - * enabled to support memory-leak diagnosis. + * OTEL_EXPORTER_OTLP_ENDPOINT is unset telemetry stays off unless + * OTEL_CONSOLE_EXPORTERS=true explicitly enables local console diagnostics. + * Application logging remains available in every mode. * * ESM note: we deliberately do NOT register the import-in-the-middle loader * hook (@opentelemetry/instrumentation/hook.mjs). That hook rebuilds the named @@ -17,7 +19,7 @@ * * See docs/superpowers/specs/2026-06-22-infra-opentelemetry-design.md. */ -import { createRequire } from 'node:module' +import { readFileSync } from 'node:fs' import { join } from 'node:path' import { NodeSDK } from '@opentelemetry/sdk-node' @@ -27,132 +29,139 @@ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http' import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' import { - ConsoleSpanExporter, - BatchSpanProcessor, - SimpleSpanProcessor, - type SpanExporter, + ConsoleSpanExporter, + BatchSpanProcessor, + SimpleSpanProcessor, + type SpanExporter } from '@opentelemetry/sdk-trace-base' import { - PeriodicExportingMetricReader, - ConsoleMetricExporter, - type PushMetricExporter, + PeriodicExportingMetricReader, + ConsoleMetricExporter, + type PushMetricExporter } from '@opentelemetry/sdk-metrics' import { - BatchLogRecordProcessor, - SimpleLogRecordProcessor, - ConsoleLogRecordExporter, - type LogRecordExporter, + BatchLogRecordProcessor, + SimpleLogRecordProcessor, + ConsoleLogRecordExporter, + type LogRecordExporter } from '@opentelemetry/sdk-logs' import { resourceFromAttributes } from '@opentelemetry/resources' -import { - ATTR_SERVICE_NAME, - ATTR_SERVICE_VERSION, -} from '@opentelemetry/semantic-conventions' +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions' import { logs, SeverityNumber } from '@opentelemetry/api-logs' import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api' // Resolve the component's package.json relative to the working directory (the // app root in every Dockerfile and local run) — robust regardless of build layout. -const require = createRequire(import.meta.url) -const pkg = require(join(process.cwd(), 'package.json')) as { name: string; version: string } +const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) as { + name: string + version: string +} const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT -const useOtlp = typeof otlpEndpoint === 'string' && otlpEndpoint.length > 0 +const useOtlp = typeof otlpEndpoint === 'string' && otlpEndpoint.trim().length > 0 const env = process.env.DEPLOY_ENV ?? process.env.NODE_ENV ?? 'development' if (process.env.OTEL_DIAG === 'true') { - diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO) + diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO) } -const resource = resourceFromAttributes({ +// No SDK, instrumentation, console bridge or shutdown hooks without an exporter. +if (useOtlp || process.env.OTEL_CONSOLE_EXPORTERS === 'true') { + const resource = resourceFromAttributes({ [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? pkg.name, [ATTR_SERVICE_VERSION]: pkg.version, - 'deployment.environment': env, -}) + 'deployment.environment': env + }) -const traceExporter: SpanExporter = useOtlp - ? new OTLPTraceExporter() - : new ConsoleSpanExporter() -const metricExporter: PushMetricExporter = useOtlp + const traceExporter: SpanExporter = useOtlp ? new OTLPTraceExporter() : new ConsoleSpanExporter() + const metricExporter: PushMetricExporter = useOtlp ? new OTLPMetricExporter() : new ConsoleMetricExporter() -const logExporter: LogRecordExporter = useOtlp + const logExporter: LogRecordExporter = useOtlp ? new OTLPLogExporter() : new ConsoleLogRecordExporter() -const logRecordProcessor = useOtlp + const logRecordProcessor = useOtlp ? new BatchLogRecordProcessor({ exporter: logExporter }) : new SimpleLogRecordProcessor({ exporter: logExporter }) -const sdk = new NodeSDK({ + const sdk = new NodeSDK({ resource, spanProcessors: [ - useOtlp - ? new BatchSpanProcessor(traceExporter) - : new SimpleSpanProcessor(traceExporter), + useOtlp ? new BatchSpanProcessor(traceExporter) : new SimpleSpanProcessor(traceExporter) ], metricReader: new PeriodicExportingMetricReader({ - exporter: metricExporter, - exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL ?? 60000), + exporter: metricExporter, + exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL ?? 60000) }), logRecordProcessors: [logRecordProcessor], instrumentations: [ - getNodeAutoInstrumentations({ - '@opentelemetry/instrumentation-fs': { enabled: false }, - }), - new RuntimeNodeInstrumentation(), - ], -}) + getNodeAutoInstrumentations({ + '@opentelemetry/instrumentation-fs': { enabled: false } + }), + new RuntimeNodeInstrumentation() + ] + }) -sdk.start() + sdk.start() -// Capture clean console refs before patching, for telemetry's own messages. -const rawInfo = console.info.bind(console) -const rawError = console.error.bind(console) + // Capture clean console refs before patching, for telemetry's own messages. + const rawInfo = console.info.bind(console) + const rawError = console.error.bind(console) -// Bridge stray console.* calls into OTel logs so nothing is lost while code is -// migrated to the structured (pino) logger. -const logger = logs.getLogger(pkg.name, pkg.version) -const SEVERITY: Record = { - debug: SeverityNumber.DEBUG, - info: SeverityNumber.INFO, - log: SeverityNumber.INFO, - warn: SeverityNumber.WARN, - error: SeverityNumber.ERROR, -} -for (const method of ['debug', 'info', 'log', 'warn', 'error'] as const) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const original = (console as any)[method].bind(console) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(console as any)[method] = (...args: any[]) => { + // Bridge stray console.* calls into OTel logs so nothing is lost while code is + // migrated to the structured (pino) logger. + if (useOtlp) { + const logger = logs.getLogger(pkg.name, pkg.version) + const SEVERITY: Record = { + debug: SeverityNumber.DEBUG, + info: SeverityNumber.INFO, + log: SeverityNumber.INFO, + warn: SeverityNumber.WARN, + error: SeverityNumber.ERROR + } + for (const method of ['debug', 'info', 'log', 'warn', 'error'] as const) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const original = (console as any)[method].bind(console) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(console as any)[method] = (...args: any[]) => { try { - logger.emit({ - severityNumber: SEVERITY[method], - severityText: method.toUpperCase(), - body: args - .map((a) => - typeof a === 'string' ? a : (() => { - try { return JSON.stringify(a) } catch { return String(a) } - })() - ) - .join(' '), - }) + logger.emit({ + severityNumber: SEVERITY[method], + severityText: method.toUpperCase(), + body: args + .map(a => + typeof a === 'string' + ? a + : (() => { + try { + return JSON.stringify(a) + } catch { + return String(a) + } + })() + ) + .join(' ') + }) } catch { - /* never let telemetry break logging */ + /* never let telemetry break logging */ } original(...args) + } } -} + } -// Flush telemetry on shutdown. Only force-exit if the app registered no handler -// of its own for this signal — otherwise we let the app's shutdown drive exit. -const shutdown = (signal: NodeJS.Signals) => { - sdk.shutdown() - .then(() => rawInfo(`[otel] flushed (${signal})`)) - .catch((err: unknown) => rawError('[otel] shutdown error', err)) - .finally(() => { - if (process.listeners(signal).length <= 1) process.exit(0) - }) + // Flush telemetry on shutdown. Only force-exit if the app registered no handler + // of its own for this signal — otherwise we let the app's shutdown drive exit. + const shutdown = (signal: NodeJS.Signals) => { + sdk + .shutdown() + .then(() => rawInfo(`[otel] flushed (${signal})`)) + .catch((err: unknown) => rawError('[otel] shutdown error', err)) + .finally(() => { + if (process.listeners(signal).length <= 1) process.exit(0) + }) + } + process.on('SIGTERM', () => shutdown('SIGTERM')) + process.on('SIGINT', () => shutdown('SIGINT')) } -process.on('SIGTERM', () => shutdown('SIGTERM')) -process.on('SIGINT', () => shutdown('SIGINT')) diff --git a/infra/uhrp-server-basic/.env.example b/infra/uhrp-server-basic/.env.example index 2871ee8f8..8eb563c85 100644 --- a/infra/uhrp-server-basic/.env.example +++ b/infra/uhrp-server-basic/.env.example @@ -9,6 +9,8 @@ HTTP_PORT=8080 # OpenTelemetry. OTLP headers may contain credentials and must come from the # deployment secret manager. OTEL_SERVICE_NAME=uhrp-server-basic +# No collector: telemetry is off. Opt in below for local console diagnostics. +OTEL_CONSOLE_EXPORTERS=false OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_HEADERS= OTEL_RESOURCE_ATTRIBUTES= diff --git a/infra/uhrp-server-basic/src/telemetry.ts b/infra/uhrp-server-basic/src/telemetry.ts index b60e0f1e1..a40bc2eba 100644 --- a/infra/uhrp-server-basic/src/telemetry.ts +++ b/infra/uhrp-server-basic/src/telemetry.ts @@ -1,14 +1,27 @@ /** - * OpenTelemetry bootstrap (CommonJS) — preloaded before app code via - * `node --require ./dist/telemetry.js`. + * OpenTelemetry bootstrap — preloaded before application code. + * Canonical source: infra/overlay-server/src/telemetry.ts. + * Run pnpm sync:service-runtime-copies after editing; six standalone contexts + * receive byte-for-byte copies governed by service-runtime-copy-policy.json. * * Emits traces, metrics and logs. All wiring is driven by OTEL_* env vars; when - * OTEL_EXPORTER_OTLP_ENDPOINT is unset we fall back to console exporters so the - * process always boots (dev-safe). Runtime (heap/GC/event-loop) metrics are - * enabled to support memory-leak diagnosis. + * OTEL_EXPORTER_OTLP_ENDPOINT is unset telemetry stays off unless + * OTEL_CONSOLE_EXPORTERS=true explicitly enables local console diagnostics. + * Application logging remains available in every mode. + * + * ESM note: we deliberately do NOT register the import-in-the-middle loader + * hook (@opentelemetry/instrumentation/hook.mjs). That hook rebuilds the named + * exports of CJS packages imported as ESM and drops some of them (e.g. + * @bsv/sdk's PushDrop), which crashes the app at import time. The libraries we + * actually instrument (http, express, mongodb, mysql2, pino) are pulled in + * through CJS dependency chains (overlay-express, wallet-toolbox, authsocket) + * and are still patched by require-in-the-middle, so coverage is retained. * * See docs/superpowers/specs/2026-06-22-infra-opentelemetry-design.md. */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + import { NodeSDK } from '@opentelemetry/sdk-node' import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' import { RuntimeNodeInstrumentation } from '@opentelemetry/instrumentation-runtime-node' @@ -16,133 +29,139 @@ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http' import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' import { - ConsoleSpanExporter, - BatchSpanProcessor, - SimpleSpanProcessor, - type SpanExporter, + ConsoleSpanExporter, + BatchSpanProcessor, + SimpleSpanProcessor, + type SpanExporter } from '@opentelemetry/sdk-trace-base' import { - PeriodicExportingMetricReader, - ConsoleMetricExporter, - type PushMetricExporter, + PeriodicExportingMetricReader, + ConsoleMetricExporter, + type PushMetricExporter } from '@opentelemetry/sdk-metrics' import { - BatchLogRecordProcessor, - SimpleLogRecordProcessor, - ConsoleLogRecordExporter, - type LogRecordExporter, + BatchLogRecordProcessor, + SimpleLogRecordProcessor, + ConsoleLogRecordExporter, + type LogRecordExporter } from '@opentelemetry/sdk-logs' import { resourceFromAttributes } from '@opentelemetry/resources' -import { - ATTR_SERVICE_NAME, - ATTR_SERVICE_VERSION, -} from '@opentelemetry/semantic-conventions' +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions' import { logs, SeverityNumber } from '@opentelemetry/api-logs' import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api' -import * as path from 'node:path' // Resolve the component's package.json relative to the working directory (the // app root in every Dockerfile and local run) — robust regardless of build layout. -// eslint-disable-next-line @typescript-eslint/no-var-requires -const pkg = require(path.join(process.cwd(), 'package.json')) as { name: string; version: string } +const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) as { + name: string + version: string +} const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT -const useOtlp = typeof otlpEndpoint === 'string' && otlpEndpoint.length > 0 +const useOtlp = typeof otlpEndpoint === 'string' && otlpEndpoint.trim().length > 0 const env = process.env.DEPLOY_ENV ?? process.env.NODE_ENV ?? 'development' if (process.env.OTEL_DIAG === 'true') { - diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO) + diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO) } -const resource = resourceFromAttributes({ +// No SDK, instrumentation, console bridge or shutdown hooks without an exporter. +if (useOtlp || process.env.OTEL_CONSOLE_EXPORTERS === 'true') { + const resource = resourceFromAttributes({ [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? pkg.name, [ATTR_SERVICE_VERSION]: pkg.version, - 'deployment.environment': env, -}) + 'deployment.environment': env + }) -const traceExporter: SpanExporter = useOtlp - ? new OTLPTraceExporter() - : new ConsoleSpanExporter() -const metricExporter: PushMetricExporter = useOtlp + const traceExporter: SpanExporter = useOtlp ? new OTLPTraceExporter() : new ConsoleSpanExporter() + const metricExporter: PushMetricExporter = useOtlp ? new OTLPMetricExporter() : new ConsoleMetricExporter() -const logExporter: LogRecordExporter = useOtlp + const logExporter: LogRecordExporter = useOtlp ? new OTLPLogExporter() : new ConsoleLogRecordExporter() -const logRecordProcessor = useOtlp + const logRecordProcessor = useOtlp ? new BatchLogRecordProcessor({ exporter: logExporter }) : new SimpleLogRecordProcessor({ exporter: logExporter }) -const sdk = new NodeSDK({ + const sdk = new NodeSDK({ resource, spanProcessors: [ - useOtlp - ? new BatchSpanProcessor(traceExporter) - : new SimpleSpanProcessor(traceExporter), + useOtlp ? new BatchSpanProcessor(traceExporter) : new SimpleSpanProcessor(traceExporter) ], metricReader: new PeriodicExportingMetricReader({ - exporter: metricExporter, - exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL ?? 60000), + exporter: metricExporter, + exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL ?? 60000) }), logRecordProcessors: [logRecordProcessor], instrumentations: [ - getNodeAutoInstrumentations({ - '@opentelemetry/instrumentation-fs': { enabled: false }, - }), - new RuntimeNodeInstrumentation(), - ], -}) + getNodeAutoInstrumentations({ + '@opentelemetry/instrumentation-fs': { enabled: false } + }), + new RuntimeNodeInstrumentation() + ] + }) -sdk.start() + sdk.start() -// Capture clean console refs before patching, for telemetry's own messages. -const rawInfo = console.info.bind(console) -const rawError = console.error.bind(console) + // Capture clean console refs before patching, for telemetry's own messages. + const rawInfo = console.info.bind(console) + const rawError = console.error.bind(console) -// Bridge stray console.* calls into OTel logs so nothing is lost while code is -// migrated to the structured (pino) logger. -const logger = logs.getLogger(pkg.name, pkg.version) -const SEVERITY: Record = { - debug: SeverityNumber.DEBUG, - info: SeverityNumber.INFO, - log: SeverityNumber.INFO, - warn: SeverityNumber.WARN, - error: SeverityNumber.ERROR, -} -for (const method of ['debug', 'info', 'log', 'warn', 'error'] as const) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const original = (console as any)[method].bind(console) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(console as any)[method] = (...args: any[]) => { + // Bridge stray console.* calls into OTel logs so nothing is lost while code is + // migrated to the structured (pino) logger. + if (useOtlp) { + const logger = logs.getLogger(pkg.name, pkg.version) + const SEVERITY: Record = { + debug: SeverityNumber.DEBUG, + info: SeverityNumber.INFO, + log: SeverityNumber.INFO, + warn: SeverityNumber.WARN, + error: SeverityNumber.ERROR + } + for (const method of ['debug', 'info', 'log', 'warn', 'error'] as const) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const original = (console as any)[method].bind(console) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(console as any)[method] = (...args: any[]) => { try { - logger.emit({ - severityNumber: SEVERITY[method], - severityText: method.toUpperCase(), - body: args - .map((a) => - typeof a === 'string' ? a : (() => { - try { return JSON.stringify(a) } catch { return String(a) } - })() - ) - .join(' '), - }) + logger.emit({ + severityNumber: SEVERITY[method], + severityText: method.toUpperCase(), + body: args + .map(a => + typeof a === 'string' + ? a + : (() => { + try { + return JSON.stringify(a) + } catch { + return String(a) + } + })() + ) + .join(' ') + }) } catch { - /* never let telemetry break logging */ + /* never let telemetry break logging */ } original(...args) + } } -} + } -// Flush telemetry on shutdown. Only force-exit if the app registered no handler -// of its own for this signal — otherwise we let the app's shutdown drive exit. -const shutdown = (signal: NodeJS.Signals) => { - sdk.shutdown() - .then(() => rawInfo(`[otel] flushed (${signal})`)) - .catch((err: unknown) => rawError('[otel] shutdown error', err)) - .finally(() => { - if (process.listeners(signal).length <= 1) process.exit(0) - }) + // Flush telemetry on shutdown. Only force-exit if the app registered no handler + // of its own for this signal — otherwise we let the app's shutdown drive exit. + const shutdown = (signal: NodeJS.Signals) => { + sdk + .shutdown() + .then(() => rawInfo(`[otel] flushed (${signal})`)) + .catch((err: unknown) => rawError('[otel] shutdown error', err)) + .finally(() => { + if (process.listeners(signal).length <= 1) process.exit(0) + }) + } + process.on('SIGTERM', () => shutdown('SIGTERM')) + process.on('SIGINT', () => shutdown('SIGINT')) } -process.on('SIGTERM', () => shutdown('SIGTERM')) -process.on('SIGINT', () => shutdown('SIGINT')) diff --git a/infra/uhrp-server-cloud-bucket/docker-compose.yml b/infra/uhrp-server-cloud-bucket/docker-compose.yml index 857c4fe3f..3423234e1 100644 --- a/infra/uhrp-server-cloud-bucket/docker-compose.yml +++ b/infra/uhrp-server-cloud-bucket/docker-compose.yml @@ -40,6 +40,7 @@ services: GOOGLE_APPLICATION_CREDENTIALS: '/app/service-account.json' # OpenTelemetry — point at any OTLP/HTTP collector. Unset => console exporters. OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} + OTEL_CONSOLE_EXPORTERS: ${OTEL_CONSOLE_EXPORTERS:-false} OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-uhrp-server-cloud-bucket} OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-} diff --git a/infra/uhrp-server-cloud-bucket/secrets/.env.example b/infra/uhrp-server-cloud-bucket/secrets/.env.example index 5cfb89ae8..45fd837fd 100644 --- a/infra/uhrp-server-cloud-bucket/secrets/.env.example +++ b/infra/uhrp-server-cloud-bucket/secrets/.env.example @@ -16,6 +16,8 @@ HTTP_PORT=8080 # OpenTelemetry. OTLP headers may contain credentials and should remain in the # environment-specific secret managed by this service's existing sync flow. OTEL_SERVICE_NAME=uhrp-server-cloud-bucket +# No collector: telemetry is off. Opt in below for local console diagnostics. +OTEL_CONSOLE_EXPORTERS=false OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_HEADERS= OTEL_RESOURCE_ATTRIBUTES= diff --git a/infra/uhrp-server-cloud-bucket/src/telemetry.ts b/infra/uhrp-server-cloud-bucket/src/telemetry.ts index b60e0f1e1..a40bc2eba 100644 --- a/infra/uhrp-server-cloud-bucket/src/telemetry.ts +++ b/infra/uhrp-server-cloud-bucket/src/telemetry.ts @@ -1,14 +1,27 @@ /** - * OpenTelemetry bootstrap (CommonJS) — preloaded before app code via - * `node --require ./dist/telemetry.js`. + * OpenTelemetry bootstrap — preloaded before application code. + * Canonical source: infra/overlay-server/src/telemetry.ts. + * Run pnpm sync:service-runtime-copies after editing; six standalone contexts + * receive byte-for-byte copies governed by service-runtime-copy-policy.json. * * Emits traces, metrics and logs. All wiring is driven by OTEL_* env vars; when - * OTEL_EXPORTER_OTLP_ENDPOINT is unset we fall back to console exporters so the - * process always boots (dev-safe). Runtime (heap/GC/event-loop) metrics are - * enabled to support memory-leak diagnosis. + * OTEL_EXPORTER_OTLP_ENDPOINT is unset telemetry stays off unless + * OTEL_CONSOLE_EXPORTERS=true explicitly enables local console diagnostics. + * Application logging remains available in every mode. + * + * ESM note: we deliberately do NOT register the import-in-the-middle loader + * hook (@opentelemetry/instrumentation/hook.mjs). That hook rebuilds the named + * exports of CJS packages imported as ESM and drops some of them (e.g. + * @bsv/sdk's PushDrop), which crashes the app at import time. The libraries we + * actually instrument (http, express, mongodb, mysql2, pino) are pulled in + * through CJS dependency chains (overlay-express, wallet-toolbox, authsocket) + * and are still patched by require-in-the-middle, so coverage is retained. * * See docs/superpowers/specs/2026-06-22-infra-opentelemetry-design.md. */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + import { NodeSDK } from '@opentelemetry/sdk-node' import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' import { RuntimeNodeInstrumentation } from '@opentelemetry/instrumentation-runtime-node' @@ -16,133 +29,139 @@ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http' import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' import { - ConsoleSpanExporter, - BatchSpanProcessor, - SimpleSpanProcessor, - type SpanExporter, + ConsoleSpanExporter, + BatchSpanProcessor, + SimpleSpanProcessor, + type SpanExporter } from '@opentelemetry/sdk-trace-base' import { - PeriodicExportingMetricReader, - ConsoleMetricExporter, - type PushMetricExporter, + PeriodicExportingMetricReader, + ConsoleMetricExporter, + type PushMetricExporter } from '@opentelemetry/sdk-metrics' import { - BatchLogRecordProcessor, - SimpleLogRecordProcessor, - ConsoleLogRecordExporter, - type LogRecordExporter, + BatchLogRecordProcessor, + SimpleLogRecordProcessor, + ConsoleLogRecordExporter, + type LogRecordExporter } from '@opentelemetry/sdk-logs' import { resourceFromAttributes } from '@opentelemetry/resources' -import { - ATTR_SERVICE_NAME, - ATTR_SERVICE_VERSION, -} from '@opentelemetry/semantic-conventions' +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions' import { logs, SeverityNumber } from '@opentelemetry/api-logs' import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api' -import * as path from 'node:path' // Resolve the component's package.json relative to the working directory (the // app root in every Dockerfile and local run) — robust regardless of build layout. -// eslint-disable-next-line @typescript-eslint/no-var-requires -const pkg = require(path.join(process.cwd(), 'package.json')) as { name: string; version: string } +const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) as { + name: string + version: string +} const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT -const useOtlp = typeof otlpEndpoint === 'string' && otlpEndpoint.length > 0 +const useOtlp = typeof otlpEndpoint === 'string' && otlpEndpoint.trim().length > 0 const env = process.env.DEPLOY_ENV ?? process.env.NODE_ENV ?? 'development' if (process.env.OTEL_DIAG === 'true') { - diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO) + diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO) } -const resource = resourceFromAttributes({ +// No SDK, instrumentation, console bridge or shutdown hooks without an exporter. +if (useOtlp || process.env.OTEL_CONSOLE_EXPORTERS === 'true') { + const resource = resourceFromAttributes({ [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? pkg.name, [ATTR_SERVICE_VERSION]: pkg.version, - 'deployment.environment': env, -}) + 'deployment.environment': env + }) -const traceExporter: SpanExporter = useOtlp - ? new OTLPTraceExporter() - : new ConsoleSpanExporter() -const metricExporter: PushMetricExporter = useOtlp + const traceExporter: SpanExporter = useOtlp ? new OTLPTraceExporter() : new ConsoleSpanExporter() + const metricExporter: PushMetricExporter = useOtlp ? new OTLPMetricExporter() : new ConsoleMetricExporter() -const logExporter: LogRecordExporter = useOtlp + const logExporter: LogRecordExporter = useOtlp ? new OTLPLogExporter() : new ConsoleLogRecordExporter() -const logRecordProcessor = useOtlp + const logRecordProcessor = useOtlp ? new BatchLogRecordProcessor({ exporter: logExporter }) : new SimpleLogRecordProcessor({ exporter: logExporter }) -const sdk = new NodeSDK({ + const sdk = new NodeSDK({ resource, spanProcessors: [ - useOtlp - ? new BatchSpanProcessor(traceExporter) - : new SimpleSpanProcessor(traceExporter), + useOtlp ? new BatchSpanProcessor(traceExporter) : new SimpleSpanProcessor(traceExporter) ], metricReader: new PeriodicExportingMetricReader({ - exporter: metricExporter, - exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL ?? 60000), + exporter: metricExporter, + exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL ?? 60000) }), logRecordProcessors: [logRecordProcessor], instrumentations: [ - getNodeAutoInstrumentations({ - '@opentelemetry/instrumentation-fs': { enabled: false }, - }), - new RuntimeNodeInstrumentation(), - ], -}) + getNodeAutoInstrumentations({ + '@opentelemetry/instrumentation-fs': { enabled: false } + }), + new RuntimeNodeInstrumentation() + ] + }) -sdk.start() + sdk.start() -// Capture clean console refs before patching, for telemetry's own messages. -const rawInfo = console.info.bind(console) -const rawError = console.error.bind(console) + // Capture clean console refs before patching, for telemetry's own messages. + const rawInfo = console.info.bind(console) + const rawError = console.error.bind(console) -// Bridge stray console.* calls into OTel logs so nothing is lost while code is -// migrated to the structured (pino) logger. -const logger = logs.getLogger(pkg.name, pkg.version) -const SEVERITY: Record = { - debug: SeverityNumber.DEBUG, - info: SeverityNumber.INFO, - log: SeverityNumber.INFO, - warn: SeverityNumber.WARN, - error: SeverityNumber.ERROR, -} -for (const method of ['debug', 'info', 'log', 'warn', 'error'] as const) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const original = (console as any)[method].bind(console) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(console as any)[method] = (...args: any[]) => { + // Bridge stray console.* calls into OTel logs so nothing is lost while code is + // migrated to the structured (pino) logger. + if (useOtlp) { + const logger = logs.getLogger(pkg.name, pkg.version) + const SEVERITY: Record = { + debug: SeverityNumber.DEBUG, + info: SeverityNumber.INFO, + log: SeverityNumber.INFO, + warn: SeverityNumber.WARN, + error: SeverityNumber.ERROR + } + for (const method of ['debug', 'info', 'log', 'warn', 'error'] as const) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const original = (console as any)[method].bind(console) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(console as any)[method] = (...args: any[]) => { try { - logger.emit({ - severityNumber: SEVERITY[method], - severityText: method.toUpperCase(), - body: args - .map((a) => - typeof a === 'string' ? a : (() => { - try { return JSON.stringify(a) } catch { return String(a) } - })() - ) - .join(' '), - }) + logger.emit({ + severityNumber: SEVERITY[method], + severityText: method.toUpperCase(), + body: args + .map(a => + typeof a === 'string' + ? a + : (() => { + try { + return JSON.stringify(a) + } catch { + return String(a) + } + })() + ) + .join(' ') + }) } catch { - /* never let telemetry break logging */ + /* never let telemetry break logging */ } original(...args) + } } -} + } -// Flush telemetry on shutdown. Only force-exit if the app registered no handler -// of its own for this signal — otherwise we let the app's shutdown drive exit. -const shutdown = (signal: NodeJS.Signals) => { - sdk.shutdown() - .then(() => rawInfo(`[otel] flushed (${signal})`)) - .catch((err: unknown) => rawError('[otel] shutdown error', err)) - .finally(() => { - if (process.listeners(signal).length <= 1) process.exit(0) - }) + // Flush telemetry on shutdown. Only force-exit if the app registered no handler + // of its own for this signal — otherwise we let the app's shutdown drive exit. + const shutdown = (signal: NodeJS.Signals) => { + sdk + .shutdown() + .then(() => rawInfo(`[otel] flushed (${signal})`)) + .catch((err: unknown) => rawError('[otel] shutdown error', err)) + .finally(() => { + if (process.listeners(signal).length <= 1) process.exit(0) + }) + } + process.on('SIGTERM', () => shutdown('SIGTERM')) + process.on('SIGINT', () => shutdown('SIGINT')) } -process.on('SIGTERM', () => shutdown('SIGTERM')) -process.on('SIGINT', () => shutdown('SIGINT')) diff --git a/infra/wab/.env.example b/infra/wab/.env.example index 4fd5f0dcd..93758b4ad 100644 --- a/infra/wab/.env.example +++ b/infra/wab/.env.example @@ -36,6 +36,8 @@ WAB_DEMO_AUTH_SECRET= # OpenTelemetry. OTLP headers may contain credentials and must come from the # deployment secret manager. OTEL_SERVICE_NAME=wab +# No collector: telemetry is off. Opt in below for local console diagnostics. +OTEL_CONSOLE_EXPORTERS=false OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_HEADERS= OTEL_RESOURCE_ATTRIBUTES= diff --git a/infra/wab/docker-compose.yml b/infra/wab/docker-compose.yml index f09f2017f..65ea79f24 100644 --- a/infra/wab/docker-compose.yml +++ b/infra/wab/docker-compose.yml @@ -27,6 +27,7 @@ services: WAB_STRICT_TRANSPORT_SECURITY: ${WAB_STRICT_TRANSPORT_SECURITY:-} # OpenTelemetry — point at any OTLP/HTTP collector. Unset => console exporters. OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} + OTEL_CONSOLE_EXPORTERS: ${OTEL_CONSOLE_EXPORTERS:-false} OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-wab-server} OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-} diff --git a/infra/wab/src/telemetry.ts b/infra/wab/src/telemetry.ts index b60e0f1e1..a40bc2eba 100644 --- a/infra/wab/src/telemetry.ts +++ b/infra/wab/src/telemetry.ts @@ -1,14 +1,27 @@ /** - * OpenTelemetry bootstrap (CommonJS) — preloaded before app code via - * `node --require ./dist/telemetry.js`. + * OpenTelemetry bootstrap — preloaded before application code. + * Canonical source: infra/overlay-server/src/telemetry.ts. + * Run pnpm sync:service-runtime-copies after editing; six standalone contexts + * receive byte-for-byte copies governed by service-runtime-copy-policy.json. * * Emits traces, metrics and logs. All wiring is driven by OTEL_* env vars; when - * OTEL_EXPORTER_OTLP_ENDPOINT is unset we fall back to console exporters so the - * process always boots (dev-safe). Runtime (heap/GC/event-loop) metrics are - * enabled to support memory-leak diagnosis. + * OTEL_EXPORTER_OTLP_ENDPOINT is unset telemetry stays off unless + * OTEL_CONSOLE_EXPORTERS=true explicitly enables local console diagnostics. + * Application logging remains available in every mode. + * + * ESM note: we deliberately do NOT register the import-in-the-middle loader + * hook (@opentelemetry/instrumentation/hook.mjs). That hook rebuilds the named + * exports of CJS packages imported as ESM and drops some of them (e.g. + * @bsv/sdk's PushDrop), which crashes the app at import time. The libraries we + * actually instrument (http, express, mongodb, mysql2, pino) are pulled in + * through CJS dependency chains (overlay-express, wallet-toolbox, authsocket) + * and are still patched by require-in-the-middle, so coverage is retained. * * See docs/superpowers/specs/2026-06-22-infra-opentelemetry-design.md. */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + import { NodeSDK } from '@opentelemetry/sdk-node' import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' import { RuntimeNodeInstrumentation } from '@opentelemetry/instrumentation-runtime-node' @@ -16,133 +29,139 @@ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http' import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' import { - ConsoleSpanExporter, - BatchSpanProcessor, - SimpleSpanProcessor, - type SpanExporter, + ConsoleSpanExporter, + BatchSpanProcessor, + SimpleSpanProcessor, + type SpanExporter } from '@opentelemetry/sdk-trace-base' import { - PeriodicExportingMetricReader, - ConsoleMetricExporter, - type PushMetricExporter, + PeriodicExportingMetricReader, + ConsoleMetricExporter, + type PushMetricExporter } from '@opentelemetry/sdk-metrics' import { - BatchLogRecordProcessor, - SimpleLogRecordProcessor, - ConsoleLogRecordExporter, - type LogRecordExporter, + BatchLogRecordProcessor, + SimpleLogRecordProcessor, + ConsoleLogRecordExporter, + type LogRecordExporter } from '@opentelemetry/sdk-logs' import { resourceFromAttributes } from '@opentelemetry/resources' -import { - ATTR_SERVICE_NAME, - ATTR_SERVICE_VERSION, -} from '@opentelemetry/semantic-conventions' +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions' import { logs, SeverityNumber } from '@opentelemetry/api-logs' import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api' -import * as path from 'node:path' // Resolve the component's package.json relative to the working directory (the // app root in every Dockerfile and local run) — robust regardless of build layout. -// eslint-disable-next-line @typescript-eslint/no-var-requires -const pkg = require(path.join(process.cwd(), 'package.json')) as { name: string; version: string } +const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) as { + name: string + version: string +} const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT -const useOtlp = typeof otlpEndpoint === 'string' && otlpEndpoint.length > 0 +const useOtlp = typeof otlpEndpoint === 'string' && otlpEndpoint.trim().length > 0 const env = process.env.DEPLOY_ENV ?? process.env.NODE_ENV ?? 'development' if (process.env.OTEL_DIAG === 'true') { - diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO) + diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO) } -const resource = resourceFromAttributes({ +// No SDK, instrumentation, console bridge or shutdown hooks without an exporter. +if (useOtlp || process.env.OTEL_CONSOLE_EXPORTERS === 'true') { + const resource = resourceFromAttributes({ [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? pkg.name, [ATTR_SERVICE_VERSION]: pkg.version, - 'deployment.environment': env, -}) + 'deployment.environment': env + }) -const traceExporter: SpanExporter = useOtlp - ? new OTLPTraceExporter() - : new ConsoleSpanExporter() -const metricExporter: PushMetricExporter = useOtlp + const traceExporter: SpanExporter = useOtlp ? new OTLPTraceExporter() : new ConsoleSpanExporter() + const metricExporter: PushMetricExporter = useOtlp ? new OTLPMetricExporter() : new ConsoleMetricExporter() -const logExporter: LogRecordExporter = useOtlp + const logExporter: LogRecordExporter = useOtlp ? new OTLPLogExporter() : new ConsoleLogRecordExporter() -const logRecordProcessor = useOtlp + const logRecordProcessor = useOtlp ? new BatchLogRecordProcessor({ exporter: logExporter }) : new SimpleLogRecordProcessor({ exporter: logExporter }) -const sdk = new NodeSDK({ + const sdk = new NodeSDK({ resource, spanProcessors: [ - useOtlp - ? new BatchSpanProcessor(traceExporter) - : new SimpleSpanProcessor(traceExporter), + useOtlp ? new BatchSpanProcessor(traceExporter) : new SimpleSpanProcessor(traceExporter) ], metricReader: new PeriodicExportingMetricReader({ - exporter: metricExporter, - exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL ?? 60000), + exporter: metricExporter, + exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL ?? 60000) }), logRecordProcessors: [logRecordProcessor], instrumentations: [ - getNodeAutoInstrumentations({ - '@opentelemetry/instrumentation-fs': { enabled: false }, - }), - new RuntimeNodeInstrumentation(), - ], -}) + getNodeAutoInstrumentations({ + '@opentelemetry/instrumentation-fs': { enabled: false } + }), + new RuntimeNodeInstrumentation() + ] + }) -sdk.start() + sdk.start() -// Capture clean console refs before patching, for telemetry's own messages. -const rawInfo = console.info.bind(console) -const rawError = console.error.bind(console) + // Capture clean console refs before patching, for telemetry's own messages. + const rawInfo = console.info.bind(console) + const rawError = console.error.bind(console) -// Bridge stray console.* calls into OTel logs so nothing is lost while code is -// migrated to the structured (pino) logger. -const logger = logs.getLogger(pkg.name, pkg.version) -const SEVERITY: Record = { - debug: SeverityNumber.DEBUG, - info: SeverityNumber.INFO, - log: SeverityNumber.INFO, - warn: SeverityNumber.WARN, - error: SeverityNumber.ERROR, -} -for (const method of ['debug', 'info', 'log', 'warn', 'error'] as const) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const original = (console as any)[method].bind(console) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(console as any)[method] = (...args: any[]) => { + // Bridge stray console.* calls into OTel logs so nothing is lost while code is + // migrated to the structured (pino) logger. + if (useOtlp) { + const logger = logs.getLogger(pkg.name, pkg.version) + const SEVERITY: Record = { + debug: SeverityNumber.DEBUG, + info: SeverityNumber.INFO, + log: SeverityNumber.INFO, + warn: SeverityNumber.WARN, + error: SeverityNumber.ERROR + } + for (const method of ['debug', 'info', 'log', 'warn', 'error'] as const) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const original = (console as any)[method].bind(console) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(console as any)[method] = (...args: any[]) => { try { - logger.emit({ - severityNumber: SEVERITY[method], - severityText: method.toUpperCase(), - body: args - .map((a) => - typeof a === 'string' ? a : (() => { - try { return JSON.stringify(a) } catch { return String(a) } - })() - ) - .join(' '), - }) + logger.emit({ + severityNumber: SEVERITY[method], + severityText: method.toUpperCase(), + body: args + .map(a => + typeof a === 'string' + ? a + : (() => { + try { + return JSON.stringify(a) + } catch { + return String(a) + } + })() + ) + .join(' ') + }) } catch { - /* never let telemetry break logging */ + /* never let telemetry break logging */ } original(...args) + } } -} + } -// Flush telemetry on shutdown. Only force-exit if the app registered no handler -// of its own for this signal — otherwise we let the app's shutdown drive exit. -const shutdown = (signal: NodeJS.Signals) => { - sdk.shutdown() - .then(() => rawInfo(`[otel] flushed (${signal})`)) - .catch((err: unknown) => rawError('[otel] shutdown error', err)) - .finally(() => { - if (process.listeners(signal).length <= 1) process.exit(0) - }) + // Flush telemetry on shutdown. Only force-exit if the app registered no handler + // of its own for this signal — otherwise we let the app's shutdown drive exit. + const shutdown = (signal: NodeJS.Signals) => { + sdk + .shutdown() + .then(() => rawInfo(`[otel] flushed (${signal})`)) + .catch((err: unknown) => rawError('[otel] shutdown error', err)) + .finally(() => { + if (process.listeners(signal).length <= 1) process.exit(0) + }) + } + process.on('SIGTERM', () => shutdown('SIGTERM')) + process.on('SIGINT', () => shutdown('SIGINT')) } -process.on('SIGTERM', () => shutdown('SIGTERM')) -process.on('SIGINT', () => shutdown('SIGINT')) diff --git a/infra/wallet-infra/.env.example b/infra/wallet-infra/.env.example index 5d19fa0ee..75bba882a 100644 --- a/infra/wallet-infra/.env.example +++ b/infra/wallet-infra/.env.example @@ -36,6 +36,8 @@ WALLET_STORAGE_MONITOR_ADMIN_PORT=8082 # OpenTelemetry. OTLP headers may contain credentials and must come from the # deployment secret manager. OTEL_SERVICE_NAME=wallet-infra +# No collector: telemetry is off. Opt in below for local console diagnostics. +OTEL_CONSOLE_EXPORTERS=false OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_HEADERS= OTEL_RESOURCE_ATTRIBUTES= diff --git a/infra/wallet-infra/docker-compose.yml b/infra/wallet-infra/docker-compose.yml index 796ce1262..e95520534 100644 --- a/infra/wallet-infra/docker-compose.yml +++ b/infra/wallet-infra/docker-compose.yml @@ -36,6 +36,7 @@ services: KNEX_DB_CONNECTION: '{"host":"mysql","user":"root","password":"rootPass","database":"wallet_storage","port":3306}' # OpenTelemetry — point at any OTLP/HTTP collector. Unset => console exporters. OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} + OTEL_CONSOLE_EXPORTERS: ${OTEL_CONSOLE_EXPORTERS:-false} OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-} OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-wallet-infra} OTEL_RESOURCE_ATTRIBUTES: ${OTEL_RESOURCE_ATTRIBUTES:-} diff --git a/infra/wallet-infra/src/telemetry.ts b/infra/wallet-infra/src/telemetry.ts index 4b99cc667..a40bc2eba 100644 --- a/infra/wallet-infra/src/telemetry.ts +++ b/infra/wallet-infra/src/telemetry.ts @@ -1,11 +1,13 @@ /** - * OpenTelemetry bootstrap (ESM) — preloaded before app code via - * `node --import ./out/src/telemetry.js`. + * OpenTelemetry bootstrap — preloaded before application code. + * Canonical source: infra/overlay-server/src/telemetry.ts. + * Run pnpm sync:service-runtime-copies after editing; six standalone contexts + * receive byte-for-byte copies governed by service-runtime-copy-policy.json. * * Emits traces, metrics and logs. All wiring is driven by OTEL_* env vars; when - * OTEL_EXPORTER_OTLP_ENDPOINT is unset we fall back to console exporters so the - * process always boots (dev-safe). Runtime (heap/GC/event-loop) metrics are - * enabled to support memory-leak diagnosis. + * OTEL_EXPORTER_OTLP_ENDPOINT is unset telemetry stays off unless + * OTEL_CONSOLE_EXPORTERS=true explicitly enables local console diagnostics. + * Application logging remains available in every mode. * * ESM note: we deliberately do NOT register the import-in-the-middle loader * hook (@opentelemetry/instrumentation/hook.mjs). That hook rebuilds the named @@ -17,7 +19,7 @@ * * See docs/superpowers/specs/2026-06-22-infra-opentelemetry-design.md. */ -import { createRequire } from 'node:module' +import { readFileSync } from 'node:fs' import { join } from 'node:path' import { NodeSDK } from '@opentelemetry/sdk-node' @@ -44,127 +46,122 @@ import { type LogRecordExporter } from '@opentelemetry/sdk-logs' import { resourceFromAttributes } from '@opentelemetry/resources' -import { - ATTR_SERVICE_NAME, - ATTR_SERVICE_VERSION -} from '@opentelemetry/semantic-conventions' +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions' import { logs, SeverityNumber } from '@opentelemetry/api-logs' import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api' // Resolve the component's package.json relative to the working directory (the // app root in every Dockerfile and local run) — robust regardless of build layout. -const require = createRequire(import.meta.url) -const pkg = require(join(process.cwd(), 'package.json')) as { +const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) as { name: string version: string } const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT -const useOtlp = typeof otlpEndpoint === 'string' && otlpEndpoint.length > 0 +const useOtlp = typeof otlpEndpoint === 'string' && otlpEndpoint.trim().length > 0 const env = process.env.DEPLOY_ENV ?? process.env.NODE_ENV ?? 'development' if (process.env.OTEL_DIAG === 'true') { diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO) } -const resource = resourceFromAttributes({ - [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? pkg.name, - [ATTR_SERVICE_VERSION]: pkg.version, - 'deployment.environment': env -}) +// No SDK, instrumentation, console bridge or shutdown hooks without an exporter. +if (useOtlp || process.env.OTEL_CONSOLE_EXPORTERS === 'true') { + const resource = resourceFromAttributes({ + [ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME ?? pkg.name, + [ATTR_SERVICE_VERSION]: pkg.version, + 'deployment.environment': env + }) -const traceExporter: SpanExporter = useOtlp - ? new OTLPTraceExporter() - : new ConsoleSpanExporter() -const metricExporter: PushMetricExporter = useOtlp - ? new OTLPMetricExporter() - : new ConsoleMetricExporter() -const logExporter: LogRecordExporter = useOtlp - ? new OTLPLogExporter() - : new ConsoleLogRecordExporter() + const traceExporter: SpanExporter = useOtlp ? new OTLPTraceExporter() : new ConsoleSpanExporter() + const metricExporter: PushMetricExporter = useOtlp + ? new OTLPMetricExporter() + : new ConsoleMetricExporter() + const logExporter: LogRecordExporter = useOtlp + ? new OTLPLogExporter() + : new ConsoleLogRecordExporter() -const logRecordProcessor = useOtlp - ? new BatchLogRecordProcessor({ exporter: logExporter }) - : new SimpleLogRecordProcessor({ exporter: logExporter }) + const logRecordProcessor = useOtlp + ? new BatchLogRecordProcessor({ exporter: logExporter }) + : new SimpleLogRecordProcessor({ exporter: logExporter }) -const sdk = new NodeSDK({ - resource, - spanProcessors: [ - useOtlp - ? new BatchSpanProcessor(traceExporter) - : new SimpleSpanProcessor(traceExporter) - ], - metricReader: new PeriodicExportingMetricReader({ - exporter: metricExporter, - exportIntervalMillis: Number( - process.env.OTEL_METRIC_EXPORT_INTERVAL ?? 60000 - ) - }), - logRecordProcessors: [logRecordProcessor], - instrumentations: [ - getNodeAutoInstrumentations({ - '@opentelemetry/instrumentation-fs': { enabled: false } + const sdk = new NodeSDK({ + resource, + spanProcessors: [ + useOtlp ? new BatchSpanProcessor(traceExporter) : new SimpleSpanProcessor(traceExporter) + ], + metricReader: new PeriodicExportingMetricReader({ + exporter: metricExporter, + exportIntervalMillis: Number(process.env.OTEL_METRIC_EXPORT_INTERVAL ?? 60000) }), - new RuntimeNodeInstrumentation() - ] -}) + logRecordProcessors: [logRecordProcessor], + instrumentations: [ + getNodeAutoInstrumentations({ + '@opentelemetry/instrumentation-fs': { enabled: false } + }), + new RuntimeNodeInstrumentation() + ] + }) -await sdk.start() + sdk.start() -// Capture clean console refs before patching, for telemetry's own messages. -const rawInfo = console.info.bind(console) -const rawError = console.error.bind(console) + // Capture clean console refs before patching, for telemetry's own messages. + const rawInfo = console.info.bind(console) + const rawError = console.error.bind(console) -// Bridge stray console.* calls into OTel logs so nothing is lost while code is -// migrated to the structured (pino) logger. -const logger = logs.getLogger(pkg.name, pkg.version) -const SEVERITY: Record = { - debug: SeverityNumber.DEBUG, - info: SeverityNumber.INFO, - log: SeverityNumber.INFO, - warn: SeverityNumber.WARN, - error: SeverityNumber.ERROR -} -for (const method of ['debug', 'info', 'log', 'warn', 'error'] as const) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const original = (console as any)[method].bind(console) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ;(console as any)[method] = (...args: any[]) => { - try { - logger.emit({ - severityNumber: SEVERITY[method], - severityText: method.toUpperCase(), - body: args - .map(a => - typeof a === 'string' - ? a - : (() => { - try { - return JSON.stringify(a) - } catch { - return String(a) - } - })() - ) - .join(' ') - }) - } catch { - /* never let telemetry break logging */ + // Bridge stray console.* calls into OTel logs so nothing is lost while code is + // migrated to the structured (pino) logger. + if (useOtlp) { + const logger = logs.getLogger(pkg.name, pkg.version) + const SEVERITY: Record = { + debug: SeverityNumber.DEBUG, + info: SeverityNumber.INFO, + log: SeverityNumber.INFO, + warn: SeverityNumber.WARN, + error: SeverityNumber.ERROR + } + for (const method of ['debug', 'info', 'log', 'warn', 'error'] as const) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const original = (console as any)[method].bind(console) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(console as any)[method] = (...args: any[]) => { + try { + logger.emit({ + severityNumber: SEVERITY[method], + severityText: method.toUpperCase(), + body: args + .map(a => + typeof a === 'string' + ? a + : (() => { + try { + return JSON.stringify(a) + } catch { + return String(a) + } + })() + ) + .join(' ') + }) + } catch { + /* never let telemetry break logging */ + } + original(...args) + } } - original(...args) } -} -// Flush telemetry on shutdown. Only force-exit if the app registered no handler -// of its own for this signal — otherwise we let the app's shutdown drive exit. -const shutdown = (signal: NodeJS.Signals) => { - sdk - .shutdown() - .then(() => rawInfo(`[otel] flushed (${signal})`)) - .catch((err: unknown) => rawError('[otel] shutdown error', err)) - .finally(() => { - if (process.listeners(signal).length <= 1) process.exit(0) - }) + // Flush telemetry on shutdown. Only force-exit if the app registered no handler + // of its own for this signal — otherwise we let the app's shutdown drive exit. + const shutdown = (signal: NodeJS.Signals) => { + sdk + .shutdown() + .then(() => rawInfo(`[otel] flushed (${signal})`)) + .catch((err: unknown) => rawError('[otel] shutdown error', err)) + .finally(() => { + if (process.listeners(signal).length <= 1) process.exit(0) + }) + } + process.on('SIGTERM', () => shutdown('SIGTERM')) + process.on('SIGINT', () => shutdown('SIGINT')) } -process.on('SIGTERM', () => shutdown('SIGTERM')) -process.on('SIGINT', () => shutdown('SIGINT')) diff --git a/packages/messaging/message-box-client/CHANGELOG.md b/packages/messaging/message-box-client/CHANGELOG.md index f0a5d1832..ed651dcac 100644 --- a/packages/messaging/message-box-client/CHANGELOG.md +++ b/packages/messaging/message-box-client/CHANGELOG.md @@ -13,6 +13,13 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +### 2.5.1 candidate — payment acknowledgment ordering + +- Internalize notification payments with the configured originator before acknowledgment. +- Require affirmative wallet acceptance in notification and PeerPay paths. +- For refundable payments, internalize, send the refund, then acknowledge. +- Keep issue #503 open for envelope/outcome handling and durable refund semantics. + ### Added - Added an optional `socketOptions` client option, forwarded to diff --git a/packages/messaging/message-box-client/README.md b/packages/messaging/message-box-client/README.md index e527c127d..508ebeb62 100644 --- a/packages/messaging/message-box-client/README.md +++ b/packages/messaging/message-box-client/README.md @@ -340,3 +340,26 @@ TS Stack first-party material is under the [Open BSV License Version 6](./LICENS The UMD bundle incorporates separately licensed SDK material; keep [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md) and [LICENSES/](./LICENSES/) with the bundle. + +## Payment acceptance and acknowledgment ordering + +`acknowledgeNotification` checks the original notification envelope, internalizes +a present recipient payment with the configured originator, and acknowledges +only after the wallet returns `accepted: true`. A notification without a +payment can be acknowledged immediately. Failures, incomplete envelopes and payments with no wallet-payment outputs +remain queued. Its boolean return contract is unchanged: `false` can +mean no payment or a retained failed payment. + +`acceptPayment` also requires affirmative wallet acceptance before acknowledgment. +For refundable amounts, `rejectPayment` internalizes first, sends the refund, +and then acknowledges. A failed internalization prevents both refund and +acknowledgment; a failed refund send leaves the message queued. The existing +small-payment policy is unchanged. + +This is the initial ordering remediation for [issue #503](https://github.com/bsv-blockchain/ts-stack/issues/503). +It does not provide a durable refund journal or exactly-once delivery. Reconcile +uncertain refund-send outcomes before retrying, since a send may have completed +before its response was lost. Payment-envelope retention and outcome reporting +through `listMessages`/`listMessagesLite`, basket-insertion policy, and resumable +refund semantics remain open. Use the original envelope for notification payment +processing; a plain acknowledgment is not evidence that a payment was accepted. diff --git a/packages/messaging/message-box-client/browser-budget.json b/packages/messaging/message-box-client/browser-budget.json index 5e6edc3b3..51d74f87d 100644 --- a/packages/messaging/message-box-client/browser-budget.json +++ b/packages/messaging/message-box-client/browser-budget.json @@ -34,8 +34,8 @@ "path": "dist/umd/bundle.js", "global": "messageBoxClient", "maximumBytes": { - "raw": 511000, - "gzip": 147000, + "raw": 513000, + "gzip": 147500, "brotli": 124000 } } diff --git a/packages/messaging/message-box-client/package.json b/packages/messaging/message-box-client/package.json index 3252f715a..a90369927 100644 --- a/packages/messaging/message-box-client/package.json +++ b/packages/messaging/message-box-client/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/message-box-client", - "version": "2.5.0", + "version": "2.5.1", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/messaging/message-box-client/src/MessageBoxClient.ts b/packages/messaging/message-box-client/src/MessageBoxClient.ts index e370c8640..6d622ee89 100644 --- a/packages/messaging/message-box-client/src/MessageBoxClient.ts +++ b/packages/messaging/message-box-client/src/MessageBoxClient.ts @@ -2148,9 +2148,11 @@ export class MessageBoxClient { * payment contained within it. * * This method: - * 1. Calls `acknowledgeMessage()` to remove the message from the server's queue. - * 2. Checks the message body for embedded payment data. - * 3. If a recipient payment exists, attempts to internalize it into the wallet. + * 1. Checks the original notification body for embedded recipient payment data. + * 2. Internalizes a present payment and requires `accepted: true` from the wallet. + * 3. Acknowledges after acceptance, or immediately when no payment is present. + * Failed, incomplete, or unsupported payments remain queued. The boolean return + * contract is unchanged; `false` can mean no payment or a retained failed payment. * * This is a convenience wrapper for acknowledgment and payment handling specifically for messages * representing notifications. @@ -2160,8 +2162,6 @@ export class MessageBoxClient { * console.log(success ? 'Payment received' : 'No payment or failed') */ async acknowledgeNotification(message: PeerMessage): Promise { - await this.acknowledgeMessage({ messageIds: [message.messageId] }) - const parsedBody: unknown = typeof message.body === 'string' ? this.tryParse(message.body) : message.body @@ -2197,24 +2197,34 @@ export class MessageBoxClient { if (tx == null || tx.length === 0) { throw new Error('Message payment transaction must be a non-empty BRC-100 byte array') } - const internalizeResult = await this.walletClient.internalizeAction({ - tx, - outputs: recipientOutputs, - description: paymentData.description ?? 'MessageBox recipient payment' - }) + const internalizeResult = await this.walletClient.internalizeAction( + { + tx, + outputs: recipientOutputs, + description: paymentData.description ?? 'MessageBox recipient payment' + }, + this.originator + ) - if (internalizeResult.accepted) { + if (internalizeResult.accepted === true) { Logger.log('[MB CLIENT] Successfully internalized recipient payment') + await this.acknowledgeMessage({ messageIds: [message.messageId] }) return true } else { Logger.warn('[MB CLIENT] Recipient payment internalization was not accepted') return false } } catch (paymentError) { - Logger.error('[MB CLIENT ERROR] Failed to internalize recipient payment:', paymentError) + Logger.error( + '[MB CLIENT ERROR] Failed to process or acknowledge recipient payment:', + paymentError + ) return false } } + if (paymentData == null) { + await this.acknowledgeMessage({ messageIds: [message.messageId] }) + } return false } diff --git a/packages/messaging/message-box-client/src/PeerPayClient.ts b/packages/messaging/message-box-client/src/PeerPayClient.ts index 17c61a443..1a71d0cfa 100644 --- a/packages/messaging/message-box-client/src/PeerPayClient.ts +++ b/packages/messaging/message-box-client/src/PeerPayClient.ts @@ -405,53 +405,65 @@ export class PeerPayClient extends MessageBoxClient { */ async acceptPayment(payment: IncomingPayment): Promise { try { - Logger.log(`[PP CLIENT] Processing payment: ${stringifyBRC100(payment, 2)}`) + const result = await this.internalizePayment(payment) + await this.acknowledgeMessage({ messageIds: [payment.messageId] }) + return result + } catch (error) { + Logger.error(`[PP CLIENT] Error accepting payment: ${String(error)}`) + return 'Unable to receive payment!' + } + } - const transaction = toBRC100PortableByteArray(payment.token.transaction) - if (transaction == null || transaction.length === 0) { - throw new Error('Payment transaction must be a non-empty BRC-100 byte array') - } + private async internalizePayment( + payment: IncomingPayment + ): Promise<{ payment: IncomingPayment; paymentResult: unknown }> { + Logger.log(`[PP CLIENT] Processing payment: ${stringifyBRC100(payment, 2)}`) - const acceptResult = await this.settlementModule.acceptSettlement( - { - threadId: 'peerpay', - sender: payment.sender, - settlement: { - customInstructions: { - derivationPrefix: payment.token.customInstructions.derivationPrefix, - derivationSuffix: payment.token.customInstructions.derivationSuffix - }, - transaction, - amountSatoshis: payment.token.amount, - outputIndex: payment.token.outputIndex ?? STANDARD_PAYMENT_OUTPUT_INDEX - } - }, - { - wallet: this.peerPayWalletClient, - originator: this.originator, - now: () => Date.now(), - logger: Logger - } - ) + const transaction = toBRC100PortableByteArray(payment.token.transaction) + if (transaction == null || transaction.length === 0) { + throw new Error('Payment transaction must be a non-empty BRC-100 byte array') + } - if (acceptResult.action === 'terminate') { - throw new Error(acceptResult.termination.message) + const acceptResult = await this.settlementModule.acceptSettlement( + { + threadId: 'peerpay', + sender: payment.sender, + settlement: { + customInstructions: { + derivationPrefix: payment.token.customInstructions.derivationPrefix, + derivationSuffix: payment.token.customInstructions.derivationSuffix + }, + transaction, + amountSatoshis: payment.token.amount, + outputIndex: payment.token.outputIndex ?? STANDARD_PAYMENT_OUTPUT_INDEX + } + }, + { + wallet: this.peerPayWalletClient, + originator: this.originator, + now: () => Date.now(), + logger: Logger } + ) - const paymentResult = acceptResult.receiptData?.internalizeResult - - Logger.log( - `[PP CLIENT] Payment internalized successfully: ${stringifyBRC100(paymentResult, 2)}` - ) - Logger.log(`[PP CLIENT] Acknowledging payment with messageId: ${payment.messageId}`) - - await this.acknowledgeMessage({ messageIds: [payment.messageId] }) + if (acceptResult.action === 'terminate') { + throw new Error(acceptResult.termination.message) + } - return { payment, paymentResult } - } catch (error) { - Logger.error(`[PP CLIENT] Error accepting payment: ${String(error)}`) - return 'Unable to receive payment!' + const paymentResult = acceptResult.receiptData?.internalizeResult + if ( + paymentResult == null || + typeof paymentResult !== 'object' || + !('accepted' in paymentResult) || + paymentResult.accepted !== true + ) { + throw new Error('Wallet did not accept the payment.') } + + Logger.log( + `[PP CLIENT] Payment internalized successfully: ${stringifyBRC100(paymentResult, 2)}` + ) + return { payment, paymentResult } } /** @@ -459,7 +471,10 @@ export class PeerPayClient extends MessageBoxClient { * * If the payment amount is too small (less than 1000 satoshis after deducting the fee), * the payment is simply acknowledged and ignored. Otherwise, the function first accepts - * the payment, then sends a new transaction refunding the sender. + * the payment, then sends a new transaction refunding the sender, and acknowledges + * only after the refund send succeeds. Internalization failure prevents the refund. + * This ordering is not a durable refund journal; reconcile uncertain send outcomes + * before retrying, because the current protocol provides no exactly-once refund guarantee. * * @param {IncomingPayment} payment - The payment object containing transaction details. * @returns {Promise} Resolves when the payment is either acknowledged or refunded. @@ -503,7 +518,7 @@ export class PeerPayClient extends MessageBoxClient { } Logger.log('[PP CLIENT] Accepting payment before refunding...') - await this.acceptPayment(payment) + await this.internalizePayment(payment) Logger.log( `[PP CLIENT] Sending refund of ${payment.token.amount - 1000} to ${payment.sender}...` diff --git a/packages/messaging/message-box-client/src/__tests/MessageBoxClientHardening.test.ts b/packages/messaging/message-box-client/src/__tests/MessageBoxClientHardening.test.ts index 218d057f0..9624e546b 100644 --- a/packages/messaging/message-box-client/src/__tests/MessageBoxClientHardening.test.ts +++ b/packages/messaging/message-box-client/src/__tests/MessageBoxClientHardening.test.ts @@ -874,6 +874,48 @@ describe('MessageBoxClient hardening branches', () => { ).resolves.toBe(false) }) + it('acknowledges a notification only after wallet acceptance and passes its originator', async () => { + const order: string[] = [] + const acknowledge = jest.spyOn(client, 'acknowledgeMessage').mockImplementation(async () => { + order.push('ack') + return 'ok' + }) + ;(client as any).originator = 'app.example' + const payment = { tx: [1, 2, 3], outputs: [{ outputIndex: 0, protocol: 'wallet payment' }] } + const message = { + messageId: 'payment-notice', + sender: recipientA, + body: { message: 'hello', payment } + } as any + wallet.internalizeAction.mockImplementation(async () => { + order.push('internalize') + return { accepted: true } + }) + await expect(client.acknowledgeNotification(message)).resolves.toBe(true) + expect(order).toEqual(['internalize', 'ack']) + expect(wallet.internalizeAction).toHaveBeenCalledWith(expect.anything(), 'app.example') + acknowledge.mockClear() + for (const result of [{ accepted: false }, {}]) { + wallet.internalizeAction.mockResolvedValueOnce(result as never) + await expect(client.acknowledgeNotification(message)).resolves.toBe(false) + } + wallet.internalizeAction.mockRejectedValueOnce(new Error('wallet unavailable')) + await expect(client.acknowledgeNotification(message)).resolves.toBe(false) + await expect( + client.acknowledgeNotification({ + ...message, + body: { message: 'hello', payment: { tx: [1] } } + }) + ).resolves.toBe(false) + await expect( + client.acknowledgeNotification({ + ...message, + body: { message: 'hello', payment: { ...payment, outputs: [] } } + }) + ).resolves.toBe(false) + expect(acknowledge).not.toHaveBeenCalled() + }) + it.each([ [{ fcmToken: '' }, 'fcmToken is required'], [{ fcmToken: 'x'.repeat(501) }, 'must not exceed 500'], diff --git a/packages/messaging/message-box-client/src/__tests/PeerPayClientUnit.test.ts b/packages/messaging/message-box-client/src/__tests/PeerPayClientUnit.test.ts index 4f7774c7d..8e8f39cf3 100644 --- a/packages/messaging/message-box-client/src/__tests/PeerPayClientUnit.test.ts +++ b/packages/messaging/message-box-client/src/__tests/PeerPayClientUnit.test.ts @@ -247,7 +247,7 @@ describe('PeerPayClient Unit Tests', () => { // Test: rejectPayment describe('rejectPayment', () => { it('should refund payment minus fee', async () => { - jest.spyOn(peerPayClient, 'acceptPayment').mockResolvedValue(undefined) + mockWalletClient.internalizeAction.mockResolvedValue({ accepted: true }) jest.spyOn(peerPayClient, 'sendPayment').mockResolvedValue(undefined) jest.spyOn(peerPayClient, 'acknowledgeMessage').mockResolvedValue('acknowledged') @@ -263,7 +263,7 @@ describe('PeerPayClient Unit Tests', () => { await peerPayClient.rejectPayment(payment) - expect(peerPayClient.acceptPayment).toHaveBeenCalledWith(payment) + expect(mockWalletClient.internalizeAction).toHaveBeenCalled() expect(peerPayClient.sendPayment).toHaveBeenCalledWith({ recipient: 'senderKey', amount: 1000 // Deduct satoshi fee @@ -323,7 +323,7 @@ describe('PeerPayClient Unit Tests', () => { }) it('does not fail a completed refund when the final acknowledgement fails', async () => { - jest.spyOn(peerPayClient, 'acceptPayment').mockResolvedValue(undefined) + mockWalletClient.internalizeAction.mockResolvedValue({ accepted: true }) jest.spyOn(peerPayClient, 'sendPayment').mockResolvedValue(undefined) jest.spyOn(peerPayClient, 'acknowledgeMessage').mockRejectedValue(new Error('offline')) @@ -341,6 +341,57 @@ describe('PeerPayClient Unit Tests', () => { }) }) + describe('payment acknowledgment ordering', () => { + const payment = { + messageId: 'ordered', + sender: 'senderKey', + token: { + customInstructions: { derivationPrefix: 'prefix', derivationSuffix: 'suffix' }, + transaction: [1, 2, 3], + amount: 3000 + } + } + + it('orders internalization, refund, and acknowledgment and retains the message on a failed refund', async () => { + const order: string[] = [] + mockWalletClient.internalizeAction.mockImplementation(async () => { + order.push('internalize') + return { accepted: true } + }) + const send = jest.spyOn(peerPayClient, 'sendPayment').mockImplementation(async () => { + order.push('refund') + return undefined + }) + const acknowledge = jest + .spyOn(peerPayClient, 'acknowledgeMessage') + .mockImplementation(async () => { + order.push('ack') + return 'ok' + }) + await peerPayClient.rejectPayment(payment) + expect(order).toEqual(['internalize', 'refund', 'ack']) + acknowledge.mockClear() + send.mockRejectedValueOnce(new Error('refund unavailable')) + await expect(peerPayClient.rejectPayment(payment)).rejects.toThrow('refund unavailable') + expect(acknowledge).not.toHaveBeenCalled() + }) + + it.each([{ accepted: false }, {}])( + 'does not acknowledge or refund an unaccepted payment: %j', + async result => { + mockWalletClient.internalizeAction.mockResolvedValue(result) + const send = jest.spyOn(peerPayClient, 'sendPayment').mockResolvedValue(undefined) + const acknowledge = jest.spyOn(peerPayClient, 'acknowledgeMessage').mockResolvedValue('ok') + await expect(peerPayClient.acceptPayment(payment)).resolves.toBe( + 'Unable to receive payment!' + ) + await expect(peerPayClient.rejectPayment(payment)).rejects.toThrow() + expect(send).not.toHaveBeenCalled() + expect(acknowledge).not.toHaveBeenCalled() + } + ) + }) + // Test: listIncomingPayments describe('listIncomingPayments', () => { it('should return parsed payment messages', async () => { diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index e07412946..c1f6d19d7 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -214,6 +214,14 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +### 2.7.0 candidate — authentication policy and settlement acceptance + +- Bind standalone certificate validation to local handshake and dynamic request + snapshots while retaining the BRC-103 v0.1 wire shape. Shared session adapters + must retain the new optional local policy fields and coordinate writers. +- Clarify that certificate callbacks observe committed validation and cannot veto it. +- Require `accepted: true` from the wallet before accepting BRC-29 settlement. + - Stop late certificate work and session recovery from dispatching requests after an AuthFetch authentication timeout. Preserve the original gateway error and do not automatically replay failed writes. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index a3a6b104b..b356ee002 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -217,3 +217,32 @@ Incorporated material remains under the separate terms identified in [LICENSES/](./LICENSES/). Keep all three payloads with source and binary distributions. Thank you for being a part of the BSV Blockchain Libraries Project. Let's build the future of BSV Blockchain together! + +## Certificate policy and observer callbacks + +SDK 2.7 records the locally requested certificate policy for each BRC-103 +session. Standalone responses must match one complete outstanding dynamic +request or the session's handshake policy. Policies are copied before sending, +so a later edit of the caller's object does not change validation. Responses +never select their own validation policy. A different dynamic request cannot +satisfy an unmet handshake requirement. + +The v0.1 AuthMessage fields, signatures and encodings are unchanged. Because a +certificateResponse does not echo the request nonce, concurrent responses are +matched against complete local requested sets, not individual request IDs. A +successful dynamic response consumes one matching request; failed validation +keeps it available for retry. Request the intended set explicitly instead of +relying on unrequested certificates. + +`listenForCertificatesReceived` is an observer. The SDK commits certificate +validation and releases its waiters before invoking listeners. A throwing or +rejecting listener stops later listeners and rejects message handling, but does +not roll back validation. Apply the requested certificate policy and explicit +application authorization before performing protected work. + +Custom `AsyncSessionManager` implementations must retain the complete +`PeerSession`, including the optional local `certificatePolicy` and +`pendingCertificateRequests` fields. They are never serialized into AuthMessage. +Peer serializes its own certificate read-modify-write operations; shared stores +must also coordinate writers across instances. Older stored sessions without +these fields use the configured handshake policy. diff --git a/packages/sdk/browser-budget.json b/packages/sdk/browser-budget.json index 569907d1a..bdad11f93 100644 --- a/packages/sdk/browser-budget.json +++ b/packages/sdk/browser-budget.json @@ -16,12 +16,12 @@ "prohibitedExports": [], "maximumBytes": { "vite": { - "raw": 742500, + "raw": 745000, "gzip": 185000, "brotli": 150000 }, "esbuild": { - "raw": 561000, + "raw": 563000, "gzip": 168000, "brotli": 140000 } @@ -30,7 +30,7 @@ "path": "dist/umd/bundle.js", "global": "bsv", "maximumBytes": { - "raw": 556000, + "raw": 558000, "gzip": 162000, "brotli": 136000 } diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 6dfe8bc27..ccf89a00a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/sdk", - "version": "2.6.0", + "version": "2.7.0", "sideEffects": false, "engines": { "node": ">=22" diff --git a/packages/sdk/src/auth/Peer.ts b/packages/sdk/src/auth/Peer.ts index 39c668b63..37a15e4d0 100644 --- a/packages/sdk/src/auth/Peer.ts +++ b/packages/sdk/src/auth/Peer.ts @@ -39,6 +39,7 @@ export class Peer { private readonly transport: Transport private readonly wallet: WalletInterface certificatesToRequest: RequestedCertificateSet + private readonly certificateSessionUpdates = new Map>() private readonly onGeneralMessageReceivedCallbacks: Map< number, (senderPublicKey: string, payload: number[]) => void | Promise @@ -187,8 +188,7 @@ export class Peer { signature } - peerSession.lastUpdate = Date.now() - await this.sessionManager.updateSession(peerSession) + await this.touchSession(peerSession.sessionNonce as string) try { await this.transport.send(generalMessage) @@ -197,6 +197,61 @@ export class Peer { } } + private async touchSession(sessionNonce: string): Promise { + await this.updateCertificateSession(sessionNonce, async session => { + session.lastUpdate = Date.now() + }) + } + + private snapshotCertificatePolicy(policy: RequestedCertificateSet): RequestedCertificateSet { + return { + certifiers: [...policy.certifiers], + types: Object.fromEntries( + Object.entries(policy.types).map(([type, fields]) => [type, [...fields]]) + ) + } + } + + private matchesCertificatePolicy( + certificates: VerifiableCertificate[], + policy: RequestedCertificateSet + ): boolean { + return certificates.every( + certificate => + policy.certifiers.includes(certificate.certifier) && + Object.hasOwn(policy.types, certificate.type) + ) + } + + // Serialize local read-modify-write operations even when a session store returns copies. + // Transport sends and observer callbacks run outside this section to allow loopback delivery. + private async updateCertificateSession( + sessionNonce: string, + update: (session: PeerSession) => Promise + ): Promise { + const manager = this.sessionManager as SessionManager | AsyncSessionManager + const previous = this.certificateSessionUpdates.get(sessionNonce) ?? Promise.resolve() + const pending = previous.then(async () => { + const session = await manager.getSession(sessionNonce) + if (session == null) throw new Error(`Session not found for nonce: ${sessionNonce}`) + const result = await update(session) + await manager.updateSession(session) + return result + }) + const settled = pending.then( + () => {}, + () => {} + ) + this.certificateSessionUpdates.set(sessionNonce, settled) + try { + return await pending + } finally { + if (this.certificateSessionUpdates.get(sessionNonce) === settled) { + this.certificateSessionUpdates.delete(sessionNonce) + } + } + } + /** * Sends a request for certificates to a peer. * This method allows a peer to dynamically request specific certificates after @@ -221,10 +276,13 @@ export class Peer { const peerSession = await this.getAuthenticatedSession(identityKey) + const policy = this.snapshotCertificatePolicy(certificatesToRequest) + const sessionNonce = peerSession.sessionNonce as string + // Prepare the message const requestNonce = Utils.toBase64(Random(32)) const { signature } = await this.wallet.createSignature({ - data: Peer.utf8ToBytes(JSON.stringify(certificatesToRequest)), + data: Peer.utf8ToBytes(JSON.stringify(policy)), protocolID: [2, 'auth message signature'], keyID: `${requestNonce} ${peerSession.peerNonce ?? ''}`, counterparty: peerSession.peerIdentityKey @@ -237,17 +295,23 @@ export class Peer { nonce: requestNonce, initialNonce: peerSession.sessionNonce, yourNonce: peerSession.peerNonce, - requestedCertificates: certificatesToRequest, + requestedCertificates: policy, signature } - // Update last-used timestamp - peerSession.lastUpdate = Date.now() - await this.sessionManager.updateSession(peerSession) + // Store our policy before send: an in-memory transport may respond synchronously. + await this.updateCertificateSession(sessionNonce, async session => { + session.pendingCertificateRequests ??= {} + session.pendingCertificateRequests[requestNonce] = this.snapshotCertificatePolicy(policy) + session.lastUpdate = Date.now() + }) try { await this.transport.send(certRequestMessage) } catch (error: unknown) { + await this.updateCertificateSession(sessionNonce, async session => { + delete session.pendingCertificateRequests?.[requestNonce] + }) this.propagateTransportError(peerSession.peerIdentityKey, error) } } @@ -313,7 +377,11 @@ export class Peer { } /** - * Registers a callback to listen for certificates received from peers. + * Registers an observer for certificates received from peers, not an acceptance hook. + * Local certificate validation is committed and its waiters are released before observers + * run. Throwing rejects message handling and stops subsequent observers; it does not + * roll back validation or revoke the session. Apply acceptance policy through the locally + * requested certificate set and explicit application authorization before protected work. * * @param {(senderPublicKey: string, certs: VerifiableCertificate[]) => void | Promise} callback - The function to call when certificates are received. * @returns {number} The ID of the callback listener. @@ -373,15 +441,16 @@ export class Peer { ): Promise { const sessionNonce = await createNonce(this.wallet, undefined, this.originator) + const certificatePolicy = this.snapshotCertificatePolicy(this.certificatesToRequest) const now = Date.now() - const certificatesRequired = - this.certificatesToRequest.certifiers.length > 0 + const certificatesRequired = certificatePolicy.certifiers.length > 0 await this.sessionManager.addSession({ isAuthenticated: false, sessionNonce, peerIdentityKey: identityKey, lastUpdate: now, + certificatePolicy, certificatesRequired, certificatesValidated: !certificatesRequired }) @@ -391,7 +460,7 @@ export class Peer { messageType: 'initialRequest', identityKey: await this.getIdentityPublicKey(), initialNonce: sessionNonce, - requestedCertificates: this.certificatesToRequest + requestedCertificates: this.snapshotCertificatePolicy(certificatePolicy) } // Register before sending: an in-memory or otherwise synchronous transport @@ -534,11 +603,11 @@ export class Peer { } const sessionNonce = await createNonce(this.wallet, undefined, this.originator) + const certificatePolicy = this.snapshotCertificatePolicy(this.certificatesToRequest) const now = Date.now() const certificatesRequired = - Array.isArray(this.certificatesToRequest?.certifiers) && - this.certificatesToRequest.certifiers.length > 0 + Array.isArray(certificatePolicy.certifiers) && certificatePolicy.certifiers.length > 0 await this.sessionManager.addSession({ isAuthenticated: true, @@ -546,6 +615,7 @@ export class Peer { peerNonce: message.initialNonce, peerIdentityKey: message.identityKey, lastUpdate: now, + certificatePolicy, certificatesRequired, certificatesValidated: !certificatesRequired }) @@ -591,7 +661,7 @@ export class Peer { initialNonce: sessionNonce, yourNonce: message.initialNonce, certificates: certificatesToInclude, - requestedCertificates: this.certificatesToRequest, + requestedCertificates: this.snapshotCertificatePolicy(certificatePolicy), signature } @@ -648,9 +718,8 @@ export class Peer { peerSession.peerIdentityKey = message.identityKey peerSession.isAuthenticated = true - peerSession.certificatesRequired = - Array.isArray(this.certificatesToRequest?.certifiers) && - this.certificatesToRequest.certifiers.length > 0 + peerSession.certificatePolicy ??= this.snapshotCertificatePolicy(this.certificatesToRequest) + peerSession.certificatesRequired = peerSession.certificatePolicy.certifiers.length > 0 // IMPORTANT: validation defaults to false if certs are required peerSession.certificatesValidated = !peerSession.certificatesRequired @@ -671,15 +740,18 @@ export class Peer { ) { return } - await validateCertificates(this.wallet, message, this.certificatesToRequest, this.originator) - - peerSession.certificatesValidated = true - peerSession.lastUpdate = Date.now() - await this.sessionManager.updateSession(peerSession) - - if (peerSession.sessionNonce != null) { - this.resolveCertificateValidation(peerSession.sessionNonce) - } + const sessionNonce = peerSession.sessionNonce as string + await this.updateCertificateSession(sessionNonce, async session => { + await validateCertificates( + this.wallet, + message, + session.certificatePolicy ?? this.certificatesToRequest, + this.originator + ) + session.certificatesValidated = true + session.lastUpdate = Date.now() + }) + this.resolveCertificateValidation(sessionNonce) for (const callback of this.onCertificatesReceivedCallbacks.values()) { await callback(message.identityKey, message.certificates as VerifiableCertificate[]) @@ -765,11 +837,10 @@ export class Peer { } // Update usage - peerSession.lastUpdate = Date.now() - await this.sessionManager.updateSession(peerSession) + await this.touchSession(peerSession.sessionNonce as string) if ( - (message.requestedCertificates != null) && + message.requestedCertificates != null && Array.isArray(message.requestedCertificates.certifiers) && message.requestedCertificates.certifiers.length > 0 ) { @@ -826,8 +897,7 @@ export class Peer { } // Update usage - peerSession.lastUpdate = Date.now() - await this.sessionManager.updateSession(peerSession) + await this.touchSession(peerSession.sessionNonce as string) try { await this.transport.send(certificateResponse) @@ -856,6 +926,13 @@ export class Peer { throw new Error(`Session not found for nonce: ${message.yourNonce as string}`) } + if ( + typeof peerSession.peerIdentityKey === 'string' && + peerSession.peerIdentityKey !== message.identityKey + ) { + throw new Error('Certificate response identity does not match the authenticated session.') + } + // Validate message signature const { valid } = await this.wallet.verifySignature({ data: Peer.utf8ToBytes(JSON.stringify(message.certificates)), @@ -870,23 +947,29 @@ export class Peer { ) } - // Validate certificates only if they were actually provided if (Array.isArray(message.certificates) && message.certificates.length > 0) { - await validateCertificates( - this.wallet, - message, - message.requestedCertificates, - this.originator - ) - - peerSession.certificatesValidated = true - peerSession.lastUpdate = Date.now() - await this.sessionManager.updateSession(peerSession) - - // Resolve any promises waiting for certificate validation - if (peerSession.sessionNonce != null) { - this.resolveCertificateValidation(peerSession.sessionNonce) - } + const sessionNonce = peerSession.sessionNonce as string + const validated = await this.updateCertificateSession(sessionNonce, async session => { + const certificates = message.certificates as VerifiableCertificate[] + const handshakePolicy = session.certificatePolicy ?? this.certificatesToRequest + // v0.1 responses do not echo the request nonce. Match one complete locally + // recorded policy; never combine certifier/type permissions from separate requests. + const requested = Object.entries(session.pendingCertificateRequests ?? {}).find( + ([, policy]) => this.matchesCertificatePolicy(certificates, policy) + ) + const matchesHandshake = this.matchesCertificatePolicy(certificates, handshakePolicy) + const policy = requested?.[1] ?? (matchesHandshake ? handshakePolicy : undefined) + if (policy == null) { + throw new Error('Certificates do not match a locally requested set for this session.') + } + await validateCertificates(this.wallet, message, policy, this.originator) + if (requested != null) delete session.pendingCertificateRequests?.[requested[0]] + // A separate dynamic request cannot satisfy a different handshake requirement. + if (matchesHandshake) session.certificatesValidated = true + session.lastUpdate = Date.now() + return session.certificatesValidated === true + }) + if (validated) this.resolveCertificateValidation(sessionNonce) } // Notify any listeners @@ -979,8 +1062,7 @@ export class Peer { } // Mark last usage - peerSession.lastUpdate = Date.now() - await this.sessionManager.updateSession(peerSession) + await this.touchSession(peerSession.sessionNonce as string) // Update lastInteractedWithPeer this.lastInteractedWithPeer = message.identityKey diff --git a/packages/sdk/src/auth/__tests/Peer.certificatePolicy.test.ts b/packages/sdk/src/auth/__tests/Peer.certificatePolicy.test.ts new file mode 100644 index 000000000..403f05d54 --- /dev/null +++ b/packages/sdk/src/auth/__tests/Peer.certificatePolicy.test.ts @@ -0,0 +1,313 @@ +import { jest } from '@jest/globals' +import type { AuthMessage, PeerSession, RequestedCertificateSet, Transport } from '../types.js' +import type { WalletInterface } from '../../wallet/Wallet.interfaces.js' +import type { VerifiableCertificate } from '../certificates/VerifiableCertificate.js' +import { SessionManager, type AsyncSessionManager } from '../SessionManager.js' + +import { Peer } from '../Peer.js' +import { validateCertificates, verifyNonce as verifyNonceFunction } from '../utils/index.js' + +jest.mock('../utils/index.js', () => ({ + createNonce: async () => 'generated-session', + verifyNonce: jest.fn(async () => true), + getVerifiableCertificates: async () => [], + validateCertificates: jest.fn(async () => {}) +})) +const validate = jest.mocked(validateCertificates) +const verifyNonce = jest.mocked(verifyNonceFunction) + +const policy = (certifier: string, type: string): RequestedCertificateSet => ({ + certifiers: [certifier], + types: { [type]: ['name'] } +}) +const cert = (certifier: string, type: string): VerifiableCertificate => + ({ certifier, type, subject: 'remote' }) as VerifiableCertificate + +function response(certificates: VerifiableCertificate[], session = 'session'): AuthMessage { + return { + version: '0.1', + messageType: 'certificateResponse', + identityKey: 'remote', + nonce: 'response-nonce', + initialNonce: 'remote-session', + yourNonce: session, + certificates, + signature: [1] + } +} + +async function setup(requested = policy('initial', 'initial'), copyStore = false) { + const backing = new SessionManager() + const store: AsyncSessionManager = { + async addSession(session) { + backing.addSession(structuredClone(session)) + }, + async updateSession(session) { + backing.updateSession(structuredClone(session)) + }, + async getSession(id) { + return structuredClone(backing.getSession(id)) + }, + async removeSession(session) { + backing.removeSession(session) + }, + async hasSession(id) { + return backing.hasSession(id) + } + } + const transport: Transport = { send: jest.fn(async () => {}), async onData() {} } + const wallet = { + getPublicKey: jest.fn(async () => ({ publicKey: 'local' })), + createSignature: jest.fn(async () => ({ signature: [1] })), + verifySignature: jest.fn(async () => ({ valid: true })) + } as unknown as WalletInterface + const peer = new Peer( + wallet, + transport, + requested, + copyStore ? store : backing, + true, + 'app.example' + ) + await peer.ready + const session: PeerSession = { + isAuthenticated: true, + sessionNonce: 'session', + peerNonce: 'remote-session', + peerIdentityKey: 'remote', + lastUpdate: 1, + certificatePolicy: structuredClone(requested), + certificatesRequired: true, + certificatesValidated: false + } + backing.addSession(session) + return { peer, backing, transport, wallet } +} + +beforeEach(() => { + validate.mockReset() + verifyNonce.mockReset() + verifyNonce.mockResolvedValue(true) +}) + +test('uses the locally stored handshake policy without requiring a new response field', async () => { + const { peer, backing } = await setup() + const message = response([cert('initial', 'initial')]) + await (peer as any).processCertificateResponse(message) + expect(validate).toHaveBeenCalledWith( + expect.anything(), + message, + policy('initial', 'initial'), + 'app.example' + ) + expect(backing.getSession('session')?.certificatesValidated).toBe(true) + expect(message).not.toHaveProperty('requestedCertificates') +}) + +test('ignores inbound policy claims and keeps unmatched certificates unvalidated', async () => { + const { peer, backing } = await setup() + const message = { + ...response([cert('different', 'different')]), + requestedCertificates: policy('different', 'different') + } + await expect((peer as any).processCertificateResponse(message)).rejects.toThrow( + 'locally requested set' + ) + expect(validate).not.toHaveBeenCalled() + expect(backing.getSession('session')?.certificatesValidated).toBe(false) +}) + +test('records dynamic policy snapshots and accepts out-of-order responses on an async copy store', async () => { + const { peer, backing, transport } = await setup(undefined, true) + const first = policy('first', 'first') + await Promise.all([ + peer.requestCertificates(first, 'remote'), + peer.requestCertificates(policy('second', 'second'), 'remote') + ]) + first.certifiers[0] = 'changed-by-caller' + first.types.first.push('changed-by-caller') + expect(Object.keys(backing.getSession('session')!.pendingCertificateRequests!)).toHaveLength(2) + await (peer as any).processCertificateResponse(response([cert('second', 'second')])) + await (peer as any).processCertificateResponse(response([cert('first', 'first')])) + expect(validate.mock.calls.map(call => call[2])).toEqual([ + policy('second', 'second'), + policy('first', 'first') + ]) + expect(backing.getSession('session')?.pendingCertificateRequests).toEqual({}) + expect(backing.getSession('session')?.certificatesValidated).toBe(false) + for (const [message] of (transport.send as jest.MockedFunction).mock.calls) { + expect(Object.keys(message).sort()).toEqual([ + 'identityKey', + 'initialNonce', + 'messageType', + 'nonce', + 'requestedCertificates', + 'signature', + 'version', + 'yourNonce' + ]) + } +}) + +test('does not combine permissions from separate requests or another session', async () => { + const { peer, backing } = await setup() + await peer.requestCertificates(policy('first', 'first'), 'remote') + await peer.requestCertificates(policy('second', 'second'), 'remote') + await expect( + (peer as any).processCertificateResponse(response([cert('first', 'second')])) + ).rejects.toThrow('locally requested set') + backing.addSession({ + isAuthenticated: true, + sessionNonce: 'other', + peerIdentityKey: 'remote', + lastUpdate: 2, + certificatePolicy: policy('initial', 'initial') + }) + await expect( + (peer as any).processCertificateResponse(response([cert('first', 'first')], 'other')) + ).rejects.toThrow('locally requested set') + expect(validate).not.toHaveBeenCalled() +}) + +test('preserves a pending request after failed validation and removes it after a valid retry', async () => { + const { peer, backing } = await setup() + await peer.requestCertificates(policy('dynamic', 'dynamic'), 'remote') + validate.mockRejectedValueOnce(new Error('certificate validation failed')) + await expect( + (peer as any).processCertificateResponse(response([cert('dynamic', 'dynamic')])) + ).rejects.toThrow('certificate validation failed') + expect(Object.keys(backing.getSession('session')!.pendingCertificateRequests!)).toHaveLength(1) + await (peer as any).processCertificateResponse(response([cert('dynamic', 'dynamic')])) + expect(backing.getSession('session')?.pendingCertificateRequests).toEqual({}) +}) + +test('records requests before synchronous transport delivery and cleans failed sends', async () => { + const { peer, backing, transport } = await setup() + ;(transport.send as jest.MockedFunction).mockImplementationOnce(async () => { + await (peer as any).processCertificateResponse(response([cert('dynamic', 'dynamic')])) + }) + await peer.requestCertificates(policy('dynamic', 'dynamic'), 'remote') + expect(backing.getSession('session')?.pendingCertificateRequests).toEqual({}) + ;(transport.send as jest.MockedFunction).mockRejectedValueOnce( + new Error('offline') + ) + await expect(peer.requestCertificates(policy('dynamic', 'dynamic'), 'remote')).rejects.toThrow( + 'offline' + ) + expect(backing.getSession('session')?.pendingCertificateRequests).toEqual({}) +}) + +test('observers run after validation and cannot roll it back when they reject', async () => { + const { peer, backing } = await setup() + const later = jest.fn() + peer.listenForCertificatesReceived(async () => { + expect(backing.getSession('session')?.certificatesValidated).toBe(true) + throw new Error('observer failed') + }) + peer.listenForCertificatesReceived(later) + await expect( + (peer as any).processCertificateResponse(response([cert('initial', 'initial')])) + ).rejects.toThrow('observer failed') + expect(backing.getSession('session')?.certificatesValidated).toBe(true) + expect(later).not.toHaveBeenCalled() +}) + +test('rejects a mismatched session identity before certificate processing', async () => { + const { peer } = await setup() + await expect( + (peer as any).processCertificateResponse({ + ...response([cert('initial', 'initial')]), + identityKey: 'another-peer' + }) + ).rejects.toThrow('identity does not match') + expect(validate).not.toHaveBeenCalled() +}) + +test('uses configured policy for older sessions, and leaves empty responses unvalidated', async () => { + const { peer, backing } = await setup() + delete backing.getSession('session')!.certificatePolicy + await (peer as any).processCertificateResponse(response([])) + expect(backing.getSession('session')?.certificatesValidated).toBe(false) + await (peer as any).processCertificateResponse(response([cert('initial', 'initial')])) + expect(backing.getSession('session')?.certificatesValidated).toBe(true) +}) + +test('initial-response observers also observe committed validation', async () => { + const { peer, backing } = await setup() + peer.listenForCertificatesReceived(() => { + expect(backing.getSession('session')?.certificatesValidated).toBe(true) + throw new Error('initial observer failed') + }) + await expect( + (peer as any).validateInitialResponseCertificates( + response([cert('initial', 'initial')]), + backing.getSession('session') + ) + ).rejects.toThrow('initial observer failed') + expect(backing.getSession('session')?.certificatesValidated).toBe(true) +}) + +test('missing-session updates reject and release their serialization queue', async () => { + const { peer } = await setup() + await expect((peer as any).updateCertificateSession('missing', async () => {})).rejects.toThrow( + 'Session not found' + ) + expect((peer as any).certificateSessionUpdates.size).toBe(0) +}) + +test('an awaiting general message cannot restore stale validation state in a copy store', async () => { + const { peer, backing } = await setup(undefined, true) + const delivered = jest.fn() + peer.listenForGeneralMessages(delivered) + const general = (peer as any).processGeneralMessage({ + ...response([]), + messageType: 'general', + payload: [1] + }) as Promise + for ( + let attempt = 0; + attempt < 20 && !(peer as any).certificateValidationPromises.has('session'); + attempt++ + ) { + await Promise.resolve() + } + expect((peer as any).certificateValidationPromises.has('session')).toBe(true) + peer.listenForCertificatesReceived(async () => { + await general + expect(delivered).toHaveBeenCalledWith('remote', [1]) + throw new Error('observer cannot veto delivery') + }) + await expect( + (peer as any).processCertificateResponse(response([cert('initial', 'initial')])) + ).rejects.toThrow('observer cannot veto delivery') + expect(backing.getSession('session')?.certificatesValidated).toBe(true) +}) + +test('initial-response validation preserves concurrent dynamic requests in an async copy store', async () => { + const { peer, backing, transport } = await setup(undefined, true) + let release!: () => void + let started!: () => void + const blocked = new Promise(resolve => { + release = resolve + }) + const validating = new Promise(resolve => { + started = resolve + }) + validate.mockImplementationOnce(async () => { + started() + await blocked + }) + const initial = (peer as any).validateInitialResponseCertificates( + response([cert('initial', 'initial')]), + structuredClone(backing.getSession('session')) + ) as Promise + await validating + const request = peer.requestCertificates(policy('dynamic', 'dynamic'), 'remote') + for (let turn = 0; turn < 20; turn++) await Promise.resolve() + expect(transport.send).not.toHaveBeenCalled() + release() + await Promise.all([initial, request]) + const session = backing.getSession('session')! + expect(session.certificatesValidated).toBe(true) + expect(Object.values(session.pendingCertificateRequests!)).toEqual([policy('dynamic', 'dynamic')]) +}) diff --git a/packages/sdk/src/auth/types.ts b/packages/sdk/src/auth/types.ts index 1f2d97567..0136c73b3 100644 --- a/packages/sdk/src/auth/types.ts +++ b/packages/sdk/src/auth/types.ts @@ -13,11 +13,7 @@ export interface RequestedCertificateSet { export interface AuthMessage { version: string messageType: - | 'initialRequest' - | 'initialResponse' - | 'certificateRequest' - | 'certificateResponse' - | 'general' + 'initialRequest' | 'initialResponse' | 'certificateRequest' | 'certificateResponse' | 'general' identityKey: string // Sender's public key (used for identity verification) nonce?: string // Sender's nonce (256-bit random value) initialNonce?: string @@ -41,4 +37,8 @@ export interface PeerSession { lastUpdate: number certificatesRequired?: boolean certificatesValidated?: boolean + /** Local handshake policy snapshot. Session stores must retain this field; never sent on the wire. */ + certificatePolicy?: RequestedCertificateSet + /** Locally issued standalone requests, keyed by their nonce. Not a wire correlation field. */ + pendingCertificateRequests?: Record } diff --git a/packages/sdk/src/remittance/__tests/BasicBRC29.additional.test.ts b/packages/sdk/src/remittance/__tests/BasicBRC29.additional.test.ts index cd523ecae..1c87ad026 100644 --- a/packages/sdk/src/remittance/__tests/BasicBRC29.additional.test.ts +++ b/packages/sdk/src/remittance/__tests/BasicBRC29.additional.test.ts @@ -18,7 +18,7 @@ const makeWallet = (overrides: Partial = {}): WalletInterface = ({ getPublicKey: jest.fn(async () => ({ publicKey: '02deadbeef' })), createAction: jest.fn(async () => ({ tx: [1, 2, 3] })), - internalizeAction: jest.fn(async () => ({ ok: true })), + internalizeAction: jest.fn(async () => ({ accepted: true })), ...overrides }) as unknown as WalletInterface diff --git a/packages/sdk/src/remittance/__tests/BasicBRC29.test.ts b/packages/sdk/src/remittance/__tests/BasicBRC29.test.ts index e39e3cb2f..975454e80 100644 --- a/packages/sdk/src/remittance/__tests/BasicBRC29.test.ts +++ b/packages/sdk/src/remittance/__tests/BasicBRC29.test.ts @@ -166,7 +166,7 @@ describe('Brc29RemittanceModule', () => { describe('settlement acceptance', () => { it('accepts settlements by internalizing the payment', async () => { const wallet = { - internalizeAction: jest.fn(async () => ({ ok: true })) + internalizeAction: jest.fn(async () => ({ accepted: true })) } as unknown as WalletInterface const module = new Brc29RemittanceModule() @@ -182,7 +182,7 @@ describe('Brc29RemittanceModule', () => { ) expect(result.action).toBe('accept') if (result.action === 'accept') { - expect(result.receiptData?.internalizeResult).toEqual({ ok: true }) + expect(result.receiptData?.internalizeResult).toEqual({ accepted: true }) } expect(wallet.internalizeAction).toHaveBeenCalledWith( @@ -206,6 +206,32 @@ describe('Brc29RemittanceModule', () => { ) }) + it.each([{ accepted: false }, {}])( + 'terminates without affirmative wallet acceptance: %j', + async result => { + const wallet = { + internalizeAction: jest.fn(async () => result) + } as unknown as WalletInterface + const module = new Brc29RemittanceModule() + const accepted = await module.acceptSettlement( + { + threadId: 'thread-1', + sender: 'payer-key', + settlement: { + customInstructions: { derivationPrefix: 'p', derivationSuffix: 's' }, + transaction: [9, 9, 9], + amountSatoshis: 1000 + } + }, + makeContext(wallet) + ) + expect(accepted).toMatchObject({ + action: 'terminate', + termination: { code: 'brc29.internalize_failed' } + }) + } + ) + it('terminates when internalization fails', async () => { const wallet = { internalizeAction: jest.fn(async () => { diff --git a/packages/sdk/src/remittance/modules/BasicBRC29.ts b/packages/sdk/src/remittance/modules/BasicBRC29.ts index 68710eabf..4538ff370 100644 --- a/packages/sdk/src/remittance/modules/BasicBRC29.ts +++ b/packages/sdk/src/remittance/modules/BasicBRC29.ts @@ -312,6 +312,9 @@ export class Brc29RemittanceModule implements RemittanceModule< origin ) + if (internalizeResult.accepted !== true) { + return terminate('brc29.internalize_failed', 'Wallet did not accept the BRC-29 settlement.') + } return { action: 'accept', receiptData: { internalizeResult } } } catch (error) { const message = error instanceof Error ? error.message : String(error) diff --git a/packages/wallet/wallet-toolbox/client/platform-budget.json b/packages/wallet/wallet-toolbox/client/platform-budget.json index a2db3db6e..707424e00 100644 --- a/packages/wallet/wallet-toolbox/client/platform-budget.json +++ b/packages/wallet/wallet-toolbox/client/platform-budget.json @@ -2,14 +2,14 @@ "profile": "browser", "maximumBytes": { "vite": { - "raw": 1717000, + "raw": 1719000, "gzip": 406500, - "brotli": 317000 + "brotli": 317500 }, "esbuild": { - "raw": 1339500, - "gzip": 369500, - "brotli": 296000 + "raw": 1341000, + "gzip": 370000, + "brotli": 296500 } } } diff --git a/packages/wallet/wallet-toolbox/mobile/platform-budget.json b/packages/wallet/wallet-toolbox/mobile/platform-budget.json index 089ca815c..16ce9608d 100644 --- a/packages/wallet/wallet-toolbox/mobile/platform-budget.json +++ b/packages/wallet/wallet-toolbox/mobile/platform-budget.json @@ -2,14 +2,14 @@ "profile": "mobile", "maximumBytes": { "metro": { - "raw": 1768000, + "raw": 1770000, "gzip": 455000, "brotli": 360000 }, "hermes": { - "raw": 3588000, - "gzip": 1460500, - "brotli": 1135000 + "raw": 3595000, + "gzip": 1463000, + "brotli": 1136000 } } } diff --git a/scripts/service-operations.mjs b/scripts/service-operations.mjs index 003c22bed..6221dc622 100644 --- a/scripts/service-operations.mjs +++ b/scripts/service-operations.mjs @@ -577,7 +577,7 @@ critical-journey monitoring. ${registry.policy.telemetry.implementation} -Every service preloads telemetry before application imports and emits +Every service preloads telemetry before application imports and supports ${registry.policy.telemetry.signals.join(', ')}. Structured logs use ${inlineCode(registry.policy.telemetry.logFields)} and correlate through ${inlineCode(registry.policy.telemetry.correlationFields)}. Every environment diff --git a/scripts/service-telemetry.test.mjs b/scripts/service-telemetry.test.mjs new file mode 100644 index 000000000..5c1ad266d --- /dev/null +++ b/scripts/service-telemetry.test.mjs @@ -0,0 +1,173 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { stripTypeScriptTypes } from 'node:module' +import { dirname, join } from 'node:path' +import test from 'node:test' +import { fileURLToPath, pathToFileURL } from 'node:url' +import vm from 'node:vm' + +const root = dirname(dirname(fileURLToPath(import.meta.url))) +const registry = JSON.parse(readFileSync(join(root, 'governance/service-operations.json'), 'utf8')) + +async function probe(service, environment) { + const constructed = [] + const emitted = [] + const output = [] + const handlers = new Map() + const exits = [] + let started = 0 + let stopped = 0 + let options + const constructors = Object.fromEntries( + [ + 'RuntimeNodeInstrumentation', + 'OTLPTraceExporter', + 'OTLPMetricExporter', + 'OTLPLogExporter', + 'ConsoleSpanExporter', + 'BatchSpanProcessor', + 'SimpleSpanProcessor', + 'PeriodicExportingMetricReader', + 'ConsoleMetricExporter', + 'BatchLogRecordProcessor', + 'SimpleLogRecordProcessor', + 'ConsoleLogRecordExporter', + 'DiagConsoleLogger' + ].map(name => [ + name, + class { + constructor(...args) { + this.kind = name + this.args = args + constructed.push(this) + } + } + ]) + ) + const modules = { + ...constructors, + NodeSDK: class { + constructor(config) { + options = config + } + start() { + started += 1 + } + async shutdown() { + stopped += 1 + } + }, + getNodeAutoInstrumentations: () => [], + resourceFromAttributes: attributes => attributes, + ATTR_SERVICE_NAME: 'service.name', + ATTR_SERVICE_VERSION: 'service.version', + logs: { getLogger: () => ({ emit: record => emitted.push(record) }) }, + SeverityNumber: { DEBUG: 5, INFO: 9, WARN: 13, ERROR: 17 }, + diag: { setLogger() {} }, + DiagLogLevel: { INFO: 60 } + } + const appConsole = Object.fromEntries( + ['debug', 'info', 'log', 'warn', 'error'].map(name => [ + name, + (...args) => output.push({ name, args }) + ]) + ) + const originalLog = appConsole.log + const fakeProcess = { + env: environment, + cwd: () => join(root, service.path), + on(signal, handler) { + handlers.set(signal, [...(handlers.get(signal) ?? []), handler]) + }, + listeners: signal => handlers.get(signal) ?? [], + exit: code => exits.push(code) + } + const packageRequire = () => ({ name: service.name, version: '1.0.0' }) + const context = vm.createContext({ + console: appConsole, + process: fakeProcess, + require: packageRequire + }) + const sourcePath = join(root, service.path, service.observability.telemetryFile) + const source = stripTypeScriptTypes(readFileSync(sourcePath, 'utf8')) + const module = new vm.SourceTextModule(source, { + context, + initializeImportMeta(meta) { + meta.url = pathToFileURL(sourcePath).href + } + }) + await module.link(specifier => { + const exports = + specifier === 'node:fs' + ? { readFileSync: () => JSON.stringify(packageRequire()) } + : specifier === 'node:module' + ? { createRequire: () => packageRequire } + : specifier === 'node:path' + ? { join } + : modules + assert.ok(specifier.startsWith('@opentelemetry/') || specifier.startsWith('node:')) + return new vm.SyntheticModule( + Object.keys(exports), + function () { + for (const [name, value] of Object.entries(exports)) this.setExport(name, value) + }, + { context } + ) + }) + await module.evaluate() + const otlp = Boolean(environment.OTEL_EXPORTER_OTLP_ENDPOINT?.trim()) + const enabled = otlp || environment.OTEL_CONSOLE_EXPORTERS === 'true' + assert.equal(started, Number(enabled)) + assert.equal(handlers.size, enabled ? 2 : 0) + appConsole.log('application message') + assert.equal(output.filter(record => record.args[0] === 'application message').length, 1) + assert.equal(emitted.length, otlp ? 1 : 0) + if (!otlp) assert.equal(appConsole.log, originalLog) + if (!enabled) { + assert.equal(constructed.length, 0) + return + } + const exporters = constructed + .filter(value => value.kind.endsWith('Exporter')) + .map(value => value.kind) + assert.deepEqual( + exporters, + otlp + ? ['OTLPTraceExporter', 'OTLPMetricExporter', 'OTLPLogExporter'] + : ['ConsoleSpanExporter', 'ConsoleMetricExporter', 'ConsoleLogRecordExporter'] + ) + assert.equal(options.spanProcessors[0].kind, otlp ? 'BatchSpanProcessor' : 'SimpleSpanProcessor') + // An application's drain handler keeps ownership of process exit. + fakeProcess.on('SIGTERM', () => {}) + handlers.get('SIGTERM')[0]() + await new Promise(resolve => setImmediate(resolve)) + assert.equal(stopped, 1) + assert.deepEqual(exits, []) +} + +if (process.argv.includes('--bootstrap-probe')) { + for (const service of registry.services) { + for (const environment of [ + {}, + { NODE_ENV: 'production', OTEL_CONSOLE_EXPORTERS: 'false' }, + { OTEL_EXPORTER_OTLP_ENDPOINT: ' ' }, + { OTEL_CONSOLE_EXPORTERS: 'true' }, + { + OTEL_EXPORTER_OTLP_ENDPOINT: 'http://collector.invalid:4318', + OTEL_CONSOLE_EXPORTERS: 'true' + } + ]) + await probe(service, environment) + } +} else { + test('all seven telemetry bootstraps preserve quiet defaults, explicit console mode, OTLP precedence and application shutdown', () => { + assert.doesNotThrow(() => + execFileSync( + process.execPath, + ['--experimental-vm-modules', fileURLToPath(import.meta.url), '--bootstrap-probe'], + { cwd: root, stdio: 'pipe', timeout: 30000 } + ) + ) + }) +} diff --git a/sonar-project.properties b/sonar-project.properties index 315272cf1..e51a435da 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -67,7 +67,13 @@ infra/uhrp-server-basic/src/chirp/openapi.ts,\ infra/uhrp-server-cloud-bucket/src/chirp/openapi.ts,\ infra/uhrp-server-cloud-bucket/src/chirp/contracts.ts,\ infra/uhrp-server-cloud-bucket/src/chirp/commitIndex.ts,\ -infra/uhrp-server-cloud-bucket/src/chirp/routes.ts +infra/uhrp-server-cloud-bucket/src/chirp/routes.ts,\ +infra/chaintracks-server/src/telemetry.ts,\ +infra/wab/src/telemetry.ts,\ +infra/uhrp-server-basic/src/telemetry.ts,\ +infra/uhrp-server-cloud-bucket/src/telemetry.ts,\ +infra/wallet-infra/src/telemetry.ts,\ +infra/message-box-server/src/telemetry.ts # Keep CI and Automatic Analysis aligned on the same narrowly registered # compatibility exceptions. sonar.issue.ignore.multicriteria=werrProtocolNames,curveSingletonAlias,curveSingletonReturn,scriptOpcodeDispatch diff --git a/specs/auth/brc103-mutual-auth.yaml b/specs/auth/brc103-mutual-auth.yaml index aeadecd24..188055380 100644 --- a/specs/auth/brc103-mutual-auth.yaml +++ b/specs/auth/brc103-mutual-auth.yaml @@ -1,8 +1,8 @@ -asyncapi: "3.1.0" +asyncapi: '3.1.0' info: title: BRC-103 Mutual Authentication Handshake - version: "1.0.0" + version: '1.0.0' description: | AsyncAPI 3.0 specification for the BRC-103 peer-to-peer mutual authentication protocol (https://bsv.brc.dev/peer-to-peer/0103) as @@ -15,15 +15,15 @@ info: (`SocketServerTransport`) — Socket.IO transport - `@bsv/sdk` `Peer` and `Transport` interfaces - Note: this file replaces the earlier `brc31-handshake.yaml`. BRC-31 was - the legacy identifier; the protocol is now standardised as BRC-103 with - BRC-104 covering the HTTP binding. + This file replaces the incorrectly named `brc31-handshake.yaml`. + BRC-31 Authrite is a separate protocol, not an alias for BRC-103. + BRC-104 covers the HTTP binding. ## Protocol overview - BRC-103 mutual authentication uses ECDH-derived key pairs to create a - shared, forward-secret session between two parties. Neither party trusts - the other's identity until the signed handshake is verified. + BRC-103 uses derived signing keys and nonce-bound signatures for peer + authentication. It does not by itself provide forward-secret transport + encryption; applications must choose their transport and authorization policy. ### Two-phase handshake @@ -33,27 +33,18 @@ info: transports, or the `authMessage` Socket.IO event for WebSocket transports. 1. **Client → Server** `initialRequest` - Client generates a fresh nonce, signs it with its identity key, and - sends the auth message. Headers on the HTTP path: - ``` - x-bsv-auth-version: - x-bsv-auth-identity-key: - x-bsv-auth-nonce: - ``` - Body (HTTP): the `AuthMessage` JSON object. + The SDK emits exactly `version`, `messageType`, `identityKey`, + `initialNonce`, and `requestedCertificates`. The initial request has no + `nonce`, `payload`, or `signature` member. The HTTP transport posts this + JSON with `Content-Type: application/json`; general-message auth headers + are not added to this handshake request. 2. **Server → Client** `initialResponse` - Server validates the client's nonce/signature, generates its own nonce, - signs the response, and returns the `AuthMessage`. HTTP response headers: - ``` - x-bsv-auth-version: - x-bsv-auth-message-type: initialResponse - x-bsv-auth-identity-key: - x-bsv-auth-nonce: - x-bsv-auth-your-nonce: - x-bsv-auth-signature: - x-bsv-auth-requested-certificates: (optional) - ``` + The server creates its own `initialNonce`, echoes the client's nonce as + `yourNonce`, and signs the pair. It returns a JSON AuthMessage containing + `version`, `messageType`, `identityKey`, `initialNonce`, `yourNonce`, + `signature`, `requestedCertificates`, and optional `certificates`. + The client validates the response before treating the peer as authenticated. **Phase 2 — General (authenticated request/response)** @@ -86,7 +77,7 @@ info: ### Certificate flow (optional) If the server declares `certificatesToRequest`, it embeds the request set - in the `x-bsv-auth-requested-certificates` header of the `initialResponse`. + in the `requestedCertificates` field of the `initialResponse` JSON body. The client then provides certificates in a follow-up `/.well-known/auth` call before the `next()` middleware proceeds. The server waits up to 30 seconds; timeout returns 408. @@ -103,15 +94,23 @@ info: response until after `Peer.toPeer` signs and re-emits it. Original methods are saved as `res.__status`, `res.__json`, etc. - The `RequestId` is a 32-byte random value encoded as base64. - - Nonces are single-use; the `SessionManager` stores seen nonces to - prevent replay. Deployments that run multiple server instances should - use a shared `AsyncSessionManager` store so every instance can resolve - the same nonce/session state during the handshake. + - A wallet-authenticated nonce is not independently a single-use or expiring + token. Preserve the protocol's signature and session checks and apply + application-level idempotency to protected operations. + - Shared `AsyncSessionManager` stores must retain the complete PeerSession, + including local `certificatePolicy` and `pendingCertificateRequests`, and + coordinate concurrent writers. These fields are never part of AuthMessage. + - Certificate responses are checked against locally recorded policies, never + against a policy supplied in the response. Dynamic requests are scoped to + their session. v0.1 responses do not echo a request ID, so a response must + match one complete outstanding local set or the local handshake set. + - Certificate listeners are observers: validation is committed and waiters + are released first. A listener error cannot revoke or veto that validation. servers: httpServer: - host: "{host}" - pathname: "/.well-known/auth" + host: '{host}' + pathname: '/.well-known/auth' protocol: https description: | HTTP endpoint for BRC-103 non-general (initial handshake) messages. @@ -127,7 +126,7 @@ components: schemas: PubKeyHex: type: string - pattern: "^0[23][0-9a-fA-F]{64}$" + pattern: '^0[23][0-9a-fA-F]{64}$' description: Compressed secp256k1 public key, 66 hex characters. Base64String: @@ -136,12 +135,12 @@ components: HexString: type: string - pattern: "^[0-9a-fA-F]+$" + pattern: '^[0-9a-fA-F]+$' description: Hex-encoded binary data. AuthMessageType: type: string - enum: [initialRequest, initialResponse, general] + enum: [initialRequest, initialResponse, certificateRequest, certificateResponse, general] description: | - `initialRequest` — first message from the initiating party - `initialResponse` — response from the receiving party completing Phase 1 @@ -155,28 +154,29 @@ components: required: [messageType, version, identityKey] properties: messageType: - $ref: "#/components/schemas/AuthMessageType" + $ref: '#/components/schemas/AuthMessageType' version: type: string description: Auth protocol version (e.g. "0.1"). identityKey: - $ref: "#/components/schemas/PubKeyHex" + $ref: '#/components/schemas/PubKeyHex' description: Public identity key of the sender of this message. nonce: - $ref: "#/components/schemas/Base64String" + $ref: '#/components/schemas/Base64String' description: | Fresh single-use random value generated by the sender. Stored in the `SessionManager` to prevent replay. Multi-instance deployments should store it in a shared `AsyncSessionManager` implementation. yourNonce: - $ref: "#/components/schemas/Base64String" + $ref: '#/components/schemas/Base64String' description: Echo of the peer's nonce from the previous message. initialNonce: - $ref: "#/components/schemas/Base64String" + $ref: '#/components/schemas/Base64String' description: | - Present in `initialRequest` only. The very first nonce from the - initiating party before a session key is established. + The sender's session nonce in initialRequest, initialResponse, + certificateRequest and certificateResponse. It is not a per-request + correlation identifier. payload: type: array items: @@ -187,19 +187,20 @@ components: For `general` messages: the signed application payload. Encoding: `requestId(32) || VarInt(statusCode) || VarInt(nHeaders) || [header pairs] || VarInt(bodyLength) || body`. - For handshake messages: empty or absent. + Absent from SDK initialRequest and initialResponse messages. signature: type: array items: type: integer minimum: 0 maximum: 255 - description: DER-encoded ECDSA signature over the payload. + description: Message-specific DER signature; absent from initialRequest. InitialResponse signs the nonce pair, while general and certificate messages sign their respective payloads. requestedCertificates: type: object description: | - BRC-52 certificate request set. Present in `initialResponse` when - the server requires certificates from the client. + Locally chosen certificate request set in initialRequest, + initialResponse and certificateRequest. CertificateResponse does not + echo this field; receivers validate against their local policy. additionalProperties: true # ------------------------------------------------------------------------- @@ -207,50 +208,19 @@ components: # ------------------------------------------------------------------------- InitialRequestHeaders: type: object - description: | - HTTP request headers sent by the client when initiating the BRC-103 - handshake at POST `/.well-known/auth`. - required: - - x-bsv-auth-version - - x-bsv-auth-identity-key - - x-bsv-auth-nonce + description: The SDK HTTP transport sends handshake data in the JSON body. + required: [Content-Type] properties: - x-bsv-auth-version: + Content-Type: type: string - x-bsv-auth-identity-key: - $ref: "#/components/schemas/PubKeyHex" - x-bsv-auth-nonce: - $ref: "#/components/schemas/Base64String" + enum: [application/json] InitialResponseHeaders: type: object - description: | - HTTP response headers set by the server completing Phase 1 of the - BRC-103 handshake. - required: - - x-bsv-auth-version - - x-bsv-auth-message-type - - x-bsv-auth-identity-key - - x-bsv-auth-nonce - - x-bsv-auth-your-nonce - - x-bsv-auth-signature + description: Handshake fields are in the JSON response body. General-message auth headers are not required. properties: - x-bsv-auth-version: - type: string - x-bsv-auth-message-type: - type: string - enum: [initialResponse] - x-bsv-auth-identity-key: - $ref: "#/components/schemas/PubKeyHex" - x-bsv-auth-nonce: - $ref: "#/components/schemas/Base64String" - x-bsv-auth-your-nonce: - $ref: "#/components/schemas/Base64String" - x-bsv-auth-signature: - $ref: "#/components/schemas/HexString" - x-bsv-auth-requested-certificates: + Content-Type: type: string - description: JSON-encoded BRC-52 certificate request set (optional). GeneralRequestHeaders: type: object @@ -268,16 +238,16 @@ components: x-bsv-auth-version: type: string x-bsv-auth-identity-key: - $ref: "#/components/schemas/PubKeyHex" + $ref: '#/components/schemas/PubKeyHex' x-bsv-auth-nonce: - $ref: "#/components/schemas/Base64String" + $ref: '#/components/schemas/Base64String' x-bsv-auth-your-nonce: - $ref: "#/components/schemas/Base64String" + $ref: '#/components/schemas/Base64String' x-bsv-auth-request-id: - $ref: "#/components/schemas/Base64String" + $ref: '#/components/schemas/Base64String' description: 32-byte random value, base64-encoded. x-bsv-auth-signature: - $ref: "#/components/schemas/HexString" + $ref: '#/components/schemas/HexString' description: | ECDSA signature over: `requestId(32B) || VarInt(method.length) || method @@ -302,15 +272,15 @@ components: x-bsv-auth-version: type: string x-bsv-auth-identity-key: - $ref: "#/components/schemas/PubKeyHex" + $ref: '#/components/schemas/PubKeyHex' x-bsv-auth-nonce: - $ref: "#/components/schemas/Base64String" + $ref: '#/components/schemas/Base64String' x-bsv-auth-your-nonce: - $ref: "#/components/schemas/Base64String" + $ref: '#/components/schemas/Base64String' x-bsv-auth-request-id: - $ref: "#/components/schemas/Base64String" + $ref: '#/components/schemas/Base64String' x-bsv-auth-signature: - $ref: "#/components/schemas/HexString" + $ref: '#/components/schemas/HexString' description: | ECDSA signature over: `requestId(32B) || VarInt(statusCode) @@ -413,13 +383,12 @@ components: # Channels # --------------------------------------------------------------------------- channels: - wellKnownAuth: - address: "/.well-known/auth" + address: '/.well-known/auth' description: | HTTP channel used for Phase 1 (non-general) BRC-103 handshake messages. The client POSTs an `AuthMessage` JSON body; the server replies with an - `AuthMessage` JSON body and the `x-bsv-auth-*` response headers. + `AuthMessage` JSON body. General-message header requirements do not apply. In Socket.IO transports the same exchange happens over the `authMessage` Socket.IO event (see `authsocket-asyncapi.yaml`) rather than this HTTP @@ -429,42 +398,36 @@ channels: name: initialRequest summary: Client initiates the BRC-103 handshake. description: | - The client generates a nonce, signs it, and sends the - `initialRequest` AuthMessage as the POST body. + The client sends its identity, initial nonce, protocol version and + requested certificate set as an unsigned initialRequest JSON body. headers: - $ref: "#/components/schemas/InitialRequestHeaders" + $ref: '#/components/schemas/InitialRequestHeaders' payload: - $ref: "#/components/schemas/AuthMessage" + $ref: '#/components/schemas/AuthMessage' examples: - name: example-initial-request payload: - messageType: initialRequest - version: "0.1" - identityKey: "028d37b941208cd6b8a4c28288eda5f2f16c2b3ab0fcb6d13c18b47fe37b971fc1" - nonce: "dGVzdE5vbmNlMTIzNA==" - initialNonce: "dGVzdE5vbmNlMTIzNA==" - payload: [] - signature: [] + version: '0.1' + messageType: 'initialRequest' + identityKey: '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798' + initialNonce: 'Z9+3SZ9qiJvtpQN1RNmmGue247vPuQLeqfIV2eFIMtvRRqFb0FpYckzLbawd0bdR' + requestedCertificates: { 'certifiers': [], 'types': {} } initialResponse: name: initialResponse summary: Server completes Phase 1 of the BRC-103 handshake. description: | - The server validates the client's nonce/signature, generates its own - nonce, signs the response, and replies. The body is an `AuthMessage` - JSON object. Response headers carry the `x-bsv-auth-*` fields. - - If `certificatesToRequest` is configured, the - `x-bsv-auth-requested-certificates` header contains the JSON-encoded - request set. The client must supply certificates in a follow-up - POST before the session is fully established. + The server returns its initial nonce, the echoed client nonce and a + signature over that pair in the AuthMessage JSON body. The body also + carries requestedCertificates and any supplied certificates. The + client verifies the response; no initialRequest signature is required. headers: - $ref: "#/components/schemas/InitialResponseHeaders" + $ref: '#/components/schemas/InitialResponseHeaders' payload: - $ref: "#/components/schemas/AuthMessage" + $ref: '#/components/schemas/AuthMessage' generalRequest: - address: "{applicationPath}" + address: '{applicationPath}' description: | Every authenticated application HTTP request (Phase 2). The path is the actual application endpoint (e.g. `/sendMessage`, `/listMessages`). @@ -482,12 +445,12 @@ channels: covers the entire request (method, path, query string, signed headers, and body) as described in `RequestAuthPayload`. headers: - $ref: "#/components/schemas/GeneralRequestHeaders" + $ref: '#/components/schemas/GeneralRequestHeaders' payload: description: Application-defined request body (any content type). generalResponse: - address: "{applicationPath}" + address: '{applicationPath}' description: | Every authenticated application HTTP response (Phase 2). The server signs the response status code, relevant headers, and body before sending. @@ -504,12 +467,12 @@ channels: including `x-bsv-auth-*` response headers. The `x-bsv-auth-signature` covers `requestId || statusCode || signed response headers || body`. headers: - $ref: "#/components/schemas/GeneralResponseHeaders" + $ref: '#/components/schemas/GeneralResponseHeaders' payload: description: Application-defined response body. authError: - address: "{applicationPath}" + address: '{applicationPath}' description: | Error responses emitted by the auth middleware when authentication fails. parameters: @@ -520,52 +483,51 @@ channels: name: unauthorized summary: 401 — mutual auth failed (no or bad auth headers). payload: - $ref: "#/components/schemas/AuthError401" + $ref: '#/components/schemas/AuthError401' certificateTimeout: name: certificateTimeout summary: 408 — server waited 30 s for client certificates and timed out. payload: - $ref: "#/components/schemas/AuthError408" + $ref: '#/components/schemas/AuthError408' signingFailed: name: signingFailed summary: 500 — server failed to sign its response payload. payload: - $ref: "#/components/schemas/AuthError500" + $ref: '#/components/schemas/AuthError500' # --------------------------------------------------------------------------- # Operations # --------------------------------------------------------------------------- operations: - sendInitialRequest: action: send channel: - $ref: "#/channels/wellKnownAuth" + $ref: '#/channels/wellKnownAuth' summary: Client initiates BRC-103 handshake (Phase 1, step 1). description: | Client POSTs an `initialRequest` AuthMessage to `/.well-known/auth`. The body is a JSON-serialized `AuthMessage` with `messageType: initialRequest`. messages: - - $ref: "#/channels/wellKnownAuth/messages/initialRequest" + - $ref: '#/channels/wellKnownAuth/messages/initialRequest' receiveInitialResponse: action: receive channel: - $ref: "#/channels/wellKnownAuth" + $ref: '#/channels/wellKnownAuth' summary: Client receives the server's Phase 1 challenge response. description: | Server replies with `initialResponse`. If `requestedCertificates` is non-empty the client must send a follow-up request with the required certificates before proceeding to Phase 2. messages: - - $ref: "#/channels/wellKnownAuth/messages/initialResponse" + - $ref: '#/channels/wellKnownAuth/messages/initialResponse' sendGeneralRequest: action: send channel: - $ref: "#/channels/generalRequest" + $ref: '#/channels/generalRequest' summary: Client sends an authenticated application request (Phase 2). description: | Any application HTTP request after the handshake. The client attaches @@ -573,26 +535,26 @@ operations: request. The server uses `buildAuthMessageFromRequest` to reconstruct and verify the payload. messages: - - $ref: "#/channels/generalRequest/messages/generalRequestMessage" + - $ref: '#/channels/generalRequest/messages/generalRequestMessage' receiveGeneralResponse: action: receive channel: - $ref: "#/channels/generalResponse" + $ref: '#/channels/generalResponse' summary: Client receives the server's authenticated application response (Phase 2). description: | The server's response includes `x-bsv-auth-*` headers and a signature over the response payload. The client can verify the response origin using `buildResponsePayload` semantics. messages: - - $ref: "#/channels/generalResponse/messages/generalResponseMessage" + - $ref: '#/channels/generalResponse/messages/generalResponseMessage' receiveAuthError: action: receive channel: - $ref: "#/channels/authError" + $ref: '#/channels/authError' summary: Client receives an auth error from the middleware. messages: - - $ref: "#/channels/authError/messages/unauthorized" - - $ref: "#/channels/authError/messages/certificateTimeout" - - $ref: "#/channels/authError/messages/signingFailed" + - $ref: '#/channels/authError/messages/unauthorized' + - $ref: '#/channels/authError/messages/certificateTimeout' + - $ref: '#/channels/authError/messages/signingFailed' diff --git a/specs/messaging/authsocket-asyncapi.yaml b/specs/messaging/authsocket-asyncapi.yaml index 774145ec8..6eb87aef0 100644 --- a/specs/messaging/authsocket-asyncapi.yaml +++ b/specs/messaging/authsocket-asyncapi.yaml @@ -1,8 +1,8 @@ -asyncapi: "3.1.0" +asyncapi: '3.1.0' info: title: AuthSocket WebSocket Protocol - version: "1.0.0" + version: '1.0.0' description: | AsyncAPI 3.0 specification for the `AuthSocketServer` / `AuthSocket` WebSocket channel used by the BSV MessageBox Server. @@ -40,18 +40,18 @@ info: servers: production: - host: "messagebox.babbage.systems" - pathname: "/" + host: 'messagebox.babbage.systems' + pathname: '/' protocol: wss description: Production MessageBox WebSocket endpoint (Socket.IO over WSS). local: - host: "localhost:{port}" - pathname: "/" + host: 'localhost:{port}' + pathname: '/' protocol: ws description: Local development Socket.IO server. variables: port: - default: "5001" + default: '5001' description: HTTP port the MessageBox Server listens on. # --------------------------------------------------------------------------- @@ -61,7 +61,7 @@ components: schemas: PubKeyHex: type: string - pattern: "^0[23][0-9a-fA-F]{64}$" + pattern: '^0[23][0-9a-fA-F]{64}$' description: Compressed secp256k1 public key, 66 hex characters. AuthMessage: @@ -73,7 +73,7 @@ components: properties: messageType: type: string - enum: [initialRequest, initialResponse, general] + enum: [initialRequest, initialResponse, certificateRequest, certificateResponse, general] description: | - `initialRequest` — first handshake message from initiating peer - `initialResponse` — server's challenge response (includes nonce, signature) @@ -82,7 +82,7 @@ components: type: string description: Auth protocol version string. identityKey: - $ref: "#/components/schemas/PubKeyHex" + $ref: '#/components/schemas/PubKeyHex' nonce: type: string description: Fresh random nonce (base64) generated by the sender. @@ -91,7 +91,7 @@ components: description: Echo of the peer's nonce from the previous message. initialNonce: type: string - description: Present in `initialRequest`; absent in subsequent messages. + description: Sender session nonce in initialRequest, initialResponse and standalone certificate messages. payload: type: array items: @@ -130,7 +130,7 @@ components: the key and responds with `authenticationSuccess` or `authenticationFailed`. properties: identityKey: - $ref: "#/components/schemas/PubKeyHex" + $ref: '#/components/schemas/PubKeyHex' AuthSuccessPayload: type: object @@ -208,7 +208,7 @@ components: type: string description: Unique identifier for this message (deduplication key). recipient: - $ref: "#/components/schemas/PubKeyHex" + $ref: '#/components/schemas/PubKeyHex' body: type: string description: Message body string. @@ -239,7 +239,7 @@ components: required: [sender, messageId, body] properties: sender: - $ref: "#/components/schemas/PubKeyHex" + $ref: '#/components/schemas/PubKeyHex' messageId: type: string body: @@ -269,7 +269,6 @@ components: # Channels # --------------------------------------------------------------------------- channels: - # -------- Low-level auth handshake (BRC-103 / Peer transport) -------- authMessage: address: authMessage @@ -286,7 +285,7 @@ channels: name: authMessageFrame summary: BRC-103 auth frame (both directions — client and server). payload: - $ref: "#/components/schemas/AuthMessage" + $ref: '#/components/schemas/AuthMessage' # -------- Application authentication -------- authenticated: @@ -301,7 +300,7 @@ channels: name: authenticateMessage summary: Client sends its identity key for post-connection auth. payload: - $ref: "#/components/schemas/AuthenticatePayload" + $ref: '#/components/schemas/AuthenticatePayload' authenticationSuccess: address: authenticationSuccess @@ -310,7 +309,7 @@ channels: authSuccessMessage: name: authSuccessMessage payload: - $ref: "#/components/schemas/AuthSuccessPayload" + $ref: '#/components/schemas/AuthSuccessPayload' authenticationFailed: address: authenticationFailed @@ -319,7 +318,7 @@ channels: authFailedMessage: name: authFailedMessage payload: - $ref: "#/components/schemas/AuthFailedPayload" + $ref: '#/components/schemas/AuthFailedPayload' # -------- Room management -------- joinRoom: @@ -335,7 +334,7 @@ channels: name: joinRoomMessage summary: Room ID string to join. payload: - $ref: "#/components/schemas/JoinRoomPayload" + $ref: '#/components/schemas/JoinRoomPayload' joinedRoom: address: joinedRoom @@ -344,7 +343,7 @@ channels: joinedRoomMessage: name: joinedRoomMessage payload: - $ref: "#/components/schemas/JoinedRoomPayload" + $ref: '#/components/schemas/JoinedRoomPayload' joinFailed: address: joinFailed @@ -353,7 +352,7 @@ channels: joinFailedMessage: name: joinFailedMessage payload: - $ref: "#/components/schemas/JoinFailedPayload" + $ref: '#/components/schemas/JoinFailedPayload' leaveRoom: address: leaveRoom @@ -363,7 +362,7 @@ channels: name: leaveRoomMessage summary: Room ID string to leave. payload: - $ref: "#/components/schemas/LeaveRoomPayload" + $ref: '#/components/schemas/LeaveRoomPayload' leftRoom: address: leftRoom @@ -372,7 +371,7 @@ channels: leftRoomMessage: name: leftRoomMessage payload: - $ref: "#/components/schemas/LeftRoomPayload" + $ref: '#/components/schemas/LeftRoomPayload' leaveFailed: address: leaveFailed @@ -381,7 +380,7 @@ channels: leaveFailedMessage: name: leaveFailedMessage payload: - $ref: "#/components/schemas/LeaveFailedPayload" + $ref: '#/components/schemas/LeaveFailedPayload' # -------- Message sending -------- sendMessage: @@ -401,10 +400,10 @@ channels: sendMessageMessage: name: sendMessageMessage payload: - $ref: "#/components/schemas/WsSendMessagePayload" + $ref: '#/components/schemas/WsSendMessagePayload' sendMessageAck: - address: "sendMessageAck-{roomId}" + address: 'sendMessageAck-{roomId}' description: | Per-room acknowledgement emitted to the sender after the message is stored or when the request must fall back to AuthFetch. The event name is @@ -417,10 +416,10 @@ channels: sendMessageAckMessage: name: sendMessageAckMessage payload: - $ref: "#/components/schemas/WsSendMessageAckPayload" + $ref: '#/components/schemas/WsSendMessageAckPayload' sendMessageBroadcast: - address: "sendMessage-{roomId}" + address: 'sendMessage-{roomId}' description: | Broadcast emitted to all connections subscribed to `roomId` after a successful `sendMessage`. The event name is `sendMessage-`. @@ -431,7 +430,7 @@ channels: sendMessageBroadcastMessage: name: sendMessageBroadcastMessage payload: - $ref: "#/components/schemas/WsSendMessageBroadcastPayload" + $ref: '#/components/schemas/WsSendMessageBroadcastPayload' messageFailed: address: messageFailed @@ -440,7 +439,7 @@ channels: messageFailedMessage: name: messageFailedMessage payload: - $ref: "#/components/schemas/MessageFailedPayload" + $ref: '#/components/schemas/MessageFailedPayload' paymentFailed: address: paymentFailed @@ -451,7 +450,7 @@ channels: paymentFailedMessage: name: paymentFailedMessage payload: - $ref: "#/components/schemas/PaymentFailedPayload" + $ref: '#/components/schemas/PaymentFailedPayload' # -------- Lifecycle -------- disconnect: @@ -463,150 +462,149 @@ channels: disconnectMessage: name: disconnectMessage payload: - $ref: "#/components/schemas/DisconnectPayload" + $ref: '#/components/schemas/DisconnectPayload' # --------------------------------------------------------------------------- # Operations # --------------------------------------------------------------------------- operations: - # --- Auth handshake (transport-level, both directions) --- receiveAuthMessage: action: receive channel: - $ref: "#/channels/authMessage" + $ref: '#/channels/authMessage' summary: Server receives an AuthMessage frame from the client during BRC-103 handshake. messages: - - $ref: "#/channels/authMessage/messages/authMessageFrame" + - $ref: '#/channels/authMessage/messages/authMessageFrame' sendAuthMessage: action: send channel: - $ref: "#/channels/authMessage" + $ref: '#/channels/authMessage' summary: Server sends an AuthMessage frame to the client during BRC-103 handshake. messages: - - $ref: "#/channels/authMessage/messages/authMessageFrame" + - $ref: '#/channels/authMessage/messages/authMessageFrame' # --- Client authentication --- receiveAuthenticated: action: receive channel: - $ref: "#/channels/authenticated" + $ref: '#/channels/authenticated' summary: Server receives the client's identity key on the 'authenticated' event. messages: - - $ref: "#/channels/authenticated/messages/authenticateMessage" + - $ref: '#/channels/authenticated/messages/authenticateMessage' sendAuthenticationSuccess: action: send channel: - $ref: "#/channels/authenticationSuccess" + $ref: '#/channels/authenticationSuccess' summary: Server confirms successful identity key validation. messages: - - $ref: "#/channels/authenticationSuccess/messages/authSuccessMessage" + - $ref: '#/channels/authenticationSuccess/messages/authSuccessMessage' sendAuthenticationFailed: action: send channel: - $ref: "#/channels/authenticationFailed" + $ref: '#/channels/authenticationFailed' summary: Server rejects an invalid identity key. messages: - - $ref: "#/channels/authenticationFailed/messages/authFailedMessage" + - $ref: '#/channels/authenticationFailed/messages/authFailedMessage' # --- Room management --- receiveJoinRoom: action: receive channel: - $ref: "#/channels/joinRoom" + $ref: '#/channels/joinRoom' summary: Server receives a room join request. messages: - - $ref: "#/channels/joinRoom/messages/joinRoomMessage" + - $ref: '#/channels/joinRoom/messages/joinRoomMessage' sendJoinedRoom: action: send channel: - $ref: "#/channels/joinedRoom" + $ref: '#/channels/joinedRoom' summary: Server confirms room join. messages: - - $ref: "#/channels/joinedRoom/messages/joinedRoomMessage" + - $ref: '#/channels/joinedRoom/messages/joinedRoomMessage' sendJoinFailed: action: send channel: - $ref: "#/channels/joinFailed" + $ref: '#/channels/joinFailed' summary: Server signals room join failure. messages: - - $ref: "#/channels/joinFailed/messages/joinFailedMessage" + - $ref: '#/channels/joinFailed/messages/joinFailedMessage' receiveLeaveRoom: action: receive channel: - $ref: "#/channels/leaveRoom" + $ref: '#/channels/leaveRoom' summary: Server receives a room leave request. messages: - - $ref: "#/channels/leaveRoom/messages/leaveRoomMessage" + - $ref: '#/channels/leaveRoom/messages/leaveRoomMessage' sendLeftRoom: action: send channel: - $ref: "#/channels/leftRoom" + $ref: '#/channels/leftRoom' summary: Server confirms room leave. messages: - - $ref: "#/channels/leftRoom/messages/leftRoomMessage" + - $ref: '#/channels/leftRoom/messages/leftRoomMessage' sendLeaveFailed: action: send channel: - $ref: "#/channels/leaveFailed" + $ref: '#/channels/leaveFailed' summary: Server signals room leave failure. messages: - - $ref: "#/channels/leaveFailed/messages/leaveFailedMessage" + - $ref: '#/channels/leaveFailed/messages/leaveFailedMessage' # --- Message sending --- receiveSendMessage: action: receive channel: - $ref: "#/channels/sendMessage" + $ref: '#/channels/sendMessage' summary: Server receives a message from the client to deliver to a recipient. messages: - - $ref: "#/channels/sendMessage/messages/sendMessageMessage" + - $ref: '#/channels/sendMessage/messages/sendMessageMessage' sendSendMessageAck: action: send channel: - $ref: "#/channels/sendMessageAck" + $ref: '#/channels/sendMessageAck' summary: Server acknowledges delivery of a message to the sender. messages: - - $ref: "#/channels/sendMessageAck/messages/sendMessageAckMessage" + - $ref: '#/channels/sendMessageAck/messages/sendMessageAckMessage' sendSendMessageBroadcast: action: send channel: - $ref: "#/channels/sendMessageBroadcast" + $ref: '#/channels/sendMessageBroadcast' summary: Server broadcasts a new message to all room subscribers. messages: - - $ref: "#/channels/sendMessageBroadcast/messages/sendMessageBroadcastMessage" + - $ref: '#/channels/sendMessageBroadcast/messages/sendMessageBroadcastMessage' sendMessageFailed: action: send channel: - $ref: "#/channels/messageFailed" + $ref: '#/channels/messageFailed' summary: Server signals message delivery failure. messages: - - $ref: "#/channels/messageFailed/messages/messageFailedMessage" + - $ref: '#/channels/messageFailed/messages/messageFailedMessage' sendPaymentFailed: action: send channel: - $ref: "#/channels/paymentFailed" + $ref: '#/channels/paymentFailed' summary: Server signals auth/payment gate rejection. messages: - - $ref: "#/channels/paymentFailed/messages/paymentFailedMessage" + - $ref: '#/channels/paymentFailed/messages/paymentFailedMessage' # --- Lifecycle --- receiveDisconnect: action: receive channel: - $ref: "#/channels/disconnect" + $ref: '#/channels/disconnect' summary: Client disconnects; server cleans up in-memory state. messages: - - $ref: "#/channels/disconnect/messages/disconnectMessage" + - $ref: '#/channels/disconnect/messages/disconnectMessage' diff --git a/tools/codegen/node/package-lock.json b/tools/codegen/node/package-lock.json index 7153f23af..6076a0bf9 100644 --- a/tools/codegen/node/package-lock.json +++ b/tools/codegen/node/package-lock.json @@ -200,9 +200,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", diff --git a/tools/codegen/node/package.json b/tools/codegen/node/package.json index 6268b5958..67312a942 100644 --- a/tools/codegen/node/package.json +++ b/tools/codegen/node/package.json @@ -10,7 +10,7 @@ }, "overrides": { "@redocly/openapi-core@1.34.17": { - "js-yaml": "4.3.1", + "js-yaml": "4.3.2", "minimatch": "10.2.5" }, "minimatch@10.2.5": {