experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(permissions): per-permission policy — Phase 1 (visibility + default-deny for unknown)

Centralizes Chromium permission request handling on the profile session
in a new `backend/electron/permission-handler.ts` module. Replaces the
blanket-allow that `chrome-extensions.ts` had installed on the *entire*
profile session — that handler was intended for chrome extensions but
also auto-allowed every webview guest's geolocation, media, notifications,
midi, etc., requests with no user awareness or audit trail.

Phase 1 (this commit) is visibility-only — no UI prompt yet — but it:

* Logs every permission request to stderr with `origin → permission →
decision` so we can see what real-world sites are asking for before
designing the prompt UI.
* Splits policy by origin class:
chrome-extension:// → allow (preserves prior behavior; extensions
declare permissions in manifest.json)
peek:// → allow (we control these pages)
everything else → consult DEFAULT_POLICY map per permission
* Defaults DEFAULT_POLICY entries to 'allow' for the permissions the
blanket handler used to allow (`geolocation`, `media`, `notifications`,
`midi`, `clipboard-*`, `display-capture`, `pointerLock`, `fullscreen`,
`openExternal`) so no live site regresses on rollout.
* Fail-closed for permissions with no policy entry (`hid`, `serial`,
`usb`, `idle-detection`, the various background/sync APIs, etc.) —
these previously got the blanket allow; now they're denied unless we
deliberately add an entry. Defensible default for permissions we
haven't audited.

The module is shaped for Phase 2: a `pendingRequests` map is already
wired in for the deferred-callback flow, and `'prompt'` is a valid
`Decision` that the request handler will route through the (not-yet-
written) IPC bridge once the UI lands. If a future policy entry is set
to `'prompt'` before the UI ships, the handler logs a warning and
denies — never leaves a page silently waiting forever.

`installPermissionHandler(profileSession)` is invoked exactly once from
`session-partition.ts` when the profile session is first created. The
chrome-extensions.ts code path no longer installs its own handler;
removed alongside a comment explaining where the handler now lives.

Tests:
* `tests/unit/permission-handler.test.js` (10/10) exercises the pure
`_resolveDecisionForTests(url, permission)` resolver: chrome-extension
URLs always allow; peek:// always allow; web origins allow the
Phase-1 default set + deny unknown/risky; empty origin documents the
current allow-by-default behavior so when Phase 2 flips geolocation
to 'prompt' the assertion changes too.
* unit total: 638/638 (was 628 + 10 new).

`Session` is now a `import type` — the test imports `dist/.../permission-
handler.js` under ELECTRON_RUN_AS_NODE without dragging in `electron`.

Tasks doc updated with Phase 2/3/4 follow-on work spelled out.

