Compare commits

...

59 Commits

Author SHA1 Message Date
tsukino
bf93e9e1c3 Fix formatting in PLUGIN.md 2025-10-28 03:09:11 +08:00
tsukino
3c58052a54 Update GitHub CI to test, lint, and build extension and plugin-sdk packages 2025-10-28 03:07:58 +08:00
tsukino
c248a9210c Add comprehensive PLUGIN.md documentation for plugin system architecture, capabilities, and examples 2025-10-28 03:05:33 +08:00
tsukino
cf5d7fc885 Fix all linter errors in extension: add missing imports, fix empty functions, and declare plugin DSL globals
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 17:15:00 +08:00
tsukino
810643a831 Fix npm run lint in extension: add TypeScript to root, create tlsn-wasm-pkg symlink, and fix linting issues
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 13:40:00 +08:00
tsukino
c7556b8632 Fix failing WindowManager tests: add browser.windows.remove mock, include requests in showOverlay, and update overlay on request add
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 10:25:00 +08:00
tsukino
d70bf8f432 Add comprehensive README with monorepo structure, build instructions, and E2E testing guide
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 10:15:00 +08:00
tsukino
434ccfe621 Remove popup UI and add Developer Console context menu with React page 2025-10-23 16:25:00 +08:00
tsukino
cdb814a2cf Add window closing capability with CLOSE_WINDOW message and auto-close on done 2025-10-22 09:40:00 +08:00
tsukino
6e4a5a07c2 wip 2025-10-21 14:50:00 +08:00
tsukino
16c6119416 Refactor verifier to spawn on session creation and fix header length overflow in prover 2025-10-20 11:05:00 +08:00
tsukino
d422140fbf wip 2025-10-17 13:35:00 +08:00
tsukino
3cc36e3e08 wip 2025-10-16 16:10:00 +08:00
tsukino
dba209ae57 wip 2025-10-15 10:50:00 +08:00
tsukino
09765bb9cc wip 2025-10-14 15:20:00 +08:00
tsukino
df99ba3d9c wip 2025-10-13 09:15:00 +08:00
tsukino
ed17168093 wip 2025-10-10 11:30:00 +08:00
tsukino
2c9e88c296 Replace TypeScript verifier-server with Rust implementation using Axum and WebSocket support
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 14:45:00 +08:00
tsukino
a283e1d14d wip 2025-10-08 10:23:00 +08:00
tsukino
330fb4ab99 Add tlsn-js integration and move SessionManager to offscreen context
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 19:01:57 +08:00
tsukino
fcaa8dbbcd Refactor hook tracking to use per-function context and add plugin config support
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 16:39:34 +08:00
tsukino
39d87e3252 Add plugin UI rendering system with DOM JSON to HTML conversion and click event handlers
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 04:41:18 +08:00
tsukino
392f9d2e40 Add DOM JSON API with overlay, div, and button builders for plugin UI rendering
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 03:36:29 +08:00
tsukino
deaa8d42c9 Add useHeaders hook with HTTP request header interception support
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 02:54:59 +08:00
tsukino
720764cc59 Add useRequests hook with request interception and auto re-execution on new requests
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 02:37:16 +08:00
tsukino
3ceddb1753 Add useEffect hook implementation with dependency tracking for plugin sessions
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 01:45:04 +08:00
tsukino
536ec91689 Rename evalCode to eval and add error handling for sandbox execution
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 00:20:35 +08:00
tsukino
528fefdeb8 Implement persistent QuickJS sandbox by keeping runSandboxed callback alive until dispose
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-03 00:13:48 +08:00
tsukino
44e22cfc94 Fix QuickJS sandbox lifecycle by removing createSandbox and using one-shot execution
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-02 23:57:05 +08:00
tsukino
c019b2e2ba Refactor SessionManager to track plugin sessions with UUID and link opened windows
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-02 23:20:13 +08:00
tsukino
c087b14d21 Refactor SessionManager to move openWindow after executePlugin and update test example
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-02 23:03:05 +08:00
tsukino
0a156fec85 Enable SessionManager in browser with WASM support and remove open/sendMessage from client API
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-02 22:59:27 +08:00
tsukino
ffeee2c539 fix test page 2025-10-02 22:41:14 +08:00
tsukino
8ab08db9b5 remove warning 2025-10-02 22:35:02 +08:00
tsukino
ff4c17bda2 make extension work with @sebastianwessel/quickjs 2025-10-02 02:59:40 +08:00
tsukino
b3cc41b37a add browser test for pluginsdk 2025-10-02 02:31:30 +08:00
tsukino
287d508bd2 use @sebastianwessel/quickjs 2025-10-02 02:15:14 +08:00
tsukino
e3b23d04b9 add basic host env for testing plugin 2025-10-02 00:13:06 +08:00
tsukino
cf27ada068 wip 2025-10-01 23:52:31 +08:00
tsukino
b6fc566074 wip 2025-10-01 23:37:01 +08:00
tsukino
2bef3e0c2c fix: use quickjs emscripten 2025-10-01 23:33:21 +08:00
tsukino
aafd962454 reset to previous working state 2025-10-01 23:05:06 +08:00
tsukino
369f90be61 Remove plugin execution implementation and add SessionManager import 2025-10-01 21:51:31 +08:00
tsukino
98a642a96c Fix type errors and update fetch test to verify error handling 2025-10-01 20:31:49 +08:00
tsukino
2366ac131b Move host functions to env object and simplify plugin execution in plugin-sdk 2025-10-01 20:13:42 +08:00
tsukino
e5b7c3e502 Set up Vite, TypeScript, testing, and linting for plugin-sdk package 2025-10-01 19:26:58 +08:00
tsukino
fb57fd7002 Refactor to monorepo structure with extension and plugin-sdk packages 2025-09-30 19:00:08 +08:00
tsukino
9e53bddd34 Add serve:test script for local test page server 2025-09-30 18:42:22 +08:00
tsukino
bfb8f302c0 Implement Phase 5: error handling and edge cases for window management
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 05:18:45 +08:00
tsukino
048cd3b6ac Add README for integration testing suite
- Test flow diagram
- Quick start guide
- File descriptions and usage instructions
- Testing best practices checklist
- Common issues and troubleshooting
- Issue reporting guidelines
- CI/CD future considerations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 05:06:39 +08:00
tsukino
dff3541dcf Add comprehensive testing suite for multi-window management (Phase 4)
Task 4.2: Integration test HTML page
- Interactive test page with 6 test sections
- Basic window opening with predefined URLs
- Custom URL testing with input field
- Window options testing (dimensions, overlay toggle)
- Multiple windows test (3, 5, 10 windows)
- Error handling tests (invalid URLs, protocols)
- Legacy API backward compatibility test
- Real-time statistics tracking
- Styled UI with instructions and status messages

