From 99e5c354b4a7877a95a7fc25868d48730ab21c3a Mon Sep 17 00:00:00 2001 From: skyflow-bharti Date: Tue, 22 Sep 2026 12:47:00 +0530 Subject: [PATCH 1/7] SK-2449 zip render support --- CHANGELOG.md | 9 + core/constants.ts | 81 ++ core/types/index.ts | 31 + core/utils/constants.ts | 28 + core/utils/logs.ts | 14 + packages/skyflow-js/README.md | 71 ++ packages/skyflow-js/package.json | 1 + packages/skyflow-js/samples/README.md | 14 + .../using-script-tag/zip-file-render.html | 186 ++++ packages/skyflow-js/src/api-utils/reveal.ts | 2 + .../reveal/composable-reveal-element.ts | 18 +- .../reveal/composable-reveal-internal.ts | 34 +- .../src/external/reveal/reveal-container.ts | 4 + packages/skyflow-js/src/index-node.ts | 3 + .../src/internal/reveal/reveal-frame.ts | 516 ++++++++++- packages/skyflow-js/src/skyflow.ts | 11 + .../skyflow-js/src/utils/validators/index.ts | 79 ++ .../skyflow-js/tests/api-utils/reveal.test.js | 11 + .../reveal/reveal-composable-element.test.js | 27 + .../reveal/reveal-composable-internal.test.js | 101 +++ .../internal/reveal/reveal-frame-zip.test.js | 848 ++++++++++++++++++ packages/skyflow-js/tests/skyflow.test.js | 14 +- .../skyflow-js/tests/utils/validators.test.js | 73 +- 23 files changed, 2154 insertions(+), 22 deletions(-) create mode 100644 packages/skyflow-js/samples/using-script-tag/zip-file-render.html create mode 100644 packages/skyflow-js/tests/core/internal/reveal/reveal-frame-zip.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 96e9dfbf7..bfc711bac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this project will be documented in this file. +## [Unreleased] +### Added +- Render zip files in composable reveal file elements (list-detail layout with per-file preview), opt-in via `renderFile({ zipRender: true })` +- `renderFile(options)` now accepts `zipRender`, `layout`, `allowDownload`, `autoSelectFirst` and `labelMode` options (`IRenderOptions`) +- `downloadCurrentFile()` on composable reveal elements to download the previewed file from a rendered zip (requires `allowDownload: true`) +- Zip rendering hardening: lazy per-file extraction, exact zip MIME detection, origin-checked download requests, and download blocked for unsafe file types +- `zipNavStyles`, `zipPanelStyles` and `zipNavListItemStyles` options for the zip render layout +- `unZippedFilesMetadata` in the `renderFile()` success response for zip files + ## [2.6.0] - 2025-11-19 ### Added - Composable Reveal elements diff --git a/core/constants.ts b/core/constants.ts index aed457edb..b6b0aae91 100644 --- a/core/constants.ts +++ b/core/constants.ts @@ -17,6 +17,16 @@ import successIcon from '../assets/path.svg'; import dropDownIcon from '../assets/drop-down.svg'; import cartesBancairesIcon from '../assets/carter-banceris.svg'; +export const DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE = 'This file type is not supported for preview.'; +export const ZIP_EMPTY_ARCHIVE_MESSAGE = 'No files found in the ZIP archive.'; +export const ZIP_PREVIEW_LOADING_MESSAGE = 'Loading preview...'; + +// MIME types recognised as a zip archive for `zipRender`. Deliberately exact: +// 'application/gzip' also contains "zip" but is not a zip. +export const ZIP_ARCHIVE_MIME_TYPES = [ + 'application/zip', 'application/x-zip-compressed', 'application/x-zip', +]; + export const SESSION_ID = 'session_id'; export const SKY_METADATA_HEADER = 'sky-metadata'; // Metadata object KEY under which the SDK version string is stored (serialized into @@ -109,6 +119,7 @@ export const ELEMENT_EVENTS_TO_CLIENT = { }; export const ELEMENT_EVENTS_TO_IFRAME = { + REVEAL_ELEMENT_DOWNLOAD_CURRENT_FILE: 'REVEAL_ELEMENT_DOWNLOAD_CURRENT_FILE', MULTIPLE_UPLOAD_FILES_RESPONSE: 'MULTIPLE_UPLOAD_FILES_RESPONSE', RENDER_MOUNTED: 'RENDER_MOUNTED', HEIGHT_CALLBACK: 'HEIGHT_CALLBACK', @@ -526,6 +537,76 @@ export const RENDER_ELEMENT_IMAGE_STYLES = { }, }; +// Zip file render (list-detail layout): container, left file list, right preview panel. +export const ZIP_CONTAINER_STYLES = { + [STYLE_TYPE.BASE]: { + display: 'flex', + 'flex-direction': 'row', + height: '100%', + width: '100%', + }, +}; + +export const ZIP_NAV_STYLES = { + [STYLE_TYPE.BASE]: { + width: '30%', + height: '100%', + borderRight: '1px solid #ccc', + boxSizing: 'border-box' as const, + margin: '4px', + overflow: 'auto' as const, + }, +}; + +export const ZIP_NAV_LIST_ITEM_STYLES = { + [STYLE_TYPE.BASE]: { + alignItems: 'center', + padding: '8px', + margin: '4px', + cursor: 'pointer', + borderRadius: '4px', + boxSizing: 'border-box' as const, + backgroundColor: '#f9f9f9', + }, + [STYLE_TYPE.FOCUS]: { + alignItems: 'center', + padding: '8px', + margin: '4px', + cursor: 'pointer', + borderRadius: '4px', + boxSizing: 'border-box' as const, + backgroundColor: '#f9f9f9', + border: '2px solid #b0b3b8ff', + }, +}; + +// In-pane messages for the zip viewer ("Loading preview...", unsupported / +// failed preview). The error styles are the default under the element's +// errorTextStyles.base. +export const ZIP_PREVIEW_MESSAGE_STYLES = { + [STYLE_TYPE.BASE]: { + padding: '10px', + }, +}; +export const ZIP_PREVIEW_ERROR_STYLES = { + [STYLE_TYPE.BASE]: { + color: 'red', + padding: '10px', + }, +}; + +export const ZIP_PANEL_STYLES = { + [STYLE_TYPE.BASE]: { + width: '70%', + height: '100%', + boxSizing: 'border-box' as const, + padding: '10px 10px 10px 10px', + justifyContent: 'center', + alignItems: 'center', + overflow: 'auto', + }, +}; + export const REVEAL_ELEMENT_LABEL_DEFAULT_STYLES = { [STYLE_TYPE.BASE]: { 'margin-bottom': '4px', diff --git a/core/types/index.ts b/core/types/index.ts index fc00a02a5..3a2af0e78 100644 --- a/core/types/index.ts +++ b/core/types/index.ts @@ -128,10 +128,41 @@ export interface IRevealResponseType { records?: Record[]; errors?: Record[]; } +export interface IUnzippedFileMetadata { + name: string; + fileSize: number; + type: string; +} + +// Options for `renderFile(options)`. Only consumed when the stored file is a zip +// archive and `zipRender` is true; a plain file render ignores them. Kept off the +// element record (which carries data + styles) because these are render-time +// behaviour flags, matching the "Zip render GA" design doc. +export enum ZipRenderLayout { + LIST_DETAIL = 'listDetail', +} +export enum ZipLabelMode { + BASENAME = 'basename', + PATH = 'path', +} +export interface IRenderOptions { + // Opt in to unzipping a .zip archive and rendering its files. Default false. + zipRender?: boolean; + // Layout for the zip viewer. Only 'listDetail' is supported. Default 'listDetail'. + layout?: ZipRenderLayout; + // Whether `downloadCurrentFile()` is permitted for the previewed file. Default false. + allowDownload?: boolean; + // Auto-select and preview the first file after unzipping. Default true. + autoSelectFirst?: boolean; + // File-list label: 'basename' (full path on hover, nearest parent folder added + // when two files share a basename) or 'path' (full path). Default 'basename'. + labelMode?: ZipLabelMode; +} export interface IRenderResponseType { fields?: Record errors?: Record fileMetadata?: Record + unZippedFilesMetadata?: IUnzippedFileMetadata[] } export interface IDetokenizeInput { diff --git a/core/utils/constants.ts b/core/utils/constants.ts index 4f5a7b458..7b320a528 100644 --- a/core/utils/constants.ts +++ b/core/utils/constants.ts @@ -4,6 +4,34 @@ Copyright (c) 2022 Skyflow, Inc. import logs from '@core/utils/logs'; const SKYFLOW_ERROR_CODE = { + FAILED_TO_UNZIP_FILES: { + code: 400, + description: logs.errorLogs.FAILED_TO_UNZIP_FILES, + }, + INVALID_RENDER_OPTIONS: { + code: 400, + description: logs.errorLogs.INVALID_RENDER_OPTIONS, + }, + INVALID_ZIP_RENDER_OPTION: { + code: 400, + description: logs.errorLogs.INVALID_ZIP_RENDER_OPTION, + }, + INVALID_LAYOUT_OPTION: { + code: 400, + description: logs.errorLogs.INVALID_LAYOUT_OPTION, + }, + INVALID_ALLOW_DOWNLOAD_OPTION: { + code: 400, + description: logs.errorLogs.INVALID_ALLOW_DOWNLOAD_OPTION, + }, + INVALID_AUTO_SELECT_FIRST_OPTION: { + code: 400, + description: logs.errorLogs.INVALID_AUTO_SELECT_FIRST_OPTION, + }, + INVALID_LABEL_MODE_OPTION: { + code: 400, + description: logs.errorLogs.INVALID_LABEL_MODE_OPTION, + }, MULTI_FILE_NOT_SUPPORTED: { code: 400, description: logs.errorLogs.MULTI_FILE_NOT_SUPPORTED, diff --git a/core/utils/logs.ts b/core/utils/logs.ts index 575833195..8894ca12a 100644 --- a/core/utils/logs.ts +++ b/core/utils/logs.ts @@ -4,6 +4,8 @@ Copyright (c) 2022 Skyflow, Inc. const logs = { infoLogs: { + FILE_DOWNLOADED: '%s1 - File %s2 downloaded successfully.', + FILES_UNZIPPED_SUCCESSFULLY: '%s1 - Files unzipped successfully for skyflowId: %s2', INITIALIZE_CLIENT: '%s1 - Initializing skyflow client.', CLIENT_INITIALIZED: '%s1 - Initialized skyflow client successfully.', @@ -97,6 +99,17 @@ const logs = { VALIDATE_GET_BY_ID_INPUT: '%s1 - Validating getByID input.', }, errorLogs: { + FAILED_DOWNLOAD_FILE: 'Failed to download file. Please try again later.', + FAILED_TO_UNZIP_FILES: 'Failed to unzip files. Please try again later.', + DOWNLOAD_NOT_ALLOWED: 'Download is not allowed for this element. Pass allowDownload: true in renderFile() options to enable downloadCurrentFile().', + DOWNLOAD_BLOCKED_DANGEROUS_FILE: 'Download blocked. The selected file type is not allowed.', + ZIP_PREVIEW_FAILED: '%s1 - Failed to preview %s2 from the zip archive.', + INVALID_RENDER_OPTIONS: 'Validation error. Invalid options passed to renderFile(). Specify an object.', + INVALID_ZIP_RENDER_OPTION: 'Validation error. Invalid \'zipRender\' key in renderFile() options. Specify a value of type boolean instead.', + INVALID_LAYOUT_OPTION: 'Validation error. Invalid \'layout\' key in renderFile() options. Supported value: \'listDetail\'.', + INVALID_ALLOW_DOWNLOAD_OPTION: 'Validation error. Invalid \'allowDownload\' key in renderFile() options. Specify a value of type boolean instead.', + INVALID_AUTO_SELECT_FIRST_OPTION: 'Validation error. Invalid \'autoSelectFirst\' key in renderFile() options. Specify a value of type boolean instead.', + INVALID_LABEL_MODE_OPTION: 'Validation error. Invalid \'labelMode\' key in renderFile() options. Supported values: \'basename\', \'path\'.', MULTI_FILE_NOT_SUPPORTED: 'Multi file upload is only supported in MULT_FILE_INPUT element in composable container. Please use MULT_FILE_INPUT element for multi file upload.', INVALID_REVEAL_COMPOSABLE_INPUT: 'Reveal composable input is invalid. Please provide a valid input.', NO_ELEMENTS_IN_COLLECT: 'Validation error. No elements found in collect container', @@ -336,6 +349,7 @@ const logs = { SIGNED_TOKEN_DECODE_FAILED: 'Failed to decode the signed token. Please check the signed token and try again.', }, warnLogs: { + INVALID_RENDER_OPTION_IGNORED: "Ignoring invalid renderFile() option '%s1'. Using its default value.", INVALID_EXPIRATION_DATE_FORMAT: 'EXPIRATION_DATE format must be in one of %s1, the format is set to default MM/YY', INVALID_EXPIRATION_YEAR_FORMAT: 'EXPIRATION_YEAR format must be in one of %s1, the format is set to default YY', UNABLE_TO_SET_VALUE_IN_PROD_ENV: '%s1 setValue() cannot invoked while in PROD env. It is Not Recommeded', diff --git a/packages/skyflow-js/README.md b/packages/skyflow-js/README.md index 7c6a2c263..05639c801 100644 --- a/packages/skyflow-js/README.md +++ b/packages/skyflow-js/README.md @@ -4319,6 +4319,77 @@ fileElement }); ``` +### Render a zip file +Zip rendering is opt-in. If the file stored in the vault is a `.zip` archive and you call `renderFile({ zipRender: true })`, the SDK unzips it in the browser and renders a list-detail layout: the extracted files are listed on the left and the selected file is previewed on the right. Without `zipRender: true` the archive is rendered as a single file, exactly as before. + +Only the archive's file list is read up front; each file is extracted the first time it is previewed or downloaded, so large archives stay responsive. Images are previewed in an `` tag, video and audio in their native players, and everything else (such as PDF) through the browser's `` viewer, so which formats display depends on the browser. Files that could execute in the browser (scripts, HTML, executables, nested archives) are listed but not previewed; the message "This file type is not supported for preview." is shown instead, and such files cannot be downloaded through `downloadCurrentFile()`. + +`renderFile()` accepts an optional options object. All keys are optional: + +```javascript +fileElement.renderFile({ + zipRender: true, // Unzip and render the archive contents. Default: false. + layout: Skyflow.ZipRenderLayout.LIST_DETAIL, // Zip viewer layout. Skyflow.ZipRenderLayout enum. Only LIST_DETAIL is supported. Default: LIST_DETAIL. + allowDownload: false, // Allow downloadCurrentFile() for the previewed file. Default: false. + autoSelectFirst: true, // Select and preview the first file automatically. Default: true. + labelMode: Skyflow.ZipLabelMode.BASENAME, // File list labels. Skyflow.ZipLabelMode enum: BASENAME or PATH. Default: BASENAME. +}); +``` + +`Skyflow.ZipRenderLayout` and `Skyflow.ZipLabelMode` are exported as `ZipRenderLayout` and `ZipLabelMode` from the npm package. For example, to show full archive paths in the file list: + +```javascript +fileElement.renderFile({ + zipRender: true, + layout: Skyflow.ZipRenderLayout.LIST_DETAIL, + labelMode: Skyflow.ZipLabelMode.PATH, +}); +``` + +With `Skyflow.ZipLabelMode.BASENAME` the list shows each file's name and the full path on hover; if two files share a name, the nearest parent folder is prepended (for example `2024/report.pdf` and `2025/report.pdf`). With `Skyflow.ZipLabelMode.PATH` the full archive path is shown. + +The zip layout accepts three additional, optional style objects on the element. Each accepts only the `base` variant (and `focus` for list items): + +```javascript +const fileElement = { + skyflowID: 'string', + column: 'string', + table: 'string', + inputStyles: { base: { height: '400px' } }, // Optional, applied to the zip layout container. + zipNavStyles: { base: { width: '30%' } }, // Optional, applied to the file list panel. + zipNavListItemStyles: { // Optional, applied to each file in the list. + base: { padding: '8px' }, + focus: { border: '2px solid #b0b3b8' }, // Applied to the selected file. + }, + zipPanelStyles: { base: { width: '70%' } }, // Optional, applied to the preview panel. +}; +``` + +Messages shown inside the preview panel, such as "This file type is not supported for preview.", use the element's `errorTextStyles.base`. An element that already sets `errorTextStyles` therefore gets consistent error styling in the zip viewer with no extra configuration. + +For zip files, the `renderFile()` success response also includes `unZippedFilesMetadata`, one entry per extracted file: + +```javascript +{ + success: { + skyflow_id: 'string', + column: 'string', + fileMetadata: { ... }, + unZippedFilesMetadata: [ + { name: 'photo.png', fileSize: 20480, type: 'image/png' }, + { name: 'doc.pdf', fileSize: 102400, type: 'application/pdf' }, + ], + }, +} +``` + +#### Download the currently previewed file +For composable reveal elements rendering a zip file with `allowDownload: true`, call `downloadCurrentFile()` to download the file currently selected in the preview. Download is off by default; without `allowDownload: true` the call is ignored and an error is logged. + +```javascript +fileElement.downloadCurrentFile(); +``` + ### End to end example of file render ```javascript // Step 1. diff --git a/packages/skyflow-js/package.json b/packages/skyflow-js/package.json index 050f5699a..f33ef0821 100644 --- a/packages/skyflow-js/package.json +++ b/packages/skyflow-js/package.json @@ -40,6 +40,7 @@ "jquery-mask-plugin": "1.14.16", "jss": "10.10.0", "jss-preset-default": "10.10.0", + "jszip": "3.10.1", "jwt-decode": "3.1.2", "lodash": "4.18.1", "mime": "3.0.0", diff --git a/packages/skyflow-js/samples/README.md b/packages/skyflow-js/samples/README.md index 39dac5fc2..4392b354f 100644 --- a/packages/skyflow-js/samples/README.md +++ b/packages/skyflow-js/samples/README.md @@ -161,6 +161,20 @@ This sample shows you how to upload files to your vault. From your browser, navigate to `http://localhost:8000/` to view the page. +### Zip file render +This sample shows you how to render a `.zip` file stored in your vault with a composable reveal element. The archive is unzipped in the browser and shown as a file list with a preview of the selected file, and the previewed file can be downloaded with `downloadCurrentFile()`. + +#### Configure +1. Navigate to the using-script-tag and open the zip-file-render.html file. +2. Replace **** and **VAULT URL** with your vault-specific values. +3. Replace **** with `http://localhost:3000/` +4. Replace ****, **** and **** with the record that holds the zip file. + +#### Run the following command. + npx live-server zip-file-render.html --port=8000 + +From your browser, navigate to `http://localhost:8000/` to view the page. + ## Using npm ### collect-element-listeners diff --git a/packages/skyflow-js/samples/using-script-tag/zip-file-render.html b/packages/skyflow-js/samples/using-script-tag/zip-file-render.html new file mode 100644 index 000000000..b38806c3c --- /dev/null +++ b/packages/skyflow-js/samples/using-script-tag/zip-file-render.html @@ -0,0 +1,186 @@ + + + + + + + Skyflow Zip File Render + + + + +

Render a zip file

+

+ Zip rendering is only available on composable reveal elements. The element unzips the archive + in the browser and shows the file list on the left and a preview of the selected file on the right. +

+ +
+
+
+ +
+ + +
+ +

