Compare commits

..

2 Commits

173 changed files with 31689 additions and 18137 deletions

View File

@@ -17,7 +17,7 @@
},
"env": {
"webextensions": true,
"es2020": true,
"es6": true,
"browser": true,
"node": true
},
@@ -31,7 +31,6 @@
"wasm",
"tlsn",
"util",
"lib",
"plugins",
"webpack.config.js"
]

46
.github/workflows/build.yaml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: build
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: 16
- uses: pnpm/action-setup@v2
name: Install pnpm
with:
version: 8
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v3
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Build
run: npm run build

View File

@@ -1,80 +0,0 @@
name: ci
on:
pull_request:
release:
types: [published]
jobs:
build-lint-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint all packages
run: npm run lint
- name: Test all packages
run: npm run test
- name: Build all packages
run: npm run build:all
- name: Save extension zip file for releases
if: github.event_name == 'release'
uses: actions/upload-artifact@v4
with:
name: extension-build
path: ./packages/extension/zip/*.zip
if-no-files-found: error
release:
if: github.event_name == 'release'
runs-on: ubuntu-latest
needs: build-lint-test
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download extension from build-lint-test job
uses: actions/download-artifact@v4
with:
name: extension-build
path: ./dist
- name: 📦 Add extension zip file to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Find the extension zip file
EXTENSION_ZIP=$(find ./dist -name "extension-*.zip" -type f | head -n 1)
if [ -z "$EXTENSION_ZIP" ]; then
echo "Error: Extension zip file not found"
exit 1
fi
echo "Found extension zip: $EXTENSION_ZIP"
gh release upload "${{ github.event.release.tag_name }}" \
"$EXTENSION_ZIP" \
--clobber
# Get tokens as documented on
# * https://developer.chrome.com/docs/webstore/using-api#beforeyoubegin
# * https://github.com/fregante/chrome-webstore-upload-keys?tab=readme-ov-file
- name: 💨 Publish to chrome store
uses: browser-actions/release-chrome-extension@latest # https://github.com/browser-actions/release-chrome-extension/tree/latest/
with:
extension-id: "gcfkkledipjbgdbimfpijgbkhajiaaph"
extension-path: ./dist/extension-0.1.0.zip
oauth-client-id: ${{ secrets.OAUTH_CLIENT_ID }}
oauth-client-secret: ${{ secrets.OAUTH_CLIENT_SECRET }}
oauth-refresh-token: ${{ secrets.OAUTH_REFRESH_TOKEN }}

46
.github/workflows/lint.yaml vendored Normal file
View File

@@ -0,0 +1,46 @@
name: lint
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: 16
- uses: pnpm/action-setup@v2
name: Install pnpm
with:
version: 8
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v3
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Lint
run: npm run lint

4
.gitignore vendored
View File

@@ -8,7 +8,3 @@ bin/
build
tlsn/
zip
.vscode
.claude
coverage
packages/verifier/target/

388
CLAUDE.md
View File

@@ -1,388 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
### Monorepo Commands (from root)
- `npm install` - Install all dependencies for all packages
- `npm run dev` - Start extension development server on port 3000
- `npm run build` - Build production extension
- `npm run build:all` - Build all packages in monorepo
- `npm run test` - Run tests for all packages
- `npm run lint` - Run linting for all packages
- `npm run lint:fix` - Auto-fix linting issues for all packages
- `npm run serve:test` - Serve test page on port 8081
- `npm run clean` - Remove all node_modules, dist, and build directories
### Extension Package Commands
- `npm run build` - Production build with zip creation
- `npm run build:webpack` - Direct webpack build
- `npm run dev` - Start webpack dev server with hot reload
- `npm run test` - Run Vitest tests
- `npm run test:watch` - Run tests in watch mode
- `npm run test:coverage` - Generate test coverage report
- `npm run lint` / `npm run lint:fix` - ESLint checks and fixes
- `npm run serve:test` - Python HTTP server for integration tests
### Plugin SDK Package Commands
- `npm run build` - Build isomorphic package with Vite + TypeScript declarations
- `npm run test` - Run Vitest tests
- `npm run test:coverage` - Generate test coverage
- `npm run lint` - Run all linters (ESLint, Prettier, TypeScript)
- `npm run lint:fix` - Auto-fix linting issues
## Monorepo Architecture
The project is organized as a monorepo using npm workspaces with two main packages:
- **`packages/extension`**: Chrome Extension (Manifest V3) for TLSNotary
- **`packages/plugin-sdk`**: SDK for developing and running TLSN WebAssembly plugins using QuickJS sandboxing
**Important**: The extension must match the version of the notary server it connects to.
## Extension Architecture Overview
### Extension Entry Points
The extension has 5 main entry points defined in `webpack.config.js`:
#### 1. **Background Service Worker** (`src/entries/Background/index.ts`)
Core responsibilities:
- **Multi-Window Management**: Uses `WindowManager` class to track multiple browser windows simultaneously
- **Session Management**: Uses `SessionManager` class for plugin session lifecycle (imported but not yet integrated)
- **Request Interception**: Uses `webRequest.onBeforeRequest` API to intercept HTTP requests per window
- **Request Storage**: Each window maintains its own request history (max 1000 requests per window)
- **Message Routing**: Forwards messages between content scripts, popup, and injected page scripts
- **Offscreen Document Management**: Creates offscreen documents for background DOM operations (Chrome 109+)
- **Automatic Cleanup**: Periodic cleanup of invalid windows every 5 minutes
- Uses `webextension-polyfill` for cross-browser compatibility
Key message handlers:
- `PING``PONG` (connectivity test)
- `OPEN_WINDOW` → Creates new managed window with URL validation, request tracking, and optional overlay
- `TLSN_CONTENT_TO_EXTENSION` → Legacy handler that opens x.com window (backward compatibility)
#### 2. **Content Script** (`src/entries/Content/index.ts`)
Injected into all HTTP/HTTPS pages via manifest. Responsibilities:
- **Script Injection**: Injects `content.bundle.js` into page context to expose page-accessible API
- **TLSN Overlay Management**: Creates/updates full-screen overlay showing intercepted requests
- **Message Bridge**: Bridges messages between page scripts and extension background
- **Request Display**: Real-time updates of intercepted requests in overlay UI
Message handlers:
- `GET_PAGE_INFO` → Returns page title, URL, domain
- `SHOW_TLSN_OVERLAY` → Creates overlay with initial requests
- `UPDATE_TLSN_REQUESTS` → Updates overlay with new requests
- `HIDE_TLSN_OVERLAY` → Removes overlay and clears state
Window message handler:
- Listens for `TLSN_CONTENT_SCRIPT_MESSAGE` from page scripts
- Forwards to background via `TLSN_CONTENT_TO_EXTENSION`
#### 3. **Content Module** (`src/entries/Content/content.ts`)
Injected script running in page context (not content script context):
- **Page API**: Exposes `window.tlsn` object to web pages with:
- `sendMessage(data)`: Legacy method for backward compatibility
- `open(url, options)`: Opens new managed window with request interception
- **Lifecycle Event**: Dispatches `extension_loaded` custom event when ready
- **Web Accessible Resource**: Listed in manifest's `web_accessible_resources`
Page API usage:
```javascript
// Open a new window with request tracking
await window.tlsn.open('https://x.com', {
width: 900,
height: 700,
showOverlay: true
});
// Legacy method
window.tlsn.sendMessage({ action: 'startTLSN' });
```
#### 4. **Popup UI** (`src/entries/Popup/index.tsx`)
React-based extension popup:
- **Simple Interface**: "Hello World" boilerplate with test button
- **Redux Integration**: Connected to Redux store via `react-redux`
- **Message Sending**: Can send messages to background script
- **Styling**: Uses Tailwind CSS with custom button/input classes
- Entry point: `popup.html` (400x300px default size)
#### 5. **Offscreen Document** (`src/entries/Offscreen/index.tsx`)
Isolated React component for background processing:
- **Purpose**: Handles DOM operations unavailable in service workers
- **Message Handling**: Listens for `PROCESS_DATA` messages (example implementation)
- **Lifecycle**: Created dynamically by background script, reused if exists
- Entry point: `offscreen.html`
### Key Classes
#### **WindowManager** (`src/background/WindowManager.ts`)
Centralized management for multiple browser windows:
- **Window Tracking**: Maintains Map of window ID to ManagedWindow objects
- **Request History**: Each window stores up to 1000 intercepted requests
- **Overlay Control**: Shows/hides TLSN overlay per window with retry logic
- **Lifecycle Management**: Register, close, lookup windows by ID or tab ID
- **Window Limits**: Enforces maximum of 10 managed windows
- **Auto-cleanup**: Removes invalid windows on periodic intervals
Key methods:
- `registerWindow(config)`: Create new managed window with UUID
- `addRequest(windowId, request)`: Add intercepted request to window
- `showOverlay(windowId)`: Display request overlay (with retry)
- `cleanupInvalidWindows()`: Remove closed windows from tracking
#### **SessionManager** (`src/background/SessionManager.ts`)
Plugin session management (currently imported but not integrated):
- Uses `@tlsn/plugin-sdk` Host class for plugin execution
- Manages plugin sessions with UUID tracking
- Intended for future plugin execution functionality
### State Management
Redux store located in `src/reducers/index.tsx`:
- **App State Interface**: `{ message: string, count: number }`
- **Action Creators**:
- `setMessage(message: string)` - Updates message state
- `incrementCount()` - Increments counter
- **Store Configuration** (`src/utils/store.ts`):
- Development: Uses `redux-thunk` + `redux-logger` middleware
- Production: Uses `redux-thunk` only
- **Type Safety**: Exports `RootState` and `AppRootState` types
### Message Passing Architecture
**Page → Extension Flow (Window Opening)**:
```
Page: window.tlsn.open(url)
↓ window.postMessage(TLSN_OPEN_WINDOW)
Content Script: event listener
↓ browser.runtime.sendMessage(OPEN_WINDOW)
Background: WindowManager.registerWindow()
↓ browser.windows.create()
↓ Returns window info
```
**Request Interception Flow**:
```
Browser: HTTP request in managed window
↓ webRequest.onBeforeRequest
Background: WindowManager.addRequest()
↓ browser.tabs.sendMessage(UPDATE_TLSN_REQUESTS)
Content Script: Update overlay UI
```
**Multi-Window Management**:
- Each window has unique UUID and separate request history
- Overlay updates are sent only to the specific window's tab
- Windows are tracked by both Chrome window ID and tab ID
- Maximum 10 concurrent managed windows
**Security**:
- Content script validates origin (`event.origin === window.location.origin`)
- URL validation using `validateUrl()` utility before window creation
- Request interception limited to managed windows only
### TLSN Overlay Feature
The overlay is a full-screen modal showing intercepted requests:
- **Design**: Dark gradient background (rgba(0,0,0,0.85)) with glassmorphic message box
- **Content**:
- Header: "TLSN Plugin In Progress" with gradient text
- Request list: Scrollable container showing METHOD + URL for each request
- Request count: Displayed in header
- **Styling**: Inline CSS with animations (fadeInScale), custom scrollbar styling
- **Updates**: Real-time updates as new requests are intercepted
- **Lifecycle**: Created when TLSN window opens, updated via background messages, cleared on window close
### Build Configuration
**Webpack 5 Setup** (`webpack.config.js`):
- **Entry Points**: popup, background, contentScript, content, offscreen
- **Output**: `build/` directory with `[name].bundle.js` pattern
- **Loaders**:
- `ts-loader` - TypeScript compilation (transpileOnly in dev)
- `babel-loader` - JavaScript transpilation with React Refresh
- `style-loader` + `css-loader` + `postcss-loader` + `sass-loader` - Styling pipeline
- `html-loader` - HTML templates
- `asset/resource` - File assets (images, fonts)
- **Plugins**:
- `ReactRefreshWebpackPlugin` - Hot module replacement (dev only)
- `CleanWebpackPlugin` - Cleans build directory
- `CopyWebpackPlugin` - Copies manifest, icons, CSS files
- `HtmlWebpackPlugin` - Generates popup.html and offscreen.html
- `TerserPlugin` - Code minification (production only)
- **Dev Server** (`utils/webserver.js`):
- Port: 3000 (configurable via `PORT` env var)
- Hot reload enabled with `webpack/hot/dev-server`
- Writes to disk for Chrome to load (`writeToDisk: true`)
- WebSocket transport for HMR
**Production Build** (`utils/build.js`):
- Adds `ZipPlugin` to create `tlsn-extension-{version}.zip` in `zip/` directory
- Uses package.json version for naming
- Exits with code 1 on errors or warnings
### Extension Permissions
Defined in `src/manifest.json`:
- `offscreen` - Create offscreen documents for background processing
- `webRequest` - Intercept HTTP/HTTPS requests
- `storage` - Persistent local storage
- `activeTab` - Access active tab information
- `tabs` - Tab management (create, query, update)
- `windows` - Window management (create, track, remove)
- `host_permissions: ["<all_urls>"]` - Access all URLs for request interception
- `content_scripts` - Inject into all HTTP/HTTPS pages
- `web_accessible_resources` - Make content.bundle.js, CSS, and icons accessible to pages
- `content_security_policy` - Allow WASM execution (`wasm-unsafe-eval`)
### TypeScript Configuration
**tsconfig.json**:
- Target: `esnext`
- Module: `esnext` with Node resolution
- Strict mode enabled
- JSX: React (not React 17+ automatic runtime)
- Includes: `src/` only
- Excludes: `build/`, `node_modules/`
- Types: `chrome` (for Chrome extension APIs)
**Type Declarations**:
- `src/global.d.ts` - Declares PNG module types
- Uses `@types/chrome`, `@types/webextension-polyfill`, `@types/react`, etc.
### Styling
**Tailwind CSS**:
- Configuration: `tailwind.config.js`
- Content: Scans all `src/**/*.{js,jsx,ts,tsx}`
- Custom theme: Primary color `#243f5f`
- PostCSS pipeline with `postcss-preset-env`
**SCSS**:
- FontAwesome integration (all icon sets: brands, solid, regular)
- Custom utility classes: `.button`, `.input`, `.select`, `.textarea`
- BEM-style modifiers: `.button--primary`
- Tailwind @apply directives mixed with custom styles
**Popup Dimensions**:
- Default: 480x600px (set in index.scss body styles)
- Customizable via inline styles or props
## Development Workflow
1. **Initial Setup** (from repository root):
```bash
npm install # Requires Node.js >= 18
```
2. **Development Mode**:
```bash
npm run dev # Starts webpack-dev-server on port 3000
```
- Hot module replacement enabled
- Files written to `packages/extension/build/` directory
- Load extension in Chrome: `chrome://extensions/` → Developer mode → Load unpacked → Select `build/` folder
3. **Testing Multi-Window Functionality**:
```javascript
// From any webpage with extension loaded:
await window.tlsn.open('https://x.com', { showOverlay: true });
```
- Opens new window with request interception
- Displays overlay showing captured HTTP requests
- Maximum 10 concurrent windows
4. **Production Build**:
```bash
NODE_ENV=production npm run build # Creates zip in packages/extension/zip/
```
5. **Running Tests**:
```bash
npm run test # Run all tests
npm run test:coverage # Generate coverage reports
```
## Plugin SDK Package (`packages/plugin-sdk`)
### Host Class API
The SDK provides a `Host` class for sandboxed plugin execution:
```typescript
import Host from '@tlsn/plugin-sdk';
const host = new Host();
// Add capabilities that plugins can use
host.addCapability('log', (message) => console.log(message));
host.addCapability('fetch', async (url) => fetch(url));
// Load and run plugins
host.loadPlugin('plugin-id', pluginCode);
const result = await host.runPlugin('plugin-id');
```
### QuickJS Sandboxing
- Uses `@sebastianwessel/quickjs` for secure JavaScript execution
- Plugins run in isolated WebAssembly environment
- Network and filesystem access disabled by default
- Host controls available capabilities through `env` object
### Build Configuration
- **Vite**: Builds isomorphic package for Node.js and browser
- **TypeScript**: Strict mode with full type declarations
- **Testing**: Vitest with coverage reporting
- **Output**: ESM module in `dist/` directory
## Known Issues & Legacy Code
⚠️ **Legacy Code Warning**: `src/entries/utils.ts` contains imports from non-existent files:
- `Background/rpc.ts` (removed in refactor)
- `SidePanel/types.ts` (removed in refactor)
- Functions: `pushToRedux()`, `openSidePanel()`, `waitForEvent()`
- **Status**: Dead code, not used by current entry points
- **Action**: Remove this file or refactor if functionality needed
⚠️ **SessionManager Integration**: Currently imported in background script but not actively used. Intended for future plugin execution features.
## Websockify Integration
Used for WebSocket proxying of TLS connections:
**Build Websockify Docker Image**:
```bash
git clone https://github.com/novnc/websockify && cd websockify
./docker/build.sh
```
**Run Websockify**:
```bash
# For x.com (Twitter)
docker run -it --rm -p 55688:80 novnc/websockify 80 api.x.com:443
# For Twitter (alternative)
docker run -it --rm -p 55688:80 novnc/websockify 80 api.twitter.com:443
```
Purpose: Proxies HTTPS connections through WebSocket for browser-based TLS operations.
## Code Quality
**ESLint Configuration** (`.eslintrc`):
- Extends: `prettier`, `@typescript-eslint/recommended`
- Parser: `@typescript-eslint/parser`
- Rules:
- `prettier/prettier`: error
- `@typescript-eslint/no-explicit-any`: warning
- `@typescript-eslint/no-var-requires`: off (allows require in webpack config)
- `@typescript-eslint/ban-ts-comment`: off
- `no-undef`: error
- `padding-line-between-statements`: error
- Environment: `webextensions`, `browser`, `node`, `es6`
- Ignores: `node_modules`, `zip`, `build`, `wasm`, `tlsn`, `webpack.config.js`
**Prettier Configuration** (`.prettierrc.json`):
- Single quotes, trailing commas, 2-space indentation
- Ignore: `.prettierignore` (not in repo, likely default ignores)

1347
PLUGIN.md

File diff suppressed because it is too large Load Diff

480
README.md
View File