Task 4.3: Manual testing checklist
- 12 comprehensive test categories
- 50+ individual test cases with pass/fail checkboxes
- Tests cover: basic operations, custom URLs, options, multiple windows,
  request interception, error handling, cleanup, backward compatibility,
  overlay functionality, edge cases, console logs
- Performance observation section
- Sign-off and reporting format
- Acceptance criteria for each test

Task 4.4: Performance testing guidelines
- 8 structured performance test procedures
- Memory usage, CPU usage, and request processing metrics
- Baseline performance targets and thresholds
- Memory leak detection methodology
- High-traffic site testing protocol
- Request tracking overhead measurement
- Cleanup efficiency verification
- Long-running window test (30 minutes)
- Periodic cleanup verification
- Tools and commands reference
- Performance issue detection checklist
- Reporting template

All 72 unit tests passing 

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 05:05:51 +08:00
tsukino
9679787a41 Implement deferred overlay display with tabs.onUpdated (Tasks 3.4-3.5)
- Add showOverlayWhenReady flag to ManagedWindow for lazy overlay display
- Implement persistent tabs.onUpdated listener to show overlay when tab is ready
- WindowManager.registerWindow no longer shows overlay immediately
- Overlay shown when tab status becomes 'complete' via tabs.onUpdated
- Add backward compatibility handler for TLSN_CONTENT_TO_EXTENSION
- Legacy handler opens x.com window using new WindowManager system
- Update tests to verify showOverlayWhenReady behavior
- All 72 tests passing

