Compare commits
59 Commits
react_demo
...
new-ext
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf93e9e1c3 | ||
|
|
3c58052a54 | ||
|
|
c248a9210c | ||
|
|
cf5d7fc885 | ||
|
|
810643a831 | ||
|
|
c7556b8632 | ||
|
|
d70bf8f432 | ||
|
|
434ccfe621 | ||
|
|
cdb814a2cf | ||
|
|
6e4a5a07c2 | ||
|
|
16c6119416 | ||
|
|
d422140fbf | ||
|
|
3cc36e3e08 | ||
|
|
dba209ae57 | ||
|
|
09765bb9cc | ||
|
|
df99ba3d9c | ||
|
|
ed17168093 | ||
|
|
2c9e88c296 | ||
|
|
a283e1d14d | ||
|
|
330fb4ab99 | ||
|
|
fcaa8dbbcd | ||
|
|
39d87e3252 | ||
|
|
392f9d2e40 | ||
|
|
deaa8d42c9 | ||
|
|
720764cc59 | ||
|
|
3ceddb1753 | ||
|
|
536ec91689 | ||
|
|
528fefdeb8 | ||
|
|
44e22cfc94 | ||
|
|
c019b2e2ba | ||
|
|
c087b14d21 | ||
|
|
0a156fec85 | ||
|
|
ffeee2c539 | ||
|
|
8ab08db9b5 | ||
|
|
ff4c17bda2 | ||
|
|
b3cc41b37a | ||
|
|
287d508bd2 | ||
|
|
e3b23d04b9 | ||
|
|
cf27ada068 | ||
|
|
b6fc566074 | ||
|
|
2bef3e0c2c | ||
|
|
aafd962454 | ||
|
|
369f90be61 | ||
|
|
98a642a96c | ||
|
|
2366ac131b | ||
|
|
e5b7c3e502 | ||
|
|
fb57fd7002 | ||
|
|
9e53bddd34 | ||
|
|
bfb8f302c0 | ||
|
|
048cd3b6ac | ||
|
|
dff3541dcf | ||
|
|
9679787a41 | ||
|
|
7db6bef352 | ||
|
|
96055d8978 | ||
|
|
988504500e | ||
|
|
41fce44ca9 | ||
|
|
7fb39c835b | ||
|
|
c17b19de70 | ||
|
|
92ecb55d6c |
34
.github/workflows/ci.yaml
vendored
@@ -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
@@ -9,3 +9,6 @@ build
|
||||
tlsn/
|
||||
zip
|
||||
.vscode
|
||||
.claude
|
||||
coverage
|
||||
packages/verifier/target/
|
||||
388
CLAUDE.md
Normal 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)
|
||||
|
||||
484
README.md
@@ -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
103
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
308
packages/extension/CLAUDE.md
Normal 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)
|
||||
1
packages/extension/lib/tlsn-wasm-pkg
Symbolic link
@@ -0,0 +1 @@
|
||||
../../tlsn-wasm-pkg
|
||||
112
packages/extension/package.json
Executable 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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 621 B After Width: | Height: | Size: 621 B |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 896 B After Width: | Height: | Size: 896 B |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
548
packages/extension/src/background/WindowManager.ts
Normal 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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
65
packages/extension/src/constants/limits.ts
Normal 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;
|
||||
139
packages/extension/src/constants/messages.ts
Normal 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;
|
||||
}
|
||||
2
packages/extension/src/empty-module.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// Empty module for browser compatibility
|
||||
export default {};
|
||||
355
packages/extension/src/entries/Background/index.ts
Normal 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 {};
|
||||
83
packages/extension/src/entries/Content/content.ts
Normal 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'));
|
||||
232
packages/extension/src/entries/Content/index.ts
Normal 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 {};
|
||||
@@ -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>
|
||||
316
packages/extension/src/entries/DevConsole/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
298
packages/extension/src/entries/DevConsole/index.tsx
Normal 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 />);
|
||||
73
packages/extension/src/entries/Offscreen/index.tsx
Normal 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 />);
|
||||
}
|
||||
33
packages/extension/src/entries/Popup/Popup.tsx
Normal 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;
|
||||
@@ -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;
|
||||
16
packages/extension/src/entries/Popup/index.tsx
Normal 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>,
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
20
packages/extension/src/node-crypto-mock.js
Normal 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,
|
||||
};
|
||||
32
packages/extension/src/node-fs-mock.js
Normal 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,
|
||||
};
|
||||
151
packages/extension/src/offscreen/ProveManager/index.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
64
packages/extension/src/offscreen/ProveManager/worker.ts
Normal 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,
|
||||
});
|
||||
539
packages/extension/src/offscreen/SessionManager.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
47
packages/extension/src/reducers/index.tsx
Normal 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;
|
||||
164
packages/extension/src/types/window-manager.ts
Normal 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>;
|
||||
}
|
||||
181
packages/extension/src/utils/url-validator.ts
Normal 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';
|
||||
}
|
||||
82
packages/extension/test-session-manager.html
Normal 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>
|
||||
558
packages/extension/tests/background/WindowManager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
266
packages/extension/tests/entries/content-api.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
72
packages/extension/tests/example.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
128
packages/extension/tests/sample-plugin.js
Normal 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,
|
||||
};
|
||||
137
packages/extension/tests/setup.ts
Normal 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();
|
||||
});
|
||||
300
packages/extension/tests/types/window-manager.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
326
packages/extension/tests/utils/url-validator.test.ts
Normal 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/');
|
||||
});
|
||||
});
|
||||
});
|
||||
91
packages/extension/tests/utils/uuid.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
"noEmit": false,
|
||||
"jsx": "react"
|
||||
},
|
||||
"types": ["chrome"],
|
||||
"include": ["src"],
|
||||
"exclude": ["build", "node_modules"]
|
||||
}
|
||||
36
packages/extension/utils/NodeProtocolResolvePlugin.js
Normal 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;
|
||||
40
packages/extension/vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
35
packages/plugin-sdk/.eslintrc.json
Normal 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
@@ -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
|
||||
9
packages/plugin-sdk/.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
||||
dist
|
||||
node_modules
|
||||
coverage
|
||||
*.min.js
|
||||
*.umd.js
|
||||
.nyc_output
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
12
packages/plugin-sdk/.prettierrc
Normal 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
|
||||
}
|
||||
65
packages/plugin-sdk/README.md
Normal 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
|
||||
71
packages/plugin-sdk/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
2
packages/plugin-sdk/src/empty-module.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// Empty module for browser compatibility
|
||||
export default {};
|
||||
89
packages/plugin-sdk/src/index.browser.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
42
packages/plugin-sdk/src/index.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
106
packages/plugin-sdk/src/index.ts
Normal 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;
|
||||
20
packages/plugin-sdk/src/node-crypto-mock.js
Normal 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,
|
||||
};
|
||||
28
packages/plugin-sdk/src/node-fs-mock.js
Normal 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,
|
||||
};
|
||||
5
packages/plugin-sdk/tsconfig.eslint.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.test.ts", "src/**/*.spec.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
39
packages/plugin-sdk/tsconfig.json
Normal 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"]
|
||||
}
|
||||
50
packages/plugin-sdk/vite.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
56
packages/plugin-sdk/vitest.browser.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
21
packages/plugin-sdk/vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
19
packages/tlsn-wasm-pkg/README.md
Normal 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)
|
||||
33
packages/tlsn-wasm-pkg/package.json
Normal 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'"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
73
packages/tlsn-wasm-pkg/spawn.js
Normal 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
@@ -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>;
|
||||
1175
packages/tlsn-wasm-pkg/tlsn_wasm.js
Normal file
BIN
packages/tlsn-wasm-pkg/tlsn_wasm_bg.wasm
Normal file
36
packages/tlsn-wasm-pkg/tlsn_wasm_bg.wasm.d.ts
vendored
Normal 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;
|
||||
43
packages/verifier/Cargo.toml
Normal 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
@@ -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.
|
||||
930
packages/verifier/src/axum_websocket.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
7
packages/verifier/src/config.rs
Normal 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;
|
||||