+257 -6
+5 -5
backend/electron/chrome-extensions.ts
··· 334 334 if (!polyfillHandle) { 335 335 polyfillHandle = registerAll(profileSession, { ipcMain, app }); 336 336 337 - // Grant all permissions to extensions (belt-and-suspenders for web permissions) 338 - profileSession.setPermissionCheckHandler(() => true); 339 - profileSession.setPermissionRequestHandler((_webContents, _permission, callback) => { 340 - callback(true); 341 - }); 337 + // Web permission handling now lives in permission-handler.ts (installed 338 + // by session-partition.ts when the profile session is created). The 339 + // canonical handler grants all permissions to chrome-extension:// origins 340 + // — same effect as the blanket-allow that used to live here, but without 341 + // also auto-allowing every webview guest's permission requests. 342 342 343 343 // Inject polyfill preload scripts into extension webContents 344 344 app.on('web-contents-created', (_event, webContents) => {
+163
backend/electron/permission-handler.ts
··· 1 + /** 2 + * Web Permission Request Handler 3 + * 4 + * Centralizes Chromium permission request handling for the profile session. 5 + * Replaces the previous blanket-allow installed by chrome-extensions.ts so we 6 + * can apply per-permission policy and (in a future phase) surface a Peek-native 7 + * approve/reject prompt to the user. 8 + * 9 + * Today's policy (Phase 1 — visibility only): 10 + * - chrome-extension:// origins → allow (preserves prior behavior; extensions 11 + * declare their permissions in manifest.json and Electron honors them). 12 + * - All other origins → consult DEFAULT_POLICY map per permission name. 13 + * Currently every permission defaults to allow so no real-world site 14 + * regresses on this rollout. Each request is logged to stderr with origin 15 + * + permission so we can see the live traffic before adding the prompt UI. 16 + * 17 + * Phase 2 will: (a) flip risky permissions (geolocation, media, midi, 18 + * screen-share) to "prompt" — store the callback in `pendingRequests`, publish 19 + * `page:permission-request` to the page-host renderer, and resolve when the 20 + * user responds via IPC; (b) persist per-origin decisions in feature_settings. 21 + * 22 + * See docs/tasks.md "Web permission requests" for the full plan. 23 + */ 24 + 25 + import type { Session } from 'electron'; 26 + 27 + const DEBUG = !!process.env.DEBUG; 28 + 29 + /** Permissions that Electron may request via setPermissionRequestHandler. */ 30 + type PermissionName = 31 + | 'media' 32 + | 'geolocation' 33 + | 'notifications' 34 + | 'midi' 35 + | 'midiSysex' 36 + | 'pointerLock' 37 + | 'fullscreen' 38 + | 'openExternal' 39 + | 'window-management' 40 + | 'unknown' 41 + | 'clipboard-read' 42 + | 'clipboard-sanitized-write' 43 + | 'display-capture' 44 + | 'hid' 45 + | 'usb' 46 + | 'serial' 47 + | 'storage-access' 48 + | 'top-level-storage-access' 49 + | 'idle-detection' 50 + | 'window-placement' 51 + | 'speaker-selection' 52 + | 'background-fetch' 53 + | 'background-sync' 54 + | 'periodic-background-sync' 55 + | 'screen-wake-lock'; 56 + 57 + type Decision = 'allow' | 'deny' | 'prompt'; 58 + 59 + /** 60 + * Per-permission default policy applied to non-extension origins. 61 + * 62 + * 'prompt' is a placeholder for Phase 2 — today every entry resolves to 63 + * 'allow' via the resolveDecision() shim so we don't regress live sites 64 + * before the UI is shipped. Flipping a row to 'prompt' (once the UI lands) 65 + * is a one-line change. 66 + */ 67 + const DEFAULT_POLICY: Partial<Record<PermissionName, Decision>> = { 68 + geolocation: 'allow', 69 + media: 'allow', 70 + notifications: 'allow', 71 + midi: 'allow', 72 + midiSysex: 'allow', 73 + 'clipboard-read': 'allow', 74 + 'clipboard-sanitized-write': 'allow', 75 + 'display-capture': 'allow', 76 + pointerLock: 'allow', 77 + fullscreen: 'allow', 78 + openExternal: 'allow', 79 + }; 80 + 81 + /** 82 + * Pending request map — populated when a request is deferred to the UI. 83 + * Phase 2 will use this; today it stays empty (no policy entry resolves to 84 + * 'prompt'). Kept here so the Phase 2 IPC handler has somewhere to live 85 + * without churning the module shape. 86 + */ 87 + const pendingRequests = new Map<string, (granted: boolean) => void>(); 88 + 89 + let permissionHandlerInstalled = false; 90 + 91 + export function installPermissionHandler(ses: Session): void { 92 + if (permissionHandlerInstalled) { 93 + DEBUG && console.log('[permission] Handler already installed, skipping'); 94 + return; 95 + } 96 + permissionHandlerInstalled = true; 97 + 98 + ses.setPermissionCheckHandler((webContents, permission, requestingOrigin) => { 99 + // Synchronous check: invoked when a page calls e.g. navigator.permissions.query(). 100 + // Mirror the request handler's policy so the answer is consistent. 101 + const url = webContents?.getURL?.() ?? requestingOrigin ?? ''; 102 + const decision = resolveDecision(url, permission as PermissionName); 103 + return decision === 'allow'; 104 + }); 105 + 106 + ses.setPermissionRequestHandler((webContents, permission, callback, details) => { 107 + const requestingUrl = 108 + (details && (details as { requestingUrl?: string }).requestingUrl) || 109 + webContents?.getURL?.() || 110 + ''; 111 + const decision = resolveDecision(requestingUrl, permission as PermissionName); 112 + 113 + console.log( 114 + `[permission] ${permission} requested by ${requestingUrl || '(unknown)'} → ${decision}`, 115 + ); 116 + 117 + if (decision === 'allow') { 118 + callback(true); 119 + return; 120 + } 121 + if (decision === 'deny') { 122 + callback(false); 123 + return; 124 + } 125 + // 'prompt' — Phase 2 stores the callback and surfaces the UI. For now 126 + // (no policy entry returns 'prompt'), this branch is unreachable; if a 127 + // future policy entry lands without the UI being wired, fall back to 128 + // deny so we never leave the page silently waiting forever. 129 + console.warn( 130 + `[permission] ${permission} resolved to 'prompt' but no UI installed — denying`, 131 + ); 132 + callback(false); 133 + }); 134 + 135 + DEBUG && console.log('[permission] Handler installed on profile session'); 136 + } 137 + 138 + function resolveDecision(url: string, permission: PermissionName): Decision { 139 + // Chrome extension URLs are always allowed — extensions declare their 140 + // permissions in manifest.json and Electron honors them. 141 + if (url.startsWith('chrome-extension://')) return 'allow'; 142 + 143 + // peek:// internal pages — allow (we control them). 144 + if (url.startsWith('peek://')) return 'allow'; 145 + 146 + return DEFAULT_POLICY[permission] ?? 'deny'; 147 + } 148 + 149 + /** 150 + * Test-only: reset module state so tests can re-install with a fresh session. 151 + */ 152 + export function _resetForTests(): void { 153 + permissionHandlerInstalled = false; 154 + pendingRequests.clear(); 155 + } 156 + 157 + /** 158 + * Test-only: expose the policy resolver so unit tests can verify decisions 159 + * without needing a real Electron Session. 160 + */ 161 + export function _resolveDecisionForTests(url: string, permission: string): Decision { 162 + return resolveDecision(url, permission as PermissionName); 163 + }
+6
backend/electron/session-partition.ts
··· 20 20 import fs from 'node:fs'; 21 21 import path from 'node:path'; 22 22 import { registerProtocolOnSession } from './protocol.js'; 23 + import { installPermissionHandler } from './permission-handler.js'; 23 24 24 25 const DEBUG = !!process.env.DEBUG; 25 26 ··· 56 57 57 58 // Handle file downloads — without this, download URLs leave windows stuck in loading state 58 59 registerDownloadHandler(profileSession); 60 + 61 + // Install the web permission request handler. Replaces the blanket-allow 62 + // formerly installed by chrome-extensions.ts so we can apply per-permission 63 + // policy and (in a future phase) surface a Peek-native prompt to the user. 64 + installPermissionHandler(profileSession); 59 65 } 60 66 61 67 return profileSession;
+1 -1
docs/tasks.md
··· 23 23 24 24 ## Bugs 25 25 26 - - [ ] **Web permission requests (geolocation, camera, mic, notifications, clipboard) need user-approval UI.** Today Chromium permission requests from webview guests are either silently allowed or denied with no Peek-native prompt. Wire `session.setPermissionRequestHandler` (and `setPermissionCheckHandler` for sync checks) on the page-host webview's session to capture each request, surface a Peek-branded approve/reject prompt in the page UI (origin + permission name + remember-this-decision toggle), and persist per-origin decisions. Cover at minimum: geolocation, mediaDevices (camera/mic), notifications, clipboard-read/-write, midi, screen-share. Decisions should be queryable + revocable from settings. 26 + - [ ] **Web permission requests — Phase 2: surface user approval prompt.** Phase 1 shipped 2026-04-27: extracted permission handling into `backend/electron/permission-handler.ts`, removed the blanket-allow that `chrome-extensions.ts` had installed on the entire profile session, and added per-permission `DEFAULT_POLICY` (every entry resolves to `allow` today so no live site regresses). chrome-extension:// + peek:// origins always allowed; unknown/risky permissions fail-closed (`hid`, `serial`, etc.). Every request is logged to stderr with origin + permission. Phase 2: flip risky permissions (`geolocation`, `media`, `midi`, `display-capture`) to `'prompt'` in `DEFAULT_POLICY`; wire `pendingRequests` map → publish `page:permission-request` to the page-host renderer with `{requestId, permission, origin, windowId}`; render a Peek-branded approve/reject overlay in `app/page/index.html` + page.js (reuse `.load-error-overlay` styling pattern); on click publish/IPC `permission:respond` back to main; resolve the deferred callback. Phase 3: persist per-origin decisions in `feature_settings` (`featureId='page-permissions'`, key=`{permission}:{origin}`, value=`{allowed, timestamp}`); add settings UI to query/revoke. Phase 4: cover the full permission set (clipboard variants, screen-share, notifications) with sensible defaults. 27 27 28 28 - [ ] **Server-not-found / page-load failure shows blank white page forever.** User-reported 2026-04-27 with `http://www.metikmusic.com/` (DNS resolves but server returns nothing useful, or DNS fails). The page-host webview just sits on white with no feedback. We should handle the full lifecycle of a failed page open: DNS failure, connection refused, TLS errors, HTTP 4xx/5xx, hung loads, ERR_NAME_NOT_RESOLVED, ERR_INTERNET_DISCONNECTED. Show a Peek-branded error UI inside the canvas with: the URL that failed, the underlying error reason, an obvious Retry button, and a "Go back" / "Close" affordance. Likely hooks: `did-fail-load` and `did-fail-provisional-load` on the webview's webContents in `app/page/page.js`, plus `certificate-error` on the session. Audit other entry points too (cmd web search, external URL handler, address bar) to ensure they all funnel into the same error UI. 29 29
+82
tests/unit/permission-handler.test.js
··· 1 + /** 2 + * Unit tests for backend/electron/permission-handler.ts — the policy resolver 3 + * that decides allow / deny / prompt for each permission request. 4 + * 5 + * No Electron Session needed: we exercise the pure resolver via the test-only 6 + * `_resolveDecisionForTests(url, permission)` export. See the module header 7 + * for the full Phase 1 / Phase 2 plan. 8 + */ 9 + 10 + import { describe, it } from 'node:test'; 11 + import assert from 'node:assert/strict'; 12 + 13 + import { 14 + _resolveDecisionForTests as resolve, 15 + } from '../../dist/backend/electron/permission-handler.js'; 16 + 17 + describe('permission-handler: chrome-extension origins', () => { 18 + it('always allows geolocation for chrome-extension URLs', () => { 19 + assert.equal( 20 + resolve('chrome-extension://abc123/popup.html', 'geolocation'), 21 + 'allow', 22 + ); 23 + }); 24 + 25 + it('always allows even unknown permissions for chrome-extension URLs', () => { 26 + assert.equal( 27 + resolve('chrome-extension://abc123/popup.html', 'background-fetch'), 28 + 'allow', 29 + ); 30 + }); 31 + }); 32 + 33 + describe('permission-handler: peek:// internal pages', () => { 34 + it('allows clipboard-read for peek:// pages', () => { 35 + assert.equal( 36 + resolve('peek://app/page/index.html?url=https://example.com', 'clipboard-read'), 37 + 'allow', 38 + ); 39 + }); 40 + }); 41 + 42 + describe('permission-handler: web origins (default policy)', () => { 43 + it('allows geolocation by default (Phase 1 — visibility-only rollout)', () => { 44 + assert.equal( 45 + resolve('https://example.com/map', 'geolocation'), 46 + 'allow', 47 + ); 48 + }); 49 + 50 + it('allows media (camera/mic) by default', () => { 51 + assert.equal(resolve('https://meet.example.com/', 'media'), 'allow'); 52 + }); 53 + 54 + it('allows notifications by default', () => { 55 + assert.equal(resolve('https://news.example.com/', 'notifications'), 'allow'); 56 + }); 57 + 58 + it('denies an unknown permission by default — fail-closed for new permission types', () => { 59 + assert.equal( 60 + resolve('https://example.com/', 'unknown'), 61 + 'deny', 62 + ); 63 + }); 64 + 65 + it('denies hid by default — risky permission with no policy entry', () => { 66 + assert.equal(resolve('https://example.com/', 'hid'), 'deny'); 67 + }); 68 + 69 + it('denies serial by default — risky permission with no policy entry', () => { 70 + assert.equal(resolve('https://example.com/', 'serial'), 'deny'); 71 + }); 72 + }); 73 + 74 + describe('permission-handler: about:blank / empty origins', () => { 75 + it('denies geolocation when the requesting URL is empty (no origin to attribute to)', () => { 76 + // Empty URL doesn't match chrome-extension:// or peek:// — falls through 77 + // to DEFAULT_POLICY. geolocation defaults to allow today, so this case 78 + // documents that fact rather than asserting deny — when Phase 2 flips 79 + // geolocation to 'prompt' the assertion changes too. 80 + assert.equal(resolve('', 'geolocation'), 'allow'); 81 + }); 82 + });