@@ -1,435 +1,67 @@
<img src="packages/extension/src/assets/img/icon-128.png" width="64"/>
![MIT licensed][mit-badge]
![Apache licensed][apache-badge]
[![Build Status][actions-badge]][actions-url]
# TLSN Extension Monorepo
[mit-badge]: https://img.shields.io/badge/license-MIT-blue.svg
[apache-badge]: https://img.shields.io/github/license/saltstack/salt
[actions-badge]: https://github.com/tlsnotary/tlsn-extension/actions/workflows/build.yaml/badge.svg
[actions-url]: https://github.com/tlsnotary/tlsn-extension/actions?query=workflow%3Abuild+branch%3Amain++
A Chrome Extension for TLSNotary with plugin SDK and verifier server.
<img src="src/assets/img/icon-128.png" width="64"/>
# Chrome Extension (MV3) for TLSNotary
> [!IMPORTANT]
> When running the extension against a notary server, please ensure that the server's version is the same as the version of this extension.
## Table of Contents
- [Monorepo Structure](#monorepo-structure)
- [Architecture Overview](#architecture-overview)
- [Getting Started](#getting-started)
- [Development](#development)
- [Production Build](#production-build)
- [End-to-End Testing](#end-to-end-testing)
- [Websockify Integration](#websockify-integration)
- [Publishing](#publishing)
- [License](#license)
## Monorepo Structure
This repository is organized as an npm workspaces monorepo with four main packages:
```
tlsn-extension/
├── packages/
│ ├── extension/ # Chrome Extension (Manifest V3)
│ │ ├── src/
│ │ │ ├── entries/
│ │ │ │ ├── Background/ # Service worker for extension logic
│ │ │ │ ├── Content/ # Content scripts injected into pages
│ │ │ │ ├── DevConsole/ # Developer Console with code editor
│ │ │ │ ├── Popup/ # Extension popup UI (optional)
│ │ │ │ └── Offscreen/ # Offscreen document for DOM operations
│ │ │ ├── manifest.json
│ │ │ └── utils/
│ │ ├── webpack.config.js
│ │ └── package.json
│ │
│ ├── plugin-sdk/ # SDK for developing TLSN plugins
│ │ ├── src/
│ │ ├── examples/
│ │ └── package.json
│ │
│ ├── verifier/ # Rust-based verifier server
│ │ ├── src/
│ │ │ ├── main.rs # Server setup and routing
│ │ │ ├── config.rs # Configuration constants
│ │ │ └── verifier.rs # TLSNotary verification logic
│ │ └── Cargo.toml
│ │
│ └── tlsn-wasm-pkg/ # Pre-built TLSN WebAssembly package
│ └── (WASM binaries)
├── package.json # Root workspace configuration
└── README.md
```
### Package Details
#### 1. **extension** - Chrome Extension (Manifest V3)
A browser extension that enables TLSNotary functionality with the following key features:
- **Multi-Window Management**: Track multiple browser windows with request interception
- **Developer Console**: Interactive code editor for writing and testing TLSN plugins
- **Request Interception**: Capture HTTP/HTTPS requests from managed windows
- **Plugin Execution**: Run sandboxed JavaScript plugins using QuickJS
- **TLSN Overlay**: Visual display of intercepted requests
**Key Entry Points:**
- `Background`: Service worker for extension logic, window management, and message routing
- `Content`: Scripts injected into pages for communication and overlay display
- `DevConsole`: Code editor page accessible via right-click context menu
- `Popup`: Optional extension popup UI
- `Offscreen`: Background DOM operations for service worker limitations
#### 2. **plugin-sdk** - Plugin Development SDK
SDK for developing and running TLSN WebAssembly plugins with QuickJS sandboxing:
- Secure JavaScript execution in isolated WebAssembly environment
- Host capability system for controlled plugin access
- Isomorphic package for Node.js and browser environments
- TypeScript support with full type declarations
#### 3. **verifier** - Verifier Server
Rust-based HTTP/WebSocket server for TLSNotary verification:
- Health check endpoint (`/health`)
- Session creation endpoint (`/session`)
- WebSocket verification endpoint (`/verifier`)
- CORS enabled for cross-origin requests
- Runs on `localhost:7047` by default
#### 4. **tlsn-wasm-pkg** - TLSN WebAssembly Package
Pre-built WebAssembly binaries for TLSNotary functionality in the browser.
## Architecture Overview
### Extension Architecture
The extension uses a message-passing architecture with five main entry points:
```
┌─────────────────────────────────────────────────────────────┐
│ Browser Extension │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Background │◄────►│ Content │◄──── Page Scripts │
│ │ (SW) │ │ Script │ │
│ └──────┬───────┘ └──────────────┘ │
│ │ │
│ ├─► Window Management (WindowManager) │
│ ├─► Request Interception (webRequest API) │
│ ├─► Session Management (SessionManager) │
│ └─► Message Routing │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ DevConsole │ │ Offscreen │ │
│ │ (Editor) │ │ (Background)│ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌──────────────┐
│ Verifier │
│ Server │
│ (localhost: │
│ 7047) │
└──────────────┘
```
### Message Flow
**Opening a Managed Window:**
```
Page → window.tlsn.open(url)
↓ window.postMessage(TLSN_OPEN_WINDOW)
Content Script → event listener
↓ browser.runtime.sendMessage(OPEN_WINDOW)
Background → WindowManager.registerWindow()
↓ browser.windows.create()
↓ Returns window info with UUID
```
**Request Interception:**
```
Browser → HTTP request in managed window
↓ webRequest.onBeforeRequest
Background → WindowManager.addRequest()
↓ browser.tabs.sendMessage(UPDATE_TLSN_REQUESTS)
Content Script → Update TLSN overlay UI
```
## Getting Started
### Prerequisites
- **Node.js** >= 18
- **Rust** (for verifier server) - Install from [rustup.rs](https://rustup.rs/)
- **Chrome/Chromium** browser
### Installation
1. Clone the repository:
```bash
git clone https://github.com/tlsnotary/tlsn-extension.git
cd tlsn-extension
```
2. Install all dependencies:
```bash
npm install
```
This will install dependencies for all packages in the monorepo.
## Development
### Running the Extension in Development Mode
1. Start the development server:
```bash
npm run dev
```
This starts webpack-dev-server on port 3000 with hot module replacement. Files are written to `packages/extension/build/`.
2. Load the extension in Chrome:
- Navigate to `chrome://extensions/`
- Enable "Developer mode" toggle (top right)
- Click "Load unpacked"
- Select the `packages/extension/build/` folder
3. The extension will auto-reload on file changes (manual refresh needed for manifest changes).
### Running the Verifier Server
The verifier server is required for E2E testing. Run it in a separate terminal:
```bash
cd packages/verifier
cargo run
```
The server will start on `http://localhost:7047`.
**Verifier API Endpoints:**
- `GET /health` - Health check
- `POST /session` - Create new verification session
- `WS /verifier?sessionId=<id>` - WebSocket verification endpoint
### Package-Specific Development
**Extension:**
```bash
cd packages/extension
npm run dev # Development mode
npm run test # Run tests
npm run test:watch # Watch mode
npm run test:coverage # Coverage report
npm run lint # Lint check
npm run lint:fix # Auto-fix linting issues
```
**Plugin SDK:**
```bash
cd packages/plugin-sdk
npm run build # Build SDK
npm run test # Run tests
npm run lint # Run all linters
npm run lint:fix # Auto-fix issues
```
**Verifier:**
```bash
cd packages/verifier
cargo run # Development mode
cargo build --release # Production build
cargo test # Run tests
```
## Production Build
### Build Extension for Production
From the repository root:
```bash
NODE_ENV=production npm run build
```
This creates:
- Optimized build in `packages/extension/build/`
- Packaged extension in `packages/extension/zip/tlsn-extension-{version}.zip`
The zip file is ready for Chrome Web Store submission.
### Build All Packages
```bash
npm run build:all
```
This builds all packages in the monorepo (extension, plugin-sdk).
### Build Verifier for Production
```bash
cd packages/verifier
cargo build --release
```
The binary will be in `target/release/`.
## End-to-End Testing
To test the complete TLSN workflow:
### 1. Start the Verifier Server
In a terminal:
```bash
cd packages/verifier
cargo run
```
Verify it's running:
```bash
curl http://localhost:7047/health
# Should return: ok
```
### 2. Start the Extension in Development Mode
In another terminal:
```bash
npm run dev
```
Load the extension in Chrome (see [Getting Started](#getting-started)).
### 3. Open the Developer Console
1. Right-click anywhere on any web page
2. Select "Developer Console" from the context menu
3. A new tab will open with the code editor
### 4. Run a Test Plugin
The Developer Console comes with a default X.com profile prover plugin. To test:
1. Ensure the verifier is running on `localhost:7047`
2. Review the default code in the editor (or modify as needed)
3. Click "▶️ Run Code" button
4. The plugin will:
- Open a new window to X.com
- Intercept requests
- Create a prover connection to the verifier
- Display a UI overlay showing progress
- Execute the proof workflow
**Console Output:**
- Execution status and timing
- Plugin logs and results
- Any errors encountered
### 5. Verify Request Interception
When a managed window is opened:
1. An overlay appears showing "TLSN Plugin In Progress"
2. Intercepted requests are listed in real-time
3. Request count updates as more requests are captured
### Testing Different Plugins
You can write custom plugins in the Developer Console editor:
```javascript
// Example: Simple plugin that opens a window
async function prove() {
console.log('Starting proof...');
// Open a managed window
openWindow('https://example.com');
// Wait for specific headers
const [header] = useHeaders(headers => {
return headers.filter(h => h.url.includes('example.com'));
});
console.log('Captured header:', header);
// Create prover connection
const proverId = await createProver('example.com', 'http://localhost:7047');
// ... rest of proof logic
}
function main() {
// Plugin UI component
return div({}, ['My Plugin']);
}
export default {
main,
prove,
config: {
name: 'My Plugin',
description: 'A custom TLSN plugin'
}
};
```
### Testing Tips
- **Monitor Background Service Worker**: Open Chrome DevTools for the background service worker via `chrome://extensions/` → Extension Details → "Inspect views: service worker"
- **Check Console Logs**: Look for WindowManager logs, request interception logs, and message routing logs
- **Test Multiple Windows**: Try opening multiple managed windows simultaneously (max 10)
- **Verifier Connection**: Ensure verifier is accessible at `localhost:7047` before running proofs
## Websockify Integration
For WebSocket proxying of TLS connections (optional):
### Build Websockify Docker Image
```bash
git clone https://github.com/novnc/websockify && cd websockify
./docker/build.sh
```
### Run Websockify
```bash
# For X.com
docker run -it --rm -p 55688:80 novnc/websockify 80 api.x.com:443
# For Twitter
docker run -it --rm -p 55688:80 novnc/websockify 80 api.twitter.com:443
```
This proxies HTTPS connections through WebSocket for browser-based TLS operations.
## Publishing
### Chrome Web Store
1. Create a production build:
```bash
NODE_ENV=production npm run build
```
2. Test the extension thoroughly
3. Upload `packages/extension/zip/tlsn-extension-{version}.zip` to [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole)
4. Follow the [Chrome Web Store publishing guide](https://developer.chrome.com/webstore/publish)
### Pre-built Extension
The easiest way to install the TLSN browser extension is from the [Chrome Web Store](https://chromewebstore.google.com/detail/tlsn-extension/gcfkkledipjbgdbimfpijgbkhajiaaph).
## Resources
- [TLSNotary Documentation](https://docs.tlsnotary.org/)
- [Webpack Documentation](https://webpack.js.org/concepts/)
- [Chrome Extension Documentation](https://developer.chrome.com/docs/extensions/)
- [Manifest V3 Migration Guide](https://developer.chrome.com/docs/extensions/mv3/intro/)
- [webextension-polyfill](https://github.com/mozilla/webextension-polyfill)
> ⚠️ When running the extension against a [notary server](https://github.com/tlsnotary/tlsn/tree/dev/notary-server), please ensure that the server's version is the same as the version of this extension
## License
This repository is licensed under either of:
This repository is licensed under either of
- [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0)
- [MIT license](http://opensource.org/licenses/MIT)
at your option.
## Installing and Running
### Procedures:
1. Check if your [Node.js](https://nodejs.org/) version is >= **18**.
2. Clone this repository.
3. Run `npm install` to install the dependencies.
4. Run `npm run dev`
5. Load your extension on Chrome following:
1. Access `chrome://extensions/`
2. Check `Developer mode`
3. Click on `Load unpacked extension`
4. Select the `build` folder.
6. Happy hacking.
## Building Websockify Docker Image
```
$ git clone https://github.com/novnc/websockify && cd websockify
$ ./docker/build.sh
$ docker run -it --rm -p 55688:80 novnc/websockify 80 api.x.com:443
```
## Running Websockify Docker Image
```
$ cd tlsn-extension
$ docker run -it --rm -p 55688:80 novnc/websockify 80 api.twitter.com:443
```
## Packing
After the development of your extension run the command
```
$ NODE_ENV=production npm run build
```
Now, the content of `build` folder will be the extension ready to be submitted to the Chrome Web Store. Just take a look at the [official guide](https://developer.chrome.com/webstore/publish) to more infos about publishing.
## Resources:
- [Webpack documentation](https://webpack.js.org/concepts/)
- [Chrome Extension documentation](https://developer.chrome.com/extensions/getstarted)

20283
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,96 @@
{
"name": "tlsn-monorepo",
"version": "0.1.0",
"private": true,
"description": "TLSN Extension monorepo with plugin SDK",
"name": "tlsn-extension",
"version": "0.1.0.6",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/tlsnotary/tlsn-extension.git"
},
"workspaces": [
"packages/*"
],
"scripts": {
"build": "npm run build --workspace=extension",
"build:all": "npm run build --workspaces",
"dev": "npm run dev --workspace=extension",
"lint": "npm run lint --workspaces",
"lint:fix": "npm run lint:fix --workspaces",
"test": "npm run test --workspaces",
"serve:test": "npm run serve:test --workspace=extension",
"clean": "rm -rf packages/*/node_modules packages/*/dist packages/*/build node_modules"
"clone:tlsn": "bash ./utils/download-tlsn.sh",
"build": "NODE_ENV=production node utils/build.js",
"build:webpack": "NODE_ENV=production webpack --config webpack.config.js",
"websockify": "docker run -it --rm -p 55688:80 -v $(pwd):/app novnc/websockify 80 --target-config /app/websockify_config",
"dev": "NODE_ENV=development node utils/webserver.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@extism/extism": "^1.0.2",
"@fortawesome/fontawesome-free": "^6.4.2",
"async-mutex": "^0.4.0",
"buffer": "^6.0.3",
"charwise": "^3.0.1",
"classnames": "^2.3.2",
"comlink": "^4.4.1",
"copy-to-clipboard": "^3.3.3",
"fast-deep-equal": "^3.1.3",
"fuse.js": "^6.6.2",
"level": "^8.0.0",
"minimatch": "^9.0.4",
"node-cache": "^5.1.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.1.2",
"react-router": "^6.15.0",
"react-router-dom": "^6.15.0",
"redux": "^4.2.1",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.4.2",
"tailwindcss": "^3.3.3",
"tlsn-js": "0.1.0-alpha.6.2",
"tlsn-jsV5.3": "npm:tlsn-js@0.1.0-alpha.5.3"
},
"devDependencies": {
"@babel/core": "^7.20.12",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
"@types/chrome": "^0.0.202",
"@types/node": "^20.4.10",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/react-router-dom": "^5.3.3",
"@types/redux-logger": "^3.0.9",
"@types/webextension-polyfill": "^0.10.7",
"babel-eslint": "^10.1.0",
"babel-loader": "^9.1.2",
"babel-preset-react-app": "^10.0.1",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3",
"eslint": "^8.31.0",
"eslint-config-prettier": "^9.0.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-flowtype": "^8.0.3",
"eslint-plugin-import": "^2.27.4",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.1.0",
"html-loader": "^4.2.0",
"html-webpack-plugin": "^5.5.0",
"postcss-loader": "^7.3.3",
"postcss-preset-env": "^9.1.1",
"prettier": "^3.0.2",
"react-refresh": "^0.14.0",
"react-refresh-typescript": "^2.0.7",
"sass": "^1.57.1",
"sass-loader": "^13.2.0",
"source-map-loader": "^3.0.1",
"style-loader": "^3.3.1",
"terser-webpack-plugin": "^5.3.6",
"ts-loader": "^9.4.2",
"type-fest": "^3.5.2",
"typescript": "^4.9.4",
"vite": "^7.1.7"
"webextension-polyfill": "^0.10.0",
"webpack": "^5.75.0",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.11.1",
"webpack-ext-reloader": "^1.1.12",
"zip-webpack-plugin": "^4.0.1"
}
}

View File

@@ -1,308 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
### Development
- `npm install` - Install dependencies
- `npm run dev` - Start webpack dev server with hot reload on port 3000 (default)
- `npm run build` - Build extension (uses NODE_ENV from utils/build.js, defaults to production)
- `npm run build:webpack` - Direct webpack build with production mode
- `npm run lint` - Run ESLint to check code quality
- `npm run lint:fix` - Run ESLint with auto-fix for issues
## TLSNotary Extension
This is a Chrome Extension (Manifest V3) for TLSNotary, enabling secure notarization of TLS data. The extension was recently refactored (commit 92ecb55) to a minimal boilerplate, with TLSN overlay functionality being incrementally added back.
**Important**: The extension must match the version of the notary server it connects to.
## Architecture Overview
### Extension Entry Points
The extension has 5 main entry points defined in `webpack.config.js`:
#### 1. **Background Service Worker** (`src/entries/Background/index.ts`)
Core responsibilities:
- **TLSN Window Management**: Creates popup windows for TLSN operations, tracks window/tab IDs
- **Request Interception**: Uses `webRequest.onBeforeRequest` API to intercept all HTTP requests from TLSN windows
- **Request Storage**: Maintains in-memory array of intercepted requests (`tlsnRequests`)
- **Message Routing**: Forwards messages between content scripts, popup, and injected page scripts
- **Offscreen Document Management**: Creates offscreen documents for background DOM operations (Chrome 109+)
- Uses `webextension-polyfill` for cross-browser compatibility
Key message handlers:
- `PING``PONG` (connectivity test)
- `TLSN_CONTENT_TO_EXTENSION` → Opens new popup window, tracks requests
- `CONTENT_SCRIPT_READY` → Confirms content script loaded
#### 2. **Content Script** (`src/entries/Content/index.ts`)
Injected into all HTTP/HTTPS pages via manifest. Responsibilities:
- **Script Injection**: Injects `content.bundle.js` into page context to expose page-accessible API
- **TLSN Overlay Management**: Creates/updates full-screen overlay showing intercepted requests
- **Message Bridge**: Bridges messages between page scripts and extension background
- **Request Display**: Real-time updates of intercepted requests in overlay UI
Message handlers:
- `GET_PAGE_INFO` → Returns page title, URL, domain
- `SHOW_TLSN_OVERLAY` → Creates overlay with initial requests
- `UPDATE_TLSN_REQUESTS` → Updates overlay with new requests
- `HIDE_TLSN_OVERLAY` → Removes overlay and clears state
Window message handler:
- Listens for `TLSN_CONTENT_SCRIPT_MESSAGE` from page scripts
- Forwards to background via `TLSN_CONTENT_TO_EXTENSION`
#### 3. **Content Module** (`src/entries/Content/content.ts`)
Injected script running in page context (not content script context):
- **Page API**: Exposes `window.extensionAPI` object to web pages
- **Message Bridge**: Provides `sendMessage()` method that posts messages via `window.postMessage`
- **Lifecycle Event**: Dispatches `extension_loaded` custom event when ready
- **Web Accessible Resource**: Listed in manifest's `web_accessible_resources`
Page API usage:
```javascript
window.extensionAPI.sendMessage({ action: 'startTLSN' });
window.addEventListener('extension_loaded', () => { /* ready */ });
```
#### 4. **Popup UI** (`src/entries/Popup/index.tsx`)
React-based extension popup:
- **Simple Interface**: "Hello World" boilerplate with test button
- **Redux Integration**: Connected to Redux store via `react-redux`
- **Message Sending**: Can send messages to background script
- **Styling**: Uses Tailwind CSS with custom button/input classes
- Entry point: `popup.html` (400x300px default size)
#### 5. **Offscreen Document** (`src/entries/Offscreen/index.tsx`)
Isolated React component for background processing:
- **Purpose**: Handles DOM operations unavailable in service workers
- **Message Handling**: Listens for `PROCESS_DATA` messages (example implementation)
- **Lifecycle**: Created dynamically by background script, reused if exists
- Entry point: `offscreen.html`
### State Management
Redux store located in `src/reducers/index.tsx`:
- **App State Interface**: `{ message: string, count: number }`
- **Action Creators**:
- `setMessage(message: string)` - Updates message state
- `incrementCount()` - Increments counter
- **Store Configuration** (`src/utils/store.ts`):
- Development: Uses `redux-thunk` + `redux-logger` middleware
- Production: Uses `redux-thunk` only
- **Type Safety**: Exports `RootState` and `AppRootState` types
### Message Passing Architecture
**Page → Extension Flow**:
```
Page (window.postMessage)
Content Script (window.addEventListener('message'))
Background (browser.runtime.sendMessage)
```
**Extension → Page Flow**:
```
Background (browser.tabs.sendMessage)
Content Script (browser.runtime.onMessage)
Page DOM manipulation (overlay, etc.)
```
**Security**: Content script only accepts messages from same origin (`event.origin === window.location.origin`)
### TLSN Overlay Feature
The overlay is a full-screen modal showing intercepted requests:
- **Design**: Dark gradient background (rgba(0,0,0,0.85)) with glassmorphic message box
- **Content**:
- Header: "TLSN Plugin In Progress" with gradient text
- Request list: Scrollable container showing METHOD + URL for each request
- Request count: Displayed in header
- **Styling**: Inline CSS with animations (fadeInScale), custom scrollbar styling
- **Updates**: Real-time updates as new requests are intercepted
- **Lifecycle**: Created when TLSN window opens, updated via background messages, cleared on window close
### Build Configuration
**Webpack 5 Setup** (`webpack.config.js`):
- **Entry Points**: popup, background, contentScript, content, offscreen
- **Output**: `build/` directory with `[name].bundle.js` pattern
- **Loaders**:
- `ts-loader` - TypeScript compilation (transpileOnly in dev)
- `babel-loader` - JavaScript transpilation with React Refresh
- `style-loader` + `css-loader` + `postcss-loader` + `sass-loader` - Styling pipeline
- `html-loader` - HTML templates
- `asset/resource` - File assets (images, fonts)
- **Plugins**:
- `ReactRefreshWebpackPlugin` - Hot module replacement (dev only)
- `CleanWebpackPlugin` - Cleans build directory
- `CopyWebpackPlugin` - Copies manifest, icons, CSS files
- `HtmlWebpackPlugin` - Generates popup.html and offscreen.html
- `TerserPlugin` - Code minification (production only)
- **Dev Server** (`utils/webserver.js`):
- Port: 3000 (configurable via `PORT` env var)
- Hot reload enabled with `webpack/hot/dev-server`
- Writes to disk for Chrome to load (`writeToDisk: true`)
- WebSocket transport for HMR
**Production Build** (`utils/build.js`):
- Adds `ZipPlugin` to create `tlsn-extension-{version}.zip` in `zip/` directory
- Uses package.json version for naming
- Exits with code 1 on errors or warnings
### Extension Permissions
Defined in `src/manifest.json`:
- `offscreen` - Create offscreen documents for background processing
- `webRequest` - Intercept HTTP/HTTPS requests
- `storage` - Persistent local storage
- `activeTab` - Access active tab information
- `tabs` - Tab management (create, query, update)
- `windows` - Window management (create, track, remove)
- `host_permissions: ["<all_urls>"]` - Access all URLs for request interception
- `content_scripts` - Inject into all HTTP/HTTPS pages
- `web_accessible_resources` - Make content.bundle.js, CSS, and icons accessible to pages
- `content_security_policy` - Allow WASM execution (`wasm-unsafe-eval`)
### TypeScript Configuration
**tsconfig.json**:
- Target: `esnext`
- Module: `esnext` with Node resolution
- Strict mode enabled
- JSX: React (not React 17+ automatic runtime)
- Includes: `src/` only
- Excludes: `build/`, `node_modules/`
- Types: `chrome` (for Chrome extension APIs)
**Type Declarations**:
- `src/global.d.ts` - Declares PNG module types
- Uses `@types/chrome`, `@types/webextension-polyfill`, `@types/react`, etc.
### Styling
**Tailwind CSS**:
- Configuration: `tailwind.config.js`
- Content: Scans all `src/**/*.{js,jsx,ts,tsx}`
- Custom theme: Primary color `#243f5f`
- PostCSS pipeline with `postcss-preset-env`
**SCSS**:
- FontAwesome integration (all icon sets: brands, solid, regular)
- Custom utility classes: `.button`, `.input`, `.select`, `.textarea`
- BEM-style modifiers: `.button--primary`
- Tailwind @apply directives mixed with custom styles
**Popup Dimensions**:
- Default: 480x600px (set in index.scss body styles)
- Customizable via inline styles or props
## Development Workflow
1. **Initial Setup**:
```bash
npm install # Requires Node.js >= 18
```
2. **Development Mode**:
```bash
npm run dev # Starts webpack-dev-server on port 3000
```
- Hot module replacement enabled
- Files written to `build/` directory
- Source maps: `cheap-module-source-map`
3. **Load Extension in Chrome**:
- Navigate to `chrome://extensions/`
- Enable "Developer mode" toggle
- Click "Load unpacked"
- Select the `build/` folder
- Extension auto-reloads on file changes (requires manual refresh for manifest changes)
4. **Testing TLSN Functionality**:
- Trigger `TLSN_CONTENT_TO_EXTENSION` message from a page using `window.extensionAPI.sendMessage()`
- Background script opens popup window to x.com
- All requests in that window are intercepted and displayed in overlay
5. **Production Build**:
```bash
NODE_ENV=production npm run build # Creates build/ and zip/
```
- Minified output with Terser
- No source maps
- Creates versioned zip file for Chrome Web Store submission
6. **Linting**:
```bash
npm run lint # Check for issues
npm run lint:fix # Auto-fix issues
```
## Known Issues & Legacy Code
⚠️ **Legacy Code Warning**: `src/entries/utils.ts` contains imports from non-existent files:
- `Background/rpc.ts` (removed in refactor)
- `SidePanel/types.ts` (removed in refactor)
- Functions: `pushToRedux()`, `openSidePanel()`, `waitForEvent()`
- **Status**: Dead code, not used by current entry points
- **Action**: Remove this file or refactor if functionality needed
## Websockify Integration
Used for WebSocket proxying of TLS connections:
**Build Websockify Docker Image**:
```bash
git clone https://github.com/novnc/websockify && cd websockify
./docker/build.sh
```
**Run Websockify**:
```bash
# For x.com (Twitter)
docker run -it --rm -p 55688:80 novnc/websockify 80 api.x.com:443
# For Twitter (alternative)
docker run -it --rm -p 55688:80 novnc/websockify 80 api.twitter.com:443
```
Purpose: Proxies HTTPS connections through WebSocket for browser-based TLS operations.
## Code Quality
**ESLint Configuration** (`.eslintrc`):
- Extends: `prettier`, `@typescript-eslint/recommended`
- Parser: `@typescript-eslint/parser`
- Rules:
- `prettier/prettier`: error
- `@typescript-eslint/no-explicit-any`: warning
- `@typescript-eslint/no-var-requires`: off (allows require in webpack config)
- `@typescript-eslint/ban-ts-comment`: off
- `no-undef`: error
- `padding-line-between-statements`: error
- Environment: `webextensions`, `browser`, `node`, `es6`
- Ignores: `node_modules`, `zip`, `build`, `wasm`, `tlsn`, `webpack.config.js`
**Prettier Configuration** (`.prettierrc.json`):
- Single quotes, trailing commas, 2-space indentation
- Ignore: `.prettierignore` (not in repo, likely default ignores)
## Publishing
After building:
1. Test extension thoroughly in Chrome
2. Create production build: `NODE_ENV=production npm run build`
3. Upload `zip/tlsn-extension-{version}.zip` to Chrome Web Store
4. Follow [Chrome Web Store publishing guide](https://developer.chrome.com/webstore/publish)
## Resources
- [Webpack Documentation](https://webpack.js.org/concepts/)
- [Chrome Extension Docs](https://developer.chrome.com/docs/extensions/)
- [Manifest V3 Migration Guide](https://developer.chrome.com/docs/extensions/mv3/intro/)
- [webextension-polyfill](https://github.com/mozilla/webextension-polyfill)

View File

@@ -1 +0,0 @@
../../tlsn-wasm-pkg

View File

@@ -1,112 +0,0 @@
{
"name": "extension",
"version": "0.1.0",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/tlsnotary/tlsn-extension.git",
"directory": "packages/extension"
},
"scripts": {
"build": "NODE_ENV=production node utils/build.js",
"build:webpack": "NODE_ENV=production webpack --config webpack.config.js",
"dev": "NODE_ENV=development node utils/webserver.js",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"serve:test": "python3 -m http.server 8081 --directory ./tests/integration"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.6",
"@fortawesome/fontawesome-free": "^6.4.2",
"@uiw/react-codemirror": "^4.25.2",
"assert": "^2.1.0",
"buffer": "^6.0.3",
"classnames": "^2.3.2",
"codemirror": "^6.0.1",
"comlink": "^4.4.2",
"events": "^3.3.0",
"fast-deep-equal": "^3.1.3",
"path-browserify": "^1.0.1",
"process": "^0.11.10",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.1.2",
"react-router": "^6.15.0",
"react-router-dom": "^6.15.0",
"redux": "^4.2.1",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.4.2",
"stream-browserify": "^3.0.0",
"tailwindcss": "^3.3.3",
"tlsn-js": "^0.1.0-alpha.12.0",
"tlsn-wasm": "./lib/tlsn-wasm-pkg/",
"util": "^0.12.5"
},
"devDependencies": {
"@babel/core": "^7.20.12",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
"@types/chrome": "^0.0.202",
"@types/node": "^20.4.10",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/react-router-dom": "^5.3.3",
"@types/redux-logger": "^3.0.9",
"@types/uuid": "^10.0.0",
"@types/webextension-polyfill": "^0.10.7",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"babel-eslint": "^10.1.0",
"babel-loader": "^9.1.2",
"babel-preset-react-app": "^10.0.1",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3",
"eslint": "^8.31.0",
"eslint-config-prettier": "^9.0.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-flowtype": "^8.0.3",
"eslint-plugin-import": "^2.27.4",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.1.0",
"happy-dom": "^19.0.1",
"html-loader": "^4.2.0",
"html-webpack-plugin": "^5.5.0",
"null-loader": "^4.0.1",
"postcss-loader": "^7.3.3",
"postcss-preset-env": "^9.1.1",
"prettier": "^3.0.2",
"react-refresh": "^0.14.0",
"react-refresh-typescript": "^2.0.7",
"sass": "^1.57.1",
"sass-loader": "^13.2.0",
"source-map-loader": "^3.0.1",
"style-loader": "^3.3.1",
"terser-webpack-plugin": "^5.3.6",
"ts-loader": "^9.4.2",
"type-fest": "^3.5.2",
"typescript": "^4.9.4",
"uuid": "^13.0.0",
"vitest": "^3.2.4",
"vitest-chrome": "^0.1.0",
"webextension-polyfill": "^0.10.0",
"webpack": "^5.75.0",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.11.1",
"webpack-ext-reloader": "^1.1.12",
"zip-webpack-plugin": "^4.0.1"
}
}

View File

@@ -1,2 +0,0 @@
<?xml version="1.0" ?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" id="Glyph" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M16,13c-1.654,0-3,1.346-3,3s1.346,3,3,3s3-1.346,3-3S17.654,13,16,13z" id="XMLID_287_"/><path d="M6,13c-1.654,0-3,1.346-3,3s1.346,3,3,3s3-1.346,3-3S7.654,13,6,13z" id="XMLID_289_"/><path d="M26,13c-1.654,0-3,1.346-3,3s1.346,3,3,3s3-1.346,3-3S27.654,13,26,13z" id="XMLID_291_"/></svg>

Before

Width:  |  Height:  |  Size: 621 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,548 +0,0 @@
/**
* WindowManager - Multi-window management for TLSNotary extension
*
* Manages multiple browser windows with request interception and overlay display.
* Each window maintains its own state, request history, and overlay visibility.
*/
import { v4 as uuidv4 } from 'uuid';
import browser from 'webextension-polyfill';
import type {
WindowRegistration,
InterceptedRequest,
ManagedWindow,
IWindowManager,
InterceptedRequestHeader,
} from '../types/window-manager';
import {
MAX_MANAGED_WINDOWS,
MAX_REQUESTS_PER_WINDOW,
OVERLAY_RETRY_DELAY_MS,
MAX_OVERLAY_RETRY_ATTEMPTS,
} from '../constants/limits';
/**
* WindowManager implementation
*
* Provides centralized management for multiple browser windows with:
* - Window lifecycle tracking (create, lookup, close)
* - Request interception per window
* - Overlay visibility control
* - Automatic cleanup of closed windows
*/
export class WindowManager implements IWindowManager {
/**
* Internal storage for managed windows
* Key: Chrome window ID
* Value: ManagedWindow object
*/
private windows: Map<number, ManagedWindow> = new Map();
/**
* Register a new window with the manager
*
* Creates a ManagedWindow object with UUID, initializes request tracking,
* and optionally shows the TLSN overlay.
*
* @param config - Window registration configuration
* @returns Promise resolving to the created ManagedWindow
*
* @example
* ```typescript
* const window = await windowManager.registerWindow({
* id: 123,
* tabId: 456,
* url: 'https://example.com',
* showOverlay: true
* });
* ```
*/
async registerWindow(config: WindowRegistration): Promise<ManagedWindow> {
// Check maximum window limit
if (this.windows.size >= MAX_MANAGED_WINDOWS) {
const error = `Maximum window limit reached (${MAX_MANAGED_WINDOWS}). Currently managing ${this.windows.size} windows. Please close some windows before opening new ones.`;
console.error(`[WindowManager] ${error}`);
throw new Error(error);
}
const managedWindow: ManagedWindow = {
id: config.id,
uuid: uuidv4(),
tabId: config.tabId,
url: config.url,
createdAt: new Date(),
requests: [],
headers: [],
overlayVisible: false,
pluginUIVisible: false,
showOverlayWhenReady: config.showOverlay !== false, // Default: true
};
this.windows.set(config.id, managedWindow);
console.log(
`[WindowManager] Window registered: ${managedWindow.uuid} (ID: ${managedWindow.id}, Tab: ${managedWindow.tabId}, showOverlayWhenReady: ${managedWindow.showOverlayWhenReady}) [${this.windows.size}/${MAX_MANAGED_WINDOWS}]`,
);
return managedWindow;
}
/**
* Close and cleanup a window
*
* Hides the overlay if visible and removes the window from tracking.
* Does nothing if the window is not found.
*
* @param windowId - Chrome window ID
*
* @example
* ```typescript
* await windowManager.closeWindow(123);
* ```
*/
async closeWindow(windowId: number): Promise<void> {
const window = this.windows.get(windowId);
if (!window) {
console.warn(
`[WindowManager] Attempted to close non-existent window: ${windowId}`,
);
return;
}
// Hide overlay before closing
if (window.overlayVisible) {
await this.hideOverlay(windowId).catch((error) => {
console.warn(
`[WindowManager] Failed to hide overlay for window ${windowId}:`,
error,
);
});
}
// Remove from tracking
this.windows.delete(windowId);
browser.windows.remove(windowId);
browser.runtime.sendMessage({
type: 'WINDOW_CLOSED',
windowId,
});
console.log(
`[WindowManager] Window closed: ${window.uuid} (ID: ${window.id})`,
);
}
/**
* Get a managed window by ID
*
* @param windowId - Chrome window ID
* @returns The ManagedWindow or undefined if not found
*
* @example
* ```typescript
* const window = windowManager.getWindow(123);
* if (window) {
* console.log(`Window has ${window.requests.length} requests`);
* }
* ```
*/
getWindow(windowId: number): ManagedWindow | undefined {
return this.windows.get(windowId);
}
/**
* Get a managed window by tab ID
*
* Searches through all windows to find one containing the specified tab.
* Useful for webRequest listeners that only provide tab IDs.
*
* @param tabId - Chrome tab ID
* @returns The ManagedWindow or undefined if not found
*
* @example
* ```typescript
* const window = windowManager.getWindowByTabId(456);
* if (window) {
* windowManager.addRequest(window.id, request);
* }
* ```
*/
getWindowByTabId(tabId: number): ManagedWindow | undefined {
for (const window of this.windows.values()) {
if (window.tabId === tabId) {
return window;
}
}
return undefined;
}
/**
* Get all managed windows
*
* @returns Map of window IDs to ManagedWindow objects (copy)
*
* @example
* ```typescript
* const allWindows = windowManager.getAllWindows();
* console.log(`Managing ${allWindows.size} windows`);
* ```
*/
getAllWindows(): Map<number, ManagedWindow> {
return new Map(this.windows);
}
/**
* Add an intercepted request to a window
*
* Appends the request to the window's request array and updates the overlay
* if it's currently visible. Logs an error if the window is not found.
*
* @param windowId - Chrome window ID
* @param request - The intercepted request to add
*
* @example
* ```typescript
* windowManager.addRequest(123, {
* id: 'req-456',
* method: 'GET',
* url: 'https://example.com/api/data',
* timestamp: Date.now(),
* tabId: 456
* });
* ```
*/
addRequest(windowId: number, request: InterceptedRequest): void {
const window = this.windows.get(windowId);
if (!window) {
console.error(
`[WindowManager] Cannot add request to non-existent window: ${windowId}`,
);
return;
}
// Add timestamp if not provided
if (!request.timestamp) {
request.timestamp = Date.now();
}
window.requests.push(request);
browser.runtime.sendMessage({
type: 'REQUEST_INTERCEPTED',
request,
windowId,
});
// Update overlay if visible
if (window.overlayVisible) {
this.updateOverlay(windowId).catch((error) => {
console.warn(
`[WindowManager] Failed to update overlay for window ${windowId}:`,
error,
);
});
}
// Enforce request limit per window to prevent unbounded memory growth
if (window.requests.length > MAX_REQUESTS_PER_WINDOW) {
const removed = window.requests.length - MAX_REQUESTS_PER_WINDOW;
window.requests.splice(0, removed);
console.warn(
`[WindowManager] Request limit reached for window ${windowId}. Removed ${removed} oldest request(s). Current: ${window.requests.length}/${MAX_REQUESTS_PER_WINDOW}`,
);
}
}
addHeader(windowId: number, header: InterceptedRequestHeader): void {
const window = this.windows.get(windowId);
if (!window) {
console.error(
`[WindowManager] Cannot add header to non-existent window: ${windowId}`,
);
return;
}
window.headers.push(header);
browser.runtime.sendMessage({
type: 'HEADER_INTERCEPTED',
header,
windowId,
});
// Enforce request limit per window to prevent unbounded memory growth
if (window.headers.length > MAX_REQUESTS_PER_WINDOW) {
const removed = window.headers.length - MAX_REQUESTS_PER_WINDOW;
window.headers.splice(0, removed);
console.warn(
`[WindowManager] Header limit reached for window ${windowId}. Removed ${removed} oldest request(s). Current: ${window.headers.length}/${MAX_REQUESTS_PER_WINDOW}`,
);
}
}
/**
* Get all requests for a window
*
* @param windowId - Chrome window ID
* @returns Array of intercepted requests (empty array if window not found)
*
* @example
* ```typescript
* const requests = windowManager.getWindowRequests(123);
* console.log(`Window has ${requests.length} requests`);
* ```
*/
getWindowRequests(windowId: number): InterceptedRequest[] {
const window = this.windows.get(windowId);
return window?.requests || [];
}
getWindowHeaders(windowId: number): InterceptedRequestHeader[] {
const window = this.windows.get(windowId);
return window?.headers || [];
}
async showPluginUI(
windowId: number,
json: any,
retryCount = 0,
): Promise<void> {
const window = this.windows.get(windowId);
if (!window) {
console.error(
`[WindowManager] Cannot show plugin UI for non-existent window: ${windowId}`,
);
return;
}
try {
await browser.tabs.sendMessage(window.tabId, {
type: 'RENDER_PLUGIN_UI',
json,
windowId,
});
window.pluginUIVisible = true;
console.log(`[WindowManager] Plugin UI shown for window ${windowId}`);
} catch (error) {
// Retry if content script not ready
if (retryCount < MAX_OVERLAY_RETRY_ATTEMPTS) {
console.log(
`[WindowManager] Plugin UI display failed for window ${windowId}, retry ${retryCount + 1}/${MAX_OVERLAY_RETRY_ATTEMPTS} in ${OVERLAY_RETRY_DELAY_MS}ms`,
);
// Wait and retry
await new Promise((resolve) =>
setTimeout(resolve, OVERLAY_RETRY_DELAY_MS),
);
// Check if window still exists before retrying
if (this.windows.has(windowId)) {
return this.showOverlay(windowId, retryCount + 1);
} else {
console.warn(
`[WindowManager] Window ${windowId} closed during retry, aborting plugin UI display`,
);
}
} else {
console.warn(
`[WindowManager] Failed to show plugin UI for window ${windowId} after ${MAX_OVERLAY_RETRY_ATTEMPTS} attempts:`,
error,
);
}
}
}
/**
* Show the TLSN overlay in a window
*
* Sends a message to the content script to display the overlay with
* the current list of intercepted requests. Catches and logs errors
* if the content script is not ready.
*
* @param windowId - Chrome window ID
*
* @example
* ```typescript
* await windowManager.showOverlay(123);
* ```
*/
async showOverlay(windowId: number, retryCount = 0): Promise<void> {
const window = this.windows.get(windowId);
if (!window) {
console.error(
`[WindowManager] Cannot show overlay for non-existent window: ${windowId}`,
);
return;
}
try {
await browser.tabs.sendMessage(window.tabId, {
type: 'SHOW_TLSN_OVERLAY',
requests: window.requests,
});
window.overlayVisible = true;
window.showOverlayWhenReady = false; // Clear the pending flag
console.log(`[WindowManager] Overlay shown for window ${windowId}`);
} catch (error) {
// Retry if content script not ready
if (retryCount < MAX_OVERLAY_RETRY_ATTEMPTS) {
console.log(
`[WindowManager] Overlay display failed for window ${windowId}, retry ${retryCount + 1}/${MAX_OVERLAY_RETRY_ATTEMPTS} in ${OVERLAY_RETRY_DELAY_MS}ms`,
);
// Wait and retry
await new Promise((resolve) =>
setTimeout(resolve, OVERLAY_RETRY_DELAY_MS),
);
// Check if window still exists before retrying
if (this.windows.has(windowId)) {
return this.showOverlay(windowId, retryCount + 1);
} else {
console.warn(
`[WindowManager] Window ${windowId} closed during retry, aborting overlay display`,
);
}
} else {
console.warn(
`[WindowManager] Failed to show overlay for window ${windowId} after ${MAX_OVERLAY_RETRY_ATTEMPTS} attempts:`,
error,
);
// Keep showOverlayWhenReady=true so tabs.onUpdated can try again
}
}
}
/**
* Hide the TLSN overlay in a window
*
* Sends a message to the content script to remove the overlay.
* Catches and logs errors if the content script is not available.
*
* @param windowId - Chrome window ID
*
* @example
* ```typescript
* await windowManager.hideOverlay(123);
* ```
*/
async hideOverlay(windowId: number): Promise<void> {
const window = this.windows.get(windowId);
if (!window) {
console.error(
`[WindowManager] Cannot hide overlay for non-existent window: ${windowId}`,
);
return;
}
try {
await browser.tabs.sendMessage(window.tabId, {
type: 'HIDE_TLSN_OVERLAY',
});
window.overlayVisible = false;
console.log(`[WindowManager] Overlay hidden for window ${windowId}`);
} catch (error) {
console.warn(
`[WindowManager] Failed to hide overlay for window ${windowId}:`,
error,
);
// Don't throw - window may already be closed
}
}
/**
* Check if overlay is visible in a window
*
* @param windowId - Chrome window ID
* @returns true if overlay is visible, false otherwise
*
* @example
* ```typescript
* if (windowManager.isOverlayVisible(123)) {
* console.log('Overlay is currently displayed');
* }
* ```
*/
isOverlayVisible(windowId: number): boolean {
const window = this.windows.get(windowId);
return window?.overlayVisible || false;
}
/**
* Update overlay with current requests (private helper)
*
* Sends an UPDATE_TLSN_REQUESTS message to the content script.
*
* @param windowId - Chrome window ID
*/
private async updateOverlay(windowId: number): Promise<void> {
const window = this.windows.get(windowId);
if (!window || !window.overlayVisible) {
return;
}
try {
await browser.tabs.sendMessage(window.tabId, {
type: 'UPDATE_TLSN_REQUESTS',
requests: window.requests,
});
console.log(
`[WindowManager] Overlay updated for window ${windowId} with ${window.requests.length} requests`,
);
} catch (error) {
console.warn(
`[WindowManager] Failed to update overlay for window ${windowId}:`,
error,
);
}
}
/**
* Cleanup windows that are no longer valid
*
* Iterates through all tracked windows and removes any that have been
* closed in the browser. This prevents memory leaks and stale state.
*
* Should be called periodically (e.g., every minute) or when handling
* window events.
*
* @example
* ```typescript
* // Run cleanup every minute
* setInterval(() => {
* windowManager.cleanupInvalidWindows();
* }, 60000);
* ```
*/
async cleanupInvalidWindows(): Promise<void> {
const windowIds = Array.from(this.windows.keys());
let cleanedCount = 0;
for (const windowId of windowIds) {
try {
// Check if window still exists in browser
await browser.windows.get(windowId);
} catch (error) {
// Window no longer exists, clean it up
const window = this.windows.get(windowId);
this.windows.delete(windowId);
cleanedCount++;
console.log(
`[WindowManager] Cleaned up invalid window: ${window?.uuid} (ID: ${windowId})`,
);
}
}
if (cleanedCount > 0) {
console.log(
`[WindowManager] Cleanup complete: ${cleanedCount} window(s) removed`,
);
}
}
}

View File

@@ -1,65 +0,0 @@
/**
* Resource limits and constraints for the TLSN extension
*
* These limits prevent resource exhaustion and ensure good performance.
*/
/**
* Maximum number of managed windows that can be open simultaneously
*
* This prevents memory exhaustion from opening too many windows.
* Each window tracks its own requests and overlay state.
*
* @default 10
*/
export const MAX_MANAGED_WINDOWS = 10;
/**
* Maximum number of requests to store per window
*
* Prevents unbounded memory growth from high-traffic sites.
* Older requests are removed when limit is reached.
*
* @default 1000
*/
export const MAX_REQUESTS_PER_WINDOW = 1000;
/**
* Timeout for overlay display attempts (milliseconds)
*
* If overlay cannot be shown within this timeout, stop retrying.
* This prevents infinite retry loops if content script never loads.
*
* @default 5000 (5 seconds)
*/
export const OVERLAY_DISPLAY_TIMEOUT_MS = 5000;
/**
* Retry delay for overlay display (milliseconds)
*
* Time to wait between retry attempts when content script isn't ready.
*
* @default 500 (0.5 seconds)
*/
export const OVERLAY_RETRY_DELAY_MS = 500;
/**
* Maximum number of retry attempts for overlay display
*
* Calculated as OVERLAY_DISPLAY_TIMEOUT_MS / OVERLAY_RETRY_DELAY_MS
*
* @default 10 (5000ms / 500ms)
*/
export const MAX_OVERLAY_RETRY_ATTEMPTS = Math.floor(
OVERLAY_DISPLAY_TIMEOUT_MS / OVERLAY_RETRY_DELAY_MS,
);
/**
* Interval for periodic cleanup of invalid windows (milliseconds)
*
* WindowManager periodically checks for windows that have been closed
* and removes them from tracking.
*
* @default 300000 (5 minutes)
*/
export const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;

View File

@@ -1,139 +0,0 @@
/**
* Message type constants for extension communication
*
* Defines all message types used for communication between:
* - Page scripts → Content scripts → Background script
* - Background script → Content scripts
*/
/**
* Legacy message types (from existing implementation)
*/
export const PING = 'PING';
export const PONG = 'PONG';
export const CONTENT_SCRIPT_READY = 'CONTENT_SCRIPT_READY';
export const GET_PAGE_INFO = 'GET_PAGE_INFO';
/**
* TLSN Content Script Messages (legacy)
*/
export const TLSN_CONTENT_SCRIPT_MESSAGE = 'TLSN_CONTENT_SCRIPT_MESSAGE';
export const TLSN_CONTENT_TO_EXTENSION = 'TLSN_CONTENT_TO_EXTENSION';
/**
* Window Management Messages
*/
/**
* Sent from content script to background to request opening a new window
*
* Payload: { url: string, width?: number, height?: number, showOverlay?: boolean }
*/
export const OPEN_WINDOW = 'OPEN_WINDOW';
/**
* Response from background when window is successfully opened
*
* Payload: { windowId: number, uuid: string, tabId: number }
*/
export const WINDOW_OPENED = 'WINDOW_OPENED';
/**
* Response from background when window opening fails
*
* Payload: { error: string, details?: string }
*/
export const WINDOW_ERROR = 'WINDOW_ERROR';
/**
* Overlay Control Messages
*/
/**
* Sent from background to content script to show TLSN overlay
*
* Payload: { requests: InterceptedRequest[] }
*/
export const SHOW_TLSN_OVERLAY = 'SHOW_TLSN_OVERLAY';
/**
* Sent from background to content script to update overlay with new requests
*
* Payload: { requests: InterceptedRequest[] }
*/
export const UPDATE_TLSN_REQUESTS = 'UPDATE_TLSN_REQUESTS';
/**
* Sent from background to content script to hide TLSN overlay
*
* Payload: none
*/
export const HIDE_TLSN_OVERLAY = 'HIDE_TLSN_OVERLAY';
/**
* Type definitions for message payloads
*/
export interface OpenWindowPayload {
url: string;
width?: number;
height?: number;
showOverlay?: boolean;
}
export interface WindowOpenedPayload {
windowId: number;
uuid: string;
tabId: number;
}
export interface WindowErrorPayload {
error: string;
details?: string;
}
export interface OverlayRequestsPayload {
requests: Array<{
id: string;
method: string;
url: string;
timestamp: number;
tabId: number;
}>;
}
/**
* Message wrapper types
*/
export interface OpenWindowMessage {
type: typeof OPEN_WINDOW;
url: string;
width?: number;
height?: number;
showOverlay?: boolean;
}
export interface WindowOpenedMessage {
type: typeof WINDOW_OPENED;
payload: WindowOpenedPayload;
}
export interface WindowErrorMessage {
type: typeof WINDOW_ERROR;
payload: WindowErrorPayload;
}
export interface ShowOverlayMessage {
type: typeof SHOW_TLSN_OVERLAY;
requests: OverlayRequestsPayload['requests'];
}
export interface UpdateOverlayMessage {
type: typeof UPDATE_TLSN_REQUESTS;
requests: OverlayRequestsPayload['requests'];
}
export interface HideOverlayMessage {
type: typeof HIDE_TLSN_OVERLAY;
}

View File

@@ -1,2 +0,0 @@
// Empty module for browser compatibility
export default {};

View File

@@ -1,355 +0,0 @@
import browser from 'webextension-polyfill';
import { WindowManager } from '../../background/WindowManager';
import type {
InterceptedRequest,
InterceptedRequestHeader,
} from '../../types/window-manager';
import { validateUrl } from '../../utils/url-validator';
const chrome = global.chrome as any;
// Basic background script setup
console.log('Background script loaded');
// Initialize WindowManager for multi-window support
const windowManager = new WindowManager();
// Create context menu for Developer Console
browser.contextMenus.create({
id: 'developer-console',
title: 'Developer Console',
contexts: ['all'],
});
// Handle context menu clicks
browser.contextMenus.onClicked.addListener((info, tab) => {
if (info.menuItemId === 'developer-console') {
// Open Developer Console in a new tab
browser.tabs.create({
url: browser.runtime.getURL('devConsole.html'),
});
}
});
// Handle extension install/update
browser.runtime.onInstalled.addListener((details) => {
console.log('Extension installed/updated:', details.reason);
});
// Set up webRequest listener to intercept all requests
browser.webRequest.onBeforeRequest.addListener(
(details) => {
// Check if this tab belongs to a managed window
const managedWindow = windowManager.getWindowByTabId(details.tabId);
if (managedWindow && details.tabId !== undefined) {
const request: InterceptedRequest = {
id: `${details.requestId}`,
method: details.method,
url: details.url,
timestamp: Date.now(),
tabId: details.tabId,
};
// Add request to window's request history
windowManager.addRequest(managedWindow.id, request);
}
},
{ urls: ['<all_urls>'] },
['requestBody', 'extraHeaders'],
);
browser.webRequest.onBeforeSendHeaders.addListener(
(details) => {
// Check if this tab belongs to a managed window
const managedWindow = windowManager.getWindowByTabId(details.tabId);
if (managedWindow && details.tabId !== undefined) {
const header: InterceptedRequestHeader = {
id: `${details.requestId}`,
method: details.method,
url: details.url,
timestamp: details.timeStamp,
type: details.type,
requestHeaders: details.requestHeaders || [],
tabId: details.tabId,
};
// Add request to window's request history
windowManager.addHeader(managedWindow.id, header);
}
},
{ urls: ['<all_urls>'] },
['requestHeaders', 'extraHeaders'],
);
// Listen for window removal
browser.windows.onRemoved.addListener(async (windowId) => {
const managedWindow = windowManager.getWindow(windowId);
if (managedWindow) {
console.log(
`[Background] Managed window closed: ${managedWindow.uuid} (ID: ${windowId})`,
);
await windowManager.closeWindow(windowId);
}
});
// Listen for tab updates to show overlay when tab is ready (Task 3.4)
browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
// Only act when tab becomes complete
if (changeInfo.status !== 'complete') {
return;
}
// Check if this tab belongs to a managed window
const managedWindow = windowManager.getWindowByTabId(tabId);
if (!managedWindow) {
return;
}
// If overlay should be shown but isn't visible yet, show it now
if (managedWindow.showOverlayWhenReady && !managedWindow.overlayVisible) {
console.log(
`[Background] Tab ${tabId} complete, showing overlay for window ${managedWindow.id}`,
);
await windowManager.showOverlay(managedWindow.id);
}
});
// Basic message handler
browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
console.log('[Background] Message received:', request.type);
// Example response
if (request.type === 'PING') {
sendResponse({ type: 'PONG' });
return true;
}
if (request.type === 'RENDER_PLUGIN_UI') {
console.log(
'[Background] RENDER_PLUGIN_UI request received:',
request.json,
request.windowId,
);
windowManager.showPluginUI(request.windowId, request.json);
return true;
}
// Handle code execution requests
if (request.type === 'EXEC_CODE') {
console.log('[Background] EXEC_CODE request received');
// Ensure offscreen document exists
createOffscreenDocument()
.then(async () => {
// Forward to offscreen document
const response = await chrome.runtime.sendMessage({
type: 'EXEC_CODE_OFFSCREEN',
code: request.code,
requestId: request.requestId,
});
console.log('[Background] EXEC_CODE_OFFSCREEN response:', response);
sendResponse(response);
})
.catch((error) => {
console.error('[Background] Error executing code:', error);
sendResponse({
success: false,
error: error.message || 'Code execution failed',
});
});
return true; // Keep message channel open for async response
}
// Handle CLOSE_WINDOW requests
if (request.type === 'CLOSE_WINDOW') {
console.log(
'[Background] CLOSE_WINDOW request received:',
request.windowId,
);
if (!request.windowId) {
console.error('[Background] No windowId provided');
sendResponse({
type: 'WINDOW_ERROR',
payload: {
error: 'No windowId provided',
details: 'windowId is required to close a window',
},
});
return true;
}
// Close the window using WindowManager
windowManager
.closeWindow(request.windowId)
.then(() => {
console.log(`[Background] Window ${request.windowId} closed`);
sendResponse({
type: 'WINDOW_CLOSED',
payload: {
windowId: request.windowId,
},
});
})
.catch((error) => {
console.error('[Background] Error closing window:', error);
sendResponse({
type: 'WINDOW_ERROR',
payload: {
error: 'Failed to close window',
details: String(error),
},
});
});
return true; // Keep message channel open for async response
}
// Handle OPEN_WINDOW requests from content scripts
if (request.type === 'OPEN_WINDOW') {
console.log('[Background] OPEN_WINDOW request received:', request.url);
// Validate URL using comprehensive validator
const urlValidation = validateUrl(request.url);
if (!urlValidation.valid) {
console.error('[Background] URL validation failed:', urlValidation.error);
sendResponse({
type: 'WINDOW_ERROR',
payload: {
error: 'Invalid URL',
details: urlValidation.error || 'URL validation failed',
},
});
return true;
}
// Open a new window with the requested URL
browser.windows
.create({
url: request.url,
type: 'popup',
width: request.width || 900,
height: request.height || 700,
})
.then(async (window) => {
if (
!window.id ||
!window.tabs ||
!window.tabs[0] ||
!window.tabs[0].id
) {
throw new Error('Failed to create window or get tab ID');
}
const windowId = window.id;
const tabId = window.tabs[0].id;
console.log(`[Background] Window created: ${windowId}, Tab: ${tabId}`);
try {
// Register window with WindowManager
const managedWindow = await windowManager.registerWindow({
id: windowId,
tabId: tabId,
url: request.url,
showOverlay: request.showOverlay !== false, // Default to true
});
console.log(`[Background] Window registered: ${managedWindow.uuid}`);
// Send success response
sendResponse({
type: 'WINDOW_OPENED',
payload: {
windowId: managedWindow.id,
uuid: managedWindow.uuid,
tabId: managedWindow.tabId,
},
});
} catch (registrationError) {
// Registration failed (e.g., window limit exceeded)
// Close the window we just created
console.error(
'[Background] Window registration failed:',
registrationError,
);
await browser.windows.remove(windowId).catch(() => {
// Ignore errors if window already closed
});
sendResponse({
type: 'WINDOW_ERROR',
payload: {
error: 'Window registration failed',
details: String(registrationError),
},
});
}
})
.catch((error) => {
console.error('[Background] Error creating window:', error);
sendResponse({
type: 'WINDOW_ERROR',
payload: {
error: 'Failed to create window',
details: String(error),
},
});
});
return true; // Keep message channel open for async response
}
return true; // Keep message channel open for async response
});
// Create offscreen document if needed (Chrome 109+)
async function createOffscreenDocument() {
// Check if we're in a Chrome environment that supports offscreen documents
if (!chrome?.offscreen) {
console.log('Offscreen API not available');
return;
}
const offscreenUrl = browser.runtime.getURL('offscreen.html');
// Check if offscreen document already exists
const existingContexts = await chrome.runtime.getContexts({
contextTypes: ['OFFSCREEN_DOCUMENT'],
documentUrls: [offscreenUrl],
});
if (existingContexts.length > 0) {
return;
}
// Create offscreen document
await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['DOM_SCRAPING'],
justification: 'Offscreen document for background processing',
});
}
// Initialize offscreen document
createOffscreenDocument().catch(console.error);
// Periodic cleanup of invalid windows (every 5 minutes)
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
setInterval(() => {
console.log('[Background] Running periodic window cleanup...');
windowManager.cleanupInvalidWindows().catch((error) => {
console.error('[Background] Error during cleanup:', error);
});
}, CLEANUP_INTERVAL_MS);
// Run initial cleanup after 10 seconds
setTimeout(() => {
windowManager.cleanupInvalidWindows().catch((error) => {
console.error('[Background] Error during initial cleanup:', error);
});
}, 10000);
export {};

View File

@@ -1,83 +0,0 @@
console.log('Page script injected');
/**
* ExtensionAPI - Public API exposed to web pages via window.tlsn
*
* Provides methods for web pages to interact with the TLSN extension,
* including opening new windows for notarization.
*/
class ExtensionAPI {
/**
* Execute JavaScript code in a sandboxed environment
*
* @param code - The JavaScript code to execute
* @returns Promise that resolves with the execution result or rejects with an error
*
* @example
* ```javascript
* // Execute simple code
* const result = await window.tlsn.execCode('1 + 2');
* console.log(result); // 3
*
* // Handle errors
* try {
* await window.tlsn.execCode('throw new Error("test")');
* } catch (error) {
* console.error(error);
* }
* ```
*/
async execCode(code: string): Promise<any> {
if (!code || typeof code !== 'string') {
throw new Error('Code must be a non-empty string');
}
return new Promise((resolve, reject) => {
// Generate a unique request ID for this execution
const requestId = `exec_${Date.now()}_${Math.random()}`;
// Set up one-time listener for the response
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== 'TLSN_EXEC_CODE_RESPONSE') return;
if (event.data?.requestId !== requestId) return;
// Remove listener
window.removeEventListener('message', handleMessage);
// Handle response
if (event.data.success) {
resolve(event.data.result);
} else {
reject(new Error(event.data.error || 'Code execution failed'));
}
};
window.addEventListener('message', handleMessage);
// Send message to content script
window.postMessage(
{
type: 'TLSN_EXEC_CODE',
payload: {
code,
requestId,
},
},
window.location.origin,
);
// Add timeout
setTimeout(() => {
window.removeEventListener('message', handleMessage);
reject(new Error('Code execution timeout'));
}, 30000); // 30 second timeout
});
}
}
// Expose API to the page
(window as any).tlsn = new ExtensionAPI();
// Dispatch event to notify page that extension is loaded
window.dispatchEvent(new CustomEvent('extension_loaded'));

View File

@@ -1,232 +0,0 @@
import browser from 'webextension-polyfill';
import { type DomJson } from '../../offscreen/SessionManager';
console.log('Content script loaded on:', window.location.href);
// Inject a script into the page if needed
function injectScript() {
const script = document.createElement('script');
script.src = browser.runtime.getURL('content.bundle.js');
script.type = 'text/javascript';
(document.head || document.documentElement).appendChild(script);
script.onload = () => script.remove();
}
// Function to create and show the TLSN overlay
function createTLSNOverlay() {
// Remove any existing overlay
const existingOverlay = document.getElementById('tlsn-overlay');
if (existingOverlay) {
existingOverlay.remove();
}
// Create overlay container
const overlay = document.createElement('div');
overlay.id = 'tlsn-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.85);
z-index: 999999;
display: flex;
justify-content: center;
align-items: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
`;
document.body.appendChild(overlay);
}
function renderPluginUI(json: DomJson, windowId: number) {
let container = document.getElementById('tlsn-plugin-container');
if (!container) {
const el = document.createElement('div');
el.id = 'tlsn-plugin-container';
document.body.appendChild(el);
container = el;
}
container.innerHTML = '';
container.appendChild(createNode(json, windowId));
}
function createNode(json: DomJson, windowId: number): HTMLElement | Text {
if (typeof json === 'string') {
const node = document.createTextNode(json);
return node;
}
const node = document.createElement(json.type);
if (json.options.className) {
node.className = json.options.className;
}
if (json.options.id) {
node.id = json.options.id;
}
if (json.options.style) {
Object.entries(json.options.style).forEach(([key, value]) => {
node.style[key as any] = value;
});
}
if (json.options.onclick) {
node.addEventListener('click', () => {
browser.runtime.sendMessage({
type: 'PLUGIN_UI_CLICK',
onclick: json.options.onclick,
windowId,
});
});
}
json.children.forEach((child) => {
node.appendChild(createNode(child, windowId));
});
return node;
}
// Listen for messages from the extension
browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
console.log('Content script received message:', request);
if (request.type === 'GET_PAGE_INFO') {
// Example: Get page information
sendResponse({
title: document.title,
url: window.location.href,
domain: window.location.hostname,
});
}
if (request.type === 'RENDER_PLUGIN_UI') {
renderPluginUI(request.json, request.windowId);
sendResponse({ success: true });
}
// if (request.type === 'SHOW_TLSN_OVERLAY') {
// createTLSNOverlay();
// sendResponse({ success: true });
// }
// if (request.type === 'UPDATE_TLSN_REQUESTS') {
// console.log('updateTLSNOverlay', request.requests);
// updateTLSNOverlay(request.requests || []);
// sendResponse({ success: true });
// }
// if (request.type === 'HIDE_TLSN_OVERLAY') {
// const overlay = document.getElementById('tlsn-overlay');
// if (overlay) {
// overlay.remove();
// }
// sendResponse({ success: true });
// }
return true; // Keep the message channel open
});
// Send a message to background script when ready
browser.runtime
.sendMessage({
type: 'CONTENT_SCRIPT_READY',
url: window.location.href,
})
.catch(console.error);
// Listen for messages from the page
window.addEventListener('message', (event) => {
// Only accept messages from the same origin
if (event.origin !== window.location.origin) return;
// Handle TLSN window.tlsn.open() calls
if (event.data?.type === 'TLSN_OPEN_WINDOW') {
console.log(
'[Content Script] Received TLSN_OPEN_WINDOW request:',
event.data.payload,
);
// Forward to background script with OPEN_WINDOW type
browser.runtime
.sendMessage({
type: 'OPEN_WINDOW',
url: event.data.payload.url,
width: event.data.payload.width,
height: event.data.payload.height,
showOverlay: event.data.payload.showOverlay,
})
.catch((error) => {
console.error(
'[Content Script] Failed to send OPEN_WINDOW message:',
error,
);
});
}
// Handle code execution requests
if (event.data?.type === 'TLSN_EXEC_CODE') {
console.log(
'[Content Script] Received TLSN_EXEC_CODE request:',
event.data.payload,
);
// Forward to background script
browser.runtime
.sendMessage({
type: 'EXEC_CODE',
code: event.data.payload.code,
requestId: event.data.payload.requestId,
})
.then((response) => {
console.log('[Content Script] EXEC_CODE response:', response);
// Send response back to page
window.postMessage(
{
type: 'TLSN_EXEC_CODE_RESPONSE',
requestId: event.data.payload.requestId,
success: true,
result: response.result,
},
window.location.origin,
);
})
.catch((error) => {
console.error('[Content Script] Failed to execute code:', error);
// Send error back to page
window.postMessage(
{
type: 'TLSN_EXEC_CODE_RESPONSE',
requestId: event.data.payload.requestId,
success: false,
error: error.message || 'Code execution failed',
},
window.location.origin,
);
});
}
// Handle legacy TLSN_CONTENT_SCRIPT_MESSAGE
if (event.data?.type === 'TLSN_CONTENT_SCRIPT_MESSAGE') {
// Forward to content script/extension
browser.runtime.sendMessage({
type: 'TLSN_CONTENT_TO_EXTENSION',
payload: event.data.payload,
});
}
});
// Inject script if document is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', injectScript);
} else {
injectScript();
}
export {};

View File

@@ -1,316 +0,0 @@
@import url('https://fonts.googleapis.com/css?family=Open+Sans&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #1e1e1e;
color: #d4d4d4;
overflow: hidden;
}
.dev-console {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #1e1e1e;
}
.editor-section {
flex: 1;
display: flex;
flex-direction: column;
border-bottom: 2px solid #333;
overflow: hidden;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background-color: #252526;
border-bottom: 1px solid #333;
}
.editor-title {
font-size: 14px;
font-weight: 600;
color: #cccccc;
letter-spacing: 0.3px;
}
.editor-actions {
display: flex;
gap: 8px;
}
.btn {
padding: 6px 16px;
font-size: 13px;
font-weight: 500;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-family: inherit;
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
&.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
&:hover {
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
}
&.btn-secondary {
background-color: #3e3e42;
color: #cccccc;
&:hover {
background-color: #4e4e52;
}
}
}
.editor-container {
flex: 1;
overflow: auto;
background-color: #1e1e1e;
}
.console-section {
height: 32rem;
display: flex;
flex-direction: column;
background-color: #1e1e1e;
}
.console-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background-color: #252526;
border-bottom: 1px solid #333;
}
.console-title {
font-size: 14px;
font-weight: 600;
color: #cccccc;
letter-spacing: 0.3px;
}
.console-output {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
background-color: #1e1e1e;
color: #d4d4d4;
&::-webkit-scrollbar {
width: 10px;
}
&::-webkit-scrollbar-track {
background: #252526;
}
&::-webkit-scrollbar-thumb {
background: #3e3e42;
border-radius: 5px;
&:hover {
background: #4e4e52;
}
}
}
.console-entry {
padding: 4px 0;
display: flex;
gap: 8px;
&.error {
color: #f48771;
}
&.success {
color: #89d185;
}
&.info {
color: #569cd6;
}
}
.console-timestamp {
color: #6a9955;
flex-shrink: 0;
}
.console-message {
flex: 1;
white-space: pre-wrap;
word-break: break-word;
}
// CodeMirror custom syntax highlighting styles
.cm-editor {
.cm-content {
caret-color: #528bff;
}
// Line numbers
.cm-gutters {
background-color: #1e1e1e;
border-right: 1px solid #333;
}
.cm-lineNumbers .cm-gutterElement {
color: #858585;
padding: 0 8px 0 5px;
}
// Active line
.cm-activeLine {
background-color: rgba(255, 255, 255, 0.05);
}
.cm-activeLineGutter {
background-color: rgba(255, 255, 255, 0.05);
}
// Selection
.cm-selectionBackground,
&.cm-focused .cm-selectionBackground {
background-color: rgba(82, 139, 255, 0.3);
}
// JavaScript syntax highlighting colors
.cm-keyword {
color: #c586c0; // Keywords: const, let, var, function, async, await, etc.
}
.cm-variableName {
color: #9cdcfe; // Variable names
}
.cm-propertyName {
color: #9cdcfe; // Object properties
}
.cm-string {
color: #ce9178; // Strings
}
.cm-number {
color: #b5cea8; // Numbers
}
.cm-bool {
color: #569cd6; // Booleans: true, false
}
.cm-null {
color: #569cd6; // null, undefined
}
.cm-operator {
color: #d4d4d4; // Operators: =, +, -, etc.
}
.cm-punctuation {
color: #d4d4d4; // Punctuation: (), {}, [], etc.
}
.cm-comment {
color: #6a9955; // Comments
font-style: italic;
}
.cm-function {
color: #dcdcaa; // Function names
}
.cm-typeName,
.cm-className {
color: #4ec9b0; // Type/Class names
}
.cm-regexp {
color: #d16969; // Regular expressions
}
.cm-escape {
color: #d7ba7d; // Escape sequences in strings
}
.cm-meta {
color: #569cd6; // Meta keywords like import, export
}
.cm-definition {
color: #dcdcaa; // Function definitions
}
// Bracket matching
.cm-matchingBracket {
background-color: rgba(255, 255, 255, 0.1);
border: 1px solid #888;
}
.cm-nonmatchingBracket {
background-color: rgba(255, 0, 0, 0.2);
}
// Cursor
.cm-cursor {
border-left-color: #aeafad;
}
// Search/selection matches
.cm-selectionMatch {
background-color: rgba(173, 214, 255, 0.15);
}
// Focused state
&.cm-focused {
outline: none;
}
// Fold gutter
.cm-foldGutter {
.cm-gutterElement {
color: #858585;
&:hover {
color: #c5c5c5;
}
}
}
}

View File

@@ -1,298 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import CodeMirror from '@uiw/react-codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { oneDark } from '@codemirror/theme-one-dark';
import browser from 'webextension-polyfill';
import './index.scss';
// Create window.tlsn API for extension pages
class ExtensionAPI {
async execCode(code: string): Promise<unknown> {
if (!code || typeof code !== 'string') {
throw new Error('Code must be a non-empty string');
}
const response = await browser.runtime.sendMessage({
type: 'EXEC_CODE',
code,
requestId: `exec_${Date.now()}_${Math.random()}`,
});
if (response.success) {
return response.result;
} else {
throw new Error(response.error || 'Code execution failed');
}
}
}
// Initialize window.tlsn API
if (typeof window !== 'undefined') {
(window as any).tlsn = new ExtensionAPI();
}
interface ConsoleEntry {
timestamp: string;
message: string;
type: 'info' | 'error' | 'success';
}
const DEFAULT_CODE = `// Open X.com and return a greeting
const config = {
name: 'X Profile Prover',
description: 'This plugin will prove your X.com profile.',
};
async function prove() {
const [header] = useHeaders(headers => {
return headers.filter(header => header.url.includes('https://api.x.com/1.1/account/settings.json'));
});
const headers = {
'cookie': header.requestHeaders.find(header => header.name === 'Cookie')?.value,
'x-csrf-token': header.requestHeaders.find(header => header.name === 'x-csrf-token')?.value,
'x-client-transaction-id': header.requestHeaders.find(header => header.name === 'x-client-transaction-id')?.value,
Host: 'api.x.com',
authorization: header.requestHeaders.find(header => header.name === 'authorization')?.value,
'Accept-Encoding': 'identity',
Connection: 'close',
};
console.log('headers', headers);
const proverId = await createProver('api.x.com', 'http://localhost:7047');
console.log('prover', proverId);
await sendRequest(proverId, 'wss://notary.pse.dev/proxy?token=api.x.com', {
url: 'https://api.x.com/1.1/account/settings.json',
method: 'GET',
headers: headers,
});
const { sent, recv } = await transcript(proverId);
const commit = {
sent: subtractRanges(
{ start: 0, end: sent.length },
mapStringToRange(
[
\`x-csrf-token: \${headers['x-csrf-token']}\`,
\`x-client-transaction-id: \${headers['x-client-transaction-id']}\`,
\`cookie: \${headers['cookie']}\`,
\`authorization: \${headers.authorization}\`,
],
Buffer.from(sent).toString('utf-8'),
),
),
recv: [{ start: 0, end: recv.length }],
};
console.log('commit', commit);
await reveal(proverId, commit);
done(proverId);
}
function main() {
const [header] = useHeaders(headers => headers.filter(header => header.url.includes('https://api.x.com/1.1/account/settings.json')));
useEffect(() => {
openWindow('https://x.com');
}, []);
return div({
style: {
position: 'fixed',
bottom: '0',
right: '8px',
width: '240px',
height: '240px',
borderRadius: '4px 4px 0 0',
backgroundColor: '#b8b8b8',
zIndex: '999999',
fontSize: '16px',
color: '#0f0f0f',
border: '1px solid #e2e2e2',
borderBottom: 'none',
padding: '8px',
fontFamily: 'sans-serif',
},
}, [
div({
style: {
fontWeight: 'bold',
color: header ? 'green' : 'red',
},
}, [ header ? 'Profile detected!' : 'No profile detected']),
header
? button({
style: {
color: 'black',
backgroundColor: 'white',
},
onclick: 'prove',
}, ['Prove'])
: div({ style: {color: 'black'}}, ['Please login to x.com'])
]);
}
export default {
main,
prove,
config,
};
`;
const DevConsole: React.FC = () => {
const [code, setCode] = useState<string>(DEFAULT_CODE);
const consoleOutputRef = useRef<HTMLDivElement>(null);
const [consoleEntries, setConsoleEntries] = useState<ConsoleEntry[]>([
{
timestamp: new Date().toLocaleTimeString(),
message: 'DevConsole initialized. window.tlsn API ready.',
type: 'success',
},
]);
// Auto-scroll console to bottom when new entries are added
useEffect(() => {
if (consoleOutputRef.current) {
consoleOutputRef.current.scrollTop =
consoleOutputRef.current.scrollHeight;
}
}, [consoleEntries]);
const addConsoleEntry = (
message: string,
type: ConsoleEntry['type'] = 'info',
) => {
const timestamp = new Date().toLocaleTimeString();
setConsoleEntries((prev) => [...prev, { timestamp, message, type }]);
};
const executeCode = async () => {
const codeToExecute = code.trim();
if (!codeToExecute) {
addConsoleEntry('No code to execute', 'error');
return;
}
addConsoleEntry('Executing code...', 'info');
const startTime = performance.now();
try {
const result = await (window as any).tlsn.execCode(codeToExecute);
const executionTime = (performance.now() - startTime).toFixed(2);
addConsoleEntry(`Execution completed in ${executionTime}ms`, 'success');
if (result !== undefined) {
if (typeof result === 'object') {
addConsoleEntry(
`Result:\n${JSON.stringify(result, null, 2)}`,
'success',
);
} else {
addConsoleEntry(`Result: ${result}`, 'success');
}
} else {
addConsoleEntry(
'Code executed successfully (no return value)',
'success',
);
}
} catch (error: any) {
const executionTime = (performance.now() - startTime).toFixed(2);
addConsoleEntry(
`Error after ${executionTime}ms:\n${error.message}`,
'error',
);
}
};
const clearConsole = () => {
setConsoleEntries([
{
timestamp: new Date().toLocaleTimeString(),
message: 'Console cleared',
type: 'info',
},
]);
};
return (
<div className="dev-console">
<div className="editor-section">
<div className="editor-header">
<div className="editor-title">Code Editor</div>
<div className="editor-actions">
<button className="btn btn-primary" onClick={executeCode}>
Run Code
</button>
</div>
</div>
<CodeMirror
value={code}
height="100%"
theme={oneDark}
extensions={[javascript({ jsx: true })]}
onChange={(value) => setCode(value)}
basicSetup={{
lineNumbers: true,
highlightActiveLineGutter: true,
highlightSpecialChars: true,
history: true,
foldGutter: true,
drawSelection: true,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
syntaxHighlighting: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
rectangularSelection: true,
crosshairCursor: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
closeBracketsKeymap: true,
defaultKeymap: true,
searchKeymap: true,
historyKeymap: true,
foldKeymap: true,
completionKeymap: true,
lintKeymap: true,
}}
style={{
fontSize: '14px',
fontFamily: "'Monaco', 'Courier New', monospace",
height: '0',
flexGrow: 1,
}}
/>
</div>
<div className="console-section">
<div className="console-header">
<div className="console-title">Console</div>
<div className="editor-actions">
<button className="btn btn-secondary" onClick={clearConsole}>
Clear Console
</button>
</div>
</div>
<div className="console-output" ref={consoleOutputRef}>
{consoleEntries.map((entry, index) => (
<div key={index} className={`console-entry ${entry.type}`}>
<span className="console-timestamp">[{entry.timestamp}]</span>
<span className="console-message">{entry.message}</span>
</div>
))}
</div>
</div>
</div>
);
};
const container = document.getElementById('root');
if (!container) {
throw new Error('Root element not found');
}
const root = createRoot(container);
root.render(<DevConsole />);

View File

@@ -1,73 +0,0 @@
import React, { useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { SessionManager } from '../../offscreen/SessionManager';
const OffscreenApp: React.FC = () => {
useEffect(() => {
console.log('Offscreen document loaded');
// Initialize SessionManager
const sessionManager = new SessionManager();
console.log('SessionManager initialized in Offscreen');
// Listen for messages from background script
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
// Example message handling
if (request.type === 'PROCESS_DATA') {
// Process data in offscreen context
sendResponse({ success: true, data: 'Processed in offscreen' });
return true;
}
// Handle code execution requests
if (request.type === 'EXEC_CODE_OFFSCREEN') {
console.log('Offscreen executing code:', request.code);
if (!sessionManager) {
sendResponse({
success: false,
error: 'SessionManager not initialized',
requestId: request.requestId,
});
return true;
}
// Execute plugin code using SessionManager
sessionManager
.awaitInit()
.then((sessionManager) => sessionManager.executePlugin(request.code))
.then((result) => {
console.log('Plugin execution result:', result);
sendResponse({
success: true,
result,
requestId: request.requestId,
});
})
.catch((error) => {
console.error('Plugin execution error:', error);
sendResponse({
success: false,
error: error.message,
requestId: request.requestId,
});
});
return true; // Keep message channel open for async response
}
});
}, []);
return (
<div className="offscreen-container">
<h1>Offscreen Document</h1>
<p>This document runs in the background for processing tasks.</p>
</div>
);
};
const container = document.getElementById('app-container');
if (container) {
const root = createRoot(container);
root.render(<OffscreenApp />);
}

View File

@@ -1,33 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../../reducers';
import browser from 'webextension-polyfill';
const Popup: React.FC = () => {
const message = useSelector((state: RootState) => state.app.message);
const handleClick = async () => {
// Send message to background script
const response = await browser.runtime.sendMessage({ type: 'PING' });
console.log('Response from background:', response);
};
return (
<div className="w-[400px] h-[300px] bg-white p-8">
<div className="flex flex-col items-center justify-center h-full">
<h1 className="text-3xl font-bold text-gray-800 mb-4">Hello World!</h1>
<p className="text-gray-600 mb-6">
{message || 'Chrome Extension Boilerplate'}
</p>
<button
onClick={handleClick}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Test Background Script
</button>
</div>
</div>
);
};
export default Popup;

View File

@@ -1,20 +0,0 @@
// Mock crypto module for browser compatibility
export function randomBytes(size) {
const bytes = new Uint8Array(size);
if (typeof window !== 'undefined' && window.crypto) {
window.crypto.getRandomValues(bytes);
}
return Buffer.from(bytes);
}
export function createHash() {
return {
update: () => ({ digest: () => '' }),
digest: () => '',
};
}
export default {
randomBytes,
createHash,
};

View File

@@ -1,32 +0,0 @@
// Mock fs module for browser compatibility
export function readFileSync() {
return '';
}
export function writeFileSync() {
// No-op mock for browser compatibility
}
export function existsSync() {
return false;
}
export function mkdirSync() {
// No-op mock for browser compatibility
}
export function readdirSync() {
return [];
}
export function statSync() {
return {
isFile: () => false,
isDirectory: () => false,
};
}
export default {
readFileSync,
writeFileSync,
existsSync,
mkdirSync,
readdirSync,
statSync,
};

View File

@@ -1,151 +0,0 @@
import * as Comlink from 'comlink';
import { v4 as uuidv4 } from 'uuid';
import type {
Prover as TProver,
Method,
} from '../../../../tlsn-wasm-pkg/tlsn_wasm';
const { init, Prover } = Comlink.wrap<{
init: any;
Prover: typeof TProver;
}>(new Worker(new URL('./worker.ts', import.meta.url)));
export class ProveManager {
private provers: Map<string, TProver> = new Map();
async init() {
await init({
loggingLevel: 'Debug',
hardwareConcurrency: navigator.hardwareConcurrency,
crateFilters: [
{ name: 'yamux', level: 'Info' },
{ name: 'uid_mux', level: 'Info' },
],
});
console.log('ProveManager initialized');
}
private async getVerifierSessionUrl(
verifierUrl: string,
maxRecvData = 16384,
maxSentData = 4096,
) {
const resp = await fetch(`${verifierUrl}/session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
maxRecvData,
maxSentData,
}),
});
const { sessionId } = await resp.json();
const _url = new URL(verifierUrl);
const protocol = _url.protocol === 'https:' ? 'wss' : 'ws';
const pathname = _url.pathname;
const sessionUrl = `${protocol}://${_url.host}${pathname === '/' ? '' : pathname}/verifier?sessionId=${sessionId!}`;
return sessionUrl;
}
async createProver(
serverDns: string,
verifierUrl: string,
maxRecvData = 16384,
maxSentData = 4096,
) {
const proverId = uuidv4();
const sessionUrl = await this.getVerifierSessionUrl(
verifierUrl,
maxRecvData,
maxSentData,
);
console.log('[ProveManager] Creating prover with config:', {
server_name: serverDns,
max_recv_data: maxRecvData,
max_sent_data: maxSentData,
network: 'Bandwidth',
});
try {
const prover = await new Prover({
server_name: serverDns,
max_recv_data: maxRecvData,
max_sent_data: maxSentData,
network: 'Bandwidth',
max_sent_records: undefined,
max_recv_data_online: undefined,
max_recv_records_online: undefined,
defer_decryption_from_start: undefined,
client_auth: undefined,
});
console.log(
'[ProveManager] Prover instance created, calling setup...',
sessionUrl,
);
await prover.setup(sessionUrl as string);
console.log('[ProveManager] Prover setup completed');
this.provers.set(proverId, prover as any);
console.log('[ProveManager] Prover registered with ID:', proverId);
await new Promise((resolve) => setTimeout(resolve, 1000));
return proverId;
} catch (error) {
console.error('[ProveManager] Failed to create prover:', error);
throw error;
}
}
async getProver(proverId: string) {
const prover = this.provers.get(proverId);
if (!prover) {
throw new Error('Prover not found');
}
return prover;
}
async sendRequest(
proverId: string,
proxyUrl: string,
options: {
url: string;
method?: Method;
headers?: Record<string, string>;
body?: string;
},
) {
const prover = await this.getProver(proverId);
const headerMap: Map<string, number[]> = new Map();
Object.entries(options.headers || {}).forEach(([key, value]) => {
headerMap.set(key, Buffer.from(value).toJSON().data);
});
await prover.send_request(proxyUrl, {
uri: options.url,
method: options.method as Method,
headers: headerMap,
body: options.body,
});
}
async transcript(proverId: string) {
const prover = await this.getProver(proverId);
const transcript = await prover.transcript();
return transcript;
}
async reveal(
proverId: string,
commit: {
sent: { start: number; end: number }[];
recv: { start: number; end: number }[];
},
) {
const prover = await this.getProver(proverId);
await prover.reveal({ ...commit, server_identity: true });
}
}

View File

@@ -1,64 +0,0 @@
import * as Comlink from 'comlink';
import initWasm, {
LoggingLevel,
initialize,
Prover,
CrateLogFilter,
SpanEvent,
LoggingConfig,
} from '../../../../tlsn-wasm-pkg/tlsn_wasm';
export default async function init(config?: {
loggingLevel?: LoggingLevel;
hardwareConcurrency?: number;
crateFilters?: CrateLogFilter[];
}): Promise<void> {
const {
loggingLevel = 'Info',
hardwareConcurrency = navigator.hardwareConcurrency || 4,
crateFilters,
} = config || {};
try {
await initWasm();
console.log('[Worker] initWasm completed successfully');
} catch (error) {
console.error('[Worker] initWasm failed:', error);
throw new Error(`WASM initialization failed: ${error}`);
}
// Build logging config - omit undefined fields to avoid WASM signature mismatch
const loggingConfig: LoggingConfig = {
level: loggingLevel,
crate_filters: crateFilters || [],
span_events: undefined,
};
try {
await initialize(loggingConfig, hardwareConcurrency);
} catch (error) {
console.error('[Worker] Initialize failed:', error);
console.error('[Worker] Error details:', {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
name: error instanceof Error ? error.name : undefined,
});
// Try one more time with completely null config as fallback
try {
console.log('[Worker] Retrying with null config...');
await initialize(null, 1);
console.log('[Worker] Retry succeeded with null config');
} catch (retryError) {
console.error('[Worker] Retry also failed:', retryError);
throw new Error(
`Initialize failed: ${error}. Retry with null also failed: ${retryError}`,
);
}
}
}
Comlink.expose({
init,
Prover,
});

View File

@@ -1,539 +0,0 @@
import Host from '@tlsn/plugin-sdk/src';
import { v4 as uuidv4 } from 'uuid';
import {
InterceptedRequest,
InterceptedRequestHeader,
} from '../types/window-manager';
import deepEqual from 'fast-deep-equal';
import { ProveManager } from './ProveManager';
import { Method, subtractRanges, mapStringToRange, Commit } from 'tlsn-js';
type SessionState = {
id: string;
pluginUrl: string;
plugin: string;
requests?: InterceptedRequest[];
headers?: InterceptedRequestHeader[];
windowId?: number;
context: {
[functionName: string]: {
effects: any[][];
selectors: any[][];
};
};
currentContext: string;
sandbox: {
eval: (code: string) => Promise<unknown>;
dispose: () => void;
};
main: () => any;
callbacks: {
[callbackName: string]: () => Promise<void>;
};
};
type DomOptions = {
className?: string;
id?: string;
style?: { [key: string]: string };
onclick?: string;
};
export type DomJson =
| {
type: 'div' | 'button';
options: DomOptions;
children: DomJson[];
}
| string;
export class SessionManager {
private host: Host;
private proveManager: ProveManager;
private sessions: Map<string, SessionState> = new Map();
private initPromise: Promise<void>;
constructor() {
this.host = new Host();
this.proveManager = new ProveManager();
this.initPromise = new Promise(async (resolve) => {
await this.proveManager.init();
resolve();
});
}
async awaitInit(): Promise<SessionManager> {
await this.initPromise;
return this;
}
async executePlugin(code: string): Promise<unknown> {
const uuid = uuidv4();
const context: {
[functionName: string]: {
effects: any[][];
selectors: any[][];
};
} = {};
let doneResolve: (args?: any[]) => void;
const donePromise = new Promise((resolve) => {
doneResolve = resolve;
});
/**
* The sandbox is a sandboxed environment that is used to execute the plugin code.
* It is created using the createEvalCode method from the plugin-sdk.
* The sandbox is created with the following capabilities:
* - div: a function that creates a div element
* - button: a function that creates a button element
* - openWindow: a function that opens a new window
* - useEffect: a function that creates a useEffect hook
* - useRequests: a function that creates a useRequests hook
* - useHeaders: a function that creates a useHeaders hook
* - subtractRanges: a function that subtracts ranges
* - mapStringToRange: a function that maps a string to a range
* - createProver: a function that creates a prover
* - sendRequest: a function that sends a request
* - transcript: a function that returns the transcript
* - reveal: a function that reveals a commit
* - closeWindow: a function that closes a window by windowId
* - done: a function that completes the session and closes the window
*/
const sandbox = await this.host.createEvalCode({
div: this.createDomJson.bind(this, 'div'),
button: this.createDomJson.bind(this, 'button'),
openWindow: this.makeOpenWindow(uuid),
useEffect: this.makeUseEffect(uuid, context),
useRequests: this.makeUseRequests(uuid, context),
useHeaders: this.makeUseHeaders(uuid, context),
subtractRanges: subtractRanges,
mapStringToRange: mapStringToRange,
createProver: (serverDns: string, verifierUrl: string) => {
return this.proveManager.createProver(serverDns, verifierUrl);
},
sendRequest: (
proverId: string,
proxyUrl: string,
options: {
url: string;
method?: Method;
headers?: Record<string, string>;
body?: string;
},
) => {
return this.proveManager.sendRequest(proverId, proxyUrl, options);
},
transcript: (proverId: string) => {
return this.proveManager.transcript(proverId);
},
reveal: (proverId: string, commit: Commit) => {
return this.proveManager.reveal(proverId, commit);
},
closeWindow: async (windowId: number) => {
const chromeRuntime = (
global as unknown as { chrome?: { runtime?: any } }
).chrome?.runtime;
if (!chromeRuntime?.sendMessage) {
throw new Error('Chrome runtime not available');
}
const response = await chromeRuntime.sendMessage({
type: 'CLOSE_WINDOW',
windowId,
});
if (response?.type === 'WINDOW_ERROR') {
throw new Error(
response.payload?.details ||
response.payload?.error ||
'Failed to close window',
);
}
return response;
},
done: (args?: any[]) => {
// Close the window if it exists
const session = this.sessions.get(uuid);
if (session?.windowId) {
const chromeRuntime = (
global as unknown as { chrome?: { runtime?: any } }
).chrome?.runtime;
if (chromeRuntime?.sendMessage) {
chromeRuntime.sendMessage({
type: 'CLOSE_WINDOW',
windowId: session.windowId,
});
}
}
doneResolve(args);
},
});
const exportedCode = await sandbox.eval(`
const div = env.div;
const button = env.button;
const openWindow = env.openWindow;
const useEffect = env.useEffect;
const useRequests = env.useRequests;
const useHeaders = env.useHeaders;
const createProver = env.createProver;
const sendRequest = env.sendRequest;
const transcript = env.transcript;
const subtractRanges = env.subtractRanges;
const mapStringToRange = env.mapStringToRange;
const reveal = env.reveal;
const closeWindow = env.closeWindow;
const done = env.done;
${code};
`);
const { main: mainFn, config, ...args } = exportedCode;
if (typeof mainFn !== 'function') {
throw new Error('Main function not found');
}
const callbacks: {
[callbackName: string]: () => Promise<void>;
} = {};
for (const key in args) {
if (typeof args[key] === 'function') {
callbacks[key] = args[key];
}
}
const main = () => {
try {
this.updateSession(uuid, {
currentContext: 'main',
});
let result = mainFn();
const lastSelectors =
this.sessions.get(uuid)?.context['main']?.selectors;
const selectors = context['main']?.selectors;
if (deepEqual(lastSelectors, selectors)) {
result = null;
}
this.updateSession(uuid, {
currentContext: '',
context: {
...this.sessions.get(uuid)?.context,
main: {
effects: JSON.parse(JSON.stringify(context['main']?.effects)),
selectors: JSON.parse(JSON.stringify(context['main']?.selectors)),
},
},
});
if (context['main']) {
context['main'].effects.length = 0;
context['main'].selectors.length = 0;
}
if (result) {
console.log('Main function executed:', result);
const chromeRuntime = (
global as unknown as { chrome?: { runtime?: any } }
).chrome?.runtime;
if (!chromeRuntime?.sendMessage) {
throw new Error('Chrome runtime not available');
}
if (this.sessions.get(uuid)?.windowId) {
chromeRuntime.sendMessage({
type: 'RENDER_PLUGIN_UI',
json: result,
windowId: this.sessions.get(uuid)?.windowId,
});
}
}
return result;
} catch (error) {
console.error('Main function error:', error);
sandbox.dispose();
return null;
}
};
this.sessions.set(uuid, {
id: uuid,
plugin: code,
pluginUrl: '',
context: {},
currentContext: '',
sandbox,
main: main,
callbacks: callbacks,
});
main();
return donePromise;
}
updateSession(
uuid: string,
params: {
windowId?: number;
plugin?: string;
requests?: InterceptedRequest[];
headers?: InterceptedRequestHeader[];
context?: {
[functionName: string]: {
effects: any[][];
selectors: any[][];
};
};
currentContext?: string;
},
): void {
const session = this.sessions.get(uuid);
if (!session) {
throw new Error('Session not found');
}
this.sessions.set(uuid, { ...session, ...params });
}
startSession(_pluginUrl: string): void {
// Reserved for future use
}
createDomJson = (
type: 'div' | 'button',
param1: DomOptions | DomJson[] = {},
param2: DomJson[] = [],
): DomJson => {
let options: DomOptions = {};
let children: DomJson[] = [];
if (Array.isArray(param1)) {
children = param1;
} else if (typeof param1 === 'object') {
options = param1;
children = param2;
}
return {
type,
options,
children,
};
};
makeUseEffect = (
uuid: string,
context: {
[functionName: string]: {
effects: any[][];
selectors: any[][];
};
},
) => {
return (effect: () => void, deps: any[]) => {
const session = this.sessions.get(uuid);
if (!session) {
throw new Error('Session not found');
}
const functionName = session.currentContext;
context[functionName] = context[functionName] || {
effects: [],
selectors: [],
};
const effects = context[functionName].effects;
const lastDeps = session.context[functionName]?.effects[effects.length];
effects.push(deps);
if (deepEqual(lastDeps, deps)) {
return;
}
effect();
};
};
makeUseRequests = (
uuid: string,
context: {
[functionName: string]: {
effects: any[][];
selectors: any[][];
};
},
) => {
return (
filterFn: (requests: InterceptedRequest[]) => InterceptedRequest[],
) => {
const session = this.sessions.get(uuid);
if (!session) {
throw new Error('Session not found');
}
const functionName = session.currentContext;
context[functionName] = context[functionName] || {
effects: [],
selectors: [],
};
const selectors = context[functionName].selectors;
const result = filterFn(session.requests || []);
selectors.push(result);
return result;
};
};
makeUseHeaders = (
uuid: string,
context: {
[functionName: string]: {
effects: any[][];
selectors: any[][];
};
},
) => {
return (
filterFn: (
headers: InterceptedRequestHeader[],
) => InterceptedRequestHeader[],
) => {
const session = this.sessions.get(uuid);
if (!session) {
throw new Error('Session not found');
}
const functionName = session.currentContext;
context[functionName] = context[functionName] || {
effects: [],
selectors: [],
};
const selectors = context[functionName].selectors;
const result = filterFn(session.headers || []);
selectors.push(result);
return result;
};
};
/**
* Open a new browser window with the specified URL
* This method sends a message to the background script to create a managed window
* with request interception enabled.
*
* @param url - The URL to open in the new window
* @param options - Optional window configuration
* @returns Promise that resolves with window info or rejects with error
*/
makeOpenWindow =
(uuid: string) =>
async (
url: string,
options?: {
width?: number;
height?: number;
showOverlay?: boolean;
},
): Promise<{ windowId: number; uuid: string; tabId: number }> => {
if (!url || typeof url !== 'string') {
throw new Error('URL must be a non-empty string');
}
// Access chrome runtime (available in offscreen document)
const chromeRuntime = (
global as unknown as { chrome?: { runtime?: any } }
).chrome?.runtime;
if (!chromeRuntime?.sendMessage) {
throw new Error('Chrome runtime not available');
}
try {
const response = await chromeRuntime.sendMessage({
type: 'OPEN_WINDOW',
url,
width: options?.width,
height: options?.height,
showOverlay: options?.showOverlay,
});
// Check if response indicates an error
if (response?.type === 'WINDOW_ERROR') {
throw new Error(
response.payload?.details ||
response.payload?.error ||
'Failed to open window',
);
}
// Return window info from successful response
if (response?.type === 'WINDOW_OPENED' && response.payload) {
this.updateSession(uuid, {
windowId: response.payload.windowId,
});
const onMessage = async (message: any) => {
if (message.type === 'REQUEST_INTERCEPTED') {
const request = message.request;
const session = this.sessions.get(uuid);
if (!session) {
throw new Error('Session not found');
}
this.updateSession(uuid, {
requests: [...(session.requests || []), request],
});
session.main();
}
if (message.type === 'HEADER_INTERCEPTED') {
const header = message.header;
const session = this.sessions.get(uuid);
if (!session) {
throw new Error('Session not found');
}
this.updateSession(uuid, {
headers: [...(session.headers || []), header],
});
session.main();
}
if (message.type === 'PLUGIN_UI_CLICK') {
console.log('PLUGIN_UI_CLICK', message);
const session = this.sessions.get(uuid);
if (!session) {
throw new Error('Session not found');
}
const cb = session.callbacks[message.onclick];
if (cb) {
this.updateSession(uuid, {
currentContext: message.onclick,
});
const result = await cb();
this.updateSession(uuid, {
currentContext: '',
});
console.log('Callback result:', result);
}
}
if (message.type === 'WINDOW_CLOSED') {
chromeRuntime.onMessage.removeListener(onMessage);
}
};
chromeRuntime.onMessage.addListener(onMessage);
return {
windowId: response.payload.windowId,
uuid: response.payload.uuid,
tabId: response.payload.tabId,
};
}
throw new Error('Invalid response from background script');
} catch (error) {
console.error('[SessionManager] Failed to open window:', error);
throw error;
}
};
}

View File

@@ -1,47 +0,0 @@
import { combineReducers } from 'redux';
// Basic app reducer
interface AppState {
message: string;
count: number;
}
const initialAppState: AppState = {
message: 'Welcome to the extension!',
count: 0,
};
// Action types
const SET_MESSAGE = 'SET_MESSAGE';
const INCREMENT_COUNT = 'INCREMENT_COUNT';
// Action creators
export const setMessage = (message: string) => ({
type: SET_MESSAGE,
payload: message,
});
export const incrementCount = () => ({
type: INCREMENT_COUNT,
});
// App reducer
const appReducer = (state = initialAppState, action: any): AppState => {
switch (action.type) {
case SET_MESSAGE:
return { ...state, message: action.payload };
case INCREMENT_COUNT:
return { ...state, count: state.count + 1 };
default:
return state;
}
};
// Root reducer
const rootReducer = combineReducers({
app: appReducer,
});
export type RootState = ReturnType<typeof rootReducer>;
export type AppRootState = RootState; // For backward compatibility
export default rootReducer;

View File

@@ -1,164 +0,0 @@
/**
* Type definitions for WindowManager
*
* These types define the core data structures for managing multiple
* browser windows with request interception and TLSN overlay functionality.
*/
/**
* Configuration for registering a new window with the WindowManager
*/
export interface WindowRegistration {
/** Chrome window ID */
id: number;
/** Primary tab ID within the window */
tabId: number;
/** Target URL for the window */
url: string;
/** Whether to show the TLSN overlay on creation (default: true) */
showOverlay?: boolean;
}
/**
* An intercepted HTTP request captured by the webRequest API
*/
export interface InterceptedRequest {
/** Unique request ID from webRequest API */
id: string;
/** HTTP method (GET, POST, PUT, DELETE, etc.) */
method: string;
/** Full request URL */
url: string;
/** Unix timestamp (milliseconds) when request was intercepted */
timestamp: number;
/** Tab ID where the request originated */
tabId: number;
}
export interface InterceptedRequestHeader {
id: string;
method: string;
url: string;
timestamp: number;
type: string;
requestHeaders: { name: string; value?: string }[];
tabId: number;
}
/**
* A managed browser window tracked by WindowManager
*/
export interface ManagedWindow {
/** Chrome window ID */
id: number;
/** Internal unique identifier (UUID v4) */
uuid: string;
/** Primary tab ID */
tabId: number;
/** Current or initial URL */
url: string;
/** Creation timestamp */
createdAt: Date;
/** Array of intercepted HTTP requests for this window */
requests: InterceptedRequest[];
/** Array of intercepted HTTP request headers for this window */
headers: InterceptedRequestHeader[];
/** Whether the TLSN overlay is currently visible */
overlayVisible: boolean;
pluginUIVisible: boolean;
/** Whether to show overlay when tab becomes ready (complete status) */
showOverlayWhenReady: boolean;
}
/**
* WindowManager interface defining all window management operations
*/
export interface IWindowManager {
/**
* Register a new window with the manager
* @param config - Window registration configuration
* @returns The created ManagedWindow object
*/
registerWindow(config: WindowRegistration): Promise<ManagedWindow>;
/**
* Close and cleanup a window
* @param windowId - Chrome window ID
*/
closeWindow(windowId: number): Promise<void>;
/**
* Get a managed window by ID
* @param windowId - Chrome window ID
* @returns The ManagedWindow or undefined if not found
*/
getWindow(windowId: number): ManagedWindow | undefined;
/**
* Get a managed window by tab ID
* @param tabId - Chrome tab ID
* @returns The ManagedWindow or undefined if not found
*/
getWindowByTabId(tabId: number): ManagedWindow | undefined;
/**
* Get all managed windows
* @returns Map of window IDs to ManagedWindow objects
*/
getAllWindows(): Map<number, ManagedWindow>;
/**
* Add an intercepted request to a window
* @param windowId - Chrome window ID
* @param request - The intercepted request to add
*/
addRequest(windowId: number, request: InterceptedRequest): void;
/**
* Get all requests for a window
* @param windowId - Chrome window ID
* @returns Array of intercepted requests
*/
getWindowRequests(windowId: number): InterceptedRequest[];
/**
* Show the TLSN overlay in a window
* @param windowId - Chrome window ID
*/
showOverlay(windowId: number): Promise<void>;
/**
* Hide the TLSN overlay in a window
* @param windowId - Chrome window ID
*/
hideOverlay(windowId: number): Promise<void>;
/**
* Check if overlay is visible in a window
* @param windowId - Chrome window ID
* @returns true if overlay is visible, false otherwise
*/
isOverlayVisible(windowId: number): boolean;
/**
* Cleanup windows that are no longer valid
* Removes windows from tracking if they've been closed in the browser
*/
cleanupInvalidWindows(): Promise<void>;
}

View File

@@ -1,181 +0,0 @@
/**
* URL validation utilities for TLSN extension
*
* Provides robust URL validation to prevent security issues
* and ensure only valid HTTP/HTTPS URLs are opened.
*/
/**
* Allowed URL protocols for window opening
*/
const ALLOWED_PROTOCOLS = ['http:', 'https:'];
/**
* Dangerous protocols that should be rejected
*/
const DANGEROUS_PROTOCOLS = [
'javascript:',
'data:',
'file:',
'blob:',
'about:',
];
/**
* Result of URL validation
*/
export interface UrlValidationResult {
/** Whether the URL is valid and safe to use */
valid: boolean;
/** Error message if validation failed */
error?: string;
/** Parsed URL object if valid */
url?: URL;
}
/**
* Validate a URL for use with window.tlsn.open()
*
* Checks that the URL:
* - Is a non-empty string
* - Can be parsed as a valid URL
* - Uses http: or https: protocol only
* - Does not use dangerous protocols
*
* @param urlString - The URL string to validate
* @returns Validation result with parsed URL or error message
*
* @example
* ```typescript
* const result = validateUrl('https://example.com');
* if (result.valid) {
* console.log('URL is safe:', result.url.href);
* } else {
* console.error('Invalid URL:', result.error);
* }
* ```
*/
export function validateUrl(urlString: unknown): UrlValidationResult {
// Check if URL is a non-empty string
if (!urlString || typeof urlString !== 'string') {
return {
valid: false,
error: 'URL must be a non-empty string',
};
}
const trimmedUrl = urlString.trim();
if (trimmedUrl.length === 0) {
return {
valid: false,
error: 'URL cannot be empty or whitespace only',
};
}
// Try to parse URL
let parsedUrl: URL;
try {
parsedUrl = new URL(trimmedUrl);
} catch (error) {
return {
valid: false,
error: `Invalid URL format: ${trimmedUrl}`,
};
}
// Check for dangerous protocols first
if (DANGEROUS_PROTOCOLS.includes(parsedUrl.protocol)) {
return {
valid: false,
error: `Dangerous protocol rejected: ${parsedUrl.protocol}. Only HTTP and HTTPS are allowed.`,
};
}
// Check for allowed protocols
if (!ALLOWED_PROTOCOLS.includes(parsedUrl.protocol)) {
return {
valid: false,
error: `Invalid protocol: ${parsedUrl.protocol}. Only HTTP and HTTPS are allowed.`,
};
}
// Additional security checks
if (!parsedUrl.hostname || parsedUrl.hostname.length === 0) {
return {
valid: false,
error: 'URL must include a valid hostname',
};
}
// URL is valid and safe
return {
valid: true,
url: parsedUrl,
};
}
/**
* Sanitize a URL by removing potentially dangerous components
*
* This function:
* - Trims whitespace
* - Removes URL fragments that could be used for XSS
* - Normalizes the URL
*
* @param urlString - The URL to sanitize
* @returns Sanitized URL string or null if invalid
*
* @example
* ```typescript
* const sanitized = sanitizeUrl(' https://example.com#dangerous ');
* // Returns: 'https://example.com/'
* ```
*/
export function sanitizeUrl(urlString: string): string | null {
const validation = validateUrl(urlString);
if (!validation.valid || !validation.url) {
return null;
}
// Return the normalized URL without fragment
const sanitized = new URL(validation.url.href);
// Keep the fragment for now - it might be needed for single-page apps
// If security concerns arise, uncomment: sanitized.hash = '';
return sanitized.href;
}
/**
* Check if a URL is an HTTP or HTTPS URL
*
* This is a convenience function for quick protocol checks.
*
* @param urlString - The URL to check
* @returns true if URL is HTTP or HTTPS
*/
export function isHttpUrl(urlString: string): boolean {
try {
const url = new URL(urlString);
return ALLOWED_PROTOCOLS.includes(url.protocol);
} catch {
return false;
}
}
/**
* Get a user-friendly error message for URL validation failures
*
* @param urlString - The URL that failed validation
* @returns User-friendly error message
*/
export function getUrlErrorMessage(urlString: unknown): string {
const result = validateUrl(urlString);
if (result.valid) {
return 'URL is valid';
}
return result.error || 'Unknown URL validation error';
}

View File

@@ -1,82 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>SessionManager Browser Test</title>
<meta charset="UTF-8">
</head>
<body>
<h1>SessionManager Browser Test</h1>
<div id="status">Loading...</div>
<div id="result"></div>
<script>
// Simulate Chrome extension runtime API for testing
if (!window.chrome || !window.chrome.runtime) {
window.chrome = {
runtime: {
onMessage: {
addListener: function(callback) {
console.log('Mock chrome.runtime.onMessage.addListener registered');
window._mockMessageListener = callback;
}
}
}
};
}
// Function to send test message
window.testSessionManager = function() {
const requestId = Date.now();
const testCode = 'export default env.add(5, 3)';
console.log('Sending test message:', testCode);
const mockSender = {};
const mockSendResponse = function(response) {
console.log('Received response:', response);
const resultDiv = document.getElementById('result');
if (response.success) {
resultDiv.innerHTML = `<div style="color: green;">
<h2>✓ Success!</h2>
<p>Request ID: ${response.requestId}</p>
<p>Result: ${response.result}</p>
<p>Expected: 8</p>
<p>Match: ${response.result === 8 ? 'YES' : 'NO'}</p>
</div>`;
} else {
resultDiv.innerHTML = `<div style="color: red;">
<h2>✗ Error</h2>
<p>Request ID: ${response.requestId}</p>
<p>Error: ${response.error}</p>
</div>`;
}
};
if (window._mockMessageListener) {
window._mockMessageListener(
{
type: 'EXEC_CODE_OFFSCREEN',
code: testCode,
requestId: requestId
},
mockSender,
mockSendResponse
);
}
};
// Load the offscreen bundle
const script = document.createElement('script');
script.src = './build/offscreen.bundle.js';
script.onload = function() {
document.getElementById('status').innerHTML = '<span style="color: green;">✓ Offscreen bundle loaded</span><br><button onclick="testSessionManager()">Run Test</button>';
console.log('Offscreen bundle loaded, SessionManager should be initialized');
};
script.onerror = function() {
document.getElementById('status').innerHTML = '<span style="color: red;">✗ Failed to load offscreen bundle</span>';
};
document.body.appendChild(script);
</script>
</body>
</html>

View File

@@ -1,558 +0,0 @@
/**
* WindowManager unit tests
*
* Tests all WindowManager functionality including window lifecycle,
* request tracking, overlay management, and cleanup.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { WindowManager } from '../../src/background/WindowManager';
import type {
WindowRegistration,
InterceptedRequest,
} from '../../src/types/window-manager';
import browser from 'webextension-polyfill';
describe('WindowManager', () => {
let windowManager: WindowManager;
beforeEach(() => {
windowManager = new WindowManager();
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('Window Registration', () => {
it('should register a new window', async () => {
const config: WindowRegistration = {
id: 123,
tabId: 456,
url: 'https://example.com',
showOverlay: false, // Don't trigger overlay in test
};
const window = await windowManager.registerWindow(config);
expect(window.id).toBe(123);
expect(window.tabId).toBe(456);
expect(window.url).toBe('https://example.com');
expect(window.uuid).toBeDefined();
expect(window.uuid).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
expect(window.createdAt).toBeInstanceOf(Date);
expect(window.requests).toEqual([]);
expect(window.overlayVisible).toBe(false);
});
it('should generate unique UUIDs for each window', async () => {
const window1 = await windowManager.registerWindow({
id: 1,
tabId: 10,
url: 'https://example1.com',
showOverlay: false,
});
const window2 = await windowManager.registerWindow({
id: 2,
tabId: 20,
url: 'https://example2.com',
showOverlay: false,
});
expect(window1.uuid).not.toBe(window2.uuid);
});
it('should set showOverlayWhenReady by default when showOverlay not specified', async () => {
const config: WindowRegistration = {
id: 123,
tabId: 456,
url: 'https://example.com',
};
const window = await windowManager.registerWindow(config);
expect(window.showOverlayWhenReady).toBe(true);
expect(window.overlayVisible).toBe(false);
// Overlay will be shown by tabs.onUpdated listener when tab becomes 'complete'
});
it('should not set showOverlayWhenReady when showOverlay is false', async () => {
const config: WindowRegistration = {
id: 123,
tabId: 456,
url: 'https://example.com',
showOverlay: false,
};
const window = await windowManager.registerWindow(config);
expect(window.showOverlayWhenReady).toBe(false);
expect(window.overlayVisible).toBe(false);
});
});
describe('Window Lookup', () => {
beforeEach(async () => {
await windowManager.registerWindow({
id: 123,
tabId: 456,
url: 'https://example.com',
showOverlay: false,
});
});
it('should retrieve window by ID', () => {
const window = windowManager.getWindow(123);
expect(window).toBeDefined();
expect(window!.id).toBe(123);
expect(window!.tabId).toBe(456);
});
it('should return undefined for non-existent window ID', () => {
const window = windowManager.getWindow(999);
expect(window).toBeUndefined();
});
it('should retrieve window by tab ID', () => {
const window = windowManager.getWindowByTabId(456);
expect(window).toBeDefined();
expect(window!.id).toBe(123);
expect(window!.tabId).toBe(456);
});
it('should return undefined for non-existent tab ID', () => {
const window = windowManager.getWindowByTabId(999);
expect(window).toBeUndefined();
});
it('should retrieve all windows', async () => {
await windowManager.registerWindow({
id: 456,
tabId: 789,
url: 'https://example2.com',
showOverlay: false,
});
const allWindows = windowManager.getAllWindows();
expect(allWindows.size).toBe(2);
expect(allWindows.has(123)).toBe(true);
expect(allWindows.has(456)).toBe(true);
});
it('should return a copy of windows map', async () => {
const windows1 = windowManager.getAllWindows();
const windows2 = windowManager.getAllWindows();
expect(windows1).not.toBe(windows2);
expect(windows1.size).toBe(windows2.size);
});
});
describe('Window Closing', () => {
beforeEach(async () => {
await windowManager.registerWindow({
id: 123,
tabId: 456,
url: 'https://example.com',
showOverlay: false,
});
});
it('should close and remove window', async () => {
await windowManager.closeWindow(123);
const window = windowManager.getWindow(123);
expect(window).toBeUndefined();
});
it('should hide overlay before closing if visible', async () => {
await windowManager.showOverlay(123);
vi.clearAllMocks();
await windowManager.closeWindow(123);
expect(browser.tabs.sendMessage).toHaveBeenCalledWith(
456,
expect.objectContaining({
type: 'HIDE_TLSN_OVERLAY',
}),
);
});
it('should handle closing non-existent window gracefully', async () => {
await expect(windowManager.closeWindow(999)).resolves.not.toThrow();
});
});
describe('Request Tracking', () => {
beforeEach(async () => {
await windowManager.registerWindow({
id: 123,
tabId: 456,
url: 'https://example.com',
showOverlay: false,
});
});
it('should add request to window', () => {
const request: InterceptedRequest = {
id: 'req-1',
method: 'GET',
url: 'https://example.com/api/data',
timestamp: Date.now(),
tabId: 456,
};
windowManager.addRequest(123, request);
const requests = windowManager.getWindowRequests(123);
expect(requests).toHaveLength(1);
expect(requests[0]).toEqual(request);
});
it('should add timestamp if not provided', () => {
const request: InterceptedRequest = {
id: 'req-1',
method: 'GET',
url: 'https://example.com/api/data',
timestamp: 0, // Will be replaced
tabId: 456,
};
const beforeTime = Date.now();
windowManager.addRequest(123, request);
const afterTime = Date.now();
const requests = windowManager.getWindowRequests(123);
expect(requests[0].timestamp).toBeGreaterThanOrEqual(beforeTime);
expect(requests[0].timestamp).toBeLessThanOrEqual(afterTime);
});
it('should handle multiple requests in order', () => {
const request1: InterceptedRequest = {
id: 'req-1',
method: 'GET',
url: 'https://example.com/page1',
timestamp: 1000,
tabId: 456,
};
const request2: InterceptedRequest = {
id: 'req-2',
method: 'POST',
url: 'https://example.com/api',
timestamp: 2000,
tabId: 456,
};
windowManager.addRequest(123, request1);
windowManager.addRequest(123, request2);
const requests = windowManager.getWindowRequests(123);
expect(requests).toHaveLength(2);
expect(requests[0].id).toBe('req-1');
expect(requests[1].id).toBe('req-2');
});
it('should log error when adding request to non-existent window', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {
/* no-op mock */
});
const request: InterceptedRequest = {
id: 'req-1',
method: 'GET',
url: 'https://example.com/api',
timestamp: Date.now(),
tabId: 999,
};
windowManager.addRequest(999, request);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Cannot add request to non-existent window'),
);
consoleErrorSpy.mockRestore();
});
it('should return empty array for non-existent window requests', () => {
const requests = windowManager.getWindowRequests(999);
expect(requests).toEqual([]);
});
it('should update overlay when request added to visible overlay', async () => {
await windowManager.showOverlay(123);
vi.clearAllMocks();
const request: InterceptedRequest = {
id: 'req-1',
method: 'GET',
url: 'https://example.com/api',
timestamp: Date.now(),
tabId: 456,
};
windowManager.addRequest(123, request);
// Give async updateOverlay time to execute
await vi.runAllTimersAsync();
expect(browser.tabs.sendMessage).toHaveBeenCalledWith(
456,
expect.objectContaining({
type: 'UPDATE_TLSN_REQUESTS',
requests: expect.arrayContaining([request]),
}),
);
});
});
describe('Overlay Management', () => {
beforeEach(async () => {
await windowManager.registerWindow({
id: 123,
tabId: 456,
url: 'https://example.com',
showOverlay: false,
});
});
it('should show overlay', async () => {
await windowManager.showOverlay(123);
expect(browser.tabs.sendMessage).toHaveBeenCalledWith(
456,
expect.objectContaining({
type: 'SHOW_TLSN_OVERLAY',
requests: [],
}),
);
expect(windowManager.isOverlayVisible(123)).toBe(true);
});
it('should hide overlay', async () => {
await windowManager.showOverlay(123);
vi.clearAllMocks();
await windowManager.hideOverlay(123);
expect(browser.tabs.sendMessage).toHaveBeenCalledWith(
456,
expect.objectContaining({
type: 'HIDE_TLSN_OVERLAY',
}),
);
expect(windowManager.isOverlayVisible(123)).toBe(false);
});
it('should include requests when showing overlay', async () => {
const request: InterceptedRequest = {
id: 'req-1',
method: 'GET',
url: 'https://example.com/api',
timestamp: Date.now(),
tabId: 456,
};
windowManager.addRequest(123, request);
await windowManager.showOverlay(123);
expect(browser.tabs.sendMessage).toHaveBeenCalledWith(
456,
expect.objectContaining({
type: 'SHOW_TLSN_OVERLAY',
requests: expect.arrayContaining([request]),
}),
);
});
it('should return false for non-existent window overlay visibility', () => {
expect(windowManager.isOverlayVisible(999)).toBe(false);
});
it('should handle overlay show error gracefully', async () => {
// Mock sendMessage to fail for all retry attempts
vi.mocked(browser.tabs.sendMessage).mockRejectedValue(
new Error('Tab not found'),
);
// Start showOverlay (which will retry with delays)
const showPromise = windowManager.showOverlay(123);
// Advance timers through all retry delays (10 retries × 500ms = 5000ms)
await vi.advanceTimersByTimeAsync(5500);
await expect(showPromise).resolves.not.toThrow();
expect(windowManager.isOverlayVisible(123)).toBe(false);
});
it('should handle overlay hide error gracefully', async () => {
await windowManager.showOverlay(123);
vi.mocked(browser.tabs.sendMessage).mockRejectedValueOnce(
new Error('Tab not found'),
);
await expect(windowManager.hideOverlay(123)).resolves.not.toThrow();
});
});
describe('Cleanup', () => {
it('should remove invalid windows during cleanup', async () => {
// Register multiple windows
await windowManager.registerWindow({
id: 123,
tabId: 456,
url: 'https://example1.com',
showOverlay: false,
});
await windowManager.registerWindow({
id: 456,
tabId: 789,
url: 'https://example2.com',
showOverlay: false,
});
// Mock window 123 still exists, window 456 is closed
vi.mocked(browser.windows.get).mockImplementation((windowId) => {
if (windowId === 123) {
return Promise.resolve({ id: 123 } as any);
}
return Promise.reject(new Error('Window not found'));
});
await windowManager.cleanupInvalidWindows();
// Window 123 should still exist
expect(windowManager.getWindow(123)).toBeDefined();
// Window 456 should be cleaned up
expect(windowManager.getWindow(456)).toBeUndefined();
});
it('should handle cleanup with no invalid windows', async () => {
await windowManager.registerWindow({
id: 123,
tabId: 456,
url: 'https://example.com',
showOverlay: false,
});
vi.mocked(browser.windows.get).mockResolvedValue({ id: 123 } as any);
await expect(
windowManager.cleanupInvalidWindows(),
).resolves.not.toThrow();
expect(windowManager.getWindow(123)).toBeDefined();
});
it('should handle cleanup with no windows', async () => {
await expect(
windowManager.cleanupInvalidWindows(),
).resolves.not.toThrow();
});
});
describe('Integration Scenarios', () => {
it('should handle complete window lifecycle', async () => {
// Register window
const window = await windowManager.registerWindow({
id: 123,
tabId: 456,
url: 'https://example.com',
showOverlay: false,
});
expect(window.uuid).toBeDefined();
// Add requests
windowManager.addRequest(123, {
id: 'req-1',
method: 'GET',
url: 'https://example.com/page',
timestamp: Date.now(),
tabId: 456,
});
windowManager.addRequest(123, {
id: 'req-2',
method: 'POST',
url: 'https://example.com/api',
timestamp: Date.now(),
tabId: 456,
});
expect(windowManager.getWindowRequests(123)).toHaveLength(2);
// Show overlay
await windowManager.showOverlay(123);
expect(windowManager.isOverlayVisible(123)).toBe(true);
// Close window
await windowManager.closeWindow(123);
expect(windowManager.getWindow(123)).toBeUndefined();
});
it('should handle multiple windows independently', async () => {
// Register two windows
await windowManager.registerWindow({
id: 123,
tabId: 456,
url: 'https://example1.com',
showOverlay: false,
});
await windowManager.registerWindow({
id: 789,
tabId: 1011,
url: 'https://example2.com',
showOverlay: false,
});
// Add requests to different windows
windowManager.addRequest(123, {
id: 'req-1',
method: 'GET',
url: 'https://example1.com/api',
timestamp: Date.now(),
tabId: 456,
});
windowManager.addRequest(789, {
id: 'req-2',
method: 'POST',
url: 'https://example2.com/api',
timestamp: Date.now(),
tabId: 1011,
});
// Each window should have its own requests
expect(windowManager.getWindowRequests(123)).toHaveLength(1);
expect(windowManager.getWindowRequests(789)).toHaveLength(1);
expect(windowManager.getWindowRequests(123)[0].id).toBe('req-1');
expect(windowManager.getWindowRequests(789)[0].id).toBe('req-2');
// Show overlay on one window
await windowManager.showOverlay(123);
expect(windowManager.isOverlayVisible(123)).toBe(true);
expect(windowManager.isOverlayVisible(789)).toBe(false);
});
});
});

View File

@@ -1,266 +0,0 @@
/**
* Tests for Content Script Client API (window.tlsn)
*
* Tests the public API exposed to web pages for interacting
* with the TLSN extension.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
describe('Content Script Client API', () => {
let postMessageSpy: any;
beforeEach(() => {
// Mock window.postMessage
postMessageSpy = vi.spyOn(window, 'postMessage');
});
describe('window.tlsn.open()', () => {
// Simulate the injected script's ExtensionAPI class
class ExtensionAPI {
async open(
url: string,
options?: {
width?: number;
height?: number;
showOverlay?: boolean;
},
): Promise<void> {
if (!url || typeof url !== 'string') {
throw new Error('URL must be a non-empty string');
}
// Validate URL format
try {
new URL(url);
} catch (error) {
throw new Error(`Invalid URL: ${url}`);
}
// Send message to content script
window.postMessage(
{
type: 'TLSN_OPEN_WINDOW',
payload: {
url,
width: options?.width,
height: options?.height,
showOverlay: options?.showOverlay,
},
},
window.location.origin,
);
}
}
let tlsn: ExtensionAPI;
beforeEach(() => {
tlsn = new ExtensionAPI();
});
it('should post message with valid URL', async () => {
await tlsn.open('https://example.com');
expect(postMessageSpy).toHaveBeenCalledWith(
{
type: 'TLSN_OPEN_WINDOW',
payload: {
url: 'https://example.com',
width: undefined,
height: undefined,
showOverlay: undefined,
},
},
window.location.origin,
);
});
it('should include width and height options', async () => {
await tlsn.open('https://example.com', {
width: 1200,
height: 800,
});
expect(postMessageSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'TLSN_OPEN_WINDOW',
payload: expect.objectContaining({
url: 'https://example.com',
width: 1200,
height: 800,
}),
}),
window.location.origin,
);
});
it('should include showOverlay option', async () => {
await tlsn.open('https://example.com', {
showOverlay: false,
});
expect(postMessageSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'TLSN_OPEN_WINDOW',
payload: expect.objectContaining({
url: 'https://example.com',
showOverlay: false,
}),
}),
window.location.origin,
);
});
it('should reject empty URL', async () => {
await expect(tlsn.open('')).rejects.toThrow(
'URL must be a non-empty string',
);
});
it('should reject non-string URL', async () => {
await expect(tlsn.open(null as any)).rejects.toThrow(
'URL must be a non-empty string',
);
await expect(tlsn.open(undefined as any)).rejects.toThrow(
'URL must be a non-empty string',
);
await expect(tlsn.open(123 as any)).rejects.toThrow(
'URL must be a non-empty string',
);
});
it('should reject invalid URL format', async () => {
await expect(tlsn.open('not-a-url')).rejects.toThrow('Invalid URL');
await expect(tlsn.open('ftp://example.com')).resolves.not.toThrow(); // Valid URL, will be validated by background
});
it('should accept http URLs', async () => {
await expect(tlsn.open('http://example.com')).resolves.not.toThrow();
});
it('should accept https URLs', async () => {
await expect(tlsn.open('https://example.com')).resolves.not.toThrow();
});
it('should accept URLs with paths', async () => {
await expect(
tlsn.open('https://example.com/path/to/page'),
).resolves.not.toThrow();
});
it('should accept URLs with query parameters', async () => {
await expect(
tlsn.open('https://example.com/search?q=test&lang=en'),
).resolves.not.toThrow();
});
it('should accept URLs with fragments', async () => {
await expect(
tlsn.open('https://example.com/page#section'),
).resolves.not.toThrow();
});
it('should post message to correct origin', async () => {
await tlsn.open('https://example.com');
expect(postMessageSpy).toHaveBeenCalledWith(
expect.any(Object),
window.location.origin,
);
});
});
describe('Message Type Constants', () => {
it('should define all required message types', async () => {
const {
OPEN_WINDOW,
WINDOW_OPENED,
WINDOW_ERROR,
SHOW_TLSN_OVERLAY,
UPDATE_TLSN_REQUESTS,
HIDE_TLSN_OVERLAY,
} = await import('../../src/constants/messages');
expect(OPEN_WINDOW).toBe('OPEN_WINDOW');
expect(WINDOW_OPENED).toBe('WINDOW_OPENED');
expect(WINDOW_ERROR).toBe('WINDOW_ERROR');
expect(SHOW_TLSN_OVERLAY).toBe('SHOW_TLSN_OVERLAY');
expect(UPDATE_TLSN_REQUESTS).toBe('UPDATE_TLSN_REQUESTS');
expect(HIDE_TLSN_OVERLAY).toBe('HIDE_TLSN_OVERLAY');
});
it('should export type definitions', async () => {
const messages = await import('../../src/constants/messages');
// Check that types are exported (TypeScript compilation will verify this)
expect(messages).toHaveProperty('OPEN_WINDOW');
expect(messages).toHaveProperty('WINDOW_OPENED');
expect(messages).toHaveProperty('WINDOW_ERROR');
});
});
describe('Content Script Message Forwarding', () => {
it('should forward TLSN_OPEN_WINDOW to background as OPEN_WINDOW', () => {
// This test verifies the message transformation logic
const pageMessage = {
type: 'TLSN_OPEN_WINDOW',
payload: {
url: 'https://example.com',
width: 1000,
height: 800,
showOverlay: true,
},
};
// Expected background message format
const expectedBackgroundMessage = {
type: 'OPEN_WINDOW',
url: 'https://example.com',
width: 1000,
height: 800,
showOverlay: true,
};
// Verify transformation logic
expect(pageMessage.payload).toEqual({
url: expectedBackgroundMessage.url,
width: expectedBackgroundMessage.width,
height: expectedBackgroundMessage.height,
showOverlay: expectedBackgroundMessage.showOverlay,
});
});
it('should handle optional parameters correctly', () => {
const pageMessage = {
type: 'TLSN_OPEN_WINDOW',
payload: {
url: 'https://example.com',
},
};
// width, height, showOverlay should be undefined
expect(pageMessage.payload.width).toBeUndefined();
expect(pageMessage.payload.height).toBeUndefined();
expect((pageMessage.payload as any).showOverlay).toBeUndefined();
});
});
describe('Origin Validation', () => {
it('should only accept messages from same origin', () => {
const currentOrigin = window.location.origin;
// Valid origins
expect(currentOrigin).toBe(window.location.origin);
// Example of what content script should check
const isValidOrigin = (eventOrigin: string) => {
return eventOrigin === window.location.origin;
};
expect(isValidOrigin(currentOrigin)).toBe(true);
expect(isValidOrigin('https://evil.com')).toBe(false);
expect(isValidOrigin('http://different.com')).toBe(false);
});
});
});

View File

@@ -1,72 +0,0 @@
/**
* Example test file demonstrating Vitest setup
*/
import { describe, it, expect } from 'vitest';
describe('Example Test Suite', () => {
it('should perform basic arithmetic', () => {
expect(1 + 1).toBe(2);
});
it('should handle string operations', () => {
const greeting = 'Hello, TLSNotary!';
expect(greeting).toContain('TLSNotary');
expect(greeting.length).toBeGreaterThan(0);
});
it('should work with arrays', () => {
const arr = [1, 2, 3, 4, 5];
expect(arr).toHaveLength(5);
expect(arr).toContain(3);
});
it('should handle async operations', async () => {
const asyncFunc = async () => {
return new Promise((resolve) => {
setTimeout(() => resolve('done'), 100);
});
};
const result = await asyncFunc();
expect(result).toBe('done');
});
});
describe('URL Validation Example', () => {
it('should validate http/https URLs', () => {
const isValidUrl = (url: string): boolean => {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
};
// Valid URLs
expect(isValidUrl('https://example.com')).toBe(true);
expect(isValidUrl('http://test.org')).toBe(true);
// Invalid URLs
expect(isValidUrl('javascript:alert(1)')).toBe(false);
expect(isValidUrl('not-a-url')).toBe(false);
expect(isValidUrl('file:///etc/passwd')).toBe(false);
});
});
describe('Browser API Mocking Example', () => {
it('should have chrome global available', () => {
expect(globalThis.chrome).toBeDefined();
expect(globalThis.chrome.runtime).toBeDefined();
});
it('should mock webextension-polyfill', async () => {
// This demonstrates that our setup.ts mock is working
const browser = await import('webextension-polyfill');
expect(browser.default.runtime.id).toBe('test-extension-id');
expect(browser.default.runtime.sendMessage).toBeDefined();
expect(browser.default.windows.create).toBeDefined();
});
});

View File

@@ -1,128 +0,0 @@
/* eslint-env node */
/* global useHeaders, createProver, sendRequest, transcript, subtractRanges, mapStringToRange, reveal, useEffect, openWindow, div, button, Buffer */
const config = {
name: 'X Profile Prover',
description: 'This plugin will prove your X.com profile.',
};
async function prove() {
const [header] = useHeaders((headers) =>
headers.filter((header) =>
header.url.includes('https://api.x.com/1.1/account/settings.json'),
),
);
const headers = {
cookie: header.requestHeaders.find((header) => header.name === 'Cookie')
?.value,
'x-csrf-token': header.requestHeaders.find(
(header) => header.name === 'x-csrf-token',
)?.value,
'x-client-transaction-id': header.requestHeaders.find(
(header) => header.name === 'x-client-transaction-id',
)?.value,
Host: 'api.x.com',
authorization: header.requestHeaders.find(
(header) => header.name === 'authorization',
)?.value,
'Accept-Encoding': 'identity',
Connection: 'close',
};
console.log('headers', headers);
const proverId = await createProver(
'api.x.com',
'https://demo.tlsnotary.org',
);
console.log('prover', proverId);
await sendRequest(proverId, 'wss://notary.pse.dev/proxy?token=api.x.com', {
url: 'https://api.x.com/1.1/account/settings.json',
method: 'GET',
headers: headers,
});
const { sent, recv } = await transcript(proverId);
const commit = {
sent: subtractRanges(
{ start: 0, end: sent.length },
mapStringToRange(
[
`x-csrf-token: ${headers['x-csrf-token']}`,
`x-client-transaction-id: ${headers['x-client-transaction-id']}`,
`cookie: ${headers['cookie']}`,
`authorization: ${headers.authorization}`,
],
Buffer.from(sent).toString('utf-8'),
),
),
recv: [{ start: 0, end: recv.length }],
};
console.log('commit', commit);
await reveal(proverId, commit);
}
function main() {
const [header] = useHeaders((headers) =>
headers.filter((header) =>
header.url.includes('https://api.x.com/1.1/account/settings.json'),
),
);
useEffect(() => {
openWindow('https://x.com');
}, []);
return div(
{
style: {
position: 'fixed',
bottom: '0',
right: '8px',
width: '240px',
height: '240px',
borderRadius: '4px 4px 0 0',
backgroundColor: '#b8b8b8',
zIndex: '999999',
fontSize: '16px',
color: '#0f0f0f',
border: '1px solid #e2e2e2',
borderBottom: 'none',
padding: '8px',
fontFamily: 'sans-serif',
},
},
[
div(
{
style: {
fontWeight: 'bold',
color: header ? 'green' : 'red',
},
},
[header ? 'Profile detected!' : 'No profile detected'],
),
header
? button(
{
style: {
color: 'black',
backgroundColor: 'white',
},
onclick: 'prove',
},
['Prove'],
)
: div({ style: { color: 'black' } }, ['Please login to x.com']),
],
);
}
export default {
main,
prove,
config,
};

View File

@@ -1,137 +0,0 @@
/**
* Vitest test setup file
*
* This file runs before all tests to set up the testing environment,
* including mocking browser APIs for Chrome extension testing.
*/
import { vi, beforeEach } from 'vitest';
// Create a mock chrome object with runtime.id (required for webextension-polyfill)
const chromeMock = {
runtime: {
id: 'test-extension-id',
sendMessage: vi.fn(),
onMessage: {
addListener: vi.fn(),
removeListener: vi.fn(),
hasListener: vi.fn(),
},
getURL: vi.fn((path: string) => `chrome-extension://test-id/${path}`),
onInstalled: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
getContexts: vi.fn(),
},
windows: {
create: vi.fn(),
get: vi.fn(),
remove: vi.fn(),
update: vi.fn(),
onRemoved: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
},
tabs: {
sendMessage: vi.fn(),
query: vi.fn(),
get: vi.fn(),
onUpdated: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
},
webRequest: {
onBeforeRequest: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
onBeforeSendHeaders: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
},
storage: {
local: {
get: vi.fn(),
set: vi.fn(),
remove: vi.fn(),
clear: vi.fn(),
},
sync: {
get: vi.fn(),
set: vi.fn(),
remove: vi.fn(),
clear: vi.fn(),
},
},
offscreen: {
createDocument: vi.fn(),
},
};
// Set up chrome global for webextension-polyfill
globalThis.chrome = chromeMock as any;
// Mock webextension-polyfill
vi.mock('webextension-polyfill', () => ({
default: {
runtime: {
id: 'test-extension-id',
sendMessage: vi.fn(),
onMessage: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
getURL: vi.fn((path: string) => `chrome-extension://test-id/${path}`),
},
windows: {
create: vi.fn(),
get: vi.fn(),
remove: vi.fn(),
onRemoved: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
},
tabs: {
sendMessage: vi.fn(),
onUpdated: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
query: vi.fn(),
},
webRequest: {
onBeforeRequest: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
onBeforeSendHeaders: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
},
storage: {
local: {
get: vi.fn(),
set: vi.fn(),
remove: vi.fn(),
clear: vi.fn(),
},
sync: {
get: vi.fn(),
set: vi.fn(),
remove: vi.fn(),
clear: vi.fn(),
},
},
},
}));
// Reset mocks before each test
beforeEach(() => {
vi.clearAllMocks();
});

View File

@@ -1,300 +0,0 @@
/**
* Type safety tests for WindowManager types
*
* These tests verify that the type definitions are correctly structured
* and can be used as expected throughout the codebase.
*/
import { describe, it, expect } from 'vitest';
import type {
WindowRegistration,
InterceptedRequest,
ManagedWindow,
IWindowManager,
} from '../../src/types/window-manager';
describe('WindowManager Type Definitions', () => {
describe('WindowRegistration', () => {
it('should accept valid window registration config', () => {
const config: WindowRegistration = {
id: 123,
tabId: 456,
url: 'https://example.com',
showOverlay: true,
};
expect(config.id).toBe(123);
expect(config.tabId).toBe(456);
expect(config.url).toBe('https://example.com');
expect(config.showOverlay).toBe(true);
});
it('should allow showOverlay to be optional', () => {
const config: WindowRegistration = {
id: 123,
tabId: 456,
url: 'https://example.com',
};
expect(config.showOverlay).toBeUndefined();
});
it('should enforce required fields', () => {
// @ts-expect-error - missing required fields
const invalid: WindowRegistration = {
id: 123,
};
expect(invalid).toBeDefined();
});
});
describe('InterceptedRequest', () => {
it('should accept valid intercepted request', () => {
const request: InterceptedRequest = {
id: 'req-123',
method: 'GET',
url: 'https://api.example.com/data',
timestamp: Date.now(),
tabId: 456,
};
expect(request.method).toBe('GET');
expect(request.url).toContain('api.example.com');
expect(request.timestamp).toBeGreaterThan(0);
});
it('should support different HTTP methods', () => {
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
methods.forEach((method) => {
const request: InterceptedRequest = {
id: `req-${method}`,
method,
url: 'https://example.com',
timestamp: Date.now(),
tabId: 456,
};
expect(request.method).toBe(method);
});
});
});
describe('ManagedWindow', () => {
it('should accept valid managed window', () => {
const window: ManagedWindow = {
id: 123,
uuid: '550e8400-e29b-41d4-a716-446655440000',
tabId: 456,
url: 'https://example.com',
createdAt: new Date(),
requests: [],
overlayVisible: false,
showOverlayWhenReady: true,
};
expect(window.id).toBe(123);
expect(window.uuid).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
expect(window.requests).toEqual([]);
expect(window.overlayVisible).toBe(false);
expect(window.showOverlayWhenReady).toBe(true);
});
it('should allow requests array to contain InterceptedRequests', () => {
const window: ManagedWindow = {
id: 123,
uuid: '550e8400-e29b-41d4-a716-446655440000',
tabId: 456,
url: 'https://example.com',
createdAt: new Date(),
requests: [
{
id: 'req-1',
method: 'GET',
url: 'https://example.com/api',
timestamp: Date.now(),
tabId: 456,
},
],
overlayVisible: true,
showOverlayWhenReady: false,
};
expect(window.requests).toHaveLength(1);
expect(window.requests[0].method).toBe('GET');
});
});
describe('IWindowManager', () => {
it('should define all required methods', () => {
// This test verifies that the interface shape is correct
// by creating a mock implementation
const mockWindowManager: IWindowManager = {
registerWindow: async (config: WindowRegistration) => ({
id: config.id,
uuid: 'test-uuid',
tabId: config.tabId,
url: config.url,
createdAt: new Date(),
requests: [],
overlayVisible: false,
showOverlayWhenReady: config.showOverlay !== false,
}),
closeWindow: async (windowId: number) => {
/* no-op mock */
},
getWindow: (windowId: number) => undefined,
getWindowByTabId: (tabId: number) => undefined,
getAllWindows: () => new Map(),
addRequest: (windowId: number, request: InterceptedRequest) => {
/* no-op mock */
},
getWindowRequests: (windowId: number) => [],
showOverlay: async (windowId: number) => {
/* no-op mock */
},
hideOverlay: async (windowId: number) => {
/* no-op mock */
},
isOverlayVisible: (windowId: number) => false,
cleanupInvalidWindows: async () => {
/* no-op mock */
},
};
expect(mockWindowManager.registerWindow).toBeDefined();
expect(mockWindowManager.closeWindow).toBeDefined();
expect(mockWindowManager.getWindow).toBeDefined();
expect(mockWindowManager.getWindowByTabId).toBeDefined();
expect(mockWindowManager.getAllWindows).toBeDefined();
expect(mockWindowManager.addRequest).toBeDefined();
expect(mockWindowManager.getWindowRequests).toBeDefined();
expect(mockWindowManager.showOverlay).toBeDefined();
expect(mockWindowManager.hideOverlay).toBeDefined();
expect(mockWindowManager.isOverlayVisible).toBeDefined();
expect(mockWindowManager.cleanupInvalidWindows).toBeDefined();
});
it('should have correct method signatures', async () => {
const mockWindowManager: IWindowManager = {
registerWindow: async (config) => ({
id: config.id,
uuid: 'test-uuid',
tabId: config.tabId,
url: config.url,
createdAt: new Date(),
requests: [],
overlayVisible: false,
showOverlayWhenReady: config.showOverlay !== false,
}),
closeWindow: async (windowId) => {
/* no-op mock */
},
getWindow: (windowId) => undefined,
getWindowByTabId: (tabId) => undefined,
getAllWindows: () => new Map(),
addRequest: (windowId, request) => {
/* no-op mock */
},
getWindowRequests: (windowId) => [],
showOverlay: async (windowId) => {
/* no-op mock */
},
hideOverlay: async (windowId) => {
/* no-op mock */
},
isOverlayVisible: (windowId) => false,
cleanupInvalidWindows: async () => {
/* no-op mock */
},
};
// Test registerWindow returns Promise<ManagedWindow>
const result = await mockWindowManager.registerWindow({
id: 123,
tabId: 456,
url: 'https://example.com',
});
expect(result).toHaveProperty('id');
expect(result).toHaveProperty('uuid');
expect(result).toHaveProperty('tabId');
expect(result).toHaveProperty('url');
expect(result).toHaveProperty('createdAt');
expect(result).toHaveProperty('requests');
expect(result).toHaveProperty('overlayVisible');
expect(result).toHaveProperty('showOverlayWhenReady');
// Test getWindowRequests returns array
const requests = mockWindowManager.getWindowRequests(123);
expect(Array.isArray(requests)).toBe(true);
// Test isOverlayVisible returns boolean
const visible = mockWindowManager.isOverlayVisible(123);
expect(typeof visible).toBe('boolean');
});
});
describe('Type Integration', () => {
it('should allow requests to be added to windows', () => {
const window: ManagedWindow = {
id: 123,
uuid: 'test-uuid',
tabId: 456,
url: 'https://example.com',
createdAt: new Date(),
requests: [],
overlayVisible: false,
showOverlayWhenReady: false,
};
const request: InterceptedRequest = {
id: 'req-1',
method: 'POST',
url: 'https://example.com/api',
timestamp: Date.now(),
tabId: 456,
};
window.requests.push(request);
expect(window.requests).toHaveLength(1);
expect(window.requests[0]).toBe(request);
});
it('should support multiple requests in a window', () => {
const window: ManagedWindow = {
id: 123,
uuid: 'test-uuid',
tabId: 456,
url: 'https://example.com',
createdAt: new Date(),
requests: [
{
id: 'req-1',
method: 'GET',
url: 'https://example.com/page',
timestamp: Date.now(),
tabId: 456,
},
{
id: 'req-2',
method: 'POST',
url: 'https://example.com/api',
timestamp: Date.now() + 1000,
tabId: 456,
},
],
overlayVisible: true,
showOverlayWhenReady: false,
};
expect(window.requests).toHaveLength(2);
expect(window.requests[0].method).toBe('GET');
expect(window.requests[1].method).toBe('POST');
});
});
});

View File

@@ -1,326 +0,0 @@
/**
* Tests for URL validation utilities
*
* Ensures robust URL validation for security and reliability.
*/
import { describe, it, expect } from 'vitest';
import {
validateUrl,
sanitizeUrl,
isHttpUrl,
getUrlErrorMessage,
} from '../../src/utils/url-validator';
describe('URL Validator', () => {
describe('validateUrl', () => {
describe('Valid URLs', () => {
it('should accept valid HTTP URL', () => {
const result = validateUrl('http://example.com');
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
expect(result.url).toBeDefined();
expect(result.url?.protocol).toBe('http:');
});
it('should accept valid HTTPS URL', () => {
const result = validateUrl('https://example.com');
expect(result.valid).toBe(true);
expect(result.url?.protocol).toBe('https:');
});
it('should accept URL with path', () => {
const result = validateUrl('https://example.com/path/to/page');
expect(result.valid).toBe(true);
expect(result.url?.pathname).toBe('/path/to/page');
});
it('should accept URL with query parameters', () => {
const result = validateUrl('https://example.com/search?q=test&lang=en');
expect(result.valid).toBe(true);
expect(result.url?.search).toBe('?q=test&lang=en');
});
it('should accept URL with fragment', () => {
const result = validateUrl('https://example.com/page#section');
expect(result.valid).toBe(true);
expect(result.url?.hash).toBe('#section');
});
it('should accept URL with port', () => {
const result = validateUrl('https://example.com:8080/path');
expect(result.valid).toBe(true);
expect(result.url?.port).toBe('8080');
});
it('should accept URL with subdomain', () => {
const result = validateUrl('https://api.example.com');
expect(result.valid).toBe(true);
expect(result.url?.hostname).toBe('api.example.com');
});
});
describe('Invalid URLs - Empty/Null', () => {
it('should reject empty string', () => {
const result = validateUrl('');
expect(result.valid).toBe(false);
expect(result.error).toContain('non-empty string');
});
it('should reject whitespace only', () => {
const result = validateUrl(' ');
expect(result.valid).toBe(false);
expect(result.error).toContain('whitespace');
});
it('should reject null', () => {
const result = validateUrl(null);
expect(result.valid).toBe(false);
expect(result.error).toContain('non-empty string');
});
it('should reject undefined', () => {
const result = validateUrl(undefined);
expect(result.valid).toBe(false);
expect(result.error).toContain('non-empty string');
});
it('should reject number', () => {
const result = validateUrl(123);
expect(result.valid).toBe(false);
expect(result.error).toContain('non-empty string');
});
it('should reject object', () => {
const result = validateUrl({ url: 'https://example.com' });
expect(result.valid).toBe(false);
expect(result.error).toContain('non-empty string');
});
});
describe('Invalid URLs - Malformed', () => {
it('should reject invalid URL format', () => {
const result = validateUrl('not-a-url');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid URL format');
});
it('should reject URL without protocol', () => {
const result = validateUrl('example.com');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid URL format');
});
it('should reject URL without hostname', () => {
const result = validateUrl('https://');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid URL format');
});
});
describe('Invalid URLs - Dangerous Protocols', () => {
it('should reject javascript: protocol', () => {
const result = validateUrl('javascript:alert(1)');
expect(result.valid).toBe(false);
expect(result.error).toContain('Dangerous protocol');
expect(result.error).toContain('javascript:');
});
it('should reject data: protocol', () => {
const result = validateUrl('data:text/html,<h1>Test</h1>');
expect(result.valid).toBe(false);
expect(result.error).toContain('Dangerous protocol');
expect(result.error).toContain('data:');
});
it('should reject file: protocol', () => {
const result = validateUrl('file:///etc/passwd');
expect(result.valid).toBe(false);
expect(result.error).toContain('Dangerous protocol');
expect(result.error).toContain('file:');
});
it('should reject blob: protocol', () => {
const result = validateUrl('blob:https://example.com/uuid');
expect(result.valid).toBe(false);
expect(result.error).toContain('Dangerous protocol');
expect(result.error).toContain('blob:');
});
it('should reject about: protocol', () => {
const result = validateUrl('about:blank');
expect(result.valid).toBe(false);
expect(result.error).toContain('Dangerous protocol');
expect(result.error).toContain('about:');
});
});
describe('Invalid URLs - Invalid Protocols', () => {
it('should reject FTP protocol', () => {
const result = validateUrl('ftp://example.com');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid protocol');
expect(result.error).toContain('ftp:');
});
it('should reject ws: protocol', () => {
const result = validateUrl('ws://example.com');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid protocol');
});
it('should reject custom protocol', () => {
const result = validateUrl('custom://example.com');
expect(result.valid).toBe(false);
expect(result.error).toContain('Invalid protocol');
});
});
});
describe('sanitizeUrl', () => {
it('should sanitize valid URL', () => {
const sanitized = sanitizeUrl(' https://example.com ');
expect(sanitized).toBe('https://example.com/');
});
it('should preserve query parameters', () => {
const sanitized = sanitizeUrl('https://example.com/search?q=test');
expect(sanitized).toContain('?q=test');
});
it('should preserve fragments', () => {
const sanitized = sanitizeUrl('https://example.com#section');
expect(sanitized).toContain('#section');
});
it('should return null for invalid URL', () => {
const sanitized = sanitizeUrl('not-a-url');
expect(sanitized).toBeNull();
});
it('should return null for dangerous protocol', () => {
const sanitized = sanitizeUrl('javascript:alert(1)');
expect(sanitized).toBeNull();
});
});
describe('isHttpUrl', () => {
it('should return true for HTTP URL', () => {
expect(isHttpUrl('http://example.com')).toBe(true);
});
it('should return true for HTTPS URL', () => {
expect(isHttpUrl('https://example.com')).toBe(true);
});
it('should return false for FTP URL', () => {
expect(isHttpUrl('ftp://example.com')).toBe(false);
});
it('should return false for javascript: URL', () => {
expect(isHttpUrl('javascript:alert(1)')).toBe(false);
});
it('should return false for invalid URL', () => {
expect(isHttpUrl('not-a-url')).toBe(false);
});
it('should return false for empty string', () => {
expect(isHttpUrl('')).toBe(false);
});
});
describe('getUrlErrorMessage', () => {
it('should return valid message for valid URL', () => {
const message = getUrlErrorMessage('https://example.com');
expect(message).toBe('URL is valid');
});
it('should return error message for invalid URL', () => {
const message = getUrlErrorMessage('javascript:alert(1)');
expect(message).toContain('Dangerous protocol');
});
it('should return error message for malformed URL', () => {
const message = getUrlErrorMessage('not-a-url');
expect(message).toContain('Invalid URL format');
});
it('should return error message for empty URL', () => {
const message = getUrlErrorMessage('');
expect(message).toContain('non-empty string');
});
});
describe('Edge Cases', () => {
it('should handle URL with Unicode characters', () => {
const result = validateUrl('https://例え.com');
expect(result.valid).toBe(true);
});
it('should handle URL with encoded characters', () => {
const result = validateUrl('https://example.com/path%20with%20spaces');
expect(result.valid).toBe(true);
});
it('should handle localhost', () => {
const result = validateUrl('http://localhost:3000');
expect(result.valid).toBe(true);
});
it('should handle IP address', () => {
const result = validateUrl('http://192.168.1.1');
expect(result.valid).toBe(true);
});
it('should handle IPv6 address', () => {
const result = validateUrl('http://[::1]:8080');
expect(result.valid).toBe(true);
});
it('should trim whitespace from URL', () => {
const result = validateUrl(' https://example.com ');
expect(result.valid).toBe(true);
expect(result.url?.href).toBe('https://example.com/');
});
});
});

View File

@@ -1,91 +0,0 @@
/**
* Tests for UUID generation functionality
*
* Verifies that the uuid package is correctly installed and
* generates valid UUIDs for WindowManager use.
*/
import { describe, it, expect } from 'vitest';
import {
v4 as uuidv4,
validate as uuidValidate,
version as uuidVersion,
} from 'uuid';
describe('UUID Generation', () => {
it('should generate valid UUID v4', () => {
const uuid = uuidv4();
expect(uuid).toBeDefined();
expect(typeof uuid).toBe('string');
expect(uuid).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
it('should generate unique UUIDs', () => {
const uuid1 = uuidv4();
const uuid2 = uuidv4();
const uuid3 = uuidv4();
expect(uuid1).not.toBe(uuid2);
expect(uuid2).not.toBe(uuid3);
expect(uuid1).not.toBe(uuid3);
});
it('should validate correct UUIDs', () => {
const uuid = uuidv4();
expect(uuidValidate(uuid)).toBe(true);
});
it('should reject invalid UUIDs', () => {
expect(uuidValidate('not-a-uuid')).toBe(false);
expect(uuidValidate('12345')).toBe(false);
expect(uuidValidate('')).toBe(false);
});
it('should identify UUID version', () => {
const uuid = uuidv4();
expect(uuidVersion(uuid)).toBe(4);
});
it('should generate UUIDs suitable for WindowManager', () => {
// Simulate what WindowManager will do
const windowUUIDs = new Set<string>();
// Generate 100 UUIDs
for (let i = 0; i < 100; i++) {
const uuid = uuidv4();
// Verify it's valid
expect(uuidValidate(uuid)).toBe(true);
// Verify it's unique
expect(windowUUIDs.has(uuid)).toBe(false);
windowUUIDs.add(uuid);
}
expect(windowUUIDs.size).toBe(100);
});
it('should work with ManagedWindow type structure', () => {
interface ManagedWindowSimple {
id: number;
uuid: string;
url: string;
}
const window: ManagedWindowSimple = {
id: 123,
uuid: uuidv4(),
url: 'https://example.com',
};
expect(window.uuid).toBeDefined();
expect(uuidValidate(window.uuid)).toBe(true);
expect(window.uuid.length).toBe(36); // UUID v4 format with dashes
});
});

View File

@@ -1,36 +0,0 @@
/**
* Webpack plugin to resolve node: protocol imports to browser polyfills
* This plugin intercepts imports like 'node:fs', 'node:path', etc. at the
* NormalModuleFactory level and redirects them to browser-compatible alternatives.
*/
class NodeProtocolResolvePlugin {
constructor(aliases) {
this.aliases = aliases || {};
}
apply(compiler) {
compiler.hooks.normalModuleFactory.tap(
'NodeProtocolResolvePlugin',
(nmf) => {
nmf.hooks.beforeResolve.tap(
'NodeProtocolResolvePlugin',
(resolveData) => {
const request = resolveData.request;
if (request && request.startsWith('node:')) {
const aliasTarget = this.aliases[request];
if (aliasTarget) {
resolveData.request = aliasTarget;
}
}
// Don't return anything - just modify resolveData in place
},
);
},
);
}
}
module.exports = NodeProtocolResolvePlugin;

View File

@@ -1,49 +0,0 @@
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'production';
process.env.NODE_ENV = 'production';
process.env.ASSET_PATH = '/';
var webpack = require('webpack'),
path = require('path'),
fs = require('fs'),
config = require('../webpack.config'),
ZipPlugin = require('zip-webpack-plugin');
delete config.chromeExtensionBoilerplate;
config.mode = 'production';
var packageInfo = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
config.plugins = (config.plugins || []).concat(
new ZipPlugin({
filename: `${packageInfo.name}-${packageInfo.version}.zip`,
path: path.join(__dirname, '../', 'zip'),
}),
);
webpack(config, function (err, stats) {
if (err) {
console.error('Webpack error:', err);
process.exit(1);
}
if (stats.hasErrors()) {
console.error('Build failed with errors:');
const info = stats.toJson();
console.error(info.errors.map((e) => e.message).join('\n\n'));
process.exit(1);
}
if (stats.hasWarnings()) {
console.warn('Build completed with warnings:');
const info = stats.toJson();
console.warn(info.warnings.map((w) => w.message).join('\n\n'));
}
console.log('Build completed successfully!');
console.log(`Output: ${path.join(__dirname, '../', 'build')}`);
console.log(
`Zip: ${path.join(__dirname, '../', 'zip', `${packageInfo.name}-${packageInfo.version}.zip`)}`,
);
});

View File

@@ -1,40 +0,0 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
// Environment
environment: 'happy-dom',
// Setup files
setupFiles: ['./tests/setup.ts'],
// Globals (optional - enables describe, it, expect without imports)
globals: true,
// Coverage configuration
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'build/',
'dist/',
'**/*.d.ts',
'**/*.config.*',
'**/mockData/',
],
},
// Test patterns
include: ['tests/**/*.{test,spec}.{js,ts,tsx}'],
exclude: ['node_modules', 'build', 'dist'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});

View File

@@ -1,35 +0,0 @@
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"project": "./tsconfig.eslint.json"
},
"plugins": ["@typescript-eslint", "prettier"],
"rules": {
"no-console": "warn",
"no-debugger": "error",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"prettier/prettier": "error"
},
"ignorePatterns": ["dist", "node_modules", "coverage", "*.config.ts", "*.config.js"]
}

View File

@@ -1,28 +0,0 @@
# Dependencies
node_modules/
# Build output
dist/
*.tsbuildinfo
# Test coverage
coverage/
.nyc_output/
# IDE
.vscode/
.idea/
*.swp
*.swo
.DS_Store
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment
.env
.env.local
.env.*.local

View File

@@ -1,9 +0,0 @@
dist
node_modules
coverage
*.min.js
*.umd.js
.nyc_output
package-lock.json
pnpm-lock.yaml
yarn.lock

View File

@@ -1,12 +0,0 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf",
"bracketSpacing": true,
"bracketSameLine": false
}

View File

@@ -1,65 +0,0 @@
# @tlsn/plugin-sdk
SDK for developing and running TLSN WebAssembly plugins using the Component Model.
## Overview
This package provides:
- **Host Environment**: Runtime for executing WASM Component Model plugins
- **Development Tools**: Utilities for building and testing plugins
- **Plugin Demos**: Example plugins demonstrating SDK capabilities
- **Type Definitions**: TypeScript types for plugin development
## Structure
```
plugin-sdk/
├── src/ # SDK source code
│ ├── host/ # Plugin host runtime
│ ├── builder/ # Build utilities
│ └── types/ # Type definitions
├── examples/ # Example plugins and demos
│ ├── hello-world/ # Basic plugin example
│ └── http-logger/ # HTTP request logging plugin
└── dist/ # Built SDK (generated)
```
## Usage
### Installation
```bash
npm install @tlsn/plugin-sdk
```
### Creating a Plugin Host
```typescript
import { PluginHost } from '@tlsn/plugin-sdk';
const host = new PluginHost({
console: {
log: (msg) => console.log('[Plugin]', msg),
},
});
const plugin = await host.loadPlugin({
id: 'my-plugin',
url: 'path/to/plugin.wasm',
});
await plugin.exports.run();
```
### Developing a Plugin
See `examples/` directory for complete plugin examples.
## Development
_Implementation in progress_
## License
MIT

View File

@@ -1,71 +0,0 @@
{
"name": "@tlsn/plugin-sdk",
"version": "0.1.0",
"description": "SDK for developing and running TLSN WebAssembly plugins",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"scripts": {
"build": "vite build && tsc --emitDeclarationOnly",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"test:browser": "vitest --config vitest.browser.config.ts",
"test:browser:ui": "vitest --config vitest.browser.config.ts --ui",
"test:browser:headed": "vitest --config vitest.browser.config.ts --browser.headless=false",
"lint": "npm run lint:eslint && npm run lint:prettier && npm run lint:typescript",
"lint:eslint": "eslint . --ext .ts,.tsx",
"lint:prettier": "prettier --check .",
"lint:typescript": "tsc --noEmit",
"lint:fix": "eslint . --ext .ts,.tsx --fix && prettier --write .",
"prepublishOnly": "npm run build"
},
"keywords": [
"tlsn",
"wasm",
"plugin",
"component-model",
"sdk"
],
"author": "TLSN Team",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/tlsnotary/tlsn-extension.git",
"directory": "packages/plugin-sdk"
},
"files": [
"dist",
"src",
"examples",
"README.md"
],
"devDependencies": {
"@types/node": "^20.19.18",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitest/browser": "^3.2.4",
"@vitest/ui": "^3.2.4",
"buffer": "^6.0.3",
"c8": "^10.1.3",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-prettier": "^5.5.4",
"happy-dom": "^19.0.2",
"path-browserify": "^1.0.1",
"playwright": "^1.55.1",
"prettier": "^3.6.2",
"process": "^0.11.10",
"stream-browserify": "^3.0.0",
"tslib": "^2.8.1",
"typescript": "^4.9.5",
"vite": "^7.1.7",
"vite-plugin-dts": "^4.5.4",
"vitest": "^3.2.4"
},
"dependencies": {
"@jitl/quickjs-ng-wasmfile-release-sync": "^0.31.0",
"@sebastianwessel/quickjs": "^3.0.0",
"quickjs-emscripten": "^0.31.0"
}
}

View File

@@ -1,2 +0,0 @@
// Empty module for browser compatibility
export default {};

View File

@@ -1,89 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock the Host class for browser environment
class MockHost {
private capabilities: Map<string, (...args: any[]) => any> = new Map();
addCapability(name: string, fn: (...args: any[]) => any): void {
this.capabilities.set(name, fn);
}
async run(code: string): Promise<any> {
// Simple mock implementation
if (code.includes('throw new Error')) {
const match = code.match(/throw new Error\(["'](.+)["']\)/);
if (match) {
throw new Error(match[1]);
}
}
if (code.includes('env.add')) {
const match = code.match(/env\.add\((\d+),\s*(\d+)\)/);
if (match && this.capabilities.has('add')) {
const fn = this.capabilities.get('add');
return fn!(parseInt(match[1]), parseInt(match[2]));
}
}
return undefined;
}
}
describe('Host (Browser Mock)', () => {
let host: MockHost;
beforeEach(() => {
host = new MockHost();
host.addCapability('add', (a: number, b: number) => {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('Invalid arguments');
}
return a + b;
});
// Clear console mocks before each test
vi.clearAllMocks();
});
it('should run code', async () => {
const result = await host.run('export default env.add(1, 2)');
expect(result).toBe(3);
});
it('should run code with errors', async () => {
try {
await host.run('throw new Error("test");');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('test');
}
});
it('should handle capability calls', () => {
const capabilities = new Map();
capabilities.set('multiply', (a: number, b: number) => a * b);
const testHost = new MockHost();
testHost.addCapability('multiply', capabilities.get('multiply')!);
expect(capabilities.get('multiply')!(3, 4)).toBe(12);
});
it('should store multiple capabilities', () => {
const testHost = new MockHost();
testHost.addCapability('subtract', (a: number, b: number) => a - b);
testHost.addCapability('divide', (a: number, b: number) => {
if (b === 0) throw new Error('Division by zero');
return a / b;
});
// Test that capabilities are stored (indirectly through mock behavior)
expect(() => {
const fn = (a: number, b: number) => {
if (b === 0) throw new Error('Division by zero');
return a / b;
};
fn(10, 0);
}).toThrow('Division by zero');
});
});

View File

@@ -1,42 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Host } from './index';
// Skip this test in browser environment since QuickJS requires Node.js
describe.skipIf(typeof window !== 'undefined')('Host', () => {
let host: Host;
beforeEach(() => {
host = new Host();
host.addCapability('add', (a: number, b: number) => {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('Invalid arguments');
}
return a + b;
});
// Clear console mocks before each test
vi.clearAllMocks();
});
it('should run code', async () => {
const result = await host.run('export default env.add(1, 2)');
expect(result).toBe(3);
});
it('should run code with errors', async () => {
try {
await host.run('throw new Error("test");');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('test');
}
});
it('should run code with invalid arguments', async () => {
try {
await host.run('export default env.add("1", 2)');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('Invalid arguments');
}
});
});

View File

@@ -1,106 +0,0 @@
/**
* @tlsn/plugin-sdk
*
* SDK for developing and running TLSN WebAssembly plugins
*/
import { SandboxEvalCode, type SandboxOptions, loadQuickJs } from '@sebastianwessel/quickjs';
import variant from '@jitl/quickjs-ng-wasmfile-release-sync';
export class Host {
private capabilities: Map<string, (...args: any[]) => any> = new Map();
addCapability(name: string, handler: (...args: any[]) => any): void {
this.capabilities.set(name, handler);
}
async createEvalCode(capabilities?: { [method: string]: (...args: any[]) => any }): Promise<{
eval: (code: string) => Promise<any>;
dispose: () => void;
}> {
const { runSandboxed } = await loadQuickJs(variant);
const options: SandboxOptions = {
allowFetch: false,
allowFs: false,
env: {
...Object.fromEntries(this.capabilities),
...(capabilities || {}),
},
};
let evalCode: SandboxEvalCode | null = null;
let disposeCallback: (() => void) | null = null;
// Start sandbox and keep it alive
// Don't await this - we want it to keep running
runSandboxed(async (sandbox) => {
evalCode = sandbox.evalCode;
// Keep the sandbox alive until dispose is called
// The runtime won't be disposed until this promise resolves
return new Promise<void>((resolve) => {
disposeCallback = resolve;
});
}, options);
// Wait for evalCode to be ready
while (!evalCode) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
// Return evalCode and dispose function
return {
eval: async (code: string) => {
const result = await evalCode!(code);
if (!result.ok) {
const err = new Error(result.error.message);
err.name = result.error.name;
err.stack = result.error.stack;
throw err;
}
return result.data;
},
dispose: () => {
if (disposeCallback) {
disposeCallback();
disposeCallback = null;
}
},
};
}
async run(
code: string,
capabilities?: { [method: string]: (...args: any[]) => any },
): Promise<any> {
const { runSandboxed } = await loadQuickJs(variant);
const options: SandboxOptions = {
allowFetch: false,
allowFs: false,
env: {
...Object.fromEntries(this.capabilities),
...(capabilities || {}),
},
};
const result = await runSandboxed(async ({ evalCode }) => {
return evalCode(code);
}, options);
if (!result.ok) {
const err = new Error(result.error.message);
err.name = result.error.name;
err.stack = result.error.stack;
throw err;
}
return result.data;
}
}
// Default export
export default Host;

View File

@@ -1,20 +0,0 @@
// Mock crypto module for browser compatibility
export function randomBytes(size) {
const bytes = new Uint8Array(size);
if (typeof window !== 'undefined' && window.crypto) {
window.crypto.getRandomValues(bytes);
}
return Buffer.from(bytes);
}
export function createHash() {
return {
update: () => ({ digest: () => '' }),
digest: () => '',
};
}
export default {
randomBytes,
createHash,
};

View File

@@ -1,28 +0,0 @@
// Mock fs module for browser compatibility
export function readFileSync() {
return '';
}
export function writeFileSync() {}
export function existsSync() {
return false;
}
export function mkdirSync() {}
export function readdirSync() {
return [];
}
export function statSync() {
return {
isFile: () => false,
isDirectory: () => false,
};
}
export default {
readFileSync,
writeFileSync,
existsSync,
mkdirSync,
readdirSync,
statSync,
};

View File

@@ -1,5 +0,0 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.test.ts", "src/**/*.spec.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,39 +0,0 @@
{
"compilerOptions": {
// Output settings
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
// Type checking
"strict": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
// Module settings
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
// Output settings
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
// Path mapping
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

View File

@@ -1,50 +0,0 @@
import { defineConfig } from 'vite';
import path from 'node:path';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [
dts({
insertTypesEntry: true,
outDir: 'dist',
include: ['src/**/*.ts'],
exclude: ['**/*.test.ts', '**/*.spec.ts'],
rollupTypes: true,
}),
],
build: {
target: 'es2020',
lib: {
entry: path.resolve(__dirname, 'src/index.ts'),
name: 'TLSNPluginSDK',
formats: ['es', 'cjs', 'umd'],
fileName: (format) => {
if (format === 'es') return 'index.js';
if (format === 'cjs') return 'index.cjs';
if (format === 'umd') return 'index.umd.js';
return `index.${format}.js`;
},
},
rollupOptions: {
// Externalize QuickJS and Node.js dependencies
external: ['@sebastianwessel/quickjs', '@jitl/quickjs-ng-wasmfile-release-sync', /^node:.*/],
output: {
// Provide global variables to use in the UMD build
// for externalized deps
globals: {
'@sebastianwessel/quickjs': 'QuickJS',
'@jitl/quickjs-ng-wasmfile-release-sync': 'QuickJSVariant',
},
exports: 'named',
},
},
sourcemap: true,
minify: false,
emptyOutDir: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});

View File

@@ -1,56 +0,0 @@
import { defineConfig } from 'vitest/config';
import path from 'node:path';
export default defineConfig({
test: {
globals: true,
browser: {
enabled: true,
instances: [
{
browser: 'chromium',
},
],
provider: 'playwright',
// Enable headless mode by default
headless: true,
},
coverage: {
provider: 'c8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules', 'dist', '**/*.config.ts', '**/*.config.js', '**/examples/**'],
},
include: ['src/**/*.browser.{test,spec}.ts'],
exclude: ['node_modules', 'dist', 'src/index.test.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
buffer: 'buffer',
process: 'process/browser',
stream: 'stream-browserify',
path: 'path-browserify',
fs: path.resolve(__dirname, './src/node-fs-mock.js'),
crypto: path.resolve(__dirname, './src/node-crypto-mock.js'),
'node:fs': path.resolve(__dirname, './src/node-fs-mock.js'),
'node:path': 'path-browserify',
'node:stream': 'stream-browserify',
'node:buffer': 'buffer',
'node:crypto': path.resolve(__dirname, './src/node-crypto-mock.js'),
cluster: path.resolve(__dirname, './src/empty-module.js'),
url: path.resolve(__dirname, './src/empty-module.js'),
},
},
define: {
'process.env': {},
global: 'globalThis',
},
optimizeDeps: {
include: ['buffer', 'process'],
esbuildOptions: {
define: {
global: 'globalThis',
},
},
},
});

View File

@@ -1,21 +0,0 @@
import { defineConfig } from 'vitest/config';
import path from 'node:path';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'c8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules', 'dist', '**/*.config.ts', '**/*.config.js', '**/examples/**'],
},
include: ['src/**/*.{test,spec}.ts'],
exclude: ['node_modules', 'dist'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});

View File

@@ -1,19 +0,0 @@
# TLSNotary WASM Bindings
This crate provides a WebAssembly package for TLSNotary, offering core functionality for the TLSNotary attestation protocol along with useful TypeScript types.
For most use cases, you may prefer to use the `tlsn-js` package instead: [tlsn-js on npm](https://www.npmjs.com/package/tlsn-js).
## Dependencies
A specific version of `wasm-pack` must be installed to build the WASM binary:
```bash
cargo install --git https://github.com/rustwasm/wasm-pack.git --rev 32e52ca
```
## Links
- [Website](https://tlsnotary.org)
- [Documentation](https://docs.tlsnotary.org)
- [API Docs](https://tlsnotary.github.io/tlsn)

View File

@@ -1,33 +0,0 @@
{
"name": "tlsn-wasm",
"type": "module",
"description": "A core WebAssembly package for TLSNotary.",
"version": "0.1.0-alpha.13",
"license": "MIT OR Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/tlsnotary/tlsn.git"
},
"files": [
"tlsn_wasm_bg.wasm",
"tlsn_wasm.js",
"tlsn_wasm.d.ts",
"tlsn_wasm_bg.wasm.d.ts",
"spawn.js",
"snippets/"
],
"main": "tlsn_wasm.js",
"homepage": "https://tlsnotary.org",
"types": "tlsn_wasm.d.ts",
"sideEffects": [
"./snippets/*"
],
"keywords": [
"tls",
"tlsn",
"tlsnotary"
],
"scripts": {
"lint": "echo 'Skipping lint for pre-built WASM package'"
}
}

View File

@@ -1,73 +0,0 @@
function registerMessageListener(target, type, callback) {
const listener = async (event) => {
const message = event.data;
if (message && message.type === type) {
await callback(message.data);
}
};
target.addEventListener('message', listener);
}
// Register listener for the start spawner message.
registerMessageListener(self, 'web_spawn_start_spawner', async (data) => {
const workerUrl = new URL(
'./spawn.js',
import.meta.url
);
const [module, memory, spawnerPtr] = data;
const pkg = await import('../../../tlsn_wasm.js');
const exports = await pkg.default({ module, memory });
const spawner = pkg.web_spawn_recover_spawner(spawnerPtr);
postMessage('web_spawn_spawner_ready');
await spawner.run(workerUrl.toString());
exports.__wbindgen_thread_destroy();
close();
});
// Register listener for the start worker message.
registerMessageListener(self, 'web_spawn_start_worker', async (data) => {
const [module, memory, workerPtr] = data;
const pkg = await import('../../../tlsn_wasm.js');
const exports = await pkg.default({ module, memory });
pkg.web_spawn_start_worker(workerPtr);
exports.__wbindgen_thread_destroy();
close();
});
/// Starts the spawner in a new worker.
export async function startSpawnerWorker(module, memory, spawner) {
const workerUrl = new URL(
'./spawn.js',
import.meta.url
);
const worker = new Worker(
workerUrl,
{
name: 'web-spawn-spawner',
type: 'module'
}
);
const data = [module, memory, spawner.intoRaw()];
worker.postMessage({
type: 'web_spawn_start_spawner',
data: data
})
await new Promise(resolve => {
worker.addEventListener('message', function handler(event) {
if (event.data === 'web_spawn_spawner_ready') {
worker.removeEventListener('message', handler);
resolve();
}
})
})
}

View File

@@ -1,73 +0,0 @@
function registerMessageListener(target, type, callback) {
const listener = async (event) => {
const message = event.data;
if (message && message.type === type) {
await callback(message.data);
}
};
target.addEventListener('message', listener);
}
// Register listener for the start spawner message.
registerMessageListener(self, 'web_spawn_start_spawner', async (data) => {
const workerUrl = new URL(
'./spawn.js',
import.meta.url
);
const [module, memory, spawnerPtr] = data;
const pkg = await import('../../../tlsn_wasm.js');
const exports = await pkg.default({ module, memory });
const spawner = pkg.web_spawn_recover_spawner(spawnerPtr);
postMessage('web_spawn_spawner_ready');
await spawner.run(workerUrl.toString());
exports.__wbindgen_thread_destroy();
close();
});
// Register listener for the start worker message.
registerMessageListener(self, 'web_spawn_start_worker', async (data) => {
const [module, memory, workerPtr] = data;
const pkg = await import('../../../tlsn_wasm.js');
const exports = await pkg.default({ module, memory });
pkg.web_spawn_start_worker(workerPtr);
exports.__wbindgen_thread_destroy();
close();
});
/// Starts the spawner in a new worker.
export async function startSpawnerWorker(module, memory, spawner) {
const workerUrl = new URL(
'./spawn.js',
import.meta.url
);
const worker = new Worker(
workerUrl,
{
name: 'web-spawn-spawner',
type: 'module'
}
);
const data = [module, memory, spawner.intoRaw()];
worker.postMessage({
type: 'web_spawn_start_spawner',
data: data
})
await new Promise(resolve => {
worker.addEventListener('message', function handler(event) {
if (event.data === 'web_spawn_spawner_ready') {
worker.removeEventListener('message', handler);
resolve();
}
})
})
}

View File

@@ -1,223 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Initializes the module.
*/
export function initialize(logging_config: LoggingConfig | null | undefined, thread_count: number): Promise<void>;
/**
* Starts the thread spawner on a dedicated worker thread.
*/
export function startSpawner(): Promise<any>;
export function web_spawn_start_worker(worker: number): void;
export function web_spawn_recover_spawner(spawner: number): Spawner;
export type LoggingLevel = "Trace" | "Debug" | "Info" | "Warn" | "Error";
export interface CrateLogFilter {
level: LoggingLevel;
name: string;
}
export interface LoggingConfig {
level: LoggingLevel | undefined;
crate_filters: CrateLogFilter[] | undefined;
span_events: SpanEvent[] | undefined;
}
export type SpanEvent = "New" | "Close" | "Active";
export interface Reveal {
sent: { start: number; end: number }[];
recv: { start: number; end: number }[];
server_identity: boolean;
}
export interface Transcript {
sent: number[];
recv: number[];
}
export type Method = "GET" | "POST" | "PUT" | "DELETE";
export type TlsVersion = "V1_2" | "V1_3";
export type NetworkSetting = "Bandwidth" | "Latency";
export type Body = JsonValue;
export interface Commit {
sent: { start: number; end: number }[];
recv: { start: number; end: number }[];
}
export interface PartialTranscript {
sent: number[];
sent_authed: { start: number; end: number }[];
recv: number[];
recv_authed: { start: number; end: number }[];
}
export interface HttpResponse {
status: number;
headers: [string, number[]][];
}
export interface ConnectionInfo {
time: number;
version: TlsVersion;
transcript_length: TranscriptLength;
}
export interface HttpRequest {
uri: string;
method: Method;
headers: Map<string, number[]>;
body: Body | undefined;
}
export interface TranscriptLength {
sent: number;
recv: number;
}
export interface VerifierOutput {
server_name: string | undefined;
connection_info: ConnectionInfo;
transcript: PartialTranscript | undefined;
}
export interface ProverConfig {
server_name: string;
max_sent_data: number;
max_sent_records: number | undefined;
max_recv_data_online: number | undefined;
max_recv_data: number;
max_recv_records_online: number | undefined;
defer_decryption_from_start: boolean | undefined;
network: NetworkSetting;
client_auth: [number[][], number[]] | undefined;
}
export interface VerifierConfig {
max_sent_data: number;
max_recv_data: number;
max_sent_records: number | undefined;
max_recv_records_online: number | undefined;
}
export class Prover {
free(): void;
[Symbol.dispose](): void;
/**
* Returns the transcript.
*/
transcript(): Transcript;
/**
* Send the HTTP request to the server.
*/
send_request(ws_proxy_url: string, request: HttpRequest): Promise<HttpResponse>;
constructor(config: ProverConfig);
/**
* Set up the prover.
*
* This performs all MPC setup prior to establishing the connection to the
* application server.
*/
setup(verifier_url: string): Promise<void>;
/**
* Reveals data to the verifier and finalizes the protocol.
*/
reveal(reveal: Reveal): Promise<void>;
}
/**
* Global spawner which spawns closures into web workers.
*/
export class Spawner {
private constructor();
free(): void;
[Symbol.dispose](): void;
/**
* Runs the spawner.
*/
run(url: string): Promise<void>;
intoRaw(): number;
}
export class Verifier {
free(): void;
[Symbol.dispose](): void;
constructor(config: VerifierConfig);
/**
* Verifies the connection and finalizes the protocol.
*/
verify(): Promise<VerifierOutput>;
/**
* Connect to the prover.
*/
connect(prover_url: string): Promise<void>;
}
export class WorkerData {
private constructor();
free(): void;
[Symbol.dispose](): void;
}
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly __wbg_prover_free: (a: number, b: number) => void;
readonly __wbg_verifier_free: (a: number, b: number) => void;
readonly initialize: (a: number, b: number) => any;
readonly prover_new: (a: any) => [number, number, number];
readonly prover_reveal: (a: number, b: any) => any;
readonly prover_send_request: (a: number, b: number, c: number, d: any) => any;
readonly prover_setup: (a: number, b: number, c: number) => any;
readonly prover_transcript: (a: number) => [number, number, number];
readonly verifier_connect: (a: number, b: number, c: number) => any;
readonly verifier_new: (a: any) => number;
readonly verifier_verify: (a: number) => any;
readonly __wbg_spawner_free: (a: number, b: number) => void;
readonly __wbg_workerdata_free: (a: number, b: number) => void;
readonly spawner_intoRaw: (a: number) => number;
readonly spawner_run: (a: number, b: number, c: number) => any;
readonly startSpawner: () => any;
readonly web_spawn_recover_spawner: (a: number) => number;
readonly web_spawn_start_worker: (a: number) => void;
readonly ring_core_0_17_14__bn_mul_mont: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
readonly memory: WebAssembly.Memory;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_exn_store: (a: number) => void;
readonly __externref_table_alloc: () => number;
readonly __wbindgen_export_5: WebAssembly.Table;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __wbindgen_export_7: WebAssembly.Table;
readonly __externref_table_dealloc: (a: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__h5261d4aab6ab8312: (a: number, b: number) => void;
readonly closure1906_externref_shim: (a: number, b: number, c: any) => void;
readonly closure43_externref_shim: (a: number, b: number, c: any) => void;
readonly closure3297_externref_shim: (a: number, b: number, c: any, d: any) => void;
readonly __wbindgen_thread_destroy: (a?: number, b?: number, c?: number) => void;
readonly __wbindgen_start: (a: number) => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput, memory?: WebAssembly.Memory, thread_stack_size?: number }} module - Passing `SyncInitInput` directly is deprecated.
* @param {WebAssembly.Memory} memory - Deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput, memory?: WebAssembly.Memory, thread_stack_size?: number } | SyncInitInput, memory?: WebAssembly.Memory): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput>, memory?: WebAssembly.Memory, thread_stack_size?: number }} module_or_path - Passing `InitInput` directly is deprecated.
* @param {WebAssembly.Memory} memory - Deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput>, memory?: WebAssembly.Memory, thread_stack_size?: number } | InitInput | Promise<InitInput>, memory?: WebAssembly.Memory): Promise<InitOutput>;

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +0,0 @@
/* tslint:disable */
/* eslint-disable */
export const __wbg_prover_free: (a: number, b: number) => void;
export const __wbg_verifier_free: (a: number, b: number) => void;
export const initialize: (a: number, b: number) => any;
export const prover_new: (a: any) => [number, number, number];
export const prover_reveal: (a: number, b: any) => any;
export const prover_send_request: (a: number, b: number, c: number, d: any) => any;
export const prover_setup: (a: number, b: number, c: number) => any;
export const prover_transcript: (a: number) => [number, number, number];
export const verifier_connect: (a: number, b: number, c: number) => any;
export const verifier_new: (a: any) => number;
export const verifier_verify: (a: number) => any;
export const __wbg_spawner_free: (a: number, b: number) => void;
export const __wbg_workerdata_free: (a: number, b: number) => void;
export const spawner_intoRaw: (a: number) => number;
export const spawner_run: (a: number, b: number, c: number) => any;
export const startSpawner: () => any;
export const web_spawn_recover_spawner: (a: number) => number;
export const web_spawn_start_worker: (a: number) => void;
export const ring_core_0_17_14__bn_mul_mont: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
export const memory: WebAssembly.Memory;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_exn_store: (a: number) => void;
export const __externref_table_alloc: () => number;
export const __wbindgen_export_5: WebAssembly.Table;
export const __wbindgen_free: (a: number, b: number, c: number) => void;
export const __wbindgen_export_7: WebAssembly.Table;
export const __externref_table_dealloc: (a: number) => void;
export const wasm_bindgen__convert__closures_____invoke__h5261d4aab6ab8312: (a: number, b: number) => void;
export const closure1906_externref_shim: (a: number, b: number, c: any) => void;
export const closure43_externref_shim: (a: number, b: number, c: any) => void;
export const closure3297_externref_shim: (a: number, b: number, c: any, d: any) => void;
export const __wbindgen_thread_destroy: (a?: number, b?: number, c?: number) => void;
export const __wbindgen_start: (a: number) => void;

View File

@@ -1,43 +0,0 @@
[package]
name = "tlsn-verifier-server"
version = "0.1.0"
edition = "2021"
[dependencies]
# TLSNotary dependency
tlsn = { git = "https://github.com/tlsnotary/tlsn.git", tag = "v0.1.0-alpha.13" }
# HTTP server framework
axum = { version = "0.7", features = ["ws"] }
axum-core = "0.4"
http = "1.0"
hyper = "1.0"
tokio = { version = "1", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors"] }
hyper-util = { version = "0.1", features = ["tokio"] }
# WebSocket utilities
ws_stream_tungstenite = "0.14"
async-tungstenite = { version = "0.28", features = ["tokio-runtime"] }
futures-util = "0.3"
async-trait = "0.1"
# Cryptography
sha1 = "0.10"
base64 = "0.22"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Error handling
eyre = "0.6"
# Utilities
tokio-util = { version = "0.7", features = ["compat"] }
uuid = { version = "1.0", features = ["v4", "serde"] }

View File

@@ -1,224 +0,0 @@
# TLSNotary Verifier Server
A Rust-based HTTP server with WebSocket support for TLSNotary verification operations.
## Features
- **Health Check Endpoint**: Simple `/health` endpoint that returns "ok" for monitoring
- **Verifier WebSocket**: WebSocket server at `/verifier` for TLSNotary verification
- **CORS Enabled**: Permissive CORS configuration for cross-origin requests
- **Async Runtime**: Built on Tokio for high-performance async operations
- **Logging**: Structured logging with tracing for debugging and monitoring
- **Error Handling**: Proper error handling and automatic cleanup on failure
## Dependencies
- **tlsn**: v0.1.0-alpha.13 from GitHub - TLSNotary verification library
- **axum**: Modern web framework with WebSocket support
- **tokio**: Async runtime with full features
- **tokio-util**: Async utilities for stream compatibility
- **tower-http**: CORS middleware
- **tracing**: Structured logging and diagnostics
- **eyre**: Error handling and reporting
## Building
```bash
# From the verifier package directory
cargo build
# For production release
cargo build --release
```
## Running
```bash
# Development mode
cargo run
# Production release
cargo run --release
```
The server will start on `0.0.0.0:7047` by default.
## API Endpoints
### Health Check
**GET** `/health`
Returns a simple "ok" response to verify the server is running.
**Example:**
```bash
curl http://localhost:7047/health
# Response: ok
```
### Create Session
**POST** `/session`
Creates a new verification session with specified data limits. Returns a session ID that can be used to connect to the verifier WebSocket.
**Request Body:**
```json
{
"maxRecvData": 16384,
"maxSentData": 4096
}
```
**Response:**
```json
{
"sessionId": "550e8400-e29b-41d4-a716-446655440000"
}
```
**Example:**
```bash
curl -X POST http://localhost:7047/session \
-H "Content-Type: application/json" \
-d '{"maxRecvData": 16384, "maxSentData": 4096}'
```
### Verifier WebSocket
**WS** `/verifier?sessionId=<session-id>`
Establishes a WebSocket connection for TLSNotary verification using a previously created session. Upon connection:
1. Validates the session ID exists
2. Retrieves maxRecvData and maxSentData from the session
3. Spawns a verifier with the configured limits
4. Performs TLS proof verification
5. Cleans up and removes the session when connection closes
**Query Parameters:**
- `sessionId` (required): Session ID returned from POST /session
**Error Responses:**
- `404 Not Found`: Session ID does not exist or has already been used
**Example using websocat:**
```bash
# First, create a session
SESSION_ID=$(curl -s -X POST http://localhost:7047/session \
-H "Content-Type: application/json" \
-d '{"maxRecvData": 16384, "maxSentData": 4096}' | jq -r '.sessionId')
# Then connect with the session ID
websocat "ws://localhost:7047/verifier?sessionId=$SESSION_ID"
```
**Example using JavaScript:**
```javascript
// Create a session first
const response = await fetch('http://localhost:7047/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ maxRecvData: 16384, maxSentData: 4096 })
});
const { sessionId } = await response.json();
// Connect to verifier with session ID
const ws = new WebSocket(`ws://localhost:7047/verifier?sessionId=${sessionId}`);
ws.onopen = () => {
console.log('Connected to verifier');
};
ws.onmessage = (event) => {
console.log('Verification result:', event.data);
};
ws.onclose = () => {
console.log('Verifier disconnected, session cleaned up');
};
ws.onerror = (error) => {
console.error('Verification error:', error);
};
```
## Verifier Architecture
The verifier implementation follows this flow:
1. **Session Creation**: Client sends POST request to `/session` with maxRecvData and maxSentData
2. **Session Storage**: Server generates UUID, stores session config in HashMap
3. **WebSocket Connection**: Client connects to `/verifier?sessionId=<id>`
4. **Session Lookup**: Server validates session exists and retrieves configuration
5. **Task Spawning**: Server spawns async task with session-specific limits
6. **Verification Process**:
- Uses maxRecvData and maxSentData from session config
- Configures protocol validator with session limits
- Creates verifier with TLSNotary config
- Performs MPC-TLS verification
- Validates server name and transcript data
7. **Error Handling**: Any errors are caught, logged, and cleaned up automatically
8. **Cleanup**: Session is removed from storage when WebSocket closes
### Session Management
- **Thread-safe storage**: Uses `Arc<Mutex<HashMap>>` for concurrent access
- **One-time use**: Sessions are automatically removed after WebSocket closes
- **Session isolation**: Each verifier gets independent maxRecvData/maxSentData limits
- **Error handling**: Invalid session IDs return 404 before WebSocket upgrade
**Note**: The current implementation logs all incoming WebSocket messages. Full verifier integration requires converting the axum WebSocket to AsyncRead/AsyncWrite format using the WsStream bridge.
## Configuration
The server configuration is currently hardcoded in `main.rs`:
- **Host**: `0.0.0.0` (all interfaces)
- **Port**: `7047`
To change these, modify the `SocketAddr::from()` call in `main.rs`.
## Development
### Adding New Routes
Add routes to the Router in `main.rs`:
```rust
let app = Router::new()
.route("/health", get(health_handler))
.route("/verifier", get(verifier_ws_handler))
.route("/your-route", get(your_handler)) // Add here
.layer(CorsLayer::permissive())
.with_state(app_state);
```
### Project Structure
```
src/
├── main.rs # Server setup, routing, and WebSocket handling
├── config.rs # Configuration constants (MAX_SENT_DATA, MAX_RECV_DATA)
└── verifier.rs # TLSNotary verification logic
```
### Extending Application State
Modify the `AppState` struct to share data between handlers:
```rust
struct AppState {
// Add your shared state here
sessions: Arc<Mutex<HashMap<String, Session>>>,
}
```
## Integration with Extension
This server is designed to work with the TLSNotary browser extension located in `packages/extension`. The extension will connect to the WebSocket endpoint for verification operations.
## License
See the root LICENSE file for license information.

View File

@@ -1,930 +0,0 @@
//! The following code is adapted from https://github.com/tokio-rs/axum/blob/axum-v0.7.3/axum/src/extract/ws.rs
//! where we swapped out tokio_tungstenite (https://docs.rs/tokio-tungstenite/latest/tokio_tungstenite/)
//! with async_tungstenite (https://docs.rs/async-tungstenite/latest/async_tungstenite/) so that we can use
//! ws_stream_tungstenite (https://docs.rs/ws_stream_tungstenite/latest/ws_stream_tungstenite/index.html)
//! to get AsyncRead and AsyncWrite implemented for the WebSocket. Any other modification is commented with the prefix "NOTARY_MODIFICATION:"
//!
//! The code is under the following license:
//!
//! Copyright (c) 2019 Axum Contributors
//!
//! Permission is hereby granted, free of charge, to any
//! person obtaining a copy of this software and associated
//! documentation files (the "Software"), to deal in the
//! Software without restriction, including without
//! limitation the rights to use, copy, modify, merge,
//! publish, distribute, sublicense, and/or sell copies of
//! the Software, and to permit persons to whom the Software
//! is furnished to do so, subject to the following
//! conditions:
//!
//! The above copyright notice and this permission notice
//! shall be included in all copies or substantial portions
//! of the Software.
//!
//! THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
//! ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
//! TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
//! PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
//! SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
//! CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
//! OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
//! IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
//! DEALINGS IN THE SOFTWARE.
//!
//!
//! Handle WebSocket connections.
//!
//! # Example
//!
//! ```
//! use axum::{
//! extract::ws::{WebSocketUpgrade, WebSocket},
//! routing::get,
//! response::{IntoResponse, Response},
//! Router,
//! };
//!
//! let app = Router::new().route("/ws", get(handler));
//!
//! async fn handler(ws: WebSocketUpgrade) -> Response {
//! ws.on_upgrade(handle_socket)
//! }
//!
//! async fn handle_socket(mut socket: WebSocket) {
//! while let Some(msg) = socket.recv().await {
//! let msg = if let Ok(msg) = msg {
//! msg
//! } else {
//! // client disconnected
//! return;
//! };
//!
//! if socket.send(msg).await.is_err() {
//! // client disconnected
//! return;
//! }
//! }
//! }
//! # let _: Router = app;
//! ```
//!
//! # Passing data and/or state to an `on_upgrade` callback
//!
//! ```
//! use axum::{
//! extract::{ws::{WebSocketUpgrade, WebSocket}, State},
//! response::Response,
//! routing::get,
//! Router,
//! };
//!
//! #[derive(Clone)]
//! struct AppState {
//! // ...
//! }
//!
//! async fn handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
//! ws.on_upgrade(|socket| handle_socket(socket, state))
//! }
//!
//! async fn handle_socket(socket: WebSocket, state: AppState) {
//! // ...
//! }
//!
//! let app = Router::new()
//! .route("/ws", get(handler))
//! .with_state(AppState { /* ... */ });
//! # let _: Router = app;
//! ```
//!
//! # Read and write concurrently
//!
//! If you need to read and write concurrently from a [`WebSocket`] you can use
//! [`StreamExt::split`]:
//!
//! ```rust,no_run
//! use axum::{Error, extract::ws::{WebSocket, Message}};
//! use futures_util::{sink::SinkExt, stream::{StreamExt, SplitSink, SplitStream}};
//!
//! async fn handle_socket(mut socket: WebSocket) {
//! let (mut sender, mut receiver) = socket.split();
//!
//! tokio::spawn(write(sender));
//! tokio::spawn(read(receiver));
//! }
//!
//! async fn read(receiver: SplitStream<WebSocket>) {
//! // ...
//! }
//!
//! async fn write(sender: SplitSink<WebSocket, Message>) {
//! // ...
//! }
//! ```
//!
//! [`StreamExt::split`]: https://docs.rs/futures/0.3.17/futures/stream/trait.StreamExt.html#method.split
#![allow(unused)]
use self::rejection::*;
use async_trait::async_trait;
use async_tungstenite::{
tokio::TokioAdapter,
tungstenite::{
self as ts,
protocol::{self, WebSocketConfig},
},
WebSocketStream,
};
use axum::{body::Bytes, extract::FromRequestParts, response::Response, Error};
use axum_core::body::Body;
use futures_util::{
sink::{Sink, SinkExt},
stream::{Stream, StreamExt},
};
use http::{
header::{self, HeaderMap, HeaderName, HeaderValue},
request::Parts,
Method, StatusCode,
};
use hyper_util::rt::TokioIo;
use sha1::{Digest, Sha1};
use std::{
borrow::Cow,
future::Future,
pin::Pin,
task::{Context, Poll},
};
use tracing::error;
/// Extractor for establishing WebSocket connections.
///
/// Note: This extractor requires the request method to be `GET` so it should
/// always be used with [`get`](crate::routing::get). Requests with other methods will be
/// rejected.
///
/// See the [module docs](self) for an example.
#[cfg_attr(docsrs, doc(cfg(feature = "ws")))]
pub struct WebSocketUpgrade<F = DefaultOnFailedUpgrade> {
config: WebSocketConfig,
/// The chosen protocol sent in the `Sec-WebSocket-Protocol` header of the response.
protocol: Option<HeaderValue>,
sec_websocket_key: HeaderValue,
on_upgrade: hyper::upgrade::OnUpgrade,
on_failed_upgrade: F,
sec_websocket_protocol: Option<HeaderValue>,
}
impl<F> std::fmt::Debug for WebSocketUpgrade<F> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebSocketUpgrade")
.field("config", &self.config)
.field("protocol", &self.protocol)
.field("sec_websocket_key", &self.sec_websocket_key)
.field("sec_websocket_protocol", &self.sec_websocket_protocol)
.finish_non_exhaustive()
}
}
impl<F> WebSocketUpgrade<F> {
/// The target minimum size of the write buffer to reach before writing the data
/// to the underlying stream.
///
/// The default value is 128 KiB.
///
/// If set to `0` each message will be eagerly written to the underlying stream.
/// It is often more optimal to allow them to buffer a little, hence the default value.
///
/// Note: [`flush`](SinkExt::flush) will always fully write the buffer regardless.
pub fn write_buffer_size(mut self, size: usize) -> Self {
self.config.write_buffer_size = size;
self
}
/// The max size of the write buffer in bytes. Setting this can provide backpressure
/// in the case the write buffer is filling up due to write errors.
///
/// The default value is unlimited.
///
/// Note: The write buffer only builds up past [`write_buffer_size`](Self::write_buffer_size)
/// when writes to the underlying stream are failing. So the **write buffer can not
/// fill up if you are not observing write errors even if not flushing**.
///
/// Note: Should always be at least [`write_buffer_size + 1 message`](Self::write_buffer_size)
/// and probably a little more depending on error handling strategy.
pub fn max_write_buffer_size(mut self, max: usize) -> Self {
self.config.max_write_buffer_size = max;
self
}
/// Set the maximum message size (defaults to 64 megabytes)
pub fn max_message_size(mut self, max: usize) -> Self {
self.config.max_message_size = Some(max);
self
}
/// Set the maximum frame size (defaults to 16 megabytes)
pub fn max_frame_size(mut self, max: usize) -> Self {
self.config.max_frame_size = Some(max);
self
}
/// Allow server to accept unmasked frames (defaults to false)
pub fn accept_unmasked_frames(mut self, accept: bool) -> Self {
self.config.accept_unmasked_frames = accept;
self
}
/// Set the known protocols.
///
/// If the protocol name specified by `Sec-WebSocket-Protocol` header
/// to match any of them, the upgrade response will include `Sec-WebSocket-Protocol` header and
/// return the protocol name.
///
/// The protocols should be listed in decreasing order of preference: if the client offers
/// multiple protocols that the server could support, the server will pick the first one in
/// this list.
///
/// # Examples
///
/// ```
/// use axum::{
/// extract::ws::{WebSocketUpgrade, WebSocket},
/// routing::get,
/// response::{IntoResponse, Response},
/// Router,
/// };
///
/// let app = Router::new().route("/ws", get(handler));
///
/// async fn handler(ws: WebSocketUpgrade) -> Response {
/// ws.protocols(["graphql-ws", "graphql-transport-ws"])
/// .on_upgrade(|socket| async {
/// // ...
/// })
/// }
/// # let _: Router = app;
/// ```
pub fn protocols<I>(mut self, protocols: I) -> Self
where
I: IntoIterator,
I::Item: Into<Cow<'static, str>>,
{
if let Some(req_protocols) = self
.sec_websocket_protocol
.as_ref()
.and_then(|p| p.to_str().ok())
{
self.protocol = protocols
.into_iter()
// FIXME: This will often allocate a new `String` and so is less efficient than it
// could be. But that can't be fixed without breaking changes to the public API.
.map(Into::into)
.find(|protocol| {
req_protocols
.split(',')
.any(|req_protocol| req_protocol.trim() == protocol)
})
.map(|protocol| match protocol {
Cow::Owned(s) => HeaderValue::from_str(&s).unwrap(),
Cow::Borrowed(s) => HeaderValue::from_static(s),
});
}
self
}
/// Provide a callback to call if upgrading the connection fails.
///
/// The connection upgrade is performed in a background task. If that fails this callback
/// will be called.
///
/// By default any errors will be silently ignored.
///
/// # Example
///
/// ```
/// use axum::{
/// extract::{WebSocketUpgrade},
/// response::Response,
/// };
///
/// async fn handler(ws: WebSocketUpgrade) -> Response {
/// ws.on_failed_upgrade(|error| {
/// report_error(error);
/// })
/// .on_upgrade(|socket| async { /* ... */ })
/// }
/// #
/// # fn report_error(_: axum::Error) {}
/// ```
pub fn on_failed_upgrade<C>(self, callback: C) -> WebSocketUpgrade<C>
where
C: OnFailedUpgrade,
{
WebSocketUpgrade {
config: self.config,
protocol: self.protocol,
sec_websocket_key: self.sec_websocket_key,
on_upgrade: self.on_upgrade,
on_failed_upgrade: callback,
sec_websocket_protocol: self.sec_websocket_protocol,
}
}
/// Finalize upgrading the connection and call the provided callback with
/// the stream.
#[must_use = "to set up the WebSocket connection, this response must be returned"]
pub fn on_upgrade<C, Fut>(self, callback: C) -> Response
where
C: FnOnce(WebSocket) -> Fut + Send + 'static,
Fut: Future<Output = ()> + Send + 'static,
F: OnFailedUpgrade,
{
let on_upgrade = self.on_upgrade;
let config = self.config;
let on_failed_upgrade = self.on_failed_upgrade;
let protocol = self.protocol.clone();
tokio::spawn(async move {
let upgraded = match on_upgrade.await {
Ok(upgraded) => upgraded,
Err(err) => {
error!("Something wrong with on_upgrade: {:?}", err);
on_failed_upgrade.call(Error::new(err));
return;
}
};
let upgraded = TokioIo::new(upgraded);
let socket = WebSocketStream::from_raw_socket(
// NOTARY_MODIFICATION: Need to use TokioAdapter to wrap Upgraded which doesn't implement futures crate's AsyncRead and AsyncWrite
TokioAdapter::new(upgraded),
protocol::Role::Server,
Some(config),
)
.await;
let socket = WebSocket {
inner: socket,
protocol,
};
callback(socket).await;
});
#[allow(clippy::declare_interior_mutable_const)]
const UPGRADE: HeaderValue = HeaderValue::from_static("upgrade");
#[allow(clippy::declare_interior_mutable_const)]
const WEBSOCKET: HeaderValue = HeaderValue::from_static("websocket");
let mut builder = Response::builder()
.status(StatusCode::SWITCHING_PROTOCOLS)
.header(header::CONNECTION, UPGRADE)
.header(header::UPGRADE, WEBSOCKET)
.header(
header::SEC_WEBSOCKET_ACCEPT,
sign(self.sec_websocket_key.as_bytes()),
);
if let Some(protocol) = self.protocol {
builder = builder.header(header::SEC_WEBSOCKET_PROTOCOL, protocol);
}
builder.body(Body::empty()).unwrap()
}
}
/// What to do when a connection upgrade fails.
///
/// See [`WebSocketUpgrade::on_failed_upgrade`] for more details.
pub trait OnFailedUpgrade: Send + 'static {
/// Call the callback.
fn call(self, error: Error);
}
impl<F> OnFailedUpgrade for F
where
F: FnOnce(Error) + Send + 'static,
{
fn call(self, error: Error) {
self(error)
}
}
/// The default `OnFailedUpgrade` used by `WebSocketUpgrade`.
///
/// It simply ignores the error.
#[non_exhaustive]
#[derive(Debug)]
pub struct DefaultOnFailedUpgrade;
impl OnFailedUpgrade for DefaultOnFailedUpgrade {
#[inline]
fn call(self, _error: Error) {}
}
#[async_trait]
impl<S> FromRequestParts<S> for WebSocketUpgrade<DefaultOnFailedUpgrade>
where
S: Send + Sync,
{
type Rejection = WebSocketUpgradeRejection;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
if parts.method != Method::GET {
return Err(MethodNotGet.into());
}
if !header_contains(&parts.headers, header::CONNECTION, "upgrade") {
return Err(InvalidConnectionHeader.into());
}
if !header_eq(&parts.headers, header::UPGRADE, "websocket") {
return Err(InvalidUpgradeHeader.into());
}
if !header_eq(&parts.headers, header::SEC_WEBSOCKET_VERSION, "13") {
return Err(InvalidWebSocketVersionHeader.into());
}
let sec_websocket_key = parts
.headers
.get(header::SEC_WEBSOCKET_KEY)
.ok_or(WebSocketKeyHeaderMissing)?
.clone();
let on_upgrade = parts
.extensions
.remove::<hyper::upgrade::OnUpgrade>()
.ok_or(ConnectionNotUpgradable)?;
let sec_websocket_protocol = parts.headers.get(header::SEC_WEBSOCKET_PROTOCOL).cloned();
Ok(Self {
config: Default::default(),
protocol: None,
sec_websocket_key,
on_upgrade,
sec_websocket_protocol,
on_failed_upgrade: DefaultOnFailedUpgrade,
})
}
}
/// NOTARY_MODIFICATION: Made this function public to be used in service.rs
pub fn header_eq(headers: &HeaderMap, key: HeaderName, value: &'static str) -> bool {
if let Some(header) = headers.get(&key) {
header.as_bytes().eq_ignore_ascii_case(value.as_bytes())
} else {
false
}
}
fn header_contains(headers: &HeaderMap, key: HeaderName, value: &'static str) -> bool {
let header = if let Some(header) = headers.get(&key) {
header
} else {
return false;
};
if let Ok(header) = std::str::from_utf8(header.as_bytes()) {
header.to_ascii_lowercase().contains(value)
} else {
false
}
}
/// A stream of WebSocket messages.
///
/// See [the module level documentation](self) for more details.
#[derive(Debug)]
pub struct WebSocket {
inner: WebSocketStream<TokioAdapter<TokioIo<hyper::upgrade::Upgraded>>>,
protocol: Option<HeaderValue>,
}
impl WebSocket {
/// NOTARY_MODIFICATION: Consume `self` and get the inner [`async_tungstenite::WebSocketStream`].
pub fn into_inner(self) -> WebSocketStream<TokioAdapter<TokioIo<hyper::upgrade::Upgraded>>> {
self.inner
}
/// Receive another message.
///
/// Returns `None` if the stream has closed.
pub async fn recv(&mut self) -> Option<Result<Message, Error>> {
self.next().await
}
/// Send a message.
pub async fn send(&mut self, msg: Message) -> Result<(), Error> {
self.inner
.send(msg.into_tungstenite())
.await
.map_err(Error::new)
}
/// Gracefully close this WebSocket.
pub async fn close(mut self) -> Result<(), Error> {
self.inner.close(None).await.map_err(Error::new)
}
/// Return the selected WebSocket subprotocol, if one has been chosen.
pub fn protocol(&self) -> Option<&HeaderValue> {
self.protocol.as_ref()
}
}
impl Stream for WebSocket {
type Item = Result<Message, Error>;
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
loop {
match futures_util::ready!(self.inner.poll_next_unpin(cx)) {
Some(Ok(msg)) => {
if let Some(msg) = Message::from_tungstenite(msg) {
return Poll::Ready(Some(Ok(msg)));
}
}
Some(Err(err)) => return Poll::Ready(Some(Err(Error::new(err)))),
None => return Poll::Ready(None),
}
}
}
}
impl Sink<Message> for WebSocket {
type Error = Error;
fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.inner).poll_ready(cx).map_err(Error::new)
}
fn start_send(mut self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> {
Pin::new(&mut self.inner)
.start_send(item.into_tungstenite())
.map_err(Error::new)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.inner).poll_flush(cx).map_err(Error::new)
}
fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
Pin::new(&mut self.inner).poll_close(cx).map_err(Error::new)
}
}
/// Status code used to indicate why an endpoint is closing the WebSocket connection.
pub type CloseCode = u16;
/// A struct representing the close command.
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct CloseFrame<'t> {
/// The reason as a code.
pub code: CloseCode,
/// The reason as text string.
pub reason: Cow<'t, str>,
}
/// A WebSocket message.
//
// This code comes from https://github.com/snapview/tungstenite-rs/blob/master/src/protocol/message.rs and is under following license:
// Copyright (c) 2017 Alexey Galakhov
// Copyright (c) 2016 Jason Housley
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#[derive(Debug, Eq, PartialEq, Clone)]
pub enum Message {
/// A text WebSocket message
Text(String),
/// A binary WebSocket message
Binary(Vec<u8>),
/// A ping message with the specified payload
///
/// The payload here must have a length less than 125 bytes.
///
/// Ping messages will be automatically responded to by the server, so you do not have to worry
/// about dealing with them yourself.
Ping(Vec<u8>),
/// A pong message with the specified payload
///
/// The payload here must have a length less than 125 bytes.
///
/// Pong messages will be automatically sent to the client if a ping message is received, so
/// you do not have to worry about constructing them yourself unless you want to implement a
/// [unidirectional heartbeat](https://tools.ietf.org/html/rfc6455#section-5.5.3).
Pong(Vec<u8>),
/// A close message with the optional close frame.
Close(Option<CloseFrame<'static>>),
}
impl Message {
fn into_tungstenite(self) -> ts::Message {
match self {
Self::Text(text) => ts::Message::Text(text),
Self::Binary(binary) => ts::Message::Binary(binary),
Self::Ping(ping) => ts::Message::Ping(ping),
Self::Pong(pong) => ts::Message::Pong(pong),
Self::Close(Some(close)) => ts::Message::Close(Some(ts::protocol::CloseFrame {
code: ts::protocol::frame::coding::CloseCode::from(close.code),
reason: close.reason,
})),
Self::Close(None) => ts::Message::Close(None),
}
}
fn from_tungstenite(message: ts::Message) -> Option<Self> {
match message {
ts::Message::Text(text) => Some(Self::Text(text)),
ts::Message::Binary(binary) => Some(Self::Binary(binary)),
ts::Message::Ping(ping) => Some(Self::Ping(ping)),
ts::Message::Pong(pong) => Some(Self::Pong(pong)),
ts::Message::Close(Some(close)) => Some(Self::Close(Some(CloseFrame {
code: close.code.into(),
reason: close.reason,
}))),
ts::Message::Close(None) => Some(Self::Close(None)),
// we can ignore `Frame` frames as recommended by the tungstenite maintainers
// https://github.com/snapview/tungstenite-rs/issues/268
ts::Message::Frame(_) => None,
}
}
/// Consume the WebSocket and return it as binary data.
pub fn into_data(self) -> Vec<u8> {
match self {
Self::Text(string) => string.into_bytes(),
Self::Binary(data) | Self::Ping(data) | Self::Pong(data) => data,
Self::Close(None) => Vec::new(),
Self::Close(Some(frame)) => frame.reason.into_owned().into_bytes(),
}
}
/// Attempt to consume the WebSocket message and convert it to a String.
pub fn into_text(self) -> Result<String, Error> {
match self {
Self::Text(string) => Ok(string),
Self::Binary(data) | Self::Ping(data) | Self::Pong(data) => Ok(String::from_utf8(data)
.map_err(|err| err.utf8_error())
.map_err(Error::new)?),
Self::Close(None) => Ok(String::new()),
Self::Close(Some(frame)) => Ok(frame.reason.into_owned()),
}
}
/// Attempt to get a &str from the WebSocket message,
/// this will try to convert binary data to utf8.
pub fn to_text(&self) -> Result<&str, Error> {
match *self {
Self::Text(ref string) => Ok(string),
Self::Binary(ref data) | Self::Ping(ref data) | Self::Pong(ref data) => {
Ok(std::str::from_utf8(data).map_err(Error::new)?)
}
Self::Close(None) => Ok(""),
Self::Close(Some(ref frame)) => Ok(&frame.reason),
}
}
}
impl From<String> for Message {
fn from(string: String) -> Self {
Message::Text(string)
}
}
impl<'s> From<&'s str> for Message {
fn from(string: &'s str) -> Self {
Message::Text(string.into())
}
}
impl<'b> From<&'b [u8]> for Message {
fn from(data: &'b [u8]) -> Self {
Message::Binary(data.into())
}
}
impl From<Vec<u8>> for Message {
fn from(data: Vec<u8>) -> Self {
Message::Binary(data)
}
}
impl From<Message> for Vec<u8> {
fn from(msg: Message) -> Self {
msg.into_data()
}
}
fn sign(key: &[u8]) -> HeaderValue {
use base64::engine::Engine as _;
let mut sha1 = Sha1::default();
sha1.update(key);
sha1.update(&b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"[..]);
let b64 = Bytes::from(base64::engine::general_purpose::STANDARD.encode(sha1.finalize()));
HeaderValue::from_maybe_shared(b64).expect("base64 is a valid value")
}
pub mod rejection {
//! WebSocket specific rejections.
use axum_core::{
__composite_rejection as composite_rejection, __define_rejection as define_rejection,
};
define_rejection! {
#[status = METHOD_NOT_ALLOWED]
#[body = "Request method must be `GET`"]
/// Rejection type for [`WebSocketUpgrade`](super::WebSocketUpgrade).
pub struct MethodNotGet;
}
define_rejection! {
#[status = BAD_REQUEST]
#[body = "Connection header did not include 'upgrade'"]
/// Rejection type for [`WebSocketUpgrade`](super::WebSocketUpgrade).
pub struct InvalidConnectionHeader;
}
define_rejection! {
#[status = BAD_REQUEST]
#[body = "`Upgrade` header did not include 'websocket'"]
/// Rejection type for [`WebSocketUpgrade`](super::WebSocketUpgrade).
pub struct InvalidUpgradeHeader;
}
define_rejection! {
#[status = BAD_REQUEST]
#[body = "`Sec-WebSocket-Version` header did not include '13'"]
/// Rejection type for [`WebSocketUpgrade`](super::WebSocketUpgrade).
pub struct InvalidWebSocketVersionHeader;
}
define_rejection! {
#[status = BAD_REQUEST]
#[body = "`Sec-WebSocket-Key` header missing"]
/// Rejection type for [`WebSocketUpgrade`](super::WebSocketUpgrade).
pub struct WebSocketKeyHeaderMissing;
}
define_rejection! {
#[status = UPGRADE_REQUIRED]
#[body = "WebSocket request couldn't be upgraded since no upgrade state was present"]
/// Rejection type for [`WebSocketUpgrade`](super::WebSocketUpgrade).
///
/// This rejection is returned if the connection cannot be upgraded for example if the
/// request is HTTP/1.0.
///
/// See [MDN] for more details about connection upgrades.
///
/// [MDN]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Upgrade
pub struct ConnectionNotUpgradable;
}
composite_rejection! {
/// Rejection used for [`WebSocketUpgrade`](super::WebSocketUpgrade).
///
/// Contains one variant for each way the [`WebSocketUpgrade`](super::WebSocketUpgrade)
/// extractor can fail.
pub enum WebSocketUpgradeRejection {
MethodNotGet,
InvalidConnectionHeader,
InvalidUpgradeHeader,
InvalidWebSocketVersionHeader,
WebSocketKeyHeaderMissing,
ConnectionNotUpgradable,
}
}
}
pub mod close_code {
//! Constants for [`CloseCode`]s.
//!
//! [`CloseCode`]: super::CloseCode
/// Indicates a normal closure, meaning that the purpose for which the connection was
/// established has been fulfilled.
pub const NORMAL: u16 = 1000;
/// Indicates that an endpoint is "going away", such as a server going down or a browser having
/// navigated away from a page.
pub const AWAY: u16 = 1001;
/// Indicates that an endpoint is terminating the connection due to a protocol error.
pub const PROTOCOL: u16 = 1002;
/// Indicates that an endpoint is terminating the connection because it has received a type of
/// data it cannot accept (e.g., an endpoint that understands only text data MAY send this if
/// it receives a binary message).
pub const UNSUPPORTED: u16 = 1003;
/// Indicates that no status code was included in a closing frame.
pub const STATUS: u16 = 1005;
/// Indicates an abnormal closure.
pub const ABNORMAL: u16 = 1006;
/// Indicates that an endpoint is terminating the connection because it has received data
/// within a message that was not consistent with the type of the message (e.g., non-UTF-8
/// RFC3629 data within a text message).
pub const INVALID: u16 = 1007;
/// Indicates that an endpoint is terminating the connection because it has received a message
/// that violates its policy. This is a generic status code that can be returned when there is
/// no other more suitable status code (e.g., `UNSUPPORTED` or `SIZE`) or if there is a need to
/// hide specific details about the policy.
pub const POLICY: u16 = 1008;
/// Indicates that an endpoint is terminating the connection because it has received a message
/// that is too big for it to process.
pub const SIZE: u16 = 1009;
/// Indicates that an endpoint (client) is terminating the connection because it has expected
/// the server to negotiate one or more extension, but the server didn't return them in the
/// response message of the WebSocket handshake. The list of extensions that are needed should
/// be given as the reason for closing. Note that this status code is not used by the server,
/// because it can fail the WebSocket handshake instead.
pub const EXTENSION: u16 = 1010;
/// Indicates that a server is terminating the connection because it encountered an unexpected
/// condition that prevented it from fulfilling the request.
pub const ERROR: u16 = 1011;
/// Indicates that the server is restarting.
pub const RESTART: u16 = 1012;
/// Indicates that the server is overloaded and the client should either connect to a different
/// IP (when multiple targets exist), or reconnect to the same IP when a user has performed an
/// action.
pub const AGAIN: u16 = 1013;
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{body::Body, routing::get, Router};
use http::{Request, Version};
// NOTARY_MODIFICATION: use tower_util instead of tower to make clippy happy
use tower_util::ServiceExt;
#[tokio::test]
async fn rejects_http_1_0_requests() {
let svc = get(|ws: Result<WebSocketUpgrade, WebSocketUpgradeRejection>| {
let rejection = ws.unwrap_err();
assert!(matches!(
rejection,
WebSocketUpgradeRejection::ConnectionNotUpgradable(_)
));
std::future::ready(())
});
let req = Request::builder()
.version(Version::HTTP_10)
.method(Method::GET)
.header("upgrade", "websocket")
.header("connection", "Upgrade")
.header("sec-websocket-key", "6D69KGBOr4Re+Nj6zx9aQA==")
.header("sec-websocket-version", "13")
.body(Body::empty())
.unwrap();
let res = svc.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[allow(dead_code)]
fn default_on_failed_upgrade() {
async fn handler(ws: WebSocketUpgrade) -> Response {
ws.on_upgrade(|_| async {})
}
let _: Router = Router::new().route("/", get(handler));
}
#[allow(dead_code)]
fn on_failed_upgrade() {
async fn handler(ws: WebSocketUpgrade) -> Response {
ws.on_failed_upgrade(|_error: Error| println!("oops!"))
.on_upgrade(|_| async {})
}
let _: Router = Router::new().route("/", get(handler));
}
}

View File

@@ -1,7 +0,0 @@
/// Configuration constants for the TLSNotary verifier server
/// Maximum number of bytes that can be sent from prover to server
pub const MAX_SENT_DATA: usize = 2048;
/// Maximum number of bytes that can be received by prover from server
pub const MAX_RECV_DATA: usize = 4096;

View File

@@ -1,280 +0,0 @@
mod axum_websocket;
mod config;
mod verifier;
use axum::{
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use axum_websocket::{WebSocket, WebSocketUpgrade};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex, oneshot};
use tokio::time::timeout;
use tokio_util::compat::FuturesAsyncReadCompatExt;
use tower_http::cors::CorsLayer;
use tracing::{error, info};
use tracing_subscriber;
use uuid::Uuid;
use ws_stream_tungstenite::WsStream;
use verifier::verifier;
#[tokio::main]
async fn main() {
// Initialize tracing with DEBUG level
tracing_subscriber::fmt()
.with_target(true)
.with_max_level(tracing::Level::INFO)
.with_thread_ids(true)
.with_line_number(true)
.init();
// Create application state with session storage
let app_state = Arc::new(AppState {
sessions: Arc::new(Mutex::new(HashMap::new())),
});
// Build router with routes
let app = Router::new()
.route("/health", get(health_handler))
.route("/session", post(create_session_handler))
.route("/verifier", get(verifier_ws_handler))
.layer(CorsLayer::permissive())
.with_state(app_state);
// Start server
let addr = SocketAddr::from(([0, 0, 0, 0], 7047));
info!("TLSNotary Verifier Server starting on {}", addr);
let listener = tokio::net::TcpListener::bind(addr)
.await
.expect("Failed to bind to address");
info!("Server listening on http://{}", addr);
info!("Health endpoint: http://{}/health", addr);
info!("Session endpoint: POST http://{}/session", addr);
info!("Verifier WebSocket endpoint: ws://{}/verifier?sessionId=<id>", addr);
axum::serve(listener, app)
.await
.expect("Server error");
}
// Session data structure
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SessionConfig {
#[serde(rename = "maxRecvData")]
max_recv_data: usize,
#[serde(rename = "maxSentData")]
max_sent_data: usize,
}
// Type alias for the WebSocket type that will be sent through the channel
type SocketSender = oneshot::Sender<WebSocket>;
// Application state for sharing data between handlers
#[derive(Clone)]
struct AppState {
sessions: Arc<Mutex<HashMap<String, SocketSender>>>,
}
// Request body for creating a session
#[derive(Debug, Deserialize)]
struct CreateSessionRequest {
#[serde(rename = "maxRecvData")]
max_recv_data: usize,
#[serde(rename = "maxSentData")]
max_sent_data: usize,
}
// Response body for session creation
#[derive(Debug, Serialize)]
struct CreateSessionResponse {
#[serde(rename = "sessionId")]
session_id: String,
}
// Query parameters for WebSocket connection
#[derive(Debug, Deserialize)]
struct VerifierQuery {
#[serde(rename = "sessionId")]
session_id: String,
}
// Health check endpoint handler
async fn health_handler() -> impl IntoResponse {
"ok"
}
// Create session endpoint handler
async fn create_session_handler(
State(state): State<Arc<AppState>>,
Json(payload): Json<CreateSessionRequest>,
) -> Result<Json<CreateSessionResponse>, (StatusCode, String)> {
// Generate a new session ID
let session_id = Uuid::new_v4().to_string();
let session_config = SessionConfig {
max_recv_data: payload.max_recv_data,
max_sent_data: payload.max_sent_data,
};
info!(
"Created session {} with maxRecvData={}, maxSentData={}",
session_id, payload.max_recv_data, payload.max_sent_data
);
// Create oneshot channel for passing WebSocket to verifier
let (socket_tx, socket_rx) = oneshot::channel::<WebSocket>();
// Store the socket sender
{
let mut sessions = state.sessions.lock().await;
sessions.insert(session_id.clone(), socket_tx);
}
// Spawn the verifier task immediately
let session_id_clone = session_id.clone();
let state_clone = state.clone();
tokio::spawn(async move {
run_verifier_task(session_id_clone, session_config, socket_rx, state_clone).await;
});
info!("[{}] Verifier task spawned, waiting for WebSocket connection", session_id);
Ok(Json(CreateSessionResponse { session_id }))
}
// WebSocket handler for verifier
async fn verifier_ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
Query(query): Query<VerifierQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let session_id = query.session_id;
// Look up the session and extract the socket sender
let socket_sender = {
let mut sessions = state.sessions.lock().await;
sessions.remove(&session_id)
};
match socket_sender {
Some(sender) => {
info!("[{}] WebSocket connection established, passing to verifier", session_id);
Ok(ws.on_upgrade(move |socket| async move {
// Send the WebSocket to the waiting verifier
if let Err(_) = sender.send(socket) {
error!("[{}] Failed to send socket to verifier - channel closed", session_id);
} else {
info!("[{}] Socket passed to verifier successfully", session_id);
}
}))
}
None => {
error!("[{}] Session not found or already connected", session_id);
Err((
StatusCode::NOT_FOUND,
format!("Session not found or already connected: {}", session_id),
))
}
}
}
// Verifier task that waits for WebSocket and runs verification
async fn run_verifier_task(
session_id: String,
config: SessionConfig,
socket_rx: oneshot::Receiver<WebSocket>,
state: Arc<AppState>,
) {
info!(
"[{}] Verifier task started, waiting for WebSocket connection...",
session_id
);
info!(
"[{}] Configuration: maxRecvData={}, maxSentData={}",
session_id, config.max_recv_data, config.max_sent_data
);
// Wait for WebSocket connection with timeout
let connection_timeout = Duration::from_secs(30);
let socket_result = timeout(connection_timeout, socket_rx).await;
let socket = match socket_result {
Ok(Ok(socket)) => {
info!("[{}] ✅ WebSocket received, starting verification", session_id);
socket
}
Ok(Err(_)) => {
error!("[{}] ❌ Socket channel closed before connection", session_id);
cleanup_session(&state, &session_id).await;
return;
}
Err(_) => {
error!(
"[{}] ⏱️ Timed out waiting for WebSocket connection after {:?}",
session_id, connection_timeout
);
cleanup_session(&state, &session_id).await;
return;
}
};
// Convert WebSocket to WsStream for AsyncRead/AsyncWrite compatibility
let stream = WsStream::new(socket.into_inner());
info!("[{}] WebSocket converted to stream", session_id);
// Convert from futures AsyncRead/AsyncWrite to tokio AsyncRead/AsyncWrite
let stream = stream.compat();
// Run the verifier with timeout
let verification_timeout = Duration::from_secs(120);
info!(
"[{}] Starting verification with timeout of {:?}",
session_id, verification_timeout
);
let verification_result = timeout(
verification_timeout,
verifier(stream, config.max_sent_data, config.max_recv_data),
)
.await;
// Handle the verification result
match verification_result {
Ok(Ok((sent_data, received_data))) => {
info!("[{}] ✅ Verification completed successfully!", session_id);
info!("[{}] Sent data length: {} bytes", session_id, sent_data.len());
info!("[{}] Received data length: {} bytes", session_id, received_data.len());
}
Ok(Err(e)) => {
error!("[{}] ❌ Verification failed: {}", session_id, e);
}
Err(_) => {
error!(
"[{}] ⏱️ Verification timed out after {:?}",
session_id, verification_timeout
);
}
}
// Clean up session (if it still exists in the map)
cleanup_session(&state, &session_id).await;
info!("[{}] Verifier task completed and cleaned up", session_id);
}
// Helper function to clean up session from state
async fn cleanup_session(state: &Arc<AppState>, session_id: &str) {
let mut sessions = state.sessions.lock().await;
if sessions.remove(session_id).is_some() {
info!("[{}] Session removed from state", session_id);
}
}

View File

@@ -1,87 +0,0 @@
use eyre::eyre;
use tlsn::{
config::ProtocolConfigValidator,
connection::ServerName,
verifier::{Verifier, VerifierConfig, VerifierOutput, VerifyConfig},
};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio_util::compat::TokioAsyncReadCompatExt;
use tracing::{debug, info};
/// Core verifier logic that validates the TLS proof
pub async fn verifier<T: AsyncWrite + AsyncRead + Send + Unpin + 'static>(
socket: T,
max_sent_data: usize,
max_recv_data: usize,
) -> Result<(String, String), eyre::ErrReport> {
info!("Starting verification with maxSentData={}, maxRecvData={}", max_sent_data, max_recv_data);
let config_validator = ProtocolConfigValidator::builder()
.max_sent_data(max_sent_data)
.max_recv_data(max_recv_data)
.build()
.unwrap();
info!("config_validator: {:?}", config_validator);
let verifier_config = VerifierConfig::builder()
.protocol_config_validator(config_validator)
.build()
.unwrap();
info!("verifier_config: {:?}", verifier_config);
let verifier = Verifier::new(verifier_config);
info!("✅ Created verifier");
// Receive authenticated data.
info!("🔄 Starting MPC-TLS verification...");
info!("⏳ BLOCKING: Waiting for prover to connect and send MPC-TLS handshake data...");
info!(" This will block until prover completes the TLS connection");
debug!("About to call verifier.verify() - this is the blocking point");
let VerifierOutput {
server_name,
transcript,
..
} = verifier
.verify(socket.compat(), &VerifyConfig::default())
.await
.map_err(|e| eyre!("Verification failed: {}", e))?;
info!("✅ verify() returned successfully - prover sent all data");
let server_name =
server_name.ok_or_else(|| eyre!("prover should have revealed server name"))?;
let transcript =
transcript.ok_or_else(|| eyre!("prover should have revealed transcript data"))?;
info!("server_name: {:?}", server_name);
info!("transcript: {:?}", transcript);
// Extract sent and received data
info!("Extracting transcript data...");
let sent = transcript.sent_unsafe().to_vec();
let received = transcript.received_unsafe().to_vec();
// Check Session info: server name.
let ServerName::Dns(dns_name) = server_name;
info!("Server name verified: {:?}", dns_name);
let sent_string = bytes_to_redacted_string(&sent)?;
let received_string = bytes_to_redacted_string(&received)?;
info!("============================================");
info!("Verification successful!");
info!("============================================");
info!("Sent data: {:?}", sent_string);
info!("Received data: {:?}", received_string);
Ok((sent_string, received_string))
}
/// Render redacted bytes as `🙈`.
fn bytes_to_redacted_string(bytes: &[u8]) -> Result<String, eyre::ErrReport> {
Ok(String::from_utf8(bytes.to_vec())
.map_err(|err| eyre!("Failed to parse bytes to redacted string: {err}"))?
.replace('\0', "🙈"))
}

1
plugins/README.md Normal file
View File

@@ -0,0 +1 @@
You can find example plugins at https://github.com/tlsnotary/tlsn-plugin-boilerplate/tree/main/examples

8639
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 896 B

After

Width:  |  Height:  |  Size: 896 B

View File

@@ -0,0 +1,70 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import {
useActiveTabUrl,
setConnection,
useIsConnected,
} from '../../reducers/requests';
import Modal, { ModalHeader, ModalContent } from '../../components/Modal/Modal';
import { deleteConnection, getConnection } from '../../entries/Background/db';
const ConnectionDetailsModal = (props: {
showConnectionDetails: boolean;
setShowConnectionDetails: (show: boolean) => void;
}) => {
const dispatch = useDispatch();
const activeTabOrigin = useActiveTabUrl();
const connected = useIsConnected();
useEffect(() => {
(async () => {
if (activeTabOrigin) {
const isConnected: boolean | null = await getConnection(
activeTabOrigin.origin,
);
dispatch(setConnection(!!isConnected));
}
})();
}, [activeTabOrigin, dispatch]);
const handleDisconnect = useCallback(async () => {
if (activeTabOrigin?.origin) {
await deleteConnection(activeTabOrigin.origin);
props.setShowConnectionDetails(false);
dispatch(setConnection(false));
}
}, [activeTabOrigin?.origin, dispatch, props]);
return (
<Modal
onClose={() => props.setShowConnectionDetails(false)}
className="flex flex-col gap-2 items-center text-base cursor-default justify-center mx-4 min-h-24"
>
<ModalHeader
className="w-full rounded-t-lg pb-0 border-b-0"
onClose={() => props.setShowConnectionDetails(false)}
>
<span className="text-lg font-semibold">
{activeTabOrigin?.hostname || 'Connections'}
</span>
</ModalHeader>
<ModalContent className="w-full gap-2 flex-grow flex flex-col items-center justify-between px-4 pt-0 pb-4">
<div className="flex flex-row gap-2 items-start w-full text-xs font-semibold text-slate-800">
{connected
? 'TLSN Extension is connected to this site.'
: 'TLSN Extension is not connected to this site. To connect to this site, find and click the connect button.'}
</div>
{connected && (
<button
className="button disabled:opacity-50 self-end"
onClick={handleDisconnect}
>
Disconnect
</button>
)}
</ModalContent>
</Modal>
);
};
export default ConnectionDetailsModal;

View File

@@ -0,0 +1,26 @@
import React, { ReactElement } from 'react';
import Modal, { ModalContent } from '../Modal/Modal';
export function ErrorModal(props: {
onClose: () => void;
message: string;
}): ReactElement {
const { onClose, message } = props;
return (
<Modal
className="flex flex-col gap-4 items-center text-base cursor-default justify-center !w-auto mx-4 my-[50%] min-h-24 p-4 border border-red-500 !bg-red-100"
onClose={onClose}
>
<ModalContent className="flex justify-center items-center text-red-500">
{message || 'Something went wrong :('}
</ModalContent>
<button
className="m-0 w-24 bg-red-200 text-red-400 hover:bg-red-200 hover:text-red-500"
onClick={onClose}
>
OK
</button>
</Modal>
);
}

View File

@@ -0,0 +1,5 @@
.icon {
display: flex;
flex-flow: row nowrap;
align-items: center;
}

View File

@@ -0,0 +1,37 @@
import React, { MouseEventHandler, ReactElement, ReactNode } from 'react';
import classNames from 'classnames';
import './icon.scss';
type Props = {
url?: string;
fa?: string;
className?: string;
size?: number;
onClick?: MouseEventHandler;
children?: ReactNode;
};
export default function Icon(props: Props): ReactElement {
const { url, size = 1, className = '', fa, onClick, children } = props;
return (
<div
className={classNames(
'bg-contain bg-center bg-no-repeat icon',
{
'cursor-pointer': onClick,
},
className,
)}
style={{
backgroundImage: url ? `url(${url})` : undefined,
width: !fa ? `${size}rem` : undefined,
height: !fa ? `${size}rem` : undefined,
}}
onClick={onClick}
>
{!url && !!fa && <i className={fa} style={{ fontSize: `${size}rem` }} />}
{children}
</div>
);
}

View File

@@ -0,0 +1,100 @@
import React, { MouseEventHandler, ReactElement, ReactNode } from 'react';
import ReactDOM from 'react-dom';
import './modal.scss';
import Icon from '../Icon';
import classNames from 'classnames';
type Props = {
className?: string;
onClose: MouseEventHandler;
children: ReactNode | ReactNode[];
};
export default function Modal(props: Props): ReactElement {
const { className, onClose, children } = props;
const modalRoot = document.querySelector('#modal-root');
if (!modalRoot) return <></>;
return ReactDOM.createPortal(
<div
className={classNames('bg-black bg-opacity-80', 'modal__overlay')}
onClick={(e) => {
e.stopPropagation();
onClose && onClose(e);
}}
>
<div
className={classNames(`modal__wrapper bg-white`, className)}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>,
modalRoot,
);
}
type HeaderProps = {
className?: string;
onClose?: () => void;
children?: ReactNode;
};
export function ModalHeader(props: HeaderProps): ReactElement {
return (
<div
className={classNames(
'border-b modal__header border-gray-100',
props.className,
)}
>
<div className="modal__header__title">{props.children}</div>
<div className="modal__header__content">
{props.onClose && (
<div
className={classNames(
'flex flex-row items-center justify-center',
'p-2 rounded-full opacity-50',
'hover:opacity-100 text-black',
)}
>
<Icon fa="fas fa-times" size={1} onClick={props.onClose} />
</div>
)}
</div>
</div>
);
}
type ContentProps = {
children: ReactNode;
className?: string;
};
export function ModalContent(props: ContentProps): ReactElement {
return (
<div className={classNames('modal__content', props.className)}>
{props.children}
</div>
);
}
type FooterProps = {
children: ReactNode;
className?: string;
};
export function ModalFooter(props: FooterProps): ReactElement {
return (
<div
className={classNames(
'border-t modal__footer border-gray-100 w-full',
props.className,
)}
>
{props.children}
</div>
);
}

View File

@@ -0,0 +1,72 @@
.modal {
display: flex;
flex-flow: column nowrap;
&__overlay {
position: fixed;
width: 100vw;
height: 100vh;
top: 0;
left: 0;
z-index: 9999;
overflow: auto;
}
&__wrapper {
margin: 3rem auto;
border-radius: 0.5rem;
z-index: 200;
overflow: hidden;
@media only screen and (max-width: 768px) {
width: 100vw !important;
}
}
&__header {
display: flex;
flex-flow: row nowrap;
flex: 0 0 auto;
align-items: center;
padding: 0.5rem 1rem;
&__title {
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
&__content {
display: flex;
flex-flow: row nowrap;
flex: 1 1 auto;
justify-content: flex-end;
}
}
&__content {
flex: 1 1 auto;
max-height: calc(100vh - 20rem);
overflow-y: auto;
p:nth-of-type(1) {
margin-top: 0;
}
.error-message {
font-size: 0.8125rem;
text-align: center;
margin-top: 1rem;
}
}
&__footer {
display: flex;
flex-flow: row nowrap;
align-content: center;
justify-content: flex-end;
flex: 0 0 auto;
padding: 1rem 1.25rem;
}
}

View File

@@ -0,0 +1,10 @@
import React from 'react';
import { ReactElement } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
export default function NavigateWithParams(props: {
to: string;
}): ReactElement {
const location = useLocation();
return <Navigate to={location.pathname + props.to} />;
}

View File

@@ -0,0 +1,20 @@
.custom-modal {
height: 100%;
max-width: 800px;
max-height: 100vh;
display: flex;
margin: 0 auto;
flex-direction: column;
}
.custom-modal-content {
flex-grow: 2;
overflow-y: auto;
max-height: 90%;
}
.modal__overlay {
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,207 @@
import React, {
ChangeEvent,
Children,
MouseEventHandler,
ReactElement,
ReactNode,
useCallback,
useState,
} from 'react';
import { makePlugin, getPluginConfig } from '../../utils/misc';
import { addPlugin } from '../../utils/rpc';
import Modal, {
ModalHeader,
ModalContent,
ModalFooter,
} from '../../components/Modal/Modal';
import type { PluginConfig } from '../../utils/misc';
import './index.scss';
import logo from '../../assets/img/icon-128.png';
import {
HostFunctionsDescriptions,
MultipleParts,
PermissionDescription,
} from '../../utils/plugins';
import { ErrorModal } from '../ErrorModal';
import classNames from 'classnames';
export default function PluginUploadInfo(): ReactElement {
const [error, showError] = useState('');
const [pluginBuffer, setPluginBuffer] = useState<ArrayBuffer | any>(null);
const [pluginContent, setPluginContent] = useState<PluginConfig | null>(null);
const onAddPlugin = useCallback(
async (evt: React.MouseEvent<HTMLButtonElement>) => {
try {
await addPlugin(Buffer.from(pluginBuffer).toString('hex'));
setPluginContent(null);
} catch (e: any) {
showError(e?.message || 'Invalid Plugin');
}
},
[pluginContent, pluginBuffer],
);
const onPluginInfo = useCallback(
async (evt: ChangeEvent<HTMLInputElement>) => {
if (!evt.target.files) return;
try {
const [file] = evt.target.files;
const arrayBuffer = await file.arrayBuffer();
const plugin = await makePlugin(arrayBuffer);
setPluginContent(await getPluginConfig(plugin));
setPluginBuffer(arrayBuffer);
} catch (e: any) {
showError(e?.message || 'Invalid Plugin');
} finally {
evt.target.value = '';
}
},
[setPluginContent, setPluginBuffer],
);
const onClose = useCallback(() => {
setPluginContent(null);
setPluginBuffer(null);
}, []);
return (
<>
<input
className="opacity-0 absolute top-0 right-0 h-full w-full cursor-pointer"
type="file"
onChange={onPluginInfo}
/>
{error && <ErrorModal onClose={() => showError('')} message={error} />}
{pluginContent && (
<PluginInfoModal
pluginContent={pluginContent}
onClose={onClose}
onAddPlugin={onAddPlugin}
/>
)}
</>
);
}
export function PluginInfoModalHeader(props: {
className?: string;
children: ReactNode | ReactNode[];
}) {
return <div className={props.className}>{props.children}</div>;
}
export function PluginInfoModalContent(props: {
className?: string;
children: ReactNode | ReactNode[];
}) {
return <div className={props.className}>{props.children}</div>;
}
export function PluginInfoModal(props: {
pluginContent: PluginConfig;
onClose: () => void;
onAddPlugin?: MouseEventHandler;
children?: ReactNode | ReactNode[];
}) {
const { pluginContent, onClose, onAddPlugin, children } = props;
const header = Children.toArray(children).filter(
(c: any) => c.type.name === 'PluginInfoModalHeader',
)[0];
const content = Children.toArray(children).filter(
(c: any) => c.type.name === 'PluginInfoModalContent',
)[0];
return (
<Modal
onClose={onClose}
className="custom-modal !rounded-none flex items-center justify-center gap-4 cursor-default"
>
<ModalHeader className="w-full p-2 border-gray-200 text-gray-500">
{header || (
<div className="flex flex-row items-end justify-start gap-2">
<img className="h-5" src={logo} alt="logo" />
<span className="font-semibold">{`Installing ${pluginContent.title}`}</span>
</div>
)}
</ModalHeader>
<ModalContent className="flex flex-col flex-grow-0 flex-shrink-0 items-center px-8 py-2 gap-2 w-full max-h-none">
{content || (
<>
<img
className="w-12 h-12"
src={pluginContent.icon}
alt="Plugin Icon"
/>
<span className="text-3xl text-center">
<span>
<span className="text-blue-600 font-semibold">
{pluginContent.title}
</span>{' '}
wants access to your browser
</span>
</span>
</>
)}
</ModalContent>
<div className="flex-grow flex-shrink overflow-y-auto w-full px-8">
<PluginPermissions pluginContent={pluginContent} />
</div>
<ModalFooter className="flex justify-end gap-2 p-4">
<button className="button" onClick={onClose}>
Cancel
</button>
{onAddPlugin && (
<button className="button button--primary" onClick={onAddPlugin}>
Allow
</button>
)}
</ModalFooter>
</Modal>
);
}
export function PluginPermissions({
pluginContent,
className,
}: {
pluginContent: PluginConfig;
className?: string;
}) {
return (
<div className={classNames('flex flex-col p-2 gap-5', className)}>
{pluginContent.hostFunctions?.map((hostFunction: string) => {
const HFComponent = HostFunctionsDescriptions[hostFunction];
return <HFComponent key={hostFunction} {...pluginContent} />;
})}
{pluginContent.cookies && (
<PermissionDescription fa="fa-solid fa-cookie-bite">
<span className="cursor-default">
<span className="mr-1">Access cookies from</span>
<MultipleParts parts={pluginContent.cookies} />
</span>
</PermissionDescription>
)}
{pluginContent.headers && (
<PermissionDescription fa="fa-solid fa-envelope">
<span className="cursor-default">
<span className="mr-1">Access headers from</span>
<MultipleParts parts={pluginContent.headers} />
</span>
</PermissionDescription>
)}
{pluginContent.requests && (
<PermissionDescription fa="fa-solid fa-globe">
<span className="cursor-default">
<span className="mr-1">Submit network requests to</span>
<MultipleParts
parts={pluginContent?.requests.map(({ url }) => url)}
/>
</span>
</PermissionDescription>
)}
</div>
);
}

View File

@@ -0,0 +1,45 @@
.plugin-box {
&__remove-icon {
opacity: 0;
height: 0;
width: 0;
padding: 0;
overflow: hidden;
transition: 200ms opacity;
}
&:hover {
.plugin-box__remove-icon {
height: 1.25rem;
width: 1.25rem;
padding: .5rem;
opacity: .5;
&:hover {
opacity: 1;
}
}
}
}
.custom-modal {
width: 100vw;
height: 100vh;
max-width: 800px;
max-height: 90vh;
display: flex;
margin: 1rem auto;
flex-direction: column;
}
.custom-modal-content {
flex-grow: 2;
overflow-y: auto;
max-height: 90%;
}
.modal__overlay {
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,217 @@
import React, {
MouseEventHandler,
ReactElement,
useCallback,
useEffect,
useState,
} from 'react';
import {
fetchPluginHashes,
removePlugin,
fetchPluginConfigByHash,
runPlugin,
} from '../../utils/rpc';
import { usePluginHashes } from '../../reducers/plugins';
import { PluginConfig } from '../../utils/misc';
import DefaultPluginIcon from '../../assets/img/default-plugin-icon.png';
import classNames from 'classnames';
import Icon from '../Icon';
import './index.scss';
import browser from 'webextension-polyfill';
import { ErrorModal } from '../ErrorModal';
import {
PluginInfoModal,
PluginInfoModalContent,
PluginInfoModalHeader,
} from '../PluginInfo';
import { getPluginConfigByHash } from '../../entries/Background/db';
export function PluginList(props: { className?: string }): ReactElement {
const hashes = usePluginHashes();
useEffect(() => {
fetchPluginHashes();
}, []);
return (
<div
className={classNames('flex flex-col flex-nowrap gap-1', props.className)}
>
{!hashes.length && (
<div className="flex flex-col items-center justify-center text-slate-400 cursor-default select-none">
<div>No available plugins</div>
</div>
)}
{hashes.map((hash) => (
<Plugin key={hash} hash={hash} />
))}
</div>
);
}
export function Plugin(props: {
hash: string;
onClick?: () => void;
}): ReactElement {
const [error, showError] = useState('');
const [config, setConfig] = useState<PluginConfig | null>(null);
const [pluginInfo, showPluginInfo] = useState(false);
const [remove, showRemove] = useState(false);
const onClick = useCallback(async () => {
if (!config || remove) return;
try {
await runPlugin(props.hash, 'start');
const [tab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
await browser.storage.local.set({ plugin_hash: props.hash });
// @ts-ignore
if (chrome.sidePanel) await chrome.sidePanel.open({ tabId: tab.id });
window.close();
} catch (e: any) {
showError(e.message);
}
}, [props.hash, config, remove]);
useEffect(() => {
(async function () {
setConfig(await getPluginConfigByHash(props.hash));
})();
}, [props.hash]);
const onRemove: MouseEventHandler = useCallback(
(e) => {
e.stopPropagation();
removePlugin(props.hash);
showRemove(false);
},
[props.hash, remove],
);
const onConfirmRemove: MouseEventHandler = useCallback(
(e) => {
e.stopPropagation();
showRemove(true);
},
[props.hash, remove],
);
const onPluginInfo: MouseEventHandler = useCallback(
(e) => {
e.stopPropagation();
showPluginInfo(true);
},
[props.hash, pluginInfo],
);
if (!config) return <></>;
return (
<div
className={classNames(
'flex flex-row justify-center border rounded border-slate-300 p-2 gap-2 plugin-box',
'cursor-pointer hover:bg-slate-100 hover:border-slate-400 active:bg-slate-200',
)}
onClick={onClick}
>
{!!error && <ErrorModal onClose={() => showError('')} message={error} />}
{!remove ? (
<div className="flex flex-row w-full gap-2">
<img className="w-12 h-12" src={config.icon || DefaultPluginIcon} />
<div className="flex flex-col w-full items-start">
<div className="font-bold flex flex-row h-6 items-center justify-between w-full">
{config.title}
<div className="flex flex-row items-center justify-center">
<Icon
fa="fa-solid fa-circle-info"
className="flex flex-row items-center justify-center cursor-pointer plugin-box__remove-icon"
onClick={onPluginInfo}
/>
<Icon
fa="fa-solid fa-xmark"
className="flex flex-row items-center justify-center cursor-pointer text-red-500 bg-red-200 rounded-full plugin-box__remove-icon"
onClick={onConfirmRemove}
/>
</div>
</div>
<div>{config.description}</div>
</div>
</div>
) : (
<RemovePlugin
onRemove={onRemove}
showRemove={showRemove}
config={config}
/>
)}
{pluginInfo && (
<PluginInfoModal
pluginContent={config}
onClose={() => showPluginInfo(false)}
>
<PluginInfoModalHeader>
<div className="flex flex-row items-end justify-start gap-2">
<Icon
className="text-slate-500 hover:text-slate-700 cursor-pointer"
size={1}
fa="fa-solid fa-caret-left"
onClick={() => showPluginInfo(false)}
/>
</div>
</PluginInfoModalHeader>
<PluginInfoModalContent className="flex flex-col items-center cursor-default">
<img
className="w-12 h-12 mb-2"
src={config.icon}
alt="Plugin Icon"
/>
<span className="text-3xl text-blue-600 font-semibold">
{config.title}
</span>
<div className="text-slate-500 text-lg">{config.description}</div>
</PluginInfoModalContent>
</PluginInfoModal>
)}
</div>
);
}
function RemovePlugin(props: {
onRemove: MouseEventHandler;
showRemove: (show: boolean) => void;
config: PluginConfig;
}): ReactElement {
const { onRemove, showRemove, config } = props;
const onCancel: MouseEventHandler = useCallback((e) => {
e.stopPropagation();
showRemove(false);
}, []);
return (
<div className="flex flex-col items-center w-full gap-1">
<div className="font-bold text-red-700">
{`Are you sure you want to remove "${config.title}" plugin?`}
</div>
<div className="mb-1">Warning: this cannot be undone.</div>
<div className="flex flex-row w-full gap-1">
<button className="flex-grow button p-1" onClick={onCancel}>
Cancel
</button>
<button
className="flex-grow font-bold bg-red-500 hover:bg-red-600 text-white rounded p-1"
onClick={onRemove}
>
Remove
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,529 @@
import React, {
ReactElement,
ReactNode,
useCallback,
useEffect,
useState,
} from 'react';
import { notarizeRequest, useRequest } from '../../reducers/requests';
import classNames from 'classnames';
import { useDispatch } from 'react-redux';
import {
Route,
Routes,
useLocation,
useNavigate,
useParams,
} from 'react-router';
import Icon from '../Icon';
import NavigateWithParams from '../NavigateWithParams';
import {
set,
get,
MAX_SENT_LS_KEY,
MAX_RECEIVED_LS_KEY,
getMaxRecv,
getMaxSent,
} from '../../utils/storage';
import { MAX_RECV, MAX_SENT } from '../../utils/constants';
import { urlify } from '../../utils/misc';
type Props = {
requestId: string;
};
export default function RequestDetail(props: Props): ReactElement {
const request = useRequest(props.requestId);
const navigate = useNavigate();
const notarize = useCallback(async () => {
if (!request) return;
navigate('/notary/' + request.requestId);
}, [request, props.requestId]);
if (!request) return <></>;
return (
<>
<div className="flex flex-row flex-nowrap relative items-center bg-slate-300 py-2 px-2 gap-2">
<Icon
className="cursor-point text-slate-400 hover:text-slate-700"
fa="fa-solid fa-xmark"
onClick={() => navigate('/requests')}
/>
<RequestDetailsHeaderTab path="/headers">
Headers
</RequestDetailsHeaderTab>
<RequestDetailsHeaderTab path="/payloads">
Payload
</RequestDetailsHeaderTab>
<RequestDetailsHeaderTab path="/response">
Response
</RequestDetailsHeaderTab>
<RequestDetailsHeaderTab path="/advanced">
Advanced
</RequestDetailsHeaderTab>
<button
className="absolute right-2 bg-primary/[0.9] text-white font-bold px-2 py-0.5 hover:bg-primary/[0.8] active:bg-primary"
onClick={notarize}
>
Notarize
</button>
</div>
<Routes>
<Route
path="headers"
element={<RequestHeaders requestId={props.requestId} />}
/>
<Route
path="payloads"
element={<RequestPayload requestId={props.requestId} />}
/>
<Route
path="response"
element={<WebResponse requestId={props.requestId} />}
/>
<Route path="advanced" element={<AdvancedOptions />} />
<Route path="/" element={<NavigateWithParams to="/headers" />} />
</Routes>
</>
);
}
function RequestDetailsHeaderTab(props: {
children: ReactNode;
path: string;
}): ReactElement {
const loc = useLocation();
const params = useParams<{ requestId: string }>();
const navigate = useNavigate();
const selected = loc.pathname.includes(props.path);
return (
<div
className={classNames('font-bold', {
'text-slate-700 cursor-default': selected,
'text-slate-400 hover:text-slate-500 cursor-pointer': !selected,
})}
onClick={() => navigate('/requests/' + params.requestId + props.path)}
>
{props.children}
</div>
);
}
function AdvancedOptions(): ReactElement {
const [maxSent, setMaxSent] = useState(MAX_SENT);
const [maxRecv, setMaxRecv] = useState(MAX_RECV);
const [dirty, setDirty] = useState(false);
useEffect(() => {
(async () => {
setMaxRecv((await getMaxRecv()) || MAX_RECV);
setMaxSent((await getMaxSent()) || MAX_SENT);
})();
}, []);
const onSave = useCallback(async () => {
await set(MAX_RECEIVED_LS_KEY, maxRecv.toString());
await set(MAX_SENT_LS_KEY, maxSent.toString());
setDirty(false);
}, [maxSent, maxRecv]);
return (
<div className="flex flex-col flex-nowrap py-1 px-2 gap-2">
<div className="font-semibold">Max Sent Data</div>
<input
type="number"
className="input border"
value={maxSent}
min={0}
onChange={(e) => {
setMaxSent(parseInt(e.target.value));
setDirty(true);
}}
/>
<div className="font-semibold">Max Received Data</div>
<input
type="number"
className="input border"
value={maxRecv}
min={0}
onChange={(e) => {
setMaxRecv(parseInt(e.target.value));
setDirty(true);
}}
/>
<div className="flex flex-row flex-nowrap justify-end gap-2 p-2">
<button
className="button !bg-primary/[0.9] hover:bg-primary/[0.8] active:bg-primary !text-white"
disabled={!dirty}
onClick={onSave}
>
Save
</button>
</div>
</div>
);
}
function RequestPayload(props: Props): ReactElement {
const data = useRequest(props.requestId);
const [url, setUrl] = useState<URL | null>();
const [json, setJson] = useState<any | null>();
const [formData, setFormData] = useState<URLSearchParams | null>(null);
useEffect(() => {
if (data?.formData) {
const params = new URLSearchParams();
Object.entries(data.formData).forEach(([key, values]) => {
values.forEach((v) => params.append(key, v));
});
setFormData(params);
}
}, [data?.formData]);
useEffect(() => {
try {
setUrl(new URL(data!.url));
} catch (e) {}
try {
if (data?.requestBody) {
setJson(JSON.parse(data.requestBody));
}
} catch (e) {
console.error(e);
setJson(null);
}
}, [data]);
return (
<div className="flex flex-col flex-nowrap overflow-y-auto">
<table className="border border-slate-300 border-collapse table-fixed w-full">
{!!url?.searchParams.size && (
<>
<thead className="bg-slate-200">
<tr>
<td colSpan={2} className="border border-slate-300 py-1 px-2">
Query String Parameters
</td>
</tr>
</thead>
<tbody>
{Array.from(url.searchParams).map((param) => {
return (
<tr key={param[0]} className="border-b border-slate-200">
<td className="border border-slate-300 font-bold align-top py-1 px-2 break-all">
{param[0]}
</td>
<td className="border border-slate-300 break-all align-top py-1 px-2 break-all">
{param[1]}
</td>
</tr>
);
})}
</tbody>
</>
)}
{!!json && (
<>
<thead className="bg-slate-200">
<tr>
<td colSpan={2} className="border border-slate-300 py-1 px-2">
Body Payload
</td>
</tr>
</thead>
<tr>
<td colSpan={2}>
<textarea
rows={10}
className="w-full bg-slate-100 text-slate-600 p-2 text-xs break-all h-full outline-none font-mono"
value={JSON.stringify(json, null, 2)}
></textarea>
</td>
</tr>
</>
)}
{!!formData && (
<>
<thead className="bg-slate-200">
<tr>
<td colSpan={2} className="border border-slate-300 py-1 px-2">
Form Data
</td>
</tr>
</thead>
<tr>
<td colSpan={2}>
<textarea
rows={10}
className="w-full bg-slate-100 text-slate-600 p-2 text-xs break-all h-full outline-none font-mono"
value={formData.toString()}
></textarea>
</td>
</tr>
</>
)}
{!json && !!data?.requestBody && (
<>
<thead className="bg-slate-200">
<tr>
<td colSpan={2} className="border border-slate-300 py-1 px-2">
Body
</td>
</tr>
</thead>
<tr>
<td colSpan={2}>
<textarea
rows={6}
className="w-full bg-slate-100 text-slate-600 p-2 text-xs break-all h-full outline-none font-mono"
value={data?.requestBody}
></textarea>
</td>
</tr>
</>
)}
</table>
</div>
);
}
function WebResponse(props: Props): ReactElement {
const data = useRequest(props.requestId);
const [response, setResponse] = useState<Response | null>(null);
const [json, setJSON] = useState<any | null>(null);
const [text, setText] = useState<string | null>(null);
const [img, setImg] = useState<string | null>(null);
const [formData, setFormData] = useState<URLSearchParams | null>(null);
useEffect(() => {
if (data?.formData) {
const params = new URLSearchParams();
Object.entries(data.formData).forEach(([key, values]) => {
values.forEach((v) => params.append(key, v));
});
setFormData(params);
}
}, [data?.formData]);
const replay = useCallback(async () => {
if (!data) return null;
const options = {
method: data.method,
headers: data.requestHeaders.reduce(
// @ts-ignore
(acc: { [key: string]: string }, h: chrome.webRequest.HttpHeader) => {
if (typeof h.name !== 'undefined' && typeof h.value !== 'undefined') {
acc[h.name] = h.value;
}
return acc;
},
{},
),
body: data?.requestBody,
};
if (formData) {
options.body = formData.toString();
}
// @ts-ignore
const resp = await fetch(data.url, options);
setResponse(resp);
const contentType =
resp?.headers.get('content-type') || resp?.headers.get('Content-Type');
if (contentType?.includes('application/json')) {
resp.json().then((json) => {
if (json) {
setJSON(json);
}
});
} else if (contentType?.includes('text')) {
resp.text().then((_text) => {
if (_text) {
setText(_text);
}
});
} else if (contentType?.includes('image')) {
resp.blob().then((blob) => {
if (blob) {
setImg(URL.createObjectURL(blob));
}
});
} else {
resp
.blob()
.then((blob) => blob.text())
.then((_text) => {
if (_text) {
setText(_text);
}
});
}
}, [data, formData]);
return (
<div className="flex flex-col flex-nowrap overflow-y-auto">
{!response && (
<div className="p-2">
<button className="button" onClick={replay}>
Fetch Response
</button>
</div>
)}
<table className="border border-slate-300 border-collapse table-fixed w-full">
{!!response?.headers && (
<>
<thead className="bg-slate-200">
<tr>
<td colSpan={2} className="border border-slate-300 py-1 px-2">
Headers
</td>
</tr>
</thead>
<tbody>
{Array.from(response.headers.entries()).map(([name, value]) => {
return (
<tr className="border-b border-slate-200">
<td className="border border-slate-300 font-bold align-top py-1 px-2 whitespace-nowrap">
{name}
</td>
<td className="border border-slate-300 break-all align-top py-1 px-2">
{value}
</td>
</tr>
);
})}
</tbody>
</>
)}
{!!json && (
<>
<thead className="bg-slate-200">
<tr>
<td colSpan={2} className="border border-slate-300 py-1 px-2">
JSON
</td>
</tr>
</thead>
<tr>
<td colSpan={2}>
<textarea
rows={16}
className="w-full bg-slate-100 text-slate-600 p-2 text-xs break-all h-full outline-none font-mono"
value={JSON.stringify(json, null, 2)}
></textarea>
</td>
</tr>
</>
)}
{!!text && (
<>
<thead className="bg-slate-200">
<tr>
<td colSpan={2} className="border border-slate-300 py-1 px-2">
Text
</td>
</tr>
</thead>
<tr>
<td colSpan={2}>
<textarea
rows={16}
className="w-full bg-slate-100 text-slate-600 p-2 text-xs break-all h-full outline-none font-mono"
value={text}
></textarea>
</td>
</tr>
</>
)}
{!!img && (
<>
<thead className="bg-slate-200">
<tr>
<td colSpan={2} className="border border-slate-300 py-1 px-2">
Img
</td>
</tr>
</thead>
<tr>
<td className="bg-slate-100" colSpan={2}>
<img src={img} />
</td>
</tr>
</>
)}
</table>
</div>
);
}
function RequestHeaders(props: Props): ReactElement {
const data = useRequest(props.requestId);
return (
<div className="flex flex-col flex-nowrap overflow-y-auto">
<table className="border border-slate-300 border-collapse table-fixed">
<thead className="bg-slate-200">
<tr>
<td colSpan={2} className="border border-slate-300 py-1 px-2">
General
</td>
</tr>
</thead>
<tbody>
<tr className="border-b border-slate-200">
<td className="border border-slate-300 font-bold align-top py-1 px-2 whitespace-nowrap">
Method
</td>
<td className="border border-slate-300 break-all align-top py-1 px-2">
{data?.method}
</td>
</tr>
<tr className="border-b border-slate-200">
<td className="border border-slate-300 font-bold align-top py-1 px-2 whitespace-nowrap">
Type
</td>
<td className="border border-slate-300 break-all align-top py-1 px-2">
{data?.type}
</td>
</tr>
<tr className="border-b border-slate-200">
<td className="border border-slate-300 font-bold align-top py-1 px-2 whitespace-nowrap">
URL
</td>
<td className="border border-slate-300 break-all align-top py-1 px-2">
{data?.url}
</td>
</tr>
</tbody>
<thead className="bg-slate-200">
<tr>
<td colSpan={2} className="border border-slate-300 py-1 px-2">
Headers
</td>
</tr>
</thead>
<tbody className="">
{data?.requestHeaders?.map((h) => (
<tr key={h.name} className="border-b border-slate-200">
<td className="border border-slate-300 font-bold align-top py-1 px-2 whitespace-nowrap">
{h.name}
</td>
<td className="border border-slate-300 break-all align-top py-1 px-2">
{h.value}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More