diff --git a/__tests__/hostApi/debugHook.spec.ts b/__tests__/hostApi/debugHook.spec.ts new file mode 100644 index 00000000..32af9420 --- /dev/null +++ b/__tests__/hostApi/debugHook.spec.ts @@ -0,0 +1,137 @@ +import { createTransport } from '@novasamatech/host-api'; +import { createAccountsProvider } from '@novasamatech/host-api-wrapper'; +import type { HostApiDebugMessageEvent } from '@novasamatech/host-container'; +import { createContainer, onHostApiDebugMessage } from '@novasamatech/host-container'; + +import { describe, expect, it, vi } from 'vitest'; + +import { createHostApiProviders } from './__mocks__/hostApiProviders.js'; + +function setup(productId?: string) { + const providers = createHostApiProviders(); + const container = createContainer(providers.host, productId !== undefined ? { productId } : {}); + const sdkTransport = createTransport(providers.sdk); + const accountsProvider = createAccountsProvider(sdkTransport); + return { container, providers, sdkTransport, accountsProvider }; +} + +describe('host-container debug hook', () => { + it('tags container-level events with the productId from createContainer', async () => { + const { container, accountsProvider } = setup('product.alpha'); + + const events: HostApiDebugMessageEvent[] = []; + container.onDebugMessage(event => events.push(event)); + + // any flow that produces traffic — the default unhandled getUserId triggers a request/response + await accountsProvider.getProductAccount('product.alpha', 0); + + expect(events.length).toBeGreaterThan(0); + for (const event of events) { + expect(event.productId).toBe('product.alpha'); + } + }); + + it('leaves productId undefined when none is supplied', async () => { + const { container, accountsProvider } = setup(); + + const events: HostApiDebugMessageEvent[] = []; + container.onDebugMessage(event => events.push(event)); + + await accountsProvider.getProductAccount('product.alpha', 0); + + expect(events.length).toBeGreaterThan(0); + for (const event of events) { + expect(event.productId).toBeUndefined(); + } + }); + + it('global onHostApiDebugMessage aggregates events across multiple containers tagged by productId', async () => { + const a = setup('product.alpha'); + const b = setup('product.beta'); + + const seen: HostApiDebugMessageEvent[] = []; + const unsubscribe = onHostApiDebugMessage(event => seen.push(event)); + + await a.accountsProvider.getProductAccount('product.alpha', 0); + await b.accountsProvider.getProductAccount('product.beta', 0); + + const ids = new Set(seen.map(e => e.productId)); + expect(ids.has('product.alpha')).toBe(true); + expect(ids.has('product.beta')).toBe(true); + + unsubscribe(); + a.container.dispose(); + b.container.dispose(); + }); + + it('container-level and global hook observe the same events for one container', async () => { + const { container, accountsProvider } = setup('product.alpha'); + + const containerEvents: HostApiDebugMessageEvent[] = []; + const globalEvents: HostApiDebugMessageEvent[] = []; + + container.onDebugMessage(event => containerEvents.push(event)); + const unsubscribeGlobal = onHostApiDebugMessage(event => { + if (event.productId === 'product.alpha') globalEvents.push(event); + }); + + await accountsProvider.getProductAccount('product.alpha', 0); + + expect(containerEvents.length).toBe(globalEvents.length); + expect(containerEvents.length).toBeGreaterThan(0); + expect(containerEvents).toEqual(globalEvents); + + unsubscribeGlobal(); + }); + + it('disposes the global-bus forwarder when the container is disposed', async () => { + const { container, accountsProvider } = setup('product.gamma'); + + const seen = vi.fn<(e: HostApiDebugMessageEvent) => void>(); + const unsubscribe = onHostApiDebugMessage(event => { + if (event.productId === 'product.gamma') seen(event); + }); + + await accountsProvider.getProductAccount('product.gamma', 0); + const beforeDispose = seen.mock.calls.length; + expect(beforeDispose).toBeGreaterThan(0); + + container.dispose(); + seen.mockClear(); + + // a fresh, separately tagged container should not surface as 'product.gamma' + const fresh = setup('product.delta'); + await fresh.accountsProvider.getProductAccount('product.delta', 0); + + expect(seen).not.toHaveBeenCalled(); + + unsubscribe(); + fresh.container.dispose(); + }); + + it('container-level unsubscribe stops further events without affecting the global bus', async () => { + const { container, accountsProvider } = setup('product.alpha'); + + const containerListener = vi.fn<(e: HostApiDebugMessageEvent) => void>(); + const globalListener = vi.fn<(e: HostApiDebugMessageEvent) => void>(); + const unsubscribeContainer = container.onDebugMessage(containerListener); + const unsubscribeGlobal = onHostApiDebugMessage(event => { + if (event.productId === 'product.alpha') globalListener(event); + }); + + await accountsProvider.getProductAccount('product.alpha', 0); + expect(containerListener).toHaveBeenCalled(); + expect(globalListener).toHaveBeenCalled(); + + unsubscribeContainer(); + containerListener.mockClear(); + globalListener.mockClear(); + + await accountsProvider.getProductAccount('product.alpha', 0); + expect(containerListener).not.toHaveBeenCalled(); + expect(globalListener).toHaveBeenCalled(); + + unsubscribeGlobal(); + container.dispose(); + }); +}); diff --git a/package-lock.json b/package-lock.json index 83520a20..a123f2bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4116,7 +4116,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", @@ -4159,7 +4158,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4181,7 +4179,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4203,7 +4200,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4225,7 +4221,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4247,7 +4242,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4269,7 +4263,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4291,7 +4284,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4313,7 +4305,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4335,7 +4326,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4357,7 +4347,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4379,7 +4368,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4401,7 +4389,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -4423,7 +4410,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -10361,7 +10347,6 @@ "dev": true, "license": "Apache-2.0", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -10586,7 +10571,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "prr": "~1.0.1" }, @@ -11977,7 +11961,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -12036,7 +12019,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "bin": { "image-size": "bin/image-size.js" }, @@ -12728,7 +12710,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "pify": "^4.0.1", "semver": "^5.6.0" @@ -12744,7 +12725,6 @@ "dev": true, "license": "ISC", "optional": true, - "peer": true, "bin": { "semver": "bin/semver" } @@ -12787,7 +12767,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "bin": { "mime": "cli.js" }, @@ -13017,7 +12996,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "iconv-lite": "^0.6.3", "sax": "^1.2.4" @@ -13071,8 +13049,7 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/node-domexception": { "version": "1.0.0", @@ -13825,7 +13802,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=6" } @@ -14147,8 +14123,7 @@ "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/pump": { "version": "3.0.4", @@ -14843,8 +14818,7 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/sass": { "version": "1.99.0", @@ -14874,7 +14848,6 @@ "dev": true, "license": "BlueOak-1.0.0", "optional": true, - "peer": true, "engines": { "node": ">=11.0.0" } @@ -16999,6 +16972,7 @@ "@noble/hashes": "2.2.0", "@novasamatech/host-api": "0.7.8", "@polkadot-api/substrate-client": "^0.7.0", + "nanoevents": "9.1.0", "nanoid": "5.1.9", "neverthrow": "^8.2.0", "polkadot-api": ">=2" diff --git a/packages/host-api/src/index.ts b/packages/host-api/src/index.ts index 3f8b561d..360f2dcc 100644 --- a/packages/host-api/src/index.ts +++ b/packages/host-api/src/index.ts @@ -1,5 +1,6 @@ export type { ConnectionStatus, + DebugMessageEvent, HostApiMethod, Logger, RequestHandler, @@ -7,6 +8,7 @@ export type { SubscriptionHandler, Transport, } from './types.js'; +export type { MessagePayloadSchema } from './protocol/messageCodec.js'; export type { Provider } from './provider.js'; export { createRequestId } from './helpers.js'; diff --git a/packages/host-api/src/transport.spec.ts b/packages/host-api/src/transport.spec.ts index c006f1b3..6d4f96d1 100644 --- a/packages/host-api/src/transport.spec.ts +++ b/packages/host-api/src/transport.spec.ts @@ -1,9 +1,12 @@ +import { enumValue } from '@novasamatech/scale'; import { createNanoEvents } from 'nanoevents'; import { describe, expect, it, vi } from 'vitest'; +import { JAM_CODEC_PROTOCOL_ID } from './constants.js'; import { createDefaultLogger } from './logger.js'; import type { Provider } from './provider.js'; import { createTransport } from './transport.js'; +import type { DebugMessageEvent } from './types.js'; function createProviders() { type Events = 'toHost' | 'toSdk'; @@ -25,6 +28,8 @@ function createProviders() { }; } +const samplePayload = () => enumValue('host_handshake_request', enumValue('v1', JAM_CODEC_PROTOCOL_ID)); + describe('transport', () => { describe('subscription', () => { it('should multiplex subscriptions', () => { @@ -76,4 +81,151 @@ describe('transport', () => { expect(s2Handler).toHaveBeenCalledTimes(2); }); }); + + describe('debug hook', () => { + it('emits outgoing events when postMessage is called and still delivers the message', () => { + const providers = createProviders(); + const host = createTransport(providers.host); + const sdk = createTransport(providers.sdk); + + const debugListener = vi.fn<(e: DebugMessageEvent) => void>(); + host.onDebugMessage(debugListener); + + const sdkReceived = vi.fn(); + sdk.listenMessages('host_handshake_request', sdkReceived); + + const requestId = 'req-1'; + const payload = samplePayload(); + host.postMessage(requestId, payload); + + expect(debugListener).toHaveBeenCalledTimes(1); + expect(debugListener).toHaveBeenCalledWith( + expect.objectContaining({ direction: 'outgoing', requestId, payload }), + ); + expect(sdkReceived).toHaveBeenCalledTimes(1); + expect(sdkReceived).toHaveBeenCalledWith(requestId, expect.objectContaining({ tag: 'host_handshake_request' })); + }); + + it('emits incoming events with decoded payload', () => { + const providers = createProviders(); + const host = createTransport(providers.host); + const sdk = createTransport(providers.sdk); + + const debugListener = vi.fn<(e: DebugMessageEvent) => void>(); + host.onDebugMessage(debugListener); + + const requestId = 'req-2'; + const payload = samplePayload(); + sdk.postMessage(requestId, payload); + + // host receives sdk's message, plus host's own outgoing handshake + // attempts (none yet, since isReady() wasn't called). Filter to incoming. + const incoming = debugListener.mock.calls.map(([event]) => event).filter(e => e.direction === 'incoming'); + + expect(incoming).toHaveLength(1); + expect(incoming[0]).toEqual( + expect.objectContaining({ + direction: 'incoming', + requestId, + payload: expect.objectContaining({ tag: 'host_handshake_request' }), + }), + ); + }); + + it('supports multiple listeners and stops after unsubscribe', () => { + const providers = createProviders(); + const host = createTransport(providers.host); + + const a = vi.fn<(e: DebugMessageEvent) => void>(); + const b = vi.fn<(e: DebugMessageEvent) => void>(); + const unsubscribeA = host.onDebugMessage(a); + host.onDebugMessage(b); + + host.postMessage('req-a', samplePayload()); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + + unsubscribeA(); + // calling unsubscribe twice must be a no-op + unsubscribeA(); + + host.postMessage('req-b', samplePayload()); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(2); + }); + + it('survives a throwing listener without breaking delivery', () => { + const providers = createProviders(); + const host = createTransport(providers.host); + const sdk = createTransport(providers.sdk); + + // Swap console.error directly so the expected throws don't pollute test + // output. Transport routes debug-callback failures to console.error (not + // provider.logger) so they stay distinct from real protocol errors. + const originalConsoleError = console.error; + const errorSpy = vi.fn(); + console.error = errorSpy; + try { + host.onDebugMessage(() => { + throw new Error('listener boom'); + }); + const goodListener = vi.fn<(e: DebugMessageEvent) => void>(); + host.onDebugMessage(goodListener); + + const sdkReceived = vi.fn(); + sdk.listenMessages('host_handshake_request', sdkReceived); + + // outgoing: a throwing listener must not block messageProvider.postMessage + host.postMessage('out-1', samplePayload()); + expect(sdkReceived).toHaveBeenCalledTimes(1); + + // incoming: a throwing listener must not block other host listenMessages subscribers + const hostReceived = vi.fn(); + host.listenMessages('host_handshake_request', hostReceived); + sdk.postMessage('in-1', samplePayload()); + expect(hostReceived).toHaveBeenCalledTimes(1); + + // the second good listener still fired despite the first one throwing + expect(goodListener).toHaveBeenCalled(); + // and the throws were observed on console.error, not propagated + expect(errorSpy).toHaveBeenCalled(); + } finally { + console.error = originalConsoleError; + } + }); + + it('cleans up the debug subscription on destroy() and blocks further sends', () => { + const providers = createProviders(); + const host = createTransport(providers.host); + const sdk = createTransport(providers.sdk); + + const listener = vi.fn<(e: DebugMessageEvent) => void>(); + host.onDebugMessage(listener); + + host.destroy(); + + // postMessage on a destroyed transport throws + expect(() => host.postMessage('after-destroy', samplePayload())).toThrow(/Transport is disposed/); + + // incoming traffic from the peer no longer surfaces to the listener + sdk.postMessage('in-after-destroy', samplePayload()); + expect(listener).not.toHaveBeenCalled(); + }); + + it('does not emit outgoing events when no listener is attached', () => { + const providers = createProviders(); + const host = createTransport(providers.host); + + // sanity: no listener attached, postMessage works fine + expect(() => host.postMessage('req', samplePayload())).not.toThrow(); + + // attach + detach + send: no events should fire to the (now-detached) listener + const listener = vi.fn<(e: DebugMessageEvent) => void>(); + const unsubscribe = host.onDebugMessage(listener); + unsubscribe(); + + host.postMessage('req2', samplePayload()); + expect(listener).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/host-api/src/transport.ts b/packages/host-api/src/transport.ts index d79a56c6..3d1f558f 100644 --- a/packages/host-api/src/transport.ts +++ b/packages/host-api/src/transport.ts @@ -16,6 +16,7 @@ import { HandshakeErr } from './protocol/v1/handshake.js'; import type { Provider } from './provider.js'; import type { ConnectionStatus, + DebugMessageEvent, HostApiMethod, MessageProvider, RequestHandler, @@ -90,6 +91,7 @@ export function createTransport(provider: Provider): Transport { const events = createNanoEvents<{ connectionStatus: (status: ConnectionStatus) => void; + debugMessage: (m: DebugMessageEvent) => void; destroy: VoidFunction; }>(); @@ -130,6 +132,28 @@ export function createTransport(provider: Provider): Transport { // subscriptions management (multiplexing) const activeSubscriptions: Map = new Map(); + // Lazy provider subscription — zero per-message decode cost while no + // debug listener is attached. + let debugListenerCount = 0; + let debugProviderUnsubscribe: VoidFunction | null = null; + + function ensureDebugProviderSubscription(): void { + if (debugProviderUnsubscribe) return; + debugProviderUnsubscribe = messageProvider.subscribe(message => { + events.emit('debugMessage', { + direction: 'incoming', + requestId: message.requestId, + payload: message.payload, + }); + }); + } + + function maybeDisposeDebugProviderSubscription(): void { + if (debugListenerCount > 0) return; + debugProviderUnsubscribe?.(); + debugProviderUnsubscribe = null; + } + const transport: Transport = { provider, @@ -426,6 +450,10 @@ export function createTransport(provider: Provider): Transport { postMessage(requestId, payload) { checks(); + if (debugListenerCount > 0) { + events.emit('debugMessage', { direction: 'outgoing', requestId, payload }); + } + messageProvider.postMessage({ requestId, payload }); }, @@ -457,12 +485,42 @@ export function createTransport(provider: Provider): Transport { destroy() { disposed = true; + debugProviderUnsubscribe?.(); + debugProviderUnsubscribe = null; + debugListenerCount = 0; provider.dispose(); changeConnectionStatus('disconnected'); events.emit('destroy'); events.events = {}; handshakeAbortController.abort('Transport disposed'); }, + onDebugMessage(callback) { + debugListenerCount++; + ensureDebugProviderSubscription(); + // Wrap each listener individually: nanoevents iterates listeners + // synchronously and a throw aborts the loop, so without per-listener + // isolation a single broken listener could starve siblings *and* + // (on the incoming side) starve unrelated messageProvider subscribers. + // Route to console.error (not provider.logger.error) so debug-callback + // bugs stay distinct from real protocol errors — matches the same + // policy used by host-papp's debugBus. + const safeCallback = (event: DebugMessageEvent) => { + try { + callback(event); + } catch (e) { + console.error('debug listener threw', e); + } + }; + const unsubscribe = events.on('debugMessage', safeCallback); + let disposed = false; + return () => { + if (disposed) return; + disposed = true; + unsubscribe(); + debugListenerCount--; + maybeDisposeDebugProviderSubscription(); + }; + }, }; if (provider.isCorrectEnvironment()) { diff --git a/packages/host-api/src/types.ts b/packages/host-api/src/types.ts index b6f5a423..54527603 100644 --- a/packages/host-api/src/types.ts +++ b/packages/host-api/src/types.ts @@ -43,6 +43,17 @@ export type MessageProvider = { subscribe(fn: (message: CodecType) => void): VoidFunction; }; +/** + * EXPERIMENTAL. A single message observed on the transport, in its + * decoded (non-SCALE) form. Intended for host-side introspection. + */ +export type DebugMessageEvent = { + /** `outgoing` = sent by this side via `postMessage`; `incoming` = received from the peer. */ + direction: 'incoming' | 'outgoing'; + requestId: string; + payload: MessagePayloadSchema; +}; + export type Transport = { readonly provider: Provider; @@ -80,4 +91,13 @@ export type Transport = { callback: (requestId: string, data: PickMessagePayload) => void, onError?: (error: unknown) => void, ): VoidFunction; + + /** + * EXPERIMENTAL. Subscribe to every message crossing this transport + * in either direction, in decoded form. Returns an unsubscribe + * function. Multiple listeners are supported; the underlying + * provider is subscribed lazily — there is no per-message cost + * while no listener is attached. + */ + onDebugMessage(callback: (event: DebugMessageEvent) => void): VoidFunction; }; diff --git a/packages/host-container/package.json b/packages/host-container/package.json index 3ce29ffb..ee006602 100644 --- a/packages/host-container/package.json +++ b/packages/host-container/package.json @@ -29,6 +29,7 @@ "polkadot-api": ">=2", "@polkadot-api/substrate-client": "^0.7.0", "@novasamatech/host-api": "0.7.8", + "nanoevents": "9.1.0", "nanoid": "5.1.9", "neverthrow": "^8.2.0" }, diff --git a/packages/host-container/src/createContainer.ts b/packages/host-container/src/createContainer.ts index 4f486c0b..4e8c2d7c 100644 --- a/packages/host-container/src/createContainer.ts +++ b/packages/host-container/src/createContainer.ts @@ -43,7 +43,14 @@ import type { Result } from 'neverthrow'; import { err, errAsync, ok, okAsync } from 'neverthrow'; import { createChainConnectionManager } from './chainConnectionManager.js'; -import type { CodecValue, Container, ContainerRequestHandler, UnwrapErrorResponse } from './types.js'; +import { emitHostApiDebugMessage, registerHostApiDebugSource } from './debugBus.js'; +import type { + CodecValue, + Container, + ContainerRequestHandler, + CreateContainerOptions, + UnwrapErrorResponse, +} from './types.js'; const UNSUPPORTED_MESSAGE_FORMAT_ERROR = 'Unsupported message format'; @@ -82,11 +89,24 @@ function guardVersion + transport.onDebugMessage(({ direction, requestId, payload }) => { + emitHostApiDebugMessage({ direction, productId, requestId, payload }); + }), + ); + transport.onDestroy(unregisterGlobalDebugSource); function init() { // init status subscription @@ -1114,5 +1134,11 @@ export function createContainer(provider: Provider): Container { dispose() { transport.destroy(); }, + + onDebugMessage(callback) { + return transport.onDebugMessage(({ direction, requestId, payload }) => { + callback({ direction, productId, requestId, payload }); + }); + }, }; } diff --git a/packages/host-container/src/debugBus.ts b/packages/host-container/src/debugBus.ts new file mode 100644 index 00000000..5c6fed71 --- /dev/null +++ b/packages/host-container/src/debugBus.ts @@ -0,0 +1,78 @@ +import { createNanoEvents } from 'nanoevents'; + +import type { HostApiDebugMessageEvent } from './types.js'; + +/** + * EXPERIMENTAL: process-global bus that aggregates debug events from + * every container created in this process. Subscribing here gives a + * single subscriber visibility into all host ↔ product traffic across + * every active container, annotated with the `productId` passed to + * `createContainer` (if any). + */ +const bus = createNanoEvents<{ + message: (event: HostApiDebugMessageEvent) => void; +}>(); + +type DebugSource = () => VoidFunction; +const sources = new Set(); +const activeSources = new Map(); +let subscriberCount = 0; + +function activateSource(source: DebugSource): void { + if (activeSources.has(source)) return; + activeSources.set(source, source()); +} + +function deactivateSource(source: DebugSource): void { + const unsubscribe = activeSources.get(source); + if (!unsubscribe) return; + activeSources.delete(source); + unsubscribe(); +} + +/** @internal Used by `createContainer` to forward its transport's debug events. */ +export function emitHostApiDebugMessage(event: HostApiDebugMessageEvent): void { + bus.emit('message', event); +} + +/** + * @internal Register a transport-level forwarder for the global bus. + * The source is activated only while the bus has at least one subscriber + * and deactivated when the last one unsubscribes — this preserves the + * transport's lazy decode path (no `Message.dec` per frame) when nobody + * is listening downstream. + */ +export function registerHostApiDebugSource(source: DebugSource): VoidFunction { + sources.add(source); + if (subscriberCount > 0) activateSource(source); + let disposed = false; + return () => { + if (disposed) return; + disposed = true; + sources.delete(source); + deactivateSource(source); + }; +} + +/** + * EXPERIMENTAL. Subscribe to every host ↔ product message across all + * containers in the current process. Returns an unsubscribe function. + */ +export function onHostApiDebugMessage(callback: (event: HostApiDebugMessageEvent) => void): VoidFunction { + const wasZero = subscriberCount === 0; + subscriberCount++; + if (wasZero) { + for (const source of sources) activateSource(source); + } + const unsubscribe = bus.on('message', callback); + let disposed = false; + return () => { + if (disposed) return; + disposed = true; + unsubscribe(); + subscriberCount--; + if (subscriberCount === 0) { + for (const source of [...activeSources.keys()]) deactivateSource(source); + } + }; +} diff --git a/packages/host-container/src/index.ts b/packages/host-container/src/index.ts index 0e16c52e..e2c6ad01 100644 --- a/packages/host-container/src/index.ts +++ b/packages/host-container/src/index.ts @@ -1,7 +1,8 @@ export { createWebviewProvider } from './createWebviewProvider.js'; export { createIframeProvider } from './createIframeProvider.js'; export { createContainer } from './createContainer.js'; -export type { Container, ContainerHandlerOf } from './types.js'; +export type { Container, ContainerHandlerOf, CreateContainerOptions, HostApiDebugMessageEvent } from './types.js'; +export { onHostApiDebugMessage } from './debugBus.js'; export { deriveProductEntropy } from './deriveEntropy.js'; export { createRateLimiter } from './rateLimiter.js'; diff --git a/packages/host-container/src/types.ts b/packages/host-container/src/types.ts index 437ca122..25a0dee3 100644 --- a/packages/host-container/src/types.ts +++ b/packages/host-container/src/types.ts @@ -4,6 +4,7 @@ import type { ConnectionStatus, HexString, HostApiProtocol, + MessagePayloadSchema, Subscription, VersionedProtocolRequest, VersionedProtocolSubscription, @@ -68,6 +69,27 @@ type InferHandler< export type ContainerHandlerOf any> = Parameters[0]; +/** + * EXPERIMENTAL. Event describing a single message observed on a + * container's transport, in decoded form, tagged with the productId + * that was passed to `createContainer`. + */ +export type HostApiDebugMessageEvent = { + direction: 'incoming' | 'outgoing'; + productId: string | undefined; + requestId: string; + payload: MessagePayloadSchema; +}; + +export type CreateContainerOptions = { + /** + * Optional identifier for the product this container talks to. + * When set, every debug event emitted via `onDebugMessage` and the + * global `onHostApiDebugMessage` bus is tagged with this value. + */ + productId?: string; +}; + export type Container = { // host @@ -160,4 +182,11 @@ export type Container = { dispose(): void; subscribeProductConnectionStatus(callback: (connectionStatus: ConnectionStatus) => void): VoidFunction; + + /** + * EXPERIMENTAL. Subscribe to every message crossing this container's + * transport in either direction, in decoded form. Returns an + * unsubscribe function. + */ + onDebugMessage(callback: (event: HostApiDebugMessageEvent) => void): VoidFunction; }; diff --git a/packages/host-papp/__tests__/attestationService.spec.ts b/packages/host-papp/__tests__/attestationService.spec.ts index db12d797..c2997582 100644 --- a/packages/host-papp/__tests__/attestationService.spec.ts +++ b/packages/host-papp/__tests__/attestationService.spec.ts @@ -21,6 +21,8 @@ vi.mock('../src/crypto.js', async importOriginal => { }; }); +import { onHostPappDebugMessage } from '../src/debugBus.js'; +import type { AttestationDebugEvent } from '../src/debugTypes.js'; import { createAttestationService, withRetry } from '../src/sso/auth/attestationService.js'; describe('withRetry', () => { @@ -154,4 +156,118 @@ describe('createAttestationService', () => { expect(signAndSubmit).not.toHaveBeenCalled(); }); }); + + describe('debug emits', () => { + const FLOW_ID = 'flow-attestation-test'; + + function captureAttestationEvents() { + const events: AttestationDebugEvent[] = []; + const unsubscribe = onHostPappDebugMessage(event => { + if (event.layer === 'attestation') events.push(event); + }); + return { events, unsubscribe }; + } + + function makeRegisterableService() { + const subscribeSpy = vi.fn( + (handlers: { next: (event: { type: string; found?: boolean; ok?: boolean }) => void }) => { + // defer next() so the `subscription` binding inside the production code + // is in scope by the time `subscription.unsubscribe()` is called + queueMicrotask(() => handlers.next({ type: 'finalized', ok: true })); + return { unsubscribe: vi.fn() }; + }, + ); + const mockApi = { + query: { + PeopleLite: { + AttestationAllowance: { getValue: vi.fn().mockResolvedValue(10) }, + }, + }, + tx: { + PeopleLite: { + increase_attestation_allowance: vi.fn(() => ({ decodedCall: {} })), + attest: vi.fn(() => ({ signSubmitAndWatch: () => ({ subscribe: subscribeSpy }) })), + }, + Sudo: { + sudo: vi.fn(() => ({ signAndSubmit: vi.fn() })), + }, + }, + }; + const lazyClient = { getClient: () => ({ getUnsafeApi: () => mockApi }) } as any; + return createAttestationService(lazyClient, FLOW_ID); + } + + it('claimUsername emits username_claimed', () => { + const { events, unsubscribe } = captureAttestationEvents(); + try { + const service = makeRegisterableService(); + const username = service.claimUsername(); + expect(events).toContainEqual( + expect.objectContaining({ + event: 'username_claimed', + flowId: FLOW_ID, + payload: { username }, + }), + ); + } finally { + unsubscribe(); + } + }); + + it('grantVerifierAllowance emits allowance_granted on success', async () => { + const { events, unsubscribe } = captureAttestationEvents(); + try { + const service = makeRegisterableService(); + const result = await service.grantVerifierAllowance(createMockAccount()); + expect(result.isOk()).toBe(true); + expect(events.some(e => e.event === 'allowance_granted' && e.flowId === FLOW_ID)).toBe(true); + } finally { + unsubscribe(); + } + }); + + it('deriveAttestationParams emits vrf_proof_generated', async () => { + const { events, unsubscribe } = captureAttestationEvents(); + try { + const service = makeRegisterableService(); + const result = await service.deriveAttestationParams('guest.0001', createMockAccount(), createMockAccount()); + expect(result.isOk()).toBe(true); + expect(events.some(e => e.event === 'vrf_proof_generated' && e.flowId === FLOW_ID)).toBe(true); + } finally { + unsubscribe(); + } + }); + + it('registerLitePerson emits person_registered after successful submission', async () => { + const { events, unsubscribe } = captureAttestationEvents(); + try { + const service = makeRegisterableService(); + const result = await service.registerLitePerson('guest.0001', createMockAccount(), createMockAccount()); + expect(result.isOk()).toBe(true); + const personRegistered = events.find(e => e.event === 'person_registered'); + expect(personRegistered).toBeDefined(); + expect(personRegistered?.flowId).toBe(FLOW_ID); + expect(personRegistered?.payload).toMatchObject({ username: 'guest.0001' }); + } finally { + unsubscribe(); + } + }); + + it('omits emits entirely when no debugFlowId is provided', () => { + const { events, unsubscribe } = captureAttestationEvents(); + try { + // service constructed without flowId — must not emit anything + const mockApi = { + query: { PeopleLite: { AttestationAllowance: { getValue: vi.fn().mockResolvedValue(10) } } }, + tx: { PeopleLite: { increase_attestation_allowance: vi.fn() }, Sudo: { sudo: vi.fn() } }, + }; + const lazyClient = { getClient: () => ({ getUnsafeApi: () => mockApi }) } as any; + const service = createAttestationService(lazyClient); + service.claimUsername(); + expect(events).toHaveLength(0); + } finally { + unsubscribe(); + } + }); + }); }); diff --git a/packages/host-papp/__tests__/auth.spec.ts b/packages/host-papp/__tests__/auth.spec.ts index 5b46a7c5..9c6d67d0 100644 --- a/packages/host-papp/__tests__/auth.spec.ts +++ b/packages/host-papp/__tests__/auth.spec.ts @@ -2,6 +2,8 @@ import type { LazyClient, Statement, StatementStoreAdapter } from '@novasamatech import { errAsync, ok, okAsync } from 'neverthrow'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { onHostPappDebugMessage } from '../src/debugBus.js'; +import type { HostPappDebugEvent } from '../src/debugTypes.js'; import { createAuth } from '../src/sso/auth/impl.js'; import type { UserSecretRepository } from '../src/sso/userSecretRepository.js'; import type { UserSessionRepository } from '../src/sso/userSessionRepository.js'; @@ -425,4 +427,112 @@ describe('createAuth', () => { expect(auth.attestationStatus.read()).toEqual({ step: 'none' }); }); }); + + describe('debug emits', () => { + function captureEvents() { + const events: HostPappDebugEvent[] = []; + const unsubscribe = onHostPappDebugMessage(event => events.push(event)); + return { events, unsubscribe }; + } + + it('emits pairing_started and attestation.started eagerly when authenticate() is called', () => { + const { auth } = buildHarness(); + const { events, unsubscribe } = captureEvents(); + try { + void auth.authenticate(); + + expect(events.find(e => e.layer === 'sso' && e.event === 'pairing_started')).toMatchObject({ + payload: { metadata: 'test-metadata' }, + }); + expect(events.some(e => e.layer === 'attestation' && e.event === 'started')).toBe(true); + } finally { + auth.abortAuthentication(); + unsubscribe(); + } + }); + + it('emits the full SSO pairing sequence and attestation.completed on a successful authenticate', async () => { + const harness = buildHarness(); + const { events, unsubscribe } = captureEvents(); + try { + const promise = harness.auth.authenticate(); + await harness.waitForSubscription(); + harness.deliverHandshake(); + const result = await promise; + expect(result.isOk()).toBe(true); + + const ssoSequence = events.filter(e => e.layer === 'sso').map(e => e.event); + expect(ssoSequence).toEqual([ + 'pairing_started', + 'deeplink_generated', + 'awaiting_response', + 'response_received', + 'session_established', + ]); + expect(events.find(e => e.layer === 'attestation' && e.event === 'completed')).toBeDefined(); + } finally { + unsubscribe(); + } + }); + + it('shares one flowId across every event in a single pairing run', async () => { + const harness = buildHarness(); + const { events, unsubscribe } = captureEvents(); + try { + const promise = harness.auth.authenticate(); + await harness.waitForSubscription(); + harness.deliverHandshake(); + await promise; + + const ssoFlowIds = new Set(events.filter(e => e.layer === 'sso').map(e => e.flowId)); + expect(ssoFlowIds.size).toBe(1); + + // attestation runs in its own flow, distinct from the SSO pairing flow + const attestationFlowIds = new Set(events.filter(e => e.layer === 'attestation').map(e => e.flowId)); + expect(attestationFlowIds.size).toBe(1); + expect(attestationFlowIds).not.toEqual(ssoFlowIds); + } finally { + unsubscribe(); + } + }); + + it('emits pairing_failed and attestation.failed when the chain rejects with a non-abort error', async () => { + mocks.registerLitePerson.mockReturnValue(errAsync(new Error('chain offline'))); + const harness = buildHarness(); + const { events, unsubscribe } = captureEvents(); + try { + const promise = harness.auth.authenticate(); + await harness.waitForSubscription(); + harness.deliverHandshake(); + const result = await promise; + + expect(result.isErr()).toBe(true); + const pairingFailed = events.find(e => e.layer === 'sso' && e.event === 'pairing_failed'); + const attestationFailed = events.find(e => e.layer === 'attestation' && e.event === 'failed'); + expect(pairingFailed?.payload).toMatchObject({ reason: 'chain offline' }); + expect(attestationFailed?.payload).toMatchObject({ reason: 'chain offline' }); + } finally { + unsubscribe(); + } + }); + + it('does not emit pairing_failed or attestation.failed when authentication is aborted by the user', async () => { + const harness = buildHarness(); + const { events, unsubscribe } = captureEvents(); + try { + const promise = harness.auth.authenticate(); + await harness.waitForSubscription(); + harness.auth.abortAuthentication(); + harness.deliverPage([]); + const result = await promise; + + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toBeNull(); + expect(events.some(e => e.layer === 'sso' && e.event === 'pairing_failed')).toBe(false); + expect(events.some(e => e.layer === 'attestation' && e.event === 'failed')).toBe(false); + } finally { + unsubscribe(); + } + }); + }); }); diff --git a/packages/host-papp/__tests__/sessionManager.spec.ts b/packages/host-papp/__tests__/sessionManager.spec.ts new file mode 100644 index 00000000..aa611111 --- /dev/null +++ b/packages/host-papp/__tests__/sessionManager.spec.ts @@ -0,0 +1,176 @@ +import type { StatementStoreAdapter } from '@novasamatech/statement-store'; +import type { StorageAdapter } from '@novasamatech/storage-adapter'; +import { okAsync } from 'neverthrow'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + createUserSession: vi.fn(), +})); + +vi.mock('../src/sso/sessionManager/userSession.js', () => ({ + createUserSession: mocks.createUserSession, +})); + +vi.mock('@novasamatech/statement-store', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + createEncryption: vi.fn(() => ({ decrypt: vi.fn(), encrypt: vi.fn() })), + }; +}); + +vi.mock('../src/sso/ssoSessionProver.js', () => ({ + createSsoStatementProver: vi.fn(() => ({})), +})); + +import { onHostPappDebugMessage } from '../src/debugBus.js'; +import type { HostPappDebugEvent } from '../src/debugTypes.js'; +import { createSsoSessionManager } from '../src/sso/sessionManager/impl.js'; +import type { StoredUserSession, UserSessionRepository } from '../src/sso/userSessionRepository.js'; + +type RepoCallback = (sessions: StoredUserSession[]) => void; + +function buildHarness() { + let deliver: RepoCallback | null = null; + + const repoSubscribe = vi.fn((cb: RepoCallback) => { + deliver = cb; + return () => { + deliver = null; + }; + }); + + const ssoSessionRepository = { + subscribe: repoSubscribe, + filter: vi.fn(() => okAsync(undefined)), + add: vi.fn(() => okAsync(undefined)), + } as unknown as UserSessionRepository; + + const userSecretRepository = { + clear: vi.fn(() => okAsync(undefined)), + } as any; + const statementStore = {} as StatementStoreAdapter; + const storage = {} as StorageAdapter; + + const manager = createSsoSessionManager({ + ssoSessionRepository, + userSecretRepository, + statementStore, + storage, + }); + + return { + manager, + repoSubscribe, + push(sessions: StoredUserSession[]) { + if (!deliver) throw new Error('ssoSessionRepository.subscribe was not called'); + deliver(sessions); + }, + }; +} + +function makeStoredUserSession(id: string): StoredUserSession { + return { + id, + localAccount: { accountId: new Uint8Array(32), kind: 'local' } as any, + remoteAccount: { accountId: new Uint8Array(32), publicKey: new Uint8Array(32), kind: 'remote' } as any, + rootAccountId: new Uint8Array(32) as any, + } as StoredUserSession; +} + +function captureEvents() { + const events: HostPappDebugEvent[] = []; + const unsubscribe = onHostPappDebugMessage(event => events.push(event)); + return { events, unsubscribe }; +} + +beforeEach(() => { + mocks.createUserSession.mockReset().mockImplementation((args: { userSession: StoredUserSession }) => ({ + id: args.userSession.id, + localAccount: args.userSession.localAccount, + remoteAccount: args.userSession.remoteAccount, + rootAccountId: args.userSession.rootAccountId, + subscribe: vi.fn(() => vi.fn()), + dispose: vi.fn(), + sendDisconnectMessage: vi.fn(() => okAsync(undefined)), + signPayload: vi.fn(), + signRaw: vi.fn(), + getRingVrfAlias: vi.fn(), + requestResourceAllocation: vi.fn(), + })); +}); + +// Regression coverage: session.opened and session.terminated should fire when +// the repository subscription adds and removes sessions. If a future refactor +// drops either emit, the matching assertion below fails. +describe('createSsoSessionManager debug emits', () => { + it('emits session.opened with flowId === sessionId when a new session appears in the repository', () => { + const harness = buildHarness(); + const { events, unsubscribe } = captureEvents(); + try { + const session = makeStoredUserSession('session-A'); + harness.push([session]); + + const opened = events.find(e => e.layer === 'session' && e.event === 'opened'); + expect(opened).toMatchObject({ + flowId: 'session-A', + payload: { sessionId: 'session-A' }, + }); + } finally { + unsubscribe(); + } + }); + + it('emits session.terminated with flowId === sessionId when a session leaves the repository', () => { + const harness = buildHarness(); + const { events, unsubscribe } = captureEvents(); + try { + const session = makeStoredUserSession('session-B'); + harness.push([session]); + harness.push([]); + + const terminated = events.find(e => e.layer === 'session' && e.event === 'terminated'); + expect(terminated).toMatchObject({ + flowId: 'session-B', + payload: { sessionId: 'session-B' }, + }); + } finally { + unsubscribe(); + } + }); + + it('does not re-emit session.opened for a session that is already active', () => { + const harness = buildHarness(); + const { events, unsubscribe } = captureEvents(); + try { + const session = makeStoredUserSession('session-C'); + harness.push([session]); + harness.push([session]); + + const opens = events.filter(e => e.layer === 'session' && e.event === 'opened' && e.flowId === 'session-C'); + expect(opens).toHaveLength(1); + } finally { + unsubscribe(); + } + }); + + it('emits opened/terminated for each session in a multi-session transition', () => { + const harness = buildHarness(); + const { events, unsubscribe } = captureEvents(); + try { + const a = makeStoredUserSession('session-A'); + const b = makeStoredUserSession('session-B'); + harness.push([a, b]); + harness.push([b]); + + const openedIds = events.filter(e => e.layer === 'session' && e.event === 'opened').map(e => e.flowId); + const terminatedIds = events.filter(e => e.layer === 'session' && e.event === 'terminated').map(e => e.flowId); + + expect(openedIds).toContain('session-A'); + expect(openedIds).toContain('session-B'); + expect(terminatedIds).toEqual(['session-A']); + } finally { + unsubscribe(); + } + }); +}); diff --git a/packages/host-papp/__tests__/userSession.spec.ts b/packages/host-papp/__tests__/userSession.spec.ts new file mode 100644 index 00000000..b66f89fd --- /dev/null +++ b/packages/host-papp/__tests__/userSession.spec.ts @@ -0,0 +1,279 @@ +import type { Encryption, StatementProver, StatementStoreAdapter } from '@novasamatech/statement-store'; +import type { StorageAdapter } from '@novasamatech/storage-adapter'; +import { ResultAsync, errAsync, okAsync } from 'neverthrow'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + request: vi.fn(), + waitForRequestMessage: vi.fn(), + submitRequestMessage: vi.fn(), + sessionSubscribe: vi.fn(), + sessionDispose: vi.fn(), + fieldListRead: vi.fn(), + fieldListMutate: vi.fn(), + nanoid: vi.fn(), +})); + +vi.mock('nanoid', () => ({ nanoid: mocks.nanoid })); + +vi.mock('@novasamatech/statement-store', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + createSession: vi.fn(() => ({ + request: mocks.request, + waitForRequestMessage: mocks.waitForRequestMessage, + submitRequestMessage: mocks.submitRequestMessage, + subscribe: mocks.sessionSubscribe, + dispose: mocks.sessionDispose, + })), + }; +}); + +vi.mock('@novasamatech/storage-adapter', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + fieldListView: vi.fn(() => ({ + read: mocks.fieldListRead, + mutate: mocks.fieldListMutate, + })), + }; +}); + +import { onHostPappDebugMessage } from '../src/debugBus.js'; +import type { HostPappDebugEvent } from '../src/debugTypes.js'; +import { createUserSession } from '../src/sso/sessionManager/userSession.js'; +import type { StoredUserSession } from '../src/sso/userSessionRepository.js'; + +const SESSION_ID = 'user-session-1'; +const MSG_ID = 'msg-fixed'; + +function captureEvents() { + const events: HostPappDebugEvent[] = []; + const unsubscribe = onHostPappDebugMessage(event => events.push(event)); + return { events, unsubscribe }; +} + +function makeStoredUserSession(): StoredUserSession { + return { + id: SESSION_ID, + localAccount: { accountId: new Uint8Array(32), kind: 'local' } as any, + remoteAccount: { accountId: new Uint8Array(32), publicKey: new Uint8Array(32), kind: 'remote' } as any, + rootAccountId: new Uint8Array(32) as any, + } as StoredUserSession; +} + +function buildSession() { + return createUserSession({ + userSession: makeStoredUserSession(), + statementStore: {} as StatementStoreAdapter, + encryption: {} as Encryption, + storage: {} as StorageAdapter, + prover: {} as StatementProver, + }); +} + +beforeEach(() => { + mocks.nanoid.mockReset().mockReturnValue(MSG_ID); + mocks.request.mockReset(); + mocks.waitForRequestMessage.mockReset(); + mocks.submitRequestMessage.mockReset().mockReturnValue(okAsync(undefined)); + mocks.sessionSubscribe.mockReset(); + mocks.sessionDispose.mockReset(); + mocks.fieldListRead.mockReset().mockReturnValue(okAsync([])); + mocks.fieldListMutate.mockReset().mockReturnValue(okAsync(undefined)); +}); + +// Regression coverage: every debug emit site in userSession.ts should fire +// when the corresponding code path runs. If a future refactor drops an emit, +// the matching assertion below fails. +describe('createUserSession debug emits', () => { + describe('host actions', () => { + it('signPayload emits host_action_sent then host_action_response_received on success', async () => { + mocks.request.mockReturnValue(okAsync(undefined)); + mocks.waitForRequestMessage.mockReturnValue( + okAsync({ success: true, value: { signed: new Uint8Array() } as any }), + ); + + const session = buildSession(); + const { events, unsubscribe } = captureEvents(); + try { + const result = await session.signPayload({} as any); + expect(result.isOk()).toBe(true); + + const hostEvents = events + .filter(e => e.layer === 'session' && e.event.startsWith('host_action')) + .map(e => ({ event: e.event, flowId: e.flowId })); + expect(hostEvents).toEqual([ + { event: 'host_action_sent', flowId: MSG_ID }, + { event: 'host_action_response_received', flowId: MSG_ID }, + ]); + const sent = events.find(e => e.event === 'host_action_sent'); + expect(sent?.payload).toMatchObject({ + sessionId: SESSION_ID, + messageId: MSG_ID, + actionKind: 'SignRequest:Payload', + }); + } finally { + unsubscribe(); + } + }); + + it('signPayload emits host_action_failed when the request rejects', async () => { + mocks.request.mockReturnValue(errAsync(new Error('peer rejected'))); + + const session = buildSession(); + const { events, unsubscribe } = captureEvents(); + try { + const result = await session.signPayload({} as any); + expect(result.isErr()).toBe(true); + + const sent = events.find(e => e.event === 'host_action_sent'); + const failed = events.find(e => e.event === 'host_action_failed'); + expect(sent).toBeDefined(); + expect(failed).toMatchObject({ + flowId: MSG_ID, + payload: { sessionId: SESSION_ID, messageId: MSG_ID, reason: 'peer rejected' }, + }); + } finally { + unsubscribe(); + } + }); + + it('signRaw emits host_action_sent with actionKind SignRequest:Raw', async () => { + mocks.request.mockReturnValue(okAsync(undefined)); + mocks.waitForRequestMessage.mockReturnValue( + okAsync({ success: true, value: { signed: new Uint8Array() } as any }), + ); + + const session = buildSession(); + const { events, unsubscribe } = captureEvents(); + try { + await session.signRaw({} as any); + expect(events.find(e => e.event === 'host_action_sent')?.payload).toMatchObject({ + actionKind: 'SignRequest:Raw', + }); + } finally { + unsubscribe(); + } + }); + + it('getRingVrfAlias emits host_action_sent with actionKind RingVrfAliasRequest', async () => { + mocks.request.mockReturnValue(okAsync(undefined)); + mocks.waitForRequestMessage.mockReturnValue(okAsync({ success: true, value: new Uint8Array() as any })); + + const session = buildSession(); + const { events, unsubscribe } = captureEvents(); + try { + await session.getRingVrfAlias(new Uint8Array(32) as any, 'product.alpha'); + expect(events.find(e => e.event === 'host_action_sent')?.payload).toMatchObject({ + actionKind: 'RingVrfAliasRequest', + }); + } finally { + unsubscribe(); + } + }); + }); + + describe('peer actions', () => { + function makePeerMessage(messageId: string, innerTag: string) { + return { + type: 'request', + payload: { + status: 'parsed', + value: { + messageId, + data: { tag: 'v1', value: { tag: innerTag, value: undefined } }, + }, + }, + } as any; + } + + it('emits peer_action_received and peer_action_processed when the callback returns true', async () => { + let invokeHandler: ((messages: any[]) => void) | undefined; + mocks.sessionSubscribe.mockImplementation((_codec, handler) => { + invokeHandler = handler; + return vi.fn(); + }); + + const session = buildSession(); + const { events, unsubscribe } = captureEvents(); + try { + const callback = vi.fn(() => okAsync(true)); + session.subscribe(callback); + invokeHandler!([makePeerMessage('peer-msg-1', 'Disconnected')]); + + await new Promise(resolve => setImmediate(resolve)); + + const received = events.find(e => e.event === 'peer_action_received'); + const processed = events.find(e => e.event === 'peer_action_processed'); + expect(received).toMatchObject({ + flowId: 'peer-msg-1', + payload: { sessionId: SESSION_ID, messageId: 'peer-msg-1', actionKind: 'Disconnected' }, + }); + expect(processed).toMatchObject({ + flowId: 'peer-msg-1', + payload: { sessionId: SESSION_ID, messageId: 'peer-msg-1' }, + }); + } finally { + unsubscribe(); + } + }); + + it('emits peer_action_failed when the callback errors', async () => { + let invokeHandler: ((messages: any[]) => void) | undefined; + mocks.sessionSubscribe.mockImplementation((_codec, handler) => { + invokeHandler = handler; + return vi.fn(); + }); + // silence the console.error from the production code's orTee + const errorSpy = vi.spyOn(console, 'error').mockImplementation(vi.fn()); + + const session = buildSession(); + const { events, unsubscribe } = captureEvents(); + try { + const callback = vi.fn(() => errAsync(new Error('handler boom')) as unknown as ResultAsync); + session.subscribe(callback); + invokeHandler!([makePeerMessage('peer-msg-2', 'Disconnected')]); + + await new Promise(resolve => setImmediate(resolve)); + + const received = events.find(e => e.event === 'peer_action_received'); + const failed = events.find(e => e.event === 'peer_action_failed'); + expect(received).toBeDefined(); + expect(failed).toMatchObject({ + flowId: 'peer-msg-2', + payload: { sessionId: SESSION_ID, messageId: 'peer-msg-2', reason: 'handler boom' }, + }); + } finally { + unsubscribe(); + errorSpy.mockRestore(); + } + }); + + it('does not emit anything for messages that were already processed in a previous run', async () => { + let invokeHandler: ((messages: any[]) => void) | undefined; + mocks.sessionSubscribe.mockImplementation((_codec, handler) => { + invokeHandler = handler; + return vi.fn(); + }); + mocks.fieldListRead.mockReturnValue(okAsync(['peer-msg-3'])); + + const session = buildSession(); + const { events, unsubscribe } = captureEvents(); + try { + const callback = vi.fn(() => okAsync(true)); + session.subscribe(callback); + invokeHandler!([makePeerMessage('peer-msg-3', 'Disconnected')]); + + await new Promise(resolve => setImmediate(resolve)); + + expect(events.filter(e => e.layer === 'session')).toHaveLength(0); + expect(callback).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } + }); + }); +}); diff --git a/packages/host-papp/package.json b/packages/host-papp/package.json index b52fcecf..e665b5e4 100644 --- a/packages/host-papp/package.json +++ b/packages/host-papp/package.json @@ -18,6 +18,11 @@ "#/source": "./src/index.ts", "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./debug": { + "#/source": "./src/debug-public.ts", + "types": "./dist/debug-public.d.ts", + "default": "./dist/debug-public.js" } }, "files": [ diff --git a/packages/host-papp/src/debug-public.ts b/packages/host-papp/src/debug-public.ts new file mode 100644 index 00000000..9e88607f --- /dev/null +++ b/packages/host-papp/src/debug-public.ts @@ -0,0 +1,8 @@ +/** + * Lightweight public entry for the debug bus. Lives outside the main + * `index.ts` so consumers can import the debug surface without + * loading the attestation / session-manager modules, which pull in + * verifiablejs WASM that can't initialise in all environments. + */ +export { emitHostPappDebugMessage, hasHostPappDebugListeners, onHostPappDebugMessage } from './debugBus.js'; +export type { AttestationDebugEvent, HostPappDebugEvent, SessionDebugEvent, SsoDebugEvent } from './debugTypes.js'; diff --git a/packages/host-papp/src/debugBus.ts b/packages/host-papp/src/debugBus.ts new file mode 100644 index 00000000..bc37b1f3 --- /dev/null +++ b/packages/host-papp/src/debugBus.ts @@ -0,0 +1,68 @@ +import { createNanoEvents } from 'nanoevents'; +import { nanoid } from 'nanoid'; + +import type { HostPappDebugEvent } from './debugTypes.js'; + +/** + * EXPERIMENTAL. Module-level bus that aggregates debug events from + * every host-papp adapter created in this process. Mirrors the + * pattern used by `onHostApiDebugMessage` in + * `@novasamatech/host-container` so a single subscriber can observe + * pairing / attestation / session activity alongside TrUAPI traffic. + * + * Subscription is *lazy in spirit*: emit sites should call + * `hasHostPappDebugListeners()` before constructing payloads that + * are non-trivial to compute. + */ +const bus = createNanoEvents<{ + message: (event: HostPappDebugEvent) => void; +}>(); + +let listenerCount = 0; + +/** @internal For host-papp emitters. */ +export function emitHostPappDebugMessage(event: HostPappDebugEvent): void { + if (listenerCount === 0) return; + bus.emit('message', event); +} + +/** + * @internal Lets call sites skip non-trivial payload construction + * when nobody is listening. Equivalent in spirit to the lazy + * subscribe pattern in host-api's transport-level hook. + */ +export function hasHostPappDebugListeners(): boolean { + return listenerCount > 0; +} + +/** + * EXPERIMENTAL. Subscribe to every host-papp debug event across all + * adapters in the current process. Returns an unsubscribe function. + * + * Each listener is isolated: a throw inside one callback is logged + * to `console.error` and does not starve sibling subscribers — the + * same pattern host-api uses in its transport-level debug hook. + */ +export function onHostPappDebugMessage(callback: (event: HostPappDebugEvent) => void): VoidFunction { + listenerCount++; + const safeCallback = (event: HostPappDebugEvent) => { + try { + callback(event); + } catch (e) { + console.error('host-papp debug listener threw', e); + } + }; + const unsubscribe = bus.on('message', safeCallback); + let disposed = false; + return () => { + if (disposed) return; + disposed = true; + listenerCount = Math.max(0, listenerCount - 1); + unsubscribe(); + }; +} + +/** @internal Convenience for creating flow identifiers. */ +export function createFlowId(): string { + return nanoid(); +} diff --git a/packages/host-papp/src/debugTypes.ts b/packages/host-papp/src/debugTypes.ts new file mode 100644 index 00000000..8da42ad3 --- /dev/null +++ b/packages/host-papp/src/debugTypes.ts @@ -0,0 +1,175 @@ +/** + * EXPERIMENTAL. Discriminated union of every host-papp debug event. + * The taxonomy is split into three "layers": SSO pairing, guest + * identity attestation, and post-pairing session activity. Every + * variant carries `{ layer, event, flowId, timestamp, payload }`. + * + * `flowId` correlates steps that belong to the same logical flow + * (e.g. one full pairing dance shares a flowId across all of its + * steps). Conventions per layer: + * - `sso.*` and `attestation.*`: one fresh flowId per pairing / + * attestation attempt, shared across all of its steps. + * - `session.opened` / `session.terminated`: reuse `sessionId` as the + * flowId so that a `(opened, terminated)` pair stays queryable. + * - `session.peer_action_*` and `session.host_action_*`: use the + * per-message `messageId` as flowId so that received→processed / + * received→failed and sent→response→failed triples line up. + * + * Mark anything subscribed to this taxonomy as EXPERIMENTAL on the + * consumer side — events and payloads may evolve across minor + * versions. + */ +export type SsoDebugEvent = + | { + layer: 'sso'; + event: 'pairing_started'; + flowId: string; + timestamp: number; + payload: { metadata: string }; + } + | { + layer: 'sso'; + event: 'deeplink_generated'; + flowId: string; + timestamp: number; + payload: { deeplink: string }; + } + | { + layer: 'sso'; + event: 'awaiting_response'; + flowId: string; + timestamp: number; + payload: { topic: string }; + } + | { + layer: 'sso'; + event: 'response_received'; + flowId: string; + timestamp: number; + payload: { sessionId: string }; + } + | { + layer: 'sso'; + event: 'session_established'; + flowId: string; + timestamp: number; + payload: { sessionId: string }; + } + | { + layer: 'sso'; + event: 'pairing_failed'; + flowId: string; + timestamp: number; + payload: { reason: string }; + }; + +export type AttestationDebugEvent = + | { + layer: 'attestation'; + event: 'started'; + flowId: string; + timestamp: number; + payload: { candidateAccountId: string }; + } + | { + layer: 'attestation'; + event: 'username_claimed'; + flowId: string; + timestamp: number; + payload: { username: string }; + } + | { + layer: 'attestation'; + event: 'allowance_granted'; + flowId: string; + timestamp: number; + payload: { verifierAccountId: string }; + } + | { + layer: 'attestation'; + event: 'vrf_proof_generated'; + flowId: string; + timestamp: number; + payload: { candidateAccountId: string }; + } + | { + layer: 'attestation'; + event: 'person_registered'; + flowId: string; + timestamp: number; + payload: { username: string; candidateAccountId: string }; + } + | { + layer: 'attestation'; + event: 'completed'; + flowId: string; + timestamp: number; + payload: { username: string }; + } + | { + layer: 'attestation'; + event: 'failed'; + flowId: string; + timestamp: number; + payload: { reason: string }; + }; + +export type SessionDebugEvent = + | { + layer: 'session'; + event: 'opened'; + flowId: string; + timestamp: number; + payload: { sessionId: string }; + } + | { + layer: 'session'; + event: 'peer_action_received'; + flowId: string; + timestamp: number; + payload: { sessionId: string; messageId: string; actionKind: string }; + } + | { + layer: 'session'; + event: 'peer_action_processed'; + flowId: string; + timestamp: number; + payload: { sessionId: string; messageId: string }; + } + | { + layer: 'session'; + event: 'peer_action_failed'; + flowId: string; + timestamp: number; + payload: { sessionId: string; messageId: string; reason: string }; + } + | { + layer: 'session'; + event: 'host_action_sent'; + flowId: string; + timestamp: number; + payload: { sessionId: string; messageId: string; actionKind: string }; + } + | { + layer: 'session'; + event: 'host_action_response_received'; + flowId: string; + timestamp: number; + payload: { sessionId: string; messageId: string }; + } + | { + layer: 'session'; + event: 'host_action_failed'; + flowId: string; + timestamp: number; + payload: { sessionId: string; messageId: string; reason: string }; + } + | { + layer: 'session'; + event: 'terminated'; + flowId: string; + timestamp: number; + payload: { sessionId: string }; + }; + +export type HostPappDebugEvent = SsoDebugEvent | AttestationDebugEvent | SessionDebugEvent; diff --git a/packages/host-papp/src/sso/auth/attestationService.ts b/packages/host-papp/src/sso/auth/attestationService.ts index 9a1ffbdf..97b18ada 100644 --- a/packages/host-papp/src/sso/auth/attestationService.ts +++ b/packages/host-papp/src/sso/auth/attestationService.ts @@ -15,6 +15,7 @@ import { member_from_entropy, sign } from 'verifiablejs/bundler'; import type { People_lite } from '../../../.papi/descriptors/dist/index.js'; import type { DerivedSr25519Account, EncrSecret } from '../../crypto.js'; import { deriveSr25519Account, getEncrPub, stringToBytes } from '../../crypto.js'; +import { emitHostPappDebugMessage } from '../../debugBus.js'; import { toError } from '../../helpers/utils.js'; const accountId = AccountId(); @@ -32,12 +33,22 @@ export function withRetry(fn: () => Promise, maxRetries = 1): Promise { }); } -export const createAttestationService = (lazyClient: LazyClient) => { +export const createAttestationService = (lazyClient: LazyClient, debugFlowId?: string) => { const service = { claimUsername() { const nameSuffixFactory = customAlphabet('abcdefghijklmnopqrstuvwxyz', 4); - return `guest${nameSuffixFactory()}.${createNumericSuffix(4)}`; + const username = `guest${nameSuffixFactory()}.${createNumericSuffix(4)}`; + if (debugFlowId !== undefined) { + emitHostPappDebugMessage({ + layer: 'attestation', + event: 'username_claimed', + flowId: debugFlowId, + timestamp: Date.now(), + payload: { username }, + }); + } + return username; }, grantVerifierAllowance(verifier: DerivedSr25519Account): ResultAsync { @@ -67,7 +78,19 @@ export const createAttestationService = (lazyClient: LazyClient) => { return withRetry(() => sudoCall.signAndSubmit(createPeopleSigner(verifier)).then(() => undefined)); }, toError); - return verifierAllowance.andThen(verifierAllowance => (verifierAllowance > 0 ? okAsync() : getAllowance())); + return verifierAllowance + .andThen(verifierAllowance => (verifierAllowance > 0 ? okAsync() : getAllowance())) + .andTee(() => { + if (debugFlowId !== undefined) { + emitHostPappDebugMessage({ + layer: 'attestation', + event: 'allowance_granted', + flowId: debugFlowId, + timestamp: Date.now(), + payload: { verifierAccountId: verifierAddress }, + }); + } + }); }, getRingRfKey(candidate: DerivedSr25519Account) { @@ -93,6 +116,16 @@ export const createAttestationService = (lazyClient: LazyClient) => { const candidateSignature = candidate.sign(message); const proofOfOwnership = sign(verifiableEntropy, message); + if (debugFlowId !== undefined) { + emitHostPappDebugMessage({ + layer: 'attestation', + event: 'vrf_proof_generated', + flowId: debugFlowId, + timestamp: Date.now(), + payload: { candidateAccountId: accountId.dec(candidate.publicKey) }, + }); + } + const ResourceSignatureCodec = Tuple( // candidate PublicKey (32 bytes) Bytes(32), @@ -182,7 +215,21 @@ export const createAttestationService = (lazyClient: LazyClient) => { return fromPromise(withRetry(submitAttestation), toError).map(() => undefined); }) - .andTee(() => console.log(`Attestation for ${accountId.dec(candidate.publicKey)} successfully passed.`)); + .andTee(() => { + console.log(`Attestation for ${accountId.dec(candidate.publicKey)} successfully passed.`); + if (debugFlowId !== undefined) { + emitHostPappDebugMessage({ + layer: 'attestation', + event: 'person_registered', + flowId: debugFlowId, + timestamp: Date.now(), + payload: { + username, + candidateAccountId: accountId.dec(candidate.publicKey), + }, + }); + } + }); }, }; diff --git a/packages/host-papp/src/sso/auth/impl.ts b/packages/host-papp/src/sso/auth/impl.ts index 8ae72e75..949abf3b 100644 --- a/packages/host-papp/src/sso/auth/impl.ts +++ b/packages/host-papp/src/sso/auth/impl.ts @@ -13,6 +13,7 @@ import { mergeUint8, toHex } from 'polkadot-api/utils'; import type { DerivedSr25519Account, EncrPublicKey, EncrSecret, SsPublicKey } from '../../crypto.js'; import { createEncrSecret, createSharedSecret, deriveSr25519Account, getEncrPub, stringToBytes } from '../../crypto.js'; +import { createFlowId, emitHostPappDebugMessage } from '../../debugBus.js'; import { AbortError } from '../../helpers/abortError.js'; import { createState, readonly } from '../../helpers/state.js'; import { toError } from '../../helpers/utils.js'; @@ -57,7 +58,17 @@ export function createAuth({ let abort: AbortController | null = null; function attestAccount(account: DerivedSr25519Account, signal: AbortSignal) { - const attestationService = createAttestationService(lazyClient); + const flowId = createFlowId(); + const candidateAccountId = toHex(account.publicKey); + emitHostPappDebugMessage({ + layer: 'attestation', + event: 'started', + flowId, + timestamp: Date.now(), + payload: { candidateAccountId }, + }); + + const attestationService = createAttestationService(lazyClient, flowId); const verifier = createSudoAliceVerifier(); const username = attestationService.claimUsername(); @@ -71,15 +82,29 @@ export function createAuth({ .andThrough(() => processSignal(signal)) .andTee(() => { attestationStatus.write({ step: 'finished' }); + emitHostPappDebugMessage({ + layer: 'attestation', + event: 'completed', + flowId, + timestamp: Date.now(), + payload: { username }, + }); }) .orTee(e => { if (!(e instanceof AbortError)) { attestationStatus.write({ step: 'attestationError', message: e.message }); + emitHostPappDebugMessage({ + layer: 'attestation', + event: 'failed', + flowId, + timestamp: Date.now(), + payload: { reason: e.message }, + }); } }); } - function handshake(account: DerivedSr25519Account, signal: AbortSignal) { + function handshake(account: DerivedSr25519Account, signal: AbortSignal, flowId: string) { const localAccount = createLocalSessionAccount(createAccountId(account.publicKey)); pairingStatus.write({ step: 'initial' }); @@ -95,12 +120,28 @@ export function createAuth({ ); const handshakeTopic = encrKeys.andThen(({ publicKey }) => createHandshakeTopic(localAccount, publicKey)); - const dataPrepared = Result.combine([handshakePayload, handshakeTopic, encrKeys]).andTee(([payload]) => - pairingStatus.write({ step: 'pairing', payload: createDeeplink(payload) }), - ); + const dataPrepared = Result.combine([handshakePayload, handshakeTopic, encrKeys]).andTee(([payload]) => { + const deeplink = createDeeplink(payload); + pairingStatus.write({ step: 'pairing', payload: deeplink }); + emitHostPappDebugMessage({ + layer: 'sso', + event: 'deeplink_generated', + flowId, + timestamp: Date.now(), + payload: { deeplink }, + }); + }); return dataPrepared .asyncAndThen(([, handshakeTopic, encrKeys]) => { + emitHostPappDebugMessage({ + layer: 'sso', + event: 'awaiting_response', + flowId, + timestamp: Date.now(), + payload: { topic: toHex(handshakeTopic) }, + }); + const pappResponse = waitForStatements( callback => statementStore.subscribeStatements({ matchAll: [handshakeTopic] }, page => callback(page.statements)), @@ -116,6 +157,13 @@ export function createAuth({ }).unwrapOr(null); if (session) { + emitHostPappDebugMessage({ + layer: 'sso', + event: 'response_received', + flowId, + timestamp: Date.now(), + payload: { sessionId: session.id }, + }); resolve(session); break; } @@ -155,8 +203,19 @@ export function createAuth({ abort = new AbortController(); const account = deriveSr25519Account(generateMnemonic(), '//wallet//sso'); + const ssoFlowId = createFlowId(); + emitHostPappDebugMessage({ + layer: 'sso', + event: 'pairing_started', + flowId: ssoFlowId, + timestamp: Date.now(), + payload: { metadata }, + }); - authResult = ResultAsync.combine([handshake(account, abort.signal), attestAccount(account, abort.signal)]) + authResult = ResultAsync.combine([ + handshake(account, abort.signal, ssoFlowId), + attestAccount(account, abort.signal), + ]) .andThen(([handshakeResult]) => { // Save secrets and sso session only after attestation has finished const { session, secretsPayload } = handshakeResult; @@ -169,13 +228,31 @@ export function createAuth({ .andThen(() => ssoSessionRepository.add(session)) .map(() => session); }) + .andTee(session => { + if (session) { + emitHostPappDebugMessage({ + layer: 'sso', + event: 'session_established', + flowId: ssoFlowId, + timestamp: Date.now(), + payload: { sessionId: session.id }, + }); + } + }) .orElse(e => (e instanceof AbortError ? ok(null) : err(e))) .andTee(() => { abort = null; }) - .orTee(() => { + .orTee(e => { authResult = null; abort = null; + emitHostPappDebugMessage({ + layer: 'sso', + event: 'pairing_failed', + flowId: ssoFlowId, + timestamp: Date.now(), + payload: { reason: e.message }, + }); }); return authResult; diff --git a/packages/host-papp/src/sso/sessionManager/impl.ts b/packages/host-papp/src/sso/sessionManager/impl.ts index 72bd57f9..2ee834f3 100644 --- a/packages/host-papp/src/sso/sessionManager/impl.ts +++ b/packages/host-papp/src/sso/sessionManager/impl.ts @@ -3,6 +3,7 @@ import { createEncryption } from '@novasamatech/statement-store'; import type { StorageAdapter } from '@novasamatech/storage-adapter'; import { okAsync } from 'neverthrow'; +import { emitHostPappDebugMessage } from '../../debugBus.js'; import { createState } from '../../helpers/state.js'; import type { Callback } from '../../types.js'; import { createSsoStatementProver } from '../ssoSessionProver.js'; @@ -53,6 +54,14 @@ export function createSsoSessionManager({ toAdd.add(session); + emitHostPappDebugMessage({ + layer: 'session', + event: 'opened', + flowId: userSession.id, + timestamp: Date.now(), + payload: { sessionId: userSession.id }, + }); + const unsubscribe = session.subscribe(message => { switch (message.data.tag) { case 'v1': { @@ -71,6 +80,13 @@ export function createSsoSessionManager({ if (toRemove.size > 0) { for (const id of toRemove) { + emitHostPappDebugMessage({ + layer: 'session', + event: 'terminated', + flowId: id, + timestamp: Date.now(), + payload: { sessionId: id }, + }); releaseSession(id); activeSessions[id]?.dispose(); } diff --git a/packages/host-papp/src/sso/sessionManager/userSession.ts b/packages/host-papp/src/sso/sessionManager/userSession.ts index 99f071f8..f3e19822 100644 --- a/packages/host-papp/src/sso/sessionManager/userSession.ts +++ b/packages/host-papp/src/sso/sessionManager/userSession.ts @@ -9,6 +9,7 @@ import type { Result } from 'neverthrow'; import { ResultAsync, err, ok, okAsync } from 'neverthrow'; import type { CodecType } from 'scale-ts'; +import { emitHostPappDebugMessage } from '../../debugBus.js'; import { createAsyncTaskPool } from '../../helpers/createAsyncTaskPool.js'; import { toError } from '../../helpers/utils.js'; import type { Callback } from '../../types.js'; @@ -42,6 +43,56 @@ type ProcessedMessage = processed: false; }; +/** + * Derive a stable `actionKind` label from a remote-message envelope. + * Shape: `OuterTag` for flat variants, `OuterTag:InnerTag` for variants + * whose payload is itself an enum (currently just `SignRequest`). + * The receive side and the send side both go through here so debug + * consumers see the same shape regardless of direction. + */ +function actionKindFromMessageData(data: CodecType['data']): string { + if (data.tag !== 'v1') return data.tag; + const inner = data.value; + if (inner.tag === 'SignRequest') return `SignRequest:${inner.value.tag}`; + return inner.tag; +} + +function emitHostAction(messageId: string, actionKind: string, sessionId: string): void { + emitHostPappDebugMessage({ + layer: 'session', + event: 'host_action_sent', + flowId: messageId, + timestamp: Date.now(), + payload: { sessionId, messageId, actionKind }, + }); +} + +function withHostActionTrace( + result: ResultAsync, + messageId: string, + sessionId: string, +): ResultAsync { + return result + .andTee(() => { + emitHostPappDebugMessage({ + layer: 'session', + event: 'host_action_response_received', + flowId: messageId, + timestamp: Date.now(), + payload: { sessionId, messageId }, + }); + }) + .orTee(error => { + emitHostPappDebugMessage({ + layer: 'session', + event: 'host_action_failed', + flowId: messageId, + timestamp: Date.now(), + payload: { sessionId, messageId, reason: error.message }, + }); + }); +} + export type UserSession = StoredUserSession & { sendDisconnectMessage(): ResultAsync; signPayload(payload: SigningPayloadRequest): ResultAsync; @@ -94,10 +145,9 @@ export function createUserSession({ signPayload(payload) { return requestQueue.call(() => { const messageId = nanoid(); - const request = session.request(RemoteMessageCodec, { - messageId, - data: enumValue('v1', enumValue('SignRequest', enumValue('Payload', payload))), - }); + const data = enumValue('v1', enumValue('SignRequest', enumValue('Payload', payload))); + emitHostAction(messageId, actionKindFromMessageData(data), userSession.id); + const request = session.request(RemoteMessageCodec, { messageId, data }); const responseFilter = (message: RemoteMessage) => { if ( @@ -119,17 +169,16 @@ export function createUserSession({ } }); - return withQueueTimeout(inner, 'signPayload'); + return withHostActionTrace(withQueueTimeout(inner, 'signPayload'), messageId, userSession.id); }); }, signRaw(payload) { return requestQueue.call(() => { const messageId = nanoid(); - const request = session.request(RemoteMessageCodec, { - messageId, - data: enumValue('v1', enumValue('SignRequest', enumValue('Raw', payload))), - }); + const data = enumValue('v1', enumValue('SignRequest', enumValue('Raw', payload))); + emitHostAction(messageId, actionKindFromMessageData(data), userSession.id); + const request = session.request(RemoteMessageCodec, { messageId, data }); const responseFilter = (message: RemoteMessage) => { if ( @@ -151,7 +200,7 @@ export function createUserSession({ } }); - return withQueueTimeout(inner, 'signRaw'); + return withHostActionTrace(withQueueTimeout(inner, 'signRaw'), messageId, userSession.id); }); }, @@ -169,16 +218,15 @@ export function createUserSession({ getRingVrfAlias(productAccountId, productId) { return requestQueue.call(() => { const messageId = nanoid(); - const request = session.request(RemoteMessageCodec, { - messageId, - data: enumValue( - 'v1', - enumValue('RingVrfAliasRequest', { - productAccountId, - productId, - }), - ), - }); + const data = enumValue( + 'v1', + enumValue('RingVrfAliasRequest', { + productAccountId, + productId, + }), + ); + emitHostAction(messageId, actionKindFromMessageData(data), userSession.id); + const request = session.request(RemoteMessageCodec, { messageId, data }); const responseFilter = (message: RemoteMessage) => { if ( @@ -190,9 +238,13 @@ export function createUserSession({ } }; - return request - .andThen(() => session.waitForRequestMessage(RemoteMessageCodec, responseFilter)) - .andThen(result => (result.success ? ok(result.value) : err(new Error(result.value)))); + return withHostActionTrace( + request + .andThen(() => session.waitForRequestMessage(RemoteMessageCodec, responseFilter)) + .andThen(result => (result.success ? ok(result.value) : err(new Error(result.value)))), + messageId, + userSession.id, + ); }); }, @@ -236,9 +288,37 @@ export function createUserSession({ return okAsync({ processed: false }); } + const messageId = payload.value.messageId; + const actionKind = actionKindFromMessageData(payload.value.data); + emitHostPappDebugMessage({ + layer: 'session', + event: 'peer_action_received', + flowId: messageId, + timestamp: Date.now(), + payload: { sessionId: userSession.id, messageId, actionKind }, + }); + return callback(payload.value) + .andTee(processed => { + if (processed) { + emitHostPappDebugMessage({ + layer: 'session', + event: 'peer_action_processed', + flowId: messageId, + timestamp: Date.now(), + payload: { sessionId: userSession.id, messageId }, + }); + } + }) .orTee(error => { console.error('Error while processing sso message:', error); + emitHostPappDebugMessage({ + layer: 'session', + event: 'peer_action_failed', + flowId: messageId, + timestamp: Date.now(), + payload: { sessionId: userSession.id, messageId, reason: error.message }, + }); }) .orElse(() => okAsync(false)) .map(processed => (processed ? { processed, message: payload.value } : { processed }));