OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop commits / d97ecb8

Add P2P shield lists and Pear Plugins
d97ecb8df6d388fb338376fcd2a71543dca46c9b
by Local LLM <localllm@local> · 2026-07-16T10:03:34.000Z

Changed files (31) +2474 -32

README.md +5 -1
……@@ -50,7 +50,10 @@ The key above is **content-addressed and stable**: when we ship a new release, t
5050- "About this site" panel (ⓘ button): drive key in hex + z-base-32 with copy buttons, scheme + path, one-click bookmark toggle
5151- **Ask Browser:** click **✦ Ask** to question a local QVAC/Qwen model about the active page, with private on-device streaming, cancellation, short follow-ups, source provenance, and no page access to the browser-owned AI channel
5252- **Local AI widget:** every blank new tab (`⌘T`) hosts a QVAC quick-ask card — zero page context, streamed on-device answers, model picker, and load-on-first-use progress ([docs](./docs/ASK_BROWSER.md))
53- **Content Shield:** Brave-style ad/tracker blocking enforced inside the browser-owned proxy — blocked requests never reach a peer, relay, or the network; cosmetic element hiding rides the HTML injection path; toggle + live counters in Settings ([plan](./docs/BROWSER_PARITY_PLAN.md))
53- **Content Shield:** Brave-style ad/tracker blocking enforced inside the browser-owned proxy — blocked requests never reach a peer, relay, or the network; cosmetic element hiding + scriptlets ride the CSP-safe HTML injection path; per-drive allowlist/strict modes, toggle + live counters in Settings ([plan](./docs/BROWSER_PARITY_PLAN.md))
54- **P2P filter lists:** subscribe to a filter-list Hyperdrive by key — rules sync peer-to-peer with sha256 verification, hot-swap when the publisher updates, and keep blocking fully offline; publish your own with `scripts/build-shield-list.mjs` + `scripts/publish-and-pin.js` ([guide](./filter-lists/README.md))
55- **Pear Plugins:** extensions as Hyperdrives with declared capabilities (`pear.content.styles/scripts`, `pear.net.filter`) — install by drive key, contributions inject hash-authorized, and a swarm update that requests new capabilities is auto-disabled pending re-approval; two ready-to-publish examples in `examples/plugins/`
56- **Plugin catalogue:** one-click discovery in Settings — curated entries install without pasting keys, `kind: "app"` entries like **anonGPT** open directly as P2P apps, and additional catalogues load from any drive with a `/plugins.json` (shippable source in `catalogues/pear-plugins/`)
5457
5558### Apps
5659- Paste any `pear://` link → opens in its own isolated window
……@@ -141,6 +144,7 @@ Three independent keypairs — BIP-39 identity, HiveRelay publisher key, Coresto
141144| [Ask Browser](./docs/ASK_BROWSER.md) | Local-model side panel, Ollama/Qwen discovery, authenticated page-context capture, stream RPC, safety boundaries, and verification. |
142145| [QVAC native AI](./docs/QVAC_NATIVE_AI.md) | Native Bare/QVAC runtime, model aliases, browser-page AI API, security limits, and smoke commands. |
143146| [Browser parity plan](./docs/BROWSER_PARITY_PLAN.md) | Roadmap to Brave-grade capability: Content Shield phases, P2P filter lists, the Pear Plugins extension model, and the clearnet session bridge. |
147| [Filter lists & plugins guide](./filter-lists/README.md) | Publishing and subscribing to P2P filter-list drives, and packaging/installing Pear Plugins with the capability-escalation guard. |
144148| [Deep audit](./docs/DEEP_AUDIT_CATALOG_SEARCH_NAMING_NOSTR_2026-06-21.md) | Detailed catalogue/search/naming/Nostr audit, issue list, fixes, and test evidence. |
145149| [Manual release smoke](./docs/MANUAL_RELEASE_SMOKE_2026-06-23.md) | Final human-run release checklist for UI flows, Peercord trust approval, mobile device smoke, and signing/store gates. |
146150| [Native install guide](./docs/INSTALL_NATIVE_PACKAGES.md) | User-facing package selection, checksum verification, and OS-specific install steps for the current native assets. |
backend/clearnet-proxy.cjs +3 -1
……@@ -194,8 +194,10 @@ function rewriteHtmlForProxy (html, documentUrl, proxyOrigin) {
194194
195195 // Inject <base> pointing at proxy document root so any leftover relative
196196 // resolution still hits our host; we also set a pear-clearnet meta.
197 // Bare's URL implementation exposes protocol + host but not `.origin`.
198 const documentOrigin = `${base.protocol}//${base.host}`
197199 const headBits =
198 `<meta name="pear-clearnet-origin" content="${escapeHtml(base.origin)}">` +
200 `<meta name="pear-clearnet-origin" content="${escapeHtml(documentOrigin)}">` +
199201 `<base href="${escapeHtml(origin)}/clearnet/${encodeClearnetTarget(base.toString())}">`
200202
201203 if (/<head[^>]*>/i.test(out)) {
backend/constants.js +16 -0
……@@ -203,6 +203,19 @@ const CMD_PLUGIN_LIST = 235 // → { plugins: [...] }
203203const CMD_PLUGIN_SET_ENABLED = 236 // { id, enabled } → kill-switch without uninstall
204204const CMD_PLUGIN_REGISTER = 237 // { id, manifest, contribution?, enabled? } → fixture/install path
205205
206// P2P distribution (Phase 2/3 gates): filter lists and plugins arrive as
207// Hyperdrives. Subscriptions and installs are durable and work offline
208// after first sync; updates hot-swap with a capability-escalation guard.
209const CMD_SHIELD_SUBSCRIBE_LIST = 239 // { driveKey } → subscribe to a filter-list drive
210const CMD_SHIELD_UNSUBSCRIBE_LIST = 240 // { driveKey }
211const CMD_SHIELD_REFRESH_LISTS = 241 // { driveKey?, force? } → refresh one or all subscriptions
212const CMD_PLUGIN_INSTALL_DRIVE = 242 // { driveKey, granted? } → install a plugin from its drive
213const CMD_PLUGIN_UPDATE_DRIVE = 243 // { driveKey, acceptEscalation? }
214const CMD_PLUGIN_UNINSTALL = 244 // { driveKey }
215const CMD_PLUGIN_CATALOG = 245 // → { entries, sources } (builtin seed + subscribed catalogue drives)
216const CMD_PLUGIN_CATALOG_LOAD_DRIVE = 246 // { driveKey } → subscribe to a catalogue drive (/plugins.json)
217const CMD_PLUGIN_CATALOG_REMOVE_SOURCE = 247 // { driveKey }
218
206219// Privacy ladder + clearnet session bridge status (Phases 4–5).
207220const CMD_PRIVACY_STATUS = 238
208221
……@@ -322,6 +335,9 @@ module.exports = {
322335 CMD_SHIELD_STATUS, CMD_SHIELD_LOAD_LIST, CMD_SHIELD_REMOVE_LIST,
323336 CMD_SHIELD_SET_ALLOW, CMD_SHIELD_SET_STRICT,
324337 CMD_PLUGIN_LIST, CMD_PLUGIN_SET_ENABLED, CMD_PLUGIN_REGISTER,
338 CMD_SHIELD_SUBSCRIBE_LIST, CMD_SHIELD_UNSUBSCRIBE_LIST, CMD_SHIELD_REFRESH_LISTS,
339 CMD_PLUGIN_INSTALL_DRIVE, CMD_PLUGIN_UPDATE_DRIVE, CMD_PLUGIN_UNINSTALL,
340 CMD_PLUGIN_CATALOG, CMD_PLUGIN_CATALOG_LOAD_DRIVE, CMD_PLUGIN_CATALOG_REMOVE_SOURCE,
325341 CMD_PRIVACY_STATUS,
326342 CMD_BRIDGE,
327343 CMD_STOP,
backend/http-bridge.js +4 -1
……@@ -90,7 +90,10 @@ class HttpBridge {
9090
9191 _sameOrigin (a, b) {
9292 try {
93 return new URL(a).origin === new URL(b).origin
93 const left = new URL(a)
94 const right = new URL(b)
95 // Bare's URL implementation does not expose `.origin`.
96 return left.protocol === right.protocol && left.host === right.host
9497 } catch {
9598 return false
9699 }
backend/hyper-proxy.js +5 -1
……@@ -50,7 +50,11 @@ function isLoopbackOrigin (origin) {
5050
5151function normalizeOrigin (origin) {
5252 if (typeof origin !== 'string' || origin.length === 0) return null
53 return new URL(origin).origin
53 const parsed = new URL(origin)
54 // Bare's URL implementation exposes protocol + host but not `origin`.
55 // Building it explicitly keeps injected <base> URLs and token bindings
56 // valid in the packaged runtime as well as in Node-based tests.
57 return `${parsed.protocol}//${parsed.host}`
5458}
5559
5660function originForPort (port) {
backend/index.js +182 -2
……@@ -37,6 +37,9 @@ const { PearBridge, PEAR_SWARM_V1_SHIM, PEAR_SYNC_SHIM, PEAR_ANONGPT_SHIM } = re
3737const { HttpBridge } = require('./http-bridge.js')
3838const { AskBrowserService } = require('./ai/ask-browser-service.cjs')
3939const { ContentShield } = require('./content-shield.cjs')
40const { ShieldListSync } = require('./shield-list-sync.cjs')
41const { PluginDriveLoader } = require('./plugin-drive-loader.cjs')
42const { PluginCatalog } = require('./plugin-catalog.cjs')
4043const { PearPluginRegistry } = require('./pear-plugins.cjs')
4144const { SessionBridge } = require('./session-bridge.cjs')
4245const {
……@@ -129,6 +132,9 @@ let swarmGrants = null
129132let aiService = null
130133let contentShield = null // Content Shield — request filter + cosmetic hiding (BROWSER_PARITY_PLAN.md)
131134let pearPlugins = null // Pear Plugins registry (Phase 3)
135let shieldListSync = null // P2P filter-list subscriptions (Phase 2 gate)
136let pluginDriveLoader = null // Plugin installs from drives (Phase 3 gate)
137let pluginCatalog = null // Plugin discovery: builtin seed + subscribed catalogue drives
132138let sessionBridge = null // Clearnet session bridge facade (Phase 4)
133139let privacySettings = { ...DEFAULT_PRIVACY }
134140/** Map<requestId, { resolve, reject, timer }> for login() ceremonies. */
……@@ -232,7 +238,7 @@ rpc.handle(C.CMD_NAVIGATE, async (data = {}) => {
232238 ? await proxy.localUrlForDrive(key, 'hyper', pathAndSearch)
233239 : `http://127.0.0.1:${proxy.port}/hyper/${key}${pathAndSearch}`
234240 const apiToken = validKey && proxy?.issueApiToken
235 ? proxy.issueApiToken(key, { origin: new URL(localUrl).origin })
241 ? proxy.issueApiToken(key, { origin: localUrl })
236242 : null
237243 const contextToken = validKey && proxy?.pageContextToken
238244 ? proxy.pageContextToken(key)
……@@ -339,6 +345,7 @@ rpc.handle(C.CMD_SHIELD_STATUS, async (data = {}) => {
339345 stats.driveAllowlisted = contentShield.isAllowlisted(driveKey)
340346 stats.driveStrict = contentShield.isStrict(driveKey)
341347 }
348 stats.subscriptions = shieldListSync ? shieldListSync.subscriptions() : []
342349 return stats
343350})
344351
……@@ -457,6 +464,119 @@ function rememberPluginPayload (data) {
457464 }
458465}
459466
467// --- P2P distribution: filter-list drives + plugin drives ---
468//
469// Rule text and plugin payloads are durable through persistShieldState();
470// these commands own the drive-sourced lifecycle (subscribe/refresh/install/
471// update) so lists and plugins keep working offline after first sync and
472// hot-swap when their drives update.
473
474rpc.handle(C.CMD_SHIELD_SUBSCRIBE_LIST, async (data = {}) => {
475 await whenReady()
476 if (!shieldListSync) throw new Error('Shield list sync not available')
477 const result = await shieldListSync.subscribe(data.driveKey)
478 await persistShieldState()
479 return { ...result, subscriptions: shieldListSync.subscriptions() }
480})
481
482rpc.handle(C.CMD_SHIELD_UNSUBSCRIBE_LIST, async (data = {}) => {
483 await whenReady()
484 if (!shieldListSync) throw new Error('Shield list sync not available')
485 const result = await shieldListSync.unsubscribe(data.driveKey)
486 await persistShieldState()
487 return { ...result, subscriptions: shieldListSync.subscriptions() }
488})
489
490rpc.handle(C.CMD_SHIELD_REFRESH_LISTS, async (data = {}) => {
491 await whenReady()
492 if (!shieldListSync) throw new Error('Shield list sync not available')
493 const outcomes = data.driveKey
494 ? [{ driveKey: String(data.driveKey).toLowerCase(), ok: true, ...(await shieldListSync.refresh(data.driveKey, { force: !!data.force })) }]
495 : await shieldListSync.refreshAll({ force: !!data.force })
496 await persistShieldState()
497 return { outcomes, subscriptions: shieldListSync.subscriptions() }
498})
499
500rpc.handle(C.CMD_PLUGIN_INSTALL_DRIVE, async (data = {}) => {
501 await whenReady()
502 if (!pluginDriveLoader) throw new Error('Plugin drive loader not available')
503 const result = await pluginDriveLoader.installFromDrive(data.driveKey, {
504 grantedCapabilities: data.granted
505 })
506 await persistShieldState()
507 return result
508})
509
510rpc.handle(C.CMD_PLUGIN_UPDATE_DRIVE, async (data = {}) => {
511 await whenReady()
512 if (!pluginDriveLoader) throw new Error('Plugin drive loader not available')
513 const result = await pluginDriveLoader.updateFromDrive(data.driveKey, {
514 acceptEscalation: !!data.acceptEscalation
515 })
516 await persistShieldState()
517 return result
518})
519
520rpc.handle(C.CMD_PLUGIN_UNINSTALL, async (data = {}) => {
521 await whenReady()
522 if (!pluginDriveLoader) throw new Error('Plugin drive loader not available')
523 const result = await pluginDriveLoader.uninstall(data.driveKey)
524 if (result.driveKey) delete persistShieldState._pluginPayloads[result.driveKey]
525 await persistShieldState()
526 return result
527})
528
529// Plugin discovery. The catalogue is metadata-only: installing still runs
530// through the drive loader's grant + escalation path, and `kind: "app"`
531// entries (anonGPT) simply open as hyper:// apps.
532rpc.handle(C.CMD_PLUGIN_CATALOG, async () => {
533 await whenReady()
534 if (!pluginCatalog) return { entries: [], sources: [] }
535 const installed = new Set(pearPlugins ? pearPlugins.list().map(p => p.id) : [])
536 return {
537 entries: pluginCatalog.entries().map(entry => ({
538 ...entry,
539 installed: !!(entry.driveKey && installed.has(entry.driveKey))
540 })),
541 sources: pluginCatalog.sources()
542 }
543})
544
545rpc.handle(C.CMD_PLUGIN_CATALOG_LOAD_DRIVE, async (data = {}) => {
546 await whenReady()
547 if (!pluginCatalog) throw new Error('Plugin catalogue not available')
548 const result = await pluginCatalog.loadFromDrive(data.driveKey)
549 await persistPluginCatalog()
550 return { ...result, sources: pluginCatalog.sources() }
551})
552
553rpc.handle(C.CMD_PLUGIN_CATALOG_REMOVE_SOURCE, async (data = {}) => {
554 await whenReady()
555 if (!pluginCatalog) throw new Error('Plugin catalogue not available')
556 const removed = pluginCatalog.removeSource(data.driveKey)
557 await persistPluginCatalog()
558 return { removed, sources: pluginCatalog.sources() }
559})
560
561function persistPluginCatalog () {
562 if (!pluginCatalog || !userData) return Promise.resolve()
563 return userData.setSettings({ contentShieldPluginCatalog: pluginCatalog.exportState() }).catch(() => {})
564}
565
566function persistPluginInstallRecords () {
567 if (!pluginDriveLoader || !userData) return Promise.resolve()
568 const records = {}
569 for (const record of pluginDriveLoader.installs()) {
570 records[record.driveKey] = {
571 granted: record.granted,
572 version: record.version,
573 installedAt: record.installedAt,
574 escalated: record.escalated
575 }
576 }
577 return userData.setSettings({ contentShieldPluginInstalls: records }).catch(() => {})
578}
579
460580rpc.handle(C.CMD_GET_IDENTITY, async () => {
461581 return {
462582 publicKey: swarm ? swarm.keyPair.publicKey.toString('hex') : null,
……@@ -569,7 +689,7 @@ rpc.handle(C.CMD_LAUNCH_APP, async (data) => {
569689 appId: data.id,
570690 name: app.name,
571691 driveKey,
572 apiToken: proxy?.issueApiToken ? proxy.issueApiToken(driveKey, { origin: new URL(localUrl).origin }) : null,
692 apiToken: proxy?.issueApiToken ? proxy.issueApiToken(driveKey, { origin: localUrl }) : null,
573693 contextToken: proxy?.pageContextToken ? proxy.pageContextToken(driveKey) : null
574694 }
575695})
……@@ -3050,9 +3170,68 @@ async function boot () {
30503170 try { pearPlugins.register(payload) } catch {}
30513171 }
30523172 }
3173
3174 // P2P distribution (Phase 2/3 gates). Rule text and plugin payloads were
3175 // already rehydrated above, so both work fully offline; these two own
3176 // the drive-sourced metadata and the hot-swap lifecycle.
3177 const sha256Hex = (buf) => require('crypto').createHash('sha256').update(buf).digest('hex')
3178 shieldListSync = new ShieldListSync({
3179 shield: contentShield,
3180 fetchDriveFile: (keyHex, path) => proxy._hybridFetch(keyHex, path),
3181 sha256Hex,
3182 persistMeta: async (meta) => {
3183 if (userData) await userData.setSettings({ contentShieldListSync: meta })
3184 }
3185 })
3186 if (shieldSettings && shieldSettings.contentShieldListSync) {
3187 shieldListSync.restore(shieldSettings.contentShieldListSync)
3188 }
3189 pluginDriveLoader = new PluginDriveLoader({
3190 registry: pearPlugins,
3191 fetchDriveFile: (keyHex, path) => proxy._hybridFetch(keyHex, path),
3192 persistInstall: async (id, payload) => {
3193 if (payload === null) {
3194 delete persistShieldState._pluginPayloads[id]
3195 } else if (payload.__recordPatch) {
3196 const existing = persistShieldState._pluginPayloads[id]
3197 if (existing) existing.enabled = false // escalation guard disabled it
3198 } else {
3199 persistShieldState._pluginPayloads[id] = {
3200 id,
3201 manifest: payload.manifest,
3202 contribution: payload.contribution,
3203 enabled: payload.enabled !== false
3204 }
3205 }
3206 await persistPluginInstallRecords()
3207 }
3208 })
3209 if (shieldSettings && shieldSettings.contentShieldPluginInstalls) {
3210 pluginDriveLoader.restore(shieldSettings.contentShieldPluginInstalls)
3211 }
3212 pluginCatalog = new PluginCatalog({
3213 fetchDriveFile: (keyHex, path) => proxy._hybridFetch(keyHex, path)
3214 })
3215 if (shieldSettings && shieldSettings.contentShieldPluginCatalog) {
3216 pluginCatalog.restore(shieldSettings.contentShieldPluginCatalog)
3217 }
30533218 } catch {}
30543219 proxy.setContentShield(contentShield)
30553220
3221 // Background list refresh: first sweep shortly after boot (drives need the
3222 // swarm), then periodic hot-swap checks. Failures never disturb browsing.
3223 if (shieldListSync) {
3224 const firstSweep = setTimeout(() => {
3225 shieldListSync.refreshAll()
3226 .then((outcomes) => {
3227 if (outcomes.some(o => o.ok && o.changed)) return persistShieldState()
3228 })
3229 .catch(() => {})
3230 }, 30 * 1000)
3231 if (typeof firstSweep?.unref === 'function') firstSweep.unref()
3232 shieldListSync.startAutoRefresh(30 * 60 * 1000)
3233 }
3234
30563235 // Session bridge + privacy ladder (Phases 4–5). Clearnet navigations route
30573236 // through the browser-owned /clearnet/* proxy by default so Content Shield
30583237 // sees every request. Direct mode (real https load) is a settings opt-in.
……@@ -3195,6 +3374,7 @@ async function ensureDevCatalogue () {
31953374
31963375async function shutdown () {
31973376 if (storageTimer) { clearInterval(storageTimer); storageTimer = null }
3377 try { shieldListSync?.stop() } catch {}
31983378 try { await askBrowserService.close() } catch {}
31993379 if (aiService) { try { await aiService.close() } catch {}; aiService = null }
32003380 if (swarmBridge) { try { await swarmBridge.destroy() } catch {} swarmBridge = null }
backend/plugin-catalog.cjs +251 -0
……@@ -0,0 +1,251 @@
1'use strict'
2
3/**
4 * Plugin catalogue — discovery for Pear Plugins and AI add-on apps.
5 *
6 * The catalogue answers "what can I install?" the way the app store answers
7 * it for apps: a browser-shipped builtin seed plus any number of
8 * P2P-distributed catalogue drives the user subscribes to (a drive with a
9 * /plugins.json). Entries are metadata only — INSTALLING still goes through
10 * the plugin drive loader with its capability grant + escalation guard, and
11 * `kind: "app"` entries (like anonGPT) open as ordinary hyper:// apps whose
12 * own manifest gates their powers. The catalogue never grants anything.
13 *
14 * /plugins.json format on a catalogue drive:
15 *
16 * {
17 * "name": "Pear Plugins",
18 * "plugins": [
19 * {
20 * "driveKey": "<64-hex>",
21 * "kind": "plugin" | "app",
22 * "name": "Pear Dark Reader",
23 * "description": "…",
24 * "author": "…",
25 * "capabilities": ["pear.content.styles"]
26 * }
27 * ]
28 * }
29 */
30
31const { ANONGPT_DRIVE_KEY } = require('./constants.js')
32
33const MAX_CATALOG_BYTES = 256 * 1024
34const MAX_ENTRIES_PER_SOURCE = 200
35const ENTRY_KINDS = new Set(['plugin', 'app'])
36
37/**
38 * Browser-shipped seed. anonGPT ships with its production drive key so a
39 * user can add it on their own; the example plugins list without keys until
40 * their drives are published (Install stays disabled — fail closed).
41 */
42const BUILTIN_PLUGIN_CATALOG = Object.freeze([
43 Object.freeze({
44 id: 'anongpt',
45 kind: 'app',
46 driveKey: ANONGPT_DRIVE_KEY,
47 name: 'anonGPT',
48 description: 'Private AI chat as a P2P app. Uses the window.pear.anongpt bridge; PearBrowser verifies the app\'s no-prompt-storage privacy manifest before the bridge is injected, and only on this drive. Open it and it works.',
49 author: 'anonGPT',
50 capabilities: Object.freeze(['pear.anongpt.infer']),
51 verified: true
52 }),
53 Object.freeze({
54 id: 'pear-dark-reader',
55 kind: 'plugin',
56 driveKey: 'bbde8330169798dc5e0d08f8909b407cea2f8fec7e31d6241f479c714ad42082', // published 2026-07-16, storage seed-pear-dark-reader
57 name: 'Pear Dark Reader',
58 description: 'Dark, high-contrast theme for every hyper:// page. Styles only — no scripts, no network rules.',
59 author: 'PearBrowser examples',
60 capabilities: Object.freeze(['pear.content.styles']),
61 verified: true
62 }),
63 Object.freeze({
64 id: 'peerit-enhancer',
65 kind: 'plugin',
66 driveKey: '1b21d8a6960bdcdfb76da94b80dae0d1a28247516de87e6839ea2f87bb609e10', // published 2026-07-16, storage seed-peerit-enhancer
67 name: 'peerit Enhancer',
68 description: 'Compact rows, reading-width cap, and j/k keyboard navigation for peerit. Scoped to the peerit drive only.',
69 author: 'PearBrowser examples',
70 capabilities: Object.freeze(['pear.content.styles', 'pear.content.scripts', 'pear.net.filter']),
71 verified: true
72 })
73])
74
75class PluginCatalogError extends Error {
76 constructor (code, message) {
77 super(message)
78 this.name = 'PluginCatalogError'
79 this.code = code
80 }
81}
82
83class PluginCatalog {
84 /**
85 * @param {object} opts
86 * @param {function} opts.fetchDriveFile — async (driveKeyHex, path) => { content: Buffer } | null
87 * @param {Array} [opts.builtin] — override the seed (tests)
88 * @param {function} [opts.now]
89 */
90 constructor (opts = {}) {
91 if (typeof opts.fetchDriveFile !== 'function') {
92 throw new TypeError('PluginCatalog requires a fetchDriveFile transport')
93 }
94 this._fetch = opts.fetchDriveFile
95 this._builtin = (Array.isArray(opts.builtin) ? opts.builtin : BUILTIN_PLUGIN_CATALOG)
96 .map(entry => validateCatalogEntry(entry))
97 .filter(Boolean)
98 this._sources = new Map() // driveKeyHex -> { name, entries, loadedAt }
99 this._now = typeof opts.now === 'function' ? opts.now : Date.now
100 }
101
102 /** Merged view: builtin first, then source entries not shadowed by them. */
103 entries () {
104 const merged = []
105 const seen = new Set()
106 const push = (entry, source) => {
107 const dedupeKey = entry.driveKey || `id:${entry.id}`
108 if (seen.has(dedupeKey)) return
109 seen.add(dedupeKey)
110 merged.push({ ...entry, source })
111 }
112 for (const entry of this._builtin) push(entry, 'builtin')
113 for (const [sourceKey, record] of this._sources) {
114 for (const entry of record.entries) push(entry, sourceKey)
115 }
116 return merged
117 }
118
119 sources () {
120 return [...this._sources.entries()].map(([driveKey, record]) => ({
121 driveKey,
122 name: record.name,
123 entryCount: record.entries.length,
124 loadedAt: record.loadedAt
125 }))
126 }
127
128 /** Subscribe to a catalogue drive: fetch, validate, register (replaces). */
129 async loadFromDrive (driveKey) {
130 const key = normalizeDriveKey(driveKey)
131 if (!key) throw new PluginCatalogError('invalid-drive-key', 'A 64-hex catalogue drive key is required')
132
133 const fetched = await this._fetch(key, '/plugins.json')
134 const content = fetched && fetched.content ? fetched.content : null
135 if (!content || content.length === 0) {
136 throw new PluginCatalogError('catalog-unavailable', `No /plugins.json on drive ${key.slice(0, 12)}…`)
137 }
138 if (content.length > MAX_CATALOG_BYTES) {
139 throw new PluginCatalogError('catalog-too-large', `Catalogue exceeds ${MAX_CATALOG_BYTES} bytes`)
140 }
141
142 let parsed
143 try {
144 parsed = JSON.parse(content.toString('utf8'))
145 } catch {
146 throw new PluginCatalogError('catalog-invalid', 'Catalogue is not valid JSON')
147 }
148 const rawEntries = Array.isArray(parsed?.plugins) ? parsed.plugins : null
149 if (!rawEntries) throw new PluginCatalogError('catalog-invalid', 'Catalogue has no plugins array')
150
151 const entries = rawEntries
152 .slice(0, MAX_ENTRIES_PER_SOURCE)
153 .map(entry => validateCatalogEntry(entry, { requireDriveKey: true }))
154 .filter(Boolean)
155 if (!entries.length) throw new PluginCatalogError('catalog-empty', 'Catalogue contains no valid entries')
156
157 const record = {
158 name: cleanString(parsed.name, 120) || `catalogue ${key.slice(0, 8)}…`,
159 entries,
160 loadedAt: this._now()
161 }
162 this._sources.set(key, record)
163 return { driveKey: key, name: record.name, entryCount: entries.length }
164 }
165
166 removeSource (driveKey) {
167 const key = normalizeDriveKey(driveKey)
168 return !!key && this._sources.delete(key)
169 }
170
171 /** Durable snapshot of subscribed sources (builtin never persists). */
172 exportState () {
173 const sources = {}
174 for (const [key, record] of this._sources) sources[key] = record
175 return { sources }
176 }
177
178 restore (state) {
179 if (!state || typeof state !== 'object' || !state.sources || typeof state.sources !== 'object') return 0
180 let restored = 0
181 for (const [key, record] of Object.entries(state.sources)) {
182 const driveKey = normalizeDriveKey(key)
183 if (!driveKey || !record || typeof record !== 'object') continue
184 const entries = Array.isArray(record.entries)
185 ? record.entries.map(entry => validateCatalogEntry(entry, { requireDriveKey: true })).filter(Boolean)
186 : []
187 if (!entries.length) continue
188 this._sources.set(driveKey, {
189 name: cleanString(record.name, 120) || `catalogue ${driveKey.slice(0, 8)}…`,
190 entries,
191 loadedAt: Number.isFinite(record.loadedAt) ? record.loadedAt : 0
192 })
193 restored++
194 }
195 return restored
196 }
197}
198
199/**
200 * Validate one catalogue entry into the canonical shape, or null. Catalogue
201 * capabilities are DISPLAY metadata (what the consent UI previews); the real
202 * grant always comes from the plugin drive's own manifest at install time.
203 */
204function validateCatalogEntry (entry, opts = {}) {
205 if (!entry || typeof entry !== 'object') return null
206 const name = cleanString(entry.name, 120)
207 if (!name) return null
208
209 const driveKey = normalizeDriveKey(entry.driveKey)
210 if (opts.requireDriveKey && !driveKey) return null
211
212 const kind = ENTRY_KINDS.has(entry.kind) ? entry.kind : 'plugin'
213 const id = cleanString(entry.id, 64).toLowerCase() || driveKey || slugify(name)
214 if (!id) return null
215
216 const capabilities = Array.isArray(entry.capabilities)
217 ? entry.capabilities.map(cap => cleanString(cap, 64)).filter(Boolean).slice(0, 8)
218 : []
219
220 return {
221 id,
222 kind,
223 driveKey,
224 name,
225 description: cleanString(entry.description, 500),
226 author: cleanString(entry.author, 120),
227 capabilities,
228 verified: entry.verified === true,
229 ...(typeof entry.unpublished === 'string' && entry.unpublished ? { unpublished: cleanString(entry.unpublished, 200) } : {})
230 }
231}
232
233function slugify (value) {
234 return cleanString(value, 64).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
235}
236
237function normalizeDriveKey (value) {
238 const text = typeof value === 'string' ? value.trim().toLowerCase() : ''
239 return /^[0-9a-f]{64}$/.test(text) ? text : ''
240}
241
242function cleanString (value, maxChars) {
243 return typeof value === 'string' ? value.trim().slice(0, maxChars) : ''
244}
245
246module.exports = {
247 PluginCatalog,
248 PluginCatalogError,
249 BUILTIN_PLUGIN_CATALOG,
250 validateCatalogEntry
251}
backend/plugin-drive-loader.cjs +329 -0
……@@ -0,0 +1,329 @@
1'use strict'
2
3/**
4 * Plugin drive loader — installs Pear Plugins from Hyperdrives (Phase 3 gate
5 * of docs/BROWSER_PARITY_PLAN.md).
6 *
7 * A plugin drive looks like an ordinary Pear app drive:
8 *
9 * /manifest.json pear.plugin metadata + capabilities (see pear-plugins.cjs)
10 * /style.css optional content styles (path declared in the manifest)
11 * /content.js optional content script
12 * /filters.txt optional network filter contribution
13 *
14 * The manifest declares which assets exist and where they match:
15 *
16 * {
17 * "name": "Dark Reader",
18 * "version": "1.0.0",
19 * "pear": {
20 * "plugin": {
21 * "capabilities": ["pear.content.styles"],
22 * "content": {
23 * "styles": { "matches": ["*"], "path": "/style.css" },
24 * "scripts": { "matches": ["*"], "path": "/content.js" },
25 * "filters": "/filters.txt"
26 * }
27 * }
28 * }
29 * }
30 *
31 * The loader fetches the declared assets, builds the inline contribution the
32 * registry expects, and registers under the drive key. Grants are captured at
33 * install time; an update whose manifest requests capabilities beyond the
34 * recorded grant is an ESCALATION — the plugin is disabled and flagged for
35 * explicit re-consent instead of silently gaining power. (A Hyperdrive
36 * update arrives over the swarm with no user action, so this guard is what
37 * makes P2P-distributed plugins safe.)
38 */
39
40const MAX_ASSET_BYTES = 512 * 1024
41const MAX_FILTER_BYTES = 2 * 1024 * 1024
42const MAX_MANIFEST_BYTES = 256 * 1024
43
44class PluginDriveError extends Error {
45 constructor (code, message) {
46 super(message)
47 this.name = 'PluginDriveError'
48 this.code = code
49 }
50}
51
52class PluginDriveLoader {
53 /**
54 * @param {object} opts
55 * @param {object} opts.registry — PearPluginRegistry
56 * @param {function} opts.fetchDriveFile — async (driveKeyHex, path) => { content: Buffer } | null
57 * @param {function} [opts.persistInstall] — async (id, payload|null) => void; null clears
58 * @param {function} [opts.now]
59 */
60 constructor (opts = {}) {
61 if (!opts.registry || typeof opts.registry.register !== 'function') {
62 throw new TypeError('PluginDriveLoader requires a PearPluginRegistry')
63 }
64 if (typeof opts.fetchDriveFile !== 'function') {
65 throw new TypeError('PluginDriveLoader requires a fetchDriveFile transport')
66 }
67 this._registry = opts.registry
68 this._fetch = opts.fetchDriveFile
69 this._persistInstall = typeof opts.persistInstall === 'function' ? opts.persistInstall : async () => {}
70 this._now = typeof opts.now === 'function' ? opts.now : Date.now
71 this._installs = new Map() // driveKeyHex -> { granted, version, escalated: {added}|null }
72 }
73
74 /** Rehydrate install records (grants + versions) from durable settings. */
75 restore (records) {
76 if (!records || typeof records !== 'object') return 0
77 let restored = 0
78 for (const [key, record] of Object.entries(records)) {
79 const driveKey = normalizeDriveKey(key)
80 if (!driveKey || !record || typeof record !== 'object') continue
81 this._installs.set(driveKey, {
82 granted: normalizeCapabilityList(record.granted),
83 version: cleanString(record.version),
84 installedAt: Number.isFinite(record.installedAt) ? record.installedAt : 0,
85 escalated: record.escalated && Array.isArray(record.escalated.added)
86 ? { added: normalizeCapabilityList(record.escalated.added) }
87 : null
88 })
89 restored++
90 }
91 return restored
92 }
93
94 installs () {
95 return [...this._installs.entries()].map(([driveKey, record]) => ({ driveKey, ...record }))
96 }
97
98 installRecord (driveKey) {
99 const key = normalizeDriveKey(driveKey)
100 return key ? this._installs.get(key) || null : null
101 }
102
103 /**
104 * Install a plugin from its drive. The user's grant is the manifest's
105 * capability set at install time (the consent surface shows exactly this
106 * list before calling install).
107 */
108 async installFromDrive (driveKey, opts = {}) {
109 const key = normalizeDriveKey(driveKey)
110 if (!key) throw new PluginDriveError('invalid-drive-key', 'A 64-hex plugin drive key is required')
111
112 const loaded = await this._load(key)
113 const requested = loaded.capabilities
114 const granted = opts.grantedCapabilities !== undefined
115 ? normalizeCapabilityList(opts.grantedCapabilities).filter(cap => requested.includes(cap))
116 : requested
117
118 const result = this._registry.register({
119 id: key,
120 manifest: withCapabilities(loaded.manifest, granted),
121 contribution: loaded.contribution,
122 enabled: opts.enabled !== false
123 })
124 if (!result || result.ok !== true) {
125 throw new PluginDriveError('register-failed', (result && result.reason) || 'Plugin registration failed')
126 }
127
128 const record = {
129 granted,
130 version: loaded.version,
131 installedAt: this._now(),
132 escalated: null
133 }
134 this._installs.set(key, record)
135 await this._persist(key, {
136 id: key,
137 manifest: withCapabilities(loaded.manifest, granted),
138 contribution: loaded.contribution,
139 enabled: opts.enabled !== false,
140 granted,
141 version: loaded.version,
142 installedAt: record.installedAt
143 })
144 return { ok: true, driveKey: key, name: result.name, version: loaded.version, granted, applied: result.applied }
145 }
146
147 /**
148 * Update an installed plugin from its drive. Fail-closed on capability
149 * escalation: the plugin is disabled, the escalation is recorded, and the
150 * caller gets the added capabilities to show in a re-consent prompt.
151 * Passing `acceptEscalation: true` re-grants to the new capability set.
152 */
153 async updateFromDrive (driveKey, opts = {}) {
154 const key = normalizeDriveKey(driveKey)
155 const record = key ? this._installs.get(key) : null
156 if (!record) throw new PluginDriveError('not-installed', 'Plugin is not installed from a drive')
157
158 const loaded = await this._load(key)
159 const added = loaded.capabilities.filter(cap => !record.granted.includes(cap))
160
161 if (added.length > 0 && !opts.acceptEscalation) {
162 this._registry.setEnabled(key, false)
163 record.escalated = { added }
164 await this._persistRecordOnly(key, record)
165 return {
166 ok: false,
167 escalated: true,
168 driveKey: key,
169 added,
170 version: loaded.version,
171 message: 'Update requests new capabilities; plugin disabled pending re-consent'
172 }
173 }
174
175 const granted = added.length > 0 ? loaded.capabilities : record.granted
176 const result = this._registry.register({
177 id: key,
178 manifest: withCapabilities(loaded.manifest, granted),
179 contribution: loaded.contribution,
180 enabled: true
181 })
182 if (!result || result.ok !== true) {
183 throw new PluginDriveError('register-failed', (result && result.reason) || 'Plugin registration failed')
184 }
185
186 record.granted = granted
187 record.version = loaded.version
188 record.escalated = null
189 await this._persist(key, {
190 id: key,
191 manifest: withCapabilities(loaded.manifest, granted),
192 contribution: loaded.contribution,
193 enabled: true,
194 granted,
195 version: loaded.version,
196 installedAt: record.installedAt || this._now()
197 })
198 return { ok: true, driveKey: key, version: loaded.version, granted, escalationAccepted: added.length > 0 }
199 }
200
201 async uninstall (driveKey) {
202 const key = normalizeDriveKey(driveKey)
203 if (!key) return { removed: false }
204 const known = this._installs.delete(key)
205 const removed = this._registry.unregister(key) || known
206 await this._persist(key, null)
207 return { removed: !!removed, driveKey: key }
208 }
209
210 async _load (key) {
211 const manifestFile = await this._fetch(key, '/manifest.json')
212 const manifestBytes = manifestFile && manifestFile.content ? manifestFile.content : null
213 if (!manifestBytes || manifestBytes.length === 0) {
214 throw new PluginDriveError('manifest-unavailable', `No /manifest.json on drive ${key.slice(0, 12)}…`)
215 }
216 if (manifestBytes.length > MAX_MANIFEST_BYTES) {
217 throw new PluginDriveError('manifest-too-large', 'Plugin manifest exceeds the size cap')
218 }
219
220 let manifest
221 try {
222 manifest = JSON.parse(manifestBytes.toString('utf8'))
223 } catch {
224 throw new PluginDriveError('manifest-invalid', 'Plugin manifest is not valid JSON')
225 }
226
227 const pear = manifest && manifest.pear && typeof manifest.pear === 'object' ? manifest.pear : {}
228 const plugin = pear.plugin && typeof pear.plugin === 'object' ? pear.plugin : null
229 if (!plugin) throw new PluginDriveError('not-a-plugin', 'Manifest does not declare pear.plugin')
230
231 const capabilities = normalizeCapabilityList(
232 [].concat(plugin.capabilities || [], pear.capabilities || [], manifest.capabilities || [])
233 )
234 const content = plugin.content && typeof plugin.content === 'object' ? plugin.content : {}
235 const contribution = {}
236
237 if (content.styles && typeof content.styles === 'object' && typeof content.styles.path === 'string') {
238 const css = await this._readAsset(key, content.styles.path, MAX_ASSET_BYTES)
239 contribution.styles = { matches: normalizeMatches(content.styles.matches), css }
240 }
241 if (content.scripts && typeof content.scripts === 'object' && typeof content.scripts.path === 'string') {
242 const js = await this._readAsset(key, content.scripts.path, MAX_ASSET_BYTES)
243 contribution.scripts = { matches: normalizeMatches(content.scripts.matches), js }
244 }
245 if (typeof content.filters === 'string' && content.filters.startsWith('/')) {
246 contribution.filters = await this._readAsset(key, content.filters, MAX_FILTER_BYTES)
247 }
248
249 return {
250 manifest,
251 capabilities,
252 contribution,
253 version: cleanString(manifest.version) || '0.0.0'
254 }
255 }
256
257 async _readAsset (key, path, maxBytes) {
258 if (typeof path !== 'string' || !path.startsWith('/') || path.includes('..')) {
259 throw new PluginDriveError('asset-path-invalid', `Invalid plugin asset path: ${path}`)
260 }
261 const fetched = await this._fetch(key, path)
262 const content = fetched && fetched.content ? fetched.content : null
263 if (!content || content.length === 0) {
264 throw new PluginDriveError('asset-unavailable', `Declared plugin asset ${path} is missing`)
265 }
266 if (content.length > maxBytes) {
267 throw new PluginDriveError('asset-too-large', `Plugin asset ${path} exceeds ${maxBytes} bytes`)
268 }
269 return content.toString('utf8')
270 }
271
272 async _persist (key, payload) {
273 try {
274 await this._persistInstall(key, payload)
275 } catch {}
276 }
277
278 async _persistRecordOnly (key, record) {
279 // Escalation state rides the same durable payload; merge via callback.
280 try {
281 await this._persistInstall(key, {
282 __recordPatch: true,
283 granted: record.granted,
284 version: record.version,
285 installedAt: record.installedAt,
286 escalated: record.escalated
287 })
288 } catch {}
289 }
290}
291
292function withCapabilities (manifest, capabilities) {
293 // The registry derives capabilities from the manifest; scope registration
294 // to the GRANTED set so an ungranted capability never reaches the engine.
295 const clone = JSON.parse(JSON.stringify(manifest || {}))
296 if (!clone.pear || typeof clone.pear !== 'object') clone.pear = {}
297 if (!clone.pear.plugin || typeof clone.pear.plugin !== 'object') clone.pear.plugin = {}
298 clone.pear.plugin.capabilities = [...capabilities]
299 delete clone.pear.capabilities
300 delete clone.capabilities
301 return clone
302}
303
304function normalizeCapabilityList (value) {
305 if (!Array.isArray(value)) return []
306 return [...new Set(value.map(item => typeof item === 'string' ? item.trim() : '').filter(Boolean))]
307}
308
309function normalizeMatches (value) {
310 if (!Array.isArray(value)) return ['*']
311 const matches = value.map(item => typeof item === 'string' ? item.trim().toLowerCase() : '').filter(Boolean)
312 return matches.length ? matches : ['*']
313}
314
315function normalizeDriveKey (value) {
316 const text = typeof value === 'string' ? value.trim().toLowerCase() : ''
317 return /^[0-9a-f]{64}$/.test(text) ? text : ''
318}
319
320function cleanString (value) {
321 return typeof value === 'string' ? value.trim().slice(0, 256) : ''
322}
323
324module.exports = {
325 PluginDriveLoader,
326 PluginDriveError,
327 MAX_ASSET_BYTES,
328 MAX_FILTER_BYTES
329}
backend/shield-list-sync.cjs +254 -0
……@@ -0,0 +1,254 @@
1'use strict'
2
3/**
4 * Shield list sync — P2P filter-list distribution (Phase 2 gate of
5 * docs/BROWSER_PARITY_PLAN.md).
6 *
7 * A filter list is published as an ordinary Hyperdrive (see
8 * filter-lists/README.md and scripts/publish-and-pin.js):
9 *
10 * /filters.txt the rules (Content Shield syntax subset)
11 * /manifest.json optional: { name, version, filters, sha256 }
12 *
13 * The browser subscribes by drive key. Rule text is registered on the
14 * shield under the namespaced list `drive:<key>` — which the shield
15 * already persists through exportListState() — so a synced list keeps
16 * working fully offline after first sync. This module owns only the
17 * subscription metadata (version/hash bookkeeping) and the hot-swap.
18 *
19 * Transport, hashing, and persistence are injected so the module runs
20 * identically under Bare (bare-crypto, hyper-proxy hybrid fetch,
21 * user-data settings) and under node:test fakes.
22 */
23
24const DRIVE_LIST_PREFIX = 'drive:'
25const MAX_LIST_BYTES = 2 * 1024 * 1024 // a converted EasyList fits well within this
26const MAX_MANIFEST_BYTES = 64 * 1024
27
28class ShieldListSyncError extends Error {
29 constructor (code, message) {
30 super(message)
31 this.name = 'ShieldListSyncError'
32 this.code = code
33 }
34}
35
36class ShieldListSync {
37 /**
38 * @param {object} opts
39 * @param {object} opts.shield — ContentShield (addList/removeList)
40 * @param {function} opts.fetchDriveFile — async (driveKeyHex, path) => { content: Buffer } | null
41 * @param {function} [opts.sha256Hex] — (bufferOrString) => hex string; required to verify checksums
42 * @param {function} [opts.persistMeta] — async (metaByKey) => void
43 * @param {function} [opts.now] — clock injection for tests
44 */
45 constructor (opts = {}) {
46 if (!opts.shield || typeof opts.shield.addList !== 'function') {
47 throw new TypeError('ShieldListSync requires a ContentShield')
48 }
49 if (typeof opts.fetchDriveFile !== 'function') {
50 throw new TypeError('ShieldListSync requires a fetchDriveFile transport')
51 }
52 this._shield = opts.shield
53 this._fetch = opts.fetchDriveFile
54 this._sha256Hex = typeof opts.sha256Hex === 'function' ? opts.sha256Hex : null
55 this._persistMeta = typeof opts.persistMeta === 'function' ? opts.persistMeta : async () => {}
56 this._now = typeof opts.now === 'function' ? opts.now : Date.now
57 this._meta = new Map() // driveKeyHex -> { name, version, sha256, updatedAt, rules }
58 this._timer = null
59 }
60
61 /** Rehydrate subscription metadata from durable settings (offline boot). */
62 restore (metaByKey) {
63 if (!metaByKey || typeof metaByKey !== 'object') return 0
64 let restored = 0
65 for (const [key, meta] of Object.entries(metaByKey)) {
66 const driveKey = normalizeDriveKey(key)
67 if (!driveKey || !meta || typeof meta !== 'object') continue
68 this._meta.set(driveKey, {
69 name: cleanString(meta.name) || shortListName(driveKey),
70 version: cleanString(meta.version),
71 sha256: cleanString(meta.sha256),
72 updatedAt: Number.isFinite(meta.updatedAt) ? meta.updatedAt : 0,
73 rules: Number.isFinite(meta.rules) ? meta.rules : 0
74 })
75 restored++
76 }
77 return restored
78 }
79
80 subscriptions () {
81 return [...this._meta.entries()].map(([driveKey, meta]) => ({
82 driveKey,
83 listName: DRIVE_LIST_PREFIX + driveKey,
84 ...meta
85 }))
86 }
87
88 isSubscribed (driveKey) {
89 const key = normalizeDriveKey(driveKey)
90 return !!key && this._meta.has(key)
91 }
92
93 /**
94 * Subscribe to a filter-list drive: first fetch, verify, register, persist.
95 * Throws ShieldListSyncError on transport/validation failure (fail-closed:
96 * nothing is registered on error).
97 */
98 async subscribe (driveKey) {
99 const key = normalizeDriveKey(driveKey)
100 if (!key) throw new ShieldListSyncError('invalid-drive-key', 'A 64-hex filter-list drive key is required')
101 const result = await this._pull(key, { force: true })
102 return { driveKey: key, ...this._meta.get(key), ...result }
103 }
104
105 async unsubscribe (driveKey) {
106 const key = normalizeDriveKey(driveKey)
107 if (!key || !this._meta.has(key)) return { removed: false }
108 this._meta.delete(key)
109 this._shield.removeList(DRIVE_LIST_PREFIX + key)
110 await this._persist()
111 return { removed: true, driveKey: key }
112 }
113
114 /**
115 * Re-fetch one subscription. Skips the rules download when the published
116 * manifest version and checksum match what is already applied (hot-swap
117 * only on change). `force` re-applies regardless.
118 */
119 async refresh (driveKey, opts = {}) {
120 const key = normalizeDriveKey(driveKey)
121 if (!key || !this._meta.has(key)) {
122 throw new ShieldListSyncError('not-subscribed', 'Not subscribed to that filter-list drive')
123 }
124 return this._pull(key, { force: !!opts.force })
125 }
126
127 /** Refresh every subscription; per-drive failures never abort the sweep. */
128 async refreshAll (opts = {}) {
129 const outcomes = []
130 for (const key of [...this._meta.keys()]) {
131 try {
132 const result = await this._pull(key, { force: !!opts.force })
133 outcomes.push({ driveKey: key, ok: true, ...result })
134 } catch (err) {
135 outcomes.push({ driveKey: key, ok: false, code: err.code || 'refresh-failed', message: err.message })
136 }
137 }
138 return outcomes
139 }
140
141 /** Periodic background refresh. Cleared by stop(); never holds the loop open. */
142 startAutoRefresh (intervalMs) {
143 const interval = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 0
144 if (!interval) return false
145 this.stop()
146 this._timer = setInterval(() => {
147 this.refreshAll().catch(() => {})
148 }, interval)
149 if (typeof this._timer?.unref === 'function') this._timer.unref()
150 return true
151 }
152
153 stop () {
154 if (!this._timer) return
155 clearInterval(this._timer)
156 this._timer = null
157 }
158
159 async _pull (key, { force }) {
160 const manifest = await this._readManifest(key)
161 const existing = this._meta.get(key)
162
163 if (!force && existing && manifest && manifest.version && manifest.version === existing.version &&
164 (!manifest.sha256 || manifest.sha256 === existing.sha256)) {
165 return { changed: false, version: existing.version }
166 }
167
168 const filtersPath = manifest && manifest.filters ? manifest.filters : '/filters.txt'
169 const fetched = await this._fetch(key, filtersPath)
170 const content = fetched && fetched.content ? fetched.content : null
171 if (!content || content.length === 0) {
172 throw new ShieldListSyncError('list-unavailable', `Filter list ${filtersPath} not found on drive ${key.slice(0, 12)}…`)
173 }
174 if (content.length > MAX_LIST_BYTES) {
175 throw new ShieldListSyncError('list-too-large', `Filter list exceeds ${MAX_LIST_BYTES} bytes`)
176 }
177
178 const text = content.toString('utf8')
179 const contentHash = this._sha256Hex ? this._sha256Hex(content) : ''
180 if (manifest && manifest.sha256 && this._sha256Hex && contentHash !== manifest.sha256) {
181 throw new ShieldListSyncError('checksum-mismatch', 'Filter list bytes do not match the published sha256')
182 }
183
184 if (!force && existing && contentHash && existing.sha256 === contentHash) {
185 // Same bytes under a version-less manifest — nothing to swap.
186 this._meta.set(key, { ...existing, updatedAt: this._now() })
187 await this._persist()
188 return { changed: false, version: existing.version }
189 }
190
191 const applied = this._shield.addList(DRIVE_LIST_PREFIX + key, text)
192 const meta = {
193 name: (manifest && cleanString(manifest.name)) || shortListName(key),
194 version: (manifest && cleanString(manifest.version)) || '',
195 sha256: (manifest && cleanString(manifest.sha256)) || contentHash,
196 updatedAt: this._now(),
197 rules: (applied && applied.blockRules + applied.exceptionRules + applied.cosmeticRules) || 0
198 }
199 this._meta.set(key, meta)
200 await this._persist()
201 return { changed: true, version: meta.version, rules: meta.rules }
202 }
203
204 async _readManifest (key) {
205 let fetched
206 try {
207 fetched = await this._fetch(key, '/manifest.json')
208 } catch {
209 return null
210 }
211 const content = fetched && fetched.content ? fetched.content : null
212 if (!content || content.length === 0 || content.length > MAX_MANIFEST_BYTES) return null
213 try {
214 const parsed = JSON.parse(content.toString('utf8'))
215 if (!parsed || typeof parsed !== 'object') return null
216 return {
217 name: cleanString(parsed.name),
218 version: cleanString(parsed.version),
219 sha256: cleanString(parsed.sha256).toLowerCase(),
220 filters: typeof parsed.filters === 'string' && parsed.filters.startsWith('/') ? parsed.filters : null
221 }
222 } catch {
223 return null
224 }
225 }
226
227 async _persist () {
228 const meta = {}
229 for (const [key, value] of this._meta) meta[key] = value
230 try {
231 await this._persistMeta(meta)
232 } catch {}
233 }
234}
235
236function normalizeDriveKey (value) {
237 const text = typeof value === 'string' ? value.trim().toLowerCase() : ''
238 return /^[0-9a-f]{64}$/.test(text) ? text : ''
239}
240
241function cleanString (value) {
242 return typeof value === 'string' ? value.trim().slice(0, 256) : ''
243}
244
245function shortListName (key) {
246 return `list ${key.slice(0, 8)}…`
247}
248
249module.exports = {
250 ShieldListSync,
251 ShieldListSyncError,
252 DRIVE_LIST_PREFIX,
253 MAX_LIST_BYTES
254}
catalogues/pear-plugins/plugins.json +36 -0
……@@ -0,0 +1,36 @@
1{
2 "name": "Pear Plugins",
3 "description": "The browser-curated plugin catalogue. Publish this directory as a Hyperdrive (scripts/publish-and-pin.js catalogues/pear-plugins --name pear-plugins-catalog) and subscribers hot-load it from Settings. Entries with an empty driveKey are placeholders: publish the plugin drive first, paste its key here, bump nothing else, and re-publish this catalogue.",
4 "plugins": [
5 {
6 "id": "anongpt",
7 "kind": "app",
8 "driveKey": "e3cf8b6fae6260608cbfcdf6b82d985c65f5ad1b9c85e777e296e7c521213abc",
9 "name": "anonGPT",
10 "description": "Private AI chat as a P2P app. Uses the window.pear.anongpt bridge; PearBrowser verifies the app's no-prompt-storage privacy manifest before the bridge is injected, and only on this drive.",
11 "author": "anonGPT",
12 "capabilities": ["pear.anongpt.infer"],
13 "verified": true
14 },
15 {
16 "id": "pear-dark-reader",
17 "kind": "plugin",
18 "driveKey": "bbde8330169798dc5e0d08f8909b407cea2f8fec7e31d6241f479c714ad42082",
19 "name": "Pear Dark Reader",
20 "description": "Dark, high-contrast theme for every hyper:// page. Styles only — no scripts, no network rules. Source: examples/plugins/dark-reader.",
21 "author": "PearBrowser examples",
22 "capabilities": ["pear.content.styles"],
23 "verified": true
24 },
25 {
26 "id": "peerit-enhancer",
27 "kind": "plugin",
28 "driveKey": "1b21d8a6960bdcdfb76da94b80dae0d1a28247516de87e6839ea2f87bb609e10",
29 "name": "peerit Enhancer",
30 "description": "Compact rows, reading-width cap, and j/k keyboard navigation for peerit. Scoped to the peerit drive only. Source: examples/plugins/peerit-enhancer.",
31 "author": "PearBrowser examples",
32 "capabilities": ["pear.content.styles", "pear.content.scripts", "pear.net.filter"],
33 "verified": true
34 }
35 ]
36}
docs/BROWSER_PARITY_PLAN.md +4 -4
……@@ -1,7 +1,7 @@
11# PearBrowser → mainstream-browser parity plan
22
3Status: Phases 1–3 shipped; Phase 4 clearnet proxy + session-bridge facade shipped; Phase 5 privacy ladder shipped (HTTPS-only, tracking strip, farbling, cookie isolation in proxy mode). Native pear-electron session.webRequest still optional for direct-mode shields.
4Date: 2026-07-13
3Status: Phases 1–3 shipped with gates closed; Phase 4 clearnet proxy + session-bridge facade shipped; Phase 5 privacy ladder shipped (HTTPS-only, tracking strip, farbling, cookie isolation in proxy mode). P2P distribution live 2026-07-16: filter-list drive subscriptions (`shield-list-sync.cjs`) and plugin installs from drives (`plugin-drive-loader.cjs` with the capability-escalation guard), plus the plugin catalogue (`plugin-catalog.cjs`, anonGPT in the builtin seed). All four distribution drives published, pinned, and fresh-peer verified — keys in `filter-lists/README.md`. Remaining: the native pear-electron session.webRequest bridge for direct-mode shields.
4Date: 2026-07-16
55
66The goal: make PearBrowser as capable a daily browser as Brave — ad blocking,
77tracker protection, and a plugin/extension system — without giving up what
……@@ -235,8 +235,8 @@ layer to host real web content:
235235| Phase | Scope | Gate to advance |
236236|---|---|---|
237237| 1 (done) | Shield engine + proxy wiring + settings + tests | full desktop suite green |
238| 2 | P2P lists, shield panel, strict mode, allowlists | list drive pinned + hot-swap proven offline |
239| 3 | Plugin manifest vocabulary, content styles/scripts, panels, Permission Center | two real plugins (e.g. a dark-reader and a peerit enhancer) shipped from drives |
238| 2 (**gate closed 2026-07-16**) | P2P lists (`shield-list-sync.cjs`, subscribe/refresh/offline restore, Settings UI), shield panel, strict mode, allowlists | pear-default list drive published + pinned + fresh-peer verified (`842fb9e6…`, see `filter-lists/README.md`); hot-swap + offline proven in `test/shield-list-sync.test.js` |
239| 3 (**gate closed 2026-07-16**) | Plugin manifest vocabulary, drive installs (`plugin-drive-loader.cjs`), escalation guard, kill switch, Settings UI, plugin catalogue (`plugin-catalog.cjs` — builtin seed with anonGPT + drive-loadable `/plugins.json` sources, one-click install/open UI) | two real plugins shipped from drives: dark-reader `bbde8330…` + peerit-enhancer `1b21d8a6…`, catalogue drive `01b74736…`, all pinned + fresh-peer verified |
240240| 4 | pear-electron session bridge, clearnet tabs, native shield | upstream decision or maintained patch + release smoke on all targets |
241241| 5 | privacy ladder (HTTPS-only, fingerprinting, cookies) | per-feature web-compat smoke |
242242
examples/plugins/dark-reader/manifest.json +14 -0
……@@ -0,0 +1,14 @@
1{
2 "name": "Pear Dark Reader",
3 "version": "1.0.0",
4 "description": "Applies a dark, high-contrast theme to every hyper:// page. Styles only — requests nothing else.",
5 "author": "PearBrowser examples",
6 "pear": {
7 "plugin": {
8 "capabilities": ["pear.content.styles"],
9 "content": {
10 "styles": { "matches": ["*"], "path": "/style.css" }
11 }
12 }
13 }
14}
examples/plugins/dark-reader/style.css +26 -0
……@@ -0,0 +1,26 @@
1/* Pear Dark Reader — example Pear Plugin (pear.content.styles).
2 *
3 * A deliberately simple inversion-based dark theme: dark canvas, light text,
4 * media flipped back to normal. Real dark-reader engines analyze computed
5 * styles; this example shows the plugin surface, not CSS science. */
6
7:root {
8 color-scheme: dark;
9}
10
11html {
12 background: #101418 !important;
13 filter: invert(1) hue-rotate(180deg);
14}
15
16/* Flip media and embeds back so photos and video look natural. */
17img, picture, video, canvas, svg, iframe, embed, object,
18[style*="background-image"] {
19 filter: invert(1) hue-rotate(180deg);
20}
21
22/* Keep selection readable through the double inversion. */
23::selection {
24 background: #2b5f8a;
25 color: #ffffff;
26}
examples/plugins/peerit-enhancer/content.js +36 -0
……@@ -0,0 +1,36 @@
1/* peerit Enhancer content script — example Pear Plugin surface.
2 *
3 * Runs only on the peerit drive (matches in manifest.json). Adds j/k
4 * keyboard navigation between posts. Content scripts are injected
5 * hash-authorized through the browser's CSP pipeline, so these exact
6 * bytes are what the user approved at install time. */
7(() => {
8 'use strict'
9 if (window.__peeritEnhancer) return
10 window.__peeritEnhancer = true
11
12 const postSelector = '.post, .post-row, article'
13 let cursor = -1
14
15 function posts () {
16 return [...document.querySelectorAll(postSelector)]
17 }
18
19 function focusPost (index) {
20 const all = posts()
21 if (!all.length) return
22 cursor = Math.max(0, Math.min(all.length - 1, index))
23 const el = all[cursor]
24 el.scrollIntoView({ block: 'center', behavior: 'smooth' })
25 for (const other of all) other.style.outline = ''
26 el.style.outline = '2px solid rgba(94, 176, 239, 0.65)'
27 el.style.outlineOffset = '2px'
28 }
29
30 window.addEventListener('keydown', (event) => {
31 if (event.target && /^(input|textarea|select)$/i.test(event.target.tagName)) return
32 if (event.metaKey || event.ctrlKey || event.altKey) return
33 if (event.key === 'j') focusPost(cursor + 1)
34 else if (event.key === 'k') focusPost(cursor - 1)
35 }, { passive: true })
36})()
examples/plugins/peerit-enhancer/filters.txt +5 -0
……@@ -0,0 +1,5 @@
1! peerit Enhancer filter contribution (pear.net.filter).
2! Namespaced under plugin:<drive-key> by the shield; the kill switch
3! or uninstall removes these rules instantly.
4ec6e2d6d9d22b9d6b40e11a9ca3042be3197e4bdca9e9a7f079be6ee830761b4##.promoted-post
5ec6e2d6d9d22b9d6b40e11a9ca3042be3197e4bdca9e9a7f079be6ee830761b4##.self-promo-banner
examples/plugins/peerit-enhancer/manifest.json +26 -0
……@@ -0,0 +1,26 @@
1{
2 "name": "peerit Enhancer",
3 "version": "1.0.0",
4 "description": "Quality-of-life tweaks for peerit (the front page of the P2P internet): compact post rows, a reading-width cap, keyboard hint chrome, and a small filter contribution. Scoped to the peerit drive only.",
5 "author": "PearBrowser examples",
6 "pear": {
7 "plugin": {
8 "capabilities": [
9 "pear.content.styles",
10 "pear.content.scripts",
11 "pear.net.filter"
12 ],
13 "content": {
14 "styles": {
15 "matches": ["ec6e2d6d9d22b9d6b40e11a9ca3042be3197e4bdca9e9a7f079be6ee830761b4"],
16 "path": "/style.css"
17 },
18 "scripts": {
19 "matches": ["ec6e2d6d9d22b9d6b40e11a9ca3042be3197e4bdca9e9a7f079be6ee830761b4"],
20 "path": "/content.js"
21 },
22 "filters": "/filters.txt"
23 }
24 }
25 }
26}
examples/plugins/peerit-enhancer/style.css +30 -0
……@@ -0,0 +1,30 @@
1/* peerit Enhancer — example Pear Plugin, scoped to the peerit drive.
2 * Compact rows and a reading-width cap; nothing here runs anywhere else. */
3
4main, .feed, .posts {
5 max-width: 780px;
6 margin-left: auto !important;
7 margin-right: auto !important;
8}
9
10.post, .post-row, article {
11 padding-top: 6px !important;
12 padding-bottom: 6px !important;
13 line-height: 1.45;
14}
15
16/* Badge so it's obvious the enhancer is active during the example demo. */
17body::after {
18 content: "peerit enhancer active";
19 position: fixed;
20 right: 10px;
21 bottom: 10px;
22 font: 11px system-ui, sans-serif;
23 color: #9aa7b0;
24 background: rgba(20, 26, 32, 0.85);
25 border: 1px solid rgba(154, 167, 176, 0.3);
26 border-radius: 999px;
27 padding: 3px 10px;
28 pointer-events: none;
29 z-index: 2147483647;
30}
filter-lists/README.md +112 -0
……@@ -0,0 +1,112 @@
1# P2P filter lists and Pear Plugins — publishing and subscribing
2
3## Live drives (published, pinned, fresh-peer verified 2026-07-16)
4
5| Artifact | Drive key | Publisher storage (`03-sites/pearbrowser-publishers/`) |
6|---|---|---|
7| pear-default filter list | `842fb9e64c1c2092ec426151fd4f9ffb23a2efcae26ff3dd61d5d564ed58d99f` | `seed-pear-default-filters` |
8| Pear Dark Reader plugin | `bbde8330169798dc5e0d08f8909b407cea2f8fec7e31d6241f479c714ad42082` | `seed-pear-dark-reader` |
9| peerit Enhancer plugin | `1b21d8a6960bdcdfb76da94b80dae0d1a28247516de87e6839ea2f87bb609e10` | `seed-peerit-enhancer` |
10| Pear Plugins catalogue | `01b7473601a6a6a58ec240b1c4ef0cdcf1aef0f6f8bf7ff16636faecb640ad13` | `seed-pear-plugins-catalog` |
11
12Subscribe to the list: Settings → Content Shield → *Filter lists from the
13swarm* → paste `842fb9e6…`. Load the catalogue: *Plugin catalog* → paste
14`01b74736…`. (The builtin catalogue seed already carries the plugin keys, so
15Install works out of the box; the catalogue drive is for distribution beyond
16this build.) Each was published with `publish-and-pin.js`, durably
17replicated with `reseed-drive.js --hold 90`, and confirmed with
18`verify-pin.js` from a fresh peer.
19
20Content Shield rules and Pear Plugins are distributed the same way as every
21other PearBrowser artifact: as Hyperdrives, pinned on HiveRelay, synced
22peer-to-peer. No CDN, no vendor list-fetch fingerprint, fully offline after
23first sync.
24
25## Filter lists
26
27A filter-list drive contains:
28
29```text
30/filters.txt the rules (Content Shield syntax subset)
31/manifest.json { name, version, filters, sha256, rules, builtAt }
32```
33
34Build (regenerates the manifest checksum) and publish:
35
36```sh
37node scripts/build-shield-list.mjs filter-lists/pear-default --name pear-default
38node scripts/publish-and-pin.js filter-lists/pear-default --name pear-default-filters
39```
40
41Ship an update: edit `filters.txt`, rebuild with `--bump`, re-publish with the
42original `--key` and `--storage`. Subscribed browsers verify the sha256 and
43hot-swap on their next refresh sweep (every 30 minutes, or immediately via
44**Refresh** in Settings → Content Shield).
45
46Subscribe in the browser: Settings → Content Shield → *Filter lists from the
47swarm* → paste the drive key. Rule text is persisted locally, so the list
48keeps blocking with no network at all; the subscription metadata
49(version/checksum) lives in user-data settings.
50
51## Pear Plugins
52
53A plugin drive is an ordinary drive with plugin metadata in `/manifest.json`
54(see `examples/plugins/*`):
55
56```json
57{
58 "name": "Pear Dark Reader",
59 "version": "1.0.0",
60 "pear": {
61 "plugin": {
62 "capabilities": ["pear.content.styles"],
63 "content": {
64 "styles": { "matches": ["*"], "path": "/style.css" },
65 "scripts": { "matches": ["<drive-key>"], "path": "/content.js" },
66 "filters": "/filters.txt"
67 }
68 }
69 }
70}
71```
72
73`matches` entries are document hosts — for hyper:// pages that is the drive
74key, so a plugin scoped to one app names that app's drive key; `"*"` matches
75everywhere. Publish exactly like a list:
76
77```sh
78node scripts/publish-and-pin.js examples/plugins/dark-reader --name pear-dark-reader
79node scripts/publish-and-pin.js examples/plugins/peerit-enhancer --name peerit-enhancer
80```
81
82Install in the browser: Settings → Content Shield → *Pear Plugins* → paste
83the plugin drive key. The consent surface shows the capability list; the
84grant is recorded at install time. **A drive update that requests new
85capabilities is disabled automatically** and flagged for explicit
86re-approval — swarm updates can never silently escalate a plugin's power.
87Styles and scripts are injected hash-authorized through the CSP pipeline, so
88the running bytes are provably the installed bytes.
89
90## The plugin catalogue
91
92Users shouldn't need drive keys. Settings → Content Shield → *Plugin
93catalog* lists curated entries with one-click actions: **Install** for
94plugin drives (runs the normal grant flow) and **Open** for `kind: "app"`
95entries — AI add-on apps like **anonGPT**, which ships in the builtin seed
96with its production drive key so anyone can add it on their own. The
97catalogue is metadata only: it grants nothing, and app entries stay gated by
98their own manifests (anonGPT's bridge still requires its privacy-claims
99manifest check, on its drive only).
100
101Catalogues are themselves P2P: any drive with a `/plugins.json` (see
102`catalogues/pear-plugins/plugins.json`) can be loaded as an extra source
103from the same Settings block, and subscribed sources persist offline.
104Publish the curated catalogue:
105
106```sh
107node scripts/publish-and-pin.js catalogues/pear-plugins --name pear-plugins-catalog
108```
109
110After publishing the two example plugin drives, paste their keys into the
111`driveKey` fields of `plugins.json` and re-publish the catalogue — every
112subscriber's Install buttons light up on their next load.
filter-lists/pear-default/filters.txt +81 -0
……@@ -0,0 +1,81 @@
1! PearBrowser default shield list (pear-default)
2! Published as a Hyperdrive; see filter-lists/README.md.
3! Syntax: Content Shield subset of Adblock Plus + hosts lines
4! (||host^, substrings, @@ exceptions, ##selector, host##selector,
5! ##+js(scriptlet, args), 0.0.0.0 host).
6
7! --- Core ad networks ---
8||doubleclick.net^
9||googlesyndication.com^
10||googleadservices.com^
11||adservice.google.com^
12||amazon-adsystem.com^
13||adsystem.com^
14||adnxs.com^
15||rubiconproject.com^
16||pubmatic.com^
17||openx.net^
18||criteo.com^
19||criteo.net^
20||taboola.com^
21||outbrain.com^
22||moatads.com^
23||adroll.com^
24||adform.net^
25||smartadserver.com^
26||yieldmo.com^
27||sharethrough.com^
28||spotxchange.com^
29||teads.tv^
30||media.net^
31||bidswitch.net^
32||casalemedia.com^
33||33across.com^
34||gumgum.com^
35||sonobi.com^
36||triplelift.com^
37
38! --- Analytics and session recording ---
39||googletagmanager.com^
40||google-analytics.com^
41||hotjar.com^
42||mixpanel.com^
43||segment.io^
44||segment.com/v1^
45||fullstory.com^
46||mouseflow.com^
47||clarity.ms^
48||chartbeat.com^
49||scorecardresearch.com^
50||quantserve.com^
51||amplitude.com/2/httpapi^
52||newrelic.com/nr-data^
53||mc.yandex.ru^
54||yandex.ru/metrika^
55||matomo.cloud^
56||plausible.io/api/event^
57
58! --- Social trackers ---
59||connect.facebook.net^
60||facebook.com/tr^
61||ads-twitter.com^
62||static.ads-twitter.com^
63||analytics.tiktok.com^
64||snap.licdn.com^
65||px.ads.linkedin.com^
66
67! --- Generic path rules ---
68/adsbygoogle.
69/pagead/js/
70/ads/banner/
71/pixel/track
72
73! --- Cosmetic ---
74##.adsbygoogle
75##.ad-banner
76##.sponsored-content
77##[id^="google_ads_iframe"]
78##iframe[src*="doubleclick.net"]
79
80! --- Exceptions (first-party widgets that break without these) ---
81@@||maps.googleapis.com^
filter-lists/pear-default/manifest.json +8 -0
……@@ -0,0 +1,8 @@
1{
2 "name": "pear-default",
3 "version": "1",
4 "filters": "/filters.txt",
5 "sha256": "40894dd043fd10c10775fd190957d96468cb6b442d36059730e924e2ade6b07c",
6 "rules": 64,
7 "builtAt": "2026-07-16T08:57:14.950Z"
8}
scripts/build-shield-list.mjs +69 -0
……@@ -0,0 +1,69 @@
1/**
2 * Build a filter-list drive directory: (re)generate manifest.json with the
3 * sha256 of filters.txt so subscribers can verify the bytes they sync.
4 *
5 * Usage:
6 * node scripts/build-shield-list.mjs filter-lists/pear-default [--name pear-default] [--bump]
7 *
8 * Then publish + pin the directory as a Hyperdrive:
9 * node scripts/publish-and-pin.js filter-lists/pear-default --name pear-default-filters
10 *
11 * Re-publishing an update: edit filters.txt, re-run this script with --bump
12 * (increments the integer version), then publish-and-pin with the original
13 * --key and --storage. Subscribed browsers hot-swap on their next refresh.
14 */
15
16import { createHash } from 'node:crypto'
17import { readFileSync, writeFileSync, existsSync } from 'node:fs'
18import { join, basename, resolve } from 'node:path'
19
20const args = process.argv.slice(2)
21const dir = args.find(a => !a.startsWith('--'))
22const bump = args.includes('--bump')
23const nameFlag = args.indexOf('--name')
24const name = nameFlag !== -1 ? args[nameFlag + 1] : null
25
26if (!dir) {
27 console.error('usage: node scripts/build-shield-list.mjs <list-dir> [--name <list-name>] [--bump]')
28 process.exit(2)
29}
30
31const listDir = resolve(dir)
32const filtersPath = join(listDir, 'filters.txt')
33const manifestPath = join(listDir, 'manifest.json')
34
35if (!existsSync(filtersPath)) {
36 console.error(`no filters.txt in ${listDir}`)
37 process.exit(2)
38}
39
40const filters = readFileSync(filtersPath)
41const sha256 = createHash('sha256').update(filters).digest('hex')
42
43let previous = {}
44if (existsSync(manifestPath)) {
45 try { previous = JSON.parse(readFileSync(manifestPath, 'utf8')) } catch {}
46}
47
48const previousVersion = Number.parseInt(previous.version, 10)
49const version = bump && Number.isFinite(previousVersion)
50 ? String(previousVersion + 1)
51 : (previous.version && !bump ? String(previous.version) : '1')
52
53const manifest = {
54 name: name || previous.name || basename(listDir),
55 version,
56 filters: '/filters.txt',
57 sha256,
58 rules: filters.toString('utf8').split('\n').filter(line => {
59 const l = line.trim()
60 return l && !l.startsWith('!') && !l.startsWith('[')
61 }).length,
62 builtAt: new Date().toISOString()
63}
64
65writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
66console.log(`${manifest.name} v${manifest.version} — ${manifest.rules} rules, sha256 ${sha256.slice(0, 16)}…`)
67console.log(`wrote ${manifestPath}`)
68console.log('publish with:')
69console.log(` node scripts/publish-and-pin.js ${dir} --name ${manifest.name}-filters`)
test/clearnet-proxy.test.js +1 -1
……@@ -44,7 +44,7 @@ test('rewriteHtmlForProxy rewrites href/src to proxy paths', () => {
4444 </body></html>`
4545 const out = rewriteHtmlForProxy(html, 'https://example.com/page', 'http://127.0.0.1:9')
4646 assert.match(out, /\/clearnet\//)
47 assert.match(out, /pear-clearnet-origin/)
47 assert.match(out, /<meta name="pear-clearnet-origin" content="https:\/\/example\.com">/)
4848 assert.doesNotMatch(out, /href="\/next"/)
4949 // Absolute CDN URL is base64url-encoded into the proxy path
5050 assert.match(out, /clearnet\/[A-Za-z0-9_-]+/)
test/clearnet-ui-wiring.test.js +2 -0
……@@ -35,6 +35,8 @@ test('shell ships clearnet chrome, privacy card, and navigate kind handling', ()
3535 assert.match(shell, /fingerprintFarbling/)
3636 assert.match(shell, /clearnet-iframe-proxy/)
3737 assert.match(shell, /kind === 'clearnet'/)
38 assert.match(shell, /<p>Fetching <code>\$\{t\.url\}<\/code> \$\{t\.kind/)
39 assert.doesNotMatch(shell, /\? html`Fetching <code>/)
3840 assert.match(shell, /CMD_PRIVACY_STATUS/)
3941 assert.match(shell, /example\.com/)
4042})
test/origin-isolation.test.js +23 -0
……@@ -148,6 +148,29 @@ test('HyperProxy assigns distinct feature-flagged loopback origins per drive', a
148148 })
149149})
150150
151test('HyperProxy injects a valid base URL when URL.origin is unavailable in Bare', async () => {
152 const NativeURL = globalThis.URL
153 class BareLikeURL extends NativeURL {
154 get origin () { return undefined }
155 }
156
157 globalThis.URL = BareLikeURL
158 try {
159 const proxy = new HyperProxy(async () => null, () => {})
160 proxy._port = 43210
161 const html = (await proxy._injectHtmlHead(
162 '<html><head></head><body></body></html>',
163 driveA,
164 `/hyper/${driveA}/index.html`
165 )).toString('utf8')
166
167 assert.ok(html.includes(`<base href="http://127.0.0.1:43210/hyper/${driveA}/">`))
168 assert.doesNotMatch(html, /<base href="undefined\//)
169 } finally {
170 globalThis.URL = NativeURL
171 }
172})
173
151174test('HyperProxy per-drive listeners serve only their bound drive key', async (t) => {
152175 const proxy = new HyperProxy(async () => null, () => {}, null, {
153176 perDriveOrigins: true
test/plugin-catalog.test.js +142 -0
……@@ -0,0 +1,142 @@
1import test from 'node:test'
2import assert from 'node:assert/strict'
3import { readFileSync } from 'node:fs'
4import { fileURLToPath } from 'node:url'
5import { dirname, join } from 'node:path'
6import { createRequire } from 'node:module'
7
8const require = createRequire(import.meta.url)
9const { PluginCatalog, PluginCatalogError, BUILTIN_PLUGIN_CATALOG, validateCatalogEntry } = require('../backend/plugin-catalog.cjs')
10const { ANONGPT_DRIVE_KEY } = require('../backend/constants.js')
11
12const root = join(dirname(fileURLToPath(import.meta.url)), '..')
13const CAT_KEY = 'f0'.repeat(32)
14
15function makeCatalog (files = {}, opts = {}) {
16 return new PluginCatalog({
17 fetchDriveFile: async (driveKey, path) => {
18 const value = files[path]
19 return value == null ? null : { content: Buffer.from(value) }
20 },
21 now: () => 4242,
22 ...opts
23 })
24}
25
26test('builtin seed lists anonGPT with its production drive key, installable by the user', () => {
27 const catalog = makeCatalog()
28 const entries = catalog.entries()
29
30 const anongpt = entries.find(entry => entry.id === 'anongpt')
31 assert.ok(anongpt, 'anonGPT is in the builtin catalogue')
32 assert.equal(anongpt.kind, 'app')
33 assert.equal(anongpt.driveKey, ANONGPT_DRIVE_KEY)
34 assert.equal(anongpt.verified, true)
35 assert.equal(anongpt.source, 'builtin')
36
37 // Example plugins carry their published drive keys (2026-07-16).
38 const darkReader = entries.find(entry => entry.id === 'pear-dark-reader')
39 assert.ok(darkReader)
40 assert.equal(darkReader.driveKey, 'bbde8330169798dc5e0d08f8909b407cea2f8fec7e31d6241f479c714ad42082')
41 assert.equal(darkReader.kind, 'plugin')
42 const enhancer = entries.find(entry => entry.id === 'peerit-enhancer')
43 assert.equal(enhancer.driveKey, '1b21d8a6960bdcdfb76da94b80dae0d1a28247516de87e6839ea2f87bb609e10')
44 assert.equal(BUILTIN_PLUGIN_CATALOG.length, 3)
45})
46
47test('a catalogue drive loads, validates entries, and merges after builtin', async () => {
48 const pluginKey = 'ab'.repeat(32)
49 const catalog = makeCatalog({
50 '/plugins.json': JSON.stringify({
51 name: 'Community Plugins',
52 plugins: [
53 { driveKey: pluginKey, name: 'Community Styler', kind: 'plugin', capabilities: ['pear.content.styles'], description: 'x' },
54 { driveKey: 'not-a-key', name: 'Broken entry' },
55 { name: 'No key at all' },
56 { driveKey: ANONGPT_DRIVE_KEY, name: 'anonGPT shadow attempt', kind: 'app' },
57 42
58 ]
59 })
60 })
61
62 const result = await catalog.loadFromDrive(CAT_KEY)
63 assert.equal(result.name, 'Community Plugins')
64 assert.equal(result.entryCount, 2) // styler + shadow attempt survive validation
65
66 const entries = catalog.entries()
67 const styler = entries.find(entry => entry.driveKey === pluginKey)
68 assert.equal(styler.source, CAT_KEY)
69
70 // Builtin wins the dedupe: the shadow entry cannot replace anonGPT.
71 const anongpt = entries.filter(entry => entry.driveKey === ANONGPT_DRIVE_KEY)
72 assert.equal(anongpt.length, 1)
73 assert.equal(anongpt[0].name, 'anonGPT')
74 assert.equal(anongpt[0].source, 'builtin')
75
76 assert.deepEqual(catalog.sources(), [{
77 driveKey: CAT_KEY, name: 'Community Plugins', entryCount: 2, loadedAt: 4242
78 }])
79})
80
81test('catalogue failures are typed and fail closed', async () => {
82 await assert.rejects(makeCatalog().loadFromDrive('nope'), err => err.code === 'invalid-drive-key')
83 await assert.rejects(makeCatalog({}).loadFromDrive(CAT_KEY), err => err.code === 'catalog-unavailable')
84 await assert.rejects(
85 makeCatalog({ '/plugins.json': 'not json' }).loadFromDrive(CAT_KEY),
86 err => err.code === 'catalog-invalid'
87 )
88 await assert.rejects(
89 makeCatalog({ '/plugins.json': JSON.stringify({ plugins: [{ name: 'keyless' }] }) }).loadFromDrive(CAT_KEY),
90 err => err.code === 'catalog-empty'
91 )
92 assert.ok(new PluginCatalogError('x', 'y') instanceof Error)
93})
94
95test('sources persist through exportState/restore and can be removed', async () => {
96 const pluginKey = 'cd'.repeat(32)
97 const first = makeCatalog({
98 '/plugins.json': JSON.stringify({
99 name: 'Community',
100 plugins: [{ driveKey: pluginKey, name: 'Styler', capabilities: ['pear.content.styles'] }]
101 })
102 })
103 await first.loadFromDrive(CAT_KEY)
104 const durable = first.exportState()
105
106 const second = makeCatalog({})
107 assert.equal(second.restore(durable), 1)
108 assert.ok(second.entries().some(entry => entry.driveKey === pluginKey))
109
110 assert.equal(second.removeSource(CAT_KEY), true)
111 assert.equal(second.entries().some(entry => entry.driveKey === pluginKey), false)
112 assert.deepEqual(second.exportState(), { sources: {} })
113})
114
115test('entry validation caps and normalizes fields', () => {
116 const entry = validateCatalogEntry({
117 driveKey: 'AB'.repeat(32),
118 name: ' Padded name ',
119 kind: 'weird-kind',
120 description: 'd'.repeat(2000),
121 capabilities: ['pear.content.styles', '', 42, 'x'.repeat(200), 'a', 'b', 'c', 'd', 'e', 'f'],
122 verified: 'yes'
123 })
124 assert.equal(entry.driveKey, 'ab'.repeat(32))
125 assert.equal(entry.name, 'Padded name')
126 assert.equal(entry.kind, 'plugin')
127 assert.equal(entry.description.length, 500)
128 assert.equal(entry.capabilities.length, 8)
129 assert.equal(entry.verified, false)
130 assert.equal(validateCatalogEntry({ driveKey: 'ab'.repeat(32) }), null) // no name
131})
132
133test('the shippable catalogue source parses and its anonGPT entry matches constants', async () => {
134 const raw = readFileSync(join(root, 'catalogues/pear-plugins/plugins.json'))
135 const catalog = makeCatalog({ '/plugins.json': raw })
136 const result = await catalog.loadFromDrive(CAT_KEY)
137 assert.ok(result.entryCount >= 1)
138
139 const parsed = JSON.parse(raw.toString('utf8'))
140 const anongpt = parsed.plugins.find(entry => entry.id === 'anongpt')
141 assert.equal(anongpt.driveKey, ANONGPT_DRIVE_KEY)
142})
test/plugin-drive-loader.test.js +201 -0
……@@ -0,0 +1,201 @@
1import test from 'node:test'
2import assert from 'node:assert/strict'
3import { createRequire } from 'node:module'
4
5const require = createRequire(import.meta.url)
6const { ContentShield } = require('../backend/content-shield.cjs')
7const { PearPluginRegistry } = require('../backend/pear-plugins.cjs')
8const { PluginDriveLoader, PluginDriveError, MAX_ASSET_BYTES } = require('../backend/plugin-drive-loader.cjs')
9
10const KEY = 'a1'.repeat(32)
11
12function pluginManifest (capabilities, content, version = '1.0.0') {
13 return JSON.stringify({
14 name: 'Fixture Plugin',
15 version,
16 pear: { plugin: { capabilities, content } }
17 })
18}
19
20function makeLoader (files) {
21 const shield = new ContentShield({ builtinList: false })
22 const registry = new PearPluginRegistry({ shield })
23 const persisted = new Map()
24 const store = { ...files }
25 const loader = new PluginDriveLoader({
26 registry,
27 fetchDriveFile: async (driveKey, path) => {
28 const value = store[path]
29 return value == null ? null : { content: Buffer.from(value) }
30 },
31 persistInstall: async (id, payload) => {
32 if (payload === null) persisted.delete(id)
33 else persisted.set(id, structuredClone(payload))
34 },
35 now: () => 99
36 })
37 return { loader, registry, shield, persisted, store }
38}
39
40test('install from a drive fetches assets and applies the contribution', async () => {
41 const { loader, registry, shield, persisted } = makeLoader({
42 '/manifest.json': pluginManifest(
43 ['pear.content.styles', 'pear.content.scripts', 'pear.net.filter'],
44 {
45 styles: { matches: ['*'], path: '/style.css' },
46 scripts: { matches: ['*'], path: '/content.js' },
47 filters: '/filters.txt'
48 }
49 ),
50 '/style.css': '.fixture-hide { display: none }',
51 '/content.js': 'window.__fixture = 1',
52 '/filters.txt': '||fixture-ads.example.com^'
53 })
54
55 const result = await loader.installFromDrive(KEY)
56 assert.equal(result.ok, true)
57 assert.equal(result.version, '1.0.0')
58 assert.deepEqual(result.granted, ['pear.content.styles', 'pear.content.scripts', 'pear.net.filter'])
59
60 const listed = registry.list().find(item => item.id === KEY)
61 assert.ok(listed)
62 assert.equal(listed.enabled, true)
63
64 assert.equal(shield.shouldBlockUrl('https://fixture-ads.example.com/x.js').blocked, true)
65 assert.ok(shield.pluginStylesFor('anything.example').includes('.fixture-hide'))
66 assert.ok(shield.pluginScriptsFor('anything.example').some(item => (item.body || '').includes('__fixture')))
67
68 const payload = persisted.get(KEY)
69 assert.equal(payload.version, '1.0.0')
70 assert.deepEqual(payload.granted, result.granted)
71})
72
73test('a narrower explicit grant strips ungranted capabilities before the engine', async () => {
74 const { loader, shield } = makeLoader({
75 '/manifest.json': pluginManifest(
76 ['pear.content.styles', 'pear.net.filter'],
77 {
78 styles: { matches: ['*'], path: '/style.css' },
79 filters: '/filters.txt'
80 }
81 ),
82 '/style.css': '.fixture-hide { display: none }',
83 '/filters.txt': '||fixture-ads.example.com^'
84 })
85
86 const result = await loader.installFromDrive(KEY, { grantedCapabilities: ['pear.content.styles'] })
87 assert.deepEqual(result.granted, ['pear.content.styles'])
88
89 // Styles applied, network filter NOT applied (capability was not granted).
90 assert.ok(shield.pluginStylesFor('x.example').includes('.fixture-hide'))
91 assert.equal(shield.shouldBlockUrl('https://fixture-ads.example.com/x.js').blocked, false)
92})
93
94test('an update that escalates capabilities disables the plugin pending re-consent', async () => {
95 const { loader, registry, store } = makeLoader({
96 '/manifest.json': pluginManifest(['pear.content.styles'], {
97 styles: { matches: ['*'], path: '/style.css' }
98 }),
99 '/style.css': '.fixture-hide { display: none }'
100 })
101 await loader.installFromDrive(KEY)
102
103 // The drive updates itself over the swarm: same plugin, new powers.
104 store['/manifest.json'] = pluginManifest(
105 ['pear.content.styles', 'pear.content.scripts'],
106 {
107 styles: { matches: ['*'], path: '/style.css' },
108 scripts: { matches: ['*'], path: '/content.js' }
109 },
110 '2.0.0'
111 )
112 store['/content.js'] = 'window.__sneaky = 1'
113
114 const outcome = await loader.updateFromDrive(KEY)
115 assert.equal(outcome.ok, false)
116 assert.equal(outcome.escalated, true)
117 assert.deepEqual(outcome.added, ['pear.content.scripts'])
118
119 const listed = registry.list().find(item => item.id === KEY)
120 assert.equal(listed.enabled, false)
121 assert.equal(loader.installRecord(KEY).escalated.added[0], 'pear.content.scripts')
122
123 // Explicit re-consent accepts the escalation and re-enables.
124 const accepted = await loader.updateFromDrive(KEY, { acceptEscalation: true })
125 assert.equal(accepted.ok, true)
126 assert.equal(accepted.escalationAccepted, true)
127 assert.deepEqual(accepted.granted, ['pear.content.styles', 'pear.content.scripts'])
128 assert.equal(registry.list().find(item => item.id === KEY).enabled, true)
129})
130
131test('a same-capability update hot-swaps without consent friction', async () => {
132 const { loader, shield, store } = makeLoader({
133 '/manifest.json': pluginManifest(['pear.content.styles'], {
134 styles: { matches: ['*'], path: '/style.css' }
135 }),
136 '/style.css': '.v1 { display: none }'
137 })
138 await loader.installFromDrive(KEY)
139
140 store['/manifest.json'] = pluginManifest(['pear.content.styles'], {
141 styles: { matches: ['*'], path: '/style.css' }
142 }, '1.1.0')
143 store['/style.css'] = '.v2 { display: none }'
144
145 const outcome = await loader.updateFromDrive(KEY)
146 assert.equal(outcome.ok, true)
147 assert.equal(outcome.version, '1.1.0')
148 assert.ok(shield.pluginStylesFor('x.example').includes('.v2'))
149})
150
151test('missing manifests, invalid assets, and oversized assets fail closed', async () => {
152 const none = makeLoader({})
153 await assert.rejects(none.loader.installFromDrive(KEY), err => err.code === 'manifest-unavailable')
154
155 const notPlugin = makeLoader({ '/manifest.json': JSON.stringify({ name: 'app' }) })
156 await assert.rejects(notPlugin.loader.installFromDrive(KEY), err => err.code === 'not-a-plugin')
157
158 const missingAsset = makeLoader({
159 '/manifest.json': pluginManifest(['pear.content.styles'], {
160 styles: { matches: ['*'], path: '/style.css' }
161 })
162 })
163 await assert.rejects(missingAsset.loader.installFromDrive(KEY), err => err.code === 'asset-unavailable')
164
165 const traversal = makeLoader({
166 '/manifest.json': pluginManifest(['pear.content.styles'], {
167 styles: { matches: ['*'], path: '/../escape.css' }
168 })
169 })
170 await assert.rejects(traversal.loader.installFromDrive(KEY), err => err.code === 'asset-path-invalid')
171
172 const oversized = makeLoader({
173 '/manifest.json': pluginManifest(['pear.content.styles'], {
174 styles: { matches: ['*'], path: '/style.css' }
175 }),
176 '/style.css': 'x'.repeat(MAX_ASSET_BYTES + 1)
177 })
178 await assert.rejects(oversized.loader.installFromDrive(KEY), err => err.code === 'asset-too-large')
179
180 assert.ok(new PluginDriveError('x', 'y') instanceof Error)
181})
182
183test('uninstall removes the registration and the durable payload', async () => {
184 const { loader, registry, shield, persisted } = makeLoader({
185 '/manifest.json': pluginManifest(['pear.content.styles', 'pear.net.filter'], {
186 styles: { matches: ['*'], path: '/style.css' },
187 filters: '/filters.txt'
188 }),
189 '/style.css': '.fixture-hide { display: none }',
190 '/filters.txt': '||fixture-ads.example.com^'
191 })
192 await loader.installFromDrive(KEY)
193 assert.equal(shield.shouldBlockUrl('https://fixture-ads.example.com/x.js').blocked, true)
194
195 const removed = await loader.uninstall(KEY)
196 assert.equal(removed.removed, true)
197 assert.equal(registry.list().length, 0)
198 assert.equal(persisted.has(KEY), false)
199 assert.equal(shield.shouldBlockUrl('https://fixture-ads.example.com/x.js').blocked, false)
200 assert.equal(shield.pluginStylesFor('x.example'), '')
201})
test/shield-distribution-examples.test.js +96 -0
……@@ -0,0 +1,96 @@
1/**
2 * The shipped example artifacts must actually work end-to-end through the
3 * real loader/sync classes: both example plugins install from their
4 * directories as if they were drives, and the built pear-default list
5 * passes checksum verification.
6 */
7import test from 'node:test'
8import assert from 'node:assert/strict'
9import { readFileSync, existsSync } from 'node:fs'
10import { createHash } from 'node:crypto'
11import { fileURLToPath } from 'node:url'
12import { dirname, join } from 'node:path'
13import { createRequire } from 'node:module'
14
15const require = createRequire(import.meta.url)
16const { ContentShield } = require('../backend/content-shield.cjs')
17const { PearPluginRegistry } = require('../backend/pear-plugins.cjs')
18const { PluginDriveLoader } = require('../backend/plugin-drive-loader.cjs')
19const { ShieldListSync } = require('../backend/shield-list-sync.cjs')
20
21const root = join(dirname(fileURLToPath(import.meta.url)), '..')
22const PEERIT_KEY = 'ec6e2d6d9d22b9d6b40e11a9ca3042be3197e4bdca9e9a7f079be6ee830761b4'
23
24function driveFromDirectory (dir) {
25 return async (driveKey, path) => {
26 const file = join(root, dir, path.slice(1))
27 if (!existsSync(file)) return null
28 return { content: readFileSync(file) }
29 }
30}
31
32function makeHarness (dir) {
33 const shield = new ContentShield({ builtinList: false })
34 const registry = new PearPluginRegistry({ shield })
35 const loader = new PluginDriveLoader({
36 registry,
37 fetchDriveFile: driveFromDirectory(dir),
38 persistInstall: async () => {}
39 })
40 return { shield, registry, loader }
41}
42
43test('dark-reader example installs and styles every host', async () => {
44 const key = 'a'.repeat(64)
45 const { shield, loader } = makeHarness('examples/plugins/dark-reader')
46
47 const result = await loader.installFromDrive(key)
48 assert.equal(result.ok, true)
49 assert.deepEqual(result.granted, ['pear.content.styles'])
50
51 const css = shield.pluginStylesFor('anything.example')
52 assert.ok(css.includes('color-scheme: dark'))
53 // Styles only: no scripts, no filter contributions.
54 assert.deepEqual(shield.pluginScriptsFor('anything.example'), [])
55})
56
57test('peerit-enhancer example installs scoped to the peerit drive', async () => {
58 const key = 'b'.repeat(64)
59 const { shield, loader } = makeHarness('examples/plugins/peerit-enhancer')
60
61 const result = await loader.installFromDrive(key)
62 assert.equal(result.ok, true)
63 assert.deepEqual(result.granted, ['pear.content.styles', 'pear.content.scripts', 'pear.net.filter'])
64
65 // Scoped: applies on the peerit drive host, not elsewhere.
66 assert.ok(shield.pluginStylesFor(PEERIT_KEY).includes('reading-width') || shield.pluginStylesFor(PEERIT_KEY).includes('max-width'))
67 assert.equal(shield.pluginStylesFor('c'.repeat(64)), '')
68 assert.ok(shield.pluginScriptsFor(PEERIT_KEY).some(item => item.body.includes('__peeritEnhancer')))
69 assert.deepEqual(shield.pluginScriptsFor('c'.repeat(64)), [])
70
71 // Filter contribution parsed into the plugin's namespaced list.
72 assert.ok(shield.cosmeticCssFor(PEERIT_KEY).includes('.promoted-post'))
73})
74
75test('pear-default list builds, verifies, and subscribes through the real sync', async () => {
76 const manifest = JSON.parse(readFileSync(join(root, 'filter-lists/pear-default/manifest.json'), 'utf8'))
77 const filters = readFileSync(join(root, 'filter-lists/pear-default/filters.txt'))
78 assert.equal(createHash('sha256').update(filters).digest('hex'), manifest.sha256)
79
80 const key = 'd'.repeat(64)
81 const shield = new ContentShield({ builtinList: false })
82 const sync = new ShieldListSync({
83 shield,
84 fetchDriveFile: driveFromDirectory('filter-lists/pear-default'),
85 sha256Hex: (buf) => createHash('sha256').update(buf).digest('hex'),
86 persistMeta: async () => {}
87 })
88
89 const result = await sync.subscribe(key)
90 assert.equal(result.changed, true)
91 assert.equal(result.name, 'pear-default')
92 assert.ok(result.rules >= 60)
93 assert.equal(shield.shouldBlockUrl('https://stats.doubleclick.net/pixel').blocked, true)
94 assert.equal(shield.shouldBlockUrl('https://maps.googleapis.com/maps/api/js').blocked, false)
95 assert.ok(shield.cosmeticCssFor('any.example').includes('.sponsored-content'))
96})
test/shield-distribution-wiring.test.js +94 -0
……@@ -0,0 +1,94 @@
1/**
2 * Structural / wiring audit for P2P shield distribution (Phase 2/3 gates):
3 * filter-list drives and plugin drives. Asserts shipped source surfaces.
4 */
5import test from 'node:test'
6import assert from 'node:assert/strict'
7import { readFileSync } from 'node:fs'
8import { fileURLToPath } from 'node:url'
9import { dirname, join } from 'node:path'
10
11const root = join(dirname(fileURLToPath(import.meta.url)), '..')
12const shell = readFileSync(join(root, 'ui/shell.js'), 'utf8')
13const boot = readFileSync(join(root, 'ui/boot.js'), 'utf8')
14const index = readFileSync(join(root, 'backend/index.js'), 'utf8')
15const constants = readFileSync(join(root, 'backend/constants.js'), 'utf8')
16
17test('boot.js and constants.js share the distribution command ids; index handles them', () => {
18 for (const [name, id] of [
19 ['CMD_SHIELD_SUBSCRIBE_LIST', 239],
20 ['CMD_SHIELD_UNSUBSCRIBE_LIST', 240],
21 ['CMD_SHIELD_REFRESH_LISTS', 241],
22 ['CMD_PLUGIN_INSTALL_DRIVE', 242],
23 ['CMD_PLUGIN_UPDATE_DRIVE', 243],
24 ['CMD_PLUGIN_UNINSTALL', 244]
25 ]) {
26 assert.match(constants, new RegExp(`const ${name} = ${id}`))
27 assert.match(constants, new RegExp(`${name},`))
28 assert.match(boot, new RegExp(`${name}: ${id}`))
29 assert.match(index, new RegExp(`rpc\\.handle\\(C\\.${name}`))
30 }
31})
32
33test('backend boots the sync + loader against the hybrid drive fetch path', () => {
34 assert.match(index, /require\('\.\/shield-list-sync\.cjs'\)/)
35 assert.match(index, /require\('\.\/plugin-drive-loader\.cjs'\)/)
36 assert.match(index, /new ShieldListSync\(/)
37 assert.match(index, /new PluginDriveLoader\(/)
38 assert.match(index, /proxy\._hybridFetch\(keyHex, path\)/)
39 assert.match(index, /contentShieldListSync/)
40 assert.match(index, /contentShieldPluginInstalls/)
41 assert.match(index, /startAutoRefresh\(/)
42 assert.match(index, /shieldListSync\?\.stop\(\)/)
43})
44
45test('settings UI exposes subscribe/install/update/uninstall controls', () => {
46 assert.match(shell, /data-testid="content-shield-list-sync"/)
47 assert.match(shell, /data-testid="content-shield-subscribe-input"/)
48 assert.match(shell, /data-testid="content-shield-subscribe"/)
49 assert.match(shell, /data-testid="plugin-install-input"/)
50 assert.match(shell, /data-testid="plugin-install"/)
51 assert.match(shell, /data-testid="plugin-escalation"/)
52 assert.match(shell, /CMD_SHIELD_SUBSCRIBE_LIST/)
53 assert.match(shell, /CMD_SHIELD_REFRESH_LISTS/)
54 assert.match(shell, /CMD_PLUGIN_INSTALL_DRIVE/)
55 assert.match(shell, /CMD_PLUGIN_UPDATE_DRIVE/)
56 assert.match(shell, /CMD_PLUGIN_UNINSTALL/)
57})
58
59test('plugin catalogue command ids are mirrored and handled', () => {
60 for (const [name, id] of [
61 ['CMD_PLUGIN_CATALOG', 245],
62 ['CMD_PLUGIN_CATALOG_LOAD_DRIVE', 246],
63 ['CMD_PLUGIN_CATALOG_REMOVE_SOURCE', 247]
64 ]) {
65 assert.match(constants, new RegExp(`const ${name} = ${id}`))
66 assert.match(constants, new RegExp(`${name},`))
67 assert.match(boot, new RegExp(`${name}: ${id}`))
68 assert.match(index, new RegExp(`rpc\\.handle\\(C\\.${name}`))
69 }
70 assert.match(index, /require\('\.\/plugin-catalog\.cjs'\)/)
71 assert.match(index, /new PluginCatalog\(/)
72 assert.match(index, /contentShieldPluginCatalog/)
73})
74
75test('catalogue UI lists entries with install/open actions and a source loader', () => {
76 assert.match(shell, /data-testid="plugin-catalog"/)
77 assert.match(shell, /data-testid=\$\{'catalog-install-' \+ entry\.id\}/)
78 assert.match(shell, /data-testid=\$\{'catalog-open-' \+ entry\.id\}/)
79 assert.match(shell, /data-testid="plugin-catalog-source-input"/)
80 assert.match(shell, /data-testid="plugin-catalog-load"/)
81 assert.match(shell, /CMD_PLUGIN_CATALOG/)
82 assert.match(shell, /CMD_PLUGIN_CATALOG_LOAD_DRIVE/)
83 assert.match(shell, /CMD_PLUGIN_CATALOG_REMOVE_SOURCE/)
84 // The anonGPT app entry opens through the App-level browse navigation.
85 assert.match(shell, /onBrowse\(`hyper:\/\/\$\{entry\.driveKey\}\/`\)/)
86 assert.match(shell, /onBrowse=\$\{launchInBrowse\}/)
87})
88
89test('escalation guard is fail-closed in the loader source', () => {
90 const loader = readFileSync(join(root, 'backend/plugin-drive-loader.cjs'), 'utf8')
91 assert.match(loader, /setEnabled\(key, false\)/)
92 assert.match(loader, /acceptEscalation/)
93 assert.match(loader, /escalated: true/)
94})
test/shield-list-sync.test.js +180 -0
……@@ -0,0 +1,180 @@
1import test from 'node:test'
2import assert from 'node:assert/strict'
3import { createHash } from 'node:crypto'
4import { createRequire } from 'node:module'
5
6const require = createRequire(import.meta.url)
7const { ContentShield } = require('../backend/content-shield.cjs')
8const { ShieldListSync, ShieldListSyncError, DRIVE_LIST_PREFIX, MAX_LIST_BYTES } = require('../backend/shield-list-sync.cjs')
9
10const KEY = 'c'.repeat(64)
11const sha256Hex = (buf) => createHash('sha256').update(buf).digest('hex')
12
13function fakeDrive (files) {
14 const calls = []
15 return {
16 calls,
17 files,
18 fetch: async (driveKey, path) => {
19 calls.push(`${driveKey.slice(0, 4)}:${path}`)
20 const value = files[path]
21 return value == null ? null : { content: Buffer.from(value) }
22 }
23 }
24}
25
26function makeSync ({ files, shield } = {}) {
27 const persisted = []
28 const drive = fakeDrive(files || {})
29 const engine = shield || new ContentShield({ builtinList: false })
30 const sync = new ShieldListSync({
31 shield: engine,
32 fetchDriveFile: drive.fetch,
33 sha256Hex,
34 persistMeta: async (meta) => { persisted.push(structuredClone(meta)) },
35 now: () => 1234
36 })
37 return { sync, shield: engine, drive, persisted }
38}
39
40test('subscribe fetches, verifies, registers, and persists a drive list', async () => {
41 const filters = '||ads.example.com^\n##.promo\n'
42 const { sync, shield, persisted } = makeSync({
43 files: {
44 '/manifest.json': JSON.stringify({ name: 'pear-default', version: '3', sha256: sha256Hex(filters) }),
45 '/filters.txt': filters
46 }
47 })
48
49 const result = await sync.subscribe(KEY)
50 assert.equal(result.changed, true)
51 assert.equal(result.name, 'pear-default')
52 assert.equal(result.version, '3')
53 assert.ok(result.rules >= 2)
54
55 assert.equal(shield.shouldBlockUrl('https://ads.example.com/x.js').blocked, true)
56 assert.ok(shield.stats().lists.includes(DRIVE_LIST_PREFIX + KEY))
57 assert.equal(persisted.length, 1)
58 assert.equal(persisted[0][KEY].version, '3')
59 assert.deepEqual(sync.subscriptions().map(item => item.driveKey), [KEY])
60})
61
62test('a checksum mismatch fails closed: nothing registered', async () => {
63 const { sync, shield } = makeSync({
64 files: {
65 '/manifest.json': JSON.stringify({ version: '1', sha256: 'f'.repeat(64) }),
66 '/filters.txt': '||ads.example.com^'
67 }
68 })
69
70 await assert.rejects(sync.subscribe(KEY), (err) => {
71 assert.ok(err instanceof ShieldListSyncError)
72 assert.equal(err.code, 'checksum-mismatch')
73 return true
74 })
75 assert.equal(shield.shouldBlockUrl('https://ads.example.com/x.js').blocked, false)
76 assert.equal(sync.subscriptions().length, 0)
77})
78
79test('a missing or oversized list fails closed', async () => {
80 const { sync } = makeSync({ files: {} })
81 await assert.rejects(sync.subscribe(KEY), err => err.code === 'list-unavailable')
82
83 const big = makeSync({ files: { '/filters.txt': 'a'.repeat(MAX_LIST_BYTES + 1) } })
84 await assert.rejects(big.sync.subscribe(KEY), err => err.code === 'list-too-large')
85})
86
87test('refresh skips the download when version and checksum are unchanged, hot-swaps on change', async () => {
88 const v1 = '||ads.example.com^'
89 const files = {
90 '/manifest.json': JSON.stringify({ version: '1', sha256: sha256Hex(v1) }),
91 '/filters.txt': v1
92 }
93 const { sync, shield, drive } = makeSync({ files })
94 await sync.subscribe(KEY)
95
96 const callsAfterSubscribe = drive.calls.length
97 const unchanged = await sync.refresh(KEY)
98 assert.equal(unchanged.changed, false)
99 // Only the manifest was consulted — no rules re-download on same version.
100 assert.deepEqual(drive.calls.slice(callsAfterSubscribe), [`${KEY.slice(0, 4)}:/manifest.json`])
101
102 const v2 = '||ads.example.com^\n||tracker.example.net^'
103 files['/manifest.json'] = JSON.stringify({ version: '2', sha256: sha256Hex(v2) })
104 files['/filters.txt'] = v2
105 const changed = await sync.refresh(KEY)
106 assert.equal(changed.changed, true)
107 assert.equal(changed.version, '2')
108 assert.equal(shield.shouldBlockUrl('https://tracker.example.net/p.gif').blocked, true)
109})
110
111test('restore + shield state give offline boot without any fetch', async () => {
112 // First session: subscribe and capture durable state.
113 const filters = '||ads.example.com^'
114 const first = makeSync({
115 files: {
116 '/manifest.json': JSON.stringify({ name: 'pear-default', version: '5', sha256: sha256Hex(filters) }),
117 '/filters.txt': filters
118 }
119 })
120 await first.sync.subscribe(KEY)
121 const durableShieldState = first.shield.exportListState()
122 const durableMeta = first.persisted.at(-1)
123
124 // Second session: no network at all — restore from durable state only.
125 const offlineShield = new ContentShield({ builtinList: false })
126 offlineShield.importListState(durableShieldState)
127 const offline = makeSync({ files: {}, shield: offlineShield })
128 const restored = offline.sync.restore(durableMeta)
129
130 assert.equal(restored, 1)
131 assert.equal(offline.sync.isSubscribed(KEY), true)
132 assert.equal(offline.sync.subscriptions()[0].version, '5')
133 assert.equal(offlineShield.shouldBlockUrl('https://ads.example.com/x.js').blocked, true)
134 assert.deepEqual(offline.drive.calls, [])
135})
136
137test('unsubscribe removes the rules and the durable metadata', async () => {
138 const filters = '||ads.example.com^'
139 const { sync, shield, persisted } = makeSync({
140 files: { '/filters.txt': filters }
141 })
142 await sync.subscribe(KEY)
143 assert.equal(shield.shouldBlockUrl('https://ads.example.com/x.js').blocked, true)
144
145 const removed = await sync.unsubscribe(KEY)
146 assert.equal(removed.removed, true)
147 assert.equal(shield.shouldBlockUrl('https://ads.example.com/x.js').blocked, false)
148 assert.deepEqual(persisted.at(-1), {})
149 assert.equal(sync.subscriptions().length, 0)
150})
151
152test('refreshAll isolates per-drive failures', async () => {
153 const goodKey = 'd'.repeat(64)
154 const filters = '||ads.example.com^'
155 const files = { '/filters.txt': filters }
156 const { sync } = makeSync({ files })
157 await sync.subscribe(goodKey)
158
159 // Subscribe a second drive, then break its content for the next sweep.
160 const flakyKey = 'e'.repeat(64)
161 files['/filters.txt'] = filters
162 await sync.subscribe(flakyKey)
163 const originalFetch = sync._fetch
164 sync._fetch = async (key, path) => {
165 if (key === flakyKey) throw new Error('drive offline')
166 return originalFetch(key, path)
167 }
168
169 const outcomes = await sync.refreshAll({ force: true })
170 const byKey = Object.fromEntries(outcomes.map(item => [item.driveKey, item]))
171 assert.equal(byKey[goodKey].ok, true)
172 assert.equal(byKey[flakyKey].ok, false)
173})
174
175test('invalid keys and duplicate list names are rejected', async () => {
176 const { sync } = makeSync({ files: {} })
177 await assert.rejects(sync.subscribe('not-a-key'), err => err.code === 'invalid-drive-key')
178 await assert.rejects(sync.refresh('a'.repeat(64)), err => err.code === 'not-subscribed')
179 assert.equal((await sync.unsubscribe('b'.repeat(64))).removed, false)
180})
ui/boot.js +9 -0
……@@ -154,6 +154,15 @@ const C = {
154154 CMD_PLUGIN_LIST: 235,
155155 CMD_PLUGIN_SET_ENABLED: 236,
156156 CMD_PLUGIN_REGISTER: 237,
157 CMD_SHIELD_SUBSCRIBE_LIST: 239,
158 CMD_SHIELD_UNSUBSCRIBE_LIST: 240,
159 CMD_SHIELD_REFRESH_LISTS: 241,
160 CMD_PLUGIN_INSTALL_DRIVE: 242,
161 CMD_PLUGIN_UPDATE_DRIVE: 243,
162 CMD_PLUGIN_UNINSTALL: 244,
163 CMD_PLUGIN_CATALOG: 245,
164 CMD_PLUGIN_CATALOG_LOAD_DRIVE: 246,
165 CMD_PLUGIN_CATALOG_REMOVE_SOURCE: 247,
157166 CMD_PRIVACY_STATUS: 238,
158167 CMD_BRIDGE: 200,
159168 EVT_READY: 100,
ui/shell.js +230 -21
……@@ -1827,9 +1827,9 @@ function Browse ({ rpc, C, navUrl, onNavigated, tabs, setTabs, activeId, setActi
18271827 </div>`
18281828 : html`<div className="browse-welcome-copy">
18291829 <h2>Loading…</h2>
1830 <p>${t.kind === 'clearnet'
1831 ? html`Fetching <code>${t.url}</code> over the clearnet proxy — shields and the privacy ladder apply.`
1832 : html`Fetching <code>${t.url}</code> directly from its peers — first load of a cold drive can take a moment.`}</p>
1830 <p>Fetching <code>${t.url}</code> ${t.kind === 'clearnet'
1831 ? 'over the clearnet proxy — shields and the privacy ladder apply.'
1832 : 'directly from its peers — first load of a cold drive can take a moment.'}</p>
18331833 </div>`}
18341834 <div className="browse-welcome-actions">
18351835 <button className="btn primary" onClick=${() => go(t.url, t.id)}>${(t.status && /^error/i.test(t.status)) ? 'Retry' : 'Reload'}</button>
……@@ -5026,12 +5026,17 @@ function DeviceSync ({ rpc, C }) {
50265026// (docs/BROWSER_PARITY_PLAN.md Phases 1–3). Toggle, per-drive allowlist/
50275027// strict, named lists, and plugin kill-switches persist in user-data;
50285028// CMD_SHIELD_* / CMD_PLUGIN_* feed this panel and the urlbar chip.
5029function ContentShieldSection ({ rpc, C, activeDriveKey = '' }) {
5029function ContentShieldSection ({ rpc, C, activeDriveKey = '', onBrowse }) {
50305030 const [enabled, setEnabled] = useState(true)
50315031 const [status, setStatus] = useState(null)
50325032 const [plugins, setPlugins] = useState([])
50335033 const [busy, setBusy] = useState(false)
50345034 const [err, setErr] = useState('')
5035 const [subscribeKey, setSubscribeKey] = useState('')
5036 const [installKey, setInstallKey] = useState('')
5037 const [escalation, setEscalation] = useState(null) // { driveKey, added: [] }
5038 const [catalog, setCatalog] = useState({ entries: [], sources: [] })
5039 const [catalogSourceKey, setCatalogSourceKey] = useState('')
50355040 const driveKey = typeof activeDriveKey === 'string' && /^[0-9a-f]{64}$/i.test(activeDriveKey)
50365041 ? activeDriveKey.toLowerCase()
50375042 : ''
……@@ -5055,6 +5060,11 @@ function ContentShieldSection ({ rpc, C, activeDriveKey = '' }) {
50555060 .then((result) => { if (!disposed) setPlugins(result?.plugins || []) })
50565061 .catch(() => {})
50575062 }
5063 if (C.CMD_PLUGIN_CATALOG != null) {
5064 rpc.request(C.CMD_PLUGIN_CATALOG)
5065 .then((result) => { if (!disposed && result) setCatalog({ entries: result.entries || [], sources: result.sources || [] }) })
5066 .catch(() => {})
5067 }
50585068 }
50595069 refresh()
50605070 const timer = setInterval(refresh, 5000)
……@@ -5108,6 +5118,109 @@ function ContentShieldSection ({ rpc, C, activeDriveKey = '' }) {
51085118 finally { setBusy(false) }
51095119 }
51105120
5121 const refreshStatusAndPlugins = async () => {
5122 const result = await rpc.request(C.CMD_SHIELD_STATUS, driveKey ? { driveKey } : {}).catch(() => null)
5123 if (result) setStatus(result)
5124 const listed = await rpc.request(C.CMD_PLUGIN_LIST).catch(() => null)
5125 if (listed) setPlugins(listed.plugins || [])
5126 if (C.CMD_PLUGIN_CATALOG != null) {
5127 const listing = await rpc.request(C.CMD_PLUGIN_CATALOG).catch(() => null)
5128 if (listing) setCatalog({ entries: listing.entries || [], sources: listing.sources || [] })
5129 }
5130 }
5131
5132 const subscribeList = async () => {
5133 const key = subscribeKey.trim().toLowerCase()
5134 if (!/^[0-9a-f]{64}$/.test(key) || C.CMD_SHIELD_SUBSCRIBE_LIST == null) return
5135 setBusy(true); setErr('')
5136 try {
5137 await rpc.request(C.CMD_SHIELD_SUBSCRIBE_LIST, { driveKey: key }, 30000)
5138 setSubscribeKey('')
5139 await refreshStatusAndPlugins()
5140 } catch (e) { setErr(`subscribe: ${e.message}`) }
5141 finally { setBusy(false) }
5142 }
5143
5144 const unsubscribeList = async (key) => {
5145 if (C.CMD_SHIELD_UNSUBSCRIBE_LIST == null) return
5146 setBusy(true); setErr('')
5147 try {
5148 await rpc.request(C.CMD_SHIELD_UNSUBSCRIBE_LIST, { driveKey: key })
5149 await refreshStatusAndPlugins()
5150 } catch (e) { setErr(`unsubscribe: ${e.message}`) }
5151 finally { setBusy(false) }
5152 }
5153
5154 const refreshLists = async (key) => {
5155 if (C.CMD_SHIELD_REFRESH_LISTS == null) return
5156 setBusy(true); setErr('')
5157 try {
5158 await rpc.request(C.CMD_SHIELD_REFRESH_LISTS, key ? { driveKey: key, force: true } : {}, 30000)
5159 await refreshStatusAndPlugins()
5160 } catch (e) { setErr(`refresh: ${e.message}`) }
5161 finally { setBusy(false) }
5162 }
5163
5164 const installPluginByKey = async (key) => {
5165 const normalized = String(key || '').trim().toLowerCase()
5166 if (!/^[0-9a-f]{64}$/.test(normalized) || C.CMD_PLUGIN_INSTALL_DRIVE == null) return
5167 setBusy(true); setErr('')
5168 try {
5169 await rpc.request(C.CMD_PLUGIN_INSTALL_DRIVE, { driveKey: normalized }, 30000)
5170 await refreshStatusAndPlugins()
5171 } catch (e) { setErr(`install: ${e.message}`) }
5172 finally { setBusy(false) }
5173 }
5174
5175 const installPlugin = async () => {
5176 await installPluginByKey(installKey)
5177 setInstallKey('')
5178 }
5179
5180 const loadCatalogSource = async () => {
5181 const key = catalogSourceKey.trim().toLowerCase()
5182 if (!/^[0-9a-f]{64}$/.test(key) || C.CMD_PLUGIN_CATALOG_LOAD_DRIVE == null) return
5183 setBusy(true); setErr('')
5184 try {
5185 await rpc.request(C.CMD_PLUGIN_CATALOG_LOAD_DRIVE, { driveKey: key }, 30000)
5186 setCatalogSourceKey('')
5187 await refreshStatusAndPlugins()
5188 } catch (e) { setErr(`catalog: ${e.message}`) }
5189 finally { setBusy(false) }
5190 }
5191
5192 const removeCatalogSource = async (key) => {
5193 if (C.CMD_PLUGIN_CATALOG_REMOVE_SOURCE == null) return
5194 setBusy(true); setErr('')
5195 try {
5196 await rpc.request(C.CMD_PLUGIN_CATALOG_REMOVE_SOURCE, { driveKey: key })
5197 await refreshStatusAndPlugins()
5198 } catch (e) { setErr(`catalog: ${e.message}`) }
5199 finally { setBusy(false) }
5200 }
5201
5202 const updatePlugin = async (id, acceptEscalation = false) => {
5203 if (C.CMD_PLUGIN_UPDATE_DRIVE == null) return
5204 setBusy(true); setErr('')
5205 try {
5206 const outcome = await rpc.request(C.CMD_PLUGIN_UPDATE_DRIVE, { driveKey: id, acceptEscalation }, 30000)
5207 setEscalation(outcome && outcome.escalated ? { driveKey: id, added: outcome.added || [] } : null)
5208 await refreshStatusAndPlugins()
5209 } catch (e) { setErr(`update: ${e.message}`) }
5210 finally { setBusy(false) }
5211 }
5212
5213 const uninstallPlugin = async (id) => {
5214 if (C.CMD_PLUGIN_UNINSTALL == null) return
5215 setBusy(true); setErr('')
5216 try {
5217 await rpc.request(C.CMD_PLUGIN_UNINSTALL, { driveKey: id })
5218 if (escalation?.driveKey === id) setEscalation(null)
5219 await refreshStatusAndPlugins()
5220 } catch (e) { setErr(`uninstall: ${e.message}`) }
5221 finally { setBusy(false) }
5222 }
5223
51115224 const listNames = (status && (status.listDetails || status.lists)) || []
51125225 const listLabel = Array.isArray(listNames)
51135226 ? listNames.map((l) => (typeof l === 'string' ? l : l.name)).join(', ')
……@@ -5157,26 +5270,122 @@ function ContentShieldSection ({ rpc, C, activeDriveKey = '' }) {
51575270 ${status && Array.isArray(status.topRules) && status.topRules.length > 0 && html`
51585271 <div className="settings-subtle">Top rules: ${status.topRules.slice(0, 3).map(item => `${item.rule} (${item.hits})`).join(' · ')}</div>
51595272 `}
5160 ${plugins.length > 0 && html`
5161 <div className="settings-row" data-testid="content-shield-plugins">
5162 <div style=${{ width: '100%' }}>
5163 <div className="settings-label">Pear Plugins</div>
5164 <div className="settings-subtle">Kill-switch disables a plugin's filter/style/script contributions without uninstalling it.</div>
5165 ${plugins.map((p) => html`
5166 <div className="settings-row" key=${p.id} data-testid=${'plugin-row-' + p.id}>
5167 <div>
5168 <div className="settings-label">${p.name || p.id}</div>
5169 <div className="settings-subtle">${(p.capabilities || []).join(', ') || 'no capabilities'}</div>
5170 </div>
5273
5274 <div className="settings-row" data-testid="content-shield-list-sync">
5275 <div style=${{ width: '100%' }}>
5276 <div className="settings-label">Filter lists from the swarm</div>
5277 <div className="settings-subtle">Subscribe to a filter-list Hyperdrive by key. Rules sync peer-to-peer, hot-swap when the publisher updates, and keep working offline — no CDN, no list-fetch fingerprint.</div>
5278 <div className="settings-row">
5279 <div className="profile-field" style=${{ flex: 1 }}>
5280 <input className="profile-input" placeholder="64-hex filter-list drive key" value=${subscribeKey}
5281 data-testid="content-shield-subscribe-input"
5282 onInput=${(e) => setSubscribeKey(e.target.value)}
5283 onKeyDown=${(e) => e.key === 'Enter' && subscribeList()} />
5284 </div>
5285 <button className="btn" data-testid="content-shield-subscribe" onClick=${subscribeList}
5286 disabled=${busy || !/^[0-9a-f]{64}$/i.test(subscribeKey.trim())}>Subscribe</button>
5287 <button className="btn subtle" onClick=${() => refreshLists()} disabled=${busy || !(status?.subscriptions?.length)}>Refresh all</button>
5288 </div>
5289 ${(status?.subscriptions || []).map((sub) => html`
5290 <div className="settings-row" key=${sub.driveKey} data-testid=${'shield-list-row-' + sub.driveKey}>
5291 <div>
5292 <div className="settings-label">${sub.name || sub.driveKey.slice(0, 12) + '…'}${sub.version ? ` · v${sub.version}` : ''}</div>
5293 <div className="settings-subtle">${sub.rules || 0} rules · ${sub.driveKey.slice(0, 16)}…</div>
5294 </div>
5295 <div className="settings-inline-actions">
5296 <button className="btn small subtle" onClick=${() => refreshLists(sub.driveKey)} disabled=${busy}>Refresh</button>
5297 <button className="btn small subtle danger" onClick=${() => unsubscribeList(sub.driveKey)} disabled=${busy}>Remove</button>
5298 </div>
5299 </div>
5300 `)}
5301 </div>
5302 </div>
5303
5304 <div className="settings-row" data-testid="plugin-catalog">
5305 <div style=${{ width: '100%' }}>
5306 <div className="settings-label">Plugin catalog</div>
5307 <div className="settings-subtle">Curated plugins and AI add-ons you can add yourself. Installing a plugin shows its declared capabilities and records your grant; app entries open as ordinary P2P apps gated by their own manifests. Load more catalogues from a drive key below.</div>
5308 ${catalog.entries.map((entry) => html`
5309 <div className="settings-row" key=${entry.id} data-testid=${'catalog-entry-' + entry.id}>
5310 <div>
5311 <div className="settings-label">${entry.name}${entry.verified ? html`<span title="Curated entry" style=${{ marginLeft: '5px', color: '#3fb950', fontSize: '12px' }}>✦</span>` : ''}</div>
5312 <div className="settings-subtle">${entry.description}</div>
5313 <div className="settings-subtle">${entry.kind === 'app' ? 'P2P app' : 'plugin'}${entry.capabilities?.length ? ` · ${entry.capabilities.join(', ')}` : ''}${entry.source !== 'builtin' ? ` · from ${String(entry.source).slice(0, 8)}…` : ''}</div>
5314 </div>
5315 <div className="settings-inline-actions">
5316 ${entry.kind === 'app' && entry.driveKey && html`
5317 <button className="btn small" data-testid=${'catalog-open-' + entry.id}
5318 onClick=${() => onBrowse && onBrowse(`hyper://${entry.driveKey}/`)}
5319 disabled=${busy || !onBrowse}>Open</button>
5320 `}
5321 ${entry.kind === 'plugin' && entry.driveKey && !entry.installed && html`
5322 <button className="btn small" data-testid=${'catalog-install-' + entry.id}
5323 onClick=${() => installPluginByKey(entry.driveKey)} disabled=${busy}>Install</button>
5324 `}
5325 ${entry.kind === 'plugin' && entry.installed && html`<span className="settings-subtle">Installed</span>`}
5326 ${entry.kind === 'plugin' && !entry.driveKey && html`<span className="settings-subtle" title=${entry.unpublished ? `Publish ${entry.unpublished} to enable` : ''}>Publish pending</span>`}
5327 </div>
5328 </div>
5329 `)}
5330 <div className="settings-row">
5331 <div className="profile-field" style=${{ flex: 1 }}>
5332 <input className="profile-input" placeholder="64-hex catalogue drive key" value=${catalogSourceKey}
5333 data-testid="plugin-catalog-source-input"
5334 onInput=${(e) => setCatalogSourceKey(e.target.value)}
5335 onKeyDown=${(e) => e.key === 'Enter' && loadCatalogSource()} />
5336 </div>
5337 <button className="btn subtle" data-testid="plugin-catalog-load" onClick=${loadCatalogSource}
5338 disabled=${busy || !/^[0-9a-f]{64}$/i.test(catalogSourceKey.trim())}>Load catalogue</button>
5339 </div>
5340 ${catalog.sources.map((source) => html`
5341 <div className="settings-row" key=${source.driveKey}>
5342 <div className="settings-subtle">${source.name} · ${source.entryCount} entries · ${source.driveKey.slice(0, 16)}…</div>
5343 <button className="btn small subtle danger" onClick=${() => removeCatalogSource(source.driveKey)} disabled=${busy}>Remove</button>
5344 </div>
5345 `)}
5346 </div>
5347 </div>
5348
5349 <div className="settings-row" data-testid="content-shield-plugins">
5350 <div style=${{ width: '100%' }}>
5351 <div className="settings-label">Pear Plugins</div>
5352 <div className="settings-subtle">Plugins are Hyperdrives with declared capabilities. An update that requests new capabilities is disabled automatically until you re-approve it. Kill-switch disables a plugin's filter/style/script contributions without uninstalling it.</div>
5353 <div className="settings-row">
5354 <div className="profile-field" style=${{ flex: 1 }}>
5355 <input className="profile-input" placeholder="64-hex plugin drive key" value=${installKey}
5356 data-testid="plugin-install-input"
5357 onInput=${(e) => setInstallKey(e.target.value)}
5358 onKeyDown=${(e) => e.key === 'Enter' && installPlugin()} />
5359 </div>
5360 <button className="btn" data-testid="plugin-install" onClick=${installPlugin}
5361 disabled=${busy || !/^[0-9a-f]{64}$/i.test(installKey.trim())}>Install</button>
5362 </div>
5363 ${escalation && html`
5364 <div className="apps-error" data-testid="plugin-escalation">
5365 Update for ${escalation.driveKey.slice(0, 12)}… requests new capabilities: ${escalation.added.join(', ')}.
5366 <button className="btn small" onClick=${() => updatePlugin(escalation.driveKey, true)} disabled=${busy}>Accept and re-enable</button>
5367 </div>
5368 `}
5369 ${plugins.map((p) => html`
5370 <div className="settings-row" key=${p.id} data-testid=${'plugin-row-' + p.id}>
5371 <div>
5372 <div className="settings-label">${p.name || p.id}</div>
5373 <div className="settings-subtle">${(p.capabilities || []).join(', ') || 'no capabilities'}${p.version ? ` · v${p.version}` : ''}</div>
5374 </div>
5375 <div className="settings-inline-actions">
5376 ${/^[0-9a-f]{64}$/.test(p.id) && html`
5377 <button className="btn small subtle" data-testid=${'plugin-update-' + p.id} onClick=${() => updatePlugin(p.id)} disabled=${busy}>Update</button>
5378 <button className="btn small subtle danger" data-testid=${'plugin-uninstall-' + p.id} onClick=${() => uninstallPlugin(p.id)} disabled=${busy}>Uninstall</button>
5379 `}
51715380 <label className="login-scope${p.enabled ? ' on' : ''}">
51725381 <input type="checkbox" checked=${!!p.enabled} disabled=${busy}
51735382 onChange=${() => togglePlugin(p.id, p.enabled)} data-testid=${'plugin-enabled-' + p.id} />
51745383 </label>
51755384 </div>
5176 `)}
5177 </div>
5385 </div>
5386 `)}
51785387 </div>
5179 `}
5388 </div>
51805389 </div>
51815390 `
51825391}
……@@ -5421,7 +5630,7 @@ function ExperimentalSection ({ rpc, C, onAutobeeChange, onDeviceSyncChange }) {
54215630 `
54225631}
54235632
5424function Settings ({ rpc, C, status, storagePath, log, appearanceTheme, onAppearanceThemeChange, activeDriveKey = '' }) {
5633function Settings ({ rpc, C, status, storagePath, log, appearanceTheme, onAppearanceThemeChange, activeDriveKey = '', onBrowse }) {
54255634 const [identity, setIdentity] = useState(null)
54265635 const [seedPhrase, setSeedPhrase] = useState(null)
54275636 const [err, setErr] = useState('')
……@@ -5705,7 +5914,7 @@ function Settings ({ rpc, C, status, storagePath, log, appearanceTheme, onAppear
57055914
57065915 <h2>Content Shield</h2>
57075916 <p className="subtitle">Brave-style ad and tracker blocking, enforced inside the browser's own proxy — blocked requests never reach a peer, a relay, or the network. Named filter lists hot-swap offline; per-drive allowlist and strict mode live here; Pear Plugins feed the same engine with a kill switch.</p>
5708 <${ContentShieldSection} rpc=${rpc} C=${C} activeDriveKey=${activeDriveKey} />
5917 <${ContentShieldSection} rpc=${rpc} C=${C} activeDriveKey=${activeDriveKey} onBrowse=${onBrowse} />
57095918
57105919 <h2>Clearnet &amp; privacy</h2>
57115920 <p className="subtitle">Browse https:// sites through the browser-owned clearnet proxy (shields on) or direct load. Privacy ladder: HTTPS-only upgrades, tracking-parameter stripping, referrer policy, fingerprint farbling, third-party cookie isolation in proxy mode.</p>
……@@ -6431,7 +6640,7 @@ export function App ({ rpc, C, storagePath }) {
64316640 ${tab === 'apps' && html`<${Apps} rpc=${rpc} C=${C} onLaunch=${launchInBrowse} />`}
64326641 ${tab === 'sites' && html`<${Sites} rpc=${rpc} C=${C} onBrowse=${launchInBrowse} />`}
64336642 ${tab === 'library' && html`<${Library} rpc=${rpc} C=${C} onBrowse=${launchInBrowse} />`}
6434 ${tab === 'settings' && html`<${Settings} rpc=${rpc} C=${C} status=${status} storagePath=${storagePath} log=${log} appearanceTheme=${appearanceTheme} onAppearanceThemeChange=${setAndPersistAppearanceTheme} activeDriveKey=${(tabs.find((t) => t.id === browseActiveId) && tabDriveKey(tabs.find((t) => t.id === browseActiveId))) || ''} />`}
6643 ${tab === 'settings' && html`<${Settings} rpc=${rpc} C=${C} status=${status} storagePath=${storagePath} log=${log} appearanceTheme=${appearanceTheme} onAppearanceThemeChange=${setAndPersistAppearanceTheme} activeDriveKey=${(tabs.find((t) => t.id === browseActiveId) && tabDriveKey(tabs.find((t) => t.id === browseActiveId))) || ''} onBrowse=${launchInBrowse} />`}
64356644 </div>
64366645
64376646 <div className=${'status ' + statusClass}>