mirror of
https://github.com/tlsnotary/tlsn-extension.git
synced 2026-01-23 05:58:05 -05:00
Compare commits
2 Commits
git_fix
...
feat/fine-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e099f0b4e9 | ||
|
|
9c0b12da2c |
182
CLAUDE.md
182
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,12 +742,18 @@ 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
|
||||
@@ -659,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
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["http://*/*", "https://*/*", "<all_urls>"],
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["contentScript.bundle.js"],
|
||||
"css": ["content.styles.css"]
|
||||
}
|
||||
@@ -23,10 +23,10 @@
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["content.styles.css", "icon-128.png", "icon-34.png", "content.bundle.js", "*.wasm"],
|
||||
"matches": ["http://*/*", "https://*/*", "<all_urls>"]
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"optional_host_permissions": ["<all_urls>"],
|
||||
"permissions": [
|
||||
"offscreen",
|
||||
"webRequest",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user