+
+    
+  
+
diff --git a/packages/skyflow-js/src/api-utils/reveal.ts b/packages/skyflow-js/src/api-utils/reveal.ts
index 01e01700a..5f264b930 100644
--- a/packages/skyflow-js/src/api-utils/reveal.ts
+++ b/packages/skyflow-js/src/api-utils/reveal.ts
@@ -339,6 +339,8 @@ export const formatForRenderClient = (response: IRenderResponseType, column: str
       skyflow_id: response.fields.skyflow_id,
       column,
       fileMetadata: response.fileMetadata,
+      ...(response.unZippedFilesMetadata
+        ? { unZippedFilesMetadata: response.unZippedFilesMetadata } : {}),
     };
     formattedResponse.success = successRecord;
   } else if (response.errors) {
diff --git a/packages/skyflow-js/src/external/reveal/composable-reveal-element.ts b/packages/skyflow-js/src/external/reveal/composable-reveal-element.ts
index fb6431702..ac229d467 100644
--- a/packages/skyflow-js/src/external/reveal/composable-reveal-element.ts
+++ b/packages/skyflow-js/src/external/reveal/composable-reveal-element.ts
@@ -1,17 +1,20 @@
 import { ELEMENT_EVENTS_TO_IFRAME } from '@core/constants';
 import CoreComposableRevealElement from '@core/external/reveal/composable-reveal-element';
-import { RenderFileResponse } from '../../utils/common';
+import { IRenderOptions, RenderFileResponse } from '../../utils/common';
 import { IRevealElementInput } from './reveal-container';
 
 // privacyDB composable reveal element: the shared @core base bound to privacyDB's
 // reveal-input shape, plus the file-render request (flowDB has no renderFile).
 class ComposableRevealElement extends CoreComposableRevealElement {
-  renderFile(): Promise {
+  // `options` are render-time behaviour flags (zip opt-in, layout, download policy,
+  // ...). They travel with the request to the internal element, which validates
+  // them and forwards them to the reveal iframe.
+  renderFile(options?: IRenderOptions): Promise {
     return new Promise((resolve, reject) => {
       // eslint-disable-next-line no-underscore-dangle
       this.eventEmitter?._emit?.(
         `${ELEMENT_EVENTS_TO_IFRAME.RENDER_FILE_REQUEST}:${this.elementName}`,
-        {},
+        { options },
         (response) => {
           if (response?.errors) {
             reject(response);
@@ -24,6 +27,15 @@ class ComposableRevealElement extends CoreComposableRevealElement {
-        this.renderFile(element)?.then((response) => {
+        this.renderFile(element, data?.options)?.then((response) => {
           callback?.(response);
         })?.catch((error) => {
           callback?.({ error });
         });
       },
     );
+    this.eventEmitter?.on(
+      `${ELEMENT_EVENTS_TO_IFRAME?.REVEAL_ELEMENT_DOWNLOAD_CURRENT_FILE}:${element?.name}`,
+      () => {
+        this.downloadCurrentFile(element?.name as string);
+      },
+    );
+  }
+
+  // Forwards the zip "download current file" request to the reveal iframe.
+  downloadCurrentFile(elementName: string): void {
+    this.emitEvent(
+      ELEMENT_EVENTS_TO_IFRAME.REVEAL_ELEMENT_DOWNLOAD_CURRENT_FILE + elementName,
+      {
+        name: ELEMENT_EVENTS_TO_IFRAME.REVEAL_ELEMENT_DOWNLOAD_CURRENT_FILE + elementName,
+      },
+    );
   }
 
   // Resolves the bearer token, emits the RENDER_FILE call, and wires its response
@@ -61,6 +79,7 @@ class ComposableRevealInternalElement
   private sendRenderFileCall(
     recordData: any,
     altText: string,
+    renderOptions: IRenderOptions,
     resolve: (value: RenderFileResponse) => void,
     reject: (reason?: any) => void,
   ): void {
@@ -75,6 +94,7 @@ class ComposableRevealInternalElement
             type: REVEAL_TYPES.RENDER_FILE,
             containerId: this.containerId,
             iframeName: recordData.name,
+            renderOptions,
           },
           clientConfig: {
             vaultURL: this.metaData.clientJSON.config.vaultURL,
@@ -118,7 +138,8 @@ class ComposableRevealInternalElement
     });
   }
 
-  renderFile(recordData: any): Promise {
+  renderFile(recordData: any, options?: IRenderOptions): Promise {
+    const renderOptions: IRenderOptions = options ?? {};
     let altText = '';
     if (Object.prototype.hasOwnProperty.call(recordData, 'altText')) {
       altText = recordData.altText;
@@ -132,14 +153,15 @@ class ComposableRevealInternalElement
           MessageType.LOG,
           loglevel);
         validateRenderElementRecord(recordData);
+        validateRenderOptions(options);
         if (this.isComposableFrameReady) {
-          this.sendRenderFileCall(recordData, altText, resolve, reject);
+          this.sendRenderFileCall(recordData, altText, renderOptions, resolve, reject);
         } else {
           window.addEventListener('message', (event) => {
             if (event.data.type === ELEMENT_EVENTS_TO_IFRAME.RENDER_MOUNTED
                     + recordData?.name) {
               this.markMounted();
-              this.sendRenderFileCall(recordData, altText, resolve, reject);
+              this.sendRenderFileCall(recordData, altText, renderOptions, resolve, reject);
             }
           });
         }
diff --git a/packages/skyflow-js/src/external/reveal/reveal-container.ts b/packages/skyflow-js/src/external/reveal/reveal-container.ts
index c3562a20a..e51eb321b 100644
--- a/packages/skyflow-js/src/external/reveal/reveal-container.ts
+++ b/packages/skyflow-js/src/external/reveal/reveal-container.ts
@@ -26,6 +26,10 @@ export interface IRevealElementInput {
   labelStyles?: object;
   altText?: string;
   errorTextStyles?: object;
+  // Zip file render (list-detail layout) styles.
+  zipNavStyles?: object;
+  zipPanelStyles?: object;
+  zipNavListItemStyles?: object;
 }
 
 // Relocated to @core/types; re-exported here under the same public name.
diff --git a/packages/skyflow-js/src/index-node.ts b/packages/skyflow-js/src/index-node.ts
index 70b34264e..bd38c8ca4 100644
--- a/packages/skyflow-js/src/index-node.ts
+++ b/packages/skyflow-js/src/index-node.ts
@@ -13,6 +13,8 @@ export {
   LogLevel,
   Env,
   ErrorType,
+  ZipLabelMode,
+  ZipRenderLayout,
 } from './utils/common';
 
 export type {
@@ -50,6 +52,7 @@ export type {
   IRevealRecord as RevealRecord,
   RevealResponse,
   RenderFileResponse,
+  IRenderOptions as RenderOptions,
   IValidationRule as ValidationRule,
   ElementState,
   ErrorMessages,
diff --git a/packages/skyflow-js/src/internal/reveal/reveal-frame.ts b/packages/skyflow-js/src/internal/reveal/reveal-frame.ts
index 9060919b0..e0d1ed020 100644
--- a/packages/skyflow-js/src/internal/reveal/reveal-frame.ts
+++ b/packages/skyflow-js/src/internal/reveal/reveal-frame.ts
@@ -7,6 +7,7 @@ Copyright (c) 2022 Skyflow, Inc.
 // message paths, so this DOM/file-upload-adjacent surface stays out of flowDB's
 // token-only bundle.
 import bus from 'framebus';
+import JSZip from 'jszip';
 import getCssClassesFromJss from '@core/libs/jss-styles';
 import {
   ELEMENT_EVENTS_TO_IFRAME,
@@ -15,20 +16,83 @@ import {
   DEFAULT_FILE_RENDER_ERROR,
   ELEMENT_EVENTS_TO_CLIENT,
   REVEAL_TYPES,
+  ZIP_CONTAINER_STYLES,
+  ZIP_NAV_STYLES,
+  ZIP_NAV_LIST_ITEM_STYLES,
+  ZIP_PANEL_STYLES,
+  ZIP_PREVIEW_MESSAGE_STYLES,
+  ZIP_PREVIEW_ERROR_STYLES,
+  DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE,
+  ZIP_EMPTY_ARCHIVE_MESSAGE,
+  ZIP_PREVIEW_LOADING_MESSAGE,
+  ZIP_ARCHIVE_MIME_TYPES,
 } from '@core/constants';
 import properties from '@core/properties';
 import Client from '@core/client';
+import SKYFLOW_ERROR_CODE from '@core/utils/constants';
+import logs from '@core/utils/logs';
 import CoreRevealFrame from '@core/internal/reveal/reveal-frame';
 import {
-  IRenderResponseType, IRevealRecord,
+  IRenderResponseType, IRevealRecord, IUnzippedFileMetadata, MessageType, IRenderOptions,
+  ZipLabelMode, ZipRenderLayout,
 } from '../../utils/common';
 import { formatForRenderClient, getFileURLFromVaultBySkyflowIDComposable } from '../../api-utils/reveal';
+import { parameterizedString, printLog } from '../../utils/logs-helper';
+import { isDangerousFileType } from '../../utils/validators';
 
 const { getType } = require('mime');
 
+const CLASS_NAME = 'RevealFrame';
+
+// Defaults for `renderFile(options)`; zip rendering and download are opt-in.
+const DEFAULT_RENDER_OPTIONS: Required = {
+  zipRender: false,
+  layout: ZipRenderLayout.LIST_DETAIL,
+  allowDownload: false,
+  autoSelectFirst: true,
+  labelMode: ZipLabelMode.BASENAME,
+};
+
+// One entry inside a rendered zip. `entry` is the JSZip handle; bytes are only
+// pulled out (and a blob URL created) the first time the file is previewed or
+// downloaded, so large archives don't get fully inflated into memory up front.
+interface IUnzippedFile {
+  name: string;
+  type: string;
+  size: number;
+  entry: JSZip.JSZipObject;
+  url?: string;
+}
+
 class RevealFrame extends CoreRevealFrame {
   #client!: Client;
 
+  // Zip render (list-detail layout) state.
+  #zipNav?: HTMLDivElement;
+
+  #zipPanel?: HTMLDivElement;
+
+  #filesList: IUnzippedFileMetadata[] = [];
+
+  #unzippedFiles: IUnzippedFile[] = [];
+
+  #currentFile?: IUnzippedFile;
+
+  #downloadListenerRegistered = false;
+
+  // Children of the element container before the zip layout replaced them, so a
+  // later re-render / failure can put the content + error spans back in the DOM.
+  #originalChildren?: Node[];
+
+  // Monotonic id per renderFile call. A render that finishes after a newer one
+  // started must not touch the DOM (stale response / overlapping calls).
+  #renderSeq = 0;
+
+  // Effective `renderFile(options)` for the current render (defaults merged with
+  // what the client passed). Kept on the instance because `downloadCurrentFile`
+  // arrives as a separate message after the render completes.
+  #renderOptions: Required = { ...DEFAULT_RENDER_OPTIONS };
+
   protected registerRenderFileResponseListener(): void {
     // Deferred wrapper (not `this.sub2` directly): the @core base constructor
     // calls this during super(), before this subclass's `sub2` arrow-field is
@@ -46,6 +110,7 @@ class RevealFrame extends CoreRevealFrame {
       if (event?.data?.name === ELEMENT_EVENTS_TO_IFRAME.REVEAL_CALL_REQUESTS + this.name) {
         if (event?.data?.data?.iframeName === this.name
         && event?.data?.data?.type === REVEAL_TYPES.RENDER_FILE) {
+          this.#renderOptions = this.sanitizeRenderOptions(event?.data?.data?.renderOptions);
           this.renderFile(this.record, event?.data?.clientConfig,
             event?.data?.errorMessages)?.then((resolvedResult) => {
             const result = formatForRenderClient(
@@ -83,6 +148,38 @@ class RevealFrame extends CoreRevealFrame {
     }
   }
 
+  // The client-side element already validates renderFile options, but the iframe
+  // only sees a postMessage payload, so it re-checks every key and falls back to
+  // the default for anything unexpected rather than trusting the message.
+  private sanitizeRenderOptions(raw: unknown): Required {
+    const out: Required = { ...DEFAULT_RENDER_OPTIONS };
+    if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
+    const r = raw as Record;
+    const ignore = (key: string) => printLog(
+      parameterizedString(logs.warnLogs.INVALID_RENDER_OPTION_IGNORED, key),
+      MessageType.WARN,
+      this.context?.logLevel,
+    );
+    const bool = (key: 'zipRender' | 'allowDownload' | 'autoSelectFirst') => {
+      if (!(key in r)) return;
+      if (typeof r[key] === 'boolean') out[key] = r[key] as boolean; else ignore(key);
+    };
+    bool('zipRender');
+    bool('allowDownload');
+    bool('autoSelectFirst');
+    if ('layout' in r) {
+      if (Object.values(ZipRenderLayout).includes(r.layout as ZipRenderLayout)) {
+        out.layout = r.layout as ZipRenderLayout;
+      } else ignore('layout');
+    }
+    if ('labelMode' in r) {
+      if (Object.values(ZipLabelMode).includes(r.labelMode as ZipLabelMode)) {
+        out.labelMode = r.labelMode as ZipLabelMode;
+      } else ignore('labelMode');
+    }
+    return out;
+  }
+
   private sub2 = (responseUrl: { iframeName?: string; error?: string; url?: string }) => {
     if (responseUrl.iframeName === this.name) {
       if (Object.prototype.hasOwnProperty.call(responseUrl, 'error') && responseUrl.error === DEFAULT_FILE_RENDER_ERROR) {
@@ -105,6 +202,23 @@ class RevealFrame extends CoreRevealFrame {
     }
   };
 
+  // Styles for the in-pane message. Errors use the element's errorTextStyles.base
+  // (same as every other error text on the element) on top of the SDK default.
+  // The loading message only takes the default.
+  private previewMessageStyles(isWarning: boolean) {
+    if (!isWarning) return ZIP_PREVIEW_MESSAGE_STYLES;
+    return {
+      [STYLE_TYPE.BASE]: {
+        ...ZIP_PREVIEW_ERROR_STYLES[STYLE_TYPE.BASE],
+        ...(this.record?.errorTextStyles?.[STYLE_TYPE.BASE] || {}),
+      },
+    };
+  }
+
+  static isZipMimeType(type: unknown): boolean {
+    return typeof type === 'string' && ZIP_ARCHIVE_MIME_TYPES.includes(type.toLowerCase());
+  }
+
   private renderFile(data: IRevealRecord, clientConfig, customErrorMessages):
   Promise | undefined {
     this.#client = new Client(clientConfig, {
@@ -112,6 +226,11 @@ class RevealFrame extends CoreRevealFrame {
       clientDomain: '',
     });
     this.#client.setErrorMessages(customErrorMessages ?? {});
+    this.#renderSeq += 1;
+    const renderId = this.#renderSeq;
+    // Any previous zip layout is torn down first so "loading" / errors land on the
+    // visible content + error spans, not on nodes detached by the last render.
+    this.resetZipLayout();
     return new Promise((resolve, reject) => {
       try {
         getFileURLFromVaultBySkyflowIDComposable(data, this.#client, clientConfig.authToken)
@@ -120,17 +239,66 @@ class RevealFrame extends CoreRevealFrame {
             if (resolvedResult.fields && data.column) {
               url = resolvedResult.fields[data.column];
             }
-            this.sub2({
-              url,
-              iframeName: this.name,
-            });
-            resolve(resolvedResult);
+            const fileType = this.getExtension(url);
+            const isZip = RevealFrame.isZipMimeType(fileType);
+            if (isZip && this.#renderOptions.zipRender) {
+              this.dataElememt.innerText = '...loading';
+              this.unZipFiles(url).then((files) => {
+                printLog(parameterizedString(logs.infoLogs.FILES_UNZIPPED_SUCCESSFULLY,
+                  CLASS_NAME, this.record?.skyflowID), MessageType.LOG, this.context?.logLevel);
+                const metadata = files.map((f) => ({
+                  name: f.name, fileSize: f.size, type: f.type,
+                }));
+                if (renderId !== this.#renderSeq) {
+                  // A newer renderFile() call owns the DOM now; just answer this one.
+                  resolve({ ...resolvedResult, unZippedFilesMetadata: metadata });
+                  return;
+                }
+                this.#unzippedFiles = files;
+                this.#filesList = metadata;
+                if (files.length > 0) {
+                  this.renderUnZipFile(files);
+                } else {
+                  this.dataElememt.innerText = ZIP_EMPTY_ARCHIVE_MESSAGE;
+                }
+                resolve({
+                  ...resolvedResult,
+                  unZippedFilesMetadata: this.#filesList,
+                });
+              }).catch((zipError) => {
+                printLog(parameterizedString(logs.errorLogs.FAILED_TO_UNZIP_FILES, CLASS_NAME,
+                  zipError), MessageType.ERROR, this.context?.logLevel);
+                if (renderId === this.#renderSeq) {
+                  this.sub2({
+                    error: DEFAULT_FILE_RENDER_ERROR,
+                    iframeName: this.name,
+                  });
+                }
+                reject({
+                  errors: {
+                    error: { ...SKYFLOW_ERROR_CODE.FAILED_TO_UNZIP_FILES },
+                  },
+                });
+              });
+            } else {
+              if (renderId !== this.#renderSeq) {
+                resolve(resolvedResult);
+                return;
+              }
+              this.sub2({
+                url,
+                iframeName: this.name,
+              });
+              resolve(resolvedResult);
+            }
           },
           (rejectedResult) => {
-            this.sub2({
-              error: DEFAULT_FILE_RENDER_ERROR,
-              iframeName: this.name,
-            });
+            if (renderId === this.#renderSeq) {
+              this.sub2({
+                error: DEFAULT_FILE_RENDER_ERROR,
+                iframeName: this.name,
+              });
+            }
             reject(rejectedResult);
           });
       } catch (err) {
@@ -139,6 +307,334 @@ class RevealFrame extends CoreRevealFrame {
     });
   }
 
+  // Downloads the archive and returns one handle per regular file (directories
+  // and OS metadata skipped). Nothing is inflated here; see getFileUrl.
+  // eslint-disable-next-line class-methods-use-this
+  private async unZipFiles(url: string): Promise {
+    const response = await fetch(url);
+    if (!response.ok) throw new Error('Failed to fetch ZIP file');
+    const arrayBuffer = await response.arrayBuffer();
+    const zip = await JSZip.loadAsync(arrayBuffer);
+
+    const validFiles = Object.keys(zip.files).filter(
+      (filename) => !zip.files[filename].dir && !RevealFrame.isSystemFile(filename),
+    );
+    return validFiles.map((filename) => {
+      const entry = zip.files[filename];
+      // JSZip keeps the central-directory size on the (internal) compressed data
+      // object; fall back to 0 when a build doesn't expose it.
+      // eslint-disable-next-line no-underscore-dangle
+      const size = Number((entry as any)?._data?.uncompressedSize) || 0;
+      return {
+        name: filename, type: getType(filename) || '', size, entry,
+      };
+    });
+  }
+
+  // Inflates one entry on first use and caches its blob URL on the file handle.
+  // eslint-disable-next-line class-methods-use-this
+  private async getFileUrl(file: IUnzippedFile): Promise {
+    if (file.url) return file.url;
+    const blob = await file.entry.async('blob');
+    const typed = new Blob([blob], { type: file.type });
+    // Another caller may have raced us; keep the first URL and drop this one.
+    if (file.url) {
+      return file.url;
+    }
+    file.url = URL.createObjectURL(typed);
+    return file.url;
+  }
+
+  private revokeUnzippedFiles() {
+    this.#unzippedFiles.forEach((file) => {
+      if (!file.url) return;
+      try {
+        URL.revokeObjectURL(file.url);
+      } catch {
+        // ignore - already revoked or unsupported
+      }
+      // eslint-disable-next-line no-param-reassign
+      file.url = undefined;
+    });
+    this.#unzippedFiles = [];
+    this.#filesList = [];
+    this.#currentFile = undefined;
+  }
+
+  // Removes the zip layout (if any), revokes its blob URLs and restores the
+  // original content/error nodes so subsequent messages render where users look.
+  private resetZipLayout() {
+    this.revokeUnzippedFiles();
+    if (!this.#zipNav && !this.#zipPanel) return;
+    this.#zipNav = undefined;
+    this.#zipPanel = undefined;
+    this.elementContainer.innerHTML = '';
+    (this.#originalChildren ?? [this.dataElememt]).forEach((node) => {
+      this.elementContainer.appendChild(node);
+    });
+    this.#originalChildren = undefined;
+  }
+
+  static isSystemFile(path: string): boolean {
+    return path.startsWith('__MACOSX/') || path.endsWith('.DS_Store') || path.endsWith('Thumbs.db');
+  }
+
+  static basename(path: string): string {
+    const parts = path.split('/').filter(Boolean);
+    return parts[parts.length - 1] ?? path;
+  }
+
+  // Builds the list-detail layout: file list on the left, preview on the right.
+  private renderUnZipFile(files: IUnzippedFile[]) {
+    this.#zipNav = document.createElement('div');
+    this.#zipPanel = document.createElement('div');
+    this.#zipNav.id = 'zip-nav';
+    this.#zipPanel.id = 'zip-panel';
+    this.#zipNav.className = `SkyflowElement-zip-nav-${STYLE_TYPE.BASE}`;
+    this.#zipPanel.className = `SkyflowElement-zip-panel-${STYLE_TYPE.BASE}`;
+
+    this.registerDownloadListener();
+    this.renderFileList(files);
+
+    const divContainer = document.createElement('div');
+    divContainer.className = `SkyflowElement-zip-container-${STYLE_TYPE.BASE}`;
+    const baseInputStyles = this.record?.inputStyles?.[STYLE_TYPE.BASE];
+    const containerStyles = {
+      [STYLE_TYPE.BASE]: {
+        ...ZIP_CONTAINER_STYLES[STYLE_TYPE.BASE],
+        ...(baseInputStyles || {}),
+      },
+    };
+    divContainer.appendChild(this.#zipNav);
+    divContainer.appendChild(this.#zipPanel);
+    this.#originalChildren = Array.from(this.elementContainer.childNodes);
+    this.elementContainer.innerHTML = '';
+    this.elementContainer.appendChild(divContainer);
+    getCssClassesFromJss(containerStyles, 'zip-container');
+
+    const heightOverride = baseInputStyles?.height ? { height: baseInputStyles.height } : {};
+    const zipNavStyles = {
+      [STYLE_TYPE.BASE]: {
+        ...ZIP_NAV_STYLES[STYLE_TYPE.BASE],
+        ...heightOverride,
+        ...(this.record?.zipNavStyles?.[STYLE_TYPE.BASE] || {}),
+      },
+    };
+    getCssClassesFromJss(zipNavStyles, 'zip-nav');
+
+    const zipPanelStyles = {
+      [STYLE_TYPE.BASE]: {
+        ...ZIP_PANEL_STYLES[STYLE_TYPE.BASE],
+        ...heightOverride,
+        ...(this.record?.zipPanelStyles?.[STYLE_TYPE.BASE] || {}),
+      },
+    };
+    getCssClassesFromJss(zipPanelStyles, 'zip-panel');
+
+    this.emitHeight();
+  }
+
+  private emitHeight() {
+    bus.emit(ELEMENT_EVENTS_TO_CLIENT.HEIGHT + this.name, {
+      height: this.elementContainer.scrollHeight,
+    }, () => {});
+  }
+
+  // Registers the "download current file" message listener once per frame. Only
+  // messages from the client page (same origin check as render requests) count.
+  private registerDownloadListener() {
+    if (this.#downloadListenerRegistered) return;
+    this.#downloadListenerRegistered = true;
+    window.addEventListener('message', (event) => {
+      if (event?.origin !== this.clientDomain) return;
+      if (event?.data?.name
+        !== ELEMENT_EVENTS_TO_IFRAME.REVEAL_ELEMENT_DOWNLOAD_CURRENT_FILE + this.name) {
+        return;
+      }
+      this.downloadCurrentFile();
+    });
+  }
+
+  private async downloadCurrentFile(): Promise {
+    if (!this.#renderOptions.allowDownload) {
+      printLog(logs.errorLogs.DOWNLOAD_NOT_ALLOWED, MessageType.ERROR, this.context?.logLevel);
+      return;
+    }
+    const currentFile = this.#currentFile;
+    if (!currentFile) {
+      printLog(logs.errorLogs.FAILED_DOWNLOAD_FILE, MessageType.ERROR, this.context?.logLevel);
+      return;
+    }
+    if (isDangerousFileType(currentFile)) {
+      printLog(logs.errorLogs.DOWNLOAD_BLOCKED_DANGEROUS_FILE, MessageType.ERROR,
+        this.context?.logLevel);
+      return;
+    }
+    let href: string;
+    try {
+      href = await this.getFileUrl(currentFile);
+    } catch {
+      printLog(logs.errorLogs.FAILED_DOWNLOAD_FILE, MessageType.ERROR, this.context?.logLevel);
+      return;
+    }
+    // Selection may have moved (or the layout been torn down) while inflating.
+    if (this.#currentFile !== currentFile) return;
+    printLog(parameterizedString(logs.infoLogs.FILE_DOWNLOADED, CLASS_NAME, currentFile.name),
+      MessageType.LOG, this.context?.logLevel);
+    const anchor = document.createElement('a');
+    anchor.href = href;
+    anchor.download = RevealFrame.basename(currentFile.name);
+    document.body.appendChild(anchor);
+    anchor.click();
+    document.body.removeChild(anchor);
+  }
+
+  private renderFileList(files: IUnzippedFile[]) {
+    if (!this.#zipNav) return;
+    const ul = document.createElement('ul');
+    ul.style.listStyle = 'none';
+    ul.style.margin = '0';
+    ul.style.padding = '0';
+    ul.style.overflow = 'auto';
+    const customItemStyles = this.record?.zipNavListItemStyles;
+    const items: HTMLLIElement[] = [];
+
+    const selectItem = (index: number) => {
+      items.forEach((el, idx) => {
+        el.classList.remove('active', `SkyflowElement-zip-nav-item${idx}-${STYLE_TYPE.FOCUS}`);
+        el.classList.add(`SkyflowElement-zip-nav-item${idx}-${STYLE_TYPE.BASE}`);
+      });
+      const li = items[index];
+      li.classList.add('active', `SkyflowElement-zip-nav-item${index}-${STYLE_TYPE.FOCUS}`);
+      li.classList.remove(`SkyflowElement-zip-nav-item${index}-${STYLE_TYPE.BASE}`);
+      const focusStyles = {
+        [STYLE_TYPE.FOCUS]: {
+          ...ZIP_NAV_LIST_ITEM_STYLES[STYLE_TYPE.BASE],
+          ...ZIP_NAV_LIST_ITEM_STYLES[STYLE_TYPE.FOCUS],
+          ...(customItemStyles?.[STYLE_TYPE.BASE] || {}),
+          ...(customItemStyles?.[STYLE_TYPE.FOCUS] || {}),
+        },
+      };
+      getCssClassesFromJss(focusStyles, `zip-nav-item${index}`);
+      this.renderZipFile(files[index]);
+    };
+
+    const labels = RevealFrame.buildFileLabels(files, this.#renderOptions.labelMode);
+    files.forEach((file, index) => {
+      const li = document.createElement('li');
+      li.className = `SkyflowElement-zip-nav-item${index}-${STYLE_TYPE.BASE}`;
+      li.textContent = labels[index];
+      // Full path is always available on hover.
+      li.title = file.name;
+      const itemStyles = {
+        [STYLE_TYPE.BASE]: {
+          ...ZIP_NAV_LIST_ITEM_STYLES[STYLE_TYPE.BASE],
+          ...(customItemStyles?.[STYLE_TYPE.BASE] || {}),
+        },
+      };
+      getCssClassesFromJss(itemStyles, `zip-nav-item${index}`);
+      li.addEventListener('click', () => selectItem(index));
+      items.push(li);
+      ul.appendChild(li);
+    });
+
+    this.#zipNav.innerHTML = '';
+    this.#zipNav.appendChild(ul);
+    if (files.length > 0 && this.#renderOptions.autoSelectFirst) selectItem(0);
+  }
+
+  // File-list labels for `labelMode`. 'path' shows the full archive path. 'basename'
+  // shows the last path segment; when two files share a basename the nearest parent
+  // folder is prepended ("a/report.pdf", "b/report.pdf"), and if that still
+  // collides (or there is no parent) the full path is used so labels stay unique.
+  static buildFileLabels(files: { name: string }[], labelMode: ZipLabelMode): string[] {
+    if (labelMode === ZipLabelMode.PATH) return files.map((f) => f.name);
+    const segments = files.map((f) => f.name.split('/').filter(Boolean));
+    const basenames = segments.map((parts, i) => parts[parts.length - 1] ?? files[i].name);
+    const count = (list: string[]) => list.reduce>((acc, key) => {
+      acc[key] = (acc[key] || 0) + 1;
+      return acc;
+    }, {});
+    const baseCounts = count(basenames);
+    const firstPass = segments.map((parts, i) => {
+      const base = basenames[i];
+      if (baseCounts[base] > 1 && parts.length > 1) return `${parts[parts.length - 2]}/${base}`;
+      return base;
+    });
+    const passCounts = count(firstPass);
+    return firstPass.map((label, i) => (passCounts[label] > 1 ? files[i].name : label));
+  }
+
+  // Previews a single extracted file in the right panel. Bytes are inflated on
+  // demand; if the user switches files before that finishes, the late result is
+  // dropped so the panel always matches the highlighted list item.
+  private async renderZipFile(file: IUnzippedFile): Promise {
+    if (!this.#zipPanel) return;
+    this.#currentFile = file;
+    const panel = this.#zipPanel;
+    panel.innerHTML = '';
+
+    const showMessage = (text: string, isWarning: boolean) => {
+      if (this.#currentFile !== file || this.#zipPanel !== panel) return;
+      panel.innerHTML = '';
+      const msg = document.createElement('div');
+      msg.textContent = text;
+      const name = isWarning ? 'zip-preview-error' : 'zip-preview-message';
+      msg.className = `SkyflowElement-${name}-${STYLE_TYPE.BASE}`;
+      getCssClassesFromJss(this.previewMessageStyles(isWarning), name);
+      panel.appendChild(msg);
+    };
+
+    if (isDangerousFileType(file)) {
+      showMessage(DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE, true);
+      return;
+    }
+    showMessage(ZIP_PREVIEW_LOADING_MESSAGE, false);
+
+    // Which types actually display is left to the browser: images in ,
+    // video/audio in their tags, everything else (pdf and the rest) in .
+    const type = (file.type || '').toLowerCase();
+    try {
+      const url = await this.getFileUrl(file);
+      if (this.#currentFile !== file || this.#zipPanel !== panel) return;
+      panel.innerHTML = '';
+      let previewElement: HTMLElement;
+      if (type.startsWith('image/')) {
+        const img = document.createElement('img');
+        img.src = url;
+        img.alt = file.name;
+        img.onerror = () => showMessage(DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE, true);
+        img.onload = () => this.emitHeight();
+        previewElement = img;
+      } else if (type.startsWith('video/')) {
+        const video = document.createElement('video');
+        video.src = url;
+        video.controls = true;
+        video.onerror = () => showMessage(DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE, true);
+        previewElement = video;
+      } else if (type.startsWith('audio/')) {
+        const audio = document.createElement('audio');
+        audio.src = url;
+        audio.controls = true;
+        audio.onerror = () => showMessage(DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE, true);
+        previewElement = audio;
+      } else {
+        // pdf and everything else the browser can embed
+        const embed = document.createElement('embed');
+        embed.src = url;
+        embed.setAttribute('type', type);
+        embed.style.width = '100%';
+        embed.style.height = '100%';
+        previewElement = embed;
+      }
+      panel.appendChild(previewElement);
+    } catch (err) {
+      printLog(parameterizedString(logs.errorLogs.ZIP_PREVIEW_FAILED, CLASS_NAME, file.name),
+        MessageType.ERROR, this.context?.logLevel);
+      showMessage(DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE, true);
+    }
+  }
+
   // eslint-disable-next-line class-methods-use-this
   private getExtension(url: string) {
     try {
diff --git a/packages/skyflow-js/src/skyflow.ts b/packages/skyflow-js/src/skyflow.ts
index 92e2c2cef..18b370df3 100644
--- a/packages/skyflow-js/src/skyflow.ts
+++ b/packages/skyflow-js/src/skyflow.ts
@@ -20,6 +20,8 @@ import {
   ISkyflow,
   RequestMethod,
   SkyflowConfigOptions,
+  ZipLabelMode,
+  ZipRenderLayout,
 } from '@core/types';
 import logs from '@core/utils/logs';
 import RevealContainer from './external/reveal/reveal-container';
@@ -171,5 +173,14 @@ ComposableRevealContainer
   static get ElementType() {
     return ElementType;
   }
+
+  // `renderFile(options)` enums for zip rendering (privacyDB-only, like renderFile).
+  static get ZipLabelMode() {
+    return ZipLabelMode;
+  }
+
+  static get ZipRenderLayout() {
+    return ZipRenderLayout;
+  }
 }
 export default Skyflow;
diff --git a/packages/skyflow-js/src/utils/validators/index.ts b/packages/skyflow-js/src/utils/validators/index.ts
index e4b3f0c99..c81363b52 100644
--- a/packages/skyflow-js/src/utils/validators/index.ts
+++ b/packages/skyflow-js/src/utils/validators/index.ts
@@ -12,6 +12,9 @@ import {
   MessageType,
   CollectElementInput,
   LogLevel,
+  IRenderOptions,
+  ZipLabelMode,
+  ZipRenderLayout,
 } from '../common';
 import { printLog } from '../logs-helper';
 
@@ -123,6 +126,31 @@ export const validateRenderElementRecord = (record: IRevealElementInput) => {
   }
 };
 
+// `renderFile(options)` options. `undefined` is allowed (all defaults); anything
+// else must be a plain object whose known keys have the right type/value.
+export const validateRenderOptions = (options?: IRenderOptions) => {
+  if (options === undefined) return;
+  if (options === null || typeof options !== 'object' || Array.isArray(options)) {
+    throw new SkyflowError(SKYFLOW_ERROR_CODE.INVALID_RENDER_OPTIONS, []);
+  }
+  const has = (key: string) => Object.prototype.hasOwnProperty.call(options, key);
+  if (has('zipRender') && typeof options.zipRender !== 'boolean') {
+    throw new SkyflowError(SKYFLOW_ERROR_CODE.INVALID_ZIP_RENDER_OPTION, []);
+  }
+  if (has('layout') && !Object.values(ZipRenderLayout).includes(options.layout as ZipRenderLayout)) {
+    throw new SkyflowError(SKYFLOW_ERROR_CODE.INVALID_LAYOUT_OPTION, []);
+  }
+  if (has('allowDownload') && typeof options.allowDownload !== 'boolean') {
+    throw new SkyflowError(SKYFLOW_ERROR_CODE.INVALID_ALLOW_DOWNLOAD_OPTION, []);
+  }
+  if (has('autoSelectFirst') && typeof options.autoSelectFirst !== 'boolean') {
+    throw new SkyflowError(SKYFLOW_ERROR_CODE.INVALID_AUTO_SELECT_FIRST_OPTION, []);
+  }
+  if (has('labelMode') && !Object.values(ZipLabelMode).includes(options.labelMode as ZipLabelMode)) {
+    throw new SkyflowError(SKYFLOW_ERROR_CODE.INVALID_LABEL_MODE_OPTION, []);
+  }
+};
+
 export const validateCollectElementInput = (input: CollectElementInput, logLevel: LogLevel) => {
   if (!Object.prototype.hasOwnProperty.call(input, 'type')) {
     throw new SkyflowError(SKYFLOW_ERROR_CODE.MISSING_ELEMENT_TYPE, [], true);
@@ -137,3 +165,54 @@ export const validateCollectElementInput = (input: CollectElementInput, logLevel
     throw new SkyflowError(SKYFLOW_ERROR_CODE.INVALID_SKYFLOWID_IN_COLLECT, [], true);
   }
 };
+
+// Zip render: file types that must never be previewed inside the reveal iframe.
+const DANGEROUS_FILE_TYPES = [
+  // Executables / binaries
+  'application/x-msdownload', 'application/x-msdos-program', 'application/x-msinstaller',
+  'application/x-exe', 'application/x-dosexec', 'application/x-elf', 'application/x-msi',
+  'application/x-ms-shortcut', 'application/x-mach-binary', 'application/x-java-applet',
+  'application/vnd.microsoft.portable-executable', 'application/vnd.debian.binary-package',
+  // Scripts
+  'application/x-sh', 'application/x-bash', 'application/x-csh', 'application/x-shellscript',
+  'application/x-php', 'application/x-python', 'application/x-perl', 'application/x-ruby',
+  'application/x-sql', 'application/x-powershell',
+  'application/javascript', 'application/ecmascript', 'application/x-javascript',
+  'text/javascript', 'text/ecmascript', 'text/x-python', 'text/x-shellscript', 'text/x-perl',
+  'text/x-php', 'text/x-ruby', 'text/x-java-source', 'text/x-c', 'text/x-c++',
+  // Markup that could execute in the preview
+  'text/html',
+  // Nested archives
+  'application/x-7z-compressed', 'application/x-rar-compressed', 'application/vnd.rar',
+  'application/zip', 'application/x-tar', 'application/gzip', 'application/x-bzip2',
+  // Others
+  'application/x-mscardfile', 'application/x-msmetafile', 'application/x-mswrite',
+  'application/x-msaccess', 'application/x-msclip',
+];
+
+const DANGEROUS_FILE_EXTENSIONS = [
+  // Executables / installers / system
+  '.exe', '.msi', '.msp', '.mst', '.com', '.scr', '.pif', '.cpl', '.msc', '.gadget', '.app',
+  '.bin', '.dll', '.drv', '.sys', '.elf', '.mach-o', '.dmg', '.apk', '.jar', '.class', '.lnk',
+  '.inf', '.reg', '.scf', '.job', '.sct', '.shb', '.shs', '.url', '.xnk',
+  // Scripts
+  '.bat', '.cmd', '.sh', '.csh', '.bash', '.command', '.scpt', '.php', '.py', '.pl', '.rb',
+  '.sql', '.js', '.jse', '.vb', '.vbe', '.vbs', '.vbscript', '.ws', '.wsc', '.wsf', '.wsh',
+  '.hta', '.ps1', '.psm1', '.psd1', '.ps1xml', '.psc1', '.psc2', '.msh', '.msh1', '.msh2',
+  '.mshxml', '.msh1xml', '.msh2xml',
+  // Markup
+  '.html', '.htm',
+  // Nested archives / disk images
+  '.7z', '.rar', '.tar', '.gz', '.bz2', '.xz', '.z', '.ace', '.cab', '.arj', '.uue', '.bz',
+  '.tbz2', '.tgz', '.zoo', '.lzh', '.lha', '.iso', '.img', '.toast', '.vmdk', '.vhd', '.vhdx',
+  '.ova', '.ovf', '.vdi', '.vbox',
+];
+
+export const isDangerousFileType = (file: { name?: string; type?: string }): boolean => {
+  const lowerName = (file?.name || '').toLowerCase();
+  const lowerType = (file?.type || '').toLowerCase();
+  if (DANGEROUS_FILE_EXTENSIONS.some((ext) => lowerName.endsWith(ext))) {
+    return true;
+  }
+  return DANGEROUS_FILE_TYPES.includes(lowerType);
+};
diff --git a/packages/skyflow-js/tests/api-utils/reveal.test.js b/packages/skyflow-js/tests/api-utils/reveal.test.js
index f6993f95c..799c2a737 100644
--- a/packages/skyflow-js/tests/api-utils/reveal.test.js
+++ b/packages/skyflow-js/tests/api-utils/reveal.test.js
@@ -338,6 +338,17 @@ describe("formatForRenderClient fn test",()=>{
       "fileMetadata": undefined,
     }});
   });
+  test("with unZippedFilesMetadata should include it in success",()=>{
+    const unZippedFilesMetadata = [{ name: 'a.png', fileSize: 10, type: 'image/png' }];
+    const testInput = {"fields": { "col" : "http://dummy.com", "skyflow_id": "id" },
+      "fileMetadata": { contentType: 'application/zip' }, unZippedFilesMetadata };
+    const fnResponse = formatForRenderClient(testInput, 'col');
+    expect(fnResponse).toStrictEqual({ success :{"column": "col",
+      "skyflow_id": "id",
+      "fileMetadata": { contentType: 'application/zip' },
+      unZippedFilesMetadata,
+    }});
+  });
   test("errors case", ()=>{
     const errorResponse = {
       "errors": {
diff --git a/packages/skyflow-js/tests/core/external/reveal/reveal-composable-element.test.js b/packages/skyflow-js/tests/core/external/reveal/reveal-composable-element.test.js
index 49667c307..18fcc3edf 100644
--- a/packages/skyflow-js/tests/core/external/reveal/reveal-composable-element.test.js
+++ b/packages/skyflow-js/tests/core/external/reveal/reveal-composable-element.test.js
@@ -166,6 +166,33 @@ describe("Reveal Composable Element Class", () => {
     const res =  testRevealElement.renderFile()
     await expect(res).resolves.toEqual({ success: { skyflow_id: '1244', column: 'column' } });
   });
+  test("renderFile forwards its options on the request payload", async () => {
+    const eventEmitter = new EventEmitter();
+    const testRevealElement = new ComposableRevealElement("name", eventEmitter, '123');
+    const seen = [];
+    eventEmitter.on(ELEMENT_EVENTS_TO_IFRAME.RENDER_FILE_REQUEST + ':name', (data, cb) => {
+      seen.push(data);
+      cb({ success: { skyflow_id: '1244', column: 'column' } });
+    });
+
+    const options = { zipRender: true, allowDownload: true, labelMode: 'path' };
+    await expect(testRevealElement.renderFile(options)).resolves.toBeDefined();
+    await expect(testRevealElement.renderFile()).resolves.toBeDefined();
+    expect(seen[0]).toEqual({ options });
+    expect(seen[1]).toEqual({ options: undefined });
+  });
+  test("downloadCurrentFile emits the download event for the element", () => {
+    const eventEmitter = new EventEmitter();
+    const testRevealElement = new ComposableRevealElement(
+        "name",
+        eventEmitter,
+        '123',
+    );
+    const listener = jest.fn();
+    eventEmitter.on(ELEMENT_EVENTS_TO_IFRAME.REVEAL_ELEMENT_DOWNLOAD_CURRENT_FILE + ':name', listener);
+    testRevealElement.downloadCurrentFile();
+    expect(listener).toHaveBeenCalledTimes(1);
+  });
   test("file render call error case 1", async () => {
     const eventEmitter = new EventEmitter();
     const testRevealElement = new ComposableRevealElement(
diff --git a/packages/skyflow-js/tests/core/external/reveal/reveal-composable-internal.test.js b/packages/skyflow-js/tests/core/external/reveal/reveal-composable-internal.test.js
index 59b40c6fc..3d87d2b37 100644
--- a/packages/skyflow-js/tests/core/external/reveal/reveal-composable-internal.test.js
+++ b/packages/skyflow-js/tests/core/external/reveal/reveal-composable-internal.test.js
@@ -11,6 +11,7 @@ import * as busEvents from '@core/utils/bus-events';
 import bus from "framebus";
 import { JSDOM } from 'jsdom';
 import EventEmitter from "@core/event-emitter";
+import SKYFLOW_ERROR_CODE from '@core/utils/constants';
 import { error } from "console";
 import properties from "@core/properties";
 
@@ -256,6 +257,72 @@ describe("Reveal Element Class", () => {
         }
     }));
   });
+  test("file render forwards renderOptions to the iframe and rejects invalid options", async () => {
+    const elementArray = {
+        rows:[{
+            elements : [{
+            "column": "file",
+            "table": "table6",
+            "altText": "Alt text 1",
+            "name": "element1",
+            "skyflowID": "id1"
+            }]
+        }]
+    };
+    const groupEmiitter = new EventEmitter();
+    const testRevealElement = new ComposableRevealInternalElement(
+        elementId,
+        elementArray,
+        clientData,
+        {containerId:containerId,isMounted:false,eventEmitter:groupEmiitter},
+        { logLevel: LogLevel.ERROR,env:Env.PROD }
+    );
+    window.dispatchEvent(new MessageEvent('message', {
+        data: { type: ELEMENT_EVENTS_TO_IFRAME.RENDER_MOUNTED + "element1", containerId: mockUuid }
+    }));
+    const testEmptyDiv = document.createElement("div");
+    testEmptyDiv.setAttribute("id", "testDiv");
+    document.body.appendChild(testEmptyDiv);
+    testRevealElement.mount("#testDiv");
+    const iframe = document.querySelector("iframe");
+    const postMessage = jest.fn();
+    iframe.contentWindow.postMessage = postMessage;
+
+    // valid options travel on the REVEAL_CALL_REQUESTS payload
+    const options = { zipRender: true, allowDownload: false, autoSelectFirst: false, labelMode: 'basename', layout: 'listDetail' };
+    groupEmiitter._emit(ELEMENT_EVENTS_TO_IFRAME.RENDER_FILE_REQUEST + ":element1", { options }, () => {});
+    await Promise.resolve('token');
+    await new Promise((r) => setTimeout(r, 0));
+    const renderCall = postMessage.mock.calls
+      .map((c) => c[0])
+      .find((m) => m?.name === ELEMENT_EVENTS_TO_IFRAME.REVEAL_CALL_REQUESTS + "element1");
+    expect(renderCall).toBeDefined();
+    expect(renderCall.data.type).toBe(REVEAL_TYPES.RENDER_FILE);
+    expect(renderCall.data.renderOptions).toEqual(options);
+
+    // no options => empty renderOptions object
+    postMessage.mockClear();
+    groupEmiitter._emit(ELEMENT_EVENTS_TO_IFRAME.RENDER_FILE_REQUEST + ":element1", {}, () => {});
+    await Promise.resolve('token');
+    await new Promise((r) => setTimeout(r, 0));
+    const plainCall = postMessage.mock.calls
+      .map((c) => c[0])
+      .find((m) => m?.name === ELEMENT_EVENTS_TO_IFRAME.REVEAL_CALL_REQUESTS + "element1");
+    expect(plainCall.data.renderOptions).toEqual({});
+
+    // invalid options reject before anything is sent
+    postMessage.mockClear();
+    const errorCb = jest.fn();
+    groupEmiitter._emit(ELEMENT_EVENTS_TO_IFRAME.RENDER_FILE_REQUEST + ":element1", { options: { zipRender: 'yes' } }, errorCb);
+    await new Promise((r) => setTimeout(r, 0));
+    expect(errorCb).toHaveBeenCalledTimes(1);
+    expect(errorCb.mock.calls[0][0].error.errors[0].code).toBe(SKYFLOW_ERROR_CODE.INVALID_ZIP_RENDER_OPTION.code);
+    // only the "loading..." alt-text update went out; no render request was sent
+    const rejectedRender = postMessage.mock.calls
+      .map((c) => c[0])
+      .find((m) => m?.name === ELEMENT_EVENTS_TO_IFRAME.REVEAL_CALL_REQUESTS + "element1");
+    expect(rejectedRender).toBeUndefined();
+  });
   test("file render call error case", async () => {
     const elementArray = {
         rows:[{
@@ -680,6 +747,40 @@ describe("Reveal Element Class", () => {
         }
     }));
  });
+ test("download current file event is forwarded to the iframe", async () => {
+    const elementArray = {
+        rows:[{
+            elements : [{
+            "column": "file",
+            "table": "table6",
+            "altText": "Alt text 1",
+            "name": "element2",
+            "skyflowID": "id1"
+            }]
+        }]
+    };
+    const groupEmiitter = new EventEmitter();
+    const testRevealElement = new ComposableRevealInternalElement(
+        elementId,
+        elementArray,
+        clientData,
+        {containerId:containerId,isMounted:false,eventEmitter:groupEmiitter},
+        { logLevel: LogLevel.ERROR,env:Env.PROD }
+    );
+    const testEmptyDiv = document.createElement("div");
+    testEmptyDiv.setAttribute("id", "testDiv");
+    document.body.appendChild(testEmptyDiv);
+    testRevealElement.mount("#testDiv");
+    const iframe = document.querySelector("iframe");
+    const postMessage = jest.fn();
+    iframe.contentWindow.postMessage = postMessage;
+
+    groupEmiitter._emit(ELEMENT_EVENTS_TO_IFRAME.REVEAL_ELEMENT_DOWNLOAD_CURRENT_FILE + ":element2", {});
+    const eventName = ELEMENT_EVENTS_TO_IFRAME.REVEAL_ELEMENT_DOWNLOAD_CURRENT_FILE + "element2";
+    const call = postMessage.mock.calls.find((c) => c[0]?.name === eventName);
+    expect(call).toBeTruthy();
+    expect(call[0]).toMatchObject({ name: eventName });
+ });
  test("update call error case when container is not mounted", async () => {
     const elementArray = {
         rows:[{
diff --git a/packages/skyflow-js/tests/core/internal/reveal/reveal-frame-zip.test.js b/packages/skyflow-js/tests/core/internal/reveal/reveal-frame-zip.test.js
new file mode 100644
index 000000000..0cc5ab21d
--- /dev/null
+++ b/packages/skyflow-js/tests/core/internal/reveal/reveal-frame-zip.test.js
@@ -0,0 +1,848 @@
+/*
+Copyright (c) 2025 Skyflow, Inc.
+*/
+import bus from "framebus";
+import JSZip from "jszip";
+import RevealFrame from "../../../../src/internal/reveal/reveal-frame";
+import {
+  DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE,
+  ELEMENT_EVENTS_TO_IFRAME,
+  REVEAL_TYPES,
+  ZIP_EMPTY_ARCHIVE_MESSAGE,
+  ZIP_CONTAINER_STYLES,
+  ZIP_NAV_STYLES,
+  ZIP_NAV_LIST_ITEM_STYLES,
+  ZIP_PANEL_STYLES,
+  ZIP_PREVIEW_ERROR_STYLES,
+} from "@core/constants";
+import SKYFLOW_ERROR_CODE from "@core/utils/constants";
+import logs from "@core/utils/logs";
+import { Env, LogLevel } from "../../../../src/utils/common";
+import properties from "@core/properties";
+
+const mockGetFileURLFromVaultBySkyflowIDComposable = jest.fn();
+jest.mock('../../../../src/api-utils/reveal', () => {
+  const original = jest.requireActual('../../../../src/api-utils/reveal');
+  return {
+    ...original,
+    getFileURLFromVaultBySkyflowIDComposable: (...args) => mockGetFileURLFromVaultBySkyflowIDComposable(...args),
+  };
+});
+// Record every JSS class generation so tests can assert the styles handed to it.
+const jssCalls = [];
+jest.mock('@core/libs/jss-styles', () => {
+  const actual = jest.requireActual('@core/libs/jss-styles');
+  return {
+    __esModule: true,
+    default: (styles, name) => {
+      jssCalls.push({ styles, name });
+      return actual.default(styles, name);
+    },
+  };
+});
+properties.IFRAME_SECURE_ORIGIN = "http://localhost";
+
+const fileUrl = (filename) => `https://fileurl?response-content-disposition=inline%3B%20filename%3D${filename}&X-Amz-Signature=abc`;
+const ZIP_URL = fileUrl('archive.zip');
+const clientDomain = 'http://localhost';
+let testCounter = 0;
+let elementNameComposable;
+
+const setFileURLResolve = (url = ZIP_URL) => {
+  mockGetFileURLFromVaultBySkyflowIDComposable.mockReset().mockImplementation(() => Promise.resolve({
+    fields: { primary_card_file: url, skyflow_id: 'abc123' },
+    fileMetadata: { contentType: 'application/zip' },
+  }));
+};
+
+const buildZip = async (entries) => {
+  const zip = new JSZip();
+  Object.entries(entries).forEach(([name, content]) => {
+    if (content === null) zip.folder(name);
+    else zip.file(name, content);
+  });
+  return zip.generateAsync({ type: 'arraybuffer' });
+};
+
+const mockFetch = (arrayBuffer, ok = true) => {
+  global.fetch = jest.fn(() => Promise.resolve({
+    ok,
+    arrayBuffer: () => Promise.resolve(arrayBuffer),
+  }));
+};
+
+const defineUrl = (url) => {
+  Object.defineProperty(window, "location", { value: { href: url }, writable: true });
+  Object.defineProperty(window, "name", { value: elementNameComposable, writable: true });
+  Object.defineProperty(window, "parent", {
+    value: { postMessage: jest.fn(), addEventListener: jest.fn() },
+    writable: true,
+  });
+};
+
+const frameData = {
+  record: {
+    skyflowID: '1815-6223-1073-1425',
+    table: 'pii_fields',
+    column: 'primary_card_file',
+    altText: 'xxxx',
+    inputStyles: { base: { height: '300px' } },
+    zipNavStyles: { base: { width: '25%' } },
+    zipPanelStyles: { base: { width: '75%' } },
+    zipNavListItemStyles: { base: { color: 'blue' }, focus: { color: 'green' } },
+  },
+  clientJSON: { metaData: { uuid: '1234' } },
+  context: { logLevel: LogLevel.ERROR, env: Env.PROD },
+};
+
+// Zip rendering is opt-in via renderFile({ zipRender: true }); the harness turns it
+// on by default so the zip tests read naturally. Pass `null` to send no options.
+const dispatchRenderRequest = (renderOptions = { zipRender: true }) => {
+  window.dispatchEvent(new MessageEvent('message', {
+    data: {
+      name: ELEMENT_EVENTS_TO_IFRAME.REVEAL_CALL_REQUESTS + elementNameComposable,
+      data: {
+        type: REVEAL_TYPES.RENDER_FILE,
+        iframeName: elementNameComposable,
+        ...(renderOptions !== null ? { renderOptions } : {}),
+      },
+      clientConfig: { vaultURL: 'http://localhost', vaultID: 'vault123', authToken: 'dummy-token' },
+    },
+    origin: clientDomain,
+  }));
+};
+
+const dispatchDownload = (origin = clientDomain) => {
+  window.dispatchEvent(new MessageEvent('message', {
+    data: { name: ELEMENT_EVENTS_TO_IFRAME.REVEAL_ELEMENT_DOWNLOAD_CURRENT_FILE + elementNameComposable },
+    origin,
+  }));
+};
+
+// jsdom does not reflect innerText into textContent, so read it off the element directly.
+const contentText = () => document.getElementsByClassName(`SkyflowElement-${elementNameComposable}-content-base`)[0]?.innerText;
+const errorText = () => document.getElementsByClassName(`SkyflowElement-${elementNameComposable}-error-base`)[0]?.innerText;
+const panel = () => document.querySelector('#zip-panel');
+const navItems = () => document.querySelectorAll('#zip-nav li');
+
+const waitFor = async (predicate, tries = 60) => {
+  for (let i = 0; i < tries; i += 1) {
+    const value = predicate();
+    if (value) return value;
+    // eslint-disable-next-line no-await-in-loop
+    await new Promise((r) => setTimeout(r, 5));
+  }
+  return predicate();
+};
+
+const waitForResponse = () => waitFor(() => window.parent.postMessage.mock.calls
+  .map((c) => c[0])
+  .find((m) => m?.type === (ELEMENT_EVENTS_TO_IFRAME.REVEAL_CALL_RESPONSE + elementNameComposable)));
+
+const renderZip = async (entries, renderOptions) => {
+  mockFetch(await buildZip(entries));
+  RevealFrame.init();
+  dispatchRenderRequest(renderOptions);
+  return waitForResponse();
+};
+
+describe("Reveal Frame - zip file render", () => {
+  let createObjectURL;
+  let revokeObjectURL;
+  let clickSpy;
+
+  beforeAll(() => {
+    createObjectURL = jest.fn((blob) => `blob:mock/${blob.size}`);
+    revokeObjectURL = jest.fn();
+    URL.createObjectURL = createObjectURL;
+    URL.revokeObjectURL = revokeObjectURL;
+    if (typeof global.localStorage === 'undefined') {
+      global.localStorage = { getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(), clear: jest.fn() };
+    }
+  });
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+    jssCalls.length = 0;
+    jest.spyOn(bus, "emit").mockImplementation(() => {});
+    jest.spyOn(bus, "target").mockReturnValue({ on: jest.fn(), off: jest.fn(), emit: jest.fn() });
+    window.postMessage = jest.fn();
+    clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
+    // Expected-failure tests log through the SDK logger; keep the output quiet.
+    // Tests that assert on logs call jest.spyOn again and get these same mocks.
+    jest.spyOn(console, 'error').mockImplementation(() => {});
+    jest.spyOn(console, 'warn').mockImplementation(() => {});
+    testCounter += 1;
+    elementNameComposable = `reveal-composable:container${testCounter}:frame123:meta:${btoa(clientDomain)}`;
+    setFileURLResolve();
+    defineUrl('http://localhost/?' + btoa(JSON.stringify(frameData)));
+  });
+
+  afterEach(async () => {
+    // Let any lazy preview / download still inflating settle before jsdom goes
+    // away, otherwise it resumes against a torn-down environment.
+    await new Promise((r) => setTimeout(r, 30));
+    clickSpy.mockRestore();
+    jest.restoreAllMocks();
+    document.body.innerHTML = "";
+  });
+
+  // ---------------------------------------------------------------- happy path
+  test("renders the list-detail layout, returns unZippedFilesMetadata and inflates lazily", async () => {
+    const response = await renderZip({
+      'photo.png': 'png-bytes',
+      'doc.pdf': 'pdf-bytes',
+      'nested/': null,
+      '__MACOSX/._photo.png': 'junk',
+      'nested/.DS_Store': 'junk',
+      'nested/Thumbs.db': 'junk',
+    });
+
+    expect(global.fetch).toHaveBeenCalledWith(ZIP_URL);
+    expect(response.data.type).toBe(REVEAL_TYPES.RENDER_FILE);
+    const { success } = response.data.result;
+    expect(success.skyflow_id).toBe('abc123');
+    expect(success.column).toBe('primary_card_file');
+    expect(success.unZippedFilesMetadata.map((f) => f.name).sort()).toEqual(['doc.pdf', 'photo.png']);
+    // size comes from the archive's central directory, before any extraction
+    expect(success.unZippedFilesMetadata.find((f) => f.name === 'photo.png')).toEqual({
+      name: 'photo.png', type: 'image/png', fileSize: 'png-bytes'.length,
+    });
+
+    const items = navItems();
+    expect(items).toHaveLength(2);
+    expect(items[0].classList.contains('active')).toBe(true);
+    expect(items[0].classList.contains('SkyflowElement-zip-nav-item0-focus')).toBe(true);
+    expect(items[1].classList.contains('SkyflowElement-zip-nav-item1-base')).toBe(true);
+
+    const preview = await waitFor(() => document.querySelector('#zip-panel img'));
+    expect(preview.getAttribute('src')).toMatch(/^blob:mock/);
+    expect(preview.getAttribute('alt')).toBe('photo.png');
+    // only the selected file was inflated
+    expect(createObjectURL).toHaveBeenCalledTimes(1);
+
+    expect(document.querySelector('.SkyflowElement-zip-container-base')).toBeTruthy();
+    expect(document.querySelector('.SkyflowElement-zip-nav-base')).toBeTruthy();
+    expect(document.querySelector('.SkyflowElement-zip-panel-base')).toBeTruthy();
+  });
+
+  test("clicking a file switches preview + focus styles; blob URLs are created once and cached", async () => {
+    await renderZip({ 'a.png': 'a', 'b.pdf': 'b' });
+    await waitFor(() => document.querySelector('#zip-panel img'));
+    expect(createObjectURL).toHaveBeenCalledTimes(1);
+
+    const items = navItems();
+    items[1].click();
+    const embed = await waitFor(() => document.querySelector('#zip-panel embed'));
+    expect(embed.getAttribute('type')).toBe('application/pdf');
+    expect(items[1].classList.contains('active')).toBe(true);
+    expect(items[1].classList.contains('SkyflowElement-zip-nav-item1-focus')).toBe(true);
+    expect(items[0].classList.contains('active')).toBe(false);
+    expect(items[0].classList.contains('SkyflowElement-zip-nav-item0-base')).toBe(true);
+    expect(createObjectURL).toHaveBeenCalledTimes(2);
+
+    // back to the first file: cached URL, no new blob
+    items[0].click();
+    await waitFor(() => document.querySelector('#zip-panel img'));
+    expect(createObjectURL).toHaveBeenCalledTimes(2);
+  });
+
+  test("late preview result is dropped when the user has already switched files", async () => {
+    const buffer = await buildZip({ 'slow.png': 'slow', 'fast.pdf': 'fast' });
+    // delay inflating slow.png only
+    const zip = await JSZip.loadAsync(buffer);
+    const proto = Object.getPrototypeOf(zip.files['slow.png']);
+    const realAsync = proto.async;
+    jest.spyOn(proto, 'async').mockImplementation(function mocked(...args) {
+      const result = realAsync.apply(this, args);
+      if (this.name === 'slow.png') return new Promise((r) => setTimeout(() => r(result), 40));
+      return result;
+    });
+    mockFetch(buffer);
+    RevealFrame.init();
+    dispatchRenderRequest();
+    await waitForResponse();
+
+    // slow.png is selected + inflating; switch to fast.pdf right away
+    navItems()[1].click();
+    await waitFor(() => document.querySelector('#zip-panel embed'));
+    await new Promise((r) => setTimeout(r, 80));
+    expect(document.querySelector('#zip-panel img')).toBeNull();
+    expect(document.querySelector('#zip-panel embed')).toBeTruthy();
+    expect(panel().children).toHaveLength(1);
+  });
+
+  // ---------------------------------------------------------------- previews by type
+  test("video and audio preview; dangerous types show the warning and are never inflated", async () => {
+    await renderZip({ 'clip.mp4': 'v', 'script.js': 'alert(1)', 'page.html': '', 'tune.mp3': 'm' });
+    await waitFor(() => document.querySelector('#zip-panel video'));
+    expect(document.querySelector('#zip-panel video').getAttribute('src')).toMatch(/^blob:mock/);
+
+    const items = navItems();
+    items[1].click();
+    await waitFor(() => panel().textContent === DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE);
+    expect(document.querySelector('#zip-panel embed')).toBeNull();
+
+    items[2].click();
+    await waitFor(() => panel().textContent === DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE);
+    expect(createObjectURL).toHaveBeenCalledTimes(1);
+
+    items[3].click();
+    await waitFor(() => document.querySelector('#zip-panel audio'));
+    expect(createObjectURL).toHaveBeenCalledTimes(2);
+  });
+
+  test("other types are handed to the browser: any image/* in , the rest in ", async () => {
+    await renderZip({ 'scan.tif': 't', 'notes.txt': 'hello', 'data.xyz': 'x', 'README': 'r' });
+    const img = await waitFor(() => document.querySelector('#zip-panel img'));
+    expect(img.getAttribute('alt')).toBe('scan.tif');
+
+    const items = navItems();
+    items[1].click();
+    let embed = await waitFor(() => document.querySelector('#zip-panel embed'));
+    expect(embed.getAttribute('type')).toBe('text/plain');
+
+    items[2].click();
+    await waitFor(() => document.querySelector('#zip-panel embed')?.getAttribute('type') === 'chemical/x-xyz'
+      || document.querySelector('#zip-panel embed')?.getAttribute('type') === '');
+    embed = document.querySelector('#zip-panel embed');
+    expect(embed.getAttribute('src')).toMatch(/^blob:mock/);
+
+    items[3].click(); // no extension => empty type, still embedded rather than blank
+    await waitFor(() => document.querySelector('#zip-panel embed')?.getAttribute('type') === '');
+    expect(createObjectURL).toHaveBeenCalledTimes(4);
+  });
+
+  test("a broken image / video / audio swaps to the unsupported-preview message", async () => {
+    await renderZip({ 'bad.png': 'not-really-png', 'bad.mp4': 'x', 'bad.mp3': 'y' });
+    const img = await waitFor(() => document.querySelector('#zip-panel img'));
+    img.onerror();
+    expect(panel().textContent).toBe(DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE);
+
+    navItems()[1].click();
+    const video = await waitFor(() => document.querySelector('#zip-panel video'));
+    video.onerror();
+    expect(panel().textContent).toBe(DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE);
+
+    navItems()[2].click();
+    const audio = await waitFor(() => document.querySelector('#zip-panel audio'));
+    audio.onerror();
+    expect(panel().textContent).toBe(DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE);
+  });
+
+  test("in-pane warning is class-styled: SDK default, overridden by errorTextStyles.base", async () => {
+    // 1. defaults only
+    await renderZip({ 'run.exe': 'MZ' });
+    await waitFor(() => panel().textContent === DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE);
+    let msg = panel().firstElementChild;
+    expect(msg.className).toBe('SkyflowElement-zip-preview-error-base');
+    expect(msg.style.color).toBe(''); // no inline colour any more
+    let call = jssCalls.filter((c) => c.name === 'zip-preview-error').pop();
+    expect(call.styles.base).toEqual({ color: 'red', padding: '10px' });
+
+    // 2. element-level errorTextStyles.base overrides the default
+    document.body.innerHTML = '';
+    window.parent.postMessage.mockClear();
+    jssCalls.length = 0;
+    defineUrl('http://localhost/?' + btoa(JSON.stringify({
+      ...frameData,
+      record: { ...frameData.record, errorTextStyles: { base: { color: '#b00020', fontSize: '12px' } } },
+    })));
+    await renderZip({ 'run.exe': 'MZ' });
+    await waitFor(() => panel().textContent === DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE);
+    call = jssCalls.filter((c) => c.name === 'zip-preview-error').pop();
+    expect(call.styles.base).toEqual({ color: '#b00020', padding: '10px', fontSize: '12px' });
+
+    // 3. errorTextStyles can also override the default padding; zipPanelStyles never
+    //    reaches the message (it styles the panel only)
+    document.body.innerHTML = '';
+    window.parent.postMessage.mockClear();
+    jssCalls.length = 0;
+    defineUrl('http://localhost/?' + btoa(JSON.stringify({
+      ...frameData,
+      record: {
+        ...frameData.record,
+        errorTextStyles: { base: { color: '#b00020', padding: '0' } },
+        zipPanelStyles: { base: { width: '75%', color: 'orange' }, error: { color: 'orange' } },
+      },
+    })));
+    await renderZip({ 'run.exe': 'MZ' });
+    await waitFor(() => panel().textContent === DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE);
+    call = jssCalls.filter((c) => c.name === 'zip-preview-error').pop();
+    expect(call.styles.base).toEqual({ color: '#b00020', padding: '0' });
+    const panelCall = jssCalls.filter((c) => c.name === 'zip-panel').pop();
+    expect(panelCall.styles.base.width).toBe('75%');
+  });
+
+  test("loading message uses the neutral message class, not the error styles", async () => {
+    await renderZip({ 'a.png': 'a' });
+    const loading = jssCalls.find((c) => c.name === 'zip-preview-message');
+    expect(loading.styles.base).toEqual({ padding: '10px' });
+    expect(jssCalls.find((c) => c.name === 'zip-preview-error')).toBeUndefined();
+  });
+
+  // ---------------------------------------------------------------- layout styles
+  describe("zip layout styles", () => {
+    // Render with a specific record (frameData.record replaced, not merged) so
+    // each case controls exactly which style objects are present.
+    const renderWithRecord = async (record, entries = { 'a.png': 'a', 'b.pdf': 'b' }) => {
+      jssCalls.length = 0;
+      defineUrl('http://localhost/?' + btoa(JSON.stringify({ ...frameData, record })));
+      await renderZip(entries);
+      await waitFor(() => document.querySelector('#zip-panel img'));
+    };
+    const styleFor = (name) => jssCalls.filter((c) => c.name === name).pop()?.styles;
+    const baseRecord = { skyflowID: 'id', table: 'pii_fields', column: 'primary_card_file' };
+
+    test("uses the SDK defaults when no zip style objects are passed", async () => {
+      await renderWithRecord(baseRecord);
+      expect(styleFor('zip-container')).toEqual(ZIP_CONTAINER_STYLES);
+      expect(styleFor('zip-nav')).toEqual(ZIP_NAV_STYLES);
+      expect(styleFor('zip-panel')).toEqual(ZIP_PANEL_STYLES);
+      expect(styleFor('zip-nav-item1')).toEqual({ base: ZIP_NAV_LIST_ITEM_STYLES.base });
+      // the auto-selected first item gets base + focus merged under the focus variant
+      expect(styleFor('zip-nav-item0')).toEqual({
+        focus: { ...ZIP_NAV_LIST_ITEM_STYLES.base, ...ZIP_NAV_LIST_ITEM_STYLES.focus },
+      });
+      expect(document.querySelector('.SkyflowElement-zip-nav-base')).toBeTruthy();
+      expect(document.querySelector('.SkyflowElement-zip-panel-base')).toBeTruthy();
+      expect(document.querySelector('.SkyflowElement-zip-nav-item0-focus')).toBeTruthy();
+      expect(document.querySelector('.SkyflowElement-zip-nav-item1-base')).toBeTruthy();
+    });
+
+    test("custom zipNavStyles / zipPanelStyles / zipNavListItemStyles merge over the defaults", async () => {
+      await renderWithRecord({
+        ...baseRecord,
+        zipNavStyles: { base: { width: '25%', backgroundColor: '#eee' } },
+        zipPanelStyles: { base: { width: '75%', padding: '0' } },
+        zipNavListItemStyles: { base: { color: 'blue', padding: '2px' }, focus: { color: 'green', border: 'none' } },
+      });
+      expect(styleFor('zip-nav').base).toEqual({ ...ZIP_NAV_STYLES.base, width: '25%', backgroundColor: '#eee' });
+      expect(styleFor('zip-panel').base).toEqual({ ...ZIP_PANEL_STYLES.base, width: '75%', padding: '0' });
+      expect(styleFor('zip-nav-item1').base).toEqual({ ...ZIP_NAV_LIST_ITEM_STYLES.base, color: 'blue', padding: '2px' });
+      // focus = default base -> default focus -> custom base -> custom focus
+      expect(styleFor('zip-nav-item0').focus).toEqual({
+        ...ZIP_NAV_LIST_ITEM_STYLES.base,
+        ...ZIP_NAV_LIST_ITEM_STYLES.focus,
+        color: 'green',
+        padding: '2px',
+        border: 'none',
+      });
+    });
+
+    test("inputStyles.base is applied to the container and its height also sizes nav and panel", async () => {
+      await renderWithRecord({
+        ...baseRecord,
+        inputStyles: { base: { height: '300px', border: '1px solid red' } },
+        zipNavStyles: { base: { width: '25%' } },
+      });
+      expect(styleFor('zip-container').base).toEqual({
+        ...ZIP_CONTAINER_STYLES.base, height: '300px', border: '1px solid red',
+      });
+      expect(styleFor('zip-nav').base.height).toBe('300px');
+      expect(styleFor('zip-panel').base.height).toBe('300px');
+      // height override loses to an explicit zip style height
+      jssCalls.length = 0;
+      document.body.innerHTML = '';
+      window.parent.postMessage.mockClear();
+      await renderWithRecord({
+        ...baseRecord,
+        inputStyles: { base: { height: '300px' } },
+        zipPanelStyles: { base: { height: '500px' } },
+      });
+      expect(styleFor('zip-panel').base.height).toBe('500px');
+      expect(styleFor('zip-nav').base.height).toBe('300px');
+    });
+
+    test("without an inputStyles height, nav and panel keep the default 100% height", async () => {
+      await renderWithRecord({ ...baseRecord, inputStyles: { base: { width: '400px' } } });
+      expect(styleFor('zip-nav').base.height).toBe('100%');
+      expect(styleFor('zip-panel').base.height).toBe('100%');
+      expect(styleFor('zip-container').base.width).toBe('400px');
+    });
+
+    test("only the base variant is honoured for nav and panel; other variants are ignored", async () => {
+      await renderWithRecord({
+        ...baseRecord,
+        zipNavStyles: { base: { width: '20%' }, focus: { width: '90%' }, error: { color: 'red' }, global: {} },
+        zipPanelStyles: { base: { width: '80%' }, hover: { width: '1%' } },
+      });
+      expect(styleFor('zip-nav')).toEqual({ base: { ...ZIP_NAV_STYLES.base, width: '20%' } });
+      expect(styleFor('zip-panel')).toEqual({ base: { ...ZIP_PANEL_STYLES.base, width: '80%' } });
+    });
+
+    test("empty, null or non-object zip style values fall back to the defaults without throwing", async () => {
+      await renderWithRecord({
+        ...baseRecord,
+        zipNavStyles: {},
+        zipPanelStyles: null,
+        zipNavListItemStyles: { base: null },
+        inputStyles: undefined,
+      });
+      expect(styleFor('zip-nav')).toEqual(ZIP_NAV_STYLES);
+      expect(styleFor('zip-panel')).toEqual(ZIP_PANEL_STYLES);
+      expect(styleFor('zip-nav-item1')).toEqual({ base: ZIP_NAV_LIST_ITEM_STYLES.base });
+      expect(styleFor('zip-container')).toEqual(ZIP_CONTAINER_STYLES);
+      expect(navItems()).toHaveLength(2);
+    });
+
+    test("style objects do not leak into each other", async () => {
+      await renderWithRecord({
+        ...baseRecord,
+        errorTextStyles: { base: { color: '#b00020' } },
+        labelStyles: { base: { fontWeight: 'bold' } },
+        zipNavStyles: { base: { width: '25%' } },
+        zipPanelStyles: { base: { width: '75%' } },
+        zipNavListItemStyles: { base: { color: 'blue' } },
+      });
+      const nav = styleFor('zip-nav').base;
+      const panelStyles = styleFor('zip-panel').base;
+      const item = styleFor('zip-nav-item1').base;
+      expect(nav.color).toBeUndefined();
+      expect(nav.fontWeight).toBeUndefined();
+      expect(panelStyles.color).toBeUndefined();
+      expect(panelStyles.width).toBe('75%');
+      expect(nav.width).toBe('25%');
+      expect(item.width).toBeUndefined();
+      expect(styleFor('zip-container').base.color).toBeUndefined();
+      // errorTextStyles only reaches the in-pane message
+      expect(styleFor('zip-preview-error')).toBeUndefined();
+    });
+
+    test("clicking a list item regenerates focus styles for that item and base for the rest", async () => {
+      await renderWithRecord({
+        ...baseRecord,
+        zipNavListItemStyles: { base: { color: 'blue' }, focus: { color: 'green' } },
+      });
+      jssCalls.length = 0;
+      navItems()[1].click();
+      await waitFor(() => document.querySelector('#zip-panel embed'));
+      expect(styleFor('zip-nav-item1')).toEqual({
+        focus: {
+          ...ZIP_NAV_LIST_ITEM_STYLES.base, ...ZIP_NAV_LIST_ITEM_STYLES.focus, color: 'green',
+        },
+      });
+      // item0 dropped back to its base class (no new base styles need generating)
+      expect(navItems()[0].classList.contains('SkyflowElement-zip-nav-item0-base')).toBe(true);
+      expect(navItems()[0].classList.contains('SkyflowElement-zip-nav-item0-focus')).toBe(false);
+      expect(navItems()[1].classList.contains('SkyflowElement-zip-nav-item1-focus')).toBe(true);
+    });
+
+    test("in-pane error message: default ZIP_PREVIEW_ERROR_STYLES when errorTextStyles is absent", async () => {
+      jssCalls.length = 0;
+      defineUrl('http://localhost/?' + btoa(JSON.stringify({ ...frameData, record: baseRecord })));
+      await renderZip({ 'run.exe': 'MZ' });
+      await waitFor(() => panel().textContent === DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE);
+      expect(styleFor('zip-preview-error')).toEqual(ZIP_PREVIEW_ERROR_STYLES);
+    });
+  });
+
+  // ---------------------------------------------------------------- download
+  test("download (allowed): downloads the selected file by basename; other origins are ignored", async () => {
+    await renderZip({ 'a.png': 'a', 'dir/sub/b.pdf': 'bb' }, { zipRender: true, allowDownload: true });
+    navItems()[1].click();
+    await waitFor(() => document.querySelector('#zip-panel embed'));
+
+    const appendSpy = jest.spyOn(document.body, 'appendChild');
+    dispatchDownload('https://evil.example');
+    await new Promise((r) => setTimeout(r, 10));
+    expect(clickSpy).not.toHaveBeenCalled();
+
+    dispatchDownload();
+    await waitFor(() => clickSpy.mock.calls.length === 1);
+    const anchor = appendSpy.mock.calls.map((c) => c[0]).find((el) => el instanceof HTMLAnchorElement);
+    expect(anchor.download).toBe('b.pdf');
+    expect(anchor.href).toMatch(/^blob:mock/);
+    expect(document.body.contains(anchor)).toBe(false);
+
+    // unrelated messages are ignored
+    window.dispatchEvent(new MessageEvent('message', { data: { name: 'something-else' }, origin: clientDomain }));
+    await new Promise((r) => setTimeout(r, 10));
+    expect(clickSpy).toHaveBeenCalledTimes(1);
+  });
+
+  test("download is blocked by default (allowDownload false)", async () => {
+    await renderZip({ 'a.png': 'a' });
+    await waitFor(() => document.querySelector('#zip-panel img'));
+    const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+    dispatchDownload();
+    await new Promise((r) => setTimeout(r, 10));
+    expect(clickSpy).not.toHaveBeenCalled();
+    expect(errorSpy.mock.calls.flat().join(' ')).toContain(logs.errorLogs.DOWNLOAD_NOT_ALLOWED);
+  });
+
+  test("download is blocked for dangerous file types even when allowed", async () => {
+    await renderZip({ 'run.exe': 'MZ' }, { zipRender: true, allowDownload: true });
+    await waitFor(() => panel().textContent === DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE);
+    const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+    dispatchDownload();
+    await new Promise((r) => setTimeout(r, 10));
+    expect(clickSpy).not.toHaveBeenCalled();
+    expect(createObjectURL).not.toHaveBeenCalled();
+    expect(errorSpy.mock.calls.flat().join(' ')).toContain(logs.errorLogs.DOWNLOAD_BLOCKED_DANGEROUS_FILE);
+  });
+
+  test("download with nothing selected (autoSelectFirst false) logs and does nothing", async () => {
+    await renderZip({ 'a.png': 'a' }, { zipRender: true, allowDownload: true, autoSelectFirst: false });
+    const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+    dispatchDownload();
+    await new Promise((r) => setTimeout(r, 10));
+    expect(clickSpy).not.toHaveBeenCalled();
+    expect(errorSpy.mock.calls.flat().join(' ')).toContain(logs.errorLogs.FAILED_DOWNLOAD_FILE);
+  });
+
+  // ---------------------------------------------------------------- options
+  test("autoSelectFirst: false lists files without previewing one; click still works", async () => {
+    await renderZip({ 'a.png': 'a', 'b.pdf': 'b' }, { zipRender: true, autoSelectFirst: false });
+    const items = navItems();
+    expect(items).toHaveLength(2);
+    expect(document.querySelector('#zip-nav li.active')).toBeNull();
+    expect(panel().children).toHaveLength(0);
+    expect(createObjectURL).not.toHaveBeenCalled();
+
+    items[0].click();
+    await waitFor(() => document.querySelector('#zip-panel img'));
+    expect(items[0].classList.contains('active')).toBe(true);
+  });
+
+  test("labelMode basename (default): basenames, full path on hover, parent folder on collisions", async () => {
+    await renderZip({ 'docs/2024/report.pdf': 'a', 'docs/2025/report.pdf': 'b', 'images/cover.png': 'c' });
+    const byTitle = Object.fromEntries([...navItems()].map((li) => [li.title, li.textContent]));
+    expect(byTitle['docs/2024/report.pdf']).toBe('2024/report.pdf');
+    expect(byTitle['docs/2025/report.pdf']).toBe('2025/report.pdf');
+    expect(byTitle['images/cover.png']).toBe('cover.png');
+  });
+
+  test("labelMode path shows the full archive path", async () => {
+    await renderZip({ 'docs/2024/report.pdf': 'a', 'images/cover.png': 'c' }, { zipRender: true, labelMode: 'path' });
+    const labels = [...navItems()].map((li) => li.textContent).sort();
+    expect(labels).toEqual(['docs/2024/report.pdf', 'images/cover.png']);
+  });
+
+  test("buildFileLabels keeps labels unique: falls back to full path when parents also collide", () => {
+    const files = [{ name: 'report.pdf' }, { name: 'x/report.pdf' }, { name: 'y/z/other.pdf' }];
+    expect(RevealFrame.buildFileLabels(files, 'basename')).toEqual(['report.pdf', 'x/report.pdf', 'other.pdf']);
+    expect(RevealFrame.buildFileLabels(files, 'path')).toEqual(['report.pdf', 'x/report.pdf', 'y/z/other.pdf']);
+    const clash = [{ name: 'a/x/r.pdf' }, { name: 'b/x/r.pdf' }, { name: 'c/r.pdf' }];
+    expect(RevealFrame.buildFileLabels(clash, 'basename')).toEqual(['a/x/r.pdf', 'b/x/r.pdf', 'c/r.pdf']);
+    expect(RevealFrame.buildFileLabels([], 'basename')).toEqual([]);
+    expect(RevealFrame.buildFileLabels([{ name: 'weird/' }], 'basename')).toEqual(['weird']);
+  });
+
+  // ---------------------------------------------------------------- opt-in / detection
+  test("without zipRender a .zip is rendered as a plain file (opt-in)", async () => {
+    mockFetch(await buildZip({ 'a.png': 'a' }));
+    RevealFrame.init();
+    dispatchRenderRequest(null);
+    const response = await waitForResponse();
+
+    expect(global.fetch).not.toHaveBeenCalled();
+    expect(response.data.result.success.unZippedFilesMetadata).toBeUndefined();
+    expect(document.querySelector('#zip-nav')).toBeNull();
+    const embed = document.querySelector('embed');
+    expect(embed.getAttribute('src')).toBe(ZIP_URL);
+  });
+
+  test("zipRender: false is honoured even when passed explicitly", async () => {
+    mockFetch(await buildZip({ 'a.png': 'a' }));
+    RevealFrame.init();
+    dispatchRenderRequest({ zipRender: false });
+    await waitForResponse();
+    expect(global.fetch).not.toHaveBeenCalled();
+    expect(document.querySelector('#zip-nav')).toBeNull();
+  });
+
+  test("gzip is not a zip: rendered as a plain file even with zipRender", async () => {
+    setFileURLResolve(fileUrl('archive.gz'));
+    mockFetch(new ArrayBuffer(0));
+    RevealFrame.init();
+    dispatchRenderRequest({ zipRender: true });
+    const response = await waitForResponse();
+    expect(global.fetch).not.toHaveBeenCalled();
+    expect(response.data.result.success).toBeDefined();
+    expect(response.data.result.errors).toBeUndefined();
+    expect(document.querySelector('#zip-nav')).toBeNull();
+    expect(document.querySelector('embed').getAttribute('src')).toBe(fileUrl('archive.gz'));
+  });
+
+  test("zipRender on a non-zip file (pdf) renders it as a plain file", async () => {
+    setFileURLResolve(fileUrl('statement.pdf'));
+    RevealFrame.init();
+    dispatchRenderRequest({ zipRender: true });
+    await waitForResponse();
+    expect(document.querySelector('#zip-nav')).toBeNull();
+    expect(document.querySelector('embed').getAttribute('type')).toBe('application/pdf');
+  });
+
+  test("isZipMimeType is exact and case-insensitive", () => {
+    expect(RevealFrame.isZipMimeType('application/zip')).toBe(true);
+    expect(RevealFrame.isZipMimeType('Application/X-Zip-Compressed')).toBe(true);
+    expect(RevealFrame.isZipMimeType('application/gzip')).toBe(false);
+    expect(RevealFrame.isZipMimeType('application/x-7z-compressed')).toBe(false);
+    expect(RevealFrame.isZipMimeType('')).toBe(false);
+    expect(RevealFrame.isZipMimeType(null)).toBe(false);
+    expect(RevealFrame.isZipMimeType(undefined)).toBe(false);
+  });
+
+  // ---------------------------------------------------------------- untrusted options
+  test("malformed renderOptions from the message fall back to defaults", async () => {
+    const buffer = await buildZip({ 'a.png': 'a' });
+    const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
+
+    // not an object → all defaults → zip not rendered
+    mockFetch(buffer);
+    RevealFrame.init();
+    dispatchRenderRequest('zipRender=true');
+    await waitForResponse();
+    expect(global.fetch).not.toHaveBeenCalled();
+
+    // wrong type for zipRender is ignored (default false)
+    document.body.innerHTML = '';
+    window.parent.postMessage.mockClear();
+    RevealFrame.init();
+    dispatchRenderRequest({ zipRender: 'yes' });
+    await waitForResponse();
+    expect(global.fetch).not.toHaveBeenCalled();
+    expect(document.querySelector('#zip-nav')).toBeNull();
+
+    // invalid labelMode / layout / autoSelectFirst are ignored individually; valid zipRender still applies.
+    // The "ignored option" warnings are WARN-level, so this frame runs at LogLevel.WARN.
+    document.body.innerHTML = '';
+    window.parent.postMessage.mockClear();
+    defineUrl('http://localhost/?' + btoa(JSON.stringify({
+      ...frameData, context: { logLevel: LogLevel.WARN, env: Env.PROD },
+    })));
+    RevealFrame.init();
+    dispatchRenderRequest({ zipRender: true, labelMode: 'weird', layout: 'grid', autoSelectFirst: 'no', extra: 1 });
+    await waitForResponse();
+    expect(navItems()).toHaveLength(1);
+    expect(navItems()[0].textContent).toBe('a.png');
+    expect(navItems()[0].classList.contains('active')).toBe(true);
+    const warnings = warnSpy.mock.calls.flat().join('\n');
+    expect(warnings).toContain("'labelMode'");
+    expect(warnings).toContain("'layout'");
+    expect(warnings).toContain("'autoSelectFirst'");
+  });
+
+  // ---------------------------------------------------------------- empty / errors / limits
+  test("empty zip (or only directories + OS metadata) shows the empty message and returns []", async () => {
+    const response = await renderZip({ 'only-dir/': null, '__MACOSX/x': 'junk', 'a/.DS_Store': 'j' });
+    expect(response.data.result.success.unZippedFilesMetadata).toEqual([]);
+    expect(contentText()).toBe(ZIP_EMPTY_ARCHIVE_MESSAGE);
+    expect(document.querySelector('#zip-nav')).toBeNull();
+  });
+
+  test("fetch failure rejects with FAILED_TO_UNZIP_FILES and shows the render error", async () => {
+    mockFetch(new ArrayBuffer(0), false);
+    RevealFrame.init();
+    dispatchRenderRequest();
+    const response = await waitForResponse();
+    expect(response.data.result.errors).toEqual({
+      errors: { error: { ...SKYFLOW_ERROR_CODE.FAILED_TO_UNZIP_FILES } },
+    });
+    expect(errorText()).toBe('File rendering failed. Please try again later.');
+    expect(contentText()).toBe('xxxx'); // altText restored
+  });
+
+  test("network error (fetch throws) rejects with FAILED_TO_UNZIP_FILES", async () => {
+    global.fetch = jest.fn(() => Promise.reject(new Error('offline')));
+    RevealFrame.init();
+    dispatchRenderRequest();
+    const response = await waitForResponse();
+    expect(response.data.result.errors.errors.error.code).toBe(SKYFLOW_ERROR_CODE.FAILED_TO_UNZIP_FILES.code);
+    expect(errorText()).toBe('File rendering failed. Please try again later.');
+  });
+
+  test("corrupt zip data rejects with FAILED_TO_UNZIP_FILES", async () => {
+    mockFetch(Uint8Array.from(Buffer.from('not a zip')).buffer);
+    RevealFrame.init();
+    dispatchRenderRequest();
+    const response = await waitForResponse();
+    expect(response.data.result.errors.errors.error.code).toBe(SKYFLOW_ERROR_CODE.FAILED_TO_UNZIP_FILES.code);
+  });
+
+  test("a large archive renders its full manifest without inflating entries up front", async () => {
+    const entries = {};
+    for (let i = 0; i < 300; i += 1) entries[`dir${i % 7}/f${i}.txt`] = 'x'.repeat(50);
+    const response = await renderZip(entries, { zipRender: true, autoSelectFirst: false });
+    expect(response.data.result.success.unZippedFilesMetadata).toHaveLength(300);
+    expect(response.data.result.success.unZippedFilesMetadata[0].fileSize).toBe(50);
+    expect(navItems()).toHaveLength(300);
+    expect(createObjectURL).not.toHaveBeenCalled();
+  });
+
+  // ---------------------------------------------------------------- re-render / races
+  test("re-rendering revokes previous blob URLs and rebuilds the layout", async () => {
+    await renderZip({ 'a.png': 'a' });
+    await waitFor(() => document.querySelector('#zip-panel img'));
+    expect(revokeObjectURL).not.toHaveBeenCalled();
+
+    window.parent.postMessage.mockClear();
+    mockFetch(await buildZip({ 'b.png': 'b', 'c.png': 'c' }));
+    dispatchRenderRequest();
+    await waitForResponse();
+    expect(revokeObjectURL).toHaveBeenCalledTimes(1);
+    expect(revokeObjectURL).toHaveBeenCalledWith(expect.stringMatching(/^blob:mock/));
+    expect(navItems()).toHaveLength(2);
+    expect(document.querySelectorAll('.SkyflowElement-zip-container-base')).toHaveLength(1);
+  });
+
+  test("a failed re-render after a zip render tears the layout down and shows the error", async () => {
+    await renderZip({ 'a.png': 'a' });
+    expect(navItems()).toHaveLength(1);
+
+    window.parent.postMessage.mockClear();
+    mockFetch(new ArrayBuffer(0), false);
+    dispatchRenderRequest();
+    const response = await waitForResponse();
+    expect(response.data.result.errors.errors.error.code).toBe(SKYFLOW_ERROR_CODE.FAILED_TO_UNZIP_FILES.code);
+    expect(document.querySelector('#zip-nav')).toBeNull();
+    expect(errorText()).toBe('File rendering failed. Please try again later.');
+    expect(contentText()).toBe('xxxx');
+  });
+
+  test("re-render as a plain file after a zip render removes the zip layout", async () => {
+    await renderZip({ 'a.png': 'a' });
+    window.parent.postMessage.mockClear();
+    setFileURLResolve(fileUrl('statement.pdf'));
+    dispatchRenderRequest({ zipRender: true });
+    await waitForResponse();
+    expect(document.querySelector('#zip-nav')).toBeNull();
+    expect(document.querySelector('embed').getAttribute('type')).toBe('application/pdf');
+  });
+
+  test("overlapping renders: the newer request owns the DOM even if the older finishes last", async () => {
+    const first = await buildZip({ 'old.png': 'o' });
+    const second = await buildZip({ 'new.png': 'n', 'new2.png': 'n' });
+    let releaseFirst;
+    global.fetch = jest.fn()
+      .mockImplementationOnce(() => new Promise((r) => { releaseFirst = () => r({ ok: true, arrayBuffer: () => Promise.resolve(first) }); }))
+      .mockImplementationOnce(() => Promise.resolve({ ok: true, arrayBuffer: () => Promise.resolve(second) }));
+
+    RevealFrame.init();
+    dispatchRenderRequest();
+    await waitFor(() => global.fetch.mock.calls.length === 1);
+    dispatchRenderRequest();
+    await waitForResponse();
+    expect(navItems()).toHaveLength(2);
+
+    releaseFirst();
+    await waitFor(() => window.parent.postMessage.mock.calls
+      .filter((c) => c[0]?.type === ELEMENT_EVENTS_TO_IFRAME.REVEAL_CALL_RESPONSE + elementNameComposable).length === 2);
+    // both callers got an answer, but the DOM still shows the second archive
+    expect(navItems()).toHaveLength(2);
+    expect([...navItems()].map((li) => li.title)).toEqual(['new.png', 'new2.png']);
+  });
+
+  // ---------------------------------------------------------------- helpers
+  test("static helpers", () => {
+    expect(RevealFrame.isSystemFile('__MACOSX/._a.png')).toBe(true);
+    expect(RevealFrame.isSystemFile('dir/.DS_Store')).toBe(true);
+    expect(RevealFrame.isSystemFile('dir/Thumbs.db')).toBe(true);
+    expect(RevealFrame.isSystemFile('dir/a.png')).toBe(false);
+
+    expect(RevealFrame.basename('a/b/c.pdf')).toBe('c.pdf');
+    expect(RevealFrame.basename('c.pdf')).toBe('c.pdf');
+    expect(RevealFrame.basename('dir/')).toBe('dir');
+  });
+});
diff --git a/packages/skyflow-js/tests/skyflow.test.js b/packages/skyflow-js/tests/skyflow.test.js
index dbf466fa1..22b1136cc 100644
--- a/packages/skyflow-js/tests/skyflow.test.js
+++ b/packages/skyflow-js/tests/skyflow.test.js
@@ -12,7 +12,7 @@ import ComposableContainer from '../src/external/collect/compose-collect-contain
 import SkyflowContainer from '../src/external/skyflow-container';
 import Client from '@core/client'
 import logs from '@core/utils/logs';
-import { ComposableRevealContainer } from '../src/index-node';
+import { ComposableRevealContainer, ZipLabelMode, ZipRenderLayout } from '../src/index-node';
 
 jest.mock('@core/utils/jwt-utils', () => ({
   __esModule: true,
@@ -1925,4 +1925,14 @@ describe('Skyflow delete tests', () => {
       done();
     });
   });
-});
\ No newline at end of file
+});
+describe('zip render enums', () => {
+  test('Skyflow exposes ZipLabelMode and ZipRenderLayout', () => {
+    expect(Skyflow.ZipLabelMode).toEqual({ BASENAME: 'basename', PATH: 'path' });
+    expect(Skyflow.ZipRenderLayout).toEqual({ LIST_DETAIL: 'listDetail' });
+  });
+  test('npm entry exports the same enums', () => {
+    expect(ZipLabelMode).toBe(Skyflow.ZipLabelMode);
+    expect(ZipRenderLayout).toBe(Skyflow.ZipRenderLayout);
+  });
+});
diff --git a/packages/skyflow-js/tests/utils/validators.test.js b/packages/skyflow-js/tests/utils/validators.test.js
index c43f5306b..cfe21ad00 100644
--- a/packages/skyflow-js/tests/utils/validators.test.js
+++ b/packages/skyflow-js/tests/utils/validators.test.js
@@ -4,6 +4,7 @@ Copyright (c) 2022 Skyflow, Inc.
 import { CardType, SDK_VERSION } from '@core/constants';
 import SKYFLOW_ERROR_CODE from '@core/utils/constants';
 import {
+  isDangerousFileType,
   detectCardType,
   isValidRegExp,
   validateCreditCardNumber,
@@ -24,10 +25,11 @@ import {
   validateDeleteRecords,
   validateInputFormatOptions,
   validateRenderElementRecord,
+  validateRenderOptions,
   validateUpdateRecord
 } from '../../src/utils/validators/index';
 import { parameterizedString } from '../../src/utils/logs-helper';
-import { RedactionType } from '../../src/utils/common';
+import { RedactionType, ZipLabelMode, ZipRenderLayout } from '../../src/utils/common';
 
 
 describe('Validation card number and Expiry Date', () => {
@@ -1462,3 +1464,72 @@ describe('test validateInputFormatOptions', () => {
 
 
 });
+
+describe('isDangerousFileType', () => {
+  test('flags dangerous extensions regardless of case and mime', () => {
+    expect(isDangerousFileType({ name: 'run.EXE', type: '' })).toBe(true);
+    expect(isDangerousFileType({ name: 'a.js', type: 'text/plain' })).toBe(true);
+    expect(isDangerousFileType({ name: 'page.html', type: 'text/html' })).toBe(true);
+    expect(isDangerousFileType({ name: 'inner.zip', type: 'application/zip' })).toBe(true);
+  });
+  test('flags dangerous mime types even with a harmless extension', () => {
+    expect(isDangerousFileType({ name: 'photo.png', type: 'application/x-msdownload' })).toBe(true);
+    expect(isDangerousFileType({ name: 'notes', type: 'TEXT/HTML' })).toBe(true);
+  });
+  test('allows regular previewable files', () => {
+    expect(isDangerousFileType({ name: 'photo.png', type: 'image/png' })).toBe(false);
+    expect(isDangerousFileType({ name: 'doc.pdf', type: 'application/pdf' })).toBe(false);
+    expect(isDangerousFileType({ name: 'clip.mp4', type: 'video/mp4' })).toBe(false);
+    expect(isDangerousFileType({})).toBe(false);
+    expect(isDangerousFileType(undefined)).toBe(false);
+  });
+});
+
+describe('validateRenderOptions', () => {
+  test('accepts undefined, an empty object and every valid key', () => {
+    expect(() => validateRenderOptions()).not.toThrow();
+    expect(() => validateRenderOptions({})).not.toThrow();
+    expect(() => validateRenderOptions({
+      zipRender: true, layout: 'listDetail', allowDownload: false, autoSelectFirst: true, labelMode: 'path',
+    })).not.toThrow();
+    expect(() => validateRenderOptions({ labelMode: 'basename' })).not.toThrow();
+  });
+  test('accepts the ZipLabelMode / ZipRenderLayout enums (same string values)', () => {
+    expect(ZipLabelMode.BASENAME).toBe('basename');
+    expect(ZipLabelMode.PATH).toBe('path');
+    expect(ZipRenderLayout.LIST_DETAIL).toBe('listDetail');
+    expect(() => validateRenderOptions({
+      layout: ZipRenderLayout.LIST_DETAIL, labelMode: ZipLabelMode.PATH,
+    })).not.toThrow();
+  });
+  test('rejects non-object options', () => {
+    [null, 'zip', 1, true, []].forEach((bad) => {
+      try {
+        validateRenderOptions(bad);
+        throw new Error('expected to throw');
+      } catch (err) {
+        expect(err.errors[0].code).toBe(SKYFLOW_ERROR_CODE.INVALID_RENDER_OPTIONS.code);
+        expect(err.errors[0].description).toBe(SKYFLOW_ERROR_CODE.INVALID_RENDER_OPTIONS.description);
+      }
+    });
+  });
+  test('rejects wrong types and unsupported values per key', () => {
+    const cases = [
+      [{ zipRender: 'true' }, SKYFLOW_ERROR_CODE.INVALID_ZIP_RENDER_OPTION],
+      [{ layout: 'grid' }, SKYFLOW_ERROR_CODE.INVALID_LAYOUT_OPTION],
+      [{ layout: 1 }, SKYFLOW_ERROR_CODE.INVALID_LAYOUT_OPTION],
+      [{ allowDownload: 1 }, SKYFLOW_ERROR_CODE.INVALID_ALLOW_DOWNLOAD_OPTION],
+      [{ autoSelectFirst: null }, SKYFLOW_ERROR_CODE.INVALID_AUTO_SELECT_FIRST_OPTION],
+      [{ labelMode: 'folder' }, SKYFLOW_ERROR_CODE.INVALID_LABEL_MODE_OPTION],
+    ];
+    cases.forEach(([options, expected]) => {
+      try {
+        validateRenderOptions(options);
+        throw new Error('expected to throw');
+      } catch (err) {
+        expect(err.errors[0].code).toBe(expected.code);
+        expect(err.errors[0].description).toBe(expected.description);
+      }
+    });
+  });
+});

From 82156d3ddf6296ec0c149515d890bd148ae9676d Mon Sep 17 00:00:00 2001
From: skyflow-bharti 
Date: Tue, 22 Sep 2026 12:53:48 +0530
Subject: [PATCH 2/7] SK-2449 add preserve file name fix

---
 core/internal/iframe-form/index.ts            |  5 +-
 core/libs/element-options.ts                  |  3 +-
 core/utils/logs.ts                            |  1 -
 .../src/internal/frame-element-init.ts        | 13 ++--
 .../frame-element-init.additional.test.js     | 65 +++++++++++++++++--
 .../core/internal/frame-element-init.test.js  |  2 +-
 .../internal/iframe-form/iframe-form.test.js  | 39 +++++++++++
 .../tests/libs/element-options.test.js        | 10 +++
 8 files changed, 126 insertions(+), 12 deletions(-)

diff --git a/core/internal/iframe-form/index.ts b/core/internal/iframe-form/index.ts
index ef8ffe9c4..181a77ecc 100644
--- a/core/internal/iframe-form/index.ts
+++ b/core/internal/iframe-form/index.ts
@@ -576,7 +576,10 @@ export default class IFrameFormElement extends EventEmitter {
               oversizedFileNames.push(files[i].name);
             }
           }
-          if (this.preserveFileName) vaildateFileNames = vaildateFileName(files[i].name);
+          // Once any file name fails, keep the flag false; do not let a later valid name reset it.
+          if (this.preserveFileName && vaildateFileNames) {
+            vaildateFileNames = vaildateFileName(files[i].name);
+          }
         }
         if (oversizedFileNames.length > 0) {
           if (files.length === 1) {
diff --git a/core/libs/element-options.ts b/core/libs/element-options.ts
index 4fdcd9e12..6d9f6095f 100644
--- a/core/libs/element-options.ts
+++ b/core/libs/element-options.ts
@@ -381,7 +381,8 @@ export const formatOptions = (
       break;
     }
 
-    case ELEMENTS.FILE_INPUT.name || ELEMENTS.MULTI_FILE_INPUT.name: {
+    case ELEMENTS.FILE_INPUT.name:
+    case ELEMENTS.MULTI_FILE_INPUT.name: {
       if (!Object.prototype.hasOwnProperty.call(formattedOptions, 'preserveFileName')) {
         formattedOptions = { ...formattedOptions, preserveFileName: true };
       }
diff --git a/core/utils/logs.ts b/core/utils/logs.ts
index 8894ca12a..ad70c3c1e 100644
--- a/core/utils/logs.ts
+++ b/core/utils/logs.ts
@@ -115,7 +115,6 @@ const logs = {
     NO_ELEMENTS_IN_COLLECT: 'Validation error. No elements found in collect container',
     NO_ELEMENTS_IN_COMPOSABLE: 'Validation error. No elements found in composable container',
     NO_ELEMENTS_IN_REVEAL: 'Validation error. No elements found in reveal container',
-    INVALID_FILE_NAMES: 'Invalid File Name. Only alphanumeric characters and !-_.*() are allowed.',
     INVALID_FILE_NAME: 'Invalid File Name. Only alphanumeric characters and !-_.*() are allowed.',
     CLIENT_CONNECTION: 'client connection not established. client info has not reached iframes',
     INVALID_BEARER_TOKEN: 'Token generated from \'getBearerToken\' callback function is invalid. Make sure the implementation of \'getBearerToken\' is correct.',
diff --git a/packages/skyflow-js/src/internal/frame-element-init.ts b/packages/skyflow-js/src/internal/frame-element-init.ts
index 4bdbe3b87..d936a3129 100644
--- a/packages/skyflow-js/src/internal/frame-element-init.ts
+++ b/packages/skyflow-js/src/internal/frame-element-init.ts
@@ -334,7 +334,8 @@ export default class FrameElementInit extends CoreFrameElementInit {
     }
 
     if (state.value === undefined || state.value === null || state.value === '') {
-      rootReject({ error: 'No files selected' });
+      const noFileError = new SkyflowError(SKYFLOW_ERROR_CODE.NO_FILE_SELECTED, [], true);
+      rootReject({ errorResponse: [{ error: noFileError.error }] });
       return;
     }
 
@@ -461,9 +462,13 @@ export default class FrameElementInit extends CoreFrameElementInit {
         throw new SkyflowError(SKYFLOW_ERROR_CODE.INVALID_FILE_TYPE, [], true);
       }
 
-      const isValidFileName = vaildateFileName(file.name);
-      if (!isValidFileName) {
-        throw new SkyflowError(SKYFLOW_ERROR_CODE.INVALID_FILE_NAME, [], true);
+      // Only validate the original file name when it will actually be sent to the vault.
+      // With preserveFileName: false the name is replaced with a generated UUID before upload.
+      if (fileElement.preserveFileName) {
+        const isValidFileName = vaildateFileName(file.name);
+        if (!isValidFileName) {
+          throw new SkyflowError(SKYFLOW_ERROR_CODE.INVALID_FILE_NAME, [], true);
+        }
       }
     });
     return true;
diff --git a/packages/skyflow-js/tests/core/internal/frame-element-init.additional.test.js b/packages/skyflow-js/tests/core/internal/frame-element-init.additional.test.js
index 273a2b435..47e5c6e26 100644
--- a/packages/skyflow-js/tests/core/internal/frame-element-init.additional.test.js
+++ b/packages/skyflow-js/tests/core/internal/frame-element-init.additional.test.js
@@ -333,22 +333,24 @@ describe('FrameElementInit extended unit tests', () => {
     expect(err.errorResponse[0].error).toMatchObject({ code: 400 });
   });
 
-  test('multipleUploadFiles rejects with { error: "No files selected" } when state.value is empty', async () => {
+  const noFileSelectedError = { code: 400, description: 'No File Selected' };
+
+  test('multipleUploadFiles rejects with NO_FILE_SELECTED in errorResponse when state.value is empty', async () => {
     const instance = new FrameElementInit();
     const fileElement = makeFileElement({ multiple: true, files: [makeFile('a.txt')] });
     fileElement.state.value = '';
     const config = { vaultURL: 'https://vault.url', vaultID: 'vault123', authToken: 'token123' };
     await expect(instance['multipleUploadFiles'](fileElement, config, undefined))
-      .rejects.toEqual({ error: 'No files selected' });
+      .rejects.toEqual({ errorResponse: [{ error: noFileSelectedError }] });
   });
 
-  test('multipleUploadFiles rejects with { error: "No files selected" } when state.value is null', async () => {
+  test('multipleUploadFiles rejects with NO_FILE_SELECTED in errorResponse when state.value is null', async () => {
     const instance = new FrameElementInit();
     const fileElement = makeFileElement({ multiple: true, files: [makeFile('a.txt')] });
     fileElement.state.value = null;
     const config = { vaultURL: 'https://vault.url', vaultID: 'vault123', authToken: 'token123' };
     await expect(instance['multipleUploadFiles'](fileElement, config, undefined))
-      .rejects.toEqual({ error: 'No files selected' });
+      .rejects.toEqual({ errorResponse: [{ error: noFileSelectedError }] });
   });
 
   test('multipleUploadFiles errorResponse contains error when SkyflowError has .errors[] (plural)', async () => {
@@ -513,6 +515,46 @@ describe('FrameElementInit extended unit tests', () => {
     expect(() => instance['validateFiles'](files, fileElement.state, fileElement)).toThrow(SkyflowError);
   });
 
+  test('validateFiles skips filename validation when preserveFileName is false', () => {
+    const instance = new FrameElementInit();
+    const files = [makeFile('my file.pdf')];
+    const fileElement = makeFileElement({ multiple: true, files, preserveFileName: false });
+    helpers.fileValidation = jest.fn(() => true);
+    helpers.vaildateFileName = jest.fn(() => false); // would reject if consulted
+    expect(() => instance['validateFiles'](files, fileElement.state, fileElement)).not.toThrow();
+    expect(helpers.vaildateFileName).not.toHaveBeenCalled();
+  });
+
+  test('validateFiles still throws INVALID_FILE_NAME when preserveFileName is true', () => {
+    const instance = new FrameElementInit();
+    const files = [makeFile('my file.pdf')];
+    const fileElement = makeFileElement({ multiple: true, files, preserveFileName: true });
+    helpers.fileValidation = jest.fn(() => true);
+    helpers.vaildateFileName = jest.fn(() => false);
+    expect(() => instance['validateFiles'](files, fileElement.state, fileElement)).toThrow(SkyflowError);
+    expect(helpers.vaildateFileName).toHaveBeenCalledTimes(1);
+  });
+
+  test('multipleUploadFiles uploads a file with an invalid original name when preserveFileName is false', async () => {
+    const instance = new FrameElementInit();
+    const files = [makeFile('my file.pdf')];
+    const fileElement = makeFileElement({ multiple: true, files, preserveFileName: false });
+    instance.iframeFormList = [fileElement];
+    helpers.fileValidation = jest.fn(() => true);
+    helpers.vaildateFileName = jest.fn(() => false); // original name is invalid
+    const originalGenerateUploadFileName = helpers.generateUploadFileName;
+    helpers.generateUploadFileName = jest.fn(() => 'generated-uuid.pdf');
+    mockClientRequest.mockResolvedValue({ skyflow_id: 'abc' });
+    const config = { vaultURL: 'https://vault.url', vaultID: 'vault123', authToken: 'token123' };
+    await expect(instance['multipleUploadFiles'](fileElement, config, undefined))
+      .resolves.toEqual({ fileUploadResponse: [{ skyflow_id: 'abc' }] });
+    expect(mockClientRequest).toHaveBeenCalledTimes(1);
+    const uploaded = mockClientRequest.mock.calls[0][0].body.get('file');
+    expect(uploaded.name).toBe('generated-uuid.pdf');
+    expect(helpers.vaildateFileName).not.toHaveBeenCalled();
+    helpers.generateUploadFileName = originalGenerateUploadFileName;
+  });
+
   test('parallelUploadFiles resolves with aggregated responses when all succeed', async () => {
     const instance = new FrameElementInit();
     const fileElementA = makeFileElement({ multiple: false, files: [makeFile('a.txt')] });
@@ -591,6 +633,21 @@ describe('FrameElementInit extended unit tests', () => {
     genSpy.mockRestore();
   });
 
+  test('uploadFiles (single FILE_INPUT) skips filename validation and uploads when preserveFileName is false', async () => {
+    const instance = new FrameElementInit();
+    const file = makeFile('my file.pdf');
+    const element = makeSingleFileElement({ file, preserveFileName: false });
+    helpers.fileValidation = jest.fn(() => true);
+    helpers.vaildateFileName = jest.fn(() => false); // original name is invalid
+    const genSpy = jest.spyOn(helpers, 'generateUploadFileName').mockImplementation(() => 'gen_name.pdf');
+    mockClientRequest.mockResolvedValue({ upload: 'ok' });
+    const config = { vaultURL: 'https://vault.url', vaultID: 'vault123', authToken: 'tokenXYZ' };
+    await expect(instance.uploadFiles(element, config)).resolves.toEqual({ upload: 'ok' });
+    expect(helpers.vaildateFileName).not.toHaveBeenCalled();
+    expect(mockClientRequest.mock.calls[0][0].body.get('file').name).toBe('gen_name.pdf');
+    genSpy.mockRestore();
+  });
+
   test('uploadFiles rejects on invalid file name when preserveFileName true', async () => {
     const instance = new FrameElementInit();
     const file = makeFile('bad.txt', 5);
diff --git a/packages/skyflow-js/tests/core/internal/frame-element-init.test.js b/packages/skyflow-js/tests/core/internal/frame-element-init.test.js
index 7835e3307..837dc80e0 100644
--- a/packages/skyflow-js/tests/core/internal/frame-element-init.test.js
+++ b/packages/skyflow-js/tests/core/internal/frame-element-init.test.js
@@ -697,7 +697,7 @@ describe('FrameElementInit Additional Test Cases', () => {
             expect(postMessageSpy).toHaveBeenCalledWith(
                 expect.objectContaining({
                     type: `${ELEMENT_EVENTS_TO_IFRAME.MULTIPLE_UPLOAD_FILES_RESPONSE}:element:MULTI_FILE_INPUT:123`,
-                    data: {"error": "No files selected"}, // Response data (success or error)
+                    data: { errorResponse: [{ error: { code: 400, description: 'No File Selected' } }] }, // Response data (success or error)
                 }),
                 'http://localhost.com'
             );
diff --git a/packages/skyflow-js/tests/core/internal/iframe-form/iframe-form.test.js b/packages/skyflow-js/tests/core/internal/iframe-form/iframe-form.test.js
index 0d7adcf55..1a61085d7 100644
--- a/packages/skyflow-js/tests/core/internal/iframe-form/iframe-form.test.js
+++ b/packages/skyflow-js/tests/core/internal/iframe-form/iframe-form.test.js
@@ -1169,6 +1169,45 @@ describe('MULTI_FILE_INPUT validator - specific UI error messages', () => {
     return arr;
   };
 
+  test('invalid file name shows the specific INVALID_FILE_NAME message in a COLLECT container for both file element types', () => {
+    const expected = logs.errorLogs.INVALID_FILE_NAME;
+    const badName = new File(['a'], 'my file.pdf', { type: 'application/pdf' });
+    const cases = [
+      [file_element, badName],
+      [multi_file_element, makeFileList(badName)],
+    ];
+    cases.forEach(([name, value]) => {
+      const element = new IFrameFormElement(name, 'Passport', { containerType: ContainerType.COLLECT }, context);
+      element.preserveFileName = true;
+      element.state.value = value;
+      expect(element.validator(value)).toBe(false);
+      expect(element.errorText).toBe(expected);
+    });
+  });
+
+  test('MULTI_FILE_INPUT with preserveFileName=true fails when an earlier file has an invalid name even if the last one is valid', () => {
+    const element = new IFrameFormElement(multi_file_element, '', { containerType: ContainerType.COLLECT }, context);
+    element.preserveFileName = true;
+    const badName = new File(['a'], 'my file.pdf', { type: 'application/pdf' }); // space is not allowed
+    const goodName = new File(['b'], 'ok.pdf', { type: 'application/pdf' });
+    const fileList = makeFileList(badName, goodName); // invalid first, valid last
+    element.state.value = fileList;
+    const result = element.validator(fileList);
+    expect(result).toBe(false);
+    expect(element.errorText).toBe(parameterizedString(logs.errorLogs.INVALID_FILE_NAME));
+  });
+
+  test('MULTI_FILE_INPUT with preserveFileName=false ignores invalid file names', () => {
+    const element = new IFrameFormElement(multi_file_element, '', { containerType: ContainerType.COLLECT }, context);
+    element.preserveFileName = false;
+    const badName = new File(['a'], 'my file.pdf', { type: 'application/pdf' });
+    const goodName = new File(['b'], 'ok.pdf', { type: 'application/pdf' });
+    const fileList = makeFileList(badName, goodName);
+    element.state.value = fileList;
+    const result = element.validator(fileList);
+    expect(result).toBe(true);
+  });
+
   test('invalid file type in MULTI_FILE_INPUT shows generic error not size-specific message', () => {
     const element = new IFrameFormElement(multi_file_element, '', { containerType: ContainerType.COLLECT }, context);
     element.maxFileSize = 10_000_000; // 10 MB — file below this so size is not the issue
diff --git a/packages/skyflow-js/tests/libs/element-options.test.js b/packages/skyflow-js/tests/libs/element-options.test.js
index 5e7c1e6d4..b6497b967 100644
--- a/packages/skyflow-js/tests/libs/element-options.test.js
+++ b/packages/skyflow-js/tests/libs/element-options.test.js
@@ -103,6 +103,16 @@ describe('test formatOptions function with format and translation', () => {
         expect(formattedOptions).toEqual({required:true,preserveFileName:true});
     });
 
+    test('should return preserveFileName true for MULTI_FILE_INPUT when not provided in options',()=>{
+        const formattedOptions = formatOptions(FileElementType.MULTI_FILE_INPUT,{required:true},LogLevel.ERROR);
+        expect(formattedOptions).toEqual({required:true,preserveFileName:true});
+    });
+
+    test('should keep preserveFileName false for MULTI_FILE_INPUT when provided as false',()=>{
+        const formattedOptions = formatOptions(FileElementType.MULTI_FILE_INPUT,{required:true,preserveFileName:false},LogLevel.ERROR);
+        expect(formattedOptions).toEqual({required:true,preserveFileName:false});
+    });
+
     test('should return preserveFileName false when not provied as false in options',()=>{
         const formattedOptions = formatOptions(FileElementType.FILE_INPUT,{required:true,preserveFileName:false},LogLevel.ERROR);
         expect(formattedOptions).toEqual({required:true,preserveFileName:false});

From 6d6f2a45348bb46a403a4dfca66d27906aada065 Mon Sep 17 00:00:00 2001
From: skyflow-bharti 
Date: Tue, 22 Sep 2026 13:02:25 +0530
Subject: [PATCH 3/7] SK-2449 update lock file for jszip

---
 package-lock.json | 159 +++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 135 insertions(+), 24 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index e75d575e4..35e9dfc5b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4820,8 +4820,7 @@
     "node_modules/core-util-is": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
-      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
-      "dev": true
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
     },
     "node_modules/cosmiconfig": {
       "version": "6.0.0",
@@ -7594,6 +7593,12 @@
         "node": ">= 4"
       }
     },
+    "node_modules/immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+      "license": "MIT"
+    },
     "node_modules/import-fresh": {
       "version": "3.3.1",
       "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -7725,8 +7730,7 @@
     "node_modules/inherits": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
-      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "dev": true
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
     },
     "node_modules/inject-stylesheet": {
       "version": "2.0.0",
@@ -9326,6 +9330,54 @@
         "jss-plugin-vendor-prefixer": "10.10.0"
       }
     },
+    "node_modules/jszip": {
+      "version": "3.10.1",
+      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+      "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+      "license": "(MIT OR GPL-3.0-or-later)",
+      "dependencies": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "setimmediate": "^1.0.5"
+      }
+    },
+    "node_modules/jszip/node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "license": "MIT"
+    },
+    "node_modules/jszip/node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/jszip/node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
+    "node_modules/jszip/node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
     "node_modules/jwt-decode": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
@@ -9390,6 +9442,15 @@
         "node": ">= 0.8.0"
       }
     },
+    "node_modules/lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "immediate": "~3.0.5"
+      }
+    },
     "node_modules/lines-and-columns": {
       "version": "1.2.4",
       "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -10188,8 +10249,7 @@
     "node_modules/pako": {
       "version": "1.0.11",
       "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
-      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
-      "dev": true
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
     },
     "node_modules/param-case": {
       "version": "3.0.4",
@@ -10543,8 +10603,7 @@
     "node_modules/process-nextick-args": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
-      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
-      "dev": true
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
     },
     "node_modules/progress": {
       "version": "2.0.3",
@@ -11447,8 +11506,7 @@
     "node_modules/setimmediate": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
-      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
-      "dev": true
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
     },
     "node_modules/setprototypeof": {
       "version": "1.2.0",
@@ -12646,8 +12704,7 @@
     "node_modules/util-deprecate": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
-      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
-      "dev": true
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
     },
     "node_modules/utila": {
       "version": "0.4.0",
@@ -13467,6 +13524,7 @@
         "jquery-mask-plugin": "1.14.16",
         "jss": "10.10.0",
         "jss-preset-default": "10.10.0",
+        "jszip": "3.10.1",
         "jwt-decode": "3.1.2",
         "lodash": "4.18.1",
         "mime": "3.0.0",
@@ -17066,8 +17124,7 @@
     "core-util-is": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
-      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
-      "dev": true
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
     },
     "cosmiconfig": {
       "version": "6.0.0",
@@ -19162,6 +19219,11 @@
       "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
       "dev": true
     },
+    "immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
+    },
     "import-fresh": {
       "version": "3.3.1",
       "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -19255,8 +19317,7 @@
     "inherits": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
-      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "dev": true
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
     },
     "inject-stylesheet": {
       "version": "2.0.0",
@@ -20459,6 +20520,51 @@
         "jss-plugin-vendor-prefixer": "10.10.0"
       }
     },
+    "jszip": {
+      "version": "3.10.1",
+      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+      "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+      "requires": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "setimmediate": "^1.0.5"
+      },
+      "dependencies": {
+        "isarray": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+          "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+        },
+        "readable-stream": {
+          "version": "2.3.8",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+          "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+          "requires": {
+            "core-util-is": "~1.0.0",
+            "inherits": "~2.0.3",
+            "isarray": "~1.0.0",
+            "process-nextick-args": "~2.0.0",
+            "safe-buffer": "~5.1.1",
+            "string_decoder": "~1.1.1",
+            "util-deprecate": "~1.0.1"
+          }
+        },
+        "safe-buffer": {
+          "version": "5.1.2",
+          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+          "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+        },
+        "string_decoder": {
+          "version": "1.1.1",
+          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+          "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+          "requires": {
+            "safe-buffer": "~5.1.0"
+          }
+        }
+      }
+    },
     "jwt-decode": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
@@ -20511,6 +20617,14 @@
         "type-check": "~0.4.0"
       }
     },
+    "lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "requires": {
+        "immediate": "~3.0.5"
+      }
+    },
     "lines-and-columns": {
       "version": "1.2.4",
       "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -21116,8 +21230,7 @@
     "pako": {
       "version": "1.0.11",
       "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
-      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
-      "dev": true
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
     },
     "param-case": {
       "version": "3.0.4",
@@ -21404,8 +21517,7 @@
     "process-nextick-args": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
-      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
-      "dev": true
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
     },
     "progress": {
       "version": "2.0.3",
@@ -22080,8 +22192,7 @@
     "setimmediate": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
-      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
-      "dev": true
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
     },
     "setprototypeof": {
       "version": "1.2.0",
@@ -22228,6 +22339,7 @@
         "jquery-mask-plugin": "1.14.16",
         "jss": "10.10.0",
         "jss-preset-default": "10.10.0",
+        "jszip": "3.10.1",
         "jwt-decode": "3.1.2",
         "lodash": "4.18.1",
         "mime": "3.0.0",
@@ -23007,8 +23119,7 @@
     "util-deprecate": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
-      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
-      "dev": true
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
     },
     "utila": {
       "version": "0.4.0",

From c86793a58f356678cdcfa723cdc052de06fb1523 Mon Sep 17 00:00:00 2001
From: skyflow-bharti 
Date: Tue, 22 Sep 2026 14:28:02 +0530
Subject: [PATCH 4/7] SK-2449 fix error msg

---
 core/constants.ts                                 |  2 +-
 .../src/internal/reveal/reveal-frame.ts           |  4 ++--
 packages/skyflow-js/src/utils/validators/index.ts |  4 +++-
 .../skyflow-js/tests/utils/validators.test.js     | 15 +++++++++++++++
 4 files changed, 21 insertions(+), 4 deletions(-)

diff --git a/core/constants.ts b/core/constants.ts
index b6b0aae91..49ebf6acd 100644
--- a/core/constants.ts
+++ b/core/constants.ts
@@ -19,7 +19,7 @@ import cartesBancairesIcon from '../assets/carter-banceris.svg';
 
 export const DEFAULT_WARNING_FOR_DANGEROUS_FILE_TYPE = 'This file type is not supported for preview.';
 export const ZIP_EMPTY_ARCHIVE_MESSAGE = 'No files found in the ZIP archive.';
-export const ZIP_PREVIEW_LOADING_MESSAGE = 'Loading preview...';
+export const ZIP_PREVIEW_LOADING_MESSAGE = '...loading';
 
 // MIME types recognised as a zip archive for `zipRender`. Deliberately exact:
 // 'application/gzip' also contains "zip" but is not a zip.
diff --git a/packages/skyflow-js/src/internal/reveal/reveal-frame.ts b/packages/skyflow-js/src/internal/reveal/reveal-frame.ts
index e0d1ed020..1c21af44d 100644
--- a/packages/skyflow-js/src/internal/reveal/reveal-frame.ts
+++ b/packages/skyflow-js/src/internal/reveal/reveal-frame.ts
@@ -242,7 +242,7 @@ class RevealFrame extends CoreRevealFrame {
             const fileType = this.getExtension(url);
             const isZip = RevealFrame.isZipMimeType(fileType);
             if (isZip && this.#renderOptions.zipRender) {
-              this.dataElememt.innerText = '...loading';
+              this.dataElememt.innerText = ZIP_PREVIEW_LOADING_MESSAGE;
               this.unZipFiles(url).then((files) => {
                 printLog(parameterizedString(logs.infoLogs.FILES_UNZIPPED_SUCCESSFULLY,
                   CLASS_NAME, this.record?.skyflowID), MessageType.LOG, this.context?.logLevel);
@@ -457,7 +457,7 @@ class RevealFrame extends CoreRevealFrame {
 
   private async downloadCurrentFile(): Promise {
     if (!this.#renderOptions.allowDownload) {
-      printLog(logs.errorLogs.DOWNLOAD_NOT_ALLOWED, MessageType.ERROR, this.context?.logLevel);
+      printLog(logs.errorLogs.DOWNLOAD_NOT_ALLOWED, MessageType.WARN, this.context?.logLevel);
       return;
     }
     const currentFile = this.#currentFile;
diff --git a/packages/skyflow-js/src/utils/validators/index.ts b/packages/skyflow-js/src/utils/validators/index.ts
index c81363b52..888162d60 100644
--- a/packages/skyflow-js/src/utils/validators/index.ts
+++ b/packages/skyflow-js/src/utils/validators/index.ts
@@ -208,7 +208,9 @@ const DANGEROUS_FILE_EXTENSIONS = [
   '.ova', '.ovf', '.vdi', '.vbox',
 ];
 
-export const isDangerousFileType = (file: { name?: string; type?: string }): boolean => {
+export const isDangerousFileType = (
+  file?: { name?: string; type?: string } | null,
+): boolean => {
   const lowerName = (file?.name || '').toLowerCase();
   const lowerType = (file?.type || '').toLowerCase();
   if (DANGEROUS_FILE_EXTENSIONS.some((ext) => lowerName.endsWith(ext))) {
diff --git a/packages/skyflow-js/tests/utils/validators.test.js b/packages/skyflow-js/tests/utils/validators.test.js
index cfe21ad00..59336712a 100644
--- a/packages/skyflow-js/tests/utils/validators.test.js
+++ b/packages/skyflow-js/tests/utils/validators.test.js
@@ -1482,6 +1482,21 @@ describe('isDangerousFileType', () => {
     expect(isDangerousFileType({ name: 'clip.mp4', type: 'video/mp4' })).toBe(false);
     expect(isDangerousFileType({})).toBe(false);
     expect(isDangerousFileType(undefined)).toBe(false);
+    expect(isDangerousFileType(null)).toBe(false);
+    expect(isDangerousFileType()).toBe(false);
+  });
+  test('handles an undefined type: decides by extension alone', () => {
+    // Extracted zip entries often have no MIME type; the extension check must carry the decision.
+    expect(isDangerousFileType({ name: 'run.exe' })).toBe(true);
+    expect(isDangerousFileType({ name: 'run.exe', type: undefined })).toBe(true);
+    expect(isDangerousFileType({ name: 'photo.png' })).toBe(false);
+    expect(isDangerousFileType({ name: 'photo.png', type: undefined })).toBe(false);
+  });
+  test('handles an undefined name: decides by mime type alone', () => {
+    expect(isDangerousFileType({ type: 'text/html' })).toBe(true);
+    expect(isDangerousFileType({ name: undefined, type: 'application/x-msdownload' })).toBe(true);
+    expect(isDangerousFileType({ type: 'image/png' })).toBe(false);
+    expect(isDangerousFileType({ name: undefined, type: 'application/pdf' })).toBe(false);
   });
 });
 

From 770d7f9ec45c9250abfad97b905a454ff40e0a12 Mon Sep 17 00:00:00 2001
From: skyflow-bharti 
Date: Tue, 22 Sep 2026 14:59:44 +0530
Subject: [PATCH 5/7] SK-2449 make consistent metadata keynames

---
 core/types/index.ts                           |  2 +-
 .../src/internal/reveal/reveal-frame.ts       |  4 +-
 .../skyflow-js/tests/api-utils/reveal.test.js |  2 +-
 .../internal/reveal/reveal-frame-zip.test.js  | 41 ++++++++++++++++++-
 4 files changed, 43 insertions(+), 6 deletions(-)

diff --git a/core/types/index.ts b/core/types/index.ts
index 3a2af0e78..58449508f 100644
--- a/core/types/index.ts
+++ b/core/types/index.ts
@@ -130,7 +130,7 @@ export interface IRevealResponseType {
 }
 export interface IUnzippedFileMetadata {
   name: string;
-  fileSize: number;
+  size: number;
   type: string;
 }
 
diff --git a/packages/skyflow-js/src/internal/reveal/reveal-frame.ts b/packages/skyflow-js/src/internal/reveal/reveal-frame.ts
index 1c21af44d..b73600468 100644
--- a/packages/skyflow-js/src/internal/reveal/reveal-frame.ts
+++ b/packages/skyflow-js/src/internal/reveal/reveal-frame.ts
@@ -239,7 +239,7 @@ class RevealFrame extends CoreRevealFrame {
             if (resolvedResult.fields && data.column) {
               url = resolvedResult.fields[data.column];
             }
-            const fileType = this.getExtension(url);
+            const fileType = this.getExtension(url) || resolvedResult?.fileMetadata?.file?.fileType;
             const isZip = RevealFrame.isZipMimeType(fileType);
             if (isZip && this.#renderOptions.zipRender) {
               this.dataElememt.innerText = ZIP_PREVIEW_LOADING_MESSAGE;
@@ -247,7 +247,7 @@ class RevealFrame extends CoreRevealFrame {
                 printLog(parameterizedString(logs.infoLogs.FILES_UNZIPPED_SUCCESSFULLY,
                   CLASS_NAME, this.record?.skyflowID), MessageType.LOG, this.context?.logLevel);
                 const metadata = files.map((f) => ({
-                  name: f.name, fileSize: f.size, type: f.type,
+                  name: f.name, size: f.size, type: f.type,
                 }));
                 if (renderId !== this.#renderSeq) {
                   // A newer renderFile() call owns the DOM now; just answer this one.
diff --git a/packages/skyflow-js/tests/api-utils/reveal.test.js b/packages/skyflow-js/tests/api-utils/reveal.test.js
index 799c2a737..e9f4e3e79 100644
--- a/packages/skyflow-js/tests/api-utils/reveal.test.js
+++ b/packages/skyflow-js/tests/api-utils/reveal.test.js
@@ -339,7 +339,7 @@ describe("formatForRenderClient fn test",()=>{
     }});
   });
   test("with unZippedFilesMetadata should include it in success",()=>{
-    const unZippedFilesMetadata = [{ name: 'a.png', fileSize: 10, type: 'image/png' }];
+    const unZippedFilesMetadata = [{ name: 'a.png', size: 10, type: 'image/png' }];
     const testInput = {"fields": { "col" : "http://dummy.com", "skyflow_id": "id" },
       "fileMetadata": { contentType: 'application/zip' }, unZippedFilesMetadata };
     const fnResponse = formatForRenderClient(testInput, 'col');
diff --git a/packages/skyflow-js/tests/core/internal/reveal/reveal-frame-zip.test.js b/packages/skyflow-js/tests/core/internal/reveal/reveal-frame-zip.test.js
index 0cc5ab21d..70cd98912 100644
--- a/packages/skyflow-js/tests/core/internal/reveal/reveal-frame-zip.test.js
+++ b/packages/skyflow-js/tests/core/internal/reveal/reveal-frame-zip.test.js
@@ -206,7 +206,7 @@ describe("Reveal Frame - zip file render", () => {
     expect(success.unZippedFilesMetadata.map((f) => f.name).sort()).toEqual(['doc.pdf', 'photo.png']);
     // size comes from the archive's central directory, before any extraction
     expect(success.unZippedFilesMetadata.find((f) => f.name === 'photo.png')).toEqual({
-      name: 'photo.png', type: 'image/png', fileSize: 'png-bytes'.length,
+      name: 'photo.png', type: 'image/png', size: 'png-bytes'.length,
     });
 
     const items = navItems();
@@ -766,11 +766,48 @@ describe("Reveal Frame - zip file render", () => {
     for (let i = 0; i < 300; i += 1) entries[`dir${i % 7}/f${i}.txt`] = 'x'.repeat(50);
     const response = await renderZip(entries, { zipRender: true, autoSelectFirst: false });
     expect(response.data.result.success.unZippedFilesMetadata).toHaveLength(300);
-    expect(response.data.result.success.unZippedFilesMetadata[0].fileSize).toBe(50);
+    expect(response.data.result.success.unZippedFilesMetadata[0].size).toBe(50);
     expect(navItems()).toHaveLength(300);
     expect(createObjectURL).not.toHaveBeenCalled();
   });
 
+  // ------------------------------------------------------- metadata shape
+  test("unZippedFilesMetadata: every entry has exactly name, size and type, with size from the archive", async () => {
+    const entries = {
+      'img.png': 'png-bytes',
+      'docs/2024/report.pdf': 'hello pdf',
+      'docs/notes.txt': 'n',
+    };
+    const response = await renderZip(entries, { zipRender: true, autoSelectFirst: false });
+    const manifest = response.data.result.success.unZippedFilesMetadata;
+
+    expect(manifest).toHaveLength(3);
+    manifest.forEach((entry) => {
+      expect(Object.keys(entry).sort()).toEqual(['name', 'size', 'type']);
+      expect(typeof entry.name).toBe('string');
+      expect(typeof entry.size).toBe('number');
+      expect(typeof entry.type).toBe('string');
+      // size is the uncompressed byte length reported by the archive; nothing was inflated yet
+      expect(entry.size).toBe(entries[entry.name].length);
+      expect(entry).not.toHaveProperty('fileSize');
+    });
+    expect(manifest.map((f) => f.name).sort()).toEqual(['docs/2024/report.pdf', 'docs/notes.txt', 'img.png']);
+    expect(manifest.find((f) => f.name === 'img.png').type).toBe('image/png');
+    expect(manifest.find((f) => f.name === 'docs/2024/report.pdf').type).toBe('application/pdf');
+    expect(createObjectURL).not.toHaveBeenCalled();
+  });
+
+  test("the archive's own fileMetadata is preserved alongside unZippedFilesMetadata", async () => {
+    const response = await renderZip({ 'a.png': 'a', 'b.pdf': 'bb' }, { zipRender: true, autoSelectFirst: false });
+    const { success } = response.data.result;
+    // fileMetadata describes the zip record itself and must survive the manifest being added
+    expect(success.fileMetadata).toEqual({ contentType: 'application/zip' });
+    expect(success.unZippedFilesMetadata).toHaveLength(2);
+    expect(success.unZippedFilesMetadata.map((f) => f.size)).toEqual(expect.arrayContaining([1, 2]));
+    expect(success.skyflow_id).toBe('abc123');
+    expect(success.column).toBe('primary_card_file');
+  });
+
   // ---------------------------------------------------------------- re-render / races
   test("re-rendering revokes previous blob URLs and rebuilds the layout", async () => {
     await renderZip({ 'a.png': 'a' });

From 9c1b78d74f51854fef3b087e2fcaf7dcc6a230e4 Mon Sep 17 00:00:00 2001
From: skyflow-bharti 
Date: Tue, 22 Sep 2026 16:00:35 +0530
Subject: [PATCH 6/7] SK-2449 update styles

---
 core/constants.ts                             | 13 +++++-
 .../internal/reveal/reveal-frame-zip.test.js  | 44 ++++++++++++++++++-
 2 files changed, 54 insertions(+), 3 deletions(-)

diff --git a/core/constants.ts b/core/constants.ts
index 49ebf6acd..319f49dfb 100644
--- a/core/constants.ts
+++ b/core/constants.ts
@@ -544,6 +544,9 @@ export const ZIP_CONTAINER_STYLES = {
     'flex-direction': 'row',
     height: '100%',
     width: '100%',
+    overflow: 'auto',
+    border: '1px solid #a5a2a2',
+    borderRadius: '4px',
   },
 };
 
@@ -567,6 +570,10 @@ export const ZIP_NAV_LIST_ITEM_STYLES = {
     borderRadius: '4px',
     boxSizing: 'border-box' as const,
     backgroundColor: '#f9f9f9',
+    overflow: 'hidden',
+    textOverflow: 'ellipsis',
+    whiteSpace: 'nowrap',
+    wordBreak: 'break-all',
   },
   [STYLE_TYPE.FOCUS]: {
     alignItems: 'center',
@@ -576,7 +583,11 @@ export const ZIP_NAV_LIST_ITEM_STYLES = {
     borderRadius: '4px',
     boxSizing: 'border-box' as const,
     backgroundColor: '#f9f9f9',
-    border: '2px solid #b0b3b8ff',
+    border: '2px solid rgb(155, 158, 164)',
+    overflow: 'hidden',
+    textOverflow: 'ellipsis',
+    whiteSpace: 'nowrap',
+    wordBreak: 'break-all',
   },
 };
 
diff --git a/packages/skyflow-js/tests/core/internal/reveal/reveal-frame-zip.test.js b/packages/skyflow-js/tests/core/internal/reveal/reveal-frame-zip.test.js
index 70cd98912..0dd5e1b0b 100644
--- a/packages/skyflow-js/tests/core/internal/reveal/reveal-frame-zip.test.js
+++ b/packages/skyflow-js/tests/core/internal/reveal/reveal-frame-zip.test.js
@@ -562,13 +562,26 @@ describe("Reveal Frame - zip file render", () => {
   });
 
   test("download is blocked by default (allowDownload false)", async () => {
-    await renderZip({ 'a.png': 'a' });
+    // A download attempt without allowDownload is a misuse, not a failure, so the SDK
+    // reports it at WARN level. Run this frame at LogLevel.WARN so the message is emitted.
+    defineUrl('http://localhost/?' + btoa(JSON.stringify({
+      ...frameData, context: { logLevel: LogLevel.WARN, env: Env.PROD },
+    })));
+    mockFetch(await buildZip({ 'a.png': 'a' }));
+    RevealFrame.init();
+    dispatchRenderRequest();
+    await waitForResponse();
     await waitFor(() => document.querySelector('#zip-panel img'));
+
+    const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
     const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
     dispatchDownload();
     await new Promise((r) => setTimeout(r, 10));
+
     expect(clickSpy).not.toHaveBeenCalled();
-    expect(errorSpy.mock.calls.flat().join(' ')).toContain(logs.errorLogs.DOWNLOAD_NOT_ALLOWED);
+    expect(warnSpy.mock.calls.flat().join(' ')).toContain(logs.errorLogs.DOWNLOAD_NOT_ALLOWED);
+    // and it is a warning, not an error
+    expect(errorSpy.mock.calls.flat().join(' ')).not.toContain(logs.errorLogs.DOWNLOAD_NOT_ALLOWED);
   });
 
   test("download is blocked for dangerous file types even when allowed", async () => {
@@ -771,6 +784,33 @@ describe("Reveal Frame - zip file render", () => {
     expect(createObjectURL).not.toHaveBeenCalled();
   });
 
+  // ---------------------------------------------------------- nav chips
+  test("long unbroken file names: nav chips stay single-line, truncate with an ellipsis, and keep the full path on hover", async () => {
+    const longName = 'zipfilesfordemo/zipfile_flower_summer_flowers_yellow_flower_with_a_very_long_name_that_cannot_wrap.jpg';
+    await renderZip({ [longName]: 'jpg', 'short.pdf': 'p' }, { zipRender: true, autoSelectFirst: false });
+
+    const items = Array.from(navItems());
+    const chip = items.find((li) => li.title === longName);
+    expect(chip).toBeTruthy();
+    // basename label (default labelMode) but the full archive path stays on hover
+    expect(chip.textContent).toBe('zipfile_flower_summer_flowers_yellow_flower_with_a_very_long_name_that_cannot_wrap.jpg');
+    expect(chip.title).toBe(longName);
+
+    // both the resting and the selected chip styles carry the truncation rules
+    [ZIP_NAV_LIST_ITEM_STYLES.base, ZIP_NAV_LIST_ITEM_STYLES.focus].forEach((style) => {
+      expect(style).toEqual(expect.objectContaining({
+        whiteSpace: 'nowrap',
+        overflow: 'hidden',
+        textOverflow: 'ellipsis',
+      }));
+    });
+    const index = items.indexOf(chip);
+    const applied = jssCalls.filter((c) => c.name === `zip-nav-item${index}`).pop()?.styles;
+    expect(applied.base).toEqual(expect.objectContaining({
+      whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
+    }));
+  });
+
   // ------------------------------------------------------- metadata shape
   test("unZippedFilesMetadata: every entry has exactly name, size and type, with size from the archive", async () => {
     const entries = {

From d3e5da0add9870f4c50cd188f692d70546d02e2c Mon Sep 17 00:00:00 2001
From: skyflow-bharti 
Date: Tue, 22 Sep 2026 16:15:38 +0530
Subject: [PATCH 7/7] SK-2449 update response type

---
 core/types/index.ts                   | 5 +++++
 packages/skyflow-js/README.md         | 6 ++++--
 packages/skyflow-js/src/index-node.ts | 1 +
 3 files changed, 10 insertions(+), 2 deletions(-)

diff --git a/core/types/index.ts b/core/types/index.ts
index 58449508f..f64431663 100644
--- a/core/types/index.ts
+++ b/core/types/index.ts
@@ -565,6 +565,11 @@ export interface RenderFileResponse {
   success?: {
     skyflow_id: string,
     column: string,
+    // Metadata of the rendered file record itself (name, size, content type).
+    fileMetadata?: Record,
+    // Present only when a zip archive was rendered with `zipRender: true`:
+    // one entry per extracted file.
+    unZippedFilesMetadata?: IUnzippedFileMetadata[],
   },
   errors?: {
     skyflowId: string,
diff --git a/packages/skyflow-js/README.md b/packages/skyflow-js/README.md
index 05639c801..e5b48db2c 100644
--- a/packages/skyflow-js/README.md
+++ b/packages/skyflow-js/README.md
@@ -4376,13 +4376,15 @@ For zip files, the `renderFile()` success response also includes `unZippedFilesM
     column: 'string',
     fileMetadata: { ... },
     unZippedFilesMetadata: [
-      { name: 'photo.png', fileSize: 20480, type: 'image/png' },
-      { name: 'doc.pdf', fileSize: 102400, type: 'application/pdf' },
+      { name: 'photo.png', size: 20480, type: 'image/png' },
+      { name: 'doc.pdf', size: 102400, type: 'application/pdf' },
     ],
   },
 }
 ```
 
+For TypeScript users, the npm package exports `RenderFileResponse` for this response, `UnzippedFileMetadata` for each entry of `unZippedFilesMetadata`, and `RenderOptions` for the `renderFile()` options object.
+
 #### Download the currently previewed file
 For composable reveal elements rendering a zip file with `allowDownload: true`, call `downloadCurrentFile()` to download the file currently selected in the preview. Download is off by default; without `allowDownload: true` the call is ignored and an error is logged.
 
diff --git a/packages/skyflow-js/src/index-node.ts b/packages/skyflow-js/src/index-node.ts
index bd38c8ca4..10a8b84c1 100644
--- a/packages/skyflow-js/src/index-node.ts
+++ b/packages/skyflow-js/src/index-node.ts
@@ -53,6 +53,7 @@ export type {
   RevealResponse,
   RenderFileResponse,
   IRenderOptions as RenderOptions,
+  IUnzippedFileMetadata as UnzippedFileMetadata,
   IValidationRule as ValidationRule,
   ElementState,
   ErrorMessages,