@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.
- package/LICENSE +18 -0
- package/README.md +209 -2
- package/dist/browser/lympid-embed.js +319 -0
- package/dist/browser/lympid-embed.js.map +7 -0
- package/dist/browser/lympid-embed.min.js +2 -0
- package/dist/browser/lympid-embed.min.js.map +7 -0
- package/dist/cjs/bridge.d.ts +31 -0
- package/dist/cjs/bridge.js +45 -0
- package/dist/cjs/embed.d.ts +8 -0
- package/dist/cjs/embed.js +154 -0
- package/dist/cjs/index.d.ts +25 -0
- package/dist/cjs/index.js +28 -0
- package/dist/cjs/modal.d.ts +30 -0
- package/dist/cjs/modal.js +159 -0
- package/dist/cjs/protocol.d.ts +71 -0
- package/dist/cjs/protocol.js +30 -0
- package/dist/cjs/types.d.ts +150 -0
- package/dist/cjs/types.js +19 -0
- package/dist/esm/bridge.js +42 -0
- package/dist/esm/embed.js +151 -0
- package/dist/esm/index.js +24 -0
- package/dist/esm/modal.js +156 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/protocol.js +26 -0
- package/dist/esm/types.js +18 -0
- package/package.json +59 -4
|
@@ -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,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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|