diff --git a/MIGRATION.md b/MIGRATION.md index eaca78ed..2ba2addf 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -24,9 +24,7 @@ The active launch path is `packages/ui/src/bridge.ts`: - calls `createIframeHost(...)` with the product URL, sandbox policy, allowed origin, and the worker-backed provider. -Modern product frames enter the Rust core through the iframe `MessageChannel`. -The temporary Nova compatibility shim forwards legacy `window.postMessage` -frames into the same product-scoped provider. +Product frames enter the Rust core through the iframe `MessageChannel`. Account, signing, statement-store, SSO pairing, restore, and logout are core-owned and do not cross the JS host callback boundary as Nova-specific routes. diff --git a/packages/protocol/src/auth-storage.ts b/packages/protocol/src/auth-storage.ts index f3863037..17d3707e 100644 --- a/packages/protocol/src/auth-storage.ts +++ b/packages/protocol/src/auth-storage.ts @@ -78,10 +78,9 @@ export function buildSharedAuthStorageKey(siteId: SiteId, key: string): string { /** Storage key used by the removed Nova host runtime. Its session encoding is * incompatible with TrUAPI, so the protocol host deletes this key at boot. * - * TODO(remove-legacy-nova): unlike the product-facing shim sites sharing this - * tag, this cleanup is gated on returning browsers, not on product migration: - * delete it (with `LEGACY_SHARED_AUTH_SESSION_KEY` above and - * `clearLegacySharedAuthSession` in `apps/protocol/src/main.ts`) once stale + * TODO(remove-legacy-nova): this cleanup is gated on returning browsers, not + * product migration. Delete it (with `LEGACY_SHARED_AUTH_SESSION_KEY` above + * and `clearLegacySharedAuthSession` in `apps/protocol/src/main.ts`) once stale * `PAPP_*` keys in long-lived browser profiles are no longer a concern. */ export function buildLegacySharedAuthSessionStorageKey(siteId: SiteId): string { return `PAPP_${siteId}_${LEGACY_SHARED_AUTH_SESSION_KEY}`; diff --git a/packages/ui/src/bridge.ts b/packages/ui/src/bridge.ts index dbb0a894..94927535 100644 --- a/packages/ui/src/bridge.ts +++ b/packages/ui/src/bridge.ts @@ -47,11 +47,6 @@ import { onStoredSessionChanged } from "./host-callbacks/SessionStore"; import { LoginRequestError } from "./login-request-error"; import { createTruapiRuntimeConfig, labelToProductId } from "./runtime-config"; import { describeWireFrame } from "./debug-wire-describe"; -// TODO(remove-legacy-nova): import used only by the legacy probe tagged below. -import { - createLegacyNovaChainHeadProvider, - createWindowMessageProvider, -} from "./legacy-host-bridge"; import type { BlockingModalCoordinator } from "./blocking-modal-queue"; import { showNotification } from "./notification"; @@ -702,10 +697,6 @@ async function createHost(args: { const productId = args.productId ?? labelToProductId(args.label); let productProvider: Provider | null = null; let disposePipe: (() => void) | null = null; - // TODO(remove-legacy-nova): `legacyProbeCleanup` (including its two `?.()` - // call sites in `dispose()` and the catch block below) exists only for the - // legacy probe block tagged further down. - let legacyProbeCleanup: (() => void) | null = null; const pipeArgs = { flowId: args.debugFlowId, label: args.label, @@ -731,62 +722,6 @@ async function createHost(args: { }, }); - // DEPRECATED legacy host-API support. Modern products announce themselves - // with `{type:"truapi-ready"}` and use the MessagePort wired above. Products - // still on the Nova host-api SDK instead post raw SCALE frames (Uint8Array) - // to `window.parent`. Detect that first frame and re-pipe the core over a - // window-postMessage provider. - // - // TODO(remove-legacy-nova): once the last legacy Nova product migrates to - // `@parity/truapi`, delete this probe block (through the - // `legacyProbeCleanup` assignment below), the `legacyProbeCleanup` - // declaration and call sites tagged above, the `legacy-host-bridge` - // import at the top of this file, and the tagged `legacy-host-bridge.ts` - // module itself. Modern products need no probe: the MessagePort from - // `onPort` is the only wiring. - let probeMode: "pending" | "modern" | "legacy" = "pending"; - const onProbe = (event: MessageEvent): void => { - if (probeMode !== "pending") { - return; - } - const targetWindow = host.iframe.contentWindow; - if ( - !targetWindow || - event.source !== targetWindow || - event.origin !== args.allowedOrigin - ) { - return; - } - if (event.data instanceof Uint8Array) { - probeMode = "legacy"; - legacyProbeCleanup?.(); - // Drop the unused modern MessagePort pipe before rewiring. - cleanupProductSide(); - const windowProvider = createWindowMessageProvider( - targetWindow, - args.allowedOrigin, - ); - const legacyProvider = createLegacyNovaChainHeadProvider( - windowProvider, - productId, - ); - productProvider = legacyProvider; - disposePipe = pipeProviders(legacyProvider, coreProvider, pipeArgs); - // Replay the handshake frame the probe just consumed. - windowProvider.injectInbound(event.data); - } else if ( - (event.data as { type?: unknown } | null)?.type === "truapi-ready" - ) { - probeMode = "modern"; - legacyProbeCleanup?.(); - } - }; - window.addEventListener("message", onProbe); - legacyProbeCleanup = () => { - window.removeEventListener("message", onProbe); - legacyProbeCleanup = null; - }; - return { iframe: host.iframe, requestLogin(reason) { @@ -800,7 +735,6 @@ async function createHost(args: { }, dispose() { unregisterPermissions(); - legacyProbeCleanup?.(); cleanupProductSide(); coreProvider.dispose(); host.dispose(); @@ -808,7 +742,6 @@ async function createHost(args: { }; } catch (error) { unregisterPermissions(); - legacyProbeCleanup?.(); cleanupProductSide(); coreProvider.dispose(); throw error; diff --git a/packages/ui/src/legacy-host-bridge.ts b/packages/ui/src/legacy-host-bridge.ts deleted file mode 100644 index 68ce5a79..00000000 --- a/packages/ui/src/legacy-host-bridge.ts +++ /dev/null @@ -1,327 +0,0 @@ -// DEPRECATED — legacy host-API backward-compat transport shim. -// -// The TrUAPI host shuttles product frames over a transferred `MessagePort`. -// Products built on the older Nova host-api stack (the `@novasamatech` packages; -// e.g. apps still on `@parity/product-sdk`) instead shuttle raw `Uint8Array` -// frames to/from `window.parent`. Nova also exposes synthetic chain-head follow -// ids to PAPI, so the legacy provider translates those ids back to their TrUAPI -// subscription wire ids. No `@novasamatech/*` dependency is needed. -// -// TODO(remove-legacy-nova): once the last product on the legacy Nova host-api -// stack migrates to `@parity/truapi`, delete this entire file together with -// every other site tagged `remove-legacy-nova` (grep for that tag): the -// window-postMessage probe in `bridge.ts` and -// `tests/legacy-host-bridge.test.ts`. Nothing in the modern MessagePort path -// or the Rust core depends on anything here. - -import { - decodeWireMessage, - encodeWireMessage, - VersionedHostAccountGetRequest, - VersionedRemoteChainHeadBodyRequest, - VersionedRemoteChainHeadCallRequest, - VersionedRemoteChainHeadContinueRequest, - VersionedRemoteChainHeadFollowRequest, - VersionedRemoteChainHeadHeaderRequest, - VersionedRemoteChainHeadStopOperationRequest, - VersionedRemoteChainHeadStorageRequest, - VersionedRemoteChainHeadUnpinRequest, - type Codec, - type WireProvider, -} from "@parity/truapi"; -import { - ACCOUNT_GET_ACCOUNT, - CHAIN_CALL_HEAD, - CHAIN_CONTINUE_HEAD, - CHAIN_FOLLOW_HEAD_SUBSCRIBE, - CHAIN_GET_HEAD_BODY, - CHAIN_GET_HEAD_HEADER, - CHAIN_GET_HEAD_STORAGE, - CHAIN_STOP_HEAD_OPERATION, - CHAIN_UNPIN_HEAD, - SYSTEM_HANDSHAKE, -} from "@parity/truapi/wire-table"; - -interface VersionedFollowBoundRequest { - tag: "V1"; - value: { - genesisHash: string; - followSubscriptionId: string; - }; -} - -interface DecodedFollowBoundRequest { - genesisHash: string; - /** The synthetic follow id Nova stamped on the incoming frame. */ - followSubscriptionId: string; - withFollowSubscriptionId(followSubscriptionId: string): Uint8Array; -} - -type FollowBoundDecoder = (payload: Uint8Array) => DecodedFollowBoundRequest; - -function createFollowBoundDecoder( - codec: Codec, -): FollowBoundDecoder { - return (payload) => { - const request = codec.dec(payload); - return { - genesisHash: request.value.genesisHash, - followSubscriptionId: request.value.followSubscriptionId, - withFollowSubscriptionId(followSubscriptionId) { - if (request.value.followSubscriptionId === followSubscriptionId) { - return payload; - } - request.value.followSubscriptionId = followSubscriptionId; - return codec.enc(request); - }, - }; - }; -} - -const followBoundDecoders = new Map([ - [ - CHAIN_GET_HEAD_HEADER.request, - createFollowBoundDecoder(VersionedRemoteChainHeadHeaderRequest), - ], - [ - CHAIN_GET_HEAD_BODY.request, - createFollowBoundDecoder(VersionedRemoteChainHeadBodyRequest), - ], - [ - CHAIN_GET_HEAD_STORAGE.request, - createFollowBoundDecoder(VersionedRemoteChainHeadStorageRequest), - ], - [ - CHAIN_CALL_HEAD.request, - createFollowBoundDecoder(VersionedRemoteChainHeadCallRequest), - ], - [ - CHAIN_UNPIN_HEAD.request, - createFollowBoundDecoder(VersionedRemoteChainHeadUnpinRequest), - ], - [ - CHAIN_CONTINUE_HEAD.request, - createFollowBoundDecoder(VersionedRemoteChainHeadContinueRequest), - ], - [ - CHAIN_STOP_HEAD_OPERATION.request, - createFollowBoundDecoder(VersionedRemoteChainHeadStopOperationRequest), - ], -]); - -/** - * A {@link WireProvider} over `window.postMessage`, matching the legacy Nova - * host-api iframe transport. Pipe it into the core with `pipeProviders` exactly - * like the MessagePort path. - */ -export interface WindowMessageProvider extends WireProvider { - /** Replay a frame already read off the `window` (the probe's detection frame). */ - injectInbound(message: Uint8Array): void; -} - -/** - * Translate Nova's synthetic chain-head follow ids at the legacy transport - * boundary. Nova numbers each follow on a chain monotonically (`follow_0`, - * `follow_1`, …) while the Rust core requires the exact subscription start - * frame's request id, so the shim mirrors that counter per genesis and - * rewrites every follow-bound frame to the wire id of the follow it is bound - * to. A product can hold several concurrent follows on one genesis (PAPI - * opens a fresh follow during resync before stopping the previous one); - * routing by the frame's own synthetic id keeps those from cross-talking. - * A synthetic id the shim never saw falls back to the first active follow. - */ -export function createLegacyNovaChainHeadProvider( - provider: WireProvider, - productId?: string, -): WireProvider { - /** Per genesis: Nova's synthetic follow id -> follow-start wire request id. */ - const followWireIdsByGenesis = new Map>(); - // Nova's synthetic counter never resets within a connection — not even - // after every follow on a genesis stops — so this mirror only resets where - // Nova's does: handshake (new document) and dispose. - const nextSyntheticOrdinal = new Map(); - const localProductId = - productId === "localhost" || productId?.startsWith("localhost:") === true - ? productId - : undefined; - const forgetAllFollows = (): void => { - followWireIdsByGenesis.clear(); - nextSyntheticOrdinal.clear(); - }; - const forgetFollowId = (requestId: string): void => { - for (const [genesisHash, followIds] of followWireIdsByGenesis) { - for (const [syntheticId, wireId] of followIds) { - if (wireId === requestId) { - followIds.delete(syntheticId); - } - } - if (followIds.size === 0) { - followWireIdsByGenesis.delete(genesisHash); - } - } - }; - - const rewrite = (message: Uint8Array): Uint8Array => { - const decoded = decodeWireMessage(message); - if (decoded.isErr()) { - return message; - } - - const { requestId, payload } = decoded.value; - // A legacy transport handshake is the first frame after an iframe reload. - // The core-side provider survives that navigation, so discard mappings - // belonging to the previous document before accepting new follows. - if (payload.id === SYSTEM_HANDSHAKE.request) { - forgetAllFollows(); - return message; - } - if ( - payload.id === ACCOUNT_GET_ACCOUNT.request && - localProductId !== undefined - ) { - try { - const request = VersionedHostAccountGetRequest.dec(payload.value); - const productAccountId = request.value.productAccountId; - if (productAccountId.dotNsIdentifier === `${localProductId}.dot`) { - productAccountId.dotNsIdentifier = localProductId; - const encoded = encodeWireMessage({ - requestId, - payload: { - id: payload.id, - value: VersionedHostAccountGetRequest.enc(request), - }, - }); - return encoded.isOk() ? encoded.value : message; - } - } catch { - // Let the core report malformed legacy frames. - return message; - } - } - - if (payload.id === CHAIN_FOLLOW_HEAD_SUBSCRIBE.start) { - let genesisHash: string; - try { - const request = VersionedRemoteChainHeadFollowRequest.dec( - payload.value, - ); - genesisHash = request.value.genesisHash; - } catch { - // Let the core report malformed legacy frames. - return message; - } - const followIds = - followWireIdsByGenesis.get(genesisHash) ?? new Map(); - const ordinal = nextSyntheticOrdinal.get(genesisHash) ?? 0; - followIds.set(`follow_${String(ordinal)}`, requestId); - nextSyntheticOrdinal.set(genesisHash, ordinal + 1); - followWireIdsByGenesis.set(genesisHash, followIds); - return message; - } - - if (payload.id === CHAIN_FOLLOW_HEAD_SUBSCRIBE.stop) { - forgetFollowId(requestId); - return message; - } - - const decodeFollowBoundRequest = followBoundDecoders.get(payload.id); - if (!decodeFollowBoundRequest) { - return message; - } - - try { - const request = decodeFollowBoundRequest(payload.value); - const followIds = followWireIdsByGenesis.get(request.genesisHash); - const followId = - followIds?.get(request.followSubscriptionId) ?? - followIds?.values().next().value; - if (followId === undefined) { - return message; - } - - const encoded = encodeWireMessage({ - requestId, - payload: { - id: payload.id, - value: request.withFollowSubscriptionId(followId), - }, - }); - return encoded.isOk() ? encoded.value : message; - } catch { - return message; - } - }; - - const subscribeClose = provider.subscribeClose?.bind(provider); - - return { - postMessage(message) { - const decoded = decodeWireMessage(message); - if ( - decoded.isOk() && - decoded.value.payload.id === CHAIN_FOLLOW_HEAD_SUBSCRIBE.interrupt - ) { - forgetFollowId(decoded.value.requestId); - } - provider.postMessage(message); - }, - subscribe(callback) { - return provider.subscribe((message) => { - callback(rewrite(message)); - }); - }, - subscribeClose, - dispose() { - forgetAllFollows(); - provider.dispose(); - }, - }; -} - -let warned = false; - -export function createWindowMessageProvider( - targetWindow: Window, - targetOrigin: string, -): WindowMessageProvider { - if (!warned) { - warned = true; - console.warn( - "[dotli] legacy host-API transport bridge active — migrate this product to @parity/truapi.", - ); - } - - const subscribers = new Set<(message: Uint8Array) => void>(); - const deliver = (message: Uint8Array): void => { - for (const callback of subscribers) { - callback(message); - } - }; - const onMessage = (event: MessageEvent): void => { - if ( - event.source === targetWindow && - event.origin === targetOrigin && - event.data instanceof Uint8Array - ) { - deliver(event.data); - } - }; - window.addEventListener("message", onMessage); - - return { - postMessage(message) { - targetWindow.postMessage(message, targetOrigin); - }, - subscribe(callback) { - subscribers.add(callback); - return () => { - subscribers.delete(callback); - }; - }, - injectInbound: deliver, - dispose() { - window.removeEventListener("message", onMessage); - subscribers.clear(); - }, - }; -} diff --git a/packages/ui/tests/legacy-host-bridge.test.ts b/packages/ui/tests/legacy-host-bridge.test.ts deleted file mode 100644 index eaf3d52b..00000000 --- a/packages/ui/tests/legacy-host-bridge.test.ts +++ /dev/null @@ -1,557 +0,0 @@ -// TODO(remove-legacy-nova): delete this file together with the tagged -// `legacy-host-bridge.ts` module it covers. - -import { describe, expect, it, vi } from "vitest"; -import { - decodeWireMessage, - encodeWireMessage, - VersionedHostAccountGetRequest, - VersionedRemoteChainHeadBodyRequest, - VersionedRemoteChainHeadCallRequest, - VersionedRemoteChainHeadContinueRequest, - VersionedRemoteChainHeadFollowRequest, - VersionedRemoteChainHeadHeaderRequest, - VersionedRemoteChainHeadStopOperationRequest, - VersionedRemoteChainHeadStorageRequest, - VersionedRemoteChainHeadUnpinRequest, - type Codec, - type WireProvider, -} from "@parity/truapi"; -import { - ACCOUNT_GET_ACCOUNT, - CHAIN_CALL_HEAD, - CHAIN_CONTINUE_HEAD, - CHAIN_FOLLOW_HEAD_SUBSCRIBE, - CHAIN_GET_HEAD_BODY, - CHAIN_GET_HEAD_HEADER, - CHAIN_GET_HEAD_STORAGE, - CHAIN_STOP_HEAD_OPERATION, - CHAIN_UNPIN_HEAD, - SYSTEM_HANDSHAKE, -} from "@parity/truapi/wire-table"; -import { - createLegacyNovaChainHeadProvider, - createWindowMessageProvider, -} from "@dotli/ui/legacy-host-bridge"; -import { unwrap } from "./support"; - -const genesisHash = `0x${"11".repeat(32)}` as const; -const blockHash = `0x${"22".repeat(32)}` as const; - -describe("createWindowMessageProvider", () => { - it("As a dotli integrator, the host pins outbound and inbound frames to the product origin", () => { - // Given - vi.spyOn(console, "warn").mockImplementation(() => {}); - const targetWindow = { - postMessage: vi.fn(), - } as unknown as Window; - const targetOrigin = "https://product.app.dotli.dev"; - const provider = createWindowMessageProvider(targetWindow, targetOrigin); - const listener = vi.fn(); - provider.subscribe(listener); - const message = new Uint8Array([1, 2, 3]); - - // When - provider.postMessage(message); - window.dispatchEvent( - new MessageEvent("message", { - data: message, - origin: "https://attacker.example", - source: targetWindow, - }), - ); - window.dispatchEvent( - new MessageEvent("message", { - data: message, - origin: targetOrigin, - source: window, - }), - ); - window.dispatchEvent( - new MessageEvent("message", { - data: message, - origin: targetOrigin, - source: targetWindow, - }), - ); - - // Then - expect(targetWindow.postMessage).toHaveBeenCalledWith( - message, - targetOrigin, - ); - expect(listener).toHaveBeenCalledTimes(1); - expect(listener).toHaveBeenCalledWith(message); - - provider.dispose(); - }); -}); - -type FollowBoundRequest = { - tag: "V1"; - value: { - genesisHash: string; - followSubscriptionId: string; - }; -}; - -function createHarness(productId?: string): { - adapted: WireProvider; - emit(message: Uint8Array): void; - received: Uint8Array[]; - sent: Uint8Array[]; - dispose: ReturnType; -} { - let listener: ((message: Uint8Array) => void) | undefined; - const dispose = vi.fn(); - const sent: Uint8Array[] = []; - const provider: WireProvider = { - postMessage: vi.fn((message: Uint8Array) => sent.push(message)), - subscribe(callback) { - listener = callback; - return () => { - listener = undefined; - }; - }, - dispose, - }; - const adapted = createLegacyNovaChainHeadProvider(provider, productId); - const received: Uint8Array[] = []; - adapted.subscribe((message) => received.push(message)); - return { - adapted, - emit(message) { - listener?.(message); - }, - received, - sent, - dispose, - }; -} - -function frame(requestId: string, id: number, value: Uint8Array): Uint8Array { - return unwrap( - encodeWireMessage({ - requestId, - payload: { id, value }, - }), - ); -} - -function followStart(requestId: string): Uint8Array { - return frame( - requestId, - CHAIN_FOLLOW_HEAD_SUBSCRIBE.start, - VersionedRemoteChainHeadFollowRequest.enc({ - tag: "V1", - value: { genesisHash, withRuntime: true }, - }), - ); -} - -function followStop(requestId: string): Uint8Array { - return frame(requestId, CHAIN_FOLLOW_HEAD_SUBSCRIBE.stop, new Uint8Array()); -} - -function followBoundFrame( - requestId: string, - id: number, - codec: Codec, - request: T, -): Uint8Array { - return frame(requestId, id, codec.enc(request)); -} - -function decodedFollowId( - message: Uint8Array, - codec: Codec, -): string { - const decoded = unwrap(decodeWireMessage(message)); - return codec.dec(decoded.payload.value).value.followSubscriptionId; -} - -describe("createLegacyNovaChainHeadProvider", () => { - it("As a dotli integrator, the host normalizes the legacy .dot suffix for a localhost product account", () => { - // Given - const harness = createHarness("localhost:3000"); - - // When - harness.emit( - frame( - "account-request", - ACCOUNT_GET_ACCOUNT.request, - VersionedHostAccountGetRequest.enc({ - tag: "V1", - value: { - productAccountId: { - dotNsIdentifier: "localhost:3000.dot", - derivationIndex: 0, - }, - }, - }), - ), - ); - - // Then - const decoded = unwrap(decodeWireMessage(harness.received.at(-1)!)); - expect( - VersionedHostAccountGetRequest.dec(decoded.payload.value).value - .productAccountId.dotNsIdentifier, - ).toBe("localhost:3000"); - }); - - it("As a dotli integrator, the host does not rewrite an explicitly requested different product account", () => { - // Given - const harness = createHarness("localhost:3000"); - - // When - harness.emit( - frame( - "account-request", - ACCOUNT_GET_ACCOUNT.request, - VersionedHostAccountGetRequest.enc({ - tag: "V1", - value: { - productAccountId: { - dotNsIdentifier: "other-product.dot", - derivationIndex: 0, - }, - }, - }), - ), - ); - - // Then - const decoded = unwrap(decodeWireMessage(harness.received.at(-1)!)); - expect( - VersionedHostAccountGetRequest.dec(decoded.payload.value).value - .productAccountId.dotNsIdentifier, - ).toBe("other-product.dot"); - }); - - it("As a dotli integrator, the host rewrites every follow-bound request to the active wire follow id", () => { - // Given - const harness = createHarness(); - harness.emit(followStart("wire-follow")); - - const cases = [ - { - id: CHAIN_GET_HEAD_HEADER.request, - codec: VersionedRemoteChainHeadHeaderRequest, - request: { - tag: "V1", - value: { - genesisHash, - followSubscriptionId: "follow_0", - hash: blockHash, - }, - }, - }, - { - id: CHAIN_GET_HEAD_BODY.request, - codec: VersionedRemoteChainHeadBodyRequest, - request: { - tag: "V1", - value: { - genesisHash, - followSubscriptionId: "follow_0", - hash: blockHash, - }, - }, - }, - { - id: CHAIN_GET_HEAD_STORAGE.request, - codec: VersionedRemoteChainHeadStorageRequest, - request: { - tag: "V1", - value: { - genesisHash, - followSubscriptionId: "follow_0", - hash: blockHash, - items: [], - }, - }, - }, - { - id: CHAIN_CALL_HEAD.request, - codec: VersionedRemoteChainHeadCallRequest, - request: { - tag: "V1", - value: { - genesisHash, - followSubscriptionId: "follow_0", - hash: blockHash, - function: "Metadata_metadata", - callParameters: "0x" as const, - }, - }, - }, - { - id: CHAIN_UNPIN_HEAD.request, - codec: VersionedRemoteChainHeadUnpinRequest, - request: { - tag: "V1", - value: { - genesisHash, - followSubscriptionId: "follow_0", - hashes: [blockHash], - }, - }, - }, - { - id: CHAIN_CONTINUE_HEAD.request, - codec: VersionedRemoteChainHeadContinueRequest, - request: { - tag: "V1", - value: { - genesisHash, - followSubscriptionId: "follow_0", - operationId: "operation-1", - }, - }, - }, - { - id: CHAIN_STOP_HEAD_OPERATION.request, - codec: VersionedRemoteChainHeadStopOperationRequest, - request: { - tag: "V1", - value: { - genesisHash, - followSubscriptionId: "follow_0", - operationId: "operation-1", - }, - }, - }, - ] as const; - - for (const [index, item] of cases.entries()) { - // When - harness.emit( - followBoundFrame( - `request-${index}`, - item.id, - item.codec as Codec, - item.request, - ), - ); - - // Then - expect( - decodedFollowId( - harness.received.at(-1)!, - item.codec as Codec, - ), - ).toBe("wire-follow"); - } - }); - - it("As a dotli integrator, the host retains the first follow until that exact subscription stops", () => { - // Given - const harness = createHarness(); - const request = { - tag: "V1", - value: { genesisHash, followSubscriptionId: "follow_0", hash: blockHash }, - } as const; - const emitHeader = (): string => { - harness.emit( - followBoundFrame( - "header-request", - CHAIN_GET_HEAD_HEADER.request, - VersionedRemoteChainHeadHeaderRequest, - request, - ), - ); - return decodedFollowId( - harness.received.at(-1)!, - VersionedRemoteChainHeadHeaderRequest, - ); - }; - - // When - harness.emit(followStart("wire-first")); - harness.emit(followStart("wire-second")); - - // Then - expect(emitHeader()).toBe("wire-first"); - - // When - harness.emit(followStop("wire-second")); - - // Then - expect(emitHeader()).toBe("wire-first"); - - // When - harness.emit(followStop("wire-first")); - - // Then - expect(emitHeader()).toBe("follow_0"); - - // When - harness.emit(followStart("wire-refollow")); - - // Then - expect(emitHeader()).toBe("wire-refollow"); - }); - - it("As a dotli integrator, the host forgets a follow interrupted by the core", () => { - // Given - const harness = createHarness(); - const request = { - tag: "V1", - value: { genesisHash, followSubscriptionId: "follow_0", hash: blockHash }, - } as const; - - // When - harness.emit(followStart("wire-old")); - harness.adapted.postMessage( - frame( - "wire-old", - CHAIN_FOLLOW_HEAD_SUBSCRIBE.interrupt, - new Uint8Array(), - ), - ); - harness.emit(followStart("wire-new")); - harness.emit( - followBoundFrame( - "header-request", - CHAIN_GET_HEAD_HEADER.request, - VersionedRemoteChainHeadHeaderRequest, - request, - ), - ); - - // Then - expect(harness.sent).toHaveLength(1); - expect( - decodedFollowId( - harness.received.at(-1)!, - VersionedRemoteChainHeadHeaderRequest, - ), - ).toBe("wire-new"); - }); - - it("As a dotli integrator, the host drops stale follows when a reloaded iframe handshakes", () => { - // Given - const harness = createHarness(); - const request = { - tag: "V1", - value: { genesisHash, followSubscriptionId: "follow_0", hash: blockHash }, - } as const; - - // When - harness.emit(followStart("wire-old")); - harness.emit( - frame("handshake", SYSTEM_HANDSHAKE.request, new Uint8Array()), - ); - harness.emit(followStart("wire-new")); - harness.emit( - followBoundFrame( - "header-request", - CHAIN_GET_HEAD_HEADER.request, - VersionedRemoteChainHeadHeaderRequest, - request, - ), - ); - - // Then - expect( - decodedFollowId( - harness.received.at(-1)!, - VersionedRemoteChainHeadHeaderRequest, - ), - ).toBe("wire-new"); - }); - - it("As a dotli integrator, the host routes each follow-bound request to its own concurrent follow", () => { - // Given: two concurrent follows on the same genesis (PAPI opens a fresh - // follow during resync before stopping the previous one), which Nova - // exposes to the product as follow_0 and follow_1. - const harness = createHarness(); - harness.emit(followStart("wire-follow-a")); - harness.emit(followStart("wire-follow-b")); - - const emitHeader = (syntheticId: string, requestId: string): string => { - harness.emit( - followBoundFrame( - requestId, - CHAIN_GET_HEAD_HEADER.request, - VersionedRemoteChainHeadHeaderRequest, - { - tag: "V1", - value: { - genesisHash, - followSubscriptionId: syntheticId, - hash: blockHash, - }, - }, - ), - ); - return decodedFollowId( - harness.received.at(-1)!, - VersionedRemoteChainHeadHeaderRequest, - ); - }; - - // Then: each op reaches the follow it is bound to, not the first active. - expect(emitHeader("follow_0", "op-0")).toBe("wire-follow-a"); - expect(emitHeader("follow_1", "op-1")).toBe("wire-follow-b"); - - // When the older follow stops, follow_1 ops still reach their follow. - harness.emit(followStop("wire-follow-a")); - - // Then - expect(emitHeader("follow_1", "op-2")).toBe("wire-follow-b"); - - // Then: a synthetic id the shim never saw falls back to the first - // active follow instead of passing through unmapped. - expect(emitHeader("follow_9", "op-3")).toBe("wire-follow-b"); - }); - - it("As a dotli integrator, the host keeps mirroring Nova's follow numbering across a full stop", () => { - // Given: Nova's synthetic counter never resets within a connection, so a - // follow started after every earlier follow stopped is follow_1, not - // follow_0. - const harness = createHarness(); - harness.emit(followStart("wire-follow-a")); - harness.emit(followStop("wire-follow-a")); - harness.emit(followStart("wire-follow-b")); - harness.emit(followStart("wire-follow-c")); - - const emitHeader = (syntheticId: string, requestId: string): string => { - harness.emit( - followBoundFrame( - requestId, - CHAIN_GET_HEAD_HEADER.request, - VersionedRemoteChainHeadHeaderRequest, - { - tag: "V1", - value: { - genesisHash, - followSubscriptionId: syntheticId, - hash: blockHash, - }, - }, - ), - ); - return decodedFollowId( - harness.received.at(-1)!, - VersionedRemoteChainHeadHeaderRequest, - ); - }; - - // Then: ops route by Nova's numbering, not a restarted count. - expect(emitHeader("follow_1", "op-0")).toBe("wire-follow-b"); - expect(emitHeader("follow_2", "op-1")).toBe("wire-follow-c"); - }); - - it("As a dotli integrator, the host owns and disposes the wrapped provider", () => { - // Given - const harness = createHarness(); - - // When - harness.adapted.dispose(); - - // Then - expect(harness.dispose).toHaveBeenCalledOnce(); - }); -});