diff --git a/src/apis/html-page-api.js b/src/apis/html-page-api.js
index c3643b5..c285ea5 100644
--- a/src/apis/html-page-api.js
+++ b/src/apis/html-page-api.js
@@ -2,24 +2,28 @@ import axios from 'axios';
class HTMLPageAPI {
async initWithAccountToken({ server, accountToken, appUuid }) {
- this.initServer(server);
- this.appUuid = appUuid;
- if (this.server && this.appUuid) {
- try {
- const res = await axios.get(`${this.server}api/v2.1/universal-apps/${this.appUuid}/access-token/`, {
- headers: { Authorization: 'Token ' + accountToken }
- });
- this.accessToken = res.data?.access_token || '';
- this.createReq();
- } catch (error) {
- // eslint-disable-next-line
- console.log('Authorization failed');
- }
+ if (!server || !accountToken || !appUuid) {
+ throw new Error('Failed to get access token: missing server, accountToken, or appUuid');
+ }
+
+ let res;
+ try {
+ res = await axios.get(`${server}api/v2.1/universal-apps/${appUuid}/access-token/`, {
+ headers: { Authorization: 'Token ' + accountToken }
+ });
+ } catch (error) {
+ throw new Error(`Failed to get access token: ${error.message}`);
+ }
+
+ const accessToken = res.data?.access_token;
+ if (!accessToken) {
+ throw new Error('Failed to get access token: access_token missing');
}
+ this.accessToken = accessToken;
}
init({ server, accessToken, appUuid }) {
- this.initServer(server);
+ this.server = server;
this.accessToken = accessToken || '';
this.appUuid = appUuid;
if (this.accessToken && this.server && this.appUuid) {
@@ -27,9 +31,21 @@ class HTMLPageAPI {
}
}
- initServer(server) {
- if (!server) return;
- this.server = server.endsWith('/') ? server : `${server}/`;
+ async getParentOrigin({ server, accessToken, appUuid }) {
+ if (!server || !accessToken || !appUuid) {
+ throw new Error('Failed to get parentOrigin: missing server, accessToken, or appUuid');
+ }
+
+ try {
+ const response = await axios.post(
+ `${server}api/v2.1/universal-apps/bootstrap/`,
+ { app_uuid: appUuid },
+ { headers: { Authorization: 'Token ' + accessToken } },
+ );
+ return response.data?.parentOrigin || '';
+ } catch (error) {
+ throw new Error(`Failed to get parentOrigin: ${error.message}`);
+ }
}
createReq() {
diff --git a/src/iframe-adapter.js b/src/iframe-adapter.js
index 1a524ae..e17d832 100644
--- a/src/iframe-adapter.js
+++ b/src/iframe-adapter.js
@@ -13,6 +13,12 @@ export const POST_MESSAGE_REQUEST_TYPE = {
GET_PREVIEW_TABLE_CONFIGS: 'get_preview_table_configs',
};
+const BOOTSTRAP_REQUEST_TYPES = new Set([
+ POST_MESSAGE_REQUEST_TYPE.GET_SERVER,
+ POST_MESSAGE_REQUEST_TYPE.GET_ACCESS_TOKEN,
+ POST_MESSAGE_REQUEST_TYPE.GET_APP_UUID,
+]);
+
const WINDOW_EVENT_SOURCE_TYPE = {
APP: 'app',
IFRAME: 'iframe',
@@ -27,6 +33,20 @@ const hasOwnProperty = (obj, key) => {
return Object.prototype.hasOwnProperty.call(obj, key);
};
+const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
+
+const normalizeOrigin = (origin) => {
+ if (typeof origin !== 'string' || !origin) return null;
+
+ try {
+ const url = new URL(origin);
+ if (!['http:', 'https:'].includes(url.protocol) || url.origin === 'null') return null;
+ return url.origin;
+ } catch (error) {
+ return null;
+ }
+};
+
const generatorBase64Code = (keyLength = 4) => {
let possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz0123456789';
let key = '';
@@ -77,10 +97,11 @@ export class IframeAdapter {
constructor(options) {
this.options = options || {};
this.selfWindow = window.parent === window.self;
- this.targetOrigin = this.options.targetOrigin || '*';
+ this.targetOrigin = null;
this.pendingRequests = {};
this.eventHandlers = {};
this.timeout = this.options.timeout || 10000;
+ this._handleMessage = this.handleMessage.bind(this);
this.setupMessageListener();
}
@@ -94,12 +115,20 @@ export class IframeAdapter {
setupMessageListener() {
if (this.selfWindow) return;
- window.addEventListener('message', this.handleMessage.bind(this));
+ window.addEventListener('message', this._handleMessage);
this.setEventsListener();
}
+ setTargetOrigin(origin) {
+ const targetOrigin = normalizeOrigin(origin);
+ if (!targetOrigin) {
+ throw new Error('Invalid trusted target origin');
+ }
+ this.targetOrigin = targetOrigin;
+ }
+
postWindowEvent(eventData) {
- if (!eventData) return;
+ if (!eventData || !this.targetOrigin) return;
window.parent.postMessage({
type: POST_MESSAGE_TYPE.WINDOW_EVENT,
params: {
@@ -153,23 +182,39 @@ export class IframeAdapter {
}
async request(method, params) {
- if (this.selfWindow) {
- return new Promise((resolve) => {
- resolve(null);
- });
+ if (this.selfWindow) return null;
+ if (!this.targetOrigin) {
+ throw new Error('Trusted target origin has not been configured');
+ }
+ return this._request(method, params, this.targetOrigin);
+ }
+
+ async bootstrapRequest(method, params) {
+ if (this.selfWindow) return null;
+
+ // Initial server, access-token, and app-UUID requests run before the trusted
+ // parent origin is known. Responses still have to originate from window.parent
+ // and match the generated request ID. All later traffic requires the configured origin.
+ if (this.targetOrigin) {
+ return this._request(method, params, this.targetOrigin);
}
+ if (!BOOTSTRAP_REQUEST_TYPES.has(method)) {
+ throw new Error(`Unsupported bootstrap request: ${method}`);
+ }
+ return this._request(method, params, '*');
+ }
+
+ _request(method, params, targetOrigin) {
const requestId = this.generatorRequestId();
return new Promise((resolve, reject) => {
- this.pendingRequests[requestId] = { resolve, reject };
+ this.pendingRequests[requestId] = { resolve, reject, targetOrigin };
window.parent.postMessage({
type: POST_MESSAGE_TYPE.HTML_PAGE_REQUEST,
requestId,
method,
params
- }, this.targetOrigin);
+ }, targetOrigin);
- // request timeout
- // reject and clear the pending request
const timeoutId = setTimeout(() => {
if (hasOwnProperty(this.pendingRequests, requestId)) {
delete this.pendingRequests[requestId];
@@ -177,7 +222,6 @@ export class IframeAdapter {
}
}, this.timeout);
- // save timeoutId for the pending request
const pending = this.pendingRequests[requestId];
if (pending) {
pending.timeoutId = timeoutId;
@@ -185,19 +229,43 @@ export class IframeAdapter {
});
}
+ isMessageFromParent(event) {
+ return event && event.source === window.parent;
+ }
+
+ isTrustedMessage(event) {
+ return Boolean(this.targetOrigin && this.isMessageFromParent(event) && event.origin === this.targetOrigin);
+ }
+
+ isExpectedResponse(event, pending) {
+ // Bootstrap responses may come from any origin because the trusted origin is
+ // not known yet, but every response must come from window.parent. After
+ // bootstrap, the response origin must match the origin used for the request.
+ if (!this.isMessageFromParent(event)) return false;
+ return pending.targetOrigin === '*' || event.origin === pending.targetOrigin;
+ }
+
handleMessage(event) {
+ if (!isObject(event?.data)) return;
+
const { type, requestId, data, error, eventType, payload } = event.data;
if (type === POST_MESSAGE_TYPE.HTML_PAGE_RESPONSE) {
const pending = this.pendingRequests[requestId];
- if (pending) {
- clearTimeout(pending.timeoutId);
- delete this.pendingRequests[requestId];
- if (error) {
- pending.reject(new Error(error));
- } else {
+ if (!pending || !this.isExpectedResponse(event, pending)) return;
+
+ clearTimeout(pending.timeoutId);
+ delete this.pendingRequests[requestId];
+ if (error) {
+ pending.reject(new Error(error));
+ } else {
+ try {
pending.resolve(data ? JSON.parse(data) : null);
+ } catch (parseError) {
+ pending.reject(new Error('Invalid response payload'));
}
}
+ } else if (!this.isTrustedMessage(event)) {
+ return;
} else if (type === POST_MESSAGE_TYPE.HTML_PAGE_EVENT) {
this.emitEvent(eventType, payload);
} else if (type === POST_MESSAGE_TYPE.WINDOW_EVENT) {
@@ -289,7 +357,7 @@ export class IframeAdapter {
}
destroy() {
- this.pendingRequests.forEach(pending => {
+ Object.values(this.pendingRequests).forEach(pending => {
clearTimeout(pending.timeoutId);
pending.reject(new Error('Adapter destroyed'));
});
diff --git a/src/sdk.js b/src/sdk.js
index b46cc18..f49ae0d 100644
--- a/src/sdk.js
+++ b/src/sdk.js
@@ -1,41 +1,70 @@
import HTMLPageAPI from './apis/html-page-api';
import { IframeAdapter, POST_MESSAGE_REQUEST_TYPE } from './iframe-adapter';
+const AI_AGENT_PAGE_ID = 'ai_agent';
+
export class HTMLPageSDK {
constructor(options) {
- this.options = options || {};
- this.iframeAdapter = new IframeAdapter(options);
+ const sdkOptions = { ...(options || {}) };
+ delete sdkOptions.accessToken;
+ this.options = sdkOptions;
+ this.iframeAdapter = new IframeAdapter(sdkOptions);
}
async init() {
- if (!this.options) {
- this.options = {};
- }
this.htmlPageAPI = new HTMLPageAPI();
- if (!this.options.server) {
- this.options.server = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_SERVER);
+ if (Object.prototype.hasOwnProperty.call(this.options, 'accountToken')) {
+ await this._initDevelopment();
+ return;
}
- if (!this.options.appUuid) {
- this.options.appUuid = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_APP_UUID);
+ await this._initProduction();
+ }
+
+ async _initDevelopment() {
+ const server = this._normalizeServer(this.options.server);
+ if (!server) {
+ throw new Error('Missing server configuration');
}
- if (!this.options.pageId) {
- this.options.pageId = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_PAGE_ID);
+
+ this.options.server = server;
+ const { accountToken, appUuid } = this.options;
+ await this.htmlPageAPI.initWithAccountToken({ server, accountToken, appUuid });
+
+ const accessToken = this.htmlPageAPI.accessToken;
+ this.htmlPageAPI.init({ server, accessToken, appUuid });
+ }
+
+ async _initProduction() {
+ const server = this._normalizeServer(
+ await this.iframeAdapter.bootstrapRequest(POST_MESSAGE_REQUEST_TYPE.GET_SERVER)
+ );
+ if (!server) {
+ throw new Error('Missing server configuration');
}
- if (this.options.pageId === 'ai_agent' && !Array.isArray(this.options.previewTableConfigs)) {
+ this.options.server = server;
+
+ const accessToken = await this.iframeAdapter.bootstrapRequest(POST_MESSAGE_REQUEST_TYPE.GET_ACCESS_TOKEN);
+ const appUuid = await this.iframeAdapter.bootstrapRequest(POST_MESSAGE_REQUEST_TYPE.GET_APP_UUID);
+ this.options.appUuid = appUuid;
+ await this._configureTrustedOrigin({ server, accessToken, appUuid });
+
+ this.options.pageId = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_PAGE_ID);
+ if (this.options.pageId === AI_AGENT_PAGE_ID) {
const previewTableConfigs = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_PREVIEW_TABLE_CONFIGS);
this.options.previewTableConfigs = Array.isArray(previewTableConfigs) ? previewTableConfigs : [];
}
- if (this.options.accountToken) {
- // dev: try to get access-token via accountToken
- const { server, accountToken, appUuid } = this.options;
- await this.htmlPageAPI.initWithAccountToken({ server, accountToken, appUuid });
- } else {
- if (!this.options.accessToken) {
- this.options.accessToken = await this.iframeAdapter.request(POST_MESSAGE_REQUEST_TYPE.GET_ACCESS_TOKEN);
- }
- const { server, accessToken, appUuid } = this.options;
- this.htmlPageAPI.init({ server, accessToken, appUuid });
- }
+
+ this.htmlPageAPI.init({ server, accessToken, appUuid: this.options.appUuid });
+ }
+
+ async _configureTrustedOrigin({ server, accessToken, appUuid }) {
+ const parentOrigin = await this.htmlPageAPI.getParentOrigin({ server, accessToken, appUuid });
+ this.iframeAdapter.setTargetOrigin(parentOrigin);
+ }
+
+ _normalizeServer(server) {
+ if (!server) return '';
+ return server.endsWith('/') ? server : `${server}/`;
}
listRows({ tableName, start, limit }) {
@@ -49,7 +78,7 @@ export class HTMLPageSDK {
}
_getPreviewTableConfig({ tableName }) {
- if (this.options.pageId !== 'ai_agent' || !Array.isArray(this.options.previewTableConfigs)) return undefined;
+ if (this.options.pageId !== AI_AGENT_PAGE_ID || !Array.isArray(this.options.previewTableConfigs)) return undefined;
const tableConfig = this.options.previewTableConfigs.find(config => tableName && config?.table_name === tableName);
if (!tableConfig) return undefined;
return {
diff --git a/tests/html-page-api.test.js b/tests/html-page-api.test.js
index 1e52332..2706b45 100644
--- a/tests/html-page-api.test.js
+++ b/tests/html-page-api.test.js
@@ -6,6 +6,7 @@ jest.mock('axios', () => ({
default: {
create: jest.fn(),
get: jest.fn(),
+ post: jest.fn(),
},
}));
@@ -23,7 +24,7 @@ function createApi() {
const api = new HTMLPageAPI();
api.init({
- server: 'https://example.com',
+ server: 'https://example.com/',
accessToken: 'token',
appUuid: 'app-uuid',
});
@@ -98,7 +99,7 @@ describe('HTMLPageAPI.listRows', () => {
const api = new HTMLPageAPI();
api.init({
- server: 'https://example.com',
+ server: 'https://example.com/',
accessToken: 'token',
appUuid: 'app-uuid',
});
@@ -415,7 +416,7 @@ describe('HTMLPageAPI.upload', () => {
const api = new HTMLPageAPI();
api.init({
- server: 'https://example.com',
+ server: 'https://example.com/',
accessToken: 'token',
appUuid: 'app-uuid',
});
@@ -455,7 +456,7 @@ describe('HTMLPageAPI.upload', () => {
const api = new HTMLPageAPI();
api.init({
- server: 'https://example.com',
+ server: 'https://example.com/',
accessToken: 'token',
appUuid: 'app-uuid',
});
@@ -485,3 +486,89 @@ describe('HTMLPageAPI.upload', () => {
});
});
});
+
+describe('HTMLPageAPI.initWithAccountToken', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('rejects with the access-token request error', async () => {
+ axios.get.mockRejectedValue(new Error('Request failed with status code 401'));
+ const api = new HTMLPageAPI();
+
+ await expect(api.initWithAccountToken({
+ server: 'https://custom-app-server.example.com/',
+ accountToken: 'invalid-account-token',
+ appUuid: 'app-uuid',
+ })).rejects.toThrow('Failed to get access token: Request failed with status code 401');
+ });
+
+ it('requires development access-token configuration', async () => {
+ const api = new HTMLPageAPI();
+
+ await expect(api.initWithAccountToken({
+ server: 'https://custom-app-server.example.com/',
+ accountToken: 'account-token',
+ appUuid: '',
+ })).rejects.toThrow('Failed to get access token: missing server, accountToken, or appUuid');
+ expect(axios.get).not.toHaveBeenCalled();
+ });
+
+ it('rejects when the access-token response does not contain an access token', async () => {
+ axios.get.mockResolvedValue({ data: {} });
+ const api = new HTMLPageAPI();
+
+ const error = await api.initWithAccountToken({
+ server: 'https://custom-app-server.example.com/',
+ accountToken: 'account-token',
+ appUuid: 'app-uuid',
+ }).catch(error => error);
+
+ expect(error.message).toBe('Failed to get access token: access_token missing');
+ });
+});
+
+describe('HTMLPageAPI.getParentOrigin', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('uses the custom-app-server bootstrap endpoint with the app access token', async () => {
+ axios.post.mockResolvedValue({ data: { parentOrigin: 'https://app.example.com' } });
+ const api = new HTMLPageAPI();
+
+ await expect(api.getParentOrigin({
+ server: 'https://custom-app-server.example.com/',
+ accessToken: 'access-token',
+ appUuid: 'app-uuid',
+ })).resolves.toBe('https://app.example.com');
+
+ expect(axios.post).toHaveBeenCalledWith(
+ 'https://custom-app-server.example.com/api/v2.1/universal-apps/bootstrap/',
+ { app_uuid: 'app-uuid' },
+ { headers: { Authorization: 'Token access-token' } },
+ );
+ });
+
+ it('adds parentOrigin context when the bootstrap request fails', async () => {
+ axios.post.mockRejectedValue(new Error('Request failed with status code 401'));
+ const api = new HTMLPageAPI();
+
+ await expect(api.getParentOrigin({
+ server: 'https://custom-app-server.example.com/',
+ accessToken: 'invalid-access-token',
+ appUuid: 'app-uuid',
+ })).rejects.toThrow('Failed to get parentOrigin: Request failed with status code 401');
+ });
+
+ it('requires bootstrap configuration', async () => {
+ const api = new HTMLPageAPI();
+
+ await expect(api.getParentOrigin({
+ server: 'https://custom-app-server.example.com/',
+ accessToken: 'access-token',
+ appUuid: '',
+ })).rejects.toThrow('Failed to get parentOrigin: missing server, accessToken, or appUuid');
+ expect(axios.post).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/iframe-adapter.test.js b/tests/iframe-adapter.test.js
index 8e0da4c..f861e54 100644
--- a/tests/iframe-adapter.test.js
+++ b/tests/iframe-adapter.test.js
@@ -27,11 +27,12 @@ describe('IframeAdapter', () => {
expect(global.window.addEventListener).not.toHaveBeenCalled();
});
- it('posts requests and resolves responses in iframe mode', async () => {
+ it('uses a wildcard only for server and access-token bootstrap requests', async () => {
jest.useFakeTimers();
const addEventListener = jest.fn();
const postMessage = jest.fn();
+ const parent = { postMessage };
const target = { dispatchEvent: jest.fn() };
global.document = {
@@ -39,38 +40,206 @@ describe('IframeAdapter', () => {
body: target,
elementFromPoint: jest.fn().mockReturnValue(target),
};
-
global.window = {
self: {},
- parent: {
- postMessage,
- },
+ parent,
addEventListener,
};
- const adapter = new IframeAdapter({ targetOrigin: 'https://example.com', timeout: 1000 });
- const requestPromise = adapter.request('get_server', { foo: 'bar' });
+ const adapter = new IframeAdapter({
+ timeout: 1000,
+ targetOrigin: 'https://unverified-parent.example.com',
+ });
+ expect(adapter.targetOrigin).toBeNull();
+
+ const serverPromise = adapter.bootstrapRequest('get_server');
+ const serverRequestId = postMessage.mock.calls[0][0].requestId;
+ expect(postMessage).toHaveBeenNthCalledWith(1, {
+ type: 'HTML_PAGE_REQUEST',
+ requestId: expect.any(String),
+ method: 'get_server',
+ params: undefined,
+ }, '*');
- expect(addEventListener).toHaveBeenCalledWith('message', expect.any(Function));
- expect(postMessage).toHaveBeenCalledWith(
- {
- type: 'HTML_PAGE_REQUEST',
- requestId: expect.any(String),
- method: 'get_server',
- params: { foo: 'bar' },
+ adapter.handleMessage({
+ source: parent,
+ origin: 'https://unverified-parent.example.com',
+ data: {
+ type: 'HTML_PAGE_RESPONSE',
+ requestId: serverRequestId,
+ data: JSON.stringify('https://custom-app-server.example.com'),
},
- 'https://example.com',
- );
+ });
+ await expect(serverPromise).resolves.toBe('https://custom-app-server.example.com');
+
+ const accessTokenPromise = adapter.bootstrapRequest('get_access_token');
+ const accessTokenRequestId = postMessage.mock.calls[1][0].requestId;
+ expect(postMessage).toHaveBeenNthCalledWith(2, {
+ type: 'HTML_PAGE_REQUEST',
+ requestId: expect.any(String),
+ method: 'get_access_token',
+ params: undefined,
+ }, '*');
+
+ adapter.handleMessage({
+ source: parent,
+ origin: 'https://unverified-parent.example.com',
+ data: {
+ type: 'HTML_PAGE_RESPONSE',
+ requestId: accessTokenRequestId,
+ data: JSON.stringify('access-token'),
+ },
+ });
+ await expect(accessTokenPromise).resolves.toBe('access-token');
+
+ const appUuidPromise = adapter.bootstrapRequest('get_app_uuid');
+ const appUuidRequestId = postMessage.mock.calls[2][0].requestId;
+ expect(postMessage).toHaveBeenNthCalledWith(3, {
+ type: 'HTML_PAGE_REQUEST',
+ requestId: expect.any(String),
+ method: 'get_app_uuid',
+ params: undefined,
+ }, '*');
+
+ adapter.handleMessage({
+ source: parent,
+ origin: 'https://unverified-parent.example.com',
+ data: {
+ type: 'HTML_PAGE_RESPONSE',
+ requestId: appUuidRequestId,
+ data: JSON.stringify('app-uuid'),
+ },
+ });
+ await expect(appUuidPromise).resolves.toBe('app-uuid');
+
+ adapter.setTargetOrigin('https://app.example.com/path-that-is-not-part-of-an-origin');
+ expect(adapter.targetOrigin).toBe('https://app.example.com');
+
+ const requestPromise = adapter.request('get_page_id');
+ const requestId = postMessage.mock.calls[3][0].requestId;
+ expect(postMessage).toHaveBeenNthCalledWith(4, {
+ type: 'HTML_PAGE_REQUEST',
+ requestId: expect.any(String),
+ method: 'get_page_id',
+ params: undefined,
+ }, 'https://app.example.com');
+
+ adapter.handleMessage({
+ source: parent,
+ origin: 'https://unverified-parent.example.com',
+ data: {
+ type: 'HTML_PAGE_RESPONSE',
+ requestId,
+ data: JSON.stringify('page-1'),
+ },
+ });
+ expect(adapter.pendingRequests[requestId]).toBeDefined();
- const requestId = postMessage.mock.calls[0][0].requestId;
adapter.handleMessage({
+ source: {},
+ origin: 'https://app.example.com',
data: {
type: 'HTML_PAGE_RESPONSE',
requestId,
- data: JSON.stringify({ server: 'https://example.com' }),
+ data: JSON.stringify('page-1'),
},
});
+ expect(adapter.pendingRequests[requestId]).toBeDefined();
- await expect(requestPromise).resolves.toEqual({ server: 'https://example.com' });
+ adapter.handleMessage({
+ source: parent,
+ origin: 'https://app.example.com',
+ data: {
+ type: 'HTML_PAGE_RESPONSE',
+ requestId,
+ data: JSON.stringify('page-1'),
+ },
+ });
+ await expect(requestPromise).resolves.toBe('page-1');
+ });
+
+
+ it('rejects non-bootstrap request types before the trusted origin is configured', async () => {
+ const postMessage = jest.fn();
+ global.window = {
+ self: {},
+ parent: { postMessage },
+ addEventListener: jest.fn(),
+ };
+
+ const adapter = new IframeAdapter({ timeout: 1 });
+
+ await expect(adapter.bootstrapRequest('get_page_id')).rejects.toThrow(
+ 'Unsupported bootstrap request: get_page_id'
+ );
+ expect(postMessage).not.toHaveBeenCalled();
+ });
+
+ it('does not send events or accept non-bootstrap messages before a target origin is configured', async () => {
+ const addEventListener = jest.fn();
+ const postMessage = jest.fn();
+ const parent = { postMessage };
+ const target = { dispatchEvent: jest.fn() };
+
+ global.document = {
+ activeElement: null,
+ body: target,
+ elementFromPoint: jest.fn().mockReturnValue(target),
+ };
+ global.window = {
+ self: {},
+ parent,
+ addEventListener,
+ };
+
+ const adapter = new IframeAdapter();
+ const handler = jest.fn();
+ adapter.on('event', handler);
+ adapter.postWindowEvent({ type: 'click' });
+ adapter.handleMessage({
+ source: parent,
+ origin: 'https://unverified-parent.example.com',
+ data: {
+ type: 'HTML_PAGE_EVENT',
+ eventType: 'event',
+ payload: { value: 1 },
+ },
+ });
+
+ expect(postMessage).not.toHaveBeenCalled();
+ expect(handler).not.toHaveBeenCalled();
+ await expect(adapter.request('get_server')).rejects.toThrow('Trusted target origin has not been configured');
+ });
+
+ it('rejects and clears pending requests when destroyed', async () => {
+ jest.useFakeTimers();
+
+ global.window = {
+ self: {},
+ parent: { postMessage: jest.fn() },
+ addEventListener: jest.fn(),
+ };
+
+ const adapter = new IframeAdapter({ timeout: 1000 });
+ adapter.setTargetOrigin('https://app.example.com');
+ const requestPromise = adapter.request('get_page_id');
+
+ adapter.destroy();
+
+ await expect(requestPromise).rejects.toThrow('Adapter destroyed');
+ expect(adapter.pendingRequests).toEqual({});
+ expect(adapter.eventHandlers).toEqual({});
+ });
+
+ it('rejects invalid target origins', () => {
+ global.window = {
+ self: {},
+ parent: { postMessage: jest.fn() },
+ addEventListener: jest.fn(),
+ };
+
+ const adapter = new IframeAdapter();
+ expect(() => adapter.setTargetOrigin('*')).toThrow('Invalid trusted target origin');
+ expect(() => adapter.setTargetOrigin('javascript:alert(1)')).toThrow('Invalid trusted target origin');
});
});
diff --git a/tests/sdk-init.test.js b/tests/sdk-init.test.js
index a0b347f..34b854d 100644
--- a/tests/sdk-init.test.js
+++ b/tests/sdk-init.test.js
@@ -1,6 +1,9 @@
import { HTMLPageSDK } from '../src/sdk';
const mockRequest = jest.fn();
+const mockBootstrapRequest = jest.fn();
+const mockSetTargetOrigin = jest.fn();
+const mockGetParentOrigin = jest.fn();
const mockInitWithAccountToken = jest.fn(function initWithAccountToken() {
this.accessToken = 'access-token';
this.req = {};
@@ -10,6 +13,7 @@ const mockInit = jest.fn();
jest.mock('../src/apis/html-page-api', () => {
return jest.fn().mockImplementation(() => ({
+ getParentOrigin: mockGetParentOrigin,
initWithAccountToken: mockInitWithAccountToken,
init: mockInit,
}));
@@ -17,7 +21,9 @@ jest.mock('../src/apis/html-page-api', () => {
jest.mock('../src/iframe-adapter', () => ({
IframeAdapter: jest.fn().mockImplementation(() => ({
+ bootstrapRequest: mockBootstrapRequest,
request: mockRequest,
+ setTargetOrigin: mockSetTargetOrigin,
})),
POST_MESSAGE_REQUEST_TYPE: {
GET_SERVER: 'get_server',
@@ -30,35 +36,121 @@ jest.mock('../src/iframe-adapter', () => ({
describe('HTMLPageSDK.init', () => {
beforeEach(() => {
- jest.clearAllMocks();
+ mockRequest.mockReset();
+ mockBootstrapRequest.mockReset();
+ mockSetTargetOrigin.mockReset();
+ mockGetParentOrigin.mockReset();
+ mockInit.mockReset();
+ mockInitWithAccountToken.mockReset().mockImplementation(function initWithAccountToken() {
+ this.accessToken = 'access-token';
+ this.req = {};
+ return Promise.resolve();
+ });
});
- it('production: initializes with access token', async () => {
- mockRequest
- .mockResolvedValueOnce('https://example.com')
- .mockResolvedValueOnce('app-uuid')
- .mockResolvedValueOnce('page-1')
- .mockResolvedValueOnce('access-token');
+ it('production: gets and normalizes server before the other initialization data', async () => {
+ mockBootstrapRequest
+ .mockResolvedValueOnce('https://custom-app-server.example.com')
+ .mockResolvedValueOnce('access-token')
+ .mockResolvedValueOnce('app-uuid');
+ mockGetParentOrigin.mockResolvedValue('https://app.example.com');
+ mockRequest.mockResolvedValueOnce('page-1');
const sdk = new HTMLPageSDK();
await sdk.init();
- expect(mockRequest).toHaveBeenNthCalledWith(1, 'get_server');
- expect(mockRequest).toHaveBeenNthCalledWith(2, 'get_app_uuid');
- expect(mockRequest).toHaveBeenNthCalledWith(3, 'get_page_id');
- expect(mockRequest).toHaveBeenNthCalledWith(4, 'get_access_token');
+ expect(mockBootstrapRequest).toHaveBeenNthCalledWith(1, 'get_server');
+ expect(mockBootstrapRequest).toHaveBeenNthCalledWith(2, 'get_access_token');
+ expect(mockBootstrapRequest).toHaveBeenNthCalledWith(3, 'get_app_uuid');
+ expect(mockGetParentOrigin).toHaveBeenCalledWith({
+ server: 'https://custom-app-server.example.com/',
+ accessToken: 'access-token',
+ appUuid: 'app-uuid',
+ });
+ expect(mockSetTargetOrigin).toHaveBeenCalledWith('https://app.example.com');
+ expect(mockRequest).toHaveBeenNthCalledWith(1, 'get_page_id');
+ expect(mockSetTargetOrigin.mock.invocationCallOrder[0]).toBeLessThan(mockRequest.mock.invocationCallOrder[0]);
expect(mockInit).toHaveBeenCalledWith({
- server: 'https://example.com',
+ server: 'https://custom-app-server.example.com/',
accessToken: 'access-token',
appUuid: 'app-uuid',
});
- expect(sdk.options.server).toBe('https://example.com');
- expect(sdk.options.appUuid).toBe('app-uuid');
- expect(sdk.options.pageId).toBe('page-1');
- expect(sdk.options.accessToken).toBe('access-token');
+ expect(sdk.options).toMatchObject({
+ server: 'https://custom-app-server.example.com/',
+ appUuid: 'app-uuid',
+ pageId: 'page-1',
+ });
+ expect(sdk.options.accessToken).toBeUndefined();
+ });
+
+ it('production: gets initialization data from iframeAdapter instead of SDK options', async () => {
+ mockBootstrapRequest
+ .mockResolvedValueOnce('https://iframe-server.example.com')
+ .mockResolvedValueOnce('bootstrapped-access-token')
+ .mockResolvedValueOnce('iframe-app-uuid');
+ mockGetParentOrigin.mockResolvedValue('https://app.example.com');
+ mockRequest.mockResolvedValueOnce('iframe-page-id');
+
+ const sdk = new HTMLPageSDK({
+ server: 'https://options-server.example.com',
+ appUuid: 'options-app-uuid',
+ pageId: 'options-page-id',
+ accessToken: 'options-access-token',
+ });
+ await sdk.init();
+
+ expect(mockBootstrapRequest).toHaveBeenNthCalledWith(1, 'get_server');
+ expect(mockBootstrapRequest).toHaveBeenNthCalledWith(2, 'get_access_token');
+ expect(mockBootstrapRequest).toHaveBeenNthCalledWith(3, 'get_app_uuid');
+ expect(mockGetParentOrigin).toHaveBeenCalledWith({
+ server: 'https://iframe-server.example.com/',
+ accessToken: 'bootstrapped-access-token',
+ appUuid: 'iframe-app-uuid',
+ });
+ expect(mockInit).toHaveBeenCalledWith({
+ server: 'https://iframe-server.example.com/',
+ accessToken: 'bootstrapped-access-token',
+ appUuid: 'iframe-app-uuid',
+ });
+ expect(sdk.options).toMatchObject({
+ server: 'https://iframe-server.example.com/',
+ appUuid: 'iframe-app-uuid',
+ pageId: 'iframe-page-id',
+ });
+ expect(sdk.options.accessToken).toBeUndefined();
+ });
+
+ it('production: stops initialization when GET_SERVER returns no server', async () => {
+ mockBootstrapRequest.mockResolvedValueOnce('');
+
+ const sdk = new HTMLPageSDK();
+
+ await expect(sdk.init()).rejects.toThrow('Missing server configuration');
+ expect(mockBootstrapRequest).toHaveBeenCalledTimes(1);
+ expect(mockBootstrapRequest).toHaveBeenCalledWith('get_server');
+ expect(mockGetParentOrigin).not.toHaveBeenCalled();
+ expect(mockSetTargetOrigin).not.toHaveBeenCalled();
+ expect(mockRequest).not.toHaveBeenCalled();
+ expect(mockInit).not.toHaveBeenCalled();
+ });
+
+ it('production: does not request trusted app data when parent-origin bootstrap fails', async () => {
+ mockBootstrapRequest
+ .mockResolvedValueOnce('https://custom-app-server.example.com')
+ .mockResolvedValueOnce('access-token')
+ .mockResolvedValueOnce('app-uuid');
+ mockGetParentOrigin.mockRejectedValue(new Error('Permission denied.'));
+
+ const sdk = new HTMLPageSDK();
+
+ await expect(sdk.init()).rejects.toThrow('Permission denied.');
+ expect(mockSetTargetOrigin).not.toHaveBeenCalled();
+ expect(mockRequest).not.toHaveBeenCalled();
+ expect(mockInit).not.toHaveBeenCalled();
});
- it('development: exchanges accountToken for accessToken', async () => {
+ it('development: initializes from options and APIs without iframe requests', async () => {
+ mockGetParentOrigin.mockResolvedValue('https://app.example.com');
const sdk = new HTMLPageSDK({
server: 'https://example.com',
appUuid: 'app-uuid',
@@ -67,33 +159,60 @@ describe('HTMLPageSDK.init', () => {
});
await sdk.init();
+ expect(mockBootstrapRequest).not.toHaveBeenCalled();
expect(mockRequest).not.toHaveBeenCalled();
expect(mockInitWithAccountToken).toHaveBeenCalledWith({
- server: 'https://example.com',
+ server: 'https://example.com/',
accountToken: 'account-token',
appUuid: 'app-uuid',
});
+ expect(mockGetParentOrigin).not.toHaveBeenCalled();
+ expect(mockSetTargetOrigin).not.toHaveBeenCalled();
+ expect(mockInit).toHaveBeenCalledWith({
+ server: 'https://example.com/',
+ accessToken: 'access-token',
+ appUuid: 'app-uuid',
+ });
+ expect(sdk.options.server).toBe('https://example.com/');
expect(sdk.htmlPageAPI.accessToken).toBe('access-token');
- expect(sdk.options.server).toBe('https://example.com');
- expect(sdk.options.appUuid).toBe('app-uuid');
- expect(sdk.options.pageId).toBe('page-1');
- expect(sdk.options.accountToken).toBe('account-token');
});
- it('loads table permission configs for ai_agent preview', async () => {
+ it('development: uses development initialization when accountToken option exists but is empty', async () => {
+ const sdk = new HTMLPageSDK({
+ server: 'https://example.com',
+ appUuid: 'app-uuid',
+ accountToken: '',
+ });
+ await sdk.init();
+
+ expect(mockBootstrapRequest).not.toHaveBeenCalled();
+ expect(mockRequest).not.toHaveBeenCalled();
+ expect(mockInitWithAccountToken).toHaveBeenCalledWith({
+ server: 'https://example.com/',
+ accountToken: '',
+ appUuid: 'app-uuid',
+ });
+ });
+
+ it('production: loads ai_agent preview configs after configuring the trusted origin', async () => {
const previewTableConfigs = [{ table_id: 'REW7', permissions: {} }];
- mockRequest
+ mockBootstrapRequest
.mockResolvedValueOnce('https://example.com')
- .mockResolvedValueOnce('app-uuid')
+ .mockResolvedValueOnce('access-token')
+ .mockResolvedValueOnce('app-uuid');
+ mockGetParentOrigin.mockResolvedValue('https://app.example.com');
+ mockRequest
.mockResolvedValueOnce('ai_agent')
- .mockResolvedValueOnce(previewTableConfigs)
- .mockResolvedValueOnce('access-token');
+ .mockResolvedValueOnce(previewTableConfigs);
const sdk = new HTMLPageSDK();
await sdk.init();
- expect(mockRequest).toHaveBeenNthCalledWith(4, 'get_preview_table_configs');
- expect(mockRequest).toHaveBeenNthCalledWith(5, 'get_access_token');
+ expect(mockBootstrapRequest).toHaveBeenNthCalledWith(1, 'get_server');
+ expect(mockBootstrapRequest).toHaveBeenNthCalledWith(2, 'get_access_token');
+ expect(mockBootstrapRequest).toHaveBeenNthCalledWith(3, 'get_app_uuid');
+ expect(mockSetTargetOrigin).toHaveBeenCalledWith('https://app.example.com');
+ expect(mockRequest).toHaveBeenNthCalledWith(2, 'get_preview_table_configs');
expect(sdk.options.previewTableConfigs).toEqual(previewTableConfigs);
});
});