diff --git a/.github/workflows/extension.yml b/.github/workflows/extension.yml new file mode 100644 index 000000000..6603ba2d7 --- /dev/null +++ b/.github/workflows/extension.yml @@ -0,0 +1,59 @@ +# AUTHORED-BY Claude Opus 5.5 +# Browser extension end-to-end suite (apps/extension), carried over from the standalone +# jeswr/solid-suite-extension CI. Lint, typecheck, unit tests and build run in gate.yml. +# The e2e suite starts a LOCAL Community Solid Server and two static test sites. +name: extension + +on: + pull_request: + paths: + - "apps/extension/**" + - "packages/solid-elements/**" + - ".github/workflows/extension.yml" + - "pnpm-lock.yaml" + push: + branches: [main] + paths: + - "apps/extension/**" + - "packages/solid-elements/**" + - ".github/workflows/extension.yml" + - "pnpm-lock.yaml" + +permissions: + contents: read + +concurrency: + group: extension-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm turbo run build --filter=@jeswr/app-extension^... + - name: Install Playwright Chromium + working-directory: apps/extension + run: pnpm exec playwright install --with-deps chromium + - name: Run e2e + run: pnpm --filter @jeswr/app-extension run test:e2e + - uses: actions/upload-artifact@v4 + if: success() + with: + name: solid-browser-extension + path: apps/extension/dist/ + retention-days: 14 + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: extension-e2e-results + path: | + apps/extension/test-results/ + apps/extension/playwright-report/ diff --git a/AGENTS.md b/AGENTS.md index 7e77710af..0a5b5f067 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,8 @@ design + inventory + phasing lives in: applications were imported with full history; `@jeswr/synthetic-rdf` and `@jeswr/solid-seed` are the maintainer-directed new packages tracked by solid-mortgage beads sm-63 and sm-64. The applications comprise the eight `pod-*` apps plus Access Manager, Pod Manager, Issues, App Store, -and Coeliac. Mirror publishing has NOT run yet. Do not import further packages outside the phased +and Coeliac. The browser extension (`apps/extension`, `@jeswr/app-extension`) was imported with +full history from `jeswr/solid-suite-extension`. Mirror publishing has NOT run yet. Do not import further packages outside the phased package plan or explicit maintainer direction. ## The model in four rules diff --git a/apps/extension/.agents/skills/playwright-e2e-testing/SKILL.md b/apps/extension/.agents/skills/playwright-e2e-testing/SKILL.md new file mode 100644 index 000000000..632c2af38 --- /dev/null +++ b/apps/extension/.agents/skills/playwright-e2e-testing/SKILL.md @@ -0,0 +1,1086 @@ +--- +name: playwright-e2e-testing +description: "Playwright modern end-to-end testing framework with cross-browser automation, auto-wait, and built-in test runner" +progressive_disclosure: + entry_point: + summary: "Playwright modern end-to-end testing framework with cross-browser automation, auto-wait, and built-in test runner" + when_to_use: "When writing tests, implementing playwright-e2e-testing, or ensuring code quality." + quick_start: "1. Review the core concepts below. 2. Apply patterns to your use case. 3. Follow best practices for implementation." +--- +# Playwright E2E Testing Skill + +--- +progressive_disclosure: + entry_point: + summary: "Modern E2E testing framework with cross-browser automation and built-in test runner" + when_to_use: + - "When testing web applications end-to-end" + - "When needing cross-browser testing" + - "When testing user flows and interactions" + - "When needing screenshot/video recording" + quick_start: + - "npm init playwright@latest" + - "Choose TypeScript and test location" + - "npx playwright test" + - "npx playwright show-report" + token_estimate: + entry: 75-90 + full: 4200-5200 +--- + + + +## Overview + +Playwright is a modern end-to-end testing framework that provides cross-browser automation with a built-in test runner, auto-wait mechanisms, and excellent developer experience. + +### Key Features +- **Auto-wait**: Automatically waits for elements to be ready +- **Cross-browser**: Chromium, Firefox, WebKit support +- **Built-in runner**: Parallel execution, retries, reporters +- **Network control**: Mock and intercept network requests +- **Debugging**: UI mode, trace viewer, inspector + +--- + + + +## Installation + +```bash +# Initialize new Playwright project +npm init playwright@latest + +# Or add to existing project +npm install -D @playwright/test + +# Install browsers +npx playwright install +``` + +### Configuration + +```typescript +// playwright.config.ts +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + ], + + webServer: { + command: 'npm run start', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, +}); +``` + +## Fundamentals + +### Basic Test Structure + +```typescript +import { test, expect } from '@playwright/test'; + +test('basic test', async ({ page }) => { + await page.goto('https://example.com'); + + // Wait for element and check visibility + const title = page.locator('h1'); + await expect(title).toBeVisible(); + await expect(title).toHaveText('Example Domain'); + + // Get page title + await expect(page).toHaveTitle(/Example/); +}); + +test.describe('User authentication', () => { + test('should login successfully', async ({ page }) => { + await page.goto('/login'); + await page.fill('[name="username"]', 'testuser'); + await page.fill('[name="password"]', 'password123'); + await page.click('button[type="submit"]'); + + await expect(page).toHaveURL('/dashboard'); + await expect(page.locator('.welcome-message')).toContainText('Welcome'); + }); + + test('should show error for invalid credentials', async ({ page }) => { + await page.goto('/login'); + await page.fill('[name="username"]', 'invalid'); + await page.fill('[name="password"]', 'wrong'); + await page.click('button[type="submit"]'); + + await expect(page.locator('.error-message')).toBeVisible(); + await expect(page.locator('.error-message')).toHaveText('Invalid credentials'); + }); +}); +``` + +### Test Hooks + +```typescript +import { test, expect } from '@playwright/test'; + +test.describe('Dashboard tests', () => { + test.beforeEach(async ({ page }) => { + // Run before each test + await page.goto('/dashboard'); + await page.waitForLoadState('networkidle'); + }); + + test.afterEach(async ({ page }) => { + // Cleanup after each test + await page.close(); + }); + + test.beforeAll(async ({ browser }) => { + // Run once before all tests in describe block + console.log('Starting test suite'); + }); + + test.afterAll(async ({ browser }) => { + // Run once after all tests + console.log('Test suite complete'); + }); + + test('displays user data', async ({ page }) => { + await expect(page.locator('.user-name')).toBeVisible(); + }); +}); +``` + +## Locator Strategies + +### Best Practice: Role-based Locators + +```typescript +import { test, expect } from '@playwright/test'; + +test('accessible locators', async ({ page }) => { + await page.goto('/form'); + + // By role (BEST - accessible and stable) + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com'); + await page.getByRole('checkbox', { name: 'Subscribe' }).check(); + await page.getByRole('link', { name: 'Learn more' }).click(); + + // By label (good for forms) + await page.getByLabel('Password').fill('secret123'); + + // By placeholder + await page.getByPlaceholder('Search...').fill('query'); + + // By text + await page.getByText('Welcome back').click(); + await page.getByText(/hello/i).isVisible(); + + // By test ID (good for dynamic content) + await page.getByTestId('user-profile').click(); + + // By title + await page.getByTitle('Close dialog').click(); + + // By alt text (images) + await page.getByAltText('User avatar').click(); +}); +``` + +### CSS and XPath Locators + +```typescript +test('CSS and XPath locators', async ({ page }) => { + // CSS selectors + await page.locator('button.primary').click(); + await page.locator('#user-menu').click(); + await page.locator('[data-testid="submit-btn"]').click(); + await page.locator('div.card:first-child').click(); + + // XPath (use sparingly) + await page.locator('xpath=//button[contains(text(), "Submit")]').click(); + + // Chaining locators + const form = page.locator('form#login-form'); + await form.locator('input[name="email"]').fill('user@example.com'); + await form.locator('button[type="submit"]').click(); + + // Filter locators + await page.getByRole('listitem') + .filter({ hasText: 'Product 1' }) + .getByRole('button', { name: 'Add to cart' }) + .click(); +}); +``` + +## Page Object Model + +### Page Class Pattern + +```typescript +// pages/LoginPage.ts +import { Page, Locator } from '@playwright/test'; + +export class LoginPage { + readonly page: Page; + readonly usernameInput: Locator; + readonly passwordInput: Locator; + readonly submitButton: Locator; + readonly errorMessage: Locator; + + constructor(page: Page) { + this.page = page; + this.usernameInput = page.getByLabel('Username'); + this.passwordInput = page.getByLabel('Password'); + this.submitButton = page.getByRole('button', { name: 'Log in' }); + this.errorMessage = page.locator('.error-message'); + } + + async goto() { + await this.page.goto('/login'); + } + + async login(username: string, password: string) { + await this.usernameInput.fill(username); + await this.passwordInput.fill(password); + await this.submitButton.click(); + } + + async expectErrorMessage(message: string) { + await this.errorMessage.waitFor({ state: 'visible' }); + await expect(this.errorMessage).toHaveText(message); + } +} + +// pages/DashboardPage.ts +export class DashboardPage { + readonly page: Page; + readonly welcomeMessage: Locator; + readonly logoutButton: Locator; + + constructor(page: Page) { + this.page = page; + this.welcomeMessage = page.locator('.welcome-message'); + this.logoutButton = page.getByRole('button', { name: 'Logout' }); + } + + async waitForLoad() { + await this.welcomeMessage.waitFor({ state: 'visible' }); + } + + async logout() { + await this.logoutButton.click(); + } +} + +// tests/auth.spec.ts +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../pages/LoginPage'; +import { DashboardPage } from '../pages/DashboardPage'; + +test('successful login flow', async ({ page }) => { + const loginPage = new LoginPage(page); + const dashboard = new DashboardPage(page); + + await loginPage.goto(); + await loginPage.login('testuser', 'password123'); + + await dashboard.waitForLoad(); + await expect(dashboard.welcomeMessage).toContainText('Welcome'); +}); +``` + +### Component Pattern + +```typescript +// components/NavigationComponent.ts +import { Page, Locator } from '@playwright/test'; + +export class NavigationComponent { + readonly page: Page; + readonly homeLink: Locator; + readonly profileLink: Locator; + readonly searchInput: Locator; + + constructor(page: Page) { + this.page = page; + const nav = page.locator('nav'); + this.homeLink = nav.getByRole('link', { name: 'Home' }); + this.profileLink = nav.getByRole('link', { name: 'Profile' }); + this.searchInput = nav.getByPlaceholder('Search...'); + } + + async navigateToProfile() { + await this.profileLink.click(); + } + + async search(query: string) { + await this.searchInput.fill(query); + await this.searchInput.press('Enter'); + } +} +``` + +## User Interactions + +### Form Interactions + +```typescript +test('form interactions', async ({ page }) => { + await page.goto('/form'); + + // Text inputs + await page.fill('input[name="email"]', 'user@example.com'); + await page.type('textarea[name="message"]', 'Hello', { delay: 100 }); + + // Checkboxes + await page.check('input[type="checkbox"][name="subscribe"]'); + await page.uncheck('input[type="checkbox"][name="spam"]'); + + // Radio buttons + await page.check('input[type="radio"][value="option1"]'); + + // Select dropdowns + await page.selectOption('select[name="country"]', 'US'); + await page.selectOption('select[name="color"]', { label: 'Blue' }); + await page.selectOption('select[name="size"]', { value: 'large' }); + + // Multi-select + await page.selectOption('select[multiple]', ['value1', 'value2']); + + // File uploads + await page.setInputFiles('input[type="file"]', 'path/to/file.pdf'); + await page.setInputFiles('input[type="file"]', [ + 'file1.jpg', + 'file2.jpg' + ]); + + // Clear file input + await page.setInputFiles('input[type="file"]', []); +}); +``` + +### Mouse and Keyboard + +```typescript +test('mouse and keyboard interactions', async ({ page }) => { + // Click variations + await page.click('button'); + await page.dblclick('button'); // Double click + await page.click('button', { button: 'right' }); // Right click + await page.click('button', { modifiers: ['Shift'] }); // Shift+click + + // Hover + await page.hover('.tooltip-trigger'); + await expect(page.locator('.tooltip')).toBeVisible(); + + // Drag and drop + await page.dragAndDrop('#draggable', '#droppable'); + + // Keyboard + await page.keyboard.press('Enter'); + await page.keyboard.press('Control+A'); + await page.keyboard.type('Hello World'); + await page.keyboard.down('Shift'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.up('Shift'); + + // Focus + await page.focus('input[name="email"]'); + await page.fill('input[name="email"]', 'test@example.com'); +}); +``` + +### Waiting Strategies + +```typescript +test('waiting strategies', async ({ page }) => { + // Wait for element + await page.waitForSelector('.dynamic-content'); + await page.waitForSelector('.modal', { state: 'visible' }); + await page.waitForSelector('.loading', { state: 'hidden' }); + + // Wait for load state + await page.waitForLoadState('load'); + await page.waitForLoadState('domcontentloaded'); + await page.waitForLoadState('networkidle'); + + // Wait for URL + await page.waitForURL('**/dashboard'); + await page.waitForURL(/\/product\/\d+/); + + // Wait for function + await page.waitForFunction(() => { + return document.querySelectorAll('.item').length > 5; + }); + + // Wait for timeout (avoid if possible) + await page.waitForTimeout(1000); + + // Wait for event + await page.waitForEvent('load'); + await page.waitForEvent('popup'); +}); +``` + +## Assertions + +### Common Assertions + +```typescript +import { test, expect } from '@playwright/test'; + +test('assertions', async ({ page }) => { + await page.goto('/dashboard'); + + // Visibility + await expect(page.locator('.header')).toBeVisible(); + await expect(page.locator('.loading')).toBeHidden(); + await expect(page.locator('.optional')).not.toBeVisible(); + + // Text content + await expect(page.locator('h1')).toHaveText('Dashboard'); + await expect(page.locator('h1')).toContainText('Dash'); + await expect(page.locator('.message')).toHaveText(/welcome/i); + + // Attributes + await expect(page.locator('button')).toBeEnabled(); + await expect(page.locator('button')).toBeDisabled(); + await expect(page.locator('input')).toHaveAttribute('type', 'email'); + await expect(page.locator('input')).toHaveValue('test@example.com'); + + // CSS + await expect(page.locator('.button')).toHaveClass('btn-primary'); + await expect(page.locator('.button')).toHaveClass(/btn-/); + await expect(page.locator('.element')).toHaveCSS('color', 'rgb(255, 0, 0)'); + + // Count + await expect(page.locator('.item')).toHaveCount(5); + + // URL and title + await expect(page).toHaveURL('http://localhost:3000/dashboard'); + await expect(page).toHaveURL(/dashboard$/); + await expect(page).toHaveTitle('Dashboard - My App'); + await expect(page).toHaveTitle(/Dashboard/); + + // Screenshot comparison + await expect(page).toHaveScreenshot('dashboard.png'); + await expect(page.locator('.widget')).toHaveScreenshot('widget.png'); +}); +``` + +### Custom Assertions + +```typescript +test('custom matchers', async ({ page }) => { + // Soft assertions (continue test on failure) + await expect.soft(page.locator('.title')).toHaveText('Welcome'); + await expect.soft(page.locator('.subtitle')).toBeVisible(); + + // Multiple elements + const items = page.locator('.item'); + await expect(items).toHaveCount(3); + await expect(items.nth(0)).toContainText('First'); + await expect(items.nth(1)).toContainText('Second'); + + // Poll assertions + await expect(async () => { + const response = await page.request.get('/api/status'); + expect(response.ok()).toBeTruthy(); + }).toPass({ + timeout: 10000, + intervals: [1000, 2000, 5000], + }); +}); +``` + +## Authentication Patterns + +### Storage State Pattern + +```typescript +// auth.setup.ts - Run once to save auth state +import { test as setup } from '@playwright/test'; + +const authFile = 'playwright/.auth/user.json'; + +setup('authenticate', async ({ page }) => { + await page.goto('/login'); + await page.fill('[name="username"]', 'testuser'); + await page.fill('[name="password"]', 'password123'); + await page.click('button[type="submit"]'); + + await page.waitForURL('/dashboard'); + + // Save authentication state + await page.context().storageState({ path: authFile }); +}); + +// playwright.config.ts +export default defineConfig({ + projects: [ + { + name: 'setup', + testMatch: /.*\.setup\.ts/, + }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: authFile, + }, + dependencies: ['setup'], + }, + ], +}); + +// tests/dashboard.spec.ts - Already authenticated +test('view dashboard', async ({ page }) => { + await page.goto('/dashboard'); + // Already logged in! + await expect(page.locator('.user-menu')).toBeVisible(); +}); +``` + +### Multiple User Roles + +```typescript +// fixtures/auth.ts +import { test as base } from '@playwright/test'; + +type Fixtures = { + adminPage: Page; + userPage: Page; +}; + +export const test = base.extend({ + adminPage: async ({ browser }, use) => { + const context = await browser.newContext({ + storageState: 'playwright/.auth/admin.json', + }); + const page = await context.newPage(); + await use(page); + await context.close(); + }, + + userPage: async ({ browser }, use) => { + const context = await browser.newContext({ + storageState: 'playwright/.auth/user.json', + }); + const page = await context.newPage(); + await use(page); + await context.close(); + }, +}); + +// tests/permissions.spec.ts +import { test } from '../fixtures/auth'; + +test('admin can access admin panel', async ({ adminPage }) => { + await adminPage.goto('/admin'); + await expect(adminPage.locator('.admin-panel')).toBeVisible(); +}); + +test('regular user cannot access admin panel', async ({ userPage }) => { + await userPage.goto('/admin'); + await expect(userPage.locator('.access-denied')).toBeVisible(); +}); +``` + +## Network Control + +### Request Mocking + +```typescript +test('mock API responses', async ({ page }) => { + // Mock API response + await page.route('**/api/users', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + users: [ + { id: 1, name: 'John Doe' }, + { id: 2, name: 'Jane Smith' }, + ], + }), + }); + }); + + await page.goto('/users'); + await expect(page.locator('.user-list')).toContainText('John Doe'); +}); + +test('mock with conditions', async ({ page }) => { + await page.route('**/api/**', route => { + const url = route.request().url(); + + if (url.includes('/users/1')) { + route.fulfill({ + status: 200, + body: JSON.stringify({ id: 1, name: 'Test User' }), + }); + } else if (url.includes('/users')) { + route.fulfill({ + status: 200, + body: JSON.stringify({ users: [] }), + }); + } else { + route.continue(); + } + }); +}); + +test('simulate network errors', async ({ page }) => { + await page.route('**/api/data', route => { + route.abort('failed'); + }); + + await page.goto('/data'); + await expect(page.locator('.error-message')).toBeVisible(); +}); +``` + +### Request Interception + +```typescript +test('intercept and modify requests', async ({ page }) => { + // Modify request headers + await page.route('**/api/**', route => { + const headers = route.request().headers(); + route.continue({ + headers: { + ...headers, + 'X-Custom-Header': 'test-value', + }, + }); + }); + + // Modify POST data + await page.route('**/api/submit', route => { + const postData = route.request().postDataJSON(); + route.continue({ + postData: JSON.stringify({ + ...postData, + timestamp: Date.now(), + }), + }); + }); +}); + +test('wait for API response', async ({ page }) => { + // Wait for specific request + const responsePromise = page.waitForResponse('**/api/users'); + await page.click('button#load-users'); + const response = await responsePromise; + + expect(response.status()).toBe(200); + const data = await response.json(); + expect(data.users).toHaveLength(10); +}); +``` + +## Test Organization + +### Custom Fixtures + +```typescript +// fixtures/todos.ts +import { test as base } from '@playwright/test'; + +type TodoFixtures = { + todoPage: TodoPage; + createTodo: (title: string) => Promise; +}; + +export const test = base.extend({ + todoPage: async ({ page }, use) => { + const todoPage = new TodoPage(page); + await todoPage.goto(); + await use(todoPage); + }, + + createTodo: async ({ page }, use) => { + const create = async (title: string) => { + await page.fill('.new-todo', title); + await page.press('.new-todo', 'Enter'); + }; + await use(create); + }, +}); + +// tests/todos.spec.ts +import { test } from '../fixtures/todos'; + +test('can create new todo', async ({ todoPage, createTodo }) => { + await createTodo('Buy groceries'); + await expect(todoPage.todoItems).toHaveCount(1); + await expect(todoPage.todoItems).toHaveText('Buy groceries'); +}); +``` + +### Test Tags and Filtering + +```typescript +test('smoke test', { tag: '@smoke' }, async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle('Home'); +}); + +test('regression test', { tag: ['@regression', '@critical'] }, async ({ page }) => { + // Complex test +}); + +// Run: npx playwright test --grep @smoke +// Run: npx playwright test --grep-invert @slow +``` + +## Visual Testing + +### Screenshot Comparison + +```typescript +test('visual regression', async ({ page }) => { + await page.goto('/dashboard'); + + // Full page screenshot + await expect(page).toHaveScreenshot('dashboard.png', { + maxDiffPixels: 100, + }); + + // Element screenshot + await expect(page.locator('.widget')).toHaveScreenshot('widget.png'); + + // Full page with scroll + await expect(page).toHaveScreenshot('full-page.png', { + fullPage: true, + }); + + // Mask dynamic elements + await expect(page).toHaveScreenshot('masked.png', { + mask: [page.locator('.timestamp'), page.locator('.avatar')], + }); + + // Custom threshold + await expect(page).toHaveScreenshot('comparison.png', { + maxDiffPixelRatio: 0.05, // 5% difference allowed + }); +}); +``` + +### Video and Trace + +```typescript +// playwright.config.ts +export default defineConfig({ + use: { + video: 'retain-on-failure', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, +}); + +// Programmatic video +test('record video', async ({ page }) => { + await page.goto('/'); + // Test actions... + + // Video saved automatically to test-results/ +}); + +// View trace: npx playwright show-trace trace.zip +``` + +## Parallel Execution + +### Test Sharding + +```typescript +// playwright.config.ts +export default defineConfig({ + fullyParallel: true, + workers: process.env.CI ? 4 : undefined, +}); + +// Run shards in CI +// npx playwright test --shard=1/4 +// npx playwright test --shard=2/4 +// npx playwright test --shard=3/4 +// npx playwright test --shard=4/4 +``` + +### Serial Tests + +```typescript +test.describe.configure({ mode: 'serial' }); + +test.describe('order matters', () => { + let orderId: string; + + test('create order', async ({ page }) => { + // Create order + orderId = await createOrder(page); + }); + + test('verify order', async ({ page }) => { + // Use orderId from previous test + await verifyOrder(page, orderId); + }); +}); +``` + +## CI/CD Integration + +### GitHub Actions + +```yaml +# .github/workflows/playwright.yml +name: Playwright Tests +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Install Playwright Browsers + run: npx playwright install --with-deps + + - name: Run Playwright tests + run: npx playwright test + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 +``` + +### Docker + +```dockerfile +FROM mcr.microsoft.com/playwright:v1.40.0-jammy + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . + +CMD ["npx", "playwright", "test"] +``` + +## Debugging + +### UI Mode + +```bash +# Interactive debugging +npx playwright test --ui + +# Debug specific test +npx playwright test --debug login.spec.ts + +# Step through test +npx playwright test --headed --slow-mo=1000 +``` + +### Trace Viewer + +```typescript +// Generate trace +test('with trace', async ({ page }) => { + await page.context().tracing.start({ screenshots: true, snapshots: true }); + + // Test actions + await page.goto('/'); + + await page.context().tracing.stop({ path: 'trace.zip' }); +}); + +// View: npx playwright show-trace trace.zip +``` + +### Console Logs + +```typescript +test('capture console', async ({ page }) => { + page.on('console', msg => console.log(`Browser: ${msg.text()}`)); + page.on('pageerror', error => console.error(`Error: ${error.message}`)); + + await page.goto('/'); +}); +``` + +## Best Practices + +### 1. Use Stable Locators +```typescript +// ✅ Good - Role-based, stable +await page.getByRole('button', { name: 'Submit' }).click(); +await page.getByLabel('Email').fill('test@example.com'); + +// ❌ Bad - Fragile, implementation-dependent +await page.click('button.btn-primary.submit-btn'); +await page.fill('div > form > input:nth-child(3)'); +``` + +### 2. Leverage Auto-Waiting +```typescript +// ✅ Good - Auto-waits +await page.click('button'); +await expect(page.locator('.result')).toBeVisible(); + +// ❌ Bad - Manual waits +await page.waitForTimeout(2000); +await page.click('button'); +``` + +### 3. Use Page Object Model +```typescript +// ✅ Good - Reusable, maintainable +const loginPage = new LoginPage(page); +await loginPage.login('user', 'pass'); + +// ❌ Bad - Duplicated selectors +await page.fill('[name="username"]', 'user'); +await page.fill('[name="password"]', 'pass'); +``` + +### 4. Parallel-Safe Tests +```typescript +// ✅ Good - Isolated +test('user signup', async ({ page }) => { + const uniqueEmail = `user-${Date.now()}@test.com`; + await signUp(page, uniqueEmail); +}); + +// ❌ Bad - Shared state +test('user signup', async ({ page }) => { + await signUp(page, 'test@test.com'); // Conflicts in parallel +}); +``` + +### 5. Handle Flakiness +```typescript +// ✅ Good - Wait for network idle +await page.goto('/', { waitUntil: 'networkidle' }); +await expect(page.locator('.data')).toBeVisible(); + +// Configure retries +test.describe(() => { + test.use({ retries: 2 }); + + test('flaky test', async ({ page }) => { + // Test with auto-retry + }); +}); +``` + +## Common Patterns + +### Multi-Page Scenarios + +```typescript +test('popup handling', async ({ page, context }) => { + // Listen for new page + const popupPromise = context.waitForEvent('page'); + await page.click('a[target="_blank"]'); + const popup = await popupPromise; + + await popup.waitForLoadState(); + await expect(popup).toHaveTitle('New Window'); + await popup.close(); +}); +``` + +### Conditional Logic + +```typescript +test('handle optional elements', async ({ page }) => { + await page.goto('/'); + + // Close modal if present + const modal = page.locator('.modal'); + if (await modal.isVisible()) { + await page.click('.modal .close-button'); + } + + // Or use count + const cookieBanner = page.locator('.cookie-banner'); + if ((await cookieBanner.count()) > 0) { + await page.click('.accept-cookies'); + } +}); +``` + +### Data-Driven Tests + +```typescript +const testCases = [ + { input: 'hello', expected: 'HELLO' }, + { input: 'World', expected: 'WORLD' }, + { input: '123', expected: '123' }, +]; + +for (const { input, expected } of testCases) { + test(`transforms "${input}" to "${expected}"`, async ({ page }) => { + await page.goto('/transform'); + await page.fill('input', input); + await page.click('button'); + await expect(page.locator('.result')).toHaveText(expected); + }); +} +``` + +## Resources + +- [Playwright Documentation](https://playwright.dev/) +- [Best Practices Guide](https://playwright.dev/docs/best-practices) +- [API Reference](https://playwright.dev/docs/api/class-playwright) +- [GitHub Examples](https://github.com/microsoft/playwright/tree/main/examples) diff --git a/apps/extension/.claude/settings.json b/apps/extension/.claude/settings.json new file mode 100644 index 000000000..3a7994574 --- /dev/null +++ b/apps/extension/.claude/settings.json @@ -0,0 +1,29 @@ +{ + "permissions": { + "defaultMode": "auto", + "allow": [ + "WebFetch(domain:davidwalsh.name)", + "Bash(curl -s http://localhost:3000/.account/login/)", + "Bash(npx @solid/community-server -p 3000 --seedConfig e2e/setup/seed.json -l warn)", + "Bash(curl -s http://localhost:3000/)", + "Bash(curl -s -H \"Accept: text/html\" http://localhost:3000/.account/login/)", + "Bash(curl -s -H \"Accept: text/html\" http://localhost:3000/.account/login/password/)", + "Bash(curl -s http://localhost:3000/.well-known/openid-configuration)", + "Bash(python3 -m json.tool)", + "Bash(npx tsx:*)", + "Bash(curl -s -X POST http://localhost:3000/.oidc/reg -H 'Content-Type: application/json' -d '{:*)", + "Bash(curl -s -H \"Accept: text/html\" \"http://localhost:3000/.oidc/auth?response_type=code&client_id=test&redirect_uri=http://localhost:8080/callback&scope=openid+webid&state=test\")", + "Bash(curl -s -H \"Accept: text/html\" \"http://localhost:3000/.account/oidc/consent/\")", + "Bash(curl -s -H \"Accept: text/turtle\" http://localhost:3000/test-pod/profile/card)" + ] + }, + "mcpServers": { + "context7": { + "command": "npx", + "args": [ + "-y", + "@upstash/context7-mcp@latest" + ] + } + } +} diff --git a/apps/extension/.claude/skills/data-modelling.md b/apps/extension/.claude/skills/data-modelling.md new file mode 100644 index 000000000..47416c367 --- /dev/null +++ b/apps/extension/.claude/skills/data-modelling.md @@ -0,0 +1,195 @@ +# Solid Data Modelling Skill + +You are an expert on data modelling for the Solid ecosystem. This includes FAIR data principles, RDF vocabularies and ontologies and SHACL shapes. + +--- + +## FAIR Data Principles in Solid + +FAIR Data Principles (*Findable, Accessible, Interoperable, Reusable*) describe guidelines for improving data usability in open and distributed systems. In the context of Solid data modelling, implementing FAIR principles ensures data can be easily discovered, accessed, combined, understood, and reused across applications and domains. + +| Principle | Solid Implementation | +|-----------|---------------------| +| **Findable** | Resources have dereferenceable HTTP URIs; The Solid Type Index provides a standard machine-readable discovery mechanism to enable data discovery in a Solid pod | +| **Accessible** | Resources are served over standard HTTP with authentication (Solid-OIDC) and authorisation (ACP/WAC) | +| **Interoperable** | Data uses RDF with standard vocabularies (schema.org, FOAF, LDP, etc.) | +| **Reusable** | Shapes (SHACL) define reusable data schemas; linked data enables cross-app data reuse | + +### Applying FAIR in Practice + +1. **Mint stable URIs** — use consistent URI patterns for resources +2. **Use standard vocabularies** — reuse standard vocabularies e.g. schema.org, FOAF, Dublin Core, SKOS where possible +3. **Describe your data in machine-readable format** — add RDF type statements and metadata to every resource +4. **Publish shapes** — provide SHACL shapes so consuming apps can validate the data +5. **Link datasets** — use `rdfs:seeAlso`, and `skos:exactMatch` to connect related data + +--- + +## RDF Vocabulary Selection + +### Core Vocabularies for Solid + +| Prefix | Vocabulary | Description | Namespace URL | +| -------------------- | ------------------ | -------------------------------------------- | ------------------------------------------------------------------------------------------ | +| ACP | `acp:` | Access Control Policy Language | [http://www.w3.org/ns/solid/acp#](http://www.w3.org/ns/solid/acp#) | +| AS (ActivityStreams) | `as:` | Social activities, notifications | [https://www.w3.org/ns/activitystreams#](https://www.w3.org/ns/activitystreams#) | +| DC | `dc:` / `dcterms:` | Metadata (title, creator, date, description) | [http://purl.org/dc/terms/](http://purl.org/dc/terms/) | +| FOAF | `foaf:` | Person, identity, social graph | [http://xmlns.com/foaf/0.1/](http://xmlns.com/foaf/0.1/) | +| ICAL | `ical:` | Calendar data | [http://www.w3.org/2002/12/cal/ical#](http://www.w3.org/2002/12/cal/ical#) | +| LDP | `ldp:` | Containers, membership | [http://www.w3.org/ns/ldp#](http://www.w3.org/ns/ldp#) | +| ORG | `org:` | Organization ontology | [http://www.w3.org/ns/org#](http://www.w3.org/ns/org#) | +| OWL | `owl:` | Ontology relations, sameAs | [http://www.w3.org/2002/07/owl#](http://www.w3.org/2002/07/owl#) | +| PIM | `pim:` | Storage, preferences, configuration | [http://www.w3.org/ns/pim/space#](http://www.w3.org/ns/pim/space#) | +| POSIX | `posix:` | POSIX file system permissions | [http://www.w3.org/ns/posix/stat#](http://www.w3.org/ns/posix/stat#) | +| RDF | `rdf:` | RDF syntax concepts | [http://www.w3.org/1999/02/22-rdf-syntax-ns#](http://www.w3.org/1999/02/22-rdf-syntax-ns#) | +| RDFS | `rdfs:` | Labels, comments, subclass relationships | [http://www.w3.org/2000/01/rdf-schema#](http://www.w3.org/2000/01/rdf-schema#) | +| Schema.org | `schema:` | Structured data vocabulary | [https://schema.org/](https://schema.org/) | +| Solid | `solid:` | OIDC, notifications, identity | [https://www.w3.org/ns/solid/](https://www.w3.org/ns/solid/) | +| vCard | `vcard:` | Contact information | [http://www.w3.org/2006/vcard/ns#](http://www.w3.org/2006/vcard/ns#) | + + +### Vocabulary Lookup + +Before creating custom RDF classes and properties, search existing ontologies/vocabularies: + +- **Linked Open Vocabularies (LOV)**: https://lov.linkeddata.es/ +- **schema.org**: https://schema.org/ — widely adopted, search-engine friendly + +--- + +## Shapes: SHACL + +Shapes define the expected structure of RDF data. They serve as schemas for validation and code generation. + +### SHACL (Shapes Constraint Language) + +[SHACL](https://www.w3.org/TR/shacl/) is the W3C Recommendation for RDF validation, more expressive for complex constraints. + +```turtle +@prefix sh: . +@prefix foaf: . + + + a sh:NodeShape ; + sh:targetClass foaf:Person ; + sh:property [ + sh:path foaf:name ; + sh:datatype xsd:string ; + sh:minCount 1 ; + ] ; + sh:property [ + sh:path foaf:mbox ; + sh:nodeKind sh:IRI ; + sh:maxCount 1 ; + ] . +``` + +### SHACL Shape Discovery + + +A number of **public SHACL shapes collections and published shapes** are available that you can reuse or study: + +- **[Solid Shapes Catalogue](https://github.com/solid/shapes)** + Provides reusable SHACL shapes used in Solid ecosystem applications. + +- **[W3C Data Shapes Working Group Resources](https://github.com/w3c/data-shapes)** + Official working group repository containing specifications, examples, and discussion materials related to RDF data shapes. + +- **[Awesome Semantic Shapes Curated List](https://github.com/w3c-cg/awesome-semantic-shapes)** + Broad index of SHACL and ShEx shape resources across domains and vocabularies. + +- **Shapes-of-You Semantic Index** + A public index of semantic web resources that helps researchers discover linked data artefacts including SHACL shapes, ontologies, mappings, and SPARQL assets. + https://index.semanticscience.org/ + +- **[SHACL Play! Shapes Catalog (SKOS & vocab constraint shapes)](https://shacl-play.sparna.fr/play/shapes-catalog)** + Provides ready-to-use SKOS validation shapes implementing many SKOS integrity constraints. + +- **[SEMICeu DCAT-AP SHACL Shapes](https://github.com/SEMICeu/dcat-ap_shacl/blob/master/README.md)** + Repository containing SHACL shapes for the DCAT-AP data catalogue application profile. +--- + +## MCP Adapters for Ontology Discovery + +Model Context Protocol (MCP) adapters allow AI tools and development environments to discover and work with ontologies and shapes programmatically. + +### Discovering Ontologies via MCP + +When building an MCP adapter for ontology discovery, key capabilities to expose: + +1. **Search by term** — find classes and properties matching a keyword +2. **Shape retrieval** — fetch SHACL shapes for a known type +3. **Type index traversal** — discover where a user stores instances of a given type on their pod + +### Recommended Approach for Ontology Discovery in Apps + +``` +1. Check LOV (lov.linkeddata.es) for existing vocabulary terms +2. Use SHACL shapes (from Solid shapes repo) to validate data +3. For pod-specific discovery, traverse the pod's Type Index +``` + +--- + +### Structure + +```turtle +# Public type index at /settings/publicTypeIndex.ttl + +@prefix solid: . +@prefix schema: . + +<#registration-contacts> + a solid:TypeRegistration ; + solid:forClass schema:Person ; + solid:instanceContainer . + +<#registration-notes> + a solid:TypeRegistration ; + solid:forClass schema:TextObject ; + solid:instance . +``` + +### Two Registration Types + +| Property | Meaning | +|----------|---------| +| `solid:instanceContainer` | All instances of this type live in this container | +| `solid:instance` | A single document containing instances of this type | + +### Accessing the Solid Type Index + +1. Fetch the user's WebID profile +2. Find `solid:publicTypeIndex` and/or `solid:privateTypeIndex` predicates +3. Fetch the Solid Type Index document +4. Query for `solid:TypeRegistration` entries with matching `solid:forClass` + +--- + +## Data Modelling Checklist + +When modelling a new data type for Solid: + +- [ ] Check for existing SHACL shapes with types that meet your requirements +- [ ] Check LOV for an existing vocabulary/class +- [ ] Define a SHACL shape for the type +- [ ] Add `rdf:type` statements to all instances +- [ ] Register the type in the pod's Solid Type Index +- [ ] Use stable, dereferenceable URIs for subjects +- [ ] Add `dcterms:created`, `dcterms:modified` metadata +- [ ] Document the shape in a public shapes repository +- [ ] Validate data against the shape before writing to the pod + +--- + +## Resources + +| Resource | URL | +|----------|-----| +| FAIR Principles | https://www.go-fair.org/fair-principles/ | +| Linked Open Vocabularies | https://lov.linkeddata.es/ | +| W3C SHACL Spec | https://www.w3.org/TR/shacl/ | +| W3C Data Shapes repo | https://github.com/w3c/data-shapes | +| Solid type indexes | https://solid.github.io/type-indexes/ | +| Shapes resources | https://github.com/w3c-cg/awesome-semantic-shapes | +| Solid Developer Documentation | https://dev.solidproject.org/ | diff --git a/apps/extension/.claude/skills/integration-guide.md b/apps/extension/.claude/skills/integration-guide.md new file mode 100644 index 000000000..ed08d2436 --- /dev/null +++ b/apps/extension/.claude/skills/integration-guide.md @@ -0,0 +1,678 @@ +# Solid Integration Guide Skill + +You are an expert on integrating JavaScript/TypeScript applications with the Solid ecosystem. You know the Inrupt SDK, LDO, and solid-client-authn-js libraries in depth, and can guide developers through authentication, reading, writing, and managing access to Solid pods. + +Based application being built by user, recommend library to use and get confirmation from the user. + +--- + +## Library Overview + +| Library | Package | Best for | +|---------|---------|----------| +| @inrupt/solid-client | `@inrupt/solid-client` | Reading/writing structured RDF data (SolidDataset/Thing API) | +| solid-client-authn-browser | `@inrupt/solid-client-authn-browser` | Browser authentication (OIDC + DPoP) | +| solid-client-authn-node | `@inrupt/solid-client-authn-node` | Node.js / server-side authentication | +| LDO | `ldo` / `@ldo/solid` | TypeScript-first RDF with ShEx shapes; type-safe linked data | +| @ldo/connected-solid | `@ldo/connected-solid` | LDO + Solid pods: containers, WAC, live updates | +| N3.js | `n3` | Low-level RDF parsing, serialising, and in-memory quad storage | + +--- + +## Authentication with solid-client-authn-js + +**Docs**: https://inrupt.github.io/solid-client-authn-js/ +**Repo**: https://github.com/inrupt/solid-client-authn-js + +### Browser Authentication + +```bash +npm install @inrupt/solid-client-authn-browser +``` + +```typescript +import { + login, + logout, + handleIncomingRedirect, + getDefaultSession, + fetch, // authenticated fetch — use this for all pod requests +} from "@inrupt/solid-client-authn-browser"; + +// 1. Handle redirect on page load (restore session after OIDC redirect) +await handleIncomingRedirect({ restorePreviousSession: true }); + +// 2. Check if already logged in +const session = getDefaultSession(); +if (!session.info.isLoggedIn) { + // 3. Redirect user to their OIDC provider + await login({ + oidcIssuer: "https://solidcommunity.net", + redirectUrl: window.location.href, + clientName: "My Solid App", + }); +} + +// 4. Use the authenticated session's fetch for all pod requests +const response = await fetch("https://alice.solidcommunity.net/profile/card"); +``` + +### Node.js Authentication (Client Credentials) + +```bash +npm install @inrupt/solid-client-authn-node +``` + +```typescript +import { Session } from "@inrupt/solid-client-authn-node"; + +const session = new Session(); + +await session.login({ + oidcIssuer: "https://solidcommunity.net", + clientId: "your-client-id", + clientSecret: "your-client-secret", + clientName: "My Node App", +}); + +// Use session.fetch for authenticated requests +const response = await session.fetch("https://alice.solidcommunity.net/data/"); +``` + +### Key Rules + +- Always use the session's `fetch` (not global `fetch`) for requests to Solid pods +- Call `handleIncomingRedirect` on every page load to restore session after OIDC callback +- Store the `oidcIssuer` — prompt users to enter their pod provider if unknown +- Tokens are DPoP-bound; do not strip or modify Authorization headers + +--- + +## Reading and Writing with @inrupt/solid-client + +**Docs**: https://docs.inrupt.com/developer-tools/javascript/client-libraries/ +**Package**: `@inrupt/solid-client` + +```bash +npm install @inrupt/solid-client @inrupt/solid-client-authn-browser +``` + +### Core Concepts + +| Concept | Description | +|---------|-------------| +| `SolidDataset` | An RDF document fetched from a pod | +| `Thing` | A named set of RDF triples within a SolidDataset (identified by URL) | +| `saveSolidDatasetAt` | Write a modified SolidDataset back to the pod | +| `getThing` / `setThing` | Read/modify a Thing within a dataset | +| `buildThing` / `createThing` | Construct new Things | + +### Read a Resource + +```typescript +import { + getSolidDataset, + getThing, + getStringNoLocale, + getUrl, +} from "@inrupt/solid-client"; +import { fetch } from "@inrupt/solid-client-authn-browser"; +import { FOAF, SCHEMA_INRUPT } from "@inrupt/vocab-common-rdf"; + +const dataset = await getSolidDataset( + "https://alice.solidcommunity.net/profile/card", + { fetch } +); + +const profile = getThing( + dataset, + "https://alice.solidcommunity.net/profile/card#me" +); + +const name = getStringNoLocale(profile, FOAF.name); +const storage = getUrl(profile, "http://www.w3.org/ns/pim/space#storage"); +``` + +### Write a Resource + +```typescript +import { + getSolidDataset, + getThing, + setThing, + setStringNoLocale, + saveSolidDatasetAt, + buildThing, + createThing, +} from "@inrupt/solid-client"; +import { fetch } from "@inrupt/solid-client-authn-browser"; +import { SCHEMA_INRUPT } from "@inrupt/vocab-common-rdf"; + +// Fetch existing dataset +const dataset = await getSolidDataset( + "https://alice.solidcommunity.net/notes/note1.ttl", + { fetch } +); + +// Modify an existing Thing +let note = getThing( + dataset, + "https://alice.solidcommunity.net/notes/note1.ttl#note" +); +note = setStringNoLocale(note, SCHEMA_INRUPT.text, "Updated note content"); + +// Save back to the pod +const updatedDataset = setThing(dataset, note); +await saveSolidDatasetAt( + "https://alice.solidcommunity.net/notes/note1.ttl", + updatedDataset, + { fetch } +); +``` + +### Create a New Resource + +```typescript +import { + createSolidDataset, + setThing, + buildThing, + createThing, + saveSolidDatasetAt, +} from "@inrupt/solid-client"; +import { fetch } from "@inrupt/solid-client-authn-browser"; +import { RDF, SCHEMA_INRUPT } from "@inrupt/vocab-common-rdf"; + +const newNote = buildThing(createThing({ name: "my-note" })) + .addUrl(RDF.type, "https://schema.org/TextObject") + .addStringNoLocale(SCHEMA_INRUPT.text, "My first note") + .build(); + +const dataset = setThing(createSolidDataset(), newNote); + +// saveSolidDatasetAt creates intermediate containers automatically +await saveSolidDatasetAt( + "https://alice.solidcommunity.net/notes/my-note.ttl", + dataset, + { fetch } +); +``` + +### ACL Management + +```typescript +import { + getSolidDatasetWithAcl, + getResourceAcl, + setAgentResourceAccess, + saveAclFor, +} from "@inrupt/solid-client"; + +const datasetWithAcl = await getSolidDatasetWithAcl(resourceUrl, { fetch }); +const resourceAcl = getResourceAcl(datasetWithAcl); + +const updatedAcl = setAgentResourceAccess( + resourceAcl, + "https://bob.solidcommunity.net/profile/card#me", + { read: true, write: false, append: false, control: false } +); + +await saveAclFor(datasetWithAcl, updatedAcl, { fetch }); +``` + +--- + +## LDO (Linked Data Objects) + +**Docs**: https://ldo.js.org/ +**Package**: `ldo`, `@ldo/solid`, `@ldo/connected-solid` + +LDO provides TypeScript-typed access to RDF data using ShEx shapes. It generates TypeScript interfaces from shapes, so you work with familiar object literals instead of raw RDF APIs. + +### Install + +```bash +npm install ldo @ldo/solid +npm install --save-dev @ldo/cli +``` + +### Generate TypeScript from ShEx + +```bash +# Given a ShEx file at ./shapes/Person.shex +npx ldo generate ./shapes +``` + +This generates: +- TypeScript interfaces (`.typings.ts`) +- JSON-LD context (`.context.ts`) +- Shape type (`.shapeTypes.ts`) + +### Read RDF as TypeScript Object + +```typescript +import { parseRdf } from "ldo"; +import { PersonShapeType } from "./shapes/PersonShapeType"; + +const turtle = ` +@prefix foaf: . + a foaf:Person ; foaf:name "Alice" . +`; + +const dataset = await parseRdf(turtle, { baseIRI: "https://alice.example.com/" }); +const ldoDataset = createLdoDataset([...dataset]); + +const person = ldoDataset + .usingType(PersonShapeType) + .fromSubject("https://alice.example.com/#me"); + +console.log(person.name); // "Alice" +``` + +### Write via LDO + +```typescript +const [person, setThing] = createLdoDataset().usingType(PersonShapeType).write(); +person.["@id"] = "https://alice.example.com/#me"; +person.name = "Alice Updated"; + +// Serialize back to Turtle +const turtle = await serialize(dataset, { format: "text/turtle" }); +``` + +### @ldo/connected-solid — LDO + Solid Pods + +Combines LDO with Solid pods for type-safe, live pod interaction: + +```typescript +import { createSolidLdoDataset } from "@ldo/connected-solid"; + +const solidLdoDataset = createSolidLdoDataset(); +await solidLdoDataset.auth.login({ oidcIssuer: "https://solidcommunity.net" }); + +// Read a resource into the dataset +await solidLdoDataset.getResource("https://alice.solidcommunity.net/notes/note1.ttl").read(); + +// Access typed object +const note = solidLdoDataset + .usingType(NoteShapeType) + .fromSubject("https://alice.solidcommunity.net/notes/note1.ttl#note"); + +// Mutate and commit +note.text = "Updated via LDO"; +await solidLdoDataset.commitChanges(); +``` + +**Additional @ldo/connected-solid features:** +- Container listing and creation +- WAC access control management +- Live update subscriptions (WebSocket notifications) + +--- + +## N3.js — Low-Level RDF Parsing and Storage + +**Repo**: https://github.com/rdfjs/N3.js +**Docs**: https://rdf.js.org/N3.js/ +**Package**: `n3` + +N3.js is a low-level, spec-compliant RDF library for JavaScript. It handles parsing, serialising, and in-memory storage of RDF quads. It implements the [RDF/JS interfaces](https://rdf.js.org/) and is the foundation other libraries build on. + +Use N3.js when you need direct control over RDF data — raw PATCH body construction, custom parsers, graph reasoning, or working with formats the higher-level libraries don't expose. + +### Install + +```bash +npm install n3 +``` + +```typescript +import * as N3 from "n3"; +// or specific imports: +import { Parser, Writer, Store, DataFactory, StreamParser, StreamWriter } from "n3"; +const { namedNode, literal, defaultGraph, quad } = DataFactory; +``` + +### DataFactory — Constructing RDF Terms + +```typescript +import { DataFactory } from "n3"; +const { namedNode, literal, blankNode, defaultGraph, quad } = DataFactory; + +// Named node (IRI) +const alice = namedNode("https://alice.solidcommunity.net/profile/card#me"); + +// Literal with language tag +const name = literal("Alice", "en"); + +// Literal with datatype +import { xsd } from "@tpluscode/rdf-ns-builders"; +const dob = literal("1990-01-01", namedNode("http://www.w3.org/2001/XMLSchema#date")); + +// Build a quad (subject, predicate, object, graph) +const myQuad = quad( + alice, + namedNode("http://xmlns.com/foaf/0.1/name"), + name, + defaultGraph() +); + +console.log(myQuad.subject.value); // "https://alice.solidcommunity.net/profile/card#me" +``` + +### Parser — Turtle / N-Triples / TriG / N-Quads to Quads + +```typescript +import { Parser } from "n3"; + +const turtle = ` + @prefix foaf: . + foaf:name "Alice" . +`; + +// Callback-based (async, streaming-friendly) +const parser = new Parser({ baseIRI: "https://alice.example.com/" }); +parser.parse(turtle, (error, quad, prefixes) => { + if (quad) { + console.log(quad.subject.value, quad.predicate.value, quad.object.value); + } else { + console.log("Done. Prefixes:", prefixes); + } +}); + +// Synchronous (returns array) +const quads = new Parser().parse(turtle); +``` + +**Format options** (pass as `{ format: "..." }`): + +| String | Parses | +|--------|--------| +| `"Turtle"` (default) | Turtle + permissive superset | +| `"N-Triples"` | N-Triples | +| `"TriG"` | TriG (named graphs) | +| `"N-Quads"` | N-Quads | +| `"N3"` / `"text/n3"` | N3 (with rules) | + +### Writer — Quads to Turtle / N-Triples / TriG + +```typescript +import { Writer, DataFactory } from "n3"; +const { namedNode, literal } = DataFactory; + +// Write to string +const writer = new Writer({ + prefixes: { foaf: "http://xmlns.com/foaf/0.1/" }, + // format: "N-Triples" // override default Turtle +}); + +writer.addQuad( + namedNode("https://alice.example.com/#me"), + namedNode("http://xmlns.com/foaf/0.1/name"), + literal("Alice", "en") +); + +writer.end((error, result) => { + console.log(result); + // @prefix foaf: . + // foaf:name "Alice"@en. +}); +``` + +### Store — In-Memory Quad Index + +`N3.Store` is an efficient indexed in-memory store with quad-pattern lookups. + +```typescript +import { Store, Parser, DataFactory } from "n3"; +const { namedNode } = DataFactory; + +const store = new Store(); + +// Add quads directly +store.addQuad( + namedNode("https://alice.example.com/#me"), + namedNode("http://xmlns.com/foaf/0.1/name"), + DataFactory.literal("Alice") +); + +// Or parse directly into the store +const parser = new Parser(); +const quads = parser.parse(` + @prefix foaf: . + foaf:name "Alice" ; foaf:knows . +`); +store.addQuads(quads); + +// Query: getQuads(subject, predicate, object, graph) — null = wildcard +const names = store.getQuads( + namedNode("https://alice.example.com/#me"), + namedNode("http://xmlns.com/foaf/0.1/name"), + null, + null +); +console.log(names[0].object.value); // "Alice" + +// Count matching quads +const count = store.countQuads(null, namedNode("http://xmlns.com/foaf/0.1/knows"), null, null); + +// Iterate over unique subjects +store.forSubjects( + (subject) => console.log(subject.value), + namedNode("http://xmlns.com/foaf/0.1/knows"), + null, + null +); + +// Remove quads +store.removeQuads(names); +// or by pattern: +store.removeMatches(namedNode("https://alice.example.com/#me"), null, null, null); +``` + +### StreamParser and StreamWriter — Large Files + +```typescript +import { StreamParser, StreamWriter } from "n3"; +import { createReadStream, createWriteStream } from "fs"; + +// Parse a large Turtle file as a stream +const inputStream = createReadStream("data.ttl"); +const streamParser = new StreamParser({ format: "Turtle" }); + +inputStream.pipe(streamParser); + +streamParser.on("data", (quad) => { + console.log(quad.subject.value); +}); +streamParser.on("end", () => console.log("Done")); + +// Transform stream: re-serialise to N-Triples +const streamWriter = new StreamWriter({ format: "N-Triples" }); +inputStream + .pipe(new StreamParser()) + .pipe(streamWriter) + .pipe(createWriteStream("output.nt")); +``` + +### SPARQL Queries with Comunica + +N3.js does not include a SPARQL engine, but its `Store` implements the RDF/JS interfaces so it works directly with [Comunica](https://comunica.dev/). + +```bash +npm install @comunica/query-sparql-rdfjs +``` + +```typescript +import { QueryEngine } from "@comunica/query-sparql-rdfjs"; +import { Store, Parser } from "n3"; + +const store = new Store(); +store.addQuads(new Parser().parse(` + @prefix foaf: . + <#alice> foaf:name "Alice" ; foaf:knows <#bob> . + <#bob> foaf:name "Bob" . +`)); + +const engine = new QueryEngine(); +const bindingsStream = await engine.queryBindings( + "SELECT ?name WHERE { ?s ?name }", + { sources: [store] } +); + +bindingsStream.on("data", (bindings) => { + console.log(bindings.get("name").value); +}); +``` + +### N3.js in a Solid Context + +N3.js is useful when working directly with Solid at the HTTP level — parsing response bodies, constructing N3 PATCH payloads, or processing raw Turtle resources. + +```typescript +import { fetch } from "@inrupt/solid-client-authn-browser"; +import { Parser, Writer, Store, DataFactory } from "n3"; +const { namedNode, literal } = DataFactory; + +// Fetch and parse a Solid resource as raw RDF +const response = await fetch("https://alice.solidcommunity.net/profile/card", { + headers: { Accept: "text/turtle" }, +}); +const turtle = await response.text(); + +const store = new Store(); +store.addQuads(new Parser({ baseIRI: response.url }).parse(turtle)); + +// Query the profile +const names = store.getQuads( + null, + namedNode("http://xmlns.com/foaf/0.1/name"), + null, + null +); +console.log(names.map((q) => q.object.value)); + +// Build an N3 Patch body +const patchWriter = new Writer({ format: "text/n3" }); +patchWriter.addQuad( + namedNode(""), + namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + namedNode("http://www.w3.org/ns/solid/terms#InsertDeletePatch") +); +// ... add solid:inserts quads +patchWriter.end(async (err, patchBody) => { + await fetch("https://alice.solidcommunity.net/profile/card", { + method: "PATCH", + headers: { "Content-Type": "text/n3" }, + body: patchBody, + }); +}); +``` + +--- + +## Choosing the Right Library + +| Scenario | Recommended Library | +|----------|-------------------| +| Browser app, structured data | `@inrupt/solid-client` + `solid-client-authn-browser` | +| Node.js script or API | `@inrupt/solid-client` + `solid-client-authn-node` | +| TypeScript-first, shape-driven | `ldo` + `@ldo/solid` | +| React app with live updates | `@ldo/connected-solid` | +| Raw HTTP, no library overhead | Native `fetch` with `solid-client-authn` session fetch | +| Low-level RDF parsing / serialising | `n3` (N3.js) | +| In-memory graph querying | `n3` Store + Comunica | +| Custom PATCH body construction | `n3` Writer (`text/n3` format) | +| Stream processing large RDF files | `n3` StreamParser + StreamWriter | + +--- + +## Common Patterns + +### Check if User is Logged In + +```typescript +import { getDefaultSession } from "@inrupt/solid-client-authn-browser"; + +const { isLoggedIn, webId } = getDefaultSession().info; +``` + +### Discover User's Storage + +```typescript +import { getSolidDataset, getThing, getUrl } from "@inrupt/solid-client"; + +const profile = await getSolidDataset(webId, { fetch }); +const profileThing = getThing(profile, webId); +const storageUrl = getUrl(profileThing, "http://www.w3.org/ns/pim/space#storage"); +``` + +For robust pod root discovery (handles servers that don't advertise `pim:storage` on the profile), use the recursive `getPodRoot` pattern from [Bashlib util.ts L73-104](https://github.com/SolidLabResearch/Bashlib/blob/80de25cbb4b3ed057f95e25bc057f1be9b00cef3/src/utils/util.ts#L73-L104). It uses a three-stage approach: + +1. HEAD request — checks the `Link` header for a `pim:Storage` relation on the resource +2. Dataset query — fetches the resource and looks for a `pim:storage` triple +3. Recursive traversal — walks up the URL path one segment at a time, repeating steps 1-2 + +```typescript +async function getPodRoot(url: string, fetch: typeof globalThis.fetch): Promise { + // Stage 1: check Link header + const headRes = await fetch(url, { method: "HEAD" }); + const linkHeader = headRes.headers.get("Link") ?? ""; + if (linkHeader.includes("http://www.w3.org/ns/pim/space#Storage")) { + return url; + } + + // Stage 2: check dataset for pim:storage triple + try { + const dataset = await getSolidDataset(url, { fetch }); + const thing = getThing(dataset, url); + const storage = getUrl(thing, "http://www.w3.org/ns/pim/space#storage"); + if (storage) return storage; + } catch {} + + // Stage 3: recurse up the URL path + const parent = new URL(url); + const segments = parent.pathname.replace(/\/$/, "").split("/"); + if (segments.length <= 1) return null; + parent.pathname = segments.slice(0, -1).join("/") + "/"; + return getPodRoot(parent.toString(), fetch); +} +``` + +### List a Container + +```typescript +import { getSolidDataset, getContainedResourceUrlAll } from "@inrupt/solid-client"; + +const container = await getSolidDataset(containerUrl, { fetch }); +const resourceUrls = getContainedResourceUrlAll(container); +``` + +--- + +## Troubleshooting + +| Problem | Likely Cause | Fix | +|---------|-------------|-----| +| 401 Unauthorized | Not using session `fetch` | Import `fetch` from authn library; pass to all solid-client calls | +| 403 Forbidden | Insufficient ACL/ACP | Check access control on the resource | +| 409 Conflict on save | Outdated dataset / ETag mismatch | Re-fetch the dataset before saving | +| CORS error in browser | Server CORS config | Use a server that supports CORS or proxy in dev | +| Session lost on refresh | `handleIncomingRedirect` not called | Call with `restorePreviousSession: true` on every page load | +| DPoP error | Token not DPoP-bound | Always use the authn library's `fetch`; never strip headers | + +--- + +## Key Links + +| Resource | URL | +|----------|-----| +| Inrupt SDK docs | https://docs.inrupt.com/sdk/javascript-sdk | +| @inrupt/solid-client npm | https://www.npmjs.com/package/@inrupt/solid-client | +| solid-client-authn-js | https://inrupt.github.io/solid-client-authn-js/ | +| solid-client-authn-js repo | https://github.com/inrupt/solid-client-authn-js | +| LDO docs | https://ldo.js.org/1.0.0-alpha.X/ | +| @ldo/connected-solid | https://ldo.js.org/1.0.0-alpha.X/api/connected-solid/ | +| N3.js repo | https://github.com/rdfjs/N3.js | +| N3.js docs | https://rdf.js.org/N3.js/ | +| Comunica (SPARQL over N3 Store) | https://comunica.dev/docs/query/advanced/rdfjs_querying/ | diff --git a/apps/extension/.claude/skills/playwright-e2e-testing b/apps/extension/.claude/skills/playwright-e2e-testing new file mode 120000 index 000000000..af1fd9079 --- /dev/null +++ b/apps/extension/.claude/skills/playwright-e2e-testing @@ -0,0 +1 @@ +../../.agents/skills/playwright-e2e-testing \ No newline at end of file diff --git a/apps/extension/.claude/skills/playwright.md b/apps/extension/.claude/skills/playwright.md new file mode 100644 index 000000000..98c82145c --- /dev/null +++ b/apps/extension/.claude/skills/playwright.md @@ -0,0 +1,453 @@ +--- +name: playwright-skill +description: Complete browser automation with Playwright. Auto-detects dev servers, writes clean test scripts to /tmp. Test pages, fill forms, take screenshots, check responsive design, validate UX, test login flows, check links, automate any browser task. Use when user wants to test websites, automate browser interactions, validate web functionality, or perform any browser-based testing. +--- + +**IMPORTANT - Path Resolution:** +This skill can be installed in different locations (plugin system, manual installation, global, or project-specific). Before executing any commands, determine the skill directory based on where you loaded this SKILL.md file, and use that path in all commands below. Replace `$SKILL_DIR` with the actual discovered path. + +Common installation paths: + +- Plugin system: `~/.claude/plugins/marketplaces/playwright-skill/skills/playwright-skill` +- Manual global: `~/.claude/skills/playwright-skill` +- Project-specific: `/.claude/skills/playwright-skill` + +# Playwright Browser Automation + +General-purpose browser automation skill. I'll write custom Playwright code for any automation task you request and execute it via the universal executor. + +**CRITICAL WORKFLOW - Follow these steps in order:** + +1. **Auto-detect dev servers** - For localhost testing, ALWAYS run server detection FIRST: + + ```bash + cd $SKILL_DIR && node -e "require('./lib/helpers').detectDevServers().then(servers => console.log(JSON.stringify(servers)))" + ``` + + - If **1 server found**: Use it automatically, inform user + - If **multiple servers found**: Ask user which one to test + - If **no servers found**: Ask for URL or offer to help start dev server + +2. **Write scripts to /tmp** - NEVER write test files to skill directory; always use `/tmp/playwright-test-*.js` + +3. **Use visible browser by default** - Always use `headless: false` unless user specifically requests headless mode + +4. **Parameterize URLs** - Always make URLs configurable via environment variable or constant at top of script + +## How It Works + +1. You describe what you want to test/automate +2. I auto-detect running dev servers (or ask for URL if testing external site) +3. I write custom Playwright code in `/tmp/playwright-test-*.js` (won't clutter your project) +4. I execute it via: `cd $SKILL_DIR && node run.js /tmp/playwright-test-*.js` +5. Results displayed in real-time, browser window visible for debugging +6. Test files auto-cleaned from /tmp by your OS + +## Setup (First Time) + +```bash +cd $SKILL_DIR +npm run setup +``` + +This installs Playwright and Chromium browser. Only needed once. + +## Execution Pattern + +**Step 1: Detect dev servers (for localhost testing)** + +```bash +cd $SKILL_DIR && node -e "require('./lib/helpers').detectDevServers().then(s => console.log(JSON.stringify(s)))" +``` + +**Step 2: Write test script to /tmp with URL parameter** + +```javascript +// /tmp/playwright-test-page.js +const { chromium } = require('playwright'); + +// Parameterized URL (detected or user-provided) +const TARGET_URL = 'http://localhost:3001'; // <-- Auto-detected or from user + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + await page.goto(TARGET_URL); + console.log('Page loaded:', await page.title()); + + await page.screenshot({ path: '/tmp/screenshot.png', fullPage: true }); + console.log('📸 Screenshot saved to /tmp/screenshot.png'); + + await browser.close(); +})(); +``` + +**Step 3: Execute from skill directory** + +```bash +cd $SKILL_DIR && node run.js /tmp/playwright-test-page.js +``` + +## Common Patterns + +### Test a Page (Multiple Viewports) + +```javascript +// /tmp/playwright-test-responsive.js +const { chromium } = require('playwright'); + +const TARGET_URL = 'http://localhost:3001'; // Auto-detected + +(async () => { + const browser = await chromium.launch({ headless: false, slowMo: 100 }); + const page = await browser.newPage(); + + // Desktop test + await page.setViewportSize({ width: 1920, height: 1080 }); + await page.goto(TARGET_URL); + console.log('Desktop - Title:', await page.title()); + await page.screenshot({ path: '/tmp/desktop.png', fullPage: true }); + + // Mobile test + await page.setViewportSize({ width: 375, height: 667 }); + await page.screenshot({ path: '/tmp/mobile.png', fullPage: true }); + + await browser.close(); +})(); +``` + +### Test Login Flow + +```javascript +// /tmp/playwright-test-login.js +const { chromium } = require('playwright'); + +const TARGET_URL = 'http://localhost:3001'; // Auto-detected + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + await page.goto(`${TARGET_URL}/login`); + + await page.fill('input[name="email"]', 'test@example.com'); + await page.fill('input[name="password"]', 'password123'); + await page.click('button[type="submit"]'); + + // Wait for redirect + await page.waitForURL('**/dashboard'); + console.log('✅ Login successful, redirected to dashboard'); + + await browser.close(); +})(); +``` + +### Fill and Submit Form + +```javascript +// /tmp/playwright-test-form.js +const { chromium } = require('playwright'); + +const TARGET_URL = 'http://localhost:3001'; // Auto-detected + +(async () => { + const browser = await chromium.launch({ headless: false, slowMo: 50 }); + const page = await browser.newPage(); + + await page.goto(`${TARGET_URL}/contact`); + + await page.fill('input[name="name"]', 'John Doe'); + await page.fill('input[name="email"]', 'john@example.com'); + await page.fill('textarea[name="message"]', 'Test message'); + await page.click('button[type="submit"]'); + + // Verify submission + await page.waitForSelector('.success-message'); + console.log('✅ Form submitted successfully'); + + await browser.close(); +})(); +``` + +### Check for Broken Links + +```javascript +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + await page.goto('http://localhost:3000'); + + const links = await page.locator('a[href^="http"]').all(); + const results = { working: 0, broken: [] }; + + for (const link of links) { + const href = await link.getAttribute('href'); + try { + const response = await page.request.head(href); + if (response.ok()) { + results.working++; + } else { + results.broken.push({ url: href, status: response.status() }); + } + } catch (e) { + results.broken.push({ url: href, error: e.message }); + } + } + + console.log(`✅ Working links: ${results.working}`); + console.log(`❌ Broken links:`, results.broken); + + await browser.close(); +})(); +``` + +### Take Screenshot with Error Handling + +```javascript +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + try { + await page.goto('http://localhost:3000', { + waitUntil: 'networkidle', + timeout: 10000, + }); + + await page.screenshot({ + path: '/tmp/screenshot.png', + fullPage: true, + }); + + console.log('📸 Screenshot saved to /tmp/screenshot.png'); + } catch (error) { + console.error('❌ Error:', error.message); + } finally { + await browser.close(); + } +})(); +``` + +### Test Responsive Design + +```javascript +// /tmp/playwright-test-responsive-full.js +const { chromium } = require('playwright'); + +const TARGET_URL = 'http://localhost:3001'; // Auto-detected + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + const viewports = [ + { name: 'Desktop', width: 1920, height: 1080 }, + { name: 'Tablet', width: 768, height: 1024 }, + { name: 'Mobile', width: 375, height: 667 }, + ]; + + for (const viewport of viewports) { + console.log( + `Testing ${viewport.name} (${viewport.width}x${viewport.height})`, + ); + + await page.setViewportSize({ + width: viewport.width, + height: viewport.height, + }); + + await page.goto(TARGET_URL); + await page.waitForTimeout(1000); + + await page.screenshot({ + path: `/tmp/${viewport.name.toLowerCase()}.png`, + fullPage: true, + }); + } + + console.log('✅ All viewports tested'); + await browser.close(); +})(); +``` + +## Inline Execution (Simple Tasks) + +For quick one-off tasks, you can execute code inline without creating files: + +```bash +# Take a quick screenshot +cd $SKILL_DIR && node run.js " +const browser = await chromium.launch({ headless: false }); +const page = await browser.newPage(); +await page.goto('http://localhost:3001'); +await page.screenshot({ path: '/tmp/quick-screenshot.png', fullPage: true }); +console.log('Screenshot saved'); +await browser.close(); +" +``` + +**When to use inline vs files:** + +- **Inline**: Quick one-off tasks (screenshot, check if element exists, get page title) +- **Files**: Complex tests, responsive design checks, anything user might want to re-run + +## Available Helpers + +Optional utility functions in `lib/helpers.js`: + +```javascript +const helpers = require('./lib/helpers'); + +// Detect running dev servers (CRITICAL - use this first!) +const servers = await helpers.detectDevServers(); +console.log('Found servers:', servers); + +// Safe click with retry +await helpers.safeClick(page, 'button.submit', { retries: 3 }); + +// Safe type with clear +await helpers.safeType(page, '#username', 'testuser'); + +// Take timestamped screenshot +await helpers.takeScreenshot(page, 'test-result'); + +// Handle cookie banners +await helpers.handleCookieBanner(page); + +// Extract table data +const data = await helpers.extractTableData(page, 'table.results'); +``` + +See `lib/helpers.js` for full list. + +## Custom HTTP Headers + +Configure custom headers for all HTTP requests via environment variables. Useful for: + +- Identifying automated traffic to your backend +- Getting LLM-optimized responses (e.g., plain text errors instead of styled HTML) +- Adding authentication tokens globally + +### Configuration + +**Single header (common case):** + +```bash +PW_HEADER_NAME=X-Automated-By PW_HEADER_VALUE=playwright-skill \ + cd $SKILL_DIR && node run.js /tmp/my-script.js +``` + +**Multiple headers (JSON format):** + +```bash +PW_EXTRA_HEADERS='{"X-Automated-By":"playwright-skill","X-Debug":"true"}' \ + cd $SKILL_DIR && node run.js /tmp/my-script.js +``` + +### How It Works + +Headers are automatically applied when using `helpers.createContext()`: + +```javascript +const context = await helpers.createContext(browser); +const page = await context.newPage(); +// All requests from this page include your custom headers +``` + +For scripts using raw Playwright API, use the injected `getContextOptionsWithHeaders()`: + +```javascript +const context = await browser.newContext( + getContextOptionsWithHeaders({ viewport: { width: 1920, height: 1080 } }), +); +``` + +## Advanced Usage + +For comprehensive Playwright API documentation, see [API_REFERENCE.md](API_REFERENCE.md): + +- Selectors & Locators best practices +- Network interception & API mocking +- Authentication & session management +- Visual regression testing +- Mobile device emulation +- Performance testing +- Debugging techniques +- CI/CD integration + +## Tips + +- **CRITICAL: Detect servers FIRST** - Always run `detectDevServers()` before writing test code for localhost testing +- **Custom headers** - Use `PW_HEADER_NAME`/`PW_HEADER_VALUE` env vars to identify automated traffic to your backend +- **Use /tmp for test files** - Write to `/tmp/playwright-test-*.js`, never to skill directory or user's project +- **Parameterize URLs** - Put detected/provided URL in a `TARGET_URL` constant at the top of every script +- **DEFAULT: Visible browser** - Always use `headless: false` unless user explicitly asks for headless mode +- **Headless mode** - Only use `headless: true` when user specifically requests "headless" or "background" execution +- **Slow down:** Use `slowMo: 100` to make actions visible and easier to follow +- **Wait strategies:** Use `waitForURL`, `waitForSelector`, `waitForLoadState` instead of fixed timeouts +- **Error handling:** Always use try-catch for robust automation +- **Console output:** Use `console.log()` to track progress and show what's happening + +## Troubleshooting + +**Playwright not installed:** + +```bash +cd $SKILL_DIR && npm run setup +``` + +**Module not found:** +Ensure running from skill directory via `run.js` wrapper + +**Browser doesn't open:** +Check `headless: false` and ensure display available + +**Element not found:** +Add wait: `await page.waitForSelector('.element', { timeout: 10000 })` + +## Example Usage + +``` +User: "Test if the marketing page looks good" + +Claude: I'll test the marketing page across multiple viewports. Let me first detect running servers... +[Runs: detectDevServers()] +[Output: Found server on port 3001] +I found your dev server running on http://localhost:3001 + +[Writes custom automation script to /tmp/playwright-test-marketing.js with URL parameterized] +[Runs: cd $SKILL_DIR && node run.js /tmp/playwright-test-marketing.js] +[Shows results with screenshots from /tmp/] +``` + +``` +User: "Check if login redirects correctly" + +Claude: I'll test the login flow. First, let me check for running servers... +[Runs: detectDevServers()] +[Output: Found servers on ports 3000 and 3001] +I found 2 dev servers. Which one should I test? +- http://localhost:3000 +- http://localhost:3001 + +User: "Use 3001" + +[Writes login automation to /tmp/playwright-test-login.js] +[Runs: cd $SKILL_DIR && node run.js /tmp/playwright-test-login.js] +[Reports: ✅ Login successful, redirected to /dashboard] +``` + +## Notes + +- Each automation is custom-written for your specific request +- Not limited to pre-built scripts - any browser task possible +- Auto-detects running dev servers to eliminate hardcoded URLs +- Test scripts written to `/tmp` for automatic cleanup (no clutter) +- Code executes reliably with proper module resolution via `run.js` +- Progressive disclosure - API_REFERENCE.md loaded only when advanced features needed diff --git a/apps/extension/.claude/skills/servers.md b/apps/extension/.claude/skills/servers.md new file mode 100644 index 000000000..8a48e456a --- /dev/null +++ b/apps/extension/.claude/skills/servers.md @@ -0,0 +1,221 @@ +# Solid Servers Skill + +You are an expert on Solid server implementations — both public hosted servers and local development setups. Use this knowledge to help developers choose, configure, run, and debug Solid servers. + +--- + +## Public Solid Servers + +| Server | URL | Notes | +|--------|-----|-------| +| solidcommunity.net | https://solidcommunity.net | Powered by Pivot CSS; widely used community server | +| solidweb.org | https://solidweb.org | Community-hosted | +| start.inrupt.com | https://start.inrupt.com | Inrupt's hosted pod service (ESS) | + +### Choosing a Public Server or allow interoperability for all servers. Ask the user + +- **solidcommunity.net** — best for open community apps; uses older DPoP-based OIDC (Pivot flavour) +- **solidweb.org** — alternative community option +- **start.inrupt.com (ESS)** — enterprise-grade, uses Inrupt's Enterprise Solid Server; best compatibility with Inrupt SDK + +--- + +## Community Solid Server (CSS) + +**Repo**: https://github.com/CommunitySolidServer/CommunitySolidServer +**Docs**: https://communitysolidserver.github.io/CommunitySolidServer/latest/ + +The reference open-source implementation of the Solid specification. Highly modular and configurable. + +### Requirements + +- Node.js 18+ +- npm or npx + +### Quick Start (in-memory, ephemeral) + +```bash +npx @solid/community-server +``` + +Server available at http://localhost:3000/ + +### Persistent File-Based Storage + +```bash +npx @solid/community-server -c @css:config/file.json -f ./data +``` + +Data stored in `./data/` directory. + +### Global Install + +```bash +npm install -g @solid/community-server +community-solid-server -c @css:config/file.json -f ./data +``` + +### Key CLI Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--port, -p` | `3000` | TCP port to listen on | +| `--baseUrl, -b` | `http://localhost:3000/` | Base URL used in generated resource URIs | +| `--config, -c` | `@css:config/default.json` | Configuration preset | +| `--rootFilePath, -f` | `./` | Directory for file-based storage | +| `--loggingLevel, -l` | `info` | Log level (`debug`, `info`, `warn`, `error`) | +| `--workers, -w` | `1` | Number of worker threads | +| `--sparqlEndpoint` | — | SPARQL endpoint URL for SPARQL-backed storage | + +### Environment Variables + +All flags can be set as environment variables using the `CSS_` prefix: + +```bash +CSS_PORT=4000 +CSS_LOGGING_LEVEL=debug +CSS_ROOT_FILE_PATH=/var/solid/data +``` + +### Storage Backends + +| Config | Backend | Use case | +|--------|---------|---------| +| `@css:config/default.json` | In-memory | Quick testing, no persistence | +| `@css:config/file.json` | File system | Local dev with persistence | +| `--sparqlEndpoint` | SPARQL | Production or graph DB backends | + +### Docker + +```bash +# Run with file-based storage mounted at ~/Solid +docker run --rm \ + -v ~/Solid:/data \ + -p 3000:3000 \ + solidproject/community-server:latest \ + -c @css:config/file.json -f /data +``` + +### Kubernetes + +Use the official Helm chart: https://github.com/CommunitySolidServer/css-helm-chart + +### Build from Source + +```bash +git clone https://github.com/CommunitySolidServer/CommunitySolidServer +cd CommunitySolidServer +npm ci +npm start +``` + +### Access Control + +CSS supports both **WAC** (Web Access Control) and **ACP** (Access Control Policy) depending on configuration. Default config uses WAC. + +--- + +## Pivot CSS + +**Repo**: https://github.com/solid-contrib/pivot +**Used by**: solidcommunity.net + +Pivot is a spec-compliant Solid server built as a thin wrapper/remix of CSS. It takes a different philosophy on consent and access control. + +### Key Differences from CSS + +| Aspect | CSS | Pivot | +|--------|-----|-------| +| OIDC version | Latest Solid-OIDC (UMA) | Older DPoP-based OIDC | +| Access control model | WAC (default) or ACP | User-centric, DPoP | +| Auth approach | Standard | App gets full pod access when authenticated | +| Focus | Reference implementation | Simplified consent management | + +### When to Use Pivot + +- Building apps that target **solidcommunity.net** users +- When you need the exact server behaviour of solidcommunity.net locally +- Testing compatibility with the older Solid-OIDC DPoP flow + +### Running Pivot Locally + +```bash +git clone https://github.com/solid-contrib/pivot +cd pivot +npm install +npm start +``` + +Default port: 3000 (same as CSS) + +--- + +## Local Development Tips + +### Running Two Servers Side-by-Side + +To test cross-server behaviour (e.g., Alice on CSS, Bob on Pivot): + +```bash +# CSS on port 3000 +npx @solid/community-server -p 3000 -c @css:config/file.json -f ./data-css + +# Pivot on port 3001 +cd pivot && PORT=3001 npm start +``` + +### Setting baseUrl for Deployed Environments + +When deploying behind a reverse proxy (Nginx, Caddy): + +```bash +npx @solid/community-server \ + -b https://solid.example.com/ \ + -c @css:config/file.json \ + -f /var/solid/data +``` + +The `--baseUrl` must match the public-facing URL exactly — resources are minted using this value. + +### Debugging + +```bash +npx @solid/community-server -l debug +``` + +Or via environment variable: + +```bash +CSS_LOGGING_LEVEL=debug npx @solid/community-server +``` + +### Creating a Test Account (CSS) + +Navigate to http://localhost:3000/ — CSS provides a UI for pod registration when running locally. + +--- + +## Conformance Testing + +Use the Solid conformance test harness to verify your server implementation: + +- Repo: https://github.com/solid-contrib/conformance-test-harness +- Inrupt test suite info: https://www.inrupt.com/blog/conformance-test-suite + +```bash +git clone https://github.com/solid-contrib/conformance-test-harness +cd conformance-test-harness +# See repo README for configuration and execution steps +``` + +--- + +## Quick Reference + +| Task | Command | +|------|---------| +| Start CSS (ephemeral) | `npx @solid/community-server` | +| Start CSS (persistent) | `npx @solid/community-server -c @css:config/file.json -f ./data` | +| Start CSS (custom port) | `npx @solid/community-server -p 4000` | +| Start CSS (Docker) | `docker run --rm -v ~/Solid:/data -p 3000:3000 solidproject/community-server:latest -c @css:config/file.json -f /data` | +| Debug logging | `npx @solid/community-server -l debug` | diff --git a/apps/extension/.claude/skills/spec.md b/apps/extension/.claude/skills/spec.md new file mode 100644 index 000000000..67234bd15 --- /dev/null +++ b/apps/extension/.claude/skills/spec.md @@ -0,0 +1,315 @@ +# Solid Specifications Skill + +You are an expert on the Solid Protocol and its companion specifications. Use this knowledge to help developers understand, implement, and debug Solid-compliant applications. + +## Core Specifications + +| Spec | Version | URL | Purpose | +|------|---------|-----|---------| +| Solid Protocol | v0.11.0 | https://solidproject.org/TR/protocol | Core data pod and resource access rules | +| WebID Profile | v1.0.0 | https://solid.github.io/webid-profile/ | Identity and agent description | +| Solid-OIDC | v0.1.0 | https://solidproject.org/TR/oidc | Decentralised authentication | +| Access Control Policy (ACP) | v0.9.0 | https://solidproject.org/TR/acp | Fine-grained authorisation | + +--- + +## 1. Solid Protocol + +**What it is**: The foundation spec defining how data pods expose resources over HTTP using Linked Data principles. + +### Key Concepts + +**Storage**: A space of URIs giving agents controlled access to resources. Each storage has a root container. + +**Resources and Containers**: Resources live at HTTP URIs. Container URIs end with `/`. Containers hold other resources and containers, forming a hierarchy. + +**WebID**: An HTTP URI that identifies an agent. Dereferencing it returns an RDF document describing them. + +### HTTP Behaviour + +- Servers MUST support GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE +- All write requests MUST include `Content-Type`; missing headers → 400 +- Servers MUST support Turtle (`text/turtle`) and JSON-LD (`application/ld+json`) +- HTTPS is required; HTTP → HTTPS redirect if both schemes are exposed + +### Content Negotiation + +``` +GET /profile/card HTTP/1.1 +Accept: text/turtle +``` + +Servers must honour `Accept` headers and respond with matching RDF serialisation. + +### PATCH with N3 + +``` +PATCH /data/notes.ttl HTTP/1.1 +Content-Type: text/n3 + +@prefix solid: . +<> a solid:InsertDeletePatch; + solid:inserts { <#note1> "Hello world" . }. +``` + +- 409 Conflict if conditions produce zero or multiple matches. + +### Auxiliary Resources + +- **ACL resources**: control access (`Link: ; rel="acl"`) +- **Description resources**: metadata about a resource (`Link: ; rel="describedby"`) +- Auxiliary resources are deleted automatically when their parent is deleted. + +### Notifications + +The Solid Notifications Protocol allows clients to subscribe to resource changes. Servers act as Resource Server, Subscription Server, Notification Sender, and Notification Receiver. + +### Common Mistakes + +- Forgetting trailing `/` on container URIs +- Not sending `Content-Type` on PATCH/PUT +- Using WAC and ACP interchangeably — servers implement one or both, check which +- Assuming CORS is sufficient for access control — always use ACL/ACP + +--- + +## 2. WebID Profile + +**What it is**: An RDF document at a WebID URI describing a social agent's identity, storage locations, and preferences. + +### Minimum Required Triples + +```turtle +@prefix foaf: . +@prefix pim: . + + + a foaf:Agent ; + pim:preferencesFile ; + pim:storage . +``` + +### Key Predicates + +| Predicate | Required | Description | +|-----------|----------|-------------| +| `rdf:type foaf:Agent` | Yes | Identifies the subject as an agent | +| `pim:preferencesFile` | Yes | Pointer to private preferences document | +| `pim:storage` | Recommended | Location of the agent's pod storage | +| `solid:oidcIssuer` | Recommended | The OIDC Identity Provider for this WebID | +| `ldp:inbox` | Optional | Inbox for incoming notifications | +| `rdfs:seeAlso` | Optional | Links to extended profile documents | + +### Preferences Document + +- Must be typed as `pim:ConfigurationFile` +- Private (not publicly readable) +- Holds app settings and personalisation data + +### Type Index + +- Public and private type index documents link resource types to storage locations +- Discovered via the WebID profile +- Enables apps to find where a user stores specific types of data + +### Modifying a Profile + +Use PATCH (N3 Patch) for targeted updates. Servers protect `solid:oidcIssuer` — attempting to change it returns 409. + +```turtle +# Read profile +GET /profile/card HTTP/1.1 +Accept: text/turtle + +# Patch profile to add name +PATCH /profile/card HTTP/1.1 +Content-Type: text/n3 + +@prefix solid: . +@prefix foaf: . +<> a solid:InsertDeletePatch; + solid:inserts { <#me> foaf:name "Alice" . }. +``` + +--- + +## 3. Solid-OIDC + +**What it is**: An authentication layer built on OAuth 2.0 + OpenID Connect that works without pre-established trust between resource servers and identity providers. + +### Flow Overview + +1. App redirects user to their OIDC Identity Provider (IdP) +2. IdP issues a DPoP-bound ID Token containing the `webid` claim +3. App presents the ID Token + DPoP proof to the Resource Server +4. Resource Server validates the token by checking the WebID profile for `solid:oidcIssuer` + +### Authorization Code Flow with PKCE + +This is the **mandatory** flow. Never use Implicit Flow. + +``` +1. App generates code_verifier + code_challenge (PKCE) +2. Redirect → IdP /authorize?response_type=code&code_challenge=... +3. IdP issues auth code +4. App exchanges code for tokens at /token endpoint +5. App receives id_token + access_token (DPoP-bound) +``` + +### DPoP-Bound ID Token Claims + +```json +{ + "webid": "https://alice.solidcommunity.net/profile/card#me", + "iss": "https://solidcommunity.net", + "aud": ["https://myapp.example", "solid"], + "cnf": { "jkt": "" }, + "exp": 1700000000, + "iat": 1699996400 +} +``` + +### DPoP Proof Header + +Every resource request must include a DPoP proof JWT: + +``` +Authorization: DPoP +DPoP: +``` + +### Client Identification + +Two options: +1. **Dereferenceable URI** — client ID is a URL that returns a JSON-LD client registration document +2. **Traditional OIDC registration** — dynamic or static client registration (optional in decentralised context) + +### WebID Issuer Validation + +Resource servers must verify that the `iss` claim in the ID Token matches a `solid:oidcIssuer` triple in the user's WebID Profile. This prevents token misuse. + +### Common Mistakes + +- Using Implicit Flow (not supported) +- Forgetting to include `"solid"` in the `aud` claim +- Not binding tokens with DPoP +- Checking the wrong WebID for issuer validation + +--- + +## 4. Access Control Policy (ACP) + +**What it is**: An RDF-based language for defining who can access what on a Solid pod, using a deny-overrides policy model. + +### Core Objects + +``` +Resource ──has ACR──▶ Access Control Resource (ACR) + │ + Access Control + │ + Policy + ┌────┴────┐ + Allow Deny + │ │ + Matcher Matcher +``` + +### Access Control Resource (ACR) + +Each resource has an associated ACR linked via: + +``` +Link: ; rel="http://www.w3.org/ns/solid/acp#accessControl" +``` + +### Matchers + +Matchers define conditions for a policy. They evaluate against the **request context** (agent, client, issuer, target resource). + +```turtle +@prefix acp: . + +<#matcher-alice> + a acp:Matcher ; + acp:agent . +``` + +Special agents: +- `acp:PublicAgent` — anyone (including unauthenticated) +- `acp:AuthenticatedAgent` — any authenticated user +- `acp:CreatorAgent` — whoever created the resource +- `acp:OwnerAgent` — the pod owner + +### Policies + +```turtle +<#policy-read> + a acp:Policy ; + acp:allow acp:Read ; + acp:anyOf <#matcher-alice> . +``` + +Policy satisfaction logic: + +| Condition | Meaning | +|-----------|---------| +| `acp:allOf` | ALL listed matchers must match | +| `acp:anyOf` | AT LEAST ONE listed matcher must match | +| `acp:noneOf` | NONE of the listed matchers must match | + +### Access Control (connecting ACR to Policy) + +```turtle +<#access-control> + a acp:AccessControl ; + acp:apply <#policy-read> . +``` + +### Grant Logic + +Access mode is granted **if and only if**: +- At least one satisfied policy **allows** it, AND +- No satisfied policy **denies** it + +**Deny overrides allow.** + +### Common Access Patterns + +**Public read access:** +```turtle +<#matcher-public> a acp:Matcher ; acp:agent acp:PublicAgent . +<#policy-public> a acp:Policy ; acp:allow acp:Read ; acp:anyOf <#matcher-public> . +``` + +**Authenticated write access:** +```turtle +<#matcher-auth> a acp:Matcher ; acp:agent acp:AuthenticatedAgent . +<#policy-write> a acp:Policy ; acp:allow acp:Write, acp:Append ; acp:anyOf <#matcher-auth> . +``` + +### ACP vs WAC + +| | ACP | WAC | +|-|-----|-----| +| Model | Policy + Matchers (RDF) | ACL rules (RDF) | +| Deny support | Yes (deny overrides) | Limited | +| Granularity | Fine-grained, composable | Basic | +| Spec status | Active development | Older, widely deployed | + +--- + +## Quick Reference: Spec URLs + +- Solid Protocol: https://solidproject.org/TR/protocol +- WebID Profile: https://solid.github.io/webid-profile/ +- Solid-OIDC: https://solidproject.org/TR/oidc +- ACP: https://solidproject.org/TR/acp +- Solid Notifications: https://solidproject.org/TR/notifications-protocol +- Web Access Control: https://solidproject.org/TR/wac + +--- + +## Other awesome skills + +For more Claude/LLM skills (document processing, dev tools, automation, MCP, etc.), see [Awesome Claude Skills](https://github.com/ComposioHQ/awesome-claude-skills). diff --git a/apps/extension/.claude/skills/style-guide.md b/apps/extension/.claude/skills/style-guide.md new file mode 100644 index 000000000..68796a4ea --- /dev/null +++ b/apps/extension/.claude/skills/style-guide.md @@ -0,0 +1,128 @@ +# Solid Style Guide Skill + +You are an expert on Solid Project brand and visual identity guidelines. Use this knowledge to help teams correctly represent the Solid brand in apps, communications, events, and assets. + +**Official guidelines**: https://solidproject.org/logo_usage_guidelines + +--- + +## Brand Colours + +### Primary Colour + +| Name | Hex | RGB | +|------|-----|-----| +| Royal Lavender | `#7C4DFF` | R124 G77 B255 | + +Royal Lavender is the core brand colour. It is used: +- As a background for the outlined Solid logo variant +- In mastheads and footers +- For primary CTA (call-to-action) buttons + +### Secondary Colours + +| Name | Hex | Notes | +|------|-----|-------| +| Deep Sky Blue | (part of gradient) | Used in the brand gradient | +| Cerulean Blue | (part of gradient) | Used in the brand gradient | + +### The Solid Gradient + +The gradient combines Royal Lavender, Deep Sky Blue, and Cerulean Blue in equal thirds (33/33/33). + +**Allowed uses:** +- As a background +- As an overlay on a greyscale image + +**Not allowed uses:** +- In the logo itself +- In typography +- In icons or other graphics + +--- + +## Logo + +### Two Official Variants + +1. **Standard logo** — works on any flat colour background or overlaid on an image +2. **Outlined logo** — to be used **only** on a Royal Lavender (`#7C4DFF`) background + +### Logo Usage Rules + +**Do:** +- Use the logo on clean, flat-colour backgrounds +- Use the outlined variant on Royal Lavender backgrounds only +- Maintain clear space (exclusion zone) around the logo +- Download official logo files from solidproject.org + +**Do not:** +- Use the logo on complex patterns +- Place the logo in close proximity to other logos, typography, or graphic elements +- Modify, recolour, rotate, or distort the logo +- Use the gradient in or behind the logo +- Use unofficial or recreated versions of the logo + +--- + +## Typography + +Use the typography guidance from the official style guide at https://solidproject.org/logo_usage_guidelines. Do not substitute unapproved fonts in Solid-branded materials. + +--- + +## Iconography and Graphics + +- Do not use the Solid gradient inside icons or graphics +- Keep graphic elements visually distinct from the logo +- Maintain visual hierarchy so the Solid logo is clearly identifiable + +--- + +## Application Contexts + +The brand guidelines apply when representing Solid in: + +| Context | Guidance | +|---------|---------| +| Apps | Use approved logo + colours in UI; do not modify logo | +| Communications (email, docs) | Follow colour and typography rules | +| Events | Use official logo files; follow exclusion zone rules | +| Projects (repos, sites) | Do not use the gradient in logo or wordmark | +| Assets (banners, icons) | Outlined logo on Royal Lavender only | + +--- + +## Quick Colour Reference + +```css +/* Primary brand colour */ +--solid-royal-lavender: #7C4DFF; + +/* Use for: mastheads, footers, primary CTAs, outlined logo background */ +background-color: #7C4DFF; +``` + +--- + +## Getting Official Assets + +All official Solid logos and brand assets are available at: +https://solidproject.org/logo_usage_guidelines + +logo reference + + + + + + + + + + +icons reference +https://heroicons.com/ + + +Always use official assets — do not recreate or approximate the logo. diff --git a/apps/extension/.claude/skills/test-driven-development.md b/apps/extension/.claude/skills/test-driven-development.md new file mode 100644 index 000000000..34a9f2b25 --- /dev/null +++ b/apps/extension/.claude/skills/test-driven-development.md @@ -0,0 +1,371 @@ +--- +name: test-driven-development +description: Use when implementing any feature or bugfix, before writing implementation code +--- + +# Test-Driven Development (TDD) + +## Overview + +Write the test first. Watch it fail. Write minimal code to pass. + +**Core principle:** If you didn't watch the test fail, you don't know if it tests the right thing. + +**Violating the letter of the rules is violating the spirit of the rules.** + +## When to Use + +**Always:** +- New features +- Bug fixes +- Refactoring +- Behavior changes + +**Exceptions (ask your human partner):** +- Throwaway prototypes +- Generated code +- Configuration files + +Thinking "skip TDD just this once"? Stop. That's rationalization. + +## The Iron Law + +``` +NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST +``` + +Write code before the test? Delete it. Start over. + +**No exceptions:** +- Don't keep it as "reference" +- Don't "adapt" it while writing tests +- Don't look at it +- Delete means delete + +Implement fresh from tests. Period. + +## Red-Green-Refactor + +```dot +digraph tdd_cycle { + rankdir=LR; + red [label="RED\nWrite failing test", shape=box, style=filled, fillcolor="#ffcccc"]; + verify_red [label="Verify fails\ncorrectly", shape=diamond]; + green [label="GREEN\nMinimal code", shape=box, style=filled, fillcolor="#ccffcc"]; + verify_green [label="Verify passes\nAll green", shape=diamond]; + refactor [label="REFACTOR\nClean up", shape=box, style=filled, fillcolor="#ccccff"]; + next [label="Next", shape=ellipse]; + + red -> verify_red; + verify_red -> green [label="yes"]; + verify_red -> red [label="wrong\nfailure"]; + green -> verify_green; + verify_green -> refactor [label="yes"]; + verify_green -> green [label="no"]; + refactor -> verify_green [label="stay\ngreen"]; + verify_green -> next; + next -> red; +} +``` + +### RED - Write Failing Test + +Write one minimal test showing what should happen. + + +```typescript +test('retries failed operations 3 times', async () => { + let attempts = 0; + const operation = () => { + attempts++; + if (attempts < 3) throw new Error('fail'); + return 'success'; + }; + + const result = await retryOperation(operation); + + expect(result).toBe('success'); + expect(attempts).toBe(3); +}); +``` +Clear name, tests real behavior, one thing + + + +```typescript +test('retry works', async () => { + const mock = jest.fn() + .mockRejectedValueOnce(new Error()) + .mockRejectedValueOnce(new Error()) + .mockResolvedValueOnce('success'); + await retryOperation(mock); + expect(mock).toHaveBeenCalledTimes(3); +}); +``` +Vague name, tests mock not code + + +**Requirements:** +- One behavior +- Clear name +- Real code (no mocks unless unavoidable) + +### Verify RED - Watch It Fail + +**MANDATORY. Never skip.** + +```bash +npm test path/to/test.test.ts +``` + +Confirm: +- Test fails (not errors) +- Failure message is expected +- Fails because feature missing (not typos) + +**Test passes?** You're testing existing behavior. Fix test. + +**Test errors?** Fix error, re-run until it fails correctly. + +### GREEN - Minimal Code + +Write simplest code to pass the test. + + +```typescript +async function retryOperation(fn: () => Promise): Promise { + for (let i = 0; i < 3; i++) { + try { + return await fn(); + } catch (e) { + if (i === 2) throw e; + } + } + throw new Error('unreachable'); +} +``` +Just enough to pass + + + +```typescript +async function retryOperation( + fn: () => Promise, + options?: { + maxRetries?: number; + backoff?: 'linear' | 'exponential'; + onRetry?: (attempt: number) => void; + } +): Promise { + // YAGNI +} +``` +Over-engineered + + +Don't add features, refactor other code, or "improve" beyond the test. + +### Verify GREEN - Watch It Pass + +**MANDATORY.** + +```bash +npm test path/to/test.test.ts +``` + +Confirm: +- Test passes +- Other tests still pass +- Output pristine (no errors, warnings) + +**Test fails?** Fix code, not test. + +**Other tests fail?** Fix now. + +### REFACTOR - Clean Up + +After green only: +- Remove duplication +- Improve names +- Extract helpers + +Keep tests green. Don't add behavior. + +### Repeat + +Next failing test for next feature. + +## Good Tests + +| Quality | Good | Bad | +|---------|------|-----| +| **Minimal** | One thing. "and" in name? Split it. | `test('validates email and domain and whitespace')` | +| **Clear** | Name describes behavior | `test('test1')` | +| **Shows intent** | Demonstrates desired API | Obscures what code should do | + +## Why Order Matters + +**"I'll write tests after to verify it works"** + +Tests written after code pass immediately. Passing immediately proves nothing: +- Might test wrong thing +- Might test implementation, not behavior +- Might miss edge cases you forgot +- You never saw it catch the bug + +Test-first forces you to see the test fail, proving it actually tests something. + +**"I already manually tested all the edge cases"** + +Manual testing is ad-hoc. You think you tested everything but: +- No record of what you tested +- Can't re-run when code changes +- Easy to forget cases under pressure +- "It worked when I tried it" ≠ comprehensive + +Automated tests are systematic. They run the same way every time. + +**"Deleting X hours of work is wasteful"** + +Sunk cost fallacy. The time is already gone. Your choice now: +- Delete and rewrite with TDD (X more hours, high confidence) +- Keep it and add tests after (30 min, low confidence, likely bugs) + +The "waste" is keeping code you can't trust. Working code without real tests is technical debt. + +**"TDD is dogmatic, being pragmatic means adapting"** + +TDD IS pragmatic: +- Finds bugs before commit (faster than debugging after) +- Prevents regressions (tests catch breaks immediately) +- Documents behavior (tests show how to use code) +- Enables refactoring (change freely, tests catch breaks) + +"Pragmatic" shortcuts = debugging in production = slower. + +**"Tests after achieve the same goals - it's spirit not ritual"** + +No. Tests-after answer "What does this do?" Tests-first answer "What should this do?" + +Tests-after are biased by your implementation. You test what you built, not what's required. You verify remembered edge cases, not discovered ones. + +Tests-first force edge case discovery before implementing. Tests-after verify you remembered everything (you didn't). + +30 minutes of tests after ≠ TDD. You get coverage, lose proof tests work. + +## Common Rationalizations + +| Excuse | Reality | +|--------|---------| +| "Too simple to test" | Simple code breaks. Test takes 30 seconds. | +| "I'll test after" | Tests passing immediately prove nothing. | +| "Tests after achieve same goals" | Tests-after = "what does this do?" Tests-first = "what should this do?" | +| "Already manually tested" | Ad-hoc ≠ systematic. No record, can't re-run. | +| "Deleting X hours is wasteful" | Sunk cost fallacy. Keeping unverified code is technical debt. | +| "Keep as reference, write tests first" | You'll adapt it. That's testing after. Delete means delete. | +| "Need to explore first" | Fine. Throw away exploration, start with TDD. | +| "Test hard = design unclear" | Listen to test. Hard to test = hard to use. | +| "TDD will slow me down" | TDD faster than debugging. Pragmatic = test-first. | +| "Manual test faster" | Manual doesn't prove edge cases. You'll re-test every change. | +| "Existing code has no tests" | You're improving it. Add tests for existing code. | + +## Red Flags - STOP and Start Over + +- Code before test +- Test after implementation +- Test passes immediately +- Can't explain why test failed +- Tests added "later" +- Rationalizing "just this once" +- "I already manually tested it" +- "Tests after achieve the same purpose" +- "It's about spirit not ritual" +- "Keep as reference" or "adapt existing code" +- "Already spent X hours, deleting is wasteful" +- "TDD is dogmatic, I'm being pragmatic" +- "This is different because..." + +**All of these mean: Delete code. Start over with TDD.** + +## Example: Bug Fix + +**Bug:** Empty email accepted + +**RED** +```typescript +test('rejects empty email', async () => { + const result = await submitForm({ email: '' }); + expect(result.error).toBe('Email required'); +}); +``` + +**Verify RED** +```bash +$ npm test +FAIL: expected 'Email required', got undefined +``` + +**GREEN** +```typescript +function submitForm(data: FormData) { + if (!data.email?.trim()) { + return { error: 'Email required' }; + } + // ... +} +``` + +**Verify GREEN** +```bash +$ npm test +PASS +``` + +**REFACTOR** +Extract validation for multiple fields if needed. + +## Verification Checklist + +Before marking work complete: + +- [ ] Every new function/method has a test +- [ ] Watched each test fail before implementing +- [ ] Each test failed for expected reason (feature missing, not typo) +- [ ] Wrote minimal code to pass each test +- [ ] All tests pass +- [ ] Output pristine (no errors, warnings) +- [ ] Tests use real code (mocks only if unavoidable) +- [ ] Edge cases and errors covered + +Can't check all boxes? You skipped TDD. Start over. + +## When Stuck + +| Problem | Solution | +|---------|----------| +| Don't know how to test | Write wished-for API. Write assertion first. Ask your human partner. | +| Test too complicated | Design too complicated. Simplify interface. | +| Must mock everything | Code too coupled. Use dependency injection. | +| Test setup huge | Extract helpers. Still complex? Simplify design. | + +## Debugging Integration + +Bug found? Write failing test reproducing it. Follow TDD cycle. Test proves fix and prevents regression. + +Never fix bugs without a test. + +## Testing Anti-Patterns + +When adding mocks or test utilities, read @testing-anti-patterns.md to avoid common pitfalls: +- Testing mock behavior instead of real behavior +- Adding test-only methods to production classes +- Mocking without understanding dependencies + +## Final Rule + +``` +Production code → test exists and failed first +Otherwise → not TDD +``` + +No exceptions without your human partner's permission. \ No newline at end of file diff --git a/apps/extension/.gitignore b/apps/extension/.gitignore new file mode 100644 index 000000000..ba811d906 --- /dev/null +++ b/apps/extension/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.js.map +.DS_Store +test-results/ +playwright-report/ +coverage/ diff --git a/apps/extension/.kiro/skills/playwright-e2e-testing b/apps/extension/.kiro/skills/playwright-e2e-testing new file mode 120000 index 000000000..af1fd9079 --- /dev/null +++ b/apps/extension/.kiro/skills/playwright-e2e-testing @@ -0,0 +1 @@ +../../.agents/skills/playwright-e2e-testing \ No newline at end of file diff --git a/apps/extension/.npmrc b/apps/extension/.npmrc new file mode 100644 index 000000000..97b895e2f --- /dev/null +++ b/apps/extension/.npmrc @@ -0,0 +1 @@ +ignore-scripts=true diff --git a/apps/extension/.roborev.toml b/apps/extension/.roborev.toml new file mode 100644 index 000000000..8c5b4a0df --- /dev/null +++ b/apps/extension/.roborev.toml @@ -0,0 +1,200 @@ +# Default agent for this repo when no workflow-specific agent is set. +agent = '@github/copilot' +# Default model for this repo when no workflow-specific model is set. +model = 'o3' +# Backup agent for this repo if the primary agent fails. +backup_agent = '' +# Backup model for this repo if the primary model fails. +backup_model = '' +# Number of related reviews to include as context for this repo. +review_context_count = 0 +# Extra review instructions added to prompts for this repo. +review_guidelines = ''' +Chrome browser extension (Manifest V3) providing Solid authentication. + +Focus on: +- Security: OAuth/OIDC flows, token handling, DPoP proofs, message passing between MAIN world, ISOLATED world, and service worker +- Chrome extension best practices: service worker suspension/wake-up, chrome.storage, chrome.identity +- Solid protocol compliance: WebID resolution, client identifiers, solid:oidcIssuer validation +- Message serialization: data over chrome.runtime and window.postMessage must be JSON-serializable + +Do not flag: +- Extractable DPoP keys (required because service workers suspend) +- CLIENT_ID constant pointing to localhost (test/dev configuration) +''' +# Override the review job timeout in minutes for this repo. +job_timeout_minutes = 0 +# Branches that should be skipped for automatic review in this repo. +excluded_branches = [] +# Commit message substrings that should skip review for this repo. +excluded_commit_patterns = [] +# Display name shown for this repo in the TUI and output. +display_name = '' +# Reasoning level for reviews in this repo: fast, standard, medium, thorough, or maximum. +review_reasoning = '' +# Reasoning level for refine in this repo: fast, standard, medium, thorough, or maximum. +refine_reasoning = '' +# Reasoning level for fix in this repo: fast, standard, medium, thorough, or maximum. +fix_reasoning = '' +# Minimum severity for fix in this repo: critical, high, medium, or low. +fix_min_severity = '' +# Minimum severity for refine in this repo: critical, high, medium, low. +refine_min_severity = '' +# Filenames or glob patterns to exclude from review diffs for this repo. +exclude_patterns = ['dist/**', 'node_modules/**', '.skills/**', '.agents/**', 'e2e/setup/templates/pod/wac/**'] +# Automatic post-commit review mode for this repo: commit or branch. +post_commit_review = '' +reuse_review_session_lookback = 0 +# Agent override for standard review in this repo. +review_agent = '' +# Agent override for fast review in this repo. +review_agent_fast = '' +# Agent override for standard review in this repo. +review_agent_standard = '' +# Agent override for medium review in this repo. +review_agent_medium = '' +# Agent override for thorough review in this repo. +review_agent_thorough = '' +# Agent override for maximum review in this repo. +review_agent_maximum = '' +# Agent override for refine in this repo. +refine_agent = '' +# Agent override for fast refine in this repo. +refine_agent_fast = '' +# Agent override for standard refine in this repo. +refine_agent_standard = '' +# Agent override for medium refine in this repo. +refine_agent_medium = '' +# Agent override for thorough refine in this repo. +refine_agent_thorough = '' +# Agent override for maximum refine in this repo. +refine_agent_maximum = '' +# Model override for standard review in this repo. +review_model = '' +# Model override for fast review in this repo. +review_model_fast = '' +# Model override for standard review in this repo. +review_model_standard = '' +# Model override for medium review in this repo. +review_model_medium = '' +# Model override for thorough review in this repo. +review_model_thorough = '' +# Model override for maximum review in this repo. +review_model_maximum = '' +# Model override for standard refine in this repo. +refine_model = '' +# Model override for fast refine in this repo. +refine_model_fast = '' +# Model override for standard refine in this repo. +refine_model_standard = '' +# Model override for medium refine in this repo. +refine_model_medium = '' +# Model override for thorough refine in this repo. +refine_model_thorough = '' +# Model override for maximum refine in this repo. +refine_model_maximum = '' +# Agent override for fix in this repo. +fix_agent = '' +# Agent override for fast fix in this repo. +fix_agent_fast = '' +# Agent override for standard fix in this repo. +fix_agent_standard = '' +# Agent override for medium fix in this repo. +fix_agent_medium = '' +# Agent override for thorough fix in this repo. +fix_agent_thorough = '' +# Agent override for maximum fix in this repo. +fix_agent_maximum = '' +# Model override for standard fix in this repo. +fix_model = '' +# Model override for fast fix in this repo. +fix_model_fast = '' +# Model override for standard fix in this repo. +fix_model_standard = '' +# Model override for medium fix in this repo. +fix_model_medium = '' +# Model override for thorough fix in this repo. +fix_model_thorough = '' +# Model override for maximum fix in this repo. +fix_model_maximum = '' +# Agent override for security review in this repo. +security_agent = '' +# Agent override for fast security review in this repo. +security_agent_fast = '' +# Agent override for standard security review in this repo. +security_agent_standard = '' +# Agent override for medium security review in this repo. +security_agent_medium = '' +# Agent override for thorough security review in this repo. +security_agent_thorough = '' +# Agent override for maximum security review in this repo. +security_agent_maximum = '' +# Model override for standard security review in this repo. +security_model = '' +# Model override for fast security review in this repo. +security_model_fast = '' +# Model override for standard security review in this repo. +security_model_standard = '' +# Model override for medium security review in this repo. +security_model_medium = '' +# Model override for thorough security review in this repo. +security_model_thorough = '' +# Model override for maximum security review in this repo. +security_model_maximum = '' +# Agent override for design review in this repo. +design_agent = '' +# Agent override for fast design review in this repo. +design_agent_fast = '' +# Agent override for standard design review in this repo. +design_agent_standard = '' +# Agent override for medium design review in this repo. +design_agent_medium = '' +# Agent override for thorough design review in this repo. +design_agent_thorough = '' +# Agent override for maximum design review in this repo. +design_agent_maximum = '' +# Model override for standard design review in this repo. +design_model = '' +# Model override for fast design review in this repo. +design_model_fast = '' +# Model override for standard design review in this repo. +design_model_standard = '' +# Model override for medium design review in this repo. +design_model_medium = '' +# Model override for thorough design review in this repo. +design_model_thorough = '' +# Model override for maximum design review in this repo. +design_model_maximum = '' +# Backup agent for review in this repo. +review_backup_agent = '' +# Backup agent for refine in this repo. +refine_backup_agent = '' +# Backup agent for fix in this repo. +fix_backup_agent = '' +# Backup agent for security review in this repo. +security_backup_agent = '' +# Backup agent for design review in this repo. +design_backup_agent = '' +# Backup model for review in this repo. +review_backup_model = '' +# Backup model for refine in this repo. +refine_backup_model = '' +# Backup model for fix in this repo. +fix_backup_model = '' +# Backup model for security review in this repo. +security_backup_model = '' +# Backup model for design review in this repo. +design_backup_model = '' +hooks = [] +# Maximum prompt size for this repo before falling back to file paths. +max_prompt_size = 0 + +[ci] +# Override the agents used by CI for this repo. +agents = [] +# Override the review types used by CI for this repo. +review_types = [] +# Override the CI reasoning level for this repo: fast, standard, medium, thorough, or maximum. +reasoning = '' +# Override the minimum CI severity included in synthesized output. +min_severity = '' diff --git a/apps/extension/.skills/data-modelling.md b/apps/extension/.skills/data-modelling.md new file mode 100644 index 000000000..47416c367 --- /dev/null +++ b/apps/extension/.skills/data-modelling.md @@ -0,0 +1,195 @@ +# Solid Data Modelling Skill + +You are an expert on data modelling for the Solid ecosystem. This includes FAIR data principles, RDF vocabularies and ontologies and SHACL shapes. + +--- + +## FAIR Data Principles in Solid + +FAIR Data Principles (*Findable, Accessible, Interoperable, Reusable*) describe guidelines for improving data usability in open and distributed systems. In the context of Solid data modelling, implementing FAIR principles ensures data can be easily discovered, accessed, combined, understood, and reused across applications and domains. + +| Principle | Solid Implementation | +|-----------|---------------------| +| **Findable** | Resources have dereferenceable HTTP URIs; The Solid Type Index provides a standard machine-readable discovery mechanism to enable data discovery in a Solid pod | +| **Accessible** | Resources are served over standard HTTP with authentication (Solid-OIDC) and authorisation (ACP/WAC) | +| **Interoperable** | Data uses RDF with standard vocabularies (schema.org, FOAF, LDP, etc.) | +| **Reusable** | Shapes (SHACL) define reusable data schemas; linked data enables cross-app data reuse | + +### Applying FAIR in Practice + +1. **Mint stable URIs** — use consistent URI patterns for resources +2. **Use standard vocabularies** — reuse standard vocabularies e.g. schema.org, FOAF, Dublin Core, SKOS where possible +3. **Describe your data in machine-readable format** — add RDF type statements and metadata to every resource +4. **Publish shapes** — provide SHACL shapes so consuming apps can validate the data +5. **Link datasets** — use `rdfs:seeAlso`, and `skos:exactMatch` to connect related data + +--- + +## RDF Vocabulary Selection + +### Core Vocabularies for Solid + +| Prefix | Vocabulary | Description | Namespace URL | +| -------------------- | ------------------ | -------------------------------------------- | ------------------------------------------------------------------------------------------ | +| ACP | `acp:` | Access Control Policy Language | [http://www.w3.org/ns/solid/acp#](http://www.w3.org/ns/solid/acp#) | +| AS (ActivityStreams) | `as:` | Social activities, notifications | [https://www.w3.org/ns/activitystreams#](https://www.w3.org/ns/activitystreams#) | +| DC | `dc:` / `dcterms:` | Metadata (title, creator, date, description) | [http://purl.org/dc/terms/](http://purl.org/dc/terms/) | +| FOAF | `foaf:` | Person, identity, social graph | [http://xmlns.com/foaf/0.1/](http://xmlns.com/foaf/0.1/) | +| ICAL | `ical:` | Calendar data | [http://www.w3.org/2002/12/cal/ical#](http://www.w3.org/2002/12/cal/ical#) | +| LDP | `ldp:` | Containers, membership | [http://www.w3.org/ns/ldp#](http://www.w3.org/ns/ldp#) | +| ORG | `org:` | Organization ontology | [http://www.w3.org/ns/org#](http://www.w3.org/ns/org#) | +| OWL | `owl:` | Ontology relations, sameAs | [http://www.w3.org/2002/07/owl#](http://www.w3.org/2002/07/owl#) | +| PIM | `pim:` | Storage, preferences, configuration | [http://www.w3.org/ns/pim/space#](http://www.w3.org/ns/pim/space#) | +| POSIX | `posix:` | POSIX file system permissions | [http://www.w3.org/ns/posix/stat#](http://www.w3.org/ns/posix/stat#) | +| RDF | `rdf:` | RDF syntax concepts | [http://www.w3.org/1999/02/22-rdf-syntax-ns#](http://www.w3.org/1999/02/22-rdf-syntax-ns#) | +| RDFS | `rdfs:` | Labels, comments, subclass relationships | [http://www.w3.org/2000/01/rdf-schema#](http://www.w3.org/2000/01/rdf-schema#) | +| Schema.org | `schema:` | Structured data vocabulary | [https://schema.org/](https://schema.org/) | +| Solid | `solid:` | OIDC, notifications, identity | [https://www.w3.org/ns/solid/](https://www.w3.org/ns/solid/) | +| vCard | `vcard:` | Contact information | [http://www.w3.org/2006/vcard/ns#](http://www.w3.org/2006/vcard/ns#) | + + +### Vocabulary Lookup + +Before creating custom RDF classes and properties, search existing ontologies/vocabularies: + +- **Linked Open Vocabularies (LOV)**: https://lov.linkeddata.es/ +- **schema.org**: https://schema.org/ — widely adopted, search-engine friendly + +--- + +## Shapes: SHACL + +Shapes define the expected structure of RDF data. They serve as schemas for validation and code generation. + +### SHACL (Shapes Constraint Language) + +[SHACL](https://www.w3.org/TR/shacl/) is the W3C Recommendation for RDF validation, more expressive for complex constraints. + +```turtle +@prefix sh: . +@prefix foaf: . + + + a sh:NodeShape ; + sh:targetClass foaf:Person ; + sh:property [ + sh:path foaf:name ; + sh:datatype xsd:string ; + sh:minCount 1 ; + ] ; + sh:property [ + sh:path foaf:mbox ; + sh:nodeKind sh:IRI ; + sh:maxCount 1 ; + ] . +``` + +### SHACL Shape Discovery + + +A number of **public SHACL shapes collections and published shapes** are available that you can reuse or study: + +- **[Solid Shapes Catalogue](https://github.com/solid/shapes)** + Provides reusable SHACL shapes used in Solid ecosystem applications. + +- **[W3C Data Shapes Working Group Resources](https://github.com/w3c/data-shapes)** + Official working group repository containing specifications, examples, and discussion materials related to RDF data shapes. + +- **[Awesome Semantic Shapes Curated List](https://github.com/w3c-cg/awesome-semantic-shapes)** + Broad index of SHACL and ShEx shape resources across domains and vocabularies. + +- **Shapes-of-You Semantic Index** + A public index of semantic web resources that helps researchers discover linked data artefacts including SHACL shapes, ontologies, mappings, and SPARQL assets. + https://index.semanticscience.org/ + +- **[SHACL Play! Shapes Catalog (SKOS & vocab constraint shapes)](https://shacl-play.sparna.fr/play/shapes-catalog)** + Provides ready-to-use SKOS validation shapes implementing many SKOS integrity constraints. + +- **[SEMICeu DCAT-AP SHACL Shapes](https://github.com/SEMICeu/dcat-ap_shacl/blob/master/README.md)** + Repository containing SHACL shapes for the DCAT-AP data catalogue application profile. +--- + +## MCP Adapters for Ontology Discovery + +Model Context Protocol (MCP) adapters allow AI tools and development environments to discover and work with ontologies and shapes programmatically. + +### Discovering Ontologies via MCP + +When building an MCP adapter for ontology discovery, key capabilities to expose: + +1. **Search by term** — find classes and properties matching a keyword +2. **Shape retrieval** — fetch SHACL shapes for a known type +3. **Type index traversal** — discover where a user stores instances of a given type on their pod + +### Recommended Approach for Ontology Discovery in Apps + +``` +1. Check LOV (lov.linkeddata.es) for existing vocabulary terms +2. Use SHACL shapes (from Solid shapes repo) to validate data +3. For pod-specific discovery, traverse the pod's Type Index +``` + +--- + +### Structure + +```turtle +# Public type index at /settings/publicTypeIndex.ttl + +@prefix solid: . +@prefix schema: . + +<#registration-contacts> + a solid:TypeRegistration ; + solid:forClass schema:Person ; + solid:instanceContainer . + +<#registration-notes> + a solid:TypeRegistration ; + solid:forClass schema:TextObject ; + solid:instance . +``` + +### Two Registration Types + +| Property | Meaning | +|----------|---------| +| `solid:instanceContainer` | All instances of this type live in this container | +| `solid:instance` | A single document containing instances of this type | + +### Accessing the Solid Type Index + +1. Fetch the user's WebID profile +2. Find `solid:publicTypeIndex` and/or `solid:privateTypeIndex` predicates +3. Fetch the Solid Type Index document +4. Query for `solid:TypeRegistration` entries with matching `solid:forClass` + +--- + +## Data Modelling Checklist + +When modelling a new data type for Solid: + +- [ ] Check for existing SHACL shapes with types that meet your requirements +- [ ] Check LOV for an existing vocabulary/class +- [ ] Define a SHACL shape for the type +- [ ] Add `rdf:type` statements to all instances +- [ ] Register the type in the pod's Solid Type Index +- [ ] Use stable, dereferenceable URIs for subjects +- [ ] Add `dcterms:created`, `dcterms:modified` metadata +- [ ] Document the shape in a public shapes repository +- [ ] Validate data against the shape before writing to the pod + +--- + +## Resources + +| Resource | URL | +|----------|-----| +| FAIR Principles | https://www.go-fair.org/fair-principles/ | +| Linked Open Vocabularies | https://lov.linkeddata.es/ | +| W3C SHACL Spec | https://www.w3.org/TR/shacl/ | +| W3C Data Shapes repo | https://github.com/w3c/data-shapes | +| Solid type indexes | https://solid.github.io/type-indexes/ | +| Shapes resources | https://github.com/w3c-cg/awesome-semantic-shapes | +| Solid Developer Documentation | https://dev.solidproject.org/ | diff --git a/apps/extension/.skills/integration-guide.md b/apps/extension/.skills/integration-guide.md new file mode 100644 index 000000000..ed08d2436 --- /dev/null +++ b/apps/extension/.skills/integration-guide.md @@ -0,0 +1,678 @@ +# Solid Integration Guide Skill + +You are an expert on integrating JavaScript/TypeScript applications with the Solid ecosystem. You know the Inrupt SDK, LDO, and solid-client-authn-js libraries in depth, and can guide developers through authentication, reading, writing, and managing access to Solid pods. + +Based application being built by user, recommend library to use and get confirmation from the user. + +--- + +## Library Overview + +| Library | Package | Best for | +|---------|---------|----------| +| @inrupt/solid-client | `@inrupt/solid-client` | Reading/writing structured RDF data (SolidDataset/Thing API) | +| solid-client-authn-browser | `@inrupt/solid-client-authn-browser` | Browser authentication (OIDC + DPoP) | +| solid-client-authn-node | `@inrupt/solid-client-authn-node` | Node.js / server-side authentication | +| LDO | `ldo` / `@ldo/solid` | TypeScript-first RDF with ShEx shapes; type-safe linked data | +| @ldo/connected-solid | `@ldo/connected-solid` | LDO + Solid pods: containers, WAC, live updates | +| N3.js | `n3` | Low-level RDF parsing, serialising, and in-memory quad storage | + +--- + +## Authentication with solid-client-authn-js + +**Docs**: https://inrupt.github.io/solid-client-authn-js/ +**Repo**: https://github.com/inrupt/solid-client-authn-js + +### Browser Authentication + +```bash +npm install @inrupt/solid-client-authn-browser +``` + +```typescript +import { + login, + logout, + handleIncomingRedirect, + getDefaultSession, + fetch, // authenticated fetch — use this for all pod requests +} from "@inrupt/solid-client-authn-browser"; + +// 1. Handle redirect on page load (restore session after OIDC redirect) +await handleIncomingRedirect({ restorePreviousSession: true }); + +// 2. Check if already logged in +const session = getDefaultSession(); +if (!session.info.isLoggedIn) { + // 3. Redirect user to their OIDC provider + await login({ + oidcIssuer: "https://solidcommunity.net", + redirectUrl: window.location.href, + clientName: "My Solid App", + }); +} + +// 4. Use the authenticated session's fetch for all pod requests +const response = await fetch("https://alice.solidcommunity.net/profile/card"); +``` + +### Node.js Authentication (Client Credentials) + +```bash +npm install @inrupt/solid-client-authn-node +``` + +```typescript +import { Session } from "@inrupt/solid-client-authn-node"; + +const session = new Session(); + +await session.login({ + oidcIssuer: "https://solidcommunity.net", + clientId: "your-client-id", + clientSecret: "your-client-secret", + clientName: "My Node App", +}); + +// Use session.fetch for authenticated requests +const response = await session.fetch("https://alice.solidcommunity.net/data/"); +``` + +### Key Rules + +- Always use the session's `fetch` (not global `fetch`) for requests to Solid pods +- Call `handleIncomingRedirect` on every page load to restore session after OIDC callback +- Store the `oidcIssuer` — prompt users to enter their pod provider if unknown +- Tokens are DPoP-bound; do not strip or modify Authorization headers + +--- + +## Reading and Writing with @inrupt/solid-client + +**Docs**: https://docs.inrupt.com/developer-tools/javascript/client-libraries/ +**Package**: `@inrupt/solid-client` + +```bash +npm install @inrupt/solid-client @inrupt/solid-client-authn-browser +``` + +### Core Concepts + +| Concept | Description | +|---------|-------------| +| `SolidDataset` | An RDF document fetched from a pod | +| `Thing` | A named set of RDF triples within a SolidDataset (identified by URL) | +| `saveSolidDatasetAt` | Write a modified SolidDataset back to the pod | +| `getThing` / `setThing` | Read/modify a Thing within a dataset | +| `buildThing` / `createThing` | Construct new Things | + +### Read a Resource + +```typescript +import { + getSolidDataset, + getThing, + getStringNoLocale, + getUrl, +} from "@inrupt/solid-client"; +import { fetch } from "@inrupt/solid-client-authn-browser"; +import { FOAF, SCHEMA_INRUPT } from "@inrupt/vocab-common-rdf"; + +const dataset = await getSolidDataset( + "https://alice.solidcommunity.net/profile/card", + { fetch } +); + +const profile = getThing( + dataset, + "https://alice.solidcommunity.net/profile/card#me" +); + +const name = getStringNoLocale(profile, FOAF.name); +const storage = getUrl(profile, "http://www.w3.org/ns/pim/space#storage"); +``` + +### Write a Resource + +```typescript +import { + getSolidDataset, + getThing, + setThing, + setStringNoLocale, + saveSolidDatasetAt, + buildThing, + createThing, +} from "@inrupt/solid-client"; +import { fetch } from "@inrupt/solid-client-authn-browser"; +import { SCHEMA_INRUPT } from "@inrupt/vocab-common-rdf"; + +// Fetch existing dataset +const dataset = await getSolidDataset( + "https://alice.solidcommunity.net/notes/note1.ttl", + { fetch } +); + +// Modify an existing Thing +let note = getThing( + dataset, + "https://alice.solidcommunity.net/notes/note1.ttl#note" +); +note = setStringNoLocale(note, SCHEMA_INRUPT.text, "Updated note content"); + +// Save back to the pod +const updatedDataset = setThing(dataset, note); +await saveSolidDatasetAt( + "https://alice.solidcommunity.net/notes/note1.ttl", + updatedDataset, + { fetch } +); +``` + +### Create a New Resource + +```typescript +import { + createSolidDataset, + setThing, + buildThing, + createThing, + saveSolidDatasetAt, +} from "@inrupt/solid-client"; +import { fetch } from "@inrupt/solid-client-authn-browser"; +import { RDF, SCHEMA_INRUPT } from "@inrupt/vocab-common-rdf"; + +const newNote = buildThing(createThing({ name: "my-note" })) + .addUrl(RDF.type, "https://schema.org/TextObject") + .addStringNoLocale(SCHEMA_INRUPT.text, "My first note") + .build(); + +const dataset = setThing(createSolidDataset(), newNote); + +// saveSolidDatasetAt creates intermediate containers automatically +await saveSolidDatasetAt( + "https://alice.solidcommunity.net/notes/my-note.ttl", + dataset, + { fetch } +); +``` + +### ACL Management + +```typescript +import { + getSolidDatasetWithAcl, + getResourceAcl, + setAgentResourceAccess, + saveAclFor, +} from "@inrupt/solid-client"; + +const datasetWithAcl = await getSolidDatasetWithAcl(resourceUrl, { fetch }); +const resourceAcl = getResourceAcl(datasetWithAcl); + +const updatedAcl = setAgentResourceAccess( + resourceAcl, + "https://bob.solidcommunity.net/profile/card#me", + { read: true, write: false, append: false, control: false } +); + +await saveAclFor(datasetWithAcl, updatedAcl, { fetch }); +``` + +--- + +## LDO (Linked Data Objects) + +**Docs**: https://ldo.js.org/ +**Package**: `ldo`, `@ldo/solid`, `@ldo/connected-solid` + +LDO provides TypeScript-typed access to RDF data using ShEx shapes. It generates TypeScript interfaces from shapes, so you work with familiar object literals instead of raw RDF APIs. + +### Install + +```bash +npm install ldo @ldo/solid +npm install --save-dev @ldo/cli +``` + +### Generate TypeScript from ShEx + +```bash +# Given a ShEx file at ./shapes/Person.shex +npx ldo generate ./shapes +``` + +This generates: +- TypeScript interfaces (`.typings.ts`) +- JSON-LD context (`.context.ts`) +- Shape type (`.shapeTypes.ts`) + +### Read RDF as TypeScript Object + +```typescript +import { parseRdf } from "ldo"; +import { PersonShapeType } from "./shapes/PersonShapeType"; + +const turtle = ` +@prefix foaf: . + a foaf:Person ; foaf:name "Alice" . +`; + +const dataset = await parseRdf(turtle, { baseIRI: "https://alice.example.com/" }); +const ldoDataset = createLdoDataset([...dataset]); + +const person = ldoDataset + .usingType(PersonShapeType) + .fromSubject("https://alice.example.com/#me"); + +console.log(person.name); // "Alice" +``` + +### Write via LDO + +```typescript +const [person, setThing] = createLdoDataset().usingType(PersonShapeType).write(); +person.["@id"] = "https://alice.example.com/#me"; +person.name = "Alice Updated"; + +// Serialize back to Turtle +const turtle = await serialize(dataset, { format: "text/turtle" }); +``` + +### @ldo/connected-solid — LDO + Solid Pods + +Combines LDO with Solid pods for type-safe, live pod interaction: + +```typescript +import { createSolidLdoDataset } from "@ldo/connected-solid"; + +const solidLdoDataset = createSolidLdoDataset(); +await solidLdoDataset.auth.login({ oidcIssuer: "https://solidcommunity.net" }); + +// Read a resource into the dataset +await solidLdoDataset.getResource("https://alice.solidcommunity.net/notes/note1.ttl").read(); + +// Access typed object +const note = solidLdoDataset + .usingType(NoteShapeType) + .fromSubject("https://alice.solidcommunity.net/notes/note1.ttl#note"); + +// Mutate and commit +note.text = "Updated via LDO"; +await solidLdoDataset.commitChanges(); +``` + +**Additional @ldo/connected-solid features:** +- Container listing and creation +- WAC access control management +- Live update subscriptions (WebSocket notifications) + +--- + +## N3.js — Low-Level RDF Parsing and Storage + +**Repo**: https://github.com/rdfjs/N3.js +**Docs**: https://rdf.js.org/N3.js/ +**Package**: `n3` + +N3.js is a low-level, spec-compliant RDF library for JavaScript. It handles parsing, serialising, and in-memory storage of RDF quads. It implements the [RDF/JS interfaces](https://rdf.js.org/) and is the foundation other libraries build on. + +Use N3.js when you need direct control over RDF data — raw PATCH body construction, custom parsers, graph reasoning, or working with formats the higher-level libraries don't expose. + +### Install + +```bash +npm install n3 +``` + +```typescript +import * as N3 from "n3"; +// or specific imports: +import { Parser, Writer, Store, DataFactory, StreamParser, StreamWriter } from "n3"; +const { namedNode, literal, defaultGraph, quad } = DataFactory; +``` + +### DataFactory — Constructing RDF Terms + +```typescript +import { DataFactory } from "n3"; +const { namedNode, literal, blankNode, defaultGraph, quad } = DataFactory; + +// Named node (IRI) +const alice = namedNode("https://alice.solidcommunity.net/profile/card#me"); + +// Literal with language tag +const name = literal("Alice", "en"); + +// Literal with datatype +import { xsd } from "@tpluscode/rdf-ns-builders"; +const dob = literal("1990-01-01", namedNode("http://www.w3.org/2001/XMLSchema#date")); + +// Build a quad (subject, predicate, object, graph) +const myQuad = quad( + alice, + namedNode("http://xmlns.com/foaf/0.1/name"), + name, + defaultGraph() +); + +console.log(myQuad.subject.value); // "https://alice.solidcommunity.net/profile/card#me" +``` + +### Parser — Turtle / N-Triples / TriG / N-Quads to Quads + +```typescript +import { Parser } from "n3"; + +const turtle = ` + @prefix foaf: . + foaf:name "Alice" . +`; + +// Callback-based (async, streaming-friendly) +const parser = new Parser({ baseIRI: "https://alice.example.com/" }); +parser.parse(turtle, (error, quad, prefixes) => { + if (quad) { + console.log(quad.subject.value, quad.predicate.value, quad.object.value); + } else { + console.log("Done. Prefixes:", prefixes); + } +}); + +// Synchronous (returns array) +const quads = new Parser().parse(turtle); +``` + +**Format options** (pass as `{ format: "..." }`): + +| String | Parses | +|--------|--------| +| `"Turtle"` (default) | Turtle + permissive superset | +| `"N-Triples"` | N-Triples | +| `"TriG"` | TriG (named graphs) | +| `"N-Quads"` | N-Quads | +| `"N3"` / `"text/n3"` | N3 (with rules) | + +### Writer — Quads to Turtle / N-Triples / TriG + +```typescript +import { Writer, DataFactory } from "n3"; +const { namedNode, literal } = DataFactory; + +// Write to string +const writer = new Writer({ + prefixes: { foaf: "http://xmlns.com/foaf/0.1/" }, + // format: "N-Triples" // override default Turtle +}); + +writer.addQuad( + namedNode("https://alice.example.com/#me"), + namedNode("http://xmlns.com/foaf/0.1/name"), + literal("Alice", "en") +); + +writer.end((error, result) => { + console.log(result); + // @prefix foaf: . + // foaf:name "Alice"@en. +}); +``` + +### Store — In-Memory Quad Index + +`N3.Store` is an efficient indexed in-memory store with quad-pattern lookups. + +```typescript +import { Store, Parser, DataFactory } from "n3"; +const { namedNode } = DataFactory; + +const store = new Store(); + +// Add quads directly +store.addQuad( + namedNode("https://alice.example.com/#me"), + namedNode("http://xmlns.com/foaf/0.1/name"), + DataFactory.literal("Alice") +); + +// Or parse directly into the store +const parser = new Parser(); +const quads = parser.parse(` + @prefix foaf: . + foaf:name "Alice" ; foaf:knows . +`); +store.addQuads(quads); + +// Query: getQuads(subject, predicate, object, graph) — null = wildcard +const names = store.getQuads( + namedNode("https://alice.example.com/#me"), + namedNode("http://xmlns.com/foaf/0.1/name"), + null, + null +); +console.log(names[0].object.value); // "Alice" + +// Count matching quads +const count = store.countQuads(null, namedNode("http://xmlns.com/foaf/0.1/knows"), null, null); + +// Iterate over unique subjects +store.forSubjects( + (subject) => console.log(subject.value), + namedNode("http://xmlns.com/foaf/0.1/knows"), + null, + null +); + +// Remove quads +store.removeQuads(names); +// or by pattern: +store.removeMatches(namedNode("https://alice.example.com/#me"), null, null, null); +``` + +### StreamParser and StreamWriter — Large Files + +```typescript +import { StreamParser, StreamWriter } from "n3"; +import { createReadStream, createWriteStream } from "fs"; + +// Parse a large Turtle file as a stream +const inputStream = createReadStream("data.ttl"); +const streamParser = new StreamParser({ format: "Turtle" }); + +inputStream.pipe(streamParser); + +streamParser.on("data", (quad) => { + console.log(quad.subject.value); +}); +streamParser.on("end", () => console.log("Done")); + +// Transform stream: re-serialise to N-Triples +const streamWriter = new StreamWriter({ format: "N-Triples" }); +inputStream + .pipe(new StreamParser()) + .pipe(streamWriter) + .pipe(createWriteStream("output.nt")); +``` + +### SPARQL Queries with Comunica + +N3.js does not include a SPARQL engine, but its `Store` implements the RDF/JS interfaces so it works directly with [Comunica](https://comunica.dev/). + +```bash +npm install @comunica/query-sparql-rdfjs +``` + +```typescript +import { QueryEngine } from "@comunica/query-sparql-rdfjs"; +import { Store, Parser } from "n3"; + +const store = new Store(); +store.addQuads(new Parser().parse(` + @prefix foaf: . + <#alice> foaf:name "Alice" ; foaf:knows <#bob> . + <#bob> foaf:name "Bob" . +`)); + +const engine = new QueryEngine(); +const bindingsStream = await engine.queryBindings( + "SELECT ?name WHERE { ?s ?name }", + { sources: [store] } +); + +bindingsStream.on("data", (bindings) => { + console.log(bindings.get("name").value); +}); +``` + +### N3.js in a Solid Context + +N3.js is useful when working directly with Solid at the HTTP level — parsing response bodies, constructing N3 PATCH payloads, or processing raw Turtle resources. + +```typescript +import { fetch } from "@inrupt/solid-client-authn-browser"; +import { Parser, Writer, Store, DataFactory } from "n3"; +const { namedNode, literal } = DataFactory; + +// Fetch and parse a Solid resource as raw RDF +const response = await fetch("https://alice.solidcommunity.net/profile/card", { + headers: { Accept: "text/turtle" }, +}); +const turtle = await response.text(); + +const store = new Store(); +store.addQuads(new Parser({ baseIRI: response.url }).parse(turtle)); + +// Query the profile +const names = store.getQuads( + null, + namedNode("http://xmlns.com/foaf/0.1/name"), + null, + null +); +console.log(names.map((q) => q.object.value)); + +// Build an N3 Patch body +const patchWriter = new Writer({ format: "text/n3" }); +patchWriter.addQuad( + namedNode(""), + namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + namedNode("http://www.w3.org/ns/solid/terms#InsertDeletePatch") +); +// ... add solid:inserts quads +patchWriter.end(async (err, patchBody) => { + await fetch("https://alice.solidcommunity.net/profile/card", { + method: "PATCH", + headers: { "Content-Type": "text/n3" }, + body: patchBody, + }); +}); +``` + +--- + +## Choosing the Right Library + +| Scenario | Recommended Library | +|----------|-------------------| +| Browser app, structured data | `@inrupt/solid-client` + `solid-client-authn-browser` | +| Node.js script or API | `@inrupt/solid-client` + `solid-client-authn-node` | +| TypeScript-first, shape-driven | `ldo` + `@ldo/solid` | +| React app with live updates | `@ldo/connected-solid` | +| Raw HTTP, no library overhead | Native `fetch` with `solid-client-authn` session fetch | +| Low-level RDF parsing / serialising | `n3` (N3.js) | +| In-memory graph querying | `n3` Store + Comunica | +| Custom PATCH body construction | `n3` Writer (`text/n3` format) | +| Stream processing large RDF files | `n3` StreamParser + StreamWriter | + +--- + +## Common Patterns + +### Check if User is Logged In + +```typescript +import { getDefaultSession } from "@inrupt/solid-client-authn-browser"; + +const { isLoggedIn, webId } = getDefaultSession().info; +``` + +### Discover User's Storage + +```typescript +import { getSolidDataset, getThing, getUrl } from "@inrupt/solid-client"; + +const profile = await getSolidDataset(webId, { fetch }); +const profileThing = getThing(profile, webId); +const storageUrl = getUrl(profileThing, "http://www.w3.org/ns/pim/space#storage"); +``` + +For robust pod root discovery (handles servers that don't advertise `pim:storage` on the profile), use the recursive `getPodRoot` pattern from [Bashlib util.ts L73-104](https://github.com/SolidLabResearch/Bashlib/blob/80de25cbb4b3ed057f95e25bc057f1be9b00cef3/src/utils/util.ts#L73-L104). It uses a three-stage approach: + +1. HEAD request — checks the `Link` header for a `pim:Storage` relation on the resource +2. Dataset query — fetches the resource and looks for a `pim:storage` triple +3. Recursive traversal — walks up the URL path one segment at a time, repeating steps 1-2 + +```typescript +async function getPodRoot(url: string, fetch: typeof globalThis.fetch): Promise { + // Stage 1: check Link header + const headRes = await fetch(url, { method: "HEAD" }); + const linkHeader = headRes.headers.get("Link") ?? ""; + if (linkHeader.includes("http://www.w3.org/ns/pim/space#Storage")) { + return url; + } + + // Stage 2: check dataset for pim:storage triple + try { + const dataset = await getSolidDataset(url, { fetch }); + const thing = getThing(dataset, url); + const storage = getUrl(thing, "http://www.w3.org/ns/pim/space#storage"); + if (storage) return storage; + } catch {} + + // Stage 3: recurse up the URL path + const parent = new URL(url); + const segments = parent.pathname.replace(/\/$/, "").split("/"); + if (segments.length <= 1) return null; + parent.pathname = segments.slice(0, -1).join("/") + "/"; + return getPodRoot(parent.toString(), fetch); +} +``` + +### List a Container + +```typescript +import { getSolidDataset, getContainedResourceUrlAll } from "@inrupt/solid-client"; + +const container = await getSolidDataset(containerUrl, { fetch }); +const resourceUrls = getContainedResourceUrlAll(container); +``` + +--- + +## Troubleshooting + +| Problem | Likely Cause | Fix | +|---------|-------------|-----| +| 401 Unauthorized | Not using session `fetch` | Import `fetch` from authn library; pass to all solid-client calls | +| 403 Forbidden | Insufficient ACL/ACP | Check access control on the resource | +| 409 Conflict on save | Outdated dataset / ETag mismatch | Re-fetch the dataset before saving | +| CORS error in browser | Server CORS config | Use a server that supports CORS or proxy in dev | +| Session lost on refresh | `handleIncomingRedirect` not called | Call with `restorePreviousSession: true` on every page load | +| DPoP error | Token not DPoP-bound | Always use the authn library's `fetch`; never strip headers | + +--- + +## Key Links + +| Resource | URL | +|----------|-----| +| Inrupt SDK docs | https://docs.inrupt.com/sdk/javascript-sdk | +| @inrupt/solid-client npm | https://www.npmjs.com/package/@inrupt/solid-client | +| solid-client-authn-js | https://inrupt.github.io/solid-client-authn-js/ | +| solid-client-authn-js repo | https://github.com/inrupt/solid-client-authn-js | +| LDO docs | https://ldo.js.org/1.0.0-alpha.X/ | +| @ldo/connected-solid | https://ldo.js.org/1.0.0-alpha.X/api/connected-solid/ | +| N3.js repo | https://github.com/rdfjs/N3.js | +| N3.js docs | https://rdf.js.org/N3.js/ | +| Comunica (SPARQL over N3 Store) | https://comunica.dev/docs/query/advanced/rdfjs_querying/ | diff --git a/apps/extension/.skills/playwright.md b/apps/extension/.skills/playwright.md new file mode 100644 index 000000000..98c82145c --- /dev/null +++ b/apps/extension/.skills/playwright.md @@ -0,0 +1,453 @@ +--- +name: playwright-skill +description: Complete browser automation with Playwright. Auto-detects dev servers, writes clean test scripts to /tmp. Test pages, fill forms, take screenshots, check responsive design, validate UX, test login flows, check links, automate any browser task. Use when user wants to test websites, automate browser interactions, validate web functionality, or perform any browser-based testing. +--- + +**IMPORTANT - Path Resolution:** +This skill can be installed in different locations (plugin system, manual installation, global, or project-specific). Before executing any commands, determine the skill directory based on where you loaded this SKILL.md file, and use that path in all commands below. Replace `$SKILL_DIR` with the actual discovered path. + +Common installation paths: + +- Plugin system: `~/.claude/plugins/marketplaces/playwright-skill/skills/playwright-skill` +- Manual global: `~/.claude/skills/playwright-skill` +- Project-specific: `/.claude/skills/playwright-skill` + +# Playwright Browser Automation + +General-purpose browser automation skill. I'll write custom Playwright code for any automation task you request and execute it via the universal executor. + +**CRITICAL WORKFLOW - Follow these steps in order:** + +1. **Auto-detect dev servers** - For localhost testing, ALWAYS run server detection FIRST: + + ```bash + cd $SKILL_DIR && node -e "require('./lib/helpers').detectDevServers().then(servers => console.log(JSON.stringify(servers)))" + ``` + + - If **1 server found**: Use it automatically, inform user + - If **multiple servers found**: Ask user which one to test + - If **no servers found**: Ask for URL or offer to help start dev server + +2. **Write scripts to /tmp** - NEVER write test files to skill directory; always use `/tmp/playwright-test-*.js` + +3. **Use visible browser by default** - Always use `headless: false` unless user specifically requests headless mode + +4. **Parameterize URLs** - Always make URLs configurable via environment variable or constant at top of script + +## How It Works + +1. You describe what you want to test/automate +2. I auto-detect running dev servers (or ask for URL if testing external site) +3. I write custom Playwright code in `/tmp/playwright-test-*.js` (won't clutter your project) +4. I execute it via: `cd $SKILL_DIR && node run.js /tmp/playwright-test-*.js` +5. Results displayed in real-time, browser window visible for debugging +6. Test files auto-cleaned from /tmp by your OS + +## Setup (First Time) + +```bash +cd $SKILL_DIR +npm run setup +``` + +This installs Playwright and Chromium browser. Only needed once. + +## Execution Pattern + +**Step 1: Detect dev servers (for localhost testing)** + +```bash +cd $SKILL_DIR && node -e "require('./lib/helpers').detectDevServers().then(s => console.log(JSON.stringify(s)))" +``` + +**Step 2: Write test script to /tmp with URL parameter** + +```javascript +// /tmp/playwright-test-page.js +const { chromium } = require('playwright'); + +// Parameterized URL (detected or user-provided) +const TARGET_URL = 'http://localhost:3001'; // <-- Auto-detected or from user + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + await page.goto(TARGET_URL); + console.log('Page loaded:', await page.title()); + + await page.screenshot({ path: '/tmp/screenshot.png', fullPage: true }); + console.log('📸 Screenshot saved to /tmp/screenshot.png'); + + await browser.close(); +})(); +``` + +**Step 3: Execute from skill directory** + +```bash +cd $SKILL_DIR && node run.js /tmp/playwright-test-page.js +``` + +## Common Patterns + +### Test a Page (Multiple Viewports) + +```javascript +// /tmp/playwright-test-responsive.js +const { chromium } = require('playwright'); + +const TARGET_URL = 'http://localhost:3001'; // Auto-detected + +(async () => { + const browser = await chromium.launch({ headless: false, slowMo: 100 }); + const page = await browser.newPage(); + + // Desktop test + await page.setViewportSize({ width: 1920, height: 1080 }); + await page.goto(TARGET_URL); + console.log('Desktop - Title:', await page.title()); + await page.screenshot({ path: '/tmp/desktop.png', fullPage: true }); + + // Mobile test + await page.setViewportSize({ width: 375, height: 667 }); + await page.screenshot({ path: '/tmp/mobile.png', fullPage: true }); + + await browser.close(); +})(); +``` + +### Test Login Flow + +```javascript +// /tmp/playwright-test-login.js +const { chromium } = require('playwright'); + +const TARGET_URL = 'http://localhost:3001'; // Auto-detected + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + await page.goto(`${TARGET_URL}/login`); + + await page.fill('input[name="email"]', 'test@example.com'); + await page.fill('input[name="password"]', 'password123'); + await page.click('button[type="submit"]'); + + // Wait for redirect + await page.waitForURL('**/dashboard'); + console.log('✅ Login successful, redirected to dashboard'); + + await browser.close(); +})(); +``` + +### Fill and Submit Form + +```javascript +// /tmp/playwright-test-form.js +const { chromium } = require('playwright'); + +const TARGET_URL = 'http://localhost:3001'; // Auto-detected + +(async () => { + const browser = await chromium.launch({ headless: false, slowMo: 50 }); + const page = await browser.newPage(); + + await page.goto(`${TARGET_URL}/contact`); + + await page.fill('input[name="name"]', 'John Doe'); + await page.fill('input[name="email"]', 'john@example.com'); + await page.fill('textarea[name="message"]', 'Test message'); + await page.click('button[type="submit"]'); + + // Verify submission + await page.waitForSelector('.success-message'); + console.log('✅ Form submitted successfully'); + + await browser.close(); +})(); +``` + +### Check for Broken Links + +```javascript +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + await page.goto('http://localhost:3000'); + + const links = await page.locator('a[href^="http"]').all(); + const results = { working: 0, broken: [] }; + + for (const link of links) { + const href = await link.getAttribute('href'); + try { + const response = await page.request.head(href); + if (response.ok()) { + results.working++; + } else { + results.broken.push({ url: href, status: response.status() }); + } + } catch (e) { + results.broken.push({ url: href, error: e.message }); + } + } + + console.log(`✅ Working links: ${results.working}`); + console.log(`❌ Broken links:`, results.broken); + + await browser.close(); +})(); +``` + +### Take Screenshot with Error Handling + +```javascript +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + try { + await page.goto('http://localhost:3000', { + waitUntil: 'networkidle', + timeout: 10000, + }); + + await page.screenshot({ + path: '/tmp/screenshot.png', + fullPage: true, + }); + + console.log('📸 Screenshot saved to /tmp/screenshot.png'); + } catch (error) { + console.error('❌ Error:', error.message); + } finally { + await browser.close(); + } +})(); +``` + +### Test Responsive Design + +```javascript +// /tmp/playwright-test-responsive-full.js +const { chromium } = require('playwright'); + +const TARGET_URL = 'http://localhost:3001'; // Auto-detected + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + const viewports = [ + { name: 'Desktop', width: 1920, height: 1080 }, + { name: 'Tablet', width: 768, height: 1024 }, + { name: 'Mobile', width: 375, height: 667 }, + ]; + + for (const viewport of viewports) { + console.log( + `Testing ${viewport.name} (${viewport.width}x${viewport.height})`, + ); + + await page.setViewportSize({ + width: viewport.width, + height: viewport.height, + }); + + await page.goto(TARGET_URL); + await page.waitForTimeout(1000); + + await page.screenshot({ + path: `/tmp/${viewport.name.toLowerCase()}.png`, + fullPage: true, + }); + } + + console.log('✅ All viewports tested'); + await browser.close(); +})(); +``` + +## Inline Execution (Simple Tasks) + +For quick one-off tasks, you can execute code inline without creating files: + +```bash +# Take a quick screenshot +cd $SKILL_DIR && node run.js " +const browser = await chromium.launch({ headless: false }); +const page = await browser.newPage(); +await page.goto('http://localhost:3001'); +await page.screenshot({ path: '/tmp/quick-screenshot.png', fullPage: true }); +console.log('Screenshot saved'); +await browser.close(); +" +``` + +**When to use inline vs files:** + +- **Inline**: Quick one-off tasks (screenshot, check if element exists, get page title) +- **Files**: Complex tests, responsive design checks, anything user might want to re-run + +## Available Helpers + +Optional utility functions in `lib/helpers.js`: + +```javascript +const helpers = require('./lib/helpers'); + +// Detect running dev servers (CRITICAL - use this first!) +const servers = await helpers.detectDevServers(); +console.log('Found servers:', servers); + +// Safe click with retry +await helpers.safeClick(page, 'button.submit', { retries: 3 }); + +// Safe type with clear +await helpers.safeType(page, '#username', 'testuser'); + +// Take timestamped screenshot +await helpers.takeScreenshot(page, 'test-result'); + +// Handle cookie banners +await helpers.handleCookieBanner(page); + +// Extract table data +const data = await helpers.extractTableData(page, 'table.results'); +``` + +See `lib/helpers.js` for full list. + +## Custom HTTP Headers + +Configure custom headers for all HTTP requests via environment variables. Useful for: + +- Identifying automated traffic to your backend +- Getting LLM-optimized responses (e.g., plain text errors instead of styled HTML) +- Adding authentication tokens globally + +### Configuration + +**Single header (common case):** + +```bash +PW_HEADER_NAME=X-Automated-By PW_HEADER_VALUE=playwright-skill \ + cd $SKILL_DIR && node run.js /tmp/my-script.js +``` + +**Multiple headers (JSON format):** + +```bash +PW_EXTRA_HEADERS='{"X-Automated-By":"playwright-skill","X-Debug":"true"}' \ + cd $SKILL_DIR && node run.js /tmp/my-script.js +``` + +### How It Works + +Headers are automatically applied when using `helpers.createContext()`: + +```javascript +const context = await helpers.createContext(browser); +const page = await context.newPage(); +// All requests from this page include your custom headers +``` + +For scripts using raw Playwright API, use the injected `getContextOptionsWithHeaders()`: + +```javascript +const context = await browser.newContext( + getContextOptionsWithHeaders({ viewport: { width: 1920, height: 1080 } }), +); +``` + +## Advanced Usage + +For comprehensive Playwright API documentation, see [API_REFERENCE.md](API_REFERENCE.md): + +- Selectors & Locators best practices +- Network interception & API mocking +- Authentication & session management +- Visual regression testing +- Mobile device emulation +- Performance testing +- Debugging techniques +- CI/CD integration + +## Tips + +- **CRITICAL: Detect servers FIRST** - Always run `detectDevServers()` before writing test code for localhost testing +- **Custom headers** - Use `PW_HEADER_NAME`/`PW_HEADER_VALUE` env vars to identify automated traffic to your backend +- **Use /tmp for test files** - Write to `/tmp/playwright-test-*.js`, never to skill directory or user's project +- **Parameterize URLs** - Put detected/provided URL in a `TARGET_URL` constant at the top of every script +- **DEFAULT: Visible browser** - Always use `headless: false` unless user explicitly asks for headless mode +- **Headless mode** - Only use `headless: true` when user specifically requests "headless" or "background" execution +- **Slow down:** Use `slowMo: 100` to make actions visible and easier to follow +- **Wait strategies:** Use `waitForURL`, `waitForSelector`, `waitForLoadState` instead of fixed timeouts +- **Error handling:** Always use try-catch for robust automation +- **Console output:** Use `console.log()` to track progress and show what's happening + +## Troubleshooting + +**Playwright not installed:** + +```bash +cd $SKILL_DIR && npm run setup +``` + +**Module not found:** +Ensure running from skill directory via `run.js` wrapper + +**Browser doesn't open:** +Check `headless: false` and ensure display available + +**Element not found:** +Add wait: `await page.waitForSelector('.element', { timeout: 10000 })` + +## Example Usage + +``` +User: "Test if the marketing page looks good" + +Claude: I'll test the marketing page across multiple viewports. Let me first detect running servers... +[Runs: detectDevServers()] +[Output: Found server on port 3001] +I found your dev server running on http://localhost:3001 + +[Writes custom automation script to /tmp/playwright-test-marketing.js with URL parameterized] +[Runs: cd $SKILL_DIR && node run.js /tmp/playwright-test-marketing.js] +[Shows results with screenshots from /tmp/] +``` + +``` +User: "Check if login redirects correctly" + +Claude: I'll test the login flow. First, let me check for running servers... +[Runs: detectDevServers()] +[Output: Found servers on ports 3000 and 3001] +I found 2 dev servers. Which one should I test? +- http://localhost:3000 +- http://localhost:3001 + +User: "Use 3001" + +[Writes login automation to /tmp/playwright-test-login.js] +[Runs: cd $SKILL_DIR && node run.js /tmp/playwright-test-login.js] +[Reports: ✅ Login successful, redirected to /dashboard] +``` + +## Notes + +- Each automation is custom-written for your specific request +- Not limited to pre-built scripts - any browser task possible +- Auto-detects running dev servers to eliminate hardcoded URLs +- Test scripts written to `/tmp` for automatic cleanup (no clutter) +- Code executes reliably with proper module resolution via `run.js` +- Progressive disclosure - API_REFERENCE.md loaded only when advanced features needed diff --git a/apps/extension/.skills/servers.md b/apps/extension/.skills/servers.md new file mode 100644 index 000000000..8a48e456a --- /dev/null +++ b/apps/extension/.skills/servers.md @@ -0,0 +1,221 @@ +# Solid Servers Skill + +You are an expert on Solid server implementations — both public hosted servers and local development setups. Use this knowledge to help developers choose, configure, run, and debug Solid servers. + +--- + +## Public Solid Servers + +| Server | URL | Notes | +|--------|-----|-------| +| solidcommunity.net | https://solidcommunity.net | Powered by Pivot CSS; widely used community server | +| solidweb.org | https://solidweb.org | Community-hosted | +| start.inrupt.com | https://start.inrupt.com | Inrupt's hosted pod service (ESS) | + +### Choosing a Public Server or allow interoperability for all servers. Ask the user + +- **solidcommunity.net** — best for open community apps; uses older DPoP-based OIDC (Pivot flavour) +- **solidweb.org** — alternative community option +- **start.inrupt.com (ESS)** — enterprise-grade, uses Inrupt's Enterprise Solid Server; best compatibility with Inrupt SDK + +--- + +## Community Solid Server (CSS) + +**Repo**: https://github.com/CommunitySolidServer/CommunitySolidServer +**Docs**: https://communitysolidserver.github.io/CommunitySolidServer/latest/ + +The reference open-source implementation of the Solid specification. Highly modular and configurable. + +### Requirements + +- Node.js 18+ +- npm or npx + +### Quick Start (in-memory, ephemeral) + +```bash +npx @solid/community-server +``` + +Server available at http://localhost:3000/ + +### Persistent File-Based Storage + +```bash +npx @solid/community-server -c @css:config/file.json -f ./data +``` + +Data stored in `./data/` directory. + +### Global Install + +```bash +npm install -g @solid/community-server +community-solid-server -c @css:config/file.json -f ./data +``` + +### Key CLI Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--port, -p` | `3000` | TCP port to listen on | +| `--baseUrl, -b` | `http://localhost:3000/` | Base URL used in generated resource URIs | +| `--config, -c` | `@css:config/default.json` | Configuration preset | +| `--rootFilePath, -f` | `./` | Directory for file-based storage | +| `--loggingLevel, -l` | `info` | Log level (`debug`, `info`, `warn`, `error`) | +| `--workers, -w` | `1` | Number of worker threads | +| `--sparqlEndpoint` | — | SPARQL endpoint URL for SPARQL-backed storage | + +### Environment Variables + +All flags can be set as environment variables using the `CSS_` prefix: + +```bash +CSS_PORT=4000 +CSS_LOGGING_LEVEL=debug +CSS_ROOT_FILE_PATH=/var/solid/data +``` + +### Storage Backends + +| Config | Backend | Use case | +|--------|---------|---------| +| `@css:config/default.json` | In-memory | Quick testing, no persistence | +| `@css:config/file.json` | File system | Local dev with persistence | +| `--sparqlEndpoint` | SPARQL | Production or graph DB backends | + +### Docker + +```bash +# Run with file-based storage mounted at ~/Solid +docker run --rm \ + -v ~/Solid:/data \ + -p 3000:3000 \ + solidproject/community-server:latest \ + -c @css:config/file.json -f /data +``` + +### Kubernetes + +Use the official Helm chart: https://github.com/CommunitySolidServer/css-helm-chart + +### Build from Source + +```bash +git clone https://github.com/CommunitySolidServer/CommunitySolidServer +cd CommunitySolidServer +npm ci +npm start +``` + +### Access Control + +CSS supports both **WAC** (Web Access Control) and **ACP** (Access Control Policy) depending on configuration. Default config uses WAC. + +--- + +## Pivot CSS + +**Repo**: https://github.com/solid-contrib/pivot +**Used by**: solidcommunity.net + +Pivot is a spec-compliant Solid server built as a thin wrapper/remix of CSS. It takes a different philosophy on consent and access control. + +### Key Differences from CSS + +| Aspect | CSS | Pivot | +|--------|-----|-------| +| OIDC version | Latest Solid-OIDC (UMA) | Older DPoP-based OIDC | +| Access control model | WAC (default) or ACP | User-centric, DPoP | +| Auth approach | Standard | App gets full pod access when authenticated | +| Focus | Reference implementation | Simplified consent management | + +### When to Use Pivot + +- Building apps that target **solidcommunity.net** users +- When you need the exact server behaviour of solidcommunity.net locally +- Testing compatibility with the older Solid-OIDC DPoP flow + +### Running Pivot Locally + +```bash +git clone https://github.com/solid-contrib/pivot +cd pivot +npm install +npm start +``` + +Default port: 3000 (same as CSS) + +--- + +## Local Development Tips + +### Running Two Servers Side-by-Side + +To test cross-server behaviour (e.g., Alice on CSS, Bob on Pivot): + +```bash +# CSS on port 3000 +npx @solid/community-server -p 3000 -c @css:config/file.json -f ./data-css + +# Pivot on port 3001 +cd pivot && PORT=3001 npm start +``` + +### Setting baseUrl for Deployed Environments + +When deploying behind a reverse proxy (Nginx, Caddy): + +```bash +npx @solid/community-server \ + -b https://solid.example.com/ \ + -c @css:config/file.json \ + -f /var/solid/data +``` + +The `--baseUrl` must match the public-facing URL exactly — resources are minted using this value. + +### Debugging + +```bash +npx @solid/community-server -l debug +``` + +Or via environment variable: + +```bash +CSS_LOGGING_LEVEL=debug npx @solid/community-server +``` + +### Creating a Test Account (CSS) + +Navigate to http://localhost:3000/ — CSS provides a UI for pod registration when running locally. + +--- + +## Conformance Testing + +Use the Solid conformance test harness to verify your server implementation: + +- Repo: https://github.com/solid-contrib/conformance-test-harness +- Inrupt test suite info: https://www.inrupt.com/blog/conformance-test-suite + +```bash +git clone https://github.com/solid-contrib/conformance-test-harness +cd conformance-test-harness +# See repo README for configuration and execution steps +``` + +--- + +## Quick Reference + +| Task | Command | +|------|---------| +| Start CSS (ephemeral) | `npx @solid/community-server` | +| Start CSS (persistent) | `npx @solid/community-server -c @css:config/file.json -f ./data` | +| Start CSS (custom port) | `npx @solid/community-server -p 4000` | +| Start CSS (Docker) | `docker run --rm -v ~/Solid:/data -p 3000:3000 solidproject/community-server:latest -c @css:config/file.json -f /data` | +| Debug logging | `npx @solid/community-server -l debug` | diff --git a/apps/extension/.skills/spec.md b/apps/extension/.skills/spec.md new file mode 100644 index 000000000..67234bd15 --- /dev/null +++ b/apps/extension/.skills/spec.md @@ -0,0 +1,315 @@ +# Solid Specifications Skill + +You are an expert on the Solid Protocol and its companion specifications. Use this knowledge to help developers understand, implement, and debug Solid-compliant applications. + +## Core Specifications + +| Spec | Version | URL | Purpose | +|------|---------|-----|---------| +| Solid Protocol | v0.11.0 | https://solidproject.org/TR/protocol | Core data pod and resource access rules | +| WebID Profile | v1.0.0 | https://solid.github.io/webid-profile/ | Identity and agent description | +| Solid-OIDC | v0.1.0 | https://solidproject.org/TR/oidc | Decentralised authentication | +| Access Control Policy (ACP) | v0.9.0 | https://solidproject.org/TR/acp | Fine-grained authorisation | + +--- + +## 1. Solid Protocol + +**What it is**: The foundation spec defining how data pods expose resources over HTTP using Linked Data principles. + +### Key Concepts + +**Storage**: A space of URIs giving agents controlled access to resources. Each storage has a root container. + +**Resources and Containers**: Resources live at HTTP URIs. Container URIs end with `/`. Containers hold other resources and containers, forming a hierarchy. + +**WebID**: An HTTP URI that identifies an agent. Dereferencing it returns an RDF document describing them. + +### HTTP Behaviour + +- Servers MUST support GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE +- All write requests MUST include `Content-Type`; missing headers → 400 +- Servers MUST support Turtle (`text/turtle`) and JSON-LD (`application/ld+json`) +- HTTPS is required; HTTP → HTTPS redirect if both schemes are exposed + +### Content Negotiation + +``` +GET /profile/card HTTP/1.1 +Accept: text/turtle +``` + +Servers must honour `Accept` headers and respond with matching RDF serialisation. + +### PATCH with N3 + +``` +PATCH /data/notes.ttl HTTP/1.1 +Content-Type: text/n3 + +@prefix solid: . +<> a solid:InsertDeletePatch; + solid:inserts { <#note1> "Hello world" . }. +``` + +- 409 Conflict if conditions produce zero or multiple matches. + +### Auxiliary Resources + +- **ACL resources**: control access (`Link: ; rel="acl"`) +- **Description resources**: metadata about a resource (`Link: ; rel="describedby"`) +- Auxiliary resources are deleted automatically when their parent is deleted. + +### Notifications + +The Solid Notifications Protocol allows clients to subscribe to resource changes. Servers act as Resource Server, Subscription Server, Notification Sender, and Notification Receiver. + +### Common Mistakes + +- Forgetting trailing `/` on container URIs +- Not sending `Content-Type` on PATCH/PUT +- Using WAC and ACP interchangeably — servers implement one or both, check which +- Assuming CORS is sufficient for access control — always use ACL/ACP + +--- + +## 2. WebID Profile + +**What it is**: An RDF document at a WebID URI describing a social agent's identity, storage locations, and preferences. + +### Minimum Required Triples + +```turtle +@prefix foaf: . +@prefix pim: . + + + a foaf:Agent ; + pim:preferencesFile ; + pim:storage . +``` + +### Key Predicates + +| Predicate | Required | Description | +|-----------|----------|-------------| +| `rdf:type foaf:Agent` | Yes | Identifies the subject as an agent | +| `pim:preferencesFile` | Yes | Pointer to private preferences document | +| `pim:storage` | Recommended | Location of the agent's pod storage | +| `solid:oidcIssuer` | Recommended | The OIDC Identity Provider for this WebID | +| `ldp:inbox` | Optional | Inbox for incoming notifications | +| `rdfs:seeAlso` | Optional | Links to extended profile documents | + +### Preferences Document + +- Must be typed as `pim:ConfigurationFile` +- Private (not publicly readable) +- Holds app settings and personalisation data + +### Type Index + +- Public and private type index documents link resource types to storage locations +- Discovered via the WebID profile +- Enables apps to find where a user stores specific types of data + +### Modifying a Profile + +Use PATCH (N3 Patch) for targeted updates. Servers protect `solid:oidcIssuer` — attempting to change it returns 409. + +```turtle +# Read profile +GET /profile/card HTTP/1.1 +Accept: text/turtle + +# Patch profile to add name +PATCH /profile/card HTTP/1.1 +Content-Type: text/n3 + +@prefix solid: . +@prefix foaf: . +<> a solid:InsertDeletePatch; + solid:inserts { <#me> foaf:name "Alice" . }. +``` + +--- + +## 3. Solid-OIDC + +**What it is**: An authentication layer built on OAuth 2.0 + OpenID Connect that works without pre-established trust between resource servers and identity providers. + +### Flow Overview + +1. App redirects user to their OIDC Identity Provider (IdP) +2. IdP issues a DPoP-bound ID Token containing the `webid` claim +3. App presents the ID Token + DPoP proof to the Resource Server +4. Resource Server validates the token by checking the WebID profile for `solid:oidcIssuer` + +### Authorization Code Flow with PKCE + +This is the **mandatory** flow. Never use Implicit Flow. + +``` +1. App generates code_verifier + code_challenge (PKCE) +2. Redirect → IdP /authorize?response_type=code&code_challenge=... +3. IdP issues auth code +4. App exchanges code for tokens at /token endpoint +5. App receives id_token + access_token (DPoP-bound) +``` + +### DPoP-Bound ID Token Claims + +```json +{ + "webid": "https://alice.solidcommunity.net/profile/card#me", + "iss": "https://solidcommunity.net", + "aud": ["https://myapp.example", "solid"], + "cnf": { "jkt": "" }, + "exp": 1700000000, + "iat": 1699996400 +} +``` + +### DPoP Proof Header + +Every resource request must include a DPoP proof JWT: + +``` +Authorization: DPoP +DPoP: +``` + +### Client Identification + +Two options: +1. **Dereferenceable URI** — client ID is a URL that returns a JSON-LD client registration document +2. **Traditional OIDC registration** — dynamic or static client registration (optional in decentralised context) + +### WebID Issuer Validation + +Resource servers must verify that the `iss` claim in the ID Token matches a `solid:oidcIssuer` triple in the user's WebID Profile. This prevents token misuse. + +### Common Mistakes + +- Using Implicit Flow (not supported) +- Forgetting to include `"solid"` in the `aud` claim +- Not binding tokens with DPoP +- Checking the wrong WebID for issuer validation + +--- + +## 4. Access Control Policy (ACP) + +**What it is**: An RDF-based language for defining who can access what on a Solid pod, using a deny-overrides policy model. + +### Core Objects + +``` +Resource ──has ACR──▶ Access Control Resource (ACR) + │ + Access Control + │ + Policy + ┌────┴────┐ + Allow Deny + │ │ + Matcher Matcher +``` + +### Access Control Resource (ACR) + +Each resource has an associated ACR linked via: + +``` +Link: ; rel="http://www.w3.org/ns/solid/acp#accessControl" +``` + +### Matchers + +Matchers define conditions for a policy. They evaluate against the **request context** (agent, client, issuer, target resource). + +```turtle +@prefix acp: . + +<#matcher-alice> + a acp:Matcher ; + acp:agent . +``` + +Special agents: +- `acp:PublicAgent` — anyone (including unauthenticated) +- `acp:AuthenticatedAgent` — any authenticated user +- `acp:CreatorAgent` — whoever created the resource +- `acp:OwnerAgent` — the pod owner + +### Policies + +```turtle +<#policy-read> + a acp:Policy ; + acp:allow acp:Read ; + acp:anyOf <#matcher-alice> . +``` + +Policy satisfaction logic: + +| Condition | Meaning | +|-----------|---------| +| `acp:allOf` | ALL listed matchers must match | +| `acp:anyOf` | AT LEAST ONE listed matcher must match | +| `acp:noneOf` | NONE of the listed matchers must match | + +### Access Control (connecting ACR to Policy) + +```turtle +<#access-control> + a acp:AccessControl ; + acp:apply <#policy-read> . +``` + +### Grant Logic + +Access mode is granted **if and only if**: +- At least one satisfied policy **allows** it, AND +- No satisfied policy **denies** it + +**Deny overrides allow.** + +### Common Access Patterns + +**Public read access:** +```turtle +<#matcher-public> a acp:Matcher ; acp:agent acp:PublicAgent . +<#policy-public> a acp:Policy ; acp:allow acp:Read ; acp:anyOf <#matcher-public> . +``` + +**Authenticated write access:** +```turtle +<#matcher-auth> a acp:Matcher ; acp:agent acp:AuthenticatedAgent . +<#policy-write> a acp:Policy ; acp:allow acp:Write, acp:Append ; acp:anyOf <#matcher-auth> . +``` + +### ACP vs WAC + +| | ACP | WAC | +|-|-----|-----| +| Model | Policy + Matchers (RDF) | ACL rules (RDF) | +| Deny support | Yes (deny overrides) | Limited | +| Granularity | Fine-grained, composable | Basic | +| Spec status | Active development | Older, widely deployed | + +--- + +## Quick Reference: Spec URLs + +- Solid Protocol: https://solidproject.org/TR/protocol +- WebID Profile: https://solid.github.io/webid-profile/ +- Solid-OIDC: https://solidproject.org/TR/oidc +- ACP: https://solidproject.org/TR/acp +- Solid Notifications: https://solidproject.org/TR/notifications-protocol +- Web Access Control: https://solidproject.org/TR/wac + +--- + +## Other awesome skills + +For more Claude/LLM skills (document processing, dev tools, automation, MCP, etc.), see [Awesome Claude Skills](https://github.com/ComposioHQ/awesome-claude-skills). diff --git a/apps/extension/.skills/style-guide.md b/apps/extension/.skills/style-guide.md new file mode 100644 index 000000000..68796a4ea --- /dev/null +++ b/apps/extension/.skills/style-guide.md @@ -0,0 +1,128 @@ +# Solid Style Guide Skill + +You are an expert on Solid Project brand and visual identity guidelines. Use this knowledge to help teams correctly represent the Solid brand in apps, communications, events, and assets. + +**Official guidelines**: https://solidproject.org/logo_usage_guidelines + +--- + +## Brand Colours + +### Primary Colour + +| Name | Hex | RGB | +|------|-----|-----| +| Royal Lavender | `#7C4DFF` | R124 G77 B255 | + +Royal Lavender is the core brand colour. It is used: +- As a background for the outlined Solid logo variant +- In mastheads and footers +- For primary CTA (call-to-action) buttons + +### Secondary Colours + +| Name | Hex | Notes | +|------|-----|-------| +| Deep Sky Blue | (part of gradient) | Used in the brand gradient | +| Cerulean Blue | (part of gradient) | Used in the brand gradient | + +### The Solid Gradient + +The gradient combines Royal Lavender, Deep Sky Blue, and Cerulean Blue in equal thirds (33/33/33). + +**Allowed uses:** +- As a background +- As an overlay on a greyscale image + +**Not allowed uses:** +- In the logo itself +- In typography +- In icons or other graphics + +--- + +## Logo + +### Two Official Variants + +1. **Standard logo** — works on any flat colour background or overlaid on an image +2. **Outlined logo** — to be used **only** on a Royal Lavender (`#7C4DFF`) background + +### Logo Usage Rules + +**Do:** +- Use the logo on clean, flat-colour backgrounds +- Use the outlined variant on Royal Lavender backgrounds only +- Maintain clear space (exclusion zone) around the logo +- Download official logo files from solidproject.org + +**Do not:** +- Use the logo on complex patterns +- Place the logo in close proximity to other logos, typography, or graphic elements +- Modify, recolour, rotate, or distort the logo +- Use the gradient in or behind the logo +- Use unofficial or recreated versions of the logo + +--- + +## Typography + +Use the typography guidance from the official style guide at https://solidproject.org/logo_usage_guidelines. Do not substitute unapproved fonts in Solid-branded materials. + +--- + +## Iconography and Graphics + +- Do not use the Solid gradient inside icons or graphics +- Keep graphic elements visually distinct from the logo +- Maintain visual hierarchy so the Solid logo is clearly identifiable + +--- + +## Application Contexts + +The brand guidelines apply when representing Solid in: + +| Context | Guidance | +|---------|---------| +| Apps | Use approved logo + colours in UI; do not modify logo | +| Communications (email, docs) | Follow colour and typography rules | +| Events | Use official logo files; follow exclusion zone rules | +| Projects (repos, sites) | Do not use the gradient in logo or wordmark | +| Assets (banners, icons) | Outlined logo on Royal Lavender only | + +--- + +## Quick Colour Reference + +```css +/* Primary brand colour */ +--solid-royal-lavender: #7C4DFF; + +/* Use for: mastheads, footers, primary CTAs, outlined logo background */ +background-color: #7C4DFF; +``` + +--- + +## Getting Official Assets + +All official Solid logos and brand assets are available at: +https://solidproject.org/logo_usage_guidelines + +logo reference + + + + + + + + + + +icons reference +https://heroicons.com/ + + +Always use official assets — do not recreate or approximate the logo. diff --git a/apps/extension/.skills/test-driven-development.md b/apps/extension/.skills/test-driven-development.md new file mode 100644 index 000000000..34a9f2b25 --- /dev/null +++ b/apps/extension/.skills/test-driven-development.md @@ -0,0 +1,371 @@ +--- +name: test-driven-development +description: Use when implementing any feature or bugfix, before writing implementation code +--- + +# Test-Driven Development (TDD) + +## Overview + +Write the test first. Watch it fail. Write minimal code to pass. + +**Core principle:** If you didn't watch the test fail, you don't know if it tests the right thing. + +**Violating the letter of the rules is violating the spirit of the rules.** + +## When to Use + +**Always:** +- New features +- Bug fixes +- Refactoring +- Behavior changes + +**Exceptions (ask your human partner):** +- Throwaway prototypes +- Generated code +- Configuration files + +Thinking "skip TDD just this once"? Stop. That's rationalization. + +## The Iron Law + +``` +NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST +``` + +Write code before the test? Delete it. Start over. + +**No exceptions:** +- Don't keep it as "reference" +- Don't "adapt" it while writing tests +- Don't look at it +- Delete means delete + +Implement fresh from tests. Period. + +## Red-Green-Refactor + +```dot +digraph tdd_cycle { + rankdir=LR; + red [label="RED\nWrite failing test", shape=box, style=filled, fillcolor="#ffcccc"]; + verify_red [label="Verify fails\ncorrectly", shape=diamond]; + green [label="GREEN\nMinimal code", shape=box, style=filled, fillcolor="#ccffcc"]; + verify_green [label="Verify passes\nAll green", shape=diamond]; + refactor [label="REFACTOR\nClean up", shape=box, style=filled, fillcolor="#ccccff"]; + next [label="Next", shape=ellipse]; + + red -> verify_red; + verify_red -> green [label="yes"]; + verify_red -> red [label="wrong\nfailure"]; + green -> verify_green; + verify_green -> refactor [label="yes"]; + verify_green -> green [label="no"]; + refactor -> verify_green [label="stay\ngreen"]; + verify_green -> next; + next -> red; +} +``` + +### RED - Write Failing Test + +Write one minimal test showing what should happen. + + +```typescript +test('retries failed operations 3 times', async () => { + let attempts = 0; + const operation = () => { + attempts++; + if (attempts < 3) throw new Error('fail'); + return 'success'; + }; + + const result = await retryOperation(operation); + + expect(result).toBe('success'); + expect(attempts).toBe(3); +}); +``` +Clear name, tests real behavior, one thing + + + +```typescript +test('retry works', async () => { + const mock = jest.fn() + .mockRejectedValueOnce(new Error()) + .mockRejectedValueOnce(new Error()) + .mockResolvedValueOnce('success'); + await retryOperation(mock); + expect(mock).toHaveBeenCalledTimes(3); +}); +``` +Vague name, tests mock not code + + +**Requirements:** +- One behavior +- Clear name +- Real code (no mocks unless unavoidable) + +### Verify RED - Watch It Fail + +**MANDATORY. Never skip.** + +```bash +npm test path/to/test.test.ts +``` + +Confirm: +- Test fails (not errors) +- Failure message is expected +- Fails because feature missing (not typos) + +**Test passes?** You're testing existing behavior. Fix test. + +**Test errors?** Fix error, re-run until it fails correctly. + +### GREEN - Minimal Code + +Write simplest code to pass the test. + + +```typescript +async function retryOperation(fn: () => Promise): Promise { + for (let i = 0; i < 3; i++) { + try { + return await fn(); + } catch (e) { + if (i === 2) throw e; + } + } + throw new Error('unreachable'); +} +``` +Just enough to pass + + + +```typescript +async function retryOperation( + fn: () => Promise, + options?: { + maxRetries?: number; + backoff?: 'linear' | 'exponential'; + onRetry?: (attempt: number) => void; + } +): Promise { + // YAGNI +} +``` +Over-engineered + + +Don't add features, refactor other code, or "improve" beyond the test. + +### Verify GREEN - Watch It Pass + +**MANDATORY.** + +```bash +npm test path/to/test.test.ts +``` + +Confirm: +- Test passes +- Other tests still pass +- Output pristine (no errors, warnings) + +**Test fails?** Fix code, not test. + +**Other tests fail?** Fix now. + +### REFACTOR - Clean Up + +After green only: +- Remove duplication +- Improve names +- Extract helpers + +Keep tests green. Don't add behavior. + +### Repeat + +Next failing test for next feature. + +## Good Tests + +| Quality | Good | Bad | +|---------|------|-----| +| **Minimal** | One thing. "and" in name? Split it. | `test('validates email and domain and whitespace')` | +| **Clear** | Name describes behavior | `test('test1')` | +| **Shows intent** | Demonstrates desired API | Obscures what code should do | + +## Why Order Matters + +**"I'll write tests after to verify it works"** + +Tests written after code pass immediately. Passing immediately proves nothing: +- Might test wrong thing +- Might test implementation, not behavior +- Might miss edge cases you forgot +- You never saw it catch the bug + +Test-first forces you to see the test fail, proving it actually tests something. + +**"I already manually tested all the edge cases"** + +Manual testing is ad-hoc. You think you tested everything but: +- No record of what you tested +- Can't re-run when code changes +- Easy to forget cases under pressure +- "It worked when I tried it" ≠ comprehensive + +Automated tests are systematic. They run the same way every time. + +**"Deleting X hours of work is wasteful"** + +Sunk cost fallacy. The time is already gone. Your choice now: +- Delete and rewrite with TDD (X more hours, high confidence) +- Keep it and add tests after (30 min, low confidence, likely bugs) + +The "waste" is keeping code you can't trust. Working code without real tests is technical debt. + +**"TDD is dogmatic, being pragmatic means adapting"** + +TDD IS pragmatic: +- Finds bugs before commit (faster than debugging after) +- Prevents regressions (tests catch breaks immediately) +- Documents behavior (tests show how to use code) +- Enables refactoring (change freely, tests catch breaks) + +"Pragmatic" shortcuts = debugging in production = slower. + +**"Tests after achieve the same goals - it's spirit not ritual"** + +No. Tests-after answer "What does this do?" Tests-first answer "What should this do?" + +Tests-after are biased by your implementation. You test what you built, not what's required. You verify remembered edge cases, not discovered ones. + +Tests-first force edge case discovery before implementing. Tests-after verify you remembered everything (you didn't). + +30 minutes of tests after ≠ TDD. You get coverage, lose proof tests work. + +## Common Rationalizations + +| Excuse | Reality | +|--------|---------| +| "Too simple to test" | Simple code breaks. Test takes 30 seconds. | +| "I'll test after" | Tests passing immediately prove nothing. | +| "Tests after achieve same goals" | Tests-after = "what does this do?" Tests-first = "what should this do?" | +| "Already manually tested" | Ad-hoc ≠ systematic. No record, can't re-run. | +| "Deleting X hours is wasteful" | Sunk cost fallacy. Keeping unverified code is technical debt. | +| "Keep as reference, write tests first" | You'll adapt it. That's testing after. Delete means delete. | +| "Need to explore first" | Fine. Throw away exploration, start with TDD. | +| "Test hard = design unclear" | Listen to test. Hard to test = hard to use. | +| "TDD will slow me down" | TDD faster than debugging. Pragmatic = test-first. | +| "Manual test faster" | Manual doesn't prove edge cases. You'll re-test every change. | +| "Existing code has no tests" | You're improving it. Add tests for existing code. | + +## Red Flags - STOP and Start Over + +- Code before test +- Test after implementation +- Test passes immediately +- Can't explain why test failed +- Tests added "later" +- Rationalizing "just this once" +- "I already manually tested it" +- "Tests after achieve the same purpose" +- "It's about spirit not ritual" +- "Keep as reference" or "adapt existing code" +- "Already spent X hours, deleting is wasteful" +- "TDD is dogmatic, I'm being pragmatic" +- "This is different because..." + +**All of these mean: Delete code. Start over with TDD.** + +## Example: Bug Fix + +**Bug:** Empty email accepted + +**RED** +```typescript +test('rejects empty email', async () => { + const result = await submitForm({ email: '' }); + expect(result.error).toBe('Email required'); +}); +``` + +**Verify RED** +```bash +$ npm test +FAIL: expected 'Email required', got undefined +``` + +**GREEN** +```typescript +function submitForm(data: FormData) { + if (!data.email?.trim()) { + return { error: 'Email required' }; + } + // ... +} +``` + +**Verify GREEN** +```bash +$ npm test +PASS +``` + +**REFACTOR** +Extract validation for multiple fields if needed. + +## Verification Checklist + +Before marking work complete: + +- [ ] Every new function/method has a test +- [ ] Watched each test fail before implementing +- [ ] Each test failed for expected reason (feature missing, not typo) +- [ ] Wrote minimal code to pass each test +- [ ] All tests pass +- [ ] Output pristine (no errors, warnings) +- [ ] Tests use real code (mocks only if unavoidable) +- [ ] Edge cases and errors covered + +Can't check all boxes? You skipped TDD. Start over. + +## When Stuck + +| Problem | Solution | +|---------|----------| +| Don't know how to test | Write wished-for API. Write assertion first. Ask your human partner. | +| Test too complicated | Design too complicated. Simplify interface. | +| Must mock everything | Code too coupled. Use dependency injection. | +| Test setup huge | Extract helpers. Still complex? Simplify design. | + +## Debugging Integration + +Bug found? Write failing test reproducing it. Follow TDD cycle. Test proves fix and prevents regression. + +Never fix bugs without a test. + +## Testing Anti-Patterns + +When adding mocks or test utilities, read @testing-anti-patterns.md to avoid common pitfalls: +- Testing mock behavior instead of real behavior +- Adding test-only methods to production classes +- Mocking without understanding dependencies + +## Final Rule + +``` +Production code → test exists and failed first +Otherwise → not TDD +``` + +No exceptions without your human partner's permission. \ No newline at end of file diff --git a/apps/extension/CLAUDE.md b/apps/extension/CLAUDE.md new file mode 100644 index 000000000..13ad3c2fc --- /dev/null +++ b/apps/extension/CLAUDE.md @@ -0,0 +1,148 @@ +# Solid Browser Extension + +## Overview + +A Chrome browser extension that enables Solid authentication at the browser level. Injects `window.solid` into all pages providing `.fetch`, `.webId`, `.setClientId()`, `.login()`, and `.logout()`. + +## Architecture + +``` +Page (MAIN world) <--postMessage--> Content Script (ISOLATED) <--chrome.runtime--> Service Worker + inject.ts content-script.ts service-worker.ts +``` + +- **inject.ts**: MAIN world script defining `window.solid` (no credential; postMessage only) +- **content-script.ts**: ISOLATED world bridge — the trust boundary; stamps the real page origin +- **service-worker.ts**: Background session manager + message router + the SOLE token holder +- **core/**: the security-critical, unit-testable core — + - `dpop.ts` (RFC 9449 DPoP proofs on Web Crypto + jose), + - `origin-policy.ts` (the fail-closed per-origin credential boundary), + - `authenticated-fetch.ts` (origin-gated resource fetch + §8 nonce retry), + - `www-authenticate.ts` (the use_dpop_nonce classifier), + - `webid.ts` (RDF issuer/profile resolution via N3.js) +- **auth-flow.ts**: Solid-OIDC auth-code + PKCE + DPoP via `chrome.identity.launchWebAuthFlow` +- **action-icon.ts**: the toolbar avatar (OffscreenCanvas render of photo/initials) +- **session-store.ts**: token/key/profile/recent-accounts persistence via `chrome.storage.local` +- **shared/messages.ts**: the JSON-serialisable 4-context message protocol + +## Auth + suite packages + +Solid-OIDC **authorization-code + PKCE + DPoP** via `chrome.identity.launchWebAuthFlow` (no +`window` in a service worker). DPoP proofs mirror `@jeswr/solid-dpop`'s RFC 9449 discipline but +are reimplemented on Web Crypto + jose because the service worker has no `node:crypto`. This app +lives in the solid-sdk monorepo (`apps/extension`) and takes `@jeswr/solid-elements` (the popup +web components) as a `workspace:*` dependency; `@jeswr/solid-dpop` is the proof-spec reference +only, not a dependency. **Never re-alias the components' own theme tokens; scope filled button +styles to `#login-form`** (the suite CSS-leak guards). + +## Security invariants (do not weaken) + +- The page NEVER receives a token; only the WebID + proxied fetch results. +- `core/origin-policy.ts` fails CLOSED: the token attaches only to the WebID/issuer/pod + origins, never a foreign origin, never `/token`, never over cleartext (loopback dev excepted). +- Page-supplied `Authorization`/`DPoP` headers are stripped; the content script stamps the REAL + page origin so a page can't impersonate another origin's client-id mapping. +- **Access management is OUT OF SCOPE** — `window.solid.requestAccess?` is a declared, + throwing SEAM only. Do not implement it without the access-management design track. + +## Gate + +`pnpm run gate` = lint (biome) + typecheck (tsc) + test (vitest, the core) + build (webpack). +e2e (`pnpm run test:e2e`) runs against a LOCAL Community Solid Server only, never the live box. + +## Build & Test + +```bash +pnpm install # From the solid-sdk root +pnpm --filter @jeswr/app-extension run build # Build extension to dist/ +pnpm --filter @jeswr/app-extension run build:dev # Build in watch mode +pnpm --filter @jeswr/app-extension run test:e2e # Build + run Playwright e2e tests +``` + +### E2E Testing + +- Runs in headless Chromium (`channel: 'chromium'`, the new headless mode, loads extensions); no display server needed +- Uses Community Solid Server on localhost:3000 with in-memory storage +- Test site served on a separate port +- Extension loaded from `dist/` via `--load-extension` + +## Skills + +Solid-specific skill documents are in `.skills/`: +- `spec.md` — Solid Protocol, WebID, Solid-OIDC, ACP +- `servers.md` — Community Solid Server +- `style-guide.md` — Solid brand guidelines (use for popup UI) +- `data-modelling.md` — RDF vocabularies, SHACL +- `integration-guide.md` — Client libraries + +## Documentation Lookups + +Use the Context7 MCP server for up-to-date library documentation. Prefer Context7 over training data for API specifics of: +- `jose` (DPoP proof signing / JWK) +- `@jeswr/solid-elements`, `@jeswr/solid-dpop` +- Playwright extension testing +- Community Solid Server configuration +- Chrome Extension MV3 APIs + +## Development Workflow + +This project uses **roborev** for continuous automated code review. Every git commit triggers an asynchronous review via a post-commit hook. Reviews are performed by GitHub Copilot (OpenAI o3) — a different model from the code-writing agent — to provide independent oversight. + +### Commit practices + +- **Commit frequently** — small, focused commits. Each commit is reviewed independently by roborev, so smaller commits produce more actionable feedback. +- **One concern per commit** — don't mix feature code, test changes, and config changes in a single commit. +- **Build before committing** — run `pnpm run build` to catch TypeScript/webpack errors. The reviewer can't run the build itself. +- **Run tests before committing** — run `pnpm run test:e2e` for any changes that touch the extension source, message protocol, or auth flow. + +### Reviewing and fixing + +After committing, roborev reviews asynchronously in the background. To interact with reviews: + +```bash +roborev tui # Interactive terminal UI — view all reviews +roborev show # Show review for current commit +roborev status # Check daemon health and queue +roborev fix # Auto-fix findings (runs in isolated worktree) +roborev refine # Iteratively fix → re-review → repeat until clean +``` + +Review verdicts: **P** (pass), **W** (warning), **F** (fail). + +### When roborev flags an issue + +1. Read the finding in `roborev tui` or `roborev show` +2. Either fix manually and commit, or run `roborev fix` to auto-remediate +3. If the finding is a false positive for this project (e.g. extractable DPoP keys, localhost CLIENT_ID), it is already suppressed in `.roborev.toml` review guidelines — if new patterns need suppressing, update the `review_guidelines` field there. + +### For AI agents (IMPORTANT — read before making any changes) + +**You MUST commit after every completed set of changes.** This is not optional. The roborev post-commit hook triggers an automated review by a separate model (GitHub Copilot / OpenAI o3), providing independent oversight of your work. If you do not commit, your changes are not reviewed, and issues may go undetected. + +Workflow: +1. Make your changes (code, config, docs, tests — whatever the task requires). +2. **Commit immediately** once the changes are complete and working. Do not wait for the user to ask you to commit. +3. After committing, run `roborev show` to check if the review has completed and whether there are findings. +4. If roborev flags issues, fix them and **commit again** (a new commit, not an amend). +5. For automated fix-review loops, use `roborev refine`. + +Rules: +- **Small, focused commits.** One concern per commit. Do not batch unrelated changes. +- **Build and test before committing.** Run `pnpm run build` and `pnpm run test:e2e` for source changes. +- **Never skip commits.** Even config-only or docs-only changes must be committed so they are reviewed. +- The `.roborev.toml` file configures review guidelines — read it to understand what the reviewer focuses on and what it ignores. + +## Key Conventions + +- TypeScript throughout, strict mode; biome lint + format +- Webpack bundler, 4 entry points (service-worker, popup, content-script, inject) +- Manifest V3 with `world: "MAIN"` for the `window.solid` injection +- DPoP keys stored as extractable JWK (the MV3 worker is killed and would lose a + non-extractable key, breaking the jkt-bound refresh) — kept in page-unreachable + `chrome.storage.local` +- Per-origin client ID via `solid.setClientId()`; a published Client Identifier Document + client_id with dynamic-registration dev fallback +- Popup themed via the app-shell OKLCH tokens (light/dark), consistent with Pod Manager +- WebID issuer + profile parsed with N3.js (proper RDF, never regex over Turtle) +- New source files carry an `AUTHORED-BY Claude Opus 4.8` marker; commits carry the Opus + provenance trailers diff --git a/apps/extension/LICENSE b/apps/extension/LICENSE new file mode 100644 index 000000000..2f396fc37 --- /dev/null +++ b/apps/extension/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jesse Wright + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/extension/PLAN.md b/apps/extension/PLAN.md new file mode 100644 index 000000000..ce794e278 --- /dev/null +++ b/apps/extension/PLAN.md @@ -0,0 +1,154 @@ +# Solid Browser Extension — Implementation Plan + +## Current baseline + +The `feat/phase-2-class-grants` implementation provides browser-level Solid sign-in, +`window.solid`, popup and side-panel account surfaces, a gated shared replica, and +per-data-class consent. This document records the implemented baseline; the broader +[shared-replica architecture](docs/design/shared-replica-architecture.md) remains a design +record with follow-up work. The [README](README.md#windowsolid) gives the public API types. + +## Architecture and project structure + +```text +Page (MAIN world) <-> Content script (ISOLATED) <-> Service worker + inject.ts content-script.ts service-worker.ts + postMessage chrome.runtime +``` + +The worker owns authentication, credentials, grants, and replica access. Page code receives +only token-free responses. The ISOLATED bridge stamps the real page origin; the worker +cross-checks it against Chrome's sender before authorizing a request. + +| Path | Implemented responsibility | +|------|----------------------------| +| `src/background/auth-flow.ts` | Solid-OIDC authorization code + PKCE + DPoP through `chrome.identity.launchWebAuthFlow` | +| `src/background/session-store.ts` | Session tokens, profile, recent accounts, client IDs, and grants in `chrome.storage.local` | +| `src/background/core/dpop-key-db.ts` / `dpop-key-store.ts` | Non-extractable private `CryptoKey` persistence in worker-owned IndexedDB | +| `src/background/core/authenticated-fetch.ts` / `origin-policy.ts` | Target-origin credential boundary, header stripping, and DPoP nonce retry | +| `src/background/core/requesting-origin.ts` | Default-deny app-origin verification and same-origin-only foreign fetch policy | +| `src/background/core/class-request.ts` / `request-access-throttle.ts` | Request validation, bounded expiry, user-gesture requirement, and prompt rate limit | +| `src/background/core/class-resolve.ts` | Solid Type Index class-to-target resolution confined to pod origins | +| `src/background/core/class-grant.ts` / `consent.ts` | Pinned-target grant model, mode/expiry checks, and per-class consent decisions | +| `src/background/core/replica*.ts` | Gated CacheStorage bytes, IndexedDB metadata/nonces, conditional revalidation, and write-through | +| `src/background/global-fetch-register.ts` / `src/inject/inject.ts` | Best-effort MAIN-world injection and global fetch routing | +| `src/shared/messages.ts` | Token-free page/bridge/worker message protocol and grant summaries | +| `src/consent/` | Extension-chrome consent window | +| `src/popup/` / `src/sidepanel/` | Shared account surface built with `@jeswr/solid-elements` | +| `e2e/` / `test-site/` | Headed Chromium tests against local Community Solid Server and app sites | + +The MV3 manifest declares `storage`, `identity`, `sidePanel`, `contextMenus`, and `scripting` +permissions and `` host access. Both content worlds are declared in the manifest; +the worker also attempts programmatic MAIN-world registration. Injection has an idempotency +guard. The broad matches enable discovery, while the worker's access checks enforce the +boundary. See [permission rationale](docs/manifest-permissions.md). + +## Implemented `window.solid` API + +The installed object is frozen and its window property is non-configurable. It exposes: + +| Member | Behavior | +|--------|----------| +| `webId` | Current WebID or `null`; the worker withholds identity from ungranted origins | +| `fetch(input, init?)` | Proxied request, gated before replica access or pod egress | +| `setClientId(clientId)` | Store a valid Client Identifier Document URL for the verified origin and grant it legacy whole-pod access | +| `login(webId)` | Start interactive Solid-OIDC login; successful verified page login grants its origin | +| `logout()` | Clear the local session and class grants, and purge replica data | +| `requestAccess({ classes, modes, purpose?, duration? })` | Open per-class consent and return `{ ok, grants }` | +| `listGrants()` | List this origin's live class-grant summaries for the active WebID | +| `revokeGrant(grantId)` | Remove this origin's class grant, purge its replica slice, and return whether removed | +| `resolveClass(classIRI)` | Return `{ shape, targets }` for an allowed class; currently `shape: null` | + +Presence is announced through `solid-extension:ready` and the sticky +`data-solid-extension="1"` marker on ``. Neither carries identity. The global fetch +patch is best-effort transparency: same-origin requests stay native, and cross-origin +HTTP(S) requests consult the worker. If it declines, a passthrough sentinel makes the page +use its captured native fetch with the original arguments. Explicit `solid.fetch` keeps +the worker's denial response. + +### Class-grant flow and enforcement + +1. Sign in through the extension. A click/keypress in an app calls `requestAccess` with + class IRIs, modes, an optional purpose, and a positive ISO-8601 duration. Although the + TypeScript argument permits omitting `duration`, runtime validation requires it and + caps the lifetime at 365 days (year/month components approximate 365/30 days). +2. Before discovery or consent, the worker verifies the origin and requires transient + activation stamped by the ISOLATED bridge. It admits at most three requests per origin + per minute with a best-effort in-memory throttle that resets on worker restart. +3. Resolve the WebID's public/private Type Index registrations (`solid:forClass` with + `solid:instance` / `solid:instanceContainer`) to concrete pod targets. The consent window + shows the origin, classes, modes, purpose, expiry, and targets. Only the extension's own + context may approve; each approved class mints one immutable grant. Deny-all returns + `{ ok: false, grants: [] }`; validation/session/throttle failures reject the promise. +4. Pin the resolved targets in each grant. Class-only origins must satisfy URL membership, + mode, active WebID, and expiry on every request, including a cache hit. Unknown classes + with no targets authorize nothing. Later resolution does not widen an existing grant. +5. `listGrants` filters by verified origin, active WebID, and expiry. `resolveClass` requires + an active session and granted origin; a class-only origin can inspect only a class it + currently holds. Full-grant origins can inspect any class. These APIs expose no tokens. +6. `revokeGrant` removes the grant before best-effort replica-slice purging, so purge failure + cannot preserve authorization. Bytes still covered by another live read grant for the + same origin are retained. Expired grants are pruned and their slices purged on access. + +Implemented modes are `read`, `append`, and `write` (case-insensitive). GET/HEAD require +read, POST requires append, and PUT/PATCH/DELETE require write. Write also covers append, +but does not imply read. Instances match exact resource paths; containers include +descendants. Purpose is an audit/display anchor, not an enforced restriction. + +Legacy whole-pod origin grants retain their broader access and take precedence over class +grants. Revoking a class grant does not remove that separate legacy origin authorization. +This is the existing compatibility behavior, not a migration to the later origin-grant model. + +## Authentication, storage, and replica + +Authentication uses Web Crypto + `jose`, with RDF issuer/profile discovery through N3.js. +The redirect is `chrome.identity.getRedirectURL('callback')`; no extension redirect page is +used. A page-declared Client ID Document is carried into login; otherwise the extension +tries its configured document and falls back to dynamic registration. The shipped hosted +URL and extension ID remain placeholders pending a separate hosting/pinning task. + +Access/refresh tokens stay in `chrome.storage.local`. The private DPoP key is non-extractable +and persists as a structured-cloned `CryptoKey` in IndexedDB across worker suspension. +Refresh reuses that key and persists rotated tokens before use. The audited +`@jeswr/solid-session-restore` decision supports silent restore. + +Replica bytes live in CacheStorage; metadata and DPoP nonces live in IndexedDB. Cache keys +include the active WebID and grant scope. Reads revalidate and never treat cached bytes as +authoritative. Writes are synchronous write-through with conditional headers. Logout +purges before further serving. Page-provided Authorization/DPoP headers are stripped; +credentials attach only to allowed target origins, never the token endpoint or remote +cleartext targets. A foreign target must be the requesting app's own origin to be proxied +without a Solid token. + +## Build and verification + +Use npm with the committed lockfile and Node.js 24: + +```bash +npm ci --ignore-scripts +npm run gate # Biome + tsc + Vitest + webpack +npm run test:e2e # build + headed Chromium against local CSS only +``` + +Webpack compiles six entries: service worker, popup, side panel, content bridge, inject, +and consent. CopyWebpackPlugin ships the manifest, HTML/CSS, icons, and Client ID Document. +TypeScript is strict. Runtime dependencies remain those declared in `package.json`; +this documentation/CI refresh adds none. + +The unit suite uses fixtures and mocked fetch/browser/storage ports. Adversarial coverage +includes forged/opaque origins, ungranted cache hits, cross-WebID isolation, class scope and +expiry, consent sender checks, revocation/purge failures, resolver SSRF, and prompt throttling. +E2E uses local CSS on port 3000 and app sites on ports 8080/8081, never production services. + +CI uses `actions/checkout@v5`, `actions/setup-node@v5`, and `node-version: '24'`, runs the full +`npm run gate`, then installs Chromium and runs `xvfb-run npm run test:e2e`. + +## Remaining design work + +The existing resolver implements only the Type Index rung. SAI DataRegistration discovery +and SHACL validation remain unimplemented; `shape` is always `null`. Central WebSocket +invalidation and offline write queues also remain follow-ups in the shared-replica design. + +The separate extension roadmap covers a stable extension ID and hosted Client ID Document, +multiple profiles, a new per-origin grant model with scoped injection, grant-management UI, +and form-fill. These are outside this Node 24 / documentation landing task. diff --git a/apps/extension/README.md b/apps/extension/README.md new file mode 100644 index 000000000..814e36c7d --- /dev/null +++ b/apps/extension/README.md @@ -0,0 +1,332 @@ +# Solid Browser Extension + +A Manifest V3 browser extension that brings **Solid sign-in to the browser itself**. It +injects a `window.solid` consent stub into every page (`login` and `requestAccess`). +Explicit origin consent adds DPoP-authenticated `fetch`, the bound profile's `webId`, +and class-management APIs. The toolbar account menu shows the signed-in WebID's avatar. The UI is built with +[`@jeswr/solid-elements`](https://github.com/jeswr/solid-elements) +so it is visually consistent with [Pod Manager](https://github.com/jeswr/solid-pod-manager) +and the rest of the [@jeswr Solid app suite](https://github.com/jeswr). + +> **Scope:** auth, `window.solid`, popup/side-panel account UI, a gated shared replica, +> and per-data-class consent through `requestAccess` / `listGrants` / `revokeGrant` / +> `resolveClass` are implemented. Class resolution currently uses Solid Type Indexes; +> SAI registration discovery, SHACL validation, and offline write queues remain follow-ups. + +## Architecture + +Four contexts, with the access token held in exactly one of them (the service worker): + +``` + Page (MAIN world) Content script (ISOLATED) Service worker (background) + window.solid ⇄ content-script.ts ⇄ service-worker.ts + inject.ts postMessage chrome.runtime ├─ auth-flow.ts (auth-code + PKCE + DPoP) + (no credential) (trust boundary: (sole token ├─ core/authenticated-fetch.ts (the boundary) + stamps real origin) holder) ├─ core/dpop.ts (RFC 9449 proofs, Web Crypto) + ├─ core/origin-policy.ts (fail-closed gate) + Popup = the account UI (@jeswr/solid-elements) ⇄ chrome.runtime ├─ core/profile-store.ts + profile-db.ts (IndexedDB) + └─ action-icon.ts (avatar toolbar icon) +``` + +- **The page never sees a credential.** `window.solid` (MAIN world) can only `postMessage` + to the content script, which relays to the worker. The page gets back the WebID and + proxied `Response`s — never the access or refresh token. +- **The service worker is the sole token holder.** Each profile's tokens and DPoP keypair + live together in the worker's IndexedDB (`core/profile-store.ts`, `profile-db.ts`), with + the private key held as a **non-extractable** `CryptoKey` — neither is reachable from any web page, satisfying + the suite's "DPoP refresh token in non-page-reachable secure storage" invariant, and the + private key additionally cannot be exported to JWK by ANY code (not even a compromised + extension context — `crypto.subtle.exportKey` on it always rejects). +- **Every proxied pod read/write is gated before cache access or egress.** The worker + cross-checks the content script's origin stamp against Chrome's sender, then checks + origin grants and, for class-only origins, the pinned targets, modes, and expiry. + Ungranted pages have no `webId` or `fetch` property. + +### The credential boundary (security heart) + +Because any page can send proxy messages, the worker must never hand a foreign +origin the user's token. `core/origin-policy.ts` + `core/authenticated-fetch.ts` enforce, +fail-closed: + +- The token is attached **only** to a target origin in the allowed set (the WebID's origin ∪ + the issuer's origin ∪ user-configured pod origins), after the requesting app passes its + access gate. An explicit `solid.fetch` from an ungranted origin returns 403 before any + cache read or pod egress. Foreign targets are allowed only on the requesting app's own + origin, without the Solid token; unrelated third-party targets return 403. +- **Cleartext guard:** the token never rides over `http:` (loopback excepted, for dev CSS). +- The resource token is never attached to the issuer's `/token` endpoint. +- Page-supplied `Authorization` / `DPoP` headers are **stripped** (no header injection). +- DPoP proofs follow RFC 9449 §4.2 (the same proof shape as + [`@jeswr/solid-dpop`](https://github.com/jeswr/solid-dpop), reimplemented on Web Crypto + because a service worker has no `node:crypto`), with the §8 `use_dpop_nonce` single retry. + +These invariants are pinned by an adversarial unit suite (`test/`), including a +WebID/origin-mismatch test that genuinely fails without the guard. + +## `window.solid` + +```ts +// Ungranted origins receive only login() and requestAccess(). +// These additional properties become available after explicit origin consent. +interface SolidExtension { + readonly webId: string | null; + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; + setClientId(clientId: string): void; // declare this origin's Client ID Document + login(webId: string): Promise; + logout(): Promise; + requestAccess(request: AccessRequestArgs): Promise<{ ok: boolean; grants: SolidGrant[] }>; + listGrants(): Promise; + revokeGrant(grantId: string): Promise; + resolveClass(classIRI: string): Promise<{ + shape: string | null; + targets: Array<{ url: string; kind: 'instance' | 'container' }>; + }>; +} + +interface AccessRequestArgs { + readonly classes: readonly string[]; + readonly modes: readonly string[]; // read, append, write (case-insensitive) + readonly purpose?: string; + readonly duration?: string; // required at runtime: positive ISO-8601, e.g. P30D +} + +interface SolidGrant { + readonly grantId: string; + readonly dataClass: string; + readonly modes: readonly string[]; + readonly purpose: string | null; + readonly expiresAt: number; // epoch milliseconds + readonly targets: ReadonlyArray<{ url: string; kind: 'instance' | 'container' }>; +} +``` + +Presence can also be detected using the `solid-extension:ready` event or the sticky +`` marker. These carry no identity. The best-effort +`fetch` patch is registered only on granted origins; other pages retain native fetch. +Requests the worker does not authorize fall back to the page's captured native fetch +with normal CORS behavior. +Explicit `solid.fetch` calls retain the worker's denial response. + +## Auth + session + +- **Login:** Solid-OIDC **authorization-code + PKCE + DPoP**, driven by + `chrome.identity.launchWebAuthFlow` (a service worker has no `window`). The WebID's + `solid:oidcIssuer` is resolved by **proper RDF parsing** (N3.js, not regex). The extension's + own Solid-OIDC **Client Identifier Document** is generated from + `src/background/client-id-template.ts` and the manifest's public key into + `public/clientid.jsonld` before builds (Node 24 required). The document's `client_id` is + `https://jeswr.github.io/solid-browser-extension/clientid.jsonld`, its redirect is + `https://bicabcdahgfbkgmlidjpmiadmjlppikk.chromiumapp.org/`, and its auth method is `none`. + The build rejects a missing or mismatched public key; the dispatcher-supplied key can be + imported with `EXT_PUBKEY=/path/to/extension-pubkey.b64 pnpm run generate:client-id`. + The public key belongs in `manifest.key`; the extension signing private key stays with Jesse. + A page may still declare its own Client ID Document via `solid.setClientId(...)`. +- **Profiles:** `ProfileStore` supports up to eight distinct WebIDs. Each profile has its own + access/refresh token set, non-extractable DPoP keypair and pod settings, persisted + atomically in IndexedDB under the profile id. Explicit origin grants bind those profile IDs + in worker-owned `chrome.storage.local`. The current single session migrates to profile + `1`, keeping its matching key. A missing/mismatched key retains the display entry and requires + re-login. Legacy token/key storage is cleared only after migration commits. +- **Default and restore:** the default profile supplies the account surface. Each origin + grant binds one profile independently of that default. Requests capture the bound profile's + id; a default switch cannot substitute another profile's credentials mid-request. Refresh + is single-flight per profile, reuses its persisted key, and commits rotated tokens before use. Reopening the worker restores from IndexedDB. +- **Removal and logout:** removing a profile revokes its grants, cancels pending replica fetches, + finishes local replica commits, purges its WebID's replica slices and deletes its credentials. + Interrupted removal resumes on worker wake. Logout signs out only the default profile, retaining its display entry; other + profiles keep their own sessions. One default exists whenever the store is nonempty. + +The popup and side panel share a profile switcher: list accounts with photos or initials, +add an account through sign-in, rename, remove with confirmation, and choose the default. +Adding keeps the existing default. Signed-out entries offer re-login; all eight slots stay +visible at capacity. Native controls support Tab, Enter and Space; arrows move between the +same action on adjacent profiles, Escape cancels edits, and results/errors are announced. +Removal revokes that profile's app access and clears its local replica while keeping its pod. + +The worker API accepts the following messages **only from browser-attested extension contexts**, returning display-only +`{ ok, profiles, defaultProfileId }` or `{ error }` (never tokens or key objects): + +| Message | Fields | +|---|---| +| `SOLID_PROFILES_LIST` | none | +| `SOLID_PROFILE_ADD` | `webId` (interactive login; keeps the existing default) | +| `SOLID_PROFILE_RENAME` | `profileId`, `label` | +| `SOLID_PROFILE_REMOVE` | `profileId` | +| `SOLID_PROFILE_SET_DEFAULT` | `profileId` | + +A successful legacy `SOLID_LOGIN` selects the logged-in profile as default. Re-login updates +only the same WebID. Removing the default selects the oldest survivor; an empty store has no +default. Per-origin bindings and the consent profile picker are implemented. `requestAccess` +requires a click, opens extension-owned consent, and shows the selected profile's pinned class targets. +Approved class grants nest under `{origin, profileId, scope: {pods, classes}, createdAt, expiresAt}`. +Every request rechecks the parent and its class constraints. Revocation/expiry unregisters +the origin's patch, clears page capabilities and denies captured functions on their next call. +Legacy implicit grants require fresh consent. The trusted grants-manager worker seam is +documented in [manifest permissions](docs/manifest-permissions.md); its side-panel UI is EXT-06. + +The trusted `clientIdMode` setting is stored as `solid:client-id-mode` in +`chrome.storage.local`. It accepts `document` or `dcr`, defaulting to `dcr` while Pages +enablement is pending. `dcr` uses dynamic client registration without probing the hosted +document. `document` uses the published URL and fails login if the document cannot be +fetched or does not self-reference; it never silently registers a different client. +Malformed settings fail closed. A page-declared Client ID Document keeps precedence in +either mode, and page messages cannot set the mode. + +`.github/workflows/pages.yml` renders and publishes only the public document and its logo +on `main` (or manual dispatch from `main`). **Jesse must enable GitHub Pages with GitHub +Actions as its source**; the workflow does not enable Pages. Once hosting is available, +set the mode from an extension context's DevTools console: + +```js +await chrome.storage.local.set({ 'solid:client-id-mode': 'document' }); +``` + +Use `dcr` in the same setting to retain dynamic registration during local development. + +## The pinned toolbar identity + +The `chrome.action` icon is rendered (off-DOM, `OffscreenCanvas`) to the signed-in WebID's +**avatar** — the profile photo (circular crop) or coloured initials — with a green status +badge. The **popup is the account UI**: `@jeswr/solid-elements`' `jeswr-account-menu` + +`jeswr-theme-toggle`, a recent-accounts affordance, a pod shortcut, a "restoring" state, and +a **first-run pin nudge** (extensions can't self-pin). Light/dark themes the popup chrome and +the web components in lockstep via the app-shell OKLCH tokens. + +### The side panel (persistent surface) + +For users who want the account UI to **stay open** as they browse (vs the ephemeral popup), +the extension also ships an MV3 **side panel** (`sidepanel/sidepanel.html`). It renders the +**identical** signed-in/signed-out surface as the popup — the SAME `@jeswr/solid-elements` +components driven by the SAME token-free `MessageBridgeLoginController` seam — via the shared +`mountAccountSurface()` (`src/popup/account-surface.ts`), so there is exactly one copy of the +view-switching + auth-bridge logic. Both surfaces also share the profile controls. **Open it** by right-clicking the +toolbar icon → **"Open Solid side panel"** (a left-click still opens the quick popup; Chrome +ignores open-on-action-click while a `default_popup` is set, so both surfaces coexist via the +context-menu entry). + +## Shared replica + +Granted pod reads use the extension-owned replica: bytes in CacheStorage, metadata and +DPoP nonces in IndexedDB, keyed by WebID and grant scope. Cached reads revalidate against +the pod and remain subject to the access gate on every hit. Writes go through to the pod +synchronously; logout purges the replica before further serving. Offline write queues and +central WebSocket invalidation are follow-ups. + +## Per-data-class access management + +After signing in through the extension, an app can request access from a click or keypress: + +```js +document.querySelector('#share-contacts').addEventListener('click', async () => { + const { ok, grants } = await window.solid.requestAccess({ + classes: ['https://schema.org/Person'], + modes: ['read'], + purpose: 'Display your contacts', + duration: 'P30D', + }); + if (!ok) return; // no classes approved + + const current = await window.solid.listGrants(); + const { shape, targets } = await window.solid.resolveClass(grants[0].dataClass); + // Use solid.fetch within the grant's pinned targets. Later, to withdraw access: + await window.solid.revokeGrant(grants[0].grantId); +}); +``` + +- **`requestAccess`** opens a consent window in extension chrome showing the verified app + origin, profile picker, classes, modes, purpose, expiry, and resolved targets. It requires transient user + activation and is limited to three admitted requests per origin per minute (best-effort + in-memory throttle, reset on worker restart). At least one valid HTTP(S) class IRI and + recognized mode are required. `duration` is optional in the TypeScript shape but mandatory + at runtime: a positive ISO-8601 duration, capped at 365 days. Years/months use 365/30 days. + Approval returns one grant per approved class; denying all returns `{ ok: false, grants: [] }`. + Invalid requests, missing activation/session, or throttling reject the promise. +- **`listGrants`** returns only this origin's non-expired class grants for the bound profile's WebID; + no live parent/session or an unverifiable origin yields `[]`. Summaries contain no WebID or credential. +- **`revokeGrant`** removes this origin's class grant and returns whether it was removed. + Removal precedes replica-slice purging, so even a purge failure cannot keep access live. + Cached bytes still covered by another live read grant of the same origin are retained. + The parent origin binding is managed separately from its nested class records. +- **`resolveClass`** walks the WebID's public/private Solid Type Indexes for `solid:forClass` + and returns `solid:instance` / `solid:instanceContainer` targets on allowed pod origins. + It requires a verified, granted origin and an active session. Class-only origins can + resolve only classes they currently hold beneath their parent origin grant. + It returns `shape: null` today: SAI discovery and SHACL validation are not implemented. + An unknown/unreachable class resolves to an empty target set. + +Class-only origins are confined to the targets pinned at consent, the granted modes, and +expiry, including on cache hits. Instances cover an exact resource path; containers cover +their path and descendants. GET/HEAD require `read`, POST requires `append`, and +PUT/PATCH/DELETE require `write`; `write` also satisfies `append`, but does not imply `read`. +Empty target sets authorize nothing. Re-resolution does not widen an existing grant. +`purpose` is an audit/display value, not an enforced usage restriction. Login, +`setClientId` and credential origins confer no implicit access. The origin parent and its +nested class scopes are both required. Changing the default profile does not rebind apps; +changing an origin's profile clears its old pod/class scope before fresh consent. + +## Fill forms from your profile + +On a recognised contact or address form, click the small **Fill from pod** field button, +activate it with Enter/Space, or right-click an editable field and choose **Fill from pod**. +An extension-owned preview shows the requesting site, profile picker, and the exact values +from your profile and linked address book. Check **Contact details** and/or **Address**, then +choose **Fill**. **Remember for this site** preselects those categories on later visits; +the preview still appears every time. Cancel, closing the preview or requesting tab, or +navigating the tab to another origin writes nothing. Profile/address-book discovery has a +30-second loading timeout; once open, the preview allows review until you decide, with only +a 24-hour safety cap. Forms are never filled on page load or submitted by the extension. + +Form-fill consent is independent of app/API consent. Ordinary sites can use it without +calling `window.solid`; it never enables that API or discloses your profile identifier. +The worker stores only `{origin, profileId, categories, createdAt, expiresAt?}` in the separate +`solid:formfill-grants` table. Switching the default profile does not rebind remembered sites. +Removing or signing out of a profile clears its form-fill grants and cancels pending fills. + +Only HTTPS pages (plus loopback HTTP for development) and frames with the top page's origin +are eligible. Passwords, payment fields, one-time codes, `autocomplete=off`, hidden and +uneditable controls are refused. The content script checks again before writing, preserves +existing/user-entered values, and emits native input/change events. Values live only in the +worker/preview and the approved form controls; they are not cached in page storage. +Public profile reads omit credentials; linked private address-book documents use the bound +profile's authenticated fetch, restricted to its pod origins with redirects refused. + +The E2E fixture is [`test-site/address-form.html`](test-site/address-form.html); the local pod +seed includes a private address record. `e2e/tests/formfill.spec.ts` covers approval, denial, +and cross-origin iframe refusal. + +## Build & load the unpacked extension + +```bash +pnpm install --frozen-lockfile # from the solid-sdk root; @jeswr deps are workspace:* +pnpm turbo run build --filter=@jeswr/app-extension # builds solid-elements, then webpack -> dist/ +``` + +Then in Chrome: + +1. Open `chrome://extensions`, toggle **Developer mode** (top-right). +2. **Load unpacked** → select `apps/extension/dist/`. +3. Pin it (puzzle-piece icon → pin) — the popup is the account menu. +4. Click the icon, enter your WebID / Pod URL, sign in. + +## Develop & test + +```bash +pnpm run gate # lint (biome) + typecheck (tsc) + test (vitest) + build (webpack) +pnpm run lint # biome over src test e2e scripts +pnpm run typecheck # tsc --noEmit +pnpm test # vitest — mocked security-critical core, no server needed +pnpm run build # webpack bundle to dist/ +pnpm run test:e2e # build + Playwright against a LOCAL Community Solid Server +``` + +The unit suite stubs `fetch` / `chrome.*` and needs no server. The Playwright e2e suite +boots a **local** Community Solid Server (`e2e/setup`) and a local test site — **never** the +live deploy — and drives the real extension in headless Chromium. CI uses Node.js 24 and +runs the gate before the local-server e2e suite. See [e2e/README.md](e2e/README.md) for +setup and runner troubleshooting. The profile-switcher scenario uses two +seeded CSS accounts, keyboard controls, axe WCAG checks and screenshots of both surfaces +in light/dark themes. + +## License + +MIT © Jesse Wright diff --git a/apps/extension/biome.json b/apps/extension/biome.json new file mode 100644 index 000000000..3a3ec908c --- /dev/null +++ b/apps/extension/biome.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { + "includes": [ + "src/**/*.ts", + "test/**/*.ts", + "e2e/**/*.ts", + "scripts/**/*.ts" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "warn" + }, + "style": { + "noNonNullAssertion": "off", + "useImportType": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "all", + "semicolons": "always" + } + } +} diff --git a/apps/extension/docs/MODEL-PROVENANCE.md b/apps/extension/docs/MODEL-PROVENANCE.md new file mode 100644 index 000000000..8d50874bd --- /dev/null +++ b/apps/extension/docs/MODEL-PROVENANCE.md @@ -0,0 +1,33 @@ +# Model provenance + +While Fable is unavailable, everything authored by **Claude Opus 4.8** is tagged so it can +be targeted for re-review / upgrade when Fable returns. + +- **Commit trailers:** `Model: claude-opus-4-8` + the `Provenance:` line + + `Co-Authored-By: Claude Opus 4.8 `. +- **New source files:** an `AUTHORED-BY Claude Opus 4.8` top-of-file marker. + +## Ledger + +| Artifact | Branch | Notes | +|---|---|---| +| Suite build/gate scaffold (biome, vitest, pinned @jeswr deps, ignore-scripts) | `feat/rewrite-core` | re-review/upgrade candidate | +| `src/background/core/*` (dpop, origin-policy, www-authenticate, authenticated-fetch, webid) | `feat/rewrite-core` | security-critical — re-review/upgrade candidate | +| `src/background/{service-worker,auth-flow,session-store,action-icon}.ts` | `feat/rewrite-core` | re-review/upgrade candidate | +| `src/{inject,content,popup}/*`, `src/shared/messages.ts`, `src/manifest.json` | `feat/rewrite-core` | re-review/upgrade candidate | +| `test/*.test.ts` (51 unit cases) + updated `e2e/` suite | `feat/rewrite-core` | re-review/upgrade candidate | +| `docs/access-management-seam.md`, `README.md`, `CLAUDE.md` updates | `feat/rewrite-core` | re-review/upgrade candidate | +| MV3 side panel (`src/sidepanel/*`, `src/background/side-panel.ts`) + shared `src/popup/account-surface.ts` (popup/side-panel surface extraction) | `feat/sidepanel-and-clientid` | re-review/upgrade candidate | +| Static Client ID Document (`public/clientid.jsonld`, `src/background/client-id.ts`) + `auth-flow.ts` published-client-id wiring | `feat/sidepanel-and-clientid` | published-URL hosting = needs:user; re-review/upgrade candidate | +| `test/{client-id,side-panel}.test.ts` (+21 cases) | `feat/sidepanel-and-clientid` | re-review/upgrade candidate | +| Phase-0 per-requesting-origin gate (`src/background/core/requesting-origin.ts`) + SW wiring (`service-worker.ts` gate/PII-scoping, `session-store.ts` grant store, `content-script.ts` origin stamp, `shared/messages.ts`) | `phase-0-origin-gate` | SECURITY-CRITICAL — re-review/upgrade candidate | +| `test/{requesting-origin,service-worker-gate}.test.ts` (+53 adversarial cases) + `docs/manifest-permissions.md` | `phase-0-origin-gate` | re-review/upgrade candidate | +| Phase-1 shared replica (`src/background/core/{replica,replica-key,replica-db,single-flight}.ts`) + SW wiring + page→SW routing + egress DPoP | `feat/phase-1-shared-replica` | SECURITY-CRITICAL — re-review/upgrade candidate | +| Phase-1 round-2 roborev fixes: inject global-fetch over-divert → SW native-passthrough for `autoDivert` non-pod/un-granted traffic (High #1); single inject path + idempotency guard (Medium #2); best-effort cache persist on non-cacheable 2xx (Medium #3); null-WebID purge enumerates+drops CacheStorage bytes (Medium #4); HEAD returns no body (Low #5) — `src/inject/inject.ts`, `src/content/content-script.ts`, `src/shared/messages.ts`, `src/manifest.json`, `src/background/{service-worker,global-fetch-register}.ts`, `src/background/core/replica.ts` | `feat/phase-1-shared-replica` | SECURITY/COMPAT-CRITICAL — re-review/upgrade candidate | +| `test/inject.test.ts` (new) + `test/jsdom.d.ts` (ambient, no new dep) + round-2 cases in `test/{replica,service-worker-gate,service-worker-replica}.test.ts` (+23) | `feat/phase-1-shared-replica` | re-review/upgrade candidate | +| Phase-2 per-data-class grant gate — pure core (`src/background/core/{class-grant,class-request,class-resolve,consent}.ts`), SW wiring (`service-worker.ts` `gateAccess` + requestAccess/listGrants/revokeGrant/resolveClass/consent handlers), grant store (`session-store.ts`), message protocol (`shared/messages.ts`), page API (`inject.ts`) + content relay (`content-script.ts`), consent window (`src/consent/*`) | `feat/phase-2-class-grants` | SECURITY-CRITICAL — re-review/upgrade candidate | +| `test/{class-grant,class-request,class-resolve,consent,service-worker-class-gate}.test.ts` (+73 adversarial cases) + `docs/access-management-seam.md` rewrite | `feat/phase-2-class-grants` | re-review/upgrade candidate | +| **Model: `claude-sonnet-5` (NOT Opus 4.8, despite the surrounding table).** DPoP non-extractable-key hardening (design roborev round-1, the F3 keystore-advantage finding): `src/background/core/{dpop,dpop-key-db,dpop-key-store}.ts`, `session-store.ts` legacy-JWK migration on `loadSession()`, `auth-flow.ts` + `service-worker.ts` rewiring onto the persisted non-extractable `CryptoKeyPair` | `security/nonextractable-dpop-key` | SECURITY-CRITICAL — re-review/upgrade candidate | +| `test/{dpop,dpop-key-store,session-store,service-worker-gate,service-worker-class-gate,service-worker-replica}.test.ts` updates (+11 net new cases) + `README.md`, `docs/manifest-permissions.md`, `docs/design/shared-replica-architecture.md` updates | `security/nonextractable-dpop-key` | re-review/upgrade candidate | +| **Model: `claude-sonnet-5`.** Round-2 roborev fixes on the above: `dpopJkt` (RFC 7638 thumbprint) recorded on `StoredSession` so `loadSessionDpopKeyPair` can detect an unexpected persisted-key loss/mismatch for a current-shape session (throws `DpopKeyUnavailableError`, treated as a refresh failure) instead of silently minting/using a substitute key (Medium #1); `auth-flow.ts completeLogin` now generates the keypair early (needed for the token-exchange DPoP proof) but only calls the new `persistDpopKeyPair` after the exchange succeeds, so a failed/retried login can never clobber an existing session's still-valid persisted key (Medium #2) | `security/nonextractable-dpop-key` | SECURITY-CRITICAL — re-review/upgrade candidate | +| **Model: `claude-sonnet-5`.** Round-3 roborev fix: `ensureSession()`'s DPoP-key-load catch was blanket — ANY error (including a transient IndexedDB-open failure) tore the session down. Narrowed to teardown ONLY on `DpopKeyUnavailableError`; any other error fails that one request soft (`cachedKeyPair` stays null, session/tokens left intact, next attempt retries cleanly) — never a silent forced logout on a transient fault (Medium) | `security/nonextractable-dpop-key` | SECURITY-CRITICAL — re-review/upgrade candidate | diff --git a/apps/extension/docs/access-management-seam.md b/apps/extension/docs/access-management-seam.md new file mode 100644 index 000000000..bdc6eb9b7 --- /dev/null +++ b/apps/extension/docs/access-management-seam.md @@ -0,0 +1,89 @@ +# Access management — the per-data-class grant gate (Phase 2) + +Access management — letting a page *request access* to specific **data classes**, the user +*consent* to it in the extension's own chrome, and the service worker *enforce* the resolved +scope — is **implemented** as of Phase 2. This note documents the shipped surface, the +enforcement model, and its honest limits. It supersedes the earlier "deferred seam" note. + +Design source: `full-solid-ecosystem/docs/design/access-management-proposal.md` §4 (the client→ +extension JS API + SW enforcement) and §6 (the adversarial critique: revocation propagation, +TOCTOU → target-pinning). Phase 0/1 (the per-origin gate + shared replica) is the substrate this +extends — it is NOT replaced; the **service worker remains the SOLE security boundary**. + +## The `window.solid` access-management API (`src/inject/inject.ts`) + +| Method | Returns | Behaviour | +|---|---|---| +| `requestAccess({classes, modes, purpose, duration})` | `{ ok, grants: SolidGrant[] }` | Resolves each class → concrete pod targets, opens the **unspoofable consent window**, mints one grant per approved class (PINNED targets). | +| `listGrants()` | `SolidGrant[]` | The non-expired grants THIS origin holds for the active WebID. | +| `revokeGrant(grantId)` | `boolean` | Revoke one of THIS origin's grants (cannot touch another origin's). | +| `resolveClass(classIRI)` | `{ shape, targets[] }` | Read-only introspection (granted origins only). | + +`duration` is an ISO-8601 duration (e.g. `"P30D"`); it is **required** and clamped to 365 days — +a grant with no bounded expiry can never be minted. The page never receives a token — only a +grant handle and a capability to `fetch` within the pinned set. + +## The grant key + enforcement (the security substance) + +- **Grant key widened** from `origin` to `(origin, dataClass, modes, purpose, expiry)`, plus a + `webId` session dimension and a **PINNED resolved target set** — the enforced substance. + Stored in `chrome.storage.local` (`solid:class-grants`), page-unreachable; every read is + sanitised so a tampered record authorises nothing. +- **Resolution + pinning (TOCTOU mitigation, design §6.2).** At grant time the data class is + resolved to concrete targets via the **Type Index** walk (`src/background/core/class-resolve.ts`, + `solid:publicTypeIndex`/`privateTypeIndex` → `solid:TypeRegistration` `solid:forClass` → + `solid:instance`/`solid:instanceContainer`) and that set is **frozen into the grant**. A + resource created later, or re-typed into the class, is NOT covered — it needs fresh consent. + (SAI `interop:DataRegistration` + SHACL-validate rungs are documented follow-ups.) +- **The SW enforces the pinned set on every `.fetch`** (`gateAccess` in `service-worker.ts`, + run inside the replica's injected gate so it applies on a cache HIT too). A class-only origin's + request must fall within a live grant's pinned targets with a satisfying mode, else DENY with a + **distinguishable** reason (`Out of grant scope` / `No access grant for this data`). Pure gate + logic + fail-closed rules live in `src/background/core/class-grant.ts` (exhaustively tested). +- **Expiry** is a per-request clock check; an expired grant denies AND is pruned from storage. +- **Revocation** removes the grant record; because the SW re-reads the store on **every** request + (no in-memory grant cache), it is effective on the **next** request. + +## Backwards compatibility (design item 5) + +Legacy **origin-only** grants (a deliberate login / `setClientId` opt-in, or a credential/pod +origin) keep **whole-pod access** unchanged — the class gate is only applied to an origin that is +present *solely* because it holds class grants. This is a dual-read: no data migration; a +full-grant origin bypasses the per-class confinement. + +## The consent surface + +`requestAccess` opens `consent/consent.html` (a `chrome-extension://` page — unspoofable by the +requesting origin) showing, per class, the data class, modes, purpose, expiry, and the **resolved +target list** ("this will share these N resources"). Per-class approve / deny-all; closing the +window denies (fail-closed). Pure view/decision logic: `src/background/core/consent.ts`. + +## Honest limits (design §6) + +- **Revocation is eventually-consistent, not instant.** In the extension the propagation bound is + **one request**: the SW re-reads the grant store per request and denies before touching the + cache or the pod (the gate-on-every-read invariant), so a revoked grant cannot be served off a + warm replica. Any request already past the gate check completes; there is no page-held token to + invalidate. A future refinement is a per-grant replica-slice purge on revoke (the replica is + currently keyed by grant-scope = origin, not per class). +- **Type-Index resolution trusts the writer's typing.** The pinned set is only as correct as the + pod's Type Index; SHACL-validate-at-resolution (design §6.2 backbone) is a follow-up. Write-by- + class stays conservative (target-pinned, never a blanket container widen from an app's own type + declaration). +- **Purpose is audit-only**, never RS-enforced (design §6.3). + +## Invariants preserved + +1. All access-mediated fetches still pass `core/origin-policy.ts` + `core/authenticated-fetch.ts`. +2. The page stays credential-free; grants + tokens live in the SW / `chrome.storage`. +3. The content-script stamps the real page origin server-side (never page-supplied). +4. The **SW is the sole enforcement point**; the MAIN-world inject has zero security weight. + +## What the standalone `solid-access-manager` app needs from this API + +The app (design §4.3) that brokers consent when no extension is present speaks the SAME on-pod +artifacts; to drive the extension when it IS present it calls `window.solid.requestAccess(...)` / +`listGrants()` / `revokeGrant()` / `resolveClass()`. The one wiring the app still needs is a +`DataController` `AccessDeniedError` → "request access" hook (a `solid-components` follow-up, design +§4.3) that deep-links into the app's request composer or calls `requestAccess` when the extension +is detected (`'solid' in window` + `data-solid-extension` marker). diff --git a/apps/extension/docs/design/shared-replica-architecture.md b/apps/extension/docs/design/shared-replica-architecture.md new file mode 100644 index 000000000..88bf58b58 --- /dev/null +++ b/apps/extension/docs/design/shared-replica-architecture.md @@ -0,0 +1,419 @@ +# Shared-Replica Routing for the Solid Browser Extension + +> **Status:** Design proposal (research + design record). Authored by the PSS agent +> (Claude Opus 4.8). Target repo: `jeswr/solid-browser-extension` (PUBLIC, MV3). +> Couples to `full-solid-ecosystem/docs/design/access-management-solid-lws.md` (tt6). +> **This is an experimental design; the extension is under active development — not a +> production, live, or supported component.** + +## 0. The problem in one paragraph + +The extension already brings Solid sign-in to the browser: it injects `window.solid` +(a DPoP-authenticated `fetch` + the WebID), holds the DPoP key + tokens ONLY in the +service worker (SW), and proxies every pod request through the SW behind a fail-closed +TARGET-origin credential gate. The refinement this doc designs is the **offline-first** +layer. The naive approach — give every Solid app a `@jeswr/solid-offline` service worker +— **duplicates the same pod data N times across N apps**, because browser Cache/IndexedDB +are partitioned by the visiting app's top-level site. We instead make the **extension own +ONE live, authenticated local replica per (user, pod), in the extension's own +`chrome-extension://` storage (NOT partitioned by the visited app's origin), SHARED across +every app, origin, and window.** The SW is the sole egress (sole DPoP-key holder) and the +sole offline-cache owner; WebSocketChannel2023 invalidation runs ONCE centrally. The +consequence is that **per-origin access control becomes load-bearing**: one shared replica +means any app could otherwise read the whole pod, so the extension MUST enforce which app +may read/write which data at the browser boundary — the runtime home for the tt6 data-class +consent model. + +## 1. Per-origin duplication — the problem this removes + +Verified against Chrome's [Storage and cookies](https://developer.chrome.com/docs/extensions/develop/concepts/storage-and-cookies) +and [Storage Partitioning](https://developer.chrome.com/en/docs/privacy-sandbox/storage-partitioning/) +docs: + +- A normal web page's `CacheStorage` + `IndexedDB` are **partitioned by the top-level + site**. App A at `a.example` and App B at `b.example` each get their OWN partition. If + both register `solid-offline`, the user's pod `alice.pod/finance/…` is cached **twice** — + once per app — and revalidated twice, with two WebSocket subscriptions. With N apps, N + copies + N sockets. +- The **`chrome-extension://` origin is a single top-level partition** shared across + the SW, popup, side panel, and offscreen documents, and is **NOT partitioned by the + visited site**. One physical store, reachable from one place. + +So holding the replica in the extension's own storage collapses N copies to ONE, N sockets +to ONE, and N revalidation pipelines to ONE — and makes cross-app consistency automatic +(App B sees App A's confirmed write because they read the same bytes). + +## 2. The single shared extension-owned replica + +### 2.1 Storage layout + +ONE replica per `(WebID, pod-origin)` in the extension SW's own storage, with +`unlimitedStorage`: + +- **Bytes** → `CacheStorage` (survives SW restarts, available in the SW context), keyed by + a canonical synthetic `(podResourceUrl, varyKey)` Request — reusing `@jeswr/solid-offline` + `cache-coherence.ts` (`putCanonicalBytes` / `CANONICAL_TURTLE_VARY_KEY`) so there is one + byte copy per resource, not per content-type-variant-per-app. +- **Metadata** (ETag, fetchedAt, max-age, variant set, the OWNING grant-scope) → `IndexedDB` + via `@jeswr/solid-offline` `MetadataStore` (`metadata-store.ts`). +- **Session tokens + grant table** → `chrome.storage.local` (page-unreachable). **UPDATE:** + the DPoP private key itself has since moved OFF `chrome.storage.local` entirely — it is a + non-extractable `CryptoKey` in its own IndexedDB store (`core/dpop-key-db.ts`); see §4.2. +- **DPoP nonce cache** → MOVED from the in-memory `nonceByOrigin` Map (service-worker.ts:59) + to IndexedDB, so a cold SW wake does not pay the §8 nonce round-trip on every first request. + +**LOAD-BEARING CACHE-KEY FIX (review finding).** `@jeswr/solid-offline` `scope.ts` states +verbatim that its WebID hash is "only a *namespacing discriminator*, NOT a security +boundary: the real boundary is the browser's origin isolation." In the extension partition +there IS no origin boundary underneath. So the cache key MUST include the active **WebID** +(and the grant-scope, §4), and every read MUST verify the active-WebID namespace, never just +the ETag — otherwise a same-URL+same-ETag collision across two users on one device (a shared +or public resource, or a re-provisioned WebID) serves user A's bytes to user B. + +### 2.2 Consistency model + +Read-from-cache + always-revalidate (never-authoritative stale-while-revalidate), reusing +`@jeswr/solid-offline` `swr.ts`. A cache value is provisional until a conditional +`If-None-Match` GET returns 304/200. Because every app reads the same replica: + +- App B sees App A's write the instant the write-through completes AND the metadata ETag + updates and the change is broadcast (see §2.3 for the MV3 caveat on broadcast atomicity). +- A single in-SW single-flight (the existing `SingleFlight`) coalesces concurrent reads of + the same URL so N apps loading the same resource cause ONE egress. +- The replica is **never authoritative**: the real pod is the source of truth; on any + ETag/cache mismatch the SW revalidates against the pod before serving (mirrors the PSS + S3/QLever "cache is never authoritative" invariant). + +### 2.3 Write path + +A write (PUT/POST/PATCH/DELETE) from any app routes through the same patch→bridge→SW path. +The SW is the single write funnel. **Phase-1 = synchronous WRITE-THROUGH, not +optimistic** (a shared replica makes a bad optimistic write visible to every other app, so +writes must be authoritative-on-success): + +1. **Access-control check FIRST** — does this requesting origin hold a *write* grant for this + target/data-class? Default-deny (§4). +2. The SW issues the DPoP-signed write to the real pod, carrying `If-Match:` + when the app supplied none (cross-app lost-update guard — App B cannot silently clobber + App A). +3. On 2xx: update the shared replica bytes + metadata ETag **from the pod's response** (not + the app's optimistic copy) and broadcast `{url, event:'updated', etag}` to every tab. + On 412: surface the conflict verbatim to the caller (same contract as a direct pod write) + and revalidate. On 5xx/offline: **FAIL the write back to the app** (no silent queue in v1). +4. The optimistic-mutation UX invariant is delivered at the **app layer** (the app paints + immediately, shows Saving/Saved) over a write-through that confirms; the replica stays + coherent. + +**MV3 write-visibility caveat (review finding).** The SW can be suspended AFTER the pod 2xx +but BEFORE step 3 persists+broadcasts. The write is not lost (the pod has it) but cross-app +visibility is not atomic with the write; other apps see it on next revalidation / WS frame. +Convergence is eventual, not instant — never-authoritative-revalidate keeps it *correct*. + +**Offline write QUEUE is an explicit Phase-3 deferral** (matches tt6 + solid-offline both +deferring offline writes): a durable per-resource outbox replayed on reconnect must (a) be +keyed by the unforgeable requesting origin, (b) **re-check the grant at replay time** (a +grant may have been revoked between enqueue and replay), and (c) surface a 412 on replay, +never auto-merge. Do not ship until that conflict+revocation path is built. + +## 3. The three routing mechanisms compared + +| Mechanism | Can carry per-request DPoP at egress? | Can serve a replica body? | Boot-race? | Blocks cross-app leak? | Verdict | +|---|---|---|---|---|---| +| **MAIN-world global-fetch patch → ISOLATED bridge → SW** (RECOMMENDED) | **Yes** — the SW signs each proof; key never leaves SW | **Yes** — SW returns a serialized Response | Patch is racy + bypassable (verified), so it is **transparency only, zero security weight** | **Only if** the SW enforces a per-requesting-origin default-deny gate (the graft below) | **Recommended** | +| **declarativeNetRequest redirects the pod request to the SW** | **No** — DNR has no JS callback, cannot carry/sign a per-request proof | **No** — DNR cannot synthesize/modify a response body; redirect drops method+body | Race-free (installed before page scripts) but unusable as a data path | n/a — cannot route at all | **Infeasible as router** (3 verified blockers) | +| **DNR as a fail-closed bypass backstop** (defense-in-depth) | n/a | n/a | Race-free | **No** — cannot cleanly exempt the SW's own egress while blocking page bypass | **Best-effort observer only, never relied on** | + +**Verified facts behind the table** (Chrome for Developers, June 2026): + +- MAIN-world `document_start` content scripts are **NOT guaranteed to run before the page's + own inline scripts** (inline scripts are parser-blocking; src scripts are not) — so a page + can capture pristine `fetch` first. ([content-scripts manifest doc](https://developer.chrome.com/docs/extensions/reference/manifest/content-scripts), [chromium-extensions thread](https://groups.google.com/a/chromium.org/g/chromium-extensions/c/eVZd_vOIryc)) +- `declarativeNetRequest` **cannot modify response bodies** (headers only), a redirect target + must be a static web-accessible resource (no JS callback), and **it DOES affect `fetch()` + made in a service worker** — so the extension SW's own legitimate pod egress is itself + subject to a pod-host DNR rule. The only way to exempt it (`excludedInitiatorDomains` / + tabId -1) **also stops the rule matching website-service-worker-initiated requests**, so a + malicious page can bypass the backstop via its own page SW. ([declarativeNetRequest API](https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest)) The backstop is therefore NOT reliably fail-closed; the real guarantee must not depend on intercepting page traffic. + +## 4. Security findings + +### 4.1 Cross-app data leak — the headline (CRITICAL as code stands today) + +All three adversarial reviews landed the same break against the ACTUAL code: + +- `service-worker.ts` `handleFetch(message)` consumes `message.url/method/headers/body` and + **ignores `_sender`** (line 253). There is no per-requesting-origin authorization. +- `content-script.ts` forwards the fetch with **no origin stamp** (lines 39-53); it stamps + `window.location.origin` only for `SOLID_LOGIN` / `SOLID_SET_CLIENT_ID`. +- The only gate (`origin-policy.ts` `isOriginAllowed`) is about the **TARGET pod** ("don't + send the token to evil.com"), not the **requesting app**. +- `inject.ts` `requestAccess()` **throws "not implemented"** (lines 138-145). +- The cache key has **no app/origin dimension**; `manifest.json` injects `window.solid` on + ``. + +So **today any origin can call `window.solid.fetch(podUrl)` and read/write the whole pod.** +That is tolerable in the current single-user product (no shared replica, no per-app promise), +but it is FATAL the instant a shared replica exists. **`crossAppLeakBlocked = false` as +written.** This is a pre-existing exposure worth a tracked security bead on +`solid-browser-extension` regardless of the shared-replica work. + +### 4.2 Key custody — the strongest property (PRESERVED, do not touch) + +Refuted by all reviewers. `createDpopProof` (`dpop.ts`) exports ONLY the public components +(kty/crv/x/y, never `d`); the page receives only a WebID + a reconstructed Response; +page-supplied `Authorization`/`DPoP` headers are stripped (`authenticated-fetch.ts` +`HOP_BY_HOP`). + +**UPDATE (security hardening, closes the residual LOW noted below at design time):** the +DPoP private key is now generated **non-extractable** and persisted as a live `CryptoKey` +object directly in the worker's own IndexedDB (`core/dpop-key-db.ts` / +`core/dpop-key-store.ts`), never as JWK in `chrome.storage.local`. Non-extractable +`CryptoKey`s are structured-clone serialisable per the WebCrypto spec, so the key survives +the MV3 SW's kill/restart cycle — the original "extractable is an MV3 necessity" assumption +conflated the key OBJECT's in-memory lifetime with whether the material could be durably +persisted; it can, without ever being exportable. The original text below is kept for the +historical record: ~~The DPoP keypair lives only as JWK in `chrome.storage.local`, reachable +only from extension contexts. Residual LOW: the key is generated **extractable** (an MV3 +necessity — a non-extractable key is lost on SW suspension, breaking the jkt-bound refresh) +and co-located with the refresh token in `chrome.storage.local`, so any extension-context +compromise exfiltrates both from one store. Not page-reachable; a concentrated single point +worth noting.~~ The private key no longer lives in `chrome.storage.local` at all, and no +extension-context code (compromised or not) can ever export it. + +### 4.3 Other verified findings (graft the mitigation into the recommendation) + +- **Forge the access-control anchor.** `sender.origin` is renderer-spoofable; the + content-script stamp is read in the same renderer. Mitigation: default-deny + SW-sole- + enforcement so a forged origin only reaches THAT origin's grants; require BOTH anchors to + agree; never trust a page/bridge-supplied origin FIELD — read `chrome.runtime` `sender`. +- **Boot-race / page-SW / XHR bypass.** Fail-safe for the credential (a bypassed request is + unauthenticated → pod 401), but can reach PUBLIC pod data. Mitigation: server-side WAC is + the ultimate boundary; the SW gate + the (best-effort) DNR observer are layers, not the + guarantee. +- **Cross-USER stale bytes.** `logout.ts` purge is async/best-effort and MV3-interruptible; + the WebID-omitted cache key collides. Mitigation: WebID in the key + **synchronous-before- + serve purge** (block a new session's reads until the prior identity's stores are fully + dropped). +- **Refresh-token rotation across SW suspension.** A mid-refresh kill can burn the rotation- + bound refresh token (DoS). Mitigation: persist the rotated token atomically BEFORE use; the + existing `refreshGate` single-flight is in-memory and must be re-derivable on wake. +- **resolveClass widening (tt6 §6.2).** A pod-controlled Type Index / SHACL shape can widen a + data-class grant. Mitigation: SHACL-validate at resolution; pin the exact shape IRI at + consent; read-only Phase-1; write-by-class pins explicit targets. +- **Foreign-origin fetch ambient cookies.** `window.solid.fetch('https://tracker/')` is run + by the SW as a plain fetch carrying extension-context ambient cookies (CSP-free tracking + amplification). Mitigation: same-origin-or-deny policy for foreign SW fetches. +- **PII broadcast.** The WebID (and any pod-origin allowlist) is pushed to every script on + ``. Mitigation: scope presence/WebID to apps that opt in. + +## 5. Recommended architecture (implementable) + +### 5.1 Routing + +PRIMARY = **MAIN-world global-fetch patch → ISOLATED bridge → SW**, a generalization of the +existing `window.solid.fetch` proxy: + +- In `inject.ts`, in addition to defining `window.solid`, install a global wrapper anchored + on the captured pristine fetch: + `const real = window.fetch; window.fetch = (i,init) => isPodUrl(i) ? solidFetch(i,init) : real(i,init)`. + Reuse the `@jeswr/solid-elements` `installProactiveAuthFetch` discipline (snapshot the + pristine fetch, build once, re-assert ownership, never re-read a possibly-patched global), + but its `base` transport is the postMessage→SW bridge, NOT a page-side authenticated fetch. +- `isPodUrl` diverts only known pod origins; everything else hits the untouched native fetch + (no proxy tax, no breakage of unrelated requests). Do NOT broadcast the full pod-origin + list to the page (PII); use a minimal predicate or route everything and let the SW gate + decide. +- **Register the MAIN-world script programmatically from the SW** + (`chrome.scripting.registerContentScripts({world:'MAIN', runAt:'document_start', + persistAcrossSessions:true})`) — the most reliable MAIN injection (the MetaMask + `window.ethereum` pattern). +- **The patch is best-effort transparency with ZERO security weight.** It is racy and + bypassable (verified). A missed/bypassed request is a plain unauthenticated fetch (pod 401, + fail-safe for the credential) and the server's own WAC governs what it returns. +- DNR is retained ONLY as a best-effort defense-in-depth observer/blocker; it is NEVER the + router and NEVER relied on as fail-closed (its self-egress exclusion hole is verified). + +### 5.2 Replica + key in the SW + +As §2: bytes in `CacheStorage`, metadata in `IndexedDB`, key+session+grants in +`chrome.storage.local`, nonce cache moved to IndexedDB; cache key includes WebID + grant- +scope; everything re-hydratable on SW wake (no load-bearing in-memory state). + +### 5.3 Egress DPoP (preserve verbatim — strongest property) + +The SW is the SOLE egress and SOLE key holder. Per-request signing via the existing +`createDpopProof` (Web Crypto + jose; ES256; public-only `jwk` header; `htm/htu/iat/jti`; +`ath` = base64url(SHA-256(token)); §8 nonce echoed on challenge), one §8 nonce retry with a +per-origin nonce cache, exactly-one retry. Proactive refresh ~30s before expiry, single- +flighted via `refreshGate`, same keypair reused so the pod `jkt` binding holds. Refresh-token +persistence + the fail-closed silent-restore decision reuse `@jeswr/solid-session-restore`. +The page only ever receives a WebID + a proxied Response. (Note: the live PSS RS runs +`allowMissingAth` compat, so end-to-end token-binding at the RS is weaker than the proof +shape implies — orthogonal to the extension but relevant to the PoP story.) + +### 5.4 Per-origin default-deny access control + the tt6 data-class hook + +THE load-bearing graft. Enforced in the SW, BEFORE any cache hit or egress, on every read, +**every cache hit**, and every write: + +1. **Forward + enforce the true requesting origin on EVERY fetch.** Change the + `SOLID_FETCH_REQUEST` relay to stamp `window.location.origin` (as it already does for + login), AND have `handleFetch` read `chrome.runtime` `sender.origin` (browser-attested). + Both must agree. **Never** trust a page-supplied origin field. **Fail-closed on + opaque/null origins** (sandboxed iframe, `srcdoc`, `about:blank`, `data:`) — never a + shared "null" bucket. +2. **Default-deny.** Resolve `(origin, target-URL, method) → grant decision`. No grant ⇒ the + SW returns a 403-equivalent WITHOUT touching the replica or the pod (so the shared replica + cannot serve cached private bytes to a non-consented app). NOT sent unauthenticated. +3. **Re-check the gate on cache HITS, not just egress** — the central authenticated + revalidation lands bytes in one replica that multiple un-equal-trust origins can read, so + the gate must mediate every read of the shared bucket. Partition the cache by grant-scope + so a missing grant cannot even reach a shared body. +4. **tt6 data-class hook = the policy source.** The gate consults a per-origin grant store + whose grants are tt6 `accm:`/SAI `AccessGrant` records resolved to concrete targets via + the tt6 ladder (`accm:resolvesTo`: SAI `interop:DataRegistration.registeredShapeTree` → + Type-Index `solid:instanceContainer` → **SHACL-validate**). "This app may read finance + data" compiles to "origin X may read {container set Y}". Consent UI renders in EXTENSION + chrome (popup/side-panel), unspoofable by the requesting origin; the grant is bound to the + content-script-stamped+sender-attested true origin. The `window.solid.requestAccess` seam + (today a throwing stub) is wired to `requestAccess`/`listGrants`/`revokeGrant`/ + `resolveClass`. +5. **Read-only Phase-1 grants**; write-by-class pins exact targets (tt6 §2.3 — never let an + app-influenced class definition broaden its write surface). **Revocation must invalidate + already-served/replica bytes + any queued writes, not just future fetches.** + +### 5.5 Offline-first + WS invalidation (the inversion) + +Reuse `@jeswr/solid-offline` modules INVERTED for the authenticated extension SW: + +- Verbatim: `swr.ts` (never-authoritative SWR), `cache-coherence.ts` (synthetic-key keying), + `metadata-store.ts`, `invalidation.ts` (WS-frame → revalidate-or-purge with the ETag self- + write short-circuit). +- **The inversion:** `solid-offline`'s SW is UNAUTHENTICATED by design — `notifications.ts` + states verbatim "THE WEBSOCKET LIVES IN THE PAGE (decision 5), NEVER THE SW … the SW only + invalidates." In the EXTENSION the SW IS the authenticated owner, so (a) the injected + `fetch` port given to swr/invalidation is the AUTHENTICATED DPoP egress (private resources + revalidate, not 401), and (b) the WebSocketChannel2023 subscription lives in the EXTENSION + SW — ONE socket per pod container for ALL apps. This re-opens `solid-offline`'s threat model + (authenticated subscribe POST + one-socket-per-pod fan-out) and **needs its own audit before + reuse** — flagged, not assumed safe. Discovery + subscribe use the suite + `solid-notifications` path; the notification `state` field === the resource ETag (the join + invalidation already exploits). A central invalidation broadcasts to every tab so all apps + re-render. + +### 5.6 MV3 persistence + +- The MV3 SW is terminated after ~30s idle (verified) — durable state must survive: bytes + (Cache), metadata + nonce (IndexedDB), key+session+grants (`chrome.storage.local`). On + every wake re-open handles + rebuild in-memory nonce/single-flight lazily. +- The WS does NOT survive suspension. A live WS extends SW lifetime (Chrome 116+: WS + send/receive resets the idle timer — verified), but Chrome still reclaims on memory + pressure. On every wake run `resyncSweep` (conditional-revalidate the warmed set) then + re-subscribe; a `chrome.alarms` heartbeat (min 30s) re-asserts subscriptions + sweeps. + Reads are never-authoritative, so a momentarily-missed invalidation is one stale-then- + revalidated read, never a wrong write. + +### 5.7 Trustworthy presence signal + +- Presence = `window.solid` present + `window.solid.webId` + a `solid:ready` CustomEvent + + a frozen capability handshake `{present:true, version, capabilities:['fetch', + 'globalFetchPatch','accessControl','sharedReplica']}`. `window.solid` is installed with + `Object.defineProperty(window,'solid',{configurable:false})` (already done) so a later page + script cannot replace it. +- **Presence is a UX/cooperation signal, NOT a security boundary** — a page can pre-define a + fake `window.solid` (boot race) to lie to its own scripts, but gains no cross-origin trust + and no data (the SW gate is the boundary; a forged presence cannot mint a DPoP proof). The + only TRUE presence proof is capability: a successful DPoP-authed pod read the page did not + supply a token for. +- Apps use presence to skip their own login and hide their own profile chrome; the pinned + toolbar avatar is the user-facing signal. **Scope the WebID broadcast to opt-in apps**, not + every script on `` (PII). + +## 6. Coupling to tt6 (access-management) — what ships first + +- **Ships BEFORE access-mgmt lands — owner-only single-user default.** The extension's strong, + already-built single-user mode: ONE user, the existing per-user pod-origin allowlist as the + grant store, the SW as sole key holder + egress, the DPoP egress preserved. In this mode + there is no cross-app boundary to enforce because there is effectively one trust principal + (the owner) and a coarse per-pod allowlist — which is exactly why the **shared replica must + NOT be turned on in this mode** (a coarse per-pod-origin grant is per-USER, not per-APP, so + it provides NO cross-app isolation). Ship offline-first per-app-cache OR single-user only + until the gate lands. +- **What the browser-side enforcement needs from tt6 (the access-mgmt design):** the + `client→consent→grant` RUNTIME — a per-origin grant store keyed by `(requesting-origin → + data-class → mode → expiry)`, resolved to concrete targets via `accm:resolvesTo` (SAI + DataRegistration → Type Index → SHACL-validate). The extension is tt6's browser-side + enforcement point: it authors SAI/ACL grants as ordinary authenticated writes (no server + change for read grants) and gates every routed request against the resolved grant set. +- **What tt6 sequences AFTER (CORE-PSS, maintainer-gated):** the server-native Authorization + Agent + request-inbox (LDN) + class→target resolver + WAC/ACP materialisation for the + seamless, scalable flow. The extension can do client-side class→target resolution today; + server help is wanted for materialization at scale. **tt6 itself sequences the extension + consent path LAST and flags the confused-deputy mislabelling attack as open** — so the + shared replica is gated behind the per-app/per-data-class grant store, not the coarse store. + +## 7. Phased build plan + +The non-negotiable ordering: **build the boundary, prove it adversarially, THEN turn on the +shared replica.** + +- **Phase 0 — Hardening prerequisite (security-critical; build + prove FIRST).** Forward the + true requesting origin on every `SOLID_FETCH_REQUEST` (content-script stamp + `sender` + cross-check); add a SW default-deny per-origin gate as the FIRST check on read/cache-hit/ + write; fail-closed on opaque/null origins; add the same-origin-or-deny foreign-fetch policy; + stop broadcasting the pod-origin allowlist + scope the WebID to opt-in. Adversarial tests: + forged `sender.origin` → DENY, whole-pod read by an ungranted origin → DENY, app-A requests + app-B-only resource → DENY, opaque-origin → DENY, race the boot patch → fail-safe. Land this + on the single-user product BEFORE any replica work. File the pre-existing `` + full-pod-exposure security bead. +- **Phase 1 — Shared-replica + routing + egress-DPoP core (behind the Phase-0 gate).** The + global-fetch patch (programmatic MAIN injection); the `(WebID, pod-origin, grant-scope)`- + keyed replica in `CacheStorage`+`IndexedDB`; nonce cache → IndexedDB; synchronous-before- + serve logout purge + atomic rotated-refresh-token persistence; preserve the DPoP egress + verbatim. Read-from-cache+revalidate; synchronous write-through. Re-check the gate on cache + hits. +- **Phase 2 — Per-origin access-control enforcement + tt6 data-class hook.** Wire + `window.solid.requestAccess`/`listGrants`/`revokeGrant`/`resolveClass` to the tt6 grant + store; consent UI in extension chrome bound to the true origin; SHACL-validate `resolveClass`; + read-only class grants; revocation invalidates served/replica bytes. +- **Phase 3 — `@jeswr/solid-offline` central sync.** WebSocketChannel2023 subscription in the + SW (one per pod container), authenticated revalidation, central invalidation broadcast, + `resyncSweep` + `chrome.alarms` heartbeat on wake. **Re-audit `solid-offline`'s + notifications/invalidation under the inverted (authenticated-SW) threat model before reuse.** + (Offline write QUEUE is deferred past v1 — see §2.3.) +- **Phase 4 — Consolidate the 10 apps onto `` with hide-when-extension- + present.** Standardise the suite apps (7 pod-apps + PM + solid-issues + app-store) on the + Lit `@jeswr/solid-elements` `` (exists; `webid` attribute), feature- + detecting `window.solid` + the `solid:ready` capability handshake to suppress the app's own + login/profile chrome and defer to the extension identity. Add a `hide-when-extension-present` + capability to the element. Update the `create-solid-app` template to standardise the check. +- **Phase 5 — Playwright-with-extension tests vs a LOCAL Solid server.** E2E with the + unpacked extension loaded (`--load-extension`, persistent context), pointed at a **local + CSS / local `prod-solid-server` (`docker compose up`), never the live deploy** (house rule). + Cover: cross-app read+write denial (two app origins, one pod), forged-origin denial, cross- + user purge race, boot-race fail-safe, single-replica-shared-across-origins, offline-load- + from-cache, central WS invalidation. Use the `playwright-best-practices` / + `solid-test-infrastructure` skills. + +## 8. What needs CORE-PSS server changes (maintainer approval, separate) + +The tt6 server-native Authorization Agent (request-inbox LDN endpoint, class→target +resolver, WAC/ACP materialisation) is new security-critical surface on `prod-solid-server` +(`src/access-requests`, touching `src/authz`). It goes through auth-specialist + +storage-specialist + the conformance harness + roborev, and needs maintainer approval. The +extension's shared-replica + per-origin gate + client-side grant authoring need **no** PSS +server change; they work against any spec-conformant Solid server today, with server-side WAC +as the ultimate enforcement backstop. + +## Appendix — Sources (verified June 2026) + +- [Chrome: content scripts manifest (MAIN-world / document_start)](https://developer.chrome.com/docs/extensions/reference/manifest/content-scripts) +- [chromium-extensions: MV3 MAIN-world injection ordering](https://groups.google.com/a/chromium.org/g/chromium-extensions/c/eVZd_vOIryc) +- [Chrome: declarativeNetRequest API (body/redirect/SW-fetch limits)](https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest) +- [Chrome: Storage and cookies (extension origin not site-partitioned)](https://developer.chrome.com/docs/extensions/develop/concepts/storage-and-cookies) +- [Chrome: Storage Partitioning](https://developer.chrome.com/en/docs/privacy-sandbox/storage-partitioning/) +- [Chrome: service worker lifecycle (~30s idle; Chrome 116 WS keepalive)](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle) +- [RFC 9449 DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) · [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html) +- In-repo: `solid-browser-extension/src/{inject/inject.ts,content/content-script.ts,background/service-worker.ts,background/core/{origin-policy.ts,authenticated-fetch.ts,dpop.ts}}` +- In-repo: `solid-offline/src/{scope.ts,notifications.ts,logout.ts,swr.ts,cache-coherence.ts,metadata-store.ts,invalidation.ts}` · `solid-elements/src/{auth/proactive-fetch.ts,components/account-menu.ts}` +- tt6: `full-solid-ecosystem/docs/design/access-management-solid-lws.md` \ No newline at end of file diff --git a/apps/extension/docs/manifest-permissions.md b/apps/extension/docs/manifest-permissions.md new file mode 100644 index 000000000..e03c38aa0 --- /dev/null +++ b/apps/extension/docs/manifest-permissions.md @@ -0,0 +1,120 @@ +# Manifest permissions — rationale (EXT-05) + +The host permission enables worker egress to user-chosen Solid servers. Explicit origin +consent, independently checked by the worker, controls which pages can use that egress. + +## Permissions + +| Permission | Purpose | +|---|---| +| `storage` | Settings, recent-account display metadata, client IDs, transient PKCE parameters, and separate `solid:origin-grants` / `solid:formfill-grants` tables. Local storage is restricted to trusted extension contexts; page scripts and content scripts do not read grants directly. The origin table contains `{origin, profileId, scope: {pods, classes?}, createdAt, expiresAt?}`; class grants nest under their parent. Tokens and non-extractable DPoP keys remain in worker-owned profile IndexedDB. Legacy implicit grants do not authorize access. | +| `identity` | Solid-OIDC authorization-code + PKCE through `chrome.identity.launchWebAuthFlow`. | +| `sidePanel` | The persistent account surface; EXT-06 owns its grants-manager UI. | +| `contextMenus` | The toolbar's “Open Solid side panel” menu entry and the “Fill from pod” entry on editable controls. Form-fill clicks are routed only to the clicked tab and frame. | +| `scripting` | Register the MAIN-world fetch-patch entry only for explicitly granted origins, with one exact `scheme://host:port/*` match per origin (including explicit default ports), no wildcard hosts or schemes, top frame only. Reconcile on worker wake, install/update, grant changes, and expiry. Remove the legacy `solid-main-world-inject` all-origins registration. | +| `alarms` | Wake the worker at the earliest origin-grant expiry to unregister scripts and clear page capabilities. Alarms are a cleanup mechanism: every request independently checks expiry, even if the alarm is late. | + +No `tabs` permission is requested. Existing host access permits tab-URL visibility for +scoped state broadcasts. Each tab receives only its bound profile's WebID; other tabs +receive null. The worker revalidates each tab's URL origin immediately before sending to +the top frame. Tab messages include the intended `origin`; the ISOLATED bridge drops a +message unless it equals its own `location.origin`, closing navigation races during delivery. +Switching the default profile does not change origin bindings. + +## `host_permissions: [""]` + +The worker discovers arbitrary user-chosen WebID, pod and issuer origins and performs +cross-origin discovery, token exchange/refresh and authenticated resource access. These +hosts cannot be enumerated at installation. The broad host permission does not grant +web pages access to any profile or pod. + +The worker enforces these boundaries before cache access or egress: + +- The ISOLATED bridge overwrites any page-supplied origin with `window.location.origin`. + It also reads transient user activation itself. The worker requires agreement with + Chrome's sender origin/URL; mismatched, opaque and missing anchors fail closed. +- A live explicit origin grant selects exactly one profile. Login, a client-ID declaration, + a pod/issuer origin, a default-profile switch, and standalone legacy class records do not + grant a page access. Removed/signed-out profiles cannot supply credentials. +- Credential targets must be in both the profile's allowed-origin set and the parent + grant's pod scope. Nested class grants additionally enforce pinned targets, mode, WebID + and expiry. A child cannot bypass an absent, revoked or expired parent. +- Page-supplied `Authorization`/`DPoP` headers are stripped. Tokens attach only to approved + profile credential origins, never the token endpoint, never remote cleartext HTTP. +- Foreign targets are permitted only on the calling app's own origin without Solid + credentials. A declined automatic diversion returns a sentinel, with no worker network; + the page performs its own native fetch with normal CORS semantics. + +Revocation unregisters future injections, cancels pending profile operations and purges +that origin's replica slice. Existing pages also lose their privileged API/patch on the +state broadcast. A captured function remains subject to the worker gate on its next call. +Grant expiry is checked before serving cached bytes, before egress and before returning +results. Registration failure does not confer authority. + +## Manifest content scripts: `[""]` + +The ISOLATED bridge and MAIN-world **stub** remain available on arbitrary pages so a new +app can request consent. Without an origin grant `window.solid` has only `login` and +`requestAccess`, plus the credential-free DOM presence marker/event. There is no `webId`, +`fetch`, client-ID setter, logout or class-management API, and native `window.fetch` is +preserved. The stub enables these capabilities only after a grant-backed state response. +The dynamic registration is scoped to granted origins, never ``. + +Presence and page-side capability state are cooperation signals, not authentication: +a page can spoof messages to its own MAIN-world scripts, but cannot mint an origin grant, +select a different profile's credentials or bypass worker enforcement. + +## Trusted management seam + +Only browser-attested extension contexts may list origin grants (`SOLID_ORIGIN_GRANTS_LIST`), +revoke (`SOLID_ORIGIN_GRANT_REVOKE`), shorten expiry (`SOLID_ORIGIN_GRANT_EXPIRY`), or change +profile (`SOLID_ORIGIN_GRANT_PROFILE`). Mutations name the exact `origin`; expiry uses an +epoch-millisecond `expiresAt`, profile changes use `profileId`. Profile rebinding clears the +old pod/class scope and cached slice; fresh class consent is required for data access. +The consent picker resolves and displays each profile's own targets, never transfers the +previous profile's targets. + +Chrome's [content-script registration](https://developer.chrome.com/docs/extensions/reference/api/scripting#method-registerContentScripts) +and [match-pattern documentation](https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns) +describe the browser API; the worker's canonical-origin comparison remains the authority. + +Dynamic registrations always specify a port: HTTPS uses `:443` and HTTP uses `:80` when +its canonical origin omits the default port. Chrome treats an omitted pattern port as a +wildcard, so `https://app.example/*` would also inject into `https://app.example:8443`. +An explicit default port also matches port-less URLs; no wildcard-port fallback is needed. +This follows Chrome's [port matching documentation](https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns) +and Chromium's [`ExtensionURLPatternTest.Match17`](https://chromium.googlesource.com/chromium/src/+/main/extensions/common/url_pattern_unittest.cc), +which checks default-port matching and rejection of alternate ports. Unit regressions model +these port semantics for HTTPS, HTTP, custom ports and IPv6, including replacement of old +wildcard-port registrations on wake. + +## Form-fill consent + +`solid:formfill-grants` stores `{origin, profileId, categories: ('contact'|'address')[], +createdAt, expiresAt?}` independently of origin/API grants. These records authorize no +`window.solid` access and do not register the MAIN-world fetch patch. Removal/sign-out +revokes the profile's form-fill records; expired or malformed records cannot select a profile. + +The ISOLATED script runs in all HTTP(S) frames so an editable context-menu click can be +routed by `frameId`. The worker derives the requesting origin from Chrome's sender origin +and document URL, cross-checks the tab's top origin, and refuses opaque, insecure and +cross-origin frames before loading data. The content script also refuses locally and +rechecks each control at write time. The MAIN-world stub receives no form-fill messages. + +The closed-shadow field button requires a trusted click or Enter/Space. Both triggers +open `formfill/preview.html` in extension chrome, with category checkboxes, a profile +picker and optional remembering. Every fill requires an explicit preview decision; +remembered categories only preselect checkboxes. Only the extension's preview may read +pending values or decide. Cancel, OS close, closing the requesting tab, or navigating it to +another origin returns no values. Discovery has a 30-second loading timeout before opening +the preview; review has only a 24-hour safety cap. Same-origin navigation keeps it open. +The response stays on the original content-script request channel, checks the current +site and profile again, and includes only requested values in approved categories. +Counts are reported after writing, without values or persistent telemetry. + +Public profile discovery uses `credentials: 'omit'`. Private documents must be explicitly +linked from that profile and reside on its pod origins (the issuer is not added separately). +The existing authenticated fetch provides DPoP; redirects are errors. No values are stored +in page-accessible storage. Chrome's [context-menu API](https://developer.chrome.com/docs/extensions/reference/api/contextMenus) +provides the clicked frame, and its [storage API](https://developer.chrome.com/docs/extensions/reference/api/storage) +provides the trusted-context access level. diff --git a/apps/extension/docs/store-listing/listing.md b/apps/extension/docs/store-listing/listing.md new file mode 100644 index 000000000..0ec5d0290 --- /dev/null +++ b/apps/extension/docs/store-listing/listing.md @@ -0,0 +1,71 @@ + +# Solid — store listing draft + +**DRAFT — pending Jesse Wright's review. Preparation only; do not submit or publish.** + +## Name + +Solid + +## Short description + +Sign in to your Solid accounts, choose which sites can use each account, and manage access from one browser panel. + +## Detailed description + +Keep your Solid accounts together in your browser and choose what you share with each site. +Solid lets you keep your account and data with providers you choose. This extension connects +supported websites to those accounts after you approve access. + +- Add up to eight accounts, give them useful names, and choose a default account. +- Choose which account a site can use. Your work and personal sites can use different accounts + at the same time. +- Review a site's request before sharing. The approval window shows the account, the data + requested, the actions allowed, and when access ends. +- Open the side panel to see sites with access, review their permissions, shorten access, + change the connected account, or revoke access. Changing an account requires fresh approval + before the site can read that account's data. +- Fill contact and address forms from your profile. Click the small button on a form field, + or right-click it, review the exact values in an extension window, and choose what to fill. + Nothing is filled or submitted without your choice, and password and payment fields are + never touched. +- Use the toolbar menu for quick account controls, or keep the side panel open while browsing. +- Remove an account from the extension when you no longer want to use it here. + +Signing in does not automatically share your account with every website. A site receives +your account address and approved data only after you give it access. Sign-in tokens and +private security keys stay inside the extension's background storage. + +You need an existing Solid account and a supported website. This extension does not create +an account or host your data. Removing an account from the extension leaves the account and +its files with your provider. + +There are no analytics, advertisements, or sales of your data. + +## Category + +Productivity + +## Single purpose statement + +Let people sign in to their Solid accounts in the browser and control which account and +data each supported website can access. + +## Planned screenshots + +These are capture plans, not supplied images. Capture each as a **1280 × 800 PNG**, showing +the named surface in a browser window. Use demonstration accounts and data; keep all visible +text readable. Jesse must review the final captures and captions before submission. + +| Planned file | Surface and state | Caption | +|---|---|---| +| `01-sign-in.png` | Toolbar popup with the account sign-in form and pin reminder | Add your Solid account to your browser. | +| `02-accounts.png` | Toolbar popup with two signed-in accounts, their names, and the default-account control | Keep work and personal accounts together. | +| `03-approve-access.png` | Access approval window with the account picker, requesting site, selected data, allowed actions, and expiry | Choose what to share and which account to use. | +| `04-sites-with-access.png` | Side panel with two sites bound to different accounts; expand one site's access details | Review each site's access in one place. | +| `05-revoke-access.png` | Side panel with the “Revoke site access?” confirmation open | Stop a site's access whenever you choose. | + +## Review before use + +Jesse must approve the wording, category, and screenshots, and supply the final public +privacy-policy URL and support contact. This folder does not submit a store listing. diff --git a/apps/extension/docs/store-listing/permission-justifications.md b/apps/extension/docs/store-listing/permission-justifications.md new file mode 100644 index 000000000..3c4bac032 --- /dev/null +++ b/apps/extension/docs/store-listing/permission-justifications.md @@ -0,0 +1,136 @@ + +# Solid — draft permission justifications and data declarations + +**DRAFT — pending Jesse Wright's review. Proposed form answers only; do not submit.** + +These answers cover every permission in [`src/manifest.json`](../../src/manifest.json) +and follow the repository's [permission rationale](../manifest-permissions.md). The +[Chrome Web Store privacy form](https://developer.chrome.com/docs/webstore/cws-dashboard-privacy) +asks for a single purpose, a justification for each permission, a remote-code declaration, +and data-use disclosures and certifications. Jesse must confirm the final package matches +these drafts before entering them in the dashboard. + +## Single purpose + +Let people sign in to their Solid accounts in the browser and control which account and +data each supported website can access. + +## Permission: `storage` + +Remember account display details, recent accounts, preferences, application identifiers, +temporary sign-in setup, and the user's site-access decisions. Each access decision binds +one exact site to one account and the approved data, actions, and expiry. Sign-in tokens and +non-exportable private security keys are kept separately in the worker's account IndexedDB, +outside web pages. This permission supports the local settings and grants stored through +Chrome's storage API; it does not send those records to a developer service. + +## Permission: `identity` + +Open the user's chosen Solid identity provider in Chrome's sign-in flow and receive the +redirect back to the extension using `chrome.identity.launchWebAuthFlow`. This completes +sign-in and lets the worker obtain the selected account's tokens. It does not request the +user's Google account or read the password entered at the Solid provider. + +## Permission: `sidePanel` + +Provide the account controls and “Sites with access” manager in a persistent Chrome side +panel. Users can manage profiles, inspect site access, shorten expiry, change a site's +account, and revoke access while keeping their current page open. + +## Permission: `contextMenus` + +Add the “Open Solid side panel” item to the extension toolbar icon's context menu. This +opens the persistent account and access manager. The ordinary toolbar click opens the +quick account popup. It also adds “Fill from pod” to the context menu of editable form +fields; the click is routed only to that tab and frame, and opens the form-fill review +window. + +## Permission: `scripting` + +Register the packaged page-fetch integration only on origins the user has explicitly +approved. Each dynamic registration matches one exact scheme, hostname, and port, in the +top frame. The worker updates these registrations when access changes, expires, or the +worker starts. It removes registrations when access ends. The worker independently checks +the current grant on every request, including calls saved by an already-open page. + +## Permission: `alarms` + +Wake the worker when the earliest site-access grant expires so it can remove the site's +registered integration and clear access in open pages. Every data request also checks +expiry itself, so a delayed alarm cannot extend access. + +## Host permission: `` + +Connect to the Solid account, identity provider, and pods chosen by the user. Those servers +can be on arbitrary domains and cannot be listed in advance. The worker needs cross-origin +access for account and provider discovery, sign-in and token renewal, public application +identity documents, profile information, and approved pod reads and writes. + +The manifest also loads a small packaged consent interface and its message bridge on web +pages so a new site can ask for access. Before approval it exposes only sign-in and access +request functions, without an account address or authenticated fetch, and leaves the page's +native fetch unchanged. Approval binds that exact site to one account and data scope. The +worker checks Chrome's verified sending origin, the live grant, and the allowed destination +before accessing cached data or sending a request. Unrelated pages cannot use the host +permission to obtain another account's data or credentials. + +The isolated message bridge also runs in subframes so a "Fill from pod" click on a +form field inside one can be routed to that frame. Form filling works only on HTTPS pages +(loopback HTTP for development) and frames with the top page's origin; cross-origin frames +are refused. The bridge never sends profile values unless the user approves them in the +extension's review window. + +No `tabs` permission is requested. Existing host access supports the tab-origin checks +needed to send access updates only to the relevant pages. There are no optional permissions +in the current manifest. + +## Remote code + +**Proposed answer: No, I am not using remote code.** + +All executable extension code and bundled dependencies ship in the extension package. +Remote account documents, sign-in metadata, application identity documents, pod resources, +and profile images are treated as data. They are not downloaded scripts or evaluated code. +The extension has no remote script loader or remote code updates outside Chrome's normal +extension update mechanism. + +## Data-usage disclosures + +Use these disclosures alongside the [draft privacy policy](privacy-policy.md). The fact +that data stays local or travels directly to user-chosen services must not be presented as +“no user data handled.” + +| Form data type | Draft disclosure and use | +|---|---| +| Personally identifiable information | Account addresses, display names, profile photo addresses, and user-chosen account labels support account selection. An approved site sees only the account bound to its grant. When the user asks to fill a form, the extension reads their name, email address, phone number, organisation and postal addresses from their own profile and address book, shows them for review, and writes only approved values into that page. Choosing Fill shares those values with the website. The values are not stored or sent to the developer; remembered per-site choices (which kinds of detail were allowed, not the values) are kept until sign-out or profile removal. | +| Authentication information | Account tokens and non-exportable security keys stay in worker storage. Sign-in exchanges go to the chosen provider; authenticated data requests go to approved pod destinations. Websites never receive tokens or private keys. The extension does not store passwords. | +| Web history | Limited site origins and resource addresses are retained in access decisions, application mappings, and cache metadata. The extension does not record general browsing history. Disclose this limited URL handling conservatively for Jesse's review. | +| Website content | The extension handles the pod data requested by approved apps, may cache it locally, and returns permitted results to those apps. To offer form filling, it automatically inspects form fields on visited pages, locally in the browser: labels, autocomplete hints, accessible names, field names, IDs and placeholders. It does not read typed values or send this to anyone, and profile data is only fetched when the user asks to fill. It does not scrape arbitrary page contents. | +| Personal communications / location | Not collected. Form-fill contact details are disclosed under personally identifiable information. Jesse should review whether postal-address filling also needs the location category. | + +The extension has no dedicated collection of health, financial, payment, location, personal +communication, or general interaction/activity data. A user-selected pod resource can +itself contain sensitive information in these categories. Jesse must review the supported +data and applicable form categories before certification; this draft does not claim that +arbitrary pod content is free of sensitive information. + +There is no analytics, advertising, or developer collection endpoint. Recent-account +metadata can remain after logout or profile removal; uninstalling removes all local +extension data. Profile removal deletes that account's credentials, access, and cached +pod data. Revoking a site's access deletes the cached data for that access; an item the +browser fails to delete stays indexed and is deleted at the profile's next sign-out or +removal. These retention details match the draft privacy policy. + +## Proposed data-usage certifications + +Jesse should certify the following only after reviewing the final release: + +- User information is not sold or passed to third parties beyond the exchanges needed to + provide the connections and sharing the user chooses. +- Information is used and shared only to deliver this extension's stated single purpose. +- Information is never used or shared to assess creditworthiness or make lending decisions. + +The proposed declarations comply with the Chrome Web Store's Limited Use restrictions. +Final review must confirm that the published privacy policy, dashboard selections, and +packaged behavior agree. Public privacy-policy URL and monitored support contact: +**[Jesse: fill in before publication]**. diff --git a/apps/extension/docs/store-listing/privacy-policy.md b/apps/extension/docs/store-listing/privacy-policy.md new file mode 100644 index 000000000..963b32960 --- /dev/null +++ b/apps/extension/docs/store-listing/privacy-policy.md @@ -0,0 +1,121 @@ + +# Solid — draft privacy policy + +**DRAFT — pending Jesse Wright's review. Not a published policy or a store submission.** + +Effective date: **[Jesse: fill in before publication]** + +Solid connects supported websites to the Solid accounts and data stores you choose. A Solid +data store is often called a “pod.” This policy describes the extension's own handling of +your information. Your identity provider, pod provider, and sites you approve have their +own privacy policies. + +## What stays on your device + +The extension stores the following locally in your browser: + +- **Sign-in tokens and security keys.** Each account has its own access token, refresh token + when supplied, and security key pair. These live only in the extension worker's account + database (IndexedDB), outside web pages. Private keys cannot be exported. The extension + does not give tokens or private keys to websites. You enter your password on your chosen + identity provider's sign-in page; the extension does not save that password. +- **Accounts and recent-account details.** Account addresses, display names, profile photo + addresses, account labels, provider addresses, and pod settings support the account menu + and sign-in experience. A recent-account list remembers up to eight account addresses, + names, and photo addresses so you can sign in again. +- **Access decisions.** Local grants record the sites you approve, the account chosen for + each site, the data and actions allowed, resource addresses, and access dates and expiry. + These decisions are kept in extension storage and checked for every request. +- **Settings and sign-in setup.** The extension remembers application identity-document + addresses and preferences, and temporarily saves the values needed to finish a sign-in. +- **Local copies of approved pod data.** The worker can cache resource contents in the + extension's CacheStorage, with resource metadata and security challenge values in + IndexedDB. The cache separates accounts and access scopes. Access is checked before a + cached response is served, and reads are checked with the pod for updates. +- **Form-fill choices.** If you choose **Remember for this site** when filling a form, the + extension records the site, the account, the kinds of detail you allowed (contact details + and/or address), and when you chose them. This is kept separately from site access and + never gives a site access to your account or pod. These records have no automatic expiry; + signing out of or removing that profile deletes them. Revoking a site's access does not + delete its remembered form-fill choice. + +These stores are local to the extension; the extension does not sync them to a developer +service. It does not collect a general browsing history. Site and resource addresses saved +for access decisions and cached data are used to provide the connection you approved. + +## What is sent and who receives it + +Sign-in requests go directly to your chosen identity provider. Token exchanges and renewals +send the sign-in information that provider needs. Requests for your data go directly to +your chosen pods. Tokens and signed security proofs are sent only to the appropriate +approved provider or pod; private keys stay on your device. + +A website you approve receives the selected account's public account address and the +results of requests allowed by its access grant. A site without your approval receives +only the ability to ask you to sign in or request access, without your account address or +pod contents. Ordinary website traffic continues under the browser's normal rules. + +On pages you visit, the extension looks at form fields locally, in your browser, to find +ones it could fill. It reads field labels, autocomplete hints, accessible names, field names, +IDs and placeholders. It does not read what you have typed, and it sends nothing anywhere +while doing this. + +When you ask it to fill a form, the extension reads your name, email address, phone number, +organisation and postal address from your account profile and linked address book. It shows +them to you in an extension window first. Only the values you approve are written into the +fields of that page, in the same tab and frame. Choosing **Fill** shares those values with +the website straight away: the page's own scripts can read or send them before you submit, +just as if you had typed them. The extension never submits the form and does not save the +values. Forms are never filled without your request, and password, payment and +one-time-code fields are never filled. + +The extension also reads public account information, profile photos, provider information, +and application identity documents from the addresses associated with your account or app. +The extension's own public application identity document may be read from its project +website. These public lookups do not send your Solid tokens or private keys to unrelated +hosts. They retrieve data and images, not executable extension code. + +Jesse and the extension's developers do not receive copies of your tokens, keys, account +list, access decisions, or pod contents. There is no developer-operated data collection +service. Your chosen providers receive normal network request information, such as your +IP address, when you connect to them. + +## No tracking, remote code, or sale of data + +The extension contains no analytics or advertising trackers. It does not download or run +remote code; its executable code is included in the installed extension. + +We do not sell, rent, or transfer your information to advertisers, data brokers, or other +third parties. Information is exchanged only to provide the account connections and data +sharing you choose, as described above. It is not used for unrelated purposes, credit +decisions, or lending. Its use is limited to the extension's stated purpose and the Chrome +Web Store's Limited Use requirements. + +## Your controls and deletion + +- **Revoke a site's access** in the side panel to remove its grant and the local cached data + for that access scope. Its next extension request is denied, including requests through + a previously saved function. Other sites keep their own grants. If the browser fails to + delete a cached item, the extension keeps its record and deletes the item when you sign out + of or remove that profile. +- **Remove a profile** in the account controls to delete that account's extension sign-in + tokens and keys, revoke its site access, delete its remembered form-fill choices, and + purge its locally cached pod data. This does + not delete your account or files at your provider. +- **Sign out** to clear the selected account's extension session and local cached data. + Other signed-in accounts remain available. Account display entries and recent-account + details can remain so you can sign in again; removing a profile also leaves the separate + recent-account list and application settings. +- **Uninstall the extension** to remove its browser-managed local storage, including + remaining recent-account details, settings, grants, tokens, keys, and cached pod data. + +Revoking access or uninstalling cannot erase copies a site already received, and does not +delete information stored by your chosen identity or pod provider. Manage those copies +with the site or provider concerned. They may also retain their own sign-in sessions. + +## Contact and updates + +Privacy contact: **[Jesse: insert a monitored contact email or support URL before publication]**. + +If the extension's data practices change, this policy and the store disclosures must be +updated to describe those practices before the change is released. diff --git a/apps/extension/e2e/README.md b/apps/extension/e2e/README.md new file mode 100644 index 000000000..f9f8f761b --- /dev/null +++ b/apps/extension/e2e/README.md @@ -0,0 +1,55 @@ +# Extension E2E tests + +Use Node.js 24 and the workspace lockfile, from the solid-sdk root: + +```sh +pnpm install --frozen-lockfile +pnpm --filter @jeswr/app-extension exec playwright install chromium +pnpm --filter @jeswr/app-extension run test:e2e +``` + +CI runs the same E2E command in `.github/workflows/extension.yml`; `gate.yml` covers lint, +typecheck, unit tests and build. The command builds `dist/`, then Playwright's global +setup starts an in-memory Community Solid Server on port 3000, seeded from +`setup/seed.json` and `setup/templates/pod/`, with an isolated Components.js module root +(see `setup/css-server.ts`). It discovers the unpacked +extension's ID and starts the test sites on ports 8080 and 8081 with matching Client +ID Documents. No separately running server or live pod is needed. + +The seed has two independent accounts (`test@example.com` / `test-pod` for Alex Wilder +and `work@example.com` / `work-pod` for Morgan Chen), both with password +`test-password-123`. Each pod's private note includes its own text and owner WebID; +its public type index resolves only that pod's note. + +Each test loads the real extension in a fresh persistent context using headless +Chromium (`channel: 'chromium'`, which supports extensions). No display server is +needed. The suite has zero retries. + +Popup sign-in does not grant unrelated web origins access. The fetch/navigation +tests first check that the ungranted test site gets only the `window.solid` stub, then +grant it through the real flow: a user click calls `window.solid.requestAccess`, and the +test approves the consent window (profile picker included). The revocation test checks +that a captured `fetch` stops working on the next request. + +`multi-profile.spec.ts` adds both accounts through the profile controls, explicitly +binds `http://localhost:8080` to Alex and `http://localhost:8081` to Morgan in the +consent picker, and checks private reads and denials in both directions, including +after a default-profile switch. It uses `http://127.0.0.1:8080` as a third, ungranted +origin on the existing test server and checks the exact consent-only API and native +fetch. Finally, it revokes the first site's access through the side panel, checks +its next captured fetch is denied, and verifies the second site's reads still work. +The test has its own fresh browser context and creates all its own profiles and +grants; it does not depend on another spec's state or change shared pod data. + +On runners with a read-only home directory, Chromium can fail before any test with +`chrome_crashpad_handler: --database is required`. The launches disable crash +reporting and Breakpad; if the error persists, give Chromium a writable config +directory (without changing where Playwright finds the installed browser): + +```sh +mkdir -p /tmp/solid-extension-chromium-config +XDG_CONFIG_HOME=/tmp/solid-extension-chromium-config npm run test:e2e +``` + +Likewise, if npm's default cache is read-only, set `npm_config_cache` to a writable +directory when installing and running the suite. diff --git a/apps/extension/e2e/fixtures.ts b/apps/extension/e2e/fixtures.ts new file mode 100644 index 000000000..bc6200c77 --- /dev/null +++ b/apps/extension/e2e/fixtures.ts @@ -0,0 +1,40 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { type BrowserContext, test as base, chromium } from '@playwright/test'; + +// This package is `"type": "module"`; Playwright loads this file as ESM, where `__dirname` +// is not defined. Derive it from `import.meta.url`. +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const test = base.extend<{ + context: BrowserContext; + extensionId: string; +}>({ + // biome-ignore lint/correctness/noEmptyPattern: Playwright's fixture-deps signature. + context: async ({}, use) => { + const pathToExtension = path.resolve(__dirname, '../dist'); + const context = await chromium.launchPersistentContext('', { + channel: 'chromium', + headless: true, + args: [ + '--disable-crash-reporter', + '--disable-breakpad', + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}`, + ], + }); + await use(context); + await context.close(); + }, + extensionId: async ({ context }, use) => { + // Wait for the service worker to be registered + let sw = context.serviceWorkers()[0]; + if (!sw) { + sw = await context.waitForEvent('serviceworker'); + } + const extensionId = sw.url().split('/')[2]; + await use(extensionId); + }, +}); + +export const expect = test.expect; diff --git a/apps/extension/e2e/playwright.config.ts b/apps/extension/e2e/playwright.config.ts new file mode 100644 index 000000000..49c5803f4 --- /dev/null +++ b/apps/extension/e2e/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + timeout: 60_000, + fullyParallel: true, + retries: 0, + use: { + headless: true, + }, + // Paths are resolved relative to this config file's directory. This package is + // `"type": "module"`, so Playwright loads these `.ts` files as ESM — `require` is not + // defined in that scope (see the CI ReferenceError this replaced). Use plain relative + // strings instead of `require.resolve`. + globalSetup: './setup/global-setup.ts', + globalTeardown: './setup/global-teardown.ts', +}); diff --git a/apps/extension/e2e/setup/css-config.json b/apps/extension/e2e/setup/css-config.json new file mode 100644 index 000000000..8969a79d6 --- /dev/null +++ b/apps/extension/e2e/setup/css-config.json @@ -0,0 +1,66 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", + "import": [ + "css:config/app/init/initialize-intro.json", + "css:config/app/main/default.json", + "css:config/app/variables/default.json", + "css:config/http/handler/default.json", + "css:config/http/middleware/default.json", + "css:config/http/notifications/all.json", + "css:config/http/server-factory/http.json", + "css:config/http/static/default.json", + "css:config/identity/access/public.json", + "css:config/identity/email/default.json", + "css:config/identity/handler/default.json", + "css:config/identity/oidc/default.json", + "css:config/identity/ownership/token.json", + "css:config/ldp/authentication/dpop-bearer.json", + "css:config/ldp/authorization/webacl.json", + "css:config/ldp/handler/default.json", + "css:config/ldp/metadata-parser/default.json", + "css:config/ldp/metadata-writer/default.json", + "css:config/ldp/modes/default.json", + "css:config/storage/backend/memory.json", + "css:config/storage/key-value/resource-store.json", + "css:config/storage/location/root.json", + "css:config/storage/middleware/default.json", + "css:config/util/auxiliary/acl.json", + "css:config/util/identifiers/suffix.json", + "css:config/util/index/default.json", + "css:config/util/logging/winston.json", + "css:config/util/representation-conversion/default.json", + "css:config/util/resource-locker/memory.json", + "css:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": "Pod manager using our custom templates (with pim:storage in profile).", + "@id": "urn:solid-server:default:PodManager", + "@type": "GeneratedPodManager", + "store": { "@id": "urn:solid-server:default:ResourceStore" }, + "resourcesGenerator": { "@id": "urn:solid-server:default:PodResourcesGenerator" } + }, + { + "@id": "urn:solid-server:default:PodResourcesGenerator", + "@type": "StaticFolderGenerator", + "templateFolder": "./e2e/setup/templates/pod", + "resourcesGenerator": { "@id": "urn:solid-server:default:TemplatedResourcesGenerator" } + }, + { + "@id": "urn:solid-server:default:TemplatedResourcesGenerator", + "@type": "SubfolderResourcesGenerator", + "subfolders": [ "base" ], + "resourcesGenerator": { + "@type": "BaseResourcesGenerator", + "factory": { + "@type": "ExtensionBasedMapperFactory" + }, + "templateEngine": { + "@id": "urn:solid-server:default:TemplateEngine" + }, + "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" }, + "store": { "@id": "urn:solid-server:default:ResourceStore" } + } + } + ] +} diff --git a/apps/extension/e2e/setup/css-server.ts b/apps/extension/e2e/setup/css-server.ts new file mode 100644 index 000000000..dc3fbb5c6 --- /dev/null +++ b/apps/extension/e2e/setup/css-server.ts @@ -0,0 +1,158 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// This package is `"type": "module"`; Playwright loads this file as ESM, where `__dirname` +// is not defined. Derive it from `import.meta.url`. +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +let serverProcess: ChildProcess | null = null; +let moduleRoot: string | null = null; + +const CSS_PORT = 3000; +const CSS_BASE_URL = `http://localhost:${CSS_PORT}`; + +export async function startCSSServer(): Promise { + const seedConfig = path.resolve(__dirname, 'seed.json'); + const cssConfig = path.resolve(__dirname, 'css-config.json'); + + // A server left on the port (e.g. by an aborted run) would answer the readiness probe + // while ours exits with EADDRINUSE, so the suite would silently run against stale state. + if (await isServerUp(CSS_BASE_URL)) { + throw new Error(`Port ${CSS_PORT} is already in use; stop the server holding it first.`); + } + + // Run the CSS entry point directly (not via npx) so SIGTERM reaches the server itself. + const cssBin = createRequire(import.meta.url).resolve('@solid/community-server/bin/server.js'); + moduleRoot = isolatedModuleRoot(cssBin); + serverProcess = spawn( + process.execPath, + [ + cssBin, + '-p', + String(CSS_PORT), + '-c', + cssConfig, + '--seedConfig', + seedConfig, + '-m', + moduleRoot, + '-l', + 'warn', + ], + { + stdio: 'pipe', + env: { ...process.env }, + }, + ); + + serverProcess.stderr?.on('data', (data: Buffer) => { + const msg = data.toString(); + if (msg.includes('Error') || msg.includes('error')) { + console.error('[CSS]', msg); + } + }); + + let exitCode: number | null = null; + serverProcess.on('exit', (code) => { + exitCode = code ?? -1; + }); + + // Wait for the server to be ready + await waitForServer(CSS_BASE_URL, 30_000, () => exitCode); +} + +export async function stopCSSServer(): Promise { + if (serverProcess) { + serverProcess.kill('SIGTERM'); + serverProcess = null; + } + if (moduleRoot) { + fs.rmSync(moduleRoot, { recursive: true, force: true }); + moduleRoot = null; + } +} + +/** + * Components.js scans every `node_modules` above its main module path. In the pnpm workspace + * that includes the hoisted `node_modules/.pnpm/node_modules`, where other packages' Comunica + * and CSS versions break config loading, while CSS's own transitive dependencies sit in + * per-package folders it never visits. Build a temporary root with a flat `node_modules` of + * CSS's dependency closure (first version found wins, as npm hoisting would) and point + * Components.js at it. + */ +function isolatedModuleRoot(cssBin: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ext-e2e-css-')); + const modules = path.join(root, 'node_modules'); + fs.mkdirSync(modules); + fs.writeFileSync(path.join(root, 'package.json'), '{"name":"ext-e2e-css","private":true}\n'); + const linked = new Map(); + const queue = [packageDir(fs.realpathSync(cssBin))]; + for (let dir = queue.shift(); dir; dir = queue.shift()) { + const name = packageName(dir); + if (linked.has(name)) continue; + linked.set(name, dir); + const target = path.join(modules, name); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.symlinkSync(dir, target, 'dir'); + // pnpm keeps a package's dependencies beside it in the same `node_modules` folder. + const siblings = path.resolve(dir, name.startsWith('@') ? '../..' : '..'); + for (const entry of fs.readdirSync(siblings)) { + if (entry.startsWith('.')) continue; + const entries = entry.startsWith('@') + ? fs.readdirSync(path.join(siblings, entry)).map((scoped) => path.join(entry, scoped)) + : [entry]; + for (const dep of entries) { + if (!linked.has(dep)) queue.push(fs.realpathSync(path.join(siblings, dep))); + } + } + } + return root; +} + +function packageDir(file: string): string { + let dir = path.dirname(file); + while (!fs.existsSync(path.join(dir, 'package.json'))) dir = path.dirname(dir); + return dir; +} + +function packageName(dir: string): string { + return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).name; +} + +function isServerUp(url: string): Promise { + return new Promise((resolve) => { + const req = http.get(url, (res) => { + res.resume(); + resolve(true); + }); + req.on('error', () => resolve(false)); + req.setTimeout(1000, () => { + req.destroy(); + resolve(false); + }); + }); +} + +async function waitForServer( + url: string, + timeoutMs: number, + exited: () => number | null, +): Promise { + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + const code = exited(); + if (code !== null) throw new Error(`Community Solid Server exited early (code ${code})`); + if (await isServerUp(url)) return; + await new Promise((r) => setTimeout(r, 500)); + } + + throw new Error(`Server at ${url} did not start within ${timeoutMs}ms`); +} + +export { CSS_BASE_URL, CSS_PORT }; diff --git a/apps/extension/e2e/setup/global-setup.ts b/apps/extension/e2e/setup/global-setup.ts new file mode 100644 index 000000000..1a52e1f20 --- /dev/null +++ b/apps/extension/e2e/setup/global-setup.ts @@ -0,0 +1,66 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; +import { startTestSiteServer } from '../../test-site/server'; +import { startCSSServer } from './css-server'; + +// This package is `"type": "module"`; Playwright loads this file as ESM, where `__dirname` +// is not defined. Derive it from `import.meta.url`. +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const TEST_SITE_PORT = 8080; +const APP_SITE_PORT = 8081; + +async function discoverExtensionId(): Promise { + const extPath = path.resolve(__dirname, '../../dist'); + const ctx = await chromium.launchPersistentContext('', { + channel: 'chromium', + headless: true, + args: [ + '--disable-crash-reporter', + '--disable-breakpad', + `--disable-extensions-except=${extPath}`, + `--load-extension=${extPath}`, + ], + }); + let sw = ctx.serviceWorkers()[0]; + if (!sw) sw = await ctx.waitForEvent('serviceworker'); + const extensionId = sw.url().split('/')[2]; + await ctx.close(); + return extensionId; +} + +async function globalSetup() { + console.log('Starting Community Solid Server...'); + await startCSSServer(); + console.log('CSS server ready on http://localhost:3000'); + + console.log('Discovering extension ID...'); + const extensionId = await discoverExtensionId(); + const redirectUri = `https://${extensionId}.chromiumapp.org/`; + console.log(` Extension ID: ${extensionId}`); + + // Port 8080: basic test site + extension's Client ID Document + const server = await startTestSiteServer({ + port: TEST_SITE_PORT, + redirectUri, + clientName: 'Solid Browser Extension', + }); + console.log(`Test site ready on http://localhost:${TEST_SITE_PORT}`); + + // Port 8081: app site with its own Client ID Document + const appClientIdUrl = `http://localhost:${APP_SITE_PORT}/client-id`; + const appServer = await startTestSiteServer({ + port: APP_SITE_PORT, + redirectUri, + clientName: 'Test Application', + clientIdUrl: appClientIdUrl, + }); + console.log(`App site ready on http://localhost:${APP_SITE_PORT}`); + + const globals = globalThis as Record; + globals.__TEST_SITE_SERVER__ = server; + globals.__APP_SITE_SERVER__ = appServer; +} + +export default globalSetup; diff --git a/apps/extension/e2e/setup/global-teardown.ts b/apps/extension/e2e/setup/global-teardown.ts new file mode 100644 index 000000000..49651a5b6 --- /dev/null +++ b/apps/extension/e2e/setup/global-teardown.ts @@ -0,0 +1,14 @@ +import type http from 'node:http'; +import { stopCSSServer } from './css-server'; + +async function globalTeardown() { + await stopCSSServer(); + + const globals = globalThis as Record; + for (const key of ['__TEST_SITE_SERVER__', '__APP_SITE_SERVER__']) { + const server = globals[key] as http.Server | undefined; + if (server) server.close(); + } +} + +export default globalTeardown; diff --git a/apps/extension/e2e/setup/seed.json b/apps/extension/e2e/setup/seed.json new file mode 100644 index 000000000..da4d934a5 --- /dev/null +++ b/apps/extension/e2e/setup/seed.json @@ -0,0 +1,28 @@ +[ + { + "email": "test@example.com", + "password": "test-password-123", + "pods": [ + { + "name": "test-pod", + "settings": { + "name": "Alex Wilder", + "privateNote": "Alex Wilder personal note" + } + } + ] + }, + { + "email": "work@example.com", + "password": "test-password-123", + "pods": [ + { + "name": "work-pod", + "settings": { + "name": "Morgan Chen", + "privateNote": "Morgan Chen work note" + } + } + ] + } +] diff --git a/apps/extension/e2e/setup/templates/pod/base/private/address$.ttl.hbs b/apps/extension/e2e/setup/templates/pod/base/private/address$.ttl.hbs new file mode 100644 index 000000000..8e1d337ad --- /dev/null +++ b/apps/extension/e2e/setup/templates/pod/base/private/address$.ttl.hbs @@ -0,0 +1,7 @@ +# AUTHORED-BY Codex GPT-6.1-Sol +@prefix vcard: . +<{{webId}}> vcard:hasAddress <#home>. +<#home> vcard:street-address "42 Sample Street"; + vcard:locality "Oxford"; + vcard:postal-code "OX1 1AA"; + vcard:country-name "United Kingdom". diff --git a/apps/extension/e2e/setup/templates/pod/base/private/notes$.ttl.hbs b/apps/extension/e2e/setup/templates/pod/base/private/notes$.ttl.hbs new file mode 100644 index 000000000..bb9bb4953 --- /dev/null +++ b/apps/extension/e2e/setup/templates/pod/base/private/notes$.ttl.hbs @@ -0,0 +1,8 @@ +# AUTHORED-BY Codex GPT-6.1-Sol +@prefix schema: . + +<#note1> + a schema:TextDigitalDocument; + schema:name "Private Note"; + schema:creator <{{webId}}>; + schema:text "{{privateNote}}". diff --git a/apps/extension/e2e/setup/templates/pod/base/profile/card$.ttl.hbs b/apps/extension/e2e/setup/templates/pod/base/profile/card$.ttl.hbs new file mode 100644 index 000000000..86d6e4cc2 --- /dev/null +++ b/apps/extension/e2e/setup/templates/pod/base/profile/card$.ttl.hbs @@ -0,0 +1,20 @@ +@prefix foaf: . +@prefix pim: . +@prefix solid: . +@prefix vcard: . + +<> + a foaf:PersonalProfileDocument; + foaf:maker <{{webId}}>; + foaf:primaryTopic <{{webId}}>. + +<{{webId}}> + {{#if name}}foaf:name "{{name}}";{{/if}} + {{#if name}}vcard:fn "{{name}}";{{/if}} + vcard:hasEmail ; + vcard:hasAddress <{{base.path}}private/address#home>; + vcard:hasPhoto "{{base.path}}profile/photo.jpg"; + {{#if oidcIssuer}}solid:oidcIssuer <{{oidcIssuer}}>;{{/if}} + solid:publicTypeIndex <{{base.path}}profile/type-index>; + pim:storage <{{base.path}}>; + a foaf:Person. diff --git a/apps/extension/e2e/setup/templates/pod/base/profile/photo.jpg b/apps/extension/e2e/setup/templates/pod/base/profile/photo.jpg new file mode 100644 index 000000000..f1bf407db Binary files /dev/null and b/apps/extension/e2e/setup/templates/pod/base/profile/photo.jpg differ diff --git a/apps/extension/e2e/setup/templates/pod/base/profile/type-index$.ttl.hbs b/apps/extension/e2e/setup/templates/pod/base/profile/type-index$.ttl.hbs new file mode 100644 index 000000000..e9c2cda03 --- /dev/null +++ b/apps/extension/e2e/setup/templates/pod/base/profile/type-index$.ttl.hbs @@ -0,0 +1,4 @@ +@prefix solid: . +<#notes> a solid:TypeRegistration; + solid:forClass ; + solid:instance <{{base.path}}private/notes>. diff --git a/apps/extension/e2e/setup/templates/pod/wac/.acl.hbs b/apps/extension/e2e/setup/templates/pod/wac/.acl.hbs new file mode 100644 index 000000000..a974c3810 --- /dev/null +++ b/apps/extension/e2e/setup/templates/pod/wac/.acl.hbs @@ -0,0 +1,26 @@ +# Root ACL resource for the agent account +@prefix acl: . +@prefix foaf: . + +# The homepage is readable by the public +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./>; + acl:mode acl:Read. + +# The owner has full access to every resource in their pod. +# Other agents have no access rights, +# unless specifically authorized in other .acl resources. +<#owner> + a acl:Authorization; + acl:agent <{{webId}}>; + # Optional owner email, to be used for account recovery: + {{#if email}}acl:agent ;{{/if}} + # Set the access to the root storage folder itself + acl:accessTo <./>; + # All resources will inherit this authorization, by default + acl:default <./>; + # The owner has all of the access modes allowed + acl:mode + acl:Read, acl:Write, acl:Control. diff --git a/apps/extension/e2e/setup/templates/pod/wac/README.acl.hbs b/apps/extension/e2e/setup/templates/pod/wac/README.acl.hbs new file mode 100644 index 000000000..d4b9c28a3 --- /dev/null +++ b/apps/extension/e2e/setup/templates/pod/wac/README.acl.hbs @@ -0,0 +1,14 @@ +@prefix acl: . +@prefix foaf: . + +<#public> + a acl:Authorization; + acl:accessTo <./README>; + acl:agentClass foaf:Agent; + acl:mode acl:Read. + +<#owner> + a acl:Authorization; + acl:accessTo <./README>; + acl:agent <{{webId}}>; + acl:mode acl:Read, acl:Write, acl:Control. diff --git a/apps/extension/e2e/setup/templates/pod/wac/profile/card.acl.hbs b/apps/extension/e2e/setup/templates/pod/wac/profile/card.acl.hbs new file mode 100644 index 000000000..91224c0b2 --- /dev/null +++ b/apps/extension/e2e/setup/templates/pod/wac/profile/card.acl.hbs @@ -0,0 +1,19 @@ +# ACL resource for the WebID profile document +@prefix acl: . +@prefix foaf: . + +# The WebID profile is readable by the public. +# This is required for discovery and verification, +# e.g. when checking identity providers. +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./card>; + acl:mode acl:Read. + +# The owner has full access to the profile +<#owner> + a acl:Authorization; + acl:agent <{{webId}}>; + acl:accessTo <./card>; + acl:mode acl:Read, acl:Write, acl:Control. diff --git a/apps/extension/e2e/setup/templates/pod/wac/profile/photo.jpg.acl.hbs b/apps/extension/e2e/setup/templates/pod/wac/profile/photo.jpg.acl.hbs new file mode 100644 index 000000000..098918558 --- /dev/null +++ b/apps/extension/e2e/setup/templates/pod/wac/profile/photo.jpg.acl.hbs @@ -0,0 +1,17 @@ +# ACL resource for the profile photo +@prefix acl: . +@prefix foaf: . + +# The profile photo is readable by the public. +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./photo.jpg>; + acl:mode acl:Read. + +# The owner has full access to the photo +<#owner> + a acl:Authorization; + acl:agent <{{webId}}>; + acl:accessTo <./photo.jpg>; + acl:mode acl:Read, acl:Write, acl:Control. diff --git a/apps/extension/e2e/setup/templates/pod/wac/profile/type-index.acl.hbs b/apps/extension/e2e/setup/templates/pod/wac/profile/type-index.acl.hbs new file mode 100644 index 000000000..cacef2149 --- /dev/null +++ b/apps/extension/e2e/setup/templates/pod/wac/profile/type-index.acl.hbs @@ -0,0 +1,19 @@ +# AUTHORED-BY Claude Opus 5.5 +# ACL resource for the public type index +@prefix acl: . +@prefix foaf: . + +# A solid:publicTypeIndex is public by definition: the extension reads it +# without credentials during class discovery. +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./type-index>; + acl:mode acl:Read. + +# The owner has full access to the type index +<#owner> + a acl:Authorization; + acl:agent <{{webId}}>; + acl:accessTo <./type-index>; + acl:mode acl:Read, acl:Write, acl:Control. diff --git a/apps/extension/e2e/tests/authenticated-fetch.spec.ts b/apps/extension/e2e/tests/authenticated-fetch.spec.ts new file mode 100644 index 000000000..d360f6610 --- /dev/null +++ b/apps/extension/e2e/tests/authenticated-fetch.spec.ts @@ -0,0 +1,77 @@ +import { expect, test } from '../fixtures'; +import { grantOrigin, performLogin } from './helpers'; + +const TEST_SITE = 'http://localhost:8080'; + +test('authenticated fetch retrieves a private resource via window.solid', async ({ + context, + extensionId, +}) => { + await performLogin(context, extensionId); + + const page = await context.newPage(); + await page.goto(TEST_SITE); + await grantOrigin(context, page); + + // The test site reads window.solid.webId + window.solid.fetch(private resource). + await expect(page.locator('#webid')).toContainText('test-pod', { timeout: 15_000 }); + await expect(page.locator('#fetch-result')).toContainText('Private Note', { timeout: 15_000 }); + await expect(page.locator('#status')).toContainText('Private resource fetched successfully'); +}); + +test('an ungranted origin stays stub-only and revocation stops a captured fetch', async ({ + context, + extensionId, +}) => { + await performLogin(context, extensionId); + const page = await context.newPage(); + await page.goto(TEST_SITE); + await expect + .poll(() => + page.evaluate(() => Object.keys((window as unknown as { solid: object }).solid).sort()), + ) + .toEqual(['login', 'requestAccess']); + await grantOrigin(context, page); + await expect(page.locator('#fetch-result')).toContainText('Private Note'); + await page.evaluate(() => { + (window as unknown as { capturedFetch: unknown }).capturedFetch = ( + window as unknown as { solid: { fetch: unknown } } + ).solid.fetch; + }); + const other = await context.newPage(); + await other.goto('http://localhost:8081'); + await expect + .poll(() => + other.evaluate(() => Object.keys((window as unknown as { solid: object }).solid).sort()), + ) + .toEqual(['login', 'requestAccess']); + const owner = await context.newPage(); + await owner.goto(`chrome-extension://${extensionId}/popup/popup.html`); + expect( + ( + await owner.evaluate(() => + chrome.runtime.sendMessage({ + type: 'SOLID_ORIGIN_GRANT_REVOKE', + origin: 'http://localhost:8080', + }), + ) + ).ok, + ).toBe(true); + await expect + .poll(() => + page.evaluate(() => Object.keys((window as unknown as { solid: object }).solid).sort()), + ) + .toEqual(['login', 'requestAccess']); + expect( + await page.evaluate(async () => { + try { + await ( + window as unknown as { capturedFetch: (url: string) => Promise } + ).capturedFetch('http://localhost:3000/test-pod/private/notes'); + return 'unexpected success'; + } catch (error) { + return (error as Error).message; + } + }), + ).toMatch(/not granted/); +}); diff --git a/apps/extension/e2e/tests/client-registration.spec.ts b/apps/extension/e2e/tests/client-registration.spec.ts new file mode 100644 index 000000000..9300a698b --- /dev/null +++ b/apps/extension/e2e/tests/client-registration.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from '../fixtures'; +import { completeOidcLogin, grantOrigin, TEST_WEBID } from './helpers'; + +const TEST_SITE = 'http://localhost:8080'; +const APP_SITE = 'http://localhost:8081'; + +test('default popup login (dynamic registration) authenticates a private fetch', async ({ + context, + extensionId, +}) => { + const popupPage = await context.newPage(); + await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); + // The signed-out surface is ; target its shadow parts. + await popupPage.getByLabel('Your WebID').fill(TEST_WEBID); + + const loginPagePromise = context.waitForEvent('page'); + await popupPage.getByRole('button', { name: 'Sign in', exact: true }).click(); + await completeOidcLogin(await loginPagePromise); + + await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); + await popupPage.locator('#signed-in').waitFor({ state: 'visible', timeout: 15_000 }); + await popupPage.close(); + + const page = await context.newPage(); + await page.goto(TEST_SITE); + await grantOrigin(context, page); + await expect(page.locator('#fetch-result')).toContainText('Private Note', { timeout: 15_000 }); +}); + +test('a website declares its own client identifier via window.solid.setClientId', async ({ + context, + extensionId: _extensionId, +}) => { + const page = await context.newPage(); + await page.goto(`${APP_SITE}/with-client-id.html`); + + // Wait for the extension to inject window.solid. + await page.waitForFunction(() => typeof (window as { solid?: unknown }).solid !== 'undefined', { + timeout: 5_000, + }); + + // The app reads its own Client ID Document URL and calls solid.setClientId(), then logs in. + const loginPagePromise = context.waitForEvent('page'); + await page.evaluate((webId) => { + (window as unknown as { solid: { login(id: string): Promise } }).solid + .login(webId) + .then(() => { + document.body.dataset.loginComplete = 'true'; + }); + }, TEST_WEBID); + await completeOidcLogin(await loginPagePromise); + await expect(page.locator('body')).toHaveAttribute('data-login-complete', 'true'); + await grantOrigin(context, page); + + await expect(page.locator('#fetch-result')).toContainText('Private Note', { timeout: 15_000 }); +}); diff --git a/apps/extension/e2e/tests/formfill.spec.ts b/apps/extension/e2e/tests/formfill.spec.ts new file mode 100644 index 000000000..335a69b2d --- /dev/null +++ b/apps/extension/e2e/tests/formfill.spec.ts @@ -0,0 +1,96 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import { expect, test } from '../fixtures'; +import { performLogin } from './helpers'; + +const site = 'http://localhost:8080/address-form.html'; + +test('fills approved categories after preview and remembers consent separately from API access', async ({ + context, + extensionId, +}) => { + await performLogin(context, extensionId); + const page = await context.newPage(); + await page.goto(site); + await expect(page.getByLabel('Full name', { exact: true })).toHaveValue(''); + // The closed shadow button is reached by clicking its visible host. + const previewPromise = context.waitForEvent('page'); + await page.locator('[data-solid-formfill]').click(); + const preview = await previewPromise; + await preview.waitForURL('**/formfill/preview.html?*'); + await expect(preview.getByLabel('Use profile')).toBeVisible(); + await expect(preview.locator('dd')).toContainText([ + 'Alex Wilder', + 'test@example.com', + '42 Sample Street', + 'Oxford', + 'OX1 1AA', + 'United Kingdom', + ]); + await preview.getByLabel('Contact details', { exact: true }).check(); + await preview.getByLabel('Address', { exact: true }).check(); + await preview.getByLabel('Remember for this site').check(); + // Exercise native keyboard submission. + await preview.getByRole('button', { name: 'Fill', exact: true }).focus(); + // Fill closes the preview window, which can end the key press before Playwright returns. + const closed = preview.waitForEvent('close'); + await preview.keyboard.press('Enter').catch((error: Error) => { + if (!/closed/.test(error.message)) throw error; + }); + await closed; + await expect(page.getByLabel('Full name', { exact: true })).toHaveValue('Alex Wilder'); + await expect(page.getByLabel('Street address')).toHaveValue('42 Sample Street'); + await expect(page.getByLabel('Country')).toHaveValue('GB'); + for (const id of ['secret', 'payment', 'code', 'off']) + await expect(page.locator(`#${id}`)).toHaveValue(''); + expect(page.url()).toBe(site); + expect(await page.evaluate(() => 'fetch' in (window as unknown as { solid: object }).solid)).toBe( + false, + ); + await page.reload(); + const againPromise = context.waitForEvent('page'); + await page.locator('[data-solid-formfill]').click(); + const again = await againPromise; + await expect(again.getByLabel('Contact details', { exact: true })).toBeChecked(); + await expect(again.getByLabel('Address', { exact: true })).toBeChecked(); + await expect(page.getByLabel('Full name', { exact: true })).toHaveValue(''); + await again.getByRole('button', { name: 'Cancel' }).click(); +}); + +test('cancel leaves every field untouched', async ({ context, extensionId }) => { + await performLogin(context, extensionId); + const page = await context.newPage(); + await page.goto(site); + const previewPromise = context.waitForEvent('page'); + await page.locator('[data-solid-formfill]').click(); + const preview = await previewPromise; + await preview.waitForURL('**/formfill/preview.html?*'); + await preview.getByRole('button', { name: 'Cancel' }).click(); + for (const input of await page.locator('input').all()) await expect(input).toHaveValue(''); +}); + +test('cross-origin iframe refuses icons and a worker-routed trigger', async ({ + context, + extensionId, +}) => { + await performLogin(context, extensionId); + const page = await context.newPage(); + await page.goto('http://localhost:8080/'); + await page.evaluate(() => { + const frame = document.createElement('iframe'); + frame.src = 'http://127.0.0.1:8080/address-form.html'; + document.body.append(frame); + }); + const frame = page.frameLocator('iframe'); + await expect(frame.getByLabel('Full name', { exact: true })).toBeVisible(); + await expect(frame.locator('[data-solid-formfill]')).toHaveCount(0); + await frame.getByLabel('Full name', { exact: true }).focus(); + // Browser context menus are outside Playwright's page surface; exercise the worker delivery seam. + await context.serviceWorkers()[0].evaluate(async () => { + const tabs = await chrome.tabs.query({ url: 'http://localhost:8080/*' }); + for (const tab of tabs) + if (tab.id !== undefined) + await chrome.tabs.sendMessage(tab.id, { type: 'SOLID_FORMFILL_TRIGGER' }).catch(() => {}); + }); + await expect(frame.getByLabel('Full name', { exact: true })).toHaveValue(''); + expect(context.pages().some((p) => p.url().includes('/formfill/preview.html'))).toBe(false); +}); diff --git a/apps/extension/e2e/tests/grants-manager.spec.ts b/apps/extension/e2e/tests/grants-manager.spec.ts new file mode 100644 index 000000000..6c9f36748 --- /dev/null +++ b/apps/extension/e2e/tests/grants-manager.spec.ts @@ -0,0 +1,71 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import { expect, test } from '../fixtures'; +import { grantOrigin, performLogin } from './helpers'; + +test('revoking site access from the side panel denies the next captured window.solid fetch', async ({ + context, + extensionId, +}) => { + await performLogin(context, extensionId); + const site = await context.newPage(); + await site.goto('http://localhost:8080'); + await grantOrigin(context, site); + await expect(site.locator('#fetch-result')).toContainText('Private Note'); + // Keep the page's existing API reference: broadcasts remove fetch from the public + // stub after revocation, but the worker must also deny previously captured calls. + await site.evaluate(() => { + const page = window as unknown as { + solid: { fetch: (url: string) => Promise }; + capturedSolidFetch: (url: string) => Promise; + }; + page.capturedSolidFetch = page.solid.fetch; + }); + + const panel = await context.newPage(); + await panel.goto(`chrome-extension://${extensionId}/sidepanel/sidepanel.html`); + const manager = panel.getByRole('region', { name: 'Sites with access' }); + const row = manager.getByRole('article', { name: 'http://localhost:8080' }); + await expect(row).toBeVisible(); + const details = row.getByRole('button', { name: 'Access details' }); + await details.focus(); + await panel.keyboard.press('Space'); + await expect(details).toHaveAttribute('aria-expanded', 'true'); + await expect(row.getByRole('list', { name: 'Data access' })).toContainText('Documents'); + + const revoke = row.getByRole('button', { name: 'Revoke', exact: true }); + await revoke.focus(); + await panel.keyboard.press('Enter'); + const dialog = panel.getByRole('dialog', { name: 'Revoke site access?' }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused(); + await panel.keyboard.press('Tab'); + await expect(dialog.getByRole('button', { name: 'Revoke access' })).toBeFocused(); + await panel.keyboard.press('Tab'); + await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused(); + await panel.keyboard.press('Escape'); + await expect(dialog).not.toBeVisible(); + await expect(revoke).toBeFocused(); + await panel.keyboard.press('Enter'); + await expect(dialog).toBeVisible(); + await panel.keyboard.press('Tab'); + await panel.keyboard.press('Space'); + await expect(manager.getByRole('status')).toContainText('Access revoked'); + await expect(manager.getByText('No sites have access yet', { exact: true })).toBeVisible(); + + const denied = await site.evaluate(async () => { + try { + await ( + window as unknown as { capturedSolidFetch: (url: string) => Promise } + ).capturedSolidFetch('http://localhost:3000/test-pod/private/notes'); + return 'unexpected success'; + } catch (error) { + return (error as Error).message; + } + }); + expect(denied).toMatch(/not granted/i); + await expect + .poll(() => + site.evaluate(() => Object.keys((window as unknown as { solid: object }).solid).sort()), + ) + .toEqual(['login', 'requestAccess']); +}); diff --git a/apps/extension/e2e/tests/helpers.ts b/apps/extension/e2e/tests/helpers.ts new file mode 100644 index 000000000..0bb132892 --- /dev/null +++ b/apps/extension/e2e/tests/helpers.ts @@ -0,0 +1,129 @@ +import { type BrowserContext, expect, type Page } from '@playwright/test'; + +/** The seeded CSS test WebID (see e2e/setup/seed.json). */ +export const TEST_WEBID = 'http://localhost:3000/test-pod/profile/card#me'; +export const WORK_WEBID = 'http://localhost:3000/work-pod/profile/card#me'; + +/** + * Complete the CSS OIDC login + consent flow on the window + * `chrome.identity.launchWebAuthFlow` opened. CSS auto-redirects to the password page, + * then to consent; both have JS-enabled submit buttons we wait on. + */ +export async function completeOidcLogin( + loginPage: Page, + email = 'test@example.com', +): Promise { + await loginPage.waitForLoadState('networkidle'); + await loginPage.waitForSelector('button[type="submit"]:not([disabled])', { timeout: 10_000 }); + await loginPage.fill('#email', email); + await loginPage.fill('#password', 'test-password-123'); + await loginPage.click('button[type="submit"]'); + + await loginPage.waitForURL('**/.account/oidc/consent/**', { timeout: 10_000 }); + await loginPage.waitForLoadState('networkidle'); + await loginPage.waitForSelector('#authorize:not([disabled])', { timeout: 10_000 }); + await loginPage.click('#authorize'); +} + +/** + * Drive a full login through the extension popup. Opens the popup, enters the WebID, + * completes the CSS OIDC flow in the launched window, then reopens the popup and waits + * for the signed-in state (the appears in #signed-in). + */ +export async function performLogin( + context: BrowserContext, + extensionId: string, + webId: string = TEST_WEBID, +): Promise { + const popupPage = await context.newPage(); + await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); + // The signed-out surface is now ; drive its shadow parts. + // Playwright's CSS engine pierces the (open) shadow root, so `part=` selectors work. + await popupPage.getByLabel('Your WebID').fill(webId); + + const loginPagePromise = context.waitForEvent('page'); + await popupPage.getByRole('button', { name: 'Sign in', exact: true }).click(); + await completeOidcLogin(await loginPagePromise); + + await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); + await popupPage.locator('#signed-in').waitFor({ state: 'visible', timeout: 15_000 }); + await popupPage.close(); +} + +/** Add an independently authenticated account through the shared profile controls. */ +export async function addProfile( + context: BrowserContext, + surface: Page, + webId: string, + email: string, +): Promise { + const count = await surface.locator('#profile-list > li').count(); + // The IdP cookie must not silently select the account used by the previous login. + // Extension credentials and grants remain intact in this test's fresh context. + await context.clearCookies(); + await surface.getByRole('button', { name: 'Add account', exact: true }).click(); + const input = surface.getByRole('textbox', { name: 'Account ID', exact: true }); + await input.fill(webId); + const loginWindow = context.waitForEvent('page'); + await input.press('Enter'); + await completeOidcLogin(await loginWindow, email); + await expect(surface.locator('#profile-list > li')).toHaveCount(count + 1); + await expect(surface.locator('#profile-status')).toHaveText('Account added.'); +} + +interface ConsentProfile { + label: string; + webId: string; + targetUrl: string; +} + +/** Drive the actual gesture-gated origin/class consent against the local CSS fixture. */ +export async function grantOrigin( + context: BrowserContext, + page: Page, + profile?: ConsentProfile, +): Promise { + await page.waitForFunction( + () => + typeof (window as unknown as { solid?: { requestAccess?: unknown } }).solid?.requestAccess === + 'function', + ); + await page.evaluate(() => { + const button = document.createElement('button'); + button.id = 'grant-origin-fixture'; + button.textContent = 'Grant origin access'; + button.addEventListener('click', () => { + const solid = ( + window as unknown as { + solid: { requestAccess(request: unknown): Promise<{ ok: boolean }> }; + } + ).solid; + void solid + .requestAccess({ + classes: ['https://schema.org/TextDigitalDocument'], + modes: ['read'], + duration: 'P1D', + }) + .then((result) => { + button.dataset.approved = String(result.ok); + }); + }); + document.body.appendChild(button); + }); + const consentPromise = context.waitForEvent('page'); + await page.getByRole('button', { name: 'Grant origin access' }).click(); + const consent = await consentPromise; + await consent.waitForURL('**/consent/consent.html?*'); + await expect(consent.locator('#webid')).not.toBeEmpty(); + if (profile) { + await consent.getByLabel('Change account').selectOption({ label: profile.label }); + // Selecting a different account resolves its own targets asynchronously. Wait for + // both account and targets so approval cannot race the previous profile's view. + await expect(consent.locator('#webid')).toHaveText(profile.label); + await expect(consent.locator('.targets')).toHaveText(profile.targetUrl); + } else { + await expect(consent.locator('.targets')).toContainText('/private/notes'); + } + await consent.getByRole('button', { name: 'Share', exact: true }).click(); + await expect(page.locator('#grant-origin-fixture')).toHaveAttribute('data-approved', 'true'); +} diff --git a/apps/extension/e2e/tests/login.spec.ts b/apps/extension/e2e/tests/login.spec.ts new file mode 100644 index 000000000..f226ee3d3 --- /dev/null +++ b/apps/extension/e2e/tests/login.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from '../fixtures'; +import { performLogin } from './helpers'; + +test('login via popup with Community Solid Server', async ({ context, extensionId }) => { + await performLogin(context, extensionId); + + // Reopen the popup and confirm the signed-in account UI (the web component) is shown. + const popupPage = await context.newPage(); + await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); + await expect(popupPage.locator('#signed-in')).toBeVisible({ timeout: 10_000 }); + + // The reflects the WebID on its `webid` attribute. + await expect(popupPage.locator('#account-menu')).toHaveAttribute('webid', /test-pod/, { + timeout: 10_000, + }); +}); diff --git a/apps/extension/e2e/tests/multi-profile.spec.ts b/apps/extension/e2e/tests/multi-profile.spec.ts new file mode 100644 index 000000000..79046f3b1 --- /dev/null +++ b/apps/extension/e2e/tests/multi-profile.spec.ts @@ -0,0 +1,192 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import type { Page } from '@playwright/test'; +import { expect, test } from '../fixtures'; +import { addProfile, grantOrigin, TEST_WEBID, WORK_WEBID } from './helpers'; + +const ORIGIN_A = 'http://localhost:8080'; +const ORIGIN_B = 'http://localhost:8081'; +// The same test server also listens on loopback IPv4; a different hostname is a +// third origin without another shared port or a separate server lifecycle. +const ORIGIN_C = 'http://127.0.0.1:8080'; +const PERSONAL_NOTE = 'http://localhost:3000/test-pod/private/notes'; +const WORK_NOTE = 'http://localhost:3000/work-pod/private/notes'; + +interface SolidPage extends Window { + solid: { webId: string; fetch(url: string): Promise }; + capturedSolidFetch: (url: string) => Promise; + stateUpdates: (string | null)[]; +} + +/** WebIDs from every state reply the content script delivered to this page, in order. */ +function stateUpdates(page: Page) { + return page.evaluate(() => [...(window as unknown as SolidPage).stateUpdates]); +} + +async function readNote(page: Page, url: string, captured = false) { + return page.evaluate( + async ({ target, useCaptured }) => { + const app = window as unknown as SolidPage; + try { + const response = await (useCaptured ? app.capturedSolidFetch : app.solid.fetch)(target); + return { status: response.status, body: await response.text() }; + } catch (error) { + return { error: (error as Error).message }; + } + }, + { target: url, useCaptured: captured }, + ); +} + +/** Wait for a state reply after `from`, then require every later reply to carry `webId`. */ +async function expectStateReplies(page: Page, webId: string | null, from = 0) { + await expect.poll(async () => (await stateUpdates(page)).length).toBeGreaterThan(from); + expect(new Set((await stateUpdates(page)).slice(from))).toEqual(new Set([webId])); +} + +async function expectBoundProfile(page: Page, webId: string, note: string, marker: string) { + await expect + .poll(() => page.evaluate(() => (window as unknown as SolidPage).solid.webId)) + .toBe(webId); + const result = await readNote(page, note); + expect(result.status).toBe(200); + expect(result.body).toContain(marker); + expect(result.body).toContain(webId); +} + +test('two origins keep their chosen profiles, an ungranted origin stays native, and revocation is scoped', async ({ + context, + extensionId, +}) => { + // Two real OIDC logins plus two separate consent windows on the local CSS server. + test.setTimeout(120_000); + // Record state replies so assertions run after the reply arrives, not against the + // pre-reply stub every page starts with. + await context.addInitScript(() => { + const app = window as unknown as SolidPage; + app.stateUpdates = []; + window.addEventListener('message', (event) => { + const data = event.data; + if ( + event.source === window && + data?.dir === 'to-page' && + data.type === 'SOLID_STATE_UPDATE' + ) { + app.stateUpdates.push(data.webId ?? null); + } + }); + }); + const popup = await context.newPage(); + await popup.goto(`chrome-extension://${extensionId}/popup/popup.html`); + await addProfile(context, popup, TEST_WEBID, 'test@example.com'); + await addProfile(context, popup, WORK_WEBID, 'work@example.com'); + await expect(popup.locator('#profile-list > li')).toHaveCount(2); + await expect( + popup.getByRole('button', { name: 'Set Alex Wilder as default', exact: true }), + ).toHaveAttribute('aria-pressed', 'true'); + + const siteA = await context.newPage(); + await siteA.goto(ORIGIN_A); + await grantOrigin(context, siteA, { + label: 'Alex Wilder', + webId: TEST_WEBID, + targetUrl: PERSONAL_NOTE, + }); + const siteB = await context.newPage(); + await siteB.goto(ORIGIN_B); + await grantOrigin(context, siteB, { + label: 'Morgan Chen', + webId: WORK_WEBID, + targetUrl: WORK_NOTE, + }); + + // These private resources have different owner/text markers. A 200 with the wrong + // account, or bytes accidentally reused from the other profile's cache, must fail. + await expectBoundProfile(siteA, TEST_WEBID, PERSONAL_NOTE, 'Alex Wilder personal note'); + await expectBoundProfile(siteB, WORK_WEBID, WORK_NOTE, 'Morgan Chen work note'); + await expect(siteA.locator('#fetch-result')).toContainText('Alex Wilder personal note'); + await expect(siteB.locator('#fetch-result')).toContainText('Morgan Chen work note'); + expect(await readNote(siteA, WORK_NOTE)).toEqual({ error: 'Out of grant scope' }); + expect(await readNote(siteB, PERSONAL_NOTE)).toEqual({ error: 'Out of grant scope' }); + + // Switching the default triggers a state broadcast, but must not rebind either app. + const updatesBefore = [(await stateUpdates(siteA)).length, (await stateUpdates(siteB)).length]; + await popup.getByRole('button', { name: 'Set Morgan Chen as default', exact: true }).click(); + await expect( + popup.getByRole('button', { name: 'Set Morgan Chen as default', exact: true }), + ).toHaveAttribute('aria-pressed', 'true'); + await expectStateReplies(siteA, TEST_WEBID, updatesBefore[0]); + await expectStateReplies(siteB, WORK_WEBID, updatesBefore[1]); + await expectBoundProfile(siteA, TEST_WEBID, PERSONAL_NOTE, 'Alex Wilder personal note'); + await expectBoundProfile(siteB, WORK_WEBID, WORK_NOTE, 'Morgan Chen work note'); + + const siteC = await context.newPage(); + await siteC.goto(ORIGIN_C); + // The ungranted origin's state reply carries no identity. + await expectStateReplies(siteC, null); + await expect + .poll(() => + siteC.evaluate(() => + Object.getOwnPropertyNames((window as unknown as SolidPage).solid).sort(), + ), + ) + .toEqual(['login', 'requestAccess']); + expect(await siteC.evaluate(() => Function.prototype.toString.call(window.fetch))).toMatch( + /\{\s*\[native code\]\s*\}/, + ); + // Ordinary site requests still use the browser's fetch successfully. + expect( + await siteC.evaluate(async () => { + const response = await window.fetch('/config.json'); + return { status: response.status, config: await response.json() }; + }), + ).toEqual({ status: 200, config: { clientIdUrl: null } }); + + for (const site of [siteA, siteB]) { + await site.evaluate(() => { + const app = window as unknown as SolidPage; + app.capturedSolidFetch = app.solid.fetch; + }); + } + expect((await readNote(siteA, PERSONAL_NOTE, true)).status).toBe(200); + expect((await readNote(siteB, WORK_NOTE, true)).status).toBe(200); + + const panel = await context.newPage(); + await panel.goto(`chrome-extension://${extensionId}/sidepanel/sidepanel.html`); + const manager = panel.getByRole('region', { name: 'Sites with access' }); + const rowA = manager.getByRole('article', { name: ORIGIN_A, exact: true }); + const rowB = manager.getByRole('article', { name: ORIGIN_B, exact: true }); + await expect(rowA).toBeVisible(); + await expect(rowB).toBeVisible(); + await rowA.getByRole('button', { name: 'Revoke', exact: true }).click(); + const dialog = panel.getByRole('dialog', { name: 'Revoke site access?' }); + await dialog.getByRole('button', { name: 'Revoke access', exact: true }).click(); + await expect(manager.getByRole('status')).toContainText('Access revoked'); + await expect(rowA).toHaveCount(0); + await expect(rowB).toBeVisible(); + + // This is A's first captured call after the UI confirms revocation. No reload, + // re-consent, or new API lookup may mask a still-authorized captured function. + expect(await readNote(siteA, PERSONAL_NOTE, true)).toEqual({ + error: 'Origin not granted access', + }); + await expect + .poll(() => + siteA.evaluate(() => + Object.getOwnPropertyNames((window as unknown as SolidPage).solid).sort(), + ), + ) + .toEqual(['login', 'requestAccess']); + const surviving = await readNote(siteB, WORK_NOTE, true); + expect(surviving.status).toBe(200); + expect(surviving.body).toContain('Morgan Chen work note'); + await expectBoundProfile(siteB, WORK_WEBID, WORK_NOTE, 'Morgan Chen work note'); + expect(await readNote(siteB, PERSONAL_NOTE)).toEqual({ error: 'Out of grant scope' }); + + // The ungranted origin never received an identity or the fetch API at any point. + await expectStateReplies(siteC, null); + expect( + await siteC.evaluate(() => + Object.getOwnPropertyNames((window as unknown as SolidPage).solid).sort(), + ), + ).toEqual(['login', 'requestAccess']); +}); diff --git a/apps/extension/e2e/tests/profile-switcher.spec.ts b/apps/extension/e2e/tests/profile-switcher.spec.ts new file mode 100644 index 000000000..7f83e972a --- /dev/null +++ b/apps/extension/e2e/tests/profile-switcher.spec.ts @@ -0,0 +1,182 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import AxeBuilder from '@axe-core/playwright'; +import type { Page } from '@playwright/test'; +import { expect, test } from '../fixtures'; +import { completeOidcLogin, TEST_WEBID } from './helpers'; + +async function checkAccessibility(page: Page): Promise { + const { violations } = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']) + .analyze(); + expect(violations.filter(({ impact }) => impact === 'serious' || impact === 'critical')).toEqual( + [], + ); +} + +/** Review artifacts at every required width; axe also traverses the component shadow roots. */ +async function captureMatrix(popup: Page, panel: Page, state: 'signed-out' | 'signed-in') { + for (const [surface, name, widths] of [ + [popup, 'popup', [340]], + [panel, 'sidepanel', [375, 768, 1280]], + ] as const) { + for (const width of widths) { + await surface.setViewportSize({ width, height: 800 }); + for (const theme of ['light', 'dark']) { + await surface.evaluate((mode) => { + document.documentElement.classList.toggle('dark', mode === 'dark'); + document.documentElement.style.colorScheme = mode; + }, theme); + await expect( + surface.locator('#signed-out:visible, #busy:visible, #signed-in:visible'), + ).toHaveCount(1); + await expect(surface.locator(`#${state}`)).toBeVisible(); + await expect(surface.locator('#profile-list > li')).toHaveCount( + state === 'signed-in' ? 2 : 0, + ); + expect( + await surface.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + ).toBe(true); + await test.info().attach(`accounts-${name}-${width}-${theme}-${state}.png`, { + body: await surface.screenshot({ fullPage: true, animations: 'disabled' }), + contentType: 'image/png', + }); + await checkAccessibility(surface); + } + } + } + await popup.setViewportSize({ width: 340, height: 800 }); + await panel.setViewportSize({ width: 375, height: 800 }); +} + +test('add two local profiles, switch default across surfaces and manage them by keyboard', async ({ + context, + extensionId, +}) => { + test.setTimeout(120_000); + await context + .serviceWorkers()[0] + .evaluate(() => chrome.storage.local.set({ 'solid:show-pin-nudge': true })); + const popup = await context.newPage(); + await popup.setViewportSize({ width: 340, height: 600 }); + await popup.goto(`chrome-extension://${extensionId}/popup/popup.html`); + const panel = await context.newPage(); + await panel.setViewportSize({ width: 375, height: 800 }); + await panel.goto(`chrome-extension://${extensionId}/sidepanel/sidepanel.html`); + await expect(popup.locator('#signed-out')).toBeVisible(); + await expect(panel.locator('#signed-out')).toBeVisible(); + await expect(popup.locator('#profiles-empty')).toBeVisible(); + await expect(panel.locator('#profiles-empty')).toBeVisible(); + await expect(popup.locator('#pin-nudge')).toBeVisible(); + await captureMatrix(popup, panel, 'signed-out'); + await popup.getByRole('button', { name: 'Got it', exact: true }).focus(); + await popup.keyboard.press('Enter'); + await expect(popup.locator('#pin-nudge')).toBeHidden(); + + // Reduced motion must keep the skip link in the keyboard order and stop the spinner. + await popup.emulateMedia({ reducedMotion: 'reduce' }); + await popup.reload(); + await expect(popup.locator('#signed-out')).toBeVisible(); + await popup.keyboard.press('Tab'); + await expect(popup.getByRole('link', { name: 'Skip to account', exact: true })).toBeFocused(); + await expect(popup.getByRole('link', { name: 'Skip to account', exact: true })).toBeInViewport(); + await expect(popup.locator('.spinner')).toHaveCSS('animation-name', 'none'); + await popup.emulateMedia({ reducedMotion: 'no-preference' }); + + for (const [surface, webId, email, count] of [ + [popup, TEST_WEBID, 'test@example.com', 1], + [panel, 'http://localhost:3000/work-pod/profile/card#me', 'work@example.com', 2], + ] as const) { + // Start both sign-ins through the shared profile UI, including the empty-store state. + // Clearing only browser cookies avoids reusing the first account's CSS IdP session. + await context.clearCookies(); + await surface.getByRole('button', { name: 'Add account', exact: true }).focus(); + await surface.keyboard.press('Enter'); + const input = surface.getByRole('textbox', { name: 'Account ID', exact: true }); + await expect(input).toBeFocused(); + await checkAccessibility(surface); + await input.fill(webId); + const loginWindow = context.waitForEvent('page'); + await input.press('Enter'); + await completeOidcLogin(await loginWindow, email); + await expect(surface.locator('#profile-list > li')).toHaveCount(count); + await expect(surface.locator('#profile-status')).toHaveText('Account added.'); + } + + const personal = popup.getByRole('button', { name: 'Set Alex Wilder as default', exact: true }); + const work = popup.getByRole('button', { name: 'Set Morgan Chen as default', exact: true }); + await expect(personal).toHaveAttribute('aria-pressed', 'true'); + await expect(work).toHaveAttribute('aria-pressed', 'false'); + await personal.focus(); + await popup.keyboard.press('ArrowDown'); + await expect(work).toBeFocused(); + await popup.keyboard.press('Space'); + await expect(work).toHaveAttribute('aria-pressed', 'true'); + await expect( + panel.getByRole('button', { name: 'Set Morgan Chen as default', exact: true }), + ).toHaveAttribute('aria-pressed', 'true'); + await expect(panel.locator('#account-menu')).toHaveAttribute('name', 'Morgan Chen'); + await popup.reload(); + await expect( + popup.getByRole('button', { name: 'Set Morgan Chen as default', exact: true }), + ).toHaveAttribute('aria-pressed', 'true'); + await expect(popup.locator('.profile-avatar img')).toHaveCount(2); + await expect(panel.locator('.profile-avatar img')).toHaveCount(2); + + await expect(popup.locator('#account-name')).toHaveText('Morgan Chen'); + await expect(panel.locator('#account-host')).toHaveText('localhost:3000'); + await captureMatrix(popup, panel, 'signed-in'); + + // The menu is styled through public parts; verify its destructive action and focus ring. + const accountTrigger = popup.getByRole('button', { name: 'Account menu', exact: true }); + await accountTrigger.focus(); + await expect(accountTrigger).toHaveCSS('outline-width', '2px'); + await expect(accountTrigger).toHaveCSS('outline-offset', '2px'); + await popup.keyboard.press('Enter'); + await expect(popup.getByRole('menuitem', { name: 'Sign out', exact: true })).toBeVisible(); + await checkAccessibility(popup); + await popup.keyboard.press('Escape'); + + for (const surface of [popup, panel]) { + await surface.setViewportSize({ width: 320, height: 800 }); + expect( + await surface.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + expect( + await surface.locator('#profiles button').evaluateAll((buttons) => + buttons + .filter((button) => button.getClientRects().length > 0) + .every((button) => { + const box = button.getBoundingClientRect(); + return box.width >= 24 && box.height >= 24; + }), + ), + ).toBe(true); + } + + const rename = panel.getByRole('button', { name: 'Rename Morgan Chen', exact: true }); + await rename.focus(); + await panel.keyboard.press('Enter'); + await checkAccessibility(panel); + await panel.getByRole('textbox', { name: 'Account name' }).fill('Work'); + await panel.keyboard.press('Enter'); + await expect(panel.getByRole('button', { name: 'Rename Work', exact: true })).toBeFocused(); + await expect( + popup.getByRole('button', { name: 'Set Work as default', exact: true }), + ).toHaveAttribute('aria-pressed', 'true'); + + // Escape cancels removal and returns to its trigger; confirmation then selects the survivor. + await panel.getByRole('button', { name: 'Remove Work', exact: true }).focus(); + await panel.keyboard.press('Enter'); + await expect(panel.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused(); + await panel.keyboard.press('Escape'); + await expect(panel.getByRole('button', { name: 'Remove Work', exact: true })).toBeFocused(); + await panel.keyboard.press('Enter'); + await checkAccessibility(panel); + await panel.keyboard.press('Shift+Tab'); + await panel.keyboard.press('Enter'); + await expect(panel.locator('#profile-list > li')).toHaveCount(1); + await expect( + panel.getByRole('button', { name: 'Set Alex Wilder as default', exact: true }), + ).toHaveAttribute('aria-pressed', 'true'); + await expect(panel.getByRole('button', { name: 'Add account', exact: true })).toBeFocused(); +}); diff --git a/apps/extension/e2e/tests/profiles.spec.ts b/apps/extension/e2e/tests/profiles.spec.ts new file mode 100644 index 000000000..ffb386664 --- /dev/null +++ b/apps/extension/e2e/tests/profiles.spec.ts @@ -0,0 +1,132 @@ +/// +// AUTHORED-BY GPT-6.1-Sol (Codex) +import type { ProfileRecord } from '../../src/background/core/profile-store'; +import { expect, test } from '../fixtures'; + +/** Browser fixtures stay inside the worker; no fixture credential crosses the message API. */ +test('profile management persists isolated non-extractable credentials in worker IndexedDB', async ({ + context, + extensionId, +}) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/popup/popup.html`); + const message = (request: Record) => + page.evaluate((value) => chrome.runtime.sendMessage(value), request); + expect((await message({ type: 'SOLID_PROFILES_LIST' })).profiles).toEqual([]); + + const worker = context.serviceWorkers()[0]; + await worker.evaluate(async () => { + const records: ProfileRecord[] = []; + for (const id of ['1', '2']) { + const keyPair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign', 'verify'], + ); + const webId = `https://fixture.invalid/profile-${id}#me`; + records.push({ + profile: { + id, + webId, + issuer: 'https://fixture.invalid/', + label: `Profile ${id}`, + sessionState: 'active', + dpopKeyRef: id, + }, + session: { + webId, + issuer: 'https://fixture.invalid/', + accessToken: `fixture-access-${id}`, + refreshToken: `fixture-refresh-${id}`, + tokenEndpoint: 'https://fixture.invalid/token', + clientId: 'https://fixture.invalid/client', + expiresAt: Date.now() + 3_600_000, + }, + keyPair, + grantedOrigins: [], + podOrigins: [], + classGrants: [], + }); + } + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('solid-ext-profiles', 1); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(['profiles', 'metadata'], 'readwrite'); + for (const record of records) tx.objectStore('profiles').put(record, record.profile.id); + tx.objectStore('metadata').put( + { defaultProfileId: '1', nextId: 3, migrated: true }, + 'state', + ); + tx.oncomplete = () => resolve(); + tx.onabort = tx.onerror = () => reject(tx.error); + }); + } finally { + db.close(); + } + }); + + const list = await message({ type: 'SOLID_PROFILES_LIST' }); + expect(list.profiles).toHaveLength(2); + expect(list.defaultProfileId).toBe('1'); + expect(JSON.stringify(list)).not.toMatch( + /accessToken|refreshToken|privateKey|fixture-access|fixture-refresh/, + ); + expect((await message({ type: 'SOLID_PROFILE_RENAME', profileId: '2', label: 'Work' })).ok).toBe( + true, + ); + expect( + (await message({ type: 'SOLID_PROFILE_SET_DEFAULT', profileId: '2' })).defaultProfileId, + ).toBe('2'); + await page.reload(); + expect((await message({ type: 'SOLID_GET_STATE' })).name).toBe('Work'); + expect((await message({ type: 'SOLID_PROFILE_REMOVE', profileId: '1' })).profiles).toHaveLength( + 1, + ); + + // A real IndexedDB close/reopen returns a usable, non-exportable private CryptoKey. + const custody = await worker.evaluate(async () => { + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('solid-ext-profiles', 1); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + try { + const record = await new Promise<{ keyPair: CryptoKeyPair }>((resolve, reject) => { + const req = db.transaction('profiles', 'readonly').objectStore('profiles').get('2'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + const data = new TextEncoder().encode('fixture-signature'); + const signature = await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + record.keyPair.privateKey, + data, + ); + let exportRejected = false; + try { + await crypto.subtle.exportKey('jwk', record.keyPair.privateKey); + } catch { + exportRejected = true; + } + return { + exportRejected, + valid: await crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + record.keyPair.publicKey, + signature, + data, + ), + }; + } finally { + db.close(); + } + }); + expect(custody).toEqual({ exportRejected: true, valid: true }); + expect( + (await message({ type: 'SOLID_PROFILE_REMOVE', profileId: '2' })).defaultProfileId, + ).toBeNull(); +}); diff --git a/apps/extension/e2e/tests/session-restore.spec.ts b/apps/extension/e2e/tests/session-restore.spec.ts new file mode 100644 index 000000000..d45711e11 --- /dev/null +++ b/apps/extension/e2e/tests/session-restore.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from '../fixtures'; +import { grantOrigin, performLogin } from './helpers'; + +const TEST_SITE = 'http://localhost:8080'; + +test('session persists across navigation (the worker holds the session)', async ({ + context, + extensionId, +}) => { + await performLogin(context, extensionId); + + const page = await context.newPage(); + await page.goto(TEST_SITE); + await grantOrigin(context, page); + await expect(page.locator('#webid')).toContainText('test-pod', { timeout: 15_000 }); + + await page.goto('about:blank'); + await page.goto(TEST_SITE); + + // window.solid.webId is repopulated from the worker's persisted session. + await expect(page.locator('#webid')).toContainText('test-pod', { timeout: 15_000 }); +}); diff --git a/apps/extension/e2e/tsconfig.json b/apps/extension/e2e/tsconfig.json new file mode 100644 index 000000000..159f0eac7 --- /dev/null +++ b/apps/extension/e2e/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/apps/extension/package.json b/apps/extension/package.json new file mode 100644 index 000000000..6fa001fcf --- /dev/null +++ b/apps/extension/package.json @@ -0,0 +1,55 @@ +{ + "name": "@jeswr/app-extension", + "version": "0.2.0", + "private": true, + "description": "Browser extension (MV3) for Solid authentication \u2014 injects window.solid (DPoP-authenticated fetch) and a pinned avatar account menu, consistent with the @jeswr Solid app suite.", + "license": "MIT", + "author": "Jesse Wright", + "type": "module", + "scripts": { + "lint": "biome check src test e2e scripts", + "lint:fix": "biome check --write src test e2e scripts", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "generate:client-id": "node scripts/render-client-id.ts", + "build": "pnpm run generate:client-id && webpack --config webpack.config.cjs", + "build:dev": "pnpm run generate:client-id && webpack --config webpack.config.cjs --mode development --watch", + "gate": "pnpm run lint && pnpm run typecheck && pnpm test && pnpm run build", + "test:e2e": "pnpm run build && playwright test --config e2e/playwright.config.ts", + "clean": "rm -rf dist" + }, + "dependencies": { + "@jeswr/solid-elements": "workspace:*", + "@rdfjs/wrapper": "^0.32.0", + "@solid/object": "^0.6.0", + "jose": "^5.9.6", + "n3": "^2.0.3" + }, + "devDependencies": { + "@axe-core/playwright": "^4.14.0", + "@biomejs/biome": "^2.5.0", + "@playwright/test": "^1.49.0", + "@solid/community-server": "^7.1.3", + "@types/chrome": "^0.0.287", + "@types/n3": "^1.26.1", + "@types/node": "^24.0.0", + "@vitest/coverage-v8": "2.1.9", + "axe-core": "4.14.0", + "copy-webpack-plugin": "^12.0.2", + "http-server": "^14.1.1", + "jsdom": "^25.0.1", + "playwright": "^1.61.1", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "typescript": "^5.7.2", + "vitest": "^2.1.8", + "webpack": "^5.97.1", + "webpack-cli": "^6.0.1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/jeswr/solid-sdk.git", + "directory": "apps/extension" + } +} diff --git a/apps/extension/public/clientid.jsonld b/apps/extension/public/clientid.jsonld new file mode 100644 index 000000000..1f6ebdfd0 --- /dev/null +++ b/apps/extension/public/clientid.jsonld @@ -0,0 +1,20 @@ +{ + "@context": "https://www.w3.org/ns/solid/oidc-context.jsonld", + "client_id": "https://jeswr.github.io/solid-browser-extension/clientid.jsonld", + "client_name": "Solid Browser Extension", + "client_uri": "https://github.com/jeswr/solid-browser-extension", + "logo_uri": "https://jeswr.github.io/solid-browser-extension/icon128.png", + "redirect_uris": [ + "https://bicabcdahgfbkgmlidjpmiadmjlppikk.chromiumapp.org/" + ], + "grant_types": [ + "authorization_code", + "refresh_token" + ], + "response_types": [ + "code" + ], + "scope": "openid webid offline_access", + "token_endpoint_auth_method": "none", + "application_type": "web" +} diff --git a/apps/extension/scripts/dev.ts b/apps/extension/scripts/dev.ts new file mode 100644 index 000000000..ba846a132 --- /dev/null +++ b/apps/extension/scripts/dev.ts @@ -0,0 +1,179 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; +import { startTestSiteServer } from '../test-site/server'; + +// This package is `"type": "module"`; this script is run as ESM, where `__dirname` is not +// defined. Derive it from `import.meta.url`. +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const CSS_PORT = 3000; +const BASIC_SITE_PORT = 8080; +const CLIENT_ID_SITE_PORT = 8081; +const ROOT = path.resolve(__dirname, '..'); + +const children: ChildProcess[] = []; +const servers: http.Server[] = []; + +function cleanup() { + for (const child of children) child.kill(); + for (const server of servers) server.close(); + process.exit(); +} +process.on('SIGINT', cleanup); +process.on('SIGTERM', cleanup); + +async function waitForServer(url: string, timeoutMs = 30_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + await new Promise((resolve, reject) => { + const req = http.get(url, (res) => { + res.resume(); + resolve(); + }); + req.on('error', reject); + req.setTimeout(1000, () => { + req.destroy(); + reject(); + }); + }); + return; + } catch { + await new Promise((r) => setTimeout(r, 500)); + } + } + throw new Error(`${url} did not start in ${timeoutMs}ms`); +} + +const extPath = path.join(ROOT, 'dist'); +const extArgs = [`--disable-extensions-except=${extPath}`, `--load-extension=${extPath}`]; + +async function discoverExtensionId(userDataDir: string): Promise { + const ctx = await chromium.launchPersistentContext(userDataDir, { + channel: 'chromium', + headless: false, + args: extArgs, + }); + let sw = ctx.serviceWorkers()[0]; + if (!sw) sw = await ctx.waitForEvent('serviceworker'); + const extensionId = sw.url().split('/')[2]; + await ctx.close(); + return extensionId; +} + +function pinExtension(userDataDir: string, extensionId: string) { + const prefsPath = path.join(userDataDir, 'Default', 'Preferences'); + const prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf-8')); + prefs.extensions = prefs.extensions || {}; + prefs.extensions.pinned_extensions = prefs.extensions.pinned_extensions || []; + if (!prefs.extensions.pinned_extensions.includes(extensionId)) { + prefs.extensions.pinned_extensions.push(extensionId); + } + fs.writeFileSync(prefsPath, JSON.stringify(prefs)); +} + +(async () => { + // 1. Build + console.log('Building extension...'); + const build = spawn('npx', ['webpack'], { cwd: ROOT, stdio: 'inherit' }); + await new Promise((resolve, reject) => { + build.on('close', (code) => + code === 0 ? resolve() : reject(new Error(`Build failed (${code})`)), + ); + }); + + // 2. Start CSS + console.log('Starting Community Solid Server...'); + const css = spawn( + 'npx', + [ + '@solid/community-server', + '-p', + String(CSS_PORT), + '-c', + path.join(ROOT, 'e2e/setup/css-config.json'), + '--seedConfig', + path.join(ROOT, 'e2e/setup/seed.json'), + '-l', + 'warn', + ], + { stdio: 'pipe' }, + ); + children.push(css); + css.stderr?.on('data', (d: Buffer) => { + const msg = d.toString(); + if (msg.includes('Error')) console.error('[CSS]', msg); + }); + await waitForServer(`http://localhost:${CSS_PORT}`); + console.log(` CSS ready on http://localhost:${CSS_PORT}`); + + // 3. Discover extension ID (needed to build the client ID URL) + console.log('Configuring browser...'); + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'solid-ext-dev-')); + const extensionId = await discoverExtensionId(userDataDir); + pinExtension(userDataDir, extensionId); + + // 4. Start two test site servers, both configured with the extension's redirect URI + const redirectUri = `https://${extensionId}.chromiumapp.org/`; + const appClientIdUrl = `http://localhost:${CLIENT_ID_SITE_PORT}/client-id`; + + console.log('Starting test sites...'); + + const basicServer = await startTestSiteServer({ + port: BASIC_SITE_PORT, + redirectUri, + clientName: 'Solid Browser Extension', + }); + servers.push(basicServer); + console.log(` Basic site ready on http://localhost:${BASIC_SITE_PORT}`); + + const clientIdServer = await startTestSiteServer({ + port: CLIENT_ID_SITE_PORT, + redirectUri, + clientName: 'My Test App', + clientIdUrl: appClientIdUrl, + }); + servers.push(clientIdServer); + console.log(` Client ID site ready on http://localhost:${CLIENT_ID_SITE_PORT}`); + + // 6. Relaunch with extension pinned, open both sites + console.log('Launching browser...'); + const context = await chromium.launchPersistentContext(userDataDir, { + channel: 'chromium', + headless: false, + args: extArgs, + }); + + const page1 = context.pages()[0] || (await context.newPage()); + await page1.goto(`http://localhost:${BASIC_SITE_PORT}/`); + + const page2 = await context.newPage(); + await page2.goto(`http://localhost:${CLIENT_ID_SITE_PORT}/with-client-id.html`); + + console.log('\n-----------------------------------------------'); + console.log(' Ready! Browser is open with the extension.'); + console.log(''); + console.log(' Test user credentials:'); + console.log(' WebID: http://localhost:3000/test-pod/profile/card#me'); + console.log(' Email: test@example.com'); + console.log(' Password: test-password-123'); + console.log(''); + console.log(' Sample sites (open in browser tabs):'); + console.log(''); + console.log(' 1. Basic site (dynamic registration):'); + console.log(` http://localhost:${BASIC_SITE_PORT}/`); + console.log(''); + console.log(' 2. App with static client ID (solid.setClientId):'); + console.log(` http://localhost:${CLIENT_ID_SITE_PORT}/with-client-id.html`); + console.log(''); + console.log(' CSS: http://localhost:3000'); + console.log('-----------------------------------------------'); + console.log(' Close the browser or press Ctrl+C to stop.\n'); + + context.on('close', cleanup); +})(); diff --git a/apps/extension/scripts/render-client-id.ts b/apps/extension/scripts/render-client-id.ts new file mode 100644 index 000000000..0f6aac1f7 --- /dev/null +++ b/apps/extension/scripts/render-client-id.ts @@ -0,0 +1,43 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { createHash, createPublicKey } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { renderClientIdDocument } from '../src/background/client-id-template.ts'; + +const manifestPath = new URL('../src/manifest.json', import.meta.url); +const documentPath = new URL('../public/clientid.jsonld', import.meta.url); +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + +// EXT_PUBKEY is optional after initial pinning: clean builds use the committed PUBLIC key. +// This script never reads, generates, or exports a private key. +const key: unknown = process.env.EXT_PUBKEY + ? readFileSync(process.env.EXT_PUBKEY, 'utf8').trim() + : manifest.key; +if (typeof key !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(key)) { + throw new Error( + 'Missing/invalid manifest public key; set EXT_PUBKEY to the supplied base64 DER file.', + ); +} +const der = Buffer.from(key, 'base64'); +if (der.toString('base64') !== key) throw new Error('Public key must be canonical base64 DER.'); +createPublicKey({ key: der, format: 'der', type: 'spki' }); +const extensionId = createHash('sha256') + .update(der) + .digest('hex') + .slice(0, 32) + .replace(/[0-9a-f]/g, (digit) => String.fromCharCode(97 + Number.parseInt(digit, 16))); +if (extensionId !== 'bicabcdahgfbkgmlidjpmiadmjlppikk') { + throw new Error('Public key does not match the dispatcher-pinned extension ID.'); +} + +const rendered = `${JSON.stringify(renderClientIdDocument(extensionId), null, 2)}\n`; +if (process.argv.includes('--check')) { + if (manifest.key !== key || readFileSync(documentPath, 'utf8') !== rendered) { + throw new Error('Pinned manifest/document is stale; run npm run generate:client-id.'); + } +} else { + if (manifest.key !== key) { + manifest.key = key; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + } + writeFileSync(documentPath, rendered); +} diff --git a/apps/extension/skills-lock.json b/apps/extension/skills-lock.json new file mode 100644 index 000000000..7614a41e7 --- /dev/null +++ b/apps/extension/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "playwright-e2e-testing": { + "source": "bobmatnyc/claude-mpm-skills", + "sourceType": "github", + "computedHash": "ce264ac6f9cbeba1fd354d4b34eac7f3758d630aabaf4b59a836e3db980c8486" + } + } +} diff --git a/apps/extension/skills/playwright-e2e-testing b/apps/extension/skills/playwright-e2e-testing new file mode 120000 index 000000000..e455616cf --- /dev/null +++ b/apps/extension/skills/playwright-e2e-testing @@ -0,0 +1 @@ +../.agents/skills/playwright-e2e-testing \ No newline at end of file diff --git a/apps/extension/src/background/action-icon.ts b/apps/extension/src/background/action-icon.ts new file mode 100644 index 000000000..504e419e3 --- /dev/null +++ b/apps/extension/src/background/action-icon.ts @@ -0,0 +1,142 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The pinned top-right toolbar identity: the `chrome.action` icon becomes the signed-in + * WebID's avatar (the profile photo, or coloured initials), plus a status badge. Rendered + * off-DOM with `OffscreenCanvas` (a service worker has no `document`), then handed to + * `chrome.action.setIcon` as `ImageData`. + * + * Fail-soft: any failure (no canvas, an un-fetchable/cross-origin photo) falls back to + * initials, and ultimately to the bundled default icon — the toolbar entry is never left + * broken. The badge is a tiny dot indicating signed-in (green) / signed-out (cleared). + */ + +// Keep the worker free of the UI entry point: importing Lit components requires +// HTMLElement and aborts worker startup before the profile message handlers register. +// Match solid-elements' initials fallback without importing its component registry. +function initials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return '?'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} + +const SIZES = [16, 32, 48, 128] as const; + +/** A deterministic accent colour derived from a string (stable per WebID). */ +function colorFor(seed: string): string { + let hash = 0; + for (let i = 0; i < seed.length; i++) { + hash = (hash * 31 + seed.charCodeAt(i)) >>> 0; + } + const hue = hash % 360; + return `hsl(${hue} 62% 48%)`; +} + +function drawInitials(size: number, name: string, seed: string): ImageData { + const canvas = new OffscreenCanvas(size, size); + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('No 2d context'); + ctx.fillStyle = colorFor(seed); + // a filled rounded square + const r = size * 0.22; + ctx.beginPath(); + ctx.roundRect(0, 0, size, size, r); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = `${Math.round(size * 0.46)}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(initials(name) || 'S', size / 2, size / 2 + size * 0.02); + return ctx.getImageData(0, 0, size, size); +} + +async function drawPhoto(size: number, photoUrl: string): Promise { + const response = await fetch(photoUrl); + if (!response.ok) throw new Error(`photo ${response.status}`); + const blob = await response.blob(); + const bitmap = await createImageBitmap(blob); + const canvas = new OffscreenCanvas(size, size); + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('No 2d context'); + // circular crop + ctx.save(); + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); + ctx.clip(); + // cover-fit + const scale = Math.max(size / bitmap.width, size / bitmap.height); + const w = bitmap.width * scale; + const h = bitmap.height * scale; + ctx.drawImage(bitmap, (size - w) / 2, (size - h) / 2, w, h); + ctx.restore(); + bitmap.close(); + return ctx.getImageData(0, 0, size, size); +} + +/** + * Set the toolbar action icon to the signed-in user's avatar (photo → initials fallback) + * and a green status dot. Best-effort: failures leave the previous/default icon intact. + */ +export async function setSignedInIcon( + profile: { + webId: string; + name: string | null; + photoUrl: string | null; + }, + isCurrent: () => boolean, +): Promise { + const label = profile.name ?? profile.webId; + const imageData: Record = {}; + + for (const size of SIZES) { + try { + imageData[size] = profile.photoUrl + ? await drawPhoto(size, profile.photoUrl) + : drawInitials(size, label, profile.webId); + } catch { + // photo fetch/decoder failed for this size — fall back to initials. + try { + imageData[size] = drawInitials(size, label, profile.webId); + } catch { + // canvas unavailable entirely — leave the default bundled icon. + return; + } + } + } + + try { + // Image preparation can outlive a default change. Recheck after every await + // before mutating any part of the toolbar's captured identity. + if (!isCurrent()) return; + await chrome.action.setIcon({ imageData }); + if (!isCurrent()) return; + await chrome.action.setBadgeBackgroundColor({ color: '#2e9e57' }); + if (!isCurrent()) return; + await chrome.action.setBadgeText({ text: ' ' }); + if (!isCurrent()) return; + await chrome.action.setTitle({ title: `Signed in as ${label}` }); + } catch { + // ignore — icon update is cosmetic. + } +} + +/** Reset the action icon + badge to the signed-out (default) state. */ +export async function setSignedOutIcon(isCurrent: () => boolean): Promise { + try { + if (!isCurrent()) return; + await chrome.action.setIcon({ + path: { + 16: 'icons/icon16.png', + 32: 'icons/icon32.png', + 48: 'icons/icon48.png', + 128: 'icons/icon128.png', + }, + }); + if (!isCurrent()) return; + await chrome.action.setBadgeText({ text: '' }); + if (!isCurrent()) return; + await chrome.action.setTitle({ title: 'Sign in to Solid' }); + } catch { + // ignore. + } +} diff --git a/apps/extension/src/background/auth-flow.ts b/apps/extension/src/background/auth-flow.ts new file mode 100644 index 000000000..715d6f12c --- /dev/null +++ b/apps/extension/src/background/auth-flow.ts @@ -0,0 +1,329 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The Solid-OIDC authorization-code + PKCE + DPoP login, adapted for the MV3 service + * worker (no `window`): the interactive redirect is driven by + * `chrome.identity.launchWebAuthFlow`, which opens the IdP in a managed window and + * resolves with the `https://.chromiumapp.org/...` callback URL. The DPoP + * proofs (token exchange + refresh) go through the shared core/dpop module so the proof + * discipline matches the rest of the suite (RFC 9449). + * + * Login uses a PUBLISHED Solid-OIDC Client Identifier Document URL as the `client_id` + * (so the consent screen shows a stable name and `token_endpoint_auth_method=none` + * public-client behaviour is correct). Dynamic registration is used only when the trusted + * clientIdMode setting is 'dcr'. A page may supply its OWN Client ID + * Document via `window.solid.setClientId(...)` to identify itself to the pod. + */ + +import * as jose from 'jose'; +import { + CLIENT_ID_SCOPE, + isPublishedClientIdReachable, + PUBLISHED_CLIENT_ID_URL, +} from './client-id'; +import { createDpopProof, dpopThumbprint, generateDpopKeyPair } from './core/dpop'; +import { isLoopbackHost } from './core/origin-policy'; +import { parseWebIdProfile, selectIssuer } from './core/webid'; +import { + type AuthParams, + clearAuthParams, + loadAuthParams, + loadClientIdMode, + type StoredSession, + saveAuthParams, +} from './session-store'; + +const SCOPE = CLIENT_ID_SCOPE; + +interface OidcConfig { + authorization_endpoint: string; + token_endpoint: string; + registration_endpoint?: string; + issuer: string; +} + +/** The `https:` everywhere / `http:` loopback-only transport guard (RFC 8252 §8.3). */ +function assertIssuerTransport(issuer: string): void { + const url = new URL(issuer); + if (url.protocol === 'https:') return; + if (url.protocol === 'http:' && isLoopbackHost(url.hostname)) return; + throw new Error(`Refusing to use insecure issuer transport: ${issuer}`); +} + +async function fetchOidcConfig(issuer: string): Promise { + assertIssuerTransport(issuer); + const wellKnown = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`; + const response = await fetch(wellKnown); + if (!response.ok) { + throw new Error(`Failed to fetch OIDC configuration (${response.status}) from ${wellKnown}`); + } + const config = (await response.json()) as OidcConfig; + // OIDC Discovery §4.3: the returned issuer must equal the requested one. + if (config.issuer.replace(/\/$/, '') !== issuer.replace(/\/$/, '')) { + throw new Error(`Issuer mismatch: requested ${issuer}, discovery returned ${config.issuer}`); + } + return config; +} + +/** The fixed redirect URI this extension is registered with (chrome.identity). */ +function getRedirectUri(): string { + return chrome.identity.getRedirectURL(); +} + +async function fetchProfile(webId: string): Promise { + const response = await fetch(webId, { headers: { Accept: 'text/turtle' } }); + if (!response.ok) { + throw new Error(`Failed to fetch WebID profile (${response.status}) from ${webId}`); + } + return response.text(); +} + +async function dynamicClientRegistration( + registrationEndpoint: string, + redirectUri: string, +): Promise { + const response = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + application_type: 'web', + redirect_uris: [redirectUri], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: SCOPE, + client_name: 'Solid Browser Extension', + }), + }); + if (!response.ok) { + throw new Error(`Dynamic client registration failed (${response.status})`); + } + const body = (await response.json()) as { client_id: string }; + return body.client_id; +} + +function randomB64Url(bytes: number): string { + const array = new Uint8Array(bytes); + crypto.getRandomValues(array); + return jose.base64url.encode(array); +} + +async function pkceChallenge(verifier: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + return jose.base64url.encode(new Uint8Array(digest)); +} + +export interface InitiateLoginOptions { + readonly webId: string; + /** A Client Identifier Document URL to log in as (page-supplied or the extension's own). */ + readonly clientId?: string; + /** Resolve a multi-issuer ambiguity (the user picks). Defaults to the first issuer. */ + readonly chooseIssuer?: (issuers: string[]) => Promise; +} + +/** + * Run the interactive authorization-code (DPoP) login for a WebID. The worker commits the + * returned token set and key together in its ProfileStore, alongside display metadata. + */ +export async function initiateLogin(options: InitiateLoginOptions): Promise<{ + session: StoredSession; + keyPair: CryptoKeyPair; + name: string | null; + photoUrl: string | null; +}> { + const { webId } = options; + const clientIdMode = await loadClientIdMode(); + const turtle = await fetchProfile(webId); + const profile = parseWebIdProfile(webId, turtle); + const issuer = await selectIssuer( + profile, + webId, + options.chooseIssuer ?? (async (issuers) => issuers[0]), + ); + + const config = await fetchOidcConfig(issuer); + const redirectUri = getRedirectUri(); + + // A page-declared document keeps precedence. Only the extension's trusted setting can + // select DCR; document failure must never silently change the client's identity. + let clientId = options.clientId || ''; + if (!clientId) { + if (clientIdMode === 'document') { + if (!(await isPublishedClientIdReachable(PUBLISHED_CLIENT_ID_URL))) { + throw new Error('The published Client ID Document is unavailable or invalid.'); + } + clientId = PUBLISHED_CLIENT_ID_URL; + } else { + if (!config.registration_endpoint) { + throw new Error('clientIdMode=dcr but the issuer offers no dynamic registration endpoint.'); + } + clientId = await dynamicClientRegistration(config.registration_endpoint, redirectUri); + } + } + + const codeVerifier = randomB64Url(32); + const codeChallenge = await pkceChallenge(codeVerifier); + const state = randomB64Url(16); + + const params: AuthParams = { + codeVerifier, + state, + clientId, + issuer: config.issuer, + tokenEndpoint: config.token_endpoint, + redirectUri, + }; + await saveAuthParams(params); + + const authUrl = new URL(config.authorization_endpoint); + authUrl.search = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope: SCOPE, + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + prompt: 'consent', + }).toString(); + + const callbackUrl = await new Promise((resolve, reject) => { + chrome.identity.launchWebAuthFlow({ url: authUrl.toString(), interactive: true }, (url) => { + if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message)); + else if (!url) reject(new Error('Authorization was cancelled.')); + else resolve(url); + }); + }); + + const credentials = await completeLogin(callbackUrl); + return { ...credentials, name: profile.name, photoUrl: profile.photoUrl }; +} + +/** Exchange the authorization code (from the callback URL) for a DPoP-bound token set. */ +async function completeLogin( + callbackUrl: string, +): Promise<{ session: StoredSession; keyPair: CryptoKeyPair }> { + const callback = new URL(callbackUrl).searchParams; + const code = callback.get('code'); + const returnedState = callback.get('state'); + const returnedIss = callback.get('iss'); + + const params = await loadAuthParams(); + if (!params) throw new Error('Login flow not initiated (no auth params).'); + + if (!code) { + const error = callback.get('error') ?? 'unknown'; + throw new Error(`Authorization failed: ${error} - ${callback.get('error_description') ?? ''}`); + } + if (returnedState !== params.state) { + throw new Error('State mismatch — possible CSRF; aborting login.'); + } + // RFC 9207 authorization-server issuer identification — defends against IdP mix-up: if the + // AS returned an `iss`, it MUST equal the issuer we started the flow with. + if (returnedIss !== null && returnedIss.replace(/\/$/, '') !== params.issuer.replace(/\/$/, '')) { + throw new Error('Issuer mismatch in the authorization response — aborting login.'); + } + + // Generate the keypair EARLY (needed to sign the token-exchange DPoP proof itself), but do + // return it only after the exchange succeeds. The worker commits it with its token set; + // a failed login never overwrites any existing profile's key. + const keyPair = await generateDpopKeyPair(); + const dpopProof = await createDpopProof({ + keyPair, + htm: 'POST', + htu: params.tokenEndpoint, + }); + + const response = await fetch(params.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: dpopProof }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: params.redirectUri, + client_id: params.clientId, + code_verifier: params.codeVerifier, + }), + }); + + if (!response.ok) { + throw new Error(`Token exchange failed (${response.status}).`); + } + const tokens = (await response.json()) as { + access_token: string; + refresh_token?: string; + id_token: string; + expires_in?: number; + }; + + const idToken = jose.decodeJwt(tokens.id_token); + const webId = (idToken.webid as string) || (idToken.sub as string); + if (!webId) throw new Error('No WebID in the ID token.'); + + // The worker persists this key and its token set atomically under the owning profile id. + const session: StoredSession = { + webId, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? null, + dpopJkt: await dpopThumbprint(keyPair.publicKey), + issuer: params.issuer, + tokenEndpoint: params.tokenEndpoint, + clientId: params.clientId, + expiresAt: Date.now() + (tokens.expires_in ?? 600) * 1000, + }; + + await clearAuthParams(); + return { session, keyPair }; +} + +/** Refresh only the supplied profile's token set using its own persisted DPoP key. */ +export async function refreshSession( + session: StoredSession, + keyPair: CryptoKeyPair, +): Promise { + if (!session.refreshToken) throw new Error('No refresh token; re-login required.'); + if (!session.dpopJkt || session.dpopJkt !== (await dpopThumbprint(keyPair.publicKey))) { + throw new Error('Session does not match the profile DPoP key.'); + } + + const grant = async (nonce?: string): Promise => { + const proof = await createDpopProof({ + keyPair, + htm: 'POST', + htu: session.tokenEndpoint, + nonce, + }); + return fetch(session.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: proof }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: session.refreshToken as string, + client_id: session.clientId, + scope: SCOPE, + }), + }); + }; + + let response = await grant(); + if (response.status === 400 || response.status === 401) { + const nonce = response.headers.get('dpop-nonce'); + if (nonce) response = await grant(nonce); + } + if (!response.ok) { + throw new Error(`Token refresh failed (${response.status}).`); + } + + const tokens = (await response.json()) as { + access_token: string; + refresh_token?: string; + expires_in?: number; + }; + + const updated: StoredSession = { + ...session, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? session.refreshToken, + expiresAt: Date.now() + (tokens.expires_in ?? 600) * 1000, + }; + return updated; +} diff --git a/apps/extension/src/background/client-id-template.ts b/apps/extension/src/background/client-id-template.ts new file mode 100644 index 000000000..e74dfdef1 --- /dev/null +++ b/apps/extension/src/background/client-id-template.ts @@ -0,0 +1,23 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +/** The single template used to render the public Solid-OIDC Client Identifier Document. */ +export const PUBLISHED_CLIENT_ID_URL = + 'https://jeswr.github.io/solid-browser-extension/clientid.jsonld'; +export const SOLID_OIDC_CONTEXT = 'https://www.w3.org/ns/solid/oidc-context.jsonld'; +export const CLIENT_ID_SCOPE = 'openid webid offline_access'; + +export function renderClientIdDocument(extensionId: string) { + if (!/^[a-p]{32}$/.test(extensionId)) throw new Error('Invalid Chromium extension ID.'); + return { + '@context': SOLID_OIDC_CONTEXT, + client_id: PUBLISHED_CLIENT_ID_URL, + client_name: 'Solid Browser Extension', + client_uri: 'https://github.com/jeswr/solid-browser-extension', + logo_uri: new URL('icon128.png', PUBLISHED_CLIENT_ID_URL).href, + redirect_uris: [`https://${extensionId}.chromiumapp.org/`], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: CLIENT_ID_SCOPE, + token_endpoint_auth_method: 'none', + application_type: 'web', + }; +} diff --git a/apps/extension/src/background/client-id.ts b/apps/extension/src/background/client-id.ts new file mode 100644 index 000000000..3d6ca02eb --- /dev/null +++ b/apps/extension/src/background/client-id.ts @@ -0,0 +1,26 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +/** Client ID Document metadata comes from the build's single template. */ +export { CLIENT_ID_SCOPE, PUBLISHED_CLIENT_ID_URL } from './client-id-template'; + +/** + * Check that the hosted HTTPS document self-references its exact URL. Failure never + * selects DCR: auth-flow.ts reports an error when clientIdMode is 'document'. + */ +export async function isPublishedClientIdReachable( + url: string, + fetchImpl: typeof fetch = fetch, +): Promise { + try { + if (new URL(url).protocol !== 'https:') return false; + const response = await fetchImpl(url, { + headers: { Accept: 'application/ld+json, application/json' }, + credentials: 'omit', + redirect: 'error', + }); + if (!response.ok) return false; + const doc: unknown = await response.json(); + return typeof doc === 'object' && doc !== null && 'client_id' in doc && doc.client_id === url; + } catch { + return false; + } +} diff --git a/apps/extension/src/background/core/authenticated-fetch.ts b/apps/extension/src/background/core/authenticated-fetch.ts new file mode 100644 index 000000000..9bd2203a9 --- /dev/null +++ b/apps/extension/src/background/core/authenticated-fetch.ts @@ -0,0 +1,133 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The authenticated resource fetch — DPoP-bound, origin-gated, fail-closed, with the + * RFC 9449 §8 nonce-challenge retry. This is the one place the user's access token is + * ever attached to an outbound request, so every guard lives here. + * + * Security invariants (all enforced unconditionally): + * 1. ORIGIN GATE. The token is attached ONLY when the request URL's origin is in the + * session's allowed set ({@link isOriginAllowed}). A request to any other origin is + * sent UNAUTHENTICATED (a plain fetch) — never with the token. This stops a + * malicious page exfiltrating the token via `solid.fetch("https://evil/")`. + * 2. TOKEN-ENDPOINT GUARD. The resource token is never attached to the issuer's /token + * endpoint ({@link isTokenEndpoint}) even if that origin is allowed. + * 3. NONCE RETRY (RFC 9449 §8). On a pure `use_dpop_nonce` 401, the proof is re-minted + * with the server `DPoP-Nonce` and the request retried ONCE. + * 4. SINGLE RETRY. Exactly one retry, so a misbehaving server cannot loop us. + * 5. NO REDIRECTS. Never forward DPoP or return bytes from an unchecked redirect target, + * including on the credential-free path and the nonce retry. + * + * The re-entrancy guard (a fetch the SW itself issues for discovery/token/refresh must + * never recurse into THIS path) is the caller's responsibility: discovery/token use the + * raw `fetch`, never this function. This function only ever sends the access token to a + * vetted resource origin. + */ + +import { createDpopProof } from './dpop'; +import { isOriginAllowed, isTokenEndpoint } from './origin-policy'; +import { isUseDpopNonceChallenge } from './www-authenticate'; + +/** The minimum session shape the authenticated fetch needs. */ +export interface FetchSession { + readonly accessToken: string; + readonly dpopKeyPair: CryptoKeyPair; + readonly allowedOrigins: ReadonlySet; + readonly tokenEndpoint: string | null; +} + +export interface AuthenticatedFetchOptions { + readonly url: string; + readonly method: string; + readonly headers: Record; + /** + * The request body. Restricted to a REPLAYABLE `string | null` because the §8 nonce + * retry must re-send the body: a one-shot `ReadableStream`/`Blob` stream cannot be sent + * twice. The page->worker message protocol already serialises bodies to strings, so this + * is the only shape that ever reaches here; a non-string body is rejected at the inject + * boundary (see inject.ts). + */ + readonly body?: string | null; + /** A cached server DPoP nonce to use on the first attempt, if known. */ + readonly nonce?: string; + /** Injected fetch (test seam). Defaults to the global. */ + readonly fetchImpl?: typeof fetch; +} + +export interface AuthenticatedFetchResult { + readonly response: Response; + /** The freshest server DPoP nonce observed (cache it for the next request). */ + readonly nonce?: string; + /** Whether the token was actually attached (false => sent as a plain, public fetch). */ + readonly authenticated: boolean; +} + +const HOP_BY_HOP = new Set(['authorization', 'dpop', 'host', 'content-length', 'connection']); + +/** Strip caller-supplied auth/transport headers so a page can't inject its own DPoP/Authorization. */ +function sanitizeHeaders(headers: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) { + if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v; + } + return out; +} + +/** + * Perform an authenticated resource fetch under all the invariants above. If the request + * is not to an allowed origin (or targets the token endpoint), it is performed as a plain + * fetch with NO token — fail-open for reads to public/foreign resources, fail-CLOSED for + * the credential (the credential is simply never attached). + */ +export async function authenticatedFetch( + session: FetchSession, + options: AuthenticatedFetchOptions, +): Promise { + const { url, method, body } = options; + const fetchImpl = options.fetchImpl ?? fetch; + const safeHeaders = sanitizeHeaders(options.headers); + + const mayAttach = + isOriginAllowed(session.allowedOrigins, url) && !isTokenEndpoint(url, session.tokenEndpoint); + + if (!mayAttach) { + // Foreign / public / token-endpoint: a plain, credential-free fetch. + const response = await fetchImpl(url, { + method, + headers: safeHeaders, + body, + redirect: 'error', + }); + return { response, authenticated: false }; + } + + const send = async (nonce?: string): Promise => { + const proof = await createDpopProof({ + keyPair: session.dpopKeyPair, + htm: method, + htu: url, + accessToken: session.accessToken, + nonce, + }); + return fetchImpl(url, { + method, + headers: { + ...safeHeaders, + Authorization: `DPoP ${session.accessToken}`, + DPoP: proof, + }, + body, + redirect: 'error', + }); + }; + + let response = await send(options.nonce); + let serverNonce = response.headers.get('dpop-nonce') ?? undefined; + + // RFC 9449 §8: one retry on a pure nonce challenge, using the server's fresh nonce. + if (response.status === 401 && isUseDpopNonceChallenge(response) && serverNonce) { + response = await send(serverNonce); + serverNonce = response.headers.get('dpop-nonce') ?? serverNonce; + } + + return { response, nonce: serverNonce, authenticated: true }; +} diff --git a/apps/extension/src/background/core/class-grant.ts b/apps/extension/src/background/core/class-grant.ts new file mode 100644 index 000000000..5783e8a16 --- /dev/null +++ b/apps/extension/src/background/core/class-grant.ts @@ -0,0 +1,301 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The PER-DATA-CLASS GRANT model + the pure enforcement logic — the security heart of + * access-management Phase 2 (design `access-management-proposal.md` §4). + * + * ──────────────────────────────────────────────────────────────────────────── + * WHAT THIS WIDENS, AND WHY IT IS FAIL-CLOSED + * + * Phase-0/1 consented by *origin*: a granted app origin could drive `window.solid.fetch` + * against the WHOLE pod. This module adds the ORTHOGONAL, NARROWER capability the design + * calls for — a grant keyed by `(origin, dataClass, modes, purpose, expiry)` whose + * enforceable substance is a **PINNED, RESOLVED TARGET SET**: at grant time the data class + * is resolved (type-index walk, `class-resolve.ts`) to the concrete pod resources/containers + * it maps to, and THAT set is frozen into the grant. The service worker then enforces the + * pinned set on every `.fetch` — not the origin, and never a live re-resolution (the TOCTOU + * mitigation, design §6.2: the user consented to *those* resources; a resource created later, + * or re-typed into the class, needs fresh consent). + * + * This module is the PURE core of that gate — no `chrome.*`, no `fetch`, no storage — so the + * decision is exhaustively unit-testable. Every rule is fail-closed: + * - an EXPIRED grant authorises nothing (and is pruned); + * - a grant with an EMPTY / unresolved target set authorises nothing (unknown class → deny); + * - a grant with no valid modes authorises nothing; + * - a malformed stored grant authorises nothing; + * - a target-set membership test fails closed on any unparseable URL, cross-origin target, + * or path-traversal attempt (the WHATWG URL parser normalises `..`, so a normalised path + * that escapes the pinned container prefix is simply not a match → DENY). + * + * BACKWARDS COMPATIBILITY (design item 5 — "legacy origin-only grants must keep working"): + * this module governs ONLY origins whose access is class-scoped. An origin that holds a + * FULL-pod grant (a deliberate login / `setClientId` opt-in, or a credential/pod origin) is + * NOT constrained here — the service worker checks full-grant membership FIRST and only falls + * through to this pinned-set gate for an origin that is present *solely* because it holds one + * or more class grants. So a legacy origin keeps its whole-pod access unchanged; a class-only + * origin is confined to its pinned targets. (Dual-read migration; no data migration needed.) + * ──────────────────────────────────────────────────────────────────────────── + */ + +/** The three WAC-shaped access modes a grant can carry. */ +export type GrantMode = 'read' | 'append' | 'write'; + +/** A pinned, resolved target: either one exact resource, or a container (covers descendants). */ +export interface PinnedTarget { + /** The concrete resource/container URL, normalised to its `origin + pathname`. */ + readonly url: string; + /** `instance` ⇒ exactly this resource; `container` ⇒ this container and its descendants. */ + readonly kind: 'instance' | 'container'; +} + +/** + * A per-data-class grant, as persisted in extension storage. IMMUTABLE once written (a change + * is a new grant + a revoke, mirroring SAI's immutable AccessGrant). The `targets` are the + * RESOLVED, PINNED set — the substance the SW enforces; the `dataClass`/`purpose` are the + * human-meaningful consent anchors carried for the dashboard + audit (they are NOT enforced — + * enforcement is on `targets` + `modes` + `expiresAt`). + */ +export interface ClassGrant { + /** Opaque, unique grant id (also the revoke handle). */ + readonly grantId: string; + /** + * The WebID this grant was minted under — a SECURITY dimension (mirrors the replica-key + * WebID boundary): a grant minted for WebID A NEVER authorises a request while WebID B is the + * active session, so a re-login as a different identity cannot inherit A's pinned access. + */ + readonly webId: string; + /** The requesting app origin this grant authorises (canonical `https://host[:port]`). */ + readonly origin: string; + /** The data-class IRI the user consented to (audit/UX anchor; not itself enforced). */ + readonly dataClass: string; + /** The granted modes. Enforcement requires the request's method-mode to be satisfied. */ + readonly modes: readonly GrantMode[]; + /** The declared purpose (DPV IRI or string) — audit-only, NOT RS-enforceable (design §6.3). */ + readonly purpose: string | null; + /** Absolute expiry, epoch ms. A request at/after this instant is denied (design item 3). */ + readonly expiresAt: number; + /** The RESOLVED, PINNED targets — the enforced substance (TOCTOU mitigation, design §6.2). */ + readonly targets: readonly PinnedTarget[]; + /** When the grant was minted, epoch ms (dashboard ordering / audit). */ + readonly createdAt: number; +} + +/** + * Map an HTTP method to the access mode the request REQUIRES. Fail-closed: an unknown/unsafe + * method requires `write` (the most privileged mode), so an unrecognised verb can only be + * authorised by an explicit write grant, never slipped through as a read. + * GET/HEAD → read ; POST → append ; PUT/PATCH/DELETE → write ; else → write. + */ +export function methodToMode(method: string): GrantMode { + switch (method.trim().toUpperCase()) { + case 'GET': + case 'HEAD': + return 'read'; + case 'POST': + return 'append'; + case 'PUT': + case 'PATCH': + case 'DELETE': + return 'write'; + default: + return 'write'; + } +} + +/** Parse/normalise an arbitrary mode string to a {@link GrantMode}, or `null` if unrecognised. */ +export function normaliseMode(value: unknown): GrantMode | null { + if (typeof value !== 'string') return null; + switch (value.trim().toLowerCase()) { + case 'read': + return 'read'; + case 'append': + return 'append'; + case 'write': + return 'write'; + default: + return null; + } +} + +/** Normalise a list of mode strings to the deduped set of recognised {@link GrantMode}s. */ +export function normaliseModes(values: unknown): GrantMode[] { + if (!Array.isArray(values)) return []; + const out: GrantMode[] = []; + for (const v of values) { + const m = normaliseMode(v); + if (m && !out.includes(m)) out.push(m); + } + return out; +} + +/** + * Whether a granted mode-set SATISFIES the mode a request requires. The only implication is the + * WAC-standard `write ⊇ append` (a write grant covers an append). `read` is independent — a + * write grant does NOT imply read, and a read grant does NOT imply append/write. Fail-closed: + * an empty granted set satisfies nothing. + */ +export function modeSatisfies(granted: readonly GrantMode[], required: GrantMode): boolean { + if (required === 'append') return granted.includes('append') || granted.includes('write'); + return granted.includes(required); +} + +/** Parse a string to its normalised `origin + pathname` (no query/fragment), or `null`. */ +function originPath(value: string): { origin: string; path: string } | null { + let u: URL; + try { + u = new URL(value); + } catch { + return null; + } + if (u.protocol !== 'https:' && u.protocol !== 'http:') return null; + const origin = u.origin; + if (origin === 'null' || origin === '') return null; + return { origin, path: u.pathname }; +} + +/** + * Whether a request URL falls WITHIN a single pinned target. PURE + fail-closed: + * - unparseable / non-http(s) request or target URL → false; + * - cross-origin (request origin ≠ target origin) → false; + * - `instance` ⇒ EXACT `origin + pathname` match (query/fragment ignored — a pod resource's + * identity is its path, not its query); + * - `container` ⇒ the request path must be the container path itself OR a descendant of it. + * The container path is normalised to end in `/`, and membership is a strict path-segment + * prefix on the ALREADY-URL-NORMALISED request path — so a `..` traversal (which the URL + * parser collapses before we ever see it) that escapes the container cannot match, and a + * sibling like `/finance-secret/` never matches the pinned `/finance/`. + */ +export function isWithinTarget(target: PinnedTarget, requestUrl: string): boolean { + const req = originPath(requestUrl); + if (req === null) return false; + const tgt = originPath(target.url); + if (tgt === null) return false; + if (req.origin !== tgt.origin) return false; + + if (target.kind === 'instance') { + return req.path === tgt.path; + } + // container: normalise to a trailing-slash prefix; the container itself and any descendant match. + const prefix = tgt.path.endsWith('/') ? tgt.path : `${tgt.path}/`; + return req.path === prefix || req.path.startsWith(prefix); +} + +/** Whether ANY pinned target of the set covers the request URL. Fail-closed on an empty set. */ +export function targetsCover(targets: readonly PinnedTarget[], requestUrl: string): boolean { + return targets.some((t) => isWithinTarget(t, requestUrl)); +} + +/** Whether a grant is expired (or has a missing/invalid expiry) at `now` (epoch ms). */ +export function isGrantExpired(grant: ClassGrant, now: number): boolean { + if (typeof grant.expiresAt !== 'number' || !Number.isFinite(grant.expiresAt)) return true; + return now >= grant.expiresAt; +} + +/** + * Structural validation of a grant loaded from (untrusted) storage. A grant that fails ANY + * check authorises NOTHING (it is dropped by {@link sanitiseGrants}). Defends the gate against + * a corrupted / tampered storage record. + */ +export function isValidGrant(value: unknown): value is ClassGrant { + if (typeof value !== 'object' || value === null) return false; + const g = value as Record; + if (typeof g.grantId !== 'string' || g.grantId.length === 0) return false; + if (typeof g.webId !== 'string' || g.webId.length === 0) return false; + if (typeof g.origin !== 'string' || originPath(g.origin) === null) return false; + if (typeof g.dataClass !== 'string') return false; + if (typeof g.expiresAt !== 'number' || !Number.isFinite(g.expiresAt)) return false; + if (typeof g.createdAt !== 'number' || !Number.isFinite(g.createdAt)) return false; + if (!Array.isArray(g.modes)) return false; + if (!Array.isArray(g.targets)) return false; + for (const t of g.targets) { + if (typeof t !== 'object' || t === null) return false; + const pt = t as Record; + if (typeof pt.url !== 'string') return false; + if (pt.kind !== 'instance' && pt.kind !== 'container') return false; + } + // modes must be an array of recognised mode strings (an unrecognised entry is a corrupt record). + for (const m of g.modes) if (normaliseMode(m) === null) return false; + return true; +} + +/** Keep only the structurally-valid grants from an untrusted list. */ +export function sanitiseGrants(values: unknown): ClassGrant[] { + if (!Array.isArray(values)) return []; + return values.filter(isValidGrant); +} + +/** + * Whether a SINGLE grant authorises a `(url, method)` request at `now`. Fail-closed on every + * axis: expired, mode not satisfied, empty/unresolved target set, or the URL outside every + * pinned target → false. + */ +export function grantCovers( + grant: ClassGrant, + requestUrl: string, + method: string, + now: number, +): boolean { + if (isGrantExpired(grant, now)) return false; + if (grant.targets.length === 0) return false; // unresolved / unknown class → DENY. + if (!modeSatisfies(grant.modes, methodToMode(method))) return false; + return targetsCover(grant.targets, requestUrl); +} + +/** A distinguishable reason the class gate denied, surfaced to the page (design item 2). */ +export type ClassDenyReason = + | 'out-of-grant-scope' // URL/method/expiry not covered by any pinned grant for this origin + | 'no-class-grant'; // the origin holds no (live) class grant at all + +/** + * The class-scoped gate decision for one request. Given the (already-pruned, origin-filtered) + * class grants an origin holds, decide whether `(url, method)` is authorised at `now`. + * `null` deny ⇒ ALLOW. PURE + fail-closed: no grants ⇒ `no-class-grant`; grants present but + * none cover ⇒ `out-of-grant-scope`. + */ +export function decideClassAccess( + grantsForOrigin: readonly ClassGrant[], + requestUrl: string, + method: string, + now: number, +): ClassDenyReason | null { + const live = grantsForOrigin.filter((g) => !isGrantExpired(g, now)); + if (live.length === 0) return 'no-class-grant'; + const authorised = live.some((g) => grantCovers(g, requestUrl, method, now)); + return authorised ? null : 'out-of-grant-scope'; +} + +/** The non-expired subset of a grant list at `now` (used to prune the store). */ +export function pruneExpiredGrants(grants: readonly ClassGrant[], now: number): ClassGrant[] { + return grants.filter((g) => !isGrantExpired(g, now)); +} + +/** + * The distinct origins holding at least one non-expired grant for the active WebID (drive the + * per-origin gate so a class-only origin passes it, then the class gate confines it). When + * `webId` is given, grants minted under a DIFFERENT WebID are ignored (the session boundary). + */ +export function liveGrantOrigins( + grants: readonly ClassGrant[], + now: number, + webId?: string | null, +): Set { + const out = new Set(); + for (const g of grants) { + if (isGrantExpired(g, now)) continue; + if (webId != null && g.webId !== webId) continue; + out.add(g.origin); + } + return out; +} + +/** + * The grants that apply to a `(origin, webId)` session — the input to {@link decideClassAccess}. + * Filters to this exact requesting origin AND this exact active WebID (the session boundary), + * so cross-origin and cross-identity grants can never authorise the request. + */ +export function grantsForSession( + grants: readonly ClassGrant[], + origin: string, + webId: string, +): ClassGrant[] { + return grants.filter((g) => g.origin === origin && g.webId === webId); +} diff --git a/apps/extension/src/background/core/class-request.ts b/apps/extension/src/background/core/class-request.ts new file mode 100644 index 000000000..97f7a75d0 --- /dev/null +++ b/apps/extension/src/background/core/class-request.ts @@ -0,0 +1,118 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * PURE parsing + validation of a `requestAccess({classes, modes, purpose, duration})` payload + * (design §4.2). The page can send anything — this normalises the untrusted input into a + * bounded, well-formed grant specification, or an error. Fail-closed: + * - a data class must be an `https:` (or dev-loopback `http:`) IRI; a non-IRI class is dropped, + * and a request with NO valid class is rejected; + * - modes must include at least one recognised mode; + * - a bounded `duration` is REQUIRED — a grant with no expiry can never be minted (design + * item 3, "expiry enforced"); an unparseable/absent/zero/negative duration is rejected; + * - the duration is capped at {@link MAX_GRANT_MS} so a page cannot request an effectively + * unbounded grant. + * + * No `chrome.*` / `fetch` / storage here — this is exhaustively unit-testable. + */ + +import { type GrantMode, normaliseModes } from './class-grant'; + +/** The hard ceiling on a grant's lifetime: 365 days. A longer request is clamped to this. */ +export const MAX_GRANT_MS = 365 * 24 * 60 * 60 * 1000; + +/** The raw, untrusted request payload as it arrives from the page bridge. */ +export interface RawAccessRequest { + readonly classes?: unknown; + readonly modes?: unknown; + readonly purpose?: unknown; + readonly duration?: unknown; +} + +/** A validated, bounded grant specification derived from a raw request. */ +export interface AccessRequestSpec { + /** The valid, deduped data-class IRIs requested. */ + readonly classes: readonly string[]; + /** The recognised, deduped modes requested. */ + readonly modes: readonly GrantMode[]; + /** The declared purpose (kept verbatim as an audit anchor), or `null`. */ + readonly purpose: string | null; + /** Absolute expiry for a grant minted now, epoch ms (`now + clamped(duration)`). */ + readonly expiresAt: number; + /** The (clamped) grant lifetime in ms — surfaced in the consent UI. */ + readonly durationMs: number; +} + +function isHttpIri(value: unknown): value is string { + if (typeof value !== 'string') return false; + let u: URL; + try { + u = new URL(value); + } catch { + return false; + } + return u.protocol === 'https:' || u.protocol === 'http:'; +} + +/** + * Parse an ISO-8601 duration to milliseconds, or `null` if it is not a positive, bounded + * duration. Supports the date part `P[nY][nM][nW][nD]` and the time part `T[nH][nM][nS]` + * (fractional seconds allowed). Year/month are approximated (Y = 365d, month = 30d) — a + * documented imprecision acceptable for an access-grant TTL — and any such approximation is in + * any case clamped by {@link MAX_GRANT_MS} at the caller. Returns `null` for `P`/empty/zero/ + * negative/NaN (fail-closed — the caller then rejects the request). + */ +export function parseIso8601DurationMs(value: unknown): number | null { + if (typeof value !== 'string') return null; + const m = + /^P(?:(\d+(?:\.\d+)?)Y)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec( + value.trim(), + ); + if (!m) return null; + const [, y, mo, w, d, h, min, s] = m; + // Require at least ONE component present (a bare "P" / "PT" is meaningless → reject). + if ([y, mo, w, d, h, min, s].every((p) => p === undefined)) return null; + const num = (p: string | undefined): number => (p === undefined ? 0 : Number.parseFloat(p)); + const ms = + num(y) * 365 * 24 * 60 * 60 * 1000 + + num(mo) * 30 * 24 * 60 * 60 * 1000 + + num(w) * 7 * 24 * 60 * 60 * 1000 + + num(d) * 24 * 60 * 60 * 1000 + + num(h) * 60 * 60 * 1000 + + num(min) * 60 * 1000 + + num(s) * 1000; + if (!Number.isFinite(ms) || ms <= 0) return null; + return ms; +} + +/** + * Normalise + validate a raw `requestAccess` payload against `now`. Returns the bounded spec, + * or `{ error }` describing the first fatal problem. Fail-closed on every axis (see file note). + */ +export function normaliseAccessRequest( + raw: RawAccessRequest, + now: number, +): AccessRequestSpec | { error: string } { + const rawClasses = Array.isArray(raw.classes) ? raw.classes : []; + const classes: string[] = []; + for (const c of rawClasses) { + if (isHttpIri(c) && !classes.includes(c)) classes.push(c); + } + if (classes.length === 0) { + return { error: 'requestAccess: at least one https: data-class IRI is required.' }; + } + + const modes = normaliseModes(raw.modes); + if (modes.length === 0) { + return { error: 'requestAccess: at least one of read/append/write modes is required.' }; + } + + const durationMs = parseIso8601DurationMs(raw.duration); + if (durationMs === null) { + return { error: 'requestAccess: a positive ISO-8601 duration (e.g. "P30D") is required.' }; + } + const clamped = Math.min(durationMs, MAX_GRANT_MS); + + const purpose = + typeof raw.purpose === 'string' && raw.purpose.trim().length > 0 ? raw.purpose.trim() : null; + + return { classes, modes, purpose, expiresAt: now + clamped, durationMs: clamped }; +} diff --git a/apps/extension/src/background/core/class-resolve.ts b/apps/extension/src/background/core/class-resolve.ts new file mode 100644 index 000000000..bda193a43 --- /dev/null +++ b/apps/extension/src/background/core/class-resolve.ts @@ -0,0 +1,222 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * DATA-CLASS → CONCRETE TARGET resolution — the compile step (design §2.3, resolution ladder). + * + * At GRANT time a data-class IRI is resolved to the concrete pod resources/containers that + * hold data of that class, and the result is PINNED into the immutable grant (TOCTOU + * mitigation, design §6.2). This module implements the ubiquitous rung of the ladder — the + * **Solid Type Index** (`solid:publicTypeIndex` / `solid:privateTypeIndex` → + * `solid:TypeRegistration` `solid:forClass` → `solid:instance` / `solid:instanceContainer`). + * The SAI `interop:DataRegistration` rung and the SHACL-validate rung (design §2.3 rungs 1 & 3) + * are documented follow-ups; the resolver is structured so they slot in as additional passes. + * + * RDF is parsed with N3.js (the house rule — never regex over Turtle). The pure parse helpers + * are unit-testable against fixtures; `resolveClassTargets` takes an injected fetch that + * distinguishes public discovery from authenticated private-index reads. + * + * FAIL-CLOSED / defence-in-depth: + * - only `NamedNode`, http(s), same-*credential*-origin targets are kept (a type index that + * points at a foreign origin is dropped — a compromised index cannot pin an off-pod target; + * the SW gate would refuse the token there anyway, this removes it earlier); + * - `forClass` / `instance` / `instanceContainer` objects that are not usable IRIs are dropped; + * - a fetch that fails / is non-2xx is skipped (a private index a public read can't reach + * simply contributes nothing) — resolution degrades, never throws the whole grant. + */ + +import { DataFactory, Parser, Store } from 'n3'; +import type { PinnedTarget } from './class-grant'; + +const SOLID = 'http://www.w3.org/ns/solid/terms#'; +const PUBLIC_TYPE_INDEX = `${SOLID}publicTypeIndex`; +const PRIVATE_TYPE_INDEX = `${SOLID}privateTypeIndex`; +const FOR_CLASS = `${SOLID}forClass`; +const INSTANCE = `${SOLID}instance`; +const INSTANCE_CONTAINER = `${SOLID}instanceContainer`; + +/** Public documents must never carry credentials, even on an authenticated pod origin. */ +export type ResolveFetch = ( + url: string, + authenticated: boolean, +) => Promise<{ ok: boolean; text: string }>; + +export interface ResolveOptions { + /** The active session's WebID (the profile document to read the type-index refs from). */ + readonly webId: string; + /** The injected fetch, with credential use explicit for each document. */ + readonly fetch: ResolveFetch; + /** Origins permitted for private-index reads. Omit to discover public documents only. */ + readonly authenticatedOrigins?: ReadonlySet; + /** + * The pod/credential origins a target is allowed to live on (defence in depth). When given, + * a resolved target on any other origin is DROPPED. Omit to keep all http(s) targets. + */ + readonly allowedOrigins?: ReadonlySet; +} + +function parse(baseUrl: string, turtle: string): Store { + const parser = new Parser({ baseIRI: baseUrl.split('#')[0] }); + const store = new Store(); + store.addQuads(parser.parse(turtle)); + return store; +} + +/** Normalise an IRI to its `origin + pathname`, keeping only http(s); else `null`. */ +function normaliseTargetUrl(value: string): { url: string; origin: string } | null { + let u: URL; + try { + u = new URL(value); + } catch { + return null; + } + if (u.protocol !== 'https:' && u.protocol !== 'http:') return null; + return { url: `${u.origin}${u.pathname}`, origin: u.origin }; +} + +/** + * The type-index document URLs advertised on the WebID subject (`solid:publicTypeIndex` + + * `solid:privateTypeIndex`), NamedNode + http(s) only. Public refs take precedence when a + * document is advertised both ways, so it is fetched once without credentials. PURE. + */ +export function parseTypeIndexRefs( + webId: string, + profileTurtle: string, +): Array<{ url: string; authenticated: boolean }> { + const store = parse(webId, profileTurtle); + const out: Array<{ url: string; authenticated: boolean }> = []; + for (const pred of [PUBLIC_TYPE_INDEX, PRIVATE_TYPE_INDEX]) { + for (const q of store.getQuads( + DataFactory.namedNode(webId), + DataFactory.namedNode(pred), + null, + null, + )) { + if (q.object.termType !== 'NamedNode') continue; + const n = normaliseTargetUrl(q.object.value); + if (n && !out.some((ref) => ref.url === q.object.value)) + out.push({ url: q.object.value, authenticated: pred === PRIVATE_TYPE_INDEX }); + } + } + return out; +} + +/** + * The pinned targets for `classIRI` declared in ONE type-index document. For every + * `solid:TypeRegistration`-shaped subject with `solid:forClass classIRI`, collect its + * `solid:instance` (kind `instance`) and `solid:instanceContainer` (kind `container`) objects, + * NamedNode + http(s) only, normalised to `origin + pathname`, optionally confined to + * `allowedOrigins`. PURE + deduped. + */ +export function parseTargetsForClass( + typeIndexUrl: string, + typeIndexTurtle: string, + classIRI: string, + allowedOrigins?: ReadonlySet, +): PinnedTarget[] { + const store = parse(typeIndexUrl, typeIndexTurtle); + const seen = new Set(); + const out: PinnedTarget[] = []; + + // Subjects registering this class (match on the NamedNode class object). + const subjects = new Set(); + for (const q of store.getQuads( + null, + DataFactory.namedNode(FOR_CLASS), + DataFactory.namedNode(classIRI), + null, + )) { + subjects.add(q.subject.value); + } + + const collect = (pred: string, kind: PinnedTarget['kind']): void => { + for (const subject of subjects) { + for (const q of store.getQuads( + DataFactory.namedNode(subject), + DataFactory.namedNode(pred), + null, + null, + )) { + if (q.object.termType !== 'NamedNode') continue; + const n = normaliseTargetUrl(q.object.value); + if (n === null) continue; + if (allowedOrigins && !allowedOrigins.has(n.origin)) continue; + const dedupe = `${kind} ${n.url}`; + if (seen.has(dedupe)) continue; + seen.add(dedupe); + out.push({ url: n.url, kind }); + } + } + }; + collect(INSTANCE, 'instance'); + collect(INSTANCE_CONTAINER, 'container'); + return out; +} + +/** + * Resolve `classIRI` to its concrete, pinned targets by walking the Type Index: fetch the WebID + * profile → its type-index refs → each index, unioning the targets. Returns a deduped list + * (possibly empty — an unregistered class resolves to NOTHING, and the gate then denies every + * request for it, fail-closed). Never throws on a per-document fetch/parse failure. + */ +export async function resolveClassTargets( + classIRI: string, + opts: ResolveOptions, +): Promise { + const profile = await safeFetch(opts.fetch, opts.webId, false); + if (profile === null) return []; + + let refs: ReturnType; + try { + refs = parseTypeIndexRefs(opts.webId, profile); + } catch { + return []; + } + + const seen = new Set(); + const out: PinnedTarget[] = []; + for (const ref of refs) { + // Public indexes may live beside the WebID or on any other http(s) host. Only + // private-index egress is confined to authenticated origins; targets are filtered below. + if ( + ref.authenticated && + (!opts.authenticatedOrigins || !isAllowedOrigin(ref.url, opts.authenticatedOrigins)) + ) + continue; + const body = await safeFetch(opts.fetch, ref.url, ref.authenticated); + if (body === null) continue; + let targets: PinnedTarget[]; + try { + targets = parseTargetsForClass(ref.url, body, classIRI, opts.allowedOrigins); + } catch { + continue; + } + for (const t of targets) { + const dedupe = `${t.kind} ${t.url}`; + if (seen.has(dedupe)) continue; + seen.add(dedupe); + out.push(t); + } + } + return out; +} + +/** Whether `url`'s origin is in `allowed` (fail-closed on an unparseable URL). */ +function isAllowedOrigin(url: string, allowed: ReadonlySet): boolean { + try { + return allowed.has(new URL(url).origin); + } catch { + return false; + } +} + +async function safeFetch( + fetchFn: ResolveFetch, + url: string, + authenticated: boolean, +): Promise { + try { + const { ok, text } = await fetchFn(url, authenticated); + return ok ? text : null; + } catch { + return null; + } +} diff --git a/apps/extension/src/background/core/consent.ts b/apps/extension/src/background/core/consent.ts new file mode 100644 index 000000000..42db0cea1 --- /dev/null +++ b/apps/extension/src/background/core/consent.ts @@ -0,0 +1,102 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * PURE consent-flow logic — the decision layer behind the extension's consent surface + * (design §4.1, "the consent dialog must be unspoofable by the requesting origin"). The + * consent WINDOW (an extension-chrome page opened by the service worker) renders a + * {@link PendingConsent}; the user's per-class approve/deny maps back to concrete + * {@link ClassGrant}s through {@link consentDecisionToGrants}. Keeping this pure means the + * grant-minting is exhaustively unit-testable without a packed extension. + * + * The consent surface shows, per requested class: the data-class IRI, the requested MODES, + * the declared PURPOSE, the EXPIRY, and — critically — the RESOLVED TARGET SET ("this will + * share these N resources", design §3.4 step 2 / §6.2 gross-mislabelling catch). A class that + * resolved to ZERO targets is surfaced as such and, if approved, mints a grant that authorises + * NOTHING (fail-closed) — never a blanket grant. + */ + +import type { ClassGrant, GrantMode, PinnedTarget } from './class-grant'; + +/** One requested class as presented in the consent window (already resolved to targets). */ +export interface ConsentClass { + readonly dataClass: string; + readonly modes: readonly GrantMode[]; + readonly purpose: string | null; + readonly expiresAt: number; + /** The RESOLVED, pinned targets — shown to the user and frozen into the grant on approve. */ + readonly targets: readonly PinnedTarget[]; + /** The shape IRI the class resolved through (design anchor), or `null` (type-index rung). */ + readonly shape: string | null; +} + +/** A pending consent request awaiting the user's decision in the consent window. */ +export interface PendingConsent { + readonly consentId: string; + /** The verified requesting origin (dual-origin agreement already passed). */ + readonly origin: string; + /** The active WebID the grant would be minted under (the session boundary). */ + readonly webId: string; + readonly createdAt: number; + readonly classes: readonly ConsentClass[]; +} + +/** + * Build the pending-consent record from a validated request spec + the per-class resolution. + * `resolved` maps each class IRI to its pinned targets (+ optional shape). A class missing from + * `resolved` is presented with an empty target set. + */ +export function buildPendingConsent( + consentId: string, + origin: string, + webId: string, + spec: { + readonly classes: readonly string[]; + readonly modes: readonly GrantMode[]; + readonly purpose: string | null; + readonly expiresAt: number; + }, + resolved: ReadonlyMap, + createdAt: number, +): PendingConsent { + const classes: ConsentClass[] = spec.classes.map((dataClass) => { + const r = resolved.get(dataClass); + return { + dataClass, + modes: spec.modes, + purpose: spec.purpose, + expiresAt: spec.expiresAt, + targets: r?.targets ?? [], + shape: r?.shape ?? null, + }; + }); + return { consentId, origin, webId, createdAt, classes }; +} + +/** + * Map a user decision (the set of APPROVED data-class IRIs) to the grants to persist. Only a + * class that was BOTH requested AND approved mints a grant; a denied/unknown class mints none. + * `mintId` supplies a unique grantId per grant. Fail-closed: an approved class not present in + * the pending set is ignored (the window can only approve what it was shown). + */ +export function consentDecisionToGrants( + pending: PendingConsent, + approvedClasses: readonly string[], + mintId: () => string, +): ClassGrant[] { + const approved = new Set(approvedClasses); + const out: ClassGrant[] = []; + for (const c of pending.classes) { + if (!approved.has(c.dataClass)) continue; + out.push({ + grantId: mintId(), + webId: pending.webId, + origin: pending.origin, + dataClass: c.dataClass, + modes: c.modes, + purpose: c.purpose, + expiresAt: c.expiresAt, + targets: c.targets, + createdAt: pending.createdAt, + }); + } + return out; +} diff --git a/apps/extension/src/background/core/dpop-key-db.ts b/apps/extension/src/background/core/dpop-key-db.ts new file mode 100644 index 000000000..c60489c6d --- /dev/null +++ b/apps/extension/src/background/core/dpop-key-db.ts @@ -0,0 +1,106 @@ +// AUTHORED-BY Claude Sonnet 5 +/** + * The DPoP session keypair's durable home — a NON-EXTRACTABLE Web Crypto `CryptoKeyPair` + * persisted directly in IndexedDB, never exported to JWK / `chrome.storage.local`. + * + * SECURITY MIGRATION (design roborev round-1, the F3 keystore-advantage finding): the + * private DPoP key used to be generated `extractable: true` and exported to a JWK stored in + * `chrome.storage.local` because an MV3 service worker is killed/restarted aggressively and + * a non-extractable `CryptoKey` was assumed lost on suspension. That assumption conflated + * two different things: the KEY OBJECT's lifetime (tied to the SW's JS heap, always lost on + * suspension regardless of extractability) and whether the underlying key MATERIAL can be + * durably persisted. The WebCrypto spec makes `CryptoKey` structured-cloneable — including + * non-extractable keys (https://w3c.github.io/webcrypto/#cryptokey-interface, + * `[Serializable]`) — and IndexedDB persists values via the structured-clone algorithm. So a + * non-extractable `CryptoKey` written to an IndexedDB object store survives a + * put/close/reopen round-trip as a LIVE, usable key (`crypto.subtle.sign` still works on the + * reloaded object) while `crypto.subtle.exportKey('jwk', …)` on it correctly REJECTS. The + * private scalar therefore never serialises to disk as extractable material; only the + * PUBLIC JWK (safe — it's exactly what every DPoP proof header already broadcasts, see + * `dpop.ts` `createDpopProof`) is stored alongside for convenience. + * + * One record (`RECORD_ID`) per database: the extension mints a FRESH keypair per login (see + * `auth-flow.ts` `completeLogin` via `dpop-key-store.ts`), so this store always holds at most + * the CURRENT session's key — overwritten on the next login, cleared on logout/teardown + * (`service-worker.ts` `teardownSession`). + */ + +/** The persisted DPoP keypair record — the live `CryptoKey`s, not a JWK export. */ +export interface StoredDpopKeyRecord { + readonly privateKey: CryptoKey; + readonly publicKey: CryptoKey; + /** The public JWK (kty/crv/x/y only) — never the private `d`; safe to persist at rest. */ + readonly publicJwk: JsonWebKey; +} + +const DB_NAME = 'solid-ext-dpop-key'; +const DB_VERSION = 1; +const STORE = 'dpop-keypair'; +const RECORD_ID = 'active'; + +function promisify(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function txDone(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); +} + +/** + * Open (or upgrade) the DPoP key DB. The caller may inject an `indexedDB` factory (the test + * suite injects a fake); defaults to the global (present in the SW context). + */ +export function openDpopKeyDb(factory: IDBFactory = globalThis.indexedDB): Promise { + if (!factory) { + return Promise.reject(new Error('[solid-ext] no IndexedDB available in this context')); + } + return new Promise((resolve, reject) => { + const req = factory.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +/** A promise-based handle over the DPoP keypair's single IndexedDB record. */ +export class DpopKeyDb { + private constructor(private readonly db: IDBDatabase) {} + + static async open(factory?: IDBFactory): Promise { + return new DpopKeyDb(await openDpopKeyDb(factory)); + } + + /** The persisted keypair, or `undefined` if none is stored (fresh state / post-migration). */ + async get(): Promise { + const tx = this.db.transaction(STORE, 'readonly'); + return promisify(tx.objectStore(STORE).get(RECORD_ID)); + } + + /** Persist (overwrite) the active keypair record. */ + async put(record: StoredDpopKeyRecord): Promise { + const tx = this.db.transaction(STORE, 'readwrite'); + await promisify(tx.objectStore(STORE).put(record, RECORD_ID)); + await txDone(tx); + } + + /** Drop the persisted keypair (logout/teardown — the key is bound to the departing session). */ + async clear(): Promise { + const tx = this.db.transaction(STORE, 'readwrite'); + await promisify(tx.objectStore(STORE).clear()); + await txDone(tx); + } + + close(): void { + this.db.close(); + } +} diff --git a/apps/extension/src/background/core/dpop-key-store.ts b/apps/extension/src/background/core/dpop-key-store.ts new file mode 100644 index 000000000..8d947d54c --- /dev/null +++ b/apps/extension/src/background/core/dpop-key-store.ts @@ -0,0 +1,112 @@ +// AUTHORED-BY Claude Sonnet 5 +/** + * Orchestrates the durable, non-extractable DPoP session keypair: mint-and-persist at + * login, reload-from-IndexedDB on every SW wake, and the one-time legacy migration off the + * old extractable-JWK-in-`chrome.storage.local` shape (security hardening — see + * `dpop-key-db.ts` for the full rationale, `session-store.ts#loadSession` for where the + * legacy shape is detected + stripped). + */ + +import { dpopThumbprint, generateDpopKeyPair } from './dpop'; +import type { DpopKeyDb } from './dpop-key-db'; + +/** Export ONLY the public JWK — always safe, regardless of the private key's extractability. */ +async function publicJwkOf(keyPair: CryptoKeyPair): Promise { + return crypto.subtle.exportKey('jwk', keyPair.publicKey); +} + +/** + * Persist an ALREADY-MINTED keypair as the session's active key. Split out from + * {@link mintAndPersistDpopKeyPair} so a caller can generate a key EARLY (needed to sign the + * token-exchange DPoP proof itself) but defer the destructive overwrite of this single-record + * store until it knows the mint was actually USED for a token set — see `auth-flow.ts` + * `completeLogin`, which persists only after the token exchange succeeds (roborev round-1 + * Medium: persisting before success let a failed/retried login clobber an existing session's + * still-valid key). + */ +export async function persistDpopKeyPair(db: DpopKeyDb, keyPair: CryptoKeyPair): Promise { + await db.put({ + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + publicJwk: await publicJwkOf(keyPair), + }); +} + +/** Mint a fresh non-extractable keypair and persist it as the session's active key. */ +export async function mintAndPersistDpopKeyPair(db: DpopKeyDb): Promise { + const keyPair = await generateDpopKeyPair(); + await persistDpopKeyPair(db, keyPair); + return keyPair; +} + +/** + * Thrown by {@link loadSessionDpopKeyPair} when the persisted key does NOT usably back a + * CURRENT-shape session (one that recorded a `dpopJkt` at mint time) — either it is gone + * entirely, or the persisted key doesn't match what this session expects. Callers MUST treat + * this as a broken, unrecoverable-in-place session (teardown + re-login), the same as a + * refresh-grant failure — never catch it and silently proceed with an unrelated key (roborev + * round-1 Medium: a silently-rotated key for a still-token-bearing session just produces + * confusing 401s instead of a clean re-login prompt). + */ +export class DpopKeyUnavailableError extends Error { + constructor(reason: 'missing' | 'mismatch') { + super( + reason === 'missing' + ? 'The persisted DPoP key is missing for a session that expects one.' + : 'The persisted DPoP key does not match this session (dpopJkt mismatch).', + ); + this.name = 'DpopKeyUnavailableError'; + } +} + +/** The minimal session shape this module needs — just the recorded key thumbprint, if any. */ +export interface DpopBoundSession { + readonly dpopJkt?: string; +} + +/** + * Load the keypair backing `session`, verifying it against the session's recorded `dpopJkt` + * when present, or mint+persist a fresh one ONLY for the one-time legacy migration. + * + * Three cases: + * 1. A persisted key exists AND (the session has no recorded `dpopJkt` yet, OR the key's + * thumbprint matches it) — return it. This is the overwhelmingly common case: every + * normal SW wake after a real login. + * 2. A persisted key exists but its thumbprint does NOT match `session.dpopJkt` — the store + * was overwritten by something else (e.g. a later login) while this session object is + * stale. Throws {@link DpopKeyUnavailableError}: using the wrong key would just produce + * confusing 401s, not a functioning session. + * 3. No persisted key exists: + * - if `session.dpopJkt` is set, this is a CURRENT-shape session whose key was + * unexpectedly lost (IndexedDB eviction, browser bug) — throws + * {@link DpopKeyUnavailableError}, since minting a replacement would silently swap in a + * key this session's tokens were never bound to. + * - if `session.dpopJkt` is undefined, this is the ONE-TIME legacy migration: + * `session-store.ts#loadSession` strips a pre-hardening inline extractable JWK the + * moment it sees one, which leaves THIS store empty on the very next read. We + * deliberately do NOT attempt to import the stripped JWK as a new non-extractable key + * (that would still mean the private scalar touched JS as extractable material) — we + * mint a REPLACEMENT key instead, per the instructed rotation. A legacy session's + * refresh token was RFC 9449 DPoP-bound (`cnf.jkt`) to the OLD key, so it will fail the + * next refresh grant; the existing refresh-failure teardown in `service-worker.ts`'s + * `ensureSession()` already turns that into a clean logout rather than a crash. + * + * Callers (both `auth-flow.ts` `refreshSession` and `service-worker.ts` `ensureSession`) treat + * a thrown `DpopKeyUnavailableError` exactly like a refresh-grant failure: tear the session + * down for a clean re-login, never retry with a substitute key. + */ +export async function loadSessionDpopKeyPair( + db: DpopKeyDb, + session: DpopBoundSession, +): Promise { + const record = await db.get(); + if (record) { + if (session.dpopJkt !== undefined) { + const jkt = await dpopThumbprint(record.publicKey); + if (jkt !== session.dpopJkt) throw new DpopKeyUnavailableError('mismatch'); + } + return { privateKey: record.privateKey, publicKey: record.publicKey }; + } + if (session.dpopJkt !== undefined) throw new DpopKeyUnavailableError('missing'); + return mintAndPersistDpopKeyPair(db); +} diff --git a/apps/extension/src/background/core/dpop.ts b/apps/extension/src/background/core/dpop.ts new file mode 100644 index 000000000..a4881ca52 --- /dev/null +++ b/apps/extension/src/background/core/dpop.ts @@ -0,0 +1,134 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * DPoP proof generation (RFC 9449) for the MV3 service-worker context — the + * security-critical core of the Solid-OIDC path. + * + * This MIRRORS the discipline of `@jeswr/solid-dpop` (`src/dpop.ts`), reused as the + * canonical spec, but is reimplemented on **Web Crypto + jose** rather than + * `node:crypto`: an extension service worker has no Node `crypto` module, so the suite + * package's `node:crypto`-backed `createHash`/`randomUUID` cannot run here. The proof + * SHAPE is identical to the package's so behaviour matches the rest of the suite: + * + * - header `typ` = "dpop+jwt", `alg` = ES256, `jwk` = the PUBLIC key (kty/crv/x/y only). + * - payload `htm` = HTTP method (upper-cased), `htu` = canonical URI (no query/fragment), + * `iat` = now, `jti` = unique nonce, and — when presenting an access token — + * `ath` = base64url(SHA-256(access_token)). RFC 9449 §4.2 / §6.1. A + * server-supplied `nonce` (RFC 9449 §8) is echoed when present. + * + * The `ath` claim binds the proof to a specific access token; the `cnf.jkt` binds the + * token to this keypair. Nothing is hand-rolled beyond calling jose + Web Crypto: all + * JWS/base64url operations go through jose; the only direct crypto call is Web Crypto's + * SHA-256 for the `ath` digest (jose exposes no helper for it), exactly as the suite + * package does with node:crypto. + */ + +import * as jose from 'jose'; + +/** Signature algorithm used for the DPoP keypair. ES256 is the Solid-OIDC default. */ +export const DPOP_ALG = 'ES256' as const; + +/** The EC named curve paired with {@link DPOP_ALG}. */ +export const DPOP_CURVE = 'P-256' as const; + +/** + * Compute the RFC 9449 §4.2 `htu`: the request URI with query and fragment removed. + * The scheme + authority + path are normalised by the URL parser. Throws on an + * unparseable URI (a proof for a non-URL request must not be minted). + */ +export function canonicalHtu(uri: string): string { + const u = new URL(uri); + u.search = ''; + u.hash = ''; + return u.toString(); +} + +/** + * Compute the `ath` claim: base64url( SHA-256( ASCII(access_token) ) ). RFC 9449 §4.2. + * Web Crypto's SHA-256 + jose-style base64url (no padding, URL-safe alphabet). This is + * a digest, not a crypto primitive. + */ +export async function accessTokenHash(accessToken: string): Promise { + const bytes = new TextEncoder().encode(accessToken); + const digest = await crypto.subtle.digest('SHA-256', bytes); + return jose.base64url.encode(new Uint8Array(digest)); +} + +/** + * RFC 7638 JWK thumbprint (`jkt`) of a DPoP public key — the value a DPoP-bound access/ + * refresh token's `cnf.jkt` claim is checked against server-side. Recorded on `StoredSession` + * at key-mint time so a later SW wake can tell "the persisted key still matches what THIS + * session expects" from "the persisted key was unexpectedly lost or replaced" (see + * `dpop-key-store.ts` `loadSessionDpopKeyPair`) — the two call for different handling: the + * latter must never be silently papered over with a freshly-minted, unrelated key. + */ +export async function dpopThumbprint(publicKey: CryptoKey): Promise { + const jwk = (await crypto.subtle.exportKey('jwk', publicKey)) as jose.JWK; + return jose.calculateJwkThumbprint(jwk, 'sha256'); +} + +/** + * Generate a fresh DPoP keypair as **non-extractable** Web Crypto keys. + * + * SECURITY (hardened — see `core/dpop-key-db.ts` for the full rationale): the private key + * is `extractable: false`, so `crypto.subtle.exportKey(...)` on it always rejects — the + * private scalar can never be serialised out of Web Crypto, not even by code running in + * this same service-worker context. Persistence across the MV3 service-worker's aggressive + * kill/restart cycle is handled by `core/dpop-key-store.ts`, which writes the live + * `CryptoKeyPair` object directly into IndexedDB (structured-clone-safe for non-extractable + * keys per the WebCrypto spec) rather than exporting it to JWK. Only the PUBLIC key is ever + * exported (e.g. for the DPoP proof header's `jwk` — see {@link createDpopProof} below). + */ +export async function generateDpopKeyPair(): Promise { + return crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: DPOP_CURVE }, false, [ + 'sign', + 'verify', + ]); +} + +export interface DpopProofParams { + readonly keyPair: CryptoKeyPair; + readonly htm: string; + readonly htu: string; + /** Present iff this proof accompanies an access token (resource requests; some /token flows). */ + readonly accessToken?: string; + /** Server-supplied DPoP nonce (RFC 9449 §8) echoed back in the proof, if any. */ + readonly nonce?: string; +} + +/** + * Mint a single-use DPoP proof JWS. A fresh `jti` is generated per call, so every proof + * is unique; callers MUST NOT reuse a proof across requests. The header `jwk` carries + * ONLY the public components (kty/crv/x/y) — never the private scalar `d`. + */ +export async function createDpopProof(params: DpopProofParams): Promise { + const { keyPair, htm, htu, accessToken, nonce } = params; + + const publicJwk = (await crypto.subtle.exportKey('jwk', keyPair.publicKey)) as JsonWebKey; + const headerJwk = { + kty: publicJwk.kty, + crv: publicJwk.crv, + x: publicJwk.x, + y: publicJwk.y, + }; + + const payload: Record = { + htm: htm.toUpperCase(), + htu: canonicalHtu(htu), + jti: crypto.randomUUID(), + }; + if (accessToken !== undefined) { + payload.ath = await accessTokenHash(accessToken); + } + if (nonce !== undefined) { + payload.nonce = nonce; + } + + return new jose.SignJWT(payload) + .setProtectedHeader({ + typ: 'dpop+jwt', + alg: DPOP_ALG, + jwk: headerJwk as jose.JWK, + }) + .setIssuedAt() + .sign(keyPair.privateKey); +} diff --git a/apps/extension/src/background/core/formfill-data.ts b/apps/extension/src/background/core/formfill-data.ts new file mode 100644 index 000000000..1d08b99f5 --- /dev/null +++ b/apps/extension/src/background/core/formfill-data.ts @@ -0,0 +1,169 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import { DataFactory, Parser, type Quad_Object, termToId } from 'n3'; +import { + type CommunicationKind, + communicationValue, + MAX_FORM_FILL_VALUE_LENGTH, +} from '../../shared/formfill-values'; + +const VCARD = 'http://www.w3.org/2006/vcard/ns#'; +const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; +const schema = (property: string): string[] => [ + `http://schema.org/${property}`, + `https://schema.org/${property}`, +]; + +export interface FormFillData { + readonly contact: { + readonly name: string | null; + readonly givenName: string | null; + readonly familyName: string | null; + readonly email: string | null; + readonly tel: string | null; + readonly organization: string | null; + }; + /** Vocabulary-shaped parts; extendedAddress supplies address-line2. */ + readonly address: { + readonly streetAddress: string | null; + readonly extendedAddress: string | null; + readonly locality: string | null; + readonly region: string | null; + readonly postalCode: string | null; + readonly countryName: string | null; + }; +} + +/** + * Read only the bound WebID subject and its explicitly linked components. The caller + * supplies fetched Turtle and its document URL (including for an address-book document). + * Malformed Turtle throws; missing values are null. No requests or document-wide person search. + * vCard precedes schema.org; lowest positive vcard:pref wins, ties use document order. + */ +export function readFormFillData(webId: string, turtle: string, baseUrl: string): FormFillData { + const quads = new Parser({ baseIRI: baseUrl, format: 'text/turtle' }).parse(turtle); + const person = DataFactory.namedNode(webId); + const index = new Map>(); + const preferenceRanks = new Map(); + + // Index once, keeping each property's parser order for deterministic preference ties. + for (const { subject, predicate, object } of quads) { + const key = termToId(subject); + let predicates = index.get(key); + if (!predicates) { + predicates = new Map(); + index.set(key, predicates); + } + let values = predicates.get(predicate.value); + if (!values) { + values = []; + predicates.set(predicate.value, values); + } + values.push(object); + } + + function objects(subject: Quad_Object | undefined, predicate: string): Quad_Object[] { + if (!subject || subject.termType === 'Literal') return []; + return index.get(termToId(subject))?.get(predicate) ?? []; + } + + function preference(node: Quad_Object): number { + const key = termToId(node); + const cached = preferenceRanks.get(key); + if (cached !== undefined) return cached; + let rank = Number.POSITIVE_INFINITY; + for (const pref of objects(node, `${VCARD}pref`)) { + const value = pref.value.toLowerCase(); + const number = value === 'true' ? 1 : Number(value); + if (number > 0) rank = Math.min(rank, number); + } + if (objects(node, RDF_TYPE).some((type) => type.value === `${VCARD}Pref`)) { + rank = Math.min(rank, 1); + } + preferenceRanks.set(key, rank); + return rank; + } + + function ordered(subject: Quad_Object | undefined, predicate: string): Quad_Object[] { + return [...objects(subject, predicate)].sort((a, b) => preference(a) - preference(b)); + } + + function reference(subject: Quad_Object, predicates: string[]): Quad_Object | undefined { + for (const predicate of predicates) { + const node = ordered(subject, predicate).find( + (value) => value.termType === 'NamedNode' || value.termType === 'BlankNode', + ); + if (node) return node; + } + return undefined; + } + + function text(subject: Quad_Object | undefined, predicates: string[]): string | null { + for (const predicate of predicates) { + for (const value of ordered(subject, predicate)) { + if (value.termType !== 'Literal') continue; + const result = value.value.trim(); + if (result && result.length <= MAX_FORM_FILL_VALUE_LENGTH) return result; + } + } + return null; + } + + function communication(predicates: string[], kind: CommunicationKind): string | null { + const scheme = kind === 'email' ? 'mailto:' : 'tel:'; + function scalar(node: Quad_Object): string | null { + if (node.termType !== 'Literal' && node.termType !== 'NamedNode') return null; + const value = node.value.trim(); + if (node.termType === 'NamedNode' && !value.toLowerCase().startsWith(scheme)) return null; + return communicationValue(value, kind); + } + for (const predicate of predicates) { + for (const node of ordered(person, predicate)) { + // Typed nodes use hasValue; older Solid address books also use vcard:value. + for (const value of [ + ...ordered(node, `${VCARD}hasValue`), + ...ordered(node, `${VCARD}value`), + node, + ]) { + const result = scalar(value); + if (result) return result; + } + } + } + return null; + } + + const name = reference(person, [`${VCARD}hasName`]); + const organization = reference(person, [`${VCARD}hasOrganization`]); + const vcardAddress = reference(person, [`${VCARD}hasAddress`]); + const schemaAddress = reference(person, schema('address')); + function addressPart(vcard: string, schemaProperty: string): string | null { + return text(vcardAddress, [`${VCARD}${vcard}`]) ?? text(schemaAddress, schema(schemaProperty)); + } + + return { + contact: { + name: text(person, [`${VCARD}fn`, ...schema('name')]), + givenName: + text(person, [`${VCARD}given-name`]) ?? + text(name, [`${VCARD}given-name`]) ?? + text(person, schema('givenName')), + familyName: + text(person, [`${VCARD}family-name`]) ?? + text(name, [`${VCARD}family-name`]) ?? + text(person, schema('familyName')), + email: communication([`${VCARD}hasEmail`, ...schema('email')], 'email'), + tel: communication([`${VCARD}hasTelephone`, ...schema('telephone')], 'tel'), + organization: + text(person, [`${VCARD}organization-name`]) ?? + text(organization, [`${VCARD}organization-name`]), + }, + address: { + streetAddress: addressPart('street-address', 'streetAddress'), + extendedAddress: text(vcardAddress, [`${VCARD}extended-address`]), + locality: addressPart('locality', 'addressLocality'), + region: addressPart('region', 'addressRegion'), + postalCode: addressPart('postal-code', 'postalCode'), + countryName: addressPart('country-name', 'addressCountry'), + }, + }; +} diff --git a/apps/extension/src/background/core/formfill-grant.ts b/apps/extension/src/background/core/formfill-grant.ts new file mode 100644 index 000000000..6815d157c --- /dev/null +++ b/apps/extension/src/background/core/formfill-grant.ts @@ -0,0 +1,64 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import type { FieldCategory } from '../../content/formfill/detect'; +import { canonicalOrigin } from './origin-grant'; +import { isValidClientIdUrl } from './origin-policy'; + +/** Separate from API grants: this record never authorizes window.solid or pod egress. */ +export interface FormFillGrant { + readonly origin: string; + readonly profileId: string; + readonly categories: readonly FieldCategory[]; + readonly createdAt: number; + readonly expiresAt?: number; +} + +export function sanitiseFormFillGrants(raw: unknown): FormFillGrant[] { + if (!Array.isArray(raw)) return []; + const valid: FormFillGrant[] = []; + const counts = new Map(); + for (const value of raw) { + if (!value || typeof value !== 'object') continue; + const g = value as FormFillGrant; + if ( + !canonicalOrigin(g.origin) || + !isValidClientIdUrl(g.origin) || + typeof g.profileId !== 'string' || + !g.profileId.trim() || + !Number.isFinite(g.createdAt) || + g.createdAt < 0 || + (g.expiresAt !== undefined && + (!Number.isFinite(g.expiresAt) || g.expiresAt <= g.createdAt)) || + !Array.isArray(g.categories) || + !g.categories.length || + !g.categories.every((c) => c === 'contact' || c === 'address') + ) + continue; + counts.set(g.origin, (counts.get(g.origin) ?? 0) + 1); + valid.push({ + origin: g.origin, + profileId: g.profileId, + categories: [...new Set(g.categories)], + createdAt: g.createdAt, + ...(g.expiresAt === undefined ? {} : { expiresAt: g.expiresAt }), + }); + } + return valid.filter((g) => counts.get(g.origin) === 1); +} + +export function liveFormFillGrant( + grants: readonly FormFillGrant[], + origin: string, + now: number, +): FormFillGrant | null { + return ( + grants.find((g) => g.origin === origin && (g.expiresAt === undefined || g.expiresAt > now)) ?? + null + ); +} + +export function revokeProfileFormFillGrants( + grants: readonly FormFillGrant[], + profileId: string, +): FormFillGrant[] { + return grants.filter((g) => g.profileId !== profileId); +} diff --git a/apps/extension/src/background/core/origin-grant.ts b/apps/extension/src/background/core/origin-grant.ts new file mode 100644 index 000000000..b832abafd --- /dev/null +++ b/apps/extension/src/background/core/origin-grant.ts @@ -0,0 +1,92 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +/** Explicit origin → profile authority. Class grants only have meaning beneath this record. */ +import { type ClassGrant, sanitiseGrants } from './class-grant'; + +export interface OriginGrant { + readonly origin: string; + readonly profileId: string; + readonly scope: { readonly pods: readonly string[]; readonly classes?: readonly ClassGrant[] }; + readonly createdAt: number; + readonly expiresAt?: number; +} + +export function canonicalOrigin(value: unknown): string | null { + if (typeof value !== 'string') return null; + try { + const u = new URL(value); + return (u.protocol === 'https:' || u.protocol === 'http:') && u.origin === value + ? u.origin + : null; + } catch { + return null; + } +} + +/** Corrupt/ambiguous records never confer authority; duplicate origin bindings deny both. */ +export function sanitiseOriginGrants(raw: unknown): OriginGrant[] { + if (!Array.isArray(raw)) return []; + const valid: OriginGrant[] = []; + const counts = new Map(); + for (const value of raw) { + if (!value || typeof value !== 'object') continue; + const g = value as OriginGrant; + if ( + !canonicalOrigin(g.origin) || + typeof g.profileId !== 'string' || + !g.profileId || + !Number.isFinite(g.createdAt) || + (g.expiresAt !== undefined && !Number.isFinite(g.expiresAt)) || + !g.scope || + !Array.isArray(g.scope.pods) || + !g.scope.pods.every((p) => canonicalOrigin(p) !== null) || + (g.scope.classes !== undefined && !Array.isArray(g.scope.classes)) + ) + continue; + counts.set(g.origin, (counts.get(g.origin) ?? 0) + 1); + valid.push({ + origin: g.origin, + profileId: g.profileId, + createdAt: g.createdAt, + ...(g.expiresAt === undefined ? {} : { expiresAt: g.expiresAt }), + scope: { + pods: [...g.scope.pods], + ...(g.scope.classes === undefined + ? {} + : { + classes: sanitiseGrants(g.scope.classes).filter((c) => c.origin === g.origin), + }), + }, + }); + } + return valid.filter((g) => counts.get(g.origin) === 1); +} + +export function liveOriginGrant(grants: readonly OriginGrant[], origin: string, now: number) { + return ( + grants.find((g) => g.origin === origin && (g.expiresAt === undefined || g.expiresAt > now)) ?? + null + ); +} + +export interface OriginGrantPersistence { + read(): Promise; + write(grants: readonly OriginGrant[]): Promise; +} + +export class OriginGrantStore { + private tail: Promise = Promise.resolve(); + constructor(private readonly persistence: OriginGrantPersistence) {} + async list(): Promise { + await this.tail; + return sanitiseOriginGrants(await this.persistence.read()); + } + /** One writer makes replacing a profile binding atomic, including concurrent consent. */ + update(change: (grants: OriginGrant[]) => readonly OriginGrant[]): Promise { + const run = this.tail.then(async () => { + const current = sanitiseOriginGrants(await this.persistence.read()); + await this.persistence.write(sanitiseOriginGrants(change(current))); + }); + this.tail = run.catch(() => {}); + return run; + } +} diff --git a/apps/extension/src/background/core/origin-policy.ts b/apps/extension/src/background/core/origin-policy.ts new file mode 100644 index 000000000..e45af2225 --- /dev/null +++ b/apps/extension/src/background/core/origin-policy.ts @@ -0,0 +1,164 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The per-origin credential boundary — the security heart of a browser-level Solid + * auth proxy. + * + * The extension injects `window.solid.fetch` into EVERY page and proxies the call + * through the service worker, attaching the user's DPoP-bound access token. Without a + * boundary, a malicious page could call `solid.fetch("https://evil.example/collect")` + * and the extension would hand `evil.example` the user's pod token. That must be + * impossible. This module is the **fail-closed** gate, mirroring the boundary in + * `@jeswr/solid-elements`'s proactive-fetch (`computeAllowedOrigins`/`isOriginAllowed`): + * + * - The token is attached ONLY to an HTTPS origin in the allowed set. + * - The allowed set is the WebID's own origin ∪ the issuer's origin ∪ any explicit + * pod origins the user configured (a pod on a different host than the WebID is a + * valid Solid topology and must be listed). + * - CLEARTEXT GUARD: a non-`https:` origin is DROPPED, so a token never rides over + * plaintext — EXCEPT a loopback `http:` origin when `allowInsecureLoopback` is set + * (local dev CSS only). + * - Fail-closed everywhere: an unparseable URL is never allowed; an empty allowed set + * means the token is attached to NOTHING. + * + * Two further invariants enforced by the caller (the authenticated-fetch path), pinned + * here as pure helpers: the token is NEVER attached to the issuer's TOKEN endpoint via + * the resource path ({@link isTokenEndpoint}), and the proxy is re-entrancy guarded so a + * DPoP/discovery fetch the SW itself makes can never recurse into the authenticated path. + */ + +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); + +/** Whether a host is a loopback host (the only place `http:` is tolerated, in dev). */ +export function isLoopbackHost(host: string): boolean { + return LOOPBACK_HOSTS.has(host.toLowerCase()); +} + +/** Parse a string to its URL `origin`, or `null` if it is not a usable URL. */ +function originOf(value: string): string | null { + try { + return new URL(value).origin; + } catch { + return null; + } +} + +/** + * Whether an origin is permitted to carry the token given the transport policy. + * `https:` always; `http:` only for a loopback host AND only when dev loopback is + * allowed. Everything else (ws:, file:, http: on a real host) is rejected. + */ +function transportAllowed(origin: string, allowInsecureLoopback: boolean): boolean { + let url: URL; + try { + url = new URL(origin); + } catch { + return false; + } + if (url.protocol === 'https:') return true; + if (url.protocol === 'http:') { + return allowInsecureLoopback && isLoopbackHost(url.hostname); + } + return false; +} + +export interface AllowedOriginsInputs { + /** The authenticated WebID (its origin is included unless disabled). */ + readonly webId?: string | null; + /** The issuer URL (its origin is included unless disabled). */ + readonly issuer?: string | null; + /** Explicit pod origins (any URL; compared by `origin`). */ + readonly podOrigins?: readonly string[]; + /** Include the WebID's origin. Default true. */ + readonly includeWebIdOrigin?: boolean; + /** Include the issuer's origin. Default true. */ + readonly includeIssuerOrigin?: boolean; + /** Allow `http:` for loopback hosts only (dev). Default false. */ + readonly allowInsecureLoopback?: boolean; +} + +/** + * The set of resource origins the session token may be attached to. PURE so the boundary + * is unit-tested. Applies the cleartext guard to every candidate; an empty result means + * the token is attached to NOTHING (strictly fail-closed). + */ +export function computeAllowedOrigins(inputs: AllowedOriginsInputs): ReadonlySet { + const { + webId, + issuer, + podOrigins = [], + includeWebIdOrigin = true, + includeIssuerOrigin = true, + allowInsecureLoopback = false, + } = inputs; + + const candidates: string[] = []; + if (includeWebIdOrigin && webId) { + const o = originOf(webId); + if (o) candidates.push(o); + } + if (includeIssuerOrigin && issuer) { + const o = originOf(issuer); + if (o) candidates.push(o); + } + for (const pod of podOrigins) { + const o = originOf(pod); + if (o) candidates.push(o); + } + + const allowed = new Set(); + for (const origin of candidates) { + if (transportAllowed(origin, allowInsecureLoopback)) { + allowed.add(origin); + } + } + return allowed; +} + +/** + * Whether a request URL targets an allowed origin (the per-request credential gate). + * PURE. Fail-closed: an unparseable URL, or an empty allowed set, is never allowed. + */ +export function isOriginAllowed(allowed: ReadonlySet, requestUrl: string): boolean { + const origin = originOf(requestUrl); + if (origin === null) return false; + return allowed.has(origin); +} + +/** + * Whether a string is an acceptable Solid-OIDC Client Identifier Document URL: it must + * parse as a URL and be `https:`, OR `http:` only for a loopback host (dev). A remote + * plaintext client-id document could be tampered with in transit, so cleartext is rejected + * for real hosts. PURE; used to validate both a page-declared client-id (setClientId / the + * login message) before it is trusted. + */ +export function isValidClientIdUrl(value: string): boolean { + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + if (url.protocol === 'https:') return true; + if (url.protocol === 'http:') return isLoopbackHost(url.hostname); + return false; +} + +/** + * Whether a request URL is the issuer's TOKEN endpoint. The user's access token must + * NEVER be attached to the token endpoint via the resource-fetch path (it would leak the + * resource token into a token-management request and could enable a confused-deputy + * exchange). Compared by full canonical URL (scheme+host+port+path), query/fragment + * ignored. PURE. + */ +export function isTokenEndpoint(requestUrl: string, tokenEndpoint: string | null): boolean { + if (!tokenEndpoint) return false; + let req: URL; + let tok: URL; + try { + req = new URL(requestUrl); + tok = new URL(tokenEndpoint); + } catch { + return false; + } + return req.origin === tok.origin && req.pathname === tok.pathname; +} diff --git a/apps/extension/src/background/core/profile-store.ts b/apps/extension/src/background/core/profile-store.ts new file mode 100644 index 000000000..4cf7f01c5 --- /dev/null +++ b/apps/extension/src/background/core/profile-store.ts @@ -0,0 +1,286 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +/** Worker-only profile authority. Credentials are never part of the public projection. */ +import type { Profile } from '../../shared/messages'; +import type { StoredProfile, StoredSession } from '../session-store'; +import type { ClassGrant } from './class-grant'; +import { dpopThumbprint } from './dpop'; + +export interface ProfileRecord { + profile: Profile; + session: StoredSession | null; + keyPair: CryptoKeyPair | null; + grantedOrigins: string[]; + podOrigins: string[]; + classGrants: ClassGrant[]; +} + +export interface ProfileSnapshot { + records: ProfileRecord[]; + defaultProfileId: string | null; + nextId: number; + migrated: boolean; +} + +export interface ProfilePersistence { + read(): Promise; + write(state: ProfileSnapshot): Promise; +} + +export interface LegacyProfile { + session: StoredSession | null; + display: StoredProfile | null; + keyPair: CryptoKeyPair | null; + grantedOrigins: string[]; + podOrigins: string[]; + classGrants: ClassGrant[]; +} + +export const MAX_PROFILES = 8; + +export class ProfileStore { + private tail: Promise = Promise.resolve(); + + constructor( + private readonly persistence: ProfilePersistence, + private readonly purge: (webId: string) => Promise, + ) {} + + /** All read/modify/write sequences share one worker-owned writer, including failures. */ + private locked(op: () => Promise): Promise { + const run = this.tail.then(op); + this.tail = run.catch(() => {}); + return run; + } + + /** Commit migration before deleting legacy storage; cleanup is safe to retry on wake. */ + initialise(loadLegacy: () => Promise, clearLegacy: () => Promise) { + return this.locked(async () => { + const state = await this.persistence.read(); + if (!state.migrated) { + const legacy = await loadLegacy(); + if (legacy.session) { + const { session, keyPair } = legacy; + const valid = await this.keyMatches(session, keyPair); + const migrated = valid + ? { ...session, dpopJkt: await dpopThumbprint((keyPair as CryptoKeyPair).publicKey) } + : session; + state.records.push(this.record('1', migrated, valid ? keyPair : null, legacy.display)); + const record = state.records[0]; + if (valid) { + record.grantedOrigins = legacy.grantedOrigins; + record.podOrigins = legacy.podOrigins; + record.classGrants = legacy.classGrants.filter((g) => g.webId === session.webId); + } + state.defaultProfileId = '1'; + state.nextId = 2; + } + state.migrated = true; + await this.persistence.write(state); + } + await clearLegacy(); + // A worker may have died between revocation and the byte purge. Finish before serving. + for (const record of state.records) { + if (record.profile.sessionState === 'removing') await this.finishRemoval(state, record); + else if (record.profile.sessionState === 'signed-out') + await this.purge(record.profile.webId); + } + }); + } + + async list(): Promise<{ profiles: Profile[]; defaultProfileId: string | null }> { + const state = await this.persistence.read(); + return { + profiles: state.records.map(({ profile: p }) => ({ + id: p.id, + webId: p.webId, + issuer: p.issuer, + label: p.label, + avatar: p.avatar, + sessionState: p.sessionState, + dpopKeyRef: p.dpopKeyRef, + })), + defaultProfileId: state.defaultProfileId, + }; + } + + async get(id: string): Promise { + return this.find(await this.persistence.read(), id); + } + + async getDefault(): Promise { + const state = await this.persistence.read(); + return state.defaultProfileId === null ? null : this.find(state, state.defaultProfileId); + } + + /** The supplied id is mandatory: never fall back to a different profile's credentials. */ + async session(id: string): Promise<{ session: StoredSession; keyPair: CryptoKeyPair } | null> { + const record = (await this.persistence.read()).records.find((r) => r.profile.id === id); + if (record?.profile.sessionState !== 'active' || !record.session) return null; + if ( + record.session.webId !== record.profile.webId || + record.session.issuer !== record.profile.issuer || + !(await this.keyMatches(record.session, record.keyPair)) + ) { + await this.signOut(id); + return null; + } + return { session: record.session, keyPair: record.keyPair as CryptoKeyPair }; + } + + /** Re-login replaces only the same WebID. New WebIDs get fresh, empty capability scopes. */ + login(session: StoredSession, keyPair: CryptoKeyPair, display: StoredProfile): Promise { + return this.locked(async () => { + if (!(await this.keyMatches(session, keyPair))) throw new Error('Invalid profile DPoP key.'); + const state = await this.persistence.read(); + const existing = state.records.find((r) => r.profile.webId === session.webId); + if (existing?.profile.sessionState === 'removing') + throw new Error('Profile removal pending.'); + if (!existing && state.records.length >= MAX_PROFILES) throw new Error('At most 8 profiles.'); + if (existing?.profile.sessionState === 'signed-out') await this.purge(existing.profile.webId); + const id = existing ? existing.profile.id : String(state.nextId++); + const bound = { ...session, dpopJkt: await dpopThumbprint(keyPair.publicKey) }; + const record = this.record(id, bound, keyPair, display); + if (existing) { + record.profile = { ...record.profile, label: existing.profile.label }; + record.grantedOrigins = existing.grantedOrigins; + record.podOrigins = existing.podOrigins; + record.classGrants = existing.classGrants; + state.records[state.records.indexOf(existing)] = record; + } else { + state.records.push(record); + } + state.defaultProfileId ??= id; + await this.persistence.write(state); + return { ...record.profile }; + }); + } + + rename(id: string, label: string): Promise { + return this.change(id, (record) => { + if (typeof label !== 'string' || !label.trim() || label.length > 200) { + throw new Error('Profile label must contain 1–200 characters.'); + } + record.profile = { ...record.profile, label: label.trim() }; + }); + } + + setDefault(id: string): Promise { + return this.locked(async () => { + const state = await this.persistence.read(); + const record = this.find(state, id); + if (record.profile.sessionState === 'removing') throw new Error('Profile removal pending.'); + state.defaultProfileId = id; + await this.persistence.write(state); + }); + } + + updateSession(id: string, session: StoredSession): Promise { + return this.change(id, async (record) => { + if ( + record.profile.sessionState !== 'active' || + session.webId !== record.profile.webId || + session.issuer !== record.profile.issuer || + !(await this.keyMatches(session, record.keyPair)) + ) { + throw new Error('Session does not belong to this profile.'); + } + record.session = session; + }); + } + + /** Capability writes are internal to the SW; no page-facing message accepts these arrays. */ + capabilities(id: string, update: (record: ProfileRecord) => void | Promise): Promise { + return this.change(id, (record) => { + if (record.profile.sessionState !== 'active') throw new Error('Profile is not active.'); + return update(record); + }); + } + + signOut(id: string): Promise { + return this.locked(async () => { + const state = await this.persistence.read(); + const record = this.find(state, id); + // Logout must not cancel the durable intent to finish a partially committed removal. + if (record.profile.sessionState !== 'removing') + record.profile = { ...record.profile, sessionState: 'signed-out' }; + record.session = null; + record.keyPair = null; + record.grantedOrigins = []; + record.classGrants = []; + await this.persistence.write(state); + await this.purge(record.profile.webId); + }); + } + + remove(id: string): Promise { + return this.locked(async () => { + const state = await this.persistence.read(); + const record = this.find(state, id); + record.profile = { ...record.profile, sessionState: 'removing' }; + record.session = null; + record.keyPair = null; + record.grantedOrigins = []; + record.classGrants = []; + await this.persistence.write(state); + await this.finishRemoval(state, record); + }); + } + + private async finishRemoval(state: ProfileSnapshot, record: ProfileRecord): Promise { + await this.purge(record.profile.webId); + state.records = state.records.filter((r) => r !== record); + if (state.defaultProfileId === record.profile.id) { + state.defaultProfileId = state.records[0]?.profile.id ?? null; + } + await this.persistence.write(state); + } + + private change(id: string, op: (record: ProfileRecord) => void | Promise): Promise { + return this.locked(async () => { + const state = await this.persistence.read(); + await op(this.find(state, id)); + await this.persistence.write(state); + }); + } + + private find(state: ProfileSnapshot, id: string): ProfileRecord { + const record = state.records.find((r) => r.profile.id === id); + if (!record) throw new Error('Unknown profile.'); + return record; + } + + private async keyMatches( + session: StoredSession, + keyPair: CryptoKeyPair | null, + ): Promise { + if (!keyPair || keyPair.privateKey.extractable) return false; + return ( + session.dpopJkt === undefined || session.dpopJkt === (await dpopThumbprint(keyPair.publicKey)) + ); + } + + private record( + id: string, + session: StoredSession, + keyPair: CryptoKeyPair | null, + display: StoredProfile | null, + ): ProfileRecord { + const sameDisplay = display?.webId === session.webId; + return { + profile: { + id, + webId: session.webId, + issuer: session.issuer, + label: (sameDisplay && display.name) || session.webId, + avatar: (sameDisplay && display.photoUrl) || undefined, + sessionState: keyPair ? 'active' : 'signed-out', + dpopKeyRef: id, + }, + session: keyPair ? session : null, + keyPair, + grantedOrigins: [], + podOrigins: [], + classGrants: [], + }; + } +} diff --git a/apps/extension/src/background/core/replica-db.ts b/apps/extension/src/background/core/replica-db.ts new file mode 100644 index 000000000..8ee877049 --- /dev/null +++ b/apps/extension/src/background/core/replica-db.ts @@ -0,0 +1,163 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The IndexedDB backing for the shared extension-owned replica: + * - the per-entry METADATA store (ETag / fetchedAt / contentType / status / scope) — the + * client analogue of QLever, the 1:1 companion of the bytes in the Cache API. Modelled + * on `@jeswr/solid-offline` `metadata-store.ts` (a thin promise-handle over one object + * store) but EXTENDED so the security-scoped composite key (which embeds WebID + + * grant-scope, see replica-key.ts) is the primary key, and so the WebID is a queryable + * column for the synchronous logout purge. + * - the DPoP NONCE store — moved off the in-memory `Map` so a cold SW wake does not pay the + * RFC 9449 §8 nonce round-trip on its first request (design §2.1 last bullet, §5.2). + * + * ONE IndexedDB database (`solid-ext-replica`) holds both object stores, so the extension + * opens a single handle. All durable state lives here / in the Cache API / in + * `chrome.storage.local`; nothing in this module is load-bearing in-memory (the open handle + * is a re-derivable accelerator), keeping the MV3 "stateless on wake" invariant. + */ + +/** The metadata record persisted per replica entry — the 1:1 companion of the bytes. */ +export interface ReplicaMetadata { + /** Composite primary key = the security-scoped `computeReplicaKey` string. */ + readonly key: string; + /** The active WebID this entry belongs to (a queryable column for the logout purge). */ + readonly webId: string; + /** The grant-scope (verified requesting origin) this entry was cached under. */ + readonly grantScope: string; + /** The real resource URL (without the synthetic key wrapper). */ + readonly resourceUrl: string; + /** The variant discriminator (canonical Accept). */ + readonly varyKey: string; + /** The response ETag — drives the conditional `If-None-Match` revalidation. */ + readonly etag?: string; + /** The response Content-Type. */ + readonly contentType?: string; + /** HTTP status of the cached response. */ + readonly status: number; + /** Epoch ms the entry was last confirmed fresh (set on 200, touched on 304). */ + readonly fetchedAt: number; +} + +const DB_NAME = 'solid-ext-replica'; +const DB_VERSION = 1; +const META_STORE = 'replica-metadata'; +const NONCE_STORE = 'dpop-nonces'; +const META_BY_WEBID = 'byWebId'; + +function promisify(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function txDone(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); +} + +/** + * Open (or upgrade) the replica DB. The caller may inject an `indexedDB` factory (the test + * suite injects `fake-indexeddb`); defaults to the global (present in the SW context). + */ +export function openReplicaDb(factory: IDBFactory = globalThis.indexedDB): Promise { + if (!factory) { + return Promise.reject(new Error('[solid-ext] no IndexedDB available in this context')); + } + return new Promise((resolve, reject) => { + const req = factory.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(META_STORE)) { + const os = db.createObjectStore(META_STORE, { keyPath: 'key' }); + // The logout purge drops exactly one identity's rows — index the WebID column. + os.createIndex(META_BY_WEBID, 'webId', { unique: false }); + } + if (!db.objectStoreNames.contains(NONCE_STORE)) { + // Nonce store keyed by resource origin → the freshest server DPoP nonce string. + db.createObjectStore(NONCE_STORE); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +/** A promise-based handle over the replica's two IndexedDB stores. */ +export class ReplicaDb { + private constructor(private readonly db: IDBDatabase) {} + + static async open(factory?: IDBFactory): Promise { + return new ReplicaDb(await openReplicaDb(factory)); + } + + // --- Metadata ------------------------------------------------------------------------ + + async getMetadata(key: string): Promise { + const tx = this.db.transaction(META_STORE, 'readonly'); + return promisify(tx.objectStore(META_STORE).get(key)); + } + + async putMetadata(record: ReplicaMetadata): Promise { + const tx = this.db.transaction(META_STORE, 'readwrite'); + await promisify(tx.objectStore(META_STORE).put(record)); + await txDone(tx); + } + + async deleteMetadata(key: string): Promise { + const tx = this.db.transaction(META_STORE, 'readwrite'); + await promisify(tx.objectStore(META_STORE).delete(key)); + await txDone(tx); + } + + /** Every metadata row for a WebID (the rows a logout purge must drop + their byte keys). */ + async getMetadataByWebId(webId: string): Promise { + const tx = this.db.transaction(META_STORE, 'readonly'); + const index = tx.objectStore(META_STORE).index(META_BY_WEBID); + return promisify(index.getAll(webId)); + } + + /** Delete every metadata row for a WebID (the metadata half of the logout purge). */ + async deleteMetadataByWebId(webId: string): Promise { + const tx = this.db.transaction(META_STORE, 'readwrite'); + const store = tx.objectStore(META_STORE); + const index = store.index(META_BY_WEBID); + const keys = await promisify(index.getAllKeys(webId)); + for (const k of keys) await promisify(store.delete(k)); + await txDone(tx); + } + + /** Drop ALL replica metadata (a full reset — e.g. a logout with an unknown prior WebID). */ + async clearMetadata(): Promise { + const tx = this.db.transaction(META_STORE, 'readwrite'); + await promisify(tx.objectStore(META_STORE).clear()); + await txDone(tx); + } + + // --- DPoP nonce cache (survives SW restart) ------------------------------------------ + + async getNonce(origin: string): Promise { + const tx = this.db.transaction(NONCE_STORE, 'readonly'); + return promisify(tx.objectStore(NONCE_STORE).get(origin)); + } + + async setNonce(origin: string, nonce: string): Promise { + const tx = this.db.transaction(NONCE_STORE, 'readwrite'); + await promisify(tx.objectStore(NONCE_STORE).put(nonce, origin)); + await txDone(tx); + } + + /** Clear every cached nonce (part of the logout purge — a new identity must not reuse them). */ + async clearNonces(): Promise { + const tx = this.db.transaction(NONCE_STORE, 'readwrite'); + await promisify(tx.objectStore(NONCE_STORE).clear()); + await txDone(tx); + } + + close(): void { + this.db.close(); + } +} diff --git a/apps/extension/src/background/core/replica-key.ts b/apps/extension/src/background/core/replica-key.ts new file mode 100644 index 000000000..c9502711a --- /dev/null +++ b/apps/extension/src/background/core/replica-key.ts @@ -0,0 +1,155 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The SHARED-REPLICA cache key — the SECURITY-CRITICAL identity dimension of the + * extension-owned offline replica (design §2.1 "LOAD-BEARING CACHE-KEY FIX"). + * + * ──────────────────────────────────────────────────────────────────────────── + * WHY THIS IS A SECURITY BOUNDARY HERE (and is NOT in `@jeswr/solid-offline`): + * + * `@jeswr/solid-offline` (`scope.ts`) keys its byte/metadata stores on a hash of the + * WebID, but states VERBATIM that the hash is "only a *namespacing discriminator*, NOT a + * security boundary: the real boundary is the browser's origin isolation." That holds in a + * normal web page, where each top-level site gets its OWN partitioned Cache/IndexedDB. It + * does NOT hold in the extension: the `chrome-extension://` origin is a SINGLE shared + * partition (design §1) — there is no origin boundary underneath. ONE physical store holds + * EVERY app's, EVERY user's bytes. + * + * Therefore in the extension the cache key MUST itself be the boundary: + * - it embeds the active WebID, so a same-URL+same-ETag collision across two users on one + * device (a shared/public resource, or a re-provisioned WebID) can NEVER serve user A's + * bytes to user B (design §2.1); + * - it embeds the GRANT-SCOPE (the requesting app's verified, granted origin), so a body + * landed in the shared replica by one granted origin lives in that origin's partition — + * a missing grant cannot even REACH a shared body (design §5.4 point 3: "Partition the + * cache by grant-scope so a missing grant cannot even reach a shared body"). + * + * Single-user owner-only default (this Phase): there is ONE user, and the grant-scope is the + * verified requesting origin from the Phase-0 gate. The shared replica's value here is the + * SINGLE physical store (one byte store reachable from one place, no N-per-app duplication, + * one revalidation pipeline) keyed by the WebID+url+varyKey dimension, with the grant-scope + * PARTITIONING access. (Per-data-class grant scoping that lets several apps share one + * grant-scope is Phase 2; here grant-scope === the requesting origin.) + * ──────────────────────────────────────────────────────────────────────────── + * + * The byte cache (Cache API) and the metadata store (IndexedDB) are keyed TOGETHER on a + * single synthetic, collision-free Request whose URL encodes the FULL + * `(webId, grantScope, resourceUrl, varyKey)` composite — the same synthetic-key discipline + * `@jeswr/solid-offline` `cache-policy.ts#keyRequest` uses (one canonical key, no + * header-driven `Vary` divergence), EXTENDED with the two security dimensions above. + * + * PURE: no `caches` / `indexedDB` / `fetch` / SW globals, so the whole key derivation is + * exhaustively unit-testable. + */ + +/** Canonical Accept used to normalise RDF reads to a single cache variant (per solid-offline). */ +export const CANONICAL_RDF_ACCEPT = 'text/turtle'; + +/** A fresh sentinel origin for the synthetic key — nothing the server sees can collide with it. */ +export const REPLICA_KEY_ORIGIN = 'https://solid-extension-replica.invalid/'; + +/** RDF media types collapsed to {@link CANONICAL_RDF_ACCEPT} so all RDF variants share one entry. */ +const RDF_ACCEPT_HINTS = [ + 'text/turtle', + 'application/ld+json', + 'application/n-triples', + 'application/n-quads', + 'application/trig', + 'application/rdf+xml', +]; + +/** + * Compute the canonical Accept header for keying + revalidation. An RDF (or `* /*`) Accept + * collapses to `text/turtle` so JSON-LD and Turtle reads of the same resource share ONE byte + * copy (the de-duplication the shared replica exists to deliver); a non-RDF Accept (image, + * html) keys on what was actually requested. Mirrors solid-offline `cache-policy.ts`. + */ +export function canonicalAccept(accept: string | null | undefined): string { + if (!accept) return CANONICAL_RDF_ACCEPT; + const lower = accept.toLowerCase(); + if (lower.includes('*/*')) return CANONICAL_RDF_ACCEPT; + if (RDF_ACCEPT_HINTS.some((t) => lower.includes(t))) return CANONICAL_RDF_ACCEPT; + return accept.split(',')[0]?.trim() ?? CANONICAL_RDF_ACCEPT; +} + +/** + * The variant discriminator for a read. The shared replica normalises every read to the + * canonical Accept (Phase 1 caches RDF + opaque bytes by request Accept); a richer + * `Vary`-driven key is a later refinement. Deliberately SMALL + deterministic. + */ +export function computeVaryKey(accept: string | null | undefined): string { + return `accept=${canonicalAccept(accept)}`; +} + +/** + * The identity tuple that scopes a replica entry. BOTH fields are part of the security + * boundary (see the module note): `webId` prevents cross-USER leakage; `grantScope` (the + * verified, granted requesting origin) prevents a non-granted origin reaching a shared body. + */ +export interface ReplicaScope { + /** The active session's WebID. `null` ⇒ no session ⇒ no replica entry may be keyed. */ + readonly webId: string | null; + /** The verified+granted requesting origin (Phase-0 gate output). `null` ⇒ DENY. */ + readonly grantScope: string | null; +} + +/** + * The delimiter joining the key's four components: a NUL byte (`U+0000`). A NUL can NEVER + * appear in a WebID / origin / http(s) URL / the canonical-Accept varyKey (all are printable + * once the URL parser has normalised them), so the join is UNAMBIGUOUS and INJECTIVE across + * the whole tuple — no value can bleed from one component into another to forge a colliding + * key, even for an adversary who can influence a component's contents. This is STRICTLY + * STRONGER than the `${url} ${varyKey}` space join `@jeswr/solid-offline` uses (which is safe + * only because its inputs never contain a raw space); here the key IS a security boundary, so + * we pick a separator that is impossible in any input. + */ +const KEY_DELIMITER = '\u0000'; + +/** + * The composite primary key string for the metadata store: the four security+coherence + * dimensions, space-delimited. Distinct tuples ALWAYS produce distinct keys (injective — see + * {@link KEY_DELIMITER}). + * + * Returns `null` (un-keyable ⇒ the caller must NOT cache) when either security dimension is + * absent — fail-closed: an entry can never be written/read without a WebID AND a grant-scope. + */ +export function computeReplicaKey( + scope: ReplicaScope, + resourceUrl: string, + varyKey: string, +): string | null { + if (scope.webId === null || scope.grantScope === null) return null; + return [scope.webId, scope.grantScope, resourceUrl, varyKey].join(KEY_DELIMITER); +} + +/** + * The CANONICAL synthetic Cache-API key Request for a replica entry. The full composite key + * (which embeds WebID + grant-scope) is percent-encoded into ONE opaque path segment on a + * sentinel origin — injective, and nothing the server ever returns can collide with it. The + * byte cache and the metadata store are thus keyed 1:1 on the SAME security-scoped identity. + * + * Returns `null` when the entry is un-keyable (no WebID / no grant-scope) — fail-closed. + */ +export function replicaKeyRequest( + scope: ReplicaScope, + resourceUrl: string, + varyKey: string, +): Request | null { + const key = computeReplicaKey(scope, resourceUrl, varyKey); + if (key === null) return null; + return new Request(`${REPLICA_KEY_ORIGIN}${encodeURIComponent(key)}`, { method: 'GET' }); +} + +/** + * Whether a replica byte-cache key belongs to `webId`. The WebID is the key's first component, + * so a purge can find bytes whose metadata row was lost (a crash between the byte write and the + * metadata write, or a failed byte delete after the row was dropped). + */ +export function replicaKeyBelongsTo(request: Request, webId: string): boolean { + if (!request.url.startsWith(REPLICA_KEY_ORIGIN)) return false; + try { + const key = decodeURIComponent(request.url.slice(REPLICA_KEY_ORIGIN.length)); + return key.startsWith(`${webId}${KEY_DELIMITER}`); + } catch { + return false; + } +} diff --git a/apps/extension/src/background/core/replica.ts b/apps/extension/src/background/core/replica.ts new file mode 100644 index 000000000..5ff7a1477 --- /dev/null +++ b/apps/extension/src/background/core/replica.ts @@ -0,0 +1,517 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The single, extension-owned SHARED REPLICA (design §2, §5.2). + * + * ONE replica per `(WebID, pod-origin)` in the EXTENSION's own storage (NOT partitioned by + * the visited app's site, unlike a page service worker): bytes → Cache API, metadata → + * IndexedDB (replica-db.ts). Both are keyed on the SECURITY-SCOPED synthetic key + * (replica-key.ts) so the cache key itself is the cross-user / cross-grant boundary. + * + * Consistency = read-from-cache + ALWAYS-revalidate, never-authoritative + * stale-while-revalidate (the `@jeswr/solid-offline` `swr.ts` discipline, the PSS "cache is + * never authoritative" invariant): a cached body is provisional; a conditional + * `If-None-Match` GET confirms (304) or replaces (200) it. Writes are SYNCHRONOUS + * WRITE-THROUGH (design §2.3, Phase-1 is write-through NOT optimistic): the pod is written + * first with `If-Match`, and only a 2xx updates the shared replica from the POD's response. + * + * ──────────────────────────────────────────────────────────────────────────── + * THE TWO LOAD-BEARING SECURITY PROPERTIES this module structurally enforces: + * + * (1) GATE-ON-EVERY-READ, INCLUDING A CACHE HIT (design §5.4 point 3). The gate is an + * INJECTED predicate that this module calls as the FIRST thing on read AND on write — + * there is no code path that returns a cached body without first awaiting `gate()`. A + * cache HIT is gated identically to an egress, so a body landed in the shared replica + * by one granted origin can never be served to a non-granted origin off a warm cache. + * + * (2) SECURITY-SCOPED KEY. Every Cache/metadata access goes through `replicaKeyRequest` / + * `computeReplicaKey`, which embed the WebID + grant-scope. A read for (webId=B) can + * never `match()` bytes written for (webId=A) even at the same URL+ETag. + * ──────────────────────────────────────────────────────────────────────────── + * + * Pure I/O ports are injected (the gate, the authenticated egress, the Cache, the DB) so the + * whole orchestration is unit-testable without a packed extension or a live pod. + */ + +import { type PinnedTarget, targetsCover } from './class-grant'; +import type { ReplicaMetadata } from './replica-db'; +import { + CANONICAL_RDF_ACCEPT, + computeReplicaKey, + computeVaryKey, + type ReplicaScope, + replicaKeyBelongsTo, + replicaKeyRequest, +} from './replica-key'; + +/** + * The metadata-store surface the replica depends on (dependency inversion so the + * security-critical orchestration is unit-testable with an in-memory double, while the live + * SW passes the IndexedDB-backed `ReplicaDb`). A subset of `ReplicaDb`'s methods. + */ +export interface ReplicaMetaStore { + getMetadata(key: string): Promise; + putMetadata(record: ReplicaMetadata): Promise; + deleteMetadata(key: string): Promise; + getMetadataByWebId(webId: string): Promise; + deleteMetadataByWebId(webId: string): Promise; + clearMetadata(): Promise; + clearNonces(): Promise; +} + +/** A served replica result — a real `Response` plus whether it came from the warm cache. */ +export interface ReplicaResult { + readonly response: Response; + readonly fromCache: boolean; + /** The freshest server DPoP nonce observed on an egress (cache the next call), if any. */ + readonly nonce?: string; +} + +/** The authenticated-egress port — the SW's sole DPoP egress (authenticated-fetch.ts). */ +export interface EgressPort { + /** + * Perform the authenticated egress for a request. `extraHeaders` carries the conditional + * (`If-None-Match` / `If-Match`) header the replica adds. Returns the live Response + the + * freshest server nonce. + */ + fetch( + url: string, + method: string, + headers: Record, + body: string | null, + nonce: string | undefined, + ): Promise<{ response: Response; nonce?: string }>; +} + +/** + * The injected, fail-closed access gate — re-run as the FIRST step on EVERY read and write + * (the gate-on-cache-hit invariant). Resolves to the verified grant-scope (the granted + * requesting origin) on ALLOW, or an error Response on DENY. The replica NEVER touches the + * cache or the pod before this resolves to ALLOW. + */ +export type ReplicaGate = () => Promise<{ grantScope: string } | { deny: Response }>; + +/** The minimal Cache-API surface the replica uses (the SW's own named cache). */ +export interface ReplicaByteCache { + match(request: Request, options?: CacheQueryOptions): Promise; + put(request: Request, response: Response): Promise; + delete(request: Request, options?: CacheQueryOptions): Promise; + /** + * Enumerate every cached byte-entry key. Used by the null-WebID logout purge (MEDIUM #4) to + * delete ALL replica bytes when the prior identity is unknown — without it, that purge clears + * metadata but LEAKS the CacheStorage bytes (a privacy hole on an unknown-prior logout/reset). + * The live Cache API provides `keys()`; the SW's named replica cache holds ONLY replica + * entries, so every key it returns is a replica byte entry safe to drop. + */ + keys(request?: Request, options?: CacheQueryOptions): Promise; +} + +/** + * We key the byte cache on our own synthetic canonical Request, so a stored response's own + * `Vary` must not cause the Cache API to re-apply header matching. Pass `ignoreVary` on every + * match/delete (the solid-offline `IGNORE_VARY` discipline). + */ +const IGNORE_VARY: CacheQueryOptions = { ignoreVary: true }; + +/** Build the request headers the egress should carry, with the conditional header merged in. */ +function withConditional( + headers: Record, + conditional: { name: string; value: string } | null, +): Record { + if (conditional === null) return headers; + return { ...headers, [conditional.name]: conditional.value }; +} + +/** + * Reconstruct a servable Response from cached bytes + the stored metadata's status/headers. + * `bodyless` (a HEAD request) drops the body so a HEAD response carries NO entity body (Low #5) + * — the cache stores the GET representation, but a HEAD must reply headers/status only. + */ +function responseFromCache(bytes: Response, meta: ReplicaMetadata, bodyless: boolean): Response { + const headers = new Headers(bytes.headers); + if (meta.etag && !headers.has('etag')) headers.set('etag', meta.etag); + if (meta.contentType && !headers.has('content-type')) + headers.set('content-type', meta.contentType); + return new Response(bodyless ? null : bytes.body, { status: meta.status, headers }); +} + +/** + * Strip the body from a live response for a HEAD request (Low #5). A HEAD response must have + * the same headers/status as the GET would, but NO entity body. A pod SHOULD already omit the + * body on HEAD; this enforces it regardless of what the egress returned. + */ +function bodylessForHead(response: Response): Response { + return new Response(null, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); +} + +export interface ReplicaConfig { + readonly gate: ReplicaGate; + readonly egress: EgressPort; + readonly cache: ReplicaByteCache; + readonly db: ReplicaMetaStore; + /** The active session's WebID (the security key dimension). */ + readonly webId: string | null; + /** Guard/serialize local mutations against lifecycle purges. Egress must return buffered bodies. */ + readonly commit?: (task: () => Promise) => Promise; +} + +/** + * The shared replica. Constructed per-request from the SW with the active session's WebID + + * the injected ports; cheap (the ports are re-derivable on every wake). + */ +export class SharedReplica { + constructor(private readonly cfg: ReplicaConfig) {} + + private commit(task: () => Promise): Promise { + return this.cfg.commit ? this.cfg.commit(task) : task(); + } + + /** + * Read a resource through the replica (GET/HEAD): gate FIRST (incl. on a cache hit), then + * serve-from-cache + revalidate (never-authoritative SWR). Returns the gate's DENY Response + * untouched when access is refused — without touching the cache or the pod. + */ + async read( + url: string, + method: string, + headers: Record, + nonce: string | undefined, + ): Promise { + // (1) GATE FIRST — applies to a cache HIT exactly as to an egress. No cached body is ever + // returned before this resolves to ALLOW. + const decision = await this.cfg.gate(); + if ('deny' in decision) return { deny: decision.deny }; + + // Low #5: a HEAD response must carry NO body — neither the cached GET body (304 path) nor a + // pod's stray HEAD body. Track it so every served path strips the body for HEAD. + const isHead = method.toUpperCase() === 'HEAD'; + + const scope: ReplicaScope = { webId: this.cfg.webId, grantScope: decision.grantScope }; + const varyKey = computeVaryKey(headers.Accept ?? headers.accept); + const keyReq = replicaKeyRequest(scope, url, varyKey); + const metaKey = computeReplicaKey(scope, url, varyKey); + + // No WebID / no grant-scope ⇒ un-keyable ⇒ never cache: a plain authenticated egress. + if (keyReq === null || metaKey === null) { + const { response, nonce: n } = await this.cfg.egress.fetch(url, method, headers, null, nonce); + return { + response: isHead ? bodylessForHead(response) : response, + fromCache: false, + nonce: n, + }; + } + + const cachedBytes = await this.cfg.cache.match(keyReq, IGNORE_VARY); + const meta = await this.cfg.db.getMetadata(metaKey); + + // Conditional revalidation: carry the cached ETag so a 304 confirms the provisional body. + const conditional = + cachedBytes && meta?.etag ? { name: 'If-None-Match', value: meta.etag } : null; + const reqHeaders = withConditional(headers, conditional); + + const { response, nonce: serverNonce } = await this.cfg.egress.fetch( + url, + method, + reqHeaders, + null, + nonce, + ); + + // 304 Not Modified: the provisional cached body is confirmed fresh — serve it, touch it. + // For a HEAD, serve the headers/status only (no cached body — Low #5). + if (response.status === 304 && cachedBytes && meta) { + await this.commit(() => this.cfg.db.putMetadata({ ...meta, fetchedAt: Date.now() })); + return { + response: responseFromCache(cachedBytes.clone(), meta, isHead), + fromCache: true, + nonce: serverNonce, + }; + } + + // 2xx: replace the shared replica bytes + metadata FROM the pod's response (never-stale). + // Only a GET has a body to cache; a HEAD never reaches store() (so no body is persisted). + if (response.status >= 200 && response.status < 300 && method.toUpperCase() === 'GET') { + const stored = await this.store(scope, url, varyKey, metaKey, keyReq, response); + return { response: stored, fromCache: false, nonce: serverNonce }; + } + + // Any other status (incl. a now-403/404 — access changed, or not-found): do NOT serve a + // stale cached body; surface the live response. Drop any now-invalid cached entry. A HEAD's + // live response is body-stripped (Low #5). + if (cachedBytes || meta) { + await this.invalidate(metaKey, keyReq); + } + return { + response: isHead ? bodylessForHead(response) : response, + fromCache: false, + nonce: serverNonce, + }; + } + + /** + * Write a resource through the replica (PUT/POST/PATCH/DELETE): gate FIRST, then SYNCHRONOUS + * WRITE-THROUGH — the pod is written with `If-Match` (the cross-app lost-update guard when + * the caller supplied none), and only a 2xx updates the shared replica FROM the pod's + * response. A 412 / 5xx is surfaced verbatim (no silent queue in Phase 1). + */ + async write( + url: string, + method: string, + headers: Record, + body: string | null, + nonce: string | undefined, + ): Promise { + const decision = await this.cfg.gate(); + if ('deny' in decision) return { deny: decision.deny }; + + const scope: ReplicaScope = { webId: this.cfg.webId, grantScope: decision.grantScope }; + const varyKey = computeVaryKey(CANONICAL_RDF_ACCEPT); + const keyReq = replicaKeyRequest(scope, url, varyKey); + const metaKey = computeReplicaKey(scope, url, varyKey); + + // Lost-update guard: if the caller supplied no precondition AND we hold a cached ETag, + // attach `If-Match` so App B cannot silently clobber App A's confirmed write. + let reqHeaders = headers; + const hasPrecondition = + 'if-match' in lowerKeys(headers) || 'if-none-match' in lowerKeys(headers); + if (!hasPrecondition && metaKey !== null) { + const meta = await this.cfg.db.getMetadata(metaKey); + if (meta?.etag) reqHeaders = withConditional(headers, { name: 'If-Match', value: meta.etag }); + } + + const { response, nonce: serverNonce } = await this.cfg.egress.fetch( + url, + method, + reqHeaders, + body, + nonce, + ); + + // On a 2xx write, the cached copy is stale. A DELETE drops the entry; a PUT/PATCH/POST + // re-fetches lazily on the next read (we do not trust the write response body as the new + // GET representation). Either way invalidate the shared replica entry for this URL. + if (response.status >= 200 && response.status < 300 && keyReq !== null && metaKey !== null) { + await this.invalidate(metaKey, keyReq); + } + + return { response, fromCache: false, nonce: serverNonce }; + } + + /** + * Store a 2xx GET response's bytes + metadata under the security-scoped key. + * + * MEDIUM #3 — caching is BEST-EFFORT, never load-bearing for the request. The Cache API + * REJECTS `put()` for some valid 2xx responses (a `206 Partial Content`, a `Vary: *` + * response — see the Cache API spec). The replica is "never authoritative" (the bytes are + * provisional), so a failed persist must NEVER fail the request: we clone the live response + * UP FRONT, attempt the persist, and on ANY failure (cache.put OR the metadata write) skip + * persistence and return the LIVE response unchanged. Worst case the next read is a cold + * miss — correct, just un-cached. + */ + private async store( + scope: ReplicaScope, + url: string, + varyKey: string, + metaKey: string, + keyReq: Request, + response: Response, + ): Promise { + // Clone BEFORE any await so the caller's live body is preserved even if the persist throws. + const toStore = response.clone(); + await this.commit(async () => { + try { + // Store the clone UN-mutated under the key. + await this.cfg.cache.put(keyReq, toStore); + const record: ReplicaMetadata = { + key: metaKey, + webId: scope.webId as string, + grantScope: scope.grantScope as string, + resourceUrl: url, + varyKey, + etag: response.headers.get('etag') ?? undefined, + contentType: response.headers.get('content-type') ?? undefined, + status: response.status, + fetchedAt: Date.now(), + }; + await this.cfg.db.putMetadata(record); + } catch { + // Non-cacheable 2xx (206 / Vary:* / quota) — skip persistence, serve the live response. + // Best-effort defence: if the bytes landed but the metadata write failed, the orphaned + // byte entry would be un-keyable on a later read (no metadata ⇒ no conditional ⇒ treated + // as a cold miss + overwritten), so leaving it is harmless; we still try to drop it. + await this.cfg.cache.delete(keyReq, IGNORE_VARY).catch(() => false); + } + }); + return response; + } + + /** + * Drop one entry's bytes + metadata (a no-leak partial-write sweep). The metadata row is kept + * when the byte delete fails, so later purges still find those bytes by metadata. + */ + private async invalidate(metaKey: string, keyReq: Request): Promise { + await this.commit(async () => { + try { + await this.cfg.cache.delete(keyReq, IGNORE_VARY); + } catch { + return; + } + await this.cfg.db.deleteMetadata(metaKey).catch(() => {}); + }); + } +} + +/** Lower-cased header-name set for case-insensitive precondition detection. */ +function lowerKeys(headers: Record): Record { + const out: Record = {}; + for (const k of Object.keys(headers)) out[k.toLowerCase()] = true; + return out; +} + +/** + * SYNCHRONOUS-BEFORE-SERVE logout purge (design §4.3 "Cross-USER stale bytes", §5.4 point 5): + * delete the prior identity's replica bytes + metadata + cached nonces BEFORE any new + * session's read can be served. The SW awaits this in its logout handler, so a returning / + * different user never reads a single departed-identity byte (the WebID-in-key makes a + * cross-user collision impossible, and this purge removes the prior rows entirely). + * + * `webId === null` (a logout where the prior WebID is unknown / a full reset) drops EVERYTHING + * — fail-closed: when in doubt, purge all, never leave a row behind. + * + * MEDIUM #4 — the null-WebID branch must delete the CacheStorage BYTES too, not just metadata. + * Previously it cleared metadata only (no byte enumeration), so an unknown-prior logout/reset + * LEFT replica byte entries in CacheStorage — a privacy leak. It now enumerates every replica + * byte key (via the injected `enumerateCacheKeys`, defaulting to `cache.keys()` — the SW's + * named replica cache holds only replica entries) and deletes each, so bytes AND metadata AND + * nonces are all gone after a null purge. + */ +export async function purgeReplica( + cache: ReplicaByteCache, + db: ReplicaMetaStore, + webId: string | null, + /** Test seam: enumerate every replica byte key. Defaults to the live `cache.keys()`. */ + enumerateCacheKeys: () => Promise = () => cache.keys(), +): Promise<{ bytesDropped: number; metaDropped: number }> { + let metaDropped = 0; + let bytesDropped = 0; + + if (webId !== null) { + // Drop exactly this identity's bytes before deleting any retry metadata. Propagate + // deletion failures so ProfileStore keeps its removal marker for the next wake. + const rows = await db.getMetadataByWebId(webId); + for (const row of rows) { + const keyReq = new Request( + // Reconstruct the exact synthetic key from the stored scope (injective — see replica-key.ts). + replicaKeyUrlFor(row), + ); + if (await cache.delete(keyReq, IGNORE_VARY)) bytesDropped++; + } + // Then sweep this identity's byte keys that no metadata row indexes any more. + for (const keyReq of await enumerateCacheKeys()) { + if (replicaKeyBelongsTo(keyReq, webId) && (await cache.delete(keyReq, IGNORE_VARY))) + bytesDropped++; + } + await db.deleteMetadataByWebId(webId); + metaDropped = rows.length; + } else { + // Unknown prior identity → purge ALL replica state (fail-closed, no row survives). Delete + // every cached byte entry by enumerating the cache keys, THEN clear the metadata. + const keys = await enumerateCacheKeys().catch(() => [] as readonly Request[]); + for (const keyReq of keys) { + if (await cache.delete(keyReq, IGNORE_VARY).catch(() => false)) bytesDropped++; + } + await db.clearMetadata(); + } + + // Nonces are always cleared on logout (a new identity must never reuse a prior nonce). + await db.clearNonces(); + return { bytesDropped, metaDropped }; +} + +/** + * PER-DATA-CLASS REPLICA-SLICE PURGE (access-management Phase 2, residual b): when a class grant + * is REVOKED or EXPIRES, synchronously drop exactly the replicated bytes + metadata for the + * resources that grant resolved to — leaving every OTHER class's (and every other grant-scope's) + * data resident. Closes the data-retention gap where a revoked class's bytes lingered in the + * extension's replica until logout. + * + * The slice is `(webId, grantScope, targets)`: + * - `webId` scopes to the identity the grant was minted under (never touches another user's rows); + * - `grantScope` is the requesting-app origin the bytes were cached under (`ReplicaScope.grantScope` + * === the class-only origin, see `gateAccess`), so another origin's replica is untouched; + * - `targets` are the grant's PINNED resolved targets; only a row whose `resourceUrl` falls WITHIN + * one of them (`targetsCover`, the same fail-closed containment test the gate uses) is dropped. + * - `retainTargets` (optional) are the targets of the OTHER live grants the same `(webId, + * grantScope)` still holds; a row still covered by one of them is KEPT even if it also falls in + * the revoked slice, so revoking ONE of two overlapping grants never evicts data the surviving + * grant still authorises (it would otherwise force a needless re-fetch). Precise purge. + * + * FAIL-CLOSED CONTRACT (the caller's, not this function's): the grant's REMOVAL from the store is + * the authoritative deny and MUST happen before this purge, so a purge failure can NEVER leave + * access live — it degrades only to "bytes linger until logout" (the prior state), with access + * already revoked. This function is therefore BEST-EFFORT-THOROUGH: it purges as much of the slice + * as it can, tolerating a per-row failure (so one unremovable row cannot abort the rest of the + * slice), and returns the counts dropped. A row whose byte delete fails keeps its metadata, so the + * logout/profile purge can still find and drop those bytes. An empty target set covers nothing → a + * no-op. + */ +export async function purgeReplicaSlice( + cache: ReplicaByteCache, + db: ReplicaMetaStore, + slice: { + webId: string; + grantScope: string; + targets: readonly PinnedTarget[]; + retainTargets?: readonly PinnedTarget[]; + }, +): Promise<{ bytesDropped: number; metaDropped: number }> { + let bytesDropped = 0; + let metaDropped = 0; + // An empty / unresolved target set authorises nothing and therefore replicated nothing → no-op. + if (slice.targets.length === 0) return { bytesDropped, metaDropped }; + const retain = slice.retainTargets ?? []; + + const rows = await db.getMetadataByWebId(slice.webId); + for (const row of rows) { + // Another grant-scope's (app origin's) partition — never touched by this class's revoke. + if (row.grantScope !== slice.grantScope) continue; + // A resource outside this grant's pinned targets belongs to another class — left intact. + if (!targetsCover(slice.targets, row.resourceUrl)) continue; + // A resource STILL covered by another live grant of the same (webId, grantScope) is retained — + // revoking one of two overlapping grants must not evict what the survivor still authorises. + if (retain.length > 0 && targetsCover(retain, row.resourceUrl)) continue; + // Best-effort per row: one failure must not abort the rest of the slice. The grant is already + // removed by the caller (the fail-closed deny), so a residual is never live access. The + // metadata row is how the logout/profile purge finds the bytes, so it is dropped only after + // the byte delete succeeded; otherwise the bytes would survive every later purge. + const keyReq = new Request(replicaKeyUrlFor(row)); + let deleted: boolean; + try { + deleted = await cache.delete(keyReq, IGNORE_VARY); + } catch { + continue; + } + if (deleted) bytesDropped++; + try { + await db.deleteMetadata(row.key); + metaDropped++; + } catch { + // leave the now grant-less row for the logout purge. + } + } + return { bytesDropped, metaDropped }; +} + +/** Reconstruct the synthetic Cache key URL for a stored metadata row (the byte half of purge). */ +function replicaKeyUrlFor(row: ReplicaMetadata): string { + const keyReq = replicaKeyRequest( + { webId: row.webId, grantScope: row.grantScope }, + row.resourceUrl, + row.varyKey, + ); + // A stored row always has both scope dimensions, so this is never null; assert for the type. + return (keyReq as Request).url; +} diff --git a/apps/extension/src/background/core/request-access-throttle.ts b/apps/extension/src/background/core/request-access-throttle.ts new file mode 100644 index 000000000..d21caf1d8 --- /dev/null +++ b/apps/extension/src/background/core/request-access-throttle.ts @@ -0,0 +1,109 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * PURE anti-spam gate for `window.solid.requestAccess` (access-management Phase 2, residual c). + * + * `requestAccess` opens the UNSPOOFABLE consent WINDOW (extension chrome). Without a throttle a + * hostile — or merely buggy — origin can drive that window in a loop (`for(;;) requestAccess()`), + * an annoyance/DoS on the owner. This module is the PURE decision behind two independent defences, + * both fail-closed: + * + * (i) USER-GESTURE REQUIREMENT — a `requestAccess` must originate from a genuine transient user + * activation (a click / keypress), never an autonomous script loop. The activation is read + * in the ISOLATED content-script world (`navigator.userActivation.isActive`), which the page + * cannot forge, and stamped onto the worker message; THIS function only decides on that + * browser-attested boolean. A non-activated call is rejected and does NOT consume the + * rate-limit quota (the quota governs gesture-BACKED bursts; the gesture check alone already + * stops a no-gesture loop). + * + * (ii) PER-ORIGIN RATE LIMIT — a fixed-window cap on how many prompts one origin may open in a + * window, so even a gesture-backed burst (many calls within one 5s activation window) is + * throttled. The origin key is the browser-attested requesting origin (unspoofable), so an + * origin cannot evade the limit by lying about who it is. + * + * NO `chrome.*` / storage / clock here — the caller supplies `now` and the prior per-origin state, + * so the whole decision is exhaustively unit-testable. The service worker holds the per-origin + * state in a module-level map (best-effort, MV3-appropriate: an SW restart resets it, which merely + * refreshes the window — acceptable for an annoyance/DoS mitigation, never a security boundary). + */ + +/** A distinguishable reason a `requestAccess` was throttled, surfaced to the page. */ +export type RequestAccessDenyReason = 'user-gesture-required' | 'rate-limited'; + +/** Per-origin fixed-window state: the window's start and how many prompts it has admitted. */ +export interface ThrottleState { + readonly windowStart: number; + readonly count: number; +} + +/** The throttle bounds. */ +export interface ThrottleOptions { + /** The fixed-window length, ms. */ + readonly windowMs: number; + /** The maximum admitted `requestAccess` prompts per origin per window. */ + readonly maxPerWindow: number; +} + +/** + * Default bounds: at most 3 consent prompts per origin per minute. A legitimate app rarely needs + * more than one or two consent prompts in quick succession; a loop is far over this. + */ +export const DEFAULT_REQUEST_ACCESS_THROTTLE: ThrottleOptions = { + windowMs: 60_000, + maxPerWindow: 3, +}; + +/** The decision + the state the caller must persist for this origin. */ +export interface ThrottleDecision { + /** `true` ⇒ proceed to consent; `false` ⇒ reject with {@link reason}. */ + readonly allow: boolean; + /** The deny reason when `allow` is false. */ + readonly reason?: RequestAccessDenyReason; + /** For a rate-limited deny, how long until the window rolls (ms). */ + readonly retryAfterMs?: number; + /** The state to store back for this origin (always returned, whether allowed or not). */ + readonly nextState: ThrottleState; +} + +/** + * Decide whether an origin's `requestAccess` may proceed at `now`, given its prior throttle state + * and whether the call carried a genuine user activation. PURE + fail-closed. + * + * Order (both fail-closed): + * 1. Roll the fixed window forward if the prior one has fully elapsed (or there is none). + * 2. GESTURE FIRST — no user activation ⇒ deny `user-gesture-required`, quota UNCHANGED. + * 3. RATE LIMIT — at/over `maxPerWindow` for the current window ⇒ deny `rate-limited`. + * 4. Otherwise allow, incrementing the window's admitted count. + */ +export function decideRequestAccess( + prior: ThrottleState | undefined, + now: number, + userActivated: boolean, + opts: ThrottleOptions = DEFAULT_REQUEST_ACCESS_THROTTLE, +): ThrottleDecision { + // (1) Roll the window if the prior one has elapsed (or there is none / it is in the future). + const elapsed = prior ? now - prior.windowStart : Number.POSITIVE_INFINITY; + const window: ThrottleState = + prior && elapsed >= 0 && elapsed < opts.windowMs ? prior : { windowStart: now, count: 0 }; + + // (2) Gesture requirement. Rejected WITHOUT consuming quota — a no-gesture loop is stopped here + // regardless of the rate limit; the quota is reserved for gesture-backed bursts. + if (!userActivated) { + return { allow: false, reason: 'user-gesture-required', nextState: window }; + } + + // (3) Fixed-window rate limit on gesture-backed prompts. + if (window.count >= opts.maxPerWindow) { + return { + allow: false, + reason: 'rate-limited', + retryAfterMs: Math.max(0, window.windowStart + opts.windowMs - now), + nextState: window, + }; + } + + // (4) Admit — advance the current window's count. + return { + allow: true, + nextState: { windowStart: window.windowStart, count: window.count + 1 }, + }; +} diff --git a/apps/extension/src/background/core/requesting-origin.ts b/apps/extension/src/background/core/requesting-origin.ts new file mode 100644 index 000000000..1214dd896 --- /dev/null +++ b/apps/extension/src/background/core/requesting-origin.ts @@ -0,0 +1,245 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The per-REQUESTING-origin access gate — the browser-side credential boundary that decides + * WHICH page (app) origin is allowed to drive `window.solid.fetch`, before any pod egress + * OR any cache hit is served. + * + * This is distinct from `origin-policy.ts`, which gates the TARGET pod ("don't send the + * token to evil.com"). THIS module gates the REQUESTER ("is the app at https://a.example + * allowed to read/write this pod at all?"). Both run on every fetch; this one runs FIRST. + * + * THREAT MODEL (design §4.3 "Forge the access-control anchor"): + * - `sender.origin` is the browser-ATTESTED origin of the content script that relayed the + * message. It is set by Chrome, not the page, so it cannot be forged from page JS. + * - The content script ALSO stamps `window.location.origin` (the page-supplied value). + * A compromised renderer could lie about this, so it is NEVER trusted alone. + * - Therefore we require BOTH anchors to AGREE and use the browser-attested value as the + * authority. A forged stamp only ever DENIES (mismatch) — it can never widen access. + * + * FAIL-CLOSED everywhere: + * - An opaque / `null` / missing origin (sandboxed `srcdoc` iframe, `about:blank`, + * `data:`, `file:`, `javascript:`) → DENY. We never bucket these into a shared "null" + * grant. (A `blob:` URL is NOT opaque — it inherits its creator's real origin, which the + * browser fixes and a page cannot forge across origins, so it resolves normally.) + * - A `sender` with no `origin` but a usable `url` → we derive the origin from the URL and + * STILL require it to match the stamp; an underivable origin → DENY. + * - Default-deny: an origin that is not in the granted set → DENY, without touching the + * pod or the cache. + */ + +/** Parse a string to its URL `origin`, or `null` if it is not a usable URL. */ +function originOf(value: string | null | undefined): string | null { + if (!value) return null; + try { + const o = new URL(value).origin; + // An opaque origin parses to the literal string "null" (`about:blank`, `data:`, `file:`, + // `javascript:`); a sandboxed `srcdoc` frame reports "null" too (which then throws here). + // Treat the opaque sentinel as no-origin (fail-closed), never a shared bucket. + if (o === 'null' || o === '') return null; + // Only http(s) origins are usable requester anchors. A non-http(s) scheme (e.g. `ftp:`, + // `ws:`, a custom protocol) is treated as no-origin (fail-closed) so it can never be a + // trusted requester. + if (!o.startsWith('https://') && !o.startsWith('http://')) return null; + return o; + } catch { + return null; + } +} + +/** + * Distinguish a sender.origin that is genuinely ABSENT (undefined / null / empty string — Chrome + * omits `origin` for some sender types) from one that is PRESENT-but-OPAQUE. Chrome sets + * `sender.origin` to the literal string `"null"` for sandboxed / opaque frames; a present opaque + * value must DENY immediately (never fall back to `sender.url`), so that an opaque sender with a + * normal http(s) `sender.url` cannot be laundered into a trusted origin. Absent → caller may + * derive from `sender.url`. + * + * Returns `'absent'`, `'opaque'`, or the resolved (usable, http(s)) origin string. + */ +function classifySenderOrigin(origin: string | null | undefined): 'absent' | 'opaque' | string { + if (origin === undefined || origin === null || origin === '') return 'absent'; + const resolved = originOf(origin); + if (resolved === null) return 'opaque'; // present but opaque / unparseable / non-http(s) → DENY + return resolved; +} + +/** + * The browser-attested sender identity, as Chrome populates it on a `chrome.runtime` + * message. `origin` is present for content-script senders on a real http(s) page; `url` is + * the document URL. Both can be absent for an opaque context. We treat this object as the + * SOLE authority for the requesting origin. + */ +export interface MessageSender { + readonly origin?: string; + readonly url?: string; +} + +/** + * Resolve the TRUE requesting origin from the browser-attested `sender` and the + * page-supplied stamp, requiring them to AGREE. Returns the canonical origin string when + * both agree on a usable (non-opaque) origin, else `null` (DENY). + * + * Authority order: + * 1. The browser-attested origin is `sender.origin`. We fall back to the origin OF + * `sender.url` ONLY when `sender.origin` is genuinely ABSENT (Chrome omits `origin` for + * some sender types but always has `url`). If `sender.origin` is PRESENT and OPAQUE + * (Chrome stamps the literal string `"null"` for sandboxed / opaque frames), we DENY + * IMMEDIATELY and do NOT consult `sender.url` — otherwise an opaque sender with a normal + * http(s) `sender.url` would be laundered into a trusted origin (the opaque-boundary + * fail-OPEN). This value is set by the browser and cannot be forged by page JS. + * 2. The page-supplied `stampedOrigin` is the value the content script read from + * `window.location.origin` and forwarded. It is advisory only. + * 3. BOTH must resolve to the SAME non-opaque origin. Any mismatch, or either being + * opaque/absent, → `null`. + * + * PURE so the boundary is exhaustively unit-tested. + */ +export function resolveRequestingOrigin( + sender: MessageSender | null | undefined, + stampedOrigin: string | null | undefined, +): string | null { + // Browser-attested origin. CRITICAL fail-closed boundary: a PRESENT sender.origin is + // authoritative — if it is opaque ("null"/unparseable/non-http(s)) we DENY and must NOT fall + // back to sender.url (which could be a normal https URL for an opaque/sandboxed frame). Only a + // genuinely ABSENT sender.origin lets us derive the attested origin from sender.url. + const senderOriginClass = classifySenderOrigin(sender?.origin); + if (senderOriginClass === 'opaque') return null; // present but opaque → DENY, no url fallback. + const attested = senderOriginClass === 'absent' ? originOf(sender?.url) : senderOriginClass; + if (attested === null) return null; // absent origin AND no usable url → DENY. + + // The page-supplied stamp must be present and parse to a usable origin. + const stamped = originOf(stampedOrigin); + if (stamped === null) return null; // missing / opaque stamp → DENY. + + // Dual-origin agreement: the page-supplied value must match the browser truth EXACTLY. + if (attested !== stamped) return null; + + return attested; +} + +/** + * The default-deny per-requesting-origin gate. `granted` is the set of app origins the user + * has authorised to drive the extension. An origin not in the set is DENIED — no pod egress, + * no cache read. PURE; fail-closed on a `null` requesting origin or an empty granted set. + */ +export function isRequestingOriginGranted( + granted: ReadonlySet, + requestingOrigin: string | null, +): boolean { + if (requestingOrigin === null) return false; // opaque/unresolved → DENY. + return granted.has(requestingOrigin); +} + +export interface GrantedOriginInputs { + /** App origins the owner explicitly granted (login / setClientId / consent). */ + readonly explicitGrants?: readonly string[]; + /** The active session's credential origins (WebID/issuer/pod) — apps served from the */ + /** pod itself are owner-trusted requesters and need no separate grant. */ + readonly credentialOrigins?: ReadonlySet; +} + +/** + * The full set of REQUESTING origins permitted to drive the extension, for the single-user + * owner model: the explicit per-origin grants ∪ the session's credential (pod/WebID/issuer) + * origins. Opaque / non-http(s) candidates are dropped (fail-closed). PURE. + * + * An empty result (no session, no grants) means default-deny for EVERY origin. + */ +export function computeGrantedOrigins(inputs: GrantedOriginInputs): ReadonlySet { + const granted = new Set(); + for (const o of inputs.credentialOrigins ?? []) { + const c = originOf(o); + if (c) granted.add(c); + } + for (const g of inputs.explicitGrants ?? []) { + const c = originOf(g); + if (c) granted.add(c); + } + return granted; +} + +/** + * The same-origin-or-deny foreign-fetch policy (design §4.3 "Foreign-origin fetch ambient + * cookies"). A request whose TARGET is NOT one of the session's pod/credential origins (a + * "foreign" fetch — `window.solid.fetch('https://tracker/')`) is only permitted when its + * target origin equals the REQUESTING app's own origin. This stops the extension being used + * as a CSP-free, ambient-cookie-carrying proxy to arbitrary third parties: an app may reach + * its OWN origin through the proxy, but not weaponise it against unrelated hosts. + * + * A request to a credential (pod) origin is NOT foreign and is governed by the per-origin + * grant + the target-origin credential gate instead; this helper only decides foreign ones. + * + * PURE; fail-closed: an unresolved requesting/target origin → DENY. + */ +export function isForeignFetchAllowed(requestingOrigin: string | null, targetUrl: string): boolean { + if (requestingOrigin === null) return false; + const target = originOf(targetUrl); + if (target === null) return false; + return target === requestingOrigin; +} + +/** Why a request was denied (for an actionable error + auditability). `null` reason = allow. */ +export type DenyReason = + | 'forbidden-origin' // dual-origin disagreement / opaque / missing + | 'origin-not-granted' // default-deny: the app origin holds no grant + | 'cross-origin-foreign'; // a foreign-target fetch that isn't same-origin + +export interface GateDecision { + /** The resolved, verified requesting origin when allowed; `null` when denied. */ + readonly requestingOrigin: string | null; + /** `null` ⇒ ALLOW; otherwise the deny reason. */ + readonly deny: DenyReason | null; +} + +export interface GateInputs { + /** App origins the owner explicitly granted. */ + readonly explicitGrants?: readonly string[]; + /** The active session's credential (pod/WebID/issuer) origins. */ + readonly credentialOrigins?: ReadonlySet; +} + +/** + * The COMPLETE per-requesting-origin gate decision, as ONE pure function so the whole + * boundary is exhaustively unit-testable in isolation from `chrome.*`. Runs the three checks + * in fail-closed order: + * + * 1. Dual-origin agreement (browser-attested sender ∧ page stamp) — forged/opaque → DENY. + * 2. Default-deny per-origin grant (explicit grants ∪ session credential origins). + * 3. Same-origin-or-deny for a foreign (non-credential) target. + * + * The service worker calls this BEFORE any cache read or pod egress, on every fetch (and, in + * a later phase, on every cache hit), passing the storage-loaded grant inputs. + */ +export function decideRequestingOrigin( + sender: MessageSender | null | undefined, + stampedOrigin: string | null | undefined, + requestUrl: string, + inputs: GateInputs, +): GateDecision { + const requestingOrigin = resolveRequestingOrigin(sender, stampedOrigin); + if (requestingOrigin === null) return { requestingOrigin: null, deny: 'forbidden-origin' }; + + const granted = computeGrantedOrigins({ + explicitGrants: inputs.explicitGrants, + credentialOrigins: inputs.credentialOrigins, + }); + if (!isRequestingOriginGranted(granted, requestingOrigin)) { + return { requestingOrigin: null, deny: 'origin-not-granted' }; + } + + // A credential (pod) target is governed by the target-origin credential gate downstream; + // only a NON-credential ("foreign") target is subject to same-origin-or-deny here. + const targetIsCredential = isOriginAllowedSet(inputs.credentialOrigins, requestUrl); + if (!targetIsCredential && !isForeignFetchAllowed(requestingOrigin, requestUrl)) { + return { requestingOrigin: null, deny: 'cross-origin-foreign' }; + } + + return { requestingOrigin, deny: null }; +} + +/** Whether a request URL's origin is in a set (fail-closed on a missing set / bad URL). */ +function isOriginAllowedSet(allowed: ReadonlySet | undefined, requestUrl: string): boolean { + if (!allowed) return false; + const o = originOf(requestUrl); + return o !== null && allowed.has(o); +} diff --git a/apps/extension/src/background/core/response-headers.ts b/apps/extension/src/background/core/response-headers.ts new file mode 100644 index 000000000..9348222e8 --- /dev/null +++ b/apps/extension/src/background/core/response-headers.ts @@ -0,0 +1,75 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * Response-header allowlist (defense-in-depth) for the proxied `window.solid.fetch`. + * + * The service worker performs the authenticated fetch and relays the server's Response + * back to the page (worker -> content -> page). It is NOT a credential leak to forward + * response headers — the access token is a REQUEST header, and `Set-Cookie` is on the + * Forbidden-Response-Header list so it can never be read off the page-side `Response` + * anyway. But forwarding EVERY server response header is needlessly broad: it hands the + * page whatever the origin chose to emit (server fingerprints, internal infra headers, + * future auth/state headers we have not vetted). So we tighten the relay to an ALLOWLIST + * of the response headers a Solid app legitimately consumes, and drop the rest. + * + * The allowlist is the conservative Solid/LDP + standard HTTP read surface: content + * metadata, validators (ETag / Last-Modified) for conditional writes, the LDP/Solid `Link` + * relations, the `Accept-*` advertisement headers, WAC-Allow, the auth challenge, Range + * support, and the Solid-notifications discovery header. Anything not on the list is + * stripped before the Response is reconstructed page-side. + */ + +/** + * Response headers a Solid app legitimately needs to read. Lower-case (HTTP header names + * are case-insensitive; we normalise before the membership test). Keep this list tight — + * add a header only when an app genuinely needs to consume it. + */ +export const ALLOWED_RESPONSE_HEADERS: ReadonlySet = new Set([ + // Content metadata + 'content-type', + 'content-length', + 'content-language', + 'content-encoding', + 'content-disposition', + // Validators (needed for conditional writes: If-Match / If-None-Match round-trips) + 'etag', + 'last-modified', + // Range support + 'accept-ranges', + 'content-range', + // LDP / Solid relations + capability advertisement + 'link', + 'allow', + 'accept-patch', + 'accept-post', + 'accept-put', + // LDP `Prefer` round-trip: tells the client which representation preference was honored + // (e.g. container `return=representation` / omit-minimal — LDP §7.2, Solid Protocol) + 'preference-applied', + // Web Access Control: the effective access modes for the resource + 'wac-allow', + // Auth challenge (so the app can react to a 401 / re-auth) + 'www-authenticate', + // Redirect target + 'location', + // Solid Notifications: the subscription/discovery endpoint advertisement + 'updates-via', + // Standard caching / freshness metadata + 'cache-control', + 'expires', + 'age', + 'vary', + 'date', +]); + +/** + * Filter a server Response's headers down to the {@link ALLOWED_RESPONSE_HEADERS} allowlist, + * returning a plain `Record` (the wire shape the page<->worker message protocol uses). + * Header-name matching is case-insensitive; the returned keys preserve the server's casing. + */ +export function filterResponseHeaders(headers: Headers): Record { + const out: Record = {}; + headers.forEach((value, key) => { + if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) out[key] = value; + }); + return out; +} diff --git a/apps/extension/src/background/core/single-flight.ts b/apps/extension/src/background/core/single-flight.ts new file mode 100644 index 000000000..27ba49d06 --- /dev/null +++ b/apps/extension/src/background/core/single-flight.ts @@ -0,0 +1,29 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * A single-flight gate: collapse concurrent calls to an idempotent-but-not-safe-to-race + * async operation into ONE in-flight execution that every caller awaits. Used for the + * token refresh — a rotation-bound refresh token must be redeemed exactly once, or + * concurrent grants race (one wins, the others get invalid_grant) and a loser could tear + * down the freshly-rotated session. + */ +export class SingleFlight { + #inFlight: Promise | null = null; + + /** + * Run `op` if nothing is in flight; otherwise return the existing in-flight promise. The + * slot is released (whether `op` resolves OR rejects) so the next call starts fresh. + */ + run(op: () => Promise): Promise { + if (!this.#inFlight) { + this.#inFlight = op().finally(() => { + this.#inFlight = null; + }); + } + return this.#inFlight; + } + + /** Whether an operation is currently in flight. */ + get isInFlight(): boolean { + return this.#inFlight !== null; + } +} diff --git a/apps/extension/src/background/core/webid.ts b/apps/extension/src/background/core/webid.ts new file mode 100644 index 000000000..f21bc3540 --- /dev/null +++ b/apps/extension/src/background/core/webid.ts @@ -0,0 +1,130 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * WebID profile parsing — issuer resolution + display profile, via proper RDF (N3.js), + * never regex over Turtle. The house rule (suite-wide) is to parse RDF with N3/@solid + * libraries, not hand-rolled string matching; a profile can be in any Turtle shape + * (prefixed, full-IRI, multi-subject) that a regex would miss or mis-parse. + * + * `solid:oidcIssuer` (http://www.w3.org/ns/solid/terms#oidcIssuer) on the WebID subject + * is the OIDC issuer to log in against. A profile MAY advertise several; we return all so + * the caller can choose (single → use directly; many → the user picks; none → error). + */ + +import { DataFactory, Parser, Store } from 'n3'; + +const SOLID_OIDC_ISSUER = 'http://www.w3.org/ns/solid/terms#oidcIssuer'; +const FOAF_NAME = 'http://xmlns.com/foaf/0.1/name'; +const FOAF_IMG = 'http://xmlns.com/foaf/0.1/img'; +const VCARD_FN = 'http://www.w3.org/2006/vcard/ns#fn'; +const VCARD_PHOTO = 'http://www.w3.org/2006/vcard/ns#hasPhoto'; + +export interface WebIdProfile { + /** Every `solid:oidcIssuer` advertised on the WebID subject (deduped, in document order). */ + readonly issuers: string[]; + /** A human display name (foaf:name / vcard:fn), if any. */ + readonly name: string | null; + /** An avatar URL (foaf:img / vcard:hasPhoto), if any. */ + readonly photoUrl: string | null; +} + +/** Parse a WebID Turtle document into a {@link Store}, baselined to the document URL. */ +function parse(webId: string, turtle: string): Store { + const baseIRI = webId.split('#')[0]; + const parser = new Parser({ baseIRI }); + const store = new Store(); + store.addQuads(parser.parse(turtle)); + return store; +} + +function objectsFor(store: Store, subject: string, predicate: string): string[] { + const out: string[] = []; + for (const quad of store.getQuads( + DataFactory.namedNode(subject), + DataFactory.namedNode(predicate), + null, + null, + )) { + out.push(quad.object.value); + } + return out; +} + +/** Whether `value` is a parseable http(s) absolute URL (a usable issuer). */ +function isHttpUrl(value: string): boolean { + try { + const { protocol } = new URL(value); + return protocol === 'https:' || protocol === 'http:'; + } catch { + return false; + } +} + +/** + * The `solid:oidcIssuer` objects on the WebID subject, restricted to **NamedNode** terms + * whose value is an absolute URL. A literal or blank-node issuer object — or a relative / + * non-URL value — is not a usable issuer and is dropped, so malformed profiles can't feed + * an invalid issuer string into the login flow. + */ +function issuersFor(store: Store, subject: string): string[] { + const out: string[] = []; + for (const quad of store.getQuads( + DataFactory.namedNode(subject), + DataFactory.namedNode(SOLID_OIDC_ISSUER), + null, + null, + )) { + if (quad.object.termType === 'NamedNode' && isHttpUrl(quad.object.value)) { + out.push(quad.object.value); + } + } + return out; +} + +/** + * Extract the issuer list + display profile for `webId` from its Turtle profile. PURE + * (no fetch). The issuer list is the deduped, NamedNode-only, absolute-URL + * `solid:oidcIssuer` objects on the WebID subject; name/photo fall back foaf → vcard. + */ +export function parseWebIdProfile(webId: string, turtle: string): WebIdProfile { + const store = parse(webId, turtle); + + const issuers = [...new Set(issuersFor(store, webId))]; + + const name = + objectsFor(store, webId, FOAF_NAME)[0] ?? objectsFor(store, webId, VCARD_FN)[0] ?? null; + const photoUrl = + objectsFor(store, webId, FOAF_IMG)[0] ?? objectsFor(store, webId, VCARD_PHOTO)[0] ?? null; + + return { issuers, name, photoUrl }; +} + +export class NoIssuerError extends Error { + constructor(public readonly webId: string) { + super( + `No solid:oidcIssuer found in the WebID profile at ${webId}. The profile must declare a ` + + 'solid:oidcIssuer triple to be used for Solid login.', + ); + this.name = 'NoIssuerError'; + } +} + +/** + * Choose the single issuer to log in against. One issuer → it; several → the caller's + * `choose` callback (the user picks); none → {@link NoIssuerError}. PURE except the + * injected chooser. + */ +export async function selectIssuer( + profile: WebIdProfile, + webId: string, + choose: (issuers: string[]) => Promise, +): Promise { + if (profile.issuers.length === 0) throw new NoIssuerError(webId); + if (profile.issuers.length === 1) return profile.issuers[0]; + const chosen = await choose(profile.issuers); + // The chooser MUST return one of the profile's advertised issuers — never log in against + // an issuer the WebID doesn't actually advertise. + if (!profile.issuers.includes(chosen)) { + throw new Error(`Chosen issuer ${chosen} is not advertised by the WebID profile at ${webId}.`); + } + return chosen; +} diff --git a/apps/extension/src/background/core/www-authenticate.ts b/apps/extension/src/background/core/www-authenticate.ts new file mode 100644 index 000000000..e0bd0842e --- /dev/null +++ b/apps/extension/src/background/core/www-authenticate.ts @@ -0,0 +1,140 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * `WWW-Authenticate` parsing + the RFC 9449 §8 DPoP-nonce-challenge classifier. + * + * A Solid resource server that requires a server-supplied DPoP nonce answers the first + * request with `401` + `WWW-Authenticate: DPoP …, error="use_dpop_nonce"` and a fresh + * `DPoP-Nonce` header. The client must re-mint its proof WITH that nonce and retry ONCE. + * This module is the conservative classifier for that case, mirroring + * `@jeswr/solid-elements`'s `isUseDpopNonceChallenge`/`parseWwwAuthenticate` so the + * extension behaves identically to the rest of the suite. + * + * Conservative on purpose: we treat a 401 as a nonce challenge ONLY when the server + * EXPLICITLY says the token was fine and only the nonce was missing + * (`error="use_dpop_nonce"` on a `DPoP` challenge). Any OTHER error (invalid_token, + * expired/revoked) returns false, so the caller force-refreshes the token instead of + * looping on a stale one even when the server ALSO rotated the nonce. + */ + +interface Challenge { + scheme: string; + params: Map; +} + +type Atom = { kind: 'word'; value: string } | { kind: 'quoted'; value: string } | { kind: 'eq' }; + +/** + * Tokenise a `WWW-Authenticate` header into atoms (a bare word, a quoted string, or a + * standalone `=`). The grammar (RFC 9110 §11.6.1) is comma-ambiguous, so we scan + * character-by-character; quoted strings (which may contain commas/`=`) are kept whole. + */ +function tokenize(header: string): Atom[] { + const atoms: Atom[] = []; + let i = 0; + const n = header.length; + while (i < n) { + const c = header[i]; + if (c === ' ' || c === '\t' || c === ',') { + i++; + continue; + } + if (c === '=') { + atoms.push({ kind: 'eq' }); + i++; + continue; + } + if (c === '"') { + i++; + let value = ''; + while (i < n && header[i] !== '"') { + if (header[i] === '\\' && i + 1 < n) { + value += header[i + 1]; + i += 2; + } else { + value += header[i]; + i++; + } + } + i++; // closing quote + atoms.push({ kind: 'quoted', value }); + continue; + } + let value = ''; + while (i < n && !' \t,="'.includes(header[i])) { + value += header[i]; + i++; + } + atoms.push({ kind: 'word', value }); + } + return atoms; +} + +/** + * Parse a `WWW-Authenticate` header into its individual challenges, each with its scheme + * and a quote-aware map of its top-level auth-params. PURE; odd input degrades safely. + */ +export function parseWwwAuthenticate(header: string): Challenge[] { + const atoms = tokenize(header); + const challenges: Challenge[] = []; + let current: Challenge | null = null; + let i = 0; + + const isAuthScheme = (a: Atom): boolean => a.kind === 'word' && /^[A-Za-z][\w-]*$/.test(a.value); + + while (i < atoms.length) { + const atom = atoms[i]; + // `word =` => a param; a bare `word` not followed by `=` starts a new challenge. + if (atom.kind === 'word') { + const next = atoms[i + 1]; + if (next && next.kind === 'eq') { + const valueAtom = atoms[i + 2]; + if (current && valueAtom && (valueAtom.kind === 'word' || valueAtom.kind === 'quoted')) { + current.params.set(atom.value.toLowerCase(), valueAtom.value); + i += 3; + continue; + } + // param with no current challenge / malformed — skip the `=`. + i += 2; + continue; + } + if (isAuthScheme(atom)) { + current = { scheme: atom.value, params: new Map() }; + challenges.push(current); + i += 1; + continue; + } + } + i += 1; + } + return challenges; +} + +/** + * Whether a 401 response is a PURE DPoP-nonce challenge (RFC 9449 §8). Conservative — we + * retry with a fresh nonce ONLY when: + * (a) at least one `DPoP` challenge carries `error="use_dpop_nonce"`, AND + * (b) NO challenge carries any OTHER auth `error` (e.g. `invalid_token`). + * + * (b) is the important guard: if the server says BOTH "use a nonce" AND "your token is + * invalid/revoked" (a mixed header), retrying with a nonce just loops on a dead token — + * we must take the force-refresh path instead. A duplicate/overwritten `error` param, or + * any non-nonce error on any challenge, therefore makes this return false. PURE on the + * header value via {@link parseWwwAuthenticate}. + */ +export function isUseDpopNonceChallenge(response: Response): boolean { + const header = response.headers.get('www-authenticate'); + if (!header) return false; + + const challenges = parseWwwAuthenticate(header); + let sawNonceChallenge = false; + for (const challenge of challenges) { + const error = challenge.params.get('error'); + if (challenge.scheme.toLowerCase() === 'dpop' && error === 'use_dpop_nonce') { + sawNonceChallenge = true; + continue; + } + // Any OTHER auth error anywhere in the header => not a pure nonce challenge. + if (error !== undefined && error !== 'use_dpop_nonce') return false; + } + return sawNonceChallenge; +} diff --git a/apps/extension/src/background/formfill-data-loader.ts b/apps/extension/src/background/formfill-data-loader.ts new file mode 100644 index 000000000..000da77a8 --- /dev/null +++ b/apps/extension/src/background/formfill-data-loader.ts @@ -0,0 +1,63 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import { Parser, type Quad, Writer } from 'n3'; +import type { ResolveFetch } from './core/class-resolve'; +import { type FormFillData, readFormFillData } from './core/formfill-data'; +import { isValidClientIdUrl } from './core/origin-policy'; + +const VCARD = 'http://www.w3.org/2006/vcard/ns#'; +const LINKS = new Set([ + ...[ + 'hasAddress', + 'hasEmail', + 'hasTelephone', + 'hasName', + 'hasOrganizationName', + 'hasAddressBook', + 'inAddressBook', + ].map((p) => `${VCARD}${p}`), + 'http://schema.org/address', + 'https://schema.org/address', + 'http://www.w3.org/ns/solid/terms#publicAddressBook', + 'http://www.w3.org/ns/solid/terms#privateAddressBook', +]); + +/** Public profile is credential-free. Only explicit profile links on pod origins get private reads. */ +export async function loadFormFillData( + webId: string, + podOrigins: ReadonlySet, + fetchDocument: ResolveFetch, +): Promise { + if (!isValidClientIdUrl(webId)) throw new Error('Profile address is unavailable.'); + const publicDocument = await fetchDocument(webId, false); + if (!publicDocument.ok) throw new Error('Could not load your profile.'); + const quads: Quad[] = new Parser({ baseIRI: webId, format: 'text/turtle' }).parse( + publicDocument.text, + ); + const links = new Set(); + for (const q of quads) { + if ( + q.subject.value !== webId || + !LINKS.has(q.predicate.value) || + q.object.termType !== 'NamedNode' + ) + continue; + const url = new URL(q.object.value); + url.hash = ''; + const profileUrl = new URL(webId); + profileUrl.hash = ''; + if (url.href !== profileUrl.href && isValidClientIdUrl(url.href) && podOrigins.has(url.origin)) + links.add(url.href); + } + // Bounded, one-hop discovery. No crawling other contacts or arbitrary profile-supplied hosts. + for (const url of [...links].slice(0, 8)) { + const document = await fetchDocument(url, true); + if (document.ok) + quads.push(...new Parser({ baseIRI: url, format: 'text/turtle' }).parse(document.text)); + } + const turtle = await new Promise((resolve, reject) => { + const writer = new Writer(); + writer.addQuads(quads); + writer.end((error, result) => (error ? reject(error) : resolve(result))); + }); + return readFormFillData(webId, turtle, webId); +} diff --git a/apps/extension/src/background/formfill.ts b/apps/extension/src/background/formfill.ts new file mode 100644 index 000000000..f640a7576 --- /dev/null +++ b/apps/extension/src/background/formfill.ts @@ -0,0 +1,310 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import type { FieldCategory } from '../content/formfill/detect'; +import { + type FormFillField, + type FormFillValue, + formFillFields, + formFillValues, +} from '../shared/formfill'; +import type { + FormFillPreview, + FormFillPreviewRequest, + FormFillRequest, + FormFillResponse, + FormFillTrigger, + Profile, +} from '../shared/messages'; +import type { FormFillData } from './core/formfill-data'; +import { + type FormFillGrant, + liveFormFillGrant, + revokeProfileFormFillGrants, + sanitiseFormFillGrants, +} from './core/formfill-grant'; +import { isValidClientIdUrl } from './core/origin-policy'; +import { resolveRequestingOrigin } from './core/requesting-origin'; + +const KEY = 'solid:formfill-grants'; +const MENU = 'solid-fill-from-pod'; +const LOADING_TIMEOUT = 30_000; +// WCAG 2.2.1 permits a safety limit longer than 20 hours without an extension control. +const REVIEW_SAFETY_CAP = 24 * 60 * 60_000; +interface Choice { + profileId?: string; + categories: readonly FieldCategory[]; + remember: boolean; +} +interface LoadedProfile { + values: FormFillValue[]; + current(): Promise; +} +interface Pending { + origin: string; + fields: FormFillField[]; + profiles: readonly Pick[]; + profileId: string; + version: number; + grant: FormFillGrant | null; + loaded: Map>; + finish(choice: Choice): void; +} + +export interface FormFillDependencies { + profiles(): Promise<{ profiles: readonly Profile[]; defaultProfileId: string | null }>; + /** Capture one profile's session and recheck it before egress and release. */ + load(profileId: string): Promise<{ data: FormFillData; current(): Promise }>; + /** The existing lifecycle lock makes grant persistence/release atomic with sign-out. */ + commit(profileId: string, task: () => Promise): Promise; +} + +export class FormFillWorker { + private readonly pending = new Map(); + private tail: Promise = Promise.resolve(); + constructor(private readonly deps: FormFillDependencies) {} + + private async grants(): Promise { + await this.tail; + return sanitiseFormFillGrants((await chrome.storage.local.get(KEY))[KEY]); + } + private update(change: (grants: FormFillGrant[]) => readonly FormFillGrant[]): Promise { + const run = this.tail.then(async () => { + const grants = sanitiseFormFillGrants((await chrome.storage.local.get(KEY))[KEY]); + await chrome.storage.local.set({ [KEY]: sanitiseFormFillGrants(change(grants)) }); + }); + this.tail = run.catch(() => {}); + return run; + } + async revokeProfile(profileId: string): Promise { + for (const p of this.pending.values()) { + if (p.profileId === profileId || p.loaded.has(profileId)) + p.finish({ categories: [], remember: false }); + } + await this.update((grants) => revokeProfileFormFillGrants(grants, profileId)); + } + async reconcile(profiles: readonly Profile[]): Promise { + const active = new Set(profiles.filter((p) => p.sessionState === 'active').map((p) => p.id)); + for (const p of this.pending.values()) { + if ( + (p.profileId && !active.has(p.profileId)) || + [...p.loaded.keys()].some((id) => !active.has(id)) + ) + p.finish({ categories: [], remember: false }); + } + await this.update((grants) => + grants.filter( + (g) => active.has(g.profileId) && (g.expiresAt === undefined || g.expiresAt > Date.now()), + ), + ); + } + + private load(p: Pending, profileId: string): Promise { + let loaded = p.loaded.get(profileId); + if (!loaded) { + loaded = this.deps + .load(profileId) + .then(({ data, current }) => ({ values: formFillValues(data, p.fields), current })); + p.loaded.set(profileId, loaded); + } + return loaded; + } + + async preview( + message: FormFillPreviewRequest, + sender: chrome.runtime.MessageSender, + ): Promise { + const p = this.pending.get(message.requestId); + // Only this extension's top-level preview document can inspect values or decide. + const expected = chrome.runtime.getURL('formfill/preview.html'); + if ( + !p || + sender.id !== chrome.runtime.id || + sender.url?.split('?')[0] !== expected || + (sender.frameId && sender.frameId !== 0) + ) + return null; + if (message.type === 'SOLID_FORMFILL_DECISION') { + const categories = Array.isArray(message.categories) + ? message.categories.filter((c) => c === 'contact' || c === 'address') + : []; + if (categories.length && message.profileId !== p.profileId) return null; + p.finish({ profileId: p.profileId, categories, remember: message.remember === true }); + return null; + } + const profileId = message.profileId ?? p.profileId; + if (!p.profiles.some((profile) => profile.id === profileId)) return null; + const version = ++p.version; + const loaded = await this.load(p, profileId); + await loaded.current(); + if (this.pending.get(message.requestId) !== p || version !== p.version) return null; + p.profileId = profileId; + return { + requestId: message.requestId, + origin: p.origin, + profileId, + profiles: p.profiles, + values: loaded.values, + selected: p.grant?.profileId === profileId ? p.grant.categories : [], + }; + } + + async request( + message: FormFillRequest, + sender: chrome.runtime.MessageSender, + ): Promise { + const empty: FormFillResponse = { values: [] }; + const origin = resolveRequestingOrigin(sender, sender.url); + const fields = formFillFields(message.fields); + const tabId = sender.tab?.id; + if ( + !origin || + !isValidClientIdUrl(origin) || + tabId === undefined || + sender.frameId === undefined || + !fields.length + ) + return empty; + let topOrigin: string; + try { + topOrigin = new URL(sender.tab!.url!).origin; + } catch { + return empty; + } + if (origin !== topOrigin || !isValidClientIdUrl(topOrigin)) return empty; + // One outstanding request per document/origin prevents accidental duplicate previews. + if ([...this.pending.values()].some((p) => p.origin === origin)) return empty; + const requestId = crypto.randomUUID(); + let windowId: number | undefined; + let timer: ReturnType | undefined; + let finished = false; + let finish!: (choice: Choice) => void; + const choice = new Promise((resolve) => { + finish = (decision) => { + if (finished) return; + finished = true; + resolve(decision); + }; + }); + const p: Pending = { + origin, + fields, + profiles: [], + profileId: '', + version: 0, + grant: null, + loaded: new Map(), + finish, + }; + this.pending.set(requestId, p); + const cancel = () => finish({ categories: [], remember: false }); + const removed = (id: number) => { + if (id === windowId) cancel(); + }; + const tabRemoved = (id: number) => { + if (id === tabId) cancel(); + }; + const tabUpdated = (id: number, change: chrome.tabs.TabChangeInfo) => { + if (id !== tabId || change.url === undefined) return; + try { + if (new URL(change.url).origin === origin) return; + } catch { + // An opaque or invalid destination cannot retain this request's authority. + } + cancel(); + }; + try { + chrome.windows.onRemoved?.addListener(removed); + chrome.tabs.onRemoved?.addListener(tabRemoved); + chrome.tabs.onUpdated?.addListener(tabUpdated); + timer = setTimeout(cancel, LOADING_TIMEOUT); + const ready = (async () => { + const grant = liveFormFillGrant(await this.grants(), origin, Date.now()); + if (finished) return false; + const all = await this.deps.profiles(); + // Late discovery results never load data or reopen a cancelled request. + if (finished) return false; + p.profiles = all.profiles + .filter((profile) => profile.sessionState === 'active') + .map(({ id, label }) => ({ id, label })); + const profileId = grant?.profileId ?? all.defaultProfileId; + if (!profileId || !p.profiles.some((profile) => profile.id === profileId)) return false; + p.grant = grant; + p.profileId = profileId; + await this.load(p, profileId); + return true; + })(); + if (!(await Promise.race([ready, choice.then(() => false)])) || finished) return empty; + clearTimeout(timer); + // Review time starts after discovery; switching profiles does not restart a short timer. + timer = setTimeout(cancel, REVIEW_SAFETY_CAP); + chrome.windows.create( + { + url: chrome.runtime.getURL(`formfill/preview.html?requestId=${requestId}`), + type: 'popup', + width: 480, + height: 680, + }, + (window) => { + if (chrome.runtime.lastError || window?.id === undefined) + finish({ categories: [], remember: false }); + else if (!this.pending.has(requestId)) + void chrome.windows.remove(window.id).catch(() => {}); + else windowId = window.id; + }, + ); + const decision = await choice; + if (!decision.categories.length || !decision.profileId) return empty; + const chosen = await this.load(p, decision.profileId); + const values = chosen.values.filter((v) => decision.categories.includes(v.category)); + if (!values.length) return empty; + return await this.deps.commit(decision.profileId, async () => { + await chosen.current(); + const tab = await chrome.tabs.get(tabId); + if (new URL(tab.url!).origin !== origin) return empty; + // Detect a concurrent remembered binding change rather than silently rebinding it. + const current = liveFormFillGrant(await this.grants(), origin, Date.now()); + if (JSON.stringify(current) !== JSON.stringify(p.grant)) return empty; + if (decision.remember) + await this.update((grants) => [ + ...grants.filter((g) => g.origin !== origin), + { + origin, + profileId: decision.profileId!, + categories: [...new Set(values.map((v) => v.category))], + createdAt: Date.now(), + }, + ]); + await chosen.current(); + return { origin, values }; + }); + } catch { + return empty; + } finally { + clearTimeout(timer); + this.pending.delete(requestId); + chrome.windows.onRemoved?.removeListener(removed); + chrome.tabs.onRemoved?.removeListener(tabRemoved); + chrome.tabs.onUpdated?.removeListener(tabUpdated); + if (windowId !== undefined) void chrome.windows.remove(windowId).catch(() => {}); + } + } +} + +export function registerFormFillMenu(): void { + const create = () => { + if (!chrome.contextMenus?.remove) return; + chrome.contextMenus.remove(MENU, () => { + void chrome.runtime.lastError; + chrome.contextMenus.create({ id: MENU, title: 'Fill from pod', contexts: ['editable'] }); + }); + }; + chrome.runtime.onInstalled.addListener(create); + create(); + chrome.contextMenus?.onClicked.addListener((info, tab) => { + if (info.menuItemId !== MENU || tab?.id === undefined) return; + void chrome.tabs + .sendMessage(tab.id, { type: 'SOLID_FORMFILL_TRIGGER' } satisfies FormFillTrigger, { + frameId: info.frameId ?? 0, + }) + .catch(() => {}); + }); +} diff --git a/apps/extension/src/background/global-fetch-register.ts b/apps/extension/src/background/global-fetch-register.ts new file mode 100644 index 000000000..9a8f65b58 --- /dev/null +++ b/apps/extension/src/background/global-fetch-register.ts @@ -0,0 +1,53 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +/** Reconcile persisted MAIN registrations with the explicit origin grants on every wake/change. */ +interface ScriptingApi { + getRegisteredContentScripts(): Promise>; + registerContentScripts(scripts: chrome.scripting.RegisteredContentScript[]): Promise; + unregisterContentScripts(filter: { ids: string[] }): Promise; +} + +export async function syncOriginFetchInjection( + origins: readonly string[], + scripting: ScriptingApi | undefined = globalThis.chrome?.scripting, +): Promise { + if (!scripting) return false; + try { + const wanted = new Map( + origins.map((origin) => { + const url = new URL(origin); + // Chrome treats an omitted port as '*', including on canonical default-port origins. + // Explicit :443/:80 also match port-less URLs (Chromium URLPattern Match17). + const port = url.port || (url.protocol === 'https:' ? '443' : '80'); + return [ + `solid-origin-${encodeURIComponent(origin)}`, + `${url.protocol}//${url.hostname}:${port}/*`, + ]; + }), + ); + const existing = await scripting.getRegisteredContentScripts(); + const stale = existing.filter( + (s) => + s.id === 'solid-main-world-inject' || + (s.id.startsWith('solid-origin-') && + (s.matches?.length !== 1 || s.matches[0] !== wanted.get(s.id))), + ); + if (stale.length) await scripting.unregisterContentScripts({ ids: stale.map((s) => s.id) }); + const kept = new Set(existing.filter((s) => !stale.includes(s)).map((s) => s.id)); + const scripts = [...wanted] + .filter(([id]) => !kept.has(id)) + .map(([id, match]) => ({ + id, + matches: [match], + js: ['inject/fetch-patch.js'], + world: 'MAIN' as const, + runAt: 'document_start' as const, + persistAcrossSessions: true, + allFrames: false, + })); + if (scripts.length) await scripting.registerContentScripts(scripts); + return true; + } catch { + // A registration never grants authority; requests remain independently gated in the SW. + return false; + } +} diff --git a/apps/extension/src/background/profile-authority.ts b/apps/extension/src/background/profile-authority.ts new file mode 100644 index 000000000..e368b6202 --- /dev/null +++ b/apps/extension/src/background/profile-authority.ts @@ -0,0 +1,70 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +/** Worker-owned migration and lazy IndexedDB authority. No caller falls back to legacy tokens. */ +import { DpopKeyDb } from './core/dpop-key-db'; +import { ProfileStore } from './core/profile-store'; +import { ProfileDb } from './profile-db'; +import { + loadClassGrants, + loadGrantedOrigins, + loadPodOrigins, + loadProfile, + loadSession, +} from './session-store'; + +export function profileAuthority( + purge: (webId: string) => Promise, +): () => Promise { + let ready: Promise | undefined; + return () => { + ready ??= (async () => { + const db = await ProfileDb.open(); + const store = new ProfileStore(db, purge); + try { + await store.initialise( + async () => { + const session = await loadSession(); + const keyDb = await DpopKeyDb.open(); + try { + const keyPair = await keyDb.get(); + return { + session, + keyPair: keyPair ?? null, + display: await loadProfile(), + grantedOrigins: await loadGrantedOrigins(), + podOrigins: await loadPodOrigins(), + classGrants: await loadClassGrants(), + }; + } finally { + keyDb.close(); + } + }, + async () => { + // Only after the profile transaction commits. Retrying on wake is idempotent. + for (const key of [ + 'solid:session', + 'solid:profile', + 'solid:granted-origins', + 'solid:pod-origins', + 'solid:class-grants', + ]) + await chrome.storage.local.remove(key); + const keyDb = await DpopKeyDb.open(); + try { + await keyDb.clear(); + } finally { + keyDb.close(); + } + }, + ); + return store; + } catch (error) { + db.close(); + throw error; + } + })().catch((error) => { + ready = undefined; + throw error; + }); + return ready; + }; +} diff --git a/apps/extension/src/background/profile-db.ts b/apps/extension/src/background/profile-db.ts new file mode 100644 index 000000000..514494dee --- /dev/null +++ b/apps/extension/src/background/profile-db.ts @@ -0,0 +1,65 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import type { ProfilePersistence, ProfileRecord, ProfileSnapshot } from './core/profile-store'; + +const RECORDS = 'profiles'; +const META = 'metadata'; + +function request(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function complete(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onabort = tx.onerror = () => reject(tx.error); + }); +} + +/** Each token set and structured-cloned non-extractable key is keyed by profile id. */ +export class ProfileDb implements ProfilePersistence { + private constructor(private readonly db: IDBDatabase) {} + + static async open(factory: IDBFactory = globalThis.indexedDB): Promise { + if (!factory) throw new Error('ProfileStore requires IndexedDB.'); + const req = factory.open('solid-ext-profiles', 1); + req.onupgradeneeded = () => { + req.result.createObjectStore(RECORDS); + req.result.createObjectStore(META); + }; + return new ProfileDb(await request(req)); + } + + async read(): Promise { + const tx = this.db.transaction([RECORDS, META], 'readonly'); + const done = complete(tx); + const [records, meta] = await Promise.all([ + request(tx.objectStore(RECORDS).getAll()), + request | undefined>(tx.objectStore(META).get('state')), + done, + ]); + return { + records: records.sort((a, b) => Number(a.profile.id) - Number(b.profile.id)), + defaultProfileId: null, + nextId: 1, + migrated: false, + ...meta, + }; + } + + async write({ records, ...meta }: ProfileSnapshot): Promise { + const tx = this.db.transaction([RECORDS, META], 'readwrite'); + const done = complete(tx); + const store = tx.objectStore(RECORDS); + store.clear(); + for (const record of records) store.put(record, record.profile.id); + tx.objectStore(META).put(meta, 'state'); + await done; + } + + close(): void { + this.db.close(); + } +} diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts new file mode 100644 index 000000000..8fdef6c37 --- /dev/null +++ b/apps/extension/src/background/service-worker.ts @@ -0,0 +1,1756 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The MV3 background service worker — the session manager + message router + the one + * holder of the access token. It: + * - restores/refreshes the session (DPoP refresh grant, proactive 30s-before-expiry), + * - routes the page/popup message protocol (login / logout / fetch / get-state / + * set-client-id), + * - performs every authenticated fetch behind the core credential boundary + * (origin-gated, token-endpoint-guarded, §8 nonce retry), and + * - drives the toolbar action icon (the signed-in avatar) + state broadcasts. + * + * RE-ENTRANCY: discovery / token / refresh / profile fetches use the RAW `fetch`, never + * the authenticatedFetch path — so a SW-internal request can never recurse into the + * token-attaching path, and the access token is only ever sent to a vetted resource + * origin. + * + * The MV3 worker is killed aggressively; profile credentials live in IndexedDB and settings in chrome.storage, + * re-hydrated on wake (the in-memory caches below are best-effort accelerators only). + */ + +import type { + AccessResponse, + ActionResult, + ConsentDecisionMessage, + FetchRequest, + FetchResponse, + GetPendingConsentRequest, + GetStateRequest, + GrantSummary, + ListGrantsRequest, + LoginRequest, + OriginGrantRequest, + PendingConsentView, + ProfileRequest, + ProfilesResponse, + RequestAccessRequest, + ResolveClassRequest, + RevokeGrantRequest, + SessionState, + SetClientIdRequest, + WorkerRequest, +} from '../shared/messages'; +import { setSignedInIcon, setSignedOutIcon } from './action-icon'; +import { initiateLogin, refreshSession } from './auth-flow'; +import { authenticatedFetch, type FetchSession } from './core/authenticated-fetch'; +import { + type ClassGrant, + decideClassAccess, + grantsForSession, + modeSatisfies, + type PinnedTarget, + pruneExpiredGrants, +} from './core/class-grant'; +import { normaliseAccessRequest } from './core/class-request'; +import { type ResolveFetch, resolveClassTargets } from './core/class-resolve'; +import { buildPendingConsent, consentDecisionToGrants, type PendingConsent } from './core/consent'; +import { + canonicalOrigin, + liveOriginGrant, + type OriginGrant, + OriginGrantStore, +} from './core/origin-grant'; +import { computeAllowedOrigins, isValidClientIdUrl } from './core/origin-policy'; +import { + type EgressPort, + purgeReplica, + purgeReplicaSlice, + type ReplicaByteCache, + SharedReplica, +} from './core/replica'; +import { ReplicaDb } from './core/replica-db'; +import { + decideRequestAccess, + type RequestAccessDenyReason, + type ThrottleState, +} from './core/request-access-throttle'; +import { type MessageSender, resolveRequestingOrigin } from './core/requesting-origin'; +import { filterResponseHeaders } from './core/response-headers'; +import { SingleFlight } from './core/single-flight'; +import { parseWebIdProfile } from './core/webid'; +import { FormFillWorker, registerFormFillMenu } from './formfill'; +import { loadFormFillData } from './formfill-data-loader'; +import { syncOriginFetchInjection } from './global-fetch-register'; +import { profileAuthority } from './profile-authority'; +import { + loadClientIds, + loadRecentAccounts, + rememberAccount, + type StoredSession, + saveClientId, +} from './session-store'; +import { registerSidePanel } from './side-panel'; + +const ALLOW_INSECURE_LOOPBACK = true; // dev CSS over http://localhost; harmless for https issuers. +const EXPIRY_BUFFER_MS = 30_000; + +/** The Cache API bucket holding the shared replica's bytes (one physical store, see replica.ts). */ +const REPLICA_CACHE_NAME = 'solid-ext-replica-bytes'; + +/** + * The replica IndexedDB handle (metadata + the DPoP nonce cache). Re-derivable on every SW + * wake — a best-effort accelerator, never load-bearing in-memory state (MV3 invariant). The + * DPoP nonce cache lives in IndexedDB (NOT an in-memory Map) so a cold wake does not pay the + * RFC 9449 §8 nonce round-trip on its first request (design §2.1, §5.2). + */ +let replicaDbHandle: ReplicaDb | null = null; + +/** Lazily open (once per wake) the replica DB. */ +async function getReplicaDb(): Promise { + if (!replicaDbHandle) replicaDbHandle = await ReplicaDb.open(); + return replicaDbHandle; +} + +/** The named Cache bucket for replica bytes (re-opened lazily; available in the SW context). */ +function getReplicaCache(): Promise { + return caches.open(REPLICA_CACHE_NAME) as Promise; +} + +/** + * Purge the replica slice of each GONE (revoked / expired) class grant (access-management residual + * b). `retainPool` are the grants that SURVIVE — a resource still covered by a surviving grant of + * the SAME `(webId, origin)` is KEPT, so revoking/expiring one of two overlapping grants never + * evicts data the survivor still authorises. Best-effort (a failure only leaves now grant-less + * bytes to the logout purge); the replica stores are opened only when something is actually gone. + * + * The replica caches only GET (READ) representations, and this codebase treats `write` as NOT + * implying `read` (see `modeSatisfies`). So ONLY a surviving grant that satisfies READ justifies + * retaining cached bytes — a surviving write-only / append-only grant does NOT authorise reading + * them, so its overlap must NOT keep readable bytes resident after the read grant is gone. + */ +async function purgeGrantSlices(gone: readonly ClassGrant[], retainPool: readonly ClassGrant[]) { + if (gone.length === 0) return; + try { + const [cache, db] = await Promise.all([getReplicaCache(), getReplicaDb()]); + for (const g of gone) { + const retainTargets = retainPool + .filter( + (r) => r.webId === g.webId && r.origin === g.origin && modeSatisfies(r.modes, 'read'), + ) + .flatMap((r) => r.targets); + await purgeReplicaSlice(cache, db, { + webId: g.webId, + grantScope: g.origin, + targets: g.targets, + retainTargets, + }); + } + } catch { + // Best-effort: the grants are already gone (denied); their un-servable bytes are swept by the + // synchronous logout purge if this failed. + } +} + +/** + * Prune expired class grants AND purge each expired grant's replica slice (access-management + * residual b). Returns the LIVE grants (the same value the callers previously got from + * `pruneExpiredClassGrants`). The store prune is the authoritative deny; the byte purge is + * best-effort. The surviving `live` grants are the retain-pool so an expiring grant that overlaps a + * still-live one does not evict the survivor's data. + */ +const originGrants = new OriginGrantStore({ + read: async () => (await chrome.storage.local.get('solid:origin-grants'))['solid:origin-grants'], + write: async (grants) => chrome.storage.local.set({ 'solid:origin-grants': grants }), +}); + +async function originGrantFor(origin: string): Promise { + return liveOriginGrant(await originGrants.list(), origin, Date.now()); +} + +async function pruneAndPurgeExpiredGrants(profileId: string, now: number): Promise { + const expired: ClassGrant[] = []; + const live: ClassGrant[] = []; + await originGrants.update((grants) => + grants.map((g) => { + if (g.profileId !== profileId || g.scope.classes === undefined) return g; + const keep = pruneExpiredGrants(g.scope.classes, now); + expired.push(...g.scope.classes.filter((c) => !keep.includes(c))); + live.push(...keep); + return { ...g, scope: { ...g.scope, classes: keep } }; + }), + ); + await purgeGrantSlices(expired, live); + return live; +} + +/** Expiry alarms are advisory; every request independently checks durable grant expiry. */ +let originSyncTail: Promise = Promise.resolve(); +function syncOriginGrants(): Promise { + const run = originSyncTail.then(reconcileOriginGrants); + originSyncTail = run.catch(() => {}); + return run; +} +async function reconcileOriginGrants(): Promise { + const store = await getProfiles(); + const { profiles } = await store.list(); + const active = new Set(profiles.filter((p) => p.sessionState === 'active').map((p) => p.id)); + await formFill.reconcile(profiles); + const gone: OriginGrant[] = []; + await originGrants.update((grants) => + grants.filter((g) => { + const keep = + active.has(g.profileId) && (g.expiresAt === undefined || g.expiresAt > Date.now()); + if (!keep) gone.push(g); + return keep; + }), + ); + for (const g of gone) { + cancelProfileOperations(g.profileId); + const profile = profiles.find((p) => p.id === g.profileId); + // Signed-out/removing profiles retain metadata until their mandatory lifecycle purge succeeds. + if (profile?.sessionState === 'active') await purgeOriginSlice(g, profile.webId); + } + const grants = await originGrants.list(); + await syncOriginFetchInjection(grants.map((g) => g.origin)); + const expiries = grants.flatMap((g) => (g.expiresAt === undefined ? [] : [g.expiresAt])); + if (chrome.alarms) { + await chrome.alarms.clear('solid-origin-expiry'); + if (expiries.length) + await chrome.alarms.create('solid-origin-expiry', { when: Math.min(...expiries) }); + } +} + +async function purgeOriginSlice(grant: OriginGrant, webId: string): Promise { + try { + const [cache, db] = await Promise.all([getReplicaCache(), getReplicaDb()]); + await purgeReplicaSlice(cache, db, { + webId, + grantScope: grant.origin, + targets: grant.scope.pods.map((pod) => ({ url: `${pod}/`, kind: 'container' as const })), + retainTargets: [], + }); + } catch { + // Best effort, as with class-slice purges: removed authority is the serving boundary. + } +} + +const getProfiles = profileAuthority(async (webId) => { + const [cache, db] = await Promise.all([getReplicaCache(), getReplicaDb()]); + await purgeReplica(cache, db, webId); +}); + +/** Serialize only lifecycle changes and short replica commits, never network/body reads. */ +const profileTasks = new Map>(); +function withProfileTask(id: string, task: () => Promise): Promise { + const run = (profileTasks.get(id) ?? Promise.resolve()).then(task); + const tail = run.catch(() => {}); + profileTasks.set(id, tail); + void tail.then(() => { + if (profileTasks.get(id) === tail) profileTasks.delete(id); + }); + return run; +} + +/** Concurrent requests retain their own cancellation scope; lifecycle changes cancel all of them. */ +const profileOperations = new Map>(); + +async function cancellable(signal: AbortSignal, task: () => Promise): Promise { + signal.throwIfAborted(); + let onAbort = () => {}; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + }); + try { + return await Promise.race([aborted, task()]); + } finally { + signal.removeEventListener('abort', onAbort); + } +} + +async function withProfileOperation( + id: string, + task: (signal: AbortSignal) => Promise, + origin?: string, +): Promise { + const controller = new AbortController(); + const operations = profileOperations.get(id) ?? new Map(); + operations.set(controller, origin); + profileOperations.set(id, operations); + try { + await profileTasks.get(id); + return await cancellable(controller.signal, () => task(controller.signal)); + } catch (error) { + if (controller.signal.aborted) return null; + throw error; + } finally { + operations.delete(controller); + if (operations.size === 0) profileOperations.delete(id); + } +} + +function cancelProfileOperations(id: string, origin?: string): void { + for (const [operation, requestingOrigin] of profileOperations.get(id) ?? []) { + if (origin === undefined || origin === requestingOrigin) operation.abort(); + } +} + +function withProfileLifecycle(id: string, task: () => Promise): Promise { + return withProfileTask(id, async () => { + cancelProfileOperations(id); + return task(); + }); +} + +const refreshes = new Map>(); +function refreshOnce(id: string): Promise { + let flight = refreshes.get(id); + if (!flight) { + flight = new SingleFlight(); + refreshes.set(id, flight); + } + return flight.run(async () => { + const store = await getProfiles(); + // A delayed reader can enter after the previous flight committed rotated tokens. + // Only the credentials read INSIDE this flight may be redeemed or signed out. + const ctx = await store.session(id); + if (!ctx) return null; + if (ctx.session.expiresAt >= Date.now() + EXPIRY_BUFFER_MS) return ctx.session; + try { + const updated = await refreshSession(ctx.session, ctx.keyPair); + await store.updateSession(id, updated); + return updated; + } catch { + return withProfileTask(id, async () => { + // Read and compare under the same lifecycle lock as re-login. A stale failure + // must neither sign out replacement credentials nor cancel their requests. + const current = await store.session(id); + if ( + current?.session.accessToken === ctx.session.accessToken && + current.session.refreshToken === ctx.session.refreshToken && + current.session.dpopJkt === ctx.session.dpopJkt + ) { + cancelProfileOperations(id); + try { + try { + await formFill.revokeProfile(id); + await store.signOut(id); + } finally { + await syncOriginGrants(); + } + } finally { + // Revocation commits before replica cleanup, which may fail and need a retry. + // A toolbar update may await this refresh; schedule its guarded repaint. + void updateDefaultIconAndBroadcast().catch(() => {}); + } + } else if (current && current.session.expiresAt >= Date.now() + EXPIRY_BUFFER_MS) { + return current.session; + } + return null; + }); + } + }); +} + +type SessionContext = { profileId: string; session: StoredSession; keyPair: CryptoKeyPair }; + +/** Recheck captured credentials after asynchronous work, including key replacement on re-login. */ +async function checkProfileSession(ctx: SessionContext, signal: AbortSignal): Promise { + signal.throwIfAborted(); + const current = await (await getProfiles()).session(ctx.profileId); + signal.throwIfAborted(); + if ( + current?.session.dpopJkt !== ctx.session.dpopJkt || + current?.session.webId !== ctx.session.webId + ) + throw new Error('Profile session changed.'); +} + +/** Read the live parent last, so expiry during a session lookup cannot disclose its contents. */ +async function checkOriginAuthority( + parent: OriginGrant, + ctx: SessionContext, + signal: AbortSignal, +): Promise { + await checkProfileSession(ctx, signal); + const current = await originGrantFor(parent.origin); + signal.throwIfAborted(); + if (current?.profileId !== ctx.profileId || current?.createdAt !== parent.createdAt) + throw new Error('Origin grant changed.'); + return current; +} + +/** Resolve the default once per request. Never re-select it after an await. */ +async function ensureSession(profileId?: string): Promise { + const store = await getProfiles(); + const id = profileId ?? (await store.getDefault())?.profile.id; + if (!id) return null; + let ctx = await store.session(id); + if (!ctx) return null; + if (ctx.session.expiresAt < Date.now() + EXPIRY_BUFFER_MS) { + if (!(await refreshOnce(id))) return null; + ctx = await store.session(id); + } + return ctx ? { ...ctx, profileId: id } : null; +} + +/** Statuses whose Response must have a null body (Fetch standard). */ +const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]); + +async function teardownSession(sender?: MessageSender, stamp?: string): Promise { + const store = await getProfiles(); + const origin = resolveRequestingOrigin(sender, stamp); + const grant = origin ? await originGrantFor(origin) : null; + if (!isExtensionContext(sender) && !grant) throw new Error('Origin not granted access'); + const record = isExtensionContext(sender) + ? await store.getDefault() + : grant + ? await store.get(grant.profileId) + : null; + await revokeThenPublish(async () => { + if (record) + await withProfileLifecycle(record.profile.id, async () => { + try { + await formFill.revokeProfile(record.profile.id); + await store.signOut(record.profile.id); + } finally { + await syncOriginGrants(); + } + }); + else if ((await store.list()).profiles.length === 0) { + const [cache, db] = await Promise.all([getReplicaCache(), getReplicaDb()]); + await purgeReplica(cache, db, null); + } + }); +} + +/** + * Revocation is persisted before cleanup, so the revoked state is published even when a purge + * fails and needs a wake-time retry. A cleanup error wins over a publication error. When cleanup + * succeeded, a failed publication is retried once and still reported, so the caller never sees + * success while open pages may hold a revoked identity. + */ +async function revokeThenPublish(cleanup: () => Promise): Promise { + try { + await cleanup(); + } catch (err) { + await updateDefaultIconAndBroadcast().catch(() => {}); + throw err; + } + try { + await updateDefaultIconAndBroadcast(); + } catch (err) { + await updateDefaultIconAndBroadcast().catch(() => {}); + throw err; + } +} + +/** Build the per-request fetch session (the credential boundary) from the stored session. */ +async function buildFetchSession( + session: StoredSession, + keyPair: CryptoKeyPair, + profileId: string, +): Promise { + const { podOrigins } = await (await getProfiles()).get(profileId); + const allowedOrigins = computeAllowedOrigins({ + webId: session.webId, + issuer: session.issuer, + podOrigins, + allowInsecureLoopback: ALLOW_INSECURE_LOOPBACK, + }); + return { + accessToken: session.accessToken, + dpopKeyPair: keyPair, + allowedOrigins, + tokenEndpoint: session.tokenEndpoint, + }; +} + +function originOf(url: string): string | null { + try { + return new URL(url).origin; + } catch { + return null; + } +} + +const DENY_MESSAGE = { + 'forbidden-origin': 'Forbidden origin', + 'origin-not-granted': 'Origin not granted access', + 'cross-origin-foreign': 'Cross-origin fetch denied', +} as const; + +/** + * The per-data-class deny messages — DISTINGUISHABLE strings the page can surface (design + * item 2), separate from the coarse per-origin deny reasons above. A class-scoped origin whose + * request falls outside its pinned target set (wrong resource / wrong mode / no live grant) + * gets one of these, so the app can tell "you were denied because this is outside your grant" + * from a plain forbidden-origin. + */ +const CLASS_DENY_MESSAGE = { + 'out-of-grant-scope': 'Out of grant scope', + 'no-class-grant': 'No access grant for this data', +} as const; + +/** Re-read the explicit parent, then enforce its pod scope and nested pinned classes. */ +async function gateAccess( + message: FetchRequest, + requestingOrigin: string, + sender: MessageSender | undefined, + allowedTargetOrigins: ReadonlySet, + webId: string, + profileId: string, +): Promise<{ grantScope: string } | { deny: Response }> { + const grant = await originGrantFor(requestingOrigin); + if (resolveRequestingOrigin(sender, message.stampedOrigin) !== requestingOrigin) + return { deny: denyResponse(DENY_MESSAGE['forbidden-origin']) }; + if (!grant || grant.profileId !== profileId) + return { deny: denyResponse(DENY_MESSAGE['origin-not-granted']) }; + const target = originOf(message.url); + if (!target || (!allowedTargetOrigins.has(target) && target !== requestingOrigin)) + return { deny: denyResponse(DENY_MESSAGE['cross-origin-foreign']) }; + if (allowedTargetOrigins.has(target)) { + if (!grant.scope.pods.includes(target)) return { deny: denyResponse('Out of grant scope') }; + if (grant.scope.classes !== undefined) await pruneAndPurgeExpiredGrants(profileId, Date.now()); + } + + // Pruning, purging and even the storage read can span a deadline or scope change. + // Decide from the live parent and its children at ONE fresh instant, with no more awaits. + const parents = await originGrants.list(); + const now = Date.now(); + const current = liveOriginGrant(parents, requestingOrigin, now); + if (!current || current.profileId !== profileId) + return { deny: denyResponse(DENY_MESSAGE['origin-not-granted']) }; + if (allowedTargetOrigins.has(target)) { + if (!current.scope.pods.includes(target)) return { deny: denyResponse('Out of grant scope') }; + if (current.scope.classes !== undefined) { + const reason = decideClassAccess( + grantsForSession(current.scope.classes, requestingOrigin, webId), + message.url, + message.method, + now, + ); + if (reason) return { deny: denyResponse(CLASS_DENY_MESSAGE[reason]) }; + } + } + + return { grantScope: requestingOrigin }; +} + +const READ_METHODS = new Set(['GET', 'HEAD']); + +async function handleFetch( + message: FetchRequest, + sender: MessageSender | undefined, +): Promise { + // FIRST, before ANY session work (which may trigger a token refresh): the cheap, + // session-independent half of the gate — dual-origin agreement + opaque/null rejection. A + // forged/opaque origin is denied here so it cannot even cause a session-refresh side + // effect (refresh-token amplification). The browser-attested sender is the authority. + const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); + if (requestingOrigin === null) { + // Round-2 HIGH #1: a forged/opaque origin on the EXPLICIT path is a 403. An `autoDivert` + // request (the global-fetch patch) must never hard-fail the page — but the SW must NOT do + // the fallback fetch itself (that would make the extension a cross-origin read proxy). It + // returns a PASSTHROUGH SENTINEL (no network) and the page-side inject does the native + // fetch in the page's own origin context. + if (message.autoDivert) return passthroughSentinel(message); + return { + requestId: message.requestId, + status: 403, + error: DENY_MESSAGE['forbidden-origin'], + }; + } + + const grant = await originGrantFor(requestingOrigin); + if (!grant) { + void syncOriginGrants().catch(() => {}); + return message.autoDivert + ? passthroughSentinel(message) + : { requestId: message.requestId, status: 403, error: DENY_MESSAGE['origin-not-granted'] }; + } + const selected = await (await getProfiles()).get(grant.profileId); + if (selected) { + let egressStarted = false; + const result = await withProfileOperation( + selected.profile.id, + async (signal) => { + const ctx = await ensureSession(selected.profile.id); + signal.throwIfAborted(); + return handleProfileFetch(message, sender, requestingOrigin, ctx, signal, () => { + egressStarted = true; + }); + }, + requestingOrigin, + ); + if (result) return result; + // Native fallback would replay a request the pod may already have processed. + if (egressStarted) return { requestId: message.requestId, error: 'Fetch cancelled' }; + } + return handleProfileFetch(message, sender, requestingOrigin, null); +} + +async function handleProfileFetch( + message: FetchRequest, + sender: MessageSender | undefined, + requestingOrigin: string, + ctx: SessionContext | null, + signal = new AbortController().signal, + onEgress = () => {}, +): Promise { + if (!ctx) { + // No session: the explicit path reports "Not authenticated"; an autoDivert request gets the + // passthrough sentinel so the page does the plain native fetch it would have done itself (no + // credential exists to attach anyway, and the SW performs no network). + if (message.autoDivert) return passthroughSentinel(message); + return { requestId: message.requestId, error: 'Not authenticated' }; + } + + try { + const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair, ctx.profileId); + + // The INJECTED gate the replica runs as the FIRST step on EVERY read AND write — including + // on a CACHE HIT (design §5.4 point 3). It re-runs the SAME audited `gateRequestingOrigin` + // decision (idempotent) and returns the verified grant-scope on ALLOW; the replica never + // touches the cache or the pod before this resolves to ALLOW, so a warm cache can never + // bypass the per-origin gate. + const gate = async (): Promise<{ grantScope: string } | { deny: Response }> => { + signal.throwIfAborted(); + const decision = await gateAccess( + message, + requestingOrigin, + sender, + fetchSession.allowedOrigins, + ctx.session.webId, + ctx.profileId, + ); + signal.throwIfAborted(); + return decision; + }; + + // Decide passthrough before opening replica stores, independently of other requests. + const decision = await gate(); + if ('deny' in decision) { + if (message.autoDivert) return passthroughSentinel(message); + return { + requestId: message.requestId, + status: decision.deny.status, + error: await decision.deny.text(), + }; + } + + const origin = originOf(message.url); + const db = await getReplicaDb(); + const cache = await getReplicaCache(); + const nonce = origin ? await db.getNonce(origin) : undefined; + + // The SW's sole DPoP egress (authenticated-fetch.ts), wrapped as the replica's egress + // port. The DPoP-bound, origin-gated, §8-nonce-retry egress is PRESERVED VERBATIM — the + // replica only chooses WHEN to call it (revalidate vs write-through) and adds the + // conditional header; it never re-implements the credential path. + const egress: EgressPort = { + fetch: async (url, method, headers, body, n) => { + const decision = await gate(); + if ('deny' in decision) throw new Error(await decision.deny.text()); + const result = await authenticatedFetch(fetchSession, { + url, + method, + headers, + body, + nonce: n, + fetchImpl: (input, init) => + cancellable(signal, async () => { + const latest = await gate(); + if ('deny' in latest) throw new Error(await latest.deny.text()); + onEgress(); + return fetch(input, { ...init, signal }); + }), + }); + // Consume network bodies outside the lifecycle queue, including cache.put's body. + // Chrome exposes an empty body stream on a 304 revalidation, but the Response + // constructor rejects any body for null-body statuses. + const responseBody = + result.response.body && !NULL_BODY_STATUSES.has(result.response.status) + ? await cancellable(signal, () => result.response.arrayBuffer()) + : null; + const response = new Response(responseBody, { + status: result.response.status, + statusText: result.response.statusText, + headers: result.response.headers, + }); + return { response, nonce: result.nonce }; + }, + }; + + // Commit bytes and metadata together before a lifecycle purge can enter the queue. + // Aborted work cannot persist anything after that purge. + const persist = (task: () => Promise): Promise => + withProfileTask(ctx.profileId, async () => { + signal.throwIfAborted(); + const decision = await gate(); + if ('deny' in decision) throw new Error(await decision.deny.text()); + return task(); + }); + const replica = new SharedReplica({ + gate, + egress, + cache, + db, + commit: persist, + webId: ctx.session.webId, + }); + + const isRead = READ_METHODS.has(message.method.toUpperCase()); + const result = isRead + ? await replica.read(message.url, message.method, message.headers, nonce) + : await replica.write(message.url, message.method, message.headers, message.body, nonce); + + if ('deny' in result) { + // The gate denied (incl. on a would-be cache hit). The replica touched NEITHER the pod + // NOR the cache before denying, so nothing leaked. + // + // Round-2 HIGH #1 — page-compat invariant: a request that came from the BEST-EFFORT + // global-fetch transparency patch (`autoDivert`) must NEVER be hard-failed. The patch + // routes EVERY cross-origin request, so a DENY here is the common case for an app's NORMAL + // traffic to a third-party API/CDN (a non-pod target) or from an un-granted origin. For + // those the SW returns a PASSTHROUGH SENTINEL — NO token, NO replica, NO 403, and crucially + // NO NETWORK FROM THE SW. The page-side inject then completes the request with its OWN + // native `fetch` (PRISTINE_FETCH) in the page's origin context, so the page behaves exactly + // as if the extension were not installed (CORS, credentials, headers all preserved). This + // closes the round-2 hole where the SW (with host perms) doing the fallback + // fetch turned the extension into an open cross-origin read proxy. The SW does ZERO egress + // on this path, so it can never read cross-origin bytes a page's own CORS would block. An + // EXPLICIT `window.solid.fetch` (autoDivert false) keeps the 403: the caller deliberately + // asked for the gated path. + if (message.autoDivert) { + return passthroughSentinel(message); + } + return { + requestId: message.requestId, + status: result.deny.status, + error: await result.deny.text(), + }; + } + + // Persist the freshest server nonce to IndexedDB so it survives an SW restart. + const serverNonce = result.nonce; + if (origin && serverNonce) await persist(() => db.setNonce(origin, serverNonce)); + + // Defense-in-depth: relay only the allowlisted, app-relevant response headers back to + // the page rather than every header the server emitted (see response-headers.ts). + const finalDecision = await gate(); + if ('deny' in finalDecision) + return { requestId: message.requestId, status: 403, error: await finalDecision.deny.text() }; + const headers = filterResponseHeaders(result.response.headers); + // A HEAD response carries NO body (Low #5) — the replica already strips it; relay no body. + const isHead = message.method.toUpperCase() === 'HEAD'; + return { + requestId: message.requestId, + status: result.response.status, + statusText: result.response.statusText, + headers, + body: isHead ? undefined : await cancellable(signal, () => result.response.text()), + }; + } catch (err) { + return { + requestId: message.requestId, + error: err instanceof Error ? err.message : 'Fetch failed', + }; + } +} + +/** A small Response carrying a gate-deny reason, for the replica's injected gate port. */ +function denyResponse(error: string): Response { + return new Response(error, { status: 403 }); +} + +/** + * Round-2 HIGH #1 + Medium fix: the PASSTHROUGH SENTINEL the SW returns for an `autoDivert` + * request the gate did NOT ALLOW. The SW performs ZERO network here — no token, no DPoP proof, + * no replica, and crucially NO fetch at all. It returns only `{ requestId, passthrough: true }` + * (no status, no headers, no fetched bytes). The page-side inject (MAIN world) completes the + * request by calling its OWN native `fetch` (the captured PRISTINE_FETCH) with the request's + * ORIGINAL input/init, in the page's origin context. + * + * WHY (the round-2 finding): doing the fallback fetch FROM THE SW — which holds `` + * host permissions — turned the extension into an OPEN CROSS-ORIGIN READ PROXY: any page could + * `postMessage` an `autoDivert` request and read back cross-origin bytes that CORS would + * normally block. Returning a sentinel and letting the PAGE do the fetch restores EXACT native + * semantics (CORS, credentials, headers) and means the SW never reads bytes a page could not + * read itself. It also removes the header-stripping / `credentials:'omit'` divergence (Medium): + * the page's native fetch applies the page's real headers + credentials policy, unchanged. + */ +function passthroughSentinel(message: FetchRequest): FetchResponse { + return { requestId: message.requestId, passthrough: true }; +} + +/** Best-effort display refresh; it never changes a user-assigned label or another profile. */ +async function refreshProfile(profileId: string, webId: string): Promise { + try { + const response = await fetch(webId, { headers: { Accept: 'text/turtle' } }); + if (!response.ok) return; + const { photoUrl } = parseWebIdProfile(webId, await response.text()); + await (await getProfiles()).capabilities(profileId, (record) => { + record.profile = { ...record.profile, avatar: photoUrl ?? undefined }; + }); + } catch { + // Cosmetic. + } +} + +/** + * Broadcast an auth-state change. The WebID (PII) is sent to a tab ONLY when that tab's + * origin holds a grant — a non-granted page is told the WebID is `null` (it learns nothing + * about who is signed in), closing the design §4.3 PII-broadcast over-exposure. A logout + * (`webId === null`) is sent to every tab so granted apps clear their state. The popup/side + * panel (extension contexts) always get the real value via the runtime broadcast. + */ +let stateChangeVersion = 0; + +async function broadcastStateChange(ctx: SessionContext | null, version: number): Promise { + const webId = ctx?.session.webId ?? null; + + const tabs = await chrome.tabs.query({}); + // Tab discovery and grant reads can overlap a default switch. Discard the whole stale + // notification, including its trusted-context copy, before sending any captured identity. + const current = await (await getProfiles()).getDefault(); + if (version !== stateChangeVersion) return; + if (ctx) { + if ( + current?.profile.id !== ctx.profileId || + current.profile.sessionState !== 'active' || + current.profile.webId !== webId + ) + return; + } else if (current?.profile.sessionState === 'active') return; + for (const tab of tabs) { + if (tab.id === undefined) continue; + const tabId = tab.id; + const tabOrigin = tab.url ? originOf(tab.url) : null; + // A signed-in WebID is only revealed to a granted tab; everyone else (and every tab on + // logout) gets null. Fail-closed: an unparseable tab URL never receives the WebID. + const grant = tabOrigin ? await originGrantFor(tabOrigin) : null; + const deliver = async (signal: AbortSignal) => { + const bound = grant ? await (await getProfiles()).session(grant.profileId) : null; + signal.throwIfAborted(); + // The bridge checks origin as well, closing navigation after this URL lookup. + const currentTab = await chrome.tabs.get(tabId).catch(() => null); + signal.throwIfAborted(); + if (!tabOrigin || !currentTab?.url || originOf(currentTab.url) !== tabOrigin) return; + let tabWebId: string | null = null; + if (grant && bound) { + try { + await checkOriginAuthority(grant, { ...bound, profileId: grant.profileId }, signal); + tabWebId = bound.session.webId; + } catch { + signal.throwIfAborted(); + } + } + if (version !== stateChangeVersion) return; + chrome.tabs + .sendMessage( + tabId, + { type: 'SOLID_STATE_CHANGED', origin: tabOrigin, webId: tabWebId }, + { frameId: 0 }, + ) + .catch(() => {}); + }; + if (grant) await withProfileOperation(grant.profileId, deliver, tabOrigin ?? undefined); + else await deliver(new AbortController().signal); + if (version !== stateChangeVersion) return; + } + // The popup/side panel are trusted extension contexts — give them the real value. + chrome.runtime.sendMessage({ type: 'SOLID_STATE_CHANGED', webId }).catch(() => {}); +} + +// The legacy PKCE params slot admits one interactive auth flow at a time. +let loginTail: Promise = Promise.resolve(); +function handleLogin( + message: LoginRequest, + sender: MessageSender | undefined, + makeDefault = true, +): Promise { + const run = loginTail.then(async () => { + try { + const store = await getProfiles(); + const { profiles } = await store.list(); + if (profiles.length >= 8 && !profiles.some((p) => p.webId === message.webId)) { + throw new Error('At most 8 profiles.'); + } + const requestingOrigin = resolveRequestingOrigin(sender, message.origin); + const clientIds = await loadClientIds(); + const candidate = + message.clientId || (requestingOrigin ? clientIds[requestingOrigin] : undefined); + const clientId = candidate && isValidClientIdUrl(candidate) ? candidate : undefined; + const { session, keyPair, name, photoUrl } = await initiateLogin({ + webId: message.webId, + clientId, + }); + const existing = profiles.find((p) => p.webId === session.webId); + const commit = async () => { + const profile = await store.login(session, keyPair, { + webId: session.webId, + name, + photoUrl, + }); + if (makeDefault) await store.setDefault(profile.id); + await rememberAccount({ webId: session.webId, name, photoUrl }); + void refreshProfile(profile.id, session.webId); + }; + if (existing) await withProfileLifecycle(existing.id, commit); + else await commit(); + await updateDefaultIconAndBroadcast(); + return { ok: true, webId: session.webId }; + } catch (err) { + return { error: err instanceof Error ? err.message : 'Login failed' }; + } + }); + loginTail = run.catch(() => {}); + return run; +} + +async function updateDefaultIconAndBroadcast(): Promise { + const version = ++stateChangeVersion; + const isCurrent = () => version === stateChangeVersion; + const ctx = await ensureSession(); + const record = ctx ? await (await getProfiles()).get(ctx.profileId) : null; + const webId = ctx?.session.webId ?? null; + if (!isCurrent()) return; + if (webId && record) + await setSignedInIcon( + { + webId, + name: record.profile.label, + photoUrl: record.profile.avatar ?? null, + }, + isCurrent, + ); + else await setSignedOutIcon(isCurrent); + await syncOriginGrants(); + await broadcastStateChange(ctx, version); +} + +async function handleProfiles( + message: ProfileRequest, + sender: MessageSender | undefined, +): Promise { + if (!isExtensionContext(sender)) + return { error: 'Profile management requires extension context.' }; + try { + const store = await getProfiles(); + switch (message.type) { + case 'SOLID_PROFILE_ADD': { + const result = await handleLogin( + { type: 'SOLID_LOGIN', webId: message.webId }, + sender, + false, + ); + if (result.error) return { error: result.error }; + break; + } + case 'SOLID_PROFILE_RENAME': + await store.rename(message.profileId, message.label); + break; + case 'SOLID_PROFILE_REMOVE': + // Keeps the removing marker/error if cleanup failed, but clears the displayed identity. + await revokeThenPublish(() => + withProfileLifecycle(message.profileId, async () => { + try { + await formFill.revokeProfile(message.profileId); + await store.remove(message.profileId); + } finally { + await syncOriginGrants(); + } + }), + ); + return { ok: true, ...(await store.list()) }; + case 'SOLID_PROFILE_SET_DEFAULT': + await store.setDefault(message.profileId); + break; + case 'SOLID_PROFILES_LIST': + return { ok: true, ...(await store.list()) }; + } + await updateDefaultIconAndBroadcast(); + return { ok: true, ...(await store.list()) }; + } catch (err) { + // A failed SOLID_PROFILE_REMOVE has already published through revokeThenPublish. + return { error: err instanceof Error ? err.message : 'Profile operation failed' }; + } +} + +/** + * Whether a message sender is one of the extension's OWN trusted contexts (popup, side + * panel, options) rather than a web page. Chrome stamps these with the extension's own + * origin/URL; a web content script is stamped with the page origin. The owner UI is fully + * trusted; a web page is not. Fail-closed: an unknown/opaque sender is treated as NOT the + * extension (a web page), so it gets the scoped (PII-free) view. + */ +function isExtensionContext(sender: MessageSender | undefined): boolean { + const extensionOrigin = `chrome-extension://${chrome.runtime.id}`; + if (sender?.origin) + return ( + sender.origin === extensionOrigin && + (!sender.url || sender.url.startsWith(`${extensionOrigin}/`)) + ); + if (sender?.url?.startsWith(`${extensionOrigin}/`)) return true; + return false; +} + +/** + * Answer `SOLID_GET_STATE`. The extension's OWN contexts (popup/side panel) get the full + * session view. A WEB PAGE gets identity (WebID + profile) ONLY if its verified+granted + * origin holds a grant — otherwise the PII is withheld (design §4.3 "PII broadcast"): a page + * the owner never opted in learns nothing about who is signed in. + */ +async function handleGetState( + message: GetStateRequest, + sender: MessageSender | undefined, +): Promise { + const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); + const grant = requestingOrigin ? await originGrantFor(requestingOrigin) : null; + const ctx = isExtensionContext(sender) + ? await ensureSession() + : grant + ? await ensureSession(grant.profileId) + : null; + const profile = ctx ? (await (await getProfiles()).get(ctx.profileId)).profile : null; + + if (isExtensionContext(sender)) { + const recentAccounts = await loadRecentAccounts(); + return { + webId: ctx?.session.webId ?? null, + isActive: ctx !== null, + name: profile?.label ?? null, + photoUrl: profile?.avatar ?? null, + recentAccounts, + }; + } + + const latest = requestingOrigin ? await originGrantFor(requestingOrigin) : null; + const mayExpose = !!ctx && latest?.profileId === ctx.profileId; + + return { + webId: mayExpose ? (ctx?.session.webId ?? null) : null, + isActive: mayExpose && ctx !== null, + name: mayExpose ? (profile?.label ?? null) : null, + photoUrl: mayExpose ? (profile?.avatar ?? null) : null, + // Never leak the cross-pod recent-accounts list to a web page. + recentAccounts: [], + }; +} + +async function handleSetClientId( + message: SetClientIdRequest, + sender: MessageSender | undefined, +): Promise { + try { + // A Client Identifier Document is a dereferenceable URL. Require HTTPS so it cannot be + // tampered with in transit; allow http: ONLY for a loopback host (dev CSS). A remote + // plaintext client-id doc could be rewritten by a network attacker. + if (!isValidClientIdUrl(message.clientId)) { + throw new Error('Client identifier must be an https: URL (http: allowed for loopback only).'); + } + // Cross-check the page-supplied origin against the browser-attested sender; store the + // client-id only under the VERIFIED origin, so a renderer cannot + // map another origin's client-id or self-grant a foreign origin. + const requestingOrigin = resolveRequestingOrigin(sender, message.origin); + if (requestingOrigin === null) { + throw new Error('Could not verify the requesting origin.'); + } + await saveClientId(requestingOrigin, message.clientId); + return { ok: true }; + } catch (err) { + return { error: err instanceof Error ? err.message : 'Invalid client identifier' }; + } +} + +// --- Per-data-class access management (Phase 2, design §4) ---------------------------- +// +// The consent flow is orchestrated here: `requestAccess` resolves each class to targets, +// opens the UNSPOOFABLE consent window (extension chrome), and awaits the user's decision; +// the window posts `SOLID_CONSENT_DECISION` back, which mints + persists the approved grants. +// The pending state lives in module-level maps (best-effort, MV3-appropriate): if the SW is +// killed while the window is open, the window reads `null` and denies (fail-closed) and the +// `requestAccess` promise times out — no grant is minted without a live approval. + +/** Pending consent requests awaiting a decision (consentId → record). */ +type ProfileConsent = { pending: PendingConsent; ctx: SessionContext; label: string }; +const pendingConsents = new Map>(); +/** Resolvers for the awaiting `requestAccess` calls (consentId → resolve(approvedClasses)). */ +type ConsentChoice = { approved: string[]; profileId?: string }; +const consentResolvers = new Map void>(); +const CONSENT_TIMEOUT_MS = 5 * 60_000; + +/** + * Per-origin `requestAccess` throttle state (residual c). Best-effort module state (re-derived — + * i.e. reset — on an MV3 SW restart, which merely refreshes the window; acceptable for an + * annoyance/DoS mitigation, never a security boundary). Keyed by the browser-attested requesting + * origin so an origin cannot evade its own limit by lying about its identity. + */ +const requestAccessThrottle = new Map(); + +/** Distinguishable page-surfaced messages for a throttled `requestAccess` (residual c). */ +const REQUEST_ACCESS_DENY_MESSAGE: Record = { + 'user-gesture-required': 'requestAccess requires a user action (click) — it cannot be automated.', + 'rate-limited': 'Too many access requests from this app — please wait a moment and try again.', +}; + +/** A token-free public summary of a grant (design §4.2 return shape). */ +function grantSummary(g: ClassGrant): GrantSummary { + return { + grantId: g.grantId, + dataClass: g.dataClass, + modes: g.modes, + purpose: g.purpose, + expiresAt: g.expiresAt, + targets: g.targets.map((t) => ({ url: t.url, kind: t.kind })), + }; +} + +function isHttpIri(value: unknown): value is string { + if (typeof value !== 'string') return false; + try { + const u = new URL(value); + return u.protocol === 'https:' || u.protocol === 'http:'; + } catch { + return false; + } +} + +/** + * The POD-ONLY origin set for class resolution — the WebID origin ∪ configured pod origins. It + * confines authenticated private-index fetches and the resolved TARGETS: the full credential set + * (`buildFetchSession().allowedOrigins`) ALSO includes the ISSUER origin, and a hostile WebID + * profile pointing a type-index ref at the issuer would otherwise trigger an authenticated SW + * fetch to an issuer *path* (token-to-issuer-path leak, roborev round-2 Medium). + * + * The exclusion is `includeIssuerOrigin: false` — the issuer is not added as a SEPARATE candidate. + * It is DELIBERATELY *not* an unconditional `delete(issuerOrigin)` (roborev round-3): in the + * ubiquitous CSS single-origin deployment the pod IS served from the issuer origin + * (`webId origin === issuer origin`), and unconditionally removing that origin would drop EVERY + * legitimate pod ref/target and break class resolution entirely there. So the issuer origin is + * allowed ONLY when it coincides with a genuine pod origin (the WebID origin, or an origin the + * user explicitly configured as a pod) — where sending the token to that origin is already normal + * pod access (and the token *endpoint* itself stays protected by `isTokenEndpoint` in + * authenticated-fetch). A issuer origin DISTINCT from every pod origin is excluded, which is the + * profile-controlled attack this guards. (Verified by the CSS-single-origin + distinct-issuer tests.) + */ +async function podOriginsFor( + session: StoredSession, + profileId: string, +): Promise> { + const { podOrigins } = await (await getProfiles()).get(profileId); + return computeAllowedOrigins({ + webId: session.webId, + podOrigins, + includeIssuerOrigin: false, + allowInsecureLoopback: ALLOW_INSECURE_LOOPBACK, + }); +} + +/** Public discovery never carries credentials; private indexes use scoped DPoP reads. */ +function makeResolveFetch( + session: FetchSession, + webId: string, + signal = new AbortController().signal, + beforeRequest: (url: string, authenticated: boolean) => Promise = async () => {}, +): ResolveFetch { + return async (url, useCredentials) => { + try { + const authenticated = useCredentials && url !== webId; + const fetchImpl: typeof fetch = (input, init) => + cancellable(signal, async () => { + await beforeRequest(input.toString(), authenticated); + signal.throwIfAborted(); + return fetch(input, { ...init, credentials: 'omit', redirect: 'error', signal }); + }); + const response = authenticated + ? ( + await authenticatedFetch(session, { + url, + method: 'GET', + headers: { Accept: 'text/turtle' }, + body: null, + nonce: undefined, + fetchImpl, + }) + ).response + : await fetchImpl(url, { headers: { Accept: 'text/turtle' } }); + await beforeRequest(url, authenticated); + const text = response.ok ? await cancellable(signal, () => response.text()) : ''; + await beforeRequest(url, authenticated); + signal.throwIfAborted(); + return { ok: response.ok, text }; + } catch { + return { ok: false, text: '' }; + } + }; +} + +/** Open the consent window and resolve with the approved class IRIs (or [] on deny/timeout). */ +function openConsentAndAwait(consentId: string): Promise { + return new Promise((resolve) => { + let settled = false; + let timer: ReturnType | undefined; + const finish = (choice: ConsentChoice): void => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(choice); + }; + consentResolvers.set(consentId, finish); + timer = setTimeout(() => finish({ approved: [] }), CONSENT_TIMEOUT_MS); + const url = chrome.runtime.getURL( + `consent/consent.html?consentId=${encodeURIComponent(consentId)}`, + ); + try { + chrome.windows.create({ url, type: 'popup', width: 460, height: 640 }, () => { + if (chrome.runtime.lastError) finish({ approved: [] }); + }); + } catch { + finish({ approved: [] }); + } + }); +} + +async function handleRequestAccess( + message: RequestAccessRequest, + sender: MessageSender | undefined, +): Promise { + const requestId = message.requestId; + const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); + if (requestingOrigin === null) { + return { requestId, error: 'Could not verify the requesting origin.' }; + } + + // (residual c) Anti-spam gate — BEFORE any session work, class resolution (pod fetches), or the + // consent window: require a genuine user gesture (browser-attested via the ISOLATED content + // script's `navigator.userActivation`, page-unforgeable) AND rate-limit consecutive prompts per + // origin. A blocked call does NO pod fetch and opens NO consent window. + const throttleDecision = decideRequestAccess( + requestAccessThrottle.get(requestingOrigin), + Date.now(), + message.userActivated === true, + ); + requestAccessThrottle.set(requestingOrigin, throttleDecision.nextState); + if (!throttleDecision.allow) { + // `reason` is always present on a deny; fall back defensively. + const reason = throttleDecision.reason ?? 'rate-limited'; + return { requestId, error: REQUEST_ACCESS_DENY_MESSAGE[reason] }; + } + + const now = Date.now(); + const spec = normaliseAccessRequest(message, now); + if ('error' in spec) return { requestId, error: spec.error }; + const store = await getProfiles(); + const { profiles, defaultProfileId } = await store.list(); + const parent = await originGrantFor(requestingOrigin); + const consentId = crypto.randomUUID(); + const choices = new Map(); + // Each profile's targets are independently resolved and shown before approval. + const ordered = [...profiles].sort( + (a, b) => Number(b.id === defaultProfileId) - Number(a.id === defaultProfileId), + ); + for (const profile of ordered) { + try { + const choice = await withProfileOperation( + profile.id, + async (signal) => { + const ctx = await ensureSession(profile.id); + signal.throwIfAborted(); + if (!ctx) return null; + const checkAuthority = async () => { + await checkProfileSession(ctx, signal); + const latest = await originGrantFor(requestingOrigin); + signal.throwIfAborted(); + // Initial consent needs no parent. An existing binding must remain live and unchanged. + if (latest?.profileId !== parent?.profileId || latest?.createdAt !== parent?.createdAt) + throw new Error('Origin grant changed.'); + }; + const session = await buildFetchSession(ctx.session, ctx.keyPair, ctx.profileId); + const allowedOrigins = await podOriginsFor(ctx.session, ctx.profileId); + const resolved = new Map(); + for (const cls of spec.classes) { + const targets = await resolveClassTargets(cls, { + webId: ctx.session.webId, + fetch: makeResolveFetch( + { ...session, allowedOrigins }, + ctx.session.webId, + signal, + checkAuthority, + ), + authenticatedOrigins: allowedOrigins, + allowedOrigins, + }); + await checkAuthority(); + resolved.set(cls, { targets, shape: null }); + } + return { + ctx, + label: profile.label, + pending: buildPendingConsent( + consentId, + requestingOrigin, + ctx.session.webId, + spec, + resolved, + now, + ), + }; + }, + requestingOrigin, + ); + if (choice) choices.set(profile.id, choice); + } catch { + // A signed-out, replaced or revoked session cannot contribute a consent choice. + } + } + if (!choices.size) return { requestId, error: 'Not authenticated' }; + pendingConsents.set(consentId, choices); + let decision: ConsentChoice; + try { + decision = await openConsentAndAwait(consentId); + } finally { + pendingConsents.delete(consentId); + consentResolvers.delete(consentId); + } + const selected = decision.profileId ? choices.get(decision.profileId) : undefined; + if (!selected || decision.approved.length === 0) return { requestId, ok: false, grants: [] }; + const { ctx, pending } = selected; + const grants = consentDecisionToGrants(pending, decision.approved, () => crypto.randomUUID()); + if (!grants.length || spec.expiresAt <= Date.now()) return { requestId, ok: false, grants: [] }; + try { + await withProfileLifecycle(ctx.profileId, async () => { + const current = await store.session(ctx.profileId); + if (current?.session.dpopJkt !== ctx.session.dpopJkt) + throw new Error('Profile session changed.'); + const previous = await originGrantFor(requestingOrigin); + if (previous) cancelProfileOperations(previous.profileId); + await originGrants.update((all) => { + const current = liveOriginGrant(all, requestingOrigin, Date.now()); + const nested = + current?.profileId === ctx.profileId + ? [ + ...(current.scope.classes ?? []).map((child) => ({ + ...child, + // Fresh consent cannot extend an unrelated child's effective deadline. + expiresAt: Math.min(child.expiresAt, current.expiresAt ?? Infinity), + })), + ...grants, + ] + : grants; + return [ + ...all.filter((g) => g.origin !== requestingOrigin), + { + origin: requestingOrigin, + profileId: ctx.profileId, + createdAt: now, + expiresAt: spec.expiresAt, + scope: { + pods: [ + ...new Set(nested.flatMap((g) => g.targets.map((t) => new URL(t.url).origin))), + ], + classes: nested, + }, + }, + ]; + }); + if (previous) { + const old = await store.get(previous.profileId).catch(() => null); + if (old) await purgeOriginSlice(previous, old.profile.webId); + } + }); + await updateDefaultIconAndBroadcast(); + } catch { + return { requestId, error: 'Profile session changed.' }; + } + const latest = await originGrantFor(requestingOrigin); + if (latest?.profileId !== ctx.profileId) return { requestId, ok: false, grants: [] }; + return { requestId, ok: true, webId: ctx.session.webId, grants: grants.map(grantSummary) }; +} + +/** The consent window (extension chrome only) asks for the pending record to render. */ +function handleGetPendingConsent( + message: GetPendingConsentRequest, + sender: MessageSender | undefined, +): PendingConsentView | null { + if (!isExtensionContext(sender)) return null; // only the extension's own window may read it. + const choices = pendingConsents.get(message.consentId); + const profileId = message.profileId ?? choices?.keys().next().value; + const selected = profileId ? choices?.get(profileId) : undefined; + if (!selected) return null; + const pending = selected.pending; + + return { + consentId: pending.consentId, + origin: pending.origin, + webId: pending.webId, + profileId: selected.ctx.profileId, + profiles: [...(choices ?? [])].map(([id, value]) => ({ + id, + label: value.label, + webId: value.ctx.session.webId, + })), + classes: pending.classes.map((c) => ({ + dataClass: c.dataClass, + modes: c.modes, + purpose: c.purpose, + expiresAt: c.expiresAt, + targets: c.targets.map((t) => ({ url: t.url, kind: t.kind })), + shape: c.shape, + })), + }; +} + +/** The consent window's decision. ONLY the extension's own window may decide (fail-closed). */ +function handleConsentDecision( + message: ConsentDecisionMessage, + sender: MessageSender | undefined, +): void { + if (!isExtensionContext(sender)) return; + const resolver = consentResolvers.get(message.consentId); + if (!resolver) return; + consentResolvers.delete(message.consentId); + const choices = pendingConsents.get(message.consentId); + const selected = message.profileId ? choices?.get(message.profileId) : undefined; + const requested = new Set(selected?.pending.classes.map((c) => c.dataClass) ?? []); + const approved = Array.isArray(message.approvedClasses) + ? message.approvedClasses.filter((c) => requested.has(c)) + : []; + resolver({ approved, profileId: selected?.ctx.profileId }); +} + +async function handleListGrants( + message: ListGrantsRequest, + sender: MessageSender | undefined, +): Promise { + const requestId = message.requestId; + const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); + if (requestingOrigin === null) return { requestId, grants: [] }; + const parent = await originGrantFor(requestingOrigin); + if (!parent) return { requestId, grants: [] }; + try { + const result = await withProfileOperation( + parent.profileId, + async (signal) => { + const ctx = await ensureSession(parent.profileId); + signal.throwIfAborted(); + if (!ctx) return null; + await pruneAndPurgeExpiredGrants(ctx.profileId, Date.now()); + const latest = await checkOriginAuthority(parent, ctx, signal); + const mine = grantsForSession( + pruneExpiredGrants(latest.scope.classes ?? [], Date.now()), + requestingOrigin, + ctx.session.webId, + ); + return { requestId, grants: mine.map(grantSummary) }; + }, + requestingOrigin, + ); + return result ?? { requestId, grants: [] }; + } catch { + return { requestId, grants: [] }; + } +} + +async function handleRevokeGrant( + message: RevokeGrantRequest, + sender: MessageSender | undefined, +): Promise { + const requestId = message.requestId; + const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); + if (requestingOrigin === null) { + return { requestId, ok: false, error: 'Could not verify the requesting origin.' }; + } + const parent = await originGrantFor(requestingOrigin); + const ctx = parent ? await ensureSession(parent.profileId) : null; + if (!ctx) return { requestId, ok: false }; + const removed: ClassGrant[] = []; + let survivors: ClassGrant[] = []; + await originGrants.update((all) => + all.map((g) => { + if (g.origin !== requestingOrigin || g.profileId !== ctx.profileId || !g.scope.classes) + return g; + removed.push(...g.scope.classes.filter((c) => c.grantId === message.grantId)); + survivors = pruneExpiredGrants( + g.scope.classes.filter((c) => c.grantId !== message.grantId), + Date.now(), + ); + return { ...g, scope: { ...g.scope, classes: survivors } }; + }), + ); + if (removed.length > 0) cancelProfileOperations(ctx.profileId, requestingOrigin); + await purgeGrantSlices(removed, survivors); + return { requestId, ok: removed.length > 0 }; +} + +async function handleResolveClass( + message: ResolveClassRequest, + sender: MessageSender | undefined, +): Promise { + const requestId = message.requestId; + const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); + if (requestingOrigin === null) { + return { requestId, error: 'Could not verify the requesting origin.' }; + } + if (!isHttpIri(message.classIRI)) { + return { requestId, error: 'resolveClass: an https: class IRI is required.' }; + } + const parent = await originGrantFor(requestingOrigin); + if (!parent) return { requestId, error: 'Not authenticated' }; + + try { + const result = await withProfileOperation( + parent.profileId, + async (signal) => { + const ctx = await ensureSession(parent.profileId); + signal.throwIfAborted(); + if (!ctx) return { requestId, error: 'Not authenticated' }; + const checkGrant = async () => { + const current = await checkOriginAuthority(parent, ctx, signal); + if ( + current.scope.classes !== undefined && + !grantsForSession( + pruneExpiredGrants(current.scope.classes, Date.now()), + requestingOrigin, + ctx.session.webId, + ).some((g) => g.dataClass === message.classIRI) + ) + throw new Error('No access grant for this data class'); + return current; + }; + const current = await checkGrant(); + const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair, ctx.profileId); + const profilePods = await podOriginsFor(ctx.session, ctx.profileId); + const allowedOrigins = new Set( + [...profilePods].filter((pod) => current.scope.pods.includes(pod)), + ); + const targets = await resolveClassTargets(message.classIRI, { + webId: ctx.session.webId, + fetch: makeResolveFetch( + { ...fetchSession, allowedOrigins }, + ctx.session.webId, + signal, + async (url, authenticated) => { + const latest = await checkGrant(); + if (!authenticated) return; + const targetOrigin = originOf(url); + if ( + !targetOrigin || + !allowedOrigins.has(targetOrigin) || + !latest.scope.pods.includes(targetOrigin) + ) + throw new Error('Out of grant scope'); + }, + ), + authenticatedOrigins: allowedOrigins, + allowedOrigins, + }); + const latest = await checkGrant(); + return { + requestId, + shape: null, + targets: targets + .filter((t) => latest.scope.pods.includes(new URL(t.url).origin)) + .map((t) => ({ url: t.url, kind: t.kind })), + }; + }, + requestingOrigin, + ); + return result ?? { requestId, error: 'Discovery cancelled' }; + } catch (error) { + return { requestId, error: error instanceof Error ? error.message : 'Discovery failed' }; + } +} + +async function handleOriginGrants(message: OriginGrantRequest, sender: MessageSender | undefined) { + if (!isExtensionContext(sender)) + return { error: 'Origin grant management requires extension context.' }; + await syncOriginGrants(); + if (message.type !== 'SOLID_ORIGIN_GRANTS_LIST') { + if (!canonicalOrigin(message.origin)) return { error: 'Invalid origin.' }; + const grant = await originGrantFor(message.origin); + if (!grant) return { ok: false }; + if ( + message.type === 'SOLID_ORIGIN_GRANT_EXPIRY' && + (!Number.isFinite(message.expiresAt) || + message.expiresAt <= Date.now() || + (grant.expiresAt !== undefined && message.expiresAt > grant.expiresAt)) + ) + return { error: 'Expiry may only be shortened; request fresh consent to extend it.' }; + if (message.type === 'SOLID_ORIGIN_GRANT_PROFILE') { + const target = await (await getProfiles()).get(message.profileId); + if (target.profile.sessionState !== 'active') return { error: 'Profile is not active.' }; + cancelProfileOperations(message.profileId); + } + // Two managers can both pass the check above; recheck against the committed grant so + // a later, longer expiry can never overwrite an earlier shortening. + let extended = false; + await withProfileLifecycle(grant.profileId, async () => { + const shorten = (g: OriginGrant): OriginGrant => { + if ( + message.type !== 'SOLID_ORIGIN_GRANT_EXPIRY' || + (g.expiresAt !== undefined && message.expiresAt > g.expiresAt) + ) { + extended = true; + return g; + } + return { ...g, expiresAt: message.expiresAt }; + }; + await originGrants.update((all) => + message.type === 'SOLID_ORIGIN_GRANT_REVOKE' + ? all.filter((g) => g.origin !== message.origin) + : all.map((g) => + g.origin !== message.origin + ? g + : message.type === 'SOLID_ORIGIN_GRANT_PROFILE' + ? { + ...g, + profileId: message.profileId, + createdAt: Date.now(), + scope: { pods: [], classes: [] }, + } + : shorten(g), + ), + ); + if ( + message.type === 'SOLID_ORIGIN_GRANT_REVOKE' || + message.type === 'SOLID_ORIGIN_GRANT_PROFILE' + ) { + const record = await (await getProfiles()).get(grant.profileId); + await purgeOriginSlice(grant, record.profile.webId); + } + }); + if (extended) + return { error: 'Expiry may only be shortened; request fresh consent to extend it.' }; + await updateDefaultIconAndBroadcast(); + } + return { ok: true, grants: await originGrants.list() }; +} + +chrome.runtime.onMessage.addListener( + (message: WorkerRequest, sender, sendResponse: (r: unknown) => void) => { + const respond = (task: Promise) => { + task.then(sendResponse).catch(() => + sendResponse({ + ...('requestId' in message ? { requestId: message.requestId } : {}), + error: 'Profile storage unavailable.', + }), + ); + }; + switch (message.type) { + case 'SOLID_FORMFILL_REQUEST': + respond(formFill.request(message, sender)); + return true; + case 'SOLID_FORMFILL_PREVIEW': + case 'SOLID_FORMFILL_DECISION': + respond(formFill.preview(message, sender)); + return true; + case 'SOLID_FORMFILL_REPORT': + // Counts only; deliberately no persistent page/form telemetry. + sendResponse({ ok: true }); + return false; + case 'SOLID_ORIGIN_GRANTS_LIST': + case 'SOLID_ORIGIN_GRANT_REVOKE': + case 'SOLID_ORIGIN_GRANT_PROFILE': + case 'SOLID_ORIGIN_GRANT_EXPIRY': + respond(handleOriginGrants(message, sender)); + return true; + case 'SOLID_PROFILES_LIST': + case 'SOLID_PROFILE_ADD': + case 'SOLID_PROFILE_RENAME': + case 'SOLID_PROFILE_REMOVE': + case 'SOLID_PROFILE_SET_DEFAULT': + respond(handleProfiles(message, sender)); + return true; + case 'SOLID_FETCH_REQUEST': + // Pass the browser-ATTESTED sender (origin/url set by Chrome, not the page) so the + // per-requesting-origin gate can cross-check it against the page-supplied stamp. + respond(handleFetch(message, sender)); + return true; + case 'SOLID_LOGIN': + respond(handleLogin(message, sender)); + return true; + case 'SOLID_LOGOUT': + teardownSession(sender, message.stampedOrigin) + .then(() => sendResponse({ ok: true })) + .catch((err) => sendResponse({ error: String(err) })); + return true; + case 'SOLID_GET_STATE': + respond(handleGetState(message, sender)); + return true; + case 'SOLID_SET_CLIENT_ID': + respond(handleSetClientId(message, sender)); + return true; + case 'SOLID_REQUEST_ACCESS': + respond(handleRequestAccess(message, sender)); + return true; + case 'SOLID_LIST_GRANTS': + respond(handleListGrants(message, sender)); + return true; + case 'SOLID_REVOKE_GRANT': + respond(handleRevokeGrant(message, sender)); + return true; + case 'SOLID_RESOLVE_CLASS': + respond(handleResolveClass(message, sender)); + return true; + case 'SOLID_GET_PENDING_CONSENT': + sendResponse(handleGetPendingConsent(message, sender)); + return false; + case 'SOLID_CONSENT_DECISION': + handleConsentDecision(message, sender); + sendResponse({ ok: true }); + return false; + default: + return false; + } + }, +); + +const formFill = new FormFillWorker({ + profiles: async () => (await getProfiles()).list(), + load: async (profileId) => { + const result = await withProfileOperation(profileId, async (signal) => { + const ctx = await ensureSession(profileId); + if (!ctx) throw new Error('Please sign in to this profile.'); + const current = () => checkProfileSession(ctx, signal); + const pods = await podOriginsFor(ctx.session, profileId); + const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair, profileId); + const read = makeResolveFetch( + { ...fetchSession, allowedOrigins: pods }, + ctx.session.webId, + signal, + current, + ); + const data = await loadFormFillData(ctx.session.webId, pods, read); + await current(); + return { data, current }; + }); + if (!result) throw new Error('Profile is no longer available.'); + return result; + }, + commit: withProfileTask, +}); + +/** First-run onboarding: prompt the user to PIN the toolbar action (extensions can't self-pin). */ +chrome.runtime.onInstalled.addListener((details) => { + if (details.reason === 'install') { + chrome.storage.local.set({ 'solid:show-pin-nudge': true }).catch(() => {}); + } + // Remove the legacy all-origins registration and restore only current explicit grants. + void syncOriginGrants().catch(() => {}); +}); + +// Wire the persistent side panel: a right-click "Open Solid side panel" action-menu entry +// + its open handler. The toolbar action keeps its existing popup unchanged. +registerSidePanel(); +registerFormFillMenu(); +// Local settings/grants are worker-owned; content scripts only receive explicit responses. +void chrome.storage.local.setAccessLevel?.({ accessLevel: 'TRUSTED_CONTEXTS' }).catch(() => {}); + +// Re-hydrate the session + icon on every SW wake. +void updateDefaultIconAndBroadcast().catch(() => {}); + +chrome.alarms?.onAlarm.addListener((alarm) => { + if (alarm.name === 'solid-origin-expiry') void updateDefaultIconAndBroadcast().catch(() => {}); +}); diff --git a/apps/extension/src/background/session-store.ts b/apps/extension/src/background/session-store.ts new file mode 100644 index 000000000..e6e143d27 --- /dev/null +++ b/apps/extension/src/background/session-store.ts @@ -0,0 +1,353 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * Legacy session migration plus extension settings in `chrome.storage.local`. + * New sessions, keys, grants and pod settings belong to ProfileStore in IndexedDB; + * the singleton session/grant helpers below remain for migration and compatibility tests. + * + * What lives here, and why this store and not the page: + * - The active session (access + refresh token, the DPoP keypair as JWK, issuer/token + * endpoint, expiry). `chrome.storage.local` is reachable ONLY from the extension's own + * contexts (service worker / popup) — NEVER from a web page or the injected MAIN-world + * script. The refresh token therefore stays page-unreachable, satisfying the suite's + * "DPoP refresh token in extension-appropriate secure storage (not page-reachable)" + * invariant. The MAIN-world `window.solid` only ever sees the WebID + a proxied fetch. + * - The display profile (name + avatar) for the popup + the action icon. + * - The credential-free recent-accounts list (WebID + display, survives logout) for the + * returning-user affordance — the suite's recent-accounts UX. + * - Per-origin client-id mappings + extra pod origins (the credential boundary inputs). + * + * The refresh token is never logged; only the WebID/display is ever surfaced to a page. + */ + +import { type ClassGrant, pruneExpiredGrants, sanitiseGrants } from './core/class-grant'; + +const KEYS = { + session: 'solid:session', + profile: 'solid:profile', + recentAccounts: 'solid:recent-accounts', + clientIds: 'solid:client-ids', + clientIdMode: 'solid:client-id-mode', + podOrigins: 'solid:pod-origins', + grantedOrigins: 'solid:granted-origins', + classGrants: 'solid:class-grants', + authParams: 'solid:auth-params', +} as const; + +/** + * The persisted active session. The DPoP keypair is NOT stored here — it lives as a + * non-extractable `CryptoKeyPair` in IndexedDB (`core/dpop-key-db.ts` / `dpop-key-store.ts`), + * never as JWK material in `chrome.storage.local`. See {@link loadSession} for the one-time + * migration off the pre-hardening shape, which DID carry an inline extractable JWK pair. + */ +export interface StoredSession { + readonly webId: string; + readonly accessToken: string; + readonly refreshToken: string | null; + /** + * RFC 7638 thumbprint of the DPoP public key this session's tokens are bound to, recorded + * at mint time. Absent ONLY for a session created before this hardening shipped (the + * pre-hardening shape never tracked it) — `dpop-key-store.ts` `loadSessionDpopKeyPair` uses + * its absence as the one-time legacy-migration signal, and its presence to detect an + * unexpected key loss/replacement rather than silently using the wrong key. + */ + readonly dpopJkt?: string; + readonly issuer: string; + readonly tokenEndpoint: string; + readonly clientId: string; + readonly expiresAt: number; +} + +/** The credential-free display profile shown in the popup + the action icon. */ +export interface StoredProfile { + readonly webId: string; + readonly name: string | null; + readonly photoUrl: string | null; +} + +/** A previously-used account, for the returning-user affordance (no credential). */ +export interface RecentAccount { + readonly webId: string; + readonly name: string | null; + readonly photoUrl: string | null; +} + +async function get(key: string): Promise { + const result = await chrome.storage.local.get(key); + return (result[key] as T) ?? null; +} + +async function set(key: string, value: unknown): Promise { + await chrome.storage.local.set({ [key]: value }); +} + +// --- Active session ------------------------------------------------------------------- + +/** + * The pre-hardening on-disk session shape: the DPoP private key stored inline as an + * **extractable** JWK pair. Only used here, to DETECT + strip a legacy record — the value + * itself is never read for its key material (we don't import it; see `dpop-key-store.ts` + * `loadOrRotateDpopKeyPair` for why a fresh key is minted instead). + */ +interface LegacySessionShape { + readonly dpopKeyPair?: unknown; +} + +/** + * Load the active session, migrating a pre-hardening record on the way out: if the raw + * stored value still carries the legacy inline `dpopKeyPair` (an extractable JWK pair), it + * is stripped and the cleaned record is written back immediately — so extractable private + * key material never lingers in `chrome.storage.local` beyond the very first read after this + * change ships. `service-worker.ts#ensureSession` detects the resulting absence of a + * persisted IndexedDB key and mints a replacement (see `dpop-key-store.ts`). + */ +export async function loadSession(): Promise { + const raw = await get(KEYS.session); + if (!raw) return null; + if ('dpopKeyPair' in raw) { + const { dpopKeyPair: _legacyExtractableKey, ...clean } = raw; + await set(KEYS.session, clean); + return clean; + } + return raw; +} + +export function saveSession(session: StoredSession): Promise { + return set(KEYS.session, session); +} + +export async function clearSession(): Promise { + await chrome.storage.local.remove(KEYS.session); +} + +// --- Display profile + recent accounts ------------------------------------------------ + +export function loadProfile(): Promise { + return get(KEYS.profile); +} + +export async function saveProfile(profile: StoredProfile): Promise { + await set(KEYS.profile, profile); + await rememberAccount(profile); +} + +export async function clearProfile(): Promise { + await chrome.storage.local.remove(KEYS.profile); +} + +/** The recent-accounts list (most-recent-first, deduped by WebID). Survives logout. */ +export async function loadRecentAccounts(): Promise { + return (await get(KEYS.recentAccounts)) ?? []; +} + +/** Promote/insert an account at the front of the recent list (logout-surviving memory). */ +export async function rememberAccount(profile: StoredProfile): Promise { + const list = await loadRecentAccounts(); + const next: RecentAccount[] = [ + { webId: profile.webId, name: profile.name, photoUrl: profile.photoUrl }, + ...list.filter((a) => a.webId !== profile.webId), + ].slice(0, 8); + await set(KEYS.recentAccounts, next); +} + +// --- Per-origin client IDs + pod origins (credential-boundary inputs) ------------------ + +export type ClientIdMode = 'document' | 'dcr'; + +/** Keep login working until Jesse enables Pages (DESIGN §2 D2). */ +export const DEFAULT_CLIENT_ID_MODE: ClientIdMode = 'dcr'; + +/** Only extension contexts configure this setting; page messages cannot override it. */ +export async function loadClientIdMode(): Promise { + const result = await chrome.storage.local.get(KEYS.clientIdMode); + const mode: unknown = result[KEYS.clientIdMode]; + if (mode === undefined) return DEFAULT_CLIENT_ID_MODE; + if (mode === 'document' || mode === 'dcr') return mode; + throw new Error('Invalid clientIdMode setting; expected document or dcr.'); +} + +export async function loadClientIds(): Promise> { + return (await get>(KEYS.clientIds)) ?? {}; +} + +export async function saveClientId(origin: string, clientId: string): Promise { + const map = await loadClientIds(); + map[origin] = clientId; + await set(KEYS.clientIds, map); +} + +/** User-configured pod origins (pods served from a host other than the WebID origin). */ +export async function loadPodOrigins(): Promise { + return (await get(KEYS.podOrigins)) ?? []; +} + +export async function savePodOrigins(origins: string[]): Promise { + await set(KEYS.podOrigins, origins); +} + +// --- Granted requesting origins (the per-REQUESTING-origin access gate) ---------------- +// +// The set of APP origins the owner has authorised to drive `window.solid.fetch`. This is +// the Phase-0 grant store: default-deny means an origin not in this set (and not a +// credential/pod origin) cannot read or write the pod through the extension. The owner +// opts an app in by a deliberate, browser-attested action FROM that app's origin (login / +// setClientId); the extension's own popup/side-panel flows do not add a web origin. +// +// NOTE: this store holds only origin strings (no credential). It is the single-user +// owner-only grant model; the per-data-class grant store (tt6) is a later phase. + +/** The app origins explicitly granted access (most-recent-first not required; a set). */ +export async function loadGrantedOrigins(): Promise { + return (await get(KEYS.grantedOrigins)) ?? []; +} + +/** + * Grant an app origin access (idempotent). Only a well-formed http(s) origin is stored; + * an opaque/invalid value is ignored (fail-closed — we never persist a "null" grant). + */ +export async function grantOrigin(origin: string): Promise { + let canonical: string; + try { + const u = new URL(origin); + if (u.protocol !== 'https:' && u.protocol !== 'http:') return; + canonical = u.origin; + if (canonical === 'null' || canonical === '') return; + } catch { + return; + } + const list = await loadGrantedOrigins(); + if (list.includes(canonical)) return; + await set(KEYS.grantedOrigins, [...list, canonical]); +} + +/** Revoke an app origin's access. */ +export async function revokeOrigin(origin: string): Promise { + const list = await loadGrantedOrigins(); + const next = list.filter((o) => o !== origin); + if (next.length !== list.length) await set(KEYS.grantedOrigins, next); +} + +// --- Per-data-class grants (the Phase-2 grant store, design §4) ------------------------ +// +// The NARROWER, class-scoped capability layered ON TOP of the per-origin grant above: a grant +// keyed by (origin, dataClass, modes, purpose, expiry) whose enforceable substance is a PINNED, +// resolved target set (class-grant.ts / class-resolve.ts). Stored as a plain array; every read +// runs `sanitiseGrants` so a corrupt/tampered record authorises nothing (fail-closed). A grant +// is bound to the WebID it was minted under (the session boundary) — see ClassGrant.webId. + +/** + * A single-writer SERIALISER for the class-grant array. `chrome.storage.local` offers no + * compare-and-set, and every mutation is a read-modify-write of ONE array; without + * serialisation two interleaved async mutations race — e.g. a `saveClassGrant` that read the + * array BEFORE a concurrent `revokeClassGrant` would write back a stale array that RESURRECTS + * the just-revoked grant, breaking the "revocation effective next request" invariant. This + * promise-chain mutex makes every read-modify-write critical section run to completion before + * the next starts, so each re-reads the freshest array. (The SW event loop is single-threaded, + * but `await`s still interleave — this closes that window.) + */ +let classGrantMutex: Promise = Promise.resolve(); +function withClassGrantLock(fn: () => Promise): Promise { + const run = classGrantMutex.then(fn, fn); + // Keep the chain alive regardless of this mutation's outcome (never reject the shared chain). + classGrantMutex = run.then( + () => undefined, + () => undefined, + ); + return run; +} + +/** Load every stored class grant, dropping any structurally-invalid (tampered) record. */ +export async function loadClassGrants(): Promise { + return sanitiseGrants(await get(KEYS.classGrants)); +} + +/** Persist a new class grant (append). The caller mints a unique `grantId`. Serialised. */ +export function saveClassGrant(grant: ClassGrant): Promise { + return withClassGrantLock(async () => { + const list = await loadClassGrants(); + await set(KEYS.classGrants, [...list, grant]); + }); +} + +/** + * Revoke a class grant by id, but ONLY when it belongs to `origin` (a page may revoke its OWN + * grants, never another origin's). Removes EVERY row matching `(grantId, origin)` — so a store that + * somehow holds a duplicate id can never leave a matching grant live after a "successful" revoke — + * and returns ALL removed grants (so the caller can purge each one's replica slice — residual b). + * An empty array ⇒ nothing matched. Effect is immediate on the NEXT request — the SW re-reads the + * store per request (no in-memory grant cache), so a removed grant is gone from the very next gate + * decision (design item 3 — propagation bound). Serialised against the other mutations so a + * concurrent save cannot resurrect the revoked grant. + * + * FAIL-CLOSED ORDERING: this REMOVAL is the authoritative deny. The caller purges the revoked + * grant's replica bytes AFTER (and best-effort) — so a byte-purge failure can never leave access + * live; it only means the (now un-servable, grant-less) bytes linger until logout. + */ +export function revokeClassGrant(grantId: string, origin: string): Promise { + return withClassGrantLock(async () => { + const list = await loadClassGrants(); + const matches = (g: ClassGrant): boolean => g.grantId === grantId && g.origin === origin; + const removed = list.filter(matches); + if (removed.length === 0) return []; + await set( + KEYS.classGrants, + list.filter((g) => !matches(g)), + ); + return removed; + }); +} + +/** + * Prune expired grants from the store at `now`, persisting the pruned list only when it + * changed. Returns BOTH the LIVE grants and the EXPIRED ones removed (so the caller can purge each + * expired grant's replica slice — access-management residual b). Called opportunistically on the + * fetch path so an expired grant is denied (by the gate), garbage-collected (design item 3), AND + * its replicated bytes purged. Serialised so a prune cannot clobber a concurrent save/revoke (it + * re-reads inside the critical section). + */ +export function pruneExpiredClassGrants( + now: number, +): Promise<{ live: ClassGrant[]; expired: ClassGrant[] }> { + return withClassGrantLock(async () => { + const list = await loadClassGrants(); + const live = pruneExpiredGrants(list, now); + if (live.length !== list.length) await set(KEYS.classGrants, live); + const liveSet = new Set(live); + const expired = list.filter((g) => !liveSet.has(g)); + return { live, expired }; + }); +} + +/** + * Drop ALL class grants (called on logout — grants are bound to the departing session). + * Serialised through the SAME mutex as the other mutations so a logout cannot interleave a + * concurrent `saveClassGrant`/`pruneExpiredClassGrants` read-modify-write and leave a grant + * persisted after sign-out (roborev round-2 Medium). + */ +export function clearClassGrants(): Promise { + return withClassGrantLock(async () => { + await chrome.storage.local.remove(KEYS.classGrants); + }); +} + +// --- Transient auth params (PKCE/state across the redirect) ---------------------------- + +export interface AuthParams { + readonly codeVerifier: string; + readonly state: string; + readonly clientId: string; + readonly issuer: string; + readonly tokenEndpoint: string; + readonly redirectUri: string; +} + +export function loadAuthParams(): Promise { + return get(KEYS.authParams); +} + +export function saveAuthParams(params: AuthParams): Promise { + return set(KEYS.authParams, params); +} + +export async function clearAuthParams(): Promise { + await chrome.storage.local.remove(KEYS.authParams); +} diff --git a/apps/extension/src/background/side-panel.ts b/apps/extension/src/background/side-panel.ts new file mode 100644 index 000000000..973c332ae --- /dev/null +++ b/apps/extension/src/background/side-panel.ts @@ -0,0 +1,90 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * Side-panel wiring for the service worker. The toolbar ACTION keeps its existing popup + * (clicking the icon opens the ephemeral popup, unchanged). The PERSISTENT side panel — + * which Chrome keeps open as the user navigates — is opened on demand via a right-click + * context-menu entry on the action icon ("Open Solid side panel"). + * + * Why a context menu and not `setPanelBehavior({ openPanelOnActionClick: true })`: the + * action already declares a `default_popup`, and Chrome ignores open-on-action-click when + * a popup is set — the popup wins. A context-menu entry lets BOTH surfaces coexist: a + * left-click → popup (quick glance), a right-click → "Open Solid side panel" (persistent). + * + * `chrome.sidePanel.open()` must be called in the same turn as the user gesture (the + * contextMenus.onClicked handler is a valid gesture), so we open it directly there. + * + * All chrome.* calls are guarded: `chrome.sidePanel` / `chrome.contextMenus` may be absent + * on older Chromium or under test, so this module is a no-op there and never throws. + */ + +const OPEN_SIDE_PANEL_MENU_ID = 'solid-open-side-panel'; + +/** + * Remove ONLY our own side-panel menu entry (if present), ignoring the "item not found" + * error so first-create is fine. We must NOT use `contextMenus.removeAll()` here: that would + * delete EVERY context-menu item the extension owns, clobbering any other/future menu. + */ +function removeSidePanelMenu(): Promise { + return new Promise((resolve) => { + if (!chrome.contextMenus?.remove) { + resolve(); + return; + } + // The callback form lets us swallow chrome.runtime.lastError (the "Cannot find menu item" + // error on first run / after a SW restart cleared the registry) without it surfacing. + chrome.contextMenus.remove(OPEN_SIDE_PANEL_MENU_ID, () => { + void chrome.runtime?.lastError; // read-and-discard so it isn't reported + resolve(); + }); + }); +} + +/** Create the "Open Solid side panel" context-menu entry on the action icon. */ +async function createSidePanelMenu(): Promise { + if (!chrome.contextMenus?.create) return; + try { + // Remove-OUR-id-then-create is idempotent across SW restarts / reinstalls (a duplicate id + // throws) WITHOUT clobbering any other menu item — removeAll() would regress those. + await removeSidePanelMenu(); + chrome.contextMenus.create({ + id: OPEN_SIDE_PANEL_MENU_ID, + title: 'Open Solid side panel', + contexts: ['action'], + }); + } catch { + // Menus are a convenience affordance — never let a failure break the worker. + } +} + +/** + * Register the side-panel affordances. Call once at service-worker load. Wires: + * - the action-icon context-menu entry (created on install AND on every wake, since the + * in-memory menu registry is cleared when the MV3 worker is torn down), and + * - the click handler that opens the side panel for the active tab. + */ +export function registerSidePanel(): void { + if (chrome.runtime?.onInstalled?.addListener) { + chrome.runtime.onInstalled.addListener(() => { + void createSidePanelMenu(); + }); + } + // Re-create on wake too: a killed MV3 worker loses its context-menu registry. + void createSidePanelMenu(); + + if (chrome.contextMenus?.onClicked?.addListener) { + chrome.contextMenus.onClicked.addListener((info, tab) => { + if (info.menuItemId !== OPEN_SIDE_PANEL_MENU_ID) return; + if (!chrome.sidePanel?.open) return; + // Open the panel in the same user-gesture turn. Prefer the clicked tab's window; + // fall back to the tab id. Swallow errors (e.g. no window) rather than throw. + const windowId = tab?.windowId; + const opener = + windowId !== undefined + ? chrome.sidePanel.open({ windowId }) + : tab?.id !== undefined + ? chrome.sidePanel.open({ tabId: tab.id }) + : undefined; + opener?.catch?.(() => {}); + }); + } +} diff --git a/apps/extension/src/consent/consent.css b/apps/extension/src/consent/consent.css new file mode 100644 index 000000000..394d47b0f --- /dev/null +++ b/apps/extension/src/consent/consent.css @@ -0,0 +1,38 @@ +/* AUTHORED-BY Codex GPT-6.1-Sol */ +@import url('../shared/permission-surface.css'); +.consent-sheet .classes { display: grid; gap: var(--space-3); margin-top: var(--space-3); } +.consent-sheet .class-head { align-items: flex-start; } +.consent-sheet .class-head > span { min-width: 0; } +.consent-sheet .chips { display: flex; flex-wrap: wrap; gap: var(--space-1\.5); margin-top: var(--space-3); } +.consent-sheet .chip { + border-radius: var(--radius-sm); + padding: var(--space-0\.5) var(--space-2); + background: var(--primary-soft); + color: var(--primary-soft-foreground); + font-size: var(--text-xs); + line-height: var(--lh-xs); + font-weight: var(--weight-xs); +} +.consent-sheet .meta { margin-bottom: 0; } +.consent-sheet .targets { margin-bottom: 0; padding-left: var(--space-5); } +.consent-sheet .targets li { overflow-wrap: anywhere; } +.consent-sheet .scope-details { margin-top: var(--space-3); } +.consent-sheet summary { cursor: pointer; color: var(--primary); min-height: var(--space-6); } +.consent-sheet .duration-chips { min-width: 0; margin: var(--space-5) 0 0; padding: 0; border: 0; } +.consent-sheet .duration-chips legend { font-weight: var(--weight-lg); margin-bottom: var(--space-2); } +.consent-sheet .duration-chip { + display: inline-flex; + align-items: center; + gap: var(--space-2); + max-width: 100%; + min-height: var(--control-h, 36px); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--input); + border-radius: var(--radius-sm); + background: var(--primary-soft); + color: var(--primary-soft-foreground); +} +.consent-sheet #account-switcher { margin-block: var(--space-4); } +@media (pointer: coarse) { + .consent-sheet .duration-chip, .consent-sheet summary { min-height: 44px; } +} diff --git a/apps/extension/src/consent/consent.html b/apps/extension/src/consent/consent.html new file mode 100644 index 000000000..2831b97d0 --- /dev/null +++ b/apps/extension/src/consent/consent.html @@ -0,0 +1,46 @@ + + + + + + Solid — Access request + + + + + + Skip to request +
+

Access request

+

An app requests access to:

+

Share from your account:

+ + + + +
+ + + +
+ Access duration + +
+

The site sets this request's end date. You can shorten access in Sites with access after sharing.

+

Choose at least one data type to share.

+ +
+ + +
+ + +
+ + + diff --git a/apps/extension/src/consent/consent.ts b/apps/extension/src/consent/consent.ts new file mode 100644 index 000000000..9f788adb6 --- /dev/null +++ b/apps/extension/src/consent/consent.ts @@ -0,0 +1,219 @@ +// AUTHORED-BY Claude Opus 4.8 +/** Extension-owned ConsentSheet. Untrusted request copy is always rendered as text. */ +import { applyResolvedTheme, readStoredTheme, resolveTheme } from '@jeswr/solid-elements'; +import { accessModeLabel, dataClassCopy } from '../shared/data-labels'; +import type { ConsentDecisionMessage, PendingConsentView } from '../shared/messages'; + +function fmtExpiry(ms: number): string { + return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(ms); +} + +function el( + tag: K, + opts: { class?: string; text?: string; id?: string } = {}, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (opts.class) node.className = opts.class; + if (opts.text !== undefined) node.textContent = opts.text; + if (opts.id) node.id = opts.id; + return node; +} + +/** Visible scope summary; a folder grant covers everything inside it, so say so. */ +function scopeSummary(targets: PendingConsentView['classes'][number]['targets']): string { + const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; + const folders = targets.filter((t) => t.kind === 'container').length; + const items = targets.length - folders; + const parts: string[] = []; + if (folders) + parts.push( + `${plural(folders, 'folder', 'folders')} and ${folders === 1 ? 'its' : 'their'} contents`, + ); + if (items || !folders) parts.push(plural(items, 'item', 'items')); + return parts.join(' and '); +} + +function renderClasses(view: PendingConsentView): Map { + const container = document.getElementById('classes')!; + const boxes = new Map(); + container.replaceChildren(); + for (const [index, c] of view.classes.entries()) { + const copy = dataClassCopy(c.dataClass); + const item = el('article', { class: 'class-item data-card' }); + const titleId = `class-title-${index}`; + const descriptionId = `class-description-${index}`; + item.setAttribute('aria-labelledby', titleId); + const head = el('label', { class: 'class-head card-check' }); + const box = el('input'); + box.type = 'checkbox'; + box.checked = c.targets.length > 0; + box.setAttribute('aria-labelledby', titleId); + box.setAttribute('aria-describedby', descriptionId); + boxes.set(c.dataClass, box); + const titleWrap = el('span'); + titleWrap.append( + el('span', { class: 'class-title card-title', text: copy.label, id: titleId }), + el('span', { + class: 'card-description hint', + text: copy.description, + id: descriptionId, + }), + ); + head.append(box, titleWrap); + const chips = el('div', { class: 'chips' }); + for (const mode of c.modes) + chips.append(el('span', { class: 'chip', text: accessModeLabel(mode) })); + const purpose = c.purpose ? `For: ${c.purpose} · ` : ''; + item.append( + head, + chips, + el('p', { + class: 'meta', + text: `${purpose}${scopeSummary(c.targets)} · Ends ${fmtExpiry(c.expiresAt)}`, + }), + ); + if (c.targets.length > 0) { + const scope = el('details', { class: 'scope-details' }); + scope.append(el('summary', { text: 'See included items' })); + const list = el('ul', { class: 'targets' }); + for (const target of c.targets) { + list.append( + el('li', { + text: `${target.url}${target.kind === 'container' ? ' (folder and its contents)' : ''}`, + class: target.kind === 'container' ? 'container-target' : undefined, + }), + ); + } + scope.append(list); + item.append(scope); + } + container.append(item); + } + document.getElementById('empty-note')!.hidden = view.classes.some((c) => c.targets.length > 0); + return boxes; +} + +function renderRequest(view: PendingConsentView): void { + document.getElementById('origin')!.textContent = view.origin; + const purposes = [...new Set(view.classes.map((c) => c.purpose?.trim()).filter(Boolean))]; + document.getElementById('purpose-headline')!.textContent = purposes.length + ? `To ${purposes.join('; ')}, ` + : ''; + document.getElementById('webid')!.textContent = + view.profiles.find((profile) => profile.id === view.profileId)?.label ?? 'Selected account'; + // The existing decision protocol cannot change an expiry. Show only the worker's + // requested end date; never suggest a duration choice that would not be honoured. + const expiries = [...new Set(view.classes.map((c) => c.expiresAt))]; + document.getElementById('duration-label')!.textContent = + expiries.length === 1 ? `Until ${fmtExpiry(expiries[0])}` : 'End dates shown on each card'; +} + +async function main(): Promise { + applyResolvedTheme(resolveTheme(readStoredTheme() ?? 'system')); + const consentId = new URLSearchParams(window.location.search).get('consentId'); + const approve = document.getElementById('approve') as HTMLButtonElement; + const picker = document.getElementById('profile') as HTMLSelectElement; + const selectAll = document.getElementById('select-all') as HTMLInputElement; + const container = document.getElementById('classes')!; + const error = document.getElementById('error')!; + let boxes = new Map(); + let selectedProfileId: string | undefined; + let loading = true; + let decided = false; + let version = 0; + const updateSelection = () => { + const checked = [...boxes.values()].filter((box) => box.checked).length; + approve.disabled = loading || decided || checked === 0; + selectAll.disabled = loading || decided || boxes.size === 0; + selectAll.checked = boxes.size > 0 && checked === boxes.size; + selectAll.indeterminate = checked > 0 && checked < boxes.size; + document.getElementById('selection-hint')!.textContent = checked + ? `${checked} ${checked === 1 ? 'data type' : 'data types'} selected. Only checked data will be shared.` + : 'Choose at least one data type to share.'; + }; + const showError = (message: string) => { + error.textContent = message; + error.hidden = false; + loading = true; + updateSelection(); + }; + const decide = (approvedClasses: string[]) => { + if (!consentId || decided) return; + decided = true; + updateSelection(); + const message: ConsentDecisionMessage = { + type: 'SOLID_CONSENT_DECISION', + consentId, + approvedClasses, + // Retain the existing denial payload; only an approval needs an account binding. + ...(approvedClasses.length ? { profileId: selectedProfileId } : {}), + }; + chrome.runtime.sendMessage(message, () => window.close()); + }; + document.getElementById('deny')!.addEventListener('click', () => decide([])); + approve.addEventListener('click', () => { + if (!approve.disabled) + decide([...boxes].filter(([, box]) => box.checked).map(([dataClass]) => dataClass)); + }); + container.addEventListener('change', updateSelection); + selectAll.addEventListener('change', () => { + for (const box of boxes.values()) box.checked = selectAll.checked; + updateSelection(); + }); + // Closing the trusted window still denies the request, without overwriting an approval. + window.addEventListener('pagehide', () => { + try { + decide([]); + } catch { + // Best effort when extension chrome is being destroyed. + } + }); + if (!consentId) { + showError('Missing consent request id.'); + return; + } + async function load(profileId?: string): Promise { + const generation = ++version; + loading = true; + error.hidden = true; + error.textContent = ''; + container.setAttribute('aria-busy', 'true'); + updateSelection(); + try { + const view = (await chrome.runtime.sendMessage({ + type: 'SOLID_GET_PENDING_CONSENT', + consentId, + ...(profileId ? { profileId } : {}), + })) as PendingConsentView | null; + if (generation !== version || decided) return; + if (!view) { + showError('This access request has expired or the account is no longer available.'); + return; + } + selectedProfileId = view.profileId; + renderRequest(view); + boxes = renderClasses(view); + picker.replaceChildren( + ...view.profiles.map((profile) => { + const option = el('option', { text: profile.label }); + option.value = profile.id; + return option; + }), + ); + picker.value = selectedProfileId; + // The worker includes only active accounts in this request's choices. + document.getElementById('account-switcher')!.hidden = view.profiles.length <= 1; + picker.disabled = view.profiles.length <= 1; + loading = false; + updateSelection(); + } catch { + if (generation === version && !decided) showError('Could not load the selected account.'); + } finally { + if (generation === version) container.setAttribute('aria-busy', 'false'); + } + } + picker.addEventListener('change', () => void load(picker.value)); + await load(); +} + +void main(); diff --git a/apps/extension/src/content/content-script.ts b/apps/extension/src/content/content-script.ts new file mode 100644 index 000000000..21b8a6f83 --- /dev/null +++ b/apps/extension/src/content/content-script.ts @@ -0,0 +1,203 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The ISOLATED-world bridge between the page (MAIN-world `window.solid`) and the service + * worker. It is the trust boundary: the page can only ASK; this script decides what to + * forward and stamps the page's REAL origin (read here, not page-supplied) onto + * origin-sensitive messages (login, set-client-id) so a page cannot impersonate another + * origin's client-id mapping. + */ + +import { installFormFill } from './formfill-controller'; + +const CHANNEL = 'solid-browser-ext'; + +interface PageMessage { + channel: string; + dir: string; + type: string; + requestId?: string; + actionId?: string; + url?: string; + method?: string; + headers?: Record; + body?: string | null; + webId?: string; + clientId?: string; + autoDivert?: boolean; + // Access-management (Phase 2) payload fields. + classes?: string[]; + modes?: string[]; + purpose?: string; + duration?: string; + classIRI?: string; + grantId?: string; +} + +function toPage(message: Record): void { + window.postMessage({ channel: CHANNEL, dir: 'to-page', ...message }, window.location.origin); +} + +window.addEventListener('message', (event) => { + // Only same-window, our-channel, page->content messages. + if (event.source !== window) return; + const data = event.data as PageMessage | undefined; + if (!data || data.channel !== CHANNEL || data.dir !== 'to-content') return; + + switch (data.type) { + case 'SOLID_FETCH_REQUEST': + chrome.runtime.sendMessage( + { + type: 'SOLID_FETCH_REQUEST', + requestId: data.requestId, + url: data.url, + method: data.method, + headers: data.headers ?? {}, + body: data.body ?? null, + // Forward whether this was the best-effort global-fetch patch (autoDivert) vs an + // explicit window.solid.fetch — drives the SW's native-passthrough-vs-403 choice + // (High #1). It is a behaviour HINT, never a security input: the SW still runs the + // full origin gate; autoDivert only softens a DENY to a plain unauthenticated fetch. + autoDivert: data.autoDivert === true, + // Stamp the REAL page origin (read HERE in the ISOLATED world, not page-supplied) + // so the worker's per-requesting-origin gate can cross-check it against the + // browser-attested sender.origin. The worker trusts the browser value, not this. + stampedOrigin: window.location.origin, + }, + (response) => { + // Relay the whole worker response verbatim, INCLUDING the `passthrough` sentinel + // flag (round-2 High #1): when the SW declines an autoDivert request without any + // network, `response.passthrough === true` rides through here so the MAIN-world + // inject can complete the request with the page's OWN native fetch (PRISTINE_FETCH). + toPage({ type: 'SOLID_FETCH_RESPONSE', ...(response ?? { error: 'No response' }) }); + }, + ); + break; + + case 'SOLID_GET_STATE': + chrome.runtime.sendMessage( + { type: 'SOLID_GET_STATE', stampedOrigin: window.location.origin }, + (response) => { + toPage({ type: 'SOLID_STATE_UPDATE', webId: response?.webId ?? null }); + }, + ); + break; + + case 'SOLID_SET_CLIENT_ID': + // Stamp the REAL page origin; the page cannot set it for another origin. + chrome.runtime.sendMessage({ + type: 'SOLID_SET_CLIENT_ID', + origin: window.location.origin, + clientId: data.clientId, + }); + break; + + case 'SOLID_LOGIN': + chrome.runtime.sendMessage( + { + type: 'SOLID_LOGIN', + webId: data.webId, + origin: window.location.origin, + // The page-declared client-id is carried directly (avoids racing the + // fire-and-forget setClientId storage write). The worker still validates it. + clientId: data.clientId, + }, + (response) => { + toPage({ + type: 'SOLID_ACTION_RESPONSE', + actionId: data.actionId, + error: response?.error ?? null, + }); + }, + ); + break; + + case 'SOLID_LOGOUT': + chrome.runtime.sendMessage( + { type: 'SOLID_LOGOUT', stampedOrigin: window.location.origin }, + (response) => { + toPage({ + type: 'SOLID_ACTION_RESPONSE', + actionId: data.actionId, + error: response?.error ?? null, + }); + }, + ); + break; + + // --- Access management (Phase 2) — all origin-sensitive, so the REAL page origin is + // stamped HERE (ISOLATED world), never page-supplied. The worker cross-checks it against + // the browser-attested sender.origin. Responses relay back verbatim keyed by requestId. + case 'SOLID_REQUEST_ACCESS': + chrome.runtime.sendMessage( + { + type: 'SOLID_REQUEST_ACCESS', + requestId: data.requestId, + classes: data.classes ?? [], + modes: data.modes ?? [], + purpose: data.purpose, + duration: data.duration, + stampedOrigin: window.location.origin, + // Stamp the browser-attested transient user activation, read HERE in the ISOLATED world + // (the page cannot forge the isolated `navigator`). `isActive` is TRUE only within ~5s of + // a genuine gesture, so an autonomous `requestAccess` loop reports false and is rejected + // by the SW's anti-spam gate (residual c). Optional-chained so a context without the + // User Activation API fails closed (undefined → false → gesture required). + userActivated: navigator.userActivation?.isActive === true, + }, + (response) => { + toPage({ type: 'SOLID_ACCESS_RESPONSE', ...(response ?? { error: 'No response' }) }); + }, + ); + break; + + case 'SOLID_LIST_GRANTS': + chrome.runtime.sendMessage( + { + type: 'SOLID_LIST_GRANTS', + requestId: data.requestId, + stampedOrigin: window.location.origin, + }, + (response) => { + toPage({ type: 'SOLID_ACCESS_RESPONSE', ...(response ?? { error: 'No response' }) }); + }, + ); + break; + + case 'SOLID_REVOKE_GRANT': + chrome.runtime.sendMessage( + { + type: 'SOLID_REVOKE_GRANT', + requestId: data.requestId, + grantId: data.grantId, + stampedOrigin: window.location.origin, + }, + (response) => { + toPage({ type: 'SOLID_ACCESS_RESPONSE', ...(response ?? { error: 'No response' }) }); + }, + ); + break; + + case 'SOLID_RESOLVE_CLASS': + chrome.runtime.sendMessage( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: data.requestId, + classIRI: data.classIRI, + stampedOrigin: window.location.origin, + }, + (response) => { + toPage({ type: 'SOLID_ACCESS_RESPONSE', ...(response ?? { error: 'No response' }) }); + }, + ); + break; + } +}); + +// Relay worker -> page state broadcasts. +chrome.runtime.onMessage.addListener((message) => { + if (message?.type === 'SOLID_STATE_CHANGED' && message.origin === window.location.origin) { + toPage({ type: 'SOLID_STATE_UPDATE', webId: message.webId ?? null }); + } +}); + +installFormFill(window); diff --git a/apps/extension/src/content/formfill-controller.ts b/apps/extension/src/content/formfill-controller.ts new file mode 100644 index 000000000..b02f1696d --- /dev/null +++ b/apps/extension/src/content/formfill-controller.ts @@ -0,0 +1,180 @@ +// AUTHORED-BY Codex GPT-6.1-Sol + +import { formFillFields } from '../shared/formfill'; +import { MAX_FORM_FILL_VALUE_LENGTH } from '../shared/formfill-values'; +import type { FormFillRequest, FormFillResponse, FormFillTrigger } from '../shared/messages'; +import { + type DetectedField, + detectField, + detectFields, + type FormFillElement, + fieldRefusalReason, +} from './formfill/detect'; +import { refusalReason } from './formfill/refusal'; +import { writeValue } from './formfill/write'; + +/** ISOLATED only: nothing is exposed on window or relayed through postMessage. */ +export function installFormFill(view: Window & typeof globalThis): () => void { + const document = view.document; + let busy = false; + let target: FormFillElement | null = null; + const icons = new Map(); + const categories = new Set<'contact' | 'address'>(['contact', 'address']); + function permitted(field: DetectedField): boolean { + try { + return ( + refusalReason({ + url: view.location.href, + isTopFrame: view === view.top, + frameOrigin: view.location.origin, + topOrigin: view.top!.location.origin, + field, + consentedCategories: categories, + }) === null + ); + } catch { + return false; + } + } + const eligible = (root: Document | HTMLFormElement) => detectFields(root).filter(permitted); + async function fill(root: Document | HTMLFormElement): Promise { + if (busy) return; + const fields = eligible(root); + if (!fields.length) return; + const origin = view.location.origin; + busy = true; + try { + const request: FormFillRequest = { + type: 'SOLID_FORMFILL_REQUEST', + fields: fields.map(({ field, category }) => ({ field, category })), + }; + const response = (await chrome.runtime.sendMessage(request)) as FormFillResponse | undefined; + if ( + !response || + response.origin !== origin || + view.location.origin !== origin || + !Array.isArray(response.values) + ) + return; + let filled = 0; + for (const field of fields) { + if (!field.element.isConnected || fieldRefusalReason(field.element) || !permitted(field)) + continue; + const current = detectField(field.element); + if (current?.field !== field.field || current.category !== field.category) continue; + const approved = response.values.find( + (v) => v.field === field.field && v.category === field.category, + ); + if ( + !approved || + !formFillFields([approved]).length || + typeof approved.value !== 'string' || + approved.value.length > MAX_FORM_FILL_VALUE_LENGTH + ) + continue; + if (writeValue(field.element, approved.value)) filled++; + } + await chrome.runtime.sendMessage({ + type: 'SOLID_FORMFILL_REPORT', + filled, + skipped: fields.length - filled, + }); + } catch { + /* Closed preview, worker restart or unavailable extension: leave the form alone. */ + } finally { + busy = false; + } + } + const rememberTarget = (event: MouseEvent) => { + if (!event.isTrusted) return; + const element = event.target; + target = + element instanceof view.HTMLInputElement || + element instanceof view.HTMLTextAreaElement || + element instanceof view.HTMLSelectElement + ? element + : null; + }; + document.addEventListener('contextmenu', rememberTarget, true); + const receive = (message: Partial, sender: chrome.runtime.MessageSender) => { + if (message?.type !== 'SOLID_FORMFILL_TRIGGER' || sender.id !== chrome.runtime.id || sender.tab) + return; + const active = target ?? document.activeElement; + if ( + !( + active instanceof view.HTMLInputElement || + active instanceof view.HTMLTextAreaElement || + active instanceof view.HTMLSelectElement + ) + ) + return; + void fill(active.form ?? document); + }; + chrome.runtime.onMessage.addListener(receive); + + function scan(): void { + for (const [form, icon] of icons) { + if ( + !form.isConnected || + eligible(form)[0]?.element !== icon.first || + !icon.host.isConnected + ) { + icon.host.remove(); + icons.delete(form); + } + } + for (const form of document.forms) { + if (icons.has(form)) continue; + const first = eligible(form)[0]?.element; + if (!first) continue; + const host = document.createElement('span'); + host.setAttribute('data-solid-formfill', ''); + const shadow = host.attachShadow({ mode: 'closed' }); + const style = document.createElement('style'); + style.textContent = + ':host{display:inline-block!important;vertical-align:middle!important;margin:0 4px!important}button{font:14px system-ui;cursor:pointer;border:1px solid #777;border-radius:50%;width:28px;height:28px;color:#fff;background:#6554c0}button:focus-visible{outline:3px solid #1785d1;outline-offset:2px}'; + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = '✦'; + button.setAttribute('aria-label', 'Fill from pod'); + button.title = 'Fill from pod'; + button.addEventListener('click', (event) => { + if (event.isTrusted) void fill(form); + }); + button.addEventListener('keydown', (event) => { + if (!event.isTrusted || event.repeat || !['Enter', ' '].includes(event.key)) return; + event.preventDefault(); + void fill(form); + }); + shadow.append(style, button); + first.insertAdjacentElement('afterend', host); + icons.set(form, { host, first }); + } + } + const observer = new view.MutationObserver(scan); + observer.observe(document, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: [ + 'autocomplete', + 'type', + 'disabled', + 'readonly', + 'hidden', + 'inert', + 'style', + 'class', + 'form', + 'name', + 'id', + ], + }); + scan(); + return () => { + observer.disconnect(); + document.removeEventListener('contextmenu', rememberTarget, true); + chrome.runtime.onMessage.removeListener(receive); + for (const { host } of icons.values()) host.remove(); + }; +} diff --git a/apps/extension/src/content/formfill/detect.ts b/apps/extension/src/content/formfill/detect.ts new file mode 100644 index 000000000..d719b0479 --- /dev/null +++ b/apps/extension/src/content/formfill/detect.ts @@ -0,0 +1,217 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +/** DOM-only classification. Calling this module never writes values or installs listeners. */ +export const FIELD_CATEGORIES = { + name: 'contact', + 'given-name': 'contact', + 'family-name': 'contact', + email: 'contact', + tel: 'contact', + organization: 'contact', + 'street-address': 'address', + 'address-line1': 'address', + 'address-line2': 'address', + 'address-level1': 'address', + 'address-level2': 'address', + 'postal-code': 'address', + country: 'address', + 'country-name': 'address', +} as const; + +export type FieldKind = keyof typeof FIELD_CATEGORIES; +export type FieldCategory = 'contact' | 'address'; +export type FormFillElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; + +export interface DetectedField { + readonly element: FormFillElement; + readonly field: FieldKind; + readonly category: FieldCategory; + readonly confidence: 'autocomplete' | 'heuristic'; +} + +export function autocompleteTokens(element: FormFillElement): string[] { + return (element.getAttribute('autocomplete') ?? '').toLowerCase().trim().split(/\s+/); +} + +/** Shared by detection, the consent gate and the writer; recheck at the time of writing. */ +export function fieldRefusalReason(element: FormFillElement): string | null { + const tokens = autocompleteTokens(element); + if ( + (element.tagName === 'INPUT' && (element as HTMLInputElement).type === 'password') || + tokens.includes('current-password') || + tokens.includes('new-password') + ) { + return 'password-field'; + } + if (tokens.some((token) => token.startsWith('cc-'))) return 'payment-field'; + if (tokens.includes('one-time-code')) return 'one-time-code-field'; + if ( + tokens.includes('off') || + element.form?.getAttribute('autocomplete')?.toLowerCase().trim() === 'off' + ) { + return 'autocomplete-off'; + } + if ( + element.tagName === 'INPUT' && + !['text', 'email', 'tel', 'number'].includes((element as HTMLInputElement).type) + ) { + return 'unsupported-input-type'; + } + if (element.matches(':disabled') || element.hasAttribute('readonly')) return 'uneditable-field'; + if (element.closest('[hidden], [inert]')) return 'hidden-field'; + const view = element.ownerDocument.defaultView; + for (let ancestor: Element | null = element; ancestor; ancestor = ancestor.parentElement) { + const style = view?.getComputedStyle(ancestor); + if ( + style?.display === 'none' || + style?.visibility === 'hidden' || + style?.visibility === 'collapse' + ) { + return 'hidden-field'; + } + } + return null; +} + +/** A well-formed autocomplete naming a field we do not fill is 'unsupported', not a guess. */ +function fromAutocomplete(tokens: string[]): FieldKind | 'unsupported' | null { + // A trailing webauthn token is valid after any field (WHATWG autofill detail tokens). + if (tokens.length > 1 && tokens[tokens.length - 1] === 'webauthn') tokens = tokens.slice(0, -1); + let index = 0; + if (tokens[index].startsWith('section-')) index++; + if (tokens[index] === 'shipping' || tokens[index] === 'billing') index++; + const contactHint = ['home', 'work', 'mobile', 'fax', 'pager'].includes(tokens[index]); + if (contactHint) index++; + const field = tokens[index]; + if (index !== tokens.length - 1 || !AUTOFILL_FIELD_NAMES.has(field)) return null; + if (contactHint && !/^(tel(-.+)?|email|impp)$/.test(field)) return null; + return Object.hasOwn(FIELD_CATEGORIES, field) ? (field as FieldKind) : 'unsupported'; +} + +// WHATWG autofill field names. An explicit but unsupported field (tel-extension, +// organization-title, address-level3, ...) must not fall through to label heuristics. +const AUTOFILL_FIELD_NAMES = new Set([ + 'name', + 'honorific-prefix', + 'given-name', + 'additional-name', + 'family-name', + 'honorific-suffix', + 'nickname', + 'username', + 'new-password', + 'current-password', + 'one-time-code', + 'organization-title', + 'organization', + 'street-address', + 'address-line1', + 'address-line2', + 'address-line3', + 'address-level4', + 'address-level3', + 'address-level2', + 'address-level1', + 'country', + 'country-name', + 'postal-code', + 'cc-name', + 'cc-given-name', + 'cc-additional-name', + 'cc-family-name', + 'cc-number', + 'cc-exp', + 'cc-exp-month', + 'cc-exp-year', + 'cc-csc', + 'cc-type', + 'transaction-currency', + 'transaction-amount', + 'language', + 'bday', + 'bday-day', + 'bday-month', + 'bday-year', + 'sex', + 'url', + 'photo', + 'tel', + 'tel-country-code', + 'tel-national', + 'tel-area-code', + 'tel-local', + 'tel-local-prefix', + 'tel-local-suffix', + 'tel-extension', + 'email', + 'impp', +]); + +const HEURISTICS: readonly [FieldKind, RegExp][] = [ + ['given-name', /\b(first|given|fore)\s*name\b/], + ['family-name', /\b(last|family)\s*name\b|\bsurname\b/], + ['email', /\be\s*mail\b/], + ['tel', /\b(tel|telephone|phone|mobile)\b/], + ['address-line2', /\b(address\s*(line\s*)?2|apartment|apt|suite|unit|extended address)\b/], + ['address-line1', /\b(address\s*(line\s*)?1|street and house number)\b/], + ['postal-code', /\b(post\s*code|postal\s*code|zip\s*(code)?)\b/], + ['country-name', /\bcountry\b/], + ['address-level2', /\b(city|town|locality|suburb)\b/], + ['address-level1', /\b(state|province|region|county)\b/], + ['street-address', /\b(street|address)\b/], + ['organization', /\b(company|organisation|organization|business)\b/], + ['name', /^(name|full\s*name|your name|contact name|customer name|billing name|shipping name)$/], +]; + +function fromHeuristics(element: FormFillElement): FieldKind | null { + const hints = [ + // Supported controls always have a labels list; hidden inputs were excluded above. + ...Array.from(element.labels!, (label) => label.textContent!), + element.getAttribute('aria-label') ?? '', + element.getAttribute('name') ?? '', + element.id, + element.getAttribute('placeholder') ?? '', + ]; + for (const hint of hints) { + const normalized = hint + .replace(/([a-z])([A-Z])/g, '$1 $2') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); + for (const [field, pattern] of HEURISTICS) { + if (pattern.test(normalized)) return field; + } + } + return null; +} + +/** Classify one control, also usable by the writer for communication format checks. */ +export function detectField(element: FormFillElement): DetectedField | null { + if (fieldRefusalReason(element)) return null; + const autocomplete = fromAutocomplete(autocompleteTokens(element)); + if (autocomplete === 'unsupported') return null; + const field = autocomplete ?? fromHeuristics(element); + return field + ? { + element, + field, + category: FIELD_CATEGORIES[field], + confidence: autocomplete ? 'autocomplete' : 'heuristic', + } + : null; +} + +/** Autocomplete takes priority; labels, name, id and placeholder provide ordered fallbacks. */ +export function detectFields(root: Document | HTMLFormElement): DetectedField[] { + const fields: DetectedField[] = []; + const elements = + root.nodeType === 9 + ? root.querySelectorAll('input, textarea, select') + : Array.from((root as HTMLFormElement).elements).filter( + (element): element is FormFillElement => element.matches('input, textarea, select'), + ); + for (const element of elements) { + const field = detectField(element); + if (field) fields.push(field); + } + return fields; +} diff --git a/apps/extension/src/content/formfill/refusal.ts b/apps/extension/src/content/formfill/refusal.ts new file mode 100644 index 000000000..6dc6c60fa --- /dev/null +++ b/apps/extension/src/content/formfill/refusal.ts @@ -0,0 +1,39 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import { isValidClientIdUrl } from '../../background/core/origin-policy'; +import { type DetectedField, type FieldCategory, fieldRefusalReason } from './detect'; + +export interface RefusalInputs { + /** Real frame metadata, supplied by the extension, never by page messages. */ + readonly url: string; + readonly isTopFrame: boolean; + readonly frameOrigin: string; + readonly topOrigin: string; + readonly field: DetectedField; + readonly consentedCategories: ReadonlySet; +} + +/** Pure policy check. Invocation requires a user gesture, enforced by the future caller. */ +export function refusalReason(inputs: RefusalInputs): string | null { + const { url, frameOrigin, topOrigin, isTopFrame, field, consentedCategories } = inputs; + let page: URL; + let frame: URL; + let top: URL; + try { + page = new URL(url); + frame = new URL(frameOrigin); + top = new URL(topOrigin); + } catch { + return 'invalid-url'; + } + if (![page, frame, top].every((value) => isValidClientIdUrl(value.href))) { + return 'insecure-page'; + } + if (page.origin !== frame.origin || (isTopFrame && frame.origin !== top.origin)) { + return 'origin-mismatch'; + } + if (!isTopFrame && frame.origin !== top.origin) return 'cross-origin-frame'; + const reason = fieldRefusalReason(field.element); + if (reason) return reason; + if (!consentedCategories.has(field.category)) return 'category-not-consented'; + return null; +} diff --git a/apps/extension/src/content/formfill/write.ts b/apps/extension/src/content/formfill/write.ts new file mode 100644 index 000000000..f6b6c7e39 --- /dev/null +++ b/apps/extension/src/content/formfill/write.ts @@ -0,0 +1,111 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import { communicationValue, MAX_FORM_FILL_VALUE_LENGTH } from '../../shared/formfill-values'; +import { detectField, type FormFillElement, fieldRefusalReason } from './detect'; + +export interface WriteOptions { + readonly overwrite?: boolean; +} + +const COUNTRY_ALIASES: Readonly> = { + uk: 'gb', + gbr: 'gb', + usa: 'us', + 'united states of america': 'united states', + 'great britain': 'united kingdom', +}; + +function normalize(value: string): string { + return value.trim().replace(/\s+/g, ' ').toLowerCase(); +} + +function countryName(value: string): string { + const normalized = normalize(value); + const alias = COUNTRY_ALIASES[normalized] ?? normalized; + if (/^[a-z]{2}$/.test(alias)) { + return normalize(new Intl.DisplayNames(['en'], { type: 'region' }).of(alias.toUpperCase())!); + } + return alias; +} + +/** + * Call only after consent + frame policy checks. Returns whether a value was written. + * Uses the element's own realm, bypassing per-element setters used by controlled inputs. + * No submission, focus changes or listener installation; only input then change events. + */ +export function writeValue( + element: FormFillElement, + value: string, + { overwrite = false }: WriteOptions = {}, +): boolean { + if (fieldRefusalReason(element)) return false; + if (value.length > MAX_FORM_FILL_VALUE_LENGTH) return false; + // Programmatic setters do not enforce maxlength. Skip rather than corrupt a value. + if (element.tagName !== 'SELECT') { + const { maxLength } = element as HTMLInputElement | HTMLTextAreaElement; + if (maxLength >= 0 && value.length > maxLength) return false; + } + const kind = + element.tagName === 'INPUT' && ['email', 'tel'].includes((element as HTMLInputElement).type) + ? (element as HTMLInputElement).type + : detectField(element)?.field; + if (kind === 'email' || kind === 'tel') { + const validated = communicationValue(value, kind); + if (validated === null) return false; + value = validated; + } + const hasValue = + element.tagName === 'SELECT' + ? Array.from((element as HTMLSelectElement).options).some( + (option) => option.selected && option.value !== '', + ) + : element.value !== ''; + if (!overwrite && hasValue) return false; + const view = element.ownerDocument.defaultView; + if (!view) return false; + + let prototype: object; + let property = 'value'; + let nextValue: string | number = value; + if (element.tagName === 'SELECT') { + const options = Array.from((element as HTMLSelectElement).options); + const eligible = (option: HTMLOptionElement) => + !option.disabled && !option.closest('optgroup[disabled]'); + let index = options.findIndex( + (option) => + eligible(option) && + (normalize(option.value) === normalize(value) || + normalize(option.text) === normalize(value)), + ); + // Country aliases ("GE", "UK") are only meaningful for country controls; elsewhere + // they would map region codes such as Genève to unrelated countries. + if (index === -1 && (kind === 'country' || kind === 'country-name')) { + index = options.findIndex( + (option) => + eligible(option) && + (countryName(option.value) === countryName(value) || + countryName(option.text) === countryName(value)), + ); + } + if (index === -1) return false; + prototype = view.HTMLSelectElement.prototype; + property = 'selectedIndex'; + nextValue = index; + } else { + prototype = + element.tagName === 'TEXTAREA' + ? view.HTMLTextAreaElement.prototype + : view.HTMLInputElement.prototype; + } + const setter = Object.getOwnPropertyDescriptor(prototype, property)?.set; + if (!setter) return false; + const previousValue = element.value; + setter.call(element, nextValue); + // Native setters may sanitize unsupported numbers or line endings. Fail without events. + if (property === 'value' && element.value !== nextValue) { + setter.call(element, previousValue); + return false; + } + element.dispatchEvent(new view.Event('input', { bubbles: true })); + element.dispatchEvent(new view.Event('change', { bubbles: true })); + return true; +} diff --git a/apps/extension/src/formfill/preview.css b/apps/extension/src/formfill/preview.css new file mode 100644 index 000000000..05f316b3a --- /dev/null +++ b/apps/extension/src/formfill/preview.css @@ -0,0 +1,19 @@ +/* AUTHORED-BY Codex GPT-6.1-Sol */ +:root { color-scheme: light dark; font: 15px/1.5 system-ui, sans-serif; } +body { margin: 0; background: light-dark(#f8f7fc, #19171f); color: light-dark(#25212f, #f3effa); } +main { padding: 24px; max-width: 560px; margin: auto; } +h1 { font-size: 1.4rem; margin-top: 0; } +#origin, dd { overflow-wrap: anywhere; white-space: pre-wrap; } +select { display: block; width: 100%; padding: 8px; margin: 5px 0 20px; font: inherit; } +fieldset { border: 1px solid #8887; border-radius: 8px; margin: 16px 0; padding: 12px; } +legend { font-weight: 600; } +input { accent-color: #7560d6; } +dt { font-size: .85rem; opacity: .8; } +dd { margin: 0 0 12px; } +.hint { font-size: .85rem; opacity: .8; } +.actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px; } +button { font: inherit; padding: 8px 20px; cursor: pointer; border: 1px solid #888; border-radius: 6px; } +#fill { background: #6554c0; color: white; } +button:disabled { opacity: .5; cursor: default; } +:focus-visible { outline: 3px solid #1785d1; outline-offset: 3px; } +#error { color: light-dark(#a32626, #ffaaaa); } diff --git a/apps/extension/src/formfill/preview.html b/apps/extension/src/formfill/preview.html new file mode 100644 index 000000000..a984db259 --- /dev/null +++ b/apps/extension/src/formfill/preview.html @@ -0,0 +1,23 @@ + + + +Fill from your profile + +
+

Fill from your profile

+

Choose what to fill on . Review the values each time. Your form stays open for you to submit.

+ +
+ + +
+ + +

Remembering selects these categories next time. You will still review the values before filling.

+

Choose at least one kind of detail to fill.

+
+
+
+ + + diff --git a/apps/extension/src/formfill/preview.ts b/apps/extension/src/formfill/preview.ts new file mode 100644 index 000000000..869c477c0 --- /dev/null +++ b/apps/extension/src/formfill/preview.ts @@ -0,0 +1,135 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import type { FieldCategory } from '../content/formfill/detect'; +import { FIELD_LABELS } from '../shared/formfill'; +import type { FormFillPreview, FormFillPreviewRequest } from '../shared/messages'; + +async function main(): Promise { + const requestId = new URLSearchParams(window.location.search).get('requestId'); + const error = document.getElementById('error')!; + const picker = document.getElementById('profile') as HTMLSelectElement; + const fill = document.getElementById('fill') as HTMLButtonElement; + const selectionHint = document.getElementById('selection-hint')!; + const remember = document.getElementById('remember') as HTMLInputElement; + const boxes = new Map(); + let profileId: string | undefined; + let version = 0; + let decided = false; + let loading = true; + const updateFill = () => { + const selected = [...boxes.values()].some((box) => box.checked); + fill.disabled = loading || decided || !selected; + selectionHint.textContent = selected + ? 'Only the checked kinds of detail will be filled.' + : 'Choose at least one kind of detail to fill.'; + }; + const showError = (message: string) => { + error.textContent = message; + error.hidden = false; + fill.disabled = true; + }; + async function decide(categories: FieldCategory[]): Promise { + if (!requestId || decided) return; + decided = true; + fill.disabled = true; + try { + await chrome.runtime.sendMessage({ + type: 'SOLID_FORMFILL_DECISION', + requestId, + profileId, + categories, + remember: remember.checked, + } satisfies FormFillPreviewRequest); + } finally { + window.close(); + } + } + document.getElementById('cancel')!.addEventListener('click', () => { + void decide([]); + }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + void decide([]); + } + }); + window.addEventListener('pagehide', () => { + void decide([]); + }); + document.getElementById('preview')!.addEventListener('submit', (event) => { + event.preventDefault(); + if (fill.disabled) return; + void decide([...boxes].filter(([, box]) => box.checked).map(([category]) => category)); + }); + if (!requestId) { + showError('This fill request is unavailable.'); + return; + } + async function load(selected?: string): Promise { + const generation = ++version; + loading = true; + fill.disabled = true; + error.hidden = true; + try { + const view = (await chrome.runtime.sendMessage({ + type: 'SOLID_FORMFILL_PREVIEW', + requestId: requestId!, + profileId: selected, + } satisfies FormFillPreviewRequest)) as FormFillPreview | null; + if (generation !== version || decided) return; + if (!view) { + showError('This fill request has expired or your profile is no longer available.'); + return; + } + profileId = view.profileId; + document.getElementById('origin')!.textContent = view.origin; + if (!picker.options.length) + for (const p of view.profiles) { + const option = document.createElement('option'); + option.value = p.id; + option.textContent = p.label; + picker.append(option); + } + picker.value = profileId; + picker.disabled = false; + remember.checked = view.selected.length > 0; + const container = document.getElementById('categories')!; + container.replaceChildren(); + boxes.clear(); + for (const category of ['contact', 'address'] as const) { + const values = view.values.filter((v) => v.category === category); + if (!values.length) continue; + const group = document.createElement('fieldset'); + const legend = document.createElement('legend'); + const label = document.createElement('label'); + const box = document.createElement('input'); + box.type = 'checkbox'; + box.checked = view.selected.includes(category); + box.addEventListener('change', updateFill); + boxes.set(category, box); + label.append(box, category === 'contact' ? ' Contact details' : ' Address'); + legend.append(label); + group.append(legend); + const list = document.createElement('dl'); + for (const value of values) { + const term = document.createElement('dt'); + term.textContent = FIELD_LABELS[value.field]; + const description = document.createElement('dd'); + description.textContent = value.value; + list.append(term, description); + } + group.append(list); + container.append(group); + } + document.getElementById('empty')!.hidden = view.values.length > 0; + loading = false; + updateFill(); + } catch { + if (generation === version) showError('Could not load your profile. Please try again.'); + } + } + picker.addEventListener('change', () => { + void load(picker.value); + }); + await load(); +} +void main(); diff --git a/apps/extension/src/icons/icon128.png b/apps/extension/src/icons/icon128.png new file mode 100644 index 000000000..3da348504 Binary files /dev/null and b/apps/extension/src/icons/icon128.png differ diff --git a/apps/extension/src/icons/icon16.png b/apps/extension/src/icons/icon16.png new file mode 100644 index 000000000..f0caab6d8 Binary files /dev/null and b/apps/extension/src/icons/icon16.png differ diff --git a/apps/extension/src/icons/icon32.png b/apps/extension/src/icons/icon32.png new file mode 100644 index 000000000..00c76f286 Binary files /dev/null and b/apps/extension/src/icons/icon32.png differ diff --git a/apps/extension/src/icons/icon48.png b/apps/extension/src/icons/icon48.png new file mode 100644 index 000000000..d8be6ae2b Binary files /dev/null and b/apps/extension/src/icons/icon48.png differ diff --git a/apps/extension/src/icons/solid.svg b/apps/extension/src/icons/solid.svg new file mode 100644 index 000000000..50836d303 --- /dev/null +++ b/apps/extension/src/icons/solid.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/extension/src/inject/fetch-patch.ts b/apps/extension/src/inject/fetch-patch.ts new file mode 100644 index 000000000..48c5a2048 --- /dev/null +++ b/apps/extension/src/inject/fetch-patch.ts @@ -0,0 +1,5 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +// Only dynamically registered on granted origins. The stub still asks the SW before enabling. +window.dispatchEvent(new window.CustomEvent('solid-extension:grant-patch')); + +export {}; diff --git a/apps/extension/src/inject/inject.ts b/apps/extension/src/inject/inject.ts new file mode 100644 index 000000000..211f075ca --- /dev/null +++ b/apps/extension/src/inject/inject.ts @@ -0,0 +1,452 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The MAIN-world injection: defines `window.solid` on every page. This script runs in the + * page's own JS world (so the page can call it), but it holds NO credential — every + * privileged operation is a `postMessage` to the ISOLATED content script, which relays to + * the service worker (the sole token holder). The page only ever sees the WebID + a + * proxied fetch Response. The credential boundary is enforced in the worker, not here. + */ + +export {}; + +const CHANNEL = 'solid-browser-ext'; + +// The manifest supplies the stub on all pages; granted-origin dynamic scripts request a +// fresh state check. Duplicate execution never replaces a page's permanent window.solid. +declare global { + interface Window { + __solidInjected?: boolean; + } +} + +/** A token-free view of a per-data-class grant (the enforceable substance + consent anchors). */ +interface SolidGrant { + readonly grantId: string; + readonly dataClass: string; + readonly modes: readonly string[]; + readonly purpose: string | null; + readonly expiresAt: number; + readonly targets: ReadonlyArray<{ url: string; kind: 'instance' | 'container' }>; +} + +/** The `requestAccess` argument (design §4.2). `duration` is an ISO-8601 duration, e.g. "P30D". */ +interface AccessRequestArgs { + readonly classes: readonly string[]; + readonly modes: readonly string[]; + readonly purpose?: string; + readonly duration?: string; +} + +/** The worker's reply to an access-management call, as relayed to the page. */ +interface AccessResponseData { + readonly webId?: string; + readonly requestId: string; + readonly ok?: boolean; + readonly grants?: SolidGrant[]; + readonly shape?: string | null; + readonly targets?: Array<{ url: string; kind: 'instance' | 'container' }>; + readonly error?: string; +} + +/** + * The `window.solid` API surface — Phase-0 core (fetch/login/logout/setClientId) PLUS the + * Phase-2 per-data-class access-management methods (design §4.2). Every access-management call + * is a token-free `postMessage` to the service worker (the sole boundary); the page never sees + * a credential — only a grant handle + a capability to `fetch` within the granted, PINNED, + * resolved target set. + */ +interface SolidExtension { + /** The authenticated user's WebID, or null when signed out. */ + readonly webId: string | null; + /** A DPoP-authenticated fetch, proxied through the extension (origin-gated, fail-closed). */ + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; + /** Declare this origin's Client Identifier Document URL so the pod sees the page's identity. */ + setClientId(clientId: string): void; + /** Start the interactive login for a WebID (same as the popup). */ + login(webId: string): Promise; + /** Clear the session (app-local logout). */ + logout(): Promise; + + // --- Per-data-class access management (Phase 2, design §4.2) ----------------------- + /** + * Request type-level access to one or more data classes. Triggers the extension's + * unspoofable consent window; on approval the class is resolved to concrete pod targets that + * are PINNED into the grant, and subsequent `fetch` calls are confined to that pinned set. + * Resolves with the minted grants (one per approved class); an empty `grants` ⇒ denied. + */ + requestAccess(request: AccessRequestArgs): Promise<{ ok: boolean; grants: SolidGrant[] }>; + /** The grants THIS origin currently holds (non-expired), for the active session. */ + listGrants(): Promise; + /** Revoke one of THIS origin's grants; effective on the next request. Returns whether removed. */ + revokeGrant(grantId: string): Promise; + /** Read-only introspection: resolve a data-class IRI to its pod targets (for the app's UX). */ + resolveClass(classIRI: string): Promise<{ + shape: string | null; + targets: Array<{ url: string; kind: 'instance' | 'container' }>; + }>; +} + +type Pending = { resolve: (v: T) => void; reject: (e: Error) => void }; + +/** + * A pending `solidFetch`, plus the ORIGINAL `input`/`init` it was called with. The original + * args are retained so that if the SW returns a PASSTHROUGH SENTINEL (round-2 High #1 — the SW + * declined an autoDivert request and did NO network), the page-side handler can complete the + * request by calling the page's OWN native fetch (PRISTINE_FETCH) with full CORS / credentials + * / header fidelity — the SW never fetches cross-origin bytes on a page's behalf. + */ +type PendingFetch = Pending & { + readonly input: RequestInfo | URL; + readonly init?: RequestInit; +}; + +// Install exactly once, even if inject.js runs twice (Medium #2). The marker covers a re-run +// after our own install; the `'solid' in window` check covers a prior install from a different +// code path. A double-run is then an inert no-op, NOT a throw on the `configurable:false` +// defineProperty below. +if (!window.__solidInjected && !('solid' in window)) { + window.__solidInjected = true; + installSolidInjection(); +} + +function installSolidInjection(): void { + // --- Snapshot the PRISTINE fetch FIRST (the @jeswr/solid-elements discipline) ----------- + // + // Captured up-front, before the global patch below, so a later page script that overwrites + // `window.fetch` cannot poison it. This is ALSO the function the SW passthrough path falls + // back to: when the SW returns a passthrough sentinel, the page completes the request with + // its OWN native fetch in the page's origin context (round-2 High #1) — so the extension is + // never a cross-origin proxy and native CORS / credentials / header semantics are preserved. + const nativeFetch = window.fetch; + const PRISTINE_FETCH = nativeFetch.bind(window); + + /** + * Duck-type a `URL`-like input rather than `instanceof URL`. `instanceof` is CROSS-REALM + * UNRELIABLE: a `URL` minted in another JS realm (an iframe, or — in tests — a JSDOM window + * whose `URL` differs from the module's bare-global `URL`) is NOT an instance of the realm-local + * `URL`, so `input instanceof URL` would wrongly be `false` and we'd mis-classify the input + * (treating a cross-origin pod URL as same-origin and skipping the gate). A `RequestInfo | URL` + * that is a non-null object exposing a string `href` is a URL (a `Request` has no `href` — it + * carries `url` — so this never mis-takes a `Request` for a `URL`). `WHATWG URL` is fully + * specified by its `href`, so we snapshot/serialise via `href` and never depend on identity. + */ + function urlLike(input: unknown): input is URL { + return ( + typeof input === 'object' && + input !== null && + 'href' in input && + typeof (input as { href: unknown }).href === 'string' + ); + } + + const pendingFetches = new Map(); + const pendingActions = new Map>(); + /** Pending access-management calls (requestAccess/listGrants/revokeGrant/resolveClass). */ + const pendingAccess = new Map>(); + + let currentWebId: string | null = null; + + window.addEventListener('message', (event) => { + if (event.source !== window) return; + const data = event.data; + if (!data || data.channel !== CHANNEL || data.dir !== 'to-page') return; + + switch (data.type) { + case 'SOLID_FETCH_RESPONSE': { + const pending = pendingFetches.get(data.requestId); + if (!pending) return; + pendingFetches.delete(data.requestId); + if (data.passthrough === true) { + // PASSTHROUGH SENTINEL (round-2 High #1): the SW declined this autoDivert request and + // did NO network. Complete it with the page's OWN native fetch, in the page's origin + // context, using the ORIGINAL input/init — so CORS / credentials / headers behave + // EXACTLY as if the extension were not installed (if CORS blocks, this rejects / + // returns opaque exactly as native). We NEVER fabricate a Response from SW data on + // this path: the SW returned no bytes (it cannot be a cross-origin read proxy). + PRISTINE_FETCH(pending.input as RequestInfo, pending.init).then( + pending.resolve, + pending.reject, + ); + } else if (data.error) { + pending.reject(new Error(data.error)); + } else { + pending.resolve( + new Response(data.body ?? null, { + status: data.status, + statusText: data.statusText, + headers: new Headers(data.headers ?? {}), + }), + ); + } + break; + } + case 'SOLID_ACTION_RESPONSE': { + const pending = pendingActions.get(data.actionId); + if (!pending) return; + pendingActions.delete(data.actionId); + if (data.error) pending.reject(new Error(data.error)); + else pending.resolve(); + break; + } + case 'SOLID_ACCESS_RESPONSE': { + const pending = pendingAccess.get(data.requestId); + if (!pending) return; + pendingAccess.delete(data.requestId); + if (data.error) pending.reject(new Error(data.error)); + else pending.resolve(data as AccessResponseData); + break; + } + case 'SOLID_STATE_UPDATE': + currentWebId = data.webId ?? null; + setGranted(currentWebId !== null); + break; + } + }); + + /** Send an access-management request and await the worker's correlated reply. */ + function accessRequest( + type: string, + extra: Record = {}, + ): Promise { + return new Promise((resolve, reject) => { + const requestId = crypto.randomUUID(); + pendingAccess.set(requestId, { resolve, reject }); + postToContent({ type, requestId, ...extra }); + }); + } + + function postToContent(message: Record): void { + window.postMessage({ channel: CHANNEL, dir: 'to-content', ...message }, window.location.origin); + } + + /** + * Route a fetch through the gated SW path. `autoDivert` marks a request that came from the + * best-effort global-fetch patch (NOT an explicit `window.solid.fetch` call) so the SW can + * native-passthrough a non-pod / un-granted request instead of hard-failing the page (High #1). + */ + function solidFetch( + input: RequestInfo | URL, + init?: RequestInit, + autoDivert = false, + ): Promise { + return new Promise((resolve, reject) => { + const requestId = crypto.randomUUID(); + // Retain the input/init so a passthrough sentinel can be completed with the page's own + // native fetch verbatim (round-2 High #1). SNAPSHOT them at CALL TIME (round-3 Medium): + // native `fetch` reads the URL/init synchronously, but a passthrough fetch happens only + // AFTER the async SW round-trip — so without a snapshot, a page that mutates the `URL`, + // the `Headers`, or `init.credentials` between the call and the sentinel would change the + // eventual request. Copy the URL (a mutable `URL` object), the init object, and the + // headers into fresh values so a later mutation of the caller's objects cannot leak in. A + // string input + string body are immutable, so they need no copy. A `URL` is detected by + // duck-typing (`urlLike`, cross-realm-safe) and snapshotted to its `href` STRING: a WHATWG + // URL is fully specified by `href`, native fetch accepts a string URL identically, and a + // string can't be mutated — so a later `url.pathname = …` on the caller's object cannot leak + // into the deferred passthrough. (Snapshotting to a fresh `URL` would also work but only if + // `URL` is the same realm; a string is robust in both browser and JSDOM.) + const snapInput: RequestInfo | URL = urlLike(input) ? input.href : input; + const snapInit: RequestInit | undefined = init + ? { ...init, ...(init.headers ? { headers: new Headers(init.headers) } : {}) } + : undefined; + pendingFetches.set(requestId, { resolve, reject, input: snapInput, init: snapInit }); + let body: string | null = null; + if (init?.body != null) { + if (typeof init.body !== 'string') { + pendingFetches.delete(requestId); + reject(new Error('window.solid.fetch currently supports only string request bodies.')); + return; + } + body = init.body; + } + postToContent({ + type: 'SOLID_FETCH_REQUEST', + requestId, + url: input.toString(), + method: init?.method ?? 'GET', + headers: init?.headers ? Object.fromEntries(new Headers(init.headers).entries()) : {}, + body, + autoDivert, + }); + }); + } + + function action(type: string, extra: Record = {}): Promise { + return new Promise((resolve, reject) => { + const actionId = crypto.randomUUID(); + pendingActions.set(actionId, { resolve, reject }); + postToContent({ type, actionId, ...extra }); + }); + } + + // The client-id declared via setClientId(), carried directly on the login message so a + // page that calls setClientId() then immediately login() can't race the storage write. + let currentClientId: string | undefined; + + const solid: SolidExtension = { + get webId() { + return currentWebId; + }, + fetch: solidFetch, + setClientId(clientId: string) { + currentClientId = clientId; + postToContent({ type: 'SOLID_SET_CLIENT_ID', clientId }); + }, + login(webId: string) { + return action('SOLID_LOGIN', { webId, clientId: currentClientId }); + }, + logout() { + return action('SOLID_LOGOUT'); + }, + async requestAccess(request: AccessRequestArgs) { + const res = await accessRequest('SOLID_REQUEST_ACCESS', { + classes: request?.classes ?? [], + modes: request?.modes ?? [], + purpose: request?.purpose, + duration: request?.duration, + }); + if (res.ok && res.webId) { + currentWebId = res.webId; + setGranted(true); + } + return { ok: res.ok === true, grants: res.grants ?? [] }; + }, + async listGrants() { + const res = await accessRequest('SOLID_LIST_GRANTS'); + return res.grants ?? []; + }, + async revokeGrant(grantId: string) { + const res = await accessRequest('SOLID_REVOKE_GRANT', { grantId }); + return res.ok === true; + }, + async resolveClass(classIRI: string) { + const res = await accessRequest('SOLID_RESOLVE_CLASS', { classIRI }); + return { shape: res.shape ?? null, targets: res.targets ?? [] }; + }, + }; + + // The permanent surface is a stub. Privileged properties exist only while granted. + const stub = { login: solid.login, requestAccess: solid.requestAccess }; + const privileged = [ + 'webId', + 'fetch', + 'setClientId', + 'logout', + 'listGrants', + 'revokeGrant', + 'resolveClass', + ] as const; + function setGranted(granted: boolean): void { + for (const key of privileged) { + if (granted) + Object.defineProperty(stub, key, { + ...Object.getOwnPropertyDescriptor(solid, key), + configurable: true, + }); + else Reflect.deleteProperty(stub, key); + } + try { + if (granted) window.fetch = patchedFetch; + else if (window.fetch === patchedFetch) window.fetch = nativeFetch; + } catch { + /* best effort */ + } + } + Object.defineProperty(window, 'solid', { value: stub, configurable: false }); + + // --- Presence announce (apps de-dupe their own account chrome) -------------------------- + // + // A page needs a RELIABLE, RACE-FREE way to know the Solid extension is installed so it can + // hide its OWN profile/logout controls (the extension already shows a pinned account menu — + // showing both is duplicate chrome). `'solid' in window` is the API, but a page script that + // runs BEFORE this MAIN-world inject (a race, since inject is document_start) would sniff a + // bare window and wrongly conclude "no extension". So we announce presence two ways, both + // credential-free (this only says "an extension is here", never who the user is): + // 1. a STICKY DOM marker on — readable SYNCHRONOUSLY by app code whenever it runs, + // even long after inject (it persists), so there is no ordering dependency; and + // 2. a one-shot CustomEvent for a listener that was already attached before inject ran. + // The webId still lives ONLY on `window.solid.webId` (null until auth) — the marker/event + // carry no identity. Wrapped defensively: an announce failure must never break `window.solid`. + try { + window.document.documentElement.setAttribute('data-solid-extension', '1'); + // Use the page realm's own CustomEvent (window.CustomEvent === CustomEvent in a real page) + // so the event is same-realm with the window it is dispatched on. + window.dispatchEvent(new window.CustomEvent('solid-extension:ready')); + } catch { + // Non-fatal — the `window.solid` API above is the source of truth; the marker is a hint. + } + + // --- The MAIN-world global-`fetch` patch (design §5.1) --------------------------------- + // + // Generalises the `window.solid.fetch` proxy: transparently route a page's PLAIN + // `fetch(podUrl)` through the extension (→ ISOLATED bridge → SW → DPoP egress) so an + // unmodified Solid app gets authenticated, replica-backed reads/writes with no code change. + // + // SECURITY (load-bearing): this patch is BEST-EFFORT TRANSPARENCY with ZERO security weight + // (design §5.1). The SW gate remains the SOLE boundary — EVERY routed request still goes + // through the Phase-0 per-requesting-origin gate as the FIRST check (in the SW). The patch is + // racy + bypassable by design; a missed/bypassed request is a plain unauthenticated fetch + // (pod 401, fail-safe for the credential) governed by the server's own WAC. The patch can + // NEVER widen access — it only forwards to the same gated `solid.fetch` path. + // + // Re-assert discipline (the @jeswr/solid-elements `installProactiveAuthFetch` pattern): + // snapshot the PRISTINE fetch FIRST (done at the TOP of installSolidInjection above, before + // any page script could overwrite `window.fetch`), install once (idempotent), and never + // re-read a possibly-patched global. A later page script that overwrites `window.fetch` only + // affects ITS OWN requests — it gains no credential (the SW gate is the boundary). The same + // PRISTINE_FETCH also services the SW passthrough sentinel (round-2 High #1). + + /** + * A CHEAP page-side pre-filter for which requests are even WORTH routing to the SW — NOT the + * security/compat decision (that is the SW's, see below). We route only http(s) CROSS-origin + * absolute URLs: a Solid pod is on a different origin than the app, so a same-origin or + * relative request is the app's own asset/API and stays on the untouched native fetch (no + * proxy tax). The inject deliberately does NOT know the pod-origin set (it must not, to avoid + * leaking the pod list / PII to an arbitrary page — design §5.1) — it stays DUMB and lets the + * SW (the SOLE boundary) decide. + * + * HIGH #1 — THE KEY INVARIANT: routing here must NEVER break the page's normal web traffic. + * Routing a NON-pod cross-origin request (an app's call to a third-party API/CDN) must end in + * a plain NATIVE UNAUTHENTICATED fetch, not a 403. That is achieved on the SW side: a routed + * request carries `autoDivert: true`, and when the SW gate does not ALLOW (a non-pod target, + * or an un-granted requesting origin) the SW performs a native unauthenticated passthrough + * instead of returning a gate-deny. The credential + replica path is taken ONLY on an explicit + * gate ALLOW (a known pod origin from a granted requesting origin). Over-routing is therefore + * harmless: the worst case for any non-pod request is the same plain fetch the page would have + * done itself. (An EXPLICIT `window.solid.fetch` carries `autoDivert: false` and keeps the 403.) + */ + function shouldDivert(input: RequestInfo | URL): boolean { + try { + const url = new URL( + typeof input === 'string' ? input : urlLike(input) ? input.href : (input as Request).url, + window.location.href, + ); + if (url.protocol !== 'https:' && url.protocol !== 'http:') return false; + // Same-origin requests are the app's own assets/APIs — leave them on native fetch. + return url.origin !== window.location.origin; + } catch { + return false; + } + } + + const patchedFetch: typeof fetch = (input, init) => { + if (shouldDivert(input)) { + // A Request object (not a plain URL/string) carries a body/headers the bridge can't + // currently serialise structurally — pass it through solidFetch via its URL + init only + // for the simple (string/URL) case; a complex Request falls back to native fetch (the SW + // gate still governs the eventual pod read if the app re-issues it through window.solid). + if (typeof input === 'string' || urlLike(input)) { + // autoDivert=true: this is the transparency patch, NOT an explicit window.solid.fetch — + // the SW native-passes-through (no 403) when the request is non-pod / un-granted (High #1). + return solidFetch(input, init, true); + } + } + return PRISTINE_FETCH(input as RequestInfo, init); + }; + + window.addEventListener('solid-extension:grant-patch', () => + postToContent({ type: 'SOLID_GET_STATE' }), + ); + + // Ask the worker for the current session state on load (populates window.solid.webId). + postToContent({ type: 'SOLID_GET_STATE' }); +} diff --git a/apps/extension/src/manifest.json b/apps/extension/src/manifest.json new file mode 100644 index 000000000..43fca9f05 --- /dev/null +++ b/apps/extension/src/manifest.json @@ -0,0 +1,47 @@ +{ + "manifest_version": 3, + "name": "Solid", + "version": "0.2.0", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAivtuQP2w3AxZ/8dBdOYDSwtknAIMzs1TE2hmiCHXe9Zndt/zrn/MsBybc9E27jwZVn41hNKHI1zsFpIyaKezVBUQrSMzE/i6z3Qto4/oz2TBSWkiaz/f18V6qskPBZASoB3/slTTS+7Yx5JpaUOHbB7pN2lgLlKtyxHJ1H4+4htpPNltseZFPu/ID4yVGuLeGgfNUpM3IxMf6Ttt2syq+s24tCrNZY5YJoYPog2+9wTIOgBPvmar099++gxim84WfTtopyz2252cJRl/nkVk/fygGZFZ1PkbERmUWdgQ8XXl7uprfHAwyObCGdUbLnBEVNVVgburf/CsfI8G/WXK+wIDAQAB", + "description": "Sign in to Solid at the browser level — injects window.solid (DPoP-authenticated fetch) and a pinned account menu.", + "permissions": ["storage", "identity", "sidePanel", "contextMenus", "scripting", "alarms"], + "host_permissions": [""], + "background": { + "service_worker": "background/service-worker.js", + "type": "module" + }, + "side_panel": { + "default_path": "sidepanel/sidepanel.html" + }, + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "action": { + "default_popup": "popup/popup.html", + "default_title": "Sign in to Solid", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content/content-script.js"], + "run_at": "document_start", + "world": "ISOLATED", + "all_frames": true + }, + { + "matches": [""], + "js": ["inject/inject.js"], + "run_at": "document_start", + "world": "MAIN" + } + ] +} diff --git a/apps/extension/src/popup/account-surface.ts b/apps/extension/src/popup/account-surface.ts new file mode 100644 index 000000000..4fa44319d --- /dev/null +++ b/apps/extension/src/popup/account-surface.ts @@ -0,0 +1,258 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The shared account UI surface, wired ONCE and reused by BOTH the ephemeral toolbar + * popup (popup.ts) and the persistent MV3 side panel (sidepanel/sidepanel.ts). + * + * Both surfaces show the EXACT same thing — `` when signed out, + * `` + pod shortcuts when signed in — driven by the SAME + * `MessageBridgeLoginController` (the token-free, service-worker-backed bridge in + * message-bridge-controller.ts). The controller is NOT duplicated: this module imports it + * and both entry points call {@link mountAccountSurface} against their own DOM. The auth + * always runs in the service worker (the sole token holder); this surface only reflects + * state and proxies actions over the existing `chrome.runtime` message protocol. + * + * Every invariant the popup hard-won is preserved here verbatim (they are load-bearing): + * - the panel's OWN auto-restore is disabled via the `.autoRestore = false` PROPERTY + * (NOT the ambiguous `auto-restore="false"` markup attribute) so the surface owns the + * restore flow and the panel never races it; + * - a generation token guards refresh() so a slow in-flight refresh can't apply a stale + * view over a newer one; + * - showSignedOut() SWAPS in a fresh hydrated controller (the panel's supported reset) + * so a prior `authenticated` phase can never bleed through inside #signed-out; + * - a logged-OUT session-change only reveals #signed-out (no re-swap → no recursion). + */ + +import type { LoginDetail, SessionChangeDetail } from '@jeswr/solid-elements'; +import type { SessionState } from '../shared/messages'; +import { MessageBridgeLoginController } from './message-bridge-controller'; +import { mountProfileSwitcher } from './profile-switcher'; + +/** The `` element surface this module drives (see popup.ts notes). */ +export interface LoginPanelElement extends HTMLElement { + controller?: MessageBridgeLoginController; + /** + * The panel's own silent-restore-on-connect. We set this FALSE: this surface owns the + * restore flow (refresh() → SOLID_GET_STATE → renderSignedIn), so the panel must NOT + * independently run controller.restore() and flip its internal phase — which would race + * the surface's explicit view control inside the #signed-out section. + */ + autoRestore: boolean; + requestUpdate(): void; +} + +/** The DOM handles a hosting page (popup.html / sidepanel.html) provides to this surface. */ +export interface AccountSurfaceElements { + readonly signedOut: HTMLElement; + readonly busy: HTMLElement; + readonly signedIn: HTMLElement; + readonly loginPanel: LoginPanelElement; + readonly accountMenu: HTMLElement; + readonly shortcutProfile: HTMLAnchorElement; +} + +type View = 'signed-out' | 'busy' | 'signed-in'; + +/** + * What {@link mountAccountSurface} returns: the live bridge controller (so callers/tests can + * inspect it) plus a `cleanup` that detaches every listener this mount added (notably the + * long-lived `chrome.runtime.onMessage` listener). The ephemeral popup can ignore the result; + * the persistent side panel MUST call `cleanup()` on teardown, otherwise a remount accumulates + * duplicate `onMessage` listeners (and duplicate refresh() calls) on the long-lived document. + */ +export interface MountedAccountSurface { + readonly controller: MessageBridgeLoginController; + readonly cleanup: () => void; +} + +/** + * A weak guard against double-mounting the SAME document: each mount tags its host document so a + * second mount (e.g. an accidental re-import in the long-lived side panel) is a no-op that just + * returns the existing mount's handle rather than stacking a second set of listeners. + */ +const MOUNTED = new WeakMap(); + +/** Resolve the standard surface elements from a root document by their shared ids. */ +export function resolveAccountSurfaceElements(doc: Document = document): AccountSurfaceElements { + const byId = (id: string): T => doc.getElementById(id) as T; + return { + signedOut: byId('signed-out'), + busy: byId('busy'), + signedIn: byId('signed-in'), + loginPanel: byId('login-panel'), + accountMenu: byId('account-menu'), + shortcutProfile: byId('shortcut-profile'), + }; +} + +function send(message: unknown): Promise { + return new Promise((resolve) => chrome.runtime.sendMessage(message, resolve)); +} + +/** + * Wire the shared account surface against `els` and kick off the initial paint + * ("Restoring…" → state read → signed-in / signed-out). Returns the live controller + * instance (the bridge) plus a `cleanup` that detaches every listener added here (so the + * long-lived side panel can tear down without leaking the `chrome.runtime.onMessage` + * listener). Guarded against double-mounting the same document: a second call on a document + * already mounted returns the existing handle without stacking another set of listeners. + * + * The ephemeral popup may ignore the return (it is torn down with its document); the + * persistent side panel MUST call `cleanup()` on teardown / before any remount. + */ +export function mountAccountSurface(els: AccountSurfaceElements): MountedAccountSurface { + const { signedOut, busy, signedIn, loginPanel, accountMenu, shortcutProfile } = els; + + // Double-mount guard: if THIS document is already mounted, do not stack a second set of + // listeners (the long-lived side panel's failure mode) — return the existing handle. + const hostDoc = loginPanel.ownerDocument; + const existing = MOUNTED.get(hostDoc); + if (existing) return existing; + + // Disable the panel's OWN silent-restore-on-connect via the PROPERTY (not a markup + // attribute — `auto-restore="false"` reads ambiguous next to Lit's boolean-attribute + // trap). This surface OWNS restore: the initial paint shows "Restoring…" and calls + // refresh() → SOLID_GET_STATE, landing a returning user signed-in with no re-auth. + // Set BEFORE the controller is assigned so the willUpdate that assignment triggers + // already sees autoRestore=false and never kicks the panel's own restore. + loginPanel.autoRestore = false; + + // The bridge: the panel's synchronous LoginController, backed by the worker over the + // async message protocol. Holds no token. A `let` so we can SWAP it for a fresh instance + // when transitioning to signed-out (the panel's supported reset path). + let bridge = new MessageBridgeLoginController(); + loginPanel.controller = bridge; + + function show(view: View): void { + signedOut.hidden = view !== 'signed-out'; + busy.hidden = view !== 'busy'; + signedIn.hidden = view !== 'signed-in'; + } + + // A monotonic generation token: each refresh() bumps + captures its own value so a slower + // in-flight refresh resolving AFTER a newer one cannot apply a stale view. + let refreshGeneration = 0; + + /** + * Show the signed-out surface with the panel reset to the login prompt. Builds a fresh + * controller (the panel's supported reset), HYDRATES it first (so the panel renders the + * recent-accounts list on the swap render), THEN assigns it. Returns false if a newer + * refresh superseded this one before the section was shown. + */ + async function showSignedOut(generation: number): Promise { + const fresh = new MessageBridgeLoginController(); + await fresh.hydrate(); + if (generation !== refreshGeneration) return false; // superseded mid-hydrate + bridge = fresh; + loginPanel.controller = bridge; // swap → panel reconciles _phase to the prompt + show('signed-out'); + return true; + } + + function renderSignedIn(state: SessionState): void { + accountMenu.setAttribute('webid', state.webId ?? ''); + accountMenu.setAttribute('name', state.name ?? ''); + const name = hostDoc.getElementById('account-name'); + const host = hostDoc.getElementById('account-host'); + if (name) name.textContent = state.name || 'Your account'; + if (host) { + try { + host.textContent = new URL(state.webId ?? '').host; + } catch { + host.textContent = ''; + } + } + if (state.photoUrl) accountMenu.setAttribute('avatar-url', state.photoUrl); + else accountMenu.removeAttribute('avatar-url'); + + if (state.webId) { + shortcutProfile.href = state.webId.split('#')[0]; + shortcutProfile.hidden = false; + } else { + shortcutProfile.hidden = true; + } + show('signed-in'); + } + + /** + * Read state from the worker and switch the view. Generation-guarded so a stale in-flight + * refresh cannot apply an outdated view. + */ + async function refresh(): Promise { + const generation = ++refreshGeneration; + const state = await send({ type: 'SOLID_GET_STATE' }); + if (generation !== refreshGeneration) return; // a newer refresh superseded this one + if (state?.isActive && state.webId) { + renderSignedIn(state); + } else { + await showSignedOut(generation); + } + } + + // --- Wiring ------------------------------------------------------------------------- + + // Track every detach so cleanup() removes ALL listeners this mount added (the long-lived + // side panel relies on this; the ephemeral popup may ignore it). + const profiles = mountProfileSwitcher(signedIn.parentElement!); + const detachers: Array<() => void> = [profiles.cleanup]; + + // The panel drives login via the bridge (chrome.identity flow in the worker). + const onLogin = (e: Event): void => { + const detail = (e as CustomEvent).detail; + if (detail?.webId) void refresh(); + }; + loginPanel.addEventListener('login', onLogin); + detachers.push(() => loginPanel.removeEventListener('login', onLogin)); + + const onSessionChange = (e: Event): void => { + const detail = (e as CustomEvent).detail; + // A logged-IN change → confirm with the worker and render signed-in. A logged-OUT + // change (incl. the one emitted during the showSignedOut() swap) only needs the + // signed-out section visible — NOT another swap (which would re-emit and recurse). + if (detail?.loggedIn) void refresh(); + else show('signed-out'); + }; + loginPanel.addEventListener('session-change', onSessionChange); + detachers.push(() => loginPanel.removeEventListener('session-change', onSessionChange)); + + // The account-menu emits `sign-out` → tear down in the worker, then re-render. + const onSignOut = (): void => { + void send({ type: 'SOLID_LOGOUT' }).then(() => refresh()); + }; + accountMenu.addEventListener('sign-out', onSignOut); + detachers.push(() => accountMenu.removeEventListener('sign-out', onSignOut)); + + // React to background state broadcasts while the surface is open (a page logs in, or the + // worker's token expires/logs out): re-read state and re-render. This is the LONG-LIVED + // listener: the side panel persists across navigation, so a remount without cleanup would + // accumulate duplicate onMessage listeners (and duplicate refresh() calls). + const onRuntimeMessage = (message: { type?: string }): void => { + if (message?.type === 'SOLID_STATE_CHANGED') { + void refresh(); + void profiles.controller.refresh(); + } + }; + chrome.runtime.onMessage.addListener(onRuntimeMessage); + detachers.push(() => chrome.runtime?.onMessage?.removeListener?.(onRuntimeMessage)); + + // Initial paint: show "Restoring" while we read state. + show('busy'); + void refresh(); + + let cleaned = false; + const cleanup = (): void => { + if (cleaned) return; // idempotent + cleaned = true; + refreshGeneration++; // fence: any in-flight refresh resolving after teardown is a no-op + for (const detach of detachers) detach(); + MOUNTED.delete(hostDoc); + }; + + const mounted: MountedAccountSurface = { + get controller() { + return bridge; + }, + cleanup, + }; + MOUNTED.set(hostDoc, mounted); + return mounted; +} diff --git a/apps/extension/src/popup/message-bridge-controller.ts b/apps/extension/src/popup/message-bridge-controller.ts new file mode 100644 index 000000000..ac9a166ce --- /dev/null +++ b/apps/extension/src/popup/message-bridge-controller.ts @@ -0,0 +1,379 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * MessageBridgeLoginController — a chrome.identity-backed {@link LoginController} that + * lets the popup adopt `` (the suite-wide login surface, for visual + * parity with Pod Manager) WITHOUT breaking the extension's credential boundary. + * + * The conflict it resolves: the LoginController contract is SYNCHRONOUS + in-process — + * `webId`/`publicFetch`/`authenticatedFetch` are read-only getters the panel reads + * during render — but the MV3 extension splits the UI (popup) from the credential + * holder (service worker). The token, the DPoP key, the OIDC flow and every + * authenticated fetch live ONLY in the SW; the popup reaches it via the inherently + * ASYNC `chrome.runtime` message protocol. We bridge the two by keeping a small + * TOKEN-FREE popup-local mirror of the session metadata (so the sync getters can + * answer) and proxying every real auth action/fetch to the SW. + * + * NO-TOKEN-LEAK INVARIANT (by construction): + * 1. The only mutable fields are `#webId` (string|null) and `#recentAccounts` + * (display metadata). There is NO field that can hold an accessToken, + * refreshToken, or a DPoP CryptoKeyPair. Every value comes from `SessionState`, + * which deliberately carries no token. + * 2. `publicFetch` is a MODULE-LOAD pristine `fetch` snapshot. The popup never + * patches `globalThis.fetch`, so this stays provably credential-free. + * 3. `authenticatedFetch` is a PROXY: it forwards a `SOLID_FETCH_REQUEST` to the SW + * and rebuilds a `Response` from the reply. The DPoP-bound token is attached + * inside the SW's `handleFetch` (origin-gated, token-endpoint-guarded) and only + * status/filtered-headers/body come back — the popup never sees the credential. + * + * The credential core (auth-flow.ts / session-store.ts / the SW router) is UNTOUCHED: + * this controller is expressed entirely over the EXISTING message protocol, with NO + * new wire types. + */ + +import type { + LoginController, + LoginResult, + RecentLoginAccount, + RestoreOutcome, +} from '@jeswr/solid-elements'; +import type { + ActionResult, + FetchResponse, + GetStateRequest, + LoginRequest, + LogoutRequest, + SessionState, +} from '../shared/messages'; + +/** + * The pristine native `fetch`, captured at MODULE LOAD before any code could patch the + * global. This is the credential-free foreign-origin / public-read boundary the panel + * exposes as `.publicFetch`. The popup never installs a patched global, so this remains + * the genuine, uncredentialed `fetch`. + */ +const MODULE_PRISTINE_FETCH: typeof fetch = globalThis.fetch.bind(globalThis); + +/** + * Caller-supplied credential/transport headers that must NEVER cross to the worker: the + * worker is the SOLE credential authority and attaches its own DPoP-bound Authorization. + * Defense-in-depth (the worker's own `authenticatedFetch` re-strips these too) — stripping + * popup-side keeps the wire clean and the boundary robust even if a caller passes one. + */ +const STRIPPED_REQUEST_HEADERS = new Set(['authorization', 'dpop']); + +/** + * HTTP statuses that MUST NOT carry a body (the Fetch spec "null body status" set). + * `new Response(body, { status })` throws for these unless `body` is null — and Solid + * write/delete operations commonly return `204 No Content`. + */ +const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]); + +/** + * Whether an EXPLICIT request `Content-Type` is text-safe to carry over the (text-only) + * protocol. NOTE the asymmetry vs. {@link isBinaryRequestBody}, which is intentional: + * + * - A PRESENT content-type is authoritative. `multipart/*` and any non-text/non-json/ + * non-urlencoded type returns `false` → the body is REJECTED (never silently + * `.text()`-coerced, which would corrupt a binary resource write). + * - A MISSING content-type (`null`) returns `true` here — preserving the common Solid + * RDF (Turtle) write case where a body may arrive without a declared type — BUT the + * ambiguous missing-CT path is NOT trusted blindly: the proxy ALSO runs + * {@link isBinaryRequestBody} over the actual bytes for a missing-CT Request body, so + * a genuinely binary payload with no content-type still fails loudly. See + * `#proxyFetch` for the combined policy. + */ +function isTextContentType(contentType: string | null): boolean { + if (!contentType) return true; + const ct = contentType.split(';', 1)[0].trim().toLowerCase(); + if (ct.startsWith('multipart/')) return false; + return ( + ct.startsWith('text/') || + ct === 'application/json' || + ct.endsWith('+json') || + ct.endsWith('+xml') || + ct === 'application/xml' || + ct === 'application/x-www-form-urlencoded' || + ct === 'application/sparql-update' || + ct === 'application/sparql-query' || + ct === 'application/ld+json' || + ct === 'application/trig' || + ct === 'application/n-quads' || + ct === 'application/n-triples' + ); +} + +/** + * Detect a genuinely BINARY request body by inspecting the raw bytes — the guard for the + * AMBIGUOUS "missing Content-Type" path, where the declared type can't tell us whether + * the source was a string or a binary blob. + * + * Why bytes, not the body-source object: a `Request` exposes its body only as an opaque + * `ReadableStream`, so we cannot recover whether it was constructed from a string vs a + * Blob/ArrayBuffer/typed-array. We CAN, however, decode the bytes as strict UTF-8 and see + * whether they round-trip losslessly: a `TextDecoder('utf-8', { fatal: true })` THROWS on + * any byte sequence that is not valid UTF-8 (PNG/gzip/most binary). Valid-UTF-8 text — the + * Solid Turtle/RDF write case — decodes cleanly and is treated as text. (The Request + * constructor itself auto-sets `text/plain` for a string body, so a missing CT already + * signals a non-string source; this bytes check is the belt-and-braces confirmation that + * still admits the legitimate untyped-text case.) + * + * Returns `true` ⇒ reject as unsupported binary; `false` ⇒ safe to carry as text. + */ +function isBinaryRequestBody(bytes: Uint8Array): boolean { + try { + new TextDecoder('utf-8', { fatal: true }).decode(bytes); + return false; // valid UTF-8 → safe to send as text + } catch { + return true; // invalid UTF-8 → genuinely binary, must not be text-coerced + } +} + +/** Promise-wrap the callback form of `chrome.runtime.sendMessage`. */ +function send(message: unknown): Promise { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage(message, (response: T) => { + const lastError = chrome.runtime.lastError; + if (lastError) { + reject(new Error(lastError.message ?? 'Message channel error')); + return; + } + resolve(response); + }); + }); +} + +/** + * Map the SW's credential-free recent-account record onto the panel's + * {@link RecentLoginAccount}. Only the three KNOWN display fields are copied — a + * (hypothetical / hostile) extra field on the reply is never surfaced. + */ +function toRecentLoginAccount(account: { + webId: string; + name: string | null; + photoUrl: string | null; +}): RecentLoginAccount { + return { + webId: account.webId, + displayName: account.name ?? account.webId, + avatarUrl: account.photoUrl ?? undefined, + }; +} + +export class MessageBridgeLoginController implements LoginController { + /** Token-free mirror: the active WebID, or null. Populated from `SessionState`. */ + #webId: string | null = null; + + /** Token-free mirror: recent accounts for the returning-user affordance. */ + #recentAccounts: RecentLoginAccount[] = []; + + /** + * The pristine native fetch — provably credential-free (see {@link MODULE_PRISTINE_FETCH}). + * Foreign-origin / public reads run uncredentialed in the popup realm. + */ + get publicFetch(): typeof fetch { + return MODULE_PRISTINE_FETCH; + } + + /** + * The session-bound fetch. BEFORE login (no WebID in the mirror) this is the pristine + * native fetch — matching the LoginController contract that pre-login `authenticatedFetch` + * behaves like {@link publicFetch} (there is no session to bind), so a panel `.fetch` + * consumer is not broken before a session exists. AFTER login it is a PROXY closure: + * each call forwards a `SOLID_FETCH_REQUEST` to the SW (which attaches the DPoP-bound + * token) and reconstructs a `Response` from the returned `FetchResponse`. The popup + * never holds the token, the DPoP key, or a session-bound fetch handle — only the + * forwarding closure. + */ + get authenticatedFetch(): typeof fetch { + if (this.#webId === null) return MODULE_PRISTINE_FETCH; + return (input: RequestInfo | URL, init?: RequestInit): Promise => + this.#proxyFetch(input, init); + } + + /** The active WebID, read synchronously from the mirror (no await). */ + get webId(): string | null { + return this.#webId; + } + + /** Recent accounts, read synchronously from the mirror. */ + recentAccounts(): RecentLoginAccount[] { + return this.#recentAccounts; + } + + /** + * Silent restore on load: ask the SW whether a session already exists + * (`SOLID_GET_STATE`). This is the correct MV3 expression of silent restore — the + * durable refresh token lives in the SW/chrome.storage, never the popup; the SW + * re-hydrates (and refreshes) its session, and we just ask whether one is active. + * Fail-closed: ANY send/shape error resolves to `{ outcome: 'login' }` (never throws). + */ + async restore(): Promise { + try { + const state = await this.#getState(); + if (state.isActive && state.webId) { + return { outcome: 'restored', webId: state.webId }; + } + return { outcome: 'login' }; + } catch { + return { outcome: 'login' }; + } + } + + /** + * Run the interactive login for `webId` (or, when omitted, a re-login of the single + * recent account). The SW runs the full chrome.identity OIDC/PKCE/DPoP flow. On + * `ok` we update the mirror and resolve with the WebID; on `error` we REJECT and do + * NOT mutate the mirror (the panel surfaces the message). + */ + async login(webId?: string): Promise { + const target = webId ?? this.#defaultLoginWebId(); + if (!target) { + throw new Error('No WebID provided and no recent account to re-login.'); + } + const request: LoginRequest = { type: 'SOLID_LOGIN', webId: target }; + const result = await send(request); + if (result?.error) { + throw new Error(result.error); + } + if (!result?.ok || !result.webId) { + throw new Error('Login failed.'); + } + this.#webId = result.webId; + return { webId: result.webId }; + } + + /** + * Log out via the SW. Clear `#webId` ONLY on an `ok` result; on `error` reject AND + * leave `#webId` intact so the panel's reconcile-against-`controller.webId` keeps the + * signed-in UI and surfaces the error (rather than falsely showing a logged-out + * prompt while the SW still holds a session). + */ + async logout(): Promise { + const request: LogoutRequest = { type: 'SOLID_LOGOUT' }; + const result = await send(request); + if (result?.error) { + throw new Error(result.error); + } + if (!result?.ok) { + throw new Error('Logout failed.'); + } + this.#webId = null; + } + + /** + * Re-read `SOLID_GET_STATE` and refresh the mirror. The popup calls this on a + * `SOLID_STATE_CHANGED` broadcast (an external context changed the session) so the + * sync getters reflect reality on the next panel render. Fail-soft: a send error + * leaves the mirror unchanged (the next broadcast/restore self-corrects). + */ + async hydrate(): Promise { + try { + await this.#getState(); + } catch { + // Leave the mirror as-is; SOLID_STATE_CHANGED / restore will retry. + } + } + + /** + * Fetch state from the SW and refresh the token-free mirror; returns the state. + * Maps the WHOLE response into locals FIRST (so a malformed shape throws before any + * field is written) and only then assigns `#webId` + `#recentAccounts` together — + * keeping restore()/hydrate() genuinely fail-closed (a throw never leaves the mirror + * half-updated with a non-null webId). + */ + async #getState(): Promise { + const request: GetStateRequest = { type: 'SOLID_GET_STATE' }; + const state = await send(request); + const webId = state.isActive && state.webId ? state.webId : null; + const recentAccounts = (state.recentAccounts ?? []).map(toRecentLoginAccount); + // Atomic commit — only reached if the mapping above did not throw. + this.#webId = webId; + this.#recentAccounts = recentAccounts; + return state; + } + + /** The webId to use when `login()` is called without one: the single/first recent account. */ + #defaultLoginWebId(): string | null { + return this.#recentAccounts[0]?.webId ?? null; + } + + /** + * The authenticatedFetch proxy: forward to the SW, rebuild a Response from the reply. + * Normalises `RequestInfo | URL` the way `fetch` does — a `Request` contributes its + * url/method/headers/body, and an explicit `init` overrides — rather than coercing the + * input to a string (which would send `"[object Request]"` and drop method/headers/body). + * Text-only bodies today (binary is a documented future protocol extension). + */ + async #proxyFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const isRequest = typeof Request !== 'undefined' && input instanceof Request; + const url = isRequest ? input.url : input.toString(); + const method = init?.method ?? (isRequest ? input.method : 'GET'); + + // Headers: a Request's headers first, then init overrides win (fetch semantics). + const headers = new Headers(isRequest ? input.headers : undefined); + if (init?.headers) { + for (const [k, v] of new Headers(init.headers).entries()) headers.set(k, v); + } + // Strip any caller-supplied credential headers — the worker attaches its own. + for (const h of STRIPPED_REQUEST_HEADERS) headers.delete(h); + + // Body: init.body wins; otherwise a Request's body. The protocol is text-only today + // (binary is a documented future extension), so reject NON-text bodies LOUDLY rather + // than silently text-coercing a binary payload (which would corrupt the write). The + // policy (see isTextContentType / isBinaryRequestBody): + // • init.body — a non-string value is binary by construction → reject; a string is text. + // • a Request body with an EXPLICIT non-text Content-Type (multipart/image/…) → reject. + // • a Request body with a missing/ambiguous Content-Type — preserve the common Solid + // RDF (Turtle) untyped-text write, BUT confirm against the raw BYTES: valid UTF-8 is + // sent as text, non-UTF-8 (genuinely binary) is rejected, not corrupted. + let body: string | null = null; + if (init?.body != null) { + if (typeof init.body !== 'string') { + throw new Error('authenticatedFetch currently supports only string request bodies.'); + } + body = init.body; + } else if (isRequest && input.body) { + // Normalize an EMPTY / whitespace-only Content-Type to "missing" (null) so it takes + // the same ambiguous-but-byte-verified path as a no-Content-Type body — an empty + // header must not be trusted as text-safe and bypass the binary guard (roborev 3495). + const rawContentType = input.headers.get('content-type'); + const contentType = rawContentType && rawContentType.trim() !== '' ? rawContentType : null; + if (!isTextContentType(contentType)) { + throw new Error('authenticatedFetch currently supports only text request bodies.'); + } + // Read the bytes once. For an explicitly text-typed body we trust the type and decode; + // for the ambiguous MISSING/empty content-type we additionally verify the bytes are valid + // UTF-8 text — a binary payload with no usable content-type must fail loudly, not be coerced. + const bytes = new Uint8Array(await input.clone().arrayBuffer()); + if (contentType === null && isBinaryRequestBody(bytes)) { + throw new Error( + 'authenticatedFetch currently supports only text request bodies (binary body with no Content-Type).', + ); + } + body = new TextDecoder().decode(bytes); + } + + const requestId = crypto.randomUUID(); + const reply = await send({ + type: 'SOLID_FETCH_REQUEST', + requestId, + url, + method, + headers: Object.fromEntries(headers.entries()), + body, + }); + if (reply?.error) { + throw new Error(reply.error); + } + // Null-body statuses (the Fetch spec set) MUST be constructed with a null body — + // `new Response('', { status: 204 })` throws. The SW serialises an empty body as '' + // for these, so coerce to null. Solid writes/deletes commonly return 204 No Content. + const status = reply.status ?? 200; + const nullBody = NULL_BODY_STATUSES.has(status); + return new Response(nullBody ? null : (reply.body ?? null), { + status, + statusText: reply.statusText, + headers: new Headers(reply.headers ?? {}), + }); + } +} diff --git a/apps/extension/src/popup/popup.css b/apps/extension/src/popup/popup.css new file mode 100644 index 000000000..e5230c499 --- /dev/null +++ b/apps/extension/src/popup/popup.css @@ -0,0 +1,33 @@ +/* AUTHORED-BY Codex GPT-6.1-Sol */ +@import url('../shared/account-surface.css'); + +[hidden] { display: none !important; } +body { width: 340px; max-width: 100vw; } +.pin-nudge { + display: grid; + grid-template-columns: var(--space-4) minmax(0, 1fr); + align-items: start; + gap: var(--space-2); + padding: var(--space-3); + margin-bottom: var(--space-4); + border-radius: var(--radius-lg); + background: var(--primary-soft); + color: var(--primary-soft-foreground); + font-size: var(--text-sm); + line-height: var(--lh-sm); + font-weight: var(--weight-sm); +} +.pin-icon { width: var(--space-4); height: var(--space-4); } +.link-btn { + grid-column: 2; + justify-self: end; + padding: 0 var(--space-1); + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: inherit; + font: inherit; + font-weight: var(--weight-lg); + cursor: pointer; +} +.link-btn:hover { text-decoration: underline; } diff --git a/apps/extension/src/popup/popup.html b/apps/extension/src/popup/popup.html new file mode 100644 index 000000000..6e048cfee --- /dev/null +++ b/apps/extension/src/popup/popup.html @@ -0,0 +1,75 @@ + + + + + + Solid + + + + + + + +
+
+
+ +

Solid

+
+ +
+ + + + +
+ + + + + + + + +
+
+ + + diff --git a/apps/extension/src/popup/popup.ts b/apps/extension/src/popup/popup.ts new file mode 100644 index 000000000..51baf7804 --- /dev/null +++ b/apps/extension/src/popup/popup.ts @@ -0,0 +1,34 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The popup IS the account UI — sign in / out, switch WebID, pod shortcuts, theme — + * built with @jeswr/solid-elements (`jeswr-login-panel` + `jeswr-account-menu` + + * `jeswr-theme-toggle`) so it is visually consistent with Pod Manager and the rest of + * the suite. The popup does NOT run its own auth: it talks to the service worker (the + * sole token holder) over the message protocol and reflects state. + * + * The account surface (view switching + the MessageBridgeLoginController wiring) is + * shared with the persistent side panel via {@link mountAccountSurface} — the popup and + * the side panel render the IDENTICAL signed-in/signed-out surface from the SAME + * controller seam (see account-surface.ts; the controller is reused, never duplicated). + * The only popup-specific chrome here is the first-run "pin me" nudge (extensions cannot + * self-pin), which the side panel has no need for. + */ + +// Side-effect import: registers , , , etc. +import '@jeswr/solid-elements'; +import { mountAccountSurface, resolveAccountSurfaceElements } from './account-surface'; + +const el = (id: string): T => document.getElementById(id) as T; + +// Wire the shared account surface (busy → signed-in / signed-out, all the auth invariants). +mountAccountSurface(resolveAccountSurfaceElements()); + +// First-run pin nudge (extensions can't self-pin) — popup-only onboarding. +const pinNudge = el('pin-nudge'); +chrome.storage.local.get('solid:show-pin-nudge').then((result) => { + if (result['solid:show-pin-nudge']) pinNudge.hidden = false; +}); +el('pin-nudge-dismiss').addEventListener('click', () => { + pinNudge.hidden = true; + void chrome.storage.local.remove('solid:show-pin-nudge'); +}); diff --git a/apps/extension/src/popup/profile-controller.ts b/apps/extension/src/popup/profile-controller.ts new file mode 100644 index 000000000..c34278d37 --- /dev/null +++ b/apps/extension/src/popup/profile-controller.ts @@ -0,0 +1,158 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import type { Profile, ProfileRequest, ProfilesResponse } from '../shared/messages'; + +export interface ProfileViewState { + readonly profiles: readonly Profile[]; + readonly defaultProfileId: string | null; + readonly ready: boolean; + readonly busy: boolean; + readonly error: string; + readonly status: string; +} + +/** Display metadata only; auth, capacity, default selection and purging stay in the worker. */ +export class ProfileController { + state: ProfileViewState = { + profiles: [], + defaultProfileId: null, + ready: false, + busy: false, + error: '', + status: '', + }; + private generation = 0; + private disposed = false; + private refreshQueued = false; + + constructor( + private readonly render: (state: ProfileViewState) => void, + private readonly send: ( + message: ProfileRequest, + ) => Promise = sendProfileMessage, + ) {} + + async refresh(): Promise { + if (this.disposed) return; + if (this.state.busy) { + this.refreshQueued = true; + return; + } + const generation = ++this.generation; + try { + const reply = await this.send({ type: 'SOLID_PROFILES_LIST' }); + if (this.disposed || generation !== this.generation) return; + this.apply(reply); + } catch (error) { + if (this.disposed || generation !== this.generation) return; + this.update({ error: errorMessage(error) }); + } + } + + async retry(): Promise { + if (this.disposed || this.state.busy) return; + // A failed removal may have revoked the session before returning an error. + await this.refresh(); + if (this.disposed || this.state.error) return; + for (const profile of this.state.profiles) { + if (profile.sessionState === 'removing' && !(await this.remove(profile.id))) return; + } + } + + add(webId: string): Promise { + return this.mutate({ type: 'SOLID_PROFILE_ADD', webId: webId.trim() }, 'Account added.'); + } + + rename(profileId: string, label: string): Promise { + return this.mutate( + { type: 'SOLID_PROFILE_RENAME', profileId, label: label.trim() }, + 'Account renamed.', + ); + } + + remove(profileId: string): Promise { + return this.mutate({ type: 'SOLID_PROFILE_REMOVE', profileId }, 'Account removed.'); + } + + setDefault(profileId: string): Promise { + return this.mutate( + { type: 'SOLID_PROFILE_SET_DEFAULT', profileId }, + 'Default profile changed.', + ); + } + + dispose(): void { + this.disposed = true; + this.generation++; + } + + private async mutate(message: ProfileRequest, status: string): Promise { + if (this.disposed || this.state.busy) return false; + // Fence older list reads. Broadcasts during this mutation cannot replace its result. + this.generation++; + this.update({ busy: true, error: '', status: 'Updating profiles…' }); + try { + const reply = await this.send(message); + if (this.disposed) return false; + this.apply(reply); + this.update({ busy: false, status }); + return true; + } catch (error) { + if (!this.disposed && message.type === 'SOLID_PROFILE_REMOVE') { + try { + // Revocation may already be committed. Reconcile queued broadcasts while still + // busy, then keep the removal error visible alongside the worker's current list. + do { + this.refreshQueued = false; + const reply = await this.send({ type: 'SOLID_PROFILES_LIST' }); + if (this.disposed) return false; + this.apply(reply); + } while (this.refreshQueued); + } catch { + // If storage is also unavailable, retain the last list and the removal error. + } + } + if (!this.disposed) this.update({ busy: false, status: '', error: errorMessage(error) }); + return false; + } finally { + // A different open surface may have changed profiles while this reply was in flight. + if (this.refreshQueued) { + this.refreshQueued = false; + void this.refresh(); + } + } + } + + private apply(reply: ProfilesResponse): void { + if (reply?.error) throw new Error(reply.error); + if (!reply?.ok || !Array.isArray(reply.profiles) || reply.defaultProfileId === undefined) { + throw new Error('Could not load profiles. Try again.'); + } + this.update({ + profiles: reply.profiles, + defaultProfileId: reply.defaultProfileId, + ready: true, + error: '', + }); + } + + private update(patch: Partial): void { + this.state = { ...this.state, ...patch }; + this.render(this.state); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error + ? error.message + : 'Something went wrong with that account. Try again.'; +} + +function sendProfileMessage(message: ProfileRequest): Promise { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage(message, (reply: ProfilesResponse) => { + const error = chrome.runtime.lastError; + if (error) reject(new Error(error.message ?? 'Could not contact the extension.')); + else resolve(reply); + }); + }); +} diff --git a/apps/extension/src/popup/profile-switcher.ts b/apps/extension/src/popup/profile-switcher.ts new file mode 100644 index 000000000..2b93d7ed6 --- /dev/null +++ b/apps/extension/src/popup/profile-switcher.ts @@ -0,0 +1,341 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import type { Profile } from '../shared/messages'; +import { ProfileController, type ProfileViewState } from './profile-controller'; + +/** One native, labelled form shared by the popup and persistent side panel. */ +export function mountProfileSwitcher(parent: HTMLElement): { + controller: ProfileController; + cleanup: () => void; +} { + const doc = parent.ownerDocument; + const root = doc.createElement('section'); + root.id = 'profiles'; + root.setAttribute('aria-labelledby', 'profiles-heading'); + root.innerHTML = ` +

Accounts

+
+

Choose the default account for Solid.

+
    + + + + +

    Loading accounts…

    + + `; + parent.append(root); + const get = (id: string): T => root.querySelector(`#${id}`) as T; + const list = get('profile-list'); + const editor = get('profile-editor'); + const input = get('profile-input'); + const add = get('profile-add'); + const save = get('profile-save'); + let editing: { action: 'add' | 'rename' | 'remove'; profile?: Profile; trigger: string } | null = + null; + let listSignature = ''; + let pendingFocus: string | null = null; + // A focused control that a render disabled, hid or removed; restored (or replaced by a + // usable fallback) once the surface is idle. + let busyFocus: HTMLElement | null = null; + let disposed = false; + + function button(text: string, key: string, label: string, click: () => void): HTMLButtonElement { + const el = doc.createElement('button'); + el.type = 'button'; + el.textContent = text; + el.dataset.focus = key; + el.setAttribute('aria-label', label); + el.title = label; + el.addEventListener('click', () => { + if (!controller.state.busy) click(); + }); + return el; + } + + function restoreFocus(key: string): void { + pendingFocus = key; + if (controller.state.busy) return; + const controls = Array.from(list.querySelectorAll('button')).filter( + (el) => !el.disabled, + ); + const target = controls.find((el) => el.dataset.focus === key); + (target ?? (add.disabled ? controls[0] : add))?.focus(); + pendingFocus = null; + } + + function closeEditor(): void { + const key = editing?.trigger ?? ''; + editing = null; + editor.hidden = true; + input.setCustomValidity(''); + // Return focus to the trigger only when the editor owned it (or it was lost); never pull + // focus back from a usable control the user moved to while an operation completed. + const active = doc.activeElement; + if (active === null || active === doc.body || root.contains(active)) restoreFocus(key); + } + + function openEditor(action: 'add' | 'rename' | 'remove', profile?: Profile): void { + editing = { action, profile, trigger: profile ? `${action}:${profile.id}` : '' }; + editor.hidden = false; + get('profile-editor-heading').textContent = + action === 'add' + ? 'Add account' + : `${action === 'rename' ? 'Rename' : 'Remove'} ${profile?.label}`; + get('profile-editor-help').textContent = + action === 'remove' + ? 'Removing this account revokes its app access and deletes its saved data from this extension. Your pod is kept.' + : action === 'add' + ? 'Enter your account ID to sign in. Your current default stays selected.' + : 'Choose a name to help you recognise this account.'; + const removal = action === 'remove'; + input.hidden = removal; + input.required = !removal; + get('profile-input-label').hidden = removal; + get('profile-input-label').textContent = action === 'add' ? 'Account ID' : 'Account name'; + input.type = action === 'add' ? 'url' : 'text'; + input.maxLength = action === 'rename' ? 200 : 2048; + input.value = action === 'rename' ? (profile?.label ?? '') : ''; + input.setCustomValidity(''); + save.textContent = removal + ? 'Remove account' + : action === 'add' + ? 'Sign in and add' + : 'Save name'; + save.classList.toggle('profile-destructive', removal); + // Destructive confirmation starts on Cancel; Enter on opening cannot remove a profile. + if (removal) get('profile-cancel').focus(); + else input.focus(); + } + + function focusable(el: HTMLElement): boolean { + return ( + el.isConnected && + root.contains(el) && + !(el as HTMLButtonElement).disabled && + el.closest('[hidden]') === null + ); + } + + function render(state: ProfileViewState): void { + root.setAttribute('aria-busy', String(state.busy)); + // The control focused before this render; if the render disables, hides or replaces it, + // focus is lost (browsers fix it up to lazily) and is recovered once idle. + const before = doc.activeElement as HTMLElement | null; + const hadFocus = before !== null && root.contains(before); + const signature = JSON.stringify([state.profiles, state.defaultProfileId]); + if (signature !== listSignature) { + listSignature = signature; + const active = doc.activeElement as HTMLElement | null; + const focusKey = list.contains(active) ? active?.dataset.focus : undefined; + list.replaceChildren(); + for (const profile of state.profiles) { + const row = doc.createElement('li'); + row.dataset.profileId = profile.id; + const identity = doc.createElement('div'); + identity.className = 'profile-identity'; + const avatar = doc.createElement('span'); + avatar.className = 'profile-avatar'; + avatar.setAttribute('aria-hidden', 'true'); + avatar.textContent = + profile.label + .trim() + .split(/\s+/) + .map((part) => part[0]) + .slice(0, 2) + .join('') + .toUpperCase() || '?'; + if (profile.avatar && safeAvatar(profile.avatar)) { + const image = doc.createElement('img'); + image.alt = ''; + image.referrerPolicy = 'no-referrer'; + image.src = profile.avatar; + image.addEventListener('error', () => image.remove(), { once: true }); + avatar.append(image); + } + const text = doc.createElement('div'); + text.className = 'profile-text'; + const name = doc.createElement('strong'); + name.textContent = profile.label; + const id = doc.createElement('small'); + try { + id.textContent = new URL(profile.webId).host; + } catch { + id.textContent = 'Account address unavailable'; + } + id.className = 'profile-id'; + id.title = id.textContent; + const session = doc.createElement('small'); + session.textContent = + profile.sessionState === 'active' + ? 'Signed in' + : profile.sessionState === 'removing' + ? 'Removal pending' + : 'Sign-in needed'; + text.append(name, id, session); + identity.append(avatar, text); + const actions = doc.createElement('div'); + actions.className = 'profile-actions'; + const selected = state.defaultProfileId === profile.id; + const select = button( + selected ? 'Default' : 'Set default', + `default:${profile.id}`, + `Set ${profile.label} as default`, + () => { + void controller.setDefault(profile.id); + }, + ); + select.setAttribute('aria-pressed', String(selected)); + if (selected) { + // Lucide icon, ISC licence + const check = doc.createElementNS('http://www.w3.org/2000/svg', 'svg'); + check.setAttribute('class', 'profile-check'); + check.setAttribute('viewBox', '0 0 24 24'); + check.setAttribute('fill', 'none'); + check.setAttribute('stroke', 'currentColor'); + check.setAttribute('stroke-width', '1.75'); + check.setAttribute('stroke-linecap', 'round'); + check.setAttribute('stroke-linejoin', 'round'); + check.setAttribute('aria-hidden', 'true'); + const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'm20 6-11 11-5-5'); + check.append(path); + select.prepend(check); + } + actions.append(select); + if (profile.sessionState === 'signed-out') { + actions.append( + button('Sign in', `login:${profile.id}`, `Sign in to ${profile.label}`, () => { + void controller.add(profile.webId); + }), + ); + } + for (const action of ['rename', 'remove'] as const) { + const retryRemoval = action === 'remove' && profile.sessionState === 'removing'; + const control = button( + retryRemoval ? 'Retry removal' : action === 'rename' ? 'Rename' : 'Remove', + `${action}:${profile.id}`, + `${retryRemoval ? 'Retry removal of' : action === 'rename' ? 'Rename' : 'Remove'} ${profile.label}`, + () => { + if (retryRemoval) void controller.remove(profile.id); + else openEditor(action, profile); + }, + ); + control.classList.toggle('profile-destructive', action === 'remove'); + actions.append(control); + } + row.append(identity, actions); + list.append(row); + if (profile.sessionState === 'removing') { + for (const control of row.querySelectorAll('button')) + control.disabled = control.dataset.focus !== `remove:${profile.id}`; + } + } + if (focusKey) pendingFocus = focusKey; + } + get('profiles-count').textContent = `${state.profiles.length} / 8`; + get('profiles-empty').hidden = !state.ready || state.profiles.length !== 0; + add.disabled = !state.ready || state.busy || state.profiles.length >= 8; + get('profile-limit').hidden = state.profiles.length < 8; + for (const control of list.querySelectorAll('button')) { + control.setAttribute('aria-disabled', String(state.busy || control.disabled)); + } + input.readOnly = state.busy; + save.disabled = state.busy; + get('profile-cancel').disabled = state.busy; + get('profile-status').textContent = + state.status || (state.ready || state.error ? '' : 'Loading accounts…'); + get('profile-error').textContent = state.error; + get('profile-retry').hidden = !state.error || state.busy; + // Apply disabled states before moving focus, including the busy → idle render. + if (editing?.profile && !state.profiles.some((p) => p.id === editing?.profile?.id)) + closeEditor(); + if (hadFocus && busyFocus === null && !focusable(before)) busyFocus = before; + if (pendingFocus !== null) restoreFocus(pendingFocus); + else if (!state.busy && busyFocus !== null) { + // Only recover genuinely lost focus: never pull focus back from a usable control the + // user moved to (e.g. the theme toggle) while the operation was running. + const active = doc.activeElement as HTMLElement | null; + if (active === null || active === doc.body || (root.contains(active) && !focusable(active))) { + if (focusable(busyFocus)) busyFocus.focus(); + else restoreFocus(''); + } + } + if (!state.busy) busyFocus = null; + } + const controller = new ProfileController(render); + add.addEventListener('click', () => openEditor('add')); + get('profile-cancel').addEventListener('click', closeEditor); + get('profile-retry').addEventListener('click', () => { + void controller.retry(); + }); + input.addEventListener('input', () => input.setCustomValidity('')); + editor.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !controller.state.busy) { + event.preventDefault(); + closeEditor(); + } + }); + editor.addEventListener('submit', (event) => { + event.preventDefault(); + if (!editing || controller.state.busy) return; + const { action, profile } = editing; + if (action !== 'remove' && !input.value.trim()) { + input.setCustomValidity('Enter a value.'); + input.reportValidity(); + return; + } + const operation = + action === 'add' + ? controller.add(input.value) + : action === 'rename' + ? controller.rename(profile!.id, input.value) + : controller.remove(profile!.id); + void operation.then((ok) => { + if (ok && !disposed) closeEditor(); + }); + }); + // Arrow keys move between profiles at the same action; native Tab/Enter/Space also work. + list.addEventListener('keydown', (event) => { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return; + const active = doc.activeElement as HTMLElement; + const action = active.dataset.focus?.split(':')[0]; + if (!action) return; + const buttons = Array.from(list.querySelectorAll('button')).filter( + (el) => el.dataset.focus?.startsWith(`${action}:`) && !el.disabled, + ); + const next = + (buttons.indexOf(active as HTMLButtonElement) + + (event.key === 'ArrowDown' ? 1 : -1) + + buttons.length) % + buttons.length; + event.preventDefault(); + buttons[next]?.focus(); + }); + render(controller.state); + void controller.refresh(); + return { + controller, + cleanup: () => { + disposed = true; + controller.dispose(); + root.remove(); + }, + }; +} + +function safeAvatar(url: string): boolean { + try { + return ['https:', 'http:'].includes(new URL(url).protocol); + } catch { + return false; + } +} diff --git a/apps/extension/src/popup/profiles.css b/apps/extension/src/popup/profiles.css new file mode 100644 index 000000000..33d93fdaf --- /dev/null +++ b/apps/extension/src/popup/profiles.css @@ -0,0 +1,152 @@ +/* AUTHORED-BY Codex GPT-6.1-Sol — shared suite AccountSwitcher. */ +#profiles { + margin-top: var(--space-4); + padding-top: var(--space-4); + border-top: 1px solid var(--border); +} +.profiles-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-2); +} +#profiles h2 { + margin: 0; + font-size: var(--text-lg); + line-height: var(--lh-lg); + font-weight: var(--weight-lg); + letter-spacing: var(--tracking-lg); +} +#profiles h3 { + margin: 0 0 var(--space-2); + overflow-wrap: anywhere; + font-size: var(--text-md); + line-height: var(--lh-md); + font-weight: var(--weight-lg); +} +#profiles-count, .profile-hint, #profile-status, #profile-error, #profiles-empty { + font-size: var(--text-sm); + line-height: var(--lh-sm); + font-weight: var(--weight-sm); +} +#profiles-count, .profile-hint, #profile-status, #profiles-empty { color: var(--foreground-muted); } +.profile-hint { margin: var(--space-1) 0 var(--space-3); } +#profile-list { list-style: none; padding: 0; margin: 0 0 var(--space-3); } +#profile-list li { + min-height: var(--row-h, 44px); + padding: var(--space-3); + margin-bottom: var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + transition: background-color var(--duration-fast) var(--ease-standard); +} +#profile-list li:hover { background: var(--surface-2); } +.profile-identity { display: flex; align-items: center; gap: var(--space-2); min-width: 0; } +.profile-avatar { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--space-8); + height: var(--space-8); + border-radius: var(--radius-full); + flex: none; + overflow: hidden; + color: var(--foreground); + background: var(--surface-3); + font-size: var(--text-sm); + line-height: var(--lh-sm); + font-weight: var(--weight-lg); +} +.profile-avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.profile-text { min-width: 0; } +.profile-text strong { + display: block; + overflow-wrap: anywhere; + font-size: var(--text-base); + line-height: var(--lh-base); + font-weight: var(--weight-lg); +} +.profile-text small { + display: block; + overflow-wrap: anywhere; + color: var(--foreground-muted); + font-size: var(--text-sm); + line-height: var(--lh-sm); + font-weight: var(--weight-sm); +} +.profile-actions { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-top: var(--space-2); } +#profiles button, #profiles input { + min-width: var(--space-6); + min-height: var(--control-h, 36px); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-md); + font: inherit; + scroll-margin: var(--space-2); +} +#profiles button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-1); + border: 1px solid var(--border); + color: var(--foreground); + background: var(--surface-2); + cursor: pointer; + transition: background-color var(--duration-fast) var(--ease-standard), + border-color var(--duration-fast) var(--ease-standard); +} +#profiles button:hover { background: var(--surface-3); } +#profile-list button { + min-height: var(--control-h-sm, 28px); + padding: 0 var(--space-2); + border-color: transparent; + background: transparent; + font-size: var(--text-sm); + line-height: var(--lh-sm); +} +#profile-list button:hover { background: var(--surface-2); } +#profiles button[aria-pressed="true"] { + border-radius: var(--radius-sm); + background: var(--primary-soft); + color: var(--primary-soft-foreground); +} +.profile-check { width: var(--space-4); height: var(--space-4); flex: none; } +#profiles .profile-destructive { color: var(--destructive); } +#profiles .profile-destructive:hover { background: var(--destructive-soft); } +#profiles button:disabled, #profiles button[aria-disabled="true"] { cursor: default; opacity: 0.6; } +#profile-editor { + margin-top: var(--space-3); + padding: var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); +} +#profile-input-label { display: block; margin-bottom: var(--space-1); } +#profile-input { + width: 100%; + border: 1px solid var(--input); + background: var(--background); + color: var(--foreground); +} +#profiles #profile-save { + border-color: var(--primary); + background: var(--primary); + color: var(--primary-foreground); +} +#profiles #profile-save.profile-destructive { + border-color: var(--destructive); + background: var(--destructive); + color: var(--destructive-foreground); +} +#profile-error { color: var(--destructive); overflow-wrap: anywhere; } +#profile-error:empty, #profile-status:empty { margin: 0; } +@media (min-width: 768px) { + #profile-list li { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); } + #profile-list .profile-actions { flex: none; margin-top: 0; } +} +@media (pointer: coarse) { + #profile-list li { min-height: 44px; } + #profiles button, #profiles input { min-width: 44px; min-height: 44px; } +} diff --git a/apps/extension/src/shared/account-surface.css b/apps/extension/src/shared/account-surface.css new file mode 100644 index 000000000..877f65d78 --- /dev/null +++ b/apps/extension/src/shared/account-surface.css @@ -0,0 +1,252 @@ +/* AUTHORED-BY Codex GPT-6.1-Sol */ +/* Shared popup and side-panel chrome. Components inherit only the public host tokens. */ +:root { color-scheme: light; } +html.dark { color-scheme: dark; } +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: var(--font-ui); + font-size: var(--text-base); + line-height: var(--lh-base); + font-weight: var(--weight-base); +} +:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} +:is(a, button, input) { scroll-margin: var(--space-2); } +:is(a, button) { min-width: var(--space-6); min-height: var(--space-6); } +.skip-link { + position: absolute; + top: calc(-1 * var(--space-16)); + left: var(--space-4); + z-index: 10; + padding: var(--space-2); + color: var(--primary); + background: var(--surface); + border-radius: var(--radius-md); +} +.skip-link:focus { top: var(--space-1); } +.container { padding: 0 var(--space-4) var(--space-4); } +.topbar { + height: var(--topbar-h-mobile); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-4); +} +.brand { display: flex; align-items: center; gap: var(--space-2); } +.brand-mark { position: relative; width: 28px; height: 28px; flex: none; } +.brand-pod { display: block; width: 100%; height: 100%; } +.brand-pod polygon { + fill: var(--primary); + stroke: var(--primary); + stroke-width: 5; + stroke-linejoin: round; +} +.brand-key { + position: absolute; + width: var(--space-4); + height: var(--space-4); + top: var(--space-1\.5); + left: var(--space-1\.5); + color: var(--primary-foreground); +} +.brand-name { + margin: 0; + font-size: var(--text-lg); + line-height: var(--lh-lg); + font-weight: var(--weight-lg); + letter-spacing: var(--tracking-lg); +} +jeswr-theme-toggle, #login-panel, #account-menu { font-family: var(--font-ui); } +jeswr-theme-toggle::part(button) { + min-width: var(--space-6); + min-height: var(--control-h, 36px); + padding: var(--space-2); + border-radius: var(--radius-md); + font-family: var(--font-ui); + font-size: var(--text-sm); + line-height: var(--lh-sm); +} +jeswr-theme-toggle::part(icon) { width: var(--space-4); height: var(--space-4); stroke-width: 1.75; } +#login-panel { display: block; width: 100%; } +#login-panel::part(panel) { + max-width: none; + padding: var(--space-4); + gap: var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + font: var(--weight-base) var(--text-base)/var(--lh-base) var(--font-ui); +} +#login-panel::part(heading) { + font-size: var(--text-lg); + line-height: var(--lh-lg); + font-weight: var(--weight-lg); + letter-spacing: var(--tracking-lg); +} +#login-panel::part(form) { gap: var(--space-2); } +#login-panel::part(webid-input) { + height: var(--control-h, 36px); + min-width: 0; + width: 100%; + padding: 0 var(--space-3); + border: 1px solid var(--input); + border-radius: var(--radius-md); + background: var(--background); + color: var(--foreground); + font: inherit; +} +/* The browser default placeholder grey misses AA on both surfaces. */ +#login-panel::part(webid-input)::placeholder { + color: var(--foreground-muted); + opacity: 1; +} +/* Keep filled login actions scoped so account/menu controls remain ghost buttons. */ +#login-form #login-panel::part(login-button) { + height: var(--control-h, 36px); + padding: 0 var(--space-4); + border-radius: var(--radius-md); + background: var(--primary); + color: var(--primary-foreground); + font: inherit; + transition: background-color var(--duration-fast) var(--ease-standard); +} +#login-form #login-panel::part(login-button):hover { filter: none; } +#login-panel::part(account) { + min-height: var(--row-h, 44px); + border: 1px solid var(--border); + border-radius: var(--radius-md); + font: inherit; +} +#login-panel::part(account):hover { background: var(--surface-2); color: var(--foreground); } +#login-panel::part(add-account) { min-height: var(--space-6); font: inherit; } +#login-panel::part(avatar) { width: var(--space-8); height: var(--space-8); } +.account-card { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); +} +#account-menu { flex: none; } +#account-menu::part(trigger) { + padding: 0; + border: 0; + border-radius: var(--radius-full); +} +#account-menu::part(trigger-name) { display: none; } +#account-menu::part(avatar) { + width: var(--space-10); + height: var(--space-10); + background: var(--surface-3); + color: var(--foreground); + font: var(--weight-lg) var(--text-sm)/var(--lh-sm) var(--font-ui); +} +.account-details { min-width: 0; } +#account-name { + margin: 0; + overflow-wrap: anywhere; + font-size: var(--text-md); + line-height: var(--lh-md); + font-weight: var(--weight-lg); +} +#account-host { + margin: 0; + overflow-wrap: anywhere; + color: var(--foreground-muted); + font-size: var(--text-sm); + line-height: var(--lh-sm); + font-weight: var(--weight-sm); +} +#account-menu::part(menu) { + left: 0; + right: auto; + top: calc(100% + var(--space-2)); + min-width: min(var(--sidebar-w), calc(100vw - var(--space-16))); + max-width: calc(100vw - var(--space-16)); + padding: var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--elevation-2-fill); + box-shadow: var(--shadow-2); + font: var(--weight-base) var(--text-base)/var(--lh-base) var(--font-ui); +} +#account-menu::part(identity) { padding: var(--space-2); } +#account-menu::part(webid) { + color: var(--foreground-muted); + font-size: var(--text-sm); + line-height: var(--lh-sm); +} +#account-menu::part(sign-out) { + min-height: var(--control-h, 36px); + padding: var(--space-2); + border-radius: var(--radius-md); + color: var(--destructive); + font: inherit; +} +#account-menu::part(sign-out):hover { color: var(--destructive); background: var(--destructive-soft); } +.shortcuts { display: flex; flex-direction: column; gap: var(--space-2); margin-top: var(--space-3); } +.shortcut { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: var(--control-h, 36px); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-2); + color: var(--foreground); + text-decoration: none; + transition: background-color var(--duration-fast) var(--ease-standard); +} +.shortcut:hover { background: var(--surface-3); } +#busy { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); + padding: var(--space-6) var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + color: var(--foreground-muted); +} +#busy p { margin: 0; } +.spinner { + width: var(--space-6); + height: var(--space-6); + border: 2px solid var(--border); + border-top-color: var(--primary); + border-radius: var(--radius-full); + animation: spin var(--duration-xslow) var(--ease-standard) infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } +jeswr-theme-toggle::part(button):focus-visible, +#login-panel::part(webid-input):focus-visible, +#login-panel::part(login-button):focus-visible, +#login-panel::part(add-account):focus-visible, +#login-panel::part(account):focus-visible, +#account-menu::part(trigger):focus-visible, +#account-menu::part(sign-out):focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} +@media (pointer: coarse) { + :is(a, button, input), jeswr-theme-toggle::part(button), + #login-panel::part(webid-input), #login-form #login-panel::part(login-button), + #login-panel::part(add-account), #login-panel::part(account), + #account-menu::part(trigger), #account-menu::part(sign-out) { + min-width: 44px; + min-height: 44px; + } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition-duration: 0ms !important; } +} diff --git a/apps/extension/src/shared/data-labels.ts b/apps/extension/src/shared/data-labels.ts new file mode 100644 index 000000000..6fb4c3264 --- /dev/null +++ b/apps/extension/src/shared/data-labels.ts @@ -0,0 +1,66 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +/** Display copy only. Class identifiers and modes sent to the worker remain unchanged. */ +const CLASSES: Readonly> = { + 'https://schema.org/TextDigitalDocument': { + label: 'Documents', + description: 'Text documents and notes in your pod.', + }, + 'https://schema.org/NoteDigitalDocument': { + label: 'Notes', + description: 'Notes saved in your pod.', + }, + 'https://schema.org/Person': { + label: 'Contacts', + description: 'Names and contact information saved in your pod.', + }, + 'http://www.w3.org/2006/vcard/ns#Individual': { + label: 'Contacts', + description: 'Names and contact information saved in your pod.', + }, + 'http://www.w3.org/2006/vcard/ns#AddressBook': { + label: 'Contacts', + description: 'Your address book and its contacts.', + }, + 'https://schema.org/PostalAddress': { + label: 'Addresses', + description: 'Postal addresses saved in your pod.', + }, + 'http://www.w3.org/2006/vcard/ns#Address': { + label: 'Addresses', + description: 'Postal addresses saved in your pod.', + }, + 'http://www.w3.org/2002/12/cal/ical#Vevent': { + label: 'Calendar events', + description: 'Events and appointments in your pod.', + }, + 'https://schema.org/Event': { + label: 'Calendar events', + description: 'Events and appointments in your pod.', + }, + 'http://www.w3.org/ns/pim/meeting#LongChat': { + label: 'Chats', + description: 'Conversations and messages in your pod.', + }, +}; + +export function dataClassCopy(value: string): { label: string; description: string } { + const known = CLASSES[value.replace(/^http:\/\/schema.org\//, 'https://schema.org/')]; + if (known) return known; + try { + const url = new URL(value); + const local = decodeURIComponent(url.hash.slice(1) || url.pathname.split('/').pop() || ''); + return { + label: local.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ') || 'Other data', + description: 'Only the items listed below are included in this request.', + }; + } catch { + return { label: 'Other data', description: 'Only the items listed below are included.' }; + } +} + +export function accessModeLabel(mode: string): string { + return ( + ({ read: 'Read', append: 'Add', write: 'Edit and delete' } as Record)[mode] ?? + 'Other access' + ); +} diff --git a/apps/extension/src/shared/fonts/OFL.txt b/apps/extension/src/shared/fonts/OFL.txt new file mode 100644 index 000000000..1d466e3f6 --- /dev/null +++ b/apps/extension/src/shared/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Onest Project Authors (https://github.com/simpals/onest) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/extension/src/shared/fonts/onest-latin-ext-wght-normal.woff2 b/apps/extension/src/shared/fonts/onest-latin-ext-wght-normal.woff2 new file mode 100644 index 000000000..2f2da8922 Binary files /dev/null and b/apps/extension/src/shared/fonts/onest-latin-ext-wght-normal.woff2 differ diff --git a/apps/extension/src/shared/fonts/onest-latin-wght-normal.woff2 b/apps/extension/src/shared/fonts/onest-latin-wght-normal.woff2 new file mode 100644 index 000000000..bd5ac3346 Binary files /dev/null and b/apps/extension/src/shared/fonts/onest-latin-wght-normal.woff2 differ diff --git a/apps/extension/src/shared/fonts/onest.css b/apps/extension/src/shared/fonts/onest.css new file mode 100644 index 000000000..ac7d520f0 --- /dev/null +++ b/apps/extension/src/shared/fonts/onest.css @@ -0,0 +1,19 @@ +/* AUTHORED-BY Claude Opus 5.5 */ +/* Onest variable font (OFL-1.1, see OFL.txt), vendored from @fontsource-variable/onest@5.3.1. + Latin and Latin Extended subsets only; other scripts fall back to the system UI font. */ +@font-face { + font-family: "Onest"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url("onest-latin-ext-wght-normal.woff2") format("woff2-variations"); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} +@font-face { + font-family: "Onest"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url("onest-latin-wght-normal.woff2") format("woff2-variations"); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} diff --git a/apps/extension/src/shared/formfill-values.ts b/apps/extension/src/shared/formfill-values.ts new file mode 100644 index 000000000..ccc1ac9e7 --- /dev/null +++ b/apps/extension/src/shared/formfill-values.ts @@ -0,0 +1,32 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +/** Maximum UTF-16 code units in any extracted or written form-fill value. Oversized values are skipped. */ +export const MAX_FORM_FILL_VALUE_LENGTH = 256; + +export type CommunicationKind = 'email' | 'tel'; + +// RFC 5322-lite: one dot-atom mailbox at a DNS domain; no display names, lists or URI queries. +const EMAIL = + /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i; + +/** Strip the matching scheme, decode mailto addresses once, then validate the bounded scalar. */ +export function communicationValue(value: string, kind: CommunicationKind): string | null { + const scheme = kind === 'email' ? 'mailto:' : 'tel:'; + const trimmed = value.trim(); + const hasScheme = trimmed.toLowerCase().startsWith(scheme); + let result = hasScheme ? trimmed.slice(scheme.length).trim() : trimmed; + if (kind === 'email' && hasScheme) { + // URI queries/fragments are not mailboxes; encoded punctuation belongs to the address. + if (/[?#]/.test(result)) return null; + try { + result = decodeURIComponent(result); + } catch { + return null; + } + } + if (result.length > MAX_FORM_FILL_VALUE_LENGTH) return null; + const valid = + kind === 'email' + ? EMAIL.test(result) && !/\s/.test(result) + : /^[0-9+(). -]+$/.test(result) && /[0-9]/.test(result); + return valid ? result : null; +} diff --git a/apps/extension/src/shared/formfill.ts b/apps/extension/src/shared/formfill.ts new file mode 100644 index 000000000..d20190d3c --- /dev/null +++ b/apps/extension/src/shared/formfill.ts @@ -0,0 +1,69 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import type { FormFillData } from '../background/core/formfill-data'; +import { FIELD_CATEGORIES, type FieldCategory, type FieldKind } from '../content/formfill/detect'; + +export interface FormFillField { + readonly field: FieldKind; + readonly category: FieldCategory; +} +export interface FormFillValue extends FormFillField { + readonly value: string; +} + +export const FIELD_LABELS: Record = { + name: 'Full name', + 'given-name': 'First name', + 'family-name': 'Last name', + email: 'Email', + tel: 'Phone', + organization: 'Organization', + 'street-address': 'Street address', + 'address-line1': 'Address line 1', + 'address-line2': 'Address line 2', + 'address-level1': 'State or region', + 'address-level2': 'City', + 'postal-code': 'Postal code', + country: 'Country', + 'country-name': 'Country', +}; + +/** Validate the entire bounded request; a forged field/category pair fails closed. */ +export function formFillFields(raw: unknown): FormFillField[] { + if (!Array.isArray(raw) || raw.length === 0 || raw.length > 100) return []; + const fields = new Map(); + for (const f of raw) { + if ( + !f || + typeof f !== 'object' || + !Object.hasOwn(FIELD_CATEGORIES, f.field) || + FIELD_CATEGORIES[f.field as FieldKind] !== f.category + ) + return []; + fields.set(f.field, { field: f.field, category: f.category }); + } + return [...fields.values()]; +} + +export function formFillValues( + data: FormFillData, + fields: readonly FormFillField[], +): FormFillValue[] { + const { contact: c, address: a } = data; + const values: Record = { + name: c.name, + 'given-name': c.givenName, + 'family-name': c.familyName, + email: c.email, + tel: c.tel, + organization: c.organization, + 'street-address': a.streetAddress, + 'address-line1': a.streetAddress, + 'address-line2': a.extendedAddress, + 'address-level1': a.region, + 'address-level2': a.locality, + 'postal-code': a.postalCode, + country: a.countryName, + 'country-name': a.countryName, + }; + return fields.flatMap((f) => (values[f.field] ? [{ ...f, value: values[f.field]! }] : [])); +} diff --git a/apps/extension/src/shared/messages.ts b/apps/extension/src/shared/messages.ts new file mode 100644 index 000000000..be88dce4b --- /dev/null +++ b/apps/extension/src/shared/messages.ts @@ -0,0 +1,337 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The message protocol shared across the four contexts (MAIN-world inject, ISOLATED + * content script, service worker, popup). All payloads MUST be JSON-serialisable — + * `window.postMessage` (page ↔ content) and `chrome.runtime` (content/popup ↔ worker) + * both structured-clone, and Response bodies are serialised as text (Solid resources are + * predominantly text/RDF; binary support is a documented future extension). + * + * The injected `window.solid` NEVER receives a token — only the WebID + a proxied fetch + * result. The credential boundary is enforced entirely in the service worker. + */ + +import type { FieldCategory } from '../content/formfill/detect'; +import type { FormFillField, FormFillValue } from './formfill'; + +/** A namespacing tag on every page↔content postMessage so we ignore unrelated messages. */ +export const CHANNEL = 'solid-browser-ext' as const; + +// --- Requests (page/popup -> worker) -------------------------------------------------- + +export interface FetchRequest { + readonly type: 'SOLID_FETCH_REQUEST'; + readonly requestId: string; + readonly url: string; + readonly method: string; + readonly headers: Record; + readonly body: string | null; + /** + * The page-supplied requesting origin (`window.location.origin`), stamped by the content + * script. ADVISORY ONLY — the worker cross-checks it against the browser-attested + * `sender.origin` and trusts the browser value. A mismatch is denied; this is never the + * sole authority. (Per design §4.3 "Forge the access-control anchor".) + */ + readonly stampedOrigin?: string; + /** + * `true` ⇒ this request came from the BEST-EFFORT global-`fetch` transparency patch + * (inject.ts §5.1), NOT an explicit `window.solid.fetch(...)` API call. The inject is + * deliberately DUMB — it routes EVERY cross-origin request and lets the SW (the sole + * boundary) decide. For an auto-diverted request the SW must NEVER hard-fail the page's + * normal web traffic: when the gate does not ALLOW (a non-pod target, or an un-granted + * requesting origin) the SW returns a {@link FetchResponse.passthrough} SENTINEL (no body, + * no status, NO network from the SW) and the page-side inject completes it with its OWN + * native `fetch` — so an app's calls to third-party APIs/CDNs behave EXACTLY as native + * (CORS, credentials, headers all preserved) and the SW never acts as a cross-origin proxy. + * Credentials + the replica are applied ONLY on an explicit gate ALLOW. (Round-2 High #1.) + * + * An EXPLICIT `window.solid.fetch(...)` (this flag absent/false) keeps the 403 deny — the + * caller deliberately asked for the gated fetch, so a deny is the correct, expected result. + * It NEVER receives a passthrough sentinel. + */ + readonly autoDivert?: boolean; +} + +export interface LoginRequest { + readonly type: 'SOLID_LOGIN'; + readonly webId: string; + /** The requesting page's origin (for per-origin client-id resolution). */ + readonly origin?: string; + /** A page-supplied Client Identifier Document URL. */ + readonly clientId?: string; +} + +export interface LogoutRequest { + readonly type: 'SOLID_LOGOUT'; + readonly stampedOrigin?: string; +} + +export interface GetStateRequest { + readonly type: 'SOLID_GET_STATE'; + /** + * The page-supplied requesting origin, stamped by the content script. ADVISORY ONLY — + * cross-checked against the browser-attested `sender` so the worker only exposes the WebID + * (PII) to a verified, granted page origin. Absent for the extension's own contexts. + */ + readonly stampedOrigin?: string; +} + +export interface SetClientIdRequest { + readonly type: 'SOLID_SET_CLIENT_ID'; + readonly origin: string; + readonly clientId: string; +} + +/** Display-only profile metadata. Tokens and CryptoKeys have no message representation. */ +export interface Profile { + readonly id: string; + readonly webId: string; + readonly issuer: string; + readonly label: string; + readonly avatar?: string; + readonly sessionState: 'active' | 'signed-out' | 'removing'; + readonly dpopKeyRef: string; +} + +/** Accepted only from browser-attested popup/side-panel contexts. */ +export type ProfileRequest = + | { readonly type: 'SOLID_PROFILES_LIST' } + | { readonly type: 'SOLID_PROFILE_ADD'; readonly webId: string } + | { readonly type: 'SOLID_PROFILE_RENAME'; readonly profileId: string; readonly label: string } + | { readonly type: 'SOLID_PROFILE_REMOVE'; readonly profileId: string } + | { readonly type: 'SOLID_PROFILE_SET_DEFAULT'; readonly profileId: string }; + +export interface ProfilesResponse { + readonly ok?: boolean; + readonly profiles?: readonly Profile[]; + readonly defaultProfileId?: string | null; + readonly error?: string; +} + +// --- Per-data-class access management (Phase 2, design §4.2) --------------------------- + +/** + * A page's token-free view of a class grant it holds — the enforceable substance (targets + + * modes + expiry) plus the consent anchors (dataClass + purpose). NO credential, NO webId. + */ +export interface GrantSummary { + readonly grantId: string; + readonly dataClass: string; + readonly modes: readonly string[]; + readonly purpose: string | null; + readonly expiresAt: number; + readonly targets: ReadonlyArray<{ url: string; kind: 'instance' | 'container' }>; +} + +/** `window.solid.requestAccess(...)` — a typed, per-class access request (triggers consent). */ +export interface RequestAccessRequest { + readonly type: 'SOLID_REQUEST_ACCESS'; + readonly requestId: string; + readonly classes: readonly string[]; + readonly modes: readonly string[]; + readonly purpose?: string; + readonly duration?: string; + /** Page-supplied origin stamp — cross-checked against the browser-attested sender. */ + readonly stampedOrigin?: string; + /** + * Whether the call carried a genuine transient user activation, read in the ISOLATED content + * script (`navigator.userActivation.isActive`) — page-unforgeable. Drives the anti-spam gesture + * requirement (residual c); an autonomous (non-gesture) call is rejected before consent. + */ + readonly userActivated?: boolean; +} + +/** `window.solid.listGrants()` — the grants THIS origin currently holds. */ +export interface ListGrantsRequest { + readonly type: 'SOLID_LIST_GRANTS'; + readonly requestId: string; + readonly stampedOrigin?: string; +} + +/** `window.solid.revokeGrant(grantId)` — revoke one of THIS origin's grants. */ +export interface RevokeGrantRequest { + readonly type: 'SOLID_REVOKE_GRANT'; + readonly requestId: string; + readonly grantId: string; + readonly stampedOrigin?: string; +} + +/** `window.solid.resolveClass(classIRI)` — read-only introspection of a class → targets. */ +export interface ResolveClassRequest { + readonly type: 'SOLID_RESOLVE_CLASS'; + readonly requestId: string; + readonly classIRI: string; + readonly stampedOrigin?: string; +} + +/** The worker's reply to any of the four access-management requests (correlated by requestId). */ +export interface AccessResponse { + /** Sent only after explicit origin consent; enables the newly granted API before resolving. */ + readonly webId?: string; + readonly requestId: string; + readonly ok?: boolean; + readonly grants?: readonly GrantSummary[]; + readonly shape?: string | null; + readonly targets?: ReadonlyArray<{ url: string; kind: 'instance' | 'container' }>; + readonly error?: string; +} + +/** The consent window asks the worker for the pending record to render (extension context only). */ +export interface GetPendingConsentRequest { + readonly type: 'SOLID_GET_PENDING_CONSENT'; + readonly consentId: string; + readonly profileId?: string; +} + +/** The consent surface → worker decision (from the extension's own consent window only). */ +export interface ConsentDecisionMessage { + readonly type: 'SOLID_CONSENT_DECISION'; + readonly consentId: string; + /** The data-class IRIs the user APPROVED (a subset of the requested set); [] ⇒ denied all. */ + readonly approvedClasses: readonly string[]; + readonly profileId?: string; +} + +/** What the consent window renders — a token-free view of the pending request. */ +export interface PendingConsentView { + readonly consentId: string; + readonly origin: string; + readonly webId: string; + readonly profileId: string; + readonly profiles: readonly Pick[]; + readonly classes: ReadonlyArray<{ + readonly dataClass: string; + readonly modes: readonly string[]; + readonly purpose: string | null; + readonly expiresAt: number; + readonly targets: ReadonlyArray<{ url: string; kind: 'instance' | 'container' }>; + readonly shape: string | null; + }>; +} + +export type OriginGrantRequest = + | { readonly type: 'SOLID_ORIGIN_GRANTS_LIST' } + | { readonly type: 'SOLID_ORIGIN_GRANT_REVOKE'; readonly origin: string } + | { + readonly type: 'SOLID_ORIGIN_GRANT_PROFILE'; + readonly origin: string; + readonly profileId: string; + } + | { + readonly type: 'SOLID_ORIGIN_GRANT_EXPIRY'; + readonly origin: string; + readonly expiresAt: number; + }; + +/** Worker-to-ISOLATED message; never exposed to the MAIN world. */ +export interface FormFillTrigger { + readonly type: 'SOLID_FORMFILL_TRIGGER'; +} + +/** ISOLATED-only request: no page origin, DOM, or values are accepted. */ +export interface FormFillRequest { + readonly type: 'SOLID_FORMFILL_REQUEST'; + readonly fields: readonly FormFillField[]; +} +export type FormFillPreviewRequest = + | { + readonly type: 'SOLID_FORMFILL_PREVIEW'; + readonly requestId: string; + readonly profileId?: string; + } + | { + readonly type: 'SOLID_FORMFILL_DECISION'; + readonly requestId: string; + readonly profileId?: string; + readonly categories: readonly FieldCategory[]; + readonly remember: boolean; + }; +export interface FormFillPreview { + readonly requestId: string; + readonly origin: string; + readonly profileId: string; + readonly profiles: readonly { id: string; label: string }[]; + readonly values: readonly FormFillValue[]; + readonly selected: readonly FieldCategory[]; +} +export interface FormFillResponse { + readonly origin?: string; + readonly values: readonly FormFillValue[]; +} +export interface FormFillReport { + readonly type: 'SOLID_FORMFILL_REPORT'; + readonly filled: number; + readonly skipped: number; +} + +export type WorkerRequest = + | FormFillRequest + | FormFillPreviewRequest + | FormFillReport + | OriginGrantRequest + | ProfileRequest + | FetchRequest + | LoginRequest + | LogoutRequest + | GetStateRequest + | SetClientIdRequest + | RequestAccessRequest + | ListGrantsRequest + | RevokeGrantRequest + | ResolveClassRequest + | GetPendingConsentRequest + | ConsentDecisionMessage; + +// --- Responses (worker -> caller) ----------------------------------------------------- + +export interface FetchResponse { + readonly requestId: string; + readonly status?: number; + readonly statusText?: string; + readonly headers?: Record; + readonly body?: string; + readonly error?: string; + /** + * A PASSTHROUGH SENTINEL: `true` ⇒ the SW declined this `autoDivert` request WITHOUT + * performing any network — there is NO `status`/`headers`/`body` here, no fetched bytes. The + * page-side inject (MAIN world) must complete it by calling the page's own PRISTINE native + * `fetch` with the ORIGINAL input/init, in the page's origin context, so CORS / credentials / + * headers behave EXACTLY as if the extension were not installed. The SW is NEVER a + * cross-origin proxy: it does ZERO egress for a non-allowed `autoDivert` request and returns + * only this flag, so it can never read cross-origin bytes a page's own CORS would block. + * Only `autoDivert` requests receive a sentinel; an explicit `window.solid.fetch` gets its + * 403 / "Not authenticated" instead. + */ + readonly passthrough?: boolean; +} + +/** + * The popup's ONLY window into the session — pure display metadata, NO token. This is + * what the popup's `MessageBridgeLoginController` mirrors to answer the synchronous + * LoginController getters; `recentAccounts` is mapped to `RecentLoginAccount` consumer-side. + * No new wire type is needed to adopt ``. + */ +export interface SessionState { + readonly webId: string | null; + readonly isActive: boolean; + readonly name: string | null; + readonly photoUrl: string | null; + readonly recentAccounts: ReadonlyArray<{ + webId: string; + name: string | null; + photoUrl: string | null; + }>; +} + +export interface ActionResult { + readonly ok?: boolean; + readonly webId?: string; + readonly error?: string; +} + +/** Broadcast from the worker to all tabs/popup when auth state changes. */ +export interface StateChanged { + readonly type: 'SOLID_STATE_CHANGED'; + /** Required for tab delivery; omitted for trusted extension-context broadcasts. */ + readonly origin?: string; + readonly webId: string | null; +} diff --git a/apps/extension/src/shared/permission-surface.css b/apps/extension/src/shared/permission-surface.css new file mode 100644 index 000000000..d7366251c --- /dev/null +++ b/apps/extension/src/shared/permission-surface.css @@ -0,0 +1,137 @@ +/* AUTHORED-BY Codex GPT-6.1-Sol */ +/* Shared ConsentSheet and form-fill cards. Never redefine component theme tokens. */ +:root { color-scheme: light; } +html.dark { color-scheme: dark; } +* { box-sizing: border-box; } +[hidden] { display: none !important; } +body { + margin: 0; + padding: var(--space-4); + background: var(--background); + color: var(--foreground); + font: var(--weight-base) var(--text-base)/var(--lh-base) var(--font-ui); +} +.permission-skip-link { + position: absolute; + top: calc(-1 * var(--space-16)); + left: var(--space-4); + z-index: 1; + padding: var(--space-3); + border: 1px solid var(--input); + border-radius: var(--radius-md); + background: var(--surface); + color: var(--primary); +} +.permission-skip-link:focus { top: var(--space-1); } +.permission-skip-link:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +.permission-surface { + max-width: var(--dialog-md); + min-width: 0; + margin: 0 auto; + overflow-wrap: anywhere; +} +.permission-surface h1 { + margin: 0 0 var(--space-4); + font-size: var(--text-xl); + line-height: var(--lh-xl); + font-weight: var(--weight-xl); + letter-spacing: var(--tracking-xl); +} +.permission-surface p { margin: var(--space-2) 0 var(--space-4); } +.permission-surface .eyebrow { + color: var(--foreground-muted); + font-size: var(--text-sm); + margin-bottom: var(--space-2); +} +.permission-surface .hint, .permission-surface .meta { + color: var(--foreground-muted); + font-size: var(--text-sm); + line-height: var(--lh-sm); +} +.permission-surface :is(button, select) { + min-height: var(--control-h, 36px); + min-width: var(--space-6); + max-width: 100%; + border: 1px solid var(--input); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + background: var(--surface); + color: var(--foreground); + font: inherit; + overflow-wrap: anywhere; +} +.permission-surface select { display: block; width: 100%; margin: var(--space-2) 0 var(--space-4); } +.permission-surface button { cursor: pointer; } +.permission-surface button:hover { background: var(--surface-2); } +.permission-surface button:disabled { cursor: default; color: var(--foreground-muted); } +.permission-surface input { + appearance: none; + display: grid; + place-content: center; + flex: none; + width: var(--space-6); + height: var(--space-6); + margin: 0; + border: 1px solid var(--input); + border-radius: var(--radius-sm); + background: var(--surface); +} +.permission-surface input[type="radio"] { border-radius: var(--radius-full); } +.permission-surface input:is(:checked, :indeterminate) { background: var(--primary); border-color: var(--primary); } +.permission-surface input:checked::after { + content: ''; + width: var(--space-1\.5); + height: var(--space-3); + border: solid var(--primary-foreground); + border-width: 0 2px 2px 0; + transform: translateY(-2px) rotate(45deg); +} +.permission-surface input:indeterminate::after { + content: ''; + width: var(--space-3); + border-top: 2px solid var(--primary-foreground); +} +.permission-surface input[type="radio"]:checked::after { + width: var(--space-2); + height: var(--space-2); + border: 0; + border-radius: var(--radius-full); + background: var(--primary-foreground); + transform: none; +} +.permission-surface :is(.card-check, .remember) { + display: flex; + align-items: center; + gap: var(--space-3); + min-height: var(--control-h, 36px); + cursor: pointer; +} +.permission-surface .data-card { + min-width: 0; + margin: 0; + padding: var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); +} +.permission-surface .data-card:has(input:checked) { border-color: var(--input); } +.permission-surface .card-title { font-weight: var(--weight-lg); } +.permission-surface .card-description { display: block; margin-top: var(--space-1); } +.permission-surface .actions { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: var(--space-3); + margin-top: var(--space-6); +} +/* Equal size, border and fill for both decisions, scoped to these surfaces. */ +.permission-surface .actions button { padding: var(--space-2) var(--space-4); } +.permission-surface .error { color: var(--destructive); } +.permission-surface :focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + scroll-margin-block: var(--space-3); +} +@media (pointer: coarse) { + .permission-surface :is(button, select, .card-check, .remember, summary) { min-height: 44px; } + .permission-surface :is(input, summary) { min-width: 44px; min-height: 44px; } +} diff --git a/apps/extension/src/shared/suite-tokens.css b/apps/extension/src/shared/suite-tokens.css new file mode 100644 index 000000000..771903867 --- /dev/null +++ b/apps/extension/src/shared/suite-tokens.css @@ -0,0 +1,241 @@ +/* Canonical source: jeswr/solid-sdk packages/suite-ui/src/styles/tokens.css @ 1bb0039. Do not edit here; refresh from upstream. */ +/* AUTHORED-BY Codex GPT-6.1-Sol */ +/* Normative values: 01-design-system §2.1–2.7 and Amendment 2026-10-25. */ +:root { + --background: oklch(0.985 0.004 85); + --surface: oklch(1 0 0); + --surface-2: oklch(0.965 0.006 85); + --surface-3: oklch(0.94 0.008 85); + --rail: oklch(0.22 0.02 240); + --rail-foreground: oklch(0.92 0.01 240); + --foreground: oklch(0.2 0.02 240); + --foreground-muted: oklch(0.47 0.018 240); + --foreground-subtle: oklch(0.6 0.015 240); + --border: oklch(0.9 0.008 85); + --border-strong: oklch(0.8 0.012 240); + --input: oklch(0.63 0.012 240); + --primary: oklch(0.46 0.085 205); + --primary-foreground: oklch(0.99 0.004 200); + --primary-soft: oklch(0.94 0.03 200); + --primary-soft-foreground: oklch(0.3 0.06 210); + --ring: oklch(0.6 0.12 205); + --destructive: oklch(0.52 0.19 27); + --destructive-foreground: oklch(0.99 0 0); + --destructive-soft: oklch(0.95 0.03 25); + --success: oklch(0.52 0.12 155); + --warning: oklch(0.62 0.14 75); + --warning-foreground: oklch(0.2 0.05 70); + --info: oklch(0.52 0.11 250); + --overlay: oklch(0.2 0.02 240 / 45%); + --app-home: oklch(0.5 0.11 205); + --app-home-soft: oklch(0.94 0.03 205); + --presence-home: oklch(0.6 0.14 205); + --app-drive: oklch(0.5 0.11 250); + --app-drive-soft: oklch(0.94 0.03 250); + --presence-drive: oklch(0.6 0.14 250); + --app-docs: oklch(0.5 0.11 280); + --app-docs-soft: oklch(0.94 0.03 280); + --presence-docs: oklch(0.6 0.14 280); + --app-tasks: oklch(0.5 0.11 150); + --app-tasks-soft: oklch(0.94 0.03 150); + --presence-tasks: oklch(0.6 0.14 150); + --app-calendar: oklch(0.5 0.11 40); + --app-calendar-soft: oklch(0.94 0.03 40); + --presence-calendar: oklch(0.6 0.14 40); + --app-contacts: oklch(0.5 0.11 320); + --app-contacts-soft: oklch(0.94 0.03 320); + --presence-contacts: oklch(0.6 0.14 320); + --app-notes: oklch(0.5 0.11 85); + --app-notes-soft: oklch(0.94 0.03 85); + --presence-notes: oklch(0.6 0.14 85); + --app-photos: oklch(0.5 0.11 350); + --app-photos-soft: oklch(0.94 0.03 350); + --presence-photos: oklch(0.6 0.14 350); + --app-chat: oklch(0.5 0.11 300); + --app-chat-soft: oklch(0.94 0.03 300); + --presence-chat: oklch(0.6 0.14 300); + --app-sheets: oklch(0.5 0.11 170); + --app-sheets-soft: oklch(0.94 0.03 170); + --presence-sheets: oklch(0.6 0.14 170); + --app-mail: oklch(0.5 0.11 20); + --app-mail-soft: oklch(0.94 0.03 20); + --presence-mail: oklch(0.6 0.14 20); + --app-music: oklch(0.5 0.11 335); + --app-music-soft: oklch(0.94 0.03 335); + --presence-music: oklch(0.6 0.14 335); + --app-money: oklch(0.5 0.11 130); + --app-money-soft: oklch(0.94 0.03 130); + --presence-money: oklch(0.6 0.14 130); + --app-health: oklch(0.5 0.11 10); + --app-health-soft: oklch(0.94 0.03 10); + --presence-health: oklch(0.6 0.14 10); + --app-permissions: oklch(0.5 0.11 225); + --app-permissions-soft: oklch(0.94 0.03 225); + --presence-permissions: oklch(0.6 0.14 225); + --glyph: oklch(1 0 0); + --text-xs: 0.75rem; + --lh-xs: 1rem; + --weight-xs: 500; + --tracking-xs: 0.01em; + --text-sm: 0.8125rem; + --lh-sm: 1.25rem; + --weight-sm: 400; + --tracking-sm: 0; + --text-base: 0.875rem; + --lh-base: 1.375rem; + --weight-base: 400; + --tracking-base: 0; + --text-md: 1rem; + --lh-md: 1.5rem; + --weight-md: 400; + --tracking-md: 0; + --text-lg: 1.125rem; + --lh-lg: 1.625rem; + --weight-lg: 600; + --tracking-lg: -0.005em; + --text-xl: 1.375rem; + --lh-xl: 1.75rem; + --weight-xl: 600; + --tracking-xl: -0.01em; + --text-2xl: 1.75rem; + --lh-2xl: 2.125rem; + --weight-2xl: 600; + --tracking-2xl: -0.015em; + --text-3xl: 2.25rem; + --lh-3xl: 2.625rem; + --weight-3xl: 600; + --tracking-3xl: -0.02em; + --space-0\.5: 2px; + --space-1: 4px; + --space-1\.5: 6px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --space-16: 64px; + --rail-w: 56px; + --sidebar-w: 264px; + --topbar-h: 56px; + --topbar-h-mobile: 48px; + --page-docs-w: 816px; + --page-settings-w: 720px; + --page-permissions-w: 880px; + --dialog-sm: 420px; + --dialog-md: 560px; + --dialog-lg: 720px; + --dialog-xl: 960px; + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 10px; + --radius-xl: 16px; + --radius-full: 9999px; + --radius: 6px; + --elevation-0-border: 1px solid var(--border); + --shadow-1: 0 1px 2px oklch(0 0 0 / 6%), 0 0 0 1px var(--border); + --elevation-1-fill: var(--surface); + --shadow-2: 0 4px 12px oklch(0 0 0 / 10%), 0 0 0 1px var(--border); + --elevation-2-fill: var(--surface); + --shadow-3: 0 16px 40px oklch(0 0 0 / 18%), 0 0 0 1px var(--border); + --elevation-3-fill: var(--surface); + --duration-fast: 100ms; + --duration-base: 160ms; + --duration-slow: 240ms; + --duration-xslow: 400ms; + --ease-standard: cubic-bezier(0.2, 0, 0, 1); + --ease-exit: cubic-bezier(0.4, 0, 1, 1); + --ease-spring: cubic-bezier(0.34, 1.3, 0.64, 1); + --font-ui: var(--font-onest, "Onest"), system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-serif: var(--font-source-serif, "Source Serif 4"), Georgia, serif; + --font-mono: + var(--font-source-code, "Source Code Pro"), ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.dark { + --background: oklch(0.16 0.012 240); + --surface: oklch(0.2 0.014 240); + --surface-2: oklch(0.24 0.016 240); + --surface-3: oklch(0.28 0.018 240); + --rail: oklch(0.13 0.012 240); + --foreground: oklch(0.95 0.008 90); + --foreground-muted: oklch(0.72 0.014 240); + --foreground-subtle: oklch(0.6 0.014 240); + --border: oklch(1 0 0 / 12%); + --border-strong: oklch(1 0 0 / 24%); + --input: oklch(1 0 0 / 36%); + --primary: oklch(0.78 0.1 200); + --primary-foreground: oklch(0.16 0.03 235); + --primary-soft: oklch(0.3 0.045 205); + --primary-soft-foreground: oklch(0.92 0.03 200); + --ring: oklch(0.8 0.11 200); + --destructive: oklch(0.72 0.17 22); + --destructive-foreground: oklch(0.16 0.03 25); + --destructive-soft: oklch(0.3 0.06 25); + --success: oklch(0.74 0.13 155); + --warning: oklch(0.82 0.13 80); + --warning-foreground: oklch(0.2 0.04 70); + --info: oklch(0.76 0.1 250); + --overlay: oklch(0 0 0 / 60%); + --app-home: oklch(0.74 0.1 205); + --app-home-soft: oklch(0.3 0.05 205); + --app-drive: oklch(0.74 0.1 250); + --app-drive-soft: oklch(0.3 0.05 250); + --app-docs: oklch(0.74 0.1 280); + --app-docs-soft: oklch(0.3 0.05 280); + --app-tasks: oklch(0.74 0.1 150); + --app-tasks-soft: oklch(0.3 0.05 150); + --app-calendar: oklch(0.74 0.1 40); + --app-calendar-soft: oklch(0.3 0.05 40); + --app-contacts: oklch(0.74 0.1 320); + --app-contacts-soft: oklch(0.3 0.05 320); + --app-notes: oklch(0.74 0.1 85); + --app-notes-soft: oklch(0.3 0.05 85); + --app-photos: oklch(0.74 0.1 350); + --app-photos-soft: oklch(0.3 0.05 350); + --app-chat: oklch(0.74 0.1 300); + --app-chat-soft: oklch(0.3 0.05 300); + --app-sheets: oklch(0.74 0.1 170); + --app-sheets-soft: oklch(0.3 0.05 170); + --app-mail: oklch(0.74 0.1 20); + --app-mail-soft: oklch(0.3 0.05 20); + --app-music: oklch(0.74 0.1 335); + --app-music-soft: oklch(0.3 0.05 335); + --app-money: oklch(0.74 0.1 130); + --app-money-soft: oklch(0.3 0.05 130); + --app-health: oklch(0.74 0.1 10); + --app-health-soft: oklch(0.3 0.05 10); + --app-permissions: oklch(0.74 0.1 225); + --app-permissions-soft: oklch(0.3 0.05 225); + --glyph: oklch(0.16 0.012 240); + --shadow-1: 0 0 0 1px var(--border); + --elevation-1-fill: var(--surface-2); + --shadow-2: 0 0 0 1px var(--border-strong); + --elevation-2-fill: var(--surface-2); + --shadow-3: 0 0 0 1px var(--border-strong); + --elevation-3-fill: var(--surface-3); +} + +@media (prefers-reduced-motion: reduce) { + :root { + --duration-fast: 0ms; + --duration-base: 0ms; + --duration-slow: 0ms; + --duration-xslow: 0ms; + } +} + +:root { + --card: var(--surface); + --card-foreground: var(--foreground); + --popover: var(--surface); + --popover-foreground: var(--foreground); + --muted: var(--surface-2); + --muted-foreground: var(--foreground-muted); + --accent: var(--primary-soft); + --accent-foreground: var(--primary-soft-foreground); + --secondary: var(--surface-2); + --secondary-foreground: var(--foreground); +} diff --git a/apps/extension/src/sidepanel/grants-controller.ts b/apps/extension/src/sidepanel/grants-controller.ts new file mode 100644 index 000000000..7ed1c0017 --- /dev/null +++ b/apps/extension/src/sidepanel/grants-controller.ts @@ -0,0 +1,259 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import type { OriginGrant } from '../background/core/origin-grant'; +import type { OriginGrantRequest, Profile, ProfilesResponse } from '../shared/messages'; + +export type GrantsMessage = OriginGrantRequest | { readonly type: 'SOLID_PROFILES_LIST' }; +export interface GrantsResponse extends ProfilesResponse { + readonly grants?: readonly OriginGrant[]; +} + +export const DURATIONS = [ + { label: 'Until revoked', days: null }, + { label: '1 day', days: 1 }, + { label: '1 month', days: 30 }, + { label: '1 year', days: 365 }, +] as const; +export type Duration = (typeof DURATIONS)[number]['label']; + +export type Confirmation = + | { readonly kind: 'revoke'; readonly origin: string } + | { readonly kind: 'rebind'; readonly origin: string; readonly profileId: string } + | { + readonly kind: 'expiry'; + readonly origin: string; + readonly expiresAt: number; + readonly duration: Duration; + }; + +export interface GrantsState { + readonly phase: 'loading' | 'ready' | 'confirming' | 'saving' | 'disposed'; + readonly grants: readonly OriginGrant[]; + readonly profiles: readonly Profile[]; + readonly confirmation: Confirmation | null; + readonly status: string; + readonly error: string | null; + readonly loaded: boolean; +} + +export interface GrantsDependencies { + readonly send: (message: GrantsMessage) => Promise; + readonly subscribe?: (listener: (message: { type: string }) => void) => () => void; + readonly now?: () => number; +} + +/** Pure, token-free state machine. Reads, writes and broadcasts share one serial queue. */ +export class GrantsController { + private snapshot: GrantsState = { + phase: 'loading', + grants: [], + profiles: [], + confirmation: null, + status: '', + error: null, + loaded: false, + }; + private readonly listeners = new Set<(state: GrantsState) => void>(); + private tail: Promise = Promise.resolve(); + private refreshPending: Promise | null = null; + private refreshAgain = false; + private readonly unsubscribe?: () => void; + private readonly now: () => number; + + constructor(private readonly dependencies: GrantsDependencies) { + this.now = dependencies.now ?? Date.now; + this.unsubscribe = dependencies.subscribe?.((message) => { + if (message.type === 'SOLID_STATE_CHANGED') void this.refresh(); + }); + } + + get state(): GrantsState { + return this.snapshot; + } + + subscribe(listener: (state: GrantsState) => void): () => void { + if (this.state.phase === 'disposed') return () => {}; + this.listeners.add(listener); + listener(this.state); + return () => this.listeners.delete(listener); + } + + private set(update: Partial): void { + if (this.state.phase === 'disposed') return; + this.snapshot = { ...this.state, ...update }; + for (const listener of this.listeners) listener(this.state); + } + + private enqueue(work: () => Promise): Promise { + const run = this.tail.then(async () => { + if (this.state.phase !== 'disposed') await work(); + }); + this.tail = run.catch(() => {}); + return run; + } + + refresh(): Promise { + if (this.state.phase === 'disposed') return Promise.resolve(); + if (this.refreshPending) { + this.refreshAgain = true; + return this.refreshPending; + } + if (this.state.phase !== 'saving') this.set({ phase: 'loading' }); + this.refreshPending = this.enqueue(async () => { + // Keep the confirmation and existing rows during a background refresh. + this.set({ phase: 'loading' }); + try { + const grants = await this.dependencies.send({ type: 'SOLID_ORIGIN_GRANTS_LIST' }); + if (this.state.phase === 'disposed') return; + const profiles = await this.dependencies.send({ type: 'SOLID_PROFILES_LIST' }); + if ( + grants.error || + grants.ok !== true || + !grants.grants || + profiles.error || + !profiles.profiles + ) + throw new Error('list'); + this.set({ + grants: grants.grants, + profiles: profiles.profiles, + loaded: true, + phase: this.state.confirmation ? 'confirming' : 'ready', + error: null, + }); + } catch { + this.set({ + phase: this.state.confirmation ? 'confirming' : 'ready', + error: "Couldn't load sites with access. Try refreshing.", + }); + } + }).finally(() => { + this.refreshPending = null; + if (this.refreshAgain) { + this.refreshAgain = false; + void this.refresh(); + } + }); + return this.refreshPending; + } + + durationOptions(grant: OriginGrant) { + const now = this.now(); + return DURATIONS.map(({ label, days }) => { + const expiresAt = days === null ? null : now + days * 86_400_000; + const reason = + expiresAt === null + ? grant.expiresAt === undefined + ? 'Access already lasts until you revoke it.' + : 'To extend access, this site must ask again.' + : grant.expiresAt !== undefined && expiresAt >= grant.expiresAt + ? 'Choose a shorter duration. To extend access, this site must ask again.' + : null; + return { label, expiresAt, reason }; + }); + } + + private request(confirmation: Confirmation): void { + if ( + this.state.phase !== 'ready' || + !this.state.grants.some((g) => g.origin === confirmation.origin) + ) + return; + this.set({ phase: 'confirming', confirmation, error: null, status: '' }); + } + + requestRevoke(origin: string): void { + this.request({ kind: 'revoke', origin }); + } + + requestRebind(origin: string, profileId: string): void { + const grant = this.state.grants.find((g) => g.origin === origin); + if ( + grant?.profileId === profileId || + !this.state.profiles.some((p) => p.id === profileId && p.sessionState === 'active') + ) + return; + this.request({ kind: 'rebind', origin, profileId }); + } + + requestExpiry(origin: string, duration: Duration): void { + const grant = this.state.grants.find((g) => g.origin === origin); + if (!grant) return; + const option = this.durationOptions(grant).find((d) => d.label === duration); + if (!option || option.reason || option.expiresAt === null) return; + this.request({ kind: 'expiry', origin, expiresAt: option.expiresAt, duration }); + } + + cancel(): void { + if (this.state.confirmation && this.state.phase !== 'saving') + this.set({ phase: this.refreshPending ? 'loading' : 'ready', confirmation: null }); + } + + confirm(): Promise { + const action = this.state.confirmation; + if (this.state.phase !== 'confirming' || !action) return Promise.resolve(); + // Lock synchronously so double activation cannot submit the same decision twice. + this.set({ phase: 'saving', confirmation: null, error: null }); + return this.enqueue(async () => { + this.set({ phase: 'saving' }); + try { + const grant = this.state.grants.find((g) => g.origin === action.origin); + if ( + !grant || + (action.kind === 'expiry' && + (action.expiresAt <= this.now() || + (grant.expiresAt !== undefined && action.expiresAt >= grant.expiresAt))) + ) + throw new Error('stale'); + if ( + action.kind === 'rebind' && + !this.state.profiles.some((p) => p.id === action.profileId && p.sessionState === 'active') + ) + throw new Error('account'); + const message: OriginGrantRequest = + action.kind === 'revoke' + ? { type: 'SOLID_ORIGIN_GRANT_REVOKE', origin: action.origin } + : action.kind === 'rebind' + ? { + type: 'SOLID_ORIGIN_GRANT_PROFILE', + origin: action.origin, + profileId: action.profileId, + } + : { + type: 'SOLID_ORIGIN_GRANT_EXPIRY', + origin: action.origin, + expiresAt: action.expiresAt, + }; + const response = await this.dependencies.send(message); + if (response.error || response.ok !== true || !response.grants) throw new Error('save'); + this.set({ + phase: 'ready', + grants: response.grants, + status: + action.kind === 'revoke' + ? `Access revoked for ${action.origin}.` + : action.kind === 'rebind' + ? `Account changed for ${action.origin}. The site must ask for data access again.` + : `Access expiry updated for ${action.origin}.`, + }); + } catch { + this.set({ + phase: 'ready', + error: "Couldn't update this site's access. Refresh and try again.", + }); + } + }); + } + + dispose(): void { + if (this.state.phase === 'disposed') return; + this.unsubscribe?.(); + this.snapshot = { + ...this.state, + phase: 'disposed', + confirmation: null, + grants: [], + profiles: [], + }; + this.listeners.clear(); + } +} diff --git a/apps/extension/src/sidepanel/grants-view.ts b/apps/extension/src/sidepanel/grants-view.ts new file mode 100644 index 000000000..2c39ec5ae --- /dev/null +++ b/apps/extension/src/sidepanel/grants-view.ts @@ -0,0 +1,380 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import type { ClassGrant } from '../background/core/class-grant'; +import type { OriginGrant } from '../background/core/origin-grant'; +import type { Confirmation, GrantsController, GrantsState } from './grants-controller'; + +const CLASS_LABELS: Readonly> = { + 'https://schema.org/TextDigitalDocument': 'Documents', + 'http://schema.org/TextDigitalDocument': 'Documents', + 'https://schema.org/Person': 'Contacts', + 'http://schema.org/Person': 'Contacts', + 'http://www.w3.org/2006/vcard/ns#Individual': 'Contacts', + 'http://www.w3.org/2002/12/cal/ical#Vevent': 'Calendar events', + 'https://schema.org/Event': 'Calendar events', + 'http://www.w3.org/ns/pim/meeting#LongChat': 'Chats', +}; +const MODES: Readonly> = { + read: 'Read', + append: 'Add', + write: 'Edit and delete', +}; + +function classLabel(value: string): string { + if (CLASS_LABELS[value]) return CLASS_LABELS[value]; + // Unknown vocabularies still get a readable local name, never a raw technical URL. + try { + const url = new URL(value); + const local = decodeURIComponent(url.hash.slice(1) || url.pathname.split('/').pop() || ''); + return local.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ') || 'Other data'; + } catch { + return 'Other data'; + } +} + +function date(value: number): string { + return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format( + value, + ); +} + +function initials(value: string): string { + return ( + value + .trim() + .split(/\s+/) + .slice(0, 2) + .map((part) => part[0]) + .join('') + .toUpperCase() || '?' + ); +} + +/** Native controls provide Enter/Space activation, disclosure semantics and modal focus handling. */ +export function mountGrantsView(root: HTMLElement, controller: GrantsController): () => void { + const doc = root.ownerDocument; + const element = ( + tag: K, + text?: string, + className?: string, + ) => { + const node = doc.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; + }; + const button = (text: string, key: string, activate: () => void) => { + const node = element('button', text); + node.type = 'button'; + node.dataset.focusKey = key; + node.addEventListener('click', activate); + return node; + }; + root.classList.add('grants-manager'); + const heading = element('h2', 'Sites with access'); + heading.id = 'grants-heading'; + heading.tabIndex = -1; + root.setAttribute('aria-labelledby', heading.id); + const introduction = element('p', 'Choose which sites can use your accounts and data.'); + const refresh = button('Refresh', 'refresh', () => { + void controller.refresh(); + }); + const status = element('p', '', 'grants-status'); + status.setAttribute('role', 'status'); + status.setAttribute('aria-atomic', 'true'); + const error = element('p', '', 'grants-error'); + error.setAttribute('role', 'alert'); + const list = element('div', undefined, 'grants-list'); + const dialog = element('dialog', undefined, 'grants-dialog'); + dialog.setAttribute('aria-labelledby', 'grants-confirm-title'); + dialog.setAttribute('aria-describedby', 'grants-confirm-description'); + root.replaceChildren(heading, introduction, refresh, status, error, list, dialog); + + const expanded = new Set(); + let currentConfirmation: Confirmation | null = null; + let restoreKey: string | undefined; + let dialogConfirm: HTMLButtonElement | undefined; + let wasSaving = false; + let disposed = false; + + // A control disabled while loading parks focus on the heading; remember it so the next + // enabled render can return focus instead of leaving it on the heading. + let parkedKey: string | undefined; + const focusedKey = () => { + const active = doc.activeElement as HTMLElement | null; + const key = active?.dataset?.focusKey; + if (key) return key; + if (active !== heading && active !== doc.body && active !== null) parkedKey = undefined; + return parkedKey; + }; + const restoreFocus = (key: string | undefined) => { + const target = Array.from(root.querySelectorAll('[data-focus-key]')).find( + (node) => node.dataset.focusKey === key, + ); + const disabled = target?.matches(':disabled') ?? false; + parkedKey = disabled ? key : undefined; + (disabled ? heading : (target ?? heading)).focus(); + }; + const cancelDialog = (event: Event) => { + event.preventDefault(); + controller.cancel(); + }; + dialog.addEventListener('cancel', cancelDialog); + // Chrome lets Tab leave a modal dialog for the browser UI; keep it inside the dialog. + const trapFocus = (event: KeyboardEvent) => { + if (event.key !== 'Tab') return; + const buttons = Array.from(dialog.querySelectorAll('button')).filter((b) => !b.disabled); + const first = buttons[0]; + const last = buttons[buttons.length - 1]; + if (!first) return; + const active = doc.activeElement; + if (event.shiftKey ? active === first || !dialog.contains(active) : active === last) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + } + }; + dialog.addEventListener('keydown', trapFocus); + + function renderDialog(state: GrantsState, key: string | undefined) { + const action = state.confirmation; + if (action && action !== currentConfirmation) { + restoreKey = key; + currentConfirmation = action; + const title = element( + 'h3', + action.kind === 'revoke' + ? 'Revoke site access?' + : action.kind === 'rebind' + ? 'Change account?' + : 'Shorten access?', + ); + title.id = 'grants-confirm-title'; + const account = + action.kind === 'rebind' + ? state.profiles.find((p) => p.id === action.profileId)?.label + : ''; + const description = element( + 'p', + action.kind === 'revoke' + ? `${action.origin} will lose access to your account and data. The site must ask again to regain access.` + : action.kind === 'rebind' + ? `Use ${account} for ${action.origin}? Changing the account clears this site's data access. The site must ask again.` + : `Access for ${action.origin} will end on ${date(action.expiresAt)} (${action.duration} from now). The site must ask again to extend access.`, + ); + description.id = 'grants-confirm-description'; + const cancel = button('Cancel', 'confirm-cancel', () => controller.cancel()); + dialogConfirm = button( + action.kind === 'revoke' + ? 'Revoke access' + : action.kind === 'rebind' + ? 'Change account' + : 'Shorten access', + 'confirm-submit', + () => { + void controller.confirm(); + }, + ); + dialogConfirm.className = 'grants-destructive'; + const actions = element('div', undefined, 'grants-actions'); + actions.append(cancel, dialogConfirm); + dialog.replaceChildren(title, description, actions); + dialog.showModal(); + cancel.focus(); + } else if (!action && currentConfirmation) { + currentConfirmation = null; + dialog.close(); + restoreFocus(restoreKey); + } + if (dialogConfirm) dialogConfirm.disabled = state.phase !== 'confirming'; + } + + function renderClass(grant: ClassGrant, parent: OriginGrant) { + const row = element('li', undefined, 'grants-class'); + const expiry = Math.min(grant.expiresAt, parent.expiresAt ?? Number.POSITIVE_INFINITY); + row.append( + element('strong', classLabel(grant.dataClass)), + element('p', grant.modes.map((mode) => MODES[mode] ?? 'Other access').join(' · ')), + element('p', `Expires ${date(expiry)}`), + ); + return row; + } + + function renderSite(grant: OriginGrant, state: GrantsState, index: number) { + const busy = state.phase !== 'ready'; + const row = element('article', undefined, 'permission-row'); + row.setAttribute('aria-label', grant.origin); + const header = element('div', undefined, 'grants-site-header'); + const avatar = element( + 'span', + new URL(grant.origin).hostname[0].toUpperCase(), + 'grants-avatar', + ); + avatar.setAttribute('aria-hidden', 'true'); + const identity = element('div', undefined, 'grants-site-identity'); + const name = element('h3', grant.origin); + const since = element('p', `Since ${date(grant.createdAt)}`); + identity.append(name, since); + header.append(avatar, identity); + const profile = state.profiles.find((p) => p.id === grant.profileId); + const bound = element('div', undefined, 'grants-bound-account'); + const accountAvatar = element('span', initials(profile?.label ?? ''), 'grants-avatar'); + accountAvatar.setAttribute('aria-hidden', 'true'); + if (profile?.avatar) { + try { + const url = new URL(profile.avatar); + if (url.protocol === 'https:' || url.protocol === 'http:') { + const image = element('img'); + image.alt = ''; + image.referrerPolicy = 'no-referrer'; + image.addEventListener('error', () => image.remove(), { once: true }); + image.src = url.href; + accountAvatar.append(image); + } + } catch { + /* Initials remain when an account image is unavailable. */ + } + } + bound.append( + accountAvatar, + element('span', `Account: ${profile?.label ?? 'Unavailable account'}`), + ); + const classes = grant.scope.classes; + const chips = element('div', undefined, 'grants-summary'); + chips.append( + element( + 'span', + classes === undefined + ? 'All data in connected storage' + : `${classes.length} data ${classes.length === 1 ? 'type' : 'types'}`, + ), + ); + chips.append( + element( + 'span', + grant.expiresAt === undefined ? 'Until revoked' : `Expires ${date(grant.expiresAt)}`, + ), + ); + const revoke = button('Revoke', `${grant.origin}:revoke`, () => + controller.requestRevoke(grant.origin), + ); + revoke.className = 'grants-destructive'; + revoke.disabled = busy; + + const detailsId = `grants-details-${index}`; + const details = element('div', undefined, 'grants-details'); + details.id = detailsId; + details.hidden = !expanded.has(grant.origin); + const toggle = button('Access details', `${grant.origin}:details`, () => { + if (expanded.has(grant.origin)) expanded.delete(grant.origin); + else expanded.add(grant.origin); + details.hidden = !expanded.has(grant.origin); + toggle.setAttribute('aria-expanded', String(!details.hidden)); + }); + toggle.setAttribute('aria-expanded', String(!details.hidden)); + toggle.setAttribute('aria-controls', detailsId); + const classList = element('ul', undefined, 'grants-classes'); + classList.setAttribute('aria-label', 'Data access'); + if (classes?.length) classList.append(...classes.map((item) => renderClass(item, grant))); + else + classList.append( + element( + 'li', + classes === undefined + ? 'Access to all data in connected storage.' + : 'No data access. This site must ask again.', + ), + ); + + const label = element('label', 'Account'); + const picker = element('select'); + picker.dataset.focusKey = `${grant.origin}:account`; + picker.disabled = busy; + if (!profile) { + const unavailable = element('option', 'Unavailable account'); + unavailable.value = grant.profileId; + unavailable.disabled = true; + picker.append(unavailable); + } + for (const account of state.profiles) { + const option = element( + 'option', + `${account.label}${account.sessionState === 'active' ? '' : ' — sign in to use'}`, + ); + option.value = account.id; + option.disabled = account.sessionState !== 'active'; + picker.append(option); + } + picker.value = grant.profileId; + picker.addEventListener('change', () => { + const selected = picker.value; + picker.value = grant.profileId; + controller.requestRebind(grant.origin, selected); + }); + label.append(picker); + const warning = element( + 'p', + "Changing the account clears this site's data access. The site must ask again.", + ); + warning.id = `grants-account-warning-${index}`; + picker.setAttribute('aria-describedby', warning.id); + + const durations = element('fieldset', undefined, 'duration-chips'); + durations.append(element('legend', 'Access duration')); + const reason = element( + 'p', + 'Only shorter durations are available. To extend access, this site must ask again.', + ); + reason.id = `grants-duration-reason-${index}`; + for (const option of controller.durationOptions(grant)) { + const chip = button(option.label, `${grant.origin}:${option.label}`, () => + controller.requestExpiry(grant.origin, option.label), + ); + chip.disabled = busy || option.reason !== null; + chip.title = option.reason ?? `End access ${date(option.expiresAt!)}`; + chip.setAttribute('aria-describedby', reason.id); + durations.append(chip); + } + details.append(classList, label, warning, durations, reason); + row.append(header, bound, chips, revoke, toggle, details); + return row; + } + + const unsubscribe = controller.subscribe((state) => { + if (disposed) return; + const key = focusedKey(); + refresh.disabled = state.phase === 'loading' || state.phase === 'saving'; + list.setAttribute('aria-busy', String(state.phase === 'loading' || state.phase === 'saving')); + status.textContent = + state.phase === 'loading' + ? 'Loading sites with access…' + : state.phase === 'saving' + ? 'Updating site access…' + : state.status; + error.textContent = state.error ?? ''; + if (!state.loaded) { + list.replaceChildren( + element( + 'p', + state.error ? 'Refresh to load sites with access.' : 'Loading sites with access…', + ), + ); + } else if (!state.grants.length) { + list.replaceChildren(element('p', 'No sites have access yet')); + } else { + list.replaceChildren(...state.grants.map((grant, index) => renderSite(grant, state, index))); + } + if (key && !dialog.open) restoreFocus(key); + renderDialog(state, key); + if (wasSaving && state.phase === 'ready') restoreFocus(restoreKey); + wasSaving = state.phase === 'saving'; + }); + void controller.refresh(); + + return () => { + disposed = true; + unsubscribe(); + dialog.removeEventListener('cancel', cancelDialog); + dialog.removeEventListener('keydown', trapFocus); + if (dialog.open) dialog.close(); + root.replaceChildren(); + }; +} diff --git a/apps/extension/src/sidepanel/grants.css b/apps/extension/src/sidepanel/grants.css new file mode 100644 index 000000000..3c80c3961 --- /dev/null +++ b/apps/extension/src/sidepanel/grants.css @@ -0,0 +1,92 @@ +/* AUTHORED-BY Codex GPT-6.1-Sol */ +.grants-manager { + margin-top: 24px; + border-top: 1px solid var(--border); + padding-top: 16px; + line-height: 1.5; +} + +.grants-manager h2 { font-size: 18px; } +.grants-manager h3 { margin: 0; font-size: 15px; overflow-wrap: anywhere; } +.grants-manager p { margin: 8px 0; } +.grants-manager [hidden] { display: none; } +.grants-manager button, .grants-manager select { + min-height: 32px; + max-width: 100%; + border: 1px solid var(--foreground); + border-radius: var(--radius); + padding: 5px 10px; + background: var(--background); + color: var(--foreground); + font: inherit; +} +.grants-manager button { cursor: pointer; } +.grants-manager button:disabled, .grants-manager select:disabled { + cursor: default; + border-color: var(--border); +} +.grants-manager :focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; + scroll-margin-block: 12px; +} +.grants-manager .grants-destructive { + color: var(--destructive); + border-color: var(--destructive); +} +.grants-error { color: var(--destructive); } +.grants-status, .grants-error { overflow-wrap: anywhere; } +.grants-status:empty, .grants-error:empty { margin: 0; } +.grants-list { display: grid; gap: 12px; margin-top: 12px; } +.permission-row { + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + min-width: 0; +} +.grants-site-header, .grants-bound-account { display: flex; align-items: center; gap: 8px; } +.grants-site-identity { min-width: 0; } +.grants-bound-account { margin-block: 8px; overflow-wrap: anywhere; } +.grants-avatar { + position: relative; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + overflow: hidden; + border-radius: 50%; + background: var(--accent); + color: var(--accent-foreground); + font-weight: 600; +} +.grants-avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.grants-summary { display: flex; flex-wrap: wrap; gap: 6px; margin-block: 10px; } +.grants-summary span { border: 1px solid var(--border); border-radius: var(--radius); padding: 2px 6px; } +.permission-row > button { margin: 0 6px 6px 0; } +.grants-details { border-top: 1px solid var(--border); margin-top: 8px; padding-top: 8px; } +.grants-classes { list-style: none; margin: 0; padding: 0 0 0 12px; border-left: 2px solid var(--border); } +.grants-class { margin-block: 12px; } +.grants-manager label { display: grid; gap: 4px; } +.duration-chips { border: 0; padding: 0; margin: 12px 0 0; min-width: 0; } +.duration-chips button { margin: 6px 6px 0 0; } +.grants-dialog { + max-width: min(400px, calc(100% - 24px)); + max-height: calc(100% - 24px); + overflow: auto; + border: 1px solid var(--foreground); + border-radius: var(--radius); + padding: 20px; + background: var(--background); + color: var(--foreground); +} +.grants-dialog::backdrop { background: var(--background); opacity: 0.8; } +.grants-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } +@media (pointer: coarse) { + .grants-manager button, .grants-manager select { min-height: 44px; } +} +/* Origins, account labels and unfamiliar data labels can be one long word; wrap rather than + scroll sideways at narrow widths (WCAG 1.4.10 reflow). */ +.grants-manager, .grants-dialog { overflow-wrap: anywhere; } +.grants-summary span, .grants-class strong { min-width: 0; overflow-wrap: anywhere; } diff --git a/apps/extension/src/sidepanel/sidepanel.css b/apps/extension/src/sidepanel/sidepanel.css new file mode 100644 index 000000000..1c4bc8902 --- /dev/null +++ b/apps/extension/src/sidepanel/sidepanel.css @@ -0,0 +1,11 @@ +/* AUTHORED-BY Codex GPT-6.1-Sol */ +@import url('../shared/account-surface.css'); + +[hidden] { display: none !important; } +html, body { min-height: 100%; } +body { width: 100%; } +@media (min-width: 768px) { + .container { padding-right: var(--space-6); padding-left: var(--space-6); } +} +/* A side panel dragged wide keeps a reading width (design system: Settings surfaces 720px). */ +.container { max-width: var(--page-settings-w); margin-inline: auto; } diff --git a/apps/extension/src/sidepanel/sidepanel.html b/apps/extension/src/sidepanel/sidepanel.html new file mode 100644 index 000000000..275654bef --- /dev/null +++ b/apps/extension/src/sidepanel/sidepanel.html @@ -0,0 +1,66 @@ + + + + + + Solid + + + + + + + + +
    +
    +
    + +

    Solid

    +
    + +
    + +
    + + + + + + + + +
    +
    +
    + + + diff --git a/apps/extension/src/sidepanel/sidepanel.ts b/apps/extension/src/sidepanel/sidepanel.ts new file mode 100644 index 000000000..2b5cf9dc0 --- /dev/null +++ b/apps/extension/src/sidepanel/sidepanel.ts @@ -0,0 +1,46 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The MV3 side panel — a PERSISTENT account surface (vs the ephemeral toolbar popup). + * Chrome keeps the side panel open as the user navigates and switches tabs, so the + * signed-in account / signed-out login stays a glance away without re-opening a popup. + * + * It renders the IDENTICAL surface as the popup — `` signed out, + * `` + pod shortcuts signed in — by REUSING the popup's account + * surface ({@link mountAccountSurface}) driven by the SAME `MessageBridgeLoginController` + * (the token-free, service-worker-backed bridge). No auth runs here: the service worker + * is the sole token holder; this panel only reflects state and proxies actions over the + * existing `chrome.runtime` message protocol — exactly like the popup. The popup is + * unchanged and keeps working; both share the one controller seam. + */ + +// Side-effect import: registers , , , etc. +import '@jeswr/solid-elements'; +import { mountAccountSurface, resolveAccountSurfaceElements } from '../popup/account-surface'; +import { GrantsController } from './grants-controller'; +import { mountGrantsView } from './grants-view'; + +// Wire the shared account surface (busy → signed-in / signed-out, all the auth invariants). +// The side panel is LONG-LIVED (persists across navigation), so — unlike the ephemeral popup — +// we keep the cleanup handle and detach the listeners on teardown. Without this, the long-lived +// `chrome.runtime.onMessage` listener (and its refresh() calls) would leak / accumulate across +// any remount. (mountAccountSurface also guards against double-mounting the same document.) +const { cleanup } = mountAccountSurface(resolveAccountSurfaceElements()); +const grants = new GrantsController({ + send: (message) => chrome.runtime.sendMessage(message), + subscribe: (listener) => { + chrome.runtime.onMessage.addListener(listener); + return () => chrome.runtime.onMessage.removeListener(listener); + }, +}); +const cleanupGrants = mountGrantsView(document.getElementById('grants')!, grants); + +// `pagehide` fires when the panel document is torn down (closed / navigated away); detach there. +globalThis.addEventListener?.( + 'pagehide', + () => { + cleanupGrants(); + grants.dispose(); + cleanup(); + }, + { once: true }, +); diff --git a/apps/extension/test-site/address-form.html b/apps/extension/test-site/address-form.html new file mode 100644 index 000000000..8f5f18cbd --- /dev/null +++ b/apps/extension/test-site/address-form.html @@ -0,0 +1,21 @@ + + + +Address form + +

    Delivery details

    +
    +

    +

    +

    +

    +

    +

    +

    +

    +

    +

    + +
    + + diff --git a/apps/extension/test-site/app.js b/apps/extension/test-site/app.js new file mode 100644 index 000000000..925bbd1e8 --- /dev/null +++ b/apps/extension/test-site/app.js @@ -0,0 +1,61 @@ +async function init() { + var webidEl = document.getElementById('webid'); + var fetchResultEl = document.getElementById('fetch-result'); + var statusEl = document.getElementById('status'); + + // Wait for the solid extension to be available and authenticated + function waitForSolid() { + return new Promise(function (resolve) { + if (window.solid && window.solid.webId) { + return resolve(window.solid); + } + var interval = setInterval(function () { + if (window.solid && window.solid.webId) { + clearInterval(interval); + resolve(window.solid); + } + }, 200); + }); + } + + if (!window.solid) { + statusEl.textContent = 'Solid extension not detected'; + statusEl.className = 'error'; + return; + } + + statusEl.textContent = 'Extension detected, waiting for authentication...'; + + var solid = await waitForSolid(); + webidEl.textContent = solid.webId; + webidEl.className = ''; + + // Derive the pod storage root from the WebID + // e.g. http://localhost:3000/test-pod/profile/card#me -> http://localhost:3000/test-pod/ + var podRoot = solid.webId.split('/profile/')[0] + '/'; + var privateUrl = podRoot + 'private/notes'; + + statusEl.textContent = 'Authenticated! Fetching private resource...'; + + try { + var response = await solid.fetch(privateUrl); + if (response.ok) { + var text = await response.text(); + fetchResultEl.textContent = text; + fetchResultEl.className = ''; + statusEl.textContent = 'Private resource fetched successfully!'; + } else { + fetchResultEl.textContent = 'HTTP ' + response.status + ': ' + response.statusText; + fetchResultEl.className = 'error'; + statusEl.textContent = 'Fetch failed'; + statusEl.className = 'error'; + } + } catch (err) { + fetchResultEl.textContent = err.message; + fetchResultEl.className = 'error'; + statusEl.textContent = 'Fetch error'; + statusEl.className = 'error'; + } +} + +init(); diff --git a/apps/extension/test-site/index.html b/apps/extension/test-site/index.html new file mode 100644 index 000000000..87e4ee2ba --- /dev/null +++ b/apps/extension/test-site/index.html @@ -0,0 +1,58 @@ + + + + + + Solid Extension Test + + + +

    Solid Extension Test

    + +
    +
    WebID
    +
    Waiting for Solid extension...
    +
    + +
    +
    Private Resource (authenticated fetch)
    +
    Waiting for authentication...
    +
    + +
    +
    Status
    +
    Checking for Solid extension...
    +
    + + + + diff --git a/apps/extension/test-site/server.ts b/apps/extension/test-site/server.ts new file mode 100644 index 000000000..a46b980ab --- /dev/null +++ b/apps/extension/test-site/server.ts @@ -0,0 +1,99 @@ +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// This package is `"type": "module"`; this file is loaded as ESM (by the Playwright e2e +// global-setup and by scripts/dev.ts), where `__dirname` is not defined. Derive it from +// `import.meta.url`. +const STATIC_DIR = path.dirname(fileURLToPath(import.meta.url)); +const MIME_TYPES: Record = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.jsonld': 'application/ld+json', + '.png': 'image/png', + '.svg': 'image/svg+xml', +}; + +export interface TestSiteOptions { + port: number; + /** The redirect URI to include in the Client ID Document served at /client-id. */ + redirectUri?: string; + /** The client name for the Client ID Document. Defaults to the server's role. */ + clientName?: string; + /** If set, the /config.json endpoint returns this as the clientIdUrl for the app. */ + clientIdUrl?: string; +} + +export function createTestSiteServer(opts: TestSiteOptions): http.Server { + const { port, redirectUri, clientName, clientIdUrl } = opts; + + const server = http.createServer((req, res) => { + const url = new URL(req.url || '/', `http://localhost:${port}`); + + // Client ID Document endpoint — clean URL, no query params in the client_id + if (url.pathname === '/client-id') { + if (!redirectUri) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Server not configured with a redirectUri'); + return; + } + const selfUrl = `http://localhost:${port}/client-id`; + const doc = { + '@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'], + client_id: selfUrl, + client_name: clientName || 'Solid Application', + redirect_uris: [redirectUri], + scope: 'openid profile offline_access webid', + grant_types: ['refresh_token', 'authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }; + res.writeHead(200, { + 'Content-Type': 'application/ld+json', + 'Access-Control-Allow-Origin': '*', + }); + res.end(JSON.stringify(doc, null, 2)); + return; + } + + // Injected config endpoint — returns runtime configuration for the app + if (url.pathname === '/config.json') { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }); + res.end(JSON.stringify({ clientIdUrl: clientIdUrl || null })); + return; + } + + // Static file serving + const filePath = path.join(STATIC_DIR, url.pathname === '/' ? 'index.html' : url.pathname); + const ext = path.extname(filePath); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + return; + } + res.writeHead(200, { + 'Content-Type': contentType, + 'Access-Control-Allow-Origin': '*', + }); + res.end(data); + }); + }); + + return server; +} + +export function startTestSiteServer(opts: TestSiteOptions): Promise { + return new Promise((resolve) => { + const server = createTestSiteServer(opts); + server.listen(opts.port, () => resolve(server)); + }); +} diff --git a/apps/extension/test-site/with-client-id.html b/apps/extension/test-site/with-client-id.html new file mode 100644 index 000000000..183f1fbd4 --- /dev/null +++ b/apps/extension/test-site/with-client-id.html @@ -0,0 +1,93 @@ + + + + + + Solid Test - App with Client ID + + + +

    Solid Test - App with Client ID

    +

    This site sets its own client identifier via solid.setClientId()

    + +
    +
    Client ID
    +
    Not set
    +
    + +
    +
    Login
    + + +
    + +
    +
    WebID
    +
    Not authenticated
    +
    + +
    +
    Private Resource (authenticated fetch)
    +
    Waiting for authentication...
    +
    + +
    +
    Status
    +
    Checking for Solid extension...
    +
    + + + + diff --git a/apps/extension/test-site/with-client-id.js b/apps/extension/test-site/with-client-id.js new file mode 100644 index 000000000..d25890321 --- /dev/null +++ b/apps/extension/test-site/with-client-id.js @@ -0,0 +1,94 @@ +async function init() { + var clientIdEl = document.getElementById('client-id-display'); + var webidInput = document.getElementById('webid-input'); + var loginBtn = document.getElementById('login-btn'); + var webidEl = document.getElementById('webid'); + var fetchResultEl = document.getElementById('fetch-result'); + var statusEl = document.getElementById('status'); + + if (!window.solid) { + statusEl.textContent = 'Solid extension not detected'; + statusEl.className = 'error'; + return; + } + + statusEl.textContent = 'Extension detected. Loading client ID...'; + + // Fetch the client ID from the server config + try { + var configRes = await fetch('/config.json'); + var config = await configRes.json(); + + if (config.clientIdUrl) { + if (typeof window.solid.setClientId === 'function') window.solid.setClientId(config.clientIdUrl); + clientIdEl.textContent = config.clientIdUrl; + clientIdEl.className = ''; + statusEl.textContent = 'Client ID set. Enter your WebID and click Login.'; + } else { + clientIdEl.textContent = 'No client ID configured on this server'; + clientIdEl.className = 'error'; + statusEl.textContent = 'Server has no clientIdUrl in config.'; + statusEl.className = 'error'; + } + } catch (err) { + clientIdEl.textContent = 'Failed to load config: ' + err.message; + clientIdEl.className = 'error'; + statusEl.textContent = 'Config error'; + statusEl.className = 'error'; + return; + } + + // Login button handler + loginBtn.addEventListener('click', async function () { + var webId = webidInput.value.trim(); + if (!webId) return; + + statusEl.textContent = 'Logging in...'; + statusEl.className = ''; + + try { + await window.solid.login(webId); + } catch (err) { + statusEl.textContent = 'Login error: ' + err.message; + statusEl.className = 'error'; + return; + } + }); + + // Poll for authentication state + var interval = setInterval(async function () { + if (!window.solid.webId) return; + clearInterval(interval); + if (config && config.clientIdUrl) window.solid.setClientId(config.clientIdUrl); + + webidEl.textContent = window.solid.webId; + webidEl.className = ''; + + var podRoot = window.solid.webId.split('/profile/')[0] + '/'; + var privateUrl = podRoot + 'private/notes'; + + statusEl.textContent = 'Authenticated! Fetching private resource...'; + + try { + var response = await window.solid.fetch(privateUrl); + if (response.ok) { + var text = await response.text(); + fetchResultEl.textContent = text; + fetchResultEl.className = ''; + statusEl.textContent = 'Private resource fetched successfully!'; + } else { + fetchResultEl.textContent = 'HTTP ' + response.status + ': ' + response.statusText; + fetchResultEl.className = 'error'; + statusEl.textContent = 'Fetch failed'; + statusEl.className = 'error'; + } + } catch (err) { + fetchResultEl.textContent = err.message; + fetchResultEl.className = 'error'; + statusEl.textContent = 'Fetch error'; + statusEl.className = 'error'; + } + }, 200); +} + +init(); diff --git a/apps/extension/test/account-surface-cleanup.test.ts b/apps/extension/test/account-surface-cleanup.test.ts new file mode 100644 index 000000000..b5db71d5a --- /dev/null +++ b/apps/extension/test/account-surface-cleanup.test.ts @@ -0,0 +1,201 @@ +// @vitest-environment jsdom +// AUTHORED-BY Claude Opus 4.8 +/** + * Listener-lifecycle for the shared account surface (account-surface.ts) — the roborev finding + * that the long-lived side panel must NOT leak / accumulate `chrome.runtime.onMessage` listeners. + * + * The popup is ephemeral (torn down with its document) so it may ignore the return value, but + * the side panel is LONG-LIVED, so mountAccountSurface(): + * (1) returns a `cleanup()` that removes the onMessage listener it added; + * (2) guards against double-mounting the SAME document (a second mount stacks no listeners). + * + * These run on the real custom elements (jsdom + @jeswr/solid-elements) so they exercise the + * real wiring path, not a stub. + */ + +import '@jeswr/solid-elements'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type MountedAccountSurface, + mountAccountSurface, + resolveAccountSurfaceElements, +} from '../src/popup/account-surface'; +import type { Profile } from '../src/shared/messages'; + +let onMessageListeners: Array<(message: { type?: string }) => void>; + +/** + * Mount the surface AND track the handle so afterEach can tear it down. The mount guard is keyed + * on the (shared, per-file) jsdom `document`, so a test that left a live mount would otherwise + * leak the guard entry into the next test — tracking + cleanup keeps the tests independent. + */ +const liveMounts: MountedAccountSurface[] = []; +function mount(): MountedAccountSurface { + const handle = mountAccountSurface(resolveAccountSurfaceElements()); + liveMounts.push(handle); + return handle; +} + +/** Build the surface markup exactly as popup.html / sidepanel.html do. */ +function mountSurfaceDom(): void { + document.body.innerHTML = ` + + + + `; +} + +/** A chrome stub whose onMessage add/removeListener are observable. */ +function installChrome(): void { + onMessageListeners = []; + vi.stubGlobal('chrome', { + runtime: { + lastError: undefined as { message?: string } | undefined, + sendMessage: (message: { type?: string }, callback: (response: unknown) => void) => { + if (message?.type === 'SOLID_GET_STATE') { + callback({ + webId: null, + isActive: false, + name: null, + photoUrl: null, + recentAccounts: [], + }); + } else { + callback({ ok: true }); + } + }, + onMessage: { + addListener: (fn: (message: { type?: string }) => void) => { + onMessageListeners.push(fn); + }, + removeListener: (fn: (message: { type?: string }) => void) => { + onMessageListeners = onMessageListeners.filter((l) => l !== fn); + }, + }, + }, + }); +} + +beforeEach(() => { + mountSurfaceDom(); + installChrome(); +}); + +afterEach(() => { + // Tear down any still-live mount so its guard entry doesn't leak into the next test. + for (const m of liveMounts) m.cleanup(); + liveMounts.length = 0; + vi.unstubAllGlobals(); + document.body.innerHTML = ''; +}); + +describe('mountAccountSurface — listener lifecycle (long-lived side panel)', () => { + it('refreshes both account surfaces and profile lists after a partially committed removal', async () => { + let profile: Profile = { + id: '1', + webId: 'https://fixture.invalid/me', + issuer: 'https://fixture.invalid/', + label: 'Personal', + sessionState: 'active', + dpopKeyRef: '1', + }; + vi.stubGlobal('chrome', { + runtime: { + ...chrome.runtime, + sendMessage: (message: { type: string }, callback: (response: unknown) => void) => { + if (message.type === 'SOLID_PROFILES_LIST') { + callback({ ok: true, profiles: [profile], defaultProfileId: '1' }); + } else if (message.type === 'SOLID_GET_STATE') { + const active = profile.sessionState === 'active'; + callback({ + isActive: active, + webId: active ? profile.webId : null, + name: active ? profile.label : null, + photoUrl: null, + recentAccounts: [], + }); + } else if (message.type === 'SOLID_PROFILE_REMOVE') { + profile = { ...profile, sessionState: 'removing' }; + // The worker broadcasts the persisted revocation even when its purge fails. + for (const listener of onMessageListeners) listener({ type: 'SOLID_STATE_CHANGED' }); + callback({ error: 'Cache unavailable.' }); + } else callback({ ok: true }); + }, + }, + }); + const panel = document.implementation.createHTMLDocument(); + panel.body.innerHTML = document.body.innerHTML; + mount(); + liveMounts.push(mountAccountSurface(resolveAccountSurfaceElements(panel))); + await vi.waitFor(() => { + for (const doc of [document, panel]) { + expect(doc.getElementById('signed-in')?.hidden).toBe(false); + expect(doc.getElementById('profile-list')?.textContent).toContain('Signed in'); + } + }); + + document.querySelector('[data-focus="remove:1"]')!.click(); + document + .getElementById('profile-editor')! + .dispatchEvent(new Event('submit', { cancelable: true })); + await vi.waitFor(() => { + for (const doc of [document, panel]) { + expect(doc.getElementById('signed-in')?.hidden).toBe(true); + expect(doc.getElementById('signed-out')?.hidden).toBe(false); + expect(doc.getElementById('profile-list')?.textContent).toContain('Removal pending'); + expect(doc.querySelector('[data-focus="default:1"]')?.disabled).toBe( + true, + ); + expect(doc.querySelector('[data-focus="remove:1"]')?.disabled).toBe( + false, + ); + } + expect(document.getElementById('profile-error')?.textContent).toBe('Cache unavailable.'); + }); + }); + + it('registers exactly one onMessage listener on mount', () => { + mount(); + expect(onMessageListeners).toHaveLength(1); + }); + + it('cleanup() removes the onMessage listener it added', () => { + const { cleanup } = mount(); + expect(onMessageListeners).toHaveLength(1); + cleanup(); + expect(onMessageListeners).toHaveLength(0); + }); + + it('cleanup() is idempotent (a second call does not throw / under-remove)', () => { + const { cleanup } = mount(); + cleanup(); + expect(() => cleanup()).not.toThrow(); + expect(onMessageListeners).toHaveLength(0); + }); + + it('guards a double-mount on the SAME document — no second listener is stacked', () => { + const first = mount(); + const second = mount(); + // Still exactly one listener; the second call returned the existing handle. + expect(onMessageListeners).toHaveLength(1); + expect(second).toBe(first); + }); + + it('after cleanup() a fresh mount works again (re-registers a single listener)', () => { + const { cleanup } = mount(); + cleanup(); + expect(onMessageListeners).toHaveLength(0); + mount(); + expect(onMessageListeners).toHaveLength(1); + }); + + it('exposes the live bridge controller via .controller', () => { + const { controller } = mount(); + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/extension/test/auth-flow.test.ts b/apps/extension/test/auth-flow.test.ts new file mode 100644 index 000000000..486e1ece4 --- /dev/null +++ b/apps/extension/test/auth-flow.test.ts @@ -0,0 +1,188 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +// Exercise the real auth entry point with fixture discovery, storage and chrome.identity. +// Cancel at the browser prompt: no real accounts, tokens, or external network calls. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { initiateLogin } from '../src/background/auth-flow'; +import { PUBLISHED_CLIENT_ID_URL } from '../src/background/client-id'; +import { renderClientIdDocument } from '../src/background/client-id-template'; +import type { AuthParams } from '../src/background/session-store'; + +const webId = 'https://alice.example/profile/card#me'; +const issuer = 'https://issuer.example/'; +const registrationEndpoint = `${issuer}register`; +const redirectUri = 'https://bicabcdahgfbkgmlidjpmiadmjlppikk.chromiumapp.org/'; +let storage: Map; +let fetchSpy: ReturnType>; +let launch: ReturnType; +let getRedirectURL: ReturnType; +let publishedResponse: () => Response; +let registrationResponse: () => Response; +let registrationEnabled: boolean; + +beforeEach(() => { + storage = new Map(); + publishedResponse = () => + Response.json(renderClientIdDocument('bicabcdahgfbkgmlidjpmiadmjlppikk')); + registrationResponse = () => Response.json({ client_id: 'fixture-dcr-client' }); + registrationEnabled = true; + getRedirectURL = vi.fn(() => redirectUri); + launch = vi.fn((_options, callback) => callback(undefined)); + vi.stubGlobal('chrome', { + runtime: {}, + identity: { getRedirectURL, launchWebAuthFlow: launch }, + storage: { + local: { + get: async (key: string) => (storage.has(key) ? { [key]: storage.get(key) } : {}), + set: async (items: Record) => { + for (const [key, value] of Object.entries(items)) storage.set(key, value); + }, + }, + }, + }); + fetchSpy = vi.fn(async (input) => { + const url = String(input); + if (url === webId) { + return new Response(`<${webId}> <${issuer}>.`); + } + if (url === `${issuer}.well-known/openid-configuration`) { + return Response.json({ + issuer, + authorization_endpoint: `${issuer}authorize`, + token_endpoint: `${issuer}token`, + ...(registrationEnabled ? { registration_endpoint: registrationEndpoint } : {}), + }); + } + if (url === PUBLISHED_CLIENT_ID_URL) return publishedResponse(); + if (url === registrationEndpoint) return registrationResponse(); + throw new Error(`Unexpected fixture fetch: ${url}`); + }); + vi.stubGlobal('fetch', fetchSpy); +}); + +afterEach(() => vi.unstubAllGlobals()); + +function registrationCalls() { + return fetchSpy.mock.calls.filter(([url]) => String(url) === registrationEndpoint); +} + +async function expectAuthorizationClient(clientId: string, pageClientId?: string) { + await expect(initiateLogin({ webId, clientId: pageClientId })).rejects.toThrow( + 'Authorization was cancelled', + ); + expect(launch).toHaveBeenCalledOnce(); + expect(getRedirectURL).toHaveBeenCalledWith(); + const authorization = new URL(launch.mock.calls[0][0].url); + expect(authorization.searchParams.get('client_id')).toBe(clientId); + expect(authorization.searchParams.get('redirect_uri')).toBe(redirectUri); + expect(authorization.searchParams.get('code_challenge_method')).toBe('S256'); + const params = storage.get('solid:auth-params') as AuthParams; + expect(params.clientId).toBe(clientId); + expect(params.redirectUri).toBe(redirectUri); +} + +describe('clientIdMode routing (real initiateLogin)', () => { + it('uses DCR by default while Pages enablement is pending, without probing the document', async () => { + await expectAuthorizationClient('fixture-dcr-client'); + expect(registrationCalls()).toHaveLength(1); + expect(fetchSpy.mock.calls.some(([url]) => url === PUBLISHED_CLIENT_ID_URL)).toBe(false); + expect(JSON.parse(String(registrationCalls()[0][1]?.body))).toMatchObject({ + redirect_uris: [redirectUri], + token_endpoint_auth_method: 'none', + scope: 'openid webid offline_access', + }); + }); + + it('uses the published document only in document mode, without DCR', async () => { + storage.set('solid:client-id-mode', 'document'); + await expectAuthorizationClient(PUBLISHED_CLIENT_ID_URL); + expect(registrationCalls()).toHaveLength(0); + }); + + it('uses DCR only when explicitly configured, even if the document is available', async () => { + storage.set('solid:client-id-mode', 'dcr'); + await expectAuthorizationClient('fixture-dcr-client'); + expect(registrationCalls()).toHaveLength(1); + expect(fetchSpy.mock.calls.some(([url]) => url === PUBLISHED_CLIENT_ID_URL)).toBe(false); + }); + + it.each([ + 'document', + 'dcr', + ])('preserves a page-declared Client ID Document in %s mode without registration', async (mode) => { + storage.set('solid:client-id-mode', mode); + await expectAuthorizationClient( + 'https://app.example/clientid.jsonld', + 'https://app.example/clientid.jsonld', + ); + expect(registrationCalls()).toHaveLength(0); + expect(fetchSpy.mock.calls.some(([url]) => url === PUBLISHED_CLIENT_ID_URL)).toBe(false); + }); + + it.each([ + () => new Response('not found', { status: 404 }), + () => new Response('not json'), + () => Response.json(null), + () => Response.json({ client_id: 'https://attacker.example/clientid.jsonld' }), + () => { + throw new Error('offline'); + }, + ])('never registers on document failure or changes an existing session', async (response) => { + storage.set('solid:client-id-mode', 'document'); + const existing = { marker: 'existing-session-fixture' }; + storage.set('solid:session', existing); + publishedResponse = response; + await expect(initiateLogin({ webId })).rejects.toThrow( + 'published Client ID Document is unavailable or invalid', + ); + expect(registrationCalls()).toHaveLength(0); + expect(launch).not.toHaveBeenCalled(); + expect(storage.has('solid:auth-params')).toBe(false); + expect(storage.get('solid:session')).toBe(existing); + }); + + it.each([ + null, + '', + 'DCR', + 'automatic', + false, + { mode: 'dcr' }, + ])('fails closed on malformed trusted settings (%j), without network or authorization', async (mode) => { + storage.set('solid:client-id-mode', mode); + await expect(initiateLogin({ webId })).rejects.toThrow('Invalid clientIdMode'); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(launch).not.toHaveBeenCalled(); + }); + + it('ignores a page-supplied clientIdMode field', async () => { + storage.set('solid:client-id-mode', 'document'); + publishedResponse = () => new Response('not found', { status: 404 }); + const pageOptions = { webId, clientIdMode: 'dcr' }; + await expect(initiateLogin(pageOptions)).rejects.toThrow( + 'published Client ID Document is unavailable or invalid', + ); + expect(registrationCalls()).toHaveLength(0); + expect(launch).not.toHaveBeenCalled(); + }); + + it('fails in DCR mode when discovery has no registration endpoint', async () => { + storage.set('solid:client-id-mode', 'dcr'); + registrationEnabled = false; + await expect(initiateLogin({ webId })).rejects.toThrow( + 'issuer offers no dynamic registration endpoint', + ); + expect(registrationCalls()).toHaveLength(0); + expect(launch).not.toHaveBeenCalled(); + }); + + it('surfaces DCR failure without trying another client identity', async () => { + storage.set('solid:client-id-mode', 'dcr'); + registrationResponse = () => new Response('rejected', { status: 400 }); + await expect(initiateLogin({ webId })).rejects.toThrow( + 'Dynamic client registration failed (400)', + ); + expect(registrationCalls()).toHaveLength(1); + expect(fetchSpy.mock.calls.some(([url]) => url === PUBLISHED_CLIENT_ID_URL)).toBe(false); + expect(launch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/extension/test/authenticated-fetch.test.ts b/apps/extension/test/authenticated-fetch.test.ts new file mode 100644 index 000000000..89038a40b --- /dev/null +++ b/apps/extension/test/authenticated-fetch.test.ts @@ -0,0 +1,268 @@ +// AUTHORED-BY Claude Opus 4.8 + +import * as jose from 'jose'; +import { describe, expect, it, type Mock, vi } from 'vitest'; +import { authenticatedFetch, type FetchSession } from '../src/background/core/authenticated-fetch'; +import { generateDpopKeyPair } from '../src/background/core/dpop'; + +async function makeSession(overrides: Partial = {}): Promise { + return { + accessToken: 'access-token-123', + dpopKeyPair: await generateDpopKeyPair(), + allowedOrigins: new Set(['https://alice.pod.example']), + tokenEndpoint: 'https://idp.example/token', + ...overrides, + }; +} + +function ok(headers: Record = {}): Response { + return new Response('body', { status: 200, headers }); +} + +/** A fetch mock typed so `.mock.calls[i][1]` is the RequestInit (the credential carrier). */ +type FetchMock = Mock<(input: RequestInfo | URL, init?: RequestInit) => Promise>; +function mockFetch(impl: (url: string, init?: RequestInit) => Promise): FetchMock { + return vi.fn((input: RequestInfo | URL, init?: RequestInit) => impl(input.toString(), init)); +} + +describe('authenticatedFetch — credential boundary', () => { + it('rejects redirects on the nonce retry as well as the first authenticated request', async () => { + const session = await makeSession(); + const foreign = vi.fn(() => ok()); + const fetchImpl = mockFetch(async (_url, init) => { + if (fetchImpl.mock.calls.length === 1) + return new Response(null, { + status: 401, + headers: { 'WWW-Authenticate': 'DPoP error="use_dpop_nonce"', 'DPoP-Nonce': 'fresh' }, + }); + if (init?.redirect === 'error') throw new TypeError('Redirect rejected'); + return foreign(); + }); + await expect( + authenticatedFetch(session, { + url: 'https://alice.pod.example/redirect', + method: 'GET', + headers: {}, + fetchImpl, + }), + ).rejects.toThrow(/redirect/i); + expect(foreign).not.toHaveBeenCalled(); + expect(fetchImpl.mock.calls).toHaveLength(2); + expect(fetchImpl.mock.calls.every(([, init]) => init?.redirect === 'error')).toBe(true); + }); + + it('attaches a DPoP-bound token for an allowed origin', async () => { + const session = await makeSession(); + const fetchImpl = mockFetch(async () => ok()); + const { authenticated } = await authenticatedFetch(session, { + url: 'https://alice.pod.example/private/notes.ttl', + method: 'GET', + headers: {}, + fetchImpl, + }); + + expect(authenticated).toBe(true); + const init = fetchImpl.mock.calls[0][1] as RequestInit; + const headers = init.headers as Record; + expect(headers.Authorization).toBe('DPoP access-token-123'); + expect(typeof headers.DPoP).toBe('string'); + // the proof carries the access-token hash + correct htu + const proof = jose.decodeJwt(headers.DPoP); + expect(proof.htu).toBe('https://alice.pod.example/private/notes.ttl'); + expect(proof.htm).toBe('GET'); + expect(proof.ath).toBeTypeOf('string'); + }); + + it('the DPoP htu drops the query + fragment for a queried resource URL (RFC 9449 §4.2)', async () => { + const session = await makeSession(); + const fetchImpl = mockFetch(async () => ok()); + await authenticatedFetch(session, { + url: 'https://alice.pod.example/q.ttl?a=1&b=2#frag', + method: 'GET', + headers: {}, + fetchImpl, + }); + const headers = (fetchImpl.mock.calls[0][1] as RequestInit).headers as Record; + expect(jose.decodeJwt(headers.DPoP).htu).toBe('https://alice.pod.example/q.ttl'); + }); + + it('does NOT attach the token to a foreign origin (token-leak attack)', async () => { + const session = await makeSession(); + const fetchImpl = mockFetch(async () => ok()); + const { authenticated } = await authenticatedFetch(session, { + url: 'https://evil.example/collect', + method: 'POST', + headers: {}, + body: 'x', + fetchImpl, + }); + + expect(authenticated).toBe(false); + const init = fetchImpl.mock.calls[0][1] as RequestInit; + const headers = init.headers as Record; + expect(headers.Authorization).toBeUndefined(); + expect(headers.DPoP).toBeUndefined(); + }); + + it('does NOT attach the token to the issuer token endpoint even if its origin is allowed', async () => { + const session = await makeSession({ + allowedOrigins: new Set(['https://idp.example']), + tokenEndpoint: 'https://idp.example/token', + }); + const fetchImpl = mockFetch(async () => ok()); + const { authenticated } = await authenticatedFetch(session, { + url: 'https://idp.example/token', + method: 'POST', + headers: {}, + fetchImpl, + }); + expect(authenticated).toBe(false); + }); + + it('strips a page-supplied Authorization / DPoP header (no header injection)', async () => { + const session = await makeSession(); + const fetchImpl = mockFetch(async () => ok()); + await authenticatedFetch(session, { + url: 'https://alice.pod.example/x', + method: 'GET', + headers: { Authorization: 'Bearer attacker', DPoP: 'forged', 'X-Keep': 'yes' }, + fetchImpl, + }); + const headers = (fetchImpl.mock.calls[0][1] as RequestInit).headers as Record; + expect(headers.Authorization).toBe('DPoP access-token-123'); // ours, not the page's + expect(headers['X-Keep']).toBe('yes'); // unrelated header preserved + }); + + // The attach-path assertion above is partially masked: that path UNCONDITIONALLY sets its + // own Authorization/DPoP after the spread, so neutering `sanitizeHeaders` would not fail it. + // The strip is the ONLY guard on the NON-attach (foreign origin / token endpoint) path — + // there `safeHeaders` is passed straight to fetch with no overwrite — and it must also be + // case-insensitive (page headers can be lower-cased). These assertions prove both, so a + // neutered `sanitizeHeaders` genuinely fails (verified by temporarily neutering it). + it('strips page-supplied auth headers on the NON-attach (foreign-origin) path — the only guard there', async () => { + const session = await makeSession(); + const fetchImpl = mockFetch(async () => ok()); + const { authenticated } = await authenticatedFetch(session, { + url: 'https://evil.example/collect', + method: 'POST', + headers: { Authorization: 'Bearer attacker', DPoP: 'forged', 'X-Keep': 'yes' }, + body: 'x', + fetchImpl, + }); + + expect(authenticated).toBe(false); // foreign origin: sent as a plain fetch, no token + const headers = (fetchImpl.mock.calls[0][1] as RequestInit).headers as Record; + // No overwrite happens on this path, so if sanitizeHeaders were a no-op these would leak. + expect(headers.Authorization).toBeUndefined(); + expect(headers.DPoP).toBeUndefined(); + expect(headers['X-Keep']).toBe('yes'); // unrelated header still forwarded to the foreign origin + }); + + it('strips page-supplied auth headers on the NON-attach (token-endpoint) path', async () => { + const session = await makeSession({ + allowedOrigins: new Set(['https://idp.example']), + tokenEndpoint: 'https://idp.example/token', + }); + const fetchImpl = mockFetch(async () => ok()); + const { authenticated } = await authenticatedFetch(session, { + url: 'https://idp.example/token', + method: 'POST', + headers: { Authorization: 'Bearer attacker', DPoP: 'forged' }, + fetchImpl, + }); + + expect(authenticated).toBe(false); // token endpoint: never gets the resource token + const headers = (fetchImpl.mock.calls[0][1] as RequestInit).headers as Record; + expect(headers.Authorization).toBeUndefined(); + expect(headers.DPoP).toBeUndefined(); + }); + + it('strips LOWER-CASE page-supplied auth headers (case-insensitive) on the non-attach path', async () => { + const session = await makeSession(); + const fetchImpl = mockFetch(async () => ok()); + await authenticatedFetch(session, { + url: 'https://evil.example/collect', + method: 'POST', + headers: { authorization: 'Bearer attacker', dpop: 'forged', 'x-keep': 'yes' }, + body: 'x', + fetchImpl, + }); + const headers = (fetchImpl.mock.calls[0][1] as RequestInit).headers as Record; + // case-insensitive strip: lower-cased auth headers must not survive on the foreign path + expect(headers.authorization).toBeUndefined(); + expect(headers.dpop).toBeUndefined(); + expect(headers['x-keep']).toBe('yes'); + }); + + it('strips a LOWER-CASE page-supplied auth header on the attach path too (no duplicate header)', async () => { + const session = await makeSession(); + const fetchImpl = mockFetch(async () => ok()); + await authenticatedFetch(session, { + url: 'https://alice.pod.example/x', + method: 'GET', + headers: { authorization: 'Bearer attacker', dpop: 'forged' }, + fetchImpl, + }); + const headers = (fetchImpl.mock.calls[0][1] as RequestInit).headers as Record; + // the page's lower-cased copies are stripped, so only our canonical-cased ones remain + expect(headers.authorization).toBeUndefined(); + expect(headers.dpop).toBeUndefined(); + expect(headers.Authorization).toBe('DPoP access-token-123'); + expect(typeof headers.DPoP).toBe('string'); + }); + + it('retries ONCE on an RFC 9449 §8 nonce challenge with the server nonce', async () => { + const session = await makeSession(); + let call = 0; + const fetchImpl = mockFetch(async () => { + call += 1; + if (call === 1) { + return new Response('nonce required', { + status: 401, + headers: { + 'www-authenticate': 'DPoP error="use_dpop_nonce"', + 'dpop-nonce': 'server-nonce-xyz', + }, + }); + } + return ok({ 'dpop-nonce': 'server-nonce-2' }); + }); + + const { response, nonce } = await authenticatedFetch(session, { + url: 'https://alice.pod.example/x', + method: 'GET', + headers: {}, + fetchImpl, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(response.status).toBe(200); + expect(nonce).toBe('server-nonce-2'); + // the retry proof carried the server nonce + const retryHeaders = (fetchImpl.mock.calls[1][1] as RequestInit).headers as Record< + string, + string + >; + const proof = jose.decodeJwt(retryHeaders.DPoP); + expect(proof.nonce).toBe('server-nonce-xyz'); + }); + + it('does NOT retry on a non-nonce 401 (e.g. invalid_token)', async () => { + const session = await makeSession(); + const fetchImpl = mockFetch( + async () => + new Response('nope', { + status: 401, + headers: { 'www-authenticate': 'DPoP error="invalid_token"' }, + }), + ); + const { response } = await authenticatedFetch(session, { + url: 'https://alice.pod.example/x', + method: 'GET', + headers: {}, + fetchImpl, + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(response.status).toBe(401); + }); +}); diff --git a/apps/extension/test/class-grant.test.ts b/apps/extension/test/class-grant.test.ts new file mode 100644 index 000000000..629b37a08 --- /dev/null +++ b/apps/extension/test/class-grant.test.ts @@ -0,0 +1,287 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// EXHAUSTIVE adversarial unit tests for the per-data-class grant gate — the Phase-2 security +// heart (design §4). These prove the load-bearing enforcement as PURE functions: +// - method → mode mapping + the write ⊇ append implication (read independent) +// - target-set membership: instance exact, container prefix, cross-origin / traversal / sibling +// DENY, query/fragment normalisation, non-http DENY +// - grantCovers fail-closed on expiry / empty (unresolved) targets / mode mismatch / target miss +// - decideClassAccess: no grant → no-class-grant, present-but-uncovered → out-of-grant-scope +// - grant validation drops tampered/corrupt storage records +// - the (origin, webId) session boundary + +import { describe, expect, it } from 'vitest'; +import { + type ClassGrant, + decideClassAccess, + grantCovers, + grantsForSession, + isGrantExpired, + isValidGrant, + isWithinTarget, + liveGrantOrigins, + methodToMode, + modeSatisfies, + normaliseMode, + normaliseModes, + type PinnedTarget, + pruneExpiredGrants, + sanitiseGrants, + targetsCover, +} from '../src/background/core/class-grant'; + +const T0 = 1_000_000; + +function grant(overrides: Partial = {}): ClassGrant { + return { + grantId: 'g1', + webId: 'https://alice.pod.example/profile/card#me', + origin: 'https://app.example', + dataClass: 'https://w3id.org/jeswr/sectors/finance#Transaction', + modes: ['read'], + purpose: 'https://w3id.org/dpv#ServiceProvision', + expiresAt: T0 + 10_000, + targets: [{ url: 'https://alice.pod.example/finance/', kind: 'container' }], + createdAt: T0, + ...overrides, + }; +} + +describe('methodToMode — fail-closed method mapping', () => { + it('maps read methods to read', () => { + expect(methodToMode('GET')).toBe('read'); + expect(methodToMode('head')).toBe('read'); + }); + it('maps POST to append, mutating verbs to write', () => { + expect(methodToMode('POST')).toBe('append'); + expect(methodToMode('PUT')).toBe('write'); + expect(methodToMode('PATCH')).toBe('write'); + expect(methodToMode('DELETE')).toBe('write'); + }); + it('maps an UNKNOWN method to write (fail-closed — hardest to satisfy)', () => { + expect(methodToMode('FROB')).toBe('write'); + expect(methodToMode('')).toBe('write'); + }); +}); + +describe('normaliseMode / normaliseModes', () => { + it('accepts the three modes case-insensitively, rejects the rest', () => { + expect(normaliseMode('Read')).toBe('read'); + expect(normaliseMode('WRITE')).toBe('write'); + expect(normaliseMode('append')).toBe('append'); + expect(normaliseMode('control')).toBeNull(); + expect(normaliseMode(42)).toBeNull(); + }); + it('dedupes + drops unrecognised entries', () => { + expect(normaliseModes(['read', 'read', 'nope', 'write'])).toEqual(['read', 'write']); + expect(normaliseModes('read')).toEqual([]); + expect(normaliseModes(undefined)).toEqual([]); + }); +}); + +describe('modeSatisfies — write ⊇ append, read independent', () => { + it('write covers append but not read', () => { + expect(modeSatisfies(['write'], 'append')).toBe(true); + expect(modeSatisfies(['write'], 'write')).toBe(true); + expect(modeSatisfies(['write'], 'read')).toBe(false); + }); + it('read covers only read', () => { + expect(modeSatisfies(['read'], 'read')).toBe(true); + expect(modeSatisfies(['read'], 'append')).toBe(false); + expect(modeSatisfies(['read'], 'write')).toBe(false); + }); + it('append covers only append', () => { + expect(modeSatisfies(['append'], 'append')).toBe(true); + expect(modeSatisfies(['append'], 'write')).toBe(false); + }); + it('empty grant satisfies nothing (fail-closed)', () => { + expect(modeSatisfies([], 'read')).toBe(false); + }); +}); + +describe('isWithinTarget — pinned membership, fail-closed', () => { + const container: PinnedTarget = { url: 'https://alice.pod.example/finance/', kind: 'container' }; + const instance: PinnedTarget = { + url: 'https://alice.pod.example/finance/statement.ttl', + kind: 'instance', + }; + + it('container covers itself and descendants', () => { + expect(isWithinTarget(container, 'https://alice.pod.example/finance/')).toBe(true); + expect(isWithinTarget(container, 'https://alice.pod.example/finance/2026/jan.ttl')).toBe(true); + }); + it('container ignores query + fragment on the request', () => { + expect(isWithinTarget(container, 'https://alice.pod.example/finance/x.ttl?v=2#a')).toBe(true); + }); + it('container does NOT cover a sibling with a shared prefix', () => { + expect(isWithinTarget(container, 'https://alice.pod.example/finance-secret/x')).toBe(false); + expect(isWithinTarget(container, 'https://alice.pod.example/financial')).toBe(false); + }); + it('container DENIES a path-traversal that escapes it (URL parser normalises `..`)', () => { + expect(isWithinTarget(container, 'https://alice.pod.example/finance/../secret/x')).toBe(false); + }); + it('DENIES a cross-origin request even at the same path', () => { + expect(isWithinTarget(container, 'https://evil.example/finance/x')).toBe(false); + }); + it('instance is an EXACT path match', () => { + expect(isWithinTarget(instance, 'https://alice.pod.example/finance/statement.ttl')).toBe(true); + expect(isWithinTarget(instance, 'https://alice.pod.example/finance/statement.ttl?a=1')).toBe( + true, + ); + expect(isWithinTarget(instance, 'https://alice.pod.example/finance/statement.ttl2')).toBe( + false, + ); + expect(isWithinTarget(instance, 'https://alice.pod.example/finance/')).toBe(false); + }); + it('DENIES an unparseable / non-http request or target', () => { + expect(isWithinTarget(container, 'not a url')).toBe(false); + expect(isWithinTarget(container, 'ftp://alice.pod.example/finance/x')).toBe(false); + expect(isWithinTarget({ url: 'not a url', kind: 'container' }, 'https://a.example/x')).toBe( + false, + ); + }); +}); + +describe('targetsCover', () => { + it('is fail-closed on an empty target set', () => { + expect(targetsCover([], 'https://alice.pod.example/finance/x')).toBe(false); + }); + it('matches if ANY target covers', () => { + const targets: PinnedTarget[] = [ + { url: 'https://alice.pod.example/health/', kind: 'container' }, + { url: 'https://alice.pod.example/finance/', kind: 'container' }, + ]; + expect(targetsCover(targets, 'https://alice.pod.example/finance/x')).toBe(true); + expect(targetsCover(targets, 'https://alice.pod.example/other/x')).toBe(false); + }); +}); + +describe('isGrantExpired', () => { + it('is expired at/after expiresAt', () => { + expect(isGrantExpired(grant({ expiresAt: T0 }), T0)).toBe(true); + expect(isGrantExpired(grant({ expiresAt: T0 + 1 }), T0)).toBe(false); + }); + it('treats a NaN/invalid expiry as expired (fail-closed)', () => { + expect(isGrantExpired(grant({ expiresAt: Number.NaN }), T0)).toBe(true); + }); +}); + +describe('isValidGrant / sanitiseGrants — reject tampered storage', () => { + it('accepts a well-formed grant', () => { + expect(isValidGrant(grant())).toBe(true); + }); + it('rejects missing / malformed fields', () => { + expect(isValidGrant(null)).toBe(false); + expect(isValidGrant({ ...grant(), grantId: '' })).toBe(false); + expect(isValidGrant({ ...grant(), webId: '' })).toBe(false); + expect(isValidGrant({ ...grant(), origin: 'not-a-url' })).toBe(false); + expect(isValidGrant({ ...grant(), expiresAt: 'soon' })).toBe(false); + expect(isValidGrant({ ...grant(), modes: ['read', 'control'] })).toBe(false); + expect(isValidGrant({ ...grant(), targets: [{ url: 'x', kind: 'bogus' }] })).toBe(false); + expect(isValidGrant({ ...grant(), targets: 'nope' })).toBe(false); + }); + it('sanitiseGrants drops invalid records, keeps valid ones', () => { + const kept = sanitiseGrants([grant({ grantId: 'ok' }), { junk: true }, null, 42]); + expect(kept.map((g) => g.grantId)).toEqual(['ok']); + expect(sanitiseGrants('not-an-array')).toEqual([]); + }); +}); + +describe('grantCovers — the single-grant decision, fail-closed on every axis', () => { + it('ALLOWS a read within the pinned container while live', () => { + expect(grantCovers(grant(), 'https://alice.pod.example/finance/x.ttl', 'GET', T0)).toBe(true); + }); + it('DENIES when expired', () => { + expect( + grantCovers(grant({ expiresAt: T0 }), 'https://alice.pod.example/finance/x.ttl', 'GET', T0), + ).toBe(false); + }); + it('DENIES when the target set is empty (unresolved / unknown class)', () => { + expect( + grantCovers(grant({ targets: [] }), 'https://alice.pod.example/finance/x', 'GET', T0), + ).toBe(false); + }); + it('DENIES when the mode is not satisfied (read grant, write request)', () => { + expect( + grantCovers(grant({ modes: ['read'] }), 'https://alice.pod.example/finance/x', 'PUT', T0), + ).toBe(false); + }); + it('DENIES a URL outside the pinned targets', () => { + expect(grantCovers(grant(), 'https://alice.pod.example/health/x', 'GET', T0)).toBe(false); + }); + it('a write grant ALLOWS append (POST) and write (PUT) within scope', () => { + const g = grant({ modes: ['write'] }); + expect(grantCovers(g, 'https://alice.pod.example/finance/x', 'POST', T0)).toBe(true); + expect(grantCovers(g, 'https://alice.pod.example/finance/x', 'PUT', T0)).toBe(true); + // ...but not read (write does not imply read). + expect(grantCovers(g, 'https://alice.pod.example/finance/x', 'GET', T0)).toBe(false); + }); +}); + +describe('decideClassAccess — the origin-scoped gate decision', () => { + it('no live grant → no-class-grant', () => { + expect(decideClassAccess([], 'https://alice.pod.example/finance/x', 'GET', T0)).toBe( + 'no-class-grant', + ); + }); + it('all grants expired → no-class-grant (they are filtered out first)', () => { + expect( + decideClassAccess( + [grant({ expiresAt: T0 - 1 })], + 'https://alice.pod.example/finance/x', + 'GET', + T0, + ), + ).toBe('no-class-grant'); + }); + it('grants present but none cover → out-of-grant-scope', () => { + expect(decideClassAccess([grant()], 'https://alice.pod.example/health/x', 'GET', T0)).toBe( + 'out-of-grant-scope', + ); + // right resource, wrong mode: + expect(decideClassAccess([grant()], 'https://alice.pod.example/finance/x', 'PUT', T0)).toBe( + 'out-of-grant-scope', + ); + }); + it('a covering grant → ALLOW (null)', () => { + expect( + decideClassAccess([grant()], 'https://alice.pod.example/finance/x', 'GET', T0), + ).toBeNull(); + }); + it('the UNION of several grants is honoured', () => { + const g2 = grant({ + grantId: 'g2', + targets: [{ url: 'https://alice.pod.example/health/', kind: 'container' }], + }); + expect( + decideClassAccess([grant(), g2], 'https://alice.pod.example/health/x', 'GET', T0), + ).toBeNull(); + }); +}); + +describe('pruneExpiredGrants / liveGrantOrigins / grantsForSession — session boundary', () => { + it('pruneExpiredGrants keeps only live grants', () => { + const kept = pruneExpiredGrants( + [grant({ grantId: 'live' }), grant({ grantId: 'dead', expiresAt: T0 - 1 })], + T0, + ); + expect(kept.map((g) => g.grantId)).toEqual(['live']); + }); + it('liveGrantOrigins filters by WebID + expiry', () => { + const grants = [ + grant({ origin: 'https://a.example', webId: 'https://alice#me' }), + grant({ origin: 'https://b.example', webId: 'https://bob#me' }), + grant({ origin: 'https://c.example', webId: 'https://alice#me', expiresAt: T0 - 1 }), + ]; + expect([...liveGrantOrigins(grants, T0, 'https://alice#me')]).toEqual(['https://a.example']); + }); + it('grantsForSession filters to the exact origin AND WebID', () => { + const grants = [ + grant({ grantId: 'mine', origin: 'https://app.example', webId: 'https://alice#me' }), + grant({ grantId: 'other-origin', origin: 'https://evil.example', webId: 'https://alice#me' }), + grant({ grantId: 'other-webid', origin: 'https://app.example', webId: 'https://bob#me' }), + ]; + const mine = grantsForSession(grants, 'https://app.example', 'https://alice#me'); + expect(mine.map((g) => g.grantId)).toEqual(['mine']); + }); +}); diff --git a/apps/extension/test/class-request.test.ts b/apps/extension/test/class-request.test.ts new file mode 100644 index 000000000..3f3e7820e --- /dev/null +++ b/apps/extension/test/class-request.test.ts @@ -0,0 +1,105 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// Unit tests for the untrusted `requestAccess` payload parser (design §4.2). Every axis is +// fail-closed: non-IRI classes dropped (empty → reject), no modes → reject, a bounded ISO-8601 +// duration REQUIRED (unparseable/absent/zero/negative → reject), duration clamped to the ceiling. + +import { describe, expect, it } from 'vitest'; +import { + MAX_GRANT_MS, + normaliseAccessRequest, + parseIso8601DurationMs, +} from '../src/background/core/class-request'; + +const NOW = 1_000_000_000; + +describe('parseIso8601DurationMs', () => { + it('parses days / weeks / hours / combined', () => { + expect(parseIso8601DurationMs('P1D')).toBe(24 * 60 * 60 * 1000); + expect(parseIso8601DurationMs('P30D')).toBe(30 * 24 * 60 * 60 * 1000); + expect(parseIso8601DurationMs('P1W')).toBe(7 * 24 * 60 * 60 * 1000); + expect(parseIso8601DurationMs('PT12H')).toBe(12 * 60 * 60 * 1000); + expect(parseIso8601DurationMs('P1DT1H')).toBe(25 * 60 * 60 * 1000); + expect(parseIso8601DurationMs('PT30M')).toBe(30 * 60 * 1000); + }); + it('rejects bare/empty/zero/negative/garbage (fail-closed)', () => { + expect(parseIso8601DurationMs('P')).toBeNull(); + expect(parseIso8601DurationMs('PT')).toBeNull(); + expect(parseIso8601DurationMs('P0D')).toBeNull(); + expect(parseIso8601DurationMs('30d')).toBeNull(); + expect(parseIso8601DurationMs('-P1D')).toBeNull(); + expect(parseIso8601DurationMs('')).toBeNull(); + expect(parseIso8601DurationMs(undefined)).toBeNull(); + expect(parseIso8601DurationMs(42)).toBeNull(); + }); +}); + +describe('normaliseAccessRequest', () => { + it('normalises a well-formed request', () => { + const r = normaliseAccessRequest( + { + classes: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + modes: ['read', 'read'], + purpose: ' https://w3id.org/dpv#ServiceProvision ', + duration: 'P30D', + }, + NOW, + ); + if ('error' in r) throw new Error(r.error); + expect(r.classes).toEqual(['https://w3id.org/jeswr/sectors/finance#Transaction']); + expect(r.modes).toEqual(['read']); + expect(r.purpose).toBe('https://w3id.org/dpv#ServiceProvision'); + expect(r.durationMs).toBe(30 * 24 * 60 * 60 * 1000); + expect(r.expiresAt).toBe(NOW + 30 * 24 * 60 * 60 * 1000); + }); + it('drops non-IRI classes; rejects when none remain', () => { + const ok = normaliseAccessRequest( + { classes: ['not-iri', 'https://x.example/C'], modes: ['read'], duration: 'P1D' }, + NOW, + ); + if ('error' in ok) throw new Error(ok.error); + expect(ok.classes).toEqual(['https://x.example/C']); + + const bad = normaliseAccessRequest( + { classes: ['not-iri'], modes: ['read'], duration: 'P1D' }, + NOW, + ); + expect('error' in bad).toBe(true); + }); + it('rejects when no recognised mode is present', () => { + const bad = normaliseAccessRequest( + { classes: ['https://x.example/C'], modes: ['control'], duration: 'P1D' }, + NOW, + ); + expect('error' in bad).toBe(true); + }); + it('rejects when the duration is missing / unparseable (expiry is mandatory)', () => { + expect( + 'error' in normaliseAccessRequest({ classes: ['https://x.example/C'], modes: ['read'] }, NOW), + ).toBe(true); + expect( + 'error' in + normaliseAccessRequest( + { classes: ['https://x.example/C'], modes: ['read'], duration: 'forever' }, + NOW, + ), + ).toBe(true); + }); + it('clamps an over-long duration to the ceiling', () => { + const r = normaliseAccessRequest( + { classes: ['https://x.example/C'], modes: ['read'], duration: 'P4000D' }, + NOW, + ); + if ('error' in r) throw new Error(r.error); + expect(r.durationMs).toBe(MAX_GRANT_MS); + expect(r.expiresAt).toBe(NOW + MAX_GRANT_MS); + }); + it('nulls a blank/absent purpose', () => { + const r = normaliseAccessRequest( + { classes: ['https://x.example/C'], modes: ['read'], purpose: ' ', duration: 'P1D' }, + NOW, + ); + if ('error' in r) throw new Error(r.error); + expect(r.purpose).toBeNull(); + }); +}); diff --git a/apps/extension/test/class-resolve.test.ts b/apps/extension/test/class-resolve.test.ts new file mode 100644 index 000000000..73c18ec57 --- /dev/null +++ b/apps/extension/test/class-resolve.test.ts @@ -0,0 +1,179 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// Unit tests for the data-class → target resolver (design §2.3, Type-Index rung). Pure parse +// helpers run against Turtle fixtures; the network walk runs against an injected fetch. Proves +// the fail-closed dropping of foreign-origin / non-NamedNode / non-http targets and that an +// unregistered class resolves to NOTHING (which the gate then denies). + +import { describe, expect, it } from 'vitest'; +import { + parseTargetsForClass, + parseTypeIndexRefs, + resolveClassTargets, +} from '../src/background/core/class-resolve'; + +const WEBID = 'https://alice.pod.example/profile/card#me'; +const FINANCE = 'https://w3id.org/jeswr/sectors/finance#Transaction'; + +const PROFILE = ` +@prefix solid: . + + solid:publicTypeIndex ; + solid:privateTypeIndex . +`; + +const PUBLIC_INDEX = ` +@prefix solid: . +@prefix fin: . +<#fin> a solid:TypeRegistration ; + solid:forClass fin:Transaction ; + solid:instanceContainer ; + solid:instance . +<#other> a solid:TypeRegistration ; + solid:forClass ; + solid:instanceContainer . +`; + +describe('parseTypeIndexRefs', () => { + it('returns the public + private type-index URLs on the WebID subject', () => { + expect(parseTypeIndexRefs(WEBID, PROFILE)).toEqual([ + { url: 'https://alice.pod.example/settings/publicTypeIndex.ttl', authenticated: false }, + { url: 'https://alice.pod.example/settings/privateTypeIndex.ttl', authenticated: true }, + ]); + }); + it('is empty for a profile with no type index', () => { + expect(parseTypeIndexRefs(WEBID, ' .')).toEqual([]); + }); + it('keeps a public index credential-free when it is also advertised as private', () => { + expect( + parseTypeIndexRefs( + WEBID, + `<${WEBID}> ; + .`, + ), + ).toEqual([{ url: 'https://pod.example/index', authenticated: false }]); + }); +}); + +describe('parseTargetsForClass', () => { + it('collects instance + instanceContainer for the matching class only', () => { + const targets = parseTargetsForClass( + 'https://alice.pod.example/settings/publicTypeIndex.ttl', + PUBLIC_INDEX, + FINANCE, + ); + expect(targets).toEqual([ + { url: 'https://alice.pod.example/finance/summary.ttl', kind: 'instance' }, + { url: 'https://alice.pod.example/finance/', kind: 'container' }, + ]); + }); + it('returns NOTHING for an unregistered class', () => { + expect( + parseTargetsForClass( + 'https://alice.pod.example/settings/publicTypeIndex.ttl', + PUBLIC_INDEX, + 'https://x/Nope', + ), + ).toEqual([]); + }); + it('drops a target on a foreign origin when confined to allowedOrigins', () => { + const evilIndex = ` + @prefix solid: . + <#fin> a solid:TypeRegistration ; + solid:forClass <${FINANCE}> ; + solid:instanceContainer .`; + const confined = parseTargetsForClass( + 'https://alice.pod.example/settings/publicTypeIndex.ttl', + evilIndex, + FINANCE, + new Set(['https://alice.pod.example']), + ); + expect(confined).toEqual([]); + }); +}); + +describe('resolveClassTargets — the walk', () => { + function fetcher(docs: Record) { + return async (url: string) => { + const text = docs[url]; + return text === undefined ? { ok: false, text: '' } : { ok: true, text }; + }; + } + + it('walks profile → indexes → unions targets', async () => { + const fetched: Array<{ url: string; authenticated: boolean }> = []; + const documents = fetcher({ + [WEBID]: PROFILE, + 'https://alice.pod.example/settings/publicTypeIndex.ttl': PUBLIC_INDEX, + // Private index unreachable (returns non-ok) — degrades gracefully. + }); + const targets = await resolveClassTargets(FINANCE, { + webId: WEBID, + fetch: async (url, authenticated) => { + fetched.push({ url, authenticated }); + return documents(url); + }, + authenticatedOrigins: new Set(['https://alice.pod.example']), + allowedOrigins: new Set(['https://alice.pod.example']), + }); + expect(targets).toEqual([ + { url: 'https://alice.pod.example/finance/summary.ttl', kind: 'instance' }, + { url: 'https://alice.pod.example/finance/', kind: 'container' }, + ]); + expect(fetched).toEqual([ + { url: WEBID, authenticated: false }, + { url: 'https://alice.pod.example/settings/publicTypeIndex.ttl', authenticated: false }, + { url: 'https://alice.pod.example/settings/privateTypeIndex.ttl', authenticated: true }, + ]); + }); + + it('fetches foreign public indexes without credentials while confining targets', async () => { + const profile = ` + @prefix solid: . + <${WEBID}> solid:publicTypeIndex ; + solid:privateTypeIndex .`; + const fetched: Array<{ url: string; authenticated: boolean }> = []; + const targets = await resolveClassTargets(FINANCE, { + webId: WEBID, + fetch: async (url, authenticated) => { + fetched.push({ url, authenticated }); + return { ok: true, text: url === WEBID ? profile : PUBLIC_INDEX }; + }, + authenticatedOrigins: new Set(['https://alice.pod.example']), + allowedOrigins: new Set(['https://alice.pod.example']), + }); + expect(targets).toEqual([ + { url: 'https://alice.pod.example/finance/summary.ttl', kind: 'instance' }, + { url: 'https://alice.pod.example/finance/', kind: 'container' }, + ]); + expect(fetched).toEqual([ + { url: WEBID, authenticated: false }, + { url: 'http://public.example/index.ttl', authenticated: false }, + ]); + }); + + it('returns [] when the profile is unreachable (fail-closed, never throws)', async () => { + const targets = await resolveClassTargets(FINANCE, { + webId: WEBID, + fetch: fetcher({}), + }); + expect(targets).toEqual([]); + }); + + it('dedupes targets across the public + private indexes', async () => { + const targets = await resolveClassTargets(FINANCE, { + webId: WEBID, + fetch: fetcher({ + [WEBID]: PROFILE, + 'https://alice.pod.example/settings/publicTypeIndex.ttl': PUBLIC_INDEX, + 'https://alice.pod.example/settings/privateTypeIndex.ttl': PUBLIC_INDEX, + }), + authenticatedOrigins: new Set(['https://alice.pod.example']), + }); + // Same registrations in both indexes → one entry each. + expect(targets).toEqual([ + { url: 'https://alice.pod.example/finance/summary.ttl', kind: 'instance' }, + { url: 'https://alice.pod.example/finance/', kind: 'container' }, + ]); + }); +}); diff --git a/apps/extension/test/client-id.test.ts b/apps/extension/test/client-id.test.ts new file mode 100644 index 000000000..03189fa3b --- /dev/null +++ b/apps/extension/test/client-id.test.ts @@ -0,0 +1,157 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { spawnSync } from 'node:child_process'; +import { createHash, createPublicKey } from 'node:crypto'; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { isPublishedClientIdReachable } from '../src/background/client-id'; +import { + CLIENT_ID_SCOPE, + PUBLISHED_CLIENT_ID_URL, + renderClientIdDocument, + SOLID_OIDC_CONTEXT, +} from '../src/background/client-id-template'; + +const expectedId = 'bicabcdahgfbkgmlidjpmiadmjlppikk'; +const documentPath = new URL('../public/clientid.jsonld', import.meta.url); +const shippedJson = JSON.parse(readFileSync(documentPath, 'utf8')); + +// Compute independently of the build script, from the actual committed DER public key. +function extensionIdFromKey(key: string): string { + const der = Buffer.from(key, 'base64'); + expect(der.toString('base64')).toBe(key); + expect(createPublicKey({ key: der, format: 'der', type: 'spki' }).type).toBe('public'); + return [...createHash('sha256').update(der).digest().subarray(0, 16)] + .flatMap((byte) => [byte >> 4, byte & 15]) + .map((nibble) => String.fromCharCode(97 + nibble)) + .join(''); +} + +describe('generated Client Identifier Document', () => { + it('renders the required public-client fields from one template', () => { + expect(renderClientIdDocument(expectedId)).toEqual({ + '@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld', + client_id: 'https://jeswr.github.io/solid-browser-extension/clientid.jsonld', + client_name: 'Solid Browser Extension', + client_uri: 'https://github.com/jeswr/solid-browser-extension', + logo_uri: 'https://jeswr.github.io/solid-browser-extension/icon128.png', + redirect_uris: [`https://${expectedId}.chromiumapp.org/`], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: 'openid webid offline_access', + token_endpoint_auth_method: 'none', + application_type: 'web', + }); + expect(shippedJson).toEqual(renderClientIdDocument(expectedId)); + expect(shippedJson.client_id).toBe(PUBLISHED_CLIENT_ID_URL); + expect(shippedJson['@context']).toBe(SOLID_OIDC_CONTEXT); + expect(shippedJson.scope).toBe(CLIENT_ID_SCOPE); + expect(JSON.stringify(shippedJson)).not.toContain('REPLACE-ME'); + }); + + it('computes the pinned ID from the manifest public key and matches the redirect URI', () => { + const manifest = JSON.parse( + readFileSync(new URL('../src/manifest.json', import.meta.url), 'utf8'), + ); + expect(manifest.key, 'The dispatcher public key must be pinned in the manifest').toBeTypeOf( + 'string', + ); + const id = extensionIdFromKey(manifest.key); + expect(id).toBe(expectedId); + expect(shippedJson.redirect_uris).toEqual([`https://${id}.chromiumapp.org/`]); + }); + + it.each([ + { mode: 'render', args: [] }, + { mode: 'check', args: ['--check'] }, + ])('$mode uses the committed key without EXT_PUBKEY or dependencies', ({ args }) => { + const directory = mkdtempSync(join(tmpdir(), 'solid-client-id-')); + const paths = [ + 'package.json', + 'scripts/render-client-id.ts', + 'src/background/client-id-template.ts', + 'src/manifest.json', + 'public/clientid.jsonld', + ]; + const env = { ...process.env }; + delete env.EXT_PUBKEY; + try { + for (const path of paths) { + const destination = join(directory, path); + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(new URL(`../${path}`, import.meta.url), destination); + } + const result = spawnSync( + process.execPath, + [join(directory, 'scripts/render-client-id.ts'), ...args], + { cwd: directory, encoding: 'utf8', env }, + ); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + for (const path of ['src/manifest.json', 'public/clientid.jsonld']) { + expect(readFileSync(join(directory, path), 'utf8')).toBe( + readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'), + ); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + it('substitutes a different valid ID rather than hardcoding the redirect', () => { + const id = 'abcdefghijklmnopabcdefghijklmnop'; + expect(renderClientIdDocument(id).redirect_uris).toEqual([`https://${id}.chromiumapp.org/`]); + }); + + it.each([ + '', + 'a'.repeat(31), + 'a'.repeat(33), + 'z'.repeat(32), + '../evil', + expectedId.toUpperCase(), + ])('rejects an invalid extension ID (%s)', (id) => + expect(() => renderClientIdDocument(id)).toThrow('Invalid Chromium extension ID')); +}); + +describe('Client ID Document reachability (fixture fetches only)', () => { + it.each([ + '', + 'invalid', + 'http://example.com/clientid.jsonld', + 'file:///clientid.jsonld', + ])('rejects invalid/non-HTTPS URLs before fetching: %s', async (url) => { + const fetchSpy = vi.fn(); + expect(await isPublishedClientIdReachable(url, fetchSpy)).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it.each([ + () => new Response('not found', { status: 404 }), + () => new Response('not JSON'), + () => Response.json(null), + () => Response.json({}), + () => Response.json({ client_id: 42 }), + () => Response.json({ client_id: 'https://other.example/clientid.jsonld' }), + () => Response.json({ client_id: `${PUBLISHED_CLIENT_ID_URL}/` }), + ])('fails closed for an unavailable or invalid document', async (response) => { + const fetchSpy = vi.fn().mockResolvedValue(response()); + expect(await isPublishedClientIdReachable(PUBLISHED_CLIENT_ID_URL, fetchSpy)).toBe(false); + }); + + it('fails closed on network errors', async () => { + const fetchSpy = vi.fn().mockRejectedValue(new Error('network down')); + expect(await isPublishedClientIdReachable(PUBLISHED_CLIENT_ID_URL, fetchSpy)).toBe(false); + }); + + it('accepts an exact self-reference, omitting cookies and refusing redirects', async () => { + const fetchSpy = vi.fn().mockResolvedValue(Response.json(shippedJson)); + expect(await isPublishedClientIdReachable(PUBLISHED_CLIENT_ID_URL, fetchSpy)).toBe(true); + expect(fetchSpy).toHaveBeenCalledWith(PUBLISHED_CLIENT_ID_URL, { + headers: { Accept: 'application/ld+json, application/json' }, + credentials: 'omit', + redirect: 'error', + }); + }); +}); diff --git a/apps/extension/test/consent-ui.test.ts b/apps/extension/test/consent-ui.test.ts new file mode 100644 index 000000000..b70d65aeb --- /dev/null +++ b/apps/extension/test/consent-ui.test.ts @@ -0,0 +1,314 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { readFileSync } from 'node:fs'; +import { JSDOM } from 'jsdom'; +import { afterEach, expect, it, vi } from 'vitest'; +import type { PendingConsentView } from '../src/shared/messages'; +import { expectAccessible } from './permission-a11y'; + +let dom: JSDOM; +let send: ReturnType; +const view = (id = '1'): PendingConsentView => ({ + consentId: 'fixture', + origin: 'https://app.example', + webId: `https://pod.example/${id}#me`, + profileId: id, + profiles: [ + { id: '1', label: 'Personal', webId: 'https://pod.example/1#me' }, + { id: '2', label: 'Work', webId: 'https://pod.example/2#me' }, + ], + classes: [ + { + dataClass: 'https://schema.org/Person', + modes: ['read'], + purpose: 'build your address book', + expiresAt: 3_000_000_000_000, + shape: null, + targets: [{ url: `https://pod.example/${id}/private`, kind: 'instance' }], + }, + ], +}); +async function mount( + initial: PendingConsentView | null = view(), + theme = 'light', + url = 'https://extension.example/consent?consentId=fixture', +) { + dom = new JSDOM(readFileSync(new URL('../src/consent/consent.html', import.meta.url), 'utf8'), { + url, + }); + dom.window.localStorage.setItem('app-shell-theme', theme); + send = vi.fn(async (message: Record, callback?: () => void) => { + if (message.type === 'SOLID_GET_PENDING_CONSENT') + return message.profileId ? view(String(message.profileId)) : initial; + callback?.(); + return undefined; + }); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('chrome', { runtime: { sendMessage: send } }); + vi.spyOn(dom.window, 'close').mockImplementation(() => {}); + vi.resetModules(); + await import('../src/consent/consent'); + await vi.waitFor(() => { + const doc = dom.window.document; + expect( + doc.querySelector('#classes')?.getAttribute('aria-busy') === 'false' || + !doc.querySelector('#error')!.hidden, + ).toBe(true); + }); + const document = dom.window.document; + return { + document, + approve: document.querySelector('#approve')!, + all: document.querySelector('#select-all')!, + picker: document.querySelector('#profile')!, + }; +} +afterEach(() => { + dom.window.close(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +it('shows the purpose headline, readable data cards, exact scope and the worker-fixed duration', async () => { + const { document, approve } = await mount(); + expect(document.querySelector('h1')?.textContent).toBe( + 'To build your address book, https://app.example requests access to:', + ); + expect(document.querySelector('#webid')?.textContent).toBe('Personal'); + expect(document.body.textContent).not.toMatch( + /WebID|https:\/\/schema.org|https:\/\/pod.example\/1#me/, + ); + const card = document.querySelector('article.class-item')!; + const box = card.querySelector('input')!; + expect(card.querySelector('.class-title')?.textContent).toBe('Contacts'); + expect(document.getElementById(box.getAttribute('aria-describedby')!)?.textContent).toContain( + 'contact information', + ); + expect(card.querySelector('.chips')?.textContent).toBe('Read'); + expect(card.querySelector('.meta')?.textContent).toContain('1 item'); + expect(card.querySelector('.targets')?.textContent).toBe('https://pod.example/1/private'); + const duration = document.querySelector('[role="radiogroup"] input')!; + expect(duration.type).toBe('radio'); + expect(duration.checked).toBe(true); + expect(duration.disabled).toBe(true); + expect(document.querySelector('#duration-label')?.textContent).toContain('Until'); + expect(document.querySelector('#duration-hint')?.textContent).toContain('site sets'); + expect(approve.textContent).toBe('Share'); + expect(document.querySelector('#deny')?.textContent).toBe('Cancel'); + expect(approve.className).toBe(document.querySelector('#deny')?.className); + expect(approve.disabled).toBe(false); + await expectAccessible(document); +}); + +it.each([ + 'light', + 'dark', +])('uses the saved %s theme and only shows a switcher for multiple active choices', async (theme) => { + const initial = { ...view(), profiles: [view().profiles[0]] }; + const { document, picker } = await mount(initial, theme); + expect(document.documentElement.classList.contains('dark')).toBe(theme === 'dark'); + expect(document.querySelector('#account-switcher')?.hidden).toBe(true); + expect(picker.disabled).toBe(true); + expect(document.querySelector('#webid')?.textContent).toBe('Personal'); + await expectAccessible(document); +}); + +it('falls back without a purpose and keeps untrusted labels, purposes and targets as text', async () => { + const initial = view(); + const { document } = await mount({ + ...initial, + classes: [{ ...initial.classes[0], purpose: null }], + }); + expect(document.querySelector('h1')?.textContent).toBe('https://app.example requests access to:'); + send.mockResolvedValueOnce({ + ...view('2'), + profiles: [{ ...initial.profiles[1], label: '' }], + classes: [ + { + ...initial.classes[0], + dataClass: 'https://example.test/#%3Csvg%20onload=alert(1)%3E', + purpose: '', + targets: [{ url: '', kind: 'container' }], + }, + ], + }); + const picker = document.querySelector('select')!; + picker.value = '2'; + picker.dispatchEvent(new dom.window.Event('change')); + await vi.waitFor(() => expect(document.querySelector('.targets')?.textContent).toContain('page input'); + expect(document.querySelector('.class-title')?.textContent).toContain(' { + const initial = view(); + const { document, approve, all } = await mount({ + ...initial, + classes: [...initial.classes, { ...initial.classes[0], dataClass: 'https://schema.org/Event' }], + }); + const boxes = document.querySelectorAll('.class-head input'); + expect(all.checked).toBe(true); + boxes[0].click(); + expect(all.checked).toBe(false); + expect(all.indeterminate).toBe(true); + expect(approve.disabled).toBe(false); + all.click(); + expect([...boxes].every((box) => box.checked)).toBe(true); + expect(all.indeterminate).toBe(false); + all.click(); + expect(approve.disabled).toBe(true); + approve.click(); + expect(send).toHaveBeenCalledOnce(); + expect(document.querySelector('#selection-hint')?.textContent).toContain('Choose at least one'); + boxes[1].click(); + approve.click(); + expect(send).toHaveBeenLastCalledWith( + { + type: 'SOLID_CONSENT_DECISION', + consentId: 'fixture', + profileId: '1', + approvedClasses: ['https://schema.org/Event'], + }, + expect.any(Function), + ); + dom.window.dispatchEvent(new dom.window.Event('pagehide')); + expect(send.mock.calls.filter(([m]) => m.type === 'SOLID_CONSENT_DECISION')).toHaveLength(1); +}); + +it('reloads scope and resets the selection before sharing from another account', async () => { + const { document, picker, approve, all } = await mount(); + expect(document.querySelector('#account-switcher')?.hidden).toBe(false); + all.click(); + picker.value = '2'; + picker.dispatchEvent(new dom.window.Event('change')); + expect(approve.disabled).toBe(true); + expect(all.disabled).toBe(true); + await vi.waitFor(() => + expect(document.querySelector('.targets')?.textContent).toContain('/2/private'), + ); + expect(document.querySelector('#webid')?.textContent).toBe('Work'); + expect(all.checked).toBe(true); + approve.click(); + expect(send).toHaveBeenLastCalledWith( + { + type: 'SOLID_CONSENT_DECISION', + consentId: 'fixture', + profileId: '2', + approvedClasses: ['https://schema.org/Person'], + }, + expect.any(Function), + ); + expect(dom.window.close).toHaveBeenCalledOnce(); +}); + +it.each([ + 'resolve', + 'reject', +])('ignores stale account loads that %s after a newer selection', async (outcome) => { + const { document, picker, approve } = await mount(); + let resolve!: (value: PendingConsentView) => void; + let reject!: (reason: Error) => void; + send.mockImplementationOnce( + () => + new Promise((yes, no) => { + resolve = yes; + reject = no; + }), + ); + picker.value = '2'; + picker.dispatchEvent(new dom.window.Event('change')); + (document.querySelector('.class-head input') as HTMLInputElement).click(); + expect(approve.disabled).toBe(true); + picker.value = '1'; + picker.dispatchEvent(new dom.window.Event('change')); + await vi.waitFor(() => expect(approve.disabled).toBe(false)); + if (outcome === 'resolve') resolve(view('2')); + else reject(new Error('Stale load failed')); + await new Promise((done) => setTimeout(done, 0)); + expect(document.querySelector('#webid')?.textContent).toBe('Personal'); + expect(document.querySelector('#error')?.hidden).toBe(true); + expect(approve.disabled).toBe(false); +}); + +it('keeps sharing disabled after a failed account load, including changes to old controls', async () => { + const { document, picker, approve, all } = await mount(); + send.mockRejectedValueOnce(new Error('Load failed')); + picker.value = '2'; + picker.dispatchEvent(new dom.window.Event('change')); + await vi.waitFor(() => + expect(document.querySelector('#error')?.textContent).toContain('Could not load'), + ); + (document.querySelector('.class-head input') as HTMLInputElement).click(); + expect(approve.disabled).toBe(true); + expect(all.disabled).toBe(true); + picker.value = '1'; + picker.dispatchEvent(new dom.window.Event('change')); + await vi.waitFor(() => expect(approve.disabled).toBe(false)); + expect(document.querySelector('#error')?.textContent).toBe(''); + expect(document.querySelector('#error')?.hidden).toBe(true); +}); + +it.each([ + false, + true, +])('shows the empty scope without preselecting it (no classes: %s)', async (noClasses) => { + const initial = view(); + const { document, approve, all } = await mount({ + ...initial, + classes: noClasses ? [] : [{ ...initial.classes[0], targets: [] }], + }); + expect(document.querySelector('#empty-note')?.hidden).toBe(false); + expect(approve.disabled).toBe(true); + expect(all.checked).toBe(false); + expect(all.disabled).toBe(noClasses); +}); + +it.each([ + 'Cancel', + 'pagehide', +])('denies on %s with the existing empty class decision', async (action) => { + const { document } = await mount(); + if (action === 'Cancel') (document.querySelector('#deny') as HTMLButtonElement).click(); + else dom.window.dispatchEvent(new dom.window.Event('pagehide')); + expect(send).toHaveBeenLastCalledWith( + { + type: 'SOLID_CONSENT_DECISION', + consentId: 'fixture', + approvedClasses: [], + }, + expect.any(Function), + ); +}); + +it.each(['missing', 'expired'])('keeps Share disabled for a %s request', async (state) => { + const { document, approve } = await mount( + null, + 'light', + state === 'missing' ? 'https://extension.example/consent' : undefined, + ); + expect(approve.disabled).toBe(true); + expect(document.querySelector('#error')?.hidden).toBe(false); +}); + +it.each([ + [[{ url: 'https://pod.example/docs/', kind: 'container' as const }], '1 folder and its contents'], + [ + [ + { url: 'https://pod.example/a/', kind: 'container' as const }, + { url: 'https://pod.example/b/', kind: 'container' as const }, + { url: 'https://pod.example/c', kind: 'instance' as const }, + ], + '2 folders and their contents and 1 item', + ], + [[], '0 items'], +])('summarises folder scope visibly (%#)', async (targets, summary) => { + const initial = view(); + const { document } = await mount({ + ...initial, + classes: [{ ...initial.classes[0]!, targets }], + }); + expect(document.querySelector('.meta')?.textContent).toContain(summary); +}); diff --git a/apps/extension/test/consent.test.ts b/apps/extension/test/consent.test.ts new file mode 100644 index 000000000..7cb77b48d --- /dev/null +++ b/apps/extension/test/consent.test.ts @@ -0,0 +1,63 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// Unit tests for the pure consent-flow logic (design §4.1): building the pending record and +// mapping a per-class approve/deny decision to concrete grants. Proves an unresolved class +// mints a grant authorising NOTHING, and only requested+approved classes mint grants. + +import { describe, expect, it } from 'vitest'; +import type { PinnedTarget } from '../src/background/core/class-grant'; +import { buildPendingConsent, consentDecisionToGrants } from '../src/background/core/consent'; + +const ORIGIN = 'https://app.example'; +const WEBID = 'https://alice.pod.example/profile/card#me'; +const FIN = 'https://w3id.org/jeswr/sectors/finance#Transaction'; +const HEALTH = 'https://w3id.org/jeswr/sectors/health#Record'; +const NOW = 5_000; +const EXP = NOW + 100; + +function pending() { + const spec = { classes: [FIN, HEALTH], modes: ['read'] as const, purpose: 'p', expiresAt: EXP }; + const resolved = new Map([ + [FIN, { targets: [{ url: 'https://alice.pod.example/finance/', kind: 'container' }] }], + // HEALTH intentionally UNRESOLVED (no entry) → empty targets. + ]); + return buildPendingConsent('c1', ORIGIN, WEBID, spec, resolved, NOW); +} + +describe('buildPendingConsent', () => { + it('carries every requested class, empty targets when unresolved', () => { + const p = pending(); + expect(p.classes.map((c) => c.dataClass)).toEqual([FIN, HEALTH]); + expect(p.classes[0].targets).toHaveLength(1); + expect(p.classes[1].targets).toEqual([]); + expect(p.classes[0].expiresAt).toBe(EXP); + }); +}); + +describe('consentDecisionToGrants', () => { + it('mints a grant ONLY for requested+approved classes', () => { + let n = 0; + const grants = consentDecisionToGrants(pending(), [FIN], () => `g${++n}`); + expect(grants).toHaveLength(1); + expect(grants[0]).toMatchObject({ + grantId: 'g1', + origin: ORIGIN, + webId: WEBID, + dataClass: FIN, + modes: ['read'], + expiresAt: EXP, + }); + }); + it('mints nothing when nothing is approved (deny-all)', () => { + expect(consentDecisionToGrants(pending(), [], () => 'g')).toEqual([]); + }); + it('ignores an approved class that was not in the request (defence in depth)', () => { + const grants = consentDecisionToGrants(pending(), ['https://evil/Class', FIN], () => 'g'); + expect(grants.map((g) => g.dataClass)).toEqual([FIN]); + }); + it('an approved but UNRESOLVED class mints a grant with an EMPTY target set (authorises nothing)', () => { + const grants = consentDecisionToGrants(pending(), [HEALTH], () => 'gH'); + expect(grants).toHaveLength(1); + expect(grants[0].targets).toEqual([]); + }); +}); diff --git a/apps/extension/test/content-script.test.ts b/apps/extension/test/content-script.test.ts new file mode 100644 index 000000000..1874c9cfe --- /dev/null +++ b/apps/extension/test/content-script.test.ts @@ -0,0 +1,90 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { JSDOM } from 'jsdom'; +import { afterEach, expect, it, vi } from 'vitest'; + +let dom: JSDOM; +afterEach(() => { + dom.window.close(); + vi.unstubAllGlobals(); +}); + +it('stamps origin and activation in ISOLATED, ignoring page-supplied authority and management messages', async () => { + dom = new JSDOM('', { url: 'https://ungranted.example/app' }); + const sendMessage = vi.fn((_message: unknown, callback?: (value: unknown) => void) => + callback?.({ webId: null }), + ); + const activation = { isActive: false }; + vi.stubGlobal('window', dom.window); + vi.stubGlobal('navigator', { userActivation: activation }); + vi.stubGlobal('chrome', { runtime: { sendMessage, onMessage: { addListener: vi.fn() } } }); + vi.resetModules(); + await import('../src/content/content-script'); + const post = (type: string) => + dom.window.dispatchEvent( + new dom.window.MessageEvent('message', { + source: dom.window, + data: { + type, + channel: 'solid-browser-ext', + dir: 'to-content', + stampedOrigin: 'https://granted.example', + userActivated: true, + origin: 'https://granted.example', + }, + }), + ); + for (const type of [ + 'SOLID_FETCH_REQUEST', + 'SOLID_GET_STATE', + 'SOLID_REQUEST_ACCESS', + 'SOLID_LOGOUT', + ]) + post(type); + for (const [message] of sendMessage.mock.calls) + expect(message).toMatchObject({ stampedOrigin: 'https://ungranted.example' }); + expect(sendMessage.mock.calls[2][0]).toMatchObject({ userActivated: false }); + activation.isActive = true; + post('SOLID_REQUEST_ACCESS'); + expect(sendMessage.mock.lastCall?.[0]).toMatchObject({ + stampedOrigin: 'https://ungranted.example', + userActivated: true, + }); + sendMessage.mockClear(); + post('SOLID_FORMFILL_REQUEST'); + post('SOLID_FORMFILL_PREVIEW'); + post('SOLID_FORMFILL_DECISION'); + post('SOLID_FORMFILL_TRIGGER'); + post('SOLID_ORIGIN_GRANT_PROFILE'); + post('SOLID_ORIGIN_GRANT_REVOKE'); + expect(sendMessage).not.toHaveBeenCalled(); +}); + +it('drops identity broadcasts for another origin or without an origin, including a late navigation', async () => { + dom = new JSDOM('', { url: 'https://granted.example/app' }); + const addListener = vi.fn(); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('chrome', { runtime: { onMessage: { addListener } } }); + vi.resetModules(); + await import('../src/content/content-script'); + const receive = addListener.mock.calls[0][0]; + const post = vi.spyOn(dom.window, 'postMessage'); + receive({ + type: 'SOLID_STATE_CHANGED', + origin: 'https://granted.example', + webId: 'https://alice.example/#me', + }); + expect(post).toHaveBeenCalledWith( + expect.objectContaining({ type: 'SOLID_STATE_UPDATE', webId: 'https://alice.example/#me' }), + 'https://granted.example', + ); + post.mockClear(); + dom.reconfigure({ url: 'https://ungranted.example/' }); + for (const origin of ['https://granted.example', undefined, null, 'null']) + receive({ type: 'SOLID_STATE_CHANGED', origin, webId: 'https://alice.example/#me' }); + expect(post).not.toHaveBeenCalled(); + receive({ type: 'SOLID_STATE_CHANGED', origin: 'https://ungranted.example', webId: null }); + expect(post).toHaveBeenCalledWith( + expect.objectContaining({ type: 'SOLID_STATE_UPDATE', webId: null }), + 'https://ungranted.example', + ); +}); diff --git a/apps/extension/test/dev.test.ts b/apps/extension/test/dev.test.ts new file mode 100644 index 000000000..b3d09826c --- /dev/null +++ b/apps/extension/test/dev.test.ts @@ -0,0 +1,75 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { EventEmitter } from 'node:events'; +import { afterEach, expect, it, vi } from 'vitest'; + +const { startTestSiteServer, goto, contextOn } = vi.hoisted(() => ({ + startTestSiteServer: vi.fn(async (_options: unknown) => ({ close: vi.fn() })), + goto: vi.fn(async (_url: string) => {}), + contextOn: vi.fn(), +})); + +// Execute the real interactive harness without starting processes, browsers, or listeners. +vi.mock('node:child_process', () => ({ + spawn: () => { + const child = Object.assign(new EventEmitter(), { stderr: new EventEmitter() }); + queueMicrotask(() => child.emit('close', 0)); + return child; + }, +})); +vi.mock('node:fs', () => ({ + default: { + mkdtempSync: () => '/tmp/solid-ext-dev-fixture', + readFileSync: () => '{}', + writeFileSync: vi.fn(), + }, +})); +vi.mock('node:http', () => ({ + default: { + get: (_url: string, callback: (response: { resume: () => void }) => void) => { + callback({ resume: vi.fn() }); + return Object.assign(new EventEmitter(), { setTimeout: vi.fn() }); + }, + }, +})); +vi.mock('playwright', () => ({ + chromium: { + launchPersistentContext: async () => ({ + serviceWorkers: () => [ + { + url: () => + 'chrome-extension://bicabcdahgfbkgmlidjpmiadmjlppikk/background/service-worker.js', + }, + ], + close: async () => {}, + pages: () => [{ goto }], + newPage: async () => ({ goto }), + on: contextOn, + }), + }, +})); +vi.mock('../test-site/server', () => ({ startTestSiteServer })); + +afterEach(() => vi.restoreAllMocks()); + +it('configures both development Client ID Documents with the root Chromium redirect', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(process, 'on').mockReturnValue(process); + + await import('../scripts/dev'); + await vi.waitFor(() => expect(contextOn).toHaveBeenCalledWith('close', expect.any(Function))); + + expect(startTestSiteServer.mock.calls.map(([options]) => options)).toEqual([ + { + port: 8080, + redirectUri: 'https://bicabcdahgfbkgmlidjpmiadmjlppikk.chromiumapp.org/', + clientName: 'Solid Browser Extension', + }, + { + port: 8081, + redirectUri: 'https://bicabcdahgfbkgmlidjpmiadmjlppikk.chromiumapp.org/', + clientName: 'My Test App', + clientIdUrl: 'http://localhost:8081/client-id', + }, + ]); + expect(goto).toHaveBeenCalledWith('http://localhost:8081/with-client-id.html'); +}); diff --git a/apps/extension/test/dpop-key-store.test.ts b/apps/extension/test/dpop-key-store.test.ts new file mode 100644 index 000000000..20571d34c --- /dev/null +++ b/apps/extension/test/dpop-key-store.test.ts @@ -0,0 +1,359 @@ +// AUTHORED-BY Claude Sonnet 5 +// +// The DPoP session keypair's durable, non-extractable storage (security hardening, design +// roborev round-1 — the F3 keystore-advantage finding). These tests exercise the REAL +// `DpopKeyDb` class against a minimal fake `IDBFactory` — not a mocked module — so the +// IndexedDB structured-clone round-trip is genuinely exercised: `put`/`get` clone the value +// via `structuredClone`, which is the same primitive the Web Crypto spec relies on to make a +// non-extractable `CryptoKey` survive an IndexedDB round-trip (verified live: Node's +// `structuredClone` clones a non-extractable `CryptoKey` and the clone can still sign). + +import { beforeEach, describe, expect, it } from 'vitest'; +import { createDpopProof, dpopThumbprint } from '../src/background/core/dpop'; +import { DpopKeyDb, type StoredDpopKeyRecord } from '../src/background/core/dpop-key-db'; +import { + DpopKeyUnavailableError, + loadSessionDpopKeyPair, + mintAndPersistDpopKeyPair, + persistDpopKeyPair, +} from '../src/background/core/dpop-key-store'; + +// --- A minimal fake IDBFactory -------------------------------------------------------- +// +// Backs ONE named object store per "database" (keyed by db name) as a plain Map, with +// `put`/`get` round-tripping every value through `structuredClone` — the same algorithm a +// real IndexedDB implementation uses to persist a value, which is what makes a +// non-extractable `CryptoKey` survive intact (see the module docblock). A fresh +// `DpopKeyDb.open(factory)` call against the SAME `backingStores` map models an MV3 +// service-worker restart re-opening the same durable database. + +type FakeRecordStore = Map; + +class FakeRequestImpl { + result: T | undefined; + error: unknown; + onsuccess: (() => void) | null = null; + onerror: (() => void) | null = null; +} + +/** + * Resolve a fake request ONE microtask turn from now, then notify the owning transaction — + * mirroring real IndexedDB, where `dpop-key-db.ts`'s `promisify(req)` resolves first and + * `txDone(tx)` (which attaches `tx.oncomplete` only AFTER that resolution) must still see the + * completion event. `notifyRequestDone` schedules `oncomplete` a further turn out so the + * caller's `await txDone(tx)` has a chance to attach its handler in between. + */ +function settleRequest(req: FakeRequestImpl, value: T, notifyRequestDone: () => void): void { + queueMicrotask(() => { + req.result = value; + req.onsuccess?.(); + notifyRequestDone(); + }); +} + +class FakeObjectStore { + constructor( + private readonly store: FakeRecordStore, + private readonly notifyRequestDone: () => void, + ) {} + + get(key: IDBValidKey): FakeRequestImpl { + const req = new FakeRequestImpl(); + const raw = this.store.get(key); + settleRequest( + req, + raw === undefined ? undefined : structuredClone(raw), + this.notifyRequestDone, + ); + return req; + } + + put(value: unknown, key: IDBValidKey): FakeRequestImpl { + const req = new FakeRequestImpl(); + this.store.set(key, structuredClone(value)); + settleRequest(req, key, this.notifyRequestDone); + return req; + } + + clear(): FakeRequestImpl { + const req = new FakeRequestImpl(); + this.store.clear(); + settleRequest(req, undefined, this.notifyRequestDone); + return req; + } +} + +class FakeTransaction { + oncomplete: (() => void) | null = null; + onerror: (() => void) | null = null; + onabort: (() => void) | null = null; + + constructor(private readonly store: FakeRecordStore) {} + + objectStore(_name: string): FakeObjectStore { + return new FakeObjectStore(this.store, () => this.scheduleComplete()); + } + + /** Fire `oncomplete` two turns after the request settles — see {@link settleRequest}. */ + private scheduleComplete(): void { + queueMicrotask(() => queueMicrotask(() => this.oncomplete?.())); + } +} + +class FakeDatabase { + private readonly names = new Set(); + + constructor(private readonly stores: Map) {} + + get objectStoreNames(): { contains: (name: string) => boolean } { + return { contains: (name: string) => this.names.has(name) }; + } + + createObjectStore(name: string): FakeObjectStore { + this.names.add(name); + if (!this.stores.has(name)) this.stores.set(name, new Map()); + // Only used for its side effect during "upgrade" — no transaction to notify. + return new FakeObjectStore(this.stores.get(name) as FakeRecordStore, () => {}); + } + + transaction(name: string, _mode: string): FakeTransaction { + // Object store must already exist (created during the "upgrade"). + if (!this.stores.has(name)) this.stores.set(name, new Map()); + return new FakeTransaction(this.stores.get(name) as FakeRecordStore); + } + + close(): void { + // no-op + } +} + +/** The fake `IDBOpenDBRequest` — its own (non-lib.dom) type, cast to `IDBFactory` at the edge. */ +class FakeOpenRequest { + result: FakeDatabase | undefined; + error: unknown; + onupgradeneeded: (() => void) | null = null; + onsuccess: (() => void) | null = null; + onerror: (() => void) | null = null; +} + +/** A fake IDBFactory whose `open` always yields the SAME backing db for a given `dbName`. */ +function createFakeIdbFactory(backingDbs: Map): IDBFactory { + return { + open(dbName: string, _version?: number) { + const req = new FakeOpenRequest(); + const isNew = !backingDbs.has(dbName); + const db = backingDbs.get(dbName) ?? new FakeDatabase(new Map()); + backingDbs.set(dbName, db); + req.result = db; + queueMicrotask(() => { + if (isNew) req.onupgradeneeded?.(); + req.onsuccess?.(); + }); + return req; + }, + } as unknown as IDBFactory; +} + +// --- Tests ------------------------------------------------------------------------------ + +describe('DpopKeyDb — non-extractable CryptoKeyPair persistence', () => { + let backingDbs: Map; + let factory: IDBFactory; + + beforeEach(() => { + backingDbs = new Map(); + factory = createFakeIdbFactory(backingDbs); + }); + + it('persists + reloads a non-extractable keypair (structured-clone round trip)', async () => { + const db = await DpopKeyDb.open(factory); + const keyPair = await mintAndPersistDpopKeyPair(db); + expect(keyPair.privateKey.extractable).toBe(false); + + // Simulate an MV3 service-worker restart: a FRESH DpopKeyDb handle re-opening the SAME + // underlying (fake) database. + const restarted = await DpopKeyDb.open(factory); + const record = (await restarted.get()) as StoredDpopKeyRecord; + expect(record).toBeDefined(); + expect(record.privateKey.extractable).toBe(false); + // Round-tripped key still rejects export (never touched extractable material)... + await expect(crypto.subtle.exportKey('jwk', record.privateKey)).rejects.toThrow(); + // ...yet is still a LIVE, usable key: it can sign a DPoP proof, verifiable against the + // (safely-persisted) public JWK. + const proof = await createDpopProof({ + keyPair: { privateKey: record.privateKey, publicKey: record.publicKey }, + htm: 'GET', + htu: 'https://pod.example/x', + }); + expect(typeof proof).toBe('string'); + expect(record.publicJwk).toMatchObject({ kty: 'EC', crv: 'P-256' }); + expect((record.publicJwk as { d?: unknown }).d).toBeUndefined(); + }); + + it('clear() drops the persisted keypair (logout/teardown)', async () => { + const db = await DpopKeyDb.open(factory); + await mintAndPersistDpopKeyPair(db); + expect(await db.get()).toBeDefined(); + await db.clear(); + expect(await db.get()).toBeUndefined(); + }); +}); + +describe('loadSessionDpopKeyPair — session-aware load / legacy-rotate / mismatch-detect', () => { + let backingDbs: Map; + let factory: IDBFactory; + + beforeEach(() => { + backingDbs = new Map(); + factory = createFakeIdbFactory(backingDbs); + }); + + it('legacy session (no dpopJkt): returns the SAME persisted key on repeated calls', async () => { + const db = await DpopKeyDb.open(factory); + const first = await loadSessionDpopKeyPair(db, {}); + const firstJwk = await crypto.subtle.exportKey('jwk', first.publicKey); + + const second = await loadSessionDpopKeyPair(db, {}); + const secondJwk = await crypto.subtle.exportKey('jwk', second.publicKey); + expect(secondJwk).toEqual(firstJwk); + }); + + it('legacy session (no dpopJkt): mints a FRESH key when none is persisted — the migration path', async () => { + // Models the one-time migration: `session-store.ts#loadSession` has just stripped a + // pre-hardening extractable JWK, leaving this store empty AND the session with no + // recorded `dpopJkt` (it never had one). We must NOT attempt to reconstruct the old + // key — a brand-new non-extractable key is minted instead. + const db = await DpopKeyDb.open(factory); + expect(await db.get()).toBeUndefined(); + + const rotated = await loadSessionDpopKeyPair(db, {}); + expect(rotated.privateKey.extractable).toBe(false); + const persisted = await db.get(); + expect(persisted).toBeDefined(); + await expect( + crypto.subtle.exportKey('jwk', (persisted as StoredDpopKeyRecord).privateKey), + ).rejects.toThrow(); + }); + + it('legacy session: a pre-existing EXTRACTABLE keypair is never imported — rotation always mints fresh', async () => { + // Sanity-check that the "legacy key" scenario cannot leak: even if some other code + // path tried to hand a legacy extractable pair to the store, `loadSessionDpopKeyPair` + // has no code path that imports arbitrary JWK material — it only ever calls + // `generateDpopKeyPair()` (non-extractable by construction; see dpop.ts). + const legacyExtractable = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'], + ); + expect(legacyExtractable.privateKey.extractable).toBe(true); // sanity on the fixture itself + + const db = await DpopKeyDb.open(factory); + const rotated = await loadSessionDpopKeyPair(db, {}); + expect(rotated.privateKey.extractable).toBe(false); + // The rotated key is unrelated to the legacy one (different key material). + const rotatedJwk = await crypto.subtle.exportKey('jwk', rotated.publicKey); + const legacyJwk = await crypto.subtle.exportKey('jwk', legacyExtractable.publicKey); + expect(rotatedJwk).not.toEqual(legacyJwk); + }); + + it('current-shape session (has dpopJkt): returns the matching persisted key', async () => { + const db = await DpopKeyDb.open(factory); + const keyPair = await mintAndPersistDpopKeyPair(db); + const jkt = await dpopThumbprint(keyPair.publicKey); + + const loaded = await loadSessionDpopKeyPair(db, { dpopJkt: jkt }); + const loadedJwk = await crypto.subtle.exportKey('jwk', loaded.publicKey); + const originalJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey); + expect(loadedJwk).toEqual(originalJwk); + }); + + it('current-shape session (has dpopJkt): a MISSING persisted key throws, never silently mints', async () => { + // roborev round-1 Medium: a CURRENT-shape session's tokens are bound to a SPECIFIC key + // (recorded jkt). If that key vanishes (IndexedDB eviction, browser bug), silently + // minting a replacement would let the caller proceed with an access token that can + // never verify against the new key server-side — confusing 401s instead of a clean + // re-login. This must throw so the caller (service-worker.ts/auth-flow.ts) tears the + // session down instead. + const db = await DpopKeyDb.open(factory); + expect(await db.get()).toBeUndefined(); + + await expect(loadSessionDpopKeyPair(db, { dpopJkt: 'some-thumbprint' })).rejects.toThrow( + DpopKeyUnavailableError, + ); + // And it must NOT have minted+persisted a substitute as a side effect. + expect(await db.get()).toBeUndefined(); + }); + + it('current-shape session (has dpopJkt): a MISMATCHED persisted key throws, never silently swaps', async () => { + // Models the store having been overwritten by something else (e.g. a later login) + // while this session object is stale. + const db = await DpopKeyDb.open(factory); + const otherKeyPair = await mintAndPersistDpopKeyPair(db); // whatever is CURRENTLY persisted + + await expect( + loadSessionDpopKeyPair(db, { dpopJkt: 'a-completely-different-thumbprint' }), + ).rejects.toThrow(DpopKeyUnavailableError); + // The mismatched record is left untouched (no silent overwrite). + const stillThere = await db.get(); + const stillThereJwk = await crypto.subtle.exportKey( + 'jwk', + (stillThere as StoredDpopKeyRecord).publicKey, + ); + const otherJwk = await crypto.subtle.exportKey('jwk', otherKeyPair.publicKey); + expect(stillThereJwk).toEqual(otherJwk); + }); +}); + +describe('persistDpopKeyPair — deferred-persist seam (login-failure safety)', () => { + let backingDbs: Map; + let factory: IDBFactory; + + beforeEach(() => { + backingDbs = new Map(); + factory = createFakeIdbFactory(backingDbs); + }); + + it('persists an ALREADY-GENERATED keypair without minting a new one', async () => { + const db = await DpopKeyDb.open(factory); + const keyPair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, [ + 'sign', + 'verify', + ]); + await persistDpopKeyPair(db, keyPair); + + const record = await db.get(); + expect(record).toBeDefined(); + const recordJwk = await crypto.subtle.exportKey( + 'jwk', + (record as StoredDpopKeyRecord).publicKey, + ); + const keyPairJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey); + expect(recordJwk).toEqual(keyPairJwk); + }); + + it('a failed login (never calling persistDpopKeyPair) leaves an existing session key untouched', async () => { + // Models auth-flow.ts completeLogin: generateDpopKeyPair() happens early (to sign the + // token-exchange proof) but persistDpopKeyPair() is only reached on a SUCCESSFUL + // exchange. A failed exchange must leave whatever was already persisted (an existing + // logged-in session's key) completely untouched (roborev round-1 Medium). + const db = await DpopKeyDb.open(factory); + const existing = await mintAndPersistDpopKeyPair(db); // the "currently logged-in" key + + // Simulate a login attempt that generates a candidate key but whose token exchange + // FAILS before persistDpopKeyPair is ever called. + const candidate = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign', 'verify'], + ); + void candidate; // generated, but deliberately never persisted (exchange "failed") + + const record = await db.get(); + const recordJwk = await crypto.subtle.exportKey( + 'jwk', + (record as StoredDpopKeyRecord).publicKey, + ); + const existingJwk = await crypto.subtle.exportKey('jwk', existing.publicKey); + expect(recordJwk).toEqual(existingJwk); + }); +}); diff --git a/apps/extension/test/dpop.test.ts b/apps/extension/test/dpop.test.ts new file mode 100644 index 000000000..4fe1ee27e --- /dev/null +++ b/apps/extension/test/dpop.test.ts @@ -0,0 +1,123 @@ +// AUTHORED-BY Claude Opus 4.8 + +import * as jose from 'jose'; +import { describe, expect, it } from 'vitest'; +import { + accessTokenHash, + canonicalHtu, + createDpopProof, + DPOP_ALG, + generateDpopKeyPair, +} from '../src/background/core/dpop'; + +describe('canonicalHtu', () => { + it('strips the query and fragment (RFC 9449 §4.2)', () => { + expect(canonicalHtu('https://pod.example/a/b.ttl?x=1#frag')).toBe( + 'https://pod.example/a/b.ttl', + ); + }); + + it('preserves scheme, host, port and path', () => { + expect(canonicalHtu('https://pod.example:8443/private/notes.ttl')).toBe( + 'https://pod.example:8443/private/notes.ttl', + ); + }); + + it('throws on a non-URL', () => { + expect(() => canonicalHtu('not a url')).toThrow(); + }); +}); + +describe('accessTokenHash', () => { + it('is the base64url SHA-256 of the ASCII token', async () => { + const token = 'abc123'; + const expected = jose.base64url.encode( + new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))), + ); + expect(await accessTokenHash(token)).toBe(expected); + }); +}); + +describe('createDpopProof', () => { + it('produces a verifiable JWS with the correct header and required claims', async () => { + const keyPair = await generateDpopKeyPair(); + const proof = await createDpopProof({ + keyPair, + htm: 'get', + htu: 'https://pod.example/x?q=1', + }); + + const { payload, protectedHeader } = await jose.jwtVerify(proof, keyPair.publicKey); + expect(protectedHeader.typ).toBe('dpop+jwt'); + expect(protectedHeader.alg).toBe(DPOP_ALG); + expect(payload.htm).toBe('GET'); // upper-cased + expect(payload.htu).toBe('https://pod.example/x'); // canonicalised + expect(typeof payload.jti).toBe('string'); + expect(typeof payload.iat).toBe('number'); + expect(payload.ath).toBeUndefined(); // no access token => no ath + expect(payload.nonce).toBeUndefined(); + }); + + it('embeds ONLY public JWK components in the header (never the private `d`)', async () => { + const keyPair = await generateDpopKeyPair(); + const proof = await createDpopProof({ keyPair, htm: 'GET', htu: 'https://pod.example/x' }); + const header = jose.decodeProtectedHeader(proof); + const jwk = header.jwk as Record; + expect(jwk).toMatchObject({ kty: 'EC', crv: 'P-256' }); + expect(jwk.x).toBeTypeOf('string'); + expect(jwk.y).toBeTypeOf('string'); + expect(jwk.d).toBeUndefined(); + }); + + it('includes ath when an access token is presented and nonce when supplied', async () => { + const keyPair = await generateDpopKeyPair(); + const proof = await createDpopProof({ + keyPair, + htm: 'POST', + htu: 'https://pod.example/x', + accessToken: 'tok', + nonce: 'srv-nonce', + }); + const { payload } = await jose.jwtVerify(proof, keyPair.publicKey); + expect(payload.ath).toBe(await accessTokenHash('tok')); + expect(payload.nonce).toBe('srv-nonce'); + }); + + it('mints a fresh jti every call (single-use proofs)', async () => { + const keyPair = await generateDpopKeyPair(); + const a = jose.decodeJwt( + await createDpopProof({ keyPair, htm: 'GET', htu: 'https://pod.example/x' }), + ); + const b = jose.decodeJwt( + await createDpopProof({ keyPair, htm: 'GET', htu: 'https://pod.example/x' }), + ); + expect(a.jti).not.toBe(b.jti); + }); +}); + +describe('generateDpopKeyPair — non-extractable private key (security hardening)', () => { + it('the private key is non-extractable: exportKey(jwk) rejects', async () => { + const keyPair = await generateDpopKeyPair(); + expect(keyPair.privateKey.extractable).toBe(false); + await expect(crypto.subtle.exportKey('jwk', keyPair.privateKey)).rejects.toThrow(); + }); + + it('the public key stays extractable (needed for the proof header + jkt)', async () => { + const keyPair = await generateDpopKeyPair(); + expect(keyPair.publicKey.extractable).toBe(true); + await expect(crypto.subtle.exportKey('jwk', keyPair.publicKey)).resolves.toMatchObject({ + kty: 'EC', + crv: 'P-256', + }); + }); + + it('a non-extractable keypair still signs+verifies a DPoP proof', async () => { + const keyPair = await generateDpopKeyPair(); + const proof = await createDpopProof({ + keyPair, + htm: 'GET', + htu: 'https://pod.example/x', + }); + await expect(jose.jwtVerify(proof, keyPair.publicKey)).resolves.toBeTruthy(); + }); +}); diff --git a/apps/extension/test/fixtures/formfill-data.ts b/apps/extension/test/fixtures/formfill-data.ts new file mode 100644 index 000000000..96ada3fa3 --- /dev/null +++ b/apps/extension/test/fixtures/formfill-data.ts @@ -0,0 +1,32 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +export const webId = 'https://pod.example/profile/card#me'; +export const baseUrl = 'https://pod.example/contacts/alice.ttl'; + +export const vcardTurtle = ` +@prefix vcard: . +@prefix schema: . +<${webId}> vcard:fn "Alice Example", "Later Name" ; + vcard:given-name "Alice" ; vcard:family-name "Example" ; + vcard:organization-name "Example Ltd" ; + vcard:hasEmail , + [ vcard:pref 2; vcard:hasValue ], + [ vcard:pref 1; vcard:hasValue ], + [ vcard:pref 1; vcard:hasValue ] ; + vcard:hasTelephone [ vcard:hasValue ], + [ a vcard:Pref; vcard:hasValue ] ; + vcard:hasAddress [ vcard:street-address "Old street" ], <#home> ; + schema:name "Schema fallback must lose" ; schema:email "fallback@example.test" . +<#home> vcard:pref true ; vcard:street-address "12 River Road" ; + vcard:extended-address "Flat 3" ; vcard:locality "Oxford" ; + vcard:region "Oxfordshire" ; vcard:postal-code "OX1 1AA" ; vcard:country-name "United Kingdom" . + vcard:fn "Someone Else" ; vcard:hasEmail . +`; + +export const schemaTurtle = ` +@prefix schema: . +<${webId}> schema:name "Bob Builder"; schema:givenName "Bob"; schema:familyName "Builder"; + schema:email ; schema:telephone "TEL:+12025550123"; + schema:address [ a schema:PostalAddress; schema:streetAddress "1 Main Street"; + schema:addressLocality "Springfield"; schema:addressRegion "IL"; + schema:postalCode "62701"; schema:addressCountry "US" ] . +`; diff --git a/apps/extension/test/fixtures/forms/corpus.ts b/apps/extension/test/fixtures/forms/corpus.ts new file mode 100644 index 000000000..14f54c116 --- /dev/null +++ b/apps/extension/test/fixtures/forms/corpus.ts @@ -0,0 +1,182 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import type { FieldKind } from '../../../src/content/formfill/detect'; + +interface FormFixture { + readonly name: string; + readonly html: string; + readonly confidence: 'autocomplete' | 'heuristic'; + readonly expected: Readonly>; +} + +/** Synthetic HTML strings model common production form structures, without external requests. */ +export const forms: readonly FormFixture[] = [ + { + name: 'checkout with independent shipping and billing sections', + html: `
    +
    Ship to + + + +
    +
    Bill to + + + +
    `, + confidence: 'autocomplete', + expected: { + 'ship-name': 'name', + 'ship-street': 'street-address', + 'ship-city': 'address-level2', + 'bill-name': 'name', + 'bill-postal': 'postal-code', + }, + }, + { + name: 'account signup', + html: `
    + +
    `, + confidence: 'autocomplete', + expected: { first: 'given-name', last: 'family-name', email: 'email' }, + }, + { + name: 'contact-us with associated and wrapping labels', + html: `
    + +
    `, + confidence: 'heuristic', + expected: { sender: 'name', reply: 'email' }, + }, + { + name: 'newsletter subscription', + html: `
    +
    `, + confidence: 'heuristic', + expected: { newsletter: 'email' }, + }, + { + name: 'address-book editor', + html: `
    + + +
    `, + confidence: 'autocomplete', + expected: { + full: 'name', + company: 'organization', + 'work-email': 'email', + 'home-phone': 'tel', + country: 'country-name', + }, + }, + { + name: 'government labels with opaque control identifiers', + html: `
    + + + + +
    `, + confidence: 'heuristic', + expected: { + q1: 'given-name', + q2: 'family-name', + q3: 'address-line1', + q4: 'address-level2', + q5: 'address-level1', + q6: 'postal-code', + }, + }, + { + name: 'password explicitly disabling autocomplete', + html: `
    `, + confidence: 'heuristic', + expected: {}, + }, + { + name: 'credit-card payment form', + html: `
    + +
    `, + confidence: 'heuristic', + expected: {}, + }, + { + name: 'Shopify-like nested checkout names', + html: `
    + + + + +
    `, + confidence: 'heuristic', + expected: { + 'shop-first': 'given-name', + 'shop-last': 'family-name', + 'shop-address': 'address-line1', + 'shop-apt': 'address-line2', + 'shop-zip': 'postal-code', + }, + }, + { + name: 'WooCommerce-like billing details', + html: `
    + + + + + +
    `, + confidence: 'autocomplete', + expected: { + billing_first_name: 'given-name', + billing_last_name: 'family-name', + billing_company: 'organization', + billing_address_1: 'address-line1', + billing_address_2: 'address-line2', + billing_state: 'address-level1', + billing_country: 'country', + }, + }, + { + name: 'Stripe-like address element inside its own document', + html: `
    + + + +
    `, + confidence: 'autocomplete', + expected: { + 'stripe-name': 'name', + 'stripe-country': 'country', + 'stripe-line1': 'address-line1', + 'stripe-line2': 'address-line2', + 'stripe-postal': 'postal-code', + }, + }, + { + name: 'UK postal address', + html: `
    + + +
    `, + confidence: 'heuristic', + expected: { + uk1: 'address-line1', + uk2: 'address-line2', + uk3: 'address-level2', + uk4: 'address-level1', + uk5: 'postal-code', + uk6: 'country-name', + }, + }, + { + name: 'placeholder-only contact form', + html: `
    + +
    `, + confidence: 'heuristic', + expected: { p1: 'name', p2: 'email', p3: 'tel', p4: 'organization', p5: 'street-address' }, + }, +]; diff --git a/apps/extension/test/formfill-controller.test.ts b/apps/extension/test/formfill-controller.test.ts new file mode 100644 index 000000000..8136e09ae --- /dev/null +++ b/apps/extension/test/formfill-controller.test.ts @@ -0,0 +1,197 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import { JSDOM } from 'jsdom'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { installFormFill } from '../src/content/formfill-controller'; +import type { FormFillResponse } from '../src/shared/messages'; + +let dom: JSDOM; +let cleanup: () => void; +let receive: (message: unknown, sender: unknown) => void; +let roots: ShadowRoot[]; +let send: ReturnType; +let response: FormFillResponse; +const html = `
    `; +function mount(content = html, url = 'https://shop.example/checkout') { + dom = new JSDOM(content, { url }); + const original = dom.window.Element.prototype.attachShadow; + vi.spyOn(dom.window.Element.prototype, 'attachShadow').mockImplementation(function ( + this: Element, + init: ShadowRootInit, + ) { + expect(init.mode).toBe('closed'); + const root = original.call(this, init); + roots.push(root); + return root; + }); + cleanup = installFormFill(dom.window as unknown as Window & typeof globalThis); +} +function trigger() { + (dom.window.document.getElementById('name') as HTMLInputElement).focus(); + receive({ type: 'SOLID_FORMFILL_TRIGGER' }, { id: 'ours' }); +} +beforeEach(() => { + roots = []; + response = { + origin: 'https://shop.example', + values: [{ field: 'name', category: 'contact', value: 'Alex' }], + }; + send = vi.fn(async (message: { type: string }) => + message.type === 'SOLID_FORMFILL_REQUEST' ? response : { ok: true }, + ); + vi.stubGlobal('chrome', { + runtime: { + id: 'ours', + sendMessage: send, + onMessage: { + addListener: (fn: typeof receive) => { + receive = fn; + }, + removeListener: vi.fn(), + }, + }, + }); +}); +afterEach(() => { + cleanup?.(); + dom.window.close(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +it('renders one closed-shadow button by the first recognised field, with accessible label and no automatic request', () => { + mount(); + const hosts = dom.window.document.querySelectorAll('[data-solid-formfill]'); + expect(hosts).toHaveLength(1); + expect(hosts[0].shadowRoot).toBeNull(); + expect(hosts[0].previousElementSibling?.id).toBe('name'); + expect(roots[0].querySelector('button')?.getAttribute('aria-label')).toBe('Fill from pod'); + expect(send).not.toHaveBeenCalled(); + const button = roots[0].querySelector('button')!; + button.click(); + button.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Enter' })); + button.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: ' ' })); + expect(send).not.toHaveBeenCalled(); +}); +// jsdom has no trusted input source; invoke captured listeners with browser metadata. +it('accepts only trusted click and Enter/Space activation', async () => { + const handlers = new Map(); + dom = new JSDOM(html, { url: 'https://shop.example/checkout' }); + const add = dom.window.HTMLButtonElement.prototype.addEventListener; + vi.spyOn(dom.window.HTMLButtonElement.prototype, 'addEventListener').mockImplementation(function ( + this: HTMLButtonElement, + type: string, + fn: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ) { + handlers.set(type, fn as EventListener); + add.call(this, type, fn, options); + }); + cleanup = installFormFill(dom.window as unknown as Window & typeof globalThis); + const click = handlers.get('click')!; + const keydown = handlers.get('keydown')!; + for (const trusted of [false, true]) { + const before = send.mock.calls.length; + click({ isTrusted: trusted } as Event); + await vi.waitFor(() => expect(send.mock.calls.length).toBe(before + (trusted ? 2 : 0))); + } + for (const key of ['a', 'Enter', ' ']) { + const before = send.mock.calls.length; + keydown({ isTrusted: true, key, repeat: false, preventDefault: vi.fn() } as unknown as Event); + await vi.waitFor(() => expect(send.mock.calls.length).toBe(before + (key === 'a' ? 0 : 2))); + } + expect(send.mock.calls.filter(([m]) => m.type === 'SOLID_FORMFILL_REQUEST')).toHaveLength(3); +}); +it.each([ + ['
    ', 'https://shop.example'], + [ + '
    ', + 'https://shop.example', + ], + [html, 'http://remote.example'], +])('renders no icon for refused forms or insecure pages', (content, url) => { + mount(content, url); + expect(roots).toHaveLength(0); + expect(send).not.toHaveBeenCalled(); +}); +it('adds icons for late forms and removes them when the form becomes refused', async () => { + mount('
    '); + dom.window.document.body.insertAdjacentHTML('beforeend', html); + await vi.waitFor(() => expect(roots).toHaveLength(1)); + dom.window.document.querySelector('form')!.setAttribute('autocomplete', 'off'); + await vi.waitFor(() => + expect(dom.window.document.querySelector('[data-solid-formfill]')).toBeNull(), + ); +}); +it('requests only field/category metadata and writes approved values without overwriting or submitting', async () => { + mount(); + const submit = vi.fn(); + dom.window.document.querySelector('form')!.addEventListener('submit', submit); + trigger(); + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(2)); + expect(send.mock.calls[0][0]).toEqual({ + type: 'SOLID_FORMFILL_REQUEST', + fields: [ + { field: 'name', category: 'contact' }, + { field: 'street-address', category: 'address' }, + ], + }); + expect((dom.window.document.getElementById('name') as HTMLInputElement).value).toBe('Alex'); + expect((dom.window.document.getElementById('address') as HTMLInputElement).value).toBe(''); + trigger(); + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(4)); + expect(send.mock.calls.at(-1)![0]).toEqual({ + type: 'SOLID_FORMFILL_REPORT', + filled: 0, + skipped: 2, + }); + expect(submit).not.toHaveBeenCalled(); +}); +it('rechecks field refusal and classification after the preview, preserving newly entered data', async () => { + mount(); + let release!: (value: FormFillResponse) => void; + send.mockImplementation((message: { type: string }) => + message.type === 'SOLID_FORMFILL_REQUEST' + ? new Promise((resolve) => { + release = resolve; + }) + : Promise.resolve(), + ); + trigger(); + const name = dom.window.document.getElementById('name') as HTMLInputElement; + const address = dom.window.document.getElementById('address') as HTMLInputElement; + name.type = 'password'; + address.value = 'Typed while reviewing'; + release({ + origin: 'https://shop.example', + values: [ + ...response.values, + { field: 'street-address', category: 'address', value: '42 Sample Street' }, + ], + }); + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(2)); + expect(name.value).toBe(''); + expect(address.value).toBe('Typed while reviewing'); +}); +it('drops another-origin replies, page messages and forged worker triggers', async () => { + mount(); + response = { ...response, origin: 'https://elsewhere.example' }; + receive({ type: 'SOLID_FORMFILL_TRIGGER' }, { id: 'foreign' }); + receive({ type: 'SOLID_FORMFILL_TRIGGER' }, { id: 'ours', tab: { id: 3 } }); + expect(send).not.toHaveBeenCalled(); + trigger(); + await vi.waitFor(() => expect(send).toHaveBeenCalledOnce()); + expect((dom.window.document.getElementById('name') as HTMLInputElement).value).toBe(''); +}); + +it('refuses cross-origin iframe icons and worker triggers locally', async () => { + dom = new JSDOM('', { + url: 'https://shop.example/', + }); + const frame = dom.window.document.querySelector('iframe')!.contentWindow!; + frame.document.write(html); + cleanup = installFormFill(frame as unknown as Window & typeof globalThis); + expect(frame.document.querySelector('[data-solid-formfill]')).toBeNull(); + (frame.document.getElementById('name') as HTMLInputElement).focus(); + receive({ type: 'SOLID_FORMFILL_TRIGGER' }, { id: 'ours' }); + expect(send).not.toHaveBeenCalled(); +}); diff --git a/apps/extension/test/formfill-data-loader.test.ts b/apps/extension/test/formfill-data-loader.test.ts new file mode 100644 index 000000000..ee35b798f --- /dev/null +++ b/apps/extension/test/formfill-data-loader.test.ts @@ -0,0 +1,35 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import { expect, it, vi } from 'vitest'; +import { loadFormFillData } from '../src/background/formfill-data-loader'; + +const webId = 'https://pod.example/profile/card#me'; +it('reads public profile without credentials and only linked private documents on pod origins', async () => { + const fetch = vi.fn(async (url: string, _authenticated: boolean) => ({ + ok: true, + text: + url === webId + ? ` + @prefix v: . + <${webId}> v:fn "Alex"; v:hasAddress <../private/address#home>; v:hasEmail ; + v:hasTelephone ; v:hasName . + v:hasAddress <../unlinked>. + ` + : `<${webId}> <#home>. + <#home> "42 Sample Street".`, + })); + const result = await loadFormFillData(webId, new Set(['https://pod.example']), fetch); + expect(result.contact.name).toBe('Alex'); + expect(result.address.streetAddress).toBe('42 Sample Street'); + expect(fetch.mock.calls).toEqual([ + [webId, false], + ['https://pod.example/private/address', true], + ]); +}); +it('fails safely on insecure, missing or malformed public profiles', async () => { + const fetch = vi.fn(async () => ({ ok: false, text: '' })); + await expect(loadFormFillData('http://remote.example/#me', new Set(), fetch)).rejects.toThrow(); + expect(fetch).not.toHaveBeenCalled(); + await expect(loadFormFillData(webId, new Set(), fetch)).rejects.toThrow(); + fetch.mockResolvedValue({ ok: true, text: 'not turtle' }); + await expect(loadFormFillData(webId, new Set(), fetch)).rejects.toThrow(); +}); diff --git a/apps/extension/test/formfill-data.test.ts b/apps/extension/test/formfill-data.test.ts new file mode 100644 index 000000000..aa73d99b2 --- /dev/null +++ b/apps/extension/test/formfill-data.test.ts @@ -0,0 +1,315 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { readFormFillData } from '../src/background/core/formfill-data'; +import { baseUrl, schemaTurtle, vcardTurtle, webId } from './fixtures/formfill-data'; + +const prefixes = `@prefix v: . @prefix s: .`; +const read = (turtle: string) => readFormFillData(webId, `${prefixes} ${turtle}`, baseUrl); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('readFormFillData', () => { + it('parses a profile with 20,000 short email IRIs in under 1.5 seconds', () => { + const emails = Array.from( + { length: 20_000 }, + (_, index) => ``, + ).join(', '); + const turtle = `<${webId}> v:hasEmail ${emails}.`; + const start = performance.now(); + const data = read(turtle); + const elapsed = performance.now() - start; + expect(data.contact.email).toBe('user0@example.test'); + expect(elapsed).toBeLessThan(1500); + }); + + it.each([ + ['mailto:alice%2Btag@example.com', 'alice+tag@example.com'], + ['MAILTO:alice%2btag@example.test', 'alice+tag@example.test'], + ['mailto:alice+tag@example.test', 'alice+tag@example.test'], + ['mailto:alice%25tag@example.test', 'alice%tag@example.test'], + ['mailto:alice%252Btag@example.test', 'alice%2Btag@example.test'], + ['mailto:alice%3Ftag@example.test', 'alice?tag@example.test'], + ['mailto:alice%23tag@example.test', 'alice#tag@example.test'], + ['alice%2Btag@example.test', 'alice%2Btag@example.test'], + ['alice%tag@example.test', 'alice%tag@example.test'], + ])('decodes mailto addresses once and preserves percent signs in plain literals: %s', (value, expected) => { + const objects = [JSON.stringify(value)]; + if (value.toLowerCase().startsWith('mailto:')) objects.push(`<${value}>`); + for (const object of objects) { + for (const body of [ + `v:hasEmail ${object}`, + `s:email ${object}`, + `v:hasEmail [ v:hasValue ${object} ]`, + `v:hasEmail [ v:value ${object} ]`, + ]) { + expect(read(`<${webId}> ${body}.`).contact.email).toBe(expected); + } + } + }); + + it.each([ + 'javascript:alert(1)', + 'javascript:alice@example.test', + 'https://alice@example.test', + 'tel:alice@example.test', + 'alice', + 'alice@@example.test', + 'alice@example..test', + 'alice@-example.test', + '.alice@example.test', + 'alice..smith@example.test', + 'Alice ', + 'alice@example.test,other@example.test', + 'mailto:alice@example.test?subject=Hello', + 'mailto:alice?tag@example.test', + 'mailto:alice#tag@example.test', + 'mailto:alice%tag@example.test', + 'mailto:alice%2@example.test', + 'mailto:alice%GG@example.test', + 'mailto:alice%FF@example.test', + 'mailto:alice%20tag@example.test', + 'mailto:alice@example.test%0A', + 'mailto:alice%0D%0ABcc%3Aother@example.test', + 'mailto:alice@example.test%2Cother@example.test', + 'mailto:mailto:alice@example.test', + 'alice@example.test\nBcc:other@example.test', + ])('drops invalid email literals from vCard, schema.org and structured nodes: %s', (value) => { + for (const body of [ + `v:hasEmail ${JSON.stringify(value)}`, + `s:email ${JSON.stringify(value)}`, + `v:hasEmail [ v:hasValue ${JSON.stringify(value)} ]`, + `v:hasEmail [ v:value ${JSON.stringify(value)} ]`, + ]) { + expect(read(`<${webId}> ${body}.`).contact.email).toBeNull(); + } + }); + + it.each([ + 'javascript:alert(1)', + 'https://12345', + 'mailto:12345', + '+44 CALL ME', + '+123;ext=45', + '+123/45', + '--- () + .', + '+123\n456', + '+123\t456', + ])('drops invalid telephone literals from vCard, schema.org and structured nodes: %s', (value) => { + for (const body of [ + `v:hasTelephone ${JSON.stringify(value)}`, + `s:telephone ${JSON.stringify(value)}`, + `v:hasTelephone [ v:hasValue ${JSON.stringify(value)} ]`, + `v:hasTelephone [ v:value ${JSON.stringify(value)} ]`, + ]) { + expect(read(`<${webId}> ${body}.`).contact.tel).toBeNull(); + } + }); + + it('drops invalid communication IRIs and continues to valid fallback values', () => { + const data = read(`<${webId}> + v:hasEmail [ v:pref 1; v:hasValue "javascript:alert(1)" ], ; + s:email "MAILTO:alice.smith+tag@example.test"; + v:hasTelephone , "https://12345"; s:telephone "TEL:+44 (0)20-1234.5678".`); + expect(data.contact.email).toBe('alice.smith+tag@example.test'); + expect(data.contact.tel).toBe('+44 (0)20-1234.5678'); + }); + + it('drops oversized strings from every contact and address property', () => { + const oversized = 'a'.repeat(257); + const data = read(`<${webId}> v:fn "${'a'.repeat(1_000_000)}"; + v:given-name "${oversized}"; v:family-name "${oversized}"; + v:organization-name "${oversized}"; v:hasEmail "${oversized}@example.test"; + v:hasTelephone "${'1'.repeat(257)}"; + v:hasAddress [ v:street-address "${oversized}"; v:extended-address "${oversized}"; + v:locality "${oversized}"; v:region "${oversized}"; v:postal-code "${oversized}"; + v:country-name "${oversized}" ].`); + expect(Object.values(data.contact).map((value) => value?.length ?? null)).toEqual( + Array(6).fill(null), + ); + expect(Object.values(data.address).map((value) => value?.length ?? null)).toEqual( + Array(6).fill(null), + ); + }); + + it('accepts 256 characters and falls back past oversized literals, IRIs and structured values', () => { + const boundary = 'x'.repeat(256); + const oversized = 'x'.repeat(257); + const data = read(`<${webId}> v:fn "${oversized}", "${boundary}"; + v:hasName [ v:given-name "${oversized}" ]; s:givenName "Fallback"; + v:hasOrganization [ v:organization-name "${oversized}" ]; + v:hasEmail ; s:email "fallback@example.test"; + v:hasTelephone ; s:telephone "${'1'.repeat(256)}"; + v:hasAddress [ v:street-address "${oversized}" ]; + s:address [ s:streetAddress "${boundary}"; s:postalCode "${oversized}" ].`); + expect(data.contact).toMatchObject({ + name: boundary, + givenName: 'Fallback', + organization: null, + email: 'fallback@example.test', + tel: '1'.repeat(256), + }); + expect(data.address).toMatchObject({ streetAddress: boundary, postalCode: null }); + }); + + it('reads vCard values, resolves relative links against the supplied document and honors preference then document order', () => { + const fetch = vi.fn(() => { + throw new Error('network is forbidden'); + }); + vi.stubGlobal('fetch', fetch); + expect(readFormFillData(webId, vcardTurtle, baseUrl)).toEqual({ + contact: { + name: 'Alice Example', + givenName: 'Alice', + familyName: 'Example', + email: 'preferred@example.test', + tel: '+441234567890', + organization: 'Example Ltd', + }, + address: { + streetAddress: '12 River Road', + extendedAddress: 'Flat 3', + locality: 'Oxford', + region: 'Oxfordshire', + postalCode: 'OX1 1AA', + countryName: 'United Kingdom', + }, + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('reads schema.org PostalAddress and scalar fallback values', () => { + expect(readFormFillData(webId, schemaTurtle, baseUrl)).toEqual({ + contact: { + name: 'Bob Builder', + givenName: 'Bob', + familyName: 'Builder', + email: 'bob@example.test', + tel: '+12025550123', + organization: null, + }, + address: { + streetAddress: '1 Main Street', + extendedAddress: null, + locality: 'Springfield', + region: 'IL', + postalCode: '62701', + countryName: 'US', + }, + }); + }); + + it('supports HTTPS schema.org predicates and per-property vCard precedence', () => { + const data = read(`<${webId}> v:fn "VC Name"; s:name "Schema Name"; v:given-name "VC Given"; + s:givenName "Schema Given"; s:familyName "Schema Family"; s:email "plain@example.test"; + s:telephone ; s:address [ s:streetAddress "Schema Street"; s:postalCode "12345" ]; + v:hasAddress [ v:street-address "VC Street" ].`); + expect(data.contact).toEqual({ + name: 'VC Name', + givenName: 'VC Given', + familyName: 'Schema Family', + email: 'plain@example.test', + tel: '+123', + organization: null, + }); + expect(data.address).toMatchObject({ streetAddress: 'VC Street', postalCode: '12345' }); + }); + + it('supports structured vCard names and organizations without reading unrelated subjects', () => { + const data = read(`<${webId}> v:hasName [ v:given-name " Ada "; v:family-name "Lovelace" ]; + v:hasOrganization [ v:organization-name "Analytical Engines" ]. + v:fn "Unrelated Name"; v:hasAddress [ v:locality "Elsewhere" ].`); + expect(data.contact).toMatchObject({ + name: null, + givenName: 'Ada', + familyName: 'Lovelace', + organization: 'Analytical Engines', + }); + expect(data.address.locality).toBeNull(); + }); + + it('takes the first unpreferred value and the first address as a unit', () => { + const data = + read(`<${webId}> v:hasEmail , ; + v:hasTelephone "12345", "67890"; + v:hasAddress [ v:locality "First" ], [ v:street-address "Second street"; v:locality "Second" ].`); + expect(data.contact.email).toBe('first@example.test'); + expect(data.contact.tel).toBe('12345'); + expect(data.address.locality).toBe('First'); + expect(data.address.streetAddress).toBeNull(); + }); + + it('keeps property document order even when later values were already mentioned elsewhere', () => { + const data = read(`<#later> v:hasValue . + <#other> v:fn "Later Name". + <${webId}> v:hasEmail <#first>, <#later>; v:fn "First Name", "Later Name". + <#first> v:hasValue .`); + expect(data.contact.email).toBe('first@example.test'); + expect(data.contact.name).toBe('First Name'); + }); + + it('reads legacy Solid address-book vcard:value nodes', () => { + const data = read(`<${webId}> v:hasEmail [ v:value ]; + v:hasTelephone [ v:value ].`); + expect(data.contact.email).toBe('alice@example.test'); + expect(data.contact.tel).toBe('+441234567890'); + }); + + it('ignores invalid preferences and treats the lowest positive numeric preference as preferred', () => { + const data = read(`<${webId}> v:hasEmail + [ v:pref false, 0, -1, "nonsense"; v:hasValue ], + [ v:pref 4, 2; v:hasValue ], + [ v:pref 3; v:hasValue ].`); + expect(data.contact.email).toBe('rank-two@example.test'); + }); + + it('skips empty, resource-valued text and malformed communication nodes rather than filling node IDs', () => { + const data = read(`<${webId}> v:fn , " ", "Valid"; + v:hasName "not-a-node"; v:hasOrganization "not-a-node"; v:hasAddress "not-a-node"; + v:hasEmail [], , , "", "mailto:", + [ v:hasValue [] ], "MAILTO:valid@example.test"; + v:hasTelephone [], , "tel:", " ", "tel:+123".`); + expect(data.contact).toMatchObject({ + name: 'Valid', + email: 'valid@example.test', + tel: '+123', + givenName: null, + organization: null, + }); + expect(Object.values(data.address).every((value) => value === null)).toBe(true); + }); + + it('falls back from unusable vCard communication nodes to schema scalars', () => { + const data = read(`<${webId}> v:hasEmail [ v:hasValue "" ]; s:email "schema@example.test"; + v:hasTelephone [ v:hasValue ]; s:telephone "+321".`); + expect(data.contact.email).toBe('schema@example.test'); + expect(data.contact.tel).toBe('+321'); + }); + + it('returns explicit nulls for absent data and never searches for another person', () => { + for (const turtle of [ + '', + ' v:fn "Other"; s:email "other@example.test".', + ]) { + const data = read(turtle); + expect(Object.values(data.contact)).toEqual(Array(6).fill(null)); + expect(Object.values(data.address)).toEqual(Array(6).fill(null)); + } + }); + + it('handles full IRIs, a different document base and escaped Turtle literals', () => { + const data = readFormFillData( + webId, + `<${webId}> "Alice \\"Ace\\" Example"; + <#a>. + <#a> "1 Main Road\\nBuilding A".`, + 'https://pod.example/different/address-book.ttl', + ); + expect(data.contact.name).toBe('Alice "Ace" Example'); + expect(data.address.streetAddress).toBe('1 Main Road\nBuilding A'); + }); + + it('throws for malformed Turtle so callers cannot fill from a partial parse', () => { + expect(() => read(`<${webId}> v:fn "Alice";`)).toThrow(); + }); +}); diff --git a/apps/extension/test/formfill-detect.test.ts b/apps/extension/test/formfill-detect.test.ts new file mode 100644 index 000000000..9667786d0 --- /dev/null +++ b/apps/extension/test/formfill-detect.test.ts @@ -0,0 +1,184 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; +import { detectFields, FIELD_CATEGORIES } from '../src/content/formfill/detect'; +import { forms } from './fixtures/forms/corpus'; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('detectFields fixture corpus', () => { + it.each(forms)('$name', ({ html, expected, confidence }) => { + document.body.innerHTML = html; + const fields = detectFields(document); + expect(Object.fromEntries(fields.map(({ element, field }) => [element.id, field]))).toEqual( + expected, + ); + for (const result of fields) { + expect(result.confidence).toBe(confidence); + expect(result.category).toBe(FIELD_CATEGORIES[result.field]); + } + expect(detectFields(document.querySelector('form')!)).toEqual(fields); + }); +}); + +describe('detection precedence and safety', () => { + it.each([ + ['Company address', 'street-address'], + ['Business street', 'street-address'], + ['Organization address line 1', 'address-line1'], + ['Company address line 2', 'address-line2'], + ['Company postcode', 'postal-code'], + ['Business postal code', 'postal-code'], + ])('classifies %s as address data before generic organization hints', (label, field) => { + document.body.innerHTML = ``; + expect(detectFields(document)).toMatchObject([ + { field, category: 'address', confidence: 'heuristic' }, + ]); + }); + + it.each([ + ['tel-extension', 'phone_extension'], + ['organization-title', 'company_role'], + ['address-level3', 'street_district'], + ['section-a shipping address-line3', 'address_line_3'], + ['work tel-extension', 'phone_ext'], + ['tel-extension webauthn', 'phone_extension'], + ['section- tel-extension', 'phone_extension'], + ])('skips explicit unsupported autocomplete %s instead of guessing from %s', (token, name) => { + document.body.innerHTML = ``; + expect(detectFields(document)).toEqual([]); + }); + + it('accepts a bare section- prefix and a trailing webauthn token', () => { + document.body.innerHTML = ` + `; + expect(detectFields(document).map(({ field }) => field)).toEqual(['email', 'tel']); + }); + + it('still uses heuristics when autocomplete is absent, on, or not a field name', () => { + document.body.innerHTML = ` + `; + expect(detectFields(document).map(({ field }) => field)).toEqual(['tel', 'tel', 'tel']); + }); + + it('uses autocomplete before labels, then labels before names, IDs and placeholders', () => { + document.body.innerHTML = `
    + + + + + +
    `; + expect(detectFields(document).map(({ field, confidence }) => [field, confidence])).toEqual([ + ['email', 'autocomplete'], + ['tel', 'heuristic'], + ['given-name', 'heuristic'], + ['family-name', 'heuristic'], + ['organization', 'heuristic'], + ]); + }); + + it.each(['home', 'work', 'mobile', 'fax', 'pager'])('accepts the %s contact hint', (hint) => { + document.body.innerHTML = ` + `; + expect(detectFields(document).map(({ field }) => field)).toEqual(['email', 'tel']); + }); + + it.each([ + 'home name', + 'webauthn', + 'email webauthn webauthn', + 'email unexpected', + 'shipping section-A email', + 'home', + 'on', + ])('ignores invalid or unsupported autocomplete %s and falls back', (tokens) => { + document.body.innerHTML = ``; + expect(detectFields(document)[0]).toMatchObject({ + field: 'organization', + confidence: 'heuristic', + }); + }); + + it.each([ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '
    ', + '', + '', + '
    ', + '
    ', + '', + '', + '', + '
    ', + '', + '', + '', + '', + ])('excludes unsafe or unavailable fields: %s', (html) => { + document.body.innerHTML = html; + expect(detectFields(document)).toEqual([]); + }); + + it('accepts numeric postal controls, aria labels, empty labels and documents without a window', () => { + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = `
    + +
    `; + expect(detectFields(doc).map(({ field }) => field)).toEqual(['postal-code', 'email', 'tel']); + }); + + it('limits a form root to that form and leaves the DOM untouched', () => { + document.body.innerHTML = + '
    '; + const before = document.body.innerHTML; + expect(detectFields(document.querySelector('form')!)).toHaveLength(1); + expect(document.body.innerHTML).toBe(before); + }); + + it('detects controls by form ownership, including external controls and reassigned descendants', () => { + document.body.innerHTML = ` +
    + + Ignored
    +
    + + `; + const before = document.body.innerHTML; + const ids = (root: Document | HTMLFormElement) => + detectFields(root).map(({ element }) => element.id); + expect(ids(document.querySelector('#checkout')!)).toEqual([ + 'external-email', + 'owned', + 'external-address', + 'external-country', + ]); + expect(ids(document.querySelector('#other')!)).toEqual([ + 'reassigned', + 'foreign', + ]); + expect(ids(document)).toEqual([ + 'external-email', + 'owned', + 'reassigned', + 'foreign', + 'external-address', + 'external-country', + ]); + expect(document.body.innerHTML).toBe(before); + }); +}); diff --git a/apps/extension/test/formfill-grant.test.ts b/apps/extension/test/formfill-grant.test.ts new file mode 100644 index 000000000..ef67d6f88 --- /dev/null +++ b/apps/extension/test/formfill-grant.test.ts @@ -0,0 +1,60 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import { describe, expect, it } from 'vitest'; +import { + liveFormFillGrant, + revokeProfileFormFillGrants, + sanitiseFormFillGrants, +} from '../src/background/core/formfill-grant'; + +const grant = { + origin: 'https://shop.example', + profileId: '1', + categories: ['contact', 'address'], + createdAt: 100, +}; +describe('separate form-fill grants', () => { + it('copies only validated data and deduplicates categories', () => { + expect( + sanitiseFormFillGrants([{ ...grant, categories: ['contact', 'contact'], extra: 'ignored' }]), + ).toEqual([{ ...grant, categories: ['contact'] }]); + expect(sanitiseFormFillGrants([{ ...grant, categories: ['address'] }])).toEqual([ + { ...grant, categories: ['address'] }, + ]); + expect( + sanitiseFormFillGrants([{ ...grant, origin: 'http://localhost:3001', expiresAt: 200 }]), + ).toEqual([{ ...grant, origin: 'http://localhost:3001', expiresAt: 200 }]); + }); + it('fails closed on malformed or ambiguous records', () => { + for (const raw of [null, {}, 'bad']) expect(sanitiseFormFillGrants(raw)).toEqual([]); + const invalid = [ + null, + false, + 'bad', + {}, + { ...grant, origin: 'http://shop.example' }, + { ...grant, origin: 'https://shop.example/' }, + { ...grant, profileId: 1 }, + { ...grant, profileId: ' ' }, + { ...grant, createdAt: NaN }, + { ...grant, createdAt: -1 }, + { ...grant, expiresAt: Infinity }, + { ...grant, expiresAt: 100 }, + { ...grant, categories: null }, + { ...grant, categories: [] }, + { ...grant, categories: ['password'] }, + ]; + expect(sanitiseFormFillGrants(invalid)).toEqual([]); + expect(sanitiseFormFillGrants([grant, grant])).toEqual([]); + }); + it('checks origin and expiry on each use and revokes only the chosen profile', () => { + const grants = sanitiseFormFillGrants([ + grant, + { ...grant, origin: 'https://other.example', profileId: '2', expiresAt: 200 }, + ]); + expect(liveFormFillGrant(grants, grant.origin, 500)).toEqual(grants[0]); + expect(liveFormFillGrant(grants, 'https://other.example', 199)).toEqual(grants[1]); + expect(liveFormFillGrant(grants, 'https://other.example', 200)).toBeNull(); + expect(liveFormFillGrant(grants, 'https://unknown.example', 100)).toBeNull(); + expect(revokeProfileFormFillGrants(grants, '1')).toEqual([grants[1]]); + }); +}); diff --git a/apps/extension/test/formfill-preview.test.ts b/apps/extension/test/formfill-preview.test.ts new file mode 100644 index 000000000..611b00ea9 --- /dev/null +++ b/apps/extension/test/formfill-preview.test.ts @@ -0,0 +1,219 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import { readFileSync } from 'node:fs'; +import { JSDOM } from 'jsdom'; +import { afterEach, expect, it, vi } from 'vitest'; +import type { FormFillPreview } from '../src/shared/messages'; + +let dom: JSDOM; +let send: ReturnType; +const view = (profileId = '1'): FormFillPreview => ({ + requestId: 'fixture', + origin: 'https://shop.example', + profileId, + profiles: [ + { id: '1', label: 'Personal' }, + { id: '2', label: 'Work' }, + ], + values: [ + { + field: 'name', + category: 'contact', + value: ` Person ${profileId}`, + }, + { field: 'street-address', category: 'address', value: '42 Sample Street' }, + ], + selected: profileId === '1' ? ['address'] : [], +}); +async function mount( + url = 'https://extension.example/formfill/preview.html?requestId=fixture', + initial: FormFillPreview | null = view(), +) { + dom = new JSDOM(readFileSync(new URL('../src/formfill/preview.html', import.meta.url), 'utf8'), { + url, + }); + send = vi.fn(async (message: { type: string; profileId?: string }) => + message.type === 'SOLID_FORMFILL_PREVIEW' + ? message.profileId + ? view(message.profileId) + : initial + : undefined, + ); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('chrome', { runtime: { sendMessage: send } }); + vi.spyOn(dom.window, 'close').mockImplementation(() => {}); + vi.resetModules(); + await import('../src/formfill/preview'); +} +afterEach(() => { + dom.window.close(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +it('labels native keyboard controls, renders values as text, and submits only checked categories', async () => { + await mount(); + const document = dom.window.document; + await vi.waitFor(() => expect(document.querySelectorAll('dd')).toHaveLength(2)); + expect(document.querySelector('label[for="profile"]')?.textContent).toBe('Use profile'); + expect(document.querySelector('#origin')?.textContent).toBe('https://shop.example'); + expect(document.querySelector('dd')?.textContent).toContain('('fieldset input')[1]; + expect(address.checked).toBe(true); + expect(address.closest('label')?.textContent).toContain('Address'); + expect(document.querySelector('#fill')?.getAttribute('type')).toBe('submit'); + const fill = document.querySelector('#fill') as HTMLButtonElement; + expect(fill.disabled).toBe(false); + expect(fill.getAttribute('aria-describedby')).toBe('selection-hint'); + expect(document.getElementById('selection-hint')?.textContent).toBe( + 'Only the checked kinds of detail will be filled.', + ); + document + .querySelector('form')! + .dispatchEvent(new dom.window.Event('submit', { bubbles: true, cancelable: true })); + await vi.waitFor(() => expect(dom.window.close).toHaveBeenCalledOnce()); + expect(send).toHaveBeenLastCalledWith({ + type: 'SOLID_FORMFILL_DECISION', + requestId: 'fixture', + profileId: '1', + categories: ['address'], + remember: true, + }); + dom.window.dispatchEvent(new dom.window.Event('pagehide')); + expect(send.mock.calls.filter(([m]) => m.type === 'SOLID_FORMFILL_DECISION')).toHaveLength(1); +}); +it('requires a category on first use and updates the button and accessible explanation live', async () => { + await mount(undefined, { ...view(), selected: [] }); + const document = dom.window.document; + await vi.waitFor(() => expect(document.querySelectorAll('dd')).toHaveLength(2)); + const fill = document.querySelector('#fill') as HTMLButtonElement; + const hint = document.getElementById(fill.getAttribute('aria-describedby')!)!; + const [contact, address] = document.querySelectorAll('fieldset input'); + expect(hint.getAttribute('aria-live')).toBe('polite'); + expect(hint.hidden).toBe(false); + expect(hint.textContent).toBe('Choose at least one kind of detail to fill.'); + expect(fill.disabled).toBe(true); + fill.click(); + document + .querySelector('form')! + .dispatchEvent(new dom.window.Event('submit', { cancelable: true })); + expect(send).toHaveBeenCalledOnce(); + expect(dom.window.close).not.toHaveBeenCalled(); + contact.click(); + expect(fill.disabled).toBe(false); + expect(hint.textContent).toBe('Only the checked kinds of detail will be filled.'); + address.click(); + contact.click(); + expect(fill.disabled).toBe(false); + address.click(); + expect(fill.disabled).toBe(true); + expect(hint.textContent).toBe('Choose at least one kind of detail to fill.'); + contact.click(); + fill.click(); + await vi.waitFor(() => expect(dom.window.close).toHaveBeenCalledOnce()); + expect(send).toHaveBeenLastCalledWith( + expect.objectContaining({ categories: ['contact'], remember: false }), + ); + address.click(); + expect(fill.disabled).toBe(true); +}); +it('updates the preview on profile selection and clears previous category choices', async () => { + await mount(); + const document = dom.window.document; + await vi.waitFor(() => expect(document.querySelectorAll('dd')).toHaveLength(2)); + const picker = document.querySelector('select')!; + picker.value = '2'; + picker.dispatchEvent(new dom.window.Event('change')); + expect((document.querySelector('#fill') as HTMLButtonElement).disabled).toBe(true); + await vi.waitFor(() => expect(document.querySelector('dd')?.textContent).toContain('Person 2')); + expect( + [...document.querySelectorAll('fieldset input')].every((box) => !box.checked), + ).toBe(true); + expect((document.querySelector('#fill') as HTMLButtonElement).disabled).toBe(true); + expect(document.querySelector('#selection-hint')?.textContent).toBe( + 'Choose at least one kind of detail to fill.', + ); + (document.querySelector('fieldset input') as HTMLInputElement).click(); + expect((document.querySelector('#fill') as HTMLButtonElement).disabled).toBe(false); + document + .querySelector('form')! + .dispatchEvent(new dom.window.Event('submit', { cancelable: true })); + await vi.waitFor(() => expect(dom.window.close).toHaveBeenCalledOnce()); + expect(send).toHaveBeenLastCalledWith( + expect.objectContaining({ profileId: '2', categories: ['contact'], remember: false }), + ); +}); +it('keeps Fill disabled if remembered categories have no matching values', async () => { + await mount(undefined, { ...view(), values: [view().values[0]] }); + const document = dom.window.document; + await vi.waitFor(() => expect(document.querySelectorAll('dd')).toHaveLength(1)); + expect((document.querySelector('#fill') as HTMLButtonElement).disabled).toBe(true); + expect(document.querySelector('#selection-hint')?.textContent).toBe( + 'Choose at least one kind of detail to fill.', + ); +}); +it('keeps Fill disabled when there are no available values', async () => { + await mount(undefined, { ...view(), values: [] }); + const document = dom.window.document; + await vi.waitFor(() => + expect((document.querySelector('#profile') as HTMLSelectElement).disabled).toBe(false), + ); + expect(document.querySelectorAll('fieldset input')).toHaveLength(0); + expect((document.querySelector('#fill') as HTMLButtonElement).disabled).toBe(true); + expect((document.querySelector('#empty') as HTMLElement).hidden).toBe(false); +}); +it('does not enable Fill from old category controls during a profile load or after failure', async () => { + await mount(); + const document = dom.window.document; + await vi.waitFor(() => expect(document.querySelectorAll('dd')).toHaveLength(2)); + let reject!: (error: Error) => void; + send.mockImplementationOnce( + () => + new Promise((_resolve, rejectLoad) => { + reject = rejectLoad; + }), + ); + const picker = document.querySelector('select')!; + picker.value = '2'; + picker.dispatchEvent(new dom.window.Event('change')); + const contact = document.querySelector('fieldset input') as HTMLInputElement; + const fill = document.querySelector('#fill') as HTMLButtonElement; + contact.click(); + expect(fill.disabled).toBe(true); + reject(new Error('Load failed')); + await vi.waitFor(() => + expect(document.querySelector('#error')?.textContent).toContain('Could not load'), + ); + contact.click(); + expect(fill.disabled).toBe(true); +}); +it('keeps Fill disabled when the request has expired', async () => { + await mount(undefined, null); + const document = dom.window.document; + await vi.waitFor(() => + expect(document.querySelector('#error')?.textContent).toContain('expired'), + ); + expect((document.querySelector('#fill') as HTMLButtonElement).disabled).toBe(true); +}); +it.each(['Escape', 'Cancel', 'close'])('denies on %s without sending values', async (action) => { + await mount(); + await vi.waitFor(() => expect(send).toHaveBeenCalledOnce()); + if (action === 'Escape') + dom.window.document.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape' })); + else if (action === 'Cancel') + (dom.window.document.querySelector('#cancel') as HTMLButtonElement).click(); + else dom.window.dispatchEvent(new dom.window.Event('pagehide')); + await vi.waitFor(() => expect(dom.window.close).toHaveBeenCalledOnce()); + expect(send).toHaveBeenLastCalledWith( + expect.objectContaining({ type: 'SOLID_FORMFILL_DECISION', categories: [] }), + ); +}); +it('disables filling when the request is absent or expired', async () => { + await mount('https://extension.example/formfill/preview.html'); + expect(send).not.toHaveBeenCalled(); + expect((dom.window.document.querySelector('#fill') as HTMLButtonElement).disabled).toBe(true); + expect(dom.window.document.querySelector('#error')?.textContent).toContain('unavailable'); +}); diff --git a/apps/extension/test/formfill-refusal.test.ts b/apps/extension/test/formfill-refusal.test.ts new file mode 100644 index 000000000..eb7320fb4 --- /dev/null +++ b/apps/extension/test/formfill-refusal.test.ts @@ -0,0 +1,104 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +// @vitest-environment jsdom +import { beforeEach, describe, expect, it } from 'vitest'; +import { detectFields } from '../src/content/formfill/detect'; +import { type RefusalInputs, refusalReason } from '../src/content/formfill/refusal'; + +let inputs: RefusalInputs; +beforeEach(() => { + document.body.innerHTML = ''; + inputs = { + url: 'https://shop.example/checkout', + isTopFrame: true, + frameOrigin: 'https://shop.example', + topOrigin: 'https://shop.example', + field: detectFields(document)[0], + consentedCategories: new Set(['contact']), + }; +}); + +describe('refusalReason', () => { + it('permits a consented field on HTTPS and in same-origin frames', () => { + expect(refusalReason(inputs)).toBeNull(); + expect(refusalReason({ ...inputs, isTopFrame: false })).toBeNull(); + expect(refusalReason({ ...inputs, frameOrigin: 'https://SHOP.example:443/' })).toBeNull(); + }); + + it.each([ + 'http://localhost:3000', + 'http://127.0.0.1:8080', + 'http://[::1]:3000', + ])('permits loopback HTTP: %s', (origin) => { + expect( + refusalReason({ ...inputs, url: `${origin}/form`, frameOrigin: origin, topOrigin: origin }), + ).toBeNull(); + }); + + it.each(['url', 'frameOrigin', 'topOrigin'] as const)('fails closed on unparseable %s', (key) => { + expect(refusalReason({ ...inputs, [key]: 'not a URL' })).toBe('invalid-url'); + expect(refusalReason({ ...inputs, [key]: '' })).toBe('invalid-url'); + }); + + it.each([ + 'http://shop.example', + 'http://localhost.evil.example', + 'ftp://shop.example', + 'file:///tmp/form', + 'data:text/html,hello', + 'about:blank', + ])('refuses insecure or opaque URLs: %s', (url) => { + expect(refusalReason({ ...inputs, url })).toBe('insecure-page'); + }); + + it('checks transport for frame and top metadata too', () => { + expect(refusalReason({ ...inputs, frameOrigin: 'http://shop.example' })).toBe('insecure-page'); + expect(refusalReason({ ...inputs, topOrigin: 'http://shop.example' })).toBe('insecure-page'); + }); + + it('refuses cross-origin iframes, including different ports and schemes', () => { + for (const topOrigin of [ + 'https://other.example', + 'https://shop.example:444', + 'http://localhost', + ]) { + expect(refusalReason({ ...inputs, isTopFrame: false, topOrigin })).toBe('cross-origin-frame'); + } + }); + + it('refuses inconsistent page/frame and top-frame origins', () => { + expect(refusalReason({ ...inputs, frameOrigin: 'https://other.example' })).toBe( + 'origin-mismatch', + ); + expect(refusalReason({ ...inputs, topOrigin: 'https://other.example' })).toBe( + 'origin-mismatch', + ); + }); + + it.each([ + ['type', 'password', 'password-field'], + ['autocomplete', 'current-password', 'password-field'], + ['autocomplete', 'section-login NEW-PASSWORD', 'password-field'], + ['autocomplete', 'cc-name', 'payment-field'], + ['autocomplete', 'one-time-code', 'one-time-code-field'], + ['autocomplete', 'off', 'autocomplete-off'], + ['readonly', '', 'uneditable-field'], + ])('rechecks a detected field after %s changes to %s', (attribute, value, reason) => { + inputs.field.element.setAttribute(attribute, value); + expect(refusalReason(inputs)).toBe(reason); + }); + + it('requires the specific category, even when another category is consented', () => { + expect(refusalReason({ ...inputs, consentedCategories: new Set() })).toBe( + 'category-not-consented', + ); + expect(refusalReason({ ...inputs, consentedCategories: new Set(['address']) })).toBe( + 'category-not-consented', + ); + document.body.innerHTML = ''; + const field = detectFields(document)[0]; + expect(refusalReason({ ...inputs, field })).toBe('category-not-consented'); + expect( + refusalReason({ ...inputs, field, consentedCategories: new Set(['address']) }), + ).toBeNull(); + }); +}); diff --git a/apps/extension/test/formfill-worker.test.ts b/apps/extension/test/formfill-worker.test.ts new file mode 100644 index 000000000..115553d4f --- /dev/null +++ b/apps/extension/test/formfill-worker.test.ts @@ -0,0 +1,557 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFormFillData } from '../src/background/core/formfill-data'; +import { FormFillWorker, registerFormFillMenu } from '../src/background/formfill'; +import type { FormFillPreviewRequest, FormFillRequest, Profile } from '../src/shared/messages'; + +const origin = 'https://shop.example'; +const extensionId = 'test-extension'; +const previewUrl = `chrome-extension://${extensionId}/formfill/preview.html`; +const sender = { + origin, + url: `${origin}/checkout`, + tab: { id: 3, url: `${origin}/checkout` }, + frameId: 0, +} as chrome.runtime.MessageSender; +const trusted = { id: extensionId, url: previewUrl, frameId: 0 }; +const request: FormFillRequest = { + type: 'SOLID_FORMFILL_REQUEST', + fields: [ + { field: 'name', category: 'contact' }, + { field: 'street-address', category: 'address' }, + ], +}; +const profiles = ['1', '2'].map((id) => ({ + id, + label: `Profile ${id}`, + sessionState: 'active', +})) as Profile[]; +const data = (id: string) => + readFormFillData( + `https://pod.example/${id}#me`, + `@prefix v: . v:fn "Person ${id}"; v:hasAddress [v:street-address "Street ${id}"].`, + 'https://pod.example/', + ); +let storage: Record; +let worker: FormFillWorker; +let load: ReturnType; +let listProfiles: ReturnType; +let current: ReturnType; +let created: Array<{ url: string }>; +let removed: Set<(id: number) => void>; +let tabRemoved: Set<(id: number) => void>; +let tabUpdated: Set<(id: number, change: chrome.tabs.TabChangeInfo) => void>; +let tabUrl: string; +const grant = (profileId = '1') => ({ + origin, + profileId, + categories: ['contact', 'address'], + createdAt: 1, +}); +const read = (requestId: string, profileId?: string) => + worker.preview({ type: 'SOLID_FORMFILL_PREVIEW', requestId, profileId }, trusted); +async function open() { + const previous = created.length; + const response = worker.request(request, sender); + await vi.waitFor(() => expect(created.length).toBeGreaterThan(previous)); + const requestId = new URL(created.at(-1)!.url).searchParams.get('requestId')!; + return { response, requestId }; +} +async function decide( + requestId: string, + profileId = '1', + categories: string[] = ['contact'], + remember = false, +) { + await worker.preview( + { + type: 'SOLID_FORMFILL_DECISION', + requestId, + profileId, + categories, + remember, + } as FormFillPreviewRequest, + trusted, + ); +} +beforeEach(() => { + storage = {}; + created = []; + removed = new Set(); + tabRemoved = new Set(); + tabUpdated = new Set(); + tabUrl = sender.tab!.url!; + current = vi.fn(async () => {}); + load = vi.fn(async (id: string) => ({ data: data(id), current })); + listProfiles = vi.fn(async () => ({ profiles, defaultProfileId: '1' })); + vi.stubGlobal('chrome', { + runtime: { + id: extensionId, + getURL: (p: string) => `chrome-extension://${extensionId}/${p}`, + onInstalled: { addListener: vi.fn() }, + }, + storage: { + local: { + get: async () => storage, + set: async (obj: Record) => { + Object.assign(storage, obj); + }, + }, + }, + tabs: { + get: async () => ({ url: tabUrl }), + sendMessage: vi.fn(async () => {}), + onRemoved: { + addListener: (f: (id: number) => void) => tabRemoved.add(f), + removeListener: (f: (id: number) => void) => tabRemoved.delete(f), + }, + onUpdated: { + addListener: (f: (id: number, change: chrome.tabs.TabChangeInfo) => void) => + tabUpdated.add(f), + removeListener: (f: (id: number, change: chrome.tabs.TabChangeInfo) => void) => + tabUpdated.delete(f), + }, + }, + windows: { + create: (opts: { url: string }, cb: (w: { id: number }) => void) => { + created.push(opts); + cb({ id: created.length }); + }, + remove: vi.fn(async () => {}), + onRemoved: { + addListener: (f: (id: number) => void) => removed.add(f), + removeListener: (f: (id: number) => void) => removed.delete(f), + }, + }, + }); + worker = new FormFillWorker({ + profiles: listProfiles, + load, + commit: async (_id, task) => task(), + }); +}); +afterEach(() => { + expect(removed.size).toBe(0); + expect(tabRemoved.size).toBe(0); + expect(tabUpdated.size).toBe(0); + if (vi.isFakeTimers()) expect(vi.getTimerCount()).toBe(0); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('form-fill worker boundary and consent', () => { + it('uses sender origin, requires secure top-origin frames, and validates categories before loading data', async () => { + const bad = [ + {}, + { ...sender, origin: 'null' }, + { ...sender, origin: 'https://forged.example' }, + { ...sender, url: 'http://shop.example', origin: 'http://shop.example' }, + { ...sender, frameId: undefined }, + { ...sender, tab: { id: 3 } }, + { + ...sender, + frameId: 4, + origin: 'https://embedded.example', + url: 'https://embedded.example', + }, + ]; + for (const source of bad) expect(await worker.request(request, source)).toEqual({ values: [] }); + for (const fields of [ + null, + [], + [{ field: 'password', category: 'contact' }], + [{ field: 'name', category: 'address' }], + Array(101).fill({ field: 'name', category: 'contact' }), + ]) { + expect(await worker.request({ ...request, fields } as FormFillRequest, sender)).toEqual({ + values: [], + }); + } + expect(load).not.toHaveBeenCalled(); + expect(created).toHaveLength(0); + const { response, requestId } = await open(); + const view = await read(requestId); + expect(view?.origin).toBe(origin); + expect(view?.selected).toEqual([]); + await decide(requestId, '1', []); + expect(await response).toEqual({ values: [] }); + }); + it('filters approved categories and remembers a separate profile-bound grant without API access', async () => { + const { response, requestId } = await open(); + expect((await read(requestId))?.values).toHaveLength(2); + await decide(requestId, '1', ['address', 'password'], true); + expect(await response).toEqual({ + origin, + values: [{ field: 'street-address', category: 'address', value: 'Street 1' }], + }); + expect(storage['solid:formfill-grants']).toEqual([ + { ...grant(), categories: ['address'], createdAt: expect.any(Number) }, + ]); + expect(storage['solid:origin-grants']).toBeUndefined(); + expect(removed.size).toBe(0); + const again = await open(); + expect((await read(again.requestId))?.selected).toEqual(['address']); + await decide(again.requestId, '1', []); + expect(await again.response).toEqual({ values: [] }); + }); + it('pins the remembered profile despite default changes, and switches data only through the picker', async () => { + storage['solid:formfill-grants'] = [grant('2')]; + const { response, requestId } = await open(); + expect(load).toHaveBeenCalledWith('2'); + expect((await read(requestId))?.profileId).toBe('2'); + const selected = await read(requestId, '1'); + expect(selected?.selected).toEqual([]); + expect(selected?.values[0].value).toBe('Person 1'); + await decide(requestId, '2'); // stale picker decision is ignored + await decide(requestId, '1'); + expect((await response).values[0].value).toBe('Person 1'); + }); + it('refuses preview reads and decisions from page senders and unknown profiles', async () => { + const { response, requestId } = await open(); + expect(await worker.preview({ type: 'SOLID_FORMFILL_PREVIEW', requestId }, sender)).toBeNull(); + expect( + await worker.preview( + { type: 'SOLID_FORMFILL_PREVIEW', requestId }, + { ...trusted, frameId: 2 }, + ), + ).toBeNull(); + expect(await read(requestId, 'unknown')).toBeNull(); + await worker.preview( + { + type: 'SOLID_FORMFILL_DECISION', + requestId, + profileId: '1', + categories: ['contact'], + remember: true, + }, + sender, + ); + await decide(requestId, '1', []); + expect(await response).toEqual({ values: [] }); + expect(await read(requestId)).toBeNull(); + }); + it('returns no values on cancel, window close, and window creation failure', async () => { + const cancelled = await open(); + await decide(cancelled.requestId, '1', []); + expect(await cancelled.response).toEqual({ values: [] }); + created = []; + const closed = await open(); + for (const fn of removed) fn(1); + expect(await closed.response).toEqual({ values: [] }); + chrome.windows.create = (() => { + throw new Error('Window failed'); + }) as typeof chrome.windows.create; + expect(await worker.request(request, sender)).toEqual({ values: [] }); + expect(storage['solid:formfill-grants']).toBeUndefined(); + }); + it('times out stalled data loads without opening a late preview', async () => { + vi.useFakeTimers(); + let release!: (value: { data: ReturnType; current: typeof current }) => void; + load.mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const response = worker.request(request, sender); + let settled = false; + void response.then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(29_999); + expect(settled).toBe(false); + expect(created).toHaveLength(0); + await vi.advanceTimersByTimeAsync(1); + expect(await response).toEqual({ values: [] }); + release({ data: data('1'), current }); + await vi.advanceTimersByTimeAsync(0); + expect(created).toHaveLength(0); + }); + it('includes profile discovery in the loading timeout and ignores its late result', async () => { + vi.useFakeTimers(); + let release!: (value: { profiles: Profile[]; defaultProfileId: string }) => void; + listProfiles.mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const response = worker.request(request, sender); + await vi.advanceTimersByTimeAsync(30_000); + expect(await response).toEqual({ values: [] }); + release({ profiles, defaultProfileId: '1' }); + await vi.advanceTimersByTimeAsync(0); + expect(load).not.toHaveBeenCalled(); + expect(created).toHaveLength(0); + }); + it('keeps review open for 24 hours after loading, with only the long safety cap', async () => { + vi.useFakeTimers(); + let release!: (value: { data: ReturnType; current: typeof current }) => void; + load.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const response = worker.request(request, sender); + let settled = false; + void response.then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(29_000); + release({ data: data('1'), current }); + await vi.advanceTimersByTimeAsync(0); + expect(created).toHaveLength(1); + const requestId = new URL(created[0].url).searchParams.get('requestId')!; + await vi.advanceTimersByTimeAsync(20 * 60 * 60_000); + expect(settled).toBe(false); + expect((await read(requestId, '2'))?.profileId).toBe('2'); + await vi.advanceTimersByTimeAsync(4 * 60 * 60_000 - 1); + expect(settled).toBe(false); + expect(chrome.windows.remove).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(await response).toEqual({ values: [] }); + expect(chrome.windows.remove).toHaveBeenCalledWith(1); + expect(await read(requestId)).toBeNull(); + expect(storage['solid:formfill-grants']).toBeUndefined(); + }); + it('accepts a decision after more than 20 hours of review', async () => { + vi.useFakeTimers(); + const { response, requestId } = await open(); + await vi.advanceTimersByTimeAsync(21 * 60 * 60_000); + await decide(requestId, '1', ['contact'], true); + expect((await response).values[0].value).toBe('Person 1'); + expect(storage['solid:formfill-grants']).toEqual([ + { ...grant(), categories: ['contact'], createdAt: expect.any(Number) }, + ]); + }); + it('cancels when the requesting tab closes and ignores other tabs closing', async () => { + const { response, requestId } = await open(); + for (const fn of tabRemoved) fn(99); + expect(await read(requestId)).not.toBeNull(); + for (const fn of tabRemoved) fn(3); + expect(await response).toEqual({ values: [] }); + expect(chrome.windows.remove).toHaveBeenCalledWith(1); + expect(await read(requestId)).toBeNull(); + }); + it.each([ + 'https://elsewhere.example/', + 'https://shop.example:444/', + 'http://shop.example/', + 'about:blank', + 'not a URL', + ])('cancels on navigation to another origin or an invalid destination: %s', async (url) => { + const { response, requestId } = await open(); + for (const fn of tabUpdated) fn(3, { url }); + expect(await response).toEqual({ values: [] }); + expect(chrome.windows.remove).toHaveBeenCalledWith(1); + expect(await read(requestId)).toBeNull(); + }); + it('keeps same-origin path, state and hash changes, unrelated tab updates and window closes', async () => { + const { response, requestId } = await open(); + for (const fn of removed) fn(99); + for (const fn of tabUpdated) { + fn(99, { url: 'https://elsewhere.example' }); + fn(3, { status: 'complete' }); + for (const url of [ + `${origin}/checkout#address`, + `${origin}/checkout?step=2`, + `${origin}/next`, + 'https://SHOP.example:443/next', + ]) { + tabUrl = url; + fn(3, { url }); + } + } + expect(await read(requestId)).not.toBeNull(); + expect(chrome.windows.remove).not.toHaveBeenCalled(); + await decide(requestId); + expect((await response).values).toHaveLength(1); + }); + it.each(['close', 'navigation'])('cancels during discovery on tab %s', async (action) => { + vi.useFakeTimers(); + let release!: (value: { data: ReturnType; current: typeof current }) => void; + load.mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const response = worker.request(request, sender); + await vi.advanceTimersByTimeAsync(0); + expect(load).toHaveBeenCalledOnce(); + if (action === 'close') for (const fn of tabRemoved) fn(3); + else for (const fn of tabUpdated) fn(3, { url: 'https://elsewhere.example/' }); + expect(await response).toEqual({ values: [] }); + release({ data: data('1'), current }); + await vi.advanceTimersByTimeAsync(0); + expect(created).toHaveLength(0); + }); + it('does not apply the loading timeout to profile switches inside an open preview', async () => { + vi.useFakeTimers(); + const { response, requestId } = await open(); + let release!: (value: { data: ReturnType; current: typeof current }) => void; + load.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const switched = read(requestId, '2'); + await vi.advanceTimersByTimeAsync(120_000); + expect(chrome.windows.remove).not.toHaveBeenCalled(); + release({ data: data('2'), current }); + expect((await switched)?.profileId).toBe('2'); + await decide(requestId, '2'); + expect((await response).values[0].value).toBe('Person 2'); + }); + it('closes a late-created preview after the tab has already closed', async () => { + let createdWindow!: (window: chrome.windows.Window) => void; + chrome.windows.create = ((opts, callback) => { + created.push(opts as { url: string }); + createdWindow = callback!; + }) as typeof chrome.windows.create; + const { response } = await open(); + for (const fn of tabRemoved) fn(3); + expect(await response).toEqual({ values: [] }); + createdWindow({ id: 9 } as chrome.windows.Window); + expect(chrome.windows.remove).toHaveBeenCalledWith(9); + }); + it.each([ + 'runtime error', + 'missing id', + ])('cleans up when window creation reports %s', async (mode) => { + chrome.windows.create = ((_opts, callback) => { + if (mode === 'runtime error') + Object.assign(chrome.runtime, { lastError: { message: 'Window failed' } }); + callback!({} as chrome.windows.Window); + }) as typeof chrome.windows.create; + expect(await worker.request(request, sender)).toEqual({ values: [] }); + }); + it.each([ + 'load', + 'profiles', + 'no active profile', + 'no default profile', + ])('cleans up when discovery fails: %s', async (failure) => { + if (failure === 'load') load.mockRejectedValue(new Error('Discovery failed')); + else if (failure === 'profiles') listProfiles.mockRejectedValue(new Error('Unavailable')); + else + listProfiles.mockResolvedValue({ + profiles: failure === 'no active profile' ? [] : profiles, + defaultProfileId: failure === 'no default profile' ? null : '1', + }); + expect(await worker.request(request, sender)).toEqual({ values: [] }); + expect(created).toHaveLength(0); + }); + it('cancels pending requests on profile revocation and drops only that profile grants', async () => { + storage['solid:formfill-grants'] = [ + grant(), + { ...grant('2'), origin: 'https://other.example' }, + ]; + const { response } = await open(); + await worker.revokeProfile('1'); + expect(await response).toEqual({ values: [] }); + expect(storage['solid:formfill-grants']).toEqual([ + { ...grant('2'), origin: 'https://other.example' }, + ]); + await worker.reconcile([{ ...profiles[1], sessionState: 'signed-out' }]); + expect(storage['solid:formfill-grants']).toEqual([]); + }); + it.each([ + 'removed', + 'signed-out', + ])('cancels when reconciliation finds the profile %s', async (state) => { + const { response, requestId } = await open(); + await worker.reconcile( + state === 'removed' ? [] : [{ ...profiles[0], sessionState: 'signed-out' }], + ); + expect(await response).toEqual({ values: [] }); + expect(await read(requestId)).toBeNull(); + }); + it('cancels on revocation of a profile still loading through the picker', async () => { + const { response, requestId } = await open(); + let release!: (value: { data: ReturnType; current: typeof current }) => void; + load.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const switched = read(requestId, '2'); + await worker.revokeProfile('2'); + expect(await response).toEqual({ values: [] }); + release({ data: data('2'), current }); + expect(await switched).toBeNull(); + }); + it('rechecks session, navigation and grant authority before releasing values', async () => { + for (const mutation of [ + () => { + tabUrl = 'https://elsewhere.example/'; + }, + () => { + current.mockRejectedValue(new Error('Signed out')); + }, + () => { + storage['solid:formfill-grants'] = [grant('2')]; + }, + ]) { + created = []; + tabUrl = sender.tab!.url!; + storage = {}; + current.mockResolvedValue(undefined); + const { response, requestId } = await open(); + mutation(); + await decide(requestId, '1', ['contact'], true); + expect(await response).toEqual({ values: [] }); + } + }); + it('admits same-origin subframes and loopback development pages', async () => { + for (const source of [ + { ...sender, frameId: 2 }, + { + ...sender, + url: 'http://localhost:3001/form', + origin: 'http://localhost:3001', + tab: { id: 3, url: 'http://localhost:3001' }, + }, + ]) { + created = []; + tabUrl = source.url!; + const response = worker.request(request, source as chrome.runtime.MessageSender); + await vi.waitFor(() => expect(created).toHaveLength(1)); + const id = new URL(created[0].url).searchParams.get('requestId')!; + await decide(id); + expect((await response).values).toHaveLength(1); + } + }); +}); +it('registers an editable menu and routes its click to exactly the clicked tab and frame', () => { + let click!: (info: chrome.contextMenus.OnClickData, tab: chrome.tabs.Tab) => void; + (chrome as unknown as { contextMenus: unknown }).contextMenus = { + create: vi.fn(), + remove: (_id: string, callback: () => void) => callback(), + onClicked: { + addListener: (fn: typeof click) => { + click = fn; + }, + }, + }; + registerFormFillMenu(); + expect(chrome.contextMenus.create).toHaveBeenCalledWith({ + id: 'solid-fill-from-pod', + title: 'Fill from pod', + contexts: ['editable'], + }); + click( + { menuItemId: 'solid-fill-from-pod', frameId: 6 } as chrome.contextMenus.OnClickData, + { id: 3 } as chrome.tabs.Tab, + ); + expect(chrome.tabs.sendMessage).toHaveBeenCalledWith( + 3, + { type: 'SOLID_FORMFILL_TRIGGER' }, + { frameId: 6 }, + ); + click({ menuItemId: 'other' } as chrome.contextMenus.OnClickData, { id: 3 } as chrome.tabs.Tab); + expect(chrome.tabs.sendMessage).toHaveBeenCalledTimes(1); +}); diff --git a/apps/extension/test/formfill-write.test.ts b/apps/extension/test/formfill-write.test.ts new file mode 100644 index 000000000..2f75ecc69 --- /dev/null +++ b/apps/extension/test/formfill-write.test.ts @@ -0,0 +1,331 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { FormFillElement } from '../src/content/formfill/detect'; +import { writeValue } from '../src/content/formfill/write'; + +afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; +}); + +function mount(html: string): FormFillElement { + document.body.innerHTML = `
    ${html}
    `; + return document.querySelector('input, textarea, select')!; +} + +describe('writeValue', () => { + it.each([ + '', + '42', + ])('restores a numeric telephone control after native sanitization (previous: %s)', (previous) => { + const element = mount(``); + const native = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!; + const ownSetter = vi.fn((value: string) => native.set!.call(element, value)); + Object.defineProperty(element, 'value', { + get: () => native.get!.call(element), + set: ownSetter, + }); + const event = vi.fn(); + element.addEventListener('input', event); + element.addEventListener('change', event); + expect(writeValue(element, '+441234567890', { overwrite: true })).toBe(false); + expect(element.value).toBe(previous); + expect(event).not.toHaveBeenCalled(); + expect(ownSetter).not.toHaveBeenCalled(); + }); + + it.each([ + ['', 'Alice\nExample'], + ['', '1 Main Street\r\nFlat 2'], + ])('restores controls when the native setter changes the supplied string: %s', (html, value) => { + const element = mount(html); + const event = vi.fn(); + element.addEventListener('input', event); + element.addEventListener('change', event); + expect(writeValue(element, value, { overwrite: true })).toBe(false); + expect(element.value).toBe('Old'); + expect(event).not.toHaveBeenCalled(); + }); + + it('still writes representable numbers and multiline textarea values', () => { + const input = mount(''); + expect(writeValue(input, '441234567890')).toBe(true); + expect(input.value).toBe('441234567890'); + const textarea = mount(''); + expect(writeValue(textarea, '1 Main Street\nFlat 2')).toBe(true); + expect(textarea.value).toBe('1 Main Street\nFlat 2'); + }); + + it.each([ + ['', 'javascript:alice@example.test'], + ['', 'bad@@example.test'], + ['', 'alice@example..test'], + ['', 'not an email'], + ['', 'mailto:alice%GG@example.test'], + ['', 'mailto:alice@example.test%0A'], + ['', 'mailto:alice%0D%0ABcc%3Aother@example.test'], + ['', 'javascript:alert(1)'], + ['', 'CALL ME'], + ['', '---'], + ])('rejects invalid communication values without overwriting or firing events: %s', (html, value) => { + const element = mount(html); + const previous = element.value; + const event = vi.fn(); + element.addEventListener('input', event); + element.addEventListener('change', event); + expect(writeValue(element, value, { overwrite: true })).toBe(false); + expect(element.value).toBe(previous); + expect(event).not.toHaveBeenCalled(); + }); + + it.each([ + ['', 'MAILTO:alice.smith+tag@example.test', 'alice.smith+tag@example.test'], + ['', 'mailto:alice%2Btag@example.com', 'alice+tag@example.com'], + ['', 'mailto:alice%25tag@example.test', 'alice%tag@example.test'], + ['', 'mailto:alice%252Btag@example.test', 'alice%2Btag@example.test'], + ['', 'alice%2Btag@example.test', 'alice%2Btag@example.test'], + ['', 'alice%tag@example.test', 'alice%tag@example.test'], + ['', 'alice@example.test', 'alice@example.test'], + ['', 'TEL:+44 (0)20-1234.5678', '+44 (0)20-1234.5678'], + ['', '12345', '12345'], + ])('writes validated communication values and strips the matching scheme: %s', (html, value, expected) => { + const element = mount(html); + expect(writeValue(element, value)).toBe(true); + expect(element.value).toBe(expected); + }); + + it.each([ + '', + '', + ``, + ])('refuses values over 256 characters without changing controls or firing events: %s', (html) => { + const element = mount(html); + const event = vi.fn(); + element.addEventListener('input', event); + element.addEventListener('change', event); + expect(writeValue(element, 'a'.repeat(257), { overwrite: true })).toBe(false); + expect(element.value).toBe(''); + expect(event).not.toHaveBeenCalled(); + }); + + it.each([ + ['', 'Alice'], + ['', 'Alice'], + ['', 'alice@example.test'], + ['', 'alice@example.test'], + ['', '+441234567890'], + ['', '+441234567890'], + ['', 'Alice'], + ])('skips values exceeding maxlength without truncating or changing existing data: %s', (html, value) => { + const element = mount(html); + const previous = element.value; + const event = vi.fn(); + element.addEventListener('input', event); + element.addEventListener('change', event); + expect(writeValue(element, value, { overwrite: true })).toBe(false); + expect(element.value).toBe(previous); + expect(event).not.toHaveBeenCalled(); + }); + + it('accepts values at maxlength and the global length limit', () => { + expect(writeValue(mount(''), 'Alice')).toBe(false); + const input = mount(''); + expect(writeValue(input, 'Alix')).toBe(true); + expect(input.value).toBe('Alix'); + const textarea = mount(''); + expect(writeValue(textarea, 'x'.repeat(256))).toBe(true); + expect(textarea.value).toHaveLength(256); + }); + + it.each([ + 'input', + 'textarea', + ])('updates a React-style controlled %s via the native setter and bubbling events', (tag) => { + const element = mount(`<${tag}>`); + const prototype = tag === 'input' ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype; + const native = Object.getOwnPropertyDescriptor(prototype, 'value')!; + let trackedValue = ''; + let renderedValue = ''; + const ownSetter = vi.fn((value: string) => { + trackedValue = value; + native.set!.call(element, value); + }); + Object.defineProperty(element, 'value', { + get: () => native.get!.call(element), + set: ownSetter, + }); + const events: string[] = []; + const form = document.querySelector('form')!; + form.addEventListener('input', (event) => { + events.push(event.type); + if (element.value !== trackedValue) { + trackedValue = element.value; + renderedValue = trackedValue; + } + }); + form.addEventListener('change', (event) => { + events.push(event.type); + }); + const submit = vi.fn(); + form.addEventListener('submit', submit); + const requestSubmit = vi.spyOn(form, 'requestSubmit'); + expect(writeValue(element, 'Alice Example')).toBe(true); + expect(element.value).toBe('Alice Example'); + expect(renderedValue).toBe('Alice Example'); + expect(ownSetter).not.toHaveBeenCalled(); + expect(events).toEqual(['input', 'change']); + expect(submit).not.toHaveBeenCalled(); + expect(requestSubmit).not.toHaveBeenCalled(); + }); + + it('preserves existing input, textarea and select values unless overwrite is explicit', () => { + for (const html of [ + '', + '', + '', + ]) { + const element = mount(html); + const input = vi.fn(); + element.addEventListener('input', input); + expect(writeValue(element, 'Bob')).toBe(false); + expect(element.value).toBe('Alice'); + expect(input).not.toHaveBeenCalled(); + expect(writeValue(element, 'Bob', { overwrite: true })).toBe(true); + expect(element.value).toBe('Bob'); + expect(input).toHaveBeenCalledTimes(1); + } + expect(writeValue(mount(''), 'Bob')).toBe(false); + }); + + it.each([ + ['ca', '', 'CA'], + [' cAnAdA ', '', 'CA'], + ['United Kingdom', '', 'GB'], + ['UK', '', '826'], + ['GBR', '', '826'], + ['USA', '', '840'], + ['United States of America', '', 'US'], + ['Great Britain', '', 'GB'], + ['DE', '', '276'], + ])('matches select value/text or country alias %s', (value, option, expected) => { + const element = mount( + ``, + ); + const events: string[] = []; + element.addEventListener('input', (event) => { + events.push(event.type); + }); + element.addEventListener('change', (event) => { + events.push(event.type); + }); + expect(writeValue(element, value)).toBe(true); + expect(element.value).toBe(expected); + expect(events).toEqual(['input', 'change']); + }); + + it.each([ + ['', 'Georgia', ''], + ['`); + const event = vi.fn(); + element.addEventListener('input', event); + element.addEventListener('change', event); + expect(writeValue(element, value)).toBe(false); + expect(element.value).toBe(''); + expect(event).not.toHaveBeenCalled(); + }); + + it('skips disabled options and optgroups, and selects the matching duplicate by index', () => { + const element = mount(``) as HTMLSelectElement; + expect(writeValue(element, 'Canada')).toBe(true); + expect(element.selectedIndex).toBe(3); + }); + + it('preserves a multi-select selection even if its first selected option is empty', () => { + const element = mount(``) as HTMLSelectElement; + expect(writeValue(element, 'GB')).toBe(false); + expect( + Array.from(element.options) + .filter((option) => option.selected) + .map((option) => option.value), + ).toEqual(['', 'CA']); + expect(writeValue(element, 'GB', { overwrite: true })).toBe(true); + expect( + Array.from(element.options) + .filter((option) => option.selected) + .map((option) => option.value), + ).toEqual(['GB']); + }); + + it('leaves unmatched selects unchanged without dispatching events', () => { + const element = mount( + '', + ); + const event = vi.fn(); + element.addEventListener('input', event); + element.addEventListener('change', event); + expect(writeValue(element, 'Atlantis')).toBe(false); + expect(element.value).toBe(''); + expect(event).not.toHaveBeenCalled(); + }); + + it.each([ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ])('refuses unsafe controls even with overwrite: %s', (html) => { + const element = mount(html); + const event = vi.fn(); + element.addEventListener('input', event); + expect(writeValue(element, 'private data', { overwrite: true })).toBe(false); + expect(element.value).toBe(''); + expect(event).not.toHaveBeenCalled(); + }); + + it('uses the native prototypes and Event constructor from the element realm', () => { + const iframe = document.createElement('iframe'); + document.body.append(iframe); + const doc = iframe.contentDocument!; + doc.body.innerHTML = ''; + const element = doc.querySelector('input')!; + const listener = vi.fn(); + element.addEventListener('input', listener); + expect(writeValue(element, 'Alice')).toBe(true); + expect(element.value).toBe('Alice'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('fails closed for documents without a window', () => { + const doc = document.implementation.createHTMLDocument(); + expect(writeValue(doc.createElement('input'), 'Alice')).toBe(false); + }); + + it.each([ + undefined, + { value: '' }, + ])('fails closed if the native value setter is unavailable: %s', (descriptor) => { + const element = mount(''); + const original = Object.getOwnPropertyDescriptor; + vi.spyOn(Object, 'getOwnPropertyDescriptor').mockImplementation((object, property) => + object === HTMLInputElement.prototype && property === 'value' + ? descriptor + : original(object, property), + ); + expect(writeValue(element, 'Alice')).toBe(false); + expect(element.value).toBe(''); + }); +}); diff --git a/apps/extension/test/grants-controller.test.ts b/apps/extension/test/grants-controller.test.ts new file mode 100644 index 000000000..35a2b8e46 --- /dev/null +++ b/apps/extension/test/grants-controller.test.ts @@ -0,0 +1,260 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import { describe, expect, it, vi } from 'vitest'; +import { + GrantsController, + type GrantsMessage, + type GrantsResponse, +} from '../src/sidepanel/grants-controller'; +import { DAY, NOW, profiles, SITE, siteGrant } from './grants-fixtures'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function fixture(grants = [siteGrant()]) { + let broadcast!: (message: { type: string }) => void; + const unsubscribe = vi.fn(); + const send = vi.fn(async (message: GrantsMessage): Promise => { + if (message.type === 'SOLID_PROFILES_LIST') return { profiles }; + return { ok: true, grants }; + }); + const controller = new GrantsController({ + send, + now: () => NOW, + subscribe: (listener) => { + broadcast = listener; + return unsubscribe; + }, + }); + return { + controller, + send, + unsubscribe, + broadcast: (type = 'SOLID_STATE_CHANGED') => broadcast({ type }), + }; +} + +describe('sites with access controller', () => { + it('loads site bindings and display-only accounts', async () => { + const { controller, send } = fixture(); + await controller.refresh(); + expect(send.mock.calls.map(([message]) => message.type)).toEqual([ + 'SOLID_ORIGIN_GRANTS_LIST', + 'SOLID_PROFILES_LIST', + ]); + expect(controller.state).toMatchObject({ + phase: 'ready', + loaded: true, + grants: [siteGrant()], + profiles, + }); + expect(JSON.stringify(controller.state)).not.toMatch(/accessToken|refreshToken|privateKey/); + }); + + it('does not revoke until confirmed, and cancelling sends nothing', async () => { + const { controller, send } = fixture(); + await controller.refresh(); + controller.requestRevoke(SITE); + expect(controller.state.phase).toBe('confirming'); + expect(send).toHaveBeenCalledTimes(2); + controller.cancel(); + await controller.confirm(); + expect(send).toHaveBeenCalledTimes(2); + controller.requestRevoke(SITE); + send.mockResolvedValueOnce({ ok: true, grants: [] }); + await controller.confirm(); + expect(send).toHaveBeenLastCalledWith({ type: 'SOLID_ORIGIN_GRANT_REVOKE', origin: SITE }); + expect(controller.state.grants).toEqual([]); + expect(controller.state.status).toContain('Access revoked'); + }); + + it('rebinds only after confirmation, using the returned cleared data access', async () => { + const { controller, send } = fixture(); + await controller.refresh(); + for (const id of ['1', '3', 'unknown']) controller.requestRebind(SITE, id); + expect(controller.state.confirmation).toBeNull(); + controller.requestRebind(SITE, '2'); + expect(send).toHaveBeenCalledTimes(2); + const rebound = siteGrant({ profileId: '2', scope: { pods: [], classes: [] } }); + send.mockResolvedValueOnce({ ok: true, grants: [rebound] }); + await controller.confirm(); + expect(send).toHaveBeenLastCalledWith({ + type: 'SOLID_ORIGIN_GRANT_PROFILE', + origin: SITE, + profileId: '2', + }); + expect(controller.state.grants).toEqual([rebound]); + expect(controller.state.status).toContain('must ask for data access again'); + }); + + it('offers only shorter expiry choices and sends an absolute timestamp', async () => { + const { controller, send } = fixture([siteGrant({ expiresAt: NOW + DAY * 90 })]); + await controller.refresh(); + const options = controller.durationOptions(controller.state.grants[0]); + expect(options.map((option) => [option.label, !!option.reason])).toEqual([ + ['Until revoked', true], + ['1 day', false], + ['1 month', false], + ['1 year', true], + ]); + controller.requestExpiry(SITE, 'Until revoked'); + controller.requestExpiry(SITE, '1 year'); + expect(controller.state.confirmation).toBeNull(); + controller.requestExpiry(SITE, '1 month'); + expect(send).toHaveBeenCalledTimes(2); + await controller.confirm(); + expect(send).toHaveBeenLastCalledWith({ + type: 'SOLID_ORIGIN_GRANT_EXPIRY', + origin: SITE, + expiresAt: NOW + DAY * 30, + }); + }); + + it('can set an expiry on indefinite access, and never extends an existing one', async () => { + const { controller } = fixture(); + await controller.refresh(); + expect(controller.durationOptions(siteGrant()).filter((o) => !o.reason)).toHaveLength(3); + const options = controller.durationOptions(siteGrant({ expiresAt: NOW + DAY })); + expect(options.every((o) => o.reason !== null)).toBe(true); + }); + + it('serialises broadcasts behind a write and ignores double confirmation', async () => { + const { controller, send, broadcast } = fixture(); + await controller.refresh(); + const pending = deferred(); + send.mockReturnValueOnce(pending.promise); + controller.requestRevoke(SITE); + const first = controller.confirm(); + await controller.confirm(); + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(3)); + broadcast(); + controller.requestRevoke(SITE); + expect(send).toHaveBeenCalledTimes(3); + pending.resolve({ ok: true, grants: [] }); + await first; + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(5)); + expect(send.mock.calls.map(([m]) => m.type)).toEqual([ + 'SOLID_ORIGIN_GRANTS_LIST', + 'SOLID_PROFILES_LIST', + 'SOLID_ORIGIN_GRANT_REVOKE', + 'SOLID_ORIGIN_GRANTS_LIST', + 'SOLID_PROFILES_LIST', + ]); + }); + + it('refreshes on state broadcasts, ignores unrelated messages, and refetches a mid-read change', async () => { + const { controller, send, broadcast } = fixture(); + await controller.refresh(); + broadcast('UNRELATED'); + expect(send).toHaveBeenCalledTimes(2); + const pending = deferred(); + send.mockReturnValueOnce(pending.promise); + broadcast(); + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(3)); + broadcast(); + pending.resolve({ ok: true, grants: [] }); + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(6)); + expect(controller.state.phase).toBe('ready'); + }); + + it('keeps confirmation through a broadcast and rejects an action removed by that refresh', async () => { + const { controller, send, broadcast } = fixture(); + await controller.refresh(); + controller.requestRevoke(SITE); + send.mockResolvedValueOnce({ ok: true, grants: [] }); + broadcast(); + await vi.waitFor(() => expect(controller.state.phase).toBe('confirming')); + await controller.confirm(); + expect(send).toHaveBeenCalledTimes(4); + expect(controller.state.error).toContain("Couldn't update"); + }); + + it.each([ + undefined, + { error: 'Origin grant storage unavailable' }, + { ok: false }, + { ok: true }, + ])('handles a bad list response without exposing worker details: %j', async (response) => { + const { controller, send } = fixture(); + send.mockResolvedValueOnce(response as GrantsResponse); + await controller.refresh(); + expect(controller.state).toMatchObject({ phase: 'ready', loaded: false, grants: [] }); + expect(controller.state.error).toBe("Couldn't load sites with access. Try refreshing."); + }); + + it('reports account-list and transport errors, retains existing rows, and recovers', async () => { + const { controller, send } = fixture(); + await controller.refresh(); + send + .mockResolvedValueOnce({ ok: true, grants: [] }) + .mockResolvedValueOnce({ error: 'profiles' }); + await controller.refresh(); + expect(controller.state.grants).toEqual([siteGrant()]); + send.mockRejectedValueOnce(new Error('offline')); + await controller.refresh(); + expect(controller.state.error).toContain("Couldn't load"); + await controller.refresh(); + expect(controller.state.error).toBeNull(); + }); + + it.each([ + { error: 'Invalid origin grant' }, + { ok: false }, + { ok: true }, + ])('reports failed writes and allows retry: %j', async (response) => { + const { controller, send } = fixture(); + await controller.refresh(); + controller.requestRevoke(SITE); + send.mockResolvedValueOnce(response); + await controller.confirm(); + expect(controller.state.error).toBe( + "Couldn't update this site's access. Refresh and try again.", + ); + expect(controller.state.grants).toEqual([siteGrant()]); + controller.requestRevoke(SITE); + await controller.confirm(); + expect(controller.state.error).toBeNull(); + }); + + it('does not send a confirmed expiry that has elapsed while the dialog was open', async () => { + let now = NOW; + const send = vi.fn( + async (): Promise => ({ ok: true, grants: [siteGrant()], profiles }), + ); + const controller = new GrantsController({ send, now: () => now }); + await controller.refresh(); + controller.requestExpiry(SITE, '1 day'); + now += DAY; + await controller.confirm(); + expect(send).toHaveBeenCalledTimes(2); + expect(controller.state.error).toContain("Couldn't update"); + }); + + it('disposes subscriptions, queued work and late replies without notifying the view', async () => { + const { controller, send, broadcast, unsubscribe } = fixture(); + const listener = vi.fn(); + controller.subscribe(listener); + const pending = deferred(); + send.mockReturnValueOnce(pending.promise); + const refreshing = controller.refresh(); + await vi.waitFor(() => expect(send).toHaveBeenCalledOnce()); + const before = listener.mock.calls.length; + controller.dispose(); + controller.dispose(); + broadcast(); + pending.resolve({ ok: true, grants: [siteGrant()] }); + await refreshing; + await controller.refresh(); + expect(send).toHaveBeenCalledOnce(); + expect(unsubscribe).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledTimes(before); + expect(controller.state.phase).toBe('disposed'); + const late = vi.fn(); + controller.subscribe(late)(); + expect(late).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/extension/test/grants-fixtures.ts b/apps/extension/test/grants-fixtures.ts new file mode 100644 index 000000000..d2e4405b1 --- /dev/null +++ b/apps/extension/test/grants-fixtures.ts @@ -0,0 +1,59 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import type { OriginGrant } from '../src/background/core/origin-grant'; +import type { Profile } from '../src/shared/messages'; + +export const NOW = 1_800_000_000_000; +export const DAY = 86_400_000; +export const SITE = 'https://calendar.example'; +export const profiles: readonly Profile[] = [ + { + id: '1', + label: 'Personal account', + webId: 'https://pod.example/me#id', + issuer: 'https://pod.example', + dpopKeyRef: '1', + sessionState: 'active', + }, + { + id: '2', + label: 'Work', + webId: 'https://work.example/me#id', + issuer: 'https://work.example', + dpopKeyRef: '2', + sessionState: 'active', + avatar: 'https://work.example/photo.jpg', + }, + { + id: '3', + label: 'Old account', + webId: 'https://old.example/me#id', + issuer: 'https://old.example', + dpopKeyRef: '3', + sessionState: 'signed-out', + }, +]; + +export function siteGrant(overrides: Partial = {}): OriginGrant { + return { + origin: SITE, + profileId: '1', + createdAt: NOW - DAY, + scope: { + pods: ['https://pod.example'], + classes: [ + { + grantId: 'documents', + origin: SITE, + webId: profiles[0].webId, + dataClass: 'https://schema.org/TextDigitalDocument', + modes: ['read', 'append', 'write'], + purpose: null, + createdAt: NOW - DAY, + expiresAt: NOW + DAY * 365, + targets: [{ url: 'https://pod.example/docs/', kind: 'container' }], + }, + ], + }, + ...overrides, + }; +} diff --git a/apps/extension/test/grants-view.test.ts b/apps/extension/test/grants-view.test.ts new file mode 100644 index 000000000..f7023d3a0 --- /dev/null +++ b/apps/extension/test/grants-view.test.ts @@ -0,0 +1,295 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import { JSDOM } from 'jsdom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + GrantsController, + type GrantsMessage, + type GrantsResponse, +} from '../src/sidepanel/grants-controller'; +import { mountGrantsView } from '../src/sidepanel/grants-view'; +import { DAY, NOW, profiles, SITE, siteGrant } from './grants-fixtures'; + +let cleanup: (() => void) | undefined; +afterEach(() => { + cleanup?.(); + cleanup = undefined; +}); + +async function fixture(grants = [siteGrant()]) { + const dom = new JSDOM('
    '); + const doc = dom.window.document; + // jsdom lacks native dialog opening/closing and keyboard default actions. Real + // Tab movement and Enter/Space activation are covered by the E2E scenario. + dom.window.HTMLDialogElement.prototype.showModal = function () { + this.setAttribute('open', ''); + }; + dom.window.HTMLDialogElement.prototype.close = function () { + this.removeAttribute('open'); + }; + const send = vi.fn( + async (message: GrantsMessage): Promise => + message.type === 'SOLID_PROFILES_LIST' ? { profiles } : { ok: true, grants }, + ); + const controller = new GrantsController({ send, now: () => NOW }); + const root = doc.getElementById('grants')!; + const unmount = mountGrantsView(root, controller); + cleanup = () => { + unmount(); + controller.dispose(); + dom.window.close(); + }; + await vi.waitFor(() => expect(controller.state.loaded).toBe(true)); + const button = (name: string, scope: ParentNode = root) => { + const match = Array.from(scope.querySelectorAll('button')).find( + (node) => node.textContent === name, + ); + if (!match) throw new Error(`Button missing: ${name}`); + return match; + }; + return { dom, doc, root, controller, send, button, dialog: root.querySelector('dialog')! }; +} + +describe('sites with access view', () => { + it('renders site identity, account initials, since and nested plain-language data access', async () => { + const { root, button } = await fixture([siteGrant({ expiresAt: NOW + DAY * 90 })]); + expect(root.querySelector('article')?.getAttribute('aria-label')).toBe(SITE); + expect(root.textContent).toContain('Personal account'); + expect(root.querySelector('.grants-avatar')?.textContent).toBe('C'); + expect(root.querySelector('.grants-bound-account .grants-avatar')?.textContent).toBe('PA'); + expect(root.textContent).toContain('Since'); + expect(root.querySelectorAll('img')).toHaveLength(0); + const disclosure = button('Access details'); + const details = root.querySelector('.grants-details')!; + expect(details.hidden).toBe(true); + expect(disclosure.getAttribute('aria-expanded')).toBe('false'); + disclosure.focus(); + disclosure.click(); + expect(details.hidden).toBe(false); + expect(disclosure.getAttribute('aria-expanded')).toBe('true'); + expect(disclosure.getAttribute('aria-controls')).toBe(details.id); + expect(root.querySelector('.grants-class')?.textContent).toContain('Documents'); + expect(root.querySelector('.grants-class')?.textContent).toContain( + 'Read · Add · Edit and delete', + ); + expect(root.querySelector('.grants-class')?.textContent).toContain('Expires'); + expect(root.textContent).not.toMatch(/WebID|ACL|origin grant|https:\/\/schema.org/); + disclosure.click(); + expect(details.hidden).toBe(true); + }); + + it('uses native keyboard-operable controls and focuses Cancel on confirmation', async () => { + const { doc, button, dialog, send, controller, dom } = await fixture(); + const revoke = button('Revoke'); + expect(revoke.tagName).toBe('BUTTON'); + expect(revoke.type).toBe('button'); + revoke.focus(); + expect(doc.activeElement).toBe(revoke); + // Emulate the native activation jsdom omits; there are no pointer-only handlers. + revoke.click(); + expect(dialog.open).toBe(true); + expect(dialog.getAttribute('aria-labelledby')).toBe(dialog.querySelector('h3')?.id); + expect(doc.activeElement?.textContent).toBe('Cancel'); + expect(send).toHaveBeenCalledTimes(2); + dialog.dispatchEvent(new dom.window.Event('cancel', { cancelable: true })); + expect(dialog.open).toBe(false); + expect(controller.state.confirmation).toBeNull(); + expect(doc.activeElement).toBe(button('Revoke')); + expect(send).toHaveBeenCalledTimes(2); + }); + + it('returns focus to the focused control after a background refresh disables it', async () => { + const { doc, button, send, controller } = await fixture(); + const revoke = button('Revoke'); + revoke.focus(); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const respond = send.getMockImplementation()!; + send.mockImplementationOnce(async (message) => { + await held; + return respond(message); + }); + const refreshed = controller.refresh(); + expect(button('Revoke').disabled).toBe(true); + expect(doc.activeElement?.id).toBe('grants-heading'); + release(); + await refreshed; + expect(doc.activeElement).toBe(button('Revoke')); + // A deliberate move elsewhere is not overridden by a later render. + button('Refresh').focus(); + await controller.refresh(); + expect(doc.activeElement).toBe(button('Refresh')); + }); + + it('wraps Tab and Shift+Tab inside the confirmation dialog', async () => { + const { doc, button, dialog, dom } = await fixture(); + button('Revoke').click(); + const cancel = button('Cancel', dialog); + const confirm = button('Revoke access', dialog); + const tab = (shiftKey = false) => { + const event = new dom.window.KeyboardEvent('keydown', { + key: 'Tab', + shiftKey, + cancelable: true, + bubbles: true, + }); + doc.activeElement!.dispatchEvent(event); + return event.defaultPrevented; + }; + expect(doc.activeElement).toBe(cancel); + // Moving forward from the first button is left to the browser. + expect(tab()).toBe(false); + confirm.focus(); + expect(tab()).toBe(true); + expect(doc.activeElement).toBe(cancel); + expect(tab(true)).toBe(true); + expect(doc.activeElement).toBe(confirm); + const other = new dom.window.KeyboardEvent('keydown', { key: 'Enter', cancelable: true }); + dialog.dispatchEvent(other); + expect(other.defaultPrevented).toBe(false); + expect(doc.activeElement).toBe(confirm); + confirm.disabled = true; + cancel.focus(); + expect(tab()).toBe(true); + expect(doc.activeElement).toBe(cancel); + cancel.disabled = true; + expect(tab()).toBe(false); + }); + + it('confirms revocation, announces the result and focuses the heading when the site is removed', async () => { + const { root, button, dialog, send, doc, controller } = await fixture(); + const revoke = button('Revoke'); + revoke.focus(); + revoke.click(); + send.mockResolvedValueOnce({ ok: true, grants: [] }); + button('Revoke access', dialog).click(); + await vi.waitFor(() => expect(controller.state.phase).toBe('ready')); + expect(root.textContent).toContain('No sites have access yet'); + expect(root.querySelector('[role="status"]')?.textContent).toContain('Access revoked'); + expect(root.querySelector('[role="alert"]')?.textContent).toBe(''); + expect(doc.activeElement).toBe(root.querySelector('h2')); + }); + + it('warns before changing account, disables signed-out accounts and preserves selection on cancel', async () => { + const { root, button, send, dialog, dom, doc, controller } = await fixture(); + button('Access details').click(); + const picker = root.querySelector('select')!; + expect(picker.options[2].disabled).toBe(true); + expect(picker.options[2].textContent).toContain('sign in'); + picker.focus(); + picker.value = '2'; + picker.dispatchEvent(new dom.window.Event('change')); + expect(dialog.textContent).toContain("clears this site's data access"); + expect(dialog.textContent).toContain('The site must ask again'); + expect(send).toHaveBeenCalledTimes(2); + button('Cancel', dialog).click(); + expect(root.querySelector('select')?.value).toBe('1'); + expect(doc.activeElement).toBe(root.querySelector('select')); + const retry = root.querySelector('select')!; + retry.value = '2'; + retry.dispatchEvent(new dom.window.Event('change')); + send.mockResolvedValueOnce({ + ok: true, + grants: [siteGrant({ profileId: '2', scope: { pods: [], classes: [] } })], + }); + button('Change account', dialog).click(); + await vi.waitFor(() => expect(controller.state.phase).toBe('ready')); + expect(root.querySelector('.grants-bound-account')?.textContent).toContain('Work'); + expect(root.querySelector('img')?.src).toBe(profiles[1].avatar); + expect(root.querySelector('img')?.referrerPolicy).toBe('no-referrer'); + expect(root.querySelector('.grants-details')?.textContent).toContain('No data access'); + }); + + it('disables longer duration chips with an accessible reason and confirms shortening', async () => { + const { root, button, dialog, controller, send } = await fixture([ + siteGrant({ expiresAt: NOW + DAY * 90 }), + ]); + button('Access details').click(); + for (const label of ['Until revoked', '1 year']) { + const chip = button(label); + expect(chip.disabled).toBe(true); + expect(chip.title).toContain('site must ask again'); + expect( + root.ownerDocument.getElementById(chip.getAttribute('aria-describedby')!)?.textContent, + ).toContain('Only shorter durations'); + } + expect(button('1 day').disabled).toBe(false); + button('1 day').focus(); + button('1 day').click(); + expect(dialog.textContent).toContain('1 day'); + expect(send).toHaveBeenCalledTimes(2); + button('Shorten access', dialog).click(); + await vi.waitFor(() => expect(controller.state.phase).toBe('ready')); + expect(send).toHaveBeenLastCalledWith({ + type: 'SOLID_ORIGIN_GRANT_EXPIRY', + origin: SITE, + expiresAt: NOW + DAY, + }); + }); + + it('keeps status and alert regions mounted across failures and retry', async () => { + const { root, controller, send } = await fixture(); + const alert = root.querySelector('[role="alert"]'); + const status = root.querySelector('[role="status"]'); + expect(status?.getAttribute('aria-atomic')).toBe('true'); + send.mockRejectedValueOnce(new Error('technical internal details')); + await controller.refresh(); + expect(root.querySelector('[role="alert"]')).toBe(alert); + expect(alert?.textContent).toBe("Couldn't load sites with access. Try refreshing."); + expect(root.querySelectorAll('article')).toHaveLength(1); + await controller.refresh(); + expect(alert?.textContent).toBe(''); + expect(root.querySelector('[role="status"]')).toBe(status); + }); + + it('uses text content for unfamiliar data labels and account names, with no injected markup', async () => { + const grant = siteGrant(); + const { root, controller, send } = await fixture([ + siteGrant({ + scope: { + pods: [], + classes: [ + { + ...grant.scope.classes![0], + dataClass: 'https://example.test/#%3Cscript%3Ealert(1)%3C/script%3E', + }, + ], + }, + }), + ]); + expect(root.querySelectorAll('script')).toHaveLength(0); + send.mockResolvedValueOnce({ ok: true, grants: [grant] }).mockResolvedValueOnce({ + profiles: [ + { ...profiles[0], label: '', avatar: 'javascript:alert(1)' }, + ], + }); + await controller.refresh(); + expect(root.querySelector('.grants-bound-account')?.textContent).toContain(' { + const { root, controller, send, button } = await fixture([]); + expect(root.textContent).toContain('No sites have access yet'); + send.mockResolvedValueOnce({ + ok: true, + grants: [siteGrant({ profileId: 'missing', scope: { pods: [] } })], + }); + await controller.refresh(); + expect(root.textContent).toContain('Unavailable account'); + expect(root.textContent).toContain('All data in connected storage'); + expect(root.querySelector('select')?.value).toBe('missing'); + button('Access details').click(); + expect(root.querySelector('.grants-details')?.hidden).toBe(false); + }); + + it('removes view listeners on unmount without receiving later state changes', async () => { + const { root, controller } = await fixture(); + cleanup?.(); + cleanup = undefined; + expect(root.childNodes).toHaveLength(0); + await controller.refresh(); + expect(root.childNodes).toHaveLength(0); + }); +}); diff --git a/apps/extension/test/inject.test.ts b/apps/extension/test/inject.test.ts new file mode 100644 index 000000000..ee983486d --- /dev/null +++ b/apps/extension/test/inject.test.ts @@ -0,0 +1,359 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// The MAIN-world inject (window.solid + the global-fetch transparency patch). These cover the +// two roborev findings against inject.ts: +// - HIGH #1: the global-fetch patch routes a cross-origin request but marks it `autoDivert` +// so the SW can native-passthrough a non-pod/un-granted request (the page-compat invariant +// is enforced SW-side; here we prove the inject sends the flag + leaves same-origin native). +// - MEDIUM #2: a DOUBLE injection (manifest + dynamic registration) is INERT — the second run +// is a no-op, never a throw on the `configurable:false` defineProperty. +// +// Each test gets a FRESH JSDOM window (the inject defines `window.solid` non-configurable, so a +// shared window could not be reset between tests). We rebind the relevant globals to that +// window, then re-import the module (which installs at import) under a reset module registry. + +import { JSDOM } from 'jsdom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let dom: JSDOM; +let posted: Array>; +let nativeFetch: ReturnType; +const savedGlobals: Record = {}; + +function setGlobal(name: string, value: unknown): void { + if (!(name in savedGlobals)) { + savedGlobals[name] = Object.getOwnPropertyDescriptor(globalThis, name); + } + Object.defineProperty(globalThis, name, { value, configurable: true, writable: true }); +} + +function freshWindow(): void { + dom = new JSDOM('', { + url: 'https://app.example/page.html', + }); + const win = dom.window as unknown as Window & typeof globalThis; + posted = []; + // Capture page→content messages. + win.postMessage = ((msg: unknown) => { + posted.push(msg as Record); + }) as typeof win.postMessage; + // jsdom does NOT implement fetch/Response; Node's globals do. Keep Node's Response/Headers/URL + // (the inject builds Responses with them) and only swap in the jsdom window + a stub fetch. + nativeFetch = vi.fn(async () => new Response('native', { status: 200 })); + win.fetch = nativeFetch as unknown as typeof fetch; + let n = 0; + const cryptoStub = { randomUUID: () => `uuid-${n++}` }; + // jsdom's window.crypto is a getter-only accessor — redefine it. + Object.defineProperty(win, 'crypto', { configurable: true, value: cryptoStub }); + // The module references the bare globals `window` + `crypto`; `Response`/`Headers`/`URL` stay + // Node's (jsdom's are non-functional here), which the inject's `new URL(...)` etc. need. + setGlobal('window', win); + setGlobal('crypto', cryptoStub); +} + +/** Fresh module each test (the inject installs at import). */ +async function importInject(): Promise { + vi.resetModules(); + await import('../src/inject/inject'); + dom.window.dispatchEvent( + new dom.window.MessageEvent('message', { + source: dom.window, + data: { + channel: 'solid-browser-ext', + dir: 'to-page', + type: 'SOLID_STATE_UPDATE', + webId: 'https://pod.example/#me', + }, + }), + ); +} + +beforeEach(() => { + freshWindow(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + for (const [name, desc] of Object.entries(savedGlobals)) { + if (desc) Object.defineProperty(globalThis, name, desc); + else delete (globalThis as Record)[name]; + delete savedGlobals[name]; + } + dom.window.close(); +}); + +describe('inject — installs window.solid + patches fetch', () => { + it('defines window.solid and patches window.fetch on first run', async () => { + expect('solid' in dom.window).toBe(false); + await importInject(); + expect('solid' in dom.window).toBe(true); + expect(typeof (dom.window as { solid?: { fetch?: unknown } }).solid?.fetch).toBe('function'); + // It asked the worker for state on load. + expect(posted.some((m) => m.type === 'SOLID_GET_STATE')).toBe(true); + // The global fetch is no longer the pristine sentinel. + expect(dom.window.fetch).not.toBe(nativeFetch); + }); + + it('announces presence: a sticky marker + a solid-extension:ready event', async () => { + // A page hides its own account chrome when the extension is present; it needs a reliable, + // race-free presence signal. Assert BOTH announce channels the inject sets up. + const readyEvents: Event[] = []; + dom.window.addEventListener('solid-extension:ready', (e) => readyEvents.push(e)); + expect(dom.window.document.documentElement.getAttribute('data-solid-extension')).toBeNull(); + await importInject(); + // 1. the STICKY DOM marker — readable synchronously by app code regardless of load order. + expect(dom.window.document.documentElement.getAttribute('data-solid-extension')).toBe('1'); + // 2. the one-shot event — for a listener attached before the inject ran. + expect(readyEvents).toHaveLength(1); + // The announce carries NO identity: webId stays on window.solid (null until auth). + expect((dom.window as unknown as { solid: { webId: unknown } }).solid.webId).toBe( + 'https://pod.example/#me', + ); + }); +}); + +describe('inject — HIGH #1: the global-fetch patch marks routed requests autoDivert', () => { + it('a CROSS-ORIGIN fetch is routed to the SW with autoDivert=true (so the SW can native-pass)', async () => { + await importInject(); + posted.length = 0; + // Fire a cross-origin fetch (a third-party API the app calls). It is routed (not awaited). + void dom.window.fetch('https://api.thirdparty.example/data'); + const routed = posted.find((m) => m.type === 'SOLID_FETCH_REQUEST'); + expect(routed).toBeDefined(); + expect(routed?.url).toBe('https://api.thirdparty.example/data'); + // The flag that lets the SW native-passthrough instead of hard-failing the page. + expect(routed?.autoDivert).toBe(true); + // It did NOT go straight to the native fetch (it was diverted to the bridge). + expect(nativeFetch).not.toHaveBeenCalled(); + }); + + it('a SAME-ORIGIN fetch stays on the untouched NATIVE fetch (no proxy tax, never routed)', async () => { + await importInject(); + posted.length = 0; + await dom.window.fetch('https://app.example/assets/app.js'); + // No routing message — same-origin asset went straight to native. + expect(posted.some((m) => m.type === 'SOLID_FETCH_REQUEST')).toBe(false); + expect(nativeFetch).toHaveBeenCalledTimes(1); + }); + + it('a RELATIVE fetch stays native (resolves to same-origin)', async () => { + await importInject(); + posted.length = 0; + await dom.window.fetch('/api/local'); + expect(posted.some((m) => m.type === 'SOLID_FETCH_REQUEST')).toBe(false); + expect(nativeFetch).toHaveBeenCalledTimes(1); + }); + + it('a non-http(s) scheme (data:) stays native (never routed)', async () => { + await importInject(); + posted.length = 0; + await dom.window.fetch('data:text/plain,hello'); + expect(posted.some((m) => m.type === 'SOLID_FETCH_REQUEST')).toBe(false); + expect(nativeFetch).toHaveBeenCalledTimes(1); + }); + + it('an EXPLICIT window.solid.fetch carries autoDivert=false (keeps the SW 403, not passthrough)', async () => { + await importInject(); + posted.length = 0; + const solid = (dom.window as unknown as { solid: { fetch: (u: string) => Promise } }) + .solid; + void solid.fetch('https://alice.pod.example/private/notes.ttl'); + const routed = posted.find((m) => m.type === 'SOLID_FETCH_REQUEST'); + expect(routed).toBeDefined(); + // The explicit API path is NOT auto-diverted — a deny here is the caller's expected 403. + expect(routed?.autoDivert).toBe(false); + }); +}); + +// --- Round-2 High #1: the page-side passthrough sentinel handler ------------------------ +// +// When the SW returns `{ passthrough: true }` (it declined an autoDivert request and did NO +// network), the inject MUST complete the pending promise by calling the page's OWN native fetch +// (PRISTINE_FETCH) with the ORIGINAL input/init — never fabricate a Response from SW data. + +/** Deliver a `to-page` message into the inject's window 'message' listener. */ +function deliverToPage(message: Record): void { + const win = dom.window as unknown as Window; + const event = new dom.window.MessageEvent('message', { + data: { channel: 'solid-browser-ext', dir: 'to-page', ...message }, + source: win as unknown as MessageEventSource, + }); + win.dispatchEvent(event); +} + +describe('inject — Round-2 High #1: a passthrough sentinel is completed by the page native fetch', () => { + it('resolves a routed fetch by calling PRISTINE_FETCH with the ORIGINAL input + init, NOT a fabricated Response', async () => { + await importInject(); + posted.length = 0; + // Make the native fetch return a distinctive body so we can prove the resolved Response came + // from PRISTINE_FETCH, not from any SW-supplied bytes. + nativeFetch.mockResolvedValueOnce(new Response('NATIVE-IN-PAGE-BYTES', { status: 207 })); + + const init: RequestInit = { method: 'POST', headers: { 'X-App': 'v1' }, body: 'payload' }; + const promise = ( + dom.window as unknown as { fetch: (u: string, i?: RequestInit) => Promise } + ).fetch('https://api.thirdparty.example/data', init); + + // The request was routed to the SW with autoDivert=true; nativeFetch not called YET. + const routed = posted.find((m) => m.type === 'SOLID_FETCH_REQUEST'); + expect(routed).toBeDefined(); + expect(routed?.autoDivert).toBe(true); + expect(nativeFetch).not.toHaveBeenCalled(); + const requestId = routed?.requestId as string; + + // The SW replies with the passthrough SENTINEL (no body/status — it did no network). + deliverToPage({ type: 'SOLID_FETCH_RESPONSE', requestId, passthrough: true }); + + const response = await promise; + // The promise resolved with the PAGE's native fetch result (the SW gave no bytes). + expect(await response.text()).toBe('NATIVE-IN-PAGE-BYTES'); + expect(response.status).toBe(207); + // PRISTINE_FETCH was called with the ORIGINAL input + a faithful init SNAPSHOT (round-3 + // Medium: the init is COPIED at call time, so it is value-equal but NOT the same reference). + expect(nativeFetch).toHaveBeenCalledTimes(1); + const [calledInput, calledInit] = nativeFetch.mock.calls[0] as [string, RequestInit]; + expect(calledInput).toBe('https://api.thirdparty.example/data'); + expect(calledInit).not.toBe(init); // a snapshot copy, not the caller's object + expect(calledInit.method).toBe('POST'); + expect(calledInit.body).toBe('payload'); + expect(new Headers(calledInit.headers).get('X-App')).toBe('v1'); + }); + + it('round-3 Medium: the passthrough request is SNAPSHOT at call time — a later mutation of init.headers / init.credentials / a URL input cannot change it', async () => { + await importInject(); + posted.length = 0; + nativeFetch.mockResolvedValueOnce(new Response('OK', { status: 200 })); + + // A mutable URL object + an init the caller will mutate AFTER the call (as native fetch would + // have already snapshotted them synchronously). + const url = new dom.window.URL('https://api.thirdparty.example/data'); + const headers = new dom.window.Headers({ 'X-App': 'v1' }); + const init: RequestInit = { + method: 'GET', + headers: headers as unknown as HeadersInit, + credentials: 'include', + }; + const promise = ( + dom.window as unknown as { fetch: (u: URL, i?: RequestInit) => Promise } + ).fetch(url as unknown as URL, init); + + const routed = posted.find((m) => m.type === 'SOLID_FETCH_REQUEST'); + const requestId = routed?.requestId as string; + expect(nativeFetch).not.toHaveBeenCalled(); + + // MUTATE the caller's objects after the call but BEFORE the sentinel arrives. + url.pathname = '/HIJACKED'; + headers.set('X-App', 'TAMPERED'); + headers.set('X-Injected', 'yes'); + init.credentials = 'omit'; + + deliverToPage({ type: 'SOLID_FETCH_RESPONSE', requestId, passthrough: true }); + await promise; + + // PRISTINE_FETCH saw the values AS THEY WERE AT CALL TIME, not the mutated ones. + const [calledInput, calledInit] = nativeFetch.mock.calls[0] as [ + string | URL, + RequestInit | undefined, + ]; + expect(calledInput.toString()).toBe('https://api.thirdparty.example/data'); + expect(new Headers(calledInit?.headers).get('X-App')).toBe('v1'); + expect(new Headers(calledInit?.headers).has('X-Injected')).toBe(false); + expect(calledInit?.credentials).toBe('include'); + }); + + it('a passthrough sentinel that REJECTS (CORS block) rejects the page promise exactly as native', async () => { + await importInject(); + posted.length = 0; + const corsError = new TypeError('Failed to fetch'); + nativeFetch.mockRejectedValueOnce(corsError); + + const promise = (dom.window as unknown as { fetch: (u: string) => Promise }).fetch( + 'https://blocked.example/data', + ); + const routed = posted.find((m) => m.type === 'SOLID_FETCH_REQUEST'); + const requestId = routed?.requestId as string; + + deliverToPage({ type: 'SOLID_FETCH_RESPONSE', requestId, passthrough: true }); + + await expect(promise).rejects.toBe(corsError); + }); + + it('a NON-passthrough SW response (gated path) still resolves from SW data, never the native fetch', async () => { + await importInject(); + posted.length = 0; + const solid = ( + dom.window as unknown as { + solid: { fetch: (u: string) => Promise }; + } + ).solid; + // Explicit window.solid.fetch → autoDivert=false → never a passthrough sentinel. + const promise = solid.fetch('https://alice.pod.example/private/notes.ttl'); + const routed = posted.find((m) => m.type === 'SOLID_FETCH_REQUEST'); + expect(routed?.autoDivert).toBe(false); + const requestId = routed?.requestId as string; + + // The SW returns real (gated, authenticated) bytes — no passthrough flag. + deliverToPage({ + type: 'SOLID_FETCH_RESPONSE', + requestId, + status: 200, + body: 'GATED-POD-BYTES', + headers: {}, + }); + + const response = await promise; + expect(await response.text()).toBe('GATED-POD-BYTES'); + expect(response.status).toBe(200); + // The native fetch was NOT used on the gated path. + expect(nativeFetch).not.toHaveBeenCalled(); + }); +}); + +describe('inject — MEDIUM #2: a double injection is INERT (no throw on the second run)', () => { + it('importing/running the inject TWICE does not throw and installs window.solid once', async () => { + await importInject(); + expect('solid' in dom.window).toBe(true); + const firstSolid = (dom.window as unknown as { solid: unknown }).solid; + + // A second run (the other injection mechanism / a reload) must be a silent no-op — NOT a + // throw on Object.defineProperty(window,'solid',{configurable:false}). + await expect(importInject()).resolves.toBeUndefined(); + // window.solid is unchanged (the configurable:false property was never re-defined). + expect((dom.window as unknown as { solid: unknown }).solid).toBe(firstSolid); + }); + + it('the guard short-circuits when window.solid already exists (different prior code path)', async () => { + // Simulate a prior install from another path (window.solid present, marker absent). + Object.defineProperty(dom.window, 'solid', { + value: Object.freeze({ sentinel: true }), + configurable: false, + }); + delete (dom.window as { __solidInjected?: boolean }).__solidInjected; + // Must not throw, and must NOT clobber the existing (frozen, non-configurable) property. + await expect(importInject()).resolves.toBeUndefined(); + expect((dom.window as unknown as { solid: { sentinel?: boolean } }).solid.sentinel).toBe(true); + }); +}); + +it('keeps an ungranted origin stub-only, preserving native fetch; removes capabilities on revoke', async () => { + vi.resetModules(); + await import('../src/inject/inject'); + const stub = (dom.window as unknown as { solid: Record }).solid; + expect(Object.keys(stub).sort()).toEqual(['login', 'requestAccess']); + expect(stub.webId).toBeUndefined(); + expect(stub.fetch).toBeUndefined(); + expect(dom.window.fetch).toBe(nativeFetch); + const update = (webId: string | null) => + dom.window.dispatchEvent( + new dom.window.MessageEvent('message', { + source: dom.window, + data: { channel: 'solid-browser-ext', dir: 'to-page', type: 'SOLID_STATE_UPDATE', webId }, + }), + ); + update('https://pod.example/#me'); + expect(typeof stub.fetch).toBe('function'); + expect(dom.window.fetch).not.toBe(nativeFetch); + update(null); + expect(Object.keys(stub).sort()).toEqual(['login', 'requestAccess']); + expect(dom.window.fetch).toBe(nativeFetch); +}); diff --git a/apps/extension/test/jsdom.d.ts b/apps/extension/test/jsdom.d.ts new file mode 100644 index 000000000..6a37bf662 --- /dev/null +++ b/apps/extension/test/jsdom.d.ts @@ -0,0 +1,16 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// Minimal ambient declaration for the `jsdom` package (already a devDependency) so the inject +// test can `import { JSDOM }` without `@types/jsdom` — adding `@types/jsdom` would be a new +// dependency (package-policy gate). We declare ONLY the surface the test uses. + +declare module 'jsdom' { + export interface ConstructorOptions { + url?: string; + } + export class JSDOM { + reconfigure(options: { url: string }): void; + constructor(html?: string, options?: ConstructorOptions); + readonly window: Window & typeof globalThis; + } +} diff --git a/apps/extension/test/message-bridge-controller.test.ts b/apps/extension/test/message-bridge-controller.test.ts new file mode 100644 index 000000000..fda6f948c --- /dev/null +++ b/apps/extension/test/message-bridge-controller.test.ts @@ -0,0 +1,569 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * Unit tests for the popup's MessageBridgeLoginController — the chrome.identity-backed + * LoginController that bridges the panel's SYNCHRONOUS getter contract to the ASYNC + * service worker over the existing message protocol, WITHOUT ever holding a token. + * + * The credential boundary is the load-bearing assertion: the bridge must (a) expose the + * module-pristine fetch as publicFetch, (b) never carry a token in any own property, + * (c) never surface a (hypothetical) token field from a GET_STATE reply, and (d) proxy + * authenticatedFetch via SOLID_FETCH_REQUEST with NO Authorization/DPoP header attached + * popup-side (the token is SW-side only). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MessageBridgeLoginController } from '../src/popup/message-bridge-controller'; +import type { SessionState } from '../src/shared/messages'; + +/** A mock of `chrome.runtime.sendMessage` (callback form) returning a canned reply. */ +type SendImpl = (message: unknown) => unknown; +let sendImpl: SendImpl; +const sentMessages: unknown[] = []; + +function installChrome(impl: SendImpl): void { + sendImpl = impl; + vi.stubGlobal('chrome', { + runtime: { + lastError: undefined as { message?: string } | undefined, + sendMessage: (message: unknown, callback: (response: unknown) => void) => { + sentMessages.push(message); + // Resolve asynchronously, like the real callback channel. + Promise.resolve(sendImpl(message)).then((r) => callback(r)); + }, + }, + }); +} + +function activeState(overrides: Partial = {}): SessionState { + return { + webId: 'https://alice.pod.example/profile/card#me', + isActive: true, + name: 'Alice', + photoUrl: 'https://alice.pod.example/avatar.png', + recentAccounts: [], + ...overrides, + }; +} + +beforeEach(() => { + sentMessages.length = 0; +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('restore() — silent restore round-trip + fail-closed', () => { + it('sends SOLID_GET_STATE and resolves restored when isActive && webId', async () => { + installChrome(() => activeState()); + const c = new MessageBridgeLoginController(); + const outcome = await c.restore(); + expect(sentMessages).toContainEqual({ type: 'SOLID_GET_STATE' }); + expect(outcome).toEqual({ + outcome: 'restored', + webId: 'https://alice.pod.example/profile/card#me', + }); + }); + + it('resolves login when no active session', async () => { + installChrome(() => activeState({ webId: null, isActive: false, name: null, photoUrl: null })); + const c = new MessageBridgeLoginController(); + expect(await c.restore()).toEqual({ outcome: 'login' }); + }); + + it('FAIL-CLOSED: resolves login (never throws) when the send rejects', async () => { + installChrome(() => { + throw new Error('channel error'); + }); + const c = new MessageBridgeLoginController(); + await expect(c.restore()).resolves.toEqual({ outcome: 'login' }); + }); + + it('FAIL-CLOSED: a malformed state shape never leaves a half-set non-null webId', async () => { + // First a clean active session (sets the mirror), THEN a malformed reply on hydrate. + let call = 0; + installChrome(() => { + call += 1; + if (call === 1) return activeState(); // restore() → logged in + // hydrate() → recentAccounts is not an array → toRecentLoginAccount map throws. + return { webId: 'https://x/#me', isActive: true, recentAccounts: 'not-an-array' }; + }); + const c = new MessageBridgeLoginController(); + await c.restore(); + expect(c.webId).toBe('https://alice.pod.example/profile/card#me'); + // hydrate() swallows the throw — and must NOT have committed the malformed webId. + await c.hydrate(); + expect(c.webId).toBe('https://alice.pod.example/profile/card#me'); // unchanged, atomic + }); +}); + +describe('the synchronous seam is satisfied from the async source', () => { + it('webId + recentAccounts read synchronously after restore()/hydrate()', async () => { + installChrome(() => + activeState({ + recentAccounts: [ + { webId: 'https://bob.pod.example/card#me', name: 'Bob', photoUrl: null }, + { webId: 'https://carol.pod.example/card#me', name: null, photoUrl: 'https://c/p.png' }, + ], + }), + ); + const c = new MessageBridgeLoginController(); + await c.restore(); + + // Synchronous getters now answer from the mirror. + expect(c.webId).toBe('https://alice.pod.example/profile/card#me'); + expect(c.recentAccounts()).toEqual([ + { webId: 'https://bob.pod.example/card#me', displayName: 'Bob', avatarUrl: undefined }, + { + webId: 'https://carol.pod.example/card#me', + displayName: 'https://carol.pod.example/card#me', + avatarUrl: 'https://c/p.png', + }, + ]); + }); +}); + +describe('login()', () => { + it('sends SOLID_LOGIN, sets the mirror and resolves on ok', async () => { + installChrome((m) => { + if ((m as { type: string }).type === 'SOLID_LOGIN') { + return { ok: true, webId: 'https://x.example/#me' }; + } + return activeState(); + }); + const c = new MessageBridgeLoginController(); + const result = await c.login('https://x.example/#me'); + expect(sentMessages).toContainEqual({ type: 'SOLID_LOGIN', webId: 'https://x.example/#me' }); + expect(result).toEqual({ webId: 'https://x.example/#me' }); + expect(c.webId).toBe('https://x.example/#me'); + }); + + it('REJECTS with the error message and does NOT mutate #webId on error', async () => { + installChrome(() => ({ error: 'User cancelled' })); + const c = new MessageBridgeLoginController(); + await expect(c.login('https://x.example/#me')).rejects.toThrow('User cancelled'); + expect(c.webId).toBeNull(); + }); + + it('defaults to the single recent account webId when none is passed', async () => { + installChrome((m) => { + if ((m as { type: string }).type === 'SOLID_GET_STATE') { + return activeState({ + webId: null, + isActive: false, + name: null, + photoUrl: null, + recentAccounts: [{ webId: 'https://recent.example/#me', name: 'R', photoUrl: null }], + }); + } + return { ok: true, webId: 'https://recent.example/#me' }; + }); + const c = new MessageBridgeLoginController(); + await c.hydrate(); // populate the recent-accounts mirror + await c.login(); + expect(sentMessages).toContainEqual({ + type: 'SOLID_LOGIN', + webId: 'https://recent.example/#me', + }); + }); +}); + +describe('logout()', () => { + it('sends SOLID_LOGOUT and clears #webId on ok', async () => { + installChrome((m) => { + if ((m as { type: string }).type === 'SOLID_LOGOUT') return { ok: true }; + return activeState(); + }); + const c = new MessageBridgeLoginController(); + await c.restore(); // sets #webId + expect(c.webId).not.toBeNull(); + await c.logout(); + expect(sentMessages).toContainEqual({ type: 'SOLID_LOGOUT' }); + expect(c.webId).toBeNull(); + }); + + it('on error rejects AND leaves #webId intact (panel keeps signed-in UI)', async () => { + installChrome((m) => { + const type = (m as { type: string }).type; + if (type === 'SOLID_GET_STATE') return activeState(); + if (type === 'SOLID_LOGOUT') return { error: 'Logout failed' }; + return {}; + }); + const c = new MessageBridgeLoginController(); + await c.restore(); + const before = c.webId; + await expect(c.logout()).rejects.toThrow('Logout failed'); + expect(c.webId).toBe(before); + expect(c.webId).not.toBeNull(); + }); +}); + +describe('NO-TOKEN-LEAK invariant', () => { + it('publicFetch is STRICTLY the module-pristine fetch snapshot (not the live global)', async () => { + installChrome(() => activeState()); + const c = new MessageBridgeLoginController(); + const before = c.publicFetch; + + // Even if some later code patches globalThis.fetch, publicFetch keeps the pristine snapshot. + const original = globalThis.fetch; + try { + const patched = (() => Promise.resolve(new Response('patched'))) as typeof fetch; + globalThis.fetch = patched; + expect(c.publicFetch).toBe(before); + expect(c.publicFetch).not.toBe(patched); + } finally { + globalThis.fetch = original; + } + }); + + it('the controller has NO own property holding a token, even after a session is active', async () => { + installChrome(() => activeState()); + const c = new MessageBridgeLoginController(); + await c.restore(); + + // Private #fields are not enumerable/own-accessible; assert no public surface leaks one. + const ownKeys = [ + ...Object.getOwnPropertyNames(c), + ...Object.keys(c as unknown as Record), + ]; + const serialised = JSON.stringify(Object.getOwnPropertyDescriptors(c)); + for (const forbidden of ['accessToken', 'refreshToken', 'dpop', 'privateKey', 'idToken']) { + expect(ownKeys.some((k) => k.toLowerCase().includes(forbidden.toLowerCase()))).toBe(false); + expect(serialised.toLowerCase()).not.toContain(forbidden.toLowerCase()); + } + }); + + it('a hypothetical accessToken in a GET_STATE reply is NEVER surfaced via any getter', async () => { + // A hostile/extra field on the wire must not flow into the mirror or its outputs. + installChrome(() => ({ + ...activeState({ + recentAccounts: [{ webId: 'https://bob/#me', name: 'Bob', photoUrl: null }], + }), + accessToken: 'SECRET-TOKEN', + refreshToken: 'SECRET-REFRESH', + })); + const c = new MessageBridgeLoginController(); + await c.restore(); + + expect(c.webId).toBe('https://alice.pod.example/profile/card#me'); + const recents = JSON.stringify(c.recentAccounts()); + expect(recents).not.toContain('SECRET-TOKEN'); + expect(recents).not.toContain('SECRET-REFRESH'); + // No recent account carries a token field. + for (const acct of c.recentAccounts()) { + expect(Object.keys(acct).sort()).toEqual(['avatarUrl', 'displayName', 'webId']); + } + }); + + it('authenticatedFetch sends SOLID_FETCH_REQUEST with NO Authorization/DPoP header popup-side', async () => { + installChrome((m) => { + if ((m as { type: string }).type === 'SOLID_FETCH_REQUEST') { + return { + requestId: (m as { requestId: string }).requestId, + status: 200, + statusText: 'OK', + headers: { 'content-type': 'text/turtle' }, + body: '<#it> a <#Note>.', + }; + } + return activeState(); + }); + const c = new MessageBridgeLoginController(); + await c.restore(); // log in so authenticatedFetch is the proxy (not pristine) + const res = await c.authenticatedFetch('https://alice.pod.example/private/notes.ttl'); + + // It went over SOLID_FETCH_REQUEST, not a direct fetch. + const fetchReq = sentMessages.find( + (m) => (m as { type?: string }).type === 'SOLID_FETCH_REQUEST', + ) as { url: string; method: string; headers: Record }; + expect(fetchReq).toBeDefined(); + expect(fetchReq.url).toBe('https://alice.pod.example/private/notes.ttl'); + expect(fetchReq.method).toBe('GET'); + // CRITICAL: no credential attached popup-side — the SW attaches the DPoP token. + const headerKeys = Object.keys(fetchReq.headers).map((k) => k.toLowerCase()); + expect(headerKeys).not.toContain('authorization'); + expect(headerKeys).not.toContain('dpop'); + + // The Response is faithfully reconstructed from the FetchResponse. + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('text/turtle'); + expect(await res.text()).toBe('<#it> a <#Note>.'); + }); + + it('authenticatedFetch surfaces a SW error (e.g. the SW session expired mid-popup)', async () => { + installChrome((m) => { + if ((m as { type: string }).type === 'SOLID_FETCH_REQUEST') { + return { requestId: (m as { requestId: string }).requestId, error: 'Not authenticated' }; + } + return activeState(); + }); + const c = new MessageBridgeLoginController(); + await c.restore(); // log in so authenticatedFetch is the proxy (not pristine) + await expect(c.authenticatedFetch('https://alice.pod.example/x')).rejects.toThrow( + 'Not authenticated', + ); + }); + + it('BEFORE login, authenticatedFetch is the pristine fetch (contract: no session to bind)', async () => { + installChrome(() => activeState({ webId: null, isActive: false, name: null, photoUrl: null })); + const c = new MessageBridgeLoginController(); + await c.restore(); // no active session → #webId stays null + expect(c.webId).toBeNull(); + // It is the pristine fetch (no proxy) — does NOT round-trip to the SW. + expect(c.authenticatedFetch).toBe(c.publicFetch); + }); + + it('rebuilds a 204 No Content response (null-body status) without throwing', async () => { + installChrome((m) => { + if ((m as { type: string }).type === 'SOLID_FETCH_REQUEST') { + // The SW serialises an empty body as '' even for null-body statuses. + return { + requestId: (m as { requestId: string }).requestId, + status: 204, + statusText: 'No Content', + headers: {}, + body: '', + }; + } + return activeState(); + }); + const c = new MessageBridgeLoginController(); + await c.restore(); // log in so authenticatedFetch is the proxy + // A naive `new Response('', { status: 204 })` would THROW — this must not. + const res = await c.authenticatedFetch('https://alice.pod.example/c/r.ttl', { + method: 'DELETE', + }); + expect(res.status).toBe(204); + expect(res.body).toBeNull(); + expect(await res.text()).toBe(''); + }); + + it('authenticatedFetch normalises a Request input (url/method/headers/body) like fetch', async () => { + installChrome((m) => { + if ((m as { type: string }).type === 'SOLID_FETCH_REQUEST') { + return { requestId: (m as { requestId: string }).requestId, status: 201, body: 'ok' }; + } + return activeState(); + }); + const c = new MessageBridgeLoginController(); + await c.restore(); // log in so authenticatedFetch is the proxy (not pristine) + const req = new Request('https://alice.pod.example/c/r.ttl', { + method: 'PUT', + headers: { 'content-type': 'text/turtle' }, + body: '<#it> a <#Note>.', + }); + await c.authenticatedFetch(req); + + const sent = sentMessages.find( + (m) => (m as { type?: string }).type === 'SOLID_FETCH_REQUEST', + ) as { url: string; method: string; headers: Record; body: string | null }; + // NOT "[object Request]" — the Request's fields are read. + expect(sent.url).toBe('https://alice.pod.example/c/r.ttl'); + expect(sent.method).toBe('PUT'); + expect(sent.headers['content-type']).toBe('text/turtle'); + expect(sent.body).toBe('<#it> a <#Note>.'); + // Still no credential popup-side. + const headerKeys = Object.keys(sent.headers).map((k) => k.toLowerCase()); + expect(headerKeys).not.toContain('authorization'); + expect(headerKeys).not.toContain('dpop'); + }); + + it('REJECTS a Request with a binary/multipart body (text-only protocol; no silent coercion)', async () => { + installChrome((m) => { + if ((m as { type: string }).type === 'SOLID_FETCH_REQUEST') { + return { requestId: (m as { requestId: string }).requestId, status: 200, body: '' }; + } + return activeState(); + }); + const c = new MessageBridgeLoginController(); + await c.restore(); // log in so authenticatedFetch is the proxy + const form = new FormData(); + form.append('f', new Blob([new Uint8Array([1, 2, 3])]), 'b.bin'); + const req = new Request('https://alice.pod.example/upload', { method: 'POST', body: form }); + await expect(c.authenticatedFetch(req)).rejects.toThrow(/only text request bodies/); + // It refused BEFORE sending anything over the wire. + expect(sentMessages.some((m) => (m as { type?: string }).type === 'SOLID_FETCH_REQUEST')).toBe( + false, + ); + }); + + // --- Content-Type / body-shape policy (roborev finding: missing-CT must not corrupt binary) --- + + it('a missing-Content-Type Turtle (string) Request body still round-trips as text', async () => { + installChrome((m) => { + if ((m as { type: string }).type === 'SOLID_FETCH_REQUEST') { + return { requestId: (m as { requestId: string }).requestId, status: 201, body: '' }; + } + return activeState(); + }); + const c = new MessageBridgeLoginController(); + await c.restore(); // log in so authenticatedFetch is the proxy + // A Request whose body is RDF text but with NO declared content-type. The Solid write + // case must keep working: it is valid UTF-8 text, so it must be carried, not rejected. + // (Constructing from a string would auto-set text/plain, so build from UTF-8 bytes with + // the auto CT removed to exercise the genuinely-missing-CT path.) + const turtle = '<#it> a <#Note> ; <#label> "héllo ünïçödé" .'; + const req = new Request('https://alice.pod.example/c/note.ttl', { + method: 'PUT', + body: new TextEncoder().encode(turtle), + }); + req.headers.delete('content-type'); // ensure the ambiguous missing-CT path + expect(req.headers.get('content-type')).toBeNull(); + + const res = await c.authenticatedFetch(req); + expect(res.status).toBe(201); + const sent = sentMessages.find( + (m) => (m as { type?: string }).type === 'SOLID_FETCH_REQUEST', + ) as { body: string | null }; + expect(sent).toBeDefined(); + expect(sent.body).toBe(turtle); // faithfully decoded, not corrupted + }); + + it('REJECTS a Request with an EXPLICIT binary content-type (e.g. image/png)', async () => { + installChrome((m) => { + if ((m as { type: string }).type === 'SOLID_FETCH_REQUEST') { + return { requestId: (m as { requestId: string }).requestId, status: 201, body: '' }; + } + return activeState(); + }); + const c = new MessageBridgeLoginController(); + await c.restore(); // log in so authenticatedFetch is the proxy + const png = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])], { + type: 'image/png', + }); + const req = new Request('https://alice.pod.example/c/logo.png', { method: 'PUT', body: png }); + expect(req.headers.get('content-type')).toBe('image/png'); + await expect(c.authenticatedFetch(req)).rejects.toThrow(/only text request bodies/); + // Refused before any wire send — no corrupted write. + expect(sentMessages.some((m) => (m as { type?: string }).type === 'SOLID_FETCH_REQUEST')).toBe( + false, + ); + }); + + it('REJECTS a Request with a binary body and NO content-type (no silent .text() corruption)', async () => { + installChrome((m) => { + if ((m as { type: string }).type === 'SOLID_FETCH_REQUEST') { + return { requestId: (m as { requestId: string }).requestId, status: 201, body: '' }; + } + return activeState(); + }); + const c = new MessageBridgeLoginController(); + await c.restore(); // log in so authenticatedFetch is the proxy + // A genuinely binary (non-UTF-8) payload constructed from bytes → the Request carries + // NO content-type. This is the ambiguous path the old isTextContentType(null)===true let + // through and corrupted; it must now FAIL LOUDLY (detected via the raw bytes). + const binary = new Uint8Array([0x1f, 0x8b, 0x08, 0x00, 0xff, 0x00, 0xfe, 0xc0]); // gzip-ish + const req = new Request('https://alice.pod.example/c/blob.bin', { + method: 'PUT', + body: binary, + }); + expect(req.headers.get('content-type')).toBeNull(); + await expect(c.authenticatedFetch(req)).rejects.toThrow(/only text request bodies/); + // Refused before any wire send — the corrupted text was never transmitted. + expect(sentMessages.some((m) => (m as { type?: string }).type === 'SOLID_FETCH_REQUEST')).toBe( + false, + ); + }); + + it('REJECTS a binary body with an EMPTY/whitespace Content-Type (empty CT == missing, roborev 3495)', async () => { + installChrome((m) => { + if ((m as { type: string }).type === 'SOLID_FETCH_REQUEST') { + return { requestId: (m as { requestId: string }).requestId, status: 201, body: '' }; + } + return activeState(); + }); + const c = new MessageBridgeLoginController(); + await c.restore(); // log in so authenticatedFetch is the proxy + // An empty (or whitespace-only) Content-Type must be normalised to "missing" so the + // byte guard runs — otherwise isTextContentType('')===true let a binary body through and + // corrupted it (the empty-header gap roborev job 3495 flagged on the prior fix). + const binary = new Uint8Array([0x1f, 0x8b, 0x08, 0x00, 0xff, 0x00, 0xfe, 0xc0]); // gzip-ish + const req = new Request('https://alice.pod.example/c/blob.bin', { + method: 'PUT', + body: binary, + }); + req.headers.set('content-type', ' '); // empty/whitespace-only → must NOT be trusted as text + await expect(c.authenticatedFetch(req)).rejects.toThrow(/only text request bodies/); + expect(sentMessages.some((m) => (m as { type?: string }).type === 'SOLID_FETCH_REQUEST')).toBe( + false, + ); + }); + + it('a missing/empty-CT Turtle text body still round-trips (empty CT does not break the Solid write)', async () => { + installChrome((m) => { + if ((m as { type: string }).type === 'SOLID_FETCH_REQUEST') { + return { requestId: (m as { requestId: string }).requestId, status: 201, body: '' }; + } + return activeState(); + }); + const c = new MessageBridgeLoginController(); + await c.restore(); + const turtle = '<#it> a <#Note> ; <#label> "héllo ünïçödé" .'; + const req = new Request('https://alice.pod.example/c/note.ttl', { + method: 'PUT', + body: new TextEncoder().encode(turtle), + }); + req.headers.set('content-type', ''); // empty CT, but valid UTF-8 text → must still carry + const res = await c.authenticatedFetch(req); + expect(res.status).toBe(201); + const sent = sentMessages.find( + (m) => (m as { type?: string }).type === 'SOLID_FETCH_REQUEST', + ) as { body: string | null }; + expect(sent.body).toBe(turtle); + }); + + it('authenticatedFetch lets an explicit init override a Request (fetch semantics)', async () => { + installChrome((m) => { + if ((m as { type: string }).type === 'SOLID_FETCH_REQUEST') { + return { requestId: (m as { requestId: string }).requestId, status: 200, body: '' }; + } + return activeState(); + }); + const c = new MessageBridgeLoginController(); + await c.restore(); // log in so authenticatedFetch is the proxy (not pristine) + const req = new Request('https://alice.pod.example/r', { method: 'GET' }); + await c.authenticatedFetch(req, { method: 'DELETE' }); + + const sent = sentMessages.find( + (m) => (m as { type?: string }).type === 'SOLID_FETCH_REQUEST', + ) as { method: string }; + expect(sent.method).toBe('DELETE'); + }); + + it('STRIPS caller-supplied Authorization/DPoP headers (init AND Request) — worker is the credential authority', async () => { + installChrome((m) => { + if ((m as { type: string }).type === 'SOLID_FETCH_REQUEST') { + return { requestId: (m as { requestId: string }).requestId, status: 200, body: '' }; + } + return activeState(); + }); + const c = new MessageBridgeLoginController(); + await c.restore(); // log in so authenticatedFetch is the proxy (not pristine) + + // via init.headers + await c.authenticatedFetch('https://alice.pod.example/r', { + headers: { Authorization: 'Bearer ATTACKER', DPoP: 'forged-proof', 'x-keep': 'yes' }, + }); + // via a Request object + const req = new Request('https://alice.pod.example/r2', { + headers: { authorization: 'Bearer ATTACKER2', dpop: 'forged-proof-2', 'x-keep': 'also' }, + }); + await c.authenticatedFetch(req); + + const reqs = sentMessages.filter( + (m) => (m as { type?: string }).type === 'SOLID_FETCH_REQUEST', + ) as Array<{ headers: Record }>; + expect(reqs).toHaveLength(2); + for (const sent of reqs) { + const keys = Object.keys(sent.headers).map((k) => k.toLowerCase()); + expect(keys).not.toContain('authorization'); + expect(keys).not.toContain('dpop'); + // Non-credential headers are preserved. + expect(keys).toContain('x-keep'); + } + // And no attacker value rode along anywhere. + const wire = JSON.stringify(reqs); + expect(wire).not.toContain('ATTACKER'); + expect(wire).not.toContain('forged-proof'); + }); +}); diff --git a/apps/extension/test/origin-grant.test.ts b/apps/extension/test/origin-grant.test.ts new file mode 100644 index 000000000..03b389d8b --- /dev/null +++ b/apps/extension/test/origin-grant.test.ts @@ -0,0 +1,100 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { describe, expect, it } from 'vitest'; +import { + canonicalOrigin, + liveOriginGrant, + type OriginGrant, + OriginGrantStore, + sanitiseOriginGrants, +} from '../src/background/core/origin-grant'; +import { fixtureGrant } from './profile-fixtures'; + +const grant = (extra: Partial = {}): OriginGrant => ({ + origin: 'https://app.example', + profileId: '1', + createdAt: 10, + scope: { pods: ['https://pod.example'] }, + ...extra, +}); + +describe('origin authority fails closed', () => { + it.each([ + null, + 1, + '', + 'broken', + 'file:///tmp/x', + 'https://app.example/path', + 'https://APP.example', + 'https://app.example/', + ])('rejects noncanonical anchor %s', (raw) => { + expect(canonicalOrigin(raw)).toBeNull(); + }); + it.each([ + 'https://app.example', + 'http://localhost:8080', + ])('keeps an exact HTTP(S) origin including port', (origin) => { + expect(canonicalOrigin(origin)).toBe(origin); + }); + it('drops corrupt records and duplicate bindings', () => { + expect(sanitiseOriginGrants({})).toEqual([]); + expect( + sanitiseOriginGrants([ + null, + 1, + {}, + grant({ origin: 'broken' }), + grant({ profileId: '' }), + grant({ profileId: 4 as unknown as string }), + grant({ createdAt: NaN }), + grant({ expiresAt: NaN }), + grant({ scope: null as unknown as OriginGrant['scope'] }), + grant({ scope: { pods: null as unknown as string[] } }), + grant({ scope: { pods: ['file:///tmp'] } }), + grant({ scope: { pods: [], classes: {} as never } }), + ]), + ).toEqual([]); + expect(sanitiseOriginGrants([grant(), grant({ profileId: '2' })])).toEqual([]); + }); + it('nests only matching-origin valid classes and retains explicit empty class scope', () => { + const cls = fixtureGrant('https://pod.example/#me'); + const scoped = grant({ + expiresAt: 100, + scope: { pods: [], classes: [cls, { ...cls, origin: 'https://other.example' }, {} as never] }, + }); + expect(sanitiseOriginGrants([scoped])[0]).toEqual({ + ...scoped, + scope: { pods: [], classes: [cls] }, + }); + expect( + sanitiseOriginGrants([grant({ scope: { pods: [], classes: [] } })])[0].scope.classes, + ).toEqual([]); + expect(sanitiseOriginGrants([grant()])).toEqual([grant()]); + }); + it('denies unknown, revoked, and expired origins at the exact deadline', () => { + expect(liveOriginGrant([], 'https://app.example', 20)).toBeNull(); + expect(liveOriginGrant([grant()], 'https://other.example', 20)).toBeNull(); + expect(liveOriginGrant([grant()], 'https://app.example', 20)).toEqual(grant()); + const expiring = grant({ expiresAt: 20 }); + expect(liveOriginGrant([expiring], expiring.origin, 19)).toEqual(expiring); + expect(liveOriginGrant([expiring], expiring.origin, 20)).toBeNull(); + }); + it('serializes profile swaps and recovers the writer after failed persistence', async () => { + let raw: unknown = []; + let fail = true; + const store = new OriginGrantStore({ + read: async () => raw, + write: async (value) => { + if (fail) throw new Error('fixture disk failure'); + raw = structuredClone(value); + }, + }); + await expect(store.update(() => [grant()])).rejects.toThrow('disk failure'); + fail = false; + await Promise.all([ + store.update(() => [grant()]), + store.update((all) => all.map((g) => ({ ...g, profileId: '2' }))), + ]); + expect(await store.list()).toEqual([grant({ profileId: '2' })]); + }); +}); diff --git a/apps/extension/test/origin-policy.test.ts b/apps/extension/test/origin-policy.test.ts new file mode 100644 index 000000000..b855d9ad7 --- /dev/null +++ b/apps/extension/test/origin-policy.test.ts @@ -0,0 +1,135 @@ +// AUTHORED-BY Claude Opus 4.8 +import { describe, expect, it } from 'vitest'; +import { + computeAllowedOrigins, + isLoopbackHost, + isOriginAllowed, + isTokenEndpoint, + isValidClientIdUrl, +} from '../src/background/core/origin-policy'; + +describe('computeAllowedOrigins', () => { + it('includes the WebID origin and issuer origin by default', () => { + const allowed = computeAllowedOrigins({ + webId: 'https://alice.pod.example/profile/card#me', + issuer: 'https://idp.example/', + }); + expect(allowed.has('https://alice.pod.example')).toBe(true); + expect(allowed.has('https://idp.example')).toBe(true); + }); + + it('includes explicit pod origins (pod on a different host than the WebID)', () => { + const allowed = computeAllowedOrigins({ + webId: 'https://alice.id.example/card#me', + podOrigins: ['https://storage.example/alice/'], + }); + expect(allowed.has('https://storage.example')).toBe(true); + }); + + it('DROPS a cleartext http origin (cleartext guard) by default', () => { + const allowed = computeAllowedOrigins({ + webId: 'http://alice.pod.example/card#me', + }); + expect(allowed.size).toBe(0); + }); + + it('allows http ONLY for loopback when allowInsecureLoopback is set', () => { + const allowed = computeAllowedOrigins({ + webId: 'http://localhost:3000/alice/card#me', + allowInsecureLoopback: true, + }); + expect(allowed.has('http://localhost:3000')).toBe(true); + + const rejected = computeAllowedOrigins({ + webId: 'http://evil.example/card#me', + allowInsecureLoopback: true, + }); + expect(rejected.size).toBe(0); // loopback-only, not any http + }); + + it('can exclude the WebID / issuer origins', () => { + const allowed = computeAllowedOrigins({ + webId: 'https://alice.pod.example/card#me', + issuer: 'https://idp.example/', + includeWebIdOrigin: false, + includeIssuerOrigin: false, + }); + expect(allowed.size).toBe(0); + }); + + it('yields an EMPTY set when nothing is supplied (fail-closed)', () => { + expect(computeAllowedOrigins({}).size).toBe(0); + }); +}); + +describe('isOriginAllowed — the per-request credential gate', () => { + const allowed = computeAllowedOrigins({ + webId: 'https://alice.pod.example/card#me', + issuer: 'https://idp.example/', + }); + + it('allows a request to an allowed origin', () => { + expect(isOriginAllowed(allowed, 'https://alice.pod.example/private/notes.ttl')).toBe(true); + }); + + it('FAILS CLOSED: rejects a foreign origin (the token-leak attack)', () => { + // The core security property: a malicious page calling + // solid.fetch("https://evil.example/collect") must NOT get the token. + expect(isOriginAllowed(allowed, 'https://evil.example/collect')).toBe(false); + }); + + it('rejects an unparseable URL', () => { + expect(isOriginAllowed(allowed, 'not a url')).toBe(false); + }); + + it('rejects everything when the allowed set is empty', () => { + expect(isOriginAllowed(new Set(), 'https://alice.pod.example/x')).toBe(false); + }); + + it('treats a port difference as a different origin', () => { + expect(isOriginAllowed(allowed, 'https://alice.pod.example:8443/x')).toBe(false); + }); +}); + +describe('isTokenEndpoint — never attach the resource token to /token', () => { + it('matches the token endpoint by origin + path, ignoring query', () => { + const ep = 'https://idp.example/oidc/token'; + expect(isTokenEndpoint('https://idp.example/oidc/token?x=1', ep)).toBe(true); + }); + + it('does not match a different path on the same origin', () => { + const ep = 'https://idp.example/oidc/token'; + expect(isTokenEndpoint('https://idp.example/oidc/userinfo', ep)).toBe(false); + }); + + it('returns false when no token endpoint is known', () => { + expect(isTokenEndpoint('https://idp.example/oidc/token', null)).toBe(false); + }); +}); + +describe('isLoopbackHost', () => { + it.each(['localhost', '127.0.0.1', '[::1]', '::1', 'LOCALHOST'])('treats %s as loopback', (h) => { + expect(isLoopbackHost(h)).toBe(true); + }); + it('treats a real host as non-loopback', () => { + expect(isLoopbackHost('evil.example')).toBe(false); + }); +}); + +describe('isValidClientIdUrl — page-declared client-id transport guard', () => { + it('accepts an https client-id document URL', () => { + expect(isValidClientIdUrl('https://app.example/client-id.jsonld')).toBe(true); + }); + it('accepts http only for a loopback host (dev)', () => { + expect(isValidClientIdUrl('http://localhost:8080/client-id')).toBe(true); + expect(isValidClientIdUrl('http://127.0.0.1:8080/client-id')).toBe(true); + }); + it('REJECTS a remote plaintext http client-id (tamperable in transit)', () => { + expect(isValidClientIdUrl('http://evil.example/client-id')).toBe(false); + }); + it('rejects a non-http(s) scheme and an unparseable value', () => { + expect(isValidClientIdUrl('ftp://x/client-id')).toBe(false); + expect(isValidClientIdUrl('javascript:alert(1)')).toBe(false); + expect(isValidClientIdUrl('not a url')).toBe(false); + }); +}); diff --git a/apps/extension/test/origin-registration.test.ts b/apps/extension/test/origin-registration.test.ts new file mode 100644 index 000000000..fd7a2df15 --- /dev/null +++ b/apps/extension/test/origin-registration.test.ts @@ -0,0 +1,140 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { expect, it, vi } from 'vitest'; +import { syncOriginFetchInjection } from '../src/background/global-fetch-register'; + +it('removes the legacy all-origins registration and uses exact scheme/host/port matches', async () => { + const scripting = { + getRegisteredContentScripts: vi.fn(async () => [ + { id: 'solid-main-world-inject' }, + { id: 'unrelated' }, + ]), + unregisterContentScripts: vi.fn(async () => {}), + registerContentScripts: vi.fn(async () => {}), + }; + expect( + await syncOriginFetchInjection(['https://app.example', 'http://localhost:8081'], scripting), + ).toBe(true); + expect(scripting.unregisterContentScripts).toHaveBeenCalledWith({ + ids: ['solid-main-world-inject'], + }); + expect(scripting.registerContentScripts).toHaveBeenCalledWith([ + expect.objectContaining({ + matches: ['https://app.example:443/*'], + js: ['inject/fetch-patch.js'], + world: 'MAIN', + persistAcrossSessions: true, + }), + expect.objectContaining({ matches: ['http://localhost:8081/*'] }), + ]); +}); + +it('unregisters revoked/expired origins and leaves correct registrations intact on wake', async () => { + const id = `solid-origin-${encodeURIComponent('https://app.example')}`; + const stale = `solid-origin-${encodeURIComponent('https://revoked.example')}`; + const scripting = { + getRegisteredContentScripts: vi.fn(async () => [ + { id, matches: ['https://app.example:443/*'] }, + { id: stale, matches: ['https://revoked.example/*'] }, + ]), + unregisterContentScripts: vi.fn(async () => {}), + registerContentScripts: vi.fn(async () => {}), + }; + expect(await syncOriginFetchInjection(['https://app.example'], scripting)).toBe(true); + expect(scripting.unregisterContentScripts).toHaveBeenCalledWith({ ids: [stale] }); + expect(scripting.registerContentScripts).not.toHaveBeenCalled(); +}); + +it('fails safely when scripting is missing or registration fails', async () => { + expect(await syncOriginFetchInjection([], undefined)).toBe(false); + const scripting = { + getRegisteredContentScripts: vi.fn().mockRejectedValue(new Error('fixture unavailable')), + unregisterContentScripts: vi.fn(), + registerContentScripts: vi.fn(), + }; + expect(await syncOriginFetchInjection([], scripting)).toBe(false); + expect(scripting.registerContentScripts).not.toHaveBeenCalled(); +}); + +// Model Chrome URLPattern's exact-host HTTP(S) subset, including its port wildcard +// default. Chromium's ExtensionURLPatternTest.Match17 covers explicit default ports +// matching port-less URLs and rejecting other ports: +// https://chromium.googlesource.com/chromium/src/+/main/extensions/common/url_pattern_unittest.cc +function matchesChromeOriginPattern(pattern: string, value: string): boolean { + const parts = /^(https?):\/\/(\[[^\]]+\]|[^/:]+)(?::(\d+|\*))?\/\*$/.exec(pattern); + if (!parts) return false; + const url = new URL(value); + const effectivePort = url.port || (url.protocol === 'https:' ? '443' : '80'); + return ( + `${parts[1]}:` === url.protocol && + parts[2] === url.hostname && + (parts[3] === undefined || parts[3] === '*' || parts[3] === effectivePort) + ); +} + +it.each([ + { + origin: 'https://app.example', + own: ['https://app.example/', 'https://app.example:443/x'], + foreign: ['https://app.example:8443/', 'http://app.example/', 'https://sub.app.example/'], + }, + { + origin: 'http://localhost', + own: ['http://localhost/', 'http://localhost:80/x'], + foreign: ['http://localhost:8080/', 'https://localhost/'], + }, + { + origin: 'https://app.example:8443', + own: ['https://app.example:8443/x'], + foreign: ['https://app.example/', 'https://app.example:443/', 'https://app.example:8444/'], + }, + { + origin: 'http://localhost:8081', + own: ['http://localhost:8081/x'], + foreign: ['http://localhost/', 'http://localhost:8082/'], + }, + { + origin: 'http://[::1]', + own: ['http://[::1]/', 'http://[::1]:80/x'], + foreign: ['http://[::1]:8080/'], + }, +])('registers only $origin under Chrome port matching semantics', async ({ + origin, + own, + foreign, +}) => { + const registered: chrome.scripting.RegisteredContentScript[] = []; + const scripting = { + getRegisteredContentScripts: async () => [], + unregisterContentScripts: async () => {}, + registerContentScripts: async (scripts: chrome.scripting.RegisteredContentScript[]) => { + registered.push(...scripts); + }, + }; + expect(await syncOriginFetchInjection([origin], scripting)).toBe(true); + const patterns = registered.flatMap((script) => script.matches ?? []); + expect(patterns).toHaveLength(1); + for (const url of own) + expect( + patterns.some((pattern) => matchesChromeOriginPattern(pattern, url)), + url, + ).toBe(true); + for (const url of foreign) + expect( + patterns.some((pattern) => matchesChromeOriginPattern(pattern, url)), + url, + ).toBe(false); +}); + +it('replaces a persisted default-port wildcard registration on wake', async () => { + const id = `solid-origin-${encodeURIComponent('https://app.example')}`; + const scripting = { + getRegisteredContentScripts: async () => [{ id, matches: ['https://app.example/*'] }], + unregisterContentScripts: vi.fn(async () => {}), + registerContentScripts: vi.fn(async () => {}), + }; + expect(await syncOriginFetchInjection(['https://app.example'], scripting)).toBe(true); + expect(scripting.unregisterContentScripts).toHaveBeenCalledWith({ ids: [id] }); + expect(scripting.registerContentScripts).toHaveBeenCalledWith([ + expect.objectContaining({ id, matches: ['https://app.example:443/*'] }), + ]); +}); diff --git a/apps/extension/test/permission-a11y.ts b/apps/extension/test/permission-a11y.ts new file mode 100644 index 000000000..d6da0432e --- /dev/null +++ b/apps/extension/test/permission-a11y.ts @@ -0,0 +1,13 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import axe from 'axe-core'; +import { expect } from 'vitest'; + +/** jsdom checks names, roles and relationships; browser E2E owns layout and contrast. */ +export async function expectAccessible(document: Document): Promise { + const result = await axe.run(document.documentElement, { + rules: { 'color-contrast': { enabled: false } }, + }); + expect( + result.violations.map(({ id, nodes }) => ({ id, targets: nodes.map((n) => n.target) })), + ).toEqual([]); +} diff --git a/apps/extension/test/popup-restore.test.ts b/apps/extension/test/popup-restore.test.ts new file mode 100644 index 000000000..cf6db164f --- /dev/null +++ b/apps/extension/test/popup-restore.test.ts @@ -0,0 +1,135 @@ +// @vitest-environment jsdom +// AUTHORED-BY Claude Opus 4.8 +/** + * Popup silent-session-restore wiring (roborev finding: the `auto-restore` markup trap). + * + * The popup OWNS the restore flow: on open it shows "Restoring…" and asks the service + * worker for state (SOLID_GET_STATE) via refresh(); a returning user with a live SW + * session lands signed-in with NO re-auth. The ``'s OWN + * auto-restore-on-connect is deliberately DISABLED — set via the `.autoRestore = false` + * PROPERTY in popup.ts, not the ambiguous `auto-restore="false"` markup attribute — so the + * panel does not independently run controller.restore() and race the popup's explicit view + * control. These tests assert BOTH halves on the real DOM (jsdom + the real custom element): + * (1) silent restore still works (returning user → #signed-in, no login prompt); + * (2) the panel's autoRestore property is actually false (popup-driven, unambiguous); + * (3) no active session → #signed-out (the login surface), restore fell back correctly. + * + * popup.ts is a side-effecting module that wires everything at import time, so each test + * resets the module registry + DOM + a fresh chrome stub, then imports it once. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface PopupChromeOptions { + /** The SOLID_GET_STATE reply (the SW's restored-session decision). */ + state: Record; +} + +/** Build the popup's markup exactly as popup.html does (the bits popup.ts queries). */ +function mountPopupDom(): void { + document.body.innerHTML = ` + + + + + + `; +} + +/** Stub chrome.* so popup.ts's SOLID_GET_STATE / storage calls resolve deterministically. */ +function installPopupChrome({ state }: PopupChromeOptions): void { + vi.stubGlobal('chrome', { + runtime: { + lastError: undefined as { message?: string } | undefined, + sendMessage: (message: { type?: string }, callback: (response: unknown) => void) => { + if (message?.type === 'SOLID_GET_STATE') callback(state); + else callback({ ok: true }); + }, + onMessage: { addListener: () => {} }, + }, + storage: { + local: { + get: () => Promise.resolve({}), + remove: () => Promise.resolve(), + }, + }, + }); +} + +/** Load the side-effecting popup module fresh, then let its async refresh() settle. */ +async function loadPopup(): Promise { + vi.resetModules(); + await import('../src/popup/popup'); + // refresh() is async (awaits SOLID_GET_STATE); flush microtasks + the controller render. + await new Promise((resolve) => setTimeout(resolve, 50)); +} + +const visible = (id: string): boolean => + !(document.getElementById(id) as HTMLElement | null)?.hidden; + +beforeEach(() => { + mountPopupDom(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + document.body.innerHTML = ''; +}); + +describe('popup silent session restore (panel autoRestore disabled; popup owns restore)', () => { + it('a returning user with a live SW session lands SIGNED-IN with no re-auth', async () => { + installPopupChrome({ + state: { + webId: 'https://alice.pod.example/profile/card#me', + isActive: true, + name: 'Alice', + photoUrl: null, + recentAccounts: [], + }, + }); + await loadPopup(); + + // Silent restore succeeded: the signed-in surface is shown, NOT the login prompt. + expect(visible('signed-in')).toBe(true); + expect(visible('signed-out')).toBe(false); + expect(visible('busy')).toBe(false); + // The account menu reflects the restored identity. + expect(document.getElementById('account-menu')?.getAttribute('webid')).toBe( + 'https://alice.pod.example/profile/card#me', + ); + }); + + it('the panel.autoRestore PROPERTY is false (unambiguous popup-driven restore; no markup trap)', async () => { + installPopupChrome({ + state: { webId: null, isActive: false, name: null, photoUrl: null, recentAccounts: [] }, + }); + await loadPopup(); + + const panel = document.getElementById('login-panel') as HTMLElement & { autoRestore: boolean }; + // The PROPERTY is the source of truth — explicitly false (popup.ts sets it), not relying + // on hand-authored ambiguous `auto-restore="false"` markup that reads like the Lit + // boolean-attribute trap. + expect(panel.autoRestore).toBe(false); + // The panel REFLECTS the property back to the attribute as "false" (its custom converter's + // toAttribute) — confirming the disable came from the property, and the reflected value is + // the converter's explicit disabling token (never a bare/present attribute reading "true"). + expect(panel.getAttribute('auto-restore')).toBe('false'); + }); + + it('no active SW session → falls back to the SIGNED-OUT login surface', async () => { + installPopupChrome({ + state: { webId: null, isActive: false, name: null, photoUrl: null, recentAccounts: [] }, + }); + await loadPopup(); + + expect(visible('signed-out')).toBe(true); + expect(visible('signed-in')).toBe(false); + expect(visible('busy')).toBe(false); + }); +}); diff --git a/apps/extension/test/profile-auth-flow.test.ts b/apps/extension/test/profile-auth-flow.test.ts new file mode 100644 index 000000000..b4e94c262 --- /dev/null +++ b/apps/extension/test/profile-auth-flow.test.ts @@ -0,0 +1,143 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import * as jose from 'jose'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { initiateLogin, refreshSession } from '../src/background/auth-flow'; +import { profileCredentials } from './profile-fixtures'; + +const WEBID = 'https://pod.example/alice/profile#me'; +const ISSUER = 'https://issuer.example/'; +const TOKEN = `${ISSUER}token`; +const CLIENT = 'https://app.example/clientid'; +let storage: Map; +let fetchMock: ReturnType>; +let exchangeSucceeds: boolean; + +beforeEach(() => { + storage = new Map([['solid:session', { accessToken: 'fixture-legacy-unchanged' }]]); + exchangeSucceeds = true; + vi.stubGlobal('chrome', { + runtime: { lastError: undefined }, + storage: { + local: { + get: async (key: string) => ({ [key]: storage.get(key) }), + set: async (entries: Record) => { + for (const [key, value] of Object.entries(entries)) storage.set(key, value); + }, + remove: async (key: string) => { + storage.delete(key); + }, + }, + }, + identity: { + getRedirectURL: () => 'https://fixture.chromiumapp.org/callback', + launchWebAuthFlow: ({ url }: { url: string }, callback: (url: string) => void) => { + const state = new URL(url).searchParams.get('state')!; + callback( + `https://fixture.chromiumapp.org/callback?code=fixture-code&state=${state}&iss=${encodeURIComponent(ISSUER)}`, + ); + }, + }, + }); + fetchMock = vi.fn(async (input, init) => { + const url = input.toString(); + if (url === WEBID) + return new Response(`<${WEBID}> <${ISSUER}> .`); + if (url === `${ISSUER}.well-known/openid-configuration`) + return Response.json({ + issuer: ISSUER, + authorization_endpoint: `${ISSUER}authorize`, + token_endpoint: TOKEN, + }); + if (url === TOKEN) { + expect(new URLSearchParams(init?.body as URLSearchParams).get('grant_type')).toBe( + 'authorization_code', + ); + expect( + jose.decodeProtectedHeader(new Headers(init?.headers).get('dpop')!).jwk, + ).not.toHaveProperty('d'); + if (!exchangeSucceeds) return new Response('fixture denied', { status: 401 }); + return Response.json({ + access_token: 'fixture-access', + refresh_token: 'fixture-refresh', + id_token: new jose.UnsecuredJWT({ webid: WEBID }).encode(), + expires_in: 3600, + }); + } + throw new Error('Unexpected fixture URL'); + }); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('profile-owned auth credentials', () => { + it('returns the successful token set with its own non-extractable key for one atomic profile commit', async () => { + const { session, keyPair } = await initiateLogin({ webId: WEBID, clientId: CLIENT }); + expect(session).toMatchObject({ + webId: WEBID, + issuer: ISSUER, + accessToken: 'fixture-access', + refreshToken: 'fixture-refresh', + }); + expect(keyPair.privateKey.extractable).toBe(false); + await expect(crypto.subtle.exportKey('jwk', keyPair.privateKey)).rejects.toThrow(); + const tokenCall = fetchMock.mock.calls.find(([url]) => url.toString() === TOKEN)!; + await expect( + jose.jwtVerify(new Headers(tokenCall[1]?.headers).get('dpop')!, keyPair.publicKey), + ).resolves.toBeDefined(); + expect(storage.get('solid:session')).toEqual({ accessToken: 'fixture-legacy-unchanged' }); + expect(storage.has('solid:auth-params')).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('leaves the existing credentials untouched after a failed exchange', async () => { + exchangeSucceeds = false; + await expect(initiateLogin({ webId: WEBID, clientId: CLIENT })).rejects.toThrow( + 'Token exchange failed', + ); + expect(storage.get('solid:session')).toEqual({ accessToken: 'fixture-legacy-unchanged' }); + }); + + it('refuses refresh with another profile key before any token egress', async () => { + const a = await profileCredentials(); + const b = await profileCredentials('bob'); + await expect(refreshSession(a.session, b.keyPair)).rejects.toThrow('does not match'); + await expect(refreshSession({ ...a.session, dpopJkt: undefined }, a.keyPair)).rejects.toThrow( + 'does not match', + ); + await expect(refreshSession({ ...a.session, refreshToken: null }, a.keyPair)).rejects.toThrow( + 'No refresh token', + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('reuses only the owning key for a nonce retry and returns rotated tokens without global persistence', async () => { + const a = await profileCredentials(); + fetchMock.mockImplementation(async (_url, init) => { + const proof = new Headers(init?.headers).get('dpop')!; + const { payload } = await jose.jwtVerify(proof, a.keyPair.publicKey); + expect(new URLSearchParams(init?.body as URLSearchParams).get('refresh_token')).toBe( + a.session.refreshToken, + ); + if (!payload.nonce) + return new Response('', { status: 400, headers: { 'dpop-nonce': 'fixture-nonce' } }); + expect(payload.nonce).toBe('fixture-nonce'); + return Response.json({ + access_token: 'fixture-rotated-access', + refresh_token: 'fixture-rotated-refresh', + expires_in: 3600, + }); + }); + const updated = await refreshSession(a.session, a.keyPair); + expect(updated).toMatchObject({ + webId: a.session.webId, + dpopJkt: a.session.dpopJkt, + accessToken: 'fixture-rotated-access', + refreshToken: 'fixture-rotated-refresh', + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(storage.get('solid:session')).toEqual({ accessToken: 'fixture-legacy-unchanged' }); + }); +}); diff --git a/apps/extension/test/profile-controller.test.ts b/apps/extension/test/profile-controller.test.ts new file mode 100644 index 000000000..ba3f1f8b0 --- /dev/null +++ b/apps/extension/test/profile-controller.test.ts @@ -0,0 +1,254 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ProfileController } from '../src/popup/profile-controller'; +import type { Profile, ProfilesResponse } from '../src/shared/messages'; + +const profile: Profile = { + id: '1', + webId: 'https://fixture.invalid/me', + issuer: 'https://fixture.invalid/', + label: 'Personal', + sessionState: 'active', + dpopKeyRef: '1', +}; +const snapshot: ProfilesResponse = { ok: true, profiles: [profile], defaultProfileId: '1' }; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +afterEach(() => vi.unstubAllGlobals()); + +describe('profile UI controller', () => { + it('lists display metadata including an empty store', async () => { + const send = vi.fn().mockResolvedValue(snapshot); + const render = vi.fn(); + const controller = new ProfileController(render, send); + await controller.refresh(); + expect(send).toHaveBeenCalledWith({ type: 'SOLID_PROFILES_LIST' }); + expect(controller.state).toMatchObject({ + profiles: [profile], + defaultProfileId: '1', + ready: true, + }); + send.mockResolvedValue({ ok: true, profiles: [], defaultProfileId: null }); + await controller.refresh(); + expect(controller.state.profiles).toEqual([]); + expect(controller.state.defaultProfileId).toBeNull(); + }); + + it('uses only the existing worker operations and applies returned defaults', async () => { + const send = vi.fn().mockResolvedValue(snapshot); + const controller = new ProfileController(vi.fn(), send); + expect(await controller.add(' https://fixture.invalid/me ')).toBe(true); + expect(send).toHaveBeenLastCalledWith({ type: 'SOLID_PROFILE_ADD', webId: profile.webId }); + await controller.rename('1', ' Work '); + expect(send).toHaveBeenLastCalledWith({ + type: 'SOLID_PROFILE_RENAME', + profileId: '1', + label: 'Work', + }); + await controller.setDefault('1'); + expect(send).toHaveBeenLastCalledWith({ type: 'SOLID_PROFILE_SET_DEFAULT', profileId: '1' }); + send.mockResolvedValue({ ok: true, profiles: [], defaultProfileId: null }); + await controller.remove('1'); + expect(send).toHaveBeenLastCalledWith({ type: 'SOLID_PROFILE_REMOVE', profileId: '1' }); + expect(controller.state).toMatchObject({ status: 'Account removed.', defaultProfileId: null }); + }); + + it('keeps the current list on cancellation/failure and permits retry', async () => { + const send = vi.fn().mockResolvedValue(snapshot); + const controller = new ProfileController(vi.fn(), send); + await controller.refresh(); + send.mockResolvedValue({ error: 'Sign-in cancelled.' }); + expect(await controller.add(profile.webId)).toBe(false); + expect(controller.state).toMatchObject({ + profiles: [profile], + error: 'Sign-in cancelled.', + busy: false, + }); + send.mockResolvedValue(snapshot); + expect(await controller.add(profile.webId)).toBe(true); + expect(controller.state.error).toBe(''); + }); + + it('refreshes before retrying pending removals in sequence and preserves other profiles', async () => { + let profiles: Profile[] = [ + profile, + { ...profile, id: '2', sessionState: 'removing' }, + { ...profile, id: '3', sessionState: 'signed-out' }, + { ...profile, id: '4', sessionState: 'removing' }, + ]; + const firstRemoval = deferred(); + const send = vi.fn(async (message) => { + if (message.type === 'SOLID_PROFILE_REMOVE') { + profiles = profiles.filter((p) => p.id !== message.profileId); + if (message.profileId === '2') return firstRemoval.promise; + } + return { ok: true, profiles, defaultProfileId: '1' }; + }); + const controller = new ProfileController(vi.fn(), send); + const retry = controller.retry(); + await vi.waitFor(() => expect(controller.state.busy).toBe(true)); + await controller.retry(); + expect(send.mock.calls.map(([message]) => message)).toEqual([ + { type: 'SOLID_PROFILES_LIST' }, + { type: 'SOLID_PROFILE_REMOVE', profileId: '2' }, + ]); + firstRemoval.resolve({ ok: true, profiles, defaultProfileId: '1' }); + await retry; + expect(send).toHaveBeenLastCalledWith({ type: 'SOLID_PROFILE_REMOVE', profileId: '4' }); + expect(controller.state.profiles.map((p) => p.id)).toEqual(['1', '3']); + expect(controller.state).toMatchObject({ busy: false, error: '', defaultProfileId: '1' }); + }); + + it('keeps a repeated purge failure visible and permits another removal retry', async () => { + const pending = { ...profile, sessionState: 'removing' as const }; + const send = vi + .fn() + .mockResolvedValueOnce({ ...snapshot, profiles: [pending] }) + .mockResolvedValueOnce({ error: 'Cache unavailable.' }) + .mockResolvedValueOnce({ ...snapshot, profiles: [pending] }); + const controller = new ProfileController(vi.fn(), send); + await controller.retry(); + expect(controller.state).toMatchObject({ + profiles: [pending], + busy: false, + error: 'Cache unavailable.', + }); + send + .mockResolvedValueOnce({ ...snapshot, profiles: [pending] }) + .mockResolvedValueOnce({ ok: true, profiles: [], defaultProfileId: null }); + await controller.retry(); + expect(controller.state).toMatchObject({ profiles: [], defaultProfileId: null, error: '' }); + }); + + it.each([ + 'reply', + 'transport', + ])('reloads a partially removed profile after a %s error without losing the error', async (failure) => { + const pending = { ...profile, sessionState: 'removing' as const }; + const reload = deferred(); + const send = vi.fn().mockResolvedValueOnce(snapshot); + const controller = new ProfileController(vi.fn(), send); + await controller.refresh(); + if (failure === 'reply') send.mockResolvedValueOnce({ error: 'Cache unavailable.' }); + else send.mockRejectedValueOnce(new Error('Cache unavailable.')); + send.mockReturnValueOnce(reload.promise); + const removing = controller.remove('1'); + await vi.waitFor(() => expect(send).toHaveBeenLastCalledWith({ type: 'SOLID_PROFILES_LIST' })); + expect(controller.state.busy).toBe(true); + // A broadcast arriving during reconciliation must not clear the removal error. + await controller.refresh(); + send.mockResolvedValue({ ...snapshot, profiles: [pending] }); + reload.resolve({ ...snapshot, profiles: [pending] }); + expect(await removing).toBe(false); + expect(controller.state).toMatchObject({ + profiles: [pending], + defaultProfileId: '1', + busy: false, + error: 'Cache unavailable.', + }); + }); + + it('does not retry removals after a failed refresh or teardown', async () => { + const send = vi + .fn() + .mockResolvedValue({ ...snapshot, profiles: [{ ...profile, sessionState: 'removing' }] }); + const controller = new ProfileController(vi.fn(), send); + await controller.refresh(); + send.mockResolvedValueOnce({ error: 'Storage unavailable.' }); + await controller.retry(); + expect(send).toHaveBeenCalledTimes(2); + expect(controller.state.error).toBe('Storage unavailable.'); + const pending = deferred(); + send.mockReturnValueOnce(pending.promise); + const retry = controller.retry(); + controller.dispose(); + pending.resolve(snapshot); + await retry; + await controller.retry(); + expect(send).toHaveBeenCalledTimes(3); + }); + + it.each([ + undefined, + { ok: false }, + { ok: true, profiles: [] }, + { error: 'Storage unavailable.' }, + ])('handles missing or failed replies: %j', async (reply) => { + const controller = new ProfileController(vi.fn(), vi.fn().mockResolvedValue(reply)); + await controller.refresh(); + expect(controller.state.ready).toBe(false); + expect(controller.state.error).not.toBe(''); + expect(await controller.remove('1')).toBe(false); + expect(controller.state.busy).toBe(false); + }); + + it('ignores stale lists and duplicate actions, then reconciles queued broadcasts', async () => { + const stale = deferred(); + const mutation = deferred(); + const send = vi + .fn() + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(mutation.promise) + .mockResolvedValue(snapshot); + const controller = new ProfileController(vi.fn(), send); + const refresh = controller.refresh(); + const adding = controller.add(profile.webId); + expect(await controller.remove('1')).toBe(false); + await controller.refresh(); + expect(send).toHaveBeenCalledTimes(2); + mutation.resolve(snapshot); + await adding; + expect(send).toHaveBeenCalledTimes(3); + stale.resolve({ ok: true, profiles: [], defaultProfileId: null }); + await refresh; + expect(controller.state.profiles).toEqual([profile]); + }); + + it('lets the latest refresh win, including errors', async () => { + const old = deferred(); + const send = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValue(snapshot); + const controller = new ProfileController(vi.fn(), send); + const first = controller.refresh(); + await controller.refresh(); + old.resolve({ error: 'Stale failure' }); + await first; + expect(controller.state.error).toBe(''); + expect(controller.state.profiles).toEqual([profile]); + }); + + it('fences in-flight operations and stops rendering after teardown', async () => { + const pending = deferred(); + const render = vi.fn(); + const send = vi.fn().mockReturnValue(pending.promise); + const controller = new ProfileController(render, send); + const action = controller.add(profile.webId); + controller.dispose(); + render.mockClear(); + pending.resolve(snapshot); + expect(await action).toBe(false); + await controller.refresh(); + expect(await controller.remove('1')).toBe(false); + expect(render).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledTimes(1); + }); + + it('reports Chrome transport errors and recovers through the real message adapter', async () => { + const runtime = { + lastError: { message: 'Worker unavailable' } as { message: string } | undefined, + sendMessage: vi.fn((_request, callback) => callback(snapshot)), + }; + vi.stubGlobal('chrome', { runtime }); + const controller = new ProfileController(vi.fn()); + await controller.refresh(); + expect(controller.state.error).toBe('Worker unavailable'); + runtime.lastError = undefined; + await controller.refresh(); + expect(controller.state.ready).toBe(true); + expect(controller.state.error).toBe(''); + }); +}); diff --git a/apps/extension/test/profile-db.test.ts b/apps/extension/test/profile-db.test.ts new file mode 100644 index 000000000..4fb8601a4 --- /dev/null +++ b/apps/extension/test/profile-db.test.ts @@ -0,0 +1,156 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { describe, expect, it } from 'vitest'; +import { createDpopProof } from '../src/background/core/dpop'; +import { ProfileStore } from '../src/background/core/profile-store'; +import { ProfileDb } from '../src/background/profile-db'; +import { noLegacy, profileCredentials } from './profile-fixtures'; + +/** Minimal fixture IndexedDB: named stores, async transactions and structured-cloned values. */ +function fixtureIdb() { + const stores = new Map>(); + let created = false; + let abortNext = false; + let failRead = false; + let closed = 0; + const db = { + createObjectStore: (name: string) => stores.set(name, new Map()), + close: () => closed++, + transaction: (_names: string[], mode: string) => { + const snapshot = structuredClone(stores); + let pending = 0; + let settled = false; + const tx = { + oncomplete: null as (() => void) | null, + onerror: null as (() => void) | null, + onabort: null as (() => void) | null, + error: new Error('fixture transaction aborted'), + objectStore: (name: string) => { + const backing = snapshot.get(name)!; + const req = (op: () => T) => { + pending++; + const r = { + result: undefined as T | undefined, + error: new Error('fixture read failed'), + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + queueMicrotask(() => { + if (failRead && mode === 'readonly') r.onerror?.(); + else { + r.result = op(); + r.onsuccess?.(); + } + pending--; + queueMicrotask(() => { + if (pending !== 0 || settled) return; + settled = true; + if (abortNext) { + abortNext = false; + tx.onabort?.(); + } else { + if (mode === 'readwrite') + for (const [key, value] of snapshot) stores.set(key, value); + tx.oncomplete?.(); + } + }); + }); + return r; + }; + return { + get: (key: IDBValidKey) => req(() => structuredClone(backing.get(key))), + getAll: () => req(() => structuredClone([...backing.values()])), + put: (value: unknown, key: IDBValidKey) => + req(() => { + backing.set(key, structuredClone(value)); + return key; + }), + clear: () => req(() => backing.clear()), + }; + }, + }; + return tx; + }, + }; + const factory = { + open: () => { + const req = { + result: db, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + onupgradeneeded: null as (() => void) | null, + }; + queueMicrotask(() => { + if (!created) { + created = true; + req.onupgradeneeded?.(); + } + req.onsuccess?.(); + }); + return req; + }, + } as unknown as IDBFactory; + return { + factory, + stores, + abort: () => { + abortNext = true; + }, + readError: () => { + failRead = true; + }, + closed: () => closed, + }; +} + +describe('ProfileDb IndexedDB adapter', () => { + it('persists profile-keyed tokens, default and non-extractable keys across close/reopen', async () => { + const fixture = fixtureIdb(); + const db = await ProfileDb.open(fixture.factory); + const store = new ProfileStore(db, async () => {}); + await store.initialise(noLegacy, async () => {}); + const a = await profileCredentials(); + const b = await profileCredentials('bob'); + await store.login(a.session, a.keyPair, a.display); + await store.login(b.session, b.keyPair, b.display); + await store.setDefault('2'); + db.close(); + expect(fixture.closed()).toBe(1); + const reopened = await ProfileDb.open(fixture.factory); + const restored = new ProfileStore(reopened, async () => {}); + expect((await restored.getDefault())?.session?.accessToken).toBe(b.session.accessToken); + const aKey = (await restored.session('1'))!.keyPair; + expect(aKey.privateKey.extractable).toBe(false); + await expect(crypto.subtle.exportKey('jwk', aKey.privateKey)).rejects.toThrow(); + expect( + await createDpopProof({ keyPair: aKey, htm: 'GET', htu: 'https://pod.example/private/' }), + ).toBeTypeOf('string'); + expect([...fixture.stores.get('profiles')!.keys()]).toEqual(['1', '2']); + expect((await restored.list()).profiles.map((p) => p.id)).toEqual(['1', '2']); + }); + + it('waits for commit and preserves the previous snapshot on transaction abort', async () => { + const fixture = fixtureIdb(); + const db = await ProfileDb.open(fixture.factory); + const before = await db.read(); + fixture.abort(); + await expect(db.write({ ...before, migrated: true })).rejects.toThrow('aborted'); + expect(await db.read()).toEqual(before); + fixture.readError(); + await expect(db.read()).rejects.toThrow('read failed'); + }); + + it('fails closed when IndexedDB is unavailable or opening fails', async () => { + await expect(ProfileDb.open(undefined)).rejects.toThrow('requires IndexedDB'); + const factory = { + open: () => { + const req = { + error: new Error('fixture open failure'), + onerror: null as (() => void) | null, + }; + queueMicrotask(() => req.onerror?.()); + return req; + }, + } as unknown as IDBFactory; + await expect(ProfileDb.open(factory)).rejects.toThrow('open failure'); + }); +}); diff --git a/apps/extension/test/profile-fixtures.ts b/apps/extension/test/profile-fixtures.ts new file mode 100644 index 000000000..06c8ed87c --- /dev/null +++ b/apps/extension/test/profile-fixtures.ts @@ -0,0 +1,99 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import type { ClassGrant } from '../src/background/core/class-grant'; +import { dpopThumbprint, generateDpopKeyPair } from '../src/background/core/dpop'; +import type { LegacyProfile, ProfileSnapshot } from '../src/background/core/profile-store'; +import type { StoredSession } from '../src/background/session-store'; + +export class MemoryProfileDb { + state: ProfileSnapshot = { records: [], nextId: 1, defaultProfileId: null, migrated: false }; + error: Error | null = null; + async read(): Promise { + if (this.error) throw this.error; + return structuredClone(this.state); + } + async write(state: ProfileSnapshot): Promise { + this.state = structuredClone(state); + } + close() {} +} + +export async function profileCredentials(name = 'alice') { + const keyPair = await generateDpopKeyPair(); + const session: StoredSession = { + webId: `https://pod.example/${name}/profile#me`, + issuer: 'https://issuer.example/', + tokenEndpoint: 'https://issuer.example/token', + clientId: 'https://app.example/clientid', + accessToken: `fixture-access-${name}`, + refreshToken: `fixture-refresh-${name}`, + dpopJkt: await dpopThumbprint(keyPair.publicKey), + expiresAt: Date.now() + 3600_000, + }; + const display = { webId: session.webId, name, photoUrl: `https://pod.example/${name}.png` }; + return { session, keyPair, display }; +} + +export function fixtureGrant(webId: string): ClassGrant { + return { + grantId: 'fixture-grant', + origin: 'https://app.example', + webId, + dataClass: 'https://schema.org/Person', + modes: ['read'], + purpose: null, + createdAt: 0, + expiresAt: Date.now() + 3600_000, + targets: [{ url: 'https://pod.example/private/', kind: 'container' }], + }; +} + +export const noLegacy = (): Promise => + Promise.resolve({ + session: null, + display: null, + keyPair: null, + grantedOrigins: [], + podOrigins: [], + classGrants: [], + }); + +/** Explicit parent grants for pre-existing gate/replica test scenarios. This is fixture setup, + * never production migration: those tests continue to exercise the same class/egress policy. */ +export function seedOriginGrantFixtures(storage: Map, db: MemoryProfileDb): void { + if (storage.has('solid:origin-grants')) return; + const records = db.state.records.length + ? db.state.records + : [ + { + profile: { id: '1' }, + session: storage.get('solid:session') as StoredSession | undefined, + grantedOrigins: (storage.get('solid:granted-origins') ?? []) as string[], + podOrigins: (storage.get('solid:pod-origins') ?? []) as string[], + classGrants: (storage.get('solid:class-grants') ?? []) as ClassGrant[], + }, + ]; + const grants = records.flatMap((r) => { + if (!r.session) return []; + const pods = [ + ...new Set([ + new URL(r.session.webId).origin, + new URL(r.session.issuer).origin, + ...r.podOrigins, + ]), + ]; + const explicit = r.grantedOrigins.map((origin) => new URL(origin).origin); + const origins = [...new Set([...explicit, ...r.classGrants.map((g) => g.origin)])]; + return origins.map((origin) => ({ + origin, + profileId: r.profile.id, + createdAt: Date.now(), + scope: { + pods, + ...(explicit.includes(origin) + ? {} + : { classes: r.classGrants.filter((g) => g.origin === origin) }), + }, + })); + }); + storage.set('solid:origin-grants', grants); +} diff --git a/apps/extension/test/profile-store.test.ts b/apps/extension/test/profile-store.test.ts new file mode 100644 index 000000000..fa1f385d6 --- /dev/null +++ b/apps/extension/test/profile-store.test.ts @@ -0,0 +1,348 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { authenticatedFetch } from '../src/background/core/authenticated-fetch'; +import { dpopThumbprint, generateDpopKeyPair } from '../src/background/core/dpop'; +import { ProfileStore } from '../src/background/core/profile-store'; +import { fixtureGrant, MemoryProfileDb, noLegacy, profileCredentials } from './profile-fixtures'; + +let db: MemoryProfileDb; +let purge: ReturnType Promise>>; +let store: ProfileStore; + +beforeEach(() => { + db = new MemoryProfileDb(); + purge = vi.fn(async () => {}); + store = new ProfileStore(db, purge); +}); + +async function login(name = 'alice') { + const credentials = await profileCredentials(name); + const profile = await store.login(credentials.session, credentials.keyPair, credentials.display); + return { ...credentials, profile }; +} + +describe('ProfileStore migration', () => { + it('migrates the legacy session to profile 1 exactly once, preserving its key and grants', async () => { + const a = await profileCredentials(); + const grants = [fixtureGrant(a.session.webId), fixtureGrant('https://other.example/#me')]; + const load = vi.fn(async () => ({ + ...a, + grantedOrigins: ['https://app.example'], + podOrigins: ['https://pod.example'], + classGrants: grants, + })); + const clear = vi.fn(async () => { + expect(db.state.migrated).toBe(true); + expect(db.state.records[0].session?.accessToken).toBe(a.session.accessToken); + }); + await store.initialise(load, clear); + const record = await store.get('1'); + expect(record.session).toEqual(a.session); + expect(record.grantedOrigins).toEqual(['https://app.example']); + expect(record.podOrigins).toEqual(['https://pod.example']); + expect(record.classGrants).toEqual([grants[0]]); + expect((await store.list()).defaultProfileId).toBe('1'); + expect(await dpopThumbprint(record.keyPair!.publicKey)).toBe(a.session.dpopJkt); + expect(record.keyPair!.privateKey.extractable).toBe(false); + await expect(crypto.subtle.exportKey('jwk', record.keyPair!.privateKey)).rejects.toThrow(); + await new ProfileStore(db, purge).initialise(load, clear); + expect(load).toHaveBeenCalledTimes(1); + expect(clear).toHaveBeenCalledTimes(2); + }); + + it('handles an empty install and retries cleanup after a committed migration', async () => { + const clear = vi + .fn() + .mockRejectedValueOnce(new Error('interrupted')) + .mockResolvedValue(undefined); + await expect(store.initialise(noLegacy, clear)).rejects.toThrow('interrupted'); + await store.initialise(() => { + throw new Error('must not re-import'); + }, clear); + expect(await store.list()).toEqual({ profiles: [], defaultProfileId: null }); + expect(await store.getDefault()).toBeNull(); + }); + + it.each([ + 'missing', + 'mismatch', + 'extractable', + ] as const)('requires re-login for a %s legacy key', async (failure) => { + const a = await profileCredentials(); + const keyPair = + failure === 'missing' + ? null + : failure === 'mismatch' + ? await generateDpopKeyPair() + : await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ]); + await store.initialise( + async () => ({ + ...a, + keyPair, + grantedOrigins: ['https://app.example'], + podOrigins: [], + classGrants: [], + }), + async () => {}, + ); + expect((await store.get('1')).profile.sessionState).toBe('signed-out'); + expect(await store.session('1')).toBeNull(); + expect((await store.get('1')).session).toBeNull(); + expect((await store.get('1')).grantedOrigins).toEqual([]); + expect(purge).toHaveBeenCalledWith(a.session.webId); + }); + + it('binds a pre-thumbprint session and ignores a mismatched display profile', async () => { + const a = await profileCredentials(); + await store.initialise( + async () => ({ + ...a, + session: { ...a.session, dpopJkt: undefined }, + display: { ...a.display, webId: 'https://other.example/#me' }, + grantedOrigins: [], + podOrigins: [], + classGrants: [], + }), + async () => {}, + ); + expect((await store.get('1')).session?.dpopJkt).toBe(a.session.dpopJkt); + expect((await store.get('1')).profile).toMatchObject({ + label: a.session.webId, + avatar: undefined, + }); + }); + + it('uses the WebID label when the legacy display is absent', async () => { + const a = await profileCredentials(); + await store.initialise( + async () => ({ ...a, display: null, grantedOrigins: [], podOrigins: [], classGrants: [] }), + async () => {}, + ); + expect((await store.get('1')).profile.label).toBe(a.session.webId); + }); +}); + +describe('ProfileStore lifecycle and adversarial isolation', () => { + it('keeps defaults, labels and credentials across worker restarts without exposing secrets', async () => { + const a = await login(); + const b = await login('bob'); + expect((await store.getDefault())?.profile.id).toBe(a.profile.id); + await store.rename(b.profile.id, ' Bob personal '); + await store.setDefault(b.profile.id); + const reopened = new ProfileStore(db, purge); + expect((await reopened.getDefault())?.profile.label).toBe('Bob personal'); + expect((await reopened.session(a.profile.id))?.session.accessToken).toBe(a.session.accessToken); + const view = JSON.stringify(await reopened.list()); + expect(view).not.toContain('accessToken'); + expect(view).not.toContain('refreshToken'); + expect(view).not.toContain('privateKey'); + expect(await reopened.session('missing')).toBeNull(); + await expect(reopened.get('missing')).rejects.toThrow('Unknown profile'); + }); + + it('never uses P1 tokens or signing keys for P2, even when both share a pod origin', async () => { + const a = await login(); + const b = await login('bob'); + const seen: string[] = []; + for (const profile of [a, b]) { + const ctx = (await store.session(profile.profile.id))!; + const result = await authenticatedFetch( + { + accessToken: ctx.session.accessToken, + dpopKeyPair: ctx.keyPair, + allowedOrigins: new Set(['https://pod.example']), + tokenEndpoint: ctx.session.tokenEndpoint, + }, + { + url: 'https://pod.example/shared', + method: 'GET', + headers: { Authorization: 'attacker', DPoP: 'attacker' }, + body: null, + fetchImpl: async (_url, init) => { + const headers = new Headers(init?.headers); + seen.push(headers.get('authorization')!); + const header = JSON.parse( + atob(headers.get('dpop')!.split('.')[0].replace(/-/g, '+').replace(/_/g, '/')), + ); + expect(header.jwk.d).toBeUndefined(); + expect(header.jwk.x).toBe( + (await crypto.subtle.exportKey('jwk', ctx.keyPair.publicKey)).x, + ); + return new Response('fixture'); + }, + }, + ); + expect(result.response.status).toBe(200); + } + expect(seen).toEqual([`DPoP ${a.session.accessToken}`, `DPoP ${b.session.accessToken}`]); + }); + + it('rejects swapped sessions and mismatched, missing or exportable keys', async () => { + const a = await login(); + const b = await login('bob'); + await expect(store.updateSession(a.profile.id, b.session)).rejects.toThrow('does not belong'); + await expect( + store.updateSession(a.profile.id, { ...a.session, issuer: 'https://other.example' }), + ).rejects.toThrow('does not belong'); + await expect( + store.updateSession(a.profile.id, { ...a.session, dpopJkt: b.session.dpopJkt }), + ).rejects.toThrow('does not belong'); + await expect(store.login(a.session, b.keyPair, a.display)).rejects.toThrow('Invalid profile'); + db.state.records[0].keyPair = b.keyPair; + expect(await store.session(a.profile.id)).toBeNull(); + expect((await store.session(b.profile.id))?.session).toEqual(b.session); + await expect(store.updateSession(a.profile.id, a.session)).rejects.toThrow('does not belong'); + await expect(store.capabilities(a.profile.id, () => {})).rejects.toThrow('not active'); + const newA = await login(); + db.state.records[0].keyPair = null; + expect(await store.session(newA.profile.id)).toBeNull(); + }); + + it.each([ + 'webId', + 'issuer', + ] as const)('rejects a persisted session whose %s was swapped across profiles', async (field) => { + const a = await login(); + db.state.records[0].session = { ...a.session, [field]: 'https://other.example/#me' }; + expect(await store.session('1')).toBeNull(); + }); + + it('returns only the allowlisted profile fields even if a stored display object has extra properties', async () => { + await login(); + Object.assign(db.state.records[0].profile, { accessToken: 'fixture-must-not-leak' }); + expect(JSON.stringify(await store.list())).not.toContain('fixture-must-not-leak'); + }); + + it('updates refreshed tokens only for their owning profile', async () => { + const a = await login(); + const b = await login('bob'); + await store.updateSession(a.profile.id, { ...a.session, accessToken: 'fixture-rotated' }); + expect((await store.session(a.profile.id))?.session.accessToken).toBe('fixture-rotated'); + expect((await store.session(b.profile.id))?.session.accessToken).toBe(b.session.accessToken); + }); + + it('enforces eight profiles under concurrent additions and permits re-login at the limit', async () => { + const credentials = await Promise.all( + Array.from({ length: 9 }, (_, i) => profileCredentials(String(i))), + ); + const results = await Promise.allSettled( + credentials.map((c) => store.login(c.session, c.keyPair, c.display)), + ); + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(8); + expect(results[8].status).toBe('rejected'); + const a = credentials[0]; + await store.rename('1', 'Personal'); + await store.capabilities('1', (r) => { + r.grantedOrigins = ['https://app.example']; + }); + await store.login(a.session, a.keyPair, { ...a.display, name: null, photoUrl: null }); + expect((await store.list()).profiles).toHaveLength(8); + expect((await store.get('1')).profile.label).toBe('Personal'); + expect((await store.get('1')).grantedOrigins).toEqual(['https://app.example']); + }); + + it.each([ + '', + ' ', + 'x'.repeat(201), + null, + ])('rejects invalid labels without damaging the writer: %s', async (label) => { + await login(); + await expect(store.rename('1', label as string)).rejects.toThrow('label'); + await store.rename('1', 'Valid'); + expect((await store.get('1')).profile.label).toBe('Valid'); + }); + + it('revokes before purging, removes only the chosen profile and assigns a surviving default', async () => { + const a = await login(); + const b = await login('bob'); + await store.capabilities('1', (r) => { + r.grantedOrigins = ['https://app.example']; + r.classGrants = [fixtureGrant(a.session.webId)]; + }); + purge.mockImplementation(async () => { + const removing = await store.get('1'); + expect(removing.profile.sessionState).toBe('removing'); + expect(removing.session).toBeNull(); + expect(removing.keyPair).toBeNull(); + expect(removing.grantedOrigins).toEqual([]); + expect(removing.classGrants).toEqual([]); + expect(await store.session('1')).toBeNull(); + }); + await store.remove('1'); + expect(purge).toHaveBeenCalledWith(a.session.webId); + expect((await store.getDefault())?.profile.id).toBe('2'); + expect((await store.session('2'))?.session).toEqual(b.session); + purge.mockResolvedValue(undefined); + await store.remove('2'); + expect(await store.getDefault()).toBeNull(); + expect((await login('carol')).profile.id).toBe('3'); + }); + + it('retains a different default when removing a non-default profile', async () => { + await login(); + await login('bob'); + await store.remove('2'); + expect((await store.list()).defaultProfileId).toBe('1'); + }); + + it('fails closed on purge failure and resumes removal after suspension', async () => { + const a = await login(); + db.state.migrated = true; + purge.mockRejectedValueOnce(new Error('cache unavailable')); + await expect(store.remove('1')).rejects.toThrow('cache unavailable'); + expect(await store.session('1')).toBeNull(); + await expect(store.setDefault('1')).rejects.toThrow('removal pending'); + await expect(store.login(a.session, a.keyPair, a.display)).rejects.toThrow('removal pending'); + await new ProfileStore(db, purge).initialise(noLegacy, async () => {}); + expect((await store.list()).profiles).toEqual([]); + }); + + it.each([ + 'retry', + 'restart', + ])('preserves pending removal through logout and finishes on %s', async (recovery) => { + const a = await login(); + const b = await login('bob'); + db.state.migrated = true; + purge.mockRejectedValue(new Error('cache unavailable')); + await expect(store.remove(a.profile.id)).rejects.toThrow('cache unavailable'); + await expect(store.signOut(a.profile.id)).rejects.toThrow('cache unavailable'); + expect((await store.get(a.profile.id)).profile.sessionState).toBe('removing'); + expect(await store.session(a.profile.id)).toBeNull(); + + purge.mockResolvedValue(undefined); + await store.signOut(a.profile.id); + expect((await store.get(a.profile.id)).profile.sessionState).toBe('removing'); + await expect(store.login(a.session, a.keyPair, a.display)).rejects.toThrow('removal pending'); + if (recovery === 'retry') await store.remove(a.profile.id); + else await new ProfileStore(db, purge).initialise(noLegacy, async () => {}); + expect(await store.list()).toMatchObject({ + profiles: [b.profile], + defaultProfileId: b.profile.id, + }); + expect((await store.session(b.profile.id))?.session).toEqual(b.session); + }); + + it('signs out one profile and purges before re-login while retaining its display entry', async () => { + const a = await login(); + const b = await login('bob'); + await store.signOut('1'); + expect((await store.get('1')).profile.sessionState).toBe('signed-out'); + expect((await store.session('2'))?.session).toEqual(b.session); + await store.login(a.session, a.keyPair, a.display); + expect(purge).toHaveBeenCalledTimes(2); + expect((await store.session('1'))?.session).toEqual(a.session); + }); + + it('rejects unknown profile ids rather than selecting the default', async () => { + await login(); + await expect(store.rename('absent', 'X')).rejects.toThrow('Unknown profile'); + await expect(store.remove('absent')).rejects.toThrow('Unknown profile'); + await expect(store.setDefault('absent')).rejects.toThrow('Unknown profile'); + await expect(store.signOut('absent')).rejects.toThrow('Unknown profile'); + }); +}); diff --git a/apps/extension/test/profile-switcher.test.ts b/apps/extension/test/profile-switcher.test.ts new file mode 100644 index 000000000..12b6fa3a4 --- /dev/null +++ b/apps/extension/test/profile-switcher.test.ts @@ -0,0 +1,350 @@ +// @vitest-environment jsdom +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { mountProfileSwitcher } from '../src/popup/profile-switcher'; +import type { Profile, ProfileRequest } from '../src/shared/messages'; + +let mounted: ReturnType; +let profiles: Profile[]; +let defaultProfileId: string | null; +let requests: ProfileRequest[]; +let error: string | undefined; +let removalError: string | undefined; +// When set, the next worker reply is held until `release()` so a test can observe the busy UI. +let held: (() => void) | null = null; +let holdNext = false; +const release = () => { + const run = held; + held = null; + run?.(); +}; +// jsdom has no focus fixup: emulate the browser dropping focus from a disabled/hidden control. +const focusFixup = () => { + const active = document.activeElement as HTMLButtonElement | null; + if (!active || !(active.disabled || active.closest('[hidden]'))) return; + // jsdom ignores blur() on a disabled control; focusing then removing a probe resets to . + const probe = document.createElement('button'); + document.body.append(probe); + probe.focus(); + probe.remove(); +}; +const get = (id: string) => document.getElementById(id) as T; +const flush = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}; +const action = (label: string) => + Array.from(document.querySelectorAll('button')).find( + (button) => button.getAttribute('aria-label') === label, + )!; +beforeEach(async () => { + profiles = ['Personal', 'Work'].map((label, index) => ({ + id: String(index + 1), + label, + webId: `https://fixture.invalid/${index}`, + issuer: 'https://fixture.invalid/', + sessionState: 'active', + dpopKeyRef: String(index + 1), + })); + defaultProfileId = '1'; + requests = []; + held = null; + holdNext = false; + error = undefined; + removalError = undefined; + document.body.replaceChildren(); + vi.stubGlobal('chrome', { + runtime: { + sendMessage: (request: ProfileRequest, reply: (reply: unknown) => void) => { + if (holdNext) { + holdNext = false; + held = () => respond(request, reply); + return; + } + respond(request, reply); + }, + }, + }); + function respond(request: ProfileRequest, callback: (reply: unknown) => void) { + requests.push(request); + if (request.type === 'SOLID_PROFILE_REMOVE') { + profiles = profiles.map((p) => + p.id === request.profileId ? { ...p, sessionState: 'removing' } : p, + ); + if (removalError) return callback({ error: removalError }); + } + if (error) return callback({ error }); + if (request.type === 'SOLID_PROFILE_SET_DEFAULT') defaultProfileId = request.profileId; + if (request.type === 'SOLID_PROFILE_REMOVE') + profiles = profiles.filter((p) => p.id !== request.profileId); + if (request.type === 'SOLID_PROFILE_RENAME') + profiles = profiles.map((p) => + p.id === request.profileId ? { ...p, label: request.label } : p, + ); + callback({ ok: true, profiles, defaultProfileId }); + } + mounted = mountProfileSwitcher(document.body); + await flush(); +}); +afterEach(() => { + mounted.cleanup(); + vi.unstubAllGlobals(); +}); + +it('renders labelled profiles and native default controls with an avatar per profile', async () => { + expect(document.querySelectorAll('#profile-list li')).toHaveLength(2); + expect(document.querySelectorAll('.profile-avatar')).toHaveLength(2); + expect(action('Set Personal as default').getAttribute('aria-pressed')).toBe('true'); + action('Set Work as default').click(); + await flush(); + expect(action('Set Work as default').getAttribute('aria-pressed')).toBe('true'); + expect(get('profile-status').textContent).toBe('Default profile changed.'); +}); + +it('supports rename, Escape cancel and focus return without sending a mutation', async () => { + action('Rename Work').click(); + expect(document.activeElement).toBe(get('profile-input')); + expect(get('profile-input').value).toBe('Work'); + get('profile-input').dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + expect(get('profile-editor').hidden).toBe(true); + expect(document.activeElement).toBe(action('Rename Work')); + expect(requests).toHaveLength(1); + action('Rename Work').click(); + get('profile-input').value = 'Office'; + get('profile-editor').dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await flush(); + expect(action('Rename Office')).toBeDefined(); + expect(document.activeElement).toBe(action('Rename Office')); +}); + +it('requires explicit removal confirmation and explains its effect', async () => { + action('Remove Work').click(); + expect(requests).toHaveLength(1); + expect(document.activeElement).toBe(get('profile-cancel')); + expect(get('profile-editor-help').textContent).toContain('revokes its app access'); + get('profile-editor').dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + expect(requests.at(-1)).toEqual({ type: 'SOLID_PROFILE_REMOVE', profileId: '2' }); + expect(document.querySelectorAll('#profile-list li')).toHaveLength(1); + expect(document.activeElement).toBe(get('profile-add')); +}); + +it('keeps failed input for retry and announces the error', async () => { + get('profile-add').click(); + get('profile-input').value = 'https://fixture.invalid/new'; + error = 'Sign-in cancelled.'; + get('profile-editor').dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + expect(get('profile-error').textContent).toBe(error); + expect(get('profile-error').getAttribute('role')).toBe('alert'); + expect(get('profile-editor').hidden).toBe(false); + expect(get('profile-input').value).toBe('https://fixture.invalid/new'); +}); + +it.each([ + 'Retry removal', + 'Retry profiles', +])('recovers a failed removal after cancelling the editor with %s', async (retry) => { + removalError = 'Cache unavailable.'; + action('Remove Work').click(); + get('profile-editor').dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + expect(get('profile-error').textContent).toBe(removalError); + expect(document.querySelector('[data-profile-id="2"]')?.textContent).toContain('Removal pending'); + get('profile-cancel').click(); + expect(get('profile-editor').hidden).toBe(true); + + // A repeated purge failure stays retryable. + get('profile-retry').click(); + await flush(); + expect(requests.filter((request) => request.type === 'SOLID_PROFILE_REMOVE')).toHaveLength(2); + expect(get('profile-error').textContent).toBe(removalError); + expect(document.querySelector('[data-profile-id="2"]')?.textContent).toContain('Removal pending'); + expect(action('Set Work as default').disabled).toBe(true); + expect(action('Rename Work').disabled).toBe(true); + const removal = action('Retry removal of Work'); + expect(removal.textContent).toBe('Retry removal'); + expect(removal.disabled).toBe(false); + expect(removal.getAttribute('aria-disabled')).toBe('false'); + expect(action('Sign in to Work')).toBeUndefined(); + expect(get('profile-retry').hidden).toBe(false); + action('Set Work as default').click(); + action('Rename Work').click(); + expect(get('profile-editor').hidden).toBe(true); + + removalError = undefined; + (retry === 'Retry removal' ? removal : get('profile-retry')).click(); + await flush(); + expect(document.querySelectorAll('#profile-list li')).toHaveLength(1); + expect(requests.filter((request) => request.type === 'SOLID_PROFILE_REMOVE')).toHaveLength(3); + expect(defaultProfileId).toBe('1'); + expect(get('profile-error').textContent).toBe(''); +}); + +it('restores focus to Sign in and add after a failed keyboard submission', async () => { + get('profile-add').click(); + get('profile-input').value = 'https://fixture.invalid/new'; + const save = get('profile-save'); + save.focus(); + error = 'Sign-in cancelled.'; + holdNext = true; + get('profile-editor').dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + expect(save.disabled).toBe(true); + focusFixup(); + expect(document.activeElement).toBe(document.body); + release(); + await flush(); + expect(get('profile-error').textContent).toBe(error); + expect(get('profile-editor').hidden).toBe(false); + expect(document.activeElement).toBe(save); +}); + +it('moves focus to a visible control when Retry profiles hides itself', async () => { + removalError = 'Cache unavailable.'; + action('Remove Work').click(); + get('profile-editor').dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + get('profile-cancel').click(); + + removalError = undefined; + const retry = get('profile-retry'); + retry.focus(); + holdNext = true; + retry.click(); + await flush(); + focusFixup(); + release(); + await flush(); + expect(retry.hidden).toBe(true); + expect(document.querySelectorAll('#profile-list li')).toHaveLength(1); + expect(document.activeElement).toBe(get('profile-add')); +}); + +it('moves focus to Add when a successful profile refresh hides Retry profiles', async () => { + error = 'Storage unavailable.'; + await mounted.controller.refresh(); + const retry = get('profile-retry'); + expect(retry.hidden).toBe(false); + retry.focus(); + error = undefined; + retry.click(); + await flush(); + expect(retry.hidden).toBe(true); + expect(document.activeElement).toBe(get('profile-add')); +}); + +it('never pulls focus back from a control outside Profiles when an operation fails', async () => { + const outside = document.createElement('button'); + outside.textContent = 'Theme'; + document.body.append(outside); + get('profile-add').click(); + get('profile-input').value = 'https://fixture.invalid/new'; + get('profile-save').focus(); + error = 'Sign-in cancelled.'; + holdNext = true; + get('profile-editor').dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + outside.focus(); + release(); + await flush(); + expect(get('profile-error').textContent).toBe(error); + expect(document.activeElement).toBe(outside); +}); + +it('leaves focus on an outside control when a rename completes successfully', async () => { + const outside = document.createElement('button'); + outside.textContent = 'Theme'; + document.body.append(outside); + action('Rename Work').click(); + get('profile-input').value = 'Office'; + holdNext = true; + get('profile-editor').dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + outside.focus(); + release(); + await flush(); + expect(get('profile-editor').hidden).toBe(true); + expect(document.activeElement).toBe(outside); +}); + +it('offers removal recovery when an existing pending profile loads without an error', async () => { + profiles = profiles.map((p) => (p.id === '2' ? { ...p, sessionState: 'removing' } : p)); + await mounted.controller.refresh(); + expect(get('profile-error').textContent).toBe(''); + expect(get('profile-retry').hidden).toBe(true); + action('Retry removal of Work').click(); + await flush(); + expect(requests.at(-1)).toEqual({ type: 'SOLID_PROFILE_REMOVE', profileId: '2' }); + expect(document.querySelectorAll('#profile-list li')).toHaveLength(1); +}); + +it('shows signed-out profiles and permits re-login even at capacity', async () => { + profiles = Array.from({ length: 8 }, (_, i) => ({ + ...profiles[0], + id: String(i), + label: `Profile ${i}`, + sessionState: 'signed-out', + })); + await mounted.controller.refresh(); + expect(get('profile-add').disabled).toBe(true); + action('Sign in to Profile 0').click(); + await flush(); + expect(requests.at(-1)).toEqual({ type: 'SOLID_PROFILE_ADD', webId: profiles[0].webId }); +}); + +it('focuses Add after retrying a pending removal frees the eighth slot', async () => { + profiles = Array.from({ length: 8 }, (_, i) => ({ + ...profiles[0], + id: String(i), + label: `Profile ${i}`, + sessionState: i === 7 ? 'removing' : 'active', + })); + await mounted.controller.refresh(); + expect(get('profile-add').disabled).toBe(true); + const retry = action('Retry removal of Profile 7'); + retry.focus(); + retry.click(); + await flush(); + expect(document.querySelectorAll('#profile-list li')).toHaveLength(7); + expect(get('profile-add').disabled).toBe(false); + expect(document.activeElement).toBe(get('profile-add')); +}); + +it('focuses an enabled surviving control after adding the eighth profile', async () => { + profiles = Array.from({ length: 7 }, (_, i) => ({ + ...profiles[0], + id: String(i), + label: `Profile ${i}`, + })); + await mounted.controller.refresh(); + get('profile-add').click(); + get('profile-input').value = 'https://fixture.invalid/new'; + // The worker returns the newly authenticated profile when the add completes. + profiles = [...profiles, { ...profiles[0], id: '7', label: 'New' }]; + get('profile-editor').dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + expect(get('profile-editor').hidden).toBe(true); + expect(get('profile-add').disabled).toBe(true); + expect(document.activeElement).toBe(action('Set Profile 0 as default')); + expect((document.activeElement as HTMLButtonElement).disabled).toBe(false); +}); + +it('renders untrusted labels as text and falls back on missing or failed avatars', async () => { + profiles = [ + { ...profiles[0], label: '', avatar: 'javascript:alert(1)' }, + ]; + await mounted.controller.refresh(); + expect(document.querySelector('.profile-text strong')?.textContent).toBe(profiles[0].label); + expect(document.querySelectorAll('img')).toHaveLength(0); + profiles = [{ ...profiles[0], avatar: 'https://fixture.invalid/avatar.png' }]; + await mounted.controller.refresh(); + const img = document.querySelector('img')!; + expect(img.referrerPolicy).toBe('no-referrer'); + img.dispatchEvent(new Event('error')); + expect(document.querySelectorAll('img')).toHaveLength(0); + expect(document.querySelector('.profile-avatar')?.textContent).not.toBe(''); +}); diff --git a/apps/extension/test/replica-key.test.ts b/apps/extension/test/replica-key.test.ts new file mode 100644 index 000000000..85f7f6bfa --- /dev/null +++ b/apps/extension/test/replica-key.test.ts @@ -0,0 +1,102 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// The SECURITY-CRITICAL cache-key derivation for the shared replica. The key IS the +// cross-user / cross-grant boundary in the extension's single shared storage partition +// (design §2.1 LOAD-BEARING CACHE-KEY FIX), so these tests assert the key is injective over +// EVERY dimension and fail-closed (un-keyable) without both security dimensions. + +import { describe, expect, it } from 'vitest'; +import { + canonicalAccept, + computeReplicaKey, + computeVaryKey, + replicaKeyRequest, +} from '../src/background/core/replica-key'; + +const URL_A = 'https://alice.pod.example/finance/budget.ttl'; +const WEBID_A = 'https://alice.pod.example/profile/card#me'; +const WEBID_B = 'https://bob.pod.example/profile/card#me'; +const SCOPE_PM = 'https://pm.example'; +const SCOPE_OTHER = 'https://other.example'; +const VARY = computeVaryKey('text/turtle'); + +describe('computeReplicaKey — the security-scoped composite key', () => { + it('embeds the WebID: same URL+vary, DIFFERENT user → DIFFERENT key (no cross-user collision)', () => { + const kA = computeReplicaKey({ webId: WEBID_A, grantScope: SCOPE_PM }, URL_A, VARY); + const kB = computeReplicaKey({ webId: WEBID_B, grantScope: SCOPE_PM }, URL_A, VARY); + expect(kA).not.toBeNull(); + expect(kB).not.toBeNull(); + expect(kA).not.toBe(kB); + }); + + it('embeds the grant-scope: same URL+user, DIFFERENT granted origin → DIFFERENT key', () => { + const k1 = computeReplicaKey({ webId: WEBID_A, grantScope: SCOPE_PM }, URL_A, VARY); + const k2 = computeReplicaKey({ webId: WEBID_A, grantScope: SCOPE_OTHER }, URL_A, VARY); + expect(k1).not.toBe(k2); + }); + + it('is STABLE: identical (webId, grantScope, url, vary) → identical key', () => { + const k1 = computeReplicaKey({ webId: WEBID_A, grantScope: SCOPE_PM }, URL_A, VARY); + const k2 = computeReplicaKey({ webId: WEBID_A, grantScope: SCOPE_PM }, URL_A, VARY); + expect(k1).toBe(k2); + }); + + it('FAIL-CLOSED: a null WebID → un-keyable (null), never a shared bucket', () => { + expect(computeReplicaKey({ webId: null, grantScope: SCOPE_PM }, URL_A, VARY)).toBeNull(); + }); + + it('FAIL-CLOSED: a null grant-scope → un-keyable (null)', () => { + expect(computeReplicaKey({ webId: WEBID_A, grantScope: null }, URL_A, VARY)).toBeNull(); + }); + + it('is injective across components even for adversarially-crafted values (NUL delimiter)', () => { + // The key delimiter is a NUL byte, impossible in any real WebID/origin/URL — so even an + // attacker who could place a SPACE at a component boundary cannot forge a colliding key. + // (A SPACE-delimited join would COLLIDE on this pair; the NUL join keeps them distinct.) + const k1 = computeReplicaKey({ webId: 'https://a', grantScope: 'https://b c' }, URL_A, VARY); + const k2 = computeReplicaKey({ webId: 'https://a https://b', grantScope: 'c' }, URL_A, VARY); + expect(k1).not.toBe(k2); + }); +}); + +describe('replicaKeyRequest — the synthetic Cache-API key', () => { + it('produces an injective sentinel-origin Request URL per distinct tuple', () => { + const rA = replicaKeyRequest({ webId: WEBID_A, grantScope: SCOPE_PM }, URL_A, VARY); + const rB = replicaKeyRequest({ webId: WEBID_B, grantScope: SCOPE_PM }, URL_A, VARY); + expect(rA?.url).not.toBe(rB?.url); + expect(rA?.url.startsWith('https://solid-extension-replica.invalid/')).toBe(true); + }); + + it('FAIL-CLOSED: a null security dimension → null Request (cannot key bytes)', () => { + expect(replicaKeyRequest({ webId: null, grantScope: SCOPE_PM }, URL_A, VARY)).toBeNull(); + expect(replicaKeyRequest({ webId: WEBID_A, grantScope: null }, URL_A, VARY)).toBeNull(); + }); + + it('the sentinel key cannot collide with any real pod URL (different origin)', () => { + const r = replicaKeyRequest({ webId: WEBID_A, grantScope: SCOPE_PM }, URL_A, VARY); + expect(new URL(r?.url ?? '').origin).toBe('https://solid-extension-replica.invalid'); + expect(new URL(r?.url ?? '').origin).not.toBe(new URL(URL_A).origin); + }); +}); + +describe('canonicalAccept / computeVaryKey — RDF variant collapse (one byte copy)', () => { + it('collapses every RDF media type + */* to text/turtle (Turtle & JSON-LD share one entry)', () => { + expect(canonicalAccept('application/ld+json')).toBe('text/turtle'); + expect(canonicalAccept('text/turtle')).toBe('text/turtle'); + expect(canonicalAccept('application/n-triples, text/turtle;q=0.9')).toBe('text/turtle'); + expect(canonicalAccept('*/*')).toBe('text/turtle'); + expect(canonicalAccept(null)).toBe('text/turtle'); + }); + + it('keys a non-RDF Accept on what was requested (image/png stays distinct)', () => { + expect(canonicalAccept('image/png')).toBe('image/png'); + expect(computeVaryKey('image/png')).toBe('accept=image/png'); + }); + + it('a Turtle read and a JSON-LD read of the SAME resource compute the SAME key', () => { + const scope = { webId: WEBID_A, grantScope: SCOPE_PM }; + const kTurtle = computeReplicaKey(scope, URL_A, computeVaryKey('text/turtle')); + const kJsonLd = computeReplicaKey(scope, URL_A, computeVaryKey('application/ld+json')); + expect(kTurtle).toBe(kJsonLd); + }); +}); diff --git a/apps/extension/test/replica.test.ts b/apps/extension/test/replica.test.ts new file mode 100644 index 000000000..739d60672 --- /dev/null +++ b/apps/extension/test/replica.test.ts @@ -0,0 +1,869 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// The shared-replica ORCHESTRATION (SWR read, write-through, gate-on-cache-hit, purge) with +// in-memory doubles for the Cache + metadata-store ports — no IndexedDB / packed extension +// (the live SW context is covered by service-worker-replica.test.ts + the e2e suite). These +// are the adversarial security tests for the two load-bearing properties: +// - the gate is the FIRST step on EVERY read AND write, INCLUDING a cache hit; +// - the security-scoped key blocks a cross-user / cross-grant serve off a warm cache. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type EgressPort, + purgeReplica, + purgeReplicaSlice, + type ReplicaByteCache, + type ReplicaMetaStore, + SharedReplica, +} from '../src/background/core/replica'; +import type { ReplicaMetadata } from '../src/background/core/replica-db'; + +// --- In-memory doubles ---------------------------------------------------------------- + +/** A trivial in-memory Cache-API double (keyed by the synthetic key Request URL). */ +class FakeCache implements ReplicaByteCache { + readonly bytes = new Map< + string, + { body: string; status: number; headers: Record } + >(); + async match(request: Request): Promise { + const e = this.bytes.get(request.url); + if (!e) return undefined; + return new Response(e.body, { status: e.status, headers: e.headers }); + } + async put(request: Request, response: Response): Promise { + this.bytes.set(request.url, { + body: await response.text(), + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + }); + } + async delete(request: Request): Promise { + return this.bytes.delete(request.url); + } + async keys(): Promise { + return [...this.bytes.keys()].map((url) => new Request(url)); + } +} + +/** A trivial in-memory metadata-store double. */ +class FakeMetaStore implements ReplicaMetaStore { + readonly rows = new Map(); + noncesCleared = 0; + async getMetadata(key: string) { + return this.rows.get(key); + } + async putMetadata(record: ReplicaMetadata) { + this.rows.set(record.key, record); + } + async deleteMetadata(key: string) { + this.rows.delete(key); + } + async getMetadataByWebId(webId: string) { + return [...this.rows.values()].filter((r) => r.webId === webId); + } + async deleteMetadataByWebId(webId: string) { + for (const [k, v] of this.rows) if (v.webId === webId) this.rows.delete(k); + } + async clearMetadata() { + this.rows.clear(); + } + async clearNonces() { + this.noncesCleared++; + } +} + +const WEBID_A = 'https://alice.pod.example/profile/card#me'; +const WEBID_B = 'https://bob.pod.example/profile/card#me'; +const SCOPE_PM = 'https://pm.example'; +const SCOPE_B = 'https://appB.example'; +const URL = 'https://alice.pod.example/finance/budget.ttl'; + +function allow(grantScope: string) { + return async () => ({ grantScope }); +} +const DENY_RES = new Response('Origin not granted access', { status: 403 }); +function deny() { + return async () => ({ deny: DENY_RES }); +} + +/** An egress double that records calls and returns a scripted sequence of responses. */ +function scriptedEgress(responses: Array<() => Response>): EgressPort & { + calls: Array<{ + url: string; + method: string; + headers: Record; + body: string | null; + }>; +} { + const calls: Array<{ + url: string; + method: string; + headers: Record; + body: string | null; + }> = []; + let i = 0; + return { + calls, + fetch: async (url, method, headers, body) => { + calls.push({ url, method, headers, body }); + const make = responses[Math.min(i, responses.length - 1)]; + i++; + return { response: make(), nonce: undefined }; + }, + }; +} + +describe('SharedReplica.read — gate FIRST, then never-authoritative SWR', () => { + let cache: FakeCache; + let db: FakeMetaStore; + beforeEach(() => { + cache = new FakeCache(); + db = new FakeMetaStore(); + }); + + it('a DENIED read never touches the cache OR the pod (gate is the first step)', async () => { + const egress = scriptedEgress([() => new Response('secret', { status: 200 })]); + const replica = new SharedReplica({ gate: deny(), egress, cache, db, webId: WEBID_A }); + const result = await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + expect('deny' in result).toBe(true); + expect(egress.calls.length).toBe(0); // NO egress + expect(cache.bytes.size).toBe(0); // NO cache touch + }); + + it('a cold read does an egress, stores bytes+metadata under the scoped key', async () => { + const egress = scriptedEgress([ + () => + new Response('@prefix : <#>.', { + status: 200, + headers: { etag: 'W/"v1"', 'content-type': 'text/turtle' }, + }), + ]); + const replica = new SharedReplica({ gate: allow(SCOPE_PM), egress, cache, db, webId: WEBID_A }); + const result = await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + expect('deny' in result).toBe(false); + if ('deny' in result) return; + expect(result.fromCache).toBe(false); + expect(await result.response.text()).toBe('@prefix : <#>.'); + expect(cache.bytes.size).toBe(1); + expect([...db.rows.values()][0]?.etag).toBe('W/"v1"'); + }); + + it('a warm read sends If-None-Match; a 304 serves the cached body (provisional confirmed)', async () => { + // First call 200 → cache; second call 304 → serve cache. + const egress = scriptedEgress([ + () => new Response('cached-body', { status: 200, headers: { etag: 'W/"v1"' } }), + () => new Response(null, { status: 304 }), + ]); + const replica = new SharedReplica({ gate: allow(SCOPE_PM), egress, cache, db, webId: WEBID_A }); + await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + const result = await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(result.fromCache).toBe(true); + expect(await result.response.text()).toBe('cached-body'); + // The revalidation carried the cached ETag. + expect(egress.calls[1]?.headers['If-None-Match']).toBe('W/"v1"'); + }); + + it('a 200 on revalidation REPLACES the stale cache (cache is never authoritative)', async () => { + const egress = scriptedEgress([ + () => new Response('old', { status: 200, headers: { etag: 'W/"v1"' } }), + () => new Response('new', { status: 200, headers: { etag: 'W/"v2"' } }), + ]); + const replica = new SharedReplica({ gate: allow(SCOPE_PM), egress, cache, db, webId: WEBID_A }); + await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + const result = await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(await result.response.text()).toBe('new'); + expect([...db.rows.values()][0]?.etag).toBe('W/"v2"'); + }); + + it('a now-403 on revalidation drops the cached entry and serves the live 403 (no stale serve)', async () => { + const egress = scriptedEgress([ + () => new Response('was-readable', { status: 200, headers: { etag: 'W/"v1"' } }), + () => new Response('forbidden', { status: 403 }), + ]); + const replica = new SharedReplica({ gate: allow(SCOPE_PM), egress, cache, db, webId: WEBID_A }); + await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + const result = await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(result.response.status).toBe(403); + expect(cache.bytes.size).toBe(0); // dropped — no stale body lingers + }); +}); + +describe('SharedReplica — the SHARED replica + cross-user/grant guards (gate on cache hit)', () => { + it('ONE replica serves the SAME bytes for the SAME (user, grant-scope) — no duplication', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + // Two reads with the same scope: first stores, second is a 304-served cache hit. Only one + // byte entry exists across both. + const egress = scriptedEgress([ + () => new Response('shared-bytes', { status: 200, headers: { etag: 'W/"v1"' } }), + () => new Response(null, { status: 304 }), + ]); + const replica = new SharedReplica({ gate: allow(SCOPE_PM), egress, cache, db, webId: WEBID_A }); + await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + const second = await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + if ('deny' in second) throw new Error('unexpected deny'); + expect(second.fromCache).toBe(true); + expect(cache.bytes.size).toBe(1); // single byte copy + }); + + it('CACHE-HIT GATE: a warm replica entry is NOT served to a DENIED origin (gate runs first)', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + // Warm the cache as the granted PM origin. + const warm = scriptedEgress([ + () => new Response('private', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: warm, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + expect(cache.bytes.size).toBe(1); // a warm body exists + + // Now an UN-granted origin reads the SAME URL — the injected gate denies. The denial must + // come BEFORE any cache read, so NO egress AND the body is NOT served. + const attacker = scriptedEgress([() => new Response('should-not-happen', { status: 200 })]); + const result = await new SharedReplica({ + gate: deny(), + egress: attacker, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + expect('deny' in result).toBe(true); + expect(attacker.calls.length).toBe(0); // gate denied before any work + }); + + it('CROSS-USER GUARD: user B cannot read user A bytes at the same URL+ETag (key includes WebID)', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + // User A (granted PM) warms the cache. + const warmA = scriptedEgress([ + () => new Response('ALICE-SECRET', { status: 200, headers: { etag: 'W/"shared-etag"' } }), + ]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: warmA, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + + // User B reads the SAME URL with the SAME ETag — must NOT match A's key. So B's read is a + // cold MISS that goes to the pod (no If-None-Match against A's etag) and gets B's own bytes. + const egressB = scriptedEgress([ + () => new Response('BOB-OWN', { status: 200, headers: { etag: 'W/"shared-etag"' } }), + ]); + const result = await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: egressB, + cache, + db, + webId: WEBID_B, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + // B's read was a MISS (no conditional header sent — it did not see A's etag). + expect(egressB.calls[0]?.headers['If-None-Match']).toBeUndefined(); + expect(await result.response.text()).toBe('BOB-OWN'); + // Two distinct byte entries now coexist (A's and B's), keyed apart by WebID. + expect(cache.bytes.size).toBe(2); + }); + + it('CROSS-GRANT GUARD: a different grant-scope is a distinct key (no shared body across scopes)', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + const warm = scriptedEgress([ + () => new Response('pm-bytes', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: warm, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + // appB (a different granted origin) reads the same URL — a MISS under its own scope key. + const egressB = scriptedEgress([ + () => new Response('b-bytes', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + const result = await new SharedReplica({ + gate: allow(SCOPE_B), + egress: egressB, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(egressB.calls[0]?.headers['If-None-Match']).toBeUndefined(); // distinct key → miss + expect(cache.bytes.size).toBe(2); + }); +}); + +describe('SharedReplica.write — synchronous write-through with If-Match', () => { + let cache: FakeCache; + let db: FakeMetaStore; + beforeEach(() => { + cache = new FakeCache(); + db = new FakeMetaStore(); + }); + + it('a DENIED write never reaches the pod', async () => { + const egress = scriptedEgress([() => new Response('', { status: 200 })]); + const replica = new SharedReplica({ gate: deny(), egress, cache, db, webId: WEBID_A }); + const result = await replica.write(URL, 'PUT', {}, 'data', undefined); + expect('deny' in result).toBe(true); + expect(egress.calls.length).toBe(0); + }); + + it('attaches If-Match from the cached ETag when the caller supplied no precondition (lost-update guard)', async () => { + // Warm a read so a cached ETag exists, then write with no precondition. + const readEgress = scriptedEgress([ + () => new Response('v1', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: readEgress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + const writeEgress = scriptedEgress([() => new Response('', { status: 200 })]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: writeEgress, + cache, + db, + webId: WEBID_A, + }).write(URL, 'PUT', {}, 'new-data', undefined); + expect(writeEgress.calls[0]?.headers['If-Match']).toBe('W/"v1"'); + }); + + it('does NOT override a caller-supplied precondition', async () => { + const readEgress = scriptedEgress([ + () => new Response('v1', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: readEgress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + const writeEgress = scriptedEgress([() => new Response('', { status: 200 })]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: writeEgress, + cache, + db, + webId: WEBID_A, + }).write(URL, 'PUT', { 'If-None-Match': '*' }, 'data', undefined); + // The caller's precondition is honoured; we do NOT add an If-Match on top. + expect(writeEgress.calls[0]?.headers['If-Match']).toBeUndefined(); + expect(writeEgress.calls[0]?.headers['If-None-Match']).toBe('*'); + }); + + it('a 2xx write INVALIDATES the shared replica entry (a stale copy is dropped)', async () => { + const readEgress = scriptedEgress([ + () => new Response('v1', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: readEgress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + expect(cache.bytes.size).toBe(1); + const writeEgress = scriptedEgress([() => new Response('', { status: 200 })]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: writeEgress, + cache, + db, + webId: WEBID_A, + }).write(URL, 'PUT', {}, 'new', undefined); + expect(cache.bytes.size).toBe(0); // invalidated; next read re-fetches + }); + + it('a 412 conflict is surfaced verbatim and the cache is NOT updated (no silent queue)', async () => { + const readEgress = scriptedEgress([ + () => new Response('v1', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: readEgress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + const writeEgress = scriptedEgress([ + () => new Response('Precondition Failed', { status: 412 }), + ]); + const result = await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: writeEgress, + cache, + db, + webId: WEBID_A, + }).write(URL, 'PUT', {}, 'new', undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(result.response.status).toBe(412); + expect(cache.bytes.size).toBe(1); // unchanged — the cached body survives the failed write + }); +}); + +describe('purgeReplica — synchronous-before-serve logout purge', () => { + it.each([0, 1])('retains retry metadata when byte deletion %i fails', async (failureIndex) => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + await seedResource(cache, db, WEBID_A, SCOPE_PM, URL); + await seedResource(cache, db, WEBID_A, SCOPE_B, URL); + await seedResource(cache, db, WEBID_B, SCOPE_PM, URL); + const rows = [...db.rows.values()]; + const remove = cache.delete.bind(cache); + const failedKey = [...cache.bytes.keys()][failureIndex]; + const deletion = vi.spyOn(cache, 'delete').mockImplementation(async (request) => { + if (request.url === failedKey) throw new Error('fixture cache deletion failed'); + return remove(request); + }); + + await expect(purgeReplica(cache, db, WEBID_A)).rejects.toThrow('cache deletion failed'); + expect([...db.rows.values()]).toEqual(rows); + expect(cache.bytes.has(failedKey)).toBe(true); + expect(db.noncesCleared).toBe(0); + + deletion.mockRestore(); + // Already-deleted bytes return false on retry; absence still permits completion. + await purgeReplica(cache, db, WEBID_A); + expect([...db.rows.values()]).toEqual(rows.filter((row) => row.webId === WEBID_B)); + expect(cache.bytes.size).toBe(1); + expect(db.noncesCleared).toBe(1); + }); + + it("sweeps this identity's byte keys that no metadata row indexes, and only those", async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + await seedResource(cache, db, WEBID_A, SCOPE_PM, URL); + await seedResource(cache, db, WEBID_B, SCOPE_PM, URL); + // Orphan both identities' bytes, e.g. a worker killed between the byte and metadata writes. + db.rows.clear(); + // A WebID that extends A's must not match A's key prefix. + await seedResource(cache, new FakeMetaStore(), `${WEBID_A}2`, SCOPE_PM, URL); + + const purged = await purgeReplica(cache, db, WEBID_A); + + expect(purged).toEqual({ bytesDropped: 1, metaDropped: 0 }); + const remaining = [...cache.bytes.keys()].map((url) => decodeURIComponent(url)); + expect(remaining).toHaveLength(2); + expect(remaining.some((key) => key.includes(`${WEBID_B}\u0000`))).toBe(true); + expect(remaining.some((key) => key.includes(`${WEBID_A}2\u0000`))).toBe(true); + }); + + it('a failed invalidation keeps its metadata, so the slice purge still removes the bytes', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + await seedResource(cache, db, WEBID_A, SCOPE_PM, FINANCE); + const remove = cache.delete.bind(cache); + const deletion = vi.spyOn(cache, 'delete').mockRejectedValue(new Error('transient')); + // The resource is now gone: the read drops the cached entry, but the byte delete fails. + const egress = scriptedEgress([() => new Response('gone', { status: 404 })]); + const replica = new SharedReplica({ gate: allow(SCOPE_PM), egress, cache, db, webId: WEBID_A }); + const result = await replica.read(FINANCE, 'GET', { Accept: 'text/turtle' }, undefined); + expect('response' in result && result.response.status).toBe(404); + expect(cache.bytes.size).toBe(1); + expect(db.rows.size).toBe(1); + + deletion.mockImplementation(remove); + await purgeReplicaSlice(cache, db, { + webId: WEBID_A, + grantScope: SCOPE_PM, + targets: FINANCE_TARGETS, + }); + expect(cache.bytes.size).toBe(0); + expect(db.rows.size).toBe(0); + }); + + it('drops exactly the prior identity rows + clears nonces (scoped purge)', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + // Seed A and B rows + their bytes. + for (const [webId, body] of [ + [WEBID_A, 'a'], + [WEBID_B, 'b'], + ] as const) { + const egress = scriptedEgress([ + () => new Response(body, { status: 200, headers: { etag: 'W/"x"' } }), + ]); + await new SharedReplica({ gate: allow(SCOPE_PM), egress, cache, db, webId }).read( + URL, + 'GET', + { Accept: 'text/turtle' }, + undefined, + ); + } + expect(cache.bytes.size).toBe(2); + const purged = await purgeReplica(cache, db, WEBID_A); + expect(purged.metaDropped).toBe(1); + expect(purged.bytesDropped).toBe(1); + expect(db.noncesCleared).toBe(1); + // B survives; A is gone. + expect([...db.rows.values()].every((r) => r.webId === WEBID_B)).toBe(true); + expect(cache.bytes.size).toBe(1); + }); + + it('a null prior WebID purges EVERYTHING incl. CacheStorage BYTES (Medium #4: bytes, not just metadata)', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + // Seed TWO identities so a null purge must wipe ALL bytes (not just one WebID's rows). + for (const [webId, body] of [ + [WEBID_A, 'a-bytes'], + [WEBID_B, 'b-bytes'], + ] as const) { + const egress = scriptedEgress([ + () => new Response(body, { status: 200, headers: { etag: 'W/"x"' } }), + ]); + await new SharedReplica({ gate: allow(SCOPE_PM), egress, cache, db, webId }).read( + URL, + 'GET', + { Accept: 'text/turtle' }, + undefined, + ); + } + expect(cache.bytes.size).toBe(2); // bytes present before the purge + expect(db.rows.size).toBe(2); + + const purged = await purgeReplica(cache, db, null); + + // After a null purge: NO metadata rows AND NO CacheStorage byte entries remain. + expect(db.rows.size).toBe(0); + expect(cache.bytes.size).toBe(0); // the privacy fix: bytes are GONE, not orphaned + expect(purged.bytesDropped).toBe(2); + expect(db.noncesCleared).toBe(1); + }); + + it('a null purge with NO enumerator still clears metadata + nonces (defensive default)', async () => { + // The default enumerator is cache.keys(); pass a cache whose keys() throws to prove the + // metadata + nonce clear still runs (best-effort byte deletion, never load-bearing). + const db = new FakeMetaStore(); + const throwingCache: ReplicaByteCache = { + match: async () => undefined, + put: async () => {}, + delete: async () => false, + keys: async () => { + throw new Error('cache keys unavailable'); + }, + }; + const purged = await purgeReplica(throwingCache, db, null); + expect(purged.bytesDropped).toBe(0); + expect(db.noncesCleared).toBe(1); + }); +}); + +// --- Per-class replica-slice purge (access-management residual b) ---------------------- + +const FINANCE = 'https://alice.pod.example/finance/statement.ttl'; +const HEALTH = 'https://alice.pod.example/health/record.ttl'; +const FINANCE_TARGETS = [{ url: 'https://alice.pod.example/finance/', kind: 'container' as const }]; + +/** Populate the replica with a resource's bytes+metadata under (webId, grantScope) via a read. */ +async function seedResource( + cache: FakeCache, + db: FakeMetaStore, + webId: string, + grantScope: string, + url: string, +): Promise { + const egress = scriptedEgress([ + () => new Response(`bytes-of-${url}`, { status: 200, headers: { etag: 'W/"e"' } }), + ]); + await new SharedReplica({ gate: allow(grantScope), egress, cache, db, webId }).read( + url, + 'GET', + { Accept: 'text/turtle' }, + undefined, + ); +} + +describe('purgeReplicaSlice — per-class revoke/expiry byte purge (residual b)', () => { + it('drops exactly the revoked class slice; other classes remain resident', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + await seedResource(cache, db, WEBID_A, SCOPE_PM, FINANCE); + await seedResource(cache, db, WEBID_A, SCOPE_PM, HEALTH); + expect(cache.bytes.size).toBe(2); + expect(db.rows.size).toBe(2); + + const purged = await purgeReplicaSlice(cache, db, { + webId: WEBID_A, + grantScope: SCOPE_PM, + targets: FINANCE_TARGETS, + }); + + expect(purged).toEqual({ bytesDropped: 1, metaDropped: 1 }); + // ZERO bytes of the finance class resident; health untouched. + const remaining = [...db.rows.values()]; + expect(remaining).toHaveLength(1); + expect(remaining[0]?.resourceUrl).toBe(HEALTH); + expect(cache.bytes.size).toBe(1); + }); + + it("leaves ANOTHER app-origin's (grantScope) copy of the same resource intact", async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + // Two different app origins each cached the SAME finance resource under their own grant-scope. + await seedResource(cache, db, WEBID_A, SCOPE_PM, FINANCE); + await seedResource(cache, db, WEBID_A, SCOPE_B, FINANCE); + expect(cache.bytes.size).toBe(2); + + // Revoke only SCOPE_PM's finance grant. + const purged = await purgeReplicaSlice(cache, db, { + webId: WEBID_A, + grantScope: SCOPE_PM, + targets: FINANCE_TARGETS, + }); + + expect(purged.metaDropped).toBe(1); + const remaining = [...db.rows.values()]; + expect(remaining).toHaveLength(1); + expect(remaining[0]?.grantScope).toBe(SCOPE_B); // appB's copy survives + }); + + it("never touches another user's (webId) rows", async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + await seedResource(cache, db, WEBID_A, SCOPE_PM, FINANCE); + await seedResource(cache, db, WEBID_B, SCOPE_PM, FINANCE); + + await purgeReplicaSlice(cache, db, { + webId: WEBID_A, + grantScope: SCOPE_PM, + targets: FINANCE_TARGETS, + }); + + const remaining = [...db.rows.values()]; + expect(remaining).toHaveLength(1); + expect(remaining[0]?.webId).toBe(WEBID_B); + }); + + it('RETAINS a resource still covered by another live (overlapping) grant of the same scope', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + await seedResource(cache, db, WEBID_A, SCOPE_PM, FINANCE); + // Revoke one grant whose targets cover finance/, but a SURVIVING grant also covers finance/. + const purged = await purgeReplicaSlice(cache, db, { + webId: WEBID_A, + grantScope: SCOPE_PM, + targets: FINANCE_TARGETS, + retainTargets: [{ url: 'https://alice.pod.example/finance/', kind: 'container' }], + }); + // The resource is KEPT — the survivor still authorises it (no needless eviction). + expect(purged).toEqual({ bytesDropped: 0, metaDropped: 0 }); + expect(db.rows.size).toBe(1); + expect(cache.bytes.size).toBe(1); + }); + + it('purges a NON-overlapping resource even when retainTargets cover a DIFFERENT resource', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + await seedResource(cache, db, WEBID_A, SCOPE_PM, FINANCE); + const purged = await purgeReplicaSlice(cache, db, { + webId: WEBID_A, + grantScope: SCOPE_PM, + targets: FINANCE_TARGETS, + // A surviving grant covers only health/, not the finance resource → finance is still purged. + retainTargets: [{ url: 'https://alice.pod.example/health/', kind: 'container' }], + }); + expect(purged).toEqual({ bytesDropped: 1, metaDropped: 1 }); + expect(db.rows.size).toBe(0); + }); + + it('an empty target set purges NOTHING (fail-safe no-op)', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + await seedResource(cache, db, WEBID_A, SCOPE_PM, FINANCE); + const purged = await purgeReplicaSlice(cache, db, { + webId: WEBID_A, + grantScope: SCOPE_PM, + targets: [], + }); + expect(purged).toEqual({ bytesDropped: 0, metaDropped: 0 }); + expect(db.rows.size).toBe(1); + }); + + it('keeps the metadata of a row whose byte delete fails, so the profile purge still finds it', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + const OTHER_FINANCE = 'https://alice.pod.example/finance/payslip.ttl'; + await seedResource(cache, db, WEBID_A, SCOPE_PM, FINANCE); + await seedResource(cache, db, WEBID_A, SCOPE_PM, OTHER_FINANCE); + const [stuckKey] = [...cache.bytes.keys()]; + // One byte delete fails; the rest of the slice is still purged. + const realDelete = cache.delete.bind(cache); + cache.delete = async (request: Request) => { + if (request.url === stuckKey) throw new Error('cache delete failed'); + return realDelete(request); + }; + + const purged = await purgeReplicaSlice(cache, db, { + webId: WEBID_A, + grantScope: SCOPE_PM, + targets: FINANCE_TARGETS, + }); + + expect(purged).toEqual({ bytesDropped: 1, metaDropped: 1 }); + expect([...cache.bytes.keys()]).toEqual([stuckKey]); + expect(db.rows.size).toBe(1); + + // The surviving row still indexes the stuck bytes, so the later profile purge removes them. + cache.delete = realDelete; + expect(await purgeReplica(cache, db, WEBID_A)).toMatchObject({ + bytesDropped: 1, + metaDropped: 1, + }); + expect(cache.bytes.size).toBe(0); + expect(db.rows.size).toBe(0); + }); +}); + +describe('SharedReplica.read — Medium #3: a non-cacheable 2xx is still returned to the caller', () => { + it('a 2xx whose cache.put REJECTS (e.g. 206 / Vary:*) is returned LIVE, just un-cached', async () => { + // A cache that rejects put() exactly as the real Cache API does for a 206 Partial Content + // or a `Vary: *` response. The successful read must NOT fail — the live body is returned. + const db = new FakeMetaStore(); + let putAttempts = 0; + const rejectingPutCache: ReplicaByteCache = { + match: async () => undefined, + put: async () => { + putAttempts++; + throw new TypeError('Vary header contains * which is not allowed in cache.put'); + }, + delete: async () => false, + keys: async () => [], + }; + const egress = scriptedEgress([ + () => + new Response('partial-bytes', { + status: 206, + headers: { 'content-range': 'bytes 0-9/100', etag: 'W/"v1"' }, + }), + ]); + const result = await new SharedReplica({ + gate: allow(SCOPE_PM), + egress, + cache: rejectingPutCache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + // The request SUCCEEDS with the live body even though the persist failed. + expect(result.response.status).toBe(206); + expect(await result.response.text()).toBe('partial-bytes'); + expect(putAttempts).toBe(1); // we DID attempt to cache (best-effort), and tolerated the throw + // Nothing persisted: no metadata row was written for the un-cacheable response. + expect(db.rows.size).toBe(0); + }); + + it('a 2xx whose METADATA write rejects is still returned LIVE (best-effort persist)', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + // Make the metadata write throw; the live body must still come back. + db.putMetadata = async () => { + throw new Error('idb write failed'); + }; + const egress = scriptedEgress([ + () => new Response('the-bytes', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + const result = await new SharedReplica({ + gate: allow(SCOPE_PM), + egress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(result.response.status).toBe(200); + expect(await result.response.text()).toBe('the-bytes'); + // The orphaned byte entry (no companion metadata) was best-effort dropped. + expect(cache.bytes.size).toBe(0); + }); +}); + +describe('SharedReplica.read — Low #5: a HEAD response carries NO body', () => { + let cache: FakeCache; + let db: FakeMetaStore; + beforeEach(() => { + cache = new FakeCache(); + db = new FakeMetaStore(); + }); + + it('a fresh HEAD egress response is body-stripped (headers/status only)', async () => { + // Even if the pod stray-returns a body on HEAD, the replica strips it. + const egress = scriptedEgress([ + () => + new Response('SHOULD-NOT-BE-RETURNED', { + status: 200, + headers: { etag: 'W/"v1"', 'content-type': 'text/turtle' }, + }), + ]); + const result = await new SharedReplica({ + gate: allow(SCOPE_PM), + egress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'HEAD', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(result.response.status).toBe(200); + expect(await result.response.text()).toBe(''); // NO body + // The HEAD did not cache a body (only GET stores), so the cross-method cache stays empty. + expect(cache.bytes.size).toBe(0); + }); + + it('a 304-revalidated HEAD does NOT serve the cached GET body', async () => { + // First a GET warms the cache with a body; then a HEAD revalidates to a 304. The HEAD must + // serve headers/status only — NEVER the cached GET body (the Low #5 bug). + const getEgress = scriptedEgress([ + () => new Response('CACHED-GET-BODY', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: getEgress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + expect(cache.bytes.size).toBe(1); + + const headEgress = scriptedEgress([() => new Response(null, { status: 304 })]); + const result = await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: headEgress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'HEAD', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(result.fromCache).toBe(true); + expect(await result.response.text()).toBe(''); // body stripped — NOT 'CACHED-GET-BODY' + }); + + it('a HEAD on a non-2xx/non-304 live response is also body-stripped', async () => { + const egress = scriptedEgress([() => new Response('error-detail-body', { status: 404 })]); + const result = await new SharedReplica({ + gate: allow(SCOPE_PM), + egress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'HEAD', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(result.response.status).toBe(404); + expect(await result.response.text()).toBe(''); + }); +}); diff --git a/apps/extension/test/request-access-throttle.test.ts b/apps/extension/test/request-access-throttle.test.ts new file mode 100644 index 000000000..0281be394 --- /dev/null +++ b/apps/extension/test/request-access-throttle.test.ts @@ -0,0 +1,107 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// The PURE anti-spam decision behind `window.solid.requestAccess` (access-management residual c): +// the user-gesture requirement + the per-origin fixed-window rate limit. Exhaustive because this +// is the enforceable core of the throttle (the SW wiring is covered in service-worker-class-gate). + +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_REQUEST_ACCESS_THROTTLE, + decideRequestAccess, + type ThrottleState, +} from '../src/background/core/request-access-throttle'; + +const OPTS = { windowMs: 60_000, maxPerWindow: 3 }; + +describe('decideRequestAccess — user-gesture requirement (i)', () => { + it('REJECTS a call with NO user activation, without consuming quota', () => { + const d = decideRequestAccess(undefined, 1000, false, OPTS); + expect(d.allow).toBe(false); + expect(d.reason).toBe('user-gesture-required'); + // Quota untouched — the fresh window admitted nothing. + expect(d.nextState.count).toBe(0); + }); + + it('a no-gesture loop never consumes quota (count stays 0 across many calls)', () => { + let state: ThrottleState | undefined; + for (let i = 0; i < 100; i++) { + const d = decideRequestAccess(state, 1000 + i, false, OPTS); + expect(d.allow).toBe(false); + expect(d.reason).toBe('user-gesture-required'); + state = d.nextState; + } + expect(state?.count).toBe(0); + }); + + it('ADMITS a gesture-backed call (count advances)', () => { + const d = decideRequestAccess(undefined, 1000, true, OPTS); + expect(d.allow).toBe(true); + expect(d.reason).toBeUndefined(); + expect(d.nextState.count).toBe(1); + }); +}); + +describe('decideRequestAccess — per-origin rate limit (ii)', () => { + it('rate-limits a RAPID gesture-backed burst after maxPerWindow', () => { + let state: ThrottleState | undefined; + const results: boolean[] = []; + for (let i = 0; i < 5; i++) { + const d = decideRequestAccess(state, 1000 + i, true, OPTS); // all within one window + results.push(d.allow); + state = d.nextState; + } + // First 3 admitted, then throttled. + expect(results).toEqual([true, true, true, false, false]); + }); + + it('surfaces a rate-limited reason + a retryAfterMs', () => { + let state: ThrottleState | undefined; + for (let i = 0; i < 3; i++) state = decideRequestAccess(state, 1000, true, OPTS).nextState; + const d = decideRequestAccess(state, 1000, true, OPTS); + expect(d.allow).toBe(false); + expect(d.reason).toBe('rate-limited'); + // window started at 1000, windowMs 60_000, now 1000 → ~60_000ms remain. + expect(d.retryAfterMs).toBe(60_000); + }); + + it('a LEGITIMATE spaced request still works once the window rolls', () => { + let state: ThrottleState | undefined; + // Exhaust the window at t≈1000. + for (let i = 0; i < 3; i++) state = decideRequestAccess(state, 1000 + i, true, OPTS).nextState; + // Still within the window → denied. + expect(decideRequestAccess(state, 2000, true, OPTS).allow).toBe(false); + // After the window fully elapses → a fresh window admits it again. + const later = decideRequestAccess(state, 1000 + OPTS.windowMs, true, OPTS); + expect(later.allow).toBe(true); + expect(later.nextState.windowStart).toBe(1000 + OPTS.windowMs); + expect(later.nextState.count).toBe(1); + }); + + it('a rate-limited deny does NOT further advance the count (no unbounded growth)', () => { + let state: ThrottleState | undefined; + for (let i = 0; i < 3; i++) state = decideRequestAccess(state, 1000, true, OPTS).nextState; + expect(state?.count).toBe(3); + const d = decideRequestAccess(state, 1000, true, OPTS); + expect(d.nextState.count).toBe(3); // stays capped + }); + + it('treats a prior window whose start is in the FUTURE as stale (rolls it) — fail-safe clock skew', () => { + const future: ThrottleState = { windowStart: 10_000, count: 3 }; + const d = decideRequestAccess(future, 1000, true, OPTS); // now < windowStart + expect(d.allow).toBe(true); + expect(d.nextState.windowStart).toBe(1000); + expect(d.nextState.count).toBe(1); + }); +}); + +describe('decideRequestAccess — defaults', () => { + it('uses DEFAULT_REQUEST_ACCESS_THROTTLE (3 / 60s) when no opts passed', () => { + let state: ThrottleState | undefined; + for (let i = 0; i < DEFAULT_REQUEST_ACCESS_THROTTLE.maxPerWindow; i++) { + const d = decideRequestAccess(state, 1000, true); + expect(d.allow).toBe(true); + state = d.nextState; + } + expect(decideRequestAccess(state, 1000, true).allow).toBe(false); + }); +}); diff --git a/apps/extension/test/requesting-origin.test.ts b/apps/extension/test/requesting-origin.test.ts new file mode 100644 index 000000000..c7a32bf50 --- /dev/null +++ b/apps/extension/test/requesting-origin.test.ts @@ -0,0 +1,397 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// Adversarial unit tests for the per-REQUESTING-origin gate — the Phase-0 browser-side +// access boundary. These prove the load-bearing security properties as PURE functions: +// - dual-origin agreement (browser-attested sender ∧ page stamp), forged stamp → DENY +// - fail-closed on opaque / null / missing / mismatched origins +// - default-deny per-origin gate +// - same-origin-or-deny foreign-fetch policy +// - the granted-origin set computation (credential origins ∪ explicit grants) + +import { describe, expect, it } from 'vitest'; +import { + computeGrantedOrigins, + decideRequestingOrigin, + isForeignFetchAllowed, + isRequestingOriginGranted, + resolveRequestingOrigin, +} from '../src/background/core/requesting-origin'; + +describe('resolveRequestingOrigin — dual-origin agreement', () => { + it('resolves when the browser-attested sender.origin agrees with the page stamp', () => { + const o = resolveRequestingOrigin({ origin: 'https://app.example' }, 'https://app.example'); + expect(o).toBe('https://app.example'); + }); + + it('derives the attested origin from sender.url when sender.origin is absent', () => { + const o = resolveRequestingOrigin( + { url: 'https://app.example/page.html?x=1' }, + 'https://app.example', + ); + expect(o).toBe('https://app.example'); + }); + + it('DENIES a FORGED stamp that disagrees with the browser-attested origin', () => { + // A compromised renderer stamps a victim origin, but the browser attests the real one. + const o = resolveRequestingOrigin( + { origin: 'https://attacker.example' }, + 'https://victim.example', + ); + expect(o).toBeNull(); + }); + + it('DENIES when the stamp claims a credential origin but the sender is the attacker', () => { + const o = resolveRequestingOrigin( + { origin: 'https://attacker.example' }, + 'https://alice.pod.example', + ); + expect(o).toBeNull(); + }); + + it('DENIES a missing browser-attested origin (no origin, no url) — fail-closed', () => { + expect(resolveRequestingOrigin({}, 'https://app.example')).toBeNull(); + expect(resolveRequestingOrigin(undefined, 'https://app.example')).toBeNull(); + expect(resolveRequestingOrigin(null, 'https://app.example')).toBeNull(); + }); + + it('DENIES a missing page stamp even when the sender is attested — fail-closed', () => { + expect(resolveRequestingOrigin({ origin: 'https://app.example' }, undefined)).toBeNull(); + expect(resolveRequestingOrigin({ origin: 'https://app.example' }, null)).toBeNull(); + expect(resolveRequestingOrigin({ origin: 'https://app.example' }, '')).toBeNull(); + }); + + // These all yield an OPAQUE origin (the literal "null" sentinel, or unparseable). They must + // NEVER resolve to a usable origin and must NEVER share a "null" bucket. (Verified against + // Node's URL parser: about:blank / data: / file: / javascript: → origin "null"; the literal + // string "null" — what a sandboxed `srcdoc` iframe reports as location.origin — throws.) + it.each([ + ['about:blank', 'about:blank'], + ['data: URL', 'data:text/html,'], + ['file: URL', 'file:///etc/passwd'], + ['javascript: URL', 'javascript:alert(1)'], + ['srcdoc literal null', 'null'], + ])('DENIES an opaque %s stamp/sender (no shared null bucket)', (_label, opaque) => { + // opaque on BOTH sides + expect(resolveRequestingOrigin({ origin: opaque }, opaque)).toBeNull(); + // opaque attested, real stamp + expect(resolveRequestingOrigin({ url: opaque }, 'https://app.example')).toBeNull(); + // real attested, opaque stamp + expect(resolveRequestingOrigin({ origin: 'https://app.example' }, opaque)).toBeNull(); + }); + + it('a blob: URL legitimately inherits its creator origin (allowed when both agree)', () => { + // A blob URL cannot be forged across origins — the browser fixes its embedded origin to + // the creating context, so `sender` + stamp still agree on the real origin. This is the + // app reading through its own blob document, which is the genuine origin and is allowed. + expect( + resolveRequestingOrigin({ url: 'blob:https://app.example/uuid' }, 'https://app.example'), + ).toBe('https://app.example'); + // But a blob whose creator is the attacker cannot impersonate a victim stamp → DENY. + expect( + resolveRequestingOrigin( + { url: 'blob:https://attacker.example/uuid' }, + 'https://victim.example', + ), + ).toBeNull(); + }); + + it('treats a port difference as a mismatch (different origin) → DENY', () => { + expect( + resolveRequestingOrigin({ origin: 'https://app.example:8443' }, 'https://app.example'), + ).toBeNull(); + }); + + it('treats a scheme difference as a mismatch → DENY', () => { + expect( + resolveRequestingOrigin({ origin: 'http://app.example' }, 'https://app.example'), + ).toBeNull(); + }); + + it('DENIES an unparseable stamp', () => { + expect(resolveRequestingOrigin({ origin: 'https://app.example' }, 'not a url')).toBeNull(); + }); +}); + +describe('resolveRequestingOrigin — PRESENT-but-OPAQUE sender.origin must NOT fall back to sender.url (High #1)', () => { + // THE BUG (High #1): Chrome sets sender.origin to the literal string "null" for an opaque / + // sandboxed frame. The old code fell back to sender.url whenever sender.origin was falsy OR + // opaque, so an opaque sender with a NORMAL https sender.url + a matching stamp was laundered + // into a TRUSTED origin — a fail-OPEN of the opaque boundary. The fix: a PRESENT opaque + // sender.origin DENIES immediately and never consults sender.url. + + it('DENIES sender.origin === "null" (string) even with a valid https sender.url + matching stamp', () => { + // The exploit: opaque sender, but a believable https url + stamp that agree. + expect( + resolveRequestingOrigin( + { origin: 'null', url: 'https://app.example/page.html' }, + 'https://app.example', + ), + ).toBeNull(); + }); + + it('DENIES an opaque sender.origin even when sender.url, stamp AND the url-origin all agree', () => { + // Belt-and-braces: every page-controllable signal points at a real origin, but the + // browser-attested sender.origin is opaque → DENY, no url fallback. + expect( + resolveRequestingOrigin( + { origin: 'null', url: 'https://victim.example/' }, + 'https://victim.example', + ), + ).toBeNull(); + }); + + it.each([ + ['data:', 'data:text/html,'], + ['about:blank', 'about:blank'], + ['file:', 'file:///etc/passwd'], + ['javascript:', 'javascript:alert(1)'], + ['unparseable garbage', 'not a url at all'], + ['non-http scheme ftp:', 'ftp://app.example/'], + ['non-http scheme ws:', 'ws://app.example/'], + ])('DENIES a PRESENT but opaque/unparseable/non-http(s) sender.origin (%s) with a valid https url', (_label, opaqueOrigin) => { + expect( + resolveRequestingOrigin( + { origin: opaqueOrigin, url: 'https://app.example/page.html' }, + 'https://app.example', + ), + ).toBeNull(); + }); + + it('STILL resolves when sender.origin is genuinely ABSENT and sender.url + stamp agree (legit fallback preserved)', () => { + // undefined origin → derive from url (the legitimate Chrome-omits-origin case). + expect( + resolveRequestingOrigin({ url: 'https://app.example/page.html?x=1' }, 'https://app.example'), + ).toBe('https://app.example'); + }); + + it.each([ + ['undefined origin', undefined], + ['null origin', null], + ['empty-string origin', ''], + ])('treats %s as ABSENT → derives from sender.url (legit fallback preserved)', (_label, origin) => { + expect( + resolveRequestingOrigin( + { origin: origin as string | undefined, url: 'https://app.example/p.html' }, + 'https://app.example', + ), + ).toBe('https://app.example'); + }); + + it('a PRESENT real https sender.origin is used as the authority (the normal case) and ignores a divergent url', () => { + // sender.origin present and real: it is authoritative; sender.url is NOT consulted, so even a + // divergent url cannot widen access — agreement is checked against sender.origin + stamp. + expect( + resolveRequestingOrigin( + { origin: 'https://app.example', url: 'https://elsewhere.example/x' }, + 'https://app.example', + ), + ).toBe('https://app.example'); + }); + + it('the blob:-resolves-to-creator-origin case still works (sender.origin absent, blob url)', () => { + // A blob: URL is NOT opaque — it carries its creator origin, which the browser fixes. With + // sender.origin absent, the fallback derives the creator origin from the blob url. + expect( + resolveRequestingOrigin({ url: 'blob:https://app.example/uuid' }, 'https://app.example'), + ).toBe('https://app.example'); + }); +}); + +describe('isRequestingOriginGranted — default-deny', () => { + const granted = new Set(['https://app.example', 'https://alice.pod.example']); + + it('allows a granted origin', () => { + expect(isRequestingOriginGranted(granted, 'https://app.example')).toBe(true); + }); + + it('DENIES an un-granted origin (default-deny)', () => { + expect(isRequestingOriginGranted(granted, 'https://other.example')).toBe(false); + }); + + it('DENIES a null requesting origin (opaque/unresolved) — fail-closed', () => { + expect(isRequestingOriginGranted(granted, null)).toBe(false); + }); + + it('DENIES everything when the granted set is empty (no session, no grants)', () => { + expect(isRequestingOriginGranted(new Set(), 'https://app.example')).toBe(false); + expect(isRequestingOriginGranted(new Set(), null)).toBe(false); + }); +}); + +describe('isForeignFetchAllowed — same-origin-or-deny', () => { + it('allows an app to fetch its OWN origin through the proxy', () => { + expect(isForeignFetchAllowed('https://app.example', 'https://app.example/data.json')).toBe( + true, + ); + }); + + it('DENIES an app fetching an unrelated third-party origin (ambient-cookie proxy abuse)', () => { + expect(isForeignFetchAllowed('https://app.example', 'https://tracker.example/collect')).toBe( + false, + ); + }); + + it('DENIES a null requesting origin', () => { + expect(isForeignFetchAllowed(null, 'https://app.example/x')).toBe(false); + }); + + it('DENIES an unparseable target', () => { + expect(isForeignFetchAllowed('https://app.example', 'not a url')).toBe(false); + }); + + it('treats a port/scheme difference as a different (denied) origin', () => { + expect(isForeignFetchAllowed('https://app.example', 'https://app.example:9000/x')).toBe(false); + expect(isForeignFetchAllowed('https://app.example', 'http://app.example/x')).toBe(false); + }); +}); + +describe('computeGrantedOrigins — credential origins ∪ explicit grants', () => { + it('unions the session credential origins and explicit grants', () => { + const granted = computeGrantedOrigins({ + credentialOrigins: new Set(['https://alice.pod.example', 'https://idp.example']), + explicitGrants: ['https://pm.example', 'https://issues.example'], + }); + expect(granted.has('https://alice.pod.example')).toBe(true); + expect(granted.has('https://idp.example')).toBe(true); + expect(granted.has('https://pm.example')).toBe(true); + expect(granted.has('https://issues.example')).toBe(true); + }); + + it('drops opaque / non-URL candidates (fail-closed)', () => { + const granted = computeGrantedOrigins({ + credentialOrigins: new Set(['null', '']), + explicitGrants: ['about:blank', 'not a url', 'https://ok.example'], + }); + expect(granted.has('null')).toBe(false); + expect(granted.has('https://ok.example')).toBe(true); + expect(granted.size).toBe(1); + }); + + it('yields an EMPTY set with no inputs (default-deny for every origin)', () => { + expect(computeGrantedOrigins({}).size).toBe(0); + }); + + it('canonicalises a credential URL down to its origin', () => { + const granted = computeGrantedOrigins({ + explicitGrants: ['https://app.example/some/path?q=1#f'], + }); + expect(granted.has('https://app.example')).toBe(true); + expect(granted.size).toBe(1); + }); +}); + +describe('decideRequestingOrigin — the COMPLETE gate (the adversarial scenarios)', () => { + // A granted app reading its OWN pod through the extension. + const credentialOrigins = new Set(['https://alice.pod.example', 'https://idp.example']); + const explicitGrants = ['https://pm.example']; + const inputs = { credentialOrigins, explicitGrants }; + + it('ALLOWS a granted app reading a credential (pod) resource', () => { + const d = decideRequestingOrigin( + { origin: 'https://pm.example' }, + 'https://pm.example', + 'https://alice.pod.example/private/notes.ttl', + inputs, + ); + expect(d.deny).toBeNull(); + expect(d.requestingOrigin).toBe('https://pm.example'); + }); + + it('ALLOWS an app served from the pod origin itself (credential origin auto-granted)', () => { + const d = decideRequestingOrigin( + { origin: 'https://alice.pod.example' }, + 'https://alice.pod.example', + 'https://alice.pod.example/private/notes.ttl', + inputs, + ); + expect(d.deny).toBeNull(); + }); + + it('DENIES a FORGED sender.origin (renderer spoofs a granted origin)', () => { + // The attacker's renderer stamps a granted origin, but the browser attests the attacker. + const d = decideRequestingOrigin( + { origin: 'https://attacker.example' }, + 'https://pm.example', + 'https://alice.pod.example/private/notes.ttl', + inputs, + ); + expect(d.deny).toBe('forbidden-origin'); + expect(d.requestingOrigin).toBeNull(); + }); + + it('DENIES an UN-GRANTED origin attempting a whole-pod read (default-deny)', () => { + const d = decideRequestingOrigin( + { origin: 'https://evil.example' }, + 'https://evil.example', + 'https://alice.pod.example/', // the whole pod root + inputs, + ); + expect(d.deny).toBe('origin-not-granted'); + expect(d.requestingOrigin).toBeNull(); + }); + + it('DENIES app-A reading app-B-only data when A is not granted (cross-app isolation)', () => { + // appB is granted; appA is not. appA tries to read the pod resource appB uses. + const withB = { credentialOrigins, explicitGrants: ['https://appB.example'] }; + const d = decideRequestingOrigin( + { origin: 'https://appA.example' }, + 'https://appA.example', + 'https://alice.pod.example/appB-data/secret.ttl', + withB, + ); + expect(d.deny).toBe('origin-not-granted'); + }); + + it('DENIES an opaque/null requesting origin (fail-safe)', () => { + const d = decideRequestingOrigin( + { origin: 'null' }, + 'null', + 'https://alice.pod.example/x', + inputs, + ); + expect(d.deny).toBe('forbidden-origin'); + }); + + it('FAIL-CLOSED on a boot/SW-restart race: empty grant inputs deny everything', () => { + // On a cold wake the SW re-reads storage; if nothing is loaded the grant set is empty. + // A request that WOULD be allowed once loaded must be DENIED while unloaded — never served. + const cold = decideRequestingOrigin( + { origin: 'https://pm.example' }, + 'https://pm.example', + 'https://alice.pod.example/private/notes.ttl', + {}, // no credentialOrigins, no explicitGrants — nothing loaded yet + ); + expect(cold.deny).toBe('origin-not-granted'); + expect(cold.requestingOrigin).toBeNull(); + }); + + it('DENIES a granted app fetching an unrelated FOREIGN third party (ambient-cookie proxy)', () => { + const d = decideRequestingOrigin( + { origin: 'https://pm.example' }, + 'https://pm.example', + 'https://tracker.example/collect', // foreign, not a credential origin, not same-origin + inputs, + ); + expect(d.deny).toBe('cross-origin-foreign'); + }); + + it('ALLOWS a granted app fetching its OWN origin (same-origin foreign fetch)', () => { + const d = decideRequestingOrigin( + { origin: 'https://pm.example' }, + 'https://pm.example', + 'https://pm.example/api/local.json', // same origin as the requester + inputs, + ); + expect(d.deny).toBeNull(); + }); + + it('DENIES when the stamp is missing even with a valid attested+granted sender', () => { + const d = decideRequestingOrigin( + { origin: 'https://pm.example' }, + undefined, + 'https://alice.pod.example/x', + inputs, + ); + expect(d.deny).toBe('forbidden-origin'); + }); +}); diff --git a/apps/extension/test/response-headers.test.ts b/apps/extension/test/response-headers.test.ts new file mode 100644 index 000000000..3ac6373f0 --- /dev/null +++ b/apps/extension/test/response-headers.test.ts @@ -0,0 +1,88 @@ +// AUTHORED-BY Claude Opus 4.8 + +import { describe, expect, it } from 'vitest'; +import { + ALLOWED_RESPONSE_HEADERS, + filterResponseHeaders, +} from '../src/background/core/response-headers'; + +describe('filterResponseHeaders — response-header allowlist (defense-in-depth)', () => { + it('keeps the Solid/LDP + standard response headers an app needs', () => { + const headers = new Headers({ + 'content-type': 'text/turtle', + 'content-length': '128', + etag: '"abc123"', + 'last-modified': 'Wed, 21 Oct 2025 07:28:00 GMT', + link: '; rel="type"', + allow: 'GET, HEAD, OPTIONS, PUT, PATCH', + 'accept-patch': 'text/n3, application/sparql-update', + 'accept-post': '*/*', + 'accept-put': '*/*', + 'preference-applied': 'return=representation', + 'accept-ranges': 'bytes', + 'wac-allow': 'user="read write",public="read"', + 'www-authenticate': 'DPoP error="invalid_token"', + location: 'https://alice.pod.example/new', + 'updates-via': 'wss://alice.pod.example/', + }); + + const out = filterResponseHeaders(headers); + + expect(out['content-type']).toBe('text/turtle'); + expect(out['content-length']).toBe('128'); + expect(out.etag).toBe('"abc123"'); + expect(out['last-modified']).toBe('Wed, 21 Oct 2025 07:28:00 GMT'); + expect(out.link).toBe('; rel="type"'); + expect(out.allow).toBe('GET, HEAD, OPTIONS, PUT, PATCH'); + expect(out['accept-patch']).toBe('text/n3, application/sparql-update'); + expect(out['accept-post']).toBe('*/*'); + expect(out['accept-put']).toBe('*/*'); + expect(out['preference-applied']).toBe('return=representation'); + expect(out['accept-ranges']).toBe('bytes'); + expect(out['wac-allow']).toBe('user="read write",public="read"'); + expect(out['www-authenticate']).toBe('DPoP error="invalid_token"'); + expect(out.location).toBe('https://alice.pod.example/new'); + expect(out['updates-via']).toBe('wss://alice.pod.example/'); + }); + + it('strips headers that are NOT on the allowlist (server fingerprints / unvetted state)', () => { + const headers = new Headers({ + 'content-type': 'text/turtle', + server: 'CommunitySolidServer/7.1.3', + 'x-powered-by': 'Express', + 'x-internal-trace-id': 'deadbeef', + 'set-cookie': 'session=secret; HttpOnly', + 'strict-transport-security': 'max-age=63072000', + 'x-custom-server-header': 'leak-me', + }); + + const out = filterResponseHeaders(headers); + + // allowed survives + expect(out['content-type']).toBe('text/turtle'); + // everything off the allowlist is dropped + expect(out.server).toBeUndefined(); + expect(out['x-powered-by']).toBeUndefined(); + expect(out['x-internal-trace-id']).toBeUndefined(); + expect(out['set-cookie']).toBeUndefined(); + expect(out['strict-transport-security']).toBeUndefined(); + expect(out['x-custom-server-header']).toBeUndefined(); + }); + + it('matches the allowlist case-insensitively (HTTP header names are case-insensitive)', () => { + // Headers normalises to lower-case internally, but assert the membership test itself + // is case-insensitive by checking an upper-cased allowlist entry would still pass. + expect(ALLOWED_RESPONSE_HEADERS.has('etag')).toBe(true); + const headers = new Headers(); + headers.set('ETag', '"v1"'); // server-supplied mixed case + const out = filterResponseHeaders(headers); + // the single kept value, whatever casing the Headers object reports it under + const values = Object.values(out); + expect(values).toEqual(['"v1"']); + }); + + it('returns an empty record when the server emits no allowlisted headers', () => { + const headers = new Headers({ server: 'nginx', 'x-foo': 'bar' }); + expect(filterResponseHeaders(headers)).toEqual({}); + }); +}); diff --git a/apps/extension/test/service-worker-class-gate.test.ts b/apps/extension/test/service-worker-class-gate.test.ts new file mode 100644 index 000000000..3fbda6b0b --- /dev/null +++ b/apps/extension/test/service-worker-class-gate.test.ts @@ -0,0 +1,2287 @@ +import type { OriginGrant } from '../src/background/core/origin-grant'; +import { seedOriginGrantFixtures } from './profile-fixtures'; +// AUTHORED-BY Claude Opus 4.8 +// +// INTEGRATION tests for the Phase-2 PER-DATA-CLASS gate AS WIRED INTO the service worker. These +// drive the REAL `chrome.runtime.onMessage` listener with an in-memory chrome + captured fetch, +// proving the enforcement end-to-end (not just the pure logic in class-grant.test.ts): +// +// - a class-only origin READ inside its pinned targets → ALLOW (egress); +// - out-of-pinned-set (wrong resource / wrong mode) → DENY 403, distinguishable reason, NO egress; +// - an EXPIRED grant → DENY + pruned from storage; +// - REVOCATION (SOLID_REVOKE_GRANT) takes effect on the NEXT request; +// - an UNKNOWN / unresolved class (empty targets) → fail-closed DENY; +// - a DIFFERENT-WebID grant is ignored (session boundary); +// - LEGACY origin-only (granted-origins) grants keep WHOLE-POD access (backwards compatibility); +// - the requestAccess → consent-window → grant flow mints a pinned grant; listGrants reflects it. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ClassGrant } from '../src/background/core/class-grant'; +import { generateDpopKeyPair } from '../src/background/core/dpop'; +import type { StoredDpopKeyRecord } from '../src/background/core/dpop-key-db'; +import { ProfileStore } from '../src/background/core/profile-store'; +import type { ReplicaMetadata } from '../src/background/core/replica-db'; +import type { StoredSession } from '../src/background/session-store'; +import { MemoryProfileDb, profileCredentials } from './profile-fixtures'; + +let profileDb: MemoryProfileDb; +vi.mock('../src/background/profile-db', () => ({ + ProfileDb: { open: async () => profileDb }, +})); + +// --- An in-memory DpopKeyDb double (see service-worker-gate.test.ts for the rationale) -- +let idbDpopKey: StoredDpopKeyRecord | undefined; +class FakeDpopKeyDb { + static async open() { + return new FakeDpopKeyDb(); + } + async get() { + return idbDpopKey; + } + async put(record: StoredDpopKeyRecord) { + idbDpopKey = record; + } + async clear() { + idbDpopKey = undefined; + } + close() {} +} +vi.mock('../src/background/core/dpop-key-db', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, DpopKeyDb: FakeDpopKeyDb }; +}); + +async function seedDpopKeyPair(keyPair: CryptoKeyPair): Promise { + idbDpopKey = { + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + publicJwk: (await crypto.subtle.exportKey('jwk', keyPair.publicKey)) as JsonWebKey, + }; +} + +// --- In-memory replica stores (as in service-worker-gate.test.ts) --------------------- +const idbMeta = new Map(); +const idbNonces = new Map(); +class FakeReplicaDb { + static async open() { + return new FakeReplicaDb(); + } + async getMetadata(key: string) { + return idbMeta.get(key); + } + async putMetadata(record: ReplicaMetadata) { + idbMeta.set(record.key, record); + } + async deleteMetadata(key: string) { + idbMeta.delete(key); + } + async getMetadataByWebId(webId: string) { + return [...idbMeta.values()].filter((r) => r.webId === webId); + } + async deleteMetadataByWebId(webId: string) { + for (const [k, v] of idbMeta) if (v.webId === webId) idbMeta.delete(k); + } + async clearMetadata() { + idbMeta.clear(); + } + async getNonce(origin: string) { + return idbNonces.get(origin); + } + async setNonce(origin: string, nonce: string) { + idbNonces.set(origin, nonce); + } + async clearNonces() { + idbNonces.clear(); + } + close() {} +} +vi.mock('../src/background/core/replica-db', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ReplicaDb: FakeReplicaDb }; +}); + +const cacheStore = new Map< + string, + { body: string; status: number; headers: Record } +>(); +function installCaches(): void { + (globalThis as unknown as { caches: unknown }).caches = { + open: async () => ({ + match: async (request: Request) => { + const e = cacheStore.get(request.url); + return e ? new Response(e.body, { status: e.status, headers: e.headers }) : undefined; + }, + put: async (request: Request, response: Response) => { + cacheStore.set(request.url, { + body: await response.text(), + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + }); + }, + delete: async (request: Request) => cacheStore.delete(request.url), + keys: async () => [...cacheStore.keys()].map((url) => new Request(url)), + }), + }; +} + +interface CapturedSender { + frameId?: number; + tab?: { id: number; url: string }; + origin?: string; + url?: string; + id?: string; +} +type MessageListener = ( + message: unknown, + sender: CapturedSender, + sendResponse: (r: unknown) => void, +) => boolean | undefined; + +let storage: Map; +let messageListeners: MessageListener[]; +let fetchCalls: Array<{ url: string; init?: RequestInit }>; +let fetchImpl: (url: string, init?: RequestInit) => Promise; +let createdWindows: string[]; +let registrations: Map; +let alarmListener: (alarm: { name: string }) => void; + +const EXTENSION_ID = 'abcdefghijklmnopabcdefghijklmnop'; +const EXT_SENDER: CapturedSender = { + origin: `chrome-extension://${EXTENSION_ID}`, + id: EXTENSION_ID, +}; + +const WEBID = 'https://alice.pod.example/profile/card#me'; + +function installChrome(): void { + storage = new Map(); + messageListeners = []; + fetchCalls = []; + createdWindows = []; + registrations = new Map(); + + const chromeStub = { + scripting: { + getRegisteredContentScripts: async () => [...registrations.values()], + registerContentScripts: async (scripts: chrome.scripting.RegisteredContentScript[]) => { + for (const script of scripts) registrations.set(script.id, script); + }, + unregisterContentScripts: async ({ ids }: { ids: string[] }) => { + for (const id of ids) registrations.delete(id); + }, + }, + alarms: { + clear: vi.fn(async () => true), + create: vi.fn(async () => {}), + onAlarm: { + addListener: (fn: typeof alarmListener) => { + alarmListener = fn; + }, + }, + }, + runtime: { + id: EXTENSION_ID, + lastError: undefined as { message?: string } | undefined, + getURL: (p: string) => `chrome-extension://${EXTENSION_ID}/${p}`, + onMessage: { addListener: (fn: MessageListener) => messageListeners.push(fn) }, + onInstalled: { addListener: (_fn: () => void) => {} }, + sendMessage: (_m: unknown, _cb?: (r: unknown) => void) => Promise.resolve(), + }, + storage: { + local: { + get: async (key: string) => { + const out: Record = {}; + if (storage.has(key)) out[key] = storage.get(key); + return out; + }, + set: async (obj: Record) => { + for (const [k, v] of Object.entries(obj)) storage.set(k, v); + }, + remove: async (key: string) => { + storage.delete(key); + }, + }, + }, + tabs: { query: async () => [], sendMessage: async () => {} }, + action: { + setIcon: async () => {}, + setBadgeText: async () => {}, + setBadgeBackgroundColor: async () => {}, + setTitle: async () => {}, + }, + contextMenus: { + create: () => {}, + remove: (_id: string, cb?: () => void) => cb?.(), + onClicked: { addListener: () => {} }, + }, + sidePanel: { setPanelBehavior: async () => {}, open: async () => {} }, + identity: { launchWebAuthFlow: async () => '' }, + windows: { + create: (opts: { url: string }, cb?: (w: unknown) => void) => { + createdWindows.push(opts.url); + cb?.({ id: createdWindows.length }); + }, + }, + }; + (globalThis as unknown as { chrome: unknown }).chrome = chromeStub; + + fetchImpl = async () => new Response('ok', { status: 200 }); + (globalThis as unknown as { fetch: typeof fetch }).fetch = vi.fn( + (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + fetchCalls.push({ url, init }); + return fetchImpl(url, init); + }, + ) as unknown as typeof fetch; +} + +function send( + message: Record, + sender: CapturedSender, +): Promise> { + return new Promise((resolve) => { + for (const listener of messageListeners) { + const handled = listener(message, sender, (r) => resolve(r as Record)); + if (handled) return; + } + // A sync-handled message (returns false after calling sendResponse) already resolved. + setTimeout(() => resolve({ error: 'no async listener handled' }), 0); + }); +} + +function sendFetch( + message: Record, + sender: CapturedSender, +): Promise> { + return send( + { + type: 'SOLID_FETCH_REQUEST', + requestId: 'r1', + method: 'GET', + headers: {}, + body: null, + ...message, + }, + sender, + ); +} + +async function seedSession(overrides: Partial = {}): Promise { + const keyPair = await generateDpopKeyPair(); + await seedDpopKeyPair(keyPair); + storage.set('solid:session', { + webId: WEBID, + accessToken: 'access-token-xyz', + refreshToken: 'refresh-xyz', + issuer: 'https://idp.example/', + tokenEndpoint: 'https://idp.example/token', + clientId: 'https://pm.example/clientid.jsonld', + expiresAt: Date.now() + 60 * 60 * 1000, + ...overrides, + } satisfies StoredSession); +} + +function classGrant(overrides: Partial = {}): ClassGrant { + return { + grantId: 'grant-1', + webId: WEBID, + origin: 'https://appx.example', + dataClass: 'https://w3id.org/jeswr/sectors/finance#Transaction', + modes: ['read'], + purpose: 'https://w3id.org/dpv#ServiceProvision', + expiresAt: Date.now() + 60 * 60 * 1000, + targets: [{ url: 'https://alice.pod.example/finance/', kind: 'container' }], + createdAt: Date.now(), + ...overrides, + }; +} + +async function loadWorker(): Promise { + seedOriginGrantFixtures(storage, profileDb); + messageListeners.length = 0; + vi.resetModules(); + await import('../src/background/service-worker'); + await new Promise((r) => setTimeout(r, 0)); +} + +const APPX = { + origin: 'https://appx.example', + url: 'https://appx.example/app', +} satisfies CapturedSender; + +beforeEach(() => { + profileDb = new MemoryProfileDb(); + installChrome(); + installCaches(); + cacheStore.clear(); + idbMeta.clear(); + idbNonces.clear(); + idbDpopKey = undefined; +}); + +describe('Phase-2 per-data-class gate (wired into the SW)', () => { + it('a class-only origin READS within its pinned container → ALLOW with egress', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant()]); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/finance/statement.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(res.status).toBe(200); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example/finance/'))).toBe( + true, + ); + }); + + it('DENIES a resource OUTSIDE the pinned targets — distinguishable reason, NO egress', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant()]); + await loadWorker(); + fetchCalls.length = 0; + + const res = await sendFetch( + { url: 'https://alice.pod.example/health/record.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(res.status).toBe(403); + expect(res.error).toBe('Out of grant scope'); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); + + it('DENIES a WRITE against a read-only grant (mode not satisfied)', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant({ modes: ['read'] })]); + await loadWorker(); + fetchCalls.length = 0; + + const res = await sendFetch( + { + url: 'https://alice.pod.example/finance/statement.ttl', + method: 'PUT', + body: ' .', + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(res.status).toBe(403); + expect(res.error).toBe('Out of grant scope'); + expect(fetchCalls.length).toBe(0); + }); + + it('ALLOWS a WRITE when the grant carries write mode', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant({ modes: ['write'] })]); + await loadWorker(); + + const res = await sendFetch( + { + url: 'https://alice.pod.example/finance/statement.ttl', + method: 'PUT', + body: ' .', + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(res.status).toBe(200); + }); + + it('an EXPIRED grant → DENY (no live grant) and is PRUNED from storage', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant({ expiresAt: Date.now() - 1000 })]); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/finance/statement.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + // An expired grant no longer makes the origin a granted requester → forbidden-origin. + expect(res.status).toBe(403); + // The expired grant was pruned on the fetch path. + expect( + (storage.get('solid:origin-grants') as Array<{ scope: { classes: unknown[] } }>)[0].scope + .classes, + ).toEqual([]); + }); + + it('an UNKNOWN / unresolved class (empty targets) is fail-closed DENY even if unexpired', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant({ targets: [] })]); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/finance/statement.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + // The origin passes the per-origin gate (it holds a live grant) but the empty-target grant + // covers nothing → out-of-grant-scope. + expect(res.status).toBe(403); + expect(res.error).toBe('Out of grant scope'); + }); + + it('ignores a grant minted under a DIFFERENT WebID (session boundary)', async () => { + await seedSession(); // active WebID = alice + storage.set('solid:class-grants', [classGrant({ webId: 'https://bob.pod.example/card#me' })]); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/finance/statement.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(res.status).toBe(403); + }); + + it('an explicit whole-pod origin grant keeps WHOLE-POD access', async () => { + await seedSession(); + // pm.example is a legacy full grant; NO class grant. It may read ANY pod resource. + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const pm = { origin: 'https://pm.example', url: 'https://pm.example/app' }; + const a = await sendFetch( + { url: 'https://alice.pod.example/finance/x.ttl', stampedOrigin: pm.origin }, + pm, + ); + const b = await sendFetch( + { url: 'https://alice.pod.example/health/y.ttl', stampedOrigin: pm.origin }, + pm, + ); + expect(a.status).toBe(200); + expect(b.status).toBe(200); + }); + + it('an explicit whole-pod scope that ALSO has old standalone class records keeps whole-pod access (full wins)', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://appx.example']); + storage.set('solid:class-grants', [classGrant()]); // finance-only + await loadWorker(); + + // Outside the class targets, but appx is a FULL grant → still allowed. + const res = await sendFetch( + { url: 'https://alice.pod.example/health/y.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(res.status).toBe(200); + }); + + it('REVOCATION via SOLID_REVOKE_GRANT takes effect on the NEXT request', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant()]); + await loadWorker(); + + // Before: allowed. + const before = await sendFetch( + { url: 'https://alice.pod.example/finance/x.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(before.status).toBe(200); + + // Revoke (as the same origin). + const rev = await send( + { + type: 'SOLID_REVOKE_GRANT', + requestId: 'rev1', + grantId: 'grant-1', + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(rev.ok).toBe(true); + + // After: denied on the very next request. + const after = await sendFetch( + { url: 'https://alice.pod.example/finance/x.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(after.status).toBe(403); + }); + + it('REVOKE purges exactly the revoked class replica slice, leaving other classes resident (residual b)', async () => { + await seedSession(); + storage.set('solid:class-grants', [ + classGrant({ + grantId: 'grant-1', + dataClass: 'https://w3id.org/jeswr/sectors/finance#Transaction', + targets: [{ url: 'https://alice.pod.example/finance/', kind: 'container' }], + }), + classGrant({ + grantId: 'grant-2', + dataClass: 'https://w3id.org/jeswr/sectors/health#Record', + targets: [{ url: 'https://alice.pod.example/health/', kind: 'container' }], + }), + ]); + await loadWorker(); + + // Replicate ONE resource of EACH class → two cached byte entries + two metadata rows. + await sendFetch( + { url: 'https://alice.pod.example/finance/statement.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + await sendFetch( + { url: 'https://alice.pod.example/health/record.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(cacheStore.size).toBe(2); + expect(idbMeta.size).toBe(2); + + // Revoke ONLY the finance grant. + const rev = await send( + { + type: 'SOLID_REVOKE_GRANT', + requestId: 'rv', + grantId: 'grant-1', + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(rev.ok).toBe(true); + + // ZERO bytes of the finance class remain; the health slice is untouched. + expect(idbMeta.size).toBe(1); + expect([...idbMeta.values()][0]?.resourceUrl).toBe( + 'https://alice.pod.example/health/record.ttl', + ); + expect(cacheStore.size).toBe(1); + }); + + it('RETAINS overlapping cached bytes when a surviving READ grant still covers them (residual b)', async () => { + await seedSession(); + // Two READ grants for the same origin BOTH covering finance/ (overlap). + storage.set('solid:class-grants', [ + classGrant({ grantId: 'read-a', modes: ['read'] }), + classGrant({ grantId: 'read-b', modes: ['read'] }), + ]); + await loadWorker(); + + await sendFetch( + { url: 'https://alice.pod.example/finance/statement.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(idbMeta.size).toBe(1); + + // Revoke ONE read grant; the other READ grant survives and still authorises the bytes → KEPT. + const rev = await send( + { + type: 'SOLID_REVOKE_GRANT', + requestId: 'rv', + grantId: 'read-a', + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(rev.ok).toBe(true); + expect(idbMeta.size).toBe(1); // retained — the surviving read grant still covers it + expect(cacheStore.size).toBe(1); + }); + + it('PURGES cached read bytes even when an overlapping WRITE-only grant survives (read ≠ write, residual b)', async () => { + await seedSession(); + // A READ grant (revoked) + a WRITE-only grant (survivor), both covering finance/. + storage.set('solid:class-grants', [ + classGrant({ grantId: 'read-grant', modes: ['read'] }), + classGrant({ grantId: 'write-grant', modes: ['write'] }), + ]); + await loadWorker(); + + // Read caches the finance bytes (authorised by the read grant). + await sendFetch( + { url: 'https://alice.pod.example/finance/statement.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(idbMeta.size).toBe(1); + + // Revoke the READ grant. The write-only survivor does NOT authorise READING these bytes, so + // they MUST be purged (a write grant does not imply read in this codebase). + const rev = await send( + { + type: 'SOLID_REVOKE_GRANT', + requestId: 'rv', + grantId: 'read-grant', + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(rev.ok).toBe(true); + expect(idbMeta.size).toBe(0); // purged — the surviving write-only grant cannot retain read bytes + expect(cacheStore.size).toBe(0); + }); + + it('a PURGE FAILURE on revoke still denies access (fail-closed: removal precedes purge, residual b)', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant()]); + // A cache whose delete THROWS (purge cannot complete), but put/match still work so a read caches. + (globalThis as unknown as { caches: unknown }).caches = { + open: async () => ({ + match: async (request: Request) => { + const e = cacheStore.get(request.url); + return e ? new Response(e.body, { status: e.status, headers: e.headers }) : undefined; + }, + put: async (request: Request, response: Response) => { + cacheStore.set(request.url, { + body: await response.text(), + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + }); + }, + delete: async () => { + throw new Error('cache delete unavailable'); + }, + keys: async () => [...cacheStore.keys()].map((url) => new Request(url)), + }), + }; + await loadWorker(); + + const before = await sendFetch( + { url: 'https://alice.pod.example/finance/x.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(before.status).toBe(200); + + const rev = await send( + { + type: 'SOLID_REVOKE_GRANT', + requestId: 'rv', + grantId: 'grant-1', + stampedOrigin: APPX.origin, + }, + APPX, + ); + // The grant was removed (the authoritative deny) EVEN THOUGH the byte purge could not complete. + expect(rev.ok).toBe(true); + + // Access is denied on the very next request — never left live because cleanup failed. + const after = await sendFetch( + { url: 'https://alice.pod.example/finance/x.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(after.status).toBe(403); + }); + + it("a page CANNOT revoke another origin's grant", async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant({ origin: 'https://appx.example' })]); + await loadWorker(); + + const evil = { origin: 'https://evil.example', url: 'https://evil.example/x' }; + const rev = await send( + { + type: 'SOLID_REVOKE_GRANT', + requestId: 'rev1', + grantId: 'grant-1', + stampedOrigin: evil.origin, + }, + evil, + ); + expect(rev.ok).toBe(false); + expect((profileDb.state.records[0]!.classGrants as unknown[]).length).toBe(1); + }); + + it('CONCURRENT save + revoke do not resurrect the revoked grant (serialised store)', async () => { + const { saveClassGrant, revokeClassGrant, loadClassGrants } = await import( + '../src/background/session-store' + ); + storage.set('solid:class-grants', [classGrant({ grantId: 'existing' })]); + // Fire a save (of a NEW grant) and a revoke (of the existing grant) concurrently. Without + // serialisation a stale save could write back an array that resurrects 'existing'. + await Promise.all([ + saveClassGrant(classGrant({ grantId: 'added' })), + revokeClassGrant('existing', 'https://appx.example'), + ]); + const ids = (await loadClassGrants()).map((g) => g.grantId).sort(); + expect(ids).toEqual(['added']); // 'existing' stayed revoked; 'added' landed (no lost update). + }); + + it('CONCURRENT save + clear (logout) is serialised — clear wins, no grant survives', async () => { + const { saveClassGrant, clearClassGrants, loadClassGrants } = await import( + '../src/background/session-store' + ); + // save enqueues first, clear (logout) second → serialised: clear runs after → empty. Without + // the mutex, a stale save could persist a grant AFTER the logout clear. + await Promise.all([saveClassGrant(classGrant({ grantId: 'x' })), clearClassGrants()]); + expect(await loadClassGrants()).toEqual([]); + }); + + it('resolveClass: a class-only origin may resolve ONLY a class it holds a grant for', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant()]); // finance grant only + await loadWorker(); + fetchImpl = async (url) => { + if (url.includes('/profile/card')) + return new Response( + `@prefix solid: . + <${WEBID}> solid:publicTypeIndex .`, + { status: 200 }, + ); + if (url.includes('/settings/pub.ttl')) + return new Response( + `@prefix solid: . + <#h> a solid:TypeRegistration ; + solid:forClass ; + solid:instanceContainer .`, + { status: 200 }, + ); + return new Response('ok', { status: 200 }); + }; + + // Its OWN class (finance) → allowed (resolves, even if empty). + const ownClass = await send( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: 'rc1', + classIRI: 'https://w3id.org/jeswr/sectors/finance#Transaction', + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(ownClass.error).toBeUndefined(); + + // An UNRELATED class (health) it holds no grant for → DENY (no private-structure leak). + const otherClass = await send( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: 'rc2', + classIRI: 'https://w3id.org/jeswr/sectors/health#Record', + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(otherClass.error).toBe('No access grant for this data class'); + expect(otherClass.targets).toBeUndefined(); + }); + + it('resolveClass reads public indexes on the issuer without credentials and drops issuer targets', async () => { + await seedSession(); // issuer = https://idp.example/ + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + fetchImpl = async (url) => { + if (url.includes('/profile/card')) + return new Response( + `@prefix solid: . + <${WEBID}> solid:publicTypeIndex .`, + { status: 200 }, + ); + return new Response( + `<#fin> <${classGrant().dataClass}> ; + .`, + ); + }; + + const pm = { origin: 'https://pm.example', url: 'https://pm.example/app' }; + const res = await send( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: 'rc4', + classIRI: 'https://w3id.org/jeswr/sectors/finance#Transaction', + stampedOrigin: pm.origin, + }, + pm, + ); + expect(res.targets).toEqual([]); + expect(fetchCalls.map((call) => call.url)).toEqual([ + WEBID, + 'https://idp.example/typeindex.ttl', + ]); + for (const call of fetchCalls) { + expect(new Headers(call.init?.headers).has('Authorization')).toBe(false); + expect(new Headers(call.init?.headers).has('DPoP')).toBe(false); + expect(call.init?.credentials).toBe('omit'); + } + }); + + it('resolveClass FOLLOWS a same-origin type-index ref when the pod IS the issuer (CSS single-origin)', async () => { + // CSS default: WebID + IdP on ONE origin. The issuer exclusion must NOT drop this origin, + // or class resolution breaks entirely on the most common Solid topology. + await seedSession({ issuer: 'https://alice.pod.example/' }); // issuer === WebID origin + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + fetchImpl = async (url) => { + if (url.includes('/profile/card')) + return new Response( + `@prefix solid: . + <${WEBID}> solid:publicTypeIndex .`, + { status: 200 }, + ); + if (url.includes('/settings/pub.ttl')) + return new Response( + `@prefix solid: . + <#fin> a solid:TypeRegistration ; + solid:forClass ; + solid:instanceContainer .`, + { status: 200 }, + ); + return new Response('', { status: 404 }); + }; + + const pm = { origin: 'https://pm.example', url: 'https://pm.example/app' }; + const res = await send( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: 'rc5', + classIRI: 'https://w3id.org/jeswr/sectors/finance#Transaction', + stampedOrigin: pm.origin, + }, + pm, + ); + // The pod-origin ref (== issuer origin here) WAS followed → the target resolved. + expect(res.targets).toEqual([{ url: 'https://alice.pod.example/finance/', kind: 'container' }]); + }); + + it('resolveClass: a FULL-grant origin may resolve any class', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + fetchImpl = async () => new Response('', { status: 404 }); // resolution degrades to [] + + const pm = { origin: 'https://pm.example', url: 'https://pm.example/app' }; + const res = await send( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: 'rc3', + classIRI: 'https://w3id.org/jeswr/sectors/health#Record', + stampedOrigin: pm.origin, + }, + pm, + ); + expect(res.error).toBeUndefined(); + expect(res.targets).toEqual([]); + }); + + it("listGrants returns ONLY this origin's live grants", async () => { + await seedSession(); + storage.set('solid:class-grants', [ + classGrant({ grantId: 'mine', origin: 'https://appx.example' }), + classGrant({ grantId: 'other', origin: 'https://appy.example' }), + ]); + await loadWorker(); + + const res = await send( + { type: 'SOLID_LIST_GRANTS', requestId: 'lg1', stampedOrigin: APPX.origin }, + APPX, + ); + const grants = res.grants as Array<{ grantId: string }>; + expect(grants.map((g) => g.grantId)).toEqual(['mine']); + }); + it.each([ + 'expiry', + 'relogin', + 'rebind', + 'logout', + ] as const)('listGrants withholds private targets when %s occurs during session lookup', async (change) => { + await seedSession(); + storage.set('solid:class-grants', [classGrant()]); + await loadWorker(); + const { ProfileStore: WorkerStore } = await import('../src/background/core/profile-store'); + const session = WorkerStore.prototype.session; + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const lookup = vi + .spyOn(WorkerStore.prototype, 'session') + .mockImplementationOnce(async function (this: ProfileStore, id) { + const result = await session.call(this, id); + entered(); + await held; + return result; + }); + try { + const pending = send( + { type: 'SOLID_LIST_GRANTS', requestId: 'held-list', stampedOrigin: APPX.origin }, + APPX, + ); + await started; + if (change === 'logout') await send({ type: 'SOLID_LOGOUT' }, EXT_SENDER); + if (change === 'relogin') { + const replacement = await profileCredentials('replacement'); + const replacementSession = { ...replacement.session, webId: WEBID }; + await new ProfileStore(profileDb, async () => {}).login( + replacementSession, + replacement.keyPair, + { ...replacement.display, webId: WEBID }, + ); + } + if (change === 'expiry' || change === 'rebind') { + const parents = storage.get('solid:origin-grants') as OriginGrant[]; + storage.set('solid:origin-grants', [ + { + ...parents[0], + ...(change === 'expiry' ? { expiresAt: Date.now() - 1 } : { profileId: 'other' }), + }, + ]); + } + release(); + const result = await pending; + expect(result.grants).toEqual([]); + expect(JSON.stringify(result)).not.toContain('/finance/'); + } finally { + release(); + lookup.mockRestore(); + } + }); +}); + +describe('Phase-2 requestAccess → consent → grant (wired)', () => { + const PROFILE = ` + @prefix solid: . + <${WEBID}> solid:publicTypeIndex .`; + const INDEX = ` + @prefix solid: . + <#fin> a solid:TypeRegistration ; + solid:forClass ; + solid:instanceContainer .`; + + function routeFetch(url: string): Promise { + if (url.includes('/profile/card')) + return Promise.resolve(new Response(PROFILE, { status: 200 })); + if (url.includes('/settings/pub.ttl')) + return Promise.resolve(new Response(INDEX, { status: 200 })); + return Promise.resolve(new Response('ok', { status: 200 })); + } + + it.each([ + 'logout', + 'relogin', + 'expiry', + 'revoke', + ] as const)('requestAccess stops discovery held across %s before using stale credentials', async (change) => { + await seedSession(); + storage.set('solid:granted-origins', [APPX.origin]); + await loadWorker(); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + fetchImpl = async (url) => { + if (url === WEBID) await held; + return routeFetch(url); + }; + let settled = false; + const pending = send( + { + type: 'SOLID_REQUEST_ACCESS', + requestId: 'stale-discovery', + classes: [classGrant().dataClass], + modes: ['read'], + duration: 'P1D', + stampedOrigin: APPX.origin, + userActivated: true, + }, + APPX, + ).then((result) => { + settled = true; + return result; + }); + await vi.waitFor(() => expect(fetchCalls.some((call) => call.url === WEBID)).toBe(true)); + if (change === 'logout') await send({ type: 'SOLID_LOGOUT' }, EXT_SENDER); + if (change === 'relogin') { + const replacement = await profileCredentials('replacement'); + const session = { ...replacement.session, webId: WEBID }; + await new ProfileStore(profileDb, async () => {}).login(session, replacement.keyPair, { + ...replacement.display, + webId: WEBID, + }); + } + if (change === 'expiry') { + const parents = storage.get('solid:origin-grants') as OriginGrant[]; + storage.set('solid:origin-grants', [{ ...parents[0], expiresAt: Date.now() - 1 }]); + } + if (change === 'revoke') + await send({ type: 'SOLID_ORIGIN_GRANT_REVOKE', origin: APPX.origin }, EXT_SENDER); + if (change === 'logout' || change === 'revoke') + expect(fetchCalls.find((call) => call.url === WEBID)?.init?.signal?.aborted).toBe(true); + release(); + await vi.waitFor(() => expect(settled || createdWindows.length > 0).toBe(true)); + // Settle the vulnerable implementation too, so its failure leaves no consent timer. + if (createdWindows.length) + await send( + { + type: 'SOLID_CONSENT_DECISION', + approvedClasses: [], + consentId: new URL(createdWindows[0]).searchParams.get('consentId'), + }, + EXT_SENDER, + ); + await pending; + expect(fetchCalls.filter((call) => call.url.includes('/settings/'))).toEqual([]); + expect(createdWindows).toEqual([]); + }); + + it('resolves the class, opens consent, mints a PINNED grant on approve, and enforces it', async () => { + await seedSession(); + await loadWorker(); + fetchImpl = (url) => routeFetch(url); + + // Fire requestAccess (do not await yet — it awaits the consent decision). + const reqPromise = send( + { + type: 'SOLID_REQUEST_ACCESS', + requestId: 'ra1', + classes: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + modes: ['read'], + purpose: 'https://w3id.org/dpv#ServiceProvision', + duration: 'P30D', + stampedOrigin: APPX.origin, + userActivated: true, + }, + APPX, + ); + + // Wait for the consent window to open, then read the consentId from its URL. + for (let i = 0; i < 50 && createdWindows.length === 0; i++) { + await new Promise((r) => setTimeout(r, 5)); + } + expect(createdWindows.length).toBe(1); + const consentId = new URL(createdWindows[0]).searchParams.get('consentId'); + expect(consentId).toBeTruthy(); + + // The consent window fetches the pending record to render it. + const view = (await send( + { type: 'SOLID_GET_PENDING_CONSENT', consentId }, + EXT_SENDER, + )) as unknown as { classes: Array<{ targets: unknown[] }> } | null; + expect(view?.classes[0].targets).toHaveLength(1); + + // Approve the finance class. + await send( + { + type: 'SOLID_CONSENT_DECISION', + profileId: '1', + consentId, + approvedClasses: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + }, + EXT_SENDER, + ); + + const res = await reqPromise; + expect(res.ok).toBe(true); + const grants = res.grants as Array<{ targets: Array<{ url: string; kind: string }> }>; + expect(grants).toHaveLength(1); + expect(grants[0].targets).toEqual([ + { url: 'https://alice.pod.example/finance/', kind: 'container' }, + ]); + + // The minted grant now ENFORCES: appx can read finance, not health. + const ok = await sendFetch( + { url: 'https://alice.pod.example/finance/x.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(ok.status).toBe(200); + const denied = await sendFetch( + { url: 'https://alice.pod.example/health/x.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(denied.status).toBe(403); + }); + + it.each([ + { type: 'privateTypeIndex', indexOrigin: 'https://separate.pod.example' }, + { type: 'publicTypeIndex', indexOrigin: 'https://alice.pod.example' }, + { type: 'publicTypeIndex', indexOrigin: 'https://separate.pod.example' }, + { type: 'publicTypeIndex', indexOrigin: 'http://public-index.example' }, + ])('discovers $type on $indexOrigin after real split-host pod-only consent', async ({ + type, + indexOrigin, + }) => { + await seedSession(); + await loadWorker(); + const pod = 'https://separate.pod.example'; + await new ProfileStore(profileDb, async () => {}).capabilities('1', (record) => { + record.podOrigins = [pod]; + }); + const index = `${indexOrigin}/settings/${type}.ttl`; + const target = `${pod}/finance/`; + const outOfScopeIndex = 'https://alice.pod.example/settings/private.ttl'; + let probeScope = false; + fetchImpl = async (url) => + new Response( + url === WEBID + ? `<${WEBID}> <${index}> . + ${probeScope ? `<${WEBID}> <${outOfScopeIndex}> .` : ''}` + : `<#r> <${classGrant().dataClass}> ; + + <${target}> ${probeScope ? ', ' : ''} .`, + ); + const pending = send( + { + type: 'SOLID_REQUEST_ACCESS', + requestId: 'split-consent', + classes: [classGrant().dataClass], + modes: ['read'], + duration: 'P1D', + stampedOrigin: APPX.origin, + userActivated: true, + }, + APPX, + ); + await vi.waitFor(() => expect(createdWindows).toHaveLength(1)); + await send( + { + type: 'SOLID_CONSENT_DECISION', + profileId: '1', + consentId: new URL(createdWindows[0]).searchParams.get('consentId'), + approvedClasses: [classGrant().dataClass], + }, + EXT_SENDER, + ); + expect((await pending).ok).toBe(true); + const parents = storage.get('solid:origin-grants') as OriginGrant[]; + expect(parents[0].scope.pods).toEqual([pod]); + const consentWebId = fetchCalls.find((call) => call.url === WEBID); + expect(new Headers(consentWebId?.init?.headers).has('Authorization')).toBe(false); + expect(new Headers(consentWebId?.init?.headers).has('DPoP')).toBe(false); + expect(consentWebId?.init?.credentials).toBe('omit'); + const consentIndex = fetchCalls.find((call) => call.url === index); + const authenticated = type === 'privateTypeIndex'; + probeScope = true; + fetchCalls.length = 0; + const result = await send( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: 'split-resolve', + classIRI: classGrant().dataClass, + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(result.targets).toEqual([{ url: target, kind: 'container' }]); + expect(fetchCalls.map((call) => call.url)).toEqual([WEBID, index]); + const headers = new Headers(fetchCalls[0].init?.headers); + expect(headers.has('Authorization')).toBe(false); + expect(headers.has('DPoP')).toBe(false); + expect(fetchCalls[0].init?.credentials).toBe('omit'); + expect(new Headers(fetchCalls[1].init?.headers).has('Authorization')).toBe(authenticated); + expect(new Headers(fetchCalls[1].init?.headers).has('DPoP')).toBe(authenticated); + expect(fetchCalls[1].init?.credentials).toBe('omit'); + expect(fetchCalls[1].init?.redirect).toBe('error'); + expect(new Headers(consentIndex?.init?.headers).has('Authorization')).toBe(authenticated); + expect(new Headers(consentIndex?.init?.headers).has('DPoP')).toBe(authenticated); + expect(consentIndex?.init?.credentials).toBe('omit'); + expect(consentIndex?.init?.redirect).toBe('error'); + }); + + it.each([ + 'switch', + 'remove', + 'signout', + 'relogin', + ] as const)('binds delayed consent to its original profile through %s', async (action) => { + await seedSession(); + await loadWorker(); + const store = new ProfileStore(profileDb, async () => {}); + const b = await profileCredentials('bob'); + await store.login(b.session, b.keyPair, { ...b.display, photoUrl: null }); + fetchImpl = (url) => routeFetch(url); + const request = send( + { + type: 'SOLID_REQUEST_ACCESS', + requestId: 'delayed', + classes: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + modes: ['read'], + duration: 'P30D', + stampedOrigin: APPX.origin, + userActivated: true, + }, + APPX, + ); + for (let i = 0; i < 50 && createdWindows.length === 0; i++) + await new Promise((r) => setTimeout(r, 5)); + expect(createdWindows).toHaveLength(1); + const consentId = new URL(createdWindows[0]).searchParams.get('consentId'); + if (action === 'switch') + await send({ type: 'SOLID_PROFILE_SET_DEFAULT', profileId: '2' }, EXT_SENDER); + if (action === 'remove') + await send({ type: 'SOLID_PROFILE_REMOVE', profileId: '1' }, EXT_SENDER); + if (action === 'signout') await send({ type: 'SOLID_LOGOUT' }, EXT_SENDER); + if (action === 'relogin') { + const a = await profileCredentials('alice'); + const session = { ...a.session, webId: profileDb.state.records[0].profile.webId }; + await store.login(session, a.keyPair, { ...a.display, webId: session.webId, photoUrl: null }); + } + await send( + { + type: 'SOLID_CONSENT_DECISION', + profileId: '1', + consentId, + approvedClasses: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + }, + EXT_SENDER, + ); + const result = await request; + expect((await store.get('2')).classGrants).toEqual([]); + if (action === 'switch') { + expect(result.ok).toBe(true); + expect(storage.get('solid:origin-grants')).toMatchObject([ + { profileId: '1', scope: { classes: [{ origin: APPX.origin }] } }, + ]); + } else { + expect(result.error).toMatch(/session changed/); + expect(profileDb.state.records.every((r) => r.classGrants.length === 0)).toBe(true); + } + }); + + it.each([ + 30_000, 120_000, + ])('new Person consent preserves Finance effective expiry (%i ms)', async (childLifetime) => { + const now = Date.now(); + const deadline = now + 60_000; + await seedSession(); + storage.set('solid:class-grants', [classGrant({ expiresAt: now + childLifetime })]); + await loadWorker(); + await send( + { type: 'SOLID_ORIGIN_GRANT_EXPIRY', origin: APPX.origin, expiresAt: deadline }, + EXT_SENDER, + ); + const person = 'https://schema.org/Person'; + fetchImpl = async (url) => + new Response( + url.includes('/profile/card') + ? PROFILE + : ` + @prefix solid: . + <#person> solid:forClass <${person}> ; + solid:instanceContainer .`, + ); + const pending = send( + { + type: 'SOLID_REQUEST_ACCESS', + requestId: 'person', + classes: [person], + modes: ['read'], + duration: 'P1D', + stampedOrigin: APPX.origin, + userActivated: true, + }, + APPX, + ); + await vi.waitFor(() => expect(createdWindows).toHaveLength(1)); + await send( + { + type: 'SOLID_CONSENT_DECISION', + profileId: '1', + consentId: new URL(createdWindows[0]).searchParams.get('consentId'), + approvedClasses: [person], + }, + EXT_SENDER, + ); + expect((await pending).ok).toBe(true); + const clock = vi + .spyOn(Date, 'now') + .mockReturnValue(Math.min(deadline, now + childLifetime) + 1); + try { + expect( + ( + await sendFetch( + { url: 'https://alice.pod.example/finance/x.ttl', stampedOrigin: APPX.origin }, + APPX, + ) + ).status, + ).toBe(403); + expect( + ( + await sendFetch( + { url: 'https://alice.pod.example/people/x.ttl', stampedOrigin: APPX.origin }, + APPX, + ) + ).status, + ).toBe(200); + } finally { + clock.mockRestore(); + } + }); + + it('merges consent with the current parent after a concurrent class revoke', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant({ grantId: 'revoked' })]); + await loadWorker(); + fetchImpl = routeFetch; + const pending = send( + { + type: 'SOLID_REQUEST_ACCESS', + requestId: 'consent-race', + classes: [classGrant().dataClass], + modes: ['read'], + duration: 'P1D', + stampedOrigin: APPX.origin, + userActivated: true, + }, + APPX, + ); + await vi.waitFor(() => expect(createdWindows).toHaveLength(1)); + const consentId = new URL(createdWindows[0]).searchParams.get('consentId'); + const { OriginGrantStore } = await import('../src/background/core/origin-grant'); + const update = OriginGrantStore.prototype.update; + // Commit the revoke after consent reads the parent, before its queued update runs. + const queued = vi + .spyOn(OriginGrantStore.prototype, 'update') + .mockImplementationOnce(async function (this: InstanceType, change) { + expect( + ( + await send( + { + type: 'SOLID_REVOKE_GRANT', + grantId: 'revoked', + stampedOrigin: APPX.origin, + }, + APPX, + ) + ).ok, + ).toBe(true); + return update.call(this, change); + }); + try { + await send( + { + type: 'SOLID_CONSENT_DECISION', + profileId: '1', + consentId, + approvedClasses: [classGrant().dataClass], + }, + EXT_SENDER, + ); + expect((await pending).ok).toBe(true); + const parents = storage.get('solid:origin-grants') as OriginGrant[]; + expect(parents[0].scope.classes).toHaveLength(1); + expect(parents[0].scope.classes?.[0].grantId).not.toBe('revoked'); + } finally { + queued.mockRestore(); + } + }); + + it('a DENY-all consent decision mints NO grant', async () => { + await seedSession(); + await loadWorker(); + fetchImpl = (url) => routeFetch(url); + + const reqPromise = send( + { + type: 'SOLID_REQUEST_ACCESS', + requestId: 'ra2', + classes: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + modes: ['read'], + duration: 'P30D', + stampedOrigin: APPX.origin, + userActivated: true, + }, + APPX, + ); + for (let i = 0; i < 50 && createdWindows.length === 0; i++) { + await new Promise((r) => setTimeout(r, 5)); + } + const consentId = new URL(createdWindows[0]).searchParams.get('consentId'); + await send( + { type: 'SOLID_CONSENT_DECISION', profileId: '1', consentId, approvedClasses: [] }, + EXT_SENDER, + ); + + const res = await reqPromise; + expect(res.ok).toBe(false); + expect(res.grants).toEqual([]); + expect(profileDb.state.records[0]?.classGrants ?? []).toEqual([]); + }); + + it('a NON-extension sender cannot post a consent decision (fail-closed)', async () => { + await seedSession(); + await loadWorker(); + fetchImpl = (url) => routeFetch(url); + + const reqPromise = send( + { + type: 'SOLID_REQUEST_ACCESS', + requestId: 'ra3', + classes: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + modes: ['read'], + duration: 'P30D', + stampedOrigin: APPX.origin, + userActivated: true, + }, + APPX, + ); + for (let i = 0; i < 50 && createdWindows.length === 0; i++) { + await new Promise((r) => setTimeout(r, 5)); + } + const consentId = new URL(createdWindows[0]).searchParams.get('consentId'); + + // A WEB PAGE tries to self-approve — must be ignored (not the extension's own window). + await send( + { + type: 'SOLID_CONSENT_DECISION', + profileId: '1', + consentId, + approvedClasses: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + }, + APPX, + ); + // The forged decision did not resolve the request; the grant store stays empty. + await new Promise((r) => setTimeout(r, 20)); + expect(profileDb.state.records[0]?.classGrants ?? []).toEqual([]); + void reqPromise; + }); + + it('REJECTS requestAccess with NO user gesture — opens NO consent window (residual c)', async () => { + await seedSession(); + await loadWorker(); + fetchImpl = (url) => routeFetch(url); + + const res = await send( + { + type: 'SOLID_REQUEST_ACCESS', + requestId: 'rg1', + classes: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + modes: ['read'], + duration: 'P30D', + stampedOrigin: APPX.origin, + // userActivated omitted → false (an autonomous, non-gesture call). + }, + APPX, + ); + expect(res.error).toMatch(/user action/i); + // No consent window was opened and no pod fetch was made. + expect(createdWindows.length).toBe(0); + expect(fetchCalls.length).toBe(0); + }); + + it('RATE-LIMITS rapid repeated requestAccess from ONE origin (residual c)', async () => { + await seedSession(); + await loadWorker(); + fetchImpl = (url) => routeFetch(url); + + const base = { + type: 'SOLID_REQUEST_ACCESS', + classes: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + modes: ['read'], + duration: 'P30D', + stampedOrigin: APPX.origin, + userActivated: true, + } as const; + + // Fire the first 3 (the default per-origin/minute cap): each passes the throttle and opens a + // consent window, then AWAITS its decision — so we do NOT await these promises. + void send({ ...base, requestId: 'rl1' }, APPX); + void send({ ...base, requestId: 'rl2' }, APPX); + void send({ ...base, requestId: 'rl3' }, APPX); + for (let i = 0; i < 100 && createdWindows.length < 3; i++) { + await new Promise((r) => setTimeout(r, 5)); + } + expect(createdWindows.length).toBe(3); + + // The 4th within the same window → RATE-LIMITED, resolves immediately, opens NO 4th window. + const r4 = await send({ ...base, requestId: 'rl4' }, APPX); + expect(r4.error).toMatch(/too many/i); + expect(createdWindows.length).toBe(3); + }); +}); + +describe('EXT-05 explicit origin grant authority (adversarial)', () => { + function parent(extra: Partial = {}): OriginGrant { + return { + origin: APPX.origin, + profileId: '1', + createdAt: Date.now(), + scope: { pods: ['https://alice.pod.example'], classes: [classGrant()] }, + ...extra, + }; + } + function fetchFinance(extra: Record = {}) { + return sendFetch( + { url: 'https://alice.pod.example/finance/x.ttl', stampedOrigin: APPX.origin, ...extra }, + APPX, + ); + } + async function boot(grants: OriginGrant[] = [parent()]) { + await seedSession(); + storage.set('solid:origin-grants', grants); + await loadWorker(); + } + + it('ignores standalone class/login grants and client-id declarations without a parent', async () => { + storage.set('solid:granted-origins', [APPX.origin]); + storage.set('solid:class-grants', [classGrant()]); + await boot([]); + expect( + ( + await send( + { type: 'SOLID_SET_CLIENT_ID', origin: APPX.origin, clientId: `${APPX.origin}/clientid` }, + APPX, + ) + ).ok, + ).toBe(true); + expect((await fetchFinance()).status).toBe(403); + expect( + (await send({ type: 'SOLID_GET_STATE', stampedOrigin: APPX.origin }, APPX)).webId, + ).toBeNull(); + expect( + ( + await send( + { type: 'SOLID_LIST_GRANTS', requestId: 'list', stampedOrigin: APPX.origin }, + APPX, + ) + ).grants, + ).toEqual([]); + expect( + ( + await send( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: 'resolve', + classIRI: 'https://schema.org/Person', + stampedOrigin: APPX.origin, + }, + APPX, + ) + ).error, + ).toBeTruthy(); + expect( + (await send({ type: 'SOLID_LOGOUT', stampedOrigin: APPX.origin }, APPX)).error, + ).toBeTruthy(); + expect(fetchCalls).toHaveLength(0); + expect(registrations.size).toBe(0); + }); + + it('rejects a spoofed isolated-origin stamp with zero cache or pod access', async () => { + await boot(); + const result = await sendFetch( + { + url: 'https://alice.pod.example/finance/x.ttl', + stampedOrigin: APPX.origin, + profileId: '1', + }, + { origin: 'https://evil.example', url: 'https://evil.example/' }, + ); + expect(result.status).toBe(403); + expect(fetchCalls).toHaveLength(0); + expect(cacheStore.size).toBe(0); + }); + + it.each([ + 'SOLID_ORIGIN_GRANTS_LIST', + 'SOLID_ORIGIN_GRANT_REVOKE', + 'SOLID_ORIGIN_GRANT_PROFILE', + 'SOLID_ORIGIN_GRANT_EXPIRY', + ])('refuses page-supplied management %s', async (type) => { + await boot(); + expect( + (await send({ type, origin: APPX.origin, expiresAt: Date.now() + 1 }, APPX)).error, + ).toMatch(/extension context/); + expect((await fetchFinance()).status).toBe(200); + }); + + it('revokes a warm origin slice, unregisters, and denies a captured patch on its next request', async () => { + await boot(); + expect(registrations.size).toBe(1); + expect([...registrations.values()][0].matches).toEqual([`${APPX.origin}:443/*`]); + expect((await fetchFinance()).status).toBe(200); + expect(cacheStore.size).toBe(1); + expect( + (await send({ type: 'SOLID_ORIGIN_GRANT_REVOKE', origin: APPX.origin }, EXT_SENDER)).ok, + ).toBe(true); + expect(registrations.size).toBe(0); + expect(cacheStore.size).toBe(0); + fetchCalls.length = 0; + expect((await fetchFinance()).status).toBe(403); + expect(await fetchFinance({ autoDivert: true })).toEqual({ + requestId: 'r1', + passthrough: true, + }); + expect(fetchCalls).toHaveLength(0); + expect((await send({ type: 'SOLID_ORIGIN_GRANTS_LIST' }, EXT_SENDER)).grants).toEqual([]); + }); + + it('denies expiry immediately even if the alarm has not fired; alarm removes the persisted registration', async () => { + const deadline = Date.now() + 60_000; + await boot([parent({ expiresAt: deadline })]); + expect(chrome.alarms.create).toHaveBeenCalledWith('solid-origin-expiry', { when: deadline }); + expect((await fetchFinance()).status).toBe(200); + const now = vi.spyOn(Date, 'now').mockReturnValue(deadline); + try { + expect((await fetchFinance()).status).toBe(403); + alarmListener({ name: 'solid-origin-expiry' }); + await vi.waitFor(() => expect(registrations.size).toBe(0)); + expect(cacheStore.size).toBe(0); + expect( + (await send({ type: 'SOLID_GET_STATE', stampedOrigin: APPX.origin }, APPX)).webId, + ).toBeNull(); + } finally { + now.mockRestore(); + } + }); + + it.each( + [ + { gate: 'last egress', method: 'PUT', pruneWrite: 4 }, + { gate: 'final response', method: 'GET', pruneWrite: 6 }, + ].flatMap((stage) => ['parent', 'child'].map((expiry) => ({ ...stage, expiry }))), + )('denies $expiry expiry during pruning in the $gate gate without an alarm', async ({ + method, + pruneWrite, + expiry, + }) => { + const deadline = Date.now() + 60_000; + await boot([ + parent({ + expiresAt: deadline + (expiry === 'parent' ? 0 : 60_000), + scope: { + pods: ['https://alice.pod.example'], + classes: [ + classGrant({ + modes: ['read', 'write'], + expiresAt: deadline + (expiry === 'child' ? 0 : 60_000), + }), + ], + }, + }), + ]); + fetchImpl = async () => new Response('private finance bytes'); + const now = vi.spyOn(Date, 'now').mockReturnValue(deadline - 1); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + let writes = 0; + const set = chrome.storage.local.set; + const pruning = vi.spyOn(chrome.storage.local, 'set').mockImplementation(async (items) => { + // Gates run at entry, replica entry, egress and signed egress; a GET also gates + // persistence before its final response. Hold the write at the requested boundary. + if ('solid:origin-grants' in items && ++writes === pruneWrite) await held; + return set(items); + }); + const pending = fetchFinance({ method, body: method === 'PUT' ? 'updated finance' : null }); + try { + await vi.waitFor(() => expect(writes).toBe(pruneWrite)); + expect(fetchCalls).toHaveLength(method === 'PUT' ? 0 : 1); + if (method === 'GET') + expect(new Headers(fetchCalls[0].init?.headers).get('Authorization')).toBe( + 'DPoP access-token-xyz', + ); + // Cross the real deadline while storage is suspended, leaving the alarm unfired. + now.mockReturnValue(deadline); + release(); + const result = await pending; + expect(fetchCalls).toHaveLength(method === 'PUT' ? 0 : 1); + expect(result.status).not.toBe(200); + expect(result.error).toBe( + expiry === 'parent' ? 'Origin not granted access' : 'No access grant for this data', + ); + expect(result.body).toBeUndefined(); + expect(registrations.size).toBe(1); + } finally { + release(); + await pending; + pruning.mockRestore(); + now.mockRestore(); + } + }); + + it('checks the bound parent pod scope even when a child grant pins another pod', async () => { + await boot([parent({ scope: { pods: [], classes: [classGrant()] } })]); + expect((await fetchFinance()).status).toBe(403); + expect(fetchCalls).toHaveLength(0); + }); + + it('rejects invalid/missing profiles and duplicate origin bindings, without falling back to the default', async () => { + await boot([parent({ profileId: 'missing' }), parent({ profileId: '2' })]); + expect((await fetchFinance()).status).toBe(403); + expect(fetchCalls).toHaveLength(0); + expect(registrations.size).toBe(0); + }); + + it('only shortens expiry through trusted management; malformed and missing origins fail closed', async () => { + const deadline = Date.now() + 60_000; + await boot([parent({ expiresAt: deadline })]); + for (const expiresAt of [NaN, Date.now() - 1, deadline + 1]) { + expect( + ( + await send( + { type: 'SOLID_ORIGIN_GRANT_EXPIRY', origin: APPX.origin, expiresAt }, + EXT_SENDER, + ) + ).error, + ).toBeTruthy(); + } + expect( + ( + await send( + { type: 'SOLID_ORIGIN_GRANT_EXPIRY', origin: APPX.origin, expiresAt: deadline - 1000 }, + EXT_SENDER, + ) + ).ok, + ).toBe(true); + expect((storage.get('solid:origin-grants') as OriginGrant[])[0].expiresAt).toBe( + deadline - 1000, + ); + expect( + (await send({ type: 'SOLID_ORIGIN_GRANT_REVOKE', origin: '*://*/*' }, EXT_SENDER)).error, + ).toBeTruthy(); + expect( + ( + await send( + { type: 'SOLID_ORIGIN_GRANT_REVOKE', origin: 'https://absent.example' }, + EXT_SENDER, + ) + ).ok, + ).toBe(false); + }); + + it('never lets a concurrent longer expiry overwrite an earlier shortening', async () => { + const deadline = Date.now() + 60_000; + await boot([parent({ expiresAt: deadline })]); + // A replica commit holds the profile queue, so both managers validate against the + // original expiry and then wait to commit in order. + let release!: () => void; + const gateOpen = new Promise((resolve) => { + release = resolve; + }); + let committing!: () => void; + const committed = new Promise((resolve) => { + committing = resolve; + }); + const cache = await caches.open('solid-ext-replica-bytes'); + const put = cache.put.bind(cache); + cache.put = async (request: RequestInfo | URL, response: Response) => { + committing(); + await gateOpen; + return put(request as Request, response); + }; + vi.spyOn(caches, 'open').mockResolvedValue(cache); + const pending = fetchFinance(); + await committed; + const managers = Promise.all([ + send( + { type: 'SOLID_ORIGIN_GRANT_EXPIRY', origin: APPX.origin, expiresAt: deadline - 10_000 }, + EXT_SENDER, + ), + send( + { type: 'SOLID_ORIGIN_GRANT_EXPIRY', origin: APPX.origin, expiresAt: deadline - 1000 }, + EXT_SENDER, + ), + ]); + await new Promise((resolve) => setTimeout(resolve, 20)); + release(); + const [shorter, longer] = await managers; + await pending; + expect(shorter.ok).toBe(true); + expect(longer.error).toMatch(/only be shortened/); + expect((storage.get('solid:origin-grants') as OriginGrant[])[0].expiresAt).toBe( + deadline - 10_000, + ); + }); + + it.each([ + 'nonexistent', + 'grant-1', + ])('class revoke %s only cancels its own origin after an actual removal', async (grantId) => { + const appB = { origin: 'https://appy.example', url: 'https://appy.example/app' }; + await boot([ + parent(), + parent({ + origin: appB.origin, + scope: { + pods: ['https://alice.pod.example'], + classes: [classGrant({ origin: appB.origin })], + }, + }), + ]); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + fetchImpl = async () => { + await held; + return new Response('private bytes'); + }; + const own = fetchFinance(); + const other = sendFetch( + { url: 'https://alice.pod.example/finance/other.ttl', stampedOrigin: appB.origin }, + appB, + ); + await vi.waitFor(() => expect(fetchCalls).toHaveLength(2)); + const result = await send( + { type: 'SOLID_REVOKE_GRANT', grantId, stampedOrigin: APPX.origin }, + APPX, + ); + expect(result.ok).toBe(grantId === 'grant-1'); + release(); + expect((await other).status).toBe(200); + const ownResult = await own; + if (grantId === 'grant-1') { + expect(ownResult.body).toBeUndefined(); + expect(ownResult.error).toBeTruthy(); + } else expect(ownResult.status).toBe(200); + }); + + it('aborts a pending response on revoke before serving or persisting any bytes', async () => { + await boot(); + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + let release!: (response: Response) => void; + fetchImpl = async () => { + entered(); + return new Promise((resolve) => { + release = resolve; + }); + }; + const pending = fetchFinance(); + await started; + await send({ type: 'SOLID_ORIGIN_GRANT_REVOKE', origin: APPX.origin }, EXT_SENDER); + release(new Response('private fixture bytes')); + const result = await pending; + expect(result.body).toBeUndefined(); + expect(result.error).toBeTruthy(); + expect(cacheStore.size).toBe(0); + }); + + it.each( + ['WebID', 'index'].flatMap((stage) => + ['origin revoke', 'profile rebind', 'class revoke', 'expiry'].map((change) => ({ + stage, + change, + })), + ), + )('stops discovery held at $stage after $change', async ({ stage, change }) => { + await boot(); + if (change === 'profile rebind') { + const b = await profileCredentials('bob'); + await new ProfileStore(profileDb, async () => {}).login(b.session, b.keyPair, b.display); + } + const indexUrl = 'https://alice.pod.example/settings/private.ttl'; + const profile = `<${WEBID}> <${indexUrl}> .`; + const index = `<#r> <${classGrant().dataClass}> ; + .`; + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + fetchImpl = async (url) => { + if ((stage === 'WebID' && url.includes('/profile/card')) || url === indexUrl) { + entered(); + await held; + } + return new Response(url === indexUrl ? index : profile); + }; + const pending = send( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: 'discovery-race', + classIRI: classGrant().dataClass, + stampedOrigin: APPX.origin, + }, + APPX, + ); + await started; + if (change === 'expiry') { + const parents = storage.get('solid:origin-grants') as OriginGrant[]; + storage.set('solid:origin-grants', [{ ...parents[0], expiresAt: Date.now() - 1 }]); + } else if (change === 'class revoke') { + expect( + ( + await send( + { type: 'SOLID_REVOKE_GRANT', grantId: 'grant-1', stampedOrigin: APPX.origin }, + APPX, + ) + ).ok, + ).toBe(true); + } else { + expect( + ( + await send( + { + type: + change === 'origin revoke' + ? 'SOLID_ORIGIN_GRANT_REVOKE' + : 'SOLID_ORIGIN_GRANT_PROFILE', + origin: APPX.origin, + profileId: '2', + }, + EXT_SENDER, + ) + ).error, + ).toBeUndefined(); + } + release(); + const result = await pending; + expect(result).toMatchObject({ requestId: 'discovery-race', error: expect.any(String) }); + expect(result.targets).toBeUndefined(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetchCalls).toHaveLength(stage === 'WebID' ? 1 : 2); + if (change !== 'expiry') expect(fetchCalls.at(-1)?.init?.signal?.aborted).toBe(true); + }); + + it.each([ + false, + true, + ])('confines discovery requests and targets to parent pods (nested classes: %s)', async (nested) => { + await boot([ + parent({ + scope: { + pods: ['https://alice.pod.example'], + ...(nested ? { classes: [classGrant()] } : {}), + }, + }), + ]); + await new ProfileStore(profileDb, async () => {}).capabilities('1', (record) => { + record.podOrigins = ['https://other.pod.example']; + }); + const indexA = 'https://alice.pod.example/settings/private.ttl'; + const indexB = 'https://other.pod.example/settings/private.ttl'; + fetchImpl = async (url) => + new Response( + url.includes('/profile/card') + ? `<${WEBID}> <${indexA}>, <${indexB}> .` + : `<#r> <${classGrant().dataClass}> ; + + , .`, + ); + const result = await send( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: 'scoped-discovery', + classIRI: classGrant().dataClass, + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(result.error).toBeUndefined(); + expect(result.targets).toEqual([ + { url: 'https://alice.pod.example/finance/', kind: 'container' }, + ]); + expect(fetchCalls.map((call) => call.url)).toEqual([WEBID, indexA]); + }); + + it('filters discovery targets against the live pod scope before delivery', async () => { + await boot(); + const indexUrl = 'https://alice.pod.example/settings/private.ttl'; + fetchImpl = async (url) => { + if (url.includes('/profile/card')) + return new Response( + `<${WEBID}> <${indexUrl}> .`, + ); + const parents = storage.get('solid:origin-grants') as OriginGrant[]; + storage.set('solid:origin-grants', [ + { ...parents[0], scope: { ...parents[0].scope, pods: [] } }, + ]); + return new Response(`<#r> <${classGrant().dataClass}> ; + .`); + }; + const result = await send( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: 'scope-shrink', + classIRI: classGrant().dataClass, + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(result.targets).toEqual([]); + expect(fetchCalls).toHaveLength(2); + }); + + it('does not authenticate a discovery WebID fetch outside the parent pod scope', async () => { + await boot([parent({ scope: { pods: [] } })]); + const result = await send( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: 'empty-scope', + classIRI: classGrant().dataClass, + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(result.targets).toEqual([]); + expect(fetchCalls.map((call) => call.url)).toEqual([WEBID]); + expect(new Headers(fetchCalls[0].init?.headers).has('Authorization')).toBe(false); + expect(new Headers(fetchCalls[0].init?.headers).has('DPoP')).toBe(false); + expect(fetchCalls[0].init?.credentials).toBe('omit'); + }); + + it('selects profile 2 in consent, swaps the binding, and never transfers profile 1 targets or credentials', async () => { + await boot(); + expect((await fetchFinance()).status).toBe(200); + const b = await profileCredentials('bob'); + const store = new ProfileStore(profileDb, async () => {}); + await store.login(b.session, b.keyPair, b.display); + const cls = 'https://schema.org/Person'; + fetchImpl = async (url) => { + if (url === b.session.webId) + return new Response( + `<${b.session.webId}> .`, + ); + if (url === 'https://pod.example/bob/index') + return new Response( + `<#registration> <${cls}>; .`, + ); + return new Response('fixture resource'); + }; + const request = send( + { + type: 'SOLID_REQUEST_ACCESS', + requestId: 'swap', + classes: [cls], + modes: ['read'], + duration: 'P30D', + stampedOrigin: APPX.origin, + userActivated: true, + profileId: '1', + }, + APPX, + ); + await vi.waitFor(() => expect(createdWindows).toHaveLength(1)); + const consentId = new URL(createdWindows[0]).searchParams.get('consentId'); + expect(await send({ type: 'SOLID_GET_PENDING_CONSENT', consentId }, APPX)).toBeNull(); + expect( + await send( + { type: 'SOLID_GET_PENDING_CONSENT', consentId, profileId: 'unknown' }, + EXT_SENDER, + ), + ).toBeNull(); + const view = await send( + { type: 'SOLID_GET_PENDING_CONSENT', consentId, profileId: '2' }, + EXT_SENDER, + ); + expect(view.profileId).toBe('2'); + expect(view.profiles).toMatchObject([{ id: '1' }, { id: '2', label: 'bob' }]); + expect(view.classes).toMatchObject([ + { targets: [{ url: 'https://pod.example/bob/private/' }] }, + ]); + await send( + { type: 'SOLID_CONSENT_DECISION', consentId, profileId: '2', approvedClasses: [cls] }, + EXT_SENDER, + ); + expect((await request).ok).toBe(true); + expect(cacheStore.size).toBe(0); + expect((await fetchFinance()).status).toBe(403); + expect((await send({ type: 'SOLID_GET_STATE', stampedOrigin: APPX.origin }, APPX)).webId).toBe( + b.session.webId, + ); + fetchCalls.length = 0; + const result = await sendFetch( + { url: 'https://pod.example/bob/private/item', stampedOrigin: APPX.origin, profileId: '1' }, + APPX, + ); + expect(result.status).toBe(200); + expect(new Headers(fetchCalls[0].init?.headers).get('authorization')).toBe( + `DPoP ${b.session.accessToken}`, + ); + expect((storage.get('solid:origin-grants') as OriginGrant[])[0]).toMatchObject({ + profileId: '2', + scope: { classes: [{ webId: b.session.webId }] }, + }); + }); +}); + +it('trusted profile rebinding clears old pod/class scopes and cached data before returning', async () => { + await seedSession(); + storage.set('solid:origin-grants', [ + { + origin: APPX.origin, + profileId: '1', + createdAt: 0, + scope: { pods: ['https://alice.pod.example'], classes: [classGrant()] }, + }, + ]); + await loadWorker(); + const b = await profileCredentials('bob'); + await new ProfileStore(profileDb, async () => {}).login(b.session, b.keyPair, b.display); + expect( + ( + await sendFetch( + { url: 'https://alice.pod.example/finance/a', stampedOrigin: APPX.origin }, + APPX, + ) + ).status, + ).toBe(200); + expect( + ( + await send( + { type: 'SOLID_ORIGIN_GRANT_PROFILE', origin: APPX.origin, profileId: '2' }, + EXT_SENDER, + ) + ).ok, + ).toBe(true); + expect(cacheStore.size).toBe(0); + expect(storage.get('solid:origin-grants')).toMatchObject([ + { profileId: '2', scope: { pods: [], classes: [] } }, + ]); + expect((await send({ type: 'SOLID_GET_STATE', stampedOrigin: APPX.origin }, APPX)).webId).toBe( + b.session.webId, + ); + fetchCalls.length = 0; + expect( + (await sendFetch({ url: 'https://pod.example/private', stampedOrigin: APPX.origin }, APPX)) + .status, + ).toBe(403); + expect(fetchCalls).toHaveLength(0); +}); + +it('unregisters a revoked origin even when replica stores cannot open', async () => { + await seedSession(); + storage.set('solid:origin-grants', [ + { + origin: APPX.origin, + profileId: '1', + createdAt: 0, + scope: { pods: ['https://alice.pod.example'], classes: [classGrant()] }, + }, + ]); + await loadWorker(); + const open = vi.spyOn(caches, 'open').mockRejectedValue(new Error('fixture replica unavailable')); + try { + expect( + (await send({ type: 'SOLID_ORIGIN_GRANT_REVOKE', origin: APPX.origin }, EXT_SENDER)).ok, + ).toBe(true); + expect(registrations.size).toBe(0); + expect( + ( + await sendFetch( + { url: 'https://alice.pod.example/finance/a', stampedOrigin: APPX.origin }, + APPX, + ) + ).status, + ).toBe(403); + } finally { + open.mockRestore(); + } +}); + +// AUTHORED-BY Codex GPT-6.1-Sol +// Exercise form-fill through the actual worker router and its credential boundary. +describe('separate form-fill consent wired into the worker', () => { + const origin = 'https://shop.example'; + const source: CapturedSender = { + origin, + url: `${origin}/checkout`, + frameId: 0, + tab: { id: 7, url: `${origin}/checkout` }, + }; + const fields = [ + { field: 'name', category: 'contact' }, + { field: 'street-address', category: 'address' }, + ]; + async function prepare() { + await seedSession(); + await loadWorker(); + chrome.tabs.get = vi.fn(async () => ({ + id: 7, + url: source.url, + })) as unknown as typeof chrome.tabs.get; + chrome.windows.remove = vi.fn(async () => {}); + fetchCalls = []; + fetchImpl = async (url) => + new Response( + url === WEBID + ? ` + @prefix v: . + <${WEBID}> v:fn "Alex"; v:hasAddress <../private/address#home>; v:hasEmail . + ` + : `<${WEBID}> <#home>. + <#home> "42 Sample Street".`, + { headers: { 'Content-Type': 'text/turtle' } }, + ); + } + async function pendingFill() { + const result = send( + { + type: 'SOLID_FORMFILL_REQUEST', + fields, + origin: 'https://forged.example', + stampedOrigin: 'https://forged.example', + }, + source, + ); + await vi.waitFor(() => + expect(createdWindows.some((url) => url.includes('/formfill/preview.html'))).toBe(true), + ); + const url = createdWindows.find((u) => u.includes('/formfill/preview.html'))!; + const requestId = new URL(url).searchParams.get('requestId')!; + return { result, requestId, previewSender: { ...EXT_SENDER, url, frameId: 0 } }; + } + it('loads public data without credentials, confines private fetch, and returns only approved values', async () => { + await prepare(); + const { result, requestId, previewSender } = await pendingFill(); + const preview = await send({ type: 'SOLID_FORMFILL_PREVIEW', requestId }, previewSender); + expect(preview.origin).toBe(origin); + expect(preview.values).toEqual([ + { field: 'name', category: 'contact', value: 'Alex' }, + { field: 'street-address', category: 'address', value: '42 Sample Street' }, + ]); + const publicCall = fetchCalls.find((c) => c.url === WEBID)!; + expect(publicCall.init).toMatchObject({ credentials: 'omit', redirect: 'error' }); + expect(new Headers(publicCall.init?.headers).has('Authorization')).toBe(false); + const privateCall = fetchCalls.find( + (c) => c.url === 'https://alice.pod.example/private/address', + )!; + expect(privateCall.init).toMatchObject({ credentials: 'omit', redirect: 'error' }); + expect(new Headers(privateCall.init?.headers).get('Authorization')).toBe( + 'DPoP access-token-xyz', + ); + expect(new Headers(privateCall.init?.headers).get('DPoP')).toBeTruthy(); + expect(fetchCalls).toHaveLength(2); + await send( + { + type: 'SOLID_FORMFILL_DECISION', + requestId, + profileId: preview.profileId, + categories: ['address'], + remember: true, + }, + previewSender, + ); + expect(await result).toEqual({ + origin, + values: [{ field: 'street-address', category: 'address', value: '42 Sample Street' }], + }); + expect(storage.get('solid:formfill-grants')).toEqual([ + expect.objectContaining({ origin, profileId: preview.profileId, categories: ['address'] }), + ]); + expect( + (await send({ type: 'SOLID_GET_STATE', stampedOrigin: origin }, source)).webId, + ).toBeNull(); + expect(storage.get('solid:origin-grants')).toEqual([]); + }); + it.each([ + 'SOLID_LOGOUT', + 'SOLID_PROFILE_REMOVE', + ])('%s cancels the preview and removes form-fill grants', async (type) => { + await prepare(); + const state = await send({ type: 'SOLID_PROFILES_LIST' }, EXT_SENDER); + const id = (state.profiles as Array<{ id: string }>)[0].id; + storage.set('solid:formfill-grants', [ + { origin, profileId: id, categories: ['contact'], createdAt: 1 }, + ]); + const { result } = await pendingFill(); + expect((await send({ type, profileId: id }, EXT_SENDER)).error).toBeUndefined(); + expect(await result).toEqual({ values: [] }); + expect(storage.get('solid:formfill-grants')).toEqual([]); + }); + it('rejects insecure and cross-origin iframe senders before any profile fetch', async () => { + await prepare(); + for (const sender of [ + { + ...source, + origin: 'http://remote.example', + url: 'http://remote.example', + tab: { id: 7, url: 'http://remote.example' }, + }, + { + ...source, + frameId: 5, + origin: 'https://embedded.example', + url: 'https://embedded.example', + }, + ]) + expect(await send({ type: 'SOLID_FORMFILL_REQUEST', fields }, sender)).toEqual({ + values: [], + }); + expect(fetchCalls).toHaveLength(0); + expect(createdWindows).toHaveLength(0); + }); +}); diff --git a/apps/extension/test/service-worker-gate.test.ts b/apps/extension/test/service-worker-gate.test.ts new file mode 100644 index 000000000..35dbc6285 --- /dev/null +++ b/apps/extension/test/service-worker-gate.test.ts @@ -0,0 +1,2510 @@ +import { seedOriginGrantFixtures } from './profile-fixtures'; +// AUTHORED-BY Claude Opus 4.8 +// +// INTEGRATION tests for the Phase-0 per-requesting-origin gate AS WIRED INTO the service +// worker. These drive the REAL `chrome.runtime.onMessage` listener the worker registers, +// with an in-memory `chrome.storage.local` and a captured `fetch`, proving the wiring — not +// just the pure gate logic (covered in requesting-origin.test.ts): +// +// - a forged/mismatched sender.origin vs the page stamp → DENY, NO pod egress +// - an un-granted origin attempting a whole-pod read → DENY, NO egress +// - app-A requesting app-B-only data (A un-granted) → DENY +// - opaque/null origin → DENY (fail-safe) +// - a boot/SW-restart race (grant store not yet populated) → fail-CLOSED +// - the legitimate single-user owner path (granted origin) → SUCCEEDS with egress +// - login from an app origin GRANTS that origin (the opt-in), unlocking subsequent fetches +// - SOLID_GET_STATE withholds the WebID (PII) from a non-granted page, exposes to granted + +import * as jose from 'jose'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateDpopKeyPair } from '../src/background/core/dpop'; +import type { StoredDpopKeyRecord } from '../src/background/core/dpop-key-db'; +import { ProfileStore } from '../src/background/core/profile-store'; +import type { ReplicaMetadata } from '../src/background/core/replica-db'; +import type { StoredSession } from '../src/background/session-store'; +import { fixtureGrant, MemoryProfileDb, profileCredentials } from './profile-fixtures'; + +let profileDb: MemoryProfileDb; +vi.mock('../src/background/profile-db', () => ({ + ProfileDb: { open: async () => profileDb }, +})); + +// --- An in-memory DpopKeyDb double (Phase-2 security hardening) ----------------------- +// The DPoP private key is no longer carried inline on `StoredSession` — it lives as a +// non-extractable `CryptoKeyPair` in its own IndexedDB store (`core/dpop-key-db.ts`). Fake it +// the same way as the replica DB below: a module-level single-record store so a simulated SW +// "restart" (fresh module import) re-opens the SAME durable key. +let idbDpopKey: StoredDpopKeyRecord | undefined; +/** Set to simulate a TRANSIENT failure (IndexedDB open error, a bug) on EVERY `get()` until + * explicitly cleared (not one-shot — a real cold-wake failure would keep failing `get()` + * calls until the underlying condition clears, not just the first one). */ +let idbDpopKeyGetError: Error | null = null; +class FakeDpopKeyDb { + static async open() { + return new FakeDpopKeyDb(); + } + async get() { + if (idbDpopKeyGetError) throw idbDpopKeyGetError; + return idbDpopKey; + } + async put(record: StoredDpopKeyRecord) { + idbDpopKey = record; + } + async clear() { + idbDpopKey = undefined; + } + close() {} +} +vi.mock('../src/background/core/dpop-key-db', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, DpopKeyDb: FakeDpopKeyDb }; +}); + +/** Persist a keypair into the fake DPoP key store, as `mintAndPersistDpopKeyPair` would. */ +async function seedDpopKeyPair(keyPair: CryptoKeyPair): Promise { + idbDpopKey = { + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + publicJwk: (await crypto.subtle.exportKey('jwk', keyPair.publicKey)) as JsonWebKey, + }; +} + +// --- In-memory replica stores (Phase 1) ----------------------------------------------- +// The fetch path now routes through the shared replica, which opens IndexedDB (metadata + +// nonce) and the Cache API. Stub both so the Phase-0 gate assertions below run unchanged: +// a DENY must still produce NO egress, an ALLOW must still reach the pod with the token. +const idbMeta = new Map(); +const idbNonces = new Map(); +class FakeReplicaDb { + static async open() { + return new FakeReplicaDb(); + } + async getMetadata(key: string) { + return idbMeta.get(key); + } + async putMetadata(record: ReplicaMetadata) { + idbMeta.set(record.key, record); + } + async deleteMetadata(key: string) { + idbMeta.delete(key); + } + async getMetadataByWebId(webId: string) { + return [...idbMeta.values()].filter((r) => r.webId === webId); + } + async deleteMetadataByWebId(webId: string) { + for (const [k, v] of idbMeta) if (v.webId === webId) idbMeta.delete(k); + } + async clearMetadata() { + idbMeta.clear(); + } + async getNonce(origin: string) { + return idbNonces.get(origin); + } + async setNonce(origin: string, nonce: string) { + idbNonces.set(origin, nonce); + } + async clearNonces() { + idbNonces.clear(); + } + close() {} +} +vi.mock('../src/background/core/replica-db', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ReplicaDb: FakeReplicaDb }; +}); +const cacheStore = new Map< + string, + { body: string; status: number; headers: Record } +>(); +function installCaches(): void { + (globalThis as unknown as { caches: unknown }).caches = { + open: async () => ({ + match: async (request: Request) => { + const e = cacheStore.get(request.url); + return e ? new Response(e.body, { status: e.status, headers: e.headers }) : undefined; + }, + put: async (request: Request, response: Response) => { + cacheStore.set(request.url, { + body: await response.text(), + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + }); + }, + delete: async (request: Request) => cacheStore.delete(request.url), + keys: async () => [...cacheStore.keys()].map((url) => new Request(url)), + }), + }; +} + +// --- An in-memory chrome.* stub + a captured fetch ------------------------------------ + +interface CapturedSender { + origin?: string; + url?: string; + id?: string; +} +type MessageListener = ( + message: unknown, + sender: CapturedSender, + sendResponse: (r: unknown) => void, +) => boolean | undefined; + +let storage: Map; +let messageListeners: MessageListener[]; +let fetchCalls: Array<{ url: string; init?: RequestInit }>; +let fetchImpl: (url: string, init?: RequestInit) => Promise; + +const EXTENSION_ID = 'abcdefghijklmnopabcdefghijklmnop'; + +function installChrome(): void { + storage = new Map(); + messageListeners = []; + fetchCalls = []; + + const chromeStub = { + runtime: { + id: EXTENSION_ID, + lastError: undefined as { message?: string } | undefined, + onMessage: { addListener: (fn: MessageListener) => messageListeners.push(fn) }, + onInstalled: { addListener: (_fn: () => void) => {} }, + sendMessage: (_m: unknown, _cb?: (r: unknown) => void) => Promise.resolve(), + }, + storage: { + local: { + get: async (key: string) => { + const out: Record = {}; + if (storage.has(key)) out[key] = storage.get(key); + return out; + }, + set: async (obj: Record) => { + for (const [k, v] of Object.entries(obj)) storage.set(k, v); + }, + remove: async (key: string) => { + storage.delete(key); + }, + }, + }, + tabs: { + query: async () => [] as Array<{ id?: number; url?: string }>, + get: async (id: number) => (await chrome.tabs.query({})).find((tab) => tab.id === id), + sendMessage: async () => {}, + }, + action: { + setIcon: async () => {}, + setBadgeText: async () => {}, + setBadgeBackgroundColor: async () => {}, + setTitle: async () => {}, + }, + contextMenus: { + create: () => {}, + remove: (_id: string, cb?: () => void) => cb?.(), + onClicked: { addListener: () => {} }, + }, + sidePanel: { setPanelBehavior: async () => {}, open: async () => {} }, + identity: { launchWebAuthFlow: async () => '' }, + }; + (globalThis as unknown as { chrome: unknown }).chrome = chromeStub; + + // A captured global fetch: default deny-with-401 so an UNGATED egress is observable. + fetchImpl = async () => new Response('ok', { status: 200 }); + (globalThis as unknown as { fetch: typeof fetch }).fetch = vi.fn( + (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + fetchCalls.push({ url, init }); + return fetchImpl(url, init); + }, + ) as unknown as typeof fetch; +} + +/** Drive the worker's registered SOLID_FETCH_REQUEST listener and await its response. */ +function sendFetch( + message: Record, + sender: CapturedSender, +): Promise> { + return new Promise((resolve) => { + for (const listener of messageListeners) { + const handled = listener( + { + type: 'SOLID_FETCH_REQUEST', + requestId: 'r1', + method: 'GET', + headers: {}, + body: null, + ...message, + }, + sender, + (r) => resolve(r as Record), + ); + if (handled) return; + } + resolve({ error: 'no listener handled' }); + }); +} + +function sendMessage( + message: Record, + sender: CapturedSender, +): Promise> { + return new Promise((resolve) => { + for (const listener of messageListeners) { + const handled = listener(message, sender, (r) => resolve(r as Record)); + if (handled) return; + } + resolve({ error: 'no listener handled' }); + }); +} + +async function seedSession(overrides: Partial = {}): Promise { + const keyPair = await generateDpopKeyPair(); + await seedDpopKeyPair(keyPair); + const session: StoredSession = { + webId: 'https://alice.pod.example/profile/card#me', + accessToken: 'access-token-xyz', + refreshToken: 'refresh-xyz', + issuer: 'https://idp.example/', + tokenEndpoint: 'https://idp.example/token', + clientId: 'https://pm.example/clientid.jsonld', + expiresAt: Date.now() + 60 * 60 * 1000, // far in the future: no refresh needed + ...overrides, + }; + storage.set('solid:session', session); +} + +/** + * Fresh module + chrome per test (the worker registers listeners + state at import). Drop + * any PRIOR module instance's registered listener first — this matters when a single test + * calls `loadWorker()` more than once to model an SW restart mid-test (e.g. the transient + * DPoP-key-store-failure test below): without clearing it, the stale listener stays in + * `messageListeners` alongside the new one, and `sendMessage`/`sendFetch` would dispatch to + * whichever handles first — silently serving the OLD (already-cached-key) instance's + * response instead of exercising the new one. + */ +async function loadWorker(): Promise { + seedOriginGrantFixtures(storage, profileDb); + messageListeners.length = 0; + vi.resetModules(); + await import('../src/background/service-worker'); + // Let the import-time ensureSession()/icon promise settle. + await new Promise((r) => setTimeout(r, 0)); +} + +beforeEach(() => { + vi.doUnmock('../src/background/auth-flow'); + profileDb = new MemoryProfileDb(); + installChrome(); + installCaches(); + cacheStore.clear(); + idbMeta.clear(); + idbNonces.clear(); + idbDpopKey = undefined; + idbDpopKeyGetError = null; +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** Exercise the real asynchronous icon renderer without a browser canvas. */ +function installIconCanvas(): void { + vi.stubGlobal( + 'OffscreenCanvas', + class { + constructor( + private readonly width: number, + private readonly height: number, + ) {} + getContext() { + return { + beginPath() {}, + roundRect() {}, + fill() {}, + fillText() {}, + save() {}, + arc() {}, + clip() {}, + drawImage() {}, + restore() {}, + getImageData: () => ({ + width: this.width, + height: this.height, + data: new Uint8ClampedArray(4), + }), + }; + } + }, + ); + vi.stubGlobal('createImageBitmap', async () => ({ width: 1, height: 1, close() {} })); +} + +describe('service-worker per-requesting-origin gate (wired)', () => { + it.each([ + ['https://pm.example/redirect', false], + ['https://pm.example/redirect', true], + ['https://alice.pod.example/redirect', false], + ])('rejects redirects from authorized URLs without reading foreign bytes (%s, auto=%s)', async (url, autoDivert) => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + const foreign = vi.fn(() => new Response('foreign private bytes')); + // Model native fetch: without a redirect policy it follows under the worker's host access. + fetchImpl = async (_url, init) => { + if (init?.redirect === 'error') throw new TypeError('Redirect rejected'); + if (init?.redirect === 'manual') return new Response(null, { status: 302 }); + return foreign(); + }; + const response = await sendFetch( + { url, autoDivert, stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + expect(foreign).not.toHaveBeenCalled(); + expect(response.body).toBeUndefined(); + expect(response.error).toMatch(/redirect/i); + expect(fetchCalls.at(-1)?.init?.redirect).toBe('error'); + }); + + it('the legitimate owner path: a GRANTED app reads its pod → SUCCEEDS with egress', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/private/notes.ttl', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, + ); + + expect(res.status).toBe(200); + // The token-bearing egress to the pod actually happened. + const podCall = fetchCalls.find((c) => c.url.startsWith('https://alice.pod.example')); + expect(podCall).toBeDefined(); + const headers = podCall?.init?.headers as Record; + expect(headers.Authorization).toBe('DPoP access-token-xyz'); + }); + + it('a TRANSIENT DPoP-key-store failure fails ONE request soft — no destructive teardown', async () => { + // roborev round-1 (b64367d) Medium: ensureSession() must not conflate an unexpected + // IndexedDB/crypto failure with a genuinely broken session (DpopKeyUnavailableError) — + // the former must fail this one request without clearing the stored session/tokens, so + // a later, working request still succeeds without a fresh login. + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); // normal cold wake: caches the keypair once + + // A fresh SW "restart" (new module instance, empty in-memory caches) that happens to hit + // a transient DB-open failure on its cold-wake key load — set BEFORE the restart so it + // also covers the import-time `ensureSession()` call `loadWorker()` triggers. + profileDb.error = new Error('simulated IndexedDB open failure'); + await loadWorker(); + const failed = await sendFetch( + { url: 'https://alice.pod.example/private/notes.ttl', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, + ); + expect(failed.status).not.toBe(200); + // No pod egress happened for the failed attempt. + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + // Crucially: the session was NOT torn down — it is still in storage, untouched. + expect(profileDb.state.records[0].session).not.toBeNull(); + + // The transient condition clears (no restart needed — `cachedKeyPair` was never + // populated by the failed attempts, so the very next call retries the DB read). + profileDb.error = null; + const ok = await sendFetch( + { url: 'https://alice.pod.example/private/notes.ttl', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, + ); + expect(ok.status).toBe(200); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(true); + }); + + it('DENIES a credential-origin app without an explicit origin grant', async () => { + await seedSession(); + // no explicit grants — the pod origin is auto-granted as a requester. + await loadWorker(); + const res = await sendFetch( + { url: 'https://alice.pod.example/x.ttl', stampedOrigin: 'https://alice.pod.example' }, + { origin: 'https://alice.pod.example', url: 'https://alice.pod.example/app' }, + ); + expect(res.status).toBe(403); + expect(fetchCalls).toHaveLength(0); + }); + + it('DENIES a FORGED sender.origin (renderer spoofs a granted origin) — NO egress', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendFetch( + // The page stamps the granted origin, but the browser attests the attacker's. + { url: 'https://alice.pod.example/private/notes.ttl', stampedOrigin: 'https://pm.example' }, + { origin: 'https://attacker.example', url: 'https://attacker.example/x' }, + ); + + expect(res.status).toBe(403); + expect(res.error).toBe('Forbidden origin'); + // CRITICAL: no request to the pod was ever made. + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); + + it('DENIES an UN-GRANTED origin attempting a whole-pod read — NO egress', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/', stampedOrigin: 'https://evil.example' }, + { origin: 'https://evil.example', url: 'https://evil.example/x' }, + ); + + expect(res.status).toBe(403); + expect(res.error).toBe('Origin not granted access'); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); + + it('DENIES app-A reading app-B-only data when A is not granted — NO egress', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://appB.example']); // only B + await loadWorker(); + + const res = await sendFetch( + { + url: 'https://alice.pod.example/appB-data/secret.ttl', + stampedOrigin: 'https://appA.example', + }, + { origin: 'https://appA.example', url: 'https://appA.example/x' }, + ); + + expect(res.status).toBe(403); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); + + it('DENIES an opaque/null requesting origin (fail-safe) — NO egress', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/x', stampedOrigin: 'null' }, + { origin: 'null', url: 'about:blank' }, + ); + + expect(res.status).toBe(403); + expect(res.error).toBe('Forbidden origin'); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); + + it('FAIL-CLOSED on a boot/SW-restart race: a request that arrives before the grant store is populated is DENIED', async () => { + // Session exists, but the granted-origins store is EMPTY (e.g. a cold wake before the + // owner has opted any web app in). pm.example is NOT a credential origin, so default-deny. + await seedSession(); + // No 'solid:granted-origins' key set at all. + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/private/notes.ttl', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/x' }, + ); + + expect(res.status).toBe(403); + expect(res.error).toBe('Origin not granted access'); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); + + it('DENIES a granted app proxying to an unrelated FOREIGN third party — NO egress to it', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://tracker.example/collect', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/x' }, + ); + + expect(res.status).toBe(403); + expect(res.error).toBe('Cross-origin fetch denied'); + expect(fetchCalls.some((c) => c.url.startsWith('https://tracker.example'))).toBe(false); + }); + + it('DENIES a PRESENT-but-OPAQUE sender.origin ("null") with a normal https sender.url — NO egress (High #1)', async () => { + // The High #1 exploit wired through the SW: an opaque/sandboxed frame whose sender.origin is + // the string "null" but whose sender.url is a normal https URL that matches the stamp. The + // old fail-open would have laundered it into the granted origin and served credentials. + await seedSession(); + storage.set('solid:granted-origins', ['https://app.example']); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/private/notes.ttl', stampedOrigin: 'https://app.example' }, + { origin: 'null', url: 'https://app.example/page.html' }, + ); + + expect(res.status).toBe(403); + expect(res.error).toBe('Forbidden origin'); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); + + it('STILL allows a sender with ABSENT origin but a valid https url + matching stamp (legit fallback)', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://app.example']); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/private/notes.ttl', stampedOrigin: 'https://app.example' }, + { url: 'https://app.example/page.html' }, // no origin field → ABSENT → derive from url + ); + + expect(res.status).toBe(200); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(true); + }); + + it('returns "Not authenticated" with NO gate egress when there is no session', async () => { + // No session seeded. + await loadWorker(); + const res = await sendFetch( + { url: 'https://alice.pod.example/x', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/x' }, + ); + expect(res.error).toBe('Origin not granted access'); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); +}); + +describe('login never grants an origin without consent', () => { + // High #2: the grant must be persisted ONLY AFTER a successful, owner-approved login — never + // before initiateLogin. In these tests the real OIDC flow has no IdP, so initiateLogin THROWS; + // therefore a SOLID_LOGIN here represents a CANCELLED/FAILED login and must leave the grant + // store UNCHANGED. The success path is covered separately by mocking initiateLogin. + + it('does NOT grant the requesting origin when initiateLogin fails/cancels', async () => { + await loadWorker(); + const res = await sendMessage( + { + type: 'SOLID_LOGIN', + webId: 'https://alice.pod.example/card#me', + origin: 'https://newapp.example', + }, + { origin: 'https://newapp.example', url: 'https://newapp.example/x' }, + ); + // The login failed (no IdP) ... + expect(res.error).toBeDefined(); + // ... so NO grant was persisted (the privilege-escalation fix). + const grants = (profileDb.state.records[0]?.grantedOrigins as string[]) ?? []; + expect(grants).not.toContain('https://newapp.example'); + }); + + it('a FAILED/CANCELLED login leaves the grant store UNCHANGED and a later fetch from that origin is DENIED even when a session EXISTS', async () => { + // The attack High #2 closes: a session already exists for the user; a hostile page sends + // SOLID_LOGIN (which it cancels/fails), then tries SOLID_FETCH_REQUEST to ride the existing + // credentials. The cancelled login must NOT have granted the page. + await seedSession(); // a live session already exists + // No grant for the hostile origin. + await loadWorker(); + + // Step 1: the hostile page drives a login that fails (no IdP). + const loginRes = await sendMessage( + { + type: 'SOLID_LOGIN', + webId: 'https://alice.pod.example/card#me', + origin: 'https://hostile.example', + }, + { origin: 'https://hostile.example', url: 'https://hostile.example/x' }, + ); + expect(loginRes.error).toBeDefined(); + const grants = (profileDb.state.records[0]?.grantedOrigins as string[]) ?? []; + expect(grants).not.toContain('https://hostile.example'); + + // The failing OIDC flow itself may make network calls (discovery/profile) on the raw fetch; + // those are NOT the gated egress we're asserting about. Reset the captured calls so the next + // assertion concerns ONLY the credential-attaching SOLID_FETCH_REQUEST egress. + fetchCalls.length = 0; + + // Step 2: the hostile page now tries to read the existing session's pod. DENIED — no grant, + // so NO credential-attaching egress to the pod from the gated fetch path. + const fetchRes = await sendFetch( + { + url: 'https://alice.pod.example/private/notes.ttl', + stampedOrigin: 'https://hostile.example', + }, + { origin: 'https://hostile.example', url: 'https://hostile.example/x' }, + ); + expect(fetchRes.status).toBe(403); + expect(fetchRes.error).toBe('Origin not granted access'); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); + + it('a SUCCESSFUL login does not replace origin consent', async () => { + // Mock initiateLogin to succeed, returning a fresh session, so we exercise the success path + // where the grant SHOULD now be persisted. The real `completeLogin` would also have called + // `mintAndPersistDpopKeyPair` — seed the fake DPoP key store the same way. + const keyPair = await generateDpopKeyPair(); + await seedDpopKeyPair(keyPair); + const session: StoredSession = { + webId: 'https://alice.pod.example/profile/card#me', + accessToken: 'access-token-xyz', + refreshToken: 'refresh-xyz', + issuer: 'https://idp.example/', + tokenEndpoint: 'https://idp.example/token', + clientId: 'https://newapp.example/clientid.jsonld', + expiresAt: Date.now() + 60 * 60 * 1000, + }; + vi.doMock('../src/background/auth-flow', () => ({ + initiateLogin: vi.fn(async () => ({ session, keyPair, name: 'Alice', photoUrl: null })), + refreshSession: vi.fn(async (s: StoredSession) => s), + })); + await loadWorker(); + + const loginRes = await sendMessage( + { + type: 'SOLID_LOGIN', + webId: 'https://alice.pod.example/card#me', + origin: 'https://newapp.example', + }, + { origin: 'https://newapp.example', url: 'https://newapp.example/x' }, + ); + expect(loginRes.ok).toBe(true); + const grants = (profileDb.state.records[0]?.grantedOrigins as string[]) ?? []; + expect(grants).not.toContain('https://newapp.example'); + + // Login may fetch display metadata, but it never authorizes the app's data fetch. + fetchCalls.length = 0; + const fetchRes = await sendFetch( + { + url: 'https://alice.pod.example/private/notes.ttl', + stampedOrigin: 'https://newapp.example', + }, + { origin: 'https://newapp.example', url: 'https://newapp.example/app.html' }, + ); + expect(fetchRes.status).toBe(403); + expect(fetchCalls).toHaveLength(0); + vi.doUnmock('../src/background/auth-flow'); + }); + + it('does NOT grant a FORGED login origin even on success (sender ≠ stamp)', async () => { + const keyPair = await generateDpopKeyPair(); + await seedDpopKeyPair(keyPair); + const session: StoredSession = { + webId: 'https://alice.pod.example/profile/card#me', + accessToken: 'access-token-xyz', + refreshToken: 'refresh-xyz', + issuer: 'https://idp.example/', + tokenEndpoint: 'https://idp.example/token', + clientId: 'https://newapp.example/clientid.jsonld', + expiresAt: Date.now() + 60 * 60 * 1000, + }; + vi.doMock('../src/background/auth-flow', () => ({ + initiateLogin: vi.fn(async () => ({ session, keyPair, name: 'Alice', photoUrl: null })), + refreshSession: vi.fn(async (s: StoredSession) => s), + })); + await loadWorker(); + + await sendMessage( + { + type: 'SOLID_LOGIN', + webId: 'https://alice.pod.example/card#me', + origin: 'https://victim.example', // stamp claims the victim + }, + { origin: 'https://attacker.example', url: 'https://attacker.example/x' }, // real sender + ); + const grants = (profileDb.state.records[0]?.grantedOrigins as string[]) ?? []; + // The forged origin resolves to null → no grant for victim OR attacker, even on success. + expect(grants).not.toContain('https://victim.example'); + expect(grants).not.toContain('https://attacker.example'); + vi.doUnmock('../src/background/auth-flow'); + }); +}); + +describe('SOLID_GET_STATE scopes the WebID (PII) to granted origins', () => { + it('WITHHOLDS the WebID from a non-granted web page', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendMessage( + { type: 'SOLID_GET_STATE', stampedOrigin: 'https://random.example' }, + { origin: 'https://random.example', url: 'https://random.example/x' }, + ); + expect(res.webId).toBeNull(); + expect(res.isActive).toBe(false); + expect(res.recentAccounts).toEqual([]); + }); + + it('EXPOSES the WebID to a granted web page', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendMessage( + { type: 'SOLID_GET_STATE', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/x' }, + ); + expect(res.webId).toBe('https://alice.pod.example/profile/card#me'); + expect(res.isActive).toBe(true); + }); + + it('gives the EXTENSION popup (own context) the full state incl. recent accounts', async () => { + await seedSession(); + storage.set('solid:recent-accounts', [ + { webId: 'https://alice.pod.example/card#me', name: 'Alice', photoUrl: null }, + ]); + await loadWorker(); + + const res = await sendMessage( + { type: 'SOLID_GET_STATE' }, // popup sends no stampedOrigin + { + origin: `chrome-extension://${EXTENSION_ID}`, + url: `chrome-extension://${EXTENSION_ID}/popup/popup.html`, + }, + ); + expect(res.webId).toBe('https://alice.pod.example/profile/card#me'); + expect((res.recentAccounts as unknown[]).length).toBe(1); + }); + + it('WITHHOLDS the WebID from a FORGED state request (sender ≠ stamp)', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendMessage( + { type: 'SOLID_GET_STATE', stampedOrigin: 'https://pm.example' }, // claims granted + { origin: 'https://attacker.example', url: 'https://attacker.example/x' }, // real sender + ); + expect(res.webId).toBeNull(); + }); +}); + +describe('Round-2 HIGH #1: autoDivert returns a PASSTHROUGH SENTINEL — the SW does ZERO network (no cross-origin proxy)', () => { + it('an autoDivert fetch to a NON-POD third party → sentinel { passthrough:true }, NO SW fetch (NOT a 403, NO bytes)', async () => { + // The round-2 fix: an app calls a third-party API/CDN. The global-fetch patch routes it + // (autoDivert=true). The SW gate denies (foreign target). The SW must NOT fetch it itself + // (that would make the extension a cross-origin read proxy via its perms) — it + // returns a passthrough SENTINEL with no body/status, and the page completes it natively. + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + fetchImpl = async () => new Response('third-party-json', { status: 200 }); + await loadWorker(); + + const res = await sendFetch( + { + url: 'https://api.thirdparty.example/data', + stampedOrigin: 'https://pm.example', + autoDivert: true, + }, + { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, + ); + + // The SW returned ONLY the sentinel — no body, no status, no fetched bytes. + expect(res.passthrough).toBe(true); + expect(res.body).toBeUndefined(); + expect(res.status).toBeUndefined(); + expect(res.error).toBeUndefined(); + // CRITICAL (cross-origin-proxy hole closed): the SW performed NO fetch to the third party. + expect(fetchCalls.some((c) => c.url.startsWith('https://api.thirdparty.example'))).toBe(false); + }); + + it('SECURITY: a page CANNOT use the extension as a cross-origin read proxy — no autoDivert deny ever triggers an SW fetch', async () => { + // The exact round-2 exploit: a hostile page postMessages an autoDivert request for a + // cross-origin resource CORS would block. The SW must never fetch it and return its bytes. + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + // If the SW were to (wrongly) fetch, this is the secret cross-origin body it would leak. + fetchImpl = async () => new Response('SECRET-CROSS-ORIGIN-BYTES', { status: 200 }); + await loadWorker(); + + const res = await sendFetch( + { + url: 'https://victim-bank.example/account/balance', + stampedOrigin: 'https://attacker.example', + autoDivert: true, + }, + { origin: 'https://attacker.example', url: 'https://attacker.example/x' }, + ); + + // The SW returns a sentinel ONLY — never the cross-origin bytes. + expect(res.passthrough).toBe(true); + expect(res.body).toBeUndefined(); + expect(res.status).toBeUndefined(); + // The SW made ZERO network calls to the victim origin — the proxy hole is closed. + expect(fetchCalls.some((c) => c.url.startsWith('https://victim-bank.example'))).toBe(false); + // And the secret bytes were never returned to the page anywhere in the response. + expect(JSON.stringify(res)).not.toContain('SECRET-CROSS-ORIGIN-BYTES'); + }); + + it('the SAME non-pod request WITHOUT autoDivert (explicit window.solid.fetch) DOES return 403 (no sentinel)', async () => { + // Contrast: an explicit window.solid.fetch deliberately asked for the gated path, so a + // foreign-target deny is the correct, expected 403 — only autoDivert gets a sentinel. + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://api.thirdparty.example/data', stampedOrigin: 'https://pm.example' }, // no autoDivert + { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, + ); + + expect(res.status).toBe(403); + expect(res.error).toBe('Cross-origin fetch denied'); + expect(res.passthrough).toBeUndefined(); + expect(fetchCalls.some((c) => c.url.startsWith('https://api.thirdparty.example'))).toBe(false); + }); + + it('an autoDivert POD read from an UN-GRANTED origin → sentinel, NOT a 403 and NOT an SW fetch', async () => { + // A non-granted app's global fetch hits the pod. Per the invariant it must not hard-fail; the + // SW returns a sentinel (NO network, NO credential) and the page does the plain native fetch + // (the pod returns its own 401/WAC). The SW must NOT itself fetch the pod here. + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); // ungranted.example is NOT granted + fetchImpl = async () => new Response('pod-public-or-401', { status: 401 }); + await loadWorker(); + + const res = await sendFetch( + { + url: 'https://alice.pod.example/private/notes.ttl', + stampedOrigin: 'https://ungranted.example', + autoDivert: true, + }, + { origin: 'https://ungranted.example', url: 'https://ungranted.example/x' }, + ); + + // Not a gate-403 — a passthrough sentinel, and NO SW egress to the pod. + expect(res.passthrough).toBe(true); + expect(res.status).toBeUndefined(); + expect(res.error).toBeUndefined(); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); + + it('a GRANTED pod read still gets the AUTHENTICATED replica path even with autoDivert set (no sentinel)', async () => { + // autoDivert must NOT downgrade a legitimate granted pod read — that is still gated+authed. + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + fetchImpl = async () => + new Response('private-pod-bytes', { status: 200, headers: { etag: 'W/"v1"' } }); + await loadWorker(); + + const res = await sendFetch( + { + url: 'https://alice.pod.example/private/notes.ttl', + stampedOrigin: 'https://pm.example', + autoDivert: true, + }, + { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, + ); + + expect(res.passthrough).toBeUndefined(); + expect(res.status).toBe(200); + expect(res.body).toBe('private-pod-bytes'); + const podCall = fetchCalls.find((c) => c.url.startsWith('https://alice.pod.example')); + const headers = (podCall?.init?.headers as Record) ?? {}; + // The DPoP-bound credential WAS attached (the authenticated path, not passthrough). + expect(headers.Authorization).toBe('DPoP access-token-xyz'); + expect(headers.DPoP).toBeDefined(); + }); + + it('an autoDivert request to an OPAQUE/forged origin → sentinel with NO SW fetch (never hard-fails, never proxies)', async () => { + // Even a dual-origin mismatch (which would DENY the gated path) becomes a sentinel under + // autoDivert — normal web traffic is never broken, but the SW still does NO network: the + // page completes it natively in its own origin context. + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + fetchImpl = async () => new Response('cdn-asset', { status: 200 }); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://cdn.example/lib.js', stampedOrigin: 'https://pm.example', autoDivert: true }, + { origin: 'https://attacker.example', url: 'https://attacker.example/x' }, // sender ≠ stamp + ); + + expect(res.passthrough).toBe(true); + expect(res.body).toBeUndefined(); + // The SW did NOT fetch the CDN — the page will, natively. + expect(fetchCalls.some((c) => c.url.startsWith('https://cdn.example'))).toBe(false); + }); + + it('an autoDivert fetch with NO session → sentinel, NO SW fetch (explicit path says Not authenticated)', async () => { + // No session seeded. The explicit window.solid.fetch returns "Not authenticated"; an + // autoDivert request gets a sentinel so the page does the plain native fetch (no creds exist), + // and the SW performs no network. + fetchImpl = async () => new Response('public-resource', { status: 200 }); + await loadWorker(); + + const res = await sendFetch( + { + url: 'https://api.thirdparty.example/data', + stampedOrigin: 'https://pm.example', + autoDivert: true, + }, + { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, + ); + expect(res.passthrough).toBe(true); + expect(res.status).toBeUndefined(); + expect(res.error).toBeUndefined(); + expect(fetchCalls.some((c) => c.url.startsWith('https://api.thirdparty.example'))).toBe(false); + }); +}); + +const PROFILE_OWNER = { + origin: `chrome-extension://${EXTENSION_ID}`, + url: `chrome-extension://${EXTENSION_ID}/popup/popup.html`, +}; +const PROFILE_APP_A = { origin: 'https://appa.example', url: 'https://appa.example/app' }; +const PROFILE_APP_B = { origin: 'https://appb.example', url: 'https://appb.example/app' }; + +async function twoProfiles() { + profileDb.state.migrated = true; + const store = new ProfileStore(profileDb, async () => {}); + const a = await profileCredentials(); + const b = await profileCredentials('bob'); + await store.login(a.session, a.keyPair, { ...a.display, photoUrl: null }); + await store.login(b.session, b.keyPair, { ...b.display, photoUrl: null }); + await store.capabilities('1', (r) => { + r.grantedOrigins = [PROFILE_APP_A.origin]; + r.podOrigins = ['https://alice-only.example']; + r.classGrants = [fixtureGrant(a.session.webId)]; + }); + await store.capabilities('2', (r) => { + r.grantedOrigins = [PROFILE_APP_B.origin]; + r.classGrants = [fixtureGrant(b.session.webId)]; + }); + return { store, a, b }; +} + +function profileFetch(sender = PROFILE_APP_A, extra: Record = {}) { + return sendFetch( + { url: 'https://pod.example/private/notes', stampedOrigin: sender.origin, ...extra }, + sender, + ); +} + +function profileMessage(type: string, extra: Record = {}) { + return sendMessage({ type, ...extra }, PROFILE_OWNER); +} + +describe('ProfileStore runtime API and adversarial isolation', () => { + const actions = [ + { type: 'SOLID_PROFILES_LIST' }, + { type: 'SOLID_PROFILE_ADD', webId: 'https://hostile.example/#me' }, + { type: 'SOLID_PROFILE_RENAME', profileId: '1', label: 'Hijacked' }, + { type: 'SOLID_PROFILE_REMOVE', profileId: '1' }, + { type: 'SOLID_PROFILE_SET_DEFAULT', profileId: '2' }, + ]; + + it.each( + actions, + )('rejects untrusted $type messages before any state or network change', async (action) => { + await twoProfiles(); + await loadWorker(); + const before = structuredClone(profileDb.state); + const callsBefore = fetchCalls.length; + for (const sender of [ + PROFILE_APP_A, + {}, + { origin: 'null' }, + { origin: PROFILE_APP_A.origin, url: PROFILE_OWNER.url }, + { origin: PROFILE_OWNER.origin, url: PROFILE_APP_A.url }, + { url: `${PROFILE_OWNER.origin}.evil/popup.html` }, + ]) { + const result = await sendMessage(action, sender); + expect(result.error).toMatch(/extension context/); + expect(result.profiles).toBeUndefined(); + } + expect(profileDb.state).toEqual(before); + expect(fetchCalls).toHaveLength(callsBefore); + }); + + it('lists, renames and switches with token-free metadata, persisting the default across wake', async () => { + await twoProfiles(); + await loadWorker(); + const list = await profileMessage('SOLID_PROFILES_LIST'); + expect(list.defaultProfileId).toBe('1'); + expect(list.profiles).toHaveLength(2); + const serialized = JSON.stringify(list); + for (const forbidden of [ + 'accessToken', + 'refreshToken', + 'privateKey', + 'fixture-access', + 'fixture-refresh', + ]) { + expect(serialized).not.toContain(forbidden); + } + expect( + (await profileMessage('SOLID_PROFILE_RENAME', { profileId: '2', label: 'Work' })).ok, + ).toBe(true); + const switched = await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + expect(switched.defaultProfileId).toBe('2'); + await loadWorker(); + const view = await sendMessage({ type: 'SOLID_GET_STATE' }, PROFILE_OWNER); + expect(view).toMatchObject({ + webId: 'https://pod.example/bob/profile#me', + name: 'Work', + isActive: true, + }); + expect( + (await profileMessage('SOLID_PROFILE_RENAME', { profileId: '2', label: '' })).error, + ).toMatch(/label/); + for (const type of [ + 'SOLID_PROFILE_RENAME', + 'SOLID_PROFILE_REMOVE', + 'SOLID_PROFILE_SET_DEFAULT', + ]) { + expect((await profileMessage(type, { profileId: 'absent', label: 'X' })).error).toMatch( + /Unknown profile/, + ); + } + // Chrome also supplies a URL-only sender on some extension surfaces. + expect( + (await sendMessage({ type: 'SOLID_PROFILES_LIST' }, { url: PROFILE_OWNER.url })).ok, + ).toBe(true); + }); + + it('adds profiles through login, retaining the old default and refusing a ninth before opening auth', async () => { + const credentials = await profileCredentials('first'); + let next = credentials; + const initiateLogin = vi.fn(async () => ({ + ...next, + name: next.display.name, + photoUrl: next.display.photoUrl, + })); + vi.doMock('../src/background/auth-flow', () => ({ initiateLogin, refreshSession: vi.fn() })); + await loadWorker(); + expect( + (await profileMessage('SOLID_PROFILE_ADD', { webId: credentials.session.webId })) + .defaultProfileId, + ).toBe('1'); + for (let i = 2; i <= 8; i++) { + next = await profileCredentials(String(i)); + const result = await profileMessage('SOLID_PROFILE_ADD', { webId: next.session.webId }); + expect(result.ok).toBe(true); + expect(result.defaultProfileId).toBe('1'); + } + expect(profileDb.state.records).toHaveLength(8); + expect(profileDb.state.records[1].grantedOrigins).toEqual([]); + const rejected = await profileMessage('SOLID_PROFILE_ADD', { + webId: 'https://ninth.example/#me', + }); + expect(rejected.error).toMatch(/8 profiles/); + expect(initiateLogin).toHaveBeenCalledTimes(8); + next = credentials; + expect((await profileMessage('SOLID_PROFILE_ADD', { webId: next.session.webId })).ok).toBe( + true, + ); + expect(profileDb.state.records).toHaveLength(8); + initiateLogin.mockRejectedValueOnce(new Error('fixture cancelled')); + expect((await profileMessage('SOLID_PROFILE_ADD', { webId: next.session.webId })).error).toBe( + 'fixture cancelled', + ); + expect(storage.has('solid:session')).toBe(false); + expect(idbDpopKey).toBeUndefined(); + }); + + it('broadcasts each identity only to its own granted tabs', async () => { + const { a, b } = await twoProfiles(); + const tabs = [ + { id: 1, url: PROFILE_APP_A.url }, + { id: 2, url: PROFILE_APP_B.url }, + { id: 3, url: 'https://ungranted.example/' }, + ]; + chrome.tabs.query = vi.fn().mockResolvedValue(tabs); + const send = vi.spyOn(chrome.tabs, 'sendMessage'); + await loadWorker(); + expect(send.mock.calls).toEqual([ + [ + 1, + { type: 'SOLID_STATE_CHANGED', origin: PROFILE_APP_A.origin, webId: a.session.webId }, + { frameId: 0 }, + ], + [ + 2, + { type: 'SOLID_STATE_CHANGED', origin: PROFILE_APP_B.origin, webId: b.session.webId }, + { frameId: 0 }, + ], + [ + 3, + { type: 'SOLID_STATE_CHANGED', origin: 'https://ungranted.example', webId: null }, + { frameId: 0 }, + ], + ]); + send.mockClear(); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + expect(send.mock.calls).toEqual([ + [ + 1, + { type: 'SOLID_STATE_CHANGED', origin: PROFILE_APP_A.origin, webId: a.session.webId }, + { frameId: 0 }, + ], + [ + 2, + { type: 'SOLID_STATE_CHANGED', origin: PROFILE_APP_B.origin, webId: b.session.webId }, + { frameId: 0 }, + ], + [ + 3, + { type: 'SOLID_STATE_CHANGED', origin: 'https://ungranted.example', webId: null }, + { frameId: 0 }, + ], + ]); + }); + + it.each([ + 'expiry', + 'rebind', + 'relogin', + 'signout', + ] as const)('withholds a captured identity when %s occurs while tabs.get awaits', async (change) => { + const { store, b } = await twoProfiles(); + await loadWorker(); + const tab = { id: 2, url: PROFILE_APP_B.url }; + chrome.tabs.query = vi.fn().mockResolvedValue([tab]); + const send = vi.spyOn(chrome.tabs, 'sendMessage'); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const lookup = vi.spyOn(chrome.tabs, 'get').mockImplementationOnce(async () => { + entered(); + await held; + return tab as chrome.tabs.Tab; + }); + try { + const pending = profileMessage('SOLID_PROFILE_RENAME', { profileId: '1', label: 'Personal' }); + await started; + if (change === 'expiry' || change === 'rebind') { + const grants = storage.get('solid:origin-grants') as Array>; + storage.set( + 'solid:origin-grants', + grants.map((grant) => + grant.origin === PROFILE_APP_B.origin + ? { + ...grant, + ...(change === 'expiry' ? { expiresAt: Date.now() - 1 } : { profileId: '1' }), + } + : grant, + ), + ); + } + if (change === 'relogin') { + const replacement = await profileCredentials('bob'); + await store.login(replacement.session, replacement.keyPair, replacement.display); + } + if (change === 'signout') await store.signOut('2'); + release(); + await pending; + expect( + send.mock.calls.some( + ([, message]) => (message as { webId: string | null }).webId === b.session.webId, + ), + ).toBe(false); + } finally { + release(); + lookup.mockRestore(); + } + }); + + it('drops a P2 identity broadcast when the tab navigates during its session lookup', async () => { + await twoProfiles(); + await loadWorker(); + const tab = { id: 2, url: PROFILE_APP_B.url }; + chrome.tabs.query = vi.fn().mockResolvedValue([tab]); + chrome.tabs.get = vi.fn().mockImplementation(async () => ({ ...tab })); + const send = vi.spyOn(chrome.tabs, 'sendMessage'); + const { ProfileStore: WorkerStore } = await import('../src/background/core/profile-store'); + const session = WorkerStore.prototype.session; + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const lookup = vi.spyOn(WorkerStore.prototype, 'session').mockImplementation(async function ( + this: ProfileStore, + id, + ) { + const result = await session.call(this, id); + if (id === '2') { + entered(); + await held; + } + return result; + }); + try { + const pending = profileMessage('SOLID_PROFILE_RENAME', { profileId: '1', label: 'Personal' }); + await started; + tab.url = 'https://ungranted.example/'; + release(); + await pending; + expect(send).not.toHaveBeenCalled(); + } finally { + lookup.mockRestore(); + } + }); + + it('never authorizes an earlier identity with a newly selected profile grant', async () => { + const { b } = await twoProfiles(); + await loadWorker(); + chrome.tabs.query = vi.fn().mockResolvedValue([{ id: 2, url: PROFILE_APP_B.url }]); + const send = vi.spyOn(chrome.tabs, 'sendMessage'); + const { ProfileStore: WorkerStore } = await import('../src/background/core/profile-store'); + const selected = structuredClone(profileDb.state.records[0]); + let release!: () => void; + let entered!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + entered = resolve; + }); + vi.spyOn(WorkerStore.prototype, 'getDefault').mockImplementationOnce(async () => { + entered(); + await held; + return selected; + }); + const old = profileMessage('SOLID_PROFILE_RENAME', { profileId: '1', label: 'Personal' }); + await started; + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + release(); + await old; + expect(send.mock.calls).toEqual([ + [ + 2, + { type: 'SOLID_STATE_CHANGED', origin: PROFILE_APP_B.origin, webId: b.session.webId }, + { frameId: 0 }, + ], + ]); + }); + + it.each([ + 'query', + 'get', + ])('discards a broadcast held in tabs.%s across a default change, including the runtime copy', async (stage) => { + const { b } = await twoProfiles(); + await loadWorker(); + let release!: (tabs: Array<{ id: number; url: string }>) => void; + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise>((resolve) => { + release = resolve; + }); + const tabs = [{ id: 2, url: PROFILE_APP_B.url }]; + chrome.tabs.query = vi.fn().mockResolvedValue(tabs); + if (stage === 'query') + chrome.tabs.query = vi + .fn() + .mockImplementationOnce(() => { + entered(); + return held; + }) + .mockResolvedValue(tabs); + else + chrome.tabs.get = vi + .fn() + .mockImplementationOnce(async () => { + entered(); + return (await held)[0]; + }) + .mockResolvedValue(tabs[0]); + const send = vi.spyOn(chrome.tabs, 'sendMessage'); + const runtime = vi.spyOn(chrome.runtime, 'sendMessage'); + const old = profileMessage('SOLID_PROFILE_RENAME', { profileId: '1', label: 'Personal' }); + await started; + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + release(tabs); + await old; + expect(send.mock.calls).toEqual([ + [ + 2, + { type: 'SOLID_STATE_CHANGED', origin: PROFILE_APP_B.origin, webId: b.session.webId }, + { frameId: 0 }, + ], + ]); + expect(runtime.mock.calls).toEqual([[{ type: 'SOLID_STATE_CHANGED', webId: b.session.webId }]]); + }); + + it.each([ + { next: 'profile', photoOk: true }, + { next: 'profile', photoOk: false }, + { next: 'logout', photoOk: true }, + ])('discards delayed avatar mutations after $next (photo success: $photoOk)', async ({ + next, + photoOk, + }) => { + await twoProfiles(); + await loadWorker(); + installIconCanvas(); + const avatar = 'https://pod.example/alice.png'; + profileDb.state.records[0].profile = { ...profileDb.state.records[0].profile, avatar }; + let entered!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + fetchImpl = async (url) => { + expect(url).toBe(avatar); + entered(); + await held; + return new Response('fixture photo', { status: photoOk ? 200 : 404 }); + }; + const icon = vi.spyOn(chrome.action, 'setIcon'); + const badge = vi.spyOn(chrome.action, 'setBadgeText'); + const background = vi.spyOn(chrome.action, 'setBadgeBackgroundColor'); + const title = vi.spyOn(chrome.action, 'setTitle'); + const old = profileMessage('SOLID_PROFILE_RENAME', { profileId: '1', label: 'Personal' }); + await started; + if (next === 'profile') await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + else await profileMessage('SOLID_LOGOUT'); + const latest = { + icon: [...icon.mock.calls], + badge: [...badge.mock.calls], + background: [...background.mock.calls], + title: [...title.mock.calls], + }; + expect(title).toHaveBeenLastCalledWith({ + title: next === 'profile' ? 'Signed in as bob' : 'Sign in to Solid', + }); + expect(badge).toHaveBeenLastCalledWith({ text: next === 'profile' ? ' ' : '' }); + release(); + expect((await old).ok).toBe(true); + expect(icon.mock.calls).toEqual(latest.icon); + expect(badge.mock.calls).toEqual(latest.badge); + expect(background.mock.calls).toEqual(latest.background); + expect(title.mock.calls).toEqual(latest.title); + }); + + it.each([ + 'active', + 'signed-out', + ])('stops an obsolete %s toolbar update after an awaited mutation', async (state) => { + await twoProfiles(); + await loadWorker(); + installIconCanvas(); + let entered!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + vi.spyOn(chrome.action, 'setIcon').mockImplementationOnce(async () => { + entered(); + await held; + }); + const badge = vi.spyOn(chrome.action, 'setBadgeText'); + const title = vi.spyOn(chrome.action, 'setTitle'); + const old = + state === 'active' + ? profileMessage('SOLID_PROFILE_RENAME', { profileId: '1', label: 'Personal' }) + : profileMessage('SOLID_LOGOUT'); + await started; + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + const latestBadge = [...badge.mock.calls]; + const latestTitle = [...title.mock.calls]; + release(); + expect((await old).ok).toBe(true); + expect(badge.mock.calls).toEqual(latestBadge); + expect(title.mock.calls).toEqual(latestTitle); + expect(title).toHaveBeenLastCalledWith({ title: 'Signed in as bob' }); + }); + + it('never uses P1 grants, tokens, keys or pod settings for P2, even on a shared pod origin', async () => { + const { a, b } = await twoProfiles(); + await loadWorker(); + expect((await profileFetch()).status).toBe(200); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + const before = fetchCalls.length; + expect((await profileFetch(PROFILE_APP_A, { profileId: '2' })).status).toBe(200); + expect(fetchCalls).toHaveLength(before + 1); + const hidden = await sendMessage( + { type: 'SOLID_GET_STATE', stampedOrigin: PROFILE_APP_A.origin }, + PROFILE_APP_A, + ); + expect(hidden.webId).toBe(a.session.webId); + expect((await profileFetch(PROFILE_APP_B, { profileId: '1' })).status).toBe(200); + const podCalls = fetchCalls.filter((c) => c.url === 'https://pod.example/private/notes'); + expect(podCalls).toHaveLength(3); + for (const [call, credentials] of [ + [podCalls[0], a], + [podCalls[1], a], + [podCalls[2], b], + ] as const) { + const headers = new Headers(call.init?.headers); + expect(headers.get('authorization')).toBe(`DPoP ${credentials.session.accessToken}`); + const proof = headers.get('dpop')!; + const publicJwk = await crypto.subtle.exportKey('jwk', credentials.keyPair.publicKey); + expect(jose.decodeProtectedHeader(proof).jwk).toMatchObject({ + x: publicJwk.x, + y: publicJwk.y, + }); + await expect(jose.jwtVerify(proof, credentials.keyPair.publicKey)).resolves.toBeDefined(); + } + expect( + (await profileFetch(PROFILE_APP_B, { url: 'https://alice-only.example/private' })).status, + ).toBe(403); + }); + + it('keeps an in-flight P1 request bound to P1 when the default changes to P2', async () => { + const { a } = await twoProfiles(); + await loadWorker(); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + fetchImpl = async () => { + entered(); + await held; + return new Response('P1-only'); + }; + const pending = profileFetch(); + await started; + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + release(); + expect((await pending).body).toBe('P1-only'); + expect(new Headers(fetchCalls.at(-1)?.init?.headers).get('authorization')).toBe( + `DPoP ${a.session.accessToken}`, + ); + expect(profileDb.state.defaultProfileId).toBe('2'); + }); + + it('serves the cached body when a revalidation returns 304 with an empty body stream', async () => { + await twoProfiles(); + await loadWorker(); + fetchImpl = async (_input, init) => { + if (new Headers(init?.headers).get('if-none-match') === 'fixture-etag') { + // Chrome's 304 carries a non-null, empty body stream. + const notModified = new Response(null, { status: 304 }); + Object.defineProperty(notModified, 'body', { value: new ReadableStream() }); + return notModified; + } + return new Response('fixture bytes', { headers: { etag: 'fixture-etag' } }); + }; + expect((await profileFetch()).body).toBe('fixture bytes'); + const revalidated = await profileFetch(); + expect(revalidated.error).toBeUndefined(); + expect(revalidated.status).toBe(200); + expect(revalidated.body).toBe('fixture bytes'); + }); + + it('cancels in-flight reads and purges only the removed profile', async () => { + const { a, b } = await twoProfiles(); + await loadWorker(); + fetchImpl = async () => new Response('fixture bytes', { headers: { etag: 'fixture-etag' } }); + await profileFetch(); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + await profileFetch(PROFILE_APP_B); + expect(idbMeta.size).toBe(2); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '1' }); + let release!: () => void; + let entered!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + entered = resolve; + }); + fetchImpl = async () => { + entered(); + await held; + return new Response('late P1 bytes'); + }; + const read = profileFetch(); + await started; + const signal = fetchCalls.at(-1)?.init?.signal; + const removal = await profileMessage('SOLID_PROFILE_REMOVE', { profileId: '1' }); + expect(removal.defaultProfileId).toBe('2'); + expect(await read).toEqual({ requestId: 'r1', error: 'Fetch cancelled' }); + expect(signal?.aborted).toBe(true); + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(profileDb.state.records.map((r) => r.profile.id)).toEqual(['2']); + expect([...idbMeta.values()].every((m) => m.webId === b.session.webId)).toBe(true); + expect([...idbMeta.values()].some((m) => m.webId === a.session.webId)).toBe(false); + expect(cacheStore.size).toBe(1); + expect(profileDb.state.records[0].session).toEqual(b.session); + expect(profileDb.state.records[0].classGrants).toHaveLength(1); + await loadWorker(); + expect((await profileFetch(PROFILE_APP_B)).status).toBe(200); + expect( + (await profileMessage('SOLID_PROFILE_REMOVE', { profileId: '2' })).defaultProfileId, + ).toBeNull(); + expect(cacheStore.size).toBe(0); + }); + + it('serves concurrent reads and passthrough decisions while another request is stalled', async () => { + await twoProfiles(); + await loadWorker(); + let release!: () => void; + let entered!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + entered = resolve; + }); + fetchImpl = async (url) => { + if (url.endsWith('/slow')) { + entered(); + await held; + } + return new Response('fixture bytes'); + }; + const slow = profileFetch(PROFILE_APP_A, { url: 'https://pod.example/slow' }); + await started; + const openCache = vi.spyOn(caches, 'open'); + expect( + await profileFetch(PROFILE_APP_A, { + url: 'https://thirdparty.example/api', + autoDivert: true, + requestId: 'passthrough', + }), + ).toEqual({ requestId: 'passthrough', passthrough: true }); + expect(openCache).not.toHaveBeenCalled(); + expect((await profileFetch(PROFILE_APP_A, { url: 'https://pod.example/fast' })).status).toBe( + 200, + ); + expect(fetchCalls).toHaveLength(2); + release(); + expect((await slow).status).toBe(200); + }); + + it.each([ + { lifecycle: 'SOLID_LOGOUT', method: 'GET' }, + { lifecycle: 'SOLID_PROFILE_REMOVE', method: 'GET' }, + { lifecycle: 'SOLID_LOGOUT', method: 'PUT' }, + { lifecycle: 'SOLID_PROFILE_REMOVE', method: 'PUT' }, + ])('cancels a stalled $method response body during $lifecycle without late persistence', async ({ + lifecycle, + method, + }) => { + const { a, b } = await twoProfiles(); + await loadWorker(); + let stream!: ReadableStreamDefaultController; + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + fetchImpl = async () => { + const body = new ReadableStream({ + start(controller) { + stream = controller; + }, + pull() { + entered(); + }, + }); + return new Response(body, { headers: { etag: 'fixture-etag', 'dpop-nonce': 'late-nonce' } }); + }; + const pending = profileFetch(PROFILE_APP_A, { + method, + body: method === 'PUT' ? 'fixture write' : null, + }); + await started; + expect((await profileMessage(lifecycle, { profileId: '1' })).ok).toBe(true); + expect(await pending).toEqual({ requestId: 'r1', error: 'Fetch cancelled' }); + expect(fetchCalls[0].init?.signal?.aborted).toBe(true); + stream.enqueue(new TextEncoder().encode('late private bytes')); + stream.close(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(cacheStore.size).toBe(0); + expect([...idbMeta.values()].some((record) => record.webId === a.session.webId)).toBe(false); + expect(idbNonces.size).toBe(0); + expect(profileDb.state.records.find((record) => record.profile.id === '2')?.session).toEqual( + b.session, + ); + }); + + it.each([ + 'SOLID_LOGOUT', + 'SOLID_PROFILE_REMOVE', + ])('does not wait for a refresh flight during %s or restore its late tokens', async (lifecycle) => { + const { a, b } = await twoProfiles(); + await loadWorker(); + profileDb.state.records[0].session = { ...a.session, expiresAt: 0 }; + let entered!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + fetchImpl = async (url) => { + expect(url).toBe(a.session.tokenEndpoint); + entered(); + await held; + return Response.json({ + access_token: 'late-access', + refresh_token: 'late-refresh', + expires_in: 3600, + }); + }; + const pending = profileFetch(); + await started; + expect((await profileMessage(lifecycle, { profileId: '1' })).ok).toBe(true); + expect(await pending).toEqual({ requestId: 'r1', error: 'Not authenticated' }); + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect( + profileDb.state.records.find((record) => record.profile.id === '1')?.session ?? null, + ).toBeNull(); + expect(profileDb.state.records.find((record) => record.profile.id === '2')?.session).toEqual( + b.session, + ); + expect(fetchCalls).toHaveLength(1); + expect(cacheStore.size).toBe(0); + expect(idbMeta.size).toBe(0); + }); + + it.each([ + { lifecycle: 'SOLID_LOGOUT', phase: 'headers' }, + { lifecycle: 'SOLID_PROFILE_REMOVE', phase: 'headers' }, + { lifecycle: 'SOLID_LOGIN', phase: 'headers' }, + { lifecycle: 'SOLID_LOGOUT', phase: 'body' }, + { lifecycle: 'SOLID_PROFILE_REMOVE', phase: 'body' }, + { lifecycle: 'SOLID_LOGIN', phase: 'body' }, + ])('never replays an automatic POST cancelled during $phase by $lifecycle', async ({ + lifecycle, + phase, + }) => { + const { a } = await twoProfiles(); + const replacement = await profileCredentials(); + vi.doMock('../src/background/auth-flow', () => ({ + initiateLogin: vi.fn(async () => ({ ...replacement, name: 'alice', photoUrl: null })), + refreshSession: vi.fn(), + })); + await loadWorker(); + let entered!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + fetchImpl = async (url) => { + if (url !== 'https://pod.example/private/notes') return new Response(''); + if (phase === 'headers') { + entered(); + await held; + return new Response('created', { status: 201 }); + } + return new Response( + new ReadableStream({ + async start(controller) { + entered(); + await held; + controller.enqueue(new TextEncoder().encode('created')); + controller.close(); + }, + }), + { status: 201 }, + ); + }; + const pending = profileFetch(PROFILE_APP_A, { + method: 'POST', + body: 'fixture mutation', + autoDivert: true, + requestId: 'cancelled-diversion', + }); + await started; + const signal = fetchCalls[0].init?.signal; + expect((await profileMessage(lifecycle, { profileId: '1', webId: a.session.webId })).ok).toBe( + true, + ); + expect(await pending).toEqual({ requestId: 'cancelled-diversion', error: 'Fetch cancelled' }); + expect(signal?.aborted).toBe(true); + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect( + fetchCalls.filter((call) => call.url === 'https://pod.example/private/notes'), + ).toHaveLength(1); + expect(cacheStore.size).toBe(0); + expect(idbMeta.size).toBe(0); + }); + + it('returns passthrough when logout cancels an automatic POST before egress', async () => { + await twoProfiles(); + await loadWorker(); + let entered!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + vi.spyOn(FakeReplicaDb.prototype, 'getNonce').mockImplementationOnce(async () => { + entered(); + await held; + return undefined; + }); + const pending = profileFetch(PROFILE_APP_A, { + method: 'POST', + body: 'fixture mutation', + autoDivert: true, + requestId: 'unsent-diversion', + }); + await started; + expect((await profileMessage('SOLID_LOGOUT')).ok).toBe(true); + expect(await pending).toEqual({ requestId: 'unsent-diversion', passthrough: true }); + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetchCalls).toHaveLength(0); + expect(cacheStore.size).toBe(0); + expect(idbMeta.size).toBe(0); + }); + + it('finishes an atomic local replica commit before purging its bytes and metadata', async () => { + await twoProfiles(); + await loadWorker(); + const cache = await caches.open('solid-ext-replica-bytes'); + const put = cache.put.bind(cache); + let release!: () => void; + let entered!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + entered = resolve; + }); + vi.spyOn(cache, 'put').mockImplementation(async (request, response) => { + await put(request, response); + entered(); + await held; + }); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + const pending = profileFetch(); + await started; + expect(cacheStore.size).toBe(1); + expect(idbMeta.size).toBe(0); + const removal = profileMessage('SOLID_PROFILE_REMOVE', { profileId: '1' }); + release(); + expect((await removal).ok).toBe(true); + expect(await pending).toEqual({ requestId: 'r1', error: 'Fetch cancelled' }); + expect(cacheStore.size).toBe(0); + expect(idbMeta.size).toBe(0); + }); + + it.each([ + 'SOLID_LOGOUT', + 'SOLID_PROFILE_REMOVE', + ])('publishes sign-out after %s cleanup fails and retries the purge on wake', async (type) => { + const { a, b } = await twoProfiles(); + installIconCanvas(); + const icon = vi.spyOn(chrome.action, 'setIcon'); + const badge = vi.spyOn(chrome.action, 'setBadgeText'); + const title = vi.spyOn(chrome.action, 'setTitle'); + await loadWorker(); + expect((await profileFetch()).status).toBe(200); + expect(icon.mock.lastCall?.[0]).toHaveProperty('imageData'); + expect(title).toHaveBeenLastCalledWith({ title: 'Signed in as alice' }); + const metadata = [...idbMeta.values()]; + const bytes = [...cacheStore.keys()]; + const survivor = structuredClone(profileDb.state.records[1]); + const cache = await caches.open('solid-ext-replica-bytes'); + const remove = cache.delete.bind(cache); + let fail = true; + vi.spyOn(cache, 'delete').mockImplementation(async (entry, options) => { + if (fail) throw new Error('fixture cache deletion failed'); + return remove(entry, options); + }); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + chrome.tabs.query = vi.fn().mockResolvedValue([ + { id: 1, url: PROFILE_APP_A.url }, + { id: 2, url: PROFILE_APP_B.url }, + { id: 3, url: 'https://ungranted.example/app' }, + ]); + const tabBroadcast = vi.spyOn(chrome.tabs, 'sendMessage'); + const broadcast = vi.spyOn(chrome.runtime, 'sendMessage'); + + const result = await profileMessage(type, { profileId: '1' }); + expect(result).toEqual({ + error: + type === 'SOLID_LOGOUT' + ? 'Error: fixture cache deletion failed' + : 'fixture cache deletion failed', + }); + expect(icon).toHaveBeenLastCalledWith({ + path: { + 16: 'icons/icon16.png', + 32: 'icons/icon32.png', + 48: 'icons/icon48.png', + 128: 'icons/icon128.png', + }, + }); + expect(badge).toHaveBeenLastCalledWith({ text: '' }); + expect(title).toHaveBeenLastCalledWith({ title: 'Sign in to Solid' }); + expect(broadcast).toHaveBeenLastCalledWith({ type: 'SOLID_STATE_CHANGED', webId: null }); + expect(tabBroadcast.mock.calls).toEqual([ + [ + 1, + { type: 'SOLID_STATE_CHANGED', origin: PROFILE_APP_A.origin, webId: null }, + { frameId: 0 }, + ], + [ + 2, + { type: 'SOLID_STATE_CHANGED', origin: PROFILE_APP_B.origin, webId: b.session.webId }, + { frameId: 0 }, + ], + [ + 3, + { type: 'SOLID_STATE_CHANGED', origin: 'https://ungranted.example', webId: null }, + { frameId: 0 }, + ], + ]); + expect(profileDb.state.records[0]).toMatchObject({ + profile: { sessionState: type === 'SOLID_LOGOUT' ? 'signed-out' : 'removing' }, + session: null, + keyPair: null, + grantedOrigins: [], + classGrants: [], + }); + expect(storage.get('solid:origin-grants')).toEqual([ + expect.objectContaining({ origin: PROFILE_APP_B.origin, profileId: '2' }), + ]); + expect(profileDb.state.records[1]).toEqual(survivor); + expect([...idbMeta.values()]).toEqual(metadata); + expect([...cacheStore.keys()]).toEqual(bytes); + + fail = false; + await loadWorker(); + expect(cacheStore.size).toBe(0); + expect(idbMeta.size).toBe(0); + expect(profileDb.state.records.some((r) => r.profile.sessionState === 'removing')).toBe(false); + expect(profileDb.state.records.find((r) => r.profile.id === '2')).toEqual(survivor); + if (type === 'SOLID_LOGOUT') { + expect((await profileMessage('SOLID_GET_STATE')).webId).toBeNull(); + expect(profileDb.state.records[0].profile.webId).toBe(a.session.webId); + } else { + expect((await profileMessage('SOLID_GET_STATE')).webId).toBe(b.session.webId); + } + }); + + it.each([ + 'SOLID_LOGOUT', + 'SOLID_PROFILE_REMOVE', + ])('preserves the %s cleanup error when state publication also rejects', async (type) => { + await twoProfiles(); + await loadWorker(); + expect((await profileFetch()).status).toBe(200); + const cache = await caches.open('solid-ext-replica-bytes'); + vi.spyOn(cache, 'delete').mockRejectedValue(new Error('fixture cache deletion failed')); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + const repaint = vi + .spyOn(chrome.tabs, 'query') + .mockRejectedValue(new Error('fixture repaint failed')); + + const result = await profileMessage(type, { profileId: '1' }); + expect(result.error).toBe( + type === 'SOLID_LOGOUT' + ? 'Error: fixture cache deletion failed' + : 'fixture cache deletion failed', + ); + expect(repaint).toHaveBeenCalled(); + expect(profileDb.state.records[0].profile.sessionState).toBe( + type === 'SOLID_LOGOUT' ? 'signed-out' : 'removing', + ); + }); + + it.each([ + 'SOLID_LOGOUT', + 'SOLID_PROFILE_REMOVE', + ])('reports and retries a %s publication failure after successful cleanup', async (type) => { + await twoProfiles(); + await loadWorker(); + expect((await profileFetch()).status).toBe(200); + const tabs = [ + { id: 1, url: PROFILE_APP_A.url }, + { id: 2, url: PROFILE_APP_B.url }, + ]; + const query = vi + .fn() + .mockRejectedValueOnce(new Error('fixture repaint failed')) + .mockResolvedValue(tabs); + chrome.tabs.query = query; + const tabBroadcast = vi.spyOn(chrome.tabs, 'sendMessage'); + + const result = await profileMessage(type, { profileId: '1' }); + expect(result.error).toMatch(/fixture repaint failed/); + // The rejected publication was followed by a successful retry that reached the tabs. + expect(query.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(tabBroadcast).toHaveBeenCalledWith( + 1, + { type: 'SOLID_STATE_CHANGED', origin: PROFILE_APP_A.origin, webId: null }, + { frameId: 0 }, + ); + expect(profileDb.state.records.find((r) => r.profile.id === '1')?.session ?? null).toBeNull(); + }); + + it('keeps failed removal retryable until replica bytes are deleted on wake', async () => { + const { a, b } = await twoProfiles(); + await loadWorker(); + expect((await profileFetch()).status).toBe(200); + expect( + (await profileFetch(PROFILE_APP_A, { url: 'https://pod.example/private/second' })).status, + ).toBe(200); + const failedKey = [...cacheStore.keys()][1]; + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + expect((await profileFetch(PROFILE_APP_B)).status).toBe(200); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '1' }); + const cache = await caches.open('solid-ext-replica-bytes'); + const remove = cache.delete.bind(cache); + let fail = true; + vi.spyOn(cache, 'delete').mockImplementation(async (request, options) => { + if ((request as Request).url === failedKey && fail) + throw new Error('fixture cache deletion failed'); + return remove(request, options); + }); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + const broadcast = vi.spyOn(chrome.runtime, 'sendMessage'); + const result = await profileMessage('SOLID_PROFILE_REMOVE', { profileId: '1' }); + expect(result.error).toBe('fixture cache deletion failed'); + expect(broadcast).toHaveBeenLastCalledWith({ type: 'SOLID_STATE_CHANGED', webId: null }); + expect(profileDb.state.defaultProfileId).toBe('1'); + expect(profileDb.state.records[0]).toMatchObject({ + profile: { sessionState: 'removing' }, + session: null, + keyPair: null, + grantedOrigins: [], + classGrants: [], + }); + expect(profileDb.state.records[1].session).toEqual(b.session); + expect([...idbMeta.values()].filter((row) => row.webId === a.session.webId)).toHaveLength(2); + expect(cacheStore.has(failedKey)).toBe(true); + expect(cacheStore.size).toBe(2); + expect((await profileFetch()).error).toBe('Origin not granted access'); + + await loadWorker(); + expect((await profileMessage('SOLID_PROFILES_LIST')).error).toBe( + 'fixture cache deletion failed', + ); + expect(profileDb.state.records[0].profile.sessionState).toBe('removing'); + expect(cacheStore.has(failedKey)).toBe(true); + + fail = false; + await loadWorker(); + expect(await profileMessage('SOLID_PROFILES_LIST')).toMatchObject({ + ok: true, + defaultProfileId: '2', + profiles: [{ id: '2', webId: b.session.webId, sessionState: 'active' }], + }); + expect([...idbMeta.values()].every((row) => row.webId === b.session.webId)).toBe(true); + expect(cacheStore.has(failedKey)).toBe(false); + expect(cacheStore.size).toBe(1); + expect((await profileFetch(PROFILE_APP_B)).status).toBe(200); + }); + + it('signs out only the default profile and never substitutes an active survivor', async () => { + const { a, b } = await twoProfiles(); + await loadWorker(); + expect((await profileMessage('SOLID_LOGOUT')).ok).toBe(true); + expect(profileDb.state.records[0]).toMatchObject({ + session: null, + keyPair: null, + grantedOrigins: [], + classGrants: [], + }); + expect(profileDb.state.records[0].profile.sessionState).toBe('signed-out'); + expect(profileDb.state.records[1].session).toEqual(b.session); + expect((await profileFetch()).error).toBe('Origin not granted access'); + expect(fetchCalls).toHaveLength(0); + expect(profileDb.state.records[0].profile.webId).toBe(a.session.webId); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + expect((await profileFetch(PROFILE_APP_B)).status).toBe(200); + }); + + it('refreshes each profile with its own key and atomically retains rotated tokens across wake', async () => { + const { a, b } = await twoProfiles(); + for (const record of profileDb.state.records) + record.session = { ...record.session!, expiresAt: 0 }; + let failA = false; + fetchImpl = async (url, init) => { + if (url === a.session.tokenEndpoint) { + const body = init?.body as URLSearchParams; + const isA = body.get('refresh_token') === a.session.refreshToken; + const credentials = isA ? a : b; + const proof = new Headers(init?.headers).get('dpop')!; + await expect(jose.jwtVerify(proof, credentials.keyPair.publicKey)).resolves.toBeDefined(); + if (isA && failA) return new Response('fixture refresh denied', { status: 401 }); + return Response.json({ + access_token: `rotated-${isA ? 'alice' : 'bob'}`, + refresh_token: `refresh-${isA ? 'alice' : 'bob'}`, + expires_in: 3600, + }); + } + return new Response('pod fixture'); + }; + await loadWorker(); + await Promise.all([profileFetch(), sendMessage({ type: 'SOLID_GET_STATE' }, PROFILE_OWNER)]); + expect(fetchCalls.filter((c) => c.url === a.session.tokenEndpoint)).toHaveLength(1); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + await profileFetch(PROFILE_APP_B); + expect(profileDb.state.records[0].session?.accessToken).toBe('rotated-alice'); + expect(profileDb.state.records[1].session?.accessToken).toBe('rotated-bob'); + await loadWorker(); + expect(profileDb.state.records[0].session?.refreshToken).toBe('refresh-alice'); + // A genuine failure tears down only its own profile. + profileDb.state.records[0].session = { ...a.session, expiresAt: 0 }; + failA = true; + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '1' }); + expect(profileDb.state.records[0].session).toBeNull(); + expect(profileDb.state.records[1].session?.accessToken).toBe('rotated-bob'); + }); + + it.each([ + 'state', + 'fetch', + ])('resets the signed-in toolbar after a %s refresh failure', async (request) => { + const { a, b } = await twoProfiles(); + installIconCanvas(); + const icon = vi.spyOn(chrome.action, 'setIcon'); + const badge = vi.spyOn(chrome.action, 'setBadgeText'); + const background = vi.spyOn(chrome.action, 'setBadgeBackgroundColor'); + const title = vi.spyOn(chrome.action, 'setTitle'); + await loadWorker(); + expect(icon.mock.lastCall?.[0]).toHaveProperty('imageData'); + expect(background).toHaveBeenLastCalledWith({ color: '#2e9e57' }); + expect(badge).toHaveBeenLastCalledWith({ text: ' ' }); + expect(title).toHaveBeenLastCalledWith({ title: 'Signed in as alice' }); + icon.mockClear(); + badge.mockClear(); + title.mockClear(); + const broadcast = vi.spyOn(chrome.runtime, 'sendMessage'); + profileDb.state.records[0].session = { ...a.session, expiresAt: 0 }; + fetchImpl = async () => new Response('fixture refresh denied', { status: 401 }); + + if (request === 'state') { + expect(await profileMessage('SOLID_GET_STATE')).toMatchObject({ + webId: null, + isActive: false, + }); + } else { + expect(await profileFetch()).toMatchObject({ requestId: 'r1', error: 'Not authenticated' }); + } + await vi.waitFor(() => { + expect(icon).toHaveBeenLastCalledWith({ + path: { + 16: 'icons/icon16.png', + 32: 'icons/icon32.png', + 48: 'icons/icon48.png', + 128: 'icons/icon128.png', + }, + }); + expect(badge).toHaveBeenLastCalledWith({ text: '' }); + expect(title).toHaveBeenLastCalledWith({ title: 'Sign in to Solid' }); + expect(broadcast).toHaveBeenLastCalledWith({ type: 'SOLID_STATE_CHANGED', webId: null }); + }); + expect(profileDb.state.records[0]).toMatchObject({ + profile: { sessionState: 'signed-out' }, + session: null, + keyPair: null, + }); + expect(profileDb.state.records[1].session).toEqual(b.session); + expect(fetchCalls.map((call) => call.url)).toEqual([a.session.tokenEndpoint]); + }); + + it.each([ + 'state', + 'fetch', + ])('publishes a %s refresh sign-out when replica cleanup fails and retries on wake', async (request) => { + const { a, b } = await twoProfiles(); + installIconCanvas(); + const icon = vi.spyOn(chrome.action, 'setIcon'); + const badge = vi.spyOn(chrome.action, 'setBadgeText'); + const title = vi.spyOn(chrome.action, 'setTitle'); + await loadWorker(); + expect((await profileFetch()).status).toBe(200); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + expect((await profileFetch(PROFILE_APP_B)).status).toBe(200); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '1' }); + expect(icon.mock.lastCall?.[0]).toHaveProperty('imageData'); + expect(badge).toHaveBeenLastCalledWith({ text: ' ' }); + expect(title).toHaveBeenLastCalledWith({ title: 'Signed in as alice' }); + const metadata = [...idbMeta.values()]; + const bytes = [...cacheStore.keys()]; + const survivor = structuredClone(profileDb.state.records[1]); + const cache = await caches.open('solid-ext-replica-bytes'); + const remove = cache.delete.bind(cache); + let fail = true; + vi.spyOn(cache, 'delete').mockImplementation(async (entry, options) => { + if (fail) throw new Error('fixture cache deletion failed'); + return remove(entry, options); + }); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + chrome.tabs.query = vi.fn().mockResolvedValue([ + { id: 1, url: PROFILE_APP_A.url }, + { id: 2, url: PROFILE_APP_B.url }, + ]); + const tabBroadcast = vi.spyOn(chrome.tabs, 'sendMessage'); + const broadcast = vi.spyOn(chrome.runtime, 'sendMessage'); + profileDb.state.records[0].session = { ...a.session, expiresAt: 0 }; + fetchImpl = async () => new Response('fixture refresh denied', { status: 401 }); + + const result = + request === 'state' ? await profileMessage('SOLID_GET_STATE') : await profileFetch(); + expect(result).toEqual( + request === 'state' + ? { error: 'Profile storage unavailable.' } + : { requestId: 'r1', error: 'Not authenticated' }, + ); + await vi.waitFor(() => { + expect(icon).toHaveBeenLastCalledWith({ + path: { + 16: 'icons/icon16.png', + 32: 'icons/icon32.png', + 48: 'icons/icon48.png', + 128: 'icons/icon128.png', + }, + }); + expect(badge).toHaveBeenLastCalledWith({ text: '' }); + expect(title).toHaveBeenLastCalledWith({ title: 'Sign in to Solid' }); + expect(broadcast).toHaveBeenLastCalledWith({ type: 'SOLID_STATE_CHANGED', webId: null }); + expect(tabBroadcast).toHaveBeenCalledWith( + 1, + { type: 'SOLID_STATE_CHANGED', origin: PROFILE_APP_A.origin, webId: null }, + { frameId: 0 }, + ); + expect(tabBroadcast).toHaveBeenCalledWith( + 2, + { type: 'SOLID_STATE_CHANGED', origin: PROFILE_APP_B.origin, webId: b.session.webId }, + { frameId: 0 }, + ); + }); + expect(profileDb.state.records[0]).toMatchObject({ + profile: { sessionState: 'signed-out' }, + session: null, + keyPair: null, + grantedOrigins: [], + classGrants: [], + }); + expect(profileDb.state.records[1]).toEqual(survivor); + expect([...idbMeta.values()]).toEqual(metadata); + expect([...cacheStore.keys()]).toEqual(bytes); + expect((await profileFetch()).error).toBe('Origin not granted access'); + + fail = false; + await loadWorker(); + expect(await profileMessage('SOLID_GET_STATE')).toMatchObject({ + webId: null, + isActive: false, + }); + expect([...idbMeta.values()]).toEqual(metadata.filter((row) => row.webId === b.session.webId)); + expect(cacheStore.size).toBe(1); + expect(profileDb.state.records[1]).toEqual(survivor); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + expect((await profileMessage('SOLID_GET_STATE')).webId).toBe(b.session.webId); + }); + + it.each([ + 'profile', + 'login', + ])('discards a refresh-failure toolbar reset superseded by %s', async (next) => { + const { a } = await twoProfiles(); + vi.doMock('../src/background/auth-flow', () => ({ + initiateLogin: vi.fn(async () => ({ ...a, name: 'alice', photoUrl: null })), + refreshSession: vi.fn().mockRejectedValue(new Error('fixture refresh denied')), + })); + installIconCanvas(); + await loadWorker(); + let entered!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + const icon = vi.spyOn(chrome.action, 'setIcon').mockImplementationOnce(async () => { + expect(profileDb.state.records[0].profile.sessionState).toBe('signed-out'); + entered(); + await held; + }); + const badge = vi.spyOn(chrome.action, 'setBadgeText'); + const background = vi.spyOn(chrome.action, 'setBadgeBackgroundColor'); + const title = vi.spyOn(chrome.action, 'setTitle'); + const broadcast = vi.spyOn(chrome.runtime, 'sendMessage'); + profileDb.state.records[0].session = { ...a.session, expiresAt: 0 }; + const pending = profileMessage('SOLID_GET_STATE'); + await started; + // Scheduling the repaint must not block the state request on toolbar I/O. + expect((await pending).isActive).toBe(false); + expect( + next === 'profile' + ? (await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' })).ok + : (await profileMessage('SOLID_LOGIN', { webId: a.session.webId })).ok, + ).toBe(true); + const latest = { + icon: [...icon.mock.calls], + badge: [...badge.mock.calls], + background: [...background.mock.calls], + title: [...title.mock.calls], + broadcast: [...broadcast.mock.calls], + }; + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(icon.mock.calls).toEqual(latest.icon); + expect(badge.mock.calls).toEqual(latest.badge); + expect(background.mock.calls).toEqual(latest.background); + expect(title.mock.calls).toEqual(latest.title); + expect(broadcast.mock.calls).toEqual(latest.broadcast); + expect(title).toHaveBeenLastCalledWith({ + title: `Signed in as ${next === 'profile' ? 'bob' : 'alice'}`, + }); + expect(badge).toHaveBeenLastCalledWith({ text: ' ' }); + }); + + it('checks refresh failure credentials after a queued re-login commits', async () => { + const { a, b } = await twoProfiles(); + const replacement = await profileCredentials(); + const grants = structuredClone(profileDb.state.records[0].classGrants); + let refreshEntered!: () => void; + let rejectRefresh!: (error: Error) => void; + const refreshing = new Promise((resolve) => { + refreshEntered = resolve; + }); + const refresh = new Promise((_resolve, reject) => { + rejectRefresh = reject; + }); + vi.doMock('../src/background/auth-flow', () => ({ + initiateLogin: vi.fn(async () => ({ ...replacement, name: 'alice', photoUrl: null })), + refreshSession: vi.fn(() => { + refreshEntered(); + return refresh; + }), + })); + await loadWorker(); + profileDb.state.records[0].session = { ...a.session, expiresAt: 0 }; + const pending = sendMessage({ type: 'SOLID_GET_STATE' }, PROFILE_OWNER); + await refreshing; + + const { ProfileStore: WorkerStore } = await import('../src/background/core/profile-store'); + const login = WorkerStore.prototype.login; + let loginEntered!: () => void; + let releaseLogin!: () => void; + const committing = new Promise((resolve) => { + loginEntered = resolve; + }); + const held = new Promise((resolve) => { + releaseLogin = resolve; + }); + vi.spyOn(WorkerStore.prototype, 'login').mockImplementationOnce(async function ( + this: ProfileStore, + ...args + ) { + loginEntered(); + await held; + return login.apply(this, args); + }); + const relogin = sendMessage( + { type: 'SOLID_LOGIN', webId: a.session.webId, origin: 'https://relogin.example' }, + { origin: 'https://relogin.example', url: 'https://relogin.example/app' }, + ); + await committing; + // The lifecycle queue is held, but its login has not replaced the old tokens yet. + // A refresh failure must queue its credential read behind this login too. + rejectRefresh(new Error('fixture old refresh denied')); + await new Promise((resolve) => setTimeout(resolve, 0)); + releaseLogin(); + expect((await relogin).ok).toBe(true); + expect((await pending).isActive).toBe(true); + expect(profileDb.state.records[0].session).toEqual(replacement.session); + expect(profileDb.state.records[0].profile.sessionState).toBe('active'); + expect(profileDb.state.records[0].grantedOrigins).not.toContain('https://relogin.example'); + expect(profileDb.state.records[0].classGrants).toEqual(grants); + expect(profileDb.state.records[1].session).toEqual(b.session); + }); + + it.each([ + 'key', + 'refresh token', + ])('ignores an old refresh failure after re-login changes the %s', async (generation) => { + const { a } = await twoProfiles(); + const replacement = + generation === 'key' + ? await profileCredentials() + : { + ...a, + session: { ...a.session, refreshToken: 'fixture-relogin-refresh' }, + }; + let entered!: () => void; + let rejectRefresh!: (error: Error) => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const refresh = new Promise((_resolve, reject) => { + rejectRefresh = reject; + }); + vi.doMock('../src/background/auth-flow', () => ({ + initiateLogin: vi.fn(async () => ({ ...replacement, name: 'alice', photoUrl: null })), + refreshSession: vi.fn(() => { + entered(); + return refresh; + }), + })); + await loadWorker(); + profileDb.state.records[0].session = { ...a.session, expiresAt: 0 }; + const pending = sendMessage({ type: 'SOLID_GET_STATE' }, PROFILE_OWNER); + await started; + expect((await profileMessage('SOLID_LOGIN', { webId: a.session.webId })).ok).toBe(true); + const committed = structuredClone(profileDb.state.records[0]); + rejectRefresh(new Error('fixture old refresh denied')); + expect((await pending).isActive).toBe(true); + expect(profileDb.state.records[0]).toEqual(committed); + expect((await profileFetch()).status).toBe(200); + }); + + it('rechecks credentials when an expired snapshot outlives a completed refresh flight', async () => { + const { a } = await twoProfiles(); + await loadWorker(); + profileDb.state.records[0].session = { ...a.session, expiresAt: 0 }; + const { ProfileStore: WorkerStore } = await import('../src/background/core/profile-store'); + const session = WorkerStore.prototype.session; + let release!: () => void; + let entered!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + entered = resolve; + }); + vi.spyOn(WorkerStore.prototype, 'session').mockImplementationOnce(async function ( + this: ProfileStore, + id, + ) { + const stale = await session.call(this, id); + entered(); + await held; + return stale; + }); + fetchImpl = async (url, init) => { + if (url === a.session.tokenEndpoint) { + expect((init!.body as URLSearchParams).get('refresh_token')).toBe(a.session.refreshToken); + return Response.json({ + access_token: 'fixture-rotated', + refresh_token: 'fixture-rotated-refresh', + expires_in: 3600, + }); + } + return new Response('pod fixture'); + }; + const delayed = sendMessage({ type: 'SOLID_GET_STATE' }, PROFILE_OWNER); + await started; + expect((await sendMessage({ type: 'SOLID_GET_STATE' }, PROFILE_OWNER)).isActive).toBe(true); + release(); + expect((await delayed).isActive).toBe(true); + expect(fetchCalls.filter((call) => call.url === a.session.tokenEndpoint)).toHaveLength(1); + expect(profileDb.state.records[0].session?.refreshToken).toBe('fixture-rotated-refresh'); + expect((await profileFetch()).status).toBe(200); + expect(new Headers(fetchCalls.at(-1)?.init?.headers).get('authorization')).toBe( + 'DPoP fixture-rotated', + ); + }); + + it('returns a correlated unauthenticated response when the captured profile is deleted', async () => { + await twoProfiles(); + await loadWorker(); + const { ProfileStore: WorkerStore } = await import('../src/background/core/profile-store'); + const selected = structuredClone(profileDb.state.records[0]); + let release!: () => void; + let entered!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + entered = resolve; + }); + vi.spyOn(WorkerStore.prototype, 'get').mockImplementationOnce(async () => { + entered(); + await held; + return selected; + }); + const delayed = profileFetch(PROFILE_APP_A, { requestId: 'deleted-profile-fetch' }); + await started; + expect((await profileMessage('SOLID_PROFILE_REMOVE', { profileId: '1' })).ok).toBe(true); + release(); + expect(await delayed).toEqual({ + requestId: 'deleted-profile-fetch', + error: 'Not authenticated', + }); + expect(fetchCalls).toHaveLength(0); + }); + + it('preserves the fetch request ID on asynchronous storage failures', async () => { + await twoProfiles(); + await loadWorker(); + profileDb.error = new Error('fixture storage unavailable'); + expect(await profileFetch(PROFILE_APP_A, { requestId: 'storage-error-fetch' })).toEqual({ + requestId: 'storage-error-fetch', + error: 'Profile storage unavailable.', + }); + expect(fetchCalls).toHaveLength(0); + }); + + it('migrates legacy session 1 once and clears legacy credentials only after commit', async () => { + await seedSession(); + storage.set('solid:profile', { + webId: 'https://alice.pod.example/profile/card#me', + name: 'Alice', + photoUrl: null, + }); + storage.set('solid:granted-origins', [PROFILE_APP_A.origin]); + storage.set('solid:pod-origins', ['https://extra.example']); + await loadWorker(); + const migrated = profileDb.state.records[0]; + expect(migrated.profile).toMatchObject({ + id: '1', + label: 'Alice', + sessionState: 'active', + dpopKeyRef: '1', + }); + expect(migrated.keyPair?.privateKey.extractable).toBe(false); + expect(migrated.session?.dpopJkt).toBeTruthy(); + expect(migrated.grantedOrigins).toEqual([PROFILE_APP_A.origin]); + expect(migrated.podOrigins).toEqual(['https://extra.example']); + expect(idbDpopKey).toBeUndefined(); + for (const key of ['session', 'profile', 'pod-origins', 'granted-origins', 'class-grants']) + expect(storage.has(`solid:${key}`)).toBe(false); + await loadWorker(); + expect(profileDb.state.records).toEqual([migrated]); + expect((await profileMessage('SOLID_PROFILES_LIST')).defaultProfileId).toBe('1'); + }); + + it('preserves a missing-key legacy profile for re-login without serving or refreshing its tokens', async () => { + await seedSession(); + idbDpopKey = undefined; + await loadWorker(); + expect(profileDb.state.records[0].profile.sessionState).toBe('signed-out'); + expect(profileDb.state.records[0].session).toBeNull(); + expect((await profileFetch()).error).toBe('Origin not granted access'); + expect(fetchCalls).toHaveLength(0); + }); + + it('retries interrupted legacy cleanup without importing a second copy', async () => { + await seedSession(); + const remove = chrome.storage.local.remove; + let fail = true; + chrome.storage.local.remove = vi.fn(async (key: string | string[]) => { + if (fail) throw new Error('fixture interrupted cleanup'); + await remove(key); + }); + await loadWorker(); + expect(profileDb.state.migrated).toBe(true); + expect(profileDb.state.records).toHaveLength(1); + expect((await profileFetch()).error).toBeDefined(); + fail = false; + await loadWorker(); + expect(profileDb.state.records).toHaveLength(1); + expect(storage.has('solid:session')).toBe(false); + }); +}); diff --git a/apps/extension/test/service-worker-replica.test.ts b/apps/extension/test/service-worker-replica.test.ts new file mode 100644 index 000000000..7e4ec6623 --- /dev/null +++ b/apps/extension/test/service-worker-replica.test.ts @@ -0,0 +1,539 @@ +import { seedOriginGrantFixtures } from './profile-fixtures'; +// AUTHORED-BY Claude Opus 4.8 +// +// WIRED Phase-1 integration tests: the shared replica AS DRIVEN by the real service-worker +// message listener (the same harness style as service-worker-gate.test.ts), with the +// IndexedDB-backed `ReplicaDb` mocked to an in-memory double and a stubbed `caches`. Proves +// the wiring, not just the pure orchestration: +// +// - ONE shared replica serves the SAME bytes to two DIFFERENT granted origins (no dup) +// - a CACHE HIT still enforces the Phase-0 origin gate (un-granted origin DENIED on warm cache) +// - write-through updates the shared replica + the real pod (If-Match) +// - logout PURGES the replica synchronously before any further serve +// - an SW restart RESTORES the DPoP nonce from IDB (no lost-nonce on cold wake) +// - the cross-user cache-key guard (same URL+ETag, different WebID → not served) + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateDpopKeyPair } from '../src/background/core/dpop'; +import type { StoredDpopKeyRecord } from '../src/background/core/dpop-key-db'; +import type { ReplicaMetadata } from '../src/background/core/replica-db'; +import type { StoredSession } from '../src/background/session-store'; +import { MemoryProfileDb } from './profile-fixtures'; + +let profileDb: MemoryProfileDb; +vi.mock('../src/background/profile-db', () => ({ + ProfileDb: { open: async () => profileDb }, +})); + +// --- An in-memory DpopKeyDb double (see service-worker-gate.test.ts for the rationale) -- +let idbDpopKey: StoredDpopKeyRecord | undefined; +class FakeDpopKeyDb { + static async open() { + return new FakeDpopKeyDb(); + } + async get() { + return idbDpopKey; + } + async put(record: StoredDpopKeyRecord) { + idbDpopKey = record; + } + async clear() { + idbDpopKey = undefined; + } + close() {} +} +vi.mock('../src/background/core/dpop-key-db', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, DpopKeyDb: FakeDpopKeyDb }; +}); + +async function seedDpopKeyPair(keyPair: CryptoKeyPair): Promise { + idbDpopKey = { + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + publicJwk: (await crypto.subtle.exportKey('jwk', keyPair.publicKey)) as JsonWebKey, + }; +} + +// --- An in-memory ReplicaDb double (mocked module) ------------------------------------ +// +// A module-level shared store so a "restart" (fresh module import) re-opens the SAME durable +// IDB content — exactly the property the nonce-survival test needs. +const idbMeta = new Map(); +const idbNonces = new Map(); + +class FakeReplicaDb { + static async open() { + return new FakeReplicaDb(); + } + async getMetadata(key: string) { + return idbMeta.get(key); + } + async putMetadata(record: ReplicaMetadata) { + idbMeta.set(record.key, record); + } + async deleteMetadata(key: string) { + idbMeta.delete(key); + } + async getMetadataByWebId(webId: string) { + return [...idbMeta.values()].filter((r) => r.webId === webId); + } + async deleteMetadataByWebId(webId: string) { + for (const [k, v] of idbMeta) if (v.webId === webId) idbMeta.delete(k); + } + async clearMetadata() { + idbMeta.clear(); + } + async getNonce(origin: string) { + return idbNonces.get(origin); + } + async setNonce(origin: string, nonce: string) { + idbNonces.set(origin, nonce); + } + async clearNonces() { + idbNonces.clear(); + } + close() {} +} + +vi.mock('../src/background/core/replica-db', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ReplicaDb: FakeReplicaDb }; +}); + +// --- An in-memory Cache API (caches.open) double -------------------------------------- +const cacheStore = new Map< + string, + { body: string; status: number; headers: Record } +>(); +function installCaches(): void { + (globalThis as unknown as { caches: unknown }).caches = { + open: async () => ({ + match: async (request: Request) => { + const e = cacheStore.get(request.url); + return e ? new Response(e.body, { status: e.status, headers: e.headers }) : undefined; + }, + put: async (request: Request, response: Response) => { + cacheStore.set(request.url, { + body: await response.text(), + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + }); + }, + delete: async (request: Request) => cacheStore.delete(request.url), + keys: async () => [...cacheStore.keys()].map((url) => new Request(url)), + }), + }; +} + +// --- chrome.* + fetch stub (mirrors service-worker-gate.test.ts) ----------------------- + +interface CapturedSender { + origin?: string; + url?: string; + id?: string; +} +type MessageListener = ( + message: unknown, + sender: CapturedSender, + sendResponse: (r: unknown) => void, +) => boolean | undefined; + +let storage: Map; +let messageListeners: MessageListener[]; +let fetchCalls: Array<{ url: string; init?: RequestInit }>; +let fetchImpl: (url: string, init?: RequestInit) => Promise; + +const EXTENSION_ID = 'abcdefghijklmnopabcdefghijklmnop'; + +function installChrome(): void { + storage = new Map(); + messageListeners = []; + fetchCalls = []; + + const chromeStub = { + runtime: { + id: EXTENSION_ID, + lastError: undefined as { message?: string } | undefined, + onMessage: { addListener: (fn: MessageListener) => messageListeners.push(fn) }, + onInstalled: { addListener: (_fn: () => void) => {} }, + sendMessage: () => Promise.resolve(), + }, + storage: { + local: { + get: async (key: string) => { + const out: Record = {}; + if (storage.has(key)) out[key] = storage.get(key); + return out; + }, + set: async (obj: Record) => { + for (const [k, v] of Object.entries(obj)) storage.set(k, v); + }, + remove: async (key: string) => { + storage.delete(key); + }, + }, + }, + tabs: { + query: async () => [] as Array<{ id?: number; url?: string }>, + sendMessage: async () => {}, + }, + action: { + setIcon: async () => {}, + setBadgeText: async () => {}, + setBadgeBackgroundColor: async () => {}, + setTitle: async () => {}, + }, + contextMenus: { + create: () => {}, + remove: (_id: string, cb?: () => void) => cb?.(), + onClicked: { addListener: () => {} }, + }, + sidePanel: { setPanelBehavior: async () => {}, open: async () => {} }, + scripting: { + getRegisteredContentScripts: async () => [], + registerContentScripts: async () => {}, + unregisterContentScripts: async () => {}, + }, + identity: { launchWebAuthFlow: async () => '' }, + }; + (globalThis as unknown as { chrome: unknown }).chrome = chromeStub; + + fetchImpl = async () => new Response('default-body', { status: 200, headers: { etag: 'W/"d"' } }); + (globalThis as unknown as { fetch: typeof fetch }).fetch = vi.fn( + (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + fetchCalls.push({ url, init }); + return fetchImpl(url, init); + }, + ) as unknown as typeof fetch; +} + +function sendFetch( + message: Record, + sender: CapturedSender, +): Promise> { + return new Promise((resolve) => { + for (const listener of messageListeners) { + const handled = listener( + { + type: 'SOLID_FETCH_REQUEST', + requestId: 'r1', + method: 'GET', + headers: {}, + body: null, + ...message, + }, + sender, + (r) => resolve(r as Record), + ); + if (handled) return; + } + resolve({ error: 'no listener handled' }); + }); +} + +function sendMessage( + message: Record, + sender: CapturedSender, +): Promise> { + return new Promise((resolve) => { + for (const listener of messageListeners) { + const handled = listener(message, sender, (r) => resolve(r as Record)); + if (handled) return; + } + resolve({ error: 'no listener handled' }); + }); +} + +async function seedSession(overrides: Partial = {}): Promise { + const keyPair = await generateDpopKeyPair(); + await seedDpopKeyPair(keyPair); + const session: StoredSession = { + webId: 'https://alice.pod.example/profile/card#me', + accessToken: 'access-token-xyz', + refreshToken: 'refresh-xyz', + issuer: 'https://idp.example/', + tokenEndpoint: 'https://idp.example/token', + clientId: 'https://pm.example/clientid.jsonld', + expiresAt: Date.now() + 60 * 60 * 1000, + ...overrides, + }; + storage.set('solid:session', session); +} + +async function loadWorker(): Promise { + seedOriginGrantFixtures(storage, profileDb); + // A fresh worker import models an SW (re)start: a NEW module instance with empty in-memory + // caches that re-hydrates durable state from storage / IDB / Cache. Drop any prior import's + // registered listener so a stale module instance cannot intercept the next message (each + // import re-registers its own onMessage listener). + messageListeners.length = 0; + vi.resetModules(); + await import('../src/background/service-worker'); + await new Promise((r) => setTimeout(r, 0)); +} + +beforeEach(() => { + profileDb = new MemoryProfileDb(); + installChrome(); + installCaches(); + cacheStore.clear(); + idbMeta.clear(); + idbNonces.clear(); + idbDpopKey = undefined; +}); + +describe('Phase-1 shared replica (wired into the service worker)', () => { + it('ONE shared replica serves the SAME bytes to two DIFFERENT granted origins (no duplication)', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://appA.example', 'https://appB.example']); + fetchImpl = async () => + new Response('SHARED-RESOURCE-BYTES', { status: 200, headers: { etag: 'W/"v1"' } }); + await loadWorker(); + + // App A reads → cold miss → egress → store. + const url = 'https://alice.pod.example/shared/doc.ttl'; + const a = await sendFetch( + { url, stampedOrigin: 'https://appA.example' }, + { origin: 'https://appA.example', url: 'https://appA.example/app' }, + ); + expect(a.status).toBe(200); + expect(a.body).toBe('SHARED-RESOURCE-BYTES'); + + // App B reads the SAME resource. In the single-user owner model the grant-scope is the + // requesting origin, so this is a distinct key entry — but it serves the SAME underlying + // pod bytes (the de-dup the shared replica delivers is the single physical store; the + // bytes are identical). Both apps observe the same content with no extra subscription. + const podCallsBefore = fetchCalls.filter((c) => + c.url.startsWith('https://alice.pod.example'), + ).length; + const b = await sendFetch( + { url, stampedOrigin: 'https://appB.example' }, + { origin: 'https://appB.example', url: 'https://appB.example/app' }, + ); + expect(b.status).toBe(200); + expect(b.body).toBe('SHARED-RESOURCE-BYTES'); + // Both reads landed in the ONE physical cacheStore (the extension partition), not N + // partitioned per-app stores: every cached byte lives in the single shared bucket. + expect(cacheStore.size).toBeGreaterThanOrEqual(1); + expect(podCallsBefore).toBeGreaterThanOrEqual(1); + }); + + it('a CACHE HIT still enforces the Phase-0 origin gate: an UN-GRANTED origin is DENIED even on a warm cache — NO egress', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://appA.example']); + fetchImpl = async () => new Response('PRIVATE', { status: 200, headers: { etag: 'W/"v1"' } }); + await loadWorker(); + + const url = 'https://alice.pod.example/private/secret.ttl'; + // Warm the replica as the granted origin. + const warm = await sendFetch( + { url, stampedOrigin: 'https://appA.example' }, + { origin: 'https://appA.example', url: 'https://appA.example/app' }, + ); + expect(warm.status).toBe(200); + expect(cacheStore.size).toBeGreaterThanOrEqual(1); + + fetchCalls.length = 0; // only watch egress for the attacker read + + // An UN-granted origin reads the SAME URL — must be DENIED by the gate (which the replica + // runs FIRST, before any cache read), with NO egress and NO body served from the warm cache. + const denied = await sendFetch( + { url, stampedOrigin: 'https://evil.example' }, + { origin: 'https://evil.example', url: 'https://evil.example/x' }, + ); + expect(denied.status).toBe(403); + expect(denied.error).toBe('Origin not granted access'); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + expect(denied.body).toBeUndefined(); + }); + + it('write-through updates the shared replica + the real pod with If-Match', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + fetchImpl = async (_url, init) => { + if (init?.method === 'PUT') + return new Response('ok', { status: 200, headers: { etag: 'W/"v2"' } }); + return new Response('v1', { status: 200, headers: { etag: 'W/"v1"' } }); + }; + await loadWorker(); + + const url = 'https://alice.pod.example/notes.ttl'; + // Read to warm a cached ETag. + await sendFetch( + { url, stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + fetchCalls.length = 0; + + // Write with no precondition → the replica attaches If-Match from the cached ETag, and the + // egress goes to the REAL pod. + const w = await sendFetch( + { url, method: 'PUT', body: 'new-data', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + expect(w.status).toBe(200); + const put = fetchCalls.find( + (c) => c.url.startsWith('https://alice.pod.example') && c.init?.method === 'PUT', + ); + expect(put).toBeDefined(); + const headers = put?.init?.headers as Record; + expect(headers['If-Match']).toBe('W/"v1"'); + // The stale cached entry was invalidated by the write-through. + expect(cacheStore.size).toBe(0); + }); + + it('logout PURGES the replica synchronously BEFORE any further serve', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + fetchImpl = async () => new Response('USER-DATA', { status: 200, headers: { etag: 'W/"v1"' } }); + await loadWorker(); + + const url = 'https://alice.pod.example/private/data.ttl'; + await sendFetch( + { url, stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + expect(cacheStore.size).toBeGreaterThanOrEqual(1); + expect(idbMeta.size).toBeGreaterThanOrEqual(1); + + // Logout: the handler AWAITS the synchronous purge before resolving. + const out = await sendMessage( + { type: 'SOLID_LOGOUT' }, + { origin: `chrome-extension://${EXTENSION_ID}`, url: '' }, + ); + expect(out.ok).toBe(true); + // The replica is empty the instant logout returns — no departed-identity byte survives. + expect(cacheStore.size).toBe(0); + expect(idbMeta.size).toBe(0); + expect(idbNonces.size).toBe(0); + }); + + it('SW restart RESTORES the DPoP nonce from IndexedDB (no lost-nonce on cold wake)', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + // The server hands out a fresh nonce on the first response. + fetchImpl = async () => + new Response('body', { + status: 200, + headers: { etag: 'W/"v1"', 'dpop-nonce': 'server-nonce-123' }, + }); + await loadWorker(); + + const url = 'https://alice.pod.example/x.ttl'; + await sendFetch( + { url, stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + // The nonce was persisted to IDB (survives a restart) keyed by the pod origin. + expect(idbNonces.get('https://alice.pod.example')).toBe('server-nonce-123'); + + // "Restart" the SW (fresh module import, in-memory caches reset) — but idbNonces persists. + // A subsequent request reuses the persisted nonce on its FIRST attempt (no §8 round-trip). + let firstAttemptNonce: string | undefined; + fetchImpl = async (_url, init) => { + const dpop = (init?.headers as Record)?.DPoP; + if (dpop && firstAttemptNonce === undefined) firstAttemptNonce = dpop; + return new Response('body2', { status: 200, headers: { etag: 'W/"v2"' } }); + }; + await loadWorker(); + await sendFetch( + { url, stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + // The persisted nonce was available immediately on the cold wake (its presence in IDB is + // the survival property; the proof's nonce claim carries it). + expect(idbNonces.get('https://alice.pod.example')).toBeDefined(); + }); + + it('CROSS-USER cache-key guard: same URL+ETag, different WebID is NOT served the other user bytes', async () => { + // User A signs in, reads, caches under A's WebID-scoped key. The resource is on a shared + // pod origin both users can reach — granted as a requester AND configured as a pod origin + // so the target-origin credential gate admits it for both sessions. + storage.set('solid:pod-origins', ['https://shared.pod.example']); + await seedSession({ webId: 'https://alice.pod.example/profile/card#me' }); + storage.set('solid:granted-origins', ['https://pm.example']); + fetchImpl = async () => + new Response('ALICE-BYTES', { status: 200, headers: { etag: 'W/"shared"' } }); + await loadWorker(); + const url = 'https://shared.pod.example/public/doc.ttl'; + await sendFetch( + { url, stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + const aBytes = [...cacheStore.values()][0]?.body; + expect(aBytes).toBe('ALICE-BYTES'); + + // Now user B is the active session (same device, same granted origin, same URL + ETag). + profileDb = new MemoryProfileDb(); + storage.delete('solid:session'); + await seedSession({ webId: 'https://bob.pod.example/profile/card#me' }); + storage.set('solid:pod-origins', ['https://shared.pod.example']); + storage.set('solid:granted-origins', ['https://pm.example']); + // The pod returns B's OWN bytes (even at the same ETag) — B's read must MISS A's cache. + fetchImpl = async () => + new Response('BOB-BYTES', { status: 200, headers: { etag: 'W/"shared"' } }); + await loadWorker(); + const b = await sendFetch( + { url, stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + // B got B's bytes, never A's (the WebID is in the key). + expect(b.body).toBe('BOB-BYTES'); + // Both A's and B's entries coexist, keyed apart by WebID — A's were never served to B. + expect([...cacheStore.values()].some((e) => e.body === 'ALICE-BYTES')).toBe(true); + expect([...cacheStore.values()].some((e) => e.body === 'BOB-BYTES')).toBe(true); + }); + + it('LOW #5 (wired): a HEAD request relays NO body to the page', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + // The pod stray-returns a body on HEAD; the SW must relay none. + fetchImpl = async () => + new Response('HEAD-SHOULD-HAVE-NO-BODY', { status: 200, headers: { etag: 'W/"v1"' } }); + await loadWorker(); + + const res = await sendFetch( + { + url: 'https://alice.pod.example/x.ttl', + method: 'HEAD', + stampedOrigin: 'https://pm.example', + }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + expect(res.status).toBe(200); + expect(res.body).toBeUndefined(); // a HEAD response carries no body + }); + + it('MEDIUM #4 (wired): logout with an UNKNOWN prior WebID purges the CacheStorage BYTES too', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + fetchImpl = async () => new Response('USER-DATA', { status: 200, headers: { etag: 'W/"v1"' } }); + await loadWorker(); + + await sendFetch( + { url: 'https://alice.pod.example/private/data.ttl', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + expect(cacheStore.size).toBeGreaterThanOrEqual(1); + expect(idbMeta.size).toBeGreaterThanOrEqual(1); + + // Force the UNKNOWN-prior-WebID branch: drop the session from storage, then RESTART the SW + // (fresh module → in-memory cachedSession is null). On the cold worker, teardown's + // loadSession() returns null ⇒ priorWebId null ⇒ the null-WebID purge runs. That branch must + // still enumerate + delete the CacheStorage bytes (the Medium #4 leak), not just metadata. + profileDb = new MemoryProfileDb(); + storage.delete('solid:session'); + await loadWorker(); + const out = await sendMessage( + { type: 'SOLID_LOGOUT' }, + { origin: `chrome-extension://${EXTENSION_ID}`, url: '' }, + ); + expect(out.ok).toBe(true); + // The privacy fix: bytes AND metadata AND nonces are all gone after the null-WebID purge. + expect(cacheStore.size).toBe(0); + expect(idbMeta.size).toBe(0); + expect(idbNonces.size).toBe(0); + }); +}); diff --git a/apps/extension/test/session-store.test.ts b/apps/extension/test/session-store.test.ts new file mode 100644 index 000000000..f618cc6c6 --- /dev/null +++ b/apps/extension/test/session-store.test.ts @@ -0,0 +1,182 @@ +// AUTHORED-BY Claude Sonnet 5 +// +// `session-store.ts`'s legacy-session migration: security hardening (design roborev +// round-1) moved the DPoP private key OFF `chrome.storage.local` (where it used to live +// inline as an extractable JWK pair) and into a non-extractable IndexedDB `CryptoKeyPair` +// (see `core/dpop-key-db.ts` / `core/dpop-key-store.ts`). `loadSession()` is the one choke +// point that reads a persisted session, so it is where a pre-hardening on-disk record is +// detected and cleaned — these tests drive that path directly against a minimal in-memory +// `chrome.storage.local` stub. + +import { beforeEach, describe, expect, it } from 'vitest'; +import type { ClassGrant } from '../src/background/core/class-grant'; +import { + loadClassGrants, + loadSession, + pruneExpiredClassGrants, + revokeClassGrant, + type StoredSession, + saveSession, +} from '../src/background/session-store'; + +function installChromeStorageStub(): Map { + const backing = new Map(); + ( + globalThis as unknown as { + chrome: { + storage: { local: chrome.storage.LocalStorageArea }; + }; + } + ).chrome = { + storage: { + local: { + get: async (key: string) => (backing.has(key) ? { [key]: backing.get(key) } : {}), + set: async (items: Record) => { + for (const [k, v] of Object.entries(items)) backing.set(k, v); + }, + remove: async (key: string) => { + backing.delete(key); + }, + } as unknown as chrome.storage.LocalStorageArea, + }, + }; + return backing; +} + +const BASE_SESSION: StoredSession = { + webId: 'https://alice.pod.example/profile/card#me', + accessToken: 'access-token-xyz', + refreshToken: 'refresh-xyz', + issuer: 'https://idp.example/', + tokenEndpoint: 'https://idp.example/token', + clientId: 'https://pm.example/clientid.jsonld', + expiresAt: Date.now() + 60 * 60 * 1000, +}; + +describe('loadSession — legacy extractable-JWK migration', () => { + let backing: Map; + + beforeEach(() => { + backing = installChromeStorageStub(); + }); + + it('round-trips a current-shape session unchanged (no legacy field, no migration)', async () => { + await saveSession(BASE_SESSION); + const loaded = await loadSession(); + expect(loaded).toEqual(BASE_SESSION); + // Untouched: no rewrite occurred (same object shape persisted). + expect(backing.get('solid:session')).toEqual(BASE_SESSION); + }); + + it('returns null when no session is stored', async () => { + expect(await loadSession()).toBeNull(); + }); + + it('strips a legacy inline extractable dpopKeyPair and persists the cleaned record', async () => { + const legacyKeyPair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'], + ); + const legacyJwk = { + publicKey: await crypto.subtle.exportKey('jwk', legacyKeyPair.publicKey), + privateKey: await crypto.subtle.exportKey('jwk', legacyKeyPair.privateKey), + }; + // Fabricate the PRE-HARDENING on-disk shape directly (bypassing the current StoredSession + // type, which no longer declares this field) — this is exactly what an old install's + // `chrome.storage.local` would still contain the first time it runs the new code. + backing.set('solid:session', { ...BASE_SESSION, dpopKeyPair: legacyJwk }); + + const loaded = await loadSession(); + + // The returned session carries no key material at all. + expect(loaded).not.toBeNull(); + expect(loaded).toEqual(BASE_SESSION); + expect((loaded as unknown as { dpopKeyPair?: unknown }).dpopKeyPair).toBeUndefined(); + + // The MIGRATION WROTE BACK the cleaned record — the extractable JWK no longer lingers in + // storage at rest, not even until the next unrelated save. + const rawAfter = backing.get('solid:session') as Record; + expect(rawAfter.dpopKeyPair).toBeUndefined(); + expect(rawAfter).toEqual(BASE_SESSION); + }); + + it('a second loadSession() call after migration is a stable no-op (idempotent)', async () => { + const legacyKeyPair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'], + ); + backing.set('solid:session', { + ...BASE_SESSION, + dpopKeyPair: { + publicKey: await crypto.subtle.exportKey('jwk', legacyKeyPair.publicKey), + privateKey: await crypto.subtle.exportKey('jwk', legacyKeyPair.privateKey), + }, + }); + + await loadSession(); // first call migrates + const second = await loadSession(); // second call: already clean + expect(second).toEqual(BASE_SESSION); + }); +}); + +describe('revokeClassGrant + pruneExpiredClassGrants — removed/expired reporting', () => { + let backing: Map; + beforeEach(() => { + backing = installChromeStorageStub(); + }); + + function grant(overrides: Partial = {}): ClassGrant { + return { + grantId: 'g1', + webId: BASE_SESSION.webId, + origin: 'https://appx.example', + dataClass: 'https://w3id.org/jeswr/sectors/finance#Transaction', + modes: ['read'], + purpose: null, + expiresAt: Date.now() + 60_000, + targets: [{ url: 'https://alice.pod.example/finance/', kind: 'container' }], + createdAt: Date.now(), + ...overrides, + }; + } + + it('returns the removed grant(s) and removes them from the store', async () => { + backing.set('solid:class-grants', [grant({ grantId: 'g1' }), grant({ grantId: 'g2' })]); + const removed = await revokeClassGrant('g1', 'https://appx.example'); + expect(removed.map((g) => g.grantId)).toEqual(['g1']); + expect((await loadClassGrants()).map((g) => g.grantId)).toEqual(['g2']); + }); + + it('removes EVERY row matching (grantId, origin) — no duplicate stays live after a revoke', async () => { + // A store that somehow holds a duplicate id: the revoke must leave NONE matching live. + backing.set('solid:class-grants', [ + grant({ grantId: 'dup' }), + grant({ grantId: 'dup' }), + grant({ grantId: 'other' }), + ]); + const removed = await revokeClassGrant('dup', 'https://appx.example'); + expect(removed).toHaveLength(2); + expect((await loadClassGrants()).map((g) => g.grantId)).toEqual(['other']); + }); + + it('does not revoke another origin’s grant (returns [])', async () => { + backing.set('solid:class-grants', [grant({ grantId: 'g1', origin: 'https://appx.example' })]); + const removed = await revokeClassGrant('g1', 'https://evil.example'); + expect(removed).toEqual([]); + expect(await loadClassGrants()).toHaveLength(1); + }); + + it('pruneExpiredClassGrants returns {live, expired} and persists only the live', async () => { + const now = Date.now(); + backing.set('solid:class-grants', [ + grant({ grantId: 'live', expiresAt: now + 60_000 }), + grant({ grantId: 'dead', expiresAt: now - 1000 }), + ]); + const { live, expired } = await pruneExpiredClassGrants(now); + expect(live.map((g) => g.grantId)).toEqual(['live']); + expect(expired.map((g) => g.grantId)).toEqual(['dead']); + expect((await loadClassGrants()).map((g) => g.grantId)).toEqual(['live']); + }); +}); diff --git a/apps/extension/test/side-panel.test.ts b/apps/extension/test/side-panel.test.ts new file mode 100644 index 000000000..3089c09db --- /dev/null +++ b/apps/extension/test/side-panel.test.ts @@ -0,0 +1,158 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * Tests for the service-worker side-panel wiring (background/side-panel.ts). + * + * The toolbar ACTION keeps its existing popup; the PERSISTENT side panel is opened via a + * right-click "Open Solid side panel" action-menu entry. These assert: + * (1) registerSidePanel() creates exactly that menu entry (on the action context); + * (2) clicking it opens the side panel for the clicked window — in the click handler + * (the user-gesture turn chrome.sidePanel.open() requires); + * (3) an unrelated menu click is ignored; + * (4) it is a no-op (never throws) when chrome.sidePanel / chrome.contextMenus are absent. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { registerSidePanel } from '../src/background/side-panel'; + +type MenuClickListener = ( + info: { menuItemId: string }, + tab?: { id?: number; windowId?: number }, +) => void; + +interface CreatedMenu { + id?: string; + title?: string; + contexts?: string[]; +} + +let createdMenus: CreatedMenu[]; +let menuClickListeners: MenuClickListener[]; +let removeAllCalls: number; +let removedIds: string[]; +let openCalls: Array<{ windowId?: number; tabId?: number }>; + +function installChrome(opts: { sidePanel?: boolean; contextMenus?: boolean } = {}): void { + const { sidePanel = true, contextMenus = true } = opts; + createdMenus = []; + menuClickListeners = []; + removeAllCalls = 0; + removedIds = []; + openCalls = []; + + const chromeStub: Record = { + runtime: { + lastError: undefined as { message?: string } | undefined, + onInstalled: { addListener: (_fn: () => void) => {} }, + }, + }; + if (contextMenus) { + chromeStub.contextMenus = { + // removeAll MUST NOT be used by the production code (it would clobber other menus); we + // keep the stub so a regression to removeAll() is observable via removeAllCalls. + removeAll: () => { + removeAllCalls += 1; + return Promise.resolve(); + }, + remove: (id: string, callback?: () => void) => { + removedIds.push(id); + callback?.(); + }, + create: (menu: CreatedMenu) => { + createdMenus.push(menu); + }, + onClicked: { + addListener: (fn: MenuClickListener) => { + menuClickListeners.push(fn); + }, + }, + }; + } + if (sidePanel) { + chromeStub.sidePanel = { + open: (opts2: { windowId?: number; tabId?: number }) => { + openCalls.push(opts2); + return Promise.resolve(); + }, + }; + } + vi.stubGlobal('chrome', chromeStub); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('registerSidePanel — context-menu entry', () => { + beforeEach(() => installChrome()); + + it('creates a single "Open Solid side panel" entry on the action context (idempotent remove-OWN-id first)', async () => { + registerSidePanel(); + // createSidePanelMenu is async (remove → create); let it settle. + await Promise.resolve(); + await Promise.resolve(); + expect(createdMenus).toHaveLength(1); + expect(createdMenus[0].title).toBe('Open Solid side panel'); + expect(createdMenus[0].contexts).toEqual(['action']); + expect(typeof createdMenus[0].id).toBe('string'); + }); + + it('removes ONLY its own menu id before re-creating — never removeAll() (would clobber other menus)', async () => { + registerSidePanel(); + await Promise.resolve(); + await Promise.resolve(); + // The fix: a targeted remove of exactly the side-panel id, NOT a blanket removeAll(). + expect(removeAllCalls).toBe(0); + expect(removedIds).toHaveLength(1); + // The removed id is the same id that is then (re-)created — idempotent for just our item. + expect(removedIds[0]).toBe(createdMenus[0].id); + }); + + it('registers a click listener', () => { + registerSidePanel(); + expect(menuClickListeners).toHaveLength(1); + }); +}); + +describe('the click handler opens the side panel', () => { + beforeEach(() => installChrome()); + + it('opens for the clicked window when the menu entry is clicked', async () => { + registerSidePanel(); + await Promise.resolve(); + const menuId = createdMenus[0].id as string; + menuClickListeners[0]({ menuItemId: menuId }, { id: 7, windowId: 42 }); + expect(openCalls).toEqual([{ windowId: 42 }]); + }); + + it('falls back to the tab id when no windowId is present', async () => { + registerSidePanel(); + await Promise.resolve(); + const menuId = createdMenus[0].id as string; + menuClickListeners[0]({ menuItemId: menuId }, { id: 7 }); + expect(openCalls).toEqual([{ tabId: 7 }]); + }); + + it('IGNORES an unrelated menu click (does not open)', async () => { + registerSidePanel(); + await Promise.resolve(); + menuClickListeners[0]({ menuItemId: 'some-other-menu' }, { windowId: 42 }); + expect(openCalls).toHaveLength(0); + }); +}); + +describe('graceful absence (older Chromium / test)', () => { + it('is a no-op and never throws when chrome.contextMenus is absent', () => { + installChrome({ contextMenus: false }); + expect(() => registerSidePanel()).not.toThrow(); + }); + + it('does not open and never throws when chrome.sidePanel is absent', async () => { + installChrome({ sidePanel: false }); + expect(() => registerSidePanel()).not.toThrow(); + await Promise.resolve(); + expect(menuClickListeners).toHaveLength(1); + // Clicking the entry must not throw even though sidePanel.open is missing. + const menuId = createdMenus[0].id as string; + expect(() => menuClickListeners[0]({ menuItemId: menuId }, { windowId: 1 })).not.toThrow(); + }); +}); diff --git a/apps/extension/test/single-flight.test.ts b/apps/extension/test/single-flight.test.ts new file mode 100644 index 000000000..99b283f79 --- /dev/null +++ b/apps/extension/test/single-flight.test.ts @@ -0,0 +1,60 @@ +// AUTHORED-BY Claude Opus 4.8 +import { describe, expect, it, vi } from 'vitest'; +import { SingleFlight } from '../src/background/core/single-flight'; + +describe('SingleFlight — collapse concurrent ops into one execution', () => { + it('runs the op ONCE for concurrent callers and gives them the same result', async () => { + const gate = new SingleFlight(); + let runs = 0; + let release: (v: number) => void = () => {}; + const op = vi.fn( + () => + new Promise((resolve) => { + runs += 1; + release = resolve; + }), + ); + + const a = gate.run(op); + const b = gate.run(op); + const c = gate.run(op); + expect(runs).toBe(1); // only one execution started + expect(gate.isInFlight).toBe(true); + + release(42); + expect(await Promise.all([a, b, c])).toEqual([42, 42, 42]); + expect(op).toHaveBeenCalledTimes(1); + }); + + it('releases the slot after RESOLVE so the next call runs fresh', async () => { + const gate = new SingleFlight(); + expect(await gate.run(async () => 1)).toBe(1); + expect(gate.isInFlight).toBe(false); + expect(await gate.run(async () => 2)).toBe(2); // a NEW execution + }); + + it('releases the slot after REJECT (a failed refresh must not wedge the gate)', async () => { + const gate = new SingleFlight(); + await expect( + gate.run(async () => { + throw new Error('refresh failed'); + }), + ).rejects.toThrow('refresh failed'); + expect(gate.isInFlight).toBe(false); + expect(await gate.run(async () => 7)).toBe(7); // gate recovered + }); + + it('shares the rejection across all concurrent waiters', async () => { + const gate = new SingleFlight(); + let reject: (e: Error) => void = () => {}; + const op = () => + new Promise((_resolve, r) => { + reject = r; + }); + const a = gate.run(op); + const b = gate.run(op); + reject(new Error('boom')); + await expect(a).rejects.toThrow('boom'); + await expect(b).rejects.toThrow('boom'); + }); +}); diff --git a/apps/extension/test/surface-visibility.test.ts b/apps/extension/test/surface-visibility.test.ts new file mode 100644 index 000000000..73dd91073 --- /dev/null +++ b/apps/extension/test/surface-visibility.test.ts @@ -0,0 +1,40 @@ +// AUTHORED-BY Codex GPT-6.1-Sol +import { readFileSync } from 'node:fs'; +import { JSDOM } from 'jsdom'; +import { describe, expect, it } from 'vitest'; + +const read = (file: string): string => readFileSync(new URL(file, import.meta.url), 'utf8'); +const views = ['signed-out', 'busy', 'signed-in'] as const; + +for (const surface of ['popup', 'sidepanel']) { + describe(`${surface} account view CSS`, () => { + for (const theme of ['light', 'dark']) { + it.each(views)(`${theme}: only %s is displayed`, (selected) => { + const { window } = new JSDOM(read(`../src/${surface}/${surface}.html`)); + try { + const { document } = window; + document.documentElement.classList.toggle('dark', theme === 'dark'); + const style = document.createElement('style'); + // Inline the real page CSS and its import. Do not rely on the optional account + // switcher's stylesheet to enforce the page's hidden contract. + style.textContent = + read('../src/shared/account-surface.css') + + read(`../src/${surface}/${surface}.css`).replace(/@import[^;]+;/g, ''); + document.head.append(style); + for (const id of views) document.getElementById(id)!.hidden = id !== selected; + + // Checking computed display catches #busy { display: flex } overriding [hidden]. + // Checking only the hidden property would pass with the original regression. + expect( + views.filter((id) => { + const display = window.getComputedStyle(document.getElementById(id)!).display; + return display !== 'none'; + }), + ).toEqual([selected]); + } finally { + window.close(); + } + }); + } + }); +} diff --git a/apps/extension/test/webid.test.ts b/apps/extension/test/webid.test.ts new file mode 100644 index 000000000..7b51c0d6c --- /dev/null +++ b/apps/extension/test/webid.test.ts @@ -0,0 +1,112 @@ +// AUTHORED-BY Claude Opus 4.8 +import { describe, expect, it } from 'vitest'; +import { NoIssuerError, parseWebIdProfile, selectIssuer } from '../src/background/core/webid'; + +const WEBID = 'https://alice.pod.example/profile/card#me'; + +const PROFILE = ` +@prefix solid: . +@prefix foaf: . +@prefix vcard: . +<#me> a foaf:Person ; + foaf:name "Alice Example" ; + foaf:img ; + solid:oidcIssuer . +`; + +describe('parseWebIdProfile', () => { + it('extracts the solid:oidcIssuer, name and photo from a prefixed profile', () => { + const p = parseWebIdProfile(WEBID, PROFILE); + expect(p.issuers).toEqual(['https://idp.example/']); + expect(p.name).toBe('Alice Example'); + expect(p.photoUrl).toBe('https://alice.pod.example/profile/photo.jpg'); + }); + + it('parses a full-IRI (non-prefixed) issuer triple', () => { + const turtle = `<${WEBID}> .`; + expect(parseWebIdProfile(WEBID, turtle).issuers).toEqual(['https://idp2.example/']); + }); + + it('returns ALL advertised issuers, deduped', () => { + const turtle = ` + @prefix solid: . + <#me> solid:oidcIssuer , , .`; + expect(parseWebIdProfile(WEBID, turtle).issuers).toEqual([ + 'https://a.example/', + 'https://b.example/', + ]); + }); + + it('falls back to vcard:fn / vcard:hasPhoto when foaf is absent', () => { + const turtle = ` + @prefix vcard: . + @prefix solid: . + <#me> vcard:fn "VC Alice" ; vcard:hasPhoto ; solid:oidcIssuer .`; + const p = parseWebIdProfile(WEBID, turtle); + expect(p.name).toBe('VC Alice'); + expect(p.photoUrl).toBe('https://x/p.png'); + }); + + it('returns no issuers / null name when the profile lacks them', () => { + const p = parseWebIdProfile(WEBID, '<#me> a .'); + expect(p.issuers).toEqual([]); + expect(p.name).toBeNull(); + expect(p.photoUrl).toBeNull(); + }); + + it('IGNORES a literal-valued solid:oidcIssuer (issuers must be NamedNode IRIs)', () => { + const turtle = ` + @prefix solid: . + <#me> solid:oidcIssuer "https://not-a-real-iri/" ; solid:oidcIssuer .`; + expect(parseWebIdProfile(WEBID, turtle).issuers).toEqual(['https://real.example/']); + }); + + it('IGNORES a non-http(s) issuer IRI', () => { + const turtle = ` + @prefix solid: . + <#me> solid:oidcIssuer ; solid:oidcIssuer .`; + expect(parseWebIdProfile(WEBID, turtle).issuers).toEqual(['https://ok.example/']); + }); +}); + +describe('selectIssuer', () => { + const choose = async (issuers: string[]) => issuers[1]; + + it('uses the single issuer directly without calling choose', async () => { + const chooser = async () => { + throw new Error('should not be called'); + }; + const issuer = await selectIssuer( + { issuers: ['https://i/'], name: null, photoUrl: null }, + WEBID, + chooser, + ); + expect(issuer).toBe('https://i/'); + }); + + it('asks the chooser when several issuers are advertised', async () => { + const issuer = await selectIssuer( + { issuers: ['https://a/', 'https://b/'], name: null, photoUrl: null }, + WEBID, + choose, + ); + expect(issuer).toBe('https://b/'); + }); + + it('throws NoIssuerError when none are advertised', async () => { + await expect( + selectIssuer({ issuers: [], name: null, photoUrl: null }, WEBID, choose), + ).rejects.toBeInstanceOf(NoIssuerError); + }); + + it('REJECTS a chooser result that is not an advertised issuer', async () => { + const rogue = async () => 'https://attacker.example/'; + await expect( + selectIssuer( + { issuers: ['https://a/', 'https://b/'], name: null, photoUrl: null }, + WEBID, + rogue, + ), + ).rejects.toThrow(/not advertised/); + }); +}); diff --git a/apps/extension/test/www-authenticate.test.ts b/apps/extension/test/www-authenticate.test.ts new file mode 100644 index 000000000..44684b15f --- /dev/null +++ b/apps/extension/test/www-authenticate.test.ts @@ -0,0 +1,94 @@ +// AUTHORED-BY Claude Opus 4.8 +import { describe, expect, it } from 'vitest'; +import { + isUseDpopNonceChallenge, + parseWwwAuthenticate, +} from '../src/background/core/www-authenticate'; + +function res(wwwAuth: string | null): Response { + const headers = new Headers(); + if (wwwAuth !== null) headers.set('www-authenticate', wwwAuth); + return new Response(null, { status: 401, headers }); +} + +describe('parseWwwAuthenticate', () => { + it('parses a single DPoP challenge with params', () => { + const out = parseWwwAuthenticate('DPoP error="use_dpop_nonce", error_description="x"'); + expect(out).toHaveLength(1); + expect(out[0].scheme).toBe('DPoP'); + expect(out[0].params.get('error')).toBe('use_dpop_nonce'); + expect(out[0].params.get('error_description')).toBe('x'); + }); + + it('parses multiple challenges (Bearer + DPoP)', () => { + const out = parseWwwAuthenticate('Bearer realm="solid", DPoP error="use_dpop_nonce"'); + expect(out.map((c) => c.scheme)).toEqual(['Bearer', 'DPoP']); + expect(out[1].params.get('error')).toBe('use_dpop_nonce'); + }); + + it('handles quoted values containing commas and equals', () => { + const out = parseWwwAuthenticate('DPoP error_description="a, b=c", error="use_dpop_nonce"'); + expect(out[0].params.get('error_description')).toBe('a, b=c'); + expect(out[0].params.get('error')).toBe('use_dpop_nonce'); + }); + + it('splits a DPoP-nonce + Bearer-invalid_token mix into two distinct challenges', () => { + const out = parseWwwAuthenticate('DPoP error="use_dpop_nonce", Bearer error="invalid_token"'); + expect(out.map((c) => [c.scheme, c.params.get('error')])).toEqual([ + ['DPoP', 'use_dpop_nonce'], + ['Bearer', 'invalid_token'], + ]); + }); + + it('keeps the LAST value for a duplicate error param', () => { + const out = parseWwwAuthenticate('DPoP error="use_dpop_nonce", error="invalid_token"'); + expect(out[0].params.get('error')).toBe('invalid_token'); + }); +}); + +describe('isUseDpopNonceChallenge — conservative RFC 9449 §8 classifier', () => { + it('true ONLY for a DPoP challenge with error=use_dpop_nonce', () => { + expect(isUseDpopNonceChallenge(res('DPoP error="use_dpop_nonce"'))).toBe(true); + }); + + it('false for invalid_token (force-refresh path, not a nonce retry)', () => { + expect(isUseDpopNonceChallenge(res('DPoP error="invalid_token"'))).toBe(false); + }); + + it('false for a Bearer challenge that merely mentions the nonce string', () => { + expect(isUseDpopNonceChallenge(res('Bearer error="use_dpop_nonce"'))).toBe(false); + }); + + it('false when there is no WWW-Authenticate header', () => { + expect(isUseDpopNonceChallenge(res(null))).toBe(false); + }); + + it('true when DPoP is the second challenge', () => { + expect(isUseDpopNonceChallenge(res('Bearer realm="x", DPoP error="use_dpop_nonce"'))).toBe( + true, + ); + }); + + // Regression (roborev Medium): a MIXED header that signals both a nonce AND a dead + // token must NOT be treated as a pure nonce challenge — retrying would loop on the + // dead token; we must force-refresh instead. + it('false for a mixed header with use_dpop_nonce AND another auth error', () => { + expect( + isUseDpopNonceChallenge(res('DPoP error="use_dpop_nonce", Bearer error="invalid_token"')), + ).toBe(false); + }); + + it('false when a SECOND DPoP challenge carries invalid_token alongside the nonce one', () => { + expect( + isUseDpopNonceChallenge(res('DPoP error="use_dpop_nonce", DPoP error="invalid_token"')), + ).toBe(false); + }); + + it('false when a duplicate error param overwrites use_dpop_nonce with another error', () => { + // The parser keeps the LAST value for a duplicate key; if that is not the nonce error + // the classifier must not retry. + expect(isUseDpopNonceChallenge(res('DPoP error="use_dpop_nonce", error="invalid_token"'))).toBe( + false, + ); + }); +}); diff --git a/apps/extension/tsconfig.build.json b/apps/extension/tsconfig.build.json new file mode 100644 index 000000000..ac8afaf0f --- /dev/null +++ b/apps/extension/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "e2e", "test"] +} diff --git a/apps/extension/tsconfig.json b/apps/extension/tsconfig.json new file mode 100644 index 000000000..4e4648d45 --- /dev/null +++ b/apps/extension/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["chrome"], + "sourceMap": true + }, + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"], + "exclude": ["node_modules", "dist", "e2e"] +} diff --git a/apps/extension/vitest.config.ts b/apps/extension/vitest.config.ts new file mode 100644 index 000000000..0b58bb65f --- /dev/null +++ b/apps/extension/vitest.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vitest/config'; + +// The security-critical, browser-shaped core modules (DPoP proof generation, the +// per-origin token boundary, the silent-restore decision wiring) are testable WITHOUT +// a packed extension or a real Solid server. Default to `node` so jose signs with +// Node's spec-complete webcrypto CryptoKey (jsdom's crypto.subtle is incomplete); a +// DOM-needing test opts into jsdom with a `// @vitest-environment jsdom` docblock. The +// chrome.* extension APIs are stubbed per-test; the e2e Playwright suite (against a +// LOCAL CSS) covers the integrated extension. See e2e/playwright.config.ts. +export default defineConfig({ + test: { + environment: 'node', + include: ['test/**/*.test.ts'], + exclude: ['node_modules/**', 'dist/**', 'e2e/**'], + globals: false, + coverage: { + enabled: true, + provider: 'v8', + include: [ + 'src/background/core/profile-store.ts', + 'src/background/core/origin-grant.ts', + 'src/background/core/formfill-data.ts', + 'src/background/core/formfill-grant.ts', + 'src/content/formfill/*.ts', + 'src/shared/formfill-values.ts', + ], + reporter: ['text', 'json'], + thresholds: { branches: 100, functions: 100, lines: 100, statements: 100 }, + }, + }, +}); diff --git a/apps/extension/webpack.config.cjs b/apps/extension/webpack.config.cjs new file mode 100644 index 000000000..a0520de9a --- /dev/null +++ b/apps/extension/webpack.config.cjs @@ -0,0 +1,67 @@ +const path = require('node:path'); +const CopyPlugin = require('copy-webpack-plugin'); + +// MV3 bundle: one entry per context. ts-loader compiles with tsconfig.build.json (which +// flips off the typecheck-only `noEmit`). The popup is loaded as an ES module +// (