mirror of
https://github.com/tlsnotary/tlsn-extension.git
synced 2026-01-22 21:48:05 -05:00
Compare commits
2 Commits
new-tutori
...
feat/fine-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e099f0b4e9 | ||
|
|
9c0b12da2c |
4
.github/workflows/demo.yml
vendored
4
.github/workflows/demo.yml
vendored
@@ -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
206
CLAUDE.md
@@ -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
|
||||
|
||||
|
||||
30
README.md
30
README.md
@@ -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
3570
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Verifier Configuration
|
||||
VITE_VERIFIER_HOST=localhost:7047
|
||||
VITE_SSL=false
|
||||
@@ -1,3 +0,0 @@
|
||||
# Production environment variables
|
||||
VITE_VERIFIER_HOST=verifier.tlsnotary.org
|
||||
VITE_SSL=true
|
||||
2
packages/demo/.gitignore
vendored
2
packages/demo/.gitignore
vendored
@@ -1,4 +1,2 @@
|
||||
*.wasm
|
||||
dist/
|
||||
public/plugins/
|
||||
generated/
|
||||
@@ -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!
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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');
|
||||
@@ -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
105
packages/demo/generate.sh
Executable 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 "========================================"
|
||||
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 >
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 { };
|
||||
@@ -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
31
packages/demo/start.sh
Executable 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 "$@"
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
306
packages/extension/src/background/PermissionManager.ts
Normal file
306
packages/extension/src/background/PermissionManager.ts
Normal 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();
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
317
packages/extension/tests/background/PermissionManager.test.ts
Normal file
317
packages/extension/tests/background/PermissionManager.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ var compiler = webpack(config);
|
||||
|
||||
var server = new WebpackDevServer(
|
||||
{
|
||||
server: 'http',
|
||||
https: false,
|
||||
hot: true,
|
||||
liveReload: false,
|
||||
client: {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
97
packages/plugin-sdk/src/globals.d.ts
vendored
97
packages/plugin-sdk/src/globals.d.ts
vendored
@@ -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 {};
|
||||
@@ -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",
|
||||
|
||||
148
packages/tlsn-wasm-pkg/tlsn_wasm.d.ts
vendored
148
packages/tlsn-wasm-pkg/tlsn_wasm.d.ts
vendored
@@ -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
Binary file not shown.
12
packages/tlsn-wasm-pkg/tlsn_wasm_bg.wasm.d.ts
vendored
12
packages/tlsn-wasm-pkg/tlsn_wasm_bg.wasm.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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 ../../
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
packages/tutorial/.gitignore
vendored
6
packages/tutorial/.gitignore
vendored
@@ -1,6 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
public/plugins/*.js
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -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
|
||||
@@ -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`);
|
||||
@@ -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
|
||||
@@ -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://<host></code>:
|
||||
<pre><code>wstcp --bind-addr 127.0.0.1:55688 <host>: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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 99 KiB |
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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'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>"CHF":"275_000_000"</code> or{' '}
|
||||
<code>"CHF":"125_000_000"</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>"CHF":"50_000_000"</code> and other currency balances)
|
||||
</li>
|
||||
<li>
|
||||
The naive verifier concatenates all revealed parts - what happens if you reveal{' '}
|
||||
<code>"CHF":"50_000_000"</code> and{' '}
|
||||
<code>"EUR":"225_000_000"</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've successfully exploited the naive verifier! This demonstrates why proper
|
||||
verification logic is critical.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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'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>
|
||||
{ type: 'RECV', part: 'BODY', action: 'REVEAL',
|
||||
params: { type: 'json', path: 'accounts.USD' }
|
||||
}
|
||||
</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>
|
||||
{ type: 'SENT', part: 'HEADERS', action:
|
||||
'REVEAL', params: { key: 'cookie' } }
|
||||
</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>
|
||||
{ type: 'RECV', part: 'HEADERS', action:
|
||||
'REVEAL', params: { key: 'date' } }
|
||||
</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's
|
||||
available:
|
||||
</p>
|
||||
<div className="bg-white p-2 rounded space-y-1">
|
||||
<p className="text-xs font-mono">
|
||||
{ type: 'RECV', part: 'BODY', action: 'REVEAL'
|
||||
} // See all response body
|
||||
</p>
|
||||
<p className="text-xs font-mono">
|
||||
{ type: 'SENT', part: 'HEADERS', action:
|
||||
'REVEAL' } // See all request headers
|
||||
</p>
|
||||
<p className="text-xs font-mono">
|
||||
{ type: 'RECV', part: 'HEADERS', action:
|
||||
'REVEAL' } // 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: { type: 'json', path: 'parent.child' }
|
||||
</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: { key: 'header-name' }
|
||||
</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'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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user