This fixes race condition where overlay was shown before content script was ready.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 05:00:56 +08:00
tsukino
7db6bef352 Implement multi-window management with tlsn.open() API
- Add WindowManager for independent multi-window state tracking
- Implement window.tlsn.open(url) client API with validation
- Add OPEN_WINDOW message handler in background script
- Add request interception and overlay updates per window
- Add automatic cleanup of closed windows
- Add URL protocol validation (http/https only)
- Add comprehensive test coverage (72 tests passing)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 04:48:09 +08:00
tsukino
96055d8978 Add Vitest testing framework and WindowManager type definitions
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-30 04:27:44 +08:00
tsukino
988504500e Add debug logging and enhance manifest configuration 2025-09-19 13:04:27 +08:00
tsukino
41fce44ca9 Add request interception and display to TLSN overlay 2025-09-17 16:22:09 +00:00
tsukino
7fb39c835b Add TLSN overlay functionality to extension 2025-09-08 14:38:45 +00:00
tsukino
c17b19de70 wip 2025-09-02 08:15:22 +00:00
tsukino
92ecb55d6c Refactor to minimal extension boilerplate 2025-08-24 11:47:33 +00:00
173 changed files with 14820 additions and 14377 deletions

View File

@@ -21,21 +21,21 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Lint
- name: Lint all packages
run: npm run lint
- name: Test Webpack Build
run: npm run build:webpack
- 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: tlsn-extension-${{ github.ref_name }}.zip
path: ./zip/tlsn-extension-${{ github.ref_name }}.zip
name: extension-build
path: ./packages/extension/zip/*.zip
if-no-files-found: error
release:
@@ -45,28 +45,36 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download extension from build-lint-test job
uses: actions/download-artifact@v4
with:
name: tlsn-extension-${{ github.ref_name }}.zip
path: .
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 }}" \
./tlsn-extension-${{ github.ref_name }}.zip \
"$EXTENSION_ZIP" \
--clobber
# Get tokens as documented on
# 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: tlsn-extension-${{ github.ref_name }}.zip
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 }}

3
.gitignore vendored
View File

@@ -9,3 +9,6 @@ build
tlsn/
zip
.vscode
.claude
coverage
packages/verifier/target/

388
CLAUDE.md Normal file
View File

@@ -0,0 +1,388 @@
# 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 Normal file

File diff suppressed because it is too large Load Diff

484
README.md
View File

@@ -1,71 +1,435 @@
![MIT licensed][mit-badge]
![Apache licensed][apache-badge]
[![Build Status][actions-badge]][actions-url]
<img src="packages/extension/src/assets/img/icon-128.png" width="64"/>
[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++
# TLSN Extension Monorepo
<img src="src/assets/img/icon-128.png" width="64"/>
# Chrome Extension (MV3) for TLSNotary
A Chrome Extension for TLSNotary with plugin SDK and verifier server.
> [!IMPORTANT]
> ⚠️ When running the extension against a [notary server](https://github.com/tlsnotary/tlsn/tree/main/crates/notary/server), please ensure that the server's version is the same as the version of this extension
> 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)
## 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
The easiest way to install the TLSN browser extension is to use the [Chrome Web Store](https://chromewebstore.google.com/detail/tlsn-extension/gcfkkledipjbgdbimfpijgbkhajiaaph).
You can also build and run it locally as explained in the following steps.
### Procedure:
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)

5767
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,97 +1,28 @@
{
"name": "tlsn-extension",
"version": "0.1.0.1201",
"name": "tlsn-monorepo",
"version": "0.1.0",
"private": true,
"description": "TLSN Extension monorepo with plugin SDK",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/tlsnotary/tlsn-extension.git"
},
"workspaces": [
"packages/*"
],
"scripts": {
"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": "^2.0.0-rc11",
"@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",
"dayjs": "^1.11.13",
"fast-deep-equal": "^3.1.3",
"fuse.js": "^6.6.2",
"http-parser-js": "^0.5.9",
"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.12.0"
"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"
},
"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",
"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"
"vite": "^7.1.7"
}
}
}

View File

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

View File

@@ -0,0 +1,308 @@
# 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

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

112
packages/extension/package.json Executable file
View File

@@ -0,0 +1,112 @@
{
"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

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

View File

Before

Width:  |  Height:  |  Size: 621 B

After

Width:  |  Height:  |  Size: 621 B

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

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,548 @@
/**
* 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

@@ -0,0 +1,65 @@
/**
* 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

@@ -0,0 +1,139 @@
/**
* 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

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

View File

@@ -0,0 +1,355 @@
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

@@ -0,0 +1,83 @@
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

@@ -0,0 +1,232 @@
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

@@ -2,11 +2,10 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Settings</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Developer Console</title>
</head>
<body>
<div id="app-container"></div>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,316 @@
@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

@@ -0,0 +1,298 @@
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

@@ -0,0 +1,73 @@
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

@@ -0,0 +1,33 @@
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

@@ -4,10 +4,10 @@
$fa-font-path: "~@fortawesome/fontawesome-free/webfonts";
@import "../../../node_modules/@fortawesome/fontawesome-free/scss/fontawesome";
@import "../../../node_modules/@fortawesome/fontawesome-free/scss/brands";
@import "../../../node_modules/@fortawesome/fontawesome-free/scss/solid";
@import "../../../node_modules/@fortawesome/fontawesome-free/scss/regular";
@import "~@fortawesome/fontawesome-free/scss/fontawesome";
@import "~@fortawesome/fontawesome-free/scss/brands";
@import "~@fortawesome/fontawesome-free/scss/solid";
@import "~@fortawesome/fontawesome-free/scss/regular";
body {
width: 480px;

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import Popup from './Popup';
import './index.scss';
import store from '../../utils/store';
const container = document.getElementById('app-container');
if (container) {
const root = createRoot(container);
root.render(
<Provider store={store}>
<Popup />
</Provider>,
);
}

View File

@@ -1,18 +1,10 @@
{
"manifest_version": 3,
"name": "TLSN Extension",
"description": "A chrome extension for TLSN",
"options_page": "options.html",
"description": "A Chrome extension for TLSN",
"background": {
"service_worker": "background.bundle.js"
},
"action": {
"default_popup": "popup.html",
"default_icon": "icon-34.png"
},
"side_panel": {
"default_path": "sidePanel.html"
},
"icons": {
"128": "icon-128.png"
},
@@ -28,16 +20,18 @@
],
"web_accessible_resources": [
{
"resources": ["content.styles.css", "icon-128.png", "icon-34.png", "content.bundle.js"],
"resources": ["content.styles.css", "icon-128.png", "icon-34.png", "content.bundle.js", "*.wasm"],
"matches": ["http://*/*", "https://*/*", "<all_urls>"]
}
],
"host_permissions": ["<all_urls>"],
"permissions": [
"offscreen",
"storage",
"webRequest",
"storage",
"activeTab",
"sidePanel"
"tabs",
"windows",
"contextMenus"
]
}
}

