Iframe messaging and EVA

What EVA is

EVA is a separate Payability front end. Parts of Internal Admin are not rendered by this codebase at all: they are EVA pages loaded in iframes, integrated over a typed postMessage protocol.

This is a deliberate architectural seam — see decision D9. It means:

  • Some UI bugs belong to another repository.

  • State that both applications need is synchronised explicitly, in both directions.

  • The MESSAGE_TYPE enum is a cross-repository contract.

Where it lives

Concern Location

Iframe plumbing

shared/iframe/ — communication/communication.service.ts, communication-message.model.ts, message-type.enum.ts, iframe-communication.const.ts, iframe.component.ts, iframe.module.ts, resize/resize-iframe.service.ts, resize/resize-iframe.model.ts

EVA integration

shared/eva/ — eva.component.ts, eva.service.ts, eva-communication.service.ts, const/eva.ts, const/eva-iframe-selector.enum.ts, models/eva-permissions.model.ts

Parent-side handling

src/app/app.component.ts

Helper

shared/utils/communication-commands.util.ts

Embedded surfaces

EVA_IFRAME_SELECTOR enumerates every host element, and therefore every EVA-rendered region:

Enum member Host selector Surface

TOP_BAR

app-supplier-box-header-eva

The supplier header bar. Flag EVA_SUPPLIER_MENU.

TRACKER

app-tracker

The underwriting tracker. Flag UNDERWRITING_TRACKER. See UW tracker.

ONBOARDING_DETAILS

app-supplier-onboarding-details

Onboarding summary. Flag EVA_ONBOARDING_SUMMARY.

UNDERWRITING_BETA

app-supplier-underwriting-beta

The beta underwriting view.

SUPPLIER_CONTRACTS

app-supplier-contracts

Supplier contracts tab.

CONTRACTS

app-contracts

Manage → contracts.

EVENT_RULES

app-event-rules-2

Event rules v2 editor.

FEATURE_FLAGS

app-admin-feature-flags

DEV Admin flag console (embeds EVA to manage EVA’s own flags).

QA

app-admin-qa

DEV Admin QA surface.

EvaService.getIframeIdBySelector(selector) resolves a selector to the iframe’s DOM id — that is how the parent addresses a specific frame without holding a component reference.

Embedding an EVA page

<app-eva [url]="'underwriting-tracker'" [queryParams]="{ supplierKey: key }"></app-eva>

EvaComponent (shared/eva/eva.component.ts) does four things on init:

  1. Reads the session directly from localStorage['currentUser'] — not from the store.

  2. Resolves the user’s sub-permissions via UsersService.getPermissionSubPerms(permCode) and calls setConfig(subPermissions).

  3. Builds the iframe URL through EvaService.getUrl(url, params).

  4. Registers its iframe id with EvaCommunicationService.setIframeId(…​).

It also clears the EVA auth cookie on window:unload and window:beforeunload, so a refresh re-issues the token.

Authentication hand-off

EvaService has two modes, and they differ enough to matter:

getUrl(subUrl, queryParams = {}): string {
  if (this.getCurrentHostname() === 'localhost') {
    return `${environment.evaApiUrl}${subUrl}?auth=${this.token}&${queryParamsString}`;
  }
  return `${this.evaUrl}${subUrl}?${queryParamsString}`;
}

private passTokenToEva(): void {
  if (this.getCurrentHostname() === 'localhost') return;
  if (this.cookieService.doesCookieExist(EVA_COOKIE_TOKEN_KEY)) {
    this.cookieService.delete(EVA_COOKIE_TOKEN_KEY, { path: '/', domain: environment.domain });
  }
  this.cookieService.setCookie(EVA_COOKIE_TOKEN_KEY, this.token,
    { path: '/', domain: environment.domain, expires: 1 });
}
Environment Mechanism

Deployed

A cookie named PYBEvaToken on environment.domain — the shared parent domain, so both applications can read it. Expires in 1 day.

localhost

The token is appended to the iframe URL as ?auth=<jwt>, because the two dev servers are on different origins and cannot share a cookie.

Local and deployed behaviour differ in where the token lives. A bug that only reproduces locally (or only when deployed) is often this. On localhost the JWT also appears in the iframe URL, and therefore in the browser’s history and in any logging of iframe src.

The postMessage layer

CommunicationService is the transport. Key properties:

Concern Implementation

Addressing

Every message carries a pluginIframeId. Children read theirs from the ?pluginIframeId= query parameter; the parent keeps a childs: IframeChild[] registry ({ iframeId, domain, url, window }).

Envelope

