Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/CortiEmbedded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ import {
type PostMessageHandlerCallbacks,
} from './utils/PostMessageHandler.js';

const IFRAME_SANDBOX_POLICY =
'allow-forms allow-modals allow-scripts allow-same-origin';

export class CortiEmbedded extends LitElement implements CortiEmbeddedAPI {
static styles = [baseStyles, containerStyles];

Expand All @@ -43,6 +46,7 @@ export class CortiEmbedded extends LitElement implements CortiEmbeddedAPI {

private normalizedBaseURL: string | null = null;

// eslint-disable-next-line class-methods-use-this
private getIframeAllowPolicy(normalizedBaseURL?: string | null): string {
const permissionTarget = normalizedBaseURL
? new URL(normalizedBaseURL).origin
Expand Down Expand Up @@ -566,13 +570,13 @@ export class CortiEmbedded extends LitElement implements CortiEmbeddedAPI {
<iframe
src=${buildEmbeddedUrl(this.normalizedBaseURL)}
title="Corti Embedded UI"
sandbox=${'allow-forms allow-modals allow-scripts allow-same-origin' as any}
sandbox=${IFRAME_SANDBOX_POLICY}
allow=${this.getIframeAllowPolicy(this.normalizedBaseURL)}
@load=${(event: Event) => this.handleIframeLoad(event)}
@unload=${() => this.postMessageHandler?.destroy()}
style=${this.visibility === 'hidden'
? 'display: none;'
: 'display: block;'}
? 'display: none;'
: 'display: block;'}
></iframe>
`;
}
Expand Down
2 changes: 1 addition & 1 deletion src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export interface CortiEmbeddedAPI {
* @param credentials Authentication credentials to store
* @returns Promise that resolves when credentials are set
*/
setCredentials(credentials: { password: string }): Promise<void>;
setCredentials(credentials: SetCredentialsPayload): Promise<void>;

/**
* Show the embedded UI
Expand Down
58 changes: 47 additions & 11 deletions src/utils/PostMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,41 @@ export interface PostMessageHandlerCallbacks {
requestTimeout?: number;
}

interface PostMessageHandlerError {
message: string;
code?: string;
details?: unknown;
}

interface PendingRequest {
resolve: (value: EmbeddedResponse) => void;
reject: (reason: PostMessageHandlerError) => void;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

function isEmbeddedEventMessage(value: unknown): value is AnyEvent {
return (
isRecord(value) &&
value.type === 'CORTI_EMBEDDED_EVENT' &&
typeof value.event === 'string'
);
}

function isEmbeddedResponseMessage(value: unknown): value is EmbeddedResponse {
return (
isRecord(value) &&
value.type === 'CORTI_EMBEDDED_RESPONSE' &&
typeof value.action === 'string' &&
typeof value.requestId === 'string' &&
typeof value.success === 'boolean'
);
}

export class PostMessageHandler {
private pendingRequests = new Map<
string,
{ resolve: (value: any) => void; reject: (reason: any) => void }
>();
private pendingRequests = new Map<string, PendingRequest>();

private messageListener: ((event: MessageEvent) => void) | null = null;

Expand Down Expand Up @@ -60,13 +90,16 @@ export class PostMessageHandler {
const { data } = event;

// Check for Corti embedded events
if (data?.type === 'CORTI_EMBEDDED_EVENT') {
if (isEmbeddedEventMessage(data)) {
this.handleEvent(data);
return;
}

// Check if this is a response to a pending request
if (data.requestId && this.pendingRequests.has(data.requestId)) {
if (
isEmbeddedResponseMessage(data) &&
this.pendingRequests.has(data.requestId)
) {
this.handleResponse(data);
}
};
Expand All @@ -75,15 +108,18 @@ export class PostMessageHandler {
}

private handleEvent(eventData: AnyEvent): void {
const eventType = (eventData as any).event;
const eventType = eventData.event;
const { payload } = eventData;

// Only 'embedded.ready' signals that the iframe is ready to receive messages
if (eventType === 'embedded.ready') {
this.isReady = true;

// Store and validate the protocol version from the ready payload
const version = (payload as any)?.version;
const version =
isRecord(payload) && typeof payload.version === 'string'
? payload.version
: undefined;
if (typeof version === 'string') {
this._protocolVersion = version;
if (version !== PostMessageHandler.SUPPORTED_PROTOCOL_VERSION) {
Expand Down Expand Up @@ -126,7 +162,7 @@ export class PostMessageHandler {
});
}

private handleResponse(data: any): void {
private handleResponse(data: EmbeddedResponse): void {
const pendingRequest = this.pendingRequests.get(data.requestId);
if (pendingRequest) {
const { resolve, reject } = pendingRequest;
Expand Down Expand Up @@ -245,11 +281,11 @@ export class PostMessageHandler {
}, effectiveTimeout);

this.pendingRequests.set(requestId, {
resolve: (value: any) => {
resolve: value => {
clearTimeout(timeoutId);
resolve(value);
},
reject: (reason: any) => {
reject: reason => {
clearTimeout(timeoutId);
reject(reason);
},
Expand Down
61 changes: 49 additions & 12 deletions src/utils/errorFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,34 @@ interface ValidationError {
message?: string;
}

type ValidationErrorLike =
| ({ expected: unknown } & Record<string, unknown>)
| ({ code: unknown } & Record<string, unknown>)
| ({ path: unknown } & Record<string, unknown>)
| ({ message: unknown } & Record<string, unknown>);

interface FormattedError {
message: string;
code?: string;
details?: unknown;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

function toStringIfFiniteNumberOrString(value: unknown): string | undefined {
if (typeof value === 'string') {
return value;
}

if (typeof value === 'number' && Number.isFinite(value)) {
return String(value);
}

return undefined;
}

/**
* Attempts to parse a JSON string safely
*/
Expand All @@ -34,7 +56,7 @@ function extractStatusCode(message: string): string | undefined {
/**
* Checks if an object looks like a validation error
*/
function isValidationError(obj: unknown): obj is ValidationError {
function isValidationError(obj: unknown): obj is ValidationErrorLike {
return (
typeof obj === 'object' &&
obj !== null &&
Expand Down Expand Up @@ -138,13 +160,17 @@ export function formatError(
}

// Handle error objects
if (typeof error === 'object') {
const errorObj = error as any;
if (isRecord(error)) {
const errorObj = error;
const objectMessage =
typeof errorObj.message === 'string' ? errorObj.message : undefined;
const objectCode = toStringIfFiniteNumberOrString(errorObj.code);
const objectStatus = toStringIfFiniteNumberOrString(errorObj.status);

// Handle objects with message and details structure (your original case)
if (errorObj.message && typeof errorObj.message === 'string') {
let formattedMessage = errorObj.message;
let { code } = errorObj;
if (objectMessage) {
let formattedMessage = objectMessage;
let code = objectCode;

// If no code is provided, try to extract it from the message
if (!code) {
Expand All @@ -153,7 +179,7 @@ export function formatError(

// Try to enhance the message with parsed details
if (
errorObj.details?.message &&
isRecord(errorObj.details) &&
typeof errorObj.details.message === 'string'
) {
const parsedDetails = tryParseJson(errorObj.details.message);
Expand All @@ -178,21 +204,32 @@ export function formatError(

// Handle single validation error objects
if (isValidationError(errorObj)) {
const validationError: ValidationError = {
expected:
typeof errorObj.expected === 'string' ? errorObj.expected : undefined,
code: objectCode,
path:
Array.isArray(errorObj.path) &&
errorObj.path.every(pathPart => typeof pathPart === 'string')
? errorObj.path
: undefined,
message: objectMessage,
};
return {
message: formatValidationError(errorObj),
code: errorObj.code,
message: formatValidationError(validationError),
code: validationError.code,
details: error,
};
}

// Handle objects that might have useful error info
if (errorObj.error || errorObj.message || errorObj.detail) {
const message = errorObj.error || errorObj.message || errorObj.detail;
if (errorObj.error || errorObj.detail || objectStatus || objectCode) {
const message = errorObj.error ?? objectMessage ?? errorObj.detail;
const messageStr =
typeof message === 'string' ? message : fallbackMessage;

// Try to get code from various properties, or extract from message
let code = errorObj.code || errorObj.status;
let code = objectCode ?? objectStatus;
if (!code && typeof message === 'string') {
code = extractStatusCode(message);
}
Expand Down
41 changes: 41 additions & 0 deletions test/error-formatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect } from '@open-wc/testing';
import { formatError } from '../src/utils/errorFormatter.js';

describe('formatError', () => {
it('formats object errors without any-based narrowing', () => {
const result = formatError({
message: '400 Bad Request',
details: {
message: JSON.stringify([
{ path: ['payload', 'path'], expected: 'string' },
]),
},
});

expect(result).to.deep.equal({
message: 'Invalid payload.path: expected string',
code: '400',
details: {
message: JSON.stringify([
{ path: ['payload', 'path'], expected: 'string' },
]),
},
});
});

it('formats status codes from generic object errors', () => {
const result = formatError({
error: 'Unauthorized',
status: 401,
});

expect(result).to.deep.equal({
message: 'Unauthorized',
code: '401',
details: {
error: 'Unauthorized',
status: 401,
},
});
});
});
Loading