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 new file mode 100644 index 00000000..55199ed2 --- /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`, `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`, `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 | + +`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..9491545d --- /dev/null +++ b/ts/packages/popup/README.md @@ -0,0 +1,364 @@ +# @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, +}) + +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 +window; `features` may add size or position (`width=480,height=720`) and must +not contain `noopener` or `noreferrer`. Without a caller position (`left`, `top`, +`screenX`, or `screenY`), successful launches from that application document are +placed side by side with a small gap, then staggered when the row is full. This +uses native opening coordinates, preserves the requested size, and leaves +already-open windows alone. It does not track or reclaim closed positions: +isolation can make a live popup's retained handle appear closed. Positioning is +best-effort; browser or window-manager policy may override it. + +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). + +```ts +declare class PopupWindow { + readonly opened: boolean + + static open(target: string, features?: string): PopupWindow + static current(fragment?: string, options?: { scope?: string }): 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 +origin's Service Worker registrations. 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 = 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 +`'*'`. Canonical HTTP on exactly `localhost` and `127.0.0.1` is also admitted, +at any valid port, for allowlists, navigation and isolation fallback. The +wildcard follows that same policy and still authenticates the exact peer; +other HTTP hosts are rejected. Empty lists and duplicates remain invalid. +The application's `allowedPopupOrigins` is always explicit. See the +[normative origin rules](../../../specs/popup-transport.md#6-origin-allowlists-and-binding). + +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. +This is best-effort logical continuity, not guaranteed delivery across a +carrier change: sends into a retired carrier can succeed locally and be lost. + +```ts +interface PopupConnection { + readonly ready: Promise + readonly closed: Promise + readonly peerOrigin: string | null + send(message: Out): void + on( + message: MessageType, + handler: (message: N) => void, + ): () => void + navigate(url: string, fragment?: URLSearchParams): Promise + navigateAway(url: string, fragment?: URLSearchParams): 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 +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. 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. + +### 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: + +```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 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 + +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. + +### 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 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, { + 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. + +When the opener was already severed, only the fallback constructor remains, +and that carrier could not cross the replacement, so the non-isolated document +does not construct it: it replaces itself first, and the isolated fallback +establishes the only carrier through its own constructor from the same +still-unused signaling round. No connection is spent on the intermediate +document. Before initial selection application sends throw; if it retains a +retired predecessor, sends may succeed locally and be lost until the new +carrier authenticates. The transport neither queues nor replays those values. + +### 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 as its first step, so the host calls it +before any other network work. By default a departing document keeps the port +into the registration that will control its destination and a document claims +from every registration on the origin, so a root registration next to a +nested one for a sub-application needs no configuration as long as both run +the keeper. A host that wants one registration and no other, say because an +unrelated worker shares the origin, names it: + +```ts +PopupWindow.current(captured, { scope: '/' }) +``` + +Then keep and claim use exactly that same-origin scope and never another. In +both modes the registration may still be installing, or not exist yet, when +the hop begins; the hop waits up to the keeper reply deadline for it to +activate. 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 { + readonly peerOrigin: string + 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. + +## Testing + +`pnpm test` runs the unit suite in Node over real `MessageChannel` ports. +`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 +remain deferred or manual. + +## Authenticated peer origin + +After `ready`, `connection.peerOrigin` identifies the authenticated peer; it is +`null` without a selected carrier. [Origin binding](docs/connection.md#authenticated-peer-origin) +describes preservation and fallback. ConnectionVersion 2 requires updated +Application, popup documents, and worker together. diff --git a/ts/packages/popup/TEST_PLAN.md b/ts/packages/popup/TEST_PLAN.md new file mode 100644 index 00000000..e50388bb --- /dev/null +++ b/ts/packages/popup/TEST_PLAN.md @@ -0,0 +1,120 @@ +# `@libid/popup` test plan + +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, + including `navigateAway`, loopback HTTP, the popup-side wildcard, and the + nontransferable-carrier replacement seam (not actual WebRTC). +- **Browser** (`pnpm test:e2e`, Playwright on Chromium, Firefox, WebKit, + 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-origin hop between participating documents that re-handshakes + over the opener, close after the opener is severed, port expiry across a + non-participating hop, loopback HTTP authentication and isolated replacement, + 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 | +|---|---| +| 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. 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 + +| ID | Assertion | +|---|---| +| 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 | 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-WINDOW-005 | Without explicit position features (including aliases), successful scripted opens request side-by-side positions, then bounded staggered positions when the row is full. Mixed requested widths, omitted sizes, blocked opens, and a nonzero screen origin retain valid placement hints. Explicit positions remain unchanged. Positioning does not inspect prior handles, move existing windows, or change native-anchor behavior. Desktop browser checks exercise concurrent opens and transport; actual placement under each OS/window manager remains manual. | +| 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 `Navigate` even while its retained handle appears usable; popup-endpoint `navigate` sends no control. HTTPS and HTTP on exact `localhost` or `127.0.0.1` at any valid port are accepted. Either popup-side path invokes replacement only after carrier continuity is preserved or prepared; malformed, credentialed, noncanonical, relative, disallowed-HTTP, 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 + +| 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. 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; 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 origins: HTTPS, or HTTP on exactly `localhost` or `127.0.0.1` at any valid port. Scheme and effective port match exactly; lookalikes, alternate loopback spellings, and other HTTP hosts fail. Every initial, native-anchor, replacement, MessagePort, and RTC participant binds one exact authenticated 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 `'*'`, subject to the same URL and authentication rules; `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, follows POPUP-CONNECTION-009's URL policy, must be same-origin 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, disallowed-HTTP, 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. | +| POPUP-CONNECTION-014 | With the opener severed and only the fallback constructor available, a non-isolated document requiring isolation replaces itself with the fallback before invoking the constructor: no carrier is established, no signaling round is spent on it, and it never becomes ready or delivers. The isolated destination establishes the only carrier through its own constructor from the still-unused round, including one prepared by an earlier application-driven navigation. Preparation alone installs no replacement: sends into the retired predecessor are lost on both same-origin and cross-origin transitions, never queued or replayed; an ordered reply before navigation reaches the old document, and new messages after destination authentication deliver. Before initial carrier selection sends throw instead. Close before the hop navigates nothing; a failed reconnection rejects only the destination's `ready`; a fallback document that stays non-isolated fails with `isolation-unavailable` without a carrier. | + +## 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; 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-005 | By default a keep uses the registration controlling the destination URL and a claim asks every registration on the origin, so with a stale nested registration sharing the root's script URL a hop restores its port whichever registration controls the claiming document. With `PopupWindow.current(fragment, { scope })`, keep and claim use only the registration with exactly that same-origin scope, never the one controlling the document. In both modes a registration absent when the hop begins is found once registered, one exposed without a worker or still installing is waited for up to the keeper reply deadline, nothing is substituted for a missing one, and a cross-origin scope rejects synchronously. | +| 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 + +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. | +| 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 + +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. | +| 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. | + +Authenticated-origin regression coverage extends POPUP-CONNECTION-007 and +POPUP-KEEPER-001: both endpoint origins before/after readiness and retirement; +origin preservation with queued delivery; destination allowlist rejection on +restoration; and missing, malformed, or unlisted fallback origins rejected before +subscription. Real browser coverage exercises restored origins through isolation. 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/docs/connection.md b/ts/packages/popup/docs/connection.md new file mode 100644 index 00000000..ff6cd196 --- /dev/null +++ b/ts/packages/popup/docs/connection.md @@ -0,0 +1,763 @@ +# 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. + +This implements the [normative popup transport](../../../../specs/popup-transport.md). +Continuity is best-effort: preserving the logical connection does not promise +delivery across carrier retirement or replay messages lost during navigation. + +```ts +type ConnectionVersion = 2 +``` + +`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 current popup endpoint and one active + carrier. MessagePort additionally binds the exact window handle; a fallback + authenticates its peer without proving that handle's identity. Each new + document authenticates and selects its own carrier; stale 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 = PopupConnection.accept(currentWindow, { + connectionId, + allowedApplicationOrigins, + fallback, + onDiagnostic, +}) +await popupConnection.ready +``` + +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, 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) +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 origin's host-registered Service Worker registrations. By +default a keep uses the registration that will control the destination URL and +a claim asks every registration on the origin at once, so the port is found +whichever registration controls the claiming document, including after a +non-participating hop. With `scope`, keep and claim use the registration with +exactly that same-origin scope and no other. In both modes the registration +may not exist yet when the hop begins; the keep waits up to the keeper reply +deadline for it to activate, including through the interval an engine exposes +it without a worker. +`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. + +`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, with the URL policy below and 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 +non-isolated fails with +`isolation-unavailable`; a refused keep or missing worker fails as it does for +`navigate`. Only a MessagePort is preserved. When no port is available and the +fallback constructor is the only remaining source, the non-isolated document +does not invoke it: a carrier it produced could not cross the replacement, so +the document replaces itself first and the isolated destination establishes +the only carrier through its own fallback constructor, from the same +still-unused signaling round. The intermediate document spends no connection, +never becomes ready, and delivers nothing. Before the first carrier is +selected, application sends throw. After a previous carrier was retired, the +application may still hold its unusable side: sends succeed locally and are +lost until the destination authenticates. Preparing a signaling round does +not authenticate the replacement or queue messages for it. 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 +duplicate. HTTP is also accepted on exactly `localhost` and `127.0.0.1`, at +any valid port, without hostname resolution. This same URL policy applies to +navigation and isolation fallback. Scheme and effective port remain part of +exact origin matching; lookalikes, other HTTP hosts, and noncanonical +spellings are rejected. `accept` alone also takes the literal `'*'`, admitting +any origin satisfying that policy while still authenticating its exact peer. +`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. + +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, + }) + + const [href, fragment = ''] = anchor.href.split('#') + void connection.navigate(href, new URLSearchParams(fragment)) + if (popupWindow.opened) event.preventDefault() +} +``` + +`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 +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, features?: string): PopupWindow + static current(fragment?: string, options?: { scope?: string }): PopupWindow +} + +interface PopupConnection { + readonly ready: Promise + readonly closed: Promise + readonly peerOrigin: string | null + send(message: Out): void + on( + message: MessageType, + handler: (message: N) => void, + ): () => void + navigate(url: string, fragment?: URLSearchParams): Promise + navigateAway(url: string, fragment?: URLSearchParams): Promise + close(): Promise +} + +type ConnectionEnd = + | { outcome: 'closed' } + | { outcome: 'failed'; code: PopupErrorCode } + +class PopupError extends Error { + readonly code: PopupErrorCode +} + +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[] | '*' + isolationFallbackUrl?: string + fallback?: CarrierConstructor + onDiagnostic?: (event: PopupDiagnostic) => void + }, + ): PopupConnection +} +``` + +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. +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. 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 +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. +`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. 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 +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: when the port is preserved, +they wait in it and reach the destination once it accepts, provided the +destination registered their handlers before yielding. When the carrier is +retired instead, including a same-origin nontransferable carrier, they are +lost until the destination authenticates. Send what the destination needs only +after it announces itself. + +`navigate` and `navigateAway` take a fragment-free canonical URL satisfying +the origin scheme/host policy above 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: + +- 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 failed port preservation or successor preparation +rejects before navigation; a fallback-only isolation hop with no carrier yet +instead connects at the destination. 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 { + readonly peerOrigin: string + 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 + +Continuity preserves the logical connection at best effort while the popup +replaces one participating document with another. It may preserve the same +carrier or authenticate a fresh one; it does not preserve the old JavaScript +heap or an `RTCDataChannel`, and does not recover lost messages. + +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. + +## Authenticated peer origin + +`connection.peerOrigin` exposes the exact origin authenticated by the selected +carrier. It is available after `ready`; it is `null` before selection and after +local carrier retirement or closure. It describes the bound document, not the +current location of a retained window. A fresh carrier may select a different +allowlisted peer and updates the value; it never derives one from the allowlist. + +The MessagePort handshake retains the browser-stamped `MessageEvent.origin`. +The keeper carries that origin with the preserved port, and the destination +checks it against its own allowlist before readiness or delivery. Fallback +constructors likewise return their authenticated `peerOrigin`; malformed or +unlisted origins fail with `handshake-rejected` before subscription. Origin +metadata is not a protocol message or diagnostic and needs no extra handshake. diff --git a/ts/packages/popup/docs/control.md b/ts/packages/popup/docs/control.md new file mode 100644 index 00000000..99bbd058 --- /dev/null +++ b/ts/packages/popup/docs/control.md @@ -0,0 +1,127 @@ +# 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 URL with no credentials, +using HTTPS or HTTP on exactly `localhost` or `127.0.0.1` at any valid port; +it may carry a fragment, which is the application's serialized fragment fields. +Relative, disallowed-HTTP, malformed, and noncanonical URLs fail closed. Connection +owns the generic message bound. + +## Execution + +`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 +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. + +`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, 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. + +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 does not encapsulate caller messages. An application-selected + navigation intentionally carries its URL and fragment to the popup; + popup-local navigation sends neither to the application. Disclosure policy + belongs to the caller. +- 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..f31d6bb8 --- /dev/null +++ b/ts/packages/popup/docs/message-port.md @@ -0,0 +1,346 @@ +# 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. +- 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. +- 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 make the worker holding the +port reachable from the destination. By default, keep uses the registration +controlling the destination URL and claim asks every registration on the +origin. With an explicit scope, both use only that registration. A mismatched +scope or inaccessible owner claims no port and selection proceeds normally. 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 lose the preserved port. The next document then follows normal +carrier selection; an authentication failure remains terminal rather than +selecting 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 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. `PopupWindow.current()` resolves registrations +as described above; control of the document is not required to message the +selected worker. + +```ts +// @libid/popup/worker +declare function installPortKeeper(): void + +declare class PortKeeper { + constructor(worker: Pick) + + keep(connectionId: string, port: MessagePort, peerOrigin: string): Promise + claim(connectionId: string): Promise<{ port: MessagePort; peerOrigin: string } | null> +} +``` + +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. 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. + +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. + +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: +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: + +```ts +declare function listenForPopupPorts( + options: { + view: Window + source: WindowProxy | null // retained handle, or null until native-anchor binding + onBind: (source: WindowProxy) => void + allowedPopupOrigins: readonly string[] + connectionId: string + }, + handlers: { + onPort: (port: MessagePort, peerOrigin: string) => void + onFail: () => void // the expected peer sent a malformed record + }, +): () => void + +declare function requestApplicationPort(options: { + view: Window + opener: WindowProxy + allowedOrigins: readonly string[] | '*' + connectionId: string + signal: AbortSignal + timeoutMs?: number +}): Promise // null when the opener stays silent + +declare class PortCarrier implements Carrier { + constructor(port: MessagePort, readonly peerOrigin: string) + /** Surrenders the port for `PortKeeper.keep`; this carrier is closed afterwards. */ + 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 +`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 +attempt from the opener that fails authentication rejects immediately; abort +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 +port for preservation. + +### Preserved origin binding + +The successful handshake yields a `PortCarrier` with the browser-stamped peer +origin. Keep records and successful claim replies include `peerOrigin` alongside +the transferred port; claims without an entry contain only `{ port: false }`. +The worker retains this metadata only for that port's existing bounded lifetime. +The destination validates its shape and canonical origin, then checks its own +allowlist before installing the restored carrier. A malformed or mismatching +binding fails locally and does not select a fallback. Same-origin worker code +is already inside the transport's trust boundary. + +ConnectionVersion 2 adds the origin to private keeper records. Deploy the updated +Application, popup documents, and worker together; an older keeper cannot preserve this binding and is not treated +as authenticated-origin evidence. There is no compatibility path that guesses +an origin or silently restores a port without it. 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/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..279be73c --- /dev/null +++ b/ts/packages/popup/e2e/popup.spec.ts @@ -0,0 +1,563 @@ +// 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.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() + +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 + /** 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, 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') + 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) + +/** 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( + ([base, hash]) => + ( + 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( + ([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. */ +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']) + // 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-005] concurrent opens receive distinct placement hints and connect', async ({ + page, +}) => { + await page.addInitScript(() => { + const open = window.open.bind(window) + const features: string[] = [] + ;(window as unknown as { __features: string[] }).__features = features + window.open = (url, target, options) => { + features.push(options ?? '') + return open(url, target, options) + } + }) + const { popup: first } = await open(page) + await expect(first.locator('#status')).toHaveText('connected') + const id = freshId() + await page.evaluate((id) => { + ;(window as unknown as { __id: string }).__id = id + const anchor = document.getElementById('go') as HTMLAnchorElement + anchor.target = `popup-${id}` + anchor.href = `${anchor.origin}/p#c=${id}` + }, id) + const opened = page.waitForEvent('popup') + await page.click('#go') + const second = await opened + await expect(second.locator('#status')).toHaveText('connected') + expect(first.isClosed()).toBe(false) + await ping(page, 1) + await expectPong(page, 1) + const features = await page.evaluate( + () => (window as unknown as { __features: string[] }).__features, + ) + expect(features).toHaveLength(2) + for (const value of features) + expect(value).toMatch(/^popup,width=480,height=720,left=-?\d+,top=-?\d+$/) + expect(features[0]).not.toBe(features[1]) + // Browser/OS placement can override these hints, especially tabs and WebKit headless. + await first.close() + await second.close() +}) + +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']) + expect( + await page.evaluate( + () => (window as unknown as { __popupWindow: { opened: boolean } }).__popupWindow.opened, + ), + ).toBe(true) +}) + +test('[POPUP-CONNECTION-009/011] HTTP loopback authenticates and preserves an isolated replacement', async ({ + page, +}) => { + const id = freshId() + const origin = 'http://localhost:4586' + const { popup } = await open(page, { + app: 'http://127.0.0.1:4585', + id, + href: `${origin}/p#c=${id}`, + }) + await expect(popup.locator('#status')).toHaveText('connected') + await expectPong(page, 0) + await popup.evaluate(() => navigator.serviceWorker.ready) + await nextDocument(popup, () => navigate(page, `${origin}/isolated#c=${id}`)) + await expect(popup.locator('#status')).toHaveText('connected') + await expect.poll(() => diag(popup)).toContain('carrier-restored') + await ping(page, 1) + expect(await expectPong(page, 1)).toMatchObject({ path: '/isolated', isolated: 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', 'connection-failed']) + 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']) + expect( + await popup.evaluate( + () => (window as unknown as { __conn: { peerOrigin: string } }).__conn.peerOrigin, + ), + ).toBe(APP_A) + expect( + await page.evaluate( + () => (window as unknown as { __conn: { peerOrigin: string } }).__conn.peerOrigin, + ), + ).toBe(POPUP) + 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']) + expect( + await popup.evaluate( + () => (window as unknown as { __conn: { peerOrigin: string } }).__conn.peerOrigin, + ), + ).toBe(APP_A) + expect( + await page.evaluate( + () => (window as unknown as { __conn: { peerOrigin: string } }).__conn.peerOrigin, + ), + ).toBe(POPUP) + 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: url.split('#')[0], + fragment: url.split('#')[1] ?? '', + }), + `${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-005] an explicit root scope keeps and claims through the root registration', async ({ + page, +}) => { + const id = freshId() + const { popup } = await open(page, { id, href: `${POPUP}/p?scope=/#c=${id}` }) + await expectPong(page, 0) + await popup.evaluate(() => navigator.serviceWorker.ready) + await nextDocument(popup, () => navigate(page, `${POPUP}/isolated?scope=/#c=${id}`)) + await expect(popup.locator('#status')).toHaveText('connected') + expect(await diag(popup)).toEqual(['carrier-restored']) + await nextDocument(popup, () => navigate(page, `${POPUP}/p?scope=/#c=${id}`)) + await expect(popup.locator('#status')).toHaveText('connected') + expect(await diag(popup)).toEqual(['carrier-restored']) + await ping(page, 8) + expect(await expectPong(page, 8)).toMatchObject({ path: '/p' }) +}) + +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', 'connection-failed']) + 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(new URL(popup.url()).pathname).toBe('/p') + expect((await diag(page)).filter((c) => c === 'control-rejected')).toHaveLength(3) +}) + +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) + 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-origin 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', 'connection-failed']) +}) + +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: url.split('#')[0], + fragment: url.split('#')[1] ?? '', + }), + `${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']) +}) + +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) + 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, f: URLSearchParams): Promise } + } + w.__onPong = (pong) => { + if (pong.n !== 1) return + w.__conn.send({ type: 'ping', n: 42 }) + void w.__conn.navigate(url.split('#')[0], new URLSearchParams(url.split('#')[1] ?? '')) + } + }, + [`${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']) +}) + +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 new file mode 100644 index 00000000..c9202965 --- /dev/null +++ b/ts/packages/popup/e2e/server.mjs @@ -0,0 +1,217 @@ +// 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. + +import { readFileSync } from 'node:fs' +import { createServer as createHttpServer } from 'node:http' +import { createServer } from 'node:https' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { makeCertificate } from './tls.mjs' + +// 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.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') +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 }) + // 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 = [] + window.__diag = [] + const onDiagnostic = (d) => { + window.__diag.push(d.code) + window.__onDiag?.(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', +} +// 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, page = popupPage) { + const url = new URL(req.url, 'https://popup.invalid') + 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': + case '/p-any': + return send(res, 200, { ...HTML, 'Cross-Origin-Opener-Policy': 'unsafe-none' }, page) + case '/isolated': + case '/dip/fallback': + return send(res, 200, { ...HTML, ...ISOLATED }, page) + case '/dip': + case '/dip-broken': + return send(res, 200, { ...HTML, ...DIP }, page) + 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' }, page) + case '/external': + return send(res, 200, HTML, externalPage) + default: + return send(res, 404, {}, '') + } +} + +function appHandler(req, res, page = appPage) { + 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, page) + 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.popupB, popupHandler], + [ORIGINS.appA, appHandler], + [ORIGINS.appB, appHandler], +]) { + createServer(tls, handler).listen(Number(new URL(origin).port)) +} + +// Exercise both exact HTTP loopback names with the same documents and worker. +const localPage = (page) => + page + .replaceAll(ORIGINS.appA, 'http://127.0.0.1:4585') + .replaceAll(ORIGINS.popup, 'http://localhost:4586') +createHttpServer((req, res) => appHandler(req, res, localPage(appPage))).listen(4585) +createHttpServer((req, res) => popupHandler(req, res, localPage(popupPage))).listen(4586) 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..b9cf7bc4 --- /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-origin 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/package.json b/ts/packages/popup/package.json new file mode 100644 index 00000000..6dad6c0e --- /dev/null +++ b/ts/packages/popup/package.json @@ -0,0 +1,44 @@ +{ + "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", + "test:e2e:install": "playwright install --with-deps chromium firefox webkit", + "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/playwright.config.ts b/ts/packages/popup/playwright.config.ts new file mode 100644 index 00000000..82925721 --- /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.localhost: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 new file mode 100644 index 00000000..bedfda8f --- /dev/null +++ b/ts/packages/popup/src/connection.test.ts @@ -0,0 +1,1486 @@ +import { describe, expect, it, vi } from 'vitest' +import { PopupConnection } from './connection.js' +import type { PopupDiagnostic } from './diagnostics.js' +import { PopupError } from './diagnostics.js' +import { + type Carrier, + type CarrierConstructor, + CONNECTION_VERSION, + type Message, +} from './message.js' +import { PortCarrier } from './port.js' +import { + APP_ORIGIN, + type FakePair, + type FakeProxy, + fakePair, + fakeScope, + fakeSignaling, + ID, + noRegistration, + 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, + allowedPopupOrigins: [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 | CarrierConstructor + 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 ? noRegistration : registrationWith(opts.worker), + ) + const { fallback } = opts + const endpoint = PopupConnection.accept(popup, { + connectionId: ID, + allowedApplicationOrigins: [APP_ORIGIN], + onDiagnostic: (e) => void side.events.push(e), + ...(fallback && { + fallback: typeof fallback === 'function' ? fallback : () => Promise.resolve(fallback), + }), + }) + // 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 } +} + +/** Both ends of a test carrier over one MessageChannel. */ +function carrierPair(): [PortCarrier, PortCarrier] { + const channel = new MessageChannel() + return [new PortCarrier(channel.port1, POPUP_ORIGIN), new PortCarrier(channel.port2, APP_ORIGIN)] +} + +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(), + allowedPopupOrigins: [POPUP_ORIGIN], + }), + ).toThrow(TypeError) + expect(() => + PopupConnection.connect(popup, { + connectionId: ID, + allowedPopupOrigins: [`${POPUP_ORIGIN}/`], + }), + ).toThrow(TypeError) + expect(pair.appView.listeners.size).toBe(0) + const current = new CurrentWindow(pair.popupWindow, noRegistration) + expect(() => + PopupConnection.accept(current, { + connectionId: 'nope', + allowedApplicationOrigins: [APP_ORIGIN], + }), + ).toThrow(TypeError) + expect(() => + PopupConnection.accept(current, { connectionId: ID, allowedApplicationOrigins: [] }), + ).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, noRegistration) + expect(() => + PopupConnection.connect(current, { connectionId: ID, allowedPopupOrigins: [POPUP_ORIGIN] }), + ).toThrow(TypeError) + PopupConnection.connect(popup, { connectionId: ID, allowedPopupOrigins: [POPUP_ORIGIN] }) + expect(() => + PopupConnection.connect(popup, { connectionId: ID, allowedPopupOrigins: [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) + } + }) + + it('PopupWindow.open always requests a separate window and keeps the opener [POPUP-WINDOW-001]', () => { + const open = vi.fn(() => null) + vi.stubGlobal('window', { + open, + outerWidth: 1280, + screen: { availWidth: 1920, availLeft: 0, availTop: 0 }, + }) + try { + PopupWindow.open('libid-popup') + PopupWindow.open('libid-popup', 'width=480,height=720') + expect(open.mock.calls).toEqual([ + ['about:blank', 'libid-popup', 'popup,left=0,top=0'], + ['about:blank', 'libid-popup', 'popup,width=480,height=720,left=0,top=0'], + ]) + 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) + 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, noRegistration).opener).toBeNull() + }) +}) + +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 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 [ + { 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).slice(-2)).toEqual(['decode-rejected', '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, + allowedPopupOrigins: [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('claims from every registration on the origin [POPUP-KEEPER-005]', async () => { + const pair = fakePair() + const app = connectApp(pair) + const stale = fakeScope() + const root = fakeScope() + await acceptPopup(pair, { worker: root.worker }).connection + await tick() + await app.connection.navigate('https://popup.example/isolated') + await tick(20) + expect(root.pending).toHaveLength(1) + // The destination is controlled by a stale nested registration holding + // nothing; the port is still found in the root worker. + const events: PopupDiagnostic[] = [] + const endpoint = PopupConnection.accept( + new CurrentWindow( + { ...pair.popupWindow, opener: null } as Window, + registrationWith(stale.worker, root.worker), + ), + { + connectionId: ID, + allowedApplicationOrigins: [APP_ORIGIN], + onDiagnostic: (e) => void events.push(e), + }, + ) + await endpoint.ready + expect(codes(events)).toEqual(['carrier-restored']) + }) + + 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).slice(-2)).toEqual(['control-rejected', 'connection-failed']) + + // 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('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' + + /** 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('ignores a handshake from an origin outside the set and stays live', async () => { + const pair = fakePair() + const app = connectMulti(pair) + pair.appView.dispatch({ + data: { type: 'message-port', connectionVersion: CONNECTION_VERSION, connectionId: ID }, + origin: 'https://evil.example', + source: pair.popupProxy, + }) + await tick() + expect(codes(app.events)).toEqual(['window-opened']) + await acceptPopup(pair).connection + await tick() + expect(codes(app.events)).toContain('carrier-message-port') + }) +}) + +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, noRegistration) + 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() + 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, noRegistration) + const pending = PopupConnection.accept(popup, { + connectionId: ID, + allowedApplicationOrigins: '*', + }) + await tick() + pair.popupView.dispatch({ + data: { type: 'message-port', connectionVersion: CONNECTION_VERSION, connectionId: ID }, + origin, + source: pair.appProxy, + ports: [new MessageChannel().port1], + }) + 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, noRegistration) + expect(() => + PopupConnection.accept(popup, { connectionId: ID, allowedApplicationOrigins: [] }), + ).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('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 ? noRegistration : registrationWith(opts.worker), + pair.popupWindow.location.hash, // captured as the host bootstrap would + ) + 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, noRegistration) + expect(() => + PopupConnection.accept(popup, { + connectionId: ID, + allowedApplicationOrigins: [APP_ORIGIN], + isolationFallbackUrl: bad, + }), + ).toThrow(TypeError) + } + expect(pair.popupView.listeners.size).toBe(0) + }) + + it('carries the captured fragment to the fallback and rejects one spelled inline', async () => { + const pair = fakePair() + pair.relocate(POPUP_ORIGIN, '/prover', '#c=1&x=y%20z') + connectApp(pair) + // The bootstrap cleared the URL after capturing; the snapshot still travels. + const captured = pair.popupWindow.location.hash + pair.relocate(POPUP_ORIGIN, '/prover', '') + const popup = new CurrentWindow( + pair.popupWindow, + registrationWith(fakeScope().worker), + captured, + ) + const events: PopupDiagnostic[] = [] + PopupConnection.accept(popup, { + connectionId: ID, + allowedApplicationOrigins: [APP_ORIGIN], + isolationFallbackUrl: '/f', + onDiagnostic: (e) => void events.push(e), + }) + await tick(20) + expect(pair.popupProxy.replaced).toEqual([`${POPUP_ORIGIN}/f#c=1&x=y%20z`]) + expect(codes(events)).toContain('keep-acknowledged') + for (const bad of ['/f#own', '/f#']) { + expect(() => + PopupConnection.accept(new CurrentWindow(pair.popupWindow, noRegistration), { + connectionId: ID, + allowedApplicationOrigins: [APP_ORIGIN], + isolationFallbackUrl: bad, + }), + ).toThrow(TypeError) + } + }) + + 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('loopback HTTP [POPUP-CONNECTION-009/011]', () => { + it.each(['localhost', '127.0.0.1'])( + 'authenticates and preserves isolation continuity on %s', + async (host) => { + const applicationOrigin = `http://${host}:4683` + const popupOrigin = `http://${host}:4684` + const pair = fakePair(popupOrigin, applicationOrigin) + pair.relocate(popupOrigin, '/prover', '#private=captured') + const scope = fakeScope(popupOrigin) + const app = PopupConnection.connect( + new OpenedWindow(pair.popupProxy as unknown as WindowProxy, pair.appView), + { connectionId: ID, allowedPopupOrigins: [popupOrigin] }, + ) + const accept = (allowedApplicationOrigins: readonly string[] | '*') => + PopupConnection.accept( + new CurrentWindow( + pair.popupWindow, + registrationWith(scope.worker), + pair.popupWindow.location.hash, + ), + { connectionId: ID, allowedApplicationOrigins, isolationFallbackUrl: '/prover/fallback' }, + ) + const first = accept([applicationOrigin]) + const premature = vi.fn() + first.on(Start, premature) + await app.ready + await expect(first.closed).resolves.toEqual({ outcome: 'closed' }) + app.send(new Start()) + expect(pair.popupProxy.replaced).toEqual([`${popupOrigin}/prover/fallback#private=captured`]) + expect(premature).not.toHaveBeenCalled() + + pair.relocate(popupOrigin, '/prover/fallback', '#private=captured') + pair.setIsolated(true) + const next = accept([applicationOrigin]) + const received = vi.fn() + next.on(Start, received) + await next.ready + await tick() + expect(received).toHaveBeenCalledTimes(1) + + // A normal HTTP departure retires the port; a fresh wildcard handshake + // then admits the same canonical local application origin. + await app.navigateAway(`${popupOrigin}/external`) + pair.relocate(popupOrigin, '/prover/fallback') + const fresh = accept('*') + fresh.on(Start, received) + await fresh.ready + await tick() + app.send(new Start()) + await tick() + expect(received).toHaveBeenCalledTimes(2) + await app.navigate(`${popupOrigin}/next`, new URLSearchParams('p=1')) + await expect(fresh.closed).resolves.toEqual({ outcome: 'closed' }) + expect(pair.popupProxy.replaced.at(-1)).toBe(`${popupOrigin}/next#p=1`) + await app.close() + }, + ) +}) + +describe('structured fragments [POPUP-CONNECTION-013]', () => { + it('serializes fragment fields into the Navigate control and direct navigation', async () => { + const pair = fakePair() + const app = connectApp(pair) + const params = new URLSearchParams({ c: ID, next: 'a b&c' }) + // Direct control before any carrier: the handle receives the composed URL. + await app.connection.navigate('https://popup.example/p', params) + expect(pair.popupProxy.replaced).toEqual([`https://popup.example/p#${params.toString()}`]) + // Over the carrier the control carries the same serialization. + const side = acceptPopup(pair) + await side.connection + await tick() + const snapshot = params.toString() + await app.connection.navigate('https://popup-b.example/p', params) + params.set('next', 'mutated after the call') + await tick(20) + expect(pair.popupProxy.replaced.at(-1)).toBe(`https://popup-b.example/p#${snapshot}`) + // An empty fragment adds nothing. + const bare = connectApp(fakePair()) + await bare.connection.navigate('https://popup.example/p', new URLSearchParams()) + expect(bare.popup.handle && (bare.popup.handle as unknown as FakeProxy).replaced).toEqual([ + 'https://popup.example/p', + ]) + }) + + it('rejects an inline fragment in every public URL argument, including an empty one', async () => { + const pair = fakePair() + const app = connectApp(pair) + const side = acceptPopup(pair) + const popup = await side.connection + await tick() + for (const bad of ['https://popup.example/x#c=1', 'https://popup.example/x#']) { + await expect(app.connection.navigate(bad)).rejects.toThrow(TypeError) + await expect(app.connection.navigateAway(bad)).rejects.toThrow(TypeError) + await expect(popup.navigate(bad)).rejects.toThrow(TypeError) + await expect(popup.navigateAway(bad)).rejects.toThrow(TypeError) + } + expect(pair.popupProxy.replaced).toEqual([]) + expect(() => popup.send(new Ready(1))).not.toThrow() + }) + + it('keeps a popup-initiated destination and fragment private to the popup', async () => { + const pair = fakePair() + const app = connectApp(pair) + const scope = fakeScope() + const side = acceptPopup(pair, { worker: scope.worker }) + const popup = await side.connection + await tick() + const before = codes(app.events).length + await popup.navigate(`${POPUP_ORIGIN}/next`, new URLSearchParams({ secret: 'value' })) + await tick(20) + expect(pair.popupProxy.replaced).toEqual([`${POPUP_ORIGIN}/next#secret=value`]) + // The application saw no control, no diagnostic, and no message. + expect(codes(app.events)).toHaveLength(before) + expect(scope.pending).toHaveLength(1) // continuity only; the keeper carries no URL + }) + + it('adopts the captured fragment from a bootstrap that cleared the URL', () => { + const view = { top: null as unknown, location: { hash: '#c=1' } } + view.top = view + vi.stubGlobal('window', view) + vi.stubGlobal('navigator', {}) + try { + const captured = PopupWindow.current('#c=1&t=2') as CurrentWindow + view.location.hash = '' + expect(captured.fragment).toBe('c=1&t=2') + expect((PopupWindow.current() as CurrentWindow).fragment).toBe('') + } finally { + vi.unstubAllGlobals() + } + }) +}) + +describe('isolation fallback over a non-transferable carrier [POPUP-CONNECTION-014]', () => { + const PROVER = 'https://popup-b.example' + + /** Application with a signaling-backed fallback, popup with its opener severed. */ + function severedPair(hub: ReturnType) { + const pair = fakePair() + pair.appProxy.closed = true // the popup's opener is gone + 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, PROVER], + fallback: hub.application, + onDiagnostic: (e) => void events.push(e), + }) + return { pair, app, events } + } + + function acceptWith( + pair: FakePair, + hub: ReturnType, + isolationFallbackUrl?: string, + ) { + const events: PopupDiagnostic[] = [] + const popup = new CurrentWindow( + pair.popupWindow, + noRegistration, + pair.popupWindow.location.hash, + ) + const endpoint = PopupConnection.accept(popup, { + connectionId: ID, + allowedApplicationOrigins: [APP_ORIGIN], + fallback: hub.popup, + ...(isolationFallbackUrl && { isolationFallbackUrl }), + onDiagnostic: (e) => void events.push(e), + }) + return { endpoint, events } + } + + it.each([POPUP_ORIGIN, PROVER])( + 'hops to %s before establishing a replacement, losing gap sends', + async (targetOrigin) => { + const hub = fakeSignaling() + const { pair, app, events } = severedPair(hub) + const first = acceptWith(pair, hub) + await first.endpoint.ready + await app.ready + expect(codes(events)).toContain('carrier-fallback') + + // Navigation prepares round two but cannot authenticate it before the + // destination exists. An ordered reply still reaches the old document. + const replies = vi.fn() + first.endpoint.on(Start, replies) + app.send(new Start()) + await app.navigate(`${targetOrigin}/prover`, new URLSearchParams('c=1')) + await tick(20) + expect(replies).toHaveBeenCalledTimes(1) + expect(pair.popupProxy.replaced).toEqual([`${targetOrigin}/prover#c=1`]) + expect(codes(events).filter((c) => c === 'carrier-fallback')).toHaveLength(1) + expect(await first.endpoint.closed).toEqual({ outcome: 'closed' }) + const rounds = hub.carriers.length + + // The non-isolated destination hops without spending a connection: no + // constructor call, no new round, nothing delivered. + pair.relocate(targetOrigin, '/prover', '#c=1') + const second = acceptWith(pair, hub, '/prover/fallback') + const leaked = vi.fn() + second.endpoint.on(Start, leaked) + await tick(20) + expect(pair.popupProxy.replaced.at(-1)).toBe(`${targetOrigin}/prover/fallback#c=1`) + expect(codes(second.events)).toEqual(['isolation-fallback', 'connection-closed']) + expect(hub.carriers).toHaveLength(rounds) + expect(leaked).not.toHaveBeenCalled() + expect(codes(events).filter((c) => c === 'carrier-fallback')).toHaveLength(1) + expect(() => app.send(new Start())).not.toThrow() // silent loss on the retired side + let settled = false + void second.endpoint.ready.then( + () => (settled = true), + () => (settled = true), + ) + await tick() + expect(settled).toBe(false) + + // The isolated fallback consumes the prepared round and both directions work. + pair.relocate(targetOrigin, '/prover/fallback', '#c=1') + pair.setIsolated(true) + const third = acceptWith(pair, hub, '/prover/fallback') + const starts = vi.fn() + third.endpoint.on(Start, starts) + await third.endpoint.ready + expect(codes(third.events)).toEqual(['carrier-fallback']) + expect(hub.carriers).toHaveLength(rounds + 2) + const readies: number[] = [] + app.on(Ready, (r) => void readies.push(r.version)) + app.send(new Start()) + third.endpoint.send(new Ready(4)) + await tick() + expect(starts).toHaveBeenCalledTimes(1) // only the post-authentication send + expect(readies).toEqual([4]) + expect(codes(events).filter((c) => c === 'carrier-fallback')).toHaveLength(2) + expect(codes(events)).not.toContain('connection-closed') + expect(codes(events)).not.toContain('connection-failed') + }, + ) + + it('closes before the hop without navigating', async () => { + const hub = fakeSignaling() + const { pair } = severedPair(hub) + const side = acceptWith(pair, hub, '/prover/fallback') + await side.endpoint.close() + await tick(20) + expect(pair.popupProxy.replaced).toEqual([]) + expect(codes(side.events)).not.toContain('isolation-fallback') + expect(await side.endpoint.closed).toEqual({ outcome: 'closed' }) + }) + + it('reports a failed reconnection through the destination only', async () => { + const hub = fakeSignaling() + const { pair, app, events } = severedPair(hub) + const first = acceptWith(pair, hub, '/prover/fallback') + await tick(20) // left for the fallback without a carrier + expect(pair.popupProxy.replaced).toHaveLength(1) + expect(hub.carriers).toHaveLength(0) + void first + pair.relocate(POPUP_ORIGIN, '/prover/fallback', '') + pair.setIsolated(true) + hub.failNext = true + const second = acceptWith(pair, hub, '/prover/fallback') + await expect(second.endpoint.ready).rejects.toThrow('fallback-failed') + // The application is not told and still awaits its first carrier, so it + // cannot send into a gap: there is none. + let appEnded = false + void app.closed.then(() => (appEnded = true)) + await tick() + expect(appEnded).toBe(false) + expect(() => app.send(new Start())).toThrow('send-unavailable') + expect(codes(events)).not.toContain('connection-failed') + }) + + it('fails without looping when the fallback document stays non-isolated', async () => { + const hub = fakeSignaling() + const { pair } = severedPair(hub) + pair.relocate(POPUP_ORIGIN, '/prover/fallback', '') + const side = acceptWith(pair, hub, '/prover/fallback') + await expect(side.endpoint.ready).rejects.toThrow('isolation-unavailable') + expect(pair.popupProxy.replaced).toEqual([]) + expect(hub.carriers).toHaveLength(0) + }) +}) + +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', 'connection-failed']) + }) + + 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, + allowedPopupOrigins: [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, noRegistration) + const events: PopupDiagnostic[] = [] + 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() + 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 }) + 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') + }) +}) + +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 = { + peerOrigin: APP_ORIGIN, + send: () => {}, + on: (handler) => { + deliver = handler + return () => {} + }, + close: () => {}, + } + const pair = fakePair() + const popup = new CurrentWindow({ ...pair.popupWindow, opener: null } as Window, noRegistration) + 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: CONNECTION_VERSION + 1, 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`, new URLSearchParams('other'))).rejects.toThrow( + TypeError, + ) + expect(() => popup.send(new Ready(1))).not.toThrow() + }) +}) + +describe('authenticated peer origin [POPUP-CONNECTION-007] [POPUP-KEEPER-001]', () => { + it('exposes the selected peer on both sides and clears it on retirement', async () => { + const pair = fakePair() + const app = connectApp(pair) + const side = acceptPopup(pair) + expect(app.connection.peerOrigin).toBeNull() + expect(side.endpoint.peerOrigin).toBeNull() + const popup = await side.connection + await app.connection.ready + expect(app.connection.peerOrigin).toBe(POPUP_ORIGIN) + expect(popup.peerOrigin).toBe(APP_ORIGIN) + await app.connection.navigateAway('https://provider.example/') + expect(app.connection.peerOrigin).toBeNull() + await popup.close() + expect(popup.peerOrigin).toBeNull() + await app.connection.close() + }) + + it.each([ + { allowedOrigin: APP_ORIGIN, isolate: false }, + { allowedOrigin: 'https://other-app.example', isolate: false }, + { allowedOrigin: 'https://other-app.example', isolate: true }, + ])( + 'restores the binding and enforces the destination allowlist %s', + async ({ allowedOrigin, isolate }) => { + const pair = fakePair() + const scope = fakeScope() + const app = connectApp(pair) + const first = await acceptPopup(pair, { worker: scope.worker }).connection + const navigating = first.navigate(`${POPUP_ORIGIN}/next`) + expect(first.peerOrigin).toBeNull() // Detached before the keeper can acknowledge. + await navigating + const fallback = vi.fn() + const next = PopupConnection.accept( + new CurrentWindow( + { ...pair.popupWindow, opener: null } as Window, + registrationWith(scope.worker), + ), + { + connectionId: ID, + allowedApplicationOrigins: [allowedOrigin], + fallback, + ...(isolate && { isolationFallbackUrl: `${POPUP_ORIGIN}/isolated` }), + }, + ) + if (allowedOrigin === APP_ORIGIN) { + await next.ready + expect(next.peerOrigin).toBe(APP_ORIGIN) + const received = vi.fn() + next.on(Start, received) + app.connection.send(new Start()) + await tick() + expect(received).toHaveBeenCalledOnce() + await next.close() + } else { + await expect(next.ready).rejects.toThrow('handshake-rejected') + expect(next.peerOrigin).toBeNull() + expect(scope.pending).toHaveLength(1) // No second keep or isolation navigation. + } + expect(fallback).not.toHaveBeenCalled() + await app.connection.close() + }, + ) + + it.each([undefined, 'null', `${APP_ORIGIN}/`, 'https://other-app.example'])( + 'rejects fallback origin %s before carrier subscription', + async (peerOrigin) => { + const pair = fakePair() + const carrier = { + peerOrigin, + send: vi.fn(), + on: vi.fn(), + close: vi.fn(), + } as unknown as Carrier + const side = acceptPopup(pair, { opener: false, fallback: carrier }) + await expect(side.endpoint.ready).rejects.toThrow('handshake-rejected') + expect(side.endpoint.peerOrigin).toBeNull() + expect(carrier.on).not.toHaveBeenCalled() + expect(carrier.close).toHaveBeenCalledOnce() + }, + ) +}) diff --git a/ts/packages/popup/src/connection.ts b/ts/packages/popup/src/connection.ts new file mode 100644 index 00000000..26af18f0 --- /dev/null +++ b/ts/packages/popup/src/connection.ts @@ -0,0 +1,727 @@ +// 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 registration, routing, and closure; the +// popup side additionally consumes the two reserved controls. + +import { + createReporter, + type DiagnosticCode, + type PopupDiagnostic, + PopupError, + type PopupErrorCode, + type Reporter, + reportUndeliverable, +} from './diagnostics.js' +import { activeRegistration, bounded, PortKeeper } from './keeper.js' +import { + type Carrier, + type CarrierConstructor, + decodeControl, + isAllowedOrigin, + isCanonicalWebUrl, + isConnectionId, + isNavigationCarrier, + isReservedType, + type Message, + type MessageType, + type Navigate, + type OriginAllowlist, + onReplacement, + type PopupControl, + prepareNavigation, + requireOrigins, + routingType, +} from './message.js' +import { listenForPopupPorts, PortCarrier, requestApplicationPort } from './port.js' +import { CurrentWindow, OpenedWindow, type PopupWindow } from './window.js' + +/** 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 + /** Authenticated peer of the selected carrier; null before selection or after retirement. */ + readonly peerOrigin: string | null + send(message: Out): void + on(message: MessageType, handler: (message: N) => void): () => void + /** + * Continuity-preserving navigation between participating documents. `url` + * carries no fragment; `fragment` supplies one as opaque protocol data, + * serialized at the call. + */ + navigate(url: string, fragment?: URLSearchParams): Promise + /** + * 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, fragment?: URLSearchParams): Promise + close(): Promise +} + +export interface ConnectOptions { + connectionId: string + allowedPopupOrigins: readonly string[] + fallback?: CarrierConstructor + onDiagnostic?: (event: PopupDiagnostic) => void +} + +export interface AcceptOptions { + connectionId: string + /** Explicit origins, or `'*'` for any canonical HTTPS (or localhost HTTP) origin the browser observed. */ + allowedApplicationOrigins: readonly string[] | '*' + /** + * Requires cross-origin isolation. A non-isolated document preserves an + * available MessagePort through the worker, or defers fallback construction + * until after replacement. The same-origin destination resolves against + * the current document and inherits its captured fragment; it must not + * spell a fragment itself. + */ + isolationFallbackUrl?: 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 +} + +/** + * The navigation target from a fragment-free URL and optional opaque + * parameters, serialized now so later mutation of `fragment` is invisible. + */ +function destination(url: string, fragment: URLSearchParams | undefined, report: Reporter): string { + if (!isCanonicalWebUrl(url) || url.includes('#')) { + report('control-rejected') + throw new TypeError( + 'navigation requires a canonical absolute HTTPS (or localhost HTTP) URL without credentials or fragment', + ) + } + const serialized = fragment?.toString() ?? '' + return serialized === '' ? url : `${url}#${serialized}` +} + +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 (or localhost HTTP) fallback, carrying the captured fragment. */ +function resolveFallback(value: string, location: Location, fragment: string): URL { + let url: URL + try { + url = new URL(value, location.href) + } catch { + throw new TypeError('isolationFallbackUrl must be a URL') + } + if (!isCanonicalWebUrl(url.href) || url.origin !== location.origin || value.includes('#')) { + throw new TypeError( + 'isolationFallbackUrl must be a same-origin HTTPS (or localhost HTTP) URL without fragment', + ) + } + url.hash = fragment + return url +} + +interface Registration { + decode: (value: unknown) => In + handler: (message: In) => void +} + +/** 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 ended = false + private readonly registrations = new Map>() + private boundOrigin: string | null = null + 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, + private readonly allowedOrigins: OriginAllowlist, + ) { + 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(() => {}) + } + + get peerOrigin(): string | null { + return this.boundOrigin + } + + send(message: Out): void { + 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.ended || !this.carrier) { + this.report('send-unavailable') + throw new PopupError('send-unavailable') + } + try { + this.carrier.send(value) + } catch (error) { + this.fail('send-unavailable', true) + throw error + } + } + + 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, fragment?: URLSearchParams): Promise + abstract navigateAway(url: string, fragment?: URLSearchParams): Promise + abstract close(): Promise + + /** Installs the selected carrier; the class is reported when it was chosen here. */ + protected install(carrier: Carrier, code?: DiagnosticCode): void { + if (!this.checkPeer(carrier)) return + this.dropCarrier() + this.carrier = carrier + this.boundOrigin = carrier.peerOrigin + this.unsubscribe = carrier.on((value) => this.receive(value)) + if (code) this.report(code) + this.resolveReady() + } + + /** Rejects an invalid binding before either delivery or isolation handoff. */ + protected checkPeer(carrier: Carrier): boolean { + if (isAllowedOrigin(carrier.peerOrigin, this.allowedOrigins)) return true + carrier.close() + this.fail('handshake-rejected') + return false + } + + 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.ended) return + const type = routingType(value) + if (type === null) { + this.fail('decode-rejected') + return + } + if (isReservedType(type)) { + const control = decodeControl(value as Record) + if (control) this.onControl(control) + else this.fail('control-rejected') + return + } + const registration = this.registrations.get(type) + if (!registration) { + this.fail('decode-rejected') + return + } + let message: In + try { + message = registration.decode(value) + } catch { + this.fail('decode-rejected') + return + } + registration.handler(message) + } + + protected dropCarrier(): void { + this.boundOrigin = null + this.unsubscribe?.() + this.unsubscribe = null + this.carrier?.close() + this.carrier = null + } + + /** + * 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' }) + } + + private end(end: ConnectionEnd): void { + this.ended = true + this.controller.abort() + this.dropCarrier() + 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 { + private readonly stopListening: () => void + private stopReplacement: (() => void) | null = null + + constructor( + private readonly popup: OpenedWindow, + options: ConnectOptions, + ) { + const allowedPopupOrigins = requireOrigins(options.allowedPopupOrigins, 'allowedPopupOrigins') + super(createReporter(options.onDiagnostic), allowedPopupOrigins) + const connectionId = requireConnectionId(options.connectionId) + 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') + }, + allowedPopupOrigins, + connectionId, + }, + { + onPort: (port, peerOrigin) => + this.install(new PortCarrier(port, peerOrigin), 'carrier-message-port'), + onFail: () => this.fail('handshake-rejected'), + }, + ) + + if (options.fallback) { + // Armed exactly once for the logical connection; observed, never awaited. + const { fallback } = options + new Promise((resolve) => resolve(fallback(this.controller.signal))).then( + (carrier) => { + if (this.ended) 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') + } + + /** + * A carrier that cannot cross a document replacement reports its own + * replacement, prepared by the popup before it navigated; the application + * installs the authenticated result under the same logical connection. + */ + protected override install(carrier: Carrier, code?: DiagnosticCode): void { + super.install(carrier, code) + if (this.ended || !isNavigationCarrier(carrier)) return + this.stopReplacement = carrier[onReplacement]((pending) => { + pending.then( + (next) => { + if (this.ended || this.carrier !== carrier) next.close() + else this.install(next, 'carrier-fallback') + }, + () => { + // A failed replacement leaves the retired carrier in place; the + // destination reports the failure through its own readiness. + }, + ) + }) + } + + protected override dropCarrier(): void { + this.stopReplacement?.() + this.stopReplacement = null + super.dropCarrier() + } + + async navigate(url: string, fragment?: URLSearchParams): Promise { + if (this.ended) throw new PopupError('connection-closed') + const target = destination(url, fragment, this.report) + if (this.carrier) { + const control: Navigate = { type: 'navigate', url: target } + this.transmit(control) + this.report('control-connected') + return + } + if (this.popup.direct) { + this.popup.replace(target) + 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 new PopupError('popup-unavailable') + } + + async navigateAway(url: string, fragment?: URLSearchParams): Promise { + if (this.ended) throw new PopupError('connection-closed') + const target = destination(url, fragment, this.report) + if (!this.popup.opened) return + if (!this.popup.direct) { + this.report('popup-unavailable') + throw new PopupError('popup-unavailable') + } + // Retire the carrier; the window listener stays armed for the next + // participating document. + this.dropCarrier() + this.popup.replace(target) + this.report('control-direct') + } + + async close(): Promise { + if (this.ended) 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() + } + + protected override release(): void { + if (this.ended) return + this.stopListening() + super.release() + } + + protected override fail(code: PopupErrorCode, viaOperation = false): void { + if (this.ended) return + this.stopListening() + super.fail(code, viaOperation) + } +} + +class PopupEndpoint extends Endpoint { + /** The first accepted control is terminal for this document. */ + private controlsDone = false + private readonly connectionId: string + private readonly isolationFallback: URL | null + + constructor( + private readonly popup: CurrentWindow, + options: AcceptOptions, + ) { + const allowedOrigins: OriginAllowlist = + options.allowedApplicationOrigins === '*' + ? '*' + : requireOrigins(options.allowedApplicationOrigins, 'allowedApplicationOrigins') + super(createReporter(options.onDiagnostic), allowedOrigins) + this.connectionId = requireConnectionId(options.connectionId) + this.isolationFallback = + options.isolationFallbackUrl === undefined + ? null + : resolveFallback(options.isolationFallbackUrl, popup.view.location, popup.fragment) + void this.select(allowedOrigins, options.fallback) + } + + /** + * 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 workers = (await this.popup.registrations()).flatMap((r) => r.active ?? []) + if (workers.length > 0) { + const port = await this.claimFrom(workers) + if (this.ended) return port?.close() + if (port) return this.admit(port, 'carrier-restored') + this.report('claim-empty') + } + const opener = this.popup.opener + if (opener) { + 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.admit(port, 'carrier-message-port') + this.report('opener-timeout') + } + if (!fallback) return this.fail('fallback-unavailable', true) + if (this.isolationFallback && !this.popup.isolated) { + if (this.ended) return + // Nothing exists to preserve yet, so the hop precedes the carrier: + // the destination establishes the only one from the same still- + // unused round, and no connection is spent on this document. + if (sameDocument(this.isolationFallback, this.popup.view.location)) { + return this.fail('isolation-unavailable', true) + } + this.report('isolation-fallback') + this.release() + this.popup.view.location.replace(this.isolationFallback.href) + return + } + const carrier = await fallback(this.controller.signal) + if (this.ended) return carrier.close() + return this.install(carrier, 'carrier-fallback') + } catch (error) { + if (this.ended) return + this.fail(error instanceof PopupError ? error.code : 'fallback-failed', true) + } + } + + /** Asks every worker at once; at most one holds this connection's port. */ + private async claimFrom(workers: ServiceWorker[]): Promise { + const ports = await Promise.all(workers.map((w) => new PortKeeper(w).claim(this.connectionId))) + const [port = null, ...extra] = ports.filter((p) => p !== null) + for (const p of extra) p.port.close() + return port ? new PortCarrier(port.port, port.peerOrigin) : null + } + + /** + * Installs an authenticated port, unless this document must be isolated + * and is not: then the port, still unstarted so every value the + * application already sent stays queued inside it, is kept through the + * worker and the document replaces itself with the isolated fallback, + * where the port continues. `ready` stays pending here; the replacement + * becomes ready instead. + */ + private async admit( + carrier: PortCarrier, + code: 'carrier-restored' | 'carrier-message-port', + ): Promise { + const { location } = this.popup.view + if (!this.isolationFallback || this.popup.isolated) return this.install(carrier, code) + if (!this.checkPeer(carrier)) return + 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. + carrier.close() + return this.fail('isolation-unavailable', true) + } + this.controlsDone = true + this.report('isolation-fallback') + this.carrier = carrier // retired by release(), never started for delivery + try { + await this.leaveFor(this.isolationFallback.href, true) + } catch { + // already failed through `ready` + } + } + + protected onControl(control: PopupControl): void { + if (this.controlsDone) return + this.controlsDone = true + if (control.type === 'navigate') { + void this.replaceDocument(control.url, false).catch(() => {}) + } else { + this.closePopup() + } + } + + async navigate(url: string, fragment?: URLSearchParams): Promise { + if (this.ended) throw new PopupError('connection-closed') + const target = destination(url, fragment, this.report) + if (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 + // Acts locally: the destination and its fragment reach no control, + // diagnostic, or signal. + await this.replaceDocument(target, true) + } + + async navigateAway(url: string, fragment?: URLSearchParams): Promise { + if (this.ended) throw new PopupError('connection-closed') + const target = destination(url, fragment, this.report) + if (this.controlsDone) throw new PopupError('popup-unavailable') + this.controlsDone = true + this.release() + this.popup.view.location.replace(target) + } + + async close(): Promise { + 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. Failure is reported through the invoking operation when there + * is one, otherwise as undeliverable. + */ + private async replaceDocument(url: string, viaOperation: boolean): Promise { + const { location } = this.popup.view + const carrier = this.carrier + if (new URL(url).origin !== location.origin && !(carrier && isNavigationCarrier(carrier))) { + // Nothing crosses an origin: the destination authenticates afresh. + this.release() + location.replace(url) + return + } + await this.leaveFor(url, viaOperation) + } + + /** + * Leaves this document for `url` with continuity: a port is kept through + * the worker; a navigation carrier prepares its replacement first and is + * then retired; any other carrier cannot continue. Failure is reported + * through the invoking operation when there is one and rethrown. + */ + private async leaveFor(url: string, viaOperation: boolean): Promise { + const { location } = this.popup.view + const carrier = this.carrier + if (carrier instanceof PortCarrier) { + const peerOrigin = carrier.peerOrigin + const port = carrier.detach() + this.dropCarrier() + await this.keepThrough(port, peerOrigin, url, viaOperation) + this.release() // the port is the worker's now; this endpoint is done + location.replace(url) + return + } + if (!carrier || !isNavigationCarrier(carrier)) { + this.fail('continuity-unsupported', viaOperation) + throw new PopupError('continuity-unsupported') + } + let target: string + try { + target = await carrier[prepareNavigation](url) + } catch { + this.fail('continuity-unsupported', viaOperation) + throw new PopupError('continuity-unsupported') + } + if (this.ended) throw new PopupError('connection-closed') + // Retire before leaving; nothing the application sends from here on + // reaches a document until the destination authenticates its successor. + this.release() + location.replace(target) + } + + /** + * Hands one port to the worker for the next same-origin document at `url`. + * Fails the endpoint and throws when no worker is active, the keep is + * refused, or the connection ended meanwhile. + */ + private async keepThrough( + port: MessagePort, + peerOrigin: string, + url: string, + 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 registration that will control the destination is the one its + // document claims from, whichever one controls this document. The host + // may still be registering it here, so wait briefly for it to activate. + const registration = await bounded( + activeRegistration(async () => (await this.popup.registrations(url))[0]), + ) + const worker = registration?.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, peerOrigin) + } catch { + failed('keep-failed') + } + if (this.ended) throw new PopupError('connection-closed') + this.report('keep-acknowledged', performance.now() - startedAt) + } + + private closePopup(): void { + this.release() + 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) + }, + + /** + * 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, + ): PopupConnection { + if (!(popupWindow instanceof CurrentWindow)) { + throw new TypeError('accept requires the PopupWindow returned by PopupWindow.current') + } + return new PopupEndpoint(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..0e54304c --- /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', -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..28dff3ea --- /dev/null +++ b/ts/packages/popup/src/diagnostics.ts @@ -0,0 +1,79 @@ +// Sanitized local diagnostics (METRICS.md): a stable code, a timestamp, and +// 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 + readonly timestamp: number + readonly durationMs?: number + readonly count?: number +} + +/** The codes an operation or the connection's terminal outcome can carry. */ +export type PopupErrorCode = + | 'handshake-rejected' + | 'opener-timeout' + | 'fallback-unavailable' + | 'fallback-failed' + | 'decode-rejected' + | 'control-rejected' + | 'continuity-unsupported' + | 'keep-failed' + | 'claim-failed' + | 'isolation-unavailable' + | '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' + | 'isolation-fallback' + | 'connection-failed' + +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, durationMs) => { + const event: PopupDiagnostic = { + code, + timestamp: performance.timeOrigin + performance.now(), + ...(durationMs !== undefined && { durationMs: Math.max(0, 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) +} diff --git a/ts/packages/popup/src/index.ts b/ts/packages/popup/src/index.ts new file mode 100644 index 00000000..e300266c --- /dev/null +++ b/ts/packages/popup/src/index.ts @@ -0,0 +1,20 @@ +/// @libid/popup — one popup browsing context and its logical connection. +/// The Service Worker handler lives behind `@libid/popup/worker`. + +export { + type AcceptOptions, + type ConnectionEnd, + type ConnectOptions, + PopupConnection, +} from './connection.js' +export { type PopupDiagnostic, PopupError, type PopupErrorCode } from './diagnostics.js' +export { + type Carrier, + type CarrierConstructor, + type Message, + type MessageType, + type NavigationCarrier, + onReplacement, + prepareNavigation, +} from './message.js' +export { type CurrentOptions, 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..4b9b00fb --- /dev/null +++ b/ts/packages/popup/src/keeper.test.ts @@ -0,0 +1,152 @@ +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 './message.js' +import { APP_ORIGIN, 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, APP_ORIGIN) + 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?.peerOrigin).toBe(APP_ORIGIN) + const pending = nextMessage(claimed!.port) + 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, APP_ORIGIN) + await expect(keeper.keep(ID, second.port1, APP_ORIGIN)).rejects.toThrow('keep-failed') + expect(await keeper.claim(ID)).toBeNull() + await expect(scope.pending[0]).resolves.toBeUndefined() + }) + + it('ignores a client from another origin, which the keeper treats as absent', async () => { + vi.useFakeTimers() + try { + const scope = fakeScope() + 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, + peerOrigin: APP_ORIGIN, + } + expect(decodeKeeperRequest(ok)).toEqual(ok) + expect( + decodeKeeperRequest({ type: CLAIM, connectionVersion: CONNECTION_VERSION, connectionId: ID }) + ?.type, + ).toBe(CLAIM) + for (const bad of [ + { ...ok, connectionVersion: CONNECTION_VERSION + 1 }, + { ...ok, connectionId: ID.toUpperCase() }, + { ...ok, extra: 1 }, + { ...ok, peerOrigin: undefined }, + { ...ok, peerOrigin: 'https://app.example/' }, + { ...ok, peerOrigin: 'null' }, + { ...ok, type: CLAIM }, + { ...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, APP_ORIGIN)).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, APP_ORIGIN) + 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, APP_ORIGIN) + 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..be16ac3f --- /dev/null +++ b/ts/packages/popup/src/keeper.ts @@ -0,0 +1,186 @@ +// 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 { PopupError } from './diagnostics.js' +import { + CONNECTION_VERSION, + hasExactKeys, + isAllowedOrigin, + isConnectionId, + isRecord, +} from './message.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 type KeeperRequest = { + connectionVersion: typeof CONNECTION_VERSION + connectionId: string +} & ({ type: typeof KEEP; peerOrigin: string } | { type: typeof CLAIM }) + +export function decodeKeeperRequest(value: unknown): KeeperRequest | null { + if ( + !isRecord(value) || + !hasExactKeys( + value, + value.type === KEEP + ? ['type', 'connectionVersion', 'connectionId', 'peerOrigin'] + : ['type', 'connectionVersion', 'connectionId'], + ) || + (value.type !== KEEP && value.type !== CLAIM) || + value.connectionVersion !== CONNECTION_VERSION || + !isConnectionId(value.connectionId) || + (value.type === KEEP && + (typeof value.peerOrigin !== 'string' || !isAllowedOrigin(value.peerOrigin, '*'))) + ) { + return null + } + return value as KeeperRequest +} + +/** The subset of ServiceWorker the keeper needs; injectable for tests. */ +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). + */ +export function activeWorker( + registration: ServiceWorkerRegistration, +): 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), KEEPER_REPLY_TIMEOUT_MS) + worker.addEventListener('statechange', onChange) + }) +} + +/** + * The registration `lookup` names once it exists and is active, or undefined + * past the keeper reply deadline. The host may register in this very + * document, so an absent registration, or one the engine already exposes + * before attaching its installing worker, is polled for rather than refused. + */ +export async function activeRegistration( + lookup: () => Promise, +): Promise { + const deadline = Date.now() + KEEPER_REPLY_TIMEOUT_MS + const attached = (r?: ServiceWorkerRegistration): boolean => + !!r && (r.active ?? r.installing ?? r.waiting) !== null + let registration = await lookup() + while (!attached(registration) && Date.now() < deadline) { + // ponytail: nothing announces a new registration; poll until the deadline. + await new Promise((resolve) => setTimeout(resolve, 50)) + registration = await lookup() + } + if (!registration) return undefined + return (await activeWorker(registration)) ? registration : undefined +} + +export class PortKeeper { + constructor(private readonly worker: KeeperWorker) {} + + /** Resolves only after the worker owns the port. */ + async keep(connectionId: string, port: MessagePort, peerOrigin: string): Promise { + const reply = await this.exchange( + { type: KEEP, connectionVersion: CONNECTION_VERSION, connectionId, peerOrigin }, + [port], + '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. 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<{ port: MessagePort; peerOrigin: string } | null> { + const reply = await this.exchange( + { type: CLAIM, connectionVersion: CONNECTION_VERSION, connectionId }, + [], + 'claim-failed', + ) + if (!reply) return null + const { data, ports } = reply + if (isRecord(data)) { + if (hasExactKeys(data, ['port']) && data.port === false && ports.length === 0) return null + if ( + hasExactKeys(data, ['port', 'peerOrigin']) && + data.port === true && + ports.length === 1 && + typeof data.peerOrigin === 'string' && + isAllowedOrigin(data.peerOrigin, '*') + ) + return { port: ports[0], peerOrigin: data.peerOrigin } + } + for (const port of ports) port.close() + throw new PopupError('claim-failed') + } + + /** One request with its own reply port; null when the worker stays silent. */ + private exchange( + message: KeeperRequest, + transfer: MessagePort[], + code: 'keep-failed' | 'claim-failed', + ): Promise { + return new Promise((resolve, reject) => { + const reply = new MessageChannel() + 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) + } + const timer = setTimeout(() => finish(null), KEEPER_REPLY_TIMEOUT_MS) + reply.port1.onmessage = (event: MessageEvent): void => finish(null, event) + try { + this.worker.postMessage(message, [...transfer, reply.port2]) + } catch { + finish(new PopupError(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..62f7f986 --- /dev/null +++ b/ts/packages/popup/src/message.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import { + canonicalOrigin, + decodeControl, + isAllowedOrigin, + isCanonicalWebUrl, + isConnectionId, + isReservedType, + MAX_TYPE_LENGTH, + requireOrigins, + 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(isCanonicalWebUrl('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(isCanonicalWebUrl(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(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) }, + { type: '' }, + { type: 1 }, + {}, + [], + new Date(), + Object.assign(Object.create({ type: 'ready' }), { type: 'ready' }), + 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() + } + }) +}) + +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) + } + }) +}) + +describe('wildcard allowlist [POPUP-CONNECTION-009]', () => { + it("accepts canonical HTTPS and rejects unrelated origins under '*'", () => { + 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) + }) +}) + +it('admits only canonical explicit loopback HTTP for navigation and origins', () => { + for (const origin of [ + 'http://localhost', + 'http://127.0.0.1', + 'http://localhost:4683', + 'http://127.0.0.1:65535', + ]) { + expect(isCanonicalWebUrl(`${origin}/prover`)).toBe(true) + expect(requireOrigins([origin], 'origins')).toEqual([origin]) + expect(isAllowedOrigin(origin, '*')).toBe(true) + expect(decodeControl({ type: 'navigate', url: `${origin}/prover` })).not.toBeNull() + expect(() => requireOrigins([origin, origin], 'origins')).toThrow() + } + for (const origin of [ + 'http://localhost.evil.test', + 'http://192.168.1.1', + 'http://localtest.me', + 'http://localhost.', + 'http://user@localhost', + 'http://LOCALHOST', + 'http://127.1', + 'http://localhost:80', + 'http://localhost:65536', + ]) { + expect(isCanonicalWebUrl(`${origin}/prover`), origin).toBe(false) + expect(() => requireOrigins([origin], 'origins'), origin).toThrow() + expect(isAllowedOrigin(origin, '*'), origin).toBe(false) + } + expect(isAllowedOrigin('http://localhost:4684', ['http://localhost:4683'])).toBe(false) + expect(isAllowedOrigin('https://localhost:4683', ['http://localhost:4683'])).toBe(false) +}) diff --git a/ts/packages/popup/src/message.ts b/ts/packages/popup/src/message.ts new file mode 100644 index 00000000..61743ff5 --- /dev/null +++ b/ts/packages/popup/src/message.ts @@ -0,0 +1,163 @@ +// 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 = 2 as const +export type ConnectionVersion = typeof CONNECTION_VERSION + +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 { + /** Exact peer origin established by this carrier's authentication. */ + readonly peerOrigin: string + send(value: Message): void + on(handler: (value: unknown) => void): () => void + close(): void +} + +export type CarrierConstructor = (signal: AbortSignal) => Promise + +/** + * Package-private lifecycle hooks of a carrier that cannot be transferred + * across a document replacement (docs/connection.md, Carrier API). The popup + * side prepares the next round before navigating; the application side + * reports the resulting replacement carrier. The connection drives both and + * never exposes them to callers. + */ +export const prepareNavigation: unique symbol = Symbol('prepareNavigation') +export const onReplacement: unique symbol = Symbol('onReplacement') + +export interface NavigationCarrier extends Carrier { + /** Arms the replacement for `target` and resolves the exact URL to navigate to. */ + [prepareNavigation](target: string): Promise + /** Reports each pending authenticated replacement carrier; returns an unsubscribe. */ + [onReplacement](handler: (carrier: Promise) => void): () => void +} + +export function isNavigationCarrier(carrier: Carrier): carrier is NavigationCarrier { + return prepareNavigation in carrier && onReplacement in carrier +} + +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) +} + +/** 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) + return prototype === Object.prototype || prototype === null +} + +/** 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 (or localhost HTTP) URL in its own serialization, without credentials. */ +export function isCanonicalWebUrl(url: string): boolean { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return false + } + return ( + (parsed.protocol === 'https:' || + (parsed.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(parsed.hostname))) && + 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' && + isCanonicalWebUrl(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 + } +} + +/** Either an explicit allowlist or any canonical HTTPS (or localhost HTTP) origin the browser observed. */ +export type OriginAllowlist = readonly string[] | '*' + +export function isAllowedOrigin(origin: string, allowlist: OriginAllowlist): boolean { + if (allowlist === '*') + return canonicalOrigin(origin) === origin && isCanonicalWebUrl(`${origin}/`) + return allowlist.includes(origin) +} + +/** + * A nonempty, duplicate-free set of canonical HTTPS (or localhost HTTP) 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 || !isCanonicalWebUrl(`${origin}/`))) { + throw new TypeError(`${option} must contain canonical HTTPS (or localhost HTTP) 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 new file mode 100644 index 00000000..fd2d17aa --- /dev/null +++ b/ts/packages/popup/src/port.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, it, vi } from 'vitest' +import { CONNECTION_VERSION } from './message.js' +import { listenForPopupPorts, PortCarrier, requestApplicationPort } from './port.js' +import { + APP_ORIGIN, + type FakePair, + 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 + bound: unknown[] + stop: () => void +} + +function listen(source: 'handle' | null = 'handle', pair = fakePair()): Harness { + const h: Harness = { pair, ports: [], fails: 0, 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), + allowedPopupOrigins: [POPUP_ORIGIN], + connectionId: ID, + }, + { 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, + }) + +const requestPort = async (pair: FakePair, overrides = {}): Promise => { + const port = await request(pair, overrides) + if (!port) throw new Error('expected a port') + return port.detach() +} + +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 requestPort(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. + const transferred = await new Promise((resolve) => { + const observer = (event: MessageEvent) => { + h.pair.popupView.removeEventListener('message', observer) + resolve(event.ports[0]) + } + h.pair.popupView.addEventListener('message', observer) + h.pair.appProxy.postMessage(handshake(), '*') + }) + transferred.postMessage({ ...handshake(), connectionVersion: CONNECTION_VERSION + 1 }) + await tick() + expect(h.ports).toHaveLength(0) + expect(h.fails).toBe(1) + + // A second echo after acceptance is an ordinary value, not a re-selection. + const h2 = listen() + const popupPort = await requestPort(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) + h.stop() + }) + + 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({ + ...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: CONNECTION_VERSION + 1 } }, + { 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.ports).toHaveLength(0) + h.stop() + } + }) + + it('accepts sequential handshakes over one listener, superseding a pending attempt', async () => { + const h = listen() + const first = await requestPort(h.pair) + await tick() + // A second document handshakes; the first port is not disturbed by us. + const second = await requestPort(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 requestPort(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 requestPort(h.pair) + await tick() + expect(h.bound).toEqual([h.pair.popupProxy]) + expect(h.ports).toHaveLength(1) + port.close() + // 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(0) + 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 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: 'https://evil.example', + source: pair.appProxy, + ports: [new MessageChannel().port1], + }, + { data: handshake(), origin: APP_ORIGIN, source: pair.appProxy, ports: [] }, + { + data: { ...handshake(), connectionVersion: CONNECTION_VERSION + 1 }, + 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 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 }) + pair.popupView.dispatch({ + data: handshake(OTHER_ID), + origin: APP_ORIGIN, + source: pair.appProxy, + }) + 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 () => { + 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, APP_ORIGIN) + 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, APP_ORIGIN) + 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..cadbd4ff --- /dev/null +++ b/ts/packages/popup/src/port.ts @@ -0,0 +1,260 @@ +// 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 { PopupError } from './diagnostics.js' +import { + type Carrier, + CONNECTION_VERSION, + hasExactKeys, + isAllowedOrigin, + isRecord, + type Message, + type OriginAllowlist, +} from './message.js' +import type { View } from './window.js' + +export const OPENER_HANDSHAKE_TIMEOUT_MS = 30_000 + +const HANDSHAKE = 'message-port' + +interface Handshake { + type: typeof HANDSHAKE + connectionVersion: typeof CONNECTION_VERSION + 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 + ) +} + +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. */ + source: WindowProxy | null + onBind: (source: WindowProxy) => void + allowedPopupOrigins: readonly string[] + connectionId: string +} + +export interface ListenHandlers { + /** The application's authenticated endpoint for one popup document. */ + onPort: (port: MessagePort, peerOrigin: string) => void + /** 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. 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 } = 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 (!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 + } + 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) { + dropPending() + handlers.onFail() + return + } + pending = null + port.onmessage = null + handlers.onPort(port, event.origin) + } + try { + // 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() + } + } + + view.addEventListener('message', listener) + return () => { + view.removeEventListener('message', listener) + dropPending() + } +} + +export interface RequestOptions { + view: View + opener: WindowProxy + allowedOrigins: OriginAllowlist + connectionId: string + signal: AbortSignal + timeoutMs?: number +} + +/** + * Popup side. Sends the handshake to the exact opener and resolves the + * 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 { + const { view, opener, allowedOrigins, connectionId, signal } = options + return new Promise((resolve, reject) => { + const finish = (error: Error | null, port: PortCarrier | null = null): void => { + view.removeEventListener('message', listener) + clearTimeout(timer) + signal.removeEventListener('abort', onAbort) + if (error) reject(error) + else resolve(port) + } + const listener = (event: MessageEvent): void => { + if (!isAttempt(event.data, connectionId) || event.source !== opener) return + if ( + !isAllowedOrigin(event.origin, allowedOrigins) || + !isExactHandshake(event.data, connectionId) || + event.ports.length !== 1 + ) { + for (const port of event.ports) port.close() + finish(new PopupError('handshake-rejected')) + return + } + const port = event.ports[0] + try { + port.postMessage(handshake(connectionId)) + } catch { + port.close() + finish(new PopupError('handshake-rejected')) + return + } + finish(null, new PortCarrier(port, event.origin)) + } + const onAbort = (): void => finish(new PopupError('connection-closed')) + const timer = setTimeout(() => finish(null), 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(new PopupError('handshake-rejected')) + } + }) +} + +/** Adapts one authenticated MessagePort to the carrier operations. */ +export class PortCarrier implements Carrier { + private port: MessagePort | null + /** Whether handlers were ever installed; assigning them starts the port. */ + private started = false + + constructor( + port: MessagePort, + readonly peerOrigin: string, + ) { + this.port = port + } + + send(value: Message): void { + if (!this.port) throw new PopupError('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 () => {} + this.started = true + 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 { + this.take()?.close() + } + + /** Surrenders the port for preservation; this carrier is closed afterwards. */ + detach(): MessagePort { + const port = this.take() + if (!port) throw new PopupError('send-unavailable') + return port + } + + /** + * Gives up the port. Handlers are cleared only if they were installed: + * assigning `onmessage`, even to null, starts the port and would dispatch + * queued values into the void before a transfer. + */ + private take(): MessagePort | null { + const port = this.port + if (!port) return null + this.port = null + if (this.started) { + 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..1e8a687c --- /dev/null +++ b/ts/packages/popup/src/testing/fakes.ts @@ -0,0 +1,318 @@ +// 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 Carrier, + type CarrierConstructor, + type Message, + type NavigationCarrier, + onReplacement, + prepareNavigation, +} from '../message.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' + +/** + * Lets pending deliveries land. Fake window dispatch is synchronous; real + * MessagePort values arrive in the event loop's poll phase, which a timer + * firing after a stall can precede, so two further loop turns follow it. + */ +export const tick = async (ms = 5): Promise => { + for (const delay of [ms, 0, 0]) await new Promise((resolve) => setTimeout(resolve, delay)) +} + +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: { + origin: string + href: string + pathname: string + search: string + hash: string + 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 + /** 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, applicationOrigin = APP_ORIGIN): FakePair { + const appView = fakeView() + const popupView = fakeView() + const state = { popupOrigin, path: '/p', hash: '', isolated: false } + 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: { + get origin() { + return targetOrigin() + }, + get href() { + return `${targetOrigin()}${state.path}${state.hash}` + }, + get pathname() { + return state.path + }, + search: '', + get hash() { + return state.hash + }, + 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 + const popupOriginNow = () => state.popupOrigin + popupProxy = makeProxy( + popupView, + popupOriginNow, + () => applicationOrigin, + () => appProxy, + ) + appProxy = makeProxy( + appView, + () => applicationOrigin, + popupOriginNow, + () => popupProxy, + ) + + const popupWindow = { + addEventListener: popupView.addEventListener, + removeEventListener: popupView.removeEventListener, + get opener() { + return appProxy + }, + location: popupProxy.location, + close: popupProxy.close, + get crossOriginIsolated() { + return state.isolated + }, + } as unknown as Window + return { + appView, + popupView, + popupProxy, + appProxy, + popupWindow, + relocate(origin, path = '/p', hash = '') { + state.popupOrigin = origin + state.path = path + state.hash = hash + popupView.listeners.clear() + }, + setIsolated(isolated) { + state.isolated = isolated + }, + } +} + +export interface FakeScope { + /** Post as a same-origin document client. */ + worker: KeeperWorker + /** 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. */ +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, + postRaw: (message, ports) => post(`${origin}/p`).postMessage(message, ports), + } +} + +export const registrationWith = + (...workers: (KeeperWorker | null)[]) => + () => + Promise.resolve(workers.map((active) => ({ active }) as unknown as ServiceWorkerRegistration)) + +export const noRegistration = () => Promise.resolve([]) + +/** + * A stand-in for a non-transferable carrier and its signaling service. Each + * round creates one MessageChannel only when the destination connects. The + * application receives a pending promise during preparation, not an already + * authenticated carrier with an unowned port that would queue gap messages. + */ +export interface FakeSignaling { + application: CarrierConstructor + popup: CarrierConstructor + /** Every carrier ever handed out, in order, for inspection. */ + carriers: Carrier[] + /** Reject the next popup-side construction. */ + failNext: boolean +} + +export function fakeSignaling(): FakeSignaling { + const hub: FakeSignaling = { + application: () => new Promise((resolve) => (resolveInitial = resolve)), + popup: async () => { + if (hub.failNext) { + hub.failNext = false + throw new Error('signaling failed') + } + const round = prepared ?? newRound(resolveInitial) + prepared = null + return round.connect() + }, + carriers: [], + failNext: false, + } + let resolveInitial: (carrier: Carrier) => void = () => {} + let prepared: ReturnType | null = null + + const replacementHandlers = new Set<(c: Promise) => void>() + function endpoint( + port: MessagePort, + peerOrigin: string, + replacements: Set<(c: Promise) => void>, + ): NavigationCarrier { + let open = true + const carrier: NavigationCarrier = { + peerOrigin, + send: (value: Message) => { + if (!open) throw new Error('retired') + port.postMessage(value) + }, + on: (handler) => { + port.onmessage = (e) => handler(e.data) + port.start() + return () => { + port.onmessage = null + } + }, + close: () => { + open = false + port.close() + }, + [prepareNavigation]: async (target) => { + // Arm authentication; no replacement carrier exists until the + // destination connects. Sends on the old port meanwhile are lost. + const round = newRound(null, new URL(target).origin) + prepared = round + for (const handler of replacementHandlers) handler(round.applicationSide) + return target + }, + [onReplacement]: (handler) => { + replacements.add(handler) + return () => replacements.delete(handler) + }, + } + hub.carriers.push(carrier) + return carrier + } + + function newRound(resolveApplication: ((c: Carrier) => void) | null, popupOrigin = POPUP_ORIGIN) { + let resolve!: (carrier: Carrier) => void + const applicationSide = new Promise((done) => (resolve = done)) + return { + applicationSide, + connect() { + const channel = new MessageChannel() + const applicationCarrier = endpoint(channel.port1, popupOrigin, replacementHandlers) + const popupSide = endpoint(channel.port2, APP_ORIGIN, new Set()) + resolveApplication?.(applicationCarrier) + resolve(applicationCarrier) + return popupSide + }, + } + } + return hub +} 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. diff --git a/ts/packages/popup/src/window.test.ts b/ts/packages/popup/src/window.test.ts new file mode 100644 index 00000000..2f370834 --- /dev/null +++ b/ts/packages/popup/src/window.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { activeRegistration } from './keeper.js' +import { type CurrentWindow, PopupWindow } from './window.js' + +const ORIGIN = 'https://popup.example' +const DOCUMENT = `${ORIGIN}/prover/x` +const NEXT = `${ORIGIN}/next` + +/** A fake ServiceWorker that activates on demand. */ +function worker(state: 'installing' | 'activated') { + const listeners = new Set<() => void>() + return { + state, + addEventListener: (_: string, l: () => void) => void listeners.add(l), + removeEventListener: (_: string, l: () => void) => void listeners.delete(l), + activate() { + this.state = 'activated' + for (const l of listeners) l() + }, + } +} + +/** A container with one script registered at any number of scopes. */ +function container() { + const registrations = new Map() + return { + add(scope: string, w: ReturnType | null) { + const registration = { + scope: `${ORIGIN}${scope}`, + worker: w, // null until the engine attaches the installing worker + get active() { + return this.worker?.state === 'activated' ? this.worker : null + }, + get installing() { + return this.worker?.state === 'installing' ? this.worker : null + }, + waiting: null, + } + registrations.set(registration.scope, registration) + return registration + }, + // Like the platform: the longest registered scope that prefixes the URL. + async getRegistration(url: string = DOCUMENT) { + let best: { scope: string } | undefined + for (const r of registrations.values()) { + if (url.startsWith(r.scope) && (!best || r.scope.length > best.scope.length)) best = r + } + return best + }, + async getRegistrations() { + return [...registrations.values()] + }, + } +} + +function current(sw: ReturnType, scope?: string): CurrentWindow { + const view = { top: null as unknown, location: new URL(DOCUMENT) } + view.top = view + vi.stubGlobal('window', view) + vi.stubGlobal('navigator', { serviceWorker: sw }) + return PopupWindow.current('', { scope }) as CurrentWindow +} + +describe('registration selection [POPUP-KEEPER-005]', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it('by default claims from every registration and keeps into the destination controller', async () => { + const sw = container() + const root = sw.add('/', worker('activated')) + const nested = sw.add('/prover/', worker('activated')) + const popup = current(sw) + expect(await popup.registrations()).toEqual([root, nested]) + expect(await popup.registrations(NEXT)).toEqual([root]) + expect(await popup.registrations(DOCUMENT)).toEqual([nested]) + }) + + it('with a scope uses exactly that registration for both, even under a nested controller', async () => { + const sw = container() + const root = sw.add('/', worker('activated')) + sw.add('/prover/', worker('activated')) + const popup = current(sw, '/') + expect(await popup.registrations()).toEqual([root]) + expect(await popup.registrations(DOCUMENT)).toEqual([root]) + }) + + it('with a scope substitutes nothing for a missing registration', async () => { + vi.useFakeTimers() + const sw = container() + sw.add('/prover/', worker('activated')) + const popup = current(sw, '/') + expect(await popup.registrations()).toEqual([]) + const ready = activeRegistration(async () => (await popup.registrations(NEXT))[0]) + await vi.advanceTimersByTimeAsync(2_500) + expect(await ready).toBeUndefined() + }) + + it('waits for a root registered and activated after the hop begins', async () => { + vi.useFakeTimers() + const sw = container() + sw.add('/prover/', worker('activated')) + const popup = current(sw, '/') + const ready = activeRegistration(async () => (await popup.registrations(NEXT))[0]) + await vi.advanceTimersByTimeAsync(300) + // Firefox exposes the registration before attaching its worker. + const root = sw.add('/', null) + await vi.advanceTimersByTimeAsync(300) + const installing = worker('installing') + root.worker = installing + await vi.advanceTimersByTimeAsync(300) + let settled = false + void ready.then(() => (settled = true)) + await vi.advanceTimersByTimeAsync(0) + expect(settled).toBe(false) // found, still installing + installing.activate() + expect(await ready).toBe(root) + }) + + it('rejects a cross-origin scope', () => { + expect(() => current(container(), 'https://other.example/')).toThrow(TypeError) + }) +}) + +describe('default popup placement [POPUP-WINDOW-005]', () => { + let open: typeof PopupWindow.open + let nativeOpen: ReturnType + + beforeEach(async () => { + vi.resetModules() + open = (await import('./window.js')).PopupWindow.open + nativeOpen = vi.fn(() => ({ closed: false })) + vi.stubGlobal('window', { + open: nativeOpen, + outerWidth: 1280, + screenX: 80, + screenY: 40, + screen: { availWidth: 1600, availLeft: -1600, availTop: 24 }, + }) + }) + afterEach(() => vi.unstubAllGlobals()) + + it('places mixed widths side by side, then staggers without consulting prior handles', () => { + const first = { + get closed(): boolean { + throw new Error('Severed handle') + }, + } + nativeOpen.mockReturnValueOnce(first) + open('one', 'width=480,height=720') + open('two', 'innerWidth=600,height=720') + open('three', 'width=480,height=720') + open('four', 'width=480,height=720') + expect(nativeOpen.mock.calls.map((call) => call[2])).toEqual([ + 'popup,width=480,height=720,left=-1600,top=24', + 'popup,innerWidth=600,height=720,left=-1088,top=24', + 'popup,width=480,height=720,left=-1568,top=56', + 'popup,width=480,height=720,left=-1056,top=56', + ]) + }) + + it('uses the final width declaration after normalizing its alias', () => { + open('wide', 'width=100,innerWidth=900') + open('next', 'width=480') + expect(nativeOpen).toHaveBeenLastCalledWith( + 'about:blank', + 'next', + 'popup,width=480,left=-668,top=24', + ) + }) + + it('keeps explicit positions, including aliases, and blocked opens out of the sequence', () => { + for (const features of ['left=10', ' TOP = 10', 'ScreenX=-20', 'screenY=40']) { + open('explicit', features) + expect(nativeOpen).toHaveBeenLastCalledWith('about:blank', 'explicit', `popup,${features}`) + } + nativeOpen.mockReturnValueOnce(null) + expect(open('blocked', 'width=480').opened).toBe(false) + expect(open('first', 'width=480').opened).toBe(true) + expect(nativeOpen.mock.calls.slice(-2).map((call) => call[2])).toEqual([ + 'popup,width=480,left=-1600,top=24', + 'popup,width=480,left=-1600,top=24', + ]) + }) + + it('uses opener width when omitted and bounds repeated large-window launches', () => { + open('default') + expect(nativeOpen).toHaveBeenLastCalledWith('about:blank', 'default', 'popup,left=-1600,top=24') + for (let i = 0; i < 20; i++) { + open(`large-${i}`, 'width=9000') + const features = nativeOpen.mock.lastCall![2] as string + expect(features).toMatch(/,left=-1600,top=\d+$/) + expect(Number(/top=(\d+)/.exec(features)![1])).toBeLessThan(280) + } + }) +}) diff --git a/ts/packages/popup/src/window.ts b/ts/packages/popup/src/window.ts new file mode 100644 index 00000000..177ea832 --- /dev/null +++ b/ts/packages/popup/src/window.ts @@ -0,0 +1,193 @@ +// 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. + +export interface CurrentOptions { + /** + * The exact scope of the registration continuity goes through, resolved + * against the current document and same-origin. Without it the departing + * document keeps into the registration that will control its destination + * and a document claims from every registration on the origin. + */ + scope?: string +} + +/** @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 +} + +function usable(handle: WindowProxy | null): handle is WindowProxy { + if (handle === null) return false + try { + return !handle.closed + } catch { + return false + } +} + +// Placement is a sequence of launch hints, not a registry of live windows: +// COOP can make a still-open popup's retained handle report closed. +let nextLeft = 0 +let cascade = 0 + +export class PopupWindow { + protected constructor() {} + + /** + * 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. Without a position, new + * windows are placed side by side, then staggered when the screen is full. + * Placement is best-effort; browsers and window managers may ignore it. + */ + static open(target: string, features = ''): PopupWindow { + if (target === '' || target.startsWith('_')) { + throw new TypeError('popup target must be a nonempty name not beginning with "_"') + } + if (/\b(noopener|noreferrer)\b/i.test(features)) { + throw new TypeError('popup features must not sever the opener') + } + let windowFeatures = features === '' ? 'popup' : `popup,${features}` + const positioned = /(?:^|[\s,])(?:left|top|screenx|screeny)(?:[\s,=]|$)/i.test(features) + let left = nextLeft + let offset = cascade + let width = 0 + if (!positioned) { + const { screen } = window + // availLeft/Top are supported by desktop engines but absent from lib.dom. + const bounds = screen as Screen & { availLeft?: number; availTop?: number } + const requested = [ + ...features.matchAll(/(?:^|[\s,])(?:width|innerwidth)\s*=\s*([+-]?\d+)/gi), + ].at(-1)?.[1] + width = Math.min(screen.availWidth, Math.max(100, Number(requested) || window.outerWidth)) + if (left + width > screen.availWidth) { + offset = (offset + 32) % 256 + left = Math.min(offset, Math.max(0, screen.availWidth - width)) + } + windowFeatures += `,left=${(bounds.availLeft ?? window.screenX) + left},top=${(bounds.availTop ?? window.screenY) + offset}` + } + const handle = window.open('about:blank', target, windowFeatures) + if (handle && !positioned) { + nextLeft = left + width + 32 + cascade = offset + } + return new OpenedWindow(handle, window) + } + + /** + * Adopts the current popup document; creates nothing. `fragment` is the + * document's URL fragment as the host captured it, for a bootstrap that + * clears the URL before importing the package; it defaults to the current + * `location.hash`. The package treats it as opaque and keeps a snapshot. + * `scope` pins continuity to one registration, which need not exist yet. + */ + static current(fragment?: string, options: CurrentOptions = {}): PopupWindow { + if (window.top !== window) throw new TypeError('current requires a top-level popup document') + const container = typeof navigator !== 'undefined' ? navigator.serviceWorker : undefined + const controlling = (url: string): Promise => + container?.getRegistration(url).catch(() => undefined) ?? Promise.resolve(undefined) + let registrations: (url?: string) => Promise + if (options.scope === undefined) { + registrations = async (url) => { + if (url === undefined) return container?.getRegistrations().catch(() => []) ?? [] + const found = await controlling(url) + return found ? [found] : [] + } + } else { + const scope = new URL(options.scope, window.location.href) + if (scope.origin !== window.location.origin) { + throw new TypeError('worker scope must be same-origin') + } + // getRegistration answers with the registration controlling the scope + // URL; a shorter scope that merely contains it does not count. + registrations = async () => { + const found = await controlling(scope.href) + return found?.scope === scope.href ? [found] : [] + } + } + return new CurrentWindow(window, registrations, fragment ?? window.location.hash) + } + + 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 usable(this.handle) + } + + 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, + /** + * The registrations a document claims from, or with `url` the one a + * departing document keeps into for that destination; resolved per use. + */ + readonly registrations: (url?: string) => Promise, + fragment = '', + ) { + super() + this.fragment = fragment.startsWith('#') ? fragment.slice(1) : fragment + } + + /** The captured fragment without its `#`; immutable once adopted. */ + readonly fragment: string + + override get opened(): boolean { + 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 + return usable(opener) ? opener : null + } +} diff --git a/ts/packages/popup/src/worker.ts b/ts/packages/popup/src/worker.ts new file mode 100644 index 00000000..9de786b9 --- /dev/null +++ b/ts/packages/popup/src/worker.ts @@ -0,0 +1,99 @@ +// @libid/popup/worker — the Service Worker half of the continuity bridge. +// 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' + +interface Held { + port: MessagePort + peerOrigin: string + release: () => void +} + +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('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() + } + if (clientOrigin(event.source) !== scope.location.origin) { + 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, + peerOrigin: request.peerOrigin, + 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, peerOrigin: existing.peerOrigin }, [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.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"] +} 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