From 0179f10dc9cb59dbc6d762fc8efb3a45c38a9194 Mon Sep 17 00:00:00 2001 From: Wondertan Date: Thu, 3 Sep 2026 16:47:48 +0100 Subject: [PATCH 01/24] docs(popup): package documentation The @libid/popup package documentation as reviewed in #22: README, connection, control, MessagePort and WebRTC carriers, test plan, and metrics. The normative transport contract lives in specs/. Assisted-by: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw Signed-off-by: Wondertan --- ts/packages/popup/METRICS.md | 64 ++ ts/packages/popup/README.md | 202 ++++++ ts/packages/popup/TEST_PLAN.md | 80 +++ ts/packages/popup/docs/connection.md | 634 +++++++++++++++++++ ts/packages/popup/docs/control.md | 110 ++++ ts/packages/popup/docs/message-port.md | 305 +++++++++ ts/packages/popup/docs/webrtc.md | 409 ++++++++++++ ts/packages/popup/src/connection/README.md | 14 + ts/packages/popup/src/message-port/README.md | 14 + ts/packages/popup/src/webrtc/README.md | 14 + 10 files changed, 1846 insertions(+) create mode 100644 ts/packages/popup/METRICS.md create mode 100644 ts/packages/popup/README.md create mode 100644 ts/packages/popup/TEST_PLAN.md create mode 100644 ts/packages/popup/docs/connection.md create mode 100644 ts/packages/popup/docs/control.md create mode 100644 ts/packages/popup/docs/message-port.md create mode 100644 ts/packages/popup/docs/webrtc.md create mode 100644 ts/packages/popup/src/connection/README.md create mode 100644 ts/packages/popup/src/message-port/README.md create mode 100644 ts/packages/popup/src/webrtc/README.md diff --git a/ts/packages/popup/METRICS.md b/ts/packages/popup/METRICS.md new file mode 100644 index 00000000..42991512 --- /dev/null +++ b/ts/packages/popup/METRICS.md @@ -0,0 +1,64 @@ +# Popup metrics and diagnostics + +`@libid/popup` measures its own window, connection, carrier, continuity, and +control work. It never exports telemetry itself. A caller may supply +`onDiagnostic` to receive sanitized local events under its own observability +policy. + +```ts +interface PopupDiagnostic { + readonly code: string + readonly timestamp: number + readonly durationMs?: number + readonly count?: number +} +``` + +`code` is a stable package-owned identifier. The public type stays `string` +because the catalog is open: each carrier adds its own codes, and a closed +union would break exhaustive consumers on every addition. Renaming or removing +a catalogued code is a breaking change. `timestamp` uses +`performance.timeOrigin + performance.now()`. Optional finite, nonnegative +`durationMs` and integer `count` fields are present only where their meaning is +fixed by the code. The callback receives no arbitrary details map, raw +exception, URL, origin, connection ID, message discriminator, or transported +value. + +## Measurements + +| Area | Measurements | +|---|---| +| Popup window | `window-opened`, `window-blocked`, `window-bound` | +| Connection | `handshake-rejected`, `carrier-message-port`, `carrier-restored`, `carrier-fallback`, `fallback-unavailable`, `popup-unavailable`, `send-unavailable`, `connection-closed` and `connection-failed` with `durationMs` since construction | +| Message delivery | `decode-rejected`; MessagePort adds no encoding or clock, so no size or latency measurement exists | +| Continuity | `keep-acknowledged` with `durationMs`, `keep-failed`, `claim-empty`, `continuity-unsupported` | +| Control | `control-direct`, `control-connected`, `control-rejected`; never remote success | +| WebRTC | signaling-path class, offer publication, answer pickup, candidate class, selected-pair class, ICE checks, DTLS, data-channel open, and terminal failure | + +`fallback-unavailable` is emitted exactly once only when opener-based +connection has failed and no fallback constructor exists. Merely omitting the +constructor or successfully selecting MessagePort emits no fallback failure. +A supplied constructor which rejects retains its own stable failure code; +connection records it only if that fallback path is selected. + +## Privacy and failure handling + +Diagnostics never contain: + +- popup or application origins, URLs, connection IDs, SDP, ICE addresses, + cookies, or signaling records; +- caller message types, fields, payloads, proof material, credentials, or byte + contents; or +- raw exceptions, stacks, caller-selected labels, or unbounded strings. + +Candidate and selected-pair measurements use only package-defined classes. +Message sizes use bounded numeric buckets rather than contents. Any caller +export maps package codes to its own cardinality-controlled schema. + +An operation that can reject reports failure through that rejection and the +optional callback. A caught asynchronous failure with no remaining caller +operation emits one sanitized `console.error` containing only the package +subsystem and stable code, then invokes `onDiagnostic` when present. Failure of +the callback or console path is inert. The package starts no reporting request, +writes no durable diagnostic record, retries nothing, and synthesizes no caller +result. diff --git a/ts/packages/popup/README.md b/ts/packages/popup/README.md new file mode 100644 index 00000000..e8058a42 --- /dev/null +++ b/ts/packages/popup/README.md @@ -0,0 +1,202 @@ +# @libid/popup + +`@libid/popup` owns one popup browsing context from creation or adoption through +navigation and closure. It connects that popup to an application page across a +caller-approved set of origins, external navigation, isolation boundaries, +mobile suspension, and popup-document replacement, carrying caller-defined +messages without owning or naming the caller protocol. + +The detailed design is split into the [popup connection](docs/connection.md), +its [MessagePort](docs/message-port.md) and [WebRTC](docs/webrtc.md) carriers, +and [popup control](docs/control.md). +Acceptance is indexed by the [test plan](TEST_PLAN.md), while +[metrics and diagnostics](METRICS.md) defines local observability. + +## API + +### Open the popup + +The application creates a lifecycle object during the user activation. It then +constructs the connection before deciding whether to suppress the action's +native navigation: + +```ts +const popupWindow = PopupWindow.open(anchor.target) +const connection = PopupConnection.connect(popupWindow, { + connectionId, + allowedPopupOrigins, + fallback, + onDiagnostic, +}) + +void connection.navigate(anchor.href) +if (popupWindow.opened) event.preventDefault() +``` + +`PopupWindow.open(target)` synchronously attempts +`window.open('about:blank', target)` and returns a wrapper even when the browser +returns no handle. It throws `TypeError` before opening for an empty target or +one beginning with `_`. When no handle is returned, the connection binds the +popup created by the same action's real anchor. See [popup creation and +native-anchor fallback](docs/connection.md#popup-creation-and-native-anchor-fallback). + +```ts +declare class PopupWindow { + readonly opened: boolean + + static open(target: string): PopupWindow + static current(): PopupWindow +} +``` + +`PopupWindow` exposes no direct navigation or closure; both go through +`PopupConnection` so continuity and control rules always apply. +`PopupWindow.current()` wraps the current popup document, its opener, and the +active Service Worker registration whose scope matches that document. It +adopts the existing popup and cannot create another one. + +### Connect from the popup + +Each participating popup document accepts its side of the same logical +connection: + +```ts +const popupWindow = PopupWindow.current() +const connection = await PopupConnection.accept(popupWindow, { + connectionId, + allowedApplicationOrigins, + fallback, + onDiagnostic, +}) +``` + +The caller supplies a fresh `crypto.randomUUID()` value for each logical +connection; the exact accepted grammar and non-reuse rule are defined by the +[connection ID contract](docs/connection.md#connection-id). + +`PopupConnection` retains a usable carrier for as long as possible and may +preserve, transfer, or replace it transparently across document changes. If no +carrier can continue or be established, the logical connection fails closed. +Same-origin replacement may preserve a `MessagePort` through the continuity +worker. Cross-origin replacement, including navigation to another site, never +transfers a port between Service Workers: the next participating document +authenticates a fresh carrier through its opener or the configured fallback. +A cross-origin destination whose isolation policy severs its opener therefore +requires a fallback constructor; without one, the connection fails closed. + +```ts +interface PopupConnection { + send(message: M): void + on( + message: MessageType, + handler: (message: N) => void, + ): () => void + navigate(url: string): Promise + close(): Promise +} +``` + +Before carrier selection, `navigate` uses the retained popup handle when +available. Once a carrier is active, the application endpoint sends navigation +control over it; popup-endpoint navigation acts locally. `close` uses an +available retained handle and otherwise uses popup control, then releases both +the connection and popup. + +### Define and exchange messages + +Each caller-owned message class supplies its discriminator and decoder: + +```ts +interface Message { + readonly type: string +} + +interface MessageType { + readonly type: M['type'] + decode(value: unknown): M +} + +class PopupReady implements Message { + static readonly type = 'popup-ready' + readonly type = PopupReady.type + + constructor(readonly version: number) {} + + static decode(value: unknown): PopupReady { + assertPopupReady(value) + return value + } +} + +type Messages = PopupReady + +connection.on(PopupReady, ready => { + // ready is PopupReady +}) + +connection.send(new PopupReady(1)) +``` + +Higher-level popup logic combines these classes into its own union and supplies +that union to `PopupConnection`. Lifecycle controls remain internal and never +reach caller handlers. Register handlers synchronously after `connect` returns +or `accept` resolves: inbound values dispatch as later tasks, and a value with +no registered handler closes the connection. `send` throws synchronously +without an active carrier or after closure; nothing is queued. + +### Diagnostics + +Both connection constructors accept an optional local diagnostic sink: + +```ts +interface PopupDiagnostic { + readonly code: string + readonly timestamp: number + readonly durationMs?: number + readonly count?: number +} +``` + +`code` is one of the stable identifiers catalogued in +[metrics and diagnostics](METRICS.md); the set grows with new carriers. + +### Continuity worker + +Connected same-origin navigation between participating popup documents +preserves the MessagePort through a Service Worker on that origin. The host, +the deployment serving the popup documents, registers that worker and calls +the handler from the `@libid/popup/worker` subpath in its worker script; the +package registers nothing and the main entry exports no worker-global types: + +```ts +// popup-origin worker script +import { installPortKeeper } from '@libid/popup/worker' + +installPortKeeper() +``` + +`accept` claims a preserved port from the active registration matching the +current document as its first step, so the host calls it before any other +network work. See [continuity across navigations](docs/message-port.md#continuity-across-navigations). + +### Fallback carrier + +The optional fallback is a carrier constructor supplied independently in every +participating document: + +```ts +type CarrierConstructor = (signal: AbortSignal) => Promise + +interface Carrier { + send(value: Message): void + on(handler: (value: unknown) => void): () => void + close(): void +} +``` + +Omitting it starts no fallback work. If opener-based connection fails, the +connection terminates with the stable `fallback-unavailable` diagnostic. The +WebRTC application constructor closes over its own signaling and ICE +configuration. Its popup-side factory eagerly consumes package-owned navigation +metadata and returns the later constructor without starting RTC. Callers do not +manage carrier selection, replacement, or lifetime. diff --git a/ts/packages/popup/TEST_PLAN.md b/ts/packages/popup/TEST_PLAN.md new file mode 100644 index 00000000..8093b5ce --- /dev/null +++ b/ts/packages/popup/TEST_PLAN.md @@ -0,0 +1,80 @@ +# `@libid/popup` test plan + +These tests qualify popup lifecycle, connection, carriers, continuity, and +control independently of any protocol transported by the package. + +## API and message delivery + +| ID | Assertion | +|---|---| +| POPUP-API-001 | A caller-defined message union is accepted by `PopupConnection` without entering package-owned source, and `send` rejects the reserved `navigate` and `close-popup` discriminators. | +| POPUP-API-002 | `on` registers one class per discriminator, rejects duplicates, calls its static `decode` exactly once, returns the same decoded object, and unsubscribes only that handler. | +| POPUP-API-003 | Unknown, unregistered, malformed, or decoder-rejected input closes the connection and reaches no caller handler. | +| POPUP-API-004 | Concurrent connections keep message registrations, native resources, controls, and delivery isolated by connection ID; before native-anchor binding, a valid handshake for another connection ID is ignored rather than rejected. | +| POPUP-API-005 | The emitted `PopupWindow` declaration omits internal `bind`; package source invokes it only after exact native-anchor validation. | + +## Popup window and control + +| ID | Assertion | +|---|---| +| POPUP-WINDOW-001 | `PopupWindow.open(target)` synchronously attempts one named `about:blank` popup and retains the returned `WindowProxy`; it creates no second browsing context. | +| POPUP-WINDOW-002 | When scripted opening returns `null`, the same activation's real anchor retains its navigation, and only an exact initial source/origin/version/connection binding marks the wrapper opened. | +| POPUP-WINDOW-003 | `PopupWindow.open` throws `TypeError` for an empty target and every target beginning with `_` before invoking `window.open`; `noopener`, `noreferrer`, delayed synthetic activation, a wrong source, and an opaque sandbox origin cannot bind the native-anchor path. | +| POPUP-WINDOW-004 | Popup presentation as a window or tab changes no connection, storage, recovery, or control rule; `closed` selects only whether a no-carrier direct operation can be attempted and never becomes a protocol result. | +| POPUP-CONTROL-001 | While native-anchor binding is pending, `navigate` performs no browser operation and leaves the same activation's default navigation intact. Without an active carrier, `navigate` and `close` use the retained handle only while it is non-null and not closed and emit no control message. | +| POPUP-CONTROL-002 | With an active carrier, application-endpoint `navigate` sends one exact canonical-HTTPS `Navigate` even while its retained handle appears usable; popup-endpoint `navigate` sends no control. Either popup-side path invokes replacement only after carrier continuity is preserved or prepared; malformed, credentialed, noncanonical, relative, non-HTTPS, and unpreparable navigations fail before browser navigation. Real-engine tests confirm a COOP-severed retained handle reports `closed`. | +| POPUP-CONTROL-003 | `close` uses a non-null, non-closed retained handle directly regardless of carrier state and otherwise sends one `ClosePopup` over an active carrier. It closes local resources, is idempotent, and is accepted only for a package-created script-closable popup. | +| POPUP-CONTROL-004 | Controls carry no version field because they travel only over a version-authenticated carrier. They are application-to-popup, one-shot per receiving document, unacknowledged, connection-bound, and private; wrong-direction, duplicate, replayed, unknown, and post-terminal controls perform no browser operation. `Navigate` may continue the logical connection in its destination, while `ClosePopup` terminates it. | + +## Carrier authentication and selection + +| ID | Assertion | +|---|---| +| POPUP-CONNECTION-001 | A carrier becomes selectable only after it authenticates both browser endpoints; origin, source, connection ID, connection version, and direction mismatches release no caller value. | +| POPUP-CONNECTION-002 | The popup selects the path for each participating document; the application never runs an independent first-promise-wins race. MessagePort becomes selectable on the application only after the popup's exact port acknowledgement. An absent, severed, or timed-out opener commits fallback, while an authentication failure is terminal. Installing a new document's authenticated carrier atomically closes the obsolete carrier; a failed active carrier never initiates fallback by itself. | +| POPUP-CONNECTION-003 | One logical connection survives repeated participating-document replacements: `navigate` preserves a usable carrier when possible and otherwise replaces it transparently; callers never manage carrier identity, count, lifetime, or reconnection. If no carrier can continue or be established, the connection fails closed. | +| POPUP-CONNECTION-004 | `connect` invokes a supplied fallback constructor exactly once with the connection-lifetime signal and observes its promise without awaiting it; `accept` invokes its constructor once only after MessagePort becomes unavailable. The constructor returns only an authenticated `Carrier`; connection creates no substitute `MessageChannel` and queues no caller value outside that carrier. MessagePort selection retains the unused application fallback. No-carrier navigation arms only a fresh document-local MessagePort operation; popup-side navigation from RTC uses its package-private lifecycle hooks to prepare the target and retain a pending replacement before navigating, then installs only its authenticated result. Connection closure aborts every pending operation. | +| POPUP-CONNECTION-005 | Without a fallback constructor, successful MessagePort use emits no fallback diagnostic; fallback selection records exactly one sanitized `fallback-unavailable` failure and closes. | +| POPUP-CONNECTION-006 | Carrier loss, endpoint loss, popup closure, background suspension, and continuity loss are never delivery, cancellation, success, or recovery. Resumed delivery preserves order. While the popup is non-participating after a connected navigation, every `send` and `navigate` succeeds locally and delivers nothing. | +| POPUP-CONNECTION-007 | Both constructors accept an exact lowercase RFC 4122 UUIDv4 `connectionId` and reject uppercase, noncanonical, malformed, wrong-version, and wrong-variant strings before carrier, keeper, or signaling work. Caller integration generates a different `crypto.randomUUID()` value for every logical connection and never reuses a retired value. | +| POPUP-CONNECTION-008 | `PopupConnection.navigate()` directly between same-origin participating documents preserves a MessagePort or prepares a nontransferable carrier. Across origins, including across sites, it never gives a MessagePort to the source origin's worker: it retires that popup endpoint and the allowed destination establishes a fresh carrier through its opener or fallback. A navigation outside that API loses the current carrier. A later participating document may establish the first RTC carrier from the still-unused initial fallback without round metadata; after RTC is active, an unmanaged navigation terminates the logical connection without restarting round zero. | +| POPUP-CONNECTION-009 | `connect` and `accept` copy nonempty, duplicate-free sets of canonical HTTPS `allowedPopupOrigins` and `allowedApplicationOrigins`, respectively. Every initial, native-anchor, replacement, MessagePort, and RTC participant binds one exact browser-observed member of the peer's set. Sequential popup participants may use different admitted origins without changing the logical connection ID or caller registrations. An empty set or duplicate, malformed, noncanonical, credentialed, or unapproved origin fails before selection or caller delivery. | + +## MessagePort and navigation continuity + +| ID | Assertion | +|---|---| +| POPUP-PORT-001 | The popup initiates one private handshake; the application exact-checks source, membership of the browser-stamped origin in `allowedPopupOrigins`, connection ID, connection version, and direction before transferring exactly one `MessagePort` back to that exact observed origin. The popup validates the response and echoes the same handshake record over the ordered port. Only an exact final acknowledgement resolves the application operation and makes MessagePort selectable; missing, malformed, duplicate, or mismatched acknowledgement selects nothing. The application listener accepts sequential handshakes from different allowed popup origins over the connection lifetime, discarding only per-attempt state, and ignores any event lacking the discriminator and matching connection ID. | +| POPUP-PORT-002 | The selected port carries ordered, nonduplicated structured-clone values. Delivery performs no serialization or allocation before the registered message decoder. | +| POPUP-KEEPER-001 | `keep` acknowledges only after the worker owns one exact port; navigation begins only after that acknowledgement, and `claim` atomically transfers and removes it. | +| POPUP-KEEPER-002 | Duplicate, mismatched-version, mismatched-ID, or malformed keep/claim operations fail and close every reachable port; an expired or already-claimed entry is absent and yields `null`. The host-registered worker handles keep/claim only through `installPortKeeper` from `@libid/popup/worker`. | +| POPUP-KEEPER-003 | Chromium, Firefox, and WebKit real or qualified emulation preserve the port across immediate same-origin document replacement below the common five-second deadline when both documents resolve the same active Service Worker registration and scope. A different same-origin registration claims no port and safely re-handshakes. Cross-origin replacement never invokes `keep` and establishes a fresh carrier; no port or keeper survives a non-participating external document or unbounded wait, and expiry, worker termination, suspension, and process loss fail without recovery. | +| POPUP-KEEPER-004 | Continuity preserves an authenticated MessagePort unchanged. WebRTC and caller values never enter `PortKeeper`; it stores no caller value in a URL, request, cookie, IndexedDB, or other durable record. | + +## WebRTC fallback + +| ID | Assertion | +|---|---| +| POPUP-RTC-001 | `connect` invokes the application fallback exactly once and starts bounded one-use answerer round zero before the first popup navigation. It keeps that round armed across any number of MessagePort-selected documents until consumed, failed, abandoned, or connection closure; no later fallback invocation can restart round zero. The destination popup constructor creates the offerer only after fallback selection. Each RTC replacement creates fresh peers, offer, answer, and ICE state under the same logical connection ID and exactly incremented round. | +| POPUP-RTC-002 | Signaling exact-checks each endpoint's browser-stamped origin against its immutable admission set and binds one exact application and popup origin for the round, along with connection version, connection ID, unsigned 32-bit round, and role on every record. Connection ID is randomized correlation and grants no authority without the authenticated endpoint origin and role. At most one round per connection ID is live, and its records are bounded, one-use, transient, and contain no caller message. Delayed offers, answers, candidates, and cleanup from round N are rejected after round N+1 starts. | +| POPUP-RTC-003 | Both peers use trickle ICE with configured STUN and one ordered reliable data channel without requiring mDNS, local-network permission, ICE gathering completion, or TURN. Direct-path failure closes the carrier. | +| POPUP-RTC-004 | The codec round-trips its closed JSON/`Uint8Array` value domain. Invalid UTF-8, JSON, byte tags, frame order or length, sparse or unsupported values, oversize, and buffer overflow reach no connection decoder. | +| POPUP-RTC-005 | MessagePort selection leaves an unused application answerer round armed and creates no popup peer. Abort, signaling failure, ICE failure, unexpected data-channel loss, or connection closure deletes the current round and closes every reachable peer and channel without reconnecting or resending. Only a fresh participating popup document or controlled RTC navigation may select or prepare an RTC carrier. | +| POPUP-RTC-006 | An RTC carrier exposes both exact package-private lifecycle symbols and no public navigation API. Popup-side `prepareNavigation(target)` sends `PrepareNavigation`; the selected application carrier internally starts exactly current round plus one, reports only its pending authenticated carrier through the one registered `onReplacement` handler, and sends `NavigationReady(nextRound)` only after its subscription is armed. The popup carrier returns the target with its private fragment field, after which connection may navigate and later install only the authenticated replacement carrier. Before `PopupConnection.accept`, the destination's popup WebRTC factory copies and clears the field and returns the later constructor without signaling. MessagePort selection therefore leaves no package field and starts no popup RTC peer. No round reaches connection, and neither lifecycle hook nor private control reaches `PopupControl`, caller handlers, the generic carrier value stream, or a MessagePort carrier. | +| POPUP-RTC-007 | Missing where required, duplicate, misplaced, malformed, noncanonical, negative, fractional, out-of-range, stale, unexpected, skipped, repeated, or overflowing navigation rounds fail without signaling, caller delivery, or browser navigation. Neither round metadata nor the connection ID grants authority without the exact authenticated endpoint origin and role. | +| POPUP-RTC-008 | Preparing and consuming `__libid_popup=rtc1..` round-trips targets with no fragment, an empty fragment, ordinary fields, duplicate fields, preserved field order, mixed percent-escape spelling, encoded separators, and trailing separators without changing one caller byte. A caller-owned raw `__libid_popup` component rejects before navigation; invalid destination metadata clears the fragment and rejects before carrier selection. | + +## Browser qualification + +| ID | Assertion | +|---|---| +| POPUP-BROWSER-001 | Chromium, Gecko, and WebKit qualification covers Android, iOS, Linux, macOS, and Windows, including foreground popup work while the application tab is hidden or suspended. | +| POPUP-BROWSER-002 | Real-device qualification covers popup blocking, native-anchor fallback, browser promotion between popup and tab, background suspension, process eviction, and isolation severing the opener. Emulator-only results do not satisfy the corresponding real-device gate. | + +## Diagnostics + +| ID | Assertion | +|---|---| +| POPUP-DIAGNOSTIC-001 | Every emitted diagnostic contains only its stable package code, monotonic-derived timestamp, and any code-defined finite nonnegative duration or count; privacy-forbidden values and raw exceptions never reach the callback or console. | +| POPUP-DIAGNOSTIC-002 | A rejecting caller operation reports through rejection and the optional callback. An otherwise undeliverable asynchronous failure emits one sanitized `console.error` and optional callback event without network reporting, durable storage, retry, or caller result. Callback or console failure is inert. | +| POPUP-DIAGNOSTIC-003 | `fallback-unavailable` is absent when MessagePort succeeds and emitted exactly once only when fallback is selected without a constructor. An early supplied-constructor rejection remains observed and silent unless its path is selected. | diff --git a/ts/packages/popup/docs/connection.md b/ts/packages/popup/docs/connection.md new file mode 100644 index 00000000..5eae9067 --- /dev/null +++ b/ts/packages/popup/docs/connection.md @@ -0,0 +1,634 @@ +# Popup connection + +This document defines the popup connection architecture. A `PopupWindow` +owns one popup from creation through closure. A `PopupConnection` composes over it, +establishes a bidirectional channel to an application page, moves caller-defined +values, selects one carrier for each participating popup document, and preserves +a transferable native resource across same-origin participating-document +replacement. Different participating popup documents may use different +caller-approved origins, including origins on different sites. It is not a +generic document-to-document abstraction. + +`PopupConnection` represents one logical connection. It retains a usable +carrier for as long as possible and may preserve, transfer, or replace that +carrier transparently across document changes. Carrier identity, count, and +lifetime are not API guarantees. If no carrier can continue or be established, +the logical connection fails closed. + +```ts +type ConnectionVersion = 1 +``` + +`ConnectionVersion` exact-matches the connection's private authentication, +carrier, signaling, framing, and continuity controls. It does not version or +describe any caller protocol. + +### Connection ID + +`connectionId` is a caller-supplied string with this exact canonical lowercase +RFC 4122 UUIDv4 grammar: + +```text +^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ +``` + +The caller generates it with a cryptographically secure random-number +generator, normally `crypto.randomUUID()`. It must be fresh for every logical +popup connection and must never be reused, including after failure or closure. +Every participating document in that logical connection receives the same +exact value. + +Connection constructors validate the grammar before starting carrier, +continuity, or signaling work. They do not normalize uppercase or other UUID +spellings. Freshness is a caller invariant: the package keeps no durable reuse +registry. + +The topology is an ordinary browser tab running the application and one +adjacent popup. The application and popup may be cross-origin and cross-site. + +```text +Application tab Popup +https://app.example https://popup.example +┌────────────────────────────┐ carrier ┌────────────────────────┐ +│ PopupConnection.connect │<=======>│ PopupConnection.accept │ +└────────────────────────────┘ └────────────────────────┘ +``` + +## Operating constraints + +The connection must: + +- connect the application tab and popup across origins or sites; +- preserve one logical connection while the popup crosses an external document, + loses its initial browsing-context relationship, and enters an isolated + document; +- require no connection-owned route, standalone script, or server endpoint on + the application origin; +- require no additional top-level browsing context beyond the existing popup + and no second user action; +- keep active work in the visible popup without requiring the application tab + to remain visible or continuously scheduled; +- carry application-level messages directly between the two browser endpoints; + no server relays or stores them or terminates their channel; and +- provide ordered bidirectional delivery across WebKit, Gecko, and Chromium on + Android, iOS, Linux, macOS, and Windows, including when mobile browsers show + only the popup and suspend the application tab, without browser-specific + protocol branches or user-agent detection. + +## Boundary + +`PopupWindow` owns: + +- popup creation, native-anchor adoption, its retained handle, direct + navigation, and direct closure on the application side; and +- the current window, its opener, and Service Worker access on the popup side. + +`PopupConnection` owns: + +- one logical connection bound to the caller-supplied connection ID and + connection version, plus its immutable allowed popup-origin set; +- composition over an injected `PopupWindow` and connected navigation and + closure; +- selection and ownership of one authenticated carrier; +- ordered delivery and continuity across popup document replacement; + and +- one active carrier at a time, carrier replacement after external navigation, + connected navigation, connection closure, cleanup, and race resolution. + +Carriers own endpoint authentication, establishment, physical serialization +where required, native framing, resource cleanup, and delivery mechanics. Each +caller-registered `MessageType` owns one message's structural decoding and +routing discriminator. Callers own the permitted message set, protocol order, +navigation destinations, route meanings, state transitions, and outcomes. The +connection invokes decoding and dispatch but does not interpret the resulting +message. + +## Failure and security rules + +- A carrier is selectable only after it authenticates both endpoints. +- One connection admits at most one popup browsing context and one active + carrier. Each participating popup document authenticates and selects its own + carrier; stale carrier results are inert. +- Application-level messages travel only over the active end-to-end carrier. + Rendezvous and continuity + controls carry none; neither do cookies, durable storage, request data, or + URLs. +- A carrier may validate its generic value domain, bounds, and framing but + cannot inspect a message discriminator or interpret, classify, synthesize, or + alter its meaning. +- Wrong, stale, duplicate, replayed, or post-close control messages cannot bind, + select, reopen, or mutate connection. The registered `MessageType` validates + structure; callers validate protocol state and order. +- Carrier, endpoint, continuity mechanism, or browser-context loss is never + delivery, success, cancellation, or recovery. +- Background suspension may delay delivery but is not success, cancellation, + or a reason to select another carrier; delivery after resumption preserves + order. +- An observed failure closes reachable resources and releases no later value; + the caller determines the outcome. + +## API + +The module has one long-lived application endpoint and a fresh popup endpoint +for each popup document. `PopupWindow` factories capture the endpoint's browser +resources; connection constructors receive only that injectable wrapper and +authentication inputs: + +```ts +const openedWindow = PopupWindow.open(anchor.target) +const applicationConnection = PopupConnection.connect(openedWindow, { + connectionId, + allowedPopupOrigins, + fallback, + onDiagnostic, +}) + +const currentWindow = PopupWindow.current() +const popupConnection = await PopupConnection.accept(currentWindow, { + connectionId, + allowedApplicationOrigins, + fallback, + onDiagnostic, +}) +``` + +Messages are caller-owned classes registered independently. The connection +requires unique discriminators but defines no protocol namespace, closed union, +or caller message type. + +`PopupWindow.open(target)` creates the application-side lifecycle object and +synchronously attempts `window.open('about:blank', target)`. +It rejects an empty target and every name beginning with `_` before invoking +the browser, matching the HTML +[valid navigable target name](https://html.spec.whatwg.org/multipage/document-sequences.html#valid-navigable-target-name-or-keyword) +rule and excluding reserved keywords such as `_blank`, `_self`, `_parent`, and +`_top`. +`PopupConnection.connect` composes over that exact object, synchronously arms +fallback binding, and never accepts a caller-supplied `WindowProxy`. It never +constructs a `PortKeeper`. `PopupWindow.current()` captures the popup document, +its opener, and the host-registered active Service Worker registration whose +scope matches that document. +`PopupConnection.accept` composes over that object. When an active registration is +available, it privately constructs a keeper and attempts `claim` for the +connection ID before selecting a new carrier. A matching entry restores its +native port; no entry leaves the fresh endpoint to use its available opener or +signaling resources normally. + +`connect` copies `allowedPopupOrigins`, and `accept` copies +`allowedApplicationOrigins`. Both must be nonempty, duplicate-free sets of +canonical HTTPS origins; either constructor rejects an invalid member or +duplicate. Every initial or later participating popup document must authenticate +from one exact popup-origin member, and each popup endpoint binds one exact +observed application-origin member. The sets admit participants; they neither +select navigation destinations nor turn an external document into a +participant. + +There is no public role field or per-operation role branch. Callers never +supply a keeper, route, or phase. + +Both endpoint records include the same caller-supplied connection ID. The +package supplies `ConnectionVersion` internally. Connection uses the ID for +authentication, continuity, and private signaling without recovering it from +transported values. It exact-matches both values in private carrier and +navigation controls but assigns neither caller-level semantics. The connection +ID lives for the logical connection; individual carrier attempts do not consume +it. + +A popup endpoint constructed from `PopupWindow.current()` and an immutable +application-origin set sends the MessagePort carrier's private handshake before +carrier selection. For that carrier, its connection ID and connection version +are correlation metadata, not capabilities or caller values. WebRTC uses the +randomized connection ID only as rendezvous correlation combined with the +authenticated endpoint origin and role; the ID alone grants no authority. The +application endpoint validates and consumes the control without exposing it to +caller code. + +`PopupWindow.open(target)` throws `TypeError` for an invalid target and otherwise +binds a returned handle privately. When the browser +returns no handle, `PopupConnection.connect` listens for the popup created by the +native anchor. It considers only the expected initial private control with the exact +connection ID and connection version from an allowed popup origin. After +exact validation it internally calls +`PopupWindow.bind(MessageEvent.source)` once. +Wrong source, origin, ID, version, direction, or initial control rejects the +connection. `bind` is package-internal and never accepts or interprets a caller +message. `PopupWindow.opened` is initially true only when scripted creation +returned a handle and becomes true after successful fallback binding. + +When a document change cannot preserve the popup endpoint's current carrier, +the connection retains its logical state and bound popup browsing context while +it prepares a replacement. An active WebRTC carrier privately asks the +application endpoint to start the next one-use signaling round and waits for its +readiness before the popup navigates. The next participating popup document +calls `accept` and installs the selected carrier under the same logical +connection. These mechanics are transparent to the caller. + +### Popup creation and native-anchor fallback + +The caller renders an action-specific anchor with the destination URL and a +unique valid target. On activation it lets the package attempt popup +creation and synchronously arms fallback binding before the handler returns: + +```ts +function activate(event: MouseEvent) { + const anchor = event.currentTarget as HTMLAnchorElement + const popupWindow = PopupWindow.open(anchor.target) + const connection = PopupConnection.connect(popupWindow, { + connectionId, + allowedPopupOrigins, + }) + + void connection.navigate(anchor.href) + if (popupWindow.opened) event.preventDefault() +} +``` + +`PopupWindow.open(target)` is one-shot and always attempts +`window.open('about:blank', target)`. +When the browser returns a usable handle, `PopupWindow` retains the exact +`WindowProxy` and the caller prevents native anchor navigation; only a later +`navigate(url)` chooses +the destination. When creation returns `null`, the caller leaves the same +activation's native anchor navigation untouched and `navigate` performs no +browser operation while that binding is pending. The application connection +binds only the popup whose initial private control authenticates for this +connection ID and one of its allowed popup origins. + +The anchor must use that same valid, unique target and +must not request `noopener` or `noreferrer`: the MessagePort fallback needs its +opener relationship long enough to authenticate and transfer the carrier port. + +The anchor is a compatibility hedge for an environment or embedding policy +which rejects scripted popup creation, not a second user flow. It must exist +before activation so the fallback proceeds in the same tap. Both paths use the +same target and create one script-closable top-level traversable. The scripted +path exposes `popup.closed` only to decide whether a no-carrier direct operation +can be attempted; before fallback binding no handle exists to observe. Closure +is never delivery, cancellation, or another caller-protocol outcome. + +The application lifecycle object and both connection endpoints expose: + +```ts +interface Message { + readonly type: string +} + +interface MessageType { + readonly type: M['type'] + decode(value: unknown): M +} + +declare class PopupWindow { + readonly opened: boolean + + /** @internal PopupConnection.connect calls this after exact validation. */ + bind(source: WindowProxy): void + + static open(target: string): PopupWindow + static current(): PopupWindow +} + +interface PopupConnection { + send(message: M): void + on( + message: MessageType, + handler: (message: N) => void, + ): () => void + navigate(url: string): Promise + close(): Promise +} + +type CarrierConstructor = (signal: AbortSignal) => Promise + +interface PopupDiagnostic { + readonly code: string // stable identifier catalogued in METRICS.md + readonly timestamp: number + readonly durationMs?: number + readonly count?: number +} + +// @libid/popup/worker +declare function installPortKeeper(): void + +declare const PopupConnection: { + connect( + popupWindow: PopupWindow, + options: { + connectionId: string + allowedPopupOrigins: readonly string[] + fallback?: CarrierConstructor + onDiagnostic?: (event: PopupDiagnostic) => void + }, + ): PopupConnection + + accept( + popupWindow: PopupWindow, + options: { + connectionId: string + allowedApplicationOrigins: readonly string[] + fallback?: CarrierConstructor + onDiagnostic?: (event: PopupDiagnostic) => void + }, + ): Promise> +} +``` + +The package build enables TypeScript `stripInternal`, so `bind` is available to +package source but absent from the emitted public declaration. `PopupWindow` +exposes no direct navigation or closure; its direct operations are +package-internal and reachable only through `PopupConnection`. +`installPortKeeper` is the Service Worker handler the host, the deployment +serving the popup documents, composes into its own popup-origin worker +script. It is exported only from the `@libid/popup/worker` subpath so the main +entry carries no worker-global types; see the +[MessagePort carrier](message-port.md#internal-portkeeper-api). + +`PopupConnection` owns one connection-lifetime cancellation signal and +document-local MessagePort cancellation. Pending handshakes, signaling, carrier +replacement, and connection closure use those signals; `close()` aborts all of +that work. Cancellation machinery is not part of the public API. + +`fallback` constructs one ordinary authenticated `Carrier` and is not another +connection abstraction. `connect` invokes a supplied constructor exactly once +for the logical connection so opener-independent signaling is armed before +navigation. It retains and observes the pending promise without awaiting it or +producing an unhandled rejection. Selecting MessagePort does not abort this +standby: it remains armed until it is consumed by a later participating popup +document, fails, or the logical connection closes. `accept` invokes its supplied +constructor only after MessagePort becomes unavailable. A carrier module may +perform synchronous, networkless destination bootstrap while producing that +constructor; in particular, the popup-side WebRTC factory consumes its private +navigation metadata before `accept` begins carrier selection. Connection passes +its connection-lifetime abort signal; the constructor closes over every +carrier-specific option. + +Before navigation without an active carrier, the application starts the next +document-local MessagePort operation while retaining any unused fallback. +Before popup-side navigation destroys an active WebRTC carrier, that carrier +privately requests and awaits preparation of its next signaling round from the +application endpoint. Connection closure aborts every pending operation. +If no constructor was supplied when fallback becomes necessary, connection +records stable code `fallback-unavailable` and closes. + +`onDiagnostic` receives sanitized local events from construction onward. It is +advisory, may throw without affecting connection behavior, and initiates no +package-owned network or durable-storage work. Its data rules and measurement +catalog are defined by [metrics and diagnostics](../METRICS.md). + +Higher-level popup logic supplies its composition-owned message union to +`PopupConnection` and registers the union's classes and handlers. +Connection-owned lifecycle controls are decoded and consumed internally and +never reach caller handlers. + +The transported implementation type is private: + +```ts +type WireMessage = M | PopupControl +``` + +Public `send` and `on` expose only `M`. `navigate` and `close` create the +controls. Their discriminators are reserved; sending or registering a caller +message with either discriminator rejects. + +On the popup side, `navigate` coordinates carrier continuity and replaces the +current document, preserving or replacing the carrier internally as needed; +`close` closes the current popup and connection. Neither can create another +browsing context. On the application side, the same operations use the control +messages described below whenever a carrier is active and otherwise delegate to +`PopupWindow` when its retained handle remains available. Callers never manage +carrier reconnection. + +`send` accepts the composition-owned union `M` and is not a delivery +acknowledgement. It throws synchronously without an active carrier or after +closure; the connection queues no caller value. Apart from rejecting reserved +control discriminators, the connection does not revalidate trusted local input. + +`on` registers one message class and handler by `message.type` and returns an +unsubscribe function. Duplicate or reserved registrations throw synchronously. +Callers register handlers synchronously after `connect` returns or `accept` +resolves; inbound values dispatch as later tasks. For each inbound carrier +value, the connection reads only a bounded string `type` from a plain record, +selects the registered `MessageType`, calls `decode` exactly once, and invokes +that handler. An unknown or unregistered type, malformed routing discriminator, +or thrown decode closes the connection and delivers no message. The registered +set therefore enforces participant direction without hardcoding protocol types +in the connection; the handler still enforces state and order. + +`navigate` is available on both connection endpoints. The application endpoint +always sends the private control defined by [popup control](control.md) when a +carrier is active. Without one, it uses its exact retained `WindowProxy` only +while the handle is non-null and not closed. The popup endpoint prepares +continuity and replaces its own document without sending that control. `close` +uses a non-null, non-closed retained handle directly and otherwise sends its +control over an active carrier. It is idempotent and closes both the logical +connection and its popup. Internal failure cleanup releases resources without +invoking either operation or controlling popup lifetime. + +`navigate` accepts a caller-selected opaque URL. It does not select the route or +interpret caller-owned fields: + +- while a carrier is active, the application endpoint sends `Navigate` over it; +- without an active carrier, the application endpoint arms the next + document-local MessagePort operation, retains an unused fallback, and + navigates its exact retained `WindowProxy` only while that handle is non-null + and not closed; while native-anchor binding is pending it performs no browser + operation; and +- either the popup endpoint calling `navigate` or the popup receiving that + control preserves a transferable carrier, deliberately releases it for a + cross-origin rebind, or privately prepares a replacement for a non-transferable + WebRTC carrier, then replaces its current document. + +A selected MessagePort is preserved through `PortKeeper` only when the target +has the current popup origin. For a different origin, including one on another +site, connection does not send the port to the source origin's worker. It +retires that popup endpoint and leaves the application listener armed; an +allowed destination establishes a fresh MessagePort through its surviving +opener. If isolation removes that opener, only the configured fallback can +establish the destination carrier. The logical connection ID and caller +registrations remain unchanged. While replacement is pending, caller values +sent by the application succeed locally and are lost, as during any other +non-participating window; the application cannot observe the retirement. + +For WebRTC replacement, connection gives the caller-selected target unchanged +to the carrier's package-private preparation hook and navigates only to the +prepared target returned by that hook. Connection does not inspect or construct +the carrier's private navigation metadata. The destination's popup-side WebRTC +factory copies, validates, and clears that metadata before `accept` attempts +MessagePort and, only if unavailable, commits that constructor. Invalid +metadata or preparation +rejects popup construction before carrier selection; preparation failure closes +the connection without navigation. + +Navigation to a non-participating document may wait for user interaction. A +preserved port expires there and the application's former carrier becomes +unusable. The application cannot observe that expiry: it retains the carrier +until a later participating document's handshake replaces it or the connection +closes. No application message is deliverable while the popup is +non-participating: every `send` and `navigate` issued meanwhile succeeds +locally and is lost, while `close` still uses the live handle. An initial +fallback which has not yet been selected remains armed. +The next participating document may use it to establish the first RTC carrier +without navigation-round metadata. Connected navigation between participating +documents may instead preserve a transferable port across the bounded +replacements defined below. + +The factory installs the appropriate operation from the native resource it +owns, never from the URL. A popup endpoint without the required carrier port +rejects rather than navigating and losing live state. The application endpoint +has no keeper, including a no-op implementation. + +## Carriers + +A carrier is a connection-internal adapter from native browser communication +resources to the common `send`, `on`, and `close` operations. It owns endpoint +authentication, establishment, delivery mechanics, and its nontransferable +native resources. Each carrier defines the endpoint identities it accepts and +returns an adapter only after authenticating both sides. + +Connection selects and owns the resulting authenticated carrier for the current +popup document. A carrier does not interpret transported values, choose another +carrier, or navigate a document. + +The browser-local [MessagePort carrier](message-port.md) is built in. +An explicitly supplied [WebRTC carrier](webrtc.md) constructor +provides the opener-independent fallback. + +MessagePort is preferred while the popup retains its opener. WebRTC defines the +opener-independent fallback boundary when supplied; its exact signaling-service +contract is outside this specification. + +### Carrier API + +Each carrier module owns construction of its native browser resource and adapts +it to the same connection-internal delivery operations: + +```ts +interface Carrier { + send(value: Message): void + on(handler: (value: unknown) => void): () => void + close(): void +} + +declare const prepareNavigation: unique symbol +declare const onReplacement: unique symbol + +interface NavigationCarrier extends Carrier { + [prepareNavigation](target: string): Promise + [onReplacement](handler: (carrier: Promise) => void): () => void +} +``` + +Local sends satisfy the base `Message` shape without making the carrier +understand the protocol. Received values remain `unknown`: they crossed a +remote browser boundary and become a concrete message only after the connection +selects and runs the registered `MessageType`. + +The adapter does not own navigation policy. Connection retains a transferable +`MessagePort` only when navigation moves ownership; the WebRTC carrier retains +and closes its own peer and channel. + +The two symbol-keyed hooks are package-private RTC lifecycle hooks, not caller +messages or public controls. On the selected application carrier, connection +registers one `onReplacement` handler and retains the authenticated carrier +promise it receives. On the popup carrier, connection calls +`prepareNavigation` with the caller-selected target and navigates only to the +prepared target it resolves. The RTC carrier internally starts and authenticates +the exact next signaling round, adds its private navigation metadata, and +reports the resulting replacement carrier without exposing any of those +mechanics to connection. The fresh popup-side WebRTC factory consumes that +metadata before selection. Each endpoint installs the resolved carrier for the +same logical connection. Any hook, constructor, timeout, metadata, or replacement +failure closes without navigation or caller delivery. + +Only a carrier implementing both exact symbol-keyed functions supports this +path. MessagePort implements neither and uses `PortKeeper`. Callers cannot +invoke, register, or replace either hook. + +### Selection + +The popup endpoint chooses one physical path for each participating document; +the application does not run an independent first-promise-wins race: + +1. The application keeps one document-local MessagePort operation and the + connection-lifetime fallback armed. +2. A popup with a usable opener completes exact source/origin authentication, + validates the transferred port, and echoes the existing private MessagePort + handshake over that port. Only that echo makes MessagePort selectable on the + application endpoint. The fallback remains pending and unused. +3. A popup whose opener is null or reports `closed`, or which receives no + valid response within `OPENER_HANDSHAKE_TIMEOUT_MS = 30_000`, commits its + configured fallback. The authenticated fallback resolving on the application endpoint + confirms that choice and replaces any carrier belonging to the preceding + popup document. An authentication failure is terminal rather than a reason + to downgrade; an unavailable fallback also terminates. + +Selection is atomic. Installing an authenticated carrier closes the obsolete +carrier and document-local operation, while the unused connection-lifetime +fallback remains armed. Stale acknowledgements, carrier completions, signaling, +or values from an earlier popup document are inert. An unexpected failure of +the current document's active carrier never initiates fallback by itself; a +fresh participating document must authenticate its own selection. A controlled +document replacement may install a fresh carrier under the same logical +connection only after its replacement path is prepared. + +`send` always uses the selected authenticated carrier. Only MessagePort is +transferable: connection may preserve its selected native port, while an +established `RTCDataChannel` is never handed across navigation. WebRTC +replacement establishes a fresh authenticated carrier in the destination +through the already-armed exact signaling round. + +## Continuity across navigations + +Carrier continuity means that the logical connection can preserve one active +carrier while the popup replaces one participating document with another. It +does not preserve the old JavaScript heap or an `RTCDataChannel`. A current +carrier which cannot survive a document change is replaced transparently. + +Only a direct `PopupConnection.navigate()` between participating documents +performs managed carrier preservation or replacement. Any navigation outside +that API, including an external document's redirect, loses the current carrier. +Before RTC has been selected, the pre-armed initial fallback may still establish +the first RTC carrier in a later participating document; this is establishment, +not preservation. After RTC is active, an unmanaged navigation cannot prepare +or identify the next signaling round and terminates the logical connection. + +A transferable carrier may preserve its authenticated native resource across +an immediate same-origin participating-document replacement. A cross-origin +replacement, including a cross-site replacement, establishes a fresh carrier. +A nontransferable carrier instead prepares its replacement before navigation +and installs it after the destination authenticates. The caller observes +neither mechanism and cannot recover from continuity loss. + +The [MessagePort carrier](message-port.md) +owns transferable-port preservation, its Service Worker bridge, timing, and +failure rules. The [WebRTC carrier](webrtc.md#private-navigation-preparation) +owns fresh-round preparation for its nontransferable data channel. + +## Document-Isolation-Policy evolution + +[Document-Isolation-Policy (DIP)](https://wicg.github.io/document-isolation-policy/) +can isolate a popup-owned cross-origin iframe without applying COOP/COEP to its +whole frame chain. With interoperable support, a non-isolated popup document +could retain its ordinary opener connection while isolated work runs in that +iframe. That would remove the top-level document replacement and its +connection-continuity machinery. It would not protect against an external page +which itself severs the opener with COOP, so the opener-independent carrier +remains a separate fallback. + +DIP support is tracked by the [Chrome documentation](https://developer.chrome.com/blog/document-isolation-policy), +[Mozilla standards position](https://github.com/mozilla/standards-positions/issues/1074), +[Firefox implementation](https://bugzilla.mozilla.org/show_bug.cgi?id=2063367), +and [WebKit standards position](https://github.com/WebKit/standards-positions/issues/399). + +Reconsider the popup topology only after Gecko and WebKit ship compatible +behavior and real-device tests confirm cross-origin isolation, shared-memory +WASM threads, cross-origin iframe messaging, asset policy, and mobile lifecycle +behavior. Without that qualification, top-level isolated work uses connection +continuity. + +## Versioning + +The package supplies one `ConnectionVersion` to both endpoints; there is no +runtime negotiation. Compatible implementation changes keep the version. +Breaking private authentication, carrier, signaling, framing, or continuity +controls increment it independently of every caller protocol. diff --git a/ts/packages/popup/docs/control.md b/ts/packages/popup/docs/control.md new file mode 100644 index 00000000..89e5981c --- /dev/null +++ b/ts/packages/popup/docs/control.md @@ -0,0 +1,110 @@ +# Popup control + +This document defines popup navigation and the control protocol by which an +application asks its connected popup to navigate or close itself. +It is independent of caller protocols: navigation and popup lifetime are +composition decisions, not protocol results. + +## Rationale + +A popup may become a top-level cross-origin-isolated document. Its COOP policy +may sever the application's `WindowProxy`, so later +`popup.location.replace(...)` and `popup.close()` calls are not reliable. +The isolated popup can still navigate or close itself, and its selected +connection remains usable across the browsing-context-group split. A connected +navigation therefore carries the application decision over that connection and +lets the popup prepare continuity before replacing itself, even while its old +`WindowProxy` still appears usable. Close control covers the later case where +that handle is absent or reports closed. This requires no app-origin return or +second popup. + +## Records + +```ts +interface Navigate { + type: 'navigate' + url: string +} + +interface ClosePopup { + type: 'close-popup' +} + +type PopupControl = Navigate | ClosePopup +``` + +These discriminators are connection-reserved. They cannot appear in a +composition-owned message union or caller registration. + +Each discriminator is its own compatibility boundary. An incompatible shape or +semantic change introduces a new message and decoder rather than a shared +protocol-version field. Unknown controls fail closed. + +Both records are application-to-popup only. The receiver exact-validates a +plain record, discriminator, and field set before acting. They travel only over +an already version-authenticated carrier. A navigation +URL must equal the serialization of an absolute HTTPS URL with no credentials. +Relative, non-HTTPS, malformed, and noncanonical URLs fail closed. Connection +owns the generic message bound. + +## Execution + +`PopupConnection.navigate(url)` is available on both endpoints. +`PopupConnection.close()` is the application-facing lifetime operation. + +While native-anchor binding is pending, `navigate` performs no browser +operation and leaves that same activation's default navigation intact. With an +active carrier, the application endpoint sends `Navigate`; without one, it uses +the exact retained `WindowProxy` only while the handle is non-null and not +closed. A popup endpoint calling `navigate` acts locally and sends no +`Navigate`. In either popup-side path, the endpoint stops accepting controls, +preserves a same-origin transferable carrier, releases it for cross-origin +rebind including across sites, or prepares a nontransferable replacement as +required, then calls `location.replace(url)`. Replacement avoids adding the +current document to popup history and creates no browsing context. Failure to +preserve or prepare a carrier rejects before navigation. A cross-origin rebind +may fail only after the destination loads; that failure releases no caller +value and selects no weaker carrier. The caller commits its state and clears +sensitive inputs before requesting navigation. + +When the exact retained `WindowProxy` is non-null and not closed, `close` calls +it directly, regardless of carrier state. Otherwise it sends `ClosePopup` over +an active carrier. It then closes the logical connection and releases its local +resources. The receiving popup stops accepting controls and calls +`window.close()` on itself. The composition MUST expose and invoke +this operation only for a separate top-level traversable created by +`window.open()` or an activated link. Both creation paths produce a +script-closable traversable, including when the browser presents it as a tab, +and that property survives provider navigation and a COOP browsing-context +group switch. A same-tab or full-page presentation MUST NOT send +`ClosePopup`. This is a window-creation invariant; there is no reliable post-COOP +runtime probe for script closability. + +The first accepted popup control is terminal and one-shot for its receiving +document. `Navigate` may continue the logical connection in the destination; +`ClosePopup` terminates it. A duplicate, replay, race loser, unknown control, +wrong-direction record, or record on another connection performs no browser +operation. + +Neither control message has a remote acknowledgement. A pending native-anchor +call resolves after the connection accepts that the same activation owns the +navigation; it does not claim that navigation was observed. Navigation can destroy +the receiver and COOP prevents the application from reliably observing either +browser action. Each promise therefore means only that the direct browser +operation was invoked or the local connection accepted its control for ordered +delivery. The composition must commit the authoritative successor state before +acting and treat connection loss or an unavailable popup as neither success, +cancellation, nor proof of delivery. + +## Security boundary + +- Only the application endpoint of the selected connection can send a control; + cookies, URLs, storage, opener state, and caller payload fields cannot + authorize one. +- The connection binding prevents one concurrent operation from + controlling another popup. +- Popup control carries no caller credential, result, or durable state. +- A caller-protocol value never selects a destination. Only the independently + decoded `Navigate` control can do so. +- Failure cleanup remains resource-only and never synthesizes `ClosePopup` + or calls `window.close()`; only an explicit application-side `close()` does. diff --git a/ts/packages/popup/docs/message-port.md b/ts/packages/popup/docs/message-port.md new file mode 100644 index 00000000..0f2463eb --- /dev/null +++ b/ts/packages/popup/docs/message-port.md @@ -0,0 +1,305 @@ +# MessagePort carrier + +This document defines the preferred browser-local carrier used by the +[popup connection](connection.md) while the returned popup retains its +application opener. + +`MessagePort` is the simplest carrier for this job. One `window.postMessage` +exchange authenticates browser-stamped source and origin and transfers one end +of a `MessageChannel`; the entangled ports then provide ordered structured-clone +delivery without framing, signaling, or a server. These are standard browser +primitives defined by the HTML Standard's [cross-document messaging](https://html.spec.whatwg.org/multipage/web-messaging.html#crossDocumentMessages) +and [message-channel](https://html.spec.whatwg.org/multipage/web-messaging.html#message-channels) +sections. + +Its limitation is establishment under isolation. `window.postMessage` requires +a live `WindowProxy`, while Cross-Origin-Opener-Policy can cause a +[browsing-context-group switch](https://html.spec.whatwg.org/multipage/browsers.html#coop-bcg-switch) +which severs the opener relationship. If that happens before a returned popup +document binds its channel, this carrier is unavailable for that document. Each +participating popup document either claims a preserved port or establishes a +fresh one. Connection retains a usable port for as long as possible and may +preserve it across repeated participating-document replacements. If the port +cannot continue across a document change, connection transparently establishes +another carrier or fails closed. + +The controls below are package-private carrier mechanics, not caller messages, +public APIs, extension points, or durable state. + +## Boundary + +The carrier begins with connection-supplied browser handles, expected origins, +connection version, the already-validated +[connection ID](connection.md#connection-id), and internal cancellation signal. +It ends with one authenticated local `MessagePort` at each endpoint. + +Within that boundary it owns: + +- validation of browser-stamped `MessageEvent.source` and + `MessageEvent.origin`; +- one `MessageChannel`, one transfer of its popup endpoint, and the lifetime + of both local endpoints; +- preservation of an authenticated port across immediate participating-document + replacement; and +- adaptation of an accepted port to the connection's typed delivery API. + +The connection owns carrier selection, the decision to navigate, and the +logical connection. The caller owns every transported value and its meaning. +The carrier neither interprets those values nor persists or recovers them. + +## Failure and security invariants + +- An event is a handshake attempt only when its data is a plain record whose + `type` is `message-port` and whose `connectionId` equals this connection's. + Every other event is ignored, including a valid handshake for another + connection ID and unrelated traffic from the bound `WindowProxy` while it + shows a non-participating document, so concurrent connections never reject + each other and a provider page cannot terminate the connection. An attempt + must then exact-match its browser-stamped origin, source (the bound + `WindowProxy` after binding), record shape, direction, and connection + version. The popup additionally requires exactly one transferred port. +- The wildcard-targeted request contains no capability or application-level + value. The response targets the request's exact browser-stamped allowed popup + origin, transfers only the new popup endpoint, and never exposes the + application's retained endpoint. After + validating that response, the popup echoes the same handshake record over the + transferred port; the application does not select the port before that echo. +- Any handshake attempt which fails those checks rejects the binding and closes + every reachable port. The application's window listener lives for the + connection; an accepted handshake discards only that attempt's state, and a + later handshake from the bound source starts a new attempt. The popup removes + its own window listener after acceptance; later window traffic there is + inert. +- After binding, possession of the entangled port authenticates the peer. + Application-level values travel only over that port; the carrier preserves + their order and shape without interpreting them. +- Abort, timeout, `messageerror`, port closure, or browser-context destruction + closes reachable resources and releases no later value. There is no + reconnect, resend, or recovery inside this carrier. +- Port loss may be silent. It is a connection failure, never delivery, success, + denial, cancellation, or any other caller outcome. + +## Authentication + +Both directions use one carrier-local record: + +```ts +interface MessagePortHandshake { + type: 'message-port' + connectionVersion: ConnectionVersion + connectionId: string +} +``` + +The returned popup sends the record with its connection ID and no transferable. +The ID is public correlation, not a capability or caller-level value. The +application accepts it only from its retained popup source, or binds the +browser-stamped source once in the native-anchor fallback, and only when the +browser-stamped origin is in its immutable allowed popup-origin set and the +connection version and connection ID exact-match. + +The application creates one `MessageChannel`, retains one endpoint, and sends +the same record back with the live connection ID and the other endpoint as its +only transferable. The popup accepts it only from its exact opener and a +browser-stamped origin in its immutable allowed-origin set, and only when the +connection version and connection ID match its current binding. It rejects a +missing or additional port. + +After accepting the response, the popup starts the transferred port and sends +the same `MessagePortHandshake` record over it as the final establishment +acknowledgement. The application exact-checks that record on its retained port +before resolving its pending operation. This reuses the carrier-local handshake +shape; it is not a caller message or an additional protocol control. A missing, +malformed, duplicate, or mismatched acknowledgement closes both reachable +endpoints and selects no carrier. + +The request may use an unrestricted target origin because it contains no +capability or application value. The response targets the exact observed popup +origin after admission. +Application-level delivery starts only after the final acknowledgement; its +position on the ordered port keeps every later caller value behind it. + +## Message delivery + +`PortCarrier` passes each logical value directly to +`MessagePort.postMessage`. Native structured clone preserves arrays, plain +records, and `Uint8Array`; this carrier adds no JSON encoding, byte tag, +normalization, or additional copy. Received `MessageEvent.data` remains +`unknown` until the connection selects and applies its registered `MessageType`. +A successful decode returns that same received object rather than allocating a +replacement. A `DataCloneError` or `messageerror` closes the carrier. A failed +decode makes the connection close it. None releases a value. + +## Continuity across navigations + +Replacing a popup document normally destroys its side of the communication +channel together with its JavaScript heap. A live `MessagePort` owned only by +that document becomes unreachable, while the destination document does not yet +exist and cannot receive it directly. The carrier gives the port a temporary +same-origin Service Worker owner across that gap: + +```text +source document Service Worker destination document + | | | + |--- keep(port) --------->| | + |<-- ownership accepted --| | + |--- navigate --------------------------------------->| + | |<-------- claim(port) ------| + | |-------- port ------------->| +``` + +The source navigates only after the worker acknowledges ownership. The +destination claims before loading caller code or using the network. This +preserves the already authenticated port without repeating its handshake. +`PortKeeper` never receives an RTC resource, substitute carrier, or caller +value. + +This path is strictly same-origin. The host must serve source and destination +under the same active Service Worker registration and scope when it expects +preservation. Connection can check only the target origin before navigation; if +a same-origin destination resolves to another registration, it claims no port +and performs a fresh handshake through its opener or fallback. Before +cross-origin navigation, including navigation to another site, connection does +not call `keep`; it releases the old popup endpoint and the allowed destination +likewise establishes a fresh carrier. + +This is a short in-memory continuity bridge, not persistence or recovery. +Worker loss breaks continuity; no later document can reconstruct or resume the +channel. + +### Timing assumptions and browser limits + +For every participating-document replacement, the bridge must complete within +its bounded interval. The same logical connection may use it repeatedly. After +the worker acknowledges preservation, the source starts navigation immediately +and the destination calls `PopupConnection.accept` before any other network +use; the claim is its first step, so the deadline includes document load and +package import. It never holds a port while an unrelated document, user +interaction, or intentional background wait owns the popup; a later +participating document establishes a replacement carrier. + +The `keep` handler uses `event.waitUntil()` to keep its message event active +until the port is claimed or its short deadline expires. Its acknowledgement +therefore confirms worker ownership but does not end the event. `claim` +atomically transfers the port and settles that event. The Service Worker +lifetime model remains event-based: registrations persist, but a worker heap +may be terminated when no event is pending or under abnormal resource pressure. + +There is no portable browser-specific minimum lifetime. The PoC observed: + +| Browser engine | Empirical hold result | +|---|---| +| Chromium | More than 60 seconds; the upper boundary was not found. | +| Firefox | Approximately 60 seconds. | +| Playwright WebKit | Seven seconds succeeded and eight seconds lost the port. | + +These are observations, not guaranteed browser contracts. The carrier uses one +conservative `CARRIER_CLAIM_TIMEOUT_MS = 5_000` across engines, below the +observed WebKit boundary. It does not sniff the user agent or select a +browser-specific deadline. Suspension, process loss, memory pressure, or expiry +may still break continuity; failure is terminal and never selects a weaker +path. + +### Internal PortKeeper API + +`PortKeeper` is the carrier's package-private continuity component. It +encapsulates Service Worker communication, temporary ownership, event lifetime, +the claim deadline, one-use transfer, and cleanup. It neither reads the port nor +knows what it carries. + +The worker itself is host-owned. The host is the deployment serving the popup +documents. A Service Worker script must be served from that origin, so the host +registers one for its popup documents and calls `installPortKeeper()` from +that script. The handler is exported from the `@libid/popup/worker` subpath +only, so worker-global types never enter the main package declaration. The +package registers nothing and `PopupWindow.current()` resolves the active +registration whose scope matches the current document; control of the +document is not required to message that worker. + +```ts +// @libid/popup/worker +declare function installPortKeeper(): void + +declare class PortKeeper { + constructor( + registration: ServiceWorkerRegistration, + connectionVersion: ConnectionVersion, + ) + + keep(connectionId: string, port: MessagePort): Promise + claim(connectionId: string): Promise +} +``` + +The constructor fixes the active registration and connection version for both +operations. `keep` resolves only after the worker owns the exact port, after +which connection may replace the source document. `claim` atomically returns +and removes the unchanged port, or returns `null` when no entry exists. `null` +authenticates and selects nothing; popup construction continues with its +available browser resources. A returned port is the selected carrier endpoint. + +The two acknowledged calls are necessary because the worker must own the port +before the source document destroys itself and the destination document does +not yet exist. The Service Worker record and control-message encoding are +implementation details. Connection version, connection ID, transferable +count, duplicate ownership, and one-use claim are checked before ownership +changes. A malformed, mismatched, or duplicate record rejects and closes every +reachable port. Expiry deletes the entry and closes its port; an expired or +already-claimed entry is absent and yields `null`, the worker keeps no record +of it. Worker loss or a failed `keep` acknowledgement prevents navigation with +live state. No `BroadcastChannel`, cookie, IndexedDB record, request, or URL +carries the port. + +## API + +The application starts listening before the popup endpoint is ready: + +```ts +declare function listenForPopupPorts( + options: { + popupWindow: PopupWindow + allowedPopupOrigins: readonly string[] + connectionVersion: ConnectionVersion + connectionId: string + signal: AbortSignal + }, + onPort: (port: MessagePort) => void, +): () => void + +declare function connectPopupMessagePort(options: { + opener: WindowProxy + allowedApplicationOrigins: readonly string[] + connectionVersion: ConnectionVersion + connectionId: string + signal: AbortSignal +}): Promise + +declare class PortCarrier implements Carrier { + constructor(port: MessagePort) + /** Hands the port to `PortKeeper.keep` and closes this carrier. */ + detach(): MessagePort +} +``` + +`listenForPopupPorts` installs one window listener synchronously for the +connection lifetime and sends nothing. When `PopupWindow` retained a handle it +requires that exact source; otherwise it binds `PopupWindow` to the source of +the first handshake whose observed origin is allowed and which exact-matches +the connection version and connection ID, and requires that source afterwards. +Each accepted handshake creates one channel, responds with the popup endpoint, +awaits the echo, and then reports the retained endpoint through `onPort`; the +connection installs it and closes the previous carrier. A newer accepted handshake +supersedes one still awaiting its echo. This replacement is private connection +machinery; callers continue using the same `PopupConnection`. + +When the popup endpoint is ready, it calls and awaits +`connectPopupMessagePort`. It sends the handshake request; the listening +application validates it, creates the channel, and sends the response with the +popup endpoint. The popup validates that response, sends the same handshake +record over the transferred port, and resolves with that endpoint. A handshake +attempt from the opener that fails authentication rejects immediately; abort +and the `OPENER_HANDSHAKE_TIMEOUT_MS` deadline also reject. Every rejection +removes the window listener and closes every reachable port. The concrete +error type is private. `PortCarrier` starts the port, forwards unchanged +structured-clone values, closes idempotently, and `detach` surrenders the +port for preservation. diff --git a/ts/packages/popup/docs/webrtc.md b/ts/packages/popup/docs/webrtc.md new file mode 100644 index 00000000..dbc88bc3 --- /dev/null +++ b/ts/packages/popup/docs/webrtc.md @@ -0,0 +1,409 @@ +# WebRTC carrier + +This document defines the WebRTC fallback carrier for the +[popup connection](connection.md) when response policy severs popup opener +authentication. It owns peer establishment and logical-value delivery over one +`RTCDataChannel`. + +MessagePort is preferred while the popup retains its opener. WebRTC covers the +case where external response isolation requires an opener-independent path. +The signaling service's exact routes, records, bounds, and implementation are +outside this specification; this document fixes only the connection boundary +and the security properties that contract must preserve. + +## Why this carrier exists + +The browser-local [MessagePort carrier](message-port.md) is simpler, +but cannot establish after response isolation severs the popup's opener. +This follows the [HTML COOP model](https://html.spec.whatwg.org/multipage/browsers.html#cross-origin-opener-policies) +and its unresolved [cross-group opener-messaging limitation](https://github.com/whatwg/html/issues/6364). +No known alternative to WebRTC satisfies the connection constraints after that +severance: cross-site operation, current-engine support, mobile suspension, no +application-origin endpoint, no additional window, and direct browser-local +application messages. WebRTC is therefore the available opener-independent +fallback. Its signaling service establishes the peers but never relays +application-level messages. A deployment without TURN accepts that direct ICE +can fail on restrictive networks. + +## Topology + +The application page and each participating top-level popup document are the +only RTC peers. A participating popup selected for WebRTC creates its peer and, +before replacing itself with another participating popup document, uses the +private rearm procedure below. No iframe, worker, or signaling service +terminates the `RTCDataChannel`. + +Every participating popup document must use an origin accepted by the signaling +service. Unrelated external navigations are non-participating: they create no +peer and preserve no current carrier. A later participating document may select +the still-unused initial fallback as its first RTC carrier. Once RTC has been +selected, an unmanaged navigation has no prepared next round and terminates the +logical connection. + +```text +Application connection Signaling service Destination popup connection + |<--- SDP / ICE --------->|<--- SDP / ICE --------->| + |<========== direct RTCDataChannel =================>| + + ---- signaling through service + ==== direct browser-to-browser application data +``` + +The application connection starts one bounded, one-use signaling subscription +before its first popup navigation and retains it while MessagePort serves any +number of participating popup documents. This creates no peer connection, SDP, +ICE candidate, or transported-value record. MessagePort selection does not +close the subscription. When a popup document later commits RTC, it creates the +fresh offer and the application creates the matching answer. Consumption, +signaling failure, or logical connection closure ends that standby round; an +active RTC carrier prepares each later round through the private rearm procedure. + +## Signaling service + +The signaling service is a bounded rendezvous, not a carrier. For each round, +the application subscribes as answerer and the destination popup publishes as +offerer under the same valid +[connection ID](connection.md#connection-id) and exact signaling round. The ID +is randomized rendezvous correlation combined with the authenticated endpoint +origin and role; neither the ID nor round alone grants authority. Round is a +non-secret unsigned 32-bit monotonic stale-signal discriminator: the initial +round is zero and each replacement uses exactly the previous round plus one. +The browser-stamped `Origin` authenticates which allowed browser role may +present the tuple but is not a general client credential. The service +exact-checks the application and popup origins. Signaled DTLS fingerprints bind +the resulting channel to that round's descriptions but do not independently +authenticate the signaling service. No second nonce is added. + +The signaling contract accepts only: + +- one application subscription from an allowed application origin; +- one popup offer from an allowed popup origin, bound as the exact origin of + that round; +- one fresh application answer; +- bounded trickled ICE candidate updates from the bound roles; and +- terminal connected, failed, or abandoned cleanup. + +The live subscription and every offer, answer, candidate, and cleanup record +exact-match the caller-supplied connection version, connection ID, round, and +role. State is transient and is deleted when the channel opens, either side +fails, the logical connection closes, or the round is abandoned. MessagePort +selection leaves an unused round armed. Only after a round is consumed or +deleted may the same live logical connection start its next round. At most one +round for a connection ID may be live. A delayed record from an earlier round +cannot match or create state for the current round. Signaling records are +consumed once and never enter signaling URLs, logs, analytics, or durable +storage. + +The signaling service handles independent one-shot rounds. It does not retain a +subscription for the logical connection, detect popup navigation, or understand +carrier continuity. Consecutive rounds may authenticate different popup origins +from the application's immutable allowed set; every round remains bound to the +one exact browser-stamped popup origin that created its offer. + +### Private navigation preparation + +When popup-side navigation would destroy an active RTC carrier, the popup sends +package-private `PrepareNavigation` through its `prepareNavigation` lifecycle +hook with the caller-selected target. The application carrier internally +increments the round, starts a fresh one-use answerer subscription, reports its +pending authenticated carrier through `onReplacement`, and replies with +package-private `NavigationReady` carrying the exact next round only after the +subscription is armed. The popup carrier adds that round to its reserved private +fragment field and resolves the prepared target. + +The reserved raw fragment field is: + +```text +__libid_popup=rtc1.. +``` + +`round` is canonical unsigned 32-bit decimal: zero is `0`, and every other value +has no leading zero. `had-fragment` is exactly `0` when the caller target had no +`#` delimiter and `1` when it did. The field name and value are literal ASCII +and are never percent-encoded. They carry no connection ID, capability, caller +value, or destination. + +The carrier treats the caller fragment as an opaque serialized prefix rather +than parsing or reserializing it. It appends the reserved field as the final raw +fragment component, adding `#` when the target had no fragment delimiter and +`&` when it had a nonempty fragment. An empty existing fragment needs no +separator. A caller target whose fragment already contains a raw component +named `__libid_popup`, in any position, rejects before navigation. + +The destination constructs its popup-side WebRTC fallback factory before +calling `PopupConnection.accept`. That factory synchronously finds exactly one +final canonical reserved field, copies its round, and uses +`history.replaceState` to remove only the package-added delimiter and field. +The `had-fragment` bit therefore restores the caller URL exactly, including +absence or presence of an empty fragment; a nonempty caller fragment retains +its byte spelling, order, duplicates, escapes, and separators. The factory +returns a `CarrierConstructor` which retains the copied round and starts the +fresh offer only if fallback is later selected. Thus MessagePort may win +without leaving package metadata in the address bar or starting RTC. + +No reserved field means initial round zero. A reserved name that is duplicate, +misplaced, malformed, noncanonical, or inconsistent with the serialized prefix +is cleared with the fragment and rejects synchronously before carrier selection, +signaling, or caller delivery. Failure, timeout, a stale round, or unsigned +32-bit overflow closes the logical connection without navigating. + +These controls exist only in the WebRTC carrier. They are consumed before the +generic carrier value stream and never enter `PopupControl`, the caller message +union, or another carrier. A MessagePort which can be transferred across an +immediate participating-document replacement uses `PortKeeper` instead and +emits neither control. + +The symbol-keyed hooks are the only RTC lifecycle extension to the common +carrier. The popup hook resolves with a target decorated only by the RTC +carrier's private metadata. The application hook reports only the pending +authenticated replacement carrier. Connection retains that promise and later +installs its result; it never observes a signaling round. The RTC module does +not navigate or select a route. + +An honest service is not on the data path and cannot read DTLS-protected framed +values. A compromised service can replace exchanged fingerprints and +man-in-the-middle the channel, but the signaling service belongs to the same +configured server trust boundary that supplies the popup program. +It therefore adds no independent signature or trust system. + +The signaling service is selected over the available establishment mechanisms: + +| Mechanism | Tradeoff | +| --- | --- | +| Signaling service | Works cross-site, is event-driven, and needs no application-origin endpoint or additional iframe. It carries only bounded SDP and ICE metadata; application messages remain browser-local. | +| Cookie or storage polling | Requires an additional popup-server iframe under the application and works only when application and popup server are same-site. Browser throttling made PoC signaling take more than one second, comparable to a service round trip, while still not covering cross-site deployments. | +| `BroadcastChannel` or shared worker | Cannot reliably cross the origin and storage-partition boundary between application and popup server. | +| Application endpoint or frontend-origin rendezvous page | Can rendezvous the peers, but adds application-specific server or hosting integration that the deployment model excludes. | +| TURN | Solves peer reachability rather than signaling. It relays encrypted DTLS/SCTP packets, placing a service on every packet path and violating the direct browser-local connection constraint, but does not terminate the data channel or read its plaintext. | + +The selected path supports cross-site peers without turning the signaling +service into an application-message relay. With STUN only, inability to form a +direct ICE path fails the connection rather than selecting another carrier. + +## API + +Signaling is private carrier machinery. Both endpoints receive the connection +ID as a connection-construction input before any caller message exists; neither +finds it by inspecting a transported value. The application starts connecting +before popup navigation, and the popup endpoint connects only after connection +commits RTC fallback: + +```ts +interface ApplicationWebRTCOptions { + signalingServiceUrl: string + stunUrls: readonly string[] + connectionId: string + allowedPopupOrigins: readonly string[] + signal: AbortSignal +} + +interface PopupWebRTCOptions { + signalingServiceUrl: string + stunUrls: readonly string[] + connectionId: string + allowedApplicationOrigins: readonly string[] + signal: AbortSignal +} + +type PopupWebRTCFallbackOptions = Omit + +declare function connectApplicationWebRTC( + options: ApplicationWebRTCOptions, +): Promise + +declare function connectPopupWebRTC( + options: PopupWebRTCOptions, +): Promise + +declare function createPopupWebRTCFallback( + options: PopupWebRTCFallbackOptions, +): CarrierConstructor +``` + +Each endpoint closes over its WebRTC options in the optional constructor passed +to `PopupConnection`: + +```ts +fallback: signal => connectApplicationWebRTC({ + ...webRTCOptions, + connectionId, + allowedPopupOrigins, + signal, +}) +``` + +The popup calls `createPopupWebRTCFallback` with its immutable allowed +application origins before `PopupConnection.accept` and supplies the returned +constructor as `fallback`. The factory performs only the eager fragment +bootstrap described above; it starts no signaling or peer work. +`PopupConnection.connect` invokes `connectApplicationWebRTC` exactly once for +the logical connection. That call synchronously starts bounded answerer round +zero and returns its pending carrier promise; no later fallback invocation can +restart round zero. It creates the answering peer only after a valid fresh +offer arrives. +`connectPopupWebRTC` creates the offering peer and data channel, publishes its +offer and candidates, and consumes the answer and remote candidates. Each +function resolves with an authenticated carrier only after its local channel +opens. +Internally they retain the peer connection and channel and own every signaling +request, trickled candidate update, origin-bound role, timeout, codec, framing, +pressure, and cleanup operation. + +The application connection observes the initial promise without awaiting it +during popup navigation, so an early failure produces no unhandled rejection. +MessagePort selection leaves that promise pending under the logical +connection's abort signal. During controlled replacement, the active application +RTC carrier internally starts the exact next round while the old channel remains +usable and reports only its pending authenticated carrier through the +package-private replacement hook. Each popup fallback creates a new physical +peer; the application creates a new peer and answer rather than reusing an +earlier description. Under RTC selection each endpoint otherwise exposes only +the common carrier API. +Abort, logical connection closure, or establishment failure closes every +reachable signaling, peer, and channel resource and releases no transported +value. No signaling API or native RTC resource is exposed to caller protocols +or package consumers. + +## Peer establishment + +The destination popup creates one ordered reliable `RTCDataChannel` and offers; +the application answers. Neither peer sets `maxPacketLifeTime` nor +`maxRetransmits`. + +Both use trickle ICE with deployment-configured STUN servers. They send each +local description as soon as it exists and forward candidates as discovered. +Host and mDNS candidates may connect immediately; server-reflexive candidates +provide a mobile path without depending on mDNS resolution or local-network +permission. ICE completion is diagnostic because the channel may open earlier. +The deployment configures no TURN server. Networks whose NAT or firewall prevents a +direct path fail closed; this is an explicit availability tradeoff, not a +connection downgrade or recovery signal. + +The selected SDP and DTLS fingerprints are immutable for one signaling round. +Duplicate signaling records are idempotent; candidate records append only exact +new candidates. Changed descriptions, roles, fingerprints, or accepted +candidates fail. Signaling state is deleted when the channel opens, either side +fails, the logical connection closes, or the round is abandoned. An unused +application answerer round remains armed while MessagePort is active. + +## Message delivery + +[`RTCDataChannel.send`](https://www.w3.org/TR/webrtc/#dom-rtcdatachannel-send) +accepts strings and binary buffers, not structured-clone objects. This carrier +therefore owns one dependency-free wire codec in addition to bounded framing, +reassembly, and send-buffer pressure. Its logical value domain is `null`, +booleans, finite numbers other than negative zero, strings, dense arrays, plain +records with enumerable own string-keyed data properties, and `Uint8Array`. +It rejects `undefined`, `bigint`, nonfinite numbers, negative zero, sparse +arrays or arrays with non-index properties, symbol or nonenumerable keys, +accessors, functions, cycles, class instances, `Date`, `Map`, `Set`, raw +`ArrayBuffer`, and other platform objects before sending. + +The codec recursively replaces each `Uint8Array` with the exact JSON object +`{"$bytes":""}`. `$bytes` is reserved and cannot be an +ordinary record key. It then uses `JSON.stringify` and `TextEncoder` to produce +UTF-8 bytes. JSON is selected because it is built into every target browser and +needs no package; CBOR or MessagePack would reduce byte expansion but add a +dependency or custom codec without changing the protocol boundary. The JSON +encoding is not canonical and is never hashed, signed, or otherwise treated as +proof bytes; only each byte tag's base64url spelling is canonical. + +One framed item is sent completely before the next. The ordered reliable channel +uses two value frame forms and two package-private navigation controls: + +```text +first: 0x01 | uint32be totalLength | payload +continuation: 0x00 | payload +prepare-nav: 0x02 +nav-ready: 0x03 | uint32be round +``` + +The first frame may complete the message. Otherwise continuation payloads append +until exactly `totalLength` bytes have arrived. The carrier fragments below the +retained peer connection's `RTCSctpTransport.maxMessageSize` and its own bounded +chunk cap, and pauses its bounded send queue using `bufferedAmount` and +`bufferedamountlow`. Private navigation controls are valid only between complete +values, in their defined direction, and once per replacement. Ordering and the +noninterleaving send queue remove the need for message IDs, chunk indexes, +acknowledgements, or a checksum. + +On receipt the channel uses `binaryType = 'arraybuffer'`. The carrier exact-checks +the frame sequence and total length, decodes UTF-8 fatally, parses JSON, restores +exact canonical byte tags to `Uint8Array`, validates the generic value domain, +and gives the resulting `unknown` to the connection. The connection then selects +and calls its registered `MessageType`. The carrier recognizes only its two +private frame tags; it never reads a caller message discriminator. + +An unexpected start or continuation, incomplete or excess body, oversized +message, invalid UTF-8 or JSON, malformed or noncanonical byte tag, unsupported +value, decode failure, or send-buffer overflow closes the carrier before any +value reaches a handler. The connection functions return the carrier and retain +the native peer and channel privately. A physical carrier never reconnects, +switches, or resends. Only a controlled navigation may replace it under the +logical connection; unexpected carrier failure closes the connection. + +## Failure and security invariants + +- Signaling carries no transported value. +- Both signaling roles require an allowed browser-stamped `Origin`. Each round + binds one exact application origin and one exact popup origin from their + respective immutable admission sets; a set match is not accepted as a + standalone client credential. +- The caller-generated, canonical UUIDv4 connection ID is randomized logical + rendezvous correlation, not a standalone capability. It is exact-matched with + the authenticated endpoint origin and role and the bounded monotonic round + for every signaling record, then retired when the logical connection closes. + Round zero is initial; each replacement uses exactly the previous round plus + one. Neither the connection ID nor round grants authority alone. +- A private navigation control cannot reach caller code or another carrier, and + cannot navigate until the application has armed the exact next round. The + destination's popup WebRTC factory clears its package-owned round fragment + before carrier selection, signaling, or caller delivery. Stale, duplicate, + misplaced, malformed, noncanonical, or overflowing rounds fail. +- `RTCDataChannel` message encoding and framing are bounded and cannot add a + value outside its authenticated connection. +- Signaling loss, ICE failure, channel loss, popup closure, or context + destruction is never a caller result or recovery signal. +- Observable failure clears reachable inputs without selecting another carrier; + failure may otherwise be silent. + +## Connection establishment + +These diagrams show initial RTC fallback and controlled RTC replacement. They do +not define signaling-service wire records or show transported values. + +```mermaid +sequenceDiagram + participant A as Application connection + participant G as Signaling service + participant P as Popup connection + + A->>G: Arm subscription (connectionId, round 0) + P->>G: Offer + ICE (connectionId, round 0) + G-->>A: Deliver offer and candidates + A->>G: Answer + trickled ICE candidates + G-->>P: Deliver answer and candidates + P-->>A: Ordered reliable RTCDataChannel opens + Note over A,G: Delete signaling state +``` + +```mermaid +sequenceDiagram + participant A as Application connection + participant G as Signaling service + participant P as Popup connection + participant N as Next popup document + + P-->>A: Private PrepareNavigation over RTC at round N + A->>G: Arm subscription (connectionId, round N+1) + A-->>P: Private NavigationReady(round N+1) + P->>N: Navigate with round N+1 fragment + N->>N: Factory copies round and exactly restores caller fragment + N->>N: PopupConnection.accept selects fallback + N->>G: Fresh offer + ICE (connectionId, round N+1) + G-->>A: Deliver offer and candidates + A->>G: Fresh answer + trickled ICE candidates + G-->>N: Deliver answer and candidates + N-->>A: New ordered reliable RTCDataChannel opens + Note over A,G: Delete signaling state +``` diff --git a/ts/packages/popup/src/connection/README.md b/ts/packages/popup/src/connection/README.md new file mode 100644 index 00000000..f554409b --- /dev/null +++ b/ts/packages/popup/src/connection/README.md @@ -0,0 +1,14 @@ +# Popup connection + +See the [popup connection architecture and specification](../../docs/connection.md) +and [popup control specification](../../docs/control.md). + +Suggested implementation shape: + +```text +index.ts Internal module boundary. +connection.ts Logical connection, carrier selection, and lifecycle. +``` + +This split is non-normative; change it if implementation reveals a cleaner +boundary. diff --git a/ts/packages/popup/src/message-port/README.md b/ts/packages/popup/src/message-port/README.md new file mode 100644 index 00000000..ed64c84a --- /dev/null +++ b/ts/packages/popup/src/message-port/README.md @@ -0,0 +1,14 @@ +# MessagePort carrier + +See the [MessagePort carrier architecture and specification](../../docs/message-port.md). + +Suggested implementation shape: + +```text +index.ts MessagePort authentication and carrier adapter. +keeper.ts Short-lived port preservation across navigation. +worker.ts Service Worker entry point for port transfer. +``` + +This split is non-normative; change it if implementation reveals a cleaner +boundary. diff --git a/ts/packages/popup/src/webrtc/README.md b/ts/packages/popup/src/webrtc/README.md new file mode 100644 index 00000000..d0366c71 --- /dev/null +++ b/ts/packages/popup/src/webrtc/README.md @@ -0,0 +1,14 @@ +# WebRTC carrier + +See the [WebRTC carrier architecture and specification](../../docs/webrtc.md). + +Suggested implementation shape: + +```text +index.ts WebRTC carrier construction and peer lifecycle. +signaling.ts Bounded signaling rounds and navigation rearm. +codec.ts Data-channel framing and value encoding. +``` + +This split is non-normative; change it if implementation reveals a cleaner +boundary. From ac1f684cd0c7db23614a78afa4cddccba07db812 Mon Sep 17 00:00:00 2001 From: Wondertan Date: Wed, 2 Sep 2026 22:25:16 +0100 Subject: [PATCH 02/24] feat(popup): implement the MessagePort connection Add the @libid/popup package through the lifecycle phase of the implementation plan: the wire leaf (message contract, reserved controls, validators), sanitized diagnostics, PopupWindow with the scripted and native-anchor paths, the MessagePort handshake and carrier, the Service Worker port keeper with its `@libid/popup/worker` entry, and the two connection endpoints with carrier selection and popup control. Unit tests run in Node over real MessageChannel ports with in-memory window and worker-scope fakes; they cover the API, window, control, connection, port, keeper, and diagnostic rows of TEST_PLAN.md that need no real browser. A build check keeps `bind` and worker-global types out of the public declarations. Assisted-by: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw Signed-off-by: Wondertan --- ts/packages/popup/build/check-exports.mjs | 22 + ts/packages/popup/package.json | 43 ++ ts/packages/popup/src/connection.test.ts | 485 ++++++++++++++++++++++ ts/packages/popup/src/connection.ts | 441 ++++++++++++++++++++ ts/packages/popup/src/diagnostics.test.ts | 31 ++ ts/packages/popup/src/diagnostics.ts | 70 ++++ ts/packages/popup/src/index.ts | 11 + ts/packages/popup/src/keeper.test.ts | 123 ++++++ ts/packages/popup/src/keeper.ts | 92 ++++ ts/packages/popup/src/message.test.ts | 110 +++++ ts/packages/popup/src/message.ts | 105 +++++ ts/packages/popup/src/port.test.ts | 294 +++++++++++++ ts/packages/popup/src/port.ts | 245 +++++++++++ ts/packages/popup/src/testing/fakes.ts | 148 +++++++ ts/packages/popup/src/window.ts | 98 +++++ ts/packages/popup/src/worker.ts | 92 ++++ ts/packages/popup/tsconfig.build.json | 4 + ts/packages/popup/tsconfig.json | 20 + ts/packages/popup/vitest.config.ts | 8 + ts/pnpm-lock.yaml | 53 +++ 20 files changed, 2495 insertions(+) create mode 100644 ts/packages/popup/build/check-exports.mjs create mode 100644 ts/packages/popup/package.json create mode 100644 ts/packages/popup/src/connection.test.ts create mode 100644 ts/packages/popup/src/connection.ts create mode 100644 ts/packages/popup/src/diagnostics.test.ts create mode 100644 ts/packages/popup/src/diagnostics.ts create mode 100644 ts/packages/popup/src/index.ts create mode 100644 ts/packages/popup/src/keeper.test.ts create mode 100644 ts/packages/popup/src/keeper.ts create mode 100644 ts/packages/popup/src/message.test.ts create mode 100644 ts/packages/popup/src/message.ts create mode 100644 ts/packages/popup/src/port.test.ts create mode 100644 ts/packages/popup/src/port.ts create mode 100644 ts/packages/popup/src/testing/fakes.ts create mode 100644 ts/packages/popup/src/window.ts create mode 100644 ts/packages/popup/src/worker.ts create mode 100644 ts/packages/popup/tsconfig.build.json create mode 100644 ts/packages/popup/tsconfig.json create mode 100644 ts/packages/popup/vitest.config.ts diff --git a/ts/packages/popup/build/check-exports.mjs b/ts/packages/popup/build/check-exports.mjs new file mode 100644 index 00000000..f2730cc5 --- /dev/null +++ b/ts/packages/popup/build/check-exports.mjs @@ -0,0 +1,22 @@ +// The emitted declarations must keep two boundaries (POPUP-API-005 and the +// worker subpath): `bind` never reaches the public PopupWindow declaration, +// and no worker-global type reaches the main entry's declaration graph. + +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const dist = join(dirname(dirname(fileURLToPath(import.meta.url))), 'dist') +const read = (file) => readFileSync(join(dist, file), 'utf8') + +if (/\bbind\s*\(/.test(read('window.d.ts'))) { + throw new Error('PopupWindow declaration must not expose bind') +} +for (const file of ['index.d.ts', 'connection.d.ts', 'window.d.ts', 'port.d.ts', 'keeper.d.ts']) { + if (/ServiceWorkerGlobalScope|ExtendableMessageEvent/.test(read(file))) { + throw new Error(`${file} must not reference worker-global types`) + } +} +if (!/export declare function installPortKeeper\(\): void/.test(read('worker.d.ts'))) { + throw new Error('worker entry must export installPortKeeper()') +} diff --git a/ts/packages/popup/package.json b/ts/packages/popup/package.json new file mode 100644 index 00000000..9414d688 --- /dev/null +++ b/ts/packages/popup/package.json @@ -0,0 +1,43 @@ +{ + "name": "@libid/popup", + "version": "0.0.0", + "private": true, + "description": "Own one popup browsing context and connect it to an application page across origins, isolation, and popup-document replacement, carrying caller-defined messages.", + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "git+https://github.com/libid-org/libid.git", + "directory": "ts/packages/popup" + }, + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./worker": { + "types": "./dist/worker.d.ts", + "default": "./dist/worker.js" + } + }, + "files": [ + "dist", + "src", + "!src/**/*.test.ts" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json && node build/check-exports.mjs", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:e2e": "playwright test", + "typecheck:e2e": "tsc -p tsconfig.e2e.json" + }, + "devDependencies": { + "@playwright/test": "^1.62.1", + "@types/node": "^22.0.0", + "typescript": "^5.9.0", + "vite": "^7.3.6", + "vitest": "^3.2.0" + } +} diff --git a/ts/packages/popup/src/connection.test.ts b/ts/packages/popup/src/connection.test.ts new file mode 100644 index 00000000..edffefad --- /dev/null +++ b/ts/packages/popup/src/connection.test.ts @@ -0,0 +1,485 @@ +import { describe, expect, it, vi } from 'vitest' +import { PopupConnection } from './connection.js' +import type { PopupDiagnostic } from './diagnostics.js' +import type { Carrier, Message } from './message.js' +import { PortCarrier } from './port.js' +import { + APP_ORIGIN, + fakePair, + type FakePair, + fakeScope, + ID, + OTHER_ID, + POPUP_ORIGIN, + registrationWith, + tick, +} from './testing/fakes.js' +import { CurrentWindow, OpenedWindow, PopupWindow } from './window.js' + +class Ready implements Message { + static readonly type = 'ready' + readonly type = Ready.type + constructor(readonly version: number) {} + static decode(value: unknown): Ready { + const v = value as { version?: unknown } + if (typeof v.version !== 'number') throw new Error('bad ready') + return new Ready(v.version) + } +} +class Start implements Message { + static readonly type = 'start' + readonly type = Start.type + static decode(value: unknown): Start { + if ((value as { type: string }).type !== 'start') throw new Error('bad start') + return new Start() + } +} +type Messages = Ready | Start + +const codes = (events: PopupDiagnostic[]) => events.map((e) => e.code) + +interface Side { + events: PopupDiagnostic[] +} + +/** An application endpoint over a fake pair with a scripted or blocked handle. */ +function connectApp(pair: FakePair, opts: { blocked?: boolean; fallback?: Carrier } = {}) { + const side: Side = { events: [] } + const popup = new OpenedWindow( + opts.blocked ? null : (pair.popupProxy as unknown as WindowProxy), + pair.appView, + ) + const connection = PopupConnection.connect(popup, { + connectionId: ID, + popupOrigin: POPUP_ORIGIN, + onDiagnostic: (e) => void side.events.push(e), + ...(opts.fallback && { fallback: () => Promise.resolve(opts.fallback as Carrier) }), + }) + return { connection, popup, ...side } +} + +/** A popup endpoint over the same pair. */ +function acceptPopup( + pair: FakePair, + opts: { + worker?: Parameters[0] + fallback?: Carrier + opener?: boolean + } = {}, +) { + const side: Side = { events: [] } + const view = opts.opener === false ? { ...pair.popupWindow, opener: null } : pair.popupWindow + const popup = new CurrentWindow( + view as Window, + opts.worker === undefined ? Promise.resolve(undefined) : registrationWith(opts.worker), + ) + const connection = PopupConnection.accept(popup, { + connectionId: ID, + allowedApplicationOrigins: [APP_ORIGIN], + onDiagnostic: (e) => void side.events.push(e), + ...(opts.fallback && { fallback: () => Promise.resolve(opts.fallback as Carrier) }), + }) + return { connection, ...side } +} + +/** Both ends of a test carrier over one MessageChannel. */ +function carrierPair(): [PortCarrier, PortCarrier] { + const channel = new MessageChannel() + return [new PortCarrier(channel.port1), new PortCarrier(channel.port2)] +} + +describe('validation [POPUP-CONNECTION-007]', () => { + it('rejects bad ids and origins before any browser work', async () => { + const pair = fakePair() + const popup = new OpenedWindow(pair.popupProxy as unknown as WindowProxy, pair.appView) + expect(() => + PopupConnection.connect(popup, { connectionId: ID.toUpperCase(), popupOrigin: POPUP_ORIGIN }), + ).toThrow(TypeError) + expect(() => + PopupConnection.connect(popup, { connectionId: ID, popupOrigin: `${POPUP_ORIGIN}/` }), + ).toThrow(TypeError) + expect(pair.appView.listeners.size).toBe(0) + const current = new CurrentWindow(pair.popupWindow, Promise.resolve(undefined)) + await expect( + PopupConnection.accept(current, { + connectionId: 'nope', + allowedApplicationOrigins: [APP_ORIGIN], + }), + ).rejects.toThrow(TypeError) + await expect( + PopupConnection.accept(current, { connectionId: ID, allowedApplicationOrigins: [] }), + ).rejects.toThrow(TypeError) + expect(pair.popupView.listeners.size).toBe(0) + }) + + it('requires the matching PopupWindow kind and one connect per object', () => { + const pair = fakePair() + const popup = new OpenedWindow(pair.popupProxy as unknown as WindowProxy, pair.appView) + const current = new CurrentWindow(pair.popupWindow, Promise.resolve(undefined)) + expect(() => + PopupConnection.connect(current, { connectionId: ID, popupOrigin: POPUP_ORIGIN }), + ).toThrow(TypeError) + PopupConnection.connect(popup, { connectionId: ID, popupOrigin: POPUP_ORIGIN }) + expect(() => + PopupConnection.connect(popup, { connectionId: ID, popupOrigin: POPUP_ORIGIN }), + ).toThrow('already connected') + }) + + it('PopupWindow.open rejects reserved and empty targets [POPUP-WINDOW-001/003]', () => { + for (const target of ['', '_blank', '_self', '_parent', '_top', '_custom']) { + expect(() => PopupWindow.open(target)).toThrow(TypeError) + } + }) +}) + +describe('MessagePort selection and delivery', () => { + it('connects, exchanges typed messages, and keeps decode identity [POPUP-API-001/002]', async () => { + const pair = fakePair() + const app = connectApp(pair) + const readies: Ready[] = [] + app.connection.on(Ready, (r) => void readies.push(r)) + const popup = await acceptPopup(pair).connection + const starts: Start[] = [] + popup.on(Start, (s) => void starts.push(s)) + await tick() + expect(codes(app.events)).toEqual(['window-opened', 'carrier-message-port']) + + popup.send(new Ready(1)) + app.connection.send(new Start()) + await tick() + expect(readies).toHaveLength(1) + expect(readies[0]).toBeInstanceOf(Ready) + expect(readies[0].version).toBe(1) + expect(starts).toHaveLength(1) + }) + + it('rejects reserved and duplicate registrations and sends [POPUP-API-001/002]', () => { + const pair = fakePair() + const { connection } = connectApp(pair) + const off = connection.on(Ready, () => {}) + expect(() => connection.on(Ready, () => {})).toThrow('already registered') + off() + expect(() => connection.on(Ready, () => {})).not.toThrow() + expect(() => + connection.on({ type: 'navigate', decode: () => new Ready(1) } as never, () => {}), + ).toThrow('reserved') + expect(() => connection.send({ type: 'close-popup' } as never)).toThrow('reserved') + }) + + it('throws on send without a carrier and queues nothing [POPUP-CONNECTION-004]', () => { + const pair = fakePair() + const app = connectApp(pair) + expect(() => app.connection.send(new Start())).toThrow('send-unavailable') + expect(codes(app.events)).toContain('send-unavailable') + }) + + it('closes on unknown, malformed, or decoder-rejected input [POPUP-API-003]', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + for (const bad of [ + { type: 'unknown' }, + { type: 'ready', version: 'x' }, + 'text', + { type: '' }, + ]) { + const pair = fakePair() + const app = connectApp(pair) + const handler = vi.fn() + app.connection.on(Ready, handler) + const popup = await acceptPopup(pair).connection + await tick() + // Bypass the typed API: push a raw value over the popup's carrier. + ;(popup as unknown as { carrier: Carrier }).carrier.send(bad as Message) + await tick() + expect(handler).not.toHaveBeenCalled() + expect(codes(app.events).at(-1)).toBe('decode-rejected') + expect(codes(app.events)).toContain('connection-failed') + expect(() => app.connection.send(new Start())).toThrow('send-unavailable') + } + error.mockRestore() + }) + + it('keeps concurrent connections isolated by id [POPUP-API-004]', async () => { + const a = fakePair() + const b = fakePair() + // Both applications share one page (view) but bind different popups. + const appA = connectApp(a) + const appB = new OpenedWindow(b.popupProxy as unknown as WindowProxy, a.appView) + const eventsB: PopupDiagnostic[] = [] + const connB = PopupConnection.connect(appB, { + connectionId: OTHER_ID, + popupOrigin: POPUP_ORIGIN, + onDiagnostic: (e) => void eventsB.push(e), + }) + const readyA = vi.fn() + const readyB = vi.fn() + appA.connection.on(Ready, readyA) + connB.on(Ready, readyB) + // Popup A's handshake reaches both listeners on the shared page. + const popupA = await acceptPopup(a).connection + await tick() + expect(codes(appA.events)).toContain('carrier-message-port') + expect(codes(eventsB)).not.toContain('handshake-rejected') + expect(codes(eventsB)).not.toContain('connection-failed') + popupA.send(new Ready(7)) + await tick() + expect(readyA).toHaveBeenCalledTimes(1) + expect(readyB).not.toHaveBeenCalled() + }) +}) + +describe('native-anchor path [POPUP-WINDOW-002] [POPUP-CONTROL-001]', () => { + it('performs no browser operation while binding is pending, then binds', async () => { + const pair = fakePair() + const app = connectApp(pair, { blocked: true }) + expect(app.popup.opened).toBe(false) + await expect(app.connection.navigate('https://popup.example/p')).resolves.toBeUndefined() + expect(pair.popupProxy.replaced).toEqual([]) + await acceptPopup(pair).connection + await tick() + expect(app.popup.opened).toBe(true) + expect(codes(app.events)).toEqual(['window-blocked', 'window-bound', 'carrier-message-port']) + }) +}) + +describe('controls [POPUP-CONTROL-001/002/003/004]', () => { + it('navigates directly without a carrier and over the carrier with one', async () => { + const pair = fakePair() + const app = connectApp(pair) + await app.connection.navigate('https://popup.example/p') + expect(pair.popupProxy.replaced).toEqual(['https://popup.example/p']) + expect(codes(app.events).at(-1)).toBe('control-direct') + + const scope = fakeScope() + await acceptPopup(pair, { worker: scope.worker }).connection + await tick() + await app.connection.navigate('https://popup.example/isolated') + expect(codes(app.events).at(-1)).toBe('control-connected') + await tick(20) + // The popup kept its port with the worker and replaced itself. + expect(scope.pending).toHaveLength(1) + expect(pair.popupProxy.replaced).toEqual([ + 'https://popup.example/p', + 'https://popup.example/isolated', + ]) + }) + + it('rejects malformed navigation before any browser operation', async () => { + const pair = fakePair() + const app = connectApp(pair) + for (const bad of [ + 'http://popup.example/p', + 'https://u:p@popup.example/p', + '/p', + 'https://popup.example', + ]) { + await expect(app.connection.navigate(bad)).rejects.toThrow(TypeError) + } + expect(pair.popupProxy.replaced).toEqual([]) + expect(codes(app.events).filter((c) => c === 'control-rejected')).toHaveLength(4) + }) + + it('continues the same connection in the next document after a connected navigation', async () => { + const pair = fakePair() + const app = connectApp(pair) + const readies: number[] = [] + app.connection.on(Ready, (r) => void readies.push(r.version)) + const scope = fakeScope() + await acceptPopup(pair, { worker: scope.worker }).connection + await tick() + await app.connection.navigate('https://popup.example/isolated') + await tick(20) + // The destination document claims and continues with the same port. + const next = await acceptPopup(pair, { worker: scope.worker, opener: false }) + const nextEvents = next.events + const connection = await next.connection + expect(codes(nextEvents)).toEqual(['carrier-restored']) + connection.send(new Ready(2)) + await tick() + expect(readies).toEqual([2]) + }) + + it('fails closed without continuity instead of navigating', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const pair = fakePair() + connectApp(pair) + const popupSide = acceptPopup(pair) + const popup = await popupSide.connection + await tick() + await expect(popup.navigate('https://popup.example/isolated')).rejects.toThrow( + 'continuity-unsupported', + ) + expect(pair.popupProxy.replaced).toEqual([]) + expect(codes(popupSide.events)).toContain('connection-failed') + error.mockRestore() + }) + + it('closes directly with a live handle, over the carrier after severing, and is idempotent', async () => { + const pair = fakePair() + const app = connectApp(pair) + const popupSide = acceptPopup(pair) + await popupSide.connection + await tick() + await app.connection.close() + expect(pair.popupProxy.closed).toBe(true) + await expect(app.connection.close()).resolves.toBeUndefined() + expect(codes(app.events).filter((c) => c === 'connection-closed')).toHaveLength(1) + + // Severed: the handle reports closed but the port is alive. + const pair2 = fakePair() + const app2 = connectApp(pair2) + const popup2Side = acceptPopup(pair2) + await popup2Side.connection + await tick() + const closeSpy = vi.spyOn(pair2.popupWindow, 'close') + pair2.popupProxy.closed = true + await app2.connection.close() + await tick() + expect(closeSpy).toHaveBeenCalledTimes(1) + expect(codes(popup2Side.events)).toContain('connection-closed') + expect(() => app2.connection.send(new Start())).toThrow('send-unavailable') + }) + + it('rejects wrong-direction, duplicate, and post-terminal controls', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + // Wrong direction: a control arriving at the application fails it. + const pair = fakePair() + const app = connectApp(pair) + const popup = await acceptPopup(pair).connection + await tick() + ;(popup as unknown as { carrier: Carrier }).carrier.send({ type: 'close-popup' }) + await tick() + expect(codes(app.events).at(-1)).toBe('control-rejected') + + // Duplicate: the second control performs no browser operation. + const pair2 = fakePair() + const app2 = connectApp(pair2) + const scope = fakeScope() + await acceptPopup(pair2, { worker: scope.worker }).connection + await tick() + const raw = (app2.connection as unknown as { carrier: Carrier }).carrier + raw.send({ type: 'navigate', url: 'https://popup.example/a' } as Message) + raw.send({ type: 'navigate', url: 'https://popup.example/b' } as Message) + raw.send({ type: 'close-popup' }) + await tick(20) + expect(pair2.popupProxy.replaced).toEqual(['https://popup.example/a']) + expect(pair2.popupProxy.closed).toBe(false) + error.mockRestore() + }) +}) + +describe('fallback seam [POPUP-CONNECTION-002/004/005] [POPUP-DIAGNOSTIC-003]', () => { + it('fails closed with fallback-unavailable exactly once when no opener and no constructor', async () => { + const pair = fakePair() + const side = acceptPopup(pair, { opener: false }) + await expect(side.connection).rejects.toThrow('fallback-unavailable') + expect(codes(side.events)).toEqual(['fallback-unavailable']) + }) + + it('emits no fallback diagnostic when MessagePort succeeds', async () => { + const pair = fakePair() + const app = connectApp(pair) + const side = acceptPopup(pair) + await side.connection + await tick() + expect(codes(app.events).concat(codes(side.events))).not.toContain('fallback-unavailable') + }) + + it('invokes the application constructor once, installs the carrier only when it resolves, and aborts on close', async () => { + const pair = fakePair() + const [appCarrier, popupCarrier] = carrierPair() + const signals: AbortSignal[] = [] + let resolveFallback: (c: Carrier) => void = () => {} + const fallback = vi.fn((signal: AbortSignal) => { + signals.push(signal) + return new Promise((resolve) => { + resolveFallback = resolve + }) + }) + const popup = new OpenedWindow(pair.popupProxy as unknown as WindowProxy, pair.appView) + const events: PopupDiagnostic[] = [] + const connection = PopupConnection.connect(popup, { + connectionId: ID, + popupOrigin: POPUP_ORIGIN, + fallback, + onDiagnostic: (e) => void events.push(e), + }) + expect(fallback).toHaveBeenCalledTimes(1) + expect(signals[0].aborted).toBe(false) + expect(() => connection.send(new Start())).toThrow('send-unavailable') + + // MessagePort wins first; the standby stays armed. + await acceptPopup(pair).connection + await tick() + expect(codes(events)).toContain('carrier-message-port') + + // The popup's next document commits fallback: resolution replaces the port carrier. + const readies: number[] = [] + connection.on(Ready, (r) => void readies.push(r.version)) + resolveFallback(appCarrier) + await tick() + expect(codes(events).at(-1)).toBe('carrier-fallback') + popupCarrier.send(new Ready(3)) + await tick() + expect(readies).toEqual([3]) + + await connection.close() + expect(signals[0].aborted).toBe(true) + }) + + it('the popup commits its constructor only after MessagePort is unavailable', async () => { + const pair = fakePair() + const [appCarrier, popupCarrier] = carrierPair() + const fallback = vi.fn(() => Promise.resolve(popupCarrier)) + const popup = new CurrentWindow( + { ...pair.popupWindow, opener: null } as Window, + Promise.resolve(undefined), + ) + const events: PopupDiagnostic[] = [] + const connection = await PopupConnection.accept(popup, { + connectionId: ID, + allowedApplicationOrigins: [APP_ORIGIN], + fallback, + onDiagnostic: (e) => void events.push(e), + }) + expect(fallback).toHaveBeenCalledTimes(1) + expect(codes(events)).toEqual(['carrier-fallback']) + const starts = vi.fn() + connection.on(Start, starts) + appCarrier.send(new Start()) + await tick() + expect(starts).toHaveBeenCalledTimes(1) + // Navigation over a non-port carrier fails closed [POPUP-CONNECTION-008]. + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + await expect(connection.navigate('https://popup.example/next')).rejects.toThrow( + 'continuity-unsupported', + ) + error.mockRestore() + }) + + it('the popup with an opener never invokes its constructor', async () => { + const pair = fakePair() + connectApp(pair) + const [, popupCarrier] = carrierPair() + const fallback = vi.fn(() => Promise.resolve(popupCarrier)) + const side = acceptPopup(pair, { fallback: popupCarrier }) + await side.connection + expect(fallback).not.toHaveBeenCalled() + expect(codes(side.events)).toEqual(['carrier-message-port']) + }) +}) + +describe('loss is never an outcome [POPUP-CONNECTION-006]', () => { + it('a closed popup port delivers nothing and resolves no caller operation', async () => { + const pair = fakePair() + const app = connectApp(pair) + const handler = vi.fn() + app.connection.on(Ready, handler) + const popup = await acceptPopup(pair).connection + await tick() + ;(popup as unknown as { carrier: Carrier }).carrier.close() + await tick() + expect(handler).not.toHaveBeenCalled() + expect(codes(app.events)).not.toContain('connection-closed') + expect(codes(app.events)).not.toContain('connection-failed') + }) +}) diff --git a/ts/packages/popup/src/connection.ts b/ts/packages/popup/src/connection.ts new file mode 100644 index 00000000..d0709d56 --- /dev/null +++ b/ts/packages/popup/src/connection.ts @@ -0,0 +1,441 @@ +// The logical connection (docs/connection.md, docs/control.md): one +// application endpoint that may see several popup documents, and one popup +// endpoint per document. Both share the handler registry; the popup side +// additionally consumes the two reserved controls. + +import { + codeOf, + createReporter, + type DiagnosticCode, + failure, + type PopupDiagnostic, + type Reporter, + reportUndeliverable, +} from './diagnostics.js' +import { PortKeeper } from './keeper.js' +import { + canonicalOrigin, + type Carrier, + type CarrierConstructor, + decodeControl, + isCanonicalHttpsUrl, + isConnectionId, + isReservedType, + type Message, + type MessageType, + type Navigate, + type PopupControl, + routingType, +} from './message.js' +import { + listenForPopupPorts, + OPENER_HANDSHAKE_TIMEOUT_MS, + PortCarrier, + requestApplicationPort, +} from './port.js' +import { CurrentWindow, OpenedWindow, type PopupWindow } from './window.js' + +export interface PopupConnection { + send(message: M): void + on(message: MessageType, handler: (message: N) => void): () => void + navigate(url: string): Promise + close(): Promise +} + +export interface ConnectOptions { + connectionId: string + popupOrigin: string + fallback?: CarrierConstructor + onDiagnostic?: (event: PopupDiagnostic) => void +} + +export interface AcceptOptions { + connectionId: string + allowedApplicationOrigins: readonly string[] + fallback?: CarrierConstructor + onDiagnostic?: (event: PopupDiagnostic) => void +} + +function requireConnectionId(value: string): string { + if (!isConnectionId(value)) { + throw new TypeError('connectionId must be a canonical lowercase RFC 4122 UUIDv4') + } + return value +} + +function requireHttpsUrl(url: string, report: Reporter): void { + if (!isCanonicalHttpsUrl(url)) { + report('control-rejected') + throw new TypeError('navigate requires a canonical absolute HTTPS URL without credentials') + } +} + +/** Caller registrations plus routing of every inbound carrier value. */ +class Handlers { + private readonly entries = new Map< + string, + { decode: (value: unknown) => M; handler: (message: M) => void } + >() + + on(message: MessageType, handler: (message: N) => void): () => void { + const { type } = message + if (isReservedType(type)) throw new TypeError(`"${type}" is a reserved discriminator`) + if (this.entries.has(type)) throw new TypeError(`"${type}" is already registered`) + const entry = { decode: (value: unknown) => message.decode(value), handler } + this.entries.set(type, entry as never) + return () => { + if (this.entries.get(type) === (entry as never)) this.entries.delete(type) + } + } + + /** + * Dispatches one caller value, or returns a decoded control for the + * endpoint. Throws `decode-rejected` or `control-rejected` otherwise. + */ + deliver(value: unknown): PopupControl | null { + const type = routingType(value) + if (type === null) throw failure('decode-rejected') + if (isReservedType(type)) { + const control = decodeControl(value as Record) + if (!control) throw failure('control-rejected') + return control + } + const entry = this.entries.get(type) + if (!entry) throw failure('decode-rejected') + let message: M + try { + message = entry.decode(value) + } catch { + throw failure('decode-rejected') + } + entry.handler(message) + return null + } +} + +/** Shared endpoint state: registry, carrier subscription, closure. */ +abstract class Endpoint implements PopupConnection { + protected readonly handlers = new Handlers() + protected readonly controller = new AbortController() + protected carrier: Carrier | null = null + protected closed = false + private unsubscribe: (() => void) | null = null + private readonly startedAt = performance.now() + + protected constructor(protected readonly report: Reporter) {} + + send(message: M): void { + if (isReservedType(message.type)) { + throw new TypeError(`"${message.type}" is a reserved discriminator`) + } + if (this.closed || !this.carrier) { + this.report('send-unavailable') + throw failure('send-unavailable') + } + this.carrier.send(message) + } + + on(message: MessageType, handler: (message: N) => void): () => void { + return this.handlers.on(message, handler) + } + + abstract navigate(url: string): Promise + abstract close(): Promise + + /** Installs the selected carrier; the class is reported when it was chosen here. */ + protected install(carrier: Carrier, code?: DiagnosticCode): void { + this.dropCarrier() + this.carrier = carrier + this.unsubscribe = carrier.on((value) => this.receive(value)) + if (code) this.report(code) + } + + protected abstract onControl(control: PopupControl): void + + private receive(value: unknown): void { + if (this.closed) return + let control: PopupControl | null + try { + control = this.handlers.deliver(value) + } catch (error) { + this.fail(codeOf(error) ?? 'decode-rejected') + return + } + if (control) this.onControl(control) + } + + protected dropCarrier(): void { + this.unsubscribe?.() + this.unsubscribe = null + this.carrier?.close() + this.carrier = null + } + + protected fail(code: DiagnosticCode): void { + if (this.closed) return + this.release('connection-failed') + reportUndeliverable(this.report, code) + } + + protected release(code: 'connection-closed' | 'connection-failed'): void { + this.closed = true + this.controller.abort() + this.dropCarrier() + this.report(code, { durationMs: performance.now() - this.startedAt }) + } +} + +class ApplicationEndpoint extends Endpoint { + private readonly stopListening: () => void + + constructor( + private readonly popup: OpenedWindow, + options: ConnectOptions, + ) { + super(createReporter(options.onDiagnostic)) + const connectionId = requireConnectionId(options.connectionId) + const popupOrigin = canonicalOrigin(options.popupOrigin) + if (!popupOrigin) throw new TypeError('popupOrigin must be a canonical origin') + if (popup.connected) throw new Error('PopupWindow is already connected') + popup.connected = true + + this.report(popup.opened ? 'window-opened' : 'window-blocked') + this.stopListening = listenForPopupPorts( + { + view: popup.view, + source: popup.handle, + onBind: (source) => { + popup.bind(source) + this.report('window-bound') + }, + popupOrigin, + connectionId, + report: this.report, + }, + { + onPort: (port) => this.install(new PortCarrier(port), 'carrier-message-port'), + onFail: () => this.fail('handshake-rejected'), + }, + ) + + if (options.fallback) { + // Armed exactly once for the logical connection; observed, never awaited. + let pending: Promise + try { + pending = Promise.resolve(options.fallback(this.controller.signal)) + } catch (error) { + pending = Promise.reject(error) + } + pending.then( + (carrier) => { + if (this.closed) carrier.close() + else this.install(carrier, 'carrier-fallback') + }, + () => { + // A rejected standby is silent unless its path was selected. + }, + ) + } + } + + protected onControl(): void { + // Controls are application-to-popup only. + this.fail('control-rejected') + } + + async navigate(url: string): Promise { + if (this.closed) throw failure('connection-closed') + requireHttpsUrl(url, this.report) + if (this.carrier) { + const control: Navigate = { type: 'navigate', url } + this.carrier.send(control) + this.report('control-connected') + return + } + if (this.popup.direct) { + this.popup.replace(url) + this.report('control-direct') + return + } + // Native-anchor binding pending: the activation's own navigation proceeds. + if (!this.popup.opened) return + this.report('popup-unavailable') + throw failure('popup-unavailable') + } + + async close(): Promise { + if (this.closed) return + if (this.popup.direct) { + this.popup.closeHandle() + } else if (this.carrier) { + try { + this.carrier.send({ type: 'close-popup' }) + } catch { + // A dead carrier cannot carry the control; local release still runs. + } + } + this.release('connection-closed') + } + + protected override release(code: 'connection-closed' | 'connection-failed'): void { + this.stopListening() + super.release(code) + } +} + +class PopupEndpoint extends Endpoint { + /** The first accepted control is terminal for this document. */ + private controlsDone = false + + private constructor( + private readonly popup: CurrentWindow, + private readonly connectionId: string, + private readonly keeper: PortKeeper | null, + report: Reporter, + ) { + super(report) + } + + static async accept( + popup: CurrentWindow, + options: AcceptOptions, + ): Promise> { + const report = createReporter(options.onDiagnostic) + const connectionId = requireConnectionId(options.connectionId) + const origins = options.allowedApplicationOrigins + if (!Array.isArray(origins) || origins.length === 0) { + throw new TypeError('allowedApplicationOrigins must list at least one origin') + } + const allowedOrigins = Object.freeze([...new Set(origins.map(canonicalOrigin))]) + if (allowedOrigins.some((origin) => origin === null)) { + throw new TypeError('allowedApplicationOrigins must contain canonical origins') + } + + const controller = new AbortController() + let keeper: PortKeeper | null = null + let carrier: Carrier | null = null + + const registration = await popup.registration + if (registration?.active) { + keeper = new PortKeeper(registration.active) + const port = await keeper.claim(connectionId).catch((error: unknown) => { + report('claim-failed') + throw error + }) + if (port) { + carrier = new PortCarrier(port) + report('carrier-restored') + } else { + report('claim-empty') + } + } + + if (!carrier) { + const opener = popup.opener + if (opener) { + try { + const port = await requestApplicationPort({ + view: popup.view, + opener, + allowedOrigins: allowedOrigins as string[], + connectionId, + signal: controller.signal, + timeoutMs: OPENER_HANDSHAKE_TIMEOUT_MS, + }) + carrier = new PortCarrier(port) + report('carrier-message-port') + } catch (error) { + const code = codeOf(error) + if (code !== 'opener-timeout') { + report('handshake-rejected') + throw error + } + report('opener-timeout') + } + } + } + + if (!carrier) { + if (!options.fallback) { + report('fallback-unavailable') + throw failure('fallback-unavailable') + } + carrier = await options.fallback(controller.signal) + report('carrier-fallback') + } + + const endpoint = new PopupEndpoint(popup, connectionId, keeper, report) + endpoint.install(carrier) + return endpoint + } + + protected onControl(control: PopupControl): void { + if (this.controlsDone) return + this.controlsDone = true + if (control.type === 'navigate') { + void this.replaceDocument(control.url).catch(() => {}) + } else { + this.closePopup('connection-closed') + } + } + + async navigate(url: string): Promise { + if (this.closed) throw failure('connection-closed') + requireHttpsUrl(url, this.report) + if (this.controlsDone) throw failure('popup-unavailable') + this.controlsDone = true + await this.replaceDocument(url) + } + + async close(): Promise { + if (this.closed) return + this.closePopup('connection-closed') + } + + /** Preserves the port through the keeper, then replaces this document. */ + private async replaceDocument(url: string): Promise { + if (!(this.carrier instanceof PortCarrier) || !this.keeper) { + this.fail('continuity-unsupported') + throw failure('continuity-unsupported') + } + const port = this.carrier.detach() + const startedAt = performance.now() + try { + await this.keeper.keep(this.connectionId, port) + } catch { + this.fail('keep-failed') + throw failure('keep-failed') + } + this.report('keep-acknowledged', { durationMs: performance.now() - startedAt }) + this.popup.view.location.replace(url) + } + + private closePopup(code: 'connection-closed'): void { + this.release(code) + this.popup.view.close() + } +} + +export const PopupConnection = { + connect( + popupWindow: PopupWindow, + options: ConnectOptions, + ): PopupConnection { + if (!(popupWindow instanceof OpenedWindow)) { + throw new TypeError('connect requires the PopupWindow returned by PopupWindow.open') + } + return new ApplicationEndpoint(popupWindow, options) + }, + + accept( + popupWindow: PopupWindow, + options: AcceptOptions, + ): Promise> { + if (!(popupWindow instanceof CurrentWindow)) { + return Promise.reject( + new TypeError('accept requires the PopupWindow returned by PopupWindow.current'), + ) + } + return PopupEndpoint.accept(popupWindow, options) + }, +} diff --git a/ts/packages/popup/src/diagnostics.test.ts b/ts/packages/popup/src/diagnostics.test.ts new file mode 100644 index 00000000..cc51591d --- /dev/null +++ b/ts/packages/popup/src/diagnostics.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest' +import { createReporter, type PopupDiagnostic, reportUndeliverable } from './diagnostics.js' + +describe('diagnostics [POPUP-DIAGNOSTIC-001/002]', () => { + it('emits only code, timestamp, and a nonnegative duration', () => { + const events: PopupDiagnostic[] = [] + const report = createReporter((event) => void events.push(event)) + report('window-opened') + report('keep-acknowledged', { durationMs: -3 }) + expect(Object.keys(events[0])).toEqual(['code', 'timestamp']) + expect(events[0].timestamp).toBeGreaterThan(performance.timeOrigin) + expect(events[1]).toMatchObject({ code: 'keep-acknowledged', durationMs: 0 }) + }) + + it('treats a throwing sink and a broken console as inert', () => { + const report = createReporter(() => { + throw new Error('sink failure') + }) + const error = vi.spyOn(console, 'error').mockImplementation(() => { + throw new Error('console failure') + }) + expect(() => report('window-opened')).not.toThrow() + expect(() => reportUndeliverable(report, 'decode-rejected')).not.toThrow() + expect(error).toHaveBeenCalledWith('[@libid/popup] decode-rejected') + error.mockRestore() + }) + + it('is a no-op without a sink', () => { + expect(() => createReporter(undefined)('window-opened')).not.toThrow() + }) +}) diff --git a/ts/packages/popup/src/diagnostics.ts b/ts/packages/popup/src/diagnostics.ts new file mode 100644 index 00000000..65e8fb27 --- /dev/null +++ b/ts/packages/popup/src/diagnostics.ts @@ -0,0 +1,70 @@ +// Sanitized local diagnostics (METRICS.md): a stable code, a timestamp, and +// at most a duration or count. Never an origin, URL, id, payload, or error. + +export interface PopupDiagnostic { + readonly code: string + readonly timestamp: number + readonly durationMs?: number + readonly count?: number +} + +export type DiagnosticCode = + | 'window-opened' + | 'window-blocked' + | 'window-bound' + | 'handshake-rejected' + | 'opener-timeout' + | 'carrier-message-port' + | 'carrier-restored' + | 'carrier-fallback' + | 'fallback-unavailable' + | 'decode-rejected' + | 'control-rejected' + | 'control-direct' + | 'control-connected' + | 'continuity-unsupported' + | 'keep-acknowledged' + | 'keep-failed' + | 'claim-empty' + | 'claim-failed' + | 'popup-unavailable' + | 'send-unavailable' + | 'connection-closed' + | 'connection-failed' + +export type Reporter = (code: DiagnosticCode, extra?: { durationMs?: number }) => void + +export function createReporter(onDiagnostic?: (event: PopupDiagnostic) => void): Reporter { + if (!onDiagnostic) return () => {} + return (code, extra) => { + const event: PopupDiagnostic = { + code, + timestamp: performance.timeOrigin + performance.now(), + ...(extra?.durationMs !== undefined && { durationMs: Math.max(0, extra.durationMs) }), + } + try { + onDiagnostic(event) + } catch { + // The sink is advisory; its failure never changes connection behavior. + } + } +} + +/** One sanitized console line for a failure no caller operation can carry. */ +export function reportUndeliverable(report: Reporter, code: DiagnosticCode): void { + try { + console.error(`[@libid/popup] ${code}`) + } catch { + // Console failure is inert. + } + report(code) +} + +/** Errors cross the API as plain `Error` whose message is the code. */ +export function failure(code: DiagnosticCode): Error { + return new Error(code) +} + +export function codeOf(error: unknown): DiagnosticCode | null { + return error instanceof Error ? (error.message as DiagnosticCode) : null +} diff --git a/ts/packages/popup/src/index.ts b/ts/packages/popup/src/index.ts new file mode 100644 index 00000000..1ebf3068 --- /dev/null +++ b/ts/packages/popup/src/index.ts @@ -0,0 +1,11 @@ +/// @libid/popup — one popup browsing context and its logical connection. +/// The Service Worker handler lives behind `@libid/popup/worker`. + +export { + type AcceptOptions, + type ConnectOptions, + PopupConnection, +} from './connection.js' +export type { PopupDiagnostic } from './diagnostics.js' +export type { Carrier, CarrierConstructor, Message, MessageType } from './message.js' +export { PopupWindow } from './window.js' diff --git a/ts/packages/popup/src/keeper.test.ts b/ts/packages/popup/src/keeper.test.ts new file mode 100644 index 00000000..436aa582 --- /dev/null +++ b/ts/packages/popup/src/keeper.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + CARRIER_CLAIM_TIMEOUT_MS, + CLAIM, + decodeKeeperRequest, + KEEP, + KEEPER_REPLY_TIMEOUT_MS, + PortKeeper, +} from './keeper.js' +import { CONNECTION_VERSION } from './port.js' +import { fakeScope, ID, OTHER_ID, tick } from './testing/fakes.js' + +const nextMessage = (port: MessagePort): Promise => + new Promise((resolve) => { + port.onmessage = (e) => resolve(e.data) + }) + +describe('PortKeeper [POPUP-KEEPER-001/004]', () => { + it('keeps then claims the same entangled port, preserving queued messages', async () => { + const scope = fakeScope() + const source = new PortKeeper(scope.worker) + const destination = new PortKeeper(scope.worker) + const channel = new MessageChannel() + + await source.keep(ID, channel.port1) + expect(scope.pending).toHaveLength(1) + // Posted while the worker owns the port: arrives after the claim. + channel.port2.postMessage({ type: 'queued' }) + + const claimed = await destination.claim(ID) + expect(claimed).not.toBeNull() + const pending = nextMessage(claimed as MessagePort) + await tick() + expect(await pending).toEqual({ type: 'queued' }) + // One-use: the entry is gone. + expect(await destination.claim(ID)).toBeNull() + await expect(scope.pending[0]).resolves.toBeUndefined() + }) + + it('returns null for an unknown id without touching anything', async () => { + const scope = fakeScope() + expect(await new PortKeeper(scope.worker).claim(ID)).toBeNull() + expect(scope.pending).toHaveLength(0) + }) +}) + +describe('worker validation [POPUP-KEEPER-002]', () => { + it('rejects a duplicate keep for a live id and closes both ports', async () => { + const scope = fakeScope() + const keeper = new PortKeeper(scope.worker) + const first = new MessageChannel() + const second = new MessageChannel() + await keeper.keep(ID, first.port1) + await expect(keeper.keep(ID, second.port1)).rejects.toThrow('keep-failed') + expect(await keeper.claim(ID)).toBeNull() + await expect(scope.pending[0]).resolves.toBeUndefined() + }) + + it( + 'ignores a client from another origin', + async () => { + const scope = fakeScope() + const foreign = new PortKeeper(scope.foreignWorker) + await expect(foreign.claim(ID)).rejects.toThrow('claim-failed') + }, + KEEPER_REPLY_TIMEOUT_MS + 500, + ) + + it('decodes exact requests only', () => { + const ok = { type: KEEP, connectionVersion: CONNECTION_VERSION, connectionId: ID } + expect(decodeKeeperRequest(ok)).toEqual(ok) + expect(decodeKeeperRequest({ ...ok, type: CLAIM })?.type).toBe(CLAIM) + for (const bad of [ + { ...ok, connectionVersion: 2 }, + { ...ok, connectionId: ID.toUpperCase() }, + { ...ok, extra: 1 }, + { ...ok, type: 'other' }, + null, + 'keep', + ]) { + expect(decodeKeeperRequest(bad)).toBeNull() + } + }) + + it('rejects malformed keep and claim reply shapes', async () => { + const worker = { + postMessage(_message: unknown, transfer: Transferable[]) { + const reply = transfer[transfer.length - 1] as MessagePort + reply.postMessage({ port: 'yes' }) + }, + } + const keeper = new PortKeeper(worker) + await expect(keeper.claim(ID)).rejects.toThrow('claim-failed') + await expect(keeper.keep(ID, new MessageChannel().port1)).rejects.toThrow('keep-failed') + }) + + it('keeps ids isolated', async () => { + const scope = fakeScope() + const keeper = new PortKeeper(scope.worker) + await keeper.keep(ID, new MessageChannel().port1) + expect(await keeper.claim(OTHER_ID)).toBeNull() + expect(await keeper.claim(ID)).not.toBeNull() + }) +}) + +describe('expiry [POPUP-KEEPER-003]', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('deletes the entry at the deadline; a later claim is absent', async () => { + const scope = fakeScope() + const keeper = new PortKeeper(scope.worker) + const channel = new MessageChannel() + const kept = keeper.keep(ID, channel.port1) + await vi.advanceTimersByTimeAsync(10) + await kept + await vi.advanceTimersByTimeAsync(CARRIER_CLAIM_TIMEOUT_MS + 1) + await expect(scope.pending[0]).resolves.toBeUndefined() + const claim = keeper.claim(ID) + await vi.advanceTimersByTimeAsync(10) + expect(await claim).toBeNull() + }) +}) diff --git a/ts/packages/popup/src/keeper.ts b/ts/packages/popup/src/keeper.ts new file mode 100644 index 00000000..5706e9e9 --- /dev/null +++ b/ts/packages/popup/src/keeper.ts @@ -0,0 +1,92 @@ +// Document side of the continuity bridge (docs/message-port.md): hand an +// authenticated port to the same-origin Service Worker before replacing this +// document, and claim it back from the next one. The worker handler lives in +// ./worker.ts; this file owns the wire records both sides share. + +import { failure } from './diagnostics.js' +import { hasExactKeys, isConnectionId, isRecord } from './message.js' +import { CONNECTION_VERSION, type ConnectionVersion } from './port.js' + +export const CARRIER_CLAIM_TIMEOUT_MS = 5_000 +export const KEEPER_REPLY_TIMEOUT_MS = 2_000 + +export const KEEP = 'libid-popup-keep' +export const CLAIM = 'libid-popup-claim' + +export interface KeeperRequest { + type: typeof KEEP | typeof CLAIM + connectionVersion: ConnectionVersion + connectionId: string +} + +export function decodeKeeperRequest(value: unknown): KeeperRequest | null { + if ( + !isRecord(value) || + !hasExactKeys(value, ['type', 'connectionVersion', 'connectionId']) || + (value.type !== KEEP && value.type !== CLAIM) || + value.connectionVersion !== CONNECTION_VERSION || + !isConnectionId(value.connectionId) + ) { + return null + } + return { + type: value.type, + connectionVersion: CONNECTION_VERSION, + connectionId: value.connectionId, + } +} + +/** The subset of ServiceWorker the keeper needs; injectable for tests. */ +export interface KeeperWorker { + postMessage(message: unknown, transfer: Transferable[]): void +} + +export class PortKeeper { + constructor(private readonly worker: KeeperWorker) {} + + /** Resolves only after the worker owns the port. */ + async keep(connectionId: string, port: MessagePort): Promise { + const reply = await this.exchange(KEEP, connectionId, [port], 'keep-failed') + if (!isRecord(reply.data) || reply.data.ok !== true || reply.ports.length !== 0) { + throw failure('keep-failed') + } + } + + /** The preserved port, or null when the worker holds no entry. */ + async claim(connectionId: string): Promise { + const reply = await this.exchange(CLAIM, connectionId, [], 'claim-failed') + const { data, ports } = reply + if (isRecord(data) && hasExactKeys(data, ['port'])) { + if (data.port === false && ports.length === 0) return null + if (data.port === true && ports.length === 1) return ports[0] + } + for (const port of ports) port.close() + throw failure('claim-failed') + } + + private exchange( + type: KeeperRequest['type'], + connectionId: string, + transfer: MessagePort[], + code: 'keep-failed' | 'claim-failed', + ): Promise { + return new Promise((resolve, reject) => { + const reply = new MessageChannel() + const finish = (error: Error | null, event?: MessageEvent): void => { + clearTimeout(timer) + reply.port1.onmessage = null + reply.port1.close() + if (error) reject(error) + else resolve(event as MessageEvent) + } + const timer = setTimeout(() => finish(failure(code)), KEEPER_REPLY_TIMEOUT_MS) + reply.port1.onmessage = (event: MessageEvent): void => finish(null, event) + const message: KeeperRequest = { type, connectionVersion: CONNECTION_VERSION, connectionId } + try { + this.worker.postMessage(message, [...transfer, reply.port2]) + } catch { + finish(failure(code)) + } + }) + } +} diff --git a/ts/packages/popup/src/message.test.ts b/ts/packages/popup/src/message.test.ts new file mode 100644 index 00000000..44398986 --- /dev/null +++ b/ts/packages/popup/src/message.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { + canonicalOrigin, + decodeControl, + isCanonicalHttpsUrl, + isConnectionId, + isReservedType, + MAX_TYPE_LENGTH, + routingType, +} from './message.js' + +const ID = '1c037b6a-2f08-4b17-9f9e-0d9a6a5b3c2d' + +describe('connection id [POPUP-CONNECTION-007]', () => { + it('accepts exact lowercase RFC 4122 UUIDv4 only', () => { + expect(isConnectionId(ID)).toBe(true) + for (const bad of [ + ID.toUpperCase(), + ID.replace('-4b17', '-1b17'), // version + ID.replace('-9f9e', '-cf9e'), // variant + `{${ID}}`, + ID.replaceAll('-', ''), + `${ID} `, + '', + 42, + null, + ]) { + expect(isConnectionId(bad)).toBe(false) + } + }) +}) + +describe('navigation url [POPUP-CONTROL-002]', () => { + it('accepts only canonical absolute HTTPS without credentials', () => { + expect(isCanonicalHttpsUrl('https://popup.example/p#c=1')).toBe(true) + for (const bad of [ + 'http://popup.example/p', + 'https://user:pw@popup.example/p', + 'https://user@popup.example/p', + '/relative', + 'https://popup.example', // noncanonical: serializes with a trailing slash + 'HTTPS://popup.example/p', + 'https://popup.example/a b', + 'javascript:alert(1)', + '', + ]) { + expect(isCanonicalHttpsUrl(bad)).toBe(false) + } + }) +}) + +describe('controls [POPUP-CONTROL-004]', () => { + it('decodes exact records only', () => { + expect(decodeControl({ type: 'close-popup' })).toEqual({ type: 'close-popup' }) + expect(decodeControl({ type: 'navigate', url: 'https://p.example/' })).toEqual({ + type: 'navigate', + url: 'https://p.example/', + }) + for (const bad of [ + { type: 'close-popup', extra: 1 }, + { type: 'navigate' }, + { type: 'navigate', url: 'http://p.example/' }, + { type: 'navigate', url: 'https://p.example/', extra: 1 }, + { type: 'navigate', url: 1 }, + { type: 'other' }, + ]) { + expect(decodeControl(bad)).toBeNull() + } + }) + + it('reserves both discriminators', () => { + expect(isReservedType('navigate')).toBe(true) + expect(isReservedType('close-popup')).toBe(true) + expect(isReservedType('ready')).toBe(false) + }) +}) + +describe('routing type', () => { + it('reads a bounded string type from a plain record', () => { + expect(routingType({ type: 'ready' })).toBe('ready') + expect(routingType({ type: 'x'.repeat(MAX_TYPE_LENGTH) })).toHaveLength(MAX_TYPE_LENGTH) + for (const bad of [ + { type: 'x'.repeat(MAX_TYPE_LENGTH + 1) }, + { type: '' }, + { type: 1 }, + {}, + [], + null, + 'ready', + ]) { + expect(routingType(bad)).toBeNull() + } + }) +}) + +describe('origins', () => { + it('accepts canonical serializations only', () => { + expect(canonicalOrigin('https://app.example')).toBe('https://app.example') + expect(canonicalOrigin('https://app.example:8443')).toBe('https://app.example:8443') + for (const bad of [ + 'https://app.example/', + 'app.example', + 'null', + 'https://app.example:443', + 1, + ]) { + expect(canonicalOrigin(bad)).toBeNull() + } + }) +}) diff --git a/ts/packages/popup/src/message.ts b/ts/packages/popup/src/message.ts new file mode 100644 index 00000000..09c15009 --- /dev/null +++ b/ts/packages/popup/src/message.ts @@ -0,0 +1,105 @@ +// The wire leaf: the caller-owned message contract, the carrier seam, the +// two reserved controls, and the validators every other module shares. +// Nothing here touches a browser global. + +export interface Message { + readonly type: string +} + +export interface MessageType { + readonly type: M['type'] + decode(value: unknown): M +} + +/** A connection-internal adapter from a native resource to delivery. */ +export interface Carrier { + send(value: Message): void + on(handler: (value: unknown) => void): () => void + close(): void +} + +export type CarrierConstructor = (signal: AbortSignal) => Promise + +export interface Navigate { + readonly type: 'navigate' + readonly url: string +} + +export interface ClosePopup { + readonly type: 'close-popup' +} + +export type PopupControl = Navigate | ClosePopup + +export const MAX_TYPE_LENGTH = 64 + +const RESERVED_TYPES: ReadonlySet = new Set(['navigate', 'close-popup']) + +export function isReservedType(type: string): boolean { + return RESERVED_TYPES.has(type) +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Exact-shape gate: the record owns exactly the listed keys. */ +export function hasExactKeys(record: Record, keys: readonly string[]): boolean { + if (Object.keys(record).length !== keys.length) return false + for (const key of keys) if (!Object.hasOwn(record, key)) return false + return true +} + +/** The bounded routing discriminator of an inbound value, or null. */ +export function routingType(value: unknown): string | null { + if (!isRecord(value)) return null + const { type } = value + return typeof type === 'string' && type.length > 0 && type.length <= MAX_TYPE_LENGTH ? type : null +} + +/** An absolute HTTPS URL in its own serialization, without credentials. */ +export function isCanonicalHttpsUrl(url: string): boolean { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return false + } + return ( + parsed.protocol === 'https:' && + parsed.href === url && + parsed.username === '' && + parsed.password === '' + ) +} + +export function decodeControl(value: Record): PopupControl | null { + if (value.type === 'close-popup') { + return hasExactKeys(value, ['type']) ? { type: 'close-popup' } : null + } + if (value.type === 'navigate') { + return hasExactKeys(value, ['type', 'url']) && + typeof value.url === 'string' && + isCanonicalHttpsUrl(value.url) + ? { type: 'navigate', url: value.url } + : null + } + return null +} + +const CONNECTION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +/** Exact canonical lowercase RFC 4122 UUIDv4; no normalization. */ +export function isConnectionId(value: unknown): value is string { + return typeof value === 'string' && CONNECTION_ID.test(value) +} + +/** The value itself when it is an origin in canonical serialization. */ +export function canonicalOrigin(value: unknown): string | null { + if (typeof value !== 'string') return null + try { + return new URL(value).origin === value ? value : null + } catch { + return null + } +} diff --git a/ts/packages/popup/src/port.test.ts b/ts/packages/popup/src/port.test.ts new file mode 100644 index 00000000..ebfad829 --- /dev/null +++ b/ts/packages/popup/src/port.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, it, vi } from 'vitest' +import type { DiagnosticCode } from './diagnostics.js' +import { + CONNECTION_VERSION, + listenForPopupPorts, + PortCarrier, + requestApplicationPort, +} from './port.js' +import { + APP_ORIGIN, + fakePair, + type FakePair, + ID, + OTHER_ID, + POPUP_ORIGIN, + tick, +} from './testing/fakes.js' + +const handshake = (connectionId = ID) => ({ + type: 'message-port', + connectionVersion: CONNECTION_VERSION, + connectionId, +}) + +interface Harness { + pair: FakePair + ports: MessagePort[] + fails: number + codes: DiagnosticCode[] + bound: unknown[] + stop: () => void +} + +function listen(source: 'handle' | null = 'handle', pair = fakePair()): Harness { + const h: Harness = { pair, ports: [], fails: 0, codes: [], bound: [], stop: () => {} } + h.stop = listenForPopupPorts( + { + view: pair.appView, + source: source === 'handle' ? (pair.popupProxy as unknown as WindowProxy) : null, + onBind: (s) => void h.bound.push(s), + popupOrigin: POPUP_ORIGIN, + connectionId: ID, + report: (code) => void h.codes.push(code), + }, + { onPort: (port) => void h.ports.push(port), onFail: () => void h.fails++ }, + ) + return h +} + +const request = ( + pair: FakePair, + overrides: Partial[0]> = {}, +) => + requestApplicationPort({ + view: pair.popupView, + opener: pair.appProxy as unknown as WindowProxy, + allowedOrigins: [APP_ORIGIN], + connectionId: ID, + signal: new AbortController().signal, + timeoutMs: 200, + ...overrides, + }) + +async function roundTrip(app: MessagePort, popup: MessagePort): Promise { + const received: unknown[] = [] + popup.onmessage = (e) => void received.push(e.data) + app.postMessage({ type: 'ping', n: 1 }) + await tick() + return received +} + +describe('MessagePort handshake [POPUP-PORT-001]', () => { + it('authenticates both endpoints and resolves entangled ports after the echo', async () => { + const h = listen() + const popupPort = await request(h.pair) + await tick() + expect(h.ports).toHaveLength(1) + expect(h.fails).toBe(0) + expect(await roundTrip(h.ports[0], popupPort)).toEqual([{ type: 'ping', n: 1 }]) + h.stop() + }) + + it('selects nothing before the popup echo', async () => { + const h = listen() + // Raw handshake from the popup without the echo step. + h.pair.appProxy.postMessage(handshake(), '*') + await tick() + expect(h.ports).toHaveLength(0) + expect(h.fails).toBe(0) + h.stop() + }) + + it('rejects a mismatched echo and ignores a duplicate one', async () => { + const h = listen() + // Intercept the application's response to capture the transferred port. + let transferred: MessagePort | undefined + const observer = (event: MessageEvent) => { + transferred = event.ports[0] + } + h.pair.popupView.addEventListener('message', observer) + h.pair.appProxy.postMessage(handshake(), '*') + await tick() + h.pair.popupView.removeEventListener('message', observer) + expect(transferred).toBeDefined() + ;(transferred as MessagePort).postMessage({ ...handshake(), connectionVersion: 2 }) + await tick() + expect(h.ports).toHaveLength(0) + expect(h.fails).toBe(1) + expect(h.codes).toEqual(['handshake-rejected']) + + // A second echo after acceptance is an ordinary value, not a re-selection. + const h2 = listen() + const popupPort = await request(h2.pair) + await tick() + popupPort.postMessage(handshake()) + await tick() + expect(h2.ports).toHaveLength(1) + expect(h2.fails).toBe(0) + h.stop() + h2.stop() + }) + + it('ignores events that are not addressed to this connection', async () => { + const h = listen() + h.pair.appProxy.postMessage({ type: 'oauth-result', state: 'x' }, '*') + h.pair.appProxy.postMessage(handshake(OTHER_ID), '*') + h.pair.appProxy.postMessage('string', '*') + await tick() + expect(h.fails).toBe(0) + expect(h.codes).toEqual([]) + h.stop() + }) + + it('rejects an attempt with a wrong origin, version, shape, or extra port', async () => { + for (const bad of [ + { event: { data: handshake(), origin: 'https://evil.example', source: 'handle' } }, + { + event: { + data: { ...handshake(), connectionVersion: 2 }, + origin: POPUP_ORIGIN, + source: 'handle', + }, + }, + { event: { data: { ...handshake(), extra: 1 }, origin: POPUP_ORIGIN, source: 'handle' } }, + { + event: { + data: handshake(), + origin: POPUP_ORIGIN, + source: 'handle', + ports: [new MessageChannel().port1], + }, + }, + { event: { data: handshake(), origin: POPUP_ORIGIN, source: 'other' } }, + ]) { + const h = listen() + h.pair.appView.dispatch({ + ...bad.event, + source: bad.event.source === 'handle' ? h.pair.popupProxy : {}, + }) + await tick() + expect(h.fails, JSON.stringify(bad)).toBe(1) + expect(h.codes).toEqual(['handshake-rejected']) + expect(h.ports).toHaveLength(0) + h.stop() + } + }) + + it('accepts sequential handshakes over one listener, superseding a pending attempt', async () => { + const h = listen() + const first = await request(h.pair) + await tick() + // A second document handshakes; the first port is not disturbed by us. + const second = await request(h.pair) + await tick() + expect(h.ports).toHaveLength(2) + expect(await roundTrip(h.ports[1], second)).toHaveLength(1) + first.close() + // A pending attempt (no echo yet) is superseded by a newer accepted one. + h.pair.appProxy.postMessage(handshake(), '*') + await tick() + const third = await request(h.pair) + await tick() + expect(h.ports).toHaveLength(3) + expect(await roundTrip(h.ports[2], third)).toHaveLength(1) + h.stop() + }) + + it('binds the native-anchor source once and pins it [POPUP-WINDOW-002]', async () => { + const h = listen(null) + const port = await request(h.pair) + await tick() + expect(h.bound).toEqual([h.pair.popupProxy]) + expect(h.ports).toHaveLength(1) + port.close() + // A handshake from another source is now an attempt from the wrong source. + h.pair.appView.dispatch({ data: handshake(), origin: POPUP_ORIGIN, source: {} }) + await tick() + expect(h.fails).toBe(1) + expect(h.bound).toHaveLength(1) + h.stop() + }) + + it('stops listening and closes pending state on stop', async () => { + const h = listen() + h.stop() + h.pair.appProxy.postMessage(handshake(), '*') + await tick() + expect(h.ports).toHaveLength(0) + expect(h.pair.appView.listeners.size).toBe(0) + }) +}) + +describe('popup request', () => { + it('rejects a response from a wrong source, origin, shape, or port count', async () => { + const pair = fakePair() + const cases: Array<{ data: unknown; origin: string; source: unknown; ports?: MessagePort[] }> = + [ + { data: handshake(), origin: APP_ORIGIN, source: {}, ports: [new MessageChannel().port1] }, + { + data: handshake(), + origin: 'https://evil.example', + source: pair.appProxy, + ports: [new MessageChannel().port1], + }, + { data: handshake(), origin: APP_ORIGIN, source: pair.appProxy, ports: [] }, + { + data: { ...handshake(), connectionVersion: 2 }, + origin: APP_ORIGIN, + source: pair.appProxy, + ports: [new MessageChannel().port1], + }, + ] + for (const event of cases) { + const pending = request(pair) + pair.popupView.dispatch(event) + await expect(pending).rejects.toThrow('handshake-rejected') + } + expect(pair.popupView.listeners.size).toBe(0) + }) + + it('ignores unrelated traffic and times out into opener-timeout', async () => { + const pair = fakePair() + const pending = request(pair, { timeoutMs: 30 }) + pair.popupView.dispatch({ data: { type: 'noise' }, origin: APP_ORIGIN, source: pair.appProxy }) + pair.popupView.dispatch({ + data: handshake(OTHER_ID), + origin: APP_ORIGIN, + source: pair.appProxy, + }) + await expect(pending).rejects.toThrow('opener-timeout') + }) + + it('rejects on abort', async () => { + const pair = fakePair() + const controller = new AbortController() + const pending = request(pair, { signal: controller.signal }) + controller.abort() + await expect(pending).rejects.toThrow('connection-closed') + }) +}) + +describe('PortCarrier [POPUP-PORT-002]', () => { + it('forwards ordered structured-clone values without reallocation', async () => { + const channel = new MessageChannel() + const carrier = new PortCarrier(channel.port1) + const received: unknown[] = [] + carrier.on((value) => void received.push(value)) + const bytes = new Uint8Array([1, 2, 3]) + channel.port2.postMessage({ type: 'a', bytes }) + channel.port2.postMessage({ type: 'b' }) + await tick() + expect(received.map((v) => (v as { type: string }).type)).toEqual(['a', 'b']) + expect((received[0] as { bytes: Uint8Array }).bytes).toBeInstanceOf(Uint8Array) + carrier.close() + expect(() => carrier.send({ type: 'x' })).toThrow('send-unavailable') + expect(() => carrier.close()).not.toThrow() + }) + + it('detaches the same entangled port and closes itself', async () => { + const channel = new MessageChannel() + const carrier = new PortCarrier(channel.port1) + const handler = vi.fn() + carrier.on(handler) + const port = carrier.detach() + expect(port).toBe(channel.port1) + expect(() => carrier.send({ type: 'x' })).toThrow('send-unavailable') + const received: unknown[] = [] + port.onmessage = (e) => void received.push(e.data) + channel.port2.postMessage({ type: 'after' }) + await tick() + expect(handler).not.toHaveBeenCalled() + expect(received).toEqual([{ type: 'after' }]) + }) +}) diff --git a/ts/packages/popup/src/port.ts b/ts/packages/popup/src/port.ts new file mode 100644 index 00000000..992e61c9 --- /dev/null +++ b/ts/packages/popup/src/port.ts @@ -0,0 +1,245 @@ +// The MessagePort carrier (docs/message-port.md): one window.postMessage +// exchange authenticates browser-stamped source and origin and transfers one +// end of a MessageChannel; the popup then echoes the same record over the +// port as the final acknowledgement. The entangled ports carry caller values +// unchanged. + +import type { Reporter } from './diagnostics.js' +import { failure } from './diagnostics.js' +import { type Carrier, hasExactKeys, isRecord, type Message } from './message.js' +import type { View } from './window.js' + +export const CONNECTION_VERSION = 1 as const +export type ConnectionVersion = typeof CONNECTION_VERSION + +export const OPENER_HANDSHAKE_TIMEOUT_MS = 30_000 + +const HANDSHAKE = 'message-port' + +interface Handshake { + type: typeof HANDSHAKE + connectionVersion: ConnectionVersion + connectionId: string +} + +const handshake = (connectionId: string): Handshake => ({ + type: HANDSHAKE, + connectionVersion: CONNECTION_VERSION, + connectionId, +}) + +/** Whether an event is addressed to this connection at all. */ +function isAttempt(data: unknown, connectionId: string): data is Record { + return isRecord(data) && data.type === HANDSHAKE && data.connectionId === connectionId +} + +function isExactHandshake(data: unknown, connectionId: string): boolean { + return ( + isAttempt(data, connectionId) && + hasExactKeys(data, ['type', 'connectionVersion', 'connectionId']) && + data.connectionVersion === CONNECTION_VERSION + ) +} + +export interface ListenOptions { + view: View + /** The retained handle, or null until native-anchor binding. */ + source: WindowProxy | null + onBind: (source: WindowProxy) => void + popupOrigin: string + connectionId: string + report: Reporter +} + +export interface ListenHandlers { + /** The application's authenticated endpoint for one popup document. */ + onPort: (port: MessagePort) => void + /** An attempt from the expected source failed authentication. */ + onFail: () => void +} + +/** + * Application side. One window listener for the connection lifetime: each + * accepted handshake yields one port; per-attempt state is discarded on + * acceptance, supersession, or stop. + */ +export function listenForPopupPorts(options: ListenOptions, handlers: ListenHandlers): () => void { + const { view, popupOrigin, connectionId, report } = options + let source = options.source + let pending: MessagePort | null = null + + const dropPending = (): void => { + if (pending) { + pending.onmessage = null + pending.close() + pending = null + } + } + + const listener = (event: MessageEvent): void => { + if (!isAttempt(event.data, connectionId)) return + if ( + event.origin !== popupOrigin || + (source !== null && event.source !== source) || + (source === null && (event.source === null || !('postMessage' in event.source))) || + !isExactHandshake(event.data, connectionId) || + event.ports.length !== 0 + ) { + report('handshake-rejected') + dropPending() + handlers.onFail() + return + } + if (source === null) { + source = event.source as WindowProxy + options.onBind(source) + } + dropPending() + const channel = new MessageChannel() + const port = channel.port1 + pending = port + port.onmessage = (ack: MessageEvent): void => { + if (pending !== port) return + if (!isExactHandshake(ack.data, connectionId) || ack.ports.length !== 0) { + report('handshake-rejected') + dropPending() + handlers.onFail() + return + } + pending = null + port.onmessage = null + handlers.onPort(port) + } + try { + source.postMessage(handshake(connectionId), popupOrigin, [channel.port2]) + } catch { + // A discarded popup context cannot be answered; the attempt lapses. + dropPending() + } + } + + view.addEventListener('message', listener) + return () => { + view.removeEventListener('message', listener) + dropPending() + } +} + +export interface RequestOptions { + view: View + opener: WindowProxy + allowedOrigins: readonly string[] + connectionId: string + signal: AbortSignal + timeoutMs?: number +} + +/** + * Popup side. Sends the handshake to the exact opener and resolves the + * transferred, acknowledged port. Rejects with `handshake-rejected` on an + * authentication failure, `opener-timeout` on silence, and + * `connection-closed` on abort; every rejection closes reachable ports. + */ +export function requestApplicationPort(options: RequestOptions): Promise { + const { view, opener, allowedOrigins, connectionId, signal } = options + return new Promise((resolve, reject) => { + const finish = (error: Error | null, port?: MessagePort): void => { + view.removeEventListener('message', listener) + clearTimeout(timer) + signal.removeEventListener('abort', onAbort) + if (error) reject(error) + else resolve(port as MessagePort) + } + const listener = (event: MessageEvent): void => { + if (!isAttempt(event.data, connectionId)) return + if ( + event.source !== opener || + !allowedOrigins.includes(event.origin) || + !isExactHandshake(event.data, connectionId) || + event.ports.length !== 1 + ) { + for (const port of event.ports) port.close() + finish(failure('handshake-rejected')) + return + } + const port = event.ports[0] + try { + port.postMessage(handshake(connectionId)) + } catch { + port.close() + finish(failure('handshake-rejected')) + return + } + finish(null, port) + } + const onAbort = (): void => finish(failure('connection-closed')) + const timer = setTimeout( + () => finish(failure('opener-timeout')), + options.timeoutMs ?? OPENER_HANDSHAKE_TIMEOUT_MS, + ) + if (signal.aborted) { + onAbort() + return + } + signal.addEventListener('abort', onAbort, { once: true }) + view.addEventListener('message', listener) + try { + opener.postMessage(handshake(connectionId), '*') + } catch { + finish(failure('handshake-rejected')) + } + }) +} + +/** Adapts one authenticated MessagePort to the carrier operations. */ +export class PortCarrier implements Carrier { + private port: MessagePort | null + + constructor(port: MessagePort) { + this.port = port + } + + send(value: Message): void { + if (!this.port) throw failure('send-unavailable') + try { + this.port.postMessage(value) + } catch (error) { + // DataCloneError: the value cannot cross; the carrier is unusable. + this.close() + throw error + } + } + + on(handler: (value: unknown) => void): () => void { + const port = this.port + if (!port) return () => {} + port.onmessage = (event: MessageEvent): void => handler(event.data) + port.onmessageerror = (): void => this.close() + port.start() + return () => { + if (this.port === port) { + port.onmessage = null + port.onmessageerror = null + } + } + } + + close(): void { + const port = this.port + if (!port) return + this.port = null + port.onmessage = null + port.onmessageerror = null + port.close() + } + + /** Surrenders the port for preservation; this carrier is closed afterwards. */ + detach(): MessagePort { + const port = this.port + if (!port) throw failure('send-unavailable') + this.port = null + port.onmessage = null + port.onmessageerror = null + return port + } +} diff --git a/ts/packages/popup/src/testing/fakes.ts b/ts/packages/popup/src/testing/fakes.ts new file mode 100644 index 00000000..4af929af --- /dev/null +++ b/ts/packages/popup/src/testing/fakes.ts @@ -0,0 +1,148 @@ +// In-memory stand-ins for the browser surfaces the package touches: two +// windows that postMessage each other with browser-stamped origin and +// source, and a Service Worker scope wired to a keeper client. Real Node +// MessageChannel ports flow through unchanged, so port semantics are real. + +import type { KeeperWorker } from '../keeper.js' +import type { View } from '../window.js' +import { installPortKeeperOn } from '../worker.js' + +export const APP_ORIGIN = 'https://app.example' +export const POPUP_ORIGIN = 'https://popup.example' +export const ID = '1c037b6a-2f08-4b17-9f9e-0d9a6a5b3c2d' +export const OTHER_ID = '2d148c7b-3f19-4c28-8a0f-1e0b7b6c4d3e' + +export const tick = (ms = 5): Promise => new Promise((resolve) => setTimeout(resolve, ms)) + +type Listener = (event: MessageEvent) => void + +export interface FakeView extends View { + readonly listeners: Set + dispatch(event: { data: unknown; origin: string; source: unknown; ports?: MessagePort[] }): void +} + +export function fakeView(): FakeView { + const listeners = new Set() + return { + listeners, + addEventListener: (_type, listener) => void listeners.add(listener), + removeEventListener: (_type, listener) => void listeners.delete(listener), + dispatch(event) { + const message = { ports: [], ...event } as unknown as MessageEvent + // Tasks, not microtasks: listeners run after the current stack. + setTimeout(() => { + for (const listener of [...listeners]) listener(message) + }, 0) + }, + } +} + +/** A WindowProxy as seen from the other side of the opener relationship. */ +export interface FakeProxy { + closed: boolean + postMessage(message: unknown, targetOrigin: string, transfer?: Transferable[]): void + location: { replace(url: string): void } + close(): void + replaced: string[] +} + +/** Two documents that see each other as opener and popup. */ +export interface FakePair { + appView: FakeView + popupView: FakeView + /** The handle the application retains (or binds); posts into the popup. */ + popupProxy: FakeProxy + /** The popup's `opener`; posts into the application. */ + appProxy: FakeProxy + /** The popup document as a Window for CurrentWindow. */ + popupWindow: Window +} + +export function fakePair(): FakePair { + const appView = fakeView() + const popupView = fakeView() + const makeProxy = ( + target: FakeView, + targetOrigin: string, + stampedOrigin: string, + self: () => FakeProxy, + ): FakeProxy => { + const proxy: FakeProxy = { + closed: false, + replaced: [], + postMessage(message, origin, transfer = []) { + if (proxy.closed) return + if (origin !== '*' && origin !== targetOrigin) return + target.dispatch({ + data: structuredClone(message), + origin: stampedOrigin, + source: self(), + ports: transfer as MessagePort[], + }) + }, + location: { replace: (url) => void proxy.replaced.push(url) }, + close: () => { + proxy.closed = true + }, + } + return proxy + } + // Each proxy stamps the *sender's* identity as `source`. + let appProxy!: FakeProxy + let popupProxy!: FakeProxy + popupProxy = makeProxy(popupView, POPUP_ORIGIN, APP_ORIGIN, () => appProxy) + appProxy = makeProxy(appView, APP_ORIGIN, POPUP_ORIGIN, () => popupProxy) + + const popupWindow = { + addEventListener: popupView.addEventListener, + removeEventListener: popupView.removeEventListener, + get opener() { + return appProxy + }, + location: popupProxy.location, + close: popupProxy.close, + } as unknown as Window + return { appView, popupView, popupProxy, appProxy, popupWindow } +} + +export interface FakeScope { + /** Post as a same-origin document client. */ + worker: KeeperWorker + /** Post as a client from another origin. */ + foreignWorker: KeeperWorker + pending: Promise[] +} + +/** The real worker handler on a fake ServiceWorkerGlobalScope. */ +export function fakeScope(origin = POPUP_ORIGIN): FakeScope { + const handlers = new Map void>() + const pending: Promise[] = [] + installPortKeeperOn({ + location: { origin }, + addEventListener: (type: string, handler: (event: unknown) => void) => { + handlers.set(type, handler) + }, + skipWaiting: () => Promise.resolve(), + clients: { claim: () => Promise.resolve() }, + } as unknown as ServiceWorkerGlobalScope) + const post = (url: string): KeeperWorker => ({ + postMessage(message, transfer) { + setTimeout(() => { + handlers.get('message')?.({ + data: structuredClone(message), + ports: transfer, + source: { url }, + waitUntil: (promise: Promise) => void pending.push(promise), + }) + }, 0) + }, + }) + return { + worker: post(`${origin}/p`), + foreignWorker: post('https://evil.example/p'), + pending, + } +} + +export const registrationWith = (worker: KeeperWorker | null) => + Promise.resolve({ active: worker } as unknown as ServiceWorkerRegistration) diff --git a/ts/packages/popup/src/window.ts b/ts/packages/popup/src/window.ts new file mode 100644 index 00000000..e8fd2fde --- /dev/null +++ b/ts/packages/popup/src/window.ts @@ -0,0 +1,98 @@ +// The popup lifecycle object. `open` captures the application's retained +// handle (or its absence, for the native-anchor path); `current` captures the +// popup document, its opener, and the matching Service Worker registration. +// Everything but `opened` is package-internal and reached through +// PopupConnection so continuity and control rules always apply. + +/** The listening surface of a Window, injectable for unit tests. */ +export interface View { + addEventListener(type: 'message', listener: (event: MessageEvent) => void): void + removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void +} + +export class PopupWindow { + /** @internal */ + protected constructor() {} + + /** Synchronously attempts `window.open('about:blank', target)`. */ + static open(target: string): PopupWindow { + if (target === '' || target.startsWith('_')) { + throw new TypeError('popup target must be a nonempty name not beginning with "_"') + } + return new OpenedWindow(window.open('about:blank', target), window) + } + + /** Adopts the current popup document; creates nothing. */ + static current(): PopupWindow { + const registration = + typeof navigator !== 'undefined' && navigator.serviceWorker + ? navigator.serviceWorker.getRegistration().catch(() => undefined) + : Promise.resolve(undefined) + return new CurrentWindow(window, registration) + } + + get opened(): boolean { + return false + } +} + +/** @internal */ +export class OpenedWindow extends PopupWindow { + handle: WindowProxy | null + /** One-shot: a second `connect` over the same object throws. */ + connected = false + + constructor( + handle: WindowProxy | null, + readonly view: View, + ) { + super() + this.handle = handle + } + + override get opened(): boolean { + return this.handle !== null + } + + /** Direct control: a retained handle that does not report closed. */ + get direct(): boolean { + return this.handle !== null && !this.handle.closed + } + + bind(source: WindowProxy): void { + if (this.handle !== null) throw new Error('popup already bound') + this.handle = source + } + + replace(url: string): void { + this.handle?.location.replace(url) + } + + closeHandle(): void { + try { + this.handle?.close() + } catch { + // A discarded browsing context makes closure best-effort. + } + } +} + +/** @internal */ +export class CurrentWindow extends PopupWindow { + constructor( + readonly view: Window, + readonly registration: Promise, + ) { + super() + } + + override get opened(): boolean { + return true + } + + /** The opener while it is usable; a closed opener counts as absent. */ + get opener(): WindowProxy | null { + const opener = this.view.opener as WindowProxy | null + return opener !== null && !opener.closed ? opener : null + } +} diff --git a/ts/packages/popup/src/worker.ts b/ts/packages/popup/src/worker.ts new file mode 100644 index 00000000..37193b28 --- /dev/null +++ b/ts/packages/popup/src/worker.ts @@ -0,0 +1,92 @@ +// @libid/popup/worker — the Service Worker half of the continuity bridge. +// The host serves and registers the worker; this handler gives a port a +// temporary owner across one popup document replacement. It never reads the +// port, keeps no durable record, and holds nothing past the claim deadline. + +import { CARRIER_CLAIM_TIMEOUT_MS, decodeKeeperRequest, KEEP } from './keeper.js' + +interface Held { + port: MessagePort + release: () => void +} + +/** @internal Installs the handlers on an explicit scope; tests inject a fake. */ +export function installPortKeeperOn(scope: ServiceWorkerGlobalScope): void { + const held = new Map() + + scope.addEventListener('install', () => void scope.skipWaiting()) + scope.addEventListener('activate', (event) => event.waitUntil(scope.clients.claim())) + scope.addEventListener('message', (event) => { + const closeAll = (): void => { + for (const port of event.ports) port.close() + } + const source = event.source + if (!source || !('url' in source) || new URL(source.url).origin !== scope.location.origin) { + closeAll() + return + } + const request = decodeKeeperRequest(event.data) + if (!request) { + closeAll() + return + } + const { connectionId } = request + const existing = held.get(connectionId) + + if (request.type === KEEP) { + if (event.ports.length !== 2) { + closeAll() + return + } + const [port, reply] = event.ports + if (existing) { + // Duplicate ownership rejects both and closes every reachable port. + held.delete(connectionId) + existing.release() + existing.port.close() + port.close() + reply.postMessage({ ok: false }) + return + } + let release!: () => void + const done = new Promise((resolve) => { + release = resolve + }) + const timer = setTimeout(() => { + if (held.get(connectionId)?.port === port) { + held.delete(connectionId) + port.close() + } + release() + }, CARRIER_CLAIM_TIMEOUT_MS) + held.set(connectionId, { + port, + release: () => { + clearTimeout(timer) + release() + }, + }) + event.waitUntil(done) + reply.postMessage({ ok: true }) + return + } + + if (event.ports.length !== 1) { + closeAll() + return + } + const [reply] = event.ports + if (!existing) { + reply.postMessage({ port: false }) + return + } + held.delete(connectionId) + existing.release() + reply.postMessage({ port: true }, [existing.port]) + }) +} + +/** Composes the port keeper into the host's popup-origin Service Worker. */ +export function installPortKeeper(): void { + installPortKeeperOn(self as unknown as ServiceWorkerGlobalScope) +} diff --git a/ts/packages/popup/tsconfig.build.json b/ts/packages/popup/tsconfig.build.json new file mode 100644 index 00000000..e63d1c75 --- /dev/null +++ b/ts/packages/popup/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.test.ts", "src/testing"] +} diff --git a/ts/packages/popup/tsconfig.json b/ts/packages/popup/tsconfig.json new file mode 100644 index 00000000..a9c779e9 --- /dev/null +++ b/ts/packages/popup/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"], + "strict": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "stripInternal": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/ts/packages/popup/vitest.config.ts b/ts/packages/popup/vitest.config.ts new file mode 100644 index 00000000..ceafc241 --- /dev/null +++ b/ts/packages/popup/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}) diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index deaf50d4..baa8df7a 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -99,6 +99,24 @@ importers: specifier: ^3.2.0 version: 3.2.7(@types/node@22.20.1) + packages/popup: + devDependencies: + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vite: + specifier: ^7.3.6 + version: 7.3.6(@types/node@22.20.1) + vitest: + specifier: ^3.2.0 + version: 3.2.7(@types/node@22.20.1) + packages: '@adraffy/ens-normalize@1.11.1': @@ -491,6 +509,11 @@ packages: '@noir-lang/types@1.0.0-beta.20': resolution: {integrity: sha512-uqje0gPxubHmcQ+NIoD2NXpah2DVaIAY9Mxt8j4S2cc2e88NnbuohrC8K9vPxlvwkghUOq9mBqU+m83Q871mVg==} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -812,6 +835,11 @@ packages: picomatch: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -902,6 +930,16 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.26: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} @@ -1396,6 +1434,10 @@ snapshots: '@noir-lang/types@1.0.0-beta.20': {} + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.62.4': @@ -1676,6 +1718,9 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -1757,6 +1802,14 @@ snapshots: picomatch@4.0.5: {} + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.26: dependencies: nanoid: 3.3.18 From 68a97228302b9d332f1aae4662973f2f32005b7e Mon Sep 17 00:00:00 2001 From: Wondertan Date: Wed, 2 Sep 2026 22:40:18 +0100 Subject: [PATCH 03/24] feat(popup): qualify the connection in real browsers Add the Playwright harness: three cross-site HTTPS origins, a participating page, a COOP-isolated participating page, a non-participating page, and the popup-origin worker script composed the way a host would. The spec covers scripted and native-anchor creation, opener authentication, application-driven navigation into and out of isolation over one preserved port, close after the opener is severed, popup-initiated navigation, port expiry across a long non-participating hop with re-establishment, and the fail-closed paths. All 60 cases pass on the five-project matrix. Two implementation changes surfaced by the browsers: the popup resolves its Service Worker registration per use, since the first participating document registers the worker after `accept` has started, and waits briefly for an installing worker before preserving a port. The stale layout READMEs are removed and the internal declarations in message-port.md now match the code. Assisted-by: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw Signed-off-by: Wondertan --- .gitignore | 5 + ts/packages/popup/METRICS.md | 4 +- ts/packages/popup/docs/message-port.md | 29 +- ts/packages/popup/e2e/build.mjs | 29 ++ ts/packages/popup/e2e/popup.spec.ts | 268 +++++++++++++++++++ ts/packages/popup/e2e/server.mjs | 154 +++++++++++ ts/packages/popup/e2e/sw-entry.ts | 5 + ts/packages/popup/e2e/tls.mjs | 38 +++ ts/packages/popup/playwright.config.ts | 37 +++ ts/packages/popup/src/connection.test.ts | 9 +- ts/packages/popup/src/connection.ts | 17 +- ts/packages/popup/src/connection/README.md | 14 - ts/packages/popup/src/keeper.ts | 26 ++ ts/packages/popup/src/message-port/README.md | 14 - ts/packages/popup/src/testing/fakes.ts | 2 +- ts/packages/popup/src/window.ts | 9 +- ts/packages/popup/tsconfig.e2e.json | 11 + 17 files changed, 609 insertions(+), 62 deletions(-) create mode 100644 ts/packages/popup/e2e/build.mjs create mode 100644 ts/packages/popup/e2e/popup.spec.ts create mode 100644 ts/packages/popup/e2e/server.mjs create mode 100644 ts/packages/popup/e2e/sw-entry.ts create mode 100644 ts/packages/popup/e2e/tls.mjs create mode 100644 ts/packages/popup/playwright.config.ts delete mode 100644 ts/packages/popup/src/connection/README.md delete mode 100644 ts/packages/popup/src/message-port/README.md create mode 100644 ts/packages/popup/tsconfig.e2e.json diff --git a/.gitignore b/.gitignore index dee237bf..04f499dd 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,8 @@ ts/apps/demo/public/ # Harness runtime output harness/.env + +# @libid/popup e2e bundles and Playwright output +ts/packages/popup/e2e/dist/ +ts/packages/*/test-results/ +ts/packages/*/playwright-report/ diff --git a/ts/packages/popup/METRICS.md b/ts/packages/popup/METRICS.md index 42991512..2bdf593c 100644 --- a/ts/packages/popup/METRICS.md +++ b/ts/packages/popup/METRICS.md @@ -29,9 +29,9 @@ value. | Area | Measurements | |---|---| | Popup window | `window-opened`, `window-blocked`, `window-bound` | -| Connection | `handshake-rejected`, `carrier-message-port`, `carrier-restored`, `carrier-fallback`, `fallback-unavailable`, `popup-unavailable`, `send-unavailable`, `connection-closed` and `connection-failed` with `durationMs` since construction | +| Connection | `handshake-rejected`, `opener-timeout`, `carrier-message-port`, `carrier-restored`, `carrier-fallback`, `fallback-unavailable`, `popup-unavailable`, `send-unavailable`, `connection-closed` and `connection-failed` with `durationMs` since construction | | Message delivery | `decode-rejected`; MessagePort adds no encoding or clock, so no size or latency measurement exists | -| Continuity | `keep-acknowledged` with `durationMs`, `keep-failed`, `claim-empty`, `continuity-unsupported` | +| Continuity | `keep-acknowledged` with `durationMs`, `keep-failed`, `claim-empty`, `claim-failed`, `continuity-unsupported` | | Control | `control-direct`, `control-connected`, `control-rejected`; never remote success | | WebRTC | signaling-path class, offer publication, answer pickup, candidate class, selected-pair class, ICE checks, DTLS, data-channel open, and terminal failure | diff --git a/ts/packages/popup/docs/message-port.md b/ts/packages/popup/docs/message-port.md index 0f2463eb..f8ec779d 100644 --- a/ts/packages/popup/docs/message-port.md +++ b/ts/packages/popup/docs/message-port.md @@ -221,18 +221,15 @@ document is not required to message that worker. declare function installPortKeeper(): void declare class PortKeeper { - constructor( - registration: ServiceWorkerRegistration, - connectionVersion: ConnectionVersion, - ) + constructor(worker: ServiceWorker) keep(connectionId: string, port: MessagePort): Promise claim(connectionId: string): Promise } ``` -The constructor fixes the active registration and connection version for both -operations. `keep` resolves only after the worker owns the exact port, after +The constructor fixes the active worker for both operations; the connection +version is the package constant. `keep` resolves only after the worker owns the exact port, after which connection may replace the source document. `claim` atomically returns and removes the unchanged port, or returns `null` when no entry exists. `null` authenticates and selects nothing; popup construction continues with its @@ -257,21 +254,25 @@ The application starts listening before the popup endpoint is ready: ```ts declare function listenForPopupPorts( options: { - popupWindow: PopupWindow + view: Window + source: WindowProxy | null // retained handle, or null until native-anchor binding + onBind: (source: WindowProxy) => void allowedPopupOrigins: readonly string[] - connectionVersion: ConnectionVersion connectionId: string - signal: AbortSignal }, - onPort: (port: MessagePort) => void, + handlers: { + onPort: (port: MessagePort) => void + onFail: () => void + }, ): () => void -declare function connectPopupMessagePort(options: { +declare function requestApplicationPort(options: { + view: Window opener: WindowProxy - allowedApplicationOrigins: readonly string[] - connectionVersion: ConnectionVersion + allowedOrigins: readonly string[] connectionId: string signal: AbortSignal + timeoutMs?: number }): Promise declare class PortCarrier implements Carrier { @@ -293,7 +294,7 @@ supersedes one still awaiting its echo. This replacement is private connection machinery; callers continue using the same `PopupConnection`. When the popup endpoint is ready, it calls and awaits -`connectPopupMessagePort`. It sends the handshake request; the listening +`requestApplicationPort`. It sends the handshake request; the listening application validates it, creates the channel, and sends the response with the popup endpoint. The popup validates that response, sends the same handshake record over the transferred port, and resolves with that endpoint. A handshake diff --git a/ts/packages/popup/e2e/build.mjs b/ts/packages/popup/e2e/build.mjs new file mode 100644 index 00000000..3a4af350 --- /dev/null +++ b/ts/packages/popup/e2e/build.mjs @@ -0,0 +1,29 @@ +// Bundle the two entries the e2e pages load as single-file ES modules: +// the package for documents and the worker entry as the Service Worker +// script. Nothing here is published; it exists so real browsers run the +// exact source under test. + +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { build } from 'vite' + +const packageDir = dirname(dirname(fileURLToPath(import.meta.url))) +const outDir = join(packageDir, 'e2e', 'dist') + +const bundle = (entry, fileName, emptyOutDir) => + build({ + configFile: false, + logLevel: 'warn', + root: packageDir, + build: { + outDir, + emptyOutDir, + target: 'es2022', + minify: false, + lib: { entry: join(packageDir, entry), formats: ['es'], fileName: () => fileName }, + rollupOptions: { output: { inlineDynamicImports: true } }, + }, + }) + +await bundle('src/index.ts', 'popup.js', true) +await bundle('e2e/sw-entry.ts', 'sw.js', false) diff --git a/ts/packages/popup/e2e/popup.spec.ts b/ts/packages/popup/e2e/popup.spec.ts new file mode 100644 index 00000000..0ef553bf --- /dev/null +++ b/ts/packages/popup/e2e/popup.spec.ts @@ -0,0 +1,268 @@ +// Real-browser qualification of the popup connection: scripted and +// native-anchor creation, opener authentication, connected navigation into +// and out of a COOP-isolated document over one preserved port, port expiry +// across a non-participating hop, and control after the opener is severed. + +import { expect, type Page, test } from '@playwright/test' + +const APP_A = 'https://app-a.lvh.me:4581' +const APP_B = 'https://app-b.local.gd:4582' +const POPUP = 'https://popup.localtest.me:4583' + +const freshId = () => crypto.randomUUID() + +interface Pong { + type: 'pong' + n: number + path: string + isolated: boolean +} + +const events = (page: Page) => + page.evaluate(() => (window as unknown as { __events: unknown[] }).__events) +const diag = (page: Page) => page.evaluate(() => (window as unknown as { __diag: string[] }).__diag) + +/** Arm the application page and click its anchor for one connection id. */ +async function open( + page: Page, + options: { app?: string; id?: string; href?: string; blocked?: boolean; rel?: string } = {}, +): Promise<{ id: string; popup: Page }> { + const id = options.id ?? freshId() + await page.goto(options.app ?? APP_A) + await page.evaluate( + ([id, href, blocked, rel]) => { + const w = window as unknown as { __id: string; open: unknown } + w.__id = id + const anchor = document.getElementById('go') as HTMLAnchorElement + anchor.href = href + if (rel) anchor.rel = rel + if (blocked) w.open = () => null + }, + [ + id, + options.href ?? `${POPUP}/p#c=${id}`, + options.blocked ?? false, + options.rel ?? '', + ] as const, + ) + const popupPromise = page.context().waitForEvent('page') + await page.click('#go') + return { id, popup: await popupPromise } +} + +const ping = (page: Page, n: number) => + page.evaluate((n) => { + ;(window as unknown as { __conn: { send(v: unknown): void } }).__conn.send({ type: 'ping', n }) + }, n) + +const navigate = (page: Page, url: string) => + page.evaluate( + (url) => + (window as unknown as { __conn: { navigate(u: string): Promise } }).__conn.navigate( + url, + ), + url, + ) + +/** Run an action that replaces the popup document and wait for the new one. */ +async function nextDocument( + popup: Page, + action: () => Promise = async () => {}, +): Promise { + const before = await popup.evaluate(() => performance.timeOrigin) + await action() + await expect + .poll(() => popup.evaluate(() => performance.timeOrigin).catch(() => before), { + timeout: 15_000, + }) + .not.toBe(before) +} + +async function expectPong(page: Page, n: number): Promise { + await expect + .poll(async () => + (await events(page)).filter((e) => (e as Pong).n === n && (e as Pong).type === 'pong'), + ) + .toHaveLength(1) + return (await events(page)).find((e) => (e as Pong).n === n) as Pong +} + +test('[POPUP-WINDOW-001] [POPUP-PORT-001] scripted open connects over MessagePort', async ({ + page, +}) => { + const { popup } = await open(page) + await expect(popup.locator('#status')).toHaveText('connected') + await expectPong(page, 0) + await ping(page, 1) + expect(await expectPong(page, 1)).toMatchObject({ path: '/p', isolated: false }) + expect(await diag(page)).toEqual(['window-opened', 'control-direct', 'carrier-message-port']) + expect(await diag(popup)).toEqual(['carrier-message-port']) +}) + +test('[POPUP-WINDOW-002] blocked scripted open binds the native anchor popup', async ({ page }) => { + const { popup } = await open(page, { blocked: true }) + await expect(popup.locator('#status')).toHaveText('connected') + await ping(page, 1) + await expectPong(page, 1) + expect(await diag(page)).toEqual(['window-blocked', 'window-bound', 'carrier-message-port']) + expect( + await page.evaluate( + () => (window as unknown as { __popupWindow: { opened: boolean } }).__popupWindow.opened, + ), + ).toBe(true) +}) + +test('[POPUP-WINDOW-003] a noopener anchor never binds and the popup fails closed', async ({ + page, +}) => { + const { popup } = await open(page, { blocked: true, rel: 'noopener' }) + await expect(popup.locator('#status')).toHaveText('failed: fallback-unavailable') + expect(await diag(popup)).toEqual(['fallback-unavailable']) + await page.waitForTimeout(300) + expect(await diag(page)).toEqual(['window-blocked']) +}) + +test('[POPUP-CONNECTION-001] an unlisted application origin is rejected by the popup', async ({ + page, +}) => { + const { popup } = await open(page, { app: APP_B }) + await expect(popup.locator('#status')).toHaveText('failed: handshake-rejected') + await page.waitForTimeout(300) + expect(await diag(page)).toEqual(['window-opened', 'control-direct']) +}) + +test('[POPUP-CONTROL-002] [POPUP-CONNECTION-003] [POPUP-KEEPER-003] one port survives app-driven navigation into and out of isolation', async ({ + page, +}) => { + const { id, popup } = await open(page) + await expectPong(page, 0) + // Let the first document's registration activate before navigating. + await popup.evaluate(() => navigator.serviceWorker.ready) + + await nextDocument(popup, () => navigate(page, `${POPUP}/isolated#c=${id}`)) + await expect(popup.locator('#status')).toHaveText('connected') + expect(await popup.evaluate(() => crossOriginIsolated)).toBe(true) + expect(await diag(popup)).toEqual(['carrier-restored']) + await ping(page, 2) + expect(await expectPong(page, 2)).toMatchObject({ path: '/isolated', isolated: true }) + // B2 qualification: the COOP switch makes the retained handle report closed. + expect( + await page.evaluate(() => (window as unknown as { __handle: Window }).__handle.closed), + ).toBe(true) + + await nextDocument(popup, () => navigate(page, `${POPUP}/p#c=${id}`)) + await expect(popup.locator('#status')).toHaveText('connected') + expect(await diag(popup)).toEqual(['carrier-restored']) + await ping(page, 3) + expect(await expectPong(page, 3)).toMatchObject({ path: '/p', isolated: false }) + + const appDiag = await diag(page) + expect(appDiag.filter((c) => c === 'carrier-message-port')).toHaveLength(1) + expect(appDiag.filter((c) => c === 'control-connected')).toHaveLength(2) + expect(appDiag).not.toContain('fallback-unavailable') +}) + +test('[POPUP-CONTROL-003] [POPUP-CONTROL-004] close reaches a severed popup over the port', async ({ + page, +}) => { + const { id, popup } = await open(page) + await expectPong(page, 0) + await popup.evaluate(() => navigator.serviceWorker.ready) + await nextDocument(popup, () => navigate(page, `${POPUP}/isolated#c=${id}`)) + await expect(popup.locator('#status')).toHaveText('connected') + const closed = popup.waitForEvent('close') + await page.evaluate(() => + (window as unknown as { __conn: { close(): Promise } }).__conn.close(), + ) + await closed + expect(await diag(page)).toContain('connection-closed') +}) + +test('[POPUP-CONTROL-001] close uses the live handle directly', async ({ page }) => { + const { popup } = await open(page) + await expectPong(page, 0) + const closed = popup.waitForEvent('close') + await page.evaluate(() => + (window as unknown as { __conn: { close(): Promise } }).__conn.close(), + ) + await closed +}) + +test('[POPUP-CONNECTION-008] popup-initiated navigation preserves the port', async ({ page }) => { + const { id, popup } = await open(page) + await expectPong(page, 0) + await popup.evaluate(() => navigator.serviceWorker.ready) + await nextDocument(popup, () => + page.evaluate( + (url) => + (window as unknown as { __conn: { send(v: unknown): void } }).__conn.send({ + type: 'go', + url, + }), + `${POPUP}/isolated#c=${id}`, + ), + ) + await expect(popup.locator('#status')).toHaveText('connected') + expect(await diag(popup)).toEqual(['carrier-restored']) + await ping(page, 4) + expect(await expectPong(page, 4)).toMatchObject({ path: '/isolated', isolated: true }) +}) + +test('[POPUP-KEEPER-003] [POPUP-CONNECTION-003] a long non-participating hop expires the port; the next document re-establishes', async ({ + page, +}) => { + test.slow() + const { id, popup } = await open(page) + await expectPong(page, 0) + await popup.evaluate(() => navigator.serviceWorker.ready) + const next = encodeURIComponent(`${POPUP}/p#c=${id}`) + await nextDocument(popup, () => navigate(page, `${POPUP}/external?delay=5500&next=${next}`)) + await expect(popup.locator('#status')).toHaveText('external') + await nextDocument(popup) + await expect(popup.locator('#status')).toHaveText('connected') + // Expired in the worker: the fresh document found nothing and used its opener. + expect(await diag(popup)).toEqual(['claim-empty', 'carrier-message-port']) + await ping(page, 5) + expect(await expectPong(page, 5)).toMatchObject({ path: '/p' }) + expect((await diag(page)).filter((c) => c === 'carrier-message-port')).toHaveLength(2) +}) + +test('[POPUP-KEEPER-003] a short non-participating hop keeps the port', async ({ page }) => { + const { id, popup } = await open(page) + await expectPong(page, 0) + await popup.evaluate(() => navigator.serviceWorker.ready) + const next = encodeURIComponent(`${POPUP}/p#c=${id}`) + await nextDocument(popup, () => navigate(page, `${POPUP}/external?delay=200&next=${next}`)) + await nextDocument(popup) + await expect(popup.locator('#status')).toHaveText('connected') + expect(await diag(popup)).toEqual(['carrier-restored']) + await ping(page, 6) + await expectPong(page, 6) +}) + +test('[POPUP-CONNECTION-002] [POPUP-CONNECTION-005] direct navigation into isolation without a carrier fails closed', async ({ + page, +}) => { + const id = freshId() + const { popup } = await open(page, { id, href: `${POPUP}/isolated#c=${id}` }) + await expect(popup.locator('#status')).toHaveText('failed: fallback-unavailable') + expect(await diag(popup)).toEqual(['fallback-unavailable']) + await page.waitForTimeout(300) + expect(await diag(page)).toEqual(['window-opened', 'control-direct']) +}) + +test('[POPUP-CONTROL-002] malformed navigation fails before any browser operation', async ({ + page, +}) => { + const { popup } = await open(page) + await expectPong(page, 0) + for (const bad of [ + 'http://popup.localtest.me:4583/p', + '/p', + 'https://u:p@popup.localtest.me:4583/p', + ]) { + await expect(navigate(page, bad)).rejects.toThrow() + } + expect(popup.url()).toContain('/p#') + expect((await diag(page)).filter((c) => c === 'control-rejected')).toHaveLength(3) +}) diff --git a/ts/packages/popup/e2e/server.mjs b/ts/packages/popup/e2e/server.mjs new file mode 100644 index 00000000..4e72b0c7 --- /dev/null +++ b/ts/packages/popup/e2e/server.mjs @@ -0,0 +1,154 @@ +// Three genuinely cross-site origins: an allowed application, an unlisted +// application, and the popup origin with a participating page, an isolated +// participating page, a non-participating page, and the worker script. +// The page scripts are the smallest caller protocol that exercises every +// documented path; they own nothing the package cares about. + +import { readFileSync } from 'node:fs' +import { createServer } from 'node:https' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { makeCertificate } from './tls.mjs' + +export const ORIGINS = { + appA: 'https://app-a.lvh.me:4581', + appB: 'https://app-b.local.gd:4582', + popup: 'https://popup.localtest.me:4583', +} + +const dist = join(dirname(fileURLToPath(import.meta.url)), 'dist') +const popupModule = readFileSync(join(dist, 'popup.js')) +const workerModule = readFileSync(join(dist, 'sw.js')) + +// Test protocol: Ping (app → popup), Pong (popup → app), Go (app → popup, +// asks the popup to navigate itself). +const protocol = ` + const message = (type, decode) => ({ type, decode }) + const Ping = message('ping', (v) => { if (typeof v.n !== 'number') throw new Error('ping'); return v }) + const Pong = message('pong', (v) => { if (typeof v.n !== 'number') throw new Error('pong'); return v }) + const Go = message('go', (v) => { if (typeof v.url !== 'string') throw new Error('go'); return v }) + window.__events = [] + window.__diag = [] + const onDiagnostic = (d) => window.__diag.push(d.code) +` + +const html = (body) => `popup e2e${body}` + +const appPage = html(` + open + +`) + +const popupPage = html(` +

popup

+ +`) + +// Non-participating: like a provider page, it eventually sends the user +// back to a participating document without touching the package. +const externalPage = html(` +

external

+ +`) + +const send = (res, status, headers, body) => { + res.writeHead(status, { 'Cache-Control': 'no-store', ...headers }) + res.end(body) +} + +const HTML = { 'Content-Type': 'text/html; charset=utf-8' } +const JS = { 'Content-Type': 'text/javascript; charset=utf-8' } +const ISOLATED = { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp', +} + +function popupHandler(req, res) { + const url = new URL(req.url, ORIGINS.popup) + switch (url.pathname) { + case '/health': + return send(res, 200, {}, 'ok') + case '/popup.js': + return send(res, 200, JS, popupModule) + case '/sw.js': + return send(res, 200, { ...JS, 'Service-Worker-Allowed': '/' }, workerModule) + case '/p': + return send(res, 200, { ...HTML, 'Cross-Origin-Opener-Policy': 'unsafe-none' }, popupPage) + case '/isolated': + return send(res, 200, { ...HTML, ...ISOLATED }, popupPage) + case '/external': + return send(res, 200, HTML, externalPage) + default: + return send(res, 404, {}, '') + } +} + +function appHandler(req, res) { + const url = new URL(req.url, ORIGINS.appA) + if (url.pathname === '/popup.js') return send(res, 200, JS, popupModule) + if (url.pathname === '/') return send(res, 200, HTML, appPage) + return send(res, 404, {}, '') +} + +const tls = makeCertificate(Object.values(ORIGINS).map((origin) => new URL(origin).hostname)) +for (const [origin, handler] of [ + [ORIGINS.popup, popupHandler], + [ORIGINS.appA, appHandler], + [ORIGINS.appB, appHandler], +]) { + createServer(tls, handler).listen(Number(new URL(origin).port)) +} diff --git a/ts/packages/popup/e2e/sw-entry.ts b/ts/packages/popup/e2e/sw-entry.ts new file mode 100644 index 00000000..1f08cb6c --- /dev/null +++ b/ts/packages/popup/e2e/sw-entry.ts @@ -0,0 +1,5 @@ +// The popup origin's Service Worker script for the e2e server: exactly what +// a host composes, and nothing else. +import { installPortKeeper } from '../src/worker.js' + +installPortKeeper() diff --git a/ts/packages/popup/e2e/tls.mjs b/ts/packages/popup/e2e/tls.mjs new file mode 100644 index 00000000..089bf2fe --- /dev/null +++ b/ts/packages/popup/e2e/tls.mjs @@ -0,0 +1,38 @@ +// Per-run self-signed certificate covering every e2e hostname, so the +// multi-origin topology is genuinely cross-site over HTTPS (the only way +// COOP and opener severing behave realistically). Playwright runs with +// ignoreHTTPSErrors; nothing here is a production artifact. + +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +export function makeCertificate(hostnames) { + const dir = mkdtempSync(join(tmpdir(), 'popup-e2e-tls-')) + const key = join(dir, 'key.pem') + const cert = join(dir, 'cert.pem') + const sans = hostnames.map((h) => `DNS:${h}`).join(',') + execFileSync( + 'openssl', + [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-nodes', + '-keyout', + key, + '-out', + cert, + '-days', + '2', + '-subj', + '/CN=popup-e2e', + '-addext', + `subjectAltName=${sans}`, + ], + { stdio: 'ignore' }, + ) + return { key: readFileSync(key), cert: readFileSync(cert) } +} diff --git a/ts/packages/popup/playwright.config.ts b/ts/packages/popup/playwright.config.ts new file mode 100644 index 00000000..b4b9c3ca --- /dev/null +++ b/ts/packages/popup/playwright.config.ts @@ -0,0 +1,37 @@ +import { defineConfig, devices } from '@playwright/test' + +// The same five-project matrix the ceremony package qualifies against. +// Serial workers: every test drives one popup per page. +export default defineConfig({ + testDir: './e2e', + workers: 1, + fullyParallel: false, + timeout: 60_000, + reporter: 'list', + use: { ignoreHTTPSErrors: true }, + webServer: { + command: 'node e2e/build.mjs && node e2e/server.mjs', + url: 'https://popup.localtest.me:4583/health', + ignoreHTTPSErrors: true, + reuseExistingServer: false, + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + launchOptions: { args: ['--ignore-certificate-errors'] }, + }, + }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } }, + { + name: 'mobile-chrome', + use: { + ...devices['Pixel 7'], + launchOptions: { args: ['--ignore-certificate-errors'] }, + }, + }, + { name: 'mobile-webkit', use: { ...devices['iPhone 15'] } }, + ], +}) diff --git a/ts/packages/popup/src/connection.test.ts b/ts/packages/popup/src/connection.test.ts index edffefad..e533361c 100644 --- a/ts/packages/popup/src/connection.test.ts +++ b/ts/packages/popup/src/connection.test.ts @@ -71,7 +71,7 @@ function acceptPopup( const view = opts.opener === false ? { ...pair.popupWindow, opener: null } : pair.popupWindow const popup = new CurrentWindow( view as Window, - opts.worker === undefined ? Promise.resolve(undefined) : registrationWith(opts.worker), + opts.worker === undefined ? () => Promise.resolve(undefined) : registrationWith(opts.worker), ) const connection = PopupConnection.accept(popup, { connectionId: ID, @@ -99,7 +99,7 @@ describe('validation [POPUP-CONNECTION-007]', () => { PopupConnection.connect(popup, { connectionId: ID, popupOrigin: `${POPUP_ORIGIN}/` }), ).toThrow(TypeError) expect(pair.appView.listeners.size).toBe(0) - const current = new CurrentWindow(pair.popupWindow, Promise.resolve(undefined)) + const current = new CurrentWindow(pair.popupWindow, () => Promise.resolve(undefined)) await expect( PopupConnection.accept(current, { connectionId: 'nope', @@ -115,7 +115,7 @@ describe('validation [POPUP-CONNECTION-007]', () => { it('requires the matching PopupWindow kind and one connect per object', () => { const pair = fakePair() const popup = new OpenedWindow(pair.popupProxy as unknown as WindowProxy, pair.appView) - const current = new CurrentWindow(pair.popupWindow, Promise.resolve(undefined)) + const current = new CurrentWindow(pair.popupWindow, () => Promise.resolve(undefined)) expect(() => PopupConnection.connect(current, { connectionId: ID, popupOrigin: POPUP_ORIGIN }), ).toThrow(TypeError) @@ -430,8 +430,7 @@ describe('fallback seam [POPUP-CONNECTION-002/004/005] [POPUP-DIAGNOSTIC-003]', const pair = fakePair() const [appCarrier, popupCarrier] = carrierPair() const fallback = vi.fn(() => Promise.resolve(popupCarrier)) - const popup = new CurrentWindow( - { ...pair.popupWindow, opener: null } as Window, + const popup = new CurrentWindow({ ...pair.popupWindow, opener: null } as Window, () => Promise.resolve(undefined), ) const events: PopupDiagnostic[] = [] diff --git a/ts/packages/popup/src/connection.ts b/ts/packages/popup/src/connection.ts index d0709d56..a313a6e7 100644 --- a/ts/packages/popup/src/connection.ts +++ b/ts/packages/popup/src/connection.ts @@ -12,7 +12,7 @@ import { type Reporter, reportUndeliverable, } from './diagnostics.js' -import { PortKeeper } from './keeper.js' +import { activeWorker, PortKeeper } from './keeper.js' import { canonicalOrigin, type Carrier, @@ -290,7 +290,6 @@ class PopupEndpoint extends Endpoint { private constructor( private readonly popup: CurrentWindow, private readonly connectionId: string, - private readonly keeper: PortKeeper | null, report: Reporter, ) { super(report) @@ -312,12 +311,12 @@ class PopupEndpoint extends Endpoint { } const controller = new AbortController() - let keeper: PortKeeper | null = null let carrier: Carrier | null = null - const registration = await popup.registration + // A preserved port can only be held by an already active worker. + const registration = await popup.registration() if (registration?.active) { - keeper = new PortKeeper(registration.active) + const keeper = new PortKeeper(registration.active) const port = await keeper.claim(connectionId).catch((error: unknown) => { report('claim-failed') throw error @@ -364,7 +363,7 @@ class PopupEndpoint extends Endpoint { report('carrier-fallback') } - const endpoint = new PopupEndpoint(popup, connectionId, keeper, report) + const endpoint = new PopupEndpoint(popup, connectionId, report) endpoint.install(carrier) return endpoint } @@ -394,14 +393,16 @@ class PopupEndpoint extends Endpoint { /** Preserves the port through the keeper, then replaces this document. */ private async replaceDocument(url: string): Promise { - if (!(this.carrier instanceof PortCarrier) || !this.keeper) { + const registration = await this.popup.registration() + const worker = registration ? await activeWorker(registration) : null + if (this.closed || !(this.carrier instanceof PortCarrier) || !worker) { this.fail('continuity-unsupported') throw failure('continuity-unsupported') } const port = this.carrier.detach() const startedAt = performance.now() try { - await this.keeper.keep(this.connectionId, port) + await new PortKeeper(worker).keep(this.connectionId, port) } catch { this.fail('keep-failed') throw failure('keep-failed') diff --git a/ts/packages/popup/src/connection/README.md b/ts/packages/popup/src/connection/README.md deleted file mode 100644 index f554409b..00000000 --- a/ts/packages/popup/src/connection/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Popup connection - -See the [popup connection architecture and specification](../../docs/connection.md) -and [popup control specification](../../docs/control.md). - -Suggested implementation shape: - -```text -index.ts Internal module boundary. -connection.ts Logical connection, carrier selection, and lifecycle. -``` - -This split is non-normative; change it if implementation reveals a cleaner -boundary. diff --git a/ts/packages/popup/src/keeper.ts b/ts/packages/popup/src/keeper.ts index 5706e9e9..6e28c403 100644 --- a/ts/packages/popup/src/keeper.ts +++ b/ts/packages/popup/src/keeper.ts @@ -41,6 +41,32 @@ export interface KeeperWorker { postMessage(message: unknown, transfer: Transferable[]): void } +/** + * The registration's active worker, waiting briefly for one still + * installing (the host registers in the first participating document). + */ +export function activeWorker( + registration: ServiceWorkerRegistration, + timeoutMs = KEEPER_REPLY_TIMEOUT_MS, +): Promise { + if (registration.active) return Promise.resolve(registration.active) + const worker = registration.installing ?? registration.waiting + if (!worker) return Promise.resolve(null) + return new Promise((resolve) => { + const finish = (value: ServiceWorker | null): void => { + clearTimeout(timer) + worker.removeEventListener('statechange', onChange) + resolve(value) + } + const onChange = (): void => { + if (worker.state === 'activated') finish(worker) + else if (worker.state === 'redundant') finish(null) + } + const timer = setTimeout(() => finish(null), timeoutMs) + worker.addEventListener('statechange', onChange) + }) +} + export class PortKeeper { constructor(private readonly worker: KeeperWorker) {} diff --git a/ts/packages/popup/src/message-port/README.md b/ts/packages/popup/src/message-port/README.md deleted file mode 100644 index ed64c84a..00000000 --- a/ts/packages/popup/src/message-port/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# MessagePort carrier - -See the [MessagePort carrier architecture and specification](../../docs/message-port.md). - -Suggested implementation shape: - -```text -index.ts MessagePort authentication and carrier adapter. -keeper.ts Short-lived port preservation across navigation. -worker.ts Service Worker entry point for port transfer. -``` - -This split is non-normative; change it if implementation reveals a cleaner -boundary. diff --git a/ts/packages/popup/src/testing/fakes.ts b/ts/packages/popup/src/testing/fakes.ts index 4af929af..0385f04f 100644 --- a/ts/packages/popup/src/testing/fakes.ts +++ b/ts/packages/popup/src/testing/fakes.ts @@ -144,5 +144,5 @@ export function fakeScope(origin = POPUP_ORIGIN): FakeScope { } } -export const registrationWith = (worker: KeeperWorker | null) => +export const registrationWith = (worker: KeeperWorker | null) => () => Promise.resolve({ active: worker } as unknown as ServiceWorkerRegistration) diff --git a/ts/packages/popup/src/window.ts b/ts/packages/popup/src/window.ts index e8fd2fde..d3294038 100644 --- a/ts/packages/popup/src/window.ts +++ b/ts/packages/popup/src/window.ts @@ -24,11 +24,11 @@ export class PopupWindow { /** Adopts the current popup document; creates nothing. */ static current(): PopupWindow { - const registration = + return new CurrentWindow(window, () => typeof navigator !== 'undefined' && navigator.serviceWorker ? navigator.serviceWorker.getRegistration().catch(() => undefined) - : Promise.resolve(undefined) - return new CurrentWindow(window, registration) + : Promise.resolve(undefined), + ) } get opened(): boolean { @@ -81,7 +81,8 @@ export class OpenedWindow extends PopupWindow { export class CurrentWindow extends PopupWindow { constructor( readonly view: Window, - readonly registration: Promise, + /** The registration whose scope matches this document, resolved per use. */ + readonly registration: () => Promise, ) { super() } diff --git a/ts/packages/popup/tsconfig.e2e.json b/ts/packages/popup/tsconfig.e2e.json new file mode 100644 index 00000000..b2b6f01f --- /dev/null +++ b/ts/packages/popup/tsconfig.e2e.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": ["e2e/**/*.ts", "playwright.config.ts"] +} From afb544fdc6ff4a512f25c5b70eb12ff10fa8fa3e Mon Sep 17 00:00:00 2001 From: Wondertan Date: Wed, 2 Sep 2026 23:51:50 +0100 Subject: [PATCH 04/24] docs(popup): record implementation coverage and keeper timing TEST_PLAN.md states which rows the unit and browser suites cover, which are deferred with WebRTC, and which remain real-device gates. message-port.md documents the keeper reply deadline and the per-use registration lookup with its wait for an installing worker. README gains a testing section. Assisted-by: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw Signed-off-by: Wondertan --- ts/packages/popup/README.md | 10 ++++++++++ ts/packages/popup/TEST_PLAN.md | 22 ++++++++++++++++++++++ ts/packages/popup/docs/message-port.md | 8 ++++++++ 3 files changed, 40 insertions(+) diff --git a/ts/packages/popup/README.md b/ts/packages/popup/README.md index e8058a42..04ddcd97 100644 --- a/ts/packages/popup/README.md +++ b/ts/packages/popup/README.md @@ -200,3 +200,13 @@ WebRTC application constructor closes over its own signaling and ICE configuration. Its popup-side factory eagerly consumes package-owned navigation metadata and returns the later constructor without starting RTC. Callers do not manage carrier selection, replacement, or lifetime. + +## Testing + +`pnpm test` runs the unit suite in Node over real `MessageChannel` ports. +`pnpm test:e2e` builds the package and its worker entry, serves three +cross-site HTTPS origins, and drives the Playwright matrix (Chromium, Firefox, +WebKit, mobile Chrome, mobile WebKit) through both creation paths, isolation +round trips over one preserved port, port expiry, and every fail-closed path. +[TEST_PLAN.md](TEST_PLAN.md) records which rows each layer covers and which +remain deferred or manual. diff --git a/ts/packages/popup/TEST_PLAN.md b/ts/packages/popup/TEST_PLAN.md index 8093b5ce..db9b5bad 100644 --- a/ts/packages/popup/TEST_PLAN.md +++ b/ts/packages/popup/TEST_PLAN.md @@ -3,6 +3,23 @@ These tests qualify popup lifecycle, connection, carriers, continuity, and control independently of any protocol transported by the package. +Coverage status: + +- **Unit** (`pnpm test`, vitest in Node over real `MessageChannel` ports with + in-memory window and worker-scope fakes): every API, WINDOW, CONTROL, + CONNECTION, PORT, KEEPER, and DIAGNOSTIC row except the clauses below. +- **Browser** (`pnpm test:e2e`, Playwright on Chromium, Firefox, WebKit, + mobile Chrome, and mobile WebKit emulation over three cross-site HTTPS + origins): popup creation on both paths, opener authentication, connected + navigation into and out of a COOP-isolated document over one preserved + port, close after the opener is severed, port expiry across a + non-participating hop, and every fail-closed path. +- **Deferred**: every POPUP-RTC row; no WebRTC carrier exists yet. +- **Manual**: POPUP-BROWSER-001/002, the delayed-activation clause of + POPUP-WINDOW-003 (Playwright runs no popup blocker), the suspension clause + of POPUP-CONNECTION-006, and the suspension and process-loss clauses of + POPUP-KEEPER-003. + ## API and message delivery | ID | Assertion | @@ -53,6 +70,8 @@ control independently of any protocol transported by the package. ## WebRTC fallback +Deferred until the WebRTC carrier is implemented; no row below is claimed. + | ID | Assertion | |---|---| | POPUP-RTC-001 | `connect` invokes the application fallback exactly once and starts bounded one-use answerer round zero before the first popup navigation. It keeps that round armed across any number of MessagePort-selected documents until consumed, failed, abandoned, or connection closure; no later fallback invocation can restart round zero. The destination popup constructor creates the offerer only after fallback selection. Each RTC replacement creates fresh peers, offer, answer, and ICE state under the same logical connection ID and exactly incremented round. | @@ -66,6 +85,9 @@ control independently of any protocol transported by the package. ## Browser qualification +Real-device gates; the Playwright projects are emulation only and do not +satisfy these rows. + | ID | Assertion | |---|---| | POPUP-BROWSER-001 | Chromium, Gecko, and WebKit qualification covers Android, iOS, Linux, macOS, and Windows, including foreground popup work while the application tab is hidden or suspended. | diff --git a/ts/packages/popup/docs/message-port.md b/ts/packages/popup/docs/message-port.md index f8ec779d..5f24786c 100644 --- a/ts/packages/popup/docs/message-port.md +++ b/ts/packages/popup/docs/message-port.md @@ -247,6 +247,14 @@ of it. Worker loss or a failed `keep` acknowledgement prevents navigation with live state. No `BroadcastChannel`, cookie, IndexedDB record, request, or URL carries the port. +Each operation carries its own reply `MessagePort` and waits at most +`KEEPER_REPLY_TIMEOUT_MS = 2_000` for the worker's answer. The popup resolves +the matching registration when it needs it rather than once at construction: +the host may register the worker in the same document after `accept` has +started, so preservation waits up to the reply deadline for a worker that is +still installing and fails closed if none activates. A claim only ever asks +an already active worker, since only that worker can hold a port. + ## API The application starts listening before the popup endpoint is ready: From 4dab745172fa994c1b19cbe185272e728444e329 Mon Sep 17 00:00:00 2001 From: Wondertan Date: Thu, 3 Sep 2026 12:34:09 +0100 Subject: [PATCH 05/24] fix(popup): harden record checks, send failure, and window guards - `isRecord` accepts only plain or null-prototype objects. - A carrier that rejects a value fails the logical connection; the `Navigate` control goes through the same guard as caller messages. - `PopupWindow.current` rejects an embedded document, and a handle or opener whose `closed` throws counts as absent. Assisted-by: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw Signed-off-by: Wondertan --- ts/packages/popup/src/connection.test.ts | 32 ++++++++++++++++++++++++ ts/packages/popup/src/connection.ts | 14 +++++++++-- ts/packages/popup/src/message.test.ts | 3 +++ ts/packages/popup/src/message.ts | 4 ++- ts/packages/popup/src/window.ts | 14 +++++++++-- 5 files changed, 62 insertions(+), 5 deletions(-) diff --git a/ts/packages/popup/src/connection.test.ts b/ts/packages/popup/src/connection.test.ts index e533361c..9f028469 100644 --- a/ts/packages/popup/src/connection.test.ts +++ b/ts/packages/popup/src/connection.test.ts @@ -130,6 +130,26 @@ describe('validation [POPUP-CONNECTION-007]', () => { expect(() => PopupWindow.open(target)).toThrow(TypeError) } }) + + it('PopupWindow.current rejects an embedded document', () => { + const frame = { top: {} } + vi.stubGlobal('window', frame) + expect(() => PopupWindow.current()).toThrow('top-level popup') + vi.unstubAllGlobals() + }) + + it('treats inaccessible popup handles and openers as absent', () => { + const inaccessible = Object.defineProperty({}, 'closed', { + get: () => { + throw new DOMException('discarded') + }, + }) as WindowProxy + expect(new OpenedWindow(inaccessible, fakePair().appView).direct).toBe(false) + expect( + new CurrentWindow({ opener: inaccessible } as Window, () => Promise.resolve(undefined)) + .opener, + ).toBeNull() + }) }) describe('MessagePort selection and delivery', () => { @@ -173,6 +193,18 @@ describe('MessagePort selection and delivery', () => { expect(codes(app.events)).toContain('send-unavailable') }) + it('closes the logical connection when its active carrier rejects a send', async () => { + const pair = fakePair() + connectApp(pair) + const side = acceptPopup(pair) + const popup = await side.connection + await tick() + + expect(() => popup.send({ type: 'start', uncloneable: () => {} } as never)).toThrow() + expect(codes(side.events)).toContain('connection-failed') + expect(() => popup.send(new Start())).toThrow('send-unavailable') + }) + it('closes on unknown, malformed, or decoder-rejected input [POPUP-API-003]', async () => { const error = vi.spyOn(console, 'error').mockImplementation(() => {}) for (const bad of [ diff --git a/ts/packages/popup/src/connection.ts b/ts/packages/popup/src/connection.ts index a313a6e7..bf24a34d 100644 --- a/ts/packages/popup/src/connection.ts +++ b/ts/packages/popup/src/connection.ts @@ -128,11 +128,21 @@ abstract class Endpoint implements PopupConnection { if (isReservedType(message.type)) { throw new TypeError(`"${message.type}" is a reserved discriminator`) } + this.transmit(message) + } + + /** Sends over the active carrier; a carrier that rejects the value fails the connection. */ + protected transmit(value: Message): void { if (this.closed || !this.carrier) { this.report('send-unavailable') throw failure('send-unavailable') } - this.carrier.send(message) + try { + this.carrier.send(value) + } catch (error) { + this.release('connection-failed') + throw error + } } on(message: MessageType, handler: (message: N) => void): () => void { @@ -248,7 +258,7 @@ class ApplicationEndpoint extends Endpoint { requireHttpsUrl(url, this.report) if (this.carrier) { const control: Navigate = { type: 'navigate', url } - this.carrier.send(control) + this.transmit(control) this.report('control-connected') return } diff --git a/ts/packages/popup/src/message.test.ts b/ts/packages/popup/src/message.test.ts index 44398986..21838158 100644 --- a/ts/packages/popup/src/message.test.ts +++ b/ts/packages/popup/src/message.test.ts @@ -78,6 +78,7 @@ describe('controls [POPUP-CONTROL-004]', () => { describe('routing type', () => { it('reads a bounded string type from a plain record', () => { expect(routingType({ type: 'ready' })).toBe('ready') + expect(routingType(Object.assign(Object.create(null), { type: 'ready' }))).toBe('ready') expect(routingType({ type: 'x'.repeat(MAX_TYPE_LENGTH) })).toHaveLength(MAX_TYPE_LENGTH) for (const bad of [ { type: 'x'.repeat(MAX_TYPE_LENGTH + 1) }, @@ -85,6 +86,8 @@ describe('routing type', () => { { type: 1 }, {}, [], + new Date(), + Object.assign(Object.create({ type: 'ready' }), { type: 'ready' }), null, 'ready', ]) { diff --git a/ts/packages/popup/src/message.ts b/ts/packages/popup/src/message.ts index 09c15009..6c679d8c 100644 --- a/ts/packages/popup/src/message.ts +++ b/ts/packages/popup/src/message.ts @@ -40,7 +40,9 @@ export function isReservedType(type: string): boolean { } export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null } /** Exact-shape gate: the record owns exactly the listed keys. */ diff --git a/ts/packages/popup/src/window.ts b/ts/packages/popup/src/window.ts index d3294038..9f3b97a0 100644 --- a/ts/packages/popup/src/window.ts +++ b/ts/packages/popup/src/window.ts @@ -10,6 +10,15 @@ export interface View { removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void } +function usable(handle: WindowProxy | null): handle is WindowProxy { + if (handle === null) return false + try { + return !handle.closed + } catch { + return false + } +} + export class PopupWindow { /** @internal */ protected constructor() {} @@ -24,6 +33,7 @@ export class PopupWindow { /** Adopts the current popup document; creates nothing. */ static current(): PopupWindow { + if (window.top !== window) throw new TypeError('current requires a top-level popup document') return new CurrentWindow(window, () => typeof navigator !== 'undefined' && navigator.serviceWorker ? navigator.serviceWorker.getRegistration().catch(() => undefined) @@ -56,7 +66,7 @@ export class OpenedWindow extends PopupWindow { /** Direct control: a retained handle that does not report closed. */ get direct(): boolean { - return this.handle !== null && !this.handle.closed + return usable(this.handle) } bind(source: WindowProxy): void { @@ -94,6 +104,6 @@ export class CurrentWindow extends PopupWindow { /** The opener while it is usable; a closed opener counts as absent. */ get opener(): WindowProxy | null { const opener = this.view.opener as WindowProxy | null - return opener !== null && !opener.closed ? opener : null + return usable(opener) ? opener : null } } From caf9f4743557f81117feece2b896c91b16ccf48c Mon Sep 17 00:00:00 2001 From: Wondertan Date: Thu, 3 Sep 2026 13:42:27 +0100 Subject: [PATCH 06/24] feat(popup): admit a set of popup origins and rebind across origins `connect` takes `allowedPopupOrigins`; both endpoints copy a nonempty, duplicate-free set of canonical HTTPS origins and reject anything else. The application accepts a handshake from any member and answers the exact origin the browser stamped on it, so sequential popup documents may sit on different origins or sites under one connection. A cross-origin replacement cannot keep the port in the source origin's worker, so the popup endpoint retires and the destination authenticates a fresh carrier through its opener or fallback. Unit tests cover the origin set and the retirement; the e2e server gains a second popup origin on another site with a participating hop that re-handshakes and an isolated hop that fails closed without a fallback. Assisted-by: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw Signed-off-by: Wondertan --- ts/packages/popup/TEST_PLAN.md | 3 +- ts/packages/popup/e2e/popup.spec.ts | 36 ++++++++++ ts/packages/popup/e2e/server.mjs | 8 ++- ts/packages/popup/src/connection.test.ts | 84 +++++++++++++++++++++--- ts/packages/popup/src/connection.ts | 35 +++++----- ts/packages/popup/src/message.test.ts | 22 +++++++ ts/packages/popup/src/message.ts | 18 +++++ ts/packages/popup/src/port.test.ts | 2 +- ts/packages/popup/src/port.ts | 9 +-- ts/packages/popup/src/testing/fakes.ts | 49 +++++++++++--- 10 files changed, 225 insertions(+), 41 deletions(-) diff --git a/ts/packages/popup/TEST_PLAN.md b/ts/packages/popup/TEST_PLAN.md index db9b5bad..d5d3a960 100644 --- a/ts/packages/popup/TEST_PLAN.md +++ b/ts/packages/popup/TEST_PLAN.md @@ -12,7 +12,8 @@ Coverage status: mobile Chrome, and mobile WebKit emulation over three cross-site HTTPS origins): popup creation on both paths, opener authentication, connected navigation into and out of a COOP-isolated document over one preserved - port, close after the opener is severed, port expiry across a + port, a cross-site hop between participating documents that re-handshakes + over the opener, close after the opener is severed, port expiry across a non-participating hop, and every fail-closed path. - **Deferred**: every POPUP-RTC row; no WebRTC carrier exists yet. - **Manual**: POPUP-BROWSER-001/002, the delayed-activation clause of diff --git a/ts/packages/popup/e2e/popup.spec.ts b/ts/packages/popup/e2e/popup.spec.ts index 0ef553bf..f567287e 100644 --- a/ts/packages/popup/e2e/popup.spec.ts +++ b/ts/packages/popup/e2e/popup.spec.ts @@ -8,6 +8,7 @@ import { expect, type Page, test } from '@playwright/test' const APP_A = 'https://app-a.lvh.me:4581' const APP_B = 'https://app-b.local.gd:4582' const POPUP = 'https://popup.localtest.me:4583' +const POPUP_B = 'https://popup-b.lvh.me:4584' const freshId = () => crypto.randomUUID() @@ -266,3 +267,38 @@ test('[POPUP-CONTROL-002] malformed navigation fails before any browser operatio expect(popup.url()).toContain('/p#') expect((await diag(page)).filter((c) => c === 'control-rejected')).toHaveLength(3) }) + +test('[POPUP-CONNECTION-008] [POPUP-CONNECTION-009] a cross-site participating hop re-handshakes over the opener', async ({ + page, +}) => { + const { id, popup } = await open(page) + await expectPong(page, 0) + await popup.evaluate(() => navigator.serviceWorker.ready) + + await nextDocument(popup, () => navigate(page, `${POPUP_B}/p#c=${id}`)) + await expect(popup.locator('#status')).toHaveText('connected') + expect(popup.url()).toContain(POPUP_B) + // No registration on the new origin yet: no claim, a fresh opener handshake. + expect(await diag(popup)).toEqual(['carrier-message-port']) + await ping(page, 7) + expect(await expectPong(page, 7)).toMatchObject({ path: '/p' }) + + // Back to the first origin: its worker holds nothing for this id. + await nextDocument(popup, () => navigate(page, `${POPUP}/p#c=${id}`)) + await expect(popup.locator('#status')).toHaveText('connected') + expect(await diag(popup)).toEqual(['claim-empty', 'carrier-message-port']) + await ping(page, 8) + await expectPong(page, 8) + expect((await diag(page)).filter((c) => c === 'carrier-message-port')).toHaveLength(3) +}) + +test('[POPUP-CONNECTION-008] a cross-site isolated destination needs a fallback', async ({ + page, +}) => { + const { id, popup } = await open(page) + await expectPong(page, 0) + await popup.evaluate(() => navigator.serviceWorker.ready) + await nextDocument(popup, () => navigate(page, `${POPUP_B}/isolated#c=${id}`)) + await expect(popup.locator('#status')).toHaveText('failed: fallback-unavailable') + expect(await diag(popup)).toEqual(['fallback-unavailable']) +}) diff --git a/ts/packages/popup/e2e/server.mjs b/ts/packages/popup/e2e/server.mjs index 4e72b0c7..eb9e7ae6 100644 --- a/ts/packages/popup/e2e/server.mjs +++ b/ts/packages/popup/e2e/server.mjs @@ -10,10 +10,13 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { makeCertificate } from './tls.mjs' +// Two popup origins on different sites so a participating document can be +// replaced by one on another site. export const ORIGINS = { appA: 'https://app-a.lvh.me:4581', appB: 'https://app-b.local.gd:4582', popup: 'https://popup.localtest.me:4583', + popupB: 'https://popup-b.lvh.me:4584', } const dist = join(dirname(fileURLToPath(import.meta.url)), 'dist') @@ -47,7 +50,7 @@ const appPage = html(` const popupWindow = PopupWindow.open(anchor.target) const connection = PopupConnection.connect(popupWindow, { connectionId: window.__id, - popupOrigin: '${ORIGINS.popup}', + allowedPopupOrigins: ['${ORIGINS.popup}', '${ORIGINS.popupB}'], onDiagnostic, }) connection.on(Pong, (pong) => window.__events.push(pong)) @@ -118,7 +121,7 @@ const ISOLATED = { } function popupHandler(req, res) { - const url = new URL(req.url, ORIGINS.popup) + const url = new URL(req.url, 'https://popup.invalid') switch (url.pathname) { case '/health': return send(res, 200, {}, 'ok') @@ -147,6 +150,7 @@ function appHandler(req, res) { const tls = makeCertificate(Object.values(ORIGINS).map((origin) => new URL(origin).hostname)) for (const [origin, handler] of [ [ORIGINS.popup, popupHandler], + [ORIGINS.popupB, popupHandler], [ORIGINS.appA, appHandler], [ORIGINS.appB, appHandler], ]) { diff --git a/ts/packages/popup/src/connection.test.ts b/ts/packages/popup/src/connection.test.ts index 9f028469..05d0e343 100644 --- a/ts/packages/popup/src/connection.test.ts +++ b/ts/packages/popup/src/connection.test.ts @@ -51,7 +51,7 @@ function connectApp(pair: FakePair, opts: { blocked?: boolean; fallback?: Carrie ) const connection = PopupConnection.connect(popup, { connectionId: ID, - popupOrigin: POPUP_ORIGIN, + allowedPopupOrigins: [POPUP_ORIGIN], onDiagnostic: (e) => void side.events.push(e), ...(opts.fallback && { fallback: () => Promise.resolve(opts.fallback as Carrier) }), }) @@ -93,10 +93,16 @@ describe('validation [POPUP-CONNECTION-007]', () => { const pair = fakePair() const popup = new OpenedWindow(pair.popupProxy as unknown as WindowProxy, pair.appView) expect(() => - PopupConnection.connect(popup, { connectionId: ID.toUpperCase(), popupOrigin: POPUP_ORIGIN }), + PopupConnection.connect(popup, { + connectionId: ID.toUpperCase(), + allowedPopupOrigins: [POPUP_ORIGIN], + }), ).toThrow(TypeError) expect(() => - PopupConnection.connect(popup, { connectionId: ID, popupOrigin: `${POPUP_ORIGIN}/` }), + PopupConnection.connect(popup, { + connectionId: ID, + allowedPopupOrigins: [`${POPUP_ORIGIN}/`], + }), ).toThrow(TypeError) expect(pair.appView.listeners.size).toBe(0) const current = new CurrentWindow(pair.popupWindow, () => Promise.resolve(undefined)) @@ -117,11 +123,11 @@ describe('validation [POPUP-CONNECTION-007]', () => { const popup = new OpenedWindow(pair.popupProxy as unknown as WindowProxy, pair.appView) const current = new CurrentWindow(pair.popupWindow, () => Promise.resolve(undefined)) expect(() => - PopupConnection.connect(current, { connectionId: ID, popupOrigin: POPUP_ORIGIN }), + PopupConnection.connect(current, { connectionId: ID, allowedPopupOrigins: [POPUP_ORIGIN] }), ).toThrow(TypeError) - PopupConnection.connect(popup, { connectionId: ID, popupOrigin: POPUP_ORIGIN }) + PopupConnection.connect(popup, { connectionId: ID, allowedPopupOrigins: [POPUP_ORIGIN] }) expect(() => - PopupConnection.connect(popup, { connectionId: ID, popupOrigin: POPUP_ORIGIN }), + PopupConnection.connect(popup, { connectionId: ID, allowedPopupOrigins: [POPUP_ORIGIN] }), ).toThrow('already connected') }) @@ -239,7 +245,7 @@ describe('MessagePort selection and delivery', () => { const eventsB: PopupDiagnostic[] = [] const connB = PopupConnection.connect(appB, { connectionId: OTHER_ID, - popupOrigin: POPUP_ORIGIN, + allowedPopupOrigins: [POPUP_ORIGIN], onDiagnostic: (e) => void eventsB.push(e), }) const readyA = vi.fn() @@ -399,6 +405,68 @@ describe('controls [POPUP-CONTROL-001/002/003/004]', () => { }) }) +describe('cross-origin replacement [POPUP-CONNECTION-008/009]', () => { + const OTHER_POPUP = 'https://popup-b.example' + + /** An application admitting two popup origins over a pair on the given one. */ + function connectMulti(pair: FakePair) { + const events: PopupDiagnostic[] = [] + const popup = new OpenedWindow(pair.popupProxy as unknown as WindowProxy, pair.appView) + const connection = PopupConnection.connect(popup, { + connectionId: ID, + allowedPopupOrigins: [POPUP_ORIGIN, OTHER_POPUP], + onDiagnostic: (e) => void events.push(e), + }) + return { connection, popup, events } + } + + it('retires the popup endpoint instead of keeping the port, then the next origin re-handshakes', async () => { + const pair = fakePair() + const app = connectMulti(pair) + const scope = fakeScope() + const first = acceptPopup(pair, { worker: scope.worker }) + const popup = await first.connection + await tick() + + await app.connection.navigate(`${OTHER_POPUP}/p`) + await tick(20) + expect(scope.pending).toHaveLength(0) // no keep + expect(pair.popupProxy.replaced).toEqual([`${OTHER_POPUP}/p`]) + expect(codes(first.events).at(-1)).toBe('connection-closed') + expect(() => popup.send(new Ready(1))).toThrow('send-unavailable') + + // The destination document on the other origin authenticates afresh over + // the same opener; the application accepts it under the same connection. + pair.relocate(OTHER_POPUP) + const readies: number[] = [] + app.connection.on(Ready, (r) => void readies.push(r.version)) + const nextSide = acceptPopup(pair) + const nextConnection = await nextSide.connection + await tick() + expect(codes(nextSide.events)).toEqual(['carrier-message-port']) + expect(codes(app.events).filter((c) => c === 'carrier-message-port')).toHaveLength(2) + nextConnection.send(new Ready(5)) + await tick() + expect(readies).toEqual([5]) + }) + + it('fails the connection on a handshake from an origin outside the set', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + const pair = fakePair() + const app = connectMulti(pair) + pair.appView.dispatch({ + data: { type: 'message-port', connectionVersion: 1, connectionId: ID }, + origin: 'https://evil.example', + source: pair.popupProxy, + }) + await tick() + expect(codes(app.events)).toContain('handshake-rejected') + expect(codes(app.events)).toContain('connection-failed') + expect(codes(app.events)).not.toContain('carrier-message-port') + error.mockRestore() + }) +}) + describe('fallback seam [POPUP-CONNECTION-002/004/005] [POPUP-DIAGNOSTIC-003]', () => { it('fails closed with fallback-unavailable exactly once when no opener and no constructor', async () => { const pair = fakePair() @@ -431,7 +499,7 @@ describe('fallback seam [POPUP-CONNECTION-002/004/005] [POPUP-DIAGNOSTIC-003]', const events: PopupDiagnostic[] = [] const connection = PopupConnection.connect(popup, { connectionId: ID, - popupOrigin: POPUP_ORIGIN, + allowedPopupOrigins: [POPUP_ORIGIN], fallback, onDiagnostic: (e) => void events.push(e), }) diff --git a/ts/packages/popup/src/connection.ts b/ts/packages/popup/src/connection.ts index bf24a34d..37feed5d 100644 --- a/ts/packages/popup/src/connection.ts +++ b/ts/packages/popup/src/connection.ts @@ -14,7 +14,6 @@ import { } from './diagnostics.js' import { activeWorker, PortKeeper } from './keeper.js' import { - canonicalOrigin, type Carrier, type CarrierConstructor, decodeControl, @@ -25,6 +24,7 @@ import { type MessageType, type Navigate, type PopupControl, + requireOrigins, routingType, } from './message.js' import { @@ -44,7 +44,7 @@ export interface PopupConnection { export interface ConnectOptions { connectionId: string - popupOrigin: string + allowedPopupOrigins: readonly string[] fallback?: CarrierConstructor onDiagnostic?: (event: PopupDiagnostic) => void } @@ -204,8 +204,7 @@ class ApplicationEndpoint extends Endpoint { ) { super(createReporter(options.onDiagnostic)) const connectionId = requireConnectionId(options.connectionId) - const popupOrigin = canonicalOrigin(options.popupOrigin) - if (!popupOrigin) throw new TypeError('popupOrigin must be a canonical origin') + const allowedPopupOrigins = requireOrigins(options.allowedPopupOrigins, 'allowedPopupOrigins') if (popup.connected) throw new Error('PopupWindow is already connected') popup.connected = true @@ -218,7 +217,7 @@ class ApplicationEndpoint extends Endpoint { popup.bind(source) this.report('window-bound') }, - popupOrigin, + allowedPopupOrigins, connectionId, report: this.report, }, @@ -311,14 +310,10 @@ class PopupEndpoint extends Endpoint { ): Promise> { const report = createReporter(options.onDiagnostic) const connectionId = requireConnectionId(options.connectionId) - const origins = options.allowedApplicationOrigins - if (!Array.isArray(origins) || origins.length === 0) { - throw new TypeError('allowedApplicationOrigins must list at least one origin') - } - const allowedOrigins = Object.freeze([...new Set(origins.map(canonicalOrigin))]) - if (allowedOrigins.some((origin) => origin === null)) { - throw new TypeError('allowedApplicationOrigins must contain canonical origins') - } + const allowedOrigins = requireOrigins( + options.allowedApplicationOrigins, + 'allowedApplicationOrigins', + ) const controller = new AbortController() let carrier: Carrier | null = null @@ -346,7 +341,7 @@ class PopupEndpoint extends Endpoint { const port = await requestApplicationPort({ view: popup.view, opener, - allowedOrigins: allowedOrigins as string[], + allowedOrigins, connectionId, signal: controller.signal, timeoutMs: OPENER_HANDSHAKE_TIMEOUT_MS, @@ -401,8 +396,18 @@ class PopupEndpoint extends Endpoint { this.closePopup('connection-closed') } - /** Preserves the port through the keeper, then replaces this document. */ + /** + * Replaces this document. A same-origin target keeps the port through the + * worker first; a cross-origin target cannot, so the endpoint retires and + * the destination authenticates a fresh carrier through its opener or + * fallback. + */ private async replaceDocument(url: string): Promise { + if (new URL(url).origin !== this.popup.view.location.origin) { + this.release('connection-closed') + this.popup.view.location.replace(url) + return + } const registration = await this.popup.registration() const worker = registration ? await activeWorker(registration) : null if (this.closed || !(this.carrier instanceof PortCarrier) || !worker) { diff --git a/ts/packages/popup/src/message.test.ts b/ts/packages/popup/src/message.test.ts index 21838158..78aab464 100644 --- a/ts/packages/popup/src/message.test.ts +++ b/ts/packages/popup/src/message.test.ts @@ -6,6 +6,7 @@ import { isConnectionId, isReservedType, MAX_TYPE_LENGTH, + requireOrigins, routingType, } from './message.js' @@ -111,3 +112,24 @@ describe('origins', () => { } }) }) + +describe('origin sets [POPUP-CONNECTION-009]', () => { + it('copies a nonempty, duplicate-free set of canonical HTTPS origins', () => { + const set = requireOrigins(['https://a.example', 'https://b.example:8443'], 'x') + expect(set).toEqual(['https://a.example', 'https://b.example:8443']) + expect(Object.isFrozen(set)).toBe(true) + for (const bad of [ + [], + ['https://a.example', 'https://a.example'], + ['http://a.example'], + ['https://a.example/'], + ['https://u:p@a.example'], + ['HTTPS://a.example'], + ['null'], + 'https://a.example', + undefined, + ]) { + expect(() => requireOrigins(bad, 'x'), JSON.stringify(bad)).toThrow(TypeError) + } + }) +}) diff --git a/ts/packages/popup/src/message.ts b/ts/packages/popup/src/message.ts index 6c679d8c..7f9df6ee 100644 --- a/ts/packages/popup/src/message.ts +++ b/ts/packages/popup/src/message.ts @@ -105,3 +105,21 @@ export function canonicalOrigin(value: unknown): string | null { return null } } + +/** + * A nonempty, duplicate-free set of canonical HTTPS origins, frozen. + * Throws `TypeError` naming the option otherwise. + */ +export function requireOrigins(value: unknown, option: string): readonly string[] { + if (!Array.isArray(value) || value.length === 0) { + throw new TypeError(`${option} must list at least one origin`) + } + const origins = value.map((origin) => canonicalOrigin(origin)) + if (origins.some((origin) => origin === null || !origin.startsWith('https://'))) { + throw new TypeError(`${option} must contain canonical HTTPS origins`) + } + if (new Set(origins).size !== origins.length) { + throw new TypeError(`${option} must not repeat an origin`) + } + return Object.freeze(origins as string[]) +} diff --git a/ts/packages/popup/src/port.test.ts b/ts/packages/popup/src/port.test.ts index ebfad829..3c17cb45 100644 --- a/ts/packages/popup/src/port.test.ts +++ b/ts/packages/popup/src/port.test.ts @@ -38,7 +38,7 @@ function listen(source: 'handle' | null = 'handle', pair = fakePair()): Harness view: pair.appView, source: source === 'handle' ? (pair.popupProxy as unknown as WindowProxy) : null, onBind: (s) => void h.bound.push(s), - popupOrigin: POPUP_ORIGIN, + allowedPopupOrigins: [POPUP_ORIGIN], connectionId: ID, report: (code) => void h.codes.push(code), }, diff --git a/ts/packages/popup/src/port.ts b/ts/packages/popup/src/port.ts index 992e61c9..8ae59943 100644 --- a/ts/packages/popup/src/port.ts +++ b/ts/packages/popup/src/port.ts @@ -46,7 +46,7 @@ export interface ListenOptions { /** The retained handle, or null until native-anchor binding. */ source: WindowProxy | null onBind: (source: WindowProxy) => void - popupOrigin: string + allowedPopupOrigins: readonly string[] connectionId: string report: Reporter } @@ -64,7 +64,7 @@ export interface ListenHandlers { * acceptance, supersession, or stop. */ export function listenForPopupPorts(options: ListenOptions, handlers: ListenHandlers): () => void { - const { view, popupOrigin, connectionId, report } = options + const { view, allowedPopupOrigins, connectionId, report } = options let source = options.source let pending: MessagePort | null = null @@ -79,7 +79,7 @@ export function listenForPopupPorts(options: ListenOptions, handlers: ListenHand const listener = (event: MessageEvent): void => { if (!isAttempt(event.data, connectionId)) return if ( - event.origin !== popupOrigin || + !allowedPopupOrigins.includes(event.origin) || (source !== null && event.source !== source) || (source === null && (event.source === null || !('postMessage' in event.source))) || !isExactHandshake(event.data, connectionId) || @@ -111,7 +111,8 @@ export function listenForPopupPorts(options: ListenOptions, handlers: ListenHand handlers.onPort(port) } try { - source.postMessage(handshake(connectionId), popupOrigin, [channel.port2]) + // The response targets the exact origin the browser stamped on the request. + source.postMessage(handshake(connectionId), event.origin, [channel.port2]) } catch { // A discarded popup context cannot be answered; the attempt lapses. dropPending() diff --git a/ts/packages/popup/src/testing/fakes.ts b/ts/packages/popup/src/testing/fakes.ts index 0385f04f..cdca38e6 100644 --- a/ts/packages/popup/src/testing/fakes.ts +++ b/ts/packages/popup/src/testing/fakes.ts @@ -41,7 +41,7 @@ export function fakeView(): FakeView { export interface FakeProxy { closed: boolean postMessage(message: unknown, targetOrigin: string, transfer?: Transferable[]): void - location: { replace(url: string): void } + location: { origin: string; replace(url: string): void } close(): void replaced: string[] } @@ -56,15 +56,18 @@ export interface FakePair { appProxy: FakeProxy /** The popup document as a Window for CurrentWindow. */ popupWindow: Window + /** Replace the popup document with one on another origin; proxies keep identity. */ + relocate(origin: string): void } -export function fakePair(): FakePair { +export function fakePair(popupOrigin = POPUP_ORIGIN): FakePair { const appView = fakeView() const popupView = fakeView() + const state = { popupOrigin } const makeProxy = ( target: FakeView, - targetOrigin: string, - stampedOrigin: string, + targetOrigin: () => string, + stampedOrigin: () => string, self: () => FakeProxy, ): FakeProxy => { const proxy: FakeProxy = { @@ -72,15 +75,20 @@ export function fakePair(): FakePair { replaced: [], postMessage(message, origin, transfer = []) { if (proxy.closed) return - if (origin !== '*' && origin !== targetOrigin) return + if (origin !== '*' && origin !== targetOrigin()) return target.dispatch({ data: structuredClone(message), - origin: stampedOrigin, + origin: stampedOrigin(), source: self(), ports: transfer as MessagePort[], }) }, - location: { replace: (url) => void proxy.replaced.push(url) }, + location: { + get origin() { + return targetOrigin() + }, + replace: (url) => void proxy.replaced.push(url), + }, close: () => { proxy.closed = true }, @@ -90,8 +98,19 @@ export function fakePair(): FakePair { // Each proxy stamps the *sender's* identity as `source`. let appProxy!: FakeProxy let popupProxy!: FakeProxy - popupProxy = makeProxy(popupView, POPUP_ORIGIN, APP_ORIGIN, () => appProxy) - appProxy = makeProxy(appView, APP_ORIGIN, POPUP_ORIGIN, () => popupProxy) + const popupOriginNow = () => state.popupOrigin + popupProxy = makeProxy( + popupView, + popupOriginNow, + () => APP_ORIGIN, + () => appProxy, + ) + appProxy = makeProxy( + appView, + () => APP_ORIGIN, + popupOriginNow, + () => popupProxy, + ) const popupWindow = { addEventListener: popupView.addEventListener, @@ -102,7 +121,17 @@ export function fakePair(): FakePair { location: popupProxy.location, close: popupProxy.close, } as unknown as Window - return { appView, popupView, popupProxy, appProxy, popupWindow } + return { + appView, + popupView, + popupProxy, + appProxy, + popupWindow, + relocate(origin) { + state.popupOrigin = origin + popupView.listeners.clear() + }, + } } export interface FakeScope { From f8717f7fbd5737546bfa4a5003ab174f41eb80b3 Mon Sep 17 00:00:00 2001 From: Wondertan Date: Thu, 3 Sep 2026 17:31:40 +0100 Subject: [PATCH 07/24] feat(popup): add navigateAway and the popup-side wildcard allowlist `navigateAway(url)` is for non-participating destinations such as an identity platform's consent page. The application endpoint navigates its retained handle directly, never sends the destination over the carrier, retires the carrier without preserving it, and keeps listening for the next participating document; it rejects once the handle is unusable. The popup endpoint releases its carrier and replaces itself without the keeper. `navigate(url)` stays the continuity-preserving operation. `accept` takes `allowedApplicationOrigins: readonly string[] | '*'`. The wildcard binds any canonical HTTPS origin the browser observed on the opener's handshake and rejects an opaque or non-HTTPS one; an empty list stays invalid and `connect` never accepts a wildcard. Unit tests cover both operations and the wildcard matrix; the e2e server gains an `/p-any` deployment and cases for navigation away from both endpoints and an unlisted application connecting to it. Assisted-by: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw Signed-off-by: Wondertan --- ts/packages/popup/README.md | 16 ++- ts/packages/popup/TEST_PLAN.md | 6 +- ts/packages/popup/docs/connection.md | 26 +++-- ts/packages/popup/docs/control.md | 15 ++- ts/packages/popup/e2e/popup.spec.ts | 58 +++++++++++ ts/packages/popup/e2e/server.mjs | 9 +- ts/packages/popup/src/connection.test.ts | 118 +++++++++++++++++++++++ ts/packages/popup/src/connection.ts | 44 ++++++++- ts/packages/popup/src/message.test.ts | 11 +++ ts/packages/popup/src/message.ts | 8 ++ ts/packages/popup/src/port.ts | 13 ++- 11 files changed, 301 insertions(+), 23 deletions(-) diff --git a/ts/packages/popup/README.md b/ts/packages/popup/README.md index 04ddcd97..d179d26f 100644 --- a/ts/packages/popup/README.md +++ b/ts/packages/popup/README.md @@ -64,12 +64,18 @@ connection: const popupWindow = PopupWindow.current() const connection = await PopupConnection.accept(popupWindow, { connectionId, - allowedApplicationOrigins, + allowedApplicationOrigins, // readonly string[] | '*' fallback, onDiagnostic, }) ``` +`allowedApplicationOrigins` is an explicit list of canonical HTTPS origins or +`'*'`, which accepts any canonical HTTPS origin the browser observed on the +opener's handshake while still binding that exact origin and source. An +empty list remains invalid. The application's `allowedPopupOrigins` is always +explicit. + The caller supplies a fresh `crypto.randomUUID()` value for each logical connection; the exact accepted grammar and non-reuse rule are defined by the [connection ID contract](docs/connection.md#connection-id). @@ -92,13 +98,19 @@ interface PopupConnection { handler: (message: N) => void, ): () => void navigate(url: string): Promise + navigateAway(url: string): Promise close(): Promise } ``` Before carrier selection, `navigate` uses the retained popup handle when available. Once a carrier is active, the application endpoint sends navigation -control over it; popup-endpoint navigation acts locally. `close` uses an +control over it; popup-endpoint navigation acts locally. `navigateAway` is for +non-participating destinations such as an identity platform's consent page: +the application endpoint navigates its retained handle directly and retires +the current carrier without preserving it, staying ready for the next +participating document; the popup endpoint replaces its own document without +keeping its port. `close` uses an available retained handle and otherwise uses popup control, then releases both the connection and popup. diff --git a/ts/packages/popup/TEST_PLAN.md b/ts/packages/popup/TEST_PLAN.md index d5d3a960..79006237 100644 --- a/ts/packages/popup/TEST_PLAN.md +++ b/ts/packages/popup/TEST_PLAN.md @@ -7,7 +7,8 @@ Coverage status: - **Unit** (`pnpm test`, vitest in Node over real `MessageChannel` ports with in-memory window and worker-scope fakes): every API, WINDOW, CONTROL, - CONNECTION, PORT, KEEPER, and DIAGNOSTIC row except the clauses below. + CONNECTION, PORT, KEEPER, and DIAGNOSTIC row except the clauses below, + including `navigateAway` and the popup-side wildcard. - **Browser** (`pnpm test:e2e`, Playwright on Chromium, Firefox, WebKit, mobile Chrome, and mobile WebKit emulation over three cross-site HTTPS origins): popup creation on both paths, opener authentication, connected @@ -43,6 +44,7 @@ Coverage status: | POPUP-CONTROL-002 | With an active carrier, application-endpoint `navigate` sends one exact canonical-HTTPS `Navigate` even while its retained handle appears usable; popup-endpoint `navigate` sends no control. Either popup-side path invokes replacement only after carrier continuity is preserved or prepared; malformed, credentialed, noncanonical, relative, non-HTTPS, and unpreparable navigations fail before browser navigation. Real-engine tests confirm a COOP-severed retained handle reports `closed`. | | POPUP-CONTROL-003 | `close` uses a non-null, non-closed retained handle directly regardless of carrier state and otherwise sends one `ClosePopup` over an active carrier. It closes local resources, is idempotent, and is accepted only for a package-created script-closable popup. | | POPUP-CONTROL-004 | Controls carry no version field because they travel only over a version-authenticated carrier. They are application-to-popup, one-shot per receiving document, unacknowledged, connection-bound, and private; wrong-direction, duplicate, replayed, unknown, and post-terminal controls perform no browser operation. `Navigate` may continue the logical connection in its destination, while `ClosePopup` terminates it. | +| POPUP-CONTROL-005 | `navigateAway` never sends `Navigate`. The application endpoint navigates a non-null, non-closed retained handle directly, retires its carrier without `keep`, and accepts the next participating document's handshake; it rejects once the handle is unusable and performs no browser operation while native-anchor binding is pending. The popup endpoint releases its carrier and replaces itself without `keep`. Malformed destinations fail before any browser operation. | ## Carrier authentication and selection @@ -56,7 +58,7 @@ Coverage status: | POPUP-CONNECTION-006 | Carrier loss, endpoint loss, popup closure, background suspension, and continuity loss are never delivery, cancellation, success, or recovery. Resumed delivery preserves order. While the popup is non-participating after a connected navigation, every `send` and `navigate` succeeds locally and delivers nothing. | | POPUP-CONNECTION-007 | Both constructors accept an exact lowercase RFC 4122 UUIDv4 `connectionId` and reject uppercase, noncanonical, malformed, wrong-version, and wrong-variant strings before carrier, keeper, or signaling work. Caller integration generates a different `crypto.randomUUID()` value for every logical connection and never reuses a retired value. | | POPUP-CONNECTION-008 | `PopupConnection.navigate()` directly between same-origin participating documents preserves a MessagePort or prepares a nontransferable carrier. Across origins, including across sites, it never gives a MessagePort to the source origin's worker: it retires that popup endpoint and the allowed destination establishes a fresh carrier through its opener or fallback. A navigation outside that API loses the current carrier. A later participating document may establish the first RTC carrier from the still-unused initial fallback without round metadata; after RTC is active, an unmanaged navigation terminates the logical connection without restarting round zero. | -| POPUP-CONNECTION-009 | `connect` and `accept` copy nonempty, duplicate-free sets of canonical HTTPS `allowedPopupOrigins` and `allowedApplicationOrigins`, respectively. Every initial, native-anchor, replacement, MessagePort, and RTC participant binds one exact browser-observed member of the peer's set. Sequential popup participants may use different admitted origins without changing the logical connection ID or caller registrations. An empty set or duplicate, malformed, noncanonical, credentialed, or unapproved origin fails before selection or caller delivery. | +| POPUP-CONNECTION-009 | `connect` and `accept` copy nonempty, duplicate-free sets of canonical HTTPS `allowedPopupOrigins` and `allowedApplicationOrigins`, respectively. Every initial, native-anchor, replacement, MessagePort, and RTC participant binds one exact browser-observed member of the peer's set. Sequential popup participants may use different admitted origins without changing the logical connection ID or caller registrations. `accept` alone also takes `'*'`, which binds any canonical HTTPS browser-observed origin and rejects an opaque or non-HTTPS one; `connect` rejects a wildcard. An empty set or duplicate, malformed, noncanonical, credentialed, or unapproved origin fails before selection or caller delivery. | ## MessagePort and navigation continuity diff --git a/ts/packages/popup/docs/connection.md b/ts/packages/popup/docs/connection.md index 5eae9067..61a5729e 100644 --- a/ts/packages/popup/docs/connection.md +++ b/ts/packages/popup/docs/connection.md @@ -177,11 +177,14 @@ signaling resources normally. `connect` copies `allowedPopupOrigins`, and `accept` copies `allowedApplicationOrigins`. Both must be nonempty, duplicate-free sets of canonical HTTPS origins; either constructor rejects an invalid member or -duplicate. Every initial or later participating popup document must authenticate -from one exact popup-origin member, and each popup endpoint binds one exact -observed application-origin member. The sets admit participants; they neither -select navigation destinations nor turn an external document into a -participant. +duplicate. `accept` alone also takes the literal `'*'`, which admits any +canonical HTTPS origin the browser stamps on the opener's handshake; an +opaque or non-HTTPS origin is still rejected, and the exact observed origin +and source are still bound. `connect` never accepts a wildcard. Every initial +or later participating popup document must authenticate from one exact +popup-origin member, and each popup endpoint binds one exact observed +application origin. The sets admit participants; they neither select +navigation destinations nor turn an external document into a participant. There is no public role field or per-operation role branch. Callers never supply a keeper, route, or phase. @@ -295,6 +298,7 @@ interface PopupConnection { handler: (message: N) => void, ): () => void navigate(url: string): Promise + navigateAway(url: string): Promise close(): Promise } @@ -325,7 +329,7 @@ declare const PopupConnection: { popupWindow: PopupWindow, options: { connectionId: string - allowedApplicationOrigins: readonly string[] + allowedApplicationOrigins: readonly string[] | '*' fallback?: CarrierConstructor onDiagnostic?: (event: PopupDiagnostic) => void }, @@ -418,7 +422,15 @@ in the connection; the handler still enforces state and order. always sends the private control defined by [popup control](control.md) when a carrier is active. Without one, it uses its exact retained `WindowProxy` only while the handle is non-null and not closed. The popup endpoint prepares -continuity and replaces its own document without sending that control. `close` +continuity and replaces its own document without sending that control. +`navigateAway` is the operation for a non-participating destination: the +application endpoint navigates its retained `WindowProxy` directly, never +sends the destination over the carrier, retires the current carrier without +preserving it, and keeps its listener armed for the next participating +document; it rejects while the handle is absent or reports closed and performs +no browser operation while native-anchor binding is pending. The popup +endpoint's `navigateAway` releases its carrier and replaces its own document +without invoking the keeper. `close` uses a non-null, non-closed retained handle directly and otherwise sends its control over an active carrier. It is idempotent and closes both the logical connection and its popup. Internal failure cleanup releases resources without diff --git a/ts/packages/popup/docs/control.md b/ts/packages/popup/docs/control.md index 89e5981c..0a5a7a04 100644 --- a/ts/packages/popup/docs/control.md +++ b/ts/packages/popup/docs/control.md @@ -49,8 +49,9 @@ owns the generic message bound. ## Execution -`PopupConnection.navigate(url)` is available on both endpoints. -`PopupConnection.close()` is the application-facing lifetime operation. +`PopupConnection.navigate(url)` and `PopupConnection.navigateAway(url)` are +available on both endpoints. `PopupConnection.close()` is the +application-facing lifetime operation. While native-anchor binding is pending, `navigate` performs no browser operation and leaves that same activation's default navigation intact. With an @@ -62,7 +63,15 @@ preserves a same-origin transferable carrier, releases it for cross-origin rebind including across sites, or prepares a nontransferable replacement as required, then calls `location.replace(url)`. Replacement avoids adding the current document to popup history and creates no browsing context. Failure to -preserve or prepare a carrier rejects before navigation. A cross-origin rebind +preserve or prepare a carrier rejects before navigation. + +`navigateAway` never sends `Navigate`. On the application side it navigates +the exact retained `WindowProxy` while that is non-null and not closed, +retires the current carrier without preservation, and leaves the application +endpoint armed for the next participating document; it rejects once the handle +is unusable. On the popup side it releases the carrier and calls +`location.replace(url)` without preparing continuity. It exists for +non-participating destinations, where preservation would only expire. A cross-origin rebind may fail only after the destination loads; that failure releases no caller value and selects no weaker carrier. The caller commits its state and clears sensitive inputs before requesting navigation. diff --git a/ts/packages/popup/e2e/popup.spec.ts b/ts/packages/popup/e2e/popup.spec.ts index f567287e..ec9aca96 100644 --- a/ts/packages/popup/e2e/popup.spec.ts +++ b/ts/packages/popup/e2e/popup.spec.ts @@ -56,6 +56,15 @@ const ping = (page: Page, n: number) => ;(window as unknown as { __conn: { send(v: unknown): void } }).__conn.send({ type: 'ping', n }) }, n) +const navigateAway = (page: Page, url: string) => + page.evaluate( + (url) => + ( + window as unknown as { __conn: { navigateAway(u: string): Promise } } + ).__conn.navigateAway(url), + url, + ) + const navigate = (page: Page, url: string) => page.evaluate( (url) => @@ -302,3 +311,52 @@ test('[POPUP-CONNECTION-008] a cross-site isolated destination needs a fallback' await expect(popup.locator('#status')).toHaveText('failed: fallback-unavailable') expect(await diag(popup)).toEqual(['fallback-unavailable']) }) + +test('[POPUP-CONTROL-005] navigateAway leaves for a provider page directly and the return re-handshakes', async ({ + page, +}) => { + const { id, popup } = await open(page) + await expectPong(page, 0) + await popup.evaluate(() => navigator.serviceWorker.ready) + const next = encodeURIComponent(`${POPUP}/p#c=${id}`) + await nextDocument(popup, () => navigateAway(page, `${POPUP}/external?delay=200&next=${next}`)) + expect((await diag(page)).at(-1)).toBe('control-direct') + await nextDocument(popup) + await expect(popup.locator('#status')).toHaveText('connected') + // Nothing was kept: the returning document found no port and used its opener. + expect(await diag(popup)).toEqual(['claim-empty', 'carrier-message-port']) + await ping(page, 9) + await expectPong(page, 9) + expect((await diag(page)).filter((c) => c === 'carrier-message-port')).toHaveLength(2) +}) + +test('[POPUP-CONTROL-005] popup-side navigateAway keeps no port', async ({ page }) => { + const { id, popup } = await open(page) + await expectPong(page, 0) + await popup.evaluate(() => navigator.serviceWorker.ready) + const next = encodeURIComponent(`${POPUP}/p#c=${id}`) + await nextDocument(popup, () => + page.evaluate( + (url) => + (window as unknown as { __conn: { send(v: unknown): void } }).__conn.send({ + type: 'away', + url, + }), + `${POPUP}/external?delay=200&next=${next}`, + ), + ) + await nextDocument(popup) + await expect(popup.locator('#status')).toHaveText('connected') + expect(await diag(popup)).toEqual(['claim-empty', 'carrier-message-port']) +}) + +test("[POPUP-CONNECTION-009] a popup deployed with '*' accepts an unlisted application origin", async ({ + page, +}) => { + const id = freshId() + const { popup } = await open(page, { app: APP_B, id, href: `${POPUP}/p-any#c=${id}` }) + await expect(popup.locator('#status')).toHaveText('connected') + await ping(page, 10) + await expectPong(page, 10) + expect(await diag(popup)).toEqual(['carrier-message-port']) +}) diff --git a/ts/packages/popup/e2e/server.mjs b/ts/packages/popup/e2e/server.mjs index eb9e7ae6..b247bc18 100644 --- a/ts/packages/popup/e2e/server.mjs +++ b/ts/packages/popup/e2e/server.mjs @@ -30,6 +30,7 @@ const protocol = ` const Ping = message('ping', (v) => { if (typeof v.n !== 'number') throw new Error('ping'); return v }) const Pong = message('pong', (v) => { if (typeof v.n !== 'number') throw new Error('pong'); return v }) const Go = message('go', (v) => { if (typeof v.url !== 'string') throw new Error('go'); return v }) + const Away = message('away', (v) => { if (typeof v.url !== 'string') throw new Error('away'); return v }) window.__events = [] window.__diag = [] const onDiagnostic = (d) => window.__diag.push(d.code) @@ -69,10 +70,12 @@ const popupPage = html(` ${protocol} const id = new URLSearchParams(location.hash.slice(1)).get('c') ?? '' window.__isolated = crossOriginIsolated + // /p-any is the same document deployed for any opener origin. + const allowedApplicationOrigins = location.pathname === '/p-any' ? '*' : ['${ORIGINS.appA}'] // Accept first: the claim must run before any other network work. const accepting = PopupConnection.accept(PopupWindow.current(), { connectionId: id, - allowedApplicationOrigins: ['${ORIGINS.appA}'], + allowedApplicationOrigins, onDiagnostic, }) // The host registers the worker in every participating document. @@ -87,6 +90,9 @@ const popupPage = html(` connection.on(Go, (go) => { connection.navigate(go.url).catch((error) => window.__events.push({ type: 'error', code: error.message })) }) + connection.on(Away, (away) => { + connection.navigateAway(away.url).catch((error) => window.__events.push({ type: 'error', code: error.message })) + }) connection.send({ type: 'pong', n: 0, path: location.pathname, isolated: crossOriginIsolated }) document.getElementById('status').textContent = 'connected' } catch (error) { @@ -130,6 +136,7 @@ function popupHandler(req, res) { case '/sw.js': return send(res, 200, { ...JS, 'Service-Worker-Allowed': '/' }, workerModule) case '/p': + case '/p-any': return send(res, 200, { ...HTML, 'Cross-Origin-Opener-Policy': 'unsafe-none' }, popupPage) case '/isolated': return send(res, 200, { ...HTML, ...ISOLATED }, popupPage) diff --git a/ts/packages/popup/src/connection.test.ts b/ts/packages/popup/src/connection.test.ts index 05d0e343..8007715b 100644 --- a/ts/packages/popup/src/connection.test.ts +++ b/ts/packages/popup/src/connection.test.ts @@ -467,6 +467,124 @@ describe('cross-origin replacement [POPUP-CONNECTION-008/009]', () => { }) }) +describe('navigateAway [POPUP-CONTROL-005]', () => { + it('navigates the handle directly, retires the carrier, and stays open for the next document', async () => { + const pair = fakePair() + const app = connectApp(pair) + const scope = fakeScope() + const first = acceptPopup(pair, { worker: scope.worker }) + const popup = await first.connection + await tick() + + await app.connection.navigateAway('https://provider.example/consent') + expect(pair.popupProxy.replaced).toEqual(['https://provider.example/consent']) + expect(codes(app.events).at(-1)).toBe('control-direct') + expect(scope.pending).toHaveLength(0) // nothing kept + expect(() => app.connection.send(new Start())).toThrow('send-unavailable') + await tick() + // The popup document is gone with the provider page; its endpoint saw no control. + expect(codes(first.events)).not.toContain('connection-closed') + void popup + + // The provider returns to a participating document: a fresh handshake. + pair.relocate(POPUP_ORIGIN) + const readies: number[] = [] + app.connection.on(Ready, (r) => void readies.push(r.version)) + const next = await acceptPopup(pair).connection + await tick() + expect(codes(app.events).filter((c) => c === 'carrier-message-port')).toHaveLength(2) + next.send(new Ready(9)) + await tick() + expect(readies).toEqual([9]) + }) + + it('rejects without direct control and is a no-op while anchor binding is pending', async () => { + const pair = fakePair() + const app = connectApp(pair) + await acceptPopup(pair).connection + await tick() + pair.popupProxy.closed = true + await expect(app.connection.navigateAway('https://provider.example/')).rejects.toThrow( + 'popup-unavailable', + ) + expect(pair.popupProxy.replaced).toEqual([]) + + const blocked = connectApp(fakePair(), { blocked: true }) + await expect( + blocked.connection.navigateAway('https://provider.example/'), + ).resolves.toBeUndefined() + await expect(blocked.connection.navigateAway('http://provider.example/')).rejects.toThrow( + TypeError, + ) + }) + + it('on the popup side replaces the document without keeping the port', async () => { + const pair = fakePair() + connectApp(pair) + const scope = fakeScope() + const side = acceptPopup(pair, { worker: scope.worker }) + const popup = await side.connection + await tick() + await popup.navigateAway('https://provider.example/consent') + expect(pair.popupProxy.replaced).toEqual(['https://provider.example/consent']) + expect(scope.pending).toHaveLength(0) + expect(codes(side.events).at(-1)).toBe('connection-closed') + await expect(popup.navigate('https://popup.example/p')).rejects.toThrow('connection-closed') + }) +}) + +describe('popup-side wildcard allowlist [POPUP-CONNECTION-009]', () => { + it("accepts any HTTPS opener origin under '*' and binds it exactly", async () => { + const pair = fakePair() + const app = connectApp(pair) + const events: PopupDiagnostic[] = [] + const popup = new CurrentWindow(pair.popupWindow, () => Promise.resolve(undefined)) + const connection = await PopupConnection.accept(popup, { + connectionId: ID, + allowedApplicationOrigins: '*', + onDiagnostic: (e) => void events.push(e), + }) + await tick() + expect(codes(events)).toEqual(['carrier-message-port']) + const starts = vi.fn() + connection.on(Start, starts) + app.connection.send(new Start()) + await tick() + expect(starts).toHaveBeenCalledTimes(1) + }) + + it("rejects an opaque or non-HTTPS observed origin even under '*'", async () => { + for (const origin of ['null', 'http://app.example']) { + const pair = fakePair() + const popup = new CurrentWindow(pair.popupWindow, () => Promise.resolve(undefined)) + const pending = PopupConnection.accept(popup, { + connectionId: ID, + allowedApplicationOrigins: '*', + }) + await tick() + pair.popupView.dispatch({ + data: { type: 'message-port', connectionVersion: 1, connectionId: ID }, + origin, + source: pair.appProxy, + ports: [new MessageChannel().port1], + }) + await expect(pending).rejects.toThrow('handshake-rejected') + } + }) + + it('keeps an empty list invalid and never accepts a wildcard on the application side', async () => { + const pair = fakePair() + const popup = new CurrentWindow(pair.popupWindow, () => Promise.resolve(undefined)) + await expect( + PopupConnection.accept(popup, { connectionId: ID, allowedApplicationOrigins: [] }), + ).rejects.toThrow(TypeError) + const opened = new OpenedWindow(pair.popupProxy as unknown as WindowProxy, pair.appView) + expect(() => + PopupConnection.connect(opened, { connectionId: ID, allowedPopupOrigins: '*' as never }), + ).toThrow(TypeError) + }) +}) + describe('fallback seam [POPUP-CONNECTION-002/004/005] [POPUP-DIAGNOSTIC-003]', () => { it('fails closed with fallback-unavailable exactly once when no opener and no constructor', async () => { const pair = fakePair() diff --git a/ts/packages/popup/src/connection.ts b/ts/packages/popup/src/connection.ts index 37feed5d..82cee235 100644 --- a/ts/packages/popup/src/connection.ts +++ b/ts/packages/popup/src/connection.ts @@ -23,6 +23,7 @@ import { type Message, type MessageType, type Navigate, + type OriginAllowlist, type PopupControl, requireOrigins, routingType, @@ -38,7 +39,14 @@ import { CurrentWindow, OpenedWindow, type PopupWindow } from './window.js' export interface PopupConnection { send(message: M): void on(message: MessageType, handler: (message: N) => void): () => void + /** Continuity-preserving navigation between participating documents. */ navigate(url: string): Promise + /** + * Navigation to a non-participating document: retires the current carrier + * without preserving it and leaves the application endpoint ready for the + * next participating document. + */ + navigateAway(url: string): Promise close(): Promise } @@ -51,7 +59,8 @@ export interface ConnectOptions { export interface AcceptOptions { connectionId: string - allowedApplicationOrigins: readonly string[] + /** Explicit origins, or `'*'` for any HTTPS origin the browser observed. */ + allowedApplicationOrigins: OriginAllowlist fallback?: CarrierConstructor onDiagnostic?: (event: PopupDiagnostic) => void } @@ -150,6 +159,7 @@ abstract class Endpoint implements PopupConnection { } abstract navigate(url: string): Promise + abstract navigateAway(url: string): Promise abstract close(): Promise /** Installs the selected carrier; the class is reported when it was chosen here. */ @@ -272,6 +282,21 @@ class ApplicationEndpoint extends Endpoint { throw failure('popup-unavailable') } + async navigateAway(url: string): Promise { + if (this.closed) throw failure('connection-closed') + requireHttpsUrl(url, this.report) + if (!this.popup.opened) return + if (!this.popup.direct) { + this.report('popup-unavailable') + throw failure('popup-unavailable') + } + // Retire the carrier; the window listener stays armed for the next + // participating document. + this.dropCarrier() + this.popup.replace(url) + this.report('control-direct') + } + async close(): Promise { if (this.closed) return if (this.popup.direct) { @@ -310,10 +335,10 @@ class PopupEndpoint extends Endpoint { ): Promise> { const report = createReporter(options.onDiagnostic) const connectionId = requireConnectionId(options.connectionId) - const allowedOrigins = requireOrigins( - options.allowedApplicationOrigins, - 'allowedApplicationOrigins', - ) + const allowedOrigins: OriginAllowlist = + options.allowedApplicationOrigins === '*' + ? '*' + : requireOrigins(options.allowedApplicationOrigins, 'allowedApplicationOrigins') const controller = new AbortController() let carrier: Carrier | null = null @@ -391,6 +416,15 @@ class PopupEndpoint extends Endpoint { await this.replaceDocument(url) } + async navigateAway(url: string): Promise { + if (this.closed) throw failure('connection-closed') + requireHttpsUrl(url, this.report) + if (this.controlsDone) throw failure('popup-unavailable') + this.controlsDone = true + this.release('connection-closed') + this.popup.view.location.replace(url) + } + async close(): Promise { if (this.closed) return this.closePopup('connection-closed') diff --git a/ts/packages/popup/src/message.test.ts b/ts/packages/popup/src/message.test.ts index 78aab464..2fec746c 100644 --- a/ts/packages/popup/src/message.test.ts +++ b/ts/packages/popup/src/message.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { canonicalOrigin, decodeControl, + isAllowedOrigin, isCanonicalHttpsUrl, isConnectionId, isReservedType, @@ -133,3 +134,13 @@ describe('origin sets [POPUP-CONNECTION-009]', () => { } }) }) + +describe('wildcard allowlist [POPUP-CONNECTION-009]', () => { + it("accepts any canonical HTTPS origin under '*' and nothing else", () => { + expect(isAllowedOrigin('https://any.example', '*')).toBe(true) + for (const bad of ['null', 'http://any.example', 'https://any.example/', '', 'file://']) { + expect(isAllowedOrigin(bad, '*'), bad).toBe(false) + } + expect(isAllowedOrigin('https://any.example', ['https://other.example'])).toBe(false) + }) +}) diff --git a/ts/packages/popup/src/message.ts b/ts/packages/popup/src/message.ts index 7f9df6ee..5bc9b076 100644 --- a/ts/packages/popup/src/message.ts +++ b/ts/packages/popup/src/message.ts @@ -106,6 +106,14 @@ export function canonicalOrigin(value: unknown): string | null { } } +/** Either an explicit allowlist or any canonical HTTPS origin the browser observed. */ +export type OriginAllowlist = readonly string[] | '*' + +export function isAllowedOrigin(origin: string, allowlist: OriginAllowlist): boolean { + if (allowlist === '*') return origin.startsWith('https://') && canonicalOrigin(origin) === origin + return allowlist.includes(origin) +} + /** * A nonempty, duplicate-free set of canonical HTTPS origins, frozen. * Throws `TypeError` naming the option otherwise. diff --git a/ts/packages/popup/src/port.ts b/ts/packages/popup/src/port.ts index 8ae59943..cade09c0 100644 --- a/ts/packages/popup/src/port.ts +++ b/ts/packages/popup/src/port.ts @@ -6,7 +6,14 @@ import type { Reporter } from './diagnostics.js' import { failure } from './diagnostics.js' -import { type Carrier, hasExactKeys, isRecord, type Message } from './message.js' +import { + type Carrier, + hasExactKeys, + isAllowedOrigin, + isRecord, + type Message, + type OriginAllowlist, +} from './message.js' import type { View } from './window.js' export const CONNECTION_VERSION = 1 as const @@ -129,7 +136,7 @@ export function listenForPopupPorts(options: ListenOptions, handlers: ListenHand export interface RequestOptions { view: View opener: WindowProxy - allowedOrigins: readonly string[] + allowedOrigins: OriginAllowlist connectionId: string signal: AbortSignal timeoutMs?: number @@ -155,7 +162,7 @@ export function requestApplicationPort(options: RequestOptions): Promise Date: Thu, 3 Sep 2026 20:18:35 +0100 Subject: [PATCH 08/24] fix(popup): settle the focused review findings Security and correctness: - A caller handler's exception propagates untouched instead of being caught as a decode failure, so no raw error text reaches diagnostics and a handler bug no longer tears the connection down. - The popup endpoint hands its own lifetime signal to the handshake and the fallback; close() now aborts them. A late keep acknowledgement after close no longer navigates. - The worker acts only on its own keep and claim records, leaves the host's traffic and ports untouched, guards an unparsable client URL, and no longer forces skipWaiting or clients.claim. - A silent worker on the popup origin is treated as holding nothing, so an unrelated Service Worker never blocks a fresh handshake. - A handshake from another window or an unlisted origin is ignored, not fatal; only a malformed record from the expected peer fails the connection, and it is reported once. - A fragment-only navigation is rejected before any port leaves the carrier; a keep whose transfer threw closes the port it kept. API: - `ready` and `closed` on every connection; `closed` settles exactly once with the terminal outcome and its stable code. - `PopupError` with a `code` from the exported `PopupErrorCode` union. - `PopupConnection` makes a wrong-direction send a type error. - `accept` returns synchronously so handlers registered before yielding precede every delivery. - `PopupWindow` is not constructible; the allowlist union is inlined. Simplifications: the registry is part of the endpoint and no longer returns codes through Error.message; the opener deadline resolves null; the version constant lives in the wire leaf; single-valued parameters are gone; the two-second keeper test uses fake timers. Docs and tests follow: 73 unit tests, 85 Playwright cases green. Assisted-by: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw Signed-off-by: Wondertan --- ts/packages/popup/METRICS.md | 2 +- ts/packages/popup/README.md | 45 ++- ts/packages/popup/TEST_PLAN.md | 10 +- ts/packages/popup/docs/connection.md | 48 ++- ts/packages/popup/docs/control.md | 5 +- ts/packages/popup/docs/message-port.md | 37 +- ts/packages/popup/e2e/popup.spec.ts | 6 +- ts/packages/popup/e2e/server.mjs | 11 +- ts/packages/popup/src/connection.test.ts | 197 ++++++++-- ts/packages/popup/src/connection.ts | 419 +++++++++++----------- ts/packages/popup/src/diagnostics.test.ts | 2 +- ts/packages/popup/src/diagnostics.ts | 55 +-- ts/packages/popup/src/index.ts | 3 +- ts/packages/popup/src/keeper.test.ts | 33 +- ts/packages/popup/src/keeper.ts | 35 +- ts/packages/popup/src/message.ts | 11 +- ts/packages/popup/src/port.test.ts | 96 ++--- ts/packages/popup/src/port.ts | 80 ++--- ts/packages/popup/src/testing/fakes.ts | 8 +- ts/packages/popup/src/window.ts | 3 +- ts/packages/popup/src/worker.ts | 31 +- 21 files changed, 701 insertions(+), 436 deletions(-) diff --git a/ts/packages/popup/METRICS.md b/ts/packages/popup/METRICS.md index 2bdf593c..9116bcab 100644 --- a/ts/packages/popup/METRICS.md +++ b/ts/packages/popup/METRICS.md @@ -29,7 +29,7 @@ value. | Area | Measurements | |---|---| | Popup window | `window-opened`, `window-blocked`, `window-bound` | -| Connection | `handshake-rejected`, `opener-timeout`, `carrier-message-port`, `carrier-restored`, `carrier-fallback`, `fallback-unavailable`, `popup-unavailable`, `send-unavailable`, `connection-closed` and `connection-failed` with `durationMs` since construction | +| Connection | `handshake-rejected`, `opener-timeout`, `carrier-message-port`, `carrier-restored`, `carrier-fallback`, `fallback-unavailable`, `fallback-failed`, `popup-unavailable`, `send-unavailable`, `connection-closed` and `connection-failed` with `durationMs` since construction | | Message delivery | `decode-rejected`; MessagePort adds no encoding or clock, so no size or latency measurement exists | | Continuity | `keep-acknowledged` with `durationMs`, `keep-failed`, `claim-empty`, `claim-failed`, `continuity-unsupported` | | Control | `control-direct`, `control-connected`, `control-rejected`; never remote success | diff --git a/ts/packages/popup/README.md b/ts/packages/popup/README.md index d179d26f..91e85d8d 100644 --- a/ts/packages/popup/README.md +++ b/ts/packages/popup/README.md @@ -62,14 +62,21 @@ connection: ```ts const popupWindow = PopupWindow.current() -const connection = await PopupConnection.accept(popupWindow, { +const connection = PopupConnection.accept(popupWindow, { connectionId, allowedApplicationOrigins, // readonly string[] | '*' fallback, onDiagnostic, }) +connection.on(Start, start => { /* ... */ }) +await connection.ready ``` +`accept` returns synchronously and selects its carrier afterwards, so +handlers registered before the caller yields precede every delivery; `ready` +settles once a carrier is selected and rejects with a `PopupError` if the +endpoint failed first. + `allowedApplicationOrigins` is an explicit list of canonical HTTPS origins or `'*'`, which accepts any canonical HTTPS origin the browser observed on the opener's handshake while still binding that exact origin and source. An @@ -91,9 +98,11 @@ A cross-origin destination whose isolation policy severs its opener therefore requires a fallback constructor; without one, the connection fails closed. ```ts -interface PopupConnection { - send(message: M): void - on( +interface PopupConnection { + readonly ready: Promise + readonly closed: Promise + send(message: Out): void + on( message: MessageType, handler: (message: N) => void, ): () => void @@ -101,8 +110,23 @@ interface PopupConnection { navigateAway(url: string): Promise close(): Promise } + +type ConnectionEnd = + | { outcome: 'closed' } + | { outcome: 'failed'; code: PopupErrorCode } + +class PopupError extends Error { + readonly code: PopupErrorCode +} ``` +`Out` is what this endpoint sends and `In` what it receives; a single union +serves both when the protocol is symmetric. `closed` settles exactly once, +with the stable code when the connection failed closed, so a protocol can wait +on it instead of polling `send`. Every rejection or throw the package raises +for a transport failure is a `PopupError` whose `code` is one of the same +codes; invalid caller input throws `TypeError`. + Before carrier selection, `navigate` uses the retained popup handle when available. Once a carrier is active, the application endpoint sends navigation control over it; popup-endpoint navigation acts locally. `navigateAway` is for @@ -110,7 +134,10 @@ non-participating destinations such as an identity platform's consent page: the application endpoint navigates its retained handle directly and retires the current carrier without preserving it, staying ready for the next participating document; the popup endpoint replaces its own document without -keeping its port. `close` uses an +keeping its port. The destination of `navigateAway` never crosses a carrier: +it stays private to the endpoint that performs it, which is why no control +exists for it and why an isolated popup, whose application has lost direct +control, must initiate its own departure. `close` uses an available retained handle and otherwise uses popup control, then releases both the connection and popup. @@ -151,9 +178,11 @@ connection.send(new PopupReady(1)) Higher-level popup logic combines these classes into its own union and supplies that union to `PopupConnection`. Lifecycle controls remain internal and never -reach caller handlers. Register handlers synchronously after `connect` returns -or `accept` resolves: inbound values dispatch as later tasks, and a value with -no registered handler closes the connection. `send` throws synchronously +reach caller handlers. Register handlers before yielding to the event loop +after `connect` or `accept` returns: inbound values dispatch as later tasks, +and a value with no registered handler closes the connection. An exception a +handler throws is the caller's own and propagates untouched; it neither +closes the connection nor reaches diagnostics. `send` throws synchronously without an active carrier or after closure; nothing is queued. ### Diagnostics diff --git a/ts/packages/popup/TEST_PLAN.md b/ts/packages/popup/TEST_PLAN.md index 79006237..73a358c0 100644 --- a/ts/packages/popup/TEST_PLAN.md +++ b/ts/packages/popup/TEST_PLAN.md @@ -28,8 +28,8 @@ Coverage status: |---|---| | POPUP-API-001 | A caller-defined message union is accepted by `PopupConnection` without entering package-owned source, and `send` rejects the reserved `navigate` and `close-popup` discriminators. | | POPUP-API-002 | `on` registers one class per discriminator, rejects duplicates, calls its static `decode` exactly once, returns the same decoded object, and unsubscribes only that handler. | -| POPUP-API-003 | Unknown, unregistered, malformed, or decoder-rejected input closes the connection and reaches no caller handler. | -| POPUP-API-004 | Concurrent connections keep message registrations, native resources, controls, and delivery isolated by connection ID; before native-anchor binding, a valid handshake for another connection ID is ignored rather than rejected. | +| POPUP-API-003 | Unknown, unregistered, malformed, or decoder-rejected input closes the connection and reaches no caller handler. An exception thrown by a caller handler propagates untouched, closes nothing, and reaches no diagnostic. | +| POPUP-API-004 | Concurrent connections keep message registrations, native resources, controls, and delivery isolated by connection ID; a valid handshake for another connection ID, or any handshake from a window or origin other than the expected peer, is ignored rather than rejected. | | POPUP-API-005 | The emitted `PopupWindow` declaration omits internal `bind`; package source invokes it only after exact native-anchor validation. | ## Popup window and control @@ -51,11 +51,11 @@ Coverage status: | ID | Assertion | |---|---| | POPUP-CONNECTION-001 | A carrier becomes selectable only after it authenticates both browser endpoints; origin, source, connection ID, connection version, and direction mismatches release no caller value. | -| POPUP-CONNECTION-002 | The popup selects the path for each participating document; the application never runs an independent first-promise-wins race. MessagePort becomes selectable on the application only after the popup's exact port acknowledgement. An absent, severed, or timed-out opener commits fallback, while an authentication failure is terminal. Installing a new document's authenticated carrier atomically closes the obsolete carrier; a failed active carrier never initiates fallback by itself. | +| POPUP-CONNECTION-002 | The popup selects the path for each participating document; the application never runs an independent first-promise-wins race. A silent same-origin worker claims nothing and the document proceeds. MessagePort becomes selectable on the application only after the popup's exact port acknowledgement. An absent, severed, or timed-out opener commits fallback, while an authentication failure is terminal. Installing a new document's authenticated carrier atomically closes the obsolete carrier; a failed active carrier never initiates fallback by itself. | | POPUP-CONNECTION-003 | One logical connection survives repeated participating-document replacements: `navigate` preserves a usable carrier when possible and otherwise replaces it transparently; callers never manage carrier identity, count, lifetime, or reconnection. If no carrier can continue or be established, the connection fails closed. | | POPUP-CONNECTION-004 | `connect` invokes a supplied fallback constructor exactly once with the connection-lifetime signal and observes its promise without awaiting it; `accept` invokes its constructor once only after MessagePort becomes unavailable. The constructor returns only an authenticated `Carrier`; connection creates no substitute `MessageChannel` and queues no caller value outside that carrier. MessagePort selection retains the unused application fallback. No-carrier navigation arms only a fresh document-local MessagePort operation; popup-side navigation from RTC uses its package-private lifecycle hooks to prepare the target and retain a pending replacement before navigating, then installs only its authenticated result. Connection closure aborts every pending operation. | | POPUP-CONNECTION-005 | Without a fallback constructor, successful MessagePort use emits no fallback diagnostic; fallback selection records exactly one sanitized `fallback-unavailable` failure and closes. | -| POPUP-CONNECTION-006 | Carrier loss, endpoint loss, popup closure, background suspension, and continuity loss are never delivery, cancellation, success, or recovery. Resumed delivery preserves order. While the popup is non-participating after a connected navigation, every `send` and `navigate` succeeds locally and delivers nothing. | +| POPUP-CONNECTION-006 | Carrier loss, endpoint loss, popup closure, background suspension, and continuity loss are never delivery, cancellation, success, or recovery. Resumed delivery preserves order. While the popup is non-participating after a connected navigation, every `send` and `navigate` succeeds locally and delivers nothing; after `navigateAway` the carrier is retired and `send` throws. `closed` settles exactly once with the terminal outcome and `ready` rejects with the same code when selection fails. | | POPUP-CONNECTION-007 | Both constructors accept an exact lowercase RFC 4122 UUIDv4 `connectionId` and reject uppercase, noncanonical, malformed, wrong-version, and wrong-variant strings before carrier, keeper, or signaling work. Caller integration generates a different `crypto.randomUUID()` value for every logical connection and never reuses a retired value. | | POPUP-CONNECTION-008 | `PopupConnection.navigate()` directly between same-origin participating documents preserves a MessagePort or prepares a nontransferable carrier. Across origins, including across sites, it never gives a MessagePort to the source origin's worker: it retires that popup endpoint and the allowed destination establishes a fresh carrier through its opener or fallback. A navigation outside that API loses the current carrier. A later participating document may establish the first RTC carrier from the still-unused initial fallback without round metadata; after RTC is active, an unmanaged navigation terminates the logical connection without restarting round zero. | | POPUP-CONNECTION-009 | `connect` and `accept` copy nonempty, duplicate-free sets of canonical HTTPS `allowedPopupOrigins` and `allowedApplicationOrigins`, respectively. Every initial, native-anchor, replacement, MessagePort, and RTC participant binds one exact browser-observed member of the peer's set. Sequential popup participants may use different admitted origins without changing the logical connection ID or caller registrations. `accept` alone also takes `'*'`, which binds any canonical HTTPS browser-observed origin and rejects an opaque or non-HTTPS one; `connect` rejects a wildcard. An empty set or duplicate, malformed, noncanonical, credentialed, or unapproved origin fails before selection or caller delivery. | @@ -68,7 +68,7 @@ Coverage status: | POPUP-PORT-002 | The selected port carries ordered, nonduplicated structured-clone values. Delivery performs no serialization or allocation before the registered message decoder. | | POPUP-KEEPER-001 | `keep` acknowledges only after the worker owns one exact port; navigation begins only after that acknowledgement, and `claim` atomically transfers and removes it. | | POPUP-KEEPER-002 | Duplicate, mismatched-version, mismatched-ID, or malformed keep/claim operations fail and close every reachable port; an expired or already-claimed entry is absent and yields `null`. The host-registered worker handles keep/claim only through `installPortKeeper` from `@libid/popup/worker`. | -| POPUP-KEEPER-003 | Chromium, Firefox, and WebKit real or qualified emulation preserve the port across immediate same-origin document replacement below the common five-second deadline when both documents resolve the same active Service Worker registration and scope. A different same-origin registration claims no port and safely re-handshakes. Cross-origin replacement never invokes `keep` and establishes a fresh carrier; no port or keeper survives a non-participating external document or unbounded wait, and expiry, worker termination, suspension, and process loss fail without recovery. | +| POPUP-KEEPER-003 | Chromium, Firefox, and WebKit real or qualified emulation preserve the port across immediate same-origin document replacement below the common five-second deadline when both documents resolve the same active Service Worker registration and scope. A different same-origin registration claims no port and safely re-handshakes. Cross-origin replacement never invokes `keep` and establishes a fresh carrier; a same-origin document replaced within the bound may find its port whether or not the intermediate document participated (`navigateAway` guarantees retirement); nothing survives the bound or an unbounded wait, and expiry, worker termination, suspension, and process loss fail without recovery. | | POPUP-KEEPER-004 | Continuity preserves an authenticated MessagePort unchanged. WebRTC and caller values never enter `PortKeeper`; it stores no caller value in a URL, request, cookie, IndexedDB, or other durable record. | ## WebRTC fallback diff --git a/ts/packages/popup/docs/connection.md b/ts/packages/popup/docs/connection.md index 61a5729e..6ba132fd 100644 --- a/ts/packages/popup/docs/connection.md +++ b/ts/packages/popup/docs/connection.md @@ -144,12 +144,13 @@ const applicationConnection = PopupConnection.connect(openedWindow, { }) const currentWindow = PopupWindow.current() -const popupConnection = await PopupConnection.accept(currentWindow, { +const popupConnection = PopupConnection.accept(currentWindow, { connectionId, allowedApplicationOrigins, fallback, onDiagnostic, }) +await popupConnection.ready ``` Messages are caller-owned classes registered independently. The connection @@ -291,9 +292,11 @@ declare class PopupWindow { static current(): PopupWindow } -interface PopupConnection { - send(message: M): void - on( +interface PopupConnection { + readonly ready: Promise + readonly closed: Promise + send(message: Out): void + on( message: MessageType, handler: (message: N) => void, ): () => void @@ -302,6 +305,14 @@ interface PopupConnection { close(): Promise } +type ConnectionEnd = + | { outcome: 'closed' } + | { outcome: 'failed'; code: PopupErrorCode } + +class PopupError extends Error { + readonly code: PopupErrorCode +} + type CarrierConstructor = (signal: AbortSignal) => Promise interface PopupDiagnostic { @@ -315,7 +326,7 @@ interface PopupDiagnostic { declare function installPortKeeper(): void declare const PopupConnection: { - connect( + connect( popupWindow: PopupWindow, options: { connectionId: string @@ -323,9 +334,9 @@ declare const PopupConnection: { fallback?: CarrierConstructor onDiagnostic?: (event: PopupDiagnostic) => void }, - ): PopupConnection + ): PopupConnection - accept( + accept( popupWindow: PopupWindow, options: { connectionId: string @@ -333,7 +344,7 @@ declare const PopupConnection: { fallback?: CarrierConstructor onDiagnostic?: (event: PopupDiagnostic) => void }, - ): Promise> + ): PopupConnection } ``` @@ -409,14 +420,20 @@ control discriminators, the connection does not revalidate trusted local input. `on` registers one message class and handler by `message.type` and returns an unsubscribe function. Duplicate or reserved registrations throw synchronously. -Callers register handlers synchronously after `connect` returns or `accept` -resolves; inbound values dispatch as later tasks. For each inbound carrier +Both constructors return synchronously and select carriers afterwards, so +handlers registered before the caller yields precede every delivery; `ready` +settles once a carrier is selected and rejects with the failure code if the +endpoint failed first. `closed` settles exactly once with the connection's +terminal outcome, the only channel through which a failure without an +invoking operation reaches the caller. For each inbound carrier value, the connection reads only a bounded string `type` from a plain record, selects the registered `MessageType`, calls `decode` exactly once, and invokes that handler. An unknown or unregistered type, malformed routing discriminator, -or thrown decode closes the connection and delivers no message. The registered -set therefore enforces participant direction without hardcoding protocol types -in the connection; the handler still enforces state and order. +or thrown decode closes the connection and delivers no message. An exception +the handler itself throws is the caller's, propagates to the event loop, and +changes no connection state. The registered set therefore enforces participant +direction without hardcoding protocol types in the connection; the handler +still enforces state and order. `navigate` is available on both connection endpoints. The application endpoint always sends the private control defined by [popup control](control.md) when a @@ -430,7 +447,10 @@ preserving it, and keeps its listener armed for the next participating document; it rejects while the handle is absent or reports closed and performs no browser operation while native-anchor binding is pending. The popup endpoint's `navigateAway` releases its carrier and replaces its own document -without invoking the keeper. `close` +without invoking the keeper. The destination of `navigateAway` is private to +the endpoint that performs it and never crosses a carrier, which is why no +control exists for it and why an isolated popup must initiate its own +departure. `close` uses a non-null, non-closed retained handle directly and otherwise sends its control over an active carrier. It is idempotent and closes both the logical connection and its popup. Internal failure cleanup releases resources without diff --git a/ts/packages/popup/docs/control.md b/ts/packages/popup/docs/control.md index 0a5a7a04..ff55d130 100644 --- a/ts/packages/popup/docs/control.md +++ b/ts/packages/popup/docs/control.md @@ -71,7 +71,10 @@ retires the current carrier without preservation, and leaves the application endpoint armed for the next participating document; it rejects once the handle is unusable. On the popup side it releases the carrier and calls `location.replace(url)` without preparing continuity. It exists for -non-participating destinations, where preservation would only expire. A cross-origin rebind +non-participating destinations, where preservation would only expire, and it +keeps the destination private: no `navigate-away` control exists because the +URL must never cross a carrier, so an isolated popup leaves on its own +initiative. A cross-origin rebind may fail only after the destination loads; that failure releases no caller value and selects no weaker carrier. The caller commits its state and clears sensitive inputs before requesting navigation. diff --git a/ts/packages/popup/docs/message-port.md b/ts/packages/popup/docs/message-port.md index 5f24786c..bf65e5db 100644 --- a/ts/packages/popup/docs/message-port.md +++ b/ts/packages/popup/docs/message-port.md @@ -64,12 +64,15 @@ The carrier neither interprets those values nor persists or recovers them. application's retained endpoint. After validating that response, the popup echoes the same handshake record over the transferred port; the application does not select the port before that echo. -- Any handshake attempt which fails those checks rejects the binding and closes - every reachable port. The application's window listener lives for the - connection; an accepted handshake discards only that attempt's state, and a - later handshake from the bound source starts a new attempt. The popup removes - its own window listener after acceptance; later window traffic there is - inert. +- An attempt from any window other than the expected peer, or from an origin + outside the allowlist, is not an attempt on this connection and is ignored + without state change; nothing that merely knows the connection ID can end a + connection. An attempt from the expected peer which fails the remaining + checks rejects the binding and closes every reachable port. The + application's window listener lives for the connection; an accepted + handshake discards only that attempt's state, and a later handshake from the + bound source starts a new attempt. The popup removes its own window listener + after acceptance; later window traffic there is inert. - After binding, possession of the entangled port authenticates the peer. Application-level values travel only over that port; the carrier preserves their order and shape without interpreting them. @@ -210,7 +213,9 @@ knows what it carries. The worker itself is host-owned. The host is the deployment serving the popup documents. A Service Worker script must be served from that origin, so the host registers one for its popup documents and calls `installPortKeeper()` from -that script. The handler is exported from the `@libid/popup/worker` subpath +that script. The handler acts only on its own keep and claim records and +leaves every other message and its ports to the host; installation, update, +and claiming policy stay the host's. The handler is exported from the `@libid/popup/worker` subpath only, so worker-global types never enter the main package declaration. The package registers nothing and `PopupWindow.current()` resolves the active registration whose scope matches the current document; control of the @@ -221,7 +226,7 @@ document is not required to message that worker. declare function installPortKeeper(): void declare class PortKeeper { - constructor(worker: ServiceWorker) + constructor(worker: Pick) keep(connectionId: string, port: MessagePort): Promise claim(connectionId: string): Promise @@ -231,7 +236,10 @@ declare class PortKeeper { The constructor fixes the active worker for both operations; the connection version is the package constant. `keep` resolves only after the worker owns the exact port, after which connection may replace the source document. `claim` atomically returns -and removes the unchanged port, or returns `null` when no entry exists. `null` +and removes the unchanged port, or returns `null` when no entry exists. A +worker that does not answer within the reply deadline is treated as holding +nothing, so an unrelated worker on the origin never blocks a fresh handshake; +a malformed answer is a failure. `null` authenticates and selects nothing; popup construction continues with its available browser resources. A returned port is the selected carrier endpoint. @@ -270,22 +278,22 @@ declare function listenForPopupPorts( }, handlers: { onPort: (port: MessagePort) => void - onFail: () => void + onFail: () => void // the expected peer sent a malformed record }, ): () => void declare function requestApplicationPort(options: { view: Window opener: WindowProxy - allowedOrigins: readonly string[] + allowedOrigins: readonly string[] | '*' connectionId: string signal: AbortSignal timeoutMs?: number -}): Promise +}): Promise // null when the opener stays silent declare class PortCarrier implements Carrier { constructor(port: MessagePort) - /** Hands the port to `PortKeeper.keep` and closes this carrier. */ + /** Surrenders the port for `PortKeeper.keep`; this carrier is closed afterwards. */ detach(): MessagePort } ``` @@ -307,7 +315,8 @@ application validates it, creates the channel, and sends the response with the popup endpoint. The popup validates that response, sends the same handshake record over the transferred port, and resolves with that endpoint. A handshake attempt from the opener that fails authentication rejects immediately; abort -and the `OPENER_HANDSHAKE_TIMEOUT_MS` deadline also reject. Every rejection +rejects; the `OPENER_HANDSHAKE_TIMEOUT_MS` deadline resolves null so the +endpoint commits its fallback. Every rejection removes the window listener and closes every reachable port. The concrete error type is private. `PortCarrier` starts the port, forwards unchanged structured-clone values, closes idempotently, and `detach` surrenders the diff --git a/ts/packages/popup/e2e/popup.spec.ts b/ts/packages/popup/e2e/popup.spec.ts index ec9aca96..8b1ceef7 100644 --- a/ts/packages/popup/e2e/popup.spec.ts +++ b/ts/packages/popup/e2e/popup.spec.ts @@ -127,7 +127,7 @@ test('[POPUP-WINDOW-003] a noopener anchor never binds and the popup fails close }) => { const { popup } = await open(page, { blocked: true, rel: 'noopener' }) await expect(popup.locator('#status')).toHaveText('failed: fallback-unavailable') - expect(await diag(popup)).toEqual(['fallback-unavailable']) + expect(await diag(popup)).toEqual(['fallback-unavailable', 'connection-failed']) await page.waitForTimeout(300) expect(await diag(page)).toEqual(['window-blocked']) }) @@ -256,7 +256,7 @@ test('[POPUP-CONNECTION-002] [POPUP-CONNECTION-005] direct navigation into isola const id = freshId() const { popup } = await open(page, { id, href: `${POPUP}/isolated#c=${id}` }) await expect(popup.locator('#status')).toHaveText('failed: fallback-unavailable') - expect(await diag(popup)).toEqual(['fallback-unavailable']) + expect(await diag(popup)).toEqual(['fallback-unavailable', 'connection-failed']) await page.waitForTimeout(300) expect(await diag(page)).toEqual(['window-opened', 'control-direct']) }) @@ -309,7 +309,7 @@ test('[POPUP-CONNECTION-008] a cross-site isolated destination needs a fallback' await popup.evaluate(() => navigator.serviceWorker.ready) await nextDocument(popup, () => navigate(page, `${POPUP_B}/isolated#c=${id}`)) await expect(popup.locator('#status')).toHaveText('failed: fallback-unavailable') - expect(await diag(popup)).toEqual(['fallback-unavailable']) + expect(await diag(popup)).toEqual(['fallback-unavailable', 'connection-failed']) }) test('[POPUP-CONTROL-005] navigateAway leaves for a provider page directly and the return re-handshakes', async ({ diff --git a/ts/packages/popup/e2e/server.mjs b/ts/packages/popup/e2e/server.mjs index b247bc18..7efc56b1 100644 --- a/ts/packages/popup/e2e/server.mjs +++ b/ts/packages/popup/e2e/server.mjs @@ -57,6 +57,7 @@ const appPage = html(` connection.on(Pong, (pong) => window.__events.push(pong)) window.__conn = connection window.__popupWindow = popupWindow + connection.closed.then((end) => window.__events.push({ type: 'end', ...end })) void connection.navigate(anchor.href).catch((error) => window.__events.push({ type: 'error', code: error.message })) if (popupWindow.opened) event.preventDefault() }) @@ -72,17 +73,18 @@ const popupPage = html(` window.__isolated = crossOriginIsolated // /p-any is the same document deployed for any opener origin. const allowedApplicationOrigins = location.pathname === '/p-any' ? '*' : ['${ORIGINS.appA}'] - // Accept first: the claim must run before any other network work. - const accepting = PopupConnection.accept(PopupWindow.current(), { + // Accept first: the claim must run before any other network work, and + // handlers registered before yielding precede every delivery. + const connection = PopupConnection.accept(PopupWindow.current(), { connectionId: id, allowedApplicationOrigins, onDiagnostic, }) + window.__conn = connection + connection.closed.then((end) => window.__events.push({ type: 'end', ...end })) // The host registers the worker in every participating document. navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {}) try { - const connection = await accepting - window.__conn = connection connection.on(Ping, (ping) => { window.__events.push(ping) connection.send({ type: 'pong', n: ping.n, path: location.pathname, isolated: crossOriginIsolated }) @@ -93,6 +95,7 @@ const popupPage = html(` connection.on(Away, (away) => { connection.navigateAway(away.url).catch((error) => window.__events.push({ type: 'error', code: error.message })) }) + await connection.ready connection.send({ type: 'pong', n: 0, path: location.pathname, isolated: crossOriginIsolated }) document.getElementById('status').textContent = 'connected' } catch (error) { diff --git a/ts/packages/popup/src/connection.test.ts b/ts/packages/popup/src/connection.test.ts index 8007715b..3862d3d1 100644 --- a/ts/packages/popup/src/connection.test.ts +++ b/ts/packages/popup/src/connection.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { PopupConnection } from './connection.js' +import { PopupError } from './diagnostics.js' import type { PopupDiagnostic } from './diagnostics.js' -import type { Carrier, Message } from './message.js' +import type { Carrier, CarrierConstructor, Message } from './message.js' import { PortCarrier } from './port.js' import { APP_ORIGIN, @@ -63,7 +64,7 @@ function acceptPopup( pair: FakePair, opts: { worker?: Parameters[0] - fallback?: Carrier + fallback?: Carrier | CarrierConstructor opener?: boolean } = {}, ) { @@ -73,13 +74,18 @@ function acceptPopup( view as Window, opts.worker === undefined ? () => Promise.resolve(undefined) : registrationWith(opts.worker), ) - const connection = PopupConnection.accept(popup, { + const { fallback } = opts + const endpoint = PopupConnection.accept(popup, { connectionId: ID, allowedApplicationOrigins: [APP_ORIGIN], onDiagnostic: (e) => void side.events.push(e), - ...(opts.fallback && { fallback: () => Promise.resolve(opts.fallback as Carrier) }), + ...(fallback && { + fallback: typeof fallback === 'function' ? fallback : () => Promise.resolve(fallback), + }), }) - return { connection, ...side } + // Resolves with the endpoint once its carrier is selected; rejects as `ready` does. + const connection = endpoint.ready.then(() => endpoint) + return { connection, endpoint, ...side } } /** Both ends of a test carrier over one MessageChannel. */ @@ -106,15 +112,15 @@ describe('validation [POPUP-CONNECTION-007]', () => { ).toThrow(TypeError) expect(pair.appView.listeners.size).toBe(0) const current = new CurrentWindow(pair.popupWindow, () => Promise.resolve(undefined)) - await expect( + expect(() => PopupConnection.accept(current, { connectionId: 'nope', allowedApplicationOrigins: [APP_ORIGIN], }), - ).rejects.toThrow(TypeError) - await expect( + ).toThrow(TypeError) + expect(() => PopupConnection.accept(current, { connectionId: ID, allowedApplicationOrigins: [] }), - ).rejects.toThrow(TypeError) + ).toThrow(TypeError) expect(pair.popupView.listeners.size).toBe(0) }) @@ -229,8 +235,7 @@ describe('MessagePort selection and delivery', () => { ;(popup as unknown as { carrier: Carrier }).carrier.send(bad as Message) await tick() expect(handler).not.toHaveBeenCalled() - expect(codes(app.events).at(-1)).toBe('decode-rejected') - expect(codes(app.events)).toContain('connection-failed') + expect(codes(app.events).slice(-2)).toEqual(['decode-rejected', 'connection-failed']) expect(() => app.connection.send(new Start())).toThrow('send-unavailable') } error.mockRestore() @@ -386,7 +391,7 @@ describe('controls [POPUP-CONTROL-001/002/003/004]', () => { await tick() ;(popup as unknown as { carrier: Carrier }).carrier.send({ type: 'close-popup' }) await tick() - expect(codes(app.events).at(-1)).toBe('control-rejected') + expect(codes(app.events).slice(-2)).toEqual(['control-rejected', 'connection-failed']) // Duplicate: the second control performs no browser operation. const pair2 = fakePair() @@ -450,8 +455,7 @@ describe('cross-origin replacement [POPUP-CONNECTION-008/009]', () => { expect(readies).toEqual([5]) }) - it('fails the connection on a handshake from an origin outside the set', async () => { - const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + it('ignores a handshake from an origin outside the set and stays live', async () => { const pair = fakePair() const app = connectMulti(pair) pair.appView.dispatch({ @@ -460,10 +464,10 @@ describe('cross-origin replacement [POPUP-CONNECTION-008/009]', () => { source: pair.popupProxy, }) await tick() - expect(codes(app.events)).toContain('handshake-rejected') - expect(codes(app.events)).toContain('connection-failed') - expect(codes(app.events)).not.toContain('carrier-message-port') - error.mockRestore() + expect(codes(app.events)).toEqual(['window-opened']) + await acceptPopup(pair).connection + await tick() + expect(codes(app.events)).toContain('carrier-message-port') }) }) @@ -539,11 +543,12 @@ describe('popup-side wildcard allowlist [POPUP-CONNECTION-009]', () => { const app = connectApp(pair) const events: PopupDiagnostic[] = [] const popup = new CurrentWindow(pair.popupWindow, () => Promise.resolve(undefined)) - const connection = await PopupConnection.accept(popup, { + const connection = PopupConnection.accept(popup, { connectionId: ID, allowedApplicationOrigins: '*', onDiagnostic: (e) => void events.push(e), }) + await connection.ready await tick() expect(codes(events)).toEqual(['carrier-message-port']) const starts = vi.fn() @@ -568,16 +573,16 @@ describe('popup-side wildcard allowlist [POPUP-CONNECTION-009]', () => { source: pair.appProxy, ports: [new MessageChannel().port1], }) - await expect(pending).rejects.toThrow('handshake-rejected') + await expect(pending.ready).rejects.toThrow('handshake-rejected') } }) it('keeps an empty list invalid and never accepts a wildcard on the application side', async () => { const pair = fakePair() const popup = new CurrentWindow(pair.popupWindow, () => Promise.resolve(undefined)) - await expect( + expect(() => PopupConnection.accept(popup, { connectionId: ID, allowedApplicationOrigins: [] }), - ).rejects.toThrow(TypeError) + ).toThrow(TypeError) const opened = new OpenedWindow(pair.popupProxy as unknown as WindowProxy, pair.appView) expect(() => PopupConnection.connect(opened, { connectionId: ID, allowedPopupOrigins: '*' as never }), @@ -590,7 +595,7 @@ describe('fallback seam [POPUP-CONNECTION-002/004/005] [POPUP-DIAGNOSTIC-003]', const pair = fakePair() const side = acceptPopup(pair, { opener: false }) await expect(side.connection).rejects.toThrow('fallback-unavailable') - expect(codes(side.events)).toEqual(['fallback-unavailable']) + expect(codes(side.events)).toEqual(['fallback-unavailable', 'connection-failed']) }) it('emits no fallback diagnostic when MessagePort succeeds', async () => { @@ -652,12 +657,13 @@ describe('fallback seam [POPUP-CONNECTION-002/004/005] [POPUP-DIAGNOSTIC-003]', Promise.resolve(undefined), ) const events: PopupDiagnostic[] = [] - const connection = await PopupConnection.accept(popup, { + const connection = PopupConnection.accept(popup, { connectionId: ID, allowedApplicationOrigins: [APP_ORIGIN], fallback, onDiagnostic: (e) => void events.push(e), }) + await connection.ready expect(fallback).toHaveBeenCalledTimes(1) expect(codes(events)).toEqual(['carrier-fallback']) const starts = vi.fn() @@ -678,7 +684,7 @@ describe('fallback seam [POPUP-CONNECTION-002/004/005] [POPUP-DIAGNOSTIC-003]', connectApp(pair) const [, popupCarrier] = carrierPair() const fallback = vi.fn(() => Promise.resolve(popupCarrier)) - const side = acceptPopup(pair, { fallback: popupCarrier }) + const side = acceptPopup(pair, { fallback }) await side.connection expect(fallback).not.toHaveBeenCalled() expect(codes(side.events)).toEqual(['carrier-message-port']) @@ -700,3 +706,144 @@ describe('loss is never an outcome [POPUP-CONNECTION-006]', () => { expect(codes(app.events)).not.toContain('connection-failed') }) }) + +describe('lifecycle outcome [POPUP-CONNECTION-006] [POPUP-DIAGNOSTIC-002]', () => { + it('settles closed on close and failed with the code on a fail-closed path', async () => { + const pair = fakePair() + const app = connectApp(pair) + const side = acceptPopup(pair) + const popup = await side.connection + await tick() + await expect(app.connection.ready).resolves.toBeUndefined() + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + ;(popup as unknown as { carrier: Carrier }).carrier.send({ type: 'unknown' }) + await tick() + expect(await app.connection.closed).toEqual({ outcome: 'failed', code: 'decode-rejected' }) + await popup.close() + expect(await popup.closed).toEqual({ outcome: 'closed' }) + error.mockRestore() + }) + + it('rejects ready with a PopupError when selection fails, before any carrier', async () => { + const pair = fakePair() + const side = acceptPopup(pair, { opener: false }) + const failure = await side.endpoint.ready.catch((e: unknown) => e) + expect(failure).toBeInstanceOf(PopupError) + expect((failure as PopupError).code).toBe('fallback-unavailable') + expect(await side.endpoint.closed).toEqual({ outcome: 'failed', code: 'fallback-unavailable' }) + }) + + it('lets a throwing caller handler propagate without failing the connection', async () => { + // A synchronous test carrier so the handler's throw surfaces to the test. + let deliver: (value: unknown) => void = () => {} + const carrier: Carrier = { + send: () => {}, + on: (handler) => { + deliver = handler + return () => {} + }, + close: () => {}, + } + const pair = fakePair() + const popup = new CurrentWindow({ ...pair.popupWindow, opener: null } as Window, () => + Promise.resolve(undefined), + ) + const events: PopupDiagnostic[] = [] + const connection = PopupConnection.accept(popup, { + connectionId: ID, + allowedApplicationOrigins: [APP_ORIGIN], + fallback: () => Promise.resolve(carrier), + onDiagnostic: (e) => void events.push(e), + }) + connection.on(Start, () => { + throw new Error('user secret https://leak.example') + }) + await connection.ready + expect(() => deliver({ type: 'start' })).toThrow('user secret') + expect(codes(events)).toEqual(['carrier-fallback']) + expect(() => connection.send(new Ready(1))).not.toThrow() + }) +}) + +describe('selection order at accept level [POPUP-CONNECTION-002]', () => { + it('treats a silent worker as holding nothing and continues over the opener', async () => { + vi.useFakeTimers() + try { + const pair = fakePair() + const app = connectApp(pair) + const silent = { postMessage: () => {} } + const side = acceptPopup(pair, { worker: silent }) + await vi.advanceTimersByTimeAsync(2_100) + await side.connection + expect(codes(side.events)).toEqual(['claim-empty', 'carrier-message-port']) + expect(codes(app.events)).toContain('carrier-message-port') + } finally { + vi.useRealTimers() + } + }) + + it('commits the fallback when the opener stays silent, and when it is closed', async () => { + vi.useFakeTimers() + try { + const pair = fakePair() + pair.appView.listeners.clear() // no application listening: the opener never answers + const [, popupCarrier] = carrierPair() + const side = acceptPopup(pair, { fallback: popupCarrier }) + await vi.advanceTimersByTimeAsync(30_100) + await side.connection + expect(codes(side.events)).toEqual(['opener-timeout', 'carrier-fallback']) + } finally { + vi.useRealTimers() + } + const pair = fakePair() + pair.appProxy.closed = true + const [, popupCarrier] = carrierPair() + const side = acceptPopup(pair, { fallback: popupCarrier }) + await side.connection + expect(codes(side.events)).toEqual(['carrier-fallback']) + }) + + it('keeps an authentication failure terminal even with a fallback supplied', async () => { + const pair = fakePair() + const [, popupCarrier] = carrierPair() + const fallback = vi.fn(() => Promise.resolve(popupCarrier)) + const side = acceptPopup(pair, { fallback }) + await tick() + pair.popupView.dispatch({ + data: { type: 'message-port', connectionVersion: 2, connectionId: ID }, + origin: APP_ORIGIN, + source: pair.appProxy, + ports: [new MessageChannel().port1], + }) + await expect(side.connection).rejects.toThrow('handshake-rejected') + expect(fallback).not.toHaveBeenCalled() + expect(codes(side.events)).toEqual(['handshake-rejected', 'connection-failed']) + }) + + it('aborts the popup-side fallback signal on close', async () => { + const pair = fakePair() + const [, popupCarrier] = carrierPair() + const signals: AbortSignal[] = [] + const side = acceptPopup(pair, { + opener: false, + fallback: (signal) => { + signals.push(signal) + return Promise.resolve(popupCarrier) + }, + }) + const popup = await side.connection + expect(signals[0].aborted).toBe(false) + await popup.close() + expect(signals[0].aborted).toBe(true) + }) + + it('rejects a fragment-only navigation without touching the carrier', async () => { + const pair = fakePair() + connectApp(pair) + const side = acceptPopup(pair, { worker: fakeScope().worker }) + const popup = await side.connection + await tick() + await expect(popup.navigate(`${POPUP_ORIGIN}/p#other`)).rejects.toThrow(TypeError) + expect(() => popup.send(new Ready(1))).not.toThrow() + }) +}) diff --git a/ts/packages/popup/src/connection.ts b/ts/packages/popup/src/connection.ts index 82cee235..42a4ef04 100644 --- a/ts/packages/popup/src/connection.ts +++ b/ts/packages/popup/src/connection.ts @@ -1,14 +1,14 @@ // The logical connection (docs/connection.md, docs/control.md): one // application endpoint that may see several popup documents, and one popup -// endpoint per document. Both share the handler registry; the popup side -// additionally consumes the two reserved controls. +// endpoint per document. Both share registration, routing, and closure; the +// popup side additionally consumes the two reserved controls. import { - codeOf, createReporter, type DiagnosticCode, - failure, type PopupDiagnostic, + PopupError, + type PopupErrorCode, type Reporter, reportUndeliverable, } from './diagnostics.js' @@ -28,23 +28,26 @@ import { requireOrigins, routingType, } from './message.js' -import { - listenForPopupPorts, - OPENER_HANDSHAKE_TIMEOUT_MS, - PortCarrier, - requestApplicationPort, -} from './port.js' +import { listenForPopupPorts, PortCarrier, requestApplicationPort } from './port.js' import { CurrentWindow, OpenedWindow, type PopupWindow } from './window.js' -export interface PopupConnection { - send(message: M): void - on(message: MessageType, handler: (message: N) => void): () => void +/** How a logical connection ended. */ +export type ConnectionEnd = { outcome: 'closed' } | { outcome: 'failed'; code: PopupErrorCode } + +export interface PopupConnection { + /** Settles when this endpoint has selected its first carrier, or rejects if it failed first. */ + readonly ready: Promise + /** Settles exactly once, when the logical connection ends; never rejects. */ + readonly closed: Promise + send(message: Out): void + on(message: MessageType, handler: (message: N) => void): () => void /** Continuity-preserving navigation between participating documents. */ navigate(url: string): Promise /** - * Navigation to a non-participating document: retires the current carrier - * without preserving it and leaves the application endpoint ready for the - * next participating document. + * Navigation to a non-participating document. The destination never + * crosses any carrier: the application navigates its retained handle + * directly and the popup replaces itself locally. The current carrier is + * retired, not preserved. */ navigateAway(url: string): Promise close(): Promise @@ -59,8 +62,8 @@ export interface ConnectOptions { export interface AcceptOptions { connectionId: string - /** Explicit origins, or `'*'` for any HTTPS origin the browser observed. */ - allowedApplicationOrigins: OriginAllowlist + /** Explicit origins, or `'*'` for any canonical HTTPS origin the browser observed. */ + allowedApplicationOrigins: readonly string[] | '*' fallback?: CarrierConstructor onDiagnostic?: (event: PopupDiagnostic) => void } @@ -75,65 +78,46 @@ function requireConnectionId(value: string): string { function requireHttpsUrl(url: string, report: Reporter): void { if (!isCanonicalHttpsUrl(url)) { report('control-rejected') - throw new TypeError('navigate requires a canonical absolute HTTPS URL without credentials') + throw new TypeError('navigation requires a canonical absolute HTTPS URL without credentials') } } -/** Caller registrations plus routing of every inbound carrier value. */ -class Handlers { - private readonly entries = new Map< - string, - { decode: (value: unknown) => M; handler: (message: M) => void } - >() +const stripFragment = (url: string): string => url.split('#', 1)[0] - on(message: MessageType, handler: (message: N) => void): () => void { - const { type } = message - if (isReservedType(type)) throw new TypeError(`"${type}" is a reserved discriminator`) - if (this.entries.has(type)) throw new TypeError(`"${type}" is already registered`) - const entry = { decode: (value: unknown) => message.decode(value), handler } - this.entries.set(type, entry as never) - return () => { - if (this.entries.get(type) === (entry as never)) this.entries.delete(type) - } - } - - /** - * Dispatches one caller value, or returns a decoded control for the - * endpoint. Throws `decode-rejected` or `control-rejected` otherwise. - */ - deliver(value: unknown): PopupControl | null { - const type = routingType(value) - if (type === null) throw failure('decode-rejected') - if (isReservedType(type)) { - const control = decodeControl(value as Record) - if (!control) throw failure('control-rejected') - return control - } - const entry = this.entries.get(type) - if (!entry) throw failure('decode-rejected') - let message: M - try { - message = entry.decode(value) - } catch { - throw failure('decode-rejected') - } - entry.handler(message) - return null - } +interface Registration { + decode: (value: unknown) => In + handler: (message: In) => void } -/** Shared endpoint state: registry, carrier subscription, closure. */ -abstract class Endpoint implements PopupConnection { - protected readonly handlers = new Handlers() +/** Shared endpoint state: registrations, carrier subscription, lifecycle. */ +abstract class Endpoint + implements PopupConnection +{ + readonly ready: Promise + readonly closed: Promise protected readonly controller = new AbortController() protected carrier: Carrier | null = null - protected closed = false + protected ended = false + private readonly registrations = new Map>() private unsubscribe: (() => void) | null = null private readonly startedAt = performance.now() + private resolveReady!: () => void + private rejectReady!: (error: PopupError) => void + private settleClosed!: (end: ConnectionEnd) => void + + protected constructor(protected readonly report: Reporter) { + this.ready = new Promise((resolve, reject) => { + this.resolveReady = resolve + this.rejectReady = reject + }) + this.closed = new Promise((resolve) => { + this.settleClosed = resolve + }) + // A consumer that only awaits `closed` must not see an unhandled rejection. + this.ready.catch(() => {}) + } - protected constructor(protected readonly report: Reporter) {} - - send(message: M): void { + send(message: Out): void { if (isReservedType(message.type)) { throw new TypeError(`"${message.type}" is a reserved discriminator`) } @@ -142,20 +126,30 @@ abstract class Endpoint implements PopupConnection { /** Sends over the active carrier; a carrier that rejects the value fails the connection. */ protected transmit(value: Message): void { - if (this.closed || !this.carrier) { + if (this.ended || !this.carrier) { this.report('send-unavailable') - throw failure('send-unavailable') + throw new PopupError('send-unavailable') } try { this.carrier.send(value) } catch (error) { - this.release('connection-failed') + this.fail('send-unavailable', true) throw error } } - on(message: MessageType, handler: (message: N) => void): () => void { - return this.handlers.on(message, handler) + on(message: MessageType, handler: (message: N) => void): () => void { + const { type } = message + if (isReservedType(type)) throw new TypeError(`"${type}" is a reserved discriminator`) + if (this.registrations.has(type)) throw new TypeError(`"${type}" is already registered`) + const registration: Registration = { + decode: (value) => message.decode(value), + handler: handler as (message: In) => void, + } + this.registrations.set(type, registration) + return () => { + if (this.registrations.get(type) === registration) this.registrations.delete(type) + } } abstract navigate(url: string): Promise @@ -168,20 +162,34 @@ abstract class Endpoint implements PopupConnection { this.carrier = carrier this.unsubscribe = carrier.on((value) => this.receive(value)) if (code) this.report(code) + this.resolveReady() } protected abstract onControl(control: PopupControl): void + /** + * Routes one inbound value. Transport-level rejection fails the + * connection; an exception thrown by a caller handler is the caller's and + * propagates to the event loop untouched. + */ private receive(value: unknown): void { - if (this.closed) return - let control: PopupControl | null + if (this.ended) return + const type = routingType(value) + if (type === null) return this.fail('decode-rejected') + if (isReservedType(type)) { + const control = decodeControl(value as Record) + if (!control) return this.fail('control-rejected') + return this.onControl(control) + } + const registration = this.registrations.get(type) + if (!registration) return this.fail('decode-rejected') + let message: In try { - control = this.handlers.deliver(value) - } catch (error) { - this.fail(codeOf(error) ?? 'decode-rejected') - return + message = registration.decode(value) + } catch { + return this.fail('decode-rejected') } - if (control) this.onControl(control) + registration.handler(message) } protected dropCarrier(): void { @@ -191,21 +199,37 @@ abstract class Endpoint implements PopupConnection { this.carrier = null } - protected fail(code: DiagnosticCode): void { - if (this.closed) return - this.release('connection-failed') - reportUndeliverable(this.report, code) + /** + * Fails the connection closed. A failure reached through a caller + * operation reports through that operation; any other is undeliverable + * and gets the one sanitized console line. + */ + protected fail(code: PopupErrorCode, viaOperation = false): void { + if (this.ended) return + if (viaOperation) this.report(code) + else reportUndeliverable(this.report, code) + this.end({ outcome: 'failed', code }) + } + + protected release(): void { + if (this.ended) return + this.end({ outcome: 'closed' }) } - protected release(code: 'connection-closed' | 'connection-failed'): void { - this.closed = true + private end(end: ConnectionEnd): void { + this.ended = true this.controller.abort() this.dropCarrier() - this.report(code, { durationMs: performance.now() - this.startedAt }) + this.report( + end.outcome === 'closed' ? 'connection-closed' : 'connection-failed', + performance.now() - this.startedAt, + ) + if (end.outcome === 'failed') this.rejectReady(new PopupError(end.code)) + this.settleClosed(end) } } -class ApplicationEndpoint extends Endpoint { +class ApplicationEndpoint extends Endpoint { private readonly stopListening: () => void constructor( @@ -229,7 +253,6 @@ class ApplicationEndpoint extends Endpoint { }, allowedPopupOrigins, connectionId, - report: this.report, }, { onPort: (port) => this.install(new PortCarrier(port), 'carrier-message-port'), @@ -239,15 +262,10 @@ class ApplicationEndpoint extends Endpoint { if (options.fallback) { // Armed exactly once for the logical connection; observed, never awaited. - let pending: Promise - try { - pending = Promise.resolve(options.fallback(this.controller.signal)) - } catch (error) { - pending = Promise.reject(error) - } - pending.then( + const { fallback } = options + new Promise((resolve) => resolve(fallback(this.controller.signal))).then( (carrier) => { - if (this.closed) carrier.close() + if (this.ended) carrier.close() else this.install(carrier, 'carrier-fallback') }, () => { @@ -263,7 +281,7 @@ class ApplicationEndpoint extends Endpoint { } async navigate(url: string): Promise { - if (this.closed) throw failure('connection-closed') + if (this.ended) throw new PopupError('connection-closed') requireHttpsUrl(url, this.report) if (this.carrier) { const control: Navigate = { type: 'navigate', url } @@ -279,16 +297,16 @@ class ApplicationEndpoint extends Endpoint { // Native-anchor binding pending: the activation's own navigation proceeds. if (!this.popup.opened) return this.report('popup-unavailable') - throw failure('popup-unavailable') + throw new PopupError('popup-unavailable') } async navigateAway(url: string): Promise { - if (this.closed) throw failure('connection-closed') + if (this.ended) throw new PopupError('connection-closed') requireHttpsUrl(url, this.report) if (!this.popup.opened) return if (!this.popup.direct) { this.report('popup-unavailable') - throw failure('popup-unavailable') + throw new PopupError('popup-unavailable') } // Retire the carrier; the window listener stays armed for the next // participating document. @@ -298,7 +316,7 @@ class ApplicationEndpoint extends Endpoint { } async close(): Promise { - if (this.closed) return + if (this.ended) return if (this.popup.direct) { this.popup.closeHandle() } else if (this.carrier) { @@ -308,184 +326,181 @@ class ApplicationEndpoint extends Endpoint { // A dead carrier cannot carry the control; local release still runs. } } - this.release('connection-closed') + this.release() + } + + protected override release(): void { + if (this.ended) return + this.stopListening() + super.release() } - protected override release(code: 'connection-closed' | 'connection-failed'): void { + protected override fail(code: PopupErrorCode, viaOperation = false): void { + if (this.ended) return this.stopListening() - super.release(code) + super.fail(code, viaOperation) } } -class PopupEndpoint extends Endpoint { +class PopupEndpoint extends Endpoint { /** The first accepted control is terminal for this document. */ private controlsDone = false + private readonly connectionId: string - private constructor( + constructor( private readonly popup: CurrentWindow, - private readonly connectionId: string, - report: Reporter, - ) { - super(report) - } - - static async accept( - popup: CurrentWindow, options: AcceptOptions, - ): Promise> { - const report = createReporter(options.onDiagnostic) - const connectionId = requireConnectionId(options.connectionId) + ) { + super(createReporter(options.onDiagnostic)) + this.connectionId = requireConnectionId(options.connectionId) const allowedOrigins: OriginAllowlist = options.allowedApplicationOrigins === '*' ? '*' : requireOrigins(options.allowedApplicationOrigins, 'allowedApplicationOrigins') + void this.select(allowedOrigins, options.fallback) + } - const controller = new AbortController() - let carrier: Carrier | null = null - - // A preserved port can only be held by an already active worker. - const registration = await popup.registration() - if (registration?.active) { - const keeper = new PortKeeper(registration.active) - const port = await keeper.claim(connectionId).catch((error: unknown) => { - report('claim-failed') - throw error - }) - if (port) { - carrier = new PortCarrier(port) - report('carrier-restored') - } else { - report('claim-empty') + /** + * Selects this document's one carrier: a preserved port, then the opener + * handshake, then the fallback. Runs after construction returns, so + * registrations the caller makes synchronously precede the first delivery. + */ + private async select( + allowedOrigins: OriginAllowlist, + fallback: CarrierConstructor | undefined, + ): Promise { + try { + // A preserved port can only be held by an already active worker. + const registration = await this.popup.registration() + if (registration?.active) { + const port = await new PortKeeper(registration.active).claim(this.connectionId) + if (this.ended) return port?.close() + if (port) return this.install(new PortCarrier(port), 'carrier-restored') + this.report('claim-empty') } - } - - if (!carrier) { - const opener = popup.opener + const opener = this.popup.opener if (opener) { - try { - const port = await requestApplicationPort({ - view: popup.view, - opener, - allowedOrigins, - connectionId, - signal: controller.signal, - timeoutMs: OPENER_HANDSHAKE_TIMEOUT_MS, - }) - carrier = new PortCarrier(port) - report('carrier-message-port') - } catch (error) { - const code = codeOf(error) - if (code !== 'opener-timeout') { - report('handshake-rejected') - throw error - } - report('opener-timeout') - } + const port = await requestApplicationPort({ + view: this.popup.view, + opener, + allowedOrigins, + connectionId: this.connectionId, + signal: this.controller.signal, + }) + if (this.ended) return port?.close() + if (port) return this.install(new PortCarrier(port), 'carrier-message-port') + this.report('opener-timeout') } + if (!fallback) return this.fail('fallback-unavailable', true) + const carrier = await fallback(this.controller.signal) + if (this.ended) return carrier.close() + this.install(carrier, 'carrier-fallback') + } catch (error) { + if (this.ended) return + this.fail(error instanceof PopupError ? error.code : 'fallback-failed', true) } - - if (!carrier) { - if (!options.fallback) { - report('fallback-unavailable') - throw failure('fallback-unavailable') - } - carrier = await options.fallback(controller.signal) - report('carrier-fallback') - } - - const endpoint = new PopupEndpoint(popup, connectionId, report) - endpoint.install(carrier) - return endpoint } protected onControl(control: PopupControl): void { if (this.controlsDone) return this.controlsDone = true if (control.type === 'navigate') { - void this.replaceDocument(control.url).catch(() => {}) + void this.replaceDocument(control.url, false).catch(() => {}) } else { - this.closePopup('connection-closed') + this.closePopup() } } async navigate(url: string): Promise { - if (this.closed) throw failure('connection-closed') + if (this.ended) throw new PopupError('connection-closed') requireHttpsUrl(url, this.report) - if (this.controlsDone) throw failure('popup-unavailable') + if (stripFragment(url) === stripFragment(this.popup.view.location.href)) { + // A fragment navigation keeps this document; there is nothing to preserve. + throw new TypeError('navigation requires a different document') + } + if (this.controlsDone) throw new PopupError('popup-unavailable') this.controlsDone = true - await this.replaceDocument(url) + await this.replaceDocument(url, true) } async navigateAway(url: string): Promise { - if (this.closed) throw failure('connection-closed') + if (this.ended) throw new PopupError('connection-closed') requireHttpsUrl(url, this.report) - if (this.controlsDone) throw failure('popup-unavailable') + if (this.controlsDone) throw new PopupError('popup-unavailable') this.controlsDone = true - this.release('connection-closed') + this.release() this.popup.view.location.replace(url) } async close(): Promise { - if (this.closed) return - this.closePopup('connection-closed') + if (this.ended) return + this.closePopup() } /** * Replaces this document. A same-origin target keeps the port through the * worker first; a cross-origin target cannot, so the endpoint retires and * the destination authenticates a fresh carrier through its opener or - * fallback. + * fallback. Failure is reported through the invoking operation when there + * is one, otherwise as undeliverable. */ - private async replaceDocument(url: string): Promise { - if (new URL(url).origin !== this.popup.view.location.origin) { - this.release('connection-closed') - this.popup.view.location.replace(url) + private async replaceDocument(url: string, viaOperation: boolean): Promise { + const { location } = this.popup.view + if (new URL(url).origin !== location.origin) { + this.release() + location.replace(url) return } + const failed = (code: PopupErrorCode): never => { + this.fail(code, viaOperation) + throw new PopupError(code) + } const registration = await this.popup.registration() const worker = registration ? await activeWorker(registration) : null - if (this.closed || !(this.carrier instanceof PortCarrier) || !worker) { - this.fail('continuity-unsupported') - throw failure('continuity-unsupported') - } + if (this.ended) throw new PopupError('connection-closed') + if (!(this.carrier instanceof PortCarrier) || !worker) return failed('continuity-unsupported') const port = this.carrier.detach() const startedAt = performance.now() try { await new PortKeeper(worker).keep(this.connectionId, port) } catch { - this.fail('keep-failed') - throw failure('keep-failed') + port.close() // a no-op once transferred; releases a port the worker never took + return failed('keep-failed') } - this.report('keep-acknowledged', { durationMs: performance.now() - startedAt }) - this.popup.view.location.replace(url) + if (this.ended) throw new PopupError('connection-closed') + this.report('keep-acknowledged', performance.now() - startedAt) + location.replace(url) } - private closePopup(code: 'connection-closed'): void { - this.release(code) + private closePopup(): void { + this.release() this.popup.view.close() } } export const PopupConnection = { - connect( + connect( popupWindow: PopupWindow, options: ConnectOptions, - ): PopupConnection { + ): PopupConnection { if (!(popupWindow instanceof OpenedWindow)) { throw new TypeError('connect requires the PopupWindow returned by PopupWindow.open') } - return new ApplicationEndpoint(popupWindow, options) + return new ApplicationEndpoint(popupWindow, options) }, - accept( + /** + * Constructs the popup endpoint synchronously so handlers registered before + * the caller yields precede every delivery; `ready` settles once a carrier + * is selected. + */ + accept( popupWindow: PopupWindow, options: AcceptOptions, - ): Promise> { + ): PopupConnection { if (!(popupWindow instanceof CurrentWindow)) { - return Promise.reject( - new TypeError('accept requires the PopupWindow returned by PopupWindow.current'), - ) + throw new TypeError('accept requires the PopupWindow returned by PopupWindow.current') } - return PopupEndpoint.accept(popupWindow, options) + return new PopupEndpoint(popupWindow, options) }, } diff --git a/ts/packages/popup/src/diagnostics.test.ts b/ts/packages/popup/src/diagnostics.test.ts index cc51591d..0e54304c 100644 --- a/ts/packages/popup/src/diagnostics.test.ts +++ b/ts/packages/popup/src/diagnostics.test.ts @@ -6,7 +6,7 @@ describe('diagnostics [POPUP-DIAGNOSTIC-001/002]', () => { const events: PopupDiagnostic[] = [] const report = createReporter((event) => void events.push(event)) report('window-opened') - report('keep-acknowledged', { durationMs: -3 }) + report('keep-acknowledged', -3) expect(Object.keys(events[0])).toEqual(['code', 'timestamp']) expect(events[0].timestamp).toBeGreaterThan(performance.timeOrigin) expect(events[1]).toMatchObject({ code: 'keep-acknowledged', durationMs: 0 }) diff --git a/ts/packages/popup/src/diagnostics.ts b/ts/packages/popup/src/diagnostics.ts index 65e8fb27..a1a556d5 100644 --- a/ts/packages/popup/src/diagnostics.ts +++ b/ts/packages/popup/src/diagnostics.ts @@ -1,5 +1,6 @@ // Sanitized local diagnostics (METRICS.md): a stable code, a timestamp, and -// at most a duration or count. Never an origin, URL, id, payload, or error. +// at most a duration. Never an origin, URL, id, payload, or error. Failures +// cross the API as PopupError carrying the same stable code. export interface PopupDiagnostic { readonly code: string @@ -8,39 +9,54 @@ export interface PopupDiagnostic { readonly count?: number } -export type DiagnosticCode = - | 'window-opened' - | 'window-blocked' - | 'window-bound' +/** The codes an operation or the connection's terminal outcome can carry. */ +export type PopupErrorCode = | 'handshake-rejected' | 'opener-timeout' - | 'carrier-message-port' - | 'carrier-restored' - | 'carrier-fallback' | 'fallback-unavailable' + | 'fallback-failed' | 'decode-rejected' | 'control-rejected' - | 'control-direct' - | 'control-connected' | 'continuity-unsupported' - | 'keep-acknowledged' | 'keep-failed' - | 'claim-empty' | 'claim-failed' | 'popup-unavailable' | 'send-unavailable' | 'connection-closed' + +export type DiagnosticCode = + | PopupErrorCode + | 'window-opened' + | 'window-blocked' + | 'window-bound' + | 'carrier-message-port' + | 'carrier-restored' + | 'carrier-fallback' + | 'control-direct' + | 'control-connected' + | 'keep-acknowledged' + | 'claim-empty' | 'connection-failed' -export type Reporter = (code: DiagnosticCode, extra?: { durationMs?: number }) => void +export class PopupError extends Error { + readonly code: PopupErrorCode + + constructor(code: PopupErrorCode) { + super(code) + this.name = 'PopupError' + this.code = code + } +} + +export type Reporter = (code: DiagnosticCode, durationMs?: number) => void export function createReporter(onDiagnostic?: (event: PopupDiagnostic) => void): Reporter { if (!onDiagnostic) return () => {} - return (code, extra) => { + return (code, durationMs) => { const event: PopupDiagnostic = { code, timestamp: performance.timeOrigin + performance.now(), - ...(extra?.durationMs !== undefined && { durationMs: Math.max(0, extra.durationMs) }), + ...(durationMs !== undefined && { durationMs: Math.max(0, durationMs) }), } try { onDiagnostic(event) @@ -59,12 +75,3 @@ export function reportUndeliverable(report: Reporter, code: DiagnosticCode): voi } report(code) } - -/** Errors cross the API as plain `Error` whose message is the code. */ -export function failure(code: DiagnosticCode): Error { - return new Error(code) -} - -export function codeOf(error: unknown): DiagnosticCode | null { - return error instanceof Error ? (error.message as DiagnosticCode) : null -} diff --git a/ts/packages/popup/src/index.ts b/ts/packages/popup/src/index.ts index 1ebf3068..19b323f9 100644 --- a/ts/packages/popup/src/index.ts +++ b/ts/packages/popup/src/index.ts @@ -3,9 +3,10 @@ export { type AcceptOptions, + type ConnectionEnd, type ConnectOptions, PopupConnection, } from './connection.js' -export type { PopupDiagnostic } from './diagnostics.js' +export { type PopupDiagnostic, PopupError, type PopupErrorCode } from './diagnostics.js' export type { Carrier, CarrierConstructor, Message, MessageType } from './message.js' export { PopupWindow } from './window.js' diff --git a/ts/packages/popup/src/keeper.test.ts b/ts/packages/popup/src/keeper.test.ts index 436aa582..58b00ff6 100644 --- a/ts/packages/popup/src/keeper.test.ts +++ b/ts/packages/popup/src/keeper.test.ts @@ -7,7 +7,7 @@ import { KEEPER_REPLY_TIMEOUT_MS, PortKeeper, } from './keeper.js' -import { CONNECTION_VERSION } from './port.js' +import { CONNECTION_VERSION } from './message.js' import { fakeScope, ID, OTHER_ID, tick } from './testing/fakes.js' const nextMessage = (port: MessagePort): Promise => @@ -56,15 +56,30 @@ describe('worker validation [POPUP-KEEPER-002]', () => { await expect(scope.pending[0]).resolves.toBeUndefined() }) - it( - 'ignores a client from another origin', - async () => { + it('ignores a client from another origin, which the keeper treats as absent', async () => { + vi.useFakeTimers() + try { const scope = fakeScope() - const foreign = new PortKeeper(scope.foreignWorker) - await expect(foreign.claim(ID)).rejects.toThrow('claim-failed') - }, - KEEPER_REPLY_TIMEOUT_MS + 500, - ) + const claim = new PortKeeper(scope.foreignWorker).claim(ID) + await vi.advanceTimersByTimeAsync(KEEPER_REPLY_TIMEOUT_MS + 1) + expect(await claim).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it("leaves the host's own worker traffic and its ports untouched", async () => { + const scope = fakeScope() + const channel = new MessageChannel() + scope.postRaw({ type: 'host-message' }, [channel.port1]) + await tick() + const received = new Promise((resolve) => { + channel.port2.onmessage = (e) => resolve(e.data) + }) + channel.port1.postMessage('still open') + expect(await received).toBe('still open') + expect(scope.pending).toHaveLength(0) + }) it('decodes exact requests only', () => { const ok = { type: KEEP, connectionVersion: CONNECTION_VERSION, connectionId: ID } diff --git a/ts/packages/popup/src/keeper.ts b/ts/packages/popup/src/keeper.ts index 6e28c403..ada04dde 100644 --- a/ts/packages/popup/src/keeper.ts +++ b/ts/packages/popup/src/keeper.ts @@ -3,9 +3,8 @@ // document, and claim it back from the next one. The worker handler lives in // ./worker.ts; this file owns the wire records both sides share. -import { failure } from './diagnostics.js' -import { hasExactKeys, isConnectionId, isRecord } from './message.js' -import { CONNECTION_VERSION, type ConnectionVersion } from './port.js' +import { PopupError } from './diagnostics.js' +import { CONNECTION_VERSION, hasExactKeys, isConnectionId, isRecord } from './message.js' export const CARRIER_CLAIM_TIMEOUT_MS = 5_000 export const KEEPER_REPLY_TIMEOUT_MS = 2_000 @@ -15,7 +14,7 @@ export const CLAIM = 'libid-popup-claim' export interface KeeperRequest { type: typeof KEEP | typeof CLAIM - connectionVersion: ConnectionVersion + connectionVersion: typeof CONNECTION_VERSION connectionId: string } @@ -47,7 +46,6 @@ export interface KeeperWorker { */ export function activeWorker( registration: ServiceWorkerRegistration, - timeoutMs = KEEPER_REPLY_TIMEOUT_MS, ): Promise { if (registration.active) return Promise.resolve(registration.active) const worker = registration.installing ?? registration.waiting @@ -62,7 +60,7 @@ export function activeWorker( if (worker.state === 'activated') finish(worker) else if (worker.state === 'redundant') finish(null) } - const timer = setTimeout(() => finish(null), timeoutMs) + const timer = setTimeout(() => finish(null), KEEPER_REPLY_TIMEOUT_MS) worker.addEventListener('statechange', onChange) }) } @@ -73,45 +71,52 @@ export class PortKeeper { /** Resolves only after the worker owns the port. */ async keep(connectionId: string, port: MessagePort): Promise { const reply = await this.exchange(KEEP, connectionId, [port], 'keep-failed') - if (!isRecord(reply.data) || reply.data.ok !== true || reply.ports.length !== 0) { - throw failure('keep-failed') + if (!reply || !isRecord(reply.data) || reply.data.ok !== true || reply.ports.length !== 0) { + throw new PopupError('keep-failed') } } - /** The preserved port, or null when the worker holds no entry. */ + /** + * The preserved port, or null when the worker holds no entry. A worker + * that does not answer is treated as holding nothing, so an unrelated + * worker on the origin never blocks a fresh handshake; a malformed + * answer is a failure. + */ async claim(connectionId: string): Promise { const reply = await this.exchange(CLAIM, connectionId, [], 'claim-failed') + if (!reply) return null const { data, ports } = reply if (isRecord(data) && hasExactKeys(data, ['port'])) { if (data.port === false && ports.length === 0) return null if (data.port === true && ports.length === 1) return ports[0] } for (const port of ports) port.close() - throw failure('claim-failed') + throw new PopupError('claim-failed') } + /** One request with its own reply port; null when the worker stays silent. */ private exchange( type: KeeperRequest['type'], connectionId: string, transfer: MessagePort[], code: 'keep-failed' | 'claim-failed', - ): Promise { + ): Promise { return new Promise((resolve, reject) => { const reply = new MessageChannel() - const finish = (error: Error | null, event?: MessageEvent): void => { + const finish = (error: Error | null, event: MessageEvent | null = null): void => { clearTimeout(timer) reply.port1.onmessage = null reply.port1.close() if (error) reject(error) - else resolve(event as MessageEvent) + else resolve(event) } - const timer = setTimeout(() => finish(failure(code)), KEEPER_REPLY_TIMEOUT_MS) + const timer = setTimeout(() => finish(null), KEEPER_REPLY_TIMEOUT_MS) reply.port1.onmessage = (event: MessageEvent): void => finish(null, event) const message: KeeperRequest = { type, connectionVersion: CONNECTION_VERSION, connectionId } try { this.worker.postMessage(message, [...transfer, reply.port2]) } catch { - finish(failure(code)) + finish(new PopupError(code)) } }) } diff --git a/ts/packages/popup/src/message.ts b/ts/packages/popup/src/message.ts index 5bc9b076..e1b3447d 100644 --- a/ts/packages/popup/src/message.ts +++ b/ts/packages/popup/src/message.ts @@ -1,6 +1,10 @@ -// The wire leaf: the caller-owned message contract, the carrier seam, the -// two reserved controls, and the validators every other module shares. -// Nothing here touches a browser global. +// The wire leaf: the transport version, the caller-owned message contract, +// the carrier seam, the two reserved controls, and the validators every +// other module shares. Nothing here touches a browser global. + +/** Exact-matched in every private transport record; never negotiated. */ +export const CONNECTION_VERSION = 1 as const +export type ConnectionVersion = typeof CONNECTION_VERSION export interface Message { readonly type: string @@ -39,6 +43,7 @@ export function isReservedType(type: string): boolean { return RESERVED_TYPES.has(type) } +/** A plain record: what structured clone produces for any object value. */ export function isRecord(value: unknown): value is Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false const prototype = Object.getPrototypeOf(value) diff --git a/ts/packages/popup/src/port.test.ts b/ts/packages/popup/src/port.test.ts index 3c17cb45..c8769d59 100644 --- a/ts/packages/popup/src/port.test.ts +++ b/ts/packages/popup/src/port.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import type { DiagnosticCode } from './diagnostics.js' -import { - CONNECTION_VERSION, - listenForPopupPorts, - PortCarrier, - requestApplicationPort, -} from './port.js' +import { CONNECTION_VERSION } from './message.js' +import { listenForPopupPorts, PortCarrier, requestApplicationPort } from './port.js' import { APP_ORIGIN, fakePair, @@ -26,13 +21,12 @@ interface Harness { pair: FakePair ports: MessagePort[] fails: number - codes: DiagnosticCode[] bound: unknown[] stop: () => void } function listen(source: 'handle' | null = 'handle', pair = fakePair()): Harness { - const h: Harness = { pair, ports: [], fails: 0, codes: [], bound: [], stop: () => {} } + const h: Harness = { pair, ports: [], fails: 0, bound: [], stop: () => {} } h.stop = listenForPopupPorts( { view: pair.appView, @@ -40,7 +34,6 @@ function listen(source: 'handle' | null = 'handle', pair = fakePair()): Harness onBind: (s) => void h.bound.push(s), allowedPopupOrigins: [POPUP_ORIGIN], connectionId: ID, - report: (code) => void h.codes.push(code), }, { onPort: (port) => void h.ports.push(port), onFail: () => void h.fails++ }, ) @@ -61,6 +54,12 @@ const request = ( ...overrides, }) +const requestPort = async (pair: FakePair, overrides = {}): Promise => { + const port = await request(pair, overrides) + if (!port) throw new Error('expected a port') + return port +} + async function roundTrip(app: MessagePort, popup: MessagePort): Promise { const received: unknown[] = [] popup.onmessage = (e) => void received.push(e.data) @@ -72,7 +71,7 @@ async function roundTrip(app: MessagePort, popup: MessagePort): Promise { it('authenticates both endpoints and resolves entangled ports after the echo', async () => { const h = listen() - const popupPort = await request(h.pair) + const popupPort = await requestPort(h.pair) await tick() expect(h.ports).toHaveLength(1) expect(h.fails).toBe(0) @@ -106,11 +105,10 @@ describe('MessagePort handshake [POPUP-PORT-001]', () => { await tick() expect(h.ports).toHaveLength(0) expect(h.fails).toBe(1) - expect(h.codes).toEqual(['handshake-rejected']) // A second echo after acceptance is an ordinary value, not a re-selection. const h2 = listen() - const popupPort = await request(h2.pair) + const popupPort = await requestPort(h2.pair) await tick() popupPort.postMessage(handshake()) await tick() @@ -127,39 +125,37 @@ describe('MessagePort handshake [POPUP-PORT-001]', () => { h.pair.appProxy.postMessage('string', '*') await tick() expect(h.fails).toBe(0) - expect(h.codes).toEqual([]) h.stop() }) - it('rejects an attempt with a wrong origin, version, shape, or extra port', async () => { - for (const bad of [ - { event: { data: handshake(), origin: 'https://evil.example', source: 'handle' } }, - { - event: { - data: { ...handshake(), connectionVersion: 2 }, - origin: POPUP_ORIGIN, - source: 'handle', - }, - }, - { event: { data: { ...handshake(), extra: 1 }, origin: POPUP_ORIGIN, source: 'handle' } }, - { - event: { - data: handshake(), - origin: POPUP_ORIGIN, - source: 'handle', - ports: [new MessageChannel().port1], - }, - }, - { event: { data: handshake(), origin: POPUP_ORIGIN, source: 'other' } }, + it('ignores an attempt from a wrong origin or source; rejects a malformed one from the peer', async () => { + for (const ignored of [ + { data: handshake(), origin: 'https://evil.example', source: 'handle' }, + { data: handshake(), origin: POPUP_ORIGIN, source: 'other' }, ]) { const h = listen() h.pair.appView.dispatch({ - ...bad.event, - source: bad.event.source === 'handle' ? h.pair.popupProxy : {}, + ...ignored, + source: ignored.source === 'handle' ? h.pair.popupProxy : {}, }) await tick() + expect(h.fails, JSON.stringify(ignored)).toBe(0) + expect(h.ports).toHaveLength(0) + // Still live: a proper handshake succeeds afterwards. + await requestPort(h.pair) + await tick() + expect(h.ports).toHaveLength(1) + h.stop() + } + for (const bad of [ + { data: { ...handshake(), connectionVersion: 2 } }, + { data: { ...handshake(), extra: 1 } }, + { data: handshake(), ports: [new MessageChannel().port1] }, + ]) { + const h = listen() + h.pair.appView.dispatch({ origin: POPUP_ORIGIN, ...bad, source: h.pair.popupProxy }) + await tick() expect(h.fails, JSON.stringify(bad)).toBe(1) - expect(h.codes).toEqual(['handshake-rejected']) expect(h.ports).toHaveLength(0) h.stop() } @@ -167,10 +163,10 @@ describe('MessagePort handshake [POPUP-PORT-001]', () => { it('accepts sequential handshakes over one listener, superseding a pending attempt', async () => { const h = listen() - const first = await request(h.pair) + const first = await requestPort(h.pair) await tick() // A second document handshakes; the first port is not disturbed by us. - const second = await request(h.pair) + const second = await requestPort(h.pair) await tick() expect(h.ports).toHaveLength(2) expect(await roundTrip(h.ports[1], second)).toHaveLength(1) @@ -178,7 +174,7 @@ describe('MessagePort handshake [POPUP-PORT-001]', () => { // A pending attempt (no echo yet) is superseded by a newer accepted one. h.pair.appProxy.postMessage(handshake(), '*') await tick() - const third = await request(h.pair) + const third = await requestPort(h.pair) await tick() expect(h.ports).toHaveLength(3) expect(await roundTrip(h.ports[2], third)).toHaveLength(1) @@ -187,15 +183,15 @@ describe('MessagePort handshake [POPUP-PORT-001]', () => { it('binds the native-anchor source once and pins it [POPUP-WINDOW-002]', async () => { const h = listen(null) - const port = await request(h.pair) + const port = await requestPort(h.pair) await tick() expect(h.bound).toEqual([h.pair.popupProxy]) expect(h.ports).toHaveLength(1) port.close() - // A handshake from another source is now an attempt from the wrong source. + // A handshake from another window is not an attempt on this connection. h.pair.appView.dispatch({ data: handshake(), origin: POPUP_ORIGIN, source: {} }) await tick() - expect(h.fails).toBe(1) + expect(h.fails).toBe(0) expect(h.bound).toHaveLength(1) h.stop() }) @@ -211,11 +207,10 @@ describe('MessagePort handshake [POPUP-PORT-001]', () => { }) describe('popup request', () => { - it('rejects a response from a wrong source, origin, shape, or port count', async () => { + it('rejects a response from the opener with a wrong origin, shape, or port count', async () => { const pair = fakePair() const cases: Array<{ data: unknown; origin: string; source: unknown; ports?: MessagePort[] }> = [ - { data: handshake(), origin: APP_ORIGIN, source: {}, ports: [new MessageChannel().port1] }, { data: handshake(), origin: 'https://evil.example', @@ -238,7 +233,7 @@ describe('popup request', () => { expect(pair.popupView.listeners.size).toBe(0) }) - it('ignores unrelated traffic and times out into opener-timeout', async () => { + it('ignores unrelated traffic and another window, then resolves null when the opener stays silent', async () => { const pair = fakePair() const pending = request(pair, { timeoutMs: 30 }) pair.popupView.dispatch({ data: { type: 'noise' }, origin: APP_ORIGIN, source: pair.appProxy }) @@ -247,7 +242,14 @@ describe('popup request', () => { origin: APP_ORIGIN, source: pair.appProxy, }) - await expect(pending).rejects.toThrow('opener-timeout') + pair.popupView.dispatch({ + data: handshake(), + origin: APP_ORIGIN, + source: {}, + ports: [new MessageChannel().port1], + }) + await expect(pending).resolves.toBeNull() + expect(pair.popupView.listeners.size).toBe(0) }) it('rejects on abort', async () => { diff --git a/ts/packages/popup/src/port.ts b/ts/packages/popup/src/port.ts index cade09c0..6afa447a 100644 --- a/ts/packages/popup/src/port.ts +++ b/ts/packages/popup/src/port.ts @@ -4,10 +4,10 @@ // port as the final acknowledgement. The entangled ports carry caller values // unchanged. -import type { Reporter } from './diagnostics.js' -import { failure } from './diagnostics.js' +import { PopupError } from './diagnostics.js' import { type Carrier, + CONNECTION_VERSION, hasExactKeys, isAllowedOrigin, isRecord, @@ -16,16 +16,13 @@ import { } from './message.js' import type { View } from './window.js' -export const CONNECTION_VERSION = 1 as const -export type ConnectionVersion = typeof CONNECTION_VERSION - export const OPENER_HANDSHAKE_TIMEOUT_MS = 30_000 const HANDSHAKE = 'message-port' interface Handshake { type: typeof HANDSHAKE - connectionVersion: ConnectionVersion + connectionVersion: typeof CONNECTION_VERSION connectionId: string } @@ -48,6 +45,10 @@ function isExactHandshake(data: unknown, connectionId: string): boolean { ) } +function isWindow(source: MessageEventSource | null): source is WindowProxy { + return source !== null && 'postMessage' in source && 'closed' in source +} + export interface ListenOptions { view: View /** The retained handle, or null until native-anchor binding. */ @@ -55,23 +56,24 @@ export interface ListenOptions { onBind: (source: WindowProxy) => void allowedPopupOrigins: readonly string[] connectionId: string - report: Reporter } export interface ListenHandlers { /** The application's authenticated endpoint for one popup document. */ onPort: (port: MessagePort) => void - /** An attempt from the expected source failed authentication. */ + /** The expected peer sent a malformed handshake or acknowledgement. */ onFail: () => void } /** * Application side. One window listener for the connection lifetime: each * accepted handshake yields one port; per-attempt state is discarded on - * acceptance, supersession, or stop. + * acceptance, supersession, or stop. An attempt from any window or origin + * other than the expected peer is not an attempt on this connection and is + * ignored, so nothing that merely knows the connection ID can end it. */ export function listenForPopupPorts(options: ListenOptions, handlers: ListenHandlers): () => void { - const { view, allowedPopupOrigins, connectionId, report } = options + const { view, allowedPopupOrigins, connectionId } = options let source = options.source let pending: MessagePort | null = null @@ -85,14 +87,9 @@ export function listenForPopupPorts(options: ListenOptions, handlers: ListenHand const listener = (event: MessageEvent): void => { if (!isAttempt(event.data, connectionId)) return - if ( - !allowedPopupOrigins.includes(event.origin) || - (source !== null && event.source !== source) || - (source === null && (event.source === null || !('postMessage' in event.source))) || - !isExactHandshake(event.data, connectionId) || - event.ports.length !== 0 - ) { - report('handshake-rejected') + if (!allowedPopupOrigins.includes(event.origin)) return + if (source !== null ? event.source !== source : !isWindow(event.source)) return + if (!isExactHandshake(event.data, connectionId) || event.ports.length !== 0) { dropPending() handlers.onFail() return @@ -108,7 +105,6 @@ export function listenForPopupPorts(options: ListenOptions, handlers: ListenHand port.onmessage = (ack: MessageEvent): void => { if (pending !== port) return if (!isExactHandshake(ack.data, connectionId) || ack.ports.length !== 0) { - report('handshake-rejected') dropPending() handlers.onFail() return @@ -144,30 +140,30 @@ export interface RequestOptions { /** * Popup side. Sends the handshake to the exact opener and resolves the - * transferred, acknowledged port. Rejects with `handshake-rejected` on an - * authentication failure, `opener-timeout` on silence, and - * `connection-closed` on abort; every rejection closes reachable ports. + * transferred, acknowledged port, or null when the opener stays silent past + * the deadline (the caller then commits its fallback). Rejects with + * `handshake-rejected` when the opener answers wrongly and `connection-closed` + * on abort; every rejection closes reachable ports. */ -export function requestApplicationPort(options: RequestOptions): Promise { +export function requestApplicationPort(options: RequestOptions): Promise { const { view, opener, allowedOrigins, connectionId, signal } = options return new Promise((resolve, reject) => { - const finish = (error: Error | null, port?: MessagePort): void => { + const finish = (error: Error | null, port: MessagePort | null = null): void => { view.removeEventListener('message', listener) clearTimeout(timer) signal.removeEventListener('abort', onAbort) if (error) reject(error) - else resolve(port as MessagePort) + else resolve(port) } const listener = (event: MessageEvent): void => { - if (!isAttempt(event.data, connectionId)) return + if (!isAttempt(event.data, connectionId) || event.source !== opener) return if ( - event.source !== opener || !isAllowedOrigin(event.origin, allowedOrigins) || !isExactHandshake(event.data, connectionId) || event.ports.length !== 1 ) { for (const port of event.ports) port.close() - finish(failure('handshake-rejected')) + finish(new PopupError('handshake-rejected')) return } const port = event.ports[0] @@ -175,16 +171,13 @@ export function requestApplicationPort(options: RequestOptions): Promise finish(failure('connection-closed')) - const timer = setTimeout( - () => finish(failure('opener-timeout')), - options.timeoutMs ?? OPENER_HANDSHAKE_TIMEOUT_MS, - ) + const onAbort = (): void => finish(new PopupError('connection-closed')) + const timer = setTimeout(() => finish(null), options.timeoutMs ?? OPENER_HANDSHAKE_TIMEOUT_MS) if (signal.aborted) { onAbort() return @@ -194,7 +187,7 @@ export function requestApplicationPort(options: RequestOptions): Promise void proxy.replaced.push(url), }, close: () => { @@ -140,6 +143,8 @@ export interface FakeScope { /** Post as a client from another origin. */ foreignWorker: KeeperWorker pending: Promise[] + /** The host's own traffic through the same worker. */ + postRaw(message: unknown, ports: MessagePort[]): void } /** The real worker handler on a fake ServiceWorkerGlobalScope. */ @@ -170,6 +175,7 @@ export function fakeScope(origin = POPUP_ORIGIN): FakeScope { worker: post(`${origin}/p`), foreignWorker: post('https://evil.example/p'), pending, + postRaw: (message, ports) => post(`${origin}/p`).postMessage(message, ports), } } diff --git a/ts/packages/popup/src/window.ts b/ts/packages/popup/src/window.ts index 9f3b97a0..b4cbe7e4 100644 --- a/ts/packages/popup/src/window.ts +++ b/ts/packages/popup/src/window.ts @@ -4,7 +4,7 @@ // Everything but `opened` is package-internal and reached through // PopupConnection so continuity and control rules always apply. -/** The listening surface of a Window, injectable for unit tests. */ +/** @internal The listening surface of a Window, injectable for unit tests. */ export interface View { addEventListener(type: 'message', listener: (event: MessageEvent) => void): void removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void @@ -20,7 +20,6 @@ function usable(handle: WindowProxy | null): handle is WindowProxy { } export class PopupWindow { - /** @internal */ protected constructor() {} /** Synchronously attempts `window.open('about:blank', target)`. */ diff --git a/ts/packages/popup/src/worker.ts b/ts/packages/popup/src/worker.ts index 37193b28..3eb359ae 100644 --- a/ts/packages/popup/src/worker.ts +++ b/ts/packages/popup/src/worker.ts @@ -1,7 +1,9 @@ // @libid/popup/worker — the Service Worker half of the continuity bridge. -// The host serves and registers the worker; this handler gives a port a -// temporary owner across one popup document replacement. It never reads the -// port, keeps no durable record, and holds nothing past the claim deadline. +// The host serves and registers the worker and owns its update policy; this +// handler only gives a port a temporary owner across one popup document +// replacement. It touches nothing but its own keep and claim records, never +// reads the port, keeps no durable record, and holds nothing past the claim +// deadline. import { CARRIER_CLAIM_TIMEOUT_MS, decodeKeeperRequest, KEEP } from './keeper.js' @@ -10,23 +12,26 @@ interface Held { release: () => void } -/** @internal Installs the handlers on an explicit scope; tests inject a fake. */ +function clientOrigin(source: ExtendableMessageEvent['source']): string | null { + if (!source || !('url' in source)) return null + try { + return new URL(source.url).origin + } catch { + return null + } +} + +/** @internal Installs the handler on an explicit scope; tests inject a fake. */ export function installPortKeeperOn(scope: ServiceWorkerGlobalScope): void { const held = new Map() - scope.addEventListener('install', () => void scope.skipWaiting()) - scope.addEventListener('activate', (event) => event.waitUntil(scope.clients.claim())) scope.addEventListener('message', (event) => { + const request = decodeKeeperRequest(event.data) + if (!request) return // not ours: the host's own traffic passes untouched const closeAll = (): void => { for (const port of event.ports) port.close() } - const source = event.source - if (!source || !('url' in source) || new URL(source.url).origin !== scope.location.origin) { - closeAll() - return - } - const request = decodeKeeperRequest(event.data) - if (!request) { + if (clientOrigin(event.source) !== scope.location.origin) { closeAll() return } From bf38fc0ea8a1913e385104187860d0fb9260c7da Mon Sep 17 00:00:00 2001 From: Wondertan Date: Thu, 3 Sep 2026 20:22:17 +0100 Subject: [PATCH 09/24] test(popup): keep the accept helper's derived promise handled Assisted-by: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw Signed-off-by: Wondertan --- ts/packages/popup/src/connection.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ts/packages/popup/src/connection.test.ts b/ts/packages/popup/src/connection.test.ts index 3862d3d1..9d0d1a63 100644 --- a/ts/packages/popup/src/connection.test.ts +++ b/ts/packages/popup/src/connection.test.ts @@ -85,6 +85,7 @@ function acceptPopup( }) // Resolves with the endpoint once its carrier is selected; rejects as `ready` does. const connection = endpoint.ready.then(() => endpoint) + connection.catch(() => {}) // tests that only inspect `endpoint` must not leak a rejection return { connection, endpoint, ...side } } From c397165eb218ea2a012e3ad63e7a274441af0752 Mon Sep 17 00:00:00 2001 From: Wondertan Date: Thu, 3 Sep 2026 20:26:36 +0100 Subject: [PATCH 10/24] fix(popup): satisfy noVoidTypeReturn in the inbound router Assisted-by: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw Signed-off-by: Wondertan --- ts/packages/popup/src/connection.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/ts/packages/popup/src/connection.ts b/ts/packages/popup/src/connection.ts index 42a4ef04..c81518fc 100644 --- a/ts/packages/popup/src/connection.ts +++ b/ts/packages/popup/src/connection.ts @@ -175,19 +175,27 @@ abstract class Endpoint private receive(value: unknown): void { if (this.ended) return const type = routingType(value) - if (type === null) return this.fail('decode-rejected') + if (type === null) { + this.fail('decode-rejected') + return + } if (isReservedType(type)) { const control = decodeControl(value as Record) - if (!control) return this.fail('control-rejected') - return this.onControl(control) + if (control) this.onControl(control) + else this.fail('control-rejected') + return } const registration = this.registrations.get(type) - if (!registration) return this.fail('decode-rejected') + if (!registration) { + this.fail('decode-rejected') + return + } let message: In try { message = registration.decode(value) } catch { - return this.fail('decode-rejected') + this.fail('decode-rejected') + return } registration.handler(message) } From e620905413fb6985e2f19ccabc7b7f116dc35a0b Mon Sep 17 00:00:00 2001 From: Wondertan Date: Thu, 3 Sep 2026 20:34:10 +0100 Subject: [PATCH 11/24] chore(popup): add the browser install script the CI job runs Assisted-by: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw Signed-off-by: Wondertan --- ts/packages/popup/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/ts/packages/popup/package.json b/ts/packages/popup/package.json index 9414d688..6dad6c0e 100644 --- a/ts/packages/popup/package.json +++ b/ts/packages/popup/package.json @@ -31,6 +31,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:e2e": "playwright test", + "test:e2e:install": "playwright install --with-deps chromium firefox webkit", "typecheck:e2e": "tsc -p tsconfig.e2e.json" }, "devDependencies": { From ae35c503c8bf76207b065999228d28a5d7cffcb3 Mon Sep 17 00:00:00 2001 From: Wondertan Date: Thu, 3 Sep 2026 21:40:49 +0100 Subject: [PATCH 12/24] docs(popup): state ordering across a transition and test it Caller messages sent before a Navigate control reach the popup's handlers before the popup acts on the control, on every destination, so a transition that carries the application's reply is driven by the side that has finished talking. Messages sent after the control cannot reach the departing document. Covered by a unit case and a cross-site browser case (POPUP-CONNECTION-010). Assisted-by: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw Signed-off-by: Wondertan --- ts/packages/popup/README.md | 22 +++++++++++++++++ ts/packages/popup/TEST_PLAN.md | 1 + ts/packages/popup/docs/connection.md | 14 +++++++++++ ts/packages/popup/e2e/popup.spec.ts | 30 ++++++++++++++++++++++++ ts/packages/popup/e2e/server.mjs | 5 +++- ts/packages/popup/src/connection.test.ts | 28 ++++++++++++++++++++++ 6 files changed, 99 insertions(+), 1 deletion(-) diff --git a/ts/packages/popup/README.md b/ts/packages/popup/README.md index 91e85d8d..1da22c28 100644 --- a/ts/packages/popup/README.md +++ b/ts/packages/popup/README.md @@ -141,6 +141,28 @@ control, must initiate its own departure. `close` uses an available retained handle and otherwise uses popup control, then releases both the connection and popup. +### Transitions that carry a reply + +Delivery on one carrier is ordered, and a `navigate` control is delivered in +that same order. So a transition that must not lose the application's reply +is driven by the side that has finished talking: + +```ts +// application +connection.on(OAuthReturn, (result) => { + connection.send(new Decision(result)) // ordered before the control + void connection.navigate(walletUrl) // the popup acts on this after Decision +}) +``` + +The popup's handler runs on `Decision` before the popup leaves, whether the +destination is same-origin or cross-origin. When the popup must choose the +destination itself, it navigates only after it has received the reply. Do not +`send` and then `navigateAway` from the application: direct navigation does +not wait for the port, and the reply may be lost. Likewise, anything the +application sends after its `navigate` control cannot reach the departing +document; send what the destination needs once it announces itself. + ### Define and exchange messages Each caller-owned message class supplies its discriminator and decoder: diff --git a/ts/packages/popup/TEST_PLAN.md b/ts/packages/popup/TEST_PLAN.md index 73a358c0..a8459372 100644 --- a/ts/packages/popup/TEST_PLAN.md +++ b/ts/packages/popup/TEST_PLAN.md @@ -59,6 +59,7 @@ Coverage status: | POPUP-CONNECTION-007 | Both constructors accept an exact lowercase RFC 4122 UUIDv4 `connectionId` and reject uppercase, noncanonical, malformed, wrong-version, and wrong-variant strings before carrier, keeper, or signaling work. Caller integration generates a different `crypto.randomUUID()` value for every logical connection and never reuses a retired value. | | POPUP-CONNECTION-008 | `PopupConnection.navigate()` directly between same-origin participating documents preserves a MessagePort or prepares a nontransferable carrier. Across origins, including across sites, it never gives a MessagePort to the source origin's worker: it retires that popup endpoint and the allowed destination establishes a fresh carrier through its opener or fallback. A navigation outside that API loses the current carrier. A later participating document may establish the first RTC carrier from the still-unused initial fallback without round metadata; after RTC is active, an unmanaged navigation terminates the logical connection without restarting round zero. | | POPUP-CONNECTION-009 | `connect` and `accept` copy nonempty, duplicate-free sets of canonical HTTPS `allowedPopupOrigins` and `allowedApplicationOrigins`, respectively. Every initial, native-anchor, replacement, MessagePort, and RTC participant binds one exact browser-observed member of the peer's set. Sequential popup participants may use different admitted origins without changing the logical connection ID or caller registrations. `accept` alone also takes `'*'`, which binds any canonical HTTPS browser-observed origin and rejects an opaque or non-HTTPS one; `connect` rejects a wildcard. An empty set or duplicate, malformed, noncanonical, credentialed, or unapproved origin fails before selection or caller delivery. | +| POPUP-CONNECTION-010 | Caller messages sent over a carrier before a `Navigate` control are delivered to the popup's handlers before the popup acts on the control, for same-origin and cross-origin destinations alike. A transition that needs the application's reply is therefore application-driven (reply, then `navigate`) or the popup navigates only after receiving the reply; an application-side `navigateAway` immediately after a send does not wait for the port. | ## MessagePort and navigation continuity diff --git a/ts/packages/popup/docs/connection.md b/ts/packages/popup/docs/connection.md index 6ba132fd..8065ffc8 100644 --- a/ts/packages/popup/docs/connection.md +++ b/ts/packages/popup/docs/connection.md @@ -456,6 +456,20 @@ control over an active carrier. It is idempotent and closes both the logical connection and its popup. Internal failure cleanup releases resources without invoking either operation or controlling popup lifetime. +Caller messages sent over a carrier before a `Navigate` control are delivered +to the popup's handlers before the popup acts on the control, on every +destination. A transition that must carry the application's reply is +therefore application-driven: the application replies, then navigates. When +the popup selects the destination, it navigates only after receiving the +reply. An application-side `navigateAway` issued immediately after a `send` +does not wait for the port and can lose the reply; the popup initiates that +departure instead. After the application sends `Navigate`, ordinary messages +it sends cannot reach the departing document: on a same-origin transition +they wait in the preserved port and reach the destination once it accepts, +provided the destination registered their handlers before yielding; on a +cross-origin transition they are lost. Send what the destination needs only +after it announces itself. + `navigate` accepts a caller-selected opaque URL. It does not select the route or interpret caller-owned fields: diff --git a/ts/packages/popup/e2e/popup.spec.ts b/ts/packages/popup/e2e/popup.spec.ts index 8b1ceef7..4ac1b300 100644 --- a/ts/packages/popup/e2e/popup.spec.ts +++ b/ts/packages/popup/e2e/popup.spec.ts @@ -360,3 +360,33 @@ test("[POPUP-CONNECTION-009] a popup deployed with '*' accepts an unlisted appli await expectPong(page, 10) expect(await diag(popup)).toEqual(['carrier-message-port']) }) + +test('[POPUP-CONNECTION-010] a reply sent before navigate reaches the popup before it leaves cross-site', async ({ + page, +}) => { + const { id, popup } = await open(page) + await expectPong(page, 0) + // Application-driven transition: on the popup's message, reply first, then + // navigate the popup to another site. The reply and the control share one + // ordered port, so the popup answers the reply before it leaves. + await page.evaluate( + ([url]) => { + const w = window as unknown as { + __onPong?: (pong: { n: number }) => void + __conn: { send(v: unknown): void; navigate(u: string): Promise } + } + w.__onPong = (pong) => { + if (pong.n !== 1) return + w.__conn.send({ type: 'ping', n: 42 }) + void w.__conn.navigate(url) + } + }, + [`${POPUP_B}/p#c=${id}`], + ) + await nextDocument(popup, () => ping(page, 1)) + // The popup replied to ping 42 from the first document before it left. + expect(await expectPong(page, 42)).toMatchObject({ path: '/p' }) + await expect(popup.locator('#status')).toHaveText('connected') + expect(popup.url()).toContain(POPUP_B) + expect(await diag(popup)).toEqual(['carrier-message-port']) +}) diff --git a/ts/packages/popup/e2e/server.mjs b/ts/packages/popup/e2e/server.mjs index 7efc56b1..2d6f82a5 100644 --- a/ts/packages/popup/e2e/server.mjs +++ b/ts/packages/popup/e2e/server.mjs @@ -54,7 +54,10 @@ const appPage = html(` allowedPopupOrigins: ['${ORIGINS.popup}', '${ORIGINS.popupB}'], onDiagnostic, }) - connection.on(Pong, (pong) => window.__events.push(pong)) + connection.on(Pong, (pong) => { + window.__events.push(pong) + window.__onPong?.(pong) + }) window.__conn = connection window.__popupWindow = popupWindow connection.closed.then((end) => window.__events.push({ type: 'end', ...end })) diff --git a/ts/packages/popup/src/connection.test.ts b/ts/packages/popup/src/connection.test.ts index 9d0d1a63..056535ef 100644 --- a/ts/packages/popup/src/connection.test.ts +++ b/ts/packages/popup/src/connection.test.ts @@ -411,6 +411,34 @@ describe('controls [POPUP-CONTROL-001/002/003/004]', () => { }) }) +describe('ordering across a transition [POPUP-CONNECTION-010]', () => { + it('delivers a reply sent before navigate to the popup before it leaves cross-origin', async () => { + const pair = fakePair() + const events: PopupDiagnostic[] = [] + const popupWindow = new OpenedWindow(pair.popupProxy as unknown as WindowProxy, pair.appView) + const app = PopupConnection.connect(popupWindow, { + connectionId: ID, + allowedPopupOrigins: [POPUP_ORIGIN, 'https://popup-b.example'], + onDiagnostic: (e) => void events.push(e), + }) + // Application-driven transition: reply, then navigate. + app.on(Ready, () => { + app.send(new Start()) + void app.navigate('https://popup-b.example/p') + }) + const side = acceptPopup(pair) + const order: string[] = [] + side.endpoint.on(Start, () => void order.push('start')) + const popup = await side.connection + await tick() + popup.send(new Ready(1)) + await tick(20) + expect(order).toEqual(['start']) + expect(pair.popupProxy.replaced).toEqual(['https://popup-b.example/p']) + expect(codes(events).at(-1)).toBe('control-connected') + }) +}) + describe('cross-origin replacement [POPUP-CONNECTION-008/009]', () => { const OTHER_POPUP = 'https://popup-b.example' From fb6f3179d9f8d1293a1d92ea8881e69e614b12cc Mon Sep 17 00:00:00 2001 From: Wondertan Date: Fri, 4 Sep 2026 03:45:33 +0100 Subject: [PATCH 13/24] feat(popup): open the popup as a separate window `PopupWindow.open(target, features?)` always requests a separate window with the `popup` feature and passes an optional size or position string through; a string that would sever the opener is rejected. The native-anchor fallback and mobile browsers still present a tab, which changes no rule. The browser suite asserts the presentation by requested size, since Chromium drops the BarProp flags after a cross-origin navigation while the size persists in every engine; the anchor path asserts a tab. Assisted-by: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw Signed-off-by: Wondertan --- ts/packages/popup/README.md | 11 +++++++---- ts/packages/popup/TEST_PLAN.md | 4 ++-- ts/packages/popup/docs/connection.md | 14 +++++++++----- ts/packages/popup/e2e/popup.spec.ts | 5 +++++ ts/packages/popup/e2e/server.mjs | 2 +- ts/packages/popup/src/connection.test.ts | 17 +++++++++++++++++ ts/packages/popup/src/window.ts | 14 +++++++++++--- 7 files changed, 52 insertions(+), 15 deletions(-) diff --git a/ts/packages/popup/README.md b/ts/packages/popup/README.md index 1da22c28..253c693a 100644 --- a/ts/packages/popup/README.md +++ b/ts/packages/popup/README.md @@ -33,9 +33,12 @@ void connection.navigate(anchor.href) if (popupWindow.opened) event.preventDefault() ``` -`PopupWindow.open(target)` synchronously attempts -`window.open('about:blank', target)` and returns a wrapper even when the browser -returns no handle. It throws `TypeError` before opening for an empty target or +`PopupWindow.open(target, features?)` synchronously attempts +`window.open('about:blank', target, 'popup,…')` and returns a wrapper even when +the browser returns no handle. The popup is always requested as a separate +window; `features` may add size or position (`width=480,height=720`) and must +not contain `noopener` or `noreferrer`. The native-anchor fallback and mobile +browsers present a tab instead, which changes no rule. It throws `TypeError` before opening for an empty target or one beginning with `_`. When no handle is returned, the connection binds the popup created by the same action's real anchor. See [popup creation and native-anchor fallback](docs/connection.md#popup-creation-and-native-anchor-fallback). @@ -44,7 +47,7 @@ native-anchor fallback](docs/connection.md#popup-creation-and-native-anchor-fall declare class PopupWindow { readonly opened: boolean - static open(target: string): PopupWindow + static open(target: string, features?: string): PopupWindow static current(): PopupWindow } ``` diff --git a/ts/packages/popup/TEST_PLAN.md b/ts/packages/popup/TEST_PLAN.md index a8459372..d0dddbc2 100644 --- a/ts/packages/popup/TEST_PLAN.md +++ b/ts/packages/popup/TEST_PLAN.md @@ -36,10 +36,10 @@ Coverage status: | ID | Assertion | |---|---| -| POPUP-WINDOW-001 | `PopupWindow.open(target)` synchronously attempts one named `about:blank` popup and retains the returned `WindowProxy`; it creates no second browsing context. | +| POPUP-WINDOW-001 | `PopupWindow.open(target)` synchronously attempts one named `about:blank` popup requested as a separate window (`popup` plus any caller size or position features; `noopener`/`noreferrer` rejected) and retains the returned `WindowProxy`; it creates no second browsing context. | | POPUP-WINDOW-002 | When scripted opening returns `null`, the same activation's real anchor retains its navigation, and only an exact initial source/origin/version/connection binding marks the wrapper opened. | | POPUP-WINDOW-003 | `PopupWindow.open` throws `TypeError` for an empty target and every target beginning with `_` before invoking `window.open`; `noopener`, `noreferrer`, delayed synthetic activation, a wrong source, and an opaque sandbox origin cannot bind the native-anchor path. | -| POPUP-WINDOW-004 | Popup presentation as a window or tab changes no connection, storage, recovery, or control rule; `closed` selects only whether a no-carrier direct operation can be attempted and never becomes a protocol result. | +| POPUP-WINDOW-004 | The scripted popup is a separate window (browser chrome bars hidden) and the native-anchor fallback is a tab; either presentation changes no connection, storage, recovery, or control rule; `closed` selects only whether a no-carrier direct operation can be attempted and never becomes a protocol result. | | POPUP-CONTROL-001 | While native-anchor binding is pending, `navigate` performs no browser operation and leaves the same activation's default navigation intact. Without an active carrier, `navigate` and `close` use the retained handle only while it is non-null and not closed and emit no control message. | | POPUP-CONTROL-002 | With an active carrier, application-endpoint `navigate` sends one exact canonical-HTTPS `Navigate` even while its retained handle appears usable; popup-endpoint `navigate` sends no control. Either popup-side path invokes replacement only after carrier continuity is preserved or prepared; malformed, credentialed, noncanonical, relative, non-HTTPS, and unpreparable navigations fail before browser navigation. Real-engine tests confirm a COOP-severed retained handle reports `closed`. | | POPUP-CONTROL-003 | `close` uses a non-null, non-closed retained handle directly regardless of carrier state and otherwise sends one `ClosePopup` over an active carrier. It closes local resources, is idempotent, and is accepted only for a package-created script-closable popup. | diff --git a/ts/packages/popup/docs/connection.md b/ts/packages/popup/docs/connection.md index 8065ffc8..e8d11ff5 100644 --- a/ts/packages/popup/docs/connection.md +++ b/ts/packages/popup/docs/connection.md @@ -157,8 +157,12 @@ Messages are caller-owned classes registered independently. The connection requires unique discriminators but defines no protocol namespace, closed union, or caller message type. -`PopupWindow.open(target)` creates the application-side lifecycle object and -synchronously attempts `window.open('about:blank', target)`. +`PopupWindow.open(target, features?)` creates the application-side lifecycle +object and synchronously attempts `window.open('about:blank', target, +'popup,…')`: the popup is always requested as a separate window, and the +optional `features` string adds only size or position; a string carrying +`noopener` or `noreferrer` is rejected because the MessagePort carrier needs +the opener. It rejects an empty target and every name beginning with `_` before invoking the browser, matching the HTML [valid navigable target name](https://html.spec.whatwg.org/multipage/document-sequences.html#valid-navigable-target-name-or-keyword) @@ -247,8 +251,8 @@ function activate(event: MouseEvent) { } ``` -`PopupWindow.open(target)` is one-shot and always attempts -`window.open('about:blank', target)`. +`PopupWindow.open(target, features?)` is one-shot and always attempts +`window.open('about:blank', target, 'popup,…')`. When the browser returns a usable handle, `PopupWindow` retains the exact `WindowProxy` and the caller prevents native anchor navigation; only a later `navigate(url)` chooses @@ -288,7 +292,7 @@ declare class PopupWindow { /** @internal PopupConnection.connect calls this after exact validation. */ bind(source: WindowProxy): void - static open(target: string): PopupWindow + static open(target: string, features?: string): PopupWindow static current(): PopupWindow } diff --git a/ts/packages/popup/e2e/popup.spec.ts b/ts/packages/popup/e2e/popup.spec.ts index 4ac1b300..3d9cab40 100644 --- a/ts/packages/popup/e2e/popup.spec.ts +++ b/ts/packages/popup/e2e/popup.spec.ts @@ -107,11 +107,16 @@ test('[POPUP-WINDOW-001] [POPUP-PORT-001] scripted open connects over MessagePor expect(await expectPong(page, 1)).toMatchObject({ path: '/p', isolated: false }) expect(await diag(page)).toEqual(['window-opened', 'control-direct', 'carrier-message-port']) expect(await diag(popup)).toEqual(['carrier-message-port']) + // A separate window, not a tab: the requested size took effect. (Chromium + // drops the BarProp flags after a cross-origin navigation; size persists.) + expect(await popup.evaluate(() => window.outerWidth)).toBe(480) }) test('[POPUP-WINDOW-002] blocked scripted open binds the native anchor popup', async ({ page }) => { const { popup } = await open(page, { blocked: true }) await expect(popup.locator('#status')).toHaveText('connected') + // An anchor carries no features, so the fallback popup is a tab. + expect(await popup.evaluate(() => window.toolbar.visible)).toBe(true) await ping(page, 1) await expectPong(page, 1) expect(await diag(page)).toEqual(['window-blocked', 'window-bound', 'carrier-message-port']) diff --git a/ts/packages/popup/e2e/server.mjs b/ts/packages/popup/e2e/server.mjs index 2d6f82a5..2041d283 100644 --- a/ts/packages/popup/e2e/server.mjs +++ b/ts/packages/popup/e2e/server.mjs @@ -48,7 +48,7 @@ const appPage = html(` window.open = (...args) => (window.__handle = realOpen(...args)) const anchor = document.getElementById('go') anchor.addEventListener('click', (event) => { - const popupWindow = PopupWindow.open(anchor.target) + const popupWindow = PopupWindow.open(anchor.target, 'width=480,height=720') const connection = PopupConnection.connect(popupWindow, { connectionId: window.__id, allowedPopupOrigins: ['${ORIGINS.popup}', '${ORIGINS.popupB}'], diff --git a/ts/packages/popup/src/connection.test.ts b/ts/packages/popup/src/connection.test.ts index 056535ef..60b6bed5 100644 --- a/ts/packages/popup/src/connection.test.ts +++ b/ts/packages/popup/src/connection.test.ts @@ -144,6 +144,23 @@ describe('validation [POPUP-CONNECTION-007]', () => { } }) + it('PopupWindow.open always requests a separate window and keeps the opener [POPUP-WINDOW-001]', () => { + const open = vi.fn(() => null) + vi.stubGlobal('window', { open }) + try { + PopupWindow.open('libid-popup') + PopupWindow.open('libid-popup', 'width=480,height=720') + expect(open.mock.calls).toEqual([ + ['about:blank', 'libid-popup', 'popup'], + ['about:blank', 'libid-popup', 'popup,width=480,height=720'], + ]) + expect(() => PopupWindow.open('libid-popup', 'noopener')).toThrow(TypeError) + expect(() => PopupWindow.open('libid-popup', 'width=1,NoReferrer')).toThrow(TypeError) + } finally { + vi.unstubAllGlobals() + } + }) + it('PopupWindow.current rejects an embedded document', () => { const frame = { top: {} } vi.stubGlobal('window', frame) diff --git a/ts/packages/popup/src/window.ts b/ts/packages/popup/src/window.ts index b4cbe7e4..4c0087cf 100644 --- a/ts/packages/popup/src/window.ts +++ b/ts/packages/popup/src/window.ts @@ -22,12 +22,20 @@ function usable(handle: WindowProxy | null): handle is WindowProxy { export class PopupWindow { protected constructor() {} - /** Synchronously attempts `window.open('about:blank', target)`. */ - static open(target: string): PopupWindow { + /** + * Synchronously attempts `window.open('about:blank', target, 'popup,…')`. + * The popup is always requested as a separate window; `features` may add + * size or position and MUST NOT sever the opener. + */ + static open(target: string, features = ''): PopupWindow { if (target === '' || target.startsWith('_')) { throw new TypeError('popup target must be a nonempty name not beginning with "_"') } - return new OpenedWindow(window.open('about:blank', target), window) + if (/\b(noopener|noreferrer)\b/i.test(features)) { + throw new TypeError('popup features must not sever the opener') + } + const windowFeatures = features === '' ? 'popup' : `popup,${features}` + return new OpenedWindow(window.open('about:blank', target, windowFeatures), window) } /** Adopts the current popup document; creates nothing. */ From 47018e7efb9b7bbb0bc8f18fb333c8f3c800082a Mon Sep 17 00:00:00 2001 From: Wondertan Date: Sat, 5 Sep 2026 22:03:51 +0100 Subject: [PATCH 14/24] feat(popup): reach isolation through a same-origin fallback automatically `accept` gains `isolationFallbackUrl`. Its presence requires cross-origin isolation of the accepting document and names a same-origin replacement, resolved against the current document with the fragment inherited unless the value spells its own. After selecting its carrier and before installing delivery or settling `ready`, a document that is not isolated keeps the still-unstarted port through the worker, so every value the application already sent travels with it, settles `closed`, and replaces itself with the fallback; the fallback restores the port, becomes ready, and delivers those values exactly once. A fallback that stays non-isolated fails with `isolation-unavailable` instead of looping. The endpoint waits briefly for a worker registration the host is making in the same document. A host serves one document with Document-Isolation-Policy for engines that honour it and a COOP fallback for the rest; the application observes one connection and calls ordinary `navigate`. The e2e origins move to `.localhost` names, which every engine resolves locally: the public wildcard domains stalled for seconds under DNS load and made the matrix flaky. Two new browser cases cover the DIP path, the fallback path with a handshake-time send delivered once, and the loop guard. 82 unit tests; 98 browser cases pass, 2 skip where DIP isolates directly. Assisted-by: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw Signed-off-by: Wondertan --- ts/packages/popup/METRICS.md | 2 +- ts/packages/popup/README.md | 32 ++++- ts/packages/popup/TEST_PLAN.md | 8 +- ts/packages/popup/docs/connection.md | 21 +++ ts/packages/popup/docs/message-port.md | 6 + ts/packages/popup/e2e/popup.spec.ts | 79 +++++++++-- ts/packages/popup/e2e/server.mjs | 43 ++++-- ts/packages/popup/e2e/tls.mjs | 2 +- ts/packages/popup/playwright.config.ts | 2 +- ts/packages/popup/src/connection.test.ts | 173 +++++++++++++++++++++++ ts/packages/popup/src/connection.ts | 108 ++++++++++++-- ts/packages/popup/src/diagnostics.ts | 2 + ts/packages/popup/src/keeper.ts | 20 +++ ts/packages/popup/src/testing/fakes.ts | 36 ++++- ts/packages/popup/src/window.ts | 19 ++- 15 files changed, 504 insertions(+), 49 deletions(-) diff --git a/ts/packages/popup/METRICS.md b/ts/packages/popup/METRICS.md index 9116bcab..55199ed2 100644 --- a/ts/packages/popup/METRICS.md +++ b/ts/packages/popup/METRICS.md @@ -31,7 +31,7 @@ value. | Popup window | `window-opened`, `window-blocked`, `window-bound` | | Connection | `handshake-rejected`, `opener-timeout`, `carrier-message-port`, `carrier-restored`, `carrier-fallback`, `fallback-unavailable`, `fallback-failed`, `popup-unavailable`, `send-unavailable`, `connection-closed` and `connection-failed` with `durationMs` since construction | | Message delivery | `decode-rejected`; MessagePort adds no encoding or clock, so no size or latency measurement exists | -| Continuity | `keep-acknowledged` with `durationMs`, `keep-failed`, `claim-empty`, `claim-failed`, `continuity-unsupported` | +| Continuity | `keep-acknowledged` with `durationMs`, `keep-failed`, `claim-empty`, `claim-failed`, `continuity-unsupported`, `isolation-fallback`, `isolation-unavailable` | | Control | `control-direct`, `control-connected`, `control-rejected`; never remote success | | WebRTC | signaling-path class, offer publication, answer pickup, candidate class, selected-pair class, ICE checks, DTLS, data-channel open, and terminal failure | diff --git a/ts/packages/popup/README.md b/ts/packages/popup/README.md index 253c693a..51890ba4 100644 --- a/ts/packages/popup/README.md +++ b/ts/packages/popup/README.md @@ -226,6 +226,34 @@ interface PopupDiagnostic { `code` is one of the stable identifiers catalogued in [metrics and diagnostics](METRICS.md); the set grows with new carriers. +### Isolation + +A participating document that needs cross-origin isolation passes +`isolationFallbackUrl`. Its presence requires isolation and names a +same-origin fallback, resolved against the current document; the fragment is +inherited unless the value spells its own, including an empty `#`. + +```ts +PopupConnection.accept(popupWindow, { + connectionId, + allowedApplicationOrigins, + isolationFallbackUrl: '/prover/fallback', +}) +``` + +The host serves `/prover` with `Document-Isolation-Policy: isolate-and-require-corp`, +`Cross-Origin-Opener-Policy: unsafe-none`, and no COEP, and `/prover/fallback` +with `Cross-Origin-Opener-Policy: same-origin` and +`Cross-Origin-Embedder-Policy: require-corp`. Both pass the option. Where the +engine honours DIP, `/prover` is isolated and keeps its opener, so nothing +else happens. Where it does not, `/prover` establishes its carrier, keeps the +still-unstarted port through the worker so every value already sent travels +with it, and replaces itself with the fallback, whose COOP isolates it; the +fallback restores the port and becomes ready. The departing endpoint never +becomes ready and delivers nothing. A fallback that is itself not isolated +fails with `isolation-unavailable` instead of looping. The package assigns no +meaning to the paths; the application observes one connection throughout. + ### Continuity worker Connected same-origin navigation between participating popup documents @@ -270,8 +298,8 @@ manage carrier selection, replacement, or lifetime. ## Testing `pnpm test` runs the unit suite in Node over real `MessageChannel` ports. -`pnpm test:e2e` builds the package and its worker entry, serves three -cross-site HTTPS origins, and drives the Playwright matrix (Chromium, Firefox, +`pnpm test:e2e` builds the package and its worker entry, serves four +cross-origin HTTPS documents, and drives the Playwright matrix (Chromium, Firefox, WebKit, mobile Chrome, mobile WebKit) through both creation paths, isolation round trips over one preserved port, port expiry, and every fail-closed path. [TEST_PLAN.md](TEST_PLAN.md) records which rows each layer covers and which diff --git a/ts/packages/popup/TEST_PLAN.md b/ts/packages/popup/TEST_PLAN.md index d0dddbc2..f2448b4d 100644 --- a/ts/packages/popup/TEST_PLAN.md +++ b/ts/packages/popup/TEST_PLAN.md @@ -10,10 +10,10 @@ Coverage status: CONNECTION, PORT, KEEPER, and DIAGNOSTIC row except the clauses below, including `navigateAway` and the popup-side wildcard. - **Browser** (`pnpm test:e2e`, Playwright on Chromium, Firefox, WebKit, - mobile Chrome, and mobile WebKit emulation over three cross-site HTTPS - origins): popup creation on both paths, opener authentication, connected + mobile Chrome, and mobile WebKit emulation over four cross-origin HTTPS + documents): popup creation on both paths, opener authentication, connected navigation into and out of a COOP-isolated document over one preserved - port, a cross-site hop between participating documents that re-handshakes + port, a cross-origin hop between participating documents that re-handshakes over the opener, close after the opener is severed, port expiry across a non-participating hop, and every fail-closed path. - **Deferred**: every POPUP-RTC row; no WebRTC carrier exists yet. @@ -60,6 +60,8 @@ Coverage status: | POPUP-CONNECTION-008 | `PopupConnection.navigate()` directly between same-origin participating documents preserves a MessagePort or prepares a nontransferable carrier. Across origins, including across sites, it never gives a MessagePort to the source origin's worker: it retires that popup endpoint and the allowed destination establishes a fresh carrier through its opener or fallback. A navigation outside that API loses the current carrier. A later participating document may establish the first RTC carrier from the still-unused initial fallback without round metadata; after RTC is active, an unmanaged navigation terminates the logical connection without restarting round zero. | | POPUP-CONNECTION-009 | `connect` and `accept` copy nonempty, duplicate-free sets of canonical HTTPS `allowedPopupOrigins` and `allowedApplicationOrigins`, respectively. Every initial, native-anchor, replacement, MessagePort, and RTC participant binds one exact browser-observed member of the peer's set. Sequential popup participants may use different admitted origins without changing the logical connection ID or caller registrations. `accept` alone also takes `'*'`, which binds any canonical HTTPS browser-observed origin and rejects an opaque or non-HTTPS one; `connect` rejects a wildcard. An empty set or duplicate, malformed, noncanonical, credentialed, or unapproved origin fails before selection or caller delivery. | | POPUP-CONNECTION-010 | Caller messages sent over a carrier before a `Navigate` control are delivered to the popup's handlers before the popup acts on the control, for same-origin and cross-origin destinations alike. A transition that needs the application's reply is therefore application-driven (reply, then `navigate`) or the popup navigates only after receiving the reply; an application-side `navigateAway` immediately after a send does not wait for the port. | +| POPUP-CONNECTION-011 | With `isolationFallbackUrl`, an isolated document installs normally. A non-isolated document keeps its unstarted port through the worker before `ready` settles or any value is delivered, settles `closed` as closed, and replaces itself with the same-origin fallback, which restores the port, becomes ready, and delivers every value the application sent meanwhile exactly once. The application observes one carrier. The fallback resolves against the current document, must be same-origin HTTPS, and inherits the fragment unless it spells its own, including an empty one. | +| POPUP-CONNECTION-012 | A document that already is the fallback, by origin, path, and query, and remains non-isolated fails with `isolation-unavailable` and never navigates again. Invalid, non-HTTPS, or cross-origin fallbacks reject synchronously. Close during the hop aborts without navigating; a refused keep or missing worker fails `ready`. Without the option, behavior is unchanged. | ## MessagePort and navigation continuity diff --git a/ts/packages/popup/docs/connection.md b/ts/packages/popup/docs/connection.md index e8d11ff5..0fffbc3c 100644 --- a/ts/packages/popup/docs/connection.md +++ b/ts/packages/popup/docs/connection.md @@ -179,6 +179,26 @@ connection ID before selecting a new carrier. A matching entry restores its native port; no entry leaves the fresh endpoint to use its available opener or signaling resources normally. +`accept` may also take `isolationFallbackUrl`, which requires cross-origin +isolation of the accepting document. After selecting its carrier and before +installing delivery or settling `ready`, the endpoint checks +`crossOriginIsolated`. An isolated document proceeds. A non-isolated document +keeps its still-unstarted port through the worker, so every value the +application already sent stays queued inside it, settles `closed` as closed, +and replaces itself with the fallback; the fallback restores the port and +becomes ready. The fallback is resolved against the current document, must be +same-origin HTTPS, and inherits the current fragment unless it spells its own, +including an empty `#`. Because the host may register its worker in the same +document, the endpoint waits up to the keeper reply deadline for that +registration to become active before it keeps the port. A document that +already is the fallback, compared by origin, path, and query, and remains +non-isolated fails with +`isolation-unavailable`; a refused keep or missing worker fails as it does for +`navigate`. Only a MessagePort carrier can be preserved this way; a fallback +carrier under the option fails with `continuity-unsupported`. This is what lets +a host serve one document with Document-Isolation-Policy for engines that +honour it and a COOP fallback for the rest, with no protocol change. + `connect` copies `allowedPopupOrigins`, and `accept` copies `allowedApplicationOrigins`. Both must be nonempty, duplicate-free sets of canonical HTTPS origins; either constructor rejects an invalid member or @@ -345,6 +365,7 @@ declare const PopupConnection: { options: { connectionId: string allowedApplicationOrigins: readonly string[] | '*' + isolationFallbackUrl?: string fallback?: CarrierConstructor onDiagnostic?: (event: PopupDiagnostic) => void }, diff --git a/ts/packages/popup/docs/message-port.md b/ts/packages/popup/docs/message-port.md index bf65e5db..04d02003 100644 --- a/ts/packages/popup/docs/message-port.md +++ b/ts/packages/popup/docs/message-port.md @@ -255,6 +255,12 @@ of it. Worker loss or a failed `keep` acknowledgement prevents navigation with live state. No `BroadcastChannel`, cookie, IndexedDB record, request, or URL carries the port. +Preservation also serves the isolation fallback: a document that must be +isolated and is not keeps the port it just selected, before starting it, and +the isolated same-origin replacement claims it. Because the port was never +started, values the application sent after the handshake are still inside it +and arrive in the replacement in order. + Each operation carries its own reply `MessagePort` and waits at most `KEEPER_REPLY_TIMEOUT_MS = 2_000` for the worker's answer. The popup resolves the matching registration when it needs it rather than once at construction: diff --git a/ts/packages/popup/e2e/popup.spec.ts b/ts/packages/popup/e2e/popup.spec.ts index 3d9cab40..09c4d3aa 100644 --- a/ts/packages/popup/e2e/popup.spec.ts +++ b/ts/packages/popup/e2e/popup.spec.ts @@ -5,10 +5,10 @@ import { expect, type Page, test } from '@playwright/test' -const APP_A = 'https://app-a.lvh.me:4581' -const APP_B = 'https://app-b.local.gd:4582' -const POPUP = 'https://popup.localtest.me:4583' -const POPUP_B = 'https://popup-b.lvh.me:4584' +const APP_A = 'https://app-a.localhost:4581' +const APP_B = 'https://app-b.localhost:4582' +const POPUP = 'https://popup.localhost:4583' +const POPUP_B = 'https://popup-b.localhost:4584' const freshId = () => crypto.randomUUID() @@ -26,24 +26,43 @@ const diag = (page: Page) => page.evaluate(() => (window as unknown as { __diag: /** Arm the application page and click its anchor for one connection id. */ async function open( page: Page, - options: { app?: string; id?: string; href?: string; blocked?: boolean; rel?: string } = {}, + options: { + app?: string + id?: string + href?: string + blocked?: boolean + rel?: string + /** Send this ping the instant the application's handshake completes. */ + pingOnHandshake?: number + } = {}, ): Promise<{ id: string; popup: Page }> { const id = options.id ?? freshId() await page.goto(options.app ?? APP_A) await page.evaluate( - ([id, href, blocked, rel]) => { - const w = window as unknown as { __id: string; open: unknown } + ([id, href, blocked, rel, ping]) => { + const w = window as unknown as { + __id: string + open: unknown + __onDiag?: (code: string) => void + __conn: { send(v: unknown): void } + } w.__id = id const anchor = document.getElementById('go') as HTMLAnchorElement anchor.href = href if (rel) anchor.rel = rel if (blocked) w.open = () => null + if (ping !== null) { + w.__onDiag = (code) => { + if (code === 'carrier-message-port') w.__conn.send({ type: 'ping', n: ping }) + } + } }, [ id, options.href ?? `${POPUP}/p#c=${id}`, options.blocked ?? false, options.rel ?? '', + options.pingOnHandshake ?? null, ] as const, ) const popupPromise = page.context().waitForEvent('page') @@ -282,7 +301,7 @@ test('[POPUP-CONTROL-002] malformed navigation fails before any browser operatio expect((await diag(page)).filter((c) => c === 'control-rejected')).toHaveLength(3) }) -test('[POPUP-CONNECTION-008] [POPUP-CONNECTION-009] a cross-site participating hop re-handshakes over the opener', async ({ +test('[POPUP-CONNECTION-008] [POPUP-CONNECTION-009] a cross-origin participating hop re-handshakes over the opener', async ({ page, }) => { const { id, popup } = await open(page) @@ -306,7 +325,7 @@ test('[POPUP-CONNECTION-008] [POPUP-CONNECTION-009] a cross-site participating h expect((await diag(page)).filter((c) => c === 'carrier-message-port')).toHaveLength(3) }) -test('[POPUP-CONNECTION-008] a cross-site isolated destination needs a fallback', async ({ +test('[POPUP-CONNECTION-008] a cross-origin isolated destination needs a fallback', async ({ page, }) => { const { id, popup } = await open(page) @@ -366,7 +385,7 @@ test("[POPUP-CONNECTION-009] a popup deployed with '*' accepts an unlisted appli expect(await diag(popup)).toEqual(['carrier-message-port']) }) -test('[POPUP-CONNECTION-010] a reply sent before navigate reaches the popup before it leaves cross-site', async ({ +test('[POPUP-CONNECTION-010] a reply sent before navigate reaches the popup before it leaves cross-origin', async ({ page, }) => { const { id, popup } = await open(page) @@ -395,3 +414,43 @@ test('[POPUP-CONNECTION-010] a reply sent before navigate reaches the popup befo expect(popup.url()).toContain(POPUP_B) expect(await diag(popup)).toEqual(['carrier-message-port']) }) + +test('[POPUP-CONNECTION-011] an isolation-requiring document isolates by DIP or by its COOP fallback, delivering once', async ({ + page, +}) => { + const id = freshId() + // Send the instant the handshake completes: the value must reach the + // isolated document exactly once whichever path the engine takes. + const { popup } = await open(page, { id, href: `${POPUP}/dip#c=${id}`, pingOnHandshake: 77 }) + await expect(popup.locator('#status')).toHaveText('connected') + expect(await popup.evaluate(() => crossOriginIsolated)).toBe(true) + expect(await expectPong(page, 77)).toMatchObject({ isolated: true }) + await page.waitForTimeout(300) + expect((await events(page)).filter((e) => (e as Pong).n === 77)).toHaveLength(1) + const popupDiag = await diag(popup) + const viaFallback = popup.url().includes('/dip/fallback') + expect(popupDiag).toEqual(viaFallback ? ['carrier-restored'] : ['carrier-message-port']) + // The application saw exactly one carrier for the whole transition. + expect((await diag(page)).filter((c) => c === 'carrier-message-port')).toHaveLength(1) + await ping(page, 78) + await expectPong(page, 78) +}) + +test('[POPUP-CONNECTION-012] a fallback that stays non-isolated fails closed without looping', async ({ + page, +}) => { + const id = freshId() + const { popup } = await open(page, { id, href: `${POPUP}/dip-broken#c=${id}` }) + await expect(popup.locator('#status')).toHaveText(/connected|failed/) + test.skip( + await popup.evaluate(() => crossOriginIsolated), + 'engine isolates by DIP; no fallback runs', + ) + await expect(popup.locator('#status')).toHaveText('failed: isolation-unavailable') + expect(popup.url()).toContain('/dip-broken/fallback') + expect(await diag(popup)).toEqual([ + 'carrier-restored', + 'isolation-unavailable', + 'connection-failed', + ]) +}) diff --git a/ts/packages/popup/e2e/server.mjs b/ts/packages/popup/e2e/server.mjs index 2041d283..1432fb3b 100644 --- a/ts/packages/popup/e2e/server.mjs +++ b/ts/packages/popup/e2e/server.mjs @@ -1,5 +1,5 @@ -// Three genuinely cross-site origins: an allowed application, an unlisted -// application, and the popup origin with a participating page, an isolated +// Four cross-origin documents: an allowed application, an unlisted +// application, and two popup origins with a participating page, an isolated // participating page, a non-participating page, and the worker script. // The page scripts are the smallest caller protocol that exercises every // documented path; they own nothing the package cares about. @@ -10,13 +10,14 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { makeCertificate } from './tls.mjs' -// Two popup origins on different sites so a participating document can be -// replaced by one on another site. +// Four distinct origins under `.localhost`, which every engine resolves +// locally without DNS. They are cross-origin, which is what the transport's +// rules depend on; site-level behavior is not exercised here. export const ORIGINS = { - appA: 'https://app-a.lvh.me:4581', - appB: 'https://app-b.local.gd:4582', - popup: 'https://popup.localtest.me:4583', - popupB: 'https://popup-b.lvh.me:4584', + appA: 'https://app-a.localhost:4581', + appB: 'https://app-b.localhost:4582', + popup: 'https://popup.localhost:4583', + popupB: 'https://popup-b.localhost:4584', } const dist = join(dirname(fileURLToPath(import.meta.url)), 'dist') @@ -33,7 +34,10 @@ const protocol = ` const Away = message('away', (v) => { if (typeof v.url !== 'string') throw new Error('away'); return v }) window.__events = [] window.__diag = [] - const onDiagnostic = (d) => window.__diag.push(d.code) + const onDiagnostic = (d) => { + window.__diag.push(d.code) + window.__onDiag?.(d.code) + } ` const html = (body) => `popup e2e${body}` @@ -74,13 +78,20 @@ const popupPage = html(` ${protocol} const id = new URLSearchParams(location.hash.slice(1)).get('c') ?? '' window.__isolated = crossOriginIsolated - // /p-any is the same document deployed for any opener origin. + // /p-any is the same document deployed for any opener origin. /dip and + // /dip-broken require isolation and name their COOP fallback. const allowedApplicationOrigins = location.pathname === '/p-any' ? '*' : ['${ORIGINS.appA}'] + const isolationFallbackUrl = location.pathname.startsWith('/dip-broken') + ? '/dip-broken/fallback' + : location.pathname.startsWith('/dip') + ? '/dip/fallback' + : undefined // Accept first: the claim must run before any other network work, and // handlers registered before yielding precede every delivery. const connection = PopupConnection.accept(PopupWindow.current(), { connectionId: id, allowedApplicationOrigins, + isolationFallbackUrl, onDiagnostic, }) window.__conn = connection @@ -131,6 +142,11 @@ const ISOLATED = { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', } +// Isolation without COOP: the opener survives where the engine supports DIP. +const DIP = { + 'Cross-Origin-Opener-Policy': 'unsafe-none', + 'Document-Isolation-Policy': 'isolate-and-require-corp', +} function popupHandler(req, res) { const url = new URL(req.url, 'https://popup.invalid') @@ -145,7 +161,14 @@ function popupHandler(req, res) { case '/p-any': return send(res, 200, { ...HTML, 'Cross-Origin-Opener-Policy': 'unsafe-none' }, popupPage) case '/isolated': + case '/dip/fallback': return send(res, 200, { ...HTML, ...ISOLATED }, popupPage) + case '/dip': + case '/dip-broken': + return send(res, 200, { ...HTML, ...DIP }, popupPage) + case '/dip-broken/fallback': + // COOP without COEP: never isolated, so the fallback must not loop. + return send(res, 200, { ...HTML, 'Cross-Origin-Opener-Policy': 'same-origin' }, popupPage) case '/external': return send(res, 200, HTML, externalPage) default: diff --git a/ts/packages/popup/e2e/tls.mjs b/ts/packages/popup/e2e/tls.mjs index 089bf2fe..b9cf7bc4 100644 --- a/ts/packages/popup/e2e/tls.mjs +++ b/ts/packages/popup/e2e/tls.mjs @@ -1,5 +1,5 @@ // Per-run self-signed certificate covering every e2e hostname, so the -// multi-origin topology is genuinely cross-site over HTTPS (the only way +// multi-origin topology is genuinely cross-origin over HTTPS (the only way // COOP and opener severing behave realistically). Playwright runs with // ignoreHTTPSErrors; nothing here is a production artifact. diff --git a/ts/packages/popup/playwright.config.ts b/ts/packages/popup/playwright.config.ts index b4b9c3ca..82925721 100644 --- a/ts/packages/popup/playwright.config.ts +++ b/ts/packages/popup/playwright.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ use: { ignoreHTTPSErrors: true }, webServer: { command: 'node e2e/build.mjs && node e2e/server.mjs', - url: 'https://popup.localtest.me:4583/health', + url: 'https://popup.localhost:4583/health', ignoreHTTPSErrors: true, reuseExistingServer: false, }, diff --git a/ts/packages/popup/src/connection.test.ts b/ts/packages/popup/src/connection.test.ts index 60b6bed5..418ab4f9 100644 --- a/ts/packages/popup/src/connection.test.ts +++ b/ts/packages/popup/src/connection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { PopupConnection } from './connection.js' import { PopupError } from './diagnostics.js' +import { PortKeeper } from './keeper.js' import type { PopupDiagnostic } from './diagnostics.js' import type { Carrier, CarrierConstructor, Message } from './message.js' import { PortCarrier } from './port.js' @@ -636,6 +637,178 @@ describe('popup-side wildcard allowlist [POPUP-CONNECTION-009]', () => { }) }) +describe('isolation fallback [POPUP-CONNECTION-011/012]', () => { + const FALLBACK = '/prover/fallback' + + /** A popup endpoint on `path` with the fallback option, over a pair. */ + function acceptIsolating( + pair: FakePair, + opts: { + worker?: Parameters[0] + fallback?: string + opener?: boolean + } = {}, + ) { + const events: PopupDiagnostic[] = [] + const view = opts.opener === false ? { ...pair.popupWindow, opener: null } : pair.popupWindow + const popup = new CurrentWindow( + view as Window, + opts.worker === undefined ? () => Promise.resolve(undefined) : registrationWith(opts.worker), + ) + const endpoint = PopupConnection.accept(popup, { + connectionId: ID, + allowedApplicationOrigins: [APP_ORIGIN], + isolationFallbackUrl: opts.fallback ?? FALLBACK, + onDiagnostic: (e) => void events.push(e), + }) + return { endpoint, events } + } + + it('installs without navigating when the document is already isolated', async () => { + const pair = fakePair() + pair.relocate(POPUP_ORIGIN, '/prover', '#c=1') + pair.setIsolated(true) + const app = connectApp(pair) + const side = acceptIsolating(pair) + await side.endpoint.ready + await tick() // the application installs its carrier on the echo, one task later + expect(codes(side.events)).toEqual(['carrier-message-port']) + expect(pair.popupProxy.replaced).toEqual([]) + const starts = vi.fn() + side.endpoint.on(Start, starts) + app.connection.send(new Start()) + await tick() + expect(starts).toHaveBeenCalledTimes(1) + }) + + it('keeps the port before navigating, with ready pending and nothing delivered', async () => { + const pair = fakePair() + pair.relocate(POPUP_ORIGIN, '/prover', '#c=1') + const app = connectApp(pair) + const scope = fakeScope() + const side = acceptIsolating(pair, { worker: scope.worker }) + const starts = vi.fn() + side.endpoint.on(Start, starts) + await tick(20) + // The application already sent into the handshake port; it must travel. + app.connection.send(new Start()) + await tick(20) + expect(starts).not.toHaveBeenCalled() + expect(scope.pending).toHaveLength(1) + expect(pair.popupProxy.replaced).toEqual([`${POPUP_ORIGIN}${FALLBACK}#c=1`]) + expect(codes(side.events)).toEqual([ + 'claim-empty', + 'carrier-message-port', + 'isolation-fallback', + 'keep-acknowledged', + 'connection-closed', + ]) + let settled = false + void side.endpoint.ready.then( + () => (settled = true), + () => (settled = true), + ) + await tick() + expect(settled).toBe(false) + expect(await side.endpoint.closed).toEqual({ outcome: 'closed' }) + + // The isolated fallback document restores the port and receives the value once. + pair.relocate(POPUP_ORIGIN, FALLBACK, '#c=1') + pair.setIsolated(true) + const next = acceptIsolating(pair, { worker: scope.worker }) + const restored = vi.fn() + next.endpoint.on(Start, restored) + await next.endpoint.ready + await tick() + expect(codes(next.events)).toEqual(['carrier-restored']) + expect(restored).toHaveBeenCalledTimes(1) + expect(pair.popupProxy.replaced).toHaveLength(1) + // Still one application carrier throughout. + expect(codes(app.events).filter((c) => c === 'carrier-message-port')).toHaveLength(1) + }) + + it('fails without looping when the fallback itself is not isolated', async () => { + const pair = fakePair() + pair.relocate(POPUP_ORIGIN, FALLBACK, '#c=1') + connectApp(pair) + const side = acceptIsolating(pair, { worker: fakeScope().worker }) + await expect(side.endpoint.ready).rejects.toThrow('isolation-unavailable') + expect(pair.popupProxy.replaced).toEqual([]) + expect(await side.endpoint.closed).toEqual({ outcome: 'failed', code: 'isolation-unavailable' }) + }) + + it('rejects an invalid, non-HTTPS, or cross-origin fallback synchronously', () => { + const pair = fakePair() + for (const bad of ['http://popup.example/x', 'https://other.example/x', 'https://:bad']) { + const popup = new CurrentWindow(pair.popupWindow, () => Promise.resolve(undefined)) + expect(() => + PopupConnection.accept(popup, { + connectionId: ID, + allowedApplicationOrigins: [APP_ORIGIN], + isolationFallbackUrl: bad, + }), + ).toThrow(TypeError) + } + expect(pair.popupView.listeners.size).toBe(0) + }) + + it('inherits the fragment unless the fallback spells its own, including an empty one', async () => { + for (const [given, expected] of [ + ['/f', `${POPUP_ORIGIN}/f#c=1`], + ['/f#own', `${POPUP_ORIGIN}/f#own`], + ['/f#', `${POPUP_ORIGIN}/f#`], + ] as const) { + const pair = fakePair() + pair.relocate(POPUP_ORIGIN, '/prover', '#c=1') + connectApp(pair) + const side = acceptIsolating(pair, { worker: fakeScope().worker, fallback: given }) + await tick(20) + expect(pair.popupProxy.replaced, given).toEqual([expected]) + expect(codes(side.events)).toContain('keep-acknowledged') + } + }) + + it('aborts on close during the hop and reports a refused keep as failure', async () => { + /** A worker holding nothing that answers keeps as told, or never. */ + const worker = (keepReply: { ok: boolean } | null) => ({ + postMessage(message: unknown, transfer: Transferable[]) { + const reply = transfer[transfer.length - 1] as MessagePort + const { type } = message as { type: string } + if (type === 'libid-popup-claim') reply.postMessage({ port: false }) + else if (keepReply) reply.postMessage(keepReply) + }, + }) + const pair = fakePair() + pair.relocate(POPUP_ORIGIN, '/prover', '#c=1') + connectApp(pair) + const refused = acceptIsolating(pair, { worker: worker({ ok: false }) }) + await expect(refused.endpoint.ready).rejects.toThrow('keep-failed') + expect(pair.popupProxy.replaced).toEqual([]) + expect(await refused.endpoint.closed).toEqual({ outcome: 'failed', code: 'keep-failed' }) + + const pair2 = fakePair() + pair2.relocate(POPUP_ORIGIN, '/prover', '#c=1') + connectApp(pair2) + const closing = acceptIsolating(pair2, { worker: worker(null) }) + await tick(20) // handshake done; the keep is now waiting on the silent worker + expect(codes(closing.events)).toContain('isolation-fallback') + await closing.endpoint.close() + expect(await closing.endpoint.closed).toEqual({ outcome: 'closed' }) + await tick() + expect(pair2.popupProxy.replaced).toEqual([]) + }) + + it('leaves behavior unchanged when the option is absent', async () => { + const pair = fakePair() + pair.relocate(POPUP_ORIGIN, '/prover', '#c=1') + connectApp(pair) + const side = acceptPopup(pair) + await side.connection + expect(codes(side.events)).toEqual(['carrier-message-port']) + expect(pair.popupProxy.replaced).toEqual([]) + }) +}) + describe('fallback seam [POPUP-CONNECTION-002/004/005] [POPUP-DIAGNOSTIC-003]', () => { it('fails closed with fallback-unavailable exactly once when no opener and no constructor', async () => { const pair = fakePair() diff --git a/ts/packages/popup/src/connection.ts b/ts/packages/popup/src/connection.ts index c81518fc..2c1d4203 100644 --- a/ts/packages/popup/src/connection.ts +++ b/ts/packages/popup/src/connection.ts @@ -12,7 +12,7 @@ import { type Reporter, reportUndeliverable, } from './diagnostics.js' -import { activeWorker, PortKeeper } from './keeper.js' +import { activeWorker, bounded, PortKeeper } from './keeper.js' import { type Carrier, type CarrierConstructor, @@ -64,6 +64,13 @@ export interface AcceptOptions { connectionId: string /** Explicit origins, or `'*'` for any canonical HTTPS origin the browser observed. */ allowedApplicationOrigins: readonly string[] | '*' + /** + * Requires cross-origin isolation. A document that is not isolated keeps + * its carrier through the worker and replaces itself with this same-origin + * destination, resolved against the current document; the fragment is + * inherited unless the value spells its own, including an empty `#`. + */ + isolationFallbackUrl?: string fallback?: CarrierConstructor onDiagnostic?: (event: PopupDiagnostic) => void } @@ -84,6 +91,27 @@ function requireHttpsUrl(url: string, report: Reporter): void { const stripFragment = (url: string): string => url.split('#', 1)[0] +/** Same origin, path, and query; fragments do not distinguish documents. */ +const sameDocument = (url: URL, location: Location): boolean => + url.origin === location.origin && + url.pathname === location.pathname && + url.search === location.search + +/** The same-origin HTTPS fallback, with the current fragment carried over. */ +function resolveFallback(value: string, location: Location): URL { + let url: URL + try { + url = new URL(value, location.href) + } catch { + throw new TypeError('isolationFallbackUrl must be a URL') + } + if (url.protocol !== 'https:' || url.origin !== location.origin) { + throw new TypeError('isolationFallbackUrl must be a same-origin HTTPS URL') + } + if (!value.includes('#')) url.hash = location.hash + return url +} + interface Registration { decode: (value: unknown) => In handler: (message: In) => void @@ -354,6 +382,7 @@ class PopupEndpoint extends Endpoint extends Endpoint extends Endpoint extends Endpoint extends Endpoint { + const { location } = this.popup.view + if (!this.isolationFallback || this.popup.isolated) { + return this.install(new PortCarrier(port), code) + } + this.report(code) + if (sameDocument(this.isolationFallback, location)) { + // Already the fallback and still not isolated: the host's policy is + // not taking effect. Never loop. + port.close() + return this.fail('isolation-unavailable', true) + } + this.controlsDone = true + this.report('isolation-fallback') + try { + await this.keepThrough(port, true) + } catch { + return // already failed through `ready` + } + this.release() + location.replace(this.isolationFallback.href) + } + protected onControl(control: PopupControl): void { if (this.controlsDone) return this.controlsDone = true @@ -459,25 +530,40 @@ class PopupEndpoint extends Endpoint { + if (!(this.carrier instanceof PortCarrier)) { + this.fail('continuity-unsupported', viaOperation) + throw new PopupError('continuity-unsupported') + } + await this.keepThrough(this.carrier.detach(), viaOperation) + location.replace(url) + } + + /** + * Hands one port to the worker for the next same-origin document. Fails + * the endpoint and throws when no worker is active, the keep is refused, + * or the connection ended meanwhile. + */ + private async keepThrough(port: MessagePort, viaOperation: boolean): Promise { + const failed: (code: PopupErrorCode) => never = (code) => { + port.close() // a no-op once transferred; releases a port the worker never took this.fail(code, viaOperation) throw new PopupError(code) } + // The host may still be registering in this very document: when no + // worker is attached yet, wait briefly for the registration to activate. const registration = await this.popup.registration() - const worker = registration ? await activeWorker(registration) : null - if (this.ended) throw new PopupError('connection-closed') - if (!(this.carrier instanceof PortCarrier) || !worker) return failed('continuity-unsupported') - const port = this.carrier.detach() + let worker = registration ? await activeWorker(registration) : null + if (!worker) worker = (await bounded(this.popup.readyRegistration()))?.active ?? null + if (this.ended) failed('connection-closed') + if (!worker) failed('continuity-unsupported') const startedAt = performance.now() try { await new PortKeeper(worker).keep(this.connectionId, port) } catch { - port.close() // a no-op once transferred; releases a port the worker never took - return failed('keep-failed') + failed('keep-failed') } if (this.ended) throw new PopupError('connection-closed') this.report('keep-acknowledged', performance.now() - startedAt) - location.replace(url) } private closePopup(): void { diff --git a/ts/packages/popup/src/diagnostics.ts b/ts/packages/popup/src/diagnostics.ts index a1a556d5..28dff3ea 100644 --- a/ts/packages/popup/src/diagnostics.ts +++ b/ts/packages/popup/src/diagnostics.ts @@ -20,6 +20,7 @@ export type PopupErrorCode = | 'continuity-unsupported' | 'keep-failed' | 'claim-failed' + | 'isolation-unavailable' | 'popup-unavailable' | 'send-unavailable' | 'connection-closed' @@ -36,6 +37,7 @@ export type DiagnosticCode = | 'control-connected' | 'keep-acknowledged' | 'claim-empty' + | 'isolation-fallback' | 'connection-failed' export class PopupError extends Error { diff --git a/ts/packages/popup/src/keeper.ts b/ts/packages/popup/src/keeper.ts index ada04dde..83cfb726 100644 --- a/ts/packages/popup/src/keeper.ts +++ b/ts/packages/popup/src/keeper.ts @@ -40,6 +40,26 @@ export interface KeeperWorker { postMessage(message: unknown, transfer: Transferable[]): void } +/** Resolves to undefined once the deadline passes. */ +export function bounded( + promise: Promise, + timeoutMs = KEEPER_REPLY_TIMEOUT_MS, +): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(undefined), timeoutMs) + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + () => { + clearTimeout(timer) + resolve(undefined) + }, + ) + }) +} + /** * The registration's active worker, waiting briefly for one still * installing (the host registers in the first participating document). diff --git a/ts/packages/popup/src/testing/fakes.ts b/ts/packages/popup/src/testing/fakes.ts index 1f8d00e1..8b441bb5 100644 --- a/ts/packages/popup/src/testing/fakes.ts +++ b/ts/packages/popup/src/testing/fakes.ts @@ -41,7 +41,14 @@ export function fakeView(): FakeView { export interface FakeProxy { closed: boolean postMessage(message: unknown, targetOrigin: string, transfer?: Transferable[]): void - location: { origin: string; href: string; replace(url: string): void } + location: { + origin: string + href: string + pathname: string + search: string + hash: string + replace(url: string): void + } close(): void replaced: string[] } @@ -56,14 +63,16 @@ export interface FakePair { appProxy: FakeProxy /** The popup document as a Window for CurrentWindow. */ popupWindow: Window - /** Replace the popup document with one on another origin; proxies keep identity. */ - relocate(origin: string): void + /** Replace the popup document; proxies keep identity. */ + relocate(origin: string, path?: string, hash?: string): void + /** What `crossOriginIsolated` reports in the popup document. */ + setIsolated(isolated: boolean): void } export function fakePair(popupOrigin = POPUP_ORIGIN): FakePair { const appView = fakeView() const popupView = fakeView() - const state = { popupOrigin } + const state = { popupOrigin, path: '/p', hash: '', isolated: false } const makeProxy = ( target: FakeView, targetOrigin: () => string, @@ -88,7 +97,14 @@ export function fakePair(popupOrigin = POPUP_ORIGIN): FakePair { return targetOrigin() }, get href() { - return `${targetOrigin()}/p` + return `${targetOrigin()}${state.path}${state.hash}` + }, + get pathname() { + return state.path + }, + search: '', + get hash() { + return state.hash }, replace: (url) => void proxy.replaced.push(url), }, @@ -123,6 +139,9 @@ export function fakePair(popupOrigin = POPUP_ORIGIN): FakePair { }, location: popupProxy.location, close: popupProxy.close, + get crossOriginIsolated() { + return state.isolated + }, } as unknown as Window return { appView, @@ -130,10 +149,15 @@ export function fakePair(popupOrigin = POPUP_ORIGIN): FakePair { popupProxy, appProxy, popupWindow, - relocate(origin) { + relocate(origin, path = '/p', hash = '') { state.popupOrigin = origin + state.path = path + state.hash = hash popupView.listeners.clear() }, + setIsolated(isolated) { + state.isolated = isolated + }, } } diff --git a/ts/packages/popup/src/window.ts b/ts/packages/popup/src/window.ts index 4c0087cf..bd3c5cc0 100644 --- a/ts/packages/popup/src/window.ts +++ b/ts/packages/popup/src/window.ts @@ -41,10 +41,13 @@ export class PopupWindow { /** Adopts the current popup document; creates nothing. */ static current(): PopupWindow { if (window.top !== window) throw new TypeError('current requires a top-level popup document') - return new CurrentWindow(window, () => - typeof navigator !== 'undefined' && navigator.serviceWorker - ? navigator.serviceWorker.getRegistration().catch(() => undefined) - : Promise.resolve(undefined), + const container = typeof navigator !== 'undefined' ? navigator.serviceWorker : undefined + return new CurrentWindow( + window, + () => container?.getRegistration().catch(() => undefined) ?? Promise.resolve(undefined), + // `ready` settles only once a matching registration is active; the + // caller bounds the wait. + () => container?.ready.catch(() => undefined) ?? new Promise(() => {}), ) } @@ -100,6 +103,9 @@ export class CurrentWindow extends PopupWindow { readonly view: Window, /** The registration whose scope matches this document, resolved per use. */ readonly registration: () => Promise, + /** Settles once a matching registration is active; may never settle. */ + readonly readyRegistration: () => Promise = () => + Promise.resolve(undefined), ) { super() } @@ -108,6 +114,11 @@ export class CurrentWindow extends PopupWindow { return true } + /** Whether this document is cross-origin isolated, by any policy. */ + get isolated(): boolean { + return this.view.crossOriginIsolated === true + } + /** The opener while it is usable; a closed opener counts as absent. */ get opener(): WindowProxy | null { const opener = this.view.opener as WindowProxy | null From 776e14c9d67f858528d813145300a916dbca0020 Mon Sep 17 00:00:00 2001 From: Wondertan Date: Sat, 5 Sep 2026 22:44:33 +0100 Subject: [PATCH 15/24] feat(popup): structured fragments and captured-fragment fallback `navigate` and `navigateAway` take a fragment-free URL and, separately, optional `URLSearchParams` serialized at the call and appended as the fragment. The fields are opaque protocol data: nothing is reserved, parsed, or tied to the connection ID. An inline fragment, even an empty `#`, is rejected on every public URL argument, `isolationFallbackUrl` included. Application-initiated navigation carries the serialized destination in the existing `Navigate` control; popup-initiated navigation discloses nothing to the application. `PopupWindow.current(fragment?)` adopts the fragment as the host captured it, so a bootstrap may clear the URL before importing the package; the endpoint keeps a snapshot and the isolation fallback always carries it. Fragment data never enters the worker, storage, or diagnostics. Tests cover cross-origin navigation with fields, serialization and mutation independence, inline rejection, capture before clearing, fallback preservation, and non-disclosure; the e2e popup page now captures and clears its URL before accepting. 86 unit tests; 98 browser cases pass, 2 skip where DIP isolates directly. Assisted-by: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012SVAaP3Z7Haz18WJBjCZAw Signed-off-by: Wondertan --- ts/packages/popup/README.md | 27 +++-- ts/packages/popup/TEST_PLAN.md | 3 +- ts/packages/popup/docs/connection.md | 26 +++-- ts/packages/popup/docs/control.md | 3 +- ts/packages/popup/e2e/popup.spec.ts | 38 ++++--- ts/packages/popup/e2e/server.mjs | 19 +++- ts/packages/popup/src/connection.test.ts | 125 ++++++++++++++++++++--- ts/packages/popup/src/connection.ts | 74 ++++++++------ ts/packages/popup/src/testing/fakes.ts | 2 + ts/packages/popup/src/window.ts | 15 ++- 10 files changed, 253 insertions(+), 79 deletions(-) diff --git a/ts/packages/popup/README.md b/ts/packages/popup/README.md index 51890ba4..6ad9ef68 100644 --- a/ts/packages/popup/README.md +++ b/ts/packages/popup/README.md @@ -29,10 +29,18 @@ const connection = PopupConnection.connect(popupWindow, { onDiagnostic, }) -void connection.navigate(anchor.href) +const [href, fragment = ''] = anchor.href.split('#') +void connection.navigate(href, new URLSearchParams(fragment)) if (popupWindow.opened) event.preventDefault() ``` +Navigation takes a fragment-free URL and, separately, the fragment fields as +`URLSearchParams`. The package serializes them at the call and treats them as +opaque protocol data: no field is reserved, parsed, or tied to the connection +ID. A URL that spells its own fragment, even an empty `#`, is rejected. The +anchor keeps its fragment because the native-anchor path navigates it as +written. + `PopupWindow.open(target, features?)` synchronously attempts `window.open('about:blank', target, 'popup,…')` and returns a wrapper even when the browser returns no handle. The popup is always requested as a separate @@ -48,7 +56,7 @@ declare class PopupWindow { readonly opened: boolean static open(target: string, features?: string): PopupWindow - static current(): PopupWindow + static current(fragment?: string): PopupWindow } ``` @@ -109,8 +117,8 @@ interface PopupConnection { message: MessageType, handler: (message: N) => void, ): () => void - navigate(url: string): Promise - navigateAway(url: string): Promise + navigate(url: string, fragment?: URLSearchParams): Promise + navigateAway(url: string, fragment?: URLSearchParams): Promise close(): Promise } @@ -230,8 +238,15 @@ interface PopupDiagnostic { A participating document that needs cross-origin isolation passes `isolationFallbackUrl`. Its presence requires isolation and names a -same-origin fallback, resolved against the current document; the fragment is -inherited unless the value spells its own, including an empty `#`. +same-origin fallback, resolved against the current document and carrying the +document's captured fragment unchanged; the value itself must not spell a +fragment. + +`PopupWindow.current(fragment?)` takes the fragment as the host captured it, +so a bootstrap may read `location.hash`, clear the URL, and only then import +the package; it defaults to the current `location.hash`. The package keeps a +snapshot, so later clearing or mutation changes nothing, and never puts the +value in the worker, storage, or a diagnostic. ```ts PopupConnection.accept(popupWindow, { diff --git a/ts/packages/popup/TEST_PLAN.md b/ts/packages/popup/TEST_PLAN.md index f2448b4d..710ec1e9 100644 --- a/ts/packages/popup/TEST_PLAN.md +++ b/ts/packages/popup/TEST_PLAN.md @@ -60,8 +60,9 @@ Coverage status: | POPUP-CONNECTION-008 | `PopupConnection.navigate()` directly between same-origin participating documents preserves a MessagePort or prepares a nontransferable carrier. Across origins, including across sites, it never gives a MessagePort to the source origin's worker: it retires that popup endpoint and the allowed destination establishes a fresh carrier through its opener or fallback. A navigation outside that API loses the current carrier. A later participating document may establish the first RTC carrier from the still-unused initial fallback without round metadata; after RTC is active, an unmanaged navigation terminates the logical connection without restarting round zero. | | POPUP-CONNECTION-009 | `connect` and `accept` copy nonempty, duplicate-free sets of canonical HTTPS `allowedPopupOrigins` and `allowedApplicationOrigins`, respectively. Every initial, native-anchor, replacement, MessagePort, and RTC participant binds one exact browser-observed member of the peer's set. Sequential popup participants may use different admitted origins without changing the logical connection ID or caller registrations. `accept` alone also takes `'*'`, which binds any canonical HTTPS browser-observed origin and rejects an opaque or non-HTTPS one; `connect` rejects a wildcard. An empty set or duplicate, malformed, noncanonical, credentialed, or unapproved origin fails before selection or caller delivery. | | POPUP-CONNECTION-010 | Caller messages sent over a carrier before a `Navigate` control are delivered to the popup's handlers before the popup acts on the control, for same-origin and cross-origin destinations alike. A transition that needs the application's reply is therefore application-driven (reply, then `navigate`) or the popup navigates only after receiving the reply; an application-side `navigateAway` immediately after a send does not wait for the port. | -| POPUP-CONNECTION-011 | With `isolationFallbackUrl`, an isolated document installs normally. A non-isolated document keeps its unstarted port through the worker before `ready` settles or any value is delivered, settles `closed` as closed, and replaces itself with the same-origin fallback, which restores the port, becomes ready, and delivers every value the application sent meanwhile exactly once. The application observes one carrier. The fallback resolves against the current document, must be same-origin HTTPS, and inherits the fragment unless it spells its own, including an empty one. | +| POPUP-CONNECTION-011 | With `isolationFallbackUrl`, an isolated document installs normally. A non-isolated document keeps its unstarted port through the worker before `ready` settles or any value is delivered, settles `closed` as closed, and replaces itself with the same-origin fallback, which restores the port, becomes ready, and delivers every value the application sent meanwhile exactly once. The application observes one carrier. The fallback resolves against the current document, must be same-origin HTTPS without a fragment of its own, and always carries the document's captured fragment, a snapshot independent of later URL clearing. | | POPUP-CONNECTION-012 | A document that already is the fallback, by origin, path, and query, and remains non-isolated fails with `isolation-unavailable` and never navigates again. Invalid, non-HTTPS, or cross-origin fallbacks reject synchronously. Close during the hop aborts without navigating; a refused keep or missing worker fails `ready`. Without the option, behavior is unchanged. | +| POPUP-CONNECTION-013 | `navigate` and `navigateAway` take a fragment-free URL and optional `URLSearchParams`, serialized at the call so later mutation is invisible and appended as the fragment; an inline fragment, even an empty `#`, is rejected on every public URL argument including `isolationFallbackUrl`. Application-initiated navigation carries the serialized destination in `Navigate`; popup-initiated navigation discloses nothing to the application. `PopupWindow.current(fragment)` adopts a fragment captured before the URL was cleared, and the fallback carries that snapshot. | ## MessagePort and navigation continuity diff --git a/ts/packages/popup/docs/connection.md b/ts/packages/popup/docs/connection.md index 0fffbc3c..e409c05b 100644 --- a/ts/packages/popup/docs/connection.md +++ b/ts/packages/popup/docs/connection.md @@ -187,8 +187,9 @@ keeps its still-unstarted port through the worker, so every value the application already sent stays queued inside it, settles `closed` as closed, and replaces itself with the fallback; the fallback restores the port and becomes ready. The fallback is resolved against the current document, must be -same-origin HTTPS, and inherits the current fragment unless it spells its own, -including an empty `#`. Because the host may register its worker in the same +same-origin HTTPS without a fragment of its own, and always carries the +document's captured fragment, the value `PopupWindow.current` was given or +read at adoption. Because the host may register its worker in the same document, the endpoint waits up to the keeper reply deadline for that registration to become active before it keeps the port. A document that already is the fallback, compared by origin, path, and query, and remains @@ -266,7 +267,8 @@ function activate(event: MouseEvent) { allowedPopupOrigins, }) - void connection.navigate(anchor.href) + const [href, fragment = ''] = anchor.href.split('#') + void connection.navigate(href, new URLSearchParams(fragment)) if (popupWindow.opened) event.preventDefault() } ``` @@ -313,7 +315,7 @@ declare class PopupWindow { bind(source: WindowProxy): void static open(target: string, features?: string): PopupWindow - static current(): PopupWindow + static current(fragment?: string): PopupWindow } interface PopupConnection { @@ -324,8 +326,8 @@ interface PopupConnection { message: MessageType, handler: (message: N) => void, ): () => void - navigate(url: string): Promise - navigateAway(url: string): Promise + navigate(url: string, fragment?: URLSearchParams): Promise + navigateAway(url: string, fragment?: URLSearchParams): Promise close(): Promise } @@ -495,6 +497,18 @@ provided the destination registered their handlers before yielding; on a cross-origin transition they are lost. Send what the destination needs only after it announces itself. +`navigate` and `navigateAway` take a fragment-free canonical HTTPS URL and, +separately, optional fragment fields as `URLSearchParams`. The endpoint +serializes the fields at the call, so later mutation of the object is +invisible, appends them as the fragment, and treats them as opaque protocol +data: no field is reserved, parsed, or related to the connection ID. A URL +spelling its own fragment, even an empty `#`, is rejected. Ordinary navigation +may cross origins and carry a fragment; destination selection and disclosure +policy belong to the calling protocol. Popup-initiated navigation acts locally +and discloses neither destination nor fragment to the application through any +control, diagnostic, or signal; application-initiated navigation carries the +serialized destination in the existing `Navigate` control. + `navigate` accepts a caller-selected opaque URL. It does not select the route or interpret caller-owned fields: diff --git a/ts/packages/popup/docs/control.md b/ts/packages/popup/docs/control.md index ff55d130..9ba3bb41 100644 --- a/ts/packages/popup/docs/control.md +++ b/ts/packages/popup/docs/control.md @@ -43,7 +43,8 @@ protocol-version field. Unknown controls fail closed. Both records are application-to-popup only. The receiver exact-validates a plain record, discriminator, and field set before acting. They travel only over an already version-authenticated carrier. A navigation -URL must equal the serialization of an absolute HTTPS URL with no credentials. +URL must equal the serialization of an absolute HTTPS URL with no credentials; +it may carry a fragment, which is the application's serialized fragment fields. Relative, non-HTTPS, malformed, and noncanonical URLs fail closed. Connection owns the generic message bound. diff --git a/ts/packages/popup/e2e/popup.spec.ts b/ts/packages/popup/e2e/popup.spec.ts index 09c4d3aa..15b1e978 100644 --- a/ts/packages/popup/e2e/popup.spec.ts +++ b/ts/packages/popup/e2e/popup.spec.ts @@ -75,22 +75,30 @@ const ping = (page: Page, n: number) => ;(window as unknown as { __conn: { send(v: unknown): void } }).__conn.send({ type: 'ping', n }) }, n) +/** Split a test URL written with an inline fragment into the structured pair. */ +const split = (url: string): [string, string] => { + const [base, hash = ''] = url.split('#') + return [base, hash] +} + const navigateAway = (page: Page, url: string) => page.evaluate( - (url) => + ([base, hash]) => ( - window as unknown as { __conn: { navigateAway(u: string): Promise } } - ).__conn.navigateAway(url), - url, + window as unknown as { + __conn: { navigateAway(u: string, f: URLSearchParams): Promise } + } + ).__conn.navigateAway(base, new URLSearchParams(hash)), + split(url), ) const navigate = (page: Page, url: string) => page.evaluate( - (url) => - (window as unknown as { __conn: { navigate(u: string): Promise } }).__conn.navigate( - url, - ), - url, + ([base, hash]) => + ( + window as unknown as { __conn: { navigate(u: string, f: URLSearchParams): Promise } } + ).__conn.navigate(base, new URLSearchParams(hash)), + split(url), ) /** Run an action that replaces the popup document and wait for the new one. */ @@ -231,7 +239,8 @@ test('[POPUP-CONNECTION-008] popup-initiated navigation preserves the port', asy (url) => (window as unknown as { __conn: { send(v: unknown): void } }).__conn.send({ type: 'go', - url, + url: url.split('#')[0], + fragment: url.split('#')[1] ?? '', }), `${POPUP}/isolated#c=${id}`, ), @@ -297,7 +306,7 @@ test('[POPUP-CONTROL-002] malformed navigation fails before any browser operatio ]) { await expect(navigate(page, bad)).rejects.toThrow() } - expect(popup.url()).toContain('/p#') + expect(new URL(popup.url()).pathname).toBe('/p') expect((await diag(page)).filter((c) => c === 'control-rejected')).toHaveLength(3) }) @@ -364,7 +373,8 @@ test('[POPUP-CONTROL-005] popup-side navigateAway keeps no port', async ({ page (url) => (window as unknown as { __conn: { send(v: unknown): void } }).__conn.send({ type: 'away', - url, + url: url.split('#')[0], + fragment: url.split('#')[1] ?? '', }), `${POPUP}/external?delay=200&next=${next}`, ), @@ -397,12 +407,12 @@ test('[POPUP-CONNECTION-010] a reply sent before navigate reaches the popup befo ([url]) => { const w = window as unknown as { __onPong?: (pong: { n: number }) => void - __conn: { send(v: unknown): void; navigate(u: string): Promise } + __conn: { send(v: unknown): void; navigate(u: string, f: URLSearchParams): Promise } } w.__onPong = (pong) => { if (pong.n !== 1) return w.__conn.send({ type: 'ping', n: 42 }) - void w.__conn.navigate(url) + void w.__conn.navigate(url.split('#')[0], new URLSearchParams(url.split('#')[1] ?? '')) } }, [`${POPUP_B}/p#c=${id}`], diff --git a/ts/packages/popup/e2e/server.mjs b/ts/packages/popup/e2e/server.mjs index 1432fb3b..1529555c 100644 --- a/ts/packages/popup/e2e/server.mjs +++ b/ts/packages/popup/e2e/server.mjs @@ -30,6 +30,8 @@ const protocol = ` const message = (type, decode) => ({ type, decode }) const Ping = message('ping', (v) => { if (typeof v.n !== 'number') throw new Error('ping'); return v }) const Pong = message('pong', (v) => { if (typeof v.n !== 'number') throw new Error('pong'); return v }) + // Go and Away carry a fragment-free url plus serialized opaque fragment + // fields; the popup rebuilds URLSearchParams for the structured API. const Go = message('go', (v) => { if (typeof v.url !== 'string') throw new Error('go'); return v }) const Away = message('away', (v) => { if (typeof v.url !== 'string') throw new Error('away'); return v }) window.__events = [] @@ -65,7 +67,10 @@ const appPage = html(` window.__conn = connection window.__popupWindow = popupWindow connection.closed.then((end) => window.__events.push({ type: 'end', ...end })) - void connection.navigate(anchor.href).catch((error) => window.__events.push({ type: 'error', code: error.message })) + // The anchor keeps its fragment for the native path; the scripted path + // passes fragment fields through the structured argument. + const [base, hash = ''] = anchor.href.split('#') + void connection.navigate(base, new URLSearchParams(hash)).catch((error) => window.__events.push({ type: 'error', code: error.message })) if (popupWindow.opened) event.preventDefault() }) @@ -76,7 +81,11 @@ const popupPage = html(`