{ pluginIframeId, payload: { type: MESSAGE_TYPE, data? } }. Inbound messages are widened to MessageFromIframe with a source of PARENT or CHILD.

Streams

incomingMessage$ merges messageFromParent$ and messageFromChild$, tagged by source and share()`d. A separate `featureFlagsFromEva$ subject carries EVA’s flag list.

Origin checks

Inbound: checkOrigin (parent messages) and checkChildOrigin (child messages, compared against new URL(child.url).origin). localhost is always allowed when environment.production is false.

Target origin

Outbound uses '*' on localhost, otherwise the parent frontend domain (for sendMessageToParent) or the child’s origin (for sendMessageToChild).

document.domain

setDomain() assigns document.domain = environment.domain before every send, wrapped in an empty catch. A legacy same-origin relaxation.

Debug logging

Every send and receive is console.log`ged as `[IA] Sending/[IA] Received when environment.production is false — the fastest way to debug the protocol.

IframeModule provides the parent-domain token as '*':

{ provide: IFRAME_COMMUNICATION_PARENT_FRONTEND_DOMAIN, useValue: '*' }

So checkOrigin compares an inbound origin against '' as a *literal string, which never matches, and sendMessageToParent targets ''. The child-side origin checks (checkChildOrigin) are real, because they derive the expected origin from the registered child’s URL. Do not read the '' value as a validated wildcard.

document.domain assignment is deprecated in modern browsers and will eventually stop working.

Message catalogue

shared/iframe/communication/message-type.enum.ts. Values are human-readable strings like '[GENERAL] Config Request'.

Configuration

Type Direction Meaning

GENERAL__CONFIG_REQUEST

EVA → IA

EVA asks for its configuration.

GENERAL__CONFIG_RESPONSE

IA → EVA

{ flags: string[], permissions: EvaPermissions } — sent by EvaCommunicationService.sendUpdatedConfig.

GENERAL__CONFIG_UPDATE

EVA → IA

EVA reports its own available/build flags.

FEATURE_FLAGS__RESET

IA → EVA

Clear EVA’s flag overrides.

EvaPermissions is { permission, subPermissions: string[] } — EVA’s authorisation is derived from what this app sends. See authorisation.

Type Direction Meaning

GENERAL__NAVIGATE_BY_LINK

EVA → IA

router.navigate([data.url]) in the parent.

GENERAL__ONLY_CHANGE_ROUTE

EVA → IA

Same handler in AppComponent — currently indistinguishable in behaviour from NAVIGATE_BY_LINK.

GENERAL__CHANGE_PARENT_ROUTE

IA → EVA

Built by changeParentRouteCommand(url); sent to the top bar on every NavigationEnd so EVA knows where the parent is.

MAIN_HEADER__NAVIGATE_BY_DEAL_LINK

EVA → IA

Jump to the external sales CRM using extDealId.

Supplier state

Type Direction Meaning

GET_STORE__SUPPLIER_DETAILS

EVA → IA

EVA asks for supplier details. AppComponent waits until selectSupplierDetails matches the requested supplierKey, then replies once.

UPDATE_EVA_STORE__SUPPLIER_DETAILS

IA → EVA

Push supplier details into EVA’s store. Sent on every change via EvaCommunicationService.updateSupplierDetailsStore.

GENERAL__UPDATE_SUPPLIER_DETAILS_STORE

EVA → IA

EVA patches supplier details in this app’s store, dispatching UpdateSupplierDetailsModelByEva.

MAIN_HEADER__REFETCH_SUPPLIER_DETAILS

IA → EVA

Ask EVA’s header to re-fetch.

SUPPLIER_MENU__GET_MENU_CONDITIONS

both

EVA asks; IA replies with { isAmazonLending, isSellerCard } computed from supplierDetails.isAmazonSeller > 0 and whether any payment method is EFS or pcardAvailable.

Supplier details flow both ways: AppComponent pushes on every store change, and EVA can patch back via GENERAL__UPDATE_SUPPLIER_DETAILS_STORE. A field added to SupplierDetailsModel must be handled on both sides, and a change made in either application propagates to the other. See suppliers.

Actions

Type Direction Meaning

MAIN_HEADER__LOGIN_AS_SUPPLIER

EVA → IA

Trigger the login-as-supplier flow (needs the browser extension).

MAIN_HEADER__LOGIN_TO_AMAZON

EVA → IA

Trigger the Amazon login flow.

ONBOARDING__INIT_LOAD_EARNINGS_PROCESS

EVA → IA

Start an earnings load. See lifecycle actions.

CONTRACTS__REFRESH

EVA → IA

Ask the parent to refresh the contracts page.

GENERAL__SHOW_TOAST