View File

@@ -0,0 +1,20 @@
// 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

@@ -0,0 +1,32 @@
// 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

@@ -0,0 +1,151 @@
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

@@ -0,0 +1,64 @@
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

@@ -0,0 +1,539 @@
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

@@ -0,0 +1,47 @@
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

@@ -0,0 +1,164 @@
/**
* 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

@@ -0,0 +1,181 @@
/**
* 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

@@ -0,0 +1,82 @@
<!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

@@ -0,0 +1,558 @@
/**
* 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

@@ -0,0 +1,266 @@
/**
* 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

@@ -0,0 +1,72 @@
/**
* 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

@@ -0,0 +1,128 @@
/* 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

@@ -0,0 +1,137 @@
/**
* 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

@@ -0,0 +1,300 @@
/**
* 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

@@ -0,0 +1,326 @@
/**
* 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

@@ -0,0 +1,91 @@
/**
* 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

@@ -15,6 +15,7 @@
"noEmit": false,
"jsx": "react"
},
"types": ["chrome"],
"include": ["src"],
"exclude": ["build", "node_modules"]
}

View File

@@ -0,0 +1,36 @@
/**
* 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

@@ -0,0 +1,40 @@
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

@@ -7,7 +7,7 @@ var webpack = require("webpack"),
TerserPlugin = require("terser-webpack-plugin");
var { CleanWebpackPlugin } = require("clean-webpack-plugin");
var ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
var ExtReloader = require('webpack-ext-reloader');
var NodeProtocolResolvePlugin = require("./utils/NodeProtocolResolvePlugin");
const ASSET_PATH = process.env.ASSET_PATH || "/";
@@ -44,28 +44,35 @@ var options = {
/Sass @import rules are deprecated and will be removed in Dart Sass 3.0.0/,
/Global built-in functions are deprecated and will be removed in Dart Sass 3.0.0./,
/repetitive deprecation warnings omitted/,
/Dart Sass 2.0.0/,
/Critical dependency: the request of a dependency is an expression/,
],
entry: {
options: path.join(__dirname, "src", "entries", "Options", "index.tsx"),
popup: path.join(__dirname, "src", "entries", "Popup", "index.tsx"),
devConsole: path.join(__dirname, "src", "entries", "DevConsole", "index.tsx"),
background: path.join(__dirname, "src", "entries", "Background", "index.ts"),
contentScript: path.join(__dirname, "src", "entries", "Content", "index.ts"),
content: path.join(__dirname, "src", "entries", "Content", "content.ts"),
offscreen: path.join(__dirname, "src", "entries", "Offscreen", "index.tsx"),
sidePanel: path.join(__dirname, "src", "entries", "SidePanel", "index.tsx"),
},
// chromeExtensionBoilerplate: {
// notHotReload: ["background", "contentScript", "devtools"],
// },
output: {
filename: "[name].bundle.js",
path: path.resolve(__dirname, "build"),
clean: true,
publicPath: ASSET_PATH,
webassemblyModuleFilename: "[hash].wasm",
},
experiments: {
asyncWebAssembly: true,
syncWebAssembly: true,
},
module: {
rules: [
{
// Ignore .d.ts files from node_modules to prevent webpack parse errors
test: /\.d\.ts$/,
include: /node_modules/,
use: 'null-loader',
},
{
// look for .css or .scss files
test: /\.(css|scss)$/,
@@ -85,9 +92,6 @@ var options = {
loader: "sass-loader",
options: {
sourceMap: true,
sassOptions: {
silenceDeprecations: ["legacy-js-api"],
}
},
},
],
@@ -96,10 +100,6 @@ var options = {
test: new RegExp(".(" + fileExtensions.join("|") + ")$"),
type: "asset/resource",
exclude: /node_modules/,
// loader: 'file-loader',
// options: {
// name: '[name].[ext]',
// },
},
{
test: /\.html$/,
@@ -114,6 +114,7 @@ var options = {
loader: require.resolve("ts-loader"),
options: {
transpileOnly: isDevelopment,
compiler: require.resolve("typescript"),
},
},
],
@@ -138,20 +139,56 @@ var options = {
],
},
resolve: {
alias: alias,
alias: {
...alias,
'process': require.resolve('process/browser.js'),
'buffer': require.resolve('buffer/'),
'stream': require.resolve('stream-browserify'),
'path': require.resolve('path-browserify'),
'events': require.resolve('events/'),
'fs': path.resolve(__dirname, './src/node-fs-mock.js'),
'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'),
},
extensions: fileExtensions
.map((extension) => "." + extension)
.concat([".js", ".jsx", ".ts", ".tsx", ".css"]),
fallback: {
"fs": path.resolve(__dirname, './src/node-fs-mock.js'),
"path": require.resolve("path-browserify"),
"stream": require.resolve("stream-browserify"),
"crypto": path.resolve(__dirname, './src/node-crypto-mock.js'),
"buffer": require.resolve("buffer/"),
"process": require.resolve("process/browser.js"),
"util": require.resolve("util/"),
"assert": require.resolve("assert/"),
"url": path.resolve(__dirname, './src/empty-module.js'),
"events": require.resolve("events/"),
}
},
plugins: [
new NodeProtocolResolvePlugin({
'node:fs': path.resolve(__dirname, './src/node-fs-mock.js'),
'node:path': require.resolve('path-browserify'),
'node:stream': require.resolve('stream-browserify'),
'node:buffer': require.resolve('buffer/'),
'node:crypto': path.resolve(__dirname, './src/node-crypto-mock.js'),
'node:events': require.resolve('events/'),
}),
isDevelopment && new ReactRefreshWebpackPlugin(),
new CleanWebpackPlugin({ verbose: false }),
new webpack.ProgressPlugin(),
// expose and write the allowed env vars on the compiled bundle
new webpack.EnvironmentPlugin(["NODE_ENV"]),
// new ExtReloader({
// manifest: path.resolve(__dirname, "src/manifest.json")
// }),
new webpack.ProvidePlugin({
Buffer: ['buffer', 'Buffer'],
process: 'process',
}),
new webpack.DefinePlugin({
'process.env': '{}',
global: 'globalThis',
}),
new CopyWebpackPlugin({
patterns: [
{
@@ -201,22 +238,16 @@ var options = {
new CopyWebpackPlugin({
patterns: [
{
from: "node_modules/tlsn-js/build",
from: "../../packages/tlsn-wasm-pkg",
to: path.join(__dirname, "build"),
force: true,
},
],
}),
new HtmlWebpackPlugin({
template: path.join(__dirname, "src", "entries", "Options", "index.html"),
filename: "options.html",
chunks: ["options"],
cache: false,
}),
new HtmlWebpackPlugin({
template: path.join(__dirname, "src", "entries", "Popup", "index.html"),
filename: "popup.html",
chunks: ["popup"],
template: path.join(__dirname, "src", "entries", "DevConsole", "index.html"),
filename: "devConsole.html",
chunks: ["devConsole"],
cache: false,
}),
new HtmlWebpackPlugin({
@@ -225,29 +256,10 @@ var options = {
chunks: ["offscreen"],
cache: false,
}),
new HtmlWebpackPlugin({
template: path.join(__dirname, "src", "entries", "SidePanel", "index.html"),
filename: "sidePanel.html",
chunks: ["sidePanel"],
cache: false,
}),
new webpack.ProvidePlugin({
Buffer: ['buffer', 'Buffer'],
}),
].filter(Boolean),
infrastructureLogging: {
level: "info",
},
// Required by wasm-bindgen-rayon, in order to use SharedArrayBuffer on the Web
// Ref:
// - https://github.com/GoogleChromeLabs/wasm-bindgen-rayon#setting-up
// - https://web.dev/i18n/en/coop-coep/
devServer: {
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin',
}
},
};
if (env.NODE_ENV === "development") {
@@ -263,4 +275,4 @@ if (env.NODE_ENV === "development") {
};
}
module.exports = options;
module.exports = options;

View File

@@ -0,0 +1,35 @@
{
"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"]
}

28
packages/plugin-sdk/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# 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

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

View File

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

View File

@@ -0,0 +1,65 @@
# @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

@@ -0,0 +1,71 @@
{
"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

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

View File

@@ -0,0 +1,89 @@
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

@@ -0,0 +1,42 @@
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

@@ -0,0 +1,106 @@
/**
* @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

@@ -0,0 +1,20 @@
// 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

@@ -0,0 +1,28 @@
// 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

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

View File

@@ -0,0 +1,39 @@
{
"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

@@ -0,0 +1,50 @@
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

@@ -0,0 +1,56 @@
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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,19 @@
# 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

@@ -0,0 +1,33 @@
{
"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

@@ -0,0 +1,73 @@
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

@@ -0,0 +1,73 @@
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();
}
})
})
}

223
packages/tlsn-wasm-pkg/tlsn_wasm.d.ts vendored Normal file
View File

@@ -0,0 +1,223 @@
/* 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

Binary file not shown.

View File

@@ -0,0 +1,36 @@
/* 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

@@ -0,0 +1,43 @@
[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"] }

224
packages/verifier/README.md Normal file
View File

@@ -0,0 +1,224 @@
# 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

@@ -0,0 +1,930 @@
//! 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

@@ -0,0 +1,7 @@
/// 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;

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