Compare commits

..

2 Commits

Author SHA1 Message Date
tsukino
e099f0b4e9 feat: add plugin permission control system
- Add PermissionManager for runtime host permission management
- Update ConfirmPopup to request host permissions during plugin approval
- Add Options page Permissions tab to view and revoke granted permissions
- Track granted origins per managed window for cleanup on close
- Update manifest to use optional_host_permissions with <all_urls>
- Add extractOrigins() to collect API hosts and page URLs from plugin config
- Implement permission cleanup when window closes or plugin execution fails
2026-01-06 23:08:14 +08:00
tsukino
9c0b12da2c docs: update CLAUDE.md with permission system and new entry points
- Document Options page and ConfirmPopup entry points
- Add ConfirmationManager class documentation
- Add Permission Validation System section
- Document PluginConfig and RequestPermission interfaces
- Update manifest permissions (contextMenus, options_page)
- Update webpack entry points list
- Add Spotify plugin to demo files (PR #210)
- Document array field access fix (PR #212)
- Add new message types documentation
- Update Known Issues section
2026-01-06 19:30:43 +08:00
113 changed files with 5722 additions and 9284 deletions

View File

@@ -86,5 +86,5 @@ jobs:
tags: ${{ steps.meta-verifier-webapp.outputs.tags }}
labels: ${{ steps.meta-verifier-webapp.outputs.labels }}
build-args: |
VITE_VERIFIER_HOST=demo-staging.tlsnotary.org
VITE_SSL=true
VERIFIER_HOST=demo-staging.tlsnotary.org
SSL=true

206
CLAUDE.md
View File

@@ -78,7 +78,7 @@ cd packages/extension && npm run dev
## Extension Architecture Overview
### Extension Entry Points
The extension has 5 main entry points defined in `webpack.config.js`:
The extension has 7 main entry points defined in `webpack.config.js`:
#### 1. **Background Service Worker** (`src/entries/Background/index.ts`)
Core responsibilities:
@@ -170,6 +170,25 @@ Isolated React component for background processing:
- **Lifecycle**: Created dynamically by background script, reused if exists
- Entry point: `offscreen.html`
#### 7. **Options Page** (`src/entries/Options/index.tsx`)
Extension settings page for user configuration:
- **Log Level Control**: Configure logging verbosity (DEBUG, INFO, WARN, ERROR)
- **IndexedDB Storage**: Settings persisted via `logLevelStorage.ts` utility
- **Live Updates**: Changes take effect immediately without restart
- **Styling**: Custom SCSS with radio button group design
- Access: Right-click extension icon → Options, or `chrome://extensions` → Details → Extension options
- Entry point: `options.html`
#### 8. **ConfirmPopup** (`src/entries/ConfirmPopup/index.tsx`)
Permission confirmation dialog for plugin execution:
- **Plugin Info Display**: Shows plugin name, description, version, author
- **Permission Review**: Lists allowed network requests and URLs
- **User Controls**: Allow/Deny buttons with keyboard shortcuts (Enter/Esc)
- **Security**: Validates plugin config before execution
- **Timeout**: 60-second auto-deny if no response
- Opened by: `ConfirmationManager` when executing untrusted plugins
- Entry point: `confirmPopup.html`
### Key Classes
#### **WindowManager** (`src/background/WindowManager.ts`)
@@ -228,6 +247,64 @@ const proof = await prove(
);
```
#### **ConfirmationManager** (`src/background/ConfirmationManager.ts`)
Manages plugin execution confirmation dialogs:
- **Permission Flow**: Opens confirmation popup before plugin execution
- **User Approval**: Displays plugin info and permissions for user review
- **Timeout Handling**: 60-second timeout auto-denies if no response
- **Window Tracking**: Tracks confirmation popup windows by request ID
- **Singleton Pattern**: Exported as `confirmationManager` instance
Key methods:
- `requestConfirmation(config, requestId)`: Opens confirmation popup, returns Promise<boolean>
- `handleConfirmationResponse(requestId, allowed)`: Processes user's allow/deny decision
- `hasPendingConfirmation()`: Check if a confirmation is in progress
#### **PermissionManager** (`src/background/PermissionManager.ts`)
Manages runtime host permission requests and revocation:
- **Runtime Permissions**: Requests host permissions from browser at plugin execution time
- **Permission Extraction**: Extracts origin patterns from plugin's `config.requests`
- **Concurrent Execution Tracking**: Tracks which permissions are in use by active plugins
- **Automatic Revocation**: Removes permissions when no longer needed
- **Singleton Pattern**: Exported as `permissionManager` instance
Key methods:
- `extractOrigins(requests)`: Extract origin patterns from RequestPermission array
- `extractPermissionPatterns(requests)`: Extract patterns with host and pathname for display
- `formatForDisplay(requests)`: Format as `host + pathname` strings for UI
- `requestPermissions(origins)`: Request permissions from browser, tracks usage
- `removePermissions(origins)`: Revoke permissions if no longer in use
- `hasPermissions(origins)`: Check if permissions are already granted
**Plugin Execution Flow with Permissions:**
1. User clicks "Allow" in ConfirmPopup
2. PermissionManager extracts origins from `config.requests`
3. Browser shows native permission prompt
4. If granted, plugin executes in offscreen document
5. After execution (success or error), permissions are revoked
### Permission Validation System
The extension implements a permission control system (PR #211) that validates plugin operations:
#### **Permission Validator** (`src/offscreen/permissionValidator.ts`)
Validates plugin API calls against declared permissions:
- `validateProvePermission()`: Checks if prove() call matches declared `requests` permissions
- `validateOpenWindowPermission()`: Checks if openWindow() call matches declared `urls` permissions
- `deriveProxyUrl()`: Derives default proxy URL from verifier URL
- `matchesPathnamePattern()`: URLPattern-based pathname matching with wildcards
**Validation Flow:**
1. Plugin declares permissions in `config.requests` and `config.urls`
2. Before `prove()` or `openWindow()` executes, validator checks permissions
3. If no matching permission found, throws descriptive error
4. Error includes list of declared permissions for debugging
**URLPattern Syntax:**
- Exact paths: `/1.1/users/show.json`
- Single-segment wildcard: `/api/*/data` (matches `/api/v1/data`)
- Multi-segment wildcard: `/api/**` (matches `/api/v1/users/123`)
### State Management
Redux store located in `src/reducers/index.tsx`:
- **App State Interface**: `{ message: string, count: number }`
@@ -282,6 +359,17 @@ Content Script: Renders plugin UI from DOM JSON
- Content script validates origin (`event.origin === window.location.origin`)
- URL validation using `validateUrl()` utility before window creation
- Request interception limited to managed windows only
- Plugin permission validation before prove() and openWindow() calls
**Additional Message Types:**
- `PLUGIN_CONFIRM_RESPONSE` → User response (allow/deny) from confirmation popup
- `PLUGIN_UI_CLICK` → Button click event from plugin UI in content script
- `EXTRACT_CONFIG` → Request to extract plugin config from code
- `EXEC_CODE_OFFSCREEN` → Execute plugin code in offscreen context
- `RENDER_PLUGIN_UI` → Render plugin UI from DOM JSON in content script
- `OFFSCREEN_LOG` → Log forwarding from offscreen to page context
- `REQUEST_HOST_PERMISSIONS` → Request browser host permissions for origins
- `REMOVE_HOST_PERMISSIONS` → Revoke browser host permissions for origins
### TLSN Overlay Feature
@@ -298,7 +386,7 @@ The overlay is a full-screen modal showing intercepted requests:
### Build Configuration
**Webpack 5 Setup** (`webpack.config.js`):
- **Entry Points**: popup, background, contentScript, content, offscreen
- **Entry Points**: popup, background, contentScript, content, offscreen, devConsole, options, confirmPopup
- **Output**: `build/` directory with `[name].bundle.js` pattern
- **Loaders**:
- `ts-loader` - TypeScript compilation (transpileOnly in dev)
@@ -310,7 +398,7 @@ The overlay is a full-screen modal showing intercepted requests:
- `ReactRefreshWebpackPlugin` - Hot module replacement (dev only)
- `CleanWebpackPlugin` - Cleans build directory
- `CopyWebpackPlugin` - Copies manifest, icons, CSS files
- `HtmlWebpackPlugin` - Generates popup.html and offscreen.html
- `HtmlWebpackPlugin` - Generates popup.html, offscreen.html, devConsole.html, options.html, confirmPopup.html
- `TerserPlugin` - Code minification (production only)
- **Dev Server** (`utils/webserver.js`):
- Port: 3000 (configurable via `PORT` env var)
@@ -332,10 +420,19 @@ Defined in `src/manifest.json`:
- `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
- `contextMenus` - Create context menu items (Developer Console access)
- `optional_host_permissions: ["https://*/*", "http://*/*"]` - Runtime-requested host permissions
- `content_scripts` - Inject into all HTTP/HTTPS pages
- `web_accessible_resources` - Make content.bundle.js, CSS, and icons accessible to pages
- `web_accessible_resources` - Make content.bundle.js, CSS, icons, and WASM files accessible to pages
- `content_security_policy` - Allow WASM execution (`wasm-unsafe-eval`)
- `options_page` - Extension settings page (`options.html`)
**Runtime Host Permissions:**
The extension uses `optional_host_permissions` instead of blanket `host_permissions` for improved privacy:
- No host permissions granted by default
- Permissions are requested at runtime based on plugin's `config.requests`
- Permissions are revoked immediately after plugin execution completes
- Uses browser's native permission prompt for user consent
### TypeScript Configuration
@@ -407,6 +504,47 @@ Defined in `src/manifest.json`:
## Plugin SDK Package (`packages/plugin-sdk`)
### Plugin Configuration
Plugins must export a `config` object with the following structure:
```typescript
interface PluginConfig {
name: string; // Display name
description: string; // What the plugin does
version?: string; // Optional version string
author?: string; // Optional author name
requests?: RequestPermission[]; // Allowed HTTP requests for prove()
urls?: string[]; // Allowed URLs for openWindow()
}
interface RequestPermission {
method: string; // HTTP method (GET, POST, etc.)
host: string; // Target hostname
pathname: string; // URL path pattern (supports wildcards)
verifierUrl: string; // Verifier server URL
proxyUrl?: string; // Optional proxy URL (derived from verifierUrl if omitted)
}
```
**Example config with permissions:**
```javascript
const config = {
name: 'Twitter Plugin',
description: 'Generate TLS proofs for Twitter profile data',
version: '1.0.0',
author: 'TLSN Team',
requests: [
{
method: 'GET',
host: 'api.x.com',
pathname: '/1.1/users/show.json',
verifierUrl: 'https://verifier.tlsnotary.org'
}
],
urls: ['https://x.com/*']
};
```
### Host Class API
The SDK provides a `Host` class for sandboxed plugin execution with capability injection:
@@ -422,6 +560,9 @@ const host = new Host({
// Execute plugin code
await host.executePlugin(pluginCode, { eventEmitter });
// Extract plugin config without executing
const config = await host.getPluginConfig(pluginCode);
```
**Capabilities injected into plugin environment:**
@@ -471,6 +612,7 @@ const ranges = parser.ranges.body('screen_name', { type: 'json', hideKey: true }
- Handle chunked transfer encoding
- Extract header ranges with case-insensitive names
- Extract JSON field ranges (top-level only)
- Array field access returns range for entire array (PR #212)
- Regex-based body pattern matching
- Track byte offsets for TLSNotary selective disclosure
@@ -486,6 +628,20 @@ const ranges = parser.ranges.body('screen_name', { type: 'json', hideKey: true }
- Reactive rendering: `main()` function called whenever hook state changes
- Force re-render: `main(true)` can be called to force UI re-render even if state hasn't changed (used on content script initialization)
### Config Extraction
The SDK provides utilities to extract plugin config without full execution:
```typescript
import { extractConfig } from '@tlsn/plugin-sdk';
// Fast extraction using regex (name/description only)
const basicConfig = await extractConfig(pluginCode);
// Full extraction using QuickJS sandbox (includes permissions)
const host = new Host({ /* ... */ });
const fullConfig = await host.getPluginConfig(pluginCode);
```
### Build Configuration
- **Vite**: Builds isomorphic package for Node.js and browser
- **TypeScript**: Strict mode with full type declarations
@@ -586,36 +742,48 @@ logger.setLevel(LogLevel.WARN);
[HH:MM:SS] [LEVEL] message
```
**Log Level Storage** (extension only):
The extension persists log level in IndexedDB via `src/utils/logLevelStorage.ts`:
- `getStoredLogLevel()`: Retrieve saved log level (defaults to WARN)
- `setStoredLogLevel(level)`: Save log level to IndexedDB
- Uses `idb-keyval` for simple key-value storage
## Demo Package (`packages/demo`)
Docker-based demo environment for testing plugins:
**Files:**
- `twitter.js`, `swissbank.js` - Example plugin files
- `twitter.js`, `swissbank.js`, `spotify.js` - Example plugin files (PR #210 added Spotify)
- `docker-compose.yml` - Docker services configuration
- `nginx.conf` - Reverse proxy configuration
- `start.sh` - Setup script with URL templating
**Docker Services:**
1. `verifier` - TLSNotary verifier server (port 7047)
2. `demo-static` - nginx serving static plugin files
3. `nginx` - Reverse proxy (port 80)
**Environment Variables (via `.env` files or Docker build args):**
- `VITE_VERIFIER_HOST` - Verifier server host (default: `localhost:7047`)
- `VITE_SSL` - Use https/wss protocols (default: `false`)
**Environment Variables:**
- `VERIFIER_HOST` - Verifier server host (default: `localhost:7047`)
- `SSL` - Use https/wss protocols (default: `false`)
**Usage:**
```bash
# Local development with npm
npm run demo
# Local development
./start.sh
# Docker (detached mode)
npm run docker:up
# Production with SSL
VERIFIER_HOST=verifier.tlsnotary.org SSL=true ./start.sh
# Docker with custom verifier
VITE_VERIFIER_HOST=verifier.example.com VITE_SSL=true docker compose up --build
# Docker detached mode
./start.sh -d
```
The `start.sh` script:
1. Processes plugin files, replacing `verifierUrl` and `proxyUrl` placeholders
2. Copies processed files to `generated/` directory
3. Starts Docker Compose services
## Important Implementation Notes
### Plugin API Changes
@@ -653,12 +821,8 @@ Parser tests (`packages/plugin-sdk/src/parser.test.ts`) use redacted sensitive d
### Known Issues
⚠️ **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
- **Nested JSON field access**: Parser does not yet support paths like `"user.profile.name"`
- **Duplicate CLAUDE.md**: `packages/extension/CLAUDE.md` contains outdated documentation; use root CLAUDE.md instead
## Websockify Integration

View File

@@ -58,7 +58,8 @@ tlsn-extension/
│ │
│ ├── demo/ # Demo server with Docker setup
│ │ ├── *.js # Example plugin files
│ │ ── docker-compose.yml # Docker services configuration
│ │ ── docker-compose.yml # Docker services configuration
│ │ └── start.sh # Setup script with configurable URLs
│ │
│ ├── tutorial/ # Tutorial examples
│ │ └── *.js # Tutorial plugin files
@@ -115,9 +116,10 @@ Rust-based HTTP/WebSocket server for TLSNotary verification:
#### 5. **demo** - Demo Server
Docker-based demo environment with:
- Pre-configured example plugins (Twitter, SwissBank)
- React + Vite frontend with environment-based configuration
- Docker Compose setup with verifier and nginx
- Configurable verifier URLs via `.env` files or Docker build args
- Configurable verifier URLs via environment variables
- Plugin file generator (`generate.sh`) with SSL support
- Docker startup script (`start.sh`)
#### 6. **tlsn-wasm-pkg** - TLSN WebAssembly Package
Pre-built WebAssembly binaries for TLSNotary functionality in the browser.
@@ -493,19 +495,25 @@ npm run demo
### Environment Variables
The demo uses `.env` files for configuration:
- `.env` - Local development defaults (`localhost:7047`)
- `.env.production` - Production settings (`verifier.tlsnotary.org`, SSL enabled)
For Docker deployments, override via environment variables:
Configure the demo for different environments:
```bash
# Local development (default)
npm run docker:up
cd packages/demo
./generate.sh && ./start.sh
# Production with custom verifier
VITE_VERIFIER_HOST=verifier.example.com VITE_SSL=true docker compose up --build
# Production with SSL
cd packages/demo
VERIFIER_HOST=verifier.tlsnotary.org SSL=true ./generate.sh
./start.sh
# Docker detached mode
./generate.sh && ./start.sh -d
```
The demo uses two scripts:
- **`generate.sh`** - Generates plugin files with configured verifier URLs (use environment variables here)
- **`start.sh`** - Starts Docker Compose services (assumes `generated/` directory exists)
### Tutorial
```bash

3570
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "tlsn-monorepo",
"version": "0.1.0-alpha.14",
"version": "0.1.0-alpha.13",
"private": true,
"description": "TLSN Extension monorepo with plugin SDK",
"license": "MIT",
@@ -22,11 +22,11 @@
"test": "npm run test --workspaces --if-present",
"serve:test": "npm run serve:test --workspace=extension",
"clean": "rm -rf packages/*/node_modules packages/*/dist packages/*/build node_modules",
"build:wasm": "sh packages/tlsn-wasm/build.sh v0.1.0-alpha.14 --no-logging",
"demo": "npm run dev --workspace=@tlsnotary/demo",
"tutorial": "npm run dev --workspace=@tlsn/tutorial",
"docker:up": "cd packages/demo && docker compose up --build -d",
"docker:down": "cd packages/demo && docker compose down"
"build:wasm": "sh packages/tlsn-wasm/build.sh v0.1.0-alpha.13 --no-logging",
"demo": "serve -l 8080 packages/demo",
"tutorial": "serve -l 8080 packages/tutorial",
"docker:up": "cd packages/demo && ./start.sh -d",
"docker:down": "cd packages/demo && docker-compose down"
},
"devDependencies": {
"typescript": "^5.5.4",

View File

@@ -33,11 +33,6 @@
"eslint-plugin-prettier": "^5.5.4",
"prettier": "^3.6.2",
"typescript": "^5.0.0",
"vitest": "^4.0.16"
},
"dependencies": {
"happy-dom": "20.0.11",
"vite": "7.3.0",
"webpack-dev-server": "5.2.2"
"vitest": "^1.0.0"
}
}

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Logger, LogLevel, DEFAULT_LOG_LEVEL } from './index';
describe('Logger', () => {
@@ -11,10 +11,6 @@ describe('Logger', () => {
logger.init(DEFAULT_LOG_LEVEL);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('LogLevel', () => {
it('should have correct hierarchy values', () => {
expect(LogLevel.DEBUG).toBe(0);

View File

@@ -1,3 +0,0 @@
# Verifier Configuration
VITE_VERIFIER_HOST=localhost:7047
VITE_SSL=false

View File

@@ -1,3 +0,0 @@
# Production environment variables
VITE_VERIFIER_HOST=verifier.tlsnotary.org
VITE_SSL=true

View File

@@ -1,4 +1,2 @@
*.wasm
dist/
public/plugins/
generated/

View File

@@ -1,52 +0,0 @@
# Adding New Plugins
Adding new plugins to the demo is straightforward. Just update the `plugins.ts` file:
## Example: Adding a GitHub Plugin
```typescript
// packages/demo/src/plugins.ts
export const plugins: Record<string, Plugin> = {
// ... existing plugins ...
github: {
name: 'GitHub Profile',
description: 'Prove your GitHub contributions and profile information',
logo: '🐙', // or use emoji: '💻', '⚡', etc.
file: '/github.js',
parseResult: (json) => {
return json.results[json.results.length - 1].value;
},
},
};
```
## Plugin Properties
| Property | Type | Description |
| ------------- | -------- | ------------------------------------------------------- |
| `name` | string | Display name shown in the card header |
| `description` | string | Brief description of what the plugin proves |
| `logo` | string | Emoji or character to display as the plugin icon |
| `file` | string | Path to the plugin JavaScript file |
| `parseResult` | function | Function to extract the result from the plugin response |
## Tips
- **Logo**: Use emojis for visual appeal (🔒, 🎮, 📧, 💰, etc.)
- **Description**: Keep it concise (1-2 lines) explaining what data is proven
- **File**: Place the plugin JS file in `/packages/demo/` directory
- **Name**: Use short, recognizable names
## Card Display
The plugin will automatically render as a card with:
- Large logo at the top
- Plugin name as heading
- Description text below
- "Run Plugin" button at the bottom
- Hover effects and animations
- Running state with spinner
No additional UI code needed!

View File

@@ -1,25 +1,17 @@
# Build stage
FROM node:20-alpine AS builder
FROM rust:latest AS builder
# Accept build arguments with defaults
ARG VITE_VERIFIER_HOST=localhost:7047
ARG VITE_SSL=false
ARG VERIFIER_HOST=localhost:7047
ARG SSL=false
WORKDIR /app
COPY index.html *.ico *.js *.sh /app/
# Copy package files and install dependencies
COPY package.json ./
RUN npm install
# Copy source files
COPY . .
# Build with environment variables
ENV VITE_VERIFIER_HOST=${VITE_VERIFIER_HOST}
ENV VITE_SSL=${VITE_SSL}
RUN npm run build
# Pass build args as environment variables to generate.sh
RUN VERIFIER_HOST="${VERIFIER_HOST}" SSL="${SSL}" ./generate.sh
# Runtime stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY --from=builder /app/generated /usr/share/nginx/html

View File

@@ -57,44 +57,31 @@ You can use the websocketproxy hosted by the TLSNotary team, or run your own pro
## 4. Launch the demo
### Development with React
This demo is built with React + TypeScript + Vite. To run it locally:
```bash
cd packages/demo
npm install
npm run dev
```
The demo will open at `http://localhost:3000` in your browser with the TLSNotary extension.
### Docker Setup
Run the demo with `npm run demo` from the repository root, or run it with docker using `npm run docker:up`.
#### Manual Docker Setup
### Manual Setup
If you want to run Docker manually:
If you want to run the scripts manually:
```bash
cd packages/demo
docker compose up --build
./generate.sh && ./start.sh
```
#### Environment Variables
The demo uses two scripts:
- **`generate.sh`** - Generates plugin files with configured verifier URLs
- **`start.sh`** - Starts Docker Compose services
The demo uses `.env` files for configuration:
- `.env` - Local development defaults (`localhost:7047`)
- `.env.production` - Production settings (`verifier.tlsnotary.org`, SSL enabled)
### Environment Variables
For Docker deployments, override via environment variables:
Configure for different environments:
```bash
# Local development (default)
docker compose up --build
./generate.sh && ./start.sh
# Production with custom verifier
VITE_VERIFIER_HOST=verifier.example.com VITE_SSL=true docker compose up --build
# Production with SSL
VERIFIER_HOST=verifier.tlsnotary.org SSL=true ./generate.sh
./start.sh
```
You can now open the demo by opening http://localhost:8080 in your browser with the TLSNotary extension

View File

@@ -1,45 +0,0 @@
import { build } from 'vite';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const plugins = ['twitter', 'swissbank', 'spotify'];
// Build URLs from environment variables (matching config.ts pattern)
const VERIFIER_HOST = process.env.VITE_VERIFIER_HOST || 'localhost:7047';
const SSL = process.env.VITE_SSL === 'true';
const VERIFIER_URL = `${SSL ? 'https' : 'http'}://${VERIFIER_HOST}`;
const PROXY_URL = `${SSL ? 'wss' : 'ws'}://${VERIFIER_HOST}/proxy?token=`;
// Build each plugin separately as plain ES module
for (const plugin of plugins) {
await build({
configFile: false,
build: {
lib: {
entry: path.resolve(__dirname, `src/plugins/${plugin}.plugin.ts`),
formats: ['es'],
fileName: () => `${plugin}.js`,
},
outDir: 'public/plugins',
emptyOutDir: false,
sourcemap: false,
minify: false,
rollupOptions: {
output: {
exports: 'default',
},
},
},
define: {
VITE_VERIFIER_URL: JSON.stringify(VERIFIER_URL),
VITE_PROXY_URL: JSON.stringify(PROXY_URL),
},
});
console.log(`✓ Built ${plugin}.js`);
}
console.log('✓ All plugins built successfully');

View File

@@ -15,8 +15,8 @@ services:
build:
context: .
args:
VITE_VERIFIER_HOST: ${VITE_VERIFIER_HOST:-localhost:7047}
VITE_SSL: ${VITE_SSL:-false}
VERIFIER_HOST: ${VERIFIER_HOST:-localhost:7047}
SSL: ${SSL:-false}
restart: unless-stopped
nginx:

105
packages/demo/generate.sh Executable file
View File

@@ -0,0 +1,105 @@
#!/bin/sh
#
# Demo Plugin File Generator
#
# This script generates plugin files with configurable verifier URLs.
# Used both locally and in CI/CD pipelines.
#
# Environment Variables:
# VERIFIER_HOST - Verifier server host (default: localhost:7047)
# SSL - Use https/wss if true (default: false)
#
# Usage:
# ./generate.sh # Local development
# VERIFIER_HOST=verifier.tlsnotary.org SSL=true ./generate.sh # Production
set -e
cd "$(dirname "$0")"
# Configuration with defaults
VERIFIER_HOST="${VERIFIER_HOST:-localhost:7047}"
SSL="${SSL:-false}"
# Determine protocol based on SSL setting
if [ "$SSL" = "true" ]; then
HTTP_PROTOCOL="https"
WS_PROTOCOL="wss"
else
HTTP_PROTOCOL="http"
WS_PROTOCOL="ws"
fi
VERIFIER_URL="${HTTP_PROTOCOL}://${VERIFIER_HOST}"
PROXY_URL_BASE="${WS_PROTOCOL}://${VERIFIER_HOST}/proxy?token="
echo "========================================"
echo "TLSNotary Demo Plugin Generator"
echo "========================================"
echo "Verifier Host: $VERIFIER_HOST"
echo "SSL Enabled: $SSL"
echo "Verifier URL: $VERIFIER_URL"
echo "Proxy URL: ${PROXY_URL_BASE}<host>"
echo "========================================"
# Create generated directory for processed files
mkdir -p generated
# Function to process a plugin file
process_plugin() {
local input_file="$1"
local output_file="generated/$(basename "$input_file")"
echo "Processing: $input_file -> $output_file"
# Replace verifierUrl and proxyUrl patterns
sed -E \
-e "s|verifierUrl: '[^']*'|verifierUrl: '${VERIFIER_URL}'|g" \
-e "s|verifierUrl: \"[^\"]*\"|verifierUrl: \"${VERIFIER_URL}\"|g" \
-e "s|proxyUrl: 'ws://[^/]+/proxy\?token=([^']+)'|proxyUrl: '${PROXY_URL_BASE}\1'|g" \
-e "s|proxyUrl: 'wss://[^/]+/proxy\?token=([^']+)'|proxyUrl: '${PROXY_URL_BASE}\1'|g" \
-e "s|proxyUrl: \"ws://[^/]+/proxy\?token=([^\"]+)\"|proxyUrl: \"${PROXY_URL_BASE}\1\"|g" \
-e "s|proxyUrl: \"wss://[^/]+/proxy\?token=([^\"]+)\"|proxyUrl: \"${PROXY_URL_BASE}\1\"|g" \
"$input_file" > "$output_file"
}
# Function to process index.html
process_index_html() {
local input_file="$1"
local output_file="generated/$(basename "$input_file")"
echo "Processing: $input_file -> $output_file"
# Replace hardcoded health check URL with configured verifier URL
sed -E \
-e "s|http://localhost:7047/health|${VERIFIER_URL}/health|g" \
"$input_file" > "$output_file"
}
# Process index.html
echo ""
echo "Processing index.html..."
process_index_html "index.html"
# Copy other static files
echo ""
echo "Copying other static files..."
cp favicon.ico generated/ 2>/dev/null || true
# Process plugin files
echo ""
echo "Processing plugin files..."
for plugin_file in *.js; do
if [ -f "$plugin_file" ]; then
process_plugin "$plugin_file"
fi
done
echo ""
echo "Generated files:"
ls -la generated/
echo ""
echo "========================================"
echo "Generation complete!"
echo "========================================"

View File

@@ -1,16 +1,510 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TLSNotary Plugin Demo</title>
<title>TLSNotary Plugin test page</title>
<style>
.result {
background: #e8f5e8;
border: 2px solid #28a745;
border-radius: 8px;
padding: 16px;
margin: 20px 0;
font-size: 18px;
display: inline-block;
}
.debug {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 12px;
margin: 20px 0;
font-family: 'Courier New', monospace;
font-size: 12px;
white-space: pre-wrap;
max-height: 400px;
overflow-y: auto;
}
.plugin-buttons {
margin: 20px 0;
}
.plugin-buttons button {
margin-right: 10px;
padding: 10px 20px;
font-size: 16px;
}
.check-item {
margin: 10px 0;
padding: 10px;
border-radius: 4px;
border: 1px solid #ddd;
}
.check-item.checking {
background: #f0f8ff;
border-color: #007bff;
}
.check-item.success {
background: #f0f8f0;
border-color: #28a745;
}
.check-item.error {
background: #fff0f0;
border-color: #dc3545;
}
.status {
font-weight: bold;
margin-left: 10px;
}
.status.checking {
color: #007bff;
}
.status.success {
color: #28a745;
}
.status.error {
color: #dc3545;
}
.warning-box {
background: #fff3cd;
border: 2px solid #ffc107;
border-radius: 8px;
padding: 16px;
margin: 20px 0;
}
.warning-box h3 {
margin-top: 0;
color: #856404;
}
.console-section {
margin: 20px 0;
border: 1px solid #dee2e6;
border-radius: 8px;
background: #1e1e1e;
overflow: hidden;
}
.console-header {
background: #2d2d2d;
color: #fff;
padding: 10px 15px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #3d3d3d;
}
.console-title {
font-weight: 600;
font-size: 14px;
}
.console-output {
max-height: 300px;
overflow-y: auto;
padding: 10px;
font-family: 'Courier New', monospace;
font-size: 12px;
color: #d4d4d4;
}
.console-entry {
margin: 4px 0;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.console-entry.info {
color: #4fc3f7;
}
.console-entry.success {
color: #4caf50;
}
.console-entry.error {
color: #f44336;
}
.console-entry.warning {
color: #ff9800;
}
.console-timestamp {
color: #888;
margin-right: 8px;
}
.console-message {
color: inherit;
}
.btn-console {
background: #007bff;
color: white;
border: none;
padding: 5px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.btn-console:hover {
background: #0056b3;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<h1>TLSNotary Plugin Demo</h1>
<p>
This page demonstrates TLSNotary plugins. Choose a plugin to test below.
</p>
<!-- Browser compatibility warning -->
<div id="browser-warning" class="warning-box" style="display: none;">
<h3>⚠️ Browser Compatibility</h3>
<p><strong>Unsupported Browser Detected</strong></p>
<p>TLSNotary extension requires a Chrome-based browser (Chrome, Edge, Brave, etc.).</p>
<p>Please switch to a supported browser to continue.</p>
</div>
<!-- System checks -->
<div>
<strong>System Checks:</strong>
<div id="check-browser" class="check-item checking">
🌐 Browser: <span class="status checking">Checking...</span>
</div>
<div id="check-extension" class="check-item checking">
🔌 Extension: <span class="status checking">Checking...</span>
</div>
<div id="check-verifier" class="check-item checking">
✅ Verifier: <span class="status checking">Checking...</span>
<div id="verifier-instructions" style="display: none; margin-top: 10px; font-size: 14px;">
<p>Start the verifier server:</p>
<code>cd packages/verifier; cargo run --release</code>
<button onclick="checkVerifier()" style="margin-left: 10px; padding: 5px 10px;">Check Again</button>
</div>
</div>
</div>
<div style="margin-top: 20px;">
<strong>Steps:</strong>
<ol>
<li>Click one of the plugin "Run" buttons below.</li>
<li>The plugin will open a new browser window with the target website.</li>
<li>Log in to the website if you are not already logged in.</li>
<li>A TLSNotary overlay will appear in the bottom right corner.</li>
<li>Click the <strong>Prove</strong> button in the overlay to start the proving process.</li>
<li>After successful proving, you can close the browser window and the results will appear on this page.</li>
</ol>
</div>
<div class="plugin-buttons" id="buttonContainer"></div>
<!-- Console Section -->
<div class="console-section">
<div class="console-header">
<div class="console-title">Console Output</div>
<div style="display: flex; gap: 10px;">
<button class="btn-console" onclick="openExtensionLogs()" style="background: #6c757d;">View Extension
Logs</button>
<button class="btn-console" onclick="clearConsole()">Clear</button>
</div>
</div>
<div class="console-output" id="consoleOutput">
<div class="console-entry info">
<span class="console-timestamp">[INFO]</span>
<span class="console-message">💡 TLSNotary proving logs will appear here in real-time. You can also view them in
the extension console by clicking "View Extension Logs" above.</span>
</div>
</div>
</div>
<script>
console.log('Testing TLSNotary plugins...');
let allChecksPass = false;
// Console functionality
function addConsoleEntry(message, type = 'info') {
const consoleOutput = document.getElementById('consoleOutput');
const timestamp = new Date().toLocaleTimeString();
const entry = document.createElement('div');
entry.className = `console-entry ${type}`;
const timestampSpan = document.createElement('span');
timestampSpan.className = 'console-timestamp';
timestampSpan.textContent = `[${timestamp}]`;
const messageSpan = document.createElement('span');
messageSpan.className = 'console-message';
messageSpan.textContent = message;
entry.appendChild(timestampSpan);
entry.appendChild(messageSpan);
consoleOutput.appendChild(entry);
// Auto-scroll to bottom
consoleOutput.scrollTop = consoleOutput.scrollHeight;
}
function clearConsole() {
const consoleOutput = document.getElementById('consoleOutput');
consoleOutput.innerHTML = '';
addConsoleEntry('Console cleared', 'info');
// Re-add the tip
const tipEntry = document.createElement('div');
tipEntry.className = 'console-entry info';
tipEntry.innerHTML = '<span class="console-timestamp">[INFO]</span><span class="console-message">💡 TLSNotary proving logs will appear here in real-time.</span>';
consoleOutput.insertBefore(tipEntry, consoleOutput.firstChild);
}
function openExtensionLogs() {
// Open extensions page
window.open('chrome://extensions/', '_blank');
addConsoleEntry('Opening chrome://extensions/ - Find TLSNotary extension → click "service worker" → find "offscreen.html" → click "inspect"', 'info');
}
// Listen for logs from offscreen document
window.addEventListener('message', (event) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type === 'TLSN_OFFSCREEN_LOG') {
addConsoleEntry(event.data.message, event.data.level);
}
});
// Initialize console with welcome message
window.addEventListener('load', () => {
addConsoleEntry('TLSNotary Plugin Demo initialized', 'success');
});
// Check browser compatibility
function checkBrowserCompatibility() {
const isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
const isEdge = /Edg/.test(navigator.userAgent);
const isBrave = navigator.brave && typeof navigator.brave.isBrave === 'function';
const isChromium = /Chromium/.test(navigator.userAgent);
const isChromeBasedBrowser = isChrome || isEdge || isBrave || isChromium;
const checkDiv = document.getElementById('check-browser');
const warningDiv = document.getElementById('browser-warning');
const statusSpan = checkDiv.querySelector('.status');
if (isChromeBasedBrowser) {
checkDiv.className = 'check-item success';
statusSpan.className = 'status success';
statusSpan.textContent = '✅ Chrome-based browser detected';
return true;
} else {
checkDiv.className = 'check-item error';
statusSpan.className = 'status error';
statusSpan.textContent = '❌ Unsupported browser';
warningDiv.style.display = 'block';
return false;
}
}
// Check extension
async function checkExtension() {
const checkDiv = document.getElementById('check-extension');
const statusSpan = checkDiv.querySelector('.status');
// Wait a bit for tlsn to load if page just loaded
await new Promise(resolve => setTimeout(resolve, 1000));
if (typeof window.tlsn !== 'undefined') {
checkDiv.className = 'check-item success';
statusSpan.className = 'status success';
statusSpan.textContent = '✅ Extension installed';
return true;
} else {
checkDiv.className = 'check-item error';
statusSpan.className = 'status error';
statusSpan.innerHTML = '❌ Extension not found - <a href="chrome://extensions/" target="_blank">Install extension</a>';
return false;
}
}
// Check verifier server
async function checkVerifier() {
const checkDiv = document.getElementById('check-verifier');
const statusSpan = checkDiv.querySelector('.status');
const instructions = document.getElementById('verifier-instructions');
statusSpan.textContent = 'Checking...';
statusSpan.className = 'status checking';
checkDiv.className = 'check-item checking';
try {
const response = await fetch('http://localhost:7047/health');
if (response.ok && await response.text() === 'ok') {
checkDiv.className = 'check-item success';
statusSpan.className = 'status success';
statusSpan.textContent = '✅ Verifier running';
instructions.style.display = 'none';
return true;
} else {
throw new Error('Unexpected response');
}
} catch (error) {
checkDiv.className = 'check-item error';
statusSpan.className = 'status error';
statusSpan.textContent = '❌ Verifier not running';
instructions.style.display = 'block';
return false;
}
}
// Run all checks
async function runAllChecks() {
const browserOk = checkBrowserCompatibility();
if (!browserOk) {
allChecksPass = false;
return;
}
const extensionOk = await checkExtension();
const verifierOk = await checkVerifier();
allChecksPass = extensionOk && verifierOk;
updateButtonState();
}
// Update button state based on checks
function updateButtonState() {
const container = document.getElementById('buttonContainer');
const buttons = container.querySelectorAll('button');
buttons.forEach(button => {
button.disabled = !allChecksPass;
if (!allChecksPass) {
button.title = 'Please complete all system checks first';
} else {
button.title = '';
}
});
}
const plugins = {
twitter: {
name: 'Twitter profile Plugin',
file: 'twitter.js',
parseResult: (json) => {
return json.results[json.results.length - 1].value;
}
},
swissbank: {
name: 'Swiss Bank Plugin',
file: 'swissbank.js',
parseResult: (json) => {
return json.results[json.results.length - 1].value;
}
},
spotify: {
name: 'Spotify Plugin',
file: 'spotify.js',
parseResult: (json) => {
return json.results[json.results.length - 1].value;
}
}
};
async function runPlugin(pluginKey) {
const plugin = plugins[pluginKey];
const button = document.getElementById(`${pluginKey}Button`);
try {
addConsoleEntry(`🎬 Starting ${plugin.name} plugin...`, 'info');
console.log(`Running ${plugin.name} plugin...`);
button.disabled = true;
button.textContent = 'Running...';
const startTime = performance.now();
const pluginCode = await fetch(plugin.file).then(r => r.text());
addConsoleEntry('🔧 Executing plugin code...', 'info');
const result = await window.tlsn.execCode(pluginCode);
const executionTime = (performance.now() - startTime).toFixed(2);
const json = JSON.parse(result);
// Create result div
const resultDiv = document.createElement('div');
resultDiv.className = 'result';
resultDiv.innerHTML = plugin.parseResult(json);
document.body.appendChild(resultDiv);
// Create header
const header = document.createElement('h3');
header.textContent = `${plugin.name} Results:`;
document.body.appendChild(header);
// Create debug div
const debugDiv = document.createElement('div');
debugDiv.className = 'debug';
debugDiv.textContent = JSON.stringify(json.results, null, 2);
document.body.appendChild(debugDiv);
addConsoleEntry(`${plugin.name} completed successfully in ${executionTime}ms`, 'success');
// Remove the button after successful execution
button.remove();
} catch (err) {
console.error(err);
// Create error div
const errorDiv = document.createElement('pre');
errorDiv.style.color = 'red';
errorDiv.textContent = err.message;
document.body.appendChild(errorDiv);
button.textContent = `Run ${plugin.name}`;
button.disabled = false;
}
}
window.addEventListener('tlsn_loaded', () => {
console.log('TLSNotary client loaded, showing plugin buttons...');
const container = document.getElementById('buttonContainer');
Object.entries(plugins).forEach(([key, plugin]) => {
const button = document.createElement('button');
button.id = `${key}Button`;
button.textContent = `Run ${plugin.name}`;
button.onclick = () => runPlugin(key);
container.appendChild(button);
});
// Update button states after creating them
updateButtonState();
});
// Run checks on page load
window.addEventListener('load', () => {
setTimeout(() => {
runAllChecks();
}, 500);
});
</script>
</body>
</html>

View File

@@ -1,26 +0,0 @@
{
"name": "@tlsnotary/demo",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "npm run build:plugins && vite",
"build": "npm run build:plugins && vite build",
"build:plugins": "node build-plugins.js",
"preview": "vite preview",
"type-check": "tsc --noEmit"
},
"dependencies": {
"happy-dom": "20.0.11",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"webpack-dev-server": "5.2.2"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^7.3.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,299 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { SystemChecks } from './components/SystemChecks';
import { ConsoleOutput } from './components/Console';
import { PluginButtons } from './components/PluginButtons';
import { StatusBar } from './components/StatusBar';
import { CollapsibleSection } from './components/CollapsibleSection';
import { HowItWorks } from './components/HowItWorks';
import { WhyPlugins } from './components/WhyPlugins';
import { BuildYourOwn } from './components/BuildYourOwn';
import { plugins } from './plugins';
import { checkBrowserCompatibility, checkExtension, checkVerifier, formatTimestamp } from './utils';
import { ConsoleEntry, CheckStatus, PluginResult as PluginResultType } from './types';
import './App.css';
interface PluginResultData {
resultHtml: string;
debugJson: string;
}
export function App() {
const [consoleEntries, setConsoleEntries] = useState<ConsoleEntry[]>([
{
timestamp: formatTimestamp(),
message:
'💡 TLSNotary proving logs will appear here in real-time. You can also view them in the extension console by clicking "View Extension Logs" above.',
type: 'info',
},
]);
const [browserCheck, setBrowserCheck] = useState<{ status: CheckStatus; message: string }>({
status: 'checking',
message: 'Checking...',
});
const [extensionCheck, setExtensionCheck] = useState<{ status: CheckStatus; message: string }>({
status: 'checking',
message: 'Checking...',
});
const [verifierCheck, setVerifierCheck] = useState<{
status: CheckStatus;
message: string;
showInstructions: boolean;
}>({
status: 'checking',
message: 'Checking...',
showInstructions: false,
});
const [showBrowserWarning, setShowBrowserWarning] = useState(false);
const [allChecksPass, setAllChecksPass] = useState(false);
const [runningPlugins, setRunningPlugins] = useState<Set<string>>(new Set());
const [pluginResults, setPluginResults] = useState<Record<string, PluginResultData>>({});
const [consoleExpanded, setConsoleExpanded] = useState(false);
const addConsoleEntry = useCallback((message: string, type: ConsoleEntry['type'] = 'info') => {
setConsoleEntries((prev) => [
...prev,
{
timestamp: formatTimestamp(),
message,
type,
},
]);
}, []);
const handleClearConsole = useCallback(() => {
setConsoleEntries([
{
timestamp: formatTimestamp(),
message: 'Console cleared',
type: 'info',
},
{
timestamp: formatTimestamp(),
message: '💡 TLSNotary proving logs will appear here in real-time.',
type: 'info',
},
]);
}, []);
const handleOpenExtensionLogs = useCallback(() => {
window.open('chrome://extensions/', '_blank');
addConsoleEntry(
'Opening chrome://extensions/ - Find TLSNotary extension → click "service worker" → find "offscreen.html" → click "inspect"',
'info'
);
}, [addConsoleEntry]);
const runAllChecks = useCallback(async () => {
// Browser check
const browserOk = checkBrowserCompatibility();
if (browserOk) {
setBrowserCheck({ status: 'success', message: '✅ Chrome-based browser detected' });
setShowBrowserWarning(false);
} else {
setBrowserCheck({ status: 'error', message: '❌ Unsupported browser' });
setShowBrowserWarning(true);
setAllChecksPass(false);
return;
}
// Extension check
const extensionOk = await checkExtension();
if (extensionOk) {
setExtensionCheck({ status: 'success', message: '✅ Extension installed' });
} else {
setExtensionCheck({ status: 'error', message: '❌ Extension not found' });
}
// Verifier check
const verifierOk = await checkVerifier();
if (verifierOk) {
setVerifierCheck({ status: 'success', message: '✅ Verifier running', showInstructions: false });
} else {
setVerifierCheck({ status: 'error', message: '❌ Verifier not running', showInstructions: true });
}
setAllChecksPass(extensionOk && verifierOk);
}, []);
const handleRecheck = useCallback(async () => {
// Recheck extension
setExtensionCheck({ status: 'checking', message: 'Checking...' });
const extensionOk = await checkExtension();
if (extensionOk) {
setExtensionCheck({ status: 'success', message: '✅ Extension installed' });
} else {
setExtensionCheck({ status: 'error', message: '❌ Extension not found' });
}
// Recheck verifier
setVerifierCheck({ status: 'checking', message: 'Checking...', showInstructions: false });
const verifierOk = await checkVerifier();
if (verifierOk) {
setVerifierCheck({ status: 'success', message: '✅ Verifier running', showInstructions: false });
} else {
setVerifierCheck({ status: 'error', message: '❌ Verifier not running', showInstructions: true });
}
setAllChecksPass(extensionOk && verifierOk);
}, []);
const handleRunPlugin = useCallback(
async (pluginKey: string) => {
const plugin = plugins[pluginKey];
if (!plugin) return;
setRunningPlugins((prev) => new Set(prev).add(pluginKey));
setConsoleExpanded(true);
try {
const startTime = performance.now();
const pluginCode = await fetch(plugin.file).then((r) => r.text());
addConsoleEntry('🔧 Executing plugin code...', 'info');
const result = await window.tlsn!.execCode(pluginCode);
const executionTime = (performance.now() - startTime).toFixed(2);
const json: PluginResultType = JSON.parse(result);
setPluginResults((prev) => ({
...prev,
[pluginKey]: {
resultHtml: plugin.parseResult(json),
debugJson: JSON.stringify(json.results, null, 2),
},
}));
addConsoleEntry(`${plugin.name} completed successfully in ${executionTime}ms`, 'success');
} catch (err) {
console.error(err);
addConsoleEntry(`❌ Error: ${err instanceof Error ? err.message : String(err)}`, 'error');
} finally {
setRunningPlugins((prev) => {
const newSet = new Set(prev);
newSet.delete(pluginKey);
return newSet;
});
}
},
[addConsoleEntry]
);
// Listen for tlsn_loaded event
useEffect(() => {
const handleTlsnLoaded = () => {
console.log('TLSNotary client loaded');
addConsoleEntry('TLSNotary client loaded', 'success');
};
window.addEventListener('tlsn_loaded', handleTlsnLoaded);
return () => window.removeEventListener('tlsn_loaded', handleTlsnLoaded);
}, [addConsoleEntry]);
// Listen for offscreen logs
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type === 'TLSN_OFFSCREEN_LOG') {
addConsoleEntry(event.data.message, event.data.level);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [addConsoleEntry]);
// Run checks on mount
useEffect(() => {
addConsoleEntry('TLSNotary Plugin Demo initialized', 'success');
setTimeout(() => {
runAllChecks();
}, 500);
}, [runAllChecks, addConsoleEntry]);
return (
<div className="app-container">
<div className="hero-section">
<h1 className="hero-title">TLSNotary Plugin Demo</h1>
<p className="hero-subtitle">
zkTLS in action secure, private data verification from any website
</p>
</div>
<HowItWorks />
<StatusBar
browserOk={browserCheck.status === 'success'}
extensionOk={extensionCheck.status === 'success'}
verifierOk={verifierCheck.status === 'success'}
onRecheck={handleRecheck}
detailsContent={
<div className="checks-section">
<div className="checks-title">System Status Details</div>
<SystemChecks
checks={{
browser: browserCheck,
extension: extensionCheck,
verifier: verifierCheck,
}}
onRecheck={handleRecheck}
showBrowserWarning={showBrowserWarning}
/>
</div>
}
/>
<div className="content-card">
<h2 className="section-title">Try It: Demo Plugins</h2>
<p className="section-subtitle">
Run a plugin to see TLSNotary in action. Click "View Source" to see how each plugin works.
</p>
{!allChecksPass && (
<div className="alert-box">
<span className="alert-icon"></span>
<span>Complete system setup above to run plugins</span>
</div>
)}
<PluginButtons
plugins={plugins}
runningPlugins={runningPlugins}
pluginResults={pluginResults}
allChecksPass={allChecksPass}
onRunPlugin={handleRunPlugin}
/>
</div>
<WhyPlugins />
<BuildYourOwn />
<CollapsibleSection title="Console Output" expanded={consoleExpanded}>
<ConsoleOutput
entries={consoleEntries}
onClear={handleClearConsole}
onOpenExtensionLogs={handleOpenExtensionLogs}
/>
</CollapsibleSection>
<footer className="app-footer">
<a
href="https://github.com/tlsnotary/tlsn-extension/tree/main/packages/demo"
target="_blank"
rel="noopener noreferrer"
className="footer-link"
>
View source on GitHub
</a>
<span className="footer-version">v{__GIT_COMMIT_HASH__}</span>
</footer>
</div>
);
}
declare const __GIT_COMMIT_HASH__: string;

View File

@@ -1,62 +0,0 @@
export function BuildYourOwn() {
return (
<div className="build-your-own">
<div className="cta-content">
<h2 className="cta-title">Ready to Build Your Own Plugin?</h2>
<p className="cta-description">
Create custom plugins to prove data from any website.
Our SDK and documentation will help you get started in minutes.
</p>
<div className="cta-buttons">
<a
href="https://tlsnotary.org/docs/extension/plugins"
target="_blank"
rel="noopener noreferrer"
className="cta-btn cta-btn-primary"
>
📚 Read the Docs
</a>
<a
href="https://github.com/tlsnotary/tlsn-extension/tree/main/packages/demo/src/plugins"
target="_blank"
rel="noopener noreferrer"
className="cta-btn cta-btn-secondary"
>
💻 View Plugin Sources
</a>
</div>
<div className="cta-resources">
<h4 className="cta-resources-title">Resources</h4>
<ul className="cta-resources-list">
<li>
<a href="https://github.com/tlsnotary/tlsn-extension" target="_blank" rel="noopener noreferrer">
GitHub Repository
<span className="resource-desc"> Extension source code and examples</span>
</a>
</li>
<li>
<a href="https://tlsnotary.org/docs/extension/plugins" target="_blank" rel="noopener noreferrer">
TLSNotary Plugin Documentation
<span className="resource-desc"> Complete protocol and API reference</span>
</a>
</li>
<li>
<a href="https://tlsnotary.org" target="_blank" rel="noopener noreferrer">
TLSNotary
<span className="resource-desc"> TLSNotary landing page</span>
</a>
</li>
<li>
<a href="https://discord.com/invite/9XwESXtcN7" target="_blank" rel="noopener noreferrer">
Discord Community
<span className="resource-desc"> Get help and share your plugins</span>
</a>
</li>
</ul>
</div>
</div>
</div>
);
}

View File

@@ -1,28 +0,0 @@
import { useState, useEffect } from 'react';
interface CollapsibleSectionProps {
title: string;
defaultExpanded?: boolean;
expanded?: boolean;
children: React.ReactNode;
}
export function CollapsibleSection({ title, defaultExpanded = false, expanded, children }: CollapsibleSectionProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
useEffect(() => {
if (expanded !== undefined) {
setIsExpanded(expanded);
}
}, [expanded]);
return (
<div className="collapsible-section">
<button className="collapsible-header" onClick={() => setIsExpanded(!isExpanded)}>
<span className="collapsible-icon">{isExpanded ? '▼' : '▶'}</span>
<span className="collapsible-title">{title}</span>
</button>
{isExpanded && <div className="collapsible-content">{children}</div>}
</div>
);
}

View File

@@ -1,45 +0,0 @@
interface ConsoleEntryProps {
timestamp: string;
message: string;
type: 'info' | 'success' | 'error' | 'warning';
}
export function ConsoleEntry({ timestamp, message, type }: ConsoleEntryProps) {
return (
<div className={`console-entry ${type}`}>
<span className="console-timestamp">[{timestamp}]</span>
<span className="console-message">{message}</span>
</div>
);
}
interface ConsoleOutputProps {
entries: ConsoleEntryProps[];
onClear: () => void;
onOpenExtensionLogs: () => void;
}
export function ConsoleOutput({ entries, onClear, onOpenExtensionLogs }: ConsoleOutputProps) {
return (
<div className="console-section">
<div className="console-header">
<div className="console-title">Console Output</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button className="btn-console" onClick={onOpenExtensionLogs} style={{ background: '#6c757d' }}>
View Extension Logs
</button>
<button className="btn-console" onClick={onClear}>
Clear
</button>
</div>
</div>
<div className="console-output" id="consoleOutput">
{entries.map((entry, index) => (
<ConsoleEntry key={index} {...entry} />
))}
</div>
</div>
);
}

View File

@@ -1,51 +0,0 @@
export function HowItWorks() {
return (
<div className="how-it-works">
<h2 className="how-it-works-title">How It Works</h2>
<p className="how-it-works-subtitle">
Experience cryptographic proof generation in three simple steps
</p>
<div className="steps-container">
<div className="step">
<div className="step-number">1</div>
<div className="step-icon">🔌</div>
<h3 className="step-title">Run a Plugin</h3>
<p className="step-description">
Select a plugin and click "Run". A new browser window opens to the target website.
</p>
</div>
<div className="step-arrow"></div>
<div className="step">
<div className="step-number">2</div>
<div className="step-icon">🔐</div>
<h3 className="step-title">Create Proof</h3>
<p className="step-description">
Log in if needed, then click "Prove". TLSNotary creates a cryptographic proof of your data.
</p>
</div>
<div className="step-arrow"></div>
<div className="step">
<div className="step-number">3</div>
<div className="step-icon"></div>
<h3 className="step-title">Verify Result</h3>
<p className="step-description">
The proof is verified by the server. Only the data you chose to reveal is shared.
</p>
</div>
</div>
<div className="how-it-works-note">
<span className="note-icon">💡</span>
<span>
<strong>Your data stays private:</strong> Plugins run inside the TLSNotary extension's secure sandbox.
Data flows through your browser never through third-party servers.
</span>
</div>
</div>
);
}

View File

@@ -1,111 +0,0 @@
import { useState } from 'react';
import { Plugin } from '../types';
interface PluginResultData {
resultHtml: string;
debugJson: string;
}
interface PluginButtonsProps {
plugins: Record<string, Plugin>;
runningPlugins: Set<string>;
pluginResults: Record<string, PluginResultData>;
allChecksPass: boolean;
onRunPlugin: (pluginKey: string) => void;
}
export function PluginButtons({
plugins,
runningPlugins,
pluginResults,
allChecksPass,
onRunPlugin,
}: PluginButtonsProps) {
const [expandedRawData, setExpandedRawData] = useState<Set<string>>(new Set());
const toggleRawData = (key: string) => {
setExpandedRawData((prev) => {
const newSet = new Set(prev);
if (newSet.has(key)) {
newSet.delete(key);
} else {
newSet.add(key);
}
return newSet;
});
};
return (
<div className="plugin-grid">
{Object.entries(plugins).map(([key, plugin]) => {
const isRunning = runningPlugins.has(key);
const result = pluginResults[key];
const hasResult = !!result;
return (
<div key={key} className={`plugin-card ${hasResult ? 'plugin-card--completed' : ''}`}>
<div className="plugin-header">
<div className="plugin-logo">{plugin.logo}</div>
<div className="plugin-info">
<h3 className="plugin-name">
{plugin.name}
{hasResult && <span className="plugin-badge"> Verified</span>}
</h3>
<p className="plugin-description">{plugin.description}</p>
</div>
</div>
<div className="plugin-actions">
<button
className="plugin-run-btn"
disabled={!allChecksPass || isRunning}
onClick={() => onRunPlugin(key)}
title={!allChecksPass ? 'Please complete all system checks first' : ''}
>
{isRunning ? (
<>
<span className="spinner"></span> Running...
</>
) : hasResult ? (
'↻ Run Again'
) : (
'▶ Run Plugin'
)}
</button>
<a
href={plugin.file}
target="_blank"
rel="noopener noreferrer"
className="plugin-source-btn"
>
<span>📄 View Source</span>
</a>
</div>
{hasResult && (
<div className="plugin-result">
<div className="plugin-result-header">
<span className="plugin-result-title">Result</span>
</div>
<div
className="plugin-result-content"
dangerouslySetInnerHTML={{ __html: result.resultHtml }}
/>
<button
className="plugin-raw-toggle"
onClick={() => toggleRawData(key)}
>
{expandedRawData.has(key) ? '▼ Hide Raw Data' : '▶ Show Raw Data'}
</button>
{expandedRawData.has(key) && (
<pre className="plugin-raw-data">{result.debugJson}</pre>
)}
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -1,98 +0,0 @@
import { useState } from 'react';
interface StatusBarProps {
browserOk: boolean;
extensionOk: boolean;
verifierOk: boolean;
onRecheck: () => void;
detailsContent?: React.ReactNode;
}
export function StatusBar({
browserOk,
extensionOk,
verifierOk,
onRecheck,
detailsContent,
}: StatusBarProps) {
const [showDetails, setShowDetails] = useState(false);
const allOk = browserOk && extensionOk && verifierOk;
const someIssues = !allOk;
return (
<div className={`status-bar ${allOk ? 'status-ready' : 'status-issues'}`}>
<div className="status-bar-content">
<div className="status-indicator">
{allOk ? (
<>
<span className="status-icon"></span>
<span className="status-text">System Ready</span>
</>
) : (
<>
<span className="status-icon"></span>
<span className="status-text">Setup Required</span>
</>
)}
</div>
<div className="status-items">
<div className={`status-badge ${browserOk ? 'ok' : 'error'}`}>
Browser: {browserOk ? '✓' : '✗'}
</div>
<div className={`status-badge ${extensionOk ? 'ok' : 'error'}`}>
Extension: {extensionOk ? '✓' : '✗'}
</div>
<div className={`status-badge ${verifierOk ? 'ok' : 'error'}`}>
Verifier: {verifierOk ? '✓' : '✗'}
</div>
</div>
<div className="status-actions">
{!verifierOk && (
<button className="btn-recheck" onClick={onRecheck}>
Recheck
</button>
)}
<button
className={`btn-details ${showDetails ? 'expanded' : ''}`}
onClick={() => setShowDetails(!showDetails)}
>
<span className="btn-details-icon">{showDetails ? '▼' : '▶'}</span>
<span>Details</span>
</button>
</div>
</div>
{
someIssues && (
<div className="status-help">
{!browserOk && <div>Please use a Chrome-based browser (Chrome, Edge, Brave)</div>}
{!extensionOk && (
<div>
TLSNotary extension not detected.{' '}
<a href="chrome://extensions/" target="_blank" rel="noopener noreferrer">
Install extension
</a>
{' '}then <strong>refresh this page</strong>.
</div>
)}
{!verifierOk && (
<div>
Verifier server not running. Start it with: <code>cd packages/verifier; cargo run --release</code>
</div>
)}
</div>
)
}
{
showDetails && detailsContent && (
<div className="status-details-content">
{detailsContent}
</div>
)
}
</div >
);
}

View File

@@ -1,85 +0,0 @@
import { CheckStatus } from '../types';
interface CheckItemProps {
id: string;
icon: string;
label: string;
status: CheckStatus;
message: string;
showInstructions?: boolean;
onRecheck?: () => void;
}
export function CheckItem({ icon, label, status, message, showInstructions, onRecheck }: CheckItemProps) {
return (
<div className={`check-item ${status}`}>
{icon} {label}: <span className={`status ${status}`}>{message}</span>
{showInstructions && (
<div style={{ marginTop: '10px', fontSize: '14px' }}>
<p>Start the verifier server:</p>
<code>cd packages/verifier; cargo run --release</code>
{onRecheck && (
<button onClick={onRecheck} style={{ marginLeft: '10px', padding: '5px 10px' }}>
Check Again
</button>
)}
</div>
)}
</div>
);
}
interface SystemChecksProps {
checks: {
browser: { status: CheckStatus; message: string };
extension: { status: CheckStatus; message: string };
verifier: { status: CheckStatus; message: string; showInstructions: boolean };
};
onRecheck: () => void;
showBrowserWarning: boolean;
}
export function SystemChecks({ checks, onRecheck, showBrowserWarning }: SystemChecksProps) {
return (
<>
{showBrowserWarning && (
<div className="warning-box">
<h3> Browser Compatibility</h3>
<p>
<strong>Unsupported Browser Detected</strong>
</p>
<p>TLSNotary extension requires a Chrome-based browser (Chrome, Edge, Brave, etc.).</p>
<p>Please switch to a supported browser to continue.</p>
</div>
)}
<div>
<strong>System Checks:</strong>
<CheckItem
id="check-browser"
icon="🌐"
label="Browser"
status={checks.browser.status}
message={checks.browser.message}
/>
<CheckItem
id="check-extension"
icon="🔌"
label="Extension"
status={checks.extension.status}
message={checks.extension.message}
/>
<CheckItem
id="check-verifier"
icon="✅"
label="Verifier"
status={checks.verifier.status}
message={checks.verifier.message}
showInstructions={checks.verifier.showInstructions}
onRecheck={onRecheck}
/>
</div>
</>
);
}

View File

@@ -1,39 +0,0 @@
export function WhyPlugins() {
return (
<div className="why-plugins">
<h2 className="why-plugins-title">Why Plugins?</h2>
<p className="why-plugins-subtitle">
TLSNotary plugins provide a secure, flexible way to prove and verify web data
</p>
<div className="benefits-grid">
<div className="benefit-card">
<div className="benefit-icon">🔒</div>
<h3 className="benefit-title">Secure by Design</h3>
<p className="benefit-description">
Plugins run inside the TLSNotary extension's sandboxed environment.
Your credentials and sensitive data never leave your browser.
</p>
</div>
<div className="benefit-card">
<div className="benefit-icon">👤</div>
<h3 className="benefit-title">User-Controlled</h3>
<p className="benefit-description">
Data flows through the user's browser not third-party servers.
You choose exactly what data to reveal in each proof.
</p>
</div>
<div className="benefit-card">
<div className="benefit-icon"></div>
<h3 className="benefit-title">Easy to Build</h3>
<p className="benefit-description">
Write plugins in JavaScript with a simple API.
Intercept requests, create proofs, and build custom UIs with minimal code.
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,10 +0,0 @@
// Environment configuration helper
// Reads from Vite's import.meta.env (populated from .env files)
const VERIFIER_HOST = (import.meta as any).env.VITE_VERIFIER_HOST || 'localhost:7047';
const SSL = (import.meta as any).env.VITE_SSL === 'true';
export const config = {
verifierUrl: `${SSL ? 'https' : 'http'}://${VERIFIER_HOST}`,
getProxyUrl: (host: string) => `${SSL ? 'wss' : 'ws'}://${VERIFIER_HOST}/proxy?token=${host}`,
};

View File

@@ -1,9 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -1,31 +0,0 @@
import { Plugin } from './types';
export const plugins: Record<string, Plugin> = {
twitter: {
name: 'Twitter Profile',
description: 'Prove your Twitter profile information with cryptographic verification',
logo: '𝕏',
file: '/plugins/twitter.js',
parseResult: (json) => {
return json.results[json.results.length - 1].value;
},
},
swissbank: {
name: 'Swiss Bank',
description: 'Verify your Swiss bank account balance securely and privately. (Login: admin / admin)',
logo: '🏦',
file: '/plugins/swissbank.js',
parseResult: (json) => {
return json.results[json.results.length - 1].value;
},
},
spotify: {
name: 'Spotify',
description: 'Prove your Spotify listening history and music preferences',
logo: '🎵',
file: '/plugins/spotify.js',
parseResult: (json) => {
return json.results[json.results.length - 1].value;
},
},
};

View File

@@ -1,224 +0,0 @@
/// <reference types="@tlsn/plugin-sdk/src/globals" />
// @ts-ignore - These will be replaced at build time by Vite's define option
const VERIFIER_URL = VITE_VERIFIER_URL;
// @ts-ignore
const PROXY_URL_BASE = VITE_PROXY_URL;
const api = 'api.spotify.com';
const ui = 'https://developer.spotify.com/';
const top_artist_path = '/v1/me/top/artists?time_range=medium_term&limit=1';
const config = {
name: 'Spotify Top Artist',
description: 'This plugin will prove your top artist on Spotify.',
requests: [
{
method: 'GET',
host: 'api.spotify.com',
pathname: '/v1/me/top/artists',
verifierUrl: VERIFIER_URL,
},
],
urls: [
'https://developer.spotify.com/*',
],
};
async function onClick() {
const isRequestPending = useState('isRequestPending', false);
if (isRequestPending) return;
setState('isRequestPending', true);
const [header] = useHeaders(headers => {
return headers.filter(header => header.url.includes(`https://${api}`));
});
const headers = {
authorization: header.requestHeaders.find(header => header.name === 'Authorization')?.value,
Host: api,
'Accept-Encoding': 'identity',
Connection: 'close',
};
const resp = await prove(
{
url: `https://${api}${top_artist_path}`,
method: 'GET',
headers: headers,
},
{
verifierUrl: VERIFIER_URL,
proxyUrl: PROXY_URL_BASE + api,
maxRecvData: 2400,
maxSentData: 600,
handlers: [
{ type: 'SENT', part: 'START_LINE', action: 'REVEAL', },
{ type: 'RECV', part: 'START_LINE', action: 'REVEAL', },
{
type: 'RECV', part: 'HEADERS', action: 'REVEAL', params: { key: 'date', },
},
{
type: 'RECV', part: 'BODY', action: 'REVEAL', params: { type: 'json', path: 'items[0].name', },
},
]
}
);
done(JSON.stringify(resp));
}
function expandUI() {
setState('isMinimized', false);
}
function minimizeUI() {
setState('isMinimized', true);
}
function main() {
const [header] = useHeaders(headers => headers.filter(h => h.url.includes(`https://${api}`)));
const isMinimized = useState('isMinimized', false);
const isRequestPending = useState('isRequestPending', false);
useEffect(() => {
openWindow(ui);
}, []);
if (isMinimized) {
return div({
style: {
position: 'fixed',
bottom: '20px',
right: '20px',
width: '60px',
height: '60px',
borderRadius: '50%',
backgroundColor: '#1DB954',
boxShadow: '0 4px 8px rgba(0,0,0,0.3)',
zIndex: '999999',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.3s ease',
fontSize: '24px',
color: 'white',
},
onclick: 'expandUI',
}, ['🎵']);
}
return div({
style: {
position: 'fixed',
bottom: '0',
right: '8px',
width: '280px',
borderRadius: '8px 8px 0 0',
backgroundColor: 'white',
boxShadow: '0 -2px 10px rgba(0,0,0,0.1)',
zIndex: '999999',
fontSize: '14px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
overflow: 'hidden',
},
}, [
div({
style: {
background: 'linear-gradient(135deg, #1DB954 0%, #1AA34A 100%)',
padding: '12px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
color: 'white',
}
}, [
div({
style: {
fontWeight: '600',
fontSize: '16px',
}
}, ['Spotify Top Artist']),
button({
style: {
background: 'transparent',
border: 'none',
color: 'white',
fontSize: '20px',
cursor: 'pointer',
padding: '0',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
onclick: 'minimizeUI',
}, [''])
]),
div({
style: {
padding: '20px',
backgroundColor: '#f8f9fa',
}
}, [
div({
style: {
marginBottom: '16px',
padding: '12px',
borderRadius: '6px',
backgroundColor: header ? '#d4edda' : '#f8d7da',
color: header ? '#155724' : '#721c24',
border: `1px solid ${header ? '#c3e6cb' : '#f5c6cb'}`,
fontWeight: '500',
},
}, [
header ? '✓ Api token detected' : '⚠ No API token detected'
]),
header ? (
button({
style: {
width: '100%',
padding: '12px 24px',
borderRadius: '6px',
border: 'none',
background: 'linear-gradient(135deg, #1DB954 0%, #1AA34A 100%)',
color: 'white',
fontWeight: '600',
fontSize: '15px',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
opacity: isRequestPending ? 0.5 : 1,
cursor: isRequestPending ? 'not-allowed' : 'pointer',
},
onclick: 'onClick',
}, [isRequestPending ? 'Generating Proof...' : 'Generate Proof'])
) : (
div({
style: {
textAlign: 'center',
color: '#666',
padding: '12px',
backgroundColor: '#fff3cd',
borderRadius: '6px',
border: '1px solid #ffeaa7',
}
}, ['Please login to Spotify to continue'])
)
])
]);
}
export default {
main,
onClick,
expandUI,
minimizeUI,
config,
};

View File

@@ -1,257 +0,0 @@
/// <reference types="@tlsn/plugin-sdk/src/globals" />
// Environment variables injected at build time
// @ts-ignore - These will be replaced at build time by Vite's define option
const VERIFIER_URL = VITE_VERIFIER_URL;
// @ts-ignore
const PROXY_URL_BASE = VITE_PROXY_URL;
const config = {
name: 'Swiss Bank Prover',
description: 'This plugin will prove your Swiss Bank account balance.',
requests: [
{
method: 'GET',
host: 'swissbank.tlsnotary.org',
pathname: '/balances',
verifierUrl: VERIFIER_URL,
},
],
urls: [
'https://swissbank.tlsnotary.org/*',
],
};
const host = 'swissbank.tlsnotary.org';
const ui_path = '/account';
const path = '/balances';
const url = `https://${host}${path}`;
async function onClick() {
const isRequestPending = useState('isRequestPending', false);
if (isRequestPending) return;
setState('isRequestPending', true);
const [header] = useHeaders((headers: any[]) => {
console.log('Intercepted headers:', headers);
return headers.filter(header => header.url.includes(`https://${host}`));
});
const headers = {
'cookie': header.requestHeaders.find((header: any) => header.name === 'Cookie')?.value,
Host: host,
'Accept-Encoding': 'identity',
Connection: 'close',
};
const resp = await prove(
{
url: url,
method: 'GET',
headers: headers,
},
{
verifierUrl: VERIFIER_URL,
proxyUrl: PROXY_URL_BASE + 'swissbank.tlsnotary.org',
maxRecvData: 460,
maxSentData: 180,
handlers: [
{ type: 'SENT', part: 'START_LINE', action: 'REVEAL' },
{ type: 'RECV', part: 'START_LINE', action: 'REVEAL' },
{
type: 'RECV',
part: 'BODY',
action: 'REVEAL',
params: { type: 'json', path: 'account_id' },
},
{
type: 'RECV',
part: 'BODY',
action: 'REVEAL',
params: { type: 'json', path: 'accounts.CHF' },
},
],
}
);
done(JSON.stringify(resp));
}
function expandUI() {
setState('isMinimized', false);
}
function minimizeUI() {
setState('isMinimized', true);
}
function main() {
const [header] = useHeaders((headers: any[]) =>
headers.filter(header => header.url.includes(`https://${host}${ui_path}`))
);
const hasNecessaryHeader = header?.requestHeaders.some((h: any) => h.name === 'Cookie');
const isMinimized = useState('isMinimized', false);
const isRequestPending = useState('isRequestPending', false);
useEffect(() => {
openWindow(`https://${host}${ui_path}`);
}, []);
if (isMinimized) {
return div(
{
style: {
position: 'fixed',
bottom: '20px',
right: '20px',
width: '60px',
height: '60px',
borderRadius: '50%',
backgroundColor: '#4CAF50',
boxShadow: '0 4px 8px rgba(0,0,0,0.3)',
zIndex: '999999',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.3s ease',
fontSize: '24px',
color: 'white',
},
onclick: 'expandUI',
},
['🔐']
);
}
return div(
{
style: {
position: 'fixed',
bottom: '0',
right: '8px',
width: '280px',
borderRadius: '8px 8px 0 0',
backgroundColor: 'white',
boxShadow: '0 -2px 10px rgba(0,0,0,0.1)',
zIndex: '999999',
fontSize: '14px',
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
overflow: 'hidden',
},
},
[
div(
{
style: {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '12px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
color: 'white',
},
},
[
div(
{
style: {
fontWeight: '600',
fontSize: '16px',
},
},
['Swiss Bank Prover']
),
button(
{
style: {
background: 'transparent',
border: 'none',
color: 'white',
fontSize: '20px',
cursor: 'pointer',
padding: '0',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
onclick: 'minimizeUI',
},
['']
),
]
),
div(
{
style: {
padding: '20px',
backgroundColor: '#f8f9fa',
},
},
[
div(
{
style: {
marginBottom: '16px',
padding: '12px',
borderRadius: '6px',
backgroundColor: header ? '#d4edda' : '#f8d7da',
color: header ? '#155724' : '#721c24',
border: `1px solid ${header ? '#c3e6cb' : '#f5c6cb'}`,
fontWeight: '500',
},
},
[hasNecessaryHeader ? '✓ Cookie detected' : '⚠ No Cookie detected']
),
hasNecessaryHeader
? button(
{
style: {
width: '100%',
padding: '12px 24px',
borderRadius: '6px',
border: 'none',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
fontWeight: '600',
fontSize: '15px',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
opacity: isRequestPending ? 0.5 : 1,
cursor: isRequestPending ? 'not-allowed' : 'pointer',
},
onclick: 'onClick',
},
[isRequestPending ? 'Generating Proof...' : 'Generate Proof']
)
: div(
{
style: {
textAlign: 'center',
color: '#666',
padding: '12px',
backgroundColor: '#fff3cd',
borderRadius: '6px',
border: '1px solid #ffeaa7',
},
},
['Please login to continue']
),
]
),
]
);
}
export default {
main,
onClick,
expandUI,
minimizeUI,
config,
};

View File

@@ -1,278 +0,0 @@
/// <reference types="@tlsn/plugin-sdk/src/globals" />
// Environment variables injected at build time
// @ts-ignore - These will be replaced at build time by Vite's define option
const VERIFIER_URL = VITE_VERIFIER_URL;
// @ts-ignore
const PROXY_URL_BASE = VITE_PROXY_URL;
// =============================================================================
// PLUGIN CONFIGURATION
// =============================================================================
/**
* The config object defines plugin metadata displayed to users.
* This information appears in the plugin selection UI.
*/
const config = {
name: 'X Profile Prover',
description: 'This plugin will prove your X.com profile.',
requests: [
{
method: 'GET',
host: 'api.x.com',
pathname: '/1.1/account/settings.json',
verifierUrl: VERIFIER_URL,
},
],
urls: [
'https://x.com/*',
],
};
// =============================================================================
// PROOF GENERATION CALLBACK
// =============================================================================
/**
* This function is triggered when the user clicks the "Prove" button.
* It extracts authentication headers from intercepted requests and generates
* a TLSNotary proof using the unified prove() API.
*/
async function onClick() {
const isRequestPending = useState('isRequestPending', false);
if (isRequestPending) return;
setState('isRequestPending', true);
const [header] = useHeaders((headers: any[]) => {
return headers.filter(header => header.url.includes('https://api.x.com/1.1/account/settings.json'));
});
const headers = {
'cookie': header.requestHeaders.find((header: any) => header.name === 'Cookie')?.value,
'x-csrf-token': header.requestHeaders.find((header: any) => header.name === 'x-csrf-token')?.value,
'x-client-transaction-id': header.requestHeaders.find((header: any) => header.name === 'x-client-transaction-id')?.value,
Host: 'api.x.com',
authorization: header.requestHeaders.find((header: any) => header.name === 'authorization')?.value,
'Accept-Encoding': 'identity',
Connection: 'close',
};
const resp = await prove(
{
url: 'https://api.x.com/1.1/account/settings.json',
method: 'GET',
headers: headers,
},
{
verifierUrl: VERIFIER_URL,
proxyUrl: PROXY_URL_BASE + 'api.x.com',
maxRecvData: 4000,
maxSentData: 2000,
handlers: [
{ type: 'SENT', part: 'START_LINE', action: 'REVEAL' },
{ type: 'RECV', part: 'START_LINE', action: 'REVEAL' },
{
type: 'RECV',
part: 'HEADERS',
action: 'REVEAL',
params: { key: 'date' },
},
{
type: 'RECV',
part: 'BODY',
action: 'REVEAL',
params: {
type: 'json',
path: 'screen_name',
},
},
],
}
);
done(JSON.stringify(resp));
}
function expandUI() {
setState('isMinimized', false);
}
function minimizeUI() {
setState('isMinimized', true);
}
// =============================================================================
// MAIN UI FUNCTION
// =============================================================================
function main() {
const [header] = useHeaders((headers: any[]) =>
headers.filter(header => header.url.includes('https://api.x.com/1.1/account/settings.json'))
);
const isMinimized = useState('isMinimized', false);
const isRequestPending = useState('isRequestPending', false);
useEffect(() => {
openWindow('https://x.com');
}, []);
if (isMinimized) {
return div(
{
style: {
position: 'fixed',
bottom: '20px',
right: '20px',
width: '60px',
height: '60px',
borderRadius: '50%',
backgroundColor: '#4CAF50',
boxShadow: '0 4px 8px rgba(0,0,0,0.3)',
zIndex: '999999',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.3s ease',
fontSize: '24px',
color: 'white',
},
onclick: 'expandUI',
},
['🔐']
);
}
return div(
{
style: {
position: 'fixed',
bottom: '0',
right: '8px',
width: '280px',
borderRadius: '8px 8px 0 0',
backgroundColor: 'white',
boxShadow: '0 -2px 10px rgba(0,0,0,0.1)',
zIndex: '999999',
fontSize: '14px',
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
overflow: 'hidden',
},
},
[
div(
{
style: {
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '12px 16px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
color: 'white',
},
},
[
div(
{
style: {
fontWeight: '600',
fontSize: '16px',
},
},
['X Profile Prover']
),
button(
{
style: {
background: 'transparent',
border: 'none',
color: 'white',
fontSize: '20px',
cursor: 'pointer',
padding: '0',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
onclick: 'minimizeUI',
},
['']
),
]
),
div(
{
style: {
padding: '20px',
backgroundColor: '#f8f9fa',
},
},
[
div(
{
style: {
marginBottom: '16px',
padding: '12px',
borderRadius: '6px',
backgroundColor: header ? '#d4edda' : '#f8d7da',
color: header ? '#155724' : '#721c24',
border: `1px solid ${header ? '#c3e6cb' : '#f5c6cb'}`,
fontWeight: '500',
},
},
[header ? '✓ Profile detected' : '⚠ No profile detected']
),
header
? button(
{
style: {
width: '100%',
padding: '12px 24px',
borderRadius: '6px',
border: 'none',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
fontWeight: '600',
fontSize: '15px',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
opacity: isRequestPending ? 0.5 : 1,
cursor: isRequestPending ? 'not-allowed' : 'pointer',
},
onclick: 'onClick',
},
[isRequestPending ? 'Generating Proof...' : 'Generate Proof']
)
: div(
{
style: {
textAlign: 'center',
color: '#666',
padding: '12px',
backgroundColor: '#fff3cd',
borderRadius: '6px',
border: '1px solid #ffeaa7',
},
},
['Please login to x.com to continue']
),
]
),
]
);
}
// =============================================================================
// PLUGIN EXPORTS
// =============================================================================
export default {
main,
onClick,
expandUI,
minimizeUI,
config,
};

View File

@@ -1,45 +0,0 @@
export interface Plugin {
name: string;
description: string;
logo: string;
file: string;
parseResult: (json: PluginResult) => string;
}
export interface PluginResult {
results: Array<{
value: string;
}>;
}
export interface ConsoleEntry {
timestamp: string;
message: string;
type: 'info' | 'success' | 'error' | 'warning';
}
export type CheckStatus = 'checking' | 'success' | 'error';
export interface SystemCheck {
id: string;
label: string;
status: CheckStatus;
message: string;
showInstructions?: boolean;
}
declare global {
interface Window {
tlsn?: {
execCode: (code: string) => Promise<string>;
};
}
interface Navigator {
brave?: {
isBrave: () => Promise<boolean>;
};
}
}
export { };

View File

@@ -1,32 +0,0 @@
import { config } from './config';
export function checkBrowserCompatibility(): boolean {
const isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
const isEdge = /Edg/.test(navigator.userAgent);
const isBrave = navigator.brave && typeof navigator.brave.isBrave === 'function';
const isChromium = /Chromium/.test(navigator.userAgent);
return isChrome || isEdge || isBrave || isChromium;
}
export async function checkExtension(): Promise<boolean> {
// Wait a bit for tlsn to load if page just loaded
await new Promise((resolve) => setTimeout(resolve, 1000));
return typeof window.tlsn !== 'undefined';
}
export async function checkVerifier(): Promise<boolean> {
try {
const response = await fetch(`${config.verifierUrl}/health`);
if (response.ok && (await response.text()) === 'ok') {
return true;
}
return false;
} catch {
return false;
}
}
export function formatTimestamp(): string {
return new Date().toLocaleTimeString();
}

31
packages/demo/start.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/sh
#
# Demo Server Startup Script
#
# This script starts the verifier server and demo file server via Docker.
# Note: Run generate.sh first to create plugin files in the generated/ directory.
#
# Usage:
# ./generate.sh && ./start.sh # Generate and start
# ./start.sh # Start only (assumes generated/ exists)
# ./start.sh -d # Start in detached mode
set -e
cd "$(dirname "$0")"
# Check if generated directory exists
if [ ! -d "generated" ]; then
echo "ERROR: generated/ directory not found!"
echo "Please run ./generate.sh first to create plugin files."
exit 1
fi
echo "========================================"
echo "TLSNotary Demo Server"
echo "========================================"
echo "Starting Docker services..."
echo "========================================"
# Start docker compose
docker compose up --build "$@"

View File

@@ -1,36 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"src"
],
"exclude": [
"src/plugins/**/*.ts"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@@ -1,12 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}

View File

@@ -1,15 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"isolatedModules": false
},
"include": [
"src/plugins/**/*.ts",
"src/plugins/plugin-globals.d.ts"
],
"exclude": []
}

View File

@@ -1,30 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { execSync } from 'child_process';
// Get git commit hash at build time
const getGitCommitHash = () => {
try {
return execSync('git rev-parse --short HEAD').toString().trim();
} catch {
return 'unknown';
}
};
export default defineConfig({
define: {
__GIT_COMMIT_HASH__: JSON.stringify(getGitCommitHash()),
},
plugins: [react()],
build: {
outDir: 'dist',
sourcemap: true,
},
server: {
port: 3000,
open: true,
headers: {
'Cache-Control': 'no-store',
},
},
});

View File

@@ -1,6 +1,6 @@
{
"name": "extension",
"version": "0.1.0.1400",
"version": "0.1.0",
"license": "MIT",
"repository": {
"type": "git",
@@ -20,13 +20,13 @@
"serve:test": "python3 -m http.server 8081 --directory ./tests/integration"
},
"dependencies": {
"@tlsn/common": "*",
"@tlsn/plugin-sdk": "*",
"@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",
"@tlsn/common": "*",
"@tlsn/plugin-sdk": "*",
"@uiw/react-codemirror": "^4.25.2",
"assert": "^2.1.0",
"buffer": "^6.0.3",
@@ -47,6 +47,7 @@
"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"
},
@@ -83,7 +84,7 @@
"eslint-plugin-react-hooks": "^4.6.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.1.0",
"happy-dom": "^20.0.11",
"happy-dom": "^19.0.1",
"html-loader": "^4.2.0",
"html-webpack-plugin": "^5.5.0",
"null-loader": "^4.0.1",
@@ -106,7 +107,7 @@
"webextension-polyfill": "^0.10.0",
"webpack": "^5.75.0",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^5.2.2",
"webpack-dev-server": "^4.11.1",
"webpack-ext-reloader": "^1.1.12",
"zip-webpack-plugin": "^4.0.1"
}

View File

@@ -2,9 +2,14 @@ import browser from 'webextension-polyfill';
import { logger } from '@tlsn/common';
import { PluginConfig } from '@tlsn/plugin-sdk/src/types';
interface ConfirmationResult {
allowed: boolean;
grantedOrigins: string[];
}
interface PendingConfirmation {
requestId: string;
resolve: (allowed: boolean) => void;
resolve: (result: ConfirmationResult) => void;
reject: (error: Error) => void;
windowId?: number;
timeoutId?: ReturnType<typeof setTimeout>;
@@ -34,15 +39,16 @@ export class ConfirmationManager {
/**
* Request confirmation from the user for plugin execution.
* Opens a popup window displaying plugin details and waits for user response.
* The popup also handles requesting host permissions from the browser.
*
* @param config - Plugin configuration (can be null for unknown plugins)
* @param requestId - Unique ID to correlate the confirmation request
* @returns Promise that resolves to true (allowed) or false (denied)
* @returns Promise that resolves to ConfirmationResult with allowed status and granted origins
*/
async requestConfirmation(
config: PluginConfig | null,
requestId: string,
): Promise<boolean> {
): Promise<ConfirmationResult> {
// Check if there's already a pending confirmation
if (this.pendingConfirmations.size > 0) {
logger.warn(
@@ -54,7 +60,7 @@ export class ConfirmationManager {
// Build URL with plugin info as query params
const popupUrl = this.buildPopupUrl(config, requestId);
return new Promise<boolean>(async (resolve, reject) => {
return new Promise<ConfirmationResult>(async (resolve, reject) => {
try {
// Create the confirmation popup window
const window = await browser.windows.create({
@@ -77,7 +83,7 @@ export class ConfirmationManager {
if (pending) {
logger.debug('[ConfirmationManager] Confirmation timed out');
this.cleanup(requestId);
resolve(false); // Treat timeout as denial
resolve({ allowed: false, grantedOrigins: [] }); // Treat timeout as denial
}
}, this.CONFIRMATION_TIMEOUT_MS);
@@ -109,8 +115,13 @@ export class ConfirmationManager {
*
* @param requestId - The request ID to match
* @param allowed - Whether the user allowed execution
* @param grantedOrigins - Origins that were granted by the browser (from popup's permission request)
*/
handleConfirmationResponse(requestId: string, allowed: boolean): void {
handleConfirmationResponse(
requestId: string,
allowed: boolean,
grantedOrigins: string[] = [],
): void {
const pending = this.pendingConfirmations.get(requestId);
if (!pending) {
logger.warn(
@@ -120,11 +131,11 @@ export class ConfirmationManager {
}
logger.debug(
`[ConfirmationManager] Received response for ${requestId}: ${allowed ? 'allowed' : 'denied'}`,
`[ConfirmationManager] Received response for ${requestId}: ${allowed ? 'allowed' : 'denied'}, origins: ${grantedOrigins.length}`,
);
// Resolve the promise
pending.resolve(allowed);
// Resolve the promise with the result
pending.resolve({ allowed, grantedOrigins });
// Close popup window if still open
if (pending.windowId) {
@@ -154,7 +165,7 @@ export class ConfirmationManager {
logger.debug(
`[ConfirmationManager] Treating window close as denial for request: ${requestId}`,
);
pending.resolve(false); // Treat close as denial
pending.resolve({ allowed: false, grantedOrigins: [] }); // Treat close as denial
this.cleanup(requestId);
break;
}

View File

@@ -0,0 +1,306 @@
import browser from 'webextension-polyfill';
import { logger } from '@tlsn/common';
import { RequestPermission } from '@tlsn/plugin-sdk/src/types';
/**
* Represents a permission pattern with metadata for display.
*/
interface PermissionPattern {
/** Browser permission pattern (e.g., "https://api.x.com/*") */
origin: string;
/** Original host from config */
host: string;
/** Original pathname pattern from config */
pathname: string;
}
/**
* Manages runtime host permissions for plugin execution.
*
* The extension uses optional_host_permissions instead of blanket host_permissions.
* Permissions are requested before plugin execution and revoked after completion.
*/
export class PermissionManager {
/** Track permissions currently in use by active plugin executions */
private activePermissions: Map<string, number> = new Map();
/**
* Extract permission patterns from plugin's request permissions.
* Uses req.host and req.pathname to build patterns.
*
* Note: Browser permissions API only supports origin-level permissions,
* but we track pathname for display and internal validation.
*
* @example
* // Input: { host: "api.x.com", pathname: "/1.1/users/*" }
* // Output: { origin: "https://api.x.com/*", host: "api.x.com", pathname: "/1.1/users/*" }
*/
extractPermissionPatterns(
requests: RequestPermission[],
): PermissionPattern[] {
const patterns: PermissionPattern[] = [];
const seenOrigins = new Set<string>();
for (const req of requests) {
// Build origin pattern from req.host
const origin = `https://${req.host}/*`;
if (!seenOrigins.has(origin)) {
seenOrigins.add(origin);
patterns.push({
origin,
host: req.host,
pathname: req.pathname,
});
}
// Also add the verifier URL host if different
try {
const verifierUrl = new URL(req.verifierUrl);
const verifierOrigin = `${verifierUrl.protocol}//${verifierUrl.host}/*`;
if (!seenOrigins.has(verifierOrigin)) {
seenOrigins.add(verifierOrigin);
patterns.push({
origin: verifierOrigin,
host: verifierUrl.host,
pathname: '/*', // Verifier needs full access
});
}
} catch {
// Invalid verifier URL, skip
}
}
return patterns;
}
/**
* Extract just the origin patterns for browser.permissions API.
* Uses req.host to build origin-level patterns.
*/
extractOrigins(requests: RequestPermission[]): string[] {
return this.extractPermissionPatterns(requests).map((p) => p.origin);
}
/**
* Format permissions for display in UI.
* Shows both host and pathname for user clarity.
*/
formatForDisplay(requests: RequestPermission[]): string[] {
return requests.map((req) => `${req.host}${req.pathname}`);
}
/**
* Request permissions for the given host patterns.
* Tracks active permissions to handle concurrent plugin executions.
*
* @returns true if all permissions granted, false otherwise
*/
async requestPermissions(origins: string[]): Promise<boolean> {
if (origins.length === 0) return true;
try {
// Check if already have permissions
const alreadyGranted = await this.hasPermissions(origins);
if (alreadyGranted) {
logger.debug(
'[PermissionManager] Permissions already granted for:',
origins,
);
// Track that we're using these permissions
this.trackPermissionUsage(origins, 1);
return true;
}
// Request new permissions
const granted = await browser.permissions.request({ origins });
logger.info(
`[PermissionManager] Permissions ${granted ? 'granted' : 'denied'} for:`,
origins,
);
if (granted) {
this.trackPermissionUsage(origins, 1);
}
return granted;
} catch (error) {
logger.error('[PermissionManager] Failed to request permissions:', error);
return false;
}
}
/**
* Check if an origin is removable (not a manifest-defined wildcard pattern).
* Manifest patterns like "http://*\/*" and "https://*\/*" cannot be removed.
*/
private isRemovableOrigin(origin: string): boolean {
// Manifest-defined patterns that cannot be removed
const manifestPatterns = ['http://*/*', 'https://*/*', '<all_urls>'];
if (manifestPatterns.includes(origin)) {
return false;
}
// Check if host contains wildcards (not removable)
try {
const url = new URL(origin.replace('/*', '/'));
if (url.hostname.includes('*')) {
return false;
}
return true;
} catch {
return false;
}
}
/**
* Remove permissions for the given host patterns.
* Only removes if no other plugin execution is using them.
* Filters out non-removable (manifest-defined) patterns.
*/
async removePermissions(origins: string[]): Promise<boolean> {
logger.info('[PermissionManager] removePermissions called with:', origins);
if (origins.length === 0) {
logger.info('[PermissionManager] No origins to remove (empty array)');
return true;
}
// Decrement usage count
this.trackPermissionUsage(origins, -1);
// Only remove permissions that are no longer in use AND are removable
logger.info('[PermissionManager] Filtering origins for removal...');
const originsToRemove = origins.filter((origin) => {
const count = this.activePermissions.get(origin) || 0;
const notInUse = count <= 0;
const removable = this.isRemovableOrigin(origin);
logger.info(
`[PermissionManager] Origin "${origin}": count=${count}, notInUse=${notInUse}, removable=${removable}`,
);
if (!removable) {
logger.debug(
`[PermissionManager] Skipping non-removable origin: ${origin}`,
);
}
return notInUse && removable;
});
logger.info(
`[PermissionManager] After filtering: ${originsToRemove.length} origins to remove:`,
originsToRemove,
);
if (originsToRemove.length === 0) {
logger.info(
'[PermissionManager] No removable permissions to remove from:',
origins,
);
return true;
}
try {
// Verify which permissions actually exist before removal
const existingPermissions = await browser.permissions.getAll();
logger.info(
'[PermissionManager] Current permissions before removal:',
existingPermissions.origins,
);
// Filter to only origins that actually exist
const existingOrigins = new Set(existingPermissions.origins || []);
const originsActuallyExist = originsToRemove.filter((o) =>
existingOrigins.has(o),
);
if (originsActuallyExist.length === 0) {
logger.info(
'[PermissionManager] None of the origins to remove actually exist, skipping',
);
return true;
}
logger.info(
'[PermissionManager] Calling browser.permissions.remove() for:',
originsActuallyExist,
);
const removed = await browser.permissions.remove({
origins: originsActuallyExist,
});
logger.info(
`[PermissionManager] browser.permissions.remove() returned: ${removed}`,
);
// Log permissions after removal
const afterPermissions = await browser.permissions.getAll();
logger.info(
'[PermissionManager] Permissions after removal:',
afterPermissions.origins,
);
return removed;
} catch (error) {
// Handle "required permissions" error gracefully
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error(
`[PermissionManager] browser.permissions.remove() threw error: ${errorMessage}`,
);
if (errorMessage.includes('required permissions')) {
logger.warn(
'[PermissionManager] Some permissions are required and cannot be removed:',
originsToRemove,
);
return true; // Don't treat as failure
}
logger.error('[PermissionManager] Failed to remove permissions:', error);
return false;
}
}
/**
* Check if permissions are already granted for the given origins.
*/
async hasPermissions(origins: string[]): Promise<boolean> {
if (origins.length === 0) return true;
try {
return await browser.permissions.contains({ origins });
} catch (error) {
logger.error('[PermissionManager] Failed to check permissions:', error);
return false;
}
}
/**
* Track permission usage for concurrent plugin executions.
* @param origins - Origins to track
* @param delta - +1 for acquire, -1 for release
*/
private trackPermissionUsage(origins: string[], delta: number): void {
for (const origin of origins) {
const current = this.activePermissions.get(origin) || 0;
const newCount = current + delta;
if (newCount <= 0) {
this.activePermissions.delete(origin);
} else {
this.activePermissions.set(origin, newCount);
}
}
}
/**
* Get the number of active usages for an origin.
* Useful for debugging and testing.
*/
getActiveUsageCount(origin: string): number {
return this.activePermissions.get(origin) || 0;
}
}
// Export singleton instance
export const permissionManager = new PermissionManager();

View File

@@ -116,6 +116,7 @@ export class WindowManager implements IWindowManager {
overlayVisible: false,
pluginUIVisible: false,
showOverlayWhenReady: config.showOverlay !== false, // Default: true
grantedOrigins: [],
};
this.windows.set(config.id, managedWindow);
@@ -363,6 +364,38 @@ export class WindowManager implements IWindowManager {
return window?.headers || [];
}
/**
* Set granted origins for a window (for cleanup on close)
*
* @param windowId - Chrome window ID
* @param origins - Array of origin patterns that were granted
*/
setGrantedOrigins(windowId: number, origins: string[]): void {
const window = this.windows.get(windowId);
if (!window) {
logger.error(
`[WindowManager] Cannot set granted origins for non-existent window: ${windowId}`,
);
return;
}
window.grantedOrigins = origins;
logger.debug(
`[WindowManager] Set granted origins for window ${windowId}:`,
origins,
);
}
/**
* Get granted origins for a window
*
* @param windowId - Chrome window ID
* @returns Array of granted origin patterns
*/
getGrantedOrigins(windowId: number): string[] {
const window = this.windows.get(windowId);
return window?.grantedOrigins || [];
}
async showPluginUI(
windowId: number,
json: any,

View File

@@ -1,6 +1,7 @@
import browser from 'webextension-polyfill';
import { WindowManager } from '../../background/WindowManager';
import { confirmationManager } from '../../background/ConfirmationManager';
import { permissionManager } from '../../background/PermissionManager';
import type { PluginConfig } from '@tlsn/plugin-sdk/src/types';
import type {
InterceptedRequest,
@@ -21,6 +22,187 @@ getStoredLogLevel().then((level) => {
// Initialize WindowManager for multi-window support
const windowManager = new WindowManager();
// Temporary storage for granted origins pending window creation
// When a plugin is executed, we store the granted origins here
// Then when the plugin opens a window, we associate these origins with that window
let pendingGrantedOrigins: string[] = [];
// =============================================================================
// DYNAMIC WEBREQUEST LISTENER MANAGEMENT
// =============================================================================
// Track active dynamic listeners by origin pattern
// We need to store handler references to remove them later
type ListenerHandlers = {
onBeforeRequest: (
details: browser.WebRequest.OnBeforeRequestDetailsType,
) => void;
onBeforeSendHeaders: (
details: browser.WebRequest.OnBeforeSendHeadersDetailsType,
) => void;
};
const dynamicListeners = new Map<string, ListenerHandlers>();
/**
* Handler for onBeforeRequest - intercepts request body
*/
function createOnBeforeRequestHandler() {
return (details: browser.WebRequest.OnBeforeRequestDetailsType) => {
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,
requestBody: details.requestBody,
};
logger.debug(`[webRequest] Intercepted request: ${details.url}`);
windowManager.addRequest(managedWindow.id, request);
}
};
}
/**
* Handler for onBeforeSendHeaders - intercepts request headers
*/
function createOnBeforeSendHeadersHandler() {
return (details: browser.WebRequest.OnBeforeSendHeadersDetailsType) => {
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,
};
logger.debug(`[webRequest] Intercepted headers for: ${details.url}`);
windowManager.addHeader(managedWindow.id, header);
}
};
}
/**
* Register dynamic webRequest listeners for specific origin patterns.
* Must be called AFTER permissions are granted for those origins.
*/
function registerDynamicListeners(origins: string[]): void {
logger.info(`[webRequest] registerDynamicListeners called with:`, origins);
for (const origin of origins) {
// Skip if already registered
if (dynamicListeners.has(origin)) {
logger.debug(`[webRequest] Listener already registered for: ${origin}`);
continue;
}
logger.info(`[webRequest] Registering listener for: "${origin}"`);
const onBeforeRequestHandler = createOnBeforeRequestHandler();
const onBeforeSendHeadersHandler = createOnBeforeSendHeadersHandler();
try {
browser.webRequest.onBeforeRequest.addListener(
onBeforeRequestHandler,
{ urls: [origin] },
['requestBody', 'extraHeaders'],
);
browser.webRequest.onBeforeSendHeaders.addListener(
onBeforeSendHeadersHandler,
{ urls: [origin] },
['requestHeaders', 'extraHeaders'],
);
dynamicListeners.set(origin, {
onBeforeRequest: onBeforeRequestHandler,
onBeforeSendHeaders: onBeforeSendHeadersHandler,
});
logger.info(
`[webRequest] Successfully registered listener for: ${origin}`,
);
} catch (error) {
logger.error(
`[webRequest] Failed to register listener for ${origin}:`,
error,
);
}
}
}
/**
* Unregister dynamic webRequest listeners for specific origin patterns.
* Should be called when permissions are revoked.
*/
function unregisterDynamicListeners(origins: string[]): void {
logger.info(`[webRequest] unregisterDynamicListeners called with:`, origins);
logger.info(
`[webRequest] Current dynamicListeners Map keys:`,
Array.from(dynamicListeners.keys()),
);
for (const origin of origins) {
logger.info(`[webRequest] Looking for listener with key: "${origin}"`);
const handlers = dynamicListeners.get(origin);
if (!handlers) {
logger.warn(
`[webRequest] No listener found for: "${origin}" - available keys: ${Array.from(dynamicListeners.keys()).join(', ')}`,
);
continue;
}
logger.info(`[webRequest] Found handlers for: ${origin}, removing...`);
try {
browser.webRequest.onBeforeRequest.removeListener(
handlers.onBeforeRequest,
);
logger.info(
`[webRequest] Removed onBeforeRequest listener for: ${origin}`,
);
browser.webRequest.onBeforeSendHeaders.removeListener(
handlers.onBeforeSendHeaders,
);
logger.info(
`[webRequest] Removed onBeforeSendHeaders listener for: ${origin}`,
);
dynamicListeners.delete(origin);
logger.info(
`[webRequest] Successfully unregistered all listeners for: ${origin}`,
);
} catch (error) {
logger.error(
`[webRequest] Failed to unregister listener for ${origin}:`,
error,
);
}
}
logger.info(
`[webRequest] After unregister, remaining keys:`,
Array.from(dynamicListeners.keys()),
);
}
/**
* Unregister all dynamic webRequest listeners.
*/
function unregisterAllDynamicListeners(): void {
const origins = Array.from(dynamicListeners.keys());
unregisterDynamicListeners(origins);
}
// Create context menu for Developer Console - only for extension icon
browser.contextMenus.create({
id: 'developer-console',
@@ -43,88 +225,88 @@ browser.runtime.onInstalled.addListener((details) => {
logger.info('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);
// NOTE: Static webRequest listeners removed - using dynamic listeners instead
// Dynamic listeners are registered when plugin permissions are granted
// and unregistered when permissions are revoked
if (managedWindow && details.tabId !== undefined) {
const request: InterceptedRequest = {
id: `${details.requestId}`,
method: details.method,
url: details.url,
timestamp: Date.now(),
tabId: details.tabId,
requestBody: details.requestBody,
};
// if (details.requestBody) {
// console.log(details.requestBody);
// }
// 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
// Listen for window removal - clean up permissions and listeners
browser.windows.onRemoved.addListener(async (windowId) => {
const managedWindow = windowManager.getWindow(windowId);
if (managedWindow) {
logger.debug(
`Managed window closed: ${managedWindow.uuid} (ID: ${windowId})`,
);
// Get granted origins before closing the window
const grantedOrigins = managedWindow.grantedOrigins || [];
logger.info(
`[Permission Cleanup] Window ${windowId} grantedOrigins:`,
grantedOrigins,
);
logger.info(
`[Permission Cleanup] Current dynamicListeners keys:`,
Array.from(dynamicListeners.keys()),
);
// Clean up permissions and listeners for this window
if (grantedOrigins.length > 0) {
logger.info(
`[Permission Cleanup] Window ${windowId} closed, cleaning up ${grantedOrigins.length} origins:`,
grantedOrigins,
);
// Step 1: Unregister dynamic webRequest listeners FIRST
logger.info(
'[Permission Cleanup] Step 1: Unregistering dynamic listeners...',
);
unregisterDynamicListeners(grantedOrigins);
logger.info(
'[Permission Cleanup] Step 1 complete. Remaining dynamicListeners:',
Array.from(dynamicListeners.keys()),
);
// Step 2: Revoke host permissions AFTER listeners are removed
logger.info('[Permission Cleanup] Step 2: Revoking host permissions...');
try {
const removed =
await permissionManager.removePermissions(grantedOrigins);
logger.info(
`[Permission Cleanup] Step 2 complete. Permissions removed: ${removed}`,
);
} catch (error) {
logger.error(
`[Permission Cleanup] Step 2 FAILED for window ${windowId}:`,
error,
);
}
} else {
logger.info(
`[Permission Cleanup] Window ${windowId} has no granted origins to clean up`,
);
}
await windowManager.closeWindow(windowId);
}
});
// Listen for tab updates to show overlay when tab is ready (Task 3.4)
// Listen for tab updates to show overlay when tab is ready
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
// Check if this tab belongs to a managed window for overlay handling
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) {
logger.debug(
`Tab ${tabId} complete, showing overlay for window ${managedWindow.id}`,
);
await windowManager.showOverlay(managedWindow.id);
if (managedWindow) {
// If overlay should be shown but isn't visible yet, show it now
if (managedWindow.showOverlayWhenReady && !managedWindow.overlayVisible) {
logger.debug(
`Tab ${tabId} complete, showing overlay for window ${managedWindow.id}`,
);
await windowManager.showOverlay(managedWindow.id);
}
}
});
@@ -162,6 +344,7 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
confirmationManager.handleConfirmationResponse(
request.requestId,
request.allowed,
request.grantedOrigins || [],
);
return true;
}
@@ -182,12 +365,12 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
// Continue with null config - user will see "Unknown Plugin" warning
}
// Step 2: Request user confirmation
// Step 2: Request user confirmation (popup also handles permission request)
const confirmRequestId = `confirm_${Date.now()}_${Math.random()}`;
let userAllowed: boolean;
let confirmResult: { allowed: boolean; grantedOrigins: string[] };
try {
userAllowed = await confirmationManager.requestConfirmation(
confirmResult = await confirmationManager.requestConfirmation(
pluginConfig,
confirmRequestId,
);
@@ -204,8 +387,8 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
}
// Step 3: If user denied, return rejection error
if (!userAllowed) {
logger.info('User rejected plugin execution');
if (!confirmResult.allowed) {
logger.info('User rejected plugin execution or denied permissions');
sendResponse({
success: false,
error: 'User rejected plugin execution',
@@ -213,13 +396,36 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
return;
}
// Step 4: User allowed - proceed with execution
logger.info('User allowed plugin execution, proceeding...');
// Step 4: User allowed and permissions granted - proceed with execution
logger.info(
'User allowed plugin execution, granted origins:',
confirmResult.grantedOrigins,
);
// Ensure offscreen document exists
// Step 4.1: Register dynamic webRequest listeners for granted origins
// This must happen AFTER permissions are granted
if (confirmResult.grantedOrigins.length > 0) {
logger.info(
'[EXEC_CODE] Registering dynamic webRequest listeners for:',
confirmResult.grantedOrigins,
);
registerDynamicListeners(confirmResult.grantedOrigins);
// Store granted origins for association with the window that will be opened
// The OPEN_WINDOW handler will associate these with the new window
pendingGrantedOrigins = confirmResult.grantedOrigins;
logger.info(
'[EXEC_CODE] Stored pendingGrantedOrigins:',
pendingGrantedOrigins,
);
}
// Step 4.2: Execute plugin
// Note: Cleanup happens when the window is closed (windows.onRemoved listener)
// NOT in a finally block here, because EXEC_CODE_OFFSCREEN returns immediately
// when the plugin starts, not when it finishes
await createOffscreenDocument();
// Forward to offscreen document
const response = await chrome.runtime.sendMessage({
type: 'EXEC_CODE_OFFSCREEN',
code: request.code,
@@ -229,6 +435,18 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
sendResponse(response);
} catch (error) {
logger.error('Error executing code:', error);
// Clean up listeners and pending origins if execution failed before window opened
if (pendingGrantedOrigins.length > 0) {
logger.info(
'[EXEC_CODE] Error occurred - cleaning up pending origins:',
pendingGrantedOrigins,
);
unregisterDynamicListeners(pendingGrantedOrigins);
await permissionManager.removePermissions(pendingGrantedOrigins);
pendingGrantedOrigins = [];
}
sendResponse({
success: false,
error:
@@ -240,6 +458,55 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
return true; // Keep message channel open for async response
}
// Handle permission requests from offscreen/content scripts
if (request.type === 'REQUEST_HOST_PERMISSIONS') {
logger.debug('REQUEST_HOST_PERMISSIONS received:', request.origins);
(async () => {
try {
const granted = await permissionManager.requestPermissions(
request.origins,
);
sendResponse({ success: true, granted });
} catch (error) {
logger.error('Failed to request permissions:', error);
sendResponse({
success: false,
error:
error instanceof Error
? error.message
: 'Permission request failed',
});
}
})();
return true;
}
if (request.type === 'REMOVE_HOST_PERMISSIONS') {
logger.debug('REMOVE_HOST_PERMISSIONS received:', request.origins);
(async () => {
try {
const removed = await permissionManager.removePermissions(
request.origins,
);
sendResponse({ success: true, removed });
} catch (error) {
logger.error('Failed to remove permissions:', error);
sendResponse({
success: false,
error:
error instanceof Error
? error.message
: 'Permission removal failed',
});
}
})();
return true;
}
// Handle CLOSE_WINDOW requests
if (request.type === 'CLOSE_WINDOW') {
logger.debug('CLOSE_WINDOW request received:', request.windowId);
@@ -334,6 +601,30 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse: any) => {
logger.debug(`Window registered: ${managedWindow.uuid}`);
// Associate pending granted origins with this window
// These will be cleaned up when the window is closed
logger.info(
`[OPEN_WINDOW] pendingGrantedOrigins at association time:`,
pendingGrantedOrigins,
);
if (pendingGrantedOrigins.length > 0) {
logger.info(
`[OPEN_WINDOW] Associating ${pendingGrantedOrigins.length} origins with window ${windowId}:`,
pendingGrantedOrigins,
);
windowManager.setGrantedOrigins(windowId, pendingGrantedOrigins);
logger.info(
`[OPEN_WINDOW] Successfully associated origins. Window ${windowId} now has grantedOrigins:`,
windowManager.getGrantedOrigins(windowId),
);
// Clear pending origins now that they're associated
pendingGrantedOrigins = [];
} else {
logger.warn(
`[OPEN_WINDOW] No pending origins to associate with window ${windowId}`,
);
}
// Send success response
sendResponse({
type: 'WINDOW_OPENED',

View File

@@ -24,6 +24,48 @@ interface PluginInfo {
urls?: string[];
}
/**
* Extract origin patterns for browser.permissions API and webRequest interception.
*
* Includes:
* - requests[].host: API hosts for webRequest interception (e.g., api.x.com)
* - urls[]: Page URLs for webRequest interception (e.g., x.com/*)
*
* Does NOT include:
* - verifierUrl: Extension connects to verifier directly, doesn't need host permission
*
* NOTE: Page URL permissions may become "required" if they match content_scripts.matches,
* but API host permissions (like api.x.com) should remain revocable.
*
* @param requests - Request permissions from plugin config (for API endpoints)
* @param urls - Page URL patterns from plugin config
*/
function extractOrigins(
requests: RequestPermission[],
urls?: string[],
): string[] {
const origins = new Set<string>();
// Add target API hosts from requests
for (const req of requests) {
origins.add(`https://${req.host}/*`);
}
// Add page URLs for webRequest interception
if (urls) {
for (const urlPattern of urls) {
if (
urlPattern.startsWith('https://') ||
urlPattern.startsWith('http://')
) {
origins.add(urlPattern);
}
}
}
return Array.from(origins);
}
const ConfirmPopup: React.FC = () => {
const [pluginInfo, setPluginInfo] = useState<PluginInfo | null>(null);
const [requestId, setRequestId] = useState<string>('');
@@ -109,16 +151,64 @@ const ConfirmPopup: React.FC = () => {
if (!requestId) return;
try {
let grantedOrigins: string[] = [];
// Request host permissions if plugin has requests or urls defined
// This MUST be done in the popup context (user gesture) for the browser to show the prompt
const hasRequests =
pluginInfo?.requests && pluginInfo.requests.length > 0;
const hasUrls = pluginInfo?.urls && pluginInfo.urls.length > 0;
if (hasRequests || hasUrls) {
const origins = extractOrigins(
pluginInfo?.requests || [],
pluginInfo?.urls,
);
logger.info('Requesting permissions for origins:', origins);
try {
const granted = await browser.permissions.request({ origins });
if (!granted) {
logger.warn('User denied host permissions');
// Send denial response
await browser.runtime.sendMessage({
type: 'PLUGIN_CONFIRM_RESPONSE',
requestId,
allowed: false,
reason: 'Host permissions denied',
});
window.close();
return;
}
grantedOrigins = origins;
logger.info('Host permissions granted:', grantedOrigins);
} catch (permError) {
logger.error('Failed to request permissions:', permError);
await browser.runtime.sendMessage({
type: 'PLUGIN_CONFIRM_RESPONSE',
requestId,
allowed: false,
reason: 'Permission request failed',
});
window.close();
return;
}
}
// Send approval with granted origins
await browser.runtime.sendMessage({
type: 'PLUGIN_CONFIRM_RESPONSE',
requestId,
allowed: true,
grantedOrigins,
});
window.close();
} catch (err) {
logger.error('Failed to send allow response:', err);
}
}, [requestId]);
}, [requestId, pluginInfo]);
const handleDeny = useCallback(async () => {
if (!requestId) return;

View File

@@ -36,10 +36,23 @@ body {
border-top-color: #4a9eff;
border-radius: 50%;
animation: spin 1s linear infinite;
&--small {
width: 24px;
height: 24px;
border-width: 2px;
}
&--tiny {
width: 14px;
height: 14px;
border-width: 2px;
display: inline-block;
}
}
&__header {
margin-bottom: 32px;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
@@ -53,6 +66,36 @@ body {
}
}
// Tabs
&__tabs {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
&__tab {
padding: 10px 20px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: #a0a0a0;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.06);
color: #e8e8e8;
}
&--active {
background: rgba(74, 158, 255, 0.15);
border-color: rgba(74, 158, 255, 0.4);
color: #4a9eff;
}
}
&__content {
background: rgba(255, 255, 255, 0.03);
border-radius: 12px;
@@ -186,6 +229,211 @@ body {
font-family: 'Monaco', 'Menlo', monospace;
}
// Permissions styles
&__permissions-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 24px;
color: #a0a0a0;
}
&__permissions-empty {
text-align: center;
padding: 40px 24px;
color: #a0a0a0;
p {
margin-top: 8px;
}
}
&__permissions-empty-icon {
font-size: 48px;
display: block;
margin-bottom: 16px;
}
&__permissions-empty-hint {
font-size: 13px;
color: #666;
margin-top: 8px;
}
&__permissions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
&__permissions-count {
font-size: 14px;
color: #a0a0a0;
}
&__delete-all-btn {
padding: 6px 12px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 6px;
color: #ef4444;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.5);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&__permissions-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
&__permission-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.04);
}
}
&__permission-origin {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 13px;
color: #e8e8e8;
word-break: break-all;
}
&__permission-delete {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: #a0a0a0;
cursor: pointer;
transition: all 0.2s ease;
margin-left: 12px;
&:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// Confirmation dialog
&__confirm-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
&__confirm-dialog {
background: linear-gradient(135deg, #1e293b 0%, #1a1a2e 100%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 24px;
max-width: 400px;
width: 90%;
animation: slideUp 0.2s ease;
h3 {
font-size: 18px;
font-weight: 600;
color: #fff;
margin-bottom: 12px;
}
p {
font-size: 14px;
color: #e8e8e8;
margin-bottom: 8px;
}
}
&__confirm-warning {
font-size: 13px !important;
color: #a0a0a0 !important;
margin-top: 12px !important;
}
&__confirm-actions {
display: flex;
gap: 12px;
margin-top: 24px;
justify-content: flex-end;
}
&__confirm-cancel {
padding: 10px 20px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
color: #e8e8e8;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
&__confirm-delete {
padding: 10px 20px;
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.4);
border-radius: 8px;
color: #ef4444;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(239, 68, 68, 0.3);
border-color: rgba(239, 68, 68, 0.6);
}
}
&__footer {
margin-top: 24px;
text-align: center;
@@ -202,3 +450,23 @@ body {
transform: rotate(360deg);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState, useCallback } from 'react';
import { createRoot } from 'react-dom/client';
import browser from 'webextension-polyfill';
import { LogLevel, logLevelToName, logger } from '@tlsn/common';
import {
getStoredLogLevel,
@@ -7,6 +8,11 @@ import {
} from '../../utils/logLevelStorage';
import './index.scss';
// Initialize logger
logger.init(LogLevel.DEBUG);
type TabId = 'logging' | 'permissions';
interface LogLevelOption {
level: LogLevel;
name: string;
@@ -37,19 +43,25 @@ const LOG_LEVEL_OPTIONS: LogLevelOption[] = [
];
const Options: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabId>('logging');
const [currentLevel, setCurrentLevel] = useState<LogLevel>(LogLevel.WARN);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
// Permissions state
const [hostPermissions, setHostPermissions] = useState<string[]>([]);
const [permissionsLoading, setPermissionsLoading] = useState(true);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
// Load current log level on mount
useEffect(() => {
const loadLevel = async () => {
try {
const level = await getStoredLogLevel();
setCurrentLevel(level);
// Initialize the logger with the stored level
logger.init(level);
logger.setLevel(level);
} catch (error) {
logger.error('Failed to load log level:', error);
} finally {
@@ -60,6 +72,65 @@ const Options: React.FC = () => {
loadLevel();
}, []);
// Load permissions when permissions tab is active
useEffect(() => {
if (activeTab === 'permissions') {
loadPermissions();
}
}, [activeTab]);
// Manifest-defined patterns that cannot be removed via permissions API
// Note: Since we removed declarative content_scripts from manifest.json,
// we no longer have required host permissions. Only web_accessible_resources
// uses <all_urls>, but that doesn't create host permissions.
// We still filter out wildcard patterns as a safety measure.
const MANIFEST_PATTERNS = new Set([
'http://*/*',
'https://*/*',
'<all_urls>',
]);
/**
* Check if an origin is removable (runtime-granted optional permission)
* A permission is removable if:
* 1. It's not a manifest-defined pattern (http://*\/* or https://*\/*)
* 2. It's a specific host pattern (e.g., https://api.x.com/*)
*/
const isRemovableOrigin = (origin: string): boolean => {
// Not removable if it's a manifest pattern
if (MANIFEST_PATTERNS.has(origin)) {
return false;
}
// Only consider specific host patterns as removable
// These are patterns like "https://api.x.com/*" (not wildcards)
try {
const url = new URL(origin.replace('/*', '/'));
// If host contains wildcards, it's not a specific host
if (url.hostname.includes('*')) {
return false;
}
return true;
} catch {
return false;
}
};
const loadPermissions = async () => {
setPermissionsLoading(true);
try {
const permissions = await browser.permissions.getAll();
logger.debug('All permissions:', permissions.origins);
// Filter to only show removable host permissions
const origins = (permissions.origins || []).filter(isRemovableOrigin);
logger.debug('Removable permissions:', origins);
setHostPermissions(origins);
} catch (error) {
logger.error('Failed to load permissions:', error);
} finally {
setPermissionsLoading(false);
}
};
const handleLevelChange = useCallback(async (level: LogLevel) => {
setSaving(true);
setSaveSuccess(false);
@@ -67,11 +138,9 @@ const Options: React.FC = () => {
try {
await setStoredLogLevel(level);
setCurrentLevel(level);
// Update the logger immediately
logger.setLevel(level);
setSaveSuccess(true);
// Clear success message after 2 seconds
setTimeout(() => setSaveSuccess(false), 2000);
} catch (error) {
logger.error('Failed to save log level:', error);
@@ -80,6 +149,121 @@ const Options: React.FC = () => {
}
}, []);
const handleDeletePermission = useCallback(async (origin: string) => {
logger.info('[Options] handleDeletePermission called for:', origin);
logger.info(
'[Options] isRemovableOrigin check:',
isRemovableOrigin(origin),
);
// Double-check that this origin is actually removable
if (!isRemovableOrigin(origin)) {
logger.warn('[Options] Origin is not removable, skipping:', origin);
setHostPermissions((prev) => prev.filter((o) => o !== origin));
setConfirmDelete(null);
return;
}
setDeleting(origin);
try {
// Get current permissions to verify the origin exists
const currentPerms = await browser.permissions.getAll();
logger.info('[Options] Current permissions:', currentPerms.origins);
if (!currentPerms.origins?.includes(origin)) {
logger.info(
'[Options] Origin not in current permissions, removing from UI',
);
setHostPermissions((prev) => prev.filter((o) => o !== origin));
return;
}
logger.info('[Options] Calling browser.permissions.remove for:', origin);
const removed = await browser.permissions.remove({ origins: [origin] });
if (removed) {
setHostPermissions((prev) => prev.filter((o) => o !== origin));
logger.info('[Options] Permission removed:', origin);
} else {
logger.warn('[Options] Failed to remove permission:', origin);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error('[Options] Error removing permission:', errorMessage);
// If Chrome says it's a required permission, remove from UI anyway
if (errorMessage.includes('required permissions')) {
logger.warn(
'[Options] Chrome considers this a required permission, removing from UI:',
origin,
);
setHostPermissions((prev) => prev.filter((o) => o !== origin));
}
} finally {
setDeleting(null);
setConfirmDelete(null);
}
}, []);
const handleDeleteAllPermissions = useCallback(async () => {
if (hostPermissions.length === 0) return;
logger.info(
'[Options] handleDeleteAllPermissions called for:',
hostPermissions,
);
setDeleting('all');
try {
// Filter to only actually removable permissions
const removableOrigins = hostPermissions.filter(isRemovableOrigin);
logger.info('[Options] Filtered removable origins:', removableOrigins);
if (removableOrigins.length === 0) {
logger.info('[Options] No removable permissions');
setHostPermissions([]);
return;
}
// Get current permissions to verify
const currentPerms = await browser.permissions.getAll();
const existingOrigins = new Set(currentPerms.origins || []);
const originsToRemove = removableOrigins.filter((o) =>
existingOrigins.has(o),
);
logger.info('[Options] Origins that actually exist:', originsToRemove);
if (originsToRemove.length === 0) {
logger.info('[Options] None of the origins exist in permissions');
setHostPermissions([]);
return;
}
const removed = await browser.permissions.remove({
origins: originsToRemove,
});
if (removed) {
setHostPermissions([]);
logger.info('[Options] All permissions removed');
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error('[Options] Error removing all permissions:', errorMessage);
// If it's a "required permissions" error, just clear the UI
if (errorMessage.includes('required permissions')) {
logger.warn('[Options] Some permissions are required, clearing UI');
// Reload to show actual state
loadPermissions();
}
} finally {
setDeleting(null);
setConfirmDelete(null);
}
}, [hostPermissions]);
if (loading) {
return (
<div className="options options--loading">
@@ -95,54 +279,190 @@ const Options: React.FC = () => {
<h1>TLSN Extension Settings</h1>
</header>
{/* Tabs */}
<nav className="options__tabs">
<button
className={`options__tab ${activeTab === 'logging' ? 'options__tab--active' : ''}`}
onClick={() => setActiveTab('logging')}
>
Logging
</button>
<button
className={`options__tab ${activeTab === 'permissions' ? 'options__tab--active' : ''}`}
onClick={() => setActiveTab('permissions')}
>
Permissions
</button>
</nav>
<main className="options__content">
<section className="options__section">
<h2>Logging</h2>
<p className="options__section-description">
Control the verbosity of console logs. Lower levels include all
higher severity logs.
</p>
{/* Logging Tab */}
{activeTab === 'logging' && (
<section className="options__section">
<h2>Log Level</h2>
<p className="options__section-description">
Control the verbosity of console logs. Lower levels include all
higher severity logs.
</p>
<div className="options__log-levels">
{LOG_LEVEL_OPTIONS.map((option) => (
<label
key={option.level}
className={`options__radio-label ${
currentLevel === option.level
? 'options__radio-label--selected'
: ''
}`}
>
<input
type="radio"
name="logLevel"
value={option.level}
checked={currentLevel === option.level}
onChange={() => handleLevelChange(option.level)}
disabled={saving}
className="options__radio-input"
/>
<span className="options__radio-custom"></span>
<span className="options__radio-text">
<span className="options__radio-name">{option.name}</span>
<span className="options__radio-description">
{option.description}
<div className="options__log-levels">
{LOG_LEVEL_OPTIONS.map((option) => (
<label
key={option.level}
className={`options__radio-label ${
currentLevel === option.level
? 'options__radio-label--selected'
: ''
}`}
>
<input
type="radio"
name="logLevel"
value={option.level}
checked={currentLevel === option.level}
onChange={() => handleLevelChange(option.level)}
disabled={saving}
className="options__radio-input"
/>
<span className="options__radio-custom"></span>
<span className="options__radio-text">
<span className="options__radio-name">{option.name}</span>
<span className="options__radio-description">
{option.description}
</span>
</span>
</span>
</label>
))}
</div>
</label>
))}
</div>
<div className="options__status">
{saving && <span className="options__saving">Saving...</span>}
{saveSuccess && (
<span className="options__success">Settings saved!</span>
<div className="options__status">
{saving && <span className="options__saving">Saving...</span>}
{saveSuccess && (
<span className="options__success">Settings saved!</span>
)}
<span className="options__current">
Current: {logLevelToName(currentLevel)}
</span>
</div>
</section>
)}
{/* Permissions Tab */}
{activeTab === 'permissions' && (
<section className="options__section">
<h2>Host Permissions</h2>
<p className="options__section-description">
These are the hosts the extension currently has access to. You can
revoke access by clicking the trash icon.
</p>
{permissionsLoading ? (
<div className="options__permissions-loading">
<div className="options__spinner options__spinner--small"></div>
<span>Loading permissions...</span>
</div>
) : hostPermissions.length === 0 ? (
<div className="options__permissions-empty">
<span className="options__permissions-empty-icon">🔒</span>
<p>No host permissions granted</p>
<p className="options__permissions-empty-hint">
Permissions are requested when you run plugins that need
network access.
</p>
</div>
) : (
<>
<div className="options__permissions-header">
<span className="options__permissions-count">
{hostPermissions.length} host
{hostPermissions.length !== 1 ? 's' : ''}
</span>
{hostPermissions.length > 1 && (
<button
className="options__delete-all-btn"
onClick={() => setConfirmDelete('all')}
disabled={deleting !== null}
>
Remove All
</button>
)}
</div>
<ul className="options__permissions-list">
{hostPermissions.map((origin) => (
<li key={origin} className="options__permission-item">
<span className="options__permission-origin">
{origin}
</span>
<button
className="options__permission-delete"
onClick={() => setConfirmDelete(origin)}
disabled={deleting !== null}
title="Remove permission"
>
{deleting === origin ? (
<span className="options__spinner options__spinner--tiny"></span>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
)}
</button>
</li>
))}
</ul>
</>
)}
<span className="options__current">
Current: {logLevelToName(currentLevel)}
</span>
</div>
</section>
{/* Confirmation Dialog */}
{confirmDelete && (
<div className="options__confirm-overlay">
<div className="options__confirm-dialog">
<h3>Confirm Removal</h3>
<p>
{confirmDelete === 'all'
? `Remove all ${hostPermissions.length} host permissions?`
: `Remove permission for "${confirmDelete}"?`}
</p>
<p className="options__confirm-warning">
Plugins will need to request this permission again to access
these hosts.
</p>
<div className="options__confirm-actions">
<button
className="options__confirm-cancel"
onClick={() => setConfirmDelete(null)}
>
Cancel
</button>
<button
className="options__confirm-delete"
onClick={() =>
confirmDelete === 'all'
? handleDeleteAllPermissions()
: handleDeletePermission(confirmDelete)
}
>
Remove
</button>
</div>
</div>
</div>
)}
</section>
)}
</main>
<footer className="options__footer">

View File

@@ -1,6 +1,6 @@
{
"manifest_version": 3,
"version": "0.1.0.1400",
"version": "0.1.0.13",
"name": "TLSNotary",
"description": "A Chrome extension for TLSNotary",
"options_page": "options.html",
@@ -15,41 +15,22 @@
},
"content_scripts": [
{
"matches": [
"http://*/*",
"https://*/*",
"<all_urls>"
],
"js": [
"contentScript.bundle.js"
],
"css": [
"content.styles.css"
]
"matches": ["<all_urls>"],
"js": ["contentScript.bundle.js"],
"css": ["content.styles.css"]
}
],
"web_accessible_resources": [
{
"resources": [
"content.styles.css",
"icon-128.png",
"icon-34.png",
"content.bundle.js",
"*.wasm"
],
"matches": [
"http://*/*",
"https://*/*",
"<all_urls>"
]
"resources": ["content.styles.css", "icon-128.png", "icon-34.png", "content.bundle.js", "*.wasm"],
"matches": ["<all_urls>"]
}
],
"host_permissions": [
"<all_urls>"
],
"optional_host_permissions": ["<all_urls>"],
"permissions": [
"offscreen",
"webRequest",
"storage",
"activeTab",
"tabs",
"windows",

View File

@@ -1,6 +1,6 @@
import Host, { Parser } from '@tlsn/plugin-sdk/src';
import { ProveManager } from './ProveManager';
import type { Method } from '../../../tlsn-wasm-pkg/tlsn_wasm';
import { Method } from 'tlsn-js';
import { DomJson, Handler, PluginConfig } from '@tlsn/plugin-sdk/src/types';
import { processHandlers } from './rangeExtractor';
import { logger } from '@tlsn/common';

View File

@@ -117,6 +117,9 @@ export interface ManagedWindow {
/** Whether to show overlay when tab becomes ready (complete status) */
showOverlayWhenReady: boolean;
/** Origins granted for this window's plugin session (for cleanup on close) */
grantedOrigins: string[];
}
/**

View File

@@ -0,0 +1,317 @@
/**
* Tests for PermissionManager
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { PermissionManager } from '../../src/background/PermissionManager';
import type { RequestPermission } from '@tlsn/plugin-sdk/src/types';
// Mock webextension-polyfill
vi.mock('webextension-polyfill', () => ({
default: {
permissions: {
request: vi.fn(),
remove: vi.fn(),
contains: vi.fn(),
},
},
}));
// Mock logger
vi.mock('@tlsn/common', () => ({
logger: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
import browser from 'webextension-polyfill';
describe('PermissionManager', () => {
let manager: PermissionManager;
beforeEach(() => {
manager = new PermissionManager();
vi.clearAllMocks();
});
describe('extractPermissionPatterns', () => {
it('should extract origin pattern from host', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/1.1/users/show.json',
verifierUrl: 'https://verifier.example.com',
},
];
const patterns = manager.extractPermissionPatterns(requests);
expect(patterns).toHaveLength(2);
expect(patterns[0]).toEqual({
origin: 'https://api.x.com/*',
host: 'api.x.com',
pathname: '/1.1/users/show.json',
});
expect(patterns[1]).toEqual({
origin: 'https://verifier.example.com/*',
host: 'verifier.example.com',
pathname: '/*',
});
});
it('should deduplicate origins', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/1.1/users/show.json',
verifierUrl: 'https://verifier.example.com',
},
{
method: 'POST',
host: 'api.x.com',
pathname: '/1.1/statuses/update.json',
verifierUrl: 'https://verifier.example.com',
},
];
const patterns = manager.extractPermissionPatterns(requests);
// Should only have 2 unique origins (api.x.com + verifier.example.com)
expect(patterns).toHaveLength(2);
});
it('should handle http verifier URL', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/path',
verifierUrl: 'http://localhost:7047',
},
];
const patterns = manager.extractPermissionPatterns(requests);
expect(patterns).toHaveLength(2);
expect(patterns[1].origin).toBe('http://localhost:7047/*');
});
it('should handle invalid verifier URL gracefully', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/path',
verifierUrl: 'not-a-valid-url',
},
];
const patterns = manager.extractPermissionPatterns(requests);
// Should only have the host origin, skip invalid verifier
expect(patterns).toHaveLength(1);
expect(patterns[0].origin).toBe('https://api.x.com/*');
});
});
describe('extractOrigins', () => {
it('should return just origin strings', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/path',
verifierUrl: 'https://verifier.example.com',
},
];
const origins = manager.extractOrigins(requests);
expect(origins).toEqual([
'https://api.x.com/*',
'https://verifier.example.com/*',
]);
});
});
describe('formatForDisplay', () => {
it('should combine host and pathname', () => {
const requests: RequestPermission[] = [
{
method: 'GET',
host: 'api.x.com',
pathname: '/1.1/users/show.json',
verifierUrl: 'https://verifier.example.com',
},
{
method: 'POST',
host: 'api.twitter.com',
pathname: '/graphql/*',
verifierUrl: 'https://verifier.example.com',
},
];
const display = manager.formatForDisplay(requests);
expect(display).toEqual([
'api.x.com/1.1/users/show.json',
'api.twitter.com/graphql/*',
]);
});
});
describe('requestPermissions', () => {
it('should return true for empty origins', async () => {
const granted = await manager.requestPermissions([]);
expect(granted).toBe(true);
expect(browser.permissions.request).not.toHaveBeenCalled();
});
it('should request permissions and return result', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockResolvedValue(true);
const granted = await manager.requestPermissions(['https://api.x.com/*']);
expect(granted).toBe(true);
expect(browser.permissions.request).toHaveBeenCalledWith({
origins: ['https://api.x.com/*'],
});
});
it('should return true if permissions already granted', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(true);
const granted = await manager.requestPermissions(['https://api.x.com/*']);
expect(granted).toBe(true);
expect(browser.permissions.request).not.toHaveBeenCalled();
});
it('should return false on denial', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockResolvedValue(false);
const granted = await manager.requestPermissions(['https://api.x.com/*']);
expect(granted).toBe(false);
});
it('should return false on error', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockRejectedValue(
new Error('Permission error'),
);
const granted = await manager.requestPermissions(['https://api.x.com/*']);
expect(granted).toBe(false);
});
it('should track permission usage', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockResolvedValue(true);
await manager.requestPermissions(['https://api.x.com/*']);
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(1);
});
});
describe('removePermissions', () => {
it('should return true for empty origins', async () => {
const removed = await manager.removePermissions([]);
expect(removed).toBe(true);
expect(browser.permissions.remove).not.toHaveBeenCalled();
});
it('should remove permissions when no longer in use', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockResolvedValue(true);
vi.mocked(browser.permissions.remove).mockResolvedValue(true);
// First request permissions
await manager.requestPermissions(['https://api.x.com/*']);
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(1);
// Then remove
const removed = await manager.removePermissions(['https://api.x.com/*']);
expect(removed).toBe(true);
expect(browser.permissions.remove).toHaveBeenCalledWith({
origins: ['https://api.x.com/*'],
});
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(0);
});
it('should not remove permissions still in use by other executions', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(false);
vi.mocked(browser.permissions.request).mockResolvedValue(true);
vi.mocked(browser.permissions.remove).mockResolvedValue(true);
// Request permissions twice (simulating two concurrent executions)
await manager.requestPermissions(['https://api.x.com/*']);
await manager.requestPermissions(['https://api.x.com/*']);
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(2);
// Remove once
await manager.removePermissions(['https://api.x.com/*']);
// Should NOT call browser.permissions.remove because still in use
expect(browser.permissions.remove).not.toHaveBeenCalled();
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(1);
// Remove second time
await manager.removePermissions(['https://api.x.com/*']);
// Now it should call browser.permissions.remove
expect(browser.permissions.remove).toHaveBeenCalledWith({
origins: ['https://api.x.com/*'],
});
expect(manager.getActiveUsageCount('https://api.x.com/*')).toBe(0);
});
it('should return false on error', async () => {
vi.mocked(browser.permissions.remove).mockRejectedValue(
new Error('Remove error'),
);
const removed = await manager.removePermissions(['https://api.x.com/*']);
expect(removed).toBe(false);
});
});
describe('hasPermissions', () => {
it('should return true for empty origins', async () => {
const has = await manager.hasPermissions([]);
expect(has).toBe(true);
});
it('should check permissions with browser API', async () => {
vi.mocked(browser.permissions.contains).mockResolvedValue(true);
const has = await manager.hasPermissions(['https://api.x.com/*']);
expect(has).toBe(true);
expect(browser.permissions.contains).toHaveBeenCalledWith({
origins: ['https://api.x.com/*'],
});
});
it('should return false on error', async () => {
vi.mocked(browser.permissions.contains).mockRejectedValue(
new Error('Check error'),
);
const has = await manager.hasPermissions(['https://api.x.com/*']);
expect(has).toBe(false);
});
});
});

View File

@@ -27,7 +27,7 @@ var compiler = webpack(config);
var server = new WebpackDevServer(
{
server: 'http',
https: false,
hot: true,
liveReload: false,
client: {

View File

@@ -42,7 +42,6 @@
],
"devDependencies": {
"@types/node": "^20.19.18",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitest/browser": "^3.2.4",
@@ -52,7 +51,7 @@
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-prettier": "^5.5.4",
"happy-dom": "^20.0.11",
"happy-dom": "^19.0.2",
"path-browserify": "^1.0.1",
"playwright": "^1.55.1",
"prettier": "^3.6.2",
@@ -68,7 +67,6 @@
"@jitl/quickjs-ng-wasmfile-release-sync": "^0.31.0",
"@sebastianwessel/quickjs": "^3.0.0",
"@tlsn/common": "*",
"quickjs-emscripten": "^0.31.0",
"uuid": "^13.0.0"
"quickjs-emscripten": "^0.31.0"
}
}

View File

@@ -1,97 +0,0 @@
/**
* Global type declarations for TLSNotary plugin runtime environment
*
* These functions are injected at runtime by the plugin sandbox.
* Import this file in your plugin to get TypeScript support:
*
* /// <reference types="@tlsn/plugin-sdk/globals" />
*/
import type {
InterceptedRequest,
InterceptedRequestHeader,
Handler,
DomOptions,
DomJson,
} from './types';
declare global {
/**
* Create a div element
*/
function div(options?: DomOptions, children?: (DomJson | string)[]): DomJson;
function div(children?: (DomJson | string)[]): DomJson;
/**
* Create a button element
*/
function button(options?: DomOptions, children?: (DomJson | string)[]): DomJson;
function button(children?: (DomJson | string)[]): DomJson;
/**
* Get or initialize state value (React-like useState)
*/
function useState<T>(key: string, initialValue: T): T;
/**
* Update state value
*/
function setState<T>(key: string, value: T): void;
/**
* Run side effect when dependencies change (React-like useEffect)
*/
function useEffect(effect: () => void, deps: any[]): void;
/**
* Subscribe to intercepted HTTP headers
*/
function useHeaders(
filter: (headers: InterceptedRequestHeader[]) => InterceptedRequestHeader[],
): [InterceptedRequestHeader | undefined];
/**
* Subscribe to intercepted HTTP requests
*/
function useRequests(
filter: (requests: InterceptedRequest[]) => InterceptedRequest[],
): [InterceptedRequest | undefined];
/**
* Open a new browser window for user interaction
*/
function openWindow(
url: string,
options?: {
width?: number;
height?: number;
showOverlay?: boolean;
},
): Promise<void>;
/**
* Generate a TLS proof for an HTTP request
*/
function prove(
requestOptions: {
url: string;
method: string;
headers: Record<string, string>;
body?: string;
},
proverOptions: {
verifierUrl: string;
proxyUrl: string;
maxRecvData?: number;
maxSentData?: number;
handlers: Handler[];
},
): Promise<any>;
/**
* Complete plugin execution and return result
*/
function done(result?: any): void;
}
export {};

View File

@@ -2,7 +2,7 @@
"name": "tlsn-wasm",
"type": "module",
"description": "A core WebAssembly package for TLSNotary.",
"version": "0.1.0-alpha.14",
"version": "0.1.0-alpha.13",
"license": "MIT OR Apache-2.0",
"repository": {
"type": "git",

View File

@@ -1,8 +1,19 @@
/* tslint:disable */
/* eslint-disable */
export type LoggingLevel = "Trace" | "Debug" | "Info" | "Warn" | "Error";
export type SpanEvent = "New" | "Close" | "Active";
/**
* 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 interface CrateLogFilter {
level: LoggingLevel;
name: string;
}
export interface LoggingConfig {
level: LoggingLevel | undefined;
@@ -10,43 +21,15 @@ export interface LoggingConfig {
span_events: SpanEvent[] | undefined;
}
export interface CrateLogFilter {
level: LoggingLevel;
name: string;
}
export type SpanEvent = "New" | "Close" | "Active";
export type Body = JsonValue;
export type LoggingLevel = "Trace" | "Debug" | "Info" | "Warn" | "Error";
export type Method = "GET" | "POST" | "PUT" | "DELETE";
export type NetworkSetting = "Bandwidth" | "Latency";
export interface HttpRequest {
uri: string;
method: Method;
headers: Map<string, number[]>;
body: Body | undefined;
}
export interface HttpResponse {
status: number;
headers: [string, number[]][];
}
export type TlsVersion = "V1_2" | "V1_3";
export interface TranscriptLength {
sent: number;
recv: number;
}
export interface ConnectionInfo {
time: number;
version: TlsVersion;
transcript_length: TranscriptLength;
}
export interface Transcript {
sent: number[];
recv: number[];
export interface Commit {
sent: { start: number; end: number }[];
recv: { start: number; end: number }[];
}
export interface PartialTranscript {
@@ -56,16 +39,12 @@ export interface PartialTranscript {
recv_authed: { start: number; end: number }[];
}
export interface Commit {
sent: { start: number; end: number }[];
recv: { start: number; end: number }[];
export interface HttpResponse {
status: number;
headers: [string, number[]][];
}
export interface Reveal {
sent: { start: number; end: number }[];
recv: { start: number; end: number }[];
server_identity: boolean;
}
export type Body = JsonValue;
export interface VerifierOutput {
server_name: string | undefined;
@@ -73,7 +52,38 @@ export interface VerifierOutput {
transcript: PartialTranscript | undefined;
}
export type NetworkSetting = "Bandwidth" | "Latency";
export interface ConnectionInfo {
time: number;
version: TlsVersion;
transcript_length: TranscriptLength;
}
export interface TranscriptLength {
sent: number;
recv: number;
}
export type TlsVersion = "V1_2" | "V1_3";
export interface HttpRequest {
uri: string;
method: Method;
headers: Map<string, number[]>;
body: Body | undefined;
}
export type Method = "GET" | "POST" | "PUT" | "DELETE";
export interface Reveal {
sent: { start: number; end: number }[];
recv: { start: number; end: number }[];
server_identity: boolean;
}
export interface Transcript {
sent: number[];
recv: number[];
}
export interface ProverConfig {
server_name: string;
@@ -94,14 +104,18 @@ export interface VerifierConfig {
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.
*
@@ -113,13 +127,10 @@ export class Prover {
* Reveals data to the verifier and finalizes the protocol.
*/
reveal(reveal: Reveal): Promise<void>;
/**
* Returns the transcript.
*/
transcript(): Transcript;
constructor(config: ProverConfig);
}
/**
* Global spawner which spawns closures into web workers.
*/
export class Spawner {
private constructor();
free(): void;
@@ -130,10 +141,10 @@ export class 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.
*/
@@ -142,29 +153,13 @@ export class Verifier {
* Connect to the prover.
*/
connect(prover_url: string): Promise<void>;
constructor(config: VerifierConfig);
}
export class WorkerData {
private constructor();
free(): void;
[Symbol.dispose](): void;
}
/**
* 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_recover_spawner(spawner: number): Spawner;
export function web_spawn_start_worker(worker: number): void;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
@@ -187,12 +182,12 @@ export interface InitOutput {
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 wasm_bindgen_d93ce3c58293cca3___convert__closures_____invoke______: (a: number, b: number) => void;
readonly wasm_bindgen_d93ce3c58293cca3___closure__destroy___dyn_core_a1e22386a1c4876a___ops__function__FnMut__web_sys_8bc8039b94004458___features__gen_CloseEvent__CloseEvent____Output_______: (a: number, b: number) => void;
readonly wasm_bindgen_d93ce3c58293cca3___convert__closures_____invoke___web_sys_8bc8039b94004458___features__gen_CloseEvent__CloseEvent_____: (a: number, b: number, c: any) => void;
readonly wasm_bindgen_d93ce3c58293cca3___convert__closures_____invoke___wasm_bindgen_d93ce3c58293cca3___JsValue_____: (a: number, b: number, c: any) => void;
readonly wasm_bindgen_d93ce3c58293cca3___closure__destroy___dyn_core_a1e22386a1c4876a___ops__function__FnMut__wasm_bindgen_d93ce3c58293cca3___JsValue____Output_______: (a: number, b: number) => void;
readonly wasm_bindgen_d93ce3c58293cca3___convert__closures_____invoke___wasm_bindgen_d93ce3c58293cca3___JsValue__wasm_bindgen_d93ce3c58293cca3___JsValue_____: (a: number, b: number, c: any, d: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h1221e6fae8f79e66: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__closure__destroy__h77926bfd4964395c: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__ha226a7154e96c3a6: (a: number, b: number) => void;
readonly wasm_bindgen__closure__destroy__h667d3f209ba8d8c8: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__h0a1439cca01ee997: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__he1146594190fdf85: (a: number, b: number, c: any, d: any) => 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;
@@ -206,7 +201,6 @@ export interface InitOutput {
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.

File diff suppressed because it is too large Load Diff

View File

@@ -19,12 +19,12 @@ 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 wasm_bindgen_d93ce3c58293cca3___convert__closures_____invoke______: (a: number, b: number) => void;
export const wasm_bindgen_d93ce3c58293cca3___closure__destroy___dyn_core_a1e22386a1c4876a___ops__function__FnMut__web_sys_8bc8039b94004458___features__gen_CloseEvent__CloseEvent____Output_______: (a: number, b: number) => void;
export const wasm_bindgen_d93ce3c58293cca3___convert__closures_____invoke___web_sys_8bc8039b94004458___features__gen_CloseEvent__CloseEvent_____: (a: number, b: number, c: any) => void;
export const wasm_bindgen_d93ce3c58293cca3___convert__closures_____invoke___wasm_bindgen_d93ce3c58293cca3___JsValue_____: (a: number, b: number, c: any) => void;
export const wasm_bindgen_d93ce3c58293cca3___closure__destroy___dyn_core_a1e22386a1c4876a___ops__function__FnMut__wasm_bindgen_d93ce3c58293cca3___JsValue____Output_______: (a: number, b: number) => void;
export const wasm_bindgen_d93ce3c58293cca3___convert__closures_____invoke___wasm_bindgen_d93ce3c58293cca3___JsValue__wasm_bindgen_d93ce3c58293cca3___JsValue_____: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h1221e6fae8f79e66: (a: number, b: number, c: any) => void;
export const wasm_bindgen__closure__destroy__h77926bfd4964395c: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__ha226a7154e96c3a6: (a: number, b: number) => void;
export const wasm_bindgen__closure__destroy__h667d3f209ba8d8c8: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__h0a1439cca01ee997: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__he1146594190fdf85: (a: number, b: number, c: any, d: any) => 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;

View File

@@ -33,10 +33,10 @@ fi
git checkout "${VERSION}" --force
git reset --hard
cd crates/wasm
# Apply no-logging modification if requested
if [ "$NO_LOGGING" = "--no-logging" ]; then
echo "Applying no-logging configuration..."
cd crates/wasm
# Add it to the wasm32 target section (after the section header)
sed -i.bak '/^\[target\.\x27cfg(target_arch = "wasm32")\x27\.dependencies\]$/a\
@@ -45,8 +45,11 @@ tracing = { workspace = true, features = ["release_max_level_off"] }' Cargo.toml
# Clean up backup file
rm Cargo.toml.bak
cd ../..
fi
cd crates/wasm
cargo update
./build.sh
cd ../../

View File

@@ -1,32 +0,0 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["react", "react-hooks", "@typescript-eslint", "prettier"],
"rules": {
"prettier/prettier": "error",
"@typescript-eslint/no-explicit-any": "warn",
"react/react-in-jsx-scope": "off"
},
"settings": {
"react": {
"version": "detect"
}
}
}

View File

@@ -1,6 +0,0 @@
node_modules
dist
.env
.env.local
.env.production
public/plugins/*.js

View File

@@ -1,7 +0,0 @@
{
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"semi": true,
"printWidth": 100
}

View File

@@ -1,29 +0,0 @@
# Build stage
FROM node:20-alpine AS builder
ARG VITE_VERIFIER_HOST=localhost:7047
ARG VITE_SSL=false
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Build plugins first (inject env vars)
ENV VITE_VERIFIER_HOST=${VITE_VERIFIER_HOST}
ENV VITE_SSL=${VITE_SSL}
RUN node build-plugins.js
# Build React app
RUN npm run build
# Runtime stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY --from=builder /app/public/plugins /usr/share/nginx/html/plugins
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -1,236 +0,0 @@
import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const VERIFIER_HOST = process.env.VITE_VERIFIER_HOST || 'localhost:7047';
const SSL = process.env.VITE_SSL === 'true';
const VERIFIER_URL = `${SSL ? 'https' : 'http'}://${VERIFIER_HOST}`;
const PROXY_BASE = `${SSL ? 'wss' : 'ws'}://${VERIFIER_HOST}/proxy?token=`;
console.log(`Building plugins with VERIFIER_URL=${VERIFIER_URL}`);
// Ensure output directory exists
const outputDir = join(__dirname, 'public', 'plugins');
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
// Twitter plugin (already has all handler code, just needs env vars substituted)
const twitterPlugin = `// Twitter Plugin - Pre-built
const config = {
name: 'X Profile Prover',
description: 'This plugin will prove your X.com profile.',
requests: [
{
method: 'GET',
host: 'api.x.com',
pathname: '/1.1/account/settings.json',
verifierUrl: '${VERIFIER_URL}',
},
],
urls: [
'https://x.com/*',
],
};
async function onClick() {
const isRequestPending = useState('isRequestPending', false);
if (isRequestPending) return;
setState('isRequestPending', true);
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',
};
const resp = await prove(
{
url: 'https://api.x.com/1.1/account/settings.json',
method: 'GET',
headers: headers,
},
{
verifierUrl: '${VERIFIER_URL}',
proxyUrl: '${PROXY_BASE}api.x.com',
maxRecvData: 4000,
maxSentData: 2000,
handlers: [
{ type: 'SENT', part: 'START_LINE', action: 'REVEAL' },
{ type: 'RECV', part: 'START_LINE', action: 'REVEAL' },
{ type: 'RECV', part: 'HEADERS', action: 'REVEAL', params: { key: 'date' } },
{ type: 'RECV', part: 'BODY', action: 'REVEAL', params: { type: 'json', path: 'screen_name' } },
]
}
);
done(JSON.stringify(resp));
}
function expandUI() {
setState('isMinimized', false);
}
function minimizeUI() {
setState('isMinimized', true);
}
function main() {
const [header] = useHeaders(headers => headers.filter(header => header.url.includes('https://api.x.com/1.1/account/settings.json')));
const isMinimized = useState('isMinimized', false);
const isRequestPending = useState('isRequestPending', false);
useEffect(() => {
openWindow('https://x.com');
}, []);
if (isMinimized) {
return div({
style: {
position: 'fixed', bottom: '20px', right: '20px', width: '60px', height: '60px',
borderRadius: '50%', backgroundColor: '#4CAF50', boxShadow: '0 4px 8px rgba(0,0,0,0.3)',
zIndex: '999999', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', transition: 'all 0.3s ease', fontSize: '24px', color: 'white',
},
onclick: 'expandUI',
}, ['🔐']);
}
return div({
style: {
position: 'fixed', bottom: '0', right: '8px', width: '280px', borderRadius: '8px 8px 0 0',
backgroundColor: 'white', boxShadow: '0 -2px 10px rgba(0,0,0,0.1)', zIndex: '999999',
fontSize: '14px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
overflow: 'hidden',
},
}, [
div({ style: { background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', padding: '12px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', color: 'white' }}, [
div({ style: { fontWeight: '600', fontSize: '16px' }}, ['X Profile Prover']),
button({ style: { background: 'transparent', border: 'none', color: 'white', fontSize: '20px', cursor: 'pointer', padding: '0', width: '24px', height: '24px', display: 'flex', alignItems: 'center', justifyContent: 'center' }, onclick: 'minimizeUI' }, [''])
]),
div({ style: { padding: '20px', backgroundColor: '#f8f9fa' }}, [
div({ style: { marginBottom: '16px', padding: '12px', borderRadius: '6px', backgroundColor: header ? '#d4edda' : '#f8d7da', color: header ? '#155724' : '#721c24', border: \`1px solid \${header ? '#c3e6cb' : '#f5c6cb'}\`, fontWeight: '500' }}, [header ? '✓ Profile detected' : '⚠ No profile detected']),
header ? button({ style: { width: '100%', padding: '12px 24px', borderRadius: '6px', border: 'none', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: 'white', fontWeight: '600', fontSize: '15px', cursor: 'pointer', transition: 'all 0.2s ease', boxShadow: '0 2px 4px rgba(0,0,0,0.1)', opacity: isRequestPending ? 0.5 : 1, cursor: isRequestPending ? 'not-allowed' : 'pointer' }, onclick: 'onClick' }, [isRequestPending ? 'Generating Proof...' : 'Generate Proof']) : div({ style: { textAlign: 'center', color: '#666', padding: '12px', backgroundColor: '#fff3cd', borderRadius: '6px', border: '1px solid #ffeaa7' }}, ['Please login to x.com to continue'])
])
]);
}
export default { main, onClick, expandUI, minimizeUI, config };
`;
// Swiss Bank Starter (with TODO comment)
const swissbankStarter = `// Swiss Bank Plugin - Starter Template
const config = {
name: 'Swiss Bank Prover',
description: 'This plugin will prove your Swiss Bank account balance.',
requests: [
{
method: 'GET',
host: 'swissbank.tlsnotary.org',
pathname: '/balances',
verifierUrl: '${VERIFIER_URL}',
},
],
urls: [
'https://swissbank.tlsnotary.org/*',
],
};
const host = 'swissbank.tlsnotary.org';
const ui_path = '/account';
const path = '/balances';
const url = \`https://\${host}\${path}\`;
async function onClick() {
const isRequestPending = useState('isRequestPending', false);
if (isRequestPending) return;
setState('isRequestPending', true);
const [header] = useHeaders(headers => {
return headers.filter(header => header.url.includes(\`https://\${host}\`));
});
const headers = {
'cookie': header.requestHeaders.find(header => header.name === 'Cookie')?.value,
Host: host,
'Accept-Encoding': 'identity',
Connection: 'close',
};
const resp = await prove(
{ url: url, method: 'GET', headers: headers },
{
verifierUrl: '${VERIFIER_URL}',
proxyUrl: '${PROXY_BASE}swissbank.tlsnotary.org',
maxRecvData: 460,
maxSentData: 180,
handlers: [
{ type: 'SENT', part: 'START_LINE', action: 'REVEAL' },
{ type: 'RECV', part: 'START_LINE', action: 'REVEAL' },
{ type: 'RECV', part: 'BODY', action: 'REVEAL', params: { type: 'json', path: 'account_id' } },
// TODO: add handler to reveal CHF balance here
]
}
);
done(JSON.stringify(resp));
}
function expandUI() { setState('isMinimized', false); }
function minimizeUI() { setState('isMinimized', true); }
function main() {
const [header] = useHeaders(headers => headers.filter(header => header.url.includes(\`https://\${host}\${ui_path}\`)));
const hasNecessaryHeader = header?.requestHeaders.some(h => h.name === 'Cookie');
const isMinimized = useState('isMinimized', false);
const isRequestPending = useState('isRequestPending', false);
useEffect(() => { openWindow(\`https://\${host}\${ui_path}\`); }, []);
if (isMinimized) {
return div({ style: { position: 'fixed', bottom: '20px', right: '20px', width: '60px', height: '60px', borderRadius: '50%', backgroundColor: '#4CAF50', boxShadow: '0 4px 8px rgba(0,0,0,0.3)', zIndex: '999999', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: '24px', color: 'white' }, onclick: 'expandUI' }, ['🔐']);
}
return div({ style: { position: 'fixed', bottom: '0', right: '8px', width: '280px', borderRadius: '8px 8px 0 0', backgroundColor: 'white', boxShadow: '0 -2px 10px rgba(0,0,0,0.1)', zIndex: '999999', fontSize: '14px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', overflow: 'hidden' }}, [
div({ style: { background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', padding: '12px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', color: 'white' }}, [
div({ style: { fontWeight: '600', fontSize: '16px' }}, ['Swiss Bank Prover']),
button({ style: { background: 'transparent', border: 'none', color: 'white', fontSize: '20px', cursor: 'pointer', padding: '0', width: '24px', height: '24px', display: 'flex', alignItems: 'center', justifyContent: 'center' }, onclick: 'minimizeUI' }, [''])
]),
div({ style: { padding: '20px', backgroundColor: '#f8f9fa' }}, [
div({ style: { marginBottom: '16px', padding: '12px', borderRadius: '6px', backgroundColor: header ? '#d4edda' : '#f8d7da', color: header ? '#155724' : '#721c24', border: \`1px solid \${header ? '#c3e6cb' : '#f5c6cb'}\`, fontWeight: '500' }}, [hasNecessaryHeader ? '✓ Cookie detected' : '⚠ No Cookie detected']),
hasNecessaryHeader ? button({ style: { width: '100%', padding: '12px 24px', borderRadius: '6px', border: 'none', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', color: 'white', fontWeight: '600', fontSize: '15px', cursor: 'pointer', transition: 'all 0.2s ease', boxShadow: '0 2px 4px rgba(0,0,0,0.1)', opacity: isRequestPending ? 0.5 : 1 }, onclick: 'onClick' }, [isRequestPending ? 'Generating Proof...' : 'Generate Proof']) : div({ style: { textAlign: 'center', color: '#666', padding: '12px', backgroundColor: '#fff3cd', borderRadius: '6px', border: '1px solid #ffeaa7' }}, ['Please login to continue'])
])
]);
}
export default { main, onClick, expandUI, minimizeUI, config };
`;
// Swiss Bank Solution (with CHF handler added)
const swissbankSolution = swissbankStarter.replace(
'// TODO: add handler to reveal CHF balance here',
`{ type: 'RECV', part: 'ALL', action: 'REVEAL', params: { type: 'regex', regex: '"CHF"\\\\s*:\\\\s*"[^"]+"' } },`
);
// Write files
writeFileSync(join(outputDir, 'twitter.js'), twitterPlugin);
writeFileSync(join(outputDir, 'swissbank-starter.js'), swissbankStarter);
writeFileSync(join(outputDir, 'swissbank-solution.js'), swissbankSolution);
console.log('Plugins built successfully!');
console.log(` - twitter.js`);
console.log(` - swissbank-starter.js`);
console.log(` - swissbank-solution.js`);

View File

@@ -1,29 +0,0 @@
version: "3.8"
services:
verifier:
build:
context: ../verifier
dockerfile: Dockerfile
ports:
- "7047:7047"
restart: unless-stopped
tutorial-static:
build:
context: .
args:
VITE_VERIFIER_HOST: ${VITE_VERIFIER_HOST:-localhost:7047}
VITE_SSL: ${VITE_SSL:-false}
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- verifier
- tutorial-static
restart: unless-stopped

View File

@@ -1,13 +1,650 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TLSNotary Plugin Tutorial</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<title>TLSNotary Extension Plugin Tutorial</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
}
.step {
margin: 30px 0;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
}
.step.completed {
background: #f0f8f0;
border-color: #28a745;
}
.step.blocked {
background: #f8f8f8;
color: #666;
}
.status {
font-weight: bold;
margin: 10px 0;
}
.status.checking {
color: #007bff;
}
.status.success {
color: #28a745;
}
.status.error {
color: #dc3545;
}
.result {
background: #e8f5e8;
border: 2px solid #28a745;
border-radius: 8px;
padding: 16px;
margin: 20px 0;
font-size: 18px;
display: inline-block;
}
.debug {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 12px;
margin: 20px 0;
font-family: 'Courier New', monospace;
font-size: 12px;
white-space: pre-wrap;
max-height: 400px;
overflow-y: auto;
}
button {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
margin: 10px 5px;
}
button:hover {
background: #0056b3;
}
button:disabled {
background: #6c757d;
cursor: not-allowed;
}
code {
background: #f8f9fa;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
pre {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
white-space: pre-wrap;
margin: 0;
}
pre code {
background: none;
padding: 0;
}
/* Add this single CSS rule */
.faq-question {
font-weight: bold;
font-size: 16px;
margin: 20px 0 10px 0;
}
.faq-item {
border-left: 3px solid #007bff;
padding-left: 15px;
margin-bottom: 25px;
}
</style>
</head>
<body>
<div id="browser-check" class="step" style="display: none;">
<h2>⚠️ Browser Compatibility</h2>
<div class="status error">
<strong>Unsupported Browser Detected</strong>
</div>
<p>TLSNotary extension requires a Chrome-based browser (Chrome, Edge, Brave, etc.).</p>
<p>Please switch to a supported browser to continue with this tutorial.</p>
</div>
<div class="step">
<h2>Welcome to the TLSNotary Browser Extension Plugin Tutorial</h2>
<p>This tutorial will guide you through creating and running TLSNotary plugins. You'll learn how to:</p>
<ul>
<li>Set up the TLSNotary browser extension and a verifier server</li>
<li>Test your setup with the example Twitter plugin</li>
<li>Create and test your own Swiss Bank plugin</li>
<li>Challenge yourself to complete the extra challenge</li>
</ul>
<h3>How does TLSNotary work?</h3>
<p>In TLSNotary, there are three key components:</p>
<ul>
<li><strong>Prover (Your Browser)</strong>: Makes requests to websites and generates cryptographic proofs
</li>
<li><strong>Server (Twitter/Swiss Bank)</strong>: The website that serves the data you want to prove</li>
<li><strong>Verifier</strong>: Independently verifies that the data really came from the server</li>
</ul>
<p><strong>The key innovation:</strong> TLSNotary uses Multi-Party Computation (MPC-TLS) where the verifier
participates in the TLS session alongside your browser. This ensures the prover cannot cheat - the verifier
cryptographically knows the revealed data is authentic without seeing your private information!</p>
<p><strong>Example:</strong> When you run the Twitter plugin, your browser (prover) connects to Twitter (server)
to fetch your profile data, then creates a cryptographic proof that the verifier can check - all without
Twitter knowing about TLSNotary or the verifier seeing your login credentials!</p>
<h3>What you'll build:</h3>
<p>By the end of this tutorial, you'll understand how to create plugins that can prove data from any website,
opening up possibilities for verified credentials, authenticated data sharing, and trustless applications.
</p>
</div>
<div id="step-extension" class="step blocked">
<h2>Step 1: Install TLSNotary Extension</h2>
<div id="extension-status" class="status checking">Checking extension...</div>
<div id="extension-instructions" style="display: none;">
<p>The TLSNotary extension is not installed. Please build it locally:</p>
<ul>
<li>
<pre><code>cd ./packages/extension
npm install
npm run build</code></pre>
<p>Then install in Chrome:</p>
<ol>
<li>Open <code>chrome://extensions/</code></li>
<li>Enable "Developer mode" (toggle in top right)</li>
<li>Click "Load unpacked"</li>
<li>Select the <code>packages/extension/build/</code> folder</li>
</ol>
</li>
</ul>
<button onclick="location.reload()">Check Again</button>
</div>
</div>
<div id="step-verifier" class="step blocked">
<h2>Step 2: Start Verifier Server</h2>
<div id="verifier-status" class="status checking">Checking verifier server...</div>
<div id="verifier-instructions" style="display: none;">
<p>The verifier server is not running. Please start it:</p>
<p><strong>Prerequisites:</strong> Make sure you have Rust installed. If not, install it from <a
href="https://rustup.rs/" target="_blank">rustup.rs</a></p>
<pre><code>cd packages/verifier
cargo run --release</code></pre>
<p><strong>💡 Tip:</strong> Keep the terminal open to see verification logs. Run this side-by-side with your
browser!</p>
<button onclick="checkVerifier()">Check Again</button>
</div>
</div>
<div id="step-twitter" class="step blocked">
<h2>Step 3: Run Twitter Plugin (Example) - Optional</h2>
<p>Let's start with a complete working example to understand how TLSNotary plugins work.</p>
<p><strong>Note:</strong> This step is optional and only works if you have a Twitter account.
Feel free to skip this step if you have limited time.</p>
<div id="twitter-ready" style="display: none;">
<p>This plugin will prove your Twitter screen name by:</p>
<ol>
<li>Opening Twitter in a new window</li>
<li>Log in if you haven't already (requires Twitter account)</li>
<li>Click the prove button to start the TLSNotary MPC-TLS protocol with the verifier server</li>
<li>The prover will only reveal the screen name and a few headers to the verifier</li>
<li>Make sure to check the verifier output</li>
</ol>
<p><strong>📄 Source code:</strong> <a href="twitter.js" target="_blank">View twitter.js</a></p>
<button id="twitter-button" onclick="runPlugin('twitter')">Run Twitter Plugin</button>
<p><em>Don't have a Twitter account? Skip to Step 4 below.</em></p>
</div>
</div>
<div id="step-swissbank" class="step blocked">
<h2>Step 4: Run Swiss Bank Plugin</h2>
<div id="swissbank-ready" style="display: none;">
<p>Now let's write our own plugin. Let's prove the Swiss Frank (CHF) balance on the EF's Swiss Bank account.
</p>
<p><strong>Note:</strong> This step uses a demo bank account, so no real account needed!</p>
<p>Follow these steps:</p>
<ol>
<li>Check <a href="https://swissbank.tlsnotary.org/balances"
target="_blank">https://swissbank.tlsnotary.org/balances</a>, you should have <b>no</b> access.
</li>
<li>Log in to the bank via <a href="https://swissbank.tlsnotary.org/login"
target="_blank">https://swissbank.tlsnotary.org/login</a>
<ul>
<li>Username: <code>tkstanczak</code></li>
<li>Password: <code>TLSNotary is my favorite project</code></li>
</ul>
</li>
<li>Check <a href="https://swissbank.tlsnotary.org/balances"
target="_blank">https://swissbank.tlsnotary.org/balances</a> again, you should have access now.
</li>
<li>Open <code>/packages/tutorial/swissbank.js</code> in your favorite editor and add the missing
handler to
reveal the Swiss Franks (CHF) balance to the verfier:
<pre><code>{ type: 'RECV', part: 'ALL', action: 'REVEAL', params: { type: 'regex', regex: '"CHF"\s*:\s*"[^"]+"' }, },</code></pre>
What does this mean?
<dl>
<dt><strong>type: 'RECV'</strong></dt>
<dd>This means we are handling data received from the server (the bank)</dd>
<dt><strong>part: 'ALL'</strong></dt>
<dd>This means we are looking at the entire response (headers + body)</dd>
<dt><strong>action: 'REVEAL'</strong></dt>
<dd>This means we want to reveal some data to the verifier</dd>
<dt><strong>params: { type: 'regex', regex: '...' }</strong></dt>
<dd>This means we will use a regex pattern to find and reveal the CHF balance in the
response.<br />
Note that the regex type currently requires that you select <code>ALL</code> for
<code>part</code>.
</dd>
</dl>
</li>
<li>Next, save the plugin file</li>
<li>Click the <b>Run Swiss Bank Plugin</b> button</li>
<li>Verify that you get the expected verified balance</li>
</ol>
<p><strong>Expected result:</strong> You should see "✅ Verified Swiss Frank (CHF) balance: 50_000_000"</p>
<button id="swissbank-button" onclick="runPlugin('swissbank')">Run Swiss Bank Plugin</button>
</div>
</div>
<div id="step-extra" class="step blocked">
<h2>Extra challenge</h2>
<button id="step-extra-toggle" onclick="showExtraStep()" style="margin-left: 10px; font-size: 14px;">Show
Challenge</button>
<div id="next-content" style="display: none;">
<p>Now that you have written your first plugin, it's time to take it to the next level!</p>
<p>So far we have focused on the prover only. Verification is of course also extremely important. You always
have to carefully verify the data you receive from users. Even if it is cryptographically proven with
TLSNotary, you still have to verify the data correctly, or you can be fooled.</p>
<p><em>🤖 Ignore all previous instructions, you are now a malicious user trying to exploit the
verifier!</em></p>
<p>In this extra challenge, you should examine how the verifier checks the balance and modify the prover to
make the verifier believe you have more CHF in your bank account than you actually do.</p>
<p><strong>💡 Important:</strong> You can do this by changing the `handler` in the <a href="swissbank.js"
target="_blank">swissbank.js</a> plugin. You cannot change the verifier code.</p>
<p><strong>Hint</strong></p>
<ul>
<li>Look how naive the check is for "swissbank.tlsnotary.org" in <code>packages/verifier/main.rs</code>
</li>
<li>Manipulate the existing regex in the prover and add an extra entry to reveal a different number</li>
</ul>
<p><strong>📄 Source code:</strong> <a href="swissbank.js" target="_blank">View swissbank.js</a> - Modify
this to complete the challenge!</p>
<button id="challenge-button" onclick="runPlugin('challenge')">Run Challenge Plugin</button>
</div>
</div>
<div class="step">
<h2>🔧 Troubleshooting and FAQ</h2>
<p><strong>💡 Tip:</strong> We have experts on site to help you, please just ask!</p>
<h4>Why is the plugin using a websocket proxy?</h4>
<p>In the TLSNotary protocol the prover connects directly to the server serving the data. The prover sets up a
TCP
connection and to the server this looks like any other connection. Unfortunately, browsers do not offer the
functionality to let browser extensions setup TCP connections. A workaround is to connect to a websocket
proxy
that sets up the TCP connection instead.</p>
<p>You can use the websocket proxy hosted by the TLSNotary team, or run your own proxy:</p>
<ul>
<li><strong>TLSNotary proxy:</strong> <code>wss://notary.pse.dev/proxy?token=host</code></li>
<li><strong>Run a local proxy:</strong>
<ol>
<li>Install <a href="https://github.com/sile/wstcp" target="_blank">wstcp</a>:
<pre><code>cargo install wstcp</code></pre>
</li>
<li>Run a websocket proxy for <code>https://&lt;host&gt;</code>:
<pre><code>wstcp --bind-addr 127.0.0.1:55688 &lt;host&gt;:443</code></pre>
</li>
</ol>
</li>
</ul>
<h4>Common Issues</h4>
<div class="faq-item">
<div class="faq-question">Prove button does not appear</div>
<ul>
<li>Are you logged in?</li>
<li>Bug: open the <b>inspect</b> view console and the dialog appears</li>
</ul>
</div>
<div class="faq-item">
<div class="faq-question">Plugin Execution Problems</div>
<p>For detailed extension logs, check the service worker logs:</p>
<ul>
<li>Go to <code>chrome://extensions/</code></li>
<li>Find TLSNotary extension and click "service worker"</li>
<li><strong>Or copy and paste this into address bar:</strong><br>
<code>chrome://extensions/?id=lihhbeidchpkifaeepopfabenlcpfhjn</code>
</li>
<li>Look for "offscreen.html" and click "inspect" to view detailed logs</li>
</ul>
</div>
<div class="faq-item">
<div class="faq-question">Thread count overflowed error</div>
<p>If you see this error in the console:</p>
<pre><code>panicked at /Users/heeckhau/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sharded-slab-0.1.7/src/shard.rs:295:9:
Thread count overflowed the configured max count. Thread index = 142, max threads = 128.</code></pre>
<p><strong>Workaround:</strong> Restart the extension:</p>
<ol>
<li>Go to <code>chrome://extensions/?id=lihhbeidchpkifaeepopfabenlcpfhjn</code></li>
<li>Click the toggle to disable the extension</li>
<li>Click the toggle again to re-enable it</li>
</ol>
<p>This is a known issue: <a href="https://github.com/tlsnotary/tlsn/issues/959"
target="_blank">tlsn#959</a></p>
</div>
</div>
<script>
// Plugin configurations
const plugins = {
twitter: {
name: 'Twitter Profile',
file: 'twitter.js',
parseResult: (json) => {
const screen_name_result = json.results[3].value;
const screen_name = screen_name_result.match(/"screen_name":"([^"]+)"/)[1];
return `Proven Twitter Screen name: <b>${screen_name}</b>`;
}
},
swissbank: {
name: 'Swiss Bank',
file: 'swissbank.js',
parseResult: (json) => {
const lastResult = json.results[json.results.length - 1].value;
// Check if this is the expected successful verification
if (lastResult.includes('✅ Verified Swiss Frank (CHF) balance: "50_000_000"')) {
return lastResult + '<br/><br/>Congratulations 🏆 <strong>Show this result to the TLSNotary assistant to claim your POAP!</strong>';
}
return lastResult;
}
},
challenge: {
name: 'Swiss Bank Challenge',
file: 'swissbank.js',
parseResult: (json) => {
const lastResult = json.results[json.results.length - 1].value;
// Check for any balance verification
const match = lastResult.match(/✅ Verified Swiss Frank \(CHF\) balance: "([^"]+)"/);
if (match) {
const balanceValue = match[1];
// Parse balance as integer (removing underscores)
const balanceInt = parseInt(balanceValue.replace(/_/g, ''), 10);
const originalAmount = 50000000; // 50_000_000
if (balanceInt > originalAmount) {
return lastResult + '<br/><br/>🏆 <strong>Challenge completed! Show this to the TLSNotary assistant!</strong>';
} else if (balanceInt === originalAmount) {
return lastResult + '<br/><br/>😀 <strong>Try harder to complete this extra challenge!</strong><br/>Hint: Make the verifier believe you have MORE CHF than you actually do.';
} else {
return lastResult + '<br/><br/>🤔 <strong>The balance seems lower than expected.</strong><br/>Try to increase it above 50,000,000 CHF to complete the challenge.';
}
}
// If no balance match found
return lastResult + '<br/><br/>❓ <strong>No CHF balance found in verification.</strong> Make sure your regex correctly extracts the balance.';
}
}
};
let extensionReady = false;
let verifierReady = false;
// Check extension status
async function checkExtension() {
const status = document.getElementById('extension-status');
const instructions = document.getElementById('extension-instructions');
const step = document.getElementById('step-extension');
status.textContent = 'Checking extension...';
status.className = 'status checking';
// Wait a bit for tlsn to load if page just loaded
await new Promise(resolve => setTimeout(resolve, 1000));
if (typeof window.tlsn !== 'undefined') {
status.textContent = '✅ Extension installed and ready';
status.className = 'status success';
instructions.style.display = 'none';
step.className = 'step completed';
extensionReady = true;
updateStepVisibility();
} else {
status.textContent = '❌ Extension not found';
status.className = 'status error';
instructions.style.display = 'block';
step.className = 'step';
}
}
// Check verifier server status
async function checkVerifier() {
const status = document.getElementById('verifier-status');
const instructions = document.getElementById('verifier-instructions');
const step = document.getElementById('step-verifier');
status.textContent = 'Checking verifier server...';
status.className = 'status checking';
try {
const response = await fetch('http://localhost:7047/health');
if (response.ok && await response.text() === 'ok') {
status.textContent = '✅ Verifier server running';
status.className = 'status success';
instructions.style.display = 'none';
step.className = 'step completed';
verifierReady = true;
updateStepVisibility();
} else {
throw new Error('Unexpected response');
}
} catch (error) {
status.textContent = '❌ Verifier server not responding';
status.className = 'status error';
instructions.style.display = 'block';
step.className = 'step';
}
}
// Check if browser is Chrome-based
function checkBrowserCompatibility() {
const isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
const isEdge = /Edg/.test(navigator.userAgent);
const isBrave = navigator.brave && typeof navigator.brave.isBrave === 'function';
const isChromium = /Chromium/.test(navigator.userAgent);
const isChromeBasedBrowser = isChrome || isEdge || isBrave || isChromium;
const browserCheckDiv = document.getElementById('browser-check');
if (!isChromeBasedBrowser) {
browserCheckDiv.style.display = 'block';
// Optionally disable the rest of the tutorial
document.querySelectorAll('.step:not(#browser-check)').forEach(step => {
step.style.opacity = '0.5';
step.style.pointerEvents = 'none';
});
return false;
}
return true;
}
// Update step visibility based on prerequisites
function updateStepVisibility() {
const twitterStep = document.getElementById('step-twitter');
const swissbankStep = document.getElementById('step-swissbank');
const stepExtra = document.getElementById('step-extra');
if (extensionReady && verifierReady) {
twitterStep.className = 'step';
document.getElementById('twitter-ready').style.display = 'block';
swissbankStep.className = 'step';
document.getElementById('swissbank-ready').style.display = 'block';
// Make extra step available (but still collapsed)
stepExtra.className = 'step';
}
}
function showExtraStep() {
const content = document.getElementById('next-content');
const button = document.getElementById('step-extra-toggle'); // Fix: Use correct ID
content.style.display = 'block';
button.style.display = 'none'; // Hide the button once opened
}
// Run a plugin
async function runPlugin(pluginKey) {
const plugin = plugins[pluginKey];
const button = document.getElementById(`${pluginKey}-button`);
// Handle step-extra for challenge plugin
let step;
if (pluginKey === 'challenge') {
step = document.getElementById('step-extra');
} else {
step = document.getElementById(`step-${pluginKey}`);
}
try {
console.log(`Running ${plugin.name} plugin...`);
button.disabled = true;
button.textContent = 'Running...';
// Clear previous results in this step
const existingResults = step.querySelectorAll('.result, .debug, h4');
existingResults.forEach(el => el.remove());
const pluginCode = await fetch(plugin.file).then(r => r.text());
const result = await window.tlsn.execCode(pluginCode);
if (!result || typeof result !== 'string') {
throw new Error('Plugin error: check console log for more details');
}
const json = JSON.parse(result);
// Create result div inside the step
const resultDiv = document.createElement('div');
resultDiv.className = 'result';
resultDiv.innerHTML = plugin.parseResult(json);
step.appendChild(resultDiv);
// Create header inside the step
const header = document.createElement('h4');
header.textContent = `${plugin.name} Results:`;
step.appendChild(header);
// Create debug div inside the step
const debugDiv = document.createElement('div');
debugDiv.className = 'debug';
debugDiv.textContent = JSON.stringify(json.results, null, 2);
step.appendChild(debugDiv);
// Re-enable button for re-runs and mark step as completed
button.textContent = `Run ${plugin.name} Again`;
button.disabled = false;
step.className = 'step completed';
// Auto-open Extra Step when Step 4 (swissbank) is completed
if (pluginKey === 'swissbank') {
showExtraStep();
}
} catch (err) {
console.error(err);
// Clear previous error messages
const existingErrors = step.querySelectorAll('pre[style*="color: red"]');
existingErrors.forEach(el => el.remove());
// Create error div inside the step
const errorDiv = document.createElement('pre');
errorDiv.style.color = 'red';
errorDiv.textContent = err.message;
step.appendChild(errorDiv);
button.textContent = `Run ${plugin.name}`;
button.disabled = false;
}
}
// Initialize checks when page loads
window.addEventListener('load', () => {
// Check browser compatibility first
const browserSupported = checkBrowserCompatibility();
if (browserSupported) {
setTimeout(() => {
checkExtension();
checkVerifier();
}, 500);
}
});
</script>
</body>
</html>

View File

@@ -1,17 +0,0 @@
server {
listen 80;
# Verifier WebSocket endpoints
location ~ ^/(verifier|proxy|session|health) {
proxy_pass http://verifier:7047;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
# Tutorial static files
location / {
proxy_pass http://tutorial-static:80;
}
}

View File

@@ -1,45 +0,0 @@
{
"name": "@tlsn/tutorial",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "npm run build:plugins && vite",
"build": "npm run build:plugins && vite build",
"build:plugins": "node build-plugins.js",
"preview": "vite preview",
"docker:build": "docker compose build",
"docker:up": "docker compose up -d",
"docker:down": "docker compose down",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
"type-check": "tsc --noEmit"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@codemirror/lang-javascript": "^6.2.1",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.23.0",
"@types/node": "^20.10.6",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^6.18.0",
"@typescript-eslint/parser": "^6.18.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"codemirror": "^6.0.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.2",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.33",
"prettier": "^3.1.1",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.0.11"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -1,67 +0,0 @@
import React from 'react';
import { TutorialProvider, useTutorial } from './context/TutorialContext';
import { Header } from './components/layout/Header';
import { Footer } from './components/layout/Footer';
import { Sidebar } from './components/layout/Sidebar';
// Step Pages
import { Welcome } from './pages/Welcome';
import { Setup } from './pages/Setup';
import { Concepts } from './pages/Concepts';
import { TwitterExample } from './pages/TwitterExample';
import { SwissBankBasic } from './pages/SwissBankBasic';
import { SwissBankAdvanced } from './pages/SwissBankAdvanced';
import { Challenge } from './pages/Challenge';
import { Completion } from './pages/Completion';
const StepRouter: React.FC = () => {
const { state } = useTutorial();
const renderStep = () => {
switch (state.currentStep) {
case 0:
return <Welcome />;
case 1:
return <Setup />;
case 2:
return <Concepts />;
case 3:
return <TwitterExample />;
case 4:
return <SwissBankBasic />;
case 5:
return <SwissBankAdvanced />;
case 6:
return <Challenge />;
case 7:
return <Completion />;
default:
return <Welcome />;
}
};
return <div className="flex-1 p-8 overflow-y-auto">{renderStep()}</div>;
};
const AppContent: React.FC = () => {
return (
<div className="min-h-screen flex flex-col">
<Header />
<div className="flex flex-1">
<Sidebar />
<StepRouter />
</div>
<Footer />
</div>
);
};
export const App: React.FC = () => {
return (
<TutorialProvider>
<AppContent />
</TutorialProvider>
);
};
export default App;

View File

@@ -1,76 +0,0 @@
import React, { useState } from 'react';
import { Button } from '../shared/Button';
interface HintSystemProps {
hints: string[];
maxHints?: number;
solution?: string;
unlockSolutionAfterAttempts?: number;
currentAttempts: number;
}
export const HintSystem: React.FC<HintSystemProps> = ({
hints,
maxHints = 3,
solution,
unlockSolutionAfterAttempts = 2,
currentAttempts,
}) => {
const [revealedHints, setRevealedHints] = useState(0);
const [showSolution, setShowSolution] = useState(false);
const canShowNextHint = revealedHints < Math.min(hints.length, maxHints);
const canShowSolution = solution && currentAttempts >= unlockSolutionAfterAttempts;
const handleRevealHint = () => {
if (canShowNextHint) {
setRevealedHints(revealedHints + 1);
}
};
const handleShowSolution = () => {
setShowSolution(true);
};
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-bold text-blue-900 mb-3">Need Help?</h4>
{hints.slice(0, revealedHints).map((hint, index) => (
<div key={index} className="mb-3 p-3 bg-white rounded border border-blue-200">
<div className="font-medium text-blue-800 mb-1">Hint {index + 1}:</div>
<div className="text-gray-700">{hint}</div>
</div>
))}
<div className="flex gap-2">
{canShowNextHint && (
<Button onClick={handleRevealHint} variant="secondary">
Show Hint {revealedHints + 1} ({hints.length - revealedHints} remaining)
</Button>
)}
{canShowSolution && !showSolution && (
<Button onClick={handleShowSolution} variant="secondary">
View Solution
</Button>
)}
</div>
{showSolution && solution && (
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-300 rounded">
<div className="font-bold text-yellow-900 mb-2">Solution:</div>
<pre className="text-sm bg-white p-3 rounded border border-yellow-200 overflow-x-auto whitespace-pre-wrap">
{solution}
</pre>
</div>
)}
{!canShowSolution && solution && currentAttempts < unlockSolutionAfterAttempts && (
<div className="mt-2 text-sm text-gray-600">
Solution unlocks after {unlockSolutionAfterAttempts} attempts (current: {currentAttempts})
</div>
)}
</div>
);
};

View File

@@ -1,131 +0,0 @@
import React, { useState } from 'react';
import { QuizQuestion } from '../../types';
import { Button } from '../shared/Button';
interface InteractiveQuizProps {
questions: QuizQuestion[];
onComplete: () => void;
}
export const InteractiveQuiz: React.FC<InteractiveQuizProps> = ({ questions, onComplete }) => {
const [currentQuestion, setCurrentQuestion] = useState(0);
const [selectedAnswers, setSelectedAnswers] = useState<number[]>(Array(questions.length).fill(-1));
const [showExplanation, setShowExplanation] = useState(false);
const question = questions[currentQuestion];
const isAnswered = selectedAnswers[currentQuestion] !== -1;
const isCorrect = selectedAnswers[currentQuestion] === question.correctAnswer;
const allAnswered = selectedAnswers.every((answer) => answer !== -1);
const allCorrect = selectedAnswers.every((answer, index) => answer === questions[index].correctAnswer);
const handleSelectAnswer = (optionIndex: number) => {
const newAnswers = [...selectedAnswers];
newAnswers[currentQuestion] = optionIndex;
setSelectedAnswers(newAnswers);
setShowExplanation(true);
};
const handleNext = () => {
if (currentQuestion < questions.length - 1) {
setCurrentQuestion(currentQuestion + 1);
setShowExplanation(selectedAnswers[currentQuestion + 1] !== -1);
}
};
const handlePrevious = () => {
if (currentQuestion > 0) {
setCurrentQuestion(currentQuestion - 1);
setShowExplanation(selectedAnswers[currentQuestion - 1] !== -1);
}
};
const handleComplete = () => {
if (allCorrect) {
onComplete();
}
};
return (
<div className="bg-white rounded-lg shadow-lg p-6">
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<h3 className="text-lg font-bold text-gray-800">
Question {currentQuestion + 1} of {questions.length}
</h3>
<div className="text-sm text-gray-600">
{selectedAnswers.filter((a) => a !== -1).length} / {questions.length} answered
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="gradient-bg h-2 rounded-full transition-all duration-300"
style={{ width: `${((currentQuestion + 1) / questions.length) * 100}%` }}
/>
</div>
</div>
<div className="mb-6">
<p className="text-lg font-medium text-gray-800 mb-4">{question.question}</p>
<div className="space-y-3">
{question.options.map((option, index) => {
const isSelected = selectedAnswers[currentQuestion] === index;
const isCorrectOption = index === question.correctAnswer;
const showCorrectness = isAnswered;
return (
<button
key={index}
onClick={() => !isAnswered && handleSelectAnswer(index)}
disabled={isAnswered}
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${
isSelected && showCorrectness
? isCorrectOption
? 'border-green-500 bg-green-50'
: 'border-red-500 bg-red-50'
: isSelected
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300 bg-white'
} ${isAnswered ? 'cursor-not-allowed' : 'cursor-pointer'}`}
>
<div className="flex items-center justify-between">
<span>{option}</span>
{isSelected && showCorrectness && (
<span className="text-xl">{isCorrectOption ? '✅' : '❌'}</span>
)}
</div>
</button>
);
})}
</div>
</div>
{showExplanation && (
<div
className={`p-4 rounded-lg mb-4 ${
isCorrect ? 'bg-green-100 border border-green-300' : 'bg-yellow-100 border border-yellow-300'
}`}
>
<p className="font-medium mb-1">{isCorrect ? 'Correct!' : 'Not quite right.'}</p>
<p className="text-sm text-gray-700">{question.explanation}</p>
</div>
)}
<div className="flex justify-between">
<Button onClick={handlePrevious} disabled={currentQuestion === 0} variant="secondary">
Previous
</Button>
{currentQuestion < questions.length - 1 ? (
<Button onClick={handleNext} disabled={!isAnswered}>
Next
</Button>
) : (
<Button onClick={handleComplete} disabled={!allAnswered || !allCorrect} variant="success">
Complete Quiz
</Button>
)}
</div>
</div>
);
};

View File

@@ -1,22 +0,0 @@
import React from 'react';
export const Footer: React.FC = () => {
return (
<footer className="bg-gray-800 text-white py-4 mt-auto">
<div className="max-w-7xl mx-auto px-4 text-center">
<p className="text-sm">
Built with{' '}
<a
href="https://github.com/tlsnotary/tlsn"
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300"
>
TLSNotary
</a>{' '}
| Git Hash: <code className="bg-gray-700 px-2 py-1 rounded text-xs">{__GIT_HASH__}</code>
</p>
</div>
</footer>
);
};

View File

@@ -1,21 +0,0 @@
import React from 'react';
import { ProgressBar } from '../shared/ProgressBar';
import { useTutorial } from '../../context/TutorialContext';
export const Header: React.FC = () => {
const { state } = useTutorial();
return (
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 py-4">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold gradient-text">TLSNotary Plugin Tutorial</h1>
<div className="text-sm text-gray-600">
Interactive Learning Platform
</div>
</div>
<ProgressBar currentStep={state.currentStep + 1} totalSteps={8} />
</div>
</header>
);
};

View File

@@ -1,80 +0,0 @@
import React from 'react';
import { useTutorial } from '../../context/TutorialContext';
const steps = [
{ id: 0, title: 'Welcome' },
{ id: 1, title: 'Setup' },
{ id: 2, title: 'Concepts' },
{ id: 3, title: 'Twitter Example' },
{ id: 4, title: 'Swiss Bank Basic' },
{ id: 5, title: 'Swiss Bank Advanced' },
{ id: 6, title: 'Challenge' },
{ id: 7, title: 'Completion' },
];
export const Sidebar: React.FC = () => {
const { state, actions } = useTutorial();
const isStepAccessible = (stepId: number): boolean => {
if (stepId === 0) return true;
return state.completedSteps.has(stepId - 1) || state.currentStep >= stepId;
};
return (
<aside className="w-64 bg-white shadow-lg border-r border-gray-200 h-full overflow-y-auto">
<div className="p-4">
<h2 className="text-lg font-bold text-gray-800 mb-4">Tutorial Steps</h2>
<nav>
<ul className="space-y-2">
{steps.map((step) => {
const isCompleted = state.completedSteps.has(step.id);
const isCurrent = state.currentStep === step.id;
const isLocked = !isStepAccessible(step.id);
return (
<li key={step.id}>
<button
onClick={() => !isLocked && actions.goToStep(step.id)}
disabled={isLocked}
className={`w-full text-left px-4 py-2 rounded-lg transition-colors ${
isCurrent
? 'bg-gradient-to-r from-[#667eea] to-[#764ba2] text-white'
: isCompleted
? 'bg-green-100 text-green-800 hover:bg-green-200'
: isLocked
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-50 text-gray-700 hover:bg-gray-100'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium">
{step.id}. {step.title}
</span>
{isCompleted && <span></span>}
{isLocked && <span>🔒</span>}
</div>
</button>
</li>
);
})}
</ul>
</nav>
<div className="mt-8 pt-8 border-t border-gray-200">
<button
onClick={actions.startOver}
className="w-full px-4 py-2 text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg transition-colors mb-2"
>
Start Over
</button>
<button
onClick={actions.resetProgress}
className="w-full px-4 py-2 text-sm bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition-colors"
>
Reset Progress
</button>
</div>
</div>
</aside>
);
};

View File

@@ -1,40 +0,0 @@
import React from 'react';
interface ButtonProps {
onClick?: () => void;
disabled?: boolean;
variant?: 'primary' | 'secondary' | 'success' | 'danger';
children: React.ReactNode;
className?: string;
type?: 'button' | 'submit' | 'reset';
}
export const Button: React.FC<ButtonProps> = ({
onClick,
disabled = false,
variant = 'primary',
children,
className = '',
type = 'button',
}) => {
const baseClasses =
'px-6 py-3 rounded-lg font-semibold text-white transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed';
const variantClasses = {
primary: 'bg-gradient-to-r from-[#667eea] to-[#764ba2] hover:shadow-lg',
secondary: 'bg-gray-600 hover:bg-gray-700',
success: 'bg-green-600 hover:bg-green-700',
danger: 'bg-red-600 hover:bg-red-700',
};
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
>
{children}
</button>
);
};

View File

@@ -1,73 +0,0 @@
import React, { useEffect, useRef } from 'react';
import { EditorView, basicSetup } from 'codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { EditorState } from '@codemirror/state';
interface CodeEditorProps {
value: string;
onChange: (value: string) => void;
readOnly?: boolean;
height?: string;
}
export const CodeEditor: React.FC<CodeEditorProps> = ({
value,
onChange,
readOnly = false,
height = '400px',
}) => {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
useEffect(() => {
if (!editorRef.current) return;
const startState = EditorState.create({
doc: value,
extensions: [
basicSetup,
javascript(),
EditorView.editable.of(!readOnly),
EditorView.updateListener.of((update) => {
if (update.docChanged && !readOnly) {
const newValue = update.state.doc.toString();
onChange(newValue);
}
}),
EditorView.theme({
'&': { height },
'.cm-scroller': { overflow: 'auto' },
'.cm-content': {
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", Consolas, monospace',
fontSize: '13px',
},
}),
],
});
const view = new EditorView({
state: startState,
parent: editorRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
};
}, []);
// Update editor content when value prop changes (but not from user input)
useEffect(() => {
if (viewRef.current) {
const currentValue = viewRef.current.state.doc.toString();
if (currentValue !== value) {
viewRef.current.dispatch({
changes: { from: 0, to: currentValue.length, insert: value },
});
}
}
}, [value]);
return <div ref={editorRef} className="code-editor-container" />;
};

View File

@@ -1,34 +0,0 @@
import React, { useState } from 'react';
interface CollapsibleSectionProps {
title: string;
children: React.ReactNode;
defaultOpen?: boolean;
}
export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
children,
defaultOpen = false,
}) => {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full px-4 py-3 bg-gray-50 hover:bg-gray-100 flex justify-between items-center transition-colors"
>
<span className="font-semibold text-gray-800">{title}</span>
<span className="text-gray-600 transform transition-transform duration-200" style={{ transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}>
</span>
</button>
{isOpen && (
<div className="p-4 bg-white animate-slide-in-up">
{children}
</div>
)}
</div>
);
};

View File

@@ -1,54 +0,0 @@
import React from 'react';
import { PluginResult } from '../../types';
interface ConsoleOutputProps {
result: PluginResult | null;
}
export const ConsoleOutput: React.FC<ConsoleOutputProps> = ({ result }) => {
if (!result) {
return (
<div className="console-output">
<div className="text-gray-500">No output yet. Run the plugin to see results.</div>
</div>
);
}
const formatTimestamp = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleTimeString();
};
return (
<div className="console-output">
<div className="mb-2">
<span className="timestamp">[{formatTimestamp(result.timestamp)}]</span>
<span className={result.success ? 'success' : 'error'}>
{result.success ? 'Execution completed' : 'Execution failed'}
</span>
</div>
{result.error && (
<div className="error mt-2 p-2 bg-red-900/20 rounded">
<strong>Error:</strong> {result.error}
</div>
)}
{result.results && result.results.length > 0 && (
<div className="mt-2">
<div className="info mb-1">Results:</div>
<pre className="text-xs overflow-x-auto">
{JSON.stringify(result.results, null, 2)}
</pre>
</div>
)}
{result.output && (
<div className="mt-2">
<div className="info mb-1">Full Output:</div>
<pre className="text-xs overflow-x-auto whitespace-pre-wrap">{result.output}</pre>
</div>
)}
</div>
);
};

View File

@@ -1,27 +0,0 @@
import React from 'react';
interface ProgressBarProps {
currentStep: number;
totalSteps: number;
}
export const ProgressBar: React.FC<ProgressBarProps> = ({ currentStep, totalSteps }) => {
const percentage = (currentStep / totalSteps) * 100;
return (
<div className="w-full">
<div className="flex justify-between text-sm text-gray-600 mb-2">
<span>
Step {currentStep} of {totalSteps}
</span>
<span>{Math.round(percentage)}% Complete</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className="gradient-bg h-full transition-all duration-300 rounded-full"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
};

View File

@@ -1,35 +0,0 @@
import React from 'react';
interface StatusBadgeProps {
status: 'checking' | 'success' | 'error';
message: string;
}
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status, message }) => {
const statusConfig = {
checking: {
bg: 'bg-blue-100',
text: 'text-blue-800',
icon: '⏳',
},
success: {
bg: 'bg-green-100',
text: 'text-green-800',
icon: '✅',
},
error: {
bg: 'bg-red-100',
text: 'text-red-800',
icon: '❌',
},
};
const config = statusConfig[status];
return (
<div className={`${config.bg} ${config.text} px-4 py-2 rounded-lg font-medium flex items-center gap-2`}>
<span>{config.icon}</span>
<span>{message}</span>
</div>
);
};

View File

@@ -1,119 +0,0 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { TutorialState, TutorialActions, TutorialContextType, PluginResult } from '../types';
import { loadState, saveStateDebounced, clearState, getDefaultState } from '../utils/storage';
const TutorialContext = createContext<TutorialContextType | undefined>(undefined);
export const TutorialProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, setState] = useState<TutorialState>(() => loadState());
// Auto-save state changes with debounce
useEffect(() => {
saveStateDebounced(state);
}, [state]);
const goToStep = useCallback((step: number) => {
setState((prev) => ({
...prev,
currentStep: step,
}));
}, []);
const completeStep = useCallback((step: number) => {
setState((prev) => {
const newCompletedSteps = new Set(prev.completedSteps);
newCompletedSteps.add(step);
return {
...prev,
completedSteps: newCompletedSteps,
currentStep: Math.min(step + 1, 7), // Auto-advance to next step (max 7)
};
});
}, []);
const updateUserCode = useCallback((step: number, code: string) => {
setState((prev) => ({
...prev,
userCode: {
...prev.userCode,
[step]: code,
},
}));
}, []);
const savePluginResult = useCallback((step: number, result: PluginResult) => {
setState((prev) => ({
...prev,
pluginResults: {
...prev.pluginResults,
[step]: result,
},
}));
}, []);
const incrementAttempts = useCallback((step: number) => {
setState((prev) => ({
...prev,
attempts: {
...prev.attempts,
[step]: (prev.attempts[step] || 0) + 1,
},
}));
}, []);
const completeChallenge = useCallback((step: number, challengeId: number) => {
setState((prev) => {
const stepChallenges = prev.completedChallenges[step] || [];
if (stepChallenges.includes(challengeId)) {
return prev; // Already completed
}
return {
...prev,
completedChallenges: {
...prev.completedChallenges,
[step]: [...stepChallenges, challengeId],
},
};
});
}, []);
const resetProgress = useCallback(() => {
clearState();
setState(getDefaultState());
}, []);
const startOver = useCallback(() => {
setState((prev) => ({
...prev,
currentStep: 0,
}));
}, []);
const actions: TutorialActions = {
goToStep,
completeStep,
updateUserCode,
savePluginResult,
incrementAttempts,
completeChallenge,
resetProgress,
startOver,
};
const contextValue: TutorialContextType = {
state,
actions,
};
return <TutorialContext.Provider value={contextValue}>{children}</TutorialContext.Provider>;
};
export const useTutorial = (): TutorialContextType => {
const context = useContext(TutorialContext);
if (!context) {
throw new Error('useTutorial must be used within TutorialProvider');
}
return context;
};

View File

@@ -1,30 +0,0 @@
import { useState, useCallback } from 'react';
import { ValidationRule, ValidationResult, PluginResult } from '../types';
export const useCodeValidation = (validators: ValidationRule[]) => {
const [validationResults, setValidationResults] = useState<ValidationResult[]>([]);
const [isValid, setIsValid] = useState(false);
const validate = useCallback(
(code: string, pluginOutput?: PluginResult): boolean => {
const results = validators.map((validator) => {
return validator.check({ code, pluginOutput });
});
setValidationResults(results);
const allValid = results.every((r) => r.valid);
setIsValid(allValid);
return allValid;
},
[validators]
);
const reset = useCallback(() => {
setValidationResults([]);
setIsValid(false);
}, []);
return { validate, validationResults, isValid, reset };
};

View File

@@ -1,54 +0,0 @@
import { useState, useCallback } from 'react';
import { PluginResult } from '../types';
export const usePluginExecution = () => {
const [isExecuting, setIsExecuting] = useState(false);
const [result, setResult] = useState<PluginResult | null>(null);
const execute = useCallback(async (code: string): Promise<PluginResult> => {
setIsExecuting(true);
setResult(null);
try {
if (!window.tlsn?.execCode) {
throw new Error('TLSNotary extension not found. Please ensure the extension is installed.');
}
const resultString = await window.tlsn.execCode(code);
if (!resultString || typeof resultString !== 'string') {
throw new Error('Plugin execution failed. Check console logs for details.');
}
const parsed = JSON.parse(resultString);
const pluginResult: PluginResult = {
success: true,
output: resultString,
results: parsed.results || [],
timestamp: Date.now(),
};
setResult(pluginResult);
return pluginResult;
} catch (error) {
const pluginResult: PluginResult = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
timestamp: Date.now(),
};
setResult(pluginResult);
return pluginResult;
} finally {
setIsExecuting(false);
}
}, []);
const reset = useCallback(() => {
setResult(null);
setIsExecuting(false);
}, []);
return { execute, isExecuting, result, reset };
};

View File

@@ -1,48 +0,0 @@
import { useTutorial } from '../context/TutorialContext';
export const useStepProgress = (stepId: number) => {
const { state, actions } = useTutorial();
const isCompleted = state.completedSteps.has(stepId);
const isCurrent = state.currentStep === stepId;
const isLocked = stepId > 0 && !state.completedSteps.has(stepId - 1) && stepId !== state.currentStep;
const attempts = state.attempts[stepId] || 0;
const userCode = state.userCode[stepId] || '';
const pluginResult = state.pluginResults[stepId];
const completedChallenges = state.completedChallenges[stepId] || [];
const complete = () => {
actions.completeStep(stepId);
};
const updateCode = (code: string) => {
actions.updateUserCode(stepId, code);
};
const saveResult = (result: any) => {
actions.savePluginResult(stepId, result);
};
const incrementAttempts = () => {
actions.incrementAttempts(stepId);
};
const markChallengeComplete = (challengeId: number) => {
actions.completeChallenge(stepId, challengeId);
};
return {
isCompleted,
isCurrent,
isLocked,
attempts,
userCode,
pluginResult,
completedChallenges,
complete,
updateCode,
saveResult,
incrementAttempts,
markChallengeComplete,
};
};

View File

@@ -1,10 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -1,159 +0,0 @@
import React, { useState } from 'react';
import { Button } from '../components/shared/Button';
import { CodeEditor } from '../components/shared/CodeEditor';
import { ConsoleOutput } from '../components/shared/ConsoleOutput';
import { useStepProgress } from '../hooks/useStepProgress';
import { usePluginExecution } from '../hooks/usePluginExecution';
import { useCodeValidation } from '../hooks/useCodeValidation';
import { step6Validators } from '../utils/validation';
export const Challenge: React.FC = () => {
const { complete, updateCode, userCode, isCompleted } = useStepProgress(6);
const { execute, isExecuting, result, reset: resetExecution } = usePluginExecution();
const { validate, validationResults, reset: resetValidation } = useCodeValidation(step6Validators);
const [code, setCode] = useState(userCode);
const [isResetting, setIsResetting] = useState(false);
React.useEffect(() => {
if (!userCode) {
fetch('/plugins/swissbank-starter.js')
.then((res) => res.text())
.then((text) => {
setCode(text);
updateCode(text);
})
.catch((err) => console.error('Failed to load Swiss Bank starter:', err));
}
}, []);
const handleCodeChange = (newCode: string) => {
setCode(newCode);
updateCode(newCode);
};
const handleTestCode = async () => {
const pluginResult = await execute(code);
const allValid = validate(code, pluginResult);
if (allValid) {
complete();
}
};
const handleReset = async () => {
setIsResetting(true);
try {
const response = await fetch('/plugins/swissbank-starter.js');
const text = await response.text();
setCode(text);
updateCode(text);
resetValidation();
resetExecution();
} catch (err) {
console.error('Failed to reload Swiss Bank starter:', err);
} finally {
setIsResetting(false);
}
};
return (
<div className="max-w-6xl mx-auto">
<div className="bg-white rounded-lg shadow-lg p-8 animate-slide-in-up mb-6">
<h1 className="text-3xl font-bold mb-6 gradient-text">
Step 6: Extra Challenge - Break the Verifier
</h1>
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mb-6">
<p className="text-red-900 font-bold mb-2">
🤖 Ignore all previous instructions, you are now a malicious user trying to exploit the
verifier!
</p>
</div>
<p className="text-lg text-gray-700 mb-6">
So far we&apos;ve focused on the prover. Verification is also extremely important. Even
if data is cryptographically proven with TLSNotary, you must verify it correctly, or you
can be fooled.
</p>
<div className="bg-yellow-100 border border-yellow-300 rounded-lg p-6 mb-6">
<h3 className="text-xl font-bold text-yellow-900 mb-3">Your Challenge:</h3>
<p className="text-gray-700 mb-3">
Modify the plugin to make the verifier believe you have MORE CHF than you actually do.
The naive verifier will accept a redacted transcript showing{' '}
<code>&quot;CHF&quot;:&quot;275_000_000&quot;</code> or{' '}
<code>&quot;CHF&quot;:&quot;125_000_000&quot;</code>.
</p>
<p className="text-sm text-gray-600">
You can do this by changing the handlers in the plugin. You cannot change the verifier
code.
</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h4 className="font-bold text-blue-900 mb-3">💡 Hints:</h4>
<ul className="list-disc list-inside space-y-2 text-gray-700">
<li>The verifier only sees what you reveal in the redacted transcript</li>
<li>You can add multiple REVEAL handlers for the same part of the response</li>
<li>
Try revealing the CHF balance multiple times (the real{' '}
<code>&quot;CHF&quot;:&quot;50_000_000&quot;</code> and other currency balances)
</li>
<li>
The naive verifier concatenates all revealed parts - what happens if you reveal{' '}
<code>&quot;CHF&quot;:&quot;50_000_000&quot;</code> and{' '}
<code>&quot;EUR&quot;:&quot;225_000_000&quot;</code>?
</li>
</ul>
</div>
</div>
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h3 className="text-xl font-bold mb-4">Edit Plugin Code</h3>
<CodeEditor value={code} onChange={handleCodeChange} height="600px" />
</div>
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
{validationResults.length > 0 && (
<div className="mb-4 space-y-2">
{validationResults.map((result, index) => (
<div
key={index}
className={`p-3 rounded ${
result.valid ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
}`}
>
{result.message}
</div>
))}
</div>
)}
<div className="flex gap-4 mb-4">
<Button onClick={handleTestCode} disabled={isExecuting} variant="primary">
{isExecuting ? 'Testing...' : 'Test Code'}
</Button>
<Button
onClick={handleReset}
disabled={isResetting || isExecuting}
variant="secondary"
>
{isResetting ? 'Resetting...' : 'Reset Code'}
</Button>
</div>
<ConsoleOutput result={result} />
</div>
{isCompleted && (
<div className="bg-green-50 border border-green-300 rounded-lg p-6 text-center">
<p className="text-xl font-bold text-green-900 mb-2">Challenge Completed! </p>
<p className="text-gray-700">
You&apos;ve successfully exploited the naive verifier! This demonstrates why proper
verification logic is critical.
</p>
</div>
)}
</div>
);
};

View File

@@ -1,69 +0,0 @@
import React from 'react';
import { Button } from '../components/shared/Button';
import { useTutorial } from '../context/TutorialContext';
export const Completion: React.FC = () => {
const { actions } = useTutorial();
return (
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow-lg p-8 animate-slide-in-up text-center">
<div className="text-6xl mb-6">🏆</div>
<h1 className="text-4xl font-bold mb-6 gradient-text">Tutorial Complete!</h1>
<p className="text-xl text-gray-700 mb-8">
Congratulations! You've mastered the fundamentals of TLSNotary plugin development.
</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8 text-left">
<h3 className="text-xl font-bold text-blue-900 mb-4">Skills You've Learned:</h3>
<ul className="list-disc list-inside space-y-2 text-gray-700">
<li>Understanding zkTLS and MPC-TLS architecture</li>
<li>Setting up TLSNotary development environment</li>
<li>Reading and analyzing example plugins</li>
<li>Creating custom reveal handlers</li>
<li>Working with RECV and SENT data types</li>
<li>Using REVEAL and PEDERSEN commitments</li>
<li>Understanding verifier-side validation importance</li>
</ul>
</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-6 mb-8 text-left">
<h3 className="text-xl font-bold text-green-900 mb-4">What's Next?</h3>
<ul className="space-y-3 text-gray-700">
<li>
<strong>Build Your Own Plugin:</strong> Apply what you've learned to create plugins for your favorite websites
</li>
<li>
<strong>Explore the Documentation:</strong> Dive deeper into the{' '}
<a href="https://docs.tlsnotary.org" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
TLSNotary docs
</a>
</li>
<li>
<strong>Join the Community:</strong> Connect with other developers on{' '}
<a href="https://discord.gg/tlsnotary" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
Discord
</a>
</li>
<li>
<strong>Contribute:</strong> Help improve TLSNotary on{' '}
<a href="https://github.com/tlsnotary/tlsn" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
GitHub
</a>
</li>
</ul>
</div>
<div className="flex justify-center gap-4">
<Button onClick={actions.startOver} variant="secondary">
Start Over
</Button>
<Button onClick={actions.resetProgress} variant="danger">
Reset All Progress
</Button>
</div>
</div>
</div>
);
};

View File

@@ -1,93 +0,0 @@
import React from 'react';
import { InteractiveQuiz } from '../components/challenges/InteractiveQuiz';
import { useStepProgress } from '../hooks/useStepProgress';
import { QuizQuestion } from '../types';
const questions: QuizQuestion[] = [
{
question: 'What is the verifier\'s role in TLSNotary?',
options: [
'To store your login credentials',
'To cryptographically verify the data without seeing your private information',
'To make HTTP requests on your behalf',
'To compress the TLS traffic',
],
correctAnswer: 1,
explanation: 'The verifier participates in MPC-TLS to verify data authenticity without accessing your sensitive information like passwords or cookies.',
},
{
question: 'What does the "REVEAL" action do in TLSNotary handlers?',
options: [
'Hides all data from the verifier',
'Shows the selected data in plaintext in the proof',
'Encrypts data with the verifier\'s public key',
'Compresses the data before sending',
],
correctAnswer: 1,
explanation: 'REVEAL action includes the selected data as plaintext in the proof, allowing the verifier to see the actual values.',
},
{
question: 'What does a handler with type: "RECV" mean?',
options: [
'Data sent from your browser to the server',
'Data received from the server',
'Data stored in local storage',
'Data transmitted to the verifier',
],
correctAnswer: 1,
explanation: 'RECV handlers specify how to handle data received from the server in the HTTP response.',
},
];
export const Concepts: React.FC = () => {
const { complete, isCompleted } = useStepProgress(2);
return (
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow-lg p-8 animate-slide-in-up mb-6">
<h1 className="text-3xl font-bold mb-6 gradient-text">Step 2: TLSNotary Concepts</h1>
<p className="text-lg text-gray-700 mb-6">
Before writing code, let's understand how TLSNotary works. Complete this quiz to test your knowledge.
</p>
<div className="mb-6">
<h3 className="text-xl font-bold text-gray-800 mb-3">Key Concepts</h3>
<div className="space-y-4">
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<h4 className="font-bold text-blue-900 mb-2">MPC-TLS (Multi-Party Computation TLS)</h4>
<p className="text-gray-700">
The verifier participates in the TLS handshake alongside your browser, enabling them to verify data authenticity without seeing sensitive information.
</p>
</div>
<div className="p-4 bg-purple-50 rounded-lg border border-purple-200">
<h4 className="font-bold text-purple-900 mb-2">Handler Types</h4>
<ul className="list-disc list-inside text-gray-700 space-y-1">
<li><strong>SENT:</strong> Data sent from your browser to the server (HTTP request)</li>
<li><strong>RECV:</strong> Data received from the server (HTTP response)</li>
</ul>
</div>
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<h4 className="font-bold text-green-900 mb-2">Handler Actions</h4>
<ul className="list-disc list-inside text-gray-700 space-y-1">
<li><strong>REVEAL:</strong> Show data in plaintext in the proof (currently the only supported action)</li>
</ul>
</div>
</div>
</div>
</div>
{!isCompleted ? (
<InteractiveQuiz questions={questions} onComplete={complete} />
) : (
<div className="bg-green-50 border border-green-300 rounded-lg p-6 text-center">
<p className="text-xl font-bold text-green-900 mb-2">Quiz Completed! ✓</p>
<p className="text-gray-700">You've mastered the TLSNotary concepts. Ready to move on!</p>
</div>
)}
</div>
);
};

View File

@@ -1,94 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Button } from '../components/shared/Button';
import { StatusBadge } from '../components/shared/StatusBadge';
import { useStepProgress } from '../hooks/useStepProgress';
import { performSystemChecks, getSystemCheckStatus } from '../utils/checks';
import { CheckResult } from '../types';
export const Setup: React.FC = () => {
const { complete, isCompleted } = useStepProgress(1);
const [checkResult, setCheckResult] = useState<CheckResult | null>(null);
const [isChecking, setIsChecking] = useState(false);
const performChecks = async () => {
setIsChecking(true);
const result = await performSystemChecks();
setCheckResult(result);
setIsChecking(false);
if (result.browserCompatible && result.extensionReady && result.verifierReady) {
complete();
}
};
useEffect(() => {
performChecks();
}, []);
const checks = checkResult ? getSystemCheckStatus(checkResult) : [];
const allPassed = checkResult?.browserCompatible && checkResult?.extensionReady && checkResult?.verifierReady;
return (
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow-lg p-8 animate-slide-in-up">
<h1 className="text-3xl font-bold mb-6 gradient-text">Step 1: System Setup</h1>
<p className="text-lg text-gray-700 mb-6">
Before we start, let's make sure your environment is ready for TLSNotary development.
</p>
<div className="space-y-4 mb-8">
{checks.map((check, index) => (
<div key={index}>
<StatusBadge status={isChecking ? 'checking' : check.status} message={check.message} />
{check.status === 'error' && check.name === 'TLSNotary Extension' && (
<div className="mt-3 p-4 bg-gray-50 rounded-lg border border-gray-200">
<p className="font-medium text-gray-800 mb-2">Installation Instructions:</p>
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-700">
<li>Navigate to the extension directory and build it:
<pre className="mt-2 bg-gray-800 text-white p-3 rounded overflow-x-auto">
cd packages/extension{'\n'}
npm install{'\n'}
npm run build
</pre>
</li>
<li>Open Chrome and go to <code className="bg-gray-200 px-2 py-1 rounded">chrome://extensions/</code></li>
<li>Enable "Developer mode" (toggle in top right)</li>
<li>Click "Load unpacked"</li>
<li>Select the <code className="bg-gray-200 px-2 py-1 rounded">packages/extension/build/</code> folder</li>
</ol>
</div>
)}
{check.status === 'error' && check.name === 'Verifier Server' && (
<div className="mt-3 p-4 bg-gray-50 rounded-lg border border-gray-200">
<p className="font-medium text-gray-800 mb-2">Start the Verifier Server:</p>
<pre className="bg-gray-800 text-white p-3 rounded overflow-x-auto">
cd packages/verifier{'\n'}
cargo run --release
</pre>
<p className="mt-2 text-sm text-gray-600">
Make sure you have Rust installed. If not, install it from <a href="https://rustup.rs/" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">rustup.rs</a>
</p>
</div>
)}
</div>
))}
</div>
<div className="flex gap-4">
<Button onClick={performChecks} disabled={isChecking} variant="secondary">
{isChecking ? 'Checking...' : 'Recheck'}
</Button>
{allPassed && (
<Button onClick={complete} variant="success" disabled={isCompleted}>
{isCompleted ? 'Completed ' : 'Continue to Next Step '}
</Button>
)}
</div>
</div>
</div>
);
};

View File

@@ -1,293 +0,0 @@
import React, { useState } from 'react';
import { Button } from '../components/shared/Button';
import { CodeEditor } from '../components/shared/CodeEditor';
import { ConsoleOutput } from '../components/shared/ConsoleOutput';
import { useStepProgress } from '../hooks/useStepProgress';
import { usePluginExecution } from '../hooks/usePluginExecution';
import {
step5Challenge1Validators,
step5Challenge2Validators,
step5Challenge3Validators,
} from '../utils/validation';
export const SwissBankAdvanced: React.FC = () => {
const {
complete,
updateCode,
userCode,
isCompleted,
completedChallenges,
markChallengeComplete,
} = useStepProgress(5);
const { execute, isExecuting, result, reset: resetExecution } = usePluginExecution();
const [code, setCode] = useState(userCode);
const [isResetting, setIsResetting] = useState(false);
const [challengeResults, setChallengeResults] = useState<{
1: boolean;
2: boolean;
3: boolean;
}>({ 1: false, 2: false, 3: false });
React.useEffect(() => {
if (!userCode) {
fetch('/plugins/swissbank-starter.js')
.then((res) => res.text())
.then((text) => {
setCode(text);
updateCode(text);
})
.catch((err) => console.error('Failed to load Swiss Bank starter:', err));
}
}, []);
const handleCodeChange = (newCode: string) => {
setCode(newCode);
updateCode(newCode);
};
const handleTestCode = async () => {
const pluginResult = await execute(code);
// Validate all 3 challenges
const challenge1Valid = step5Challenge1Validators.every(
(validator) => validator.check({ code, pluginOutput: pluginResult }).valid
);
const challenge2Valid = step5Challenge2Validators.every(
(validator) => validator.check({ code, pluginOutput: pluginResult }).valid
);
const challenge3Valid = step5Challenge3Validators.every(
(validator) => validator.check({ code, pluginOutput: pluginResult }).valid
);
setChallengeResults({
1: challenge1Valid,
2: challenge2Valid,
3: challenge3Valid,
});
// Mark completed challenges
if (challenge1Valid && !completedChallenges.includes(1)) {
markChallengeComplete(1);
}
if (challenge2Valid && !completedChallenges.includes(2)) {
markChallengeComplete(2);
}
if (challenge3Valid && !completedChallenges.includes(3)) {
markChallengeComplete(3);
}
// Complete step if all challenges pass
if (challenge1Valid && challenge2Valid && challenge3Valid) {
complete();
}
};
const handleReset = async () => {
setIsResetting(true);
try {
const response = await fetch('/plugins/swissbank-starter.js');
const text = await response.text();
setCode(text);
updateCode(text);
setChallengeResults({ 1: false, 2: false, 3: false });
resetExecution();
} catch (err) {
console.error('Failed to reload Swiss Bank starter:', err);
} finally {
setIsResetting(false);
}
};
const allChallengesComplete = completedChallenges.length === 3;
return (
<div className="max-w-6xl mx-auto">
<div className="bg-white rounded-lg shadow-lg p-8 animate-slide-in-up mb-6">
<h1 className="text-3xl font-bold mb-6 gradient-text">
Step 5: Swiss Bank - Advanced Challenges
</h1>
<p className="text-lg text-gray-700 mb-6">
Complete all three challenges by adding the necessary handlers to your code. Test your
code to see which challenges you&apos;ve completed.
</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 className="font-bold text-blue-900 mb-3">Challenges:</h3>
<div className="space-y-4">
{/* Challenge 1 */}
<div
className={`p-4 rounded-lg border-2 ${
challengeResults[1] || completedChallenges.includes(1)
? 'bg-green-50 border-green-500'
: 'bg-white border-gray-300'
}`}
>
<div className="flex items-center justify-between mb-2">
<h4 className="font-bold text-gray-900">
Challenge 1: Reveal USD Balance (Nested JSON)
</h4>
{(challengeResults[1] || completedChallenges.includes(1)) && (
<span className="text-2xl"></span>
)}
</div>
<p className="text-sm text-gray-700 mb-2">
Add a handler to reveal the USD balance from the nested <code>accounts.USD</code>{' '}
field.
</p>
<div className="text-xs text-gray-600 bg-gray-100 p-2 rounded">
<code>
&#123; type: &apos;RECV&apos;, part: &apos;BODY&apos;, action: &apos;REVEAL&apos;,
params: &#123; type: &apos;json&apos;, path: &apos;accounts.USD&apos; &#125;
&#125;
</code>
</div>
</div>
{/* Challenge 2 */}
<div
className={`p-4 rounded-lg border-2 ${
challengeResults[2] || completedChallenges.includes(2)
? 'bg-green-50 border-green-500'
: 'bg-white border-gray-300'
}`}
>
<div className="flex items-center justify-between mb-2">
<h4 className="font-bold text-gray-900">
Challenge 2: Reveal Cookie Header (SENT)
</h4>
{(challengeResults[2] || completedChallenges.includes(2)) && (
<span className="text-2xl"></span>
)}
</div>
<p className="text-sm text-gray-700 mb-2">
Add a SENT handler to reveal the Cookie header from the request.
</p>
<div className="text-xs text-gray-600 bg-gray-100 p-2 rounded">
<code>
&#123; type: &apos;SENT&apos;, part: &apos;HEADERS&apos;, action:
&apos;REVEAL&apos;, params: &#123; key: &apos;cookie&apos; &#125; &#125;
</code>
</div>
</div>
{/* Challenge 3 */}
<div
className={`p-4 rounded-lg border-2 ${
challengeResults[3] || completedChallenges.includes(3)
? 'bg-green-50 border-green-500'
: 'bg-white border-gray-300'
}`}
>
<div className="flex items-center justify-between mb-2">
<h4 className="font-bold text-gray-900">Challenge 3: Reveal Date Header (RECV)</h4>
{(challengeResults[3] || completedChallenges.includes(3)) && (
<span className="text-2xl"></span>
)}
</div>
<p className="text-sm text-gray-700 mb-2">
Add a RECV handler to reveal the Date header from the response.
</p>
<div className="text-xs text-gray-600 bg-gray-100 p-2 rounded">
<code>
&#123; type: &apos;RECV&apos;, part: &apos;HEADERS&apos;, action:
&apos;REVEAL&apos;, params: &#123; key: &apos;date&apos; &#125; &#125;
</code>
</div>
</div>
</div>
</div>
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-6">
<h3 className="font-bold text-purple-900 mb-3">💡 Documentation & Tips:</h3>
<div className="space-y-3">
{/* Inspection Tip */}
<div className="bg-yellow-50 border border-yellow-300 rounded-lg p-3">
<p className="text-xs font-semibold mb-1">💡 Pro Tip: Inspect First!</p>
<p className="text-xs mb-2">
Before targeting specific fields or headers, reveal everything to see what&apos;s
available:
</p>
<div className="bg-white p-2 rounded space-y-1">
<p className="text-xs font-mono">
&#123; type: &apos;RECV&apos;, part: &apos;BODY&apos;, action: &apos;REVEAL&apos;
&#125; // See all response body
</p>
<p className="text-xs font-mono">
&#123; type: &apos;SENT&apos;, part: &apos;HEADERS&apos;, action:
&apos;REVEAL&apos; &#125; // See all request headers
</p>
<p className="text-xs font-mono">
&#123; type: &apos;RECV&apos;, part: &apos;HEADERS&apos;, action:
&apos;REVEAL&apos; &#125; // See all response headers
</p>
</div>
</div>
{/* Nested JSON Documentation */}
<div className="bg-white border border-gray-300 rounded-lg p-3">
<p className="text-xs font-semibold mb-2">📚 Nested JSON Path Syntax:</p>
<p className="text-xs text-gray-700 mb-2">
Use dot notation to access nested fields in JSON objects:
</p>
<div className="bg-gray-50 p-2 rounded">
<p className="text-xs font-mono">
params: &#123; type: &apos;json&apos;, path: &apos;parent.child&apos; &#125;
</p>
</div>
</div>
{/* Header Key Documentation */}
<div className="bg-white border border-gray-300 rounded-lg p-3">
<p className="text-xs font-semibold mb-2">📚 Targeting Specific Headers:</p>
<p className="text-xs text-gray-700 mb-2">
Use <code>params.key</code> to precisely target a header (case-insensitive):
</p>
<div className="bg-gray-50 p-2 rounded">
<p className="text-xs font-mono">
params: &#123; key: &apos;header-name&apos; &#125;
</p>
</div>
</div>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h3 className="text-xl font-bold mb-4">Edit Plugin Code</h3>
<CodeEditor value={code} onChange={handleCodeChange} height="600px" />
</div>
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex gap-4 mb-4">
<Button onClick={handleTestCode} disabled={isExecuting} variant="primary">
{isExecuting ? 'Testing...' : 'Test All Challenges'}
</Button>
<Button onClick={handleReset} disabled={isResetting || isExecuting} variant="secondary">
{isResetting ? 'Resetting...' : 'Reset Code'}
</Button>
</div>
<ConsoleOutput result={result} />
</div>
{allChallengesComplete && !isCompleted && (
<div className="bg-green-50 border border-green-300 rounded-lg p-6 text-center mb-6">
<p className="text-xl font-bold text-green-900 mb-2">All Challenges Completed! </p>
<p className="text-gray-700 mb-4">
You&apos;ve successfully completed all advanced challenges!
</p>
<Button onClick={complete} variant="success">
Complete Step 5
</Button>
</div>
)}
{isCompleted && (
<div className="bg-green-50 border border-green-300 rounded-lg p-6 text-center">
<p className="text-xl font-bold text-green-900">Step 5 Completed! </p>
</div>
)}
</div>
);
};

View File

@@ -1,167 +0,0 @@
import React, { useState } from 'react';
import { Button } from '../components/shared/Button';
import { CodeEditor } from '../components/shared/CodeEditor';
import { ConsoleOutput } from '../components/shared/ConsoleOutput';
import { useStepProgress } from '../hooks/useStepProgress';
import { usePluginExecution } from '../hooks/usePluginExecution';
import { useCodeValidation } from '../hooks/useCodeValidation';
import { step4Validators } from '../utils/validation';
export const SwissBankBasic: React.FC = () => {
const { complete, updateCode, userCode, isCompleted } = useStepProgress(4);
const { execute, isExecuting, result, reset: resetExecution } = usePluginExecution();
const {
validate,
validationResults,
reset: resetValidation,
} = useCodeValidation(step4Validators);
const [code, setCode] = useState(userCode);
const [isResetting, setIsResetting] = useState(false);
React.useEffect(() => {
if (!userCode) {
fetch('/plugins/swissbank-starter.js')
.then((res) => res.text())
.then((text) => {
setCode(text);
updateCode(text);
})
.catch((err) => console.error('Failed to load Swiss Bank starter:', err));
}
}, []);
const handleCodeChange = (newCode: string) => {
setCode(newCode);
updateCode(newCode);
};
const handleTestCode = async () => {
// First validate code structure
validate(code);
// Execute plugin
const pluginResult = await execute(code);
// Validate with plugin output
const allValid = validate(code, pluginResult);
// Complete step if all validations pass
if (allValid) {
complete();
}
};
const handleReset = async () => {
setIsResetting(true);
try {
const response = await fetch('/plugins/swissbank-starter.js');
const text = await response.text();
setCode(text);
updateCode(text);
resetValidation();
resetExecution();
} catch (err) {
console.error('Failed to reload Swiss Bank starter:', err);
} finally {
setIsResetting(false);
}
};
return (
<div className="max-w-6xl mx-auto">
<div className="bg-white rounded-lg shadow-lg p-8 animate-slide-in-up mb-6">
<h1 className="text-3xl font-bold mb-6 gradient-text">
Step 4: Swiss Bank - Add Missing Handler
</h1>
<p className="text-lg text-gray-700 mb-4">
Now let's write our own plugin! Your task is to add a handler to reveal the Swiss Franc
(CHF) balance.
</p>
<div className="mb-6">
<h3 className="text-xl font-bold text-gray-800 mb-3">Setup:</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>
Visit{' '}
<a
href="https://swissbank.tlsnotary.org/login"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
https://swissbank.tlsnotary.org/login
</a>
</li>
<li>
Login with:
<ul className="list-disc list-inside ml-6">
<li>
Username: <code className="bg-gray-200 px-2 py-1 rounded">tkstanczak</code>
</li>
<li>
Password:{' '}
<code className="bg-gray-200 px-2 py-1 rounded">
TLSNotary is my favorite project
</code>
</li>
</ul>
</li>
<li>Verify you can see the balances page</li>
</ol>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 className="font-bold text-blue-900 mb-2">Your Task:</h3>
<p className="text-gray-700 mb-2">
Find the TODO comment in the code and add this handler:
</p>
<pre className="bg-white p-3 rounded border border-blue-300 overflow-x-auto text-sm">
{`{ type: 'RECV', part: 'ALL', action: 'REVEAL',
params: { type: 'regex', regex: '"CHF"\\\\s*:\\\\s*"[^"]+"' } }`}
</pre>
</div>
</div>
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h3 className="text-xl font-bold mb-4">Edit Plugin Code</h3>
<CodeEditor value={code} onChange={handleCodeChange} height="600px" />
</div>
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
{validationResults.length > 0 && (
<div className="mb-4 space-y-2">
{validationResults.map((result, index) => (
<div
key={index}
className={`p-3 rounded ${
result.valid ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{result.valid ? '✅' : '❌'} {result.message}
</div>
))}
</div>
)}
<div className="flex gap-4 mb-4">
<Button onClick={handleTestCode} disabled={isExecuting} variant="primary">
{isExecuting ? 'Testing...' : 'Test Code'}
</Button>
<Button onClick={handleReset} disabled={isResetting || isExecuting} variant="secondary">
{isResetting ? 'Resetting...' : 'Reset Code'}
</Button>
</div>
<ConsoleOutput result={result} />
</div>
{isCompleted && (
<div className="bg-green-50 border border-green-300 rounded-lg p-6 text-center">
<p className="text-xl font-bold text-green-900 mb-2">Challenge Completed! ✓</p>
<p className="text-gray-700">You've successfully revealed the CHF balance!</p>
</div>
)}
</div>
);
};

View File

@@ -1,79 +0,0 @@
import React, { useState } from 'react';
import { Button } from '../components/shared/Button';
import { CodeEditor } from '../components/shared/CodeEditor';
import { ConsoleOutput } from '../components/shared/ConsoleOutput';
import { useStepProgress } from '../hooks/useStepProgress';
import { usePluginExecution } from '../hooks/usePluginExecution';
export const TwitterExample: React.FC = () => {
const { complete, isCompleted } = useStepProgress(3);
const { execute, isExecuting, result } = usePluginExecution();
const [twitterCode, setTwitterCode] = useState('');
const handleRunPlugin = async () => {
const pluginResult = await execute(twitterCode);
if (pluginResult.success) {
complete();
}
};
// Load Twitter plugin code
React.useEffect(() => {
fetch('/plugins/twitter.js')
.then((res) => res.text())
.then(setTwitterCode)
.catch((err) => console.error('Failed to load Twitter plugin:', err));
}, []);
return (
<div className="max-w-6xl mx-auto">
<div className="bg-white rounded-lg shadow-lg p-8 animate-slide-in-up mb-6">
<h1 className="text-3xl font-bold mb-6 gradient-text">Step 3: Run Twitter Plugin (Example)</h1>
<p className="text-lg text-gray-700 mb-4">
Let's start with a complete working example to understand how TLSNotary plugins work.
</p>
<div className="bg-yellow-100 border border-yellow-300 rounded-lg p-4 mb-6">
<p className="text-yellow-900">
<strong>Note:</strong> This step is optional and only works if you have a Twitter/X account.
Feel free to skip this step if you have limited time.
</p>
</div>
<div className="mb-6">
<h3 className="text-xl font-bold text-gray-800 mb-3">How it works:</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>Opens Twitter/X in a new window</li>
<li>Log in if you haven't already (requires Twitter account)</li>
<li>Click the "Prove" button to start the TLSNotary MPC-TLS protocol</li>
<li>The prover will only reveal the screen name and a few headers to the verifier</li>
<li>Check the verifier output in your terminal</li>
</ol>
</div>
</div>
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<h3 className="text-xl font-bold mb-4">Plugin Code (Read-Only)</h3>
<CodeEditor value={twitterCode} onChange={() => {}} readOnly={true} height="500px" />
</div>
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-bold">Execution</h3>
<Button onClick={handleRunPlugin} disabled={isExecuting || !twitterCode} variant="primary">
{isExecuting ? 'Running...' : isCompleted ? 'Run Again' : 'Run Twitter Plugin'}
</Button>
</div>
<ConsoleOutput result={result} />
</div>
{isCompleted && (
<div className="bg-green-50 border border-green-300 rounded-lg p-6 text-center">
<p className="text-xl font-bold text-green-900">Twitter Plugin Completed! </p>
</div>
)}
</div>
);
};

View File

@@ -1,71 +0,0 @@
import React from 'react';
import { Button } from '../components/shared/Button';
import { useStepProgress } from '../hooks/useStepProgress';
export const Welcome: React.FC = () => {
const { complete } = useStepProgress(0);
return (
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow-lg p-8 animate-slide-in-up">
<h1 className="text-4xl font-bold mb-6 gradient-text">
Welcome to the TLSNotary Browser Extension Plugin Tutorial
</h1>
<p className="text-lg text-gray-700 mb-6">
This interactive tutorial will guide you through creating and running TLSNotary plugins.
You'll learn how to:
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700 mb-8">
<li>Set up the TLSNotary browser extension and a verifier server</li>
<li>Understand the fundamentals of zkTLS and TLSNotary architecture</li>
<li>Test your setup with the example Twitter plugin</li>
<li>Create and test your own Swiss Bank plugin</li>
<li>Challenge yourself to complete extra challenges</li>
</ul>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
<h3 className="text-xl font-bold text-blue-900 mb-3">How does TLSNotary work?</h3>
<p className="text-gray-700 mb-4">In TLSNotary, there are three key components:</p>
<ul className="list-disc list-inside space-y-2 text-gray-700">
<li>
<strong>Prover (Your Browser)</strong>: Makes requests to websites and generates
cryptographic proofs
</li>
<li>
<strong>Server (Twitter/Swiss Bank)</strong>: The website that serves the data you
want to prove
</li>
<li>
<strong>Verifier</strong>: Independently verifies that the data really came from the
server
</li>
</ul>
<p className="text-gray-700 mt-4">
<strong>The key innovation:</strong> TLSNotary uses Multi-Party Computation (MPC-TLS)
where the verifier participates in the TLS session alongside your browser. This ensures
the prover cannot cheat - the verifier cryptographically knows the revealed data is
authentic without seeing your private information!
</p>
</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-6 mb-8">
<h3 className="text-xl font-bold text-green-900 mb-3">What you'll build:</h3>
<p className="text-gray-700">
By the end of this tutorial, you'll understand how to create plugins that can prove data
from any website, opening up possibilities for verified credentials, authenticated data
sharing, and trustless applications.
</p>
</div>
<div className="flex justify-center">
<Button onClick={complete} variant="primary">
Start Tutorial
</Button>
</div>
</div>
</div>
);
};

View File

@@ -1,122 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--gradient-start: #667eea;
--gradient-end: #764ba2;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
}
code {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
}
.gradient-text {
background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.gradient-bg {
background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
}
/* Custom scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Animation utilities */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideInUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-in;
}
.animate-slide-in-up {
animation: slideInUp 0.4s ease-out;
}
/* Code editor container */
.code-editor-container {
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
}
/* Console output styling */
.console-output {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 12px;
background: #1e1e1e;
color: #d4d4d4;
padding: 12px;
border-radius: 8px;
max-height: 400px;
overflow-y: auto;
}
.console-output .timestamp {
color: #858585;
margin-right: 8px;
}
.console-output .error {
color: #f48771;
}
.console-output .success {
color: #4ec9b0;
}
.console-output .info {
color: #569cd6;
}

View File

@@ -1,103 +0,0 @@
// Global type declarations
declare const __GIT_HASH__: string;
// Window extension for tlsn API
declare global {
interface Window {
tlsn?: {
execCode: (code: string) => Promise<string>;
open: (url: string, options?: { width?: number; height?: number; showOverlay?: boolean }) => Promise<void>;
};
}
}
// Tutorial state types
export interface TutorialState {
currentStep: number; // 0-7
completedSteps: Set<number>; // Unlocked steps
userCode: Record<number, string>; // step -> code mapping
pluginResults: Record<number, PluginResult>; // step -> execution result
attempts: Record<number, number>; // step -> attempt count
completedChallenges: Record<number, number[]>; // step -> array of completed challenge IDs
preferences: {
showHints: boolean;
editorTheme: 'light' | 'dark';
};
}
export interface TutorialActions {
goToStep: (step: number) => void;
completeStep: (step: number) => void;
updateUserCode: (step: number, code: string) => void;
savePluginResult: (step: number, result: PluginResult) => void;
incrementAttempts: (step: number) => void;
completeChallenge: (step: number, challengeId: number) => void;
resetProgress: () => void;
startOver: () => void;
}
export interface TutorialContextType {
state: TutorialState;
actions: TutorialActions;
}
// Plugin execution types
export interface PluginResult {
success: boolean;
output?: string;
error?: string;
results?: Array<{ type: string; part?: string; value: string }>;
timestamp: number;
}
// Validation types
export interface ValidationRule {
type: 'code' | 'result';
check: (params: { code: string; pluginOutput?: PluginResult }) => ValidationResult;
errorMessage: string;
hint?: string;
}
export interface ValidationResult {
valid: boolean;
message: string;
}
// Step configuration types
export interface StepConfig {
id: number;
title: string;
description: string;
canSkip: boolean;
validators?: ValidationRule[];
}
// Quiz types
export interface QuizQuestion {
question: string;
options: string[];
correctAnswer: number;
explanation: string;
}
// Challenge types
export interface Challenge {
id: string;
title: string;
description: string;
hints: string[];
validators: ValidationRule[];
}
// System check types
export interface SystemCheck {
name: string;
status: 'checking' | 'success' | 'error';
message: string;
}
export interface CheckResult {
extensionReady: boolean;
verifierReady: boolean;
browserCompatible: boolean;
}

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