EVA → IA

{ type, message } — 'success' shows a success toast, anything else an error toast.

Sizing

Type Effect

RESIZE_IFRAME_SIZE

Set both height and width.

RESIZE_IFRAME_WIDTH_SIZE / RESIZE_IFRAME_HEIGHT_SIZE

Set one dimension.

RESET_IFRAME_SIZE

Back to 100% for both.

FULL_SIZE_TOP_BAR / REVERT_TOP_BAR_SIZE

Expand and restore the supplier top bar.

ResizeIframeService (providedIn: 'root', extends SubscriberComponent) filters incomingMessage$ for the four resize types and applies them:

private getElement(item: ResizeItem): ResizeHTMLElement {
  const result = item.differentFrameSelector
    ? document.querySelector(`${item.differentFrameSelector} iframe`)
    : document.getElementById(item.id);
  if (!result) throw new Error('Element not found!');
  return result as ResizeHTMLElement;
}

differentFrameSelector lets one iframe resize another — used when a modal inside one EVA frame needs the containing frame to grow.

getElement throws 'Element not found!' when the target is missing. The throw happens inside the incomingMessage$ subscription, which will terminate the subscription and stop all subsequent resize handling for the page. A resize message arriving after its frame is destroyed therefore breaks resizing until reload.

The repository README documents the symmetry: a same-named ResizeIframeService exists in both applications, and in the default direction EVA sends dimensions to Internal Admin, which resizes the frame.

Parent-side handling

AppComponent.doActionIfMessage(message) is the single inbound switch:

GENERAL__SHOW_TOAST                        → toastr success | error
GENERAL__UPDATE_SUPPLIER_DETAILS_STORE     → dispatch UpdateSupplierDetailsModelByEva
GET_STORE__SUPPLIER_DETAILS                → reply with UPDATE_EVA_STORE__SUPPLIER_DETAILS
GENERAL__NAVIGATE_BY_LINK                  → router.navigate
GENERAL__ONLY_CHANGE_ROUTE                 → router.navigate
SUPPLIER_MENU__GET_MENU_CONDITIONS         → reply with { isAmazonLending, isSellerCard }

Resize messages are handled separately by ResizeIframeService; flag messages by FeatureFlagsEvaService.

doActionIfEvaNeedsSupplierMenuCondition pushes a new subscription into this.subscriptions every time the message arrives, and that subscription is only cleaned up when AppComponent is destroyed — i.e. never, in practice. Repeated menu-condition requests accumulate subscriptions.

Outbound helper

EvaCommunicationService (provided per EvaComponent, not globally) implements the outbound command set:

interface EvaCommunicationCommands {
  refetchSupplierDetails(): void;
  sendUpdatedConfig(flags: string[], permissions?: EvaPermissions): void;
  resetFeatureFlags(): void;
  updateSupplierDetailsStore(details: SupplierDetailsModel): void;
}

updateSupplierDetailsStore resolves the top-bar frame by selector rather than using the component’s own iframe id, and returns silently if that frame is absent.

EvaCommunicationService lives in shared/ but imports SupplierDetailsModel from suppliers/model/ — one of the two systematic shared → feature dependencies in the codebase. See architectural overview.

Debugging the protocol

  1. Open the console on a non-production build. Every message is logged as [IA] Sending <type> <data> <target> or [IA] Received <type> <data>.

  2. If nothing is logged on receive, the message was dropped by checkIframeIdExists, checkOrigin/checkChildOrigin, or the pluginIframeId comparison.

  3. If the frame is blank, check the token: cookie PYBEvaToken on environment.domain when deployed, ?auth= in the iframe URL on localhost.

  4. If sizing is stuck, check whether a 'Element not found!' error has already killed the ResizeIframeService subscription.

  5. If flags look wrong, check sessionStorage['FeatureExecFlagsEva'] and the FEATURE_FLAGS_EVA global setting. See feature flags.

Changing the contract

MESSAGE_TYPE is shared with the EVA repository. Adding a type is safe (the switch ignores unknown types); changing or removing one is a coordinated release across both applications. Add the new type, ship both sides, then remove the old one.

Gotchas summary

Issue Detail

Parent domain token is '*'

checkOrigin cannot match it.

Local vs deployed auth differs

Query parameter vs cookie.

Two navigation message types behave identically

NAVIGATE_BY_LINK and ONLY_CHANGE_ROUTE.

A missing resize target kills resizing

The thrown error terminates the subscription.

Menu-condition handler leaks subscriptions

One per received message.

document.domain is deprecated

Assigned before every send.

Supplier details are bidirectional

Changes must be handled on both sides.