@lympid/embed 0.0.0-stage → 1.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Public type contracts for `@lympid/embed`.
3
+ *
4
+ * Six flows as of this writing: `connect`/`sign-in` and `kyc` (Phase 1),
5
+ * `register` (in-widget account creation, a per-partner opt-in), and
6
+ * `invest`/`deposit`/`withdraw` (the embed financial flows plan, also
7
+ * per-partner opt-in gates via `PartnerEmbedFlow`). Two login models
8
+ * selected per partner on the Lympid side (`connected` and
9
+ * `lympid-code` — see `apps/partner-api/src/modules/embed-sessions/`),
10
+ * and the `getSessionCode` callback that lets a `connected` partner's
11
+ * session never visibly expire.
12
+ *
13
+ * Known gap: PRIIPs KID delivery for `invest` does not exist yet (see
14
+ * `docs/COMPLIANCE-BACKLOG.md`) — this is a deliberately tracked
15
+ * compliance gap, not an engineering oversight, and does not block this
16
+ * flag's existence or rollout.
17
+ */
18
+ /**
19
+ * Widget flows.
20
+ *
21
+ * - `register` opens the in-widget account-creation flow — a one-time
22
+ * confirm screen for the `connected` model (when `getSessionCode`'s
23
+ * underlying handoff resolves to a not-yet-existing user) or a full
24
+ * self-serve form for the `lympid-code` model.
25
+ * - `invest` opens the in-widget investment flow: the appropriateness
26
+ * assessment (if none valid exists), the WARN/BLOCK consent gate (if
27
+ * applicable), a step-up OTP confirmation, then the SEPA investment
28
+ * itself.
29
+ * - `deposit` opens a simple SEPA deposit flow: amount entry, then
30
+ * bank transfer instructions to display.
31
+ * - `withdraw` opens the SEPA withdrawal flow: amount, destination
32
+ * IBAN, a step-up OTP confirmation, then submission.
33
+ */
34
+ export type EmbedFlow = 'connect' | 'sign-in' | 'kyc' | 'register' | 'invest' | 'deposit' | 'withdraw';
35
+ /**
36
+ * A structured lifecycle event the widget (the iframe content, i.e. the
37
+ * whitelabel app running in embed mode) reports back to the host page.
38
+ *
39
+ * `type` documents the known events for editor autocomplete, but
40
+ * intentionally also accepts any other string so the widget can
41
+ * introduce new event types without a synchronized SDK release — the
42
+ * host page should treat an unrecognised `type` as a no-op, not an
43
+ * error.
44
+ */
45
+ export interface LympidEmbedEvent {
46
+ type: 'ready' | 'connected' | 'signed-in' | 'kyc-submitted' | 'kyc-approved' | 'kyc-rejected' | 'registered' | 'session-code-error' | 'session-expired' | 'invested' | 'invest-error' | 'deposit-pending' | 'deposit-settled' | 'deposit-error' | 'withdrawal-submitted' | 'withdrawal-settled' | 'withdrawal-error' | 'closed' | (string & Record<never, never>);
47
+ /** Event-specific extra fields. Deliberately untyped per-field —
48
+ * `type` is the only field callers should branch on. */
49
+ [key: string]: unknown;
50
+ }
51
+ /**
52
+ * Options passed to `LympidEmbedInstance.open()`.
53
+ */
54
+ export interface OpenOptions {
55
+ /** Which flow to open inside the widget modal. */
56
+ flow: EmbedFlow;
57
+ /**
58
+ * Required when this partner's `embedLoginModel` is `connected`
59
+ * (Phase 1 plan §2). Called by the SDK whenever the widget needs a
60
+ * fresh session — on first open, and again silently in the
61
+ * background before the current in-memory access token expires, so
62
+ * the end user's session never visibly expires.
63
+ *
64
+ * Must resolve to a single-use handoff code minted by **the
65
+ * partner's own server** calling
66
+ * `POST /api/v1/embed-sessions` (behind `x-api-key` plus the
67
+ * partner's IP allowlist) — never call that endpoint from the
68
+ * browser. See
69
+ * `apps/partner-api/src/modules/embed-sessions/embed-sessions.service.ts`.
70
+ *
71
+ * Not required, and never called, when this partner uses the
72
+ * `lympid-code` model — the widget itself collects the end user's
73
+ * email/phone and one-time code in that case.
74
+ *
75
+ * May throw/reject; the widget surfaces the resulting
76
+ * `session-code-error` to the end user and keeps the modal open so
77
+ * they can retry.
78
+ */
79
+ getSessionCode?: () => string | Promise<string>;
80
+ /**
81
+ * Required when `flow` is `'invest'` — which RWA the investor is
82
+ * funding. Ignored for every other flow. The widget resolves this
83
+ * RWA's product category, minimum investment, remaining supply, and
84
+ * `professionalInvestorsOnly` flag itself; this option only
85
+ * identifies which one to open.
86
+ */
87
+ rwaId?: string;
88
+ /** Called for every widget lifecycle event (see `LympidEmbedEvent`). */
89
+ onEvent?: (event: LympidEmbedEvent) => void;
90
+ /** Called exactly once when the widget modal closes, for any reason
91
+ * (end user clicked close, the widget itself requested a close after
92
+ * completing a flow, or `LympidEmbedInstance.close()`/`destroy()` was
93
+ * called programmatically). */
94
+ onClose?: () => void;
95
+ }
96
+ /**
97
+ * Options passed to `init()`.
98
+ */
99
+ export interface InitOptions {
100
+ /**
101
+ * The exact origin (scheme + host + optional port, **no path, no
102
+ * trailing slash**) of the partner's whitelabel deployment running in
103
+ * embed mode, e.g. `"https://invest.yourpartnerdomain.com"`.
104
+ *
105
+ * This MUST match what the partner-api `embedAllowedOrigins` config
106
+ * for this partner expects as the `postMessage` counterpart (see
107
+ * `docs/WHITELABEL-TENANTS.md`, "Lympid Embed" section) — every
108
+ * message exchanged with the iframe is validated against this exact
109
+ * origin in both directions.
110
+ */
111
+ baseUrl: string;
112
+ /**
113
+ * Your Lympid partner id. Required when `open()` is ever called with
114
+ * `flow: 'register'`; harmless to always pass.
115
+ *
116
+ * Before this field existed, the widget had no way to identify which
117
+ * partner it was serving until *after* a session already existed —
118
+ * `baseUrl` names a whitelabel deployment, which can serve any
119
+ * configured partner, and the iframe URL otherwise carried only
120
+ * `flow`. Registration happens before any session exists, so it needs
121
+ * this hint. It is exactly that — a hint, not a trust boundary: the
122
+ * API re-validates it against the widget's actual request origin and
123
+ * that partner's `embedAllowedOrigins` on every registration call.
124
+ */
125
+ partnerId?: string;
126
+ /**
127
+ * DOM node the modal overlay is appended to. Defaults to
128
+ * `document.body`. Mainly useful for scoping the overlay inside a
129
+ * specific container in tests or unusual page layouts.
130
+ */
131
+ container?: HTMLElement;
132
+ }
133
+ /**
134
+ * The handle returned by `init()`. One instance per `baseUrl`/partner
135
+ * configuration; call `open()` as many times as needed (each call
136
+ * replaces any currently-open modal).
137
+ */
138
+ export interface LympidEmbedInstance {
139
+ /** Opens the widget modal for the given flow. Closes any
140
+ * already-open modal from this instance first. */
141
+ open(options: OpenOptions): void;
142
+ /** Closes the widget modal, if open, and fires the current
143
+ * `onClose` callback. No-op if nothing is open. */
144
+ close(): void;
145
+ /** Closes the modal (if open) and removes every DOM node and
146
+ * listener this instance created. Call when the host page is done
147
+ * with the widget entirely (e.g. unmounting a SPA route). The
148
+ * instance must not be reused after this. */
149
+ destroy(): void;
150
+ }
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ /**
3
+ * Public type contracts for `@lympid/embed`.
4
+ *
5
+ * Six flows as of this writing: `connect`/`sign-in` and `kyc` (Phase 1),
6
+ * `register` (in-widget account creation, a per-partner opt-in), and
7
+ * `invest`/`deposit`/`withdraw` (the embed financial flows plan, also
8
+ * per-partner opt-in gates via `PartnerEmbedFlow`). Two login models
9
+ * selected per partner on the Lympid side (`connected` and
10
+ * `lympid-code` — see `apps/partner-api/src/modules/embed-sessions/`),
11
+ * and the `getSessionCode` callback that lets a `connected` partner's
12
+ * session never visibly expire.
13
+ *
14
+ * Known gap: PRIIPs KID delivery for `invest` does not exist yet (see
15
+ * `docs/COMPLIANCE-BACKLOG.md`) — this is a deliberately tracked
16
+ * compliance gap, not an engineering oversight, and does not block this
17
+ * flag's existence or rollout.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,42 @@
1
+ import { HOST_TO_WIDGET_SOURCE, isWidgetToHostMessage, } from './protocol.js';
2
+ /**
3
+ * @param iframe The widget iframe this bridge talks to.
4
+ * @param targetOrigin The exact origin the iframe was loaded from
5
+ * (derived from `InitOptions.baseUrl`, never inferred from the
6
+ * iframe's `src` string, which a compromised/misconfigured iframe
7
+ * could have redirected away from).
8
+ * @param onMessage Called for every well-formed `WidgetToHostMessage`
9
+ * received from exactly `targetOrigin`. Messages from any other
10
+ * origin, or that don't match the `WidgetToHostMessage` shape, are
11
+ * silently dropped.
12
+ */
13
+ export function createBridge(iframe, targetOrigin, onMessage) {
14
+ const listener = (domEvent) => {
15
+ // Origin check FIRST, before touching `data` at all — this is the
16
+ // entire security boundary of the bridge. A mismatched origin is
17
+ // dropped unconditionally, regardless of how well-formed the
18
+ // payload looks.
19
+ if (domEvent.origin !== targetOrigin) {
20
+ return;
21
+ }
22
+ if (domEvent.source !== iframe.contentWindow) {
23
+ return;
24
+ }
25
+ if (!isWidgetToHostMessage(domEvent.data)) {
26
+ return;
27
+ }
28
+ onMessage(domEvent.data);
29
+ };
30
+ window.addEventListener('message', listener);
31
+ return {
32
+ send: (message) => {
33
+ if (!iframe.contentWindow) {
34
+ return;
35
+ }
36
+ iframe.contentWindow.postMessage({ ...message, source: HOST_TO_WIDGET_SOURCE }, targetOrigin);
37
+ },
38
+ teardown: () => {
39
+ window.removeEventListener('message', listener);
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,151 @@
1
+ import { createBridge } from './bridge.js';
2
+ import { createModal } from './modal.js';
3
+ import { HOST_TO_WIDGET_SOURCE } from './protocol.js';
4
+ /** Normalises a configured `baseUrl` into a bare origin string
5
+ * (`"https://host:port"`, no path/trailing slash) suitable both for
6
+ * building the iframe `src` and for `postMessage` origin checks. Throws
7
+ * early — at `init()` time, not at `open()` time — on a malformed URL,
8
+ * since a bad `baseUrl` is a partner-integration mistake that should
9
+ * fail loudly immediately rather than silently drop every widget
10
+ * message later. */
11
+ function normalizeOrigin(baseUrl) {
12
+ const url = new URL(baseUrl);
13
+ return url.origin;
14
+ }
15
+ function buildIframeSrc(origin, flow, partnerId, rwaId) {
16
+ const params = new URLSearchParams({ flow });
17
+ if (partnerId) {
18
+ params.set('partnerId', partnerId);
19
+ }
20
+ if (rwaId) {
21
+ params.set('rwaId', rwaId);
22
+ }
23
+ return `${origin}/embed?${params.toString()}`;
24
+ }
25
+ /**
26
+ * Creates a `LympidEmbedInstance` bound to one partner's whitelabel
27
+ * origin. See `InitOptions`/`OpenOptions` in `./types.ts` for the full
28
+ * contract, and `apps/whitelabel/src/app/embed/` (whitelabel-embed-mode)
29
+ * for the counterpart that runs inside the iframe.
30
+ */
31
+ export function init(options) {
32
+ const origin = normalizeOrigin(options.baseUrl);
33
+ const container = options.container ?? document.body;
34
+ let current = null;
35
+ let destroyed = false;
36
+ /** Tears down the currently-open modal/bridge, if any, optionally
37
+ * firing the `onClose` callback that was passed to the `open()` call
38
+ * being torn down. */
39
+ function teardownCurrent(fireOnClose) {
40
+ if (!current) {
41
+ return;
42
+ }
43
+ const { modal, bridge, onClose } = current;
44
+ bridge.teardown();
45
+ modal.remove();
46
+ current = null;
47
+ if (fireOnClose && onClose) {
48
+ onClose();
49
+ }
50
+ }
51
+ function open(openOptions) {
52
+ if (destroyed) {
53
+ throw new Error('This LympidEmbedInstance has been destroyed and cannot be reused — call init() again.');
54
+ }
55
+ // Replace any already-open modal from this instance first — only
56
+ // one widget flow can sensibly be open at a time per instance.
57
+ teardownCurrent(true);
58
+ const src = buildIframeSrc(origin, openOptions.flow, options.partnerId, openOptions.rwaId);
59
+ const modal = createModal(container, src, () => teardownCurrent(true));
60
+ const bridge = createBridge(modal.iframe, origin, (message) => {
61
+ switch (message.type) {
62
+ case 'ready': {
63
+ bridge.send({
64
+ source: HOST_TO_WIDGET_SOURCE,
65
+ type: 'init',
66
+ flow: openOptions.flow,
67
+ hasSessionCodeProvider: !!openOptions.getSessionCode,
68
+ partnerId: options.partnerId,
69
+ rwaId: openOptions.rwaId,
70
+ });
71
+ break;
72
+ }
73
+ case 'request-session-code': {
74
+ void handleSessionCodeRequest(bridge, openOptions, message.requestId);
75
+ break;
76
+ }
77
+ case 'resize': {
78
+ modal.setHeight(message.height);
79
+ break;
80
+ }
81
+ case 'event': {
82
+ openOptions.onEvent?.(message.event);
83
+ break;
84
+ }
85
+ case 'close': {
86
+ teardownCurrent(true);
87
+ break;
88
+ }
89
+ default:
90
+ break;
91
+ }
92
+ });
93
+ current = { modal, bridge, onClose: openOptions.onClose };
94
+ }
95
+ function close() {
96
+ teardownCurrent(true);
97
+ }
98
+ function destroy() {
99
+ teardownCurrent(true);
100
+ destroyed = true;
101
+ }
102
+ return { open, close, destroy };
103
+ }
104
+ /**
105
+ * Resolves `openOptions.getSessionCode()` (if configured) and relays
106
+ * the result back to the widget as `session-code`/`session-code-error`.
107
+ * Extracted so `init()`'s `open()` switch stays a flat dispatch table.
108
+ *
109
+ * @param requestId Correlation id the widget attached to its
110
+ * `request-session-code` message — echoed back unchanged so the
111
+ * widget can match the response to the right in-flight request even
112
+ * if several overlap.
113
+ */
114
+ async function handleSessionCodeRequest(bridge, openOptions, requestId) {
115
+ const provider = openOptions.getSessionCode;
116
+ if (!provider) {
117
+ // The widget asked for a session code on a partner that never
118
+ // configured `getSessionCode` — this is an integration mistake
119
+ // (likely a `connected`-model partner whose host page forgot the
120
+ // callback). Surface it both as an event (for the host page's own
121
+ // logging/monitoring) and back to the widget (so it can show the
122
+ // end user a retry/contact-support state instead of hanging).
123
+ const error = 'getSessionCode was not provided to open()';
124
+ openOptions.onEvent?.({ type: 'session-code-error', requestId, error });
125
+ bridge.send({
126
+ source: HOST_TO_WIDGET_SOURCE,
127
+ type: 'session-code-error',
128
+ requestId,
129
+ error,
130
+ });
131
+ return;
132
+ }
133
+ try {
134
+ const code = await provider();
135
+ bridge.send({
136
+ source: HOST_TO_WIDGET_SOURCE,
137
+ type: 'session-code',
138
+ requestId,
139
+ code,
140
+ });
141
+ }
142
+ catch (error) {
143
+ const errorMessage = error instanceof Error ? error.message : String(error);
144
+ bridge.send({
145
+ source: HOST_TO_WIDGET_SOURCE,
146
+ type: 'session-code-error',
147
+ requestId,
148
+ error: errorMessage,
149
+ });
150
+ }
151
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `@lympid/embed` — public entry point.
3
+ *
4
+ * ```ts
5
+ * import { init } from '@lympid/embed';
6
+ *
7
+ * const widget = init({ baseUrl: 'https://invest.yourpartnerdomain.com' });
8
+ *
9
+ * widget.open({
10
+ * flow: 'connect',
11
+ * // Only needed for partners using the "connected" login model —
12
+ * // call YOUR OWN backend here, which in turn calls
13
+ * // POST /api/v1/embed-sessions with your x-api-key.
14
+ * getSessionCode: async () => {
15
+ * const res = await fetch('/api/lympid/session-code', { method: 'POST' });
16
+ * const { handoffCode } = await res.json();
17
+ * return handoffCode;
18
+ * },
19
+ * onEvent: (event) => console.log('lympid event', event),
20
+ * onClose: () => console.log('lympid widget closed'),
21
+ * });
22
+ * ```
23
+ */
24
+ export { init } from './embed.js';
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Modal overlay + iframe DOM construction for the widget.
3
+ *
4
+ * Deliberately framework-agnostic (no React) and dependency-free — a
5
+ * handful of inline-styled `div`/`iframe` nodes is simpler, smaller, and
6
+ * safer than reaching for a UI library inside a script a partner embeds
7
+ * on their own page (no risk of CSS/class-name collisions with the
8
+ * partner's own stylesheet).
9
+ */
10
+ const Z_INDEX = 2147483000; // Comfortably above virtually any host-page UI.
11
+ const DEFAULT_HEIGHT_PX = 600;
12
+ /** Light/dark panel background, matching the whitelabel app's own
13
+ * `LEDGER_PAPER` surface token (`packages/shared-types/src/
14
+ * design-tokens.ts`) so the modal's own chrome doesn't flash a white
15
+ * box around a dark-themed widget. The host page has no visibility
16
+ * into the iframe's actual in-app theme setting (cross-origin, and the
17
+ * whitelabel app can toggle independently of OS preference), so this
18
+ * follows the OS-level `prefers-color-scheme` media query as the best
19
+ * available signal without a cross-origin theme-sync protocol. */
20
+ const PANEL_BACKGROUND_LIGHT = '#ffffff';
21
+ const PANEL_BACKGROUND_DARK = '#161B22';
22
+ function prefersDarkColorScheme() {
23
+ return (typeof window !== 'undefined' &&
24
+ typeof window.matchMedia === 'function' &&
25
+ window.matchMedia('(prefers-color-scheme: dark)').matches);
26
+ }
27
+ /** Close glyph centered in the 32px button. Path matches
28
+ * `apps/whitelabel/src/assets/images/svg/24/close.svg`. */
29
+ function createCloseIcon() {
30
+ const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
31
+ icon.setAttribute('viewBox', '0 0 24 24');
32
+ icon.setAttribute('width', '20');
33
+ icon.setAttribute('height', '20');
34
+ icon.setAttribute('fill', 'none');
35
+ icon.setAttribute('aria-hidden', 'true');
36
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
37
+ path.setAttribute('d', 'M17 7 7 17M7 7l10 10');
38
+ path.setAttribute('stroke', 'currentColor');
39
+ path.setAttribute('stroke-width', '1.3');
40
+ path.setAttribute('stroke-linecap', 'round');
41
+ path.setAttribute('stroke-linejoin', 'round');
42
+ icon.appendChild(path);
43
+ return icon;
44
+ }
45
+ /**
46
+ * Builds and appends the backdrop + iframe modal to `container`.
47
+ *
48
+ * @param container Parent node to append the overlay to.
49
+ * @param src The widget iframe's `src` URL (the partner's whitelabel
50
+ * origin + `/embed` route + flow query param).
51
+ * @param onBackdropClick Called when the end user clicks the backdrop
52
+ * or the close button — the caller decides whether that closes the
53
+ * modal (it always should; kept as a callback so `embed.ts` stays
54
+ * the single owner of close/`onClose` semantics).
55
+ */
56
+ export function createModal(container, src, onBackdropClick) {
57
+ const overlay = document.createElement('div');
58
+ overlay.setAttribute('data-lympid-embed', 'overlay');
59
+ overlay.style.position = 'fixed';
60
+ overlay.style.inset = '0';
61
+ overlay.style.zIndex = String(Z_INDEX);
62
+ overlay.style.display = 'flex';
63
+ overlay.style.alignItems = 'center';
64
+ overlay.style.justifyContent = 'center';
65
+ overlay.style.backgroundColor = 'rgba(15, 23, 42, 0.6)';
66
+ const panel = document.createElement('div');
67
+ panel.setAttribute('data-lympid-embed', 'panel');
68
+ panel.style.position = 'relative';
69
+ panel.style.width = 'min(420px, calc(100vw - 32px))';
70
+ panel.style.height = `min(${DEFAULT_HEIGHT_PX}px, calc(100vh - 32px))`;
71
+ panel.style.maxHeight = 'calc(100vh - 32px)';
72
+ panel.style.borderRadius = '16px';
73
+ panel.style.overflow = 'hidden';
74
+ panel.style.boxShadow = '0 20px 60px rgba(0, 0, 0, 0.35)';
75
+ panel.style.backgroundColor = prefersDarkColorScheme()
76
+ ? PANEL_BACKGROUND_DARK
77
+ : PANEL_BACKGROUND_LIGHT;
78
+ panel.style.transition = 'height 0.2s ease';
79
+ // Keep the panel's background in step if the OS-level preference
80
+ // changes while the modal is open (e.g. a scheduled light/dark
81
+ // switch, or the user flips it in their OS settings mid-session).
82
+ let colorSchemeMedia = null;
83
+ let handleColorSchemeChange = null;
84
+ if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
85
+ colorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)');
86
+ handleColorSchemeChange = (event) => {
87
+ panel.style.backgroundColor = event.matches
88
+ ? PANEL_BACKGROUND_DARK
89
+ : PANEL_BACKGROUND_LIGHT;
90
+ };
91
+ colorSchemeMedia.addEventListener('change', handleColorSchemeChange);
92
+ }
93
+ const closeButton = document.createElement('button');
94
+ closeButton.setAttribute('data-lympid-embed', 'close');
95
+ closeButton.setAttribute('aria-label', 'Close');
96
+ closeButton.type = 'button';
97
+ closeButton.style.position = 'absolute';
98
+ closeButton.style.top = '8px';
99
+ closeButton.style.right = '8px';
100
+ closeButton.style.zIndex = String(Z_INDEX + 1);
101
+ closeButton.style.display = 'flex';
102
+ closeButton.style.alignItems = 'center';
103
+ closeButton.style.justifyContent = 'center';
104
+ closeButton.style.width = '32px';
105
+ closeButton.style.height = '32px';
106
+ closeButton.style.margin = '0';
107
+ closeButton.style.padding = '0';
108
+ closeButton.style.border = 'none';
109
+ closeButton.style.borderRadius = '50%';
110
+ closeButton.style.backgroundColor = 'rgba(15, 23, 42, 0.06)';
111
+ closeButton.style.color = '#585858';
112
+ closeButton.style.cursor = 'pointer';
113
+ closeButton.style.appearance = 'none';
114
+ closeButton.style.setProperty('-webkit-appearance', 'none');
115
+ // Same stroke as the whitelabel `Icon` named `close`
116
+ // (`src/assets/images/svg/24/close.svg`), which every other modal
117
+ // uses. A text "×" sits on the font baseline, so it looks high, and
118
+ // a host `button { padding }` rule shifts it further. Flex + a reset
119
+ // padding keeps the glyph centered on the partner page.
120
+ closeButton.appendChild(createCloseIcon());
121
+ const iframe = document.createElement('iframe');
122
+ iframe.setAttribute('data-lympid-embed', 'iframe');
123
+ iframe.src = src;
124
+ iframe.style.width = '100%';
125
+ iframe.style.height = '100%';
126
+ iframe.style.border = 'none';
127
+ iframe.title = 'Lympid';
128
+ // Phase 1 flows (connect/sign-in, KYC) need neither top-level
129
+ // navigation nor same-origin escalation beyond what the whitelabel
130
+ // app itself needs to run (forms, scripts, camera for KYC).
131
+ iframe.setAttribute('allow', 'camera; microphone; clipboard-write');
132
+ iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups');
133
+ closeButton.addEventListener('click', onBackdropClick);
134
+ overlay.addEventListener('click', (domEvent) => {
135
+ if (domEvent.target === overlay) {
136
+ onBackdropClick();
137
+ }
138
+ });
139
+ panel.appendChild(closeButton);
140
+ panel.appendChild(iframe);
141
+ overlay.appendChild(panel);
142
+ container.appendChild(overlay);
143
+ return {
144
+ iframe,
145
+ setHeight: (height) => {
146
+ const clamped = Math.max(240, Math.min(height, (typeof window !== 'undefined' ? window.innerHeight : height) - 32));
147
+ panel.style.height = `${clamped}px`;
148
+ },
149
+ remove: () => {
150
+ if (colorSchemeMedia && handleColorSchemeChange) {
151
+ colorSchemeMedia.removeEventListener('change', handleColorSchemeChange);
152
+ }
153
+ overlay.remove();
154
+ },
155
+ };
156
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `postMessage` wire protocol between the host page (this SDK) and the
3
+ * widget iframe (the whitelabel app running in embed mode — see
4
+ * `whitelabel-embed-mode` in the Phase 1 plan).
5
+ *
6
+ * Both directions are only ever accepted/sent against the exact origin
7
+ * configured via `InitOptions.baseUrl` — see `createBridge()` in
8
+ * `./bridge.ts`. This file only defines the message *shapes*; it has
9
+ * zero DOM/`window` dependencies so it can be unit-tested directly.
10
+ */
11
+ /** Discriminator so a `postMessage` listener can cheaply ignore every
12
+ * unrelated message landing on the same `window` (browser extensions,
13
+ * other SDKs, etc.) before even checking the origin. */
14
+ export const HOST_TO_WIDGET_SOURCE = 'lympid-embed-host';
15
+ export const WIDGET_TO_HOST_SOURCE = 'lympid-embed-widget';
16
+ /** Narrows an arbitrary `MessageEvent.data` payload to a
17
+ * `WidgetToHostMessage`, without throwing on malformed input — a
18
+ * `postMessage` listener must never crash the host page just because
19
+ * some unrelated script on the same window posted something shaped
20
+ * differently. */
21
+ export function isWidgetToHostMessage(data) {
22
+ return (typeof data === 'object' &&
23
+ data !== null &&
24
+ data.source === WIDGET_TO_HOST_SOURCE &&
25
+ typeof data.type === 'string');
26
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Public type contracts for `@lympid/embed`.
3
+ *
4
+ * Six flows as of this writing: `connect`/`sign-in` and `kyc` (Phase 1),
5
+ * `register` (in-widget account creation, a per-partner opt-in), and
6
+ * `invest`/`deposit`/`withdraw` (the embed financial flows plan, also
7
+ * per-partner opt-in gates via `PartnerEmbedFlow`). Two login models
8
+ * selected per partner on the Lympid side (`connected` and
9
+ * `lympid-code` — see `apps/partner-api/src/modules/embed-sessions/`),
10
+ * and the `getSessionCode` callback that lets a `connected` partner's
11
+ * session never visibly expire.
12
+ *
13
+ * Known gap: PRIIPs KID delivery for `invest` does not exist yet (see
14
+ * `docs/COMPLIANCE-BACKLOG.md`) — this is a deliberately tracked
15
+ * compliance gap, not an engineering oversight, and does not block this
16
+ * flag's existence or rollout.
17
+ */
18
+ export {};
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "@lympid/embed",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0-rc.2",
4
+ "description": "Lympid Embed widget SDK — init/open/close/destroy for the Lympid-hosted modal iframe (connect/sign-in, KYC, and in-widget registration flows). apps/partner-api's embed login models (connected, lympid-code) plus apps/whitelabel's embed mode are the server/widget-content counterparts to this client-side script.",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/0xLympid/lympid-platform.git",
9
+ "directory": "packages/embed"
10
+ },
11
+ "main": "./dist/cjs/index.js",
12
+ "module": "./dist/esm/index.js",
13
+ "types": "./dist/cjs/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/cjs/index.d.ts",
17
+ "import": "./dist/esm/index.js",
18
+ "require": "./dist/cjs/index.js"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
22
+ "sideEffects": false,
23
+ "files": [
24
+ "dist",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "provenance": false
31
+ },
32
+ "scripts": {
33
+ "build": "yarn build:cjs && yarn build:esm && yarn build:browser",
34
+ "build:cjs": "tsc -p tsconfig.json",
35
+ "build:esm": "tsc -p tsconfig.esm.json && node scripts/fix-esm-extensions.mjs && node scripts/write-esm-package-json.mjs",
36
+ "build:browser": "node scripts/build-browser.mjs",
37
+ "verify:bundle": "node scripts/assert-no-leaks.mjs",
38
+ "prepublishOnly": "yarn clean && yarn build && yarn verify:bundle",
39
+ "type:check": "tsc --noEmit",
40
+ "lint": "eslint \"src/**/*.ts\"",
41
+ "test": "jest",
42
+ "test:watch": "jest --watch",
43
+ "test:cov": "jest --coverage",
44
+ "clean": "rm -rf dist coverage"
45
+ },
46
+ "devDependencies": {
47
+ "@eslint/js": "^10.0.1",
48
+ "@semantic-release/changelog": "^6.0.3",
49
+ "@semantic-release/git": "^10.0.1",
50
+ "@types/jest": "^30.0.0",
51
+ "esbuild": "^0.28.2",
52
+ "eslint": "^9.8.0",
53
+ "globals": "^16.0.0",
54
+ "jest": "^30.3.0",
55
+ "jest-environment-jsdom": "^30.3.0",
56
+ "semantic-release-monorepo": "^8.0.2",
57
+ "ts-jest": "^29.2.5",
58
+ "typescript": "^5.9.3",
59
+ "typescript-eslint": "^8.57.2"
60
+ }
61
+ }